@vinext/cloudflare 1.0.1 → 1.1.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.
@@ -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, splitCacheControlDirectives } 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,76 @@ 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
+ /** A Pages page render, as an HTML document or a `_next/data` payload. */
187
+ function isPagesPageInvocation(invocation) {
188
+ const props = invocation.props;
189
+ if (props === null || typeof props !== "object") return false;
190
+ const kind = Reflect.get(props, "kind");
191
+ return kind === "pages-page" || kind === "hybrid-pages" && Reflect.get(props, "resourceKind") === "page";
192
+ }
193
+ /**
194
+ * Workers Cache revalidates an entry that has a validator with a conditional
195
+ * request, and serves the stale entry meanwhile or fills an expired one.
196
+ */
197
+ function revalidatesStoredEntry(request) {
198
+ return request.headers.has("If-None-Match") || request.headers.has("If-Modified-Since");
199
+ }
200
+ /**
201
+ * No client waits on a deployment warm-up or on a revalidation, as with the
202
+ * Response Store's.
203
+ */
204
+ function rendersWholeDocument(request) {
205
+ return request.headers.get("user-agent") === WARMUP_USER_AGENT || revalidatesStoredEntry(request);
206
+ }
207
+ /**
208
+ * A conditional revalidation of a Pages page skips getStaticPaths(), so only a
209
+ * successful render may be stored with a validator. Bot renders carry an ETag
210
+ * whatever their status. A 304 answers a revalidation of a stored 200, and a
211
+ * response a shared cache may not store keeps its validators.
212
+ */
213
+ function withPagesRevalidationValidator(response) {
214
+ if (response.status === 200) return withRevalidationValidator(response);
215
+ const policy = selectedCachePolicy(response);
216
+ if (response.status === 304 || policy !== null && forbidsSharedStorage(policy) || !response.headers.has("ETag") && !response.headers.has("Last-Modified")) return response;
217
+ const headers = new Headers(response.headers);
218
+ headers.delete("ETag");
219
+ headers.delete("Last-Modified");
220
+ return new Response(response.body, {
221
+ headers,
222
+ status: response.status,
223
+ statusText: response.statusText
224
+ });
225
+ }
226
+ /**
227
+ * `no-cache` and a field-qualified `private` still let a shared cache store the
228
+ * response (RFC 9111 sections 5.2.2.4 and 5.2.2.7).
229
+ */
230
+ function forbidsSharedStorage(policy) {
231
+ return splitCacheControlDirectives(policy).some((directive) => {
232
+ const normalized = directive.toLowerCase();
233
+ return normalized === "private" || normalized.split("=", 1)[0].trim() === "no-store";
234
+ });
235
+ }
236
+ function selectedCachePolicy(response) {
237
+ return response.headers.get("Cloudflare-CDN-Cache-Control") ?? response.headers.get("CDN-Cache-Control") ?? response.headers.get("Cache-Control");
238
+ }
239
+ /** Give a cacheable document a validator, so Workers Cache revalidates it conditionally. */
240
+ function withRevalidationValidator(response) {
241
+ if (response.headers.has("ETag") || response.headers.has("Last-Modified")) return response;
242
+ const policy = selectedCachePolicy(response);
243
+ if (policy === null || isNonCacheableCacheControl(policy)) return response;
244
+ const headers = new Headers(response.headers);
245
+ headers.set("Last-Modified", (/* @__PURE__ */ new Date()).toUTCString());
246
+ return new Response(response.body, {
247
+ headers,
248
+ status: response.status,
249
+ statusText: response.statusText
250
+ });
251
+ }
182
252
  function preventResponseCaching(response) {
183
253
  const headers = new Headers(response.headers);
184
254
  headers.set("Cache-Control", "no-store");
@@ -211,17 +281,28 @@ function hasTaggedCustomVary(response) {
211
281
  if (!response.headers.get("Cache-Tag")) return false;
212
282
  return (response.headers.get("Vary") ?? "").split(",").map((name) => name.trim().toLowerCase()).filter(Boolean).some((name) => !FRAMEWORK_RESPONSE_VARY_FIELDS.has(name));
213
283
  }
214
- function withResponseStagePurge(context) {
284
+ /**
285
+ * Workers Cache purges and invalidations only reach the calling entrypoint's
286
+ * cache, so route both through the cache-bearing response entrypoint.
287
+ */
288
+ function withResponseStageCache(context) {
215
289
  const factory = context.exports?.[CACHED_RESPONSE_STAGE_EXPORT];
216
290
  if (typeof factory !== "function") return context;
217
291
  const fallback = context.cache;
218
292
  return {
219
293
  ...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
- } }
294
+ cache: {
295
+ purge(options) {
296
+ const target = factory({ props: {} });
297
+ if (hasCacheMethod(target, "purge")) return target.purge(options);
298
+ return hasCacheMethod(fallback, "purge") ? fallback.purge(options) : void 0;
299
+ },
300
+ invalidate(options) {
301
+ const target = factory({ props: {} });
302
+ const targetCanUpdate = hasCacheMethod(target, "invalidate") || hasCacheMethod(target, "purge");
303
+ return invalidateOrPurge(targetCanUpdate ? target : fallback, options);
304
+ }
305
+ }
225
306
  };
226
307
  }
227
308
  function getResponseStageInvocation(value, expectedCache) {
@@ -282,19 +363,36 @@ var VinextCachedResponse = class extends WorkerEntrypoint {
282
363
  }));
283
364
  if (invocation.expectedResponseStageBuildIdentity !== void 0 && invocation.expectedResponseStageBuildIdentity !== getVinextCdnBuildIdentity()) return stampResponseStageBuildIdentity(responseStageUnavailable());
284
365
  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);
366
+ const isAppPageDocument = isAppPageDocumentInvocation(invocation);
367
+ const isPagesPage = isPagesPageInvocation(invocation);
368
+ const response = await invokeResponseStage(restored.request, this.env, context, isAppPageDocument && rendersWholeDocument(request) ? {
369
+ ...invocation,
370
+ options: {
371
+ ...invocation.options,
372
+ renderWholeDocument: true
373
+ }
374
+ } : isPagesPage && revalidatesStoredEntry(request) ? {
375
+ ...invocation,
376
+ options: {
377
+ ...invocation.options,
378
+ regeneratesStoredEntry: true
379
+ }
380
+ } : invocation);
381
+ return stampResponseStageBuildIdentity(restored.didAccessRequestCf() || hasTaggedCustomVary(response) ? preventResponseCaching(response) : isAppPageDocument ? withRevalidationValidator(response) : isPagesPage ? withPagesRevalidationValidator(response) : response);
287
382
  }
288
383
  async purge(options) {
289
384
  const cache = Reflect.get(this.ctx, "cache");
290
- if (!hasPurge(cache)) return void 0;
385
+ if (!hasCacheMethod(cache, "purge")) return void 0;
291
386
  return cache.purge(options);
292
387
  }
388
+ async invalidate(options) {
389
+ return invalidateOrPurge(Reflect.get(this.ctx, "cache"), options);
390
+ }
293
391
  };
294
392
  /** Uncached response entrypoint retained for deployment readiness probes. */
295
393
  var VinextUncachedResponse = class extends WorkerEntrypoint {
296
394
  async fetch(request) {
297
- const context = withResponseStagePurge(withWorkerHostRuntime(this.ctx, this.env));
395
+ const context = withResponseStageCache(withWorkerHostRuntime(this.ctx, this.env));
298
396
  const invocation = getResponseStageInvocation(context.props, "bypass");
299
397
  if (!invocation) return stampResponseStageBuildIdentity(new Response("Invalid vinext response-stage invocation", {
300
398
  status: 400,
@@ -308,7 +406,7 @@ var VinextUncachedResponse = class extends WorkerEntrypoint {
308
406
  var cdn_adapter_worker_default = { async fetch(request, env, context) {
309
407
  request = stripUntrustedTransportHeaders(request);
310
408
  const sharedResponses = /* @__PURE__ */ new Map();
311
- const stageContext = withResponseStagePurge(withWorkerHostRuntime(context, env));
409
+ const stageContext = withResponseStageCache(withWorkerHostRuntime(context, env));
312
410
  const dispatchResponseStage = async (stageRequest, props, options) => {
313
411
  const expectedResponseStageBuildIdentity = getVinextCdnBuildIdentity();
314
412
  const invocation = {
@@ -6,7 +6,7 @@ type ResponseStoreStorageOptions = {
6
6
  };
7
7
  export declare function createWorkersResponseStoreSelfContainedConfig(options: ResponseStoreStorageOptions): Promise<{
8
8
  cache: {
9
- enabled: true;
9
+ enabled: false;
10
10
  };
11
11
  exports: {
12
12
  CacheMetadata: {
@@ -45,7 +45,7 @@ export declare function createWorkersResponseStoreServiceBindingConfig(options:
45
45
  accountId: string | undefined;
46
46
  serviceBindingWorker: {
47
47
  cache: {
48
- enabled: true;
48
+ enabled: false;
49
49
  };
50
50
  exports: {
51
51
  CacheMetadata: {
@@ -84,7 +84,7 @@ export declare function createWorkersResponseStoreServiceBindingConfig(options:
84
84
  env: {
85
85
  RESPONSE_STORE: import("cf/config").WorkerBinding<{
86
86
  cache: {
87
- enabled: true;
87
+ enabled: false;
88
88
  };
89
89
  exports: {
90
90
  CacheMetadata: {
@@ -10,7 +10,7 @@ const RESPONSE_STORE_SERVICE_ENTRYPOINT = "@cloudflare/workers-response-store/se
10
10
  async function createResponseStoreWorkerConfig(options) {
11
11
  const { bindings, exports } = await import("cf/config");
12
12
  return {
13
- cache: { enabled: true },
13
+ cache: { enabled: false },
14
14
  exports: {
15
15
  [CACHE_METADATA_CLASS]: {
16
16
  ...exports.durableObject({ storage: "sqlite" }),
@@ -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,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { fnv1a64 } from "vinext/internal/utils/hash";
2
3
  //#region src/cache/kv-key.ts
3
4
  /** Key prefix for tag invalidation timestamps. */
@@ -27,19 +28,37 @@ function kvKeyByteLength(key) {
27
28
  return KV_KEY_ENCODER.encode(key).length;
28
29
  }
29
30
  /**
31
+ * Digest an entry key that is too long to store verbatim. Logical keys can
32
+ * carry request input, so the digest must be collision resistant: two keys
33
+ * sharing a digest would share an entry.
34
+ */
35
+ function digestEntryKey(key) {
36
+ return createHash("sha256").update(key).digest("hex");
37
+ }
38
+ /**
39
+ * Digest a tag that is too long or unsafe to store verbatim. Tags sharing a
40
+ * digest share one invalidation marker, which can only invalidate extra
41
+ * entries. Markers are permanent, so a new digest would drop the
42
+ * invalidations already recorded for entries still in KV.
43
+ */
44
+ function digestTag(tag) {
45
+ return fnv1a64(tag);
46
+ }
47
+ /**
30
48
  * Keep short app prefixes readable, but bound the namespace portion so a
31
49
  * hashed entry or tag key is always able to fit within Cloudflare KV's limit.
32
50
  */
33
51
  function normalizeAppPrefix(appPrefix) {
34
52
  if (!appPrefix) return "";
35
53
  const prefix = `${appPrefix}:`;
36
- if (kvKeyByteLength(`${prefix}cache:${HASHED_KEY_PREFIX}${fnv1a64("")}`) <= KV_KEY_MAX_BYTES) return prefix;
54
+ if (kvKeyByteLength(`${prefix}cache:${HASHED_KEY_PREFIX}${digestEntryKey("")}`) <= KV_KEY_MAX_BYTES) return prefix;
37
55
  return `__app:${fnv1a64(appPrefix)}:`;
38
56
  }
39
57
  function buildStorageKey(prefix, categoryPrefix, logicalKey) {
40
58
  const key = `${prefix}${categoryPrefix}${logicalKey}`;
41
59
  if (!(categoryPrefix === TAG_PREFIX && logicalKey.includes(":")) && kvKeyByteLength(key) <= KV_KEY_MAX_BYTES) return key;
42
- return `${prefix}${categoryPrefix}${HASHED_KEY_PREFIX}${fnv1a64(logicalKey)}`;
60
+ const digest = categoryPrefix === TAG_PREFIX ? digestTag(logicalKey) : digestEntryKey(logicalKey);
61
+ return `${prefix}${categoryPrefix}${HASHED_KEY_PREFIX}${digest}`;
43
62
  }
44
63
  /** Create the deterministic key namespace for runtime cache operations. */
45
64
  function createKvKeySpace(appPrefix) {
@@ -1,11 +1,12 @@
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
- import { isNonCacheableCacheControl } from "vinext/shims/cdn-cache";
5
+ import { hasSharedCacheCredentials, isNonCacheableCacheControl } from "vinext/shims/cdn-cache";
6
6
  import { loadVinextRequestStage } from "vinext/server/request-stage";
7
7
  import { loadVinextResponseStage } from "vinext/server/response-stage";
8
8
  import { traceCachedResponseStart } from "vinext/internal/server/response-start-tracing";
9
+ import { isPageNotModified } from "vinext/internal/server/http-conditional";
9
10
  //#region src/cache/response-store-adapter.worker.ts
10
11
  const ROUTE_REVALIDATOR_ID = "vinext:response";
11
12
  const RESPONSE_STORE_KEY_PARAM = "__workers_response_store";
@@ -41,7 +42,7 @@ function replayHeaders(request) {
41
42
  function isReplayableInvocation(request, props) {
42
43
  const method = request.method.toUpperCase();
43
44
  if (method !== "GET" && method !== "HEAD") return false;
44
- if (request.headers.has("authorization") || request.headers.has("cookie")) return false;
45
+ if (hasSharedCacheCredentials(request.headers)) return false;
45
46
  return !(props && typeof props === "object" && ["draftModeCookie", "middlewareCookieOverlay"].some((name) => name in props && Reflect.get(props, name) !== null));
46
47
  }
47
48
  function prepareInvocation(request, props) {
@@ -71,9 +72,9 @@ function restoreRequest(invocation) {
71
72
  }
72
73
  async function invokeRequestStage(request, env, ctx) {
73
74
  const { handleRequestStage } = await loadVinextRequestStage();
74
- return handleRequestStage(request, env, stageContext(ctx, env), (request, props, options) => invokeResponseStage(request, props, env, ctx, options.cache));
75
+ return handleRequestStage(request, env, stageContext(ctx, env), (request, props, options) => invokeResponseStage(request, props, env, ctx, { cache: options.cache }));
75
76
  }
76
- async function invokeResponseStage(request, props, env, ctx, cache, capture, invocation) {
77
+ async function invokeResponseStage(request, props, env, ctx, stageOptions, capture, invocation) {
77
78
  const context = stageContext(ctx, env);
78
79
  const dispatchRequestStage = (request) => invokeRequestStage(request, env, ctx);
79
80
  const { handleResponseStage } = await loadVinextResponseStage();
@@ -81,7 +82,7 @@ async function invokeResponseStage(request, props, env, ctx, cache, capture, inv
81
82
  replayable: isReplayableInvocation(request, props),
82
83
  serialized: serializeInvocation(request, props)
83
84
  };
84
- return runWithResponseStoreInvocation(storedInvocation.serialized, storedInvocation.replayable, () => handleResponseStage(request, env, context, props, dispatchRequestStage, { cache }), capture);
85
+ return runWithResponseStoreInvocation(storedInvocation.serialized, storedInvocation.replayable, () => handleResponseStage(request, env, context, props, dispatchRequestStage, stageOptions), capture);
85
86
  }
86
87
  function createVinextResponseStoreOptions(configuration) {
87
88
  const locationHint = configuration?.locationHint;
@@ -94,7 +95,11 @@ function createVinextResponseStoreOptions(configuration) {
94
95
  async regenerate(input, { env, ctx }) {
95
96
  if (input.id === ROUTE_REVALIDATOR_ID) {
96
97
  const invocation = parseInvocation(input.args.at(-1));
97
- const response = await invokeResponseStage(restoreRequest(invocation), invocation.props, env, ctx, "shared");
98
+ const response = await invokeResponseStage(restoreRequest(invocation), invocation.props, env, ctx, {
99
+ cache: "shared",
100
+ regeneratesStoredEntry: true,
101
+ renderWholeDocument: true
102
+ });
98
103
  if (!isCacheable(response)) {
99
104
  await response.body?.cancel().catch(() => {});
100
105
  throw new Error("Vinext response-stage regeneration was not cacheable");
@@ -113,7 +118,7 @@ function createVinextResponseStoreOptions(configuration) {
113
118
  if (input.id === "vinext:data" && typeof input.args[0] === "string") {
114
119
  const invocation = parseInvocation(input.args.at(-1));
115
120
  return captureResponseStoreDataRegeneration(input.args[0], async () => {
116
- await (await invokeResponseStage(restoreRequest(invocation), invocation.props, env, ctx, "bypass")).body?.pipeTo(new WritableStream());
121
+ await (await invokeResponseStage(restoreRequest(invocation), invocation.props, env, ctx, { cache: "bypass" })).body?.pipeTo(new WritableStream());
117
122
  });
118
123
  }
119
124
  throw new Error(`Unknown vinext response-store revalidator ${input.id}`);
@@ -152,15 +157,70 @@ function withoutRequestScopedHeaders(response, responseStageProps) {
152
157
  statusText: response.statusText
153
158
  });
154
159
  }
160
+ function isStoredErrorStatus(status) {
161
+ return status === 401 || status === 403 || status === 404;
162
+ }
155
163
  function isCacheable(response) {
156
164
  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);
165
+ return response.status >= 200 && response.status !== 304 && (response.status < 400 || isStoredErrorStatus(response.status)) && policy !== null && !isNonCacheableCacheControl(policy);
166
+ }
167
+ /**
168
+ * A page's 304 headers. Next.js sends the 304 before it sets the payload's
169
+ * headers, so it carries no representation or body framing headers.
170
+ */
171
+ function notModifiedHeaders(source) {
172
+ const headers = new Headers(source);
173
+ for (const name of [
174
+ "Content-Encoding",
175
+ "Content-Length",
176
+ "Content-Type",
177
+ "Transfer-Encoding"
178
+ ]) headers.delete(name);
179
+ return headers;
180
+ }
181
+ /** Send the 304 for a page whose ETag matched the request's If-None-Match. */
182
+ function notModifiedResponse(response) {
183
+ response.body?.cancel().catch(() => {});
184
+ return new Response(null, {
185
+ headers: notModifiedHeaders(response.headers),
186
+ status: 304
187
+ });
188
+ }
189
+ /**
190
+ * Whether Next.js sends this stored response through `sendRenderResult`, which
191
+ * answers a conditional request by the page's ETag. It sends every App page
192
+ * that way. A Pages redirect, and a Pages data request's notFound, are sent
193
+ * without an ETag, as is a route handler's response. A Pages API response is
194
+ * never stored.
195
+ */
196
+ function answersConditionalRequest(responseStageProps, status) {
197
+ if (responseStageProps === null || typeof responseStageProps !== "object") return false;
198
+ const kind = Reflect.get(responseStageProps, "kind");
199
+ if (kind === "app-page" || kind === "app-not-found") return true;
200
+ let isDataRequest;
201
+ if (kind === "hybrid-pages") {
202
+ if (Reflect.get(responseStageProps, "resourceKind") !== "page") return false;
203
+ isDataRequest = Reflect.get(responseStageProps, "isDataRequest") === true;
204
+ } else if (kind === "pages-page") {
205
+ const renderOptions = Reflect.get(responseStageProps, "renderOptions");
206
+ isDataRequest = renderOptions !== null && typeof renderOptions === "object" && Reflect.get(renderOptions, "isDataReq") === true;
207
+ } else return false;
208
+ return status >= 200 && status < 300 || status === 404 && !isDataRequest;
209
+ }
210
+ /** Answer a stored page's conditional request as Next.js does for a cached page. */
211
+ function storedForRequest(stored, request, responseStageProps) {
212
+ const etag = stored.headers.get("ETag");
213
+ return etag && answersConditionalRequest(responseStageProps, stored.status) && isPageNotModified({
214
+ cacheControl: request.headers.get("Cache-Control") ?? void 0,
215
+ ifModifiedSince: request.headers.get("If-Modified-Since") ?? void 0,
216
+ ifNoneMatch: request.headers.get("If-None-Match") ?? void 0
217
+ }, etag) ? notModifiedResponse(stored) : stored;
158
218
  }
159
219
  async function readStoredResponse(key) {
160
220
  try {
161
221
  const response = await responseStore.fetch(key);
162
222
  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;
223
+ if (response.status >= 200 && response.status < 400 || isStoredErrorStatus(response.status) && (storeStatus === "BLOB-FRESH" || storeStatus === "BLOB-STALE")) return response;
164
224
  response.body?.cancel().catch(() => {});
165
225
  if (response.status === 404 && storeStatus === "MISS") return null;
166
226
  throw new Error(`Workers Response Store returned ${response.status}`);
@@ -195,7 +255,7 @@ function publicResponse(response, cacheStatus, responseStageProps, pendingAdmiss
195
255
  headers.set("X-Vinext-Cache", publicCacheStatus);
196
256
  }
197
257
  const cacheControl = headers.get("Cache-Control");
198
- if (pendingAdmission && (!cacheControl || !isNonCacheableCacheControl(cacheControl, "browser"))) headers.set("Cache-Control", "private, max-age=0, must-revalidate");
258
+ if (pendingAdmission && (!cacheControl || !isNonCacheableCacheControl(cacheControl, "browser"))) headers.set("Cache-Control", "no-store, must-revalidate");
199
259
  return traceCachedResponseStart(new Response(response.body, {
200
260
  headers,
201
261
  status: response.status,
@@ -211,7 +271,7 @@ function createVinextResponseStoreHandler(store) {
211
271
  const handler = { async fetch(request, env, ctx) {
212
272
  const context = stageContext(ctx, env);
213
273
  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);
274
+ if (options.cache === "bypass" || stageRequest.method !== "GET" && stageRequest.method !== "HEAD") return publicResponse(await invokeResponseStage(stageRequest, props, env, ctx, { cache: "bypass" }), "BYPASS", props);
215
275
  const identityRequest = options.cacheIdentity?.request ?? stageRequest;
216
276
  const identityProps = options.cacheIdentity?.props ?? props;
217
277
  const isWarmup = request.headers.get("user-agent") === WARMUP_USER_AGENT;
@@ -229,20 +289,24 @@ const handler = { async fetch(request, env, ctx) {
229
289
  const key = await cacheRequest(invocation);
230
290
  const stored = await readStoredResponse(key);
231
291
  if (stored) {
232
- if (!rscKey) return publicResponse(stored, "HIT", props);
292
+ if (!rscKey) return publicResponse(storedForRequest(stored, stageRequest, props), "HIT", props);
233
293
  const storedRsc = await readStoredResponse(rscKey);
234
294
  if (storedRsc) {
235
295
  storedRsc.body?.cancel().catch(() => {});
236
- return publicResponse(stored, "HIT", props);
296
+ return publicResponse(storedForRequest(stored, stageRequest, props), "HIT", props);
237
297
  }
238
298
  stored.body?.cancel().catch(() => {});
239
299
  }
240
300
  const capture = rscSeed ? { captureRscData: true } : isWarmup ? {} : { streamResponse: true };
241
301
  const serializedInvocation = JSON.stringify(invocation);
242
- const rendered = await invokeResponseStage(stageRequest, props, env, ctx, "shared", capture, {
302
+ const rendered = await invokeResponseStage(stageRequest, props, env, ctx, isWarmup ? {
303
+ cache: "shared",
304
+ renderWholeDocument: true
305
+ } : { cache: "shared" }, capture, {
243
306
  replayable: isReplayableInvocation(stageRequest, props),
244
307
  serialized: options.cacheIdentity ? serializeInvocation(stageRequest, props) : serializedInvocation
245
308
  });
309
+ const foreground = (response) => capture.notModified ? notModifiedResponse(response) : response;
246
310
  if (capture.admittedResponse) {
247
311
  ctx.waitUntil(capture.admittedResponse.then(async (admitted) => {
248
312
  if (!isCacheable(admitted)) {
@@ -262,17 +326,23 @@ const handler = { async fetch(request, env, ctx) {
262
326
  error: error instanceof Error ? error.message : String(error)
263
327
  }));
264
328
  }));
265
- return publicResponse(rendered, "MISS", props, true);
329
+ if (!capture.notModified) return publicResponse(rendered, "MISS", props, true);
330
+ const notModified = notModifiedResponse(rendered);
331
+ const admitted = await capture.admittedResponse.catch(() => null);
332
+ return admitted ? publicResponse(new Response(null, {
333
+ headers: notModifiedHeaders(admitted.headers),
334
+ status: 304
335
+ }), "MISS", props) : publicResponse(notModified, "MISS", props, true);
266
336
  }
267
337
  if (!isCacheable(rendered)) {
268
338
  capture?.rscData?.catch(() => {});
269
- return publicResponse(rendered, "BYPASS", props);
339
+ return publicResponse(foreground(rendered), "BYPASS", props);
270
340
  }
271
341
  if (rscSeed && !capture?.rscData) {
272
342
  await rendered.body?.cancel();
273
343
  throw new Error("Vinext response-store warmup did not capture the App page RSC payload");
274
344
  }
275
- const [foreground, cacheBody] = rendered.body ? rendered.body.tee() : [null, null];
345
+ const [foregroundBody, cacheBody] = rendered.body ? rendered.body.tee() : [null, null];
276
346
  const cacheResponse = withoutRequestScopedHeaders(new Response(cacheBody, rendered), props);
277
347
  try {
278
348
  await responseStore.put(key, cacheResponse, {
@@ -302,10 +372,11 @@ const handler = { async fetch(request, env, ctx) {
302
372
  rscHeaders.set("Vary", VINEXT_RSC_VARY_HEADER$1);
303
373
  applyRscCompatibilityIdHeader(rscHeaders);
304
374
  applyRscDeploymentIdHeader(rscHeaders);
375
+ if (isStoredErrorStatus(rendered.status)) rscHeaders.set(VINEXT_SPECIAL_ERROR_STATUS_HEADER, String(rendered.status));
305
376
  const serializedRscInvocation = JSON.stringify(rscInvocation);
306
377
  await responseStore.put(rscKey, new Response(rscData, {
307
378
  headers: rscHeaders,
308
- status: 200
379
+ status: rendered.status >= 300 && rendered.status < 400 ? 200 : rendered.status
309
380
  }), {
310
381
  coalesce: true,
311
382
  revalidator: {
@@ -314,7 +385,7 @@ const handler = { async fetch(request, env, ctx) {
314
385
  }
315
386
  });
316
387
  }
317
- return publicResponse(new Response(foreground, rendered), "MISS", props);
388
+ return publicResponse(foreground(new Response(foregroundBody, rendered)), "MISS", props);
318
389
  };
319
390
  const { handleRequestStage } = await loadVinextRequestStage();
320
391
  const sharedResponses = /* @__PURE__ */ new Map();
@@ -1,5 +1,5 @@
1
1
  import createCloudflareCdnCacheAdapter from "./cdn-adapter.runtime.js";
2
- import { captureResponseStoreRscData, deferResponseStoreAdmission } from "./response-store-data.runtime.js";
2
+ import { captureResponseStoreRscData, deferResponseStoreAdmission, deferResponseStoreNotModified } from "./response-store-data.runtime.js";
3
3
  //#region src/cache/response-store-cdn.runtime.ts
4
4
  /** Response Store owns page serving and SWR; vinext only emits admitted response policy. */
5
5
  var ResponseStoreCdnCacheAdapter = class {
@@ -33,6 +33,9 @@ var ResponseStoreCdnCacheAdapter = class {
33
33
  captureAppPageRscData(rscData) {
34
34
  captureResponseStoreRscData(rscData);
35
35
  }
36
+ deferNotModifiedResponse() {
37
+ return deferResponseStoreNotModified();
38
+ }
36
39
  async revalidateTag() {}
37
40
  };
38
41
  function createResponseStoreCdnCacheAdapter(context) {
@@ -4,6 +4,8 @@ import { CacheHandler, CacheHandlerValue, IncrementalCacheValue } from "vinext/s
4
4
  export type ResponseStoreInvocationCapture = {
5
5
  admittedResponse?: Promise<Response>;
6
6
  captureRscData?: boolean;
7
+ /** The render matched the request's If-None-Match; the adapter sends the 304. */
8
+ notModified?: boolean;
7
9
  rscData?: Promise<ArrayBuffer>;
8
10
  streamResponse?: boolean;
9
11
  };
@@ -15,6 +17,8 @@ export declare function setResponseStore(store: WorkersResponseStore): void;
15
17
  export declare function runWithResponseStoreInvocation<T>(serialized: string, replayable: boolean, callback: () => T, capture?: ResponseStoreInvocationCapture): T;
16
18
  /** Retain the RSC side stream only when the outer response-store invocation requested it. */
17
19
  export declare function captureResponseStoreRscData(rscData: Promise<ArrayBuffer>): void;
20
+ /** A shared invocation stores the page, so its adapter sends the page's 304. */
21
+ export declare function deferResponseStoreNotModified(): boolean;
18
22
  /** Stream the foreground body while retaining an independent admission branch. */
19
23
  export declare function deferResponseStoreAdmission(response: Response, complete: (response: Response) => Promise<Response>): Response | null;
20
24
  /** Re-render an invocation and return the exact rewritten data-cache entry. */
@@ -25,6 +29,7 @@ export declare class WorkersResponseStoreCacheHandler implements CacheHandler {
25
29
  constructor(store?: WorkersResponseStore);
26
30
  get(key: string, context?: Record<string, unknown>): Promise<CacheHandlerValue | null>;
27
31
  set(key: string, value: IncrementalCacheValue | null, context?: Record<string, unknown>): Promise<void>;
32
+ private write;
28
33
  revalidateTag(tags: string | string[], durations?: {
29
34
  expire?: number;
30
35
  }): 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";
@@ -32,6 +33,13 @@ function captureResponseStoreRscData(rscData) {
32
33
  if (capture?.captureRscData) capture.rscData = rscData;
33
34
  else rscData.catch(() => {});
34
35
  }
36
+ /** A shared invocation stores the page, so its adapter sends the page's 304. */
37
+ function deferResponseStoreNotModified() {
38
+ const capture = invocationStorage.getStore()?.capture;
39
+ if (!capture) return false;
40
+ capture.notModified = true;
41
+ return true;
42
+ }
35
43
  /** Stream the foreground body while retaining an independent admission branch. */
36
44
  function deferResponseStoreAdmission(response, complete) {
37
45
  const capture = invocationStorage.getStore()?.capture;
@@ -136,8 +144,15 @@ function deferResponseStoreAdmission(response, complete) {
136
144
  }
137
145
  /** Re-render an invocation and return the exact rewritten data-cache entry. */
138
146
  async function captureResponseStoreDataRegeneration(key, callback) {
139
- const scope = { targetKey: key };
140
- await regenerationStorage.run(scope, callback);
147
+ const scope = {
148
+ sideWrites: [],
149
+ targetKey: key
150
+ };
151
+ try {
152
+ await regenerationStorage.run(scope, callback);
153
+ } finally {
154
+ while (scope.sideWrites.length) await Promise.allSettled(scope.sideWrites.splice(0));
155
+ }
141
156
  if (!scope.captured) throw new Error(`vinext response-store regeneration did not rewrite data key ${key}`);
142
157
  return scope.captured;
143
158
  }
@@ -180,7 +195,8 @@ function deserialize(value) {
180
195
  }
181
196
  function cachePolicy(revalidate, expire) {
182
197
  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)}`;
198
+ const staleSeconds = expire === void 0 ? CACHE_MAX_AGE_SECONDS : Math.max(0, expire - revalidate);
199
+ return `public, max-age=${Math.max(0, revalidate)}, stale-while-revalidate=${staleSeconds}`;
184
200
  }
185
201
  var WorkersResponseStoreCacheHandler = class {
186
202
  store;
@@ -231,9 +247,9 @@ var WorkersResponseStoreCacheHandler = class {
231
247
  const requestedStale = typeof requestedRevalidate === "number" && requestedRevalidate > 0 && age > requestedRevalidate * 1e3;
232
248
  let cacheState;
233
249
  if (response.headers.get(REPLAYABLE_HEADER) === "1") {
234
- if (requestedStale) cacheState = "stale";
250
+ if (requestedStale && storeStatus !== "BLOB-STALE") cacheState = "stale";
235
251
  } 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";
252
+ else if (requestedStale || storeStatus === "BLOB-STALE" || typeof entry.cacheControl?.revalidate === "number" && entry.cacheControl.revalidate > 0 && age > entry.cacheControl.revalidate * 1e3) cacheState = "stale";
237
253
  return {
238
254
  lastModified: entry.lastModified,
239
255
  value: entry.value,
@@ -248,7 +264,13 @@ var WorkersResponseStoreCacheHandler = class {
248
264
  return null;
249
265
  }
250
266
  }
251
- async set(key, value, context) {
267
+ set(key, value, context) {
268
+ const write = this.write(key, value, context);
269
+ const regeneration = regenerationStorage.getStore();
270
+ if (regeneration && regeneration.targetKey !== key) regeneration.sideWrites.push(write);
271
+ return write;
272
+ }
273
+ async write(key, value, context) {
252
274
  let revalidate = readCacheControlField(context, "revalidate");
253
275
  if (value && "revalidate" in value) revalidate = value.revalidate;
254
276
  if (revalidate === 0) return;
@@ -287,12 +309,14 @@ var WorkersResponseStoreCacheHandler = class {
287
309
  ...revalidator ? { [REPLAYABLE_HEADER]: "1" } : {}
288
310
  } });
289
311
  const regeneration = regenerationStorage.getStore();
290
- if (regeneration) {
291
- if (key === regeneration.targetKey) regeneration.captured = response;
312
+ if (regeneration?.targetKey === key) {
313
+ regeneration.captured = response;
292
314
  return;
293
315
  }
316
+ if (regeneration && !revalidator) return;
294
317
  await this.store.put(await cacheRequest(key), response, {
295
318
  ...revalidator ? { revalidator } : {},
319
+ ...revalidator?.id !== "vinext:cache-function" ? { expiryBehavior: "miss" } : {},
296
320
  coalesce: true,
297
321
  purgeExisting: true
298
322
  });
@@ -300,7 +324,10 @@ var WorkersResponseStoreCacheHandler = class {
300
324
  async revalidateTag(tags, durations) {
301
325
  const encodedTags = (Array.isArray(tags) ? tags : [tags]).map(encodeCloudflareCacheTag);
302
326
  if (!encodedTags.length) return;
303
- if (durations?.expire && durations.expire > 0) await this.store.refresh({ tags: encodedTags });
327
+ if (isStaleTagInvalidation(durations)) await this.store.invalidate({
328
+ tags: encodedTags,
329
+ ...durations?.expire === void 0 ? {} : { expire: durations.expire }
330
+ });
304
331
  else await this.store.purge({ tags: encodedTags });
305
332
  }
306
333
  };
@@ -308,4 +335,4 @@ function createResponseStoreDataCacheAdapter() {
308
335
  return new WorkersResponseStoreCacheHandler();
309
336
  }
310
337
  //#endregion
311
- export { CACHE_FUNCTION_REVALIDATOR_ID, DATA_REVALIDATOR_ID, WorkersResponseStoreCacheHandler, captureResponseStoreDataRegeneration, captureResponseStoreRscData, createResponseStoreDataCacheAdapter as default, deferResponseStoreAdmission, runWithResponseStoreInvocation, setResponseStore };
338
+ export { CACHE_FUNCTION_REVALIDATOR_ID, DATA_REVALIDATOR_ID, WorkersResponseStoreCacheHandler, captureResponseStoreDataRegeneration, captureResponseStoreRscData, createResponseStoreDataCacheAdapter as default, deferResponseStoreAdmission, deferResponseStoreNotModified, runWithResponseStoreInvocation, setResponseStore };
@@ -2,8 +2,8 @@ import { STATIC_ASSET_CACHE_PATH } from "./static-assets-adapter.shared.js";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { createHash } from "node:crypto";
5
- import { readPrerenderManifest } from "vinext/internal/server/prerender-manifest";
6
- import { appIsrCacheKey, pagesIsrCacheKey } from "vinext/internal/server/isr-cache";
5
+ import { isFallbackShellArtifactPath, readPrerenderManifest } from "vinext/internal/server/prerender-manifest";
6
+ import { appIsrCacheKey, appIsrFallbackShellKey, pagesIsrCacheKey } from "vinext/internal/server/isr-cache";
7
7
  import { extractVinextNextDataJson } from "vinext/internal/client/vinext-next-data";
8
8
  import { normalizePregeneratedPathname } from "vinext/internal/server/pregenerated-concrete-paths";
9
9
  import { getAppRouteOutputPath, getOutputPath, getRscOutputPath } from "vinext/internal/utils/prerender-output-paths";
@@ -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}`);
@@ -66,8 +67,9 @@ function finalizeStaticAssetsPrerenderOutput(root, options = {}) {
66
67
  const pathname = route.path ?? route.route;
67
68
  const cachePathname = normalizePregeneratedPathname(pathname);
68
69
  if (route.router === "app") {
69
- count += Number(writeCacheAsset(outputDir, index, appIsrCacheKey(cachePathname, "html", manifest.buildId), "html", path.join(prerenderDir, getOutputPath(pathname, manifest.trailingSlash ?? false)), route));
70
- count += Number(writeCacheAsset(outputDir, index, appIsrCacheKey(cachePathname, "rsc", manifest.buildId), "rsc", path.join(prerenderDir, getRscOutputPath(pathname)), route));
70
+ const appKey = isFallbackShellArtifactPath(pathname, route) ? appIsrFallbackShellKey : appIsrCacheKey;
71
+ count += Number(writeCacheAsset(outputDir, index, appKey(cachePathname, "html", manifest.buildId), "html", path.join(prerenderDir, getOutputPath(pathname, manifest.trailingSlash ?? false)), route));
72
+ count += Number(writeCacheAsset(outputDir, index, appKey(cachePathname, "rsc", manifest.buildId), "rsc", path.join(prerenderDir, getRscOutputPath(pathname)), route));
71
73
  } else if (route.router === "pages") {
72
74
  const sourcePath = path.join(prerenderDir, getOutputPath(pathname, manifest.trailingSlash ?? false));
73
75
  if (!fs.existsSync(sourcePath)) continue;
@@ -9,9 +9,11 @@ export declare class StaticAssetsCacheAdapter implements CdnCacheAdapter {
9
9
  readonly assets: AssetFetcher;
10
10
  readonly hasPrerenderedPages = true;
11
11
  readonly ownsBackgroundRevalidation = false;
12
- private indexPromise;
12
+ private index;
13
+ private pendingIndex;
13
14
  constructor(assets: AssetFetcher);
14
15
  private loadIndex;
16
+ private fetchIndex;
15
17
  get(key: string): Promise<CacheHandlerValue | null>;
16
18
  set(_key: string, _data: IncrementalCacheValue | null, _ctx?: Record<string, unknown>): Promise<void>;
17
19
  buildResponseHeaders(input: CdnCacheableHeaderInput): CdnResponseHeaders;
@@ -1,5 +1,6 @@
1
1
  import { STATIC_ASSET_CACHE_PATH } from "./static-assets-adapter.shared.js";
2
2
  import { DefaultCdnCacheAdapter } from "vinext/shims/cdn-cache";
3
+ import { getRequestExecutionContext } from "vinext/shims/request-context";
3
4
  //#region src/cache/static-assets-adapter.runtime.ts
4
5
  async function cacheAssetId(key) {
5
6
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(key));
@@ -12,12 +13,32 @@ var StaticAssetsCacheAdapter = class {
12
13
  assets;
13
14
  hasPrerenderedPages = true;
14
15
  ownsBackgroundRevalidation = false;
15
- indexPromise;
16
+ index;
17
+ pendingIndex;
16
18
  constructor(assets) {
17
19
  this.assets = assets;
18
20
  }
19
21
  loadIndex() {
20
- return this.indexPromise ??= this.assets.fetch(`https://vinext.invalid${STATIC_ASSET_CACHE_PATH}/index.json`).then(async (response) => response.ok ? await response.json() : null).then((value) => value !== null && typeof value === "object" ? value : null);
22
+ if (this.index !== void 0) return Promise.resolve(this.index);
23
+ if (this.pendingIndex) return this.pendingIndex;
24
+ const ctx = getRequestExecutionContext();
25
+ if (!ctx) return this.fetchIndex();
26
+ const pending = this.fetchIndex().finally(() => {
27
+ if (this.pendingIndex === pending) this.pendingIndex = void 0;
28
+ });
29
+ this.pendingIndex = pending;
30
+ ctx.waitUntil(pending.catch(() => {}));
31
+ return pending;
32
+ }
33
+ async fetchIndex() {
34
+ const response = await this.assets.fetch(`https://vinext.invalid${STATIC_ASSET_CACHE_PATH}/index.json`);
35
+ if (!response.ok) {
36
+ response.body?.cancel().catch(() => {});
37
+ if (response.status === 404) this.index = null;
38
+ return null;
39
+ }
40
+ const value = await response.json();
41
+ return this.index = value !== null && typeof value === "object" ? value : null;
21
42
  }
22
43
  async get(key) {
23
44
  const id = await cacheAssetId(key);
@@ -56,10 +77,10 @@ var StaticAssetsCacheAdapter = class {
56
77
  kind: "APP_PAGE",
57
78
  html: "",
58
79
  rscData: await bodyResponse.arrayBuffer(),
59
- headers: void 0,
80
+ headers: metadata.headers,
60
81
  postponed: void 0,
61
82
  prerendered: true,
62
- status: void 0
83
+ status: metadata.status
63
84
  };
64
85
  else value = {
65
86
  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 };
@@ -43,7 +43,6 @@ export declare const DEFAULT_STAGED_READINESS_INTERVAL_MS = 1000;
43
43
  export declare const DEFAULT_STAGED_READINESS_PHASE_TIMEOUT_MS = 120000;
44
44
  export type PrerenderCdnWarmOptions = Omit<CdnWarmOptions, "paths"> & {
45
45
  root: string;
46
- includeFallbackShells?: boolean;
47
46
  /** Use the manifest build identity unless the configured adapter cannot expose it. */
48
47
  validateBuildIdentity?: boolean;
49
48
  };
@@ -92,13 +91,11 @@ export type PrerenderWarmPlan = {
92
91
  };
93
92
  type PrerenderWarmPlanOptions = {
94
93
  includeCanonicalRsc?: boolean;
95
- includeFallbackShells?: boolean;
96
94
  strict?: boolean;
97
95
  };
98
96
  export declare function createPrerenderWarmPlan(root: string, manifest: PrerenderPathManifest, options?: PrerenderWarmPlanOptions): PrerenderWarmPlan;
99
97
  export declare function readPrerenderWarmPlan(root: string, options?: PrerenderWarmPlanOptions): PrerenderWarmPlan;
100
98
  export declare function readPrerenderWarmPaths(root: string, options?: {
101
- includeFallbackShells?: boolean;
102
99
  strict?: boolean;
103
100
  }): string[];
104
101
  export declare function getWarmPathsFromPrerenderManifest(manifest: PrerenderManifest, options?: PrerenderedPathSelectionOptions): string[];
package/dist/cdn-warm.js CHANGED
@@ -6,7 +6,7 @@ import { randomUUID } from "node:crypto";
6
6
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
7
7
  import { VINEXT_CACHE_HEADER, VINEXT_PRERENDER_READINESS_HEADER, VINEXT_PRERENDER_READINESS_PATH, VINEXT_PRERENDER_SECRET_HEADER } from "vinext/internal/server/headers";
8
8
  import { PRERENDER_PATHS_MANIFEST } from "vinext/internal/build/prerender-paths";
9
- import { getPrerenderedConcretePaths, readPrerenderManifest } from "vinext/internal/server/prerender-manifest";
9
+ import { getPrerenderedConcretePaths } from "vinext/internal/server/prerender-manifest";
10
10
  import { VINEXT_RSC_BUILD_ID_HEADER, VINEXT_RSC_CONTENT_TYPE, VINEXT_RSC_VARY_HEADER as VINEXT_RSC_VARY_HEADER$1, createCanonicalLoadingShellRscRequestHeaders, createCanonicalRscRequestHeaders, createCanonicalRscRequestUrl, createRscRequestUrl } from "vinext/internal/server/app-rsc-cache-busting";
11
11
  import { isNonCacheableCacheControl } from "vinext/shims/cdn-cache";
12
12
  import { normalizePathTrailingSlash } from "vinext/shims/url-utils";
@@ -73,17 +73,7 @@ function createPrerenderWarmPlan(root, manifest, options) {
73
73
  const supportsCanonicalRsc = (manifest.responseVary === "verbatim" || options?.includeCanonicalRsc === true) && manifest.rscPaths !== void 0 && manifest.rscBuildId !== void 0;
74
74
  const applyConfig = (pathname) => applyWarmPathConfig(pathname, manifest);
75
75
  const routePatterns = manifest.routePatterns ? Object.fromEntries(Object.entries(manifest.routePatterns).map(([pathname, route]) => [pathname.includes("/_next/data/") ? pathname : applyConfig(pathname), route])) : void 0;
76
- let htmlPaths = pathPlan.paths;
77
- if (options?.includeFallbackShells === true) {
78
- const prerenderManifest = readPrerenderManifest(path.join(root, "dist", "server", "vinext-prerender.json"));
79
- if (!prerenderManifest) console.warn("[vinext] CDN warmup fallback shells requested, but prerender manifest not found; warming build-discovered paths only.");
80
- else if (!prerenderManifest.buildId || prerenderManifest.buildId !== manifest.buildId) {
81
- const message = "[vinext] CDN warmup skipped: prerender manifest buildId does not match dist/server/BUILD_ID.";
82
- if (options.strict) throw new Error(message);
83
- console.warn(message);
84
- htmlPaths = [];
85
- } else htmlPaths = getPrerenderedConcretePaths(prerenderManifest, { includeFallbackShells: true }).filter((pathname) => pathname.startsWith("/")).filter((pathname) => !manifest.excludedWarmPaths?.includes(pathname)).map(applyConfig);
86
- }
76
+ const htmlPaths = pathPlan.paths;
87
77
  return {
88
78
  ...manifest.appPaths ? { appPaths: manifest.appPaths.map(applyConfig) } : {},
89
79
  buildId: manifest.buildId,
@@ -482,7 +472,7 @@ function validateBuildIdentity(response, expectedBuildId) {
482
472
  return null;
483
473
  }
484
474
  function isExpectedTerminalStatus(status) {
485
- return status >= 300 && status < 400 || status === 404;
475
+ return status >= 300 && status < 400 || status === 401 || status === 403 || status === 404;
486
476
  }
487
477
  function validateRscWarmResponse(response, expectedBuildId, expectedRscBuildId, requireCacheHit = false, statusSource = "cloudflare") {
488
478
  const buildIdentityValidation = validateBuildIdentity(response, expectedBuildId);
@@ -959,10 +949,7 @@ async function warmCdnCache(options) {
959
949
  return result;
960
950
  }
961
951
  async function warmCdnCacheFromPrerender(options) {
962
- const plan = readPrerenderWarmPlan(options.root, {
963
- includeFallbackShells: options.includeFallbackShells,
964
- strict: options.strict
965
- });
952
+ const plan = readPrerenderWarmPlan(options.root, { strict: options.strict });
966
953
  const warmPlan = {
967
954
  deploymentId: plan.deploymentId,
968
955
  loadingShellPaths: plan.loadingShellPaths,
package/dist/cli.js CHANGED
@@ -62,7 +62,6 @@ async function deployCommand() {
62
62
  dangerouslyPromoteOnCdnWarmError: parsed.dangerouslyPromoteOnCdnWarmError,
63
63
  warmCdnPromote: parsed.warmCdnPromote,
64
64
  warmCdnPromotionDelay: parsed.warmCdnPromotionDelay,
65
- warmCdnIncludeFallbacks: parsed.warmCdnIncludeFallbacks,
66
65
  experimentalTPR: parsed.experimentalTPR,
67
66
  tprCoverage: parsed.tprCoverage,
68
67
  tprLimit: parsed.tprLimit,
@@ -67,8 +67,6 @@ function formatDeployHelp() {
67
67
  verified (never bypasses --warm-cache-certify)
68
68
  --warm-cache-promotion-delay <ms>
69
69
  Delay before promotion after warmup (default: 15000)
70
- --warm-cache-include-fallbacks
71
- Also warm PPR fallback-shell placeholder paths
72
70
  -h, --help Show this help
73
71
 
74
72
  Traffic-aware warming:
package/dist/deploy.d.ts CHANGED
@@ -62,8 +62,6 @@ export type DeployOptions = {
62
62
  warmCdnPromote?: boolean;
63
63
  /** Delay between successful warmup and promotion in milliseconds */
64
64
  warmCdnPromotionDelay?: number;
65
- /** Include PPR fallback-shell placeholder paths during CDN warmup */
66
- warmCdnIncludeFallbacks?: boolean;
67
65
  /** Select CDN pre-warm routes using traffic analytics */
68
66
  experimentalTPR?: boolean;
69
67
  /** TPR: traffic coverage percentage target (0–100, default: 90) */
@@ -104,7 +102,6 @@ export declare function parseDeployArgs(args: string[]): {
104
102
  dangerouslyPromoteOnCdnWarmError: boolean;
105
103
  warmCdnPromote: boolean;
106
104
  warmCdnPromotionDelay: number | undefined;
107
- warmCdnIncludeFallbacks: boolean;
108
105
  experimentalTPR: boolean;
109
106
  tprCoverage: number | undefined;
110
107
  tprLimit: number | undefined;
package/dist/deploy.js CHANGED
@@ -228,7 +228,7 @@ function parseDeployArgs(args) {
228
228
  values["warm-cache-readiness-probe-delay"] ??= values["warm-cdn-readiness-probe-delay"];
229
229
  values["warm-cache-no-promote"] ||= values["warm-cdn-no-promote"];
230
230
  values["warm-cache-promotion-delay"] ??= values["warm-cdn-promotion-delay"];
231
- values["warm-cache-include-fallbacks"] ||= values["warm-cdn-include-fallbacks"];
231
+ if (values["warm-cache-include-fallbacks"] || values["warm-cdn-include-fallbacks"]) console.warn("[vinext] --warm-cache-include-fallbacks is deprecated and has no effect.");
232
232
  values["traffic-aware-warm-cache"] ||= values["experimental-traffic-aware-warm-cache"];
233
233
  const trafficAwareWarmCache = values["traffic-aware-warm-cache"] || values["experimental-tpr"];
234
234
  const warming = values["warm-cache"] || trafficAwareWarmCache;
@@ -271,7 +271,6 @@ function parseDeployArgs(args) {
271
271
  dangerouslyPromoteOnCdnWarmError: values["dangerously-promote-on-warm-cache-error"],
272
272
  warmCdnPromote: !values["no-promote"] && !values["warm-cache-no-promote"],
273
273
  warmCdnPromotionDelay: values["warm-cache-promotion-delay"] === void 0 ? void 0 : validatePromotionDelay(parseNonNegativeIntegerArg(values["warm-cache-promotion-delay"], "--warm-cache-promotion-delay"), values["warm-cache-promotion-delay"]),
274
- warmCdnIncludeFallbacks: values["warm-cache-include-fallbacks"],
275
274
  experimentalTPR: trafficAwareWarmCache,
276
275
  tprCoverage: parseIntArg("traffic-aware-coverage", values["traffic-aware-coverage"] ?? values["tpr-coverage"]),
277
276
  tprLimit: parseIntArg("traffic-aware-limit", values["traffic-aware-limit"] ?? values["tpr-limit"]),
@@ -1328,7 +1327,6 @@ async function deploy(options) {
1328
1327
  };
1329
1328
  return createPrerenderWarmPlan(root, discovery, {
1330
1329
  includeCanonicalRsc: hasCanonicalRscWarmup,
1331
- includeFallbackShells: options.warmCdnIncludeFallbacks,
1332
1330
  strict: options.warmCdnCertify === true || !options.dangerouslyPromoteOnCdnWarmError
1333
1331
  });
1334
1332
  },
@@ -3,7 +3,9 @@ export declare function parseWorkerDeploymentUrl(output: string): string | null;
3
3
  /**
4
4
  * Parse the concrete hostname that Wrangler reports after applying Worker
5
5
  * triggers. Catch-all routes are valid warmup targets even though they are not
6
- * canonical deployment URLs for general CLI reporting.
6
+ * canonical deployment URLs for general CLI reporting. Warmup requests carry the
7
+ * prerender secret, so an `http://`-only route (which Cloudflare does not match
8
+ * for HTTPS requests) is never used as a target.
7
9
  */
8
10
  export declare function parseCdnWarmupDeploymentUrl(output: string): string | null;
9
11
  //#endregion
@@ -6,7 +6,9 @@ function parseWorkerDeploymentUrl(output) {
6
6
  /**
7
7
  * Parse the concrete hostname that Wrangler reports after applying Worker
8
8
  * triggers. Catch-all routes are valid warmup targets even though they are not
9
- * canonical deployment URLs for general CLI reporting.
9
+ * canonical deployment URLs for general CLI reporting. Warmup requests carry the
10
+ * prerender secret, so an `http://`-only route (which Cloudflare does not match
11
+ * for HTTPS requests) is never used as a target.
10
12
  */
11
13
  function parseCdnWarmupDeploymentUrl(output) {
12
14
  return parseCustomDomainUrl(output) ?? parseCatchAllWorkerRouteOrigin(output) ?? parseWorkersDevUrl(output);
@@ -17,7 +19,7 @@ function parseCatchAllWorkerRouteOrigin(output) {
17
19
  if (!route || !route.endsWith("/*")) continue;
18
20
  try {
19
21
  const url = new URL(route.includes("://") ? route : `https://${route}`);
20
- if (url.protocol !== "https:" && url.protocol !== "http:" || url.hostname.includes("*") || url.username !== "" || url.password !== "" || url.port !== "" || url.pathname !== "/*" || url.search !== "" || url.hash !== "") continue;
22
+ if (url.protocol !== "https:" || url.hostname.includes("*") || url.username !== "" || url.password !== "" || url.port !== "" || url.pathname !== "/*" || url.search !== "" || url.hash !== "") continue;
21
23
  return url.origin;
22
24
  } catch {
23
25
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vinext/cloudflare",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
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.1"
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.2.0"
61
61
  },
62
62
  "peerDependencies": {
63
63
  "@cloudflare/vite-plugin": "*",
64
64
  "cf": "*",
65
- "vinext": "^1.0.1"
65
+ "vinext": "^1.2.0"
66
66
  },
67
67
  "peerDependenciesMeta": {
68
68
  "@cloudflare/vite-plugin": {