@vinext/cloudflare 1.2.1 → 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 };
@@ -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;
@@ -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";
@@ -308,7 +308,7 @@ var KVCacheHandler = class {
308
308
  const effectiveStale = readCacheControlNumberField(ctx, "stale");
309
309
  if (data && "revalidate" in data && typeof data.revalidate === "number") effectiveRevalidate = data.revalidate;
310
310
  if (effectiveRevalidate === 0) return Promise.resolve();
311
- const now = Date.now();
311
+ const now = readCacheTimestamp(ctx, Date.now());
312
312
  const revalidateAt = typeof effectiveRevalidate === "number" && effectiveRevalidate > 0 && Number.isFinite(effectiveRevalidate) ? now + effectiveRevalidate * 1e3 : null;
313
313
  const expireAt = typeof effectiveExpire === "number" && effectiveExpire > 0 ? now + effectiveExpire * 1e3 : null;
314
314
  const cacheControl = typeof effectiveRevalidate === "number" ? {
@@ -1,7 +1,8 @@
1
+ import { hasDocumentPart, isCanonicalAppPage } from "./app-page-parts.js";
1
2
  import { SHARED_RESPONSE_STAGE_HEADER, finalizeGatewayResponse } from "./browser-cache-policy.js";
2
3
  import { captureResponseStoreDataRegeneration, runWithResponseStoreInvocation, setResponseStore } from "./response-store-data.runtime.js";
3
- import { VINEXT_PARAMS_HEADER, VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, VINEXT_SPECIAL_ERROR_STATUS_HEADER } from "vinext/internal/server/headers";
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";
4
+ import { NEXTJS_DEPLOYMENT_ID_HEADER, VINEXT_PARAMS_HEADER, VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, VINEXT_RSC_COMPLETION_METADATA_HEADER, VINEXT_SPECIAL_ERROR_STATUS_HEADER, VINEXT_STALE_TIME_PENDING_HEADER } from "vinext/internal/server/headers";
5
+ import { VINEXT_RSC_BUILD_ID_HEADER, VINEXT_RSC_COMPATIBILITY_ID_HEADER, VINEXT_RSC_CONTENT_TYPE, VINEXT_RSC_VARY_HEADER as VINEXT_RSC_VARY_HEADER$1, applyRscCompatibilityIdHeader, applyRscDeploymentIdHeader, createCanonicalRscRequestHeaders, createCanonicalRscRequestUrl, stripRscCacheBustingSearchParam } from "vinext/internal/server/app-rsc-cache-busting";
5
6
  import { hasSharedCacheCredentials, isNonCacheableCacheControl } from "vinext/shims/cdn-cache";
6
7
  import { loadVinextRequestStage } from "vinext/server/request-stage";
7
8
  import { loadVinextResponseStage } from "vinext/server/response-stage";
@@ -9,6 +10,8 @@ import { traceCachedResponseStart } from "vinext/internal/server/response-start-
9
10
  import { isPageNotModified } from "vinext/internal/server/http-conditional";
10
11
  //#region src/cache/response-store-adapter.worker.ts
11
12
  const ROUTE_REVALIDATOR_ID = "vinext:response";
13
+ /** Leads the revalidator args of a page part whose render stores both parts. */
14
+ const PAGE_PARTS_REVALIDATOR_ARG = "vinext:page-parts";
12
15
  const RESPONSE_STORE_KEY_PARAM = "__workers_response_store";
13
16
  const AGE_BASIS_HEADER = "X-Workers-Response-Store-Age-Basis";
14
17
  const WARMUP_USER_AGENT = "vinext-cloudflare-cdn-warm";
@@ -58,6 +61,15 @@ function prepareInvocation(request, props) {
58
61
  function serializeInvocation(request, props) {
59
62
  return JSON.stringify(prepareInvocation(request, props));
60
63
  }
64
+ function serializeDispatchInvocation(invocation) {
65
+ return JSON.stringify({
66
+ ...invocation,
67
+ request: {
68
+ ...invocation.request,
69
+ headers: [...invocation.request.headers, ["host", new URL(invocation.request.url).host]]
70
+ }
71
+ });
72
+ }
61
73
  function parseInvocation(value) {
62
74
  if (typeof value !== "string") throw new TypeError("Invalid vinext response-store invocation");
63
75
  const invocation = JSON.parse(value);
@@ -95,16 +107,29 @@ function createVinextResponseStoreOptions(configuration) {
95
107
  async regenerate(input, { env, ctx }) {
96
108
  if (input.id === ROUTE_REVALIDATOR_ID) {
97
109
  const invocation = parseInvocation(input.args.at(-1));
98
- const response = await invokeResponseStage(restoreRequest(invocation), invocation.props, env, ctx, {
110
+ const request = restoreRequest(invocation);
111
+ const otherPart = input.args.length > 1 && input.args[0] === PAGE_PARTS_REVALIDATOR_ARG ? otherPagePart(request, invocation.props) : void 0;
112
+ const capture = otherPagePartCapture(otherPart);
113
+ const response = await invokeResponseStage(request, invocation.props, env, ctx, {
99
114
  cache: "shared",
100
115
  regeneratesStoredEntry: true,
101
116
  renderWholeDocument: true
102
- });
117
+ }, capture);
103
118
  if (!isCacheable(response)) {
104
119
  await response.body?.cancel().catch(() => {});
120
+ capture.rscData?.catch(() => {});
105
121
  throw new Error("Vinext response-stage regeneration was not cacheable");
106
122
  }
107
- return withoutRequestScopedHeaders(response, invocation.props);
123
+ const stored = withoutRequestScopedHeaders(response, invocation.props);
124
+ try {
125
+ await putOtherPagePart(otherPart, capture, stored);
126
+ } catch (error) {
127
+ console.error(JSON.stringify({
128
+ message: "Vinext response-store regeneration failed to store the page's other part",
129
+ error: error instanceof Error ? error.message : String(error)
130
+ }));
131
+ }
132
+ return stored;
108
133
  }
109
134
  if (input.id === "vinext:cache-function" && typeof input.args[0] === "string" && typeof input.args[1] === "string") {
110
135
  const invocation = JSON.parse(input.args[1]);
@@ -137,7 +162,8 @@ async function cacheRequest(invocation) {
137
162
  url.searchParams.set(RESPONSE_STORE_KEY_PARAM, `v1.${[...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`);
138
163
  return new Request(url, {
139
164
  headers: CACHE_REQUEST_VARY_HEADERS,
140
- method: "GET"
165
+ method: "GET",
166
+ redirect: "manual"
141
167
  });
142
168
  }
143
169
  /**
@@ -157,6 +183,116 @@ function withoutRequestScopedHeaders(response, responseStageProps) {
157
183
  statusText: response.statusText
158
184
  });
159
185
  }
186
+ /**
187
+ * The revalidator of a page part. One whose render stores the page's other
188
+ * part says so, as a public config policy's full-URL entry stores its own.
189
+ */
190
+ function routeRevalidator(serializedInvocation, storesOtherPart) {
191
+ return {
192
+ id: ROUTE_REVALIDATOR_ID,
193
+ args: storesOtherPart ? [PAGE_PARTS_REVALIDATOR_ARG, serializedInvocation] : [serializedInvocation]
194
+ };
195
+ }
196
+ /** The invocation an RSC navigation to an HTML invocation's page stores under. */
197
+ function rscPartInvocation(htmlRequest, htmlProps) {
198
+ return prepareInvocation(new Request(new URL(createCanonicalRscRequestUrl(htmlRequest.url), htmlRequest.url), { headers: createCanonicalRscRequestHeaders() }), {
199
+ ...htmlProps,
200
+ isRscRequest: true,
201
+ renderMode: "navigation"
202
+ });
203
+ }
204
+ /** The invocation an HTML request for an RSC invocation's page stores under. */
205
+ function documentPartInvocation(rscRequest, rscProps) {
206
+ const url = new URL(rscRequest.url);
207
+ stripRscCacheBustingSearchParam(url);
208
+ return prepareInvocation(new Request(url), {
209
+ ...rscProps,
210
+ isRscRequest: false
211
+ });
212
+ }
213
+ /**
214
+ * The page's RSC payload an HTML render produced, with its document's headers
215
+ * and status.
216
+ */
217
+ function rscPartResponse(htmlResponse, rscData) {
218
+ const headers = new Headers(htmlResponse.headers);
219
+ headers.delete("Content-Length");
220
+ headers.delete("Link");
221
+ headers.delete("X-Vinext-Response-Store-Replayable");
222
+ headers.delete(VINEXT_PARAMS_HEADER);
223
+ headers.delete(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER);
224
+ headers.set("Content-Type", VINEXT_RSC_CONTENT_TYPE);
225
+ headers.set("Vary", VINEXT_RSC_VARY_HEADER$1);
226
+ applyRscCompatibilityIdHeader(headers);
227
+ applyRscDeploymentIdHeader(headers);
228
+ if (isStoredErrorStatus(htmlResponse.status)) headers.set(VINEXT_SPECIAL_ERROR_STATUS_HEADER, String(htmlResponse.status));
229
+ return new Response(rscData, {
230
+ headers,
231
+ status: htmlResponse.status >= 300 && htmlResponse.status < 400 ? 200 : htmlResponse.status
232
+ });
233
+ }
234
+ /**
235
+ * The page's document an RSC render produced, with the headers an HTML request
236
+ * for the page stores: the RSC response's, without the payload's own.
237
+ */
238
+ function documentPartResponse(rscResponse, document) {
239
+ const headers = new Headers(rscResponse.headers);
240
+ for (const name of [
241
+ "Content-Length",
242
+ NEXTJS_DEPLOYMENT_ID_HEADER,
243
+ VINEXT_RSC_BUILD_ID_HEADER,
244
+ VINEXT_RSC_COMPATIBILITY_ID_HEADER,
245
+ VINEXT_RSC_COMPLETION_METADATA_HEADER,
246
+ VINEXT_STALE_TIME_PENDING_HEADER
247
+ ]) headers.delete(name);
248
+ headers.set("Content-Type", "text/html; charset=utf-8");
249
+ if (document.linkHeader) headers.append("Link", document.linkHeader);
250
+ return new Response(document.html, {
251
+ headers,
252
+ status: rscResponse.status
253
+ });
254
+ }
255
+ /**
256
+ * The other part of the page a canonical App page render stores. Next.js
257
+ * keeps a page's document and RSC payload in one entry, so a render that
258
+ * stores one part also stores the other, under the key a request for that
259
+ * part reads. A variant has no other part.
260
+ */
261
+ function otherPagePart(identityRequest, identityProps) {
262
+ if (isCanonicalAppPage(identityProps, false)) return {
263
+ invocation: rscPartInvocation(identityRequest, identityProps),
264
+ kind: "rsc"
265
+ };
266
+ if (isCanonicalAppPage(identityProps, true) && hasDocumentPart(identityRequest.url)) return {
267
+ invocation: documentPartInvocation(identityRequest, identityProps),
268
+ kind: "document"
269
+ };
270
+ }
271
+ /** What a render captures for the other part of its page. */
272
+ function otherPagePartCapture(part) {
273
+ if (part?.kind === "rsc") return { captureRscData: true };
274
+ if (part?.kind === "document") return { captureDocument: true };
275
+ return {};
276
+ }
277
+ /**
278
+ * Store the other part of the page from the render that stores this one.
279
+ * Admission settles the document before it completes the response. A miss
280
+ * stores it first, so a failed write leaves the render's own part missing too
281
+ * and the next miss retries both. A regeneration's other part stays stale
282
+ * when it fails, so the next read of that part regenerates it. Concurrent
283
+ * misses coalesce each key on its own, so their parts can come from different
284
+ * renders of the page.
285
+ */
286
+ async function putOtherPagePart(part, capture, stored) {
287
+ let response;
288
+ if (part?.kind === "rsc" && capture.rscData) response = rscPartResponse(stored, await capture.rscData);
289
+ else if (part?.kind === "document" && capture.document) response = documentPartResponse(stored, capture.document);
290
+ else return;
291
+ if (!(await responseStore.put(await cacheRequest(part.invocation), response, {
292
+ coalesce: true,
293
+ revalidator: routeRevalidator(JSON.stringify(part.invocation), true)
294
+ })).backingStoreUpdated) throw new Error(`Vinext response-store did not publish the App page ${part.kind} part`);
295
+ }
160
296
  function isStoredErrorStatus(status) {
161
297
  return status === 401 || status === 403 || status === 404;
162
298
  }
@@ -275,17 +411,9 @@ const handler = { async fetch(request, env, ctx) {
275
411
  const identityRequest = options.cacheIdentity?.request ?? stageRequest;
276
412
  const identityProps = options.cacheIdentity?.props ?? props;
277
413
  const isWarmup = request.headers.get("user-agent") === WARMUP_USER_AGENT;
278
- const rscSeed = isWarmup && props !== null && typeof props === "object" && Reflect.get(props, "kind") === "app-page" && Reflect.get(props, "isRscRequest") === false && Reflect.get(props, "matchKind") === "request" && Reflect.get(props, "interceptionContext") === null && Reflect.get(props, "interceptionId") === null && Reflect.get(props, "mountedSlotsHeader") === null ? {
279
- props: {
280
- ...identityProps,
281
- isRscRequest: true,
282
- renderMode: "navigation"
283
- },
284
- request: new Request(new URL(createCanonicalRscRequestUrl(identityRequest.url), identityRequest.url), { headers: createCanonicalRscRequestHeaders() })
285
- } : void 0;
414
+ const otherPart = isWarmup || options.cacheIdentity?.queryFree ? otherPagePart(identityRequest, identityProps) : void 0;
415
+ const rscKey = isWarmup && otherPart?.kind === "rsc" ? await cacheRequest(otherPart.invocation) : void 0;
286
416
  const invocation = prepareInvocation(identityRequest, identityProps);
287
- const rscInvocation = rscSeed ? prepareInvocation(rscSeed.request, rscSeed.props) : void 0;
288
- const rscKey = rscInvocation ? await cacheRequest(rscInvocation) : void 0;
289
417
  const key = await cacheRequest(invocation);
290
418
  const stored = await readStoredResponse(key);
291
419
  if (stored) {
@@ -297,8 +425,12 @@ const handler = { async fetch(request, env, ctx) {
297
425
  }
298
426
  stored.body?.cancel().catch(() => {});
299
427
  }
300
- const capture = rscSeed ? { captureRscData: true } : isWarmup ? {} : { streamResponse: true };
428
+ const capture = {
429
+ ...isWarmup ? {} : { streamResponse: true },
430
+ ...otherPagePartCapture(otherPart)
431
+ };
301
432
  const serializedInvocation = JSON.stringify(invocation);
433
+ const revalidatorInvocation = !options.cacheIdentity ? serializeDispatchInvocation(invocation) : options.cacheIdentity.regenerateFromDispatch ? serializeDispatchInvocation(prepareInvocation(stageRequest, props)) : serializedInvocation;
302
434
  const rendered = await invokeResponseStage(stageRequest, props, env, ctx, isWarmup ? {
303
435
  cache: "shared",
304
436
  renderWholeDocument: true
@@ -311,14 +443,19 @@ const handler = { async fetch(request, env, ctx) {
311
443
  ctx.waitUntil(capture.admittedResponse.then(async (admitted) => {
312
444
  if (!isCacheable(admitted)) {
313
445
  await admitted.body?.cancel().catch(() => {});
446
+ capture.rscData?.catch(() => {});
314
447
  return;
315
448
  }
316
- await responseStore.put(key, withoutRequestScopedHeaders(admitted, props), {
449
+ const stored = withoutRequestScopedHeaders(admitted, props);
450
+ try {
451
+ await putOtherPagePart(otherPart, capture, stored);
452
+ } catch (error) {
453
+ await stored.body?.cancel().catch(() => {});
454
+ throw error;
455
+ }
456
+ await responseStore.put(key, stored, {
317
457
  coalesce: true,
318
- revalidator: {
319
- id: ROUTE_REVALIDATOR_ID,
320
- args: [serializedInvocation]
321
- }
458
+ revalidator: routeRevalidator(revalidatorInvocation, otherPart !== void 0)
322
459
  });
323
460
  }).catch((error) => {
324
461
  console.error(JSON.stringify({
@@ -335,22 +472,20 @@ const handler = { async fetch(request, env, ctx) {
335
472
  }), "MISS", props) : publicResponse(notModified, "MISS", props, true);
336
473
  }
337
474
  if (!isCacheable(rendered)) {
338
- capture?.rscData?.catch(() => {});
475
+ capture.rscData?.catch(() => {});
339
476
  return publicResponse(foreground(rendered), "BYPASS", props);
340
477
  }
341
- if (rscSeed && !capture?.rscData) {
478
+ if (rscKey && !capture.rscData) {
342
479
  await rendered.body?.cancel();
343
480
  throw new Error("Vinext response-store warmup did not capture the App page RSC payload");
344
481
  }
345
482
  const [foregroundBody, cacheBody] = rendered.body ? rendered.body.tee() : [null, null];
346
483
  const cacheResponse = withoutRequestScopedHeaders(new Response(cacheBody, rendered), props);
347
484
  try {
485
+ await putOtherPagePart(otherPart, capture, cacheResponse);
348
486
  await responseStore.put(key, cacheResponse, {
349
487
  coalesce: true,
350
- revalidator: {
351
- id: ROUTE_REVALIDATOR_ID,
352
- args: [serializedInvocation]
353
- }
488
+ revalidator: routeRevalidator(revalidatorInvocation, otherPart !== void 0)
354
489
  });
355
490
  } catch (error) {
356
491
  if (isWarmup) throw error;
@@ -360,31 +495,6 @@ const handler = { async fetch(request, env, ctx) {
360
495
  error: error instanceof Error ? error.message : String(error)
361
496
  }));
362
497
  }
363
- if (rscSeed && rscInvocation && rscKey && capture?.rscData) {
364
- const rscData = await capture.rscData;
365
- const rscHeaders = new Headers(rendered.headers);
366
- rscHeaders.delete("Content-Length");
367
- rscHeaders.delete("Link");
368
- rscHeaders.delete("X-Vinext-Response-Store-Replayable");
369
- rscHeaders.delete(VINEXT_PARAMS_HEADER);
370
- rscHeaders.delete(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER);
371
- rscHeaders.set("Content-Type", VINEXT_RSC_CONTENT_TYPE);
372
- rscHeaders.set("Vary", VINEXT_RSC_VARY_HEADER$1);
373
- applyRscCompatibilityIdHeader(rscHeaders);
374
- applyRscDeploymentIdHeader(rscHeaders);
375
- if (isStoredErrorStatus(rendered.status)) rscHeaders.set(VINEXT_SPECIAL_ERROR_STATUS_HEADER, String(rendered.status));
376
- const serializedRscInvocation = JSON.stringify(rscInvocation);
377
- await responseStore.put(rscKey, new Response(rscData, {
378
- headers: rscHeaders,
379
- status: rendered.status >= 300 && rendered.status < 400 ? 200 : rendered.status
380
- }), {
381
- coalesce: true,
382
- revalidator: {
383
- id: ROUTE_REVALIDATOR_ID,
384
- args: [serializedRscInvocation]
385
- }
386
- });
387
- }
388
498
  return publicResponse(foreground(new Response(foregroundBody, rendered)), "MISS", props);
389
499
  };
390
500
  const { handleRequestStage } = await loadVinextRequestStage();
@@ -1,5 +1,5 @@
1
1
  import createCloudflareCdnCacheAdapter from "./cdn-adapter.runtime.js";
2
- import { captureResponseStoreRscData, deferResponseStoreAdmission, deferResponseStoreNotModified } from "./response-store-data.runtime.js";
2
+ import { captureResponseStoreAppPageDocument, 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
+ captureAppPageDocument() {
37
+ return captureResponseStoreAppPageDocument();
38
+ }
36
39
  deferNotModifiedResponse() {
37
40
  return deferResponseStoreNotModified();
38
41
  }
@@ -1,9 +1,14 @@
1
+ import { CdnAppPageDocument } from "vinext/shims/cdn-cache";
1
2
  import { WorkersResponseStore } from "@cloudflare/workers-response-store";
2
3
  import { CacheHandler, CacheHandlerValue, IncrementalCacheValue } from "vinext/shims/cache";
3
4
  //#region src/cache/response-store-data.runtime.d.ts
4
5
  export type ResponseStoreInvocationCapture = {
5
6
  admittedResponse?: Promise<Response>;
7
+ /** Store the page's document beside an App page RSC response. */
8
+ captureDocument?: boolean;
6
9
  captureRscData?: boolean;
10
+ /** The page's document, set before admission completes the response. */
11
+ document?: CdnAppPageDocument;
7
12
  /** The render matched the request's If-None-Match; the adapter sends the 304. */
8
13
  notModified?: boolean;
9
14
  rscData?: Promise<ArrayBuffer>;
@@ -17,6 +22,11 @@ export declare function setResponseStore(store: WorkersResponseStore): void;
17
22
  export declare function runWithResponseStoreInvocation<T>(serialized: string, replayable: boolean, callback: () => T, capture?: ResponseStoreInvocationCapture): T;
18
23
  /** Retain the RSC side stream only when the outer response-store invocation requested it. */
19
24
  export declare function captureResponseStoreRscData(rscData: Promise<ArrayBuffer>): void;
25
+ /**
26
+ * Retain the page's document only when the outer response-store invocation
27
+ * requested it. Admission settles it before it completes the response.
28
+ */
29
+ export declare function captureResponseStoreAppPageDocument(): ((document: CdnAppPageDocument | null) => void) | undefined;
20
30
  /** A shared invocation stores the page, so its adapter sends the page's 304. */
21
31
  export declare function deferResponseStoreNotModified(): boolean;
22
32
  /** Stream the foreground body while retaining an independent admission branch. */
@@ -26,6 +36,8 @@ export declare function captureResponseStoreDataRegeneration(key: string, callba
26
36
  export declare class WorkersResponseStoreCacheHandler implements CacheHandler {
27
37
  private readonly store;
28
38
  private readonly tagExpirations;
39
+ /** The revision behind each entry returned stale by the Store, for the write refreshing it. */
40
+ private readonly staleRevisions;
29
41
  constructor(store?: WorkersResponseStore);
30
42
  get(key: string, context?: Record<string, unknown>): Promise<CacheHandlerValue | null>;
31
43
  set(key: string, value: IncrementalCacheValue | null, context?: Record<string, unknown>): Promise<void>;
@@ -1,5 +1,6 @@
1
1
  import { isStaleTagInvalidation } from "./workers-cache-invalidation.js";
2
2
  import { encodeCloudflareCacheTag } from "./cdn-adapter.runtime.js";
3
+ import { readCacheTimestamp } from "../utils/cache-control-metadata.js";
3
4
  import { Buffer } from "node:buffer";
4
5
  import { AsyncLocalStorage } from "node:async_hooks";
5
6
  import { cacheForRequest } from "vinext/cache";
@@ -8,6 +9,8 @@ const ARRAY_BUFFER_MARKER = "$vinextArrayBuffer";
8
9
  const CACHE_MAX_AGE_SECONDS = 31536e4;
9
10
  const DATA_ENTRY_PATH = "__vinext_data";
10
11
  const REPLAYABLE_HEADER = "X-Vinext-Response-Store-Replayable";
12
+ /** The Store's internal header for when a response's generation started. */
13
+ const GENERATED_AT_HEADER = "X-Workers-Response-Store-Generated-At";
11
14
  const MAX_CACHE_TAG_HEADER_BYTES = 16384;
12
15
  /** How long past its revalidate a current Store's hit can still be marked fresh: write latency. */
13
16
  const STALE_FRESH_HIT_GRACE_MS = 5e3;
@@ -34,6 +37,17 @@ function captureResponseStoreRscData(rscData) {
34
37
  if (capture?.captureRscData) capture.rscData = rscData;
35
38
  else rscData.catch(() => {});
36
39
  }
40
+ /**
41
+ * Retain the page's document only when the outer response-store invocation
42
+ * requested it. Admission settles it before it completes the response.
43
+ */
44
+ function captureResponseStoreAppPageDocument() {
45
+ const capture = invocationStorage.getStore()?.capture;
46
+ if (!capture?.captureDocument) return void 0;
47
+ return (document) => {
48
+ capture.document = document ?? void 0;
49
+ };
50
+ }
37
51
  /** A shared invocation stores the page, so its adapter sends the page's 304. */
38
52
  function deferResponseStoreNotModified() {
39
53
  const capture = invocationStorage.getStore()?.capture;
@@ -202,6 +216,8 @@ function cachePolicy(revalidate, expire) {
202
216
  var WorkersResponseStoreCacheHandler = class {
203
217
  store;
204
218
  tagExpirations = cacheForRequest(() => /* @__PURE__ */ new Map());
219
+ /** The revision behind each entry returned stale by the Store, for the write refreshing it. */
220
+ staleRevisions = /* @__PURE__ */ new WeakMap();
205
221
  constructor(store = responseStore) {
206
222
  this.store = store;
207
223
  if (!store) throw new Error("[vinext] The Workers Response Store adapter must run through its generated Cloudflare worker entry.");
@@ -238,10 +254,15 @@ var WorkersResponseStoreCacheHandler = class {
238
254
  await this.store.purge({ pathPrefixes: [new URL(request.url).pathname] });
239
255
  return null;
240
256
  }
257
+ let tagExpiration;
241
258
  if (softTags.length) {
242
259
  const expiration = eagerExpiration ? await eagerExpiration : { value: await getTagExpiration() };
243
260
  if ("error" in expiration) throw expiration.error;
244
261
  if (expiration.value >= entry.lastModified) return null;
262
+ tagExpiration = {
263
+ tags: softTags,
264
+ expiration: expiration.value
265
+ };
245
266
  }
246
267
  const age = Date.now() - entry.lastModified;
247
268
  const requestedRevalidate = readCacheControlField(context, "revalidate");
@@ -252,12 +273,18 @@ var WorkersResponseStoreCacheHandler = class {
252
273
  if (requestedStale && storeStatus !== "BLOB-STALE") cacheState = "stale";
253
274
  } else if (typeof entry.cacheControl?.expire === "number" && age > entry.cacheControl.expire * 1e3) cacheState = "expired";
254
275
  else if (storeStatus === "BLOB-STALE" || requestedStale && typeof requestedRevalidate === "number" && (typeof storedRevalidate !== "number" || requestedRevalidate < storedRevalidate) || typeof storedRevalidate === "number" && storedRevalidate > 0 && age > storedRevalidate * 1e3 + STALE_FRESH_HIT_GRACE_MS) cacheState = "stale";
255
- return {
276
+ const value = {
256
277
  lastModified: entry.lastModified,
257
278
  value: entry.value,
258
279
  ...cacheState ? { cacheState } : {},
259
280
  ...entry.cacheControl ? { cacheControl: entry.cacheControl } : {}
260
281
  };
282
+ const revision = Number(response.headers.get("X-Workers-Response-Store-Revision"));
283
+ if (cacheState && storeStatus === "BLOB-STALE" && Number.isSafeInteger(revision) && revision > 0) this.staleRevisions.set(value, {
284
+ revision,
285
+ ...tagExpiration ? { tagExpiration } : {}
286
+ });
287
+ return value;
261
288
  } catch (error) {
262
289
  console.error(JSON.stringify({
263
290
  message: "Vinext response-store data lookup failed; treating as a cache miss",
@@ -302,11 +329,13 @@ var WorkersResponseStoreCacheHandler = class {
302
329
  id: DATA_REVALIDATOR_ID,
303
330
  args: [key, invocation.serialized]
304
331
  } : void 0;
332
+ const lastModified = readCacheTimestamp(context, Date.now());
305
333
  const response = new Response(serialize({
306
334
  ...cacheControl ? { cacheControl } : {},
307
- lastModified: Date.now(),
335
+ lastModified,
308
336
  value
309
337
  }), { headers: {
338
+ ...lastModified === context?.timestamp ? { [GENERATED_AT_HEADER]: String(lastModified) } : {},
310
339
  "Cache-Control": cachePolicy(revalidate, expire),
311
340
  ...tagHeader ? { "Cache-Tag": tagHeader } : {},
312
341
  "Content-Type": "application/json",
@@ -318,11 +347,17 @@ var WorkersResponseStoreCacheHandler = class {
318
347
  return;
319
348
  }
320
349
  if (regeneration && !revalidator) return;
350
+ const staleEntry = context?.staleEntry;
351
+ const replaces = staleEntry && typeof staleEntry === "object" ? this.staleRevisions.get(staleEntry) : void 0;
321
352
  await this.store.put(await cacheRequest(key), response, {
322
353
  ...revalidator ? { revalidator } : {},
323
354
  ...revalidator?.id !== "vinext:cache-function" ? { expiryBehavior: "miss" } : {},
324
355
  coalesce: true,
325
- purgeExisting: true
356
+ purgeExisting: true,
357
+ ...replaces ? {
358
+ replacesRevision: replaces.revision,
359
+ ...replaces.tagExpiration ? { replacesTagExpiration: replaces.tagExpiration } : {}
360
+ } : {}
326
361
  });
327
362
  }
328
363
  async revalidateTag(tags, durations) {
@@ -339,4 +374,4 @@ function createResponseStoreDataCacheAdapter() {
339
374
  return new WorkersResponseStoreCacheHandler();
340
375
  }
341
376
  //#endregion
342
- export { CACHE_FUNCTION_REVALIDATOR_ID, DATA_REVALIDATOR_ID, WorkersResponseStoreCacheHandler, captureResponseStoreDataRegeneration, captureResponseStoreRscData, createResponseStoreDataCacheAdapter as default, deferResponseStoreAdmission, deferResponseStoreNotModified, runWithResponseStoreInvocation, setResponseStore };
377
+ export { CACHE_FUNCTION_REVALIDATOR_ID, DATA_REVALIDATOR_ID, WorkersResponseStoreCacheHandler, captureResponseStoreAppPageDocument, captureResponseStoreDataRegeneration, captureResponseStoreRscData, createResponseStoreDataCacheAdapter as default, deferResponseStoreAdmission, deferResponseStoreNotModified, runWithResponseStoreInvocation, setResponseStore };
package/dist/cli.js CHANGED
@@ -45,6 +45,7 @@ async function deployCommand() {
45
45
  preview: parsed.preview,
46
46
  env: parsed.env,
47
47
  config: parsed.config,
48
+ viteConfig: parsed.viteConfig,
48
49
  skipBuild: parsed.skipBuild,
49
50
  dryRun: parsed.dryRun,
50
51
  verbose: parsed.verbose,
@@ -92,6 +93,7 @@ async function previewsCommand() {
92
93
  root: process.cwd(),
93
94
  env: options.mode,
94
95
  skipBuild: options.skipBuild,
96
+ viteConfig: options.viteConfig,
95
97
  target: {
96
98
  type: "preview",
97
99
  name: options.name
@@ -8,7 +8,7 @@ import { VinextCacheConfig } from "vinext/internal/config/prerender";
8
8
  * Returns true when the config both imports the package and invokes
9
9
  * `cloudflare(...)` in the plugin list.
10
10
  */
11
- export declare function viteConfigHasCloudflarePlugin(root: string): boolean;
11
+ export declare function viteConfigHasCloudflarePlugin(root: string, configFile?: string): boolean;
12
12
  /**
13
13
  * Detect whether the Vite config assigns a CDN or data cache adapter — i.e. the
14
14
  * `cdn` or `data` field of the `vinext({ cache })` option is given a value.
@@ -17,12 +17,12 @@ export declare function viteConfigHasCloudflarePlugin(root: string): boolean;
17
17
  * an unreadable or absent config is treated as configured so a deploy is never
18
18
  * blocked on a false negative.
19
19
  */
20
- export declare function viteConfigHasCacheAdapter(root: string): boolean;
20
+ export declare function viteConfigHasCacheAdapter(root: string, configFile?: string): boolean;
21
21
  export type ResolvedCdnAdapterConfig = {
22
22
  versionMetadataBinding: string;
23
23
  };
24
24
  export declare function resolveCdnAdapterConfig(cache: VinextCacheConfig | null | undefined): ResolvedCdnAdapterConfig | null;
25
- export declare function viteConfigHasImageAdapter(root: string): boolean;
25
+ export declare function viteConfigHasImageAdapter(root: string, configFile?: string): boolean;
26
26
  /**
27
27
  * Detect whether an existing user-authored Worker entry wires up a cache
28
28
  * backend imperatively via one of the `setCacheHandler` / `setDataCacheHandler`
@@ -48,5 +48,5 @@ export declare function formatMissingCacheAdapterError(options: {
48
48
  configFile?: string;
49
49
  typedConfig?: boolean;
50
50
  }): string;
51
- export declare function formatImageOptimizationHint(typedConfig?: boolean): string;
51
+ export declare function formatImageOptimizationHint(typedConfig?: boolean, configFile?: string): string;
52
52
  //#endregion
@@ -15,8 +15,8 @@ function escapeRegExp(value) {
15
15
  * Returns true when the config both imports the package and invokes
16
16
  * `cloudflare(...)` in the plugin list.
17
17
  */
18
- function viteConfigHasCloudflarePlugin(root) {
19
- const configPath = findViteConfigPath(root);
18
+ function viteConfigHasCloudflarePlugin(root, configFile) {
19
+ const configPath = configFile ?? findViteConfigPath(root);
20
20
  if (!configPath) return false;
21
21
  try {
22
22
  const content = fs.readFileSync(configPath, "utf-8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
@@ -75,8 +75,8 @@ function cacheFieldAssigned(cacheBlock, field) {
75
75
  * an unreadable or absent config is treated as configured so a deploy is never
76
76
  * blocked on a false negative.
77
77
  */
78
- function viteConfigHasCacheAdapter(root) {
79
- const configPath = findViteConfigPath(root);
78
+ function viteConfigHasCacheAdapter(root, configFile) {
79
+ const configPath = configFile ?? findViteConfigPath(root);
80
80
  if (!configPath) return true;
81
81
  let content;
82
82
  try {
@@ -104,8 +104,8 @@ function resolveCdnAdapterConfig(cache) {
104
104
  const options = cdn.options;
105
105
  return { versionMetadataBinding: typeof options?.versionMetadataBinding === "string" && options.versionMetadataBinding.length > 0 ? options.versionMetadataBinding : DEFAULT_CDN_VERSION_METADATA_BINDING };
106
106
  }
107
- function viteConfigHasImageAdapter(root) {
108
- const configPath = findViteConfigPath(root);
107
+ function viteConfigHasImageAdapter(root, configFile) {
108
+ const configPath = configFile ?? findViteConfigPath(root);
109
109
  if (!configPath) return true;
110
110
  try {
111
111
  const content = fs.readFileSync(configPath, "utf-8");
@@ -161,7 +161,10 @@ function formatMissingCacheAdapterError(options) {
161
161
  const configRef = options.configFile ? options.configFile : "your Vite config";
162
162
  return `[vinext] This app uses ISR / caching but no cache adapter is configured in ${configRef}.\n\n Production deployments need a persistent cache backend. Declare one on the\n vinext() plugin in ${configRef}:\n\n import { kvDataAdapter } from "@vinext/cloudflare/cache/kv-data-adapter";\n\n export default defineConfig({\n plugins: [\n vinext({\n cache: {\n data: kvDataAdapter(), // KV-backed data cache (binding: VINEXT_KV_CACHE)\n },\n }),\n cloudflare(),\n ],\n });\n\n` + (options.typedConfig ? ` Configure the VINEXT_KV_CACHE namespace binding in cloudflare.config.ts.` : " The VINEXT_KV_CACHE namespace binding is added to wrangler.jsonc for you.\n Create the namespace with:\n\n npx wrangler kv namespace create VINEXT_KV_CACHE");
163
163
  }
164
- function formatImageOptimizationHint(typedConfig = false) {
164
+ function formatImageOptimizationHint(typedConfig = false, configFile) {
165
+ if (configFile) return ` [vinext] next/image is served unoptimized. To enable edge image
166
+ optimization via Cloudflare Images, configure imagesOptimizer() in
167
+ ${configFile} and the IMAGES binding in ${typedConfig ? "cloudflare.config.ts" : "your Wrangler config"}.`;
165
168
  if (typedConfig) return " [vinext] next/image is served unoptimized. To enable edge image\n optimization via Cloudflare Images, configure imagesOptimizer() in your\n Vite config and the IMAGES binding in cloudflare.config.ts.";
166
169
  return " [vinext] next/image is served unoptimized. To enable edge image\n optimization via Cloudflare Images, run:\n\n vinext init --platform=cloudflare --image-optimization=cloudflare-images\n\n This adds the imagesOptimizer() option to your Vite config and the matching\n IMAGES binding to your Wrangler config without replacing existing settings.";
167
170
  }
@@ -16,6 +16,7 @@ function formatDeployHelp() {
16
16
  --env <name> Cloudflare mode for typed config; Wrangler environment otherwise
17
17
  --name <name> Custom Worker name for Wrangler config projects
18
18
  --config <path> Wrangler config path (default: wrangler.jsonc/json/toml)
19
+ --vite-config <path> Vite config file to build with (default: vite.config.*)
19
20
  --skip-build Skip the build step (use existing dist/)
20
21
  --dry-run Validate setup without building or deploying
21
22
  --verbose Print raw output from internal Cloudflare CLI commands
@@ -129,9 +130,10 @@ function formatPreviewsDeployHelp() {
129
130
  preview-name Optional Preview name (cf uses the CI or Git branch if omitted)
130
131
 
131
132
  Options:
132
- --mode <mode> Vite mode to build and deploy in (default: production)
133
- --skip-build Deploy existing Preview Build Output without rebuilding
134
- -h, --help Show this help
133
+ --mode <mode> Vite mode to build and deploy in (default: production)
134
+ --skip-build Deploy existing Preview Build Output without rebuilding
135
+ --vite-config <path> Vite config file to build with (default: vite.config.*)
136
+ -h, --help Show this help
135
137
 
136
138
  Examples:
137
139
  vinext-cloudflare previews deploy
package/dist/deploy.d.ts CHANGED
@@ -23,6 +23,8 @@ export type DeployOptions = {
23
23
  name?: string;
24
24
  /** Wrangler config path, relative to root unless absolute */
25
25
  config?: string;
26
+ /** Vite config file used to build and read deploy metadata, relative to root unless absolute */
27
+ viteConfig?: string;
26
28
  /** Skip the build step (assume already built) */
27
29
  skipBuild?: boolean;
28
30
  /** Dry run — validate setup but don't build or deploy */
@@ -85,6 +87,7 @@ export declare function parseDeployArgs(args: string[]): {
85
87
  env: string | undefined;
86
88
  name: string | undefined;
87
89
  config: string | undefined;
90
+ viteConfig: string | undefined;
88
91
  skipBuild: boolean;
89
92
  dryRun: boolean;
90
93
  verbose: boolean;
package/dist/deploy.js CHANGED
@@ -99,6 +99,7 @@ const deployArgOptions = {
99
99
  env: { type: "string" },
100
100
  name: { type: "string" },
101
101
  config: { type: "string" },
102
+ "vite-config": { type: "string" },
102
103
  "skip-build": {
103
104
  type: "boolean",
104
105
  default: false
@@ -249,6 +250,7 @@ function parseDeployArgs(args) {
249
250
  env: values.env?.trim() || void 0,
250
251
  name: values.name?.trim() || void 0,
251
252
  config: values.config?.trim() || void 0,
253
+ viteConfig: values["vite-config"]?.trim() || void 0,
252
254
  skipBuild: values["skip-build"],
253
255
  dryRun: values["dry-run"],
254
256
  verbose: values.verbose,
@@ -339,11 +341,11 @@ async function loadProjectWranglerApi(root) {
339
341
  function resolveViteBuildMode(deploymentTool, env) {
340
342
  return deploymentTool === "cf" ? env ?? "production" : "production";
341
343
  }
342
- async function loadDeployViteConfigMetadata(root, mode) {
344
+ async function loadDeployViteConfigMetadata(root, mode, configFile) {
343
345
  const plugins = (await (await loadProjectViteApi(root)).loadConfigFromFile({
344
346
  command: "build",
345
347
  mode
346
- }, void 0, root))?.config.plugins;
348
+ }, configFile, root))?.config.plugins;
347
349
  return {
348
350
  cacheConfig: await findVinextCacheConfigInPlugins(plugins),
349
351
  nextConfig: await findVinextNextConfigInPlugins(plugins),
@@ -351,7 +353,7 @@ async function loadDeployViteConfigMetadata(root, mode) {
351
353
  routeRootConfig: await findVinextRouteRootConfigInPlugins(plugins)
352
354
  };
353
355
  }
354
- async function runBuild(info, env, mode) {
356
+ async function runBuild(info, env, mode, configFile) {
355
357
  console.log("\n Building for Cloudflare Workers...\n");
356
358
  const { createBuilder } = await loadProjectViteApi(info.root);
357
359
  let completed = false;
@@ -362,6 +364,7 @@ async function runBuild(info, env, mode) {
362
364
  await (await createBuilder({
363
365
  root: info.root,
364
366
  mode,
367
+ configFile,
365
368
  [VINEXT_BUILD_LIFECYCLE_CONFIG]: invocation
366
369
  })).buildApp();
367
370
  });
@@ -1190,6 +1193,7 @@ async function deploy(options) {
1190
1193
  if (isPreview && deploymentTool !== "cf") throw new Error("Worker Previews require a cloudflare.config.ts project and the cf CLI.");
1191
1194
  if (deploymentTool === "cf" && options.name) throw new Error("--name is not supported for cloudflare.config.ts projects. Set `name` in cloudflare.config.ts instead.");
1192
1195
  const viteMode = resolveViteBuildMode(deploymentTool, deployEnv);
1196
+ const viteConfigFile = options.viteConfig ? path.resolve(root, options.viteConfig) : void 0;
1193
1197
  const wranglerFallbackEnv = deploymentTool === "cf" ? void 0 : deployEnv;
1194
1198
  if (!process.env.NODE_ENV) Reflect.set(process.env, "NODE_ENV", "production");
1195
1199
  loadDotenv({
@@ -1210,16 +1214,25 @@ async function deploy(options) {
1210
1214
  console.log(` Project: ${info.projectName}`);
1211
1215
  console.log(` Router: ${info.isAppRouter ? "App Router" : "Pages Router"}`);
1212
1216
  console.log(` ISR: ${info.hasISR ? "detected" : "none"}`);
1213
- const missingScaffolding = [!info.hasViteConfig && "Vite config", !info.hasWranglerConfig && "Wrangler config"].filter((value) => Boolean(value));
1217
+ if (viteConfigFile && !fs.existsSync(viteConfigFile)) throw new Error(`Vite config file not found: ${viteConfigFile}`);
1218
+ const hasViteConfig = Boolean(viteConfigFile) || info.hasViteConfig;
1219
+ const missingScaffolding = [!hasViteConfig && "Vite config", !info.hasWranglerConfig && "Wrangler config"].filter((value) => Boolean(value));
1214
1220
  if (missingScaffolding.length > 0) throw new Error(`Missing Cloudflare deployment setup: ${missingScaffolding.join(", ")}. Run \`vinext init --platform=cloudflare\` first.`);
1215
1221
  const missingDeps = getMissingDeps(info);
1216
1222
  if (missingDeps.length > 0) throw new Error(`Missing deployment dependencies: ${missingDeps.map((dependency) => dependency.name).join(", ")}. Run \`vinext init --platform=cloudflare\` first.`);
1217
1223
  if (deploymentTool === "cf" && !isCfCliInstalled(root)) throw new Error("Missing deployment dependencies: cf. Run `vinext init --platform=cloudflare` first.");
1218
- if (info.hasViteConfig && !viteConfigHasCloudflarePlugin(root)) throw new Error(formatMissingCloudflarePluginError({ isAppRouter: info.isAppRouter }));
1219
- if (info.hasISR && !viteConfigHasCacheAdapter(root) && !workerEntryHasCacheHandler(root)) throw new Error(formatMissingCacheAdapterError({ typedConfig: deploymentTool === "cf" }));
1220
- if (!viteConfigHasImageAdapter(root)) {
1224
+ if (hasViteConfig && !viteConfigHasCloudflarePlugin(root, viteConfigFile)) throw new Error(formatMissingCloudflarePluginError({
1225
+ isAppRouter: info.isAppRouter,
1226
+ configFile: viteConfigFile,
1227
+ initUpdatesConfig: !viteConfigFile
1228
+ }));
1229
+ if (info.hasISR && !viteConfigHasCacheAdapter(root, viteConfigFile) && !workerEntryHasCacheHandler(root)) throw new Error(formatMissingCacheAdapterError({
1230
+ configFile: viteConfigFile,
1231
+ typedConfig: deploymentTool === "cf"
1232
+ }));
1233
+ if (!viteConfigHasImageAdapter(root, viteConfigFile)) {
1221
1234
  console.log();
1222
- console.log(formatImageOptimizationHint(deploymentTool === "cf"));
1235
+ console.log(formatImageOptimizationHint(deploymentTool === "cf", viteConfigFile));
1223
1236
  }
1224
1237
  if (options.dryRun) {
1225
1238
  console.log("\n Dry run complete. No build or deploy performed.\n");
@@ -1233,7 +1246,7 @@ async function deploy(options) {
1233
1246
  }));
1234
1247
  return;
1235
1248
  }
1236
- const viteConfigMetadata = await withBuildContext(() => loadDeployViteConfigMetadata(info.root, viteMode));
1249
+ const viteConfigMetadata = await withBuildContext(() => loadDeployViteConfigMetadata(info.root, viteMode, viteConfigFile));
1237
1250
  const cdnAdapterConfig = resolveCdnAdapterConfig(viteConfigMetadata.cacheConfig);
1238
1251
  const nextConfig = await withBuildContext(async () => {
1239
1252
  const inlineNextConfig = viteConfigMetadata.nextConfig;
@@ -1259,7 +1272,7 @@ async function deploy(options) {
1259
1272
  const replacement = isPreview ? "Preview deployments render these routes on request." : options.warmCdnCache ? "Routes will be rendered and warmed through the staged Worker instead." : "Use --warm-cache to render and warm routes through the deployed Worker instead.";
1260
1273
  console.warn(`\n Warning: ${trigger} is ignored by Cloudflare deploy. ${replacement}`);
1261
1274
  }
1262
- if (!options.skipBuild) await withBuildContext(() => runBuild(info, cloudflareEnv, viteMode));
1275
+ if (!options.skipBuild) await withBuildContext(() => runBuild(info, cloudflareEnv, viteMode, viteConfigFile));
1263
1276
  else console.log("\n Skipping build (--skip-build)");
1264
1277
  const prerenderOutputDirs = resolvePrerenderOutputDirs(info.root, deploymentTool, viteConfigMetadata.routeRootConfig);
1265
1278
  const canWarmTpr = options.experimentalTPR && !shouldPrerenderLocally && hasBuildIdentityHeader;
@@ -5,5 +5,6 @@ export declare function parsePreviewsDeployArgs(args: string[]): {
5
5
  name: string | undefined;
6
6
  mode: string | undefined;
7
7
  skipBuild: boolean;
8
+ viteConfig: string | undefined;
8
9
  };
9
10
  //#endregion
@@ -28,7 +28,8 @@ function parsePreviewsDeployArgs(args) {
28
28
  "skip-build": {
29
29
  type: "boolean",
30
30
  default: false
31
- }
31
+ },
32
+ "vite-config": { type: "string" }
32
33
  }
33
34
  });
34
35
  if (positionals.length > 1) throw new Error("Expected at most one Preview name: previews deploy [preview-name].");
@@ -36,7 +37,8 @@ function parsePreviewsDeployArgs(args) {
36
37
  help: values.help,
37
38
  name: positionals[0] === void 0 ? void 0 : validatePreviewName(positionals[0]),
38
39
  mode: values.mode === void 0 ? void 0 : validateMode(values.mode),
39
- skipBuild: values["skip-build"]
40
+ skipBuild: values["skip-build"],
41
+ viteConfig: values["vite-config"]?.trim() || void 0
40
42
  };
41
43
  }
42
44
  //#endregion
@@ -6,5 +6,10 @@
6
6
  * helpers in the vinext core).
7
7
  */
8
8
  export declare function isUnknownRecord(value: unknown): value is Record<string, unknown>;
9
+ /**
10
+ * When a `"use cache"` entry's generation started (`ctx.timestamp`), which dates
11
+ * the entry like Next.js does. Absent, malformed or future values fall back to `now`.
12
+ */
13
+ export declare function readCacheTimestamp(ctx: Record<string, unknown> | undefined, now: number): number;
9
14
  export declare function readCacheControlNumberField(ctx: Record<string, unknown> | undefined, field: string): number | undefined;
10
15
  //#endregion
@@ -12,9 +12,17 @@ function readRecordField(ctx, field) {
12
12
  const value = ctx?.[field];
13
13
  return isUnknownRecord(value) ? value : void 0;
14
14
  }
15
+ /**
16
+ * When a `"use cache"` entry's generation started (`ctx.timestamp`), which dates
17
+ * the entry like Next.js does. Absent, malformed or future values fall back to `now`.
18
+ */
19
+ function readCacheTimestamp(ctx, now) {
20
+ const value = ctx?.timestamp;
21
+ return typeof value === "number" && Number.isFinite(value) && value > 0 && value <= now ? value : now;
22
+ }
15
23
  function readCacheControlNumberField(ctx, field) {
16
24
  const value = readRecordField(ctx, "cacheControl")?.[field] ?? ctx?.[field];
17
25
  return typeof value === "number" ? value : void 0;
18
26
  }
19
27
  //#endregion
20
- export { isUnknownRecord, readCacheControlNumberField };
28
+ export { isUnknownRecord, readCacheControlNumberField, readCacheTimestamp };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vinext/cloudflare",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
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.3"
51
+ "@cloudflare/workers-response-store": "1.1.4"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@types/node": "^25.9.2",
@@ -56,12 +56,12 @@
56
56
  "miniflare": "5.20260730.0-alpha",
57
57
  "vite": "npm:@voidzero-dev/vite-plus-core@1.3.0",
58
58
  "vite-plus": "1.3.0",
59
- "vinext": "1.3.1"
59
+ "vinext": "1.4.0"
60
60
  },
61
61
  "peerDependencies": {
62
62
  "@cloudflare/vite-plugin": "*",
63
63
  "cf": "*",
64
- "vinext": "^1.3.1"
64
+ "vinext": "^1.4.0"
65
65
  },
66
66
  "peerDependenciesMeta": {
67
67
  "@cloudflare/vite-plugin": {