@vinext/cloudflare 1.0.0 → 1.0.1
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/browser-cache-policy.d.ts +7 -0
- package/dist/cache/browser-cache-policy.js +39 -0
- package/dist/cache/cdn-adapter.runtime.js +16 -16
- package/dist/cache/cdn-adapter.worker.d.ts +1 -1
- package/dist/cache/cdn-adapter.worker.js +10 -35
- package/dist/cache/kv-data-adapter.runtime.js +4 -6
- package/dist/cache/kv-key.js +1 -1
- package/dist/cache/response-store-adapter.worker.js +23 -7
- package/dist/cache/static-assets-adapter.build.d.ts +1 -1
- package/dist/cache/static-assets-adapter.build.js +42 -5
- package/dist/cache/static-assets-adapter.d.ts +1 -1
- package/dist/cache/static-assets-adapter.js +1 -1
- package/dist/cache/static-assets-adapter.runtime.d.ts +2 -1
- package/dist/cache/static-assets-adapter.runtime.js +20 -3
- package/dist/cache/static-assets-adapter.shared.d.ts +1 -1
- package/dist/cache/workers-cache-cdn-adapter.d.ts +4 -4
- package/dist/cache/workers-cache-cdn-adapter.js +4 -4
- package/dist/cdn-warm.js +2 -2
- package/package.json +4 -4
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
//#region src/cache/browser-cache-policy.d.ts
|
|
2
|
+
export type SharedResponseStage = {
|
|
3
|
+
headers: Headers;
|
|
4
|
+
};
|
|
5
|
+
export declare const SHARED_RESPONSE_STAGE_HEADER = "x-vinext-cloudflare-shared-response-stage";
|
|
6
|
+
export declare function finalizeGatewayResponse(response: Response, sharedResponses: Map<string, SharedResponseStage>, cacheStatusHeader?: string): Response;
|
|
7
|
+
//#endregion
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { NEXTJS_CACHE_HEADER, VINEXT_CACHE_HEADER } from "vinext/internal/server/headers";
|
|
2
|
+
//#region src/cache/browser-cache-policy.ts
|
|
3
|
+
const CLOUDFLARE_EDGE_POLICY_HEADER = "Cloudflare-CDN-Cache-Control";
|
|
4
|
+
const SHARED_RESPONSE_STAGE_HEADER = "x-vinext-cloudflare-shared-response-stage";
|
|
5
|
+
const PRIVATE_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
|
|
6
|
+
SHARED_RESPONSE_STAGE_HEADER,
|
|
7
|
+
CLOUDFLARE_EDGE_POLICY_HEADER.toLowerCase(),
|
|
8
|
+
"cdn-cache-control",
|
|
9
|
+
"cache-tag",
|
|
10
|
+
VINEXT_CACHE_HEADER.toLowerCase(),
|
|
11
|
+
NEXTJS_CACHE_HEADER.toLowerCase()
|
|
12
|
+
]);
|
|
13
|
+
function finalizeGatewayResponse(response, sharedResponses, cacheStatusHeader = "CF-Cache-Status") {
|
|
14
|
+
const provenance = response.headers.get(SHARED_RESPONSE_STAGE_HEADER);
|
|
15
|
+
const sharedResponse = provenance === null ? void 0 : sharedResponses.get(provenance);
|
|
16
|
+
const usedSharedResponseStage = sharedResponse !== void 0;
|
|
17
|
+
const sharedResponseStageCollision = provenance !== null && !usedSharedResponseStage;
|
|
18
|
+
if (!response.headers.has(CLOUDFLARE_EDGE_POLICY_HEADER) && !usedSharedResponseStage && !sharedResponseStageCollision) return response;
|
|
19
|
+
const headers = new Headers(response.headers);
|
|
20
|
+
headers.delete(SHARED_RESPONSE_STAGE_HEADER);
|
|
21
|
+
headers.delete(CLOUDFLARE_EDGE_POLICY_HEADER);
|
|
22
|
+
if (usedSharedResponseStage || sharedResponseStageCollision) {
|
|
23
|
+
for (const name of PRIVATE_RESPONSE_HEADERS) headers.delete(name);
|
|
24
|
+
if (sharedResponseStageCollision) headers.set("Cache-Control", "no-store");
|
|
25
|
+
else if (!headers.has("Cache-Control")) headers.set("Cache-Control", "private, max-age=0, must-revalidate");
|
|
26
|
+
}
|
|
27
|
+
const cacheStatus = sharedResponse?.headers.get(cacheStatusHeader);
|
|
28
|
+
if (cacheStatus) {
|
|
29
|
+
headers.set(VINEXT_CACHE_HEADER, cacheStatus);
|
|
30
|
+
headers.set(NEXTJS_CACHE_HEADER, cacheStatus);
|
|
31
|
+
}
|
|
32
|
+
return new Response(response.body, {
|
|
33
|
+
headers,
|
|
34
|
+
status: response.status,
|
|
35
|
+
statusText: response.statusText
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
//#endregion
|
|
39
|
+
export { SHARED_RESPONSE_STAGE_HEADER, finalizeGatewayResponse };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { VINEXT_CDN_BUILD_ID_HEADER, getVinextCdnBuildIdentity } from "./cdn-build-id.js";
|
|
2
2
|
import { VINEXT_EXPECTED_WORKER_VERSION_HEADER } from "../version-headers.js";
|
|
3
|
-
import { isNonCacheableCacheControl } from "vinext/shims/cdn-cache";
|
|
3
|
+
import { isNonCacheableCacheControl, splitCacheControlDirectives } from "vinext/shims/cdn-cache";
|
|
4
4
|
import { getRequestExecutionContext } from "vinext/shims/request-context";
|
|
5
5
|
import { fnv1a64 } from "vinext/internal/utils/hash";
|
|
6
6
|
//#region src/cache/cdn-adapter.runtime.ts
|
|
@@ -19,11 +19,9 @@ import { fnv1a64 } from "vinext/internal/utils/hash";
|
|
|
19
19
|
* cache headers, so there is nothing to persist at the origin.
|
|
20
20
|
* - `buildResponseHeaders` emits the SWR policy as `Cloudflare-CDN-Cache-Control`
|
|
21
21
|
* (`public, max-age=…, stale-while-revalidate=…`) so the edge caches and
|
|
22
|
-
* revalidates.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* `private, max-age=0, must-revalidate` before public egress so personalized
|
|
26
|
-
* request-stage headers cannot enter another shared cache. A `Cache-Tag`
|
|
22
|
+
* revalidates. Explicit endpoint Cache-Control is preserved for clients,
|
|
23
|
+
* independently of shared admission. Framework-managed responses default to
|
|
24
|
+
* `private, max-age=0, must-revalidate`. A `Cache-Tag`
|
|
27
25
|
* header lets entries be purged by tag. Note the edge directive uses `max-age`
|
|
28
26
|
* (not `s-maxage`):
|
|
29
27
|
* the framework computes the policy with `s-maxage` for shared caches, but
|
|
@@ -54,13 +52,13 @@ function getBuildIdentityResponseHeader() {
|
|
|
54
52
|
const buildId = getVinextCdnBuildIdentity();
|
|
55
53
|
return buildId ? { [VINEXT_CDN_BUILD_ID_HEADER]: buildId } : {};
|
|
56
54
|
}
|
|
57
|
-
/**
|
|
58
|
-
function clearCloudflareCdnResponseHeaders(cacheControl) {
|
|
55
|
+
/** Disable shared caching while preserving an explicit client policy. */
|
|
56
|
+
function clearCloudflareCdnResponseHeaders(cacheControl, browserCacheControl) {
|
|
59
57
|
return {
|
|
60
58
|
...getBuildIdentityResponseHeader(),
|
|
61
|
-
"Cache-Control": cacheControl,
|
|
59
|
+
"Cache-Control": browserCacheControl ?? cacheControl,
|
|
62
60
|
"CDN-Cache-Control": null,
|
|
63
|
-
"Cloudflare-CDN-Cache-Control": null,
|
|
61
|
+
"Cloudflare-CDN-Cache-Control": browserCacheControl !== void 0 && !isNonCacheableCacheControl(browserCacheControl) ? "no-store" : null,
|
|
64
62
|
"Cache-Tag": null
|
|
65
63
|
};
|
|
66
64
|
}
|
|
@@ -99,7 +97,7 @@ const NO_STORE = "no-store";
|
|
|
99
97
|
* revalidate (against the edge) rather than serving a stored copy — so the user
|
|
100
98
|
* always sees edge-fresh content while still permitting conditional 304s.
|
|
101
99
|
*/
|
|
102
|
-
const BROWSER_REVALIDATE = "
|
|
100
|
+
const BROWSER_REVALIDATE = "private, max-age=0, must-revalidate";
|
|
103
101
|
/**
|
|
104
102
|
* A concrete stale window (1 year) substituted for a value-less
|
|
105
103
|
* `stale-while-revalidate`. The framework emits a bare `stale-while-revalidate`
|
|
@@ -119,8 +117,10 @@ const UNBOUNDED_SWR_SECONDS = 31536e3;
|
|
|
119
117
|
* `public`.
|
|
120
118
|
*/
|
|
121
119
|
function toEdgeCacheControl(cacheControl) {
|
|
122
|
-
const
|
|
123
|
-
|
|
120
|
+
const directives = splitCacheControlDirectives(cacheControl);
|
|
121
|
+
const hasSharedMaxAge = directives.some((directive) => /^s-maxage\s*=/i.test(directive));
|
|
122
|
+
const withMaxAge = directives.filter((directive) => !hasSharedMaxAge || !/^max-age\s*=/i.test(directive)).map((directive) => directive.replace(/^s-maxage\s*=/i, "max-age=").replace(/^stale-while-revalidate$/i, `stale-while-revalidate=${UNBOUNDED_SWR_SECONDS}`)).join(", ");
|
|
123
|
+
return directives.some((directive) => /^public$/i.test(directive)) ? withMaxAge : `public, ${withMaxAge}`;
|
|
124
124
|
}
|
|
125
125
|
/**
|
|
126
126
|
* Cloudflare's `Cache-Tag` header budget is 16 KB total. Fixed-size digests let
|
|
@@ -197,12 +197,12 @@ var CloudflareCdnCacheAdapter = class {
|
|
|
197
197
|
buildResponseHeaders(input) {
|
|
198
198
|
if (input.pendingDynamicCheck) return clearCloudflareCdnResponseHeaders(NO_STORE);
|
|
199
199
|
if (!input.cacheControl) return clearCloudflareCdnResponseHeaders(NO_STORE);
|
|
200
|
-
if (isNonCacheableCacheControl(input.cacheControl)) return clearCloudflareCdnResponseHeaders(input.cacheControl);
|
|
200
|
+
if (isNonCacheableCacheControl(input.cacheControl)) return clearCloudflareCdnResponseHeaders(input.cacheControl, input.browserCacheControl);
|
|
201
201
|
const cacheTag = input.tags?.length ? formatCacheTag(input.tags) : null;
|
|
202
|
-
if (input.tags?.length && !cacheTag) return clearCloudflareCdnResponseHeaders(NO_STORE);
|
|
202
|
+
if (input.tags?.length && !cacheTag) return clearCloudflareCdnResponseHeaders(NO_STORE, input.browserCacheControl);
|
|
203
203
|
return {
|
|
204
204
|
...getBuildIdentityResponseHeader(),
|
|
205
|
-
"Cache-Control": BROWSER_REVALIDATE,
|
|
205
|
+
"Cache-Control": input.browserCacheControl ?? BROWSER_REVALIDATE,
|
|
206
206
|
"CDN-Cache-Control": null,
|
|
207
207
|
"Cloudflare-CDN-Cache-Control": toEdgeCacheControl(input.cacheControl),
|
|
208
208
|
"Cache-Tag": cacheTag
|
|
@@ -19,7 +19,7 @@ export declare class VinextCachedResponse extends WorkerEntrypoint<unknown, unkn
|
|
|
19
19
|
fetch(request: Request): Promise<Response>;
|
|
20
20
|
purge(options: CachePurgeOptions): Promise<unknown>;
|
|
21
21
|
}
|
|
22
|
-
/** Uncached response entrypoint
|
|
22
|
+
/** Uncached response entrypoint retained for deployment readiness probes. */
|
|
23
23
|
export declare class VinextUncachedResponse extends WorkerEntrypoint<unknown, unknown> {
|
|
24
24
|
fetch(request: Request): Promise<Response>;
|
|
25
25
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { VINEXT_CDN_BUILD_ID_HEADER, getVinextCdnBuildIdentity } from "./cdn-build-id.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { SHARED_RESPONSE_STAGE_HEADER, finalizeGatewayResponse } from "./browser-cache-policy.js";
|
|
3
|
+
import { VINEXT_PRERENDER_READINESS_PATH, VINEXT_RSC_VARY_HEADER } from "vinext/internal/server/headers";
|
|
4
4
|
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
5
5
|
import "vinext/internal/server/cloudflare-workers-tracing";
|
|
6
6
|
import { loadVinextRequestStage } from "vinext/server/request-stage";
|
|
@@ -13,8 +13,6 @@ const AUTHORIZATION_TRANSPORT_HEADER = "x-vinext-internal-authorization";
|
|
|
13
13
|
const REQUEST_CACHE_CONTROL_TRANSPORT_HEADER = "x-vinext-internal-request-cache-control";
|
|
14
14
|
const REQUEST_CF_TRANSPORT_HEADER = "x-vinext-internal-request-cf";
|
|
15
15
|
const REQUEST_PRAGMA_TRANSPORT_HEADER = "x-vinext-internal-request-pragma";
|
|
16
|
-
const CLOUDFLARE_EDGE_POLICY_HEADER = "Cloudflare-CDN-Cache-Control";
|
|
17
|
-
const SHARED_RESPONSE_STAGE_HEADER = "x-vinext-cloudflare-shared-response-stage";
|
|
18
16
|
const RESPONSE_STAGE_WIRE_CACHE = {
|
|
19
17
|
bypass: "vinext-cloudflare-v1:bypass",
|
|
20
18
|
shared: "vinext-cloudflare-v1:shared"
|
|
@@ -209,33 +207,6 @@ function markSharedResponseStage(response, provenanceToken, responseStageProps,
|
|
|
209
207
|
statusText: response.statusText
|
|
210
208
|
}), cacheStatus, responseStageProps);
|
|
211
209
|
}
|
|
212
|
-
function finalizeGatewayResponse(response, provenanceToken) {
|
|
213
|
-
const provenance = response.headers.get(SHARED_RESPONSE_STAGE_HEADER);
|
|
214
|
-
const usedSharedResponseStage = provenance === provenanceToken || provenance?.startsWith(`${provenanceToken}:`) === true;
|
|
215
|
-
const sharedResponseStageCollision = provenance !== null && !usedSharedResponseStage;
|
|
216
|
-
const cacheControl = response.headers.get("Cache-Control");
|
|
217
|
-
if (!response.headers.has(CLOUDFLARE_EDGE_POLICY_HEADER) && !usedSharedResponseStage && !sharedResponseStageCollision) return response;
|
|
218
|
-
const headers = new Headers(response.headers);
|
|
219
|
-
headers.delete(SHARED_RESPONSE_STAGE_HEADER);
|
|
220
|
-
headers.delete(CLOUDFLARE_EDGE_POLICY_HEADER);
|
|
221
|
-
if (usedSharedResponseStage || sharedResponseStageCollision) {
|
|
222
|
-
headers.delete("CDN-Cache-Control");
|
|
223
|
-
headers.delete("Cache-Tag");
|
|
224
|
-
headers.delete(VINEXT_CACHE_HEADER);
|
|
225
|
-
headers.delete(NEXTJS_CACHE_HEADER);
|
|
226
|
-
if (!cacheControl || !isNonCacheableCacheControl(cacheControl)) headers.set("Cache-Control", "private, max-age=0, must-revalidate");
|
|
227
|
-
}
|
|
228
|
-
if (usedSharedResponseStage && provenance?.startsWith(`${provenanceToken}:`)) {
|
|
229
|
-
const cacheStatus = decodeURIComponent(provenance.slice(provenanceToken.length + 1));
|
|
230
|
-
headers.set(VINEXT_CACHE_HEADER, cacheStatus);
|
|
231
|
-
headers.set(NEXTJS_CACHE_HEADER, cacheStatus);
|
|
232
|
-
}
|
|
233
|
-
return new Response(response.body, {
|
|
234
|
-
headers,
|
|
235
|
-
status: response.status,
|
|
236
|
-
statusText: response.statusText
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
210
|
function hasTaggedCustomVary(response) {
|
|
240
211
|
if (!response.headers.get("Cache-Tag")) return false;
|
|
241
212
|
return (response.headers.get("Vary") ?? "").split(",").map((name) => name.trim().toLowerCase()).filter(Boolean).some((name) => !FRAMEWORK_RESPONSE_VARY_FIELDS.has(name));
|
|
@@ -320,7 +291,7 @@ var VinextCachedResponse = class extends WorkerEntrypoint {
|
|
|
320
291
|
return cache.purge(options);
|
|
321
292
|
}
|
|
322
293
|
};
|
|
323
|
-
/** Uncached response entrypoint
|
|
294
|
+
/** Uncached response entrypoint retained for deployment readiness probes. */
|
|
324
295
|
var VinextUncachedResponse = class extends WorkerEntrypoint {
|
|
325
296
|
async fetch(request) {
|
|
326
297
|
const context = withResponseStagePurge(withWorkerHostRuntime(this.ctx, this.env));
|
|
@@ -336,7 +307,7 @@ var VinextUncachedResponse = class extends WorkerEntrypoint {
|
|
|
336
307
|
/** Uncached gateway: request routing and middleware always execute here. */
|
|
337
308
|
var cdn_adapter_worker_default = { async fetch(request, env, context) {
|
|
338
309
|
request = stripUntrustedTransportHeaders(request);
|
|
339
|
-
const
|
|
310
|
+
const sharedResponses = /* @__PURE__ */ new Map();
|
|
340
311
|
const stageContext = withResponseStagePurge(withWorkerHostRuntime(context, env));
|
|
341
312
|
const dispatchResponseStage = async (stageRequest, props, options) => {
|
|
342
313
|
const expectedResponseStageBuildIdentity = getVinextCdnBuildIdentity();
|
|
@@ -349,6 +320,7 @@ var cdn_adapter_worker_default = { async fetch(request, env, context) {
|
|
|
349
320
|
};
|
|
350
321
|
const usesSharedCache = options.cache === "shared";
|
|
351
322
|
try {
|
|
323
|
+
if (!usesSharedCache && !isResponseStageReadinessRequest(stageRequest)) return stampResponseStageBuildIdentity(await invokeResponseStage(stageRequest, env, stageContext, invocation));
|
|
352
324
|
const serializedInvocation = JSON.stringify({
|
|
353
325
|
...invocation,
|
|
354
326
|
options: expectedResponseStageBuildIdentity === null ? options : {
|
|
@@ -360,14 +332,17 @@ var cdn_adapter_worker_default = { async fetch(request, env, context) {
|
|
|
360
332
|
if (!binding) return responseStageUnavailable();
|
|
361
333
|
const entrypointRequest = usesSharedCache ? await createCacheFacingRequest(stageRequest, serializedInvocation) : stageRequest;
|
|
362
334
|
const response = validateResponseStageBuildIdentity(await binding.fetch(entrypointRequest));
|
|
363
|
-
|
|
335
|
+
if (!usesSharedCache) return response;
|
|
336
|
+
const shared = markSharedResponseStage(response, crypto.randomUUID(), props, true);
|
|
337
|
+
sharedResponses.set(shared.headers.get(SHARED_RESPONSE_STAGE_HEADER), { headers: new Headers(shared.headers) });
|
|
338
|
+
return shared;
|
|
364
339
|
} catch (error) {
|
|
365
340
|
if (isResponseStageReadinessRequest(stageRequest)) return responseStageUnavailable();
|
|
366
341
|
throw error;
|
|
367
342
|
}
|
|
368
343
|
};
|
|
369
344
|
const { handleRequestStage } = await loadVinextRequestStage();
|
|
370
|
-
return finalizeGatewayResponse(await handleRequestStage(request, env, stageContext, dispatchResponseStage),
|
|
345
|
+
return finalizeGatewayResponse(await handleRequestStage(request, env, stageContext, dispatchResponseStage), sharedResponses);
|
|
371
346
|
} };
|
|
372
347
|
//#endregion
|
|
373
348
|
export { VinextCachedResponse, VinextUncachedResponse, cdn_adapter_worker_default as default };
|
|
@@ -26,8 +26,6 @@ import { getRequestExecutionContext } from "vinext/shims/request-context";
|
|
|
26
26
|
const DEFAULT_BINDING = "VINEXT_KV_CACHE";
|
|
27
27
|
/** Prefix used by revalidatePath for path-based tags. */
|
|
28
28
|
const PATH_TAG_PREFIX = "_N_T_";
|
|
29
|
-
/** Max tag length to prevent KV key abuse. */
|
|
30
|
-
const MAX_TAG_LENGTH = 256;
|
|
31
29
|
/** The runtime rejects a lower `cacheTtl` with "Cache TTL must be at least 30". */
|
|
32
30
|
const MIN_KV_CACHE_TTL_SECONDS = 30;
|
|
33
31
|
/** Cloudflare caps a bulk `get()` at 100 keys per call. */
|
|
@@ -36,12 +34,12 @@ const KV_BULK_GET_LIMIT = 100;
|
|
|
36
34
|
const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
37
35
|
/**
|
|
38
36
|
* Validate a cache tag. Returns null if invalid.
|
|
39
|
-
*
|
|
40
|
-
*
|
|
37
|
+
* Tags may already be header-encoded, so their length is not the raw API tag
|
|
38
|
+
* length. The key builder bounds KV keys and safely handles colon tags.
|
|
41
39
|
*/
|
|
42
40
|
function validateTag(tag) {
|
|
43
|
-
if (typeof tag !== "string" || tag.length === 0
|
|
44
|
-
if (/[\x00-\x1f
|
|
41
|
+
if (typeof tag !== "string" || tag.length === 0) return null;
|
|
42
|
+
if (/[\x00-\x1f\\]/.test(tag)) return null;
|
|
45
43
|
return tag;
|
|
46
44
|
}
|
|
47
45
|
function readStringArrayField(ctx, field) {
|
package/dist/cache/kv-key.js
CHANGED
|
@@ -38,7 +38,7 @@ function normalizeAppPrefix(appPrefix) {
|
|
|
38
38
|
}
|
|
39
39
|
function buildStorageKey(prefix, categoryPrefix, logicalKey) {
|
|
40
40
|
const key = `${prefix}${categoryPrefix}${logicalKey}`;
|
|
41
|
-
if (kvKeyByteLength(key) <= KV_KEY_MAX_BYTES) return key;
|
|
41
|
+
if (!(categoryPrefix === TAG_PREFIX && logicalKey.includes(":")) && kvKeyByteLength(key) <= KV_KEY_MAX_BYTES) return key;
|
|
42
42
|
return `${prefix}${categoryPrefix}${HASHED_KEY_PREFIX}${fnv1a64(logicalKey)}`;
|
|
43
43
|
}
|
|
44
44
|
/** Create the deterministic key namespace for runtime cache operations. */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SHARED_RESPONSE_STAGE_HEADER, finalizeGatewayResponse } from "./browser-cache-policy.js";
|
|
1
2
|
import { captureResponseStoreDataRegeneration, runWithResponseStoreInvocation, setResponseStore } from "./response-store-data.runtime.js";
|
|
2
3
|
import { VINEXT_PARAMS_HEADER, VINEXT_RENDERED_PATH_AND_SEARCH_HEADER } from "vinext/internal/server/headers";
|
|
3
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";
|
|
@@ -153,14 +154,15 @@ function withoutRequestScopedHeaders(response, responseStageProps) {
|
|
|
153
154
|
}
|
|
154
155
|
function isCacheable(response) {
|
|
155
156
|
const policy = response.headers.get("Cloudflare-CDN-Cache-Control") ?? response.headers.get("CDN-Cache-Control") ?? response.headers.get("Cache-Control");
|
|
156
|
-
return response.status >= 200 && response.status < 400 && policy !== null && !isNonCacheableCacheControl(policy);
|
|
157
|
+
return response.status >= 200 && (response.status < 400 || response.status === 404) && policy !== null && !isNonCacheableCacheControl(policy);
|
|
157
158
|
}
|
|
158
159
|
async function readStoredResponse(key) {
|
|
159
160
|
try {
|
|
160
161
|
const response = await responseStore.fetch(key);
|
|
161
|
-
|
|
162
|
+
const storeStatus = response.headers.get("X-Workers-Response-Store");
|
|
163
|
+
if (response.status >= 200 && response.status < 400 || response.status === 404 && (storeStatus === "BLOB-FRESH" || storeStatus === "BLOB-STALE")) return response;
|
|
162
164
|
response.body?.cancel().catch(() => {});
|
|
163
|
-
if (response.status === 404 &&
|
|
165
|
+
if (response.status === 404 && storeStatus === "MISS") return null;
|
|
164
166
|
throw new Error(`Workers Response Store returned ${response.status}`);
|
|
165
167
|
} catch (error) {
|
|
166
168
|
console.error(JSON.stringify({
|
|
@@ -170,7 +172,7 @@ async function readStoredResponse(key) {
|
|
|
170
172
|
return null;
|
|
171
173
|
}
|
|
172
174
|
}
|
|
173
|
-
function publicResponse(response, cacheStatus, responseStageProps) {
|
|
175
|
+
function publicResponse(response, cacheStatus, responseStageProps, pendingAdmission = false) {
|
|
174
176
|
const headers = new Headers(response.headers);
|
|
175
177
|
const publicCacheStatus = cacheStatus === "HIT" && headers.get("CF-Cache-Status") === "UPDATING" ? "UPDATING" : cacheStatus;
|
|
176
178
|
const ageBasis = /^(\d+):(\d+)$/.exec(headers.get(AGE_BASIS_HEADER) ?? "");
|
|
@@ -193,7 +195,7 @@ function publicResponse(response, cacheStatus, responseStageProps) {
|
|
|
193
195
|
headers.set("X-Vinext-Cache", publicCacheStatus);
|
|
194
196
|
}
|
|
195
197
|
const cacheControl = headers.get("Cache-Control");
|
|
196
|
-
if (!cacheControl || !isNonCacheableCacheControl(cacheControl)) headers.set("Cache-Control", "private, max-age=0, must-revalidate");
|
|
198
|
+
if (pendingAdmission && (!cacheControl || !isNonCacheableCacheControl(cacheControl, "browser"))) headers.set("Cache-Control", "private, max-age=0, must-revalidate");
|
|
197
199
|
return traceCachedResponseStart(new Response(response.body, {
|
|
198
200
|
headers,
|
|
199
201
|
status: response.status,
|
|
@@ -260,7 +262,7 @@ const handler = { async fetch(request, env, ctx) {
|
|
|
260
262
|
error: error instanceof Error ? error.message : String(error)
|
|
261
263
|
}));
|
|
262
264
|
}));
|
|
263
|
-
return publicResponse(rendered, "MISS", props);
|
|
265
|
+
return publicResponse(rendered, "MISS", props, true);
|
|
264
266
|
}
|
|
265
267
|
if (!isCacheable(rendered)) {
|
|
266
268
|
capture?.rscData?.catch(() => {});
|
|
@@ -315,7 +317,21 @@ const handler = { async fetch(request, env, ctx) {
|
|
|
315
317
|
return publicResponse(new Response(foreground, rendered), "MISS", props);
|
|
316
318
|
};
|
|
317
319
|
const { handleRequestStage } = await loadVinextRequestStage();
|
|
318
|
-
|
|
320
|
+
const sharedResponses = /* @__PURE__ */ new Map();
|
|
321
|
+
const dispatch = async (stageRequest, props, options) => {
|
|
322
|
+
const response = await dispatchResponseStage(stageRequest, props, options);
|
|
323
|
+
if (options.cache !== "shared") return response;
|
|
324
|
+
const headers = new Headers(response.headers);
|
|
325
|
+
const token = crypto.randomUUID();
|
|
326
|
+
headers.set(SHARED_RESPONSE_STAGE_HEADER, token);
|
|
327
|
+
sharedResponses.set(token, { headers: new Headers(headers) });
|
|
328
|
+
return new Response(response.body, {
|
|
329
|
+
headers,
|
|
330
|
+
status: response.status,
|
|
331
|
+
statusText: response.statusText
|
|
332
|
+
});
|
|
333
|
+
};
|
|
334
|
+
return finalizeGatewayResponse(await handleRequestStage(request, env, context, dispatch), sharedResponses, "X-Vinext-Cache");
|
|
319
335
|
} };
|
|
320
336
|
//#endregion
|
|
321
337
|
export { createVinextResponseStoreHandler, createVinextResponseStoreOptions };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
//#region src/cache/static-assets-adapter.build.d.ts
|
|
2
|
-
/** Package
|
|
2
|
+
/** Package prerender output from both routers as immutable Workers Static Assets. */
|
|
3
3
|
export declare function finalizeStaticAssetsPrerenderOutput(root: string, options?: {
|
|
4
4
|
clientOutDir?: string;
|
|
5
5
|
}): number;
|
|
@@ -3,13 +3,23 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
import { readPrerenderManifest } from "vinext/internal/server/prerender-manifest";
|
|
6
|
-
import { appIsrCacheKey } from "vinext/internal/server/isr-cache";
|
|
6
|
+
import { appIsrCacheKey, pagesIsrCacheKey } from "vinext/internal/server/isr-cache";
|
|
7
|
+
import { extractVinextNextDataJson } from "vinext/internal/client/vinext-next-data";
|
|
7
8
|
import { normalizePregeneratedPathname } from "vinext/internal/server/pregenerated-concrete-paths";
|
|
8
9
|
import { getAppRouteOutputPath, getOutputPath, getRscOutputPath } from "vinext/internal/utils/prerender-output-paths";
|
|
9
10
|
//#region src/cache/static-assets-adapter.build.ts
|
|
10
11
|
function cacheAssetId(key) {
|
|
11
12
|
return createHash("sha256").update(key).digest("hex");
|
|
12
13
|
}
|
|
14
|
+
function pagesCacheKey(pathname, locale, buildId) {
|
|
15
|
+
let pagesPathname = new URL(`https://vinext.invalid${pathname}`).pathname;
|
|
16
|
+
if (locale) {
|
|
17
|
+
const prefix = `/${locale}`;
|
|
18
|
+
if (pagesPathname === prefix) pagesPathname = "/";
|
|
19
|
+
else if (pagesPathname.startsWith(`${prefix}/`)) pagesPathname = pagesPathname.slice(prefix.length);
|
|
20
|
+
}
|
|
21
|
+
return pagesIsrCacheKey(pagesPathname, buildId, locale ? `locale:${locale}` : void 0);
|
|
22
|
+
}
|
|
13
23
|
function cacheControl(route) {
|
|
14
24
|
if (route.revalidate === void 0) return void 0;
|
|
15
25
|
return {
|
|
@@ -18,10 +28,10 @@ function cacheControl(route) {
|
|
|
18
28
|
...route.stale === void 0 ? {} : { stale: route.stale }
|
|
19
29
|
};
|
|
20
30
|
}
|
|
21
|
-
function writeCacheAsset(outputDir, index, key, kind, sourcePath, route) {
|
|
31
|
+
function writeCacheAsset(outputDir, index, key, kind, sourcePath, route, contents) {
|
|
22
32
|
if (!fs.existsSync(sourcePath)) return false;
|
|
23
33
|
const id = cacheAssetId(key);
|
|
24
|
-
const extension = kind
|
|
34
|
+
const extension = kind;
|
|
25
35
|
const policy = cacheControl(route);
|
|
26
36
|
const metadata = {
|
|
27
37
|
kind,
|
|
@@ -31,11 +41,13 @@ function writeCacheAsset(outputDir, index, key, kind, sourcePath, route) {
|
|
|
31
41
|
...kind !== "rsc" && route.responseStatus !== void 0 ? { status: route.responseStatus } : {}
|
|
32
42
|
};
|
|
33
43
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
34
|
-
|
|
44
|
+
const outputPath = path.join(outputDir, `${id}.${extension}`);
|
|
45
|
+
if (contents === void 0) fs.copyFileSync(sourcePath, outputPath);
|
|
46
|
+
else fs.writeFileSync(outputPath, contents);
|
|
35
47
|
index[id] = metadata;
|
|
36
48
|
return true;
|
|
37
49
|
}
|
|
38
|
-
/** Package
|
|
50
|
+
/** Package prerender output from both routers as immutable Workers Static Assets. */
|
|
39
51
|
function finalizeStaticAssetsPrerenderOutput(root, options = {}) {
|
|
40
52
|
const serverDir = path.join(root, "dist", "server");
|
|
41
53
|
const prerenderDir = path.join(serverDir, "prerendered-routes");
|
|
@@ -56,6 +68,31 @@ function finalizeStaticAssetsPrerenderOutput(root, options = {}) {
|
|
|
56
68
|
if (route.router === "app") {
|
|
57
69
|
count += Number(writeCacheAsset(outputDir, index, appIsrCacheKey(cachePathname, "html", manifest.buildId), "html", path.join(prerenderDir, getOutputPath(pathname, manifest.trailingSlash ?? false)), route));
|
|
58
70
|
count += Number(writeCacheAsset(outputDir, index, appIsrCacheKey(cachePathname, "rsc", manifest.buildId), "rsc", path.join(prerenderDir, getRscOutputPath(pathname)), route));
|
|
71
|
+
} else if (route.router === "pages") {
|
|
72
|
+
const sourcePath = path.join(prerenderDir, getOutputPath(pathname, manifest.trailingSlash ?? false));
|
|
73
|
+
if (!fs.existsSync(sourcePath)) continue;
|
|
74
|
+
const html = fs.readFileSync(sourcePath, "utf8");
|
|
75
|
+
const key = pagesCacheKey(pathname, route.locale, manifest.buildId);
|
|
76
|
+
if (route.notFound) {
|
|
77
|
+
count += Number(writeCacheAsset(outputDir, index, key, "not-found", sourcePath, route, "null"));
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (route.redirectProps) {
|
|
81
|
+
count += Number(writeCacheAsset(outputDir, index, key, "redirect", sourcePath, route, JSON.stringify(route.redirectProps)));
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const json = extractVinextNextDataJson(html);
|
|
85
|
+
if (json === null) continue;
|
|
86
|
+
const nextData = JSON.parse(json);
|
|
87
|
+
if (nextData.gsp !== true && nextData.autoExport !== true) continue;
|
|
88
|
+
if (!nextData.props || typeof nextData.props !== "object") throw new Error(`[vinext] Missing prerendered Pages props for ${pathname}`);
|
|
89
|
+
count += Number(writeCacheAsset(outputDir, index, pagesCacheKey(pathname, nextData.locale ?? route.locale, manifest.buildId), "pages", sourcePath, {
|
|
90
|
+
...route,
|
|
91
|
+
responseStatus: route.route === "/404" ? 404 : route.route === "/500" ? 500 : route.responseStatus ?? 200
|
|
92
|
+
}, JSON.stringify({
|
|
93
|
+
html,
|
|
94
|
+
pageData: nextData.props
|
|
95
|
+
})));
|
|
59
96
|
} else if (route.router === "metadata") count += Number(writeCacheAsset(outputDir, index, appIsrCacheKey(cachePathname, "route", manifest.buildId), "route", path.join(prerenderDir, getAppRouteOutputPath(pathname)), route));
|
|
60
97
|
}
|
|
61
98
|
if (count > 0) fs.writeFileSync(path.join(outputDir, "index.json"), JSON.stringify(index));
|
|
@@ -6,7 +6,7 @@ export type StaticAssetsAdapterOptions = {
|
|
|
6
6
|
/**
|
|
7
7
|
* Read-only page cache backed by Workers Static Assets.
|
|
8
8
|
*
|
|
9
|
-
* Locally prerendered App
|
|
9
|
+
* Locally prerendered Pages HTML/data and App HTML, RSC, and metadata are copied
|
|
10
10
|
* into `dist/client` after the build. Runtime cache writes and invalidations are
|
|
11
11
|
* intentionally no-ops; a new deployment replaces the cache contents.
|
|
12
12
|
*/
|
|
@@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url";
|
|
|
4
4
|
/**
|
|
5
5
|
* Read-only page cache backed by Workers Static Assets.
|
|
6
6
|
*
|
|
7
|
-
* Locally prerendered App
|
|
7
|
+
* Locally prerendered Pages HTML/data and App HTML, RSC, and metadata are copied
|
|
8
8
|
* into `dist/client` after the build. Runtime cache writes and invalidations are
|
|
9
9
|
* intentionally no-ops; a new deployment replaces the cache contents.
|
|
10
10
|
*/
|
|
@@ -6,7 +6,8 @@ type AssetFetcher = {
|
|
|
6
6
|
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
|
7
7
|
};
|
|
8
8
|
export declare class StaticAssetsCacheAdapter implements CdnCacheAdapter {
|
|
9
|
-
|
|
9
|
+
readonly assets: AssetFetcher;
|
|
10
|
+
readonly hasPrerenderedPages = true;
|
|
10
11
|
readonly ownsBackgroundRevalidation = false;
|
|
11
12
|
private indexPromise;
|
|
12
13
|
constructor(assets: AssetFetcher);
|
|
@@ -6,10 +6,11 @@ async function cacheAssetId(key) {
|
|
|
6
6
|
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
7
7
|
}
|
|
8
8
|
function isMetadata(value) {
|
|
9
|
-
return value !== null && typeof value === "object" && "kind" in value && (value.kind === "html" || value.kind === "rsc" || value.kind === "route") && "lastModified" in value && typeof value.lastModified === "number";
|
|
9
|
+
return value !== null && typeof value === "object" && "kind" in value && (value.kind === "html" || value.kind === "rsc" || value.kind === "route" || value.kind === "pages" || value.kind === "redirect" || value.kind === "not-found") && "lastModified" in value && typeof value.lastModified === "number";
|
|
10
10
|
}
|
|
11
11
|
var StaticAssetsCacheAdapter = class {
|
|
12
12
|
assets;
|
|
13
|
+
hasPrerenderedPages = true;
|
|
13
14
|
ownsBackgroundRevalidation = false;
|
|
14
15
|
indexPromise;
|
|
15
16
|
constructor(assets) {
|
|
@@ -22,11 +23,27 @@ var StaticAssetsCacheAdapter = class {
|
|
|
22
23
|
const id = await cacheAssetId(key);
|
|
23
24
|
const metadata = (await this.loadIndex())?.[id];
|
|
24
25
|
if (!isMetadata(metadata)) return null;
|
|
25
|
-
const extension = metadata.kind
|
|
26
|
+
const extension = metadata.kind;
|
|
26
27
|
const bodyResponse = await this.assets.fetch(`https://vinext.invalid${STATIC_ASSET_CACHE_PATH}/${id}.${extension}`);
|
|
27
28
|
if (!bodyResponse.ok) return null;
|
|
28
29
|
let value;
|
|
29
|
-
if (metadata.kind === "
|
|
30
|
+
if (metadata.kind === "not-found") {
|
|
31
|
+
await bodyResponse.body?.cancel();
|
|
32
|
+
value = null;
|
|
33
|
+
} else if (metadata.kind === "redirect") value = {
|
|
34
|
+
kind: "REDIRECT",
|
|
35
|
+
props: await bodyResponse.json()
|
|
36
|
+
};
|
|
37
|
+
else if (metadata.kind === "pages") {
|
|
38
|
+
const { html, pageData } = await bodyResponse.json();
|
|
39
|
+
value = {
|
|
40
|
+
kind: "PAGES",
|
|
41
|
+
html,
|
|
42
|
+
pageData,
|
|
43
|
+
headers: metadata.headers,
|
|
44
|
+
status: metadata.status
|
|
45
|
+
};
|
|
46
|
+
} else if (metadata.kind === "html") value = {
|
|
30
47
|
kind: "APP_PAGE",
|
|
31
48
|
html: await bodyResponse.text(),
|
|
32
49
|
rscData: void 0,
|
|
@@ -2,7 +2,7 @@ import { CacheControlMetadata } from "vinext/shims/cache-handler";
|
|
|
2
2
|
//#region src/cache/static-assets-adapter.shared.d.ts
|
|
3
3
|
export declare const STATIC_ASSET_CACHE_PATH = "/_vinext/static-cache";
|
|
4
4
|
export type StaticAssetCacheMetadata = {
|
|
5
|
-
kind: "html" | "rsc" | "route";
|
|
5
|
+
kind: "html" | "rsc" | "route" | "pages" | "redirect" | "not-found";
|
|
6
6
|
lastModified: number;
|
|
7
7
|
cacheControl?: CacheControlMetadata;
|
|
8
8
|
headers?: Record<string, string | string[]>;
|
|
@@ -12,15 +12,15 @@ export type CdnAdapterOptions = {
|
|
|
12
12
|
* Unlike the data adapter (which stores cache entries in a durable store and
|
|
13
13
|
* serves HIT/STALE itself), this adapter delegates serving to Workers Cache on
|
|
14
14
|
* a named Worker entrypoint. The default entrypoint always runs middleware and
|
|
15
|
-
* request-time routing
|
|
16
|
-
*
|
|
15
|
+
* request-time routing, then renders bypass requests inline or dispatches shared
|
|
16
|
+
* requests to the cached response-stage entrypoint.
|
|
17
17
|
*
|
|
18
18
|
* The deployment configuration enables Workers Cache only for that response-stage
|
|
19
19
|
* export, so cache hits do not start the application stage. Legacy Cloudflare
|
|
20
20
|
* builds receive this policy in their generated Wrangler config. Cloudflare Vite
|
|
21
21
|
* plugin v2 builds declare the same entrypoint policy in `cloudflare.config.ts`.
|
|
22
|
-
*
|
|
23
|
-
*
|
|
22
|
+
* Deployment readiness probes still use the uncached response entrypoint to
|
|
23
|
+
* verify that named response entrypoints are available.
|
|
24
24
|
*
|
|
25
25
|
* The adapter adds a transport-only URL digest so distinct response-stage
|
|
26
26
|
* identities cannot collide. Workers Cache owns this key independently of
|
|
@@ -10,15 +10,15 @@ const CLOUDFLARE_WORKER_ENTRY_ID = "virtual:cloudflare/worker-entry";
|
|
|
10
10
|
* Unlike the data adapter (which stores cache entries in a durable store and
|
|
11
11
|
* serves HIT/STALE itself), this adapter delegates serving to Workers Cache on
|
|
12
12
|
* a named Worker entrypoint. The default entrypoint always runs middleware and
|
|
13
|
-
* request-time routing
|
|
14
|
-
*
|
|
13
|
+
* request-time routing, then renders bypass requests inline or dispatches shared
|
|
14
|
+
* requests to the cached response-stage entrypoint.
|
|
15
15
|
*
|
|
16
16
|
* The deployment configuration enables Workers Cache only for that response-stage
|
|
17
17
|
* export, so cache hits do not start the application stage. Legacy Cloudflare
|
|
18
18
|
* builds receive this policy in their generated Wrangler config. Cloudflare Vite
|
|
19
19
|
* plugin v2 builds declare the same entrypoint policy in `cloudflare.config.ts`.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
20
|
+
* Deployment readiness probes still use the uncached response entrypoint to
|
|
21
|
+
* verify that named response entrypoints are available.
|
|
22
22
|
*
|
|
23
23
|
* The adapter adds a transport-only URL digest so distinct response-stage
|
|
24
24
|
* identities cannot collide. Workers Cache owns this key independently of
|
package/dist/cdn-warm.js
CHANGED
|
@@ -373,7 +373,7 @@ const CDN_CACHE_POLICY_HEADERS = [
|
|
|
373
373
|
"CDN-Cache-Control",
|
|
374
374
|
"Cache-Control"
|
|
375
375
|
];
|
|
376
|
-
function splitCacheControlDirectives(value) {
|
|
376
|
+
function splitCacheControlDirectives$1(value) {
|
|
377
377
|
const directives = [];
|
|
378
378
|
let start = 0;
|
|
379
379
|
let quoted = false;
|
|
@@ -392,7 +392,7 @@ function splitCacheControlDirectives(value) {
|
|
|
392
392
|
return directives;
|
|
393
393
|
}
|
|
394
394
|
function hasFieldQualifiedSetCookie(value) {
|
|
395
|
-
return splitCacheControlDirectives(value).some((directive) => {
|
|
395
|
+
return splitCacheControlDirectives$1(value).some((directive) => {
|
|
396
396
|
const separator = directive.indexOf("=");
|
|
397
397
|
if (separator === -1) return false;
|
|
398
398
|
const name = directive.slice(0, separator).trim().toLowerCase();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vinext/cloudflare",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
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.0.
|
|
51
|
+
"@cloudflare/workers-response-store": "1.0.1"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@types/node": "^25.9.2",
|
|
@@ -57,12 +57,12 @@
|
|
|
57
57
|
"vite": "npm:@voidzero-dev/vite-plus-core@0.3.2",
|
|
58
58
|
"vite-plus": "0.3.2",
|
|
59
59
|
"vitest": "4.1.11",
|
|
60
|
-
"vinext": "1.0.
|
|
60
|
+
"vinext": "1.0.1"
|
|
61
61
|
},
|
|
62
62
|
"peerDependencies": {
|
|
63
63
|
"@cloudflare/vite-plugin": "*",
|
|
64
64
|
"cf": "*",
|
|
65
|
-
"vinext": "^1.0.
|
|
65
|
+
"vinext": "^1.0.1"
|
|
66
66
|
},
|
|
67
67
|
"peerDependenciesMeta": {
|
|
68
68
|
"@cloudflare/vite-plugin": {
|