@ultimat3/cli 22.14.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/budgets.ts +10 -2
- package/src/cmd-dev.ts +2 -2
- package/src/dev-route-table.ts +21 -6
- 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/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
|
@@ -242,7 +242,7 @@ async function bootDev(
|
|
|
242
242
|
// `x dev`'s own, so a reload can empty it: an `isr` page's first render was otherwise served for
|
|
243
243
|
// its whole ttl after every save, whatever the modules behind it now said.
|
|
244
244
|
const isr = createIsrController({ buildId });
|
|
245
|
-
const { routes, theme, errorStyles, mcpPath, mcpPaths } = await devRouteTable({
|
|
245
|
+
const { routes, theme, speculation, errorStyles, mcpPath, mcpPaths } = await devRouteTable({
|
|
246
246
|
isr,
|
|
247
247
|
root: options.root,
|
|
248
248
|
env: options.env,
|
|
@@ -278,7 +278,7 @@ async function bootDev(
|
|
|
278
278
|
// The `/_x` shell, the harness's frame, and the app's own error pages — the inline bodies this
|
|
279
279
|
// process serves; the app's surfaces are content-hashed files `'self'` admits.
|
|
280
280
|
inlineStyles: [await devShellStyle(), FRAME_STYLE, ...errorStyles],
|
|
281
|
-
inlineScripts: [theme.cspSource],
|
|
281
|
+
inlineScripts: [theme.cspSource, ...(speculation === undefined ? [] : [speculation.cspSource])],
|
|
282
282
|
// The overlay renders this request's own loops under the error it is already showing.
|
|
283
283
|
// `serve.ts` boots through the same `startRoles` and passes nothing (axiom 6).
|
|
284
284
|
devNotices: (ctx: RequestContext): readonly OverlayNotice[] =>
|
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,6 +58,8 @@ 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
65
|
/** Where the app's default MCP endpoint was mounted, or `null`. */
|
|
@@ -75,11 +79,14 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
75
79
|
// The same call `serve.ts` makes, so the two boots cannot serve different sync targets.
|
|
76
80
|
const sync = await pageSync(input.root, input.env, input.buildId, input.realtime);
|
|
77
81
|
// The client router, when a surface opted in (`navigation.client`) — the same call in both boots.
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
+
});
|
|
83
90
|
const errorStyles = await errorPageStyleSources(input.root);
|
|
84
91
|
// Built once at boot and NOT rebuilt with the islands on a watcher tick: a service worker that
|
|
85
92
|
// changes under a page it controls is the update path, and one per keystroke exercises it per save.
|
|
@@ -151,6 +158,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
151
158
|
persisted: sync.persisted,
|
|
152
159
|
...(navigation.head === undefined ? {} : { navigation: navigation.head }),
|
|
153
160
|
themeHead: theme.head,
|
|
161
|
+
...(speculation === undefined ? {} : { speculationHead: speculation.head }),
|
|
154
162
|
...(origin === undefined ? {} : { origin }),
|
|
155
163
|
...(pwa === undefined
|
|
156
164
|
? {}
|
|
@@ -158,5 +166,12 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
158
166
|
}),
|
|
159
167
|
];
|
|
160
168
|
|
|
161
|
-
return {
|
|
169
|
+
return {
|
|
170
|
+
routes,
|
|
171
|
+
theme,
|
|
172
|
+
speculation,
|
|
173
|
+
errorStyles,
|
|
174
|
+
mcpPath: mcpMount.path,
|
|
175
|
+
mcpPaths: mcpMount.paths,
|
|
176
|
+
};
|
|
162
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
|
+
}
|