@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
@@ -0,0 +1,241 @@
1
+ // The page runtime as ONE chunk beside the islands (#505): realtime's `page-runtime.ts` — the store,
2
+ // the socket stack, the query client, core's transport — built once per build, addressed by its
3
+ // source graph like any island, and loaded by a realtime island only on a document the page boot
4
+ // is absent from. Islands carry thin hooks over it; none carries a copy of it.
5
+
6
+ // why: Bun ships no path API; the runtime is resolved beside the realtime the islands bundle.
7
+ import { dirname, join } from 'node:path';
8
+ import type { BunPlugin } from 'bun';
9
+ import { IslandBuildFailedError } from './errors';
10
+ import type { IslandChunk, SharedChunk } from './island-bundle';
11
+ import { realSpelling } from './island-duplicates';
12
+ import { describeBuildError, graphHash, stableChunk, stripDebugId } from './island-identity';
13
+ import { frameworkDedupePlugins } from './island-package-dedupe';
14
+ import type { SourcePaths } from './island-sources';
15
+ import { sourcesOnDisk } from './island-sources';
16
+
17
+ /** The runtime, as realtime exports it — resolved from the copy every realtime island bundles. */
18
+ const RUNTIME_SPECIFIER = '@ultimat3/realtime/page-runtime';
19
+
20
+ /** What an island awaits before its first hook: realtime's own, from the same copy. */
21
+ const WAIT_SPECIFIER = '@ultimat3/realtime/page-runtime-wait';
22
+
23
+ /** The virtual entry the runtime is built from, and the module it installs from. */
24
+ const ENTRY = 'ultimate:realtime-runtime';
25
+ const MODULE = 'ultimate:realtime-runtime-module';
26
+ const NAMESPACE = 'ultimate-realtime-runtime';
27
+
28
+ /**
29
+ * `page-runtime.<identity>.js`. The dot is the point: an island entry is `<moduleId>-<identity>.js`
30
+ * and a module id is `[a-z0-9-]` only (`islandModuleId`), so no island — not even one named
31
+ * `realtime.island.tsx` — can be served at a URL this name could be.
32
+ */
33
+ const RUNTIME_CHUNK_FILE = /^page-runtime\.[0-9a-z]+\.js$/;
34
+
35
+ /** The relative specifier a realtime island's bootstrap `import()`s the runtime chunk by. */
36
+ export const RUNTIME_CHUNK_SPECIFIER = /^\.\/page-runtime\.[0-9a-z]+\.js$/;
37
+
38
+ /** Whether `url` (any base path) is the page runtime's chunk — the one predicate, never a prefix. */
39
+ export function isRuntimeChunk(url: string): boolean {
40
+ const slash = url.lastIndexOf('/');
41
+ return slash !== -1 && RUNTIME_CHUNK_FILE.test(url.slice(slash + 1));
42
+ }
43
+
44
+ /** The page runtime a realtime island loads — built once per `buildIslands` call. */
45
+ export type Runtimes = (island: string) => Promise<RuntimeChunk>;
46
+
47
+ /** One runtime chunk: served beside the islands, never a `data-x-entry` of its own. */
48
+ export interface RuntimeChunk {
49
+ /**
50
+ * The realtime barrel it was built against — the ONE copy every realtime island of the build
51
+ * bundles its hooks from, or the page object the runtime installs is not the shape they read.
52
+ */
53
+ readonly realtime: string;
54
+ /** What an island's bootstrap awaits the runtime with: `page-runtime-wait.ts`, of that copy. */
55
+ readonly wait: string;
56
+ readonly url: string;
57
+ readonly code: string;
58
+ readonly bytes: number;
59
+ readonly sources: SourcePaths;
60
+ }
61
+
62
+ /**
63
+ * Builds the runtime at most once per build: every realtime island of one `buildIslands` call loads
64
+ * the SAME chunk, whether the islands are built together or one by one. The first island's realtime
65
+ * copy is the build's; another copy at the SAME version is the same code and folds onto it (a
66
+ * Windows `file:` install copies a package where a symlink would fold it). Another VERSION is
67
+ * refused: the first runtime to install wins the page, so that copy's islands would call services
68
+ * of a shape they were not built against.
69
+ */
70
+ export function runtimeBuilder(root: string, basePath: string): Runtimes {
71
+ let first: { readonly copy: RealtimeCopy; readonly chunk: Promise<RuntimeChunk> } | undefined;
72
+ return async (island: string): Promise<RuntimeChunk> => {
73
+ // From the island's own directory: the resolution the island itself gets — by its REAL path,
74
+ // the spelling the framework dedupe gives every other `@ultimat3/*` file. Windows' resolver
75
+ // answers a junction's spelling, and Bun keys a module by its path string, so the barrel's own
76
+ // relative imports would ship again beside the realpath ones (`island-duplicates.ts`).
77
+ const copy = await realtimeCopy(
78
+ island,
79
+ realSpelling(Bun.resolveSync('@ultimat3/realtime', dirname(join(root, island)))),
80
+ );
81
+ if (first === undefined) {
82
+ first = { copy, chunk: buildRuntime(root, island, copy.barrel, basePath) };
83
+ } else if (first.copy.version !== copy.version) {
84
+ throw new IslandBuildFailedError({
85
+ file: island,
86
+ logs: `it resolves @ultimat3/realtime ${copy.version} (${copy.dir}) while another island of this build resolves ${first.copy.version} (${first.copy.dir}): one page runs ONE runtime, and @ultimat3/realtime is released in lockstep, so install one version of it across the apps`,
87
+ });
88
+ }
89
+ return first.chunk;
90
+ };
91
+ }
92
+
93
+ interface RealtimeCopy {
94
+ /** The barrel the island resolved. */
95
+ readonly barrel: string;
96
+ /** The package's directory, for the refusal. */
97
+ readonly dir: string;
98
+ readonly version: string;
99
+ }
100
+
101
+ /** The installed package the barrel belongs to: the nearest `package.json` above it. */
102
+ async function realtimeCopy(island: string, barrel: string): Promise<RealtimeCopy> {
103
+ for (let dir = dirname(barrel); dir !== dirname(dir); dir = dirname(dir)) {
104
+ const manifest = Bun.file(join(dir, 'package.json'));
105
+ if (!(await manifest.exists())) continue;
106
+ const parsed: unknown = await manifest.json();
107
+ const version =
108
+ typeof parsed === 'object' && parsed !== null ? Reflect.get(parsed, 'version') : undefined;
109
+ return { barrel, dir, version: typeof version === 'string' ? version : 'an unversioned copy' };
110
+ }
111
+ throw new IslandBuildFailedError({
112
+ file: island,
113
+ logs: `the @ultimat3/realtime it resolves (${barrel}) has no package.json above it, so its version cannot be checked against the build's`,
114
+ });
115
+ }
116
+
117
+ /** A subpath of the realtime at `realtime`, through its `exports` — never a path into its source. */
118
+ function exported(island: string, realtime: string, specifier: string): string {
119
+ try {
120
+ return realSpelling(Bun.resolveSync(specifier, dirname(realtime)));
121
+ } catch {
122
+ throw new IslandBuildFailedError({
123
+ file: island,
124
+ logs: `the realtime it resolves (${realtime}) exports no ${specifier}: @ultimat3/realtime and @ultimat3/cli are released in lockstep, so install the same version of both`,
125
+ });
126
+ }
127
+ }
128
+
129
+ async function buildRuntime(
130
+ root: string,
131
+ island: string,
132
+ realtime: string,
133
+ basePath: string,
134
+ ): Promise<RuntimeChunk> {
135
+ const file = exported(island, realtime, RUNTIME_SPECIFIER);
136
+ const wait = exported(island, realtime, WAIT_SPECIFIER);
137
+ let built: Awaited<ReturnType<typeof Bun.build>>;
138
+ try {
139
+ built = await Bun.build({
140
+ entrypoints: [ENTRY],
141
+ root,
142
+ target: 'browser',
143
+ format: 'esm',
144
+ minify: true,
145
+ plugins: [runtimeEntryPlugin(file), ...frameworkDedupePlugins(root)],
146
+ // `island-bundle.ts`'s reasons, verbatim: only ever built to be shipped, and the map's
147
+ // `sourcesContent` is the one stable identity a minified bundle has.
148
+ define: { 'process.env.NODE_ENV': '"production"' },
149
+ sourcemap: 'external',
150
+ });
151
+ } catch (error) {
152
+ throw new IslandBuildFailedError({ file: island, logs: describeBuildError(error) });
153
+ }
154
+ const output = built.outputs.find((artifact) => artifact.kind === 'entry-point');
155
+ const map = built.outputs.find((artifact) => artifact.kind === 'sourcemap');
156
+ if (!built.success || output === undefined || map === undefined) {
157
+ throw new IslandBuildFailedError({
158
+ file: island,
159
+ logs: built.logs.map((log) => String(log)).join('; '),
160
+ });
161
+ }
162
+ const text = await map.text();
163
+ const identity = graphHash(ENTRY, text);
164
+ const url = `${basePath}/page-runtime.${identity}.js`;
165
+ return {
166
+ realtime,
167
+ wait,
168
+ url,
169
+ ...stableChunk(url, identity, stripDebugId(await output.text())),
170
+ sources: await sourcesOnDisk(root, JSON.parse(text)),
171
+ };
172
+ }
173
+
174
+ /**
175
+ * The entry installs the runtime as its whole body; the module is named BARE in that source and
176
+ * resolved here, because the source is part of `sourcesContent` and a machine path in it would give
177
+ * one runtime a different URL in every checkout (the reason `island-realtime.ts` names realtime).
178
+ */
179
+ function runtimeEntryPlugin(file: string): BunPlugin {
180
+ return {
181
+ name: 'ultimate-realtime-runtime',
182
+ setup(build) {
183
+ build.onResolve({ filter: /^ultimate:realtime-runtime$/ }, () => ({
184
+ path: 'entry',
185
+ namespace: NAMESPACE,
186
+ }));
187
+ build.onResolve({ filter: /^ultimate:realtime-runtime-module$/ }, () => ({ path: file }));
188
+ build.onLoad({ filter: /.*/, namespace: NAMESPACE }, () => ({
189
+ contents: `import { installPageRuntime } from '${MODULE}';\ninstallPageRuntime();\n`,
190
+ loader: 'js',
191
+ }));
192
+ },
193
+ };
194
+ }
195
+
196
+ /** Every realtime entry names the runtime among what it loads: a precache and a budget count it. */
197
+ export function loadingRuntime(
198
+ chunks: readonly IslandChunk[],
199
+ live: readonly string[],
200
+ runtime: RuntimeChunk | undefined,
201
+ ): readonly IslandChunk[] {
202
+ if (runtime === undefined) return chunks;
203
+ return chunks.map((chunk) =>
204
+ live.includes(chunk.file)
205
+ ? { ...chunk, imports: [...new Set([...chunk.imports, runtime.url])].sort() }
206
+ : chunk,
207
+ );
208
+ }
209
+
210
+ /** The runtime as the bundle's shared asset — served, written and precached beside the entries. */
211
+ export function runtimeShared(
212
+ runtime: RuntimeChunk | undefined,
213
+ live: readonly string[],
214
+ ): readonly SharedChunk[] {
215
+ if (runtime === undefined) return [];
216
+ const { url, code, bytes, sources } = runtime;
217
+ return [{ url, code, bytes, importers: [...live].sort(), sources }];
218
+ }
219
+
220
+ /**
221
+ * One-island builds as one bundle's two tables: every entry, and each shared asset once by URL with
222
+ * its importers the union — each solo build names the runtime for itself.
223
+ */
224
+ export function mergeBuilds(
225
+ builds: readonly {
226
+ readonly chunks: readonly IslandChunk[];
227
+ readonly shared: readonly SharedChunk[];
228
+ }[],
229
+ ): [readonly IslandChunk[], readonly SharedChunk[]] {
230
+ const byUrl = new Map<string, SharedChunk>();
231
+ for (const one of builds.flatMap((build) => build.shared)) {
232
+ const seen = byUrl.get(one.url);
233
+ byUrl.set(
234
+ one.url,
235
+ seen === undefined
236
+ ? one
237
+ : { ...seen, importers: [...new Set([...seen.importers, ...one.importers])].sort() },
238
+ );
239
+ }
240
+ return [builds.flatMap((build) => build.chunks), [...byUrl.values()]];
241
+ }
@@ -0,0 +1,105 @@
1
+ // The files on disk an island chunk was built from, and whether they still hold those bytes. The
2
+ // prebuilt store (`island-store.ts`) verified only its own chunk bytes, so a source edited after
3
+ // `x build --target prebuilt` still verified and a boot served the chunk built from the old one.
4
+
5
+ // why: Bun ships no path join/resolve/relative primitive.
6
+ import { isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
7
+ import { contentHash } from '@ultimat3/render/server';
8
+ import { hasPathSegment } from './path-segments';
9
+
10
+ /**
11
+ * App-root-relative POSIX paths of the files an output was built from — its source map's
12
+ * `sources`, outside `node_modules` and inside the app root.
13
+ */
14
+ export type SourcePaths = readonly string[];
15
+
16
+ /**
17
+ * A path the store may record and a boot may read: app-root-relative, normalized, inside the
18
+ * root, outside `node_modules`. The writer filters on it and the reader REFUSES on it — the index is
19
+ * a file on disk, and a path read at every boot must never be `../../etc/hostname` or `/dev/zero`.
20
+ */
21
+ export function isRecordableSource(path: string): boolean {
22
+ return (
23
+ path !== '' &&
24
+ posix.normalize(path) === path &&
25
+ !path.startsWith('..') &&
26
+ !isAbsolute(path) &&
27
+ !path.includes('\\') &&
28
+ !hasPathSegment(path, 'node_modules')
29
+ );
30
+ }
31
+
32
+ /** One recorded source: its path and the content digest of its bytes when the store was written. */
33
+ export interface SourceStamp {
34
+ readonly path: string;
35
+ readonly digest: string;
36
+ }
37
+
38
+ /**
39
+ * A source map's `sources` that are files in the app. A virtual module (the realtime wrapper) has
40
+ * no file; `node_modules` changes only with an install, and hashing it would make every pod boot
41
+ * pay for the dependency tree.
42
+ */
43
+ export async function sourcesOnDisk(root: string, map: unknown): Promise<SourcePaths> {
44
+ const listed: unknown =
45
+ typeof map === 'object' && map !== null ? (map as Record<string, unknown>)['sources'] : [];
46
+ // Bun 1.4.2 writes each source relative to the BUILDING process's cwd, not to the map or to the
47
+ // build's `root` (measured: a build run from the repo root over a /tmp app names
48
+ // `../../tmp/…/plain.island.tsx`), so that is what they are resolved against.
49
+ const cwd = process.cwd();
50
+ const paths = (Array.isArray(listed) ? listed : [])
51
+ .filter((one): one is string => typeof one === 'string')
52
+ .map((one) => relative(root, resolve(cwd, one)).split(sep).join('/'))
53
+ .filter(isRecordableSource);
54
+ const present = await Promise.all(paths.map((one) => Bun.file(join(root, one)).exists()));
55
+ return [...new Set(paths.filter((_, index) => present[index]))].sort();
56
+ }
57
+
58
+ /** Each path with the content digest of its bytes now — `undefined` for a file that is gone. */
59
+ export type SourceDigester = (path: string) => Promise<string | undefined>;
60
+
61
+ /**
62
+ * A digester that reads each file once, however many chunks name it: a module shared by every
63
+ * island is one read per boot, not one per island.
64
+ */
65
+ export function sourceDigester(root: string): SourceDigester {
66
+ const seen = new Map<string, Promise<string | undefined>>();
67
+ return (path) => {
68
+ let digested = seen.get(path);
69
+ if (digested === undefined) {
70
+ const file = Bun.file(join(root, path));
71
+ digested = file
72
+ .exists()
73
+ .then(async (exists) => (exists ? contentHash(await file.text()) : undefined));
74
+ seen.set(path, digested);
75
+ }
76
+ return digested;
77
+ };
78
+ }
79
+
80
+ /** What the store records for one chunk's sources. */
81
+ export async function stampSources(
82
+ paths: SourcePaths,
83
+ digestOf: SourceDigester,
84
+ ): Promise<readonly SourceStamp[]> {
85
+ const stamps: SourceStamp[] = [];
86
+ for (const path of paths) {
87
+ const now = await digestOf(path);
88
+ if (now !== undefined) stamps.push({ path, digest: now });
89
+ }
90
+ return stamps;
91
+ }
92
+
93
+ /**
94
+ * The first recorded source that is gone or whose content digest moved, else `undefined`. A plain
95
+ * comparison: a digest of a source file the app commits is no secret, and nothing here is timed.
96
+ */
97
+ export async function changedSource(
98
+ stamps: readonly SourceStamp[],
99
+ digestOf: SourceDigester,
100
+ ): Promise<string | undefined> {
101
+ for (const stamp of stamps) {
102
+ if ((await digestOf(stamp.path)) !== stamp.digest) return stamp.path;
103
+ }
104
+ return undefined;
105
+ }
@@ -53,6 +53,12 @@ export async function discoverIslandStates(root: string): Promise<readonly strin
53
53
  async function manifestsIn(root: string, file: string): Promise<readonly IslandStatesManifest[]> {
54
54
  const absolute = join(root, file);
55
55
  assertIslandStatesPure(file, await Bun.file(absolute).text());
56
+ // Evicted first: Bun caches `import()` by path, and `x dev` loads the states on every shot
57
+ // request in one process — so an edited states file was never read again until a restart, and
58
+ // `app-load.ts`'s reload skips this file by name. The check above read the NEW text; this makes
59
+ // the import evaluate that same text. A states file imports no relative module (the purity rule
60
+ // refuses one), so the file itself is the whole of what can be stale.
61
+ delete require.cache[absolute];
56
62
  const module: unknown = await import(absolute);
57
63
  const exported = typeof module === 'object' && module !== null ? Object.values(module) : [];
58
64
  const found = exported.filter(isIslandStatesManifest);
@@ -10,8 +10,11 @@
10
10
  import { join, relative, sep } from 'node:path'; // why: Bun ships no path-join primitive.
11
11
  import { frameworkVersion } from '@ultimat3/core';
12
12
  import { contentHash, loadStylesheet } from '@ultimat3/render/server';
13
+ import { IslandBuildFailedError } from './errors';
13
14
  import type { IslandBundle, IslandChunk, SharedChunk } from './island-bundle';
14
15
  import { buildIslands, discoverIslands, islandBundle } from './island-bundle';
16
+ import type { SourceDigester, SourceStamp } from './island-sources';
17
+ import { changedSource, isRecordableSource, sourceDigester, stampSources } from './island-sources';
15
18
  import { islandStylesheets } from './island-styles';
16
19
  import { PREBUILT_ISLANDS_DIR } from './serve-prebuilt-paths';
17
20
 
@@ -26,6 +29,8 @@ interface StoredChunk {
26
29
  readonly identity: string;
27
30
  /** The shared chunk URLs the entry loads, transitively (`IslandChunk.imports`). */
28
31
  readonly imports: readonly string[];
32
+ /** The files it was built from, hashed at write — rehashed at read (`island-sources.ts`). */
33
+ readonly sources: readonly SourceStamp[];
29
34
  }
30
35
 
31
36
  /** A shared chunk: no island of its own, verified by the same hash. */
@@ -33,6 +38,7 @@ interface StoredShared {
33
38
  readonly url: string;
34
39
  readonly identity: string;
35
40
  readonly importers: readonly string[];
41
+ readonly sources: readonly SourceStamp[];
36
42
  }
37
43
 
38
44
  interface StoreIndex {
@@ -52,12 +58,32 @@ interface StoreIndex {
52
58
 
53
59
  const chunkFile = (url: string): string => url.slice(url.lastIndexOf('/') + 1);
54
60
 
61
+ /**
62
+ * Refuses a bundle in which an island's own file is a source of nothing it loads. Its file is
63
+ * always one — in its entry chunk, or in a shared chunk when another island imports it — so its
64
+ * absence means the map's paths stopped resolving into the root (`sourcesOnDisk`), and the store
65
+ * would record nothing to rehash: a boot would serve that chunk after any edit, the K8 bug again.
66
+ */
67
+ function refuseUntracked(bundle: IslandBundle): void {
68
+ const shared = new Map(bundle.shared.map((one) => [one.url, one.sources ?? []]));
69
+ for (const chunk of bundle.chunks) {
70
+ const loaded = [chunk.sources ?? [], ...chunk.imports.map((url) => shared.get(url) ?? [])];
71
+ if (loaded.some((sources) => sources.includes(chunk.file))) continue;
72
+ throw new IslandBuildFailedError({
73
+ file: chunk.file,
74
+ logs: `neither its chunk nor a shared chunk it loads records ${chunk.file} as a source — the source map's paths, resolved against the build's cwd ${process.cwd()}, no longer land in the app root, so a prebuilt store would serve this chunk after the file is edited`,
75
+ });
76
+ }
77
+ }
78
+
55
79
  /** Writes every chunk and the index that verifies them. Answers the files written, app-relative. */
56
80
  export async function writeIslandStore(
57
81
  root: string,
58
82
  bundle: IslandBundle,
59
83
  ): Promise<readonly string[]> {
84
+ refuseUntracked(bundle);
60
85
  const dir = join(root, ISLAND_STORE_DIR);
86
+ const digestOf = sourceDigester(root);
61
87
  const chunks: StoredChunk[] = [];
62
88
  const written: string[] = [];
63
89
  for (const chunk of bundle.chunks) {
@@ -69,13 +95,19 @@ export async function writeIslandStore(
69
95
  url: chunk.url,
70
96
  identity: contentHash(chunk.code),
71
97
  imports: chunk.imports,
98
+ sources: await stampSources(chunk.sources ?? [], digestOf),
72
99
  });
73
100
  }
74
101
  const shared: StoredShared[] = [];
75
102
  for (const chunk of bundle.shared) {
76
103
  await Bun.write(join(dir, chunkFile(chunk.url)), chunk.code);
77
104
  written.push(`${ISLAND_STORE_DIR}/${chunkFile(chunk.url)}`);
78
- shared.push({ url: chunk.url, identity: contentHash(chunk.code), importers: chunk.importers });
105
+ shared.push({
106
+ url: chunk.url,
107
+ identity: contentHash(chunk.code),
108
+ importers: chunk.importers,
109
+ sources: await stampSources(chunk.sources ?? [], digestOf),
110
+ });
79
111
  }
80
112
  const stylesheets = islandStylesheets()
81
113
  .map((path) => relative(root, path).split(sep).join('/'))
@@ -97,8 +129,29 @@ const isString = (value: unknown): value is string => typeof value === 'string';
97
129
  const stringsOf = (value: unknown): readonly string[] =>
98
130
  Array.isArray(value) ? value.filter(isString) : [];
99
131
 
100
- /** The index, narrowed field by field — a file on disk is input, never a trusted shape. */
101
- function parseIndex(value: unknown): StoreIndex | undefined {
132
+ /**
133
+ * `undefined` when the field is absent or any entry is malformed or names a path outside the app:
134
+ * a store that cannot say what it was built from cannot be checked, which is stale — never
135
+ * "nothing to check".
136
+ */
137
+ function stampsOf(value: unknown): readonly SourceStamp[] | undefined {
138
+ if (!Array.isArray(value)) return undefined;
139
+ const stamps: SourceStamp[] = [];
140
+ for (const entry of value) {
141
+ if (typeof entry !== 'object' || entry === null) return undefined;
142
+ const { path, digest } = entry as Record<string, unknown>;
143
+ // A path the writer could not have recorded is refused before anything reads it.
144
+ if (!isString(path) || !isString(digest) || !isRecordableSource(path)) return undefined;
145
+ stamps.push({ path, digest });
146
+ }
147
+ return stamps;
148
+ }
149
+
150
+ /**
151
+ * The index, narrowed field by field — a file on disk is input, never a trusted shape. `unchecked`
152
+ * names an output that records no sources: a store written before they were recorded.
153
+ */
154
+ function parseIndex(value: unknown): StoreIndex | { readonly unchecked: string } | undefined {
102
155
  if (typeof value !== 'object' || value === null) return undefined;
103
156
  const record = value as Record<string, unknown>;
104
157
  const chunks = record['chunks'];
@@ -112,14 +165,18 @@ function parseIndex(value: unknown): StoreIndex | undefined {
112
165
  const { file, moduleId, url, identity, imports } = chunk;
113
166
  if (!isString(file) || !isString(moduleId) || !isString(url) || !isString(identity))
114
167
  return undefined;
115
- parsed.push({ file, moduleId, url, identity, imports: stringsOf(imports) });
168
+ const sources = stampsOf(chunk['sources']);
169
+ if (sources === undefined) return { unchecked: url };
170
+ parsed.push({ file, moduleId, url, identity, imports: stringsOf(imports), sources });
116
171
  }
117
172
  const shared: StoredShared[] = [];
118
173
  for (const entry of Array.isArray(record['shared']) ? record['shared'] : []) {
119
174
  if (typeof entry !== 'object' || entry === null) return undefined;
120
- const { url, identity, importers } = entry as Record<string, unknown>;
175
+ const { url, identity, importers, sources: listed } = entry as Record<string, unknown>;
121
176
  if (!isString(url) || !isString(identity)) return undefined;
122
- shared.push({ url, identity, importers: stringsOf(importers) });
177
+ const sources = stampsOf(listed);
178
+ if (sources === undefined) return { unchecked: url };
179
+ shared.push({ url, identity, importers: stringsOf(importers), sources });
123
180
  }
124
181
  const sheets = record['stylesheets'];
125
182
  // Absent in a 22.3.2 store: read as none, and the boot builds when it matters (see below).
@@ -133,6 +190,18 @@ function parseIndex(value: unknown): StoreIndex | undefined {
133
190
  };
134
191
  }
135
192
 
193
+ /** The first output with a recorded source that is gone or no longer hashes the same. */
194
+ async function firstEdited(
195
+ outputs: readonly { readonly url: string; readonly sources: readonly SourceStamp[] }[],
196
+ digestOf: SourceDigester,
197
+ ): Promise<{ readonly url: string; readonly path: string } | undefined> {
198
+ for (const output of outputs) {
199
+ const path = await changedSource(output.sources, digestOf);
200
+ if (path !== undefined) return { url: output.url, path };
201
+ }
202
+ return undefined;
203
+ }
204
+
136
205
  /** A verified store, or the one sentence saying why it cannot be served. */
137
206
  export type StoreRead =
138
207
  | { readonly bundle: IslandBundle; readonly stale?: undefined }
@@ -144,6 +213,11 @@ export async function readIslandStore(root: string): Promise<StoreRead> {
144
213
  if (!(await file.exists())) return { stale: `no ${ISLAND_STORE_DIR}/${INDEX}` };
145
214
  const index = parseIndex(await file.json().catch(() => undefined));
146
215
  if (index === undefined) return { stale: `${ISLAND_STORE_DIR}/${INDEX} does not parse` };
216
+ if ('unchecked' in index) {
217
+ return {
218
+ stale: `${index.unchecked} records no checkable sources, so an edit since it was built would be unseen`,
219
+ };
220
+ }
147
221
  if (index.framework !== frameworkVersion() || index.bun !== Bun.version) {
148
222
  return {
149
223
  stale: `built by framework ${index.framework} on Bun ${index.bun}, serving ${frameworkVersion()} on Bun ${Bun.version}`,
@@ -154,6 +228,16 @@ export async function readIslandStore(root: string): Promise<StoreRead> {
154
228
  if (stored.join('\n') !== present.join('\n')) {
155
229
  return { stale: 'the stored islands are not the islands this app has' };
156
230
  }
231
+ // Every recorded source first: a chunk whose bytes verify was still built from whatever the files
232
+ // said THEN. Only the recorded paths are read, each once, so a boot pays for the app's own island
233
+ // sources and nothing else.
234
+ const digestOf = sourceDigester(root);
235
+ const edited = await firstEdited([...index.chunks, ...index.shared], digestOf);
236
+ if (edited !== undefined) {
237
+ return {
238
+ stale: `${edited.path}, a source of ${edited.url}, changed since the store was built`,
239
+ };
240
+ }
157
241
  const verified = async (entry: { readonly url: string; readonly identity: string }) => {
158
242
  const bytes = Bun.file(join(dir, chunkFile(entry.url)));
159
243
  const code = (await bytes.exists()) ? await bytes.text() : undefined;
package/src/jobs-json.ts CHANGED
@@ -13,7 +13,6 @@ import type {
13
13
  StepTrace,
14
14
  WorkerRecord,
15
15
  } from '@ultimat3/jobs';
16
- import type { DrainFailure, DrainSkip } from './jobs-drain';
17
16
  import type { JsonValue } from './output';
18
17
 
19
18
  function stepTraceToJson(step: StepTrace): JsonValue {
@@ -181,28 +180,3 @@ export function deadLetterToJson(entry: DeadLetterEntry): JsonValue {
181
180
  retryCommand: entry.retryCommand,
182
181
  };
183
182
  }
184
-
185
- export function drainFailureToJson(failure: DrainFailure): JsonValue {
186
- return {
187
- id: failure.id,
188
- name: failure.name,
189
- finding: {
190
- code: failure.finding.code,
191
- cause: failure.finding.cause,
192
- fix: failure.finding.fix,
193
- docs: failure.finding.docs ?? null,
194
- at: failure.finding.at ?? null,
195
- },
196
- };
197
- }
198
-
199
- /** A candidate the drain refused to touch, and why — the half of the outcome `moved` cannot show. */
200
- export function drainSkipToJson(skip: DrainSkip): JsonValue {
201
- return {
202
- id: skip.id,
203
- name: skip.name,
204
- queue: skip.queue,
205
- state: skip.state,
206
- reason: skip.reason,
207
- };
208
- }