@ultimat3/cli 24.0.0 → 25.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 (242) hide show
  1. package/CLAUDE.md +15 -8
  2. package/README.md +30 -5
  3. package/package.json +29 -29
  4. package/src/admin-catalog-keys.ts +2 -2
  5. package/src/affected.ts +3 -1
  6. package/src/app-boundaries.ts +17 -4
  7. package/src/app-config-load.ts +3 -3
  8. package/src/app-env.ts +21 -14
  9. package/src/app-load.ts +38 -14
  10. package/src/app-manifest.ts +2 -2
  11. package/src/app-mcp.ts +25 -27
  12. package/src/app-name.ts +52 -0
  13. package/src/app-permissions-borrowed.ts +2 -8
  14. package/src/app-permissions-site.ts +31 -7
  15. package/src/app-root.ts +9 -5
  16. package/src/bin.ts +3 -7
  17. package/src/browser-launcher.ts +4 -2
  18. package/src/budgets-charged.ts +60 -0
  19. package/src/budgets-scan.ts +76 -0
  20. package/src/budgets-unmeasured.ts +91 -0
  21. package/src/budgets.ts +115 -121
  22. package/src/cdp-shot-driver.ts +17 -0
  23. package/src/cdp-shot-watch.ts +35 -31
  24. package/src/ci-log.ts +0 -0
  25. package/src/cmd-auth.ts +2 -2
  26. package/src/cmd-build-spec.ts +7 -1
  27. package/src/cmd-build.ts +89 -9
  28. package/src/cmd-db-backfill.ts +1 -1
  29. package/src/cmd-db-branch.ts +2 -2
  30. package/src/cmd-db.ts +5 -2
  31. package/src/cmd-deploy-compose.ts +94 -0
  32. package/src/cmd-deploy-drain.ts +48 -0
  33. package/src/cmd-deploy-start-first.ts +178 -0
  34. package/src/cmd-deploy.ts +158 -36
  35. package/src/cmd-dev.ts +18 -357
  36. package/src/cmd-doctor.ts +16 -66
  37. package/src/cmd-env.ts +29 -14
  38. package/src/cmd-generate.ts +12 -2
  39. package/src/cmd-i18n.ts +11 -8
  40. package/src/cmd-jobs-spec.ts +7 -22
  41. package/src/cmd-jobs.ts +18 -137
  42. package/src/cmd-mcp.ts +25 -7
  43. package/src/cmd-new.ts +36 -8
  44. package/src/cmd-planned.ts +9 -0
  45. package/src/cmd-routes.ts +3 -3
  46. package/src/cmd-shot-matrix.ts +21 -4
  47. package/src/cmd-shot-spec.ts +10 -2
  48. package/src/cmd-shot.ts +35 -39
  49. package/src/cmd-test.ts +1 -1
  50. package/src/coverage-lcov.ts +4 -2
  51. package/src/db-backfill.ts +2 -2
  52. package/src/db-replay-engine.ts +4 -10
  53. package/src/db-subscribes.ts +19 -16
  54. package/src/dev-boot.ts +290 -0
  55. package/src/dev-child-watch.ts +63 -12
  56. package/src/dev-dashboard-guard.ts +1 -1
  57. package/src/dev-dashboard.ts +5 -1
  58. package/src/dev-lock.ts +96 -24
  59. package/src/dev-rebuild.ts +80 -0
  60. package/src/dev-restart-retire.ts +46 -0
  61. package/src/dev-route-table.ts +30 -8
  62. package/src/dev-supervisor.ts +114 -22
  63. package/src/dev-traces.ts +53 -20
  64. package/src/dispatch.ts +14 -7
  65. package/src/doctor-auth.ts +4 -4
  66. package/src/doctor-offline.ts +6 -5
  67. package/src/document-options.ts +7 -0
  68. package/src/drift-replica-identity.ts +2 -2
  69. package/src/drift.ts +29 -6
  70. package/src/error-codes.ts +6 -0
  71. package/src/error-page-csp.ts +14 -15
  72. package/src/exec.ts +8 -0
  73. package/src/favicon.ts +2 -2
  74. package/src/fix-command.ts +9 -1
  75. package/src/framework-env.ts +148 -0
  76. package/src/framework-schema.ts +13 -2
  77. package/src/generate-catalog-locales.ts +79 -0
  78. package/src/generate-kinds.ts +3 -17
  79. package/src/generate-plural.ts +50 -0
  80. package/src/generate-shadow.ts +50 -1
  81. package/src/hold.ts +56 -1
  82. package/src/i18n-audit.ts +21 -8
  83. package/src/i18n-index.ts +5 -6
  84. package/src/i18n-registration.ts +7 -9
  85. package/src/import-scan.ts +28 -3
  86. package/src/index.ts +1 -3
  87. package/src/island-bundle.ts +54 -64
  88. package/src/island-capture.ts +1 -1
  89. package/src/island-duplicate-refusal.ts +57 -0
  90. package/src/island-duplicates.ts +221 -0
  91. package/src/island-entry-missing.ts +64 -0
  92. package/src/island-harness-route.ts +20 -4
  93. package/src/island-harness-script.ts +23 -2
  94. package/src/island-identity.ts +17 -14
  95. package/src/island-package-dedupe.ts +150 -0
  96. package/src/island-realtime.ts +36 -16
  97. package/src/island-routes.ts +2 -2
  98. package/src/island-runtime.ts +241 -0
  99. package/src/island-sources.ts +105 -0
  100. package/src/island-states-load.ts +6 -0
  101. package/src/island-store.ts +90 -6
  102. package/src/jobs-json.ts +0 -26
  103. package/src/jobs-report.ts +32 -16
  104. package/src/local-cli-handoff.ts +90 -0
  105. package/src/mcp-errors.ts +8 -1
  106. package/src/mcp-host.ts +6 -5
  107. package/src/mcp-ui-inspect.ts +3 -2
  108. package/src/mcp-ui-interact.ts +3 -2
  109. package/src/mcp-ui.ts +8 -7
  110. package/src/measure-scope.ts +15 -10
  111. package/src/messages.ts +27 -16
  112. package/src/metrics-endpoint.ts +3 -2
  113. package/src/migration-extensions.ts +1 -1
  114. package/src/otlp-export.ts +2 -2
  115. package/src/output.ts +18 -0
  116. package/src/page-speculation.ts +14 -11
  117. package/src/parse.ts +0 -8
  118. package/src/permission-grants.ts +3 -3
  119. package/src/posix-path.ts +30 -0
  120. package/src/prerender-locales.ts +6 -4
  121. package/src/prerender-report.ts +58 -0
  122. package/src/prerender-served.ts +52 -0
  123. package/src/prerender.ts +63 -98
  124. package/src/pwa-artifacts.ts +8 -1
  125. package/src/pwa-manifest-locales.ts +9 -9
  126. package/src/retry-memo.ts +1 -1
  127. package/src/role-realtime.ts +7 -1
  128. package/src/role-replicator.ts +110 -31
  129. package/src/role-start.ts +64 -85
  130. package/src/role-sync.ts +65 -6
  131. package/src/root-env.ts +157 -28
  132. package/src/route-islands.ts +69 -0
  133. package/src/runtime-admin.ts +4 -1
  134. package/src/runtime-assets.ts +15 -2
  135. package/src/runtime-bindings.ts +23 -3
  136. package/src/runtime-cache.ts +8 -8
  137. package/src/runtime-hooks.ts +2 -1
  138. package/src/runtime-isr-outcome.ts +100 -0
  139. package/src/runtime-isr.ts +2 -2
  140. package/src/runtime-jobs.ts +78 -18
  141. package/src/runtime-live-feed.ts +1 -1
  142. package/src/runtime-mail.ts +26 -0
  143. package/src/runtime-mfa-warning.ts +2 -2
  144. package/src/runtime-overrides.ts +3 -3
  145. package/src/runtime-purge.ts +1 -1
  146. package/src/runtime-queue.ts +12 -12
  147. package/src/runtime-realtime.ts +5 -3
  148. package/src/runtime-render.ts +77 -82
  149. package/src/runtime-replica.ts +3 -8
  150. package/src/runtime-services.ts +33 -13
  151. package/src/runtime-storage-upload.ts +2 -2
  152. package/src/runtime-storage.ts +3 -3
  153. package/src/schema-diff.ts +1 -1
  154. package/src/secrets-rotation.ts +8 -9
  155. package/src/seo-routes.ts +2 -2
  156. package/src/serve-boot.ts +22 -11
  157. package/src/serve-drain.ts +3 -1
  158. package/src/serve-retire.ts +95 -0
  159. package/src/serve-web.ts +19 -8
  160. package/src/serve.ts +27 -10
  161. package/src/server-barrels.ts +6 -3
  162. package/src/shot-browser.ts +1 -1
  163. package/src/shot-locale.ts +6 -15
  164. package/src/shot-settle.ts +3 -3
  165. package/src/shot-theme.ts +1 -1
  166. package/src/shot-viewport.ts +152 -0
  167. package/src/signal-shred.ts +26 -12
  168. package/src/site-asset-routes.ts +2 -2
  169. package/src/site-seo.ts +6 -4
  170. package/src/source-files.ts +13 -3
  171. package/src/static-report.ts +5 -0
  172. package/src/style-routes.ts +2 -2
  173. package/src/sw-artifacts.ts +8 -8
  174. package/src/sw-precache-plan.ts +34 -4
  175. package/src/sync-authenticator.ts +3 -3
  176. package/src/sync-url.ts +1 -1
  177. package/src/templates/action.ts +4 -4
  178. package/src/templates/backfill.ts +4 -4
  179. package/src/templates/entity.ts +2 -2
  180. package/src/templates/github/ci.yml.ts +28 -18
  181. package/src/templates/github/image.yml.ts +92 -0
  182. package/src/templates/github/runner.ts +18 -0
  183. package/src/templates/guard-bare-error.ts +16 -2
  184. package/src/templates/guard-unzoned-date.ts +56 -6
  185. package/src/templates/index.ts +1 -1
  186. package/src/templates/job-body-test.ts +2 -2
  187. package/src/templates/job.ts +6 -6
  188. package/src/templates/query.ts +9 -8
  189. package/src/templates/resource-page.ts +9 -7
  190. package/src/templates/resource-service.ts +2 -2
  191. package/src/templates/route.ts +23 -8
  192. package/src/templates/scaffold-api.ts +2 -3
  193. package/src/templates/scaffold-app.ts +2 -1
  194. package/src/templates/scaffold-auth.ts +205 -70
  195. package/src/templates/scaffold-bin.ts +122 -0
  196. package/src/templates/scaffold-claude-commands.ts +11 -11
  197. package/src/templates/scaffold-container.ts +12 -6
  198. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  199. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  200. package/src/templates/scaffold-dashboard-example.ts +112 -172
  201. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  202. package/src/templates/scaffold-dashboard.ts +9 -5
  203. package/src/templates/scaffold-db-package.ts +2 -2
  204. package/src/templates/scaffold-docs.ts +20 -77
  205. package/src/templates/scaffold-domain-package.ts +0 -4
  206. package/src/templates/scaffold-entries.ts +4 -3
  207. package/src/templates/scaffold-env.ts +6 -3
  208. package/src/templates/scaffold-gitattributes.ts +59 -0
  209. package/src/templates/scaffold-helm-templates.ts +74 -18
  210. package/src/templates/scaffold-helm.ts +31 -4
  211. package/src/templates/scaffold-http.ts +1 -1
  212. package/src/templates/scaffold-i18n.ts +15 -2
  213. package/src/templates/scaffold-icon.ts +2 -2
  214. package/src/templates/scaffold-repo.ts +18 -15
  215. package/src/templates/scaffold-shell.ts +17 -2
  216. package/src/test-only-shipped.ts +193 -0
  217. package/src/test-select.ts +2 -1
  218. package/src/test-shards.ts +1 -4
  219. package/src/theme-boot.ts +2 -2
  220. package/src/theme-brand.ts +66 -0
  221. package/src/trusted-hops.ts +49 -0
  222. package/src/unscanned-admin.ts +2 -2
  223. package/src/verify-checks.ts +12 -2
  224. package/src/verify-coverage-run.ts +3 -2
  225. package/src/verify-deadline.ts +87 -10
  226. package/src/verify-merge.ts +48 -16
  227. package/src/verify-part-step.ts +10 -2
  228. package/src/verify-role-load-probe.ts +12 -2
  229. package/src/verify-role-load.ts +9 -4
  230. package/src/verify-run.ts +3 -0
  231. package/src/verify-step.ts +2 -0
  232. package/src/verify-test-run.ts +3 -2
  233. package/src/verify-tests.ts +0 -1
  234. package/src/worker-bundle.ts +2 -2
  235. package/src/workspace-checks.ts +53 -7
  236. package/src/workspace-graph.ts +3 -2
  237. package/src/write-line.ts +82 -7
  238. package/src/browser-launcher-fake-html.ts +0 -80
  239. package/src/browser-launcher-fake.ts +0 -168
  240. package/src/jobs-drain.ts +0 -230
  241. package/src/scaffold-typecheck.ts +0 -227
  242. package/src/thrown-by.ts +0 -24
@@ -32,9 +32,9 @@ export interface SettleOptions {
32
32
  /**
33
33
  * Read the probe until every booted island has settled, or the window runs out.
34
34
  *
35
- * `DEFAULT_SETTLE_MS` is the deadline at which the hydration runtime CALLS `import()` — `mounted`
36
- * and `failed` land after it — so a single read at that instant reports `mounted: 0` for a page
37
- * that hydrates perfectly and the verdict was taken one tick before the outcome existed. Polling
35
+ * The default window, `IDLE_HYDRATE_TIMEOUT_MS`, is the deadline at which the hydration runtime
36
+ * CALLS `import()` — `mounted` and `failed` land after it — so a single read at that instant
37
+ * reports `mounted: 0` for a page that hydrates perfectly and the verdict was taken one tick before the outcome existed. Polling
38
38
  * is the only shape that ends EARLY on a fast page and still bounds a slow one.
39
39
  *
40
40
  * A `null` answer never overwrites a real count: `null` means "not counted", and a probe that
package/src/shot-theme.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // (issue #489). A requested theme is therefore stored as the visitor's CHOICE, on the page's origin,
6
6
  // before the boot reads it: the picture is then what a visitor who chose that theme sees.
7
7
 
8
- import { THEME_STORAGE_KEY } from '@ultimat3/render';
8
+ import { THEME_STORAGE_KEY } from '@ultimat3/core';
9
9
  import type { ShotColorScheme } from './browser-launcher-port';
10
10
  import { BadFlagError } from './errors';
11
11
 
@@ -0,0 +1,152 @@
1
+ // `x shot --viewport 390x844,1440x900`: the flag read into sizes, and one route photographed once
2
+ // per size into `<out>/<w>x<h>/` over ONE dev server. Every responsive picture used to need a
3
+ // hand-written browser script beside the tool that exists to make that unnecessary (#444).
4
+
5
+ // why: no Bun native joins a path.
6
+ import { join } from 'node:path';
7
+ import { renderCauseValue } from '@ultimat3/core';
8
+ import type { ShotRun } from './cmd-shot';
9
+ import { BadFlagError } from './errors';
10
+ import { msg } from './messages';
11
+ import type { CommandResult } from './output';
12
+ import type { ShotServer } from './shot-server';
13
+ import type { ShotArtifacts } from './shot-verdict';
14
+ import { shotLines, shotSummary, verdictJson } from './shot-verdict';
15
+
16
+ /** CSS pixels a page is laid out in. */
17
+ export interface ShotViewport {
18
+ readonly width: number;
19
+ readonly height: number;
20
+ }
21
+
22
+ /**
23
+ * The largest side a size may have: Chrome's maximum texture dimension, past which a capture is
24
+ * tiles the compositor never painted. One bound for both sides, so the refusal states one number.
25
+ */
26
+ export const VIEWPORT_MAX = 16_384;
27
+
28
+ /** Digits only: `0x10`, `1e3` and `390.5` are not a size anybody typed meaning a size. */
29
+ const SIZE = /^(\d+)x(\d+)$/i;
30
+
31
+ const FIX = 'x shot / --viewport 390x844,1440x900 --json';
32
+
33
+ const refuse = (reason: string): never => {
34
+ throw new BadFlagError({ flag: 'viewport', command: 'shot', reason, fix: FIX });
35
+ };
36
+
37
+ /**
38
+ * An island state declares its own `viewport` in its `*.island.states.ts`, and the harness opens one
39
+ * browser per declared size — so a size asked for beside `--island`/`--all-islands` is a second
40
+ * answer to a question the states already answer.
41
+ */
42
+ export function refuseViewportWithComponent(): never {
43
+ throw new BadFlagError({
44
+ flag: 'viewport',
45
+ command: 'shot',
46
+ reason: 'sizes a route capture; an island is photographed at the viewport each state declares',
47
+ fix: FIX,
48
+ });
49
+ }
50
+
51
+ /** A size is its own directory name, so two sizes can never write one picture. */
52
+ export const viewportDir = (viewport: ShotViewport): string =>
53
+ `${String(viewport.width)}x${String(viewport.height)}`;
54
+
55
+ function readSize(entry: string): ShotViewport {
56
+ const match = SIZE.exec(entry);
57
+ if (match === null) {
58
+ return refuse(`${renderCauseValue(entry)} is not <width>x<height>, e.g. 390x844`);
59
+ }
60
+ const width = Number(match[1]);
61
+ const height = Number(match[2]);
62
+ if (width < 1 || height < 1 || width > VIEWPORT_MAX || height > VIEWPORT_MAX) {
63
+ return refuse(`${renderCauseValue(entry)} is outside 1..${String(VIEWPORT_MAX)} on a side`);
64
+ }
65
+ return { width, height };
66
+ }
67
+
68
+ /**
69
+ * The flag, every entry judged before any is returned — a run that photographed the good half and
70
+ * refused afterwards would leave a reader with half a set and a failure. Absent is `undefined`:
71
+ * no override, the capture's own default size. Refused, never skipped: an empty entry and the same
72
+ * size twice are both a list the reader did not mean, and the second would overwrite the first.
73
+ */
74
+ export function parseViewports(raw: string | undefined): readonly ShotViewport[] | undefined {
75
+ if (raw === undefined) return undefined;
76
+ const sizes = raw.split(',').map((entry) => readSize(entry.trim()));
77
+ const seen = new Set<string>();
78
+ for (const size of sizes) {
79
+ const dir = viewportDir(size);
80
+ if (seen.has(dir)) return refuse(`${dir} is listed twice`);
81
+ seen.add(dir);
82
+ }
83
+ return sizes;
84
+ }
85
+
86
+ export interface ViewportRun {
87
+ readonly viewports: readonly ShotViewport[];
88
+ /** Absolute; each size lands in `<outDir>/<w>x<h>/`. */
89
+ readonly outDir: string;
90
+ readonly boot: () => Promise<ShotServer>;
91
+ /** `runShot`, handed in so this module never imports the command that imports it. */
92
+ readonly shoot: (run: ShotRun) => Promise<ShotArtifacts>;
93
+ /** Everything a size shares: the route, the driver, the waits, the locale, the theme. */
94
+ readonly base: Omit<ShotRun, 'outDir' | 'boot' | 'viewport'>;
95
+ }
96
+
97
+ /**
98
+ * One server for every size, `runShotMatrix`'s rule: each capture gets a boot that never stops it,
99
+ * and the run stops it once after the last picture. Each size is an ordinary `runShot`, so its
100
+ * `verdict.json` means exactly what a single shot's does; `ok` only when every one is.
101
+ */
102
+ export async function runShotViewports(run: ViewportRun): Promise<CommandResult> {
103
+ // `parseViewports` never answers an empty list; a hand-built one is refused before the boot.
104
+ if (run.viewports.length === 0) refuse('lists no size');
105
+ const server = await run.boot();
106
+ const shared: ShotServer = { ...server, stop: () => Promise.resolve() };
107
+ const shots: { readonly viewport: ShotViewport; readonly artifacts: ShotArtifacts }[] = [];
108
+ try {
109
+ for (const viewport of run.viewports) {
110
+ const artifacts = await run.shoot({
111
+ ...run.base,
112
+ viewport,
113
+ outDir: join(run.outDir, viewportDir(viewport)),
114
+ boot: () => Promise.resolve(shared),
115
+ });
116
+ shots.push({ viewport, artifacts });
117
+ }
118
+ } finally {
119
+ await server.stop().catch(() => undefined);
120
+ }
121
+ // The summary is the first failure's, else the first picture's — labelled by its size, so a
122
+ // reader of one line knows which of the sizes it speaks for.
123
+ const worst = shots.find((shot) => !shot.artifacts.verdict.ok) ?? shots[0];
124
+ return {
125
+ ok: shots.every((shot) => shot.artifacts.verdict.ok),
126
+ command: 'shot',
127
+ summary:
128
+ worst === undefined
129
+ ? refuse('lists no size')
130
+ : msg('cli.shot.viewport.summary', {
131
+ size: viewportDir(worst.viewport),
132
+ summary: shotSummary(worst.artifacts.verdict),
133
+ }),
134
+ lines: shots.flatMap((shot) => [
135
+ msg('cli.shot.viewport.line', {
136
+ size: viewportDir(shot.viewport),
137
+ summary: shotSummary(shot.artifacts.verdict),
138
+ }),
139
+ ...shotLines(shot.artifacts).map((line) => ` ${line}`),
140
+ ]),
141
+ data: {
142
+ outDir: run.outDir,
143
+ shots: shots.map((shot) => ({
144
+ width: shot.viewport.width,
145
+ height: shot.viewport.height,
146
+ image: shot.artifacts.image,
147
+ verdictFile: shot.artifacts.verdictFile,
148
+ verdict: verdictJson(shot.artifacts.verdict),
149
+ })),
150
+ },
151
+ };
152
+ }
@@ -4,24 +4,38 @@
4
4
 
5
5
  export type Reraise = (signal: NodeJS.Signals) => void;
6
6
 
7
+ /** Ctrl-C, a supervisor's stop, and a terminal closing under the editor. */
8
+ const SHRED_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const;
9
+
7
10
  const reraiseDefault: Reraise = (signal) => {
8
11
  process.kill(process.pid, signal);
9
12
  };
10
13
 
11
14
  /**
12
- * Shred on SIGINT/SIGTERM, then re-raise the same signal: `once` has already removed this
13
- * listener, so the re-raised signal meets the default action and terminates. Returns the undo for
14
- * the normal path, which shreds in its own `finally`.
15
+ * Shred on SIGINT/SIGTERM/SIGHUP, then re-raise the same signal with the listeners gone, so it
16
+ * meets the default action and terminates. Returns the undo for the normal path, which shreds in
17
+ * its own `finally`.
18
+ *
19
+ * The listeners stay installed UNTIL the shred is done, and a repeat meanwhile is ignored. With
20
+ * `once` the listener was gone the moment the first signal was dispatched, so a second one — a
21
+ * Ctrl-C the terminal delivers to the whole group and a wrapper forwards again — met the default
22
+ * action: a kernel-level kill mid-`rmSync`, the plaintext left in $TMPDIR (30 of 40 runs).
15
23
  */
16
24
  export function shredOnSignal(shred: () => void, reraise: Reraise = reraiseDefault): () => void {
17
- const handler = (signal: NodeJS.Signals): void => {
18
- shred();
19
- reraise(signal);
20
- };
21
- process.once('SIGINT', handler);
22
- process.once('SIGTERM', handler);
23
- return () => {
24
- process.off('SIGINT', handler);
25
- process.off('SIGTERM', handler);
25
+ let shredding = false;
26
+ const undo = (): void => {
27
+ for (const signal of SHRED_SIGNALS) process.off(signal, handler);
26
28
  };
29
+ function handler(signal: NodeJS.Signals): void {
30
+ if (shredding) return;
31
+ shredding = true;
32
+ try {
33
+ shred();
34
+ } finally {
35
+ undo();
36
+ reraise(signal);
37
+ }
38
+ }
39
+ for (const signal of SHRED_SIGNALS) process.on(signal, handler);
40
+ return undo;
27
41
  }
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { isUltimateError } from '@ultimat3/core';
7
7
  import type { CacheHint, Route, UltimateRequest } from '@ultimat3/http';
8
- import { applyCacheHeaders, json } from '@ultimat3/http';
8
+ import { applyCacheHeaders, jsonResponse } from '@ultimat3/http';
9
9
  import type { SiteAsset, SiteAssetTable } from './site-assets';
10
10
  import { parseHashedAssetUrl, SITE_ASSET_BASE_PATH } from './site-assets';
11
11
 
@@ -13,7 +13,7 @@ import { parseHashedAssetUrl, SITE_ASSET_BASE_PATH } from './site-assets';
13
13
  export const SITE_ASSET_CACHE: CacheHint = { mode: 'immutable' };
14
14
 
15
15
  const notFound = (code: string, cause: string, fix: string): Response =>
16
- json({ ok: false, error: { code, cause, fix } }, { status: 404 });
16
+ jsonResponse({ ok: false, error: { code, cause, fix } }, { status: 404 });
17
17
 
18
18
  /** One `bytes=` range, resolved against the file's size; `null` when it cannot be satisfied. */
19
19
  export function byteRange(
package/src/site-seo.ts CHANGED
@@ -4,7 +4,8 @@
4
4
 
5
5
  import type { Environment, SeoSitemapConfig } from '@ultimat3/core';
6
6
  import { UltimateError } from '@ultimat3/core';
7
- import { localeConfig, localizedPath, routedLocales, unlocalizedPath } from '@ultimat3/i18n';
7
+ import { localizedPath, unlocalizedPath } from '@ultimat3/i18n';
8
+ import { appLocaleSet } from '@ultimat3/i18n/app-catalogs';
8
9
  import type { RouteEntry } from '@ultimat3/render';
9
10
  import { routeEntries } from '@ultimat3/render';
10
11
  import { enumeratePrerender, fillPath } from '@ultimat3/render/server';
@@ -150,10 +151,11 @@ export async function siteSeo(options: SiteSeoOptions): Promise<SiteSeo> {
150
151
  // Every routed locale, with `xhtml:link` alternates: one `<url>` per page per locale, each
151
152
  // naming the whole cluster and `x-default`. A single-locale app passes none and gets the plain
152
153
  // urlset it always had — an alternates cluster of one is noise.
153
- const locales = routedLocales();
154
- const defaultLocale = localeConfig().fallback;
155
- const settings = options.sitemap ?? NO_SITEMAP_SETTINGS;
154
+ // The app's declared set — the one the manifest, the worker and the prerender read — never the
155
+ // ambient locale config, which a build process has not configured.
156
156
  const root = options.root ?? process.cwd();
157
+ const { locales, defaultLocale } = await appLocaleSet(root);
158
+ const settings = options.sitemap ?? NO_SITEMAP_SETTINGS;
157
159
  const stamp: Stamp = (file) => {
158
160
  const lastmod = lastmodOf(file, settings.lastmod, root);
159
161
  return lastmod === undefined ? {} : { lastmod };
@@ -3,6 +3,7 @@
3
3
  // means a finding one of them can never see.
4
4
 
5
5
  import { hasPathSegment } from './path-segments';
6
+ import { toPosix } from './posix-path';
6
7
 
7
8
  export const SOURCE_GLOBS = [
8
9
  'packages/*/src/**/*.{ts,tsx}',
@@ -38,7 +39,17 @@ export const isGenerated = (path: string): boolean => path.endsWith('.d.ts');
38
39
  export const isTest = (path: string): boolean => /\.test\.tsx?$/.test(path);
39
40
 
40
41
  /**
41
- * Every source file under `root`, repo-relative and deduplicated across the globs — in a SORTED
42
+ * Glob answers as the POSIX paths every consumer keys by, in code-unit order of that spelling.
43
+ * The glob answers in the host's separator: on Windows `guards\untranslated-string.ts` missed
44
+ * `i18n`'s `startsWith('guards/')`, so the example `t('…')` in its fix line became a key every
45
+ * fresh scaffold was missing — and `^packages\/` rules matched nothing at all.
46
+ */
47
+ export function sortedPosix(paths: readonly string[]): string[] {
48
+ return paths.map(toPosix).sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
49
+ }
50
+
51
+ /**
52
+ * Every source file under `root`, repo-relative, POSIX and deduplicated across the globs — in a SORTED
42
53
  * order per pattern, `As of 2026-09-05`. `Bun.Glob.scan` yields in the filesystem's `readdir`
43
54
  * order, which ext4 hashes: the first offender a scan names was `index.ts` on one machine and
44
55
  * `tools.ts` on a GitHub runner, so a finding's `cause` depended on which disk held the checkout
@@ -52,8 +63,7 @@ export async function* eachSourceFile(root: string): AsyncGenerator<string> {
52
63
  for await (const path of new Bun.Glob(pattern).scan({ cwd: root, absolute: false })) {
53
64
  if (!isVendored(path)) matches.push(path);
54
65
  }
55
- matches.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
56
- for (const path of matches) {
66
+ for (const path of sortedPosix(matches)) {
57
67
  if (seen.has(path)) continue;
58
68
  seen.add(path);
59
69
  yield path;
@@ -71,6 +71,11 @@ export type UnmeasuredRoute = {
71
71
  readonly code?: string;
72
72
  readonly cause?: string;
73
73
  readonly fix?: string;
74
+ /**
75
+ * Who the measurement render ran as, labelled (`measure-scope.ts`'s `measurementActorLabel`) —
76
+ * so a refusal OF that actor names it, and the edit that changes it (#675). Never its facts.
77
+ */
78
+ readonly actor?: string;
74
79
  /**
75
80
  * `false` when no build can weigh the route by construction — a `render: 'ssr'` page with params,
76
81
  * which may not declare `prerender()`, so the build holds no value to render it at. Not a defect
@@ -4,7 +4,7 @@
4
4
  // image.
5
5
 
6
6
  import type { Route, UltimateRequest } from '@ultimat3/http';
7
- import { applyCacheHeaders, json, NO_STORE } from '@ultimat3/http';
7
+ import { applyCacheHeaders, jsonResponse, NO_STORE } from '@ultimat3/http';
8
8
  import type { StyleBundle } from './style-bundle';
9
9
  import { STYLE_BASE_PATH } from './style-bundle';
10
10
 
@@ -32,7 +32,7 @@ export function styleRoutes(source: StyleSource): readonly Route[] {
32
32
  handler: (request: UltimateRequest): Response => {
33
33
  const chunk = source().chunkAt(request.pathname);
34
34
  if (chunk === undefined) {
35
- const missing = json(
35
+ const missing = jsonResponse(
36
36
  {
37
37
  ok: false,
38
38
  error: {
@@ -3,7 +3,6 @@
3
3
  // until #390. Beside `pwa-artifacts.ts` and not inside it: that file needs a root and a config
4
4
  // file, this one needs a booted app and a finished build.
5
5
 
6
- import { localeConfig, routedLocales } from '@ultimat3/i18n';
7
6
  import type { PrecacheManifest } from '@ultimat3/pwa';
8
7
  import { generateServiceWorker } from '@ultimat3/pwa';
9
8
  import type { RouteDescriptor } from '@ultimat3/render';
@@ -12,7 +11,7 @@ import { ISLAND_BASE_PATH } from './island-bundle';
12
11
  import { msg } from './messages';
13
12
  import type { PwaArtifacts } from './pwa-artifacts';
14
13
  import type { StyleBundle } from './style-bundle';
15
- import type { RenderedDocument, RoutedLocales } from './sw-precache-plan';
14
+ import type { RenderedDocument } from './sw-precache-plan';
16
15
  import { localePrefixes, precacheAssets, pwaRoutes } from './sw-precache-plan';
17
16
 
18
17
  export type { RenderedDocument, RoutedLocales } from './sw-precache-plan';
@@ -73,11 +72,6 @@ export interface ServiceWorkerInput {
73
72
  * offline reload that cannot load the boot restores no record and shows the old count.
74
73
  */
75
74
  readonly scripts?: readonly { readonly url: string; readonly bytes: number }[];
76
- /**
77
- * The routed locales, default first. Absent, `@ultimat3/i18n`'s own — the answer the server routes
78
- * with, set once the app's catalogs are loaded, which every caller has done by now.
79
- */
80
- readonly locales?: RoutedLocales;
81
75
  }
82
76
 
83
77
  /**
@@ -157,7 +151,9 @@ export function serviceWorkerArtifacts(
157
151
  // Absent when this pass did not render the fallback (no route serves it — `x doctor` reports
158
152
  // that as `X_PWA_NO_OFFLINE_FALLBACK`), and then `@ultimat3/pwa` falls back to the build id.
159
153
  const fallbackDocument = documents.get(fallback);
160
- const locales = input.locales ?? { routed: routedLocales(), fallback: localeConfig().fallback };
154
+ // The set the manifests were built from (`PwaArtifacts.locales`), never the ambient locale
155
+ // config: one answer, so a locale an installed manifest names is always one the worker prefixes.
156
+ const locales = pwa.locales;
161
157
  const output = generateServiceWorker(
162
158
  pwaRoutes(input.routes, documents, locales),
163
159
  {
@@ -187,6 +183,10 @@ export function serviceWorkerArtifacts(
187
183
  styles: input.styles,
188
184
  scripts: input.scripts ?? [],
189
185
  fallback,
186
+ placeholders: {
187
+ urls: [pwa.offline.image, pwa.offline.font].filter((url): url is string => url !== null),
188
+ buildId: input.buildId,
189
+ },
190
190
  }),
191
191
  },
192
192
  input.buildId,
@@ -7,6 +7,7 @@ import type { PrecacheAsset, PwaRoute } from '@ultimat3/pwa';
7
7
  import { strategyFor } from '@ultimat3/pwa';
8
8
  import type { RouteDescriptor } from '@ultimat3/render';
9
9
  import type { IslandBundle } from './island-bundle';
10
+ import { parseHashedAssetUrl } from './site-assets';
10
11
  import type { StyleBundle } from './style-bundle';
11
12
 
12
13
  /**
@@ -94,6 +95,12 @@ export interface PrecacheAssetInput {
94
95
  readonly scripts: readonly { readonly url: string; readonly bytes: number }[];
95
96
  /** The offline document's path: its islands ride the precache whatever its own route says. */
96
97
  readonly fallback: string;
98
+ /**
99
+ * `pwa.offline.image` / `pwa.offline.font`: the worker answers a failed image or font request
100
+ * from the PRECACHE only, so a placeholder nothing precached is a branch that matches nothing.
101
+ * `buildId` is the revision of one whose bytes this build cannot hash — the offline document's.
102
+ */
103
+ readonly placeholders?: { readonly urls: readonly string[]; readonly buildId: string };
97
104
  }
98
105
 
99
106
  /**
@@ -140,12 +147,35 @@ export function precacheAssets(input: PrecacheAssetInput): readonly PrecacheAsse
140
147
  for (const chunk of input.islands.chunks) {
141
148
  if (wanted.has(chunk.url)) for (const url of chunk.imports) wanted.add(url);
142
149
  }
143
- return [
150
+ const chunks: PrecacheAsset[] = [
144
151
  ...input.islands.chunks.filter((chunk) => wanted.has(chunk.url)),
145
152
  ...input.islands.shared.filter((chunk) => wanted.has(chunk.url)),
146
153
  ...input.styles.chunks,
147
154
  ...input.scripts,
148
- ]
149
- .map((chunk) => ({ url: chunk.url, revision: chunk.url, bytes: chunk.bytes }))
150
- .sort((a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : 0));
155
+ ].map((chunk) => ({ url: chunk.url, revision: chunk.url, bytes: chunk.bytes }));
156
+ return [...chunks, ...placeholderAssets(input, new Set(chunks.map((chunk) => chunk.url)))].sort(
157
+ (a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : 0),
158
+ );
159
+ }
160
+
161
+ /**
162
+ * The placeholders on the offline document's terms (`buildPrecacheManifest`'s `fallback` entry):
163
+ * never refused here — this pass cannot see the site assets, and the worker installs entry by
164
+ * entry, so a URL that 404s costs that entry and the branch falls through to the 503 — and
165
+ * stamped with the build id unless the URL is content-addressed, which is its own revision. Bytes
166
+ * unknown count 0, as the offline document's do. A chunk already listed keeps its own entry.
167
+ */
168
+ function placeholderAssets(
169
+ input: PrecacheAssetInput,
170
+ listed: ReadonlySet<string>,
171
+ ): PrecacheAsset[] {
172
+ const out = new Map<string, PrecacheAsset>();
173
+ const placeholders = input.placeholders;
174
+ if (placeholders === undefined) return [];
175
+ for (const url of placeholders.urls) {
176
+ if (listed.has(url) || out.has(url)) continue;
177
+ const hashed = parseHashedAssetUrl(url) !== undefined;
178
+ out.set(url, { url, revision: hashed ? url : placeholders.buildId, bytes: 0 });
179
+ }
180
+ return [...out.values()];
151
181
  }
@@ -9,8 +9,8 @@ import { finiteCount, systemClock } from '@ultimat3/core';
9
9
  import type { HttpConfig } from '@ultimat3/http';
10
10
  import {
11
11
  configuredAuthenticator,
12
- createRequestContext,
13
12
  defineHttpConfig,
13
+ requestContext,
14
14
  UltimateRequest,
15
15
  } from '@ultimat3/http';
16
16
  import type { SyncAuthenticator, SyncGrant } from '@ultimat3/realtime/server';
@@ -84,7 +84,7 @@ export interface SyncAuthenticatorOptions {
84
84
  /**
85
85
  * What the sync node is given when the app configured an authenticator, and `undefined` when it
86
86
  * did not — which keeps `x dev` anonymous and makes the node log that it is, exactly as
87
- * `createSyncNode` documents. A stub that answered `{ actor: anonymous }` would look configured.
87
+ * `syncNode` documents. A stub that answered `{ actor: anonymous }` would look configured.
88
88
  *
89
89
  * The grant carries an `expiresAt` and a `refresh`, and both are the app's own resolver asked
90
90
  * again: `configureAuthenticator` says who is dialling, and the only honest way to learn that it
@@ -115,7 +115,7 @@ export function syncAuthenticator(
115
115
  method: credential.method,
116
116
  headers: credential.headers,
117
117
  });
118
- const ctx = createRequestContext({
118
+ const ctx = requestContext({
119
119
  url: new URL(credential.url),
120
120
  method: credential.method,
121
121
  role: 'sync',
package/src/sync-url.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  import { ConfigInvalidError } from '@ultimat3/core';
6
6
 
7
7
  /**
8
- * The sync node's own path (`createSyncNode`'s default in `@ultimat3/realtime`). Same origin by
8
+ * The sync node's own path (`syncNode`'s default in `@ultimat3/realtime`). Same origin by
9
9
  * default because every rung already serves it there: `x dev` and a combined-role container mount
10
10
  * the node on the web port, and `docker/helm`'s ingress routes `/_x/sync` to the `sync` service.
11
11
  */
@@ -154,7 +154,7 @@ const input = { id, orgId${isMutator ? ", title: 'a title'" : ''} };
154
154
 
155
155
  // Named here because every projection needs a stable name and this file does not boot the app.
156
156
  // At boot \`registerActions(await import('./actions'))\` stamps the same name onto the same
157
- // object, so \`${name.camel}.tool()\` works there with nothing to remember.
157
+ // object, so each projection of it works there with nothing to remember.
158
158
  const target = ${name.camel}.named('${name.camel}');
159
159
  ${
160
160
  outsider
@@ -234,7 +234,7 @@ ${
234
234
  ? `
235
235
  unitTest('the handler answers the row it found', async () => {
236
236
  const draft = { orgId, title: 'kept', price: { minor: 0, currency: 'USD' } };
237
- const row = await runWithContext(createContext({ actor: writer }), () => repo.insert(draft));
237
+ const row = await runWithContext(ctxOf({ actor: writer }), () => repo.insert(draft));
238
238
  const answer = await target.as(writer, ${call});
239
239
  expect(answer).toEqual(${found});
240
240
  });
@@ -263,7 +263,7 @@ const actionUnitTest = (name: NameSet, feature: NameSet, shape: HandlerShape): s
263
263
  return `// ${name.camel}: its declared shape, the input it refuses, and what its handler answers — in
264
264
  // process, so all three belong to the \`unit\` step. The contract projections are next door.
265
265
  ${sortedImports([
266
- ...(stores ? ["import { createContext, runWithContext } from '@ultimat3/core';"] : []),
266
+ ...(stores ? ["import { ctxOf, runWithContext } from '@ultimat3/core';"] : []),
267
267
  "import { testActor } from '@ultimat3/policy';",
268
268
  `import { ${testing} } from '@ultimat3/testing';`,
269
269
  ...(stores ? [`import { driver } from '${shape.dbModule}';`] : []),
@@ -276,7 +276,7 @@ const input = { id, orgId${shape.isMutator ? ", title: 'a title'" : ''} };
276
276
  ${shape.isMutator ? `\n// The client store's table: the entity's own, so the twin and the server row share one key.\nconst TABLE = '${feature.table}';\n` : ''}
277
277
  // Named here because every projection needs a stable name and this file does not boot the app.
278
278
  // At boot \`registerActions(await import('./actions'))\` stamps the same name onto the same
279
- // object, so \`${name.camel}.tool()\` works there with nothing to remember.
279
+ // object, so each projection of it works there with nothing to remember.
280
280
  const target = ${name.camel}.named('${name.camel}');
281
281
 
282
282
  // Holds the grant in the org the input names: past the policy, so what answers is the handler.
@@ -107,14 +107,14 @@ const backfillTest = (
107
107
  ): string => `// ${name.camel}'s durable identity: one live run per name, retried under the same key. The work
108
108
  // itself — the pass over real rows and the projection it applies — is the unit suite's, next door.
109
109
 
110
- import { createMemoryDriver, resetJobDriver, setJobDriver } from '@ultimat3/jobs';
110
+ import { memoryJobDriver, resetJobDriver, setJobDriver } from '@ultimat3/jobs';
111
111
  import { afterAll, beforeAll, expect, jobTest } from '@ultimat3/testing';
112
112
  import { ${name.camel} } from './${name.kebab}';
113
113
 
114
114
  // The driver is process-global, so it is installed and released around this file rather than
115
115
  // left behind for whichever test happens to run next.
116
116
  beforeAll(() => {
117
- setJobDriver(createMemoryDriver());
117
+ setJobDriver(memoryJobDriver());
118
118
  });
119
119
  afterAll(resetJobDriver);
120
120
 
@@ -162,7 +162,7 @@ const backfillUnitTest = (
162
162
  // driver. A sweep rewrites rows no user asked it to, so what is worth failing on is that it reaches
163
163
  // every tenant's rows, writes the projection and nothing else, and that a replay changes nothing.
164
164
  ${sortedImports([
165
- "import { createContext, runWithContext } from '@ultimat3/core';",
165
+ "import { ctxOf, runWithContext } from '@ultimat3/core';",
166
166
  "import { testActor } from '@ultimat3/policy';",
167
167
  "import { afterEach, expect, unitTest } from '@ultimat3/testing';",
168
168
  `import { db, driver } from '${dbModule}';`,
@@ -175,7 +175,7 @@ const orgB = '00000000-0000-4000-8000-000000000009';
175
175
 
176
176
  /** What a request is to the handle: an actor, whose org every read and write runs under. */
177
177
  const inOrg = <T>(orgId: string, run: () => Promise<T>): Promise<T> =>
178
- runWithContext(createContext({ actor: testActor('member', { orgId }).actor }), run);
178
+ runWithContext(ctxOf({ actor: testActor('member', { orgId }).actor }), run);
179
179
 
180
180
  const store = (orgId: string, title: string): Promise<${feature.pascal}> => {
181
181
  const draft = { orgId, title, price: { minor: 1000, currency: 'USD' } };
@@ -156,7 +156,7 @@ const repoTest = (
156
156
  ): string => `// The ${name.kebab} repo against the in-memory driver: the contract Postgres serves, with no
157
157
  // database. What it pins is what the repo cannot show by being read — whose rows a call reaches.
158
158
  ${sortedImports([
159
- "import { createContext, frozenClock, runWithContext } from '@ultimat3/core';",
159
+ "import { ctxOf, frozenClock, runWithContext } from '@ultimat3/core';",
160
160
  "import { testActor } from '@ultimat3/policy';",
161
161
  "import { afterEach, expect, unitTest } from '@ultimat3/testing';",
162
162
  `import { driver } from '${dbModule}';`,
@@ -171,7 +171,7 @@ const clock = frozenClock('2026-01-01T00:00:00.000Z');
171
171
 
172
172
  /** What a request is to the handle: an actor, whose org every read and write runs under. */
173
173
  const inOrg = <T>(org: string, run: () => Promise<T>): Promise<T> =>
174
- runWithContext(createContext({ actor: testActor('member', { orgId: org }).actor, clock }), run);
174
+ runWithContext(ctxOf({ actor: testActor('member', { orgId: org }).actor, clock }), run);
175
175
 
176
176
  const draft = (title: string) => ({ orgId, title, price: { minor: 1200, currency: 'USD' } });
177
177