@ultimat3/cli 22.7.0 → 22.8.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 CHANGED
@@ -83,6 +83,7 @@ Commands: `bun test packages/cli` (from the repo root — the test preload lives
83
83
  | `runtime-overrides.ts` | the one field a host hands the framework a driver through — and `routes`, the plain routes for a wire format no primitive speaks (OAuth token endpoint) |
84
84
  | `app-openapi.ts` | `openapi.json` (complete when `defineApi({ openapi })` is declared) and each bearer mount's own document; staleness for all of them |
85
85
  | `script-csp.ts` / `style-csp.ts` / `style-bundle.ts` / `page-sync.ts` / `worker-bundle.ts` | CSP hashes, the CSS file, the page's sync target and worker |
86
+ | `page-navigation.ts` | the client router for `navigation.client` surfaces — built, routed and named ONCE for `x dev`, the container and the static export; none opted in builds nothing |
86
87
  | `island-bundle.ts` / `island-store.ts` / `island-realtime.ts` / `solid-loader.ts` | islands: one `Bun.build` each, source-addressed; `x build --target docker` writes a verified store the container loads |
87
88
  | `dev-*.ts` | `x dev` only: dashboard sources, traces, the N+1 ledger, the watcher, the reload, the lock, the port |
88
89
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "22.7.0",
3
+ "version": "22.8.0",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,33 +38,33 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@babel/core": "^8.0.1",
41
- "@ultimat3/action": "22.7.0",
42
- "@ultimat3/admin": "22.7.0",
43
- "@ultimat3/ai": "22.7.0",
44
- "@ultimat3/auth": "22.7.0",
45
- "@ultimat3/cache": "22.7.0",
46
- "@ultimat3/core": "22.7.0",
47
- "@ultimat3/db": "22.7.0",
48
- "@ultimat3/entity": "22.7.0",
49
- "@ultimat3/flags": "22.7.0",
50
- "@ultimat3/http": "22.7.0",
51
- "@ultimat3/i18n": "22.7.0",
52
- "@ultimat3/jobs": "22.7.0",
53
- "@ultimat3/mail": "22.7.0",
54
- "@ultimat3/manifest": "22.7.0",
55
- "@ultimat3/mcp": "22.7.0",
56
- "@ultimat3/money": "22.7.0",
57
- "@ultimat3/notify": "22.7.0",
58
- "@ultimat3/policy": "22.7.0",
59
- "@ultimat3/pwa": "22.7.0",
60
- "@ultimat3/query": "22.7.0",
61
- "@ultimat3/realtime": "22.7.0",
62
- "@ultimat3/render": "22.7.0",
63
- "@ultimat3/schema": "22.7.0",
64
- "@ultimat3/seo": "22.7.0",
65
- "@ultimat3/storage": "22.7.0",
66
- "@ultimat3/testing": "22.7.0",
67
- "@ultimat3/time": "22.7.0",
41
+ "@ultimat3/action": "22.8.0",
42
+ "@ultimat3/admin": "22.8.0",
43
+ "@ultimat3/ai": "22.8.0",
44
+ "@ultimat3/auth": "22.8.0",
45
+ "@ultimat3/cache": "22.8.0",
46
+ "@ultimat3/core": "22.8.0",
47
+ "@ultimat3/db": "22.8.0",
48
+ "@ultimat3/entity": "22.8.0",
49
+ "@ultimat3/flags": "22.8.0",
50
+ "@ultimat3/http": "22.8.0",
51
+ "@ultimat3/i18n": "22.8.0",
52
+ "@ultimat3/jobs": "22.8.0",
53
+ "@ultimat3/mail": "22.8.0",
54
+ "@ultimat3/manifest": "22.8.0",
55
+ "@ultimat3/mcp": "22.8.0",
56
+ "@ultimat3/money": "22.8.0",
57
+ "@ultimat3/notify": "22.8.0",
58
+ "@ultimat3/policy": "22.8.0",
59
+ "@ultimat3/pwa": "22.8.0",
60
+ "@ultimat3/query": "22.8.0",
61
+ "@ultimat3/realtime": "22.8.0",
62
+ "@ultimat3/render": "22.8.0",
63
+ "@ultimat3/schema": "22.8.0",
64
+ "@ultimat3/seo": "22.8.0",
65
+ "@ultimat3/storage": "22.8.0",
66
+ "@ultimat3/testing": "22.8.0",
67
+ "@ultimat3/time": "22.8.0",
68
68
  "babel-preset-solid": "^1.9.15"
69
69
  }
70
70
  }
@@ -35,8 +35,8 @@ export class IslandBuildFailedError extends UltimateError {
35
35
  }
36
36
  }
37
37
 
38
- /** Which framework script a page ships: the one sync worker, or the one page boot. */
39
- export type FrameworkScriptKind = 'sync worker' | 'page boot';
38
+ /** Which framework script a page ships: the sync worker, the page boot, or the client router. */
39
+ export type FrameworkScriptKind = 'sync worker' | 'page boot' | 'client router';
40
40
 
41
41
  /**
42
42
  * One of the page's framework scripts would not bundle. Same code as an island: "a browser entry
@@ -16,6 +16,7 @@ import type { IslandBundle } from './island-bundle';
16
16
  import { islandHarnessRoutes } from './island-harness-route';
17
17
  import { islandRoutes } from './island-routes';
18
18
  import { loadIslandStates } from './island-states-load';
19
+ import { loadNavigation, pageNavigation } from './page-navigation';
19
20
  import { pageSync } from './page-sync';
20
21
  import { loadPwaArtifacts } from './pwa-artifacts';
21
22
  import { assetRoutes } from './runtime-assets';
@@ -71,6 +72,12 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
71
72
  const origin = publicOrigin(input.env, site);
72
73
  // The same call `serve.ts` makes, so the two boots cannot serve different sync targets.
73
74
  const sync = await pageSync(input.root, input.env, input.buildId, input.realtime);
75
+ // The client router, when a surface opted in (`navigation.client`) — the same call in both boots.
76
+ const navigation = await pageNavigation(
77
+ input.root,
78
+ await loadNavigation(input.root),
79
+ input.buildId,
80
+ );
74
81
  const errorStyles = await errorPageStyleSources(input.root);
75
82
  // Built once at boot and NOT rebuilt with the islands on a watcher tick: a service worker that
76
83
  // changes under a page it controls is the update path, and one per keystroke exercises it per save.
@@ -83,7 +90,10 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
83
90
  routes: describeRoutes(),
84
91
  islands: input.islands(),
85
92
  styles: styleBundle(),
86
- scripts: sync.scripts,
93
+ scripts: [
94
+ ...sync.scripts,
95
+ ...(navigation.script === undefined ? [] : [navigation.script]),
96
+ ],
87
97
  });
88
98
 
89
99
  // The app's own MCP endpoint, discovered from `apps/<app>/mcp.ts` and mounted through the SAME
@@ -129,6 +139,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
129
139
  }),
130
140
  ...(serviceWorker === undefined ? [] : serviceWorkerRoutes(serviceWorker)),
131
141
  ...sync.routes,
142
+ ...navigation.routes,
132
143
  ...pagePostRoutes(),
133
144
  ...appRoutes({
134
145
  buildId: input.buildId,
@@ -136,6 +147,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
136
147
  resolveIsland: (file) => input.islands().resolverFor(file),
137
148
  ...(sync.head === undefined ? {} : { sync: sync.head }),
138
149
  persisted: sync.persisted,
150
+ ...(navigation.head === undefined ? {} : { navigation: navigation.head }),
139
151
  themeHead: theme.head,
140
152
  ...(origin === undefined ? {} : { origin }),
141
153
  ...(pwa === undefined
@@ -0,0 +1,206 @@
1
+ // The client router a document may carry, composed ONCE for `x dev`, the container and the static
2
+ // export: which surfaces opted in (`navigation: { client: [...] }` in `app.config.ts`), the router
3
+ // script built from the app's own `@ultimat3/render`, its route, the head a document of an opted-in
4
+ // surface carries, and the SERVER's half per page — which router may swap it in, whether it may be
5
+ // prefetched (`@ultimat3/http`'s navigation gate reads that off the route meta), and the principal
6
+ // check a soft visit gets before `load`. No surface opted in: nothing is built, served or named — a
7
+ // `site/` page that did not ask stays 0kb (axiom 6).
8
+
9
+ // why: Bun exposes no path-join primitive, and the config path is app-root-relative — the same
10
+ // necessity `theme-boot.ts` records.
11
+ import { join } from 'node:path';
12
+ import { clientScopeOf } from '@ultimat3/auth';
13
+ import type { Ctx, NavigationSurface } from '@ultimat3/core';
14
+ import { CLIENT_NAVIGATION_SCOPE_HEADER, NAVIGATION_SURFACES } from '@ultimat3/core';
15
+ import type { Route, RouteNavigation, UltimateRequest } from '@ultimat3/http';
16
+ import { navigationPurpose, relocate } from '@ultimat3/http';
17
+ import type { ClientNavigationHead, HeadTag, RouteEntry } from '@ultimat3/render';
18
+ import {
19
+ clientNavigationTags,
20
+ documentCarriesScope,
21
+ RouteNavigationInvalidError,
22
+ routeEntries,
23
+ } from '@ultimat3/render';
24
+ import { ssrHeaders } from '@ultimat3/render/server';
25
+ import { APP_CONFIG_EXPORT } from './app-auth';
26
+ import { APP_CONFIG_FILE } from './app-root';
27
+ import { FrameworkScriptBuildFailedError } from './errors';
28
+ import {
29
+ buildNavigationScript,
30
+ type FrameworkScript,
31
+ NAVIGATION_SPECIFIER,
32
+ navigationRoutes,
33
+ } from './worker-bundle';
34
+
35
+ /** What a document renderer needs to decide, per route, whether to name the router. */
36
+ export interface NavigationDocumentHead {
37
+ readonly surfaces: ReadonlySet<string>;
38
+ readonly scriptUrl: string;
39
+ /** The skew check's reference: a soft navigation onto another build is a full load. */
40
+ readonly buildId: string;
41
+ /** `app.config.ts`'s `name`: two apps on one origin (web and admin) are two routers. */
42
+ readonly app: string;
43
+ }
44
+
45
+ export interface PageNavigation {
46
+ readonly routes: readonly Route[];
47
+ /** The router script, for the static export to write and the service worker to precache. */
48
+ readonly script: FrameworkScript | undefined;
49
+ /** `undefined` when no surface opted in. */
50
+ readonly head: NavigationDocumentHead | undefined;
51
+ }
52
+
53
+ /** `app.config.ts`'s answer: the app's name and the surfaces that opted in. */
54
+ export interface NavigationDeclaration {
55
+ readonly app: string;
56
+ readonly surfaces: readonly NavigationSurface[];
57
+ }
58
+
59
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
60
+ typeof value === 'object' && value !== null;
61
+
62
+ const isSurface = (value: unknown): value is NavigationSurface =>
63
+ NAVIGATION_SURFACES.some((surface) => surface === value);
64
+
65
+ /**
66
+ * `navigation.client` and `name`, as `defineConfig` validated them — no surfaces when the app says
67
+ * nothing or has no config file. Structural, never `instanceof`, for `loadThemeMode`'s reason.
68
+ */
69
+ export async function loadNavigation(root: string): Promise<NavigationDeclaration> {
70
+ const configPath = join(root, APP_CONFIG_FILE);
71
+ if (!(await Bun.file(configPath).exists())) return { app: 'app', surfaces: [] };
72
+ const module = (await import(configPath)) as Record<string, unknown>;
73
+ const config = module[APP_CONFIG_EXPORT];
74
+ if (!isRecord(config)) return { app: 'app', surfaces: [] };
75
+ const app = typeof config['name'] === 'string' ? config['name'] : 'app';
76
+ const navigation = config['navigation'];
77
+ const client = isRecord(navigation) ? navigation['client'] : undefined;
78
+ return { app, surfaces: Array.isArray(client) ? client.filter(isSurface) : [] };
79
+ }
80
+
81
+ /**
82
+ * A page declaring `navigation` on a surface with no client navigation says something no process
83
+ * can honour — refused at boot, before a single request, for every route in the table.
84
+ */
85
+ export function assertRouteNavigation(
86
+ surfaces: readonly NavigationSurface[],
87
+ entries: readonly RouteEntry[] = routeEntries(),
88
+ ): void {
89
+ for (const entry of entries) {
90
+ const mode = entry.config.navigation;
91
+ if (mode === undefined || surfaces.some((surface) => surface === entry.surface)) continue;
92
+ throw new RouteNavigationInvalidError(
93
+ `${entry.file} declares navigation: '${mode}', and its surface (${entry.surface}) is not in app.config.ts navigation.client`,
94
+ );
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Built at boot, never on the watcher tick — the router is framework code, and a tab that loaded
100
+ * it keeps it across every soft navigation, so a URL that changed per save would be one no tab
101
+ * asks for again.
102
+ */
103
+ export async function pageNavigation(
104
+ root: string,
105
+ declared: NavigationDeclaration,
106
+ buildId: string,
107
+ ): Promise<PageNavigation> {
108
+ assertRouteNavigation(declared.surfaces);
109
+ if (declared.surfaces.length === 0) return { routes: [], script: undefined, head: undefined };
110
+ const script = await buildNavigationScript(root);
111
+ // Opted in and nothing to build is a refusal, never a silent full-page app: the app asked for a
112
+ // behaviour and would otherwise ship without it and without a word.
113
+ if (script === undefined) {
114
+ throw new FrameworkScriptBuildFailedError({
115
+ what: 'client router',
116
+ entry: NAVIGATION_SPECIFIER,
117
+ logs: `${NAVIGATION_SPECIFIER} does not resolve from ${root} — install @ultimat3/render at the version of @ultimat3/cli`,
118
+ });
119
+ }
120
+ return {
121
+ routes: navigationRoutes(() => script),
122
+ script,
123
+ head: {
124
+ surfaces: new Set(declared.surfaces),
125
+ scriptUrl: script.url,
126
+ buildId,
127
+ app: declared.app,
128
+ },
129
+ };
130
+ }
131
+
132
+ /** `<app>:<surface>` — what the document says and what the route meta requires. */
133
+ export const surfaceKey = (head: NavigationDocumentHead, surface: string): string =>
134
+ `${head.app}:${surface}`;
135
+
136
+ /** The head for one document, or `undefined` when its surface did not opt in. */
137
+ export function navigationHeadFor(
138
+ head: NavigationDocumentHead | undefined,
139
+ surface: string,
140
+ ): ClientNavigationHead | undefined {
141
+ if (head === undefined || !head.surfaces.has(surface)) return undefined;
142
+ return { surface: surfaceKey(head, surface), buildId: head.buildId, scriptUrl: head.scriptUrl };
143
+ }
144
+
145
+ /** The router's meta, build and script — only on a surface that opted in. */
146
+ export function navigationTagsOf(
147
+ head: NavigationDocumentHead | undefined,
148
+ surface: string,
149
+ ): readonly HeadTag[] {
150
+ const tags = navigationHeadFor(head, surface);
151
+ return tags === undefined ? [] : clientNavigationTags(tags);
152
+ }
153
+
154
+ /** `{ navigation }` for `metaOf`'s spread, or nothing. */
155
+ export function navigationMetaOf(
156
+ entry: RouteEntry,
157
+ head: NavigationDocumentHead | undefined,
158
+ ): { readonly navigation?: RouteNavigation } {
159
+ const navigation = routeNavigationOf(entry, head);
160
+ return navigation === undefined ? {} : { navigation };
161
+ }
162
+
163
+ /**
164
+ * The route meta `@ultimat3/http`'s gate reads: present only for a page the router may swap in.
165
+ * `navigation: 'document'` and every page of a surface that did not opt in get none — so a
166
+ * router request to them runs nothing and is handed to the browser.
167
+ */
168
+ export function routeNavigationOf(
169
+ entry: RouteEntry,
170
+ head: NavigationDocumentHead | undefined,
171
+ ): RouteNavigation | undefined {
172
+ if (head === undefined || !head.surfaces.has(entry.surface)) return undefined;
173
+ if (entry.config.navigation === 'document') return undefined;
174
+ return {
175
+ surface: surfaceKey(head, entry.surface),
176
+ prefetch: entry.config.navigation === 'prefetch',
177
+ };
178
+ }
179
+
180
+ /**
181
+ * The principal this page's document will be rendered for — `null` for a shareable document, which
182
+ * carries no scope — by the rule `resultFor` renders by: every `stream` and every gated `ssr`.
183
+ */
184
+ function scopeOf(entry: RouteEntry, ctx: Ctx, buildId: string): string | null {
185
+ const render = entry.config.render;
186
+ const scoped =
187
+ render === 'stream' ||
188
+ (render === 'ssr' && documentCarriesScope(ssrHeaders(entry, { buildId })));
189
+ return scoped ? clientScopeOf(ctx.actor) : null;
190
+ }
191
+
192
+ /**
193
+ * A soft visit to a page rendered for a principal other than the one the router's document was:
194
+ * the page boot restores and fences ONE principal per load, so it must be a real load — answered
195
+ * here, before `load` runs, so the page runs once (for that load) and never for a swap discarded.
196
+ */
197
+ export function principalRelocation(
198
+ entry: RouteEntry,
199
+ request: UltimateRequest,
200
+ ctx: Ctx,
201
+ buildId: string,
202
+ ): Response | undefined {
203
+ if (navigationPurpose(request.raw) === null) return undefined;
204
+ const said = request.header(CLIENT_NAVIGATION_SCOPE_HEADER);
205
+ return said === scopeOf(entry, ctx, buildId) ? undefined : relocate(request.url.href);
206
+ }
package/src/prerender.ts CHANGED
@@ -27,6 +27,7 @@ import { buildIslands, writeIslands } from './island-bundle';
27
27
  import { measureDatabase } from './measure-database';
28
28
  import { measurePaths } from './measure-paths';
29
29
  import { measureScope, withAppUrl } from './measure-scope';
30
+ import { loadNavigation, pageNavigation } from './page-navigation';
30
31
  import { localizedArtifacts } from './prerender-locales';
31
32
  import { clearPrerenderOut } from './prerender-out';
32
33
  import { loadPwaArtifacts, writePwaIcons } from './pwa-artifacts';
@@ -228,6 +229,17 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
228
229
  // names it either.
229
230
  const pwa = await loadPwaArtifacts(options.root);
230
231
  const theme = themeBoot(await loadThemeMode(options.root));
232
+ // The client router, for the surfaces that opted in: written BEFORE any document names it, for
233
+ // the register's reason below — `measureDocumentJs` weighs it off disk, and it is charged to
234
+ // the route (the interactivity the app asked for), so it must be there to be weighed.
235
+ const navigation = await pageNavigation(
236
+ options.root,
237
+ await loadNavigation(options.root),
238
+ buildId,
239
+ );
240
+ if (navigation.script !== undefined) {
241
+ await Bun.write(join(options.out, navigation.script.url.slice(1)), navigation.script.code);
242
+ }
231
243
  // The registration TAG now, the worker itself after the render loop — the two halves are wanted
232
244
  // at different moments and used to be taken at the same one. Every document below has to name
233
245
  // `/x-sw-register.js`, and the worker's precache manifest is built from the content hash of
@@ -289,6 +301,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
289
301
  resolveIsland: (file: string) => islands.resolverFor(file),
290
302
  themeHead: theme.head,
291
303
  origin,
304
+ ...(navigation.head === undefined ? {} : { navigation: navigation.head }),
292
305
  ...(pwa === undefined
293
306
  ? {}
294
307
  : { pwaHead: (locale: string) => pwa.headFor(locale) + (swHead ?? '') }),
@@ -434,6 +447,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
434
447
  islands,
435
448
  styles,
436
449
  documents,
450
+ ...(navigation.script === undefined ? {} : { scripts: [navigation.script] }),
437
451
  });
438
452
  // `sw.js` only: `serviceWorker.register` IS `serviceWorkerRegistration()`, already on disk above
439
453
  // and identical by construction. A second writer of one path is how the two could ever disagree.
@@ -66,6 +66,12 @@ import {
66
66
  streamResult,
67
67
  } from '@ultimat3/render/server';
68
68
  import { realtimeIslandFiles } from './island-realtime';
69
+ import {
70
+ type NavigationDocumentHead,
71
+ navigationMetaOf,
72
+ navigationTagsOf,
73
+ principalRelocation,
74
+ } from './page-navigation';
69
75
  import { styleBundle } from './style-bundle';
70
76
 
71
77
  /**
@@ -113,6 +119,11 @@ export interface DocumentOptions {
113
119
  * resolves against whatever host it happened to fetch from, a CDN's or a preview's.
114
120
  */
115
121
  readonly origin?: string;
122
+ /**
123
+ * The client router — `pageNavigation(…).head`. A document of a surface listed in
124
+ * `navigation.client` names it; every other document carries none of it.
125
+ */
126
+ readonly navigation?: NavigationDocumentHead;
116
127
  }
117
128
 
118
129
  export interface DevRenderOptions extends DocumentOptions {
@@ -166,6 +177,7 @@ const headFor = async (
166
177
  }),
167
178
  [
168
179
  ...(options.sync === undefined ? [] : clientSyncTags(options.sync)),
180
+ ...navigationTagsOf(options.navigation, entry.surface),
169
181
  // The page boot rides the scope tag: its whole job — restoring a principal's persisted
170
182
  // records and replaying its queued writes — is per principal, and a shareable document
171
183
  // (no scope tag) has neither. Cheaper than walking the page's islands, and exact.
@@ -420,11 +432,8 @@ const responseOf = (result: RenderResult): Response =>
420
432
  ? html(result.body, { status: result.status, headers: result.headers })
421
433
  : stream(result.body, { status: result.status, headers: result.headers });
422
434
 
423
- /**
424
- * `auth` follows the route's own guard, so a gated page is gated in dev by the same pipeline
425
- * stage that gates it in production. A route that declares no policy is public by declaration.
426
- */
427
- const metaOf = (entry: RouteEntry): HttpRouteMeta => ({
435
+ /** `auth` follows the route's own guard: a gated page is gated in dev by the production stage. */
436
+ const metaOf = (entry: RouteEntry, head?: NavigationDocumentHead): HttpRouteMeta => ({
428
437
  name: entry.file,
429
438
  auth: entry.config.policy === undefined ? 'public' : 'required',
430
439
  render: entry.config.render,
@@ -439,6 +448,8 @@ const metaOf = (entry: RouteEntry): HttpRouteMeta => ({
439
448
  ...(entry.config.cache === undefined
440
449
  ? {}
441
450
  : { cache: entry.config.cache === 'no-store' ? NO_STORE : entry.config.cache }),
451
+ // Which router may swap this page in, read by `@ultimat3/http`'s gate before anything runs.
452
+ ...navigationMetaOf(entry, head),
442
453
  });
443
454
 
444
455
  /**
@@ -462,11 +473,14 @@ export function appRoutes(options: DevRenderOptions): readonly Route[] {
462
473
  method: 'GET' as const,
463
474
  path: registered.path,
464
475
  get meta(): HttpRouteMeta {
465
- return metaOf(routeFor(registered.path) ?? registered);
476
+ return metaOf(routeFor(registered.path) ?? registered, options.navigation);
466
477
  },
467
478
  // `ctx.params` is the router's own match — the CLI never re-parses a path it did not match.
468
479
  handler: async (request, ctx): Promise<Response> => {
469
480
  const entry = routeFor(registered.path) ?? registered;
481
+ // A soft visit onto another principal's document is a real load — answered before `load`.
482
+ const moved = principalRelocation(entry, request, asCtx(ctx), options.buildId);
483
+ if (moved !== undefined) return moved;
470
484
  const data: DevRouteData = { url: request.url.href, params: ctx.params };
471
485
  // ONCE per request, before the mode is chosen: every branch of `resultFor` reads this same
472
486
  // object, so a route's `load` runs exactly once however its mode splits head from body.
package/src/serve-boot.ts CHANGED
@@ -18,6 +18,7 @@ import { islandRoutes } from './island-routes';
18
18
  import { loadOrBuildIslands } from './island-store';
19
19
  import type { MetricsEndpoint } from './metrics-endpoint';
20
20
  import { startOtlpExport } from './otlp-export';
21
+ import { loadNavigation, pageNavigation } from './page-navigation';
21
22
  import { pageSync } from './page-sync';
22
23
  import { loadPwaArtifacts } from './pwa-artifacts';
23
24
  import { startRoles } from './role-start';
@@ -149,6 +150,12 @@ async function webSurface(
149
150
  // The page's sync target and its scripts — the same call `x dev` makes, so the two cannot differ.
150
151
  // Before the service worker, which precaches those scripts.
151
152
  const sync = await pageSync(options.root, options.env, buildId, runtime.realtime);
153
+ // The client router, when a surface opted in (`navigation.client`) — the same call in both boots.
154
+ const navigation = await pageNavigation(
155
+ options.root,
156
+ await loadNavigation(options.root),
157
+ buildId,
158
+ );
152
159
  // The worker, from the SAME route table this process is about to serve — `describeRoutes()` is
153
160
  // the one projection `x.manifest.json`, `/_x`, the sitemap and `sw.js` are all built from, so a
154
161
  // route added here cannot be missing from the precache manifest.
@@ -161,7 +168,10 @@ async function webSurface(
161
168
  routes: describeRoutes(),
162
169
  islands,
163
170
  styles: styleBundle(),
164
- scripts: sync.scripts,
171
+ scripts: [
172
+ ...sync.scripts,
173
+ ...(navigation.script === undefined ? [] : [navigation.script]),
174
+ ],
165
175
  });
166
176
  // The app's own MCP endpoint, through the same call `x dev` makes — see `app-mcp.ts`.
167
177
  const mcpMount = await mountAppMcp(options.root);
@@ -191,6 +201,7 @@ async function webSurface(
191
201
  ...seoRoutes({ env: options.env, site }),
192
202
  // The page's one socket: its worker script, served beside the islands for their reason.
193
203
  ...sync.routes,
204
+ ...navigation.routes,
194
205
  // A page's `POST`, bound to an action (`defineRoute({ post })`) — beside the page's `GET`.
195
206
  ...pagePostRoutes(),
196
207
  ...appRoutes({
@@ -198,6 +209,7 @@ async function webSurface(
198
209
  resolveIsland: (file) => islands.resolverFor(file),
199
210
  ...(sync.head === undefined ? {} : { sync: sync.head }),
200
211
  persisted: sync.persisted,
212
+ ...(navigation.head === undefined ? {} : { navigation: navigation.head }),
201
213
  themeHead: theme.head,
202
214
  ...(origin === undefined ? {} : { origin }),
203
215
  ...(pwa === undefined
@@ -67,7 +67,8 @@ export interface ServiceWorkerInput {
67
67
  */
68
68
  readonly documents?: ReadonlyMap<string, RenderedDocument>;
69
69
  /**
70
- * The page's framework scripts — realtime's page boot and sync worker (`pageSync(…).scripts`).
70
+ * The page's framework scripts — realtime's page boot and sync worker (`pageSync(…).scripts`), and
71
+ * the client router when a surface opted in (`pageNavigation(…).script`).
71
72
  * Precached beside the island chunks for their reason: source-addressed and `immutable`, and an
72
73
  * offline reload that cannot load the boot restores no record and shows the old count.
73
74
  */
@@ -23,6 +23,12 @@ export const PAGE_BOOT_BASE_PATH = '/_x/page-boot';
23
23
  /** Realtime's page boot, from the APP's install. */
24
24
  export const PAGE_BOOT_SPECIFIER = '@ultimat3/realtime/boot';
25
25
 
26
+ /** Under the dev namespace too; one per document whose surface opted into client navigation. */
27
+ export const NAVIGATION_BASE_PATH = '/_x/navigation';
28
+
29
+ /** Render's client router, from the APP's install — the copy its documents were rendered by. */
30
+ export const NAVIGATION_SPECIFIER = '@ultimat3/render/navigation';
31
+
26
32
  /** One framework script: the sync worker or the page boot. */
27
33
  export interface FrameworkScript {
28
34
  /** `<base>/<hash>.js` — what the document names. */
@@ -74,6 +80,23 @@ export function buildPageBoot(
74
80
  );
75
81
  }
76
82
 
83
+ /**
84
+ * The client router (`navigation: { client }` in `app.config.ts`), or `undefined` when the app's
85
+ * render predates it. Built only when a surface opted in — see `pageNavigation`.
86
+ */
87
+ export function buildNavigationScript(
88
+ root: string,
89
+ options: BuildSyncWorkerOptions = {},
90
+ ): Promise<FrameworkScript | undefined> {
91
+ return buildFrameworkScript(
92
+ root,
93
+ 'client router',
94
+ NAVIGATION_SPECIFIER,
95
+ NAVIGATION_BASE_PATH,
96
+ options.entry,
97
+ );
98
+ }
99
+
77
100
  async function buildFrameworkScript(
78
101
  root: string,
79
102
  what: FrameworkScriptKind,
@@ -158,6 +181,10 @@ export function pageBootRoutes(source: SyncWorkerSource): readonly Route[] {
158
181
  return [scriptRoute(PAGE_BOOT_BASE_PATH, 'assets.page-boot', 'page boot', source)];
159
182
  }
160
183
 
184
+ export function navigationRoutes(source: SyncWorkerSource): readonly Route[] {
185
+ return [scriptRoute(NAVIGATION_BASE_PATH, 'assets.navigation', 'client router', source)];
186
+ }
187
+
161
188
  function scriptRoute(
162
189
  basePath: string,
163
190
  name: string,