@ultimat3/cli 22.13.0 → 22.15.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
@@ -84,6 +84,8 @@ Commands: `bun test packages/cli` (from the repo root — the test preload lives
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
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 |
87
+ | `page-speculation.ts` | Speculation Rules for documents WITHOUT the router (`navigation.speculation`): the allow-list of pure-read pages, the one tag, and its `script-src` hash — composed once for all three writers. An allow-list, never "every link but": a prefetch is a real GET with cookies |
88
+ | `static-document.ts` | a served `static` page: `304` on a matching `If-None-Match`, and (container only, `memoStatic`) the document kept after its first render. Never `x dev` — a save moves the stylesheet URL the kept document names |
87
89
  | `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 |
88
90
  | `dev-*.ts` | `x dev` only: dashboard sources, traces, the N+1 ledger, the watcher, the reload, the lock, the port |
89
91
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "22.13.0",
3
+ "version": "22.15.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.13.0",
42
- "@ultimat3/admin": "22.13.0",
43
- "@ultimat3/ai": "22.13.0",
44
- "@ultimat3/auth": "22.13.0",
45
- "@ultimat3/cache": "22.13.0",
46
- "@ultimat3/core": "22.13.0",
47
- "@ultimat3/db": "22.13.0",
48
- "@ultimat3/entity": "22.13.0",
49
- "@ultimat3/flags": "22.13.0",
50
- "@ultimat3/http": "22.13.0",
51
- "@ultimat3/i18n": "22.13.0",
52
- "@ultimat3/jobs": "22.13.0",
53
- "@ultimat3/mail": "22.13.0",
54
- "@ultimat3/manifest": "22.13.0",
55
- "@ultimat3/mcp": "22.13.0",
56
- "@ultimat3/money": "22.13.0",
57
- "@ultimat3/notify": "22.13.0",
58
- "@ultimat3/policy": "22.13.0",
59
- "@ultimat3/pwa": "22.13.0",
60
- "@ultimat3/query": "22.13.0",
61
- "@ultimat3/realtime": "22.13.0",
62
- "@ultimat3/render": "22.13.0",
63
- "@ultimat3/schema": "22.13.0",
64
- "@ultimat3/seo": "22.13.0",
65
- "@ultimat3/storage": "22.13.0",
66
- "@ultimat3/testing": "22.13.0",
67
- "@ultimat3/time": "22.13.0",
41
+ "@ultimat3/action": "22.15.0",
42
+ "@ultimat3/admin": "22.15.0",
43
+ "@ultimat3/ai": "22.15.0",
44
+ "@ultimat3/auth": "22.15.0",
45
+ "@ultimat3/cache": "22.15.0",
46
+ "@ultimat3/core": "22.15.0",
47
+ "@ultimat3/db": "22.15.0",
48
+ "@ultimat3/entity": "22.15.0",
49
+ "@ultimat3/flags": "22.15.0",
50
+ "@ultimat3/http": "22.15.0",
51
+ "@ultimat3/i18n": "22.15.0",
52
+ "@ultimat3/jobs": "22.15.0",
53
+ "@ultimat3/mail": "22.15.0",
54
+ "@ultimat3/manifest": "22.15.0",
55
+ "@ultimat3/mcp": "22.15.0",
56
+ "@ultimat3/money": "22.15.0",
57
+ "@ultimat3/notify": "22.15.0",
58
+ "@ultimat3/policy": "22.15.0",
59
+ "@ultimat3/pwa": "22.15.0",
60
+ "@ultimat3/query": "22.15.0",
61
+ "@ultimat3/realtime": "22.15.0",
62
+ "@ultimat3/render": "22.15.0",
63
+ "@ultimat3/schema": "22.15.0",
64
+ "@ultimat3/seo": "22.15.0",
65
+ "@ultimat3/storage": "22.15.0",
66
+ "@ultimat3/testing": "22.15.0",
67
+ "@ultimat3/time": "22.15.0",
68
68
  "babel-preset-solid": "^1.9.15"
69
69
  }
70
70
  }
package/src/app-mcp.ts CHANGED
@@ -3,7 +3,9 @@
3
3
  // `ai: { mcp: { expose: true, path: '/mcp' } }` by DEFAULT, and nothing between the two served it:
4
4
  // neither `x dev` nor `runRole` mounted the route, so `POST /mcp` answered `X_ROUTE_NOT_FOUND` in
5
5
  // every app ever scaffolded (measured 2026-09-05). The contract is one file: `apps/<app>/mcp.ts`
6
- // exports `mcp`, an `AppMcp`; this module finds it, and both boots mount what it carries.
6
+ // exports `mcp` — one `AppMcp`, or an array of them, one per population (customers, staff,
7
+ // affiliates) — this module finds it, and both boots mount what it carries: endpoint #0 at
8
+ // `ai.mcp.path`, every other at its own `defineAppMcp({ path })`.
7
9
 
8
10
  // why: a directory's existence — `Bun.file().exists()` answers for files, and `apps/` is a directory.
9
11
  import { existsSync } from 'node:fs';
@@ -12,23 +14,38 @@ import { join } from 'node:path';
12
14
  import { logger } from '@ultimat3/core';
13
15
  import type { Route } from '@ultimat3/http';
14
16
  import { json, selfOrigin } from '@ultimat3/http';
15
- import { type AppMcp, McpAppUnmountedError } from '@ultimat3/mcp';
17
+ import {
18
+ type AppMcp,
19
+ McpAppUnmountedError,
20
+ McpPathDuplicateError,
21
+ PROTECTED_RESOURCE_WELL_KNOWN,
22
+ } from '@ultimat3/mcp';
16
23
  import { APP_CONFIG_EXPORT } from './app-auth';
17
24
  import { APP_CONFIG_FILE } from './app-root';
18
25
 
19
26
  /** The one file an app writes, per app directory. */
20
27
  export const APP_MCP_GLOB = 'apps/*/mcp.ts';
21
- /** The export that file makes — an `AppMcp`, the value `defineAppMcp` returns. */
28
+ /**
29
+ * The export that file makes — an `AppMcp`, the value `defineAppMcp` returns, or a non-empty array
30
+ * of them. ONE export name either way: a second named export per endpoint would be a second way
31
+ * to say the same thing, and the array's order is the one fact the mount needs (#0 is the default).
32
+ */
22
33
  export const APP_MCP_EXPORT = 'mcp';
23
- /** What the boot line and `/_x` call the route. */
34
+ /** What the boot line and `/_x` call endpoint #0's route; endpoint #n is `mcp:<its path>`. */
24
35
  export const APP_MCP_ROUTE_NAME = 'mcp';
25
36
 
26
37
  export interface AppMcpMount {
27
- /** `[]` when `expose` is false, when nothing exports `mcp`, or when the export has no route. */
38
+ /** `[]` when `expose` is false, when nothing exports `mcp`, or when no endpoint has a route. */
28
39
  readonly routes: readonly Route[];
29
- /** `POST <path>` when mounted, else `null` — the boot line prints it. */
40
+ /** Endpoint #0's `POST <path>` when mounted, else `null` — the default, at `ai.mcp.path`. */
30
41
  readonly path: string | null;
31
- /** Set exactly when `expose` is true and `routes` is empty: the reason, as an instruction. */
42
+ /** Every mounted endpoint's `POST <path>`, in export order — the boot line prints one each. */
43
+ readonly paths: readonly string[];
44
+ /**
45
+ * Set when `expose` is true and an endpoint could not be mounted — nothing exports `mcp`, or the
46
+ * first endpoint built without `resolveToken`: the reason, as an instruction. The endpoints that
47
+ * could be mounted still are.
48
+ */
32
49
  readonly warning: McpAppUnmountedError | undefined;
33
50
  }
34
51
 
@@ -65,6 +82,13 @@ async function exposeDeclaration(root: string): Promise<ExposeDeclaration | unde
65
82
  const isAppMcp = (value: unknown): value is AppMcp =>
66
83
  isRecord(value) && 'server' in value && 'tools' in value && 'route' in value;
67
84
 
85
+ /** The export as a list of endpoints, or `undefined` when it is not one (the "missing" case). */
86
+ function endpointsOf(exported: unknown): readonly AppMcp[] | undefined {
87
+ if (isAppMcp(exported)) return [exported];
88
+ if (!Array.isArray(exported) || exported.length === 0) return undefined;
89
+ return exported.every(isAppMcp) ? (exported as readonly AppMcp[]) : undefined;
90
+ }
91
+
68
92
  /** Every `apps/<app>/mcp.ts`, app-root-relative and sorted, so two apps answer in one order. */
69
93
  async function candidates(root: string): Promise<readonly string[]> {
70
94
  // A root with no `apps/` is an app with no MCP file, never a boot failure — the scan's ENOENT
@@ -75,8 +99,10 @@ async function candidates(root: string): Promise<readonly string[]> {
75
99
  return files.sort();
76
100
  }
77
101
 
102
+ const NOTHING: AppMcpMount = { routes: [], path: null, paths: [], warning: undefined };
103
+
78
104
  /**
79
- * The route to mount, or the reason there is none. Pure over the filesystem it is pointed at;
105
+ * The routes to mount, or the reason there are none. Pure over the filesystem it is pointed at;
80
106
  * `mountAppMcp` below is the one place the warning becomes a log line.
81
107
  *
82
108
  * `meta.auth: 'public'` and `enforcedBy: 'handler'` — the http pipeline must not pre-judge:
@@ -87,72 +113,17 @@ async function candidates(root: string): Promise<readonly string[]> {
87
113
  */
88
114
  export async function appMcpMount(root: string): Promise<AppMcpMount> {
89
115
  const declared = await exposeDeclaration(root);
90
- if (declared === undefined || !declared.expose)
91
- return { routes: [], path: null, warning: undefined };
116
+ if (declared === undefined || !declared.expose) return NOTHING;
92
117
  const files = await candidates(root);
93
118
  const fallbackFile = 'apps/web/mcp.ts';
94
- if (files.length === 0) {
95
- return {
96
- routes: [],
97
- path: null,
98
- warning: new McpAppUnmountedError({
99
- reason: 'missing',
100
- path: declared.path,
101
- file: fallbackFile,
102
- }),
103
- };
104
- }
105
119
  for (const file of files) {
106
120
  const module = (await import(join(root, file))) as Record<string, unknown>;
107
- const exported = module[APP_MCP_EXPORT];
108
- if (!isAppMcp(exported)) continue;
109
- const route = exported.route;
110
- if (route === undefined) {
111
- return {
112
- routes: [],
113
- path: null,
114
- warning: new McpAppUnmountedError({ reason: 'no-route', path: declared.path, file }),
115
- };
116
- }
117
- // The PUBLIC origin, as the pipeline resolved it (`ctx.https` honours a trusted proxy's
118
- // `x-forwarded-proto`): behind a TLS-terminating ingress the raw request URL is `http://` on
119
- // an internal host, and a `resource_metadata` naming that is a URL no client can reach.
120
- const resource = route.protectedResource;
121
- return {
122
- routes: [
123
- {
124
- method: 'POST',
125
- path: declared.path,
126
- handler: (request, ctx) =>
127
- route.handle(request.raw, { origin: selfOrigin(ctx.url, ctx.https) }),
128
- meta: { name: APP_MCP_ROUTE_NAME, auth: 'public', enforcedBy: 'handler' },
129
- },
130
- // RFC 9728 protected-resource metadata, when the app declared `oauth`: path-inserted and
131
- // at the root, so a client probing either finds the authorization server.
132
- ...(resource === undefined
133
- ? []
134
- : resource.paths.map(
135
- (path): Route => ({
136
- method: 'GET',
137
- path,
138
- handler: (_request, ctx) => {
139
- const response = json(resource.document(selfOrigin(ctx.url, ctx.https)));
140
- // A browser-hosted MCP client reads this cross-origin; it holds nothing secret.
141
- response.headers.set('access-control-allow-origin', '*');
142
- response.headers.set('cache-control', 'public, max-age=3600');
143
- return response;
144
- },
145
- meta: { name: `${APP_MCP_ROUTE_NAME}.oauth-protected-resource`, auth: 'public' },
146
- }),
147
- )),
148
- ],
149
- path: declared.path,
150
- warning: undefined,
151
- };
121
+ const endpoints = endpointsOf(module[APP_MCP_EXPORT]);
122
+ if (endpoints === undefined) continue;
123
+ return mountEndpoints(endpoints, declared.path, file);
152
124
  }
153
125
  return {
154
- routes: [],
155
- path: null,
126
+ ...NOTHING,
156
127
  warning: new McpAppUnmountedError({
157
128
  reason: 'missing',
158
129
  path: declared.path,
@@ -161,6 +132,95 @@ export async function appMcpMount(root: string): Promise<AppMcpMount> {
161
132
  };
162
133
  }
163
134
 
135
+ /**
136
+ * One file's endpoints onto one route table. An endpoint built without `resolveToken` has no
137
+ * route: it is skipped and the FIRST such is the warning, so one unfinished population never takes
138
+ * the others down with it. Two endpoints on one route is thrown — see `McpPathDuplicateError`.
139
+ */
140
+ function mountEndpoints(
141
+ endpoints: readonly AppMcp[],
142
+ defaultPath: string,
143
+ file: string,
144
+ ): AppMcpMount {
145
+ const routes: Route[] = [];
146
+ const paths: string[] = [];
147
+ const claimed = new Map<string, number>();
148
+ let warning: McpAppUnmountedError | undefined;
149
+ endpoints.forEach((endpoint, index) => {
150
+ const route = endpoint.route;
151
+ if (route === undefined) {
152
+ warning ??= new McpAppUnmountedError({
153
+ reason: 'no-route',
154
+ path: defaultPath,
155
+ file,
156
+ ...(index === 0 ? {} : { endpoint: index }),
157
+ });
158
+ return;
159
+ }
160
+ const path = index === 0 ? defaultPath : route.path;
161
+ for (const mounted of endpointRoutes(route, path, index)) {
162
+ const key = `${mounted.method} ${mounted.path}`;
163
+ const first = claimed.get(key);
164
+ if (first !== undefined) {
165
+ throw new McpPathDuplicateError({
166
+ method: mounted.method,
167
+ path: mounted.path,
168
+ endpoints: [first, index],
169
+ file,
170
+ });
171
+ }
172
+ claimed.set(key, index);
173
+ routes.push(mounted);
174
+ }
175
+ paths.push(path);
176
+ });
177
+ const path = endpoints[0]?.route === undefined ? null : defaultPath;
178
+ return { routes, path, paths, warning };
179
+ }
180
+
181
+ type Descriptor = NonNullable<AppMcp['route']>;
182
+
183
+ /** One endpoint's `POST` and, when it declared `oauth`, its RFC 9728 metadata `GET`s. */
184
+ function endpointRoutes(route: Descriptor, path: string, index: number): readonly Route[] {
185
+ const name = index === 0 ? APP_MCP_ROUTE_NAME : `${APP_MCP_ROUTE_NAME}:${path}`;
186
+ const resource = route.protectedResource;
187
+ // The ROOT document belongs to endpoint #0 alone: a client that probes the bare well-known URL
188
+ // is asking about the default resource, and a second endpoint answering there would collide.
189
+ // Every endpoint keeps its path-inserted document (RFC 9728 §3.1), which its own 401 names.
190
+ const metadata: readonly Route[] =
191
+ resource === undefined
192
+ ? []
193
+ : resource.paths
194
+ .filter((metadataPath) => index === 0 || metadataPath !== PROTECTED_RESOURCE_WELL_KNOWN)
195
+ .map(
196
+ (metadataPath): Route => ({
197
+ method: 'GET',
198
+ path: metadataPath,
199
+ handler: (_request, ctx) => {
200
+ const response = json(resource.document(selfOrigin(ctx.url, ctx.https)));
201
+ // A browser-hosted MCP client reads this cross-origin; it holds nothing secret.
202
+ response.headers.set('access-control-allow-origin', '*');
203
+ response.headers.set('cache-control', 'public, max-age=3600');
204
+ return response;
205
+ },
206
+ meta: { name: `${name}.oauth-protected-resource`, auth: 'public' },
207
+ }),
208
+ );
209
+ return [
210
+ {
211
+ method: 'POST',
212
+ path,
213
+ // The PUBLIC origin, as the pipeline resolved it (`ctx.https` honours a trusted proxy's
214
+ // `x-forwarded-proto`): behind a TLS-terminating ingress the raw request URL is `http://`
215
+ // on an internal host, and a `resource_metadata` naming that is a URL no client can reach.
216
+ handler: (request, ctx) =>
217
+ route.handle(request.raw, { origin: selfOrigin(ctx.url, ctx.https) }),
218
+ meta: { name, auth: 'public', enforcedBy: 'handler' },
219
+ },
220
+ ...metadata,
221
+ ];
222
+ }
223
+
164
224
  /**
165
225
  * The boot's call: the routes to spread into the table, with the warning already logged ONCE and
166
226
  * the mount announced. Both `x dev` and `runRole` go through here, so a developer's terminal and a
@@ -171,6 +231,6 @@ export async function mountAppMcp(root: string): Promise<AppMcpMount> {
171
231
  if (mount.warning !== undefined) {
172
232
  logger.warn(`${mount.warning.code}: ${mount.warning.cause} — fix: ${mount.warning.fix}`);
173
233
  }
174
- if (mount.path !== null) logger.info('app mcp mounted', { method: 'POST', path: mount.path });
234
+ for (const path of mount.paths) logger.info('app mcp mounted', { method: 'POST', path });
175
235
  return mount;
176
236
  }
package/src/budgets.ts CHANGED
@@ -10,7 +10,12 @@ import { existsSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
11
  import { ERROR_DOCS_URL } from '@ultimat3/core';
12
12
  import type { Manifest, RouteFact } from '@ultimat3/manifest';
13
- import { formatBytes, parseByteBudget, themeScriptBody } from '@ultimat3/render';
13
+ import {
14
+ formatBytes,
15
+ parseByteBudget,
16
+ SPECULATION_RULES_TYPE,
17
+ themeScriptBody,
18
+ } from '@ultimat3/render';
14
19
  import type { Finding } from './output';
15
20
  import type { UnmeasuredRoute } from './static-report';
16
21
  import { SW_REGISTER_PATH } from './sw-artifacts';
@@ -242,7 +247,10 @@ const carriesJson = (attrs: string): boolean => {
242
247
  // `type="application/ld+json; charset=utf-8"`, which does not END with `json`, so the suffix
243
248
  // test alone charged an SEO structured-data block as executable JavaScript all over again.
244
249
  const [type = ''] = (TYPE_ATTR.exec(attrs)?.groups?.['type'] ?? '').split(';');
245
- return type.trim().toLowerCase().endsWith('json');
250
+ const declared = type.trim().toLowerCase();
251
+ // Speculation rules are JSON under a type that does not say so: read by the browser's
252
+ // prefetcher, never executed — the one script a 0kb page may carry for free.
253
+ return declared.endsWith('json') || declared === SPECULATION_RULES_TYPE;
246
254
  };
247
255
 
248
256
  /**
package/src/cmd-dev.ts CHANGED
@@ -79,8 +79,10 @@ export interface DevServer {
79
79
  readonly runtime: RunningServices;
80
80
  /** Panel keys `/_x` mounted, in tab order. Reported so `--json` names what is reachable. */
81
81
  readonly panels: readonly string[];
82
- /** `POST <path>` of the app's own MCP endpoint, or `null` when nothing was mounted. */
82
+ /** `POST <path>` of the app's default MCP endpoint, or `null` when nothing was mounted. */
83
83
  readonly mcp: string | null;
84
+ /** `POST <path>` of every mounted MCP endpoint, default first — `[]` when none was. */
85
+ readonly mcpPaths: readonly string[];
84
86
  stop(): Promise<void>;
85
87
  }
86
88
 
@@ -240,7 +242,7 @@ async function bootDev(
240
242
  // `x dev`'s own, so a reload can empty it: an `isr` page's first render was otherwise served for
241
243
  // its whole ttl after every save, whatever the modules behind it now said.
242
244
  const isr = createIsrController({ buildId });
243
- const { routes, theme, errorStyles, mcpPath } = await devRouteTable({
245
+ const { routes, theme, speculation, errorStyles, mcpPath, mcpPaths } = await devRouteTable({
244
246
  isr,
245
247
  root: options.root,
246
248
  env: options.env,
@@ -276,7 +278,7 @@ async function bootDev(
276
278
  // The `/_x` shell, the harness's frame, and the app's own error pages — the inline bodies this
277
279
  // process serves; the app's surfaces are content-hashed files `'self'` admits.
278
280
  inlineStyles: [await devShellStyle(), FRAME_STYLE, ...errorStyles],
279
- inlineScripts: [theme.cspSource],
281
+ inlineScripts: [theme.cspSource, ...(speculation === undefined ? [] : [speculation.cspSource])],
280
282
  // The overlay renders this request's own loops under the error it is already showing.
281
283
  // `serve.ts` boots through the same `startRoles` and passes nothing (axiom 6).
282
284
  devNotices: (ctx: RequestContext): readonly OverlayNotice[] =>
@@ -328,6 +330,7 @@ async function bootDev(
328
330
  services,
329
331
  roles: running.roles,
330
332
  mcp: mcpPath,
333
+ mcpPaths,
331
334
  get buildId(): string {
332
335
  return stamped ?? state.manifest.buildId;
333
336
  },
@@ -447,6 +450,7 @@ export const devCommand: CliCommand = {
447
450
  introspect: `${server.url}/_x`,
448
451
  panels: [...server.panels],
449
452
  mcp: server.mcp,
453
+ mcpPaths: [...server.mcpPaths],
450
454
  },
451
455
  lines: [
452
456
  // A hard kill leaves the lock behind; clearing it is normal and worth one line, never a
@@ -458,7 +462,8 @@ export const devCommand: CliCommand = {
458
462
  msg('cli.dev.introspect', { url: `${server.url}/_x` }),
459
463
  // Only when something was mounted: the unmounted case has already said why, once, as a
460
464
  // warning with a fix, and a summary line reading `mcp none` would be a second copy of it.
461
- ...(server.mcp === null ? [] : [msg('cli.dev.mcp', { path: server.mcp })]),
465
+ // One line per endpoint, so an app serving customers, staff and affiliates sees all three.
466
+ ...server.mcpPaths.map((path) => msg('cli.dev.mcp', { path })),
462
467
  ],
463
468
  };
464
469
  await writeLock(services.stateDir, {
@@ -17,6 +17,8 @@ import { islandHarnessRoutes } from './island-harness-route';
17
17
  import { islandRoutes } from './island-routes';
18
18
  import { loadIslandStates } from './island-states-load';
19
19
  import { loadNavigation, pageNavigation } from './page-navigation';
20
+ import type { PageSpeculation } from './page-speculation';
21
+ import { loadSpeculation, pageSpeculation } from './page-speculation';
20
22
  import { pageSync } from './page-sync';
21
23
  import { loadPwaArtifacts } from './pwa-artifacts';
22
24
  import { assetRoutes } from './runtime-assets';
@@ -56,10 +58,14 @@ export interface DevRouteTable {
56
58
  readonly routes: readonly Route[];
57
59
  /** The theme boot, whose `cspSource` the web role admits. */
58
60
  readonly theme: ThemeBoot;
61
+ /** The speculation rules, admitted the same way — `undefined` when no document carries any. */
62
+ readonly speculation: PageSpeculation | undefined;
59
63
  /** The app's own error pages' inline styles, admitted the same way. */
60
64
  readonly errorStyles: readonly string[];
61
- /** Where the app's MCP endpoint was mounted, or `undefined`. */
65
+ /** Where the app's default MCP endpoint was mounted, or `null`. */
62
66
  readonly mcpPath: string | null;
67
+ /** Every mounted MCP endpoint's path, default first — one per population the app serves. */
68
+ readonly mcpPaths: readonly string[];
63
69
  }
64
70
 
65
71
  export async function devRouteTable(input: DevRouteTableInput): Promise<DevRouteTable> {
@@ -73,11 +79,14 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
73
79
  // The same call `serve.ts` makes, so the two boots cannot serve different sync targets.
74
80
  const sync = await pageSync(input.root, input.env, input.buildId, input.realtime);
75
81
  // 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
- );
82
+ const declared = await loadNavigation(input.root);
83
+ const navigation = await pageNavigation(input.root, declared, input.buildId);
84
+ // The browser's own prefetch, from the table as it stands at boot: a page added by a later save
85
+ // joins the rules on the next `x dev` — its links are a full load until then, never a wrong one.
86
+ const speculation = pageSpeculation({
87
+ config: await loadSpeculation(input.root),
88
+ client: declared.surfaces,
89
+ });
81
90
  const errorStyles = await errorPageStyleSources(input.root);
82
91
  // Built once at boot and NOT rebuilt with the islands on a watcher tick: a service worker that
83
92
  // changes under a page it controls is the update path, and one per keystroke exercises it per save.
@@ -149,6 +158,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
149
158
  persisted: sync.persisted,
150
159
  ...(navigation.head === undefined ? {} : { navigation: navigation.head }),
151
160
  themeHead: theme.head,
161
+ ...(speculation === undefined ? {} : { speculationHead: speculation.head }),
152
162
  ...(origin === undefined ? {} : { origin }),
153
163
  ...(pwa === undefined
154
164
  ? {}
@@ -156,5 +166,12 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
156
166
  }),
157
167
  ];
158
168
 
159
- return { routes, theme, errorStyles, mcpPath: mcpMount.path };
169
+ return {
170
+ routes,
171
+ theme,
172
+ speculation,
173
+ errorStyles,
174
+ mcpPath: mcpMount.path,
175
+ mcpPaths: mcpMount.paths,
176
+ };
160
177
  }
@@ -0,0 +1,64 @@
1
+ // What a document renderer is handed by the boot that knows it: the island resolver and every
2
+ // document-level head the route itself cannot decide. Split out of `runtime-render.ts` at its
3
+ // 500-line ceiling; one declaration for `x dev`, the container and the static export.
4
+
5
+ import type { ClientSyncHead } from '@ultimat3/render';
6
+ import type { NavigationDocumentHead } from './page-navigation';
7
+
8
+ /**
9
+ * Specifier → built chunk URL, bound to the route file the specifier is written relative to.
10
+ * Supplied by whoever built the islands (`x dev`, the container, the static build); absent means
11
+ * no island was built, and a page that renders one then fails by name rather than emitting a
12
+ * `data-x-entry` nothing can import.
13
+ */
14
+ export type IslandResolver = (routeFile: string) => (src: string) => string;
15
+
16
+ export interface DocumentOptions {
17
+ readonly resolveIsland?: IslandResolver;
18
+ /**
19
+ * `<link rel="manifest">`, both `theme-color` metas and the apple-touch links — `PwaArtifacts.head`
20
+ * from `pwa-artifacts.ts`, or absent when the app is not installable.
21
+ *
22
+ * A document-level string rather than something a route's `meta()` returns: it is the same three
23
+ * elements on every page of the app, an installable app is one whose EVERY page carries them
24
+ * (a browser offers the install on whichever page the visitor landed on), and `headFromMeta`
25
+ * projects per-route SEO. Passed through `DocumentOptions` for `resolveIsland`'s reason — the
26
+ * boot knows it, the renderer cannot ask.
27
+ */
28
+ readonly pwaHead?: string | ((locale: string) => string);
29
+ /**
30
+ * The no-flash theme `<script>` from `theme-boot.ts`, or absent for a caller that renders no
31
+ * documents a browser paints. Document-level for `pwaHead`'s reason — the same tag on every page,
32
+ * decided by `app.config.ts`, which the boot read and the renderer cannot.
33
+ */
34
+ readonly themeHead?: string;
35
+ /**
36
+ * The page's sync target — `pageSync(…).head` — rendered as render's `clientSyncTags` on every
37
+ * document this process serves. Principal-free, so a shareable document carries it too; absent
38
+ * for a caller that serves no socket at all (the static export).
39
+ */
40
+ readonly sync?: ClientSyncHead;
41
+ /**
42
+ * The record types the app persists (`entity(…, { persist: true })`), read per render. Rendered
43
+ * as `ultimate-persist` beside the scope tag only — persistence is per principal, so a document
44
+ * with no scope carries none.
45
+ */
46
+ readonly persisted?: () => readonly string[];
47
+ /**
48
+ * The public origin canonical, `og:url` and hreflang are absolute against — `publicOrigin()`
49
+ * from `site-config.ts`. Absent, the request's own origin: a relative canonical is one a crawler
50
+ * resolves against whatever host it happened to fetch from, a CDN's or a preview's.
51
+ */
52
+ readonly origin?: string;
53
+ /**
54
+ * The client router — `pageNavigation(…).head`. A document of a surface listed in
55
+ * `navigation.client` names it; every other document carries none of it.
56
+ */
57
+ readonly navigation?: NavigationDocumentHead;
58
+ /**
59
+ * `<script type="speculationrules">` — `pageSpeculation(…).head` — on every document that names
60
+ * NO client router: the browser prefetches the next page instead. A document with the router
61
+ * carries none, because the router prefetches by its own rules.
62
+ */
63
+ readonly speculationHead?: string;
64
+ }
@@ -0,0 +1,124 @@
1
+ // Speculation Rules for the documents that carry NO client router, composed ONCE for `x dev`, the
2
+ // container and the static export: `navigation.speculation` from `app.config.ts`, the pages the
3
+ // route table knows to be a pure read, the one `<script type="speculationrules">` every such
4
+ // document carries, and the `script-src` source that admits exactly that body. A browser fetches
5
+ // the next page's document on pointer rest, so a full-page navigation between prerendered pages
6
+ // paints from memory — and the page still ships 0kb of JavaScript (axiom 6).
7
+
8
+ // why: Bun exposes no path-join primitive, and the config path is app-root-relative — the same
9
+ // necessity `theme-boot.ts` records.
10
+ import { join } from 'node:path';
11
+ import type { NavigationSurface, SpeculationConfig } from '@ultimat3/core';
12
+ import { DEFAULT_SPECULATION, localeSegment, resolveSpeculation } from '@ultimat3/core';
13
+ import { cspHashSource } from '@ultimat3/http';
14
+ import { localeConfig, routedLocales } from '@ultimat3/i18n';
15
+ import type { RouteDescriptor, RouteEntry } from '@ultimat3/render';
16
+ import {
17
+ describeRoutes,
18
+ renderHead,
19
+ routeEntries,
20
+ speculationPattern,
21
+ speculationRulesBody,
22
+ speculationRulesTag,
23
+ } from '@ultimat3/render';
24
+ import { APP_CONFIG_EXPORT } from './app-auth';
25
+ import { APP_CONFIG_FILE } from './app-root';
26
+
27
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
28
+ typeof value === 'object' && value !== null;
29
+
30
+ /**
31
+ * `navigation.speculation` — the default (on, `'moderate'`) when the app says nothing or has no
32
+ * config file. Structural, for `loadThemeMode`'s reason, and so possibly NOT what `defineConfig`
33
+ * validated: what it finds goes through core's `resolveSpeculation`, the one validator, which
34
+ * REFUSES (`X_CONFIG_INVALID`) a value `defineConfig` would refuse. Never coerced: an `'eager'`
35
+ * quietly served as `'moderate'` is a rule the app did not write.
36
+ */
37
+ export async function loadSpeculation(root: string): Promise<SpeculationConfig> {
38
+ const configPath = join(root, APP_CONFIG_FILE);
39
+ if (!(await Bun.file(configPath).exists())) return DEFAULT_SPECULATION;
40
+ const module = (await import(configPath)) as Record<string, unknown>;
41
+ const config = module[APP_CONFIG_EXPORT];
42
+ const navigation = isRecord(config) ? config['navigation'] : undefined;
43
+ return resolveSpeculation(isRecord(navigation) ? navigation['speculation'] : undefined);
44
+ }
45
+
46
+ /**
47
+ * Whether a browser may fetch this page before anyone asked for it. An ALLOW-list, never "every
48
+ * link but these": a prefetch is a real GET with the visitor's cookies, so the only safe candidates
49
+ * are pages the table itself says are a pure read.
50
+ *
51
+ * - On a surface WITHOUT the client router: a prerendered, shareable document — `static` or `isr`,
52
+ * no policy, no `no-store`. Such a page ran its `load` at build time, for nobody. An `ssr` page
53
+ * there has no way to declare that its GET records something (`navigation` is refused on a
54
+ * surface that did not opt in), so it is never a candidate.
55
+ * - On a client-routed surface: only a page that declared `navigation: 'prefetch'` — the app's own
56
+ * statement that its GET is a pure read, the same one the router's prefetch obeys.
57
+ * - Never an `api/` route, a `navigation: 'document'` page, or an `offline: 'network-only'` one.
58
+ */
59
+ export function speculationCandidate(
60
+ entry: Pick<RouteEntry, 'surface' | 'config'>,
61
+ described: Pick<RouteDescriptor, 'mode' | 'offline' | 'personal'>,
62
+ client: ReadonlySet<string>,
63
+ ): boolean {
64
+ if (entry.surface !== 'site' && entry.surface !== 'app') return false;
65
+ if (entry.config.navigation === 'document' || described.offline === 'network-only') return false;
66
+ if (client.has(entry.surface)) return entry.config.navigation === 'prefetch';
67
+ return (described.mode === 'static' || described.mode === 'isr') && !described.personal;
68
+ }
69
+
70
+ export interface PageSpeculation {
71
+ /** The rendered `<script type="speculationrules">`, for `DocumentOptions.speculationHead`. */
72
+ readonly head: string;
73
+ /** The text between its tags — what a browser hashes. */
74
+ readonly body: string;
75
+ /** The `script-src` source that admits exactly that body. */
76
+ readonly cspSource: string;
77
+ }
78
+
79
+ export interface PageSpeculationInput {
80
+ readonly config: SpeculationConfig;
81
+ /** `navigation.client`: the surfaces whose documents carry the router instead. */
82
+ readonly client: readonly NavigationSurface[];
83
+ readonly entries?: readonly RouteEntry[];
84
+ readonly described?: readonly RouteDescriptor[];
85
+ /** The NON-default routed locales' URL segments. Read from `@ultimat3/i18n` when absent. */
86
+ readonly localeSegments?: readonly string[];
87
+ }
88
+
89
+ const otherLocaleSegments = (): readonly string[] => {
90
+ const fallback = localeSegment(localeConfig().fallback);
91
+ return routedLocales()
92
+ .map(localeSegment)
93
+ .filter((segment) => segment !== fallback);
94
+ };
95
+
96
+ /**
97
+ * Tag and hash from ONE body, as `themeBoot` does. `undefined` when the app turned it off or no
98
+ * page is a candidate — then no document carries a tag and the policy admits nothing new. Called
99
+ * after the app is loaded: the route table and the routed locales are what it reads.
100
+ */
101
+ export function pageSpeculation(input: PageSpeculationInput): PageSpeculation | undefined {
102
+ if (input.config.prefetch === false) return undefined;
103
+ const entries = input.entries ?? routeEntries();
104
+ const described = new Map((input.described ?? describeRoutes()).map((one) => [one.file, one]));
105
+ const client = new Set<string>(input.client);
106
+ const segments = input.localeSegments ?? otherLocaleSegments();
107
+ const include = entries
108
+ .filter((entry) => {
109
+ const facts = described.get(entry.file);
110
+ return facts !== undefined && speculationCandidate(entry, facts, client);
111
+ })
112
+ .map((entry) => speculationPattern(entry.path, segments));
113
+ const body = speculationRulesBody({
114
+ eagerness: input.config.prefetch,
115
+ include,
116
+ exclude: input.config.exclude,
117
+ });
118
+ if (body === undefined) return undefined;
119
+ return {
120
+ head: renderHead([speculationRulesTag(body)]),
121
+ body,
122
+ cspSource: cspHashSource(body),
123
+ };
124
+ }
package/src/prerender.ts CHANGED
@@ -28,6 +28,7 @@ import { measureDatabase } from './measure-database';
28
28
  import { measurePaths } from './measure-paths';
29
29
  import { measureScope, withAppUrl } from './measure-scope';
30
30
  import { loadNavigation, pageNavigation } from './page-navigation';
31
+ import { loadSpeculation, pageSpeculation } from './page-speculation';
31
32
  import { localizedArtifacts } from './prerender-locales';
32
33
  import { clearPrerenderOut } from './prerender-out';
33
34
  import { loadPwaArtifacts, writePwaIcons } from './pwa-artifacts';
@@ -237,11 +238,13 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
237
238
  // The client router, for the surfaces that opted in: written BEFORE any document names it, for
238
239
  // the register's reason below — `measureDocumentJs` weighs it off disk, and it is charged to
239
240
  // the route (the interactivity the app asked for), so it must be there to be weighed.
240
- const navigation = await pageNavigation(
241
- options.root,
242
- await loadNavigation(options.root),
243
- buildId,
244
- );
241
+ const declared = await loadNavigation(options.root);
242
+ const navigation = await pageNavigation(options.root, declared, buildId);
243
+ // The same rules the served process writes, so the exported file and the served document agree.
244
+ const speculation = pageSpeculation({
245
+ config: await loadSpeculation(options.root),
246
+ client: declared.surfaces,
247
+ });
245
248
  if (navigation.script !== undefined) {
246
249
  await Bun.write(join(options.out, navigation.script.url.slice(1)), navigation.script.code);
247
250
  }
@@ -305,6 +308,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
305
308
  routeDocument(entry, data, {
306
309
  resolveIsland: (file: string) => islands.resolverFor(file),
307
310
  themeHead: theme.head,
311
+ ...(speculation === undefined ? {} : { speculationHead: speculation.head }),
308
312
  origin,
309
313
  ...(navigation.head === undefined ? {} : { navigation: navigation.head }),
310
314
  ...(pwa === undefined
@@ -27,13 +27,7 @@ import type {
27
27
  } from '@ultimat3/http';
28
28
  import { asCtx, html, NO_STORE, redirect, stream, takeRedirect } from '@ultimat3/http';
29
29
  import { currentLocale, localeConfig } from '@ultimat3/i18n';
30
- import type {
31
- ClientSyncHead,
32
- IslandCollector,
33
- RenderResult,
34
- RouteData,
35
- RouteEntry,
36
- } from '@ultimat3/render';
30
+ import type { IslandCollector, RenderResult, RouteData, RouteEntry } from '@ultimat3/render';
37
31
  import {
38
32
  clientBootTags,
39
33
  clientPersistTags,
@@ -65,6 +59,7 @@ import {
65
59
  staticHeaders,
66
60
  streamResult,
67
61
  } from '@ultimat3/render/server';
62
+ import type { DocumentOptions } from './document-options';
68
63
  import { realtimeIslandFiles } from './island-realtime';
69
64
  import {
70
65
  type NavigationDocumentHead,
@@ -72,64 +67,21 @@ import {
72
67
  navigationTagsOf,
73
68
  principalRelocation,
74
69
  } from './page-navigation';
70
+ import type { StaticResult } from './static-document';
71
+ import { createStaticMemo, staticMemoKey, staticResponse } from './static-document';
75
72
  import { styleBundle } from './style-bundle';
76
73
 
77
- /**
78
- * Specifier → built chunk URL, bound to the route file the specifier is written relative to.
79
- * Supplied by whoever built the islands (`x dev`, the container, the static build); absent means
80
- * no island was built, and a page that renders one then fails by name rather than emitting a
81
- * `data-x-entry` nothing can import.
82
- */
83
- export type IslandResolver = (routeFile: string) => (src: string) => string;
84
-
85
- export interface DocumentOptions {
86
- readonly resolveIsland?: IslandResolver;
87
- /**
88
- * `<link rel="manifest">`, both `theme-color` metas and the apple-touch links — `PwaArtifacts.head`
89
- * from `pwa-artifacts.ts`, or absent when the app is not installable.
90
- *
91
- * A document-level string rather than something a route's `meta()` returns: it is the same three
92
- * elements on every page of the app, an installable app is one whose EVERY page carries them
93
- * (a browser offers the install on whichever page the visitor landed on), and `headFromMeta`
94
- * projects per-route SEO. Passed through `DocumentOptions` for `resolveIsland`'s reason — the
95
- * boot knows it, the renderer cannot ask.
96
- */
97
- readonly pwaHead?: string | ((locale: string) => string);
98
- /**
99
- * The no-flash theme `<script>` from `theme-boot.ts`, or absent for a caller that renders no
100
- * documents a browser paints. Document-level for `pwaHead`'s reason — the same tag on every page,
101
- * decided by `app.config.ts`, which the boot read and the renderer cannot.
102
- */
103
- readonly themeHead?: string;
104
- /**
105
- * The page's sync target — `pageSync(…).head` — rendered as render's `clientSyncTags` on every
106
- * document this process serves. Principal-free, so a shareable document carries it too; absent
107
- * for a caller that serves no socket at all (the static export).
108
- */
109
- readonly sync?: ClientSyncHead;
110
- /**
111
- * The record types the app persists (`entity(…, { persist: true })`), read per render. Rendered
112
- * as `ultimate-persist` beside the scope tag only — persistence is per principal, so a document
113
- * with no scope carries none.
114
- */
115
- readonly persisted?: () => readonly string[];
116
- /**
117
- * The public origin canonical, `og:url` and hreflang are absolute against — `publicOrigin()`
118
- * from `site-config.ts`. Absent, the request's own origin: a relative canonical is one a crawler
119
- * resolves against whatever host it happened to fetch from, a CDN's or a preview's.
120
- */
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;
127
- }
74
+ export type { DocumentOptions, IslandResolver } from './document-options';
128
75
 
129
76
  export interface DevRenderOptions extends DocumentOptions {
130
77
  readonly buildId: string;
131
78
  /** Injected so a test can drive the ISR store without a timer. */
132
79
  readonly isr?: IsrController;
80
+ /**
81
+ * Keep each `static` document after its first render (`static-document.ts`). The container sets
82
+ * it; `x dev` never does — a save changes the page, its stylesheet's URL and its islands'.
83
+ */
84
+ readonly memoStatic?: boolean;
133
85
  }
134
86
 
135
87
  /** What a route's `meta(data)` is given. `url` is a string because that is what `ld.*` embeds. */
@@ -162,6 +114,7 @@ const headFor = async (
162
114
  ): Promise<string> => {
163
115
  const meta = metaContextFor(ctx, data);
164
116
  const url = new URL(ctx.url);
117
+ const router = navigationTagsOf(options.navigation, entry);
165
118
  return (
166
119
  renderHead(
167
120
  headFromMeta(
@@ -177,7 +130,7 @@ const headFor = async (
177
130
  }),
178
131
  [
179
132
  ...(options.sync === undefined ? [] : clientSyncTags(options.sync)),
180
- ...navigationTagsOf(options.navigation, entry),
133
+ ...router,
181
134
  // The page boot rides the scope tag: its whole job — restoring a principal's persisted
182
135
  // records and replaying its queued writes — is per principal, and a shareable document
183
136
  // (no scope tag) has neither. Cheaper than walking the page's islands, and exact.
@@ -188,6 +141,8 @@ const headFor = async (
188
141
  ),
189
142
  ) +
190
143
  (options.themeHead ?? '') +
144
+ // One mechanism per document: the router prefetches for itself, the browser for the rest.
145
+ (router.length === 0 ? (options.speculationHead ?? '') : '') +
191
146
  // Per locale: each links its own locale's manifest (`PwaArtifacts.headFor`).
192
147
  (typeof options.pwaHead === 'function' ? options.pwaHead(meta.locale) : (options.pwaHead ?? ''))
193
148
  );
@@ -469,6 +424,7 @@ const metaOf = (entry: RouteEntry, head?: NavigationDocumentHead): HttpRouteMeta
469
424
  */
470
425
  export function appRoutes(options: DevRenderOptions): readonly Route[] {
471
426
  const isr = options.isr ?? createIsrController({ buildId: options.buildId });
427
+ const memo = createStaticMemo();
472
428
  return routeEntries().map((registered) => ({
473
429
  method: 'GET' as const,
474
430
  path: registered.path,
@@ -481,6 +437,13 @@ export function appRoutes(options: DevRenderOptions): readonly Route[] {
481
437
  // A soft visit onto another principal's document is a real load — answered before `load`.
482
438
  const moved = principalRelocation(entry, request, asCtx(ctx), options.buildId);
483
439
  if (moved !== undefined) return moved;
440
+ // A static page already rendered by this process is answered without `load` or a render.
441
+ const key =
442
+ options.memoStatic === true
443
+ ? staticMemoKey(entry, request.url, asCtx(ctx).locale)
444
+ : undefined;
445
+ const kept = key === undefined ? undefined : memo.get(key);
446
+ if (kept !== undefined) return staticResponse(request, kept);
484
447
  const data: DevRouteData = { url: request.url.href, params: ctx.params };
485
448
  // ONCE per request, before the mode is chosen: every branch of `resultFor` reads this same
486
449
  // object, so a route's `load` runs exactly once however its mode splits head from body.
@@ -490,7 +453,14 @@ export function appRoutes(options: DevRenderOptions): readonly Route[] {
490
453
  // 3xx because a rendered document has no `Location`; this is the path that has one.
491
454
  const to = takeRedirect(ctx);
492
455
  if (to !== undefined) return loadRedirect(entry, to);
493
- return responseOf(await resultFor(entry, data, loaded, options, isr, asCtx(ctx)));
456
+ const result = await resultFor(entry, data, loaded, options, isr, asCtx(ctx));
457
+ if (entry.config.render !== 'static' || typeof result.body !== 'string') {
458
+ return responseOf(result);
459
+ }
460
+ const document: StaticResult = { ...result, body: result.body };
461
+ if (key !== undefined) memo.set(key, document);
462
+ // The ETag was only ever a header: a matching `If-None-Match` is a 304, not the page again.
463
+ return staticResponse(request, document);
494
464
  },
495
465
  }));
496
466
  }
package/src/serve-boot.ts CHANGED
@@ -19,6 +19,7 @@ import { loadOrBuildIslands } from './island-store';
19
19
  import type { MetricsEndpoint } from './metrics-endpoint';
20
20
  import { startOtlpExport } from './otlp-export';
21
21
  import { loadNavigation, pageNavigation } from './page-navigation';
22
+ import { loadSpeculation, pageSpeculation } from './page-speculation';
22
23
  import { pageSync } from './page-sync';
23
24
  import { loadPwaArtifacts } from './pwa-artifacts';
24
25
  import { startRoles } from './role-start';
@@ -98,7 +99,7 @@ export async function bootRoles(boot: {
98
99
  // The enforced policy this process sends must admit the app's own error pages' `<style>` and
99
100
  // the theme boot the documents carry; `x dev` is report-only, so only here was it a blank page.
100
101
  inlineStyles: web === undefined ? [] : await errorPageStyleSources(options.root),
101
- inlineScripts: web === undefined ? [] : [web.themeCsp],
102
+ inlineScripts: web === undefined ? [] : web.inlineScripts,
102
103
  // The app's own `apps/web/site/errors/<status>.html`, resolved inside `startWeb` so this
103
104
  // process and `x dev` cannot answer a browser differently.
104
105
  root: options.root,
@@ -134,7 +135,7 @@ async function webSurface(
134
135
  options: ServeOptions,
135
136
  runtime: RunningServices,
136
137
  buildId: string,
137
- ): Promise<{ readonly routes: readonly Route[]; readonly themeCsp: string }> {
138
+ ): Promise<{ readonly routes: readonly Route[]; readonly inlineScripts: readonly string[] }> {
138
139
  // Read from the store `x build --target docker` wrote and VERIFIED against this app and this
139
140
  // runtime (`island-store.ts`); built here only when there is no store to trust, which is correct
140
141
  // and slower, and logged with its reason.
@@ -151,11 +152,13 @@ async function webSurface(
151
152
  // Before the service worker, which precaches those scripts.
152
153
  const sync = await pageSync(options.root, options.env, buildId, runtime.realtime);
153
154
  // 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
- );
155
+ const declared = await loadNavigation(options.root);
156
+ const navigation = await pageNavigation(options.root, declared, buildId);
157
+ // The browser's own prefetch, for the documents that carry no router (`navigation.speculation`).
158
+ const speculation = pageSpeculation({
159
+ config: await loadSpeculation(options.root),
160
+ client: declared.surfaces,
161
+ });
159
162
  // The worker, from the SAME route table this process is about to serve — `describeRoutes()` is
160
163
  // the one projection `x.manifest.json`, `/_x`, the sitemap and `sw.js` are all built from, so a
161
164
  // route added here cannot be missing from the precache manifest.
@@ -211,6 +214,9 @@ async function webSurface(
211
214
  persisted: sync.persisted,
212
215
  ...(navigation.head === undefined ? {} : { navigation: navigation.head }),
213
216
  themeHead: theme.head,
217
+ ...(speculation === undefined ? {} : { speculationHead: speculation.head }),
218
+ // A `static` page is the same bytes for every request of this process: rendered once.
219
+ memoStatic: true,
214
220
  ...(origin === undefined ? {} : { origin }),
215
221
  ...(pwa === undefined
216
222
  ? {}
@@ -223,5 +229,9 @@ async function webSurface(
223
229
  : { isr: createIsrController({ buildId, store: options.runtime.isrStore }) }),
224
230
  }),
225
231
  ];
226
- return { routes, themeCsp: theme.cspSource };
232
+ return {
233
+ routes,
234
+ // The theme boot and the speculation rules: the two inline bodies every document may carry.
235
+ inlineScripts: [theme.cspSource, ...(speculation === undefined ? [] : [speculation.cspSource])],
236
+ };
227
237
  }
@@ -0,0 +1,108 @@
1
+ // How a served process answers a `render: 'static'` page: a 304 when the browser already holds
2
+ // these bytes, and — in the container — the document it rendered the first time, from memory.
3
+ // A static page is one file in the export; the served process answering it must not cost a render
4
+ // per request, nor a body per revalidation. No other mode comes through here.
5
+
6
+ import type { UltimateRequest } from '@ultimat3/http';
7
+ import { html } from '@ultimat3/http';
8
+ import type { RenderResult, RouteEntry } from '@ultimat3/render';
9
+ import { etagMatches } from './runtime-storage';
10
+
11
+ /** A rendered static document: always a string body, never a stream. */
12
+ export type StaticResult = RenderResult & { readonly body: string };
13
+
14
+ /**
15
+ * The entries one process keeps. A static route has no params here and no query, so the real count
16
+ * is routes × locales; the cap is what a forged `Host` (part of the key when the app declares no
17
+ * origin) can cost — past it a page is rendered per request, as before, never refused.
18
+ */
19
+ export const STATIC_MEMO_LIMIT = 1024;
20
+
21
+ /**
22
+ * The BYTES one process keeps, the cache tiers' `maxBytes` idea: a count alone lets 1,024 rotated
23
+ * `Host` headers retain 1,024 whole documents, whatever each weighs. Past it, as past the count, a
24
+ * page is rendered per request.
25
+ */
26
+ export const STATIC_MEMO_MAX_BYTES = 32 * 1024 * 1024;
27
+
28
+ export interface StaticMemo {
29
+ get(key: string): StaticResult | undefined;
30
+ set(key: string, result: StaticResult): void;
31
+ readonly size: number;
32
+ /** UTF-8 bytes of the kept keys and bodies. */
33
+ readonly bytes: number;
34
+ }
35
+
36
+ const encoder = new TextEncoder();
37
+ const weigh = (key: string, result: StaticResult): number =>
38
+ encoder.encode(key).byteLength + encoder.encode(result.body).byteLength;
39
+
40
+ export function createStaticMemo(
41
+ limit: number = STATIC_MEMO_LIMIT,
42
+ maxBytes: number = STATIC_MEMO_MAX_BYTES,
43
+ ): StaticMemo {
44
+ const kept = new Map<string, { readonly result: StaticResult; readonly weight: number }>();
45
+ let bytes = 0;
46
+ return {
47
+ get: (key) => kept.get(key)?.result,
48
+ set(key, result) {
49
+ // Only a 200 is the page: a loader's 404 or 500 is an answer about one moment.
50
+ if (result.status !== 200) return;
51
+ const held = kept.get(key);
52
+ if (held === undefined && kept.size >= limit) return;
53
+ const weight = weigh(key, result);
54
+ // A replacement gives its old weight back first; one that no longer fits is not kept, and
55
+ // neither is the stale document it would have replaced.
56
+ const after = bytes - (held?.weight ?? 0) + weight;
57
+ if (after > maxBytes) {
58
+ if (held !== undefined) {
59
+ kept.delete(key);
60
+ bytes -= held.weight;
61
+ }
62
+ return;
63
+ }
64
+ kept.set(key, { result, weight });
65
+ bytes = after;
66
+ },
67
+ get size() {
68
+ return kept.size;
69
+ },
70
+ get bytes() {
71
+ return bytes;
72
+ },
73
+ };
74
+ }
75
+
76
+ /**
77
+ * The key a request's document is kept under, or `undefined` when it must be rendered: a query
78
+ * string is part of what `meta` reads (`data.url`), and a dynamic segment is an unbounded set an
79
+ * anonymous visitor chooses. The origin is in the key because canonical and `og:url` are absolute
80
+ * against the request's own when the app declares none.
81
+ */
82
+ export function staticMemoKey(
83
+ entry: Pick<RouteEntry, 'config' | 'pattern'>,
84
+ url: URL,
85
+ locale: string,
86
+ ): string | undefined {
87
+ if (entry.config.render !== 'static') return undefined;
88
+ if (entry.pattern.keys.length > 0 || url.search !== '') return undefined;
89
+ return `${locale}\n${url.origin}${url.pathname}`;
90
+ }
91
+
92
+ /**
93
+ * The document, or a bodiless 304 when `If-None-Match` names its ETag. The 304 repeats the
94
+ * validators and the cache posture (RFC 9110 §15.4.5) and drops `content-type`: it has no content.
95
+ * Only a 200 revalidates — a 404 rendered under `static` is sent whole.
96
+ */
97
+ export function staticResponse(request: UltimateRequest, result: StaticResult): Response {
98
+ const etag = result.headers['etag'];
99
+ if (
100
+ result.status === 200 &&
101
+ etag !== undefined &&
102
+ etagMatches(request.header('if-none-match'), etag)
103
+ ) {
104
+ const { 'content-type': _dropped, ...headers } = result.headers;
105
+ return new Response(null, { status: 304, headers });
106
+ }
107
+ return html(result.body, { status: result.status, headers: result.headers });
108
+ }