@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.
@@ -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
- /** Purge edge-cached responses by tag via the request context's `cache.purge`. */
36
- revalidateTag(tags: string | string[], _durations?: {
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
- /** Purge edge-cached responses by tag via the request context's `cache.purge`. */
212
- async revalidateTag(tags, _durations) {
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 result = await cache.purge({ tags: tagList.map(encodeCloudflareCacheTag) });
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 purge failed${errors ? `: ${errors}` : ""}`);
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
- function withResponseStagePurge(context) {
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: { purge(options) {
221
- const target = factory({ props: {} });
222
- if (hasPurge(target)) return target.purge(options);
223
- return hasPurge(fallback) ? fallback.purge(options) : void 0;
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 response = await invokeResponseStage(restored.request, this.env, context, invocation);
286
- return stampResponseStageBuildIdentity(restored.didAccessRequestCf() || hasTaggedCustomVary(response) ? preventResponseCaching(response) : response);
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 (!hasPurge(cache)) return void 0;
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 = withResponseStagePurge(withWorkerHostRuntime(this.ctx, this.env));
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 = withResponseStagePurge(withWorkerHostRuntime(context, env));
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
- /** Default KV `expirationTtl` in seconds. @default 2592000 (30 days) */
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, cache, capture, invocation) {
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, { cache }), capture);
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, "shared");
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 === 404) && policy !== null && !isNonCacheableCacheControl(policy);
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 === 404 && (storeStatus === "BLOB-FRESH" || storeStatus === "BLOB-STALE")) return response;
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", "private, max-age=0, must-revalidate");
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, "shared", capture, {
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 = { targetKey: key };
140
- await regenerationStorage.run(scope, callback);
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
- return `public, max-age=${Math.max(0, revalidate)}, stale-while-revalidate=${Math.max(0, (expire ?? revalidate) - revalidate)}`;
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
- async set(key, value, context) {
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
- if (key === regeneration.targetKey) regeneration.captured = response;
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?.expire && durations.expire > 0) await this.store.refresh({ tags: encodedTags });
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
- ...kind !== "rsc" && route.headers ? { headers: route.headers } : {},
41
- ...kind !== "rsc" && route.responseStatus !== void 0 ? { status: route.responseStatus } : {}
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: void 0,
59
+ headers: metadata.headers,
60
60
  postponed: void 0,
61
61
  prerendered: true,
62
- status: void 0
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.1",
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.1"
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@0.3.2",
58
- "vite-plus": "0.3.2",
59
- "vitest": "4.1.11",
60
- "vinext": "1.0.1"
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.1"
65
+ "vinext": "^1.1.0"
66
66
  },
67
67
  "peerDependenciesMeta": {
68
68
  "@cloudflare/vite-plugin": {