@vinext/cloudflare 1.0.0-beta.7 → 1.0.0-beta.9
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/README.md +64 -22
- package/dist/cache/cdn-adapter-config.d.ts +16 -8
- package/dist/cache/cdn-adapter-config.js +57 -17
- package/dist/cache/cdn-adapter.d.ts +25 -24
- package/dist/cache/cdn-adapter.js +24 -20
- package/dist/cache/cdn-adapter.runtime.d.ts +9 -3
- package/dist/cache/cdn-adapter.runtime.js +63 -40
- package/dist/cache/cdn-adapter.worker.d.ts +31 -0
- package/dist/cache/cdn-adapter.worker.js +373 -0
- package/dist/cache/cdn-build-id.d.ts +4 -3
- package/dist/cache/cdn-build-id.js +5 -1
- package/dist/cache/kv-data-adapter.d.ts +25 -5
- package/dist/cache/kv-data-adapter.js +5 -1
- package/dist/cache/kv-data-adapter.runtime.d.ts +38 -4
- package/dist/cache/kv-data-adapter.runtime.js +115 -41
- package/dist/cache/kv-key.d.ts +4 -5
- package/dist/cache/response-store-adapter.d.ts +70 -0
- package/dist/cache/response-store-adapter.js +45 -0
- package/dist/cache/response-store-adapter.self-contained.worker.d.ts +18 -0
- package/dist/cache/response-store-adapter.self-contained.worker.js +12 -0
- package/dist/cache/response-store-adapter.service-binding.worker.d.ts +16 -0
- package/dist/cache/response-store-adapter.service-binding.worker.js +11 -0
- package/dist/cache/response-store-adapter.worker.d.ts +14 -0
- package/dist/cache/response-store-adapter.worker.js +264 -0
- package/dist/cache/response-store-cdn.runtime.d.ts +5 -0
- package/dist/cache/response-store-cdn.runtime.js +41 -0
- package/dist/cache/response-store-data.runtime.d.ts +34 -0
- package/dist/cache/response-store-data.runtime.js +289 -0
- package/dist/cacheability-artifact.d.ts +2 -3
- package/dist/cacheability-artifact.js +27 -1
- package/dist/cacheability-manifest-limits.d.ts +3 -4
- package/dist/cacheability-manifest-limits.js +1 -1
- package/dist/cacheability-probe.d.ts +8 -9
- package/dist/cacheability-probe.js +255 -91
- package/dist/cdn-warm.d.ts +31 -25
- package/dist/cdn-warm.js +228 -54
- package/dist/deploy-config.d.ts +11 -12
- package/dist/deploy-config.js +8 -3
- package/dist/deploy-help.d.ts +3 -4
- package/dist/deploy-help.js +20 -14
- package/dist/deploy.d.ts +35 -28
- package/dist/deploy.js +204 -95
- package/dist/images/images-optimizer.d.ts +3 -4
- package/dist/prerender-kv-populate.d.ts +3 -4
- package/dist/prerender-kv-populate.js +3 -2
- package/dist/tpr.d.ts +26 -74
- package/dist/tpr.js +45 -603
- package/dist/utils/cache-control-metadata.d.ts +3 -4
- package/dist/version-deploy.d.ts +17 -17
- package/dist/version-deploy.js +10 -5
- package/dist/worker-deployment-url.d.ts +3 -4
- package/dist/workers-dev-url.d.ts +2 -3
- package/dist/wrangler-config.d.ts +19 -0
- package/dist/wrangler-config.js +294 -0
- package/dist/wrangler-version-metadata.d.ts +2 -3
- package/package.json +13 -11
package/dist/tpr.js
CHANGED
|
@@ -1,325 +1,22 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { createRequire } from "node:module";
|
|
3
|
-
import fs from "node:fs";
|
|
4
|
-
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";
|
|
1
|
+
import { parseWranglerConfig } from "./wrangler-config.js";
|
|
10
2
|
//#region src/tpr.ts
|
|
11
3
|
/**
|
|
12
|
-
* TPR:
|
|
4
|
+
* TPR: traffic-aware pre-warm route selection
|
|
13
5
|
*
|
|
14
|
-
* Uses Cloudflare zone analytics to determine which
|
|
15
|
-
*
|
|
16
|
-
* HTML is uploaded to KV in the same format ISR uses at runtime — no
|
|
17
|
-
* runtime changes needed.
|
|
6
|
+
* Uses Cloudflare zone analytics to determine which routes should be fed into
|
|
7
|
+
* the standard CDN pre-warming flow.
|
|
18
8
|
*
|
|
19
9
|
* Flow:
|
|
20
|
-
* 1. Parse wrangler config to find custom domain
|
|
10
|
+
* 1. Parse wrangler config to find the custom domain
|
|
21
11
|
* 2. Resolve the Cloudflare zone for the custom domain
|
|
22
12
|
* 3. Query zone analytics (GraphQL) for top pages by request count
|
|
23
|
-
* 4.
|
|
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)
|
|
13
|
+
* 4. Return the ranked candidates for standard route resolution and selection
|
|
27
14
|
*
|
|
28
|
-
* TPR is an experimental feature enabled via
|
|
29
|
-
* gracefully skips when no custom
|
|
30
|
-
*
|
|
15
|
+
* TPR is an experimental feature enabled via
|
|
16
|
+
* --experimental-traffic-aware-warm-cache. It gracefully skips when no custom
|
|
17
|
+
* domain, API token, or traffic data exists.
|
|
31
18
|
*/
|
|
32
19
|
/**
|
|
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
20
|
* Generate zone lookup candidates from shortest (2-part) to longest.
|
|
324
21
|
* Tries the most common case first (e.g., "example.com") and progressively
|
|
325
22
|
* adds labels for multi-part TLDs (e.g., "co.uk" → "example.co.uk").
|
|
@@ -347,48 +44,44 @@ async function resolveZoneId(domain, apiToken) {
|
|
|
347
44
|
}
|
|
348
45
|
return null;
|
|
349
46
|
}
|
|
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
47
|
/**
|
|
362
48
|
* Query Cloudflare zone analytics for top page paths by request count
|
|
363
49
|
* over the given time window.
|
|
364
50
|
*/
|
|
365
51
|
async function queryTraffic(zoneTag, apiToken, windowHours) {
|
|
366
52
|
const now = /* @__PURE__ */ new Date();
|
|
367
|
-
const
|
|
53
|
+
const start = /* @__PURE__ */ new Date(now.getTime() - windowHours * 60 * 60 * 1e3);
|
|
54
|
+
const response = await fetch("https://api.cloudflare.com/client/v4/graphql", {
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: {
|
|
57
|
+
Authorization: `Bearer ${apiToken}`,
|
|
58
|
+
"Content-Type": "application/json"
|
|
59
|
+
},
|
|
60
|
+
body: JSON.stringify({
|
|
61
|
+
query: `query($zoneTag: string!, $start: Time!, $end: Time!) {
|
|
368
62
|
viewer {
|
|
369
|
-
zones(filter: { zoneTag:
|
|
63
|
+
zones(filter: { zoneTag: $zoneTag }) {
|
|
370
64
|
httpRequestsAdaptiveGroups(
|
|
371
65
|
limit: 10000
|
|
372
|
-
orderBy: [
|
|
66
|
+
orderBy: [count_DESC]
|
|
373
67
|
filter: {
|
|
374
|
-
datetime_geq:
|
|
375
|
-
datetime_lt:
|
|
68
|
+
datetime_geq: $start
|
|
69
|
+
datetime_lt: $end
|
|
376
70
|
requestSource: "eyeball"
|
|
377
71
|
}
|
|
378
72
|
) {
|
|
379
|
-
|
|
73
|
+
count
|
|
380
74
|
dimensions { clientRequestPath }
|
|
381
75
|
}
|
|
382
76
|
}
|
|
383
77
|
}
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
}
|
|
391
|
-
body: JSON.stringify({ query })
|
|
78
|
+
}`,
|
|
79
|
+
variables: {
|
|
80
|
+
zoneTag,
|
|
81
|
+
start: start.toISOString(),
|
|
82
|
+
end: now.toISOString()
|
|
83
|
+
}
|
|
84
|
+
})
|
|
392
85
|
});
|
|
393
86
|
if (!response.ok) throw new Error(`Zone analytics query failed: ${response.status} ${response.statusText}`);
|
|
394
87
|
const data = await response.json();
|
|
@@ -397,7 +90,7 @@ async function queryTraffic(zoneTag, apiToken, windowHours) {
|
|
|
397
90
|
if (!groups || groups.length === 0) return [];
|
|
398
91
|
return filterTrafficPaths(groups.map((g) => ({
|
|
399
92
|
path: g.dimensions.clientRequestPath,
|
|
400
|
-
requests: g.
|
|
93
|
+
requests: g.count
|
|
401
94
|
})));
|
|
402
95
|
}
|
|
403
96
|
/** Filter out non-page requests (static assets, API routes, internal routes). */
|
|
@@ -438,284 +131,33 @@ function selectRoutes(traffic, coverageTarget, limit) {
|
|
|
438
131
|
coveragePercent: accumulated / totalRequests * 100
|
|
439
132
|
};
|
|
440
133
|
}
|
|
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
134
|
/**
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
* linked/package-manager-managed installs use the app's vinext peer.
|
|
135
|
+
* Resolve ranked traffic paths. The standard CDN pre-warming flow owns route
|
|
136
|
+
* matching, coverage selection, rendering, and cache admission.
|
|
525
137
|
*/
|
|
526
|
-
function
|
|
527
|
-
const
|
|
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 = 720 * 3600;
|
|
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
|
-
/**
|
|
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.
|
|
646
|
-
*/
|
|
647
|
-
async function runTPR(options) {
|
|
648
|
-
const startTime = Date.now();
|
|
649
|
-
const { root, config, coverage, limit, window: windowHours } = options;
|
|
138
|
+
async function resolveTPRRoutes(options) {
|
|
139
|
+
const { root, config, window: windowHours } = options;
|
|
650
140
|
const skip = (reason) => ({
|
|
651
|
-
|
|
652
|
-
prerenderedCount: 0,
|
|
653
|
-
coverageAchieved: 0,
|
|
654
|
-
durationMs: Date.now() - startTime,
|
|
141
|
+
routes: [],
|
|
655
142
|
skipped: reason
|
|
656
143
|
});
|
|
657
144
|
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
|
|
658
145
|
if (!apiToken) return skip("no CLOUDFLARE_API_TOKEN set");
|
|
659
146
|
const wranglerConfig = parseWranglerConfig(root, config);
|
|
660
147
|
if (!wranglerConfig) return skip("could not parse wrangler config");
|
|
661
|
-
|
|
662
|
-
if (!
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
const zoneId = await resolveZoneId(wranglerConfig.customDomain, apiToken);
|
|
667
|
-
if (!zoneId) return skip(`could not resolve zone for ${wranglerConfig.customDomain}`);
|
|
148
|
+
const hostname = options.hostname ?? wranglerConfig.env?.[options.env ?? ""]?.customDomain ?? wranglerConfig.customDomain;
|
|
149
|
+
if (!hostname) return skip("no custom domain — zone analytics unavailable");
|
|
150
|
+
console.log(` TPR: Analyzing zone traffic for ${hostname} (last ${windowHours}h)`);
|
|
151
|
+
const zoneId = await resolveZoneId(hostname, apiToken);
|
|
152
|
+
if (!zoneId) return skip(`could not resolve zone for ${hostname}`);
|
|
668
153
|
let traffic;
|
|
669
154
|
try {
|
|
670
155
|
traffic = await queryTraffic(zoneId, apiToken, windowHours);
|
|
671
|
-
} catch (
|
|
672
|
-
return skip(`analytics query failed: ${
|
|
156
|
+
} catch (error) {
|
|
157
|
+
return skip(`analytics query failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
673
158
|
}
|
|
674
159
|
if (traffic.length === 0) return skip("no traffic data available (first deploy?)");
|
|
675
|
-
|
|
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
|
-
return {
|
|
714
|
-
totalPaths: traffic.length,
|
|
715
|
-
prerenderedCount: rendered.size,
|
|
716
|
-
coverageAchieved: selection.coveragePercent,
|
|
717
|
-
durationMs
|
|
718
|
-
};
|
|
160
|
+
return { routes: traffic };
|
|
719
161
|
}
|
|
720
162
|
//#endregion
|
|
721
|
-
export {
|
|
163
|
+
export { domainCandidates, filterTrafficPaths, parseWranglerConfig, resolveTPRRoutes, selectRoutes };
|