@ultimat3/cli 24.0.0 → 25.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +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 +66 -87
- package/src/role-sync.ts +65 -6
- package/src/role-wake.ts +2 -3
- 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 +19 -30
- 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 +36 -15
- 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/runtime-jobs.ts
CHANGED
|
@@ -1,60 +1,120 @@
|
|
|
1
|
-
// `jobs.queues`, `jobs.concurrency` and
|
|
2
|
-
// `
|
|
3
|
-
//
|
|
1
|
+
// The queues a `worker` serves and the slots each gets: `jobs.queues`, `jobs.concurrency` and
|
|
2
|
+
// `jobs.visibilityTimeoutMs` off the app's own `app.config.ts`, and `WORKER_QUEUES` off the
|
|
3
|
+
// Deployment's env — the one per-Deployment answer, since every Deployment runs the same image and
|
|
4
|
+
// the same config (issues #673, #676).
|
|
4
5
|
|
|
5
|
-
import type { AppConfig } from '@ultimat3/core';
|
|
6
|
+
import type { AppConfig, JobsConcurrency } from '@ultimat3/core';
|
|
7
|
+
import { ConfigInvalidError, logger, renderCauseValue, renderFixShellArg } from '@ultimat3/core';
|
|
6
8
|
import { registeredJobs, type WorkerOptions } from '@ultimat3/jobs';
|
|
9
|
+
import type { Env } from './runtime-bindings';
|
|
10
|
+
|
|
11
|
+
/** The env key a worker Deployment names its queues with: `WORKER_QUEUES=banks,banks-long`. */
|
|
12
|
+
export const WORKER_QUEUES_ENV = 'WORKER_QUEUES';
|
|
7
13
|
|
|
8
14
|
/** What the worker takes from the config. An absent number keeps the worker's own default. */
|
|
9
15
|
export interface WorkerConfig {
|
|
10
16
|
readonly queues: readonly string[];
|
|
11
|
-
|
|
17
|
+
/**
|
|
18
|
+
* `true` when `WORKER_QUEUES` named `queues`: they are served EXACTLY, never widened by the
|
|
19
|
+
* queues registered jobs are enqueued on. `false` is the union `workerQueuesFor` explains.
|
|
20
|
+
*/
|
|
21
|
+
readonly exact: boolean;
|
|
22
|
+
readonly concurrency: JobsConcurrency | undefined;
|
|
12
23
|
readonly visibilityTimeoutMs: number | undefined;
|
|
13
24
|
}
|
|
14
25
|
|
|
15
26
|
/** A root with no `app.config.ts`: no queue of its own, and the worker's own timings. */
|
|
16
27
|
const NO_WORKER_CONFIG: WorkerConfig = Object.freeze({
|
|
17
28
|
queues: [],
|
|
29
|
+
exact: false,
|
|
18
30
|
concurrency: undefined,
|
|
19
31
|
visibilityTimeoutMs: undefined,
|
|
20
32
|
});
|
|
21
33
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
34
|
+
/**
|
|
35
|
+
* `WORKER_QUEUES`, read: `undefined` when unset or blank (`ROLE`'s rule — an empty value is a
|
|
36
|
+
* template that said nothing), else its names trimmed and deduplicated in the order written. An
|
|
37
|
+
* empty entry (`banks,,long`) is REFUSED, not skipped: it is a queue somebody meant to name, and
|
|
38
|
+
* the worker that dropped it would leave that queue's jobs waiting with nothing in the log.
|
|
39
|
+
*/
|
|
40
|
+
export function workerQueuesFromEnv(env: Env): readonly string[] | undefined {
|
|
41
|
+
const raw = env[WORKER_QUEUES_ENV];
|
|
42
|
+
if (raw === undefined || raw.trim() === '') return undefined;
|
|
43
|
+
const names = raw.split(',').map((name) => name.trim());
|
|
44
|
+
const named = [...new Set(names.filter((name) => name !== ''))];
|
|
45
|
+
if (!names.includes('')) return named;
|
|
46
|
+
throw new ConfigInvalidError({
|
|
47
|
+
cause: `${WORKER_QUEUES_ENV} ${renderCauseValue(raw)} contains an empty queue name — every comma-separated entry must name a queue`,
|
|
48
|
+
fix: `WORKER_QUEUES=${renderFixShellArg(named.join(','), '<queue>,<queue>')} on this Deployment — or unset WORKER_QUEUES to serve jobs.queues and every registered job's queue`,
|
|
49
|
+
meta: { key: WORKER_QUEUES_ENV },
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function workerConfigOf(config: AppConfig | undefined, env: Env = {}): WorkerConfig {
|
|
54
|
+
const fromEnv = workerQueuesFromEnv(env);
|
|
55
|
+
const base =
|
|
56
|
+
config === undefined
|
|
57
|
+
? NO_WORKER_CONFIG
|
|
58
|
+
: {
|
|
59
|
+
queues: config.jobs.queues,
|
|
60
|
+
exact: false,
|
|
61
|
+
concurrency: config.jobs.concurrency,
|
|
62
|
+
visibilityTimeoutMs: config.jobs.visibilityTimeoutMs,
|
|
63
|
+
};
|
|
64
|
+
return fromEnv === undefined ? base : { ...base, queues: fromEnv, exact: true };
|
|
26
65
|
}
|
|
27
66
|
|
|
28
67
|
/**
|
|
29
68
|
* The queues a worker serves: the configured ones, then every queue a registered job is enqueued
|
|
30
69
|
* on. The union and not the list, decided 2026-09-23: `x new` and the deployed demo configure
|
|
31
70
|
* `['<app>-default']` while a `job()` naming no queue lands on `default`, so obeying the list alone
|
|
32
|
-
* would leave every such job queued for ever — the silent direction. `
|
|
33
|
-
*
|
|
71
|
+
* would leave every such job queued for ever — the silent direction. `exact` (a Deployment that
|
|
72
|
+
* set `WORKER_QUEUES`) is the list alone: that operator split the queues across Deployments on
|
|
73
|
+
* purpose, and widening one to the rest is what made their grace periods mean nothing (#673).
|
|
74
|
+
* `undefined` when nothing is served, which is `jobWorker`'s own `['default']`.
|
|
34
75
|
*/
|
|
35
76
|
export function workerQueuesFor(
|
|
36
77
|
configured: readonly string[],
|
|
37
78
|
registered: readonly string[],
|
|
79
|
+
exact = false,
|
|
38
80
|
): readonly string[] | undefined {
|
|
39
|
-
const served = [...new Set([...configured, ...registered])];
|
|
81
|
+
const served = [...new Set(exact ? configured : [...configured, ...registered])];
|
|
40
82
|
return served.length === 0 ? undefined : served;
|
|
41
83
|
}
|
|
42
84
|
|
|
85
|
+
/**
|
|
86
|
+
* An exact worker's one boot line about what it leaves: the queues registered jobs are enqueued
|
|
87
|
+
* on that it will never claim. A warning and not a refusal — another Deployment serving them is
|
|
88
|
+
* the whole point of the split — but this process cannot see that one, so it says what to check.
|
|
89
|
+
*/
|
|
90
|
+
function reportUnserved(served: readonly string[], registered: readonly string[]): void {
|
|
91
|
+
const queues = [...new Set(registered)].filter((queue) => !served.includes(queue)).sort();
|
|
92
|
+
if (queues.length === 0) return;
|
|
93
|
+
logger.warn('jobs.worker.queue-unserved', {
|
|
94
|
+
queues,
|
|
95
|
+
served,
|
|
96
|
+
fix: `x jobs ls --json — every queue listed with no worker needs one: WORKER_QUEUES=<queue>,<queue> on another worker Deployment, or add it to this one's`,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
43
100
|
type WorkerConfigOptions = Pick<WorkerOptions, 'queues' | 'concurrency' | 'visibilityTimeoutMs'>;
|
|
44
101
|
|
|
45
102
|
/**
|
|
46
|
-
* The `
|
|
103
|
+
* The `jobWorker` options the config supplies, read at START so every job the app registered
|
|
47
104
|
* on import is counted. A key the config leaves unset is omitted, never passed as `undefined`, so
|
|
48
105
|
* the worker's own default applies (`exactOptionalPropertyTypes`).
|
|
49
106
|
*/
|
|
50
107
|
export function workerOptionsFor(config: WorkerConfig | undefined): WorkerConfigOptions {
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
);
|
|
108
|
+
const registered = registeredJobs().map((handle) => handle.queue);
|
|
109
|
+
const exact = config?.exact === true;
|
|
110
|
+
const queues = workerQueuesFor(config?.queues ?? [], registered, exact);
|
|
111
|
+
if (exact) reportUnserved(queues ?? [], registered);
|
|
112
|
+
// Passed as written: `jobWorker` reads a table by own key and runs a queue it does not name at
|
|
113
|
+
// core's `JOBS_CONCURRENCY_DEFAULT` — the one slot default, so no second table is built here.
|
|
114
|
+
const concurrency = config?.concurrency;
|
|
55
115
|
return {
|
|
56
116
|
...(queues === undefined ? {} : { queues }),
|
|
57
|
-
...(
|
|
117
|
+
...(concurrency === undefined ? {} : { concurrency }),
|
|
58
118
|
...(config?.visibilityTimeoutMs === undefined
|
|
59
119
|
? {}
|
|
60
120
|
: { visibilityTimeoutMs: config.visibilityTimeoutMs }),
|
package/src/runtime-live-feed.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Where a `sync` node's changes come from when this process boots one. Production decodes the
|
|
2
|
-
// write-ahead log (`x dev --role replicator`, `
|
|
2
|
+
// write-ahead log (`x dev --role replicator`, `postgresChangeFeed()`, a real `DATABASE_URL`).
|
|
3
3
|
// The embedded database has no walsender, and until 2026-09-05 nothing stood in for it: a live
|
|
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.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The `mail` section of `app.config.ts` the boot obeys: `mail.retainMime`, handed to
|
|
2
|
+
// `@ultimat3/mail`'s `selectMailDriver` — the one option an environment cannot carry. Selection
|
|
3
|
+
// still reads env for WHICH transport; the config only says what the chosen one keeps.
|
|
4
|
+
|
|
5
|
+
import type { AppConfig } from '@ultimat3/core';
|
|
6
|
+
import type { MailEnvironment, MailSelection, MailSelectOptions } from '@ultimat3/mail';
|
|
7
|
+
import { selectMailDriver } from '@ultimat3/mail';
|
|
8
|
+
|
|
9
|
+
/** No config file, or `retainMime: false`, is no option at all — never `retainMime: undefined`. */
|
|
10
|
+
export function mailSelectOptionsOf(config: AppConfig | undefined): MailSelectOptions {
|
|
11
|
+
const retain = config?.mail.retainMime ?? false;
|
|
12
|
+
if (retain === false) return {};
|
|
13
|
+
return { retainMime: retain.maxBytes === undefined ? {} : { maxBytes: retain.maxBytes } };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The boot's mail selection. Pure, as `selectMailDriver` is: it builds a transport and dials
|
|
18
|
+
* nothing, so a cap above mail's ceiling or retention asked of Resend refuses the boot here.
|
|
19
|
+
*/
|
|
20
|
+
export const selectAppMailDriver = (
|
|
21
|
+
env: MailEnvironment,
|
|
22
|
+
config: AppConfig | undefined,
|
|
23
|
+
): MailSelection =>
|
|
24
|
+
// `retainMimeKey`: mail judges the cap before it picks a transport — the ceiling is its number —
|
|
25
|
+
// and names the `app.config.ts` key in the refusal, so the boot restates no check of its own.
|
|
26
|
+
selectMailDriver(env, { ...mailSelectOptionsOf(config), retainMimeKey: 'mail.retainMime' });
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// the operator step for sealed MFA secrets). `x doctor` asks the same question only when someone
|
|
3
3
|
// runs it; an unsealed secret is a user refused at the second factor, so the boot says so too.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { countUnsealedMfaSecrets, postgresAuthAdapter } from '@ultimat3/auth';
|
|
6
6
|
import { logger } from '@ultimat3/core';
|
|
7
7
|
import type { DbClient } from '@ultimat3/db';
|
|
8
8
|
|
|
@@ -14,7 +14,7 @@ import type { DbClient } from '@ultimat3/db';
|
|
|
14
14
|
export async function warnUnsealedMfaSecrets(client: DbClient): Promise<number> {
|
|
15
15
|
let unsealed = 0;
|
|
16
16
|
try {
|
|
17
|
-
unsealed = await countUnsealedMfaSecrets({ adapter:
|
|
17
|
+
unsealed = await countUnsealedMfaSecrets({ adapter: postgresAuthAdapter(client) });
|
|
18
18
|
} catch {
|
|
19
19
|
return 0;
|
|
20
20
|
}
|
package/src/runtime-overrides.ts
CHANGED
|
@@ -35,7 +35,7 @@ export interface RuntimeOverrides {
|
|
|
35
35
|
readonly mail?: MailDriver;
|
|
36
36
|
/**
|
|
37
37
|
* Replaces the `NATS_URL` selection. Already connected when it arrives, and NOT closed by
|
|
38
|
-
* `stop()`: whoever built it owns its socket, exactly as `
|
|
38
|
+
* `stop()`: whoever built it owns its socket, exactly as `httpServer` does not close a
|
|
39
39
|
* `rateLimitStore` it was handed.
|
|
40
40
|
*/
|
|
41
41
|
readonly transport?: Transport;
|
|
@@ -53,12 +53,12 @@ export interface RuntimeOverrides {
|
|
|
53
53
|
*/
|
|
54
54
|
readonly rateLimitStore?: RateLimitStore;
|
|
55
55
|
/**
|
|
56
|
-
* Where regenerated ISR pages live. Omitted, `
|
|
56
|
+
* Where regenerated ISR pages live. Omitted, `isrController` keeps a per-process memory
|
|
57
57
|
* store — twelve replicas then hold twelve of them, and a purge tag reaches one twelfth of the
|
|
58
58
|
* fleet.
|
|
59
59
|
*/
|
|
60
60
|
readonly isrStore?: IsrStore;
|
|
61
|
-
/** Prepended to the pipeline by `
|
|
61
|
+
/** Prepended to the pipeline by `httpServer`, which `startRoles` never passed one. */
|
|
62
62
|
readonly middleware?: readonly Middleware[];
|
|
63
63
|
/**
|
|
64
64
|
* Plain HTTP routes at paths no primitive projects to — `/.well-known/oauth-authorization-server`,
|
package/src/runtime-purge.ts
CHANGED
|
@@ -96,7 +96,7 @@ function notifyTargets(retention: InboxRetention): readonly PurgeTarget[] {
|
|
|
96
96
|
},
|
|
97
97
|
{
|
|
98
98
|
name: 'x_notify_digests',
|
|
99
|
-
// The job's clock, and the store's own window (`
|
|
99
|
+
// The job's clock, and the store's own window (`postgresDigestStore({ retentionMs })`, a week
|
|
100
100
|
// by default) — a closed window only its dead-lettered flush would ever have drained.
|
|
101
101
|
purgeExpired: (nowMs: number): Promise<number> => purgeNotifyDigests(nowMs),
|
|
102
102
|
},
|
package/src/runtime-queue.ts
CHANGED
|
@@ -11,22 +11,23 @@ import {
|
|
|
11
11
|
resetIdempotency,
|
|
12
12
|
setIdempotencyStore,
|
|
13
13
|
} from '@ultimat3/action';
|
|
14
|
-
import type {
|
|
14
|
+
import type { PgliteClient, PostgresClient } from '@ultimat3/db';
|
|
15
15
|
import {
|
|
16
16
|
baseClient,
|
|
17
|
-
createPgliteClient,
|
|
18
|
-
createPostgresClient,
|
|
19
17
|
currentTx,
|
|
18
|
+
dbExecutor,
|
|
19
|
+
pgliteClient,
|
|
20
20
|
pgliteDataDir,
|
|
21
|
+
postgresClient,
|
|
21
22
|
setDbClient,
|
|
22
23
|
} from '@ultimat3/db';
|
|
23
24
|
import type { Tx } from '@ultimat3/entity';
|
|
24
|
-
import type { EventBus, JobDriver, OutboxStore
|
|
25
|
+
import type { EventBus, JobDriver, OutboxStore } from '@ultimat3/jobs';
|
|
25
26
|
import {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
outboxJobsFacade,
|
|
28
|
+
postgresEventBus,
|
|
29
|
+
postgresJobDriver,
|
|
30
|
+
postgresOutboxStore,
|
|
30
31
|
resetEventBus,
|
|
31
32
|
resetJobDriver,
|
|
32
33
|
resetJobsFacade,
|
|
@@ -99,30 +100,16 @@ export function startDb(services: DevServices, env: ReplicaEnv): StartedDb {
|
|
|
99
100
|
// Linked at boot, from the app's own migrations: PGlite cannot `create extension` one it
|
|
100
101
|
// was not handed, and the schema dump's scratch replay already asks this same reader — an
|
|
101
102
|
// app that replayed in the gate and failed in `x dev` was the two disagreeing.
|
|
102
|
-
|
|
103
|
+
pgliteClient({
|
|
103
104
|
dataDir: pgliteDataDir(binding.url),
|
|
104
105
|
extensions: () => appExtensions(services.root),
|
|
105
106
|
})
|
|
106
|
-
:
|
|
107
|
+
: postgresClient({ url: binding.url });
|
|
107
108
|
const attached = attachReplica(client, replicaUrlFor(binding, env));
|
|
108
109
|
setDbClient(attached.client);
|
|
109
110
|
return { client, replica: attached.replica };
|
|
110
111
|
}
|
|
111
112
|
|
|
112
|
-
/**
|
|
113
|
-
* `@ultimat3/jobs` deliberately depends on no database package: Postgres reaches it as an
|
|
114
|
-
* injected `PgExecutor`. Boot code is what supplies one, and this is the boot.
|
|
115
|
-
*
|
|
116
|
-
* The fragment is assembled by hand rather than through `sql`` ` because the driver hands over
|
|
117
|
-
* `$1..$n` text it wrote itself plus already-bound values — there is no interpolation to guard.
|
|
118
|
-
*/
|
|
119
|
-
export function pgExecutorFor(client: DbClient): PgExecutor {
|
|
120
|
-
return {
|
|
121
|
-
query: <R>(text: string, values: readonly unknown[]): Promise<readonly R[]> =>
|
|
122
|
-
client.query<R>({ text, values } satisfies SqlFragment),
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
|
|
126
113
|
/**
|
|
127
114
|
* Every table this process's framework packages own, applied before anything reads one.
|
|
128
115
|
*
|
|
@@ -183,18 +170,20 @@ async function startJobs(
|
|
|
183
170
|
schema: SchemaMode,
|
|
184
171
|
): Promise<RunningQueue> {
|
|
185
172
|
await applySchema(client, schema);
|
|
186
|
-
|
|
187
|
-
|
|
173
|
+
// Bound to `client`, never the ambient `db()`: the queue, the event bus and the idempotency
|
|
174
|
+
// reservations stay on the boot's pool whatever transaction is open.
|
|
175
|
+
const executor = dbExecutor(() => client);
|
|
176
|
+
const driver = overrides?.jobs ?? postgresJobDriver({ executor });
|
|
188
177
|
setJobDriver(driver);
|
|
189
|
-
const outbox =
|
|
178
|
+
const outbox = postgresOutboxStore({
|
|
190
179
|
executor,
|
|
191
180
|
// The open transaction is a client on its own connection; the `Tx` token is not that object.
|
|
192
|
-
txExecutor: () =>
|
|
181
|
+
txExecutor: () => dbExecutor(() => currentTx() ?? client),
|
|
193
182
|
});
|
|
194
183
|
setJobsFacade(
|
|
195
|
-
|
|
184
|
+
outboxJobsFacade({ store: outbox, driver }, () => currentTx() as unknown as Tx | undefined),
|
|
196
185
|
);
|
|
197
|
-
const events =
|
|
186
|
+
const events = postgresEventBus({ executor });
|
|
198
187
|
setEventBus(events);
|
|
199
188
|
const idempotency = postgresIdempotencyStore({
|
|
200
189
|
executor,
|
package/src/runtime-realtime.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// The `realtime` section for the boot, out of the config `startServices` loads once
|
|
2
|
+
// (`app-config-load.ts`). Until 22.0.0 no boot read `enabled`, `transport` or `urlEnv` and
|
|
3
|
+
// `NATS_URL` alone decided the bus; the two per-actor caps are read by the `sync` role.
|
|
4
4
|
|
|
5
5
|
import type { AppConfig, RealtimeConfig } from '@ultimat3/core';
|
|
6
6
|
|
|
@@ -9,6 +9,8 @@ export const REALTIME_DEFAULTS: RealtimeConfig = Object.freeze({
|
|
|
9
9
|
enabled: true,
|
|
10
10
|
transport: 'memory',
|
|
11
11
|
urlEnv: undefined,
|
|
12
|
+
maxSubscriptionsPerActor: undefined,
|
|
13
|
+
maxSocketsPerActor: undefined,
|
|
12
14
|
});
|
|
13
15
|
|
|
14
16
|
/**
|
package/src/runtime-render.ts
CHANGED
|
@@ -3,12 +3,10 @@
|
|
|
3
3
|
// document (head + the route's component, the surface's CSS LINKED, `style-bundle.ts`), it never
|
|
4
4
|
// decides what a mode means or what headers it earns.
|
|
5
5
|
|
|
6
|
-
// why: Bun ships no path API; an island's file is its route file's directory joined to its `src`.
|
|
7
|
-
import { posix } from 'node:path';
|
|
8
6
|
import { actionPathStyle } from '@ultimat3/action';
|
|
9
7
|
import { clientScopeOf } from '@ultimat3/auth';
|
|
10
|
-
import type { Ctx } from '@ultimat3/core';
|
|
11
|
-
import { CLIENT_SCOPE_HEADER,
|
|
8
|
+
import type { Actor, Ctx } from '@ultimat3/core';
|
|
9
|
+
import { CLIENT_SCOPE_HEADER, singleFlight } from '@ultimat3/core';
|
|
12
10
|
import type {
|
|
13
11
|
RouteMeta as HttpRouteMeta,
|
|
14
12
|
RedirectIntent,
|
|
@@ -18,20 +16,16 @@ import type {
|
|
|
18
16
|
UltimateRequest,
|
|
19
17
|
} from '@ultimat3/http';
|
|
20
18
|
import { asCtx, html, NO_STORE, redirect, stream, takeRedirect } from '@ultimat3/http';
|
|
21
|
-
import { currentLocale
|
|
19
|
+
import { currentLocale } from '@ultimat3/i18n';
|
|
22
20
|
import type { IslandCollector, RenderResult, RouteData, RouteEntry } from '@ultimat3/render';
|
|
23
21
|
import {
|
|
24
|
-
clientBootTags,
|
|
25
22
|
clientPathStyleTags,
|
|
26
23
|
clientPersistTags,
|
|
27
24
|
clientScopeTag,
|
|
28
25
|
clientSyncTags,
|
|
29
|
-
createIslandCollector,
|
|
30
26
|
documentCarriesScope,
|
|
31
27
|
headFromMeta,
|
|
32
28
|
hydrateRuntime,
|
|
33
|
-
islandModuleId,
|
|
34
|
-
islandModuleIds,
|
|
35
29
|
metaContextFor,
|
|
36
30
|
renderHead,
|
|
37
31
|
routeDataFor,
|
|
@@ -43,7 +37,6 @@ import {
|
|
|
43
37
|
import type { IsrController } from '@ultimat3/render/server';
|
|
44
38
|
import {
|
|
45
39
|
contentHash,
|
|
46
|
-
isrKey,
|
|
47
40
|
ROOT_ELEMENT_ID,
|
|
48
41
|
renderComponent,
|
|
49
42
|
renderSsr,
|
|
@@ -52,14 +45,15 @@ import {
|
|
|
52
45
|
streamResult,
|
|
53
46
|
} from '@ultimat3/render/server';
|
|
54
47
|
import type { DocumentOptions } from './document-options';
|
|
55
|
-
import { realtimeIslandFiles } from './island-realtime';
|
|
56
48
|
import {
|
|
57
49
|
type NavigationDocumentHead,
|
|
58
50
|
navigationMetaOf,
|
|
59
51
|
navigationTagsOf,
|
|
60
52
|
principalRelocation,
|
|
61
53
|
} from './page-navigation';
|
|
54
|
+
import { bootScript, collectorFor } from './route-islands';
|
|
62
55
|
import { attachedIsr } from './runtime-isr';
|
|
56
|
+
import { isrOutcome } from './runtime-isr-outcome';
|
|
63
57
|
import type { StaticResult } from './static-document';
|
|
64
58
|
import { createStaticMemo, staticMemoKey, staticResponse } from './static-document';
|
|
65
59
|
import { styleBundle } from './style-bundle';
|
|
@@ -119,9 +113,11 @@ const headFor = async (
|
|
|
119
113
|
seoRenderers({
|
|
120
114
|
path: url.pathname,
|
|
121
115
|
baseUrl: options.origin ?? url.origin,
|
|
116
|
+
// The default is the cluster's own head (`alternates` is default-first by contract), so
|
|
117
|
+
// `x-default` comes from the list the hreflang tags do — never a second locale read.
|
|
122
118
|
localization: {
|
|
123
119
|
locale: meta.locale,
|
|
124
|
-
defaultLocale:
|
|
120
|
+
defaultLocale: meta.alternates[0]?.locale ?? meta.locale,
|
|
125
121
|
alternates: meta.alternates,
|
|
126
122
|
},
|
|
127
123
|
}),
|
|
@@ -170,6 +166,10 @@ const styleTag = (entry: RouteEntry): string => {
|
|
|
170
166
|
return href === undefined ? '' : `<link rel="stylesheet" href="${href}">`;
|
|
171
167
|
};
|
|
172
168
|
|
|
169
|
+
/** The surface stylesheet, then the app's brand — the order that lets the brand win the cascade. */
|
|
170
|
+
const stylesFor = (entry: RouteEntry, options: DocumentOptions): string =>
|
|
171
|
+
styleTag(entry) + (options.brandHead ?? '');
|
|
172
|
+
|
|
173
173
|
/**
|
|
174
174
|
* The route's rendered body, inside the hydration root. Every mode goes through here — an empty
|
|
175
175
|
* root is what a module exporting no component renders, and nothing else: `spa` was the one mode
|
|
@@ -201,46 +201,6 @@ export async function routeBody(
|
|
|
201
201
|
return `<div id="${ROOT_ELEMENT_ID}">${html}</div>`;
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
-
/**
|
|
205
|
-
* One collector per RENDER, never module-global: two requests render different params, and a
|
|
206
|
-
* shared collector would bill one page for the other's islands. `hydrate` comes off the route, so
|
|
207
|
-
* an island never declares its own timing, and `resolve` is the build's — identity when nothing
|
|
208
|
-
* built any, which fails at the first island by name rather than emitting an unusable entry.
|
|
209
|
-
*/
|
|
210
|
-
/**
|
|
211
|
-
* Realtime's page boot, as one deferred script — or nothing. Two conditions, both exact: the
|
|
212
|
-
* document carries a principal scope (restoring persisted records and replaying queued writes are
|
|
213
|
-
* per principal; a shareable document has neither), AND one of the islands this render emitted
|
|
214
|
-
* reaches `@ultimat3/realtime` (a page whose islands never touch a record has nothing to restore
|
|
215
|
-
* into and no write to replay). After the body, because which islands rendered is a fact the walk
|
|
216
|
-
* just recorded; still before the hydration runtime, so it runs first among the deferred scripts.
|
|
217
|
-
*/
|
|
218
|
-
function bootScript(
|
|
219
|
-
entry: RouteEntry,
|
|
220
|
-
islands: IslandCollector,
|
|
221
|
-
options: DocumentOptions,
|
|
222
|
-
scope: string | undefined,
|
|
223
|
-
): string {
|
|
224
|
-
if (scope === undefined || options.sync === undefined) return '';
|
|
225
|
-
const rendered = new Set(islandModuleIds(islands.directives));
|
|
226
|
-
if (rendered.size === 0) return '';
|
|
227
|
-
// An island's module id is derived from its `src`, written relative to the page that renders it:
|
|
228
|
-
// each realtime island file, spelled from THIS page, is the id its directive would carry.
|
|
229
|
-
const pageDir = posix.dirname(entry.file);
|
|
230
|
-
const reaches = [...realtimeIslandFiles()].some((file) => {
|
|
231
|
-
const src = posix.relative(pageDir, file);
|
|
232
|
-
return rendered.has(islandModuleId(src.startsWith('.') ? src : `./${src}`));
|
|
233
|
-
});
|
|
234
|
-
return reaches ? renderHead(clientBootTags(options.sync)) : '';
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
const collectorFor = (entry: RouteEntry, options: DocumentOptions): IslandCollector =>
|
|
238
|
-
createIslandCollector({
|
|
239
|
-
file: entry.file,
|
|
240
|
-
hydrate: entry.config.hydrate,
|
|
241
|
-
...(options.resolveIsland === undefined ? {} : { resolve: options.resolveIsland(entry.file) }),
|
|
242
|
-
});
|
|
243
|
-
|
|
244
204
|
/**
|
|
245
205
|
* Head + body for one route render. Exported because the build's prerenderer must emit the same
|
|
246
206
|
* document `x dev` serves — two document builders is how a page that works in dev ships broken.
|
|
@@ -253,6 +213,42 @@ export async function routeDocument(
|
|
|
253
213
|
return documentFrom(entry, ctx, await routeDataFor(entry.config, ctx), options);
|
|
254
214
|
}
|
|
255
215
|
|
|
216
|
+
/**
|
|
217
|
+
* The document a request by `actor` is SERVED: `routeDocument` plus the scope the response would
|
|
218
|
+
* carry, which is what earns the page boot. The build weighs an app/ page through this, because a
|
|
219
|
+
* budget is a promise about what that page's browser downloads — and an unscoped render of a
|
|
220
|
+
* scoped page weighed a runtime chunk it never fetches and no boot it always does (#505).
|
|
221
|
+
*/
|
|
222
|
+
export async function servedDocument(
|
|
223
|
+
entry: RouteEntry,
|
|
224
|
+
ctx: DevRouteData,
|
|
225
|
+
actor: Actor,
|
|
226
|
+
options: DocumentOptions & { readonly buildId: string },
|
|
227
|
+
): Promise<string> {
|
|
228
|
+
const data = await routeDataFor(entry.config, ctx);
|
|
229
|
+
return documentFrom(entry, ctx, data, options, documentScope(entry, options.buildId, actor));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Whether, and as whom, a mode's response is scoped — ONE decision for the request path and for
|
|
234
|
+
* `servedDocument`. A stream is always `private, no-store`; an ssr page carries the scope exactly
|
|
235
|
+
* when the headers `renderSsr` sends are private (an ungated page is `public, s-maxage`, and a CDN
|
|
236
|
+
* may hand it to anyone); a static or isr document is shareable by construction.
|
|
237
|
+
*/
|
|
238
|
+
function documentScope(entry: RouteEntry, buildId: string, actor: Actor): string | undefined {
|
|
239
|
+
switch (entry.config.render) {
|
|
240
|
+
case 'static':
|
|
241
|
+
case 'isr':
|
|
242
|
+
return undefined;
|
|
243
|
+
case 'stream':
|
|
244
|
+
return clientScopeOf(actor);
|
|
245
|
+
default:
|
|
246
|
+
return documentCarriesScope(ssrHeaders(entry, { buildId }))
|
|
247
|
+
? clientScopeOf(actor)
|
|
248
|
+
: undefined;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
256
252
|
/**
|
|
257
253
|
* The document from data ALREADY resolved. Split from `routeDocument` so one request resolves
|
|
258
254
|
* `load` exactly once: `stream` renders head and body separately, and resolving in each would let
|
|
@@ -269,23 +265,23 @@ async function documentFrom(
|
|
|
269
265
|
options: DocumentOptions,
|
|
270
266
|
scope?: string,
|
|
271
267
|
): Promise<string> {
|
|
272
|
-
const islands = collectorFor(entry, options);
|
|
268
|
+
const islands = collectorFor(entry, options, scope);
|
|
273
269
|
const [head, body] = await Promise.all([
|
|
274
270
|
headFor(entry, ctx, data, options, scope),
|
|
275
271
|
routeBody(entry, ctx, data, islands),
|
|
276
272
|
]);
|
|
277
273
|
return (
|
|
278
|
-
`<!doctype html><html lang="${lang()}"><head>${head}${
|
|
274
|
+
`<!doctype html><html lang="${lang()}"><head>${head}${stylesFor(entry, options)}</head>` +
|
|
279
275
|
`<body>${body}${bootScript(entry, islands, options, scope)}${hydrateRuntime(islands.directives)}</body></html>`
|
|
280
276
|
);
|
|
281
277
|
}
|
|
282
278
|
|
|
279
|
+
/** Every mode but `isr`, which `renderIsr` answers: its `load` runs only when the store misses. */
|
|
283
280
|
async function resultFor(
|
|
284
281
|
entry: RouteEntry,
|
|
285
282
|
request: DevRouteData,
|
|
286
283
|
data: RouteData,
|
|
287
284
|
options: DevRenderOptions,
|
|
288
|
-
isr: IsrController,
|
|
289
285
|
ctx: Ctx,
|
|
290
286
|
): Promise<RenderResult> {
|
|
291
287
|
const url = new URL(request.url);
|
|
@@ -299,57 +295,40 @@ async function resultFor(
|
|
|
299
295
|
const body = await documentFrom(entry, request, data, options);
|
|
300
296
|
return { status, headers: staticHeaders(contentHash(body), options.buildId), body };
|
|
301
297
|
}
|
|
302
|
-
case 'isr': {
|
|
303
|
-
// `isrKey(url, locale)`, never `url.pathname`: the query is part of what was rendered — this
|
|
304
|
-
// route's own `meta` reads `data.url` — so two URLs differing only in their query are two
|
|
305
|
-
// documents. Keyed on the pathname alone, the first render answered every later query
|
|
306
|
-
// string (#171). Render owns the derivation so no second caller can invent another.
|
|
307
|
-
// The locale is the second dimension and it is `ctx.locale`, the answer the `locale` stage
|
|
308
|
-
// already negotiated for THIS request — never `currentLocale()`, which would read the same
|
|
309
|
-
// value through an ambient store the key does not need.
|
|
310
|
-
// `{ html, status }`, never the bare string: the entry stores the status beside the HTML
|
|
311
|
-
// and serves it on every hit, so a 404 under `isr` is a 404 for its whole TTL.
|
|
312
|
-
const served = await isr.serve(isrKey(url, ctx.locale), async () => ({
|
|
313
|
-
html: await documentFrom(entry, request, data, options),
|
|
314
|
-
status,
|
|
315
|
-
}));
|
|
316
|
-
return served.result;
|
|
317
|
-
}
|
|
318
298
|
case 'stream': {
|
|
319
299
|
// The shell IS the component: nothing can yet mark a subtree as a hole. Solid's `Suspense`
|
|
320
300
|
// is not the missing piece and never will be here — it calls `getContextId()`, which throws
|
|
321
301
|
// outside a Solid renderer, and this package's JSX factory is inert by design. A hole marker
|
|
322
302
|
// has to be the framework's own. Until it exists the first flush carries the whole body —
|
|
323
303
|
// correct output, no streaming benefit.
|
|
324
|
-
|
|
304
|
+
// A stream is always `private, no-store` (`streamResult`), so it always carries the scope.
|
|
305
|
+
const scope = documentScope(entry, options.buildId, ctx.actor);
|
|
306
|
+
const islands = collectorFor(entry, options, scope);
|
|
325
307
|
const [head, shell] = await Promise.all([
|
|
326
|
-
|
|
327
|
-
headFor(entry, request, data, options, clientScopeOf(ctx.actor)),
|
|
308
|
+
headFor(entry, request, data, options, scope),
|
|
328
309
|
routeBody(entry, request, data, islands),
|
|
329
310
|
]);
|
|
330
311
|
return withScope(
|
|
331
312
|
streamResult(
|
|
332
313
|
{
|
|
333
|
-
head: `<!doctype html><html lang="${lang()}"><head>${head}${
|
|
314
|
+
head: `<!doctype html><html lang="${lang()}"><head>${head}${stylesFor(entry, options)}</head><body>`,
|
|
334
315
|
// The runtime rides the first flush, with the shell it boots. A later chunk would leave
|
|
335
316
|
// the window between flush one and the close with inert islands and no listeners on
|
|
336
317
|
// them — which is exactly the first-click-lost failure `interaction` replay exists for.
|
|
337
|
-
shell: `${shell}${bootScript(entry, islands, options,
|
|
318
|
+
shell: `${shell}${bootScript(entry, islands, options, scope)}${hydrateRuntime(islands.directives)}`,
|
|
338
319
|
holes: [],
|
|
339
320
|
},
|
|
340
321
|
{ buildId: options.buildId },
|
|
341
322
|
status,
|
|
342
323
|
),
|
|
343
|
-
|
|
324
|
+
scope,
|
|
344
325
|
);
|
|
345
326
|
}
|
|
346
327
|
default: {
|
|
347
328
|
// Asked of the headers `renderSsr` is about to send: a gated page is private and carries the
|
|
348
329
|
// scope; an ungated one is `public, s-maxage` and a CDN may hand it to anyone, so it carries
|
|
349
330
|
// none — absent, which core reads as "not rendered for anyone", never as anonymous.
|
|
350
|
-
const scope =
|
|
351
|
-
? clientScopeOf(ctx.actor)
|
|
352
|
-
: undefined;
|
|
331
|
+
const scope = documentScope(entry, options.buildId, ctx.actor);
|
|
353
332
|
return withScope(
|
|
354
333
|
await renderSsr(
|
|
355
334
|
{ entry, params: request.params, url, ctx },
|
|
@@ -429,7 +408,7 @@ export function appRoutes(options: DevRenderOptions): readonly Route[] {
|
|
|
429
408
|
const memo = createStaticMemo();
|
|
430
409
|
// A cold static key is ONE render however many requests arrive during it: the memo alone was
|
|
431
410
|
// check-then-act, so a burst at boot ran `load` and the render once per request.
|
|
432
|
-
const firstRender =
|
|
411
|
+
const firstRender = singleFlight();
|
|
433
412
|
return routeEntries().map((registered) => ({
|
|
434
413
|
method: 'GET' as const,
|
|
435
414
|
path: registered.path,
|
|
@@ -482,6 +461,7 @@ async function renderEntry(
|
|
|
482
461
|
isr: IsrController,
|
|
483
462
|
): Promise<Rendered> {
|
|
484
463
|
const data: DevRouteData = { url: request.url.href, params: ctx.params };
|
|
464
|
+
if (entry.config.render === 'isr') return renderIsr(entry, data, ctx, options, isr);
|
|
485
465
|
// ONCE per request, before the mode is chosen: every branch of `resultFor` reads this same
|
|
486
466
|
// object, so a route's `load` runs exactly once however its mode splits head from body.
|
|
487
467
|
const loaded = await routeDataFor(entry.config, data);
|
|
@@ -490,10 +470,25 @@ async function renderEntry(
|
|
|
490
470
|
// 3xx because a rendered document has no `Location`; this is the path that has one.
|
|
491
471
|
const to = takeRedirect(ctx);
|
|
492
472
|
if (to !== undefined) return { respond: () => loadRedirect(entry, to) };
|
|
493
|
-
const result = await resultFor(entry, data, loaded, options,
|
|
473
|
+
const result = await resultFor(entry, data, loaded, options, asCtx(ctx));
|
|
494
474
|
if (entry.config.render !== 'static' || typeof result.body !== 'string') {
|
|
495
475
|
return { respond: () => responseOf(result) };
|
|
496
476
|
}
|
|
497
477
|
const document: StaticResult = { ...result, body: result.body };
|
|
498
478
|
return { document, respond: () => responseOf(document) };
|
|
499
479
|
}
|
|
480
|
+
|
|
481
|
+
/** `isr`'s outcome (`runtime-isr-outcome.ts`) as this file's answer: a page, or the redirect. */
|
|
482
|
+
async function renderIsr(
|
|
483
|
+
entry: RouteEntry,
|
|
484
|
+
data: DevRouteData,
|
|
485
|
+
ctx: RequestContext,
|
|
486
|
+
options: DevRenderOptions,
|
|
487
|
+
isr: IsrController,
|
|
488
|
+
): Promise<Rendered> {
|
|
489
|
+
const document = (loaded: RouteData): Promise<string> =>
|
|
490
|
+
documentFrom(entry, data, loaded, options);
|
|
491
|
+
const outcome = await isrOutcome({ entry, data, ctx, isr, document });
|
|
492
|
+
if (outcome.kind === 'redirect') return { respond: () => loadRedirect(entry, outcome.to) };
|
|
493
|
+
return { respond: () => responseOf(outcome.result) };
|
|
494
|
+
}
|