@nifrajs/web 2.2.0 → 2.3.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/README.md +3 -3
- package/dist/build-vite.d.ts +4 -4
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +20 -74
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +64 -42
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +100 -78
- package/dist/build.js.map +1 -1
- package/dist/bun-dev-entry.d.ts +1 -1
- package/dist/bun-dev-entry.js +1 -1
- package/dist/chunk-names.js +1 -1
- package/dist/client.d.ts +3 -9
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +201 -29
- package/dist/client.js.map +1 -1
- package/dist/conformance.d.ts +2 -2
- package/dist/conformance.js +3 -3
- package/dist/deferred.d.ts +11 -11
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +16 -16
- package/dist/dev-error.d.ts +2 -2
- package/dist/dev-error.js +3 -3
- package/dist/dev-port.d.ts +2 -2
- package/dist/dev-port.js +1 -1
- package/dist/dev.d.ts +3 -3
- package/dist/dev.js +9 -9
- package/dist/draft.d.ts +12 -12
- package/dist/draft.js +11 -11
- package/dist/fn-state.d.ts +56 -0
- package/dist/fn-state.d.ts.map +1 -0
- package/dist/fn-state.js +77 -0
- package/dist/fn-state.js.map +1 -0
- package/dist/fn.d.ts +111 -0
- package/dist/fn.d.ts.map +1 -0
- package/dist/fn.js +125 -0
- package/dist/fn.js.map +1 -0
- package/dist/fonts-google.d.ts +9 -9
- package/dist/fonts-google.js +10 -10
- package/dist/fonts.d.ts +7 -7
- package/dist/fonts.js +1 -1
- package/dist/forms.d.ts +8 -8
- package/dist/fs.d.ts +1 -1
- package/dist/fs.js +1 -1
- package/dist/index.d.ts +151 -70
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +317 -139
- package/dist/index.js.map +1 -1
- package/dist/internal/head-attributes.d.ts +15 -0
- package/dist/internal/head-attributes.d.ts.map +1 -0
- package/dist/internal/head-attributes.js +99 -0
- package/dist/internal/head-attributes.js.map +1 -0
- package/dist/internal/script-types.d.ts +14 -0
- package/dist/internal/script-types.d.ts.map +1 -0
- package/dist/internal/script-types.js +17 -0
- package/dist/internal/script-types.js.map +1 -0
- package/dist/internal/server-boundary.d.ts +60 -0
- package/dist/internal/server-boundary.d.ts.map +1 -0
- package/dist/internal/server-boundary.js +174 -0
- package/dist/internal/server-boundary.js.map +1 -0
- package/dist/internal/timing-safe-equal.d.ts +1 -1
- package/dist/internal/timing-safe-equal.js +1 -1
- package/dist/internal/vite-import.d.ts +63 -0
- package/dist/internal/vite-import.d.ts.map +1 -0
- package/dist/internal/vite-import.js +94 -0
- package/dist/internal/vite-import.js.map +1 -0
- package/dist/islands.d.ts +5 -5
- package/dist/islands.js +5 -5
- package/dist/isr.d.ts +15 -15
- package/dist/isr.js +7 -7
- package/dist/manifest.d.ts +73 -35
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +10 -16
- package/dist/manifest.js.map +1 -1
- package/dist/module-graph.d.ts +5 -5
- package/dist/module-graph.js +4 -4
- package/dist/navigation.d.ts +120 -5
- package/dist/navigation.d.ts.map +1 -1
- package/dist/navigation.js +37 -5
- package/dist/navigation.js.map +1 -1
- package/dist/plugins/css-modules.d.ts +3 -3
- package/dist/plugins/css-modules.js +0 -0
- package/dist/plugins/kit.d.ts +8 -8
- package/dist/plugins/kit.js +9 -9
- package/dist/plugins/scss.js +3 -3
- package/dist/plugins/vite-leak-guard.d.ts +1 -1
- package/dist/plugins/vite-leak-guard.js +1 -1
- package/dist/plugins/vite-server-fn.d.ts +23 -0
- package/dist/plugins/vite-server-fn.d.ts.map +1 -0
- package/dist/plugins/vite-server-fn.js +51 -0
- package/dist/plugins/vite-server-fn.js.map +1 -0
- package/dist/plugins/vite-server-only.d.ts +49 -0
- package/dist/plugins/vite-server-only.d.ts.map +1 -0
- package/dist/plugins/vite-server-only.js +57 -0
- package/dist/plugins/vite-server-only.js.map +1 -0
- package/dist/prerender.d.ts +5 -5
- package/dist/prerender.js +7 -7
- package/dist/query.d.ts +10 -10
- package/dist/query.js +15 -15
- package/dist/route-manifest.d.ts +1 -1
- package/dist/route-manifest.js +2 -2
- package/dist/router.d.ts +30 -21
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +57 -28
- package/dist/router.js.map +1 -1
- package/dist/search.d.ts +94 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +232 -0
- package/dist/search.js.map +1 -0
- package/dist/server-only.d.ts +6 -6
- package/dist/server-only.js +7 -7
- package/dist/service-worker.d.ts +56 -0
- package/dist/service-worker.d.ts.map +1 -0
- package/dist/service-worker.js +127 -0
- package/dist/service-worker.js.map +1 -0
- package/dist/vite.d.ts +15 -13
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +31 -21
- package/dist/vite.js.map +1 -1
- package/package.json +33 -4
- package/server-manifest.d.ts +31 -0
- package/src/build-vite.ts +24 -77
- package/src/build.ts +137 -105
- package/src/bun-dev-entry.ts +1 -1
- package/src/chunk-names.ts +1 -1
- package/src/client.ts +215 -32
- package/src/conformance.ts +3 -3
- package/src/deferred.ts +17 -17
- package/src/dev-error.ts +3 -3
- package/src/dev-port.ts +2 -2
- package/src/dev.ts +12 -12
- package/src/draft.ts +15 -15
- package/src/fn-state.ts +107 -0
- package/src/fn.ts +199 -0
- package/src/fonts-google.ts +13 -13
- package/src/fonts.ts +7 -7
- package/src/forms.ts +8 -8
- package/src/fs.ts +2 -2
- package/src/index.ts +460 -181
- package/src/internal/head-attributes.ts +109 -0
- package/src/internal/script-types.ts +18 -0
- package/src/internal/server-boundary.ts +187 -0
- package/src/internal/timing-safe-equal.ts +1 -1
- package/src/internal/vite-import.ts +92 -0
- package/src/islands.ts +7 -7
- package/src/isr.ts +17 -17
- package/src/manifest.ts +83 -42
- package/src/module-graph.ts +6 -6
- package/src/navigation.ts +151 -5
- package/src/plugins/css-modules.ts +0 -0
- package/src/plugins/kit.ts +11 -11
- package/src/plugins/scss.ts +3 -3
- package/src/plugins/vite-leak-guard.ts +2 -2
- package/src/plugins/vite-server-fn.ts +67 -0
- package/src/plugins/vite-server-only.ts +75 -0
- package/src/prerender.ts +10 -10
- package/src/query.ts +22 -22
- package/src/route-manifest.ts +3 -3
- package/src/router.ts +91 -41
- package/src/search.ts +297 -0
- package/src/server-only.ts +7 -7
- package/src/service-worker.ts +165 -0
- package/src/vite.ts +41 -29
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server/client boundary, owned in one place.
|
|
3
|
+
*
|
|
4
|
+
* The client build must never ship server-only code, or server-only config, to a browser. Three
|
|
5
|
+
* conventions enforce that, and each has to mean the SAME thing in every pipeline that builds the client
|
|
6
|
+
* - the Bun client build, the Vite dev server, the Vite production build, and `nifra dev --bun`'s refusal
|
|
7
|
+
* (which cannot transform, so it refuses to start instead):
|
|
8
|
+
*
|
|
9
|
+
* - `*.server` modules - emptied, so a secret / `node:` / native import subtree never reaches the client.
|
|
10
|
+
* - `*.fn` modules - replaced with HTTP stubs, so the function bodies (and their imports) stay server-side.
|
|
11
|
+
* - public-env prefix - which environment variables the client bundle is allowed to inline.
|
|
12
|
+
*
|
|
13
|
+
* When each pipeline re-encoded one of these rules, they drifted. A hand-written glob
|
|
14
|
+
* `**\/*.server.{ts,tsx,js,jsx}` missed the extensionless `db.server` and the `.mts`/`.cts`/`.mjs`/`.cjs`
|
|
15
|
+
* forms the regex matches, waving a module the build empties straight into the browser. So the matchers,
|
|
16
|
+
* the replacement bodies, and the env mapping get exactly one definition here; every pipeline imports it,
|
|
17
|
+
* and parity tests assert the Bun and Vite halves emit identical bytes. A matcher that decides whether a
|
|
18
|
+
* secret can reach a browser gets one owner, not four.
|
|
19
|
+
*/
|
|
20
|
+
// Both module conventions match a filename SUFFIX with the identical optional source-extension tail -
|
|
21
|
+
// `.ts`, `.tsx`, `.mts`, `.cts`, `.mjs`, `.cjs`, `.js`, `.jsx`, or none (the extensionless `db.server` /
|
|
22
|
+
// `todos.fn`). Kept adjacent so the shared shape is visible, and a parity test pins that the two never
|
|
23
|
+
// diverge on which extensions count - a copied-then-edited second tail is exactly how the old glob drifted.
|
|
24
|
+
/** Matches `db.server.ts`, `auth.server.tsx`, `x.server.mjs`, and the extensionless `foo.server`. */
|
|
25
|
+
export const SERVER_ONLY_MODULE = /\.server(\.[cm]?[jt]sx?)?$/;
|
|
26
|
+
/** Modules whose exports become client stubs. Mirrors the `.server` convention's shape. */
|
|
27
|
+
export const SERVER_FN_MODULE = /\.fn(\.[cm]?[jt]sx?)?$/;
|
|
28
|
+
// ---------------------------------------------------------------------------------------------------
|
|
29
|
+
// `.server` - empty a server-only module in the client build.
|
|
30
|
+
// ---------------------------------------------------------------------------------------------------
|
|
31
|
+
/**
|
|
32
|
+
* The replacement body for an emptied module. A Proxy rather than `export {}` so any named OR default
|
|
33
|
+
* import resolves to `undefined` instead of failing the bundle with a missing-export error - the client
|
|
34
|
+
* degrades at the call site it wrote, not at link time in a file it never named.
|
|
35
|
+
*
|
|
36
|
+
* Shared so the Bun and Vite pipelines emit the same bytes; a parity test asserts it.
|
|
37
|
+
*/
|
|
38
|
+
export const SERVER_ONLY_REPLACEMENT = "module.exports = new Proxy({}, { get: () => undefined })";
|
|
39
|
+
const EXPORTED_DECLARATION = /\bexport\s+(?:declare\s+)?(?:async\s+)?(?:const|let|var|function|class|enum)\s+([A-Za-z_$][\w$]*)/g;
|
|
40
|
+
const EXPORTED_LIST = /\bexport\s*\{([^}]*)\}(?:\s*from\s*["'][^"']+["'])?/g;
|
|
41
|
+
/**
|
|
42
|
+
* Vite dev serves native ESM, so the Bun/CommonJS proxy above is invalid there. Emit inert ESM
|
|
43
|
+
* bindings derived from the source's public names while discarding the implementation and imports.
|
|
44
|
+
* An unsupported exotic export fails closed at ESM link time; server code is never served as fallback.
|
|
45
|
+
*/
|
|
46
|
+
export function viteServerOnlyReplacement(source) {
|
|
47
|
+
const names = new Set();
|
|
48
|
+
let hasDefault = /\bexport\s+default\b/.test(source);
|
|
49
|
+
for (const match of source.matchAll(EXPORTED_DECLARATION))
|
|
50
|
+
names.add(match[1]);
|
|
51
|
+
for (const match of source.matchAll(EXPORTED_LIST)) {
|
|
52
|
+
for (const raw of (match[1] ?? "").split(",")) {
|
|
53
|
+
const item = raw.trim().replace(/^type\s+/, "");
|
|
54
|
+
if (item === "")
|
|
55
|
+
continue;
|
|
56
|
+
const parts = item.split(/\s+as\s+/);
|
|
57
|
+
const exported = (parts[1] ?? parts[0] ?? "").trim();
|
|
58
|
+
if (exported === "default")
|
|
59
|
+
hasDefault = true;
|
|
60
|
+
else if (/^[A-Za-z_$][\w$]*$/.test(exported))
|
|
61
|
+
names.add(exported);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const bindings = [...names].sort().map((name) => `export const ${name} = undefined`);
|
|
65
|
+
if (hasDefault)
|
|
66
|
+
bindings.push("export default undefined");
|
|
67
|
+
return `// Generated by @nifrajs/web: server-only implementation removed from the browser.\n${bindings.join("\n")}\n`;
|
|
68
|
+
}
|
|
69
|
+
// ---------------------------------------------------------------------------------------------------
|
|
70
|
+
// `.fn` - turn a server-function module into the client stubs that call it.
|
|
71
|
+
// ---------------------------------------------------------------------------------------------------
|
|
72
|
+
/**
|
|
73
|
+
* The server half of a server function must never reach a browser: it closes over database handles,
|
|
74
|
+
* secrets and everything else the module imports. So the CLIENT build does not bundle these modules at
|
|
75
|
+
* all - it replaces each one with a stub per export that POSTs to the route the server mounted.
|
|
76
|
+
*
|
|
77
|
+
* The generation is pure on purpose. The Bun and Vite pipelines both need this transform and must
|
|
78
|
+
* produce byte-identical stubs, so it lives here and each bundler contributes only its own plugin shell.
|
|
79
|
+
* A divergence between the two would be a client that works in dev and 404s in production.
|
|
80
|
+
*
|
|
81
|
+
* The exports are found by scanning the SOURCE rather than importing it - importing would execute the
|
|
82
|
+
* module, which is precisely what must not happen in a client build - and the declaration form is fixed:
|
|
83
|
+
*
|
|
84
|
+
* export const addTodo = serverFn({ … }, async (input, c) => { … })
|
|
85
|
+
*
|
|
86
|
+
* Anything else is refused rather than skipped: a server function the scanner cannot see would be absent
|
|
87
|
+
* from the client and fail as `addTodo is not a function` at runtime, far from the cause.
|
|
88
|
+
*/
|
|
89
|
+
/** `export const NAME = serverFn(` at the start of a line - the one declaration form. */
|
|
90
|
+
const DECLARATION = /^[\t ]*export\s+const\s+([A-Za-z_$][\w$]*)\s*=\s*serverFn\s*\(/gm;
|
|
91
|
+
/** Any mention of `serverFn(` as a call, used to prove nothing was missed. */
|
|
92
|
+
const ANY_CALL = /\bserverFn\s*\(/g;
|
|
93
|
+
/** Line comments and block comments, stripped before scanning so prose cannot register as code. */
|
|
94
|
+
const COMMENTS = /\/\*[\s\S]*?\*\/|(^|[^:])\/\/.*$/gm;
|
|
95
|
+
/**
|
|
96
|
+
* The namespace a `*.fn.ts` module is mounted under: its filename without the `.fn` suffix.
|
|
97
|
+
*
|
|
98
|
+
* Deriving it from the filename rather than the full path keeps the build machine's directory layout
|
|
99
|
+
* out of a public URL, and keeps the route readable in logs. It also means the mount on the server -
|
|
100
|
+
* `serverFunctions("todos", …)` - has to use the same word, which is the one thing a reader must check
|
|
101
|
+
* by eye today; {@link SERVER_FN_MODULE} files are named for it precisely to make that obvious.
|
|
102
|
+
*/
|
|
103
|
+
export function serverFnNamespace(filePath) {
|
|
104
|
+
const base = filePath.replaceAll("\\", "/").split("/").pop() ?? "";
|
|
105
|
+
return base.replace(SERVER_FN_MODULE, "");
|
|
106
|
+
}
|
|
107
|
+
/** The server functions a module declares, in source order. */
|
|
108
|
+
export function scanServerFnExports(source) {
|
|
109
|
+
const code = source.replaceAll(COMMENTS, "$1");
|
|
110
|
+
const names = [...code.matchAll(DECLARATION)].map((m) => m[1]);
|
|
111
|
+
const calls = [...code.matchAll(ANY_CALL)].length;
|
|
112
|
+
if (calls > names.length) {
|
|
113
|
+
throw new Error("[nifra/fn] a server function is declared in a form the client transform cannot read. Use " +
|
|
114
|
+
"`export const name = serverFn(…)` at the top level: the client build scans the source rather " +
|
|
115
|
+
"than importing it (importing would run your server code during a browser build), so a " +
|
|
116
|
+
"declaration it cannot see would be missing from the client and fail as `name is not a function`.");
|
|
117
|
+
}
|
|
118
|
+
return names;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The client replacement for a `*.fn.ts` module: one stub per export, and none of the original code.
|
|
122
|
+
*
|
|
123
|
+
* `credentials: "same-origin"` is explicit rather than relied upon - the server refuses a cross-origin
|
|
124
|
+
* call, and stating it here means the browser never sends cookies somewhere that would reject them.
|
|
125
|
+
* The content type is not decoration either: it is what makes a cross-origin form unable to forge one
|
|
126
|
+
* of these calls, so the stub and the server guard have to agree on it.
|
|
127
|
+
*/
|
|
128
|
+
export function generateServerFnStub(source, namespace) {
|
|
129
|
+
const names = scanServerFnExports(source);
|
|
130
|
+
const calls = names
|
|
131
|
+
.map((name) => `export const ${name} = (input) => __nifraCall(${JSON.stringify(name)}, input)`)
|
|
132
|
+
.join("\n");
|
|
133
|
+
return `// Generated by @nifrajs/web: the server half of this module never reaches the browser.
|
|
134
|
+
const __nifraBase = ${JSON.stringify(`/_nifra/fn/${namespace}`)}
|
|
135
|
+
|
|
136
|
+
async function __nifraCall(name, input) {
|
|
137
|
+
const response = await fetch(\`\${__nifraBase}/\${name}\`, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: { "content-type": "application/json" },
|
|
140
|
+
body: JSON.stringify(input === undefined ? {} : input),
|
|
141
|
+
credentials: "same-origin",
|
|
142
|
+
})
|
|
143
|
+
if (response.status === 404) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
\`[nifra/fn] \${name} is not mounted at \${__nifraBase}. The client derives that path from this \` +
|
|
146
|
+
\`file's name, so the server's serverFunctions(...) namespace has to match it.\`,
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
if (!response.ok) {
|
|
150
|
+
let detail = ""
|
|
151
|
+
try {
|
|
152
|
+
detail = JSON.stringify(await response.json())
|
|
153
|
+
} catch {
|
|
154
|
+
// A non-JSON error body is not worth a second failure; the status carries enough.
|
|
155
|
+
}
|
|
156
|
+
throw new Error(\`[nifra/fn] \${name} failed: \${response.status}\${detail ? \` \${detail}\` : ""}\`)
|
|
157
|
+
}
|
|
158
|
+
return response.status === 204 ? undefined : await response.json()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
${calls}
|
|
162
|
+
`;
|
|
163
|
+
}
|
|
164
|
+
// ---------------------------------------------------------------------------------------------------
|
|
165
|
+
// public-env - which environment variables the client bundle may inline.
|
|
166
|
+
// ---------------------------------------------------------------------------------------------------
|
|
167
|
+
/** Translate Nifra's public-env contract to Vite's `envPrefix` option. */
|
|
168
|
+
export function vitePublicEnvPrefix(prefix) {
|
|
169
|
+
const configured = prefix ?? "PUBLIC_";
|
|
170
|
+
// Vite rejects an empty prefix. Environment-variable names cannot contain NUL, so this sentinel
|
|
171
|
+
// cannot match an ambient key and faithfully represents Nifra's "expose nothing" setting.
|
|
172
|
+
return configured === "" ? "\0nifra-public-env-disabled" : configured;
|
|
173
|
+
}
|
|
174
|
+
//# sourceMappingURL=server-boundary.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server-boundary.js","sourceRoot":"","sources":["../../src/internal/server-boundary.ts"],"names":[],"mappings":"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"}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Constant-time string equality, shared by every secret check in `@nifrajs/web`. Internal: no
|
|
3
3
|
* `exports` entry points here, so it never becomes public API.
|
|
4
4
|
*
|
|
5
|
-
* One owner per package rather than a copy per call site
|
|
5
|
+
* One owner per package rather than a copy per call site - this is the primitive whose whole value
|
|
6
6
|
* lies in a detail (no early exit) that a well-meaning "simplification" silently removes, and a bug
|
|
7
7
|
* in it is invisible in review and in tests, since a timing-unsafe compare returns the same answers.
|
|
8
8
|
*/
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Constant-time string equality, shared by every secret check in `@nifrajs/web`. Internal: no
|
|
3
3
|
* `exports` entry points here, so it never becomes public API.
|
|
4
4
|
*
|
|
5
|
-
* One owner per package rather than a copy per call site
|
|
5
|
+
* One owner per package rather than a copy per call site - this is the primitive whose whole value
|
|
6
6
|
* lies in a detail (no early exit) that a well-meaning "simplification" silently removes, and a bug
|
|
7
7
|
* in it is invisible in review and in tests, since a timing-unsafe compare returns the same answers.
|
|
8
8
|
*/
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place nifra imports Vite from.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is a module and not two `await import("vite")` calls
|
|
5
|
+
*
|
|
6
|
+
* Vite must not be imported before {@link relaxCaptureStackTrace} has run, and that requirement is
|
|
7
|
+
* invisible at the call site. It was invisible in exactly the way that matters: the production build
|
|
8
|
+
* relaxed first, the dev server did not, and the dev server's import ran earlier in the test process.
|
|
9
|
+
* ESM caches a module that throws while EVALUATING, so once vite failed there it kept failing - every
|
|
10
|
+
* later `loadVite` re-threw the cached error, with its shim correctly installed and no longer able to
|
|
11
|
+
* help. One unguarded import poisons vite for the whole process.
|
|
12
|
+
*
|
|
13
|
+
* So the import lives here and the guard is not optional. A caller cannot get the module without it.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Restore V8's `Error.captureStackTrace` contract on a runtime that is stricter than it.
|
|
17
|
+
*
|
|
18
|
+
* `captureStackTrace` is a V8 API and V8 decorates ANY object handed to it. Some runtimes require a
|
|
19
|
+
* real Error - one with the internal slot - and throw "First argument must be an Error object".
|
|
20
|
+
*
|
|
21
|
+
* That breaks importing vite outright. Vite bundles `follow-redirects`, which builds its error types
|
|
22
|
+
* the pre-class way:
|
|
23
|
+
*
|
|
24
|
+
* function CustomError(props) { Error.captureStackTrace(this, this.constructor); … }
|
|
25
|
+
* CustomError.prototype = new (baseClass || Error)()
|
|
26
|
+
*
|
|
27
|
+
* The last line CONSTRUCTS `baseClass` while defining the subclass, so `this` inherits from Error but
|
|
28
|
+
* was never built by it - and the throw lands during module evaluation, before vite exports anything.
|
|
29
|
+
*
|
|
30
|
+
* Decorating a stack is best-effort by definition, so swallowing the refusal loses nothing: the shim
|
|
31
|
+
* delegates and suppresses only that. Installed once and deliberately not restored, since the strict
|
|
32
|
+
* behaviour is the deviation and a library evaluated later meets the same wall.
|
|
33
|
+
*
|
|
34
|
+
* ## Why it does not probe first
|
|
35
|
+
*
|
|
36
|
+
* It used to construct an object like follow-redirects' and install the shim only if the runtime
|
|
37
|
+
* refused it. That shipped and CI still failed, because the probe was not the same shape as the real
|
|
38
|
+
* call: it passed `Object.create(Error.prototype)` and no `constructorOpt`, while follow-redirects
|
|
39
|
+
* passes an object whose prototype is an Error INSTANCE plus `this.constructor`. One Bun build accepts
|
|
40
|
+
* the former and rejects the latter, so the probe reported "permissive" and installed nothing.
|
|
41
|
+
*
|
|
42
|
+
* A probe has to predict every caller's construction pattern to be right, and is silently wrong when
|
|
43
|
+
* it guesses short. Installing unconditionally cannot fail that way.
|
|
44
|
+
*/
|
|
45
|
+
export declare function relaxCaptureStackTrace(): void;
|
|
46
|
+
/**
|
|
47
|
+
* Import the project's Vite, with the stack-trace contract restored first.
|
|
48
|
+
*
|
|
49
|
+
* Callers keep their own error message - the dev server and the production build fail for the same
|
|
50
|
+
* reasons but need to say different things - so this deliberately lets the cause through untouched.
|
|
51
|
+
* Use {@link isViteUnresolved} to tell "vite is not installed" from "vite failed to load".
|
|
52
|
+
*/
|
|
53
|
+
export declare function importVite<T>(): Promise<T>;
|
|
54
|
+
/**
|
|
55
|
+
* Whether a failed {@link importVite} means vite is absent rather than broken.
|
|
56
|
+
*
|
|
57
|
+
* `vite` is an OPTIONAL peer, so a project without it fails to RESOLVE - the common case, and the one
|
|
58
|
+
* worth naming. But vite can also resolve and fail while EVALUATING, which is not a missing dependency
|
|
59
|
+
* at all: vite 8 loads rolldown's native binding on import, and a binding that will not load surfaces
|
|
60
|
+
* the same way. Telling that user to install what they already installed sends them the wrong way.
|
|
61
|
+
*/
|
|
62
|
+
export declare function isViteUnresolved(cause: unknown): boolean;
|
|
63
|
+
//# sourceMappingURL=vite-import.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vite-import.d.ts","sourceRoot":"","sources":["../../src/internal/vite-import.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAKH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAgB,sBAAsB,IAAI,IAAI,CAkB7C;AAED;;;;;;GAMG;AACH,wBAAsB,UAAU,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,CAGhD;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAGxD"}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place nifra imports Vite from.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is a module and not two `await import("vite")` calls
|
|
5
|
+
*
|
|
6
|
+
* Vite must not be imported before {@link relaxCaptureStackTrace} has run, and that requirement is
|
|
7
|
+
* invisible at the call site. It was invisible in exactly the way that matters: the production build
|
|
8
|
+
* relaxed first, the dev server did not, and the dev server's import ran earlier in the test process.
|
|
9
|
+
* ESM caches a module that throws while EVALUATING, so once vite failed there it kept failing - every
|
|
10
|
+
* later `loadVite` re-threw the cached error, with its shim correctly installed and no longer able to
|
|
11
|
+
* help. One unguarded import poisons vite for the whole process.
|
|
12
|
+
*
|
|
13
|
+
* So the import lives here and the guard is not optional. A caller cannot get the module without it.
|
|
14
|
+
*/
|
|
15
|
+
/** Marks the shim as installed, so repeated imports do not wrap a wrapper. */
|
|
16
|
+
const RELAXED = Symbol.for("@nifrajs/web/relaxed-capture-stack-trace");
|
|
17
|
+
/**
|
|
18
|
+
* Restore V8's `Error.captureStackTrace` contract on a runtime that is stricter than it.
|
|
19
|
+
*
|
|
20
|
+
* `captureStackTrace` is a V8 API and V8 decorates ANY object handed to it. Some runtimes require a
|
|
21
|
+
* real Error - one with the internal slot - and throw "First argument must be an Error object".
|
|
22
|
+
*
|
|
23
|
+
* That breaks importing vite outright. Vite bundles `follow-redirects`, which builds its error types
|
|
24
|
+
* the pre-class way:
|
|
25
|
+
*
|
|
26
|
+
* function CustomError(props) { Error.captureStackTrace(this, this.constructor); … }
|
|
27
|
+
* CustomError.prototype = new (baseClass || Error)()
|
|
28
|
+
*
|
|
29
|
+
* The last line CONSTRUCTS `baseClass` while defining the subclass, so `this` inherits from Error but
|
|
30
|
+
* was never built by it - and the throw lands during module evaluation, before vite exports anything.
|
|
31
|
+
*
|
|
32
|
+
* Decorating a stack is best-effort by definition, so swallowing the refusal loses nothing: the shim
|
|
33
|
+
* delegates and suppresses only that. Installed once and deliberately not restored, since the strict
|
|
34
|
+
* behaviour is the deviation and a library evaluated later meets the same wall.
|
|
35
|
+
*
|
|
36
|
+
* ## Why it does not probe first
|
|
37
|
+
*
|
|
38
|
+
* It used to construct an object like follow-redirects' and install the shim only if the runtime
|
|
39
|
+
* refused it. That shipped and CI still failed, because the probe was not the same shape as the real
|
|
40
|
+
* call: it passed `Object.create(Error.prototype)` and no `constructorOpt`, while follow-redirects
|
|
41
|
+
* passes an object whose prototype is an Error INSTANCE plus `this.constructor`. One Bun build accepts
|
|
42
|
+
* the former and rejects the latter, so the probe reported "permissive" and installed nothing.
|
|
43
|
+
*
|
|
44
|
+
* A probe has to predict every caller's construction pattern to be right, and is silently wrong when
|
|
45
|
+
* it guesses short. Installing unconditionally cannot fail that way.
|
|
46
|
+
*/
|
|
47
|
+
export function relaxCaptureStackTrace() {
|
|
48
|
+
const strict = Error.captureStackTrace;
|
|
49
|
+
if (typeof strict !== "function")
|
|
50
|
+
return;
|
|
51
|
+
if (Error[RELAXED] === true)
|
|
52
|
+
return;
|
|
53
|
+
try {
|
|
54
|
+
Error.captureStackTrace = ((target, constructorOpt) => {
|
|
55
|
+
try {
|
|
56
|
+
;
|
|
57
|
+
strict.call(Error, target, constructorOpt);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// The runtime refused to decorate this object. A missing `.stack` is not worth failing over.
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
Object.defineProperty(Error, RELAXED, { value: true, configurable: true });
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// The property is frozen or non-writable. Nothing can be done about the import that follows, but
|
|
67
|
+
// failing HERE would replace vite's own error with an assignment error - strictly less
|
|
68
|
+
// informative than letting the import fail and be reported by the caller.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Import the project's Vite, with the stack-trace contract restored first.
|
|
73
|
+
*
|
|
74
|
+
* Callers keep their own error message - the dev server and the production build fail for the same
|
|
75
|
+
* reasons but need to say different things - so this deliberately lets the cause through untouched.
|
|
76
|
+
* Use {@link isViteUnresolved} to tell "vite is not installed" from "vite failed to load".
|
|
77
|
+
*/
|
|
78
|
+
export async function importVite() {
|
|
79
|
+
relaxCaptureStackTrace();
|
|
80
|
+
return (await import("vite"));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Whether a failed {@link importVite} means vite is absent rather than broken.
|
|
84
|
+
*
|
|
85
|
+
* `vite` is an OPTIONAL peer, so a project without it fails to RESOLVE - the common case, and the one
|
|
86
|
+
* worth naming. But vite can also resolve and fail while EVALUATING, which is not a missing dependency
|
|
87
|
+
* at all: vite 8 loads rolldown's native binding on import, and a binding that will not load surfaces
|
|
88
|
+
* the same way. Telling that user to install what they already installed sends them the wrong way.
|
|
89
|
+
*/
|
|
90
|
+
export function isViteUnresolved(cause) {
|
|
91
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
92
|
+
return /cannot find (?:package|module)|ERR_MODULE_NOT_FOUND/i.test(message);
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=vite-import.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vite-import.js","sourceRoot":"","sources":["../../src/internal/vite-import.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,8EAA8E;AAC9E,MAAM,OAAO,GAAkB,MAAM,CAAC,GAAG,CAAC,0CAA0C,CAAC,CAAA;AAErF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,MAAM,UAAU,sBAAsB;IACpC,MAAM,MAAM,GAAG,KAAK,CAAC,iBAAiB,CAAA;IACtC,IAAI,OAAO,MAAM,KAAK,UAAU;QAAE,OAAM;IACxC,IAAK,KAAiD,CAAC,OAAO,CAAC,KAAK,IAAI;QAAE,OAAM;IAChF,IAAI,CAAC;QACH,KAAK,CAAC,iBAAiB,GAAG,CAAC,CAAC,MAAc,EAAE,cAAwB,EAAE,EAAE;YACtE,IAAI,CAAC;gBACH,CAAC;gBAAC,MAA2C,CAAC,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,cAAc,CAAC,CAAA;YACnF,CAAC;YAAC,MAAM,CAAC;gBACP,6FAA6F;YAC/F,CAAC;QACH,CAAC,CAAmC,CAAA;QACpC,MAAM,CAAC,cAAc,CAAC,KAAK,EAAE,OAAO,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,YAAY,EAAE,IAAI,EAAE,CAAC,CAAA;IAC5E,CAAC;IAAC,MAAM,CAAC;QACP,iGAAiG;QACjG,uFAAuF;QACvF,0EAA0E;IAC5E,CAAC;AACH,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU;IAC9B,sBAAsB,EAAE,CAAA;IACxB,OAAO,CAAC,MAAM,MAAM,CAAC,MAAM,CAAC,CAAiB,CAAA;AAC/C,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAAc;IAC7C,MAAM,OAAO,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;IACtE,OAAO,sDAAsD,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;AAC7E,CAAC"}
|
package/dist/islands.d.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @nifrajs/web/islands
|
|
2
|
+
* @nifrajs/web/islands - the framework-agnostic islands client runtime.
|
|
3
3
|
*
|
|
4
4
|
* An "island" is a server-rendered `<nifra-island>` element whose interactivity is mounted by a small
|
|
5
5
|
* **plain-DOM** enhancer instead of a framework runtime. On a route that opts out of full-document
|
|
6
6
|
* hydration (`export const hydrate = false`), the page ships **zero** framework JS; only the island
|
|
7
7
|
* bundle (this runtime + your enhancers) loads. This variant ships ~99% less client JS than full
|
|
8
|
-
* React hydration on a mostly-static page
|
|
8
|
+
* React hydration on a mostly-static page - the framework runtime is the cost, and islands skip it
|
|
9
9
|
* entirely here.
|
|
10
10
|
*
|
|
11
11
|
* Server side: render the marker with `@nifrajs/web-react`'s `<Island>` (or any framework's host element
|
|
12
12
|
* `<nifra-island data-id data-strategy data-props>`). Props are JSON-encoded inline in `data-props`
|
|
13
|
-
* (attribute-escaped by the framework)
|
|
13
|
+
* (attribute-escaped by the framework) - no central registry, so concurrent SSR renders never share
|
|
14
14
|
* state. Client side: bundle one entry that calls `mountIslands({ <id>: enhancer })` and load it via
|
|
15
15
|
* the route's `islandScripts`.
|
|
16
16
|
*/
|
|
@@ -20,14 +20,14 @@ export type IslandStrategy = "load" | "idle" | "visible";
|
|
|
20
20
|
export type IslandCleanup = () => void;
|
|
21
21
|
/**
|
|
22
22
|
* Enhances one island element with its (typed) props. Return a cleanup function to tear down on
|
|
23
|
-
* `dispose()` (listeners, observers)
|
|
23
|
+
* `dispose()` (listeners, observers) - optional; an enhancer with nothing to clean up returns nothing.
|
|
24
24
|
* The `void` member is the no-cleanup case, the same shape as React's `EffectCallback`.
|
|
25
25
|
*/
|
|
26
26
|
export type IslandEnhancer<P = unknown> = (el: HTMLElement, props: P) => IslandCleanup | void;
|
|
27
27
|
/**
|
|
28
28
|
* Find every `<nifra-island data-id>` under `root` (default `document`) and enhance each with the
|
|
29
29
|
* matching enhancer, honoring its `data-strategy`. An island whose `id` has no enhancer is left as
|
|
30
|
-
* inert SSR HTML (forward-compatible). An enhancer that throws is isolated
|
|
30
|
+
* inert SSR HTML (forward-compatible). An enhancer that throws is isolated - it never blocks the
|
|
31
31
|
* others (each island is independent). Returns a disposer that cancels pending triggers and runs every
|
|
32
32
|
* enhancer's cleanup (call it on soft-nav teardown; harmless if your app never navigates).
|
|
33
33
|
*/
|
package/dist/islands.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @nifrajs/web/islands
|
|
2
|
+
* @nifrajs/web/islands - the framework-agnostic islands client runtime.
|
|
3
3
|
*
|
|
4
4
|
* An "island" is a server-rendered `<nifra-island>` element whose interactivity is mounted by a small
|
|
5
5
|
* **plain-DOM** enhancer instead of a framework runtime. On a route that opts out of full-document
|
|
6
6
|
* hydration (`export const hydrate = false`), the page ships **zero** framework JS; only the island
|
|
7
7
|
* bundle (this runtime + your enhancers) loads. This variant ships ~99% less client JS than full
|
|
8
|
-
* React hydration on a mostly-static page
|
|
8
|
+
* React hydration on a mostly-static page - the framework runtime is the cost, and islands skip it
|
|
9
9
|
* entirely here.
|
|
10
10
|
*
|
|
11
11
|
* Server side: render the marker with `@nifrajs/web-react`'s `<Island>` (or any framework's host element
|
|
12
12
|
* `<nifra-island data-id data-strategy data-props>`). Props are JSON-encoded inline in `data-props`
|
|
13
|
-
* (attribute-escaped by the framework)
|
|
13
|
+
* (attribute-escaped by the framework) - no central registry, so concurrent SSR renders never share
|
|
14
14
|
* state. Client side: bundle one entry that calls `mountIslands({ <id>: enhancer })` and load it via
|
|
15
15
|
* the route's `islandScripts`.
|
|
16
16
|
*/
|
|
@@ -63,7 +63,7 @@ function whenStrategy(el, strategy, run) {
|
|
|
63
63
|
/**
|
|
64
64
|
* Find every `<nifra-island data-id>` under `root` (default `document`) and enhance each with the
|
|
65
65
|
* matching enhancer, honoring its `data-strategy`. An island whose `id` has no enhancer is left as
|
|
66
|
-
* inert SSR HTML (forward-compatible). An enhancer that throws is isolated
|
|
66
|
+
* inert SSR HTML (forward-compatible). An enhancer that throws is isolated - it never blocks the
|
|
67
67
|
* others (each island is independent). Returns a disposer that cancels pending triggers and runs every
|
|
68
68
|
* enhancer's cleanup (call it on soft-nav teardown; harmless if your app never navigates).
|
|
69
69
|
*/
|
|
@@ -99,7 +99,7 @@ export function mountIslands(enhancers, options = {}) {
|
|
|
99
99
|
dispose();
|
|
100
100
|
}
|
|
101
101
|
catch {
|
|
102
|
-
// best-effort teardown
|
|
102
|
+
// best-effort teardown - a failing cleanup must not block the rest
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
};
|
package/dist/isr.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** A cached SSR response
|
|
1
|
+
/** A cached SSR response - the bytes + metadata a {@link CacheStore} persists. */
|
|
2
2
|
export interface CachedResponse {
|
|
3
3
|
/** The rendered document (UTF-8 HTML, fully buffered). */
|
|
4
4
|
readonly body: string;
|
|
@@ -25,7 +25,7 @@ export interface CacheStore {
|
|
|
25
25
|
delete(key: string): Promise<void>;
|
|
26
26
|
}
|
|
27
27
|
export interface MemoryCacheStoreOptions {
|
|
28
|
-
/** Allow the in-memory store in production. Off by default
|
|
28
|
+
/** Allow the in-memory store in production. Off by default - per-instance caching means revalidation
|
|
29
29
|
* won't propagate across instances and each instance caches separately. */
|
|
30
30
|
readonly allowInProduction?: boolean;
|
|
31
31
|
/** Hard cap on entries; the least-recently-used is evicted past it (default 500). */
|
|
@@ -33,7 +33,7 @@ export interface MemoryCacheStoreOptions {
|
|
|
33
33
|
}
|
|
34
34
|
/**
|
|
35
35
|
* In-process ISR cache. Refuses to run in production unless explicitly allowed (mirrors the
|
|
36
|
-
* rate-limit `MemoryStore`
|
|
36
|
+
* rate-limit `MemoryStore` - a per-instance cache is unsafe across instances). Bounded **LRU**: a
|
|
37
37
|
* read or write bumps the entry, so the least-recently-used evicts past `max` (a hot, frequently-read
|
|
38
38
|
* page survives a burst of new pages).
|
|
39
39
|
*/
|
|
@@ -46,7 +46,7 @@ export declare class MemoryCacheStore implements CacheStore {
|
|
|
46
46
|
delete(key: string): Promise<void>;
|
|
47
47
|
}
|
|
48
48
|
/**
|
|
49
|
-
* Minimal structural shape of a Cloudflare Workers **KV namespace** binding
|
|
49
|
+
* Minimal structural shape of a Cloudflare Workers **KV namespace** binding - just the three methods
|
|
50
50
|
* {@link KVCacheStore} uses. Structural (not a dependency on `@cloudflare/workers-types`) so any
|
|
51
51
|
* KV-like binding satisfies it and tests can pass an in-memory double.
|
|
52
52
|
*/
|
|
@@ -63,16 +63,16 @@ export interface KVNamespaceLike {
|
|
|
63
63
|
export interface KVCacheStoreOptions {
|
|
64
64
|
/**
|
|
65
65
|
* GC backstop (**seconds**) written as the KV entry's `expirationTtl`, so abandoned entries
|
|
66
|
-
* eventually evict. MUST exceed your longest `revalidate` window
|
|
66
|
+
* eventually evict. MUST exceed your longest `revalidate` window - otherwise KV expiry turns a
|
|
67
67
|
* stale-while-revalidate into a *blocking* miss (the entry vanishes instead of being served stale
|
|
68
|
-
* while it regenerates)
|
|
68
|
+
* while it regenerates) - and be ≥ 60 (Cloudflare KV's minimum). Omit ⇒ entries persist until
|
|
69
69
|
* overwritten on regeneration or purged via `revalidateEndpoint`.
|
|
70
70
|
*/
|
|
71
71
|
readonly expirationTtl?: number;
|
|
72
72
|
}
|
|
73
73
|
/**
|
|
74
74
|
* A {@link CacheStore} backed by a **Cloudflare Workers KV** namespace (or any {@link KVNamespaceLike}
|
|
75
|
-
* binding)
|
|
75
|
+
* binding) - the production-grade shared/durable store ISR wants: cached pages and on-demand purges
|
|
76
76
|
* hold *across* worker instances (unlike the per-instance {@link MemoryCacheStore}). Entries serialize
|
|
77
77
|
* to JSON; every read is validated before it's trusted (a malformed/version-skewed entry is treated as
|
|
78
78
|
* a miss). Construct it in your Workers `fetch` from the binding: `new KVCacheStore(env.ISR_CACHE)`.
|
|
@@ -85,12 +85,12 @@ export declare class KVCacheStore implements CacheStore {
|
|
|
85
85
|
set(key: string, value: CachedResponse): Promise<void>;
|
|
86
86
|
delete(key: string): Promise<void>;
|
|
87
87
|
}
|
|
88
|
-
/** Minimal platform shape `withISR` needs
|
|
88
|
+
/** Minimal platform shape `withISR` needs - just `waitUntil` (edge runtimes extend the response
|
|
89
89
|
* lifetime so background regeneration finishes). Off-edge it's absent and regen runs fire-and-forget. */
|
|
90
90
|
export interface ISRPlatform {
|
|
91
91
|
readonly waitUntil?: (promise: Promise<unknown>) => void;
|
|
92
92
|
}
|
|
93
|
-
/** The app `withISR` wraps
|
|
93
|
+
/** The app `withISR` wraps - anything with a `fetch(req, platform?)` (a `createWebApp` result). */
|
|
94
94
|
export interface ISRApp {
|
|
95
95
|
fetch(req: Request, platform?: ISRPlatform): Response | Promise<Response>;
|
|
96
96
|
}
|
|
@@ -99,7 +99,7 @@ export interface ISRApp {
|
|
|
99
99
|
export declare const ISR_STATUS_HEADER = "x-nifra-isr";
|
|
100
100
|
/**
|
|
101
101
|
* Response header a route uses to advertise its ISR freshness (**seconds**) to a {@link withISR}
|
|
102
|
-
* wrapper
|
|
102
|
+
* wrapper - `createWebApp` emits it from a route's `export const revalidate`. Deliberately distinct
|
|
103
103
|
* from the action-revalidation `x-nifra-revalidate` header (a CSV path list the *client* parses to
|
|
104
104
|
* refetch): this one is an integer TTL the *wrapper* reads, so the two channels never alias.
|
|
105
105
|
*/
|
|
@@ -110,14 +110,14 @@ export interface ISROptions {
|
|
|
110
110
|
* behind). A route overrides it per-page via `export const revalidate` (surfaced as the
|
|
111
111
|
* `x-nifra-isr-revalidate` response header). */
|
|
112
112
|
readonly revalidate: number;
|
|
113
|
-
/** Monotonic clock (ms)
|
|
113
|
+
/** Monotonic clock (ms) - injected for testability; production passes `() => Date.now()`. */
|
|
114
114
|
readonly now: () => number;
|
|
115
115
|
/** Cache key for a request. Default: `origin + pathname + search` so host-routed apps do not share
|
|
116
116
|
* entries across tenants. Return `null` to bypass the cache for this request (it goes straight to the
|
|
117
117
|
* app, uncached). */
|
|
118
118
|
readonly key?: (req: Request) => string | null;
|
|
119
119
|
/** Draft/preview secret (the same one given to `createWebApp({ draftSecret })` + `enableDraft`). When
|
|
120
|
-
* set, a request carrying a valid signed draft cookie **bypasses the cache**
|
|
120
|
+
* set, a request carrying a valid signed draft cookie **bypasses the cache** - editors always render
|
|
121
121
|
* fresh, and a draft render is never written to the store (it can't poison the public cache). */
|
|
122
122
|
readonly draftSecret?: string;
|
|
123
123
|
}
|
|
@@ -125,7 +125,7 @@ export interface ISROptions {
|
|
|
125
125
|
* Wrap a nifra app with **Incremental Static Regeneration**: a cacheable page is served from
|
|
126
126
|
* {@link CacheStore} when fresh, served **stale while a fresh copy regenerates in the background**
|
|
127
127
|
* (`platform.waitUntil` on edge), or rendered + stored on a miss. Framework-agnostic (it caches the
|
|
128
|
-
* rendered bytes). Returns a `fetch(req, platform?)` handler
|
|
128
|
+
* rendered bytes). Returns a `fetch(req, platform?)` handler - hand it to `Bun.serve`/the Workers
|
|
129
129
|
* `export default`, etc. Regeneration is single-flight per key (no stampede on a hot stale page).
|
|
130
130
|
*
|
|
131
131
|
* Each route's freshness comes from the `revalidate` header the app sets (per-route
|
|
@@ -139,12 +139,12 @@ export interface RevalidateEndpointOptions {
|
|
|
139
139
|
readonly secret: string;
|
|
140
140
|
/** Header carrying the secret. Default `x-nifra-revalidate-token`. */
|
|
141
141
|
readonly tokenHeader?: string;
|
|
142
|
-
/** Map a to-purge path → its cache key
|
|
142
|
+
/** Map a to-purge path → its cache key - MUST match the `withISR` `key` fn. The default uses the
|
|
143
143
|
* revalidation request's origin plus the purged path, matching `withISR`'s default host-aware key. */
|
|
144
144
|
readonly key?: (path: string, req: Request) => string;
|
|
145
145
|
}
|
|
146
146
|
/**
|
|
147
|
-
* An **on-demand revalidation** (purge) endpoint
|
|
147
|
+
* An **on-demand revalidation** (purge) endpoint - a `fetch` handler that drops a path's cached entry
|
|
148
148
|
* so the next request re-renders. `POST` with the secret in the token header and the path as `?path=`
|
|
149
149
|
* or a JSON `{ "path": "/blog/x" }` body. The token is checked in **constant time** (wrong/missing →
|
|
150
150
|
* `401`); a missing/relative path → `400`; non-POST → `405`. Mount it on a nifra route, e.g.
|
package/dist/isr.js
CHANGED
|
@@ -10,7 +10,7 @@ import { isDraftEnabled } from "./draft.js";
|
|
|
10
10
|
import { timingSafeEqual } from "./internal/timing-safe-equal.js";
|
|
11
11
|
/**
|
|
12
12
|
* In-process ISR cache. Refuses to run in production unless explicitly allowed (mirrors the
|
|
13
|
-
* rate-limit `MemoryStore`
|
|
13
|
+
* rate-limit `MemoryStore` - a per-instance cache is unsafe across instances). Bounded **LRU**: a
|
|
14
14
|
* read or write bumps the entry, so the least-recently-used evicts past `max` (a hot, frequently-read
|
|
15
15
|
* page survives a burst of new pages).
|
|
16
16
|
*/
|
|
@@ -29,7 +29,7 @@ export class MemoryCacheStore {
|
|
|
29
29
|
get(key) {
|
|
30
30
|
const value = this.cache.get(key);
|
|
31
31
|
// LRU: a read bumps the entry to the tail so the bounded evict drops the LEAST-RECENTLY-USED,
|
|
32
|
-
// not the oldest-written
|
|
32
|
+
// not the oldest-written - otherwise a burst of new pages would evict a hot, frequently-read one.
|
|
33
33
|
if (value !== undefined) {
|
|
34
34
|
this.cache.delete(key);
|
|
35
35
|
this.cache.set(key, value);
|
|
@@ -77,7 +77,7 @@ const isCachedResponse = (value) => {
|
|
|
77
77
|
};
|
|
78
78
|
/**
|
|
79
79
|
* A {@link CacheStore} backed by a **Cloudflare Workers KV** namespace (or any {@link KVNamespaceLike}
|
|
80
|
-
* binding)
|
|
80
|
+
* binding) - the production-grade shared/durable store ISR wants: cached pages and on-demand purges
|
|
81
81
|
* hold *across* worker instances (unlike the per-instance {@link MemoryCacheStore}). Entries serialize
|
|
82
82
|
* to JSON; every read is validated before it's trusted (a malformed/version-skewed entry is treated as
|
|
83
83
|
* a miss). Construct it in your Workers `fetch` from the binding: `new KVCacheStore(env.ISR_CACHE)`.
|
|
@@ -122,7 +122,7 @@ export class KVCacheStore {
|
|
|
122
122
|
export const ISR_STATUS_HEADER = "x-nifra-isr";
|
|
123
123
|
/**
|
|
124
124
|
* Response header a route uses to advertise its ISR freshness (**seconds**) to a {@link withISR}
|
|
125
|
-
* wrapper
|
|
125
|
+
* wrapper - `createWebApp` emits it from a route's `export const revalidate`. Deliberately distinct
|
|
126
126
|
* from the action-revalidation `x-nifra-revalidate` header (a CSV path list the *client* parses to
|
|
127
127
|
* refetch): this one is an integer TTL the *wrapper* reads, so the two channels never alias.
|
|
128
128
|
*/
|
|
@@ -198,7 +198,7 @@ function sanitizeCachedHeaders(headers) {
|
|
|
198
198
|
* Wrap a nifra app with **Incremental Static Regeneration**: a cacheable page is served from
|
|
199
199
|
* {@link CacheStore} when fresh, served **stale while a fresh copy regenerates in the background**
|
|
200
200
|
* (`platform.waitUntil` on edge), or rendered + stored on a miss. Framework-agnostic (it caches the
|
|
201
|
-
* rendered bytes). Returns a `fetch(req, platform?)` handler
|
|
201
|
+
* rendered bytes). Returns a `fetch(req, platform?)` handler - hand it to `Bun.serve`/the Workers
|
|
202
202
|
* `export default`, etc. Regeneration is single-flight per key (no stampede on a hot stale page).
|
|
203
203
|
*
|
|
204
204
|
* Each route's freshness comes from the `revalidate` header the app sets (per-route
|
|
@@ -234,7 +234,7 @@ export function withISR(app, options) {
|
|
|
234
234
|
headers: { ...entry.headers, [ISR_STATUS_HEADER]: "miss" },
|
|
235
235
|
});
|
|
236
236
|
};
|
|
237
|
-
// Background regeneration (single-flight per key)
|
|
237
|
+
// Background regeneration (single-flight per key) - a failed regen keeps the stale entry; the next
|
|
238
238
|
// stale hit retries. Never throws (it runs detached / under waitUntil).
|
|
239
239
|
const regenerate = async (req, platform, key) => {
|
|
240
240
|
if (regenerating.has(key))
|
|
@@ -288,7 +288,7 @@ export function withISR(app, options) {
|
|
|
288
288
|
}
|
|
289
289
|
const jsonError = (status, error) => Response.json({ ok: false, error }, { status });
|
|
290
290
|
/**
|
|
291
|
-
* An **on-demand revalidation** (purge) endpoint
|
|
291
|
+
* An **on-demand revalidation** (purge) endpoint - a `fetch` handler that drops a path's cached entry
|
|
292
292
|
* so the next request re-renders. `POST` with the secret in the token header and the path as `?path=`
|
|
293
293
|
* or a JSON `{ "path": "/blog/x" }` body. The token is checked in **constant time** (wrong/missing →
|
|
294
294
|
* `401`); a missing/relative path → `400`; non-POST → `405`. Mount it on a nifra route, e.g.
|