@ultimat3/cli 27.16.1 → 29.0.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.
@@ -25,6 +25,7 @@ import { loadApp } from './app-load';
25
25
  import type { DuplicateProbe } from './duplicate-packages';
26
26
  import { duplicateCause, duplicateFinding, findDuplicateInstalls } from './duplicate-packages';
27
27
  import { auditApp } from './i18n-audit';
28
+ import { localeParityFindings } from './i18n-locale-parity';
28
29
  import type { Finding } from './output';
29
30
  import { findingFrom } from './output';
30
31
  import { CATALOG_ROOT, catalogPath } from './templates/locales';
@@ -287,6 +288,10 @@ export function missingKeyFindings(report: ExtractReport): readonly Finding[] {
287
288
  * Both halves of one question — does every string this app renders resolve? — for a caller that
288
289
  * wants the verdict and not the table. `x verify`'s `i18n` step is that caller; `cmd-i18n.ts`
289
290
  * composes the same two pieces itself because it also renders per-locale rows.
291
+ *
292
+ * The locale parity joins them: a catalog can hold every key source uses and still be wrong in
293
+ * the two ways only a comparison with the reference locale can see — a slot one side does not
294
+ * have, and a string nobody translated.
290
295
  */
291
296
  export async function catalogFindings(root: string): Promise<readonly Finding[]> {
292
297
  const { report, catalogs, extraction, ignoreUnused } = await auditApp(root);
@@ -294,5 +299,6 @@ export async function catalogFindings(root: string): Promise<readonly Finding[]>
294
299
  return [
295
300
  ...missingKeyFindings(withPlaceholdersMissing(report, catalogs)),
296
301
  ...registration.findings,
302
+ ...localeParityFindings(catalogs),
297
303
  ];
298
304
  }
package/src/mcp-errors.ts CHANGED
@@ -306,6 +306,12 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
306
306
  'x verify --only boundaries --json # then move each declaration in X.ts to X/<name>.ts, one primitive per file',
307
307
  X_SLICE_POLICY_INCOMPLETE:
308
308
  'x policy list --json # names the slice policy module; add the export lines the thrown fix spells out to it, then run the generator again',
309
+ X_CATALOG_SLOT_MISMATCH:
310
+ 'x i18n check --json # the finding names the locale, the key and the slots that differ',
311
+ X_CATALOG_UNTRANSLATED:
312
+ 'x i18n check --json # the finding names the locale and the key still carrying the reference value',
313
+ X_WORKER_QUEUE_UNKNOWN:
314
+ 'x jobs list --json # set WORKER_QUEUES to queues it lists, or add the queue to jobs.queues in app.config.ts',
309
315
  };
310
316
 
311
317
  const isCliCode = (code: string): code is CliErrorCode =>
package/src/messages.ts CHANGED
@@ -139,6 +139,10 @@ const CATALOG = {
139
139
  'cli.i18n.dynamic': '{count} dynamic t() call(s) the extractor cannot verify:',
140
140
  'cli.i18n.gaps': '{missing} missing key(s) across {locales} locale(s)',
141
141
  'cli.i18n.ok': '{locales} locale(s), {keys} key(s) used — no gaps',
142
+ // The summary a locale-parity finding needs: `cli.i18n.gaps` counts MISSING KEYS, so it cannot
143
+ // say "this locale's slots drifted and this string was never translated".
144
+ 'cli.i18n.parity':
145
+ '{count} locale-parity finding(s) across {locales} locale(s) — a slot or an untranslated copy against {reference}',
142
146
  'cli.i18n.synced': 'synced {locale} from {from} — {added} key(s) added, {total} total',
143
147
  'cli.i18n.unused': '{count} key(s) defined in {locale} and never used:',
144
148
  'cli.jobs.backfillNoCursor': 'no cursor yet',
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,33 +92,30 @@ 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);
@@ -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 }),
@@ -1,9 +1,9 @@
1
- // The public origin and the crawler rules out of `app.config.ts` — `site.origin`,
2
- // `seo.robots.disallow` and `seo.robots.groups` — and the one resolution of "which origin is this site served on" every
1
+ // The public origin, the app's favicon and the crawler rules out of `app.config.ts` — `site.origin`,
2
+ // `site.favicon`, `seo.robots.disallow` and `seo.robots.groups` — and the one resolution of "which origin is this site served on" every
3
3
  // absolute URL a document, a sitemap or a robots file carries is built against. Read off the one
4
4
  // loader (`app-config-load.ts`).
5
5
 
6
- import type { Environment, SeoRobotsGroup, SeoSitemapConfig } from '@ultimat3/core';
6
+ import type { Environment, SeoRobotsGroup, SeoSitemapConfig, SiteFavicon } from '@ultimat3/core';
7
7
  import { loadAppConfig } from './app-config-load';
8
8
 
9
9
  export interface SiteSettings {
@@ -18,6 +18,14 @@ export interface SiteSettings {
18
18
  readonly robotsGroups?: readonly SeoRobotsGroup[] | undefined;
19
19
  /** `seo.sitemap` — public `app/` pages to list, where `<lastmod>` comes from, sections, page size. */
20
20
  readonly sitemap: SeoSitemapConfig;
21
+ /**
22
+ * `site.favicon` — the app's SVG favicon, named as a site asset path (`assets/brand/icon.svg`).
23
+ * `undefined` when the app declares none. The boot resolves it ONCE, through the site asset
24
+ * table (`siteAssetTable(root).resolve(path).url` — the table `/assets/*` serves and `asset()`
25
+ * answers from), into the content-hashed URL every document carries. Optional so a
26
+ * `SiteSettings` literal written before it existed still compiles.
27
+ */
28
+ readonly favicon?: SiteFavicon | undefined;
21
29
  }
22
30
 
23
31
  export const NO_SITE_SETTINGS: SiteSettings = {
@@ -25,6 +33,7 @@ export const NO_SITE_SETTINGS: SiteSettings = {
25
33
  disallow: [],
26
34
  robotsGroups: [],
27
35
  sitemap: { extra: [], lastmod: 'none', sections: [], pageSize: 50_000 },
36
+ favicon: undefined,
28
37
  };
29
38
 
30
39
  const trimSlash = (origin: string): string => origin.replace(/\/+$/, '');
@@ -32,12 +41,13 @@ const trimSlash = (origin: string): string => origin.replace(/\/+$/, '');
32
41
  export async function loadSiteSettings(root: string): Promise<SiteSettings> {
33
42
  const config = await loadAppConfig(root);
34
43
  if (config === undefined) return NO_SITE_SETTINGS;
35
- const { origin } = config.site;
44
+ const { origin, favicon } = config.site;
36
45
  return {
37
46
  origin: origin === null ? null : trimSlash(origin),
38
47
  disallow: [...config.seo.robots.disallow],
39
48
  robotsGroups: [...(config.seo.robots.groups ?? [])],
40
49
  sitemap: config.seo.sitemap,
50
+ favicon,
41
51
  };
42
52
  }
43
53
 
@@ -32,16 +32,16 @@ const dockerfile = (
32
32
  # from the file itself, and without it the frozen install cannot find what the lockfile names.
33
33
  # The tag names the series for the reader; the digest is what builds — a tag is a pointer its
34
34
  # publisher can move. Dependabot's docker ecosystem moves the digest together with the tag.
35
- # The digest is \`oven/bun:1.4.2-alpine\`'s, the patch the framework that wrote this file was
35
+ # The digest is \`oven/bun:1.4.4-alpine\`'s, the patch the framework that wrote this file was
36
36
  # tested on — the same patch as \`engines.bun\` in package.json.
37
- FROM oven/bun:1.4-alpine@sha256:d888c0ae6c86d7866ff10c5aafdd9077b36aee6455b33dd270fb93c0dd5cef6f AS manifests
37
+ FROM oven/bun:1.4-alpine@sha256:4cf82f5686b196504fe119db3fcd9185b191f82ece82c40680e597228ec6070a AS manifests
38
38
  WORKDIR /app
39
39
  COPY . .
40
40
  RUN find . ! -type d ! -name package.json ! -name bunfig.toml ! -name '*.tgz' -delete \\
41
41
  && find . -type d -empty -delete
42
42
 
43
43
  # ---------- deps: runtime dependencies only, cached on the lockfile and the manifests ----------
44
- FROM oven/bun:1.4-alpine@sha256:d888c0ae6c86d7866ff10c5aafdd9077b36aee6455b33dd270fb93c0dd5cef6f AS deps
44
+ FROM oven/bun:1.4-alpine@sha256:4cf82f5686b196504fe119db3fcd9185b191f82ece82c40680e597228ec6070a AS deps
45
45
  WORKDIR /app
46
46
  COPY --from=manifests /app ./
47
47
  COPY bun.lock ./
@@ -45,11 +45,11 @@ const TYPESCRIPT_VERSION = '^7.0.2';
45
45
 
46
46
  /**
47
47
  * The Solid runtime every island and `@ultimat3/ui` component renders with, exact for the reason
48
- * `BIOME_VERSION` is: the CLI compiles islands with `babel-preset-solid ^1.9.15`, and a scaffold
48
+ * `BIOME_VERSION` is: the CLI compiles islands with `babel-preset-solid ^1.9.16`, and a scaffold
49
49
  * that installed an older runtime than the compiler targets drifted as a buried literal (1.9.14
50
50
  * beside the repository's 1.9.15). `scaffold-repo.test.ts` holds it equal to the root manifest's.
51
51
  */
52
- const SOLID_VERSION = '1.9.15';
52
+ const SOLID_VERSION = '1.9.17';
53
53
 
54
54
  // `engines.bun` is `REQUIRED_BUN`, the floor the SHIPPED `x` enforces
55
55
  // (`packages/cli/src/app-root.ts`), and not a second literal. It was one: `>=1.3.0`, a whole minor