@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/root-env.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// The `.env*` files of the APP ROOT, whatever directory `x` was started from. Bun auto-loads them
|
|
2
2
|
// from the process cwd only, while every command finds its root by walking up — so `x db migrate`
|
|
3
3
|
// run from `apps/web` found the app and none of its `.env`, and silently used the embedded
|
|
4
|
-
// database. Loaded once, after the root is known, never overriding a
|
|
4
|
+
// database. Loaded once, after the root is known, never overriding a real environment variable.
|
|
5
5
|
|
|
6
|
-
import { isAbsolute, join, resolve } from 'node:path'; // why: Bun ships no path join/resolve.
|
|
6
|
+
import { isAbsolute, join, relative, resolve } from 'node:path'; // why: Bun ships no path join/resolve.
|
|
7
7
|
import { findAppRoot } from './app-root';
|
|
8
8
|
|
|
9
9
|
type Env = Readonly<Record<string, string | undefined>>;
|
|
@@ -25,43 +25,172 @@ export const rootEnvFiles = (envName: string): readonly string[] => [
|
|
|
25
25
|
`.env.${envName}.local`,
|
|
26
26
|
];
|
|
27
27
|
|
|
28
|
+
const QUOTES = new Set(['"', "'", '`']);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The quoted value opening at `text[open]`, read the way Bun reads it: across lines until the first
|
|
32
|
+
* unescaped closing quote, a backslash keeping the character after it (so `\"` never closes), and
|
|
33
|
+
* only double quotes turning `\n` and `\r` into the characters. `undefined` when the quote never
|
|
34
|
+
* closes — Bun then keeps the line as written, opening quote and all.
|
|
35
|
+
*/
|
|
36
|
+
function quoted(text: string, open: number): { value: string; end: number } | undefined {
|
|
37
|
+
const quote = text[open];
|
|
38
|
+
let value = '';
|
|
39
|
+
for (let at = open + 1; at < text.length; at += 1) {
|
|
40
|
+
const char = text[at];
|
|
41
|
+
if (char === quote) return { value, end: at + 1 };
|
|
42
|
+
if (char === '\\' && at + 1 < text.length) {
|
|
43
|
+
const next = text[at + 1];
|
|
44
|
+
value +=
|
|
45
|
+
quote !== '"'
|
|
46
|
+
? `${char}${next}`
|
|
47
|
+
: next === 'n'
|
|
48
|
+
? '\n'
|
|
49
|
+
: next === 'r'
|
|
50
|
+
? '\r'
|
|
51
|
+
: `${char}${next}`;
|
|
52
|
+
at += 1;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
value += char;
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
28
60
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* belongs in the process environment, which always wins here anyway.
|
|
61
|
+
* Bun's expansion pass, the one part of it reproduced: `\$` is a literal `$` in every kind of
|
|
62
|
+
* value, quoted or not. Run after the quote pass, which is why `"a\\$x"` reads `a\$x` in Bun too.
|
|
32
63
|
*/
|
|
33
|
-
|
|
64
|
+
const literalDollars = (value: string): string => value.replaceAll('\\$', '$');
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A dotenv file, parsed to agree with Bun's own loader on the same bytes — a root `.env` must mean
|
|
68
|
+
* the same thing whether Bun read it (the process started in the root) or this did (it started
|
|
69
|
+
* below it). `KEY=value`, an optional `export `, `#` comments (an unquoted value ends at the first
|
|
70
|
+
* `#`), values quoted with `"`, `'` or a backtick and free to span lines, so a PEM key parses. A
|
|
71
|
+
* bare `\r` separates lines as `\n` does, inside a quoted value too.
|
|
72
|
+
* Bun's `$VAR` expansion is NOT reproduced: it resolves lazily across every loaded file and the
|
|
73
|
+
* process env, so a root default that needs one belongs in the process environment.
|
|
74
|
+
*/
|
|
75
|
+
export function parseDotenv(source: string): ReadonlyMap<string, string> {
|
|
76
|
+
const text = source.replace(/\r\n?/g, '\n');
|
|
34
77
|
const out = new Map<string, string>();
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
78
|
+
let start = 0;
|
|
79
|
+
while (start < text.length) {
|
|
80
|
+
const newline = text.indexOf('\n', start);
|
|
81
|
+
const eol = newline === -1 ? text.length : newline;
|
|
82
|
+
const line = text.slice(start, eol);
|
|
83
|
+
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.]*)\s*=\s*/.exec(line);
|
|
84
|
+
const open = start + (match?.[0].length ?? 0);
|
|
85
|
+
start = eol + 1;
|
|
39
86
|
if (match === null) continue;
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
87
|
+
const key = match[1] ?? '';
|
|
88
|
+
const read = QUOTES.has(text[open] ?? '') ? quoted(text, open) : undefined;
|
|
89
|
+
if (read !== undefined) {
|
|
90
|
+
out.set(key, literalDollars(read.value));
|
|
91
|
+
// Whatever follows the closing quote on its line is a comment to Bun.
|
|
92
|
+
const after = text.indexOf('\n', read.end);
|
|
93
|
+
start = after === -1 ? text.length : after + 1;
|
|
94
|
+
continue;
|
|
47
95
|
}
|
|
96
|
+
const value = line.slice(match[0].length);
|
|
97
|
+
const hash = value.indexOf('#');
|
|
98
|
+
out.set(key, literalDollars((hash === -1 ? value : value.slice(0, hash)).trim()));
|
|
48
99
|
}
|
|
49
100
|
return out;
|
|
50
101
|
}
|
|
51
102
|
|
|
52
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
*/
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
103
|
+
/** Where the command runs, and where the PROCESS started — not the same once `--cwd` is passed. */
|
|
104
|
+
export interface RootEnvInput {
|
|
105
|
+
/** The `--cwd`-resolved directory the command works in; its app root is the one loaded. */
|
|
106
|
+
readonly cwd: string;
|
|
107
|
+
/** The directory the process started in: the only one whose `.env*` Bun loaded itself. */
|
|
108
|
+
readonly processCwd: string;
|
|
109
|
+
readonly env: Env;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Every `.env*` file in `dir`, merged in Bun's precedence for `envName`. */
|
|
113
|
+
async function envFilesIn(dir: string, names: readonly string[]): Promise<Map<string, string>> {
|
|
60
114
|
const merged = new Map<string, string>();
|
|
61
|
-
for (const name of
|
|
62
|
-
const file = Bun.file(join(
|
|
115
|
+
for (const name of names) {
|
|
116
|
+
const file = Bun.file(join(dir, name));
|
|
63
117
|
if (!(await file.exists())) continue;
|
|
64
118
|
for (const [key, value] of parseDotenv(await file.text())) merged.set(key, value);
|
|
65
119
|
}
|
|
66
|
-
return
|
|
120
|
+
return merged;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** True when `dir` is `root` or below it. */
|
|
124
|
+
const isWithin = (root: string, dir: string): boolean => {
|
|
125
|
+
const rel = relative(resolve(root), resolve(dir));
|
|
126
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** What one command's env owes the app root: keys to set, and another app's keys to remove. */
|
|
130
|
+
export interface RootEnvChanges {
|
|
131
|
+
readonly set: Readonly<Record<string, string>>;
|
|
132
|
+
readonly unset: readonly string[];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* What the root's `.env*` files change in `env`. A real environment variable always wins. Nothing
|
|
137
|
+
* when there is no app root or the process STARTED in the root (Bun loaded those files itself).
|
|
138
|
+
* Judged against `processCwd`, never `cwd`: `x --cwd <app>` run from elsewhere resolves `cwd` to
|
|
139
|
+
* the root, and Bun loaded the other directory's files, not the app's.
|
|
140
|
+
*
|
|
141
|
+
* What Bun loaded from the starting directory is not a real variable. Started below the root
|
|
142
|
+
* (`apps/web`), it is the same app's own, more specific file, and it wins. Started OUTSIDE the
|
|
143
|
+
* root — `x --cwd ../app-b db migrate` run in app-a — it is another app's: a key whose value is
|
|
144
|
+
* exactly what that directory's files say is overridden by the target's, and REMOVED when the
|
|
145
|
+
* target does not define it — left in place, app-a's DATABASE_URL migrated app-b, which relies on
|
|
146
|
+
* its default. A value that differs from the file is one Bun did not write (it never overrides a
|
|
147
|
+
* real variable), so it is real and stays.
|
|
148
|
+
*/
|
|
149
|
+
export async function rootEnvChanges(input: RootEnvInput): Promise<RootEnvChanges> {
|
|
150
|
+
const { env } = input;
|
|
151
|
+
const root = findAppRoot(input.cwd);
|
|
152
|
+
if (root === undefined || resolve(root.dir) === resolve(input.processCwd)) {
|
|
153
|
+
return { set: {}, unset: [] };
|
|
154
|
+
}
|
|
155
|
+
const outside = !isWithin(root.dir, input.processCwd);
|
|
156
|
+
// A superset of what Bun loads from a cwd, so a key from any of them is recognised as file-made.
|
|
157
|
+
const filesAt = (dir: string, name: string) =>
|
|
158
|
+
envFilesIn(dir, [...rootEnvFiles(name), '.env.local']);
|
|
159
|
+
let envName = env['NODE_ENV'] ?? 'development';
|
|
160
|
+
let foreign = outside ? await filesAt(input.processCwd, envName) : new Map<string, string>();
|
|
161
|
+
// A NODE_ENV another app's file wrote is not one the operator chose: Bun picked that directory's
|
|
162
|
+
// file set with no real NODE_ENV, so with its default — and the target's set is picked the same
|
|
163
|
+
// way, never from the foreign value (app-a's `NODE_ENV=test` loaded app-b's `.env.test`).
|
|
164
|
+
if (foreign.has('NODE_ENV') && env['NODE_ENV'] === foreign.get('NODE_ENV')) {
|
|
165
|
+
envName = 'development';
|
|
166
|
+
foreign = await filesAt(input.processCwd, envName);
|
|
167
|
+
}
|
|
168
|
+
const merged = await envFilesIn(root.dir, rootEnvFiles(envName));
|
|
169
|
+
const fromFile = (key: string): boolean => foreign.has(key) && env[key] === foreign.get(key);
|
|
170
|
+
return {
|
|
171
|
+
set: Object.fromEntries([...merged].filter(([key]) => env[key] === undefined || fromFile(key))),
|
|
172
|
+
unset: [...foreign.keys()].filter((key) => fromFile(key) && !merged.has(key)).sort(),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
type MutableEnv = Record<string, string | undefined>;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The env a command runs with. When `env` IS the process env, it is changed in place — a package
|
|
180
|
+
* reading `Bun.env` directly must see what the command sees, and an unset key must be GONE, not
|
|
181
|
+
* merely shadowed. An injected env is never mutated: the command gets a rebuilt copy.
|
|
182
|
+
*/
|
|
183
|
+
export function applyRootEnv(
|
|
184
|
+
env: Env,
|
|
185
|
+
changes: RootEnvChanges,
|
|
186
|
+
processEnv: MutableEnv = Bun.env,
|
|
187
|
+
): Env {
|
|
188
|
+
if (env === processEnv) {
|
|
189
|
+
for (const key of changes.unset) delete processEnv[key];
|
|
190
|
+
Object.assign(processEnv, changes.set);
|
|
191
|
+
return processEnv;
|
|
192
|
+
}
|
|
193
|
+
const rebuilt: MutableEnv = { ...env, ...changes.set };
|
|
194
|
+
for (const key of changes.unset) delete rebuilt[key];
|
|
195
|
+
return rebuilt;
|
|
67
196
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Which islands one route render collects, and the realtime page boot that render earns. Split
|
|
2
|
+
// from `runtime-render.ts` so the document builder stays one job; both `documentFrom` and the
|
|
3
|
+
// `stream` branch read the same two answers.
|
|
4
|
+
|
|
5
|
+
// why: Bun ships no path API; an island's file is its route file's directory joined to its `src`.
|
|
6
|
+
import { posix } from 'node:path';
|
|
7
|
+
import type { IslandCollector, RouteEntry } from '@ultimat3/render';
|
|
8
|
+
import {
|
|
9
|
+
clientBootTags,
|
|
10
|
+
islandCollector,
|
|
11
|
+
islandModuleId,
|
|
12
|
+
islandModuleIds,
|
|
13
|
+
renderHead,
|
|
14
|
+
} from '@ultimat3/render';
|
|
15
|
+
import type { DocumentOptions } from './document-options';
|
|
16
|
+
import { realtimeIslandFiles } from './island-realtime';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One collector per RENDER, never module-global: two requests render different params, and a
|
|
20
|
+
* shared collector would bill one page for the other's islands. `hydrate` comes off the route, so
|
|
21
|
+
* an island never declares its own timing, and `resolve` is the build's — identity when nothing
|
|
22
|
+
* built any, which fails at the first island by name rather than emitting an unusable entry.
|
|
23
|
+
*/
|
|
24
|
+
export const collectorFor = (
|
|
25
|
+
entry: RouteEntry,
|
|
26
|
+
options: DocumentOptions,
|
|
27
|
+
scope: string | undefined,
|
|
28
|
+
): IslandCollector =>
|
|
29
|
+
islandCollector({
|
|
30
|
+
file: entry.file,
|
|
31
|
+
hydrate: entry.config.hydrate,
|
|
32
|
+
...(options.resolveIsland === undefined ? {} : { resolve: options.resolveIsland(entry.file) }),
|
|
33
|
+
// #506: on a document that carries the page boot (the same two conditions as `bootScript`), a
|
|
34
|
+
// realtime island's server markup is held off screen until it mounts over the restored store.
|
|
35
|
+
...(scope === undefined || options.sync === undefined
|
|
36
|
+
? {}
|
|
37
|
+
: { hold: (src: string) => reachesRealtime(entry, src) }),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** Whether `src`, as the page at `entry` wrote it, is an island the last build found realtime. */
|
|
41
|
+
const reachesRealtime = (entry: RouteEntry, src: string): boolean =>
|
|
42
|
+
realtimeIslandFiles().has(posix.join(posix.dirname(entry.file), src));
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Realtime's page boot, as one deferred script — or nothing. Two conditions, both exact: the
|
|
46
|
+
* document carries a principal scope (restoring persisted records and replaying queued writes are
|
|
47
|
+
* per principal; a shareable document has neither), AND one of the islands this render emitted
|
|
48
|
+
* reaches `@ultimat3/realtime` (a page whose islands never touch a record has nothing to restore
|
|
49
|
+
* into and no write to replay). After the body, because which islands rendered is a fact the walk
|
|
50
|
+
* just recorded; still before the hydration runtime, so it runs first among the deferred scripts.
|
|
51
|
+
*/
|
|
52
|
+
export function bootScript(
|
|
53
|
+
entry: RouteEntry,
|
|
54
|
+
islands: IslandCollector,
|
|
55
|
+
options: DocumentOptions,
|
|
56
|
+
scope: string | undefined,
|
|
57
|
+
): string {
|
|
58
|
+
if (scope === undefined || options.sync === undefined) return '';
|
|
59
|
+
const rendered = new Set(islandModuleIds(islands.directives));
|
|
60
|
+
if (rendered.size === 0) return '';
|
|
61
|
+
// An island's module id is derived from its `src`, written relative to the page that renders it:
|
|
62
|
+
// each realtime island file, spelled from THIS page, is the id its directive would carry.
|
|
63
|
+
const pageDir = posix.dirname(entry.file);
|
|
64
|
+
const reaches = [...realtimeIslandFiles()].some((file) => {
|
|
65
|
+
const src = posix.relative(pageDir, file);
|
|
66
|
+
return rendered.has(islandModuleId(src.startsWith('.') ? src : `./${src}`));
|
|
67
|
+
});
|
|
68
|
+
return reaches ? renderHead(clientBootTags(options.sync)) : '';
|
|
69
|
+
}
|
package/src/runtime-admin.ts
CHANGED
|
@@ -28,7 +28,8 @@ const declared = (): ReadonlyMap<string, AdminApp> => {
|
|
|
28
28
|
return held instanceof Map ? (held as ReadonlyMap<string, AdminApp>) : new Map();
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
-
export interface AdminMountOptions
|
|
31
|
+
export interface AdminMountOptions
|
|
32
|
+
extends Pick<DocumentOptions, 'themeHead' | 'brandHead' | 'origin'> {
|
|
32
33
|
readonly buildId: string;
|
|
33
34
|
}
|
|
34
35
|
|
|
@@ -103,6 +104,8 @@ async function answer(
|
|
|
103
104
|
() =>
|
|
104
105
|
routeDocument(entry, data, {
|
|
105
106
|
...(options.themeHead === undefined ? {} : { themeHead: options.themeHead }),
|
|
107
|
+
// The app's brand restyles its admin too: a screen is a document of the `app/` surface.
|
|
108
|
+
...(options.brandHead === undefined ? {} : { brandHead: options.brandHead }),
|
|
106
109
|
...(options.origin === undefined ? {} : { origin: options.origin }),
|
|
107
110
|
}),
|
|
108
111
|
{ buildId: options.buildId, status: response.status },
|
package/src/runtime-assets.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { applyCacheHeaders } from '@ultimat3/http';
|
|
|
11
11
|
import type { ImageQuery, ImageTransformDriver } from '@ultimat3/seo';
|
|
12
12
|
import { builtinImageDriver, DEFAULT_WIDTHS, parseImageQuery, usableWidths } from '@ultimat3/seo';
|
|
13
13
|
import type { ImageTransform, Storage, VariantFormat } from '@ultimat3/storage';
|
|
14
|
-
import { isTenantScoped, isVariantFormat, variantKey } from '@ultimat3/storage';
|
|
14
|
+
import { DEFAULT_QUALITY, isTenantScoped, isVariantFormat, variantKey } from '@ultimat3/storage';
|
|
15
15
|
import { faviconRoute } from './favicon';
|
|
16
16
|
// The icon matrix's source, its base path and its renderer live in their own module so that
|
|
17
17
|
// `pwa-artifacts.ts` — which this file imports for the manifest route — can reach them without
|
|
@@ -69,6 +69,15 @@ const IMMUTABLE_IMAGE: CacheHint = { mode: 'immutable' };
|
|
|
69
69
|
const isMintableWidth = (width: number | undefined, intrinsic: number): boolean =>
|
|
70
70
|
width === undefined || usableWidths(intrinsic, DEFAULT_WIDTHS).includes(width);
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The same question for `?q=`. `@ultimat3/seo` never writes a quality into a variant URL, so the
|
|
74
|
+
* framework mints exactly one — the default — and `variantKey` already spells the default and an
|
|
75
|
+
* absent one as the same key. Every other value from 1 to 100 was its own stored object per
|
|
76
|
+
* minted width per format: a hundred writes a caller drives with a loop. Served, never stored.
|
|
77
|
+
*/
|
|
78
|
+
const isMintableQuality = (quality: number | undefined): boolean =>
|
|
79
|
+
quality === undefined || quality === DEFAULT_QUALITY;
|
|
80
|
+
|
|
72
81
|
const imageResponse = (bytes: Uint8Array, contentType: string, cache: CacheHint): Response =>
|
|
73
82
|
applyCacheHeaders(
|
|
74
83
|
// Copied, not passed through: a `Uint8Array<ArrayBufferLike>` may be backed by a
|
|
@@ -146,7 +155,11 @@ async function transformedVariant(
|
|
|
146
155
|
...(query.format === undefined ? {} : { format: query.format }),
|
|
147
156
|
...(query.quality === undefined ? {} : { quality: query.quality }),
|
|
148
157
|
});
|
|
149
|
-
if (
|
|
158
|
+
if (
|
|
159
|
+
cached !== undefined &&
|
|
160
|
+
isMintableWidth(query.width, intrinsic) &&
|
|
161
|
+
isMintableQuality(query.quality)
|
|
162
|
+
) {
|
|
150
163
|
await disk.put(cached, variant.bytes, { contentType: variant.contentType });
|
|
151
164
|
}
|
|
152
165
|
return imageResponse(variant.bytes, variant.contentType, cache);
|
package/src/runtime-bindings.ts
CHANGED
|
@@ -34,9 +34,29 @@ export interface DevServices {
|
|
|
34
34
|
|
|
35
35
|
export type Env = Readonly<Record<string, string | undefined>>;
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* An embedded binding's `url`: `<scheme>://` and then the HOST path, verbatim — a scheme-tagged
|
|
39
|
+
* path, NOT a URL. `pglite://D:\\app\\.x\\pgdata` is what Windows gets, and no URL parser reads it
|
|
40
|
+
* back (a `\\` is not a separator to one, a space would need encoding). Nothing parses it: db's
|
|
41
|
+
* `pgliteDataDir` and `startStorage` strip the prefix, and the report goes through `safeUrlLabel`,
|
|
42
|
+
* which answers the binding's name for anything that is not a URL. `Bun.pathToFileURL` was the
|
|
43
|
+
* other option, and would have made both readers decode as well as strip.
|
|
44
|
+
*/
|
|
45
|
+
export const embeddedLocation = (scheme: 'pglite' | 'file', path: string): string =>
|
|
46
|
+
`${scheme}://${path}`;
|
|
47
|
+
|
|
37
48
|
const nonEmpty = (value: string | undefined): string | undefined =>
|
|
38
49
|
value === undefined || value.trim().length === 0 ? undefined : value;
|
|
39
50
|
|
|
51
|
+
const s3EndpointOf = (env: Env): string | undefined => nonEmpty(env['S3_ENDPOINT']);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* THE storage choice: a non-empty `S3_ENDPOINT` is object storage, anything else the embedded disk
|
|
55
|
+
* — which alone mints its own signed URLs and so needs `STORAGE_SIGNING_SECRET`. Exported so the
|
|
56
|
+
* deploy contract (`framework-env.ts`) asks this function rather than restating the rule.
|
|
57
|
+
*/
|
|
58
|
+
export const storageIsExternal = (env: Env): boolean => s3EndpointOf(env) !== undefined;
|
|
59
|
+
|
|
40
60
|
/**
|
|
41
61
|
* Embedded Postgres is PGlite on disk under `.x/`, so a restart keeps the data and a `x db reset`
|
|
42
62
|
* is a directory delete rather than a container dance.
|
|
@@ -51,7 +71,7 @@ export function resolveServices(
|
|
|
51
71
|
// (`e2e-app.ts`) instead of resetting the developer's own, beside a running `x dev`.
|
|
52
72
|
const stateDir = nonEmpty(env['ULTIMATE_STATE_DIR']) ?? join(root, '.x');
|
|
53
73
|
const databaseUrl = nonEmpty(env['DATABASE_URL']);
|
|
54
|
-
const s3Endpoint =
|
|
74
|
+
const s3Endpoint = s3EndpointOf(env);
|
|
55
75
|
// Created only when something will actually live in it: the embedded database and the local disk
|
|
56
76
|
// do, in-process events never touch the disk. A container whose bindings are external runs
|
|
57
77
|
// non-root over a read-only app directory, and a mkdir there is an EACCES at boot for a directory
|
|
@@ -67,7 +87,7 @@ export function resolveServices(
|
|
|
67
87
|
? {
|
|
68
88
|
name: 'db',
|
|
69
89
|
mode: 'embedded',
|
|
70
|
-
url:
|
|
90
|
+
url: embeddedLocation('pglite', join(stateDir, 'pgdata')),
|
|
71
91
|
detail: 'PGlite in this process — set DATABASE_URL to use a real Postgres',
|
|
72
92
|
}
|
|
73
93
|
: { name: 'db', mode: 'external', url: databaseUrl, detail: 'DATABASE_URL' },
|
|
@@ -77,7 +97,7 @@ export function resolveServices(
|
|
|
77
97
|
? {
|
|
78
98
|
name: 'storage',
|
|
79
99
|
mode: 'embedded',
|
|
80
|
-
url:
|
|
100
|
+
url: embeddedLocation('file', join(stateDir, 'storage')),
|
|
81
101
|
detail: 'local directory — set S3_ENDPOINT to use S3',
|
|
82
102
|
}
|
|
83
103
|
: { name: 'storage', mode: 'external', url: s3Endpoint, detail: 'S3_ENDPOINT' },
|
package/src/runtime-cache.ts
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
import type { CacheTier, PurgeDriver } from '@ultimat3/cache';
|
|
8
8
|
import {
|
|
9
9
|
CacheDriverUnavailableError,
|
|
10
|
-
|
|
11
|
-
createLruTier,
|
|
12
|
-
createMemoTier,
|
|
13
|
-
createRedisTier,
|
|
10
|
+
cdnTier,
|
|
14
11
|
isNoopPurgeDriver,
|
|
12
|
+
lruTier,
|
|
13
|
+
memoTier,
|
|
15
14
|
receiveInvalidationBroadcast,
|
|
15
|
+
redisTier,
|
|
16
16
|
registerInvalidationBroadcast,
|
|
17
17
|
registerTier,
|
|
18
18
|
resetTiers,
|
|
@@ -85,9 +85,9 @@ function redisUrl(env: Env): string | undefined {
|
|
|
85
85
|
function buildTier(name: CacheTierName, options: CacheTiersOptions): CacheTier {
|
|
86
86
|
switch (name) {
|
|
87
87
|
case 'request-memo':
|
|
88
|
-
return
|
|
88
|
+
return memoTier();
|
|
89
89
|
case 'lru':
|
|
90
|
-
return
|
|
90
|
+
return lruTier();
|
|
91
91
|
case 'redis':
|
|
92
92
|
if (redisUrl(options.env) === undefined) {
|
|
93
93
|
throw new CacheDriverUnavailableError({
|
|
@@ -97,7 +97,7 @@ function buildTier(name: CacheTierName, options: CacheTiersOptions): CacheTier {
|
|
|
97
97
|
fix: 'set REDIS_URL in .env, or drop the redis tier from cache.tiers in app.config.ts',
|
|
98
98
|
});
|
|
99
99
|
}
|
|
100
|
-
return
|
|
100
|
+
return redisTier();
|
|
101
101
|
case 'cdn':
|
|
102
102
|
// A noop tier would put a `cdn` line in every invalidation report claiming keys an edge that
|
|
103
103
|
// does not exist had accepted — and the `/_x` cache panel renders those reports, so the lie
|
|
@@ -110,7 +110,7 @@ function buildTier(name: CacheTierName, options: CacheTiersOptions): CacheTier {
|
|
|
110
110
|
fix: 'set FASTLY_API_TOKEN and FASTLY_SERVICE_ID in .env, or CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID, or drop the cdn tier from cache.tiers in app.config.ts',
|
|
111
111
|
});
|
|
112
112
|
}
|
|
113
|
-
return
|
|
113
|
+
return cdnTier({ purge: options.purge });
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
116
|
|
package/src/runtime-hooks.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// decides for pages only, from the SAME `Policy` object every other surface evaluates — the route
|
|
3
3
|
// table's declared permission — so a denial in `x dev` is the one production produces.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { explainActionPathMiss } from '@ultimat3/action';
|
|
6
|
+
import { actorOf } from '@ultimat3/core';
|
|
6
7
|
import type { AuthzDecision, ServerHooks } from '@ultimat3/http';
|
|
7
8
|
import { asCtx, configuredAuthenticator } from '@ultimat3/http';
|
|
8
9
|
import type { KnownPermission, Policy } from '@ultimat3/policy';
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// What one `isr` request is answered with: a page from the store, or the redirect ITS OWN `load`
|
|
2
|
+
// decided. A returned discriminated outcome, never a throw: the controller's regeneration treats
|
|
3
|
+
// anything thrown as a failure and logs it, and a redirect is an ordinary answer, not a failure.
|
|
4
|
+
|
|
5
|
+
import type { RedirectIntent, RequestContext, RouteParams } from '@ultimat3/http';
|
|
6
|
+
import { asCtx, takeRedirect } from '@ultimat3/http';
|
|
7
|
+
import type { RenderResult, RouteData, RouteEntry } from '@ultimat3/render';
|
|
8
|
+
import { routeDataFor, routeStatusOf } from '@ultimat3/render';
|
|
9
|
+
import type { IsrController, IsrEntry } from '@ultimat3/render/server';
|
|
10
|
+
import { isrKey } from '@ultimat3/render/server';
|
|
11
|
+
|
|
12
|
+
export type IsrOutcome =
|
|
13
|
+
| { readonly kind: 'page'; readonly result: RenderResult }
|
|
14
|
+
| { readonly kind: 'redirect'; readonly to: RedirectIntent };
|
|
15
|
+
|
|
16
|
+
export interface IsrRequest {
|
|
17
|
+
readonly entry: RouteEntry;
|
|
18
|
+
/** What `load` and `meta` are given; `url` is a string because that is what `ld.*` embeds. */
|
|
19
|
+
readonly data: { readonly url: string; readonly params: RouteParams } & Record<string, unknown>;
|
|
20
|
+
readonly ctx: RequestContext;
|
|
21
|
+
readonly isr: IsrController;
|
|
22
|
+
/** The route's whole document for what `load` returned (`runtime-render.ts`'s `documentFrom`). */
|
|
23
|
+
readonly document: (loaded: RouteData) => Promise<string>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The producer's answer when `load` redirected. A 3xx status is never a page — `withStatus` refuses
|
|
28
|
+
* one by name, because a document has no `Location` — so a 3xx entry can only be this marker, and a
|
|
29
|
+
* request that JOINED the flight reads it as "the leader's load redirected for the leader".
|
|
30
|
+
*/
|
|
31
|
+
const isRedirectMarker = (entry: IsrEntry): boolean => {
|
|
32
|
+
// An entry stored before statuses were (`status` absent) is a 200, and never this marker.
|
|
33
|
+
const { status } = entry;
|
|
34
|
+
return status !== undefined && status >= 300 && status < 400;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A redirect means the key holds no page. Deleting drops the stale copy, so the next request is a
|
|
39
|
+
* miss and decides again. `markStale` records an invalidation of the key AFTER the regeneration's
|
|
40
|
+
* fence was sampled, and the controller refuses to store a render its fence no longer covers — so
|
|
41
|
+
* the marker this producer returns is never published, and never served from the store.
|
|
42
|
+
*/
|
|
43
|
+
function dropPage(isr: IsrController, key: string): void {
|
|
44
|
+
isr.store().delete(key);
|
|
45
|
+
isr.markStale(key);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `isr`: a store hit is answered WITHOUT `load` — resolving it before the mode choice, as every
|
|
50
|
+
* other mode does, made each hit cost a full SSR's database work. So `load` and its redirect live
|
|
51
|
+
* inside the producer, which runs on a miss and on a stale page's regeneration.
|
|
52
|
+
*
|
|
53
|
+
* Concurrent misses on one key share ONE producer run, and only a PAGE may be shared. A redirect is
|
|
54
|
+
* the request's own (its `load` may have read a cookie), so a request that joined a run whose load
|
|
55
|
+
* redirected decides for itself, exactly as the static single-flight in `appRoutes` does.
|
|
56
|
+
*/
|
|
57
|
+
export async function isrOutcome(request: IsrRequest): Promise<IsrOutcome> {
|
|
58
|
+
const { entry, data, ctx, isr } = request;
|
|
59
|
+
// `isrKey(url, locale)`, never `url.pathname`: the query is part of what was rendered — `meta`
|
|
60
|
+
// reads `data.url` — so two URLs differing only in their query are two documents (#171). The
|
|
61
|
+
// locale is `ctx.locale`, the one the `locale` stage negotiated for THIS request.
|
|
62
|
+
const key = isrKey(new URL(data.url), asCtx(ctx).locale);
|
|
63
|
+
let decided: RedirectIntent | undefined;
|
|
64
|
+
// `{ html, status }`, never the bare string: the entry stores the status beside the HTML and
|
|
65
|
+
// serves it on every hit, so a 404 under `isr` is a 404 for its whole TTL.
|
|
66
|
+
const served = await isr.serve(key, async () => {
|
|
67
|
+
const loaded = await routeDataFor(entry.config, data);
|
|
68
|
+
const to = takeRedirect(ctx);
|
|
69
|
+
if (to === undefined)
|
|
70
|
+
return { html: await request.document(loaded), status: routeStatusOf(loaded) };
|
|
71
|
+
// Set by THIS request's producer only. On a stale page it runs behind the answer, and when it
|
|
72
|
+
// has already decided by the time `serve` returns, the redirect is still this request's own.
|
|
73
|
+
decided = to;
|
|
74
|
+
dropPage(isr, key);
|
|
75
|
+
return { html: '', status: to.status };
|
|
76
|
+
});
|
|
77
|
+
if (decided !== undefined) return { kind: 'redirect', to: decided };
|
|
78
|
+
if (!isRedirectMarker(served.entry)) return { kind: 'page', result: served.result };
|
|
79
|
+
return ownOutcome(request);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A joiner whose leader's load redirected: its own `load`, answered for this request alone. A page
|
|
84
|
+
* here is NOT stored — the store was just told the key has none — so it is `private, no-store`;
|
|
85
|
+
* the next request to miss stores one.
|
|
86
|
+
*/
|
|
87
|
+
async function ownOutcome(request: IsrRequest): Promise<IsrOutcome> {
|
|
88
|
+
const loaded = await routeDataFor(request.entry.config, request.data);
|
|
89
|
+
const to = takeRedirect(request.ctx);
|
|
90
|
+
if (to !== undefined) return { kind: 'redirect', to };
|
|
91
|
+
const body = await request.document(loaded);
|
|
92
|
+
return {
|
|
93
|
+
kind: 'page',
|
|
94
|
+
result: {
|
|
95
|
+
status: routeStatusOf(loaded),
|
|
96
|
+
headers: { 'cache-control': 'private, no-store' },
|
|
97
|
+
body,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
package/src/runtime-isr.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// container, so neither can build a controller `invalidateTags` never reaches (plan 101, s2-con #1).
|
|
4
4
|
|
|
5
5
|
import type { IsrController, IsrStore } from '@ultimat3/render/server';
|
|
6
|
-
import {
|
|
6
|
+
import { isrController } from '@ultimat3/render/server';
|
|
7
7
|
|
|
8
8
|
export interface AttachedIsr {
|
|
9
9
|
readonly isr: IsrController;
|
|
@@ -19,7 +19,7 @@ export function attachedIsr(options: {
|
|
|
19
19
|
readonly buildId: string;
|
|
20
20
|
readonly store?: IsrStore;
|
|
21
21
|
}): AttachedIsr {
|
|
22
|
-
const isr =
|
|
22
|
+
const isr = isrController({
|
|
23
23
|
buildId: options.buildId,
|
|
24
24
|
...(options.store === undefined ? {} : { store: options.store }),
|
|
25
25
|
});
|