@vinext/cloudflare 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,15 +1,19 @@
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";
6
7
  //#region src/cache/response-store-data.runtime.ts
7
8
  const ARRAY_BUFFER_MARKER = "$vinextArrayBuffer";
8
9
  const CACHE_MAX_AGE_SECONDS = 31536e4;
9
- const CACHE_MAX_AGE = `public, max-age=${CACHE_MAX_AGE_SECONDS}`;
10
10
  const DATA_ENTRY_PATH = "__vinext_data";
11
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";
12
14
  const MAX_CACHE_TAG_HEADER_BYTES = 16384;
15
+ /** How long past its revalidate a current Store's hit can still be marked fresh: write latency. */
16
+ const STALE_FRESH_HIT_GRACE_MS = 5e3;
13
17
  const DATA_REVALIDATOR_ID = "vinext:data";
14
18
  const CACHE_FUNCTION_REVALIDATOR_ID = "vinext:cache-function";
15
19
  const invocationStorage = new AsyncLocalStorage();
@@ -33,6 +37,17 @@ function captureResponseStoreRscData(rscData) {
33
37
  if (capture?.captureRscData) capture.rscData = rscData;
34
38
  else rscData.catch(() => {});
35
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
+ }
36
51
  /** A shared invocation stores the page, so its adapter sends the page's 304. */
37
52
  function deferResponseStoreNotModified() {
38
53
  const capture = invocationStorage.getStore()?.capture;
@@ -194,13 +209,15 @@ function deserialize(value) {
194
209
  return entry;
195
210
  }
196
211
  function cachePolicy(revalidate, expire) {
197
- if (revalidate === false || revalidate === void 0) return `public, max-age=${CACHE_MAX_AGE_SECONDS}`;
198
- const staleSeconds = expire === void 0 ? CACHE_MAX_AGE_SECONDS : Math.max(0, expire - revalidate);
199
- return `public, max-age=${Math.max(0, revalidate)}, stale-while-revalidate=${staleSeconds}`;
212
+ if (revalidate === false || revalidate === void 0) return `public, max-age=${expire === void 0 ? CACHE_MAX_AGE_SECONDS : Math.max(1, Math.ceil(expire))}`;
213
+ const maxAge = revalidate > 0 ? Math.max(1, Math.floor(revalidate)) : 0;
214
+ return `public, max-age=${maxAge}, stale-while-revalidate=${expire === void 0 ? CACHE_MAX_AGE_SECONDS : Math.max(0, Math.ceil(expire) - maxAge)}`;
200
215
  }
201
216
  var WorkersResponseStoreCacheHandler = class {
202
217
  store;
203
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();
204
221
  constructor(store = responseStore) {
205
222
  this.store = store;
206
223
  if (!store) throw new Error("[vinext] The Workers Response Store adapter must run through its generated Cloudflare worker entry.");
@@ -237,25 +254,37 @@ var WorkersResponseStoreCacheHandler = class {
237
254
  await this.store.purge({ pathPrefixes: [new URL(request.url).pathname] });
238
255
  return null;
239
256
  }
257
+ let tagExpiration;
240
258
  if (softTags.length) {
241
259
  const expiration = eagerExpiration ? await eagerExpiration : { value: await getTagExpiration() };
242
260
  if ("error" in expiration) throw expiration.error;
243
261
  if (expiration.value >= entry.lastModified) return null;
262
+ tagExpiration = {
263
+ tags: softTags,
264
+ expiration: expiration.value
265
+ };
244
266
  }
245
267
  const age = Date.now() - entry.lastModified;
246
268
  const requestedRevalidate = readCacheControlField(context, "revalidate");
269
+ const storedRevalidate = entry.cacheControl?.revalidate;
247
270
  const requestedStale = typeof requestedRevalidate === "number" && requestedRevalidate > 0 && age > requestedRevalidate * 1e3;
248
271
  let cacheState;
249
272
  if (response.headers.get(REPLAYABLE_HEADER) === "1") {
250
273
  if (requestedStale && storeStatus !== "BLOB-STALE") cacheState = "stale";
251
274
  } else if (typeof entry.cacheControl?.expire === "number" && age > entry.cacheControl.expire * 1e3) cacheState = "expired";
252
- else if (requestedStale || storeStatus === "BLOB-STALE" || typeof entry.cacheControl?.revalidate === "number" && entry.cacheControl.revalidate > 0 && age > entry.cacheControl.revalidate * 1e3) cacheState = "stale";
253
- return {
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";
276
+ const value = {
254
277
  lastModified: entry.lastModified,
255
278
  value: entry.value,
256
279
  ...cacheState ? { cacheState } : {},
257
280
  ...entry.cacheControl ? { cacheControl: entry.cacheControl } : {}
258
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;
259
288
  } catch (error) {
260
289
  console.error(JSON.stringify({
261
290
  message: "Vinext response-store data lookup failed; treating as a cache miss",
@@ -274,9 +303,11 @@ var WorkersResponseStoreCacheHandler = class {
274
303
  let revalidate = readCacheControlField(context, "revalidate");
275
304
  if (value && "revalidate" in value) revalidate = value.revalidate;
276
305
  if (revalidate === 0) return;
306
+ if (typeof revalidate === "number" && !Number.isFinite(revalidate)) revalidate = false;
277
307
  const rawExpire = readCacheControlField(context, "expire");
278
- const expire = typeof rawExpire === "number" ? rawExpire : void 0;
279
- const stale = readCacheControlField(context, "stale");
308
+ const expire = typeof rawExpire === "number" && Number.isFinite(rawExpire) ? rawExpire : void 0;
309
+ const rawStale = readCacheControlField(context, "stale");
310
+ const stale = typeof rawStale === "number" && Number.isFinite(rawStale) ? rawStale : void 0;
280
311
  const cacheControl = typeof revalidate === "number" || revalidate === false ? {
281
312
  revalidate,
282
313
  ...typeof expire === "number" ? { expire } : {},
@@ -298,12 +329,14 @@ var WorkersResponseStoreCacheHandler = class {
298
329
  id: DATA_REVALIDATOR_ID,
299
330
  args: [key, invocation.serialized]
300
331
  } : void 0;
332
+ const lastModified = readCacheTimestamp(context, Date.now());
301
333
  const response = new Response(serialize({
302
334
  ...cacheControl ? { cacheControl } : {},
303
- lastModified: Date.now(),
335
+ lastModified,
304
336
  value
305
337
  }), { headers: {
306
- "Cache-Control": revalidator ? cachePolicy(revalidate, expire) : CACHE_MAX_AGE,
338
+ ...lastModified === context?.timestamp ? { [GENERATED_AT_HEADER]: String(lastModified) } : {},
339
+ "Cache-Control": cachePolicy(revalidate, expire),
307
340
  ...tagHeader ? { "Cache-Tag": tagHeader } : {},
308
341
  "Content-Type": "application/json",
309
342
  ...revalidator ? { [REPLAYABLE_HEADER]: "1" } : {}
@@ -314,11 +347,17 @@ var WorkersResponseStoreCacheHandler = class {
314
347
  return;
315
348
  }
316
349
  if (regeneration && !revalidator) return;
350
+ const staleEntry = context?.staleEntry;
351
+ const replaces = staleEntry && typeof staleEntry === "object" ? this.staleRevisions.get(staleEntry) : void 0;
317
352
  await this.store.put(await cacheRequest(key), response, {
318
353
  ...revalidator ? { revalidator } : {},
319
354
  ...revalidator?.id !== "vinext:cache-function" ? { expiryBehavior: "miss" } : {},
320
355
  coalesce: true,
321
- purgeExisting: true
356
+ purgeExisting: true,
357
+ ...replaces ? {
358
+ replacesRevision: replaces.revision,
359
+ ...replaces.tagExpiration ? { replacesTagExpiration: replaces.tagExpiration } : {}
360
+ } : {}
322
361
  });
323
362
  }
324
363
  async revalidateTag(tags, durations) {
@@ -335,4 +374,4 @@ function createResponseStoreDataCacheAdapter() {
335
374
  return new WorkersResponseStoreCacheHandler();
336
375
  }
337
376
  //#endregion
338
- 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 };
@@ -26,4 +26,5 @@ export default function createStaticAssetsCacheAdapter({ env, options }?: {
26
26
  env?: Record<string, unknown>;
27
27
  options?: StaticAssetsAdapterOptions;
28
28
  }): CdnCacheAdapter;
29
- //#endregion
29
+ //#endregion
30
+ export {};
@@ -27,4 +27,5 @@ export declare function hasCacheMethod<Name extends CacheMethodName>(value: unkn
27
27
  * `undefined` when the cache supports neither.
28
28
  */
29
29
  export declare function invalidateOrPurge(cache: unknown, options: CacheInvalidationOptions): unknown;
30
- //#endregion
30
+ //#endregion
31
+ export {};
@@ -130,4 +130,5 @@ export declare function waitForCdnWarmTargetReadiness(options: Pick<CdnWarmOptio
130
130
  }): Promise<CdnWarmReadinessResult>;
131
131
  export declare function warmCdnCache(options: CdnWarmOptions): Promise<CdnWarmResult>;
132
132
  export declare function warmCdnCacheFromPrerender(options: PrerenderCdnWarmOptions): Promise<CdnWarmResult>;
133
- //#endregion
133
+ //#endregion
134
+ export {};
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
  }