Files
orca/config/scripts/verify-mobile-web-app-bundle.mjs
T
NeilandArthur Chang 0cba9f2e08 fix(mobile): render offline Nerd Font symbols on first terminal display (#31446)
Fixes #17168

Supersedes community PR #17220. Original contributor credit is preserved below.

Co-authored-by: Arthur Chang <arthurc0102@gmail.com>
2026-10-21 01:17:08 -07:00

318 lines
14 KiB
JavaScript

import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import * as esbuild from 'esbuild'
import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs'
import { isDirectInvocation } from './script-entry-detection.mjs'
import { assertNoCarriageReturnsInSource } from './mobile-web-source-line-endings.mjs'
import {
MOBILE_WEB_BUNDLE_DIR as defaultBundleDir,
assertMobileWebBundleBuilt
} from './verify-packaged-mobile-web-bundle.cjs'
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
const manifestContract = join(
projectDir,
'src',
'shared',
'mobile-web-bundle',
'manifest-contract.ts'
)
/**
* The document, the route chunks and the images the route tree imports. Derived rather than
* pinned, because a flat number stops agreeing with the chunk ceiling as routes are added: at 128
* and 42 images, 18 routes are already allowed 98 chunks, and 98 + 42 + 1 is 141, so the asset
* count would have failed first and named the count rather than the split that caused it. Written
* as chunks + images + the document, a bundle at the chunk ceiling sits exactly at this one, so
* the chunk ceiling always trips first and the failure says what actually grew.
*/
export function mobileWebAppBundleMaxAssets(routeCount, imageCount) {
return mobileWebAppBundleMaxChunks(routeCount) + imageCount + 1
}
/**
* Phase C byte budget for the app bundle, not the contract ceiling (10 MiB per asset,
* MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES). Deliberately below it so growth trips a build rather than a
* refused asset on a phone. Splitting barely moves it — the same code is emitted in more files —
* so shrinking this still means cutting code.
*
* This head reads 7,686,714 bytes of the 9,437,184 here, 81.5%, leaving 1,750,470. A reading and
* not a pin: nothing asserts it, because the number moves with every build. It is here so the
* generation that spends the rest can see it was already this close.
*/
export const MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES = 9 * 1024 * 1024
// Only this licensed desktop-identical asset has its own allowance; code retains the 9 MiB fence.
export const MOBILE_WEB_APP_TERMINAL_FONT_MAX_BYTES = 1200 * 1024
const TERMINAL_FONT_SHA256 = '8efa6ba89f0a1f3eefde028f36aa64a13e36282e15ea0ca6929c664501037467'
const TERMINAL_FONT_BYTES = 1_177_916
export function mobileWebAppBundleBudgetBytes(manifest) {
const fonts = manifest.assets.filter(
(asset) => asset.path.endsWith('.woff2') || asset.contentType.startsWith('font/')
)
if (fonts.length > 1) {
fail('only one licensed terminal font may use the independent font allowance')
}
const font = fonts[0]
if (!font) {
return manifest.totalBytes
}
if (
font.path !== `assets/${TERMINAL_FONT_SHA256}.woff2` ||
font.sha256 !== TERMINAL_FONT_SHA256 ||
font.contentType !== 'font/woff2' ||
font.byteLength !== TERMINAL_FONT_BYTES ||
font.byteLength > MOBILE_WEB_APP_TERMINAL_FONT_MAX_BYTES
) {
fail(
'terminal font allowance requires the approved hash, path, MIME type and bounded byte length'
)
}
return manifest.totalBytes - font.byteLength
}
/**
* Scripts emitted at every prefix of the sorted route key list, and the route each prefix added.
*
* A chunk is emitted per distinct set of importers, not per route, so a route's marginal cost is
* what it fails to share rather than what it weighs. Re-measured on this head by building
* `routes.slice(0, n)` for every n, which is what the fence below is derived from rather than
* fitted to. The spread it shows is 1 to 11: `pr` adds one script, `tasks` adds eleven.
* The root `./_layout.tsx` (the page's web sibling of the native root) sorts first; with it the
* swept tree reads 78 scripts at 15 routes, with theme tokens in the entry.
*
* This table is the fence's only input, so a route added to the tree stales it and the pins beside
* the fence fail until it is re-measured. That is the point: the bound is re-derived, never bumped.
*/
export const MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP = [
['./_layout.tsx', 3],
['./h/[hostId]/[...page].tsx', 6],
['./h/[hostId]/accounts.tsx', 10],
['./h/[hostId]/agent-history/[worktreeId].tsx', 15],
['./h/[hostId]/edit.tsx', 20],
['./h/[hostId]/files/[worktreeId].tsx', 24],
['./h/[hostId]/files/preview/[worktreeId].tsx', 31],
['./h/[hostId]/history/[worktreeId].tsx', 34],
['./h/[hostId]/index.tsx', 42],
['./h/[hostId]/pr/[worktreeId].tsx', 43],
['./h/[hostId]/review/[worktreeId].tsx', 50],
['./h/[hostId]/session/[worktreeId].tsx', 60],
['./h/[hostId]/source-control/[worktreeId].tsx', 64],
['./h/[hostId]/tasks.tsx', 75],
['./h/_layout.tsx', 78]
]
const sweptScripts = MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP.map(([, scripts]) => scripts)
/** What each route after the first cost, which is the spread the envelope is an upper bound of. */
export const MOBILE_WEB_APP_BUNDLE_ROUTE_SCRIPT_SPREAD = sweptScripts
.slice(1)
.map((scripts, index) => scripts - sweptScripts[index])
/**
* How far above the measurement the envelope sits, and the only slack a refactor gets.
*
* Measured, not chosen, and per head rather than cumulative: at 8, 10, 12 and 14 routes the head
* that wrote the old `4r + 16` read 32, 43, 61 and 69, and one refactor with no route added moved
* the 12-route prefix by four (61 to 57). Four is the worst step a refactor alone has cost. The
* sweep before this branch read 34, 40, 58 and 71 and this head reads 34, 40, 59 and 75: four at
* 14 routes, from main's own drift plus the server-workspace modules this head adds. A head
* between them read 77 there, six past that sweep, and re-measured rather than fitting inside the
* margin, which is what a step past four is meant to force.
* Summing the steps instead would grow this number every head and loosen the fence for free.
*/
export const MOBILE_WEB_APP_BUNDLE_SCRIPT_MARGIN = 4
/**
* How many scripts the page may be cut into, for a given number of routes.
*
* Anchored on the sweep above rather than fitted to the route count, because the count is not a
* function of the route count alone: `4r + 16` was a guess at break-even and its slack ran from 17
* at one route to 4 at thirteen, so it was a near-miss exactly where the tree actually sits. This
* is the measurement plus the margin at the swept tree, growing by the worst route the sweep saw
* for every route past it — a route cannot breach it without costing more than any route measured.
*
* Flat below the swept length, which is the whole sweep's upper bound too: the count rises with the
* prefix, so one number above its top row is above every row. The fence is only ever asked about
* the real tree, and routes are only ever added.
*
* Two-sided in the test beside it. An envelope more than the margin above the build is a fence
* nobody re-derived, and it fails there rather than surviving as headroom for a bump.
*
* The route count stays the only term. A deferred engine belongs inside one artifact and costs one
* script: C7.10 item B first reached mermaid with `import('mermaid')`, which emitted 103 more
* because mermaid lazily imports each of its own diagram types, and a second term admitting those
* would have raised this fence far enough to admit any split at all. The build test's control is
* what holds that line.
*
* This is the ceiling that catches a split running away; MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES
* below is the one that catches it collapsing, and it is the real budget of the two.
*/
export function mobileWebAppBundleMaxChunks(routeCount) {
const beyondTheSweep = Math.max(0, routeCount - MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP.length)
return (
sweptScripts.at(-1) +
MOBILE_WEB_APP_BUNDLE_SCRIPT_MARGIN +
Math.max(...MOBILE_WEB_APP_BUNDLE_ROUTE_SCRIPT_SPREAD) * beyondTheSweep
)
}
/**
* What the browser must parse before the first route can paint: the entry plus every chunk it
* reaches by static import. This is the budget splitting exists to hold — it was 8.16 MB as one
* chunk and measures 1,244,312 bytes split on this head, 1.19 of the 3 MiB — so a route
* re-imported statically, or `splitting` dropped, fails the build here instead of arriving as a
* slow first open on a phone.
*
* It is not a per-route escape hatch. Re-measured here by making one route's manifest entry a
* static import and reading this same closure back: session alone breaks the bound at 3.32 MiB,
* and tasks at 2.17, source-control 2.04, review 2.03, index 1.89 and files/preview 1.85 each
* spend most of a budget that has to cover the entry as well. What keeps the hatch usable at all
* is that expo-router reads `unstable_settings` off layout nodes only, and the subtree's one
* layout, `h/_layout.tsx`, measures 1.89 MiB static. Any other route needing a synchronous export
* needs this number re-measured, not a static import.
*/
export const MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES = 3 * 1024 * 1024
/** Every tree whose bytes reach the buildId, so a CRLF checkout cannot fork it. */
export const MOBILE_WEB_APP_SOURCE_DIRS = [
join(projectDir, 'mobile', 'web-entry'),
join(projectDir, 'mobile', 'app'),
join(projectDir, 'mobile', 'src')
]
class VerificationError extends Error {}
function fail(message) {
throw new VerificationError(message)
}
/**
* How many assets the phone will accept, read from the contract rather than copied: the native
* shells hold their own 256 and refuse a larger manifest outright. Bundled through esbuild
* because node cannot resolve that module's extensionless TypeScript imports, so the number is
* evaluated from the contract and not parsed out of it.
*/
export async function readMobileWebBundleMaxAssets() {
const { outputFiles } = await esbuild.build({
entryPoints: [manifestContract],
bundle: true,
write: false,
format: 'esm',
platform: 'node',
logLevel: 'silent'
})
const source = Buffer.from(outputFiles[0].contents).toString('base64')
const { MOBILE_WEB_BUNDLE_MAX_ASSETS: ceiling } = await import(
`data:text/javascript;base64,${source}`
)
if (typeof ceiling !== 'number') {
fail(`${manifestContract} exports no MOBILE_WEB_BUNDLE_MAX_ASSETS to bound the build with`)
}
return ceiling
}
/**
* The derived ceiling is only a budget while it stays inside the map the phone can hold: the
* shells return null for a manifest over MOBILE_WEB_BUNDLE_MAX_ASSETS rather than dropping the
* extra assets, so a route count that pushes the chunk envelope plus images plus the document past
* it would pass this build and fail on the device with nothing to read. At today's 42 images that
* is 28 routes, inside what Phase C adds, which is why this is a build failure and not a comment.
* The envelope grants the worst swept route to each one past the sweep, so re-measuring a tree
* whose routes share more moves that crossing out again.
*/
export function assertAssetCeilingFitsShell(routeCount, imageCount, shellMaxAssets) {
const ceiling = mobileWebAppBundleMaxAssets(routeCount, imageCount)
if (ceiling > shellMaxAssets) {
fail(
`the ceiling derived for ${String(routeCount)} route(s) and ${String(imageCount)} image(s) ` +
`is ${String(ceiling)} assets, over the ${String(shellMaxAssets)} the shell will load`
)
}
return ceiling
}
async function buildIntoScratch() {
const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-verify-'))
try {
return await buildMobileWebAppBundle({ outDir: join(scratch, 'mobile-web') })
} finally {
await rm(scratch, { recursive: true, force: true })
}
}
// bundleDir is a seam for the tests, which verify a scratch build; the script always verifies out/.
export async function verifyMobileWebAppBundle({ bundleDir = defaultBundleDir } = {}) {
for (const directory of MOBILE_WEB_APP_SOURCE_DIRS) {
await assertNoCarriageReturnsInSource(directory)
}
const manifest = assertMobileWebBundleBuilt(bundleDir)
const budgetBytes = mobileWebAppBundleBudgetBytes(manifest)
if (budgetBytes > MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES) {
fail(
`bundle excluding the approved font is ${String(budgetBytes)} bytes, over the Phase C budget of ` +
`${String(MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES)}`
)
}
const first = await buildIntoScratch()
const second = await buildIntoScratch()
if (first.manifest.buildId !== second.manifest.buildId) {
fail(`buildId is not reproducible: ${first.manifest.buildId} then ${second.manifest.buildId}`)
}
if (first.manifest.buildId !== manifest.buildId) {
fail(
`${bundleDir} is stale: it carries buildId ${manifest.buildId}, a fresh build produces ${first.manifest.buildId}`
)
}
// Read off the fresh build rather than the manifest: neither bound is a manifest field, and the
// buildId just proved this build is the one on disk.
// After the fresh build, which is what knows how many of the assets are images.
const maxAssets = assertAssetCeilingFitsShell(
first.routeKeys.length,
first.imageCount,
await readMobileWebBundleMaxAssets()
)
if (manifest.assets.length > maxAssets) {
fail(
`bundle has ${String(manifest.assets.length)} assets, over the Phase C budget of ` +
`${String(maxAssets)} for ${String(first.routeKeys.length)} route(s) and ` +
`${String(first.imageCount)} image(s)`
)
}
const maxChunks = mobileWebAppBundleMaxChunks(first.routeKeys.length)
if (first.chunkCount > maxChunks) {
fail(
`bundle is cut into ${String(first.chunkCount)} chunks, over the Phase C budget of ` +
`${String(maxChunks)} for ${String(first.routeKeys.length)} route(s)`
)
}
if (first.entryStaticBytes > MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES) {
fail(
`${String(first.entryStaticBytes)} bytes load before the first route, over the Phase C ` +
`budget of ${String(MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES)}`
)
}
return manifest
}
if (isDirectInvocation(import.meta.url, process.argv[1])) {
try {
const manifest = await verifyMobileWebAppBundle()
console.log(
`[verify-mobile-web-app-bundle] OK — ${String(manifest.assets.length)} asset(s), ` +
`${String(manifest.totalBytes)} total bytes, ` +
`${String(mobileWebAppBundleBudgetBytes(manifest))}/${String(MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES)} bytes excluding the approved font, ` +
`reproducible buildId ${manifest.buildId}`
)
} catch (error) {
console.error(`[verify-mobile-web-app-bundle] ${error.message}`)
process.exit(1)
}
}