@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.
- 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/dev-route-table.ts +6 -0
- package/src/document-options.ts +10 -0
- package/src/error-codes.ts +6 -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/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
|
package/src/workspace-graph.ts
CHANGED
|
@@ -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
|
|
4
|
-
// for `tsc`
|
|
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 {
|
|
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
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
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
|
}
|