@vinext/cloudflare 1.2.1 → 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.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/kv-data-adapter.runtime.js +2 -2
- 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 +39 -4
- 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 +3 -0
- package/dist/deploy.js +23 -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/package.json +4 -4
|
@@ -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;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isStaleTagInvalidation } from "./workers-cache-invalidation.js";
|
|
2
|
-
import { isUnknownRecord, readCacheControlNumberField } from "../utils/cache-control-metadata.js";
|
|
2
|
+
import { isUnknownRecord, readCacheControlNumberField, readCacheTimestamp } from "../utils/cache-control-metadata.js";
|
|
3
3
|
import { ENTRY_PREFIX, createKvKeySpace, resolveKvExpirationTtlSeconds } from "./kv-key.js";
|
|
4
4
|
import { Buffer } from "node:buffer";
|
|
5
5
|
import { getRequestExecutionContext } from "vinext/shims/request-context";
|
|
@@ -308,7 +308,7 @@ var KVCacheHandler = class {
|
|
|
308
308
|
const effectiveStale = readCacheControlNumberField(ctx, "stale");
|
|
309
309
|
if (data && "revalidate" in data && typeof data.revalidate === "number") effectiveRevalidate = data.revalidate;
|
|
310
310
|
if (effectiveRevalidate === 0) return Promise.resolve();
|
|
311
|
-
const now = Date.now();
|
|
311
|
+
const now = readCacheTimestamp(ctx, Date.now());
|
|
312
312
|
const revalidateAt = typeof effectiveRevalidate === "number" && effectiveRevalidate > 0 && Number.isFinite(effectiveRevalidate) ? now + effectiveRevalidate * 1e3 : null;
|
|
313
313
|
const expireAt = typeof effectiveExpire === "number" && effectiveExpire > 0 ? now + effectiveExpire * 1e3 : null;
|
|
314
314
|
const cacheControl = typeof effectiveRevalidate === "number" ? {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { hasDocumentPart, isCanonicalAppPage } from "./app-page-parts.js";
|
|
1
2
|
import { SHARED_RESPONSE_STAGE_HEADER, finalizeGatewayResponse } from "./browser-cache-policy.js";
|
|
2
3
|
import { captureResponseStoreDataRegeneration, runWithResponseStoreInvocation, setResponseStore } from "./response-store-data.runtime.js";
|
|
3
|
-
import { VINEXT_PARAMS_HEADER, VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, VINEXT_SPECIAL_ERROR_STATUS_HEADER } from "vinext/internal/server/headers";
|
|
4
|
-
import { VINEXT_RSC_CONTENT_TYPE, VINEXT_RSC_VARY_HEADER as VINEXT_RSC_VARY_HEADER$1, applyRscCompatibilityIdHeader, applyRscDeploymentIdHeader, createCanonicalRscRequestHeaders, createCanonicalRscRequestUrl } from "vinext/internal/server/app-rsc-cache-busting";
|
|
4
|
+
import { NEXTJS_DEPLOYMENT_ID_HEADER, VINEXT_PARAMS_HEADER, VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, VINEXT_RSC_COMPLETION_METADATA_HEADER, VINEXT_SPECIAL_ERROR_STATUS_HEADER, VINEXT_STALE_TIME_PENDING_HEADER } from "vinext/internal/server/headers";
|
|
5
|
+
import { VINEXT_RSC_BUILD_ID_HEADER, VINEXT_RSC_COMPATIBILITY_ID_HEADER, VINEXT_RSC_CONTENT_TYPE, VINEXT_RSC_VARY_HEADER as VINEXT_RSC_VARY_HEADER$1, applyRscCompatibilityIdHeader, applyRscDeploymentIdHeader, createCanonicalRscRequestHeaders, createCanonicalRscRequestUrl, stripRscCacheBustingSearchParam } from "vinext/internal/server/app-rsc-cache-busting";
|
|
5
6
|
import { hasSharedCacheCredentials, isNonCacheableCacheControl } from "vinext/shims/cdn-cache";
|
|
6
7
|
import { loadVinextRequestStage } from "vinext/server/request-stage";
|
|
7
8
|
import { loadVinextResponseStage } from "vinext/server/response-stage";
|
|
@@ -9,6 +10,8 @@ import { traceCachedResponseStart } from "vinext/internal/server/response-start-
|
|
|
9
10
|
import { isPageNotModified } from "vinext/internal/server/http-conditional";
|
|
10
11
|
//#region src/cache/response-store-adapter.worker.ts
|
|
11
12
|
const ROUTE_REVALIDATOR_ID = "vinext:response";
|
|
13
|
+
/** Leads the revalidator args of a page part whose render stores both parts. */
|
|
14
|
+
const PAGE_PARTS_REVALIDATOR_ARG = "vinext:page-parts";
|
|
12
15
|
const RESPONSE_STORE_KEY_PARAM = "__workers_response_store";
|
|
13
16
|
const AGE_BASIS_HEADER = "X-Workers-Response-Store-Age-Basis";
|
|
14
17
|
const WARMUP_USER_AGENT = "vinext-cloudflare-cdn-warm";
|
|
@@ -58,6 +61,15 @@ function prepareInvocation(request, props) {
|
|
|
58
61
|
function serializeInvocation(request, props) {
|
|
59
62
|
return JSON.stringify(prepareInvocation(request, props));
|
|
60
63
|
}
|
|
64
|
+
function serializeDispatchInvocation(invocation) {
|
|
65
|
+
return JSON.stringify({
|
|
66
|
+
...invocation,
|
|
67
|
+
request: {
|
|
68
|
+
...invocation.request,
|
|
69
|
+
headers: [...invocation.request.headers, ["host", new URL(invocation.request.url).host]]
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
61
73
|
function parseInvocation(value) {
|
|
62
74
|
if (typeof value !== "string") throw new TypeError("Invalid vinext response-store invocation");
|
|
63
75
|
const invocation = JSON.parse(value);
|
|
@@ -95,16 +107,29 @@ function createVinextResponseStoreOptions(configuration) {
|
|
|
95
107
|
async regenerate(input, { env, ctx }) {
|
|
96
108
|
if (input.id === ROUTE_REVALIDATOR_ID) {
|
|
97
109
|
const invocation = parseInvocation(input.args.at(-1));
|
|
98
|
-
const
|
|
110
|
+
const request = restoreRequest(invocation);
|
|
111
|
+
const otherPart = input.args.length > 1 && input.args[0] === PAGE_PARTS_REVALIDATOR_ARG ? otherPagePart(request, invocation.props) : void 0;
|
|
112
|
+
const capture = otherPagePartCapture(otherPart);
|
|
113
|
+
const response = await invokeResponseStage(request, invocation.props, env, ctx, {
|
|
99
114
|
cache: "shared",
|
|
100
115
|
regeneratesStoredEntry: true,
|
|
101
116
|
renderWholeDocument: true
|
|
102
|
-
});
|
|
117
|
+
}, capture);
|
|
103
118
|
if (!isCacheable(response)) {
|
|
104
119
|
await response.body?.cancel().catch(() => {});
|
|
120
|
+
capture.rscData?.catch(() => {});
|
|
105
121
|
throw new Error("Vinext response-stage regeneration was not cacheable");
|
|
106
122
|
}
|
|
107
|
-
|
|
123
|
+
const stored = withoutRequestScopedHeaders(response, invocation.props);
|
|
124
|
+
try {
|
|
125
|
+
await putOtherPagePart(otherPart, capture, stored);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
console.error(JSON.stringify({
|
|
128
|
+
message: "Vinext response-store regeneration failed to store the page's other part",
|
|
129
|
+
error: error instanceof Error ? error.message : String(error)
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
return stored;
|
|
108
133
|
}
|
|
109
134
|
if (input.id === "vinext:cache-function" && typeof input.args[0] === "string" && typeof input.args[1] === "string") {
|
|
110
135
|
const invocation = JSON.parse(input.args[1]);
|
|
@@ -137,7 +162,8 @@ async function cacheRequest(invocation) {
|
|
|
137
162
|
url.searchParams.set(RESPONSE_STORE_KEY_PARAM, `v1.${[...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`);
|
|
138
163
|
return new Request(url, {
|
|
139
164
|
headers: CACHE_REQUEST_VARY_HEADERS,
|
|
140
|
-
method: "GET"
|
|
165
|
+
method: "GET",
|
|
166
|
+
redirect: "manual"
|
|
141
167
|
});
|
|
142
168
|
}
|
|
143
169
|
/**
|
|
@@ -157,6 +183,116 @@ function withoutRequestScopedHeaders(response, responseStageProps) {
|
|
|
157
183
|
statusText: response.statusText
|
|
158
184
|
});
|
|
159
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* The revalidator of a page part. One whose render stores the page's other
|
|
188
|
+
* part says so, as a public config policy's full-URL entry stores its own.
|
|
189
|
+
*/
|
|
190
|
+
function routeRevalidator(serializedInvocation, storesOtherPart) {
|
|
191
|
+
return {
|
|
192
|
+
id: ROUTE_REVALIDATOR_ID,
|
|
193
|
+
args: storesOtherPart ? [PAGE_PARTS_REVALIDATOR_ARG, serializedInvocation] : [serializedInvocation]
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/** The invocation an RSC navigation to an HTML invocation's page stores under. */
|
|
197
|
+
function rscPartInvocation(htmlRequest, htmlProps) {
|
|
198
|
+
return prepareInvocation(new Request(new URL(createCanonicalRscRequestUrl(htmlRequest.url), htmlRequest.url), { headers: createCanonicalRscRequestHeaders() }), {
|
|
199
|
+
...htmlProps,
|
|
200
|
+
isRscRequest: true,
|
|
201
|
+
renderMode: "navigation"
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
/** The invocation an HTML request for an RSC invocation's page stores under. */
|
|
205
|
+
function documentPartInvocation(rscRequest, rscProps) {
|
|
206
|
+
const url = new URL(rscRequest.url);
|
|
207
|
+
stripRscCacheBustingSearchParam(url);
|
|
208
|
+
return prepareInvocation(new Request(url), {
|
|
209
|
+
...rscProps,
|
|
210
|
+
isRscRequest: false
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* The page's RSC payload an HTML render produced, with its document's headers
|
|
215
|
+
* and status.
|
|
216
|
+
*/
|
|
217
|
+
function rscPartResponse(htmlResponse, rscData) {
|
|
218
|
+
const headers = new Headers(htmlResponse.headers);
|
|
219
|
+
headers.delete("Content-Length");
|
|
220
|
+
headers.delete("Link");
|
|
221
|
+
headers.delete("X-Vinext-Response-Store-Replayable");
|
|
222
|
+
headers.delete(VINEXT_PARAMS_HEADER);
|
|
223
|
+
headers.delete(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER);
|
|
224
|
+
headers.set("Content-Type", VINEXT_RSC_CONTENT_TYPE);
|
|
225
|
+
headers.set("Vary", VINEXT_RSC_VARY_HEADER$1);
|
|
226
|
+
applyRscCompatibilityIdHeader(headers);
|
|
227
|
+
applyRscDeploymentIdHeader(headers);
|
|
228
|
+
if (isStoredErrorStatus(htmlResponse.status)) headers.set(VINEXT_SPECIAL_ERROR_STATUS_HEADER, String(htmlResponse.status));
|
|
229
|
+
return new Response(rscData, {
|
|
230
|
+
headers,
|
|
231
|
+
status: htmlResponse.status >= 300 && htmlResponse.status < 400 ? 200 : htmlResponse.status
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* The page's document an RSC render produced, with the headers an HTML request
|
|
236
|
+
* for the page stores: the RSC response's, without the payload's own.
|
|
237
|
+
*/
|
|
238
|
+
function documentPartResponse(rscResponse, document) {
|
|
239
|
+
const headers = new Headers(rscResponse.headers);
|
|
240
|
+
for (const name of [
|
|
241
|
+
"Content-Length",
|
|
242
|
+
NEXTJS_DEPLOYMENT_ID_HEADER,
|
|
243
|
+
VINEXT_RSC_BUILD_ID_HEADER,
|
|
244
|
+
VINEXT_RSC_COMPATIBILITY_ID_HEADER,
|
|
245
|
+
VINEXT_RSC_COMPLETION_METADATA_HEADER,
|
|
246
|
+
VINEXT_STALE_TIME_PENDING_HEADER
|
|
247
|
+
]) headers.delete(name);
|
|
248
|
+
headers.set("Content-Type", "text/html; charset=utf-8");
|
|
249
|
+
if (document.linkHeader) headers.append("Link", document.linkHeader);
|
|
250
|
+
return new Response(document.html, {
|
|
251
|
+
headers,
|
|
252
|
+
status: rscResponse.status
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* The other part of the page a canonical App page render stores. Next.js
|
|
257
|
+
* keeps a page's document and RSC payload in one entry, so a render that
|
|
258
|
+
* stores one part also stores the other, under the key a request for that
|
|
259
|
+
* part reads. A variant has no other part.
|
|
260
|
+
*/
|
|
261
|
+
function otherPagePart(identityRequest, identityProps) {
|
|
262
|
+
if (isCanonicalAppPage(identityProps, false)) return {
|
|
263
|
+
invocation: rscPartInvocation(identityRequest, identityProps),
|
|
264
|
+
kind: "rsc"
|
|
265
|
+
};
|
|
266
|
+
if (isCanonicalAppPage(identityProps, true) && hasDocumentPart(identityRequest.url)) return {
|
|
267
|
+
invocation: documentPartInvocation(identityRequest, identityProps),
|
|
268
|
+
kind: "document"
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
/** What a render captures for the other part of its page. */
|
|
272
|
+
function otherPagePartCapture(part) {
|
|
273
|
+
if (part?.kind === "rsc") return { captureRscData: true };
|
|
274
|
+
if (part?.kind === "document") return { captureDocument: true };
|
|
275
|
+
return {};
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Store the other part of the page from the render that stores this one.
|
|
279
|
+
* Admission settles the document before it completes the response. A miss
|
|
280
|
+
* stores it first, so a failed write leaves the render's own part missing too
|
|
281
|
+
* and the next miss retries both. A regeneration's other part stays stale
|
|
282
|
+
* when it fails, so the next read of that part regenerates it. Concurrent
|
|
283
|
+
* misses coalesce each key on its own, so their parts can come from different
|
|
284
|
+
* renders of the page.
|
|
285
|
+
*/
|
|
286
|
+
async function putOtherPagePart(part, capture, stored) {
|
|
287
|
+
let response;
|
|
288
|
+
if (part?.kind === "rsc" && capture.rscData) response = rscPartResponse(stored, await capture.rscData);
|
|
289
|
+
else if (part?.kind === "document" && capture.document) response = documentPartResponse(stored, capture.document);
|
|
290
|
+
else return;
|
|
291
|
+
if (!(await responseStore.put(await cacheRequest(part.invocation), response, {
|
|
292
|
+
coalesce: true,
|
|
293
|
+
revalidator: routeRevalidator(JSON.stringify(part.invocation), true)
|
|
294
|
+
})).backingStoreUpdated) throw new Error(`Vinext response-store did not publish the App page ${part.kind} part`);
|
|
295
|
+
}
|
|
160
296
|
function isStoredErrorStatus(status) {
|
|
161
297
|
return status === 401 || status === 403 || status === 404;
|
|
162
298
|
}
|
|
@@ -275,17 +411,9 @@ const handler = { async fetch(request, env, ctx) {
|
|
|
275
411
|
const identityRequest = options.cacheIdentity?.request ?? stageRequest;
|
|
276
412
|
const identityProps = options.cacheIdentity?.props ?? props;
|
|
277
413
|
const isWarmup = request.headers.get("user-agent") === WARMUP_USER_AGENT;
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
...identityProps,
|
|
281
|
-
isRscRequest: true,
|
|
282
|
-
renderMode: "navigation"
|
|
283
|
-
},
|
|
284
|
-
request: new Request(new URL(createCanonicalRscRequestUrl(identityRequest.url), identityRequest.url), { headers: createCanonicalRscRequestHeaders() })
|
|
285
|
-
} : void 0;
|
|
414
|
+
const otherPart = isWarmup || options.cacheIdentity?.queryFree ? otherPagePart(identityRequest, identityProps) : void 0;
|
|
415
|
+
const rscKey = isWarmup && otherPart?.kind === "rsc" ? await cacheRequest(otherPart.invocation) : void 0;
|
|
286
416
|
const invocation = prepareInvocation(identityRequest, identityProps);
|
|
287
|
-
const rscInvocation = rscSeed ? prepareInvocation(rscSeed.request, rscSeed.props) : void 0;
|
|
288
|
-
const rscKey = rscInvocation ? await cacheRequest(rscInvocation) : void 0;
|
|
289
417
|
const key = await cacheRequest(invocation);
|
|
290
418
|
const stored = await readStoredResponse(key);
|
|
291
419
|
if (stored) {
|
|
@@ -297,8 +425,12 @@ const handler = { async fetch(request, env, ctx) {
|
|
|
297
425
|
}
|
|
298
426
|
stored.body?.cancel().catch(() => {});
|
|
299
427
|
}
|
|
300
|
-
const capture =
|
|
428
|
+
const capture = {
|
|
429
|
+
...isWarmup ? {} : { streamResponse: true },
|
|
430
|
+
...otherPagePartCapture(otherPart)
|
|
431
|
+
};
|
|
301
432
|
const serializedInvocation = JSON.stringify(invocation);
|
|
433
|
+
const revalidatorInvocation = !options.cacheIdentity ? serializeDispatchInvocation(invocation) : options.cacheIdentity.regenerateFromDispatch ? serializeDispatchInvocation(prepareInvocation(stageRequest, props)) : serializedInvocation;
|
|
302
434
|
const rendered = await invokeResponseStage(stageRequest, props, env, ctx, isWarmup ? {
|
|
303
435
|
cache: "shared",
|
|
304
436
|
renderWholeDocument: true
|
|
@@ -311,14 +443,19 @@ const handler = { async fetch(request, env, ctx) {
|
|
|
311
443
|
ctx.waitUntil(capture.admittedResponse.then(async (admitted) => {
|
|
312
444
|
if (!isCacheable(admitted)) {
|
|
313
445
|
await admitted.body?.cancel().catch(() => {});
|
|
446
|
+
capture.rscData?.catch(() => {});
|
|
314
447
|
return;
|
|
315
448
|
}
|
|
316
|
-
|
|
449
|
+
const stored = withoutRequestScopedHeaders(admitted, props);
|
|
450
|
+
try {
|
|
451
|
+
await putOtherPagePart(otherPart, capture, stored);
|
|
452
|
+
} catch (error) {
|
|
453
|
+
await stored.body?.cancel().catch(() => {});
|
|
454
|
+
throw error;
|
|
455
|
+
}
|
|
456
|
+
await responseStore.put(key, stored, {
|
|
317
457
|
coalesce: true,
|
|
318
|
-
revalidator:
|
|
319
|
-
id: ROUTE_REVALIDATOR_ID,
|
|
320
|
-
args: [serializedInvocation]
|
|
321
|
-
}
|
|
458
|
+
revalidator: routeRevalidator(revalidatorInvocation, otherPart !== void 0)
|
|
322
459
|
});
|
|
323
460
|
}).catch((error) => {
|
|
324
461
|
console.error(JSON.stringify({
|
|
@@ -335,22 +472,20 @@ const handler = { async fetch(request, env, ctx) {
|
|
|
335
472
|
}), "MISS", props) : publicResponse(notModified, "MISS", props, true);
|
|
336
473
|
}
|
|
337
474
|
if (!isCacheable(rendered)) {
|
|
338
|
-
capture
|
|
475
|
+
capture.rscData?.catch(() => {});
|
|
339
476
|
return publicResponse(foreground(rendered), "BYPASS", props);
|
|
340
477
|
}
|
|
341
|
-
if (
|
|
478
|
+
if (rscKey && !capture.rscData) {
|
|
342
479
|
await rendered.body?.cancel();
|
|
343
480
|
throw new Error("Vinext response-store warmup did not capture the App page RSC payload");
|
|
344
481
|
}
|
|
345
482
|
const [foregroundBody, cacheBody] = rendered.body ? rendered.body.tee() : [null, null];
|
|
346
483
|
const cacheResponse = withoutRequestScopedHeaders(new Response(cacheBody, rendered), props);
|
|
347
484
|
try {
|
|
485
|
+
await putOtherPagePart(otherPart, capture, cacheResponse);
|
|
348
486
|
await responseStore.put(key, cacheResponse, {
|
|
349
487
|
coalesce: true,
|
|
350
|
-
revalidator:
|
|
351
|
-
id: ROUTE_REVALIDATOR_ID,
|
|
352
|
-
args: [serializedInvocation]
|
|
353
|
-
}
|
|
488
|
+
revalidator: routeRevalidator(revalidatorInvocation, otherPart !== void 0)
|
|
354
489
|
});
|
|
355
490
|
} catch (error) {
|
|
356
491
|
if (isWarmup) throw error;
|
|
@@ -360,31 +495,6 @@ const handler = { async fetch(request, env, ctx) {
|
|
|
360
495
|
error: error instanceof Error ? error.message : String(error)
|
|
361
496
|
}));
|
|
362
497
|
}
|
|
363
|
-
if (rscSeed && rscInvocation && rscKey && capture?.rscData) {
|
|
364
|
-
const rscData = await capture.rscData;
|
|
365
|
-
const rscHeaders = new Headers(rendered.headers);
|
|
366
|
-
rscHeaders.delete("Content-Length");
|
|
367
|
-
rscHeaders.delete("Link");
|
|
368
|
-
rscHeaders.delete("X-Vinext-Response-Store-Replayable");
|
|
369
|
-
rscHeaders.delete(VINEXT_PARAMS_HEADER);
|
|
370
|
-
rscHeaders.delete(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER);
|
|
371
|
-
rscHeaders.set("Content-Type", VINEXT_RSC_CONTENT_TYPE);
|
|
372
|
-
rscHeaders.set("Vary", VINEXT_RSC_VARY_HEADER$1);
|
|
373
|
-
applyRscCompatibilityIdHeader(rscHeaders);
|
|
374
|
-
applyRscDeploymentIdHeader(rscHeaders);
|
|
375
|
-
if (isStoredErrorStatus(rendered.status)) rscHeaders.set(VINEXT_SPECIAL_ERROR_STATUS_HEADER, String(rendered.status));
|
|
376
|
-
const serializedRscInvocation = JSON.stringify(rscInvocation);
|
|
377
|
-
await responseStore.put(rscKey, new Response(rscData, {
|
|
378
|
-
headers: rscHeaders,
|
|
379
|
-
status: rendered.status >= 300 && rendered.status < 400 ? 200 : rendered.status
|
|
380
|
-
}), {
|
|
381
|
-
coalesce: true,
|
|
382
|
-
revalidator: {
|
|
383
|
-
id: ROUTE_REVALIDATOR_ID,
|
|
384
|
-
args: [serializedRscInvocation]
|
|
385
|
-
}
|
|
386
|
-
});
|
|
387
|
-
}
|
|
388
498
|
return publicResponse(foreground(new Response(foregroundBody, rendered)), "MISS", props);
|
|
389
499
|
};
|
|
390
500
|
const { handleRequestStage } = await loadVinextRequestStage();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import createCloudflareCdnCacheAdapter from "./cdn-adapter.runtime.js";
|
|
2
|
-
import { captureResponseStoreRscData, deferResponseStoreAdmission, deferResponseStoreNotModified } from "./response-store-data.runtime.js";
|
|
2
|
+
import { captureResponseStoreAppPageDocument, captureResponseStoreRscData, deferResponseStoreAdmission, deferResponseStoreNotModified } from "./response-store-data.runtime.js";
|
|
3
3
|
//#region src/cache/response-store-cdn.runtime.ts
|
|
4
4
|
/** Response Store owns page serving and SWR; vinext only emits admitted response policy. */
|
|
5
5
|
var ResponseStoreCdnCacheAdapter = class {
|
|
@@ -33,6 +33,9 @@ var ResponseStoreCdnCacheAdapter = class {
|
|
|
33
33
|
captureAppPageRscData(rscData) {
|
|
34
34
|
captureResponseStoreRscData(rscData);
|
|
35
35
|
}
|
|
36
|
+
captureAppPageDocument() {
|
|
37
|
+
return captureResponseStoreAppPageDocument();
|
|
38
|
+
}
|
|
36
39
|
deferNotModifiedResponse() {
|
|
37
40
|
return deferResponseStoreNotModified();
|
|
38
41
|
}
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
import { CdnAppPageDocument } from "vinext/shims/cdn-cache";
|
|
1
2
|
import { WorkersResponseStore } from "@cloudflare/workers-response-store";
|
|
2
3
|
import { CacheHandler, CacheHandlerValue, IncrementalCacheValue } from "vinext/shims/cache";
|
|
3
4
|
//#region src/cache/response-store-data.runtime.d.ts
|
|
4
5
|
export type ResponseStoreInvocationCapture = {
|
|
5
6
|
admittedResponse?: Promise<Response>;
|
|
7
|
+
/** Store the page's document beside an App page RSC response. */
|
|
8
|
+
captureDocument?: boolean;
|
|
6
9
|
captureRscData?: boolean;
|
|
10
|
+
/** The page's document, set before admission completes the response. */
|
|
11
|
+
document?: CdnAppPageDocument;
|
|
7
12
|
/** The render matched the request's If-None-Match; the adapter sends the 304. */
|
|
8
13
|
notModified?: boolean;
|
|
9
14
|
rscData?: Promise<ArrayBuffer>;
|
|
@@ -17,6 +22,11 @@ export declare function setResponseStore(store: WorkersResponseStore): void;
|
|
|
17
22
|
export declare function runWithResponseStoreInvocation<T>(serialized: string, replayable: boolean, callback: () => T, capture?: ResponseStoreInvocationCapture): T;
|
|
18
23
|
/** Retain the RSC side stream only when the outer response-store invocation requested it. */
|
|
19
24
|
export declare function captureResponseStoreRscData(rscData: Promise<ArrayBuffer>): void;
|
|
25
|
+
/**
|
|
26
|
+
* Retain the page's document only when the outer response-store invocation
|
|
27
|
+
* requested it. Admission settles it before it completes the response.
|
|
28
|
+
*/
|
|
29
|
+
export declare function captureResponseStoreAppPageDocument(): ((document: CdnAppPageDocument | null) => void) | undefined;
|
|
20
30
|
/** A shared invocation stores the page, so its adapter sends the page's 304. */
|
|
21
31
|
export declare function deferResponseStoreNotModified(): boolean;
|
|
22
32
|
/** Stream the foreground body while retaining an independent admission branch. */
|
|
@@ -26,6 +36,8 @@ export declare function captureResponseStoreDataRegeneration(key: string, callba
|
|
|
26
36
|
export declare class WorkersResponseStoreCacheHandler implements CacheHandler {
|
|
27
37
|
private readonly store;
|
|
28
38
|
private readonly tagExpirations;
|
|
39
|
+
/** The revision behind each entry returned stale by the Store, for the write refreshing it. */
|
|
40
|
+
private readonly staleRevisions;
|
|
29
41
|
constructor(store?: WorkersResponseStore);
|
|
30
42
|
get(key: string, context?: Record<string, unknown>): Promise<CacheHandlerValue | null>;
|
|
31
43
|
set(key: string, value: IncrementalCacheValue | null, context?: Record<string, unknown>): Promise<void>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isStaleTagInvalidation } from "./workers-cache-invalidation.js";
|
|
2
2
|
import { encodeCloudflareCacheTag } from "./cdn-adapter.runtime.js";
|
|
3
|
+
import { readCacheTimestamp } from "../utils/cache-control-metadata.js";
|
|
3
4
|
import { Buffer } from "node:buffer";
|
|
4
5
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
5
6
|
import { cacheForRequest } from "vinext/cache";
|
|
@@ -8,6 +9,8 @@ const ARRAY_BUFFER_MARKER = "$vinextArrayBuffer";
|
|
|
8
9
|
const CACHE_MAX_AGE_SECONDS = 31536e4;
|
|
9
10
|
const DATA_ENTRY_PATH = "__vinext_data";
|
|
10
11
|
const REPLAYABLE_HEADER = "X-Vinext-Response-Store-Replayable";
|
|
12
|
+
/** The Store's internal header for when a response's generation started. */
|
|
13
|
+
const GENERATED_AT_HEADER = "X-Workers-Response-Store-Generated-At";
|
|
11
14
|
const MAX_CACHE_TAG_HEADER_BYTES = 16384;
|
|
12
15
|
/** How long past its revalidate a current Store's hit can still be marked fresh: write latency. */
|
|
13
16
|
const STALE_FRESH_HIT_GRACE_MS = 5e3;
|
|
@@ -34,6 +37,17 @@ function captureResponseStoreRscData(rscData) {
|
|
|
34
37
|
if (capture?.captureRscData) capture.rscData = rscData;
|
|
35
38
|
else rscData.catch(() => {});
|
|
36
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Retain the page's document only when the outer response-store invocation
|
|
42
|
+
* requested it. Admission settles it before it completes the response.
|
|
43
|
+
*/
|
|
44
|
+
function captureResponseStoreAppPageDocument() {
|
|
45
|
+
const capture = invocationStorage.getStore()?.capture;
|
|
46
|
+
if (!capture?.captureDocument) return void 0;
|
|
47
|
+
return (document) => {
|
|
48
|
+
capture.document = document ?? void 0;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
37
51
|
/** A shared invocation stores the page, so its adapter sends the page's 304. */
|
|
38
52
|
function deferResponseStoreNotModified() {
|
|
39
53
|
const capture = invocationStorage.getStore()?.capture;
|
|
@@ -202,6 +216,8 @@ function cachePolicy(revalidate, expire) {
|
|
|
202
216
|
var WorkersResponseStoreCacheHandler = class {
|
|
203
217
|
store;
|
|
204
218
|
tagExpirations = cacheForRequest(() => /* @__PURE__ */ new Map());
|
|
219
|
+
/** The revision behind each entry returned stale by the Store, for the write refreshing it. */
|
|
220
|
+
staleRevisions = /* @__PURE__ */ new WeakMap();
|
|
205
221
|
constructor(store = responseStore) {
|
|
206
222
|
this.store = store;
|
|
207
223
|
if (!store) throw new Error("[vinext] The Workers Response Store adapter must run through its generated Cloudflare worker entry.");
|
|
@@ -238,10 +254,15 @@ var WorkersResponseStoreCacheHandler = class {
|
|
|
238
254
|
await this.store.purge({ pathPrefixes: [new URL(request.url).pathname] });
|
|
239
255
|
return null;
|
|
240
256
|
}
|
|
257
|
+
let tagExpiration;
|
|
241
258
|
if (softTags.length) {
|
|
242
259
|
const expiration = eagerExpiration ? await eagerExpiration : { value: await getTagExpiration() };
|
|
243
260
|
if ("error" in expiration) throw expiration.error;
|
|
244
261
|
if (expiration.value >= entry.lastModified) return null;
|
|
262
|
+
tagExpiration = {
|
|
263
|
+
tags: softTags,
|
|
264
|
+
expiration: expiration.value
|
|
265
|
+
};
|
|
245
266
|
}
|
|
246
267
|
const age = Date.now() - entry.lastModified;
|
|
247
268
|
const requestedRevalidate = readCacheControlField(context, "revalidate");
|
|
@@ -252,12 +273,18 @@ var WorkersResponseStoreCacheHandler = class {
|
|
|
252
273
|
if (requestedStale && storeStatus !== "BLOB-STALE") cacheState = "stale";
|
|
253
274
|
} else if (typeof entry.cacheControl?.expire === "number" && age > entry.cacheControl.expire * 1e3) cacheState = "expired";
|
|
254
275
|
else if (storeStatus === "BLOB-STALE" || requestedStale && typeof requestedRevalidate === "number" && (typeof storedRevalidate !== "number" || requestedRevalidate < storedRevalidate) || typeof storedRevalidate === "number" && storedRevalidate > 0 && age > storedRevalidate * 1e3 + STALE_FRESH_HIT_GRACE_MS) cacheState = "stale";
|
|
255
|
-
|
|
276
|
+
const value = {
|
|
256
277
|
lastModified: entry.lastModified,
|
|
257
278
|
value: entry.value,
|
|
258
279
|
...cacheState ? { cacheState } : {},
|
|
259
280
|
...entry.cacheControl ? { cacheControl: entry.cacheControl } : {}
|
|
260
281
|
};
|
|
282
|
+
const revision = Number(response.headers.get("X-Workers-Response-Store-Revision"));
|
|
283
|
+
if (cacheState && storeStatus === "BLOB-STALE" && Number.isSafeInteger(revision) && revision > 0) this.staleRevisions.set(value, {
|
|
284
|
+
revision,
|
|
285
|
+
...tagExpiration ? { tagExpiration } : {}
|
|
286
|
+
});
|
|
287
|
+
return value;
|
|
261
288
|
} catch (error) {
|
|
262
289
|
console.error(JSON.stringify({
|
|
263
290
|
message: "Vinext response-store data lookup failed; treating as a cache miss",
|
|
@@ -302,11 +329,13 @@ var WorkersResponseStoreCacheHandler = class {
|
|
|
302
329
|
id: DATA_REVALIDATOR_ID,
|
|
303
330
|
args: [key, invocation.serialized]
|
|
304
331
|
} : void 0;
|
|
332
|
+
const lastModified = readCacheTimestamp(context, Date.now());
|
|
305
333
|
const response = new Response(serialize({
|
|
306
334
|
...cacheControl ? { cacheControl } : {},
|
|
307
|
-
lastModified
|
|
335
|
+
lastModified,
|
|
308
336
|
value
|
|
309
337
|
}), { headers: {
|
|
338
|
+
...lastModified === context?.timestamp ? { [GENERATED_AT_HEADER]: String(lastModified) } : {},
|
|
310
339
|
"Cache-Control": cachePolicy(revalidate, expire),
|
|
311
340
|
...tagHeader ? { "Cache-Tag": tagHeader } : {},
|
|
312
341
|
"Content-Type": "application/json",
|
|
@@ -318,11 +347,17 @@ var WorkersResponseStoreCacheHandler = class {
|
|
|
318
347
|
return;
|
|
319
348
|
}
|
|
320
349
|
if (regeneration && !revalidator) return;
|
|
350
|
+
const staleEntry = context?.staleEntry;
|
|
351
|
+
const replaces = staleEntry && typeof staleEntry === "object" ? this.staleRevisions.get(staleEntry) : void 0;
|
|
321
352
|
await this.store.put(await cacheRequest(key), response, {
|
|
322
353
|
...revalidator ? { revalidator } : {},
|
|
323
354
|
...revalidator?.id !== "vinext:cache-function" ? { expiryBehavior: "miss" } : {},
|
|
324
355
|
coalesce: true,
|
|
325
|
-
purgeExisting: true
|
|
356
|
+
purgeExisting: true,
|
|
357
|
+
...replaces ? {
|
|
358
|
+
replacesRevision: replaces.revision,
|
|
359
|
+
...replaces.tagExpiration ? { replacesTagExpiration: replaces.tagExpiration } : {}
|
|
360
|
+
} : {}
|
|
326
361
|
});
|
|
327
362
|
}
|
|
328
363
|
async revalidateTag(tags, durations) {
|
|
@@ -339,4 +374,4 @@ function createResponseStoreDataCacheAdapter() {
|
|
|
339
374
|
return new WorkersResponseStoreCacheHandler();
|
|
340
375
|
}
|
|
341
376
|
//#endregion
|
|
342
|
-
export { CACHE_FUNCTION_REVALIDATOR_ID, DATA_REVALIDATOR_ID, WorkersResponseStoreCacheHandler, captureResponseStoreDataRegeneration, captureResponseStoreRscData, createResponseStoreDataCacheAdapter as default, deferResponseStoreAdmission, deferResponseStoreNotModified, runWithResponseStoreInvocation, setResponseStore };
|
|
377
|
+
export { CACHE_FUNCTION_REVALIDATOR_ID, DATA_REVALIDATOR_ID, WorkersResponseStoreCacheHandler, captureResponseStoreAppPageDocument, captureResponseStoreDataRegeneration, captureResponseStoreRscData, createResponseStoreDataCacheAdapter as default, deferResponseStoreAdmission, deferResponseStoreNotModified, runWithResponseStoreInvocation, setResponseStore };
|
package/dist/cli.js
CHANGED
|
@@ -45,6 +45,7 @@ async function deployCommand() {
|
|
|
45
45
|
preview: parsed.preview,
|
|
46
46
|
env: parsed.env,
|
|
47
47
|
config: parsed.config,
|
|
48
|
+
viteConfig: parsed.viteConfig,
|
|
48
49
|
skipBuild: parsed.skipBuild,
|
|
49
50
|
dryRun: parsed.dryRun,
|
|
50
51
|
verbose: parsed.verbose,
|
|
@@ -92,6 +93,7 @@ async function previewsCommand() {
|
|
|
92
93
|
root: process.cwd(),
|
|
93
94
|
env: options.mode,
|
|
94
95
|
skipBuild: options.skipBuild,
|
|
96
|
+
viteConfig: options.viteConfig,
|
|
95
97
|
target: {
|
|
96
98
|
type: "preview",
|
|
97
99
|
name: options.name
|
package/dist/deploy-config.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { VinextCacheConfig } from "vinext/internal/config/prerender";
|
|
|
8
8
|
* Returns true when the config both imports the package and invokes
|
|
9
9
|
* `cloudflare(...)` in the plugin list.
|
|
10
10
|
*/
|
|
11
|
-
export declare function viteConfigHasCloudflarePlugin(root: string): boolean;
|
|
11
|
+
export declare function viteConfigHasCloudflarePlugin(root: string, configFile?: string): boolean;
|
|
12
12
|
/**
|
|
13
13
|
* Detect whether the Vite config assigns a CDN or data cache adapter — i.e. the
|
|
14
14
|
* `cdn` or `data` field of the `vinext({ cache })` option is given a value.
|
|
@@ -17,12 +17,12 @@ export declare function viteConfigHasCloudflarePlugin(root: string): boolean;
|
|
|
17
17
|
* an unreadable or absent config is treated as configured so a deploy is never
|
|
18
18
|
* blocked on a false negative.
|
|
19
19
|
*/
|
|
20
|
-
export declare function viteConfigHasCacheAdapter(root: string): boolean;
|
|
20
|
+
export declare function viteConfigHasCacheAdapter(root: string, configFile?: string): boolean;
|
|
21
21
|
export type ResolvedCdnAdapterConfig = {
|
|
22
22
|
versionMetadataBinding: string;
|
|
23
23
|
};
|
|
24
24
|
export declare function resolveCdnAdapterConfig(cache: VinextCacheConfig | null | undefined): ResolvedCdnAdapterConfig | null;
|
|
25
|
-
export declare function viteConfigHasImageAdapter(root: string): boolean;
|
|
25
|
+
export declare function viteConfigHasImageAdapter(root: string, configFile?: string): boolean;
|
|
26
26
|
/**
|
|
27
27
|
* Detect whether an existing user-authored Worker entry wires up a cache
|
|
28
28
|
* backend imperatively via one of the `setCacheHandler` / `setDataCacheHandler`
|
|
@@ -48,5 +48,5 @@ export declare function formatMissingCacheAdapterError(options: {
|
|
|
48
48
|
configFile?: string;
|
|
49
49
|
typedConfig?: boolean;
|
|
50
50
|
}): string;
|
|
51
|
-
export declare function formatImageOptimizationHint(typedConfig?: boolean): string;
|
|
51
|
+
export declare function formatImageOptimizationHint(typedConfig?: boolean, configFile?: string): string;
|
|
52
52
|
//#endregion
|
package/dist/deploy-config.js
CHANGED
|
@@ -15,8 +15,8 @@ function escapeRegExp(value) {
|
|
|
15
15
|
* Returns true when the config both imports the package and invokes
|
|
16
16
|
* `cloudflare(...)` in the plugin list.
|
|
17
17
|
*/
|
|
18
|
-
function viteConfigHasCloudflarePlugin(root) {
|
|
19
|
-
const configPath = findViteConfigPath(root);
|
|
18
|
+
function viteConfigHasCloudflarePlugin(root, configFile) {
|
|
19
|
+
const configPath = configFile ?? findViteConfigPath(root);
|
|
20
20
|
if (!configPath) return false;
|
|
21
21
|
try {
|
|
22
22
|
const content = fs.readFileSync(configPath, "utf-8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
|
|
@@ -75,8 +75,8 @@ function cacheFieldAssigned(cacheBlock, field) {
|
|
|
75
75
|
* an unreadable or absent config is treated as configured so a deploy is never
|
|
76
76
|
* blocked on a false negative.
|
|
77
77
|
*/
|
|
78
|
-
function viteConfigHasCacheAdapter(root) {
|
|
79
|
-
const configPath = findViteConfigPath(root);
|
|
78
|
+
function viteConfigHasCacheAdapter(root, configFile) {
|
|
79
|
+
const configPath = configFile ?? findViteConfigPath(root);
|
|
80
80
|
if (!configPath) return true;
|
|
81
81
|
let content;
|
|
82
82
|
try {
|
|
@@ -104,8 +104,8 @@ function resolveCdnAdapterConfig(cache) {
|
|
|
104
104
|
const options = cdn.options;
|
|
105
105
|
return { versionMetadataBinding: typeof options?.versionMetadataBinding === "string" && options.versionMetadataBinding.length > 0 ? options.versionMetadataBinding : DEFAULT_CDN_VERSION_METADATA_BINDING };
|
|
106
106
|
}
|
|
107
|
-
function viteConfigHasImageAdapter(root) {
|
|
108
|
-
const configPath = findViteConfigPath(root);
|
|
107
|
+
function viteConfigHasImageAdapter(root, configFile) {
|
|
108
|
+
const configPath = configFile ?? findViteConfigPath(root);
|
|
109
109
|
if (!configPath) return true;
|
|
110
110
|
try {
|
|
111
111
|
const content = fs.readFileSync(configPath, "utf-8");
|
|
@@ -161,7 +161,10 @@ function formatMissingCacheAdapterError(options) {
|
|
|
161
161
|
const configRef = options.configFile ? options.configFile : "your Vite config";
|
|
162
162
|
return `[vinext] This app uses ISR / caching but no cache adapter is configured in ${configRef}.\n\n Production deployments need a persistent cache backend. Declare one on the\n vinext() plugin in ${configRef}:\n\n import { kvDataAdapter } from "@vinext/cloudflare/cache/kv-data-adapter";\n\n export default defineConfig({\n plugins: [\n vinext({\n cache: {\n data: kvDataAdapter(), // KV-backed data cache (binding: VINEXT_KV_CACHE)\n },\n }),\n cloudflare(),\n ],\n });\n\n` + (options.typedConfig ? ` Configure the VINEXT_KV_CACHE namespace binding in cloudflare.config.ts.` : " The VINEXT_KV_CACHE namespace binding is added to wrangler.jsonc for you.\n Create the namespace with:\n\n npx wrangler kv namespace create VINEXT_KV_CACHE");
|
|
163
163
|
}
|
|
164
|
-
function formatImageOptimizationHint(typedConfig = false) {
|
|
164
|
+
function formatImageOptimizationHint(typedConfig = false, configFile) {
|
|
165
|
+
if (configFile) return ` [vinext] next/image is served unoptimized. To enable edge image
|
|
166
|
+
optimization via Cloudflare Images, configure imagesOptimizer() in
|
|
167
|
+
${configFile} and the IMAGES binding in ${typedConfig ? "cloudflare.config.ts" : "your Wrangler config"}.`;
|
|
165
168
|
if (typedConfig) return " [vinext] next/image is served unoptimized. To enable edge image\n optimization via Cloudflare Images, configure imagesOptimizer() in your\n Vite config and the IMAGES binding in cloudflare.config.ts.";
|
|
166
169
|
return " [vinext] next/image is served unoptimized. To enable edge image\n optimization via Cloudflare Images, run:\n\n vinext init --platform=cloudflare --image-optimization=cloudflare-images\n\n This adds the imagesOptimizer() option to your Vite config and the matching\n IMAGES binding to your Wrangler config without replacing existing settings.";
|
|
167
170
|
}
|
package/dist/deploy-help.js
CHANGED
|
@@ -16,6 +16,7 @@ function formatDeployHelp() {
|
|
|
16
16
|
--env <name> Cloudflare mode for typed config; Wrangler environment otherwise
|
|
17
17
|
--name <name> Custom Worker name for Wrangler config projects
|
|
18
18
|
--config <path> Wrangler config path (default: wrangler.jsonc/json/toml)
|
|
19
|
+
--vite-config <path> Vite config file to build with (default: vite.config.*)
|
|
19
20
|
--skip-build Skip the build step (use existing dist/)
|
|
20
21
|
--dry-run Validate setup without building or deploying
|
|
21
22
|
--verbose Print raw output from internal Cloudflare CLI commands
|
|
@@ -129,9 +130,10 @@ function formatPreviewsDeployHelp() {
|
|
|
129
130
|
preview-name Optional Preview name (cf uses the CI or Git branch if omitted)
|
|
130
131
|
|
|
131
132
|
Options:
|
|
132
|
-
--mode <mode>
|
|
133
|
-
--skip-build
|
|
134
|
-
-
|
|
133
|
+
--mode <mode> Vite mode to build and deploy in (default: production)
|
|
134
|
+
--skip-build Deploy existing Preview Build Output without rebuilding
|
|
135
|
+
--vite-config <path> Vite config file to build with (default: vite.config.*)
|
|
136
|
+
-h, --help Show this help
|
|
135
137
|
|
|
136
138
|
Examples:
|
|
137
139
|
vinext-cloudflare previews deploy
|
package/dist/deploy.d.ts
CHANGED
|
@@ -23,6 +23,8 @@ export type DeployOptions = {
|
|
|
23
23
|
name?: string;
|
|
24
24
|
/** Wrangler config path, relative to root unless absolute */
|
|
25
25
|
config?: string;
|
|
26
|
+
/** Vite config file used to build and read deploy metadata, relative to root unless absolute */
|
|
27
|
+
viteConfig?: string;
|
|
26
28
|
/** Skip the build step (assume already built) */
|
|
27
29
|
skipBuild?: boolean;
|
|
28
30
|
/** Dry run — validate setup but don't build or deploy */
|
|
@@ -85,6 +87,7 @@ export declare function parseDeployArgs(args: string[]): {
|
|
|
85
87
|
env: string | undefined;
|
|
86
88
|
name: string | undefined;
|
|
87
89
|
config: string | undefined;
|
|
90
|
+
viteConfig: string | undefined;
|
|
88
91
|
skipBuild: boolean;
|
|
89
92
|
dryRun: boolean;
|
|
90
93
|
verbose: boolean;
|
package/dist/deploy.js
CHANGED
|
@@ -99,6 +99,7 @@ const deployArgOptions = {
|
|
|
99
99
|
env: { type: "string" },
|
|
100
100
|
name: { type: "string" },
|
|
101
101
|
config: { type: "string" },
|
|
102
|
+
"vite-config": { type: "string" },
|
|
102
103
|
"skip-build": {
|
|
103
104
|
type: "boolean",
|
|
104
105
|
default: false
|
|
@@ -249,6 +250,7 @@ function parseDeployArgs(args) {
|
|
|
249
250
|
env: values.env?.trim() || void 0,
|
|
250
251
|
name: values.name?.trim() || void 0,
|
|
251
252
|
config: values.config?.trim() || void 0,
|
|
253
|
+
viteConfig: values["vite-config"]?.trim() || void 0,
|
|
252
254
|
skipBuild: values["skip-build"],
|
|
253
255
|
dryRun: values["dry-run"],
|
|
254
256
|
verbose: values.verbose,
|
|
@@ -339,11 +341,11 @@ async function loadProjectWranglerApi(root) {
|
|
|
339
341
|
function resolveViteBuildMode(deploymentTool, env) {
|
|
340
342
|
return deploymentTool === "cf" ? env ?? "production" : "production";
|
|
341
343
|
}
|
|
342
|
-
async function loadDeployViteConfigMetadata(root, mode) {
|
|
344
|
+
async function loadDeployViteConfigMetadata(root, mode, configFile) {
|
|
343
345
|
const plugins = (await (await loadProjectViteApi(root)).loadConfigFromFile({
|
|
344
346
|
command: "build",
|
|
345
347
|
mode
|
|
346
|
-
},
|
|
348
|
+
}, configFile, root))?.config.plugins;
|
|
347
349
|
return {
|
|
348
350
|
cacheConfig: await findVinextCacheConfigInPlugins(plugins),
|
|
349
351
|
nextConfig: await findVinextNextConfigInPlugins(plugins),
|
|
@@ -351,7 +353,7 @@ async function loadDeployViteConfigMetadata(root, mode) {
|
|
|
351
353
|
routeRootConfig: await findVinextRouteRootConfigInPlugins(plugins)
|
|
352
354
|
};
|
|
353
355
|
}
|
|
354
|
-
async function runBuild(info, env, mode) {
|
|
356
|
+
async function runBuild(info, env, mode, configFile) {
|
|
355
357
|
console.log("\n Building for Cloudflare Workers...\n");
|
|
356
358
|
const { createBuilder } = await loadProjectViteApi(info.root);
|
|
357
359
|
let completed = false;
|
|
@@ -362,6 +364,7 @@ async function runBuild(info, env, mode) {
|
|
|
362
364
|
await (await createBuilder({
|
|
363
365
|
root: info.root,
|
|
364
366
|
mode,
|
|
367
|
+
configFile,
|
|
365
368
|
[VINEXT_BUILD_LIFECYCLE_CONFIG]: invocation
|
|
366
369
|
})).buildApp();
|
|
367
370
|
});
|
|
@@ -1190,6 +1193,7 @@ async function deploy(options) {
|
|
|
1190
1193
|
if (isPreview && deploymentTool !== "cf") throw new Error("Worker Previews require a cloudflare.config.ts project and the cf CLI.");
|
|
1191
1194
|
if (deploymentTool === "cf" && options.name) throw new Error("--name is not supported for cloudflare.config.ts projects. Set `name` in cloudflare.config.ts instead.");
|
|
1192
1195
|
const viteMode = resolveViteBuildMode(deploymentTool, deployEnv);
|
|
1196
|
+
const viteConfigFile = options.viteConfig ? path.resolve(root, options.viteConfig) : void 0;
|
|
1193
1197
|
const wranglerFallbackEnv = deploymentTool === "cf" ? void 0 : deployEnv;
|
|
1194
1198
|
if (!process.env.NODE_ENV) Reflect.set(process.env, "NODE_ENV", "production");
|
|
1195
1199
|
loadDotenv({
|
|
@@ -1210,16 +1214,25 @@ async function deploy(options) {
|
|
|
1210
1214
|
console.log(` Project: ${info.projectName}`);
|
|
1211
1215
|
console.log(` Router: ${info.isAppRouter ? "App Router" : "Pages Router"}`);
|
|
1212
1216
|
console.log(` ISR: ${info.hasISR ? "detected" : "none"}`);
|
|
1213
|
-
|
|
1217
|
+
if (viteConfigFile && !fs.existsSync(viteConfigFile)) throw new Error(`Vite config file not found: ${viteConfigFile}`);
|
|
1218
|
+
const hasViteConfig = Boolean(viteConfigFile) || info.hasViteConfig;
|
|
1219
|
+
const missingScaffolding = [!hasViteConfig && "Vite config", !info.hasWranglerConfig && "Wrangler config"].filter((value) => Boolean(value));
|
|
1214
1220
|
if (missingScaffolding.length > 0) throw new Error(`Missing Cloudflare deployment setup: ${missingScaffolding.join(", ")}. Run \`vinext init --platform=cloudflare\` first.`);
|
|
1215
1221
|
const missingDeps = getMissingDeps(info);
|
|
1216
1222
|
if (missingDeps.length > 0) throw new Error(`Missing deployment dependencies: ${missingDeps.map((dependency) => dependency.name).join(", ")}. Run \`vinext init --platform=cloudflare\` first.`);
|
|
1217
1223
|
if (deploymentTool === "cf" && !isCfCliInstalled(root)) throw new Error("Missing deployment dependencies: cf. Run `vinext init --platform=cloudflare` first.");
|
|
1218
|
-
if (
|
|
1219
|
-
|
|
1220
|
-
|
|
1224
|
+
if (hasViteConfig && !viteConfigHasCloudflarePlugin(root, viteConfigFile)) throw new Error(formatMissingCloudflarePluginError({
|
|
1225
|
+
isAppRouter: info.isAppRouter,
|
|
1226
|
+
configFile: viteConfigFile,
|
|
1227
|
+
initUpdatesConfig: !viteConfigFile
|
|
1228
|
+
}));
|
|
1229
|
+
if (info.hasISR && !viteConfigHasCacheAdapter(root, viteConfigFile) && !workerEntryHasCacheHandler(root)) throw new Error(formatMissingCacheAdapterError({
|
|
1230
|
+
configFile: viteConfigFile,
|
|
1231
|
+
typedConfig: deploymentTool === "cf"
|
|
1232
|
+
}));
|
|
1233
|
+
if (!viteConfigHasImageAdapter(root, viteConfigFile)) {
|
|
1221
1234
|
console.log();
|
|
1222
|
-
console.log(formatImageOptimizationHint(deploymentTool === "cf"));
|
|
1235
|
+
console.log(formatImageOptimizationHint(deploymentTool === "cf", viteConfigFile));
|
|
1223
1236
|
}
|
|
1224
1237
|
if (options.dryRun) {
|
|
1225
1238
|
console.log("\n Dry run complete. No build or deploy performed.\n");
|
|
@@ -1233,7 +1246,7 @@ async function deploy(options) {
|
|
|
1233
1246
|
}));
|
|
1234
1247
|
return;
|
|
1235
1248
|
}
|
|
1236
|
-
const viteConfigMetadata = await withBuildContext(() => loadDeployViteConfigMetadata(info.root, viteMode));
|
|
1249
|
+
const viteConfigMetadata = await withBuildContext(() => loadDeployViteConfigMetadata(info.root, viteMode, viteConfigFile));
|
|
1237
1250
|
const cdnAdapterConfig = resolveCdnAdapterConfig(viteConfigMetadata.cacheConfig);
|
|
1238
1251
|
const nextConfig = await withBuildContext(async () => {
|
|
1239
1252
|
const inlineNextConfig = viteConfigMetadata.nextConfig;
|
|
@@ -1259,7 +1272,7 @@ async function deploy(options) {
|
|
|
1259
1272
|
const replacement = isPreview ? "Preview deployments render these routes on request." : options.warmCdnCache ? "Routes will be rendered and warmed through the staged Worker instead." : "Use --warm-cache to render and warm routes through the deployed Worker instead.";
|
|
1260
1273
|
console.warn(`\n Warning: ${trigger} is ignored by Cloudflare deploy. ${replacement}`);
|
|
1261
1274
|
}
|
|
1262
|
-
if (!options.skipBuild) await withBuildContext(() => runBuild(info, cloudflareEnv, viteMode));
|
|
1275
|
+
if (!options.skipBuild) await withBuildContext(() => runBuild(info, cloudflareEnv, viteMode, viteConfigFile));
|
|
1263
1276
|
else console.log("\n Skipping build (--skip-build)");
|
|
1264
1277
|
const prerenderOutputDirs = resolvePrerenderOutputDirs(info.root, deploymentTool, viteConfigMetadata.routeRootConfig);
|
|
1265
1278
|
const canWarmTpr = options.experimentalTPR && !shouldPrerenderLocally && hasBuildIdentityHeader;
|
package/dist/previews-deploy.js
CHANGED
|
@@ -28,7 +28,8 @@ function parsePreviewsDeployArgs(args) {
|
|
|
28
28
|
"skip-build": {
|
|
29
29
|
type: "boolean",
|
|
30
30
|
default: false
|
|
31
|
-
}
|
|
31
|
+
},
|
|
32
|
+
"vite-config": { type: "string" }
|
|
32
33
|
}
|
|
33
34
|
});
|
|
34
35
|
if (positionals.length > 1) throw new Error("Expected at most one Preview name: previews deploy [preview-name].");
|
|
@@ -36,7 +37,8 @@ function parsePreviewsDeployArgs(args) {
|
|
|
36
37
|
help: values.help,
|
|
37
38
|
name: positionals[0] === void 0 ? void 0 : validatePreviewName(positionals[0]),
|
|
38
39
|
mode: values.mode === void 0 ? void 0 : validateMode(values.mode),
|
|
39
|
-
skipBuild: values["skip-build"]
|
|
40
|
+
skipBuild: values["skip-build"],
|
|
41
|
+
viteConfig: values["vite-config"]?.trim() || void 0
|
|
40
42
|
};
|
|
41
43
|
}
|
|
42
44
|
//#endregion
|
|
@@ -6,5 +6,10 @@
|
|
|
6
6
|
* helpers in the vinext core).
|
|
7
7
|
*/
|
|
8
8
|
export declare function isUnknownRecord(value: unknown): value is Record<string, unknown>;
|
|
9
|
+
/**
|
|
10
|
+
* When a `"use cache"` entry's generation started (`ctx.timestamp`), which dates
|
|
11
|
+
* the entry like Next.js does. Absent, malformed or future values fall back to `now`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function readCacheTimestamp(ctx: Record<string, unknown> | undefined, now: number): number;
|
|
9
14
|
export declare function readCacheControlNumberField(ctx: Record<string, unknown> | undefined, field: string): number | undefined;
|
|
10
15
|
//#endregion
|
|
@@ -12,9 +12,17 @@ function readRecordField(ctx, field) {
|
|
|
12
12
|
const value = ctx?.[field];
|
|
13
13
|
return isUnknownRecord(value) ? value : void 0;
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* When a `"use cache"` entry's generation started (`ctx.timestamp`), which dates
|
|
17
|
+
* the entry like Next.js does. Absent, malformed or future values fall back to `now`.
|
|
18
|
+
*/
|
|
19
|
+
function readCacheTimestamp(ctx, now) {
|
|
20
|
+
const value = ctx?.timestamp;
|
|
21
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 && value <= now ? value : now;
|
|
22
|
+
}
|
|
15
23
|
function readCacheControlNumberField(ctx, field) {
|
|
16
24
|
const value = readRecordField(ctx, "cacheControl")?.[field] ?? ctx?.[field];
|
|
17
25
|
return typeof value === "number" ? value : void 0;
|
|
18
26
|
}
|
|
19
27
|
//#endregion
|
|
20
|
-
export { isUnknownRecord, readCacheControlNumberField };
|
|
28
|
+
export { isUnknownRecord, readCacheControlNumberField, readCacheTimestamp };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vinext/cloudflare",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Cloudflare cache and image adapters for vinext.",
|
|
5
5
|
"homepage": "https://vinext.dev/",
|
|
6
6
|
"license": "MIT",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
}
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@cloudflare/workers-response-store": "1.1.
|
|
51
|
+
"@cloudflare/workers-response-store": "1.1.4"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@types/node": "^25.9.2",
|
|
@@ -56,12 +56,12 @@
|
|
|
56
56
|
"miniflare": "5.20260730.0-alpha",
|
|
57
57
|
"vite": "npm:@voidzero-dev/vite-plus-core@1.3.0",
|
|
58
58
|
"vite-plus": "1.3.0",
|
|
59
|
-
"vinext": "1.
|
|
59
|
+
"vinext": "1.4.0"
|
|
60
60
|
},
|
|
61
61
|
"peerDependencies": {
|
|
62
62
|
"@cloudflare/vite-plugin": "*",
|
|
63
63
|
"cf": "*",
|
|
64
|
-
"vinext": "^1.
|
|
64
|
+
"vinext": "^1.4.0"
|
|
65
65
|
},
|
|
66
66
|
"peerDependenciesMeta": {
|
|
67
67
|
"@cloudflare/vite-plugin": {
|