@ultimat3/cli 23.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 (296) hide show
  1. package/CLAUDE.md +28 -13
  2. package/README.md +32 -7
  3. package/package.json +29 -29
  4. package/src/admin-catalog-keys.ts +2 -2
  5. package/src/affected.ts +30 -36
  6. package/src/api-registration.ts +89 -0
  7. package/src/app-artifacts.ts +1 -1
  8. package/src/app-auth.ts +4 -27
  9. package/src/app-boundaries.ts +17 -4
  10. package/src/app-config-load.ts +51 -0
  11. package/src/app-env.ts +26 -25
  12. package/src/app-load.ts +38 -14
  13. package/src/app-manifest.ts +4 -10
  14. package/src/app-mcp.ts +31 -46
  15. package/src/app-name.ts +52 -0
  16. package/src/app-permissions-borrowed.ts +123 -0
  17. package/src/app-permissions-site.ts +48 -0
  18. package/src/app-permissions.ts +7 -17
  19. package/src/app-root.ts +9 -5
  20. package/src/bin.ts +3 -7
  21. package/src/browser-launcher.ts +4 -2
  22. package/src/budgets-charged.ts +60 -0
  23. package/src/budgets-scan.ts +76 -0
  24. package/src/budgets-unmeasured.ts +91 -0
  25. package/src/budgets.ts +117 -154
  26. package/src/cdp-shot-driver.ts +20 -1
  27. package/src/cdp-shot-watch.ts +35 -31
  28. package/src/ci-log.ts +0 -0
  29. package/src/cmd-auth-spec.ts +16 -0
  30. package/src/cmd-auth.ts +65 -0
  31. package/src/cmd-build-spec.ts +7 -1
  32. package/src/cmd-build.ts +151 -22
  33. package/src/cmd-db-backfill.ts +26 -13
  34. package/src/cmd-db-branch.ts +2 -2
  35. package/src/cmd-db.ts +10 -6
  36. package/src/cmd-deploy-compose.ts +94 -0
  37. package/src/cmd-deploy-drain.ts +48 -0
  38. package/src/cmd-deploy-helm.ts +7 -6
  39. package/src/cmd-deploy-start-first.ts +178 -0
  40. package/src/cmd-deploy.ts +158 -36
  41. package/src/cmd-dev.ts +18 -356
  42. package/src/cmd-doctor-spec.ts +2 -9
  43. package/src/cmd-doctor.ts +57 -80
  44. package/src/cmd-env.ts +55 -16
  45. package/src/cmd-generate.ts +89 -32
  46. package/src/cmd-help.ts +19 -6
  47. package/src/cmd-i18n.ts +21 -17
  48. package/src/cmd-jobs-spec.ts +7 -22
  49. package/src/cmd-jobs.ts +33 -140
  50. package/src/cmd-manifest-spec.ts +1 -1
  51. package/src/cmd-manifest.ts +6 -25
  52. package/src/cmd-mcp.ts +71 -22
  53. package/src/cmd-new.ts +36 -8
  54. package/src/cmd-planned.ts +24 -8
  55. package/src/cmd-routes.ts +15 -5
  56. package/src/cmd-secrets.ts +47 -6
  57. package/src/cmd-shot-matrix.ts +22 -7
  58. package/src/cmd-shot-spec.ts +10 -2
  59. package/src/cmd-shot.ts +46 -41
  60. package/src/cmd-tasks.ts +2 -2
  61. package/src/cmd-test-spec.ts +1 -7
  62. package/src/cmd-test.ts +14 -30
  63. package/src/cmd-verify.ts +4 -4
  64. package/src/coverage-lcov.ts +4 -2
  65. package/src/db-backfill.ts +2 -2
  66. package/src/db-replay-engine.ts +4 -10
  67. package/src/db-subscribes.ts +27 -16
  68. package/src/dev-boot.ts +290 -0
  69. package/src/dev-child-watch.ts +63 -12
  70. package/src/dev-dashboard-guard.ts +76 -0
  71. package/src/dev-dashboard.ts +56 -30
  72. package/src/dev-lock.ts +100 -31
  73. package/src/dev-rebuild.ts +80 -0
  74. package/src/dev-restart-retire.ts +46 -0
  75. package/src/dev-route-table.ts +30 -8
  76. package/src/dev-supervisor.ts +114 -22
  77. package/src/dev-traces.ts +53 -20
  78. package/src/dispatch.ts +17 -13
  79. package/src/doctor-app-url.ts +39 -0
  80. package/src/doctor-auth.ts +95 -0
  81. package/src/doctor-offline.ts +6 -5
  82. package/src/document-options.ts +7 -0
  83. package/src/drift-replica-identity.ts +42 -0
  84. package/src/drift.ts +29 -6
  85. package/src/error-catalog.ts +6 -9
  86. package/src/error-codes.ts +29 -3
  87. package/src/error-page-csp.ts +14 -15
  88. package/src/error-unthrown.ts +29 -11
  89. package/src/errors.ts +54 -83
  90. package/src/exec.ts +8 -0
  91. package/src/favicon.ts +2 -2
  92. package/src/fix-command.ts +9 -1
  93. package/src/framework-env.ts +148 -0
  94. package/src/framework-schema-apply.ts +78 -0
  95. package/src/framework-schema-stamp.ts +96 -0
  96. package/src/framework-schema.ts +25 -3
  97. package/src/generate-catalog-locales.ts +79 -0
  98. package/src/generate-files.ts +4 -3
  99. package/src/generate-kinds.ts +18 -0
  100. package/src/generate-plural.ts +50 -0
  101. package/src/generate-shadow.ts +95 -0
  102. package/src/generate-write.ts +47 -13
  103. package/src/gh-target.ts +12 -7
  104. package/src/hold.ts +56 -1
  105. package/src/i18n-audit.ts +25 -9
  106. package/src/i18n-index.ts +5 -6
  107. package/src/i18n-registration.ts +7 -9
  108. package/src/import-scan.ts +28 -3
  109. package/src/index.ts +2 -6
  110. package/src/island-bundle.ts +57 -79
  111. package/src/island-capture.ts +1 -1
  112. package/src/island-duplicate-refusal.ts +57 -0
  113. package/src/island-duplicates.ts +221 -0
  114. package/src/island-entry-missing.ts +64 -0
  115. package/src/island-harness-route.ts +20 -4
  116. package/src/island-harness-script.ts +23 -2
  117. package/src/island-identity.ts +17 -14
  118. package/src/island-package-dedupe.ts +150 -0
  119. package/src/island-realtime.ts +36 -16
  120. package/src/island-routes.ts +2 -2
  121. package/src/island-runtime.ts +241 -0
  122. package/src/island-sources.ts +105 -0
  123. package/src/island-states-load.ts +6 -0
  124. package/src/island-store.ts +90 -6
  125. package/src/jobs-json.ts +0 -26
  126. package/src/jobs-report.ts +36 -20
  127. package/src/local-cli-handoff.ts +90 -0
  128. package/src/loopback-host.ts +13 -0
  129. package/src/manifest-staleness.ts +62 -0
  130. package/src/mcp-errors.ts +20 -4
  131. package/src/mcp-host.ts +22 -14
  132. package/src/mcp-test-run.ts +38 -0
  133. package/src/mcp-ui-inspect.ts +3 -2
  134. package/src/mcp-ui-interact.ts +3 -2
  135. package/src/mcp-ui.ts +8 -7
  136. package/src/measure-scope.ts +15 -10
  137. package/src/messages.ts +29 -16
  138. package/src/metrics-endpoint.ts +28 -3
  139. package/src/migration-extensions.ts +1 -1
  140. package/src/otlp-export.ts +2 -2
  141. package/src/output.ts +24 -0
  142. package/src/page-navigation.ts +7 -23
  143. package/src/page-speculation.ts +19 -31
  144. package/src/parse.ts +0 -8
  145. package/src/permission-grants.ts +3 -3
  146. package/src/posix-path.ts +30 -0
  147. package/src/prerender-locales.ts +6 -4
  148. package/src/prerender-out.ts +34 -7
  149. package/src/prerender-report.ts +58 -0
  150. package/src/prerender-served.ts +52 -0
  151. package/src/prerender.ts +71 -103
  152. package/src/pwa-artifacts.ts +33 -94
  153. package/src/pwa-manifest-locales.ts +9 -9
  154. package/src/registry.ts +2 -0
  155. package/src/retry-memo.ts +1 -1
  156. package/src/revalidated-response.ts +4 -3
  157. package/src/role-realtime.ts +7 -1
  158. package/src/role-replicator.ts +124 -29
  159. package/src/role-start.ts +69 -85
  160. package/src/role-sync.ts +119 -25
  161. package/src/root-env.ts +157 -28
  162. package/src/route-islands.ts +69 -0
  163. package/src/runtime-admin.ts +4 -1
  164. package/src/runtime-assets.ts +26 -9
  165. package/src/runtime-bindings.ts +27 -7
  166. package/src/runtime-cache.ts +46 -55
  167. package/src/runtime-hooks.ts +2 -1
  168. package/src/runtime-idempotency-scope.ts +23 -0
  169. package/src/runtime-isr-outcome.ts +100 -0
  170. package/src/runtime-isr.ts +36 -0
  171. package/src/runtime-jobs.ts +76 -41
  172. package/src/runtime-live-feed.ts +1 -1
  173. package/src/runtime-mail.ts +26 -0
  174. package/src/runtime-mfa-warning.ts +29 -0
  175. package/src/runtime-notify-retention.ts +10 -46
  176. package/src/runtime-overrides.ts +4 -4
  177. package/src/runtime-purge.ts +38 -16
  178. package/src/runtime-queue.ts +43 -22
  179. package/src/runtime-realtime.ts +11 -45
  180. package/src/runtime-render.ts +136 -114
  181. package/src/runtime-replica.ts +3 -8
  182. package/src/runtime-services.ts +49 -26
  183. package/src/runtime-storage-upload.ts +2 -2
  184. package/src/runtime-storage.ts +13 -8
  185. package/src/schema-diff.ts +16 -0
  186. package/src/schema-drift.ts +45 -15
  187. package/src/secrets-rotation.ts +8 -9
  188. package/src/seo-routes.ts +80 -9
  189. package/src/serve-boot.ts +51 -12
  190. package/src/serve-drain.ts +11 -33
  191. package/src/serve-env.ts +9 -6
  192. package/src/serve-retire.ts +95 -0
  193. package/src/serve-web.ts +31 -15
  194. package/src/serve.ts +27 -10
  195. package/src/server-barrels.ts +6 -3
  196. package/src/shot-browser.ts +1 -1
  197. package/src/shot-locale.ts +6 -33
  198. package/src/shot-settle.ts +3 -3
  199. package/src/shot-theme.ts +1 -1
  200. package/src/shot-viewport.ts +152 -0
  201. package/src/signal-shred.ts +26 -12
  202. package/src/site-asset-routes.ts +2 -2
  203. package/src/site-assets.ts +3 -5
  204. package/src/site-config.ts +9 -48
  205. package/src/site-seo.ts +7 -5
  206. package/src/sitemap-lastmod.ts +5 -0
  207. package/src/solid-loader.ts +12 -3
  208. package/src/source-files.ts +19 -3
  209. package/src/static-report.ts +5 -0
  210. package/src/stored-object-headers.ts +43 -0
  211. package/src/style-routes.ts +2 -2
  212. package/src/sw-artifacts.ts +8 -8
  213. package/src/sw-precache-plan.ts +34 -4
  214. package/src/sync-authenticator.ts +3 -3
  215. package/src/sync-url.ts +3 -3
  216. package/src/tasks-facts.ts +3 -29
  217. package/src/templates/action.ts +7 -4
  218. package/src/templates/backfill.ts +4 -4
  219. package/src/templates/entity.ts +2 -2
  220. package/src/templates/github/ci.yml.ts +28 -18
  221. package/src/templates/github/image.yml.ts +92 -0
  222. package/src/templates/github/runner.ts +18 -0
  223. package/src/templates/guard-bare-error.ts +16 -2
  224. package/src/templates/guard-unzoned-date.ts +56 -6
  225. package/src/templates/index.ts +1 -1
  226. package/src/templates/job-body-test.ts +2 -2
  227. package/src/templates/job.ts +6 -6
  228. package/src/templates/naming.ts +28 -0
  229. package/src/templates/query.ts +9 -8
  230. package/src/templates/resource-page.ts +9 -7
  231. package/src/templates/resource-service.ts +2 -2
  232. package/src/templates/route.ts +26 -14
  233. package/src/templates/scaffold-api.ts +2 -3
  234. package/src/templates/scaffold-app.ts +8 -3
  235. package/src/templates/scaffold-auth.ts +205 -70
  236. package/src/templates/scaffold-bin.ts +122 -0
  237. package/src/templates/scaffold-claude-commands.ts +11 -11
  238. package/src/templates/scaffold-container-compose.ts +10 -1
  239. package/src/templates/scaffold-container.ts +16 -8
  240. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  241. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  242. package/src/templates/scaffold-dashboard-example.ts +112 -172
  243. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  244. package/src/templates/scaffold-dashboard.ts +9 -5
  245. package/src/templates/scaffold-db-client.ts +4 -2
  246. package/src/templates/scaffold-db-package.ts +2 -2
  247. package/src/templates/scaffold-docs.ts +20 -77
  248. package/src/templates/scaffold-domain-package.ts +0 -4
  249. package/src/templates/scaffold-entries.ts +4 -3
  250. package/src/templates/scaffold-env.ts +6 -3
  251. package/src/templates/scaffold-gitattributes.ts +59 -0
  252. package/src/templates/scaffold-helm-fences.ts +76 -0
  253. package/src/templates/scaffold-helm-templates.ts +100 -19
  254. package/src/templates/scaffold-helm.ts +62 -5
  255. package/src/templates/scaffold-http.ts +1 -1
  256. package/src/templates/scaffold-i18n.ts +15 -2
  257. package/src/templates/scaffold-icon.ts +2 -2
  258. package/src/templates/scaffold-repo.ts +27 -16
  259. package/src/templates/scaffold-shell.ts +55 -37
  260. package/src/test-counts.ts +2 -6
  261. package/src/test-ignores.ts +25 -0
  262. package/src/test-only-shipped.ts +193 -0
  263. package/src/test-passes.ts +0 -15
  264. package/src/test-select.ts +7 -15
  265. package/src/test-shards.ts +40 -95
  266. package/src/test-workers.ts +26 -4
  267. package/src/theme-boot.ts +8 -33
  268. package/src/theme-brand.ts +66 -0
  269. package/src/trusted-hops-error.ts +17 -0
  270. package/src/trusted-hops.ts +49 -0
  271. package/src/unscanned-admin.ts +2 -2
  272. package/src/unthrown-codes.ts +26 -0
  273. package/src/verify-checks.ts +29 -48
  274. package/src/verify-coverage-run.ts +3 -2
  275. package/src/verify-deadline.ts +207 -33
  276. package/src/verify-merge.ts +108 -43
  277. package/src/verify-part-step.ts +98 -0
  278. package/src/verify-role-load-probe.ts +12 -2
  279. package/src/verify-role-load.ts +9 -4
  280. package/src/verify-run.ts +32 -8
  281. package/src/verify-shard.ts +13 -2
  282. package/src/verify-stalled.ts +152 -0
  283. package/src/verify-step.ts +2 -0
  284. package/src/verify-test-run.ts +3 -2
  285. package/src/verify-tests.ts +5 -3
  286. package/src/worker-bundle.ts +2 -2
  287. package/src/workspace-checks.ts +53 -7
  288. package/src/workspace-graph.ts +3 -2
  289. package/src/write-line.ts +82 -7
  290. package/src/browser-launcher-fake-html.ts +0 -80
  291. package/src/browser-launcher-fake.ts +0 -168
  292. package/src/build-errors.ts +0 -54
  293. package/src/jobs-drain.ts +0 -163
  294. package/src/scaffold-typecheck.ts +0 -227
  295. package/src/thrown-by.ts +0 -24
  296. package/src/verify-errors.ts +0 -58
@@ -5,6 +5,10 @@
5
5
  // 100% CPU, measured at ~50 MB/s of heap growth). Found on a laptop, 2026-09-29: two children of
6
6
  // `cmd-dev-restart.live.test.ts`, six hours old, ppid 1, cwd deleted, one at 7 GB.
7
7
  //
8
+ // Both facts and the stop itself hold on Windows: the supervisor is asked for by pid
9
+ // (`process.kill(pid, 0)`), since a Windows child's ppid never changes, and a stop arrives as a
10
+ // `drain` message over IPC — `kill('SIGTERM')` there is TerminateProcess, with no drain at all.
11
+ //
8
12
  // And a stop that does not finish is finished for it. A drain is bounded by core's deadline, but a
9
13
  // loop starved by a spin like the one above reaches its timers late or never, so every stop this
10
14
  // child begins — a signal, the watch below, a restart — carries a hard exit past that bound.
@@ -29,8 +33,8 @@ export interface DevChildWatchInput {
29
33
  readonly env: Readonly<Record<string, string | undefined>>;
30
34
  /** Called once, when the child decides to leave. */
31
35
  readonly leave: (why: DevChildGone) => void;
32
- /** Test seams: the defaults read the process and the disk. */
33
- readonly parentPid?: () => number;
36
+ /** Test seams: the defaults read the process table and the disk. */
37
+ readonly supervisorAlive?: (pid: number) => boolean;
34
38
  readonly rootExists?: (root: string) => boolean;
35
39
  readonly intervalMs?: number;
36
40
  }
@@ -43,21 +47,46 @@ export function supervisorPid(
43
47
  return Number.isInteger(said) && said > 0 ? said : undefined;
44
48
  }
45
49
 
50
+ /** `process.kill(pid, 0)`'s answer: delivered, or refused for permission — both mean it exists. */
51
+ export function pidAlive(
52
+ pid: number,
53
+ kill: (pid: number, signal: 0) => void = process.kill,
54
+ ): boolean {
55
+ try {
56
+ kill(pid, 0);
57
+ return true;
58
+ } catch (error) {
59
+ return (error as { readonly code?: unknown } | null)?.code === 'EPERM';
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Whether the supervisor at `pid` still runs this child. POSIX asks the ppid — immune to a reused
65
+ * pid, and a supervisor that died before the first tick is still caught, since the child is
66
+ * reparented. Windows never reparents (the ppid stays the dead supervisor's forever), so there the
67
+ * process table is asked directly.
68
+ */
69
+ export function defaultSupervisorAlive(
70
+ platform: string = process.platform,
71
+ ): (pid: number) => boolean {
72
+ return platform === 'win32' ? (pid) => pidAlive(pid) : (pid) => process.ppid === pid;
73
+ }
74
+
46
75
  /**
47
- * Polls both facts until one fails, then calls `leave` once and stops. Answers the stop.
48
- * `process.ppid` rather than a pipe from the supervisor: stdin is the terminal's (`inherit`), and
49
- * a supervisor that died before the first tick is still caught — its pid is never ours again.
76
+ * Polls both facts until one fails, then calls `leave` once and stops. Answers the stop. Polled
77
+ * rather than a pipe from the supervisor: stdin is the terminal's (`inherit`), and IPC closing is
78
+ * not a fact Bun reports to the child.
50
79
  */
51
80
  export function watchDevChild(input: DevChildWatchInput): () => void {
52
81
  const expected = supervisorPid(input.env);
53
- const parent = input.parentPid ?? (() => process.ppid);
82
+ const alive = input.supervisorAlive ?? defaultSupervisorAlive();
54
83
  const exists = input.rootExists ?? existsSync;
55
84
  let left = false;
56
85
  const timer = setInterval(
57
86
  () => {
58
87
  if (left) return;
59
88
  const why: DevChildGone | undefined =
60
- expected !== undefined && parent() !== expected
89
+ expected !== undefined && !alive(expected)
61
90
  ? 'supervisor'
62
91
  : exists(input.root)
63
92
  ? undefined
@@ -116,10 +145,26 @@ export function hardExit(
116
145
  };
117
146
  }
118
147
 
148
+ /** What the supervisor sends for a stop: the signal it would have forwarded on POSIX. */
149
+ export interface DevDrainMessage {
150
+ readonly type: 'x-dev-drain';
151
+ readonly signal: 'SIGINT' | 'SIGTERM';
152
+ }
153
+
154
+ export function isDevDrainMessage(message: unknown): message is DevDrainMessage {
155
+ if (typeof message !== 'object' || message === null) return false;
156
+ const { type, signal } = message as Record<string, unknown>;
157
+ return type === 'x-dev-drain' && (signal === 'SIGINT' || signal === 'SIGTERM');
158
+ }
159
+
160
+ /** The exit code a stop for `signal` answers: 128 + its number, as a shell reports it. */
161
+ export const signalExitCode = (signal: 'SIGINT' | 'SIGTERM'): number =>
162
+ signal === 'SIGINT' ? 130 : 143;
163
+
119
164
  /**
120
- * The child's watch, wired to core's drain: the supervisor's death or the root's, and SIGINT or
121
- * SIGTERM, each begin a stop that ends within `devHardExitMs` however the drain fares. `onGone`
122
- * marks why, so the hold's exit can answer a code.
165
+ * The child's watch, wired to core's drain: the supervisor's death or the root's, SIGINT or
166
+ * SIGTERM, and the supervisor's `drain` message each begin a stop that ends within
167
+ * `devHardExitMs` however the drain fares. `onGone` marks why, so the hold's exit can answer a code.
123
168
  */
124
169
  export function startDevChildWatch(
125
170
  root: string,
@@ -128,8 +173,14 @@ export function startDevChildWatch(
128
173
  ): { readonly stopping: (code: number) => void } {
129
174
  const stopping = hardExit((code) => process.exit(code));
130
175
  // Beside core's own handlers, never instead of them: the drain is theirs, the deadline is this.
131
- process.on('SIGINT', () => stopping(130));
132
- process.on('SIGTERM', () => stopping(143));
176
+ process.on('SIGINT', () => stopping(signalExitCode('SIGINT')));
177
+ process.on('SIGTERM', () => stopping(signalExitCode('SIGTERM')));
178
+ // The supervisor's stop, on every platform: the same drain a signal begins, by message.
179
+ process.on('message', (message: unknown) => {
180
+ if (!isDevDrainMessage(message)) return;
181
+ stopping(signalExitCode(message.signal));
182
+ void drain(message.signal);
183
+ });
133
184
  watchDevChild({
134
185
  root,
135
186
  env,
@@ -0,0 +1,76 @@
1
+ // The doors in front of `/_x` that a browser can knock on. `x dev` binds loopback, but a page the
2
+ // developer has open can still reach it: by DNS rebinding (a hostile name that resolves to
3
+ // 127.0.0.1, read back same-origin) and by a cross-site request to the SQL panel.
4
+
5
+ import { proveSameOrigin, renderFixShellArg, UltimateError } from '@ultimat3/core';
6
+ import type { UltimateRequest } from '@ultimat3/http';
7
+ import { csrfBlocked, HttpError, jsonResponse, selfOrigin } from '@ultimat3/http';
8
+ import { t } from '@ultimat3/schema';
9
+ import { isLoopbackHostname } from './loopback-host';
10
+
11
+ /** A `/_x` request named a Host that is not this machine. Never echoes the Host it was sent. */
12
+ export class DevHostRefusedError extends UltimateError {
13
+ constructor(input: { devUrl: string }) {
14
+ super({
15
+ code: 'X_DEV_HOST_REFUSED',
16
+ cause:
17
+ 'a /_x request named a Host that is not localhost, a *.localhost name or a loopback address — the shape of a DNS-rebinding page reading the dev dashboard',
18
+ fix: `curl -sS -H 'accept: application/json' ${renderFixShellArg(`${input.devUrl}/_x`, 'http://localhost:3000/_x')}`,
19
+ });
20
+ }
21
+ }
22
+
23
+ /** The Host as sent, else the one the URL was built from — Bun builds it from the header. */
24
+ const hostnameOf = (request: UltimateRequest): string | undefined => {
25
+ const header = request.header('host') ?? request.url.host;
26
+ return URL.parse(`http://${header}`)?.hostname;
27
+ };
28
+
29
+ /**
30
+ * `421 Misdirected Request` for a Host that is not this machine, on every `/_x` route. The status
31
+ * says "not this server", which is the truth: a rebound name was never meant to reach it.
32
+ */
33
+ export function hostRefusal(request: UltimateRequest, devUrl: string): Response | undefined {
34
+ const hostname = hostnameOf(request);
35
+ if (hostname !== undefined && isLoopbackHostname(hostname)) return undefined;
36
+ const error = new DevHostRefusedError({ devUrl });
37
+ return jsonResponse(
38
+ { ok: false, error: { code: error.code, cause: error.cause, fix: error.fix } },
39
+ { status: 421 },
40
+ );
41
+ }
42
+
43
+ /**
44
+ * A statement runs on a POST that came from this origin — core's `proveSameOrigin`, the rule the
45
+ * app's own writes and the sync upgrade answer to, asked HERE rather than left to the pipeline's
46
+ * CSRF stage: an app with `csrf: { mode: 'off' }` must not switch the dev database's door off with
47
+ * it. Neither header present is `curl`, as `checkCsrf` reads it: no page can drive that client.
48
+ */
49
+ export function assertSameOrigin(request: UltimateRequest): void {
50
+ const origin = request.header('origin');
51
+ const secFetchSite = request.header('sec-fetch-site');
52
+ if (origin === null && secFetchSite === null) return;
53
+ const verdict = proveSameOrigin({
54
+ selfOrigins: [selfOrigin(request.url, request.ctx.https)],
55
+ origin,
56
+ secFetchSite,
57
+ listed: () => false,
58
+ listName: 'no list — /_x admits its own origin only',
59
+ });
60
+ if (!verdict.ok) throw csrfBlocked(request.pathname, verdict.reason, request.ctx.ip);
61
+ }
62
+
63
+ /** The SQL panel's one input, from a form or a JSON body — `request.body` parses either. */
64
+ export const SQL_BODY = t.object({ sql: t.string });
65
+
66
+ /**
67
+ * A statement on the query string is refused rather than run: a GET is what `<img src>` and a
68
+ * prefetch send cross-site with nobody's consent. The fix is the POST that does run it.
69
+ */
70
+ export const sqlOnGet = (devUrl: string): HttpError =>
71
+ new HttpError({
72
+ code: 'X_METHOD_NOT_ALLOWED',
73
+ cause:
74
+ '/_x/db runs a statement on a same-origin POST only — a GET carrying ?sql= is what a cross-site page can send',
75
+ fix: `curl -sS -H 'accept: application/json' --data-urlencode 'sql=select 1' ${renderFixShellArg(`${devUrl}/_x/db`, 'http://localhost:3000/_x/db')}`,
76
+ });
@@ -1,6 +1,6 @@
1
1
  // Mounting `@ultimat3/admin`'s `/_x` dashboard in the `x dev` process. The CLI contributes only
2
- // what no registry holds — a SQL runner on the live dev database, the caught outbox, the committed
3
- // manifest, and two panels of process facts — and projects the dashboard onto HTTP routes.
2
+ // what no registry holds — the live dev database, the caught outbox, the committed manifest, and
3
+ // two panels of process facts — and projects the dashboard onto HTTP routes behind a Host check.
4
4
  // A panel implemented here instead of in `admin` would be the second copy this seam exists to ban.
5
5
 
6
6
  import type {
@@ -14,16 +14,23 @@ import type {
14
14
  SqlResult,
15
15
  StatementLoopFact,
16
16
  } from '@ultimat3/admin/dev';
17
- import { DEV_BASE_PATH, DEV_PANELS, defaultDevSources, devDashboard } from '@ultimat3/admin/dev';
17
+ import {
18
+ DEV_BASE_PATH,
19
+ DEV_PANELS,
20
+ defaultDevSources,
21
+ devDashboard,
22
+ readOnlySql,
23
+ } from '@ultimat3/admin/dev';
18
24
  import { recentInvalidations } from '@ultimat3/cache';
19
25
  import type { Role } from '@ultimat3/core';
20
26
  import type { Route, UltimateRequest } from '@ultimat3/http';
21
- import { json as jsonResponse } from '@ultimat3/http';
27
+ import { jsonResponse } from '@ultimat3/http';
22
28
  import type { MemoryMailDriver } from '@ultimat3/mail';
23
29
  import { isMemoryDriver } from '@ultimat3/mail';
24
30
  import type { Manifest } from '@ultimat3/manifest';
25
31
  import { appManifest, readAppManifest } from './app-manifest';
26
32
  import { appBoundaryFindings } from './boundary-findings';
33
+ import { assertSameOrigin, hostRefusal, SQL_BODY, sqlOnGet } from './dev-dashboard-guard';
27
34
  import type { StatementLedger } from './dev-n-plus-one';
28
35
  import { devPolicyMatrix } from './dev-policy';
29
36
  import type { TraceRecorder } from './dev-traces';
@@ -31,6 +38,7 @@ import type { Finding } from './output';
31
38
  import type { DevServices } from './runtime-bindings';
32
39
  import type { RunningServices } from './runtime-services';
33
40
  import { loopFacts } from './statement-loop';
41
+ import { styleBundle } from './style-bundle';
34
42
 
35
43
  export interface DevStatus {
36
44
  readonly url: string;
@@ -53,24 +61,6 @@ export interface DevDashboardInput {
53
61
  readonly statements?: StatementLedger | undefined;
54
62
  }
55
63
 
56
- /**
57
- * Read-only is already enforced by `assertReadOnly` inside `dbPanel`, before `runSql` is ever
58
- * reached. A second gate here would be a second authz: two places to update when what the panel
59
- * allows changes, and one of them would eventually disagree.
60
- */
61
- async function runSql(input: DevDashboardInput, sql: string): Promise<SqlResult> {
62
- const started = performance.now();
63
- const rows = await input.runtime.db.query<Readonly<Record<string, unknown>>>({
64
- text: sql,
65
- values: [],
66
- });
67
- const elapsedMs = Math.round(performance.now() - started);
68
- // Columns come from the first row because the driver returns objects, not a described result
69
- // set; no rows means no columns to name, which the panel renders as an empty grid.
70
- const columns = Object.keys(rows[0] ?? {});
71
- return { columns, rows: rows.map((row) => columns.map((column) => row[column])), elapsedMs };
72
- }
73
-
74
64
  /** `MailMessage.locale` is non-optional in `@ultimat3/mail`, so the panel never has to guess. */
75
65
  function mailFacts(outbox: MemoryMailDriver): readonly MailFact[] {
76
66
  return outbox.outbox().map((entry) => ({
@@ -137,9 +127,13 @@ export function devSources(input: DevDashboardInput): DevSources {
137
127
  // the messages are at the provider, so the hook is omitted rather than answered with `[]` —
138
128
  // an empty outbox claims nobody was mailed, which is a different and unearned answer.
139
129
  const outbox = isMemoryDriver(input.runtime.mail) ? input.runtime.mail : undefined;
130
+ // `dbPanel`'s `assertReadOnly` is the parse guard; `readOnlySql` is the server saying no — its own
131
+ // `BEGIN READ ONLY` that always rolls back, a statement timeout and a row ceiling. The plain
132
+ // `db.query` it replaces ran whatever the guard let through with the app's own credentials.
133
+ const runSql = readOnlySql(input.runtime.db);
140
134
  return defaultDevSources({
141
135
  hooks: {
142
- runSql: (sql: string): Promise<SqlResult> => runSql(input, sql),
136
+ runSql: (sql: string): Promise<SqlResult> => runSql(sql),
143
137
  ...(outbox === undefined
144
138
  ? {}
145
139
  : { mail: (): Promise<readonly MailFact[]> => Promise.resolve(mailFacts(outbox)) }),
@@ -222,8 +216,13 @@ const notClaimed = (path: string): Response =>
222
216
  { status: 404 },
223
217
  );
224
218
 
225
- const devRoute = (path: string, name: string, handler: Route['handler']): Route => ({
226
- method: 'GET',
219
+ const devRoute = (
220
+ path: string,
221
+ name: string,
222
+ handler: Route['handler'],
223
+ method: 'GET' | 'POST' = 'GET',
224
+ ): Route => ({
225
+ method,
227
226
  path,
228
227
  // Public: /_x exists to be read without credentials by whatever drives the dev loop, and
229
228
  // `devDashboard` refuses to construct at all outside development.
@@ -232,9 +231,10 @@ const devRoute = (path: string, name: string, handler: Route['handler']): Route
232
231
  });
233
232
 
234
233
  /**
235
- * One route for the base path plus one per panel, because the router matches exact paths. The
236
- * dashboard is built once — its sources close over this process, and rebuilding per request would
237
- * re-run `assertDevOnly` on every hit for no new answer.
234
+ * One route for the base path plus one per panel, because the router matches exact paths, and a
235
+ * `POST` for the SQL panel — the only `/_x` read that runs something. The dashboard is built once
236
+ * — its sources close over this process, and rebuilding per request would re-run `assertDevOnly`
237
+ * on every hit for no new answer. Every route answers this machine's Host only.
238
238
  */
239
239
  export function devDashboardRoutes(input: DevDashboardInput): readonly Route[] {
240
240
  const panels = devPanels(input);
@@ -242,16 +242,42 @@ export function devDashboardRoutes(input: DevDashboardInput): readonly Route[] {
242
242
  basePath: DEV_BASE_PATH,
243
243
  panels,
244
244
  sources: devSources(input),
245
+ // The drawn tabs render ui's components, whose rules ride the `app/` surface's bundle — the
246
+ // same URL an `app/` document links, asked per request because a rebuild moves the hash.
247
+ stylesheetHref: () => styleBundle().hrefFor('app'),
245
248
  ...(input.env === undefined ? {} : { env: input.env }),
246
249
  });
247
250
 
248
- const handler = async (request: UltimateRequest): Promise<Response> =>
249
- (await dashboard.handle(request.raw)) ?? notClaimed(request.pathname);
251
+ const sqlPath = `${DEV_BASE_PATH}/db`;
252
+ const handler = async (request: UltimateRequest): Promise<Response> => {
253
+ const devUrl = input.status().url;
254
+ const refused = hostRefusal(request, devUrl);
255
+ if (refused !== undefined) return refused;
256
+ if (request.pathname === sqlPath && request.url.searchParams.has('sql')) throw sqlOnGet(devUrl);
257
+ return (await dashboard.handle(request.raw)) ?? notClaimed(request.pathname);
258
+ };
259
+ // The statement moves from the body onto the panel's own parameter, server side: `dbPanel`
260
+ // reads `sql` off the query, and this is the one caller allowed to put it there.
261
+ const runStatement = async (request: UltimateRequest): Promise<Response> => {
262
+ const refused = hostRefusal(request, input.status().url);
263
+ if (refused !== undefined) return refused;
264
+ assertSameOrigin(request);
265
+ const { sql } = await request.body(SQL_BODY);
266
+ const target = new URL(request.url);
267
+ target.searchParams.set('sql', sql);
268
+ // A GET carries no body, so the body's own headers stay behind; `accept` and the rest ride.
269
+ const headers = new Headers(request.headers);
270
+ headers.delete('content-type');
271
+ headers.delete('content-length');
272
+ const forwarded = new Request(target, { headers });
273
+ return (await dashboard.handle(forwarded)) ?? notClaimed(request.pathname);
274
+ };
250
275
 
251
276
  return [
252
277
  devRoute(DEV_BASE_PATH, 'dev._x', handler),
253
278
  ...panels.map((panel) =>
254
279
  devRoute(`${DEV_BASE_PATH}/${panel.key}`, `dev._x.${panel.key}`, handler),
255
280
  ),
281
+ devRoute(sqlPath, 'dev._x.db.run', runStatement, 'POST'),
256
282
  ];
257
283
  }
package/src/dev-lock.ts CHANGED
@@ -14,10 +14,11 @@
14
14
  // The lock file is what makes the second one nameable at all: nothing else in the process can tell
15
15
  // "another dev server owns this directory" from "the database is broken".
16
16
 
17
- import { closeSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
17
+ import { linkSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
18
18
  import { join } from 'node:path';
19
19
  import { stringField, UltimateError } from '@ultimat3/core';
20
20
  import { exec, type Runner } from './exec';
21
+ import { portPairAfter } from './flag-number';
21
22
  import { quoteArg } from './shell-quote';
22
23
 
23
24
  /** Where the running dev server records itself, inside the state directory it already owns. */
@@ -141,6 +142,26 @@ export class DevLockUnreadableError extends UltimateError {
141
142
  }
142
143
  }
143
144
 
145
+ /**
146
+ * The state directory refused the claim itself: it could not be created, the lock could not be
147
+ * staged in it, or the filesystem would not hard-link the staged file into place. None of that is
148
+ * another `x dev` — only `EEXIST` is — and escaping raw it reached the operator as
149
+ * `X_CLI_UNEXPECTED` with `fix: x doctor --json`, which says nothing about `.x/`.
150
+ */
151
+ export class DevStateUnwritableError extends UltimateError {
152
+ constructor(input: { readonly stateDir: string; readonly error: unknown }) {
153
+ // Read the field, never cast and dereference: `unknown` is what a catch hands over.
154
+ const errno = stringField(input.error, 'code') ?? 'an unreported errno';
155
+ super({
156
+ code: 'X_DEV_STATE_UNWRITABLE',
157
+ cause: `${input.stateDir} could not be created, written, or hard-linked into (${errno}), so x dev cannot take its lock — a read-only checkout, a .x/ this user may not write, or a filesystem without hard links`,
158
+ fix: `ls -ld ${quoteArg(input.stateDir)} # make it writable by this user, on a filesystem that supports hard links, then re-run x dev`,
159
+ meta: { stateDir: input.stateDir, errno },
160
+ sourceError: input.error,
161
+ });
162
+ }
163
+ }
164
+
144
165
  /** Whatever is listening, as far as the OS will say. Both fields absent when it will not say. */
145
166
  export interface PortHolder {
146
167
  readonly pid?: number;
@@ -150,7 +171,8 @@ export interface PortHolder {
150
171
 
151
172
  /**
152
173
  * Who holds the port. `ss` first because it is present on every Linux box the framework targets and
153
- * needs no elevation for your own processes; `lsof` is the macOS answer.
174
+ * needs no elevation for your own processes; `lsof` is the macOS answer; `netstat -ano` plus
175
+ * `tasklist` the Windows one.
154
176
  *
155
177
  * Through `exec.ts`, like every other subprocess the CLI runs, so a test injects a fake `Runner`
156
178
  * rather than racing a real socket.
@@ -158,7 +180,7 @@ export interface PortHolder {
158
180
  * EVERY PROBE IS CAUGHT SEPARATELY. `exec` refuses a missing program with `X_CLI_UNEXPECTED`, and
159
181
  * letting that escape would mean a box without `ss` gets the CLI's catch-all instead of
160
182
  * `X_PORT_IN_USE` — the exact substitution this module exists to end. A missing `ss` must fall
161
- * through to `lsof`, and a box with neither still gets the refusal, just without a pid in it.
183
+ * through to `lsof`, and a box with none still gets the refusal, just without a pid in it.
162
184
  */
163
185
  export const portHolder = async (port: number, runner: Runner = exec): Promise<PortHolder> => {
164
186
  const probe = async (command: readonly string[]): Promise<string> => {
@@ -184,9 +206,38 @@ export const portHolder = async (port: number, runner: Runner = exec): Promise<P
184
206
  if (pid !== undefined && /^\d+$/.test(pid)) {
185
207
  return command === undefined ? { pid: Number(pid) } : { command, pid: Number(pid) };
186
208
  }
187
- return {};
209
+
210
+ // Windows has neither: `netstat -ano` names the listening pid, `tasklist` its image.
211
+ const listener = netstatListener(await probe(['netstat', '-ano', '-p', 'TCP']), port);
212
+ if (listener === undefined) return {};
213
+ const image = tasklistImage(
214
+ await probe(['tasklist', '/FI', `PID eq ${String(listener)}`, '/FO', 'CSV', '/NH']),
215
+ );
216
+ return image === undefined ? { pid: listener } : { command: image, pid: listener };
188
217
  };
189
218
 
219
+ /**
220
+ * The pid `netstat -ano` reports LISTENING on `port`, any local address: `TCP 127.0.0.1:3000
221
+ * 0.0.0.0:0 LISTENING 4242`, and `[::1]:3000` for IPv6. The state column is the word Windows
222
+ * prints in English on every locale this was read on; an unrecognised line is skipped.
223
+ */
224
+ export function netstatListener(text: string, port: number): number | undefined {
225
+ for (const line of text.split(/\r?\n/)) {
226
+ const [proto, local, , state, pid] = line.trim().split(/\s+/);
227
+ if (proto !== 'TCP' || state !== 'LISTENING' || pid === undefined || !/^\d+$/.test(pid)) {
228
+ continue;
229
+ }
230
+ if (local?.endsWith(`:${String(port)}`) === true) return Number(pid);
231
+ }
232
+ return undefined;
233
+ }
234
+
235
+ /** The image name off `tasklist /FO CSV /NH`'s one row — `"bun.exe","4242",…` — or `undefined`. */
236
+ export function tasklistImage(text: string): string | undefined {
237
+ const name = /^"([^"]+)","\d+"/m.exec(text.trim())?.[1];
238
+ return name === undefined || name === '' ? undefined : name;
239
+ }
240
+
190
241
  /**
191
242
  * The port is bound by something that is not us. Named separately from the lock case because the
192
243
  * remedy is different: a stranger's port is worked around with `--port`, while a second `x dev` on
@@ -220,12 +271,6 @@ export class DevPortInUseError extends UltimateError {
220
271
  }
221
272
  }
222
273
 
223
- /**
224
- * The next port to suggest. Never `port + 1` at the top of the range — 65536 is not a port, and a
225
- * `fix:` that cannot run is the failure this whole module exists to end.
226
- */
227
- export const suggestPort = (port: number): number => (port >= 65535 ? port - 1 : port + 1);
228
-
229
274
  /**
230
275
  * Is anything listening? A successful bind-then-close is the only answer that does not lie.
231
276
  *
@@ -246,33 +291,46 @@ export const isPortBound = (port: number, hostname: string): boolean => {
246
291
  }
247
292
  };
248
293
 
294
+ /** A sibling of the lock, unique to this process and call, where its bytes are staged. */
295
+ const stagingPath = (path: string): string =>
296
+ `${path}.${process.pid}.${crypto.randomUUID().slice(0, 8)}.tmp`;
297
+
298
+ const lockText = (lock: DevLock): string => `${JSON.stringify(lock, null, 2)}\n`;
299
+
249
300
  /**
250
301
  * Take the lock, or answer `false` because someone else holds it.
251
302
  *
252
- * `wx` is the whole mechanism: the create and the exclusivity are ONE syscall, so two boots racing
253
- * this cannot both come back `true`. A check followed by a write is what this replaces, and the
254
- * window between those two was seconds wide — `startDev` boots embedded Postgres, the queue, the
255
- * transport and the app's modules before anything was written down.
303
+ * Written whole to a staging file first, then `linkSync`ed into place: the link is the create AND
304
+ * the exclusivity in ONE syscall (`EEXIST` when the name is taken), so two boots racing this cannot
305
+ * both come back `true` — and the lock path never exists without its contents. It was `openSync(
306
+ * path, 'wx')` and THEN a write; a racing preflight that read the file between the two parsed an
307
+ * empty lock as stale, unlinked the live claim and took the slot itself.
256
308
  *
257
- * Only `EEXIST` is "someone else has it". Anything else — a read-only checkout, a `.x/` nobody may
258
- * write — is rethrown as it arrives: it is the same failure `writeLock` would have raised seconds
259
- * later, and inventing a code for it here would be a second answer to one condition.
309
+ * Only `EEXIST` is "someone else has it". Any other refusal of the staging write or the link — a
310
+ * `.x/` this user may not write, a filesystem without hard links — is `X_DEV_STATE_UNWRITABLE`
311
+ * naming the directory and the errno, the original kept as its `sourceError`.
260
312
  */
261
- function claimExclusive(path: string, lock: DevLock): boolean {
262
- let fd: number;
313
+ function claimExclusive(stateDir: string, path: string, lock: DevLock): boolean {
314
+ const staged = stagingPath(path);
263
315
  try {
264
- fd = openSync(path, 'wx');
316
+ writeFileSync(staged, lockText(lock));
265
317
  } catch (error) {
266
- // Same rule as `isProcessAlive` above: read the field, never cast and dereference.
267
- if (stringField(error, 'code') === 'EEXIST') return false;
268
- throw error;
318
+ throw new DevStateUnwritableError({ stateDir, error });
269
319
  }
270
320
  try {
271
- writeFileSync(fd, `${JSON.stringify(lock, null, 2)}\n`);
321
+ linkSync(staged, path);
322
+ return true;
323
+ } catch (error) {
324
+ // Same rule as `isProcessAlive` above: read the field, never cast and dereference.
325
+ if (stringField(error, 'code') === 'EEXIST') return false;
326
+ throw new DevStateUnwritableError({ stateDir, error });
272
327
  } finally {
273
- closeSync(fd);
328
+ try {
329
+ unlinkSync(staged);
330
+ } catch {
331
+ // Already gone; the staging name is unique to this call, so nothing else can need it.
332
+ }
274
333
  }
275
- return true;
276
334
  }
277
335
 
278
336
  /** Whatever is on disk right now, or `null` if it is absent or half-written. */
@@ -326,7 +384,11 @@ export const preflight = async (input: PreflightInput): Promise<PreflightResult>
326
384
  const release = (): void => clearLock(input.stateDir);
327
385
  // The lock lives inside the state directory, which the database has not created yet — this runs
328
386
  // before anything boots, which is the whole point of it.
329
- mkdirSync(input.stateDir, { recursive: true });
387
+ try {
388
+ mkdirSync(input.stateDir, { recursive: true });
389
+ } catch (error) {
390
+ throw new DevStateUnwritableError({ stateDir: input.stateDir, error });
391
+ }
330
392
  const mine: DevLock = {
331
393
  pid: process.pid,
332
394
  port: input.port,
@@ -337,7 +399,7 @@ export const preflight = async (input: PreflightInput): Promise<PreflightResult>
337
399
  };
338
400
  let clearedStale = false;
339
401
 
340
- if (!claimExclusive(path, mine)) {
402
+ if (!claimExclusive(input.stateDir, path, mine)) {
341
403
  const held = await readLock(path);
342
404
  if (held !== null && alive(held.pid)) {
343
405
  throw new DevAlreadyRunningError({
@@ -354,7 +416,7 @@ export const preflight = async (input: PreflightInput): Promise<PreflightResult>
354
416
  // Already gone, or not ours to remove. The claim below is what decides.
355
417
  }
356
418
  clearedStale = true;
357
- if (!claimExclusive(path, mine)) {
419
+ if (!claimExclusive(input.stateDir, path, mine)) {
358
420
  // Lost the race for the slot we just cleared. `held` is the best identity available — a
359
421
  // half-written file parses as `null` for microseconds — and refusing on a stale pid beats
360
422
  // the alternative, which is two processes writing one single-writer data directory.
@@ -379,7 +441,9 @@ export const preflight = async (input: PreflightInput): Promise<PreflightResult>
379
441
  release();
380
442
  throw new DevPortInUseError({
381
443
  port: input.port,
382
- suggestion: suggestPort(input.port),
444
+ // `portPairAfter`, never `port + 1`: `x dev` binds a PAIR, so the holder is most often
445
+ // another `x dev` whose sync role already sits on `port + 1`.
446
+ suggestion: portPairAfter(input.port),
383
447
  holder: await (input.holder ?? portHolder)(input.port),
384
448
  });
385
449
  }
@@ -391,7 +455,12 @@ export const preflight = async (input: PreflightInput): Promise<PreflightResult>
391
455
  * with the address the server actually bound, which is the one field the preflight could not know.
392
456
  */
393
457
  export const writeLock = async (stateDir: string, lock: DevLock): Promise<void> => {
394
- await Bun.write(lockPath(stateDir), `${JSON.stringify(lock, null, 2)}\n`);
458
+ // Staged and renamed over the claim, never rewritten in place: a truncate-then-write leaves an
459
+ // empty lock for an instant, and a preflight reading it then would clear a live claim as stale.
460
+ const path = lockPath(stateDir);
461
+ const staged = stagingPath(path);
462
+ writeFileSync(staged, lockText(lock));
463
+ renameSync(staged, path);
395
464
  };
396
465
 
397
466
  /** Remove it. Safe to call twice — shutdown paths overlap, and a throw here would mask the real one. */
@@ -0,0 +1,80 @@
1
+ // The `x dev` reload: one save in, the manifest, the findings and the island bundles rebuilt as
2
+ // one generation, the ISR store emptied, and a save this process cannot serve handed to the
3
+ // supervisor as a restart. Coalesced, so a tick arriving mid-build is one trailing rebuild.
4
+
5
+ import type { Manifest } from '@ultimat3/manifest';
6
+ import type { IsrController } from '@ultimat3/render/server';
7
+ import { appManifest } from './app-manifest';
8
+ import type { StalePin } from './app-reload-graph';
9
+ import { takeStalePins } from './app-reload-graph';
10
+ import type { ReloadTrigger } from './dev-reload';
11
+ import { coalesceReloads } from './dev-reload';
12
+ import { restartFinding } from './dev-supervisor';
13
+ import type { IslandBundle } from './island-bundle';
14
+ import { buildIslands } from './island-bundle';
15
+ import type { Finding } from './output';
16
+ import { findingFrom } from './output';
17
+
18
+ export interface DevState {
19
+ manifest: Manifest;
20
+ reloads: number;
21
+ /** A save that will not build. Replaced on every attempt, so a fixed file clears it. */
22
+ reloadFinding: Finding | undefined;
23
+ /**
24
+ * Modules that would not import and primitives that would not register, as of the LAST scan —
25
+ * the boot's, then each rebuild's. A page saved with a syntax error is a finding here until the
26
+ * save that fixes it, and the boot's list alone would never show it.
27
+ */
28
+ appFindings: readonly Finding[];
29
+ /**
30
+ * The client entries, rebuilt on the same tick as the manifest. An island is the one module this
31
+ * process never imports, so a fresh `Bun.build` is the whole of its reload. The route module
32
+ * beside it is re-imported by that same scan (`app-load.ts`) — the two are one generation, or
33
+ * a save serves a new island under an old page, which is what it did until 2026-09-07.
34
+ */
35
+ islands: IslandBundle;
36
+ }
37
+
38
+ /** What a rebuild reports to, read off `StartDevOptions`. */
39
+ export interface DevRebuildOptions {
40
+ readonly root: string;
41
+ readonly onReload?: (file: string, durationMs: number) => void;
42
+ readonly onRestart?: (pins: readonly StalePin[]) => void;
43
+ }
44
+
45
+ /** The watcher's `onChange`: rebuild `state` for the saved `file`, never rejecting. */
46
+ export function devRebuilder(
47
+ options: DevRebuildOptions,
48
+ state: DevState,
49
+ isr: IsrController,
50
+ ): ReloadTrigger {
51
+ // One rebuild at a time, and the last save wins: a tick arriving mid-build coalesces into ONE
52
+ // trailing rebuild instead of racing the one in flight for `state.manifest` and `state.islands`.
53
+ return coalesceReloads(
54
+ async (file) => {
55
+ const started = performance.now();
56
+ const [{ manifest, findings }, islands] = await Promise.all([
57
+ appManifest(options.root),
58
+ buildIslands(options.root),
59
+ ]);
60
+ state.manifest = manifest;
61
+ state.appFindings = findings;
62
+ state.islands = islands;
63
+ for (const path of isr.store().paths()) isr.store().delete(path);
64
+ state.reloads += 1;
65
+ // Read on EVERY rebuild, so a pin one save reached is never reported against the next save.
66
+ const pins = takeStalePins();
67
+ state.reloadFinding =
68
+ pins.length > 0 && options.onRestart === undefined
69
+ ? restartFinding(options.root, pins)
70
+ : undefined;
71
+ if (pins.length > 0 && options.onRestart !== undefined) options.onRestart(pins);
72
+ else options.onReload?.(file, Math.round(performance.now() - started));
73
+ },
74
+ // Same rule as a module that will not import: a save the manifest cannot be rebuilt from is
75
+ // a finding on `/_x`, never an unhandled rejection that takes the dev server down.
76
+ (error: unknown, file: string) => {
77
+ state.reloadFinding = { ...findingFrom(error), at: file };
78
+ },
79
+ );
80
+ }