@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.
- package/CLAUDE.md +69 -1
- package/package.json +30 -30
- package/src/app-env.ts +2 -2
- package/src/budgets.ts +45 -12
- package/src/build-errors.ts +54 -0
- package/src/cdp-browser.ts +21 -27
- package/src/cdp-connection.ts +66 -30
- package/src/cdp-e2e-page.ts +84 -113
- package/src/cdp-e2e-session.ts +199 -0
- package/src/cdp-launch.ts +95 -41
- package/src/cdp-offline-script.ts +73 -0
- package/src/cdp-pipe.ts +77 -0
- package/src/cmd-deploy.ts +7 -0
- package/src/cmd-dev.ts +23 -86
- package/src/dev-live-feed.ts +2 -0
- package/src/dev-render.ts +119 -20
- package/src/dev-route-table.ts +119 -0
- package/src/dev-services.ts +4 -1
- package/src/dev-sync.ts +5 -3
- package/src/e2e-app.ts +103 -0
- package/src/e2e-browser-handle.ts +55 -0
- package/src/e2e-driver.ts +32 -12
- package/src/e2e-errors.ts +14 -0
- package/src/e2e-page.ts +5 -2
- package/src/e2e-preload.ts +64 -0
- package/src/e2e-probe.ts +23 -0
- package/src/e2e-spawn.ts +169 -0
- package/src/error-codes.ts +7 -0
- package/src/error-unthrown.ts +130 -0
- package/src/errors.ts +8 -29
- package/src/index.ts +18 -4
- package/src/island-bundle.ts +33 -11
- package/src/island-realtime.ts +91 -0
- package/src/island-verdict.ts +1 -1
- package/src/live-routes.ts +82 -42
- package/src/mcp-errors.ts +3 -0
- package/src/page-sync.ts +54 -0
- package/src/realtime-browser-probe-fixture.ts +2 -2
- package/src/serve.ts +9 -0
- package/src/sw-artifacts.ts +13 -3
- package/src/sync-url.ts +31 -0
- package/src/templates/resource-form-island.ts +30 -21
- package/src/templates/route.ts +3 -0
- package/src/templates/scaffold-container.ts +18 -3
- package/src/templates/scaffold-env.ts +6 -0
- package/src/verify-e2e.ts +38 -0
- package/src/verify-run.ts +105 -50
- package/src/verify-tests.ts +21 -4
- 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-
|
|
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": "
|
|
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": "^
|
|
40
|
-
"@ultimat3/action": "
|
|
41
|
-
"@ultimat3/admin": "
|
|
42
|
-
"@ultimat3/ai": "
|
|
43
|
-
"@ultimat3/auth": "
|
|
44
|
-
"@ultimat3/cache": "
|
|
45
|
-
"@ultimat3/core": "
|
|
46
|
-
"@ultimat3/db": "
|
|
47
|
-
"@ultimat3/entity": "
|
|
48
|
-
"@ultimat3/flags": "
|
|
49
|
-
"@ultimat3/http": "
|
|
50
|
-
"@ultimat3/i18n": "
|
|
51
|
-
"@ultimat3/jobs": "
|
|
52
|
-
"@ultimat3/mail": "
|
|
53
|
-
"@ultimat3/manifest": "
|
|
54
|
-
"@ultimat3/mcp": "
|
|
55
|
-
"@ultimat3/money": "
|
|
56
|
-
"@ultimat3/notify": "
|
|
57
|
-
"@ultimat3/policy": "
|
|
58
|
-
"@ultimat3/pwa": "
|
|
59
|
-
"@ultimat3/query": "
|
|
60
|
-
"@ultimat3/realtime": "
|
|
61
|
-
"@ultimat3/render": "
|
|
62
|
-
"@ultimat3/schema": "
|
|
63
|
-
"@ultimat3/scraping": "
|
|
64
|
-
"@ultimat3/seo": "
|
|
65
|
-
"@ultimat3/storage": "
|
|
66
|
-
"@ultimat3/testing": "
|
|
67
|
-
"@ultimat3/time": "
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
* emitted
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|
package/src/cdp-browser.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// One responsibility: compose the
|
|
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 {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
|
94
|
-
return compose(launched,
|
|
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
|
}
|
package/src/cdp-connection.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
// One responsibility: a Chrome DevTools Protocol connection
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
172
|
-
},
|
|
207
|
+
reject(new CdpTimeoutError({ method, timeoutMs: timeoutMs }));
|
|
208
|
+
}, timeoutMs);
|
|
173
209
|
pending.set(id, { resolve, reject, timer });
|
|
174
|
-
|
|
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
|
-
|
|
244
|
+
transport.close();
|
|
209
245
|
},
|
|
210
246
|
};
|
|
211
247
|
}
|