@ultimat3/cli 20.2.1 → 21.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CLAUDE.md +69 -1
  2. package/package.json +30 -30
  3. package/src/app-env.ts +2 -2
  4. package/src/budgets.ts +45 -12
  5. package/src/build-errors.ts +54 -0
  6. package/src/cdp-browser.ts +21 -27
  7. package/src/cdp-connection.ts +66 -30
  8. package/src/cdp-e2e-page.ts +84 -113
  9. package/src/cdp-e2e-session.ts +199 -0
  10. package/src/cdp-launch.ts +95 -41
  11. package/src/cdp-offline-script.ts +73 -0
  12. package/src/cdp-pipe.ts +77 -0
  13. package/src/cmd-deploy.ts +7 -0
  14. package/src/cmd-dev.ts +23 -86
  15. package/src/dev-live-feed.ts +2 -0
  16. package/src/dev-render.ts +119 -20
  17. package/src/dev-route-table.ts +119 -0
  18. package/src/dev-services.ts +4 -1
  19. package/src/dev-sync.ts +5 -3
  20. package/src/e2e-app.ts +103 -0
  21. package/src/e2e-browser-handle.ts +55 -0
  22. package/src/e2e-driver.ts +32 -12
  23. package/src/e2e-errors.ts +14 -0
  24. package/src/e2e-page.ts +5 -2
  25. package/src/e2e-preload.ts +64 -0
  26. package/src/e2e-probe.ts +23 -0
  27. package/src/e2e-spawn.ts +169 -0
  28. package/src/error-codes.ts +7 -0
  29. package/src/error-unthrown.ts +130 -0
  30. package/src/errors.ts +8 -29
  31. package/src/index.ts +18 -4
  32. package/src/island-bundle.ts +33 -11
  33. package/src/island-realtime.ts +91 -0
  34. package/src/island-verdict.ts +1 -1
  35. package/src/live-routes.ts +82 -42
  36. package/src/mcp-errors.ts +3 -0
  37. package/src/page-sync.ts +54 -0
  38. package/src/realtime-browser-probe-fixture.ts +2 -2
  39. package/src/serve.ts +9 -0
  40. package/src/sw-artifacts.ts +13 -3
  41. package/src/sync-url.ts +31 -0
  42. package/src/templates/resource-form-island.ts +30 -21
  43. package/src/templates/route.ts +3 -0
  44. package/src/templates/scaffold-container.ts +18 -3
  45. package/src/templates/scaffold-env.ts +6 -0
  46. package/src/verify-e2e.ts +38 -0
  47. package/src/verify-run.ts +105 -50
  48. package/src/verify-tests.ts +21 -4
  49. package/src/worker-bundle.ts +192 -0
@@ -0,0 +1,73 @@
1
+ // One responsibility: `navigator.onLine` reads `false` from a document's FIRST script while the
2
+ // session's offline switch is thrown, and reads `true` again — with the `online` event a page
3
+ // reconnects on — when it goes back. `Network.emulateNetworkConditions` cuts the NETWORK for a
4
+ // document created under the switch but, measured on Chrome 150, never tells it so: a reload under
5
+ // the switch read `navigator.onLine === true` at its first script every time, COOP or not, still
6
+ // read `true` a second later, and got no `online` event when the switch went back.
7
+ // `Network.overrideNetworkState` changed nothing. The dummy's page boot asks at its first script
8
+ // and replayed its outbox with a real POST under the cut (`offline-like.e2e.test.ts`, two attempts).
9
+ //
10
+ // The override is an OWN property on `navigator`, so the real getter on `Navigator.prototype` is one
11
+ // `delete` away. A document Chrome DID tell (it saw `offline`) gets Chrome's own `online` event on
12
+ // restore; one it never told gets one from `RESTORE_ONLINE`, so a page reconnects exactly once.
13
+
14
+ import type { CdpResult } from './cdp-connection';
15
+
16
+ type Send = (
17
+ method: string,
18
+ params: Record<string, unknown>,
19
+ session: string,
20
+ ) => Promise<CdpResult>;
21
+
22
+ /** Runs before the page's own scripts, in every document a page session creates while cut. */
23
+ export const OFFLINE_FIRST_SCRIPT = `(() => {
24
+ let told = false;
25
+ Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => false });
26
+ addEventListener('offline', () => { told = true; });
27
+ addEventListener('online', () => { delete navigator.onLine; }, { once: true });
28
+ Object.defineProperty(window, '__xRestoreOnLine', { configurable: true, value: () => {
29
+ if (!Object.getOwnPropertyDescriptor(navigator, 'onLine')) return;
30
+ delete navigator.onLine;
31
+ if (!told) dispatchEvent(new Event('online'));
32
+ } });
33
+ })();`;
34
+
35
+ /** Evaluated in every open page when the switch goes back: a no-op in a document never cut. */
36
+ export const RESTORE_ONLINE = `window.__xRestoreOnLine?.()`;
37
+
38
+ export interface OfflineScripts {
39
+ /** Register the script on one page session; answers once the browser has it. */
40
+ add(session: string): Promise<unknown>;
41
+ /** Unregister it from one page session, if it holds it, and restore the open document. */
42
+ remove(session: string): Promise<unknown>;
43
+ }
44
+
45
+ const field = (from: unknown, key: string): string | undefined => {
46
+ const value =
47
+ typeof from === 'object' && from !== null ? (from as Record<string, unknown>)[key] : undefined;
48
+ return typeof value === 'string' ? value : undefined;
49
+ };
50
+
51
+ /** Per-session registration, keyed by the identifier the browser hands back. */
52
+ export function offlineScripts(send: Send): OfflineScripts {
53
+ const held = new Map<string, string>();
54
+ return {
55
+ async add(session) {
56
+ const answer = await send(
57
+ 'Page.addScriptToEvaluateOnNewDocument',
58
+ { source: OFFLINE_FIRST_SCRIPT },
59
+ session,
60
+ );
61
+ const identifier = field(answer.result, 'identifier');
62
+ if (identifier !== undefined) held.set(session, identifier);
63
+ return answer;
64
+ },
65
+ async remove(session) {
66
+ const identifier = held.get(session);
67
+ if (identifier === undefined) return undefined;
68
+ held.delete(session);
69
+ await send('Page.removeScriptToEvaluateOnNewDocument', { identifier }, session);
70
+ return send('Runtime.evaluate', { expression: RESTORE_ONLINE }, session);
71
+ },
72
+ };
73
+ }
@@ -0,0 +1,77 @@
1
+ // One responsibility: CDP over the pipe `--remote-debugging-pipe` opens — Chrome reads commands on
2
+ // its fd 3 and writes replies and events on its fd 4, each message one JSON text ended by a NUL
3
+ // byte. This is the e2e driver's wire; the WebSocket in `cdp-connection.ts` is for a remote browser.
4
+ //
5
+ // Why a pipe rather than the WebSocket Chrome also offers: Bun 1.4.0's WebSocket client handed
6
+ // `onmessage` text spliced from several frames under the dummy's `offline-feed` load — 64
7
+ // unparseable frames in one run, one of them a `Runtime.evaluate` reply that then waited out its
8
+ // 30 s deadline. A frame nobody can parse has no `id`, so no layer above can even tell which call
9
+ // it lost. A pipe is bytes and a delimiter, read here, and nothing in between.
10
+
11
+ import type { CdpTransport } from './cdp-connection';
12
+
13
+ /** The two ends a transport needs: a sink for whole messages, and the byte stream Chrome writes. */
14
+ export interface PipeEnds {
15
+ /** Write these bytes to Chrome's fd 3, in order. */
16
+ readonly write: (bytes: Uint8Array) => void;
17
+ /** Chrome's fd 4. */
18
+ readonly read: ReadableStream<Uint8Array>;
19
+ /** Release the write end — Chrome treats its fd 3 closing as the client going away. */
20
+ readonly end: () => void;
21
+ }
22
+
23
+ const NUL = 0;
24
+
25
+ /** Concatenate two byte arrays; the reader only ever holds the unterminated tail. */
26
+ const join = (a: Uint8Array, b: Uint8Array): Uint8Array => {
27
+ if (a.length === 0) return b;
28
+ const out = new Uint8Array(a.length + b.length);
29
+ out.set(a);
30
+ out.set(b, a.length);
31
+ return out;
32
+ };
33
+
34
+ export function pipeTransport(ends: PipeEnds): CdpTransport {
35
+ const encoder = new TextEncoder();
36
+ let closed = false;
37
+ const reader = ends.read.getReader();
38
+ return {
39
+ send(text: string): void {
40
+ if (closed) return;
41
+ const body = encoder.encode(text);
42
+ const framed = new Uint8Array(body.length + 1);
43
+ framed.set(body);
44
+ framed[body.length] = NUL;
45
+ ends.write(framed);
46
+ },
47
+ close(): void {
48
+ if (closed) return;
49
+ closed = true;
50
+ ends.end();
51
+ void reader.cancel().catch(() => undefined);
52
+ },
53
+ listen(handlers): void {
54
+ void (async () => {
55
+ // Split on BYTES, decoded per message: a multi-byte character may straddle two reads, and
56
+ // decoding each read on its own would corrupt it — the failure this file exists to end.
57
+ const decoder = new TextDecoder();
58
+ let tail: Uint8Array = new Uint8Array(0);
59
+ try {
60
+ for (;;) {
61
+ const { value, done } = await reader.read();
62
+ if (done) break;
63
+ tail = join(tail, value);
64
+ for (let at = tail.indexOf(NUL); at !== -1; at = tail.indexOf(NUL)) {
65
+ handlers.message(decoder.decode(tail.subarray(0, at)));
66
+ tail = tail.subarray(at + 1);
67
+ }
68
+ }
69
+ } catch {
70
+ // A read that fails is a pipe that is gone — reported as the close it is, below.
71
+ }
72
+ closed = true;
73
+ handlers.closed('the browser closed the CDP pipe');
74
+ })();
75
+ },
76
+ };
77
+ }
package/src/cmd-deploy.ts CHANGED
@@ -11,6 +11,7 @@ import { msg } from './messages';
11
11
  import type { CommandResult, JsonValue } from './output';
12
12
  import { flagBool, flagString } from './parse';
13
13
  import { quoteArg } from './shell-quote';
14
+ import { PROD_ENV_FILE } from './templates/scaffold-container';
14
15
 
15
16
  /**
16
17
  * Ordered, and the order is the design. `migrate` GATES — it runs to completion before anything
@@ -146,6 +147,12 @@ export function planDeploy(image: string, method: DeployMethod, root: string): D
146
147
  command: [
147
148
  'docker',
148
149
  'compose',
150
+ // Compose interpolates `${SYNC_URL:?…}` and `${POSTGRES_PASSWORD:?…}` from the shell and
151
+ // `--env-file` only — never from a service's `env_file:`. Without this an operator who put
152
+ // them in `.env.production`, the one file the compose file tells them to fill, had every
153
+ // step die on a parse error. Global flag, so it precedes `-f`; the shell still wins over it.
154
+ '--env-file',
155
+ join(root, PROD_ENV_FILE),
149
156
  '-f',
150
157
  join(root, 'docker', 'docker-compose.prod.yml'),
151
158
  ONE_SHOT_ROLES.includes(role) ? 'run' : 'up',
package/src/cmd-dev.ts CHANGED
@@ -9,58 +9,43 @@ import { devShellStyle } from '@ultimat3/admin/dev';
9
9
  import type { Role } from '@ultimat3/core';
10
10
  import { configureTelemetry, METRICS_PATH, noopExporter } from '@ultimat3/core';
11
11
  import { setStatementObserver } from '@ultimat3/db';
12
- import type { OverlayNotice, RequestContext, Route } from '@ultimat3/http';
12
+ import type { OverlayNotice, RequestContext } from '@ultimat3/http';
13
13
  import { asCtx } from '@ultimat3/http';
14
14
  import type { Manifest } from '@ultimat3/manifest';
15
15
  import { MANIFEST_FILENAME } from '@ultimat3/manifest';
16
- import { describeRoutes } from '@ultimat3/render';
17
- import { apiRoutes } from './api-routes';
18
16
  import { loadSignInPath } from './app-auth';
19
17
  import { appManifest } from './app-manifest';
20
- import { mountAppMcp } from './app-mcp';
21
18
  import { requireAppRoot } from './app-root';
22
19
  import { loadAppRuntime } from './app-runtime';
23
20
  import type { CliCommand, CommandContext } from './command';
24
- import { assetRoutes } from './dev-assets';
25
21
  import type { DevDashboardInput, DevStatus } from './dev-dashboard';
26
- import { devDashboardRoutes, devPanels } from './dev-dashboard';
22
+ import { devPanels } from './dev-dashboard';
27
23
  import { declareDevEnvironment } from './dev-environment';
28
24
  import { liveFeedLabel } from './dev-live-feed';
29
25
  import { clearLock, preflight, writeLock } from './dev-lock';
30
26
  import { createStatementLedger } from './dev-n-plus-one';
31
27
  import { coalesceReloads } from './dev-reload';
32
- import { appRoutes } from './dev-render';
33
28
  import { replicaOverrides } from './dev-replica';
34
29
  import type { RunningRoles } from './dev-roles';
35
30
  import { DEV_BINDING, DEV_ROLES, selectRoles, startRoles } from './dev-roles';
31
+ import { devRouteTable } from './dev-route-table';
36
32
  import type { RunningServices } from './dev-runtime';
37
33
  import { cdnLabel, describeCdn, describeMail, mailLabel, startServices } from './dev-runtime';
38
34
  import type { DevServices } from './dev-services';
39
35
  import { describeServices, reportedUrls, resolveServices } from './dev-services';
40
- import { storageRoutes } from './dev-storage';
41
36
  import { createTraceRecorder } from './dev-traces';
42
37
  import { watchTree } from './dev-watch-tree';
43
- import { errorPageStyleSources } from './error-page-csp';
44
38
  import { intFlagOr, PORT_RANGE } from './flag-number';
45
39
  import { holdUntilShutdown } from './hold';
46
40
  import type { IslandBundle } from './island-bundle';
47
41
  import { buildIslands } from './island-bundle';
48
42
  import { FRAME_STYLE } from './island-harness';
49
- import { islandHarnessRoutes } from './island-harness-route';
50
- import { islandRoutes } from './island-routes';
51
- import { loadIslandStates } from './island-states-load';
52
43
  import { msg } from './messages';
53
44
  import type { CommandResult, Finding } from './output';
54
45
  import { findingFrom } from './output';
55
46
  import { flagString } from './parse';
56
- import { loadPwaArtifacts } from './pwa-artifacts';
57
47
  import { metricsPortFor } from './serve';
58
48
  import { loopFacts, loopFinding, loopNotice } from './statement-loop';
59
- import { styleBundle } from './style-bundle';
60
- import { styleRoutes } from './style-routes';
61
- import { serviceWorkerArtifacts } from './sw-artifacts';
62
- import { serviceWorkerRoutes } from './sw-routes';
63
- import { loadThemeMode, themeBoot } from './theme-boot';
64
49
 
65
50
  const DEFAULT_PORT = 3000;
66
51
 
@@ -68,7 +53,10 @@ export interface DevServer {
68
53
  readonly url: string;
69
54
  readonly services: DevServices;
70
55
  readonly roles: readonly Role[];
71
- /** The manifest as it stands now — a reload that registers a new route moves it. */
56
+ /**
57
+ * `BUILD_ID` when stamped — the id every response carries. Otherwise the manifest as it stands
58
+ * now, so a reload that registers a new route moves it.
59
+ */
72
60
  readonly buildId: string;
73
61
  /**
74
62
  * Modules that would not import, primitives that would not register, reloads that would not
@@ -164,7 +152,12 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
164
152
  // boot on purpose: the header is handed to the HTTP config and the render modes once, and a
165
153
  // reload cannot re-pin it — `state.manifest.buildId` is what `/_x` and `--json` report, so a
166
154
  // divergence between the two is visible rather than silent, and a restart closes it.
167
- const buildId = state.manifest.buildId;
155
+ // `BUILD_ID` wins when set — `serve.ts`'s rule, so an e2e `deploy.newBuild()` can restart `x dev`
156
+ // as a new build with the same sources.
157
+ // Stamped, it is ALSO what `server.buildId` answers below: the process serves no other build.
158
+ const rawStamp = options.env['BUILD_ID'];
159
+ const stamped = rawStamp !== undefined && rawStamp !== '' ? rawStamp : undefined;
160
+ const buildId = stamped ?? state.manifest.buildId;
168
161
 
169
162
  let server: DevServer;
170
163
  // Read at request time, never captured at boot: `/_x/services` must report the reload counter
@@ -185,70 +178,14 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
185
178
  };
186
179
  const panels = devPanels(dashboard).map((panel) => panel.key);
187
180
 
188
- // Resolved once, before the first route: the manifest's bytes and the three head elements that
189
- // name it. `undefined` for an app that is not installable, and then nothing is mounted and no
190
- // document changes — the 0kb baseline is not spent on a `<link>` to a file that does not exist.
191
- const pwa = await loadPwaArtifacts(options.root);
192
- const theme = themeBoot(await loadThemeMode(options.root));
193
- const errorStyles = await errorPageStyleSources(options.root);
194
- // Built once at boot, from this process's own route table and island bundle. `x dev` rebuilds
195
- // islands on the watcher tick and the worker is NOT rebuilt with them, deliberately: a service
196
- // worker that changes under a page it already controls is the update path, and re-emitting one
197
- // per keystroke would exercise it on every save.
198
- const serviceWorker =
199
- pwa === undefined
200
- ? undefined
201
- : serviceWorkerArtifacts({
202
- pwa,
203
- buildId,
204
- routes: describeRoutes(),
205
- islands: state.islands,
206
- styles: styleBundle(),
207
- });
208
-
209
- // The app's own MCP endpoint, discovered from `apps/<app>/mcp.ts` and mounted through the SAME
210
- // call `runRole` makes — `POST /mcp` answered 404 in every process the framework booted until
211
- // one of them asked. Warned once here when `expose` is true and nothing can be mounted.
212
- const mcpMount = await mountAppMcp(options.root);
213
- const routes: readonly Route[] = [
214
- ...devDashboardRoutes(dashboard),
215
- // The same API table the container serves: a read that answers here and 404s in production
216
- // is exactly the drift one composition exists to prevent.
217
- ...apiRoutes(),
218
- ...mcpMount.routes,
219
- // The image pipeline's only HTTP surface: the icons the web manifest declares, and the
220
- // variants every `srcset` promises. Mounted before the app's own routes so a page route can
221
- // never shadow `/icons` or `/media`.
222
- ...assetRoutes({
223
- root: options.root,
224
- storage: runtime.storage,
225
- ...(pwa === undefined ? {} : { pwa }),
226
- }),
227
- ...storageRoutes({ storage: runtime.storage }),
228
- // The chunks the documents below name. Mounted before the app's routes for the reason
229
- // `/icons` and `/media` are: a page route must not be able to shadow an asset URL.
230
- ...islandRoutes(() => state.islands),
231
- // And the stylesheet every one of those documents links. Read through the getter for the
232
- // reason the islands are: a rebuilt island registers CSS, which mints a new URL, and a table
233
- // captured at boot would answer 404 for the href the document now carries.
234
- ...styleRoutes(() => styleBundle()),
235
- // `x shot --island`'s harness, in the `/_x` dev namespace so no app route can shadow it. It
236
- // lives here rather than in a second server because everything it needs is in THIS process:
237
- // the built chunks, the app's stylesheet registry, and the one embedded Postgres a checkout
238
- // may have. The states are read per REQUEST — an author editing a state and re-running the
239
- // command must not need a restart to see it.
240
- ...islandHarnessRoutes({
241
- islands: () => state.islands,
242
- states: () => loadIslandStates(options.root),
243
- }),
244
- ...(serviceWorker === undefined ? [] : serviceWorkerRoutes(serviceWorker)),
245
- ...appRoutes({
246
- buildId,
247
- resolveIsland: (file) => state.islands.resolverFor(file),
248
- themeHead: theme.head,
249
- ...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
250
- }),
251
- ];
181
+ const { routes, theme, errorStyles, mcpPath } = await devRouteTable({
182
+ root: options.root,
183
+ env: options.env,
184
+ buildId,
185
+ storage: runtime.storage,
186
+ dashboard,
187
+ islands: () => state.islands,
188
+ });
252
189
 
253
190
  // The app's `apps/<app>/runtime.ts`, composed exactly as `runRole` composes a caller's
254
191
  // `runtime`: the replica scope in front, the app's own middleware behind it. Before this the
@@ -320,9 +257,9 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
320
257
  url: running.url ?? `http://localhost:${options.port}`,
321
258
  services,
322
259
  roles: running.roles,
323
- mcp: mcpMount.path,
260
+ mcp: mcpPath,
324
261
  get buildId(): string {
325
- return state.manifest.buildId;
262
+ return stamped ?? state.manifest.buildId;
326
263
  },
327
264
  // A getter, not a snapshot: `/_x` and `--json` must show the reload that just failed and the
328
265
  // loop the last request tripped, not the findings as they were when the route table was built.
@@ -49,6 +49,8 @@ export async function startLiveFeed(input: LiveFeedInput): Promise<RunningLiveFe
49
49
  }
50
50
  const bridge = await startLiveReplicator({
51
51
  registry: input.sync.registry,
52
+ // The same changes the channels read — a real node's change subscription feeds both.
53
+ channels: input.sync.hub,
52
54
  // Logged, never thrown: one change nobody could fan out must not take the dev server down.
53
55
  onError: (error) =>
54
56
  logger.warn('live.bridge_delivery_failed', { error: renderThrowable(error) }),
package/src/dev-render.ts CHANGED
@@ -14,15 +14,32 @@
14
14
  // navigation, with a re-parse on top. The static export writes the file (`writeStyles`), so the
15
15
  // "second file" cost is one `Bun.write`.
16
16
 
17
+ // why: Bun ships no path API; an island's file is its route file's directory joined to its `src`.
18
+ import { posix } from 'node:path';
19
+ import { clientScopeOf } from '@ultimat3/auth';
17
20
  import type { Ctx } from '@ultimat3/core';
21
+ import { CLIENT_SCOPE_HEADER } from '@ultimat3/core';
18
22
  import type { RouteMeta as HttpRouteMeta, Route, RouteParams } from '@ultimat3/http';
19
23
  import { asCtx, html, stream } from '@ultimat3/http';
20
24
  import { currentLocale } from '@ultimat3/i18n';
21
- import type { IslandCollector, RenderResult, RouteData, RouteEntry } from '@ultimat3/render';
25
+ import type {
26
+ ClientSyncHead,
27
+ IslandCollector,
28
+ RenderResult,
29
+ RouteData,
30
+ RouteEntry,
31
+ } from '@ultimat3/render';
22
32
  import {
33
+ clientBootTags,
34
+ clientPersistTags,
35
+ clientScopeTag,
36
+ clientSyncTags,
23
37
  createIslandCollector,
38
+ documentCarriesScope,
24
39
  headFromMeta,
25
40
  hydrateRuntime,
41
+ islandModuleId,
42
+ islandModuleIds,
26
43
  metaContextFor,
27
44
  renderHead,
28
45
  routeDataFor,
@@ -39,9 +56,11 @@ import {
39
56
  ROOT_ELEMENT_ID,
40
57
  renderComponent,
41
58
  renderSsr,
59
+ ssrHeaders,
42
60
  staticHeaders,
43
61
  streamResult,
44
62
  } from '@ultimat3/render/server';
63
+ import { realtimeIslandFiles } from './island-realtime';
45
64
  import { styleBundle } from './style-bundle';
46
65
 
47
66
  /**
@@ -71,6 +90,18 @@ export interface DocumentOptions {
71
90
  * decided by `app.config.ts`, which the boot read and the renderer cannot.
72
91
  */
73
92
  readonly themeHead?: string;
93
+ /**
94
+ * The page's sync target — `pageSync(…).head` — rendered as render's `clientSyncTags` on every
95
+ * document this process serves. Principal-free, so a shareable document carries it too; absent
96
+ * for a caller that serves no socket at all (the static export).
97
+ */
98
+ readonly sync?: ClientSyncHead;
99
+ /**
100
+ * The record types the app persists (`entity(…, { persist: true })`), read per render. Rendered
101
+ * as `ultimate-persist` beside the scope tag only — persistence is per principal, so a document
102
+ * with no scope carries none.
103
+ */
104
+ readonly persisted?: () => readonly string[];
74
105
  }
75
106
 
76
107
  export interface DevRenderOptions extends DocumentOptions {
@@ -94,16 +125,32 @@ export interface DevRouteData extends Record<string, unknown> {
94
125
  */
95
126
  const lang = (): string => currentLocale();
96
127
 
128
+ /**
129
+ * `scope` is present only on a PRIVATE document (a gated `ssr` page, every `stream`): the page's
130
+ * client scope (`@ultimat3/auth`'s `clientScopeOf`), which core's `pageClient()` reads to fence its
131
+ * one store per principal. A shareable document (`static`, `isr`, ungated `ssr`) carries NO scope
132
+ * tag — absent means "not rendered for anyone", a different answer from `''`, the anonymous page.
133
+ */
97
134
  const headFor = async (
98
135
  entry: RouteEntry,
99
136
  ctx: DevRouteData,
100
137
  data: RouteData,
101
138
  options: DocumentOptions,
139
+ scope?: string,
102
140
  ): Promise<string> =>
103
141
  renderHead(
104
142
  headFromMeta(
105
143
  await entry.config.meta(metaContextFor(ctx, data)),
106
144
  seoRenderers({ path: new URL(ctx.url).pathname }),
145
+ [
146
+ ...(options.sync === undefined ? [] : clientSyncTags(options.sync)),
147
+ // The page boot rides the scope tag: its whole job — restoring a principal's persisted
148
+ // records and replaying its queued writes — is per principal, and a shareable document
149
+ // (no scope tag) has neither. Cheaper than walking the page's islands, and exact.
150
+ ...(scope === undefined
151
+ ? []
152
+ : [clientScopeTag(scope), ...clientPersistTags(options.persisted?.() ?? [])]),
153
+ ],
107
154
  ),
108
155
  ) +
109
156
  (options.themeHead ?? '') +
@@ -166,6 +213,33 @@ export async function routeBody(
166
213
  * an island never declares its own timing, and `resolve` is the build's — identity when nothing
167
214
  * built any, which fails at the first island by name rather than emitting an unusable entry.
168
215
  */
216
+ /**
217
+ * Realtime's page boot, as one deferred script — or nothing. Two conditions, both exact: the
218
+ * document carries a principal scope (restoring persisted records and replaying queued writes are
219
+ * per principal; a shareable document has neither), AND one of the islands this render emitted
220
+ * reaches `@ultimat3/realtime` (a page whose islands never touch a record has nothing to restore
221
+ * into and no write to replay). After the body, because which islands rendered is a fact the walk
222
+ * just recorded; still before the hydration runtime, so it runs first among the deferred scripts.
223
+ */
224
+ function bootScript(
225
+ entry: RouteEntry,
226
+ islands: IslandCollector,
227
+ options: DocumentOptions,
228
+ scope: string | undefined,
229
+ ): string {
230
+ if (scope === undefined || options.sync === undefined) return '';
231
+ const rendered = new Set(islandModuleIds(islands.directives));
232
+ if (rendered.size === 0) return '';
233
+ // An island's module id is derived from its `src`, written relative to the page that renders it:
234
+ // each realtime island file, spelled from THIS page, is the id its directive would carry.
235
+ const pageDir = posix.dirname(entry.file);
236
+ const reaches = [...realtimeIslandFiles()].some((file) => {
237
+ const src = posix.relative(pageDir, file);
238
+ return rendered.has(islandModuleId(src.startsWith('.') ? src : `./${src}`));
239
+ });
240
+ return reaches ? renderHead(clientBootTags(options.sync)) : '';
241
+ }
242
+
169
243
  const collectorFor = (entry: RouteEntry, options: DocumentOptions): IslandCollector =>
170
244
  createIslandCollector({
171
245
  file: entry.file,
@@ -199,15 +273,16 @@ async function documentFrom(
199
273
  ctx: DevRouteData,
200
274
  data: RouteData,
201
275
  options: DocumentOptions,
276
+ scope?: string,
202
277
  ): Promise<string> {
203
278
  const islands = collectorFor(entry, options);
204
279
  const [head, body] = await Promise.all([
205
- headFor(entry, ctx, data, options),
280
+ headFor(entry, ctx, data, options, scope),
206
281
  routeBody(entry, ctx, data, islands),
207
282
  ]);
208
283
  return (
209
284
  `<!doctype html><html lang="${lang()}"><head>${head}${styleTag(entry)}</head>` +
210
- `<body>${body}${hydrateRuntime(islands.directives)}</body></html>`
285
+ `<body>${body}${bootScript(entry, islands, options, scope)}${hydrateRuntime(islands.directives)}</body></html>`
211
286
  );
212
287
  }
213
288
 
@@ -256,31 +331,55 @@ async function resultFor(
256
331
  // correct output, no streaming benefit.
257
332
  const islands = collectorFor(entry, options);
258
333
  const [head, shell] = await Promise.all([
259
- headFor(entry, request, data, options),
334
+ // A stream is always `private, no-store` (`streamResult`), so it always carries the scope.
335
+ headFor(entry, request, data, options, clientScopeOf(ctx.actor)),
260
336
  routeBody(entry, request, data, islands),
261
337
  ]);
262
- return streamResult(
263
- {
264
- head: `<!doctype html><html lang="${lang()}"><head>${head}${styleTag(entry)}</head><body>`,
265
- // The runtime rides the first flush, with the shell it boots. A later chunk would leave
266
- // the window between flush one and the close with inert islands and no listeners on
267
- // them — which is exactly the first-click-lost failure `interaction` replay exists for.
268
- shell: `${shell}${hydrateRuntime(islands.directives)}`,
269
- holes: [],
270
- },
271
- { buildId: options.buildId },
272
- status,
338
+ return withScope(
339
+ streamResult(
340
+ {
341
+ head: `<!doctype html><html lang="${lang()}"><head>${head}${styleTag(entry)}</head><body>`,
342
+ // The runtime rides the first flush, with the shell it boots. A later chunk would leave
343
+ // the window between flush one and the close with inert islands and no listeners on
344
+ // them — which is exactly the first-click-lost failure `interaction` replay exists for.
345
+ shell: `${shell}${bootScript(entry, islands, options, clientScopeOf(ctx.actor))}${hydrateRuntime(islands.directives)}`,
346
+ holes: [],
347
+ },
348
+ { buildId: options.buildId },
349
+ status,
350
+ ),
351
+ clientScopeOf(ctx.actor),
273
352
  );
274
353
  }
275
- default:
276
- return renderSsr(
277
- { entry, params: request.params, url, ctx },
278
- () => documentFrom(entry, request, data, options),
279
- { buildId: options.buildId, status },
354
+ default: {
355
+ // Asked of the headers `renderSsr` is about to send: a gated page is private and carries the
356
+ // scope; an ungated one is `public, s-maxage` and a CDN may hand it to anyone, so it carries
357
+ // none — absent, which core reads as "not rendered for anyone", never as anonymous.
358
+ const scope = documentCarriesScope(ssrHeaders(entry, { buildId: options.buildId }))
359
+ ? clientScopeOf(ctx.actor)
360
+ : undefined;
361
+ return withScope(
362
+ await renderSsr(
363
+ { entry, params: request.params, url, ctx },
364
+ () => documentFrom(entry, request, data, options, scope),
365
+ { buildId: options.buildId, status },
366
+ ),
367
+ scope,
280
368
  );
369
+ }
281
370
  }
282
371
  }
283
372
 
373
+ /**
374
+ * A private document's scope, as a RESPONSE header too: the service worker partitions its offline
375
+ * pages by principal and never parses HTML, so the meta alone cannot reach it. Exactly the
376
+ * documents that carry the scope tag carry this — a shareable one carries neither.
377
+ */
378
+ const withScope = (result: RenderResult, scope: string | undefined): RenderResult =>
379
+ scope === undefined
380
+ ? result
381
+ : { ...result, headers: { ...result.headers, [CLIENT_SCOPE_HEADER]: scope } };
382
+
284
383
  const responseOf = (result: RenderResult): Response =>
285
384
  typeof result.body === 'string'
286
385
  ? html(result.body, { status: result.status, headers: result.headers })