@ultimat3/cli 28.0.0 → 29.1.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.
@@ -11,10 +11,14 @@ import type { Ctx, NavigationSurface } from '@ultimat3/core';
11
11
  import { CLIENT_NAVIGATION_SCOPE_HEADER } from '@ultimat3/core';
12
12
  import type { Route, RouteNavigation, UltimateRequest } from '@ultimat3/http';
13
13
  import { locationFor, navigationPurpose, relocate } from '@ultimat3/http';
14
+ import type { Translator } from '@ultimat3/i18n';
14
15
  import type { ClientNavigationHead, HeadTag, RouteEntry } from '@ultimat3/render';
15
16
  import {
16
17
  clientNavigationTags,
17
18
  documentCarriesScope,
19
+ modalRouteList,
20
+ navigationModeOf,
21
+ navigationPrefetches,
18
22
  RouteNavigationInvalidError,
19
23
  routeEntries,
20
24
  } from '@ultimat3/render';
@@ -36,6 +40,11 @@ export interface NavigationDocumentHead {
36
40
  readonly buildId: string;
37
41
  /** `app.config.ts`'s `name`: two apps on one origin (web and admin) are two routers. */
38
42
  readonly app: string;
43
+ /**
44
+ * Per opted-in surface, its modal routes as `modalRouteList` orders them — what lets a click
45
+ * open the dialog before the answer. Absent or empty: the documents name none.
46
+ */
47
+ readonly modalRoutes?: ReadonlyMap<string, readonly string[]>;
39
48
  }
40
49
 
41
50
  export interface PageNavigation {
@@ -71,7 +80,7 @@ export function assertRouteNavigation(
71
80
  entries: readonly RouteEntry[] = routeEntries(),
72
81
  ): void {
73
82
  for (const entry of entries) {
74
- const mode = entry.config.navigation;
83
+ const mode = navigationModeOf(entry.config.navigation);
75
84
  if (mode === undefined || surfaces.some((surface) => surface === entry.surface)) continue;
76
85
  throw new RouteNavigationInvalidError(
77
86
  `${entry.file} declares navigation: '${mode}', and its surface (${entry.surface}) is not in app.config.ts navigation.client`,
@@ -79,6 +88,28 @@ export function assertRouteNavigation(
79
88
  }
80
89
  }
81
90
 
91
+ /**
92
+ * Each opted-in surface's modal routes, from the whole table: a page of ANY surface may outrank a
93
+ * modal pattern on the shared URL space, and the router must not open a frame the server will
94
+ * answer with that page.
95
+ */
96
+ export function modalRoutesOf(
97
+ surfaces: readonly NavigationSurface[],
98
+ entries: readonly RouteEntry[] = routeEntries(),
99
+ ): ReadonlyMap<string, readonly string[]> {
100
+ return new Map(
101
+ surfaces.map((surface) => [
102
+ surface,
103
+ modalRouteList(
104
+ entries.map((entry) => ({
105
+ path: entry.path,
106
+ modal: entry.surface === surface && navigationModeOf(entry.config.navigation) === 'modal',
107
+ })),
108
+ ),
109
+ ]),
110
+ );
111
+ }
112
+
82
113
  /**
83
114
  * Built at boot, never on the watcher tick — the router is framework code, and a tab that loaded
84
115
  * it keeps it across every soft navigation, so a URL that changed per save would be one no tab
@@ -109,6 +140,7 @@ export async function pageNavigation(
109
140
  scriptUrl: script.url,
110
141
  buildId,
111
142
  app: declared.app,
143
+ modalRoutes: modalRoutesOf(declared.surfaces),
112
144
  },
113
145
  };
114
146
  }
@@ -117,13 +149,25 @@ export async function pageNavigation(
117
149
  export const surfaceKey = (head: NavigationDocumentHead, surface: string): string =>
118
150
  `${head.app}:${surface}`;
119
151
 
120
- /** The head for one document, or `undefined` when its surface did not opt in. */
152
+ /**
153
+ * The head for one document, or `undefined` when its surface did not opt in. With `t`, and modal
154
+ * routes on the surface, it names them and the frame's two words in the document's own locale
155
+ * (the framework catalog's `ui.close` and `ui.loading`, which an app may override).
156
+ */
121
157
  export function navigationHeadFor(
122
158
  head: NavigationDocumentHead | undefined,
123
159
  surface: string,
160
+ t?: Translator,
124
161
  ): ClientNavigationHead | undefined {
125
162
  if (head === undefined || !head.surfaces.has(surface)) return undefined;
126
- return { surface: surfaceKey(head, surface), buildId: head.buildId, scriptUrl: head.scriptUrl };
163
+ const base = {
164
+ surface: surfaceKey(head, surface),
165
+ buildId: head.buildId,
166
+ scriptUrl: head.scriptUrl,
167
+ };
168
+ const routes = head.modalRoutes?.get(surface) ?? [];
169
+ if (t === undefined || routes.length === 0) return base;
170
+ return { ...base, modals: { routes, close: t('ui.close'), loading: t('ui.loading') } };
127
171
  }
128
172
 
129
173
  /**
@@ -137,15 +181,15 @@ export function navigationHeadFor(
137
181
  export function navigationTagsOf(
138
182
  head: NavigationDocumentHead | undefined,
139
183
  entry: Pick<RouteEntry, 'surface' | 'config'>,
184
+ t?: Translator,
140
185
  ): readonly HeadTag[] {
141
- if (entry.config.navigation === 'document') return [];
142
- const tags = navigationHeadFor(head, entry.surface);
186
+ const mode = navigationModeOf(entry.config.navigation);
187
+ if (mode === 'document') return [];
188
+ const tags = navigationHeadFor(head, entry.surface, t);
143
189
  if (tags === undefined) return [];
144
190
  // A modal says so in its own document: the router shows it over the page beneath, never decided
145
191
  // from a link — a redirect's target and a pasted `#/…` address are answered alike.
146
- return clientNavigationTags(
147
- entry.config.navigation === 'modal' ? { ...tags, modal: true } : tags,
148
- );
192
+ return clientNavigationTags(mode === 'modal' ? { ...tags, modal: true } : tags);
149
193
  }
150
194
 
151
195
  /** `{ navigation }` for `metaOf`'s spread, or nothing. */
@@ -167,10 +211,10 @@ export function routeNavigationOf(
167
211
  head: NavigationDocumentHead | undefined,
168
212
  ): RouteNavigation | undefined {
169
213
  if (head === undefined || !head.surfaces.has(entry.surface)) return undefined;
170
- if (entry.config.navigation === 'document') return undefined;
214
+ if (navigationModeOf(entry.config.navigation) === 'document') return undefined;
171
215
  return {
172
216
  surface: surfaceKey(head, entry.surface),
173
- prefetch: entry.config.navigation === 'prefetch',
217
+ prefetch: navigationPrefetches(entry.config.navigation),
174
218
  };
175
219
  }
176
220
 
@@ -12,6 +12,8 @@ import type { AppLocaleSet } from '@ultimat3/i18n/app-catalogs';
12
12
  import type { RouteDescriptor, RouteEntry } from '@ultimat3/render';
13
13
  import {
14
14
  describePages,
15
+ navigationModeOf,
16
+ navigationPrefetches,
15
17
  renderHead,
16
18
  routeEntries,
17
19
  speculationPattern,
@@ -37,8 +39,9 @@ export async function loadSpeculation(root: string): Promise<SpeculationConfig>
37
39
  * no policy, no `no-store`. Such a page ran its `load` at build time, for nobody. An `ssr` page
38
40
  * there has no way to declare that its GET records something (`navigation` is refused on a
39
41
  * surface that did not opt in), so it is never a candidate.
40
- * - On a client-routed surface: only a page that declared `navigation: 'prefetch'` — the app's own
41
- * statement that its GET is a pure read, the same one the router's prefetch obeys.
42
+ * - On a client-routed surface: only a page that declared `navigation: 'prefetch'` (or a modal
43
+ * that declared `prefetch: 'intent'`) — the app's own statement that its GET is a pure read,
44
+ * the same one the router's prefetch obeys.
42
45
  * - Never an `api/` route, a `navigation: 'document'` page, or an `offline: 'network-only'` one.
43
46
  */
44
47
  export function speculationCandidate(
@@ -47,8 +50,9 @@ export function speculationCandidate(
47
50
  client: ReadonlySet<string>,
48
51
  ): boolean {
49
52
  if (entry.surface !== 'site' && entry.surface !== 'app') return false;
50
- if (entry.config.navigation === 'document' || described.offline === 'network-only') return false;
51
- if (client.has(entry.surface)) return entry.config.navigation === 'prefetch';
53
+ const mode = navigationModeOf(entry.config.navigation);
54
+ if (mode === 'document' || described.offline === 'network-only') return false;
55
+ if (client.has(entry.surface)) return navigationPrefetches(entry.config.navigation);
52
56
  return (described.mode === 'static' || described.mode === 'isr') && !described.personal;
53
57
  }
54
58
 
package/src/prerender.ts CHANGED
@@ -195,6 +195,8 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
195
195
  const theme = themeBoot(await loadThemeMode(options.root));
196
196
  // The brand the served process carries, so an exported page and a served one paint alike.
197
197
  const brand = await loadThemeBrand(options.root);
198
+ // The app's own favicon is resolved by the DOCUMENT, per render, from the app root below — the
199
+ // link the served surfaces emit, so an exported document and a served one carry the same icon.
198
200
  // The client router, for the surfaces that opted in: written BEFORE any document names it, for
199
201
  // the register's reason below — `measureDocumentJs` weighs it off disk, and it is charged to
200
202
  // the route (the interactivity the app asked for), so it must be there to be weighed.
@@ -266,6 +268,9 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
266
268
  // Started on the first route that needs weighing, and released however the loop ends.
267
269
  const database = measureDatabase(options.root);
268
270
  const documentOptions = {
271
+ // The app root: the document resolves the app's own favicon from it, per render. `writeSiteAssets`
272
+ // above copied the icon into the artifact under the same hashed URL this resolves.
273
+ root: options.root,
269
274
  resolveIsland: (file: string) => islands.resolverFor(file),
270
275
  realtimeIslands: () => islands.realtime,
271
276
  themeHead: theme.head,
@@ -1,10 +1,16 @@
1
- // The queues a `worker` serves and the slots each gets: `jobs.queues`, `jobs.concurrency` and
2
- // `jobs.visibilityTimeoutMs` off the app's own `app.config.ts`, and `WORKER_QUEUES` off the
1
+ // The queues a `worker` serves and the slots each gets: `jobs.queues`, `jobs.concurrency`,
2
+ // `jobs.maxInFlight` and `jobs.visibilityTimeoutMs` off the app's own `app.config.ts`, and `WORKER_QUEUES` off the
3
3
  // Deployment's env — the one per-Deployment answer, since every Deployment runs the same image and
4
4
  // the same config (issues #673, #676).
5
5
 
6
6
  import type { AppConfig, JobsConcurrency } from '@ultimat3/core';
7
- import { ConfigInvalidError, logger, renderCauseValue, renderFixShellArg } from '@ultimat3/core';
7
+ import {
8
+ ConfigInvalidError,
9
+ logger,
10
+ renderCauseValue,
11
+ renderFixShellArg,
12
+ UltimateError,
13
+ } from '@ultimat3/core';
8
14
  import { registeredJobs, type WorkerOptions } from '@ultimat3/jobs';
9
15
  import type { Env } from './runtime-bindings';
10
16
 
@@ -19,7 +25,14 @@ export interface WorkerConfig {
19
25
  * queues registered jobs are enqueued on. `false` is the union `workerQueuesFor` explains.
20
26
  */
21
27
  readonly exact: boolean;
28
+ /**
29
+ * `jobs.queues` as the app configured them, kept when `WORKER_QUEUES` replaced `queues`: with
30
+ * the registered jobs' queues, the set an exact entry must belong to (`refuseUnknownQueues`).
31
+ */
32
+ readonly configured: readonly string[];
22
33
  readonly concurrency: JobsConcurrency | undefined;
34
+ /** `jobs.maxInFlight`: runs across every queue this process serves. Unset: no process cap. */
35
+ readonly maxInFlight?: number | undefined;
23
36
  readonly visibilityTimeoutMs: number | undefined;
24
37
  }
25
38
 
@@ -27,7 +40,9 @@ export interface WorkerConfig {
27
40
  const NO_WORKER_CONFIG: WorkerConfig = Object.freeze({
28
41
  queues: [],
29
42
  exact: false,
43
+ configured: [],
30
44
  concurrency: undefined,
45
+ maxInFlight: undefined,
31
46
  visibilityTimeoutMs: undefined,
32
47
  });
33
48
 
@@ -45,7 +60,7 @@ export function workerQueuesFromEnv(env: Env): readonly string[] | undefined {
45
60
  if (!names.includes('')) return named;
46
61
  throw new ConfigInvalidError({
47
62
  cause: `${WORKER_QUEUES_ENV} ${renderCauseValue(raw)} contains an empty queue name — every comma-separated entry must name a queue`,
48
- fix: `WORKER_QUEUES=${renderFixShellArg(named.join(','), '<queue>,<queue>')} on this Deployment — or unset WORKER_QUEUES to serve jobs.queues and every registered job's queue`,
63
+ fix: `WORKER_QUEUES=${renderFixShellArg(named.join(','), '<queue>,<queue>')} # on this Deployment; or unset WORKER_QUEUES to serve jobs.queues and every registered job's queue`,
49
64
  meta: { key: WORKER_QUEUES_ENV },
50
65
  });
51
66
  }
@@ -58,7 +73,9 @@ export function workerConfigOf(config: AppConfig | undefined, env: Env = {}): Wo
58
73
  : {
59
74
  queues: config.jobs.queues,
60
75
  exact: false,
76
+ configured: config.jobs.queues,
61
77
  concurrency: config.jobs.concurrency,
78
+ maxInFlight: config.jobs.maxInFlight,
62
79
  visibilityTimeoutMs: config.jobs.visibilityTimeoutMs,
63
80
  };
64
81
  return fromEnv === undefined ? base : { ...base, queues: fromEnv, exact: true };
@@ -97,16 +114,74 @@ function reportUnserved(served: readonly string[], registered: readonly string[]
97
114
  });
98
115
  }
99
116
 
100
- type WorkerConfigOptions = Pick<WorkerOptions, 'queues' | 'concurrency' | 'visibilityTimeoutMs'>;
117
+ /**
118
+ * A `WORKER_QUEUES` entry no registered job and no `jobs.queues` entry names. The worker would
119
+ * claim a queue nothing is enqueued on and leave the real ones waiting while its pod stays Ready —
120
+ * a 15-minute outage behind a healthy roll (`banks-*`, never expanded, 2026-10-27). Refused at
121
+ * boot instead, so the new pods crash and a rolling update stalls on the old ones.
122
+ */
123
+ export class WorkerQueueUnknownError extends UltimateError {
124
+ constructor(input: {
125
+ unknown: readonly string[];
126
+ known: readonly string[];
127
+ named: readonly string[];
128
+ }) {
129
+ const patterns = input.unknown.filter((queue) => /[*?[\]{}]/.test(queue));
130
+ // A pattern's fix is its expansion over the known queues: the list the author meant to write.
131
+ const meant = input.named.flatMap((queue) =>
132
+ patterns.includes(queue)
133
+ ? input.known.filter((name) => new Bun.Glob(queue).match(name))
134
+ : input.unknown.includes(queue)
135
+ ? []
136
+ : [queue],
137
+ );
138
+ const list = [...new Set(meant)].join(',');
139
+ // Every name through `renderCauseValue`: an env value can carry a newline, and a cause is one line.
140
+ const names = (queues: readonly string[]) => queues.map((q) => renderCauseValue(q)).join(', ');
141
+ const pattern =
142
+ patterns.length === 0
143
+ ? ''
144
+ : ` — WORKER_QUEUES is never expanded, so ${names(patterns)} is a literal queue name`;
145
+ super({
146
+ code: 'X_WORKER_QUEUE_UNKNOWN',
147
+ cause: `${WORKER_QUEUES_ENV} names ${names(input.unknown)}, which no registered job is enqueued on and jobs.queues does not list${pattern}; the queues this app knows are ${input.known.length === 0 ? 'none' : names(input.known)}`,
148
+ // The prose after `#`, so the line pasted into a shell sets the variable and runs nothing.
149
+ fix: `WORKER_QUEUES=${renderFixShellArg(list === '' ? undefined : list, '<queue>,<queue>')} # on this Deployment; or add the queue to jobs.queues in app.config.ts when jobs reach it by enqueue({ queue }) alone`,
150
+ meta: { key: WORKER_QUEUES_ENV, unknown: input.unknown, known: input.known },
151
+ });
152
+ }
153
+ }
154
+
155
+ /**
156
+ * An exact worker's entries, held to what the app knows: `jobs.queues` plus every registered
157
+ * job's queue — the union an unset `WORKER_QUEUES` would serve. Thrown, never logged: an info line
158
+ * is what let the worker run claiming nothing (2026-10-27).
159
+ */
160
+ function refuseUnknownQueues(
161
+ named: readonly string[],
162
+ configured: readonly string[],
163
+ registered: readonly string[],
164
+ ): void {
165
+ const known = [...new Set([...configured, ...registered])].sort();
166
+ const unknown = named.filter((queue) => !known.includes(queue));
167
+ if (unknown.length > 0) throw new WorkerQueueUnknownError({ unknown, known, named });
168
+ }
169
+
170
+ type WorkerConfigOptions = Pick<
171
+ WorkerOptions,
172
+ 'queues' | 'concurrency' | 'maxInFlight' | 'visibilityTimeoutMs'
173
+ >;
101
174
 
102
175
  /**
103
176
  * The `jobWorker` options the config supplies, read at START so every job the app registered
104
- * on import is counted. A key the config leaves unset is omitted, never passed as `undefined`, so
105
- * the worker's own default applies (`exactOptionalPropertyTypes`).
177
+ * on import is counted — and an exact entry the app does not know is refused there. A key the
178
+ * config leaves unset is omitted, never passed as `undefined`, so the worker's own default applies
179
+ * (`exactOptionalPropertyTypes`).
106
180
  */
107
181
  export function workerOptionsFor(config: WorkerConfig | undefined): WorkerConfigOptions {
108
182
  const registered = registeredJobs().map((handle) => handle.queue);
109
183
  const exact = config?.exact === true;
184
+ if (exact) refuseUnknownQueues(config?.queues ?? [], config?.configured ?? [], registered);
110
185
  const queues = workerQueuesFor(config?.queues ?? [], registered, exact);
111
186
  if (exact) reportUnserved(queues ?? [], registered);
112
187
  // Passed as written: `jobWorker` reads a table by own key and runs a queue it does not name at
@@ -115,6 +190,7 @@ export function workerOptionsFor(config: WorkerConfig | undefined): WorkerConfig
115
190
  return {
116
191
  ...(queues === undefined ? {} : { queues }),
117
192
  ...(concurrency === undefined ? {} : { concurrency }),
193
+ ...(config?.maxInFlight === undefined ? {} : { maxInFlight: config.maxInFlight }),
118
194
  ...(config?.visibilityTimeoutMs === undefined
119
195
  ? {}
120
196
  : { visibilityTimeoutMs: config.visibilityTimeoutMs }),
@@ -6,7 +6,7 @@
6
6
  import { actionPathStyle } from '@ultimat3/action';
7
7
  import { clientScopeOf } from '@ultimat3/auth';
8
8
  import type { Actor, Ctx } from '@ultimat3/core';
9
- import { CLIENT_SCOPE_HEADER, singleFlight } from '@ultimat3/core';
9
+ import { CLIENT_SCOPE_HEADER, escapeHtml, singleFlight } from '@ultimat3/core';
10
10
  import type {
11
11
  RouteMeta as HttpRouteMeta,
12
12
  RedirectIntent,
@@ -44,6 +44,7 @@ import {
44
44
  staticHeaders,
45
45
  streamResult,
46
46
  } from '@ultimat3/render/server';
47
+ import { loadAppConfig } from './app-config-load';
47
48
  import type { DocumentOptions } from './document-options';
48
49
  import { routeQuery } from './isr-query-guard';
49
50
  import {
@@ -56,13 +57,28 @@ import { bootScript, collectorFor } from './route-islands';
56
57
  import { addressedUrl } from './runtime-address';
57
58
  import { attachedIsr } from './runtime-isr';
58
59
  import { isrOutcome } from './runtime-isr-outcome';
60
+ import { siteAssetTable } from './site-assets';
59
61
  import type { StaticResult } from './static-document';
60
62
  import { createStaticMemo, staticMemoKey, staticResponse } from './static-document';
61
63
  import { styleBundle } from './style-bundle';
62
64
 
63
65
  export type { DocumentOptions, IslandResolver } from './document-options';
64
66
 
65
- export interface DevRenderOptions extends DocumentOptions {
67
+ /**
68
+ * The app-wide favicon head, from `site.favicon`: the content-hashed URL `asset('assets/….svg')`
69
+ * returns and `/assets/*` serves, never the raw path. Per render, never frozen at boot.
70
+ */
71
+ export async function appFaviconHead(root: string): Promise<string | undefined> {
72
+ const favicon = (await loadAppConfig(root))?.site.favicon;
73
+ if (favicon === undefined) return undefined;
74
+ const url = siteAssetTable(root).resolve(favicon).url;
75
+ return `<link rel="icon" type="image/svg+xml" href="${escapeHtml(url)}">`;
76
+ }
77
+
78
+ /** `DocumentOptions`, plus the app root the app's own favicon is resolved against per render. */
79
+ export type RenderOptions = DocumentOptions & { readonly root?: string };
80
+
81
+ export interface DevRenderOptions extends RenderOptions {
66
82
  readonly buildId: string;
67
83
  /**
68
84
  * The boot's own controller, attached and released by it (`runtime-isr.ts`). Omitted, one is
@@ -76,38 +92,35 @@ export interface DevRenderOptions extends DocumentOptions {
76
92
  */
77
93
  readonly memoStatic?: boolean;
78
94
  }
79
-
80
95
  /** What a route's `meta(data)` is given. `url` is a string because that is what `ld.*` embeds. */
81
96
  export interface DevRouteData extends Record<string, unknown> {
82
97
  readonly url: string;
83
98
  readonly params: RouteParams;
84
99
  }
85
-
86
100
  /**
87
101
  * `<html lang>` is the request's own locale, never a constant: the `locale` stage negotiated it
88
102
  * one stage before this handler and published it on the context, so a hardcoded `'en'` shipped
89
- * every document mislabelled — wrong for a screen reader, wrong for `hreflang`, wrong for a CDN
90
- * keying on `content-language`. Outside a request (`x build`'s prerender) it is the app's own
91
- * configured fallback, which is the only defensible answer there.
103
+ * every document mislabelled — wrong for a screen reader, wrong for `hreflang`. Outside a request
104
+ * (`x build`'s prerender) it is the app's own configured fallback.
92
105
  */
93
106
  const lang = (): string => currentLocale();
94
107
 
95
108
  /**
96
109
  * `scope` is present only on a PRIVATE document (a gated `ssr` page, every `stream`): the page's
97
110
  * client scope (`@ultimat3/auth`'s `clientScopeOf`), which core's `pageClient()` reads to fence its
98
- * one store per principal. A shareable document (`static`, `isr`, ungated `ssr`) carries NO scope
99
- * tag — absent means "not rendered for anyone", a different answer from `''`, the anonymous page.
111
+ * one store per principal. A shareable document carries NO scope tag — absent means "not rendered
112
+ * for anyone", a different answer from `''`, the anonymous page.
100
113
  */
101
114
  const headFor = async (
102
115
  entry: RouteEntry,
103
116
  ctx: DevRouteData,
104
117
  data: RouteData,
105
- options: DocumentOptions,
118
+ options: RenderOptions,
106
119
  scope?: string,
107
120
  ): Promise<string> => {
108
121
  const meta = metaContextFor(ctx, data);
109
122
  const url = new URL(ctx.url);
110
- const router = navigationTagsOf(options.navigation, entry);
123
+ const router = navigationTagsOf(options.navigation, entry, meta.t);
111
124
  return (
112
125
  renderHead(
113
126
  headFromMeta(
@@ -132,7 +145,7 @@ const headFor = async (
132
145
  ...router,
133
146
  // The page boot rides the scope tag: its whole job — restoring a principal's persisted
134
147
  // records and replaying its queued writes — is per principal, and a shareable document
135
- // (no scope tag) has neither. Cheaper than walking the page's islands, and exact.
148
+ // (no scope tag) has neither.
136
149
  ...(scope === undefined
137
150
  ? []
138
151
  : [clientScopeTag(scope), ...clientPersistTags(options.persisted?.() ?? [])]),
@@ -142,6 +155,9 @@ const headFor = async (
142
155
  (options.themeHead ?? '') +
143
156
  // One mechanism per document: the router prefetches for itself, the browser for the rest.
144
157
  (router.length === 0 ? (options.speculationHead ?? '') : '') +
158
+ // The app's own favicon: one link, every document, the URL the table resolves NOW.
159
+ ((options.root === undefined ? options.faviconHead : await appFaviconHead(options.root)) ??
160
+ '') +
145
161
  // Per locale: each links its own locale's manifest (`PwaArtifacts.headFor`).
146
162
  (typeof options.pwaHead === 'function' ? options.pwaHead(meta.locale) : (options.pwaHead ?? ''))
147
163
  );
@@ -149,19 +165,17 @@ const headFor = async (
149
165
 
150
166
  /**
151
167
  * `<link rel="stylesheet">` for the surface's own stylesheets, or nothing at all when the surface
152
- * imports none. Inlined until 2026-09-06: every `app/` document of ai-maxxing then carried the same
153
- * 156,738-byte `<style>` (92% of it) under `no-store`, re-paid on every navigation.
168
+ * imports none.
154
169
  *
155
170
  * In `<head>`, which is what keeps this a swap and not a regression: a `<link rel="stylesheet">`
156
- * there is render-blocking in every browser, exactly as the inline block was, so there is no
157
- * window in which the document paints unstyled. Moving it to the body, or deferring it, is what
158
- * would introduce a flash — never do that here.
171
+ * there is render-blocking in every browser, exactly as the inline block was, so there is no window
172
+ * in which the document paints unstyled. Moving it to the body, or deferring it, would introduce a
173
+ * flash — never do that here.
159
174
  *
160
175
  * Read through `styleBundle()` rather than passed in through `DocumentOptions`: the registry it
161
176
  * derives from is process-global (importing the app IS what fills it), so a caller that forgot to
162
- * thread a resolver would serve a document with no CSS at all — and `appRoutes` is on this
163
- * package's public surface, called as `appRoutes({ buildId })` by both tracked apps' contract
164
- * tests. One reader, and it is the same one `styleRoutes` serves from.
177
+ * thread a resolver would serve a document with no CSS at all. One reader, and it is the same one
178
+ * `styleRoutes` serves from.
165
179
  */
166
180
  const styleTag = (entry: RouteEntry): string => {
167
181
  const href = styleBundle().hrefFor(entry.surface);
@@ -175,8 +189,7 @@ const stylesFor = (entry: RouteEntry, options: DocumentOptions): string =>
175
189
  /**
176
190
  * The route's rendered body, inside the hydration root. Every mode goes through here — an empty
177
191
  * root is what a module exporting no component renders, and nothing else: `spa` was the one mode
178
- * that never reached this function, which is why every `spa` route ever declared served
179
- * `<div id="x-root"></div>` and painted nothing.
192
+ * that never reached this function, so every `spa` route ever declared served an empty root.
180
193
  */
181
194
  export async function routeBody(
182
195
  entry: RouteEntry,
@@ -204,28 +217,27 @@ export async function routeBody(
204
217
  }
205
218
 
206
219
  /**
207
- * Head + body for one route render. Exported because the build's prerenderer must emit the same
208
- * document `x dev` serves — two document builders is how a page that works in dev ships broken.
220
+ * Head + body for one route render — the document `x dev` serves, which the build's prerenderer
221
+ * must emit too.
209
222
  */
210
223
  export async function routeDocument(
211
224
  entry: RouteEntry,
212
225
  ctx: DevRouteData,
213
- options: DocumentOptions = {},
226
+ options: RenderOptions = {},
214
227
  ): Promise<string> {
215
228
  return documentFrom(entry, ctx, await routeDataFor(entry.config, ctx), options);
216
229
  }
217
230
 
218
231
  /**
219
232
  * The document a request by `actor` is SERVED: `routeDocument` plus the scope the response would
220
- * carry, which is what earns the page boot. The build weighs an app/ page through this, because a
221
- * budget is a promise about what that page's browser downloads — and an unscoped render of a
222
- * scoped page weighed a runtime chunk it never fetches and no boot it always does (#505).
233
+ * carry, which is what earns the page boot. The build weighs an app/ page through this — a budget
234
+ * is a promise about what that page's browser downloads (#505).
223
235
  */
224
236
  export async function servedDocument(
225
237
  entry: RouteEntry,
226
238
  ctx: DevRouteData,
227
239
  actor: Actor,
228
- options: DocumentOptions & { readonly buildId: string },
240
+ options: RenderOptions & { readonly buildId: string },
229
241
  ): Promise<string> {
230
242
  const data = await routeDataFor(entry.config, ctx);
231
243
  return documentFrom(entry, ctx, data, options, documentScope(entry, options.buildId, actor));
@@ -233,9 +245,8 @@ export async function servedDocument(
233
245
 
234
246
  /**
235
247
  * Whether, and as whom, a mode's response is scoped — ONE decision for the request path and for
236
- * `servedDocument`. A stream is always `private, no-store`; an ssr page carries the scope exactly
237
- * when the headers `renderSsr` sends are private (an ungated page is `public, s-maxage`, and a CDN
238
- * may hand it to anyone); a static or isr document is shareable by construction.
248
+ * `servedDocument`. A stream is always `private, no-store`; an ssr page carries the scope when the
249
+ * headers `renderSsr` sends are private; a static or isr document is shareable.
239
250
  */
240
251
  function documentScope(entry: RouteEntry, buildId: string, actor: Actor): string | undefined {
241
252
  switch (entry.config.render) {
@@ -257,14 +268,14 @@ function documentScope(entry: RouteEntry, buildId: string, actor: Actor): string
257
268
  * a `<title>` describe content the body does not contain.
258
269
  *
259
270
  * The hydration runtime is appended after the body and only after it: what strategies a page needs
260
- * is a fact about the islands the walk just recorded, so emitting it earlier would either guess or
261
- * ship the whole runtime to a page with no island — the 0kb baseline, spent on nothing.
271
+ * is a fact about the islands the walk just recorded, so emitting it earlier would guess or ship
272
+ * the whole runtime to a page with no island.
262
273
  */
263
274
  async function documentFrom(
264
275
  entry: RouteEntry,
265
276
  ctx: DevRouteData,
266
277
  data: RouteData,
267
- options: DocumentOptions,
278
+ options: RenderOptions,
268
279
  scope?: string,
269
280
  ): Promise<string> {
270
281
  const islands = collectorFor(entry, options, scope);
@@ -298,11 +309,10 @@ async function resultFor(
298
309
  return { status, headers: staticHeaders(contentHash(body), options.buildId), body };
299
310
  }
300
311
  case 'stream': {
301
- // The shell IS the component: nothing can yet mark a subtree as a hole. Solid's `Suspense`
302
- // is not the missing piece and never will be here — it calls `getContextId()`, which throws
303
- // outside a Solid renderer, and this package's JSX factory is inert by design. A hole marker
304
- // has to be the framework's own. Until it exists the first flush carries the whole body —
305
- // correct output, no streaming benefit.
312
+ // The shell IS the component: nothing can yet mark a subtree as a hole, and Solid's
313
+ // `Suspense` cannot be the missing piece — it calls `getContextId()`, which throws outside a
314
+ // Solid renderer. Until a hole marker of the framework's own exists, the first flush carries
315
+ // the whole body — correct output, no streaming benefit.
306
316
  // A stream is always `private, no-store` (`streamResult`), so it always carries the scope.
307
317
  const scope = documentScope(entry, options.buildId, ctx.actor);
308
318
  const islands = collectorFor(entry, options, scope);
@@ -316,7 +326,7 @@ async function resultFor(
316
326
  head: `<!doctype html><html lang="${lang()}"><head>${head}${stylesFor(entry, options)}</head><body>`,
317
327
  // The runtime rides the first flush, with the shell it boots. A later chunk would leave
318
328
  // the window between flush one and the close with inert islands and no listeners on
319
- // them — which is exactly the first-click-lost failure `interaction` replay exists for.
329
+ // them — the first-click-lost failure `interaction` replay exists for.
320
330
  shell: `${shell}${bootScript(entry, islands, options, scope)}${hydrateRuntime(islands.directives)}`,
321
331
  holes: [],
322
332
  },
@@ -345,8 +355,7 @@ async function resultFor(
345
355
 
346
356
  /**
347
357
  * A private document's scope, as a RESPONSE header too: the service worker partitions its offline
348
- * pages by principal and never parses HTML, so the meta alone cannot reach it. Exactly the
349
- * documents that carry the scope tag carry this — a shareable one carries neither.
358
+ * pages by principal and never parses HTML, so the meta alone cannot reach it.
350
359
  */
351
360
  const withScope = (result: RenderResult, scope: string | undefined): RenderResult =>
352
361
  scope === undefined
@@ -355,16 +364,14 @@ const withScope = (result: RenderResult, scope: string | undefined): RenderResul
355
364
 
356
365
  /**
357
366
  * A loader's redirect. No declared `cache` means `private, no-store`: the loader decided it for
358
- * THIS request (it recorded an open, it checked a token), and the mode's default would offer that
359
- * decision to a CDN, which would then answer every later request without running the loader. A
360
- * declared one is the pipeline's to apply, off `meta.cache`, exactly as for any handler.
367
+ * THIS request, and the mode's default would offer that decision to a CDN, which would then answer
368
+ * every later request without running the loader.
361
369
  */
362
370
  function loadRedirect(entry: RouteEntry, to: RedirectIntent): Response {
363
371
  const response = redirect(to.location, to.status);
364
372
  if (entry.config.cache === undefined) response.headers.set('cache-control', 'private, no-store');
365
373
  return response;
366
374
  }
367
-
368
375
  const responseOf = (result: RenderResult): Response =>
369
376
  typeof result.body === 'string'
370
377
  ? html(result.body, { status: result.status, headers: result.headers })
@@ -381,8 +388,7 @@ const metaOf = (entry: RouteEntry, head?: NavigationDocumentHead): HttpRouteMeta
381
388
  localeSource: entry.surface === 'site' ? 'path' : 'request',
382
389
  ...(entry.config.policy === undefined ? {} : { policy: entry.config.policy.permission }),
383
390
  // Projected so the router screens its ages at mount (`assertRouteCache`) and the `cache-headers`
384
- // stage applies it to a response that wrote none — a loader's redirect. The rendered document
385
- // carries its own header, from `ssrHeaders`, which the stage reviews rather than replaces.
391
+ // stage applies it to a response that wrote none — a loader's redirect.
386
392
  ...(entry.config.cache === undefined
387
393
  ? {}
388
394
  : { cache: entry.config.cache === 'no-store' ? NO_STORE : entry.config.cache }),
@@ -397,13 +403,9 @@ const metaOf = (entry: RouteEntry, head?: NavigationDocumentHead): HttpRouteMeta
397
403
  * handler renders AND the `meta` the pipeline enforces — is read back from the table on every
398
404
  * request. `x dev` re-registers a route module when its source changes (`app-load.ts`), and a
399
405
  * handler closing over the entry it was built from kept serving the first component after every
400
- * save — the table had moved and this closure had not. `meta` was the same defect one stage
401
- * earlier: a snapshot taken here, so a `policy` added in a save was not enforced until a restart
402
- * while the page behind it was already the new one — the pipeline's `auth` and `authz` stages read
403
- * `route.meta` per request, and this getter is what makes that read the table's. The path cannot
404
- * move under a reload (the table derives it from the file, and the file is the reload's key), so
405
- * the entry captured here is only the fallback for a table cleared under a running server, which
406
- * only a test does — and then guard and page fall back together.
406
+ * save. `meta` was the same defect one stage earlier, and this getter is what makes that read the
407
+ * table's: the pipeline's `auth` and `authz` stages read `route.meta` per request. The path cannot
408
+ * move under a reload, so the captured entry is only the fallback for a cleared table.
407
409
  */
408
410
  export function appRoutes(options: DevRenderOptions): readonly Route[] {
409
411
  const isr = options.isr ?? attachedIsr({ buildId: options.buildId }).isr;
package/src/serve-web.ts CHANGED
@@ -69,6 +69,9 @@ export async function webSurface(
69
69
  // `site.origin` and `seo.robots.disallow`: the absolute URLs every document and the sitemap carry.
70
70
  const site = await loadSiteSettings(options.root);
71
71
  const origin = publicOrigin(options.env, site);
72
+ // The app's own favicon is NOT resolved here: the document resolves it, per render, from the app
73
+ // root below — the same link `x dev` serves, so the tab icon does not change between a laptop
74
+ // and the image.
72
75
  // The page's sync target and its scripts — the same call `x dev` makes, so the two cannot differ.
73
76
  // Before the service worker, which precaches those scripts.
74
77
  const sync = await pageSync(options.root, options.env, buildId, runtime.realtime);
@@ -150,6 +153,8 @@ export async function webSurface(
150
153
  }),
151
154
  ...appRoutes({
152
155
  buildId,
156
+ // The app root: the document resolves the app's own favicon from it, per render.
157
+ root: options.root,
153
158
  resolveIsland: (file) => islands.resolverFor(file),
154
159
  realtimeIslands: () => islands.realtime,
155
160
  ...(sync.head === undefined ? {} : { sync: sync.head }),