@ultimat3/cli 24.0.0 → 25.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 +15 -8
- package/README.md +30 -5
- package/package.json +29 -29
- package/src/admin-catalog-keys.ts +2 -2
- package/src/affected.ts +3 -1
- package/src/app-boundaries.ts +17 -4
- package/src/app-config-load.ts +3 -3
- package/src/app-env.ts +21 -14
- package/src/app-load.ts +38 -14
- package/src/app-manifest.ts +2 -2
- package/src/app-mcp.ts +25 -27
- package/src/app-name.ts +52 -0
- package/src/app-permissions-borrowed.ts +2 -8
- package/src/app-permissions-site.ts +31 -7
- package/src/app-root.ts +9 -5
- package/src/bin.ts +3 -7
- package/src/browser-launcher.ts +4 -2
- package/src/budgets-charged.ts +60 -0
- package/src/budgets-scan.ts +76 -0
- package/src/budgets-unmeasured.ts +91 -0
- package/src/budgets.ts +115 -121
- package/src/cdp-shot-driver.ts +17 -0
- package/src/cdp-shot-watch.ts +35 -31
- package/src/ci-log.ts +0 -0
- package/src/cmd-auth.ts +2 -2
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +89 -9
- package/src/cmd-db-backfill.ts +1 -1
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db.ts +5 -2
- package/src/cmd-deploy-compose.ts +94 -0
- package/src/cmd-deploy-drain.ts +48 -0
- package/src/cmd-deploy-start-first.ts +178 -0
- package/src/cmd-deploy.ts +158 -36
- package/src/cmd-dev.ts +18 -357
- package/src/cmd-doctor.ts +16 -66
- package/src/cmd-env.ts +29 -14
- package/src/cmd-generate.ts +12 -2
- package/src/cmd-i18n.ts +11 -8
- package/src/cmd-jobs-spec.ts +7 -22
- package/src/cmd-jobs.ts +18 -137
- package/src/cmd-mcp.ts +25 -7
- package/src/cmd-new.ts +36 -8
- package/src/cmd-planned.ts +9 -0
- package/src/cmd-routes.ts +3 -3
- package/src/cmd-shot-matrix.ts +21 -4
- package/src/cmd-shot-spec.ts +10 -2
- package/src/cmd-shot.ts +35 -39
- package/src/cmd-test.ts +1 -1
- package/src/coverage-lcov.ts +4 -2
- package/src/db-backfill.ts +2 -2
- package/src/db-replay-engine.ts +4 -10
- package/src/db-subscribes.ts +19 -16
- package/src/dev-boot.ts +290 -0
- package/src/dev-child-watch.ts +63 -12
- package/src/dev-dashboard-guard.ts +1 -1
- package/src/dev-dashboard.ts +5 -1
- package/src/dev-lock.ts +96 -24
- package/src/dev-rebuild.ts +80 -0
- package/src/dev-restart-retire.ts +46 -0
- package/src/dev-route-table.ts +30 -8
- package/src/dev-supervisor.ts +114 -22
- package/src/dev-traces.ts +53 -20
- package/src/dispatch.ts +14 -7
- package/src/doctor-auth.ts +4 -4
- package/src/doctor-offline.ts +6 -5
- package/src/document-options.ts +7 -0
- package/src/drift-replica-identity.ts +2 -2
- package/src/drift.ts +29 -6
- package/src/error-codes.ts +6 -0
- package/src/error-page-csp.ts +14 -15
- package/src/exec.ts +8 -0
- package/src/favicon.ts +2 -2
- package/src/fix-command.ts +9 -1
- package/src/framework-env.ts +148 -0
- package/src/framework-schema.ts +13 -2
- package/src/generate-catalog-locales.ts +79 -0
- package/src/generate-kinds.ts +3 -17
- package/src/generate-plural.ts +50 -0
- package/src/generate-shadow.ts +50 -1
- package/src/hold.ts +56 -1
- package/src/i18n-audit.ts +21 -8
- package/src/i18n-index.ts +5 -6
- package/src/i18n-registration.ts +7 -9
- package/src/import-scan.ts +28 -3
- package/src/index.ts +1 -3
- package/src/island-bundle.ts +54 -64
- package/src/island-capture.ts +1 -1
- package/src/island-duplicate-refusal.ts +57 -0
- package/src/island-duplicates.ts +221 -0
- package/src/island-entry-missing.ts +64 -0
- package/src/island-harness-route.ts +20 -4
- package/src/island-harness-script.ts +23 -2
- package/src/island-identity.ts +17 -14
- package/src/island-package-dedupe.ts +150 -0
- package/src/island-realtime.ts +36 -16
- package/src/island-routes.ts +2 -2
- package/src/island-runtime.ts +241 -0
- package/src/island-sources.ts +105 -0
- package/src/island-states-load.ts +6 -0
- package/src/island-store.ts +90 -6
- package/src/jobs-json.ts +0 -26
- package/src/jobs-report.ts +32 -16
- package/src/local-cli-handoff.ts +90 -0
- package/src/mcp-errors.ts +8 -1
- package/src/mcp-host.ts +6 -5
- package/src/mcp-ui-inspect.ts +3 -2
- package/src/mcp-ui-interact.ts +3 -2
- package/src/mcp-ui.ts +8 -7
- package/src/measure-scope.ts +15 -10
- package/src/messages.ts +27 -16
- package/src/metrics-endpoint.ts +3 -2
- package/src/migration-extensions.ts +1 -1
- package/src/otlp-export.ts +2 -2
- package/src/output.ts +18 -0
- package/src/page-speculation.ts +14 -11
- package/src/parse.ts +0 -8
- package/src/permission-grants.ts +3 -3
- package/src/posix-path.ts +30 -0
- package/src/prerender-locales.ts +6 -4
- package/src/prerender-report.ts +58 -0
- package/src/prerender-served.ts +52 -0
- package/src/prerender.ts +63 -98
- package/src/pwa-artifacts.ts +8 -1
- package/src/pwa-manifest-locales.ts +9 -9
- package/src/retry-memo.ts +1 -1
- package/src/role-realtime.ts +7 -1
- package/src/role-replicator.ts +110 -31
- package/src/role-start.ts +64 -85
- package/src/role-sync.ts +65 -6
- package/src/root-env.ts +157 -28
- package/src/route-islands.ts +69 -0
- package/src/runtime-admin.ts +4 -1
- package/src/runtime-assets.ts +15 -2
- package/src/runtime-bindings.ts +23 -3
- package/src/runtime-cache.ts +8 -8
- package/src/runtime-hooks.ts +2 -1
- package/src/runtime-isr-outcome.ts +100 -0
- package/src/runtime-isr.ts +2 -2
- package/src/runtime-jobs.ts +78 -18
- package/src/runtime-live-feed.ts +1 -1
- package/src/runtime-mail.ts +26 -0
- package/src/runtime-mfa-warning.ts +2 -2
- package/src/runtime-overrides.ts +3 -3
- package/src/runtime-purge.ts +1 -1
- package/src/runtime-queue.ts +12 -12
- package/src/runtime-realtime.ts +5 -3
- package/src/runtime-render.ts +77 -82
- package/src/runtime-replica.ts +3 -8
- package/src/runtime-services.ts +33 -13
- package/src/runtime-storage-upload.ts +2 -2
- package/src/runtime-storage.ts +3 -3
- package/src/schema-diff.ts +1 -1
- package/src/secrets-rotation.ts +8 -9
- package/src/seo-routes.ts +2 -2
- package/src/serve-boot.ts +22 -11
- package/src/serve-drain.ts +3 -1
- package/src/serve-retire.ts +95 -0
- package/src/serve-web.ts +19 -8
- package/src/serve.ts +27 -10
- package/src/server-barrels.ts +6 -3
- package/src/shot-browser.ts +1 -1
- package/src/shot-locale.ts +6 -15
- package/src/shot-settle.ts +3 -3
- package/src/shot-theme.ts +1 -1
- package/src/shot-viewport.ts +152 -0
- package/src/signal-shred.ts +26 -12
- package/src/site-asset-routes.ts +2 -2
- package/src/site-seo.ts +6 -4
- package/src/source-files.ts +13 -3
- package/src/static-report.ts +5 -0
- package/src/style-routes.ts +2 -2
- package/src/sw-artifacts.ts +8 -8
- package/src/sw-precache-plan.ts +34 -4
- package/src/sync-authenticator.ts +3 -3
- package/src/sync-url.ts +1 -1
- package/src/templates/action.ts +4 -4
- package/src/templates/backfill.ts +4 -4
- package/src/templates/entity.ts +2 -2
- package/src/templates/github/ci.yml.ts +28 -18
- package/src/templates/github/image.yml.ts +92 -0
- package/src/templates/github/runner.ts +18 -0
- package/src/templates/guard-bare-error.ts +16 -2
- package/src/templates/guard-unzoned-date.ts +56 -6
- package/src/templates/index.ts +1 -1
- package/src/templates/job-body-test.ts +2 -2
- package/src/templates/job.ts +6 -6
- package/src/templates/query.ts +9 -8
- package/src/templates/resource-page.ts +9 -7
- package/src/templates/resource-service.ts +2 -2
- package/src/templates/route.ts +23 -8
- package/src/templates/scaffold-api.ts +2 -3
- package/src/templates/scaffold-app.ts +2 -1
- package/src/templates/scaffold-auth.ts +205 -70
- package/src/templates/scaffold-bin.ts +122 -0
- package/src/templates/scaffold-claude-commands.ts +11 -11
- package/src/templates/scaffold-container.ts +12 -6
- package/src/templates/scaffold-dashboard-bare.ts +8 -4
- package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
- package/src/templates/scaffold-dashboard-example.ts +112 -172
- package/src/templates/scaffold-dashboard-shared.ts +62 -1
- package/src/templates/scaffold-dashboard.ts +9 -5
- package/src/templates/scaffold-db-package.ts +2 -2
- package/src/templates/scaffold-docs.ts +20 -77
- package/src/templates/scaffold-domain-package.ts +0 -4
- package/src/templates/scaffold-entries.ts +4 -3
- package/src/templates/scaffold-env.ts +6 -3
- package/src/templates/scaffold-gitattributes.ts +59 -0
- package/src/templates/scaffold-helm-templates.ts +74 -18
- package/src/templates/scaffold-helm.ts +31 -4
- package/src/templates/scaffold-http.ts +1 -1
- package/src/templates/scaffold-i18n.ts +15 -2
- package/src/templates/scaffold-icon.ts +2 -2
- package/src/templates/scaffold-repo.ts +18 -15
- package/src/templates/scaffold-shell.ts +17 -2
- package/src/test-only-shipped.ts +193 -0
- package/src/test-select.ts +2 -1
- package/src/test-shards.ts +1 -4
- package/src/theme-boot.ts +2 -2
- package/src/theme-brand.ts +66 -0
- package/src/trusted-hops.ts +49 -0
- package/src/unscanned-admin.ts +2 -2
- package/src/verify-checks.ts +12 -2
- package/src/verify-coverage-run.ts +3 -2
- package/src/verify-deadline.ts +87 -10
- package/src/verify-merge.ts +48 -16
- package/src/verify-part-step.ts +10 -2
- package/src/verify-role-load-probe.ts +12 -2
- package/src/verify-role-load.ts +9 -4
- package/src/verify-run.ts +3 -0
- package/src/verify-step.ts +2 -0
- package/src/verify-test-run.ts +3 -2
- package/src/verify-tests.ts +0 -1
- package/src/worker-bundle.ts +2 -2
- package/src/workspace-checks.ts +53 -7
- package/src/workspace-graph.ts +3 -2
- package/src/write-line.ts +82 -7
- package/src/browser-launcher-fake-html.ts +0 -80
- package/src/browser-launcher-fake.ts +0 -168
- package/src/jobs-drain.ts +0 -230
- package/src/scaffold-typecheck.ts +0 -227
- package/src/thrown-by.ts +0 -24
package/src/role-realtime.ts
CHANGED
|
@@ -14,10 +14,14 @@ const REALTIME_ROLES: readonly Role[] = ['sync', 'replicator'];
|
|
|
14
14
|
* what `x dev`'s default set needs on an app that opted out (`enabled: false`; the default is on).
|
|
15
15
|
* A selection that was NOTHING but realtime roles is refused instead: that is `ROLE=sync` on an app
|
|
16
16
|
* that turned realtime off, a container that would bind nothing and restart behind its probe.
|
|
17
|
+
*
|
|
18
|
+
* `dev` is the boot's `WebBinding.dev`, and picks the invocation the fix names: `x dev --role` for
|
|
19
|
+
* `x dev`, a `ROLE=` for a container — neither reads the other's selector.
|
|
17
20
|
*/
|
|
18
21
|
export function rolesUnderRealtime(
|
|
19
22
|
selected: readonly Role[],
|
|
20
23
|
realtime: Pick<RealtimeConfig, 'enabled'>,
|
|
24
|
+
dev: boolean,
|
|
21
25
|
): readonly Role[] {
|
|
22
26
|
// The key is read only when a realtime role was asked for, so a selection without one never
|
|
23
27
|
// depends on it — a hand-built runtime in a test that starts `web` alone included.
|
|
@@ -27,7 +31,9 @@ export function rolesUnderRealtime(
|
|
|
27
31
|
if (kept.length === 0) {
|
|
28
32
|
throw new ConfigInvalidError({
|
|
29
33
|
cause: `role ${dropped.join(',')} was asked for and realtime.enabled is false in app.config.ts, so this process would serve nothing`,
|
|
30
|
-
fix:
|
|
34
|
+
fix: dev
|
|
35
|
+
? 'set realtime: { enabled: true } in app.config.ts, or run a role that is not realtime: x dev --role web,worker,scheduler'
|
|
36
|
+
: 'ROLE=web bun apps/web/server.ts — a role that is not realtime; or set realtime: { enabled: true } in app.config.ts and rebuild the image, or drop the role from the deploy (the chart: roles.<role>.enabled: false in values.yaml)',
|
|
31
37
|
meta: { key: 'realtime.enabled', roles: dropped },
|
|
32
38
|
});
|
|
33
39
|
}
|
package/src/role-replicator.ts
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
|
-
// Single responsibility: the `replicator` role
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Single responsibility: the `replicator` role — the one place the Postgres logical replication
|
|
2
|
+
// feed is selected, locked and pumped into the transport, for `x dev --role replicator` and a
|
|
3
|
+
// `ROLE=replicator` container alike. The two differ in what losing the lock means: terminal under
|
|
4
|
+
// `x dev`, a standby that keeps asking in a container.
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
ConfigInvalidError,
|
|
8
|
+
logger,
|
|
9
|
+
registerReadinessCheck,
|
|
10
|
+
renderThrowable,
|
|
11
|
+
} from '@ultimat3/core';
|
|
7
12
|
import { describeEntities } from '@ultimat3/entity';
|
|
8
13
|
import { ReplicatorSlotHeldError } from '@ultimat3/realtime';
|
|
9
|
-
import type { Replicator, Transport } from '@ultimat3/realtime/server';
|
|
10
|
-
import {
|
|
14
|
+
import type { Replicator, ReplicatorOptions, Transport } from '@ultimat3/realtime/server';
|
|
15
|
+
import {
|
|
16
|
+
changeFeedReplicator,
|
|
17
|
+
replicatorLockKey,
|
|
18
|
+
selectChangeFeed,
|
|
19
|
+
} from '@ultimat3/realtime/server';
|
|
11
20
|
import { BadFlagError } from './errors';
|
|
12
21
|
import type { DevServices, Env } from './runtime-bindings';
|
|
13
22
|
|
|
@@ -15,6 +24,14 @@ export interface StartReplicatorOptions {
|
|
|
15
24
|
readonly services: DevServices;
|
|
16
25
|
readonly env: Env;
|
|
17
26
|
readonly transport: Transport;
|
|
27
|
+
/**
|
|
28
|
+
* `x dev` (true) or a container (false) — the boot's `WebBinding.dev`. It decides what a lost
|
|
29
|
+
* lock race means and which invocation a refusal names: `x dev --role` is not a command a pod
|
|
30
|
+
* can run, and `ROLE=` is not a flag `x dev` reads.
|
|
31
|
+
*/
|
|
32
|
+
readonly dev: boolean;
|
|
33
|
+
/** The replicator's constructor. A seam for the role's own tests; the boot never passes it. */
|
|
34
|
+
readonly create?: (options: ReplicatorOptions) => Replicator;
|
|
18
35
|
}
|
|
19
36
|
|
|
20
37
|
export interface RunningReplicator {
|
|
@@ -32,15 +49,23 @@ export interface RunningReplicator {
|
|
|
32
49
|
* "not a dev role", which is what this used to say and sent an agent looking for a flag instead of
|
|
33
50
|
* a database. Same code as before (`X_CLI_BAD_FLAG`) because it is the same invocation.
|
|
34
51
|
*/
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
52
|
+
const NO_WALSENDER =
|
|
53
|
+
'the replicator decodes a write-ahead log, and the embedded database is PGlite — it has no ' +
|
|
54
|
+
'walsender to decode';
|
|
55
|
+
|
|
56
|
+
const embeddedRefusal = (dev: boolean): BadFlagError | ConfigInvalidError =>
|
|
57
|
+
dev
|
|
58
|
+
? new BadFlagError({
|
|
59
|
+
flag: 'role',
|
|
60
|
+
command: 'dev',
|
|
61
|
+
reason: NO_WALSENDER,
|
|
62
|
+
fix: 'DATABASE_URL=postgres://user:password@localhost:5432/app x dev --role replicator',
|
|
63
|
+
})
|
|
64
|
+
: new ConfigInvalidError({
|
|
65
|
+
cause: `ROLE=replicator booted with no DATABASE_URL: ${NO_WALSENDER}`,
|
|
66
|
+
fix: 'DATABASE_URL=postgres://user:password@host:5432/app ROLE=replicator bun apps/web/server.ts — in the chart, DATABASE_URL comes from the Secret named by existingSecret in docker/helm/values.yaml',
|
|
67
|
+
meta: { key: 'DATABASE_URL', role: 'replicator' },
|
|
68
|
+
});
|
|
44
69
|
|
|
45
70
|
/**
|
|
46
71
|
* The relations the feed decodes: every registered entity's PHYSICAL TABLE, never its name.
|
|
@@ -65,13 +90,22 @@ export function replicatedRelations(): readonly string[] {
|
|
|
65
90
|
* and forwards none. Refused here rather than inside the feed: this is the layer that knows the
|
|
66
91
|
* list came from the app's own registry, so it can name the command that adds to it.
|
|
67
92
|
*/
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
93
|
+
const NO_ENTITIES =
|
|
94
|
+
'no entities are registered, so the replicator would decode changes nothing matches';
|
|
95
|
+
|
|
96
|
+
const noEntitiesRefusal = (dev: boolean): BadFlagError | ConfigInvalidError =>
|
|
97
|
+
dev
|
|
98
|
+
? new BadFlagError({
|
|
99
|
+
flag: 'role',
|
|
100
|
+
command: 'dev',
|
|
101
|
+
reason: NO_ENTITIES,
|
|
102
|
+
fix: 'x g entity Post title:text — then x dev --role replicator',
|
|
103
|
+
})
|
|
104
|
+
: new ConfigInvalidError({
|
|
105
|
+
cause: `ROLE=replicator: ${NO_ENTITIES}`,
|
|
106
|
+
fix: 'x g entity Post title:text — then rebuild the image and roll the ROLE=replicator Deployment',
|
|
107
|
+
meta: { role: 'replicator' },
|
|
108
|
+
});
|
|
75
109
|
|
|
76
110
|
/** The name `/readyz` reports the replicator under. */
|
|
77
111
|
export const REPLICATOR_READINESS_CHECK = 'replicator';
|
|
@@ -85,28 +119,71 @@ export function watchReplicatorReadiness(replicator: Pick<Replicator, 'running'>
|
|
|
85
119
|
return registerReadinessCheck(REPLICATOR_READINESS_CHECK, () => replicator.running);
|
|
86
120
|
}
|
|
87
121
|
|
|
122
|
+
/**
|
|
123
|
+
* The standby: ask for the slot again on the replicator's own jittered backoff until it is granted
|
|
124
|
+
* or the role is stopped. `start()` answering `false` arms no retry of its own — the replicator's
|
|
125
|
+
* takeover loop covers a run it LOST, not one it never had — so a container that lost the race at
|
|
126
|
+
* boot would otherwise stand by forever, and a holder that died would leave the slot unreplicated.
|
|
127
|
+
*
|
|
128
|
+
* Unref'd: a pending ask must never be what keeps a draining process alive. Returns the cancel.
|
|
129
|
+
*/
|
|
130
|
+
function standBy(replicator: Replicator, key: string): () => void {
|
|
131
|
+
let cancelled = false;
|
|
132
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
133
|
+
const ask = (attempt: number): void => {
|
|
134
|
+
timer = setTimeout(() => {
|
|
135
|
+
timer = undefined;
|
|
136
|
+
if (cancelled) return;
|
|
137
|
+
// Voided: both outcomes are handled here, and a timer has nobody to hand a promise to.
|
|
138
|
+
void replicator.start().then(
|
|
139
|
+
(held) => {
|
|
140
|
+
if (held) logger.info('replicator.slot_taken_over', { key });
|
|
141
|
+
else if (!cancelled) ask(attempt + 1);
|
|
142
|
+
},
|
|
143
|
+
(thrown: unknown) => {
|
|
144
|
+
logger.warn('replicator.standby_failed', { key, error: renderThrowable(thrown) });
|
|
145
|
+
if (!cancelled) ask(attempt + 1);
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
}, replicator.retryDelayMs(attempt));
|
|
149
|
+
timer.unref?.();
|
|
150
|
+
};
|
|
151
|
+
ask(0);
|
|
152
|
+
return () => {
|
|
153
|
+
cancelled = true;
|
|
154
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
88
158
|
/**
|
|
89
159
|
* Lock first, feed second. A replicator that opened its slot before losing the lock race would
|
|
90
160
|
* have already consumed WAL the holder is responsible for, and `pg_try_advisory_lock` is the only
|
|
91
161
|
* thing standing between two containers and every change delivered twice.
|
|
92
162
|
*/
|
|
93
163
|
export async function startReplicator(options: StartReplicatorOptions): Promise<RunningReplicator> {
|
|
94
|
-
if (options.services.db.mode === 'embedded') throw embeddedRefusal();
|
|
164
|
+
if (options.services.db.mode === 'embedded') throw embeddedRefusal(options.dev);
|
|
95
165
|
const entities = replicatedRelations();
|
|
96
|
-
if (entities.length === 0) throw noEntitiesRefusal();
|
|
166
|
+
if (entities.length === 0) throw noEntitiesRefusal(options.dev);
|
|
97
167
|
|
|
98
168
|
const selection = selectChangeFeed(options.env, { entities });
|
|
99
169
|
const slot = selection.slot ?? '';
|
|
100
|
-
const
|
|
170
|
+
const key = replicatorLockKey(slot);
|
|
171
|
+
const replicator = (options.create ?? changeFeedReplicator)({
|
|
101
172
|
feed: selection.feed,
|
|
102
173
|
transport: options.transport,
|
|
103
174
|
lock: selection.lock,
|
|
104
175
|
});
|
|
105
|
-
// `start()` answers `false` for the one condition that is not this process's fault
|
|
106
|
-
// has nothing to fail over to
|
|
107
|
-
//
|
|
176
|
+
// `start()` answers `false` for the one condition that is not this process's fault: another
|
|
177
|
+
// process holds the lock. A dev boot has nothing to fail over to, so it is terminal there, with
|
|
178
|
+
// the code the topology docs promised. A container stays up, `/readyz` false, and stands by —
|
|
179
|
+
// a pod that threw here crash-looped, and a rolling update waiting on its readiness never
|
|
180
|
+
// terminated the old holder, so every rollout stalled (`replicator.ts`'s header: the loser stays
|
|
181
|
+
// up, unready).
|
|
182
|
+
let cancelStandby = (): void => undefined;
|
|
108
183
|
if (!(await replicator.start())) {
|
|
109
|
-
throw new ReplicatorSlotHeldError({ key
|
|
184
|
+
if (options.dev) throw new ReplicatorSlotHeldError({ key });
|
|
185
|
+
logger.warn('replicator.standby', { key, detail: 'another process holds the slot' });
|
|
186
|
+
cancelStandby = standBy(replicator, key);
|
|
110
187
|
}
|
|
111
188
|
const unregister = watchReplicatorReadiness(replicator);
|
|
112
189
|
return {
|
|
@@ -114,7 +191,9 @@ export async function startReplicator(options: StartReplicatorOptions): Promise<
|
|
|
114
191
|
slot,
|
|
115
192
|
detail: selection.detail,
|
|
116
193
|
async stop() {
|
|
117
|
-
// First: a replicator being stopped on purpose is not a failing one
|
|
194
|
+
// First: a replicator being stopped on purpose is not a failing one, and a standby must not
|
|
195
|
+
// ask for a slot after the role that wanted it has gone.
|
|
196
|
+
cancelStandby();
|
|
118
197
|
unregister();
|
|
119
198
|
await replicator.stop();
|
|
120
199
|
},
|
package/src/role-start.ts
CHANGED
|
@@ -7,24 +7,24 @@
|
|
|
7
7
|
// something every `x dev` in a team should do to the same server by simply starting.
|
|
8
8
|
|
|
9
9
|
import type { Role } from '@ultimat3/core';
|
|
10
|
-
import {
|
|
10
|
+
import { ctxOf, isRole, logger, markReady, ROLES } from '@ultimat3/core';
|
|
11
11
|
import type { RateLimitStore, Route, ServerHandle, WebSocketMount } from '@ultimat3/http';
|
|
12
12
|
import {
|
|
13
|
+
adoptRateLimitStore,
|
|
13
14
|
configuredAuthenticator,
|
|
14
15
|
configuredHttp,
|
|
15
|
-
createServer,
|
|
16
16
|
defineHttpConfig,
|
|
17
|
-
|
|
17
|
+
httpServer,
|
|
18
18
|
mergeHttpConfig,
|
|
19
19
|
} from '@ultimat3/http';
|
|
20
20
|
import type { OutboxRelay } from '@ultimat3/jobs';
|
|
21
21
|
import {
|
|
22
|
-
createOutboxRelay,
|
|
23
|
-
createPgLeaseLeader,
|
|
24
|
-
createScheduler,
|
|
25
|
-
createWorker,
|
|
26
22
|
jobDriver,
|
|
27
|
-
|
|
23
|
+
jobScheduler,
|
|
24
|
+
jobWorker,
|
|
25
|
+
outboxRelay,
|
|
26
|
+
postgresLeaseLeader,
|
|
27
|
+
postgresSchedulerState,
|
|
28
28
|
} from '@ultimat3/jobs';
|
|
29
29
|
import type { SyncWs } from '@ultimat3/realtime/server';
|
|
30
30
|
import { errorPageHook } from './error-pages';
|
|
@@ -40,16 +40,15 @@ import type { RunningRoles, StartRolesOptions } from './role-start-types';
|
|
|
40
40
|
import { DEV_ROLES } from './role-start-types';
|
|
41
41
|
import { prepareSync, type RunningSync } from './role-sync';
|
|
42
42
|
import { startWorkerWake } from './role-wake';
|
|
43
|
-
import type { Env } from './runtime-bindings';
|
|
44
43
|
import { devHooks } from './runtime-hooks';
|
|
45
44
|
import { workerOptionsFor } from './runtime-jobs';
|
|
46
45
|
import { startLiveFeed } from './runtime-live-feed';
|
|
47
46
|
import { pgExecutorFor } from './runtime-queue';
|
|
48
|
-
import type
|
|
47
|
+
import { type RunningServices, releaseOrThrow } from './runtime-services';
|
|
49
48
|
import { inlineScriptSources } from './script-csp';
|
|
50
49
|
import { inlineStyleSources } from './style-csp';
|
|
51
50
|
import { syncConnectSources } from './sync-url';
|
|
52
|
-
import {
|
|
51
|
+
import { trustedHopsFromEnv } from './trusted-hops';
|
|
53
52
|
import { DEV_BINDING } from './web-binding';
|
|
54
53
|
|
|
55
54
|
// Declared beside the option types so `x dev`'s spec reads it without loading this module.
|
|
@@ -125,47 +124,8 @@ function warnIfUnauthenticatable(routes: readonly Route[]): void {
|
|
|
125
124
|
);
|
|
126
125
|
}
|
|
127
126
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
*
|
|
131
|
-
* `defineHttpConfig` screens the same setting — `assertFiniteCount('trustedProxyHops', …,
|
|
132
|
-
* MAX_PROXY_HOPS)` — and this screen used to say 16 where that one says 64, so one setting had two
|
|
133
|
-
* ceilings and a deployment behind 20 hops was accepted by the library and refused by the boot.
|
|
134
|
-
* Widened rather than narrowed: tightening the library would break a shipped public API, while
|
|
135
|
-
* this end only ever refused topologies http already supports.
|
|
136
|
-
*
|
|
137
|
-
* It was a duplicated literal until 2026-08-26, because `@ultimat3/http` did not export the
|
|
138
|
-
* constant. It does now, so this file IMPORTS it — a downward edge, tier 5 to tier 2 — and there is
|
|
139
|
-
* no second number left to drift. `runtime-overrides.test.ts` still probes both screens for the
|
|
140
|
-
* highest count each accepts, and it is kept rather than deleted: it compares BEHAVIOUR, so it also
|
|
141
|
-
* catches the two disagreeing for a reason a shared constant cannot fix, such as one side gaining a
|
|
142
|
-
* range check the other does not have.
|
|
143
|
-
*/
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* How many proxies append to `x-forwarded-for` between the client and this process, or `null`
|
|
147
|
-
* when nothing in front of it is trusted.
|
|
148
|
-
*
|
|
149
|
-
* Read from the environment and not from `app.config.ts`, for the reason `PORT` and `ROLE` are: it
|
|
150
|
-
* is a fact about the DEPLOYMENT — one image runs behind an ingress in one cluster and behind
|
|
151
|
-
* nothing on a laptop — and an app that hardcoded it would be wrong in one of the two. `x dev`
|
|
152
|
-
* sets neither and gets `trustProxy: false`, which is correct: there is no proxy.
|
|
153
|
-
*
|
|
154
|
-
* Without this seam a container behind an ingress reads `ctx.ip` as the ingress's own socket
|
|
155
|
-
* address on every request, so the rate limiter keys the entire fleet's anonymous traffic into ONE
|
|
156
|
-
* bucket and a single scanner 429s every real signup.
|
|
157
|
-
*/
|
|
158
|
-
export function trustedHopsFromEnv(env: Env): number | null {
|
|
159
|
-
const raw = env['TRUSTED_PROXY_HOPS']?.trim();
|
|
160
|
-
if (raw === undefined || raw === '') return null;
|
|
161
|
-
const hops = Number(raw);
|
|
162
|
-
// A malformed count is refused rather than defaulted: reading the header at the wrong index is
|
|
163
|
-
// trusting a value the client typed, which is the failure trusting a proxy exists to avoid.
|
|
164
|
-
if (!/^\d+$/.test(raw) || !Number.isInteger(hops) || hops < 1 || hops > MAX_PROXY_HOPS) {
|
|
165
|
-
throw new TrustedProxyHopsInvalidError({ value: raw });
|
|
166
|
-
}
|
|
167
|
-
return hops;
|
|
168
|
-
}
|
|
127
|
+
// `trusted-hops.ts` owns the read; re-exported because callers and tests already name it here.
|
|
128
|
+
export { trustedHopsFromEnv } from './trusted-hops';
|
|
169
129
|
|
|
170
130
|
/**
|
|
171
131
|
* A deployment that substituted a PER-PROCESS store is enforcing every declared number once per
|
|
@@ -190,7 +150,7 @@ function warnIfProcessScoped(store: RateLimitStore): void {
|
|
|
190
150
|
*
|
|
191
151
|
* One expression, one answer, in the order `RuntimeOverrides` documents — an override REPLACES the
|
|
192
152
|
* resolved default rather than sitting beside it. `undefined` is reachable only from a hand-built
|
|
193
|
-
* runtime, which is `
|
|
153
|
+
* runtime, which is `httpServer`'s per-process memory store and `scope: 'process'` below.
|
|
194
154
|
*/
|
|
195
155
|
function rateLimitStoreFor(options: StartRolesOptions): RateLimitStore | undefined {
|
|
196
156
|
const supplied = options.overrides?.rateLimitStore;
|
|
@@ -206,7 +166,7 @@ function startWeb(options: StartRolesOptions, mount?: WebSocketMount<SyncWs>): S
|
|
|
206
166
|
const binding = options.http ?? DEV_BINDING;
|
|
207
167
|
const hops = trustedHopsFromEnv(options.env);
|
|
208
168
|
const store = rateLimitStoreFor(options);
|
|
209
|
-
return
|
|
169
|
+
return httpServer({
|
|
210
170
|
routes: options.routes,
|
|
211
171
|
role: 'web',
|
|
212
172
|
...(mount === undefined ? {} : { websocket: mount }),
|
|
@@ -217,7 +177,7 @@ function startWeb(options: StartRolesOptions, mount?: WebSocketMount<SyncWs>): S
|
|
|
217
177
|
? {}
|
|
218
178
|
: { errorPage: errorPageHook(options.root, { perRequest: binding.dev }) }),
|
|
219
179
|
}),
|
|
220
|
-
// Both seams `
|
|
180
|
+
// Both seams `httpServer` already had and `startRoles` passed neither of, so an app's own
|
|
221
181
|
// middleware could not reach the pipeline any process the framework boots actually runs.
|
|
222
182
|
...(options.overrides?.middleware === undefined
|
|
223
183
|
? {}
|
|
@@ -272,7 +232,7 @@ function startWeb(options: StartRolesOptions, mount?: WebSocketMount<SyncWs>): S
|
|
|
272
232
|
* `startServices` captures the drivers it built; `loadApp` imports the app's modules after it, and
|
|
273
233
|
* a module calling `setJobDriver()` at import time moves the ambient slot and leaves the capture
|
|
274
234
|
* alone. From here on the two are indistinguishable at every call site: `handle.enqueue()` reads
|
|
275
|
-
* the ambient one, `
|
|
235
|
+
* the ambient one, `jobWorker` claims from the captured one, and `/_x` reads the ambient one —
|
|
276
236
|
* so the dashboard agrees with the enqueue side and disagrees with reality.
|
|
277
237
|
*
|
|
278
238
|
* Refused, not reconciled. Reading through the accessor would make the split invisible instead of
|
|
@@ -291,12 +251,26 @@ function assertOneJobDriver(runtime: RunningServices): void {
|
|
|
291
251
|
|
|
292
252
|
export async function startRoles(options: StartRolesOptions): Promise<RunningRoles> {
|
|
293
253
|
// `realtime.enabled` read here, before anything binds: a refusal leaves nothing to unwind.
|
|
294
|
-
const selected = rolesUnderRealtime(
|
|
254
|
+
const selected = rolesUnderRealtime(
|
|
255
|
+
options.roles,
|
|
256
|
+
options.runtime.realtime,
|
|
257
|
+
(options.http ?? DEV_BINDING).dev,
|
|
258
|
+
);
|
|
295
259
|
assertOneJobDriver(options.runtime);
|
|
296
260
|
// Roles bind sockets in order, so a role that fails to start has to release the ones before it.
|
|
297
261
|
// Without this a failed `sync` leaves the web server bound and unreachable by any caller.
|
|
298
262
|
const started: (() => Promise<void>)[] = [];
|
|
299
263
|
try {
|
|
264
|
+
// Every role, before any can serve: an action's or a query's declared `rateLimit:` is spent
|
|
265
|
+
// inside the primitive (`invoke`, the read path) on every surface — a `worker` running
|
|
266
|
+
// `.job()`s as much as `web` — and counted in the installed store. The SAME instance the web
|
|
267
|
+
// role's limiter gets below (`rateLimitStoreFor` is the one decision), so one replica's MCP
|
|
268
|
+
// call and another's HTTP call spend one bucket in one table.
|
|
269
|
+
// A frame of its own over what `startServices` adopted, popped by its own undo — so stopping
|
|
270
|
+
// these roles takes back exactly this install, whatever else stacked over or under it.
|
|
271
|
+
const limits = rateLimitStoreFor(options);
|
|
272
|
+
const restoreLimits = limits === undefined ? () => undefined : adoptRateLimitStore(limits);
|
|
273
|
+
started.push(async () => restoreLimits());
|
|
300
274
|
// First, and for every role rather than only the two that open an HTTP socket: `worker` and
|
|
301
275
|
// `sync` are precisely the roles whose HPAs read a series the process itself has to publish,
|
|
302
276
|
// and a `worker` container with no listener is an HPA pinned at `<unknown>` forever.
|
|
@@ -320,21 +294,32 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
320
294
|
// own port and a listening server cannot be handed one, while the neighbouring-port refusals
|
|
321
295
|
// are only the right answer once the web port's own has been given.
|
|
322
296
|
const prepared = selected.includes('sync') ? await prepareSync(options) : null;
|
|
323
|
-
// One rollback entry, kept current — two would stop the node twice out of a failed boot.
|
|
297
|
+
// One rollback entry, kept current — two would stop the node twice out of a failed boot. It
|
|
298
|
+
// goes on the list AFTER the server's, because the list is also the stop order (newest
|
|
299
|
+
// first): the node's listener closes before the web server drains, as it always has. The
|
|
300
|
+
// window between — `startWeb` refusing — releases the prepared node itself.
|
|
324
301
|
let releaseSync = prepared?.stop ?? null;
|
|
302
|
+
let server: ServerHandle | null;
|
|
303
|
+
try {
|
|
304
|
+
server = selected.includes('web') ? startWeb(options, prepared?.mount) : null;
|
|
305
|
+
} catch (error) {
|
|
306
|
+
await releaseSync?.().catch(() => undefined);
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
if (server !== null) {
|
|
310
|
+
const web = server;
|
|
311
|
+
started.push(() => web.stop());
|
|
312
|
+
}
|
|
325
313
|
if (prepared !== null) started.push(async () => await releaseSync?.());
|
|
326
314
|
|
|
327
|
-
const server = selected.includes('web') ? startWeb(options, prepared?.mount) : null;
|
|
328
|
-
if (server !== null) started.push(() => server.stop());
|
|
329
|
-
|
|
330
315
|
const sync: RunningSync | null =
|
|
331
316
|
prepared === null ? null : await prepared.listen(server === null ? null : server.url());
|
|
332
317
|
if (sync !== null) releaseSync = sync.stop;
|
|
333
318
|
|
|
334
319
|
const worker = selected.includes('worker')
|
|
335
|
-
?
|
|
320
|
+
? jobWorker({
|
|
336
321
|
driver: options.runtime.jobs,
|
|
337
|
-
context: () =>
|
|
322
|
+
context: () => ctxOf({ role: 'worker', buildId: options.buildId }),
|
|
338
323
|
...workerOptionsFor(options.runtime.workerConfig),
|
|
339
324
|
})
|
|
340
325
|
: null;
|
|
@@ -352,7 +337,7 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
352
337
|
// collapses a repeat only while the first job is still live. A deployment with no `worker` has
|
|
353
338
|
// no one to run the jobs either way.
|
|
354
339
|
const relay: OutboxRelay | null = selected.includes('worker')
|
|
355
|
-
?
|
|
340
|
+
? outboxRelay({ store: options.runtime.outbox, driver: options.runtime.jobs })
|
|
356
341
|
: null;
|
|
357
342
|
relay?.start();
|
|
358
343
|
// Returned, not called-and-discarded: `stop()` waits out the pass in flight, and an unawaited
|
|
@@ -366,20 +351,20 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
366
351
|
const wake = selected.includes('worker') ? startWorkerWake(options.runtime.db) : null;
|
|
367
352
|
if (wake !== null) started.push(() => wake.stop());
|
|
368
353
|
|
|
369
|
-
// `state` and `leader`, not the defaults. `
|
|
354
|
+
// `state` and `leader`, not the defaults. `memorySchedulerState` forgets every watermark
|
|
370
355
|
// on restart, so a rolling deploy re-fires or skips whatever was due across it, and
|
|
371
356
|
// `soleLeader()` makes every replica the leader — three `scheduler` pods, three of every task.
|
|
372
357
|
//
|
|
373
|
-
// `
|
|
358
|
+
// `postgresLeaseLeader` and NOT `postgresLeader`: the latter's `pg_try_advisory_lock` is
|
|
374
359
|
// SESSION-scoped, and the session ends the moment the connection goes back to the pool, so
|
|
375
360
|
// every node reads itself as leader anyway. An expiring row is correct on the executor this
|
|
376
361
|
// package is actually handed.
|
|
377
362
|
const executor = pgExecutorFor(options.runtime.db);
|
|
378
363
|
const scheduler = selected.includes('scheduler')
|
|
379
|
-
?
|
|
364
|
+
? jobScheduler({
|
|
380
365
|
driver: options.runtime.jobs,
|
|
381
|
-
state:
|
|
382
|
-
leader:
|
|
366
|
+
state: postgresSchedulerState(executor),
|
|
367
|
+
leader: postgresLeaseLeader({ executor }),
|
|
383
368
|
})
|
|
384
369
|
: null;
|
|
385
370
|
scheduler?.start();
|
|
@@ -393,6 +378,7 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
393
378
|
services: options.runtime.services,
|
|
394
379
|
env: options.env,
|
|
395
380
|
transport: options.runtime.transport,
|
|
381
|
+
dev: binding.dev,
|
|
396
382
|
})
|
|
397
383
|
: null;
|
|
398
384
|
if (replicator !== null) started.push(() => replicator.stop());
|
|
@@ -425,22 +411,15 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
425
411
|
liveBridge: live.bridge,
|
|
426
412
|
liveRegistry: sync?.registry ?? null,
|
|
427
413
|
async stop() {
|
|
428
|
-
//
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
//
|
|
433
|
-
// and
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
|
|
437
|
-
await wake?.stop();
|
|
438
|
-
await relay?.stop();
|
|
439
|
-
await worker?.stop('x dev stopped');
|
|
440
|
-
await sync?.stop();
|
|
441
|
-
await server?.stop();
|
|
442
|
-
// Last: a scrape taken while the roles above drain is the one that explains the drain.
|
|
443
|
-
metrics.stop();
|
|
414
|
+
// `started` IS the stop list, in boot order: released newest first — the live feed, the
|
|
415
|
+
// replicator (so the slot is released before the bus it published to closes), the
|
|
416
|
+
// scheduler, the wake session, the relay (awaited: a pass is an enqueue then a
|
|
417
|
+
// `markPublished`, and a pool closed between the two re-publishes next boot), the worker,
|
|
418
|
+
// the sync node, the server, the metrics listener (a scrape taken mid-drain explains the
|
|
419
|
+
// drain) and, after every role that could spend from it, the rate-limit store. EVERY step
|
|
420
|
+
// runs: one rejection used to skip the rest. One list, so the rollback and the stop cannot
|
|
421
|
+
// drift apart.
|
|
422
|
+
await releaseOrThrow(started);
|
|
444
423
|
},
|
|
445
424
|
};
|
|
446
425
|
} catch (error) {
|
package/src/role-sync.ts
CHANGED
|
@@ -2,25 +2,35 @@
|
|
|
2
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
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { ctxOf, logger, type RealtimeConfig, UltimateError } from '@ultimat3/core';
|
|
6
|
+
import {
|
|
7
|
+
clientAddress,
|
|
8
|
+
configuredHttp,
|
|
9
|
+
defineHttpConfig,
|
|
10
|
+
mergeHttpConfig,
|
|
11
|
+
type WebSocketMount,
|
|
12
|
+
} from '@ultimat3/http';
|
|
7
13
|
import { listQueries } from '@ultimat3/query';
|
|
8
14
|
import type { SyncNode, SyncWs } from '@ultimat3/realtime/server';
|
|
9
15
|
import {
|
|
10
16
|
ChannelHub,
|
|
11
|
-
createSyncNode,
|
|
12
17
|
LiveQueryRegistry,
|
|
13
18
|
listenSyncNode,
|
|
14
19
|
liveQueryDefinition,
|
|
15
20
|
PresenceRegistry,
|
|
16
21
|
RingChangeBuffer,
|
|
17
22
|
SocketRegistry,
|
|
23
|
+
syncNode,
|
|
18
24
|
} from '@ultimat3/realtime/server';
|
|
19
25
|
import { neighbouringPort, PORT_RANGE, portPairAfter } from './flag-number';
|
|
20
26
|
import { portFree } from './port-probe';
|
|
21
27
|
import type { StartRolesOptions } from './role-start';
|
|
28
|
+
import type { Env } from './runtime-bindings';
|
|
22
29
|
import { syncAuthenticator } from './sync-authenticator';
|
|
23
30
|
import { syncOriginsFrom } from './sync-url';
|
|
31
|
+
// A value edge back into `role-start.ts`, read only inside a call: the one parser of
|
|
32
|
+
// `TRUSTED_PROXY_HOPS`, never a second copy of it.
|
|
33
|
+
import { trustedHopsFromEnv } from './trusted-hops';
|
|
24
34
|
import { DEV_BINDING } from './web-binding';
|
|
25
35
|
|
|
26
36
|
/**
|
|
@@ -154,18 +164,26 @@ export interface RunningSync {
|
|
|
154
164
|
* close. And no default is defensible: one tenant is a single person and the next is five
|
|
155
165
|
* thousand seats, so any number here is either unreachable or an outage on a Monday morning. The
|
|
156
166
|
* per-socket 128 stands because a socket is one browser tab, which is a bound the framework can
|
|
157
|
-
* actually know
|
|
167
|
+
* actually know, and so does the per-ACTOR 1,000 (`realtime.maxSubscriptionsPerActor`): one
|
|
168
|
+
* principal's share is knowable where a tenant's is not. A deployment that wants the tenant cap
|
|
169
|
+
* passes both:
|
|
158
170
|
*
|
|
159
171
|
* new LiveQueryRegistry({ …, maxPerTenant: 5_000, tenantOf: (actor) => actor?.orgId ?? null })
|
|
160
172
|
*/
|
|
161
173
|
export function registerLiveQueries(options: StartRolesOptions): LiveQueryRegistry {
|
|
174
|
+
// `?.` because a caller that builds only the registry (`role-sync-resume.test.ts`) hands no
|
|
175
|
+
// runtime; unset is the registry's own `DEFAULT_MAX_PER_ACTOR`, which is what core leaves it.
|
|
176
|
+
const maxPerActor = options.runtime?.realtime?.maxSubscriptionsPerActor;
|
|
162
177
|
const registry = new LiveQueryRegistry({
|
|
163
178
|
source: new RingChangeBuffer(),
|
|
179
|
+
// One actor's — or one anonymous address's — share of this node's live queries, from the app's
|
|
180
|
+
// `realtime.maxSubscriptionsPerActor`. Without it 79 sockets of one actor filled the node.
|
|
181
|
+
...(maxPerActor === undefined ? {} : { maxPerActor }),
|
|
164
182
|
// A withheld row is a metric, never a frame and never an error: telling a client "there is a
|
|
165
183
|
// row you may not see" is the leak the gate exists to prevent.
|
|
166
184
|
onRowDenied: (event) => logger.debug('live.rows_denied', { ...event }),
|
|
167
185
|
});
|
|
168
|
-
const ctx =
|
|
186
|
+
const ctx = ctxOf({ role: 'sync', buildId: options.buildId });
|
|
169
187
|
// The position a snapshot claims: the newest change this node had received when the read began.
|
|
170
188
|
// The read then holds at least that change, so claiming it is true, and every later change is
|
|
171
189
|
// above it. Unwired, every snapshot claimed `''` and a read that landed after any fan-out was
|
|
@@ -177,6 +195,45 @@ export function registerLiveQueries(options: StartRolesOptions): LiveQueryRegist
|
|
|
177
195
|
return registry;
|
|
178
196
|
}
|
|
179
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Who dialled the socket, resolved by `@ultimat3/http`'s `clientAddress()` — the rule the web role's
|
|
200
|
+
* `ctx.ip` follows, at the same `TRUSTED_PROXY_HOPS`. It keys an anonymous live reader's rate
|
|
201
|
+
* limit: read off the socket alone, a node behind an ingress put every anonymous viewer of the
|
|
202
|
+
* fleet in the ingress's one bucket, and one visitor looping subscribes locked out the rest.
|
|
203
|
+
*
|
|
204
|
+
* The config is built only to carry the hops — `rateLimit.scope` is declared because
|
|
205
|
+
* `defineHttpConfig` requires it, and this node runs no HTTP limiter that would read it.
|
|
206
|
+
*/
|
|
207
|
+
export function syncClientAddressOf(
|
|
208
|
+
env: Env,
|
|
209
|
+
): (request: Request, socketAddress: string | null) => string | null {
|
|
210
|
+
const hops = trustedHopsFromEnv(env);
|
|
211
|
+
const config = defineHttpConfig(
|
|
212
|
+
mergeHttpConfig(configuredHttp(), {
|
|
213
|
+
rateLimit: { scope: 'process' },
|
|
214
|
+
...(hops === null ? {} : { trustProxy: true, trustedProxyHops: hops }),
|
|
215
|
+
}),
|
|
216
|
+
);
|
|
217
|
+
return (request, socketAddress) =>
|
|
218
|
+
clientAddress({
|
|
219
|
+
headers: request.headers,
|
|
220
|
+
config,
|
|
221
|
+
socketAddress,
|
|
222
|
+
urlProtocol: new URL(request.url).protocol,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The node's half of the app's per-actor caps: `realtime.maxSocketsPerActor`, the sockets one
|
|
228
|
+
* principal may hold here. Absent, the node keeps its own default (16) — which is core's too.
|
|
229
|
+
*/
|
|
230
|
+
export function syncNodeCaps(realtime: RealtimeConfig | undefined): {
|
|
231
|
+
readonly maxSocketsPerActor?: number;
|
|
232
|
+
} {
|
|
233
|
+
const max = realtime?.maxSocketsPerActor;
|
|
234
|
+
return max === undefined ? {} : { maxSocketsPerActor: max };
|
|
235
|
+
}
|
|
236
|
+
|
|
180
237
|
const healthDetailPeersOf = (
|
|
181
238
|
peers: readonly string[] | undefined,
|
|
182
239
|
): { readonly healthDetailPeers?: readonly string[] } =>
|
|
@@ -226,7 +283,7 @@ export async function prepareSync(options: StartRolesOptions): Promise<PreparedS
|
|
|
226
283
|
const authenticate = options.overrides?.syncAuthenticate ?? syncAuthenticator(options.buildId);
|
|
227
284
|
const registry = registerLiveQueries(options);
|
|
228
285
|
const origins = syncOriginsFrom(options.env);
|
|
229
|
-
const node =
|
|
286
|
+
const node = syncNode({
|
|
230
287
|
hub,
|
|
231
288
|
registry,
|
|
232
289
|
transport: options.runtime.transport,
|
|
@@ -244,6 +301,8 @@ export async function prepareSync(options: StartRolesOptions): Promise<PreparedS
|
|
|
244
301
|
// Who this node's OWN `/healthz` and `/readyz` tell the detail to: the app's one declaration,
|
|
245
302
|
// the same list the web role reads. Unset, the node keeps its default (the box itself).
|
|
246
303
|
...healthDetailPeersOf(configuredHttp()?.healthDetailPeers),
|
|
304
|
+
clientAddressOf: syncClientAddressOf(options.env),
|
|
305
|
+
...syncNodeCaps(options.runtime.realtime),
|
|
247
306
|
// Tier 1 is presence, and without a registry the node answers a topic subscribe with no member
|
|
248
307
|
// list at all — the KV bucket the transport just created would hold nothing and every `sync`
|
|
249
308
|
// container would run a presence-less protocol. It reads and writes `transport.shared`, so it
|