@vinext/cloudflare 1.1.0 → 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.
@@ -2,7 +2,7 @@ import { VINEXT_CDN_BUILD_ID_HEADER, getVinextCdnBuildIdentity } from "./cdn-bui
2
2
  import { SHARED_RESPONSE_STAGE_HEADER, finalizeGatewayResponse } from "./browser-cache-policy.js";
3
3
  import { hasCacheMethod, invalidateOrPurge } from "./workers-cache-invalidation.js";
4
4
  import { VINEXT_PRERENDER_READINESS_PATH, VINEXT_RSC_VARY_HEADER } from "vinext/internal/server/headers";
5
- import { isNonCacheableCacheControl } from "vinext/shims/cdn-cache";
5
+ import { isNonCacheableCacheControl, splitCacheControlDirectives } from "vinext/shims/cdn-cache";
6
6
  import { WorkerEntrypoint } from "cloudflare:workers";
7
7
  import "vinext/internal/server/cloudflare-workers-tracing";
8
8
  import { loadVinextRequestStage } from "vinext/server/request-stage";
@@ -183,18 +183,63 @@ function isAppPageDocumentInvocation(invocation) {
183
183
  const props = invocation.props;
184
184
  return props !== null && typeof props === "object" && Reflect.get(props, "kind") === "app-page" && Reflect.get(props, "isRscRequest") === false;
185
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
+ }
186
200
  /**
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.
201
+ * No client waits on a deployment warm-up or on a revalidation, as with the
202
+ * Response Store's.
190
203
  */
191
204
  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");
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");
193
238
  }
194
239
  /** Give a cacheable document a validator, so Workers Cache revalidates it conditionally. */
195
240
  function withRevalidationValidator(response) {
196
241
  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");
242
+ const policy = selectedCachePolicy(response);
198
243
  if (policy === null || isNonCacheableCacheControl(policy)) return response;
199
244
  const headers = new Headers(response.headers);
200
245
  headers.set("Last-Modified", (/* @__PURE__ */ new Date()).toUTCString());
@@ -319,14 +364,21 @@ var VinextCachedResponse = class extends WorkerEntrypoint {
319
364
  if (invocation.expectedResponseStageBuildIdentity !== void 0 && invocation.expectedResponseStageBuildIdentity !== getVinextCdnBuildIdentity()) return stampResponseStageBuildIdentity(responseStageUnavailable());
320
365
  const restored = restoreResponseStageRequest(request, invocation.requestUrl, invocation.requestMethod);
321
366
  const isAppPageDocument = isAppPageDocumentInvocation(invocation);
367
+ const isPagesPage = isPagesPageInvocation(invocation);
322
368
  const response = await invokeResponseStage(restored.request, this.env, context, isAppPageDocument && rendersWholeDocument(request) ? {
323
369
  ...invocation,
324
370
  options: {
325
371
  ...invocation.options,
326
372
  renderWholeDocument: true
327
373
  }
374
+ } : isPagesPage && revalidatesStoredEntry(request) ? {
375
+ ...invocation,
376
+ options: {
377
+ ...invocation.options,
378
+ regeneratesStoredEntry: true
379
+ }
328
380
  } : invocation);
329
- return stampResponseStageBuildIdentity(restored.didAccessRequestCf() || hasTaggedCustomVary(response) ? preventResponseCaching(response) : isAppPageDocument ? withRevalidationValidator(response) : response);
381
+ return stampResponseStageBuildIdentity(restored.didAccessRequestCf() || hasTaggedCustomVary(response) ? preventResponseCaching(response) : isAppPageDocument ? withRevalidationValidator(response) : isPagesPage ? withPagesRevalidationValidator(response) : response);
330
382
  }
331
383
  async purge(options) {
332
384
  const cache = Reflect.get(this.ctx, "cache");
@@ -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" }),
@@ -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) {
@@ -2,10 +2,11 @@ import { SHARED_RESPONSE_STAGE_HEADER, finalizeGatewayResponse } from "./browser
2
2
  import { captureResponseStoreDataRegeneration, runWithResponseStoreInvocation, setResponseStore } from "./response-store-data.runtime.js";
3
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) {
@@ -96,6 +97,7 @@ function createVinextResponseStoreOptions(configuration) {
96
97
  const invocation = parseInvocation(input.args.at(-1));
97
98
  const response = await invokeResponseStage(restoreRequest(invocation), invocation.props, env, ctx, {
98
99
  cache: "shared",
100
+ regeneratesStoredEntry: true,
99
101
  renderWholeDocument: true
100
102
  });
101
103
  if (!isCacheable(response)) {
@@ -160,7 +162,59 @@ function isStoredErrorStatus(status) {
160
162
  }
161
163
  function isCacheable(response) {
162
164
  const policy = response.headers.get("Cloudflare-CDN-Cache-Control") ?? response.headers.get("CDN-Cache-Control") ?? response.headers.get("Cache-Control");
163
- return response.status >= 200 && (response.status < 400 || isStoredErrorStatus(response.status)) && 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;
164
218
  }
165
219
  async function readStoredResponse(key) {
166
220
  try {
@@ -235,11 +289,11 @@ const handler = { async fetch(request, env, ctx) {
235
289
  const key = await cacheRequest(invocation);
236
290
  const stored = await readStoredResponse(key);
237
291
  if (stored) {
238
- if (!rscKey) return publicResponse(stored, "HIT", props);
292
+ if (!rscKey) return publicResponse(storedForRequest(stored, stageRequest, props), "HIT", props);
239
293
  const storedRsc = await readStoredResponse(rscKey);
240
294
  if (storedRsc) {
241
295
  storedRsc.body?.cancel().catch(() => {});
242
- return publicResponse(stored, "HIT", props);
296
+ return publicResponse(storedForRequest(stored, stageRequest, props), "HIT", props);
243
297
  }
244
298
  stored.body?.cancel().catch(() => {});
245
299
  }
@@ -252,6 +306,7 @@ const handler = { async fetch(request, env, ctx) {
252
306
  replayable: isReplayableInvocation(stageRequest, props),
253
307
  serialized: options.cacheIdentity ? serializeInvocation(stageRequest, props) : serializedInvocation
254
308
  });
309
+ const foreground = (response) => capture.notModified ? notModifiedResponse(response) : response;
255
310
  if (capture.admittedResponse) {
256
311
  ctx.waitUntil(capture.admittedResponse.then(async (admitted) => {
257
312
  if (!isCacheable(admitted)) {
@@ -271,17 +326,23 @@ const handler = { async fetch(request, env, ctx) {
271
326
  error: error instanceof Error ? error.message : String(error)
272
327
  }));
273
328
  }));
274
- 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);
275
336
  }
276
337
  if (!isCacheable(rendered)) {
277
338
  capture?.rscData?.catch(() => {});
278
- return publicResponse(rendered, "BYPASS", props);
339
+ return publicResponse(foreground(rendered), "BYPASS", props);
279
340
  }
280
341
  if (rscSeed && !capture?.rscData) {
281
342
  await rendered.body?.cancel();
282
343
  throw new Error("Vinext response-store warmup did not capture the App page RSC payload");
283
344
  }
284
- const [foreground, cacheBody] = rendered.body ? rendered.body.tee() : [null, null];
345
+ const [foregroundBody, cacheBody] = rendered.body ? rendered.body.tee() : [null, null];
285
346
  const cacheResponse = withoutRequestScopedHeaders(new Response(cacheBody, rendered), props);
286
347
  try {
287
348
  await responseStore.put(key, cacheResponse, {
@@ -324,7 +385,7 @@ const handler = { async fetch(request, env, ctx) {
324
385
  }
325
386
  });
326
387
  }
327
- return publicResponse(new Response(foreground, rendered), "MISS", props);
388
+ return publicResponse(foreground(new Response(foregroundBody, rendered)), "MISS", props);
328
389
  };
329
390
  const { handleRequestStage } = await loadVinextRequestStage();
330
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. */
@@ -33,6 +33,13 @@ function captureResponseStoreRscData(rscData) {
33
33
  if (capture?.captureRscData) capture.rscData = rscData;
34
34
  else rscData.catch(() => {});
35
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
+ }
36
43
  /** Stream the foreground body while retaining an independent admission branch. */
37
44
  function deferResponseStoreAdmission(response, complete) {
38
45
  const capture = invocationStorage.getStore()?.capture;
@@ -328,4 +335,4 @@ function createResponseStoreDataCacheAdapter() {
328
335
  return new WorkersResponseStoreCacheHandler();
329
336
  }
330
337
  //#endregion
331
- 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";
@@ -67,8 +67,9 @@ function finalizeStaticAssetsPrerenderOutput(root, options = {}) {
67
67
  const pathname = route.path ?? route.route;
68
68
  const cachePathname = normalizePregeneratedPathname(pathname);
69
69
  if (route.router === "app") {
70
- count += Number(writeCacheAsset(outputDir, index, appIsrCacheKey(cachePathname, "html", manifest.buildId), "html", path.join(prerenderDir, getOutputPath(pathname, manifest.trailingSlash ?? false)), route));
71
- 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));
72
73
  } else if (route.router === "pages") {
73
74
  const sourcePath = path.join(prerenderDir, getOutputPath(pathname, manifest.trailingSlash ?? false));
74
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);
@@ -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,
@@ -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.1.0",
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,7 +48,7 @@
48
48
  }
49
49
  },
50
50
  "dependencies": {
51
- "@cloudflare/workers-response-store": "1.1.0"
51
+ "@cloudflare/workers-response-store": "1.1.1"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@types/node": "^25.9.2",
@@ -57,12 +57,12 @@
57
57
  "vite": "npm:@voidzero-dev/vite-plus-core@1.1.0",
58
58
  "vite-plus": "1.1.0",
59
59
  "vitest": "5.0.3",
60
- "vinext": "1.1.0"
60
+ "vinext": "1.2.0"
61
61
  },
62
62
  "peerDependencies": {
63
63
  "@cloudflare/vite-plugin": "*",
64
64
  "cf": "*",
65
- "vinext": "^1.1.0"
65
+ "vinext": "^1.2.0"
66
66
  },
67
67
  "peerDependenciesMeta": {
68
68
  "@cloudflare/vite-plugin": {