@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
package/src/serve.ts CHANGED
@@ -4,211 +4,33 @@
4
4
  // `dev: true`. The only production-shaped decisions live here: which role, which port, and which
5
5
  // interface — every one by default, because a container is reached through a port mapping.
6
6
 
7
- import type { Role } from '@ultimat3/core';
8
- import {
9
- configureErrorReporting,
10
- isRole,
11
- logger,
12
- ROLES,
13
- sentryErrorReporter,
14
- } from '@ultimat3/core';
15
- import {
16
- assertNoDrift,
17
- checkDrift,
18
- type DriftReport,
19
- type MigrationReport,
20
- migrate,
21
- } from '@ultimat3/db';
22
- import type { Route } from '@ultimat3/http';
23
- import { describeRoutes } from '@ultimat3/render';
24
- import { createIsrController } from '@ultimat3/render/server';
25
- import { apiRoutes } from './api-routes';
26
- import { loadSignInPath } from './app-auth';
27
- import { loadApp } from './app-load';
28
- import { appManifest } from './app-manifest';
29
- import { mountAppMcp } from './app-mcp';
7
+ import { assertNoDevSecretsOutsideLocal, logger } from '@ultimat3/core';
8
+ import { assertNoDrift, checkDrift, migrate } from '@ultimat3/db';
30
9
  import { loadAppRuntime } from './app-runtime';
31
10
  import { acceptCreatedTables } from './db-accept-created';
32
- import { assetRoutes } from './dev-assets';
33
- import { startQueue } from './dev-queue';
34
- import { appRoutes } from './dev-render';
35
- import { replicaOverrides } from './dev-replica';
36
- import type { RunningRoles, WebBinding } from './dev-roles';
37
- import { startRoles } from './dev-roles';
38
- import type { RunningServices } from './dev-runtime';
39
- import { startServices } from './dev-runtime';
40
- import type { Env } from './dev-services';
41
- import { resolveServices } from './dev-services';
42
- import { storageRoutes } from './dev-storage';
43
- import { errorPageStyleSources } from './error-page-csp';
44
- import { PortInvalidError, RoleUnknownError } from './errors';
45
11
  import { holdUntilShutdown } from './hold';
46
- import { buildIslands } from './island-bundle';
47
- import { islandRoutes } from './island-routes';
48
- import { DEFAULT_METRICS_PORT } from './metrics-endpoint';
12
+ import { startMetricsEndpoint } from './metrics-endpoint';
49
13
  import { readMigrations } from './migrations';
50
- import { startOtlpExport } from './otlp-export';
51
- import { loadPwaArtifacts } from './pwa-artifacts';
52
- import type { RuntimeOverrides } from './runtime-overrides';
53
- import { styleBundle } from './style-bundle';
54
- import { styleRoutes } from './style-routes';
55
- import { serviceWorkerArtifacts } from './sw-artifacts';
56
- import { serviceWorkerRoutes } from './sw-routes';
57
- import { loadThemeMode, themeBoot } from './theme-boot';
58
-
59
- export const DEFAULT_PORT = 3000;
60
-
61
- /**
62
- * Every interface, which is what a role binds when nothing says otherwise: a container bound to
63
- * loopback is unreachable through its own port mapping. `HOST` and `ServeOptions.hostname` are the
64
- * two ways of saying otherwise — see `hostnameFromEnv`.
65
- */
66
- export const CONTAINER_BINDING: WebBinding = { dev: false, hostname: '0.0.0.0' };
67
-
68
- /**
69
- * The interface the `web` and `sync` roles bind, and the metrics endpoint with them (`WebBinding`
70
- * is one decision). Read the way `PORT` is: empty or whitespace is the default.
71
- *
72
- * Exists because a container had exactly one binding, `0.0.0.0`, and an app whose auth mode is
73
- * "nobody logs in, one implicit actor" must refuse a public interface — so it could not run in a
74
- * container at all. `HOST=127.0.0.1` is unreachable through `docker run -p` (the proxy connects to
75
- * the container's bridge address, never its loopback); it is reachable where the container shares
76
- * the host's network namespace (`--network host`), or through a sidecar and `ssh -L` inside it —
77
- * which is the exposure such an app wants. Not `HOSTNAME`: Docker sets that to the container id.
78
- */
79
- export function hostnameFromEnv(env: Env): string {
80
- const raw = env['HOST']?.trim();
81
- return raw === undefined || raw.length === 0 ? CONTAINER_BINDING.hostname : raw;
82
- }
83
-
84
- /** What `serveApp` hands `startRoles`: the caller's hostname, else `HOST`, else every interface. */
85
- export const containerBinding = (env: Env, hostname?: string): WebBinding => ({
86
- dev: false,
87
- hostname: hostname ?? hostnameFromEnv(env),
88
- });
89
-
90
- /**
91
- * `ROLE` is the one knob one image exposes. Validated rather than defaulted: a typo that fell back
92
- * to `web` would start a process that serves nothing the operator asked for and reports healthy.
93
- */
94
- export function roleFromEnv(env: Env): Role {
95
- const raw = env['ROLE'] ?? 'web';
96
- if (!isRole(raw)) throw new RoleUnknownError({ role: raw, known: ROLES });
97
- return raw;
98
- }
99
-
100
- /**
101
- * `Number.parseInt` would read `80abc` as 80, so the whole string has to be a port — a
102
- * partially-parsed port is a deploy that binds somewhere nobody asked for.
103
- */
104
- function portValue(env: Env, name: string, fallback: number): number {
105
- const raw = env[name];
106
- if (raw === undefined || raw.trim().length === 0) return fallback;
107
- const port = Number(raw.trim());
108
- if (!Number.isInteger(port) || port < 0 || port > 65_535)
109
- throw new PortInvalidError({ value: raw, name });
110
- return port;
111
- }
112
-
113
- /** Every PaaS injects `PORT` and routes traffic to exactly it. */
114
- export function portFromEnv(env: Env): number {
115
- return portValue(env, 'PORT', DEFAULT_PORT);
116
- }
117
-
118
- /**
119
- * The scrape port, deliberately its own env var and not `PORT + n`: an operator who moves the app
120
- * port must not silently move the port their Prometheus is configured against, and the roles that
121
- * set no `PORT` at all — `worker`, `scheduler`, `replicator` — still need this one.
122
- */
123
- export function metricsPortFromEnv(env: Env): number {
124
- return portValue(env, 'METRICS_PORT', DEFAULT_METRICS_PORT);
125
- }
126
-
127
- /**
128
- * The scrape port a boot uses, given the app port it already resolved. One expression, and it is
129
- * exported because `x dev` is the second caller: `cmd-dev.ts` passed no `metricsPort` at all, so
130
- * `METRICS_PORT` was honoured in the container and ignored on a laptop — the dev/prod parity break
131
- * `dev-roles.ts`'s own header forbids, and a second copy of this rule would be the same break
132
- * one edit later.
133
- *
134
- * An in-process caller asking for an ephemeral app port is a test, and a test that grabbed the
135
- * fixed 9090 would fail the next suite to boot beside it. An environment that names the port still
136
- * wins — that is the deploy talking.
137
- */
138
- export const metricsPortFor = (env: Env, port: number, override?: number): number =>
139
- override ?? (port === 0 && env['METRICS_PORT'] === undefined ? 0 : metricsPortFromEnv(env));
140
-
141
- /**
142
- * The one env var that turns error monitoring on, and the only vendor-shaped name in the boot
143
- * path. Not a platform primitive (axiom 7): the value is a URL to whatever the operator runs, the
144
- * wire format behind it is documented and self-hostable, and `SENTRY_DSN` is what every monitor
145
- * that speaks it already documents — inventing a second spelling would mean an operator's existing
146
- * tooling sets a variable this framework ignores. Exactly the precedent
147
- * `OTEL_EXPORTER_OTLP_ENDPOINT` already sets in `docker/helm/values.yaml`.
148
- */
149
- export const ERROR_DSN_KEY = 'SENTRY_DSN';
150
-
151
- /**
152
- * Switch reporting on for this process. Unset DSN leaves core's no-op reporter in place, so a
153
- * laptop and a CI run pay nothing and page nobody — and the release every event carries is the
154
- * build id this boot already computed, never a second identity for the same deploy.
155
- */
156
- export function configureReporting(env: Env, buildId: string): void {
157
- const dsn = env[ERROR_DSN_KEY]?.trim();
158
- configureErrorReporting({
159
- release: buildId,
160
- // A malformed DSN throws here, at boot, rather than at the first outage: a monitor that was
161
- // never connected looks exactly like an app that never failed.
162
- ...(dsn === undefined || dsn.length === 0 ? {} : { reporter: sentryErrorReporter({ dsn }) }),
163
- });
164
- }
165
-
166
- export interface ServeOptions {
167
- readonly root: string;
168
- readonly env: Env;
169
- /** Overrides `ROLE`; `runRole` reads the environment when this is absent. */
170
- readonly role?: Role;
171
- /** Overrides `PORT`. 0 asks the kernel for an ephemeral one, which is what a test wants. */
172
- readonly port?: number;
173
- /** Overrides `METRICS_PORT`, on the same terms. */
174
- readonly metricsPort?: number;
175
- /**
176
- * Overrides `HOST`: the interface the HTTP roles bind. An app that must never answer on a public
177
- * interface passes `'127.0.0.1'` here rather than trusting the deployment to set the variable.
178
- */
179
- readonly hostname?: string;
180
- /**
181
- * The drivers this deployment supplies instead of the ones the environment would select.
182
- *
183
- * This field is why `apps/web/server.ts` can stay three lines and still run a custom queue, a
184
- * shared ISR store or an app's own middleware. Before it there was nowhere to hand the framework
185
- * a driver, so the only way was an ambient setter from an app module — which `loadApp` imports
186
- * AFTER `startServices` has captured its own, giving a process that enqueues to one queue and
187
- * claims from another. `startRoles` now refuses that split outright.
188
- */
189
- readonly runtime?: RuntimeOverrides;
190
- }
191
-
192
- export interface ServedApp {
193
- readonly kind: 'served';
194
- readonly role: Role;
195
- /** `http://…` for the web role; null for the roles that open no HTTP socket. */
196
- readonly url: string | null;
197
- readonly buildId: string;
198
- readonly running: RunningRoles;
199
- readonly runtime: RunningServices;
200
- stop(): Promise<void>;
201
- }
202
-
203
- export interface MigratedApp {
204
- readonly kind: 'migrated';
205
- readonly role: 'migrate';
206
- readonly report: MigrationReport;
207
- /** The post-condition: the live schema against the ledger this run just wrote. */
208
- readonly drift: DriftReport;
209
- }
210
-
211
- export type StartedApp = ServedApp | MigratedApp;
14
+ import { resolveServices } from './runtime-bindings';
15
+ import { startQueue } from './runtime-queue';
16
+ import { startServices } from './runtime-services';
17
+ import { bootRoles } from './serve-boot';
18
+ import { containerBinding, metricsPortFor, portFromEnv, roleFromEnv } from './serve-env';
19
+ import type { MigratedApp, ServedApp, ServeOptions, StartedApp } from './serve-types';
20
+
21
+ export {
22
+ CONTAINER_BINDING,
23
+ configureReporting,
24
+ containerBinding,
25
+ DEFAULT_PORT,
26
+ ERROR_DSN_KEY,
27
+ hostnameFromEnv,
28
+ metricsPortFor,
29
+ metricsPortFromEnv,
30
+ portFromEnv,
31
+ roleFromEnv,
32
+ } from './serve-env';
33
+ export type { MigratedApp, ServedApp, ServeOptions, StartedApp } from './serve-types';
212
34
 
213
35
  /**
214
36
  * The release phase, as a role. `migrate` is not a server: it applies the app's own migrations
@@ -308,153 +130,37 @@ export async function withAppRuntime(options: ServeOptions): Promise<ServeOption
308
130
  }
309
131
 
310
132
  export async function serveApp(input: ServeOptions): Promise<ServedApp> {
133
+ // FIRST, before any service starts: a production process on a key this framework publishes is
134
+ // refused, rather than reported by `x doctor` and served anyway (plan 101, slices 01 e and 12 h).
135
+ // The storage twin is `startStorage`'s own `LocalDiskUnsafeError`, one step later, where the
136
+ // disk is chosen.
137
+ assertNoDevSecretsOutsideLocal({ env: input.env });
311
138
  const options = await withAppRuntime(input);
312
139
  const role = options.role ?? roleFromEnv(options.env);
313
- const runtime = await startServices(
314
- resolveServices(options.root, options.env),
315
- options.env,
316
- options.runtime,
317
- );
140
+ // The scrape listener before ANY boot work — the services, the app's modules, the island build —
141
+ // so a cold pod answers `/metrics` (and the chart's startup probe on it) while it boots. It was
142
+ // opened inside `startRoles`, after all of that, so `_helpers.tpl`'s "FIRST" was not true.
143
+ const port = options.port ?? portFromEnv(options.env);
144
+ const metrics = startMetricsEndpoint({
145
+ port: metricsPortFor(options.env, port, options.metricsPort),
146
+ hostname: containerBinding(options.env, options.hostname).hostname,
147
+ });
318
148
  // Everything acquired from here down, in order, so a throw anywhere below gives it all back.
319
- const acquired: (() => void | Promise<void>)[] = [() => runtime.stop()];
149
+ const acquired: (() => void | Promise<void>)[] = [() => metrics.stop()];
320
150
  try {
321
- return await bootRoles({ options, role, runtime, acquired });
151
+ const runtime = await startServices(
152
+ resolveServices(options.root, options.env),
153
+ options.env,
154
+ options.runtime,
155
+ );
156
+ acquired.push(() => runtime.stop());
157
+ return await bootRoles({ options, role, runtime, acquired, metrics });
322
158
  } catch (error) {
323
159
  await releaseBoot(acquired);
324
160
  throw error;
325
161
  }
326
162
  }
327
163
 
328
- /** The half of `serveApp` whose every acquisition is registered for rollback. */
329
- async function bootRoles(boot: {
330
- readonly options: ServeOptions;
331
- readonly role: Role;
332
- readonly runtime: RunningServices;
333
- readonly acquired: (() => void | Promise<void>)[];
334
- }): Promise<ServedApp> {
335
- const { options, role, runtime, acquired } = boot;
336
- // Importing the app's modules IS the registration: every route, action and job below is
337
- // whatever this call put in the registries.
338
- await loadApp(options.root);
339
- // The build stamps `BUILD_ID` into the image; unstamped, the manifest's content hash is the same
340
- // answer computed here, so `x-ultimate-build` is never absent and never a lie. Projected only
341
- // when unstamped, because a stamped image already paid for it at build time and a replica's boot
342
- // should not repeat it — the load above is the part every boot needs either way.
343
- const stamped = options.env['BUILD_ID'];
344
- const buildId =
345
- stamped !== undefined && stamped.length > 0
346
- ? stamped
347
- : (await appManifest(options.root)).manifest.buildId;
348
- // Before the first socket opens: everything above this line fails loudly into the container's
349
- // own logs, everything below it is a served request, a claimed job or a routed frame.
350
- configureReporting(options.env, buildId);
351
- // Beside error reporting, and for the same reason it is here: `OTEL_EXPORTER_OTLP_ENDPOINT` is
352
- // in the shipped chart and nothing read it, so every deployment that configured a collector got
353
- // an empty dashboard. `x dev` keeps its own recorder — the `/_x` timeline is a different sink
354
- // with a different lifetime — so this is the production boot's alone (axiom 6).
355
- const stopOtlp = startOtlpExport(options.env);
356
- acquired.push(stopOtlp);
357
- // Built at boot rather than shipped prebuilt, so the container serves the same chunks `x dev`
358
- // does from the same source — the alternative is a second bundler invocation in the image build
359
- // whose output nothing compares against the one the dev loop proved.
360
- const islands = await buildIslands(options.root);
361
- // The same two strings `x dev` resolves, from the same reader: a `<link rel="manifest">` served
362
- // on a laptop and absent in the image is exactly the dev/prod difference this file exists to
363
- // prevent, and it is the one an operator cannot see without installing the app.
364
- const pwa = await loadPwaArtifacts(options.root);
365
- const theme = themeBoot(await loadThemeMode(options.root));
366
- // The worker, from the SAME route table this process is about to serve — `describeRoutes()` is
367
- // the one projection `x.manifest.json`, `/_x`, the sitemap and `sw.js` are all built from, so a
368
- // route added here cannot be missing from the precache manifest.
369
- const serviceWorker =
370
- pwa === undefined
371
- ? undefined
372
- : serviceWorkerArtifacts({
373
- pwa,
374
- buildId,
375
- routes: describeRoutes(),
376
- islands,
377
- styles: styleBundle(),
378
- });
379
- // The app's own MCP endpoint, through the same call `x dev` makes — see `app-mcp.ts`.
380
- const mcpMount = await mountAppMcp(options.root);
381
- const routes: readonly Route[] = [
382
- ...apiRoutes(),
383
- ...mcpMount.routes,
384
- ...(serviceWorker === undefined ? [] : serviceWorkerRoutes(serviceWorker)),
385
- ...assetRoutes({
386
- root: options.root,
387
- storage: runtime.storage,
388
- ...(options.runtime?.images === undefined ? {} : { images: options.runtime.images }),
389
- ...(pwa === undefined ? {} : { pwa }),
390
- }),
391
- ...storageRoutes({ storage: runtime.storage }),
392
- ...islandRoutes(() => islands),
393
- // The surface stylesheets the documents link. Built from the registry the `loadApp` above
394
- // filled, so this process serves exactly the CSS it renders against.
395
- ...styleRoutes(() => styleBundle()),
396
- ...appRoutes({
397
- buildId,
398
- resolveIsland: (file) => islands.resolverFor(file),
399
- themeHead: theme.head,
400
- ...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
401
- // Only when a store was supplied. `createIsrController` defaults to a per-process memory
402
- // store, so twelve replicas hold twelve of them and a purge tag regenerates one twelfth of
403
- // the fleet while the other eleven keep serving the page it just invalidated.
404
- ...(options.runtime?.isrStore === undefined
405
- ? {}
406
- : { isr: createIsrController({ buildId, store: options.runtime.isrStore }) }),
407
- }),
408
- ];
409
- const port = options.port ?? portFromEnv(options.env);
410
- // An in-process caller asking for an ephemeral app port is a test, and a test that grabbed the
411
- // fixed 9090 would fail the next suite to boot beside it. An environment that names the port
412
- // still wins — that is the deploy talking.
413
- const metricsPort = metricsPortFor(options.env, port, options.metricsPort);
414
- const replicaOverride = replicaOverrides(options.runtime, runtime.services.db, options.env);
415
- const running = await startRoles({
416
- roles: [role],
417
- port,
418
- metricsPort,
419
- buildId,
420
- runtime,
421
- routes,
422
- env: options.env,
423
- // Same declaration `x dev` reads. Without it a container answers a browser that opened a
424
- // guarded page with the problem document, rendered as raw JSON in the viewport.
425
- signInPath: await loadSignInPath(options.root),
426
- // The enforced policy this process sends must admit the app's own error pages' `<style>` and
427
- // the theme boot the documents carry; `x dev` is report-only, so only here was it a blank page.
428
- inlineStyles: await errorPageStyleSources(options.root),
429
- inlineScripts: [theme.cspSource],
430
- // The app's own `apps/web/site/errors/<status>.html`, resolved inside `startWeb` so this
431
- // process and `x dev` cannot answer a browser differently.
432
- root: options.root,
433
- http: containerBinding(options.env, options.hostname),
434
- // The read-replica scope rides in FRONT of whatever the host supplied, or the host's own value
435
- // passes through untouched. `DATABASE_REPLICA_URL` was read by no booted process before this:
436
- // `defaultClient()` is the one composer of a replicated pair and it runs only when an app
437
- // installed no client, which no framework boot leaves true (`dev-queue.ts`).
438
- ...(replicaOverride === undefined ? {} : { overrides: replicaOverride }),
439
- });
440
- acquired.push(() => running.stop());
441
- return {
442
- kind: 'served',
443
- role,
444
- url: running.url,
445
- buildId,
446
- running,
447
- runtime,
448
- async stop() {
449
- await running.stop();
450
- await runtime.stop();
451
- // Last: the exporters outlive the roles they were recording, so the drain's own spans and
452
- // the final counter snapshot still have somewhere to go.
453
- stopOtlp();
454
- },
455
- };
456
- }
457
-
458
164
  /**
459
165
  * What `apps/web/server.ts` calls. Returns for `migrate` — the process is meant to exit — and
460
166
  * holds for every other role until core's drain completes, so SIGTERM from a rolling restart takes
@@ -7,8 +7,8 @@
7
7
  import { join } from 'node:path';
8
8
  import { startDev } from './cmd-dev';
9
9
  import { clearLock, isProcessAlive, lockPath, parseLock, preflight, writeLock } from './dev-lock';
10
- import { DEV_BINDING } from './dev-roles';
11
- import { resolveServices } from './dev-services';
10
+ import { DEV_BINDING } from './role-start';
11
+ import { resolveServices } from './runtime-bindings';
12
12
 
13
13
  export const SHOT_DIR = join('.x', 'shot');
14
14
 
@@ -57,6 +57,15 @@ export async function settleIslands(
57
57
  return answer;
58
58
  }
59
59
 
60
+ /**
61
+ * Quiet AND answered: the island mounted or failed. `ready` alone was not enough (#474) — an island
62
+ * on the `idle` strategy mounts up to `IDLE_HYDRATE_TIMEOUT_MS` after the page went quiet, in idle
63
+ * time the activity counter never sees, so a busy machine read `mounted: false` and reported "did
64
+ * not finish mounting" with most of the window unused.
65
+ */
66
+ const readinessSettled = (answer: IslandReadiness | null): boolean =>
67
+ answer?.ready === true && (answer.mounted || answer.failed !== null);
68
+
60
69
  /**
61
70
  * Read the harness's readiness until the page goes QUIET or the window runs out.
62
71
  *
@@ -76,7 +85,7 @@ export async function settleReadiness(
76
85
  const sleep = options.sleep ?? ((ms: number): Promise<void> => Bun.sleep(ms));
77
86
  let answer = await probe();
78
87
  let waited = 0;
79
- while (answer?.ready !== true && waited < options.windowMs) {
88
+ while (!readinessSettled(answer) && waited < options.windowMs) {
80
89
  // At least 1ms, or a `pollMs` of zero is a loop with no exit while the window stands.
81
90
  const step = Math.max(1, Math.min(options.pollMs, options.windowMs - waited));
82
91
  await sleep(step);
package/src/shot-theme.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // before the boot reads it: the picture is then what a visitor who chose that theme sees.
7
7
 
8
8
  import { THEME_STORAGE_KEY } from '@ultimat3/render';
9
- import type { ColorScheme } from '@ultimat3/scraping';
9
+ import type { ShotColorScheme } from './browser-launcher-port';
10
10
  import { BadFlagError } from './errors';
11
11
 
12
12
  /** What `x shot --theme` accepts: the two the boot honours from storage, and nothing else. */
@@ -19,7 +19,7 @@ const isShotTheme = (value: string): value is ShotTheme =>
19
19
  /**
20
20
  * `--theme light|dark` on a ROUTE shot, or nothing — the box's own preference and the app's own
21
21
  * default, which is what `x shot` has always photographed. Refused by name for any other value:
22
- * `no-preference` is the scraping vocabulary's clear, not a theme a reader can ask for.
22
+ * `no-preference` is the browser port's clear, not a theme a reader can ask for.
23
23
  */
24
24
  export function readThemeFlag(value: string | undefined): ShotTheme | undefined {
25
25
  if (value === undefined) return undefined;
@@ -43,7 +43,7 @@ export function readThemeFlag(value: string | undefined): ShotTheme | undefined
43
43
  * `about:blank` is opaque and its `localStorage` throws, and a throw from a new-document script
44
44
  * would surface as a page error on a capture that has not navigated yet.
45
45
  */
46
- export function themeChoiceExpression(scheme: ColorScheme): string | undefined {
46
+ export function themeChoiceExpression(scheme: ShotColorScheme): string | undefined {
47
47
  if (scheme === 'no-preference') return undefined;
48
48
  return (
49
49
  `try{localStorage.setItem(${JSON.stringify(THEME_STORAGE_KEY)},${JSON.stringify(scheme)})}` +
@@ -6,7 +6,7 @@ import { probeImage } from '@ultimat3/core';
6
6
  import { ISLAND_FAILED_ATTRIBUTE, ISLAND_MOUNTED_ATTRIBUTE } from '@ultimat3/render';
7
7
  import type { StandardSchemaV1 } from '@ultimat3/schema';
8
8
  import { t, validate } from '@ultimat3/schema';
9
- import type { ConsoleLine, NetworkEntry, PageError } from '@ultimat3/scraping';
9
+ import type { ConsoleLine, NetworkEntry, PageError } from './browser-launcher-port';
10
10
  import { msg } from './messages';
11
11
  import type { JsonValue } from './output';
12
12
 
@@ -38,7 +38,6 @@ export const SHOT_MESSAGE_KEYS = [
38
38
  'cli.shot.console',
39
39
  'cli.shot.threw',
40
40
  'cli.shot.pageError',
41
- 'cli.shot.blind.status',
42
41
  ] as const;
43
42
 
44
43
  /**
@@ -157,6 +156,11 @@ export interface ShotVerdict extends ShotInput {
157
156
  readonly warnings: number;
158
157
  readonly canvas: ShotCanvas | null;
159
158
  readonly refused: number;
159
+ /**
160
+ * Responses with a status of 400 or above — a 404 script, a 500 API read. RECORDED and never
161
+ * gating, like a console warning: a missing favicon would otherwise fail every shot of every app.
162
+ */
163
+ readonly failed: number;
160
164
  /** What this verdict cannot see, stated every time — a `0` whose blind spots are named. */
161
165
  readonly blind: readonly string[];
162
166
  }
@@ -166,12 +170,14 @@ export interface ShotVerdict extends ShotInput {
166
170
  * these are properties of the browser port, not of a run — and in the artifact because `errors: 0`
167
171
  * read without them is a claim the tool cannot support.
168
172
  *
169
- * It was three. `pageErrors` and `hydration` both left on 2026-08-21, when `@ultimat3/scraping`
170
- * learned to capture `pageerror` and the hydration prelude learned to mark a mount's outcome. A
171
- * blind spot is worth stating while it is true and worth DELETING the moment it is not: a stale
172
- * one teaches an agent to distrust an answer the tool can now give.
173
+ * It was three. `pageErrors` and `hydration` both left on 2026-08-21, when the driver learned to
174
+ * capture `pageerror` and the hydration prelude learned to mark a mount's outcome; response status
175
+ * left in 22.0.0, when the raw-CDP driver began reading `Network.responseReceived`. A blind spot is
176
+ * worth stating while it is true and worth DELETING the moment it is not: a stale one teaches an
177
+ * agent to distrust an answer the tool can now give. The list is empty and stays in the artifact,
178
+ * so the next one has a place to be stated.
173
179
  */
174
- export const BLIND_SPOTS = ['cli.shot.blind.status'] as const;
180
+ export const BLIND_SPOTS: readonly string[] = [];
175
181
 
176
182
  const levelCount = (lines: readonly ConsoleLine[], level: ConsoleLine['level']): number =>
177
183
  lines.filter((line) => line.level === level).length;
@@ -204,6 +210,7 @@ export function buildVerdict(input: ShotInput): ShotVerdict {
204
210
  warnings: levelCount(input.console, 'warn'),
205
211
  canvas: canvasOf(input.bytes),
206
212
  refused: input.network.filter((entry) => entry.refused !== undefined).length,
213
+ failed: input.network.filter((entry) => (entry.status ?? 0) >= 400).length,
207
214
  blind: BLIND_SPOTS.map((key) => msg(key)),
208
215
  };
209
216
  }
@@ -267,6 +274,7 @@ export function verdictJson(verdict: ShotVerdict): JsonValue {
267
274
  network: {
268
275
  requests: verdict.network.length,
269
276
  refused: verdict.refused,
277
+ failed: verdict.failed,
270
278
  dropped: verdict.networkDropped,
271
279
  },
272
280
  blind: [...verdict.blind],
@@ -308,6 +316,7 @@ export function shotLines(artifacts: ShotArtifacts): readonly string[] {
308
316
  msg('cli.shot.network', {
309
317
  requests: verdict.network.length,
310
318
  refused: verdict.refused,
319
+ failed: verdict.failed,
311
320
  dropped: verdict.networkDropped,
312
321
  }),
313
322
  ...verdict.pageErrors.map((error) =>
@@ -0,0 +1,27 @@
1
+ // Delete a plaintext buffer when the process is signalled, and then let the signal do what it
2
+ // came to do. A bare `process.once('SIGINT', shred)` ran the shred and SWALLOWED the signal — a
3
+ // listener replaces the default action — so Ctrl-C in `x secrets edit` left the command running.
4
+
5
+ export type Reraise = (signal: NodeJS.Signals) => void;
6
+
7
+ const reraiseDefault: Reraise = (signal) => {
8
+ process.kill(process.pid, signal);
9
+ };
10
+
11
+ /**
12
+ * Shred on SIGINT/SIGTERM, then re-raise the same signal: `once` has already removed this
13
+ * listener, so the re-raised signal meets the default action and terminates. Returns the undo for
14
+ * the normal path, which shreds in its own `finally`.
15
+ */
16
+ export function shredOnSignal(shred: () => void, reraise: Reraise = reraiseDefault): () => void {
17
+ const handler = (signal: NodeJS.Signals): void => {
18
+ shred();
19
+ reraise(signal);
20
+ };
21
+ process.once('SIGINT', handler);
22
+ process.once('SIGTERM', handler);
23
+ return () => {
24
+ process.off('SIGINT', handler);
25
+ process.off('SIGTERM', handler);
26
+ };
27
+ }
@@ -6,9 +6,7 @@
6
6
  // The reference is load-bearing, not decorative: @ultimat3/cli ships SOURCE, so an APP's
7
7
  // `tsc` compiles this file inside ITS program, where a `.d.ts` sitting in this directory is
8
8
  // not included and its `declare module` never applies. `tsc -b` proves it in this repo.
9
- import { transformAsync } from '@babel/core';
10
9
  import { contentHash } from '@ultimat3/render/server';
11
- import solidPreset from 'babel-preset-solid';
12
10
  import type { BunPlugin } from 'bun';
13
11
  import { IslandBuildFailedError } from './errors';
14
12
 
@@ -32,6 +30,31 @@ const PRESET_OPTIONS = { generate: 'dom', hydratable: false } as const;
32
30
  */
33
31
  const PARSER_PLUGINS = ['typescript', 'jsx'] as const;
34
32
 
33
+ /**
34
+ * Babel and the Solid preset, loaded on the first island transform and never at import: ~330ms of
35
+ * module evaluation, measured, paid by every process that imported the island bundler — `x dev`'s
36
+ * whole module graph and the CLI registry before it went lazy — whether or not an island was built.
37
+ */
38
+ let loadedBabel:
39
+ | Promise<{
40
+ readonly transformAsync: typeof import('@babel/core').transformAsync;
41
+ /** Opaque, as `types/babel-modules.d.ts` declares it: handed to `presets:`, never called. */
42
+ readonly solidPreset: unknown;
43
+ }>
44
+ | undefined;
45
+
46
+ const babel = () => {
47
+ loadedBabel ??= Promise.all([import('@babel/core'), import('babel-preset-solid')]).then(
48
+ // The preset is CommonJS: a dynamic import hands back its `module.exports` as `default`,
49
+ // which the static default import it replaced was reading all along.
50
+ ([core, preset]) => ({
51
+ transformAsync: core.transformAsync,
52
+ solidPreset: Reflect.get(Object(preset), 'default') as unknown,
53
+ }),
54
+ );
55
+ return loadedBabel;
56
+ };
57
+
35
58
  interface CacheEntry {
36
59
  /** `contentHash` of the source the code was compiled from. */
37
60
  readonly hash: string;
@@ -76,6 +99,7 @@ export async function transformIslandTsx(source: string, path: string): Promise<
76
99
  // `transformAsync` and never `transformFileAsync`: the latter is gated behind `@babel/core`'s
77
100
  // `browser` export condition and throws "Transforming files is not supported in browsers"
78
101
  // under `bun --conditions=browser`, which is the condition Solid work runs in.
102
+ const { transformAsync, solidPreset } = await babel();
79
103
  const result = await transformAsync(source, {
80
104
  filename: path,
81
105
  // The app's own Babel config is not this transform's business, and an app that happens to
@@ -71,6 +71,12 @@ export type UnmeasuredRoute = {
71
71
  readonly code?: string;
72
72
  readonly cause?: string;
73
73
  readonly fix?: string;
74
+ /**
75
+ * `false` when no build can weigh the route by construction — a `render: 'ssr'` page with params,
76
+ * which may not declare `prerender()`, so the build holds no value to render it at. Not a defect
77
+ * in the app, so the `budgets` step prints it and raises no finding (`measure-paths.ts`).
78
+ */
79
+ readonly weighable?: false;
74
80
  };
75
81
 
76
82
  /** One HTML file in the artifact, and the declared route that produced it. */
@@ -192,7 +198,8 @@ const isUnmeasured = (value: unknown): value is UnmeasuredRoute =>
192
198
  typeof value['reason'] === 'string' &&
193
199
  optionalString(value['code']) &&
194
200
  optionalString(value['cause']) &&
195
- optionalString(value['fix']);
201
+ optionalString(value['fix']) &&
202
+ (value['weighable'] === undefined || value['weighable'] === false);
196
203
 
197
204
  const isEmitted = (value: unknown): value is EmittedPage =>
198
205
  isRecord(value) &&