@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,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
|
|
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 {
|
|
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
|
|
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
|
|
46
|
+
import { DEV_BINDING } from './web-binding';
|
|
53
47
|
|
|
54
|
-
|
|
55
|
-
export
|
|
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
|
-
|
|
65
|
-
|
|
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 `
|
|
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 `
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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 (`
|
|
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({
|
|
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 `
|
|
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,10 +16,11 @@ 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
|
+
import { syncOriginsFrom } from './sync-url';
|
|
23
24
|
import { DEV_BINDING } from './web-binding';
|
|
24
25
|
|
|
25
26
|
/**
|
|
@@ -164,8 +165,13 @@ export function registerLiveQueries(options: StartRolesOptions): LiveQueryRegist
|
|
|
164
165
|
onRowDenied: (event) => logger.debug('live.rows_denied', { ...event }),
|
|
165
166
|
});
|
|
166
167
|
const ctx = createContext({ role: 'sync', buildId: options.buildId });
|
|
168
|
+
// The position a snapshot claims: the newest change this node had received when the read began.
|
|
169
|
+
// The read then holds at least that change, so claiming it is true, and every later change is
|
|
170
|
+
// above it. Unwired, every snapshot claimed `''` and a read that landed after any fan-out was
|
|
171
|
+
// discarded as older than its own window (plan 101, slice 06 c).
|
|
172
|
+
const lsn = (): string => registry.lastLsn;
|
|
167
173
|
for (const target of listQueries()) {
|
|
168
|
-
if (target.isLive) registry.register(liveQueryDefinition(target, { ctx }));
|
|
174
|
+
if (target.isLive) registry.register(liveQueryDefinition(target, { ctx, lsn }));
|
|
169
175
|
}
|
|
170
176
|
return registry;
|
|
171
177
|
}
|
|
@@ -196,6 +202,9 @@ export async function prepareSync(options: StartRolesOptions): Promise<PreparedS
|
|
|
196
202
|
buildId: options.buildId,
|
|
197
203
|
sockets,
|
|
198
204
|
...(authenticate === undefined ? {} : { authenticate }),
|
|
205
|
+
// The page's origin when it is served on another host than this node; the node's own host is
|
|
206
|
+
// admitted without it. Anything else is refused before `authenticate` (X_SOCKET_ORIGIN_REFUSED).
|
|
207
|
+
allowedOrigins: syncOriginsFrom(options.env),
|
|
199
208
|
// Tier 1 is presence, and without a registry the node answers a topic subscribe with no member
|
|
200
209
|
// list at all — the KV bucket the transport just created would hold nothing and every `sync`
|
|
201
210
|
// container would run a presence-less protocol. It reads and writes `transport.shared`, so it
|
|
@@ -253,11 +262,16 @@ async function listen(
|
|
|
253
262
|
// so the one socket that streams live database patches was the one socket on every
|
|
254
263
|
// interface — and `WebBinding`'s own docstring is about not serving a laptop's app to a café.
|
|
255
264
|
const binding = options.http ?? DEV_BINDING;
|
|
256
|
-
// No drain grace
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
|
|
265
|
+
// No drain grace UNDER `x dev`: there is one node and it is the one going away, and a grace
|
|
266
|
+
// there was five seconds of every Ctrl-C (measured 2026-09-06, 5.0s of 5.1s) spent on a
|
|
267
|
+
// reconnect frame whose target does not exist yet. A container is the opposite case — other
|
|
268
|
+
// sync pods are up — and a grace of 0 there dropped every socket at once on SIGTERM, so it
|
|
269
|
+
// takes the node's own default and spreads the reconnects.
|
|
270
|
+
const listener = listenSyncNode(node, {
|
|
271
|
+
port,
|
|
272
|
+
hostname: binding.hostname,
|
|
273
|
+
...(binding.dev ? { drainGraceMs: 0 } : {}),
|
|
274
|
+
});
|
|
261
275
|
// BOTH doors, named, once. `sync node ready` said only that a node existed: the first question
|
|
262
276
|
// a failing browser socket raises — "is the ws server up, and where?" — had no answer anywhere
|
|
263
277
|
// in the boot output, and the port was never printed at all. It is also what an editor's port
|
package/src/root-env.ts
ADDED
|
@@ -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 `
|
|
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,18 +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(
|
|
44
|
+
export function resolveServices(
|
|
45
|
+
root: string,
|
|
46
|
+
env: Env,
|
|
47
|
+
realtime: RealtimeConfig = REALTIME_DEFAULTS,
|
|
48
|
+
): DevServices {
|
|
43
49
|
// `ULTIMATE_STATE_DIR` relocates the whole of `.x/` — the embedded database, the local disk and
|
|
44
50
|
// the dev lock — for one process tree. It is how an e2e run boots the app on a THROWAWAY database
|
|
45
51
|
// (`e2e-app.ts`) instead of resetting the developer's own, beside a running `x dev`.
|
|
46
52
|
const stateDir = nonEmpty(env['ULTIMATE_STATE_DIR']) ?? join(root, '.x');
|
|
47
53
|
const databaseUrl = nonEmpty(env['DATABASE_URL']);
|
|
48
|
-
const natsUrl = nonEmpty(env['NATS_URL']);
|
|
49
54
|
const s3Endpoint = nonEmpty(env['S3_ENDPOINT']);
|
|
50
|
-
// Created only when something will actually live in it
|
|
51
|
-
//
|
|
52
|
-
// EACCES at boot for a directory
|
|
53
|
-
|
|
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) {
|
|
54
60
|
mkdirSync(stateDir, { recursive: true });
|
|
55
61
|
}
|
|
56
62
|
return {
|
|
@@ -65,15 +71,7 @@ export function resolveServices(root: string, env: Env): DevServices {
|
|
|
65
71
|
detail: 'PGlite in this process — set DATABASE_URL to use a real Postgres',
|
|
66
72
|
}
|
|
67
73
|
: { name: 'db', mode: 'external', url: databaseUrl, detail: 'DATABASE_URL' },
|
|
68
|
-
events:
|
|
69
|
-
natsUrl === undefined
|
|
70
|
-
? {
|
|
71
|
-
name: 'events',
|
|
72
|
-
mode: 'embedded',
|
|
73
|
-
url: 'inproc://events',
|
|
74
|
-
detail: 'in-process fanout — set NATS_URL to use NATS',
|
|
75
|
-
}
|
|
76
|
-
: { name: 'events', mode: 'external', url: natsUrl, detail: 'NATS_URL' },
|
|
74
|
+
events: eventsBinding(env, realtime),
|
|
77
75
|
storage:
|
|
78
76
|
s3Endpoint === undefined
|
|
79
77
|
? {
|
|
@@ -91,7 +89,7 @@ export function resolveServices(root: string, env: Env): DevServices {
|
|
|
91
89
|
* `x dev --json` emitted `DATABASE_URL` and `NATS_URL` verbatim — passwords included — into a
|
|
92
90
|
* field that is printed to a terminal, piped into a log and scraped by a script, while the rule
|
|
93
91
|
* against it was already written three lines from the emitting code and applied only to mail and
|
|
94
|
-
* cdn. The bindings keep the real url because `
|
|
92
|
+
* cdn. The bindings keep the real url because `runtime-queue.ts` has to connect with it; only this
|
|
95
93
|
* projection is redacted, so a leak cannot come back as a caller forgetting to call a helper.
|
|
96
94
|
*/
|
|
97
95
|
export const reportedUrls = (services: DevServices): Record<ServiceBinding['name'], string> => ({
|
|
@@ -104,3 +102,28 @@ export const describeServices = (services: DevServices): string =>
|
|
|
104
102
|
[services.db, services.events, services.storage]
|
|
105
103
|
.map((binding) => `${binding.name}=${binding.mode}`)
|
|
106
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 './
|
|
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 `
|
|
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) {
|