one
Version:
One is a React framework for web, iOS, and Android apps.
320 lines (305 loc) • 11.7 kB
text/typescript
import { execFileSync } from 'node:child_process'
import { createHash, randomUUID } from 'node:crypto'
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
realpathSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join, relative, resolve, sep } from 'node:path'
import { validateNativeApp } from '../native/appManifest'
import { loadUserOneOptions } from '../vite/loadConfig'
type PublishArgs = {
platform?: string | string[]
out?: string | string[]
metadata?: string | string[]
intermediatesOut?: string | string[]
runtimeVersion?: string | string[]
}
function lastValue(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[value.length - 1] : value
}
function allValues(value: string | string[] | undefined): string[] {
if (value === undefined) return []
return Array.isArray(value) ? value : [value]
}
function base64Url(bytes: Buffer): string {
return bytes
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}
function sha256Base64Url(path: string): string {
return base64Url(createHash('sha256').update(readFileSync(path)).digest())
}
// --metadata key=value pairs. values parse as json scalars when they look
// like one, so attempt=3 stages a number; anything else stays a string.
function parseMetadata(entries: string[]): Record<string, string | number | boolean> {
const metadata: Record<string, string | number | boolean> = {}
for (const entry of entries) {
const equals = entry.indexOf('=')
if (equals <= 0) {
throw new Error(`[one] --metadata must look like key=value, got "${entry}"`)
}
const key = entry.slice(0, equals)
const raw = entry.slice(equals + 1)
let value: string | number | boolean = raw
try {
const parsed: unknown = JSON.parse(raw)
if (
typeof parsed === 'string' ||
typeof parsed === 'number' ||
typeof parsed === 'boolean'
) {
value = parsed
}
} catch {
// not json: the raw string stands
}
metadata[key] = value
}
return metadata
}
function collectFiles(root: string): string[] {
const files: string[] = []
const visit = (dir: string) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name)
if (entry.isDirectory()) visit(path)
else if (entry.isFile()) files.push(path)
}
}
if (existsSync(root)) visit(root)
return files.sort()
}
function resolveCliJs(root: string): string {
// cli.js is not in react-native's exports map since 0.87, so resolve the
// exported package.json and step to the sibling cli.js on disk.
const require = createRequire(join(root, 'package.json'))
const packageJson = require.resolve('react-native/package.json')
return join(packageJson.slice(0, -'package.json'.length), 'cli.js')
}
function resolveNpmHermesc(root: string): string {
// through react-native's own dependency graph: it pins the compiler whose
// bytecode its hermes vm runs.
const require = createRequire(join(root, 'package.json'))
const reactNativeJson = require.resolve('react-native/package.json')
const nested = createRequire(reactNativeJson)
const compilerJson = nested.resolve('hermes-compiler/package.json')
const dir = compilerJson.slice(0, -'package.json'.length)
const bin =
process.platform === 'darwin'
? 'osx-bin'
: process.platform === 'win32'
? 'win64-bin'
: 'linux64-bin'
return join(dir, 'hermesc', bin, process.platform === 'win32' ? 'hermesc.exe' : 'hermesc')
}
function resolveHermesc(root: string, platform: string): string {
// the embedded ios build compiles with the hermes-engine pod's own
// hermesc when it exists, because the pod's vm train can differ from the
// npm compiler; otherwise both platforms use the npm compiler. publish
// follows the same rule, so its bytecode always matches the binary's vm.
if (platform === 'ios') {
const pod = join(root, 'ios', 'Pods', 'hermes-engine', 'destroot', 'bin', 'hermesc')
if (existsSync(pod)) return pod
}
const npm = resolveNpmHermesc(root)
if (!existsSync(npm)) {
throw new Error(
`[one] hermesc not found at ${npm}: install dependencies${platform === 'ios' ? ' and run one prebuild with pod install' : ''} first`
)
}
return npm
}
// one prebuild reads the runtime version from native.app.updates. an app
// whose native project comes from expo prebuild configures One.Updates
// through vxrn/expo-plugin instead and has no native.app, so it names the
// runtime version it built with on the command line.
async function resolveRuntimeVersion(
platform: 'ios' | 'android',
flag: string | undefined
): Promise<string> {
const { oneOptions } = await loadUserOneOptions('build', true)
const native = oneOptions?.native
const app = typeof native === 'object' ? native.app : undefined
if (!app) {
if (!flag) {
throw new Error(
'[one] one updates publish needs native.app.updates.runtimeVersion, or --runtime-version for an app built with expo prebuild: the binary only takes updates published for its own runtime version'
)
}
return flag
}
validateNativeApp(app, platform)
const runtimeVersion = app.updates?.runtimeVersion
if (!runtimeVersion) {
throw new Error(
'[one] one updates publish needs native.app.updates.runtimeVersion: the binary only takes updates published for its own runtime version'
)
}
if (flag !== undefined && flag !== runtimeVersion) {
throw new Error(
`[one] --runtime-version ${flag} differs from native.app.updates.runtimeVersion ${runtimeVersion}`
)
}
return runtimeVersion
}
export async function runUpdatesPublish(args: PublishArgs): Promise<void> {
const platform = lastValue(args.platform)
if (platform !== 'ios' && platform !== 'android') {
throw new Error('[one] one updates publish needs --platform ios|android')
}
const out = lastValue(args.out)
if (!out) {
throw new Error('[one] one updates publish needs --out <dir>')
}
const intermediatesOut = lastValue(args.intermediatesOut)
if (intermediatesOut) {
const published = resolve(out)
const debug = resolve(intermediatesOut)
if (
published === debug ||
published.startsWith(`${debug}${sep}`) ||
debug.startsWith(`${published}${sep}`)
) {
throw new Error('[one] --intermediates-out must be separate from --out')
}
if (existsSync(debug) && readdirSync(debug).length > 0) {
throw new Error(`[one] refusing to write intermediates into non-empty ${debug}`)
}
}
const root = process.cwd()
const runtimeVersion = await resolveRuntimeVersion(
platform,
lastValue(args.runtimeVersion)
)
if (existsSync(out) && readdirSync(out).length > 0) {
throw new Error(
`[one] refusing to publish into non-empty ${out}: every publish writes a fresh directory`
)
}
mkdirSync(out, { recursive: true })
if (intermediatesOut) {
mkdirSync(intermediatesOut, { recursive: true })
const published = realpathSync(out)
const debug = realpathSync(intermediatesOut)
if (
published === debug ||
published.startsWith(`${debug}${sep}`) ||
debug.startsWith(`${published}${sep}`)
) {
throw new Error('[one] --intermediates-out must be separate from --out')
}
}
const assetsOut = join(out, 'assets')
mkdirSync(assetsOut, { recursive: true })
const metadata = parseMetadata(allValues(args.metadata))
const work = mkdtempSync(join(tmpdir(), 'one-updates-publish-'))
const bundleJs = join(work, 'bundle.js')
const packagerMap = join(work, 'bundle.js.map')
const assetsDir = join(work, 'assets')
const bytecode = join(work, 'main.jsbundle')
const composedMap = join(work, 'main.jsbundle.map')
try {
// the same bundle command the embedded builders run, through the same
// react-native config override, so the bundler and entry match the
// embedded build by construction. minify matches each platform's
// embedded default: the ios pod patch appends --minify true after the
// script's --minify false and the last flag wins, while gradle passes
// false.
execFileSync(
process.execPath,
[
resolveCliJs(root),
'bundle',
'--entry-file',
'index.js',
'--platform',
platform,
'--dev',
'false',
'--reset-cache',
'--bundle-output',
bundleJs,
...(intermediatesOut ? ['--sourcemap-output', packagerMap] : []),
'--assets-dest',
assetsDir,
'--minify',
platform === 'ios' ? 'true' : 'false',
],
{ cwd: root, stdio: 'inherit' }
)
// the same compiler and flags as the embedded builders: -O bytecode,
// warnings off on android like the gradle task. generating a hermes map
// changes the bytecode, so keep the map paired with this exact output.
const hermesc = resolveHermesc(root, platform)
const hermescArgs = ['-emit-binary', '-max-diagnostic-width=80']
if (platform === 'android') hermescArgs.push('-w')
hermescArgs.push('-O')
if (intermediatesOut) hermescArgs.push('-output-source-map')
hermescArgs.push('-out', bytecode, bundleJs)
try {
execFileSync(hermesc, hermescArgs, { cwd: root, stdio: 'inherit' })
} catch (error) {
throw new Error(
`[one] hermesc failed to compile the ${platform} update bundle${error instanceof Error ? `: ${error.message}` : ''}`
)
}
if (intermediatesOut) {
const require = createRequire(join(root, 'package.json'))
const reactNativeJson = require.resolve('react-native/package.json')
const composeSourceMaps = join(
reactNativeJson.slice(0, -'package.json'.length),
'scripts',
'compose-source-maps.js'
)
execFileSync(
process.execPath,
[composeSourceMaps, packagerMap, `${bytecode}.map`, '-o', composedMap],
{ cwd: root, stdio: 'inherit' }
)
writeFileSync(join(intermediatesOut, 'bundle.js'), readFileSync(bundleJs))
writeFileSync(join(intermediatesOut, 'main.jsbundle.map'), readFileSync(composedMap))
}
// the assets tree the bundler laid out is already the update layout:
// React Native resolves each asset at its relative path next to a
// bundle loaded from a file url. every served file is content-named.
const bundleHash = sha256Base64Url(bytecode)
writeFileSync(join(assetsOut, bundleHash), readFileSync(bytecode))
const assets: Array<{ hash: string; url: string; path: string }> = []
for (const file of collectFiles(assetsDir)) {
const hash = sha256Base64Url(file)
const target = join(assetsOut, hash)
if (!existsSync(target)) writeFileSync(target, readFileSync(file))
assets.push({
hash,
url: `assets/${hash}`,
path: relative(assetsDir, file).split(sep).join('/'),
})
}
assets.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
const manifest = {
id: randomUUID(),
createdAt: new Date().toISOString(),
runtimeVersion,
launchAsset: { hash: bundleHash, url: `assets/${bundleHash}`, path: 'main.jsbundle' },
assets,
metadata,
}
writeFileSync(join(out, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
console.info(
`[one] published ${platform} update ${manifest.id} (${assets.length} assets) to ${out}`
)
} finally {
rmSync(work, { recursive: true, force: true })
}
}