@vinext/cloudflare 1.2.1 → 1.3.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.
@@ -0,0 +1,7 @@
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
+ //#endregion
@@ -0,0 +1,10 @@
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
+ //#endregion
10
+ export { isCanonicalAppPage };
@@ -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 { 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,116 @@ 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
+ const headers = new Headers(request.headers);
387
+ for (const name of FRAMEWORK_RESPONSE_VARY_FIELDS) headers.delete(name);
388
+ for (const name of [
389
+ "If-Match",
390
+ "If-Modified-Since",
391
+ "If-None-Match",
392
+ "If-Range",
393
+ "If-Unmodified-Since",
394
+ "Range"
395
+ ]) headers.delete(name);
396
+ if (isRscRequest) {
397
+ const url = new URL(request.url);
398
+ stripRscCacheBustingSearchParam(url);
399
+ headers.delete("x-deployment-id");
400
+ headers.set("Accept", "text/html");
401
+ headers.set(RENDER_WHOLE_DOCUMENT_TRANSPORT_HEADER, "1");
402
+ return {
403
+ props: {
404
+ ...props,
405
+ isRscRequest: false
406
+ },
407
+ request: withRequestCf(new Request(url, { headers }), request)
408
+ };
409
+ }
410
+ for (const [name, value] of createCanonicalRscRequestHeaders()) headers.set(name, value);
411
+ return {
412
+ props: {
413
+ ...props,
414
+ isRscRequest: true,
415
+ renderMode: "navigation"
416
+ },
417
+ request: withRequestCf(new Request(new URL(createCanonicalRscRequestUrl(request.url), request.url), { headers }), request)
418
+ };
419
+ }
420
+ function isPlainObject(value) {
421
+ return value !== null && typeof value === "object" && !Array.isArray(value);
422
+ }
423
+ function pathAndSearch(url) {
424
+ return `${url.pathname}${url.search}`;
425
+ }
426
+ /**
427
+ * What a shared dispatch's host-free identity replaced: the request URL, and
428
+ * the top-level props that name the host. Null when the identity is anything
429
+ * else, so the dispatch keys itself, host and all.
430
+ */
431
+ function getDispatchRestore(request, props, identity) {
432
+ if (identity.request.method !== request.method || pathAndSearch(new URL(identity.request.url)) !== pathAndSearch(new URL(request.url))) return null;
433
+ const dispatched = JSON.parse(JSON.stringify(props) ?? "null");
434
+ const keyed = JSON.parse(JSON.stringify(identity.props) ?? "null");
435
+ if (!isPlainObject(dispatched) || !isPlainObject(keyed)) return null;
436
+ if (Object.keys(keyed).some((name) => !Object.hasOwn(dispatched, name))) return null;
437
+ const replaced = {};
438
+ for (const [name, value] of Object.entries(dispatched)) {
439
+ if (JSON.stringify(value) === JSON.stringify(keyed[name])) continue;
440
+ if (!HOST_BEARING_PROPS.has(name)) return null;
441
+ replaced[name] = value;
442
+ }
443
+ return {
444
+ props: replaced,
445
+ requestUrl: request.url
446
+ };
447
+ }
448
+ /**
449
+ * Rebuild the dispatch a host-free invocation keys from the restore header
450
+ * the gateway sent. Null when an identity invocation has no valid restore.
451
+ */
452
+ function restoreDispatchInvocation(request, invocation) {
453
+ if (!invocation.restoresDispatch) return invocation;
454
+ const serialized = request.headers.get(DISPATCH_TRANSPORT_HEADER);
455
+ if (serialized === null || !isPlainObject(invocation.props)) return null;
456
+ let restore;
457
+ try {
458
+ restore = JSON.parse(decodeURIComponent(serialized));
459
+ } catch {
460
+ return null;
461
+ }
462
+ if (!isPlainObject(restore) || !isPlainObject(restore.props)) return null;
463
+ if (Object.keys(restore.props).some((name) => !HOST_BEARING_PROPS.has(name))) return null;
464
+ const requestUrl = restore.requestUrl;
465
+ if (typeof requestUrl !== "string") return null;
466
+ let url;
467
+ try {
468
+ url = new URL(requestUrl);
469
+ } catch {
470
+ return null;
471
+ }
472
+ if (pathAndSearch(url) !== pathAndSearch(new URL(invocation.requestUrl))) return null;
473
+ const { restoresDispatch: _restoresDispatch, ...dispatch } = invocation;
474
+ return {
475
+ ...dispatch,
476
+ props: {
477
+ ...invocation.props,
478
+ ...restore.props
479
+ },
339
480
  requestUrl
340
481
  };
341
482
  }
@@ -363,22 +504,27 @@ var VinextCachedResponse = class extends WorkerEntrypoint {
363
504
  headers: { "Cache-Control": "no-store" }
364
505
  }));
365
506
  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);
507
+ const dispatch = restoreDispatchInvocation(request, invocation);
508
+ if (!dispatch) return stampResponseStageBuildIdentity(new Response("Invalid vinext response-stage invocation", {
509
+ status: 400,
510
+ headers: { "Cache-Control": "no-store" }
511
+ }));
512
+ const restored = restoreResponseStageRequest(request, dispatch.requestUrl, dispatch.requestMethod);
513
+ const isAppPageDocument = isAppPageDocumentInvocation(dispatch);
514
+ const isPagesPage = isPagesPageInvocation(dispatch);
369
515
  const response = await invokeResponseStage(restored.request, this.env, context, isAppPageDocument && rendersWholeDocument(request) ? {
370
- ...invocation,
516
+ ...dispatch,
371
517
  options: {
372
- ...invocation.options,
518
+ ...dispatch.options,
373
519
  renderWholeDocument: true
374
520
  }
375
521
  } : isPagesPage && revalidatesStoredEntry(request) ? {
376
- ...invocation,
522
+ ...dispatch,
377
523
  options: {
378
- ...invocation.options,
524
+ ...dispatch.options,
379
525
  regeneratesStoredEntry: true
380
526
  }
381
- } : invocation);
527
+ } : dispatch);
382
528
  return stampResponseStageBuildIdentity(restored.didAccessRequestCf() || hasTaggedCustomVary(response) ? preventResponseCaching(response) : isAppPageDocument ? withRevalidationValidator(response) : isPagesPage ? withPagesRevalidationValidator(response) : response);
383
529
  }
384
530
  async purge(options) {
@@ -408,30 +554,71 @@ var cdn_adapter_worker_default = { async fetch(request, env, context) {
408
554
  request = stripUntrustedTransportHeaders(request);
409
555
  const sharedResponses = /* @__PURE__ */ new Map();
410
556
  const stageContext = withResponseStageCache(withWorkerHostRuntime(context, env));
411
- const dispatchResponseStage = async (stageRequest, props, options) => {
557
+ const createInvocation = (stageRequest, props, options) => {
412
558
  const expectedResponseStageBuildIdentity = getVinextCdnBuildIdentity();
413
- const invocation = {
559
+ const { cacheIdentity: _cacheIdentity, ...stageOptions } = options;
560
+ return {
414
561
  ...expectedResponseStageBuildIdentity === null ? {} : { expectedResponseStageBuildIdentity },
415
- options,
562
+ options: stageOptions,
416
563
  props,
417
564
  requestMethod: stageRequest.method,
418
565
  requestUrl: stageRequest.url
419
566
  };
567
+ };
568
+ /** Fetch a response stage through its named entrypoint, the cached one for a shared dispatch. */
569
+ const fetchResponseStageEntrypoint = async (stageRequest, props, options) => {
570
+ const invocation = createInvocation(stageRequest, props, options);
571
+ const usesSharedCache = options.cache === "shared";
572
+ const { cacheIdentity } = options;
573
+ const dispatchRestore = usesSharedCache && cacheIdentity ? getDispatchRestore(stageRequest, props, cacheIdentity) : null;
574
+ const keyedRequest = dispatchRestore ? cacheIdentity.request : stageRequest;
575
+ const serializedInvocation = JSON.stringify({
576
+ ...invocation,
577
+ ...dispatchRestore ? {
578
+ props: cacheIdentity.props,
579
+ requestMethod: keyedRequest.method,
580
+ requestUrl: keyedRequest.url,
581
+ restoresDispatch: true
582
+ } : {},
583
+ options: invocation.expectedResponseStageBuildIdentity === void 0 ? invocation.options : {
584
+ ...invocation.options,
585
+ cache: RESPONSE_STAGE_WIRE_CACHE[options.cache]
586
+ }
587
+ });
588
+ const binding = getResponseStageBinding(stageContext, usesSharedCache ? CACHED_RESPONSE_STAGE_EXPORT : UNCACHED_RESPONSE_STAGE_EXPORT, serializedInvocation);
589
+ if (!binding) return responseStageUnavailable();
590
+ const entrypointRequest = usesSharedCache ? await createCacheFacingRequest(stageRequest, serializedInvocation, keyedRequest, dispatchRestore) : stageRequest;
591
+ return validateResponseStageBuildIdentity(await binding.fetch(entrypointRequest));
592
+ };
593
+ /**
594
+ * Fetch the other part of a canonical App page part Workers Cache just
595
+ * rendered, so its entry is filled or refreshed too. Workers Cache stores
596
+ * only what its entrypoint returns, so the other part renders on its own.
597
+ * A deployment warm-up already requests both parts, and this fetch reaches
598
+ * the entrypoint directly, so it never fetches another part itself.
599
+ */
600
+ const storeOtherPagePart = (stageRequest, props, options, cacheStatus) => {
601
+ if (cacheStatus === null || !RENDERED_ENTRY_CACHE_STATUSES.has(cacheStatus) || stageRequest.headers.get("user-agent") === WARMUP_USER_AGENT || typeof stageContext.waitUntil !== "function") return;
602
+ stageContext.waitUntil((async () => {
603
+ const otherPart = otherPagePartDispatch(stageRequest, props);
604
+ if (!otherPart) return;
605
+ const { cacheIdentity, ...otherOptions } = options;
606
+ const otherIdentity = cacheIdentity ? otherPagePartDispatch(cacheIdentity.request, cacheIdentity.props) : null;
607
+ await (await fetchResponseStageEntrypoint(otherPart.request, otherPart.props, otherIdentity ? {
608
+ ...otherOptions,
609
+ cacheIdentity: otherIdentity
610
+ } : otherOptions)).body?.pipeTo(new WritableStream());
611
+ })().catch((error) => {
612
+ console.error("[vinext] Workers Cache could not store a page's other part:", error);
613
+ }));
614
+ };
615
+ const dispatchResponseStage = async (stageRequest, props, options) => {
420
616
  const usesSharedCache = options.cache === "shared";
421
617
  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));
618
+ if (!usesSharedCache && !isResponseStageReadinessRequest(stageRequest)) return stampResponseStageBuildIdentity(await invokeResponseStage(stageRequest, env, stageContext, createInvocation(stageRequest, props, options)));
619
+ const response = await fetchResponseStageEntrypoint(stageRequest, props, options);
434
620
  if (!usesSharedCache) return response;
621
+ storeOtherPagePart(stageRequest, props, options, response.headers.get("CF-Cache-Status"));
435
622
  const shared = markSharedResponseStage(response, crypto.randomUUID(), props, true);
436
623
  sharedResponses.set(shared.headers.get(SHARED_RESPONSE_STAGE_HEADER), { headers: new Headers(shared.headers) });
437
624
  return shared;
@@ -1,5 +1,5 @@
1
1
  import { isStaleTagInvalidation } from "./workers-cache-invalidation.js";
2
- import { isUnknownRecord, readCacheControlNumberField } from "../utils/cache-control-metadata.js";
2
+ import { isUnknownRecord, readCacheControlNumberField, readCacheTimestamp } from "../utils/cache-control-metadata.js";
3
3
  import { ENTRY_PREFIX, createKvKeySpace, resolveKvExpirationTtlSeconds } from "./kv-key.js";
4
4
  import { Buffer } from "node:buffer";
5
5
  import { getRequestExecutionContext } from "vinext/shims/request-context";
@@ -25,6 +25,14 @@ import { getRequestExecutionContext } from "vinext/shims/request-context";
25
25
  */
26
26
  /** Default KV namespace binding name read from the Worker `env`. */
27
27
  const DEFAULT_BINDING = "VINEXT_KV_CACHE";
28
+ function toStoredLifetime(seconds) {
29
+ if (seconds === Infinity) return "Infinity";
30
+ if (seconds === -Infinity) return "-Infinity";
31
+ return seconds;
32
+ }
33
+ function isStoredLifetime(value) {
34
+ return typeof value === "number" || value === "Infinity" || value === "-Infinity";
35
+ }
28
36
  function parseTagMarker(raw) {
29
37
  if (raw === null || raw === void 0) return {};
30
38
  if (!raw.startsWith("{")) return { expired: /^\d+$/.test(raw) ? Number(raw) : NaN };
@@ -308,13 +316,13 @@ var KVCacheHandler = class {
308
316
  const effectiveStale = readCacheControlNumberField(ctx, "stale");
309
317
  if (data && "revalidate" in data && typeof data.revalidate === "number") effectiveRevalidate = data.revalidate;
310
318
  if (effectiveRevalidate === 0) return Promise.resolve();
311
- const now = Date.now();
319
+ const now = readCacheTimestamp(ctx, Date.now());
312
320
  const revalidateAt = typeof effectiveRevalidate === "number" && effectiveRevalidate > 0 && Number.isFinite(effectiveRevalidate) ? now + effectiveRevalidate * 1e3 : null;
313
321
  const expireAt = typeof effectiveExpire === "number" && effectiveExpire > 0 ? now + effectiveExpire * 1e3 : null;
314
322
  const cacheControl = typeof effectiveRevalidate === "number" ? {
315
323
  revalidate: Number.isFinite(effectiveRevalidate) ? effectiveRevalidate : false,
316
- ...effectiveExpire === void 0 ? {} : { expire: effectiveExpire },
317
- ...effectiveStale === void 0 ? {} : { stale: effectiveStale }
324
+ ...effectiveExpire === void 0 ? {} : { expire: toStoredLifetime(effectiveExpire) },
325
+ ...effectiveStale === void 0 ? {} : { stale: toStoredLifetime(effectiveStale) }
318
326
  } : void 0;
319
327
  const entry = {
320
328
  value: data ? serializeForJSON(data) : null,
@@ -469,18 +477,24 @@ function validateCacheEntry(raw) {
469
477
  if (obj.cacheControl !== void 0) {
470
478
  if (!isUnknownRecord(obj.cacheControl)) return null;
471
479
  if (typeof obj.cacheControl.revalidate !== "number" && obj.cacheControl.revalidate !== false) return null;
472
- if (obj.cacheControl.expire !== void 0 && typeof obj.cacheControl.expire !== "number") return null;
473
- if (obj.cacheControl.stale !== void 0 && typeof obj.cacheControl.stale !== "number") return null;
480
+ for (const field of ["expire", "stale"]) {
481
+ const seconds = obj.cacheControl[field];
482
+ if (seconds !== void 0 && !isStoredLifetime(seconds)) return null;
483
+ }
474
484
  }
475
485
  if (obj.value !== null) {
476
486
  if (!obj.value || typeof obj.value !== "object") return null;
477
487
  const value = obj.value;
478
488
  if (typeof value.kind !== "string" || !VALID_KINDS.has(value.kind)) return null;
479
489
  }
480
- if (obj.cacheControl?.revalidate === false) obj.cacheControl = {
481
- ...obj.cacheControl,
482
- revalidate: Infinity
483
- };
490
+ if (obj.cacheControl !== void 0) {
491
+ const { revalidate, expire, stale } = obj.cacheControl;
492
+ obj.cacheControl = {
493
+ revalidate: revalidate === false ? Infinity : revalidate,
494
+ ...expire === void 0 ? {} : { expire: Number(expire) },
495
+ ...stale === void 0 ? {} : { stale: Number(stale) }
496
+ };
497
+ }
484
498
  return raw;
485
499
  }
486
500
  /**