@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.
- package/CLAUDE.md +84 -1582
- package/README.md +40 -2
- package/package.json +31 -31
- package/src/api-registration.ts +124 -0
- package/src/app-artifacts.ts +39 -0
- package/src/app-boundaries.ts +5 -10
- package/src/app-env.ts +2 -2
- 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 +56 -13
- package/src/build-errors.ts +54 -0
- 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 +117 -42
- package/src/cmd-dev-spec.ts +25 -0
- package/src/cmd-dev.ts +78 -122
- 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 +119 -0
- 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 +33 -28
- package/src/error-pages.ts +19 -7
- package/src/error-unthrown.ts +130 -0
- package/src/errors.ts +22 -29
- 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 -334
- package/src/invocation-flags.ts +26 -0
- package/src/island-bundle.ts +38 -11
- package/src/island-capture.ts +6 -6
- package/src/island-harness-script.ts +6 -2
- package/src/island-realtime.ts +98 -0
- package/src/island-shot.ts +5 -0
- package/src/island-store.ts +131 -0
- package/src/island-verdict.ts +2 -2
- package/src/job-registration.ts +42 -0
- package/src/jobs-driver.ts +2 -2
- package/src/live-routes.ts +82 -42
- package/src/load-findings.ts +51 -0
- package/src/mcp-db-target.ts +1 -1
- package/src/mcp-errors.ts +30 -26
- 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 +19 -15
- 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/page-sync.ts +54 -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/realtime-browser-probe-fixture.ts +2 -2
- 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} +23 -11
- 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} +43 -17
- 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} +20 -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-render.ts → runtime-render.ts} +119 -20
- 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 -338
- 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/sw-artifacts.ts +13 -3
- package/src/sync-url.ts +31 -0
- 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 +76 -30
- package/src/templates/resource.ts +10 -4
- package/src/templates/route.ts +3 -0
- 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 +25 -145
- 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 +41 -0
- package/src/verify-run.ts +115 -50
- package/src/verify-step.ts +3 -3
- package/src/verify-tests.ts +22 -30
- package/src/verify-typecheck.ts +28 -0
- package/src/web-binding.ts +2 -2
- package/src/worker-bundle.ts +192 -0
- package/src/workspace-graph.ts +10 -33
- package/src/cdp-browser.ts +0 -100
- package/src/cdp-connection.ts +0 -211
- package/src/cdp-e2e-page.ts +0 -209
- package/src/cdp-errors.ts +0 -56
- package/src/cdp-launch.ts +0 -139
- package/src/e2e-dom-fixture.ts +0 -117
- package/src/e2e-driver.ts +0 -97
- package/src/e2e-errors.ts +0 -103
- package/src/e2e-evaluate.ts +0 -156
- package/src/e2e-locator.ts +0 -86
- package/src/e2e-page.ts +0 -150
- package/src/e2e-selection.ts +0 -182
- package/src/measurement-actor.ts +0 -26
- /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// The options `startRoles` takes and what it answers. Split from `role-start.ts` at its line
|
|
2
|
+
// ceiling; `role-start.ts` re-exports both, so every importer keeps its path.
|
|
3
|
+
|
|
4
|
+
import type { DrainConfig, Role } from '@ultimat3/core';
|
|
5
|
+
import type { Route, ServerHandle, ServerHooks } from '@ultimat3/http';
|
|
6
|
+
import type { Scheduler, Worker } from '@ultimat3/jobs';
|
|
7
|
+
import type { LiveQueryRegistry, LiveReplicator } from '@ultimat3/realtime/server';
|
|
8
|
+
import type { MetricsEndpoint } from './metrics-endpoint';
|
|
9
|
+
import type { RunningReplicator } from './role-replicator';
|
|
10
|
+
import type { Env } from './runtime-bindings';
|
|
11
|
+
import type { LiveFeed } from './runtime-live-feed';
|
|
12
|
+
import type { RuntimeOverrides } from './runtime-overrides';
|
|
13
|
+
import type { RunningServices } from './runtime-services';
|
|
14
|
+
import type { WebBinding } from './web-binding';
|
|
15
|
+
|
|
16
|
+
/** The roles `x dev` starts when `--role` names none, in boot order. */
|
|
17
|
+
export const DEV_ROLES: readonly Role[] = ['web', 'sync', 'worker', 'scheduler'];
|
|
18
|
+
|
|
19
|
+
export interface StartRolesOptions {
|
|
20
|
+
readonly roles: readonly Role[];
|
|
21
|
+
readonly port: number;
|
|
22
|
+
readonly buildId: string;
|
|
23
|
+
readonly runtime: RunningServices;
|
|
24
|
+
/** Routes the web role serves: `/_x`, the actions, the pages. */
|
|
25
|
+
readonly routes: readonly Route[];
|
|
26
|
+
/** The process environment, for the roles that resolve a driver from it. */
|
|
27
|
+
readonly env: Env;
|
|
28
|
+
/**
|
|
29
|
+
* How the web role binds and what it admits about itself. `x dev` keeps the default —
|
|
30
|
+
* loopback, `dev: true`, so a laptop on a café network is not serving the app to the café. A
|
|
31
|
+
* container passes `{ dev: false, hostname: '0.0.0.0' }`: a process bound to `localhost` inside
|
|
32
|
+
* a container is unreachable from the port mapping, the load balancer and every PaaS health
|
|
33
|
+
* probe, which is the same failure in four costumes.
|
|
34
|
+
*/
|
|
35
|
+
readonly http?: WebBinding;
|
|
36
|
+
/**
|
|
37
|
+
* The app's `auth.signInPath`. Threaded rather than read from the config here because
|
|
38
|
+
* `startRoles` takes plain values — a test starts a web role with no `app.config.ts` at all.
|
|
39
|
+
*/
|
|
40
|
+
readonly signInPath?: string | null;
|
|
41
|
+
/**
|
|
42
|
+
* The app root, for the one seam that is a FILE and not a value: `apps/web/site/errors/404.html`
|
|
43
|
+
* and its siblings. Bound HERE rather than passed by each caller, because `x dev` and `serve.ts`
|
|
44
|
+
* both boot through this function and an override wired at one of them alone is a page that
|
|
45
|
+
* appears in dev and not in production — `/favicon.ico`'s rule, one seam over.
|
|
46
|
+
*
|
|
47
|
+
* Optional for the reason `signInPath` is: `startRoles` takes plain values, and a test starts a
|
|
48
|
+
* web role with no app on disk at all. Absent, every error page is the framework's.
|
|
49
|
+
*/
|
|
50
|
+
readonly root?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Inline `<style>` bodies this process serves that the app's own surfaces do not account for —
|
|
53
|
+
* `/_x`'s shell. The surfaces themselves are read from the stylesheet registry here rather than
|
|
54
|
+
* passed, so no caller of `startRoles` can ship a web server whose CSP blocks the pages it
|
|
55
|
+
* serves: that policy is what rendered every deployed app completely unstyled.
|
|
56
|
+
*/
|
|
57
|
+
readonly inlineStyles?: readonly string[];
|
|
58
|
+
/** `script-src` sources beyond the hydration runtime's — the theme boot's hash. */
|
|
59
|
+
readonly inlineScripts?: readonly string[];
|
|
60
|
+
/**
|
|
61
|
+
* Non-fatal findings the browser overlay shows next to an error, for the request being answered.
|
|
62
|
+
* Only `x dev` supplies one — `serve.ts` boots through this same function and omits it, so a
|
|
63
|
+
* production process never has a diagnostic to call (axiom 6).
|
|
64
|
+
*/
|
|
65
|
+
readonly devNotices?: ServerHooks['devNotices'];
|
|
66
|
+
/**
|
|
67
|
+
* Where the scrape listener binds. Defaults to `DEFAULT_METRICS_PORT`, except when `port` is 0
|
|
68
|
+
* — a caller asking the kernel for an ephemeral HTTP port is a test, and a test that grabbed
|
|
69
|
+
* 9090 would fail the next one to run beside it.
|
|
70
|
+
*/
|
|
71
|
+
readonly metricsPort?: number;
|
|
72
|
+
/**
|
|
73
|
+
* What the host substituted for a boot decision. Read here for the three seams that are not
|
|
74
|
+
* services — the rate-limit store, the middleware chain and the sync authenticator — while the
|
|
75
|
+
* drivers themselves arrive already resolved on `runtime`.
|
|
76
|
+
*/
|
|
77
|
+
readonly overrides?: RuntimeOverrides;
|
|
78
|
+
/**
|
|
79
|
+
* `app.config.ts`'s `drain` section, for the web server — its readiness grace is how long
|
|
80
|
+
* `/readyz` answers 503 before the listener closes. `serve.ts` passes it; absent, core's own
|
|
81
|
+
* default stands.
|
|
82
|
+
*/
|
|
83
|
+
readonly drain?: Partial<DrainConfig>;
|
|
84
|
+
/**
|
|
85
|
+
* A scrape listener the caller opened before its own boot work, adopted rather than bound twice:
|
|
86
|
+
* `serve.ts` opens it before loading the app and building islands, so a cold pod answers
|
|
87
|
+
* `/metrics` — and the chart's startup probe — while it boots. Stopped with the roles.
|
|
88
|
+
*/
|
|
89
|
+
readonly metrics?: MetricsEndpoint;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface RunningRoles {
|
|
93
|
+
readonly roles: readonly Role[];
|
|
94
|
+
/** `http://…` once the web role is up; null when it was not selected. */
|
|
95
|
+
readonly url: string | null;
|
|
96
|
+
/** Where the sync role accepts websockets; null when it was not selected. */
|
|
97
|
+
readonly syncUrl: string | null;
|
|
98
|
+
/** `http://…` — the scrape base. Never null: every role publishes a signal worth scaling on. */
|
|
99
|
+
readonly metricsUrl: string;
|
|
100
|
+
readonly server: ServerHandle | null;
|
|
101
|
+
readonly worker: Worker | null;
|
|
102
|
+
readonly scheduler: Scheduler | null;
|
|
103
|
+
/** The slot and feed this process holds; null when the replicator was not selected. */
|
|
104
|
+
readonly replicator: RunningReplicator | null;
|
|
105
|
+
/** What feeds the sync node: this process's own writes, the WAL decoder, or nothing. */
|
|
106
|
+
readonly liveFeed: LiveFeed;
|
|
107
|
+
/** The in-process bridge when `liveFeed` is `in-process`, so a test can await `settled()`. */
|
|
108
|
+
readonly liveBridge: LiveReplicator | null;
|
|
109
|
+
/** The sync node's registry, so a test can hold a real subscription; null without the role. */
|
|
110
|
+
readonly liveRegistry: LiveQueryRegistry | null;
|
|
111
|
+
stop(): Promise<void>;
|
|
112
|
+
}
|
|
@@ -8,13 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { Role } from '@ultimat3/core';
|
|
10
10
|
import { createContext, isRole, logger, ROLES } from '@ultimat3/core';
|
|
11
|
-
import type {
|
|
12
|
-
RateLimitStore,
|
|
13
|
-
Route,
|
|
14
|
-
ServerHandle,
|
|
15
|
-
ServerHooks,
|
|
16
|
-
WebSocketMount,
|
|
17
|
-
} from '@ultimat3/http';
|
|
11
|
+
import type { RateLimitStore, Route, ServerHandle, WebSocketMount } from '@ultimat3/http';
|
|
18
12
|
import {
|
|
19
13
|
configuredAuthenticator,
|
|
20
14
|
configuredHttp,
|
|
@@ -23,7 +17,7 @@ import {
|
|
|
23
17
|
MAX_PROXY_HOPS,
|
|
24
18
|
mergeHttpConfig,
|
|
25
19
|
} from '@ultimat3/http';
|
|
26
|
-
import type { OutboxRelay
|
|
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,9 +16,9 @@ import {
|
|
|
16
16
|
RingChangeBuffer,
|
|
17
17
|
SocketRegistry,
|
|
18
18
|
} from '@ultimat3/realtime/server';
|
|
19
|
-
import type { StartRolesOptions } from './dev-roles';
|
|
20
19
|
import { neighbouringPort, PORT_RANGE, portPairAfter } from './flag-number';
|
|
21
20
|
import { portFree } from './port-probe';
|
|
21
|
+
import type { StartRolesOptions } from './role-start';
|
|
22
22
|
import { syncAuthenticator } from './sync-authenticator';
|
|
23
23
|
import { DEV_BINDING } from './web-binding';
|
|
24
24
|
|
|
@@ -131,6 +131,8 @@ export interface RunningSync {
|
|
|
131
131
|
readonly url: string;
|
|
132
132
|
/** The node's registry, so the boot can hand it a change feed the database cannot produce. */
|
|
133
133
|
readonly registry: LiveQueryRegistry;
|
|
134
|
+
/** The node's channel hub — fed the same changes, so a declared channel's `records` flow in dev. */
|
|
135
|
+
readonly hub: ChannelHub;
|
|
134
136
|
stop(): Promise<void>;
|
|
135
137
|
}
|
|
136
138
|
|
|
@@ -162,8 +164,13 @@ export function registerLiveQueries(options: StartRolesOptions): LiveQueryRegist
|
|
|
162
164
|
onRowDenied: (event) => logger.debug('live.rows_denied', { ...event }),
|
|
163
165
|
});
|
|
164
166
|
const ctx = createContext({ role: 'sync', buildId: options.buildId });
|
|
167
|
+
// The position a snapshot claims: the newest change this node had received when the read began.
|
|
168
|
+
// The read then holds at least that change, so claiming it is true, and every later change is
|
|
169
|
+
// above it. Unwired, every snapshot claimed `''` and a read that landed after any fan-out was
|
|
170
|
+
// discarded as older than its own window (plan 101, slice 06 c).
|
|
171
|
+
const lsn = (): string => registry.lastLsn;
|
|
165
172
|
for (const target of listQueries()) {
|
|
166
|
-
if (target.isLive) registry.register(liveQueryDefinition(target, { ctx }));
|
|
173
|
+
if (target.isLive) registry.register(liveQueryDefinition(target, { ctx, lsn }));
|
|
167
174
|
}
|
|
168
175
|
return registry;
|
|
169
176
|
}
|
|
@@ -212,7 +219,7 @@ export async function prepareSync(options: StartRolesOptions): Promise<PreparedS
|
|
|
212
219
|
// second copy of `/_x/sync` here is the copy that stays behind when it moves.
|
|
213
220
|
mount: { path: node.path, fetch: node.fetch, websocket: node.websocket },
|
|
214
221
|
stop: () => node.stop(),
|
|
215
|
-
listen: async (appUrl) => await listen(options, node, registry, appUrl),
|
|
222
|
+
listen: async (appUrl) => await listen(options, node, { registry, hub }, appUrl),
|
|
216
223
|
};
|
|
217
224
|
}
|
|
218
225
|
|
|
@@ -241,7 +248,7 @@ function syncPortFrom(requested: number, appUrl: string | null): number {
|
|
|
241
248
|
async function listen(
|
|
242
249
|
options: StartRolesOptions,
|
|
243
250
|
node: SyncNode,
|
|
244
|
-
|
|
251
|
+
feeds: Pick<RunningSync, 'registry' | 'hub'>,
|
|
245
252
|
appUrl: string | null,
|
|
246
253
|
): Promise<RunningSync> {
|
|
247
254
|
const port = syncPortFrom(options.port, appUrl);
|
|
@@ -251,11 +258,16 @@ async function listen(
|
|
|
251
258
|
// so the one socket that streams live database patches was the one socket on every
|
|
252
259
|
// interface — and `WebBinding`'s own docstring is about not serving a laptop's app to a café.
|
|
253
260
|
const binding = options.http ?? DEV_BINDING;
|
|
254
|
-
// No drain grace
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
261
|
+
// No drain grace UNDER `x dev`: there is one node and it is the one going away, and a grace
|
|
262
|
+
// there was five seconds of every Ctrl-C (measured 2026-09-06, 5.0s of 5.1s) spent on a
|
|
263
|
+
// reconnect frame whose target does not exist yet. A container is the opposite case — other
|
|
264
|
+
// sync pods are up — and a grace of 0 there dropped every socket at once on SIGTERM, so it
|
|
265
|
+
// takes the node's own default and spreads the reconnects.
|
|
266
|
+
const listener = listenSyncNode(node, {
|
|
267
|
+
port,
|
|
268
|
+
hostname: binding.hostname,
|
|
269
|
+
...(binding.dev ? { drainGraceMs: 0 } : {}),
|
|
270
|
+
});
|
|
259
271
|
// BOTH doors, named, once. `sync node ready` said only that a node existed: the first question
|
|
260
272
|
// a failing browser socket raises — "is the ws server up, and where?" — had no answer anywhere
|
|
261
273
|
// in the boot output, and the port was never printed at all. It is also what an editor's port
|
|
@@ -266,7 +278,7 @@ async function listen(
|
|
|
266
278
|
});
|
|
267
279
|
return {
|
|
268
280
|
url: listener.url,
|
|
269
|
-
|
|
281
|
+
...feeds,
|
|
270
282
|
stop: async () => {
|
|
271
283
|
listener.stop();
|
|
272
284
|
await node.stop();
|
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,15 +41,22 @@ const nonEmpty = (value: string | undefined): string | undefined =>
|
|
|
39
41
|
* Embedded Postgres is PGlite on disk under `.x/`, so a restart keeps the data and a `x db reset`
|
|
40
42
|
* is a directory delete rather than a container dance.
|
|
41
43
|
*/
|
|
42
|
-
export function resolveServices(
|
|
43
|
-
|
|
44
|
+
export function resolveServices(
|
|
45
|
+
root: string,
|
|
46
|
+
env: Env,
|
|
47
|
+
realtime: RealtimeConfig = REALTIME_DEFAULTS,
|
|
48
|
+
): DevServices {
|
|
49
|
+
// `ULTIMATE_STATE_DIR` relocates the whole of `.x/` — the embedded database, the local disk and
|
|
50
|
+
// the dev lock — for one process tree. It is how an e2e run boots the app on a THROWAWAY database
|
|
51
|
+
// (`e2e-app.ts`) instead of resetting the developer's own, beside a running `x dev`.
|
|
52
|
+
const stateDir = nonEmpty(env['ULTIMATE_STATE_DIR']) ?? join(root, '.x');
|
|
44
53
|
const databaseUrl = nonEmpty(env['DATABASE_URL']);
|
|
45
|
-
const natsUrl = nonEmpty(env['NATS_URL']);
|
|
46
54
|
const s3Endpoint = nonEmpty(env['S3_ENDPOINT']);
|
|
47
|
-
// Created only when something will actually live in it
|
|
48
|
-
//
|
|
49
|
-
// EACCES at boot for a directory
|
|
50
|
-
|
|
55
|
+
// Created only when something will actually live in it: the embedded database and the local disk
|
|
56
|
+
// do, in-process events never touch the disk. A container whose bindings are external runs
|
|
57
|
+
// non-root over a read-only app directory, and a mkdir there is an EACCES at boot for a directory
|
|
58
|
+
// that would have stayed empty — which an unset NATS_URL alone used to trigger.
|
|
59
|
+
if (databaseUrl === undefined || s3Endpoint === undefined) {
|
|
51
60
|
mkdirSync(stateDir, { recursive: true });
|
|
52
61
|
}
|
|
53
62
|
return {
|
|
@@ -62,15 +71,7 @@ export function resolveServices(root: string, env: Env): DevServices {
|
|
|
62
71
|
detail: 'PGlite in this process — set DATABASE_URL to use a real Postgres',
|
|
63
72
|
}
|
|
64
73
|
: { name: 'db', mode: 'external', url: databaseUrl, detail: 'DATABASE_URL' },
|
|
65
|
-
events:
|
|
66
|
-
natsUrl === undefined
|
|
67
|
-
? {
|
|
68
|
-
name: 'events',
|
|
69
|
-
mode: 'embedded',
|
|
70
|
-
url: 'inproc://events',
|
|
71
|
-
detail: 'in-process fanout — set NATS_URL to use NATS',
|
|
72
|
-
}
|
|
73
|
-
: { name: 'events', mode: 'external', url: natsUrl, detail: 'NATS_URL' },
|
|
74
|
+
events: eventsBinding(env, realtime),
|
|
74
75
|
storage:
|
|
75
76
|
s3Endpoint === undefined
|
|
76
77
|
? {
|
|
@@ -88,7 +89,7 @@ export function resolveServices(root: string, env: Env): DevServices {
|
|
|
88
89
|
* `x dev --json` emitted `DATABASE_URL` and `NATS_URL` verbatim — passwords included — into a
|
|
89
90
|
* field that is printed to a terminal, piped into a log and scraped by a script, while the rule
|
|
90
91
|
* against it was already written three lines from the emitting code and applied only to mail and
|
|
91
|
-
* cdn. The bindings keep the real url because `
|
|
92
|
+
* cdn. The bindings keep the real url because `runtime-queue.ts` has to connect with it; only this
|
|
92
93
|
* projection is redacted, so a leak cannot come back as a caller forgetting to call a helper.
|
|
93
94
|
*/
|
|
94
95
|
export const reportedUrls = (services: DevServices): Record<ServiceBinding['name'], string> => ({
|
|
@@ -101,3 +102,28 @@ export const describeServices = (services: DevServices): string =>
|
|
|
101
102
|
[services.db, services.events, services.storage]
|
|
102
103
|
.map((binding) => `${binding.name}=${binding.mode}`)
|
|
103
104
|
.join(' ');
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The bus as `realtime.transport` chose it, and the variable `realtime.urlEnv` names — the pair
|
|
108
|
+
* `@ultimat3/realtime`'s `selectTransport` obeys. It read `NATS_URL` literally, so an app naming
|
|
109
|
+
* its own variable reported `events=embedded` over a boot that had dialled NATS.
|
|
110
|
+
*/
|
|
111
|
+
export function eventsBinding(env: Env, realtime: RealtimeConfig): ServiceBinding {
|
|
112
|
+
const name = realtime.urlEnv;
|
|
113
|
+
const url = realtime.transport === 'nats' && name !== undefined ? nonEmpty(env[name]) : undefined;
|
|
114
|
+
return url === undefined
|
|
115
|
+
? {
|
|
116
|
+
name: 'events',
|
|
117
|
+
mode: 'embedded',
|
|
118
|
+
url: 'inproc://events',
|
|
119
|
+
detail: "in-process fanout — set realtime: { transport: 'nats', urlEnv } to use NATS",
|
|
120
|
+
}
|
|
121
|
+
: { name: 'events', mode: 'external', url, detail: name ?? 'NATS_URL' };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** `services` with the events binding the booted runtime actually chose (`RunningServices.realtime`). */
|
|
125
|
+
export const withRealtimeEvents = (
|
|
126
|
+
services: DevServices,
|
|
127
|
+
env: Env,
|
|
128
|
+
realtime: RealtimeConfig,
|
|
129
|
+
): DevServices => ({ ...services, events: eventsBinding(env, realtime) });
|
|
@@ -24,7 +24,7 @@ import { CACHE_TIERS, defineConfig, logger, renderThrowable } from '@ultimat3/co
|
|
|
24
24
|
import type { Transport, TransportSubscription } from '@ultimat3/realtime/server';
|
|
25
25
|
import { APP_CONFIG_EXPORT } from './app-auth';
|
|
26
26
|
import { APP_CONFIG_FILE } from './app-root';
|
|
27
|
-
import type { Env } from './
|
|
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) {
|