Files
bsky-social-app/scripts/bundleUpdate.js
T
2026-09-05 18:55:17 +03:00

131 lines
5.1 KiB
JavaScript
Executable File

#!/usr/bin/env node
/* oxlint-disable import/no-nodejs-modules -- This is a Node-only build script. */
const crypto = require('crypto')
const fs = require('fs')
const fsp = fs.promises
const path = require('path')
/** @param {string[]} argv */
function args(argv) {
/** @type {Record<string, string>} */
const out = {}
for (let i = 0; i < argv.length; i += 2) {
if (!argv[i]?.startsWith('--') || argv[i + 1] == null)
throw new Error(`Invalid argument: ${argv[i]}`)
out[argv[i].slice(2)] = argv[i + 1]
}
return out
}
async function digest(file) {
const hash = crypto.createHash('md5')
for await (const chunk of fs.createReadStream(file)) hash.update(chunk)
return hash.digest('hex')
}
async function childOf(parent, child) {
const relative = path.relative(
await fsp.realpath(parent),
await fsp.realpath(child),
)
return (
relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative)
)
}
/**
* @param {string} platform
* @param {string} sourceDir
* @param {string} destinationDir
*/
async function packagePlatform(platform, sourceDir, destinationDir) {
const metadataPath = path.join(sourceDir, 'metadata.json')
if (!(await childOf(sourceDir, metadataPath)))
throw new Error('Metadata escapes export directory')
/** @type {{fileMetadata?: Record<string, {bundle: string, assets?: {path: string, ext: string}[]}>}} */
const metadata = JSON.parse(await fsp.readFile(metadataPath, 'utf8'))
const input = metadata.fileMetadata?.[platform]
if (!input || typeof input.bundle !== 'string')
throw new Error(`${metadataPath} has no explicit ${platform} bundle`)
const outputDir = path.join(destinationDir, platform)
await fsp.mkdir(path.join(outputDir, 'bundles'), {recursive: true})
await fsp.mkdir(path.join(outputDir, 'assets'), {recursive: true})
const bundleSource = path.resolve(sourceDir, input.bundle)
if (!(await childOf(sourceDir, bundleSource)))
throw new Error(`Bundle escapes export directory: ${input.bundle}`)
const bundle = `bundles/${await digest(bundleSource)}.bundle`
await fsp.copyFile(bundleSource, path.join(outputDir, bundle))
const assets = []
for (const asset of input.assets ?? []) {
if (
typeof asset.path !== 'string' ||
typeof asset.ext !== 'string' ||
!/^[a-zA-Z0-9]+$/.test(asset.ext)
)
throw new Error(`Invalid ${platform} asset metadata`)
const source = path.resolve(sourceDir, asset.path)
if (!(await childOf(sourceDir, source)))
throw new Error(`Asset escapes export directory: ${asset.path}`)
const target = `assets/${await digest(source)}.${asset.ext}`
await fsp.copyFile(source, path.join(outputDir, target))
assets.push(target)
}
await fsp.writeFile(
path.join(outputDir, 'metadata.json'),
`${JSON.stringify({version: 0, bundler: 'metro', fileMetadata: {[platform]: {bundle, assets}}})}\n`,
)
const rollbackPath = path.join(sourceDir, 'rollback')
if (fs.existsSync(rollbackPath)) {
if (!(await childOf(sourceDir, rollbackPath)))
throw new Error('Rollback marker escapes export directory')
await fsp.copyFile(rollbackPath, path.join(outputDir, 'rollback'))
}
}
async function main() {
const options = args(process.argv.slice(2))
if (!options['release-file']) throw new Error('--release-file is required')
const releasePath = path.resolve(options['release-file'])
const base = path.dirname(releasePath)
/** @type {{schemaVersion: number, bundleVersion: string, platforms: Record<string, {bundleDirectory: string}>}} */
const release = JSON.parse(await fsp.readFile(releasePath, 'utf8'))
if (
release.schemaVersion !== 1 ||
!release.platforms ||
!/^[0-9]{13}$/.test(release.bundleVersion ?? '')
)
throw new Error('Invalid OTA export schema')
const outputDir = options['output-dir']
? path.resolve(options['output-dir'])
: await fsp.mkdtemp(path.join(base, 'ota-bundles-'))
const outputRelative = path.relative(base, outputDir)
if (
!outputRelative ||
outputRelative.startsWith('..') ||
path.isAbsolute(outputRelative)
)
throw new Error('Packaged output must be inside the release directory')
if (options['output-dir']) await fsp.mkdir(outputDir, {recursive: false})
for (const [platform, entry] of Object.entries(release.platforms)) {
if (
!['ios', 'android'].includes(platform) ||
typeof entry.bundleDirectory !== 'string'
)
throw new Error(`Invalid platform entry: ${platform}`)
const sourceDir = path.resolve(base, entry.bundleDirectory)
if (sourceDir !== base && !(await childOf(base, sourceDir)))
throw new Error('Export directory escapes release directory')
await packagePlatform(platform, sourceDir, outputDir)
entry.bundleDirectory = path.relative(base, path.join(outputDir, platform))
}
const packagedReleasePath = `${outputDir}.json`
await fsp.writeFile(
packagedReleasePath,
`${JSON.stringify(release, null, 2)}\n`,
{flag: 'wx'},
)
process.stdout.write(`${packagedReleasePath}\n`)
}
main().catch(error => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})