@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.
- package/CLAUDE.md +1 -1
- package/package.json +31 -31
- package/src/app-root.ts +1 -1
- package/src/cmd-deploy-compose.ts +10 -1
- package/src/cmd-deploy-start-first.ts +63 -19
- package/src/cmd-deploy.ts +10 -1
- package/src/cmd-i18n.ts +28 -6
- package/src/compile-executable.ts +44 -0
- package/src/compile-externals.ts +4 -3
- package/src/dev-route-table.ts +6 -0
- package/src/document-options.ts +10 -0
- package/src/error-codes.ts +6 -0
- package/src/errors.ts +14 -0
- package/src/i18n-locale-parity.ts +134 -0
- package/src/i18n-registration.ts +6 -0
- package/src/mcp-errors.ts +6 -0
- package/src/messages.ts +4 -0
- package/src/prerender.ts +5 -0
- package/src/runtime-jobs.ts +83 -7
- package/src/runtime-render.ts +57 -55
- package/src/serve-web.ts +5 -0
- package/src/site-config.ts +14 -4
- package/src/templates/scaffold-container.ts +3 -3
- package/src/templates/scaffold-repo.ts +2 -2
- package/src/workspace-graph.ts +56 -9
package/src/i18n-registration.ts
CHANGED
|
@@ -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,
|
package/src/runtime-jobs.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
// The queues a `worker` serves and the slots each gets: `jobs.queues`, `jobs.concurrency
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
105
|
-
* the worker's own default applies
|
|
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 }),
|
package/src/runtime-render.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
90
|
-
*
|
|
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
|
|
99
|
-
*
|
|
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:
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
158
|
-
*
|
|
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
|
|
163
|
-
*
|
|
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,
|
|
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
|
|
208
|
-
*
|
|
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:
|
|
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
|
|
221
|
-
*
|
|
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:
|
|
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
|
|
237
|
-
*
|
|
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
|
|
261
|
-
*
|
|
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:
|
|
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
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
//
|
|
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 —
|
|
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.
|
|
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
|
|
359
|
-
*
|
|
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.
|
|
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
|
|
401
|
-
*
|
|
402
|
-
*
|
|
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 }),
|
package/src/site-config.ts
CHANGED
|
@@ -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.
|
|
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:
|
|
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:
|
|
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.
|
|
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.
|
|
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
|