@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.
Files changed (53) hide show
  1. package/CLAUDE.md +70 -1
  2. package/package.json +30 -30
  3. package/src/app-env.ts +2 -2
  4. package/src/budgets.ts +45 -12
  5. package/src/build-errors.ts +54 -0
  6. package/src/cdp-browser.ts +21 -27
  7. package/src/cdp-connection.ts +66 -30
  8. package/src/cdp-e2e-page.ts +84 -113
  9. package/src/cdp-e2e-session.ts +199 -0
  10. package/src/cdp-launch.ts +95 -41
  11. package/src/cdp-offline-script.ts +73 -0
  12. package/src/cdp-pipe.ts +77 -0
  13. package/src/cmd-deploy.ts +7 -0
  14. package/src/cmd-dev.ts +23 -86
  15. package/src/cmd-shot.ts +30 -4
  16. package/src/dev-live-feed.ts +2 -0
  17. package/src/dev-render.ts +119 -20
  18. package/src/dev-route-table.ts +119 -0
  19. package/src/dev-services.ts +4 -1
  20. package/src/dev-sync.ts +5 -3
  21. package/src/e2e-app.ts +103 -0
  22. package/src/e2e-browser-handle.ts +55 -0
  23. package/src/e2e-driver.ts +32 -12
  24. package/src/e2e-errors.ts +14 -0
  25. package/src/e2e-page.ts +5 -2
  26. package/src/e2e-preload.ts +64 -0
  27. package/src/e2e-probe.ts +23 -0
  28. package/src/e2e-spawn.ts +169 -0
  29. package/src/error-codes.ts +7 -0
  30. package/src/error-unthrown.ts +130 -0
  31. package/src/errors.ts +8 -29
  32. package/src/index.ts +18 -4
  33. package/src/island-bundle.ts +38 -11
  34. package/src/island-realtime.ts +91 -0
  35. package/src/island-solid-dedupe.ts +108 -0
  36. package/src/island-verdict.ts +1 -1
  37. package/src/live-routes.ts +82 -42
  38. package/src/mcp-errors.ts +3 -0
  39. package/src/page-sync.ts +54 -0
  40. package/src/realtime-browser-probe-fixture.ts +2 -2
  41. package/src/serve.ts +9 -0
  42. package/src/shot-theme.ts +52 -0
  43. package/src/sw-artifacts.ts +13 -3
  44. package/src/sync-url.ts +31 -0
  45. package/src/templates/resource-form-island.ts +30 -21
  46. package/src/templates/route.ts +3 -0
  47. package/src/templates/scaffold-container.ts +18 -3
  48. package/src/templates/scaffold-dashboard-shared.ts +8 -5
  49. package/src/templates/scaffold-env.ts +6 -0
  50. package/src/verify-e2e.ts +38 -0
  51. package/src/verify-run.ts +105 -50
  52. package/src/verify-tests.ts +21 -4
  53. 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
+ }
@@ -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 dialing `@ultimat3/realtime`'s `LiveClient.connect()` does not fail the state it
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[];
@@ -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 with a registered `LiveClient`. Each one either subscribes, mutates
19
- * or reads the connection, so a module naming one is a module that needs a browser to have booted
20
- * it — `hasLiveClient` and `LiveClient` itself are deliberately absent: the first IS the guard, and
21
- * the second is what an island's `mount()` constructs.
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
- 'useLive',
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
- * The one escape hatch, and it is a call an author writes on purpose: a module that ASKS whether
33
- * there is a client has already written what happens when there is none. `app/update-banner.tsx`
34
- * in the reference app is the shape — imported by the layout, so by every page, and correct on all
35
- * of them.
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 = 'hasLiveClient';
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(GUARD)) return [];
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 the route module's own import graph and answer the first live hook in it.
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 liveReachOf(root: string, file: string): Promise<LiveReach | undefined> {
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 hook = liveHooksIn(module.source)[0];
108
- if (hook !== undefined) return { at: module.path, hook };
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 route that reads live rows with nothing to receive them. Two shapes, one condition — no
136
- * island at all, and an island the route declares `hydrate: 'never'` for. `X_ISLAND_NOT_HYDRATED`
137
- * covers the second only at render time, and only for a render that reaches the island, so a route
138
- * can hold the contradiction and never be asked.
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
- if (entry.islands.length > 0 && entry.config.hydrate !== 'never') continue;
148
- const reach = await liveReachOf(root, entry.file);
149
- if (reach === undefined) continue;
150
- gaps.push({
151
- ...reach,
152
- route: entry.path,
153
- file: entry.file,
154
- hydrate: entry.config.hydrate,
155
- islands: entry.islands,
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}() (${gap.at}) and ` +
165
- (gap.islands.length === 0
166
- ? 'declares no island'
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
- `${generatorFor(gap.file)}, declare it with island({ src: './${posix.basename(posix.dirname(gap.file))}${ISLAND_EXTENSION}' }) above defineRoute in ${gap.file}, ` +
171
- `and move the ${gap.hook}() read into its mount() — which is where setLiveClient() can be called`,
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 .',
@@ -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 { useLive } from '@ultimat3/realtime';
7
+ import { useQuery } from '@ultimat3/realtime';
8
8
 
9
- export const probeUseLive = useLive;
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
+ }
@@ -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 = (islands: IslandBundle, styles: StyleBundle): readonly PrecacheAsset[] =>
121
- [...islands.chunks, ...styles.chunks]
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
  );