@vinext/cloudflare 0.2.1 → 1.0.0-beta.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.
package/dist/deploy.js CHANGED
@@ -1,16 +1,23 @@
1
- import { runTPR } from "./tpr.js";
2
- import { formatImageOptimizationHint, formatMissingCacheAdapterError, viteConfigHasCacheAdapter, viteConfigHasCloudflarePlugin, viteConfigHasImageAdapter, workerEntryHasCacheHandler } from "./deploy-config.js";
1
+ import { readPrerenderWarmPaths, warmCdnCache } from "./cdn-warm.js";
2
+ import { parseWranglerConfig, runTPR } from "./tpr.js";
3
+ import { formatImageOptimizationHint, formatMissingCacheAdapterError, resolveKvDataAdapterConfig, viteConfigHasCacheAdapter, viteConfigHasCloudflarePlugin, viteConfigHasImageAdapter, workerEntryHasCacheHandler } from "./deploy-config.js";
4
+ import { parseWorkersDevUrl } from "./workers-dev-url.js";
5
+ import { runWranglerDeploymentStatus, runWranglerTriggersDeploy, runWranglerVersionDeploy, runWranglerVersionUpload } from "./version-deploy.js";
6
+ import { buildPrerenderKVPairs } from "./prerender-kv-populate.js";
3
7
  import { createRequire } from "node:module";
4
- import fs from "node:fs";
5
8
  import path from "node:path";
6
- import { execFileSync } from "node:child_process";
9
+ import fs from "node:fs";
10
+ import { emitPrerenderPathManifest } from "vinext/internal/build/prerender-paths";
11
+ import os from "node:os";
12
+ import { spawn } from "node:child_process";
7
13
  import { parseArgs } from "node:util";
8
14
  import { pathToFileURL } from "node:url";
9
15
  import { runPrerender } from "vinext/internal/build/run-prerender";
10
16
  import { loadDotenv } from "vinext/internal/config/dotenv";
11
- import { loadNextConfig, resolveNextConfig } from "vinext/internal/config/next-config";
12
- import { formatVinextPrerenderLabel, loadVinextPrerenderConfigFromViteConfig, resolveVinextPrerenderDecision } from "vinext/internal/config/prerender";
17
+ import { findVinextNextConfigInPlugins, loadNextConfig, resolveNextConfig, resolveNextConfigInput } from "vinext/internal/config/next-config";
18
+ import { findVinextCacheConfigInPlugins, findVinextPrerenderConfigInPlugins, findVinextRouteRootConfigInPlugins, formatVinextPrerenderLabel, resolveVinextPrerenderDecision } from "vinext/internal/config/prerender";
13
19
  import { detectProject, findInNodeModules, formatMissingCloudflarePluginError, getMissingDeps } from "vinext/internal/utils/project";
20
+ import { PHASE_PRODUCTION_BUILD } from "vinext/shims/constants";
14
21
  //#region src/deploy.ts
15
22
  /**
16
23
  * vinext-cloudflare deploy — one-command Cloudflare Workers deployment.
@@ -27,6 +34,16 @@ function parsePositiveIntegerArg(raw, flag) {
27
34
  if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${flag} expects a positive integer, but got "${raw}".`);
28
35
  return parsed;
29
36
  }
37
+ function parseNonNegativeIntegerArg(raw, flag) {
38
+ if (raw === "") throw new Error(`${flag} requires a value, but none was provided.`);
39
+ const parsed = Number(raw);
40
+ if (!Number.isInteger(parsed) || parsed < 0) throw new Error(`${flag} expects a non-negative integer, but got "${raw}".`);
41
+ return parsed;
42
+ }
43
+ function formatUnknownError(error) {
44
+ if (error instanceof Error && error.message) return error.message;
45
+ return String(error);
46
+ }
30
47
  /** Deploy command flag definitions for util.parseArgs. */
31
48
  const deployArgOptions = {
32
49
  help: {
@@ -40,6 +57,7 @@ const deployArgOptions = {
40
57
  },
41
58
  env: { type: "string" },
42
59
  name: { type: "string" },
60
+ config: { type: "string" },
43
61
  "skip-build": {
44
62
  type: "boolean",
45
63
  default: false
@@ -53,6 +71,21 @@ const deployArgOptions = {
53
71
  default: false
54
72
  },
55
73
  "prerender-concurrency": { type: "string" },
74
+ "experimental-warm-cdn-cache": {
75
+ type: "boolean",
76
+ default: false
77
+ },
78
+ "warm-cdn-concurrency": { type: "string" },
79
+ "warm-cdn-timeout": { type: "string" },
80
+ "warm-cdn-retries": { type: "string" },
81
+ "warm-cdn-strict": {
82
+ type: "boolean",
83
+ default: false
84
+ },
85
+ "warm-cdn-include-fallbacks": {
86
+ type: "boolean",
87
+ default: false
88
+ },
56
89
  "experimental-tpr": {
57
90
  type: "boolean",
58
91
  default: false
@@ -81,10 +114,17 @@ function parseDeployArgs(args) {
81
114
  preview: values.preview,
82
115
  env: values.env?.trim() || void 0,
83
116
  name: values.name?.trim() || void 0,
117
+ config: values.config?.trim() || void 0,
84
118
  skipBuild: values["skip-build"],
85
119
  dryRun: values["dry-run"],
86
120
  prerenderAll: values["prerender-all"],
87
121
  prerenderConcurrency: values["prerender-concurrency"] === void 0 ? void 0 : parsePositiveIntegerArg(values["prerender-concurrency"], "--prerender-concurrency"),
122
+ warmCdnCache: values["experimental-warm-cdn-cache"],
123
+ warmCdnConcurrency: values["warm-cdn-concurrency"] === void 0 ? void 0 : parsePositiveIntegerArg(values["warm-cdn-concurrency"], "--warm-cdn-concurrency"),
124
+ warmCdnTimeout: values["warm-cdn-timeout"] === void 0 ? void 0 : parsePositiveIntegerArg(values["warm-cdn-timeout"], "--warm-cdn-timeout"),
125
+ warmCdnRetries: values["warm-cdn-retries"] === void 0 ? void 0 : parseNonNegativeIntegerArg(values["warm-cdn-retries"], "--warm-cdn-retries"),
126
+ warmCdnStrict: values["warm-cdn-strict"],
127
+ warmCdnIncludeFallbacks: values["warm-cdn-include-fallbacks"],
88
128
  experimentalTPR: values["experimental-tpr"],
89
129
  tprCoverage: parseIntArg("tpr-coverage", values["tpr-coverage"]),
90
130
  tprLimit: parseIntArg("tpr-limit", values["tpr-limit"]),
@@ -124,6 +164,18 @@ async function loadProjectViteApi(root) {
124
164
  }
125
165
  return await (vitePath === "vite" ? import(vitePath) : import(pathToFileURL(vitePath).href));
126
166
  }
167
+ async function loadDeployViteConfigMetadata(root) {
168
+ const plugins = (await (await loadProjectViteApi(root)).loadConfigFromFile({
169
+ command: "build",
170
+ mode: "production"
171
+ }, void 0, root))?.config.plugins;
172
+ return {
173
+ cacheConfig: viteConfigHasCacheAdapter(root) ? await findVinextCacheConfigInPlugins(plugins) : null,
174
+ nextConfig: await findVinextNextConfigInPlugins(plugins),
175
+ prerenderConfig: await findVinextPrerenderConfigInPlugins(plugins),
176
+ routeRootConfig: await findVinextRouteRootConfigInPlugins(plugins)
177
+ };
178
+ }
127
179
  async function runBuild(info, env) {
128
180
  console.log("\n Building for Cloudflare Workers...\n");
129
181
  const { createBuilder } = await loadProjectViteApi(info.root);
@@ -131,6 +183,25 @@ async function runBuild(info, env) {
131
183
  await (await createBuilder({ root: info.root })).buildApp();
132
184
  });
133
185
  }
186
+ async function populateKVCacheFromPrerenderedArtifacts(root, wranglerEnv, cacheConfig) {
187
+ const kvConfig = resolveKvDataAdapterConfig(cacheConfig);
188
+ if (!kvConfig) return;
189
+ const { routeCount, pairs } = buildPrerenderKVPairs(path.join(root, "dist", "server"), {
190
+ appPrefix: kvConfig.appPrefix,
191
+ ttlSeconds: kvConfig.ttlSeconds
192
+ });
193
+ if (pairs.length === 0) {
194
+ console.log(" KV cache: Skipping prerender upload (no App Router prerendered cache entries found).");
195
+ return;
196
+ }
197
+ await runWranglerKVBulkPut(root, {
198
+ binding: kvConfig.binding,
199
+ env: wranglerEnv,
200
+ pairs
201
+ });
202
+ console.log(` KV cache: Uploaded ${pairs.length} entr${pairs.length === 1 ? "y" : "ies"} for ${routeCount} prerendered route${routeCount === 1 ? "" : "s"}.`);
203
+ }
204
+ const KV_BULK_PUT_CHUNK_SIZE = 25;
134
205
  function validateWranglerEnvName(env) {
135
206
  if (env.includes("\0")) throw new Error("Wrangler environment names cannot contain null bytes.");
136
207
  return env;
@@ -138,6 +209,25 @@ function validateWranglerEnvName(env) {
138
209
  function buildWranglerDeployArgs(options) {
139
210
  const args = ["deploy"];
140
211
  const env = options.env || (options.preview ? "preview" : void 0);
212
+ if (options.config) args.push("--config", options.config);
213
+ if (options.name) args.push("--name", options.name);
214
+ if (env) args.push("--env", validateWranglerEnvName(env));
215
+ return {
216
+ args,
217
+ env
218
+ };
219
+ }
220
+ function buildWranglerKVBulkPutArgs(options) {
221
+ const env = options.env || void 0;
222
+ const args = [
223
+ "kv",
224
+ "bulk",
225
+ "put",
226
+ options.filePath,
227
+ "--binding",
228
+ options.binding,
229
+ "--remote"
230
+ ];
141
231
  if (env) args.push("--env", validateWranglerEnvName(env));
142
232
  return {
143
233
  args,
@@ -179,21 +269,196 @@ function buildWranglerInvocation(root, options, nodeExecutable = process.execPat
179
269
  env
180
270
  };
181
271
  }
182
- function runWranglerDeploy(root, options, execute = execFileSync) {
183
- const execOpts = {
272
+ async function runWranglerKVBulkPut(root, options, execute = spawn, nodeExecutable = process.execPath) {
273
+ const tempDir = fs.mkdtempSync(path.join(options.tempDir ?? os.tmpdir(), "vinext-kv-bulk-"));
274
+ try {
275
+ const wranglerBin = resolveWranglerBin(root);
276
+ const totalChunks = Math.ceil(options.pairs.length / KV_BULK_PUT_CHUNK_SIZE);
277
+ for (let i = 0; i < totalChunks; i++) {
278
+ const filePath = path.join(tempDir, `prerender-kv-${i}.json`);
279
+ const chunk = options.pairs.slice(i * KV_BULK_PUT_CHUNK_SIZE, (i + 1) * KV_BULK_PUT_CHUNK_SIZE);
280
+ fs.writeFileSync(filePath, JSON.stringify(chunk), "utf-8");
281
+ const { args } = buildWranglerKVBulkPutArgs({
282
+ binding: options.binding,
283
+ env: options.env,
284
+ filePath
285
+ });
286
+ const invocation = buildNodeCliInvocation(wranglerBin, args, nodeExecutable);
287
+ const child = execute(invocation.file, invocation.args, {
288
+ cwd: root,
289
+ stdio: "inherit",
290
+ shell: false
291
+ });
292
+ await new Promise((resolve, reject) => {
293
+ child.once("error", reject);
294
+ child.once("close", (code, signal) => {
295
+ if (code === 0) {
296
+ resolve();
297
+ return;
298
+ }
299
+ const exitReason = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`;
300
+ reject(/* @__PURE__ */ new Error(`Wrangler KV bulk put failed with ${exitReason}.`));
301
+ });
302
+ });
303
+ }
304
+ } finally {
305
+ fs.rmSync(tempDir, {
306
+ recursive: true,
307
+ force: true
308
+ });
309
+ }
310
+ }
311
+ async function runWranglerDeploy(root, options, execute = spawn) {
312
+ const spawnOptions = {
184
313
  cwd: root,
185
- stdio: "pipe",
186
- encoding: "utf-8",
314
+ stdio: [
315
+ "inherit",
316
+ "pipe",
317
+ "pipe"
318
+ ],
187
319
  shell: false
188
320
  };
189
321
  const { file, args, env } = buildWranglerInvocation(root, options);
190
322
  if (env) console.log(`\n Deploying to env: ${env}...`);
191
323
  else console.log("\n Deploying to production...");
192
- const output = execute(file, args, execOpts);
193
- const urlMatch = output.match(/https:\/\/[^\s]+\.workers\.dev[^\s]*/);
194
- const deployedUrl = urlMatch ? urlMatch[0] : null;
195
- if (output.trim()) for (const line of output.trim().split("\n")) console.log(` ${line}`);
196
- return deployedUrl ?? "(URL not detected in wrangler output)";
324
+ const child = execute(file, args, spawnOptions);
325
+ let output = "";
326
+ child.stdout?.on("data", (chunk) => {
327
+ const text = chunk.toString();
328
+ output += text;
329
+ process.stdout.write(text);
330
+ });
331
+ child.stderr?.on("data", (chunk) => {
332
+ const text = chunk.toString();
333
+ output += text;
334
+ process.stderr.write(text);
335
+ });
336
+ await new Promise((resolve, reject) => {
337
+ child.once("error", reject);
338
+ child.once("close", (code, signal) => {
339
+ if (code === 0) {
340
+ resolve();
341
+ return;
342
+ }
343
+ const exitReason = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`;
344
+ reject(/* @__PURE__ */ new Error(`Wrangler deploy failed with ${exitReason}.`));
345
+ });
346
+ });
347
+ return parseWorkersDevUrl(output) ?? "(URL not detected in wrangler output)";
348
+ }
349
+ async function deployWithCdnWarmup(root, paths, options) {
350
+ const upload = runWranglerVersionUpload(root, options);
351
+ const wranglerConfig = parseWranglerConfig(root, options.config);
352
+ const stagingTraffic = getZeroPercentStagingTraffic(readWranglerDeploymentStatus(root, options), upload.versionId);
353
+ let staged = null;
354
+ let triggersDeployedUrl = null;
355
+ let warmedBeforePromotion = false;
356
+ let triggersApplied = false;
357
+ function applyTriggers() {
358
+ if (triggersApplied) return;
359
+ triggersDeployedUrl = runWranglerTriggersDeploy(root, options).deployedUrl;
360
+ triggersApplied = true;
361
+ }
362
+ if (stagingTraffic) {
363
+ staged = runWranglerVersionDeploy(root, stagingTraffic, options, "stage");
364
+ try {
365
+ applyTriggers();
366
+ } catch (error) {
367
+ throw withStagedVersionCleanupNote(error);
368
+ }
369
+ const targetUrl = resolveCdnWarmupTargetUrl(root, staged.deployedUrl ?? triggersDeployedUrl, options);
370
+ const headers = buildVersionOverrideHeaders(resolveWorkerNameForVersionOverride(wranglerConfig, options), upload.versionId);
371
+ if (targetUrl && headers) {
372
+ try {
373
+ await warmCdnCache({
374
+ targetUrl,
375
+ paths,
376
+ headers,
377
+ concurrency: options.warmCdnConcurrency,
378
+ timeoutMs: options.warmCdnTimeout,
379
+ retries: options.warmCdnRetries,
380
+ strict: options.warmCdnStrict
381
+ });
382
+ } catch (error) {
383
+ throw withStagedVersionCleanupNote(error);
384
+ }
385
+ warmedBeforePromotion = true;
386
+ } else if (options.warmCdnStrict) throw new Error("CDN warmup failed: pre-traffic warmup needs a production URL and Worker name for version overrides. Configure a route/custom domain and Worker name, or rerun without --warm-cdn-strict. " + getStagedVersionCleanupNote());
387
+ } else console.warn(" CDN warmup: pre-traffic version override skipped because the current deployment is not a single 100% version.");
388
+ const deployed = runWranglerVersionDeploy(root, [{
389
+ versionId: upload.versionId,
390
+ percentage: 100
391
+ }], options, warmedBeforePromotion ? "promote-warmed" : "promote-uploaded");
392
+ if (!warmedBeforePromotion) {
393
+ try {
394
+ applyTriggers();
395
+ } catch (error) {
396
+ throw withPromotedVersionTriggerNote(error);
397
+ }
398
+ const targetUrl = resolveCdnWarmupTargetUrl(root, deployed.deployedUrl ?? triggersDeployedUrl, options);
399
+ if (targetUrl) await warmCdnCache({
400
+ targetUrl,
401
+ paths,
402
+ concurrency: options.warmCdnConcurrency,
403
+ timeoutMs: options.warmCdnTimeout,
404
+ retries: options.warmCdnRetries,
405
+ strict: options.warmCdnStrict
406
+ });
407
+ else if (options.warmCdnStrict) throw new Error("CDN warmup failed: no production URL could be inferred from wrangler config or output. Configure a route/custom domain, ensure Wrangler prints a workers.dev URL, or rerun without --warm-cdn-strict.");
408
+ else console.warn(" CDN warmup skipped: no production URL could be inferred from wrangler config or output.");
409
+ }
410
+ return deployed.deployedUrl ?? triggersDeployedUrl ?? staged?.deployedUrl ?? upload.previewUrl ?? "(URL not detected in wrangler output)";
411
+ }
412
+ function resolveCdnWarmupTargetUrl(root, deployedUrl, options) {
413
+ const config = parseWranglerConfig(root, options?.config);
414
+ const env = getWranglerTargetEnv(options ?? {});
415
+ const customDomain = (env ? config?.env?.[env]?.customDomain : void 0) ?? config?.customDomain;
416
+ if (customDomain) return `https://${customDomain}`;
417
+ return deployedUrl;
418
+ }
419
+ function readWranglerDeploymentStatus(root, options) {
420
+ try {
421
+ return runWranglerDeploymentStatus(root, options);
422
+ } catch {
423
+ return null;
424
+ }
425
+ }
426
+ function getZeroPercentStagingTraffic(deployment, versionId) {
427
+ const current = deployment?.versions ?? [];
428
+ if (current.length !== 1 || current[0].percentage !== 100) return null;
429
+ if (current[0].versionId === versionId) return null;
430
+ return [current[0], {
431
+ versionId,
432
+ percentage: 0
433
+ }];
434
+ }
435
+ function getWranglerTargetEnv(options) {
436
+ return options.env || (options.preview ? "preview" : void 0);
437
+ }
438
+ function resolveWorkerNameForVersionOverride(config, options) {
439
+ if (options.name) return options.name;
440
+ const env = getWranglerTargetEnv(options);
441
+ if (!env) return config?.name;
442
+ if (config?.legacyEnv === false) return config.name;
443
+ return config?.env?.[env]?.name ?? (config?.name ? `${config.name}-${env}` : void 0);
444
+ }
445
+ function quoteStructuredHeaderString(value) {
446
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
447
+ }
448
+ function buildVersionOverrideHeaders(workerName, versionId) {
449
+ if (!workerName) return void 0;
450
+ return { "Cloudflare-Workers-Version-Overrides": `${workerName}=${quoteStructuredHeaderString(versionId)}` };
451
+ }
452
+ function withStagedVersionCleanupNote(error) {
453
+ const message = error instanceof Error ? error.message : String(error);
454
+ return new Error(`${message} ${getStagedVersionCleanupNote()}`, { cause: error });
455
+ }
456
+ function getStagedVersionCleanupNote() {
457
+ return "The uploaded version may remain staged at 0% with the previous version still serving 100% traffic; rerun deploy to promote it or use `wrangler versions deploy` to choose the desired version split.";
458
+ }
459
+ function withPromotedVersionTriggerNote(error) {
460
+ const message = error instanceof Error ? error.message : String(error);
461
+ return new Error(`${message} The uploaded version may already be promoted to 100%, but Worker triggers/routes may not be updated; rerun deploy or \`wrangler triggers deploy\` after fixing the trigger error.`, { cause: error });
197
462
  }
198
463
  async function deploy(options) {
199
464
  const deployEnv = validateWranglerEnvName(options.env || (options.preview ? "preview" : "production"));
@@ -229,44 +494,81 @@ async function deploy(options) {
229
494
  return;
230
495
  }
231
496
  const buildEnv = deployEnv === "production" && !options.env ? void 0 : deployEnv;
497
+ const viteConfigMetadata = await withCloudflareEnv(buildEnv, () => loadDeployViteConfigMetadata(info.root));
498
+ const nextConfig = await withCloudflareEnv(buildEnv, async () => {
499
+ const inlineNextConfig = viteConfigMetadata.nextConfig;
500
+ return resolveNextConfig(inlineNextConfig ? await resolveNextConfigInput(inlineNextConfig, PHASE_PRODUCTION_BUILD) : await loadNextConfig(info.root, PHASE_PRODUCTION_BUILD), info.root);
501
+ });
502
+ const vinextPrerenderConfig = !options.prerenderAll && nextConfig.output !== "export" ? viteConfigMetadata.prerenderConfig : null;
503
+ const prerenderDecision = resolveVinextPrerenderDecision({
504
+ prerenderAllFlag: options.prerenderAll,
505
+ vinextPrerenderConfig,
506
+ nextOutput: nextConfig.output
507
+ });
508
+ const shouldEmitPrerenderPathManifest = options.warmCdnCache || !options.skipBuild && prerenderDecision;
232
509
  if (!options.skipBuild) await runBuild(info, buildEnv);
233
510
  else console.log("\n Skipping build (--skip-build)");
234
- {
235
- const nextConfig = await resolveNextConfig(await loadNextConfig(info.root), info.root);
236
- const vinextPrerenderConfig = !options.prerenderAll && nextConfig.output !== "export" ? await withCloudflareEnv(buildEnv, async () => {
237
- return loadVinextPrerenderConfigFromViteConfig(await loadProjectViteApi(info.root), info.root);
238
- }) : null;
239
- const prerenderDecision = resolveVinextPrerenderDecision({
240
- prerenderAllFlag: options.prerenderAll,
241
- vinextPrerenderConfig,
242
- nextOutput: nextConfig.output
243
- });
244
- if (prerenderDecision) {
245
- console.log(`\n ${formatVinextPrerenderLabel(prerenderDecision)}`);
246
- if (nextConfig.enablePrerenderSourceMaps) {
247
- process.setSourceMapsEnabled(true);
248
- Error.stackTraceLimit = Math.max(Error.stackTraceLimit, 50);
249
- }
250
- await runPrerender({
251
- root: info.root,
252
- concurrency: options.prerenderConcurrency
253
- });
511
+ if (shouldEmitPrerenderPathManifest) await emitPrerenderPathManifest({
512
+ root: info.root,
513
+ nextConfig,
514
+ routeRootConfig: viteConfigMetadata.routeRootConfig
515
+ });
516
+ let ranPrerender = false;
517
+ if (prerenderDecision) {
518
+ console.log(`\n ${formatVinextPrerenderLabel(prerenderDecision)}`);
519
+ if (nextConfig.enablePrerenderSourceMaps) {
520
+ process.setSourceMapsEnabled(true);
521
+ Error.stackTraceLimit = Math.max(Error.stackTraceLimit, 50);
254
522
  }
523
+ await runPrerender({
524
+ root: info.root,
525
+ concurrency: options.prerenderConcurrency,
526
+ nextConfig
527
+ });
528
+ ranPrerender = true;
529
+ }
530
+ if (ranPrerender) try {
531
+ await populateKVCacheFromPrerenderedArtifacts(root, deployEnv === "production" && !options.env ? void 0 : deployEnv, viteConfigMetadata.cacheConfig);
532
+ } catch (error) {
533
+ console.log(` KV cache: Skipping prerender upload (${formatUnknownError(error)}). Continuing with deploy.`);
255
534
  }
256
535
  if (options.experimentalTPR) {
257
536
  console.log();
258
537
  const tprResult = await runTPR({
259
538
  root,
539
+ config: options.config,
260
540
  coverage: Math.max(1, Math.min(100, options.tprCoverage ?? 90)),
261
541
  limit: Math.max(1, options.tprLimit ?? 1e3),
262
542
  window: Math.max(1, options.tprWindow ?? 24)
263
543
  });
264
544
  if (tprResult.skipped) console.log(` TPR: Skipped (${tprResult.skipped})`);
265
545
  }
266
- const url = runWranglerDeploy(root, { env: deployEnv === "production" && !options.env ? void 0 : deployEnv });
546
+ const wranglerOptions = {
547
+ env: deployEnv === "production" && !options.env ? void 0 : deployEnv,
548
+ name: options.name,
549
+ config: options.config
550
+ };
551
+ let url;
552
+ if (options.warmCdnCache) {
553
+ const warmPaths = readPrerenderWarmPaths(root, {
554
+ includeFallbackShells: options.warmCdnIncludeFallbacks,
555
+ strict: options.warmCdnStrict
556
+ });
557
+ if (warmPaths.length > 0) url = await deployWithCdnWarmup(root, warmPaths, {
558
+ ...wranglerOptions,
559
+ warmCdnConcurrency: options.warmCdnConcurrency,
560
+ warmCdnTimeout: options.warmCdnTimeout,
561
+ warmCdnRetries: options.warmCdnRetries,
562
+ warmCdnStrict: options.warmCdnStrict
563
+ });
564
+ else {
565
+ console.log("\n CDN warmup skipped: no build-discovered paths found.");
566
+ url = await runWranglerDeploy(root, wranglerOptions);
567
+ }
568
+ } else url = await runWranglerDeploy(root, wranglerOptions);
267
569
  console.log("\n ─────────────────────────────────────────");
268
570
  console.log(` Deployed to: ${url}`);
269
571
  console.log(" ─────────────────────────────────────────\n");
270
572
  }
271
573
  //#endregion
272
- export { buildNodeCliInvocation, buildWranglerDeployArgs, buildWranglerInvocation, deploy, parseDeployArgs, resolveWranglerBin, runWranglerDeploy, validateWranglerEnvName, withCloudflareEnv };
574
+ export { buildNodeCliInvocation, buildVersionOverrideHeaders, buildWranglerDeployArgs, buildWranglerInvocation, buildWranglerKVBulkPutArgs, deploy, deployWithCdnWarmup, getZeroPercentStagingTraffic, parseDeployArgs, resolveCdnWarmupTargetUrl, resolveWorkerNameForVersionOverride, resolveWranglerBin, runWranglerDeploy, runWranglerKVBulkPut, validateWranglerEnvName, withCloudflareEnv };
@@ -0,0 +1,25 @@
1
+ //#region src/prerender-kv-populate.d.ts
2
+ /**
3
+ * Deploy-time KV population for App Router prerendered artifacts.
4
+ *
5
+ * Reads `dist/server/vinext-prerender.json` and `dist/server/prerendered-routes/*`,
6
+ * converts rendered App Router HTML/RSC artifacts into the same serialized
7
+ * KVCacheEntry shape written by KVCacheHandler.set(), and returns Wrangler KV
8
+ * bulk import records for deploy-time upload.
9
+ */
10
+ type KVBulkPair = {
11
+ key: string;
12
+ value: string;
13
+ expiration_ttl?: number;
14
+ metadata?: Record<string, unknown>;
15
+ };
16
+ declare function buildPrerenderKVPairs(serverDir: string, options?: {
17
+ appPrefix?: string;
18
+ now?: number;
19
+ ttlSeconds?: number;
20
+ }): {
21
+ routeCount: number;
22
+ pairs: KVBulkPair[];
23
+ };
24
+ //#endregion
25
+ export { KVBulkPair, buildPrerenderKVPairs };
@@ -0,0 +1,120 @@
1
+ import { ENTRY_PREFIX } from "./cache/kv-data-adapter.runtime.js";
2
+ import path from "node:path";
3
+ import fs from "node:fs";
4
+ import { getRenderedAppRoutes, readPrerenderManifest } from "vinext/internal/server/prerender-manifest";
5
+ import { appIsrCacheKey } from "vinext/internal/server/isr-cache";
6
+ import { buildAppPageCacheTags } from "vinext/internal/server/app-page-cache";
7
+ import { normalizePregeneratedPathname } from "vinext/internal/server/pregenerated-concrete-paths";
8
+ import { getOutputPath, getRscOutputPath } from "vinext/internal/utils/prerender-output-paths";
9
+ //#region src/prerender-kv-populate.ts
10
+ /**
11
+ * Deploy-time KV population for App Router prerendered artifacts.
12
+ *
13
+ * Reads `dist/server/vinext-prerender.json` and `dist/server/prerendered-routes/*`,
14
+ * converts rendered App Router HTML/RSC artifacts into the same serialized
15
+ * KVCacheEntry shape written by KVCacheHandler.set(), and returns Wrangler KV
16
+ * bulk import records for deploy-time upload.
17
+ */
18
+ /** Default KV expiration TTL used by KVCacheHandler for revalidating entries. */
19
+ const DEFAULT_KV_TTL_SECONDS = 720 * 3600;
20
+ function resolveContainedFile(rootDir, relativePath) {
21
+ const resolvedRoot = path.resolve(rootDir);
22
+ const resolvedFile = path.resolve(resolvedRoot, relativePath);
23
+ const relative = path.relative(resolvedRoot, resolvedFile);
24
+ if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`[vinext] Refusing to read prerender artifact outside ${resolvedRoot}`);
25
+ return resolvedFile;
26
+ }
27
+ function formatUnknownError(error) {
28
+ if (error instanceof Error && error.message) return error.message;
29
+ return String(error);
30
+ }
31
+ function buildKVKey(appPrefix, cacheKey) {
32
+ return `${appPrefix ? `${appPrefix}:` : ""}${ENTRY_PREFIX}${cacheKey}`;
33
+ }
34
+ function buildCacheEntry(value, tags, now, revalidateSeconds, expireSeconds) {
35
+ const cacheControl = revalidateSeconds === void 0 ? void 0 : expireSeconds === void 0 ? { revalidate: revalidateSeconds } : {
36
+ revalidate: revalidateSeconds,
37
+ expire: expireSeconds
38
+ };
39
+ return JSON.stringify({
40
+ value,
41
+ tags,
42
+ lastModified: now,
43
+ revalidateAt: revalidateSeconds === void 0 ? null : now + revalidateSeconds * 1e3,
44
+ expireAt: expireSeconds === void 0 ? null : now + expireSeconds * 1e3,
45
+ ...cacheControl ? { cacheControl } : {}
46
+ });
47
+ }
48
+ function buildMetadata(tags) {
49
+ const metadata = { tags };
50
+ return JSON.stringify(metadata).length <= 1024 ? metadata : void 0;
51
+ }
52
+ function buildPrerenderKVPairs(serverDir, options) {
53
+ const manifest = readPrerenderManifest(path.join(serverDir, "vinext-prerender.json"));
54
+ if (!manifest?.buildId || !Array.isArray(manifest.routes)) return {
55
+ routeCount: 0,
56
+ pairs: []
57
+ };
58
+ const prerenderDir = path.join(serverDir, "prerendered-routes");
59
+ if (!fs.existsSync(prerenderDir)) return {
60
+ routeCount: 0,
61
+ pairs: []
62
+ };
63
+ const pairs = [];
64
+ const now = options?.now ?? Date.now();
65
+ const ttlSeconds = options?.ttlSeconds ?? DEFAULT_KV_TTL_SECONDS;
66
+ const trailingSlash = manifest.trailingSlash ?? false;
67
+ let routeCount = 0;
68
+ for (const route of getRenderedAppRoutes(manifest.routes)) {
69
+ const artifactPathname = route.path ?? route.route;
70
+ const cachePathname = normalizePregeneratedPathname(artifactPathname);
71
+ let htmlPath;
72
+ let rscPath;
73
+ try {
74
+ htmlPath = resolveContainedFile(prerenderDir, getOutputPath(artifactPathname, trailingSlash));
75
+ rscPath = resolveContainedFile(prerenderDir, getRscOutputPath(artifactPathname));
76
+ } catch (error) {
77
+ console.warn(`[vinext] Skipping prerender KV seed for ${artifactPathname}: ${formatUnknownError(error)}`);
78
+ continue;
79
+ }
80
+ if (!fs.existsSync(htmlPath)) continue;
81
+ if (typeof route.revalidate === "number" && route.revalidate <= 0) continue;
82
+ const revalidateSeconds = typeof route.revalidate === "number" ? route.revalidate : void 0;
83
+ const expireSeconds = typeof route.expire === "number" ? route.expire : void 0;
84
+ const expirationTtl = revalidateSeconds === void 0 ? void 0 : ttlSeconds;
85
+ const tags = buildAppPageCacheTags(cachePathname, []);
86
+ const metadata = buildMetadata(tags);
87
+ const htmlKey = appIsrCacheKey(cachePathname, "html", manifest.buildId);
88
+ const rscKey = appIsrCacheKey(cachePathname, "rsc", manifest.buildId);
89
+ pairs.push({
90
+ key: buildKVKey(options?.appPrefix, htmlKey),
91
+ value: buildCacheEntry({
92
+ kind: "APP_PAGE",
93
+ html: fs.readFileSync(htmlPath, "utf-8"),
94
+ headers: route.headers
95
+ }, tags, now, revalidateSeconds, expireSeconds),
96
+ ...expirationTtl !== void 0 ? { expiration_ttl: expirationTtl } : {},
97
+ ...metadata ? { metadata } : {}
98
+ });
99
+ if (fs.existsSync(rscPath)) {
100
+ const rscData = fs.readFileSync(rscPath).toString("base64");
101
+ pairs.push({
102
+ key: buildKVKey(options?.appPrefix, rscKey),
103
+ value: buildCacheEntry({
104
+ kind: "APP_PAGE",
105
+ html: "",
106
+ rscData
107
+ }, tags, now, revalidateSeconds, expireSeconds),
108
+ ...expirationTtl !== void 0 ? { expiration_ttl: expirationTtl } : {},
109
+ ...metadata ? { metadata } : {}
110
+ });
111
+ }
112
+ routeCount++;
113
+ }
114
+ return {
115
+ routeCount,
116
+ pairs
117
+ };
118
+ }
119
+ //#endregion
120
+ export { buildPrerenderKVPairs };
package/dist/tpr.d.ts CHANGED
@@ -21,7 +21,8 @@
21
21
  * or no KV namespace is configured.
22
22
  */
23
23
  type TPROptions = {
24
- /** Project root directory. */root: string; /** Traffic coverage percentage (0–100). Default: 90. */
24
+ /** Project root directory. */root: string; /** Wrangler config path, relative to root unless absolute. */
25
+ config?: string; /** Traffic coverage percentage (0–100). Default: 90. */
25
26
  coverage: number; /** Hard cap on number of pages to pre-render. Default: 1000. */
26
27
  limit: number; /** Analytics lookback window in hours. Default: 24. */
27
28
  window: number;
@@ -42,12 +43,19 @@ type WranglerConfig = {
42
43
  accountId?: string;
43
44
  kvNamespaceId?: string;
44
45
  customDomain?: string;
46
+ name?: string;
47
+ legacyEnv?: boolean;
48
+ env?: Record<string, WranglerEnvironmentConfig>;
49
+ };
50
+ type WranglerEnvironmentConfig = {
51
+ customDomain?: string;
52
+ name?: string;
45
53
  };
46
54
  /**
47
55
  * Parse wrangler config (JSONC or TOML) to extract the fields TPR needs:
48
56
  * account_id, VINEXT_KV_CACHE KV namespace ID, and custom domain.
49
57
  */
50
- declare function parseWranglerConfig(root: string): WranglerConfig | null;
58
+ declare function parseWranglerConfig(root: string, configPath?: string): WranglerConfig | null;
51
59
  /**
52
60
  * Generate zone lookup candidates from shortest (2-part) to longest.
53
61
  * Tries the most common case first (e.g., "example.com") and progressively
@@ -80,4 +88,4 @@ declare function buildTprKVPairs(entries: Map<string, PrerenderResult>, buildId:
80
88
  */
81
89
  declare function runTPR(options: TPROptions): Promise<TPRResult>;
82
90
  //#endregion
83
- export { TPROptions, TPRResult, buildTprKVPairs, domainCandidates, parseWranglerConfig, resolveVinextProdServerPath, runTPR };
91
+ export { TPROptions, TPRResult, WranglerEnvironmentConfig, buildTprKVPairs, domainCandidates, parseWranglerConfig, resolveVinextProdServerPath, runTPR };