mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
* build(mobile): split the Route A page into per-route chunks (OTA phase C, C1.5) The page bundled as one 8.16 MB script because every route was a static import. The route manifest now defers each screen behind `import()`, the build is esm with splitting on, and the document loads the entry as a module. What the browser parses before the first route can paint drops from 8.16 MB to 908 KiB; the whole page still weighs the same. Two budgets hold it: the chunk count, which catches a split running away, and the bytes the entry reaches by static import, which catches it collapsing back. The second is the one that matters, and it is measured from esbuild's metafile because only that says which import is static. The RequireContext stays synchronous, since expo-router reads keys() to build the route tree before anything renders. A lazy module cannot answer `unstable_settings` or `ErrorBoundary`, which expo-router reads off the namespace, so a test holds that no route in the subtree exports either. The render check now waits for the route's own text: the entry's mount signal lands while the route chunk is still being fetched. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read a route's synchronous exports from esbuild, not a regex `export { x as ErrorBoundary }`, `export class ErrorBoundary` and a re-export all reach the namespace without matching the declaration pattern the guard was matching, so the lazy manifest dropped the boundary and the page painted blank. A star re-export is now reported rather than read as clean. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say that the entry budget is not a per-route opt-out Measured: statically importing one route already breaks the 3 MiB bound for 5 of the 14. The hatch only works for a layout node, which is the only place expo-router reads a synchronous export from. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * build(mobile): derive the chunk ceiling from the route count 64 was three routes of headroom over the 53 chunks 14 routes measure, so C2's routes would have failed on a number measured before they existed. Four per route plus 16 tracks the measured slope; the entry-bytes bound stays the real budget. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the web entry's dead suspense boundary expo-router wraps every screen in its own, so this one never fires; all nine render checks stay green without it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin that a client-side navigation fetches the next route's chunk Goes red with splitting off: the tasks screen paints out of the entry and no new script is fetched. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name every bundle output by its bytes, not by esbuild's path hash esbuild's [hash] is over the metafile's input keys, which are paths relative to absWorkingDir, so a checkout at another depth or with node_modules as a symlink named a byte-identical chunk differently and shipped a different buildId for one commit. Outputs are now renamed leaves-first to the sha256 of their final bytes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): fail the build on a route the lazy manifest would strip The guard ran only in a test while the docstring said it failed the build. It now runs in bundleMobileWebApp and names the route and the export. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * build(mobile): derive the asset ceiling from the chunk ceiling and the images A flat 128 stopped agreeing with the chunk ceiling at 18 routes, where the asset count would have failed first and named the count instead of the split. Chunks plus images plus the document keeps the chunk ceiling the one that trips. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): split the route-manifest tests out of the bundle builder's The builder's test file passed 600 lines. The route manifest, the synthesized RequireContext and the web entry are their own subject and move together. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): give the export guard the builder's route-source loaders Without .js as jsx the guard reported a React Native .js route carrying JSX as "JSX syntax extension is not enabled" instead of reading its exports. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): assert the navigation fetches the tasks route's own chunk "some new script arrived" passed on any fetch. The builder now names the chunk each route lands in, read off the metafile, and the check asserts that exact path arrived and was not already loaded. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): resolve a route's realpath before matching it to its chunk esbuild writes metafile input keys after resolving symlinks, so every scratch route tree under /var on macOS reached no output and failed the build. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): fail the build when the asset ceiling outgrows the shell's map The derived ceiling had no upper bound, and the native shells return null for a manifest over their own 256 rather than truncating it. At 42 images the formula crosses that at 50 routes, inside what Phase C adds, so the build would stay green while the phone got nothing. The number is read from the contract through esbuild, not restated here. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): cover the two hard stops in the content-addressed naming Both throws only ran through a whole bundle before, where neither can be provoked. A cycle and a route no output claims are now asserted directly; each test goes red when its throw is removed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): exit the app-bundle build on one line, not a stack The route-export guard fails this script by design, and a raw stack put the route and the export name under twelve frames of node internals. Mirrors the verifier's exit; the message is printed as thrown because every throw on this path already names its source. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
204 lines
8.6 KiB
JavaScript
204 lines
8.6 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 './build-mobile-web-bundle.mjs'
|
|
import { assertNoCarriageReturnsInSource } from './verify-mobile-web-bundle.mjs'
|
|
import { assertMobileWebBundleBuilt } from './verify-packaged-mobile-web-bundle.cjs'
|
|
|
|
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
|
|
const defaultBundleDir = join(projectDir, 'out', 'mobile-web-app')
|
|
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 88 chunks, and 88 + 42 + 1 is 131, 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.
|
|
*/
|
|
export const MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES = 9 * 1024 * 1024
|
|
|
|
/**
|
|
* How many scripts the page may be cut into, for a given number of routes. A chunk is emitted per
|
|
* distinct set of importers rather than per route, so the count is combinatorial in what the
|
|
* routes share: 8 routes measure 23 chunks, 10 measure 40, 12 measure 47, 14 measure 53, about
|
|
* three more per route at the top. Four per route with a flat 16 leaves the next few routes room,
|
|
* so a route added in C2 fails on its own weight and not on a number measured before it existed.
|
|
*
|
|
* 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) {
|
|
return 4 * routeCount + 16
|
|
}
|
|
|
|
/**
|
|
* 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 0.89 MiB split — 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. Importing one route statically already breaks this bound
|
|
* for 5 of the 14: session at 7.16 MiB, tasks 5.91, source-control 5.78, review 5.77,
|
|
* files/preview 5.21. 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 2.22 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 4r + 16 + images + 1 past it would pass this build
|
|
* and fail on the device with nothing to read. At today's 42 images that is 50 routes, inside
|
|
* what Phase C adds, which is why this is a build failure and not a comment.
|
|
*/
|
|
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-app') })
|
|
} 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)
|
|
|
|
if (manifest.totalBytes > MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES) {
|
|
fail(
|
|
`bundle is ${String(manifest.totalBytes)} 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)}/${String(MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES)} bytes, ` +
|
|
`reproducible buildId ${manifest.buildId}`
|
|
)
|
|
} catch (error) {
|
|
console.error(`[verify-mobile-web-app-bundle] ${error.message}`)
|
|
process.exit(1)
|
|
}
|
|
}
|