@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,91 @@
|
|
|
1
|
+
// Realtime installed for the author, on exactly the islands that use it: an island whose own import
|
|
2
|
+
// graph reaches `@ultimat3/realtime` is built from a virtual entry that first calls
|
|
3
|
+
// `installRealtime({ signal: createSignal })` with THIS bundle's solid-js, then re-exports the
|
|
4
|
+
// island whole. Every other island is built from its own file and pays nothing (plan 101 slice 14).
|
|
5
|
+
|
|
6
|
+
// why: Bun ships no path API; the entry is joined to the root and resolved from its directory.
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import type { BunPlugin } from 'bun';
|
|
9
|
+
import { firstInGraph } from './live-routes';
|
|
10
|
+
|
|
11
|
+
/** What `Bun.build` is handed for a realtime island; resolved by `islandRealtimePlugin`. */
|
|
12
|
+
export const REALTIME_ISLAND_ENTRY = 'ultimate:island-entry';
|
|
13
|
+
|
|
14
|
+
const REALTIME = '@ultimat3/realtime';
|
|
15
|
+
const NAMESPACE = 'ultimate-island';
|
|
16
|
+
const INSTALL = 'ultimate:island-realtime';
|
|
17
|
+
const MODULE = 'ultimate:island-module';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Whether the island's own graph value-imports realtime. Relative specifiers only, which is the
|
|
21
|
+
* one blind spot: a PACKAGE importing realtime for the island is not seen, and a hook it calls
|
|
22
|
+
* then throws `X_REALTIME_UNINSTALLED` by name — loud, never silent.
|
|
23
|
+
*/
|
|
24
|
+
export async function reachesRealtime(root: string, file: string): Promise<boolean> {
|
|
25
|
+
const found = await firstInGraph(root, file, (source, path) => {
|
|
26
|
+
// The transpiler, not a regex: it erases `import type` and reads a re-export as an import.
|
|
27
|
+
const scanned = new Bun.Transpiler({ loader: path.endsWith('x') ? 'tsx' : 'ts' }).scanImports(
|
|
28
|
+
source,
|
|
29
|
+
);
|
|
30
|
+
return scanned.some((entry) => entry.path === REALTIME) ? true : undefined;
|
|
31
|
+
});
|
|
32
|
+
// Recorded with the answer, so the document renderer can ask it of a page's islands without a
|
|
33
|
+
// second graph walk per request. Every build re-asks, so an edit that drops realtime drops it.
|
|
34
|
+
if (found === true) realtimeIslands.add(file);
|
|
35
|
+
else realtimeIslands.delete(file);
|
|
36
|
+
return found === true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** App-root-relative island files whose graph reaches realtime, as the last build answered. */
|
|
40
|
+
const realtimeIslands = new Set<string>();
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The islands (app-root-relative POSIX paths) the last build found reaching realtime. A page needs
|
|
44
|
+
* realtime's page boot only if one of ITS islands does — `settings` paid 34.9 kB of boot script
|
|
45
|
+
* for an island that never touched a record.
|
|
46
|
+
*/
|
|
47
|
+
export function realtimeIslandFiles(): ReadonlySet<string> {
|
|
48
|
+
return realtimeIslands;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `export *` and never a named list: the hydration runtime reads `mount` off the module, and the
|
|
53
|
+
* wrapper must not decide which of an island's names survive. The install is imported FIRST, so it
|
|
54
|
+
* has run before the island's module body — and every hook the island calls — does.
|
|
55
|
+
*/
|
|
56
|
+
const ENTRY_SOURCE = `import '${INSTALL}';\nexport * from '${MODULE}';\n`;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The install resolves `@ultimat3/realtime` from the ISLAND's own directory — the resolution the
|
|
60
|
+
* island itself gets — so the bundle holds one copy and the signal lands where the hooks read it.
|
|
61
|
+
* `solid-js` goes through `island-solid-dedupe.ts` like every other import in the graph.
|
|
62
|
+
*/
|
|
63
|
+
const INSTALL_SOURCE = (realtime: string): string =>
|
|
64
|
+
`import { installRealtime } from ${JSON.stringify(realtime)};\n` +
|
|
65
|
+
`import { createSignal } from 'solid-js';\n` +
|
|
66
|
+
`installRealtime({ signal: createSignal });\n`;
|
|
67
|
+
|
|
68
|
+
export function islandRealtimePlugin(root: string, file: string): BunPlugin {
|
|
69
|
+
const island = join(root, file);
|
|
70
|
+
return {
|
|
71
|
+
name: 'ultimate-island-realtime',
|
|
72
|
+
setup(build) {
|
|
73
|
+
build.onResolve({ filter: /^ultimate:island-entry$/ }, () => ({
|
|
74
|
+
path: 'entry',
|
|
75
|
+
namespace: NAMESPACE,
|
|
76
|
+
}));
|
|
77
|
+
build.onResolve({ filter: /^ultimate:island-realtime$/ }, () => ({
|
|
78
|
+
path: 'install',
|
|
79
|
+
namespace: NAMESPACE,
|
|
80
|
+
}));
|
|
81
|
+
build.onResolve({ filter: /^ultimate:island-module$/ }, () => ({ path: island }));
|
|
82
|
+
build.onLoad({ filter: /.*/, namespace: NAMESPACE }, (args) => ({
|
|
83
|
+
contents:
|
|
84
|
+
args.path === 'entry'
|
|
85
|
+
? ENTRY_SOURCE
|
|
86
|
+
: INSTALL_SOURCE(Bun.resolveSync(REALTIME, dirname(island))),
|
|
87
|
+
loader: 'js',
|
|
88
|
+
}));
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// One Solid runtime per island chunk: the app's. Every `solid-js` specifier in the island's graph
|
|
2
|
+
// — the entry's own `render` from `solid-js/web`, and the `solid-js/web` helpers the JSX
|
|
3
|
+
// transform writes into every `@ultimat3/ui` component — resolves to the copy the APP installed.
|
|
4
|
+
//
|
|
5
|
+
// Without this, `Bun.build` resolves each import from the importing file's REAL path. A package
|
|
6
|
+
// reached through a symlink (`file:` overrides, `bun link`, a workspace with its own install)
|
|
7
|
+
// resolves `solid-js` from its own `node_modules`, and the chunk ships two runtimes: measured on
|
|
8
|
+
// the scaffold's theme-toggle island under CI's own `file:` links, 62,463 B against 50,042 B with
|
|
9
|
+
// one (issue #490). Two copies are also two reactive graphs — a context created by one is never
|
|
10
|
+
// found by the other's `useContext` — so the dedupe is a correctness rule that happens to be the
|
|
11
|
+
// biggest single cut in the chunk, not a size trick.
|
|
12
|
+
|
|
13
|
+
// why: Bun ships no path API; `dirname` recovers the package directory from the manifest path
|
|
14
|
+
// `Bun.resolveSync` answers, and `join` puts an export target under it.
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import type { BunPlugin } from 'bun';
|
|
17
|
+
|
|
18
|
+
const SOLID_PACKAGE = 'solid-js';
|
|
19
|
+
|
|
20
|
+
/** `solid-js` and every subpath of it — `solid-js/web`, `solid-js/store`, `solid-js/h`. */
|
|
21
|
+
export const SOLID_SPECIFIER = /^solid-js(?:\/.*)?$/;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The conditions an island is built under, in the order they are tried: `browser` because the
|
|
25
|
+
* chunk runs there, `import` because it is ESM, `default` as the map's own fallback. NOT
|
|
26
|
+
* `development` — `island-bundle.ts` defines `process.env.NODE_ENV` as `"production"` for the
|
|
27
|
+
* same reason, and solid nests `development` INSIDE `browser`, so walking without it is what
|
|
28
|
+
* selects `dist/solid.js` over `dist/dev.js`.
|
|
29
|
+
*/
|
|
30
|
+
const BROWSER_CONDITIONS: readonly string[] = ['browser', 'import', 'default'];
|
|
31
|
+
|
|
32
|
+
/** `solid-js` → `.`, `solid-js/web` → `./web`: the key the package's `exports` map uses. */
|
|
33
|
+
export function solidSubpath(specifier: string): string {
|
|
34
|
+
return specifier === SOLID_PACKAGE ? '.' : `.${specifier.slice(SOLID_PACKAGE.length)}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The target one `exports` entry names under the conditions above — depth-first, in the map's
|
|
39
|
+
* own key order, which is how Node and Bun read a conditions object. A string is a target; a
|
|
40
|
+
* nested object is walked; a condition the island does not build under (`node`, `worker`,
|
|
41
|
+
* `require`, `types`, `development`) is skipped. `undefined` when nothing matched, and the
|
|
42
|
+
* caller then leaves the specifier to Bun's own resolver rather than guessing.
|
|
43
|
+
*/
|
|
44
|
+
export function conditionTarget(entry: unknown): string | undefined {
|
|
45
|
+
if (typeof entry === 'string') return entry;
|
|
46
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return undefined;
|
|
47
|
+
for (const [condition, nested] of Object.entries(entry)) {
|
|
48
|
+
if (!BROWSER_CONDITIONS.includes(condition)) continue;
|
|
49
|
+
const target = conditionTarget(nested);
|
|
50
|
+
if (target !== undefined) return target;
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface AppSolid {
|
|
56
|
+
/** The installed package's directory — where every export target is joined onto. */
|
|
57
|
+
readonly dir: string;
|
|
58
|
+
/** Its `exports` map, verbatim. */
|
|
59
|
+
readonly exports: unknown;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The `solid-js` the app installed, found the way the app's own entry would find it: from the app
|
|
64
|
+
* root upward. `null` when there is none — an app with no islands never gets here, and an island
|
|
65
|
+
* importing solid where none is installed fails the build in Bun's own words either way.
|
|
66
|
+
*/
|
|
67
|
+
export async function resolveAppSolid(root: string): Promise<AppSolid | null> {
|
|
68
|
+
let manifest: string;
|
|
69
|
+
try {
|
|
70
|
+
manifest = Bun.resolveSync(`${SOLID_PACKAGE}/package.json`, root);
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const parsed = (await Bun.file(manifest).json()) as { readonly exports?: unknown };
|
|
75
|
+
return { dir: dirname(manifest), exports: parsed.exports };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Where `specifier` lands in the app's own copy, or `undefined` to leave it to Bun. */
|
|
79
|
+
export function appSolidPath(solid: AppSolid, specifier: string): string | undefined {
|
|
80
|
+
const exports = solid.exports;
|
|
81
|
+
if (exports === null || typeof exports !== 'object') return undefined;
|
|
82
|
+
const entry = Object.hasOwn(exports, solidSubpath(specifier))
|
|
83
|
+
? (exports as Record<string, unknown>)[solidSubpath(specifier)]
|
|
84
|
+
: undefined;
|
|
85
|
+
const target = conditionTarget(entry);
|
|
86
|
+
return target === undefined ? undefined : join(solid.dir, target);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The plugin `island-bundle.ts` installs FIRST, so it sees every `solid-js` specifier before the
|
|
91
|
+
* JSX and style plugins do. Resolved once per build and cached: the map is read from disk at
|
|
92
|
+
* most one time however many modules import solid.
|
|
93
|
+
*/
|
|
94
|
+
export function solidDedupePlugin(root: string): BunPlugin {
|
|
95
|
+
let app: Promise<AppSolid | null> | null = null;
|
|
96
|
+
return {
|
|
97
|
+
name: 'ultimate-solid-dedupe',
|
|
98
|
+
setup(build): void {
|
|
99
|
+
build.onResolve({ filter: SOLID_SPECIFIER }, async (args) => {
|
|
100
|
+
app ??= resolveAppSolid(root);
|
|
101
|
+
const solid = await app;
|
|
102
|
+
if (solid === null) return undefined;
|
|
103
|
+
const path = appSolidPath(solid, args.path);
|
|
104
|
+
return path === undefined ? undefined : { path };
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
package/src/island-verdict.ts
CHANGED
|
@@ -57,7 +57,7 @@ export interface IslandReadiness {
|
|
|
57
57
|
/**
|
|
58
58
|
* `"WS <url>"` / `"SSE <url>"` for every socket a component constructed — recorded, never
|
|
59
59
|
* gating. The harness's stand-in is inert (constructs, never opens, `close()` is a no-op), so
|
|
60
|
-
* a component
|
|
60
|
+
* a component whose page socket dials `/_x/sync` (`@ultimat3/realtime`) does not fail the state it
|
|
61
61
|
* is mounted in; this is the fact a picture cannot carry about that.
|
|
62
62
|
*/
|
|
63
63
|
readonly sockets: readonly string[];
|
package/src/live-routes.ts
CHANGED
|
@@ -12,29 +12,35 @@ import { join, posix } from 'node:path';
|
|
|
12
12
|
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
13
13
|
import type { RouteEntry } from '@ultimat3/render';
|
|
14
14
|
import { ISLAND_EXTENSION, routeEntries } from '@ultimat3/render';
|
|
15
|
+
import { discoverIslands } from './island-bundle';
|
|
15
16
|
import type { Finding } from './output';
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
|
-
* The exports that only work
|
|
19
|
-
* or
|
|
20
|
-
*
|
|
21
|
-
* the
|
|
19
|
+
* The exports that only work in a booted browser page: each one reads the page's store, its socket
|
|
20
|
+
* or its connection, so a module naming one needs an island to have run it. `hasPageSocket` is
|
|
21
|
+
* deliberately absent — it IS the guard — and so is `installRealtime`, which the island bundle
|
|
22
|
+
* writes for the author (plan 101, slice 14).
|
|
22
23
|
*/
|
|
23
24
|
export const LIVE_HOOKS = [
|
|
24
|
-
'
|
|
25
|
-
'liveHookFor',
|
|
25
|
+
'useQuery',
|
|
26
26
|
'useConnection',
|
|
27
27
|
'useMutation',
|
|
28
28
|
'useMutationQueue',
|
|
29
|
+
'useRecord',
|
|
30
|
+
'useChannel',
|
|
29
31
|
] as const;
|
|
30
32
|
|
|
31
33
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
34
|
+
* NOT an escape hatch, `As of 2026-09-22`: `hasPageSocket()` answers false on the server, every
|
|
35
|
+
* time, so a module that guards on it and never runs in a browser renders nothing forever. It was
|
|
36
|
+
* exempted here — "a module that asks has handled the absence" — and that is exactly how
|
|
37
|
+
* `examples/dummy`'s update banner, in a layout no island imports, never showed "A new version is
|
|
38
|
+
* ready.". It is a browser-only read like any hook, so it is reported like one.
|
|
36
39
|
*/
|
|
37
|
-
const GUARD = '
|
|
40
|
+
const GUARD = 'hasPageSocket';
|
|
41
|
+
|
|
42
|
+
/** Everything that only means something in a booted browser page: the hooks, and the guard. */
|
|
43
|
+
const BROWSER_ONLY: readonly string[] = [GUARD, ...LIVE_HOOKS];
|
|
38
44
|
|
|
39
45
|
/** Value imports only: `import type` is erased, so it boots nothing and needs nothing. */
|
|
40
46
|
const REALTIME_IMPORT = /import\s+([^;]*?)from\s*['"]@ultimat3\/realtime(?:\/[\w-]+)?['"]/g;
|
|
@@ -45,18 +51,14 @@ const bindingsOf = (clause: string): readonly string[] =>
|
|
|
45
51
|
.map((entry) => entry.split(/\bas\b/)[0]?.trim() ?? '')
|
|
46
52
|
.filter((name) => name.length > 0 && !name.startsWith('type '));
|
|
47
53
|
|
|
48
|
-
/**
|
|
49
|
-
* Which live hooks one module imports, or `[]` — including for a module that guards, which is a
|
|
50
|
-
* per-FILE verdict on purpose: the guard is written next to the read it protects.
|
|
51
|
-
*/
|
|
54
|
+
/** Which browser-only reads one module imports — the hooks and `hasPageSocket` — or `[]`. */
|
|
52
55
|
export function liveHooksIn(source: string): readonly string[] {
|
|
53
56
|
const hooks: string[] = [];
|
|
54
57
|
for (const match of source.matchAll(REALTIME_IMPORT)) {
|
|
55
58
|
const clause = match[1] ?? '';
|
|
56
59
|
if (clause.trimStart().startsWith('type ')) continue;
|
|
57
60
|
const names = bindingsOf(clause);
|
|
58
|
-
if (names.includes(
|
|
59
|
-
for (const hook of LIVE_HOOKS) if (names.includes(hook)) hooks.push(hook);
|
|
61
|
+
for (const hook of BROWSER_ONLY) if (names.includes(hook)) hooks.push(hook);
|
|
60
62
|
}
|
|
61
63
|
return hooks;
|
|
62
64
|
}
|
|
@@ -88,14 +90,18 @@ export interface LiveReach {
|
|
|
88
90
|
}
|
|
89
91
|
|
|
90
92
|
/**
|
|
91
|
-
* Walk
|
|
93
|
+
* Walk a module's own import graph and answer the first thing `probe` finds in it.
|
|
92
94
|
*
|
|
93
95
|
* Relative specifiers only. A bare one resolves through `node_modules` or a workspace name, and
|
|
94
96
|
* following either would mean guessing which package a name came from — the limit `fix-imports.ts`
|
|
95
97
|
* records for the same walk. So this UNDER-reports rather than over-reports: a finding here is
|
|
96
98
|
* always a real one, which is what lets the rule ship with no pin table.
|
|
97
99
|
*/
|
|
98
|
-
export async function
|
|
100
|
+
export async function firstInGraph<T>(
|
|
101
|
+
root: string,
|
|
102
|
+
file: string,
|
|
103
|
+
probe: (source: string, path: string) => T | undefined,
|
|
104
|
+
): Promise<T | undefined> {
|
|
99
105
|
const seen = new Set<string>();
|
|
100
106
|
const queue = [file];
|
|
101
107
|
while (queue.length > 0) {
|
|
@@ -104,8 +110,8 @@ export async function liveReachOf(root: string, file: string): Promise<LiveReach
|
|
|
104
110
|
seen.add(next);
|
|
105
111
|
const module = await readModule(root, next);
|
|
106
112
|
if (module === undefined) continue;
|
|
107
|
-
const
|
|
108
|
-
if (
|
|
113
|
+
const found = probe(module.source, module.path);
|
|
114
|
+
if (found !== undefined) return found;
|
|
109
115
|
const loader = module.path.endsWith('x') ? 'tsx' : 'ts';
|
|
110
116
|
// Bun's transpiler is the parser, exactly as in `scripts/boundaries.ts`: it erases type-only
|
|
111
117
|
// imports and finds the dynamic ones, which no regex over this source could do.
|
|
@@ -117,6 +123,36 @@ export async function liveReachOf(root: string, file: string): Promise<LiveReach
|
|
|
117
123
|
return undefined;
|
|
118
124
|
}
|
|
119
125
|
|
|
126
|
+
/** Every module in a file's relative import graph, app-root-relative — one walk, no probe. */
|
|
127
|
+
export async function graphModules(root: string, file: string): Promise<ReadonlySet<string>> {
|
|
128
|
+
const seen = new Set<string>();
|
|
129
|
+
await firstInGraph(root, file, (_source, path) => {
|
|
130
|
+
seen.add(path);
|
|
131
|
+
return undefined;
|
|
132
|
+
});
|
|
133
|
+
return seen;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Every module a browser can run: the union of every island's graph in the app. */
|
|
137
|
+
async function islandModules(root: string): Promise<ReadonlySet<string>> {
|
|
138
|
+
const modules = new Set<string>();
|
|
139
|
+
for (const island of await discoverIslands(root)) {
|
|
140
|
+
for (const path of await graphModules(root, island)) modules.add(path);
|
|
141
|
+
}
|
|
142
|
+
return modules;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Each module in the route's graph that reads a browser-only name, with the first name it reads. */
|
|
146
|
+
async function browserOnlyReads(root: string, file: string): Promise<readonly LiveReach[]> {
|
|
147
|
+
const reads: LiveReach[] = [];
|
|
148
|
+
await firstInGraph(root, file, (source, path) => {
|
|
149
|
+
const hook = liveHooksIn(source)[0];
|
|
150
|
+
if (hook !== undefined) reads.push({ at: path, hook });
|
|
151
|
+
return undefined;
|
|
152
|
+
});
|
|
153
|
+
return reads;
|
|
154
|
+
}
|
|
155
|
+
|
|
120
156
|
export interface LiveRouteGap extends LiveReach {
|
|
121
157
|
readonly route: string;
|
|
122
158
|
readonly file: string;
|
|
@@ -132,28 +168,34 @@ const generatorFor = (file: string): string => {
|
|
|
132
168
|
};
|
|
133
169
|
|
|
134
170
|
/**
|
|
135
|
-
* Every
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
171
|
+
* Every browser-only read a route's SERVER graph holds that no browser will ever run. A page's
|
|
172
|
+
* imports are server-rendered — an island is reached by a `src` string, never an import — so a
|
|
173
|
+
* module there runs in a browser only if some island's own graph imports it too. Anything else
|
|
174
|
+
* renders its server state (nothing, or `loading`) forever, at 200. `hydrate: 'never'` boots no
|
|
175
|
+
* island at all, so every read on such a route is reported. One finding per MODULE: a layout every
|
|
176
|
+
* page imports is one mistake, not one per route.
|
|
139
177
|
*/
|
|
140
178
|
export async function liveRouteGaps(
|
|
141
179
|
root: string,
|
|
142
180
|
entries: readonly RouteEntry[],
|
|
143
181
|
): Promise<readonly LiveRouteGap[]> {
|
|
182
|
+
const inBrowser = await islandModules(root);
|
|
183
|
+
const reported = new Set<string>();
|
|
144
184
|
const gaps: LiveRouteGap[] = [];
|
|
145
185
|
for (const entry of entries) {
|
|
146
186
|
if (entry.surface === 'api') continue;
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
187
|
+
const never = entry.config.hydrate === 'never';
|
|
188
|
+
for (const read of await browserOnlyReads(root, entry.file)) {
|
|
189
|
+
if (reported.has(read.at) || (!never && inBrowser.has(read.at))) continue;
|
|
190
|
+
reported.add(read.at);
|
|
191
|
+
gaps.push({
|
|
192
|
+
...read,
|
|
193
|
+
route: entry.path,
|
|
194
|
+
file: entry.file,
|
|
195
|
+
hydrate: entry.config.hydrate,
|
|
196
|
+
islands: entry.islands,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
157
199
|
}
|
|
158
200
|
return gaps;
|
|
159
201
|
}
|
|
@@ -161,14 +203,12 @@ export async function liveRouteGaps(
|
|
|
161
203
|
export const liveRouteFindingFor = (gap: LiveRouteGap): Finding => ({
|
|
162
204
|
code: 'X_LIVE_ROUTE_NO_ISLAND',
|
|
163
205
|
cause:
|
|
164
|
-
`${gap.route} reads ${gap.hook}()
|
|
165
|
-
(gap.
|
|
166
|
-
|
|
167
|
-
: `declares hydrate: 'never' beside ${gap.islands.join(', ')}`) +
|
|
168
|
-
', so no module of this route ever runs in a browser: its rows have nowhere to arrive and the page renders its loading branch forever, at 200',
|
|
206
|
+
`${gap.route} reads ${gap.hook}() in ${gap.at}, which ` +
|
|
207
|
+
(gap.hydrate === 'never' ? `sits on a route declaring hydrate: 'never'` : 'no island imports') +
|
|
208
|
+
' — so it only ever runs on the server, where it answers its server state (nothing, or loading) forever, at 200',
|
|
169
209
|
fix:
|
|
170
|
-
|
|
171
|
-
|
|
210
|
+
`move it into an island: ${generatorFor(gap.file)}, import ${gap.at} from that island's mount(), and declare it with island({ src: './${posix.basename(posix.dirname(gap.file))}${ISLAND_EXTENSION}' }) in ${gap.file}` +
|
|
211
|
+
(gap.hydrate === 'never' ? `, with a hydrate other than 'never'` : ''),
|
|
172
212
|
docs: ERROR_DOCS_URL,
|
|
173
213
|
at: gap.at,
|
|
174
214
|
});
|
package/src/mcp-errors.ts
CHANGED
|
@@ -102,6 +102,7 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
102
102
|
X_E2E_LOCATOR_AMBIGUOUS:
|
|
103
103
|
'x test e2e --json # the fix line carries the same call with .first() on it',
|
|
104
104
|
X_E2E_SERVICE_WORKER_ABSENT: 'x build --target static --json',
|
|
105
|
+
X_E2E_APP_FAILED: 'x dev --json',
|
|
105
106
|
// The four raw-CDP codes. `x doctor` for the missing browser, because that is the command whose
|
|
106
107
|
// whole job is reporting what this machine does not have; the other three are raised inside a
|
|
107
108
|
// running suite, so the runnable half is the command that re-runs it.
|
|
@@ -122,6 +123,8 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
122
123
|
'x errors list --json # register the code in its package src/errors.ts, or move its row under "Reserved codes"',
|
|
123
124
|
X_ERROR_CODE_UNRESOLVED:
|
|
124
125
|
'x verify --json # the finding names the file, the line and the name it could not resolve',
|
|
126
|
+
X_ERROR_CODE_UNTHROWN:
|
|
127
|
+
'x errors explain X_ERROR_CODE_UNTHROWN --json # then mark the row "registered, thrown by nothing since <version>"',
|
|
125
128
|
X_CLI_UNEXPECTED: 'x doctor --json',
|
|
126
129
|
X_TYPECHECK_FAILED: 'bunx tsc -b --pretty false',
|
|
127
130
|
X_LINT_FAILED: 'bunx biome check --write .',
|
package/src/page-sync.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// What a page needs to reach the sync node, composed ONCE for `x dev` and the container alike: the
|
|
2
|
+
// worker bundle and the page boot with their routes, the head a document carries (`ultimate-sync`,
|
|
3
|
+
// `ultimate-build`, `ultimate-sync-worker`, the boot script), and the persisted record types a
|
|
4
|
+
// private document names. One call from both boots, so the two cannot serve different targets.
|
|
5
|
+
|
|
6
|
+
import { persistedRecordTypes } from '@ultimat3/entity';
|
|
7
|
+
import type { Route } from '@ultimat3/http';
|
|
8
|
+
import type { ClientSyncHead } from '@ultimat3/render';
|
|
9
|
+
import { syncUrlFrom } from './sync-url';
|
|
10
|
+
import {
|
|
11
|
+
buildPageBoot,
|
|
12
|
+
buildSyncWorker,
|
|
13
|
+
type FrameworkScript,
|
|
14
|
+
pageBootRoutes,
|
|
15
|
+
syncWorkerRoutes,
|
|
16
|
+
} from './worker-bundle';
|
|
17
|
+
|
|
18
|
+
export interface PageSync {
|
|
19
|
+
readonly routes: readonly Route[];
|
|
20
|
+
/** The scripts those routes serve, for the service worker to precache beside the islands. */
|
|
21
|
+
readonly scripts: readonly FrameworkScript[];
|
|
22
|
+
readonly head: ClientSyncHead;
|
|
23
|
+
/**
|
|
24
|
+
* The record types the app persists, read per render off the entity registry — the app's modules
|
|
25
|
+
* register their entities during boot, so a value captured here could predate them.
|
|
26
|
+
*/
|
|
27
|
+
readonly persisted: () => readonly string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Built at boot and never on the watcher tick: the worker is framework code, not the app's, and a
|
|
32
|
+
* worker that changes under tabs already running it is exactly what a source-addressed URL exists
|
|
33
|
+
* to keep from happening mid-session.
|
|
34
|
+
*/
|
|
35
|
+
export async function pageSync(
|
|
36
|
+
root: string,
|
|
37
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
38
|
+
buildId: string,
|
|
39
|
+
): Promise<PageSync> {
|
|
40
|
+
const syncUrl = syncUrlFrom(env);
|
|
41
|
+
const worker = await buildSyncWorker(root);
|
|
42
|
+
const boot = await buildPageBoot(root);
|
|
43
|
+
return {
|
|
44
|
+
routes: [...syncWorkerRoutes(() => worker), ...pageBootRoutes(() => boot)],
|
|
45
|
+
scripts: [worker, boot].filter((script): script is FrameworkScript => script !== undefined),
|
|
46
|
+
head: {
|
|
47
|
+
syncUrl,
|
|
48
|
+
buildId,
|
|
49
|
+
...(worker === undefined ? {} : { workerUrl: worker.url }),
|
|
50
|
+
...(boot === undefined ? {} : { bootUrl: boot.url }),
|
|
51
|
+
},
|
|
52
|
+
persisted: persistedRecordTypes,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -4,6 +4,6 @@
|
|
|
4
4
|
// Bundled AND imported by `realtime-browser-barrel.test.ts` — the import is what gives it an lcov
|
|
5
5
|
// record, since `Bun.build()` reads this file without evaluating it.
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { useQuery } from '@ultimat3/realtime';
|
|
8
8
|
|
|
9
|
-
export const
|
|
9
|
+
export const probeUseQuery = useQuery;
|
package/src/serve.ts
CHANGED
|
@@ -48,6 +48,7 @@ import { islandRoutes } from './island-routes';
|
|
|
48
48
|
import { DEFAULT_METRICS_PORT } from './metrics-endpoint';
|
|
49
49
|
import { readMigrations } from './migrations';
|
|
50
50
|
import { startOtlpExport } from './otlp-export';
|
|
51
|
+
import { pageSync } from './page-sync';
|
|
51
52
|
import { loadPwaArtifacts } from './pwa-artifacts';
|
|
52
53
|
import type { RuntimeOverrides } from './runtime-overrides';
|
|
53
54
|
import { styleBundle } from './style-bundle';
|
|
@@ -363,6 +364,9 @@ async function bootRoles(boot: {
|
|
|
363
364
|
// prevent, and it is the one an operator cannot see without installing the app.
|
|
364
365
|
const pwa = await loadPwaArtifacts(options.root);
|
|
365
366
|
const theme = themeBoot(await loadThemeMode(options.root));
|
|
367
|
+
// The page's sync target and its scripts — the same call `x dev` makes, so the two cannot differ.
|
|
368
|
+
// Before the service worker, which precaches those scripts.
|
|
369
|
+
const sync = await pageSync(options.root, options.env, buildId);
|
|
366
370
|
// The worker, from the SAME route table this process is about to serve — `describeRoutes()` is
|
|
367
371
|
// the one projection `x.manifest.json`, `/_x`, the sitemap and `sw.js` are all built from, so a
|
|
368
372
|
// route added here cannot be missing from the precache manifest.
|
|
@@ -375,6 +379,7 @@ async function bootRoles(boot: {
|
|
|
375
379
|
routes: describeRoutes(),
|
|
376
380
|
islands,
|
|
377
381
|
styles: styleBundle(),
|
|
382
|
+
scripts: sync.scripts,
|
|
378
383
|
});
|
|
379
384
|
// The app's own MCP endpoint, through the same call `x dev` makes — see `app-mcp.ts`.
|
|
380
385
|
const mcpMount = await mountAppMcp(options.root);
|
|
@@ -393,9 +398,13 @@ async function bootRoles(boot: {
|
|
|
393
398
|
// The surface stylesheets the documents link. Built from the registry the `loadApp` above
|
|
394
399
|
// filled, so this process serves exactly the CSS it renders against.
|
|
395
400
|
...styleRoutes(() => styleBundle()),
|
|
401
|
+
// The page's one socket: its worker script, served beside the islands for their reason.
|
|
402
|
+
...sync.routes,
|
|
396
403
|
...appRoutes({
|
|
397
404
|
buildId,
|
|
398
405
|
resolveIsland: (file) => islands.resolverFor(file),
|
|
406
|
+
sync: sync.head,
|
|
407
|
+
persisted: sync.persisted,
|
|
399
408
|
themeHead: theme.head,
|
|
400
409
|
...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
|
|
401
410
|
// Only when a store was supplied. `createIsrController` defaults to a per-process memory
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// The script a capture runs ahead of the document when a theme was ASKED for. Since 20.2.0 the
|
|
2
|
+
// boot inlines a theme script (`@ultimat3/render`'s `themeScriptBody`) whose fallback is the app's
|
|
3
|
+
// `theme.defaultMode`, and only a stored choice under `THEME_STORAGE_KEY` beats it — so emulating
|
|
4
|
+
// `prefers-color-scheme` alone photographs a `defaultMode: 'dark'` app dark whatever was requested
|
|
5
|
+
// (issue #489). A requested theme is therefore stored as the visitor's CHOICE, on the page's origin,
|
|
6
|
+
// before the boot reads it: the picture is then what a visitor who chose that theme sees.
|
|
7
|
+
|
|
8
|
+
import { THEME_STORAGE_KEY } from '@ultimat3/render';
|
|
9
|
+
import type { ColorScheme } from '@ultimat3/scraping';
|
|
10
|
+
import { BadFlagError } from './errors';
|
|
11
|
+
|
|
12
|
+
/** What `x shot --theme` accepts: the two the boot honours from storage, and nothing else. */
|
|
13
|
+
const SHOT_THEMES = ['light', 'dark'] as const;
|
|
14
|
+
export type ShotTheme = (typeof SHOT_THEMES)[number];
|
|
15
|
+
|
|
16
|
+
const isShotTheme = (value: string): value is ShotTheme =>
|
|
17
|
+
(SHOT_THEMES as readonly string[]).includes(value);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `--theme light|dark` on a ROUTE shot, or nothing — the box's own preference and the app's own
|
|
21
|
+
* default, which is what `x shot` has always photographed. Refused by name for any other value:
|
|
22
|
+
* `no-preference` is the scraping vocabulary's clear, not a theme a reader can ask for.
|
|
23
|
+
*/
|
|
24
|
+
export function readThemeFlag(value: string | undefined): ShotTheme | undefined {
|
|
25
|
+
if (value === undefined) return undefined;
|
|
26
|
+
if (isShotTheme(value)) return value;
|
|
27
|
+
throw new BadFlagError({
|
|
28
|
+
flag: 'theme',
|
|
29
|
+
command: 'shot',
|
|
30
|
+
reason: `"${value}" is not a theme; it is light or dark`,
|
|
31
|
+
fix: 'x shot / --theme light --json',
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The seeding expression, DETERMINISTIC per scheme: the offline drivers key recordings on the exact
|
|
37
|
+
* string, and a test asserts on it by value. `'no-preference'` answers `undefined` — the boot
|
|
38
|
+
* honours only `"light"` and `"dark"` from storage, so there is no choice to store, and storing
|
|
39
|
+
* anything else would be a value the tokens have no block for.
|
|
40
|
+
*
|
|
41
|
+
* `JSON.stringify` on both halves, never a value pasted between quotes: the key and the scheme land
|
|
42
|
+
* inside a JS string, where one `"` ends it. The `try` is for the origin the page starts on:
|
|
43
|
+
* `about:blank` is opaque and its `localStorage` throws, and a throw from a new-document script
|
|
44
|
+
* would surface as a page error on a capture that has not navigated yet.
|
|
45
|
+
*/
|
|
46
|
+
export function themeChoiceExpression(scheme: ColorScheme): string | undefined {
|
|
47
|
+
if (scheme === 'no-preference') return undefined;
|
|
48
|
+
return (
|
|
49
|
+
`try{localStorage.setItem(${JSON.stringify(THEME_STORAGE_KEY)},${JSON.stringify(scheme)})}` +
|
|
50
|
+
'catch(e){}'
|
|
51
|
+
);
|
|
52
|
+
}
|
package/src/sw-artifacts.ts
CHANGED
|
@@ -57,6 +57,12 @@ export interface ServiceWorkerInput {
|
|
|
57
57
|
* this map does not name.
|
|
58
58
|
*/
|
|
59
59
|
readonly documents?: ReadonlyMap<string, RenderedDocument>;
|
|
60
|
+
/**
|
|
61
|
+
* The page's framework scripts — realtime's page boot and sync worker (`pageSync(…).scripts`).
|
|
62
|
+
* Precached beside the island chunks for their reason: source-addressed and `immutable`, and an
|
|
63
|
+
* offline reload that cannot load the boot restores no record and shows the old count.
|
|
64
|
+
*/
|
|
65
|
+
readonly scripts?: readonly { readonly url: string; readonly bytes: number }[];
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
/**
|
|
@@ -117,8 +123,12 @@ const pwaRoutes = (
|
|
|
117
123
|
* Sorted by url, because `buildPrecacheManifest` sorts its own entries but the ASSET list is what
|
|
118
124
|
* decides which of two equal urls wins, and `sw.js` must be byte-identical for identical input.
|
|
119
125
|
*/
|
|
120
|
-
const staticAssets = (
|
|
121
|
-
|
|
126
|
+
const staticAssets = (
|
|
127
|
+
islands: IslandBundle,
|
|
128
|
+
styles: StyleBundle,
|
|
129
|
+
scripts: readonly { readonly url: string; readonly bytes: number }[],
|
|
130
|
+
): readonly PrecacheAsset[] =>
|
|
131
|
+
[...islands.chunks, ...styles.chunks, ...scripts]
|
|
122
132
|
.map((chunk) => ({ url: chunk.url, revision: chunk.url, bytes: chunk.bytes }))
|
|
123
133
|
.sort((a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : 0));
|
|
124
134
|
|
|
@@ -200,7 +210,7 @@ export function serviceWorkerArtifacts(
|
|
|
200
210
|
neverCache: pwa.offline.neverCache,
|
|
201
211
|
},
|
|
202
212
|
capabilities: { backgroundSync: pwa.backgroundSync, push: pwa.push },
|
|
203
|
-
assets: staticAssets(input.islands, input.styles),
|
|
213
|
+
assets: staticAssets(input.islands, input.styles, input.scripts ?? []),
|
|
204
214
|
},
|
|
205
215
|
input.buildId,
|
|
206
216
|
);
|