@vinext/cloudflare 1.0.0-beta.1 → 1.0.0-beta.11
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 +193 -1
- package/dist/cache/cdn-adapter-config.d.ts +31 -0
- package/dist/cache/cdn-adapter-config.js +95 -0
- package/dist/cache/cdn-adapter.d.ts +48 -15
- package/dist/cache/cdn-adapter.js +50 -13
- package/dist/cache/cdn-adapter.runtime.d.ts +26 -4
- package/dist/cache/cdn-adapter.runtime.js +150 -37
- 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 +6 -0
- package/dist/cache/cdn-build-id.js +9 -0
- package/dist/cache/config.d.ts +137 -0
- package/dist/cache/config.js +87 -0
- package/dist/cache/kv-data-adapter.d.ts +32 -8
- package/dist/cache/kv-data-adapter.js +5 -1
- package/dist/cache/kv-data-adapter.runtime.d.ts +45 -13
- package/dist/cache/kv-data-adapter.runtime.js +132 -52
- package/dist/cache/kv-key.d.ts +15 -0
- package/dist/cache/kv-key.js +43 -0
- package/dist/cache/response-store-adapter.d.ts +73 -0
- package/dist/cache/response-store-adapter.js +64 -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 +278 -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 +300 -0
- package/dist/cacheability-artifact.d.ts +10 -0
- package/dist/cacheability-artifact.js +215 -0
- package/dist/cacheability-manifest-limits.d.ts +4 -0
- package/dist/cacheability-manifest-limits.js +7 -0
- package/dist/cacheability-probe.d.ts +49 -0
- package/dist/cacheability-probe.js +660 -0
- package/dist/cdn-warm.d.ts +110 -12
- package/dist/cdn-warm.js +880 -82
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +15 -1
- package/dist/deploy-config.d.ts +14 -11
- package/dist/deploy-config.js +24 -5
- package/dist/deploy-help.d.ts +3 -4
- package/dist/deploy-help.js +65 -22
- package/dist/deploy.d.ts +139 -42
- package/dist/deploy.js +946 -132
- package/dist/images/images-optimizer.d.ts +5 -5
- package/dist/images/images-optimizer.runtime.d.ts +1 -5
- package/dist/prerender-kv-populate.d.ts +3 -4
- package/dist/prerender-kv-populate.js +51 -18
- package/dist/tpr.d.ts +34 -70
- package/dist/tpr.js +50 -603
- package/dist/utils/cache-control-metadata.d.ts +3 -4
- package/dist/version-deploy.d.ts +46 -17
- package/dist/version-deploy.js +151 -14
- package/dist/version-headers.d.ts +2 -0
- package/dist/version-headers.js +2 -0
- package/dist/worker-deployment-url.d.ts +9 -0
- package/dist/worker-deployment-url.js +45 -0
- 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 +7 -0
- package/dist/wrangler-version-metadata.js +9 -0
- package/package.json +21 -11
package/dist/tpr.js
CHANGED
|
@@ -1,324 +1,23 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { createRequire } from "node:module";
|
|
3
|
-
import path from "node:path";
|
|
1
|
+
import { parseWranglerConfig } from "./wrangler-config.js";
|
|
4
2
|
import fs from "node:fs";
|
|
5
|
-
import
|
|
6
|
-
import { pathToFileURL } from "node:url";
|
|
7
|
-
import { VINEXT_REVALIDATE_HEADER } from "vinext/internal/server/headers";
|
|
8
|
-
import { isrCacheKey } from "vinext/internal/server/isr-cache";
|
|
9
|
-
import { buildAppPageCacheTags } from "vinext/internal/server/app-page-cache";
|
|
3
|
+
import path from "node:path";
|
|
10
4
|
//#region src/tpr.ts
|
|
11
5
|
/**
|
|
12
|
-
* TPR:
|
|
6
|
+
* TPR: traffic-aware pre-warm route selection
|
|
13
7
|
*
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
29
|
-
* gracefully skips when no custom
|
|
30
|
-
*
|
|
31
|
-
*/
|
|
32
|
-
/**
|
|
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.
|
|
17
|
+
* TPR is an experimental feature enabled via
|
|
18
|
+
* --experimental-traffic-aware-warm-cache. It gracefully skips when no custom
|
|
19
|
+
* domain, API token, or traffic data exists.
|
|
211
20
|
*/
|
|
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
21
|
/**
|
|
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
|
|
@@ -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
|
|
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:
|
|
65
|
+
zones(filter: { zoneTag: $zoneTag }) {
|
|
370
66
|
httpRequestsAdaptiveGroups(
|
|
371
67
|
limit: 10000
|
|
372
|
-
orderBy: [
|
|
68
|
+
orderBy: [count_DESC]
|
|
373
69
|
filter: {
|
|
374
|
-
datetime_geq:
|
|
375
|
-
datetime_lt:
|
|
70
|
+
datetime_geq: $start
|
|
71
|
+
datetime_lt: $end
|
|
376
72
|
requestSource: "eyeball"
|
|
377
73
|
}
|
|
378
74
|
) {
|
|
379
|
-
|
|
75
|
+
count
|
|
380
76
|
dimensions { clientRequestPath }
|
|
381
77
|
}
|
|
382
78
|
}
|
|
383
79
|
}
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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.
|
|
95
|
+
requests: g.count
|
|
401
96
|
})));
|
|
402
97
|
}
|
|
403
98
|
/** Filter out non-page requests (static assets, API routes, internal routes). */
|
|
@@ -438,283 +133,35 @@ 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 = 720 * 3600;
|
|
577
136
|
/**
|
|
578
|
-
*
|
|
579
|
-
*
|
|
580
|
-
* Key format matches the runtime KVCacheHandler exactly:
|
|
581
|
-
* ENTRY_PREFIX + isrCacheKey("app", pathname, buildId) + ":html"
|
|
582
|
-
* → "cache:app:<buildId>:<pathname>:html"
|
|
137
|
+
* Resolve ranked traffic paths. The standard CDN pre-warming flow owns route
|
|
138
|
+
* matching, coverage selection, rendering, and cache admission.
|
|
583
139
|
*/
|
|
584
|
-
function
|
|
585
|
-
const
|
|
586
|
-
const pairs = [];
|
|
587
|
-
for (const [routePath, result] of entries) {
|
|
588
|
-
if (!isTprCacheable(result.headers)) continue;
|
|
589
|
-
const revalidateSeconds = readTprRevalidateHeader(result.headers) ?? defaultRevalidateSeconds;
|
|
590
|
-
if (!Number.isFinite(revalidateSeconds) || revalidateSeconds <= 0) continue;
|
|
591
|
-
const revalidateAt = now + revalidateSeconds * 1e3;
|
|
592
|
-
const kvTtl = MAX_KV_TTL_SECONDS;
|
|
593
|
-
const tags = buildAppPageCacheTags(routePath, []);
|
|
594
|
-
const entry = {
|
|
595
|
-
value: {
|
|
596
|
-
kind: "APP_PAGE",
|
|
597
|
-
html: result.html,
|
|
598
|
-
headers: result.headers,
|
|
599
|
-
status: result.status
|
|
600
|
-
},
|
|
601
|
-
tags,
|
|
602
|
-
lastModified: now,
|
|
603
|
-
revalidateAt
|
|
604
|
-
};
|
|
605
|
-
const cacheKey = ENTRY_PREFIX + isrCacheKey("app", routePath, buildId) + ":html";
|
|
606
|
-
pairs.push({
|
|
607
|
-
key: cacheKey,
|
|
608
|
-
value: JSON.stringify(entry),
|
|
609
|
-
expiration_ttl: kvTtl
|
|
610
|
-
});
|
|
611
|
-
}
|
|
612
|
-
return pairs;
|
|
613
|
-
}
|
|
614
|
-
/**
|
|
615
|
-
* Upload pre-rendered pages to KV using the Cloudflare REST API.
|
|
616
|
-
* Writes in the same KVCacheEntry format that KVCacheHandler reads
|
|
617
|
-
* at runtime, so ISR serves these entries without any code changes.
|
|
618
|
-
*/
|
|
619
|
-
async function uploadToKV(entries, namespaceId, accountId, apiToken, defaultRevalidateSeconds, buildId) {
|
|
620
|
-
const pairs = buildTprKVPairs(entries, buildId, defaultRevalidateSeconds);
|
|
621
|
-
for (let i = 0; i < pairs.length; i += KV_BATCH_SIZE) {
|
|
622
|
-
const batch = pairs.slice(i, i + KV_BATCH_SIZE);
|
|
623
|
-
const response = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/bulk`, {
|
|
624
|
-
method: "PUT",
|
|
625
|
-
headers: {
|
|
626
|
-
Authorization: `Bearer ${apiToken}`,
|
|
627
|
-
"Content-Type": "application/json"
|
|
628
|
-
},
|
|
629
|
-
body: JSON.stringify(batch)
|
|
630
|
-
});
|
|
631
|
-
if (!response.ok) {
|
|
632
|
-
const text = await response.text();
|
|
633
|
-
throw new Error(`KV bulk upload failed (batch ${Math.floor(i / KV_BATCH_SIZE) + 1}): ${response.status} — ${text}`);
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
/** Default revalidation TTL for pre-rendered pages (1 hour). */
|
|
638
|
-
const DEFAULT_REVALIDATE_SECONDS = 3600;
|
|
639
|
-
/**
|
|
640
|
-
* Run the TPR pipeline: query traffic, select routes, pre-render, upload.
|
|
641
|
-
*
|
|
642
|
-
* Designed to be called between the build step and wrangler deploy in the
|
|
643
|
-
* `vinext-cloudflare deploy` pipeline. Gracefully skips (never errors) when
|
|
644
|
-
* the prerequisites aren't met.
|
|
645
|
-
*/
|
|
646
|
-
async function runTPR(options) {
|
|
647
|
-
const startTime = Date.now();
|
|
648
|
-
const { root, config, coverage, limit, window: windowHours } = options;
|
|
140
|
+
async function resolveTPRRoutes(options) {
|
|
141
|
+
const { root, config, window: windowHours } = options;
|
|
649
142
|
const skip = (reason) => ({
|
|
650
|
-
|
|
651
|
-
prerenderedCount: 0,
|
|
652
|
-
coverageAchieved: 0,
|
|
653
|
-
durationMs: Date.now() - startTime,
|
|
143
|
+
routes: [],
|
|
654
144
|
skipped: reason
|
|
655
145
|
});
|
|
656
146
|
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
|
|
657
147
|
if (!apiToken) return skip("no CLOUDFLARE_API_TOKEN set");
|
|
658
|
-
const wranglerConfig = parseWranglerConfig(root, config);
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
if (!wranglerConfig.
|
|
662
|
-
const
|
|
663
|
-
if (!
|
|
664
|
-
console.log(` TPR: Analyzing traffic for ${
|
|
665
|
-
const zoneId = await resolveZoneId(
|
|
666
|
-
if (!zoneId) return skip(`could not resolve zone for ${
|
|
148
|
+
const wranglerConfig = options.hostname || options.typedConfig ? null : parseWranglerConfig(root, config);
|
|
149
|
+
const buildOutputConfigPath = path.join(root, ".cloudflare/output/v0/workers/default/worker.config.json");
|
|
150
|
+
const typedDomains = options.typedConfig && !options.hostname && fs.existsSync(buildOutputConfigPath) ? JSON.parse(fs.readFileSync(buildOutputConfigPath, "utf8")).domains : void 0;
|
|
151
|
+
if (!wranglerConfig && !options.hostname && !options.typedConfig) return skip("could not parse wrangler config");
|
|
152
|
+
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;
|
|
153
|
+
if (!hostname) return skip("no custom domain — zone analytics unavailable");
|
|
154
|
+
console.log(` TPR: Analyzing zone traffic for ${hostname} (last ${windowHours}h)`);
|
|
155
|
+
const zoneId = await resolveZoneId(hostname, apiToken);
|
|
156
|
+
if (!zoneId) return skip(`could not resolve zone for ${hostname}`);
|
|
667
157
|
let traffic;
|
|
668
158
|
try {
|
|
669
159
|
traffic = await queryTraffic(zoneId, apiToken, windowHours);
|
|
670
|
-
} catch (
|
|
671
|
-
return skip(`analytics query failed: ${
|
|
160
|
+
} catch (error) {
|
|
161
|
+
return skip(`analytics query failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
672
162
|
}
|
|
673
163
|
if (traffic.length === 0) return skip("no traffic data available (first deploy?)");
|
|
674
|
-
|
|
675
|
-
console.log(` TPR: ${traffic.length.toLocaleString()} unique paths — ${selection.routes.length} pages cover ${Math.round(selection.coveragePercent)}% of traffic`);
|
|
676
|
-
if (selection.routes.length === 0) return {
|
|
677
|
-
totalPaths: traffic.length,
|
|
678
|
-
prerenderedCount: 0,
|
|
679
|
-
coverageAchieved: 0,
|
|
680
|
-
durationMs: Date.now() - startTime,
|
|
681
|
-
skipped: "no pre-renderable routes after filtering"
|
|
682
|
-
};
|
|
683
|
-
console.log(` TPR: Pre-rendering ${selection.routes.length} pages...`);
|
|
684
|
-
const routePaths = selection.routes.map((r) => r.path);
|
|
685
|
-
let rendered;
|
|
686
|
-
try {
|
|
687
|
-
rendered = await prerenderRoutes(routePaths, root, wranglerConfig.customDomain);
|
|
688
|
-
} catch (err) {
|
|
689
|
-
return skip(`pre-rendering failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
690
|
-
}
|
|
691
|
-
if (rendered.size === 0) return {
|
|
692
|
-
totalPaths: traffic.length,
|
|
693
|
-
prerenderedCount: 0,
|
|
694
|
-
coverageAchieved: selection.coveragePercent,
|
|
695
|
-
durationMs: Date.now() - startTime,
|
|
696
|
-
skipped: "all pages failed to pre-render (request-dependent?)"
|
|
697
|
-
};
|
|
698
|
-
let buildId;
|
|
699
|
-
try {
|
|
700
|
-
buildId = fs.readFileSync(path.join(root, "dist", "server", "BUILD_ID"), "utf-8").trim();
|
|
701
|
-
} catch {
|
|
702
|
-
console.warn(" TPR: Could not read BUILD_ID from dist/server/ — KV keys will not match runtime. Skipping KV upload.");
|
|
703
|
-
return skip("BUILD_ID not found in dist/server/ — build output may be incomplete");
|
|
704
|
-
}
|
|
705
|
-
try {
|
|
706
|
-
await uploadToKV(rendered, wranglerConfig.kvNamespaceId, accountId, apiToken, DEFAULT_REVALIDATE_SECONDS, buildId);
|
|
707
|
-
} catch (err) {
|
|
708
|
-
return skip(`KV upload failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
709
|
-
}
|
|
710
|
-
const durationMs = Date.now() - startTime;
|
|
711
|
-
console.log(` TPR: Pre-rendered ${rendered.size} pages in ${(durationMs / 1e3).toFixed(1)}s → KV cache`);
|
|
712
|
-
return {
|
|
713
|
-
totalPaths: traffic.length,
|
|
714
|
-
prerenderedCount: rendered.size,
|
|
715
|
-
coverageAchieved: selection.coveragePercent,
|
|
716
|
-
durationMs
|
|
717
|
-
};
|
|
164
|
+
return { routes: traffic };
|
|
718
165
|
}
|
|
719
166
|
//#endregion
|
|
720
|
-
export {
|
|
167
|
+
export { domainCandidates, filterTrafficPaths, parseWranglerConfig, resolveTPRRoutes, selectRoutes };
|