@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,87 @@
|
|
|
1
|
+
// `jobs.queues`, `jobs.concurrency` and `jobs.visibilityTimeoutMs`, read out of the app's own
|
|
2
|
+
// `app.config.ts` for the `worker` role — the sibling of `runtime-realtime.ts`, and structural
|
|
3
|
+
// for its reason. Until 22.0.0 `createWorker` was called with none of them: every worker served
|
|
4
|
+
// `default` at its own concurrency whatever the config said, so a `mail` queue ran nothing.
|
|
5
|
+
|
|
6
|
+
// why: Bun ships no path-joining API — `Object.keys(Bun)` has `file`, `write`, `Glob`,
|
|
7
|
+
// `pathToFileURL` and `fileURLToPath`, and nothing that joins a path.
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { registeredJobs, type WorkerOptions } from '@ultimat3/jobs';
|
|
10
|
+
import { APP_CONFIG_EXPORT } from './app-auth';
|
|
11
|
+
import { APP_CONFIG_FILE } from './app-root';
|
|
12
|
+
|
|
13
|
+
/** What the worker takes from the config. An absent number keeps the worker's own default. */
|
|
14
|
+
export interface WorkerConfig {
|
|
15
|
+
readonly queues: readonly string[];
|
|
16
|
+
readonly concurrency: number | undefined;
|
|
17
|
+
readonly visibilityTimeoutMs: number | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const NO_WORKER_CONFIG: WorkerConfig = Object.freeze({
|
|
21
|
+
queues: [],
|
|
22
|
+
concurrency: undefined,
|
|
23
|
+
visibilityTimeoutMs: undefined,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
27
|
+
typeof value === 'object' && value !== null;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A number is handed on AS WRITTEN, never screened here: `createWorker`'s `resolveWorkerTimings`
|
|
31
|
+
* is the one refusal for a non-finite knob, and core's `defineConfig` screens both counts first.
|
|
32
|
+
*/
|
|
33
|
+
const numberOf = (value: unknown): number | undefined =>
|
|
34
|
+
typeof value === 'number' ? value : undefined;
|
|
35
|
+
|
|
36
|
+
export async function loadWorkerConfig(root: string): Promise<WorkerConfig> {
|
|
37
|
+
const path = join(root, APP_CONFIG_FILE);
|
|
38
|
+
if (!(await Bun.file(path).exists())) return NO_WORKER_CONFIG;
|
|
39
|
+
const module = (await import(path)) as Record<string, unknown>;
|
|
40
|
+
const config = module[APP_CONFIG_EXPORT];
|
|
41
|
+
const jobs = isRecord(config) ? config['jobs'] : undefined;
|
|
42
|
+
if (!isRecord(jobs)) return NO_WORKER_CONFIG;
|
|
43
|
+
const queues = Array.isArray(jobs['queues'])
|
|
44
|
+
? jobs['queues'].filter((queue): queue is string => typeof queue === 'string' && queue !== '')
|
|
45
|
+
: [];
|
|
46
|
+
return {
|
|
47
|
+
queues,
|
|
48
|
+
concurrency: numberOf(jobs['concurrency']),
|
|
49
|
+
visibilityTimeoutMs: numberOf(jobs['visibilityTimeoutMs']),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The queues a worker serves: the configured ones, then every queue a registered job is enqueued
|
|
55
|
+
* on. The union and not the list, decided 2026-09-23: `x new` and the deployed demo configure
|
|
56
|
+
* `['<app>-default']` while a `job()` naming no queue lands on `default`, so obeying the list alone
|
|
57
|
+
* would leave every such job queued for ever — the silent direction. `undefined` when both are
|
|
58
|
+
* empty, which is `createWorker`'s own `['default']`.
|
|
59
|
+
*/
|
|
60
|
+
export function workerQueuesFor(
|
|
61
|
+
configured: readonly string[],
|
|
62
|
+
registered: readonly string[],
|
|
63
|
+
): readonly string[] | undefined {
|
|
64
|
+
const served = [...new Set([...configured, ...registered])];
|
|
65
|
+
return served.length === 0 ? undefined : served;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type WorkerConfigOptions = Pick<WorkerOptions, 'queues' | 'concurrency' | 'visibilityTimeoutMs'>;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The `createWorker` options the config supplies, read at START so every job the app registered
|
|
72
|
+
* on import is counted. A key the config leaves unset is omitted, never passed as `undefined`, so
|
|
73
|
+
* the worker's own default applies (`exactOptionalPropertyTypes`).
|
|
74
|
+
*/
|
|
75
|
+
export function workerOptionsFor(config: WorkerConfig | undefined): WorkerConfigOptions {
|
|
76
|
+
const queues = workerQueuesFor(
|
|
77
|
+
config?.queues ?? [],
|
|
78
|
+
registeredJobs().map((handle) => handle.queue),
|
|
79
|
+
);
|
|
80
|
+
return {
|
|
81
|
+
...(queues === undefined ? {} : { queues }),
|
|
82
|
+
...(config?.concurrency === undefined ? {} : { concurrency: config.concurrency }),
|
|
83
|
+
...(config?.visibilityTimeoutMs === undefined
|
|
84
|
+
? {}
|
|
85
|
+
: { visibilityTimeoutMs: config.visibilityTimeoutMs }),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -4,17 +4,22 @@
|
|
|
4
4
|
// subscription under `x dev` took its snapshot and then heard nothing, so every `--live` query in
|
|
5
5
|
// every scaffolded app was dead in development — which is where an author first tries one.
|
|
6
6
|
//
|
|
7
|
-
// The bridge is `@ultimat3/
|
|
7
|
+
// The bridge is `@ultimat3/realtime/server`'s `startLiveReplicator`, the same in-process row observer the
|
|
8
8
|
// framework's own live tests run on: a repository write in THIS process becomes a `ChangeEvent`
|
|
9
9
|
// shaped exactly as the WAL decoder shapes one, fanned into the node's registry. Its honest bound
|
|
10
10
|
// is stated there and repeated here — a write another process makes is invisible — and `x dev` is
|
|
11
11
|
// the one boot where that bound holds by construction: every role runs in this one process.
|
|
12
12
|
|
|
13
13
|
import { logger, renderThrowable } from '@ultimat3/core';
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
import
|
|
17
|
-
|
|
14
|
+
// `@ultimat3/realtime/server`, never `@ultimat3/testing`: this module is on the production boot
|
|
15
|
+
// path (`serve.ts` → `role-start.ts` → here), and the test harness was 38 modules of every image.
|
|
16
|
+
import {
|
|
17
|
+
type LiveReplicator,
|
|
18
|
+
RealtimeTopologyError,
|
|
19
|
+
startLiveReplicator,
|
|
20
|
+
} from '@ultimat3/realtime/server';
|
|
21
|
+
import type { RunningSync } from './role-sync';
|
|
22
|
+
import type { ServiceMode } from './runtime-bindings';
|
|
18
23
|
|
|
19
24
|
/** What feeds the sync node, said out loud on the boot line and in `--json`. */
|
|
20
25
|
export type LiveFeed = 'in-process' | 'replication' | 'none';
|
|
@@ -31,6 +36,10 @@ export interface LiveFeedInput {
|
|
|
31
36
|
readonly sync: RunningSync | null;
|
|
32
37
|
/** The database's binding: `embedded` is PGlite, which has no log to decode. */
|
|
33
38
|
readonly dbMode: ServiceMode;
|
|
39
|
+
/** The bus this process fans out on (`Transport.name`): `in-process` reaches no other process. */
|
|
40
|
+
readonly transport: string;
|
|
41
|
+
/** Whether the `replicator` role runs in THIS process. */
|
|
42
|
+
readonly replicatorHere: boolean;
|
|
34
43
|
}
|
|
35
44
|
|
|
36
45
|
/** The label the `x dev` boot line carries beside `db=`, `events=` and `storage=`. */
|
|
@@ -45,10 +54,16 @@ export const liveFeedLabel = (feed: LiveFeed): string => `live=${feed}`;
|
|
|
45
54
|
export async function startLiveFeed(input: LiveFeedInput): Promise<RunningLiveFeed> {
|
|
46
55
|
if (input.sync === null) return { feed: 'none', bridge: null, stop: () => undefined };
|
|
47
56
|
if (input.dbMode !== 'embedded') {
|
|
57
|
+
// A decoder this node can hear: in this process, or on a bus other processes share. Neither
|
|
58
|
+
// is a sync node that reported `replication` while nothing could ever reach it.
|
|
59
|
+
if (input.transport === 'in-process' && !input.replicatorHere)
|
|
60
|
+
throw new RealtimeTopologyError();
|
|
48
61
|
return { feed: 'replication', bridge: null, stop: () => undefined };
|
|
49
62
|
}
|
|
50
63
|
const bridge = await startLiveReplicator({
|
|
51
64
|
registry: input.sync.registry,
|
|
65
|
+
// The same changes the channels read — a real node's change subscription feeds both.
|
|
66
|
+
channels: input.sync.hub,
|
|
52
67
|
// Logged, never thrown: one change nobody could fan out must not take the dev server down.
|
|
53
68
|
onError: (error) =>
|
|
54
69
|
logger.warn('live.bridge_delivery_failed', { error: renderThrowable(error) }),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// `notify.inboxReadRetentionMs` and `notify.inboxUnreadRetentionMs`, read out of the app's own
|
|
2
|
-
// `app.config.ts`. The sibling of `app-auth.ts`'s `loadSignInPath` and `
|
|
2
|
+
// `app.config.ts`. The sibling of `app-auth.ts`'s `loadSignInPath` and `runtime-cache.ts`'s
|
|
3
3
|
// `loadCacheTiers`, and structural for the same reason: `defineConfig` returns a plain object, so
|
|
4
4
|
// a config that resolved through an older core simply has no `notify` section.
|
|
5
5
|
//
|
|
@@ -23,8 +23,8 @@ import type { PostgresRateLimitStore } from '@ultimat3/http';
|
|
|
23
23
|
import type { JobHandle, PurgeInput, PurgeTarget } from '@ultimat3/jobs';
|
|
24
24
|
import { DEFAULT_PURGE_CRON, getJob, getTask, purge, task } from '@ultimat3/jobs';
|
|
25
25
|
import { purgeNotifyDeliveries, purgeNotifyInbox } from '@ultimat3/notify';
|
|
26
|
-
import type { InboxRetention } from './
|
|
27
|
-
import { NO_INBOX_RETENTION } from './
|
|
26
|
+
import type { InboxRetention } from './runtime-notify-retention';
|
|
27
|
+
import { NO_INBOX_RETENTION } from './runtime-notify-retention';
|
|
28
28
|
|
|
29
29
|
/** The durable queue key. Pinned, like every framework-owned job name — rows carry it. */
|
|
30
30
|
export const PURGE_JOB_NAME = 'x.purge';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The database and the job queue, started together and released together. Split from
|
|
2
|
-
// `
|
|
2
|
+
// `runtime-services.ts` because `x jobs` needs exactly this pair and nothing else — and because a
|
|
3
3
|
// process that installs ambient accessors (`db()`, `jobDriver()`, the jobs facade, the event bus)
|
|
4
4
|
// must have one place that takes them all back, or the next command in the same process inherits
|
|
5
5
|
// a driver over a closed socket.
|
|
@@ -32,10 +32,10 @@ import {
|
|
|
32
32
|
setJobDriver,
|
|
33
33
|
setJobsFacade,
|
|
34
34
|
} from '@ultimat3/jobs';
|
|
35
|
-
import { attachReplica, type ReplicaEnv, replicaUrlFor } from './dev-replica';
|
|
36
|
-
import type { DevServices } from './dev-services';
|
|
37
35
|
import { applyFrameworkSchema } from './framework-schema';
|
|
36
|
+
import type { DevServices } from './runtime-bindings';
|
|
38
37
|
import type { RuntimeOverrides } from './runtime-overrides';
|
|
38
|
+
import { attachReplica, type ReplicaEnv, replicaUrlFor } from './runtime-replica';
|
|
39
39
|
|
|
40
40
|
/** Both embedded and external clients boot lazily and close explicitly. */
|
|
41
41
|
export type DevDbClient = PgliteClient | PostgresClient;
|
|
@@ -51,7 +51,7 @@ export interface RunningQueue {
|
|
|
51
51
|
readonly jobs: JobDriver;
|
|
52
52
|
/**
|
|
53
53
|
* The `x_outbox` store this boot installed behind `handle.enqueue()`. Returned because the
|
|
54
|
-
* relay that drains it is a ROLE's decision, not the queue's — `
|
|
54
|
+
* relay that drains it is a ROLE's decision, not the queue's — `role-start.ts` starts one.
|
|
55
55
|
*/
|
|
56
56
|
readonly outbox: OutboxStore;
|
|
57
57
|
/** The `x_job_events` bus a `step.waitForEvent` resumes from. Durable, not per-process. */
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// `realtime.enabled`, `realtime.transport` and `realtime.urlEnv`, read out of the app's own
|
|
2
|
+
// `app.config.ts` for the boot. The sibling of `runtime-notify-retention.ts` and `runtime-cache.ts`, and
|
|
3
|
+
// structural for their reason: `startServices` holds no `AppConfig`, and `defineConfig` returns a
|
|
4
|
+
// plain object. Until 22.0.0 no boot read any of the three and `NATS_URL` alone decided the bus.
|
|
5
|
+
|
|
6
|
+
// why: Bun ships no path-joining API — `Object.keys(Bun)` has `file`, `write`, `Glob`,
|
|
7
|
+
// `pathToFileURL` and `fileURLToPath`, and nothing that joins a path.
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { ConfigInvalidError, type RealtimeConfig, type RealtimeTransport } from '@ultimat3/core';
|
|
10
|
+
import { APP_CONFIG_EXPORT } from './app-auth';
|
|
11
|
+
import { APP_CONFIG_FILE } from './app-root';
|
|
12
|
+
|
|
13
|
+
/** Core's `defaults()` for the section, restated as the answer for a config that names none. */
|
|
14
|
+
export const REALTIME_DEFAULTS: RealtimeConfig = Object.freeze({
|
|
15
|
+
enabled: true,
|
|
16
|
+
transport: 'memory',
|
|
17
|
+
urlEnv: undefined,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const TRANSPORTS: readonly RealtimeTransport[] = ['memory', 'nats'];
|
|
21
|
+
|
|
22
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
23
|
+
typeof value === 'object' && value !== null;
|
|
24
|
+
|
|
25
|
+
/** A transport this boot can build, or a refusal — never a guess at a bus. */
|
|
26
|
+
function transportOf(section: Record<string, unknown>): RealtimeTransport {
|
|
27
|
+
const value = section['transport'];
|
|
28
|
+
if (value === undefined) return REALTIME_DEFAULTS.transport;
|
|
29
|
+
const known = TRANSPORTS.find((transport) => transport === value);
|
|
30
|
+
if (known !== undefined) return known;
|
|
31
|
+
throw new ConfigInvalidError({
|
|
32
|
+
cause: `realtime.transport in ${APP_CONFIG_FILE} is not one of ${TRANSPORTS.join(', ')}`,
|
|
33
|
+
fix: `set realtime: { transport: 'memory' } in ${APP_CONFIG_FILE}, or { transport: 'nats', urlEnv: 'NATS_URL' } for more than one node`,
|
|
34
|
+
meta: { key: 'realtime.transport' },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The section as the boot obeys it. A key the file does not name keeps core's default, the same
|
|
40
|
+
* per-key merge `defineConfig` performs, so the boot and the app's own config object agree.
|
|
41
|
+
*/
|
|
42
|
+
export async function loadRealtimeConfig(root: string): Promise<RealtimeConfig> {
|
|
43
|
+
const path = join(root, APP_CONFIG_FILE);
|
|
44
|
+
if (!(await Bun.file(path).exists())) return REALTIME_DEFAULTS;
|
|
45
|
+
const module = (await import(path)) as Record<string, unknown>;
|
|
46
|
+
const config = module[APP_CONFIG_EXPORT];
|
|
47
|
+
const section = isRecord(config) ? config['realtime'] : undefined;
|
|
48
|
+
if (!isRecord(section)) return REALTIME_DEFAULTS;
|
|
49
|
+
const { enabled, urlEnv } = section;
|
|
50
|
+
return {
|
|
51
|
+
enabled: typeof enabled === 'boolean' ? enabled : REALTIME_DEFAULTS.enabled,
|
|
52
|
+
transport: transportOf(section),
|
|
53
|
+
urlEnv: typeof urlEnv === 'string' && urlEnv.trim() !== '' ? urlEnv.trim() : undefined,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -14,15 +14,32 @@
|
|
|
14
14
|
// navigation, with a re-parse on top. The static export writes the file (`writeStyles`), so the
|
|
15
15
|
// "second file" cost is one `Bun.write`.
|
|
16
16
|
|
|
17
|
+
// why: Bun ships no path API; an island's file is its route file's directory joined to its `src`.
|
|
18
|
+
import { posix } from 'node:path';
|
|
19
|
+
import { clientScopeOf } from '@ultimat3/auth';
|
|
17
20
|
import type { Ctx } from '@ultimat3/core';
|
|
21
|
+
import { CLIENT_SCOPE_HEADER } from '@ultimat3/core';
|
|
18
22
|
import type { RouteMeta as HttpRouteMeta, Route, RouteParams } from '@ultimat3/http';
|
|
19
23
|
import { asCtx, html, stream } from '@ultimat3/http';
|
|
20
24
|
import { currentLocale } from '@ultimat3/i18n';
|
|
21
|
-
import type {
|
|
25
|
+
import type {
|
|
26
|
+
ClientSyncHead,
|
|
27
|
+
IslandCollector,
|
|
28
|
+
RenderResult,
|
|
29
|
+
RouteData,
|
|
30
|
+
RouteEntry,
|
|
31
|
+
} from '@ultimat3/render';
|
|
22
32
|
import {
|
|
33
|
+
clientBootTags,
|
|
34
|
+
clientPersistTags,
|
|
35
|
+
clientScopeTag,
|
|
36
|
+
clientSyncTags,
|
|
23
37
|
createIslandCollector,
|
|
38
|
+
documentCarriesScope,
|
|
24
39
|
headFromMeta,
|
|
25
40
|
hydrateRuntime,
|
|
41
|
+
islandModuleId,
|
|
42
|
+
islandModuleIds,
|
|
26
43
|
metaContextFor,
|
|
27
44
|
renderHead,
|
|
28
45
|
routeDataFor,
|
|
@@ -39,9 +56,11 @@ import {
|
|
|
39
56
|
ROOT_ELEMENT_ID,
|
|
40
57
|
renderComponent,
|
|
41
58
|
renderSsr,
|
|
59
|
+
ssrHeaders,
|
|
42
60
|
staticHeaders,
|
|
43
61
|
streamResult,
|
|
44
62
|
} from '@ultimat3/render/server';
|
|
63
|
+
import { realtimeIslandFiles } from './island-realtime';
|
|
45
64
|
import { styleBundle } from './style-bundle';
|
|
46
65
|
|
|
47
66
|
/**
|
|
@@ -71,6 +90,18 @@ export interface DocumentOptions {
|
|
|
71
90
|
* decided by `app.config.ts`, which the boot read and the renderer cannot.
|
|
72
91
|
*/
|
|
73
92
|
readonly themeHead?: string;
|
|
93
|
+
/**
|
|
94
|
+
* The page's sync target — `pageSync(…).head` — rendered as render's `clientSyncTags` on every
|
|
95
|
+
* document this process serves. Principal-free, so a shareable document carries it too; absent
|
|
96
|
+
* for a caller that serves no socket at all (the static export).
|
|
97
|
+
*/
|
|
98
|
+
readonly sync?: ClientSyncHead;
|
|
99
|
+
/**
|
|
100
|
+
* The record types the app persists (`entity(…, { persist: true })`), read per render. Rendered
|
|
101
|
+
* as `ultimate-persist` beside the scope tag only — persistence is per principal, so a document
|
|
102
|
+
* with no scope carries none.
|
|
103
|
+
*/
|
|
104
|
+
readonly persisted?: () => readonly string[];
|
|
74
105
|
}
|
|
75
106
|
|
|
76
107
|
export interface DevRenderOptions extends DocumentOptions {
|
|
@@ -94,16 +125,32 @@ export interface DevRouteData extends Record<string, unknown> {
|
|
|
94
125
|
*/
|
|
95
126
|
const lang = (): string => currentLocale();
|
|
96
127
|
|
|
128
|
+
/**
|
|
129
|
+
* `scope` is present only on a PRIVATE document (a gated `ssr` page, every `stream`): the page's
|
|
130
|
+
* client scope (`@ultimat3/auth`'s `clientScopeOf`), which core's `pageClient()` reads to fence its
|
|
131
|
+
* one store per principal. A shareable document (`static`, `isr`, ungated `ssr`) carries NO scope
|
|
132
|
+
* tag — absent means "not rendered for anyone", a different answer from `''`, the anonymous page.
|
|
133
|
+
*/
|
|
97
134
|
const headFor = async (
|
|
98
135
|
entry: RouteEntry,
|
|
99
136
|
ctx: DevRouteData,
|
|
100
137
|
data: RouteData,
|
|
101
138
|
options: DocumentOptions,
|
|
139
|
+
scope?: string,
|
|
102
140
|
): Promise<string> =>
|
|
103
141
|
renderHead(
|
|
104
142
|
headFromMeta(
|
|
105
143
|
await entry.config.meta(metaContextFor(ctx, data)),
|
|
106
144
|
seoRenderers({ path: new URL(ctx.url).pathname }),
|
|
145
|
+
[
|
|
146
|
+
...(options.sync === undefined ? [] : clientSyncTags(options.sync)),
|
|
147
|
+
// The page boot rides the scope tag: its whole job — restoring a principal's persisted
|
|
148
|
+
// records and replaying its queued writes — is per principal, and a shareable document
|
|
149
|
+
// (no scope tag) has neither. Cheaper than walking the page's islands, and exact.
|
|
150
|
+
...(scope === undefined
|
|
151
|
+
? []
|
|
152
|
+
: [clientScopeTag(scope), ...clientPersistTags(options.persisted?.() ?? [])]),
|
|
153
|
+
],
|
|
107
154
|
),
|
|
108
155
|
) +
|
|
109
156
|
(options.themeHead ?? '') +
|
|
@@ -166,6 +213,33 @@ export async function routeBody(
|
|
|
166
213
|
* an island never declares its own timing, and `resolve` is the build's — identity when nothing
|
|
167
214
|
* built any, which fails at the first island by name rather than emitting an unusable entry.
|
|
168
215
|
*/
|
|
216
|
+
/**
|
|
217
|
+
* Realtime's page boot, as one deferred script — or nothing. Two conditions, both exact: the
|
|
218
|
+
* document carries a principal scope (restoring persisted records and replaying queued writes are
|
|
219
|
+
* per principal; a shareable document has neither), AND one of the islands this render emitted
|
|
220
|
+
* reaches `@ultimat3/realtime` (a page whose islands never touch a record has nothing to restore
|
|
221
|
+
* into and no write to replay). After the body, because which islands rendered is a fact the walk
|
|
222
|
+
* just recorded; still before the hydration runtime, so it runs first among the deferred scripts.
|
|
223
|
+
*/
|
|
224
|
+
function bootScript(
|
|
225
|
+
entry: RouteEntry,
|
|
226
|
+
islands: IslandCollector,
|
|
227
|
+
options: DocumentOptions,
|
|
228
|
+
scope: string | undefined,
|
|
229
|
+
): string {
|
|
230
|
+
if (scope === undefined || options.sync === undefined) return '';
|
|
231
|
+
const rendered = new Set(islandModuleIds(islands.directives));
|
|
232
|
+
if (rendered.size === 0) return '';
|
|
233
|
+
// An island's module id is derived from its `src`, written relative to the page that renders it:
|
|
234
|
+
// each realtime island file, spelled from THIS page, is the id its directive would carry.
|
|
235
|
+
const pageDir = posix.dirname(entry.file);
|
|
236
|
+
const reaches = [...realtimeIslandFiles()].some((file) => {
|
|
237
|
+
const src = posix.relative(pageDir, file);
|
|
238
|
+
return rendered.has(islandModuleId(src.startsWith('.') ? src : `./${src}`));
|
|
239
|
+
});
|
|
240
|
+
return reaches ? renderHead(clientBootTags(options.sync)) : '';
|
|
241
|
+
}
|
|
242
|
+
|
|
169
243
|
const collectorFor = (entry: RouteEntry, options: DocumentOptions): IslandCollector =>
|
|
170
244
|
createIslandCollector({
|
|
171
245
|
file: entry.file,
|
|
@@ -199,15 +273,16 @@ async function documentFrom(
|
|
|
199
273
|
ctx: DevRouteData,
|
|
200
274
|
data: RouteData,
|
|
201
275
|
options: DocumentOptions,
|
|
276
|
+
scope?: string,
|
|
202
277
|
): Promise<string> {
|
|
203
278
|
const islands = collectorFor(entry, options);
|
|
204
279
|
const [head, body] = await Promise.all([
|
|
205
|
-
headFor(entry, ctx, data, options),
|
|
280
|
+
headFor(entry, ctx, data, options, scope),
|
|
206
281
|
routeBody(entry, ctx, data, islands),
|
|
207
282
|
]);
|
|
208
283
|
return (
|
|
209
284
|
`<!doctype html><html lang="${lang()}"><head>${head}${styleTag(entry)}</head>` +
|
|
210
|
-
`<body>${body}${hydrateRuntime(islands.directives)}</body></html>`
|
|
285
|
+
`<body>${body}${bootScript(entry, islands, options, scope)}${hydrateRuntime(islands.directives)}</body></html>`
|
|
211
286
|
);
|
|
212
287
|
}
|
|
213
288
|
|
|
@@ -256,31 +331,55 @@ async function resultFor(
|
|
|
256
331
|
// correct output, no streaming benefit.
|
|
257
332
|
const islands = collectorFor(entry, options);
|
|
258
333
|
const [head, shell] = await Promise.all([
|
|
259
|
-
|
|
334
|
+
// A stream is always `private, no-store` (`streamResult`), so it always carries the scope.
|
|
335
|
+
headFor(entry, request, data, options, clientScopeOf(ctx.actor)),
|
|
260
336
|
routeBody(entry, request, data, islands),
|
|
261
337
|
]);
|
|
262
|
-
return
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
338
|
+
return withScope(
|
|
339
|
+
streamResult(
|
|
340
|
+
{
|
|
341
|
+
head: `<!doctype html><html lang="${lang()}"><head>${head}${styleTag(entry)}</head><body>`,
|
|
342
|
+
// The runtime rides the first flush, with the shell it boots. A later chunk would leave
|
|
343
|
+
// the window between flush one and the close with inert islands and no listeners on
|
|
344
|
+
// them — which is exactly the first-click-lost failure `interaction` replay exists for.
|
|
345
|
+
shell: `${shell}${bootScript(entry, islands, options, clientScopeOf(ctx.actor))}${hydrateRuntime(islands.directives)}`,
|
|
346
|
+
holes: [],
|
|
347
|
+
},
|
|
348
|
+
{ buildId: options.buildId },
|
|
349
|
+
status,
|
|
350
|
+
),
|
|
351
|
+
clientScopeOf(ctx.actor),
|
|
273
352
|
);
|
|
274
353
|
}
|
|
275
|
-
default:
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
354
|
+
default: {
|
|
355
|
+
// Asked of the headers `renderSsr` is about to send: a gated page is private and carries the
|
|
356
|
+
// scope; an ungated one is `public, s-maxage` and a CDN may hand it to anyone, so it carries
|
|
357
|
+
// none — absent, which core reads as "not rendered for anyone", never as anonymous.
|
|
358
|
+
const scope = documentCarriesScope(ssrHeaders(entry, { buildId: options.buildId }))
|
|
359
|
+
? clientScopeOf(ctx.actor)
|
|
360
|
+
: undefined;
|
|
361
|
+
return withScope(
|
|
362
|
+
await renderSsr(
|
|
363
|
+
{ entry, params: request.params, url, ctx },
|
|
364
|
+
() => documentFrom(entry, request, data, options, scope),
|
|
365
|
+
{ buildId: options.buildId, status },
|
|
366
|
+
),
|
|
367
|
+
scope,
|
|
280
368
|
);
|
|
369
|
+
}
|
|
281
370
|
}
|
|
282
371
|
}
|
|
283
372
|
|
|
373
|
+
/**
|
|
374
|
+
* A private document's scope, as a RESPONSE header too: the service worker partitions its offline
|
|
375
|
+
* pages by principal and never parses HTML, so the meta alone cannot reach it. Exactly the
|
|
376
|
+
* documents that carry the scope tag carry this — a shareable one carries neither.
|
|
377
|
+
*/
|
|
378
|
+
const withScope = (result: RenderResult, scope: string | undefined): RenderResult =>
|
|
379
|
+
scope === undefined
|
|
380
|
+
? result
|
|
381
|
+
: { ...result, headers: { ...result.headers, [CLIENT_SCOPE_HEADER]: scope } };
|
|
382
|
+
|
|
284
383
|
const responseOf = (result: RenderResult): Response =>
|
|
285
384
|
typeof result.body === 'string'
|
|
286
385
|
? html(result.body, { status: result.status, headers: result.headers })
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
//
|
|
7
7
|
// 1. `defaultClient()` is the one place db composes `replicatedClient(primary, replica)` from
|
|
8
8
|
// `DATABASE_REPLICA_URL`, and it runs only from `baseClient()` — "the client an app installed
|
|
9
|
-
// none for". Every process the framework boots installs one: `
|
|
9
|
+
// none for". Every process the framework boots installs one: `runtime-queue.ts` calls
|
|
10
10
|
// `setDbClient(createPgliteClient(…) | createPostgresClient({ url }))`, so `defaultClient()` was
|
|
11
11
|
// unreachable from `x dev`, from `apps/web/server.ts` and from every container role.
|
|
12
12
|
// 2. Routing needs an OPEN scope as well as a configured replica, and nothing opened one.
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
withReplicaReads,
|
|
25
25
|
} from '@ultimat3/db';
|
|
26
26
|
import type { Middleware } from '@ultimat3/http';
|
|
27
|
-
import type { ServiceBinding } from './
|
|
27
|
+
import type { ServiceBinding } from './runtime-bindings';
|
|
28
28
|
import type { RuntimeOverrides } from './runtime-overrides';
|
|
29
29
|
|
|
30
30
|
export type ReplicaEnv = Readonly<Record<string, string | undefined>>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Starting the services `
|
|
1
|
+
// Starting the services `runtime-bindings.ts` resolved. Resolution answers "which database"; this
|
|
2
2
|
// answers "it is running, and every ambient accessor in the framework now points at it" — so
|
|
3
3
|
// `db()`, `jobDriver()`, `mailDriver()` and the realtime transport are the objects a production
|
|
4
4
|
// boot installs, only backed by embedded drivers.
|
|
@@ -10,6 +10,7 @@ import type { PurgeDriver } from '@ultimat3/cache';
|
|
|
10
10
|
import { isNoopPurgeDriver, selectPurgeDriver } from '@ultimat3/cache';
|
|
11
11
|
import {
|
|
12
12
|
isLocal,
|
|
13
|
+
type RealtimeConfig,
|
|
13
14
|
registerReadinessCheck,
|
|
14
15
|
renderThrowable,
|
|
15
16
|
resolveEnvironment,
|
|
@@ -30,15 +31,17 @@ import type { Transport, TransportSelection } from '@ultimat3/realtime/server';
|
|
|
30
31
|
import { selectTransport } from '@ultimat3/realtime/server';
|
|
31
32
|
import type { Storage } from '@ultimat3/storage';
|
|
32
33
|
import { defineStorage, localDriver, s3Driver, usesDevStorageSecret } from '@ultimat3/storage';
|
|
33
|
-
import { loadCacheTiers, startCacheTiers } from './dev-cache';
|
|
34
|
-
import { loadInboxRetention } from './dev-notify-retention';
|
|
35
|
-
import { installRetentionSweep } from './dev-purge';
|
|
36
|
-
import type { DevDbClient } from './dev-queue';
|
|
37
|
-
import { pgExecutorFor, startQueue } from './dev-queue';
|
|
38
|
-
import type { DevServices, Env } from './dev-services';
|
|
39
34
|
import { LocalDiskUnsafeError, StorageUnwritableError } from './errors';
|
|
40
35
|
import { msg } from './messages';
|
|
36
|
+
import type { DevServices, Env } from './runtime-bindings';
|
|
37
|
+
import { loadCacheTiers, startCacheTiers } from './runtime-cache';
|
|
38
|
+
import { loadWorkerConfig, type WorkerConfig } from './runtime-jobs';
|
|
39
|
+
import { loadInboxRetention } from './runtime-notify-retention';
|
|
41
40
|
import type { RuntimeOverrides } from './runtime-overrides';
|
|
41
|
+
import { installRetentionSweep } from './runtime-purge';
|
|
42
|
+
import type { DevDbClient } from './runtime-queue';
|
|
43
|
+
import { pgExecutorFor, startQueue } from './runtime-queue';
|
|
44
|
+
import { loadRealtimeConfig } from './runtime-realtime';
|
|
42
45
|
|
|
43
46
|
export interface RunningServices {
|
|
44
47
|
readonly services: DevServices;
|
|
@@ -48,6 +51,17 @@ export interface RunningServices {
|
|
|
48
51
|
readonly outbox: OutboxStore;
|
|
49
52
|
readonly events: EventBus;
|
|
50
53
|
readonly transport: Transport;
|
|
54
|
+
/**
|
|
55
|
+
* `app.config.ts`'s `realtime` section as this boot obeyed it. `transport` and `urlEnv` chose
|
|
56
|
+
* `transport` above; `enabled` is what `startRoles` reads before it starts `sync` or a replicator.
|
|
57
|
+
*/
|
|
58
|
+
readonly realtime: RealtimeConfig;
|
|
59
|
+
/**
|
|
60
|
+
* `app.config.ts`'s `jobs.queues` / `concurrency` / `visibilityTimeoutMs`, for the `worker` role.
|
|
61
|
+
* Optional because a `RunningServices` can be hand-built, and a test runtime with none keeps the
|
|
62
|
+
* worker's own defaults — the answer it always got. `startServices` always sets it.
|
|
63
|
+
*/
|
|
64
|
+
readonly workerConfig?: WorkerConfig;
|
|
51
65
|
readonly storage: Storage;
|
|
52
66
|
readonly mail: MailDriver;
|
|
53
67
|
/**
|
|
@@ -193,8 +207,13 @@ export function startStorage(services: DevServices, env: Env, override?: Storage
|
|
|
193
207
|
// `usesDevStorageSecret({ env })` left bare, refuse a boot whose own env carries a real
|
|
194
208
|
// `STORAGE_SIGNING_SECRET` because the PROCESS does not. Which environment, whether a secret
|
|
195
209
|
// exists, and the name the message prints are one question about one table.
|
|
196
|
-
|
|
197
|
-
|
|
210
|
+
// `fallback: 'production'` on both, the reading `localDriver` itself takes: a boot that names no
|
|
211
|
+
// environment is not a development one, and this message beats the driver's to it.
|
|
212
|
+
if (!isLocal({ env, fallback: 'production' }) && usesDevStorageSecret({ env })) {
|
|
213
|
+
throw new LocalDiskUnsafeError({
|
|
214
|
+
environment: resolveEnvironment({ env, fallback: 'production' }),
|
|
215
|
+
root,
|
|
216
|
+
});
|
|
198
217
|
}
|
|
199
218
|
try {
|
|
200
219
|
mkdirSync(root, { recursive: true });
|
|
@@ -293,12 +312,15 @@ export async function startServices(
|
|
|
293
312
|
// Same reason, same place: building a purge driver reads env and dials nothing, so a half-set
|
|
294
313
|
// `FASTLY_API_TOKEN` without its service id fails here rather than on the first stale page.
|
|
295
314
|
const cdn = selectPurgeDriver(env);
|
|
296
|
-
// Third of the same kind. `
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
// `@ultimat3/realtime`'s decision, and it is the same call a
|
|
300
|
-
// process cannot resolve the bus differently from the
|
|
301
|
-
|
|
315
|
+
// Third of the same kind. `realtime.transport` selects the bus and `realtime.urlEnv` names the
|
|
316
|
+
// variable holding its url; a config and an environment that disagree refuse here, before any
|
|
317
|
+
// service starts, rather than quietly keeping the in-process bus. Which transport, which KV
|
|
318
|
+
// bucket and which presence TTL is `@ultimat3/realtime`'s decision, and it is the same call a
|
|
319
|
+
// `ROLE=sync` container makes, so this process cannot resolve the bus differently from the
|
|
320
|
+
// container it stands in for. The root is `dirname(stateDir)`, as `loadCacheTiers` reads it.
|
|
321
|
+
const realtime = await loadRealtimeConfig(dirname(services.stateDir));
|
|
322
|
+
const workerConfig = await loadWorkerConfig(dirname(services.stateDir));
|
|
323
|
+
const bus: TransportSelection = selectTransport(env, realtime);
|
|
302
324
|
// `env`, not the ambient one: this function is HANDED the boot's environment and every other
|
|
303
325
|
// reader here already uses it, so a queue that asked `process.env` would decide the standby from
|
|
304
326
|
// a different answer than the middleware that routes to it.
|
|
@@ -335,7 +357,7 @@ export async function startServices(
|
|
|
335
357
|
// ships a `purgeExpired()` that nothing called, so every row written was a row kept —
|
|
336
358
|
// `x_rate_limit` takes one upsert per request the web role serves, assets included.
|
|
337
359
|
//
|
|
338
|
-
// `inboxRetention` is READ HERE and not defaulted in `
|
|
360
|
+
// `inboxRetention` is READ HERE and not defaulted in `runtime-purge.ts`: the two windows are the
|
|
339
361
|
// app's, `startServices` holds no `AppConfig`, and a loader that silently answered "never
|
|
340
362
|
// sweep" from inside the sweep would be indistinguishable from an app that chose it.
|
|
341
363
|
started.push(
|
|
@@ -347,7 +369,7 @@ export async function startServices(
|
|
|
347
369
|
);
|
|
348
370
|
// One readiness check per resource this boot OWNS, released with it. Nothing in the tree
|
|
349
371
|
// registered one, so `/readyz` was `markReady()` alone — "this process bound a socket" — and
|
|
350
|
-
// `packages/http/src/server.ts` calls that BEFORE `Bun.serve`, while `
|
|
372
|
+
// `packages/http/src/server.ts` calls that BEFORE `Bun.serve`, while `role-start.ts` calls it
|
|
351
373
|
// before `sync`, `worker` and `scheduler` start. The chart's `readinessProbe` and the container
|
|
352
374
|
// healthcheck both route on it, so a replica whose pool was gone kept taking traffic.
|
|
353
375
|
started.push(probedReadinessCheck('database', () => db.ping()));
|
|
@@ -399,6 +421,8 @@ export async function startServices(
|
|
|
399
421
|
outbox,
|
|
400
422
|
events,
|
|
401
423
|
transport,
|
|
424
|
+
realtime,
|
|
425
|
+
workerConfig,
|
|
402
426
|
storage,
|
|
403
427
|
mail,
|
|
404
428
|
mailDetail: selection.detail,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Single responsibility: the one HTTP surface that SERVES a stored object. `@ultimat3/storage`
|
|
2
2
|
// owns keys, bytes and the tenant boundary and owns no `Response`; `@ultimat3/policy` owns the
|
|
3
|
-
// one authz decision; this file is where those two meet a `Route` — the same shape `
|
|
3
|
+
// one authz decision; this file is where those two meet a `Route` — the same shape `runtime-assets.ts`
|
|
4
4
|
// uses for `/icons` and `/media`, so `x dev` and `apps/web/server.ts` mount one read path, not two.
|
|
5
5
|
//
|
|
6
6
|
// The base path is `@ultimat3/storage`'s `DEFAULT_SIGNED_URL_BASE`, imported and never restated:
|
|
@@ -37,7 +37,7 @@ export const STORAGE_READ_PERMISSION = 'storage:read';
|
|
|
37
37
|
* augments `PermissionRegistry` — and this package compiles against no app, so the bare literal is
|
|
38
38
|
* a type error inside a generated project (`scaffold-typecheck` is what proves it). The check that
|
|
39
39
|
* decides is the runtime one anyway: `can()` calls `assertPermission`, which throws
|
|
40
|
-
* `X_PERMISSION_UNKNOWN` naming the `definePermissions` edit. `
|
|
40
|
+
* `X_PERMISSION_UNKNOWN` naming the `definePermissions` edit. `runtime-hooks.ts` narrows a route's
|
|
41
41
|
* structurally-typed permission for the same reason.
|
|
42
42
|
*/
|
|
43
43
|
const READ_PERMISSION = STORAGE_READ_PERMISSION as unknown as KnownPermission;
|
|
@@ -94,7 +94,7 @@ export function authorizeStorageRead(input: StorageReadInput, ctx: RequestContex
|
|
|
94
94
|
* (never 403 — `error-map.ts` maps `X_STORAGE_ORG_MISMATCH` there so a refusal cannot confirm that
|
|
95
95
|
* a key exists).
|
|
96
96
|
*
|
|
97
|
-
* Split out of `readStorageObject` because `/media/*key` (`
|
|
97
|
+
* Split out of `readStorageObject` because `/media/*key` (`runtime-assets.ts`) has to make the same
|
|
98
98
|
* decision and made none at all: it passed a client-supplied key straight to `disk().get`, so every
|
|
99
99
|
* object on the app's only disk was one unauthenticated URL away. A second copy of this test is how
|
|
100
100
|
* one of the two surfaces would drift back — `storage-surfaces.test.ts` is what holds them level.
|
|
@@ -198,7 +198,7 @@ export function storageResponse(request: UltimateRequest, read: StorageRead): Re
|
|
|
198
198
|
headers.set('content-range', `bytes */${size}`);
|
|
199
199
|
return new Response(null, { status: 416, headers });
|
|
200
200
|
}
|
|
201
|
-
// Copied at each call, not through a helper, for `
|
|
201
|
+
// Copied at each call, not through a helper, for `runtime-assets.ts`'s reason: a
|
|
202
202
|
// `Uint8Array<ArrayBufferLike>` may be backed by a `SharedArrayBuffer`, which `Response` does
|
|
203
203
|
// not accept — and a helper's declared return type widens the copy back to the type it refuses.
|
|
204
204
|
if (range === undefined) {
|