mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 17:17:38 +00:00
* test(e2e): add a paired phone client helper and phone-mirror baseline specs
Later phone-mirror slices need one oracle that drives a real mobile-scoped device with the
phone's own capabilities against a serve host or a paired desktop. Pins #26022 across a serve
restart and today's gap: a desktop's phone does not list the desktop's server workspaces.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): show SSH host health on the workspace row host badge
Rows on an SSH host the desktop cannot reach looked healthy on the phone. The badge now
adds the desktop sidebar's own word ("devbox · Disconnected"), derived by the same shared
function from the connection status ssh.listTargetSummaries already sends; old desktops keep today's badge.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(e2e): compose the phone-mirror topology and check the cold snapshot
Slices need one composer for host, desktop and phone with a single teardown, so specs stop
copying setup. The serve-restart spec now reads the first tab-stream snapshot after the cold
start, which is what the phone builds its tab strip from.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): name an unhealthy SSH host even in a single-host list
The desktop card shows SSH status on any host count, so the phone now reads target summaries
whenever a repo is on SSH and badges an unhealthy host alone too. Labels and health share one
target-to-host keying, and a hook-level test pins the reply-to-row wiring.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(e2e): keep phone-mirror teardown errors from masking test failures
Teardown failures become a test attachment instead of replacing the real failure, and the
cold-snapshot poll reports stream errors together with the PTY list.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): clear stale SSH host badges and announce them without the dot
A catalog that no longer spans hosts or holds an SSH repo now clears the health and label maps,
so a host that left cannot keep badging rows. The badge gets a screen-reader label ("devbox,
Disconnected") instead of reading the middle dot.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(runtime): let a paired desktop register its phones as delegated mobile devices
A desktop paired to this runtime can now call pairing.delegatedMobileDevice.sync to make each of
its phones an ordinary mobile-scope device it owns, so the phone mirrors that host through the
desktop. Children are never pending, inherit the parent's reach, and die with the parent's grant.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(runtime): move the mobile device revoke primitive below dispatch
Dispatch binds the caller-bound delegated-device context, so the revoke path it needs now lives in
a layer under it as a pure move; this drops the abstract method seam. No behaviour change.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(runtime): keep a runtime grant until its delegated phones are revoked
A failed child cleanup now leaves the parent in place so a retry can still find the children, and
revokeRuntimeAccess lives beside the revoke it cascades to. Adds restart, rename and bounds coverage,
shares credential minting, and drops the timing-dependent census.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(runtime): fail a delegated sync whose dropped phone cannot be revoked
Matches the parent revoke: a child whose cleanup could not be saved is kept and the sync errors
before minting anything, so the desktop retries instead of believing the server matches its list.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): relay a paired phone's server-workspace requests through the desktop
A phone mirrors its desktop, including workspaces on the desktop's configured servers. The desktop
now forwards a request that names `executionHost: runtime:<env>` to that server over one socket per
(phone, server), signed in as the phone's delegated device; replies pass through untouched except
stream ids. Hidden until the phone client ships: the relay capability is not advertised yet.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): type the single-host health fixtures inline so the test tsc passes
Fixtures hoisted into consts widened their health maps to string, which the now-required
hostHealthById rejects under the tests-typecheck ratchet; passing them inline keeps the union.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): state the desktop relay contract in its entry module
main dropped docs/reference, so the relay's contract lives beside the code. Also pins that a
server whose delegated sync fails (e.g. delegated_device_revoke_failed) is unavailable until a
later request syncs it again.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the relay's configured-server adapter
The production hosts adapter was only typechecked; this pins that it resolves configured servers
by exact id only (never a name or a removed server), fences by pairing revision and runtime
identity, and stops reporting retirements once unsubscribed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record the repo-metadata goldens for SSH host health
The settled metadata state now carries hostHealthById, and an SSH-only catalog now reads the
target summaries so a lone unhealthy host can be badged. Scoped re-record via rpc-recording.mts.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): tighten the desktop relay after elegance review
Reuses the shared E2EE socket opener, folds relay state into fewer fields, never sends a request
whose link ended while opening, passes failure codes through, relays only this desktop's own
phones, and drops the unused host-state reader until S2 needs one.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): bound relay frames and stop sticky relay verdicts
Restores the 8 MiB frame cap on every remote runtime socket, re-checks an update-needed server on
each new link so an in-place upgrade relays, answers a phone the server just declined without
syncing again, and ends a link whose mirrored capability update cannot be queued.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): run targeted desktop-owned calls on the desktop
The phone tags every call on a workspace screen with its execution host, so a
paired-desktop method carrying `runtime:<env>` now runs locally instead of
failing 'forbidden'. The Mac stays the only reader of the routing table.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): list the desktop's configured servers and their last workspace rows for the phone
The phone needs the same host list the desktop shows, including servers that are offline or
too old to relay to. mobileRelay.hosts.list/worktrees answer from the desktop's own status and
its own worktree.ps fetches, cached per server and fenced by pairing, never from relayed replies.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): list server rows through one desktop fetch, with no side effect on listing
The phone now always lists a server's rows through mobileRelay.hosts.worktrees, so the
desktop's own fetch is both the live source and the cache, and stamping stays in main.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): fetch a server's full workspace list within a bound, and forget removed servers
The desktop now asks for the phone's full-catalog limit, answers within 5s with the last rows
when a server hangs, drops a server's rows when it is removed or re-paired, and checks the
server's identity after the fetch so a reply from before a re-pair is never served.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): name a server workspace's host on the phone's calls
A phone reaches a server's workspace through its desktop only if each call names the host; the
direct, cloud-relay and shell-bridge transports now carry `executionHost`, streams keep it for
replay and unsubscribe, and an older shell (no `executionHost` grant) refuses rather than strips it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): list managed servers as the desktop sidebar does, and hide rows mid-delete
A server Orca manages on an SSH host now lists once under that host's name, or not at all while
the host is reached over the SSH relay, using the sidebar's own folding. Rows the server is
deleting are left out at reply time, as a phone asking the server itself would see.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): keep a disconnected server's last rows, and never join a re-paired server's fetch
Retirement also fires on a manual disconnect or managed-server update, which wrongly emptied
the phone's list; rows are now pruned only once a server is no longer configured. Fetches are
shared per server identity, so a poll after a re-pair asks the new server right away.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): list the paired desktop's server workspaces on the phone
Fixes the phone missing a desktop's `orca serve` workspaces: rows come from the desktop's
mobileRelay.hosts.* with each server's health ("Update needed", "Disconnected"), open through the
relay, follow the desktop's hidden hosts, and the desktop now advertises the relay capability.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin that a desktop failing the hosts list leaves only its own rows
The page asks a relaying desktop for its servers; a refusal such as method_not_found must leave
today's list with no server rows and no catalog error.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): mirror the desktop's shown hosts exactly, one host included
Jinwoo's call: the phone follows visibleWorkspaceHostIds as the desktop sidebar does, so a
one-host list narrows the phone too instead of being read as a single-host focus.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): scope a server workspace at its route, never the desktop
Every screen under a server workspace's route now reads one context and
gets that server's client and a router that keeps pushes on it, refusing
to fall back to the desktop; pins write to the row's owner like the Mac.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): never open an unreachable server workspace, and prove the relay in e2e
A server workspace the phone can't reach now gets one explicit screen at its route
and no row tap or action-sheet entry opens it; native routes read the route's host,
a census pins every workspace route, and the e2e reads files and git on the server.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): gate a server workspace on that server's own features
A targeted status.get now relays to the server, so a server workspace's route
provides that server's status to every screen under it and its action sheet,
while whether the phone can reach the server stays the desktop's answer.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(ci): run the remote-runtime WebSocket opener test on Node
Under the bun vitest project, `ws` resolves to Bun's built-in client, which
ignores maxPayload, so the frame-cap test hung. The opener only ships on
Node/Electron, so run its test in the Node runtime project like its siblings.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): give the file preview the workspace router, back to 128 page assets
The preview was the one workspace screen on the plain router, so the scoped router
was reached by a route set of its own and esbuild split it into an extra chunk
(129 assets); every workspace screen now uses it and it shares the route's chunk.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile-relay): derive the phone allowlist from the routing table
Adding a phone method now takes one edit (its route). A server-targeted call on a
legacy non-E2EE socket is refused instead of running locally, and only
execution-host methods enter the relay.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile-relay): drop unread freshness fields from server workspace lists
The phone reads only the rows, so stale, fetchedAt, totalCount and truncated
were dead wire weight. The desktop still serves a server's last rows while
the server is unreachable.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): read only the rows of a server's workspace list
The desktop no longer sends a freshness flag, so the phone's schema drops it.
The relay capability list names the delegated-devices constant instead of
repeating its literal, which also retires a now-circular type check.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): show a server workspace as disconnected while its server is down
The phone read the desktop's connection state for a server workspace, so a stopped
server kept a green dot and "Loading tabs" spun forever. The workspace route now
narrows that state by the server's health from mobileRelay.hosts.list, reusing the
existing disconnected session UI and refetching tabs when the server returns.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): share one desktop server-list feed between the host list and workspaces
A server workspace polled mobileRelay.hosts.list beside the host list's own read, so
the phone could ask the desktop twice per tick and the row badge and the screen could
disagree. One feed per desktop client now owns that read; both readers join it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(e2e): read a server's reachability from the hosts list, not a dropped stale flag
eff410fe30 dropped `stale` from mobileRelay.hosts.worktrees, so the mirror spec's
schema never parsed and its first poll timed out. It now waits on the server's
relay/health from mobileRelay.hosts.list, the field the phone itself reads.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* build(mobile): re-measure the page's script sweep for server workspaces
Main already sat on its 78-script ceiling (re-swept: 78, table: 74); the workspace
route's shared modules split importer sets three ways more, reaching 81. Re-swept per
route prefix on this head, as #24257 did for its feature; the spread is now 1 to 11.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): export the server-list feed's React reader beside the feed
useRelayHosts moves from workspace-server.ts into relay-hosts-feed.ts, so every reader (workspace screens now, the new-workspace sheet next) subscribes through the feed's own module. No behaviour change.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): keep polling the server list and read a server's reachability from relay
A hosts-list read slower than the poll stopped the feed for good, since the next tick was
scheduled when the read started; it is now scheduled from the finish. A server workspace now
reads connected only when the desktop relays to it, so a server with no status yet is not shown up.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): show a reached server as connecting and a too-old one as disconnected
A server the desktop has reached but whose status is not answered yet is moments from relaying,
so it reads connecting; a server too old to relay to never will, so it reads disconnected.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(shared): move the account capabilities into their own group module
Main's merge put protocol-version.ts at 301 lines, over the 300 cap. The account group moves out
like the relay and skill groups did, same strings in the same order; the page's script sweep is
re-measured because the split changed its chunking (81 to 79 at 16 routes).
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): name a server workspace through the request envelope
The server name now rides the generic request envelope from #27815 instead of
its own executionHost option, bridge grant and per-transport plumbing. Server
rows show only where the transport carries an envelope (native, or a shell
that grants rpcEnvelope), so an older app keeps today's list.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the server-call wire and the no-envelope deep link
A server workspace call still reaches the desktop as the same top-level
executionHost frame, on the direct and relay wires, and a deep link into a
server workspace under an app without rpcEnvelope shows the unreachable screen.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): read a relay stream's envelope slot in sorted key order
Equal envelopes name one cleanup slot whatever their key order, the scoped view
passes its client's envelope answer through as it does the generation, and the
direct wire harness is shared instead of rebuilt.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): a phone's new chat in a server workspace follows the desktop's chat setting
A relayed agent.launch ran on the server, which picked chat vs terminal from its
own setting. The desktop relay now stamps its own default as nativeChatDefault;
new servers use it in place of their setting (feasibility still decides), older
servers strip it. Outside the replay fingerprint.
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor(mobile): move the relay's delegated phone grant cache into its own module
Keeps mobile-desktop-relay.ts under the line limit once #27749's page relay lands on it.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(mobile): show the early terminal tab from the relaying desktop's chat default
The early-tab preflight read the server's own chat setting while the launch
used the desktop's stamped nativeChatDefault, so on a windowed server with
chats off a relayed chat launch flashed a terminal tab. Both now read the
settings through readAgentLaunchModeSettings(runtime, nativeChatDefault).
---------
Co-authored-by: Claude <noreply@anthropic.com>
287 lines
13 KiB
JavaScript
287 lines
13 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
|
|
|
|
/**
|
|
* 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` and `web` add one script each, `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 16 routes.
|
|
*
|
|
* 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', 7],
|
|
['./h/[hostId]/accounts.tsx', 10],
|
|
['./h/[hostId]/agent-history/[worktreeId].tsx', 15],
|
|
['./h/[hostId]/edit.tsx', 19],
|
|
['./h/[hostId]/files/[worktreeId].tsx', 23],
|
|
['./h/[hostId]/files/preview/[worktreeId].tsx', 30],
|
|
['./h/[hostId]/history/[worktreeId].tsx', 33],
|
|
['./h/[hostId]/index.tsx', 38],
|
|
['./h/[hostId]/pr/[worktreeId].tsx', 39],
|
|
['./h/[hostId]/review/[worktreeId].tsx', 48],
|
|
['./h/[hostId]/session/[worktreeId].tsx', 58],
|
|
['./h/[hostId]/source-control/[worktreeId].tsx', 63],
|
|
['./h/[hostId]/tasks.tsx', 74],
|
|
['./h/[hostId]/web.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)
|
|
|
|
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)
|
|
}
|
|
}
|