@vinext/cloudflare 1.0.0-beta.8 → 1.0.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.
Files changed (59) hide show
  1. package/README.md +174 -37
  2. package/dist/cache/cdn-adapter-config.d.ts +2 -1
  3. package/dist/cache/cdn-adapter-config.js +13 -5
  4. package/dist/cache/cdn-adapter.d.ts +5 -56
  5. package/dist/cache/cdn-adapter.js +3 -61
  6. package/dist/cache/cdn-adapter.runtime.d.ts +1 -0
  7. package/dist/cache/cdn-adapter.runtime.js +17 -3
  8. package/dist/cache/cdn-adapter.worker.d.ts +1 -0
  9. package/dist/cache/cdn-adapter.worker.js +6 -4
  10. package/dist/cache/cdn-build-id.d.ts +1 -1
  11. package/dist/cache/cdn-build-id.js +1 -1
  12. package/dist/cache/config.d.ts +137 -0
  13. package/dist/cache/config.js +87 -0
  14. package/dist/cache/kv-data-adapter.d.ts +5 -1
  15. package/dist/cache/kv-data-adapter.js +6 -2
  16. package/dist/cache/kv-data-adapter.runtime.js +13 -9
  17. package/dist/cache/kv-key.d.ts +7 -4
  18. package/dist/cache/kv-key.js +16 -5
  19. package/dist/cache/response-store-adapter.d.ts +36 -0
  20. package/dist/cache/response-store-adapter.js +26 -1
  21. package/dist/cache/response-store-adapter.self-contained.worker.d.ts +1 -0
  22. package/dist/cache/response-store-adapter.self-contained.worker.js +3 -1
  23. package/dist/cache/response-store-adapter.service-binding.worker.d.ts +1 -0
  24. package/dist/cache/response-store-adapter.service-binding.worker.js +3 -1
  25. package/dist/cache/response-store-adapter.worker.d.ts +1 -1
  26. package/dist/cache/response-store-adapter.worker.js +148 -81
  27. package/dist/cache/response-store-cdn.runtime.js +1 -0
  28. package/dist/cache/response-store-data.runtime.js +53 -31
  29. package/dist/cache/static-assets-adapter.build.d.ts +6 -0
  30. package/dist/cache/static-assets-adapter.build.js +65 -0
  31. package/dist/cache/static-assets-adapter.d.ts +27 -0
  32. package/dist/cache/static-assets-adapter.js +26 -0
  33. package/dist/cache/static-assets-adapter.runtime.d.ts +26 -0
  34. package/dist/cache/static-assets-adapter.runtime.js +77 -0
  35. package/dist/cache/static-assets-adapter.shared.d.ts +12 -0
  36. package/dist/cache/static-assets-adapter.shared.js +4 -0
  37. package/dist/cache/workers-cache-cdn-adapter.d.ts +58 -0
  38. package/dist/cache/workers-cache-cdn-adapter.js +65 -0
  39. package/dist/cacheability-artifact.d.ts +7 -1
  40. package/dist/cacheability-artifact.js +60 -21
  41. package/dist/cacheability-probe.d.ts +2 -0
  42. package/dist/cacheability-probe.js +107 -13
  43. package/dist/cdn-warm.d.ts +2 -1
  44. package/dist/cdn-warm.js +25 -8
  45. package/dist/deploy-config.d.ts +3 -8
  46. package/dist/deploy-config.js +5 -23
  47. package/dist/deploy-help.js +58 -47
  48. package/dist/deploy.d.ts +31 -21
  49. package/dist/deploy.js +501 -286
  50. package/dist/tpr.d.ts +29 -72
  51. package/dist/tpr.js +52 -601
  52. package/dist/version-deploy.d.ts +27 -0
  53. package/dist/version-deploy.js +125 -5
  54. package/dist/wrangler-config.d.ts +19 -0
  55. package/dist/wrangler-config.js +294 -0
  56. package/dist/wrangler-version-metadata.js +1 -1
  57. package/package.json +16 -9
  58. package/dist/prerender-kv-populate.d.ts +0 -24
  59. package/dist/prerender-kv-populate.js +0 -153
package/dist/tpr.js CHANGED
@@ -1,325 +1,24 @@
1
- import { createKvKeySpace } from "./cache/kv-key.js";
2
- import { createRequire } from "node:module";
1
+ import { parseWranglerConfig } from "./wrangler-config.js";
3
2
  import fs from "node:fs";
4
3
  import path from "node:path";
5
- import { VINEXT_REVALIDATE_HEADER } from "vinext/internal/server/headers";
6
- import { spawn } from "node:child_process";
7
- import { pathToFileURL } from "node:url";
8
- import { isrCacheKey } from "vinext/internal/server/isr-cache";
9
- import { buildAppPageCacheTags } from "vinext/internal/server/app-page-cache";
10
4
  //#region src/tpr.ts
11
5
  /**
12
- * TPR: Traffic-aware Pre-Rendering
6
+ * TPR: traffic-aware pre-warm route selection
13
7
  *
14
- * Uses Cloudflare zone analytics to determine which pages actually get
15
- * traffic, and pre-renders only those during deploy. The pre-rendered
16
- * HTML is uploaded to KV in the same format ISR uses at runtime — no
17
- * runtime changes needed.
8
+ * Uses Cloudflare zone analytics to determine which routes should be fed into
9
+ * the standard CDN pre-warming flow.
18
10
  *
19
11
  * Flow:
20
- * 1. Parse wrangler config to find custom domain and KV namespace
12
+ * 1. Find the custom domain in the generated Worker or Wrangler config
21
13
  * 2. Resolve the Cloudflare zone for the custom domain
22
14
  * 3. Query zone analytics (GraphQL) for top pages by request count
23
- * 4. Walk ranked list until coverage threshold is met
24
- * 5. Start the built production server locally
25
- * 6. Fetch each hot route to produce HTML
26
- * 7. Upload pre-rendered HTML to KV (same KVCacheEntry format ISR reads)
15
+ * 4. Return the ranked candidates for standard route resolution and selection
27
16
  *
28
- * TPR is an experimental feature enabled via --experimental-tpr. It
29
- * gracefully skips when no custom domain, no API token, no traffic data,
30
- * or no KV namespace is configured.
17
+ * TPR is enabled via
18
+ * --traffic-aware-warm-cache. It gracefully skips when no custom
19
+ * domain, API token, or traffic data exists.
31
20
  */
32
21
  /**
33
- * Parse wrangler config (JSONC or TOML) to extract the fields TPR needs:
34
- * account_id, VINEXT_KV_CACHE KV namespace ID, and custom domain.
35
- */
36
- function parseWranglerConfig(root, configPath) {
37
- if (configPath) {
38
- const filepath = path.resolve(root, configPath);
39
- if (!fs.existsSync(filepath)) return null;
40
- const content = fs.readFileSync(filepath, "utf-8");
41
- if (filepath.endsWith(".toml")) return extractFromTOML(content);
42
- try {
43
- return extractFromJSON(JSON.parse(stripJsonCommentsAndTrailingCommas(content)));
44
- } catch {
45
- return null;
46
- }
47
- }
48
- for (const filename of ["wrangler.jsonc", "wrangler.json"]) {
49
- const filepath = path.join(root, filename);
50
- if (fs.existsSync(filepath)) {
51
- const content = fs.readFileSync(filepath, "utf-8");
52
- try {
53
- return extractFromJSON(JSON.parse(stripJsonCommentsAndTrailingCommas(content)));
54
- } catch {
55
- continue;
56
- }
57
- }
58
- }
59
- const tomlPath = path.join(root, "wrangler.toml");
60
- if (fs.existsSync(tomlPath)) return extractFromTOML(fs.readFileSync(tomlPath, "utf-8"));
61
- return null;
62
- }
63
- /**
64
- * Strip single-line (//), multi-line comments, and trailing commas from JSONC
65
- * while preserving strings that contain comment-like text or commas.
66
- */
67
- function stripJsonCommentsAndTrailingCommas(str) {
68
- let result = "";
69
- let inString = false;
70
- let inSingleLine = false;
71
- let inMultiLine = false;
72
- let escapeNext = false;
73
- for (let i = 0; i < str.length; i++) {
74
- const ch = str[i];
75
- const next = str[i + 1];
76
- if (escapeNext) {
77
- if (!inSingleLine && !inMultiLine) result += ch;
78
- escapeNext = false;
79
- continue;
80
- }
81
- if (ch === "\\" && inString) {
82
- result += ch;
83
- escapeNext = true;
84
- continue;
85
- }
86
- if (inSingleLine) {
87
- if (ch === "\n") {
88
- inSingleLine = false;
89
- result += ch;
90
- }
91
- continue;
92
- }
93
- if (inMultiLine) {
94
- if (ch === "*" && next === "/") {
95
- inMultiLine = false;
96
- i++;
97
- }
98
- continue;
99
- }
100
- if (ch === "\"" && !inString) {
101
- inString = true;
102
- result += ch;
103
- continue;
104
- }
105
- if (ch === "\"" && inString) {
106
- inString = false;
107
- result += ch;
108
- continue;
109
- }
110
- if (!inString && ch === "/" && next === "/") {
111
- inSingleLine = true;
112
- i++;
113
- continue;
114
- }
115
- if (!inString && ch === "/" && next === "*") {
116
- inMultiLine = true;
117
- i++;
118
- continue;
119
- }
120
- if (!inString && ch === "," && isJsonTrailingComma(str, i + 1)) continue;
121
- result += ch;
122
- }
123
- return result;
124
- }
125
- function isJsonTrailingComma(str, start) {
126
- for (let i = start; i < str.length; i++) {
127
- const ch = str[i];
128
- const next = str[i + 1];
129
- if (ch === void 0) return false;
130
- if (/\s/.test(ch)) continue;
131
- if (ch === "/" && next === "/") {
132
- i += 2;
133
- while (i < str.length && str[i] !== "\n") i++;
134
- continue;
135
- }
136
- if (ch === "/" && next === "*") {
137
- i += 2;
138
- while (i < str.length) {
139
- if (str[i] === "*" && str[i + 1] === "/") {
140
- i++;
141
- break;
142
- }
143
- i++;
144
- }
145
- continue;
146
- }
147
- return ch === "}" || ch === "]";
148
- }
149
- return false;
150
- }
151
- function extractFromJSON(config) {
152
- const result = {};
153
- if (typeof config.name === "string" && config.name.length > 0) result.name = config.name;
154
- if (typeof config.legacy_env === "boolean") result.legacyEnv = config.legacy_env;
155
- if (typeof config.account_id === "string") result.accountId = config.account_id;
156
- if (Array.isArray(config.kv_namespaces)) {
157
- const vinextKV = config.kv_namespaces.find((ns) => ns && typeof ns === "object" && (ns.binding === "VINEXT_KV_CACHE" || ns.binding === "VINEXT_CACHE"));
158
- if (vinextKV && typeof vinextKV.id === "string" && vinextKV.id !== "<your-kv-namespace-id>") result.kvNamespaceId = vinextKV.id;
159
- }
160
- const domain = extractDomainFromRoutes(config.routes) ?? extractDomainFromCustomDomains(config);
161
- if (domain) result.customDomain = domain;
162
- const env = extractEnvConfigs(config.env);
163
- if (env) result.env = env;
164
- return result;
165
- }
166
- function extractEnvConfigs(envs) {
167
- if (!envs || typeof envs !== "object" || Array.isArray(envs)) return void 0;
168
- const result = {};
169
- for (const [envName, rawConfig] of Object.entries(envs)) {
170
- if (!rawConfig || typeof rawConfig !== "object" || Array.isArray(rawConfig)) continue;
171
- const envConfig = extractEnvironmentConfig(rawConfig);
172
- if (envConfig.name || envConfig.customDomain) result[envName] = envConfig;
173
- }
174
- return Object.keys(result).length > 0 ? result : void 0;
175
- }
176
- function extractEnvironmentConfig(config) {
177
- const result = {};
178
- if (typeof config.name === "string" && config.name.length > 0) result.name = config.name;
179
- const domain = extractDomainFromRoutes(config.routes) ?? extractDomainFromCustomDomains(config);
180
- if (domain) result.customDomain = domain;
181
- return result;
182
- }
183
- function extractDomainFromRoutes(routes) {
184
- if (!Array.isArray(routes)) return null;
185
- for (const route of routes) if (typeof route === "string") {
186
- const domain = cleanDomain(route);
187
- if (domain && !domain.includes("workers.dev")) return domain;
188
- } else if (route && typeof route === "object") {
189
- const r = route;
190
- const pattern = typeof r.zone_name === "string" ? r.zone_name : typeof r.pattern === "string" ? r.pattern : null;
191
- if (pattern) {
192
- const domain = cleanDomain(pattern);
193
- if (domain && !domain.includes("workers.dev")) return domain;
194
- }
195
- }
196
- return null;
197
- }
198
- function extractDomainFromCustomDomains(config) {
199
- if (Array.isArray(config.custom_domains)) {
200
- for (const d of config.custom_domains) if (typeof d === "string" && !d.includes("workers.dev")) return cleanDomain(d);
201
- }
202
- return null;
203
- }
204
- /** Strip protocol and trailing wildcards from a route pattern to get a bare domain. */
205
- function cleanDomain(raw) {
206
- return raw.replace(/^https?:\/\//, "").replace(/\/\*$/, "").replace(/\/+$/, "").split("/")[0] || null;
207
- }
208
- /**
209
- * Simple extraction of specific fields from wrangler.toml content.
210
- * Not a full TOML parser — just enough for the fields we need.
211
- */
212
- function extractFromTOML(content) {
213
- const result = {};
214
- const nameMatch = content.match(/^name\s*=\s*"([^"]+)"/m);
215
- if (nameMatch) result.name = nameMatch[1];
216
- const legacyEnvMatch = content.match(/^legacy_env\s*=\s*(true|false)\s*$/m);
217
- if (legacyEnvMatch) result.legacyEnv = legacyEnvMatch[1] === "true";
218
- const accountMatch = content.match(/^account_id\s*=\s*"([^"]+)"/m);
219
- if (accountMatch) result.accountId = accountMatch[1];
220
- const kvBlocks = content.split(/\[\[kv_namespaces\]\]/);
221
- for (let i = 1; i < kvBlocks.length; i++) {
222
- const block = kvBlocks[i].split(/\[\[/)[0];
223
- const bindingMatch = block.match(/binding\s*=\s*"([^"]+)"/);
224
- const idMatch = block.match(/\bid\s*=\s*"([^"]+)"/);
225
- if ((bindingMatch?.[1] === "VINEXT_KV_CACHE" || bindingMatch?.[1] === "VINEXT_CACHE") && idMatch?.[1] && idMatch[1] !== "<your-kv-namespace-id>") result.kvNamespaceId = idMatch[1];
226
- }
227
- const routeMatch = content.match(/^route\s*=\s*"([^"]+)"/m);
228
- if (routeMatch) {
229
- const domain = cleanDomain(routeMatch[1]);
230
- if (domain && !domain.includes("workers.dev")) result.customDomain = domain;
231
- }
232
- if (!result.customDomain) {
233
- const routeBlocks = content.split(/\[\[routes\]\]/);
234
- for (let i = 1; i < routeBlocks.length; i++) {
235
- const patternMatch = routeBlocks[i].split(/\[\[/)[0].match(/pattern\s*=\s*"([^"]+)"/);
236
- if (patternMatch) {
237
- const domain = cleanDomain(patternMatch[1]);
238
- if (domain && !domain.includes("workers.dev")) {
239
- result.customDomain = domain;
240
- break;
241
- }
242
- }
243
- }
244
- }
245
- const env = extractEnvConfigsFromTOML(content);
246
- if (env) result.env = env;
247
- return result;
248
- }
249
- function extractEnvConfigsFromTOML(content) {
250
- const result = {};
251
- for (const section of getTomlSections(content)) {
252
- const envName = section.header.match(/^env\.([^.]+)$/)?.[1];
253
- if (envName) {
254
- const envConfig = result[envName] ?? {};
255
- const nameMatch = section.body.match(/^name\s*=\s*"([^"]+)"/m);
256
- if (nameMatch) envConfig.name = nameMatch[1];
257
- const domain = extractTomlScalarRouteDomain(section.body) ?? extractTomlRoutesArrayDomain(section.body);
258
- if (domain) envConfig.customDomain = domain;
259
- if (envConfig.name || envConfig.customDomain) result[envName] = envConfig;
260
- continue;
261
- }
262
- const routesEnvName = section.header.match(/^env\.([^.]+)\.routes$/)?.[1];
263
- if (routesEnvName) {
264
- const envConfig = result[routesEnvName] ?? {};
265
- const domain = extractTomlRouteBlockDomain(section.body);
266
- if (domain) envConfig.customDomain = domain;
267
- if (envConfig.name || envConfig.customDomain) result[routesEnvName] = envConfig;
268
- }
269
- }
270
- return Object.keys(result).length > 0 ? result : void 0;
271
- }
272
- function getTomlSections(content) {
273
- const sections = [];
274
- let currentHeader = null;
275
- let currentBody = [];
276
- for (const line of content.split("\n")) {
277
- const header = parseTomlSectionHeader(line);
278
- if (header) {
279
- if (currentHeader) sections.push({
280
- header: currentHeader,
281
- body: currentBody.join("\n")
282
- });
283
- currentHeader = header;
284
- currentBody = [];
285
- } else if (currentHeader) currentBody.push(line);
286
- }
287
- if (currentHeader) sections.push({
288
- header: currentHeader,
289
- body: currentBody.join("\n")
290
- });
291
- return sections;
292
- }
293
- function parseTomlSectionHeader(line) {
294
- const trimmed = line.trim();
295
- if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return null;
296
- const isArrayHeader = trimmed.startsWith("[[") && trimmed.endsWith("]]");
297
- const start = isArrayHeader ? 2 : 1;
298
- const end = isArrayHeader ? trimmed.length - 2 : trimmed.length - 1;
299
- const header = trimmed.slice(start, end).trim();
300
- return header.length > 0 ? header : null;
301
- }
302
- function extractTomlScalarRouteDomain(section) {
303
- const routeMatch = section.match(/^route\s*=\s*"([^"]+)"/m);
304
- if (!routeMatch) return null;
305
- const domain = cleanDomain(routeMatch[1]);
306
- return domain && !domain.includes("workers.dev") ? domain : null;
307
- }
308
- function extractTomlRoutesArrayDomain(section) {
309
- const routesMatch = section.match(/^routes\s*=\s*\[([\s\S]*?)\]/m);
310
- if (!routesMatch) return null;
311
- const patternMatch = (routesMatch[1] ?? "").match(/(?:pattern\s*=\s*)?"([^"]+)"/);
312
- if (!patternMatch) return null;
313
- const domain = cleanDomain(patternMatch[1]);
314
- return domain && !domain.includes("workers.dev") ? domain : null;
315
- }
316
- function extractTomlRouteBlockDomain(section) {
317
- const patternMatch = section.match(/^(?:pattern|zone_name)\s*=\s*"([^"]+)"/m);
318
- if (!patternMatch) return null;
319
- const domain = cleanDomain(patternMatch[1]);
320
- return domain && !domain.includes("workers.dev") ? domain : null;
321
- }
322
- /**
323
22
  * Generate zone lookup candidates from shortest (2-part) to longest.
324
23
  * Tries the most common case first (e.g., "example.com") and progressively
325
24
  * adds labels for multi-part TLDs (e.g., "co.uk" → "example.co.uk").
@@ -347,48 +46,44 @@ async function resolveZoneId(domain, apiToken) {
347
46
  }
348
47
  return null;
349
48
  }
350
- /** Resolve the account ID associated with the API token. */
351
- async function resolveAccountId(apiToken) {
352
- const response = await fetch("https://api.cloudflare.com/client/v4/accounts?per_page=1", { headers: {
353
- Authorization: `Bearer ${apiToken}`,
354
- "Content-Type": "application/json"
355
- } });
356
- if (!response.ok) return null;
357
- const data = await response.json();
358
- if (!data.success || !data.result?.length) return null;
359
- return data.result[0].id;
360
- }
361
49
  /**
362
50
  * Query Cloudflare zone analytics for top page paths by request count
363
51
  * over the given time window.
364
52
  */
365
53
  async function queryTraffic(zoneTag, apiToken, windowHours) {
366
54
  const now = /* @__PURE__ */ new Date();
367
- const query = `{
55
+ const start = /* @__PURE__ */ new Date(now.getTime() - windowHours * 60 * 60 * 1e3);
56
+ const response = await fetch("https://api.cloudflare.com/client/v4/graphql", {
57
+ method: "POST",
58
+ headers: {
59
+ Authorization: `Bearer ${apiToken}`,
60
+ "Content-Type": "application/json"
61
+ },
62
+ body: JSON.stringify({
63
+ query: `query($zoneTag: string!, $start: Time!, $end: Time!) {
368
64
  viewer {
369
- zones(filter: { zoneTag: "${zoneTag}" }) {
65
+ zones(filter: { zoneTag: $zoneTag }) {
370
66
  httpRequestsAdaptiveGroups(
371
67
  limit: 10000
372
- orderBy: [sum_requests_DESC]
68
+ orderBy: [count_DESC]
373
69
  filter: {
374
- datetime_geq: "${(/* @__PURE__ */ new Date(now.getTime() - windowHours * 60 * 60 * 1e3)).toISOString()}"
375
- datetime_lt: "${now.toISOString()}"
70
+ datetime_geq: $start
71
+ datetime_lt: $end
376
72
  requestSource: "eyeball"
377
73
  }
378
74
  ) {
379
- sum { requests }
75
+ count
380
76
  dimensions { clientRequestPath }
381
77
  }
382
78
  }
383
79
  }
384
- }`;
385
- const response = await fetch("https://api.cloudflare.com/client/v4/graphql", {
386
- method: "POST",
387
- headers: {
388
- Authorization: `Bearer ${apiToken}`,
389
- "Content-Type": "application/json"
390
- },
391
- body: JSON.stringify({ query })
80
+ }`,
81
+ variables: {
82
+ zoneTag,
83
+ start: start.toISOString(),
84
+ end: now.toISOString()
85
+ }
86
+ })
392
87
  });
393
88
  if (!response.ok) throw new Error(`Zone analytics query failed: ${response.status} ${response.statusText}`);
394
89
  const data = await response.json();
@@ -397,7 +92,7 @@ async function queryTraffic(zoneTag, apiToken, windowHours) {
397
92
  if (!groups || groups.length === 0) return [];
398
93
  return filterTrafficPaths(groups.map((g) => ({
399
94
  path: g.dimensions.clientRequestPath,
400
- requests: g.sum.requests
95
+ requests: g.count
401
96
  })));
402
97
  }
403
98
  /** Filter out non-page requests (static assets, API routes, internal routes). */
@@ -438,284 +133,40 @@ function selectRoutes(traffic, coverageTarget, limit) {
438
133
  coveragePercent: accumulated / totalRequests * 100
439
134
  };
440
135
  }
441
- /** Pre-render port — high number to avoid collisions with dev servers. */
442
- const PRERENDER_PORT = 19384;
443
- /** Max time to wait for the local server to start (ms). */
444
- const SERVER_STARTUP_TIMEOUT = 3e4;
445
- /** Max concurrent fetch requests during pre-rendering. */
446
- const FETCH_CONCURRENCY = 10;
447
- const NON_CACHEABLE_CACHE_CONTROL_RE = /\b(?:no-store|no-cache|private)\b/i;
448
- function getTprHeader(headers, name) {
449
- const normalizedName = name.toLowerCase();
450
- for (const [key, value] of Object.entries(headers)) if (key.toLowerCase() === normalizedName) return value;
451
- }
452
- function hasNonCacheableCacheControl(headers) {
453
- const cacheControl = getTprHeader(headers, "cache-control");
454
- return cacheControl ? NON_CACHEABLE_CACHE_CONTROL_RE.test(cacheControl) : false;
455
- }
456
- function readTprRevalidateHeader(headers) {
457
- const revalidateHeader = getTprHeader(headers, VINEXT_REVALIDATE_HEADER);
458
- if (revalidateHeader === void 0) return void 0;
459
- return Number(revalidateHeader);
460
- }
461
- function isTprCacheable(headers) {
462
- if (hasNonCacheableCacheControl(headers)) return false;
463
- const revalidate = readTprRevalidateHeader(headers);
464
- return revalidate === void 0 || Number.isFinite(revalidate) && revalidate > 0;
465
- }
466
- /**
467
- * Start a local production server, fetch each route to produce HTML,
468
- * and return the results. Pages that fail to render are skipped.
469
- */
470
- async function prerenderRoutes(routes, root, hostDomain) {
471
- const results = /* @__PURE__ */ new Map();
472
- let failedCount = 0;
473
- const port = PRERENDER_PORT;
474
- const distDir = path.join(root, "dist");
475
- if (!fs.existsSync(distDir)) {
476
- console.log(" TPR: Skipping pre-render — dist/ directory not found");
477
- return results;
478
- }
479
- const serverProcess = startLocalServer(root, port);
480
- try {
481
- await waitForServer(port, SERVER_STARTUP_TIMEOUT);
482
- for (let i = 0; i < routes.length; i += FETCH_CONCURRENCY) {
483
- const promises = routes.slice(i, i + FETCH_CONCURRENCY).map(async (routePath) => {
484
- try {
485
- const response = await fetch(`http://127.0.0.1:${port}${routePath}`, {
486
- headers: {
487
- "User-Agent": "vinext-tpr/1.0",
488
- ...hostDomain ? { Host: hostDomain } : {}
489
- },
490
- redirect: "manual"
491
- });
492
- if (response.status < 400) {
493
- const headers = {};
494
- response.headers.forEach((value, key) => {
495
- if (key === "content-type" || key === "cache-control" || key === VINEXT_REVALIDATE_HEADER || key === "location") headers[key] = value;
496
- });
497
- if (!isTprCacheable(headers)) return;
498
- const html = await response.text();
499
- results.set(routePath, {
500
- html,
501
- status: response.status,
502
- headers
503
- });
504
- }
505
- } catch {
506
- failedCount++;
507
- }
508
- });
509
- await Promise.all(promises);
510
- }
511
- if (failedCount > 0) console.log(` TPR: ${failedCount} page(s) failed to pre-render (skipped)`);
512
- } finally {
513
- serverProcess.kill("SIGTERM");
514
- await new Promise((resolve) => {
515
- serverProcess.on("exit", resolve);
516
- setTimeout(resolve, 2e3);
517
- });
518
- }
519
- return results;
520
- }
521
- /**
522
- * Spawn a subprocess running the vinext production server.
523
- * Uses the same Node.js binary and resolves vinext from the project root so
524
- * linked/package-manager-managed installs use the app's vinext peer.
525
- */
526
- function startLocalServer(root, port) {
527
- const prodServerPath = resolveVinextProdServerPath(root);
528
- const outDir = path.join(root, "dist");
529
- const script = [
530
- `import(${JSON.stringify(pathToFileURL(prodServerPath).href)})`,
531
- `.then(m => m.startProdServer({ port: ${port}, host: "127.0.0.1", outDir: ${JSON.stringify(outDir)} }))`,
532
- `.catch(e => { console.error("[vinext-tpr] Server failed to start:", e); process.exit(1); });`
533
- ].join("");
534
- const proc = spawn(process.execPath, [
535
- "--input-type=module",
536
- "-e",
537
- script
538
- ], {
539
- cwd: root,
540
- stdio: "pipe",
541
- env: {
542
- ...process.env,
543
- NODE_ENV: "production"
544
- }
545
- });
546
- proc.stderr?.on("data", (chunk) => {
547
- const msg = chunk.toString().trim();
548
- if (msg) console.error(` [tpr-server] ${msg}`);
549
- });
550
- return proc;
551
- }
552
- function resolveVinextProdServerPath(root) {
553
- return createRequire(path.join(root, "package.json")).resolve("vinext/server/prod-server");
554
- }
555
- /** Poll the local server until it responds or the timeout is reached. */
556
- async function waitForServer(port, timeoutMs) {
557
- const start = Date.now();
558
- while (Date.now() - start < timeoutMs) try {
559
- const controller = new AbortController();
560
- const timer = setTimeout(() => controller.abort(), 2e3);
561
- const response = await fetch(`http://127.0.0.1:${port}/`, {
562
- redirect: "manual",
563
- signal: controller.signal
564
- });
565
- clearTimeout(timer);
566
- await response.text();
567
- return;
568
- } catch {
569
- await new Promise((r) => setTimeout(r, 300));
570
- }
571
- throw new Error(`Local production server failed to start within ${timeoutMs / 1e3}s`);
572
- }
573
- /** KV bulk API accepts up to 10,000 pairs per request */
574
- const KV_BATCH_SIZE = 1e4;
575
- /** Maximum KV expiration TTL: 30 days */
576
- const MAX_KV_TTL_SECONDS = 2592e3;
577
- /**
578
- * Build KV bulk API pairs from pre-rendered entries.
579
- *
580
- * Key format matches the runtime KVCacheHandler exactly:
581
- * createKvKeySpace().entryKey(isrCacheKey("app", pathname, buildId) + ":html")
582
- * → "cache:app:<buildId>:<pathname>:html"
583
- */
584
- function buildTprKVPairs(entries, buildId, defaultRevalidateSeconds) {
585
- const now = Date.now();
586
- const pairs = [];
587
- const keySpace = createKvKeySpace(void 0);
588
- for (const [routePath, result] of entries) {
589
- if (!isTprCacheable(result.headers)) continue;
590
- const revalidateSeconds = readTprRevalidateHeader(result.headers) ?? defaultRevalidateSeconds;
591
- if (!Number.isFinite(revalidateSeconds) || revalidateSeconds <= 0) continue;
592
- const revalidateAt = now + revalidateSeconds * 1e3;
593
- const kvTtl = MAX_KV_TTL_SECONDS;
594
- const tags = buildAppPageCacheTags(routePath, []);
595
- const entry = {
596
- value: {
597
- kind: "APP_PAGE",
598
- html: result.html,
599
- headers: result.headers,
600
- status: result.status
601
- },
602
- tags,
603
- lastModified: now,
604
- revalidateAt
605
- };
606
- const cacheKey = keySpace.entryKey(isrCacheKey("app", routePath, buildId) + ":html");
607
- pairs.push({
608
- key: cacheKey,
609
- value: JSON.stringify(entry),
610
- expiration_ttl: kvTtl
611
- });
612
- }
613
- return pairs;
614
- }
615
- /**
616
- * Upload pre-rendered pages to KV using the Cloudflare REST API.
617
- * Writes in the same KVCacheEntry format that KVCacheHandler reads
618
- * at runtime, so ISR serves these entries without any code changes.
619
- */
620
- async function uploadToKV(entries, namespaceId, accountId, apiToken, defaultRevalidateSeconds, buildId) {
621
- const pairs = buildTprKVPairs(entries, buildId, defaultRevalidateSeconds);
622
- for (let i = 0; i < pairs.length; i += KV_BATCH_SIZE) {
623
- const batch = pairs.slice(i, i + KV_BATCH_SIZE);
624
- const response = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/bulk`, {
625
- method: "PUT",
626
- headers: {
627
- Authorization: `Bearer ${apiToken}`,
628
- "Content-Type": "application/json"
629
- },
630
- body: JSON.stringify(batch)
631
- });
632
- if (!response.ok) {
633
- const text = await response.text();
634
- throw new Error(`KV bulk upload failed (batch ${Math.floor(i / KV_BATCH_SIZE) + 1}): ${response.status} — ${text}`);
635
- }
636
- }
637
- }
638
- /** Default revalidation TTL for pre-rendered pages (1 hour). */
639
- const DEFAULT_REVALIDATE_SECONDS = 3600;
640
136
  /**
641
- * Run the TPR pipeline: query traffic, select routes, pre-render, upload.
642
- *
643
- * Designed to be called between the build step and wrangler deploy in the
644
- * `vinext-cloudflare deploy` pipeline. Gracefully skips (never errors) when
645
- * the prerequisites aren't met.
137
+ * Resolve ranked traffic paths. The standard CDN pre-warming flow owns route
138
+ * matching, coverage selection, rendering, and cache admission.
646
139
  */
647
- async function runTPR(options) {
648
- const startTime = Date.now();
649
- const { root, config, coverage, limit, window: windowHours } = options;
140
+ async function resolveTPRRoutes(options) {
141
+ const { root, config, window: windowHours } = options;
142
+ const wranglerConfig = options.hostname || options.typedConfig ? null : parseWranglerConfig(root, config);
143
+ const buildOutputConfigPath = path.join(root, ".cloudflare/output/v0/workers/default/worker.config.json");
144
+ const typedDomains = options.typedConfig && !options.hostname && fs.existsSync(buildOutputConfigPath) ? JSON.parse(fs.readFileSync(buildOutputConfigPath, "utf8")).domains : void 0;
145
+ const hostname = options.hostname ?? (Array.isArray(typedDomains) ? typedDomains.find((domain) => typeof domain === "string" && domain.length > 0) : void 0) ?? wranglerConfig?.env?.[options.env ?? ""]?.customDomain ?? wranglerConfig?.customDomain;
146
+ const targetUrl = options.typedConfig && hostname ? `https://${hostname}` : void 0;
650
147
  const skip = (reason) => ({
651
- totalPaths: 0,
652
- prerenderedCount: 0,
653
- coverageAchieved: 0,
654
- durationMs: Date.now() - startTime,
148
+ routes: [],
149
+ targetUrl,
655
150
  skipped: reason
656
151
  });
657
152
  const apiToken = process.env.CLOUDFLARE_API_TOKEN;
658
153
  if (!apiToken) return skip("no CLOUDFLARE_API_TOKEN set");
659
- const wranglerConfig = parseWranglerConfig(root, config);
660
- if (!wranglerConfig) return skip("could not parse wrangler config");
661
- if (!wranglerConfig.customDomain) return skip("no custom domain — zone analytics unavailable");
662
- if (!wranglerConfig.kvNamespaceId) return skip("no VINEXT_KV_CACHE KV namespace configured");
663
- const accountId = wranglerConfig.accountId ?? await resolveAccountId(apiToken);
664
- if (!accountId) return skip("could not resolve Cloudflare account ID");
665
- console.log(` TPR: Analyzing traffic for ${wranglerConfig.customDomain} (last ${windowHours}h)`);
666
- const zoneId = await resolveZoneId(wranglerConfig.customDomain, apiToken);
667
- if (!zoneId) return skip(`could not resolve zone for ${wranglerConfig.customDomain}`);
154
+ if (!wranglerConfig && !options.hostname && !options.typedConfig) return skip("could not parse wrangler config");
155
+ if (!hostname) return skip("no custom domain — zone analytics unavailable");
156
+ console.log(` TPR: Analyzing zone traffic for ${hostname} (last ${windowHours}h)`);
157
+ const zoneId = await resolveZoneId(hostname, apiToken);
158
+ if (!zoneId) return skip(`could not resolve zone for ${hostname}`);
668
159
  let traffic;
669
160
  try {
670
161
  traffic = await queryTraffic(zoneId, apiToken, windowHours);
671
- } catch (err) {
672
- return skip(`analytics query failed: ${err instanceof Error ? err.message : String(err)}`);
162
+ } catch (error) {
163
+ return skip(`analytics query failed: ${error instanceof Error ? error.message : String(error)}`);
673
164
  }
674
165
  if (traffic.length === 0) return skip("no traffic data available (first deploy?)");
675
- const selection = selectRoutes(traffic, coverage, limit);
676
- console.log(` TPR: ${traffic.length.toLocaleString()} unique paths — ${selection.routes.length} pages cover ${Math.round(selection.coveragePercent)}% of traffic`);
677
- if (selection.routes.length === 0) return {
678
- totalPaths: traffic.length,
679
- prerenderedCount: 0,
680
- coverageAchieved: 0,
681
- durationMs: Date.now() - startTime,
682
- skipped: "no pre-renderable routes after filtering"
683
- };
684
- console.log(` TPR: Pre-rendering ${selection.routes.length} pages...`);
685
- const routePaths = selection.routes.map((r) => r.path);
686
- let rendered;
687
- try {
688
- rendered = await prerenderRoutes(routePaths, root, wranglerConfig.customDomain);
689
- } catch (err) {
690
- return skip(`pre-rendering failed: ${err instanceof Error ? err.message : String(err)}`);
691
- }
692
- if (rendered.size === 0) return {
693
- totalPaths: traffic.length,
694
- prerenderedCount: 0,
695
- coverageAchieved: selection.coveragePercent,
696
- durationMs: Date.now() - startTime,
697
- skipped: "all pages failed to pre-render (request-dependent?)"
698
- };
699
- let buildId;
700
- try {
701
- buildId = fs.readFileSync(path.join(root, "dist", "server", "BUILD_ID"), "utf-8").trim();
702
- } catch {
703
- console.warn(" TPR: Could not read BUILD_ID from dist/server/ — KV keys will not match runtime. Skipping KV upload.");
704
- return skip("BUILD_ID not found in dist/server/ — build output may be incomplete");
705
- }
706
- try {
707
- await uploadToKV(rendered, wranglerConfig.kvNamespaceId, accountId, apiToken, DEFAULT_REVALIDATE_SECONDS, buildId);
708
- } catch (err) {
709
- return skip(`KV upload failed: ${err instanceof Error ? err.message : String(err)}`);
710
- }
711
- const durationMs = Date.now() - startTime;
712
- console.log(` TPR: Pre-rendered ${rendered.size} pages in ${(durationMs / 1e3).toFixed(1)}s → KV cache`);
713
166
  return {
714
- totalPaths: traffic.length,
715
- prerenderedCount: rendered.size,
716
- coverageAchieved: selection.coveragePercent,
717
- durationMs
167
+ routes: traffic,
168
+ targetUrl
718
169
  };
719
170
  }
720
171
  //#endregion
721
- export { buildTprKVPairs, domainCandidates, parseWranglerConfig, resolveVinextProdServerPath, runTPR };
172
+ export { domainCandidates, filterTrafficPaths, parseWranglerConfig, resolveTPRRoutes, selectRoutes };