@vinext/cloudflare 1.1.0 → 1.2.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.
@@ -132,6 +132,7 @@ function toEdgeCacheControl(cacheControl) {
132
132
  */
133
133
  const MAX_CACHE_TAG_BYTES = 16384;
134
134
  const CACHE_TAG_PREFIX = "vinext-";
135
+ const MAX_TAGS_PER_PURGE = 100;
135
136
  /** Encode a case-sensitive Next.js tag into a fixed Workers Cache tag. */
136
137
  function encodeCloudflareCacheTag(tag) {
137
138
  return `${CACHE_TAG_PREFIX}${fnv1a64(`0:${tag}`)}${fnv1a64(`1:${tag}`)}`;
@@ -222,11 +223,16 @@ var CloudflareCdnCacheAdapter = class {
222
223
  if (!cache) return;
223
224
  const tagList = (Array.isArray(tags) ? tags : [tags]).filter((t) => typeof t === "string");
224
225
  if (tagList.length === 0) return;
225
- const options = { tags: tagList.map(encodeCloudflareCacheTag) };
226
+ const encodedTags = [...new Set(tagList.map(encodeCloudflareCacheTag))];
226
227
  const operation = isStaleTagInvalidation(durations) ? "invalidate" : "purge";
227
- const result = await (operation === "invalidate" ? invalidateOrPurge(cache, options) : cache.purge(options));
228
- if (result?.success === false) {
229
- const errors = result.errors.map(({ code, message }) => `${code}: ${message}`).join(", ");
228
+ const chunks = [];
229
+ for (let i = 0; i < encodedTags.length; i += MAX_TAGS_PER_PURGE) chunks.push(encodedTags.slice(i, i + MAX_TAGS_PER_PURGE));
230
+ const settled = await Promise.allSettled(chunks.map(async (chunk) => await (operation === "invalidate" ? invalidateOrPurge(cache, { tags: chunk }) : cache.purge({ tags: chunk }))));
231
+ const rejection = settled.find((result) => result.status === "rejected");
232
+ if (rejection) throw rejection.reason;
233
+ const failures = settled.flatMap((result) => result.status === "fulfilled" && result.value?.success === false ? [result.value] : []);
234
+ if (failures.length > 0) {
235
+ const errors = failures.flatMap((result) => result.errors).map(({ code, message }) => `${code}: ${message}`).join(", ");
230
236
  throw new Error(`[vinext] Workers Cache ${operation} failed${errors ? `: ${errors}` : ""}`);
231
237
  }
232
238
  }
@@ -2,7 +2,7 @@ import { VINEXT_CDN_BUILD_ID_HEADER, getVinextCdnBuildIdentity } from "./cdn-bui
2
2
  import { SHARED_RESPONSE_STAGE_HEADER, finalizeGatewayResponse } from "./browser-cache-policy.js";
3
3
  import { hasCacheMethod, invalidateOrPurge } from "./workers-cache-invalidation.js";
4
4
  import { VINEXT_PRERENDER_READINESS_PATH, VINEXT_RSC_VARY_HEADER } from "vinext/internal/server/headers";
5
- import { isNonCacheableCacheControl } from "vinext/shims/cdn-cache";
5
+ import { isNonCacheableCacheControl, splitCacheControlDirectives } from "vinext/shims/cdn-cache";
6
6
  import { WorkerEntrypoint } from "cloudflare:workers";
7
7
  import "vinext/internal/server/cloudflare-workers-tracing";
8
8
  import { loadVinextRequestStage } from "vinext/server/request-stage";
@@ -132,7 +132,8 @@ async function createCacheFacingRequest(request, serializedInvocation) {
132
132
  return new Request(url, {
133
133
  cf: { vary: { default: { action: "passthrough" } } },
134
134
  headers,
135
- method: request.method
135
+ method: request.method,
136
+ redirect: "manual"
136
137
  });
137
138
  }
138
139
  function restoreResponseStageRequest(request, requestUrl, requestMethod) {
@@ -183,18 +184,63 @@ function isAppPageDocumentInvocation(invocation) {
183
184
  const props = invocation.props;
184
185
  return props !== null && typeof props === "object" && Reflect.get(props, "kind") === "app-page" && Reflect.get(props, "isRscRequest") === false;
185
186
  }
187
+ /** A Pages page render, as an HTML document or a `_next/data` payload. */
188
+ function isPagesPageInvocation(invocation) {
189
+ const props = invocation.props;
190
+ if (props === null || typeof props !== "object") return false;
191
+ const kind = Reflect.get(props, "kind");
192
+ return kind === "pages-page" || kind === "hybrid-pages" && Reflect.get(props, "resourceKind") === "page";
193
+ }
194
+ /**
195
+ * Workers Cache revalidates an entry that has a validator with a conditional
196
+ * request, and serves the stale entry meanwhile or fills an expired one.
197
+ */
198
+ function revalidatesStoredEntry(request) {
199
+ return request.headers.has("If-None-Match") || request.headers.has("If-Modified-Since");
200
+ }
186
201
  /**
187
- * No client waits on a deployment warm-up. Workers Cache revalidates an entry
188
- * that has a validator with a conditional request, as it does the Response
189
- * Store's, and serves the stale entry meanwhile or fills an expired one.
202
+ * No client waits on a deployment warm-up or on a revalidation, as with the
203
+ * Response Store's.
190
204
  */
191
205
  function rendersWholeDocument(request) {
192
- return request.headers.get("user-agent") === WARMUP_USER_AGENT || request.headers.has("If-None-Match") || request.headers.has("If-Modified-Since");
206
+ return request.headers.get("user-agent") === WARMUP_USER_AGENT || revalidatesStoredEntry(request);
207
+ }
208
+ /**
209
+ * A conditional revalidation of a Pages page skips getStaticPaths(), so only a
210
+ * successful render may be stored with a validator. Bot renders carry an ETag
211
+ * whatever their status. A 304 answers a revalidation of a stored 200, and a
212
+ * response a shared cache may not store keeps its validators.
213
+ */
214
+ function withPagesRevalidationValidator(response) {
215
+ if (response.status === 200) return withRevalidationValidator(response);
216
+ const policy = selectedCachePolicy(response);
217
+ if (response.status === 304 || policy !== null && forbidsSharedStorage(policy) || !response.headers.has("ETag") && !response.headers.has("Last-Modified")) return response;
218
+ const headers = new Headers(response.headers);
219
+ headers.delete("ETag");
220
+ headers.delete("Last-Modified");
221
+ return new Response(response.body, {
222
+ headers,
223
+ status: response.status,
224
+ statusText: response.statusText
225
+ });
226
+ }
227
+ /**
228
+ * `no-cache` and a field-qualified `private` still let a shared cache store the
229
+ * response (RFC 9111 sections 5.2.2.4 and 5.2.2.7).
230
+ */
231
+ function forbidsSharedStorage(policy) {
232
+ return splitCacheControlDirectives(policy).some((directive) => {
233
+ const normalized = directive.toLowerCase();
234
+ return normalized === "private" || normalized.split("=", 1)[0].trim() === "no-store";
235
+ });
236
+ }
237
+ function selectedCachePolicy(response) {
238
+ return response.headers.get("Cloudflare-CDN-Cache-Control") ?? response.headers.get("CDN-Cache-Control") ?? response.headers.get("Cache-Control");
193
239
  }
194
240
  /** Give a cacheable document a validator, so Workers Cache revalidates it conditionally. */
195
241
  function withRevalidationValidator(response) {
196
242
  if (response.headers.has("ETag") || response.headers.has("Last-Modified")) return response;
197
- const policy = response.headers.get("Cloudflare-CDN-Cache-Control") ?? response.headers.get("CDN-Cache-Control") ?? response.headers.get("Cache-Control");
243
+ const policy = selectedCachePolicy(response);
198
244
  if (policy === null || isNonCacheableCacheControl(policy)) return response;
199
245
  const headers = new Headers(response.headers);
200
246
  headers.set("Last-Modified", (/* @__PURE__ */ new Date()).toUTCString());
@@ -319,14 +365,21 @@ var VinextCachedResponse = class extends WorkerEntrypoint {
319
365
  if (invocation.expectedResponseStageBuildIdentity !== void 0 && invocation.expectedResponseStageBuildIdentity !== getVinextCdnBuildIdentity()) return stampResponseStageBuildIdentity(responseStageUnavailable());
320
366
  const restored = restoreResponseStageRequest(request, invocation.requestUrl, invocation.requestMethod);
321
367
  const isAppPageDocument = isAppPageDocumentInvocation(invocation);
368
+ const isPagesPage = isPagesPageInvocation(invocation);
322
369
  const response = await invokeResponseStage(restored.request, this.env, context, isAppPageDocument && rendersWholeDocument(request) ? {
323
370
  ...invocation,
324
371
  options: {
325
372
  ...invocation.options,
326
373
  renderWholeDocument: true
327
374
  }
375
+ } : isPagesPage && revalidatesStoredEntry(request) ? {
376
+ ...invocation,
377
+ options: {
378
+ ...invocation.options,
379
+ regeneratesStoredEntry: true
380
+ }
328
381
  } : invocation);
329
- return stampResponseStageBuildIdentity(restored.didAccessRequestCf() || hasTaggedCustomVary(response) ? preventResponseCaching(response) : isAppPageDocument ? withRevalidationValidator(response) : response);
382
+ return stampResponseStageBuildIdentity(restored.didAccessRequestCf() || hasTaggedCustomVary(response) ? preventResponseCaching(response) : isAppPageDocument ? withRevalidationValidator(response) : isPagesPage ? withPagesRevalidationValidator(response) : response);
330
383
  }
331
384
  async purge(options) {
332
385
  const cache = Reflect.get(this.ctx, "cache");
@@ -6,7 +6,7 @@ type ResponseStoreStorageOptions = {
6
6
  };
7
7
  export declare function createWorkersResponseStoreSelfContainedConfig(options: ResponseStoreStorageOptions): Promise<{
8
8
  cache: {
9
- enabled: true;
9
+ enabled: false;
10
10
  };
11
11
  exports: {
12
12
  CacheMetadata: {
@@ -45,7 +45,7 @@ export declare function createWorkersResponseStoreServiceBindingConfig(options:
45
45
  accountId: string | undefined;
46
46
  serviceBindingWorker: {
47
47
  cache: {
48
- enabled: true;
48
+ enabled: false;
49
49
  };
50
50
  exports: {
51
51
  CacheMetadata: {
@@ -84,7 +84,7 @@ export declare function createWorkersResponseStoreServiceBindingConfig(options:
84
84
  env: {
85
85
  RESPONSE_STORE: import("cf/config").WorkerBinding<{
86
86
  cache: {
87
- enabled: true;
87
+ enabled: false;
88
88
  };
89
89
  exports: {
90
90
  CacheMetadata: {
@@ -10,7 +10,7 @@ const RESPONSE_STORE_SERVICE_ENTRYPOINT = "@cloudflare/workers-response-store/se
10
10
  async function createResponseStoreWorkerConfig(options) {
11
11
  const { bindings, exports } = await import("cf/config");
12
12
  return {
13
- cache: { enabled: true },
13
+ cache: { enabled: false },
14
14
  exports: {
15
15
  [CACHE_METADATA_CLASS]: {
16
16
  ...exports.durableObject({ storage: "sqlite" }),
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { fnv1a64 } from "vinext/internal/utils/hash";
2
3
  //#region src/cache/kv-key.ts
3
4
  /** Key prefix for tag invalidation timestamps. */
@@ -27,19 +28,37 @@ function kvKeyByteLength(key) {
27
28
  return KV_KEY_ENCODER.encode(key).length;
28
29
  }
29
30
  /**
31
+ * Digest an entry key that is too long to store verbatim. Logical keys can
32
+ * carry request input, so the digest must be collision resistant: two keys
33
+ * sharing a digest would share an entry.
34
+ */
35
+ function digestEntryKey(key) {
36
+ return createHash("sha256").update(key).digest("hex");
37
+ }
38
+ /**
39
+ * Digest a tag that is too long or unsafe to store verbatim. Tags sharing a
40
+ * digest share one invalidation marker, which can only invalidate extra
41
+ * entries. Markers are permanent, so a new digest would drop the
42
+ * invalidations already recorded for entries still in KV.
43
+ */
44
+ function digestTag(tag) {
45
+ return fnv1a64(tag);
46
+ }
47
+ /**
30
48
  * Keep short app prefixes readable, but bound the namespace portion so a
31
49
  * hashed entry or tag key is always able to fit within Cloudflare KV's limit.
32
50
  */
33
51
  function normalizeAppPrefix(appPrefix) {
34
52
  if (!appPrefix) return "";
35
53
  const prefix = `${appPrefix}:`;
36
- if (kvKeyByteLength(`${prefix}cache:${HASHED_KEY_PREFIX}${fnv1a64("")}`) <= KV_KEY_MAX_BYTES) return prefix;
54
+ if (kvKeyByteLength(`${prefix}cache:${HASHED_KEY_PREFIX}${digestEntryKey("")}`) <= KV_KEY_MAX_BYTES) return prefix;
37
55
  return `__app:${fnv1a64(appPrefix)}:`;
38
56
  }
39
57
  function buildStorageKey(prefix, categoryPrefix, logicalKey) {
40
58
  const key = `${prefix}${categoryPrefix}${logicalKey}`;
41
59
  if (!(categoryPrefix === TAG_PREFIX && logicalKey.includes(":")) && kvKeyByteLength(key) <= KV_KEY_MAX_BYTES) return key;
42
- return `${prefix}${categoryPrefix}${HASHED_KEY_PREFIX}${fnv1a64(logicalKey)}`;
60
+ const digest = categoryPrefix === TAG_PREFIX ? digestTag(logicalKey) : digestEntryKey(logicalKey);
61
+ return `${prefix}${categoryPrefix}${HASHED_KEY_PREFIX}${digest}`;
43
62
  }
44
63
  /** Create the deterministic key namespace for runtime cache operations. */
45
64
  function createKvKeySpace(appPrefix) {
@@ -2,10 +2,11 @@ import { SHARED_RESPONSE_STAGE_HEADER, finalizeGatewayResponse } from "./browser
2
2
  import { captureResponseStoreDataRegeneration, runWithResponseStoreInvocation, setResponseStore } from "./response-store-data.runtime.js";
3
3
  import { VINEXT_PARAMS_HEADER, VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, VINEXT_SPECIAL_ERROR_STATUS_HEADER } from "vinext/internal/server/headers";
4
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";
5
- import { isNonCacheableCacheControl } from "vinext/shims/cdn-cache";
5
+ import { hasSharedCacheCredentials, isNonCacheableCacheControl } from "vinext/shims/cdn-cache";
6
6
  import { loadVinextRequestStage } from "vinext/server/request-stage";
7
7
  import { loadVinextResponseStage } from "vinext/server/response-stage";
8
8
  import { traceCachedResponseStart } from "vinext/internal/server/response-start-tracing";
9
+ import { isPageNotModified } from "vinext/internal/server/http-conditional";
9
10
  //#region src/cache/response-store-adapter.worker.ts
10
11
  const ROUTE_REVALIDATOR_ID = "vinext:response";
11
12
  const RESPONSE_STORE_KEY_PARAM = "__workers_response_store";
@@ -41,7 +42,7 @@ function replayHeaders(request) {
41
42
  function isReplayableInvocation(request, props) {
42
43
  const method = request.method.toUpperCase();
43
44
  if (method !== "GET" && method !== "HEAD") return false;
44
- if (request.headers.has("authorization") || request.headers.has("cookie")) return false;
45
+ if (hasSharedCacheCredentials(request.headers)) return false;
45
46
  return !(props && typeof props === "object" && ["draftModeCookie", "middlewareCookieOverlay"].some((name) => name in props && Reflect.get(props, name) !== null));
46
47
  }
47
48
  function prepareInvocation(request, props) {
@@ -96,6 +97,7 @@ function createVinextResponseStoreOptions(configuration) {
96
97
  const invocation = parseInvocation(input.args.at(-1));
97
98
  const response = await invokeResponseStage(restoreRequest(invocation), invocation.props, env, ctx, {
98
99
  cache: "shared",
100
+ regeneratesStoredEntry: true,
99
101
  renderWholeDocument: true
100
102
  });
101
103
  if (!isCacheable(response)) {
@@ -160,7 +162,59 @@ function isStoredErrorStatus(status) {
160
162
  }
161
163
  function isCacheable(response) {
162
164
  const policy = response.headers.get("Cloudflare-CDN-Cache-Control") ?? response.headers.get("CDN-Cache-Control") ?? response.headers.get("Cache-Control");
163
- return response.status >= 200 && (response.status < 400 || isStoredErrorStatus(response.status)) && policy !== null && !isNonCacheableCacheControl(policy);
165
+ return response.status >= 200 && response.status !== 304 && (response.status < 400 || isStoredErrorStatus(response.status)) && policy !== null && !isNonCacheableCacheControl(policy);
166
+ }
167
+ /**
168
+ * A page's 304 headers. Next.js sends the 304 before it sets the payload's
169
+ * headers, so it carries no representation or body framing headers.
170
+ */
171
+ function notModifiedHeaders(source) {
172
+ const headers = new Headers(source);
173
+ for (const name of [
174
+ "Content-Encoding",
175
+ "Content-Length",
176
+ "Content-Type",
177
+ "Transfer-Encoding"
178
+ ]) headers.delete(name);
179
+ return headers;
180
+ }
181
+ /** Send the 304 for a page whose ETag matched the request's If-None-Match. */
182
+ function notModifiedResponse(response) {
183
+ response.body?.cancel().catch(() => {});
184
+ return new Response(null, {
185
+ headers: notModifiedHeaders(response.headers),
186
+ status: 304
187
+ });
188
+ }
189
+ /**
190
+ * Whether Next.js sends this stored response through `sendRenderResult`, which
191
+ * answers a conditional request by the page's ETag. It sends every App page
192
+ * that way. A Pages redirect, and a Pages data request's notFound, are sent
193
+ * without an ETag, as is a route handler's response. A Pages API response is
194
+ * never stored.
195
+ */
196
+ function answersConditionalRequest(responseStageProps, status) {
197
+ if (responseStageProps === null || typeof responseStageProps !== "object") return false;
198
+ const kind = Reflect.get(responseStageProps, "kind");
199
+ if (kind === "app-page" || kind === "app-not-found") return true;
200
+ let isDataRequest;
201
+ if (kind === "hybrid-pages") {
202
+ if (Reflect.get(responseStageProps, "resourceKind") !== "page") return false;
203
+ isDataRequest = Reflect.get(responseStageProps, "isDataRequest") === true;
204
+ } else if (kind === "pages-page") {
205
+ const renderOptions = Reflect.get(responseStageProps, "renderOptions");
206
+ isDataRequest = renderOptions !== null && typeof renderOptions === "object" && Reflect.get(renderOptions, "isDataReq") === true;
207
+ } else return false;
208
+ return status >= 200 && status < 300 || status === 404 && !isDataRequest;
209
+ }
210
+ /** Answer a stored page's conditional request as Next.js does for a cached page. */
211
+ function storedForRequest(stored, request, responseStageProps) {
212
+ const etag = stored.headers.get("ETag");
213
+ return etag && answersConditionalRequest(responseStageProps, stored.status) && isPageNotModified({
214
+ cacheControl: request.headers.get("Cache-Control") ?? void 0,
215
+ ifModifiedSince: request.headers.get("If-Modified-Since") ?? void 0,
216
+ ifNoneMatch: request.headers.get("If-None-Match") ?? void 0
217
+ }, etag) ? notModifiedResponse(stored) : stored;
164
218
  }
165
219
  async function readStoredResponse(key) {
166
220
  try {
@@ -235,11 +289,11 @@ const handler = { async fetch(request, env, ctx) {
235
289
  const key = await cacheRequest(invocation);
236
290
  const stored = await readStoredResponse(key);
237
291
  if (stored) {
238
- if (!rscKey) return publicResponse(stored, "HIT", props);
292
+ if (!rscKey) return publicResponse(storedForRequest(stored, stageRequest, props), "HIT", props);
239
293
  const storedRsc = await readStoredResponse(rscKey);
240
294
  if (storedRsc) {
241
295
  storedRsc.body?.cancel().catch(() => {});
242
- return publicResponse(stored, "HIT", props);
296
+ return publicResponse(storedForRequest(stored, stageRequest, props), "HIT", props);
243
297
  }
244
298
  stored.body?.cancel().catch(() => {});
245
299
  }
@@ -252,6 +306,7 @@ const handler = { async fetch(request, env, ctx) {
252
306
  replayable: isReplayableInvocation(stageRequest, props),
253
307
  serialized: options.cacheIdentity ? serializeInvocation(stageRequest, props) : serializedInvocation
254
308
  });
309
+ const foreground = (response) => capture.notModified ? notModifiedResponse(response) : response;
255
310
  if (capture.admittedResponse) {
256
311
  ctx.waitUntil(capture.admittedResponse.then(async (admitted) => {
257
312
  if (!isCacheable(admitted)) {
@@ -271,17 +326,23 @@ const handler = { async fetch(request, env, ctx) {
271
326
  error: error instanceof Error ? error.message : String(error)
272
327
  }));
273
328
  }));
274
- return publicResponse(rendered, "MISS", props, true);
329
+ if (!capture.notModified) return publicResponse(rendered, "MISS", props, true);
330
+ const notModified = notModifiedResponse(rendered);
331
+ const admitted = await capture.admittedResponse.catch(() => null);
332
+ return admitted ? publicResponse(new Response(null, {
333
+ headers: notModifiedHeaders(admitted.headers),
334
+ status: 304
335
+ }), "MISS", props) : publicResponse(notModified, "MISS", props, true);
275
336
  }
276
337
  if (!isCacheable(rendered)) {
277
338
  capture?.rscData?.catch(() => {});
278
- return publicResponse(rendered, "BYPASS", props);
339
+ return publicResponse(foreground(rendered), "BYPASS", props);
279
340
  }
280
341
  if (rscSeed && !capture?.rscData) {
281
342
  await rendered.body?.cancel();
282
343
  throw new Error("Vinext response-store warmup did not capture the App page RSC payload");
283
344
  }
284
- const [foreground, cacheBody] = rendered.body ? rendered.body.tee() : [null, null];
345
+ const [foregroundBody, cacheBody] = rendered.body ? rendered.body.tee() : [null, null];
285
346
  const cacheResponse = withoutRequestScopedHeaders(new Response(cacheBody, rendered), props);
286
347
  try {
287
348
  await responseStore.put(key, cacheResponse, {
@@ -324,7 +385,7 @@ const handler = { async fetch(request, env, ctx) {
324
385
  }
325
386
  });
326
387
  }
327
- return publicResponse(new Response(foreground, rendered), "MISS", props);
388
+ return publicResponse(foreground(new Response(foregroundBody, rendered)), "MISS", props);
328
389
  };
329
390
  const { handleRequestStage } = await loadVinextRequestStage();
330
391
  const sharedResponses = /* @__PURE__ */ new Map();
@@ -1,5 +1,5 @@
1
1
  import createCloudflareCdnCacheAdapter from "./cdn-adapter.runtime.js";
2
- import { captureResponseStoreRscData, deferResponseStoreAdmission } from "./response-store-data.runtime.js";
2
+ import { 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
+ deferNotModifiedResponse() {
37
+ return deferResponseStoreNotModified();
38
+ }
36
39
  async revalidateTag() {}
37
40
  };
38
41
  function createResponseStoreCdnCacheAdapter(context) {
@@ -4,6 +4,8 @@ import { CacheHandler, CacheHandlerValue, IncrementalCacheValue } from "vinext/s
4
4
  export type ResponseStoreInvocationCapture = {
5
5
  admittedResponse?: Promise<Response>;
6
6
  captureRscData?: boolean;
7
+ /** The render matched the request's If-None-Match; the adapter sends the 304. */
8
+ notModified?: boolean;
7
9
  rscData?: Promise<ArrayBuffer>;
8
10
  streamResponse?: boolean;
9
11
  };
@@ -15,6 +17,8 @@ export declare function setResponseStore(store: WorkersResponseStore): void;
15
17
  export declare function runWithResponseStoreInvocation<T>(serialized: string, replayable: boolean, callback: () => T, capture?: ResponseStoreInvocationCapture): T;
16
18
  /** Retain the RSC side stream only when the outer response-store invocation requested it. */
17
19
  export declare function captureResponseStoreRscData(rscData: Promise<ArrayBuffer>): void;
20
+ /** A shared invocation stores the page, so its adapter sends the page's 304. */
21
+ export declare function deferResponseStoreNotModified(): boolean;
18
22
  /** Stream the foreground body while retaining an independent admission branch. */
19
23
  export declare function deferResponseStoreAdmission(response: Response, complete: (response: Response) => Promise<Response>): Response | null;
20
24
  /** Re-render an invocation and return the exact rewritten data-cache entry. */
@@ -33,6 +33,13 @@ function captureResponseStoreRscData(rscData) {
33
33
  if (capture?.captureRscData) capture.rscData = rscData;
34
34
  else rscData.catch(() => {});
35
35
  }
36
+ /** A shared invocation stores the page, so its adapter sends the page's 304. */
37
+ function deferResponseStoreNotModified() {
38
+ const capture = invocationStorage.getStore()?.capture;
39
+ if (!capture) return false;
40
+ capture.notModified = true;
41
+ return true;
42
+ }
36
43
  /** Stream the foreground body while retaining an independent admission branch. */
37
44
  function deferResponseStoreAdmission(response, complete) {
38
45
  const capture = invocationStorage.getStore()?.capture;
@@ -328,4 +335,4 @@ function createResponseStoreDataCacheAdapter() {
328
335
  return new WorkersResponseStoreCacheHandler();
329
336
  }
330
337
  //#endregion
331
- export { CACHE_FUNCTION_REVALIDATOR_ID, DATA_REVALIDATOR_ID, WorkersResponseStoreCacheHandler, captureResponseStoreDataRegeneration, captureResponseStoreRscData, createResponseStoreDataCacheAdapter as default, deferResponseStoreAdmission, runWithResponseStoreInvocation, setResponseStore };
338
+ export { CACHE_FUNCTION_REVALIDATOR_ID, DATA_REVALIDATOR_ID, WorkersResponseStoreCacheHandler, captureResponseStoreDataRegeneration, captureResponseStoreRscData, createResponseStoreDataCacheAdapter as default, deferResponseStoreAdmission, deferResponseStoreNotModified, runWithResponseStoreInvocation, setResponseStore };
@@ -2,8 +2,8 @@ import { STATIC_ASSET_CACHE_PATH } from "./static-assets-adapter.shared.js";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { createHash } from "node:crypto";
5
- import { readPrerenderManifest } from "vinext/internal/server/prerender-manifest";
6
- import { appIsrCacheKey, pagesIsrCacheKey } from "vinext/internal/server/isr-cache";
5
+ import { isFallbackShellArtifactPath, readPrerenderManifest } from "vinext/internal/server/prerender-manifest";
6
+ import { appIsrCacheKey, appIsrFallbackShellKey, pagesIsrCacheKey } from "vinext/internal/server/isr-cache";
7
7
  import { extractVinextNextDataJson } from "vinext/internal/client/vinext-next-data";
8
8
  import { normalizePregeneratedPathname } from "vinext/internal/server/pregenerated-concrete-paths";
9
9
  import { getAppRouteOutputPath, getOutputPath, getRscOutputPath } from "vinext/internal/utils/prerender-output-paths";
@@ -67,8 +67,9 @@ function finalizeStaticAssetsPrerenderOutput(root, options = {}) {
67
67
  const pathname = route.path ?? route.route;
68
68
  const cachePathname = normalizePregeneratedPathname(pathname);
69
69
  if (route.router === "app") {
70
- count += Number(writeCacheAsset(outputDir, index, appIsrCacheKey(cachePathname, "html", manifest.buildId), "html", path.join(prerenderDir, getOutputPath(pathname, manifest.trailingSlash ?? false)), route));
71
- count += Number(writeCacheAsset(outputDir, index, appIsrCacheKey(cachePathname, "rsc", manifest.buildId), "rsc", path.join(prerenderDir, getRscOutputPath(pathname)), route));
70
+ const appKey = isFallbackShellArtifactPath(pathname, route) ? appIsrFallbackShellKey : appIsrCacheKey;
71
+ count += Number(writeCacheAsset(outputDir, index, appKey(cachePathname, "html", manifest.buildId), "html", path.join(prerenderDir, getOutputPath(pathname, manifest.trailingSlash ?? false)), route));
72
+ count += Number(writeCacheAsset(outputDir, index, appKey(cachePathname, "rsc", manifest.buildId), "rsc", path.join(prerenderDir, getRscOutputPath(pathname)), route));
72
73
  } else if (route.router === "pages") {
73
74
  const sourcePath = path.join(prerenderDir, getOutputPath(pathname, manifest.trailingSlash ?? false));
74
75
  if (!fs.existsSync(sourcePath)) continue;
@@ -9,9 +9,11 @@ export declare class StaticAssetsCacheAdapter implements CdnCacheAdapter {
9
9
  readonly assets: AssetFetcher;
10
10
  readonly hasPrerenderedPages = true;
11
11
  readonly ownsBackgroundRevalidation = false;
12
- private indexPromise;
12
+ private index;
13
+ private pendingIndex;
13
14
  constructor(assets: AssetFetcher);
14
15
  private loadIndex;
16
+ private fetchIndex;
15
17
  get(key: string): Promise<CacheHandlerValue | null>;
16
18
  set(_key: string, _data: IncrementalCacheValue | null, _ctx?: Record<string, unknown>): Promise<void>;
17
19
  buildResponseHeaders(input: CdnCacheableHeaderInput): CdnResponseHeaders;
@@ -1,5 +1,6 @@
1
1
  import { STATIC_ASSET_CACHE_PATH } from "./static-assets-adapter.shared.js";
2
2
  import { DefaultCdnCacheAdapter } from "vinext/shims/cdn-cache";
3
+ import { getRequestExecutionContext } from "vinext/shims/request-context";
3
4
  //#region src/cache/static-assets-adapter.runtime.ts
4
5
  async function cacheAssetId(key) {
5
6
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(key));
@@ -12,12 +13,32 @@ var StaticAssetsCacheAdapter = class {
12
13
  assets;
13
14
  hasPrerenderedPages = true;
14
15
  ownsBackgroundRevalidation = false;
15
- indexPromise;
16
+ index;
17
+ pendingIndex;
16
18
  constructor(assets) {
17
19
  this.assets = assets;
18
20
  }
19
21
  loadIndex() {
20
- return this.indexPromise ??= this.assets.fetch(`https://vinext.invalid${STATIC_ASSET_CACHE_PATH}/index.json`).then(async (response) => response.ok ? await response.json() : null).then((value) => value !== null && typeof value === "object" ? value : null);
22
+ if (this.index !== void 0) return Promise.resolve(this.index);
23
+ if (this.pendingIndex) return this.pendingIndex;
24
+ const ctx = getRequestExecutionContext();
25
+ if (!ctx) return this.fetchIndex();
26
+ const pending = this.fetchIndex().finally(() => {
27
+ if (this.pendingIndex === pending) this.pendingIndex = void 0;
28
+ });
29
+ this.pendingIndex = pending;
30
+ ctx.waitUntil(pending.catch(() => {}));
31
+ return pending;
32
+ }
33
+ async fetchIndex() {
34
+ const response = await this.assets.fetch(`https://vinext.invalid${STATIC_ASSET_CACHE_PATH}/index.json`);
35
+ if (!response.ok) {
36
+ response.body?.cancel().catch(() => {});
37
+ if (response.status === 404) this.index = null;
38
+ return null;
39
+ }
40
+ const value = await response.json();
41
+ return this.index = value !== null && typeof value === "object" ? value : null;
21
42
  }
22
43
  async get(key) {
23
44
  const id = await cacheAssetId(key);
@@ -43,7 +43,6 @@ export declare const DEFAULT_STAGED_READINESS_INTERVAL_MS = 1000;
43
43
  export declare const DEFAULT_STAGED_READINESS_PHASE_TIMEOUT_MS = 120000;
44
44
  export type PrerenderCdnWarmOptions = Omit<CdnWarmOptions, "paths"> & {
45
45
  root: string;
46
- includeFallbackShells?: boolean;
47
46
  /** Use the manifest build identity unless the configured adapter cannot expose it. */
48
47
  validateBuildIdentity?: boolean;
49
48
  };
@@ -92,13 +91,11 @@ export type PrerenderWarmPlan = {
92
91
  };
93
92
  type PrerenderWarmPlanOptions = {
94
93
  includeCanonicalRsc?: boolean;
95
- includeFallbackShells?: boolean;
96
94
  strict?: boolean;
97
95
  };
98
96
  export declare function createPrerenderWarmPlan(root: string, manifest: PrerenderPathManifest, options?: PrerenderWarmPlanOptions): PrerenderWarmPlan;
99
97
  export declare function readPrerenderWarmPlan(root: string, options?: PrerenderWarmPlanOptions): PrerenderWarmPlan;
100
98
  export declare function readPrerenderWarmPaths(root: string, options?: {
101
- includeFallbackShells?: boolean;
102
99
  strict?: boolean;
103
100
  }): string[];
104
101
  export declare function getWarmPathsFromPrerenderManifest(manifest: PrerenderManifest, options?: PrerenderedPathSelectionOptions): string[];
package/dist/cdn-warm.js CHANGED
@@ -6,7 +6,7 @@ import { randomUUID } from "node:crypto";
6
6
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
7
7
  import { VINEXT_CACHE_HEADER, VINEXT_PRERENDER_READINESS_HEADER, VINEXT_PRERENDER_READINESS_PATH, VINEXT_PRERENDER_SECRET_HEADER } from "vinext/internal/server/headers";
8
8
  import { PRERENDER_PATHS_MANIFEST } from "vinext/internal/build/prerender-paths";
9
- import { getPrerenderedConcretePaths, readPrerenderManifest } from "vinext/internal/server/prerender-manifest";
9
+ import { getPrerenderedConcretePaths } from "vinext/internal/server/prerender-manifest";
10
10
  import { VINEXT_RSC_BUILD_ID_HEADER, VINEXT_RSC_CONTENT_TYPE, VINEXT_RSC_VARY_HEADER as VINEXT_RSC_VARY_HEADER$1, createCanonicalLoadingShellRscRequestHeaders, createCanonicalRscRequestHeaders, createCanonicalRscRequestUrl, createRscRequestUrl } from "vinext/internal/server/app-rsc-cache-busting";
11
11
  import { isNonCacheableCacheControl } from "vinext/shims/cdn-cache";
12
12
  import { normalizePathTrailingSlash } from "vinext/shims/url-utils";
@@ -73,17 +73,7 @@ function createPrerenderWarmPlan(root, manifest, options) {
73
73
  const supportsCanonicalRsc = (manifest.responseVary === "verbatim" || options?.includeCanonicalRsc === true) && manifest.rscPaths !== void 0 && manifest.rscBuildId !== void 0;
74
74
  const applyConfig = (pathname) => applyWarmPathConfig(pathname, manifest);
75
75
  const routePatterns = manifest.routePatterns ? Object.fromEntries(Object.entries(manifest.routePatterns).map(([pathname, route]) => [pathname.includes("/_next/data/") ? pathname : applyConfig(pathname), route])) : void 0;
76
- let htmlPaths = pathPlan.paths;
77
- if (options?.includeFallbackShells === true) {
78
- const prerenderManifest = readPrerenderManifest(path.join(root, "dist", "server", "vinext-prerender.json"));
79
- if (!prerenderManifest) console.warn("[vinext] CDN warmup fallback shells requested, but prerender manifest not found; warming build-discovered paths only.");
80
- else if (!prerenderManifest.buildId || prerenderManifest.buildId !== manifest.buildId) {
81
- const message = "[vinext] CDN warmup skipped: prerender manifest buildId does not match dist/server/BUILD_ID.";
82
- if (options.strict) throw new Error(message);
83
- console.warn(message);
84
- htmlPaths = [];
85
- } else htmlPaths = getPrerenderedConcretePaths(prerenderManifest, { includeFallbackShells: true }).filter((pathname) => pathname.startsWith("/")).filter((pathname) => !manifest.excludedWarmPaths?.includes(pathname)).map(applyConfig);
86
- }
76
+ const htmlPaths = pathPlan.paths;
87
77
  return {
88
78
  ...manifest.appPaths ? { appPaths: manifest.appPaths.map(applyConfig) } : {},
89
79
  buildId: manifest.buildId,
@@ -959,10 +949,7 @@ async function warmCdnCache(options) {
959
949
  return result;
960
950
  }
961
951
  async function warmCdnCacheFromPrerender(options) {
962
- const plan = readPrerenderWarmPlan(options.root, {
963
- includeFallbackShells: options.includeFallbackShells,
964
- strict: options.strict
965
- });
952
+ const plan = readPrerenderWarmPlan(options.root, { strict: options.strict });
966
953
  const warmPlan = {
967
954
  deploymentId: plan.deploymentId,
968
955
  loadingShellPaths: plan.loadingShellPaths,
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { deploy, parseDeployArgs } from "./deploy.js";
3
- import { printDeployHelp } from "./deploy-help.js";
3
+ import { printDeployHelp, printPreviewsDeployHelp } from "./deploy-help.js";
4
+ import { parsePreviewsDeployArgs } from "./previews-deploy.js";
4
5
  import fs from "node:fs";
5
6
  //#region src/cli.ts
6
7
  const VERSION = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf-8")).version;
@@ -11,6 +12,10 @@ function printHelp(commandName) {
11
12
  printDeployHelp();
12
13
  return;
13
14
  }
15
+ if (commandName === "previews") {
16
+ printPreviewsDeployHelp();
17
+ return;
18
+ }
14
19
  console.log(`
15
20
  vinext-cloudflare v${VERSION}
16
21
 
@@ -21,7 +26,8 @@ function printHelp(commandName) {
21
26
  vp exec vinext-cloudflare <command> Run the locally installed bin
22
27
 
23
28
  Commands:
24
- deploy Deploy to Cloudflare Workers
29
+ deploy Deploy to Cloudflare Workers
30
+ previews deploy [preview-name] Deploy a Cloudflare Worker Preview
25
31
 
26
32
  Options:
27
33
  -h, --help Show this help
@@ -62,13 +68,36 @@ async function deployCommand() {
62
68
  dangerouslyPromoteOnCdnWarmError: parsed.dangerouslyPromoteOnCdnWarmError,
63
69
  warmCdnPromote: parsed.warmCdnPromote,
64
70
  warmCdnPromotionDelay: parsed.warmCdnPromotionDelay,
65
- warmCdnIncludeFallbacks: parsed.warmCdnIncludeFallbacks,
66
71
  experimentalTPR: parsed.experimentalTPR,
67
72
  tprCoverage: parsed.tprCoverage,
68
73
  tprLimit: parsed.tprLimit,
69
74
  tprWindow: parsed.tprWindow
70
75
  });
71
76
  }
77
+ async function previewsCommand() {
78
+ const subcommand = rawArgs[0];
79
+ if (subcommand !== "deploy") {
80
+ if (!subcommand || subcommand === "--help" || subcommand === "-h") {
81
+ printHelp("previews");
82
+ return;
83
+ }
84
+ throw new Error(`Unknown previews command: ${subcommand}`);
85
+ }
86
+ const options = parsePreviewsDeployArgs(rawArgs.slice(1));
87
+ if (options.help) {
88
+ printHelp("previews");
89
+ return;
90
+ }
91
+ await deploy({
92
+ root: process.cwd(),
93
+ env: options.mode,
94
+ skipBuild: options.skipBuild,
95
+ target: {
96
+ type: "preview",
97
+ name: options.name
98
+ }
99
+ });
100
+ }
72
101
  if (command === "--version" || command === "-v") {
73
102
  console.log(`vinext-cloudflare v${VERSION}`);
74
103
  process.exit(0);
@@ -84,6 +113,12 @@ switch (command) {
84
113
  process.exit(1);
85
114
  });
86
115
  break;
116
+ case "previews":
117
+ previewsCommand().catch((error) => {
118
+ console.error(error instanceof Error ? error.message : String(error));
119
+ process.exit(1);
120
+ });
121
+ break;
87
122
  default:
88
123
  console.error(`Unknown command: ${command}`);
89
124
  printHelp();
@@ -1,4 +1,6 @@
1
1
  //#region src/deploy-help.d.ts
2
2
  export declare function formatDeployHelp(): string;
3
3
  export declare function printDeployHelp(): void;
4
+ export declare function formatPreviewsDeployHelp(): string;
5
+ export declare function printPreviewsDeployHelp(): void;
4
6
  //#endregion
@@ -67,8 +67,6 @@ function formatDeployHelp() {
67
67
  verified (never bypasses --warm-cache-certify)
68
68
  --warm-cache-promotion-delay <ms>
69
69
  Delay before promotion after warmup (default: 15000)
70
- --warm-cache-include-fallbacks
71
- Also warm PPR fallback-shell placeholder paths
72
70
  -h, --help Show this help
73
71
 
74
72
  Traffic-aware warming:
@@ -89,11 +87,14 @@ function formatDeployHelp() {
89
87
  be reused outside the data center reached by the warmup request after cache
90
88
  propagation, but the deploy does not wait for every edge location to fill.
91
89
 
90
+ --preview is an alias for --env preview. It selects a mode/environment,
91
+ not a Preview deployment. Use previews deploy to deploy a Worker Preview.
92
+
92
93
  Examples:
93
94
  npx @vinext/cloudflare deploy Build and deploy to production
94
95
  vpx @vinext/cloudflare deploy Build and deploy with Vite+
95
96
  vp exec vinext-cloudflare deploy Run the locally installed Vite+ bin
96
- vinext-cloudflare deploy --preview Deploy to a preview URL
97
+ vinext-cloudflare deploy --preview Deploy using mode/environment preview
97
98
  vinext-cloudflare deploy --env staging Deploy using Cloudflare mode/environment staging
98
99
  vinext-cloudflare deploy --config dist/server/wrangler.json Deploy using a generated Wrangler config
99
100
  vinext-cloudflare deploy --dry-run Validate setup without building or deploying
@@ -115,5 +116,32 @@ function formatDeployHelp() {
115
116
  function printDeployHelp() {
116
117
  console.log(formatDeployHelp());
117
118
  }
119
+ function formatPreviewsDeployHelp() {
120
+ return `
121
+ vinext-cloudflare previews deploy - Deploy a Cloudflare Worker Preview
122
+
123
+ Usage: vinext-cloudflare previews deploy [preview-name] [options]
124
+
125
+ Builds the app with isPreview=true, then deploys its Preview Build Output
126
+ using cf previews deploy. Requires cloudflare.config.ts and the cf CLI.
127
+
128
+ Arguments:
129
+ preview-name Optional Preview name (cf uses the CI or Git branch if omitted)
130
+
131
+ 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
135
+
136
+ Examples:
137
+ vinext-cloudflare previews deploy
138
+ vinext-cloudflare previews deploy pr-123
139
+ vinext-cloudflare previews deploy pr-123 --mode staging
140
+ vinext-cloudflare previews deploy pr-123 --skip-build
141
+ `;
142
+ }
143
+ function printPreviewsDeployHelp() {
144
+ console.log(formatPreviewsDeployHelp());
145
+ }
118
146
  //#endregion
119
- export { formatDeployHelp, printDeployHelp };
147
+ export { formatDeployHelp, formatPreviewsDeployHelp, printDeployHelp, printPreviewsDeployHelp };
package/dist/deploy.d.ts CHANGED
@@ -12,6 +12,11 @@ export type DeployOptions = {
12
12
  root: string;
13
13
  /** Deploy to preview environment (default: production) */
14
14
  preview?: boolean;
15
+ /** Deploy to Preview deployment, supported only by cf. */
16
+ target?: {
17
+ type: "preview";
18
+ name?: string;
19
+ };
15
20
  /** Wrangler environment name from wrangler.jsonc env.<name> */
16
21
  env?: string;
17
22
  /** Custom project name for the Worker */
@@ -62,8 +67,6 @@ export type DeployOptions = {
62
67
  warmCdnPromote?: boolean;
63
68
  /** Delay between successful warmup and promotion in milliseconds */
64
69
  warmCdnPromotionDelay?: number;
65
- /** Include PPR fallback-shell placeholder paths during CDN warmup */
66
- warmCdnIncludeFallbacks?: boolean;
67
70
  /** Select CDN pre-warm routes using traffic analytics */
68
71
  experimentalTPR?: boolean;
69
72
  /** TPR: traffic coverage percentage target (0–100, default: 90) */
@@ -104,7 +107,6 @@ export declare function parseDeployArgs(args: string[]): {
104
107
  dangerouslyPromoteOnCdnWarmError: boolean;
105
108
  warmCdnPromote: boolean;
106
109
  warmCdnPromotionDelay: number | undefined;
107
- warmCdnIncludeFallbacks: boolean;
108
110
  experimentalTPR: boolean;
109
111
  tprCoverage: number | undefined;
110
112
  tprLimit: number | undefined;
@@ -123,6 +125,8 @@ export declare function parseDeployArgs(args: string[]): {
123
125
  * Passing `undefined` is a no-op; the callback runs with `process.env` untouched.
124
126
  */
125
127
  export declare function withCloudflareEnv<T>(env: string | undefined, fn: () => Promise<T>): Promise<T>;
128
+ /** Cloudflare Vite Plugin reads this flag when evaluating the typed config and emitting Build Output. */
129
+ export declare function withCloudflarePreviewBuild<T>(fn: () => Promise<T>): Promise<T>;
126
130
  export declare function resolveViteBuildMode(deploymentTool: DeploymentTool, env: string | undefined): string;
127
131
  type WranglerDeployArgs = {
128
132
  args: string[];
@@ -136,7 +140,7 @@ export declare function validateWranglerEnvName(env: string): string;
136
140
  export declare function buildWranglerDeployArgs(options: Pick<DeployOptions, "preview" | "env" | "name" | "config">): WranglerDeployArgs;
137
141
  export declare function resolveDeploymentTool(root: string): DeploymentTool;
138
142
  export declare function isCfCliInstalled(root: string, resolvePackageJson?: (root: string) => string | null): boolean;
139
- export declare function buildCfDeployArgs(options: Pick<DeployOptions, "preview" | "env">): CfDeployArgs;
143
+ export declare function buildCfDeployArgs(options: Pick<DeployOptions, "preview" | "env" | "target">): CfDeployArgs;
140
144
  /**
141
145
  * Resolve Wrangler's JavaScript CLI entrypoint in node_modules.
142
146
  *
@@ -157,7 +161,7 @@ export declare function buildWranglerInvocation(root: string, options: Pick<Depl
157
161
  export declare function runWranglerDeploy(root: string, options: Pick<DeployOptions, "preview" | "env" | "name" | "config" | "verbose"> & {
158
162
  promote?: boolean;
159
163
  }, execute?: typeof spawn): Promise<string>;
160
- export declare function runCfDeploy(root: string, options: Pick<DeployOptions, "preview" | "env">, execute?: typeof spawn): Promise<string>;
164
+ export declare function runCfDeploy(root: string, options: Pick<DeployOptions, "preview" | "env" | "target">, execute?: typeof spawn): Promise<string>;
161
165
  export declare function hasCdnWarmRequests(plan: Omit<CdnWarmRequestPlan, "pagesDataPaths"> & {
162
166
  pagesDataPaths?: readonly string[];
163
167
  }): boolean;
package/dist/deploy.js CHANGED
@@ -16,7 +16,7 @@ import { setTimeout } from "node:timers/promises";
16
16
  import { DEFAULT_REMOTE_PATH_DISCOVERY_PHASE_TIMEOUT_MS, DEFAULT_REMOTE_PATH_DISCOVERY_RETRY_DELAY_MS, discoverPrerenderPathManifest, emitPrerenderPathManifest } from "vinext/internal/build/prerender-paths";
17
17
  import { normalizePathTrailingSlash } from "vinext/shims/url-utils";
18
18
  import { spawn } from "node:child_process";
19
- import { parseArgs } from "node:util";
19
+ import { parseArgs, stripVTControlCharacters } from "node:util";
20
20
  import { pathToFileURL } from "node:url";
21
21
  import { VINEXT_BUILD_LIFECYCLE_CONFIG } from "vinext/internal/build/lifecycle";
22
22
  import { runPrerender } from "vinext/internal/build/run-prerender";
@@ -228,7 +228,7 @@ function parseDeployArgs(args) {
228
228
  values["warm-cache-readiness-probe-delay"] ??= values["warm-cdn-readiness-probe-delay"];
229
229
  values["warm-cache-no-promote"] ||= values["warm-cdn-no-promote"];
230
230
  values["warm-cache-promotion-delay"] ??= values["warm-cdn-promotion-delay"];
231
- values["warm-cache-include-fallbacks"] ||= values["warm-cdn-include-fallbacks"];
231
+ if (values["warm-cache-include-fallbacks"] || values["warm-cdn-include-fallbacks"]) console.warn("[vinext] --warm-cache-include-fallbacks is deprecated and has no effect.");
232
232
  values["traffic-aware-warm-cache"] ||= values["experimental-traffic-aware-warm-cache"];
233
233
  const trafficAwareWarmCache = values["traffic-aware-warm-cache"] || values["experimental-tpr"];
234
234
  const warming = values["warm-cache"] || trafficAwareWarmCache;
@@ -271,7 +271,6 @@ function parseDeployArgs(args) {
271
271
  dangerouslyPromoteOnCdnWarmError: values["dangerously-promote-on-warm-cache-error"],
272
272
  warmCdnPromote: !values["no-promote"] && !values["warm-cache-no-promote"],
273
273
  warmCdnPromotionDelay: values["warm-cache-promotion-delay"] === void 0 ? void 0 : validatePromotionDelay(parseNonNegativeIntegerArg(values["warm-cache-promotion-delay"], "--warm-cache-promotion-delay"), values["warm-cache-promotion-delay"]),
274
- warmCdnIncludeFallbacks: values["warm-cache-include-fallbacks"],
275
274
  experimentalTPR: trafficAwareWarmCache,
276
275
  tprCoverage: parseIntArg("traffic-aware-coverage", values["traffic-aware-coverage"] ?? values["tpr-coverage"]),
277
276
  tprLimit: parseIntArg("traffic-aware-limit", values["traffic-aware-limit"] ?? values["tpr-limit"]),
@@ -302,6 +301,18 @@ async function withCloudflareEnv(env, fn) {
302
301
  else delete process.env.CLOUDFLARE_ENV;
303
302
  }
304
303
  }
304
+ /** Cloudflare Vite Plugin reads this flag when evaluating the typed config and emitting Build Output. */
305
+ async function withCloudflarePreviewBuild(fn) {
306
+ const hadPrevious = "CLOUDFLARE_PREVIEW_BUILD" in process.env;
307
+ const previous = process.env.CLOUDFLARE_PREVIEW_BUILD;
308
+ process.env.CLOUDFLARE_PREVIEW_BUILD = "true";
309
+ try {
310
+ return await fn();
311
+ } finally {
312
+ if (hadPrevious) process.env.CLOUDFLARE_PREVIEW_BUILD = previous;
313
+ else delete process.env.CLOUDFLARE_PREVIEW_BUILD;
314
+ }
315
+ }
305
316
  async function loadProjectViteApi(root) {
306
317
  let vitePath;
307
318
  try {
@@ -385,7 +396,10 @@ function isCfCliInstalled(root, resolvePackageJson = (projectRoot) => {
385
396
  }
386
397
  function buildCfDeployArgs(options) {
387
398
  const mode = options.env || (options.preview ? "preview" : void 0);
388
- const args = ["deploy", "--prebuilt"];
399
+ const isPreviewDeploy = options.target?.type === "preview";
400
+ const args = isPreviewDeploy ? ["previews", "deploy"] : ["deploy"];
401
+ if (isPreviewDeploy && options.target?.name) args.push(options.target.name);
402
+ args.push("--prebuilt");
389
403
  if (mode) args.push("--mode", validateWranglerEnvName(mode));
390
404
  return {
391
405
  args,
@@ -483,7 +497,8 @@ async function runWranglerDeploy(root, options, execute = spawn) {
483
497
  }
484
498
  async function runCfDeploy(root, options, execute = spawn) {
485
499
  const { args, mode } = buildCfDeployArgs(options);
486
- console.log(mode ? `\n Deploying Build Output in mode: ${mode}...` : "\n Deploying Build Output...");
500
+ const isPreview = options.target?.type === "preview";
501
+ console.log(mode ? `\n Deploying ${isPreview ? "Preview " : ""}Build Output in mode: ${mode}...` : "\n Deploying Build Output...");
487
502
  const child = execute(process.execPath, [resolveCfBin(root), ...args], {
488
503
  cwd: root,
489
504
  stdio: [
@@ -494,9 +509,11 @@ async function runCfDeploy(root, options, execute = spawn) {
494
509
  shell: false
495
510
  });
496
511
  let output = "";
512
+ let stdout = "";
497
513
  child.stdout?.on("data", (chunk) => {
498
514
  const text = chunk.toString();
499
515
  output += text;
516
+ stdout += text;
500
517
  process.stdout.write(text);
501
518
  });
502
519
  child.stderr?.on("data", (chunk) => {
@@ -508,11 +525,27 @@ async function runCfDeploy(root, options, execute = spawn) {
508
525
  child.once("error", reject);
509
526
  child.once("close", (code, signal) => {
510
527
  if (code === 0) resolve();
511
- else reject(/* @__PURE__ */ new Error(`cf deploy failed with ${signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`}.`));
528
+ else reject(/* @__PURE__ */ new Error(`cf ${isPreview ? "previews deploy" : "deploy"} failed with ${signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`}.`));
512
529
  });
513
530
  });
531
+ if (isPreview) return parseCfPreviewUrl(stdout) ?? parseWorkerDeploymentUrl(output) ?? "(Preview URL not detected in cf output)";
514
532
  return parseWorkerDeploymentUrl(output) ?? "(URL not detected in cf output)";
515
533
  }
534
+ /** `cf previews deploy` prints a JSON result; prefer the named Preview URL over the deployment URL. */
535
+ function parseCfPreviewUrl(stdout) {
536
+ const text = stripVTControlCharacters(stdout);
537
+ const start = text.indexOf("{");
538
+ const end = text.lastIndexOf("}");
539
+ if (start === -1 || end < start) return null;
540
+ let result;
541
+ try {
542
+ result = JSON.parse(text.slice(start, end + 1));
543
+ } catch {
544
+ return null;
545
+ }
546
+ for (const urls of [result.preview_urls, result.deployment_urls]) if (Array.isArray(urls) && typeof urls[0] === "string") return urls[0];
547
+ return null;
548
+ }
516
549
  function hasCdnWarmRequests(plan) {
517
550
  return plan.paths.length + (plan.pagesDataPaths?.length ?? 0) + (plan.routeHandlerPaths?.length ?? 0) + plan.rscPaths.length + plan.loadingShellPaths.length > 0;
518
551
  }
@@ -1131,13 +1164,30 @@ function withPromotedVersionWarmupNote(error) {
1131
1164
  const message = error instanceof Error ? error.message : String(error);
1132
1165
  return new Error(`${message} The uploaded version is already promoted to 100% and its Worker triggers/routes were updated; rerun deploy to retry cache warming or create a deployment for the previous version.`, { cause: error });
1133
1166
  }
1167
+ /** Worker Previews skip production-only steps, so reject options that would otherwise be ignored. */
1168
+ function assertPreviewDeployOptions(options) {
1169
+ const unsupported = Object.entries({
1170
+ preview: options.preview,
1171
+ name: options.name !== void 0,
1172
+ config: options.config !== void 0,
1173
+ warmCdnCache: options.warmCdnCache,
1174
+ warmCdnTarget: options.warmCdnTarget !== void 0,
1175
+ warmCdnCertify: options.warmCdnCertify,
1176
+ warmCdnPromote: options.warmCdnPromote === false,
1177
+ experimentalTPR: options.experimentalTPR
1178
+ }).filter(([, isSet]) => isSet).map(([option]) => option);
1179
+ if (unsupported.length > 0) throw new Error(`Worker Preview deployments do not support: ${unsupported.join(", ")}.`);
1180
+ }
1134
1181
  async function deploy(options) {
1182
+ const isPreview = options.target?.type === "preview";
1183
+ if (isPreview) assertPreviewDeployOptions(options);
1135
1184
  if (options.warmCdnTarget !== void 0 && !options.warmCdnCache && !options.experimentalTPR) throw new Error("--warm-cache-target requires --warm-cache or --traffic-aware-warm-cache.");
1136
1185
  const warmCdnTarget = options.warmCdnTarget === void 0 ? void 0 : validateCdnWarmTarget(options.warmCdnTarget);
1137
1186
  const deployEnv = options.env || (options.preview ? "preview" : void 0);
1138
1187
  if (deployEnv) validateWranglerEnvName(deployEnv);
1139
1188
  const root = path.resolve(options.root);
1140
1189
  const deploymentTool = resolveDeploymentTool(root);
1190
+ if (isPreview && deploymentTool !== "cf") throw new Error("Worker Previews require a cloudflare.config.ts project and the cf CLI.");
1141
1191
  if (deploymentTool === "cf" && options.name) throw new Error("--name is not supported for cloudflare.config.ts projects. Set `name` in cloudflare.config.ts instead.");
1142
1192
  const viteMode = resolveViteBuildMode(deploymentTool, deployEnv);
1143
1193
  const wranglerFallbackEnv = deploymentTool === "cf" ? void 0 : deployEnv;
@@ -1145,7 +1195,9 @@ async function deploy(options) {
1145
1195
  root,
1146
1196
  mode: viteMode
1147
1197
  });
1148
- console.log("\n vinext-cloudflare deploy\n");
1198
+ const cloudflareEnv = isPreview ? void 0 : deployEnv;
1199
+ const withBuildContext = (fn) => withCloudflareEnv(cloudflareEnv, () => isPreview ? withCloudflarePreviewBuild(fn) : fn());
1200
+ console.log(isPreview ? "\n vinext-cloudflare previews deploy\n" : "\n vinext-cloudflare deploy\n");
1149
1201
  const info = detectProject(root);
1150
1202
  if (!info.isAppRouter && !info.isPagesRouter) {
1151
1203
  console.error(" Error: No app/ or pages/ directory found.");
@@ -1172,9 +1224,17 @@ async function deploy(options) {
1172
1224
  console.log("\n Dry run complete. No build or deploy performed.\n");
1173
1225
  return;
1174
1226
  }
1175
- const viteConfigMetadata = await withCloudflareEnv(deployEnv, () => loadDeployViteConfigMetadata(info.root, viteMode));
1227
+ if (isPreview && options.skipBuild) {
1228
+ console.log("\n Skipping build (--skip-build)");
1229
+ printDeployUrl("Preview URL", await runCfDeploy(root, {
1230
+ env: viteMode,
1231
+ target: options.target
1232
+ }));
1233
+ return;
1234
+ }
1235
+ const viteConfigMetadata = await withBuildContext(() => loadDeployViteConfigMetadata(info.root, viteMode));
1176
1236
  const cdnAdapterConfig = resolveCdnAdapterConfig(viteConfigMetadata.cacheConfig);
1177
- const nextConfig = await withCloudflareEnv(deployEnv, async () => {
1237
+ const nextConfig = await withBuildContext(async () => {
1178
1238
  const inlineNextConfig = viteConfigMetadata.nextConfig;
1179
1239
  const rawNextConfig = inlineNextConfig ? await resolveNextConfigInput(inlineNextConfig, PHASE_PRODUCTION_BUILD) : await loadNextConfig(info.root, PHASE_PRODUCTION_BUILD);
1180
1240
  return resolveNextConfig(rawNextConfig, info.root);
@@ -1195,10 +1255,10 @@ async function deploy(options) {
1195
1255
  const shouldEmitPrerenderPathManifest = !options.skipBuild && shouldPrerenderLocally;
1196
1256
  if (prerenderDecision && !shouldPrerenderLocally) {
1197
1257
  const trigger = prerenderDecision.reason === "flag" ? "--prerender-all" : "vinext prerender config";
1198
- const replacement = 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.";
1258
+ 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.";
1199
1259
  console.warn(`\n Warning: ${trigger} is ignored by Cloudflare deploy. ${replacement}`);
1200
1260
  }
1201
- if (!options.skipBuild) await runBuild(info, deployEnv, viteMode);
1261
+ if (!options.skipBuild) await withBuildContext(() => runBuild(info, cloudflareEnv, viteMode));
1202
1262
  else console.log("\n Skipping build (--skip-build)");
1203
1263
  const prerenderOutputDirs = resolvePrerenderOutputDirs(info.root, deploymentTool, viteConfigMetadata.routeRootConfig);
1204
1264
  const canWarmTpr = options.experimentalTPR && !shouldPrerenderLocally && hasBuildIdentityHeader;
@@ -1328,7 +1388,6 @@ async function deploy(options) {
1328
1388
  };
1329
1389
  return createPrerenderWarmPlan(root, discovery, {
1330
1390
  includeCanonicalRsc: hasCanonicalRscWarmup,
1331
- includeFallbackShells: options.warmCdnIncludeFallbacks,
1332
1391
  strict: options.warmCdnCertify === true || !options.dangerouslyPromoteOnCdnWarmError
1333
1392
  });
1334
1393
  },
@@ -1357,15 +1416,21 @@ async function deploy(options) {
1357
1416
  }
1358
1417
  if (url === void 0) {
1359
1418
  if (deploymentTool === "cf" && options.warmCdnPromote === false) url = runCfVersionUpload(root, wranglerOptions).previewUrl ?? "(Preview URL not detected in cf output)";
1360
- else if (deploymentTool === "cf") url = await runCfDeploy(root, wranglerOptions);
1419
+ else if (deploymentTool === "cf") url = await runCfDeploy(root, {
1420
+ ...wranglerOptions,
1421
+ target: options.target
1422
+ });
1361
1423
  else url = await runWranglerDeploy(root, {
1362
1424
  ...wranglerOptions,
1363
1425
  promote: options.warmCdnPromote
1364
1426
  });
1365
1427
  }
1428
+ printDeployUrl(isPreview ? "Preview URL" : options.warmCdnPromote === false ? "Version URL" : "Deployed to", url);
1429
+ }
1430
+ function printDeployUrl(label, url) {
1366
1431
  console.log("\n ─────────────────────────────────────────");
1367
- console.log(` ${options.warmCdnPromote === false ? "Version URL" : "Deployed to"}: ${url}`);
1432
+ console.log(` ${label}: ${url}`);
1368
1433
  console.log(" ─────────────────────────────────────────\n");
1369
1434
  }
1370
1435
  //#endregion
1371
- export { DEFAULT_CDN_WARM_PROMOTION_DELAY_MS, buildCfDeployArgs, buildNodeCliInvocation, buildVersionOverrideHeaders, buildWranglerDeployArgs, buildWranglerInvocation, deploy, deployWithCdnWarmup, getZeroPercentStagingTraffic, hasCdnWarmRequests, isCfCliInstalled, parseDeployArgs, projectRequiresRouteCacheabilityProbeManifest, resolveCdnWarmupTargetUrl, resolveCfBin, resolveDeploymentControlPlaneOptions, resolveDeploymentTool, resolvePrerenderOutputDirs, resolveViteBuildMode, resolveWorkerNameForVersionOverride, resolveWranglerBin, runCfDeploy, runWranglerDeploy, selectTPRWarmPlan, validateWranglerEnvName, withCloudflareEnv };
1436
+ export { DEFAULT_CDN_WARM_PROMOTION_DELAY_MS, buildCfDeployArgs, buildNodeCliInvocation, buildVersionOverrideHeaders, buildWranglerDeployArgs, buildWranglerInvocation, deploy, deployWithCdnWarmup, getZeroPercentStagingTraffic, hasCdnWarmRequests, isCfCliInstalled, parseDeployArgs, projectRequiresRouteCacheabilityProbeManifest, resolveCdnWarmupTargetUrl, resolveCfBin, resolveDeploymentControlPlaneOptions, resolveDeploymentTool, resolvePrerenderOutputDirs, resolveViteBuildMode, resolveWorkerNameForVersionOverride, resolveWranglerBin, runCfDeploy, runWranglerDeploy, selectTPRWarmPlan, validateWranglerEnvName, withCloudflareEnv, withCloudflarePreviewBuild };
@@ -0,0 +1,9 @@
1
+ //#region src/previews-deploy.d.ts
2
+ /** CLI options for `vinext-cloudflare previews deploy`. */
3
+ export declare function parsePreviewsDeployArgs(args: string[]): {
4
+ help: boolean;
5
+ name: string | undefined;
6
+ mode: string | undefined;
7
+ skipBuild: boolean;
8
+ };
9
+ //#endregion
@@ -0,0 +1,43 @@
1
+ import { parseArgs } from "node:util";
2
+ //#region src/previews-deploy.ts
3
+ function validatePreviewName(name) {
4
+ const trimmed = name.trim();
5
+ if (!trimmed) throw new Error("Preview name must not be empty.");
6
+ if (trimmed.includes("\0")) throw new Error("Preview names cannot contain null bytes.");
7
+ if (trimmed.startsWith("-")) throw new Error("Preview name must not start with '-'.");
8
+ return trimmed;
9
+ }
10
+ function validateMode(mode) {
11
+ const trimmed = mode.trim();
12
+ if (!trimmed) throw new Error("--mode requires a non-empty value.");
13
+ return trimmed;
14
+ }
15
+ /** CLI options for `vinext-cloudflare previews deploy`. */
16
+ function parsePreviewsDeployArgs(args) {
17
+ const { values, positionals } = parseArgs({
18
+ args,
19
+ allowPositionals: true,
20
+ strict: true,
21
+ options: {
22
+ help: {
23
+ type: "boolean",
24
+ short: "h",
25
+ default: false
26
+ },
27
+ mode: { type: "string" },
28
+ "skip-build": {
29
+ type: "boolean",
30
+ default: false
31
+ }
32
+ }
33
+ });
34
+ if (positionals.length > 1) throw new Error("Expected at most one Preview name: previews deploy [preview-name].");
35
+ return {
36
+ help: values.help,
37
+ name: positionals[0] === void 0 ? void 0 : validatePreviewName(positionals[0]),
38
+ mode: values.mode === void 0 ? void 0 : validateMode(values.mode),
39
+ skipBuild: values["skip-build"]
40
+ };
41
+ }
42
+ //#endregion
43
+ export { parsePreviewsDeployArgs };
@@ -3,7 +3,9 @@ export declare function parseWorkerDeploymentUrl(output: string): string | null;
3
3
  /**
4
4
  * Parse the concrete hostname that Wrangler reports after applying Worker
5
5
  * triggers. Catch-all routes are valid warmup targets even though they are not
6
- * canonical deployment URLs for general CLI reporting.
6
+ * canonical deployment URLs for general CLI reporting. Warmup requests carry the
7
+ * prerender secret, so an `http://`-only route (which Cloudflare does not match
8
+ * for HTTPS requests) is never used as a target.
7
9
  */
8
10
  export declare function parseCdnWarmupDeploymentUrl(output: string): string | null;
9
11
  //#endregion
@@ -6,7 +6,9 @@ function parseWorkerDeploymentUrl(output) {
6
6
  /**
7
7
  * Parse the concrete hostname that Wrangler reports after applying Worker
8
8
  * triggers. Catch-all routes are valid warmup targets even though they are not
9
- * canonical deployment URLs for general CLI reporting.
9
+ * canonical deployment URLs for general CLI reporting. Warmup requests carry the
10
+ * prerender secret, so an `http://`-only route (which Cloudflare does not match
11
+ * for HTTPS requests) is never used as a target.
10
12
  */
11
13
  function parseCdnWarmupDeploymentUrl(output) {
12
14
  return parseCustomDomainUrl(output) ?? parseCatchAllWorkerRouteOrigin(output) ?? parseWorkersDevUrl(output);
@@ -17,7 +19,7 @@ function parseCatchAllWorkerRouteOrigin(output) {
17
19
  if (!route || !route.endsWith("/*")) continue;
18
20
  try {
19
21
  const url = new URL(route.includes("://") ? route : `https://${route}`);
20
- if (url.protocol !== "https:" && url.protocol !== "http:" || url.hostname.includes("*") || url.username !== "" || url.password !== "" || url.port !== "" || url.pathname !== "/*" || url.search !== "" || url.hash !== "") continue;
22
+ if (url.protocol !== "https:" || url.hostname.includes("*") || url.username !== "" || url.password !== "" || url.port !== "" || url.pathname !== "/*" || url.search !== "" || url.hash !== "") continue;
21
23
  return url.origin;
22
24
  } catch {
23
25
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vinext/cloudflare",
3
- "version": "1.1.0",
3
+ "version": "1.2.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.0"
51
+ "@cloudflare/workers-response-store": "1.1.2"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@types/node": "^25.9.2",
@@ -57,12 +57,12 @@
57
57
  "vite": "npm:@voidzero-dev/vite-plus-core@1.1.0",
58
58
  "vite-plus": "1.1.0",
59
59
  "vitest": "5.0.3",
60
- "vinext": "1.1.0"
60
+ "vinext": "1.3.0"
61
61
  },
62
62
  "peerDependencies": {
63
63
  "@cloudflare/vite-plugin": "*",
64
64
  "cf": "*",
65
- "vinext": "^1.1.0"
65
+ "vinext": "^1.3.0"
66
66
  },
67
67
  "peerDependenciesMeta": {
68
68
  "@cloudflare/vite-plugin": {