@ultimat3/cli 23.0.0 → 24.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 +15 -7
- package/README.md +3 -3
- package/package.json +28 -28
- package/src/affected.ts +27 -35
- package/src/api-registration.ts +89 -0
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +5 -11
- package/src/app-manifest.ts +2 -8
- package/src/app-mcp.ts +10 -23
- package/src/app-permissions-borrowed.ts +129 -0
- package/src/app-permissions-site.ts +24 -0
- package/src/app-permissions.ts +7 -17
- package/src/budgets.ts +4 -35
- package/src/cdp-shot-driver.ts +3 -1
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build.ts +64 -15
- package/src/cmd-db-backfill.ts +25 -12
- package/src/cmd-db.ts +6 -5
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy.ts +4 -4
- package/src/cmd-dev.ts +9 -8
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +41 -14
- package/src/cmd-env.ts +26 -2
- package/src/cmd-generate.ts +79 -32
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +10 -9
- package/src/cmd-jobs.ts +18 -6
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +46 -15
- package/src/cmd-planned.ts +15 -8
- package/src/cmd-routes.ts +12 -2
- package/src/cmd-secrets.ts +47 -6
- package/src/cmd-shot-matrix.ts +1 -3
- package/src/cmd-shot.ts +12 -3
- package/src/cmd-tasks.ts +2 -2
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +13 -29
- package/src/cmd-verify.ts +4 -4
- package/src/db-subscribes.ts +12 -4
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +51 -29
- package/src/dev-lock.ts +4 -7
- package/src/dispatch.ts +3 -6
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +23 -3
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +13 -2
- package/src/generate-files.ts +4 -3
- package/src/generate-kinds.ts +33 -1
- package/src/generate-shadow.ts +46 -0
- package/src/generate-write.ts +47 -13
- package/src/gh-target.ts +12 -7
- package/src/i18n-audit.ts +4 -1
- package/src/index.ts +1 -3
- package/src/island-bundle.ts +3 -15
- package/src/jobs-drain.ts +82 -15
- package/src/jobs-report.ts +4 -4
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +12 -3
- package/src/mcp-host.ts +16 -9
- package/src/mcp-test-run.ts +38 -0
- package/src/messages.ts +2 -0
- package/src/metrics-endpoint.ts +25 -1
- package/src/output.ts +6 -0
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +5 -20
- package/src/prerender-out.ts +34 -7
- package/src/prerender.ts +8 -5
- package/src/pwa-artifacts.ts +26 -94
- package/src/registry.ts +2 -0
- package/src/revalidated-response.ts +4 -3
- package/src/role-replicator.ts +16 -0
- package/src/role-start.ts +9 -4
- package/src/role-sync.ts +55 -20
- package/src/runtime-assets.ts +11 -7
- package/src/runtime-bindings.ts +4 -4
- package/src/runtime-cache.ts +38 -47
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +8 -33
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +1 -1
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +31 -10
- package/src/runtime-realtime.ts +9 -45
- package/src/runtime-render.ts +62 -35
- package/src/runtime-services.ts +20 -17
- package/src/runtime-storage.ts +10 -5
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +34 -6
- package/src/serve-drain.ts +9 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-web.ts +12 -7
- package/src/shot-locale.ts +5 -23
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +1 -1
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-files.ts +6 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/sync-url.ts +2 -2
- package/src/tasks-facts.ts +3 -29
- package/src/templates/action.ts +3 -0
- package/src/templates/naming.ts +28 -0
- package/src/templates/route.ts +3 -6
- package/src/templates/scaffold-app.ts +6 -2
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +4 -2
- package/src/templates/scaffold-db-client.ts +4 -2
- package/src/templates/scaffold-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +32 -7
- package/src/templates/scaffold-helm.ts +31 -1
- package/src/templates/scaffold-repo.ts +9 -1
- package/src/templates/scaffold-shell.ts +39 -36
- package/src/test-counts.ts +2 -6
- package/src/test-ignores.ts +25 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +5 -14
- package/src/test-shards.ts +41 -93
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +6 -31
- package/src/trusted-hops-error.ts +17 -0
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +17 -46
- package/src/verify-deadline.ts +130 -33
- package/src/verify-merge.ts +60 -27
- package/src/verify-part-step.ts +90 -0
- package/src/verify-run.ts +29 -8
- package/src/verify-shard.ts +13 -2
- package/src/verify-stalled.ts +152 -0
- package/src/verify-tests.ts +5 -2
- package/src/build-errors.ts +0 -54
- package/src/verify-errors.ts +0 -58
package/src/prerender.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { appManifest } from './app-manifest';
|
|
|
21
21
|
import type { RouteStats } from './budgets';
|
|
22
22
|
import { measureDocumentJs, writeBuildStats } from './budgets';
|
|
23
23
|
import { errorPageDocument, STATIC_ERROR_PAGE } from './error-pages';
|
|
24
|
+
import { PrerenderLoadFailedError } from './errors';
|
|
24
25
|
import { FAVICON_PATH, faviconBytes } from './favicon';
|
|
25
26
|
import type { IslandBundle } from './island-bundle';
|
|
26
27
|
import { buildIslands, writeIslands } from './island-bundle';
|
|
@@ -168,15 +169,17 @@ const NOT_FOUND_STATUS = 404;
|
|
|
168
169
|
*/
|
|
169
170
|
const declaresBudget = (entry: RouteEntry): boolean => {
|
|
170
171
|
const budget = entry.config.budget;
|
|
171
|
-
return budget
|
|
172
|
+
return budget?.js !== undefined;
|
|
172
173
|
};
|
|
173
174
|
|
|
174
175
|
export async function prerenderSite(options: PrerenderOptions): Promise<PrerenderReport> {
|
|
175
|
-
// The
|
|
176
|
-
//
|
|
177
|
-
//
|
|
176
|
+
// The load `x dev` and `x manifest` perform: importing the app's modules fills the route registry.
|
|
177
|
+
// A module that would not import registered nothing, so its page would vanish from the export
|
|
178
|
+
// under a green build — refused BEFORE the export is emptied, so the last good artifact survives.
|
|
179
|
+
const loaded = await loadApp(options.root);
|
|
180
|
+
if (loaded.findings.length > 0) throw new PrerenderLoadFailedError(loaded.findings);
|
|
181
|
+
// Emptied before anything is written: a deleted route's HTML kept shipping when it only grew.
|
|
178
182
|
await clearPrerenderOut(options.out, options.root);
|
|
179
|
-
await loadApp(options.root);
|
|
180
183
|
const buildId = (await appManifest(options.root)).manifest.buildId;
|
|
181
184
|
const declaredOrigin =
|
|
182
185
|
options.origin ?? publicOrigin(process.env, await loadSiteSettings(options.root));
|
package/src/pwa-artifacts.ts
CHANGED
|
@@ -15,15 +15,10 @@
|
|
|
15
15
|
// build behind them — `wiki/PWA-And-Offline.md` says so — and a bad `sw.js` is sticky in a way a
|
|
16
16
|
// manifest is not, so the worker lands behind a real browser check rather than beside this.
|
|
17
17
|
|
|
18
|
-
// why: Bun exposes no
|
|
19
|
-
// `app-auth.ts` and `runtime-cache.ts` each make before importing an app's config — a root with no
|
|
20
|
-
// `app.config.ts` is an ordinary answer here (a scratch root, `x build` outside an app).
|
|
21
|
-
import { existsSync } from 'node:fs';
|
|
22
|
-
// why: Bun exposes no path-join primitive, and `APP_CONFIG_FILE` is app-root-relative — the same
|
|
23
|
-
// necessity `favicon.ts` and `runtime-assets.ts` each record for their own root-relative constant.
|
|
18
|
+
// why: Bun exposes no path-join primitive, and the export writes each icon under an out directory.
|
|
24
19
|
import { join } from 'node:path';
|
|
25
20
|
import type { PwaColors, PwaOfflineConfig } from '@ultimat3/core';
|
|
26
|
-
import { localeSegment } from '@ultimat3/core';
|
|
21
|
+
import { escapeHtml, localeSegment } from '@ultimat3/core';
|
|
27
22
|
import type { CacheHint, RequestContext, Route, UltimateRequest } from '@ultimat3/http';
|
|
28
23
|
import { applyCacheHeaders } from '@ultimat3/http';
|
|
29
24
|
import {
|
|
@@ -34,9 +29,7 @@ import {
|
|
|
34
29
|
} from '@ultimat3/pwa';
|
|
35
30
|
import type { AssetPath } from '@ultimat3/render';
|
|
36
31
|
import { assetPathProblem } from '@ultimat3/render';
|
|
37
|
-
import {
|
|
38
|
-
import { APP_CONFIG_EXPORT } from './app-auth';
|
|
39
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
32
|
+
import { loadAppConfig } from './app-config-load';
|
|
40
33
|
import { hasSourceIcon, iconPlan, iconRenderer } from './icon-assets';
|
|
41
34
|
import type { AppLocales } from './pwa-manifest-locales';
|
|
42
35
|
import { appLocales, localeMembers, spellIn } from './pwa-manifest-locales';
|
|
@@ -80,47 +73,14 @@ export interface PwaArtifacts {
|
|
|
80
73
|
/** The `<head>` for a document in `locale` — it links that locale's manifest. */
|
|
81
74
|
headFor(locale: string): string;
|
|
82
75
|
/**
|
|
83
|
-
* The three `pwa` keys the SERVICE WORKER needs, carried here
|
|
84
|
-
*
|
|
85
|
-
* bundle as well, and a second `await import` of `app.config.ts` would be a second answer to
|
|
86
|
-
* "what did this app declare".
|
|
76
|
+
* The three `pwa` keys the SERVICE WORKER needs, carried here so `sw-artifacts.ts` — which needs
|
|
77
|
+
* the route table and the island bundle as well — reads them off the same resolved block.
|
|
87
78
|
*/
|
|
88
79
|
readonly offline: PwaOfflineConfig;
|
|
89
80
|
readonly backgroundSync: boolean;
|
|
90
81
|
readonly push: boolean;
|
|
91
82
|
}
|
|
92
83
|
|
|
93
|
-
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
94
|
-
typeof value === 'object' && value !== null;
|
|
95
|
-
|
|
96
|
-
const text = (value: unknown): string | undefined =>
|
|
97
|
-
typeof value === 'string' && value.trim() !== '' ? value : undefined;
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* `colors` read structurally, for `loadSignInPath`'s reason: `defineConfig` returns a plain object
|
|
101
|
-
* and a config resolved through an older core simply has no such key. `validate()` already refused
|
|
102
|
-
* a blank one an `await import` above this line, so anything this rejects is a hand-written config
|
|
103
|
-
* object — and the honest answer for one is no manifest at all, never a colour we invented.
|
|
104
|
-
*/
|
|
105
|
-
function colorsOf(value: unknown): PwaColors | undefined {
|
|
106
|
-
if (!isRecord(value)) return undefined;
|
|
107
|
-
const light = isRecord(value['light']) ? value['light'] : undefined;
|
|
108
|
-
const dark = isRecord(value['dark']) ? value['dark'] : undefined;
|
|
109
|
-
if (light === undefined || dark === undefined) return undefined;
|
|
110
|
-
const [lt, lb, dt, db] = [
|
|
111
|
-
text(light['themeColor']),
|
|
112
|
-
text(light['backgroundColor']),
|
|
113
|
-
text(dark['themeColor']),
|
|
114
|
-
text(dark['backgroundColor']),
|
|
115
|
-
];
|
|
116
|
-
if (lt === undefined || lb === undefined || dt === undefined || db === undefined)
|
|
117
|
-
return undefined;
|
|
118
|
-
return {
|
|
119
|
-
light: { themeColor: lt, backgroundColor: lb },
|
|
120
|
-
dark: { themeColor: dt, backgroundColor: db },
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
84
|
/** The `pwa` block, as much of it as this file needs, or `undefined` when the app declares none. */
|
|
125
85
|
interface InstallableApp {
|
|
126
86
|
readonly name: string;
|
|
@@ -133,60 +93,30 @@ interface InstallableApp {
|
|
|
133
93
|
readonly push: boolean;
|
|
134
94
|
}
|
|
135
95
|
|
|
136
|
-
async function loadInstallable(root: string): Promise<InstallableApp | undefined> {
|
|
137
|
-
const configPath = join(root, APP_CONFIG_FILE);
|
|
138
|
-
if (!existsSync(configPath)) return undefined;
|
|
139
|
-
const module = (await import(configPath)) as Record<string, unknown>;
|
|
140
|
-
const config = module[APP_CONFIG_EXPORT];
|
|
141
|
-
if (!isRecord(config)) return undefined;
|
|
142
|
-
const pwa = config['pwa'];
|
|
143
|
-
// `pwa.enabled`, read. Not a truthiness test: `enabled` is the key this whole file exists to
|
|
144
|
-
// give a reader, and `=== true` is what makes a hand-written `enabled: 'yes'` produce no
|
|
145
|
-
// manifest rather than one nobody asked for.
|
|
146
|
-
if (!isRecord(pwa) || pwa['enabled'] !== true) return undefined;
|
|
147
|
-
const name = text(pwa['name']);
|
|
148
|
-
const colors = colorsOf(pwa['colors']);
|
|
149
|
-
if (name === undefined || colors === undefined) return undefined;
|
|
150
|
-
return {
|
|
151
|
-
name,
|
|
152
|
-
colors,
|
|
153
|
-
block: pwa,
|
|
154
|
-
locales: appLocales(config),
|
|
155
|
-
offline: offlineOf(pwa['offline']),
|
|
156
|
-
...flags(pwa),
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
|
|
160
96
|
/**
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* then emits no worker at all — never a path the framework invented, which offline would be a
|
|
165
|
-
* cached 404 answering every navigation.
|
|
97
|
+
* `pwa.enabled`, read, off the one loader (`app-config-load.ts`). Core's validator refuses an
|
|
98
|
+
* enabled block without a name, both colour schemes or an absolute `offline.fallback`, so nothing
|
|
99
|
+
* here invents a colour or a fallback route; `colors` is optional only in the type.
|
|
166
100
|
*/
|
|
167
|
-
function
|
|
168
|
-
const
|
|
169
|
-
const
|
|
170
|
-
|
|
101
|
+
async function loadInstallable(root: string): Promise<InstallableApp | undefined> {
|
|
102
|
+
const config = await loadAppConfig(root);
|
|
103
|
+
const pwa = config?.pwa;
|
|
104
|
+
if (config === undefined || pwa === undefined || !pwa.enabled || pwa.colors === undefined) {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
171
107
|
return {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
108
|
+
name: pwa.name,
|
|
109
|
+
colors: pwa.colors,
|
|
110
|
+
block: { ...pwa },
|
|
111
|
+
locales: appLocales({ locales: config.locales, defaultLocale: config.defaultLocale }),
|
|
112
|
+
offline: pwa.offline,
|
|
113
|
+
backgroundSync: pwa.backgroundSync,
|
|
114
|
+
push: pwa.push,
|
|
179
115
|
};
|
|
180
116
|
}
|
|
181
117
|
|
|
182
|
-
/** `=== true` for `enabled`'s reason: a hand-written `backgroundSync: 'yes'` wires nothing. */
|
|
183
|
-
const flags = (pwa: Record<string, unknown>): { backgroundSync: boolean; push: boolean } => ({
|
|
184
|
-
backgroundSync: pwa['backgroundSync'] === true,
|
|
185
|
-
push: pwa['push'] === true,
|
|
186
|
-
});
|
|
187
|
-
|
|
188
118
|
/**
|
|
189
|
-
* Resolved ONCE at boot, like `loadSignInPath` and
|
|
119
|
+
* Resolved ONCE at boot, like `loadSignInPath` and unlike `faviconBytes`: the loader's
|
|
190
120
|
* `await import` caches the module, so re-reading per request would answer the same object at a
|
|
191
121
|
* per-request cost, and the head string has to be available synchronously while a document renders.
|
|
192
122
|
*/
|
|
@@ -219,7 +149,7 @@ export async function loadPwaArtifacts(root: string): Promise<PwaArtifacts | und
|
|
|
219
149
|
path,
|
|
220
150
|
body: serializeWebManifest(result.manifest),
|
|
221
151
|
head:
|
|
222
|
-
`<link rel="manifest" href="${
|
|
152
|
+
`<link rel="manifest" href="${escapeHtml(path)}">` +
|
|
223
153
|
renderThemeColorMeta(result.themeColorMeta) +
|
|
224
154
|
(icons === undefined ? '' : appleTouchLinks(icons)),
|
|
225
155
|
};
|
|
@@ -244,7 +174,9 @@ export async function loadPwaArtifacts(root: string): Promise<PwaArtifacts | und
|
|
|
244
174
|
* `asset()` does in a page. The site serves an asset ONLY at its hashed URL, and the manifest took
|
|
245
175
|
* `src` verbatim, so an app had to hash its own screenshots to avoid shipping a 404 in the install
|
|
246
176
|
* sheet (notificado.co's `pwa-install.ts`). A missing file is `X_ASSET_MISSING` at boot, never a
|
|
247
|
-
* broken image in an install prompt; any other `src`
|
|
177
|
+
* broken image in an install prompt; any other `src` passes untouched — an absolute path outside
|
|
178
|
+
* `/assets/`, an already hashed one, or a shortcut icon's off-site URL (core screens screenshots and
|
|
179
|
+
* shortcut urls to absolute paths, not icon sources).
|
|
248
180
|
*/
|
|
249
181
|
function withHashedAssets<M extends ReturnType<typeof localeMembers>>(members: M, root: string): M {
|
|
250
182
|
const resolve = (src: string): string => {
|
package/src/registry.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// dev server it never started (plan 101 slice 12 d).
|
|
8
8
|
|
|
9
9
|
import { affectedSpec } from './cmd-affected-spec';
|
|
10
|
+
import { authSpec } from './cmd-auth-spec';
|
|
10
11
|
import { buildSpec } from './cmd-build-spec';
|
|
11
12
|
import { ciSpec } from './cmd-ci-spec';
|
|
12
13
|
import { dbSpec } from './cmd-db-spec';
|
|
@@ -85,6 +86,7 @@ export const LAZY_COMMANDS: readonly LazyCommand[] = [
|
|
|
85
86
|
lazy(deploySpec, async () => (await import('./cmd-deploy')).deployCommand),
|
|
86
87
|
lazy(envSpec, async () => (await import('./cmd-env')).envCommand),
|
|
87
88
|
lazy(secretsSpec, async () => (await import('./cmd-secrets')).secretsCommand),
|
|
89
|
+
lazy(authSpec, async () => (await import('./cmd-auth')).authCommand),
|
|
88
90
|
lazy(manifestSpec, async () => (await import('./cmd-manifest')).manifestCommand),
|
|
89
91
|
lazy(routesSpec, async () => (await import('./cmd-routes')).routesCommand),
|
|
90
92
|
lazy(actionsSpec, async () => (await import('./cmd-registries')).actionsCommand),
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// name that outlives its bytes is a file no client refreshes for a year.
|
|
5
5
|
|
|
6
6
|
import type { UltimateRequest } from '@ultimat3/http';
|
|
7
|
+
import { contentHash } from '@ultimat3/render/server';
|
|
7
8
|
import { etagMatches } from './runtime-storage';
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -14,8 +15,8 @@ import { etagMatches } from './runtime-storage';
|
|
|
14
15
|
export const REVALIDATE_CACHE_CONTROL = 'public, max-age=0, must-revalidate';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
|
-
* A strong validator from the bytes —
|
|
18
|
-
* taken here over bytes because an icon is not a string. Revalidating costs a request and no body.
|
|
18
|
+
* A strong validator from the bytes — `@ultimat3/render`'s `contentHash`, the function a document's
|
|
19
|
+
* ETag is, taken here over bytes because an icon is not a string. Revalidating costs a request and no body.
|
|
19
20
|
*/
|
|
20
21
|
export function revalidatedResponse(
|
|
21
22
|
request: UltimateRequest,
|
|
@@ -25,7 +26,7 @@ export function revalidatedResponse(
|
|
|
25
26
|
// Copied, not passed through: a `Uint8Array<ArrayBufferLike>` may be backed by a
|
|
26
27
|
// `SharedArrayBuffer`, which `Response` does not accept — `runtime-assets.ts`'s rule, verbatim.
|
|
27
28
|
const bytes = typeof body === 'string' ? new TextEncoder().encode(body) : new Uint8Array(body);
|
|
28
|
-
const etag = `"${
|
|
29
|
+
const etag = `"${contentHash(bytes)}"`;
|
|
29
30
|
const headers = {
|
|
30
31
|
'content-type': contentType,
|
|
31
32
|
'cache-control': REVALIDATE_CACHE_CONTROL,
|
package/src/role-replicator.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// a `ROLE=replicator` container starts; the only difference is that this process also runs the
|
|
4
4
|
// roles reading the other end of that transport.
|
|
5
5
|
|
|
6
|
+
import { registerReadinessCheck } from '@ultimat3/core';
|
|
6
7
|
import { describeEntities } from '@ultimat3/entity';
|
|
7
8
|
import { ReplicatorSlotHeldError } from '@ultimat3/realtime';
|
|
8
9
|
import type { Replicator, Transport } from '@ultimat3/realtime/server';
|
|
@@ -72,6 +73,18 @@ const noEntitiesRefusal = (): BadFlagError =>
|
|
|
72
73
|
fix: 'x g entity Post title:text — then x dev --role replicator',
|
|
73
74
|
});
|
|
74
75
|
|
|
76
|
+
/** The name `/readyz` reports the replicator under. */
|
|
77
|
+
export const REPLICATOR_READINESS_CHECK = 'replicator';
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Ready means REPLICATING. A stream that ends is restarted by the replicator itself, and until it
|
|
81
|
+
* is back `running` is `false`: this process holds no slot and publishes nothing, so it must not be
|
|
82
|
+
* counted as the database's replicator. Returns the unregister.
|
|
83
|
+
*/
|
|
84
|
+
export function watchReplicatorReadiness(replicator: Pick<Replicator, 'running'>): () => void {
|
|
85
|
+
return registerReadinessCheck(REPLICATOR_READINESS_CHECK, () => replicator.running);
|
|
86
|
+
}
|
|
87
|
+
|
|
75
88
|
/**
|
|
76
89
|
* Lock first, feed second. A replicator that opened its slot before losing the lock race would
|
|
77
90
|
* have already consumed WAL the holder is responsible for, and `pg_try_advisory_lock` is the only
|
|
@@ -95,11 +108,14 @@ export async function startReplicator(options: StartReplicatorOptions): Promise<
|
|
|
95
108
|
if (!(await replicator.start())) {
|
|
96
109
|
throw new ReplicatorSlotHeldError({ key: replicatorLockKey(slot) });
|
|
97
110
|
}
|
|
111
|
+
const unregister = watchReplicatorReadiness(replicator);
|
|
98
112
|
return {
|
|
99
113
|
replicator,
|
|
100
114
|
slot,
|
|
101
115
|
detail: selection.detail,
|
|
102
116
|
async stop() {
|
|
117
|
+
// First: a replicator being stopped on purpose is not a failing one.
|
|
118
|
+
unregister();
|
|
103
119
|
await replicator.stop();
|
|
104
120
|
},
|
|
105
121
|
};
|
package/src/role-start.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// something every `x dev` in a team should do to the same server by simply starting.
|
|
8
8
|
|
|
9
9
|
import type { Role } from '@ultimat3/core';
|
|
10
|
-
import { createContext, isRole, logger, ROLES } from '@ultimat3/core';
|
|
10
|
+
import { createContext, isRole, logger, markReady, ROLES } from '@ultimat3/core';
|
|
11
11
|
import type { RateLimitStore, Route, ServerHandle, WebSocketMount } from '@ultimat3/http';
|
|
12
12
|
import {
|
|
13
13
|
configuredAuthenticator,
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
} from '@ultimat3/jobs';
|
|
29
29
|
import type { SyncWs } from '@ultimat3/realtime/server';
|
|
30
30
|
import { errorPageHook } from './error-pages';
|
|
31
|
-
import { BadFlagError,
|
|
31
|
+
import { BadFlagError, RuntimeDriverSplitError } from './errors';
|
|
32
32
|
import {
|
|
33
33
|
DEFAULT_METRICS_PORT,
|
|
34
34
|
startMetricsEndpoint,
|
|
@@ -49,6 +49,7 @@ import type { RunningServices } from './runtime-services';
|
|
|
49
49
|
import { inlineScriptSources } from './script-csp';
|
|
50
50
|
import { inlineStyleSources } from './style-csp';
|
|
51
51
|
import { syncConnectSources } from './sync-url';
|
|
52
|
+
import { TrustedProxyHopsInvalidError } from './trusted-hops-error';
|
|
52
53
|
import { DEV_BINDING } from './web-binding';
|
|
53
54
|
|
|
54
55
|
// Declared beside the option types so `x dev`'s spec reads it without loading this module.
|
|
@@ -160,8 +161,8 @@ export function trustedHopsFromEnv(env: Env): number | null {
|
|
|
160
161
|
const hops = Number(raw);
|
|
161
162
|
// A malformed count is refused rather than defaulted: reading the header at the wrong index is
|
|
162
163
|
// trusting a value the client typed, which is the failure trusting a proxy exists to avoid.
|
|
163
|
-
if (!Number.isInteger(hops) || hops < 1 || hops > MAX_PROXY_HOPS) {
|
|
164
|
-
throw new
|
|
164
|
+
if (!/^\d+$/.test(raw) || !Number.isInteger(hops) || hops < 1 || hops > MAX_PROXY_HOPS) {
|
|
165
|
+
throw new TrustedProxyHopsInvalidError({ value: raw });
|
|
165
166
|
}
|
|
166
167
|
return hops;
|
|
167
168
|
}
|
|
@@ -406,6 +407,10 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
406
407
|
replicatorHere: replicator !== null,
|
|
407
408
|
});
|
|
408
409
|
started.push(async () => live.stop());
|
|
410
|
+
// The web server and the sync node mark ready when they bind; a process with neither — a
|
|
411
|
+
// worker, scheduler or replicator pod — is ready once its roles started, which is what the
|
|
412
|
+
// scrape port's `/readyz` answers (`metrics-endpoint.ts`). The checks still decide after this.
|
|
413
|
+
if (server === null && sync === null) markReady();
|
|
409
414
|
|
|
410
415
|
return {
|
|
411
416
|
roles: selected,
|
package/src/role-sync.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// and a listener of its own — and because that file is the boot's index, not its detail.
|
|
4
4
|
|
|
5
5
|
import { createContext, logger, UltimateError } from '@ultimat3/core';
|
|
6
|
-
import type
|
|
6
|
+
import { configuredHttp, type WebSocketMount } from '@ultimat3/http';
|
|
7
7
|
import { listQueries } from '@ultimat3/query';
|
|
8
8
|
import type { SyncNode, SyncWs } from '@ultimat3/realtime/server';
|
|
9
9
|
import {
|
|
@@ -120,7 +120,8 @@ export interface PreparedSync {
|
|
|
120
120
|
readonly mount: WebSocketMount<SyncWs>;
|
|
121
121
|
/**
|
|
122
122
|
* Bind `PORT + 1`, and answer with the same object `startSync` always did. `appUrl` is the web
|
|
123
|
-
* role's own origin when one runs here
|
|
123
|
+
* role's own origin when one runs here: named in the boot line beside the node's, and — under
|
|
124
|
+
* `x dev` with an `APP_URL` declared — admitted as a page origin (`devOrigins`).
|
|
124
125
|
*/
|
|
125
126
|
listen(appUrl: string | null): Promise<RunningSync>;
|
|
126
127
|
/** Release the node when nothing ever bound it — a `web` role that threw after it was built. */
|
|
@@ -176,6 +177,35 @@ export function registerLiveQueries(options: StartRolesOptions): LiveQueryRegist
|
|
|
176
177
|
return registry;
|
|
177
178
|
}
|
|
178
179
|
|
|
180
|
+
const healthDetailPeersOf = (
|
|
181
|
+
peers: readonly string[] | undefined,
|
|
182
|
+
): { readonly healthDetailPeers?: readonly string[] } =>
|
|
183
|
+
peers === undefined ? {} : { healthDetailPeers: peers };
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* What `x dev` adds to a DECLARED list: the origin its own web role is serving pages on, under
|
|
187
|
+
* each loopback spelling a browser may have been pointed at. A declaration is the whole list, and
|
|
188
|
+
* the scaffold's `.env` declares `APP_URL=http://localhost:3000` — so `x dev --port 4000`, or dev
|
|
189
|
+
* opened as `127.0.0.1`, was a page on an origin nothing named and every socket was refused.
|
|
190
|
+
*
|
|
191
|
+
* Nothing is added to an EMPTY list: an undeclared node admits the origin it was reached on, which
|
|
192
|
+
* already covers these and a forwarded host besides — and one entry would end that rule. Nothing
|
|
193
|
+
* is added outside dev either: in a container the declaration is the operator's, and a wrong one
|
|
194
|
+
* must refuse rather than be papered over by whatever address the process bound.
|
|
195
|
+
*/
|
|
196
|
+
function devOrigins(
|
|
197
|
+
options: StartRolesOptions,
|
|
198
|
+
declared: readonly string[],
|
|
199
|
+
appUrl: string | null,
|
|
200
|
+
): string[] {
|
|
201
|
+
if (!(options.http ?? DEV_BINDING).dev || appUrl === null || declared.length === 0) return [];
|
|
202
|
+
const own = new URL(appUrl);
|
|
203
|
+
const loopback = ['localhost', '127.0.0.1', '[::1]'].map(
|
|
204
|
+
(host) => `${own.protocol}//${host}:${own.port}`,
|
|
205
|
+
);
|
|
206
|
+
return [own.origin, ...loopback].filter((origin) => !declared.includes(origin));
|
|
207
|
+
}
|
|
208
|
+
|
|
179
209
|
/**
|
|
180
210
|
* The node itself: its hub, its registry, its authenticator and its change subscription. Nothing
|
|
181
211
|
* bound — `listen()` and the `web` role's mount are the two doors, and this is what is behind both.
|
|
@@ -195,6 +225,7 @@ export async function prepareSync(options: StartRolesOptions): Promise<PreparedS
|
|
|
195
225
|
// `exp`), or resolves identity from a header the adapter deliberately does not retain.
|
|
196
226
|
const authenticate = options.overrides?.syncAuthenticate ?? syncAuthenticator(options.buildId);
|
|
197
227
|
const registry = registerLiveQueries(options);
|
|
228
|
+
const origins = syncOriginsFrom(options.env);
|
|
198
229
|
const node = createSyncNode({
|
|
199
230
|
hub,
|
|
200
231
|
registry,
|
|
@@ -202,9 +233,17 @@ export async function prepareSync(options: StartRolesOptions): Promise<PreparedS
|
|
|
202
233
|
buildId: options.buildId,
|
|
203
234
|
sockets,
|
|
204
235
|
...(authenticate === undefined ? {} : { authenticate }),
|
|
205
|
-
//
|
|
206
|
-
// admitted
|
|
207
|
-
|
|
236
|
+
// `APP_URL`'s origin, and when one is declared it is the WHOLE list: the origin this node was
|
|
237
|
+
// reached on is no longer admitted beside it. Undeclared, that origin (and its https spelling
|
|
238
|
+
// when TLS ended at a proxy) is what is admitted; anything else is refused before
|
|
239
|
+
// `authenticate` (X_SOCKET_ORIGIN_REFUSED). Held by reference: `listen` adds `x dev`'s own.
|
|
240
|
+
allowedOrigins: origins,
|
|
241
|
+
// Dev may keep the reached-on origin beside a declaration and a container may not: `x dev`
|
|
242
|
+
// binds loopback, so a `Host` reaches it only through a forward its own user set up.
|
|
243
|
+
...((options.http ?? DEV_BINDING).dev ? { admitReachedOrigin: true } : {}),
|
|
244
|
+
// Who this node's OWN `/healthz` and `/readyz` tell the detail to: the app's one declaration,
|
|
245
|
+
// the same list the web role reads. Unset, the node keeps its default (the box itself).
|
|
246
|
+
...healthDetailPeersOf(configuredHttp()?.healthDetailPeers),
|
|
208
247
|
// Tier 1 is presence, and without a registry the node answers a topic subscribe with no member
|
|
209
248
|
// list at all — the KV bucket the transport just created would hold nothing and every `sync`
|
|
210
249
|
// container would run a presence-less protocol. It reads and writes `transport.shared`, so it
|
|
@@ -223,7 +262,10 @@ export async function prepareSync(options: StartRolesOptions): Promise<PreparedS
|
|
|
223
262
|
// second copy of `/_x/sync` here is the copy that stays behind when it moves.
|
|
224
263
|
mount: { path: node.path, fetch: node.fetch, websocket: node.websocket },
|
|
225
264
|
stop: () => node.stop(),
|
|
226
|
-
listen: async (appUrl) =>
|
|
265
|
+
listen: async (appUrl) => {
|
|
266
|
+
origins.push(...devOrigins(options, origins, appUrl));
|
|
267
|
+
return await listen(options, node, { registry, hub }, appUrl);
|
|
268
|
+
},
|
|
227
269
|
};
|
|
228
270
|
}
|
|
229
271
|
|
|
@@ -235,27 +277,20 @@ export async function prepareSync(options: StartRolesOptions): Promise<PreparedS
|
|
|
235
277
|
* neighbouring port, and reversing the order would answer a second `x dev` on this checkout with
|
|
236
278
|
* "port 3001 is in use" when the fact worth printing is that 3000 is.
|
|
237
279
|
*
|
|
238
|
-
* Port 0
|
|
239
|
-
*
|
|
240
|
-
* `
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
* listener's own bound address either way, never a string built from the port that was requested.
|
|
280
|
+
* Port 0 IS passed through to the kernel, web role or not. A scratch server (`x shot`, `ui.shot`,
|
|
281
|
+
* a test) asks for 0, and its pages dial `/_x/sync` on their OWN origin — the web role's mount
|
|
282
|
+
* (`sync-url.ts`) — so nothing computes this listener's port from the web port's. Deriving
|
|
283
|
+
* `PORT + 1` from the kernel's answer asked for one specific port nobody had checked, and under
|
|
284
|
+
* load another socket held it: the boot died `X_PORT_IN_USE` (role-realtime.test.ts, 6 of 20 with
|
|
285
|
+
* 12,000 loopback connections held). The reported url is the listener's own bound address.
|
|
245
286
|
*/
|
|
246
|
-
function syncPortFrom(requested: number, appUrl: string | null): number {
|
|
247
|
-
if (requested !== 0 || appUrl === null) return syncPortFor(requested);
|
|
248
|
-
const bound = Number(new URL(appUrl).port);
|
|
249
|
-
return Number.isInteger(bound) && bound > 0 ? syncPortFor(bound) : 0;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
287
|
async function listen(
|
|
253
288
|
options: StartRolesOptions,
|
|
254
289
|
node: SyncNode,
|
|
255
290
|
feeds: Pick<RunningSync, 'registry' | 'hub'>,
|
|
256
291
|
appUrl: string | null,
|
|
257
292
|
): Promise<RunningSync> {
|
|
258
|
-
const port =
|
|
293
|
+
const port = syncPortFor(options.port);
|
|
259
294
|
try {
|
|
260
295
|
// The SAME interface the web role binds, resolved from the same option and the same default.
|
|
261
296
|
// Without this the sync node took Bun's `0.0.0.0` while `x dev`'s web role took `localhost`,
|
package/src/runtime-assets.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { probeImage } from '@ultimat3/core';
|
|
|
9
9
|
import type { CacheHint, RequestContext, Route, UltimateRequest } from '@ultimat3/http';
|
|
10
10
|
import { applyCacheHeaders } from '@ultimat3/http';
|
|
11
11
|
import type { ImageQuery, ImageTransformDriver } from '@ultimat3/seo';
|
|
12
|
-
import { builtinImageDriver, DEFAULT_WIDTHS, parseImageQuery } from '@ultimat3/seo';
|
|
12
|
+
import { builtinImageDriver, DEFAULT_WIDTHS, parseImageQuery, usableWidths } from '@ultimat3/seo';
|
|
13
13
|
import type { ImageTransform, Storage, VariantFormat } from '@ultimat3/storage';
|
|
14
14
|
import { isTenantScoped, isVariantFormat, variantKey } from '@ultimat3/storage';
|
|
15
15
|
import { faviconRoute } from './favicon';
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
} from './runtime-storage';
|
|
30
30
|
import { siteAssetRoutes } from './site-asset-routes';
|
|
31
31
|
import { siteAssetTable } from './site-assets';
|
|
32
|
+
import { storedObjectHeaders } from './stored-object-headers';
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
35
|
* Storage-backed images. `responsiveImage({ src: '/media/<key>' })` mints its variants under it.
|
|
@@ -58,21 +59,24 @@ const IMMUTABLE_IMAGE: CacheHint = { mode: 'immutable' };
|
|
|
58
59
|
* blast radius and does not close it: 8192 stored objects per source, per format, is amplification
|
|
59
60
|
* a tenant drives with a `for` loop.
|
|
60
61
|
*
|
|
61
|
-
* The set is `DEFAULT_WIDTHS`
|
|
62
|
-
*
|
|
63
|
-
*
|
|
62
|
+
* The set is `usableWidths(intrinsic, DEFAULT_WIDTHS)` — the function that writes the `srcset`,
|
|
63
|
+
* asked rather than restated. A restatement (`DEFAULT_WIDTHS` plus the intrinsic width) disagreed
|
|
64
|
+
* with it for a source wider than `MAX_IMAGE_WIDTH`, whose widest candidate is the ceiling: that
|
|
65
|
+
* URL, minted by the framework itself, was decoded on every request and never stored.
|
|
64
66
|
* Anything outside it is still SERVED: this decides what is written, not what is answered, so no
|
|
65
67
|
* caller gains a new 4xx and the disk stops growing on a stranger's key.
|
|
66
68
|
*/
|
|
67
69
|
const isMintableWidth = (width: number | undefined, intrinsic: number): boolean =>
|
|
68
|
-
width === undefined ||
|
|
70
|
+
width === undefined || usableWidths(intrinsic, DEFAULT_WIDTHS).includes(width);
|
|
69
71
|
|
|
70
72
|
const imageResponse = (bytes: Uint8Array, contentType: string, cache: CacheHint): Response =>
|
|
71
73
|
applyCacheHeaders(
|
|
72
74
|
// Copied, not passed through: a `Uint8Array<ArrayBufferLike>` may be backed by a
|
|
73
75
|
// `SharedArrayBuffer`, which `Response` does not accept, and copying is what makes that true
|
|
74
76
|
// by construction rather than by a cast that would only silence it.
|
|
75
|
-
new Response(new Uint8Array(bytes), {
|
|
77
|
+
new Response(new Uint8Array(bytes), {
|
|
78
|
+
headers: { 'content-type': contentType, ...storedObjectHeaders(contentType) },
|
|
79
|
+
}),
|
|
76
80
|
cache,
|
|
77
81
|
);
|
|
78
82
|
|
|
@@ -174,7 +178,7 @@ export interface AssetRoutesOptions {
|
|
|
174
178
|
/** App root. The source icon is resolved against it; storage keys never are. */
|
|
175
179
|
readonly root: string;
|
|
176
180
|
readonly storage: Storage;
|
|
177
|
-
/** Replaces `builtinImageDriver` for `/media/*`. Omitted, core's PNG/JPEG pipeline. */
|
|
181
|
+
/** Replaces `builtinImageDriver` for `/media/*`. Omitted, core's PNG/JPEG/WebP pipeline. */
|
|
178
182
|
readonly images?: ImageTransformDriver;
|
|
179
183
|
/**
|
|
180
184
|
* `manifest.webmanifest`, resolved at boot by `pwa-artifacts.ts`. Absent when the app declares
|
package/src/runtime-bindings.ts
CHANGED
|
@@ -24,10 +24,10 @@ export interface DevServices {
|
|
|
24
24
|
readonly storage: ServiceBinding;
|
|
25
25
|
readonly stateDir: string;
|
|
26
26
|
/**
|
|
27
|
-
* The app directory itself, carried rather than re-derived from `stateDir`. A boot that needs
|
|
28
|
-
* read the app's own `app.config.ts` — `
|
|
29
|
-
* `join(root, '.x')` above, and a `dirname` that silently disagrees with this
|
|
30
|
-
* path bug nothing would catch.
|
|
27
|
+
* The app directory itself, carried rather than re-derived from `stateDir`. A boot that needs
|
|
28
|
+
* to read the app's own `app.config.ts` — `startServices`' `loadAppConfig` does — otherwise has
|
|
29
|
+
* to undo the `join(root, '.x')` above, and a `dirname` that silently disagrees with this
|
|
30
|
+
* file's join is a path bug nothing would catch.
|
|
31
31
|
*/
|
|
32
32
|
readonly root: string;
|
|
33
33
|
}
|