@vinext/cloudflare 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ //#region src/cache/app-page-parts.d.ts
2
+ /**
3
+ * Whether the response-stage props select an App page's own document or RSC
4
+ * payload, the two parts Next.js keeps in one entry, rather than a variant.
5
+ */
6
+ export declare function isCanonicalAppPage(responseStageProps: unknown, isRscRequest: boolean): boolean;
7
+ /**
8
+ * Whether a canonical RSC request has a document part. A `.rsc` path reaches
9
+ * its page's HTML path only through the app's trailingSlash policy, and
10
+ * vinext's client never sends one, so it stores its payload alone.
11
+ */
12
+ export declare function hasDocumentPart(rscRequestUrl: string): boolean;
13
+ //#endregion
@@ -0,0 +1,18 @@
1
+ //#region src/cache/app-page-parts.ts
2
+ /**
3
+ * Whether the response-stage props select an App page's own document or RSC
4
+ * payload, the two parts Next.js keeps in one entry, rather than a variant.
5
+ */
6
+ function isCanonicalAppPage(responseStageProps, isRscRequest) {
7
+ return responseStageProps !== null && typeof responseStageProps === "object" && Reflect.get(responseStageProps, "kind") === "app-page" && Reflect.get(responseStageProps, "isRscRequest") === isRscRequest && (Reflect.get(responseStageProps, "matchKind") === "request" || Reflect.get(responseStageProps, "matchKind") === "resolved") && (!isRscRequest || Reflect.get(responseStageProps, "renderMode") === "navigation") && Reflect.get(responseStageProps, "interceptionContext") === null && Reflect.get(responseStageProps, "interceptionId") === null && Reflect.get(responseStageProps, "mountedSlotsHeader") === null;
8
+ }
9
+ /**
10
+ * Whether a canonical RSC request has a document part. A `.rsc` path reaches
11
+ * its page's HTML path only through the app's trailingSlash policy, and
12
+ * vinext's client never sends one, so it stores its payload alone.
13
+ */
14
+ function hasDocumentPart(rscRequestUrl) {
15
+ return !new URL(rscRequestUrl).pathname.endsWith(".rsc");
16
+ }
17
+ //#endregion
18
+ export { hasDocumentPart, isCanonicalAppPage };
@@ -28,4 +28,5 @@ export declare function finalizeCdnAdapterBuildOutput({ root, outDir, isPrimaryS
28
28
  binding: string;
29
29
  bindingIsExplicit: boolean;
30
30
  }): Promise<void>;
31
- //#endregion
31
+ //#endregion
32
+ export {};
@@ -13,6 +13,7 @@ export declare class CloudflareCdnCacheAdapter implements CdnCacheAdapter {
13
13
  private readonly versionMetadata?;
14
14
  private readonly versionMetadataBinding;
15
15
  readonly requiresCompletedResponseAdmission = true;
16
+ readonly responseStageCacheIdentity: "host-free";
16
17
  readonly responseVary: "verbatim";
17
18
  readonly responsePolicy: {
18
19
  isHeader(name: string): boolean;
@@ -150,6 +150,7 @@ var CloudflareCdnCacheAdapter = class {
150
150
  versionMetadata;
151
151
  versionMetadataBinding;
152
152
  requiresCompletedResponseAdmission = true;
153
+ responseStageCacheIdentity = "host-free";
153
154
  responseVary = "verbatim";
154
155
  responsePolicy = {
155
156
  isHeader(name) {
@@ -1,7 +1,9 @@
1
1
  import { VINEXT_CDN_BUILD_ID_HEADER, getVinextCdnBuildIdentity } from "./cdn-build-id.js";
2
+ import { hasDocumentPart, isCanonicalAppPage } from "./app-page-parts.js";
2
3
  import { SHARED_RESPONSE_STAGE_HEADER, finalizeGatewayResponse } from "./browser-cache-policy.js";
3
4
  import { hasCacheMethod, invalidateOrPurge } from "./workers-cache-invalidation.js";
4
5
  import { VINEXT_PRERENDER_READINESS_PATH, VINEXT_RSC_VARY_HEADER } from "vinext/internal/server/headers";
6
+ import { createCanonicalRscRequestHeaders, createCanonicalRscRequestUrl, stripRscCacheBustingSearchParam } from "vinext/internal/server/app-rsc-cache-busting";
5
7
  import { isNonCacheableCacheControl, splitCacheControlDirectives } from "vinext/shims/cdn-cache";
6
8
  import { WorkerEntrypoint } from "cloudflare:workers";
7
9
  import "vinext/internal/server/cloudflare-workers-tracing";
@@ -15,11 +17,29 @@ const AUTHORIZATION_TRANSPORT_HEADER = "x-vinext-internal-authorization";
15
17
  const REQUEST_CACHE_CONTROL_TRANSPORT_HEADER = "x-vinext-internal-request-cache-control";
16
18
  const REQUEST_CF_TRANSPORT_HEADER = "x-vinext-internal-request-cf";
17
19
  const REQUEST_PRAGMA_TRANSPORT_HEADER = "x-vinext-internal-request-pragma";
20
+ const RENDER_WHOLE_DOCUMENT_TRANSPORT_HEADER = "x-vinext-internal-render-whole-document";
21
+ const DISPATCH_TRANSPORT_HEADER = "x-vinext-internal-dispatch";
22
+ /** The stage props a host-free cache identity replaces. */
23
+ const HOST_BEARING_PROPS = /* @__PURE__ */ new Set([
24
+ "requestHost",
25
+ "requestOrigin",
26
+ "requestUrl"
27
+ ]);
18
28
  const WARMUP_USER_AGENT = "vinext-cloudflare-cdn-warm";
19
29
  const RESPONSE_STAGE_WIRE_CACHE = {
20
30
  bypass: "vinext-cloudflare-v1:bypass",
21
31
  shared: "vinext-cloudflare-v1:shared"
22
32
  };
33
+ /**
34
+ * The statuses of a part Workers Cache just rendered for its entry, inline or
35
+ * as a background refresh of a stale one.
36
+ * https://developers.cloudflare.com/workers/cache/debugging/
37
+ */
38
+ const RENDERED_ENTRY_CACHE_STATUSES = /* @__PURE__ */ new Set([
39
+ "EXPIRED",
40
+ "MISS",
41
+ "UPDATING"
42
+ ]);
23
43
  const FRAMEWORK_RESPONSE_VARY_FIELDS = new Set(VINEXT_RSC_VARY_HEADER.split(",").map((name) => name.trim().toLowerCase()));
24
44
  function isResponseStageReadinessRequest(request) {
25
45
  return request.url.includes(VINEXT_PRERENDER_READINESS_PATH) && new URL(request.url).pathname === VINEXT_PRERENDER_READINESS_PATH;
@@ -63,20 +83,25 @@ function validateResponseStageBuildIdentity(response) {
63
83
  return responseStageUnavailable();
64
84
  }
65
85
  function stripUntrustedTransportHeaders(request) {
66
- if (!request.headers.has(AUTHORIZATION_TRANSPORT_HEADER) && !request.headers.has(REQUEST_CACHE_CONTROL_TRANSPORT_HEADER) && !request.headers.has(REQUEST_CF_TRANSPORT_HEADER) && !request.headers.has(REQUEST_PRAGMA_TRANSPORT_HEADER)) return request;
86
+ if (!request.headers.has(AUTHORIZATION_TRANSPORT_HEADER) && !request.headers.has(REQUEST_CACHE_CONTROL_TRANSPORT_HEADER) && !request.headers.has(REQUEST_CF_TRANSPORT_HEADER) && !request.headers.has(REQUEST_PRAGMA_TRANSPORT_HEADER) && !request.headers.has(RENDER_WHOLE_DOCUMENT_TRANSPORT_HEADER) && !request.headers.has(DISPATCH_TRANSPORT_HEADER)) return request;
67
87
  const headers = new Headers(request.headers);
68
88
  headers.delete(AUTHORIZATION_TRANSPORT_HEADER);
69
89
  headers.delete(REQUEST_CACHE_CONTROL_TRANSPORT_HEADER);
70
90
  headers.delete(REQUEST_CF_TRANSPORT_HEADER);
71
91
  headers.delete(REQUEST_PRAGMA_TRANSPORT_HEADER);
72
- const sanitized = new Request(request, { headers });
73
- const requestCf = Reflect.get(request, "cf");
74
- if (requestCf !== void 0) Object.defineProperty(sanitized, "cf", {
92
+ headers.delete(RENDER_WHOLE_DOCUMENT_TRANSPORT_HEADER);
93
+ headers.delete(DISPATCH_TRANSPORT_HEADER);
94
+ return withRequestCf(new Request(request, { headers }), request);
95
+ }
96
+ /** Give a request built from another the platform's `request.cf` of that one. */
97
+ function withRequestCf(request, source) {
98
+ const requestCf = Reflect.get(source, "cf");
99
+ if (requestCf !== void 0) Object.defineProperty(request, "cf", {
75
100
  configurable: true,
76
101
  enumerable: true,
77
102
  value: requestCf
78
103
  });
79
- return sanitized;
104
+ return request;
80
105
  }
81
106
  function withWorkerHostRuntime(context, env) {
82
107
  const candidateAssets = env && typeof env === "object" && Reflect.has(env, "ASSETS") ? Reflect.get(env, "ASSETS") : void 0;
@@ -100,7 +125,7 @@ function getResponseStageBinding(context, exportName, serializedInvocation) {
100
125
  const target = binding({ props: JSON.parse(serializedInvocation) });
101
126
  return hasFetch(target) ? target : null;
102
127
  }
103
- async function createCacheFacingRequest(request, serializedInvocation) {
128
+ async function createCacheFacingRequest(request, serializedInvocation, identityRequest, dispatchRestore) {
104
129
  const authorization = request.headers.get("Authorization");
105
130
  let serializedRequestCf = null;
106
131
  const requestCf = Reflect.get(request, "cf");
@@ -109,9 +134,9 @@ async function createCacheFacingRequest(request, serializedInvocation) {
109
134
  if (json !== void 0) serializedRequestCf = encodeURIComponent(json);
110
135
  } catch {}
111
136
  const authorizationIdentity = authorization === null ? "absent" : `present:${authorization.length}:${authorization}`;
112
- const frameworkVaryIdentity = JSON.stringify([...FRAMEWORK_RESPONSE_VARY_FIELDS].map((name) => [name, request.headers.get(name)]));
137
+ const frameworkVaryIdentity = JSON.stringify([...FRAMEWORK_RESPONSE_VARY_FIELDS].map((name) => [name, identityRequest.headers.get(name)]));
113
138
  const responseStageBuildIdentity = getVinextCdnBuildIdentity() ?? "";
114
- const bytes = new TextEncoder().encode(`${request.url}\0${serializedInvocation}\0${authorizationIdentity}\0${frameworkVaryIdentity}\0${responseStageBuildIdentity}`);
139
+ const bytes = new TextEncoder().encode(`${identityRequest.url}\0${serializedInvocation}\0${authorizationIdentity}\0${frameworkVaryIdentity}\0${responseStageBuildIdentity}`);
115
140
  const key = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((byte) => byte.toString(16).padStart(2, "0")).join("");
116
141
  const url = new URL(request.url);
117
142
  url.searchParams.set("__vinext_cache_key", key);
@@ -125,7 +150,9 @@ async function createCacheFacingRequest(request, serializedInvocation) {
125
150
  headers.delete(REQUEST_CACHE_CONTROL_TRANSPORT_HEADER);
126
151
  headers.delete(REQUEST_CF_TRANSPORT_HEADER);
127
152
  headers.delete(REQUEST_PRAGMA_TRANSPORT_HEADER);
153
+ headers.delete(DISPATCH_TRANSPORT_HEADER);
128
154
  if (authorization !== null) headers.set(AUTHORIZATION_TRANSPORT_HEADER, encodeURIComponent(authorization));
155
+ if (dispatchRestore !== null) headers.set(DISPATCH_TRANSPORT_HEADER, encodeURIComponent(JSON.stringify(dispatchRestore)));
129
156
  if (serializedRequestCf !== null) headers.set(REQUEST_CF_TRANSPORT_HEADER, serializedRequestCf);
130
157
  if (requestCacheControl !== null) headers.set(REQUEST_CACHE_CONTROL_TRANSPORT_HEADER, encodeURIComponent(requestCacheControl));
131
158
  if (requestPragma !== null) headers.set(REQUEST_PRAGMA_TRANSPORT_HEADER, encodeURIComponent(requestPragma));
@@ -149,6 +176,8 @@ function restoreResponseStageRequest(request, requestUrl, requestMethod) {
149
176
  headers.delete(REQUEST_CACHE_CONTROL_TRANSPORT_HEADER);
150
177
  headers.delete(REQUEST_CF_TRANSPORT_HEADER);
151
178
  headers.delete(REQUEST_PRAGMA_TRANSPORT_HEADER);
179
+ headers.delete(RENDER_WHOLE_DOCUMENT_TRANSPORT_HEADER);
180
+ headers.delete(DISPATCH_TRANSPORT_HEADER);
152
181
  if (serializedAuthorization !== null) try {
153
182
  headers.set("Authorization", decodeURIComponent(serializedAuthorization));
154
183
  } catch {}
@@ -199,11 +228,11 @@ function revalidatesStoredEntry(request) {
199
228
  return request.headers.has("If-None-Match") || request.headers.has("If-Modified-Since");
200
229
  }
201
230
  /**
202
- * No client waits on a deployment warm-up or on a revalidation, as with the
203
- * Response Store's.
231
+ * No client waits on a deployment warm-up, on a revalidation, as with the
232
+ * Response Store's, or on the gateway's fill of a page's document.
204
233
  */
205
234
  function rendersWholeDocument(request) {
206
- return request.headers.get("user-agent") === WARMUP_USER_AGENT || revalidatesStoredEntry(request);
235
+ return request.headers.get("user-agent") === WARMUP_USER_AGENT || request.headers.has(RENDER_WHOLE_DOCUMENT_TRANSPORT_HEADER) || revalidatesStoredEntry(request);
207
236
  }
208
237
  /**
209
238
  * A conditional revalidation of a Pages page skips getStaticPaths(), so only a
@@ -328,6 +357,8 @@ function getResponseStageInvocation(value, expectedCache) {
328
357
  } catch {
329
358
  return null;
330
359
  }
360
+ const restoresDispatch = Reflect.get(value, "restoresDispatch");
361
+ if (restoresDispatch !== void 0 && restoresDispatch !== true) return null;
331
362
  return {
332
363
  ...typeof expectedResponseStageBuildIdentity === "string" ? { expectedResponseStageBuildIdentity } : {},
333
364
  options: {
@@ -336,6 +367,117 @@ function getResponseStageInvocation(value, expectedCache) {
336
367
  },
337
368
  props: Reflect.get(value, "props"),
338
369
  requestMethod,
370
+ requestUrl,
371
+ ...restoresDispatch ? { restoresDispatch } : {}
372
+ };
373
+ }
374
+ /**
375
+ * The dispatch of the other part of the page a canonical App page part belongs
376
+ * to. Next.js keeps a page's document and RSC payload in one entry, so the two
377
+ * dispatches differ only in the RSC request selectors, the URL's `_rsc` and the
378
+ * request type in the props. The request keeps the client's other headers,
379
+ * minus those that would make the fetch conditional or partial. No client
380
+ * waits on a document fetched this way, so it is rendered whole, marked by a
381
+ * header that, unlike the props, does not key its entry.
382
+ */
383
+ function otherPagePartDispatch(request, props) {
384
+ const isRscRequest = isCanonicalAppPage(props, true);
385
+ if (!isRscRequest && !isCanonicalAppPage(props, false)) return null;
386
+ if (isRscRequest && !hasDocumentPart(request.url)) return null;
387
+ const headers = new Headers(request.headers);
388
+ for (const name of FRAMEWORK_RESPONSE_VARY_FIELDS) headers.delete(name);
389
+ for (const name of [
390
+ "If-Match",
391
+ "If-Modified-Since",
392
+ "If-None-Match",
393
+ "If-Range",
394
+ "If-Unmodified-Since",
395
+ "Range"
396
+ ]) headers.delete(name);
397
+ if (isRscRequest) {
398
+ const url = new URL(request.url);
399
+ stripRscCacheBustingSearchParam(url);
400
+ headers.delete("x-deployment-id");
401
+ headers.set("Accept", "text/html");
402
+ headers.set(RENDER_WHOLE_DOCUMENT_TRANSPORT_HEADER, "1");
403
+ return {
404
+ props: {
405
+ ...props,
406
+ isRscRequest: false
407
+ },
408
+ request: withRequestCf(new Request(url, { headers }), request)
409
+ };
410
+ }
411
+ for (const [name, value] of createCanonicalRscRequestHeaders()) headers.set(name, value);
412
+ return {
413
+ props: {
414
+ ...props,
415
+ isRscRequest: true,
416
+ renderMode: "navigation"
417
+ },
418
+ request: withRequestCf(new Request(new URL(createCanonicalRscRequestUrl(request.url), request.url), { headers }), request)
419
+ };
420
+ }
421
+ function isPlainObject(value) {
422
+ return value !== null && typeof value === "object" && !Array.isArray(value);
423
+ }
424
+ function pathAndSearch(url) {
425
+ return `${url.pathname}${url.search}`;
426
+ }
427
+ /**
428
+ * What a shared dispatch's host-free identity replaced: the request URL, and
429
+ * the top-level props that name the host. Null when the identity is anything
430
+ * else, so the dispatch keys itself, host and all.
431
+ */
432
+ function getDispatchRestore(request, props, identity) {
433
+ if (identity.request.method !== request.method || pathAndSearch(new URL(identity.request.url)) !== pathAndSearch(new URL(request.url))) return null;
434
+ const dispatched = JSON.parse(JSON.stringify(props) ?? "null");
435
+ const keyed = JSON.parse(JSON.stringify(identity.props) ?? "null");
436
+ if (!isPlainObject(dispatched) || !isPlainObject(keyed)) return null;
437
+ if (Object.keys(keyed).some((name) => !Object.hasOwn(dispatched, name))) return null;
438
+ const replaced = {};
439
+ for (const [name, value] of Object.entries(dispatched)) {
440
+ if (JSON.stringify(value) === JSON.stringify(keyed[name])) continue;
441
+ if (!HOST_BEARING_PROPS.has(name)) return null;
442
+ replaced[name] = value;
443
+ }
444
+ return {
445
+ props: replaced,
446
+ requestUrl: request.url
447
+ };
448
+ }
449
+ /**
450
+ * Rebuild the dispatch a host-free invocation keys from the restore header
451
+ * the gateway sent. Null when an identity invocation has no valid restore.
452
+ */
453
+ function restoreDispatchInvocation(request, invocation) {
454
+ if (!invocation.restoresDispatch) return invocation;
455
+ const serialized = request.headers.get(DISPATCH_TRANSPORT_HEADER);
456
+ if (serialized === null || !isPlainObject(invocation.props)) return null;
457
+ let restore;
458
+ try {
459
+ restore = JSON.parse(decodeURIComponent(serialized));
460
+ } catch {
461
+ return null;
462
+ }
463
+ if (!isPlainObject(restore) || !isPlainObject(restore.props)) return null;
464
+ if (Object.keys(restore.props).some((name) => !HOST_BEARING_PROPS.has(name))) return null;
465
+ const requestUrl = restore.requestUrl;
466
+ if (typeof requestUrl !== "string") return null;
467
+ let url;
468
+ try {
469
+ url = new URL(requestUrl);
470
+ } catch {
471
+ return null;
472
+ }
473
+ if (pathAndSearch(url) !== pathAndSearch(new URL(invocation.requestUrl))) return null;
474
+ const { restoresDispatch: _restoresDispatch, ...dispatch } = invocation;
475
+ return {
476
+ ...dispatch,
477
+ props: {
478
+ ...invocation.props,
479
+ ...restore.props
480
+ },
339
481
  requestUrl
340
482
  };
341
483
  }
@@ -363,22 +505,27 @@ var VinextCachedResponse = class extends WorkerEntrypoint {
363
505
  headers: { "Cache-Control": "no-store" }
364
506
  }));
365
507
  if (invocation.expectedResponseStageBuildIdentity !== void 0 && invocation.expectedResponseStageBuildIdentity !== getVinextCdnBuildIdentity()) return stampResponseStageBuildIdentity(responseStageUnavailable());
366
- const restored = restoreResponseStageRequest(request, invocation.requestUrl, invocation.requestMethod);
367
- const isAppPageDocument = isAppPageDocumentInvocation(invocation);
368
- const isPagesPage = isPagesPageInvocation(invocation);
508
+ const dispatch = restoreDispatchInvocation(request, invocation);
509
+ if (!dispatch) return stampResponseStageBuildIdentity(new Response("Invalid vinext response-stage invocation", {
510
+ status: 400,
511
+ headers: { "Cache-Control": "no-store" }
512
+ }));
513
+ const restored = restoreResponseStageRequest(request, dispatch.requestUrl, dispatch.requestMethod);
514
+ const isAppPageDocument = isAppPageDocumentInvocation(dispatch);
515
+ const isPagesPage = isPagesPageInvocation(dispatch);
369
516
  const response = await invokeResponseStage(restored.request, this.env, context, isAppPageDocument && rendersWholeDocument(request) ? {
370
- ...invocation,
517
+ ...dispatch,
371
518
  options: {
372
- ...invocation.options,
519
+ ...dispatch.options,
373
520
  renderWholeDocument: true
374
521
  }
375
522
  } : isPagesPage && revalidatesStoredEntry(request) ? {
376
- ...invocation,
523
+ ...dispatch,
377
524
  options: {
378
- ...invocation.options,
525
+ ...dispatch.options,
379
526
  regeneratesStoredEntry: true
380
527
  }
381
- } : invocation);
528
+ } : dispatch);
382
529
  return stampResponseStageBuildIdentity(restored.didAccessRequestCf() || hasTaggedCustomVary(response) ? preventResponseCaching(response) : isAppPageDocument ? withRevalidationValidator(response) : isPagesPage ? withPagesRevalidationValidator(response) : response);
383
530
  }
384
531
  async purge(options) {
@@ -408,30 +555,71 @@ var cdn_adapter_worker_default = { async fetch(request, env, context) {
408
555
  request = stripUntrustedTransportHeaders(request);
409
556
  const sharedResponses = /* @__PURE__ */ new Map();
410
557
  const stageContext = withResponseStageCache(withWorkerHostRuntime(context, env));
411
- const dispatchResponseStage = async (stageRequest, props, options) => {
558
+ const createInvocation = (stageRequest, props, options) => {
412
559
  const expectedResponseStageBuildIdentity = getVinextCdnBuildIdentity();
413
- const invocation = {
560
+ const { cacheIdentity: _cacheIdentity, ...stageOptions } = options;
561
+ return {
414
562
  ...expectedResponseStageBuildIdentity === null ? {} : { expectedResponseStageBuildIdentity },
415
- options,
563
+ options: stageOptions,
416
564
  props,
417
565
  requestMethod: stageRequest.method,
418
566
  requestUrl: stageRequest.url
419
567
  };
568
+ };
569
+ /** Fetch a response stage through its named entrypoint, the cached one for a shared dispatch. */
570
+ const fetchResponseStageEntrypoint = async (stageRequest, props, options) => {
571
+ const invocation = createInvocation(stageRequest, props, options);
572
+ const usesSharedCache = options.cache === "shared";
573
+ const { cacheIdentity } = options;
574
+ const dispatchRestore = usesSharedCache && cacheIdentity ? getDispatchRestore(stageRequest, props, cacheIdentity) : null;
575
+ const keyedRequest = dispatchRestore ? cacheIdentity.request : stageRequest;
576
+ const serializedInvocation = JSON.stringify({
577
+ ...invocation,
578
+ ...dispatchRestore ? {
579
+ props: cacheIdentity.props,
580
+ requestMethod: keyedRequest.method,
581
+ requestUrl: keyedRequest.url,
582
+ restoresDispatch: true
583
+ } : {},
584
+ options: invocation.expectedResponseStageBuildIdentity === void 0 ? invocation.options : {
585
+ ...invocation.options,
586
+ cache: RESPONSE_STAGE_WIRE_CACHE[options.cache]
587
+ }
588
+ });
589
+ const binding = getResponseStageBinding(stageContext, usesSharedCache ? CACHED_RESPONSE_STAGE_EXPORT : UNCACHED_RESPONSE_STAGE_EXPORT, serializedInvocation);
590
+ if (!binding) return responseStageUnavailable();
591
+ const entrypointRequest = usesSharedCache ? await createCacheFacingRequest(stageRequest, serializedInvocation, keyedRequest, dispatchRestore) : stageRequest;
592
+ return validateResponseStageBuildIdentity(await binding.fetch(entrypointRequest));
593
+ };
594
+ /**
595
+ * Fetch the other part of a canonical App page part Workers Cache just
596
+ * rendered, so its entry is filled or refreshed too. Workers Cache stores
597
+ * only what its entrypoint returns, so the other part renders on its own.
598
+ * A deployment warm-up already requests both parts, and this fetch reaches
599
+ * the entrypoint directly, so it never fetches another part itself.
600
+ */
601
+ const storeOtherPagePart = (stageRequest, props, options, cacheStatus) => {
602
+ if (cacheStatus === null || !RENDERED_ENTRY_CACHE_STATUSES.has(cacheStatus) || stageRequest.headers.get("user-agent") === WARMUP_USER_AGENT || typeof stageContext.waitUntil !== "function") return;
603
+ stageContext.waitUntil((async () => {
604
+ const otherPart = otherPagePartDispatch(stageRequest, props);
605
+ if (!otherPart) return;
606
+ const { cacheIdentity, ...otherOptions } = options;
607
+ const otherIdentity = cacheIdentity ? otherPagePartDispatch(cacheIdentity.request, cacheIdentity.props) : null;
608
+ await (await fetchResponseStageEntrypoint(otherPart.request, otherPart.props, otherIdentity ? {
609
+ ...otherOptions,
610
+ cacheIdentity: otherIdentity
611
+ } : otherOptions)).body?.pipeTo(new WritableStream());
612
+ })().catch((error) => {
613
+ console.error("[vinext] Workers Cache could not store a page's other part:", error);
614
+ }));
615
+ };
616
+ const dispatchResponseStage = async (stageRequest, props, options) => {
420
617
  const usesSharedCache = options.cache === "shared";
421
618
  try {
422
- if (!usesSharedCache && !isResponseStageReadinessRequest(stageRequest)) return stampResponseStageBuildIdentity(await invokeResponseStage(stageRequest, env, stageContext, invocation));
423
- const serializedInvocation = JSON.stringify({
424
- ...invocation,
425
- options: expectedResponseStageBuildIdentity === null ? options : {
426
- ...options,
427
- cache: RESPONSE_STAGE_WIRE_CACHE[options.cache]
428
- }
429
- });
430
- const binding = getResponseStageBinding(stageContext, usesSharedCache ? CACHED_RESPONSE_STAGE_EXPORT : UNCACHED_RESPONSE_STAGE_EXPORT, serializedInvocation);
431
- if (!binding) return responseStageUnavailable();
432
- const entrypointRequest = usesSharedCache ? await createCacheFacingRequest(stageRequest, serializedInvocation) : stageRequest;
433
- const response = validateResponseStageBuildIdentity(await binding.fetch(entrypointRequest));
619
+ if (!usesSharedCache && !isResponseStageReadinessRequest(stageRequest)) return stampResponseStageBuildIdentity(await invokeResponseStage(stageRequest, env, stageContext, createInvocation(stageRequest, props, options)));
620
+ const response = await fetchResponseStageEntrypoint(stageRequest, props, options);
434
621
  if (!usesSharedCache) return response;
622
+ storeOtherPagePart(stageRequest, props, options, response.headers.get("CF-Cache-Status"));
435
623
  const shared = markSharedResponseStage(response, crypto.randomUUID(), props, true);
436
624
  sharedResponses.set(shared.headers.get(SHARED_RESPONSE_STAGE_HEADER), { headers: new Headers(shared.headers) });
437
625
  return shared;
@@ -134,4 +134,5 @@ export declare function createWorkersCacheConfig({ versionMetadataBinding }?: {
134
134
  VinextUncachedResponse: import("cf/config").WorkerEntrypointExport;
135
135
  };
136
136
  }>;
137
- //#endregion
137
+ //#endregion
138
+ export {};
@@ -60,7 +60,7 @@ export declare class KVCacheHandler implements CacheHandler {
60
60
  private keySpace;
61
61
  private ctx;
62
62
  private ttlSeconds;
63
- /** Local in-memory cache for tag invalidation timestamps. Avoids redundant KV reads. */
63
+ /** Local in-memory cache for tag invalidation markers. Avoids redundant KV reads. */
64
64
  private _tagCache;
65
65
  /** Monotonic ordering for concurrent tag-cache fills and local invalidations. */
66
66
  private _tagCacheOrder;
@@ -95,14 +95,18 @@ export declare class KVCacheHandler implements CacheHandler {
95
95
  */
96
96
  private _readTagMarkers;
97
97
  /**
98
- * Report whether any tag has an invalidation marker at or after `lastModified`.
98
+ * Report whether any tag expired an entry last modified at `lastModified`:
99
+ * its marker's expire time is at or after `lastModified` and, for a cacheLife
100
+ * profile's marker, has passed.
99
101
  * Call `_primeTagCache` for the same tags first — a tag with no cache entry
100
102
  * counts as never invalidated. Pass `requireFresh` to call it before a prime,
101
103
  * so an entry past `tagCacheTtlMs` does not answer for a tag it never re-read.
102
104
  */
103
105
  private _hasRevalidatedTag;
106
+ /** Report whether any primed tag was marked stale after `lastModified`. */
107
+ private _hasStaleTag;
104
108
  set(key: string, data: IncrementalCacheValue | null, ctx?: Record<string, unknown>): Promise<void>;
105
- revalidateTag(tags: string | string[], _durations?: {
109
+ revalidateTag(tags: string | string[], durations?: {
106
110
  expire?: number;
107
111
  }): Promise<void>;
108
112
  /**
@@ -1,4 +1,5 @@
1
- import { isUnknownRecord, readCacheControlNumberField } from "../utils/cache-control-metadata.js";
1
+ import { isStaleTagInvalidation } from "./workers-cache-invalidation.js";
2
+ import { isUnknownRecord, readCacheControlNumberField, readCacheTimestamp } from "../utils/cache-control-metadata.js";
2
3
  import { ENTRY_PREFIX, createKvKeySpace, resolveKvExpirationTtlSeconds } from "./kv-key.js";
3
4
  import { Buffer } from "node:buffer";
4
5
  import { getRequestExecutionContext } from "vinext/shims/request-context";
@@ -24,6 +25,23 @@ import { getRequestExecutionContext } from "vinext/shims/request-context";
24
25
  */
25
26
  /** Default KV namespace binding name read from the Worker `env`. */
26
27
  const DEFAULT_BINDING = "VINEXT_KV_CACHE";
28
+ function parseTagMarker(raw) {
29
+ if (raw === null || raw === void 0) return {};
30
+ if (!raw.startsWith("{")) return { expired: /^\d+$/.test(raw) ? Number(raw) : NaN };
31
+ let parsed;
32
+ try {
33
+ parsed = JSON.parse(raw);
34
+ } catch {
35
+ return { expired: NaN };
36
+ }
37
+ if (!isUnknownRecord(parsed)) return { expired: NaN };
38
+ const { stale, expired } = parsed;
39
+ if (stale === void 0 && expired === void 0 || stale !== void 0 && typeof stale !== "number" || expired !== void 0 && typeof expired !== "number") return { expired: NaN };
40
+ return {
41
+ ...stale === void 0 ? {} : { stale },
42
+ ...expired === void 0 ? {} : { expired }
43
+ };
44
+ }
27
45
  /** Prefix used by revalidatePath for path-based tags. */
28
46
  const PATH_TAG_PREFIX = "_N_T_";
29
47
  /** The runtime rejects a lower `cacheTtl` with "Cache TTL must be at least 30". */
@@ -98,7 +116,7 @@ var KVCacheHandler = class {
98
116
  keySpace;
99
117
  ctx;
100
118
  ttlSeconds;
101
- /** Local in-memory cache for tag invalidation timestamps. Avoids redundant KV reads. */
119
+ /** Local in-memory cache for tag invalidation markers. Avoids redundant KV reads. */
102
120
  _tagCache = /* @__PURE__ */ new Map();
103
121
  /** Monotonic ordering for concurrent tag-cache fills and local invalidations. */
104
122
  _tagCacheOrder = 0;
@@ -179,6 +197,7 @@ var KVCacheHandler = class {
179
197
  return null;
180
198
  }
181
199
  if (this._hasRevalidatedTag(softTags, entry.lastModified)) return null;
200
+ const hasStaleTag = this._hasStaleTag([...entryTags, ...softTags], entry.lastModified);
182
201
  if (entry.expireAt !== void 0 && entry.expireAt !== null && Date.now() > entry.expireAt) {
183
202
  this._deleteEntryReadInBackground(kvKey);
184
203
  return null;
@@ -186,7 +205,7 @@ var KVCacheHandler = class {
186
205
  const now = Date.now();
187
206
  const requestedRevalidate = readPositiveNumberField(_ctx, "revalidate");
188
207
  const requestedRevalidateAt = requestedRevalidate === void 0 ? null : entry.lastModified + requestedRevalidate * 1e3;
189
- if (entry.revalidateAt !== null && now > entry.revalidateAt || requestedRevalidateAt !== null && now > requestedRevalidateAt) return {
208
+ if (hasStaleTag || entry.revalidateAt !== null && now > entry.revalidateAt || requestedRevalidateAt !== null && now > requestedRevalidateAt) return {
190
209
  lastModified: entry.lastModified,
191
210
  value: restoredValue,
192
211
  cacheState: "stale",
@@ -221,7 +240,7 @@ var KVCacheHandler = class {
221
240
  if (current && current.order >= order) continue;
222
241
  const marker = markers.get(this._tagKey(tag));
223
242
  tagCache.set(tag, {
224
- timestamp: marker ? Number(marker) : 0,
243
+ marker: parseTagMarker(marker),
225
244
  fetchedAt: now,
226
245
  order
227
246
  });
@@ -244,18 +263,30 @@ var KVCacheHandler = class {
244
263
  return markers;
245
264
  }
246
265
  /**
247
- * Report whether any tag has an invalidation marker at or after `lastModified`.
266
+ * Report whether any tag expired an entry last modified at `lastModified`:
267
+ * its marker's expire time is at or after `lastModified` and, for a cacheLife
268
+ * profile's marker, has passed.
248
269
  * Call `_primeTagCache` for the same tags first — a tag with no cache entry
249
270
  * counts as never invalidated. Pass `requireFresh` to call it before a prime,
250
271
  * so an entry past `tagCacheTtlMs` does not answer for a tag it never re-read.
251
272
  */
252
273
  _hasRevalidatedTag(tags, lastModified, requireFresh = false) {
253
- const now = requireFresh ? Date.now() : 0;
274
+ const now = Date.now();
254
275
  for (const tag of tags) {
255
276
  const cached = this._tagCache.get(tag);
256
- if (!cached || cached.timestamp === 0) continue;
277
+ if (cached?.marker.expired === void 0) continue;
257
278
  if (requireFresh && now - cached.fetchedAt >= this._tagCacheTtl) continue;
258
- if (Number.isNaN(cached.timestamp) || cached.timestamp >= lastModified) return true;
279
+ const { stale, expired } = cached.marker;
280
+ const pending = stale !== void 0 && expired > now;
281
+ if (Number.isNaN(expired) || expired >= lastModified && !pending) return true;
282
+ }
283
+ return false;
284
+ }
285
+ /** Report whether any primed tag was marked stale after `lastModified`. */
286
+ _hasStaleTag(tags, lastModified) {
287
+ for (const tag of tags) {
288
+ const stale = this._tagCache.get(tag)?.marker.stale;
289
+ if (stale !== void 0 && stale > lastModified) return true;
259
290
  }
260
291
  return false;
261
292
  }
@@ -277,7 +308,7 @@ var KVCacheHandler = class {
277
308
  const effectiveStale = readCacheControlNumberField(ctx, "stale");
278
309
  if (data && "revalidate" in data && typeof data.revalidate === "number") effectiveRevalidate = data.revalidate;
279
310
  if (effectiveRevalidate === 0) return Promise.resolve();
280
- const now = Date.now();
311
+ const now = readCacheTimestamp(ctx, Date.now());
281
312
  const revalidateAt = typeof effectiveRevalidate === "number" && effectiveRevalidate > 0 && Number.isFinite(effectiveRevalidate) ? now + effectiveRevalidate * 1e3 : null;
282
313
  const expireAt = typeof effectiveExpire === "number" && effectiveExpire > 0 ? now + effectiveExpire * 1e3 : null;
283
314
  const cacheControl = typeof effectiveRevalidate === "number" ? {
@@ -300,16 +331,32 @@ var KVCacheHandler = class {
300
331
  metadata
301
332
  });
302
333
  }
303
- async revalidateTag(tags, _durations) {
334
+ async revalidateTag(tags, durations) {
304
335
  const tagList = Array.isArray(tags) ? tags : [tags];
305
336
  const now = Date.now();
306
337
  const validTags = tagList.filter((t) => validateTag(t) !== null);
307
- await Promise.all(validTags.map((tag) => this.kv.put(this._tagKey(tag), String(now))));
338
+ const markers = await Promise.all(validTags.map(async (tag) => {
339
+ const tagKey = this._tagKey(tag);
340
+ if (!isStaleTagInvalidation(durations)) {
341
+ await this.kv.put(tagKey, String(now));
342
+ return { expired: now };
343
+ }
344
+ const expire = durations?.expire;
345
+ const expired = expire === void 0 ? parseTagMarker(await this.kv.get(tagKey)).expired : now + expire * 1e3;
346
+ const marker = {
347
+ stale: now,
348
+ ...expired !== void 0 && Number.isFinite(expired) ? { expired } : {}
349
+ };
350
+ await this.kv.put(tagKey, JSON.stringify(marker));
351
+ return marker;
352
+ }));
308
353
  const order = ++this._tagCacheOrder;
309
- for (const tag of validTags) this._tagCache.set(tag, {
310
- timestamp: now,
311
- fetchedAt: now,
312
- order
354
+ validTags.forEach((tag, index) => {
355
+ this._tagCache.set(tag, {
356
+ marker: markers[index],
357
+ fetchedAt: now,
358
+ order
359
+ });
313
360
  });
314
361
  }
315
362
  /**
@@ -11,4 +11,5 @@ export declare function createVinextResponseStoreOptions<Env extends VinextRespo
11
11
  export declare function createVinextResponseStoreHandler(store: WorkersResponseStore): {
12
12
  fetch(request: Request, env: VinextResponseStoreEnv, ctx: WorkerExecutionContext): Promise<Response>;
13
13
  };
14
- //#endregion
14
+ //#endregion
15
+ export {};