@vinext/cloudflare 0.2.0 → 1.0.0-beta.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.
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
17
  import { loadNextConfig, resolveNextConfig } from "vinext/internal/config/next-config";
12
- import { formatVinextPrerenderLabel, loadVinextPrerenderConfigFromViteConfig, resolveVinextPrerenderDecision } from "vinext/internal/config/prerender";
18
+ import { formatVinextPrerenderLabel, loadVinextCacheConfigFromViteConfig, loadVinextPrerenderConfigFromViteConfig, loadVinextRouteRootConfigFromViteConfig, 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"]),
@@ -131,6 +171,27 @@ async function runBuild(info, env) {
131
171
  await (await createBuilder({ root: info.root })).buildApp();
132
172
  });
133
173
  }
174
+ async function populateKVCacheFromPrerenderedArtifacts(root, buildEnv, wranglerEnv) {
175
+ if (!viteConfigHasCacheAdapter(root)) return;
176
+ const vite = await loadProjectViteApi(root);
177
+ const kvConfig = resolveKvDataAdapterConfig(await withCloudflareEnv(buildEnv, () => loadVinextCacheConfigFromViteConfig(vite, root)));
178
+ if (!kvConfig) return;
179
+ const { routeCount, pairs } = buildPrerenderKVPairs(path.join(root, "dist", "server"), {
180
+ appPrefix: kvConfig.appPrefix,
181
+ ttlSeconds: kvConfig.ttlSeconds
182
+ });
183
+ if (pairs.length === 0) {
184
+ console.log(" KV cache: Skipping prerender upload (no App Router prerendered cache entries found).");
185
+ return;
186
+ }
187
+ await runWranglerKVBulkPut(root, {
188
+ binding: kvConfig.binding,
189
+ env: wranglerEnv,
190
+ pairs
191
+ });
192
+ console.log(` KV cache: Uploaded ${pairs.length} entr${pairs.length === 1 ? "y" : "ies"} for ${routeCount} prerendered route${routeCount === 1 ? "" : "s"}.`);
193
+ }
194
+ const KV_BULK_PUT_CHUNK_SIZE = 25;
134
195
  function validateWranglerEnvName(env) {
135
196
  if (env.includes("\0")) throw new Error("Wrangler environment names cannot contain null bytes.");
136
197
  return env;
@@ -138,6 +199,25 @@ function validateWranglerEnvName(env) {
138
199
  function buildWranglerDeployArgs(options) {
139
200
  const args = ["deploy"];
140
201
  const env = options.env || (options.preview ? "preview" : void 0);
202
+ if (options.config) args.push("--config", options.config);
203
+ if (options.name) args.push("--name", options.name);
204
+ if (env) args.push("--env", validateWranglerEnvName(env));
205
+ return {
206
+ args,
207
+ env
208
+ };
209
+ }
210
+ function buildWranglerKVBulkPutArgs(options) {
211
+ const env = options.env || void 0;
212
+ const args = [
213
+ "kv",
214
+ "bulk",
215
+ "put",
216
+ options.filePath,
217
+ "--binding",
218
+ options.binding,
219
+ "--remote"
220
+ ];
141
221
  if (env) args.push("--env", validateWranglerEnvName(env));
142
222
  return {
143
223
  args,
@@ -179,21 +259,196 @@ function buildWranglerInvocation(root, options, nodeExecutable = process.execPat
179
259
  env
180
260
  };
181
261
  }
182
- function runWranglerDeploy(root, options, execute = execFileSync) {
183
- const execOpts = {
262
+ async function runWranglerKVBulkPut(root, options, execute = spawn, nodeExecutable = process.execPath) {
263
+ const tempDir = fs.mkdtempSync(path.join(options.tempDir ?? os.tmpdir(), "vinext-kv-bulk-"));
264
+ try {
265
+ const wranglerBin = resolveWranglerBin(root);
266
+ const totalChunks = Math.ceil(options.pairs.length / KV_BULK_PUT_CHUNK_SIZE);
267
+ for (let i = 0; i < totalChunks; i++) {
268
+ const filePath = path.join(tempDir, `prerender-kv-${i}.json`);
269
+ const chunk = options.pairs.slice(i * KV_BULK_PUT_CHUNK_SIZE, (i + 1) * KV_BULK_PUT_CHUNK_SIZE);
270
+ fs.writeFileSync(filePath, JSON.stringify(chunk), "utf-8");
271
+ const { args } = buildWranglerKVBulkPutArgs({
272
+ binding: options.binding,
273
+ env: options.env,
274
+ filePath
275
+ });
276
+ const invocation = buildNodeCliInvocation(wranglerBin, args, nodeExecutable);
277
+ const child = execute(invocation.file, invocation.args, {
278
+ cwd: root,
279
+ stdio: "inherit",
280
+ shell: false
281
+ });
282
+ await new Promise((resolve, reject) => {
283
+ child.once("error", reject);
284
+ child.once("close", (code, signal) => {
285
+ if (code === 0) {
286
+ resolve();
287
+ return;
288
+ }
289
+ const exitReason = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`;
290
+ reject(/* @__PURE__ */ new Error(`Wrangler KV bulk put failed with ${exitReason}.`));
291
+ });
292
+ });
293
+ }
294
+ } finally {
295
+ fs.rmSync(tempDir, {
296
+ recursive: true,
297
+ force: true
298
+ });
299
+ }
300
+ }
301
+ async function runWranglerDeploy(root, options, execute = spawn) {
302
+ const spawnOptions = {
184
303
  cwd: root,
185
- stdio: "pipe",
186
- encoding: "utf-8",
304
+ stdio: [
305
+ "inherit",
306
+ "pipe",
307
+ "pipe"
308
+ ],
187
309
  shell: false
188
310
  };
189
311
  const { file, args, env } = buildWranglerInvocation(root, options);
190
312
  if (env) console.log(`\n Deploying to env: ${env}...`);
191
313
  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)";
314
+ const child = execute(file, args, spawnOptions);
315
+ let output = "";
316
+ child.stdout?.on("data", (chunk) => {
317
+ const text = chunk.toString();
318
+ output += text;
319
+ process.stdout.write(text);
320
+ });
321
+ child.stderr?.on("data", (chunk) => {
322
+ const text = chunk.toString();
323
+ output += text;
324
+ process.stderr.write(text);
325
+ });
326
+ await new Promise((resolve, reject) => {
327
+ child.once("error", reject);
328
+ child.once("close", (code, signal) => {
329
+ if (code === 0) {
330
+ resolve();
331
+ return;
332
+ }
333
+ const exitReason = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`;
334
+ reject(/* @__PURE__ */ new Error(`Wrangler deploy failed with ${exitReason}.`));
335
+ });
336
+ });
337
+ return parseWorkersDevUrl(output) ?? "(URL not detected in wrangler output)";
338
+ }
339
+ async function deployWithCdnWarmup(root, paths, options) {
340
+ const upload = runWranglerVersionUpload(root, options);
341
+ const wranglerConfig = parseWranglerConfig(root, options.config);
342
+ const stagingTraffic = getZeroPercentStagingTraffic(readWranglerDeploymentStatus(root, options), upload.versionId);
343
+ let staged = null;
344
+ let triggersDeployedUrl = null;
345
+ let warmedBeforePromotion = false;
346
+ let triggersApplied = false;
347
+ function applyTriggers() {
348
+ if (triggersApplied) return;
349
+ triggersDeployedUrl = runWranglerTriggersDeploy(root, options).deployedUrl;
350
+ triggersApplied = true;
351
+ }
352
+ if (stagingTraffic) {
353
+ staged = runWranglerVersionDeploy(root, stagingTraffic, options, "stage");
354
+ try {
355
+ applyTriggers();
356
+ } catch (error) {
357
+ throw withStagedVersionCleanupNote(error);
358
+ }
359
+ const targetUrl = resolveCdnWarmupTargetUrl(root, staged.deployedUrl ?? triggersDeployedUrl, options);
360
+ const headers = buildVersionOverrideHeaders(resolveWorkerNameForVersionOverride(wranglerConfig, options), upload.versionId);
361
+ if (targetUrl && headers) {
362
+ try {
363
+ await warmCdnCache({
364
+ targetUrl,
365
+ paths,
366
+ headers,
367
+ concurrency: options.warmCdnConcurrency,
368
+ timeoutMs: options.warmCdnTimeout,
369
+ retries: options.warmCdnRetries,
370
+ strict: options.warmCdnStrict
371
+ });
372
+ } catch (error) {
373
+ throw withStagedVersionCleanupNote(error);
374
+ }
375
+ warmedBeforePromotion = true;
376
+ } 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());
377
+ } else console.warn(" CDN warmup: pre-traffic version override skipped because the current deployment is not a single 100% version.");
378
+ const deployed = runWranglerVersionDeploy(root, [{
379
+ versionId: upload.versionId,
380
+ percentage: 100
381
+ }], options, warmedBeforePromotion ? "promote-warmed" : "promote-uploaded");
382
+ if (!warmedBeforePromotion) {
383
+ try {
384
+ applyTriggers();
385
+ } catch (error) {
386
+ throw withPromotedVersionTriggerNote(error);
387
+ }
388
+ const targetUrl = resolveCdnWarmupTargetUrl(root, deployed.deployedUrl ?? triggersDeployedUrl, options);
389
+ if (targetUrl) await warmCdnCache({
390
+ targetUrl,
391
+ paths,
392
+ concurrency: options.warmCdnConcurrency,
393
+ timeoutMs: options.warmCdnTimeout,
394
+ retries: options.warmCdnRetries,
395
+ strict: options.warmCdnStrict
396
+ });
397
+ 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.");
398
+ else console.warn(" CDN warmup skipped: no production URL could be inferred from wrangler config or output.");
399
+ }
400
+ return deployed.deployedUrl ?? triggersDeployedUrl ?? staged?.deployedUrl ?? upload.previewUrl ?? "(URL not detected in wrangler output)";
401
+ }
402
+ function resolveCdnWarmupTargetUrl(root, deployedUrl, options) {
403
+ const config = parseWranglerConfig(root, options?.config);
404
+ const env = getWranglerTargetEnv(options ?? {});
405
+ const customDomain = (env ? config?.env?.[env]?.customDomain : void 0) ?? config?.customDomain;
406
+ if (customDomain) return `https://${customDomain}`;
407
+ return deployedUrl;
408
+ }
409
+ function readWranglerDeploymentStatus(root, options) {
410
+ try {
411
+ return runWranglerDeploymentStatus(root, options);
412
+ } catch {
413
+ return null;
414
+ }
415
+ }
416
+ function getZeroPercentStagingTraffic(deployment, versionId) {
417
+ const current = deployment?.versions ?? [];
418
+ if (current.length !== 1 || current[0].percentage !== 100) return null;
419
+ if (current[0].versionId === versionId) return null;
420
+ return [current[0], {
421
+ versionId,
422
+ percentage: 0
423
+ }];
424
+ }
425
+ function getWranglerTargetEnv(options) {
426
+ return options.env || (options.preview ? "preview" : void 0);
427
+ }
428
+ function resolveWorkerNameForVersionOverride(config, options) {
429
+ if (options.name) return options.name;
430
+ const env = getWranglerTargetEnv(options);
431
+ if (!env) return config?.name;
432
+ if (config?.legacyEnv === false) return config.name;
433
+ return config?.env?.[env]?.name ?? (config?.name ? `${config.name}-${env}` : void 0);
434
+ }
435
+ function quoteStructuredHeaderString(value) {
436
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
437
+ }
438
+ function buildVersionOverrideHeaders(workerName, versionId) {
439
+ if (!workerName) return void 0;
440
+ return { "Cloudflare-Workers-Version-Overrides": `${workerName}=${quoteStructuredHeaderString(versionId)}` };
441
+ }
442
+ function withStagedVersionCleanupNote(error) {
443
+ const message = error instanceof Error ? error.message : String(error);
444
+ return new Error(`${message} ${getStagedVersionCleanupNote()}`, { cause: error });
445
+ }
446
+ function getStagedVersionCleanupNote() {
447
+ 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.";
448
+ }
449
+ function withPromotedVersionTriggerNote(error) {
450
+ const message = error instanceof Error ? error.message : String(error);
451
+ 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
452
  }
198
453
  async function deploy(options) {
199
454
  const deployEnv = validateWranglerEnvName(options.env || (options.preview ? "preview" : "production"));
@@ -214,11 +469,7 @@ async function deploy(options) {
214
469
  console.log(` Project: ${info.projectName}`);
215
470
  console.log(` Router: ${info.isAppRouter ? "App Router" : "Pages Router"}`);
216
471
  console.log(` ISR: ${info.hasISR ? "detected" : "none"}`);
217
- const missingScaffolding = [
218
- !info.hasViteConfig && "Vite config",
219
- !info.hasWranglerConfig && "Wrangler config",
220
- info.isPagesRouter && !info.hasWorkerEntry && "Worker entry"
221
- ].filter((value) => Boolean(value));
472
+ const missingScaffolding = [!info.hasViteConfig && "Vite config", !info.hasWranglerConfig && "Wrangler config"].filter((value) => Boolean(value));
222
473
  if (missingScaffolding.length > 0) throw new Error(`Missing Cloudflare deployment setup: ${missingScaffolding.join(", ")}. Run \`vinext init --platform=cloudflare\` first.`);
223
474
  const missingDeps = getMissingDeps(info);
224
475
  if (missingDeps.length > 0) throw new Error(`Missing deployment dependencies: ${missingDeps.map((dependency) => dependency.name).join(", ")}. Run \`vinext init --platform=cloudflare\` first.`);
@@ -232,45 +483,84 @@ async function deploy(options) {
232
483
  console.log("\n Dry run complete. No build or deploy performed.\n");
233
484
  return;
234
485
  }
486
+ const nextConfig = await resolveNextConfig(await loadNextConfig(info.root, PHASE_PRODUCTION_BUILD), info.root);
235
487
  const buildEnv = deployEnv === "production" && !options.env ? void 0 : deployEnv;
488
+ const vinextPrerenderConfig = !options.prerenderAll && nextConfig.output !== "export" ? await withCloudflareEnv(buildEnv, async () => {
489
+ return loadVinextPrerenderConfigFromViteConfig(await loadProjectViteApi(info.root), info.root);
490
+ }) : null;
491
+ const prerenderDecision = resolveVinextPrerenderDecision({
492
+ prerenderAllFlag: options.prerenderAll,
493
+ vinextPrerenderConfig,
494
+ nextOutput: nextConfig.output
495
+ });
496
+ const shouldEmitPrerenderPathManifest = options.warmCdnCache || !options.skipBuild && prerenderDecision;
236
497
  if (!options.skipBuild) await runBuild(info, buildEnv);
237
498
  else console.log("\n Skipping build (--skip-build)");
238
- {
239
- const nextConfig = await resolveNextConfig(await loadNextConfig(info.root), info.root);
240
- const vinextPrerenderConfig = !options.prerenderAll && nextConfig.output !== "export" ? await withCloudflareEnv(buildEnv, async () => {
241
- return loadVinextPrerenderConfigFromViteConfig(await loadProjectViteApi(info.root), info.root);
242
- }) : null;
243
- const prerenderDecision = resolveVinextPrerenderDecision({
244
- prerenderAllFlag: options.prerenderAll,
245
- vinextPrerenderConfig,
246
- nextOutput: nextConfig.output
499
+ if (shouldEmitPrerenderPathManifest) {
500
+ const routeRootConfig = await withCloudflareEnv(buildEnv, async () => {
501
+ return loadVinextRouteRootConfigFromViteConfig(await loadProjectViteApi(info.root), info.root);
247
502
  });
248
- if (prerenderDecision) {
249
- console.log(`\n ${formatVinextPrerenderLabel(prerenderDecision)}`);
250
- if (nextConfig.enablePrerenderSourceMaps) {
251
- process.setSourceMapsEnabled(true);
252
- Error.stackTraceLimit = Math.max(Error.stackTraceLimit, 50);
253
- }
254
- await runPrerender({
255
- root: info.root,
256
- concurrency: options.prerenderConcurrency
257
- });
503
+ await emitPrerenderPathManifest({
504
+ root: info.root,
505
+ nextConfigOverride: nextConfig,
506
+ routeRootConfig
507
+ });
508
+ }
509
+ let ranPrerender = false;
510
+ if (prerenderDecision) {
511
+ console.log(`\n ${formatVinextPrerenderLabel(prerenderDecision)}`);
512
+ if (nextConfig.enablePrerenderSourceMaps) {
513
+ process.setSourceMapsEnabled(true);
514
+ Error.stackTraceLimit = Math.max(Error.stackTraceLimit, 50);
258
515
  }
516
+ await runPrerender({
517
+ root: info.root,
518
+ concurrency: options.prerenderConcurrency
519
+ });
520
+ ranPrerender = true;
521
+ }
522
+ if (ranPrerender) try {
523
+ await populateKVCacheFromPrerenderedArtifacts(root, buildEnv, deployEnv === "production" && !options.env ? void 0 : deployEnv);
524
+ } catch (error) {
525
+ console.log(` KV cache: Skipping prerender upload (${formatUnknownError(error)}). Continuing with deploy.`);
259
526
  }
260
527
  if (options.experimentalTPR) {
261
528
  console.log();
262
529
  const tprResult = await runTPR({
263
530
  root,
531
+ config: options.config,
264
532
  coverage: Math.max(1, Math.min(100, options.tprCoverage ?? 90)),
265
533
  limit: Math.max(1, options.tprLimit ?? 1e3),
266
534
  window: Math.max(1, options.tprWindow ?? 24)
267
535
  });
268
536
  if (tprResult.skipped) console.log(` TPR: Skipped (${tprResult.skipped})`);
269
537
  }
270
- const url = runWranglerDeploy(root, { env: deployEnv === "production" && !options.env ? void 0 : deployEnv });
538
+ const wranglerOptions = {
539
+ env: deployEnv === "production" && !options.env ? void 0 : deployEnv,
540
+ name: options.name,
541
+ config: options.config
542
+ };
543
+ let url;
544
+ if (options.warmCdnCache) {
545
+ const warmPaths = readPrerenderWarmPaths(root, {
546
+ includeFallbackShells: options.warmCdnIncludeFallbacks,
547
+ strict: options.warmCdnStrict
548
+ });
549
+ if (warmPaths.length > 0) url = await deployWithCdnWarmup(root, warmPaths, {
550
+ ...wranglerOptions,
551
+ warmCdnConcurrency: options.warmCdnConcurrency,
552
+ warmCdnTimeout: options.warmCdnTimeout,
553
+ warmCdnRetries: options.warmCdnRetries,
554
+ warmCdnStrict: options.warmCdnStrict
555
+ });
556
+ else {
557
+ console.log("\n CDN warmup skipped: no build-discovered paths found.");
558
+ url = await runWranglerDeploy(root, wranglerOptions);
559
+ }
560
+ } else url = await runWranglerDeploy(root, wranglerOptions);
271
561
  console.log("\n ─────────────────────────────────────────");
272
562
  console.log(` Deployed to: ${url}`);
273
563
  console.log(" ─────────────────────────────────────────\n");
274
564
  }
275
565
  //#endregion
276
- export { buildNodeCliInvocation, buildWranglerDeployArgs, buildWranglerInvocation, deploy, parseDeployArgs, resolveWranglerBin, runWranglerDeploy, validateWranglerEnvName, withCloudflareEnv };
566
+ 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 };