@vinext/cloudflare 1.0.1 → 1.1.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/cdn-adapter.runtime.d.ts +6 -2
- package/dist/cache/cdn-adapter.runtime.js +14 -4
- package/dist/cache/cdn-adapter.worker.d.ts +1 -0
- package/dist/cache/cdn-adapter.worker.js +60 -14
- package/dist/cache/kv-data-adapter.d.ts +5 -1
- package/dist/cache/kv-data-adapter.runtime.js +1 -1
- package/dist/cache/response-store-adapter.worker.js +22 -12
- package/dist/cache/response-store-data.runtime.d.ts +1 -0
- package/dist/cache/response-store-data.runtime.js +29 -9
- package/dist/cache/static-assets-adapter.build.js +3 -2
- package/dist/cache/static-assets-adapter.runtime.js +2 -2
- package/dist/cache/workers-cache-invalidation.d.ts +30 -0
- package/dist/cache/workers-cache-invalidation.js +27 -0
- package/dist/cdn-warm.js +1 -1
- package/package.json +7 -7
|
@@ -32,8 +32,12 @@ export declare class CloudflareCdnCacheAdapter implements CdnCacheAdapter {
|
|
|
32
32
|
set(_key: string, _data: IncrementalCacheValue | null, _ctx?: Record<string, unknown>): Promise<void>;
|
|
33
33
|
buildResponseIdentityHeaders(): CdnResponseHeaders;
|
|
34
34
|
buildResponseHeaders(input: CdnCacheableHeaderInput): CdnResponseHeaders;
|
|
35
|
-
/**
|
|
36
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Invalidate edge-cached responses by tag via the request context's cache.
|
|
37
|
+
* Stale tags map to `cache.invalidate`, so the edge serves the stale response
|
|
38
|
+
* while it refetches; expired tags map to `cache.purge`.
|
|
39
|
+
*/
|
|
40
|
+
revalidateTag(tags: string | string[], durations?: {
|
|
37
41
|
expire?: number;
|
|
38
42
|
}): Promise<void>;
|
|
39
43
|
}
|
|
@@ -1,5 +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 { invalidateOrPurge, isStaleTagInvalidation } from "./workers-cache-invalidation.js";
|
|
3
4
|
import { isNonCacheableCacheControl, splitCacheControlDirectives } from "vinext/shims/cdn-cache";
|
|
4
5
|
import { getRequestExecutionContext } from "vinext/shims/request-context";
|
|
5
6
|
import { fnv1a64 } from "vinext/internal/utils/hash";
|
|
@@ -28,6 +29,9 @@ import { fnv1a64 } from "vinext/internal/utils/hash";
|
|
|
28
29
|
* `Cloudflare-CDN-Cache-Control` is already CDN-scoped so `max-age` is the correct knob
|
|
29
30
|
* for the edge to honor max-age + stale-while-revalidate.
|
|
30
31
|
* - `revalidateTag` purges the edge via the request context's `cache.purge({ tags })`.
|
|
32
|
+
* A stale-while-revalidate invalidation (`revalidateTag` with durations
|
|
33
|
+
* other than `expire: 0`) uses `cache.invalidate({ tags })` instead, so the
|
|
34
|
+
* edge keeps serving the stale response while it refetches in the background.
|
|
31
35
|
*
|
|
32
36
|
* Tags use fixed-size lowercase digests before emission and purge because
|
|
33
37
|
* Workers Cache tags are case-insensitive printable ASCII, while Next.js tags
|
|
@@ -208,16 +212,22 @@ var CloudflareCdnCacheAdapter = class {
|
|
|
208
212
|
"Cache-Tag": cacheTag
|
|
209
213
|
};
|
|
210
214
|
}
|
|
211
|
-
/**
|
|
212
|
-
|
|
215
|
+
/**
|
|
216
|
+
* Invalidate edge-cached responses by tag via the request context's cache.
|
|
217
|
+
* Stale tags map to `cache.invalidate`, so the edge serves the stale response
|
|
218
|
+
* while it refetches; expired tags map to `cache.purge`.
|
|
219
|
+
*/
|
|
220
|
+
async revalidateTag(tags, durations) {
|
|
213
221
|
const cache = getWorkersCache();
|
|
214
222
|
if (!cache) return;
|
|
215
223
|
const tagList = (Array.isArray(tags) ? tags : [tags]).filter((t) => typeof t === "string");
|
|
216
224
|
if (tagList.length === 0) return;
|
|
217
|
-
const
|
|
225
|
+
const options = { tags: tagList.map(encodeCloudflareCacheTag) };
|
|
226
|
+
const operation = isStaleTagInvalidation(durations) ? "invalidate" : "purge";
|
|
227
|
+
const result = await (operation === "invalidate" ? invalidateOrPurge(cache, options) : cache.purge(options));
|
|
218
228
|
if (result?.success === false) {
|
|
219
229
|
const errors = result.errors.map(({ code, message }) => `${code}: ${message}`).join(", ");
|
|
220
|
-
throw new Error(`[vinext] Workers Cache
|
|
230
|
+
throw new Error(`[vinext] Workers Cache ${operation} failed${errors ? `: ${errors}` : ""}`);
|
|
221
231
|
}
|
|
222
232
|
}
|
|
223
233
|
};
|
|
@@ -18,6 +18,7 @@ type CachePurgeOptions = {
|
|
|
18
18
|
export declare class VinextCachedResponse extends WorkerEntrypoint<unknown, unknown> {
|
|
19
19
|
fetch(request: Request): Promise<Response>;
|
|
20
20
|
purge(options: CachePurgeOptions): Promise<unknown>;
|
|
21
|
+
invalidate(options: CachePurgeOptions): Promise<unknown>;
|
|
21
22
|
}
|
|
22
23
|
/** Uncached response entrypoint retained for deployment readiness probes. */
|
|
23
24
|
export declare class VinextUncachedResponse extends WorkerEntrypoint<unknown, unknown> {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { VINEXT_CDN_BUILD_ID_HEADER, getVinextCdnBuildIdentity } from "./cdn-build-id.js";
|
|
2
2
|
import { SHARED_RESPONSE_STAGE_HEADER, finalizeGatewayResponse } from "./browser-cache-policy.js";
|
|
3
|
+
import { hasCacheMethod, invalidateOrPurge } from "./workers-cache-invalidation.js";
|
|
3
4
|
import { VINEXT_PRERENDER_READINESS_PATH, VINEXT_RSC_VARY_HEADER } from "vinext/internal/server/headers";
|
|
5
|
+
import { isNonCacheableCacheControl } from "vinext/shims/cdn-cache";
|
|
4
6
|
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
5
7
|
import "vinext/internal/server/cloudflare-workers-tracing";
|
|
6
8
|
import { loadVinextRequestStage } from "vinext/server/request-stage";
|
|
@@ -13,6 +15,7 @@ const AUTHORIZATION_TRANSPORT_HEADER = "x-vinext-internal-authorization";
|
|
|
13
15
|
const REQUEST_CACHE_CONTROL_TRANSPORT_HEADER = "x-vinext-internal-request-cache-control";
|
|
14
16
|
const REQUEST_CF_TRANSPORT_HEADER = "x-vinext-internal-request-cf";
|
|
15
17
|
const REQUEST_PRAGMA_TRANSPORT_HEADER = "x-vinext-internal-request-pragma";
|
|
18
|
+
const WARMUP_USER_AGENT = "vinext-cloudflare-cdn-warm";
|
|
16
19
|
const RESPONSE_STAGE_WIRE_CACHE = {
|
|
17
20
|
bypass: "vinext-cloudflare-v1:bypass",
|
|
18
21
|
shared: "vinext-cloudflare-v1:shared"
|
|
@@ -91,9 +94,6 @@ function withWorkerHostRuntime(context, env) {
|
|
|
91
94
|
function hasFetch(value) {
|
|
92
95
|
return value !== null && (typeof value === "object" || typeof value === "function") && "fetch" in value && typeof value.fetch === "function";
|
|
93
96
|
}
|
|
94
|
-
function hasPurge(value) {
|
|
95
|
-
return value !== null && (typeof value === "object" || typeof value === "function") && "purge" in value && typeof value.purge === "function";
|
|
96
|
-
}
|
|
97
97
|
function getResponseStageBinding(context, exportName, serializedInvocation) {
|
|
98
98
|
const binding = context.exports?.[exportName];
|
|
99
99
|
if (typeof binding !== "function") return null;
|
|
@@ -179,6 +179,31 @@ function restoreResponseStageRequest(request, requestUrl, requestMethod) {
|
|
|
179
179
|
request: restored
|
|
180
180
|
};
|
|
181
181
|
}
|
|
182
|
+
function isAppPageDocumentInvocation(invocation) {
|
|
183
|
+
const props = invocation.props;
|
|
184
|
+
return props !== null && typeof props === "object" && Reflect.get(props, "kind") === "app-page" && Reflect.get(props, "isRscRequest") === false;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* No client waits on a deployment warm-up. Workers Cache revalidates an entry
|
|
188
|
+
* that has a validator with a conditional request, as it does the Response
|
|
189
|
+
* Store's, and serves the stale entry meanwhile or fills an expired one.
|
|
190
|
+
*/
|
|
191
|
+
function rendersWholeDocument(request) {
|
|
192
|
+
return request.headers.get("user-agent") === WARMUP_USER_AGENT || request.headers.has("If-None-Match") || request.headers.has("If-Modified-Since");
|
|
193
|
+
}
|
|
194
|
+
/** Give a cacheable document a validator, so Workers Cache revalidates it conditionally. */
|
|
195
|
+
function withRevalidationValidator(response) {
|
|
196
|
+
if (response.headers.has("ETag") || response.headers.has("Last-Modified")) return response;
|
|
197
|
+
const policy = response.headers.get("Cloudflare-CDN-Cache-Control") ?? response.headers.get("CDN-Cache-Control") ?? response.headers.get("Cache-Control");
|
|
198
|
+
if (policy === null || isNonCacheableCacheControl(policy)) return response;
|
|
199
|
+
const headers = new Headers(response.headers);
|
|
200
|
+
headers.set("Last-Modified", (/* @__PURE__ */ new Date()).toUTCString());
|
|
201
|
+
return new Response(response.body, {
|
|
202
|
+
headers,
|
|
203
|
+
status: response.status,
|
|
204
|
+
statusText: response.statusText
|
|
205
|
+
});
|
|
206
|
+
}
|
|
182
207
|
function preventResponseCaching(response) {
|
|
183
208
|
const headers = new Headers(response.headers);
|
|
184
209
|
headers.set("Cache-Control", "no-store");
|
|
@@ -211,17 +236,28 @@ function hasTaggedCustomVary(response) {
|
|
|
211
236
|
if (!response.headers.get("Cache-Tag")) return false;
|
|
212
237
|
return (response.headers.get("Vary") ?? "").split(",").map((name) => name.trim().toLowerCase()).filter(Boolean).some((name) => !FRAMEWORK_RESPONSE_VARY_FIELDS.has(name));
|
|
213
238
|
}
|
|
214
|
-
|
|
239
|
+
/**
|
|
240
|
+
* Workers Cache purges and invalidations only reach the calling entrypoint's
|
|
241
|
+
* cache, so route both through the cache-bearing response entrypoint.
|
|
242
|
+
*/
|
|
243
|
+
function withResponseStageCache(context) {
|
|
215
244
|
const factory = context.exports?.[CACHED_RESPONSE_STAGE_EXPORT];
|
|
216
245
|
if (typeof factory !== "function") return context;
|
|
217
246
|
const fallback = context.cache;
|
|
218
247
|
return {
|
|
219
248
|
...context,
|
|
220
|
-
cache: {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
249
|
+
cache: {
|
|
250
|
+
purge(options) {
|
|
251
|
+
const target = factory({ props: {} });
|
|
252
|
+
if (hasCacheMethod(target, "purge")) return target.purge(options);
|
|
253
|
+
return hasCacheMethod(fallback, "purge") ? fallback.purge(options) : void 0;
|
|
254
|
+
},
|
|
255
|
+
invalidate(options) {
|
|
256
|
+
const target = factory({ props: {} });
|
|
257
|
+
const targetCanUpdate = hasCacheMethod(target, "invalidate") || hasCacheMethod(target, "purge");
|
|
258
|
+
return invalidateOrPurge(targetCanUpdate ? target : fallback, options);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
225
261
|
};
|
|
226
262
|
}
|
|
227
263
|
function getResponseStageInvocation(value, expectedCache) {
|
|
@@ -282,19 +318,29 @@ var VinextCachedResponse = class extends WorkerEntrypoint {
|
|
|
282
318
|
}));
|
|
283
319
|
if (invocation.expectedResponseStageBuildIdentity !== void 0 && invocation.expectedResponseStageBuildIdentity !== getVinextCdnBuildIdentity()) return stampResponseStageBuildIdentity(responseStageUnavailable());
|
|
284
320
|
const restored = restoreResponseStageRequest(request, invocation.requestUrl, invocation.requestMethod);
|
|
285
|
-
const
|
|
286
|
-
|
|
321
|
+
const isAppPageDocument = isAppPageDocumentInvocation(invocation);
|
|
322
|
+
const response = await invokeResponseStage(restored.request, this.env, context, isAppPageDocument && rendersWholeDocument(request) ? {
|
|
323
|
+
...invocation,
|
|
324
|
+
options: {
|
|
325
|
+
...invocation.options,
|
|
326
|
+
renderWholeDocument: true
|
|
327
|
+
}
|
|
328
|
+
} : invocation);
|
|
329
|
+
return stampResponseStageBuildIdentity(restored.didAccessRequestCf() || hasTaggedCustomVary(response) ? preventResponseCaching(response) : isAppPageDocument ? withRevalidationValidator(response) : response);
|
|
287
330
|
}
|
|
288
331
|
async purge(options) {
|
|
289
332
|
const cache = Reflect.get(this.ctx, "cache");
|
|
290
|
-
if (!
|
|
333
|
+
if (!hasCacheMethod(cache, "purge")) return void 0;
|
|
291
334
|
return cache.purge(options);
|
|
292
335
|
}
|
|
336
|
+
async invalidate(options) {
|
|
337
|
+
return invalidateOrPurge(Reflect.get(this.ctx, "cache"), options);
|
|
338
|
+
}
|
|
293
339
|
};
|
|
294
340
|
/** Uncached response entrypoint retained for deployment readiness probes. */
|
|
295
341
|
var VinextUncachedResponse = class extends WorkerEntrypoint {
|
|
296
342
|
async fetch(request) {
|
|
297
|
-
const context =
|
|
343
|
+
const context = withResponseStageCache(withWorkerHostRuntime(this.ctx, this.env));
|
|
298
344
|
const invocation = getResponseStageInvocation(context.props, "bypass");
|
|
299
345
|
if (!invocation) return stampResponseStageBuildIdentity(new Response("Invalid vinext response-stage invocation", {
|
|
300
346
|
status: 400,
|
|
@@ -308,7 +354,7 @@ var VinextUncachedResponse = class extends WorkerEntrypoint {
|
|
|
308
354
|
var cdn_adapter_worker_default = { async fetch(request, env, context) {
|
|
309
355
|
request = stripUntrustedTransportHeaders(request);
|
|
310
356
|
const sharedResponses = /* @__PURE__ */ new Map();
|
|
311
|
-
const stageContext =
|
|
357
|
+
const stageContext = withResponseStageCache(withWorkerHostRuntime(context, env));
|
|
312
358
|
const dispatchResponseStage = async (stageRequest, props, options) => {
|
|
313
359
|
const expectedResponseStageBuildIdentity = getVinextCdnBuildIdentity();
|
|
314
360
|
const invocation = {
|
|
@@ -6,7 +6,11 @@ export type KvDataAdapterOptions = {
|
|
|
6
6
|
binding?: string;
|
|
7
7
|
/** Namespace prefix for cache keys (isolates multiple apps in one namespace). */
|
|
8
8
|
appPrefix?: string;
|
|
9
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* Default KV `expirationTtl` in seconds. An entry whose `expire` is longer,
|
|
11
|
+
* such as one cached with `cacheLife("max")`, keeps it for its `expire`.
|
|
12
|
+
* @default 2592000 (30 days)
|
|
13
|
+
*/
|
|
10
14
|
ttlSeconds?: number;
|
|
11
15
|
/** TTL in milliseconds for the in-memory tag-invalidation cache. @default 5000 */
|
|
12
16
|
tagCacheTtlMs?: number;
|
|
@@ -293,7 +293,7 @@ var KVCacheHandler = class {
|
|
|
293
293
|
expireAt,
|
|
294
294
|
cacheControl
|
|
295
295
|
};
|
|
296
|
-
const expirationTtl = this.ttlSeconds;
|
|
296
|
+
const expirationTtl = typeof effectiveExpire === "number" && Number.isFinite(effectiveExpire) && effectiveExpire > this.ttlSeconds ? resolveKvExpirationTtlSeconds(Math.ceil(effectiveExpire)) : this.ttlSeconds;
|
|
297
297
|
const metadata = JSON.stringify({ tags }).length <= 1024 ? { tags } : void 0;
|
|
298
298
|
return this._put(this._entryKey(key), JSON.stringify(entry), {
|
|
299
299
|
expirationTtl,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { SHARED_RESPONSE_STAGE_HEADER, finalizeGatewayResponse } from "./browser-cache-policy.js";
|
|
2
2
|
import { captureResponseStoreDataRegeneration, runWithResponseStoreInvocation, setResponseStore } from "./response-store-data.runtime.js";
|
|
3
|
-
import { VINEXT_PARAMS_HEADER, VINEXT_RENDERED_PATH_AND_SEARCH_HEADER } from "vinext/internal/server/headers";
|
|
3
|
+
import { VINEXT_PARAMS_HEADER, VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, VINEXT_SPECIAL_ERROR_STATUS_HEADER } from "vinext/internal/server/headers";
|
|
4
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";
|
|
5
5
|
import { isNonCacheableCacheControl } from "vinext/shims/cdn-cache";
|
|
6
6
|
import { loadVinextRequestStage } from "vinext/server/request-stage";
|
|
@@ -71,9 +71,9 @@ function restoreRequest(invocation) {
|
|
|
71
71
|
}
|
|
72
72
|
async function invokeRequestStage(request, env, ctx) {
|
|
73
73
|
const { handleRequestStage } = await loadVinextRequestStage();
|
|
74
|
-
return handleRequestStage(request, env, stageContext(ctx, env), (request, props, options) => invokeResponseStage(request, props, env, ctx, options.cache));
|
|
74
|
+
return handleRequestStage(request, env, stageContext(ctx, env), (request, props, options) => invokeResponseStage(request, props, env, ctx, { cache: options.cache }));
|
|
75
75
|
}
|
|
76
|
-
async function invokeResponseStage(request, props, env, ctx,
|
|
76
|
+
async function invokeResponseStage(request, props, env, ctx, stageOptions, capture, invocation) {
|
|
77
77
|
const context = stageContext(ctx, env);
|
|
78
78
|
const dispatchRequestStage = (request) => invokeRequestStage(request, env, ctx);
|
|
79
79
|
const { handleResponseStage } = await loadVinextResponseStage();
|
|
@@ -81,7 +81,7 @@ async function invokeResponseStage(request, props, env, ctx, cache, capture, inv
|
|
|
81
81
|
replayable: isReplayableInvocation(request, props),
|
|
82
82
|
serialized: serializeInvocation(request, props)
|
|
83
83
|
};
|
|
84
|
-
return runWithResponseStoreInvocation(storedInvocation.serialized, storedInvocation.replayable, () => handleResponseStage(request, env, context, props, dispatchRequestStage,
|
|
84
|
+
return runWithResponseStoreInvocation(storedInvocation.serialized, storedInvocation.replayable, () => handleResponseStage(request, env, context, props, dispatchRequestStage, stageOptions), capture);
|
|
85
85
|
}
|
|
86
86
|
function createVinextResponseStoreOptions(configuration) {
|
|
87
87
|
const locationHint = configuration?.locationHint;
|
|
@@ -94,7 +94,10 @@ function createVinextResponseStoreOptions(configuration) {
|
|
|
94
94
|
async regenerate(input, { env, ctx }) {
|
|
95
95
|
if (input.id === ROUTE_REVALIDATOR_ID) {
|
|
96
96
|
const invocation = parseInvocation(input.args.at(-1));
|
|
97
|
-
const response = await invokeResponseStage(restoreRequest(invocation), invocation.props, env, ctx,
|
|
97
|
+
const response = await invokeResponseStage(restoreRequest(invocation), invocation.props, env, ctx, {
|
|
98
|
+
cache: "shared",
|
|
99
|
+
renderWholeDocument: true
|
|
100
|
+
});
|
|
98
101
|
if (!isCacheable(response)) {
|
|
99
102
|
await response.body?.cancel().catch(() => {});
|
|
100
103
|
throw new Error("Vinext response-stage regeneration was not cacheable");
|
|
@@ -113,7 +116,7 @@ function createVinextResponseStoreOptions(configuration) {
|
|
|
113
116
|
if (input.id === "vinext:data" && typeof input.args[0] === "string") {
|
|
114
117
|
const invocation = parseInvocation(input.args.at(-1));
|
|
115
118
|
return captureResponseStoreDataRegeneration(input.args[0], async () => {
|
|
116
|
-
await (await invokeResponseStage(restoreRequest(invocation), invocation.props, env, ctx, "bypass")).body?.pipeTo(new WritableStream());
|
|
119
|
+
await (await invokeResponseStage(restoreRequest(invocation), invocation.props, env, ctx, { cache: "bypass" })).body?.pipeTo(new WritableStream());
|
|
117
120
|
});
|
|
118
121
|
}
|
|
119
122
|
throw new Error(`Unknown vinext response-store revalidator ${input.id}`);
|
|
@@ -152,15 +155,18 @@ function withoutRequestScopedHeaders(response, responseStageProps) {
|
|
|
152
155
|
statusText: response.statusText
|
|
153
156
|
});
|
|
154
157
|
}
|
|
158
|
+
function isStoredErrorStatus(status) {
|
|
159
|
+
return status === 401 || status === 403 || status === 404;
|
|
160
|
+
}
|
|
155
161
|
function isCacheable(response) {
|
|
156
162
|
const policy = response.headers.get("Cloudflare-CDN-Cache-Control") ?? response.headers.get("CDN-Cache-Control") ?? response.headers.get("Cache-Control");
|
|
157
|
-
return response.status >= 200 && (response.status < 400 || response.status
|
|
163
|
+
return response.status >= 200 && (response.status < 400 || isStoredErrorStatus(response.status)) && policy !== null && !isNonCacheableCacheControl(policy);
|
|
158
164
|
}
|
|
159
165
|
async function readStoredResponse(key) {
|
|
160
166
|
try {
|
|
161
167
|
const response = await responseStore.fetch(key);
|
|
162
168
|
const storeStatus = response.headers.get("X-Workers-Response-Store");
|
|
163
|
-
if (response.status >= 200 && response.status < 400 || response.status
|
|
169
|
+
if (response.status >= 200 && response.status < 400 || isStoredErrorStatus(response.status) && (storeStatus === "BLOB-FRESH" || storeStatus === "BLOB-STALE")) return response;
|
|
164
170
|
response.body?.cancel().catch(() => {});
|
|
165
171
|
if (response.status === 404 && storeStatus === "MISS") return null;
|
|
166
172
|
throw new Error(`Workers Response Store returned ${response.status}`);
|
|
@@ -195,7 +201,7 @@ function publicResponse(response, cacheStatus, responseStageProps, pendingAdmiss
|
|
|
195
201
|
headers.set("X-Vinext-Cache", publicCacheStatus);
|
|
196
202
|
}
|
|
197
203
|
const cacheControl = headers.get("Cache-Control");
|
|
198
|
-
if (pendingAdmission && (!cacheControl || !isNonCacheableCacheControl(cacheControl, "browser"))) headers.set("Cache-Control", "
|
|
204
|
+
if (pendingAdmission && (!cacheControl || !isNonCacheableCacheControl(cacheControl, "browser"))) headers.set("Cache-Control", "no-store, must-revalidate");
|
|
199
205
|
return traceCachedResponseStart(new Response(response.body, {
|
|
200
206
|
headers,
|
|
201
207
|
status: response.status,
|
|
@@ -211,7 +217,7 @@ function createVinextResponseStoreHandler(store) {
|
|
|
211
217
|
const handler = { async fetch(request, env, ctx) {
|
|
212
218
|
const context = stageContext(ctx, env);
|
|
213
219
|
const dispatchResponseStage = async (stageRequest, props, options) => {
|
|
214
|
-
if (options.cache === "bypass" || stageRequest.method !== "GET" && stageRequest.method !== "HEAD") return publicResponse(await invokeResponseStage(stageRequest, props, env, ctx, "bypass"), "BYPASS", props);
|
|
220
|
+
if (options.cache === "bypass" || stageRequest.method !== "GET" && stageRequest.method !== "HEAD") return publicResponse(await invokeResponseStage(stageRequest, props, env, ctx, { cache: "bypass" }), "BYPASS", props);
|
|
215
221
|
const identityRequest = options.cacheIdentity?.request ?? stageRequest;
|
|
216
222
|
const identityProps = options.cacheIdentity?.props ?? props;
|
|
217
223
|
const isWarmup = request.headers.get("user-agent") === WARMUP_USER_AGENT;
|
|
@@ -239,7 +245,10 @@ const handler = { async fetch(request, env, ctx) {
|
|
|
239
245
|
}
|
|
240
246
|
const capture = rscSeed ? { captureRscData: true } : isWarmup ? {} : { streamResponse: true };
|
|
241
247
|
const serializedInvocation = JSON.stringify(invocation);
|
|
242
|
-
const rendered = await invokeResponseStage(stageRequest, props, env, ctx,
|
|
248
|
+
const rendered = await invokeResponseStage(stageRequest, props, env, ctx, isWarmup ? {
|
|
249
|
+
cache: "shared",
|
|
250
|
+
renderWholeDocument: true
|
|
251
|
+
} : { cache: "shared" }, capture, {
|
|
243
252
|
replayable: isReplayableInvocation(stageRequest, props),
|
|
244
253
|
serialized: options.cacheIdentity ? serializeInvocation(stageRequest, props) : serializedInvocation
|
|
245
254
|
});
|
|
@@ -302,10 +311,11 @@ const handler = { async fetch(request, env, ctx) {
|
|
|
302
311
|
rscHeaders.set("Vary", VINEXT_RSC_VARY_HEADER$1);
|
|
303
312
|
applyRscCompatibilityIdHeader(rscHeaders);
|
|
304
313
|
applyRscDeploymentIdHeader(rscHeaders);
|
|
314
|
+
if (isStoredErrorStatus(rendered.status)) rscHeaders.set(VINEXT_SPECIAL_ERROR_STATUS_HEADER, String(rendered.status));
|
|
305
315
|
const serializedRscInvocation = JSON.stringify(rscInvocation);
|
|
306
316
|
await responseStore.put(rscKey, new Response(rscData, {
|
|
307
317
|
headers: rscHeaders,
|
|
308
|
-
status: 200
|
|
318
|
+
status: rendered.status >= 300 && rendered.status < 400 ? 200 : rendered.status
|
|
309
319
|
}), {
|
|
310
320
|
coalesce: true,
|
|
311
321
|
revalidator: {
|
|
@@ -25,6 +25,7 @@ export declare class WorkersResponseStoreCacheHandler implements CacheHandler {
|
|
|
25
25
|
constructor(store?: WorkersResponseStore);
|
|
26
26
|
get(key: string, context?: Record<string, unknown>): Promise<CacheHandlerValue | null>;
|
|
27
27
|
set(key: string, value: IncrementalCacheValue | null, context?: Record<string, unknown>): Promise<void>;
|
|
28
|
+
private write;
|
|
28
29
|
revalidateTag(tags: string | string[], durations?: {
|
|
29
30
|
expire?: number;
|
|
30
31
|
}): Promise<void>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isStaleTagInvalidation } from "./workers-cache-invalidation.js";
|
|
1
2
|
import { encodeCloudflareCacheTag } from "./cdn-adapter.runtime.js";
|
|
2
3
|
import { Buffer } from "node:buffer";
|
|
3
4
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
@@ -136,8 +137,15 @@ function deferResponseStoreAdmission(response, complete) {
|
|
|
136
137
|
}
|
|
137
138
|
/** Re-render an invocation and return the exact rewritten data-cache entry. */
|
|
138
139
|
async function captureResponseStoreDataRegeneration(key, callback) {
|
|
139
|
-
const scope = {
|
|
140
|
-
|
|
140
|
+
const scope = {
|
|
141
|
+
sideWrites: [],
|
|
142
|
+
targetKey: key
|
|
143
|
+
};
|
|
144
|
+
try {
|
|
145
|
+
await regenerationStorage.run(scope, callback);
|
|
146
|
+
} finally {
|
|
147
|
+
while (scope.sideWrites.length) await Promise.allSettled(scope.sideWrites.splice(0));
|
|
148
|
+
}
|
|
141
149
|
if (!scope.captured) throw new Error(`vinext response-store regeneration did not rewrite data key ${key}`);
|
|
142
150
|
return scope.captured;
|
|
143
151
|
}
|
|
@@ -180,7 +188,8 @@ function deserialize(value) {
|
|
|
180
188
|
}
|
|
181
189
|
function cachePolicy(revalidate, expire) {
|
|
182
190
|
if (revalidate === false || revalidate === void 0) return `public, max-age=${CACHE_MAX_AGE_SECONDS}`;
|
|
183
|
-
|
|
191
|
+
const staleSeconds = expire === void 0 ? CACHE_MAX_AGE_SECONDS : Math.max(0, expire - revalidate);
|
|
192
|
+
return `public, max-age=${Math.max(0, revalidate)}, stale-while-revalidate=${staleSeconds}`;
|
|
184
193
|
}
|
|
185
194
|
var WorkersResponseStoreCacheHandler = class {
|
|
186
195
|
store;
|
|
@@ -231,9 +240,9 @@ var WorkersResponseStoreCacheHandler = class {
|
|
|
231
240
|
const requestedStale = typeof requestedRevalidate === "number" && requestedRevalidate > 0 && age > requestedRevalidate * 1e3;
|
|
232
241
|
let cacheState;
|
|
233
242
|
if (response.headers.get(REPLAYABLE_HEADER) === "1") {
|
|
234
|
-
if (requestedStale) cacheState = "stale";
|
|
243
|
+
if (requestedStale && storeStatus !== "BLOB-STALE") cacheState = "stale";
|
|
235
244
|
} else if (typeof entry.cacheControl?.expire === "number" && age > entry.cacheControl.expire * 1e3) cacheState = "expired";
|
|
236
|
-
else if (requestedStale || typeof entry.cacheControl?.revalidate === "number" && entry.cacheControl.revalidate > 0 && age > entry.cacheControl.revalidate * 1e3) cacheState = "stale";
|
|
245
|
+
else if (requestedStale || storeStatus === "BLOB-STALE" || typeof entry.cacheControl?.revalidate === "number" && entry.cacheControl.revalidate > 0 && age > entry.cacheControl.revalidate * 1e3) cacheState = "stale";
|
|
237
246
|
return {
|
|
238
247
|
lastModified: entry.lastModified,
|
|
239
248
|
value: entry.value,
|
|
@@ -248,7 +257,13 @@ var WorkersResponseStoreCacheHandler = class {
|
|
|
248
257
|
return null;
|
|
249
258
|
}
|
|
250
259
|
}
|
|
251
|
-
|
|
260
|
+
set(key, value, context) {
|
|
261
|
+
const write = this.write(key, value, context);
|
|
262
|
+
const regeneration = regenerationStorage.getStore();
|
|
263
|
+
if (regeneration && regeneration.targetKey !== key) regeneration.sideWrites.push(write);
|
|
264
|
+
return write;
|
|
265
|
+
}
|
|
266
|
+
async write(key, value, context) {
|
|
252
267
|
let revalidate = readCacheControlField(context, "revalidate");
|
|
253
268
|
if (value && "revalidate" in value) revalidate = value.revalidate;
|
|
254
269
|
if (revalidate === 0) return;
|
|
@@ -287,12 +302,14 @@ var WorkersResponseStoreCacheHandler = class {
|
|
|
287
302
|
...revalidator ? { [REPLAYABLE_HEADER]: "1" } : {}
|
|
288
303
|
} });
|
|
289
304
|
const regeneration = regenerationStorage.getStore();
|
|
290
|
-
if (regeneration) {
|
|
291
|
-
|
|
305
|
+
if (regeneration?.targetKey === key) {
|
|
306
|
+
regeneration.captured = response;
|
|
292
307
|
return;
|
|
293
308
|
}
|
|
309
|
+
if (regeneration && !revalidator) return;
|
|
294
310
|
await this.store.put(await cacheRequest(key), response, {
|
|
295
311
|
...revalidator ? { revalidator } : {},
|
|
312
|
+
...revalidator?.id !== "vinext:cache-function" ? { expiryBehavior: "miss" } : {},
|
|
296
313
|
coalesce: true,
|
|
297
314
|
purgeExisting: true
|
|
298
315
|
});
|
|
@@ -300,7 +317,10 @@ var WorkersResponseStoreCacheHandler = class {
|
|
|
300
317
|
async revalidateTag(tags, durations) {
|
|
301
318
|
const encodedTags = (Array.isArray(tags) ? tags : [tags]).map(encodeCloudflareCacheTag);
|
|
302
319
|
if (!encodedTags.length) return;
|
|
303
|
-
if (durations
|
|
320
|
+
if (isStaleTagInvalidation(durations)) await this.store.invalidate({
|
|
321
|
+
tags: encodedTags,
|
|
322
|
+
...durations?.expire === void 0 ? {} : { expire: durations.expire }
|
|
323
|
+
});
|
|
304
324
|
else await this.store.purge({ tags: encodedTags });
|
|
305
325
|
}
|
|
306
326
|
};
|
|
@@ -33,12 +33,13 @@ function writeCacheAsset(outputDir, index, key, kind, sourcePath, route, content
|
|
|
33
33
|
const id = cacheAssetId(key);
|
|
34
34
|
const extension = kind;
|
|
35
35
|
const policy = cacheControl(route);
|
|
36
|
+
const headers = kind !== "rsc" ? route.headers : route.responseStatus !== void 0 && route.headers?.location !== void 0 ? { location: route.headers.location } : void 0;
|
|
36
37
|
const metadata = {
|
|
37
38
|
kind,
|
|
38
39
|
lastModified: fs.statSync(sourcePath).mtimeMs,
|
|
39
40
|
...policy ? { cacheControl: policy } : {},
|
|
40
|
-
...
|
|
41
|
-
...
|
|
41
|
+
...headers ? { headers } : {},
|
|
42
|
+
...route.responseStatus !== void 0 ? { status: route.responseStatus } : {}
|
|
42
43
|
};
|
|
43
44
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
44
45
|
const outputPath = path.join(outputDir, `${id}.${extension}`);
|
|
@@ -56,10 +56,10 @@ var StaticAssetsCacheAdapter = class {
|
|
|
56
56
|
kind: "APP_PAGE",
|
|
57
57
|
html: "",
|
|
58
58
|
rscData: await bodyResponse.arrayBuffer(),
|
|
59
|
-
headers:
|
|
59
|
+
headers: metadata.headers,
|
|
60
60
|
postponed: void 0,
|
|
61
61
|
prerendered: true,
|
|
62
|
-
status:
|
|
62
|
+
status: metadata.status
|
|
63
63
|
};
|
|
64
64
|
else value = {
|
|
65
65
|
kind: "APP_ROUTE",
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
//#region src/cache/workers-cache-invalidation.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Workers Cache tag invalidation shared by the CDN adapter, its worker
|
|
4
|
+
* entrypoints, and the Response Store data adapter.
|
|
5
|
+
*/
|
|
6
|
+
type CacheInvalidationOptions = {
|
|
7
|
+
tags: string[];
|
|
8
|
+
};
|
|
9
|
+
type CacheMethodName = "invalidate" | "purge";
|
|
10
|
+
type CacheMethods<Name extends CacheMethodName> = Record<Name, (options: CacheInvalidationOptions) => unknown>;
|
|
11
|
+
/**
|
|
12
|
+
* Whether `revalidateTag` marks its tags stale rather than expiring them.
|
|
13
|
+
*
|
|
14
|
+
* Next.js marks a tag stale whenever durations are passed, and expires it
|
|
15
|
+
* immediately when they are absent (`updateTag`, `revalidatePath`, the legacy
|
|
16
|
+
* single-argument `revalidateTag`) or `expire` is 0.
|
|
17
|
+
* https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/incremental-cache/file-system-cache.ts
|
|
18
|
+
*/
|
|
19
|
+
export declare function isStaleTagInvalidation(durations?: {
|
|
20
|
+
expire?: number;
|
|
21
|
+
}): boolean;
|
|
22
|
+
export declare function hasCacheMethod<Name extends CacheMethodName>(value: unknown, name: Name): value is CacheMethods<Name>;
|
|
23
|
+
/**
|
|
24
|
+
* Mark matching Workers Cache responses stale so the edge keeps serving them
|
|
25
|
+
* while it refetches in the background. Local Miniflare does not implement
|
|
26
|
+
* `invalidate()` yet, so it falls back to a hard purge there. Returns
|
|
27
|
+
* `undefined` when the cache supports neither.
|
|
28
|
+
*/
|
|
29
|
+
export declare function invalidateOrPurge(cache: unknown, options: CacheInvalidationOptions): unknown;
|
|
30
|
+
//#endregion
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
//#region src/cache/workers-cache-invalidation.ts
|
|
2
|
+
/**
|
|
3
|
+
* Whether `revalidateTag` marks its tags stale rather than expiring them.
|
|
4
|
+
*
|
|
5
|
+
* Next.js marks a tag stale whenever durations are passed, and expires it
|
|
6
|
+
* immediately when they are absent (`updateTag`, `revalidatePath`, the legacy
|
|
7
|
+
* single-argument `revalidateTag`) or `expire` is 0.
|
|
8
|
+
* https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/incremental-cache/file-system-cache.ts
|
|
9
|
+
*/
|
|
10
|
+
function isStaleTagInvalidation(durations) {
|
|
11
|
+
return durations !== void 0 && !(typeof durations.expire === "number" && durations.expire <= 0);
|
|
12
|
+
}
|
|
13
|
+
function hasCacheMethod(value, name) {
|
|
14
|
+
return value !== null && (typeof value === "object" || typeof value === "function") && name in value && typeof Reflect.get(value, name) === "function";
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Mark matching Workers Cache responses stale so the edge keeps serving them
|
|
18
|
+
* while it refetches in the background. Local Miniflare does not implement
|
|
19
|
+
* `invalidate()` yet, so it falls back to a hard purge there. Returns
|
|
20
|
+
* `undefined` when the cache supports neither.
|
|
21
|
+
*/
|
|
22
|
+
function invalidateOrPurge(cache, options) {
|
|
23
|
+
if (hasCacheMethod(cache, "invalidate")) return cache.invalidate(options);
|
|
24
|
+
return hasCacheMethod(cache, "purge") ? cache.purge(options) : void 0;
|
|
25
|
+
}
|
|
26
|
+
//#endregion
|
|
27
|
+
export { hasCacheMethod, invalidateOrPurge, isStaleTagInvalidation };
|
package/dist/cdn-warm.js
CHANGED
|
@@ -482,7 +482,7 @@ function validateBuildIdentity(response, expectedBuildId) {
|
|
|
482
482
|
return null;
|
|
483
483
|
}
|
|
484
484
|
function isExpectedTerminalStatus(status) {
|
|
485
|
-
return status >= 300 && status < 400 || status === 404;
|
|
485
|
+
return status >= 300 && status < 400 || status === 401 || status === 403 || status === 404;
|
|
486
486
|
}
|
|
487
487
|
function validateRscWarmResponse(response, expectedBuildId, expectedRscBuildId, requireCacheHit = false, statusSource = "cloudflare") {
|
|
488
488
|
const buildIdentityValidation = validateBuildIdentity(response, expectedBuildId);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vinext/cloudflare",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Cloudflare cache and image adapters for vinext.",
|
|
5
5
|
"homepage": "https://vinext.dev/",
|
|
6
6
|
"license": "MIT",
|
|
@@ -48,21 +48,21 @@
|
|
|
48
48
|
}
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@cloudflare/workers-response-store": "1.0
|
|
51
|
+
"@cloudflare/workers-response-store": "1.1.0"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@types/node": "^25.9.2",
|
|
55
55
|
"cf": "1.0.0-beta.1",
|
|
56
56
|
"miniflare": "5.20260730.0-alpha",
|
|
57
|
-
"vite": "npm:@voidzero-dev/vite-plus-core@
|
|
58
|
-
"vite-plus": "
|
|
59
|
-
"vitest": "
|
|
60
|
-
"vinext": "1.0
|
|
57
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@1.1.0",
|
|
58
|
+
"vite-plus": "1.1.0",
|
|
59
|
+
"vitest": "5.0.3",
|
|
60
|
+
"vinext": "1.1.0"
|
|
61
61
|
},
|
|
62
62
|
"peerDependencies": {
|
|
63
63
|
"@cloudflare/vite-plugin": "*",
|
|
64
64
|
"cf": "*",
|
|
65
|
-
"vinext": "^1.0
|
|
65
|
+
"vinext": "^1.1.0"
|
|
66
66
|
},
|
|
67
67
|
"peerDependenciesMeta": {
|
|
68
68
|
"@cloudflare/vite-plugin": {
|