@ultimat3/cli 20.2.1 → 22.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 (237) hide show
  1. package/CLAUDE.md +84 -1582
  2. package/README.md +40 -2
  3. package/package.json +31 -31
  4. package/src/api-registration.ts +124 -0
  5. package/src/app-artifacts.ts +39 -0
  6. package/src/app-boundaries.ts +5 -10
  7. package/src/app-env.ts +2 -2
  8. package/src/app-load.ts +37 -5
  9. package/src/app-openapi.ts +27 -0
  10. package/src/app-permissions.ts +0 -0
  11. package/src/app-root.ts +1 -1
  12. package/src/async-pages.ts +32 -0
  13. package/src/bin.ts +7 -1
  14. package/src/boundary-findings.ts +36 -0
  15. package/src/browser-launcher-fake-html.ts +80 -0
  16. package/src/browser-launcher-fake.ts +165 -0
  17. package/src/browser-launcher-port.ts +159 -0
  18. package/src/browser-launcher.ts +26 -133
  19. package/src/budgets.ts +56 -13
  20. package/src/build-errors.ts +54 -0
  21. package/src/cdp-shot-a11y.ts +77 -0
  22. package/src/cdp-shot-clock.ts +14 -0
  23. package/src/cdp-shot-driver.ts +150 -0
  24. package/src/cdp-shot-element.ts +147 -0
  25. package/src/cdp-shot-errors.ts +62 -0
  26. package/src/cdp-shot-keys.ts +152 -0
  27. package/src/cdp-shot-page.ts +230 -0
  28. package/src/cdp-shot-watch.ts +241 -0
  29. package/src/cmd-affected-spec.ts +24 -0
  30. package/src/cmd-affected.ts +3 -19
  31. package/src/cmd-build-spec.ts +16 -0
  32. package/src/cmd-build.ts +46 -20
  33. package/src/cmd-ci-spec.ts +24 -0
  34. package/src/cmd-ci.ts +17 -21
  35. package/src/cmd-db-branch.ts +2 -2
  36. package/src/cmd-db-spec.ts +99 -0
  37. package/src/cmd-db.ts +15 -94
  38. package/src/cmd-deploy-helm.ts +136 -0
  39. package/src/cmd-deploy-spec.ts +40 -0
  40. package/src/cmd-deploy.ts +117 -42
  41. package/src/cmd-dev-spec.ts +25 -0
  42. package/src/cmd-dev.ts +78 -122
  43. package/src/cmd-docs-spec.ts +16 -0
  44. package/src/cmd-docs.ts +2 -11
  45. package/src/cmd-doctor-spec.ts +21 -0
  46. package/src/cmd-doctor.ts +4 -18
  47. package/src/cmd-env-spec.ts +18 -0
  48. package/src/cmd-env.ts +4 -13
  49. package/src/cmd-errors-spec.ts +23 -0
  50. package/src/cmd-errors.ts +3 -17
  51. package/src/cmd-fix-spec.ts +18 -0
  52. package/src/cmd-fix.ts +4 -13
  53. package/src/cmd-generate-spec.ts +31 -0
  54. package/src/cmd-generate.ts +46 -42
  55. package/src/cmd-i18n-spec.ts +16 -0
  56. package/src/cmd-i18n.ts +7 -14
  57. package/src/cmd-jobs-spec.ts +56 -0
  58. package/src/cmd-jobs.ts +3 -50
  59. package/src/cmd-manifest-spec.ts +15 -0
  60. package/src/cmd-manifest.ts +19 -20
  61. package/src/cmd-mcp-spec.ts +21 -0
  62. package/src/cmd-mcp.ts +2 -15
  63. package/src/cmd-new-spec.ts +33 -0
  64. package/src/cmd-new.ts +38 -33
  65. package/src/cmd-policy-spec.ts +13 -0
  66. package/src/cmd-policy.ts +2 -9
  67. package/src/cmd-pr-spec.ts +39 -0
  68. package/src/cmd-pr.ts +5 -65
  69. package/src/cmd-registries-spec.ts +32 -0
  70. package/src/cmd-registries.ts +4 -25
  71. package/src/cmd-routes-spec.ts +12 -0
  72. package/src/cmd-routes.ts +2 -7
  73. package/src/cmd-secrets-spec.ts +19 -0
  74. package/src/cmd-secrets.ts +22 -34
  75. package/src/cmd-shot-island.ts +12 -13
  76. package/src/cmd-shot-spec.ts +53 -0
  77. package/src/cmd-shot.ts +11 -60
  78. package/src/cmd-tasks-spec.ts +21 -0
  79. package/src/cmd-tasks.ts +2 -16
  80. package/src/cmd-test-spec.ts +54 -0
  81. package/src/cmd-test.ts +4 -49
  82. package/src/cmd-verify-spec.ts +28 -0
  83. package/src/cmd-verify.ts +3 -23
  84. package/src/db-seed.ts +4 -2
  85. package/src/dev-dashboard.ts +4 -4
  86. package/src/dev-lock.ts +18 -2
  87. package/src/dev-port.ts +21 -0
  88. package/src/dev-route-table.ts +119 -0
  89. package/src/dispatch.ts +12 -5
  90. package/src/document-styles.ts +1 -1
  91. package/src/drift.ts +4 -18
  92. package/src/error-catalog.ts +16 -9
  93. package/src/error-codes.ts +33 -28
  94. package/src/error-pages.ts +19 -7
  95. package/src/error-unthrown.ts +130 -0
  96. package/src/errors.ts +22 -29
  97. package/src/favicon.ts +2 -2
  98. package/src/fix-imports.ts +1 -1
  99. package/src/fix-scan.ts +2 -9
  100. package/src/flag-reads.ts +1 -2
  101. package/src/foreign-text.ts +36 -0
  102. package/src/framework-schema.ts +5 -6
  103. package/src/generate-feature.ts +42 -0
  104. package/src/generate-files.ts +7 -3
  105. package/src/generate-grants.ts +83 -0
  106. package/src/generate-kinds.ts +56 -4
  107. package/src/i18n-index.ts +59 -10
  108. package/src/icon-assets.ts +1 -1
  109. package/src/image-prepare.ts +14 -0
  110. package/src/import-scan.ts +63 -0
  111. package/src/index.ts +67 -334
  112. package/src/invocation-flags.ts +26 -0
  113. package/src/island-bundle.ts +38 -11
  114. package/src/island-capture.ts +6 -6
  115. package/src/island-harness-script.ts +6 -2
  116. package/src/island-realtime.ts +98 -0
  117. package/src/island-shot.ts +5 -0
  118. package/src/island-store.ts +131 -0
  119. package/src/island-verdict.ts +2 -2
  120. package/src/job-registration.ts +42 -0
  121. package/src/jobs-driver.ts +2 -2
  122. package/src/live-routes.ts +82 -42
  123. package/src/load-findings.ts +51 -0
  124. package/src/mcp-db-target.ts +1 -1
  125. package/src/mcp-errors.ts +30 -26
  126. package/src/mcp-host.ts +27 -12
  127. package/src/mcp-ui-diff.ts +27 -0
  128. package/src/mcp-ui-inspect.ts +4 -4
  129. package/src/mcp-ui-interact.ts +21 -15
  130. package/src/mcp-ui.ts +19 -15
  131. package/src/measure-database.ts +73 -0
  132. package/src/measure-paths.ts +74 -0
  133. package/src/measure-scope.ts +74 -0
  134. package/src/messages.ts +1 -3
  135. package/src/metrics-endpoint.ts +1 -1
  136. package/src/otlp-export.ts +1 -1
  137. package/src/output.ts +6 -0
  138. package/src/page-sync.ts +54 -0
  139. package/src/permission-grants.ts +86 -0
  140. package/src/prerender-out.ts +25 -0
  141. package/src/prerender.ts +121 -102
  142. package/src/pwa-artifacts.ts +3 -3
  143. package/src/realtime-browser-probe-fixture.ts +2 -2
  144. package/src/reexport-manifest.ts +2 -1
  145. package/src/registry.ts +80 -56
  146. package/src/role-realtime.ts +36 -0
  147. package/src/{dev-replicator.ts → role-replicator.ts} +1 -1
  148. package/src/{dev-roles-fixture.ts → role-start-fixture.ts} +9 -5
  149. package/src/role-start-types.ts +112 -0
  150. package/src/{dev-roles.ts → role-start.ts} +42 -115
  151. package/src/{dev-sync.ts → role-sync.ts} +23 -11
  152. package/src/root-env.ts +67 -0
  153. package/src/{dev-assets.ts → runtime-assets.ts} +7 -7
  154. package/src/{dev-services.ts → runtime-bindings.ts} +43 -17
  155. package/src/{dev-cache.ts → runtime-cache.ts} +2 -2
  156. package/src/runtime-jobs.ts +87 -0
  157. package/src/{dev-live-feed.ts → runtime-live-feed.ts} +20 -5
  158. package/src/{dev-notify-retention.ts → runtime-notify-retention.ts} +1 -1
  159. package/src/{dev-purge.ts → runtime-purge.ts} +2 -2
  160. package/src/{dev-queue.ts → runtime-queue.ts} +4 -4
  161. package/src/runtime-realtime.ts +55 -0
  162. package/src/{dev-render.ts → runtime-render.ts} +119 -20
  163. package/src/{dev-replica.ts → runtime-replica.ts} +2 -2
  164. package/src/{dev-runtime.ts → runtime-services.ts} +41 -17
  165. package/src/{dev-storage.ts → runtime-storage.ts} +4 -4
  166. package/src/scaffold-fixture.ts +28 -6
  167. package/src/scaffold-typecheck.ts +6 -3
  168. package/src/schema-drift.ts +7 -1
  169. package/src/script-csp.ts +5 -2
  170. package/src/secrets-rotation.ts +59 -0
  171. package/src/serve-boot.ts +192 -0
  172. package/src/serve-drain.ts +24 -0
  173. package/src/serve-entry.ts +6 -0
  174. package/src/serve-env.ts +116 -0
  175. package/src/serve-types.ts +55 -0
  176. package/src/serve.ts +44 -338
  177. package/src/shot-server.ts +2 -2
  178. package/src/shot-settle.ts +10 -1
  179. package/src/shot-theme.ts +3 -3
  180. package/src/shot-verdict.ts +16 -7
  181. package/src/signal-shred.ts +27 -0
  182. package/src/solid-loader.ts +26 -2
  183. package/src/static-report.ts +8 -1
  184. package/src/sw-artifacts.ts +13 -3
  185. package/src/sync-url.ts +31 -0
  186. package/src/templates/action.ts +30 -16
  187. package/src/templates/entity.ts +12 -7
  188. package/src/templates/index.ts +1 -1
  189. package/src/templates/job.ts +10 -7
  190. package/src/templates/policy.ts +20 -2
  191. package/src/templates/resource-create.ts +127 -0
  192. package/src/templates/resource-form-island.ts +76 -30
  193. package/src/templates/resource.ts +10 -4
  194. package/src/templates/route.ts +3 -0
  195. package/src/templates/scaffold-app.ts +4 -1
  196. package/src/templates/scaffold-auth.ts +3 -1
  197. package/src/templates/scaffold-container-compose.ts +184 -0
  198. package/src/templates/scaffold-container.ts +25 -145
  199. package/src/templates/scaffold-dashboard-example.ts +2 -2
  200. package/src/templates/scaffold-db-package.ts +16 -3
  201. package/src/templates/scaffold-demo-org.ts +41 -0
  202. package/src/templates/scaffold-entries.ts +1 -1
  203. package/src/templates/scaffold-env.ts +6 -0
  204. package/src/templates/scaffold-helm-templates.ts +66 -7
  205. package/src/templates/scaffold-helm.ts +27 -5
  206. package/src/templates/scaffold-i18n.ts +15 -10
  207. package/src/templates/scaffold-repo.ts +13 -10
  208. package/src/templates/scaffold-roles.ts +38 -10
  209. package/src/templates/slice-foundation.ts +1 -1
  210. package/src/templates/wrap.ts +4 -1
  211. package/src/test-passes.ts +2 -1
  212. package/src/test-workers.ts +26 -0
  213. package/src/ts-scan.ts +3 -6
  214. package/src/tsconfig-references.ts +1 -2
  215. package/src/verify-checks.ts +41 -39
  216. package/src/verify-e2e.ts +41 -0
  217. package/src/verify-run.ts +115 -50
  218. package/src/verify-step.ts +3 -3
  219. package/src/verify-tests.ts +22 -30
  220. package/src/verify-typecheck.ts +28 -0
  221. package/src/web-binding.ts +2 -2
  222. package/src/worker-bundle.ts +192 -0
  223. package/src/workspace-graph.ts +10 -33
  224. package/src/cdp-browser.ts +0 -100
  225. package/src/cdp-connection.ts +0 -211
  226. package/src/cdp-e2e-page.ts +0 -209
  227. package/src/cdp-errors.ts +0 -56
  228. package/src/cdp-launch.ts +0 -139
  229. package/src/e2e-dom-fixture.ts +0 -117
  230. package/src/e2e-driver.ts +0 -97
  231. package/src/e2e-errors.ts +0 -103
  232. package/src/e2e-evaluate.ts +0 -156
  233. package/src/e2e-locator.ts +0 -86
  234. package/src/e2e-page.ts +0 -150
  235. package/src/e2e-selection.ts +0 -182
  236. package/src/measurement-actor.ts +0 -26
  237. /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
@@ -0,0 +1,112 @@
1
+ // The options `startRoles` takes and what it answers. Split from `role-start.ts` at its line
2
+ // ceiling; `role-start.ts` re-exports both, so every importer keeps its path.
3
+
4
+ import type { DrainConfig, Role } from '@ultimat3/core';
5
+ import type { Route, ServerHandle, ServerHooks } from '@ultimat3/http';
6
+ import type { Scheduler, Worker } from '@ultimat3/jobs';
7
+ import type { LiveQueryRegistry, LiveReplicator } from '@ultimat3/realtime/server';
8
+ import type { MetricsEndpoint } from './metrics-endpoint';
9
+ import type { RunningReplicator } from './role-replicator';
10
+ import type { Env } from './runtime-bindings';
11
+ import type { LiveFeed } from './runtime-live-feed';
12
+ import type { RuntimeOverrides } from './runtime-overrides';
13
+ import type { RunningServices } from './runtime-services';
14
+ import type { WebBinding } from './web-binding';
15
+
16
+ /** The roles `x dev` starts when `--role` names none, in boot order. */
17
+ export const DEV_ROLES: readonly Role[] = ['web', 'sync', 'worker', 'scheduler'];
18
+
19
+ export interface StartRolesOptions {
20
+ readonly roles: readonly Role[];
21
+ readonly port: number;
22
+ readonly buildId: string;
23
+ readonly runtime: RunningServices;
24
+ /** Routes the web role serves: `/_x`, the actions, the pages. */
25
+ readonly routes: readonly Route[];
26
+ /** The process environment, for the roles that resolve a driver from it. */
27
+ readonly env: Env;
28
+ /**
29
+ * How the web role binds and what it admits about itself. `x dev` keeps the default —
30
+ * loopback, `dev: true`, so a laptop on a café network is not serving the app to the café. A
31
+ * container passes `{ dev: false, hostname: '0.0.0.0' }`: a process bound to `localhost` inside
32
+ * a container is unreachable from the port mapping, the load balancer and every PaaS health
33
+ * probe, which is the same failure in four costumes.
34
+ */
35
+ readonly http?: WebBinding;
36
+ /**
37
+ * The app's `auth.signInPath`. Threaded rather than read from the config here because
38
+ * `startRoles` takes plain values — a test starts a web role with no `app.config.ts` at all.
39
+ */
40
+ readonly signInPath?: string | null;
41
+ /**
42
+ * The app root, for the one seam that is a FILE and not a value: `apps/web/site/errors/404.html`
43
+ * and its siblings. Bound HERE rather than passed by each caller, because `x dev` and `serve.ts`
44
+ * both boot through this function and an override wired at one of them alone is a page that
45
+ * appears in dev and not in production — `/favicon.ico`'s rule, one seam over.
46
+ *
47
+ * Optional for the reason `signInPath` is: `startRoles` takes plain values, and a test starts a
48
+ * web role with no app on disk at all. Absent, every error page is the framework's.
49
+ */
50
+ readonly root?: string;
51
+ /**
52
+ * Inline `<style>` bodies this process serves that the app's own surfaces do not account for —
53
+ * `/_x`'s shell. The surfaces themselves are read from the stylesheet registry here rather than
54
+ * passed, so no caller of `startRoles` can ship a web server whose CSP blocks the pages it
55
+ * serves: that policy is what rendered every deployed app completely unstyled.
56
+ */
57
+ readonly inlineStyles?: readonly string[];
58
+ /** `script-src` sources beyond the hydration runtime's — the theme boot's hash. */
59
+ readonly inlineScripts?: readonly string[];
60
+ /**
61
+ * Non-fatal findings the browser overlay shows next to an error, for the request being answered.
62
+ * Only `x dev` supplies one — `serve.ts` boots through this same function and omits it, so a
63
+ * production process never has a diagnostic to call (axiom 6).
64
+ */
65
+ readonly devNotices?: ServerHooks['devNotices'];
66
+ /**
67
+ * Where the scrape listener binds. Defaults to `DEFAULT_METRICS_PORT`, except when `port` is 0
68
+ * — a caller asking the kernel for an ephemeral HTTP port is a test, and a test that grabbed
69
+ * 9090 would fail the next one to run beside it.
70
+ */
71
+ readonly metricsPort?: number;
72
+ /**
73
+ * What the host substituted for a boot decision. Read here for the three seams that are not
74
+ * services — the rate-limit store, the middleware chain and the sync authenticator — while the
75
+ * drivers themselves arrive already resolved on `runtime`.
76
+ */
77
+ readonly overrides?: RuntimeOverrides;
78
+ /**
79
+ * `app.config.ts`'s `drain` section, for the web server — its readiness grace is how long
80
+ * `/readyz` answers 503 before the listener closes. `serve.ts` passes it; absent, core's own
81
+ * default stands.
82
+ */
83
+ readonly drain?: Partial<DrainConfig>;
84
+ /**
85
+ * A scrape listener the caller opened before its own boot work, adopted rather than bound twice:
86
+ * `serve.ts` opens it before loading the app and building islands, so a cold pod answers
87
+ * `/metrics` — and the chart's startup probe — while it boots. Stopped with the roles.
88
+ */
89
+ readonly metrics?: MetricsEndpoint;
90
+ }
91
+
92
+ export interface RunningRoles {
93
+ readonly roles: readonly Role[];
94
+ /** `http://…` once the web role is up; null when it was not selected. */
95
+ readonly url: string | null;
96
+ /** Where the sync role accepts websockets; null when it was not selected. */
97
+ readonly syncUrl: string | null;
98
+ /** `http://…` — the scrape base. Never null: every role publishes a signal worth scaling on. */
99
+ readonly metricsUrl: string;
100
+ readonly server: ServerHandle | null;
101
+ readonly worker: Worker | null;
102
+ readonly scheduler: Scheduler | null;
103
+ /** The slot and feed this process holds; null when the replicator was not selected. */
104
+ readonly replicator: RunningReplicator | null;
105
+ /** What feeds the sync node: this process's own writes, the WAL decoder, or nothing. */
106
+ readonly liveFeed: LiveFeed;
107
+ /** The in-process bridge when `liveFeed` is `in-process`, so a test can await `settled()`. */
108
+ readonly liveBridge: LiveReplicator | null;
109
+ /** The sync node's registry, so a test can hold a real subscription; null without the role. */
110
+ readonly liveRegistry: LiveQueryRegistry | null;
111
+ stop(): Promise<void>;
112
+ }
@@ -8,13 +8,7 @@
8
8
 
9
9
  import type { Role } from '@ultimat3/core';
10
10
  import { createContext, isRole, logger, ROLES } from '@ultimat3/core';
11
- import type {
12
- RateLimitStore,
13
- Route,
14
- ServerHandle,
15
- ServerHooks,
16
- WebSocketMount,
17
- } from '@ultimat3/http';
11
+ import type { RateLimitStore, Route, ServerHandle, WebSocketMount } from '@ultimat3/http';
18
12
  import {
19
13
  configuredAuthenticator,
20
14
  configuredHttp,
@@ -23,7 +17,7 @@ import {
23
17
  MAX_PROXY_HOPS,
24
18
  mergeHttpConfig,
25
19
  } from '@ultimat3/http';
26
- import type { OutboxRelay, Scheduler, Worker } from '@ultimat3/jobs';
20
+ import type { OutboxRelay } from '@ultimat3/jobs';
27
21
  import {
28
22
  createOutboxRelay,
29
23
  createPgLeaseLeader,
@@ -32,27 +26,27 @@ import {
32
26
  jobDriver,
33
27
  pgSchedulerState,
34
28
  } from '@ultimat3/jobs';
35
- import type { LiveQueryRegistry, SyncWs } from '@ultimat3/realtime/server';
36
- import type { LiveReplicator } from '@ultimat3/testing';
37
- import { devHooks } from './dev-hooks';
38
- import type { LiveFeed } from './dev-live-feed';
39
- import { startLiveFeed } from './dev-live-feed';
40
- import { pgExecutorFor } from './dev-queue';
41
- import type { RunningReplicator } from './dev-replicator';
42
- import { startReplicator } from './dev-replicator';
43
- import type { RunningServices } from './dev-runtime';
44
- import type { Env } from './dev-services';
45
- import { prepareSync, type RunningSync } from './dev-sync';
29
+ import type { SyncWs } from '@ultimat3/realtime/server';
46
30
  import { errorPageHook } from './error-pages';
47
31
  import { BadFlagError, PortInvalidError, RuntimeDriverSplitError } from './errors';
48
32
  import { DEFAULT_METRICS_PORT, startMetricsEndpoint } from './metrics-endpoint';
49
- import type { RuntimeOverrides } from './runtime-overrides';
33
+ import { rolesUnderRealtime } from './role-realtime';
34
+ import { startReplicator } from './role-replicator';
35
+ import type { RunningRoles, StartRolesOptions } from './role-start-types';
36
+ import { DEV_ROLES } from './role-start-types';
37
+ import { prepareSync, type RunningSync } from './role-sync';
38
+ import type { Env } from './runtime-bindings';
39
+ import { devHooks } from './runtime-hooks';
40
+ import { workerOptionsFor } from './runtime-jobs';
41
+ import { startLiveFeed } from './runtime-live-feed';
42
+ import { pgExecutorFor } from './runtime-queue';
43
+ import type { RunningServices } from './runtime-services';
50
44
  import { inlineScriptSources } from './script-csp';
51
45
  import { inlineStyleSources } from './style-csp';
52
- import { DEV_BINDING, type WebBinding } from './web-binding';
46
+ import { DEV_BINDING } from './web-binding';
53
47
 
54
- /** The roles `x dev` starts when `--role` names none, in boot order. */
55
- export const DEV_ROLES: readonly Role[] = ['web', 'sync', 'worker', 'scheduler'];
48
+ // Declared beside the option types so `x dev`'s spec reads it without loading this module.
49
+ export { DEV_ROLES } from './role-start-types';
56
50
 
57
51
  /**
58
52
  * What `--role` accepts. The replicator is here but not in `DEV_ROLES`: opt-in, because it takes
@@ -61,93 +55,13 @@ export const DEV_ROLES: readonly Role[] = ['web', 'sync', 'worker', 'scheduler']
61
55
  */
62
56
  export const SELECTABLE_ROLES: readonly Role[] = [...DEV_ROLES, 'replicator'];
63
57
 
64
- export interface StartRolesOptions {
65
- readonly roles: readonly Role[];
66
- readonly port: number;
67
- readonly buildId: string;
68
- readonly runtime: RunningServices;
69
- /** Routes the web role serves: `/_x`, the actions, the pages. */
70
- readonly routes: readonly Route[];
71
- /** The process environment, for the roles that resolve a driver from it. */
72
- readonly env: Env;
73
- /**
74
- * How the web role binds and what it admits about itself. `x dev` keeps the default —
75
- * loopback, `dev: true`, so a laptop on a café network is not serving the app to the café. A
76
- * container passes `{ dev: false, hostname: '0.0.0.0' }`: a process bound to `localhost` inside
77
- * a container is unreachable from the port mapping, the load balancer and every PaaS health
78
- * probe, which is the same failure in four costumes.
79
- */
80
- readonly http?: WebBinding;
81
- /**
82
- * The app's `auth.signInPath`. Threaded rather than read from the config here because
83
- * `startRoles` takes plain values — a test starts a web role with no `app.config.ts` at all.
84
- */
85
- readonly signInPath?: string | null;
86
- /**
87
- * The app root, for the one seam that is a FILE and not a value: `apps/web/site/errors/404.html`
88
- * and its siblings. Bound HERE rather than passed by each caller, because `x dev` and `serve.ts`
89
- * both boot through this function and an override wired at one of them alone is a page that
90
- * appears in dev and not in production — `/favicon.ico`'s rule, one seam over.
91
- *
92
- * Optional for the reason `signInPath` is: `startRoles` takes plain values, and a test starts a
93
- * web role with no app on disk at all. Absent, every error page is the framework's.
94
- */
95
- readonly root?: string;
96
- /**
97
- * Inline `<style>` bodies this process serves that the app's own surfaces do not account for —
98
- * `/_x`'s shell. The surfaces themselves are read from the stylesheet registry here rather than
99
- * passed, so no caller of `startRoles` can ship a web server whose CSP blocks the pages it
100
- * serves: that policy is what rendered every deployed app completely unstyled.
101
- */
102
- readonly inlineStyles?: readonly string[];
103
- /** `script-src` sources beyond the hydration runtime's — the theme boot's hash. */
104
- readonly inlineScripts?: readonly string[];
105
- /**
106
- * Non-fatal findings the browser overlay shows next to an error, for the request being answered.
107
- * Only `x dev` supplies one — `serve.ts` boots through this same function and omits it, so a
108
- * production process never has a diagnostic to call (axiom 6).
109
- */
110
- readonly devNotices?: ServerHooks['devNotices'];
111
- /**
112
- * Where the scrape listener binds. Defaults to `DEFAULT_METRICS_PORT`, except when `port` is 0
113
- * — a caller asking the kernel for an ephemeral HTTP port is a test, and a test that grabbed
114
- * 9090 would fail the next one to run beside it.
115
- */
116
- readonly metricsPort?: number;
117
- /**
118
- * What the host substituted for a boot decision. Read here for the three seams that are not
119
- * services — the rate-limit store, the middleware chain and the sync authenticator — while the
120
- * drivers themselves arrive already resolved on `runtime`.
121
- */
122
- readonly overrides?: RuntimeOverrides;
123
- }
58
+ // The two shapes this module takes and answers, in a file of their own at the line ceiling.
59
+ export type { RunningRoles, StartRolesOptions } from './role-start-types';
124
60
 
125
- // Re-exported, not re-declared: `web-binding.ts` is a leaf so `dev-sync` can read the default
61
+ // Re-exported, not re-declared: `web-binding.ts` is a leaf so `role-sync` can read the default
126
62
  // without importing this module, which imports it.
127
63
  export { DEV_BINDING, type WebBinding } from './web-binding';
128
64
 
129
- export interface RunningRoles {
130
- readonly roles: readonly Role[];
131
- /** `http://…` once the web role is up; null when it was not selected. */
132
- readonly url: string | null;
133
- /** Where the sync role accepts websockets; null when it was not selected. */
134
- readonly syncUrl: string | null;
135
- /** `http://…` — the scrape base. Never null: every role publishes a signal worth scaling on. */
136
- readonly metricsUrl: string;
137
- readonly server: ServerHandle | null;
138
- readonly worker: Worker | null;
139
- readonly scheduler: Scheduler | null;
140
- /** The slot and feed this process holds; null when the replicator was not selected. */
141
- readonly replicator: RunningReplicator | null;
142
- /** What feeds the sync node: this process's own writes, the WAL decoder, or nothing. */
143
- readonly liveFeed: LiveFeed;
144
- /** The in-process bridge when `liveFeed` is `in-process`, so a test can await `settled()`. */
145
- readonly liveBridge: LiveReplicator | null;
146
- /** The sync node's registry, so a test can hold a real subscription; null without the role. */
147
- readonly liveRegistry: LiveQueryRegistry | null;
148
- stop(): Promise<void>;
149
- }
150
-
151
65
  /**
152
66
  * `--role web,worker` picks a subset. An unknown or out-of-scope role is a flag error with the
153
67
  * working invocation in the fix line, never a silently ignored value — which is what it was.
@@ -278,7 +192,7 @@ function rateLimitStoreFor(options: StartRolesOptions): RateLimitStore | undefin
278
192
  return supplied;
279
193
  }
280
194
 
281
- /** `mount` is the sync node's socket, served on THIS port as well as its own — why, in `dev-sync`.
195
+ /** `mount` is the sync node's socket, served on THIS port as well as its own — why, in `role-sync`.
282
196
  * Undefined without the `sync` role, and then this server opens no websocket, as it always did. */
283
197
  function startWeb(options: StartRolesOptions, mount?: WebSocketMount<SyncWs>): ServerHandle {
284
198
  warnIfUnauthenticatable(options.routes);
@@ -289,9 +203,12 @@ function startWeb(options: StartRolesOptions, mount?: WebSocketMount<SyncWs>): S
289
203
  routes: options.routes,
290
204
  role: 'web',
291
205
  ...(mount === undefined ? {} : { websocket: mount }),
206
+ ...(options.drain === undefined ? {} : { drain: options.drain }),
292
207
  hooks: devHooks({
293
208
  ...(options.devNotices === undefined ? {} : { devNotices: options.devNotices }),
294
- ...(options.root === undefined ? {} : { errorPage: errorPageHook(options.root) }),
209
+ ...(options.root === undefined
210
+ ? {}
211
+ : { errorPage: errorPageHook(options.root, { perRequest: binding.dev }) }),
295
212
  }),
296
213
  // Both seams `createServer` already had and `startRoles` passed neither of, so an app's own
297
214
  // middleware could not reach the pipeline any process the framework boots actually runs.
@@ -363,7 +280,8 @@ function assertOneJobDriver(runtime: RunningServices): void {
363
280
  }
364
281
 
365
282
  export async function startRoles(options: StartRolesOptions): Promise<RunningRoles> {
366
- const selected = options.roles;
283
+ // `realtime.enabled` read here, before anything binds: a refusal leaves nothing to unwind.
284
+ const selected = rolesUnderRealtime(options.roles, options.runtime.realtime);
367
285
  assertOneJobDriver(options.runtime);
368
286
  // Roles bind sockets in order, so a role that fails to start has to release the ones before it.
369
287
  // Without this a failed `sync` leaves the web server bound and unreachable by any caller.
@@ -376,10 +294,13 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
376
294
  // the metrics endpoint on Bun's `0.0.0.0` for exactly the caller that asked for loopback —
377
295
  // `x dev`, which passes no `http` at all — so the same gap the sync node had was here too.
378
296
  const binding = options.http ?? DEV_BINDING;
379
- const metrics = startMetricsEndpoint({
380
- port: options.metricsPort ?? (options.port === 0 ? 0 : DEFAULT_METRICS_PORT),
381
- hostname: binding.hostname,
382
- });
297
+ // A container's boot opened it already, before its slow work (`serve-boot.ts`): adopted.
298
+ const metrics =
299
+ options.metrics ??
300
+ startMetricsEndpoint({
301
+ port: options.metricsPort ?? (options.port === 0 ? 0 : DEFAULT_METRICS_PORT),
302
+ hostname: binding.hostname,
303
+ });
383
304
  started.push(async () => metrics.stop());
384
305
 
385
306
  // BUILT here, BOUND below, the web role between them: `web` serves the node's socket on its
@@ -401,6 +322,7 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
401
322
  ? createWorker({
402
323
  driver: options.runtime.jobs,
403
324
  context: () => createContext({ role: 'worker', buildId: options.buildId }),
325
+ ...workerOptionsFor(options.runtime.workerConfig),
404
326
  })
405
327
  : null;
406
328
  worker?.start();
@@ -456,9 +378,14 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
456
378
  if (replicator !== null) started.push(() => replicator.stop());
457
379
 
458
380
  // What feeds the sync node this process booted. The embedded database has no log to decode,
459
- // so under it the node is fed by this process's own repository writes (`dev-live-feed.ts`);
381
+ // so under it the node is fed by this process's own repository writes (`runtime-live-feed.ts`);
460
382
  // with a real database the WAL decoder above is the feed, here or in another process.
461
- const live = await startLiveFeed({ sync, dbMode: options.runtime.services.db.mode });
383
+ const live = await startLiveFeed({
384
+ sync,
385
+ dbMode: options.runtime.services.db.mode,
386
+ transport: options.runtime.transport.name,
387
+ replicatorHere: replicator !== null,
388
+ });
462
389
  started.push(async () => live.stop());
463
390
 
464
391
  return {
@@ -1,5 +1,5 @@
1
1
  // The `sync` role: which live queries this node serves, who is dialling it, and the socket it owns.
2
- // Split from `dev-roles.ts` because it is the one role with an authenticator, a presence registry
2
+ // Split from `role-start.ts` because it is the one role with an authenticator, a presence registry
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';
@@ -16,9 +16,9 @@ import {
16
16
  RingChangeBuffer,
17
17
  SocketRegistry,
18
18
  } from '@ultimat3/realtime/server';
19
- import type { StartRolesOptions } from './dev-roles';
20
19
  import { neighbouringPort, PORT_RANGE, portPairAfter } from './flag-number';
21
20
  import { portFree } from './port-probe';
21
+ import type { StartRolesOptions } from './role-start';
22
22
  import { syncAuthenticator } from './sync-authenticator';
23
23
  import { DEV_BINDING } from './web-binding';
24
24
 
@@ -131,6 +131,8 @@ export interface RunningSync {
131
131
  readonly url: string;
132
132
  /** The node's registry, so the boot can hand it a change feed the database cannot produce. */
133
133
  readonly registry: LiveQueryRegistry;
134
+ /** The node's channel hub — fed the same changes, so a declared channel's `records` flow in dev. */
135
+ readonly hub: ChannelHub;
134
136
  stop(): Promise<void>;
135
137
  }
136
138
 
@@ -162,8 +164,13 @@ export function registerLiveQueries(options: StartRolesOptions): LiveQueryRegist
162
164
  onRowDenied: (event) => logger.debug('live.rows_denied', { ...event }),
163
165
  });
164
166
  const ctx = createContext({ role: 'sync', buildId: options.buildId });
167
+ // The position a snapshot claims: the newest change this node had received when the read began.
168
+ // The read then holds at least that change, so claiming it is true, and every later change is
169
+ // above it. Unwired, every snapshot claimed `''` and a read that landed after any fan-out was
170
+ // discarded as older than its own window (plan 101, slice 06 c).
171
+ const lsn = (): string => registry.lastLsn;
165
172
  for (const target of listQueries()) {
166
- if (target.isLive) registry.register(liveQueryDefinition(target, { ctx }));
173
+ if (target.isLive) registry.register(liveQueryDefinition(target, { ctx, lsn }));
167
174
  }
168
175
  return registry;
169
176
  }
@@ -212,7 +219,7 @@ export async function prepareSync(options: StartRolesOptions): Promise<PreparedS
212
219
  // second copy of `/_x/sync` here is the copy that stays behind when it moves.
213
220
  mount: { path: node.path, fetch: node.fetch, websocket: node.websocket },
214
221
  stop: () => node.stop(),
215
- listen: async (appUrl) => await listen(options, node, registry, appUrl),
222
+ listen: async (appUrl) => await listen(options, node, { registry, hub }, appUrl),
216
223
  };
217
224
  }
218
225
 
@@ -241,7 +248,7 @@ function syncPortFrom(requested: number, appUrl: string | null): number {
241
248
  async function listen(
242
249
  options: StartRolesOptions,
243
250
  node: SyncNode,
244
- registry: LiveQueryRegistry,
251
+ feeds: Pick<RunningSync, 'registry' | 'hub'>,
245
252
  appUrl: string | null,
246
253
  ): Promise<RunningSync> {
247
254
  const port = syncPortFrom(options.port, appUrl);
@@ -251,11 +258,16 @@ async function listen(
251
258
  // so the one socket that streams live database patches was the one socket on every
252
259
  // interface — and `WebBinding`'s own docstring is about not serving a laptop's app to a café.
253
260
  const binding = options.http ?? DEV_BINDING;
254
- // No drain grace: there is one node here and it is the one going away. Its clients reconnect
255
- // to it when `x dev` is back, and a grace that kept their patches flowing meanwhile was five
256
- // seconds of every Ctrl-C (measured 2026-09-06, 5.0s of 5.1s) spent on a reconnect frame
257
- // whose target does not exist yet.
258
- const listener = listenSyncNode(node, { port, hostname: binding.hostname, drainGraceMs: 0 });
261
+ // No drain grace UNDER `x dev`: there is one node and it is the one going away, and a grace
262
+ // there was five seconds of every Ctrl-C (measured 2026-09-06, 5.0s of 5.1s) spent on a
263
+ // reconnect frame whose target does not exist yet. A container is the opposite case — other
264
+ // sync pods are up — and a grace of 0 there dropped every socket at once on SIGTERM, so it
265
+ // takes the node's own default and spreads the reconnects.
266
+ const listener = listenSyncNode(node, {
267
+ port,
268
+ hostname: binding.hostname,
269
+ ...(binding.dev ? { drainGraceMs: 0 } : {}),
270
+ });
259
271
  // BOTH doors, named, once. `sync node ready` said only that a node existed: the first question
260
272
  // a failing browser socket raises — "is the ws server up, and where?" — had no answer anywhere
261
273
  // in the boot output, and the port was never printed at all. It is also what an editor's port
@@ -266,7 +278,7 @@ async function listen(
266
278
  });
267
279
  return {
268
280
  url: listener.url,
269
- registry,
281
+ ...feeds,
270
282
  stop: async () => {
271
283
  listener.stop();
272
284
  await node.stop();
@@ -0,0 +1,67 @@
1
+ // The `.env*` files of the APP ROOT, whatever directory `x` was started from. Bun auto-loads them
2
+ // from the process cwd only, while every command finds its root by walking up — so `x db migrate`
3
+ // run from `apps/web` found the app and none of its `.env`, and silently used the embedded
4
+ // database. Loaded once, after the root is known, never overriding a variable already set.
5
+
6
+ import { isAbsolute, join, resolve } from 'node:path'; // why: Bun ships no path join/resolve.
7
+ import { findAppRoot } from './app-root';
8
+
9
+ type Env = Readonly<Record<string, string | undefined>>;
10
+
11
+ /** `--cwd <dir>` / `--cwd=<dir>` from raw argv, resolved against `cwd` — for the hand-over. */
12
+ export function cwdFromArgv(argv: readonly string[], cwd: string): string {
13
+ const at = argv.findIndex((token) => token === '--cwd' || token.startsWith('--cwd='));
14
+ if (at === -1) return cwd;
15
+ const token = argv[at] ?? '';
16
+ const value = token.startsWith('--cwd=') ? token.slice('--cwd='.length) : argv[at + 1];
17
+ if (value === undefined || value === '') return cwd;
18
+ return isAbsolute(value) ? value : resolve(cwd, value);
19
+ }
20
+
21
+ /** The files Bun itself would load at the root, lowest precedence first. */
22
+ export const rootEnvFiles = (envName: string): readonly string[] => [
23
+ '.env',
24
+ `.env.${envName}`,
25
+ `.env.${envName}.local`,
26
+ ];
27
+
28
+ /**
29
+ * One dotenv line: `KEY=value`, an optional `export `, a `#` comment, a value in single or double
30
+ * quotes (double quotes read `\n`). No `${VAR}` expansion — a root-level default that needs one
31
+ * belongs in the process environment, which always wins here anyway.
32
+ */
33
+ export function parseDotenv(text: string): ReadonlyMap<string, string> {
34
+ const out = new Map<string, string>();
35
+ for (const raw of text.split(/\r?\n/)) {
36
+ const line = raw.trim();
37
+ if (line === '' || line.startsWith('#')) continue;
38
+ const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_.]*)\s*=\s*(.*)$/.exec(line);
39
+ if (match === null) continue;
40
+ const value = (match[2] ?? '').trim();
41
+ const quote = value[0];
42
+ if ((quote === '"' || quote === "'") && value.lastIndexOf(quote) > 0) {
43
+ const inner = value.slice(1, value.lastIndexOf(quote));
44
+ out.set(match[1] ?? '', quote === '"' ? inner.replaceAll('\\n', '\n') : inner);
45
+ } else {
46
+ out.set(match[1] ?? '', value.replace(/\s+#.*$/, ''));
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+
52
+ /**
53
+ * The variables the root's `.env*` files add to `env`: only keys `env` does not already hold, so a
54
+ * real environment variable — and anything Bun already loaded from the cwd — always wins. Empty
55
+ * when there is no app root or the cwd IS the root (Bun loaded those files itself).
56
+ */
57
+ export async function rootEnvAdditions(cwd: string, env: Env): Promise<Record<string, string>> {
58
+ const root = findAppRoot(cwd);
59
+ if (root === undefined || resolve(root.dir) === resolve(cwd)) return {};
60
+ const merged = new Map<string, string>();
61
+ for (const name of rootEnvFiles(env['NODE_ENV'] ?? 'development')) {
62
+ const file = Bun.file(join(root.dir, name));
63
+ if (!(await file.exists())) continue;
64
+ for (const [key, value] of parseDotenv(await file.text())) merged.set(key, value);
65
+ }
66
+ return Object.fromEntries([...merged].filter(([key]) => env[key] === undefined));
67
+ }
@@ -3,7 +3,7 @@
3
3
  // `@ultimat3/pwa` the icons a web manifest promises — and `@ultimat3/core`'s pipeline owns every
4
4
  // pixel, so this file picks the two base paths they hang off and decides nothing else. The one
5
5
  // thing it does NOT decide is who may read a stored object: `/media` borrows that whole answer
6
- // from `dev-storage.ts`, because the same bytes are reachable through both.
6
+ // from `runtime-storage.ts`, because the same bytes are reachable through both.
7
7
 
8
8
  import { probeImage } from '@ultimat3/core';
9
9
  import type { CacheHint, RequestContext, Route, UltimateRequest } from '@ultimat3/http';
@@ -12,12 +12,6 @@ import type { ImageQuery, ImageTransformDriver } from '@ultimat3/seo';
12
12
  import { builtinImageDriver, DEFAULT_WIDTHS, parseImageQuery } from '@ultimat3/seo';
13
13
  import type { ImageTransform, Storage, VariantFormat } from '@ultimat3/storage';
14
14
  import { isTenantScoped, isVariantFormat, variantKey } from '@ultimat3/storage';
15
- import {
16
- AUTHORIZED_OBJECT_CACHE,
17
- assertReadableKey,
18
- authorizeStorageRead,
19
- STORAGE_READ_PERMISSION,
20
- } from './dev-storage';
21
15
  import { faviconRoute } from './favicon';
22
16
  // The icon matrix's source, its base path and its renderer live in their own module so that
23
17
  // `pwa-artifacts.ts` — which this file imports for the manifest route — can reach them without
@@ -26,6 +20,12 @@ import { faviconRoute } from './favicon';
26
20
  import { iconPlan, iconRenderer } from './icon-assets';
27
21
  import type { PwaArtifacts } from './pwa-artifacts';
28
22
  import { pwaManifestRoute } from './pwa-artifacts';
23
+ import {
24
+ AUTHORIZED_OBJECT_CACHE,
25
+ assertReadableKey,
26
+ authorizeStorageRead,
27
+ STORAGE_READ_PERMISSION,
28
+ } from './runtime-storage';
29
29
 
30
30
  /**
31
31
  * Storage-backed images. `responsiveImage({ src: '/media/<key>' })` mints its variants under it.
@@ -4,6 +4,8 @@
4
4
 
5
5
  import { mkdirSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
+ import type { RealtimeConfig } from '@ultimat3/core';
8
+ import { REALTIME_DEFAULTS } from './runtime-realtime';
7
9
  import { safeUrlLabel } from './safe-url-label';
8
10
 
9
11
  export type ServiceMode = 'embedded' | 'external';
@@ -39,15 +41,22 @@ const nonEmpty = (value: string | undefined): string | undefined =>
39
41
  * Embedded Postgres is PGlite on disk under `.x/`, so a restart keeps the data and a `x db reset`
40
42
  * is a directory delete rather than a container dance.
41
43
  */
42
- export function resolveServices(root: string, env: Env): DevServices {
43
- const stateDir = join(root, '.x');
44
+ export function resolveServices(
45
+ root: string,
46
+ env: Env,
47
+ realtime: RealtimeConfig = REALTIME_DEFAULTS,
48
+ ): DevServices {
49
+ // `ULTIMATE_STATE_DIR` relocates the whole of `.x/` — the embedded database, the local disk and
50
+ // the dev lock — for one process tree. It is how an e2e run boots the app on a THROWAWAY database
51
+ // (`e2e-app.ts`) instead of resetting the developer's own, beside a running `x dev`.
52
+ const stateDir = nonEmpty(env['ULTIMATE_STATE_DIR']) ?? join(root, '.x');
44
53
  const databaseUrl = nonEmpty(env['DATABASE_URL']);
45
- const natsUrl = nonEmpty(env['NATS_URL']);
46
54
  const s3Endpoint = nonEmpty(env['S3_ENDPOINT']);
47
- // Created only when something will actually live in it. A container whose bindings are all
48
- // external runs non-root over a read-only app directory, and an unconditional mkdir there is an
49
- // EACCES at boot for a directory that would have stayed empty.
50
- if (databaseUrl === undefined || natsUrl === undefined || s3Endpoint === undefined) {
55
+ // Created only when something will actually live in it: the embedded database and the local disk
56
+ // do, in-process events never touch the disk. A container whose bindings are external runs
57
+ // non-root over a read-only app directory, and a mkdir there is an EACCES at boot for a directory
58
+ // that would have stayed empty — which an unset NATS_URL alone used to trigger.
59
+ if (databaseUrl === undefined || s3Endpoint === undefined) {
51
60
  mkdirSync(stateDir, { recursive: true });
52
61
  }
53
62
  return {
@@ -62,15 +71,7 @@ export function resolveServices(root: string, env: Env): DevServices {
62
71
  detail: 'PGlite in this process — set DATABASE_URL to use a real Postgres',
63
72
  }
64
73
  : { name: 'db', mode: 'external', url: databaseUrl, detail: 'DATABASE_URL' },
65
- events:
66
- natsUrl === undefined
67
- ? {
68
- name: 'events',
69
- mode: 'embedded',
70
- url: 'inproc://events',
71
- detail: 'in-process fanout — set NATS_URL to use NATS',
72
- }
73
- : { name: 'events', mode: 'external', url: natsUrl, detail: 'NATS_URL' },
74
+ events: eventsBinding(env, realtime),
74
75
  storage:
75
76
  s3Endpoint === undefined
76
77
  ? {
@@ -88,7 +89,7 @@ export function resolveServices(root: string, env: Env): DevServices {
88
89
  * `x dev --json` emitted `DATABASE_URL` and `NATS_URL` verbatim — passwords included — into a
89
90
  * field that is printed to a terminal, piped into a log and scraped by a script, while the rule
90
91
  * against it was already written three lines from the emitting code and applied only to mail and
91
- * cdn. The bindings keep the real url because `dev-queue.ts` has to connect with it; only this
92
+ * cdn. The bindings keep the real url because `runtime-queue.ts` has to connect with it; only this
92
93
  * projection is redacted, so a leak cannot come back as a caller forgetting to call a helper.
93
94
  */
94
95
  export const reportedUrls = (services: DevServices): Record<ServiceBinding['name'], string> => ({
@@ -101,3 +102,28 @@ export const describeServices = (services: DevServices): string =>
101
102
  [services.db, services.events, services.storage]
102
103
  .map((binding) => `${binding.name}=${binding.mode}`)
103
104
  .join(' ');
105
+
106
+ /**
107
+ * The bus as `realtime.transport` chose it, and the variable `realtime.urlEnv` names — the pair
108
+ * `@ultimat3/realtime`'s `selectTransport` obeys. It read `NATS_URL` literally, so an app naming
109
+ * its own variable reported `events=embedded` over a boot that had dialled NATS.
110
+ */
111
+ export function eventsBinding(env: Env, realtime: RealtimeConfig): ServiceBinding {
112
+ const name = realtime.urlEnv;
113
+ const url = realtime.transport === 'nats' && name !== undefined ? nonEmpty(env[name]) : undefined;
114
+ return url === undefined
115
+ ? {
116
+ name: 'events',
117
+ mode: 'embedded',
118
+ url: 'inproc://events',
119
+ detail: "in-process fanout — set realtime: { transport: 'nats', urlEnv } to use NATS",
120
+ }
121
+ : { name: 'events', mode: 'external', url, detail: name ?? 'NATS_URL' };
122
+ }
123
+
124
+ /** `services` with the events binding the booted runtime actually chose (`RunningServices.realtime`). */
125
+ export const withRealtimeEvents = (
126
+ services: DevServices,
127
+ env: Env,
128
+ realtime: RealtimeConfig,
129
+ ): DevServices => ({ ...services, events: eventsBinding(env, realtime) });
@@ -24,7 +24,7 @@ import { CACHE_TIERS, defineConfig, logger, renderThrowable } from '@ultimat3/co
24
24
  import type { Transport, TransportSubscription } from '@ultimat3/realtime/server';
25
25
  import { APP_CONFIG_EXPORT } from './app-auth';
26
26
  import { APP_CONFIG_FILE } from './app-root';
27
- import type { Env } from './dev-services';
27
+ import type { Env } from './runtime-bindings';
28
28
 
29
29
  /**
30
30
  * The subject every replica of every app publishes tag invalidations on. One subject and not one
@@ -101,7 +101,7 @@ function redisUrl(env: Env): string | undefined {
101
101
  *
102
102
  * `X_CACHE_DRIVER_UNAVAILABLE` is BORROWED from `@ultimat3/cache` rather than twinned: "this tier
103
103
  * cannot be built here" is what that code already means, and its shipped `fix:` is this one. The
104
- * precedent is `dev-assets.ts` throwing `@ultimat3/pwa`'s `PwaIconMissingError`.
104
+ * precedent is `runtime-assets.ts` throwing `@ultimat3/pwa`'s `PwaIconMissingError`.
105
105
  */
106
106
  function buildTier(name: CacheTierName, options: CacheTiersOptions): CacheTier {
107
107
  switch (name) {