@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 +2 -0
- package/package.json +28 -28
- package/src/app-mcp.ts +129 -69
- package/src/budgets.ts +10 -2
- package/src/cmd-dev.ts +9 -4
- package/src/dev-route-table.ts +24 -7
- package/src/document-options.ts +64 -0
- package/src/page-speculation.ts +124 -0
- package/src/prerender.ts +9 -5
- package/src/runtime-render.ts +30 -60
- package/src/serve-boot.ts +18 -8
- package/src/static-document.ts +108 -0
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.
|
|
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.
|
|
42
|
-
"@ultimat3/admin": "22.
|
|
43
|
-
"@ultimat3/ai": "22.
|
|
44
|
-
"@ultimat3/auth": "22.
|
|
45
|
-
"@ultimat3/cache": "22.
|
|
46
|
-
"@ultimat3/core": "22.
|
|
47
|
-
"@ultimat3/db": "22.
|
|
48
|
-
"@ultimat3/entity": "22.
|
|
49
|
-
"@ultimat3/flags": "22.
|
|
50
|
-
"@ultimat3/http": "22.
|
|
51
|
-
"@ultimat3/i18n": "22.
|
|
52
|
-
"@ultimat3/jobs": "22.
|
|
53
|
-
"@ultimat3/mail": "22.
|
|
54
|
-
"@ultimat3/manifest": "22.
|
|
55
|
-
"@ultimat3/mcp": "22.
|
|
56
|
-
"@ultimat3/money": "22.
|
|
57
|
-
"@ultimat3/notify": "22.
|
|
58
|
-
"@ultimat3/policy": "22.
|
|
59
|
-
"@ultimat3/pwa": "22.
|
|
60
|
-
"@ultimat3/query": "22.
|
|
61
|
-
"@ultimat3/realtime": "22.
|
|
62
|
-
"@ultimat3/render": "22.
|
|
63
|
-
"@ultimat3/schema": "22.
|
|
64
|
-
"@ultimat3/seo": "22.
|
|
65
|
-
"@ultimat3/storage": "22.
|
|
66
|
-
"@ultimat3/testing": "22.
|
|
67
|
-
"@ultimat3/time": "22.
|
|
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
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
40
|
+
/** Endpoint #0's `POST <path>` when mounted, else `null` — the default, at `ai.mcp.path`. */
|
|
30
41
|
readonly path: string | null;
|
|
31
|
-
/**
|
|
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
|
|
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
|
|
108
|
-
if (
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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, {
|
package/src/dev-route-table.ts
CHANGED
|
@@ -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 `
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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 {
|
|
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
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
package/src/runtime-render.ts
CHANGED
|
@@ -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
|
-
...
|
|
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
|
-
|
|
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 ? [] :
|
|
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
|
|
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
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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 {
|
|
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
|
+
}
|