@ultimat3/cli 28.0.0 → 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.
@@ -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.3-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:629e17411f1f129dbec3af78d5af9c9f2a937435c80349437206c6b0b7422373 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:629e17411f1f129dbec3af78d5af9c9f2a937435c80349437206c6b0b7422373 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
@@ -1,13 +1,16 @@
1
1
  // The workspace dependency graph, in the two forms that must agree: the edges every manifest
2
2
  // DECLARES, the edges every shipped source file actually IMPORTS, and the gate rule that the
3
- // second is a subset of the first. An edge that lives only in a tsconfig `paths` entry resolves
4
- // for `tsc` and is invisible to bun, to `--filter` ordering and to any change-detection tool.
3
+ // second is covered by the first, with no unused framework sibling declarations. A tsconfig-only
4
+ // edge resolves for `tsc` but is invisible to bun, `--filter` ordering and change-detection tools.
5
5
 
6
6
  import { join } from 'node:path';
7
7
  import { ERROR_DOCS_URL, maskLiterals } from '@ultimat3/core';
8
+ import { CATALOG_PACKAGES } from './error-catalog';
8
9
  import { scanAllImports } from './import-scan';
9
10
  import type { Finding } from './output';
10
- import { eachSourceFile, isGenerated, isTest, isVendored } from './source-files';
11
+ import { FIXTURE_SUFFIX } from './publish-closure';
12
+ import { quoteArg } from './shell-quote';
13
+ import { eachSourceFile, isGenerated, isTest, isVendored, SOURCE_GLOBS } from './source-files';
11
14
 
12
15
  export interface WorkspaceNode {
13
16
  /** package.json "name". */
@@ -176,7 +179,7 @@ export const undeclaredWorkspaceDepFinding = (
176
179
  cause: `${at} imports ${to.name}, which ${from.dir}/package.json does not declare`,
177
180
  // The exact line to paste, at the version the target really carries: a `workspace:*` range would
178
181
  // resolve and then fail `checkLockstep`, which compares a sibling pin against the version.
179
- fix: `add "${to.name}": "${to.version ?? '0.0.0'}" to "dependencies" in ${from.dir}/package.json`,
182
+ fix: `add "${to.name}": "${to.version ?? '0.0.0'}" to "${at.endsWith(FIXTURE_SUFFIX) ? 'devDependencies' : 'dependencies'}" in ${from.dir}/package.json`,
180
183
  docs: ERROR_DOCS_URL,
181
184
  at: `${from.dir}/package.json`,
182
185
  });
@@ -186,10 +189,9 @@ export const undeclaredWorkspaceDepFinding = (
186
189
  * declared none of it — the imports resolved through the root tsconfig's `paths`, so the edges
187
190
  * existed only inside `tsc` and every tool that asks what a change affects answered short.
188
191
  *
189
- * Shipped source only. A test file is excluded from the tarball by every package here
190
- * (`TEST_EXCLUSION`) and resolves its imports through the ROOT manifest's hoisted devDependencies,
191
- * which is why no `packages/*` in this repo carries a `devDependencies` block of its own —
192
- * judging test imports would demand 29 of them to describe a resolution that already works.
192
+ * Tests resolve through the root's hoisted devDependencies and are excluded here. Fixtures still
193
+ * need forward declarations, but their imports justify only development dependencies: they are
194
+ * excluded from tarballs and must not keep unused shipped dependency fields alive.
193
195
  *
194
196
  * One finding per (workspace, dependency) pair, never one per import site: the fix is one line in
195
197
  * one manifest, and repeating it per file is noise an author has to deduplicate by hand.
@@ -202,6 +204,8 @@ export async function checkWorkspaceDependencies(root: string): Promise<readonly
202
204
  // Deepest directory first: a nested workspace's files are its own, never its parent's.
203
205
  const owners = [...graph].sort((left, right) => right.dir.length - left.dir.length);
204
206
  const reported = new Set<string>();
207
+ const used = new Set<string>();
208
+ const fixtureUsed = new Set<string>();
205
209
  for await (const path of eachSourceFile(root)) {
206
210
  if (isTest(path) || isGenerated(path)) continue;
207
211
  const owner = owners.find((node) => path.startsWith(`${node.dir}/`));
@@ -209,12 +213,55 @@ export async function checkWorkspaceDependencies(root: string): Promise<readonly
209
213
  for (const name of importedPackages(await Bun.file(join(root, path)).text(), path)) {
210
214
  const target = byName.get(name);
211
215
  if (target === undefined || target.name === owner.name) continue;
212
- if (owner.dependencies.includes(target.name)) continue;
213
216
  const pair = `${owner.dir} ${target.name}`;
217
+ // Fixtures still owe forward declarations, but never justify a shipped dependency.
218
+ (path.endsWith(FIXTURE_SUFFIX) ? fixtureUsed : used).add(pair);
219
+ if (owner.dependencies.includes(target.name)) continue;
214
220
  if (reported.has(pair)) continue;
215
221
  reported.add(pair);
216
222
  findings.push(undeclaredWorkspaceDepFinding(owner, target, path));
217
223
  }
218
224
  }
225
+ for (const owner of graph) {
226
+ // Only infer absence where the source contract scans. Match directory prefixes rather than
227
+ // observed files so a covered workspace with no source still owes removal of unused siblings.
228
+ const covered = SOURCE_GLOBS.some((pattern) => {
229
+ const directories = pattern.split('/').slice(0, -1);
230
+ return directories.some((_, index) =>
231
+ new Bun.Glob(directories.slice(0, index + 1).join('/')).match(owner.dir),
232
+ );
233
+ });
234
+ if (!covered || isVendored(owner.dir)) continue;
235
+ const unused = owner.dependencies.filter(
236
+ (name) =>
237
+ name.startsWith('@ultimat3/') &&
238
+ !used.has(`${owner.dir} ${name}`) &&
239
+ !(owner.name === '@ultimat3/cli' && CATALOG_PACKAGES.some((root) => root === name)),
240
+ );
241
+ if (unused.length === 0) continue;
242
+ const path = `${owner.dir}/package.json`;
243
+ const manifest = await readManifest(join(root, path));
244
+ for (const name of unused) {
245
+ const fields = DEPENDENCY_FIELDS.filter(
246
+ (field) =>
247
+ Object.hasOwn(asRecord(manifest?.[field]) ?? {}, name) &&
248
+ !(field === 'devDependencies' && fixtureUsed.has(`${owner.dir} ${name}`)),
249
+ );
250
+ if (fields.length === 0) continue;
251
+ // A package-wide remove would also delete a valid fixture-only development declaration.
252
+ const script =
253
+ 'const [path, name, fields] = Bun.argv.slice(-3); ' +
254
+ 'const manifest = await Bun.file(path).json(); ' +
255
+ 'for (const field of JSON.parse(fields)) delete manifest[field]?.[name]; ' +
256
+ 'await Bun.write(path, JSON.stringify(manifest, null, 2) + "\\n");';
257
+ findings.push({
258
+ code: 'X_PACKAGE_SHAPE',
259
+ cause: `${path} declares ${name} in ${fields.join(', ')}, but no eligible source in ${owner.dir} imports it`,
260
+ fix: `bun -e ${quoteArg(script)} -- ${quoteArg(path)} ${quoteArg(name)} ${quoteArg(JSON.stringify(fields))}`,
261
+ docs: ERROR_DOCS_URL,
262
+ at: path,
263
+ });
264
+ }
265
+ }
219
266
  return findings;
220
267
  }