@ultimat3/cli 20.2.1 → 21.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CLAUDE.md +69 -1
  2. package/package.json +30 -30
  3. package/src/app-env.ts +2 -2
  4. package/src/budgets.ts +45 -12
  5. package/src/build-errors.ts +54 -0
  6. package/src/cdp-browser.ts +21 -27
  7. package/src/cdp-connection.ts +66 -30
  8. package/src/cdp-e2e-page.ts +84 -113
  9. package/src/cdp-e2e-session.ts +199 -0
  10. package/src/cdp-launch.ts +95 -41
  11. package/src/cdp-offline-script.ts +73 -0
  12. package/src/cdp-pipe.ts +77 -0
  13. package/src/cmd-deploy.ts +7 -0
  14. package/src/cmd-dev.ts +23 -86
  15. package/src/dev-live-feed.ts +2 -0
  16. package/src/dev-render.ts +119 -20
  17. package/src/dev-route-table.ts +119 -0
  18. package/src/dev-services.ts +4 -1
  19. package/src/dev-sync.ts +5 -3
  20. package/src/e2e-app.ts +103 -0
  21. package/src/e2e-browser-handle.ts +55 -0
  22. package/src/e2e-driver.ts +32 -12
  23. package/src/e2e-errors.ts +14 -0
  24. package/src/e2e-page.ts +5 -2
  25. package/src/e2e-preload.ts +64 -0
  26. package/src/e2e-probe.ts +23 -0
  27. package/src/e2e-spawn.ts +169 -0
  28. package/src/error-codes.ts +7 -0
  29. package/src/error-unthrown.ts +130 -0
  30. package/src/errors.ts +8 -29
  31. package/src/index.ts +18 -4
  32. package/src/island-bundle.ts +33 -11
  33. package/src/island-realtime.ts +91 -0
  34. package/src/island-verdict.ts +1 -1
  35. package/src/live-routes.ts +82 -42
  36. package/src/mcp-errors.ts +3 -0
  37. package/src/page-sync.ts +54 -0
  38. package/src/realtime-browser-probe-fixture.ts +2 -2
  39. package/src/serve.ts +9 -0
  40. package/src/sw-artifacts.ts +13 -3
  41. package/src/sync-url.ts +31 -0
  42. package/src/templates/resource-form-island.ts +30 -21
  43. package/src/templates/route.ts +3 -0
  44. package/src/templates/scaffold-container.ts +18 -3
  45. package/src/templates/scaffold-env.ts +6 -0
  46. package/src/verify-e2e.ts +38 -0
  47. package/src/verify-run.ts +105 -50
  48. package/src/verify-tests.ts +21 -4
  49. package/src/worker-bundle.ts +192 -0
package/CLAUDE.md CHANGED
@@ -414,7 +414,11 @@ to know about everything — so the join is here, and it is the same rule
414
414
  | `cdp-browser.ts` | the two doors: `openE2eBrowserIfAvailable()` (undefined when there is no browser) and `openE2eBrowser()` (refuses by name), and the close that undoes both halves |
415
415
  | `cdp-launch.ts` | which Chrome, and starting it — the candidate list, the flags, and the endpoint read off its stderr |
416
416
  | `cdp-connection.ts` | CDP over Bun's own `WebSocket`: request framing, reply correlation by `id`, one-shot event waiters, the per-call deadline |
417
- | `cdp-e2e-page.ts` | `E2eBrowserPage`'s five methods over an attached, flattened session |
417
+ | `cdp-e2e-session.ts` | the BROWSER half, `E2eSession`: every target auto-attached at browser level and PAUSED until its Network domain is on (a SharedWorker opens its socket at start-up); `newTab()`, `addInitScript()`, `offline()` for every page and worker including later ones, `setCookie()`, and the log of every WebSocket (`sockets()`) and request (`requests()`) in any realm. A page's own workers attach under it UNPAUSED — paused there, the emitted service worker never took control (measured, `e2e/service-worker.e2e.test.ts`) |
418
+ | `cdp-e2e-page.ts` | one TAB, `E2eTab`: `E2eBrowserPage`'s five methods plus `reload`, `waitFor`, `indexedDbNames`, `close`. `offline()` forwards to the session — the switch is browser-wide |
419
+ | `e2e-app.ts` | `startE2eApp({ root, mode: 'dev' \| 'serve', seed })`: reset + seed + spawn on a THROWAWAY `ULTIMATE_STATE_DIR` (database, disk, dev lock) and a free web AND metrics port, `/readyz`-gated, `stop()` removes the directory. Never the developer's `.x/pgdata`; two apps and an `x dev` coexist. Measured: `examples/dummy` up in ~12 s |
420
+ | `e2e-preload.ts` + `e2e-browser-handle.ts` | the `e2e` step's own preload: with `ULTIMATE_E2E_ROOT` set (`E2E_ROOT_ENV`, by `verify-e2e.ts`) it spawns that app on a throwaway database, opens ONE browser, `installE2eDriver`s its first tab, and publishes the whole `E2eBrowser` for `e2eBrowser()` and the app for `e2eApp()`; `e2eBaseUrl()` is a function answering the spawned app's origin, never an env var |
421
+ | `verify-e2e.ts` | `withE2eApp`: in an APP on a machine with Chrome, the `e2e` step spawns the app and runs the suite with `--preload e2e-preload.ts`. No browser or not an app: the suite runs exactly as before. Until this, no app ever installed a driver, so every `e2eTest` skipped and every `page` fixture refused |
418
422
  | `cdp-errors.ts` | one constructor per way the browser half refuses |
419
423
 
420
424
  **Absent by default, and that is a requirement rather than a state.** Nothing here runs until
@@ -1249,6 +1253,70 @@ is a fact about the deployment, not an app config choice, and one image runs beh
1249
1253
  one cluster and behind nothing on a laptop. Without it `ctx.ip` is the ingress's socket address on
1250
1254
  every request, so the limiter keys the whole fleet's anonymous traffic into one bucket.
1251
1255
 
1256
+ ### The document carries the page's client scope — on private documents only
1257
+
1258
+ `dev-render.ts` puts `@ultimat3/render`'s `clientScopeTag(clientScopeOf(ctx.actor))` (`@ultimat3/auth`) in the head of a
1259
+ gated `ssr` page and every `stream` — the documents whose own headers say `private`
1260
+ (`documentCarriesScope`). A shareable document carries none, because a CDN would hand one visitor's
1261
+ scope to the next. `dev-render-scope.test.ts` drives `static`, `isr`, `ssr` (gated and not) and `stream` over the real pipeline. There is
1262
+ no island bootstrap: the page client is created lazily by the first transport call or realtime hook
1263
+ (plan 101, decided 2026-09-22; `island-bundle.ts`'s header records what an eager one cost).
1264
+
1265
+ ### The page's sync target is the framework's, and so is the worker that hosts the socket
1266
+
1267
+ `page-sync.ts` is ONE call `cmd-dev.ts` and `serve.ts` both make (plan 101 slice 11, `As of
1268
+ 2026-09-22`), so the two boots cannot serve different targets:
1269
+
1270
+ | File | Job |
1271
+ |---|---|
1272
+ | `sync-url.ts` | `syncUrlFrom(env)`: `SYNC_URL` verbatim (must be `ws:`/`wss:`, else `X_CONFIG_INVALID` at boot), otherwise the same-origin `SYNC_PATH` — `/_x/sync`, which `x dev`, a combined-role container and the chart's ingress all serve. Never a derived neighbouring port: behind an ingress that is a URL nothing publishes. No app owns a `sync-url.ts` any more |
1273
+ | `worker-bundle.ts` | `@ultimat3/realtime/sync-worker`, resolved from the APP root, built as ONE classic-script (`iife`) browser bundle, source-addressed with `island-bundle.ts`'s `graphHash`, served `immutable` at `/_x/sync-worker/<hash>.js`. `undefined` when the app cannot resolve it — no realtime means no socket to share, and the tab-side host falls back in-page. Built at boot only: it is framework code, and a new URL per deploy is what keeps an old tab on the worker it started with. The SAME builder makes `@ultimat3/realtime/boot` at `/_x/page-boot/<hash>.js`: the disk restore and the outbox, once per page instead of in every island (a `useRecord` island 34.8 → 19.4 kB, `examples/dummy`'s `likes-badge` 48.6 → 32.9 kB) |
1274
+ | `dev-route-table.ts` | `x dev`'s route table in mount order, and the theme, error-page styles and MCP path it resolved on the way — split from `cmd-dev.ts` at its 500-line ceiling. It is where `pageSync` is called on the dev side |
1275
+ | `page-sync.ts` → `persisted` | `@ultimat3/entity`'s `persistedRecordTypes`, read per render, passed as `DocumentOptions.persisted` by both boots: render's `clientPersistTags` puts `ultimate-persist` beside the scope tag and ONLY there — persistence is per principal, so a shareable document names none |
1276
+ | `dev-render.ts` | `DocumentOptions.sync` → render's `clientSyncTags`: `ultimate-sync`, `ultimate-build`, `ultimate-sync-worker` on EVERY document (principal-free, unlike the scope tag). The page boot's `<script defer>` (render's `clientBootTags`) is rendered after the body, only when the document carries the SCOPE tag (restoring and replaying are per principal) AND an island this render emitted reaches `@ultimat3/realtime` (`island-realtime.ts` records the answer every island build computes; a page whose islands never touch a record — `examples/dummy`'s settings — pays nothing). `dev-render-scope.test.ts`. Both framework scripts resolve realtime from the root, then from `apps/*` — a workspace app's realtime is `apps/web`'s dependency, and resolving from the root alone silently built no worker and no boot for `examples/dummy`. The static export carries none — it has no sync node |
1277
+
1278
+ ### Realtime is installed FOR the author, only where it is used
1279
+
1280
+ `island-realtime.ts` (plan 101 slice 14, `As of 2026-09-22`): an island whose own import graph
1281
+ reaches `@ultimat3/realtime` — walked with `live-routes.ts`'s `firstInGraph`, relative specifiers
1282
+ only, the transpiler reading re-exports and erasing `import type` — is built from a virtual entry
1283
+ that calls `installRealtime({ signal: createSignal })` with that bundle's solid-js before the
1284
+ island's module body runs. Every other island is built from its own file, byte for byte. +103 B on
1285
+ a Solid island reading `useConnection`, +0 elsewhere. A PACKAGE importing realtime for an island is
1286
+ the walk's blind spot, and a hook there throws `X_REALTIME_UNINSTALLED` by name.
1287
+
1288
+ ### `x verify` runs the static steps beside the serial suites
1289
+
1290
+ `verify-run.ts`'s `BESIDE_SERIAL_SUITES` — `lint`, `boundaries`, `filesize`, `package-shape`,
1291
+ `errors` — start when `live` does and are joined before the first step after `eval` (DX ledger #14).
1292
+ They read the tree and write nothing a later step reads; `typecheck` stays first and alone, `unit`
1293
+ alone. The table, `--json` and every step name keep the declared order; `data.durationMs` is WALL
1294
+ time now, not the sum of step times. Measured locally on the framework root, 12 cores, one run
1295
+ each, `As of 2026-09-22`: **395 s → 270 s** wall. `verify-run-overlap.test.ts` pins both halves.
1296
+
1297
+ ### A browser-only read in a module no island imports is `X_LIVE_ROUTE_NO_ISLAND`
1298
+
1299
+ `live-routes.ts`, `As of 2026-09-22`: the rule walks each route's SERVER graph (the page's relative
1300
+ imports — an island is a `src` string, never an import) and reports every module there that reads
1301
+ `useQuery`, `useConnection`, `useMutation`, `useMutationQueue`, `useRecord`, `useChannel` **or
1302
+ `hasPageSocket`**, unless some island's own graph imports that module too. Once per module, however
1303
+ many routes reach it. `hasPageSocket()` was an EXEMPTION until this date, on the argument that a
1304
+ module asking had written its fallback; on the server it answers false every time, so a guarded
1305
+ module no browser runs renders nothing forever — `examples/dummy`'s update banner, in a layout no
1306
+ island imported, never showed "A new version is ready.". The fix line starts "move it into an
1307
+ island".
1308
+
1309
+ ### A registered code nothing throws must say so
1310
+
1311
+ `error-unthrown.ts`'s `checkErrorCodesThrown(root, page)` (DX ledger #9, `X_ERROR_CODE_UNTHROWN`):
1312
+ a code a package registry names, used nowhere else in shipped `packages/*/src` — not at a `code:`,
1313
+ not as a fallback literal, not through a table member something reads — is reported unless its
1314
+ reference row says `thrown by nothing` / `not thrown` or sits under `## Reserved codes`. The fix is
1315
+ the row, never the registration: a shipped code is stable forever. A host check, like the other
1316
+ reference-page rules — in a generated app every framework code would read as unthrown. Its limit:
1317
+ an exported class holding `code: 'X_…'` counts as a thrower whether or not anything constructs it,
1318
+ which is why `X_RPC_FAILED`'s `RpcFailedError` is not reported.
1319
+
1252
1320
  ### `island-bundle.ts` is the bundler half of `hydrate`
1253
1321
 
1254
1322
  `@ultimat3/render` shipped `island()`, the collector, `emitIslandAttributes`, `hydrateRuntime`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "20.2.1",
3
+ "version": "21.0.0",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,35 +36,35 @@
36
36
  "dev": "bun run src/bin.ts dev"
37
37
  },
38
38
  "dependencies": {
39
- "@babel/core": "^7.28.4",
40
- "@ultimat3/action": "20.2.1",
41
- "@ultimat3/admin": "20.2.1",
42
- "@ultimat3/ai": "20.2.1",
43
- "@ultimat3/auth": "20.2.1",
44
- "@ultimat3/cache": "20.2.1",
45
- "@ultimat3/core": "20.2.1",
46
- "@ultimat3/db": "20.2.1",
47
- "@ultimat3/entity": "20.2.1",
48
- "@ultimat3/flags": "20.2.1",
49
- "@ultimat3/http": "20.2.1",
50
- "@ultimat3/i18n": "20.2.1",
51
- "@ultimat3/jobs": "20.2.1",
52
- "@ultimat3/mail": "20.2.1",
53
- "@ultimat3/manifest": "20.2.1",
54
- "@ultimat3/mcp": "20.2.1",
55
- "@ultimat3/money": "20.2.1",
56
- "@ultimat3/notify": "20.2.1",
57
- "@ultimat3/policy": "20.2.1",
58
- "@ultimat3/pwa": "20.2.1",
59
- "@ultimat3/query": "20.2.1",
60
- "@ultimat3/realtime": "20.2.1",
61
- "@ultimat3/render": "20.2.1",
62
- "@ultimat3/schema": "20.2.1",
63
- "@ultimat3/scraping": "20.2.1",
64
- "@ultimat3/seo": "20.2.1",
65
- "@ultimat3/storage": "20.2.1",
66
- "@ultimat3/testing": "20.2.1",
67
- "@ultimat3/time": "20.2.1",
39
+ "@babel/core": "^8.0.1",
40
+ "@ultimat3/action": "21.0.0",
41
+ "@ultimat3/admin": "21.0.0",
42
+ "@ultimat3/ai": "21.0.0",
43
+ "@ultimat3/auth": "21.0.0",
44
+ "@ultimat3/cache": "21.0.0",
45
+ "@ultimat3/core": "21.0.0",
46
+ "@ultimat3/db": "21.0.0",
47
+ "@ultimat3/entity": "21.0.0",
48
+ "@ultimat3/flags": "21.0.0",
49
+ "@ultimat3/http": "21.0.0",
50
+ "@ultimat3/i18n": "21.0.0",
51
+ "@ultimat3/jobs": "21.0.0",
52
+ "@ultimat3/mail": "21.0.0",
53
+ "@ultimat3/manifest": "21.0.0",
54
+ "@ultimat3/mcp": "21.0.0",
55
+ "@ultimat3/money": "21.0.0",
56
+ "@ultimat3/notify": "21.0.0",
57
+ "@ultimat3/policy": "21.0.0",
58
+ "@ultimat3/pwa": "21.0.0",
59
+ "@ultimat3/query": "21.0.0",
60
+ "@ultimat3/realtime": "21.0.0",
61
+ "@ultimat3/render": "21.0.0",
62
+ "@ultimat3/schema": "21.0.0",
63
+ "@ultimat3/scraping": "21.0.0",
64
+ "@ultimat3/seo": "21.0.0",
65
+ "@ultimat3/storage": "21.0.0",
66
+ "@ultimat3/testing": "21.0.0",
67
+ "@ultimat3/time": "21.0.0",
68
68
  "babel-preset-solid": "^1.9.15"
69
69
  }
70
70
  }
package/src/app-env.ts CHANGED
@@ -2,9 +2,9 @@
2
2
  // `app.config.ts`, the `.env.example` projected from it, and the drift between the two. One
3
3
  // declaration, both files (axiom 2) — nothing here holds a second list of variable names.
4
4
 
5
- // Bun ships no equivalent: `existsSync` answers whether this root is an app, `join` builds the
6
- // host-separator path to the two files this module reads.
5
+ // why: Bun ships no synchronous exists; `existsSync` answers whether this root is an app.
7
6
  import { existsSync } from 'node:fs';
7
+ // why: Bun exposes no path API — the two files this module reads are joined to the app root.
8
8
  import { join } from 'node:path';
9
9
  import type { EnvSchema, EnvVarDecl } from '@ultimat3/core';
10
10
  import {
package/src/budgets.ts CHANGED
@@ -48,8 +48,21 @@ export interface RouteStats {
48
48
 
49
49
  export interface BuildStats {
50
50
  readonly routes: readonly RouteStats[];
51
+ /** The measurement rules that wrote the file — `BUILD_STATS_RULES` at the time. */
52
+ readonly measuredBy?: number;
53
+ /** Set by `readBuildStats` on a file an earlier rule wrote: there are no numbers to read. */
54
+ readonly stale?: true;
51
55
  }
52
56
 
57
+ /**
58
+ * The version of the rules `measureDocumentJs` measures by. BUMP IT whenever what is charged
59
+ * changes — a script exempted, a kind of tag excluded — so every stats file written under the old
60
+ * rule stops being read as a measurement. `.x/` survives across framework upgrades, and
61
+ * `examples/dummy` was charged 250 B for `/x-sw-register.js` by a file written before
62
+ * `FRAMEWORK_SCRIPTS` exempted it. `2`: that exemption and the page-boot decision (ledger #28).
63
+ */
64
+ export const BUILD_STATS_RULES = 2;
65
+
53
66
  const chainOf = (stats: RouteStats): string =>
54
67
  stats.heaviestChain === undefined ? 'unknown import chain' : stats.heaviestChain.join(' -> ');
55
68
 
@@ -78,7 +91,16 @@ function declaredBudgets(js: number | null, lcp: number | undefined): string {
78
91
  * second build's. Reporting the first as the second is what sends a reader to re-run a build that
79
92
  * already did everything it was going to do.
80
93
  */
81
- function unmeasuredFinding(url: string, declared: string, built: boolean): Finding {
94
+ function unmeasuredFinding(url: string, declared: string, built: boolean, stale = false): Finding {
95
+ if (stale) {
96
+ return {
97
+ code: 'X_BUDGET_UNMEASURED',
98
+ cause: `${url} declares a ${declared} budget and ${BUILD_STATS_FILE} was written by an earlier measurement rule than this gate's (v${String(BUILD_STATS_RULES)}), so its numbers are not this gate's to read`,
99
+ fix: 'x build --target static --json && x verify --json',
100
+ docs: ERROR_DOCS_URL,
101
+ at: url,
102
+ };
103
+ }
82
104
  return {
83
105
  code: 'X_BUDGET_UNMEASURED',
84
106
  cause: built
@@ -153,7 +175,12 @@ export function checkBudgets(
153
175
  if (js !== null || lcp !== undefined) {
154
176
  findings.push(
155
177
  ownCodeFinding(route.url, unmeasured) ??
156
- unmeasuredFinding(route.url, declaredBudgets(js, lcp), stats !== undefined),
178
+ unmeasuredFinding(
179
+ route.url,
180
+ declaredBudgets(js, lcp),
181
+ stats !== undefined,
182
+ stats?.stale === true,
183
+ ),
157
184
  );
158
185
  }
159
186
  continue;
@@ -183,7 +210,9 @@ export function checkBudgets(
183
210
  export async function readBuildStats(root: string): Promise<BuildStats | undefined> {
184
211
  const path = join(root, BUILD_STATS_FILE);
185
212
  if (!existsSync(path)) return undefined;
186
- return (await Bun.file(path).json()) as BuildStats;
213
+ const read = (await Bun.file(path).json()) as BuildStats;
214
+ // A file the current rules did not write carries no numbers this gate may read.
215
+ return read.measuredBy === BUILD_STATS_RULES ? read : { routes: [], stale: true };
187
216
  }
188
217
 
189
218
  const SCRIPT_TAG = /<script(?<attrs>[^>]*)>(?<body>[\s\S]*?)<\/script>/g;
@@ -242,14 +271,17 @@ export interface MeasuredJs {
242
271
  * and the next build measured the one before it and recorded 250. Same commit, green then red,
243
272
  * decided by whether anything had cleaned the output directory.
244
273
  *
245
- * ENUMERATED, and it is one entry: `serviceWorkerHead` is the only `<script src>` the framework
246
- * puts in a prerendered document — measured on a fresh scaffold plus `x g island` and
247
- * `x g route`, where `<script src="/x-sw-register.js" defer>` is the only script tag across every
248
- * emitted page. Two framework scripts are deliberately NOT here. `render/src/hydrate.ts`'s inline
249
- * module runtime is charged, because it exists only when the page ships an island — it is the
250
- * cost of the app's own interactivity, and a page with a `0kb` budget has none. `island-props.ts`'
251
- * `<script type="application/json">` is already excluded as data, by `carriesJson`. A third one
252
- * joins this set by a decision, here, with the same argument.
274
+ * ENUMERATED, and it is one entry — the service-worker register is the only framework
275
+ * `<script src>` EXEMPTED, not the only one emitted. Three framework scripts are deliberately
276
+ * CHARGED. The page boot (plan 101: the page client, the principal fence, the sync target) is
277
+ * framework-emitted too, and charged by decision (DX ledger #28, 2026-09-22): it ships only on a
278
+ * page that hydrates something, so it is part of the interactivity the app opted into, and
279
+ * `examples/dummy`'s route budgets already include it. `render/src/hydrate.ts`'s inline module
280
+ * runtime is charged for the same reason — it exists only when the page ships an island, and a
281
+ * page with a `0kb` budget has none. `island-props.ts`' `<script type="application/json">` is
282
+ * excluded as data, by `carriesJson`, not by this set. A further entry joins this set by a
283
+ * decision, here, with the register's argument: every document carries it and no author can
284
+ * remove it.
253
285
  */
254
286
  export const FRAMEWORK_SCRIPTS: ReadonlySet<string> = new Set([SW_REGISTER_PATH]);
255
287
 
@@ -335,6 +367,7 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
335
367
  */
336
368
  export async function writeBuildStats(root: string, stats: BuildStats): Promise<string> {
337
369
  const path = join(root, BUILD_STATS_FILE);
338
- await Bun.write(path, `${JSON.stringify(stats, null, 2)}\n`);
370
+ const stamped: BuildStats = { ...stats, measuredBy: BUILD_STATS_RULES };
371
+ await Bun.write(path, `${JSON.stringify(stamped, null, 2)}\n`);
339
372
  return path;
340
373
  }
@@ -0,0 +1,54 @@
1
+ // The CLI's build and bundle refusals: an entry an app does not have, an island that will not
2
+ // compile, a framework browser script that will not bundle. Split from `errors.ts` at its 500-line
3
+ // ceiling; `errors.ts` re-exports every name, so no import site moves.
4
+
5
+ import { UltimateError } from '@ultimat3/core';
6
+ import { quoteArg } from './shell-quote';
7
+
8
+ /**
9
+ * A build target names an entry file the app does not have. `x build` refuses before it spawns the
10
+ * builder: `bun build`'s own "module not found" says nothing about which file an Ultimate app is
11
+ * supposed to own, and `docker build`'s says nothing about which target wanted it.
12
+ */
13
+ export class BuildEntryMissingError extends UltimateError {
14
+ constructor(input: { target: string; entry: string }) {
15
+ super({
16
+ code: 'X_BUILD_ENTRY_MISSING',
17
+ cause: `x build --target ${input.target} builds from ${input.entry}, and the app does not have it`,
18
+ fix: `x new scratch-app --dry-run --json # its file list carries ${input.entry}; copy that file into this app`,
19
+ });
20
+ }
21
+ }
22
+
23
+ /**
24
+ * A client entry would not compile. `X_BUILD_FAILED`, not a code of its own: an island is a bundle
25
+ * entry point like any other, and the target's own logs are what says which line. The fix builds
26
+ * exactly that one file, so the next message an author reads is the compiler's and not the CLI's.
27
+ */
28
+ export class IslandBuildFailedError extends UltimateError {
29
+ constructor(input: { file: string; logs: string }) {
30
+ super({
31
+ code: 'X_BUILD_FAILED',
32
+ cause: `${input.file} is an island entry point and would not bundle: ${input.logs}`,
33
+ fix: `bun build --target browser ${input.file}`,
34
+ });
35
+ }
36
+ }
37
+
38
+ /** Which framework script a page ships: the one sync worker, or the one page boot. */
39
+ export type FrameworkScriptKind = 'sync worker' | 'page boot';
40
+
41
+ /**
42
+ * One of the page's framework scripts would not bundle. Same code as an island: "a browser entry
43
+ * the framework builds did not build" is one condition, and the entry here is framework code, not
44
+ * the app's — the cause names WHICH script, so the reader knows what the page is now missing.
45
+ */
46
+ export class FrameworkScriptBuildFailedError extends UltimateError {
47
+ constructor(input: { what: FrameworkScriptKind; entry: string; logs: string }) {
48
+ super({
49
+ code: 'X_BUILD_FAILED',
50
+ cause: `the ${input.what} (${input.entry}) would not bundle for the browser: ${input.logs}`,
51
+ fix: `bun build --target browser --format iife ${quoteArg(input.entry)}`,
52
+ });
53
+ }
54
+ }
@@ -1,4 +1,4 @@
1
- // One responsibility: compose the three halves — find a browser, connect to it, attach a page —
1
+ // One responsibility: compose the halves — find a browser, launch it over its pipe, attach a page —
2
2
  // into the one object `installE2eDriver({ page })` takes, plus the way to shut it down.
3
3
  //
4
4
  // **Absent is a SKIP, never a failure, and that is a requirement rather than a state.** A CI box
@@ -8,19 +8,25 @@
8
8
  // already decided a browser is required.
9
9
 
10
10
  import { finiteCount } from '@ultimat3/core';
11
- import type { CdpConnection } from './cdp-connection';
12
- import { cdpConnect } from './cdp-connection';
13
- import { cdpE2ePage } from './cdp-e2e-page';
11
+ import type { E2eTab } from './cdp-e2e-page';
12
+ import type { E2eSession } from './cdp-e2e-session';
13
+ import { cdpE2eSession } from './cdp-e2e-session';
14
14
  import { CdpBrowserMissingError } from './cdp-errors';
15
15
  import type { LaunchedBrowser } from './cdp-launch';
16
16
  import { CHROME_CANDIDATES, findChrome, launchChrome } from './cdp-launch';
17
- import type { E2eBrowserPage } from './e2e-page';
18
17
 
19
18
  /** How long a launch, a connect or a single CDP call may take. One number, three deadlines. */
20
19
  export const DEFAULT_CDP_TIMEOUT_MS = 30_000;
21
20
 
22
21
  export interface E2eBrowser {
23
- readonly page: E2eBrowserPage;
22
+ /** The first tab — what `installE2eDriver({ page })` drives. */
23
+ readonly page: E2eTab;
24
+ /**
25
+ * The browser itself: more tabs in the same profile, init scripts, the offline switch for every
26
+ * page and worker, and the log of every socket and request. What a multi-tab acceptance suite
27
+ * drives, on the same launch as `page` — one harness, never a second one beside the driver.
28
+ */
29
+ readonly session: E2eSession;
24
30
  /** Idempotent, and it closes both halves: the CDP socket, then the process and its profile. */
25
31
  close(): void;
26
32
  }
@@ -40,18 +46,13 @@ export interface OpenE2eBrowserOptions {
40
46
  const budget = (options: OpenE2eBrowserOptions): number =>
41
47
  finiteCount('openE2eBrowser', 'timeoutMs', options.timeoutMs ?? DEFAULT_CDP_TIMEOUT_MS);
42
48
 
43
- const compose = (
44
- launched: LaunchedBrowser,
45
- connection: CdpConnection,
46
- page: E2eBrowserPage,
47
- ): E2eBrowser => ({
49
+ const compose = (launched: LaunchedBrowser, session: E2eSession, page: E2eTab): E2eBrowser => ({
48
50
  page,
49
- close(): void {
50
- // The socket first: closing the process out from under an open connection makes every
51
- // in-flight call report "the browser closed the CDP connection", which is true and useless.
52
- connection.close();
53
- launched.close();
54
- },
51
+ session,
52
+ // The connection first, then the process — `launched.close()` does both, in that order: closing
53
+ // the process out from under an open connection makes every in-flight call report "the browser
54
+ // closed the CDP connection", which is true and useless.
55
+ close: () => launched.close(),
55
56
  });
56
57
 
57
58
  /**
@@ -82,18 +83,11 @@ export async function openE2eBrowserIfAvailable(
82
83
  */
83
84
  async function openLaunched(executable: string, timeoutMs: number): Promise<E2eBrowser> {
84
85
  const launched = await launchChrome({ executable, timeoutMs });
85
- let connection: CdpConnection;
86
- try {
87
- connection = await cdpConnect({ endpoint: launched.endpoint, timeoutMs });
88
- } catch (error) {
89
- launched.close();
90
- throw error;
91
- }
86
+ const { connection } = launched;
92
87
  try {
93
- const page = await cdpE2ePage({ connection, loadTimeoutMs: timeoutMs });
94
- return compose(launched, connection, page);
88
+ const session = await cdpE2eSession({ connection, loadTimeoutMs: timeoutMs });
89
+ return compose(launched, session, await session.newTab());
95
90
  } catch (error) {
96
- connection.close();
97
91
  launched.close();
98
92
  throw error;
99
93
  }
@@ -1,6 +1,8 @@
1
- // One responsibility: a Chrome DevTools Protocol connection over Bun's own WebSocket — request
2
- // framing, response correlation, and the per-call deadline. Launching a browser is `cdp-launch.ts`
3
- // and the page surface is `cdp-e2e-page.ts`; this file knows nothing about either.
1
+ // One responsibility: a Chrome DevTools Protocol connection — request framing, response
2
+ // correlation, and the per-call deadline — over a TRANSPORT that moves whole messages. Two exist:
3
+ // the pipe a launched Chrome is driven over (`cdp-pipe.ts`, what the e2e driver uses) and a remote
4
+ // browser's WebSocket (`cdpConnect` below). Launching is `cdp-launch.ts`; the page surface is
5
+ // `cdp-e2e-page.ts`; this file knows nothing about either.
4
6
  //
5
7
  // **No library, and that is the point rather than an economy.** `packages/scraping/src/cdp-port.ts`
6
8
  // declares a ~25-method port because `ScrapePage` is a full scraping surface, and its intended
@@ -57,12 +59,33 @@ const errorText = (frame: Record<string, unknown>): string | undefined => {
57
59
  return typeof message === 'string' ? message : 'the browser refused the call';
58
60
  };
59
61
 
62
+ /**
63
+ * What a connection needs of the wire: whole messages out, whole messages in, and a close. Framing
64
+ * is the transport's — a WebSocket frames per message, the pipe splits on NUL — so correlation and
65
+ * deadlines are written once, over either.
66
+ */
67
+ export interface CdpTransport {
68
+ send(text: string): void;
69
+ close(): void;
70
+ /** Installed exactly once, by the connection, before the first `send`. */
71
+ listen(handlers: {
72
+ readonly message: (text: string) => void;
73
+ readonly closed: (reason: string) => void;
74
+ }): void;
75
+ }
76
+
60
77
  export interface CdpConnectionOptions {
61
78
  readonly endpoint: string;
62
79
  /** Per-call deadline. A CDP call that never answers is a suite that never finishes. */
63
80
  readonly timeoutMs: number;
64
81
  }
65
82
 
83
+ /**
84
+ * A remote browser over its WebSocket url. NOT the e2e driver's wire: measured in the dummy's
85
+ * `offline-feed` suite on Bun 1.4.0, Bun's WebSocket client handed `onmessage` text spliced out of
86
+ * several frames — 64 unparseable frames in one run, one of them the reply to a `Runtime.evaluate`
87
+ * that then waited out its whole deadline. A launched Chrome is driven over `cdp-pipe.ts` instead.
88
+ */
66
89
  export async function cdpConnect(options: CdpConnectionOptions): Promise<CdpConnection> {
67
90
  assert(
68
91
  options.endpoint.startsWith('ws://') || options.endpoint.startsWith('wss://'),
@@ -70,6 +93,40 @@ export async function cdpConnect(options: CdpConnectionOptions): Promise<CdpConn
70
93
  'pass the `webSocketDebuggerUrl` Chrome prints on stderr, or the one /json/version answers',
71
94
  );
72
95
  const socket = new WebSocket(options.endpoint);
96
+ await new Promise<void>((resolve, reject) => {
97
+ const timer = setTimeout(() => {
98
+ reject(new CdpTimeoutError({ method: 'connect', timeoutMs: options.timeoutMs }));
99
+ }, options.timeoutMs);
100
+ socket.onopen = (): void => {
101
+ clearTimeout(timer);
102
+ resolve();
103
+ };
104
+ // `onerror` is the handshake's only until `listen` below reassigns it: a failure BEFORE open
105
+ // has no pending call to abandon, and rejecting is the only way the caller hears about it.
106
+ socket.onerror = (): void => {
107
+ clearTimeout(timer);
108
+ reject(
109
+ new CdpCallFailedError({ method: 'connect', detail: 'the browser refused the connection' }),
110
+ );
111
+ };
112
+ });
113
+
114
+ const transport: CdpTransport = {
115
+ send: (text) => socket.send(text),
116
+ close: () => socket.close(),
117
+ listen(handlers) {
118
+ socket.onmessage = (event: MessageEvent): void => {
119
+ handlers.message(typeof event.data === 'string' ? event.data : '');
120
+ };
121
+ socket.onclose = (): void => handlers.closed('the browser closed the CDP connection');
122
+ socket.onerror = (): void => handlers.closed('the CDP connection failed');
123
+ },
124
+ };
125
+ return cdpConnectOver(transport, options.timeoutMs);
126
+ }
127
+
128
+ /** A connection over any transport — correlation, deadlines and events, written once. */
129
+ export function cdpConnectOver(transport: CdpTransport, timeoutMs: number): CdpConnection {
73
130
  const pending = new Map<number, Pending>();
74
131
  const waiters = new Set<(method: string, sessionId: string | undefined) => void>();
75
132
  const listeners = new Map<string, Set<(params: Record<string, unknown>) => void>>();
@@ -91,8 +148,7 @@ export async function cdpConnect(options: CdpConnectionOptions): Promise<CdpConn
91
148
  waiters.clear();
92
149
  };
93
150
 
94
- socket.onmessage = (event: MessageEvent): void => {
95
- const raw = typeof event.data === 'string' ? event.data : '';
151
+ const message = (raw: string): void => {
96
152
  let frame: Record<string, unknown>;
97
153
  try {
98
154
  frame = JSON.parse(raw) as Record<string, unknown>;
@@ -134,27 +190,7 @@ export async function cdpConnect(options: CdpConnectionOptions): Promise<CdpConn
134
190
  : undefined,
135
191
  });
136
192
  };
137
- socket.onclose = (): void => abandon('the browser closed the CDP connection');
138
- socket.onerror = (): void => abandon('the CDP connection failed');
139
-
140
- await new Promise<void>((resolve, reject) => {
141
- const timer = setTimeout(() => {
142
- reject(new CdpTimeoutError({ method: 'connect', timeoutMs: options.timeoutMs }));
143
- }, options.timeoutMs);
144
- socket.onopen = (): void => {
145
- clearTimeout(timer);
146
- resolve();
147
- };
148
- // `onerror` is replaced for the handshake only, then restored above: a failure BEFORE open has
149
- // no pending call to abandon, and rejecting is the only way the caller hears about it.
150
- socket.onerror = (): void => {
151
- clearTimeout(timer);
152
- reject(
153
- new CdpCallFailedError({ method: 'connect', detail: 'the browser refused the connection' }),
154
- );
155
- };
156
- });
157
- socket.onerror = (): void => abandon('the CDP connection failed');
193
+ transport.listen({ message, closed: abandon });
158
194
 
159
195
  return {
160
196
  send(method, params = {}, sessionId): Promise<CdpResult> {
@@ -168,10 +204,10 @@ export async function cdpConnect(options: CdpConnectionOptions): Promise<CdpConn
168
204
  return new Promise<CdpResult>((resolve, reject) => {
169
205
  const timer = setTimeout(() => {
170
206
  pending.delete(id);
171
- reject(new CdpTimeoutError({ method, timeoutMs: options.timeoutMs }));
172
- }, options.timeoutMs);
207
+ reject(new CdpTimeoutError({ method, timeoutMs: timeoutMs }));
208
+ }, timeoutMs);
173
209
  pending.set(id, { resolve, reject, timer });
174
- socket.send(
210
+ transport.send(
175
211
  JSON.stringify({ id, method, params, ...(sessionId === undefined ? {} : { sessionId }) }),
176
212
  );
177
213
  });
@@ -205,7 +241,7 @@ export async function cdpConnect(options: CdpConnectionOptions): Promise<CdpConn
205
241
  close(): void {
206
242
  listeners.clear();
207
243
  abandon('the driver closed the CDP connection');
208
- socket.close();
244
+ transport.close();
209
245
  },
210
246
  };
211
247
  }