Files
orca/config/scripts/build-mobile-web-app-bundle.mjs
Jinwoo Hong e6aa90ff36 test(mobile): certify the browser pane's golden families and render it in a page (OTA phase C, C6.5) (#21777)
* test(mobile): pin the browser pane's golden families

The half pin for C6: 4 families, 15 goldens, every verdict the one C2's
rule predicts. Measured per family with vitest `-t` over the full
787-golden corpus, with C1's 103 reproduced golden-for-golden as the
control: 6 byte-identical, 9 result-absent-settlement.

No composed `c6-page-closure.ts`: a composed table is pinned against a
route and the browser is a pane, so C7's route is what composes this
with C1's.

The derivation census does not wait for that route. `mobileWebAppRoute-
Closure` becomes one case of `mobileWebAppModuleClosure`, which takes
any entries, so the pane's own closure can be read from the module. Two
cases: the pane alone reaches exactly the pinned four, and the pane
beside `app/h/_layout` adds exactly those four and no other, with the
layout reproducing C1's 22 as the control for the difference.

Closure at this base: 48 local modules alone, 34 beyond the layout, 30
under `src/browser` and four through the web siblings. The design said
23, all under `src/browser`; it was measured before C6.2 and C6.3 added
those siblings, so the pin carries the re-measured number.

`browser.screencast` has no golden at all, so this certifies the input
path and says nothing about the frame path.

Red first: with `browser.wheel` dropped from the table, both census
cases fail naming the missing family; restored, the file's 10 cases pass
and the parity suite reports "15 goldens in 4 families, 6 byte-identical".

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the frame budget against the shell's real frame

Ruling 2's pin. `binaryEventEnvelopeBytes()` sizes the mobile view's
device scale from a skeleton it builds itself, and until now its only
check was another skeleton of the same shape in the same file: two
copies of one assumption agreeing with each other.

This measures the real thing. A frame with CDP's nine metadata fields
and a real `Page.screencastFrame` timestamp, encoded by C6.1's
`encodeBridgeScreencastFrame` and serialized by the real
`BridgeHostSubscriptions`, posted through the host harness: 303 bytes
besides the image, against a bound of 516.

Held above is not enough on its own — 213 bytes of slack is room for the
shell to grow the envelope by a field the page never hears about — so
the bound is reconstructed exactly instead. Every byte of that slack is
a number this frame prints narrower than a double can; adding those back
gives 516 on the nose.

The budget cases run a generated noise image at the budgeted scale, not
a committed fixture: the worst case is the image JPEG compresses least,
and a photograph sits a tenth of the way to it. 901,161 px at 0.545
bytes per pixel is 491,132 bytes, which the shell posts at 654,857 of
the 655,360-byte cap. One envelope more and the shell drops it, which is
ruling 1 read from the budget's side.

Red first, two ways. Drop the metadata widening from the bound and three
cases fail, the sharpest being the real shell answering the frame the
page thought it could send with zero posts. Add a field to the shell's
own envelope and the reconstruction fails at 516 against 548, where the
existing suite stays green on all 14 — which is the drift this file
exists for.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): record the measured frame bytes, correcting e7cd24ef10

The previous commit message says the shell posts 654,857 bytes for a
frame at the budgeted area. That number was not measured; I wrote it
from the budget arithmetic instead of reading it off the harness. The
measured value is 655,147, which is 213 under the cap rather than 503.

Nothing in the assertions changes — they compare against the cap and
the bound, never against a literal — but the figure now lives in the
file where it was measured rather than only in a message that has it
wrong.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): render the browser pane in a page and paint a real frame

The only place C6's whole frame path runs. Every other check reads one
half: the shell suites drive the host with no page, the page suites
drive the hooks with no shell, and the parity pin certifies the input
path from a recording.

Ruling 4: no route is added. `bundleMobileWebApp` already takes an
`appDir`, so this builds a one-route tree of its own and nothing under
`mobile/app` moves. The shell double grows a screencast lane to serve
it: it accepts a subscribe, posts `event.binary`, and prices each frame
the way `BridgeHostSubscriptions` does, so an over-cap frame is dropped
where the page can watch the stream survive it.

Six cases: the pane subscribes with `wantsBinary` and paints the frame
it is handed; a second frame flips the double buffer; an over-cap frame
is dropped and the next one paints on the same subscription; the grant
withheld produces the update-the-app copy and no subscribe at all; a tap
issues one `browser.mouseClick` at the centre of the source viewport;
and no request leaves the bundle's own origin.

Measured. The frame the pane asks this viewport for is 390x698, which as
noise is 201,924 base64 characters. The phone's mobile-mode frame is
780x1424 and encodes to 811,168, which is 124% of the cap and the reason
the area budget exists; the over-cap case uses 2400x2160 at 3,761,580,
574% of it. The tap maps to (194, 356) against a 390x712 source, one
device pixel off centre because the rendered width is 382.33 CSS pixels
for 390 source pixels.

One finding, recorded rather than fixed because it is not the pane's.
The page files a CSP `script-src` violation on every load, on any route:
Zod 4 feature-detects its compiled path with `new Function('')`, the
shell's `script-src 'self'` blocks it, Zod catches the throw and takes
the interpreted path. The page is correct and the report is filed
anyway. One case names it so a second `eval` is visible, and every other
case asserts no violation beyond it.

Red first, twice, both by reverting behaviour C6.2 landed. Stub out the
decode probe in `whenBrowserFrameDisplayable` and the flip case fails on
two identical frame digests. Point `updateBrowserImageSource` at the
host element instead of the surface child and the paint and flip cases
both fail. The first case's comment is corrected by the first of those:
it claimed a visible layer proved the decode-then-flip, and the frame
still paints with the probe gone, so the flip case is what proves it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): sweep the worst-case JPEG cost instead of taking one point

The reviewer is right, and it is worse than the report says. At 0.545
bytes per pixel, 90 of 143 viewports posted a frame over the cap, and
59 of the 111 the budget claims to fit were dropped outright by the
real shell — 390x712 at scale 1.8 among them.

The old number came from one 2400x2160 frame. A single large frame is
the cheapest per pixel in the whole range, so a worst case measured
there is not a worst case anywhere else.

Swept 143 viewports, widths 320 to 1400 and heights 480 to 1600, each
encoded by Chromium at the scale the real budget picks for it. Across
the 111 the budget fits, the cost ranges 0.54470 to 0.55351 bytes per
pixel. The constant is now 0.56: that maximum plus 0.00649, about 1.2%,
for the encoder version it was not swept on. The docstring carries the
sweep, the range, the margin and the date.

0.56 is a fixed point, not a guess. Raising the constant shrinks the
budget, which lowers the scale, which moves the cost; 0.555, 0.56 and
0.565 all leave the same 31 viewports over the cap, and every one of
those sits at the scale floor of 1, where the module already declines
to go blurrier and C6 ruling 1's drop rule is the protection. The new
test asserts both halves: nothing the budget fits goes over, and the
largest viewport it cannot fit is dropped by the real shell.

The sweep lives in `config/scripts` because it needs Chromium: the
frames are CDP screencast frames, so Chromium's encoder is the oracle
and a Node JPEG library would calibrate against the wrong bytes. It
drives the real budget, the real scale function and the real
`BridgeHostSubscriptions`, and runs in about 4 seconds.

Ruling 2's block in `browser-screencast-budget-at-the-shell.test.ts`
now says plainly what it measures. It feeds `noise(area * theConstant)`,
a byte count the constant itself produced, so it can falsify the
expansion and the drop rule but never the constant. It read as if it
validated the worst case, and it did not.

Red first: put 0.545 back and the sweep fails with 59 viewports, each
naming its scale and reporting `null` — the real shell dropping the
frame rather than posting it over the cap.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): turn Zod's JIT probe off for the page, before any module

The page filed a CSP `script-src` violation on every load: Zod decides
whether it may compile by constructing `new Function('')` and reading
the throw as "no JIT here", the shell's `script-src 'self'` is exactly
that throw, and the browser reports it before Zod catches it. Zod's own
source gates the probe on `jitless` for this case.

`z.config({ jitless: true })` at the entry does not work, and the
reviewer's suggestion of putting it there was measured losing the race.
`$ZodObject` reads `allowsEval` when a schema is constructed, not when
one is parsed, so the first module-scope `z.object(...)` in the bundle
fires the probe — and esbuild evaluates the chunk holding zod and its
callers before the chunk holding any module of ours that imports zod. A
Function-constructor trap in the page put the call under `new ZodObject`
ahead of the entry's first statement.

`globalConfig` is `globalThis.__zod_globalConfig`, which zod adopts with
`??=` rather than replacing, so the banner can set the flag before any
module runs. That is where it now lives, beside the `process` shim and
under the same `MOBILE_WEB_APP_SHIMS` contract, which asserts it is
applied. Nothing is lost: the compiled path was never reachable in a
page under this policy.

The render check's `newCsp()` filter is gone. It dropped violations by
`blockedURI === 'eval'`, which would have hidden a real one, and every
case now asserts zero. The first case walks load and first paint, which
is where the second of the two reports fired. The dead `violations`
array is deleted.

Red first: blank the banner constant and four of the six cases fail,
each naming a `blockedUri: 'eval'` the filter used to swallow.

Finding, not fixed here and reported instead: the page bundles two
copies of zod, mobile's 4.4.3 and the repo root's 4.5.4, because
`src/shared/zod-salvage.ts` resolves upward. That is 808 KB of duplicate
source. Aliasing `zod` to one copy in the builder fixes it and was
measured working, but it changes which zod shared code runs in the
shipped page, which is a call to make on its own rather than inside a
CSP fix.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): give the shell double the whole canCarry rule and the acks

The double reproduced one arm of `BridgeHostSubscriptions.canCarry`, the
message cap, and silently carried anything the other two would have
refused: a window already holding its maximum frames, and a window whose
bytes the frame would push past the limit. It also ignored the page's
`ack` frames, so its window never reopened — which was invisible only
because no case streamed far enough to close it.

Both arms are in now, and the `ack` arm consumes the page's acks exactly
as the host does. The three caps are read out of `bridge-caps.ts` and
`bridge-host-subscriptions.ts` rather than retyped, the same way the
harness already reads the protocol version and the CSP, so a double
carrying a stale number is not possible. The render check's own
`640 * 1024` is gone with them.

One case for it: thirty frames of about 200 KB, roughly 6 MB through a
4 MiB window, nothing over the message cap, so a drop can only come from
the window. Every frame posts, nothing is dropped, and the page's ack
seqs are read back to show the window stayed open because the page acked
rather than because the double was generous.

The file docstring said the double answers no RPC. It serves a
screencast stream now, so it says that instead, and says what it still
is not: it decides no domain behaviour.

The dead `violations` array is gone, folded with the CSP commit.

Red first: make the `ack` arm inert, as it was before this commit, and
the case fails with `Set{'posted','dropped'}` against `Set{'posted'}`.
A first attempt at that mutation left the byte subtraction in place and
stayed green, which is the mutation being wrong rather than the case.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* ci(mobile): run the whole mobile-web-app family, not a list that goes stale

The `mobile_web_app` job hand-listed ten files. The render check this
chain added was not among them, so it would have skipped in CI — and it
was not the first: three landed censuses were already unlisted, and
their closure blocks only run with `ORCA_MOBILE_WEB_APP_DEPS_REQUIRED=1`,
so they are green in the sharded `test` job whether or not they ever
ran here. Nobody could see it.

The list is now two vitest filename filters, `config/scripts/mobile-web-
app-` and the one builder test outside that prefix. Quoted, because
vitest matches a positional as a substring against the discovered files
rather than expanding a glob: `mobile-web-app-*.test.mjs` finds nothing,
and it fails by reporting no test files rather than by running fewer.
Both forms were tried before this one was written.

It runs 18 files and 205 cases, against 10 files before. With mobile
dependencies absent, 111 of those 205 skip, which is the measure of what
only this job runs. Per file, cases CI has never run:

  browser-pane-render            7 of 7   (this chain)
  source-control-external-links  9 of 9
  source-control-keyboard        6 of 6
  source-control-text-inputs     6 of 19  (C4.2)
  frame-budget-sweep             4 of 4   (this chain)
  route-manifest                 2 of 17

Two more files the filter adds run fully in the sharded job already and
change nothing here: `browser-pane-text-inputs` (C6.4 — it censuses a
hand-written closure and never bundles, so unlike the report it was not
skipping) and `external-link-seam`.

The sweep is renamed into the family for the same reason. As
`mobile-browser-frame-budget-sweep.test.ts` it matched neither the job's
filter nor `pr-code-change-scope.mjs`'s `config/scripts/mobile-web-app-`
prefix, so a change to it alone would not have run the job that runs it.
It is also gated on the dependency check now: it needs no
react-native-web, but it launches Chromium, and that flag is what tells
the job with a browser from the one without. Unguarded it would have
failed the sharded `test` job outright.

That filter is a prefix match. `config/scripts/mobile-web-app-` and
`mobile/src/` both fire this job, and `.github/workflows/pr.yml` is in
GLOBAL_FORCE_PREFIXES, so this commit runs everything.

Also: `postedFrame` in the ruling-2 pin and in the sweep both reached
the binary lane through `?.`, so a subscribe that opened no stream read
as zero posts — indistinguishable from a dropped frame, which is the
verdict both files are about. They throw now. The render check's
restated `640 * 1024` went with the window caps in c73b405f81; the cap
is read from `bridge-caps.ts`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* ci(mobile): record what the mobile-web-app job costs to run

The filter that replaced the hand list runs 18 files where the list ran
10, so the step's cost is now a function of what anyone names into the
family rather than of what a reviewer remembered to add. Measured on
this machine: 25-30s wall for the whole step, of which the frame-budget
sweep is 2.5s.

The sweep is the one part whose cost is a choice. It encodes 111 noise
JPEGs in Chromium, one per viewport the budget fits, so adding rows to
that set is a decision about this job's runtime and the comment says so
where someone would make it.

Found, not fixed, and reported for its own PR rather than folded here:
the page bundles two copies of zod, mobile's 4.4.3 and the repo root's
4.5.4, reached through `src/shared/zod-salvage.ts`, which resolves
upward while `mobile/src/` resolves to mobile's. That is 808 KB of
duplicate source and two module instances in the shipped page. Aliasing
`zod` in `mobileWebAppBuildOptions` fixes it and was measured working
during this chain; it is reverted and stays reverted, because it changes
which zod shared code runs in the page and that is not a call to make
inside a CI commit. The CSP fix in 71254ab3a9 does not depend on it:
`globalConfig` lives on `globalThis`, so the banner covers both copies.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): load the frame-budget sweep's mobile modules after the dependency guard

vite transforms every file under mobile/ against mobile/tsconfig.json, which extends
expo/tsconfig.base.json; the sharded test job installs no mobile dependencies, so the
sweep's static imports failed the file at load before describe.skip ran. Type-only imports
stay static; the values load in beforeAll behind mobileWebAppDependenciesPresent().

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): certify the frame budget at the quality the pane ships

Round 2: the sweep and the render check encoded fixtures at a retyped 0.72; both now read
BROWSER_FRAME_QUALITY (the sweep from the module, the render check through the harness reader),
so a quality change fails the certification instead of leaving it green. Every render case now
asserts zero CSP violations; the sweep pins the 32 viewports left at scale 1; three references to
a renamed file and a file that never existed are corrected; a shim count comment is made
count-agnostic.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): assert the frame-budget sweep against the constant, not the measured maximum

The margin above the measured 0.55351 is what an encoder drift is allowed to spend; pinning the
measurement made a drift inside the margin fail a budget that still held. The number stays in the
docstring as the sweep's record.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): move the sweep's measured-maximum note beside the assertion it explains

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 07:43:14 -04:00

558 lines
24 KiB
JavaScript

import { readFile } from 'node:fs/promises'
import { realpathSync } from 'node:fs'
import { basename, extname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import * as esbuild from 'esbuild'
import {
MOBILE_WEB_BUNDLE_ENTRYPOINT,
hashedAsset,
isDirectInvocation,
readDesktopVersion,
readProtocolWindow,
sha256Hex,
writeMobileWebBundleTree,
contentTypeForExtension
} from './build-mobile-web-bundle.mjs'
import {
ROUTE_SOURCE_LOADERS,
assertRoutesCarryNoSynchronousExports,
collectMobileWebAppRoutes,
renderMobileWebAppRouteManifest,
routePathnameFromKey
} from './mobile-web-app-route-manifest.mjs'
import { MOBILE_WEB_PAGE_ROUTES } from './mobile-web-page-routes.mjs'
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
const mobileDir = join(projectDir, 'mobile')
const defaultAppDir = join(mobileDir, 'app')
const entryPoint = join(mobileDir, 'web-entry', 'index.tsx')
const defaultOutDir = join(projectDir, 'out', 'mobile-web-app')
/**
* Every shim the app bundle needs, each one a documented Metro/RN-Web gap. `appliesTo` reads the
* esbuild option that implements the shim, so the list cannot claim a shim the build does not
* apply and a dropped option fails the named shim rather than the whole build.
*/
export const MOBILE_WEB_APP_SHIMS = [
{
// react-native has no browser build; react-native-web is the whole point of Route A.
name: 'react-native-web-alias',
appliesTo: (options) => options.alias?.['react-native'] === 'react-native-web'
},
{
// RN ships untranspiled JSX inside .js files (expo-router's own build/ included).
name: 'js-as-jsx',
appliesTo: (options) => options.loader?.['.js'] === 'jsx'
},
{
// RN code assumes a Hermes/Metro `global`; the browser only has `globalThis`.
name: 'global-as-globalthis',
appliesTo: (options) => options.define?.global === 'globalThis'
},
{
// RN and Expo modules read process.env at module scope, before any of our code runs.
name: 'process-banner',
appliesTo: (options) => options.banner?.js?.includes('globalThis.process ??=') === true
},
{
// Zod probes for a usable JIT with `new Function('')`, which the shell's CSP reports even
// though Zod catches the throw and runs interpreted. Turned off before any module, because a
// schema constructed at module scope reaches the probe before our own code can run.
name: 'zod-jitless-banner',
appliesTo: (options) => options.banner?.js?.includes('__zod_globalConfig') === true
},
{
// lucide-react-native@1.14.0's barrel re-exports LucideProvider from a context.mjs that does
// not export it. Metro's loose CJS interop tolerates it; esbuild's strict ESM does not.
// Web-build only: patching the package would change what the shipped native app consumes.
name: 'lucide-barrel-provider',
appliesTo: (options) =>
options.plugins?.some((plugin) => plugin.name === LUCIDE_PLUGIN_NAME) === true
},
{
// AsyncStorage's web build is window.localStorage, which the shell's page does not have:
// Android turns DOM storage off and on iOS the origin host is the session id, so anything
// written there is gone on the next remount. The page module holds the app's own values,
// primed by `init` and written back over the `storage` grant.
name: 'async-storage-over-the-bridge',
appliesTo: (options) =>
options.alias?.['@react-native-async-storage/async-storage'] === PAGE_ASYNC_STORAGE_MODULE
},
{
// esbuild has no require.context, so the route tree is generated and injected.
name: 'route-manifest',
appliesTo: (options) =>
options.plugins?.some((plugin) => plugin.name === ROUTE_MANIFEST_PLUGIN_NAME) === true
}
]
/**
* react-native-web's own root reset: the same declaration set and `id="expo-reset"` as Expo's web
* template (`@expo/cli/static/template/index.html`), minified — the template's own block is
* pretty-printed with comments, so this is 112 bytes against its 410. Nothing generates it for a
* document built here.
*
* Every box below the mount is `flex: 1` against its parent, so with no definite height on all
* three the root measures 0 and the collapse is silent: the screen still lays out, still reaches
* the accessibility tree at the right offsets, and never paints or hit-tests below the header.
* A phone showed the header over a blank list with every row readable to VoiceOver and no row
* tappable (lane C1.7, both platforms).
*
* Inline, because the shell's CSP already allows `style-src 'unsafe-inline'` for the sheet
* react-native-web injects at runtime; a linked asset would need a second round trip before the
* first frame and would paint the collapsed layout until it landed.
*
* Height, `overflow` and the root's flex box and nothing else, which is what the template carries:
* react-native-web emits `body{margin:0}` in that runtime sheet, so a copy here would only cover
* the frames before it lands and would make this string something to keep in step with two sources.
*/
export const MOBILE_WEB_APP_ROOT_RESET =
'<style id="expo-reset">html,body{height:100%}body{overflow:hidden}' +
'#root{display:flex;height:100%;flex:1}</style>'
const PAGE_ASYNC_STORAGE_MODULE = join(
mobileDir,
'src',
'mobile-web-shell',
'bridge',
'page-async-storage.ts'
)
/**
* Zod's compiled path, off before any module runs.
*
* Zod decides whether it may compile by constructing `new Function('')` and reading the throw as
* "no JIT here". Under the shell's `script-src 'self'` that throw is exactly what happens, Zod
* catches it and takes the interpreted path — but the browser files a `securitypolicyviolation`
* report first, and it does so on every page load. Zod's own source gates the probe on `jitless`
* for this case, so nothing here is a workaround.
*
* In the banner rather than a module that calls `z.config`, because a module cannot win the race.
* `$ZodObject` reads `allowsEval` when a schema is *constructed*, not parsed, so the first
* module-scope `z.object(...)` in the bundle fires the probe — and esbuild evaluates the chunk
* holding zod and its callers before the chunk holding any module of ours that imports zod. An
* entry import placed first was measured losing that race; the banner runs before every module.
*
* `globalConfig` is `globalThis.__zod_globalConfig`, which zod adopts with `??=` rather than
* replacing, so setting the flag on it here is what zod itself reads.
*/
const ZOD_JITLESS_BANNER =
'globalThis.__zod_globalConfig ??= {}; globalThis.__zod_globalConfig.jitless = true;'
const ROUTE_MANIFEST_PLUGIN_NAME = 'orca-route-manifest'
const LUCIDE_PLUGIN_NAME = 'orca-lucide-barrel-provider'
/** The entry output's name, so classifying the outputs never has to guess which one it is. */
const ENTRY_CHUNK_NAME = 'entry'
// mobile/web-entry/route-manifest.ts is a real typed file rather than a virtual specifier, so the
// entry typechecks and Metro can still resolve it; only its body is replaced here.
function routeManifestPlugin(manifestSource) {
return {
name: ROUTE_MANIFEST_PLUGIN_NAME,
setup(build) {
build.onLoad({ filter: /web-entry[\\/]route-manifest\.ts$/ }, () => ({
contents: manifestSource,
loader: 'js',
resolveDir: mobileDir
}))
}
}
}
const lucideBarrelPlugin = {
name: LUCIDE_PLUGIN_NAME,
setup(build) {
build.onLoad({ filter: /lucide-react-native[\\/].*[\\/]context\.mjs$/ }, async (args) => ({
contents: `${await readFile(args.path, 'utf8')}\nexport const LucideProvider = ({ children }) => children;\n`,
loader: 'js'
}))
}
}
/** Split out so a test can read the options MOBILE_WEB_APP_SHIMS claims, without a build. */
export function mobileWebAppBuildOptions(routes) {
return {
// Fixed so no absolute path of this checkout can reach the output.
absWorkingDir: mobileDir,
entryPoints: [entryPoint],
bundle: true,
minify: true,
// Virtual: write is false, so outdir only names the emitted files esbuild hands back.
outdir: 'dist',
write: false,
// esm, because `splitting` requires it and a per-route chunk is the point: with iife and
// static imports esbuild emitted one 8.16 MB script for all 14 routes.
format: 'esm',
splitting: true,
// esbuild's `[hash]` is over the metafile's input keys, which are paths relative to
// absWorkingDir, so this name is not a function of the bytes and differs between two
// checkouts of one commit. It is a placeholder: renameOutputsByContent replaces it below.
chunkNames: '[hash]',
// Pinned rather than defaulted, so the entry is found by name and not by elimination.
entryNames: ENTRY_CHUNK_NAME,
target: ['es2022'],
charset: 'utf8',
legalComments: 'none',
// No sourcemap: it is an emitted file and would carry this checkout's absolute paths into the
// bundle. The metafile carries them too but is never written and never hashed; it is the only
// thing that says which output is the entry, which of its imports are static, and which
// outputs each one names.
sourcemap: false,
metafile: true,
logLevel: 'silent',
jsx: 'automatic',
// One React: resolve everything from mobile/node_modules, which is where the entry lives.
nodePaths: [join(mobileDir, 'node_modules')],
alias: {
'react-native': 'react-native-web',
'@react-native-async-storage/async-storage': PAGE_ASYNC_STORAGE_MODULE
},
plugins: [routeManifestPlugin(renderMobileWebAppRouteManifest(routes)), lucideBarrelPlugin],
resolveExtensions: [
'.web.tsx',
'.web.ts',
'.web.jsx',
'.web.js',
'.tsx',
'.ts',
'.jsx',
'.js',
'.json'
],
// Images are emitted as same-origin assets, not data: URLs, so their content-hashed names keep
// the buildId reproducible and the bytes out of every chunk that imports one. The policy now
// admits data: for images, but that is for a preview the page composes at runtime, not for a
// bundled asset. A font would fail the build here rather than silently ship under font-src 'none'.
loader: {
...ROUTE_SOURCE_LOADERS,
'.png': 'file',
'.jpg': 'file',
'.jpeg': 'file',
'.gif': 'file',
'.webp': 'file',
'.svg': 'file'
},
assetNames: '[hash]',
// Absolute, because the document is served at every route depth and a path relative to the
// script would resolve against the route instead.
publicPath: '/assets',
banner: {
js: `globalThis.process ??= { env: { NODE_ENV: 'production', EXPO_OS: 'web' }, platform: 'web', version: '', nextTick: (fn) => setTimeout(fn, 0) };${ZOD_JITLESS_BANNER}`
},
define: {
global: 'globalThis',
__DEV__: 'false',
'process.env.NODE_ENV': '"production"',
'process.env.EXPO_OS': '"web"',
'process.env.EXPO_ROUTER_IMPORT_MODE': '"sync"'
}
}
}
/**
* What the browser must have before the first route can paint: the entry plus every chunk it
* reaches by static import, transitively. A dynamic import is what the split exists to defer, so
* it is where this stops.
*
* The bound the verifier holds is this number and not the entry file alone, because esbuild puts
* the code shared by entry and routes in a chunk the entry imports statically: budgeting the entry
* file on its own would fall as the shared chunk grew.
*/
export function entryStaticClosure(metafile, entryOutputPath) {
const reached = new Set([entryOutputPath])
const queue = [entryOutputPath]
while (queue.length > 0) {
const current = queue.shift()
for (const imported of metafile.outputs[current]?.imports ?? []) {
if (imported.kind !== 'import-statement' || reached.has(imported.path)) {
continue
}
reached.add(imported.path)
queue.push(imported.path)
}
}
return reached
}
/**
* Every emitted output, renamed to the sha256 of its own final bytes.
*
* esbuild's `[hash]` is computed over the metafile's input keys, and those keys are paths
* relative to absWorkingDir. A tree whose mobile/node_modules is a symlink keys most of its
* inputs as `../../<somewhere>/...`, a tree that holds a real directory keys them as
* `node_modules/...`, and a byte-identical chunk comes out under a different name in each. The
* name is embedded in every importer, so the difference cascades into a different buildId for one
* commit -- and every phone re-downloads a bundle whose bytes never changed.
*
* Renaming here is what removes the path from the output. Leaves first, so an importer is hashed
* only once the names written inside it are final: an image before the chunk that loads it, a
* chunk before the chunk that imports it, the entry last. The result is what `hashedAsset` would
* name each of these anyway, which is how the name inside the bytes and the manifest's own sha256
* stay the same string.
*/
export function renameOutputsByContent(metafile, outputFiles) {
const emitted = new Map(
outputFiles.map((file) => [basename(file.path), Buffer.from(file.contents)])
)
const importsOf = new Map(
Object.entries(metafile.outputs).map(([output, { imports }]) => [
basename(output),
(imports ?? []).map((entry) => basename(entry.path)).filter((name) => emitted.has(name))
])
)
const renamed = new Map()
const open = new Set()
function rename(name) {
const done = renamed.get(name)
if (done) {
return done
}
if (open.has(name)) {
// Two outputs naming each other have no content hash at all, so this is a hard stop rather
// than a fallback. esbuild's splitting emits a DAG; nothing in the tree has produced one.
throw new Error(
`[build-mobile-web-app-bundle] ${name} is in an output cycle and cannot be content-named`
)
}
open.add(name)
let bytes = emitted.get(name)
for (const child of importsOf.get(name) ?? []) {
const { name: childName } = rename(child)
// publicPath already rewrote the specifier to this exact shape, and an esbuild output name
// is a token that appears nowhere else.
bytes = Buffer.from(
bytes.toString('utf8').split(`/assets/${child}`).join(`/assets/${childName}`),
'utf8'
)
}
open.delete(name)
const result = { name: `${sha256Hex(bytes)}${extname(name)}`, bytes }
renamed.set(name, result)
return result
}
for (const name of [...emitted.keys()].sort()) {
rename(name)
}
return renamed
}
/**
* Which emitted chunk each route key's `import()` lands in. esbuild puts a route module in exactly
* one output, so the metafile's own inputs answer it; nothing downstream can, because by then
* every name is a hash of bytes and the route's source path is gone from the bundle.
*/
export function routeChunkNames(metafile, routes, renamed) {
const owner = new Map()
for (const [output, { inputs }] of Object.entries(metafile.outputs)) {
for (const input of Object.keys(inputs ?? {})) {
// Absolute, and through realpath on the lookup side below: esbuild writes its input keys
// relative to absWorkingDir after resolving symlinks, so a route reached through one (every
// scratch tree under /var on macOS) is keyed by a path the caller never spelled.
owner.set(resolve(mobileDir, input), basename(output))
}
}
return Object.fromEntries(
routes.map(({ key, module }) => {
const emittedName = owner.get(realpathSync(module))
if (!emittedName) {
throw new Error(`[build-mobile-web-app-bundle] ${key} reached no output`)
}
return [key, renamed.get(emittedName).name]
})
)
}
const isScriptOutput = (path) => path.endsWith('.js')
// appDir is a seam for the tests, which bundle a scratch route tree; production always uses mobile/app.
/**
* Every source module one page route reaches, as the builder itself resolves them.
*
* Both entry points are needed: `app/h/_layout.tsx` wraps every route under it, and its imports are
* part of the page as surely as the route module's.
*/
export async function mobileWebAppRouteClosure(routeModule) {
return await mobileWebAppModuleClosure(['app/h/_layout', routeModule])
}
/**
* The same closure for any entry modules, which a route plus the layout is one case of.
*
* One definition of "what a page contains", read from `metafile.inputs` — the modules the entries
* pull in — rather than from `entryStaticClosure`, which walks emitted chunks and answers what a
* browser must download.
*
* A component a route mounts rather than one the router registers — `MobileBrowserPane` is the
* first with a pin of its own — has a closure to certify and no route to name it by. Pass it alone
* to read what it reaches on its own, or beside `app/h/_layout` to read what it adds to a page.
*
* `splitting: false` and a per-name output are required for a multi-entry build; with the defaults
* esbuild fails on two outputs claiming `dist/entry.js`.
*
* Note for anyone comparing this with a parity pin: `c1-page-closure.ts`, and the closures C2.6,
* C5.2 and C3.2 generate, derive theirs by the C1.6 method inside the mobile suite. The two are
* not the same computation, and a divergence between them is a finding rather than noise.
*/
export async function mobileWebAppModuleClosure(entryModules) {
const base = mobileWebAppBuildOptions(MOBILE_WEB_PAGE_ROUTES)
const result = await esbuild.build({
...base,
// Extensionless, so `resolveExtensions` picks the same file the bundle ships: a route with a
// `.web.tsx` sibling resolves to that one, and naming the `.tsx` path explicitly would measure
// the native switch no browser ever loads.
entryPoints: entryModules.map((entry) => entry.replace(/\.tsx?$/, '')),
splitting: false,
entryNames: '[name]',
plugins: base.plugins.filter((plugin) => plugin.name !== ROUTE_MANIFEST_PLUGIN_NAME),
write: false,
metafile: true,
logLevel: 'silent'
})
const inputs = Object.keys(result.metafile.inputs)
return {
modules: inputs,
/** Everything outside `node_modules`: this repository's own source, which a census reads. */
local: inputs.filter((input) => !input.includes('node_modules'))
}
}
export async function bundleMobileWebApp({ appDir = defaultAppDir } = {}) {
const routes = await collectMobileWebAppRoutes(appDir)
await assertRoutesCarryNoSynchronousExports(routes)
const result = await esbuild.build(mobileWebAppBuildOptions(routes))
const entryOutputPath = Object.keys(result.metafile.outputs).find(
(path) => basename(path) === `${ENTRY_CHUNK_NAME}.js`
)
if (!entryOutputPath) {
throw new Error('[build-mobile-web-app-bundle] esbuild emitted no entry script')
}
const renamed = renameOutputsByContent(result.metafile, result.outputFiles)
const entry = renamed.get(basename(entryOutputPath))
const byName = (left, right) => (left.name < right.name ? -1 : 1)
const others = [...renamed.entries()]
.filter(([emittedName]) => emittedName !== basename(entryOutputPath))
.map(([emittedName, output]) => ({ emittedName, ...output }))
// Chunks keep their new name into the served path: the entry imports them by it, and
// publicPath has already made that specifier /assets/<name>.
const chunks = others.filter(({ emittedName }) => isScriptOutput(emittedName)).sort(byName)
const images = others.filter(({ emittedName }) => !isScriptOutput(emittedName)).sort(byName)
const closure = entryStaticClosure(result.metafile, entryOutputPath)
return {
script: entry.bytes,
chunks,
images,
// Counted off the renamed bytes rather than the metafile's own sizes, which are from before
// the names inside each output grew. Only the metafile knows which import is static; see
// entryStaticClosure.
entryStaticBytes: [...closure].reduce(
(total, path) => total + (renamed.get(basename(path))?.bytes.byteLength ?? 0),
0
),
routeKeys: routes.map((route) => route.key),
routeChunks: routeChunkNames(result.metafile, routes, renamed)
}
}
/**
* The declared page routes, checked against the tree that was actually bundled.
*
* A declaration naming a screen this bundle has no module for would reach a phone as a route the
* shell opens the page for and the page then paints as Unmatched. Failing the build is the only
* place that mismatch is visible to whoever wrote the declaration.
*/
export function resolveMobileWebPageRoutes(routeKeys, declared = MOBILE_WEB_PAGE_ROUTES) {
const bundled = new Set(routeKeys.map(routePathnameFromKey).filter((path) => path !== null))
for (const route of declared) {
if (!bundled.has(route.pathname)) {
throw new Error(
`[build-mobile-web-app-bundle] declared page route ${route.pathname} has no module in the bundle`
)
}
}
return declared.map((route) => ({ pathname: route.pathname, grants: [...route.grants] }))
}
/**
* `pageRoutes` rides with `appDir`: the declarations name screens in the real route tree, so a
* caller bundling some other tree has none to check against and says so by passing its own.
*/
export async function buildMobileWebAppBundle({
appDir,
outDir = defaultOutDir,
pageRoutes = MOBILE_WEB_PAGE_ROUTES
} = {}) {
const [
desktopVersion,
protocolWindow,
{ script, chunks, images, entryStaticBytes, routeChunks, routeKeys }
] = await Promise.all([
readDesktopVersion(),
readProtocolWindow(),
bundleMobileWebApp({ appDir })
])
// Every output is already named by its own bytes, and a name is written inside whatever imports
// it, so hashedAsset here reproduces the name rather than choosing one.
const scriptAsset = hashedAsset(script, 'js')
const written = [
scriptAsset,
...[...chunks, ...images].map(({ name, bytes }) => hashedAsset(bytes, extname(name).slice(1)))
]
// Root-absolute, unlike the Phase A bootstrap's bare relative src: this document is served at
// every route depth (/h/<hostId>/tasks), where a relative href resolves against the route and
// 404s. A <base> tag would be the other fix, but the shell's CSP sets base-uri 'none'.
// type="module", because the entry is esm and reaches its routes through import(). Same-origin
// module and chunk both load under the shell's script-src 'self'; the policy is unchanged.
const html =
'<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8" />\n' +
'<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />\n' +
`<title>Orca</title>\n${MOBILE_WEB_APP_ROOT_RESET}\n</head>\n<body>\n<div id="root"></div>\n` +
`<script type="module" src="/${scriptAsset.path}"></script>\n</body>\n</html>\n`
const indexBytes = Buffer.from(html, 'utf8')
const indexAsset = {
bytes: indexBytes,
path: MOBILE_WEB_BUNDLE_ENTRYPOINT,
sha256: sha256Hex(indexBytes),
byteLength: indexBytes.byteLength,
contentType: contentTypeForExtension('html')
}
const { manifest } = await writeMobileWebBundleTree({
outDir,
written: [indexAsset, ...written],
desktopVersion,
protocolWindow,
routes: resolveMobileWebPageRoutes(routeKeys, pageRoutes)
})
return {
manifest,
outDir,
routeChunks,
routeKeys,
entryStaticBytes,
// The entry counts: it is a chunk the browser fetches, and the budget is about how many.
chunkCount: chunks.length + 1,
// Everything the routes import that is not a script, which is the rest of the asset budget.
imageCount: images.length
}
}
if (isDirectInvocation(import.meta.url, process.argv[1])) {
try {
const { manifest, outDir, routeKeys, entryStaticBytes, chunkCount } =
await buildMobileWebAppBundle()
console.log(
`[build-mobile-web-app-bundle] OK — ${String(routeKeys.length)} route(s), ` +
`${String(chunkCount)} chunk(s), ${String(entryStaticBytes)} bytes before the first route, ` +
`${String(manifest.assets.length)} asset(s), ${String(manifest.totalBytes)} bytes, ` +
`buildId ${manifest.buildId} -> ${outDir}`
)
} catch (error) {
// The route guards fail here by design, and every throw on this path already names its
// source, so a stack only buries which route and which export.
console.error(error.message)
process.exit(1)
}
}