@vinext/cloudflare 1.1.1 → 1.2.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.
@@ -28,4 +28,5 @@ export declare function finalizeCdnAdapterBuildOutput({ root, outDir, isPrimaryS
28
28
  binding: string;
29
29
  bindingIsExplicit: boolean;
30
30
  }): Promise<void>;
31
- //#endregion
31
+ //#endregion
32
+ export {};
@@ -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
  }
@@ -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) {
@@ -134,4 +134,5 @@ export declare function createWorkersCacheConfig({ versionMetadataBinding }?: {
134
134
  VinextUncachedResponse: import("cf/config").WorkerEntrypointExport;
135
135
  };
136
136
  }>;
137
- //#endregion
137
+ //#endregion
138
+ export {};
@@ -60,7 +60,7 @@ export declare class KVCacheHandler implements CacheHandler {
60
60
  private keySpace;
61
61
  private ctx;
62
62
  private ttlSeconds;
63
- /** Local in-memory cache for tag invalidation timestamps. Avoids redundant KV reads. */
63
+ /** Local in-memory cache for tag invalidation markers. Avoids redundant KV reads. */
64
64
  private _tagCache;
65
65
  /** Monotonic ordering for concurrent tag-cache fills and local invalidations. */
66
66
  private _tagCacheOrder;
@@ -95,14 +95,18 @@ export declare class KVCacheHandler implements CacheHandler {
95
95
  */
96
96
  private _readTagMarkers;
97
97
  /**
98
- * Report whether any tag has an invalidation marker at or after `lastModified`.
98
+ * Report whether any tag expired an entry last modified at `lastModified`:
99
+ * its marker's expire time is at or after `lastModified` and, for a cacheLife
100
+ * profile's marker, has passed.
99
101
  * Call `_primeTagCache` for the same tags first — a tag with no cache entry
100
102
  * counts as never invalidated. Pass `requireFresh` to call it before a prime,
101
103
  * so an entry past `tagCacheTtlMs` does not answer for a tag it never re-read.
102
104
  */
103
105
  private _hasRevalidatedTag;
106
+ /** Report whether any primed tag was marked stale after `lastModified`. */
107
+ private _hasStaleTag;
104
108
  set(key: string, data: IncrementalCacheValue | null, ctx?: Record<string, unknown>): Promise<void>;
105
- revalidateTag(tags: string | string[], _durations?: {
109
+ revalidateTag(tags: string | string[], durations?: {
106
110
  expire?: number;
107
111
  }): Promise<void>;
108
112
  /**
@@ -1,3 +1,4 @@
1
+ import { isStaleTagInvalidation } from "./workers-cache-invalidation.js";
1
2
  import { isUnknownRecord, readCacheControlNumberField } from "../utils/cache-control-metadata.js";
2
3
  import { ENTRY_PREFIX, createKvKeySpace, resolveKvExpirationTtlSeconds } from "./kv-key.js";
3
4
  import { Buffer } from "node:buffer";
@@ -24,6 +25,23 @@ import { getRequestExecutionContext } from "vinext/shims/request-context";
24
25
  */
25
26
  /** Default KV namespace binding name read from the Worker `env`. */
26
27
  const DEFAULT_BINDING = "VINEXT_KV_CACHE";
28
+ function parseTagMarker(raw) {
29
+ if (raw === null || raw === void 0) return {};
30
+ if (!raw.startsWith("{")) return { expired: /^\d+$/.test(raw) ? Number(raw) : NaN };
31
+ let parsed;
32
+ try {
33
+ parsed = JSON.parse(raw);
34
+ } catch {
35
+ return { expired: NaN };
36
+ }
37
+ if (!isUnknownRecord(parsed)) return { expired: NaN };
38
+ const { stale, expired } = parsed;
39
+ if (stale === void 0 && expired === void 0 || stale !== void 0 && typeof stale !== "number" || expired !== void 0 && typeof expired !== "number") return { expired: NaN };
40
+ return {
41
+ ...stale === void 0 ? {} : { stale },
42
+ ...expired === void 0 ? {} : { expired }
43
+ };
44
+ }
27
45
  /** Prefix used by revalidatePath for path-based tags. */
28
46
  const PATH_TAG_PREFIX = "_N_T_";
29
47
  /** The runtime rejects a lower `cacheTtl` with "Cache TTL must be at least 30". */
@@ -98,7 +116,7 @@ var KVCacheHandler = class {
98
116
  keySpace;
99
117
  ctx;
100
118
  ttlSeconds;
101
- /** Local in-memory cache for tag invalidation timestamps. Avoids redundant KV reads. */
119
+ /** Local in-memory cache for tag invalidation markers. Avoids redundant KV reads. */
102
120
  _tagCache = /* @__PURE__ */ new Map();
103
121
  /** Monotonic ordering for concurrent tag-cache fills and local invalidations. */
104
122
  _tagCacheOrder = 0;
@@ -179,6 +197,7 @@ var KVCacheHandler = class {
179
197
  return null;
180
198
  }
181
199
  if (this._hasRevalidatedTag(softTags, entry.lastModified)) return null;
200
+ const hasStaleTag = this._hasStaleTag([...entryTags, ...softTags], entry.lastModified);
182
201
  if (entry.expireAt !== void 0 && entry.expireAt !== null && Date.now() > entry.expireAt) {
183
202
  this._deleteEntryReadInBackground(kvKey);
184
203
  return null;
@@ -186,7 +205,7 @@ var KVCacheHandler = class {
186
205
  const now = Date.now();
187
206
  const requestedRevalidate = readPositiveNumberField(_ctx, "revalidate");
188
207
  const requestedRevalidateAt = requestedRevalidate === void 0 ? null : entry.lastModified + requestedRevalidate * 1e3;
189
- if (entry.revalidateAt !== null && now > entry.revalidateAt || requestedRevalidateAt !== null && now > requestedRevalidateAt) return {
208
+ if (hasStaleTag || entry.revalidateAt !== null && now > entry.revalidateAt || requestedRevalidateAt !== null && now > requestedRevalidateAt) return {
190
209
  lastModified: entry.lastModified,
191
210
  value: restoredValue,
192
211
  cacheState: "stale",
@@ -221,7 +240,7 @@ var KVCacheHandler = class {
221
240
  if (current && current.order >= order) continue;
222
241
  const marker = markers.get(this._tagKey(tag));
223
242
  tagCache.set(tag, {
224
- timestamp: marker ? Number(marker) : 0,
243
+ marker: parseTagMarker(marker),
225
244
  fetchedAt: now,
226
245
  order
227
246
  });
@@ -244,18 +263,30 @@ var KVCacheHandler = class {
244
263
  return markers;
245
264
  }
246
265
  /**
247
- * Report whether any tag has an invalidation marker at or after `lastModified`.
266
+ * Report whether any tag expired an entry last modified at `lastModified`:
267
+ * its marker's expire time is at or after `lastModified` and, for a cacheLife
268
+ * profile's marker, has passed.
248
269
  * Call `_primeTagCache` for the same tags first — a tag with no cache entry
249
270
  * counts as never invalidated. Pass `requireFresh` to call it before a prime,
250
271
  * so an entry past `tagCacheTtlMs` does not answer for a tag it never re-read.
251
272
  */
252
273
  _hasRevalidatedTag(tags, lastModified, requireFresh = false) {
253
- const now = requireFresh ? Date.now() : 0;
274
+ const now = Date.now();
254
275
  for (const tag of tags) {
255
276
  const cached = this._tagCache.get(tag);
256
- if (!cached || cached.timestamp === 0) continue;
277
+ if (cached?.marker.expired === void 0) continue;
257
278
  if (requireFresh && now - cached.fetchedAt >= this._tagCacheTtl) continue;
258
- if (Number.isNaN(cached.timestamp) || cached.timestamp >= lastModified) return true;
279
+ const { stale, expired } = cached.marker;
280
+ const pending = stale !== void 0 && expired > now;
281
+ if (Number.isNaN(expired) || expired >= lastModified && !pending) return true;
282
+ }
283
+ return false;
284
+ }
285
+ /** Report whether any primed tag was marked stale after `lastModified`. */
286
+ _hasStaleTag(tags, lastModified) {
287
+ for (const tag of tags) {
288
+ const stale = this._tagCache.get(tag)?.marker.stale;
289
+ if (stale !== void 0 && stale > lastModified) return true;
259
290
  }
260
291
  return false;
261
292
  }
@@ -300,16 +331,32 @@ var KVCacheHandler = class {
300
331
  metadata
301
332
  });
302
333
  }
303
- async revalidateTag(tags, _durations) {
334
+ async revalidateTag(tags, durations) {
304
335
  const tagList = Array.isArray(tags) ? tags : [tags];
305
336
  const now = Date.now();
306
337
  const validTags = tagList.filter((t) => validateTag(t) !== null);
307
- await Promise.all(validTags.map((tag) => this.kv.put(this._tagKey(tag), String(now))));
338
+ const markers = await Promise.all(validTags.map(async (tag) => {
339
+ const tagKey = this._tagKey(tag);
340
+ if (!isStaleTagInvalidation(durations)) {
341
+ await this.kv.put(tagKey, String(now));
342
+ return { expired: now };
343
+ }
344
+ const expire = durations?.expire;
345
+ const expired = expire === void 0 ? parseTagMarker(await this.kv.get(tagKey)).expired : now + expire * 1e3;
346
+ const marker = {
347
+ stale: now,
348
+ ...expired !== void 0 && Number.isFinite(expired) ? { expired } : {}
349
+ };
350
+ await this.kv.put(tagKey, JSON.stringify(marker));
351
+ return marker;
352
+ }));
308
353
  const order = ++this._tagCacheOrder;
309
- for (const tag of validTags) this._tagCache.set(tag, {
310
- timestamp: now,
311
- fetchedAt: now,
312
- order
354
+ validTags.forEach((tag, index) => {
355
+ this._tagCache.set(tag, {
356
+ marker: markers[index],
357
+ fetchedAt: now,
358
+ order
359
+ });
313
360
  });
314
361
  }
315
362
  /**
@@ -11,4 +11,5 @@ export declare function createVinextResponseStoreOptions<Env extends VinextRespo
11
11
  export declare function createVinextResponseStoreHandler(store: WorkersResponseStore): {
12
12
  fetch(request: Request, env: VinextResponseStoreEnv, ctx: WorkerExecutionContext): Promise<Response>;
13
13
  };
14
- //#endregion
14
+ //#endregion
15
+ export {};
@@ -6,10 +6,11 @@ import { cacheForRequest } from "vinext/cache";
6
6
  //#region src/cache/response-store-data.runtime.ts
7
7
  const ARRAY_BUFFER_MARKER = "$vinextArrayBuffer";
8
8
  const CACHE_MAX_AGE_SECONDS = 31536e4;
9
- const CACHE_MAX_AGE = `public, max-age=${CACHE_MAX_AGE_SECONDS}`;
10
9
  const DATA_ENTRY_PATH = "__vinext_data";
11
10
  const REPLAYABLE_HEADER = "X-Vinext-Response-Store-Replayable";
12
11
  const MAX_CACHE_TAG_HEADER_BYTES = 16384;
12
+ /** How long past its revalidate a current Store's hit can still be marked fresh: write latency. */
13
+ const STALE_FRESH_HIT_GRACE_MS = 5e3;
13
14
  const DATA_REVALIDATOR_ID = "vinext:data";
14
15
  const CACHE_FUNCTION_REVALIDATOR_ID = "vinext:cache-function";
15
16
  const invocationStorage = new AsyncLocalStorage();
@@ -194,9 +195,9 @@ function deserialize(value) {
194
195
  return entry;
195
196
  }
196
197
  function cachePolicy(revalidate, expire) {
197
- if (revalidate === false || revalidate === void 0) return `public, max-age=${CACHE_MAX_AGE_SECONDS}`;
198
- const staleSeconds = expire === void 0 ? CACHE_MAX_AGE_SECONDS : Math.max(0, expire - revalidate);
199
- return `public, max-age=${Math.max(0, revalidate)}, stale-while-revalidate=${staleSeconds}`;
198
+ if (revalidate === false || revalidate === void 0) return `public, max-age=${expire === void 0 ? CACHE_MAX_AGE_SECONDS : Math.max(1, Math.ceil(expire))}`;
199
+ const maxAge = revalidate > 0 ? Math.max(1, Math.floor(revalidate)) : 0;
200
+ return `public, max-age=${maxAge}, stale-while-revalidate=${expire === void 0 ? CACHE_MAX_AGE_SECONDS : Math.max(0, Math.ceil(expire) - maxAge)}`;
200
201
  }
201
202
  var WorkersResponseStoreCacheHandler = class {
202
203
  store;
@@ -244,12 +245,13 @@ var WorkersResponseStoreCacheHandler = class {
244
245
  }
245
246
  const age = Date.now() - entry.lastModified;
246
247
  const requestedRevalidate = readCacheControlField(context, "revalidate");
248
+ const storedRevalidate = entry.cacheControl?.revalidate;
247
249
  const requestedStale = typeof requestedRevalidate === "number" && requestedRevalidate > 0 && age > requestedRevalidate * 1e3;
248
250
  let cacheState;
249
251
  if (response.headers.get(REPLAYABLE_HEADER) === "1") {
250
252
  if (requestedStale && storeStatus !== "BLOB-STALE") cacheState = "stale";
251
253
  } else if (typeof entry.cacheControl?.expire === "number" && age > entry.cacheControl.expire * 1e3) cacheState = "expired";
252
- else if (requestedStale || storeStatus === "BLOB-STALE" || typeof entry.cacheControl?.revalidate === "number" && entry.cacheControl.revalidate > 0 && age > entry.cacheControl.revalidate * 1e3) cacheState = "stale";
254
+ 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";
253
255
  return {
254
256
  lastModified: entry.lastModified,
255
257
  value: entry.value,
@@ -274,9 +276,11 @@ var WorkersResponseStoreCacheHandler = class {
274
276
  let revalidate = readCacheControlField(context, "revalidate");
275
277
  if (value && "revalidate" in value) revalidate = value.revalidate;
276
278
  if (revalidate === 0) return;
279
+ if (typeof revalidate === "number" && !Number.isFinite(revalidate)) revalidate = false;
277
280
  const rawExpire = readCacheControlField(context, "expire");
278
- const expire = typeof rawExpire === "number" ? rawExpire : void 0;
279
- const stale = readCacheControlField(context, "stale");
281
+ const expire = typeof rawExpire === "number" && Number.isFinite(rawExpire) ? rawExpire : void 0;
282
+ const rawStale = readCacheControlField(context, "stale");
283
+ const stale = typeof rawStale === "number" && Number.isFinite(rawStale) ? rawStale : void 0;
280
284
  const cacheControl = typeof revalidate === "number" || revalidate === false ? {
281
285
  revalidate,
282
286
  ...typeof expire === "number" ? { expire } : {},
@@ -303,7 +307,7 @@ var WorkersResponseStoreCacheHandler = class {
303
307
  lastModified: Date.now(),
304
308
  value
305
309
  }), { headers: {
306
- "Cache-Control": revalidator ? cachePolicy(revalidate, expire) : CACHE_MAX_AGE,
310
+ "Cache-Control": cachePolicy(revalidate, expire),
307
311
  ...tagHeader ? { "Cache-Tag": tagHeader } : {},
308
312
  "Content-Type": "application/json",
309
313
  ...revalidator ? { [REPLAYABLE_HEADER]: "1" } : {}
@@ -26,4 +26,5 @@ export default function createStaticAssetsCacheAdapter({ env, options }?: {
26
26
  env?: Record<string, unknown>;
27
27
  options?: StaticAssetsAdapterOptions;
28
28
  }): CdnCacheAdapter;
29
- //#endregion
29
+ //#endregion
30
+ export {};
@@ -27,4 +27,5 @@ export declare function hasCacheMethod<Name extends CacheMethodName>(value: unkn
27
27
  * `undefined` when the cache supports neither.
28
28
  */
29
29
  export declare function invalidateOrPurge(cache: unknown, options: CacheInvalidationOptions): unknown;
30
- //#endregion
30
+ //#endregion
31
+ export {};
@@ -130,4 +130,5 @@ export declare function waitForCdnWarmTargetReadiness(options: Pick<CdnWarmOptio
130
130
  }): Promise<CdnWarmReadinessResult>;
131
131
  export declare function warmCdnCache(options: CdnWarmOptions): Promise<CdnWarmResult>;
132
132
  export declare function warmCdnCacheFromPrerender(options: PrerenderCdnWarmOptions): Promise<CdnWarmResult>;
133
- //#endregion
133
+ //#endregion
134
+ export {};
package/dist/cli.js CHANGED
@@ -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
@@ -68,6 +74,30 @@ async function deployCommand() {
68
74
  tprWindow: parsed.tprWindow
69
75
  });
70
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
+ }
71
101
  if (command === "--version" || command === "-v") {
72
102
  console.log(`vinext-cloudflare v${VERSION}`);
73
103
  process.exit(0);
@@ -83,6 +113,12 @@ switch (command) {
83
113
  process.exit(1);
84
114
  });
85
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;
86
122
  default:
87
123
  console.error(`Unknown command: ${command}`);
88
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
@@ -87,11 +87,14 @@ function formatDeployHelp() {
87
87
  be reused outside the data center reached by the warmup request after cache
88
88
  propagation, but the deploy does not wait for every edge location to fill.
89
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
+
90
93
  Examples:
91
94
  npx @vinext/cloudflare deploy Build and deploy to production
92
95
  vpx @vinext/cloudflare deploy Build and deploy with Vite+
93
96
  vp exec vinext-cloudflare deploy Run the locally installed Vite+ bin
94
- vinext-cloudflare deploy --preview Deploy to a preview URL
97
+ vinext-cloudflare deploy --preview Deploy using mode/environment preview
95
98
  vinext-cloudflare deploy --env staging Deploy using Cloudflare mode/environment staging
96
99
  vinext-cloudflare deploy --config dist/server/wrangler.json Deploy using a generated Wrangler config
97
100
  vinext-cloudflare deploy --dry-run Validate setup without building or deploying
@@ -113,5 +116,32 @@ function formatDeployHelp() {
113
116
  function printDeployHelp() {
114
117
  console.log(formatDeployHelp());
115
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
+ }
116
146
  //#endregion
117
- 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 */
@@ -120,6 +125,8 @@ export declare function parseDeployArgs(args: string[]): {
120
125
  * Passing `undefined` is a no-op; the callback runs with `process.env` untouched.
121
126
  */
122
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>;
123
130
  export declare function resolveViteBuildMode(deploymentTool: DeploymentTool, env: string | undefined): string;
124
131
  type WranglerDeployArgs = {
125
132
  args: string[];
@@ -133,7 +140,7 @@ export declare function validateWranglerEnvName(env: string): string;
133
140
  export declare function buildWranglerDeployArgs(options: Pick<DeployOptions, "preview" | "env" | "name" | "config">): WranglerDeployArgs;
134
141
  export declare function resolveDeploymentTool(root: string): DeploymentTool;
135
142
  export declare function isCfCliInstalled(root: string, resolvePackageJson?: (root: string) => string | null): boolean;
136
- export declare function buildCfDeployArgs(options: Pick<DeployOptions, "preview" | "env">): CfDeployArgs;
143
+ export declare function buildCfDeployArgs(options: Pick<DeployOptions, "preview" | "env" | "target">): CfDeployArgs;
137
144
  /**
138
145
  * Resolve Wrangler's JavaScript CLI entrypoint in node_modules.
139
146
  *
@@ -154,7 +161,7 @@ export declare function buildWranglerInvocation(root: string, options: Pick<Depl
154
161
  export declare function runWranglerDeploy(root: string, options: Pick<DeployOptions, "preview" | "env" | "name" | "config" | "verbose"> & {
155
162
  promote?: boolean;
156
163
  }, execute?: typeof spawn): Promise<string>;
157
- 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>;
158
165
  export declare function hasCdnWarmRequests(plan: Omit<CdnWarmRequestPlan, "pagesDataPaths"> & {
159
166
  pagesDataPaths?: readonly string[];
160
167
  }): boolean;
@@ -185,4 +192,5 @@ export declare function resolveDeploymentControlPlaneOptions(options: Deployment
185
192
  export declare function resolveWorkerNameForVersionOverride(config: ParsedWranglerConfig | null, options: Pick<DeployOptions, "preview" | "env" | "name">): string | undefined;
186
193
  export declare function buildVersionOverrideHeaders(workerName: string | undefined, versionId: string): HeadersInit | undefined;
187
194
  export declare function deploy(options: DeployOptions): Promise<void>;
188
- //#endregion
195
+ //#endregion
196
+ export {};
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";
@@ -301,6 +301,18 @@ async function withCloudflareEnv(env, fn) {
301
301
  else delete process.env.CLOUDFLARE_ENV;
302
302
  }
303
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
+ }
304
316
  async function loadProjectViteApi(root) {
305
317
  let vitePath;
306
318
  try {
@@ -384,7 +396,10 @@ function isCfCliInstalled(root, resolvePackageJson = (projectRoot) => {
384
396
  }
385
397
  function buildCfDeployArgs(options) {
386
398
  const mode = options.env || (options.preview ? "preview" : void 0);
387
- 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");
388
403
  if (mode) args.push("--mode", validateWranglerEnvName(mode));
389
404
  return {
390
405
  args,
@@ -482,7 +497,8 @@ async function runWranglerDeploy(root, options, execute = spawn) {
482
497
  }
483
498
  async function runCfDeploy(root, options, execute = spawn) {
484
499
  const { args, mode } = buildCfDeployArgs(options);
485
- 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...");
486
502
  const child = execute(process.execPath, [resolveCfBin(root), ...args], {
487
503
  cwd: root,
488
504
  stdio: [
@@ -493,9 +509,11 @@ async function runCfDeploy(root, options, execute = spawn) {
493
509
  shell: false
494
510
  });
495
511
  let output = "";
512
+ let stdout = "";
496
513
  child.stdout?.on("data", (chunk) => {
497
514
  const text = chunk.toString();
498
515
  output += text;
516
+ stdout += text;
499
517
  process.stdout.write(text);
500
518
  });
501
519
  child.stderr?.on("data", (chunk) => {
@@ -507,11 +525,27 @@ async function runCfDeploy(root, options, execute = spawn) {
507
525
  child.once("error", reject);
508
526
  child.once("close", (code, signal) => {
509
527
  if (code === 0) resolve();
510
- 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"}`}.`));
511
529
  });
512
530
  });
531
+ if (isPreview) return parseCfPreviewUrl(stdout) ?? parseWorkerDeploymentUrl(output) ?? "(Preview URL not detected in cf output)";
513
532
  return parseWorkerDeploymentUrl(output) ?? "(URL not detected in cf output)";
514
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
+ }
515
549
  function hasCdnWarmRequests(plan) {
516
550
  return plan.paths.length + (plan.pagesDataPaths?.length ?? 0) + (plan.routeHandlerPaths?.length ?? 0) + plan.rscPaths.length + plan.loadingShellPaths.length > 0;
517
551
  }
@@ -1130,21 +1164,41 @@ function withPromotedVersionWarmupNote(error) {
1130
1164
  const message = error instanceof Error ? error.message : String(error);
1131
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 });
1132
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
+ }
1133
1181
  async function deploy(options) {
1182
+ const isPreview = options.target?.type === "preview";
1183
+ if (isPreview) assertPreviewDeployOptions(options);
1134
1184
  if (options.warmCdnTarget !== void 0 && !options.warmCdnCache && !options.experimentalTPR) throw new Error("--warm-cache-target requires --warm-cache or --traffic-aware-warm-cache.");
1135
1185
  const warmCdnTarget = options.warmCdnTarget === void 0 ? void 0 : validateCdnWarmTarget(options.warmCdnTarget);
1136
1186
  const deployEnv = options.env || (options.preview ? "preview" : void 0);
1137
1187
  if (deployEnv) validateWranglerEnvName(deployEnv);
1138
1188
  const root = path.resolve(options.root);
1139
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.");
1140
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.");
1141
1192
  const viteMode = resolveViteBuildMode(deploymentTool, deployEnv);
1142
1193
  const wranglerFallbackEnv = deploymentTool === "cf" ? void 0 : deployEnv;
1194
+ if (!process.env.NODE_ENV) Reflect.set(process.env, "NODE_ENV", "production");
1143
1195
  loadDotenv({
1144
1196
  root,
1145
1197
  mode: viteMode
1146
1198
  });
1147
- console.log("\n vinext-cloudflare deploy\n");
1199
+ const cloudflareEnv = isPreview ? void 0 : deployEnv;
1200
+ const withBuildContext = (fn) => withCloudflareEnv(cloudflareEnv, () => isPreview ? withCloudflarePreviewBuild(fn) : fn());
1201
+ console.log(isPreview ? "\n vinext-cloudflare previews deploy\n" : "\n vinext-cloudflare deploy\n");
1148
1202
  const info = detectProject(root);
1149
1203
  if (!info.isAppRouter && !info.isPagesRouter) {
1150
1204
  console.error(" Error: No app/ or pages/ directory found.");
@@ -1171,9 +1225,17 @@ async function deploy(options) {
1171
1225
  console.log("\n Dry run complete. No build or deploy performed.\n");
1172
1226
  return;
1173
1227
  }
1174
- const viteConfigMetadata = await withCloudflareEnv(deployEnv, () => loadDeployViteConfigMetadata(info.root, viteMode));
1228
+ if (isPreview && options.skipBuild) {
1229
+ console.log("\n Skipping build (--skip-build)");
1230
+ printDeployUrl("Preview URL", await runCfDeploy(root, {
1231
+ env: viteMode,
1232
+ target: options.target
1233
+ }));
1234
+ return;
1235
+ }
1236
+ const viteConfigMetadata = await withBuildContext(() => loadDeployViteConfigMetadata(info.root, viteMode));
1175
1237
  const cdnAdapterConfig = resolveCdnAdapterConfig(viteConfigMetadata.cacheConfig);
1176
- const nextConfig = await withCloudflareEnv(deployEnv, async () => {
1238
+ const nextConfig = await withBuildContext(async () => {
1177
1239
  const inlineNextConfig = viteConfigMetadata.nextConfig;
1178
1240
  const rawNextConfig = inlineNextConfig ? await resolveNextConfigInput(inlineNextConfig, PHASE_PRODUCTION_BUILD) : await loadNextConfig(info.root, PHASE_PRODUCTION_BUILD);
1179
1241
  return resolveNextConfig(rawNextConfig, info.root);
@@ -1194,10 +1256,10 @@ async function deploy(options) {
1194
1256
  const shouldEmitPrerenderPathManifest = !options.skipBuild && shouldPrerenderLocally;
1195
1257
  if (prerenderDecision && !shouldPrerenderLocally) {
1196
1258
  const trigger = prerenderDecision.reason === "flag" ? "--prerender-all" : "vinext prerender config";
1197
- 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.";
1259
+ 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.";
1198
1260
  console.warn(`\n Warning: ${trigger} is ignored by Cloudflare deploy. ${replacement}`);
1199
1261
  }
1200
- if (!options.skipBuild) await runBuild(info, deployEnv, viteMode);
1262
+ if (!options.skipBuild) await withBuildContext(() => runBuild(info, cloudflareEnv, viteMode));
1201
1263
  else console.log("\n Skipping build (--skip-build)");
1202
1264
  const prerenderOutputDirs = resolvePrerenderOutputDirs(info.root, deploymentTool, viteConfigMetadata.routeRootConfig);
1203
1265
  const canWarmTpr = options.experimentalTPR && !shouldPrerenderLocally && hasBuildIdentityHeader;
@@ -1355,15 +1417,21 @@ async function deploy(options) {
1355
1417
  }
1356
1418
  if (url === void 0) {
1357
1419
  if (deploymentTool === "cf" && options.warmCdnPromote === false) url = runCfVersionUpload(root, wranglerOptions).previewUrl ?? "(Preview URL not detected in cf output)";
1358
- else if (deploymentTool === "cf") url = await runCfDeploy(root, wranglerOptions);
1420
+ else if (deploymentTool === "cf") url = await runCfDeploy(root, {
1421
+ ...wranglerOptions,
1422
+ target: options.target
1423
+ });
1359
1424
  else url = await runWranglerDeploy(root, {
1360
1425
  ...wranglerOptions,
1361
1426
  promote: options.warmCdnPromote
1362
1427
  });
1363
1428
  }
1429
+ printDeployUrl(isPreview ? "Preview URL" : options.warmCdnPromote === false ? "Version URL" : "Deployed to", url);
1430
+ }
1431
+ function printDeployUrl(label, url) {
1364
1432
  console.log("\n ─────────────────────────────────────────");
1365
- console.log(` ${options.warmCdnPromote === false ? "Version URL" : "Deployed to"}: ${url}`);
1433
+ console.log(` ${label}: ${url}`);
1366
1434
  console.log(" ─────────────────────────────────────────\n");
1367
1435
  }
1368
1436
  //#endregion
1369
- 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 };
1437
+ 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 };
@@ -1,2 +1 @@
1
- import { VINEXT_EXPECTED_WORKER_VERSION_HEADER } from "vinext/internal/server/headers";
2
- export { VINEXT_EXPECTED_WORKER_VERSION_HEADER };
1
+ export { VINEXT_EXPECTED_WORKER_VERSION_HEADER } from "vinext/internal/server/headers";
@@ -16,4 +16,5 @@ type WranglerEnvironmentConfig = {
16
16
  * Parse the Wrangler fields used by TPR and staged CDN warming.
17
17
  */
18
18
  export declare function parseWranglerConfig(root: string, configPath?: string): WranglerConfig | null;
19
- //#endregion
19
+ //#endregion
20
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vinext/cloudflare",
3
- "version": "1.1.1",
3
+ "version": "1.2.1",
4
4
  "description": "Cloudflare cache and image adapters for vinext.",
5
5
  "homepage": "https://vinext.dev/",
6
6
  "license": "MIT",
@@ -48,21 +48,20 @@
48
48
  }
49
49
  },
50
50
  "dependencies": {
51
- "@cloudflare/workers-response-store": "1.1.1"
51
+ "@cloudflare/workers-response-store": "1.1.3"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@types/node": "^25.9.2",
55
55
  "cf": "1.0.0-beta.1",
56
56
  "miniflare": "5.20260730.0-alpha",
57
- "vite": "npm:@voidzero-dev/vite-plus-core@1.1.0",
58
- "vite-plus": "1.1.0",
59
- "vitest": "5.0.3",
60
- "vinext": "1.2.0"
57
+ "vite": "npm:@voidzero-dev/vite-plus-core@1.3.0",
58
+ "vite-plus": "1.3.0",
59
+ "vinext": "1.3.1"
61
60
  },
62
61
  "peerDependencies": {
63
62
  "@cloudflare/vite-plugin": "*",
64
63
  "cf": "*",
65
- "vinext": "^1.2.0"
64
+ "vinext": "^1.3.1"
66
65
  },
67
66
  "peerDependenciesMeta": {
68
67
  "@cloudflare/vite-plugin": {
@@ -78,6 +77,6 @@
78
77
  "scripts": {
79
78
  "build": "vp pack",
80
79
  "dev": "vp pack --watch",
81
- "test": "vp run vinext#build && vp run @cloudflare/workers-response-store#build && vp run @vinext/cloudflare#build && VINEXT_RESPONSE_STORE_MODE=self-contained VINEXT_RESPONSE_STORE_LOCATION_HINT=weur pnpm --filter response-store-demo run build && VINEXT_RESPONSE_STORE_LOCATION_HINT=wnam pnpm --filter response-store-demo run build && vitest run"
80
+ "test": "vp run vinext#build && vp run @cloudflare/workers-response-store#build && vp run @vinext/cloudflare#build && VINEXT_RESPONSE_STORE_MODE=self-contained VINEXT_RESPONSE_STORE_LOCATION_HINT=weur pnpm --filter response-store-demo run build && VINEXT_RESPONSE_STORE_LOCATION_HINT=wnam pnpm --filter response-store-demo run build && vp test run"
82
81
  }
83
82
  }