@vinext/cloudflare 1.2.0 → 1.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/dist/cache/app-page-parts.d.ts +13 -0
- package/dist/cache/app-page-parts.js +18 -0
- package/dist/cache/cdn-adapter-config.d.ts +2 -1
- package/dist/cache/cdn-adapter.runtime.d.ts +1 -0
- package/dist/cache/cdn-adapter.runtime.js +1 -0
- package/dist/cache/cdn-adapter.worker.js +222 -34
- package/dist/cache/config.d.ts +2 -1
- package/dist/cache/kv-data-adapter.runtime.d.ts +7 -3
- package/dist/cache/kv-data-adapter.runtime.js +62 -15
- package/dist/cache/response-store-adapter.worker.d.ts +2 -1
- package/dist/cache/response-store-adapter.worker.js +163 -53
- package/dist/cache/response-store-cdn.runtime.js +4 -1
- package/dist/cache/response-store-data.runtime.d.ts +12 -0
- package/dist/cache/response-store-data.runtime.js +51 -12
- package/dist/cache/static-assets-adapter.runtime.d.ts +2 -1
- package/dist/cache/workers-cache-invalidation.d.ts +2 -1
- package/dist/cdn-warm.d.ts +2 -1
- package/dist/cli.js +2 -0
- package/dist/deploy-config.d.ts +4 -4
- package/dist/deploy-config.js +10 -7
- package/dist/deploy-help.js +5 -3
- package/dist/deploy.d.ts +5 -1
- package/dist/deploy.js +24 -10
- package/dist/previews-deploy.d.ts +1 -0
- package/dist/previews-deploy.js +4 -2
- package/dist/utils/cache-control-metadata.d.ts +5 -0
- package/dist/utils/cache-control-metadata.js +9 -1
- package/dist/version-headers.js +1 -2
- package/dist/wrangler-config.d.ts +2 -1
- package/package.json +7 -8
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//#region src/cache/app-page-parts.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Whether the response-stage props select an App page's own document or RSC
|
|
4
|
+
* payload, the two parts Next.js keeps in one entry, rather than a variant.
|
|
5
|
+
*/
|
|
6
|
+
export declare function isCanonicalAppPage(responseStageProps: unknown, isRscRequest: boolean): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Whether a canonical RSC request has a document part. A `.rsc` path reaches
|
|
9
|
+
* its page's HTML path only through the app's trailingSlash policy, and
|
|
10
|
+
* vinext's client never sends one, so it stores its payload alone.
|
|
11
|
+
*/
|
|
12
|
+
export declare function hasDocumentPart(rscRequestUrl: string): boolean;
|
|
13
|
+
//#endregion
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
//#region src/cache/app-page-parts.ts
|
|
2
|
+
/**
|
|
3
|
+
* Whether the response-stage props select an App page's own document or RSC
|
|
4
|
+
* payload, the two parts Next.js keeps in one entry, rather than a variant.
|
|
5
|
+
*/
|
|
6
|
+
function isCanonicalAppPage(responseStageProps, isRscRequest) {
|
|
7
|
+
return responseStageProps !== null && typeof responseStageProps === "object" && Reflect.get(responseStageProps, "kind") === "app-page" && Reflect.get(responseStageProps, "isRscRequest") === isRscRequest && (Reflect.get(responseStageProps, "matchKind") === "request" || Reflect.get(responseStageProps, "matchKind") === "resolved") && (!isRscRequest || Reflect.get(responseStageProps, "renderMode") === "navigation") && Reflect.get(responseStageProps, "interceptionContext") === null && Reflect.get(responseStageProps, "interceptionId") === null && Reflect.get(responseStageProps, "mountedSlotsHeader") === null;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Whether a canonical RSC request has a document part. A `.rsc` path reaches
|
|
11
|
+
* its page's HTML path only through the app's trailingSlash policy, and
|
|
12
|
+
* vinext's client never sends one, so it stores its payload alone.
|
|
13
|
+
*/
|
|
14
|
+
function hasDocumentPart(rscRequestUrl) {
|
|
15
|
+
return !new URL(rscRequestUrl).pathname.endsWith(".rsc");
|
|
16
|
+
}
|
|
17
|
+
//#endregion
|
|
18
|
+
export { hasDocumentPart, isCanonicalAppPage };
|
|
@@ -13,6 +13,7 @@ export declare class CloudflareCdnCacheAdapter implements CdnCacheAdapter {
|
|
|
13
13
|
private readonly versionMetadata?;
|
|
14
14
|
private readonly versionMetadataBinding;
|
|
15
15
|
readonly requiresCompletedResponseAdmission = true;
|
|
16
|
+
readonly responseStageCacheIdentity: "host-free";
|
|
16
17
|
readonly responseVary: "verbatim";
|
|
17
18
|
readonly responsePolicy: {
|
|
18
19
|
isHeader(name: string): boolean;
|
|
@@ -150,6 +150,7 @@ var CloudflareCdnCacheAdapter = class {
|
|
|
150
150
|
versionMetadata;
|
|
151
151
|
versionMetadataBinding;
|
|
152
152
|
requiresCompletedResponseAdmission = true;
|
|
153
|
+
responseStageCacheIdentity = "host-free";
|
|
153
154
|
responseVary = "verbatim";
|
|
154
155
|
responsePolicy = {
|
|
155
156
|
isHeader(name) {
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { VINEXT_CDN_BUILD_ID_HEADER, getVinextCdnBuildIdentity } from "./cdn-build-id.js";
|
|
2
|
+
import { hasDocumentPart, isCanonicalAppPage } from "./app-page-parts.js";
|
|
2
3
|
import { SHARED_RESPONSE_STAGE_HEADER, finalizeGatewayResponse } from "./browser-cache-policy.js";
|
|
3
4
|
import { hasCacheMethod, invalidateOrPurge } from "./workers-cache-invalidation.js";
|
|
4
5
|
import { VINEXT_PRERENDER_READINESS_PATH, VINEXT_RSC_VARY_HEADER } from "vinext/internal/server/headers";
|
|
6
|
+
import { createCanonicalRscRequestHeaders, createCanonicalRscRequestUrl, stripRscCacheBustingSearchParam } from "vinext/internal/server/app-rsc-cache-busting";
|
|
5
7
|
import { isNonCacheableCacheControl, splitCacheControlDirectives } from "vinext/shims/cdn-cache";
|
|
6
8
|
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
7
9
|
import "vinext/internal/server/cloudflare-workers-tracing";
|
|
@@ -15,11 +17,29 @@ const AUTHORIZATION_TRANSPORT_HEADER = "x-vinext-internal-authorization";
|
|
|
15
17
|
const REQUEST_CACHE_CONTROL_TRANSPORT_HEADER = "x-vinext-internal-request-cache-control";
|
|
16
18
|
const REQUEST_CF_TRANSPORT_HEADER = "x-vinext-internal-request-cf";
|
|
17
19
|
const REQUEST_PRAGMA_TRANSPORT_HEADER = "x-vinext-internal-request-pragma";
|
|
20
|
+
const RENDER_WHOLE_DOCUMENT_TRANSPORT_HEADER = "x-vinext-internal-render-whole-document";
|
|
21
|
+
const DISPATCH_TRANSPORT_HEADER = "x-vinext-internal-dispatch";
|
|
22
|
+
/** The stage props a host-free cache identity replaces. */
|
|
23
|
+
const HOST_BEARING_PROPS = /* @__PURE__ */ new Set([
|
|
24
|
+
"requestHost",
|
|
25
|
+
"requestOrigin",
|
|
26
|
+
"requestUrl"
|
|
27
|
+
]);
|
|
18
28
|
const WARMUP_USER_AGENT = "vinext-cloudflare-cdn-warm";
|
|
19
29
|
const RESPONSE_STAGE_WIRE_CACHE = {
|
|
20
30
|
bypass: "vinext-cloudflare-v1:bypass",
|
|
21
31
|
shared: "vinext-cloudflare-v1:shared"
|
|
22
32
|
};
|
|
33
|
+
/**
|
|
34
|
+
* The statuses of a part Workers Cache just rendered for its entry, inline or
|
|
35
|
+
* as a background refresh of a stale one.
|
|
36
|
+
* https://developers.cloudflare.com/workers/cache/debugging/
|
|
37
|
+
*/
|
|
38
|
+
const RENDERED_ENTRY_CACHE_STATUSES = /* @__PURE__ */ new Set([
|
|
39
|
+
"EXPIRED",
|
|
40
|
+
"MISS",
|
|
41
|
+
"UPDATING"
|
|
42
|
+
]);
|
|
23
43
|
const FRAMEWORK_RESPONSE_VARY_FIELDS = new Set(VINEXT_RSC_VARY_HEADER.split(",").map((name) => name.trim().toLowerCase()));
|
|
24
44
|
function isResponseStageReadinessRequest(request) {
|
|
25
45
|
return request.url.includes(VINEXT_PRERENDER_READINESS_PATH) && new URL(request.url).pathname === VINEXT_PRERENDER_READINESS_PATH;
|
|
@@ -63,20 +83,25 @@ function validateResponseStageBuildIdentity(response) {
|
|
|
63
83
|
return responseStageUnavailable();
|
|
64
84
|
}
|
|
65
85
|
function stripUntrustedTransportHeaders(request) {
|
|
66
|
-
if (!request.headers.has(AUTHORIZATION_TRANSPORT_HEADER) && !request.headers.has(REQUEST_CACHE_CONTROL_TRANSPORT_HEADER) && !request.headers.has(REQUEST_CF_TRANSPORT_HEADER) && !request.headers.has(REQUEST_PRAGMA_TRANSPORT_HEADER)) return request;
|
|
86
|
+
if (!request.headers.has(AUTHORIZATION_TRANSPORT_HEADER) && !request.headers.has(REQUEST_CACHE_CONTROL_TRANSPORT_HEADER) && !request.headers.has(REQUEST_CF_TRANSPORT_HEADER) && !request.headers.has(REQUEST_PRAGMA_TRANSPORT_HEADER) && !request.headers.has(RENDER_WHOLE_DOCUMENT_TRANSPORT_HEADER) && !request.headers.has(DISPATCH_TRANSPORT_HEADER)) return request;
|
|
67
87
|
const headers = new Headers(request.headers);
|
|
68
88
|
headers.delete(AUTHORIZATION_TRANSPORT_HEADER);
|
|
69
89
|
headers.delete(REQUEST_CACHE_CONTROL_TRANSPORT_HEADER);
|
|
70
90
|
headers.delete(REQUEST_CF_TRANSPORT_HEADER);
|
|
71
91
|
headers.delete(REQUEST_PRAGMA_TRANSPORT_HEADER);
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
92
|
+
headers.delete(RENDER_WHOLE_DOCUMENT_TRANSPORT_HEADER);
|
|
93
|
+
headers.delete(DISPATCH_TRANSPORT_HEADER);
|
|
94
|
+
return withRequestCf(new Request(request, { headers }), request);
|
|
95
|
+
}
|
|
96
|
+
/** Give a request built from another the platform's `request.cf` of that one. */
|
|
97
|
+
function withRequestCf(request, source) {
|
|
98
|
+
const requestCf = Reflect.get(source, "cf");
|
|
99
|
+
if (requestCf !== void 0) Object.defineProperty(request, "cf", {
|
|
75
100
|
configurable: true,
|
|
76
101
|
enumerable: true,
|
|
77
102
|
value: requestCf
|
|
78
103
|
});
|
|
79
|
-
return
|
|
104
|
+
return request;
|
|
80
105
|
}
|
|
81
106
|
function withWorkerHostRuntime(context, env) {
|
|
82
107
|
const candidateAssets = env && typeof env === "object" && Reflect.has(env, "ASSETS") ? Reflect.get(env, "ASSETS") : void 0;
|
|
@@ -100,7 +125,7 @@ function getResponseStageBinding(context, exportName, serializedInvocation) {
|
|
|
100
125
|
const target = binding({ props: JSON.parse(serializedInvocation) });
|
|
101
126
|
return hasFetch(target) ? target : null;
|
|
102
127
|
}
|
|
103
|
-
async function createCacheFacingRequest(request, serializedInvocation) {
|
|
128
|
+
async function createCacheFacingRequest(request, serializedInvocation, identityRequest, dispatchRestore) {
|
|
104
129
|
const authorization = request.headers.get("Authorization");
|
|
105
130
|
let serializedRequestCf = null;
|
|
106
131
|
const requestCf = Reflect.get(request, "cf");
|
|
@@ -109,9 +134,9 @@ async function createCacheFacingRequest(request, serializedInvocation) {
|
|
|
109
134
|
if (json !== void 0) serializedRequestCf = encodeURIComponent(json);
|
|
110
135
|
} catch {}
|
|
111
136
|
const authorizationIdentity = authorization === null ? "absent" : `present:${authorization.length}:${authorization}`;
|
|
112
|
-
const frameworkVaryIdentity = JSON.stringify([...FRAMEWORK_RESPONSE_VARY_FIELDS].map((name) => [name,
|
|
137
|
+
const frameworkVaryIdentity = JSON.stringify([...FRAMEWORK_RESPONSE_VARY_FIELDS].map((name) => [name, identityRequest.headers.get(name)]));
|
|
113
138
|
const responseStageBuildIdentity = getVinextCdnBuildIdentity() ?? "";
|
|
114
|
-
const bytes = new TextEncoder().encode(`${
|
|
139
|
+
const bytes = new TextEncoder().encode(`${identityRequest.url}\0${serializedInvocation}\0${authorizationIdentity}\0${frameworkVaryIdentity}\0${responseStageBuildIdentity}`);
|
|
115
140
|
const key = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
116
141
|
const url = new URL(request.url);
|
|
117
142
|
url.searchParams.set("__vinext_cache_key", key);
|
|
@@ -125,7 +150,9 @@ async function createCacheFacingRequest(request, serializedInvocation) {
|
|
|
125
150
|
headers.delete(REQUEST_CACHE_CONTROL_TRANSPORT_HEADER);
|
|
126
151
|
headers.delete(REQUEST_CF_TRANSPORT_HEADER);
|
|
127
152
|
headers.delete(REQUEST_PRAGMA_TRANSPORT_HEADER);
|
|
153
|
+
headers.delete(DISPATCH_TRANSPORT_HEADER);
|
|
128
154
|
if (authorization !== null) headers.set(AUTHORIZATION_TRANSPORT_HEADER, encodeURIComponent(authorization));
|
|
155
|
+
if (dispatchRestore !== null) headers.set(DISPATCH_TRANSPORT_HEADER, encodeURIComponent(JSON.stringify(dispatchRestore)));
|
|
129
156
|
if (serializedRequestCf !== null) headers.set(REQUEST_CF_TRANSPORT_HEADER, serializedRequestCf);
|
|
130
157
|
if (requestCacheControl !== null) headers.set(REQUEST_CACHE_CONTROL_TRANSPORT_HEADER, encodeURIComponent(requestCacheControl));
|
|
131
158
|
if (requestPragma !== null) headers.set(REQUEST_PRAGMA_TRANSPORT_HEADER, encodeURIComponent(requestPragma));
|
|
@@ -149,6 +176,8 @@ function restoreResponseStageRequest(request, requestUrl, requestMethod) {
|
|
|
149
176
|
headers.delete(REQUEST_CACHE_CONTROL_TRANSPORT_HEADER);
|
|
150
177
|
headers.delete(REQUEST_CF_TRANSPORT_HEADER);
|
|
151
178
|
headers.delete(REQUEST_PRAGMA_TRANSPORT_HEADER);
|
|
179
|
+
headers.delete(RENDER_WHOLE_DOCUMENT_TRANSPORT_HEADER);
|
|
180
|
+
headers.delete(DISPATCH_TRANSPORT_HEADER);
|
|
152
181
|
if (serializedAuthorization !== null) try {
|
|
153
182
|
headers.set("Authorization", decodeURIComponent(serializedAuthorization));
|
|
154
183
|
} catch {}
|
|
@@ -199,11 +228,11 @@ function revalidatesStoredEntry(request) {
|
|
|
199
228
|
return request.headers.has("If-None-Match") || request.headers.has("If-Modified-Since");
|
|
200
229
|
}
|
|
201
230
|
/**
|
|
202
|
-
* No client waits on a deployment warm-up
|
|
203
|
-
* Response Store's.
|
|
231
|
+
* No client waits on a deployment warm-up, on a revalidation, as with the
|
|
232
|
+
* Response Store's, or on the gateway's fill of a page's document.
|
|
204
233
|
*/
|
|
205
234
|
function rendersWholeDocument(request) {
|
|
206
|
-
return request.headers.get("user-agent") === WARMUP_USER_AGENT || revalidatesStoredEntry(request);
|
|
235
|
+
return request.headers.get("user-agent") === WARMUP_USER_AGENT || request.headers.has(RENDER_WHOLE_DOCUMENT_TRANSPORT_HEADER) || revalidatesStoredEntry(request);
|
|
207
236
|
}
|
|
208
237
|
/**
|
|
209
238
|
* A conditional revalidation of a Pages page skips getStaticPaths(), so only a
|
|
@@ -328,6 +357,8 @@ function getResponseStageInvocation(value, expectedCache) {
|
|
|
328
357
|
} catch {
|
|
329
358
|
return null;
|
|
330
359
|
}
|
|
360
|
+
const restoresDispatch = Reflect.get(value, "restoresDispatch");
|
|
361
|
+
if (restoresDispatch !== void 0 && restoresDispatch !== true) return null;
|
|
331
362
|
return {
|
|
332
363
|
...typeof expectedResponseStageBuildIdentity === "string" ? { expectedResponseStageBuildIdentity } : {},
|
|
333
364
|
options: {
|
|
@@ -336,6 +367,117 @@ function getResponseStageInvocation(value, expectedCache) {
|
|
|
336
367
|
},
|
|
337
368
|
props: Reflect.get(value, "props"),
|
|
338
369
|
requestMethod,
|
|
370
|
+
requestUrl,
|
|
371
|
+
...restoresDispatch ? { restoresDispatch } : {}
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* The dispatch of the other part of the page a canonical App page part belongs
|
|
376
|
+
* to. Next.js keeps a page's document and RSC payload in one entry, so the two
|
|
377
|
+
* dispatches differ only in the RSC request selectors, the URL's `_rsc` and the
|
|
378
|
+
* request type in the props. The request keeps the client's other headers,
|
|
379
|
+
* minus those that would make the fetch conditional or partial. No client
|
|
380
|
+
* waits on a document fetched this way, so it is rendered whole, marked by a
|
|
381
|
+
* header that, unlike the props, does not key its entry.
|
|
382
|
+
*/
|
|
383
|
+
function otherPagePartDispatch(request, props) {
|
|
384
|
+
const isRscRequest = isCanonicalAppPage(props, true);
|
|
385
|
+
if (!isRscRequest && !isCanonicalAppPage(props, false)) return null;
|
|
386
|
+
if (isRscRequest && !hasDocumentPart(request.url)) return null;
|
|
387
|
+
const headers = new Headers(request.headers);
|
|
388
|
+
for (const name of FRAMEWORK_RESPONSE_VARY_FIELDS) headers.delete(name);
|
|
389
|
+
for (const name of [
|
|
390
|
+
"If-Match",
|
|
391
|
+
"If-Modified-Since",
|
|
392
|
+
"If-None-Match",
|
|
393
|
+
"If-Range",
|
|
394
|
+
"If-Unmodified-Since",
|
|
395
|
+
"Range"
|
|
396
|
+
]) headers.delete(name);
|
|
397
|
+
if (isRscRequest) {
|
|
398
|
+
const url = new URL(request.url);
|
|
399
|
+
stripRscCacheBustingSearchParam(url);
|
|
400
|
+
headers.delete("x-deployment-id");
|
|
401
|
+
headers.set("Accept", "text/html");
|
|
402
|
+
headers.set(RENDER_WHOLE_DOCUMENT_TRANSPORT_HEADER, "1");
|
|
403
|
+
return {
|
|
404
|
+
props: {
|
|
405
|
+
...props,
|
|
406
|
+
isRscRequest: false
|
|
407
|
+
},
|
|
408
|
+
request: withRequestCf(new Request(url, { headers }), request)
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
for (const [name, value] of createCanonicalRscRequestHeaders()) headers.set(name, value);
|
|
412
|
+
return {
|
|
413
|
+
props: {
|
|
414
|
+
...props,
|
|
415
|
+
isRscRequest: true,
|
|
416
|
+
renderMode: "navigation"
|
|
417
|
+
},
|
|
418
|
+
request: withRequestCf(new Request(new URL(createCanonicalRscRequestUrl(request.url), request.url), { headers }), request)
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
function isPlainObject(value) {
|
|
422
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
423
|
+
}
|
|
424
|
+
function pathAndSearch(url) {
|
|
425
|
+
return `${url.pathname}${url.search}`;
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* What a shared dispatch's host-free identity replaced: the request URL, and
|
|
429
|
+
* the top-level props that name the host. Null when the identity is anything
|
|
430
|
+
* else, so the dispatch keys itself, host and all.
|
|
431
|
+
*/
|
|
432
|
+
function getDispatchRestore(request, props, identity) {
|
|
433
|
+
if (identity.request.method !== request.method || pathAndSearch(new URL(identity.request.url)) !== pathAndSearch(new URL(request.url))) return null;
|
|
434
|
+
const dispatched = JSON.parse(JSON.stringify(props) ?? "null");
|
|
435
|
+
const keyed = JSON.parse(JSON.stringify(identity.props) ?? "null");
|
|
436
|
+
if (!isPlainObject(dispatched) || !isPlainObject(keyed)) return null;
|
|
437
|
+
if (Object.keys(keyed).some((name) => !Object.hasOwn(dispatched, name))) return null;
|
|
438
|
+
const replaced = {};
|
|
439
|
+
for (const [name, value] of Object.entries(dispatched)) {
|
|
440
|
+
if (JSON.stringify(value) === JSON.stringify(keyed[name])) continue;
|
|
441
|
+
if (!HOST_BEARING_PROPS.has(name)) return null;
|
|
442
|
+
replaced[name] = value;
|
|
443
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
props: replaced,
|
|
446
|
+
requestUrl: request.url
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Rebuild the dispatch a host-free invocation keys from the restore header
|
|
451
|
+
* the gateway sent. Null when an identity invocation has no valid restore.
|
|
452
|
+
*/
|
|
453
|
+
function restoreDispatchInvocation(request, invocation) {
|
|
454
|
+
if (!invocation.restoresDispatch) return invocation;
|
|
455
|
+
const serialized = request.headers.get(DISPATCH_TRANSPORT_HEADER);
|
|
456
|
+
if (serialized === null || !isPlainObject(invocation.props)) return null;
|
|
457
|
+
let restore;
|
|
458
|
+
try {
|
|
459
|
+
restore = JSON.parse(decodeURIComponent(serialized));
|
|
460
|
+
} catch {
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
if (!isPlainObject(restore) || !isPlainObject(restore.props)) return null;
|
|
464
|
+
if (Object.keys(restore.props).some((name) => !HOST_BEARING_PROPS.has(name))) return null;
|
|
465
|
+
const requestUrl = restore.requestUrl;
|
|
466
|
+
if (typeof requestUrl !== "string") return null;
|
|
467
|
+
let url;
|
|
468
|
+
try {
|
|
469
|
+
url = new URL(requestUrl);
|
|
470
|
+
} catch {
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
if (pathAndSearch(url) !== pathAndSearch(new URL(invocation.requestUrl))) return null;
|
|
474
|
+
const { restoresDispatch: _restoresDispatch, ...dispatch } = invocation;
|
|
475
|
+
return {
|
|
476
|
+
...dispatch,
|
|
477
|
+
props: {
|
|
478
|
+
...invocation.props,
|
|
479
|
+
...restore.props
|
|
480
|
+
},
|
|
339
481
|
requestUrl
|
|
340
482
|
};
|
|
341
483
|
}
|
|
@@ -363,22 +505,27 @@ var VinextCachedResponse = class extends WorkerEntrypoint {
|
|
|
363
505
|
headers: { "Cache-Control": "no-store" }
|
|
364
506
|
}));
|
|
365
507
|
if (invocation.expectedResponseStageBuildIdentity !== void 0 && invocation.expectedResponseStageBuildIdentity !== getVinextCdnBuildIdentity()) return stampResponseStageBuildIdentity(responseStageUnavailable());
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
508
|
+
const dispatch = restoreDispatchInvocation(request, invocation);
|
|
509
|
+
if (!dispatch) return stampResponseStageBuildIdentity(new Response("Invalid vinext response-stage invocation", {
|
|
510
|
+
status: 400,
|
|
511
|
+
headers: { "Cache-Control": "no-store" }
|
|
512
|
+
}));
|
|
513
|
+
const restored = restoreResponseStageRequest(request, dispatch.requestUrl, dispatch.requestMethod);
|
|
514
|
+
const isAppPageDocument = isAppPageDocumentInvocation(dispatch);
|
|
515
|
+
const isPagesPage = isPagesPageInvocation(dispatch);
|
|
369
516
|
const response = await invokeResponseStage(restored.request, this.env, context, isAppPageDocument && rendersWholeDocument(request) ? {
|
|
370
|
-
...
|
|
517
|
+
...dispatch,
|
|
371
518
|
options: {
|
|
372
|
-
...
|
|
519
|
+
...dispatch.options,
|
|
373
520
|
renderWholeDocument: true
|
|
374
521
|
}
|
|
375
522
|
} : isPagesPage && revalidatesStoredEntry(request) ? {
|
|
376
|
-
...
|
|
523
|
+
...dispatch,
|
|
377
524
|
options: {
|
|
378
|
-
...
|
|
525
|
+
...dispatch.options,
|
|
379
526
|
regeneratesStoredEntry: true
|
|
380
527
|
}
|
|
381
|
-
} :
|
|
528
|
+
} : dispatch);
|
|
382
529
|
return stampResponseStageBuildIdentity(restored.didAccessRequestCf() || hasTaggedCustomVary(response) ? preventResponseCaching(response) : isAppPageDocument ? withRevalidationValidator(response) : isPagesPage ? withPagesRevalidationValidator(response) : response);
|
|
383
530
|
}
|
|
384
531
|
async purge(options) {
|
|
@@ -408,30 +555,71 @@ var cdn_adapter_worker_default = { async fetch(request, env, context) {
|
|
|
408
555
|
request = stripUntrustedTransportHeaders(request);
|
|
409
556
|
const sharedResponses = /* @__PURE__ */ new Map();
|
|
410
557
|
const stageContext = withResponseStageCache(withWorkerHostRuntime(context, env));
|
|
411
|
-
const
|
|
558
|
+
const createInvocation = (stageRequest, props, options) => {
|
|
412
559
|
const expectedResponseStageBuildIdentity = getVinextCdnBuildIdentity();
|
|
413
|
-
const
|
|
560
|
+
const { cacheIdentity: _cacheIdentity, ...stageOptions } = options;
|
|
561
|
+
return {
|
|
414
562
|
...expectedResponseStageBuildIdentity === null ? {} : { expectedResponseStageBuildIdentity },
|
|
415
|
-
options,
|
|
563
|
+
options: stageOptions,
|
|
416
564
|
props,
|
|
417
565
|
requestMethod: stageRequest.method,
|
|
418
566
|
requestUrl: stageRequest.url
|
|
419
567
|
};
|
|
568
|
+
};
|
|
569
|
+
/** Fetch a response stage through its named entrypoint, the cached one for a shared dispatch. */
|
|
570
|
+
const fetchResponseStageEntrypoint = async (stageRequest, props, options) => {
|
|
571
|
+
const invocation = createInvocation(stageRequest, props, options);
|
|
572
|
+
const usesSharedCache = options.cache === "shared";
|
|
573
|
+
const { cacheIdentity } = options;
|
|
574
|
+
const dispatchRestore = usesSharedCache && cacheIdentity ? getDispatchRestore(stageRequest, props, cacheIdentity) : null;
|
|
575
|
+
const keyedRequest = dispatchRestore ? cacheIdentity.request : stageRequest;
|
|
576
|
+
const serializedInvocation = JSON.stringify({
|
|
577
|
+
...invocation,
|
|
578
|
+
...dispatchRestore ? {
|
|
579
|
+
props: cacheIdentity.props,
|
|
580
|
+
requestMethod: keyedRequest.method,
|
|
581
|
+
requestUrl: keyedRequest.url,
|
|
582
|
+
restoresDispatch: true
|
|
583
|
+
} : {},
|
|
584
|
+
options: invocation.expectedResponseStageBuildIdentity === void 0 ? invocation.options : {
|
|
585
|
+
...invocation.options,
|
|
586
|
+
cache: RESPONSE_STAGE_WIRE_CACHE[options.cache]
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
const binding = getResponseStageBinding(stageContext, usesSharedCache ? CACHED_RESPONSE_STAGE_EXPORT : UNCACHED_RESPONSE_STAGE_EXPORT, serializedInvocation);
|
|
590
|
+
if (!binding) return responseStageUnavailable();
|
|
591
|
+
const entrypointRequest = usesSharedCache ? await createCacheFacingRequest(stageRequest, serializedInvocation, keyedRequest, dispatchRestore) : stageRequest;
|
|
592
|
+
return validateResponseStageBuildIdentity(await binding.fetch(entrypointRequest));
|
|
593
|
+
};
|
|
594
|
+
/**
|
|
595
|
+
* Fetch the other part of a canonical App page part Workers Cache just
|
|
596
|
+
* rendered, so its entry is filled or refreshed too. Workers Cache stores
|
|
597
|
+
* only what its entrypoint returns, so the other part renders on its own.
|
|
598
|
+
* A deployment warm-up already requests both parts, and this fetch reaches
|
|
599
|
+
* the entrypoint directly, so it never fetches another part itself.
|
|
600
|
+
*/
|
|
601
|
+
const storeOtherPagePart = (stageRequest, props, options, cacheStatus) => {
|
|
602
|
+
if (cacheStatus === null || !RENDERED_ENTRY_CACHE_STATUSES.has(cacheStatus) || stageRequest.headers.get("user-agent") === WARMUP_USER_AGENT || typeof stageContext.waitUntil !== "function") return;
|
|
603
|
+
stageContext.waitUntil((async () => {
|
|
604
|
+
const otherPart = otherPagePartDispatch(stageRequest, props);
|
|
605
|
+
if (!otherPart) return;
|
|
606
|
+
const { cacheIdentity, ...otherOptions } = options;
|
|
607
|
+
const otherIdentity = cacheIdentity ? otherPagePartDispatch(cacheIdentity.request, cacheIdentity.props) : null;
|
|
608
|
+
await (await fetchResponseStageEntrypoint(otherPart.request, otherPart.props, otherIdentity ? {
|
|
609
|
+
...otherOptions,
|
|
610
|
+
cacheIdentity: otherIdentity
|
|
611
|
+
} : otherOptions)).body?.pipeTo(new WritableStream());
|
|
612
|
+
})().catch((error) => {
|
|
613
|
+
console.error("[vinext] Workers Cache could not store a page's other part:", error);
|
|
614
|
+
}));
|
|
615
|
+
};
|
|
616
|
+
const dispatchResponseStage = async (stageRequest, props, options) => {
|
|
420
617
|
const usesSharedCache = options.cache === "shared";
|
|
421
618
|
try {
|
|
422
|
-
if (!usesSharedCache && !isResponseStageReadinessRequest(stageRequest)) return stampResponseStageBuildIdentity(await invokeResponseStage(stageRequest, env, stageContext,
|
|
423
|
-
const
|
|
424
|
-
...invocation,
|
|
425
|
-
options: expectedResponseStageBuildIdentity === null ? options : {
|
|
426
|
-
...options,
|
|
427
|
-
cache: RESPONSE_STAGE_WIRE_CACHE[options.cache]
|
|
428
|
-
}
|
|
429
|
-
});
|
|
430
|
-
const binding = getResponseStageBinding(stageContext, usesSharedCache ? CACHED_RESPONSE_STAGE_EXPORT : UNCACHED_RESPONSE_STAGE_EXPORT, serializedInvocation);
|
|
431
|
-
if (!binding) return responseStageUnavailable();
|
|
432
|
-
const entrypointRequest = usesSharedCache ? await createCacheFacingRequest(stageRequest, serializedInvocation) : stageRequest;
|
|
433
|
-
const response = validateResponseStageBuildIdentity(await binding.fetch(entrypointRequest));
|
|
619
|
+
if (!usesSharedCache && !isResponseStageReadinessRequest(stageRequest)) return stampResponseStageBuildIdentity(await invokeResponseStage(stageRequest, env, stageContext, createInvocation(stageRequest, props, options)));
|
|
620
|
+
const response = await fetchResponseStageEntrypoint(stageRequest, props, options);
|
|
434
621
|
if (!usesSharedCache) return response;
|
|
622
|
+
storeOtherPagePart(stageRequest, props, options, response.headers.get("CF-Cache-Status"));
|
|
435
623
|
const shared = markSharedResponseStage(response, crypto.randomUUID(), props, true);
|
|
436
624
|
sharedResponses.set(shared.headers.get(SHARED_RESPONSE_STAGE_HEADER), { headers: new Headers(shared.headers) });
|
|
437
625
|
return shared;
|
package/dist/cache/config.d.ts
CHANGED
|
@@ -60,7 +60,7 @@ export declare class KVCacheHandler implements CacheHandler {
|
|
|
60
60
|
private keySpace;
|
|
61
61
|
private ctx;
|
|
62
62
|
private ttlSeconds;
|
|
63
|
-
/** Local in-memory cache for tag invalidation
|
|
63
|
+
/** Local in-memory cache for tag invalidation markers. Avoids redundant KV reads. */
|
|
64
64
|
private _tagCache;
|
|
65
65
|
/** Monotonic ordering for concurrent tag-cache fills and local invalidations. */
|
|
66
66
|
private _tagCacheOrder;
|
|
@@ -95,14 +95,18 @@ export declare class KVCacheHandler implements CacheHandler {
|
|
|
95
95
|
*/
|
|
96
96
|
private _readTagMarkers;
|
|
97
97
|
/**
|
|
98
|
-
* Report whether any tag
|
|
98
|
+
* Report whether any tag expired an entry last modified at `lastModified`:
|
|
99
|
+
* its marker's expire time is at or after `lastModified` and, for a cacheLife
|
|
100
|
+
* profile's marker, has passed.
|
|
99
101
|
* Call `_primeTagCache` for the same tags first — a tag with no cache entry
|
|
100
102
|
* counts as never invalidated. Pass `requireFresh` to call it before a prime,
|
|
101
103
|
* so an entry past `tagCacheTtlMs` does not answer for a tag it never re-read.
|
|
102
104
|
*/
|
|
103
105
|
private _hasRevalidatedTag;
|
|
106
|
+
/** Report whether any primed tag was marked stale after `lastModified`. */
|
|
107
|
+
private _hasStaleTag;
|
|
104
108
|
set(key: string, data: IncrementalCacheValue | null, ctx?: Record<string, unknown>): Promise<void>;
|
|
105
|
-
revalidateTag(tags: string | string[],
|
|
109
|
+
revalidateTag(tags: string | string[], durations?: {
|
|
106
110
|
expire?: number;
|
|
107
111
|
}): Promise<void>;
|
|
108
112
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isStaleTagInvalidation } from "./workers-cache-invalidation.js";
|
|
2
|
+
import { isUnknownRecord, readCacheControlNumberField, readCacheTimestamp } from "../utils/cache-control-metadata.js";
|
|
2
3
|
import { ENTRY_PREFIX, createKvKeySpace, resolveKvExpirationTtlSeconds } from "./kv-key.js";
|
|
3
4
|
import { Buffer } from "node:buffer";
|
|
4
5
|
import { getRequestExecutionContext } from "vinext/shims/request-context";
|
|
@@ -24,6 +25,23 @@ import { getRequestExecutionContext } from "vinext/shims/request-context";
|
|
|
24
25
|
*/
|
|
25
26
|
/** Default KV namespace binding name read from the Worker `env`. */
|
|
26
27
|
const DEFAULT_BINDING = "VINEXT_KV_CACHE";
|
|
28
|
+
function parseTagMarker(raw) {
|
|
29
|
+
if (raw === null || raw === void 0) return {};
|
|
30
|
+
if (!raw.startsWith("{")) return { expired: /^\d+$/.test(raw) ? Number(raw) : NaN };
|
|
31
|
+
let parsed;
|
|
32
|
+
try {
|
|
33
|
+
parsed = JSON.parse(raw);
|
|
34
|
+
} catch {
|
|
35
|
+
return { expired: NaN };
|
|
36
|
+
}
|
|
37
|
+
if (!isUnknownRecord(parsed)) return { expired: NaN };
|
|
38
|
+
const { stale, expired } = parsed;
|
|
39
|
+
if (stale === void 0 && expired === void 0 || stale !== void 0 && typeof stale !== "number" || expired !== void 0 && typeof expired !== "number") return { expired: NaN };
|
|
40
|
+
return {
|
|
41
|
+
...stale === void 0 ? {} : { stale },
|
|
42
|
+
...expired === void 0 ? {} : { expired }
|
|
43
|
+
};
|
|
44
|
+
}
|
|
27
45
|
/** Prefix used by revalidatePath for path-based tags. */
|
|
28
46
|
const PATH_TAG_PREFIX = "_N_T_";
|
|
29
47
|
/** The runtime rejects a lower `cacheTtl` with "Cache TTL must be at least 30". */
|
|
@@ -98,7 +116,7 @@ var KVCacheHandler = class {
|
|
|
98
116
|
keySpace;
|
|
99
117
|
ctx;
|
|
100
118
|
ttlSeconds;
|
|
101
|
-
/** Local in-memory cache for tag invalidation
|
|
119
|
+
/** Local in-memory cache for tag invalidation markers. Avoids redundant KV reads. */
|
|
102
120
|
_tagCache = /* @__PURE__ */ new Map();
|
|
103
121
|
/** Monotonic ordering for concurrent tag-cache fills and local invalidations. */
|
|
104
122
|
_tagCacheOrder = 0;
|
|
@@ -179,6 +197,7 @@ var KVCacheHandler = class {
|
|
|
179
197
|
return null;
|
|
180
198
|
}
|
|
181
199
|
if (this._hasRevalidatedTag(softTags, entry.lastModified)) return null;
|
|
200
|
+
const hasStaleTag = this._hasStaleTag([...entryTags, ...softTags], entry.lastModified);
|
|
182
201
|
if (entry.expireAt !== void 0 && entry.expireAt !== null && Date.now() > entry.expireAt) {
|
|
183
202
|
this._deleteEntryReadInBackground(kvKey);
|
|
184
203
|
return null;
|
|
@@ -186,7 +205,7 @@ var KVCacheHandler = class {
|
|
|
186
205
|
const now = Date.now();
|
|
187
206
|
const requestedRevalidate = readPositiveNumberField(_ctx, "revalidate");
|
|
188
207
|
const requestedRevalidateAt = requestedRevalidate === void 0 ? null : entry.lastModified + requestedRevalidate * 1e3;
|
|
189
|
-
if (entry.revalidateAt !== null && now > entry.revalidateAt || requestedRevalidateAt !== null && now > requestedRevalidateAt) return {
|
|
208
|
+
if (hasStaleTag || entry.revalidateAt !== null && now > entry.revalidateAt || requestedRevalidateAt !== null && now > requestedRevalidateAt) return {
|
|
190
209
|
lastModified: entry.lastModified,
|
|
191
210
|
value: restoredValue,
|
|
192
211
|
cacheState: "stale",
|
|
@@ -221,7 +240,7 @@ var KVCacheHandler = class {
|
|
|
221
240
|
if (current && current.order >= order) continue;
|
|
222
241
|
const marker = markers.get(this._tagKey(tag));
|
|
223
242
|
tagCache.set(tag, {
|
|
224
|
-
|
|
243
|
+
marker: parseTagMarker(marker),
|
|
225
244
|
fetchedAt: now,
|
|
226
245
|
order
|
|
227
246
|
});
|
|
@@ -244,18 +263,30 @@ var KVCacheHandler = class {
|
|
|
244
263
|
return markers;
|
|
245
264
|
}
|
|
246
265
|
/**
|
|
247
|
-
* Report whether any tag
|
|
266
|
+
* Report whether any tag expired an entry last modified at `lastModified`:
|
|
267
|
+
* its marker's expire time is at or after `lastModified` and, for a cacheLife
|
|
268
|
+
* profile's marker, has passed.
|
|
248
269
|
* Call `_primeTagCache` for the same tags first — a tag with no cache entry
|
|
249
270
|
* counts as never invalidated. Pass `requireFresh` to call it before a prime,
|
|
250
271
|
* so an entry past `tagCacheTtlMs` does not answer for a tag it never re-read.
|
|
251
272
|
*/
|
|
252
273
|
_hasRevalidatedTag(tags, lastModified, requireFresh = false) {
|
|
253
|
-
const now =
|
|
274
|
+
const now = Date.now();
|
|
254
275
|
for (const tag of tags) {
|
|
255
276
|
const cached = this._tagCache.get(tag);
|
|
256
|
-
if (
|
|
277
|
+
if (cached?.marker.expired === void 0) continue;
|
|
257
278
|
if (requireFresh && now - cached.fetchedAt >= this._tagCacheTtl) continue;
|
|
258
|
-
|
|
279
|
+
const { stale, expired } = cached.marker;
|
|
280
|
+
const pending = stale !== void 0 && expired > now;
|
|
281
|
+
if (Number.isNaN(expired) || expired >= lastModified && !pending) return true;
|
|
282
|
+
}
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
/** Report whether any primed tag was marked stale after `lastModified`. */
|
|
286
|
+
_hasStaleTag(tags, lastModified) {
|
|
287
|
+
for (const tag of tags) {
|
|
288
|
+
const stale = this._tagCache.get(tag)?.marker.stale;
|
|
289
|
+
if (stale !== void 0 && stale > lastModified) return true;
|
|
259
290
|
}
|
|
260
291
|
return false;
|
|
261
292
|
}
|
|
@@ -277,7 +308,7 @@ var KVCacheHandler = class {
|
|
|
277
308
|
const effectiveStale = readCacheControlNumberField(ctx, "stale");
|
|
278
309
|
if (data && "revalidate" in data && typeof data.revalidate === "number") effectiveRevalidate = data.revalidate;
|
|
279
310
|
if (effectiveRevalidate === 0) return Promise.resolve();
|
|
280
|
-
const now = Date.now();
|
|
311
|
+
const now = readCacheTimestamp(ctx, Date.now());
|
|
281
312
|
const revalidateAt = typeof effectiveRevalidate === "number" && effectiveRevalidate > 0 && Number.isFinite(effectiveRevalidate) ? now + effectiveRevalidate * 1e3 : null;
|
|
282
313
|
const expireAt = typeof effectiveExpire === "number" && effectiveExpire > 0 ? now + effectiveExpire * 1e3 : null;
|
|
283
314
|
const cacheControl = typeof effectiveRevalidate === "number" ? {
|
|
@@ -300,16 +331,32 @@ var KVCacheHandler = class {
|
|
|
300
331
|
metadata
|
|
301
332
|
});
|
|
302
333
|
}
|
|
303
|
-
async revalidateTag(tags,
|
|
334
|
+
async revalidateTag(tags, durations) {
|
|
304
335
|
const tagList = Array.isArray(tags) ? tags : [tags];
|
|
305
336
|
const now = Date.now();
|
|
306
337
|
const validTags = tagList.filter((t) => validateTag(t) !== null);
|
|
307
|
-
await Promise.all(validTags.map((tag) =>
|
|
338
|
+
const markers = await Promise.all(validTags.map(async (tag) => {
|
|
339
|
+
const tagKey = this._tagKey(tag);
|
|
340
|
+
if (!isStaleTagInvalidation(durations)) {
|
|
341
|
+
await this.kv.put(tagKey, String(now));
|
|
342
|
+
return { expired: now };
|
|
343
|
+
}
|
|
344
|
+
const expire = durations?.expire;
|
|
345
|
+
const expired = expire === void 0 ? parseTagMarker(await this.kv.get(tagKey)).expired : now + expire * 1e3;
|
|
346
|
+
const marker = {
|
|
347
|
+
stale: now,
|
|
348
|
+
...expired !== void 0 && Number.isFinite(expired) ? { expired } : {}
|
|
349
|
+
};
|
|
350
|
+
await this.kv.put(tagKey, JSON.stringify(marker));
|
|
351
|
+
return marker;
|
|
352
|
+
}));
|
|
308
353
|
const order = ++this._tagCacheOrder;
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
354
|
+
validTags.forEach((tag, index) => {
|
|
355
|
+
this._tagCache.set(tag, {
|
|
356
|
+
marker: markers[index],
|
|
357
|
+
fetchedAt: now,
|
|
358
|
+
order
|
|
359
|
+
});
|
|
313
360
|
});
|
|
314
361
|
}
|
|
315
362
|
/**
|
|
@@ -11,4 +11,5 @@ export declare function createVinextResponseStoreOptions<Env extends VinextRespo
|
|
|
11
11
|
export declare function createVinextResponseStoreHandler(store: WorkersResponseStore): {
|
|
12
12
|
fetch(request: Request, env: VinextResponseStoreEnv, ctx: WorkerExecutionContext): Promise<Response>;
|
|
13
13
|
};
|
|
14
|
-
//#endregion
|
|
14
|
+
//#endregion
|
|
15
|
+
export {};
|