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