@vinext/cloudflare 1.2.1 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,8 @@
1
+ import { isCanonicalAppPage } from "./app-page-parts.js";
1
2
  import { SHARED_RESPONSE_STAGE_HEADER, finalizeGatewayResponse } from "./browser-cache-policy.js";
2
- 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";
3
+ import { captureResponseStoreDataRegeneration, runWithResponseStoreInvocation, setResponseStore, trackResponseStoreRegenerationWork } from "./response-store-data.runtime.js";
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";
@@ -22,7 +25,10 @@ function stageContext(ctx, env) {
22
25
  hostRuntime: "worker",
23
26
  passThroughOnException: () => ctx.passThroughOnException(),
24
27
  ...ctx.props === void 0 ? {} : { props: ctx.props },
25
- waitUntil: (promise) => ctx.waitUntil(promise)
28
+ waitUntil: (promise) => {
29
+ trackResponseStoreRegenerationWork(promise);
30
+ ctx.waitUntil(promise);
31
+ }
26
32
  });
27
33
  }
28
34
  function safeProps(props) {
@@ -58,6 +64,15 @@ function prepareInvocation(request, props) {
58
64
  function serializeInvocation(request, props) {
59
65
  return JSON.stringify(prepareInvocation(request, props));
60
66
  }
67
+ function serializeDispatchInvocation(invocation) {
68
+ return JSON.stringify({
69
+ ...invocation,
70
+ request: {
71
+ ...invocation.request,
72
+ headers: [...invocation.request.headers, ["host", new URL(invocation.request.url).host]]
73
+ }
74
+ });
75
+ }
61
76
  function parseInvocation(value) {
62
77
  if (typeof value !== "string") throw new TypeError("Invalid vinext response-store invocation");
63
78
  const invocation = JSON.parse(value);
@@ -95,16 +110,29 @@ function createVinextResponseStoreOptions(configuration) {
95
110
  async regenerate(input, { env, ctx }) {
96
111
  if (input.id === ROUTE_REVALIDATOR_ID) {
97
112
  const invocation = parseInvocation(input.args.at(-1));
98
- const response = await invokeResponseStage(restoreRequest(invocation), invocation.props, env, ctx, {
113
+ const request = restoreRequest(invocation);
114
+ const otherPart = input.args.length > 1 && input.args[0] === PAGE_PARTS_REVALIDATOR_ARG ? otherPagePart(request, invocation.props) : void 0;
115
+ const capture = otherPagePartCapture(otherPart);
116
+ const response = await invokeResponseStage(request, invocation.props, env, ctx, {
99
117
  cache: "shared",
100
118
  regeneratesStoredEntry: true,
101
119
  renderWholeDocument: true
102
- });
120
+ }, capture);
103
121
  if (!isCacheable(response)) {
104
122
  await response.body?.cancel().catch(() => {});
123
+ capture.rscData?.catch(() => {});
105
124
  throw new Error("Vinext response-stage regeneration was not cacheable");
106
125
  }
107
- return withoutRequestScopedHeaders(response, invocation.props);
126
+ const stored = withoutRequestScopedHeaders(response, invocation.props);
127
+ try {
128
+ await putOtherPagePart(otherPart, capture, stored);
129
+ } catch (error) {
130
+ console.error(JSON.stringify({
131
+ message: "Vinext response-store regeneration failed to store the page's other part",
132
+ error: error instanceof Error ? error.message : String(error)
133
+ }));
134
+ }
135
+ return stored;
108
136
  }
109
137
  if (input.id === "vinext:cache-function" && typeof input.args[0] === "string" && typeof input.args[1] === "string") {
110
138
  const invocation = JSON.parse(input.args[1]);
@@ -137,7 +165,8 @@ async function cacheRequest(invocation) {
137
165
  url.searchParams.set(RESPONSE_STORE_KEY_PARAM, `v1.${[...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`);
138
166
  return new Request(url, {
139
167
  headers: CACHE_REQUEST_VARY_HEADERS,
140
- method: "GET"
168
+ method: "GET",
169
+ redirect: "manual"
141
170
  });
142
171
  }
143
172
  /**
@@ -157,6 +186,116 @@ function withoutRequestScopedHeaders(response, responseStageProps) {
157
186
  statusText: response.statusText
158
187
  });
159
188
  }
189
+ /**
190
+ * The revalidator of a page part. One whose render stores the page's other
191
+ * part says so, as a public config policy's full-URL entry stores its own.
192
+ */
193
+ function routeRevalidator(serializedInvocation, storesOtherPart) {
194
+ return {
195
+ id: ROUTE_REVALIDATOR_ID,
196
+ args: storesOtherPart ? [PAGE_PARTS_REVALIDATOR_ARG, serializedInvocation] : [serializedInvocation]
197
+ };
198
+ }
199
+ /** The invocation an RSC navigation to an HTML invocation's page stores under. */
200
+ function rscPartInvocation(htmlRequest, htmlProps) {
201
+ return prepareInvocation(new Request(new URL(createCanonicalRscRequestUrl(htmlRequest.url), htmlRequest.url), { headers: createCanonicalRscRequestHeaders() }), {
202
+ ...htmlProps,
203
+ isRscRequest: true,
204
+ renderMode: "navigation"
205
+ });
206
+ }
207
+ /** The invocation an HTML request for an RSC invocation's page stores under. */
208
+ function documentPartInvocation(rscRequest, rscProps) {
209
+ const url = new URL(rscRequest.url);
210
+ stripRscCacheBustingSearchParam(url);
211
+ return prepareInvocation(new Request(url), {
212
+ ...rscProps,
213
+ isRscRequest: false
214
+ });
215
+ }
216
+ /**
217
+ * The page's RSC payload an HTML render produced, with its document's headers
218
+ * and status.
219
+ */
220
+ function rscPartResponse(htmlResponse, rscData) {
221
+ const headers = new Headers(htmlResponse.headers);
222
+ headers.delete("Content-Length");
223
+ headers.delete("Link");
224
+ headers.delete("X-Vinext-Response-Store-Replayable");
225
+ headers.delete(VINEXT_PARAMS_HEADER);
226
+ headers.delete(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER);
227
+ headers.set("Content-Type", VINEXT_RSC_CONTENT_TYPE);
228
+ headers.set("Vary", VINEXT_RSC_VARY_HEADER$1);
229
+ applyRscCompatibilityIdHeader(headers);
230
+ applyRscDeploymentIdHeader(headers);
231
+ if (isStoredErrorStatus(htmlResponse.status)) headers.set(VINEXT_SPECIAL_ERROR_STATUS_HEADER, String(htmlResponse.status));
232
+ return new Response(rscData, {
233
+ headers,
234
+ status: htmlResponse.status >= 300 && htmlResponse.status < 400 ? 200 : htmlResponse.status
235
+ });
236
+ }
237
+ /**
238
+ * The page's document an RSC render produced, with the headers an HTML request
239
+ * for the page stores: the RSC response's, without the payload's own.
240
+ */
241
+ function documentPartResponse(rscResponse, document) {
242
+ const headers = new Headers(rscResponse.headers);
243
+ for (const name of [
244
+ "Content-Length",
245
+ NEXTJS_DEPLOYMENT_ID_HEADER,
246
+ VINEXT_RSC_BUILD_ID_HEADER,
247
+ VINEXT_RSC_COMPATIBILITY_ID_HEADER,
248
+ VINEXT_RSC_COMPLETION_METADATA_HEADER,
249
+ VINEXT_STALE_TIME_PENDING_HEADER
250
+ ]) headers.delete(name);
251
+ headers.set("Content-Type", "text/html; charset=utf-8");
252
+ if (document.linkHeader) headers.append("Link", document.linkHeader);
253
+ return new Response(document.html, {
254
+ headers,
255
+ status: rscResponse.status
256
+ });
257
+ }
258
+ /**
259
+ * The other part of the page a canonical App page render stores. Next.js
260
+ * keeps a page's document and RSC payload in one entry, so a render that
261
+ * stores one part also stores the other, under the key a request for that
262
+ * part reads. A variant has no other part.
263
+ */
264
+ function otherPagePart(identityRequest, identityProps) {
265
+ if (isCanonicalAppPage(identityProps, false)) return {
266
+ invocation: rscPartInvocation(identityRequest, identityProps),
267
+ kind: "rsc"
268
+ };
269
+ if (isCanonicalAppPage(identityProps, true)) return {
270
+ invocation: documentPartInvocation(identityRequest, identityProps),
271
+ kind: "document"
272
+ };
273
+ }
274
+ /** What a render captures for the other part of its page. */
275
+ function otherPagePartCapture(part) {
276
+ if (part?.kind === "rsc") return { captureRscData: true };
277
+ if (part?.kind === "document") return { captureDocument: true };
278
+ return {};
279
+ }
280
+ /**
281
+ * Store the other part of the page from the render that stores this one.
282
+ * Admission settles the document before it completes the response. A miss
283
+ * stores it first, so a failed write leaves the render's own part missing too
284
+ * and the next miss retries both. A regeneration's other part stays stale
285
+ * when it fails, so the next read of that part regenerates it. Concurrent
286
+ * misses coalesce each key on its own, so their parts can come from different
287
+ * renders of the page.
288
+ */
289
+ async function putOtherPagePart(part, capture, stored) {
290
+ let response;
291
+ if (part?.kind === "rsc" && capture.rscData) response = rscPartResponse(stored, await capture.rscData);
292
+ else if (part?.kind === "document" && capture.document) response = documentPartResponse(stored, capture.document);
293
+ else return;
294
+ if (!(await responseStore.put(await cacheRequest(part.invocation), response, {
295
+ coalesce: true,
296
+ revalidator: routeRevalidator(JSON.stringify(part.invocation), true)
297
+ })).backingStoreUpdated) throw new Error(`Vinext response-store did not publish the App page ${part.kind} part`);
298
+ }
160
299
  function isStoredErrorStatus(status) {
161
300
  return status === 401 || status === 403 || status === 404;
162
301
  }
@@ -275,17 +414,9 @@ const handler = { async fetch(request, env, ctx) {
275
414
  const identityRequest = options.cacheIdentity?.request ?? stageRequest;
276
415
  const identityProps = options.cacheIdentity?.props ?? props;
277
416
  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;
417
+ const otherPart = isWarmup || options.cacheIdentity?.queryFree ? otherPagePart(identityRequest, identityProps) : void 0;
418
+ const rscKey = isWarmup && otherPart?.kind === "rsc" ? await cacheRequest(otherPart.invocation) : void 0;
286
419
  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
420
  const key = await cacheRequest(invocation);
290
421
  const stored = await readStoredResponse(key);
291
422
  if (stored) {
@@ -297,8 +428,12 @@ const handler = { async fetch(request, env, ctx) {
297
428
  }
298
429
  stored.body?.cancel().catch(() => {});
299
430
  }
300
- const capture = rscSeed ? { captureRscData: true } : isWarmup ? {} : { streamResponse: true };
431
+ const capture = {
432
+ ...isWarmup ? {} : { streamResponse: true },
433
+ ...otherPagePartCapture(otherPart)
434
+ };
301
435
  const serializedInvocation = JSON.stringify(invocation);
436
+ const revalidatorInvocation = !options.cacheIdentity ? serializeDispatchInvocation(invocation) : options.cacheIdentity.regenerateFromDispatch ? serializeDispatchInvocation(prepareInvocation(stageRequest, props)) : serializedInvocation;
302
437
  const rendered = await invokeResponseStage(stageRequest, props, env, ctx, isWarmup ? {
303
438
  cache: "shared",
304
439
  renderWholeDocument: true
@@ -311,14 +446,19 @@ const handler = { async fetch(request, env, ctx) {
311
446
  ctx.waitUntil(capture.admittedResponse.then(async (admitted) => {
312
447
  if (!isCacheable(admitted)) {
313
448
  await admitted.body?.cancel().catch(() => {});
449
+ capture.rscData?.catch(() => {});
314
450
  return;
315
451
  }
316
- await responseStore.put(key, withoutRequestScopedHeaders(admitted, props), {
452
+ const stored = withoutRequestScopedHeaders(admitted, props);
453
+ try {
454
+ await putOtherPagePart(otherPart, capture, stored);
455
+ } catch (error) {
456
+ await stored.body?.cancel().catch(() => {});
457
+ throw error;
458
+ }
459
+ await responseStore.put(key, stored, {
317
460
  coalesce: true,
318
- revalidator: {
319
- id: ROUTE_REVALIDATOR_ID,
320
- args: [serializedInvocation]
321
- }
461
+ revalidator: routeRevalidator(revalidatorInvocation, otherPart !== void 0)
322
462
  });
323
463
  }).catch((error) => {
324
464
  console.error(JSON.stringify({
@@ -335,22 +475,20 @@ const handler = { async fetch(request, env, ctx) {
335
475
  }), "MISS", props) : publicResponse(notModified, "MISS", props, true);
336
476
  }
337
477
  if (!isCacheable(rendered)) {
338
- capture?.rscData?.catch(() => {});
478
+ capture.rscData?.catch(() => {});
339
479
  return publicResponse(foreground(rendered), "BYPASS", props);
340
480
  }
341
- if (rscSeed && !capture?.rscData) {
481
+ if (rscKey && !capture.rscData) {
342
482
  await rendered.body?.cancel();
343
483
  throw new Error("Vinext response-store warmup did not capture the App page RSC payload");
344
484
  }
345
485
  const [foregroundBody, cacheBody] = rendered.body ? rendered.body.tee() : [null, null];
346
486
  const cacheResponse = withoutRequestScopedHeaders(new Response(cacheBody, rendered), props);
347
487
  try {
488
+ await putOtherPagePart(otherPart, capture, cacheResponse);
348
489
  await responseStore.put(key, cacheResponse, {
349
490
  coalesce: true,
350
- revalidator: {
351
- id: ROUTE_REVALIDATOR_ID,
352
- args: [serializedInvocation]
353
- }
491
+ revalidator: routeRevalidator(revalidatorInvocation, otherPart !== void 0)
354
492
  });
355
493
  } catch (error) {
356
494
  if (isWarmup) throw error;
@@ -360,31 +498,6 @@ const handler = { async fetch(request, env, ctx) {
360
498
  error: error instanceof Error ? error.message : String(error)
361
499
  }));
362
500
  }
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
501
  return publicResponse(foreground(new Response(foregroundBody, rendered)), "MISS", props);
389
502
  };
390
503
  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,15 +22,24 @@ 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. */
23
33
  export declare function deferResponseStoreAdmission(response: Response, complete: (response: Response) => Promise<Response>): Response | null;
34
+ /** Keeps work a regeneration hands to waitUntil inside the regeneration. */
35
+ export declare function trackResponseStoreRegenerationWork(work: Promise<unknown>): void;
24
36
  /** Re-render an invocation and return the exact rewritten data-cache entry. */
25
37
  export declare function captureResponseStoreDataRegeneration(key: string, callback: () => Promise<void>): Promise<Response>;
26
38
  export declare class WorkersResponseStoreCacheHandler implements CacheHandler {
27
39
  private readonly store;
28
40
  private readonly tagExpirations;
41
+ /** The revision behind each entry returned stale by the Store, for the write refreshing it. */
42
+ private readonly staleRevisions;
29
43
  constructor(store?: WorkersResponseStore);
30
44
  get(key: string, context?: Record<string, unknown>): Promise<CacheHandlerValue | null>;
31
45
  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;
@@ -143,6 +157,10 @@ function deferResponseStoreAdmission(response, complete) {
143
157
  writer.closed.catch((error) => finishAdmission({ reason: error }));
144
158
  return new Response(foreground, response);
145
159
  }
160
+ /** Keeps work a regeneration hands to waitUntil inside the regeneration. */
161
+ function trackResponseStoreRegenerationWork(work) {
162
+ regenerationStorage.getStore()?.sideWrites.push(work.then(() => void 0, () => void 0));
163
+ }
146
164
  /** Re-render an invocation and return the exact rewritten data-cache entry. */
147
165
  async function captureResponseStoreDataRegeneration(key, callback) {
148
166
  const scope = {
@@ -202,6 +220,8 @@ function cachePolicy(revalidate, expire) {
202
220
  var WorkersResponseStoreCacheHandler = class {
203
221
  store;
204
222
  tagExpirations = cacheForRequest(() => /* @__PURE__ */ new Map());
223
+ /** The revision behind each entry returned stale by the Store, for the write refreshing it. */
224
+ staleRevisions = /* @__PURE__ */ new WeakMap();
205
225
  constructor(store = responseStore) {
206
226
  this.store = store;
207
227
  if (!store) throw new Error("[vinext] The Workers Response Store adapter must run through its generated Cloudflare worker entry.");
@@ -238,10 +258,15 @@ var WorkersResponseStoreCacheHandler = class {
238
258
  await this.store.purge({ pathPrefixes: [new URL(request.url).pathname] });
239
259
  return null;
240
260
  }
261
+ let tagExpiration;
241
262
  if (softTags.length) {
242
263
  const expiration = eagerExpiration ? await eagerExpiration : { value: await getTagExpiration() };
243
264
  if ("error" in expiration) throw expiration.error;
244
265
  if (expiration.value >= entry.lastModified) return null;
266
+ tagExpiration = {
267
+ tags: softTags,
268
+ expiration: expiration.value
269
+ };
245
270
  }
246
271
  const age = Date.now() - entry.lastModified;
247
272
  const requestedRevalidate = readCacheControlField(context, "revalidate");
@@ -252,12 +277,18 @@ var WorkersResponseStoreCacheHandler = class {
252
277
  if (requestedStale && storeStatus !== "BLOB-STALE") cacheState = "stale";
253
278
  } else if (typeof entry.cacheControl?.expire === "number" && age > entry.cacheControl.expire * 1e3) cacheState = "expired";
254
279
  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 {
280
+ const value = {
256
281
  lastModified: entry.lastModified,
257
282
  value: entry.value,
258
283
  ...cacheState ? { cacheState } : {},
259
284
  ...entry.cacheControl ? { cacheControl: entry.cacheControl } : {}
260
285
  };
286
+ const revision = Number(response.headers.get("X-Workers-Response-Store-Revision"));
287
+ if (cacheState && storeStatus === "BLOB-STALE" && Number.isSafeInteger(revision) && revision > 0) this.staleRevisions.set(value, {
288
+ revision,
289
+ ...tagExpiration ? { tagExpiration } : {}
290
+ });
291
+ return value;
261
292
  } catch (error) {
262
293
  console.error(JSON.stringify({
263
294
  message: "Vinext response-store data lookup failed; treating as a cache miss",
@@ -268,8 +299,7 @@ var WorkersResponseStoreCacheHandler = class {
268
299
  }
269
300
  set(key, value, context) {
270
301
  const write = this.write(key, value, context);
271
- const regeneration = regenerationStorage.getStore();
272
- if (regeneration && regeneration.targetKey !== key) regeneration.sideWrites.push(write);
302
+ regenerationStorage.getStore()?.sideWrites.push(write);
273
303
  return write;
274
304
  }
275
305
  async write(key, value, context) {
@@ -302,11 +332,13 @@ var WorkersResponseStoreCacheHandler = class {
302
332
  id: DATA_REVALIDATOR_ID,
303
333
  args: [key, invocation.serialized]
304
334
  } : void 0;
335
+ const lastModified = readCacheTimestamp(context, Date.now());
305
336
  const response = new Response(serialize({
306
337
  ...cacheControl ? { cacheControl } : {},
307
- lastModified: Date.now(),
338
+ lastModified,
308
339
  value
309
340
  }), { headers: {
341
+ ...lastModified === context?.timestamp ? { [GENERATED_AT_HEADER]: String(lastModified) } : {},
310
342
  "Cache-Control": cachePolicy(revalidate, expire),
311
343
  ...tagHeader ? { "Cache-Tag": tagHeader } : {},
312
344
  "Content-Type": "application/json",
@@ -318,11 +350,17 @@ var WorkersResponseStoreCacheHandler = class {
318
350
  return;
319
351
  }
320
352
  if (regeneration && !revalidator) return;
353
+ const staleEntry = context?.staleEntry;
354
+ const replaces = staleEntry && typeof staleEntry === "object" ? this.staleRevisions.get(staleEntry) : void 0;
321
355
  await this.store.put(await cacheRequest(key), response, {
322
356
  ...revalidator ? { revalidator } : {},
323
357
  ...revalidator?.id !== "vinext:cache-function" ? { expiryBehavior: "miss" } : {},
324
358
  coalesce: true,
325
- purgeExisting: true
359
+ purgeExisting: true,
360
+ ...replaces ? {
361
+ replacesRevision: replaces.revision,
362
+ ...replaces.tagExpiration ? { replacesTagExpiration: replaces.tagExpiration } : {}
363
+ } : {}
326
364
  });
327
365
  }
328
366
  async revalidateTag(tags, durations) {
@@ -339,4 +377,4 @@ function createResponseStoreDataCacheAdapter() {
339
377
  return new WorkersResponseStoreCacheHandler();
340
378
  }
341
379
  //#endregion
342
- export { CACHE_FUNCTION_REVALIDATOR_ID, DATA_REVALIDATOR_ID, WorkersResponseStoreCacheHandler, captureResponseStoreDataRegeneration, captureResponseStoreRscData, createResponseStoreDataCacheAdapter as default, deferResponseStoreAdmission, deferResponseStoreNotModified, runWithResponseStoreInvocation, setResponseStore };
380
+ export { CACHE_FUNCTION_REVALIDATOR_ID, DATA_REVALIDATOR_ID, WorkersResponseStoreCacheHandler, captureResponseStoreAppPageDocument, captureResponseStoreDataRegeneration, captureResponseStoreRscData, createResponseStoreDataCacheAdapter as default, deferResponseStoreAdmission, deferResponseStoreNotModified, runWithResponseStoreInvocation, setResponseStore, trackResponseStoreRegenerationWork };
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;