@ultimat3/cli 22.15.0 → 24.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (267) hide show
  1. package/CLAUDE.md +36 -13
  2. package/README.md +31 -8
  3. package/package.json +29 -28
  4. package/src/admin-catalog-keys.ts +16 -0
  5. package/src/admin-registration.ts +192 -0
  6. package/src/affected.ts +27 -35
  7. package/src/api-registration.ts +125 -11
  8. package/src/app-artifacts.ts +1 -1
  9. package/src/app-auth.ts +4 -27
  10. package/src/app-boundaries.ts +6 -20
  11. package/src/app-config-load.ts +51 -0
  12. package/src/app-env.ts +5 -11
  13. package/src/app-load.ts +55 -11
  14. package/src/app-manifest.ts +16 -8
  15. package/src/app-mcp.ts +10 -23
  16. package/src/app-permissions-borrowed.ts +129 -0
  17. package/src/app-permissions-site.ts +24 -0
  18. package/src/app-permissions.ts +0 -0
  19. package/src/app-reload-graph.ts +7 -30
  20. package/src/app-transport.ts +219 -0
  21. package/src/boundary-findings.ts +16 -4
  22. package/src/browser-transport.ts +160 -0
  23. package/src/budgets.ts +27 -34
  24. package/src/cdp-shot-driver.ts +3 -1
  25. package/src/cmd-auth-spec.ts +16 -0
  26. package/src/cmd-auth.ts +65 -0
  27. package/src/cmd-build-spec.ts +10 -3
  28. package/src/cmd-build.ts +107 -11
  29. package/src/cmd-db-backfill.ts +25 -12
  30. package/src/cmd-db.ts +66 -16
  31. package/src/cmd-deploy-helm.ts +7 -6
  32. package/src/cmd-deploy.ts +4 -4
  33. package/src/cmd-dev.ts +9 -8
  34. package/src/cmd-doctor-spec.ts +2 -9
  35. package/src/cmd-doctor.ts +75 -24
  36. package/src/cmd-env.ts +26 -2
  37. package/src/cmd-generate.ts +195 -47
  38. package/src/cmd-help.ts +19 -6
  39. package/src/cmd-i18n.ts +10 -9
  40. package/src/cmd-jobs-spec.ts +19 -3
  41. package/src/cmd-jobs.ts +136 -7
  42. package/src/cmd-manifest-spec.ts +1 -1
  43. package/src/cmd-manifest.ts +6 -25
  44. package/src/cmd-mcp.ts +46 -15
  45. package/src/cmd-new.ts +15 -1
  46. package/src/cmd-planned.ts +15 -8
  47. package/src/cmd-routes.ts +27 -3
  48. package/src/cmd-secrets-ring.ts +76 -0
  49. package/src/cmd-secrets-spec.ts +10 -2
  50. package/src/cmd-secrets.ts +95 -18
  51. package/src/cmd-shot-matrix.ts +1 -3
  52. package/src/cmd-shot.ts +12 -3
  53. package/src/cmd-tasks.ts +84 -15
  54. package/src/cmd-test-spec.ts +1 -7
  55. package/src/cmd-test.ts +13 -29
  56. package/src/cmd-verify.ts +13 -5
  57. package/src/comment-safe.ts +16 -0
  58. package/src/coverage-floor.ts +334 -0
  59. package/src/coverage-lcov.ts +193 -0
  60. package/src/coverage-source.ts +136 -0
  61. package/src/db-object-drift.ts +30 -0
  62. package/src/db-replay-engine.ts +115 -0
  63. package/src/db-schema-dump.ts +150 -0
  64. package/src/db-schema-refresh.ts +122 -0
  65. package/src/db-subscribes.ts +12 -4
  66. package/src/dev-dashboard-guard.ts +76 -0
  67. package/src/dev-dashboard.ts +51 -29
  68. package/src/dev-lock.ts +4 -7
  69. package/src/dev-route-table.ts +9 -0
  70. package/src/dispatch.ts +3 -6
  71. package/src/doctor-app-url.ts +39 -0
  72. package/src/doctor-auth.ts +95 -0
  73. package/src/doctor-coverage.ts +39 -0
  74. package/src/doctor-guards.ts +54 -0
  75. package/src/doctor-sealed.ts +132 -0
  76. package/src/document-graph.ts +82 -0
  77. package/src/drift-replica-identity.ts +42 -0
  78. package/src/error-catalog.ts +6 -9
  79. package/src/error-codes.ts +55 -3
  80. package/src/error-unthrown.ts +29 -11
  81. package/src/errors.ts +54 -83
  82. package/src/framework-schema-apply.ts +78 -0
  83. package/src/framework-schema-stamp.ts +96 -0
  84. package/src/framework-schema.ts +28 -2
  85. package/src/generate-files.ts +79 -12
  86. package/src/generate-grant-findings.ts +76 -0
  87. package/src/generate-grants.ts +77 -3
  88. package/src/generate-kinds.ts +52 -1
  89. package/src/generate-shadow.ts +46 -0
  90. package/src/generate-write.ts +62 -40
  91. package/src/generated-imports.ts +59 -0
  92. package/src/gh-target.ts +12 -7
  93. package/src/guard-sources.ts +104 -0
  94. package/src/guards.ts +23 -7
  95. package/src/handle-registration.ts +217 -0
  96. package/src/i18n-audit.ts +4 -1
  97. package/src/i18n-registration.ts +36 -0
  98. package/src/image-prepare.ts +51 -8
  99. package/src/import-closure.ts +165 -0
  100. package/src/index.ts +12 -5
  101. package/src/island-bundle.ts +3 -15
  102. package/src/island-store.ts +24 -16
  103. package/src/jobs-drain.ts +95 -21
  104. package/src/jobs-json.ts +50 -0
  105. package/src/jobs-report.ts +7 -4
  106. package/src/json-merge.ts +46 -0
  107. package/src/loopback-host.ts +13 -0
  108. package/src/manifest-staleness.ts +62 -0
  109. package/src/mcp-errors.ts +41 -4
  110. package/src/mcp-host.ts +16 -9
  111. package/src/mcp-test-run.ts +38 -0
  112. package/src/messages.ts +35 -0
  113. package/src/metrics-endpoint.ts +95 -24
  114. package/src/migration-extensions.ts +29 -0
  115. package/src/module-imports.ts +47 -0
  116. package/src/output.ts +24 -9
  117. package/src/page-navigation.ts +7 -23
  118. package/src/page-speculation.ts +5 -20
  119. package/src/permission-grants.ts +41 -9
  120. package/src/prerender-out.ts +34 -7
  121. package/src/prerender.ts +8 -5
  122. package/src/publish-closure.ts +101 -0
  123. package/src/pwa-artifacts.ts +26 -94
  124. package/src/registry.ts +2 -0
  125. package/src/revalidated-response.ts +4 -3
  126. package/src/role-load.ts +143 -0
  127. package/src/role-replicator.ts +16 -0
  128. package/src/role-start.ts +27 -5
  129. package/src/role-sync.ts +55 -20
  130. package/src/role-wake.ts +45 -0
  131. package/src/runtime-admin.ts +141 -0
  132. package/src/runtime-assets.ts +11 -7
  133. package/src/runtime-bindings.ts +4 -4
  134. package/src/runtime-cache.ts +38 -47
  135. package/src/runtime-hooks.ts +5 -2
  136. package/src/runtime-idempotency-scope.ts +23 -0
  137. package/src/runtime-isr.ts +36 -0
  138. package/src/runtime-jobs.ts +8 -33
  139. package/src/runtime-mfa-warning.ts +29 -0
  140. package/src/runtime-notify-retention.ts +10 -46
  141. package/src/runtime-overrides.ts +1 -1
  142. package/src/runtime-purge.ts +38 -16
  143. package/src/runtime-queue.ts +39 -11
  144. package/src/runtime-realtime.ts +9 -45
  145. package/src/runtime-render.ts +68 -35
  146. package/src/runtime-services.ts +22 -18
  147. package/src/runtime-storage.ts +10 -5
  148. package/src/scaffold-typecheck.ts +1 -0
  149. package/src/schema-diff.ts +16 -0
  150. package/src/schema-drift.ts +45 -15
  151. package/src/schema-dump-drift.ts +64 -0
  152. package/src/seo-routes.ts +80 -9
  153. package/src/serve-boot.ts +114 -158
  154. package/src/serve-drain.ts +9 -33
  155. package/src/serve-env.ts +9 -6
  156. package/src/serve-prebuilt-paths.ts +19 -0
  157. package/src/serve-prebuilt.ts +84 -0
  158. package/src/serve-web.ts +169 -0
  159. package/src/serve.ts +6 -9
  160. package/src/server-barrels.ts +54 -0
  161. package/src/shot-locale.ts +5 -23
  162. package/src/site-assets.ts +3 -5
  163. package/src/site-config.ts +9 -48
  164. package/src/site-seo.ts +1 -1
  165. package/src/sitemap-lastmod.ts +5 -0
  166. package/src/solid-loader.ts +12 -3
  167. package/src/source-edit.ts +49 -0
  168. package/src/source-files.ts +6 -0
  169. package/src/stored-object-headers.ts +43 -0
  170. package/src/sync-url.ts +2 -2
  171. package/src/tasks-facts.ts +3 -26
  172. package/src/templates/action.ts +147 -11
  173. package/src/templates/admin-catalog.ts +58 -0
  174. package/src/templates/admin-page.ts +36 -8
  175. package/src/templates/admin.ts +57 -17
  176. package/src/templates/backfill.ts +121 -87
  177. package/src/templates/entity.ts +140 -44
  178. package/src/templates/guard-animated-layout-property.ts +4 -11
  179. package/src/templates/guard-bare-error.ts +9 -13
  180. package/src/templates/guard-focus-visible.ts +4 -11
  181. package/src/templates/guard-image-dimensions.ts +6 -6
  182. package/src/templates/guard-island-without-states.ts +2 -4
  183. package/src/templates/guard-raw-breakpoint.ts +210 -0
  184. package/src/templates/guard-raw-colour.ts +4 -11
  185. package/src/templates/guard-raw-length.ts +375 -0
  186. package/src/templates/guard-raw-motion.ts +213 -0
  187. package/src/templates/guard-raw-shadow.ts +187 -0
  188. package/src/templates/guard-raw-z-index.ts +166 -0
  189. package/src/templates/guard-repo-raw-sql.ts +345 -0
  190. package/src/templates/guard-semantic-interactive.ts +6 -6
  191. package/src/templates/guard-undeclared-custom-property.ts +217 -0
  192. package/src/templates/guard-undefined-style-class.ts +215 -0
  193. package/src/templates/guard-untranslated-string.ts +6 -6
  194. package/src/templates/guard-unzoned-date.ts +9 -13
  195. package/src/templates/index.ts +3 -2
  196. package/src/templates/island.ts +34 -51
  197. package/src/templates/job-body-test.ts +121 -0
  198. package/src/templates/job.ts +23 -8
  199. package/src/templates/naming.ts +28 -0
  200. package/src/templates/policy.ts +16 -9
  201. package/src/templates/query.ts +242 -26
  202. package/src/templates/resource-create.ts +62 -18
  203. package/src/templates/resource-form-island.ts +58 -80
  204. package/src/templates/resource-page.ts +269 -0
  205. package/src/templates/resource-service.ts +96 -0
  206. package/src/templates/resource.ts +86 -61
  207. package/src/templates/route.ts +41 -35
  208. package/src/templates/scaffold-api.ts +55 -1
  209. package/src/templates/scaffold-app.ts +177 -53
  210. package/src/templates/scaffold-auth.ts +83 -19
  211. package/src/templates/scaffold-browser-client.ts +90 -0
  212. package/src/templates/scaffold-container-compose.ts +10 -1
  213. package/src/templates/scaffold-container.ts +20 -2
  214. package/src/templates/scaffold-dashboard-bare.ts +64 -2
  215. package/src/templates/scaffold-dashboard-example.ts +124 -31
  216. package/src/templates/scaffold-dashboard-shared.ts +1 -20
  217. package/src/templates/scaffold-dashboard.ts +1 -2
  218. package/src/templates/scaffold-db-client.ts +144 -0
  219. package/src/templates/scaffold-db-package.ts +90 -8
  220. package/src/templates/scaffold-docs.ts +50 -5
  221. package/src/templates/scaffold-guards.ts +54 -11
  222. package/src/templates/scaffold-helm-fences.ts +76 -0
  223. package/src/templates/scaffold-helm-templates.ts +32 -7
  224. package/src/templates/scaffold-helm.ts +31 -1
  225. package/src/templates/scaffold-http.ts +9 -7
  226. package/src/templates/scaffold-repo.ts +61 -3
  227. package/src/templates/scaffold-roles.ts +56 -9
  228. package/src/templates/scaffold-shell.ts +74 -105
  229. package/src/templates/scaffold-site.ts +45 -19
  230. package/src/templates/scaffold-ui-package.ts +18 -0
  231. package/src/templates/slice-foundation.ts +46 -8
  232. package/src/test-counts.ts +14 -2
  233. package/src/test-ignores.ts +25 -0
  234. package/src/test-passes.ts +0 -15
  235. package/src/test-select.ts +5 -14
  236. package/src/test-shards.ts +41 -93
  237. package/src/test-workers.ts +26 -4
  238. package/src/theme-boot.ts +6 -31
  239. package/src/transport-calls.ts +105 -0
  240. package/src/trusted-hops-error.ts +17 -0
  241. package/src/unscanned-admin.ts +54 -0
  242. package/src/unthrown-codes.ts +26 -0
  243. package/src/verify-checks.ts +43 -53
  244. package/src/verify-coverage-run.ts +190 -0
  245. package/src/verify-deadline.ts +353 -0
  246. package/src/verify-floor.ts +36 -9
  247. package/src/verify-merge.ts +171 -45
  248. package/src/verify-part-step.ts +90 -0
  249. package/src/verify-progress.ts +25 -0
  250. package/src/verify-role-load-probe.ts +54 -0
  251. package/src/verify-role-load.ts +148 -0
  252. package/src/verify-run.ts +116 -37
  253. package/src/verify-shard.ts +33 -11
  254. package/src/verify-stalled.ts +152 -0
  255. package/src/verify-step.ts +42 -1
  256. package/src/verify-tests.ts +83 -8
  257. package/src/workspace-checks.ts +50 -8
  258. package/src/workspace-dep-edit.ts +127 -0
  259. package/src/build-errors.ts +0 -54
  260. package/src/cmd-dev-fixture.ts +0 -204
  261. package/src/dev-live-fixture.ts +0 -122
  262. package/src/policy-fixture.ts +0 -78
  263. package/src/realtime-browser-probe-fixture.ts +0 -9
  264. package/src/role-start-fixture.ts +0 -78
  265. package/src/scaffold-fixture.ts +0 -160
  266. package/src/templates/island-fixture.ts +0 -76
  267. package/src/verify-errors.ts +0 -49
package/src/registry.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  // dev server it never started (plan 101 slice 12 d).
8
8
 
9
9
  import { affectedSpec } from './cmd-affected-spec';
10
+ import { authSpec } from './cmd-auth-spec';
10
11
  import { buildSpec } from './cmd-build-spec';
11
12
  import { ciSpec } from './cmd-ci-spec';
12
13
  import { dbSpec } from './cmd-db-spec';
@@ -85,6 +86,7 @@ export const LAZY_COMMANDS: readonly LazyCommand[] = [
85
86
  lazy(deploySpec, async () => (await import('./cmd-deploy')).deployCommand),
86
87
  lazy(envSpec, async () => (await import('./cmd-env')).envCommand),
87
88
  lazy(secretsSpec, async () => (await import('./cmd-secrets')).secretsCommand),
89
+ lazy(authSpec, async () => (await import('./cmd-auth')).authCommand),
88
90
  lazy(manifestSpec, async () => (await import('./cmd-manifest')).manifestCommand),
89
91
  lazy(routesSpec, async () => (await import('./cmd-routes')).routesCommand),
90
92
  lazy(actionsSpec, async () => (await import('./cmd-registries')).actionsCommand),
@@ -4,6 +4,7 @@
4
4
  // name that outlives its bytes is a file no client refreshes for a year.
5
5
 
6
6
  import type { UltimateRequest } from '@ultimat3/http';
7
+ import { contentHash } from '@ultimat3/render/server';
7
8
  import { etagMatches } from './runtime-storage';
8
9
 
9
10
  /**
@@ -14,8 +15,8 @@ import { etagMatches } from './runtime-storage';
14
15
  export const REVALIDATE_CACHE_CONTROL = 'public, max-age=0, must-revalidate';
15
16
 
16
17
  /**
17
- * A strong validator from the bytes — xxHash32, the function `contentHash` applies to a document,
18
- * taken here over bytes because an icon is not a string. Revalidating costs a request and no body.
18
+ * A strong validator from the bytes — `@ultimat3/render`'s `contentHash`, the function a document's
19
+ * ETag is, taken here over bytes because an icon is not a string. Revalidating costs a request and no body.
19
20
  */
20
21
  export function revalidatedResponse(
21
22
  request: UltimateRequest,
@@ -25,7 +26,7 @@ export function revalidatedResponse(
25
26
  // Copied, not passed through: a `Uint8Array<ArrayBufferLike>` may be backed by a
26
27
  // `SharedArrayBuffer`, which `Response` does not accept — `runtime-assets.ts`'s rule, verbatim.
27
28
  const bytes = typeof body === 'string' ? new TextEncoder().encode(body) : new Uint8Array(body);
28
- const etag = `"${Bun.hash.xxHash32(bytes).toString(16).padStart(8, '0')}"`;
29
+ const etag = `"${contentHash(bytes)}"`;
29
30
  const headers = {
30
31
  'content-type': contentType,
31
32
  'cache-control': REVALIDATE_CACHE_CONTROL,
@@ -0,0 +1,143 @@
1
+ // What a container role imports of the app. `web`, `sync` and `replicator` answer for registries a
2
+ // request, a subscription or the WAL names — routes, queries, entities — so they import every
3
+ // module. `worker` and `scheduler` run jobs and tasks: they import the API index and every module
4
+ // that brings no document (a component, a stylesheet) with it, and nothing else.
5
+
6
+ // why: Bun ships no path API; the API index is named app-root-relative and imported absolute.
7
+ import { join, resolve } from 'node:path';
8
+ import type { Role } from '@ultimat3/core';
9
+ import { isJsonObject, logger, UltimateError } from '@ultimat3/core';
10
+ import { registeredJobs, registeredTasks } from '@ultimat3/jobs';
11
+ import type { AppScan } from './app-load';
12
+ import { scanAppModules } from './app-load';
13
+ import { API_INDEX } from './app-root';
14
+ import { createDocumentGraph } from './document-graph';
15
+
16
+ /** `everything`: the scan every tool runs. `background`: no document a job does not import. */
17
+ export type RoleLoad = 'everything' | 'background';
18
+
19
+ /**
20
+ * Typed over `Role`, so a seventh role does not compile until somebody decides what it imports.
21
+ * `migrate` never reaches a load — `runMigrations` reads SQL files — and says `everything` so a
22
+ * caller that boots it by hand is given the whole app rather than a guess.
23
+ */
24
+ const DECLARED: Readonly<Record<Role, RoleLoad>> = {
25
+ web: 'everything',
26
+ // A subscription names a live query, and a page module may export one.
27
+ sync: 'everything',
28
+ // The slot decodes every table an entity declares, wherever the entity is declared.
29
+ replicator: 'everything',
30
+ worker: 'background',
31
+ scheduler: 'background',
32
+ migrate: 'everything',
33
+ };
34
+
35
+ /** A `Map`, so the read below is an own-key lookup — never a member of `Object.prototype`. */
36
+ export const ROLE_LOADS: ReadonlyMap<Role, RoleLoad> = new Map(
37
+ Object.entries(DECLARED) as [Role, RoleLoad][],
38
+ );
39
+
40
+ /** What `role` imports. A value that is no role — an untyped caller's — is given everything. */
41
+ export const roleLoadFor = (role: Role): RoleLoad => ROLE_LOADS.get(role) ?? 'everything';
42
+
43
+ /** The file the gate holds fresh (`manifest` step) and every image carries beside the source. */
44
+ const MANIFEST_FILE = 'x.manifest.json';
45
+
46
+ /**
47
+ * A background load that left out a job or task the committed manifest names. Logged, never
48
+ * thrown: the boot answers it by importing the rest of the app, so the role runs everything it
49
+ * ran before — it only pays for the documents this load exists to leave out.
50
+ */
51
+ export class RoleLoadIncompleteError extends UltimateError {
52
+ constructor(input: { role: Role; missing: readonly string[] }) {
53
+ super({
54
+ code: 'X_ROLE_LOAD_INCOMPLETE',
55
+ cause: `the ${input.role} role imported ${API_INDEX} and every module that reaches no component or stylesheet, and ${MANIFEST_FILE} names ${String(input.missing.length)} job(s) or task(s) that load did not register (${input.missing.join(', ')}), so the whole app was imported instead`,
56
+ fix: `add import * as <module> from the file that declares it and <module> to jobs: [...] or tasks: [...] in ${API_INDEX}, then x manifest`,
57
+ meta: { role: input.role, missing: input.missing },
58
+ });
59
+ }
60
+ }
61
+
62
+ /** `name` of every entry of one manifest list — `[]` for a list that is absent or malformed. */
63
+ function namesIn(manifest: Record<string, unknown>, key: 'jobs' | 'tasks'): readonly string[] {
64
+ const list = manifest[key];
65
+ if (!Array.isArray(list)) return [];
66
+ return list.flatMap((entry: unknown) =>
67
+ isJsonObject(entry) && typeof entry['name'] === 'string' ? [entry['name']] : [],
68
+ );
69
+ }
70
+
71
+ /**
72
+ * Every job and task the committed manifest names that this process has not registered —
73
+ * `undefined` when there is no manifest to check against, which is not the same answer as "none".
74
+ */
75
+ export async function unregisteredFromManifest(
76
+ root: string,
77
+ ): Promise<readonly string[] | undefined> {
78
+ const parsed: unknown = await Bun.file(join(root, MANIFEST_FILE))
79
+ .json()
80
+ .catch(() => undefined);
81
+ if (!isJsonObject(parsed)) return undefined;
82
+ const jobs = new Set(registeredJobs().map((job) => job.name));
83
+ const tasks = new Set(registeredTasks().map((task) => task.name));
84
+ return [
85
+ ...namesIn(parsed, 'jobs').filter((name) => !jobs.has(name)),
86
+ ...namesIn(parsed, 'tasks').filter((name) => !tasks.has(name)),
87
+ ].sort();
88
+ }
89
+
90
+ /**
91
+ * The background scan alone, with no proof and no fallback: the API index and every module that
92
+ * brings no document. What a worker imports when nothing is wrong — and what the gate imports in a
93
+ * child to compare against the whole app (`verify-role-load.ts`).
94
+ */
95
+ export async function scanBackground(root: string): Promise<AppScan> {
96
+ const index = resolve(root, API_INDEX);
97
+ // why `require.cache`: Bun 1.4 lists every evaluated ES module there, by absolute path — the
98
+ // same table `app-reload-graph.ts` evicts from.
99
+ const loaded = (absolute: string): boolean => absolute in require.cache;
100
+ const graph = createDocumentGraph(loaded);
101
+ return scanAppModules(root, {
102
+ track: false,
103
+ // The index first (`appModulePaths` leads with it): what it imports is what the app runs.
104
+ // Then every module already here, and every one that brings no document with it — a service,
105
+ // a catalog, a storage declaration registers on import and no job names it.
106
+ include: async (absolute) =>
107
+ absolute === index || loaded(absolute) || !(await graph.reachesDocument(absolute)),
108
+ });
109
+ }
110
+
111
+ /**
112
+ * The background load, then the proof it was enough. A job runs by NAME — the queue row carries
113
+ * it — so the one thing this load must not do is leave a named job unregistered. The gate already
114
+ * requires every job and task to be named from a `defineApi()` call (`X_JOB_UNREGISTERED`); the
115
+ * manifest is the list that call produced, and a name it holds that this load did not register is
116
+ * answered by importing everything, loudly.
117
+ */
118
+ async function loadBackground(root: string, role: Role): Promise<AppScan> {
119
+ const scan = await scanBackground(root);
120
+ const missing = await unregisteredFromManifest(root);
121
+ if (missing !== undefined && missing.length === 0) return scan;
122
+ if (missing === undefined) {
123
+ logger.warn('ultimate role load unverified', {
124
+ role,
125
+ cause: `${MANIFEST_FILE} is absent or unreadable, so nothing names the jobs this role must run and the whole app was imported`,
126
+ fix: 'x manifest',
127
+ });
128
+ } else {
129
+ const error = new RoleLoadIncompleteError({ role, missing });
130
+ logger.error(error.format(), { code: error.code, role, missing });
131
+ }
132
+ return scanAppModules(root, { track: false });
133
+ }
134
+
135
+ /**
136
+ * What `serve-boot.ts` calls instead of `loadApp`: the scan a role needs and none of what a tool
137
+ * reads from it afterwards — no reload graph, no error-code walk of the app's source.
138
+ */
139
+ export async function loadAppForRole(root: string, role: Role): Promise<AppScan> {
140
+ return roleLoadFor(role) === 'background'
141
+ ? loadBackground(root, role)
142
+ : scanAppModules(root, { track: false });
143
+ }
@@ -3,6 +3,7 @@
3
3
  // a `ROLE=replicator` container starts; the only difference is that this process also runs the
4
4
  // roles reading the other end of that transport.
5
5
 
6
+ import { registerReadinessCheck } from '@ultimat3/core';
6
7
  import { describeEntities } from '@ultimat3/entity';
7
8
  import { ReplicatorSlotHeldError } from '@ultimat3/realtime';
8
9
  import type { Replicator, Transport } from '@ultimat3/realtime/server';
@@ -72,6 +73,18 @@ const noEntitiesRefusal = (): BadFlagError =>
72
73
  fix: 'x g entity Post title:text — then x dev --role replicator',
73
74
  });
74
75
 
76
+ /** The name `/readyz` reports the replicator under. */
77
+ export const REPLICATOR_READINESS_CHECK = 'replicator';
78
+
79
+ /**
80
+ * Ready means REPLICATING. A stream that ends is restarted by the replicator itself, and until it
81
+ * is back `running` is `false`: this process holds no slot and publishes nothing, so it must not be
82
+ * counted as the database's replicator. Returns the unregister.
83
+ */
84
+ export function watchReplicatorReadiness(replicator: Pick<Replicator, 'running'>): () => void {
85
+ return registerReadinessCheck(REPLICATOR_READINESS_CHECK, () => replicator.running);
86
+ }
87
+
75
88
  /**
76
89
  * Lock first, feed second. A replicator that opened its slot before losing the lock race would
77
90
  * have already consumed WAL the holder is responsible for, and `pg_try_advisory_lock` is the only
@@ -95,11 +108,14 @@ export async function startReplicator(options: StartReplicatorOptions): Promise<
95
108
  if (!(await replicator.start())) {
96
109
  throw new ReplicatorSlotHeldError({ key: replicatorLockKey(slot) });
97
110
  }
111
+ const unregister = watchReplicatorReadiness(replicator);
98
112
  return {
99
113
  replicator,
100
114
  slot,
101
115
  detail: selection.detail,
102
116
  async stop() {
117
+ // First: a replicator being stopped on purpose is not a failing one.
118
+ unregister();
103
119
  await replicator.stop();
104
120
  },
105
121
  };
package/src/role-start.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  // something every `x dev` in a team should do to the same server by simply starting.
8
8
 
9
9
  import type { Role } from '@ultimat3/core';
10
- import { createContext, isRole, logger, ROLES } from '@ultimat3/core';
10
+ import { createContext, isRole, logger, markReady, ROLES } from '@ultimat3/core';
11
11
  import type { RateLimitStore, Route, ServerHandle, WebSocketMount } from '@ultimat3/http';
12
12
  import {
13
13
  configuredAuthenticator,
@@ -28,13 +28,18 @@ import {
28
28
  } from '@ultimat3/jobs';
29
29
  import type { SyncWs } from '@ultimat3/realtime/server';
30
30
  import { errorPageHook } from './error-pages';
31
- import { BadFlagError, PortInvalidError, RuntimeDriverSplitError } from './errors';
32
- import { DEFAULT_METRICS_PORT, startMetricsEndpoint } from './metrics-endpoint';
31
+ import { BadFlagError, RuntimeDriverSplitError } from './errors';
32
+ import {
33
+ DEFAULT_METRICS_PORT,
34
+ startMetricsEndpoint,
35
+ whenMetricsPortTaken,
36
+ } from './metrics-endpoint';
33
37
  import { rolesUnderRealtime } from './role-realtime';
34
38
  import { startReplicator } from './role-replicator';
35
39
  import type { RunningRoles, StartRolesOptions } from './role-start-types';
36
40
  import { DEV_ROLES } from './role-start-types';
37
41
  import { prepareSync, type RunningSync } from './role-sync';
42
+ import { startWorkerWake } from './role-wake';
38
43
  import type { Env } from './runtime-bindings';
39
44
  import { devHooks } from './runtime-hooks';
40
45
  import { workerOptionsFor } from './runtime-jobs';
@@ -44,6 +49,7 @@ import type { RunningServices } from './runtime-services';
44
49
  import { inlineScriptSources } from './script-csp';
45
50
  import { inlineStyleSources } from './style-csp';
46
51
  import { syncConnectSources } from './sync-url';
52
+ import { TrustedProxyHopsInvalidError } from './trusted-hops-error';
47
53
  import { DEV_BINDING } from './web-binding';
48
54
 
49
55
  // Declared beside the option types so `x dev`'s spec reads it without loading this module.
@@ -155,8 +161,8 @@ export function trustedHopsFromEnv(env: Env): number | null {
155
161
  const hops = Number(raw);
156
162
  // A malformed count is refused rather than defaulted: reading the header at the wrong index is
157
163
  // trusting a value the client typed, which is the failure trusting a proxy exists to avoid.
158
- if (!Number.isInteger(hops) || hops < 1 || hops > MAX_PROXY_HOPS) {
159
- throw new PortInvalidError({ value: raw, name: 'TRUSTED_PROXY_HOPS' });
164
+ if (!/^\d+$/.test(raw) || !Number.isInteger(hops) || hops < 1 || hops > MAX_PROXY_HOPS) {
165
+ throw new TrustedProxyHopsInvalidError({ value: raw });
160
166
  }
161
167
  return hops;
162
168
  }
@@ -304,6 +310,9 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
304
310
  startMetricsEndpoint({
305
311
  port: options.metricsPort ?? (options.port === 0 ? 0 : DEFAULT_METRICS_PORT),
306
312
  hostname: binding.hostname,
313
+ // `x dev` is one process running several roles: its `process_info` says which.
314
+ role: selected.join('+'),
315
+ whenTaken: whenMetricsPortTaken(binding.dev, options.env),
307
316
  });
308
317
  started.push(async () => metrics.stop());
309
318
 
@@ -350,6 +359,13 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
350
359
  // one hands the failure rollback the same window a dropped `await` gives the teardown below.
351
360
  if (relay !== null) started.push(() => relay.stop());
352
361
 
362
+ // The cross-process wake: one LISTEN session, so a job another pod committed starts now
363
+ // instead of at the next poll. On `worker` alone — it is the role whose two loops (the claim
364
+ // and the relay above) hear it; a web or scheduler pod holding a session would pin a
365
+ // connection per replica to wake nothing (`role-wake.ts`).
366
+ const wake = selected.includes('worker') ? startWorkerWake(options.runtime.db) : null;
367
+ if (wake !== null) started.push(() => wake.stop());
368
+
353
369
  // `state` and `leader`, not the defaults. `createMemorySchedulerState` forgets every watermark
354
370
  // on restart, so a rolling deploy re-fires or skips whatever was due across it, and
355
371
  // `soleLeader()` makes every replica the leader — three `scheduler` pods, three of every task.
@@ -391,6 +407,10 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
391
407
  replicatorHere: replicator !== null,
392
408
  });
393
409
  started.push(async () => live.stop());
410
+ // The web server and the sync node mark ready when they bind; a process with neither — a
411
+ // worker, scheduler or replicator pod — is ready once its roles started, which is what the
412
+ // scrape port's `/readyz` answers (`metrics-endpoint.ts`). The checks still decide after this.
413
+ if (server === null && sync === null) markReady();
394
414
 
395
415
  return {
396
416
  roles: selected,
@@ -413,6 +433,8 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
413
433
  // and AWAITED, because a pass is a `driver.enqueue` followed by a `markPublished`. Dropped,
414
434
  // this returns between the two and the lines below close the pool under the row it was
415
435
  // about to mark: re-published next boot at best, a rejection against a closed pool at worst.
436
+ // The session first: a notification arriving after this wakes a loop that is about to stop.
437
+ await wake?.stop();
416
438
  await relay?.stop();
417
439
  await worker?.stop('x dev stopped');
418
440
  await sync?.stop();
package/src/role-sync.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // and a listener of its own — and because that file is the boot's index, not its detail.
4
4
 
5
5
  import { createContext, logger, UltimateError } from '@ultimat3/core';
6
- import type { WebSocketMount } from '@ultimat3/http';
6
+ import { configuredHttp, type WebSocketMount } from '@ultimat3/http';
7
7
  import { listQueries } from '@ultimat3/query';
8
8
  import type { SyncNode, SyncWs } from '@ultimat3/realtime/server';
9
9
  import {
@@ -120,7 +120,8 @@ export interface PreparedSync {
120
120
  readonly mount: WebSocketMount<SyncWs>;
121
121
  /**
122
122
  * Bind `PORT + 1`, and answer with the same object `startSync` always did. `appUrl` is the web
123
- * role's own origin when one runs here, for the line below that names both doors.
123
+ * role's own origin when one runs here: named in the boot line beside the node's, and — under
124
+ * `x dev` with an `APP_URL` declared — admitted as a page origin (`devOrigins`).
124
125
  */
125
126
  listen(appUrl: string | null): Promise<RunningSync>;
126
127
  /** Release the node when nothing ever bound it — a `web` role that threw after it was built. */
@@ -176,6 +177,35 @@ export function registerLiveQueries(options: StartRolesOptions): LiveQueryRegist
176
177
  return registry;
177
178
  }
178
179
 
180
+ const healthDetailPeersOf = (
181
+ peers: readonly string[] | undefined,
182
+ ): { readonly healthDetailPeers?: readonly string[] } =>
183
+ peers === undefined ? {} : { healthDetailPeers: peers };
184
+
185
+ /**
186
+ * What `x dev` adds to a DECLARED list: the origin its own web role is serving pages on, under
187
+ * each loopback spelling a browser may have been pointed at. A declaration is the whole list, and
188
+ * the scaffold's `.env` declares `APP_URL=http://localhost:3000` — so `x dev --port 4000`, or dev
189
+ * opened as `127.0.0.1`, was a page on an origin nothing named and every socket was refused.
190
+ *
191
+ * Nothing is added to an EMPTY list: an undeclared node admits the origin it was reached on, which
192
+ * already covers these and a forwarded host besides — and one entry would end that rule. Nothing
193
+ * is added outside dev either: in a container the declaration is the operator's, and a wrong one
194
+ * must refuse rather than be papered over by whatever address the process bound.
195
+ */
196
+ function devOrigins(
197
+ options: StartRolesOptions,
198
+ declared: readonly string[],
199
+ appUrl: string | null,
200
+ ): string[] {
201
+ if (!(options.http ?? DEV_BINDING).dev || appUrl === null || declared.length === 0) return [];
202
+ const own = new URL(appUrl);
203
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].map(
204
+ (host) => `${own.protocol}//${host}:${own.port}`,
205
+ );
206
+ return [own.origin, ...loopback].filter((origin) => !declared.includes(origin));
207
+ }
208
+
179
209
  /**
180
210
  * The node itself: its hub, its registry, its authenticator and its change subscription. Nothing
181
211
  * bound — `listen()` and the `web` role's mount are the two doors, and this is what is behind both.
@@ -195,6 +225,7 @@ export async function prepareSync(options: StartRolesOptions): Promise<PreparedS
195
225
  // `exp`), or resolves identity from a header the adapter deliberately does not retain.
196
226
  const authenticate = options.overrides?.syncAuthenticate ?? syncAuthenticator(options.buildId);
197
227
  const registry = registerLiveQueries(options);
228
+ const origins = syncOriginsFrom(options.env);
198
229
  const node = createSyncNode({
199
230
  hub,
200
231
  registry,
@@ -202,9 +233,17 @@ export async function prepareSync(options: StartRolesOptions): Promise<PreparedS
202
233
  buildId: options.buildId,
203
234
  sockets,
204
235
  ...(authenticate === undefined ? {} : { authenticate }),
205
- // The page's origin when it is served on another host than this node; the node's own host is
206
- // admitted without it. Anything else is refused before `authenticate` (X_SOCKET_ORIGIN_REFUSED).
207
- allowedOrigins: syncOriginsFrom(options.env),
236
+ // `APP_URL`'s origin, and when one is declared it is the WHOLE list: the origin this node was
237
+ // reached on is no longer admitted beside it. Undeclared, that origin (and its https spelling
238
+ // when TLS ended at a proxy) is what is admitted; anything else is refused before
239
+ // `authenticate` (X_SOCKET_ORIGIN_REFUSED). Held by reference: `listen` adds `x dev`'s own.
240
+ allowedOrigins: origins,
241
+ // Dev may keep the reached-on origin beside a declaration and a container may not: `x dev`
242
+ // binds loopback, so a `Host` reaches it only through a forward its own user set up.
243
+ ...((options.http ?? DEV_BINDING).dev ? { admitReachedOrigin: true } : {}),
244
+ // Who this node's OWN `/healthz` and `/readyz` tell the detail to: the app's one declaration,
245
+ // the same list the web role reads. Unset, the node keeps its default (the box itself).
246
+ ...healthDetailPeersOf(configuredHttp()?.healthDetailPeers),
208
247
  // Tier 1 is presence, and without a registry the node answers a topic subscribe with no member
209
248
  // list at all — the KV bucket the transport just created would hold nothing and every `sync`
210
249
  // container would run a presence-less protocol. It reads and writes `transport.shared`, so it
@@ -223,7 +262,10 @@ export async function prepareSync(options: StartRolesOptions): Promise<PreparedS
223
262
  // second copy of `/_x/sync` here is the copy that stays behind when it moves.
224
263
  mount: { path: node.path, fetch: node.fetch, websocket: node.websocket },
225
264
  stop: () => node.stop(),
226
- listen: async (appUrl) => await listen(options, node, { registry, hub }, appUrl),
265
+ listen: async (appUrl) => {
266
+ origins.push(...devOrigins(options, origins, appUrl));
267
+ return await listen(options, node, { registry, hub }, appUrl);
268
+ },
227
269
  };
228
270
  }
229
271
 
@@ -235,27 +277,20 @@ export async function prepareSync(options: StartRolesOptions): Promise<PreparedS
235
277
  * neighbouring port, and reversing the order would answer a second `x dev` on this checkout with
236
278
  * "port 3001 is in use" when the fact worth printing is that 3000 is.
237
279
  *
238
- * Port 0 is NOT passed through to the kernel. It cannot be incremented (`+ 1` would ask for port
239
- * 1), but the web role has bound by the time this runs and `appUrl` carries the port it got — and
240
- * `PORT + 1` is the contract every scaffolded `sync-url.ts` computes from and the wiki states. A
241
- * scratch server (`x shot`, `ui.shot`) always asks for 0, so before this every one of its
242
- * pictures carried `WebSocket … ERR_CONNECTION_REFUSED` for every live island, on the framework's
243
- * own account. With no web role to follow, 0 still goes to the kernel. The reported url is the
244
- * listener's own bound address either way, never a string built from the port that was requested.
280
+ * Port 0 IS passed through to the kernel, web role or not. A scratch server (`x shot`, `ui.shot`,
281
+ * a test) asks for 0, and its pages dial `/_x/sync` on their OWN origin — the web role's mount
282
+ * (`sync-url.ts`) — so nothing computes this listener's port from the web port's. Deriving
283
+ * `PORT + 1` from the kernel's answer asked for one specific port nobody had checked, and under
284
+ * load another socket held it: the boot died `X_PORT_IN_USE` (role-realtime.test.ts, 6 of 20 with
285
+ * 12,000 loopback connections held). The reported url is the listener's own bound address.
245
286
  */
246
- function syncPortFrom(requested: number, appUrl: string | null): number {
247
- if (requested !== 0 || appUrl === null) return syncPortFor(requested);
248
- const bound = Number(new URL(appUrl).port);
249
- return Number.isInteger(bound) && bound > 0 ? syncPortFor(bound) : 0;
250
- }
251
-
252
287
  async function listen(
253
288
  options: StartRolesOptions,
254
289
  node: SyncNode,
255
290
  feeds: Pick<RunningSync, 'registry' | 'hub'>,
256
291
  appUrl: string | null,
257
292
  ): Promise<RunningSync> {
258
- const port = syncPortFrom(options.port, appUrl);
293
+ const port = syncPortFor(options.port);
259
294
  try {
260
295
  // The SAME interface the web role binds, resolved from the same option and the same default.
261
296
  // Without this the sync node took Bun's `0.0.0.0` while `x dev`'s web role took `localhost`,
@@ -0,0 +1,45 @@
1
+ // The worker role's cross-process wake, and the one series that says whether it is PROVEN. The
2
+ // mechanism is `@ultimat3/jobs`' (`startQueueWake`); this decides who holds the LISTEN session —
3
+ // the worker, and only over a client that can listen — and publishes `queue_wake_live`, because a
4
+ // transaction-pooling proxy disables the wake in silence and the poll hides that it did.
5
+
6
+ import { gauge } from '@ultimat3/core';
7
+ import type { DbClient } from '@ultimat3/db';
8
+ import { canListen } from '@ultimat3/db';
9
+ import type { QueueWake } from '@ultimat3/jobs';
10
+ import { startQueueWake } from '@ultimat3/jobs';
11
+ import { pgExecutorFor } from './runtime-queue';
12
+
13
+ // The wake the gauge reads. One observer for the life of the process: a gauge redeclared with a
14
+ // different `observe` is refused, and `x dev` starts the roles again on every restart-in-place.
15
+ let current: QueueWake | undefined;
16
+ let declared = false;
17
+
18
+ const wakeLive = (): number => (current?.live() === true ? 1 : 0);
19
+
20
+ /**
21
+ * Starts the wake for a worker, or answers `null` when the client cannot hold a session (a
22
+ * hand-built runtime): the claim loop and the relay then keep the poll they always had.
23
+ */
24
+ export function startWorkerWake(db: DbClient): QueueWake | null {
25
+ if (!canListen(db)) return null;
26
+ if (!declared) {
27
+ declared = true;
28
+ gauge('queue_wake_live', {
29
+ unit: '1',
30
+ description:
31
+ '1 while a notification has crossed the worker LISTEN session, 0 while jobs start on the poll alone',
32
+ observe: wakeLive,
33
+ });
34
+ }
35
+ const wake = startQueueWake({ listener: db, executor: pgExecutorFor(db) });
36
+ current = wake;
37
+ return {
38
+ live: () => wake.live(),
39
+ async stop(): Promise<void> {
40
+ // Only the wake still published: a stop of one that was already replaced silences nothing.
41
+ if (current === wake) current = undefined;
42
+ await wake.stop();
43
+ },
44
+ };
45
+ }
@@ -0,0 +1,141 @@
1
+ // Mounting the app's generated admin. `defineAdmin()` declares it; this file is the whole of what
2
+ // serves it — one catch-all under each admin's base path, in `x dev` and in the container alike,
3
+ // through the same document pipeline every page uses. The CLI contributes the HTTP crossing and
4
+ // nothing else: which screen a URL names, who may see it and what it renders are the admin's.
5
+
6
+ import type { AdminApp, AdminRouteConfig } from '@ultimat3/admin';
7
+ import type { Route, RouteMeta } from '@ultimat3/http';
8
+ import { asCtx, html, redirect, routeNotFound } from '@ultimat3/http';
9
+ import type { RouteEntry } from '@ultimat3/render';
10
+ import { compilePattern } from '@ultimat3/render';
11
+ import { renderSsr } from '@ultimat3/render/server';
12
+ import type { DocumentOptions } from './document-options';
13
+ import { routeDocument } from './runtime-render';
14
+
15
+ /**
16
+ * `@ultimat3/admin`'s `ADMIN_MOUNTS`, read off the GLOBAL symbol registry rather than imported.
17
+ * Importing that package loads every screen and registers its stylesheets, so an app that declared
18
+ * no admin would carry one into its documents (axiom 6); asking the registry costs nothing. An app
19
+ * that DID declare one has already loaded the package, which is when `screens()` below imports it.
20
+ */
21
+ const ADMIN_MOUNTS: symbol = Symbol.for('ultimate.admin.mounts');
22
+
23
+ /** The file a mounted screen is attributed to — a package, because no app file declares it. */
24
+ export const ADMIN_MOUNT_FILE = '@ultimat3/admin';
25
+
26
+ const declared = (): ReadonlyMap<string, AdminApp> => {
27
+ const held = (globalThis as { [key: symbol]: unknown })[ADMIN_MOUNTS];
28
+ return held instanceof Map ? (held as ReadonlyMap<string, AdminApp>) : new Map();
29
+ };
30
+
31
+ export interface AdminMountOptions extends Pick<DocumentOptions, 'themeHead' | 'origin'> {
32
+ readonly buildId: string;
33
+ }
34
+
35
+ /**
36
+ * The route table's own entry shape, for a screen nobody registered there: the document builder
37
+ * reads a `RouteEntry`, and an admin screen is a route — `ssr`, gated, `hydrate: 'never'` — whose
38
+ * component is the body the screen just answered with.
39
+ */
40
+ const entryFor = (route: AdminRouteConfig, body: unknown): RouteEntry => ({
41
+ file: ADMIN_MOUNT_FILE,
42
+ path: route.path,
43
+ // An admin screen is behind sign-in: it carries the `app/` surface's stylesheet, never `site/`'s.
44
+ surface: 'app',
45
+ config: route.config,
46
+ suspenseBoundaries: 0,
47
+ islands: [],
48
+ pattern: compilePattern(route.path),
49
+ component: () => body,
50
+ });
51
+
52
+ /**
53
+ * `auth: 'required'` is the pipeline's half — nobody anonymous reaches a screen, and a sign-in
54
+ * redirect is the app's own. `enforcedBy: 'handler'` is the other half: the screen holds the row a
55
+ * row-level rule reads, so deciding in the `authz` stage too would be a second authz system that
56
+ * knows strictly less and answers first.
57
+ */
58
+ const metaFor = (base: string): RouteMeta => ({
59
+ name: `admin:${base}`,
60
+ auth: 'required',
61
+ policy: 'admin:read',
62
+ enforcedBy: 'handler',
63
+ render: 'ssr',
64
+ tags: ['admin'],
65
+ });
66
+
67
+ async function answer(
68
+ base: string,
69
+ method: 'GET' | 'POST',
70
+ options: AdminMountOptions,
71
+ request: Parameters<Route['handler']>[0],
72
+ context: Parameters<Route['handler']>[1],
73
+ ): Promise<Response> {
74
+ // Read per request, never captured: `x dev` re-evaluates the module that declares the admin
75
+ // when it changes, and the admin a request meets is the one the author just saved.
76
+ const app = declared().get(base);
77
+ // Dynamic, and only here: reached only when an admin is mounted, by which point the app has
78
+ // already loaded the package — and after `loadApp` installed the `.tsx` loader its screens need.
79
+ const { adminRouteMatch } = await import('@ultimat3/admin');
80
+ const matched = app === undefined ? null : adminRouteMatch(app, request.url.pathname);
81
+ if (app === undefined || matched === null) throw routeNotFound(method, request.url.pathname);
82
+
83
+ const form = method === 'POST' ? await request.bodyRaw() : null;
84
+ const response = await matched.route.respond({
85
+ ctx: await app.requestCtx(request.raw),
86
+ params: matched.params,
87
+ url: request.url.href,
88
+ method,
89
+ // A POST with no body is a form with no fields — a button-only form posts exactly that.
90
+ form: method === 'POST' ? (isRecord(form) ? form : {}) : null,
91
+ });
92
+ if (response.kind === 'redirect') {
93
+ // 303, always: the browser must GET the page it lands on, not re-POST the form at it.
94
+ const moved = redirect(response.location, 303);
95
+ moved.headers.set('cache-control', 'private, no-store');
96
+ return moved;
97
+ }
98
+
99
+ const entry = entryFor(matched.route, response.body);
100
+ const data = { url: request.url.href, params: matched.params };
101
+ const result = await renderSsr(
102
+ { entry, params: matched.params, url: request.url, ctx: asCtx(context) },
103
+ () =>
104
+ routeDocument(entry, data, {
105
+ ...(options.themeHead === undefined ? {} : { themeHead: options.themeHead }),
106
+ ...(options.origin === undefined ? {} : { origin: options.origin }),
107
+ }),
108
+ { buildId: options.buildId, status: response.status },
109
+ );
110
+ // `renderSsr` answers the string the render function returned; a stream is `stream`'s shape.
111
+ const document = typeof result.body === 'string' ? result.body : '';
112
+ return html(document, { status: result.status, headers: result.headers });
113
+ }
114
+
115
+ const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
116
+ typeof value === 'object' && value !== null && !Array.isArray(value);
117
+
118
+ const METHODS: readonly ('GET' | 'POST')[] = ['GET', 'POST'];
119
+
120
+ /**
121
+ * Two paths per admin and two methods on each: the base path itself (the dashboard) and one
122
+ * catch-all under it. The admin's own table is matched INSIDE the handler, against the table as
123
+ * it is now — so a page added in a save is served without this list being rebuilt, and a URL the
124
+ * admin does not declare is the framework's ordinary 404.
125
+ *
126
+ * Empty for an app that declared no admin: nothing is mounted, and nothing is imported.
127
+ */
128
+ export function adminMountRoutes(options: AdminMountOptions): readonly Route[] {
129
+ return [...declared().keys()].flatMap((base) =>
130
+ [base, `${base}/*rest`].flatMap((path) =>
131
+ METHODS.map(
132
+ (method): Route => ({
133
+ method,
134
+ path,
135
+ meta: metaFor(base),
136
+ handler: (request, context) => answer(base, method, options, request, context),
137
+ }),
138
+ ),
139
+ ),
140
+ );
141
+ }