void 0.10.8 → 0.10.10
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/dist/{auth-cmd-DlgNwByu.mjs → auth-cmd-Cw1edJdZ.mjs} +2 -2
- package/dist/{build-cmd-Br8qL0rA.mjs → build-cmd-C6kNDJBv.mjs} +1 -1
- package/dist/{cache-wH-mP8UE.mjs → cache-DIUSnIjJ.mjs} +1 -1
- package/dist/{cancel-deploy-BEBOEgtu.mjs → cancel-deploy-BosnzEHC.mjs} +1 -1
- package/dist/cli/cli.mjs +77 -28
- package/dist/{client-Cj96iiBH.mjs → client-C9FG6Vzc.mjs} +8 -0
- package/dist/{config-1twldYCW.mjs → config-BcAeIPe3.mjs} +6 -0
- package/dist/{create-project-DD9n8Ho-.mjs → create-project-CjYX23M_.mjs} +1 -1
- package/dist/{db-DjKE2-A-.mjs → db-BvKV34IK.mjs} +4 -4
- package/dist/{delete-D2kr3Kmk.mjs → delete-CThKLXfi.mjs} +1 -1
- package/dist/{deploy-C4PbkFyE.mjs → deploy-B02JyKP9.mjs} +59 -16
- package/dist/{domain-_luIsM_B.mjs → domain-DMrBabBQ.mjs} +1 -1
- package/dist/{env-CO5XAS9t.mjs → env-BfG7O71F.mjs} +1 -1
- package/dist/env-raw-Cx8ElDdj.mjs +58 -0
- package/dist/{gen-Cf79J4aw.mjs → gen-Bee261SV.mjs} +1 -1
- package/dist/{github-cmd-B6OX9c7d.mjs → github-cmd-DfeFEasr.mjs} +101 -2
- package/dist/{headers-BwvFGhkx.mjs → headers-CTAjX-UO.mjs} +1 -1
- package/dist/index.d.mts +13 -1
- package/dist/index.mjs +70 -31
- package/dist/{init-KirOzVDs.mjs → init-xfwW7cLp.mjs} +4 -4
- package/dist/{link-CUmiosyb.mjs → link-Co136MEs.mjs} +2 -2
- package/dist/{list-3GEw7b6m.mjs → list-BKDjA3M5.mjs} +1 -1
- package/dist/{login-DJReaT_Q.mjs → login-CrOTnR_6.mjs} +1 -1
- package/dist/{logs-D-rQ56Lq.mjs → logs-DlAg25ww.mjs} +1 -1
- package/dist/{mcp-D7yc0dXY.mjs → mcp-BNjMGMB0.mjs} +1 -1
- package/dist/{node-yFFk626c.mjs → node-CnE-SZCk.mjs} +1 -1
- package/dist/pages/index.mjs +2 -2
- package/dist/{prepare-C_cVurhP.mjs → prepare-BRyC4TCC.mjs} +1 -1
- package/dist/{project-cmd-DnU7u9QF.mjs → project-cmd-B2bcKefD.mjs} +8 -8
- package/dist/{rollback-Yh7bCKob.mjs → rollback-9dfmB-YZ.mjs} +1 -1
- package/dist/runtime/ai.mjs +113 -15
- package/dist/runtime/env-public.mjs +9 -4
- package/dist/runtime/env.d.mts +14 -1
- package/dist/runtime/env.mjs +24 -10
- package/dist/runtime/isr-cache.d.mts +207 -0
- package/dist/runtime/isr-cache.mjs +523 -0
- package/dist/runtime/isr.d.mts +4 -3
- package/dist/runtime/isr.mjs +44 -5
- package/dist/runtime/live.mjs +1 -1
- package/dist/runtime/sandbox.mjs +1 -1
- package/dist/{secret-u7FRvg8d.mjs → secret-DeMnMcV0.mjs} +1 -1
- package/package.json +3 -2
- package/schema.json +11 -0
- package/skills/void/docs/integrations/cloudflare.md +107 -0
- package/skills/void/docs/node_modules/void/{AGENTS.md → CLAUDE.md} +14 -0
- package/skills/void/docs/reference/cli.md +23 -0
- package/dist/env-raw-CoS20LHP.mjs +0 -32
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
//#region src/runtime/isr-cache.ts
|
|
2
|
+
/**
|
|
3
|
+
* Build the normalized path segment for an ISR-cached page.
|
|
4
|
+
* Root path `/` maps to `__index__` to avoid trailing-slash ambiguity.
|
|
5
|
+
*/
|
|
6
|
+
function normalizeIsrPath(pathname) {
|
|
7
|
+
let normalized = pathname;
|
|
8
|
+
if (normalized === "/") normalized = "__index__";
|
|
9
|
+
else {
|
|
10
|
+
normalized = normalized.startsWith("/") ? normalized.slice(1) : normalized;
|
|
11
|
+
if (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
|
|
12
|
+
}
|
|
13
|
+
return normalized;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Inverse of `normalizeIsrPath`: turn a normalized segment back into a
|
|
17
|
+
* pathname starting with `/`.
|
|
18
|
+
*/
|
|
19
|
+
function denormalizeIsrPath(normalized) {
|
|
20
|
+
return normalized === "__index__" ? "/" : `/${normalized}`;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Canonicalize an inbound pathname to the form used inside edge cache URLs.
|
|
24
|
+
* Equivalent to `denormalizeIsrPath(normalizeIsrPath(pathname))` — strips any
|
|
25
|
+
* trailing slash except for root.
|
|
26
|
+
*/
|
|
27
|
+
function canonicalIsrPathname(pathname) {
|
|
28
|
+
return denormalizeIsrPath(normalizeIsrPath(pathname));
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* KV cache key for an ISR-cached page. Deployment- AND host-scoped: cached HTML
|
|
32
|
+
* cannot outlive the hashed assets of the deployment that produced it (a fresh
|
|
33
|
+
* `wrangler deploy` mints a new deployment id → cold cache), and one host's
|
|
34
|
+
* cached content never bleeds into another host served by the same worker
|
|
35
|
+
* (custom domain + workers.dev, apex + www, etc.).
|
|
36
|
+
*/
|
|
37
|
+
function isrKey(deploymentId, host, pathname) {
|
|
38
|
+
return `void-isr/${deploymentId}/${host}/${normalizeIsrPath(pathname)}`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Host-registry key. Every host that has written cached content registers itself
|
|
42
|
+
* under `void-isr-hosts/<id>/<host>` (value `''`) so a purge with no request host
|
|
43
|
+
* (cron / queue `revalidate({ paths })`) can enumerate the hosts to purge.
|
|
44
|
+
* Scoped by deployment id so registrations from an old deploy do not leak.
|
|
45
|
+
*/
|
|
46
|
+
function hostRegistryKey(deploymentId, host) {
|
|
47
|
+
return `void-isr-hosts/${deploymentId}/${host}`;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Edge-cache (`caches.default`) key for the HTML variant. The public URL stays
|
|
51
|
+
* clean; this internal key carries the deployment id so a new deploy never
|
|
52
|
+
* reads HTML produced by an older deploy.
|
|
53
|
+
*/
|
|
54
|
+
function htmlEdgeKey(host, pathname, id) {
|
|
55
|
+
return `https://${host}${canonicalIsrPathname(pathname)}?__void_isr=${id}`;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Edge-cache key for the JSON (pages-protocol) variant. Separated from HTML by
|
|
59
|
+
* the `&__void=json` marker.
|
|
60
|
+
*/
|
|
61
|
+
function jsonEdgeKey(host, pathname, id) {
|
|
62
|
+
return `${htmlEdgeKey(host, pathname, id)}&__void=json`;
|
|
63
|
+
}
|
|
64
|
+
const MAX_CACHE = 256;
|
|
65
|
+
const literalRegexCache = /* @__PURE__ */ new Map();
|
|
66
|
+
/**
|
|
67
|
+
* Match a pattern against a pathname using LITERAL semantics — no named
|
|
68
|
+
* captures. Two wildcards are special: `*` matches any characters INCLUDING `/`
|
|
69
|
+
* (a splat), and a WHOLE-segment `:name` (preceded by `/` or start, followed by
|
|
70
|
+
* `/` or end) matches exactly ONE path segment (`[^/]+`). A mid-segment literal
|
|
71
|
+
* `:` (e.g. `/api/v1:health`) is left alone. This is a strict SUPERSET of the
|
|
72
|
+
* old `*`-only matcher (old `*`-only manifests match identically). Malformed
|
|
73
|
+
* patterns yield `false` rather than throwing.
|
|
74
|
+
*/
|
|
75
|
+
function matchLiteralPattern(pattern, pathname) {
|
|
76
|
+
let regex = literalRegexCache.get(pattern);
|
|
77
|
+
if (regex) {
|
|
78
|
+
literalRegexCache.delete(pattern);
|
|
79
|
+
literalRegexCache.set(pattern, regex);
|
|
80
|
+
} else {
|
|
81
|
+
try {
|
|
82
|
+
const escaped = pattern.replace(/(^|\/):[A-Za-z0-9_]+(?=\/|$)/g, "$1").replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", ".*").replaceAll("", "[^/]+");
|
|
83
|
+
regex = new RegExp(`^${escaped}$`);
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
if (literalRegexCache.size >= MAX_CACHE) {
|
|
88
|
+
const first = literalRegexCache.keys().next();
|
|
89
|
+
if (!first.done) literalRegexCache.delete(first.value);
|
|
90
|
+
}
|
|
91
|
+
literalRegexCache.set(pattern, regex);
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
return regex.test(pathname);
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Pack response headers + body into a single ArrayBuffer.
|
|
101
|
+
* Format: [4 bytes uint32 LE header length][UTF-8 header JSON][raw body]
|
|
102
|
+
*/
|
|
103
|
+
function packIsrValue(headers, body) {
|
|
104
|
+
const headerJson = new TextEncoder().encode(JSON.stringify(headers));
|
|
105
|
+
const buffer = new ArrayBuffer(4 + headerJson.byteLength + body.byteLength);
|
|
106
|
+
new DataView(buffer).setUint32(0, headerJson.byteLength, true);
|
|
107
|
+
new Uint8Array(buffer, 4, headerJson.byteLength).set(headerJson);
|
|
108
|
+
new Uint8Array(buffer, 4 + headerJson.byteLength).set(new Uint8Array(body));
|
|
109
|
+
return buffer;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Unpack headers + body from a packed ArrayBuffer.
|
|
113
|
+
* Returns null if the buffer is too small or malformed.
|
|
114
|
+
*/
|
|
115
|
+
function unpackIsrValue(buffer) {
|
|
116
|
+
if (buffer.byteLength < 4) return null;
|
|
117
|
+
const headerLen = new DataView(buffer).getUint32(0, true);
|
|
118
|
+
if (buffer.byteLength < 4 + headerLen) return null;
|
|
119
|
+
try {
|
|
120
|
+
const headerBytes = new Uint8Array(buffer, 4, headerLen);
|
|
121
|
+
return {
|
|
122
|
+
headers: JSON.parse(new TextDecoder().decode(headerBytes)),
|
|
123
|
+
body: buffer.slice(4 + headerLen)
|
|
124
|
+
};
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Read an ISR cache entry from KV. Returns null on cache miss, malformed
|
|
131
|
+
* metadata, or ANY KV error (fail-open → caller renders live). The KV key is
|
|
132
|
+
* deployment-scoped via `env.__VOID_ISR_DEPLOYMENT_ID` (see module header) and
|
|
133
|
+
* host-scoped via `host` so one host never reads another host's cached bytes.
|
|
134
|
+
*/
|
|
135
|
+
async function readIsrCache(env, host, pathname) {
|
|
136
|
+
const kv = env.ISR_CACHE;
|
|
137
|
+
if (!kv) return null;
|
|
138
|
+
const key = isrKey(env.__VOID_ISR_DEPLOYMENT_ID ?? "", host, pathname);
|
|
139
|
+
let value;
|
|
140
|
+
let metadata;
|
|
141
|
+
try {
|
|
142
|
+
const result = await kv.getWithMetadata(key, "arrayBuffer");
|
|
143
|
+
value = result.value;
|
|
144
|
+
metadata = result.metadata;
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
if (!value || !metadata) return null;
|
|
149
|
+
const unpacked = unpackIsrValue(value);
|
|
150
|
+
if (!unpacked) return null;
|
|
151
|
+
if (!Number.isInteger(metadata.s) || metadata.s < 200 || metadata.s > 599) return null;
|
|
152
|
+
if (!Number.isFinite(metadata.c) || metadata.c <= 0 || !Number.isFinite(metadata.t) || metadata.t <= 0) return null;
|
|
153
|
+
try {
|
|
154
|
+
new Headers(unpacked.headers);
|
|
155
|
+
} catch {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
body: new ReadableStream({ start(controller) {
|
|
160
|
+
controller.enqueue(new Uint8Array(unpacked.body));
|
|
161
|
+
controller.close();
|
|
162
|
+
} }),
|
|
163
|
+
status: metadata.s,
|
|
164
|
+
headers: unpacked.headers,
|
|
165
|
+
createdAt: metadata.c,
|
|
166
|
+
ttl: metadata.t
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Write an ISR cache entry to KV. The `response.clone()` is taken SYNCHRONOUSLY
|
|
171
|
+
* before any `await` so a caller that consumes `response.body` for the client
|
|
172
|
+
* response (after kicking us off via `ctx.waitUntil`) cannot race the body
|
|
173
|
+
* away. Any KV error is swallowed (fail-open on the write path).
|
|
174
|
+
*/
|
|
175
|
+
async function writeIsrCache(env, host, pathname, response, ttl) {
|
|
176
|
+
const kv = env.ISR_CACHE;
|
|
177
|
+
if (!kv) return;
|
|
178
|
+
const responseClone = response.clone();
|
|
179
|
+
const headers = serializeHeaders(responseClone);
|
|
180
|
+
const status = responseClone.status;
|
|
181
|
+
const id = env.__VOID_ISR_DEPLOYMENT_ID ?? "";
|
|
182
|
+
try {
|
|
183
|
+
const packed = packIsrValue(headers, await responseClone.arrayBuffer());
|
|
184
|
+
await kv.put(isrKey(id, host, pathname), packed, { metadata: {
|
|
185
|
+
s: status,
|
|
186
|
+
c: Date.now(),
|
|
187
|
+
t: ttl
|
|
188
|
+
} });
|
|
189
|
+
} catch {}
|
|
190
|
+
await registerIsrHost(kv, id, host);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* In-isolate guard: `${id}/${host}` values whose host-registry key this isolate
|
|
194
|
+
* has already written (or attempted). Bounds host registration to ~one KV write
|
|
195
|
+
* per (isolate, deployment, host) instead of one per page write.
|
|
196
|
+
*/
|
|
197
|
+
const registeredHosts = /* @__PURE__ */ new Set();
|
|
198
|
+
async function registerIsrHost(kv, deploymentId, host) {
|
|
199
|
+
const guardKey = `${deploymentId}/${host}`;
|
|
200
|
+
if (registeredHosts.has(guardKey)) return;
|
|
201
|
+
registeredHosts.add(guardKey);
|
|
202
|
+
try {
|
|
203
|
+
await kv.put(hostRegistryKey(deploymentId, host), "");
|
|
204
|
+
} catch {
|
|
205
|
+
registeredHosts.delete(guardKey);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* List every host that has registered cached content for a deployment. Used by
|
|
210
|
+
* the on-demand `revalidate({ paths })` purge when there is no request host
|
|
211
|
+
* (cron / queue). Fail-open: a registry read error yields the hosts collected so
|
|
212
|
+
* far (or none) rather than throwing — degraded purge coverage, never a 500.
|
|
213
|
+
*/
|
|
214
|
+
async function listIsrHosts(kv, deploymentId) {
|
|
215
|
+
const prefix = `void-isr-hosts/${deploymentId}/`;
|
|
216
|
+
const hosts = [];
|
|
217
|
+
try {
|
|
218
|
+
let cursor;
|
|
219
|
+
do {
|
|
220
|
+
const list = await kv.list({
|
|
221
|
+
prefix,
|
|
222
|
+
cursor
|
|
223
|
+
});
|
|
224
|
+
for (const key of list.keys) hosts.push(key.name.slice(prefix.length));
|
|
225
|
+
cursor = list.list_complete ? void 0 : list.cursor;
|
|
226
|
+
} while (cursor);
|
|
227
|
+
} catch {}
|
|
228
|
+
return hosts;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Delete an ISR cache entry from KV. Used when a worker permanently opts out
|
|
232
|
+
* via `x-revalidate: 0`. Any KV error is swallowed (fail-open).
|
|
233
|
+
*/
|
|
234
|
+
async function deleteIsrCache(env, host, pathname) {
|
|
235
|
+
const kv = env.ISR_CACHE;
|
|
236
|
+
if (!kv) return;
|
|
237
|
+
try {
|
|
238
|
+
await kv.delete(isrKey(env.__VOID_ISR_DEPLOYMENT_ID ?? "", host, pathname));
|
|
239
|
+
} catch {}
|
|
240
|
+
}
|
|
241
|
+
/** Check if a cached entry is stale. */
|
|
242
|
+
function isStale(entry) {
|
|
243
|
+
return Date.now() - entry.createdAt > entry.ttl * 1e3;
|
|
244
|
+
}
|
|
245
|
+
/** Build a Response from an ISR cache entry. */
|
|
246
|
+
function isrCacheResponse(entry, stale) {
|
|
247
|
+
const headers = new Headers(entry.headers);
|
|
248
|
+
headers.set("x-isr-cache", stale ? "STALE" : "HIT");
|
|
249
|
+
if (stale) {
|
|
250
|
+
const staleTtl = Math.min(10, entry.ttl);
|
|
251
|
+
headers.set("Cache-Control", `public, s-maxage=${staleTtl}, max-age=0, must-revalidate`);
|
|
252
|
+
} else {
|
|
253
|
+
const remainingSeconds = Math.max(0, Math.floor((entry.createdAt + entry.ttl * 1e3 - Date.now()) / 1e3));
|
|
254
|
+
headers.set("Cache-Control", `public, s-maxage=${remainingSeconds}, max-age=0, must-revalidate`);
|
|
255
|
+
}
|
|
256
|
+
return new Response(entry.body, {
|
|
257
|
+
status: entry.status,
|
|
258
|
+
headers
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Read the `x-revalidate` header from a worker response.
|
|
263
|
+
* Returns the override TTL, or the default. A value of 0 means "do not cache".
|
|
264
|
+
*/
|
|
265
|
+
function resolveRevalidateTtl(response, defaultTtl) {
|
|
266
|
+
const header = response.headers.get("x-revalidate");
|
|
267
|
+
if (header === null) return defaultTtl;
|
|
268
|
+
const value = parseInt(header, 10);
|
|
269
|
+
if (isNaN(value) || value < 0) return defaultTtl;
|
|
270
|
+
return value;
|
|
271
|
+
}
|
|
272
|
+
function acceptsEventStream(request) {
|
|
273
|
+
return request.headers.get("Accept")?.toLowerCase().split(",").some((part) => part.split(";", 1)[0]?.trim() === "text/event-stream") ?? false;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* ISR should be bypassed for requests with credentials and EventSource
|
|
277
|
+
* requests. Pages protocol navigation (X-VoidPages) uses a separate JSON edge
|
|
278
|
+
* cache.
|
|
279
|
+
*/
|
|
280
|
+
function shouldBypassIsr(request) {
|
|
281
|
+
return request.headers.has("Cookie") || request.headers.has("Authorization") || acceptsEventStream(request);
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Resolve the ISR TTL for a given pathname.
|
|
285
|
+
* Accepts the `revalidate` config (number, object, or undefined). For the
|
|
286
|
+
* object form, the first matching pattern wins.
|
|
287
|
+
*/
|
|
288
|
+
function resolvePathTtl(revalidate, pathname) {
|
|
289
|
+
if (revalidate == null) return 0;
|
|
290
|
+
if (typeof revalidate === "number") return revalidate;
|
|
291
|
+
for (const [pattern, ttl] of Object.entries(revalidate)) if (matchLiteralPattern(pattern, pathname)) return ttl;
|
|
292
|
+
return 0;
|
|
293
|
+
}
|
|
294
|
+
/** Extract response headers as a plain object, skipping ISR-managed headers. */
|
|
295
|
+
const SKIP_HEADERS = /* @__PURE__ */ new Set([
|
|
296
|
+
"cache-control",
|
|
297
|
+
"x-isr-cache",
|
|
298
|
+
"x-void-page-data",
|
|
299
|
+
"set-cookie"
|
|
300
|
+
]);
|
|
301
|
+
function serializeHeaders(response) {
|
|
302
|
+
const result = {};
|
|
303
|
+
response.headers.forEach((value, key) => {
|
|
304
|
+
if (!SKIP_HEADERS.has(key.toLowerCase())) result[key] = value;
|
|
305
|
+
});
|
|
306
|
+
return result;
|
|
307
|
+
}
|
|
308
|
+
const PAGE_DATA_SCRIPT_RE = /<script\b[^>]*\bid=(["'])__VOID_PAGE_DATA__\1[^>]*>([\s\S]*?)<\/script>/i;
|
|
309
|
+
function normalizePageDataForJsonCache(raw) {
|
|
310
|
+
try {
|
|
311
|
+
const parsed = JSON.parse(raw);
|
|
312
|
+
if (parsed.deferred !== void 0 || parsed.deferredKeys !== void 0) return null;
|
|
313
|
+
return raw;
|
|
314
|
+
} catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
function extractPageDataFromHtml(html) {
|
|
319
|
+
const match = html.match(PAGE_DATA_SCRIPT_RE);
|
|
320
|
+
if (!match) return null;
|
|
321
|
+
return normalizePageDataForJsonCache(match[2]);
|
|
322
|
+
}
|
|
323
|
+
const CLIENT_REVALIDATE_CACHE_CONTROL = "public, max-age=0, must-revalidate";
|
|
324
|
+
function clientRevalidateResponse(response) {
|
|
325
|
+
const res = new Response(response.body, response);
|
|
326
|
+
res.headers.set("Cache-Control", CLIENT_REVALIDATE_CACHE_CONTROL);
|
|
327
|
+
return res;
|
|
328
|
+
}
|
|
329
|
+
function isEventStreamResponse(response) {
|
|
330
|
+
return response.headers.get("content-type")?.toLowerCase().split(";", 1)[0]?.trim() === "text/event-stream";
|
|
331
|
+
}
|
|
332
|
+
function jsonCacheResponse(pageData, ttl, state) {
|
|
333
|
+
return new Response(pageData, { headers: {
|
|
334
|
+
"content-type": "application/json",
|
|
335
|
+
"X-VoidPages": "true",
|
|
336
|
+
Vary: "X-VoidPages",
|
|
337
|
+
"Cache-Control": `public, s-maxage=${ttl}, max-age=0, must-revalidate`,
|
|
338
|
+
"x-isr-cache": state
|
|
339
|
+
} });
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Populate the JSON edge cache from a response's embedded page data (the
|
|
343
|
+
* `x-void-page-data` header or the `__VOID_PAGE_DATA__` inline script). No-ops
|
|
344
|
+
* when there is no extractable page data. Any cache error is swallowed
|
|
345
|
+
* (fail-open) so this is always safe to fire inside `ctx.waitUntil`.
|
|
346
|
+
*/
|
|
347
|
+
async function populateJsonEdgeCache(cache, cacheKey, response, ttl, state) {
|
|
348
|
+
if (!cache) return;
|
|
349
|
+
try {
|
|
350
|
+
const fromHeader = response.headers.get("x-void-page-data");
|
|
351
|
+
const pageData = fromHeader ? normalizePageDataForJsonCache(fromHeader) : extractPageDataFromHtml(await response.clone().text());
|
|
352
|
+
if (!pageData) return;
|
|
353
|
+
await cache.put(cacheKey, jsonCacheResponse(pageData, ttl, state));
|
|
354
|
+
} catch {}
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* The per-colo edge cache (`caches.default`), or `undefined` when the Cache API
|
|
358
|
+
* is absent/degraded. Accessed through a cast because this package has both
|
|
359
|
+
* `@types/node` and `@cloudflare/workers-types` in scope, and Node's ambient
|
|
360
|
+
* `CacheStorage` (which lacks `.default`) otherwise shadows the Workers one on
|
|
361
|
+
* the `caches` global.
|
|
362
|
+
*
|
|
363
|
+
* Non-throwing by contract: dereferencing `caches.default` can throw if the
|
|
364
|
+
* Cache API is missing (e.g. a non-worker runtime) or degraded. We swallow that
|
|
365
|
+
* here and return `undefined` so `handleIsr` never rejects before its fail-open
|
|
366
|
+
* guards run — the serving invariant is a degraded cache must render live, never
|
|
367
|
+
* 500. Every call site guards `undefined` (via `if (!cache)` or `cache?.`).
|
|
368
|
+
*/
|
|
369
|
+
function edgeCache() {
|
|
370
|
+
try {
|
|
371
|
+
return caches.default;
|
|
372
|
+
} catch {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
async function safeCachePut(cache, key, response) {
|
|
377
|
+
if (!cache) return;
|
|
378
|
+
try {
|
|
379
|
+
await cache.put(key, response);
|
|
380
|
+
} catch {}
|
|
381
|
+
}
|
|
382
|
+
async function safeCacheDelete(cache, key) {
|
|
383
|
+
if (!cache) return;
|
|
384
|
+
try {
|
|
385
|
+
await cache.delete(key);
|
|
386
|
+
} catch {}
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Build a new Request with the `X-VoidPages` header removed. Headers on an
|
|
390
|
+
* existing Request are immutable, so we must clone into a fresh Request. ISR
|
|
391
|
+
* only runs for GET, so there is no body to preserve.
|
|
392
|
+
*/
|
|
393
|
+
function cloneWithoutVoidPages(request) {
|
|
394
|
+
const headers = new Headers(request.headers);
|
|
395
|
+
headers.delete("X-VoidPages");
|
|
396
|
+
return new Request(request, { headers });
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Revalidate an ISR page in the background. Re-renders as HTML (X-VoidPages
|
|
400
|
+
* stripped), writes fresh HTML to KV + HTML edge cache, and populates the JSON
|
|
401
|
+
* edge cache from embedded page data. Errors are caught and logged — serve
|
|
402
|
+
* stale, don't retry. `ttl === 0` means the worker permanently opted out:
|
|
403
|
+
* delete the KV entry and both edge variants.
|
|
404
|
+
*/
|
|
405
|
+
async function revalidateInBackground(env, request, host, pathname, defaultTtl, keys, render) {
|
|
406
|
+
try {
|
|
407
|
+
const response = await render(cloneWithoutVoidPages(request));
|
|
408
|
+
if (isEventStreamResponse(response)) return;
|
|
409
|
+
const ttl = resolveRevalidateTtl(response, defaultTtl);
|
|
410
|
+
const cache = edgeCache();
|
|
411
|
+
if (ttl > 0 && response.status === 200) {
|
|
412
|
+
await writeIsrCache(env, host, pathname, response, ttl);
|
|
413
|
+
const headers = new Headers(response.headers);
|
|
414
|
+
headers.set("x-isr-cache", "HIT");
|
|
415
|
+
headers.set("Cache-Control", `public, s-maxage=${ttl}, max-age=0, must-revalidate`);
|
|
416
|
+
headers.delete("set-cookie");
|
|
417
|
+
headers.delete("x-void-page-data");
|
|
418
|
+
await safeCachePut(cache, keys.htmlKey, new Response(response.clone().body, {
|
|
419
|
+
status: response.status,
|
|
420
|
+
headers
|
|
421
|
+
}));
|
|
422
|
+
await populateJsonEdgeCache(cache, keys.jsonKey, response, ttl, "HIT");
|
|
423
|
+
} else if (ttl === 0) {
|
|
424
|
+
await deleteIsrCache(env, host, pathname);
|
|
425
|
+
await safeCacheDelete(cache, keys.htmlKey);
|
|
426
|
+
await safeCacheDelete(cache, keys.jsonKey);
|
|
427
|
+
}
|
|
428
|
+
} catch (err) {
|
|
429
|
+
console.error("ISR revalidation failed:", err);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Run the ISR caching ladder for a request.
|
|
434
|
+
*
|
|
435
|
+
* Returns a cached/rendered `Response` when ISR applies, or `null` when the
|
|
436
|
+
* caller should render live itself (request not ISR-eligible, or a read/hit
|
|
437
|
+
* cache op failed open).
|
|
438
|
+
*
|
|
439
|
+
* Ladder:
|
|
440
|
+
* - Bail (`null`) if not GET, a Range request, `shouldBypassIsr`, or the path
|
|
441
|
+
* has no ISR TTL. Range requests bail BEFORE any cache read/write so they
|
|
442
|
+
* render live (correct 206) and never touch the shared full-response entry.
|
|
443
|
+
* - JSON (X-VoidPages) path: edge match → hit serves `clientRevalidateResponse`;
|
|
444
|
+
* miss renders live JSON and returns it — the JSON path never writes the edge
|
|
445
|
+
* cache. The JSON edge variant is warmed only as a side-effect of the HTML
|
|
446
|
+
* render path (cold colo re-renders JSON).
|
|
447
|
+
* - HTML path: L1 edge match → L2 KV. Hit serves `isrCacheResponse`
|
|
448
|
+
* (stale hits also queue a background revalidation). Miss renders live and,
|
|
449
|
+
* when cacheable, writes KV + JSON edge + HTML edge in the background.
|
|
450
|
+
*/
|
|
451
|
+
async function handleIsr(request, env, ctx, options, render) {
|
|
452
|
+
const { revalidate, deploymentId } = options;
|
|
453
|
+
if (request.method !== "GET") return null;
|
|
454
|
+
if (request.headers.has("range")) return null;
|
|
455
|
+
if (shouldBypassIsr(request)) return null;
|
|
456
|
+
const url = new URL(request.url);
|
|
457
|
+
const pathname = url.pathname;
|
|
458
|
+
const isrTtl = resolvePathTtl(revalidate, pathname);
|
|
459
|
+
if (isrTtl <= 0) return null;
|
|
460
|
+
env.__VOID_ISR_DEPLOYMENT_ID ??= deploymentId;
|
|
461
|
+
const host = url.hostname;
|
|
462
|
+
const cache = edgeCache();
|
|
463
|
+
if (request.headers.has("X-VoidPages")) {
|
|
464
|
+
const jsonKey = jsonEdgeKey(host, pathname, deploymentId);
|
|
465
|
+
let jsonCached;
|
|
466
|
+
try {
|
|
467
|
+
jsonCached = await cache?.match(jsonKey);
|
|
468
|
+
} catch {
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
if (jsonCached) return clientRevalidateResponse(jsonCached);
|
|
472
|
+
return render(request);
|
|
473
|
+
}
|
|
474
|
+
const htmlKey = htmlEdgeKey(host, pathname, deploymentId);
|
|
475
|
+
const jsonKey = jsonEdgeKey(host, pathname, deploymentId);
|
|
476
|
+
let edgeCached;
|
|
477
|
+
try {
|
|
478
|
+
edgeCached = await cache?.match(htmlKey);
|
|
479
|
+
} catch {
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
if (edgeCached) return clientRevalidateResponse(edgeCached);
|
|
483
|
+
const cached = await readIsrCache(env, host, pathname);
|
|
484
|
+
if (cached) try {
|
|
485
|
+
const stale = isStale(cached);
|
|
486
|
+
if (stale) ctx.waitUntil(revalidateInBackground(env, request, host, pathname, isrTtl, {
|
|
487
|
+
htmlKey,
|
|
488
|
+
jsonKey
|
|
489
|
+
}, render));
|
|
490
|
+
const cacheResponse = isrCacheResponse(cached, stale);
|
|
491
|
+
ctx.waitUntil(safeCachePut(cache, htmlKey, cacheResponse.clone()));
|
|
492
|
+
return clientRevalidateResponse(cacheResponse);
|
|
493
|
+
} catch {
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
const response = await render(request);
|
|
497
|
+
if (isEventStreamResponse(response)) return response;
|
|
498
|
+
const ttl = resolveRevalidateTtl(response, isrTtl);
|
|
499
|
+
if (ttl > 0 && response.status === 200) {
|
|
500
|
+
ctx.waitUntil(writeIsrCache(env, host, pathname, response, ttl));
|
|
501
|
+
ctx.waitUntil(populateJsonEdgeCache(cache, jsonKey, response, ttl, "MISS"));
|
|
502
|
+
const cacheHeaders = new Headers(response.headers);
|
|
503
|
+
cacheHeaders.set("Cache-Control", `public, s-maxage=${ttl}, max-age=0, must-revalidate`);
|
|
504
|
+
cacheHeaders.set("x-isr-cache", "MISS");
|
|
505
|
+
cacheHeaders.delete("x-void-page-data");
|
|
506
|
+
cacheHeaders.delete("set-cookie");
|
|
507
|
+
const cacheResponse = new Response(response.clone().body, {
|
|
508
|
+
status: response.status,
|
|
509
|
+
headers: cacheHeaders
|
|
510
|
+
});
|
|
511
|
+
ctx.waitUntil(safeCachePut(cache, htmlKey, cacheResponse));
|
|
512
|
+
const clientHeaders = new Headers(cacheHeaders);
|
|
513
|
+
clientHeaders.set("Cache-Control", CLIENT_REVALIDATE_CACHE_CONTROL);
|
|
514
|
+
for (const cookie of response.headers.getSetCookie()) clientHeaders.append("set-cookie", cookie);
|
|
515
|
+
return new Response(response.body, {
|
|
516
|
+
status: response.status,
|
|
517
|
+
headers: clientHeaders
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
return response;
|
|
521
|
+
}
|
|
522
|
+
//#endregion
|
|
523
|
+
export { canonicalIsrPathname, clientRevalidateResponse, cloneWithoutVoidPages, deleteIsrCache, denormalizeIsrPath, extractPageDataFromHtml, handleIsr, hostRegistryKey, htmlEdgeKey, isEventStreamResponse, isStale, isrCacheResponse, isrKey, jsonEdgeKey, listIsrHosts, matchLiteralPattern, normalizeIsrPath, normalizePageDataForJsonCache, packIsrValue, populateJsonEdgeCache, readIsrCache, resolvePathTtl, resolveRevalidateTtl, serializeHeaders, shouldBypassIsr, unpackIsrValue, writeIsrCache };
|
package/dist/runtime/isr.d.mts
CHANGED
|
@@ -6,9 +6,10 @@ type RevalidateOptions = {
|
|
|
6
6
|
/**
|
|
7
7
|
* Revalidate ISR-cached pages on demand.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
* to the API for R2 deletion + edge cache purge.
|
|
11
|
-
*
|
|
9
|
+
* On managed Void, calls the platform proxy via service binding which forwards
|
|
10
|
+
* to the API for R2 deletion + edge cache purge. On self-host, purges this
|
|
11
|
+
* worker's own ISR_CACHE (KV) entries plus the caches.default edge variants.
|
|
12
|
+
* When neither is configured (e.g. local dev with no ISR), this is a no-op.
|
|
12
13
|
*
|
|
13
14
|
* @example
|
|
14
15
|
* ```ts
|
package/dist/runtime/isr.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { a as getRawRuntimeEnv } from "../env-raw-Cx8ElDdj.mjs";
|
|
2
|
+
import { denormalizeIsrPath, htmlEdgeKey, isrKey, jsonEdgeKey, listIsrHosts } from "./isr-cache.mjs";
|
|
2
3
|
//#region src/runtime/isr.ts
|
|
3
4
|
/**
|
|
4
5
|
* Revalidate ISR-cached pages on demand.
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
* to the API for R2 deletion + edge cache purge.
|
|
8
|
-
*
|
|
7
|
+
* On managed Void, calls the platform proxy via service binding which forwards
|
|
8
|
+
* to the API for R2 deletion + edge cache purge. On self-host, purges this
|
|
9
|
+
* worker's own ISR_CACHE (KV) entries plus the caches.default edge variants.
|
|
10
|
+
* When neither is configured (e.g. local dev with no ISR), this is a no-op.
|
|
9
11
|
*
|
|
10
12
|
* @example
|
|
11
13
|
* ```ts
|
|
@@ -21,7 +23,44 @@ import { i as getRawRuntimeEnv } from "../env-raw-CoS20LHP.mjs";
|
|
|
21
23
|
async function revalidate(options) {
|
|
22
24
|
const rawEnv = getRawRuntimeEnv();
|
|
23
25
|
const proxy = rawEnv.__VOID_PROXY;
|
|
24
|
-
if (!proxy)
|
|
26
|
+
if (!proxy) {
|
|
27
|
+
const kv = rawEnv.ISR_CACHE;
|
|
28
|
+
const id = rawEnv.__VOID_ISR_DEPLOYMENT_ID;
|
|
29
|
+
if (!kv || !id) return;
|
|
30
|
+
const requestHost = rawEnv.__VOID_ISR_HOST;
|
|
31
|
+
const purge = async (host, path) => {
|
|
32
|
+
await kv.delete(isrKey(id, host, path));
|
|
33
|
+
try {
|
|
34
|
+
const cache = caches.default;
|
|
35
|
+
await cache.delete(htmlEdgeKey(host, path, id));
|
|
36
|
+
await cache.delete(jsonEdgeKey(host, path, id));
|
|
37
|
+
} catch {}
|
|
38
|
+
};
|
|
39
|
+
if (options.all) {
|
|
40
|
+
const prefix = `void-isr/${id}/`;
|
|
41
|
+
let cursor;
|
|
42
|
+
do {
|
|
43
|
+
const list = await kv.list({
|
|
44
|
+
prefix,
|
|
45
|
+
cursor
|
|
46
|
+
});
|
|
47
|
+
for (const key of list.keys) {
|
|
48
|
+
const tail = key.name.slice(prefix.length);
|
|
49
|
+
const slash = tail.indexOf("/");
|
|
50
|
+
if (slash < 0) continue;
|
|
51
|
+
await purge(tail.slice(0, slash), denormalizeIsrPath(tail.slice(slash + 1)));
|
|
52
|
+
}
|
|
53
|
+
cursor = list.list_complete ? void 0 : list.cursor;
|
|
54
|
+
} while (cursor);
|
|
55
|
+
} else {
|
|
56
|
+
const paths = options.paths ?? [];
|
|
57
|
+
if (paths.length === 0) return;
|
|
58
|
+
const hosts = new Set(await listIsrHosts(kv, id));
|
|
59
|
+
if (requestHost) hosts.add(requestHost);
|
|
60
|
+
for (const host of hosts) for (const path of paths) await purge(host, path);
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
25
64
|
const projectId = rawEnv.__PROJECT_ID;
|
|
26
65
|
const token = rawEnv.__VOID_PROXY_TOKEN;
|
|
27
66
|
if (!projectId || !token) throw new Error("isr: Revalidation requires __PROJECT_ID and __VOID_PROXY_TOKEN bindings.");
|
package/dist/runtime/live.mjs
CHANGED
package/dist/runtime/sandbox.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { a as getRawRuntimeEnv } from "../env-raw-Cx8ElDdj.mjs";
|
|
2
2
|
import { requireRuntimeBinding } from "./env.mjs";
|
|
3
3
|
import { Sandbox, getSandbox as getSandbox$1 } from "@cloudflare/sandbox";
|
|
4
4
|
//#region src/runtime/sandbox.ts
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { D as outro, E as note, O as password, S as confirm, T as log, a as cliTitle, j as isCancel, w as intro, x as cancel } from "./agents-Bmr5tFFb.mjs";
|
|
2
|
-
import { b as readProjectConfig, f as getToken, n as PlatformClient } from "./client-
|
|
2
|
+
import { b as readProjectConfig, f as getToken, n as PlatformClient } from "./client-C9FG6Vzc.mjs";
|
|
3
3
|
import { r as resolveProjectBySlug, t as getRequestedProjectSlug } from "./resolve-project-D2HI3TrG.mjs";
|
|
4
4
|
import { readFileSync } from "node:fs";
|
|
5
5
|
//#region src/cli/secret.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "void",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.10",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/voidzero-dev/void.git",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"#self/runtime/live-server": "./dist/runtime/live-server.mjs",
|
|
39
39
|
"#self/runtime/fetch": "./dist/runtime/fetch.mjs",
|
|
40
40
|
"#self/runtime/fetch-stream": "./dist/runtime/fetch-stream.mjs",
|
|
41
|
+
"#self/runtime/isr-cache": "./dist/runtime/isr-cache.mjs",
|
|
41
42
|
"#self/runtime/migration-handler": "./dist/runtime/migration-handler.mjs",
|
|
42
43
|
"#self/runtime/migration-handler-pg": "./dist/runtime/migration-handler-pg.mjs",
|
|
43
44
|
"#self/runtime/ws-server": "./dist/runtime/ws-server.mjs"
|
|
@@ -353,7 +354,7 @@
|
|
|
353
354
|
"valibot": ">=1.0.0-beta.7",
|
|
354
355
|
"vite": "^8.0.0",
|
|
355
356
|
"zod": "^3.25.0 || ^4.0.0",
|
|
356
|
-
"@void/md": "0.10.
|
|
357
|
+
"@void/md": "0.10.10"
|
|
357
358
|
},
|
|
358
359
|
"peerDependenciesMeta": {
|
|
359
360
|
"@void/md": {
|
package/schema.json
CHANGED
|
@@ -363,6 +363,17 @@
|
|
|
363
363
|
"type": "string",
|
|
364
364
|
"enum": ["pg"],
|
|
365
365
|
"description": "Database backend. Omit for D1 (SQLite, default). Set to \"pg\" for PostgreSQL via Hyperdrive."
|
|
366
|
+
},
|
|
367
|
+
"ai": {
|
|
368
|
+
"type": "object",
|
|
369
|
+
"description": "AI configuration for self-hosted deploys. Configures the Cloudflare AI Gateway used by ai.provider().fetch() (Workers AI ai.run/ai.image use the env.AI binding directly and are not affected).",
|
|
370
|
+
"properties": {
|
|
371
|
+
"gateway": {
|
|
372
|
+
"type": "string",
|
|
373
|
+
"description": "Cloudflare AI Gateway name/slug to route ai.provider().fetch() through your own account."
|
|
374
|
+
}
|
|
375
|
+
},
|
|
376
|
+
"additionalProperties": false
|
|
366
377
|
}
|
|
367
378
|
},
|
|
368
379
|
"additionalProperties": false
|