@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.
Files changed (149) hide show
  1. package/CLAUDE.md +15 -7
  2. package/README.md +3 -3
  3. package/package.json +28 -28
  4. package/src/affected.ts +27 -35
  5. package/src/api-registration.ts +89 -0
  6. package/src/app-artifacts.ts +1 -1
  7. package/src/app-auth.ts +4 -27
  8. package/src/app-config-load.ts +51 -0
  9. package/src/app-env.ts +5 -11
  10. package/src/app-manifest.ts +2 -8
  11. package/src/app-mcp.ts +10 -23
  12. package/src/app-permissions-borrowed.ts +129 -0
  13. package/src/app-permissions-site.ts +24 -0
  14. package/src/app-permissions.ts +7 -17
  15. package/src/budgets.ts +4 -35
  16. package/src/cdp-shot-driver.ts +3 -1
  17. package/src/cmd-auth-spec.ts +16 -0
  18. package/src/cmd-auth.ts +65 -0
  19. package/src/cmd-build.ts +64 -15
  20. package/src/cmd-db-backfill.ts +25 -12
  21. package/src/cmd-db.ts +6 -5
  22. package/src/cmd-deploy-helm.ts +7 -6
  23. package/src/cmd-deploy.ts +4 -4
  24. package/src/cmd-dev.ts +9 -8
  25. package/src/cmd-doctor-spec.ts +2 -9
  26. package/src/cmd-doctor.ts +41 -14
  27. package/src/cmd-env.ts +26 -2
  28. package/src/cmd-generate.ts +79 -32
  29. package/src/cmd-help.ts +19 -6
  30. package/src/cmd-i18n.ts +10 -9
  31. package/src/cmd-jobs.ts +18 -6
  32. package/src/cmd-manifest-spec.ts +1 -1
  33. package/src/cmd-manifest.ts +6 -25
  34. package/src/cmd-mcp.ts +46 -15
  35. package/src/cmd-planned.ts +15 -8
  36. package/src/cmd-routes.ts +12 -2
  37. package/src/cmd-secrets.ts +47 -6
  38. package/src/cmd-shot-matrix.ts +1 -3
  39. package/src/cmd-shot.ts +12 -3
  40. package/src/cmd-tasks.ts +2 -2
  41. package/src/cmd-test-spec.ts +1 -7
  42. package/src/cmd-test.ts +13 -29
  43. package/src/cmd-verify.ts +4 -4
  44. package/src/db-subscribes.ts +12 -4
  45. package/src/dev-dashboard-guard.ts +76 -0
  46. package/src/dev-dashboard.ts +51 -29
  47. package/src/dev-lock.ts +4 -7
  48. package/src/dispatch.ts +3 -6
  49. package/src/doctor-app-url.ts +39 -0
  50. package/src/doctor-auth.ts +95 -0
  51. package/src/drift-replica-identity.ts +42 -0
  52. package/src/error-catalog.ts +6 -9
  53. package/src/error-codes.ts +23 -3
  54. package/src/error-unthrown.ts +29 -11
  55. package/src/errors.ts +54 -83
  56. package/src/framework-schema-apply.ts +78 -0
  57. package/src/framework-schema-stamp.ts +96 -0
  58. package/src/framework-schema.ts +13 -2
  59. package/src/generate-files.ts +4 -3
  60. package/src/generate-kinds.ts +33 -1
  61. package/src/generate-shadow.ts +46 -0
  62. package/src/generate-write.ts +47 -13
  63. package/src/gh-target.ts +12 -7
  64. package/src/i18n-audit.ts +4 -1
  65. package/src/index.ts +1 -3
  66. package/src/island-bundle.ts +3 -15
  67. package/src/jobs-drain.ts +82 -15
  68. package/src/jobs-report.ts +4 -4
  69. package/src/loopback-host.ts +13 -0
  70. package/src/manifest-staleness.ts +62 -0
  71. package/src/mcp-errors.ts +12 -3
  72. package/src/mcp-host.ts +16 -9
  73. package/src/mcp-test-run.ts +38 -0
  74. package/src/messages.ts +2 -0
  75. package/src/metrics-endpoint.ts +25 -1
  76. package/src/output.ts +6 -0
  77. package/src/page-navigation.ts +7 -23
  78. package/src/page-speculation.ts +5 -20
  79. package/src/prerender-out.ts +34 -7
  80. package/src/prerender.ts +8 -5
  81. package/src/pwa-artifacts.ts +26 -94
  82. package/src/registry.ts +2 -0
  83. package/src/revalidated-response.ts +4 -3
  84. package/src/role-replicator.ts +16 -0
  85. package/src/role-start.ts +9 -4
  86. package/src/role-sync.ts +55 -20
  87. package/src/runtime-assets.ts +11 -7
  88. package/src/runtime-bindings.ts +4 -4
  89. package/src/runtime-cache.ts +38 -47
  90. package/src/runtime-idempotency-scope.ts +23 -0
  91. package/src/runtime-isr.ts +36 -0
  92. package/src/runtime-jobs.ts +8 -33
  93. package/src/runtime-mfa-warning.ts +29 -0
  94. package/src/runtime-notify-retention.ts +10 -46
  95. package/src/runtime-overrides.ts +1 -1
  96. package/src/runtime-purge.ts +38 -16
  97. package/src/runtime-queue.ts +31 -10
  98. package/src/runtime-realtime.ts +9 -45
  99. package/src/runtime-render.ts +62 -35
  100. package/src/runtime-services.ts +20 -17
  101. package/src/runtime-storage.ts +10 -5
  102. package/src/schema-diff.ts +16 -0
  103. package/src/schema-drift.ts +45 -15
  104. package/src/seo-routes.ts +80 -9
  105. package/src/serve-boot.ts +34 -6
  106. package/src/serve-drain.ts +9 -33
  107. package/src/serve-env.ts +9 -6
  108. package/src/serve-web.ts +12 -7
  109. package/src/shot-locale.ts +5 -23
  110. package/src/site-assets.ts +3 -5
  111. package/src/site-config.ts +9 -48
  112. package/src/site-seo.ts +1 -1
  113. package/src/sitemap-lastmod.ts +5 -0
  114. package/src/solid-loader.ts +12 -3
  115. package/src/source-files.ts +6 -0
  116. package/src/stored-object-headers.ts +43 -0
  117. package/src/sync-url.ts +2 -2
  118. package/src/tasks-facts.ts +3 -29
  119. package/src/templates/action.ts +3 -0
  120. package/src/templates/naming.ts +28 -0
  121. package/src/templates/route.ts +3 -6
  122. package/src/templates/scaffold-app.ts +6 -2
  123. package/src/templates/scaffold-container-compose.ts +10 -1
  124. package/src/templates/scaffold-container.ts +4 -2
  125. package/src/templates/scaffold-db-client.ts +4 -2
  126. package/src/templates/scaffold-helm-fences.ts +76 -0
  127. package/src/templates/scaffold-helm-templates.ts +32 -7
  128. package/src/templates/scaffold-helm.ts +31 -1
  129. package/src/templates/scaffold-repo.ts +9 -1
  130. package/src/templates/scaffold-shell.ts +39 -36
  131. package/src/test-counts.ts +2 -6
  132. package/src/test-ignores.ts +25 -0
  133. package/src/test-passes.ts +0 -15
  134. package/src/test-select.ts +5 -14
  135. package/src/test-shards.ts +41 -93
  136. package/src/test-workers.ts +26 -4
  137. package/src/theme-boot.ts +6 -31
  138. package/src/trusted-hops-error.ts +17 -0
  139. package/src/unthrown-codes.ts +26 -0
  140. package/src/verify-checks.ts +17 -46
  141. package/src/verify-deadline.ts +130 -33
  142. package/src/verify-merge.ts +60 -27
  143. package/src/verify-part-step.ts +90 -0
  144. package/src/verify-run.ts +29 -8
  145. package/src/verify-shard.ts +13 -2
  146. package/src/verify-stalled.ts +152 -0
  147. package/src/verify-tests.ts +5 -2
  148. package/src/build-errors.ts +0 -54
  149. 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 !== undefined && (budget.js !== undefined || budget.lcp !== undefined);
172
+ return budget?.js !== undefined;
172
173
  };
173
174
 
174
175
  export async function prerenderSite(options: PrerenderOptions): Promise<PrerenderReport> {
175
- // The same load `x dev` and `x manifest` perform: importing the app's modules IS what fills the
176
- // route registry, so there is no route table to prerender before this runs.
177
- // Emptied FIRST: the export only ever gained files, so a deleted route's HTML kept shipping.
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));
@@ -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 synchronous file-existence primitive, and this read is the same one
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 { escapeAttribute } from '@ultimat3/seo';
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 because this is the one module
84
- * that reads an app's config file — `sw-artifacts.ts` needs the route table and the island
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
- * The offline block, read structurally for `colorsOf`'s reason: `defineConfig` refuses
162
- * `enabled: true` without an absolute `offline.fallback`, but a HAND-WRITTEN config object never
163
- * passed through it. A missing or relative fallback answers `null`, and `serviceWorkerArtifacts`
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 offlineOf(value: unknown): PwaOfflineConfig {
168
- const block = isRecord(value) ? value : {};
169
- const fallback = text(block['fallback']);
170
- const patterns = block['neverCache'];
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
- fallback: fallback?.startsWith('/') === true ? fallback : null,
173
- image: text(block['image']) ?? null,
174
- font: text(block['font']) ?? null,
175
- neverCache: Array.isArray(patterns)
176
- ? patterns.filter((entry): entry is string => typeof entry === 'string')
177
- : [],
178
- personalPages: block['personalPages'] === 'last-member' ? 'last-member' : 'never',
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 `loadCacheTiers` and unlike `faviconBytes`:
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="${escapeAttribute(path)}">` +
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` (absolute, already hashed) passes untouched.
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 — xxHash32, the function `contentHash` applies to a document,
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 = `"${Bun.hash.xxHash32(bytes).toString(16).padStart(8, '0')}"`;
29
+ const etag = `"${contentHash(bytes)}"`;
29
30
  const headers = {
30
31
  'content-type': contentType,
31
32
  'cache-control': REVALIDATE_CACHE_CONTROL,
@@ -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, PortInvalidError, RuntimeDriverSplitError } from './errors';
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 PortInvalidError({ value: raw, name: 'TRUSTED_PROXY_HOPS' });
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 { WebSocketMount } from '@ultimat3/http';
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, for the line below that names both doors.
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
- // The page's origin when it is served on another host than this node; the node's own host is
206
- // admitted without it. Anything else is refused before `authenticate` (X_SOCKET_ORIGIN_REFUSED).
207
- allowedOrigins: syncOriginsFrom(options.env),
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) => await listen(options, node, { registry, hub }, 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 is NOT passed through to the kernel. It cannot be incremented (`+ 1` would ask for port
239
- * 1), but the web role has bound by the time this runs and `appUrl` carries the port it got — and
240
- * `PORT + 1` is the contract every scaffolded `sync-url.ts` computes from and the wiki states. A
241
- * scratch server (`x shot`, `ui.shot`) always asks for 0, so before this every one of its
242
- * pictures carried `WebSocket … ERR_CONNECTION_REFUSED` for every live island, on the framework's
243
- * own account. With no web role to follow, 0 still goes to the kernel. The reported url is the
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 = syncPortFrom(options.port, appUrl);
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`,
@@ -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` **plus the source's intrinsic width**, which is exactly what
62
- * `usableWidths` puts in a `srcset` — clamping to the constant alone would refuse the widest entry
63
- * of every image whose intrinsic width is not one of the eight, a URL the framework mints itself.
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 || width === intrinsic || DEFAULT_WIDTHS.includes(width);
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), { headers: { 'content-type': contentType } }),
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
@@ -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 to
28
- * read the app's own `app.config.ts` — `loadInboxRetention` does — otherwise has to undo the
29
- * `join(root, '.x')` above, and a `dirname` that silently disagrees with this file's join is a
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
  }