@ultimat3/cli 20.2.0 → 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 +70 -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/cmd-shot.ts +30 -4
- 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 +38 -11
- package/src/island-realtime.ts +91 -0
- package/src/island-solid-dedupe.ts +108 -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/shot-theme.ts +52 -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-dashboard-shared.ts +8 -5
- 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
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// The e2e step's own preload: when the gate names an app root (`ULTIMATE_E2E_ROOT`, set by
|
|
2
|
+
// `verify-e2e.ts`), spawn that app on a throwaway database, open ONE browser, install it as the
|
|
3
|
+
// `page` fixture and the `e2eTest` driver, register `deploy.newBuild()` as a restart of the app on
|
|
4
|
+
// its own port, and hand both to any test that asks `e2eBrowser()` / `e2eApp()`.
|
|
5
|
+
|
|
6
|
+
import { afterAll, beforeEach } from 'bun:test';
|
|
7
|
+
import type { E2eBrowser } from './cdp-browser';
|
|
8
|
+
import { openE2eBrowser } from './cdp-browser';
|
|
9
|
+
import { startE2eApp } from './e2e-app';
|
|
10
|
+
import { E2E_ROOT_ENV, e2eBrowser, publishE2eRun, republishE2eBrowser } from './e2e-browser-handle';
|
|
11
|
+
import { installE2eDriver } from './e2e-driver';
|
|
12
|
+
import type { E2eBrowserPage } from './e2e-page';
|
|
13
|
+
import { answersWithin } from './e2e-probe';
|
|
14
|
+
|
|
15
|
+
/** How long a live browser gets to answer `1` before it is declared hung and relaunched. */
|
|
16
|
+
const PROBE_MS = 5_000;
|
|
17
|
+
|
|
18
|
+
const root = Bun.env[E2E_ROOT_ENV];
|
|
19
|
+
if (root !== undefined && root !== '') {
|
|
20
|
+
const app = await startE2eApp({ root });
|
|
21
|
+
let builds = 0;
|
|
22
|
+
// A deploy leaves the browser holding the OLD build's state: a SharedWorker whose socket went
|
|
23
|
+
// down with the restart and is now deep in its reconnect backoff, and tabs rendered by the old
|
|
24
|
+
// build. The test that deployed asserts on exactly that; the NEXT test must not inherit it.
|
|
25
|
+
let deployed = false;
|
|
26
|
+
const newBuild = async (): Promise<void> => {
|
|
27
|
+
deployed = true;
|
|
28
|
+
builds += 1;
|
|
29
|
+
await app.restart({ BUILD_ID: `e2e-build-${String(builds)}-${String(Date.now())}` });
|
|
30
|
+
};
|
|
31
|
+
// `openE2eBrowser`, never the `IfAvailable` door: the step only names a root after finding one.
|
|
32
|
+
let browser: E2eBrowser = await openE2eBrowser();
|
|
33
|
+
publishE2eRun({ browser, app });
|
|
34
|
+
// Installed ONCE, over a page that delegates to whichever browser is current: an `e2eTest` body
|
|
35
|
+
// is bound to its fixtures when the file DEFINES it, so reinstalling on a relaunch would leave
|
|
36
|
+
// every test defined before it driving a closed browser.
|
|
37
|
+
const current: E2eBrowserPage = {
|
|
38
|
+
url: () => browser.page.url(),
|
|
39
|
+
goto: (url, options) => browser.page.goto(url, options),
|
|
40
|
+
evaluate: (expression) => browser.page.evaluate(expression),
|
|
41
|
+
click: (selector) => browser.page.click(selector),
|
|
42
|
+
offline: (enabled) => browser.page.offline(enabled),
|
|
43
|
+
};
|
|
44
|
+
const uninstall = installE2eDriver({ page: current, baseUrl: app.base, newBuild });
|
|
45
|
+
|
|
46
|
+
// A browser that stopped answering takes every later suite down with it, one call deadline per
|
|
47
|
+
// call (run 8). So each test starts with a short probe, and a browser that fails it — or one a
|
|
48
|
+
// deploy ran under — is closed and relaunched: the app is untouched, and the test gets a fresh
|
|
49
|
+
// profile, a fresh SharedWorker and a fresh tab on the same origin.
|
|
50
|
+
beforeEach(async () => {
|
|
51
|
+
const alive = !deployed && (await answersWithin(e2eBrowser().page, PROBE_MS));
|
|
52
|
+
if (alive) return;
|
|
53
|
+
deployed = false;
|
|
54
|
+
browser.close();
|
|
55
|
+
browser = await openE2eBrowser();
|
|
56
|
+
republishE2eBrowser(browser);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterAll(async () => {
|
|
60
|
+
uninstall();
|
|
61
|
+
browser.close();
|
|
62
|
+
await app.stop();
|
|
63
|
+
});
|
|
64
|
+
}
|
package/src/e2e-probe.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Is the browser still answering? One cheap `evaluate('1')` raced against a short budget — the
|
|
2
|
+
// question the e2e preload asks before every test, because a hung browser otherwise costs every
|
|
3
|
+
// later suite one full CDP deadline per call (run 8) instead of one relaunch.
|
|
4
|
+
|
|
5
|
+
/** `true` when the page evaluated `1` within `ms`; a rejection or a stall is `false`, never a throw. */
|
|
6
|
+
export async function answersWithin(
|
|
7
|
+
page: { evaluate(expression: string): Promise<unknown> },
|
|
8
|
+
ms: number,
|
|
9
|
+
): Promise<boolean> {
|
|
10
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
11
|
+
const stalled = new Promise<false>((resolve) => {
|
|
12
|
+
timer = setTimeout(() => resolve(false), ms);
|
|
13
|
+
});
|
|
14
|
+
const answered = page.evaluate('1').then(
|
|
15
|
+
() => true,
|
|
16
|
+
() => false,
|
|
17
|
+
);
|
|
18
|
+
try {
|
|
19
|
+
return await Promise.race([answered, stalled]);
|
|
20
|
+
} finally {
|
|
21
|
+
clearTimeout(timer);
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/e2e-spawn.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// The e2e app's PROCESS half: spawn it on a free port, wait for `/readyz`, restart it on the same
|
|
2
|
+
// port as a deploy, stop it. Split from `e2e-app.ts`, which owns the DATABASE half (the throwaway
|
|
3
|
+
// state directory, the reset and the seed), so this half runs against any root with an entry —
|
|
4
|
+
// which is what lets a unit test drive it without a Postgres.
|
|
5
|
+
|
|
6
|
+
// why: the readiness poll must not go through `globalThis.fetch` — inside `bun test` the testing
|
|
7
|
+
// preload SEALS it, and the app's `/readyz` would be refused as egress. `node:http` is not sealed.
|
|
8
|
+
import { get } from 'node:http';
|
|
9
|
+
// why: Bun exposes no path API — the CLI's own bin is joined, not concatenated.
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { assert } from '@ultimat3/core';
|
|
12
|
+
import { E2eAppFailedError } from './e2e-errors';
|
|
13
|
+
|
|
14
|
+
/** `x dev` (sync included), or the production entry `apps/web/server.ts` under `ROLE=web`. */
|
|
15
|
+
export type E2eAppMode = 'dev' | 'serve';
|
|
16
|
+
|
|
17
|
+
/** The CLI this process IS — never a global `x`, which may be a different version of the framework. */
|
|
18
|
+
export const X_BIN = join(import.meta.dir, 'bin.ts');
|
|
19
|
+
const POLL_MS = 250;
|
|
20
|
+
|
|
21
|
+
export interface SpawnedE2eApp {
|
|
22
|
+
/** `http://localhost:<port>`, no trailing slash. */
|
|
23
|
+
readonly base: string;
|
|
24
|
+
/** Kill the process. Idempotent. */
|
|
25
|
+
stop(): Promise<void>;
|
|
26
|
+
/** Kill it and start it again on the SAME port, with `env` added — a deploy. Refused after `stop()`. */
|
|
27
|
+
restart(env?: Readonly<Record<string, string>>): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SpawnE2eAppOptions {
|
|
31
|
+
readonly root: string;
|
|
32
|
+
readonly mode: E2eAppMode;
|
|
33
|
+
/** Every spawn's environment, on top of this process's minus `NODE_ENV`. */
|
|
34
|
+
readonly env: Readonly<Record<string, string>>;
|
|
35
|
+
/** Already screened finite by the caller. */
|
|
36
|
+
readonly readyTimeoutMs: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A port nothing holds right now, asked of the OS and handed to the child. */
|
|
40
|
+
const freePort = (): number => {
|
|
41
|
+
const probe = Bun.serve({ port: 0, fetch: () => new Response() });
|
|
42
|
+
const port = probe.port ?? 0;
|
|
43
|
+
probe.stop(true);
|
|
44
|
+
return port;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* This process's environment minus what makes the child a TEST process. Spawned from `bun test`,
|
|
49
|
+
* `NODE_ENV=test` rode along and the app resolved its environment as `test` — so a development-only
|
|
50
|
+
* seam (the demo viewer an app installs instead of a sign-in route) was off and every page 401'd.
|
|
51
|
+
* The app under e2e is a development app unless the caller's `env` says otherwise.
|
|
52
|
+
*/
|
|
53
|
+
export const inherited = (): Record<string, string | undefined> => {
|
|
54
|
+
const { NODE_ENV: _node, ...rest } = Bun.env;
|
|
55
|
+
return { ...rest, ULTIMATE_ENV: 'development' };
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const refuse = (step: string, output: string): E2eAppFailedError =>
|
|
59
|
+
new E2eAppFailedError({ step, output });
|
|
60
|
+
|
|
61
|
+
/** Spawn the app and answer once `/readyz` does; refuses with the app's own output tail. */
|
|
62
|
+
export async function spawnE2eApp(options: SpawnE2eAppOptions): Promise<SpawnedE2eApp> {
|
|
63
|
+
const deadline = options.readyTimeoutMs;
|
|
64
|
+
const port = freePort();
|
|
65
|
+
// Its own scrape port too: every role opens one, the default is a fixed 9090, and a second app —
|
|
66
|
+
// or a developer's `x dev` — already holding it is an app that dies at boot with X_PORT_IN_USE.
|
|
67
|
+
const metricsPort = freePort();
|
|
68
|
+
const base = `http://localhost:${String(port)}`;
|
|
69
|
+
const command =
|
|
70
|
+
options.mode === 'serve'
|
|
71
|
+
? ['bun', 'apps/web/server.ts']
|
|
72
|
+
: ['bun', X_BIN, 'dev', '--port', String(port)];
|
|
73
|
+
const spawnApp = (extra: Readonly<Record<string, string>>) =>
|
|
74
|
+
Bun.spawn(command, {
|
|
75
|
+
cwd: options.root,
|
|
76
|
+
env: {
|
|
77
|
+
...inherited(),
|
|
78
|
+
...options.env,
|
|
79
|
+
METRICS_PORT: String(metricsPort),
|
|
80
|
+
// The origin the app is actually reachable at. A page that renders a typed client builds
|
|
81
|
+
// its absolute URLs from it, and without it `/feed` answered 500 with X_ENV_MISSING APP_URL.
|
|
82
|
+
APP_URL: base,
|
|
83
|
+
...(options.mode === 'serve' ? { ROLE: 'web', PORT: String(port) } : {}),
|
|
84
|
+
...extra,
|
|
85
|
+
},
|
|
86
|
+
stdout: 'pipe',
|
|
87
|
+
stderr: 'pipe',
|
|
88
|
+
});
|
|
89
|
+
const ready = async (child: ReturnType<typeof spawnApp>, tail: () => string): Promise<void> => {
|
|
90
|
+
for (let waited = 0; waited < deadline; waited += POLL_MS) {
|
|
91
|
+
if (child.exitCode !== null) break;
|
|
92
|
+
if (await answersOk(`${base}/readyz`)) return;
|
|
93
|
+
await Bun.sleep(POLL_MS);
|
|
94
|
+
}
|
|
95
|
+
child.kill();
|
|
96
|
+
await child.exited;
|
|
97
|
+
throw refuse(`${command.join(' ')} never answered ${base}/readyz`, tail());
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
let child = spawnApp({});
|
|
101
|
+
// Drained from the start and kept bounded: an app's log is unbounded, and a pipe nobody reads
|
|
102
|
+
// fills its buffer and blocks the child on its next write — an app that "never got ready".
|
|
103
|
+
let tail = drainTail(child.stdout, child.stderr);
|
|
104
|
+
let stopped = false;
|
|
105
|
+
const stop = async (): Promise<void> => {
|
|
106
|
+
if (stopped) return;
|
|
107
|
+
stopped = true;
|
|
108
|
+
child.kill();
|
|
109
|
+
await child.exited;
|
|
110
|
+
};
|
|
111
|
+
try {
|
|
112
|
+
await ready(child, tail);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
await stop();
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
base,
|
|
119
|
+
stop,
|
|
120
|
+
async restart(next: Readonly<Record<string, string>> = {}): Promise<void> {
|
|
121
|
+
// `stop()` is final. A child respawned here would be one no later `stop()` kills — the flag
|
|
122
|
+
// already says done — and `startE2eApp` has deleted the state directory it would boot on.
|
|
123
|
+
assert(
|
|
124
|
+
!stopped,
|
|
125
|
+
'restart() was called on an e2e app that was already stopped, so there is no app to deploy over',
|
|
126
|
+
'startE2eApp({ root }) again for a fresh app — restart() is for an app that is still running',
|
|
127
|
+
);
|
|
128
|
+
// The same port and the same state directory — a deploy, not a second app: a tab already
|
|
129
|
+
// open on `base` sees the new build on its next request, and the data it wrote is still there.
|
|
130
|
+
child.kill();
|
|
131
|
+
await child.exited;
|
|
132
|
+
child = spawnApp(next);
|
|
133
|
+
tail = drainTail(child.stdout, child.stderr);
|
|
134
|
+
await ready(child, tail);
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const TAIL_CHARS = 16_000;
|
|
140
|
+
|
|
141
|
+
/** Read both streams to their end in the background; answer the last `TAIL_CHARS` of either. */
|
|
142
|
+
function drainTail(...streams: readonly ReadableStream<Uint8Array>[]): () => string {
|
|
143
|
+
let text = '';
|
|
144
|
+
for (const stream of streams) {
|
|
145
|
+
void (async () => {
|
|
146
|
+
const decoder = new TextDecoder();
|
|
147
|
+
for await (const chunk of stream) {
|
|
148
|
+
text = (text + decoder.decode(chunk, { stream: true })).slice(-TAIL_CHARS);
|
|
149
|
+
}
|
|
150
|
+
})().catch(() => undefined);
|
|
151
|
+
}
|
|
152
|
+
return () => text;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** One GET, answered as "2xx or not" — never a throw, and never through the sealed `fetch`. */
|
|
156
|
+
function answersOk(url: string): Promise<boolean> {
|
|
157
|
+
return new Promise((resolve) => {
|
|
158
|
+
const request = get(url, (response) => {
|
|
159
|
+
response.resume();
|
|
160
|
+
const status = response.statusCode ?? 0;
|
|
161
|
+
resolve(status >= 200 && status < 300);
|
|
162
|
+
});
|
|
163
|
+
request.on('error', () => resolve(false));
|
|
164
|
+
request.setTimeout(POLL_MS * 4, () => {
|
|
165
|
+
request.destroy();
|
|
166
|
+
resolve(false);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|
package/src/error-codes.ts
CHANGED
|
@@ -30,6 +30,9 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
30
30
|
// `code: STALE` here is invisible to every reader of the code set, and silence there is
|
|
31
31
|
// permissive — the DRYer the author, the less the gate sees (#277).
|
|
32
32
|
'X_ERROR_CODE_UNRESOLVED',
|
|
33
|
+
// The fourth: registered, presented as live, and constructed by nothing — the state a code is left
|
|
34
|
+
// in when its last thrower moves on. The reference row has to say so in words.
|
|
35
|
+
'X_ERROR_CODE_UNTHROWN',
|
|
33
36
|
// Reported as `Finding`s rather than thrown, and unregistered until now because of it — so
|
|
34
37
|
// `x errors explain X_TYPECHECK_FAILED` refused a code `x verify` had just printed. A finding
|
|
35
38
|
// carries an `X_*` code to the same reader a throw does; the registry is what makes that code
|
|
@@ -185,6 +188,7 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
185
188
|
'X_E2E_LOCATOR_EMPTY',
|
|
186
189
|
'X_E2E_LOCATOR_AMBIGUOUS',
|
|
187
190
|
'X_E2E_SERVICE_WORKER_ABSENT',
|
|
191
|
+
'X_E2E_APP_FAILED',
|
|
188
192
|
// The raw-CDP browser under that driver — `cdp-launch.ts`, `cdp-connection.ts`,
|
|
189
193
|
// `cdp-e2e-page.ts`, `cdp-browser.ts`. Four codes and not one, because the four repairs differ:
|
|
190
194
|
// install a browser, read the browser's own stderr, look at the page, raise a deadline.
|
|
@@ -262,6 +266,8 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
262
266
|
X_ERROR_CODE_UNDOCUMENTED: 'a shipped error code has no row in the error reference',
|
|
263
267
|
X_ERROR_CODE_UNREGISTERED: 'the error reference documents a code no package registers',
|
|
264
268
|
X_ERROR_CODE_UNRESOLVED: 'an error code is written as a name this repository cannot resolve',
|
|
269
|
+
X_ERROR_CODE_UNTHROWN:
|
|
270
|
+
'a registered error code is constructed by nothing and its reference row does not say so',
|
|
265
271
|
X_FRAMEWORK_SCHEMA_FAILED: 'a framework table could not be created at boot',
|
|
266
272
|
X_STORAGE_UNWRITABLE: 'the storage disk this process needs cannot be written to',
|
|
267
273
|
X_STORAGE_SECRET_DEV: 'upload grants would be signed with the shipped development key',
|
|
@@ -330,6 +336,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
330
336
|
X_E2E_LOCATOR_EMPTY: 'an e2e locator matched no element',
|
|
331
337
|
X_E2E_LOCATOR_AMBIGUOUS: 'an e2e locator matched more than one element and was asked to click',
|
|
332
338
|
X_E2E_SERVICE_WORKER_ABSENT: 'no service worker took control of the page within the budget',
|
|
339
|
+
X_E2E_APP_FAILED: 'the app an e2e run spawned did not come up',
|
|
333
340
|
X_CDP_BROWSER_MISSING: 'no Chrome or Chromium is installed for the e2e driver to launch',
|
|
334
341
|
X_CDP_LAUNCH_FAILED: 'the browser started and never announced a DevTools endpoint',
|
|
335
342
|
X_CDP_CALL_FAILED: 'the browser refused a DevTools call',
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// A registered code nothing constructs. The registry and the reference keep a shipped code alive
|
|
2
|
+
// forever — which is right — and that is exactly why a code can outlive its last thrower with no
|
|
3
|
+
// gate noticing: `X_RPC_FAILED` sat registered and documented as live after the transport change
|
|
4
|
+
// took its only throw site away. The reference row has to SAY so, or this reports it.
|
|
5
|
+
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { ERROR_DOCS_URL, maskLiterals, stripComments } from '@ultimat3/core';
|
|
8
|
+
import { RESERVED_HEADING } from './error-contract';
|
|
9
|
+
import type { Finding } from './output';
|
|
10
|
+
import { eachSourceFile, isGenerated, isTest } from './source-files';
|
|
11
|
+
import { isCodeRegistry } from './ts-scan';
|
|
12
|
+
|
|
13
|
+
const CODE_LITERAL = /(['"`])(X_[A-Z0-9_]+)\1/g;
|
|
14
|
+
const TITLE_KEY = /^[\t ]*(X_[A-Z0-9_]+)\s*:/gm;
|
|
15
|
+
/** A registry's own code LIST line — `'X_FOO',` — declares a code and throws nothing. */
|
|
16
|
+
const LIST_LINE = /^[\t ]*(['"`])X_[A-Z0-9_]+\1\s*,?\s*$/;
|
|
17
|
+
/** `metaMissing: 'X_SEO_META_MISSING'` — the table `@ultimat3/seo` and `@ultimat3/ui` raise from. */
|
|
18
|
+
const TABLE_ENTRY = /\b([a-z][A-Za-z0-9]*)\s*:\s*(['"`])(X_[A-Z0-9_]+)\2/g;
|
|
19
|
+
const MEMBER_READ = /\.([A-Za-z_$][\w$]*)/g;
|
|
20
|
+
|
|
21
|
+
/** Phrases a reference row uses to say, in words, that nothing throws the code any more. */
|
|
22
|
+
const DECLARED_UNTHROWN = /thrown by nothing|not thrown/i;
|
|
23
|
+
|
|
24
|
+
export interface CodeUse {
|
|
25
|
+
/** Every code a package registry names. */
|
|
26
|
+
readonly registered: ReadonlySet<string>;
|
|
27
|
+
/** Every code shipped source constructs, compares or raises outside a registry's declaration. */
|
|
28
|
+
readonly used: ReadonlySet<string>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One file's contribution. A registry file's code LIST and title KEYS declare; any other literal in
|
|
33
|
+
* it is a use (a class's `code:`, a `?? 'X_…'` fallback, a comparison). A table entry counts only
|
|
34
|
+
* once something reads that member — which is how two packages raise every code they own.
|
|
35
|
+
*/
|
|
36
|
+
export function codeUseOf(source: string): {
|
|
37
|
+
readonly registered: readonly string[];
|
|
38
|
+
readonly used: readonly string[];
|
|
39
|
+
readonly table: ReadonlyMap<string, string>;
|
|
40
|
+
readonly members: readonly string[];
|
|
41
|
+
} {
|
|
42
|
+
const text = stripComments(source);
|
|
43
|
+
const members = [...maskLiterals(source).matchAll(MEMBER_READ)].map((m) => m[1] ?? '');
|
|
44
|
+
if (!isCodeRegistry(text)) {
|
|
45
|
+
return {
|
|
46
|
+
registered: [],
|
|
47
|
+
used: [...text.matchAll(CODE_LITERAL)].map((m) => m[2] ?? ''),
|
|
48
|
+
table: new Map(),
|
|
49
|
+
members,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const registered = [
|
|
53
|
+
...[...text.matchAll(TITLE_KEY)].map((m) => m[1] ?? ''),
|
|
54
|
+
...[...text.matchAll(CODE_LITERAL)].map((m) => m[2] ?? ''),
|
|
55
|
+
];
|
|
56
|
+
const table = new Map<string, string>();
|
|
57
|
+
for (const m of text.matchAll(TABLE_ENTRY)) {
|
|
58
|
+
// `code: 'X_…'` is a class or a factory constructing the code, never a table entry.
|
|
59
|
+
if (m[1] !== 'code') table.set(m[1] ?? '', m[3] ?? '');
|
|
60
|
+
}
|
|
61
|
+
const tableCodes = new Set(table.values());
|
|
62
|
+
const used: string[] = [];
|
|
63
|
+
for (const line of text.split('\n')) {
|
|
64
|
+
if (LIST_LINE.test(line)) continue;
|
|
65
|
+
for (const m of line.matchAll(CODE_LITERAL)) {
|
|
66
|
+
const code = m[2] ?? '';
|
|
67
|
+
if (!tableCodes.has(code)) used.push(code);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { registered, used, table, members };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Shipped package source only: `scripts/` never ships, so a code only a gate script names is unthrown. */
|
|
74
|
+
export async function collectCodeUse(root: string): Promise<CodeUse> {
|
|
75
|
+
const registered = new Set<string>();
|
|
76
|
+
const used = new Set<string>();
|
|
77
|
+
const table = new Map<string, string>();
|
|
78
|
+
const members = new Set<string>();
|
|
79
|
+
for await (const source of eachSourceFile(root)) {
|
|
80
|
+
if (!/^packages\/[^/]+\/src\//.test(source) || isTest(source) || isGenerated(source)) continue;
|
|
81
|
+
const use = codeUseOf(await Bun.file(join(root, source)).text());
|
|
82
|
+
for (const code of use.registered) registered.add(code);
|
|
83
|
+
for (const code of use.used) used.add(code);
|
|
84
|
+
for (const [member, code] of use.table) table.set(member, code);
|
|
85
|
+
for (const member of use.members) members.add(member);
|
|
86
|
+
}
|
|
87
|
+
for (const [member, code] of table) if (members.has(member)) used.add(code);
|
|
88
|
+
return { registered, used };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Codes whose reference row says nothing throws them, or that sit under the reserved heading. */
|
|
92
|
+
export function declaredUnthrown(markdown: string): ReadonlySet<string> {
|
|
93
|
+
const out = new Set<string>();
|
|
94
|
+
let reserved = false;
|
|
95
|
+
for (const line of markdown.split('\n')) {
|
|
96
|
+
if (line.trim() === RESERVED_HEADING) reserved = true;
|
|
97
|
+
const row = /^\|\s*`(X_[A-Z0-9_]+)`\s*\|/.exec(line);
|
|
98
|
+
if (row !== null && (reserved || DECLARED_UNTHROWN.test(line))) out.add(row[1] ?? '');
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const unthrownFinding = (code: string, page: string): Finding => ({
|
|
104
|
+
code: 'X_ERROR_CODE_UNTHROWN',
|
|
105
|
+
cause: `${code} is registered and ${page} presents it as live, but no shipped source constructs it — a reader matching on it waits for an error that cannot arrive`,
|
|
106
|
+
// Never "delete the registration": a shipped code is stable forever, and an old log line must
|
|
107
|
+
// still explain. The row is what has to change.
|
|
108
|
+
fix: `write "registered, thrown by nothing since <version>" into ${code}'s row in ${page}, naming the code that replaced it — or throw it again where it belongs`,
|
|
109
|
+
docs: ERROR_DOCS_URL,
|
|
110
|
+
at: page,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Registered, used by nothing, and not declared unthrown on the reference. A host check — the page
|
|
115
|
+
* is the host repo's to name, and only a monorepo's walk sees every package's source; in a
|
|
116
|
+
* generated app every framework code would read as unthrown.
|
|
117
|
+
*/
|
|
118
|
+
export async function checkErrorCodesThrown(
|
|
119
|
+
root: string,
|
|
120
|
+
page: string,
|
|
121
|
+
): Promise<readonly Finding[]> {
|
|
122
|
+
const reference = Bun.file(join(root, page));
|
|
123
|
+
if (!(await reference.exists())) return [];
|
|
124
|
+
const exempt = declaredUnthrown(await reference.text());
|
|
125
|
+
const { registered, used } = await collectCodeUse(root);
|
|
126
|
+
return [...registered]
|
|
127
|
+
.filter((code) => !used.has(code) && !exempt.has(code))
|
|
128
|
+
.sort()
|
|
129
|
+
.map((code) => unthrownFinding(code, page));
|
|
130
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -287,35 +287,14 @@ export class FixTargetUnknownError extends UltimateError {
|
|
|
287
287
|
}
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
code: 'X_BUILD_ENTRY_MISSING',
|
|
299
|
-
cause: `x build --target ${input.target} builds from ${input.entry}, and the app does not have it`,
|
|
300
|
-
fix: `x new scratch-app --dry-run --json # its file list carries ${input.entry}; copy that file into this app`,
|
|
301
|
-
});
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* A client entry would not compile. `X_BUILD_FAILED`, not a code of its own: an island is a bundle
|
|
307
|
-
* entry point like any other, and the target's own logs are what says which line. The fix builds
|
|
308
|
-
* exactly that one file, so the next message an author reads is the compiler's and not the CLI's.
|
|
309
|
-
*/
|
|
310
|
-
export class IslandBuildFailedError extends UltimateError {
|
|
311
|
-
constructor(input: { file: string; logs: string }) {
|
|
312
|
-
super({
|
|
313
|
-
code: 'X_BUILD_FAILED',
|
|
314
|
-
cause: `${input.file} is an island entry point and would not bundle: ${input.logs}`,
|
|
315
|
-
fix: `bun build --target browser ${input.file}`,
|
|
316
|
-
});
|
|
317
|
-
}
|
|
318
|
-
}
|
|
290
|
+
// The build and bundle refusals live in `build-errors.ts` (split at the 500-line ceiling); re-exported
|
|
291
|
+
// here so every existing `from './errors'` import keeps resolving.
|
|
292
|
+
export type { FrameworkScriptKind } from './build-errors';
|
|
293
|
+
export {
|
|
294
|
+
BuildEntryMissingError,
|
|
295
|
+
FrameworkScriptBuildFailedError,
|
|
296
|
+
IslandBuildFailedError,
|
|
297
|
+
} from './build-errors';
|
|
319
298
|
|
|
320
299
|
/**
|
|
321
300
|
* `ROLE` selects what a container is. One image runs every role, so a typo is a process that would
|
package/src/index.ts
CHANGED
|
@@ -24,7 +24,13 @@ export { findAppRoot, requireAppRoot, requireBunVersion, versionAtLeast } from '
|
|
|
24
24
|
export type { BoundaryCut, BoundarySplit } from './boundary-cuts';
|
|
25
25
|
export { planBoundaryCuts } from './boundary-cuts';
|
|
26
26
|
export type { BuildStats, RouteStats } from './budgets';
|
|
27
|
-
export {
|
|
27
|
+
export {
|
|
28
|
+
BUILD_STATS_FILE,
|
|
29
|
+
checkBudgets,
|
|
30
|
+
FRAMEWORK_INLINE_SCRIPTS,
|
|
31
|
+
FRAMEWORK_SCRIPTS,
|
|
32
|
+
readBuildStats,
|
|
33
|
+
} from './budgets';
|
|
28
34
|
// The raw-CDP browser the driver above runs on. `openE2eBrowserIfAvailable()` is what an app's
|
|
29
35
|
// test preload calls: it answers `undefined` on a machine with no Chrome, so the browser-backed
|
|
30
36
|
// suite SKIPS rather than turning a gate red for a reason unrelated to the change.
|
|
@@ -36,8 +42,10 @@ export {
|
|
|
36
42
|
} from './cdp-browser';
|
|
37
43
|
export type { CdpConnection, CdpConnectionOptions, CdpResult } from './cdp-connection';
|
|
38
44
|
export { cdpConnect } from './cdp-connection';
|
|
39
|
-
export type {
|
|
40
|
-
export {
|
|
45
|
+
export type { CdpE2eTabOptions, E2eTab } from './cdp-e2e-page';
|
|
46
|
+
export { cdpE2eTab } from './cdp-e2e-page';
|
|
47
|
+
export type { CdpE2eSessionOptions, E2eSession } from './cdp-e2e-session';
|
|
48
|
+
export { cdpE2eSession } from './cdp-e2e-session';
|
|
41
49
|
export {
|
|
42
50
|
CdpBrowserMissingError,
|
|
43
51
|
CdpCallFailedError,
|
|
@@ -150,9 +158,13 @@ export {
|
|
|
150
158
|
// The browser-backed e2e driver. `installE2eDriver` is the ONE entry point an app's test preload
|
|
151
159
|
// calls; everything below it is exported because the adapter's own pieces are what a driver author
|
|
152
160
|
// re-uses, and a deep import into `src/` would make each of them a compatibility promise anyway.
|
|
161
|
+
export type { E2eApp, E2eAppMode, StartE2eAppOptions } from './e2e-app';
|
|
162
|
+
export { startE2eApp } from './e2e-app';
|
|
163
|
+
export { e2eApp, e2eBaseUrl, e2eBrowser } from './e2e-browser-handle';
|
|
153
164
|
export type { E2eDriverOptions } from './e2e-driver';
|
|
154
165
|
export { e2eFixtures, installE2eDriver } from './e2e-driver';
|
|
155
166
|
export {
|
|
167
|
+
E2eAppFailedError,
|
|
156
168
|
E2eEvaluateCapturedError,
|
|
157
169
|
E2eEvaluateThrewError,
|
|
158
170
|
E2eEvaluateUnsupportedError,
|
|
@@ -209,6 +221,8 @@ export {
|
|
|
209
221
|
resetCodeFixes,
|
|
210
222
|
scanScopeFixes,
|
|
211
223
|
} from './error-fixes';
|
|
224
|
+
export type { CodeUse } from './error-unthrown';
|
|
225
|
+
export { checkErrorCodesThrown } from './error-unthrown';
|
|
212
226
|
export {
|
|
213
227
|
BadFlagError,
|
|
214
228
|
BuildEntryMissingError,
|
|
@@ -281,7 +295,7 @@ export {
|
|
|
281
295
|
// `islandBundle`, `writeIslands`, `ISLAND_BASE_PATH` and `ISLAND_GLOB` stay internal: they are
|
|
282
296
|
// `x build`'s and `x dev`'s wiring, and every name here is a semver promise forever.
|
|
283
297
|
export type { IslandBundle, IslandChunk } from './island-bundle';
|
|
284
|
-
export { buildIslands } from './island-bundle';
|
|
298
|
+
export { buildIslands, clearIslandChunkCache } from './island-bundle';
|
|
285
299
|
export type { DrainFailure, DrainOutcome, DrainSkip } from './jobs-drain';
|
|
286
300
|
export { drainJobs } from './jobs-drain';
|
|
287
301
|
export type { JobsListFilter, JobsListResult } from './jobs-report';
|
package/src/island-bundle.ts
CHANGED
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
// byte-deterministic), plus the resolver that turns a page's `src` specifier into the URL its
|
|
4
4
|
// `data-x-entry` carries. One entry point per island is axiom 6 made mechanical — the page's graph
|
|
5
5
|
// never reaches an island, so a `site/` document stays at 0kb whatever the island imports.
|
|
6
|
+
//
|
|
7
|
+
// No page-client bootstrap is prepended (plan 101, decided 2026-09-22): the handle is one
|
|
8
|
+
// `globalThis` object created lazily by the first transport call or realtime hook. Measured before
|
|
9
|
+
// that decision on `examples/dummy`, a core-importing wrapper cost contact-sales 875 → 8,827 B.
|
|
10
|
+
// What IS prepended is the realtime install, and only where the island's graph reaches
|
|
11
|
+
// `@ultimat3/realtime` (`island-realtime.ts`). Measured on a fixture, As of 2026-09-22: a Solid
|
|
12
|
+
// island reading `useConnection` 64,964 → 65,067 B (+103 B); an island not reaching realtime +0.
|
|
6
13
|
|
|
7
14
|
// Bun ships no path API. `posix` does the specifier arithmetic (an app-relative route file is
|
|
8
15
|
// POSIX by construction), `join`/`basename` the filesystem side.
|
|
@@ -11,6 +18,8 @@ import { frameworkVersion, renderThrowable } from '@ultimat3/core';
|
|
|
11
18
|
import { ISLAND_EXTENSION, IslandInvalidError, islandModuleId } from '@ultimat3/render';
|
|
12
19
|
import { contentHash } from '@ultimat3/render/server';
|
|
13
20
|
import { IslandBuildFailedError } from './errors';
|
|
21
|
+
import { islandRealtimePlugin, REALTIME_ISLAND_ENTRY, reachesRealtime } from './island-realtime';
|
|
22
|
+
import { solidDedupePlugin } from './island-solid-dedupe';
|
|
14
23
|
import { islandStylesPlugin } from './island-styles';
|
|
15
24
|
import { hasPathSegment } from './path-segments';
|
|
16
25
|
import { solidJsxPlugin } from './solid-loader';
|
|
@@ -74,10 +83,13 @@ export async function discoverIslands(root: string): Promise<readonly string[]>
|
|
|
74
83
|
async function buildOne(root: string, file: string): Promise<IslandChunk> {
|
|
75
84
|
// `Bun.build` REJECTS on a failed bundle, it does not answer `success: false` — so the catch is
|
|
76
85
|
// the real path here and the `success` test below is the belt for a future default.
|
|
86
|
+
// Only an island whose own graph reaches `@ultimat3/realtime` is wrapped (`island-realtime.ts`);
|
|
87
|
+
// every other one is built from its own file, byte for byte what it was.
|
|
88
|
+
const realtime = await reachesRealtime(root, file);
|
|
77
89
|
let built: Awaited<ReturnType<typeof Bun.build>>;
|
|
78
90
|
try {
|
|
79
91
|
built = await Bun.build({
|
|
80
|
-
entrypoints: [join(root, file)],
|
|
92
|
+
entrypoints: [realtime ? REALTIME_ISLAND_ENTRY : join(root, file)],
|
|
81
93
|
target: 'browser',
|
|
82
94
|
format: 'esm',
|
|
83
95
|
splitting: false,
|
|
@@ -91,7 +103,16 @@ async function buildOne(root: string, file: string): Promise<IslandChunk> {
|
|
|
91
103
|
// The second closes the same shape of failure — a wrong answer `Bun.build` reports as
|
|
92
104
|
// `success: true`: without it, Bun's file loader resolves a `.module.scss` to its asset
|
|
93
105
|
// PATH, so `styles['x']` is `undefined` and every element renders unclassed.
|
|
94
|
-
|
|
106
|
+
//
|
|
107
|
+
// The dedupe goes FIRST: it answers `solid-js` specifiers before either plugin loads a file,
|
|
108
|
+
// so the `solid-js/web` helpers the JSX transform writes into a symlinked package resolve
|
|
109
|
+
// to the app's one copy. See `island-solid-dedupe.ts` for the measurement.
|
|
110
|
+
plugins: [
|
|
111
|
+
...(realtime ? [islandRealtimePlugin(root, file)] : []),
|
|
112
|
+
solidDedupePlugin(root),
|
|
113
|
+
solidJsxPlugin,
|
|
114
|
+
islandStylesPlugin,
|
|
115
|
+
],
|
|
95
116
|
// The third one, and it is a `define` rather than the plugin this used to be: Bun selects
|
|
96
117
|
// the `development`/`production` export condition from the BUILD PROCESS's own `NODE_ENV`,
|
|
97
118
|
// and a defined `process.env.NODE_ENV` overrides it. Measured on 1.4.0, `solid-js` plus
|
|
@@ -133,9 +154,8 @@ async function buildOne(root: string, file: string): Promise<IslandChunk> {
|
|
|
133
154
|
// The FIRST bytes this process emitted for these inputs, so a URL served `immutable` answers
|
|
134
155
|
// one byte string for as long as the process lives. Without it `x dev` re-mints the chunk on
|
|
135
156
|
// every watcher tick and a browser holding the previous one under `max-age=31536000` has two
|
|
136
|
-
// different files at one address.
|
|
137
|
-
|
|
138
|
-
bytes: new TextEncoder().encode(code).byteLength,
|
|
157
|
+
// different files at one address. `bytes` is measured on THAT code, never on this build's.
|
|
158
|
+
...stableChunk(file, hash, code),
|
|
139
159
|
};
|
|
140
160
|
}
|
|
141
161
|
|
|
@@ -148,7 +168,7 @@ async function buildOne(root: string, file: string): Promise<IslandChunk> {
|
|
|
148
168
|
*/
|
|
149
169
|
const DEBUG_ID_COMMENT = '\n//# debugId=';
|
|
150
170
|
|
|
151
|
-
function stripDebugId(code: string): string {
|
|
171
|
+
export function stripDebugId(code: string): string {
|
|
152
172
|
const at = code.lastIndexOf(DEBUG_ID_COMMENT);
|
|
153
173
|
return at === -1 ? code : code.slice(0, at);
|
|
154
174
|
}
|
|
@@ -180,7 +200,7 @@ function stripDebugId(code: string): string {
|
|
|
180
200
|
* measured at 193,590 bytes against 131,649, +47% raw and +20% gzipped, on every island of every
|
|
181
201
|
* app. Delete this the day `Bun.build` is deterministic.
|
|
182
202
|
*/
|
|
183
|
-
function graphHash(file: string, map: string): string {
|
|
203
|
+
export function graphHash(file: string, map: string): string {
|
|
184
204
|
const parsed: unknown = JSON.parse(map);
|
|
185
205
|
const contents = sourcesContentOf(parsed);
|
|
186
206
|
if (contents === undefined) {
|
|
@@ -231,11 +251,18 @@ export function clearIslandChunkCache(): void {
|
|
|
231
251
|
* value called `hash` is a digest an attacker may be probing. This one is a build input's
|
|
232
252
|
* identity — the same reason `pr-threads.ts` calls a review state `wanted`.
|
|
233
253
|
*/
|
|
234
|
-
function
|
|
254
|
+
export function stableChunk(
|
|
255
|
+
file: string,
|
|
256
|
+
graph: string,
|
|
257
|
+
code: string,
|
|
258
|
+
): { readonly code: string; readonly bytes: number } {
|
|
235
259
|
const hit = emitted.get(file);
|
|
236
|
-
|
|
237
|
-
emitted.set(file, { graph, code });
|
|
238
|
-
|
|
260
|
+
const served = hit !== undefined && hit.graph === graph ? hit.code : code;
|
|
261
|
+
if (served === code) emitted.set(file, { graph, code });
|
|
262
|
+
// Measured on the code that is SERVED. It was measured on this build's output, which under a
|
|
263
|
+
// minifier that renames differently between builds is a second size for one URL in one process
|
|
264
|
+
// — and a budget weighed on bytes no browser receives.
|
|
265
|
+
return { code: served, bytes: new TextEncoder().encode(served).byteLength };
|
|
239
266
|
}
|
|
240
267
|
|
|
241
268
|
/**
|