@ultimat3/cli 21.0.0 → 22.1.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.
- package/CLAUDE.md +84 -1650
- package/README.md +40 -2
- package/package.json +30 -30
- package/src/api-registration.ts +124 -0
- package/src/app-artifacts.ts +39 -0
- package/src/app-boundaries.ts +5 -10
- package/src/app-load.ts +37 -5
- package/src/app-openapi.ts +27 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-root.ts +1 -1
- package/src/async-pages.ts +32 -0
- package/src/bin.ts +7 -1
- package/src/boundary-findings.ts +36 -0
- package/src/browser-launcher-fake-html.ts +80 -0
- package/src/browser-launcher-fake.ts +165 -0
- package/src/browser-launcher-port.ts +159 -0
- package/src/browser-launcher.ts +26 -133
- package/src/budgets.ts +11 -1
- package/src/cdp-shot-a11y.ts +77 -0
- package/src/cdp-shot-clock.ts +14 -0
- package/src/cdp-shot-driver.ts +150 -0
- package/src/cdp-shot-element.ts +147 -0
- package/src/cdp-shot-errors.ts +62 -0
- package/src/cdp-shot-keys.ts +152 -0
- package/src/cdp-shot-page.ts +230 -0
- package/src/cdp-shot-watch.ts +241 -0
- package/src/cmd-affected-spec.ts +24 -0
- package/src/cmd-affected.ts +3 -19
- package/src/cmd-build-spec.ts +16 -0
- package/src/cmd-build.ts +46 -20
- package/src/cmd-ci-spec.ts +24 -0
- package/src/cmd-ci.ts +17 -21
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db-spec.ts +99 -0
- package/src/cmd-db.ts +15 -94
- package/src/cmd-deploy-helm.ts +136 -0
- package/src/cmd-deploy-spec.ts +40 -0
- package/src/cmd-deploy.ts +111 -43
- package/src/cmd-dev-spec.ts +25 -0
- package/src/cmd-dev.ts +55 -36
- package/src/cmd-docs-spec.ts +16 -0
- package/src/cmd-docs.ts +2 -11
- package/src/cmd-doctor-spec.ts +21 -0
- package/src/cmd-doctor.ts +4 -18
- package/src/cmd-env-spec.ts +18 -0
- package/src/cmd-env.ts +4 -13
- package/src/cmd-errors-spec.ts +23 -0
- package/src/cmd-errors.ts +3 -17
- package/src/cmd-fix-spec.ts +18 -0
- package/src/cmd-fix.ts +4 -13
- package/src/cmd-generate-spec.ts +31 -0
- package/src/cmd-generate.ts +46 -42
- package/src/cmd-i18n-spec.ts +16 -0
- package/src/cmd-i18n.ts +7 -14
- package/src/cmd-jobs-spec.ts +56 -0
- package/src/cmd-jobs.ts +3 -50
- package/src/cmd-manifest-spec.ts +15 -0
- package/src/cmd-manifest.ts +19 -20
- package/src/cmd-mcp-spec.ts +21 -0
- package/src/cmd-mcp.ts +2 -15
- package/src/cmd-new-spec.ts +33 -0
- package/src/cmd-new.ts +38 -33
- package/src/cmd-policy-spec.ts +13 -0
- package/src/cmd-policy.ts +2 -9
- package/src/cmd-pr-spec.ts +39 -0
- package/src/cmd-pr.ts +5 -65
- package/src/cmd-registries-spec.ts +32 -0
- package/src/cmd-registries.ts +4 -25
- package/src/cmd-routes-spec.ts +12 -0
- package/src/cmd-routes.ts +2 -7
- package/src/cmd-secrets-spec.ts +19 -0
- package/src/cmd-secrets.ts +22 -34
- package/src/cmd-shot-island.ts +12 -13
- package/src/cmd-shot-spec.ts +53 -0
- package/src/cmd-shot.ts +11 -60
- package/src/cmd-tasks-spec.ts +21 -0
- package/src/cmd-tasks.ts +2 -16
- package/src/cmd-test-spec.ts +54 -0
- package/src/cmd-test.ts +4 -49
- package/src/cmd-verify-spec.ts +28 -0
- package/src/cmd-verify.ts +3 -23
- package/src/db-seed.ts +4 -2
- package/src/dev-dashboard.ts +4 -4
- package/src/dev-lock.ts +18 -2
- package/src/dev-port.ts +21 -0
- package/src/dev-route-table.ts +3 -3
- package/src/dispatch.ts +12 -5
- package/src/document-styles.ts +1 -1
- package/src/drift.ts +4 -18
- package/src/error-catalog.ts +16 -9
- package/src/error-codes.ts +28 -30
- package/src/error-pages.ts +19 -7
- package/src/errors.ts +14 -0
- package/src/favicon.ts +2 -2
- package/src/fix-imports.ts +1 -1
- package/src/fix-scan.ts +2 -9
- package/src/flag-reads.ts +1 -2
- package/src/foreign-text.ts +36 -0
- package/src/framework-schema.ts +5 -6
- package/src/generate-feature.ts +42 -0
- package/src/generate-files.ts +7 -3
- package/src/generate-grants.ts +83 -0
- package/src/generate-kinds.ts +56 -4
- package/src/i18n-index.ts +59 -10
- package/src/icon-assets.ts +1 -1
- package/src/image-prepare.ts +14 -0
- package/src/import-scan.ts +63 -0
- package/src/index.ts +67 -348
- package/src/invocation-flags.ts +26 -0
- package/src/island-bundle.ts +6 -1
- package/src/island-capture.ts +6 -6
- package/src/island-harness-script.ts +6 -2
- package/src/island-realtime.ts +15 -8
- package/src/island-shot.ts +5 -0
- package/src/island-store.ts +131 -0
- package/src/island-verdict.ts +1 -1
- package/src/job-registration.ts +42 -0
- package/src/jobs-driver.ts +2 -2
- package/src/load-findings.ts +51 -0
- package/src/mcp-db-target.ts +1 -1
- package/src/mcp-errors.ts +28 -27
- package/src/mcp-host.ts +27 -12
- package/src/mcp-ui-diff.ts +27 -0
- package/src/mcp-ui-inspect.ts +4 -4
- package/src/mcp-ui-interact.ts +21 -15
- package/src/mcp-ui.ts +23 -17
- package/src/measure-database.ts +73 -0
- package/src/measure-paths.ts +74 -0
- package/src/measure-scope.ts +74 -0
- package/src/messages.ts +1 -3
- package/src/metrics-endpoint.ts +1 -1
- package/src/otlp-export.ts +1 -1
- package/src/output.ts +6 -0
- package/src/permission-grants.ts +86 -0
- package/src/prerender-out.ts +25 -0
- package/src/prerender.ts +121 -102
- package/src/pwa-artifacts.ts +3 -3
- package/src/reexport-manifest.ts +2 -1
- package/src/registry.ts +80 -56
- package/src/role-realtime.ts +36 -0
- package/src/{dev-replicator.ts → role-replicator.ts} +1 -1
- package/src/{dev-roles-fixture.ts → role-start-fixture.ts} +9 -5
- package/src/role-start-types.ts +112 -0
- package/src/{dev-roles.ts → role-start.ts} +42 -115
- package/src/{dev-sync.ts → role-sync.ts} +22 -8
- package/src/root-env.ts +67 -0
- package/src/{dev-assets.ts → runtime-assets.ts} +7 -7
- package/src/{dev-services.ts → runtime-bindings.ts} +39 -16
- package/src/{dev-cache.ts → runtime-cache.ts} +2 -2
- package/src/runtime-jobs.ts +87 -0
- package/src/{dev-live-feed.ts → runtime-live-feed.ts} +18 -5
- package/src/{dev-notify-retention.ts → runtime-notify-retention.ts} +1 -1
- package/src/{dev-purge.ts → runtime-purge.ts} +2 -2
- package/src/{dev-queue.ts → runtime-queue.ts} +4 -4
- package/src/runtime-realtime.ts +55 -0
- package/src/{dev-replica.ts → runtime-replica.ts} +2 -2
- package/src/{dev-runtime.ts → runtime-services.ts} +41 -17
- package/src/{dev-storage.ts → runtime-storage.ts} +4 -4
- package/src/scaffold-fixture.ts +28 -6
- package/src/scaffold-typecheck.ts +6 -3
- package/src/schema-drift.ts +7 -1
- package/src/script-csp.ts +5 -2
- package/src/secrets-rotation.ts +59 -0
- package/src/serve-boot.ts +192 -0
- package/src/serve-drain.ts +24 -0
- package/src/serve-entry.ts +6 -0
- package/src/serve-env.ts +116 -0
- package/src/serve-types.ts +55 -0
- package/src/serve.ts +44 -347
- package/src/shot-server.ts +2 -2
- package/src/shot-settle.ts +10 -1
- package/src/shot-theme.ts +3 -3
- package/src/shot-verdict.ts +16 -7
- package/src/signal-shred.ts +27 -0
- package/src/solid-loader.ts +26 -2
- package/src/static-report.ts +8 -1
- package/src/sync-url.ts +21 -1
- package/src/templates/action.ts +30 -16
- package/src/templates/entity.ts +12 -7
- package/src/templates/index.ts +1 -1
- package/src/templates/job.ts +10 -7
- package/src/templates/policy.ts +20 -2
- package/src/templates/resource-create.ts +127 -0
- package/src/templates/resource-form-island.ts +49 -12
- package/src/templates/resource.ts +10 -4
- package/src/templates/scaffold-app.ts +4 -1
- package/src/templates/scaffold-auth.ts +3 -1
- package/src/templates/scaffold-container-compose.ts +184 -0
- package/src/templates/scaffold-container.ts +24 -159
- package/src/templates/scaffold-dashboard-example.ts +2 -2
- package/src/templates/scaffold-db-package.ts +16 -3
- package/src/templates/scaffold-demo-org.ts +41 -0
- package/src/templates/scaffold-entries.ts +1 -1
- package/src/templates/scaffold-env.ts +6 -0
- package/src/templates/scaffold-helm-templates.ts +66 -7
- package/src/templates/scaffold-helm.ts +27 -5
- package/src/templates/scaffold-i18n.ts +15 -10
- package/src/templates/scaffold-repo.ts +13 -10
- package/src/templates/scaffold-roles.ts +38 -10
- package/src/templates/slice-foundation.ts +1 -1
- package/src/templates/wrap.ts +4 -1
- package/src/test-passes.ts +2 -1
- package/src/test-workers.ts +26 -0
- package/src/ts-scan.ts +3 -6
- package/src/tsconfig-references.ts +1 -2
- package/src/verify-checks.ts +41 -39
- package/src/verify-e2e.ts +10 -7
- package/src/verify-run.ts +11 -1
- package/src/verify-step.ts +3 -3
- package/src/verify-tests.ts +1 -26
- package/src/verify-typecheck.ts +28 -0
- package/src/web-binding.ts +2 -2
- package/src/workspace-graph.ts +10 -33
- package/src/cdp-browser.ts +0 -94
- package/src/cdp-connection.ts +0 -247
- package/src/cdp-e2e-page.ts +0 -180
- package/src/cdp-e2e-session.ts +0 -199
- package/src/cdp-errors.ts +0 -56
- package/src/cdp-launch.ts +0 -193
- package/src/cdp-offline-script.ts +0 -73
- package/src/cdp-pipe.ts +0 -77
- package/src/e2e-app.ts +0 -103
- package/src/e2e-browser-handle.ts +0 -55
- package/src/e2e-dom-fixture.ts +0 -117
- package/src/e2e-driver.ts +0 -117
- package/src/e2e-errors.ts +0 -117
- package/src/e2e-evaluate.ts +0 -156
- package/src/e2e-locator.ts +0 -86
- package/src/e2e-page.ts +0 -153
- package/src/e2e-preload.ts +0 -64
- package/src/e2e-probe.ts +0 -23
- package/src/e2e-selection.ts +0 -182
- package/src/e2e-spawn.ts +0 -169
- package/src/measurement-actor.ts +0 -26
- /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
- /package/src/{dev-render.ts → runtime-render.ts} +0 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// The web and background roles' boot, after the services are up: the app loaded, its route table
|
|
2
|
+
// built, the roles started. Split from `serve.ts` (the entry points) at its line ceiling; the rules
|
|
3
|
+
// that file's header states — the SAME boot `x dev` runs, minus the watcher, `/_x` and `dev: true`
|
|
4
|
+
// — are this file's.
|
|
5
|
+
|
|
6
|
+
import type { Role } from '@ultimat3/core';
|
|
7
|
+
import type { Route } from '@ultimat3/http';
|
|
8
|
+
import { describeRoutes } from '@ultimat3/render';
|
|
9
|
+
import { createIsrController } from '@ultimat3/render/server';
|
|
10
|
+
import { apiRoutes } from './api-routes';
|
|
11
|
+
import { loadSignInPath } from './app-auth';
|
|
12
|
+
import { loadApp } from './app-load';
|
|
13
|
+
import { appManifest } from './app-manifest';
|
|
14
|
+
import { mountAppMcp } from './app-mcp';
|
|
15
|
+
import { errorPageStyleSources } from './error-page-csp';
|
|
16
|
+
import { islandRoutes } from './island-routes';
|
|
17
|
+
import { loadOrBuildIslands } from './island-store';
|
|
18
|
+
import type { MetricsEndpoint } from './metrics-endpoint';
|
|
19
|
+
import { startOtlpExport } from './otlp-export';
|
|
20
|
+
import { pageSync } from './page-sync';
|
|
21
|
+
import { loadPwaArtifacts } from './pwa-artifacts';
|
|
22
|
+
import { startRoles } from './role-start';
|
|
23
|
+
import { assetRoutes } from './runtime-assets';
|
|
24
|
+
import { appRoutes } from './runtime-render';
|
|
25
|
+
import { replicaOverrides } from './runtime-replica';
|
|
26
|
+
import type { RunningServices } from './runtime-services';
|
|
27
|
+
import { storageRoutes } from './runtime-storage';
|
|
28
|
+
import { loadDrainConfig } from './serve-drain';
|
|
29
|
+
import { configureReporting, containerBinding, metricsPortFor, portFromEnv } from './serve-env';
|
|
30
|
+
import type { ServedApp, ServeOptions } from './serve-types';
|
|
31
|
+
import { styleBundle } from './style-bundle';
|
|
32
|
+
import { styleRoutes } from './style-routes';
|
|
33
|
+
import { serviceWorkerArtifacts } from './sw-artifacts';
|
|
34
|
+
import { serviceWorkerRoutes } from './sw-routes';
|
|
35
|
+
import { loadThemeMode, themeBoot } from './theme-boot';
|
|
36
|
+
|
|
37
|
+
/** The half of `serveApp` whose every acquisition is registered for rollback. */
|
|
38
|
+
export async function bootRoles(boot: {
|
|
39
|
+
readonly options: ServeOptions;
|
|
40
|
+
readonly role: Role;
|
|
41
|
+
readonly runtime: RunningServices;
|
|
42
|
+
readonly acquired: (() => void | Promise<void>)[];
|
|
43
|
+
/** The scrape listener `serveApp` opened before any of this work; `startRoles` adopts it. */
|
|
44
|
+
readonly metrics: MetricsEndpoint;
|
|
45
|
+
}): Promise<ServedApp> {
|
|
46
|
+
const { options, role, runtime, acquired, metrics } = boot;
|
|
47
|
+
// Importing the app's modules IS the registration: every route, action and job below is
|
|
48
|
+
// whatever this call put in the registries.
|
|
49
|
+
await loadApp(options.root);
|
|
50
|
+
// The build stamps `BUILD_ID` into the image; unstamped, the manifest's content hash is the same
|
|
51
|
+
// answer computed here, so `x-ultimate-build` is never absent and never a lie. Projected only
|
|
52
|
+
// when unstamped, because a stamped image already paid for it at build time and a replica's boot
|
|
53
|
+
// should not repeat it — the load above is the part every boot needs either way.
|
|
54
|
+
const stamped = options.env['BUILD_ID'];
|
|
55
|
+
const buildId =
|
|
56
|
+
stamped !== undefined && stamped.length > 0
|
|
57
|
+
? stamped
|
|
58
|
+
: (await appManifest(options.root)).manifest.buildId;
|
|
59
|
+
// Before the first socket opens: everything above this line fails loudly into the container's
|
|
60
|
+
// own logs, everything below it is a served request, a claimed job or a routed frame.
|
|
61
|
+
configureReporting(options.env, buildId);
|
|
62
|
+
// Beside error reporting, and for the same reason it is here: `OTEL_EXPORTER_OTLP_ENDPOINT` is
|
|
63
|
+
// in the shipped chart and nothing read it, so every deployment that configured a collector got
|
|
64
|
+
// an empty dashboard. `x dev` keeps its own recorder — the `/_x` timeline is a different sink
|
|
65
|
+
// with a different lifetime — so this is the production boot's alone (axiom 6).
|
|
66
|
+
const stopOtlp = startOtlpExport(options.env);
|
|
67
|
+
acquired.push(stopOtlp);
|
|
68
|
+
// Only the web role serves pages, so only it builds islands and assembles a route table: a
|
|
69
|
+
// worker, scheduler, sync or replicator pod compiled every island of the app on every boot for
|
|
70
|
+
// nothing it would ever serve (plan 101, slice 12 e).
|
|
71
|
+
const web = role === 'web' ? await webSurface(options, runtime, buildId) : undefined;
|
|
72
|
+
const port = options.port ?? portFromEnv(options.env);
|
|
73
|
+
// An in-process caller asking for an ephemeral app port is a test, and a test that grabbed the
|
|
74
|
+
// fixed 9090 would fail the next suite to boot beside it. An environment that names the port
|
|
75
|
+
// still wins — that is the deploy talking.
|
|
76
|
+
const metricsPort = metricsPortFor(options.env, port, options.metricsPort);
|
|
77
|
+
const drain = await loadDrainConfig(options.root);
|
|
78
|
+
const replicaOverride = replicaOverrides(options.runtime, runtime.services.db, options.env);
|
|
79
|
+
const running = await startRoles({
|
|
80
|
+
roles: [role],
|
|
81
|
+
port,
|
|
82
|
+
metricsPort,
|
|
83
|
+
metrics,
|
|
84
|
+
buildId,
|
|
85
|
+
runtime,
|
|
86
|
+
routes: web?.routes ?? [],
|
|
87
|
+
env: options.env,
|
|
88
|
+
// Same declaration `x dev` reads. Without it a container answers a browser that opened a
|
|
89
|
+
// guarded page with the problem document, rendered as raw JSON in the viewport.
|
|
90
|
+
signInPath: await loadSignInPath(options.root),
|
|
91
|
+
// The enforced policy this process sends must admit the app's own error pages' `<style>` and
|
|
92
|
+
// the theme boot the documents carry; `x dev` is report-only, so only here was it a blank page.
|
|
93
|
+
inlineStyles: web === undefined ? [] : await errorPageStyleSources(options.root),
|
|
94
|
+
inlineScripts: web === undefined ? [] : [web.themeCsp],
|
|
95
|
+
// The app's own `apps/web/site/errors/<status>.html`, resolved inside `startWeb` so this
|
|
96
|
+
// process and `x dev` cannot answer a browser differently.
|
|
97
|
+
root: options.root,
|
|
98
|
+
http: containerBinding(options.env, options.hostname),
|
|
99
|
+
// `app.config.ts`'s drain section: the readiness grace the chart's grace period is sized for.
|
|
100
|
+
...(drain === undefined ? {} : { drain }),
|
|
101
|
+
// The read-replica scope rides in FRONT of whatever the host supplied, or the host's own value
|
|
102
|
+
// passes through untouched. `DATABASE_REPLICA_URL` was read by no booted process before this:
|
|
103
|
+
// `defaultClient()` is the one composer of a replicated pair and it runs only when an app
|
|
104
|
+
// installed no client, which no framework boot leaves true (`runtime-queue.ts`).
|
|
105
|
+
...(replicaOverride === undefined ? {} : { overrides: replicaOverride }),
|
|
106
|
+
});
|
|
107
|
+
acquired.push(() => running.stop());
|
|
108
|
+
return {
|
|
109
|
+
kind: 'served',
|
|
110
|
+
role,
|
|
111
|
+
url: running.url,
|
|
112
|
+
buildId,
|
|
113
|
+
running,
|
|
114
|
+
runtime,
|
|
115
|
+
async stop() {
|
|
116
|
+
await running.stop();
|
|
117
|
+
await runtime.stop();
|
|
118
|
+
// Last: the exporters outlive the roles they were recording, so the drain's own spans and
|
|
119
|
+
// the final counter snapshot still have somewhere to go.
|
|
120
|
+
stopOtlp();
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** What only the web role serves: its islands, its documents and every route beside them. */
|
|
126
|
+
async function webSurface(
|
|
127
|
+
options: ServeOptions,
|
|
128
|
+
runtime: RunningServices,
|
|
129
|
+
buildId: string,
|
|
130
|
+
): Promise<{ readonly routes: readonly Route[]; readonly themeCsp: string }> {
|
|
131
|
+
// Read from the store `x build --target docker` wrote and VERIFIED against this app and this
|
|
132
|
+
// runtime (`island-store.ts`); built here only when there is no store to trust, which is correct
|
|
133
|
+
// and slower, and logged with its reason.
|
|
134
|
+
const islands = await loadOrBuildIslands(options.root);
|
|
135
|
+
// The same two strings `x dev` resolves, from the same reader: a `<link rel="manifest">` served
|
|
136
|
+
// on a laptop and absent in the image is exactly the dev/prod difference this file exists to
|
|
137
|
+
// prevent, and it is the one an operator cannot see without installing the app.
|
|
138
|
+
const pwa = await loadPwaArtifacts(options.root);
|
|
139
|
+
const theme = themeBoot(await loadThemeMode(options.root));
|
|
140
|
+
// The page's sync target and its scripts — the same call `x dev` makes, so the two cannot differ.
|
|
141
|
+
// Before the service worker, which precaches those scripts.
|
|
142
|
+
const sync = await pageSync(options.root, options.env, buildId);
|
|
143
|
+
// The worker, from the SAME route table this process is about to serve — `describeRoutes()` is
|
|
144
|
+
// the one projection `x.manifest.json`, `/_x`, the sitemap and `sw.js` are all built from, so a
|
|
145
|
+
// route added here cannot be missing from the precache manifest.
|
|
146
|
+
const serviceWorker =
|
|
147
|
+
pwa === undefined
|
|
148
|
+
? undefined
|
|
149
|
+
: serviceWorkerArtifacts({
|
|
150
|
+
pwa,
|
|
151
|
+
buildId,
|
|
152
|
+
routes: describeRoutes(),
|
|
153
|
+
islands,
|
|
154
|
+
styles: styleBundle(),
|
|
155
|
+
scripts: sync.scripts,
|
|
156
|
+
});
|
|
157
|
+
// The app's own MCP endpoint, through the same call `x dev` makes — see `app-mcp.ts`.
|
|
158
|
+
const mcpMount = await mountAppMcp(options.root);
|
|
159
|
+
const routes: readonly Route[] = [
|
|
160
|
+
...apiRoutes(),
|
|
161
|
+
...mcpMount.routes,
|
|
162
|
+
...(serviceWorker === undefined ? [] : serviceWorkerRoutes(serviceWorker)),
|
|
163
|
+
...assetRoutes({
|
|
164
|
+
root: options.root,
|
|
165
|
+
storage: runtime.storage,
|
|
166
|
+
...(options.runtime?.images === undefined ? {} : { images: options.runtime.images }),
|
|
167
|
+
...(pwa === undefined ? {} : { pwa }),
|
|
168
|
+
}),
|
|
169
|
+
...storageRoutes({ storage: runtime.storage }),
|
|
170
|
+
...islandRoutes(() => islands),
|
|
171
|
+
// The surface stylesheets the documents link. Built from the registry the `loadApp` above
|
|
172
|
+
// filled, so this process serves exactly the CSS it renders against.
|
|
173
|
+
...styleRoutes(() => styleBundle()),
|
|
174
|
+
// The page's one socket: its worker script, served beside the islands for their reason.
|
|
175
|
+
...sync.routes,
|
|
176
|
+
...appRoutes({
|
|
177
|
+
buildId,
|
|
178
|
+
resolveIsland: (file) => islands.resolverFor(file),
|
|
179
|
+
sync: sync.head,
|
|
180
|
+
persisted: sync.persisted,
|
|
181
|
+
themeHead: theme.head,
|
|
182
|
+
...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
|
|
183
|
+
// Only when a store was supplied. `createIsrController` defaults to a per-process memory
|
|
184
|
+
// store, so twelve replicas hold twelve of them and a purge tag regenerates one twelfth of
|
|
185
|
+
// the fleet while the other eleven keep serving the page it just invalidated.
|
|
186
|
+
...(options.runtime?.isrStore === undefined
|
|
187
|
+
? {}
|
|
188
|
+
: { isr: createIsrController({ buildId, store: options.runtime.isrStore }) }),
|
|
189
|
+
}),
|
|
190
|
+
];
|
|
191
|
+
return { routes, themeCsp: theme.cspSource };
|
|
192
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// `app.config.ts`'s `drain` section, read for the production boot — the same import every other
|
|
2
|
+
// config reader here makes (`theme-boot.ts`, `app-auth.ts`), because the config is a module.
|
|
3
|
+
|
|
4
|
+
import type { DrainConfig } from '@ultimat3/core';
|
|
5
|
+
import { APP_CONFIG_EXPORT } from './app-auth';
|
|
6
|
+
import { APP_CONFIG_FILE } from './app-root';
|
|
7
|
+
|
|
8
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
9
|
+
typeof value === 'object' && value !== null;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The declared section, or `undefined` when the app has none (a config not built by
|
|
13
|
+
* `defineConfig`, or no config file): `@ultimat3/http` then keeps core's own default. A value that
|
|
14
|
+
* is not a number is left for core to refuse, where the rule lives, rather than dropped here.
|
|
15
|
+
*/
|
|
16
|
+
export async function loadDrainConfig(root: string): Promise<Partial<DrainConfig> | undefined> {
|
|
17
|
+
const path = `${root}/${APP_CONFIG_FILE}`;
|
|
18
|
+
if (!(await Bun.file(path).exists())) return undefined;
|
|
19
|
+
const module = (await import(path)) as Record<string, unknown>;
|
|
20
|
+
const config = module[APP_CONFIG_EXPORT];
|
|
21
|
+
const drain = isRecord(config) ? config['drain'] : undefined;
|
|
22
|
+
if (!isRecord(drain) || !Object.hasOwn(drain, 'readinessGraceMs')) return undefined;
|
|
23
|
+
return { readinessGraceMs: drain['readinessGraceMs'] as number };
|
|
24
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// `@ultimat3/cli/serve`: what a container's `apps/web/server.ts` imports, and nothing else. The
|
|
2
|
+
// `@ultimat3/cli` barrel is 1,349 modules — every command, every template, the e2e driver — and a
|
|
3
|
+
// production process that booted through it loaded all of them to call one function.
|
|
4
|
+
|
|
5
|
+
export { runRole } from './serve';
|
|
6
|
+
export type { MigratedApp, ServedApp, ServeOptions, StartedApp } from './serve-types';
|
package/src/serve-env.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// What a container's environment decides about a boot: which role, which port, which interface,
|
|
2
|
+
// and where errors are reported. Split from `serve.ts`, which re-exports every name here.
|
|
3
|
+
|
|
4
|
+
import type { Role } from '@ultimat3/core';
|
|
5
|
+
import { configureErrorReporting, isRole, ROLES, sentryErrorReporter } from '@ultimat3/core';
|
|
6
|
+
import { PortInvalidError, RoleUnknownError } from './errors';
|
|
7
|
+
import { DEFAULT_METRICS_PORT } from './metrics-endpoint';
|
|
8
|
+
import type { WebBinding } from './role-start';
|
|
9
|
+
import type { Env } from './runtime-bindings';
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_PORT = 3000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Every interface, which is what a role binds when nothing says otherwise: a container bound to
|
|
15
|
+
* loopback is unreachable through its own port mapping. `HOST` and `ServeOptions.hostname` are the
|
|
16
|
+
* two ways of saying otherwise — see `hostnameFromEnv`.
|
|
17
|
+
*/
|
|
18
|
+
export const CONTAINER_BINDING: WebBinding = { dev: false, hostname: '0.0.0.0' };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The interface the `web` and `sync` roles bind, and the metrics endpoint with them (`WebBinding`
|
|
22
|
+
* is one decision). Read the way `PORT` is: empty or whitespace is the default.
|
|
23
|
+
*
|
|
24
|
+
* Exists because a container had exactly one binding, `0.0.0.0`, and an app whose auth mode is
|
|
25
|
+
* "nobody logs in, one implicit actor" must refuse a public interface — so it could not run in a
|
|
26
|
+
* container at all. `HOST=127.0.0.1` is unreachable through `docker run -p` (the proxy connects to
|
|
27
|
+
* the container's bridge address, never its loopback); it is reachable where the container shares
|
|
28
|
+
* the host's network namespace (`--network host`), or through a sidecar and `ssh -L` inside it —
|
|
29
|
+
* which is the exposure such an app wants. Not `HOSTNAME`: Docker sets that to the container id.
|
|
30
|
+
*/
|
|
31
|
+
export function hostnameFromEnv(env: Env): string {
|
|
32
|
+
const raw = env['HOST']?.trim();
|
|
33
|
+
return raw === undefined || raw.length === 0 ? CONTAINER_BINDING.hostname : raw;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What `serveApp` hands `startRoles`: the caller's hostname, else `HOST`, else every interface. */
|
|
37
|
+
export const containerBinding = (env: Env, hostname?: string): WebBinding => ({
|
|
38
|
+
dev: false,
|
|
39
|
+
hostname: hostname ?? hostnameFromEnv(env),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `ROLE` is the one knob one image exposes. Validated rather than defaulted: a typo that fell back
|
|
44
|
+
* to `web` would start a process that serves nothing the operator asked for and reports healthy.
|
|
45
|
+
*/
|
|
46
|
+
export function roleFromEnv(env: Env): Role {
|
|
47
|
+
const raw = env['ROLE'] ?? 'web';
|
|
48
|
+
if (!isRole(raw)) throw new RoleUnknownError({ role: raw, known: ROLES });
|
|
49
|
+
return raw;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `Number.parseInt` would read `80abc` as 80, so the whole string has to be a port — a
|
|
54
|
+
* partially-parsed port is a deploy that binds somewhere nobody asked for.
|
|
55
|
+
*/
|
|
56
|
+
function portValue(env: Env, name: string, fallback: number): number {
|
|
57
|
+
const raw = env[name];
|
|
58
|
+
if (raw === undefined || raw.trim().length === 0) return fallback;
|
|
59
|
+
const port = Number(raw.trim());
|
|
60
|
+
if (!Number.isInteger(port) || port < 0 || port > 65_535)
|
|
61
|
+
throw new PortInvalidError({ value: raw, name });
|
|
62
|
+
return port;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Every PaaS injects `PORT` and routes traffic to exactly it. */
|
|
66
|
+
export function portFromEnv(env: Env): number {
|
|
67
|
+
return portValue(env, 'PORT', DEFAULT_PORT);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The scrape port, deliberately its own env var and not `PORT + n`: an operator who moves the app
|
|
72
|
+
* port must not silently move the port their Prometheus is configured against, and the roles that
|
|
73
|
+
* set no `PORT` at all — `worker`, `scheduler`, `replicator` — still need this one.
|
|
74
|
+
*/
|
|
75
|
+
export function metricsPortFromEnv(env: Env): number {
|
|
76
|
+
return portValue(env, 'METRICS_PORT', DEFAULT_METRICS_PORT);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The scrape port a boot uses, given the app port it already resolved. One expression, and it is
|
|
81
|
+
* exported because `x dev` is the second caller: `cmd-dev.ts` passed no `metricsPort` at all, so
|
|
82
|
+
* `METRICS_PORT` was honoured in the container and ignored on a laptop — the dev/prod parity break
|
|
83
|
+
* `role-start.ts`'s own header forbids, and a second copy of this rule would be the same break
|
|
84
|
+
* one edit later.
|
|
85
|
+
*
|
|
86
|
+
* An in-process caller asking for an ephemeral app port is a test, and a test that grabbed the
|
|
87
|
+
* fixed 9090 would fail the next suite to boot beside it. An environment that names the port still
|
|
88
|
+
* wins — that is the deploy talking.
|
|
89
|
+
*/
|
|
90
|
+
export const metricsPortFor = (env: Env, port: number, override?: number): number =>
|
|
91
|
+
override ?? (port === 0 && env['METRICS_PORT'] === undefined ? 0 : metricsPortFromEnv(env));
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The one env var that turns error monitoring on, and the only vendor-shaped name in the boot
|
|
95
|
+
* path. Not a platform primitive (axiom 7): the value is a URL to whatever the operator runs, the
|
|
96
|
+
* wire format behind it is documented and self-hostable, and `SENTRY_DSN` is what every monitor
|
|
97
|
+
* that speaks it already documents — inventing a second spelling would mean an operator's existing
|
|
98
|
+
* tooling sets a variable this framework ignores. Exactly the precedent
|
|
99
|
+
* `OTEL_EXPORTER_OTLP_ENDPOINT` already sets in `docker/helm/values.yaml`.
|
|
100
|
+
*/
|
|
101
|
+
export const ERROR_DSN_KEY = 'SENTRY_DSN';
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Switch reporting on for this process. Unset DSN leaves core's no-op reporter in place, so a
|
|
105
|
+
* laptop and a CI run pay nothing and page nobody — and the release every event carries is the
|
|
106
|
+
* build id this boot already computed, never a second identity for the same deploy.
|
|
107
|
+
*/
|
|
108
|
+
export function configureReporting(env: Env, buildId: string): void {
|
|
109
|
+
const dsn = env[ERROR_DSN_KEY]?.trim();
|
|
110
|
+
configureErrorReporting({
|
|
111
|
+
release: buildId,
|
|
112
|
+
// A malformed DSN throws here, at boot, rather than at the first outage: a monitor that was
|
|
113
|
+
// never connected looks exactly like an app that never failed.
|
|
114
|
+
...(dsn === undefined || dsn.length === 0 ? {} : { reporter: sentryErrorReporter({ dsn }) }),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// The shapes a production boot takes and answers. Split from `serve.ts`, which re-exports them.
|
|
2
|
+
|
|
3
|
+
import type { Role } from '@ultimat3/core';
|
|
4
|
+
import type { DriftReport, MigrationReport } from '@ultimat3/db';
|
|
5
|
+
import type { RunningRoles } from './role-start';
|
|
6
|
+
import type { Env } from './runtime-bindings';
|
|
7
|
+
import type { RuntimeOverrides } from './runtime-overrides';
|
|
8
|
+
import type { RunningServices } from './runtime-services';
|
|
9
|
+
|
|
10
|
+
export interface ServeOptions {
|
|
11
|
+
readonly root: string;
|
|
12
|
+
readonly env: Env;
|
|
13
|
+
/** Overrides `ROLE`; `runRole` reads the environment when this is absent. */
|
|
14
|
+
readonly role?: Role;
|
|
15
|
+
/** Overrides `PORT`. 0 asks the kernel for an ephemeral one, which is what a test wants. */
|
|
16
|
+
readonly port?: number;
|
|
17
|
+
/** Overrides `METRICS_PORT`, on the same terms. */
|
|
18
|
+
readonly metricsPort?: number;
|
|
19
|
+
/**
|
|
20
|
+
* Overrides `HOST`: the interface the HTTP roles bind. An app that must never answer on a public
|
|
21
|
+
* interface passes `'127.0.0.1'` here rather than trusting the deployment to set the variable.
|
|
22
|
+
*/
|
|
23
|
+
readonly hostname?: string;
|
|
24
|
+
/**
|
|
25
|
+
* The drivers this deployment supplies instead of the ones the environment would select.
|
|
26
|
+
*
|
|
27
|
+
* This field is why `apps/web/server.ts` can stay three lines and still run a custom queue, a
|
|
28
|
+
* shared ISR store or an app's own middleware. Before it there was nowhere to hand the framework
|
|
29
|
+
* a driver, so the only way was an ambient setter from an app module — which `loadApp` imports
|
|
30
|
+
* AFTER `startServices` has captured its own, giving a process that enqueues to one queue and
|
|
31
|
+
* claims from another. `startRoles` now refuses that split outright.
|
|
32
|
+
*/
|
|
33
|
+
readonly runtime?: RuntimeOverrides;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ServedApp {
|
|
37
|
+
readonly kind: 'served';
|
|
38
|
+
readonly role: Role;
|
|
39
|
+
/** `http://…` for the web role; null for the roles that open no HTTP socket. */
|
|
40
|
+
readonly url: string | null;
|
|
41
|
+
readonly buildId: string;
|
|
42
|
+
readonly running: RunningRoles;
|
|
43
|
+
readonly runtime: RunningServices;
|
|
44
|
+
stop(): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface MigratedApp {
|
|
48
|
+
readonly kind: 'migrated';
|
|
49
|
+
readonly role: 'migrate';
|
|
50
|
+
readonly report: MigrationReport;
|
|
51
|
+
/** The post-condition: the live schema against the ledger this run just wrote. */
|
|
52
|
+
readonly drift: DriftReport;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type StartedApp = ServedApp | MigratedApp;
|