@vinext/cloudflare 1.0.0-beta.6 → 1.0.0-beta.7
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 +17 -0
- package/dist/cache/cdn-adapter-config.d.ts +22 -0
- package/dist/cache/cdn-adapter-config.js +47 -0
- package/dist/cache/cdn-adapter.d.ts +34 -3
- package/dist/cache/cdn-adapter.js +34 -2
- package/dist/cache/cdn-adapter.runtime.d.ts +17 -1
- package/dist/cache/cdn-adapter.runtime.js +80 -5
- package/dist/cache/cdn-build-id.d.ts +5 -0
- package/dist/cache/cdn-build-id.js +5 -0
- package/dist/cache/kv-data-adapter.runtime.js +1 -1
- package/dist/cacheability-artifact.d.ts +10 -0
- package/dist/cacheability-artifact.js +165 -0
- package/dist/cacheability-manifest-limits.d.ts +5 -0
- package/dist/cacheability-manifest-limits.js +7 -0
- package/dist/cacheability-probe.d.ts +50 -0
- package/dist/cacheability-probe.js +496 -0
- package/dist/cdn-warm.d.ts +95 -2
- package/dist/cdn-warm.js +704 -80
- package/dist/cli.js +15 -1
- package/dist/deploy-config.d.ts +5 -1
- package/dist/deploy-config.js +15 -2
- package/dist/deploy-help.js +43 -6
- package/dist/deploy.d.ts +61 -6
- package/dist/deploy.js +631 -82
- package/dist/prerender-kv-populate.js +1 -1
- package/dist/tpr.js +2 -2
- package/dist/version-deploy.d.ts +12 -9
- package/dist/version-deploy.js +24 -10
- package/dist/version-headers.d.ts +2 -0
- package/dist/version-headers.js +2 -0
- package/dist/worker-deployment-url.d.ts +7 -1
- package/dist/worker-deployment-url.js +23 -1
- package/dist/wrangler-version-metadata.d.ts +8 -0
- package/dist/wrangler-version-metadata.js +9 -0
- package/package.json +4 -3
package/dist/cdn-warm.js
CHANGED
|
@@ -1,9 +1,26 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { VINEXT_CDN_BUILD_ID_HEADER } from "./cache/cdn-build-id.js";
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
6
|
+
import { VINEXT_PRERENDER_READINESS_HEADER, VINEXT_PRERENDER_READINESS_PATH, VINEXT_PRERENDER_SECRET_HEADER } from "vinext/internal/server/headers";
|
|
3
7
|
import { PRERENDER_PATHS_MANIFEST } from "vinext/internal/build/prerender-paths";
|
|
4
8
|
import { getPrerenderedConcretePaths, readPrerenderManifest } from "vinext/internal/server/prerender-manifest";
|
|
9
|
+
import { VINEXT_RSC_BUILD_ID_HEADER, VINEXT_RSC_CONTENT_TYPE, VINEXT_RSC_VARY_HEADER, createCanonicalLoadingShellRscRequestHeaders, createCanonicalRscRequestHeaders, createCanonicalRscRequestUrl, createRscRequestUrl } from "vinext/internal/server/app-rsc-cache-busting";
|
|
10
|
+
import { isNonCacheableCacheControl } from "vinext/shims/cdn-cache";
|
|
11
|
+
import { normalizePathTrailingSlash } from "vinext/shims/url-utils";
|
|
5
12
|
//#region src/cdn-warm.ts
|
|
6
|
-
const
|
|
13
|
+
const DEFAULT_CDN_WARM_CONCURRENCY = 25;
|
|
14
|
+
const DEFAULT_CDN_WARM_TIMEOUT_MS = 1e4;
|
|
15
|
+
const DEFAULT_STAGED_READINESS_RETRIES = 60;
|
|
16
|
+
const DEFAULT_STAGED_READINESS_INTERVAL_MS = 1e3;
|
|
17
|
+
const DEFAULT_STAGED_READINESS_PHASE_TIMEOUT_MS = 12e4;
|
|
18
|
+
const DEFAULT_STAGED_READINESS_SUCCESSES = 6;
|
|
19
|
+
const STAGED_READINESS_QUERY_PARAM = "__vinext_cdn_warm_readiness";
|
|
20
|
+
function applyWarmPathConfig(pathname, config) {
|
|
21
|
+
const normalized = normalizePathTrailingSlash(config.basePath ? pathname === "/" ? config.basePath : `${config.basePath}${pathname}` : pathname, config.trailingSlash === true);
|
|
22
|
+
return new URL(normalized, "http://vinext.local").pathname;
|
|
23
|
+
}
|
|
7
24
|
function readBuiltBuildId(root) {
|
|
8
25
|
try {
|
|
9
26
|
const buildId = fs.readFileSync(path.join(root, "dist", "server", "BUILD_ID"), "utf-8").trim();
|
|
@@ -19,7 +36,7 @@ function readPrerenderPathManifest(manifestPath) {
|
|
|
19
36
|
const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
|
|
20
37
|
if (!parsed || typeof parsed !== "object") return null;
|
|
21
38
|
const manifest = parsed;
|
|
22
|
-
if (!Array.isArray(manifest.paths)) return null;
|
|
39
|
+
if (!Array.isArray(manifest.paths) || !manifest.paths.every((pathname) => typeof pathname === "string") || manifest.appPaths !== void 0 && (!Array.isArray(manifest.appPaths) || !manifest.appPaths.every((pathname) => typeof pathname === "string")) || manifest.pagesPaths !== void 0 && (!Array.isArray(manifest.pagesPaths) || !manifest.pagesPaths.every((pathname) => typeof pathname === "string")) || manifest.pagesDataPaths !== void 0 && (!Array.isArray(manifest.pagesDataPaths) || !manifest.pagesDataPaths.every((pathname) => typeof pathname === "string")) || manifest.excludedWarmPaths !== void 0 && (!Array.isArray(manifest.excludedWarmPaths) || !manifest.excludedWarmPaths.every((pathname) => typeof pathname === "string")) || manifest.fallbackRoutePatterns !== void 0 && (!Array.isArray(manifest.fallbackRoutePatterns) || !manifest.fallbackRoutePatterns.every((route) => route !== null && typeof route === "object" && !Array.isArray(route) && (route.kind === "app-page" || route.kind === "app-route" || route.kind === "pages-page") && typeof route.pattern === "string" && route.pattern.startsWith("/"))) || manifest.rscPaths !== void 0 && (!Array.isArray(manifest.rscPaths) || !manifest.rscPaths.every((pathname) => typeof pathname === "string")) || manifest.routeHandlerPaths !== void 0 && (!Array.isArray(manifest.routeHandlerPaths) || !manifest.routeHandlerPaths.every((pathname) => typeof pathname === "string")) || manifest.routePatterns !== void 0 && (!manifest.routePatterns || typeof manifest.routePatterns !== "object" || Array.isArray(manifest.routePatterns) || !Object.entries(manifest.routePatterns).every(([pathname, route]) => pathname.startsWith("/") && route !== null && typeof route === "object" && !Array.isArray(route) && (route.kind === "app-page" || route.kind === "app-route" || route.kind === "pages-page") && typeof route.pattern === "string" && route.pattern.startsWith("/") && (route.cacheabilityProbe === void 0 || route.cacheabilityProbe !== null && typeof route.cacheabilityProbe === "object" && !Array.isArray(route.cacheabilityProbe) && typeof route.cacheabilityProbe.canPrunePattern === "boolean" && (route.cacheabilityProbe.concretePathname === void 0 || typeof route.cacheabilityProbe.concretePathname === "string" && route.cacheabilityProbe.concretePathname.startsWith("/"))))) || manifest.loadingShellPaths !== void 0 && (!Array.isArray(manifest.loadingShellPaths) || !manifest.loadingShellPaths.every((pathname) => typeof pathname === "string")) || manifest.basePath !== void 0 && typeof manifest.basePath !== "string" || manifest.buildIdentity !== void 0 && typeof manifest.buildIdentity !== "string" || manifest.deploymentId !== void 0 && typeof manifest.deploymentId !== "string" || manifest.rscBuildId !== void 0 && typeof manifest.rscBuildId !== "string" || manifest.trailingSlash !== void 0 && typeof manifest.trailingSlash !== "boolean" || manifest.responseVary !== void 0 && manifest.responseVary !== "verbatim") return null;
|
|
23
40
|
return manifest;
|
|
24
41
|
} catch (error) {
|
|
25
42
|
console.warn(`[vinext] Failed to read prerender path manifest at ${manifestPath}:`, error);
|
|
@@ -34,37 +51,58 @@ function readPrerenderPathWarmPaths(root, options) {
|
|
|
34
51
|
const message = "[vinext] CDN warmup skipped: prerender path manifest buildId does not match dist/server/BUILD_ID.";
|
|
35
52
|
if (options?.strict) throw new Error(message);
|
|
36
53
|
console.warn(message);
|
|
37
|
-
return
|
|
54
|
+
return null;
|
|
38
55
|
}
|
|
39
|
-
return
|
|
56
|
+
return {
|
|
57
|
+
manifest,
|
|
58
|
+
pagesPaths: (manifest.pagesPaths ?? []).filter((pathname) => pathname.startsWith("/")).filter((pathname) => !manifest.excludedWarmPaths?.includes(pathname)).map((pathname) => applyWarmPathConfig(pathname, manifest)),
|
|
59
|
+
paths: manifest.paths.filter((pathname) => pathname.startsWith("/")).filter((pathname) => !manifest.excludedWarmPaths?.includes(pathname)).map((pathname) => applyWarmPathConfig(pathname, manifest))
|
|
60
|
+
};
|
|
40
61
|
}
|
|
41
|
-
function
|
|
42
|
-
const
|
|
43
|
-
if (!
|
|
44
|
-
const
|
|
45
|
-
if (pathManifestPaths !== null) return pathManifestPaths;
|
|
46
|
-
}
|
|
47
|
-
const manifest = readPrerenderManifest(path.join(root, "dist", "server", "vinext-prerender.json"));
|
|
48
|
-
if (!manifest) {
|
|
49
|
-
if (shouldPreferPrerenderManifest) {
|
|
50
|
-
const pathManifestPaths = readPrerenderPathWarmPaths(root, options);
|
|
51
|
-
if (pathManifestPaths !== null) {
|
|
52
|
-
console.warn("[vinext] CDN warmup fallback shells requested, but prerender manifest not found; warming build-discovered paths only.");
|
|
53
|
-
return pathManifestPaths;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
const message = "[vinext] CDN warmup skipped: prerender manifest not found.";
|
|
62
|
+
function readPrerenderWarmPlan(root, options) {
|
|
63
|
+
const pathPlan = readPrerenderPathWarmPaths(root, options);
|
|
64
|
+
if (!pathPlan) {
|
|
65
|
+
const message = "[vinext] CDN warmup skipped: prerender path manifest not found.";
|
|
57
66
|
if (options?.strict) throw new Error(message);
|
|
58
|
-
return
|
|
67
|
+
return {
|
|
68
|
+
loadingShellPaths: [],
|
|
69
|
+
paths: [],
|
|
70
|
+
rscPaths: []
|
|
71
|
+
};
|
|
59
72
|
}
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
73
|
+
const { manifest } = pathPlan;
|
|
74
|
+
const supportsCanonicalRsc = manifest.responseVary === "verbatim" && manifest.rscPaths !== void 0 && manifest.rscBuildId !== void 0;
|
|
75
|
+
const applyConfig = (pathname) => applyWarmPathConfig(pathname, manifest);
|
|
76
|
+
const routePatterns = manifest.routePatterns ? Object.fromEntries(Object.entries(manifest.routePatterns).map(([pathname, route]) => [pathname.includes("/_next/data/") ? pathname : applyConfig(pathname), route])) : void 0;
|
|
77
|
+
let htmlPaths = pathPlan.paths;
|
|
78
|
+
if (options?.includeFallbackShells === true) {
|
|
79
|
+
const prerenderManifest = readPrerenderManifest(path.join(root, "dist", "server", "vinext-prerender.json"));
|
|
80
|
+
if (!prerenderManifest) console.warn("[vinext] CDN warmup fallback shells requested, but prerender manifest not found; warming build-discovered paths only.");
|
|
81
|
+
else if (!prerenderManifest.buildId || prerenderManifest.buildId !== manifest.buildId) {
|
|
82
|
+
const message = "[vinext] CDN warmup skipped: prerender manifest buildId does not match dist/server/BUILD_ID.";
|
|
83
|
+
if (options.strict) throw new Error(message);
|
|
84
|
+
console.warn(message);
|
|
85
|
+
htmlPaths = [];
|
|
86
|
+
} else htmlPaths = getPrerenderedConcretePaths(prerenderManifest, { includeFallbackShells: true }).filter((pathname) => pathname.startsWith("/")).filter((pathname) => !manifest.excludedWarmPaths?.includes(pathname)).map(applyConfig);
|
|
66
87
|
}
|
|
67
|
-
return
|
|
88
|
+
return {
|
|
89
|
+
...manifest.appPaths ? { appPaths: manifest.appPaths.map(applyConfig) } : {},
|
|
90
|
+
buildId: manifest.buildId,
|
|
91
|
+
...manifest.buildIdentity ? { buildIdentity: manifest.buildIdentity } : {},
|
|
92
|
+
...manifest.deploymentId ? { deploymentId: manifest.deploymentId } : {},
|
|
93
|
+
...manifest.fallbackRoutePatterns ? { fallbackRoutePatterns: manifest.fallbackRoutePatterns } : {},
|
|
94
|
+
loadingShellPaths: supportsCanonicalRsc ? (manifest.loadingShellPaths ?? []).map(applyConfig) : [],
|
|
95
|
+
...manifest.pagesDataPaths ? { pagesDataPaths: manifest.pagesDataPaths } : {},
|
|
96
|
+
...manifest.pagesPaths ? { pagesPaths: manifest.pagesPaths.map(applyConfig) } : {},
|
|
97
|
+
paths: htmlPaths,
|
|
98
|
+
...supportsCanonicalRsc ? { rscBuildId: manifest.rscBuildId } : {},
|
|
99
|
+
rscPaths: supportsCanonicalRsc ? manifest.rscPaths.map(applyConfig) : [],
|
|
100
|
+
...manifest.routeHandlerPaths ? { routeHandlerPaths: manifest.routeHandlerPaths.map(applyConfig) } : {},
|
|
101
|
+
...routePatterns ? { routePatterns } : {}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function readPrerenderWarmPaths(root, options) {
|
|
105
|
+
return readPrerenderWarmPlan(root, options).paths;
|
|
68
106
|
}
|
|
69
107
|
function getWarmPathsFromPrerenderManifest(manifest, options) {
|
|
70
108
|
return getPrerenderedConcretePaths(manifest, options);
|
|
@@ -75,45 +113,518 @@ function normalizeWarmPath(pathname) {
|
|
|
75
113
|
function buildWarmupUrl(targetUrl, pathname) {
|
|
76
114
|
return new URL(normalizeWarmPath(pathname), targetUrl.endsWith("/") ? targetUrl : `${targetUrl}/`);
|
|
77
115
|
}
|
|
78
|
-
function isRetryableStatus(status) {
|
|
79
|
-
return status === 408 || status === 429 || status >= 500;
|
|
116
|
+
function isRetryableStatus(status, retryNotFound) {
|
|
117
|
+
return status === 408 || status === 429 || status >= 500 || retryNotFound && status === 404;
|
|
118
|
+
}
|
|
119
|
+
const WORKER_VERSION_OVERRIDE_HEADER = "Cloudflare-Workers-Version-Overrides";
|
|
120
|
+
async function fetchWithTimeout(fetchImpl, url, timeoutMs, headers, redirect) {
|
|
121
|
+
const controller = new AbortController();
|
|
122
|
+
let response;
|
|
123
|
+
let timer;
|
|
124
|
+
const requestHeaders = new Headers(headers);
|
|
125
|
+
requestHeaders.set("User-Agent", "vinext-cloudflare-cdn-warm");
|
|
126
|
+
try {
|
|
127
|
+
const timedOut = new Promise((_resolve, reject) => {
|
|
128
|
+
timer = setTimeout(() => {
|
|
129
|
+
controller.abort();
|
|
130
|
+
reject(new DOMException(`Timed out after ${timeoutMs}ms`, "AbortError"));
|
|
131
|
+
}, timeoutMs);
|
|
132
|
+
});
|
|
133
|
+
return await Promise.race([(async () => {
|
|
134
|
+
response = await fetchImpl(url, {
|
|
135
|
+
method: "GET",
|
|
136
|
+
redirect,
|
|
137
|
+
headers: requestHeaders,
|
|
138
|
+
signal: controller.signal
|
|
139
|
+
});
|
|
140
|
+
return {
|
|
141
|
+
body: await response.arrayBuffer(),
|
|
142
|
+
response
|
|
143
|
+
};
|
|
144
|
+
})(), timedOut]);
|
|
145
|
+
} finally {
|
|
146
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
147
|
+
if (controller.signal.aborted && response?.body) response.body.cancel().catch(() => {});
|
|
148
|
+
}
|
|
80
149
|
}
|
|
81
|
-
async function
|
|
150
|
+
async function fetchHeadersWithTimeout(fetchImpl, url, timeoutMs, headers, method = "GET") {
|
|
82
151
|
const controller = new AbortController();
|
|
83
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
84
152
|
const requestHeaders = new Headers(headers);
|
|
85
153
|
requestHeaders.set("User-Agent", "vinext-cloudflare-cdn-warm");
|
|
154
|
+
let timer;
|
|
86
155
|
try {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
156
|
+
const timedOut = new Promise((_resolve, reject) => {
|
|
157
|
+
timer = setTimeout(() => {
|
|
158
|
+
controller.abort();
|
|
159
|
+
reject(new DOMException(`Timed out after ${timeoutMs}ms`, "AbortError"));
|
|
160
|
+
}, timeoutMs);
|
|
161
|
+
});
|
|
162
|
+
return await Promise.race([fetchImpl(url, {
|
|
163
|
+
method,
|
|
164
|
+
redirect: "manual",
|
|
90
165
|
headers: requestHeaders,
|
|
91
166
|
signal: controller.signal
|
|
92
|
-
});
|
|
167
|
+
}), timedOut]);
|
|
93
168
|
} finally {
|
|
94
|
-
clearTimeout(timer);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
async function
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (
|
|
104
|
-
|
|
105
|
-
|
|
169
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async function createCdnWarmTargets(options) {
|
|
173
|
+
const requests = [];
|
|
174
|
+
const fullRscPaths = new Set(options.rscPaths ?? []);
|
|
175
|
+
const loadingShellPaths = new Set(options.loadingShellPaths ?? []);
|
|
176
|
+
const commonHeaders = new Headers(options.headers);
|
|
177
|
+
for (const pathname of /* @__PURE__ */ new Set([...fullRscPaths, ...loadingShellPaths])) {
|
|
178
|
+
if (fullRscPaths.has(pathname)) {
|
|
179
|
+
const rscHeaders = new Headers(commonHeaders);
|
|
180
|
+
for (const [name, value] of createCanonicalRscRequestHeaders(options.deploymentId)) rscHeaders.set(name, value);
|
|
181
|
+
requests.push({
|
|
182
|
+
headers: rscHeaders,
|
|
183
|
+
kind: "rsc-full",
|
|
184
|
+
label: `${pathname} (RSC full)`,
|
|
185
|
+
pathname: createCanonicalRscRequestUrl(pathname),
|
|
186
|
+
sourcePathname: pathname,
|
|
187
|
+
route: options.routePatterns?.[pathname]
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
if (loadingShellPaths.has(pathname)) {
|
|
191
|
+
const loadingHeaders = new Headers(commonHeaders);
|
|
192
|
+
for (const [name, value] of createCanonicalLoadingShellRscRequestHeaders(options.deploymentId)) loadingHeaders.set(name, value);
|
|
193
|
+
requests.push({
|
|
194
|
+
headers: loadingHeaders,
|
|
195
|
+
kind: "rsc-loading-shell",
|
|
196
|
+
label: `${pathname} (RSC loading shell)`,
|
|
197
|
+
pathname: await createRscRequestUrl(pathname, loadingHeaders),
|
|
198
|
+
sourcePathname: pathname,
|
|
199
|
+
route: options.routePatterns?.[pathname]
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
for (const pathname of new Set(options.paths)) {
|
|
204
|
+
const htmlHeaders = new Headers(commonHeaders);
|
|
205
|
+
htmlHeaders.set("Accept", "text/html");
|
|
206
|
+
requests.push({
|
|
207
|
+
headers: htmlHeaders,
|
|
208
|
+
kind: "html",
|
|
209
|
+
label: pathname,
|
|
210
|
+
pathname,
|
|
211
|
+
sourcePathname: pathname,
|
|
212
|
+
route: options.routePatterns?.[pathname]
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
for (const pathname of new Set(options.pagesDataPaths ?? [])) {
|
|
216
|
+
const dataHeaders = new Headers(commonHeaders);
|
|
217
|
+
dataHeaders.set("Accept", "application/json");
|
|
218
|
+
requests.push({
|
|
219
|
+
headers: dataHeaders,
|
|
220
|
+
kind: "pages-data",
|
|
221
|
+
label: `${pathname} (Pages data)`,
|
|
222
|
+
pathname,
|
|
223
|
+
sourcePathname: pathname,
|
|
224
|
+
route: options.routePatterns?.[pathname]
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
for (const pathname of new Set(options.routeHandlerPaths ?? [])) {
|
|
228
|
+
const routeHeaders = new Headers(commonHeaders);
|
|
229
|
+
routeHeaders.set("Accept", "*/*");
|
|
230
|
+
requests.push({
|
|
231
|
+
headers: routeHeaders,
|
|
232
|
+
kind: "app-route",
|
|
233
|
+
label: `${pathname} (Route Handler)`,
|
|
234
|
+
pathname,
|
|
235
|
+
sourcePathname: pathname,
|
|
236
|
+
route: options.routePatterns?.[pathname]
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
return requests;
|
|
240
|
+
}
|
|
241
|
+
var CdnOperationProgress = class {
|
|
242
|
+
isTTY = process.stderr.isTTY;
|
|
243
|
+
lastLineLength = 0;
|
|
244
|
+
update(completed, total, label, phase = "Warming CDN cache") {
|
|
245
|
+
if (!this.isTTY) return;
|
|
246
|
+
const percent = total > 0 ? Math.floor(completed / total * 100) : 0;
|
|
247
|
+
const filled = Math.floor(percent / 5);
|
|
248
|
+
const bar = `[${"█".repeat(filled)}${" ".repeat(20 - filled)}]`;
|
|
249
|
+
const shortLabel = label.length > 40 ? `…${label.slice(-39)}` : label;
|
|
250
|
+
const line = `${phase}... ${bar} ${String(completed).padStart(String(total).length)}/${total} ${shortLabel}`;
|
|
251
|
+
process.stderr.write(`\r${line.padEnd(this.lastLineLength)}`);
|
|
252
|
+
this.lastLineLength = line.length;
|
|
253
|
+
}
|
|
254
|
+
finish() {
|
|
255
|
+
if (!this.isTTY || this.lastLineLength === 0) return;
|
|
256
|
+
process.stderr.write(`\r${" ".repeat(this.lastLineLength)}\r`);
|
|
257
|
+
this.lastLineLength = 0;
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
const REQUIRED_RSC_VARY_HEADERS = VINEXT_RSC_VARY_HEADER.split(",").map((name) => name.trim().toLowerCase());
|
|
261
|
+
const ADMITTED_CF_CACHE_STATUSES = /* @__PURE__ */ new Set([
|
|
262
|
+
"HIT",
|
|
263
|
+
"MISS",
|
|
264
|
+
"EXPIRED",
|
|
265
|
+
"REVALIDATED",
|
|
266
|
+
"UPDATING"
|
|
267
|
+
]);
|
|
268
|
+
const REUSABLE_CF_CACHE_STATUSES = /* @__PURE__ */ new Set([
|
|
269
|
+
"HIT",
|
|
270
|
+
"REVALIDATED",
|
|
271
|
+
"UPDATING"
|
|
272
|
+
]);
|
|
273
|
+
const NON_CACHEABLE_CF_CACHE_STATUSES = /* @__PURE__ */ new Set(["BYPASS"]);
|
|
274
|
+
const CDN_CACHE_POLICY_HEADERS = [
|
|
275
|
+
"Cloudflare-CDN-Cache-Control",
|
|
276
|
+
"CDN-Cache-Control",
|
|
277
|
+
"Cache-Control"
|
|
278
|
+
];
|
|
279
|
+
function splitCacheControlDirectives(value) {
|
|
280
|
+
const directives = [];
|
|
281
|
+
let start = 0;
|
|
282
|
+
let quoted = false;
|
|
283
|
+
let escaped = false;
|
|
284
|
+
for (let index = 0; index < value.length; index++) {
|
|
285
|
+
const character = value[index];
|
|
286
|
+
if (escaped) escaped = false;
|
|
287
|
+
else if (quoted && character === "\\") escaped = true;
|
|
288
|
+
else if (character === "\"") quoted = !quoted;
|
|
289
|
+
else if (character === "," && !quoted) {
|
|
290
|
+
directives.push(value.slice(start, index));
|
|
291
|
+
start = index + 1;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
directives.push(value.slice(start));
|
|
295
|
+
return directives;
|
|
296
|
+
}
|
|
297
|
+
function hasFieldQualifiedSetCookie(value) {
|
|
298
|
+
return splitCacheControlDirectives(value).some((directive) => {
|
|
299
|
+
const separator = directive.indexOf("=");
|
|
300
|
+
if (separator === -1) return false;
|
|
301
|
+
const name = directive.slice(0, separator).trim().toLowerCase();
|
|
302
|
+
if (name !== "private" && name !== "no-cache") return false;
|
|
303
|
+
const rawFields = directive.slice(separator + 1).trim();
|
|
304
|
+
return (rawFields.startsWith("\"") && rawFields.endsWith("\"") ? rawFields.slice(1, -1) : rawFields).split(",").some((field) => field.trim().toLowerCase() === "set-cookie");
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
function validateCachePolicy(response, requireCacheStatus, requireCacheHit = false) {
|
|
308
|
+
const effectivePolicy = CDN_CACHE_POLICY_HEADERS.map((name) => ({
|
|
309
|
+
name,
|
|
310
|
+
value: response.headers.get(name)
|
|
311
|
+
})).find((entry) => entry.value !== null);
|
|
312
|
+
const nonCacheableHeaders = effectivePolicy && isNonCacheableCacheControl(effectivePolicy.value) ? [effectivePolicy.name] : [];
|
|
313
|
+
const hasSetCookie = response.headers.has("Set-Cookie");
|
|
314
|
+
const cacheStatus = response.headers.get("CF-Cache-Status")?.trim().toUpperCase();
|
|
315
|
+
if (requireCacheHit && !REUSABLE_CF_CACHE_STATUSES.has(cacheStatus ?? "")) return {
|
|
316
|
+
outcome: "failed",
|
|
317
|
+
error: `CF-Cache-Status is ${cacheStatus ?? "missing"}; the cache fill is not reusable`
|
|
318
|
+
};
|
|
319
|
+
if (cacheStatus && ADMITTED_CF_CACHE_STATUSES.has(cacheStatus)) {
|
|
320
|
+
if (hasSetCookie && (!effectivePolicy || !hasFieldQualifiedSetCookie(effectivePolicy.value))) return {
|
|
321
|
+
outcome: "failed",
|
|
322
|
+
error: "response sets a cookie without an observable field-qualified cache policy"
|
|
323
|
+
};
|
|
324
|
+
return { outcome: "warmed" };
|
|
325
|
+
}
|
|
326
|
+
if (cacheStatus && NON_CACHEABLE_CF_CACHE_STATUSES.has(cacheStatus)) return {
|
|
327
|
+
outcome: "skipped",
|
|
328
|
+
reason: nonCacheableHeaders.length > 0 ? `${nonCacheableHeaders.join(", ")} opts out of caching` : hasSetCookie ? "response sets a cookie" : `CF-Cache-Status is ${cacheStatus}`
|
|
329
|
+
};
|
|
330
|
+
if (cacheStatus === "DYNAMIC") {
|
|
331
|
+
if (nonCacheableHeaders.length > 0 || hasSetCookie) return {
|
|
332
|
+
outcome: "skipped",
|
|
333
|
+
reason: nonCacheableHeaders.length > 0 ? `${nonCacheableHeaders.join(", ")} opts out of caching` : "response sets a cookie"
|
|
334
|
+
};
|
|
335
|
+
return {
|
|
336
|
+
outcome: "failed",
|
|
337
|
+
error: "CF-Cache-Status is DYNAMIC"
|
|
106
338
|
};
|
|
107
|
-
lastError = `HTTP ${response.status}`;
|
|
108
|
-
if (!isRetryableStatus(response.status)) break;
|
|
109
|
-
} catch (error) {
|
|
110
|
-
if (error instanceof DOMException && error.name === "AbortError") lastError = `timed out after ${options.timeoutMs}ms`;
|
|
111
|
-
else lastError = error instanceof Error ? error.message : String(error);
|
|
112
339
|
}
|
|
340
|
+
if (nonCacheableHeaders.length > 0 || hasSetCookie) return {
|
|
341
|
+
outcome: "failed",
|
|
342
|
+
error: `${nonCacheableHeaders.length > 0 ? `${nonCacheableHeaders.join(", ")} opts out of caching` : "response sets a cookie"}, but CF-Cache-Status is ${cacheStatus ?? "missing"}`
|
|
343
|
+
};
|
|
344
|
+
if (!cacheStatus) return requireCacheStatus ? {
|
|
345
|
+
outcome: "failed",
|
|
346
|
+
error: "response is missing CF-Cache-Status"
|
|
347
|
+
} : { outcome: "warmed" };
|
|
113
348
|
return {
|
|
114
|
-
|
|
349
|
+
outcome: "failed",
|
|
350
|
+
error: `CF-Cache-Status is ${cacheStatus}`
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
function validateBuildIdentity(response, expectedBuildId) {
|
|
354
|
+
if (expectedBuildId !== void 0 && response.headers.get("X-Vinext-Build-Id") !== expectedBuildId) return {
|
|
355
|
+
outcome: "failed",
|
|
356
|
+
error: `response ${VINEXT_CDN_BUILD_ID_HEADER} does not match build ${expectedBuildId} (received ${response.headers.get("X-Vinext-Build-Id") ?? "missing"})`
|
|
357
|
+
};
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
function isExpectedTerminalStatus(status) {
|
|
361
|
+
return status >= 300 && status < 400 || status === 404;
|
|
362
|
+
}
|
|
363
|
+
function validateRscWarmResponse(response, expectedBuildId, expectedRscBuildId, requireCacheHit = false) {
|
|
364
|
+
const buildIdentityValidation = validateBuildIdentity(response, expectedBuildId);
|
|
365
|
+
if (buildIdentityValidation) return buildIdentityValidation;
|
|
366
|
+
if (expectedRscBuildId !== void 0 && response.headers.get(VINEXT_RSC_BUILD_ID_HEADER) !== expectedRscBuildId) return {
|
|
367
|
+
outcome: "failed",
|
|
368
|
+
error: `response ${VINEXT_RSC_BUILD_ID_HEADER} does not match build ${expectedRscBuildId}`
|
|
369
|
+
};
|
|
370
|
+
if (response.redirected) return {
|
|
371
|
+
outcome: "failed",
|
|
372
|
+
error: "redirected response"
|
|
373
|
+
};
|
|
374
|
+
const terminalResponse = response.status < 200 || response.status >= 300;
|
|
375
|
+
if (terminalResponse) {
|
|
376
|
+
if (!isExpectedTerminalStatus(response.status) || expectedBuildId === void 0 && expectedRscBuildId === void 0) return {
|
|
377
|
+
outcome: "failed",
|
|
378
|
+
error: `HTTP ${response.status}`
|
|
379
|
+
};
|
|
380
|
+
} else if (!response.headers.get("Content-Type")?.toLowerCase().startsWith(VINEXT_RSC_CONTENT_TYPE)) return {
|
|
381
|
+
outcome: "failed",
|
|
382
|
+
error: `expected ${VINEXT_RSC_CONTENT_TYPE} response`
|
|
383
|
+
};
|
|
384
|
+
const cachePolicyValidation = validateCachePolicy(response, true, requireCacheHit);
|
|
385
|
+
if (cachePolicyValidation.outcome !== "warmed") return cachePolicyValidation;
|
|
386
|
+
if (terminalResponse && !response.headers.get("Content-Type")?.toLowerCase().startsWith(VINEXT_RSC_CONTENT_TYPE)) return {
|
|
387
|
+
outcome: "failed",
|
|
388
|
+
error: `expected ${VINEXT_RSC_CONTENT_TYPE} response`
|
|
389
|
+
};
|
|
390
|
+
const vary = new Set((response.headers.get("Vary") ?? "").split(",").map((name) => name.trim().toLowerCase()).filter(Boolean));
|
|
391
|
+
const missingVary = REQUIRED_RSC_VARY_HEADERS.find((name) => !vary.has(name));
|
|
392
|
+
if (missingVary) return {
|
|
393
|
+
outcome: "failed",
|
|
394
|
+
error: `response Vary is missing ${missingVary}`
|
|
395
|
+
};
|
|
396
|
+
return { outcome: "warmed" };
|
|
397
|
+
}
|
|
398
|
+
function validateHtmlWarmResponse(response, expectedBuildId, requireCacheHit = false) {
|
|
399
|
+
const buildIdentityValidation = validateBuildIdentity(response, expectedBuildId);
|
|
400
|
+
if (buildIdentityValidation) return buildIdentityValidation;
|
|
401
|
+
if (response.redirected) return {
|
|
402
|
+
outcome: "failed",
|
|
403
|
+
error: "redirected response"
|
|
404
|
+
};
|
|
405
|
+
if (response.status < 200 || response.status >= 300) {
|
|
406
|
+
if (!isExpectedTerminalStatus(response.status) || expectedBuildId === void 0) return {
|
|
407
|
+
outcome: "failed",
|
|
408
|
+
error: `HTTP ${response.status}`
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
const cachePolicyValidation = validateCachePolicy(response, true, requireCacheHit);
|
|
412
|
+
if (cachePolicyValidation.outcome !== "warmed") return cachePolicyValidation;
|
|
413
|
+
return { outcome: "warmed" };
|
|
414
|
+
}
|
|
415
|
+
function validatePagesDataWarmResponse(response, expectedBuildId, requireCacheHit = false) {
|
|
416
|
+
const validation = validateHtmlWarmResponse(response, expectedBuildId, requireCacheHit);
|
|
417
|
+
if (validation.outcome !== "warmed") return validation;
|
|
418
|
+
if (!response.headers.get("Content-Type")?.toLowerCase().startsWith("application/json")) return {
|
|
419
|
+
outcome: "failed",
|
|
420
|
+
error: "expected application/json response"
|
|
421
|
+
};
|
|
422
|
+
return validation;
|
|
423
|
+
}
|
|
424
|
+
function validateReadinessResponse(response, kind, expectedBuildId, expectedRscBuildId) {
|
|
425
|
+
const buildIdentityValidation = validateBuildIdentity(response, expectedBuildId);
|
|
426
|
+
if (buildIdentityValidation?.outcome === "failed") return buildIdentityValidation.error;
|
|
427
|
+
if (kind === "rsc" && expectedRscBuildId !== void 0 && response.headers.get(VINEXT_RSC_BUILD_ID_HEADER) !== expectedRscBuildId) return `response ${VINEXT_RSC_BUILD_ID_HEADER} does not match build ${expectedRscBuildId}`;
|
|
428
|
+
if (response.redirected) return "redirected response";
|
|
429
|
+
if (response.status >= 500) return `HTTP ${response.status}`;
|
|
430
|
+
if (response.status >= 200 && response.status < 300) {
|
|
431
|
+
const contentType = response.headers.get("Content-Type")?.toLowerCase();
|
|
432
|
+
if (kind === "rsc" && !contentType?.startsWith(VINEXT_RSC_CONTENT_TYPE)) return `expected ${VINEXT_RSC_CONTENT_TYPE} response`;
|
|
433
|
+
if (kind === "pages-data" && !contentType?.startsWith("application/json")) return "expected application/json response";
|
|
434
|
+
}
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
function validatePrerenderReadinessResponse(response, expectedBuildId) {
|
|
438
|
+
const buildIdentityValidation = validateBuildIdentity(response, expectedBuildId);
|
|
439
|
+
if (buildIdentityValidation?.outcome === "failed") return buildIdentityValidation.error;
|
|
440
|
+
if (response.redirected) return "redirected response";
|
|
441
|
+
if (response.status !== 204) return `expected readiness HTTP 204, received ${response.status}`;
|
|
442
|
+
if (response.headers.get(VINEXT_PRERENDER_READINESS_HEADER) !== "1") return `response is missing ${VINEXT_PRERENDER_READINESS_HEADER}: 1`;
|
|
443
|
+
const cacheControl = response.headers.get("Cache-Control");
|
|
444
|
+
if (!cacheControl || !/(?:^|,)\s*no-store\s*(?:,|$)/i.test(cacheControl)) return "readiness response is missing Cache-Control: no-store";
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Wait until version-override requests consistently reach the uploaded build
|
|
449
|
+
* before any real cache key is filled. Every probe has a unique query key, so
|
|
450
|
+
* an early response cannot make a later readiness attempt pass from cache.
|
|
451
|
+
*/
|
|
452
|
+
async function waitForCdnWarmTargetReadiness(options) {
|
|
453
|
+
const rscPath = options.plan.rscPaths[0] ?? options.plan.loadingShellPaths[0];
|
|
454
|
+
const htmlPath = options.plan.paths[0];
|
|
455
|
+
const pagesDataPath = options.plan.pagesDataPaths[0];
|
|
456
|
+
const routeHandlerPath = options.plan.routeHandlerPaths?.[0];
|
|
457
|
+
const kind = rscPath ? "rsc" : htmlPath ? "html" : pagesDataPath ? "pages-data" : "app-route";
|
|
458
|
+
const pathname = rscPath ?? htmlPath ?? pagesDataPath ?? routeHandlerPath;
|
|
459
|
+
if (!pathname) return { ready: true };
|
|
460
|
+
if (options.expectedBuildId === void 0 && options.expectedRscBuildId === void 0) return {
|
|
461
|
+
error: "no response build identity is available for a staged-version readiness probe",
|
|
462
|
+
ready: false
|
|
463
|
+
};
|
|
464
|
+
const headers = new Headers(options.headers);
|
|
465
|
+
const readinessSecret = options.prerenderSecret;
|
|
466
|
+
const useReadinessEndpoint = Boolean(readinessSecret);
|
|
467
|
+
const probePath = useReadinessEndpoint ? VINEXT_PRERENDER_READINESS_PATH : kind === "rsc" ? createCanonicalRscRequestUrl(pathname) : pathname;
|
|
468
|
+
if (readinessSecret) {
|
|
469
|
+
headers.set("Accept", "text/html");
|
|
470
|
+
headers.set(VINEXT_PRERENDER_SECRET_HEADER, readinessSecret);
|
|
471
|
+
} else if (kind === "rsc") for (const [name, value] of createCanonicalRscRequestHeaders(options.deploymentId)) headers.set(name, value);
|
|
472
|
+
else if (kind === "html") headers.set("Accept", "text/html");
|
|
473
|
+
else if (kind === "pages-data") headers.set("Accept", "application/json");
|
|
474
|
+
else headers.set("Accept", "*/*");
|
|
475
|
+
headers.set("Cache-Control", "no-cache");
|
|
476
|
+
headers.set("Pragma", "no-cache");
|
|
477
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
478
|
+
const timeoutMs = Math.max(1, options.timeoutMs ?? 1e4);
|
|
479
|
+
const probeIntervalMs = Math.max(0, options.probeIntervalMs ?? 1e3);
|
|
480
|
+
const requiredConsecutiveSuccesses = Math.max(1, options.requiredConsecutiveSuccesses ?? DEFAULT_STAGED_READINESS_SUCCESSES);
|
|
481
|
+
const readinessRetries = Math.max(0, options.retries ?? 60);
|
|
482
|
+
const maxAttempts = Math.max(requiredConsecutiveSuccesses, options.maxAttempts ?? requiredConsecutiveSuccesses + readinessRetries);
|
|
483
|
+
const phaseTimeoutMs = Math.max(1, options.phaseTimeoutMs ?? 12e4);
|
|
484
|
+
const deadlineAt = Date.now() + phaseTimeoutMs;
|
|
485
|
+
const probeId = randomUUID();
|
|
486
|
+
let consecutiveSuccesses = 0;
|
|
487
|
+
let lastError = "readiness probe did not run";
|
|
488
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
489
|
+
const remainingMs = deadlineAt - Date.now();
|
|
490
|
+
if (remainingMs <= 0) {
|
|
491
|
+
lastError = `staged readiness exceeded its ${phaseTimeoutMs}ms phase deadline`;
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
const url = buildWarmupUrl(options.targetUrl, probePath);
|
|
495
|
+
url.searchParams.set(STAGED_READINESS_QUERY_PARAM, `${probeId}-${attempt}`);
|
|
496
|
+
let response;
|
|
497
|
+
try {
|
|
498
|
+
response = await fetchHeadersWithTimeout(fetchImpl, url, Math.min(timeoutMs, remainingMs), headers, useReadinessEndpoint ? "POST" : "GET");
|
|
499
|
+
const validationError = useReadinessEndpoint ? validatePrerenderReadinessResponse(response, options.expectedBuildId) : validateReadinessResponse(response, kind, options.expectedBuildId, options.expectedRscBuildId);
|
|
500
|
+
if (process.env.VINEXT_CDN_WARM_DEBUG === "1") console.log(` CDN warm readiness attempt ${attempt + 1}: ${url.pathname}${url.search} HTTP ${response.status} build=${response.headers.get(VINEXT_RSC_BUILD_ID_HEADER) ?? response.headers.get("X-Vinext-Build-Id") ?? "missing"}`);
|
|
501
|
+
if (validationError === null) {
|
|
502
|
+
consecutiveSuccesses++;
|
|
503
|
+
if (consecutiveSuccesses >= requiredConsecutiveSuccesses) return { ready: true };
|
|
504
|
+
} else {
|
|
505
|
+
consecutiveSuccesses = 0;
|
|
506
|
+
lastError = validationError;
|
|
507
|
+
}
|
|
508
|
+
} catch (error) {
|
|
509
|
+
consecutiveSuccesses = 0;
|
|
510
|
+
lastError = Date.now() >= deadlineAt ? `staged readiness exceeded its ${phaseTimeoutMs}ms phase deadline` : error instanceof DOMException && error.name === "AbortError" ? `timed out after ${Math.min(timeoutMs, remainingMs)}ms` : error instanceof Error ? error.message : String(error);
|
|
511
|
+
} finally {
|
|
512
|
+
if (response?.body) response.body.cancel().catch(() => {});
|
|
513
|
+
}
|
|
514
|
+
if (attempt + 1 < maxAttempts && probeIntervalMs > 0) {
|
|
515
|
+
const delayMs = Math.min(probeIntervalMs, Math.max(0, deadlineAt - Date.now()));
|
|
516
|
+
if (delayMs <= 0) {
|
|
517
|
+
lastError = `staged readiness exceeded its ${phaseTimeoutMs}ms phase deadline`;
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
await setTimeout$1(delayMs);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return {
|
|
524
|
+
error: `${lastError}; uploaded build was not stable for ${requiredConsecutiveSuccesses} consecutive probe(s)`,
|
|
525
|
+
ready: false
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
function shouldRetryValidationFailure(response, target, options) {
|
|
529
|
+
if (!options.retryPropagationFailures) return isRetryableStatus(response.status, options.retryNotFound);
|
|
530
|
+
const expectedIdentities = [options.expectedBuildId === void 0 ? null : response.headers.get(VINEXT_CDN_BUILD_ID_HEADER) === options.expectedBuildId, !target.kind.startsWith("rsc-") || options.expectedRscBuildId === void 0 ? null : response.headers.get(VINEXT_RSC_BUILD_ID_HEADER) === options.expectedRscBuildId].filter((matches) => matches !== null);
|
|
531
|
+
if (expectedIdentities.some((matches) => matches)) return isRetryableStatus(response.status, false);
|
|
532
|
+
if (expectedIdentities.some((matches) => !matches)) return true;
|
|
533
|
+
return isRetryableStatus(response.status, options.retryNotFound);
|
|
534
|
+
}
|
|
535
|
+
async function warmOnePath(target, options) {
|
|
536
|
+
const url = buildWarmupUrl(options.targetUrl, target.pathname);
|
|
537
|
+
let lastError = "request failed before the first attempt";
|
|
538
|
+
let lastRetryable = true;
|
|
539
|
+
const phaseDeadlineError = () => `CDN warmup exceeded its ${options.phaseTimeoutMs}ms phase deadline`;
|
|
540
|
+
const remainingPhaseMs = () => options.deadlineAt === void 0 ? options.timeoutMs : options.deadlineAt - Date.now();
|
|
541
|
+
const canRetry = (attempt) => attempt < options.retries;
|
|
542
|
+
const waitBeforeRetry = async () => {
|
|
543
|
+
if (options.retryDelayMs <= 0) return remainingPhaseMs() > 0;
|
|
544
|
+
const delayMs = Math.min(options.retryDelayMs, Math.max(0, remainingPhaseMs()));
|
|
545
|
+
if (delayMs <= 0) return false;
|
|
546
|
+
await setTimeout$1(delayMs);
|
|
547
|
+
return remainingPhaseMs() > 0;
|
|
548
|
+
};
|
|
549
|
+
for (let attempt = 0; attempt <= options.retries; attempt++) {
|
|
550
|
+
const remainingMs = remainingPhaseMs();
|
|
551
|
+
if (remainingMs <= 0) return {
|
|
552
|
+
path: target.label,
|
|
553
|
+
ok: false,
|
|
554
|
+
error: phaseDeadlineError(),
|
|
555
|
+
retryable: false
|
|
556
|
+
};
|
|
557
|
+
const attemptTimeoutMs = Math.min(options.timeoutMs, remainingMs);
|
|
558
|
+
try {
|
|
559
|
+
const response = options.requireCacheHit ? await fetchHeadersWithTimeout(options.fetchImpl, url, attemptTimeoutMs, target.headers ?? options.headers) : (await fetchWithTimeout(options.fetchImpl, url, attemptTimeoutMs, target.headers ?? options.headers, "manual")).response;
|
|
560
|
+
if (options.deadlineAt !== void 0 && Date.now() >= options.deadlineAt) return {
|
|
561
|
+
path: target.label,
|
|
562
|
+
ok: false,
|
|
563
|
+
error: phaseDeadlineError(),
|
|
564
|
+
retryable: false
|
|
565
|
+
};
|
|
566
|
+
if (options.requireCacheHit) response.body?.cancel().catch(() => {});
|
|
567
|
+
if (process.env.VINEXT_CDN_WARM_DEBUG === "1") console.log(` CDN warm debug ${target.label} attempt ${attempt + 1}: ${url.pathname}${url.search} HTTP ${response.status} cache=${response.headers.get("CF-Cache-Status") ?? "missing"} ray=${response.headers.get("CF-Ray") ?? "missing"} encoding=${response.headers.get("Content-Encoding") ?? "identity"} rscBuild=${response.headers.get(VINEXT_RSC_BUILD_ID_HEADER) ?? "missing"}`);
|
|
568
|
+
if (target.kind.startsWith("rsc-")) {
|
|
569
|
+
const validation = validateRscWarmResponse(response, options.expectedBuildId, options.expectedRscBuildId, options.requireCacheHit);
|
|
570
|
+
if (validation.outcome === "warmed") return {
|
|
571
|
+
path: target.label,
|
|
572
|
+
ok: true,
|
|
573
|
+
skipped: false
|
|
574
|
+
};
|
|
575
|
+
if (validation.outcome === "skipped") return {
|
|
576
|
+
path: target.label,
|
|
577
|
+
ok: true,
|
|
578
|
+
skipped: true,
|
|
579
|
+
reason: validation.reason
|
|
580
|
+
};
|
|
581
|
+
lastError = validation.error;
|
|
582
|
+
const cacheStatus = response.headers.get("CF-Cache-Status")?.trim().toUpperCase();
|
|
583
|
+
lastRetryable = options.requireCacheHit && ADMITTED_CF_CACHE_STATUSES.has(cacheStatus ?? "") && !REUSABLE_CF_CACHE_STATUSES.has(cacheStatus ?? "") || shouldRetryValidationFailure(response, target, options);
|
|
584
|
+
if (!lastRetryable) break;
|
|
585
|
+
if (!canRetry(attempt)) break;
|
|
586
|
+
if (!await waitBeforeRetry()) return {
|
|
587
|
+
path: target.label,
|
|
588
|
+
ok: false,
|
|
589
|
+
error: phaseDeadlineError(),
|
|
590
|
+
retryable: false
|
|
591
|
+
};
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
const validation = target.kind === "pages-data" ? validatePagesDataWarmResponse(response, options.expectedBuildId, options.requireCacheHit) : validateHtmlWarmResponse(response, options.expectedBuildId, options.requireCacheHit);
|
|
595
|
+
if (validation.outcome === "warmed") return {
|
|
596
|
+
path: target.label,
|
|
597
|
+
ok: true,
|
|
598
|
+
skipped: false
|
|
599
|
+
};
|
|
600
|
+
if (validation.outcome === "skipped") return {
|
|
601
|
+
path: target.label,
|
|
602
|
+
ok: true,
|
|
603
|
+
skipped: true,
|
|
604
|
+
reason: validation.reason
|
|
605
|
+
};
|
|
606
|
+
lastError = validation.error;
|
|
607
|
+
const cacheStatus = response.headers.get("CF-Cache-Status")?.trim().toUpperCase();
|
|
608
|
+
lastRetryable = options.requireCacheHit && ADMITTED_CF_CACHE_STATUSES.has(cacheStatus ?? "") && !REUSABLE_CF_CACHE_STATUSES.has(cacheStatus ?? "") || shouldRetryValidationFailure(response, target, options);
|
|
609
|
+
if (!lastRetryable) break;
|
|
610
|
+
} catch (error) {
|
|
611
|
+
lastRetryable = true;
|
|
612
|
+
if (error instanceof DOMException && error.name === "AbortError") lastError = options.deadlineAt !== void 0 && Date.now() >= options.deadlineAt ? phaseDeadlineError() : `timed out after ${attemptTimeoutMs}ms`;
|
|
613
|
+
else lastError = error instanceof Error ? error.message : String(error);
|
|
614
|
+
}
|
|
615
|
+
if (!canRetry(attempt)) break;
|
|
616
|
+
if (!await waitBeforeRetry()) return {
|
|
617
|
+
path: target.label,
|
|
618
|
+
ok: false,
|
|
619
|
+
error: phaseDeadlineError(),
|
|
620
|
+
retryable: false
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
return {
|
|
624
|
+
path: target.label,
|
|
115
625
|
ok: false,
|
|
116
|
-
error: lastError
|
|
626
|
+
error: lastError,
|
|
627
|
+
retryable: lastRetryable
|
|
117
628
|
};
|
|
118
629
|
}
|
|
119
630
|
async function runWithConcurrency(items, concurrency, fn) {
|
|
@@ -131,53 +642,166 @@ async function runWithConcurrency(items, concurrency, fn) {
|
|
|
131
642
|
return results;
|
|
132
643
|
}
|
|
133
644
|
async function warmCdnCache(options) {
|
|
134
|
-
const
|
|
135
|
-
const
|
|
136
|
-
const
|
|
137
|
-
|
|
645
|
+
const targets = await createCdnWarmTargets(options);
|
|
646
|
+
const requests = targets.filter((target) => target.kind !== "html");
|
|
647
|
+
const htmlRequests = targets.filter((target) => target.kind === "html");
|
|
648
|
+
requests.push(...htmlRequests);
|
|
649
|
+
const timeoutMs = Math.max(1, options.timeoutMs ?? 1e4);
|
|
650
|
+
const hasVersionOverride = new Headers(options.headers).has(WORKER_VERSION_OVERRIDE_HEADER);
|
|
651
|
+
const propagatingTarget = options.propagatingTarget ?? hasVersionOverride;
|
|
652
|
+
const concurrency = Math.max(1, options.concurrency ?? 25);
|
|
653
|
+
const normalRetries = Math.max(0, options.retries ?? 1);
|
|
654
|
+
const propagationRetries = Math.max(0, options.retries ?? 60);
|
|
655
|
+
const normalRetryDelayMs = Math.max(0, options.retryDelayMs ?? 0);
|
|
656
|
+
const propagationRetryDelayMs = Math.max(0, options.retryDelayMs ?? 1e3);
|
|
138
657
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
139
|
-
|
|
658
|
+
const phaseTimeoutMs = options.phaseTimeoutMs === void 0 ? void 0 : Math.max(1, options.phaseTimeoutMs);
|
|
659
|
+
const deadlineAt = phaseTimeoutMs === void 0 ? void 0 : Date.now() + phaseTimeoutMs;
|
|
660
|
+
if (requests.length === 0) return {
|
|
140
661
|
total: 0,
|
|
141
662
|
warmed: 0,
|
|
663
|
+
skipped: 0,
|
|
142
664
|
failed: 0,
|
|
143
|
-
failures: []
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
665
|
+
failures: [],
|
|
666
|
+
skippedTargets: [],
|
|
667
|
+
warmedPlan: {
|
|
668
|
+
loadingShellPaths: [],
|
|
669
|
+
pagesDataPaths: [],
|
|
670
|
+
paths: [],
|
|
671
|
+
rscPaths: []
|
|
672
|
+
},
|
|
673
|
+
retryPlan: {
|
|
674
|
+
loadingShellPaths: [],
|
|
675
|
+
pagesDataPaths: [],
|
|
676
|
+
paths: [],
|
|
677
|
+
rscPaths: []
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
console.log(`\n Warming CDN cache with ${requests.length} discovered request(s)...`);
|
|
681
|
+
const progress = new CdnOperationProgress();
|
|
682
|
+
let completedRequests = 0;
|
|
683
|
+
progress.update(0, requests.length, "starting warmup");
|
|
684
|
+
const warmTarget = (target, retryMode = "normal") => {
|
|
685
|
+
const isPropagationRequest = retryMode !== "normal";
|
|
686
|
+
const retries = retryMode === "propagation-retry" ? Math.max(0, propagationRetries - 1) : retryMode === "propagation-pass" ? 0 : normalRetries;
|
|
687
|
+
return warmOnePath(target, {
|
|
688
|
+
targetUrl: options.targetUrl,
|
|
689
|
+
timeoutMs,
|
|
690
|
+
retries,
|
|
691
|
+
deadlineAt,
|
|
692
|
+
fetchImpl,
|
|
693
|
+
headers: options.headers,
|
|
694
|
+
expectedBuildId: options.expectedBuildId,
|
|
695
|
+
expectedRscBuildId: options.expectedRscBuildId,
|
|
696
|
+
retryPropagationFailures: isPropagationRequest,
|
|
697
|
+
retryDelayMs: isPropagationRequest ? propagationRetryDelayMs : normalRetryDelayMs,
|
|
698
|
+
retryNotFound: isPropagationRequest,
|
|
699
|
+
phaseTimeoutMs,
|
|
700
|
+
requireCacheHit: options.requireCacheHit === true
|
|
701
|
+
});
|
|
702
|
+
};
|
|
703
|
+
const warmRequest = async (target, retryMode = "normal") => {
|
|
704
|
+
const result = await warmTarget(target, retryMode);
|
|
705
|
+
completedRequests++;
|
|
706
|
+
progress.update(completedRequests, requests.length, target.label);
|
|
707
|
+
return result;
|
|
708
|
+
};
|
|
709
|
+
const warmPropagatingPass = async (targets) => {
|
|
710
|
+
const results = await runWithConcurrency(targets, concurrency, (target) => warmRequest(target, "propagation-pass"));
|
|
711
|
+
const retryableFailed = targets.map((target, index) => ({
|
|
712
|
+
index,
|
|
713
|
+
result: results[index],
|
|
714
|
+
target
|
|
715
|
+
})).filter((entry) => !entry.result.ok).filter(({ result }) => result.retryable);
|
|
716
|
+
if (retryableFailed.length === 0 || propagationRetries === 0) return results;
|
|
717
|
+
progress.finish();
|
|
718
|
+
console.log(` CDN warmup: retrying ${retryableFailed.length} failed request(s) after completing the initial pass...`);
|
|
719
|
+
let completedRetries = 0;
|
|
720
|
+
progress.update(0, retryableFailed.length, "starting retry pass", "Retrying CDN cache");
|
|
721
|
+
const retried = await runWithConcurrency(retryableFailed.map(({ target }) => target), concurrency, async (target) => {
|
|
722
|
+
const result = await warmTarget(target, "propagation-retry");
|
|
723
|
+
completedRetries++;
|
|
724
|
+
progress.update(completedRetries, retryableFailed.length, target.label, "Retrying CDN cache");
|
|
725
|
+
return result;
|
|
726
|
+
});
|
|
727
|
+
progress.finish();
|
|
728
|
+
for (const [retryIndex, { index }] of retryableFailed.entries()) results[index] = retried[retryIndex];
|
|
729
|
+
return results;
|
|
730
|
+
};
|
|
731
|
+
let results;
|
|
732
|
+
if (propagatingTarget) results = await warmPropagatingPass(requests);
|
|
733
|
+
else results = await runWithConcurrency(requests, concurrency, (target) => warmRequest(target));
|
|
734
|
+
progress.finish();
|
|
735
|
+
const failedRequests = requests.map((target, index) => ({
|
|
736
|
+
result: results[index],
|
|
737
|
+
target
|
|
738
|
+
})).filter((entry) => !entry.result.ok);
|
|
739
|
+
const failures = failedRequests.map(({ result: { path, error } }) => ({
|
|
154
740
|
path,
|
|
155
741
|
error
|
|
156
742
|
}));
|
|
157
|
-
const
|
|
158
|
-
|
|
743
|
+
const skippedResults = results.filter((result) => result.ok && result.skipped);
|
|
744
|
+
const skippedTargets = requests.filter((_target, index) => {
|
|
745
|
+
const result = results[index];
|
|
746
|
+
return result.ok && result.skipped;
|
|
747
|
+
});
|
|
748
|
+
const warmedRequests = requests.filter((_target, index) => {
|
|
749
|
+
const result = results[index];
|
|
750
|
+
return result.ok && !result.skipped;
|
|
751
|
+
});
|
|
752
|
+
const warmed = results.length - failures.length - skippedResults.length;
|
|
753
|
+
const warmedRouteHandlerPaths = warmedRequests.filter((target) => target.kind === "app-route").map((target) => target.sourcePathname);
|
|
754
|
+
const failedRouteHandlerPaths = failedRequests.filter(({ target }) => target.kind === "app-route").map(({ target }) => target.sourcePathname);
|
|
755
|
+
console.log(` CDN warmup: ${warmed} warmed, ${skippedResults.length} skipped, ${failures.length} failed.`);
|
|
756
|
+
for (const skipped of skippedResults.slice(0, 5)) console.log(` CDN warmup skipped ${skipped.path}: ${skipped.reason}`);
|
|
159
757
|
if (failures.length > 0) {
|
|
160
758
|
for (const failure of failures.slice(0, 5)) console.warn(` CDN warmup failed for ${failure.path}: ${failure.error}`);
|
|
161
759
|
if (failures.length > 5) console.warn(` CDN warmup: ${failures.length - 5} additional failure(s) omitted.`);
|
|
162
760
|
}
|
|
163
761
|
const result = {
|
|
164
|
-
total:
|
|
762
|
+
total: requests.length,
|
|
165
763
|
warmed,
|
|
764
|
+
skipped: skippedResults.length,
|
|
166
765
|
failed: failures.length,
|
|
167
|
-
failures
|
|
766
|
+
failures,
|
|
767
|
+
skippedTargets,
|
|
768
|
+
warmedPlan: {
|
|
769
|
+
loadingShellPaths: warmedRequests.filter((target) => target.kind === "rsc-loading-shell").map((target) => target.sourcePathname),
|
|
770
|
+
pagesDataPaths: warmedRequests.filter((target) => target.kind === "pages-data").map((target) => target.sourcePathname),
|
|
771
|
+
paths: warmedRequests.filter((target) => target.kind === "html").map((target) => target.sourcePathname),
|
|
772
|
+
rscPaths: warmedRequests.filter((target) => target.kind === "rsc-full").map((target) => target.sourcePathname),
|
|
773
|
+
...warmedRouteHandlerPaths.length > 0 ? { routeHandlerPaths: warmedRouteHandlerPaths } : {}
|
|
774
|
+
},
|
|
775
|
+
retryPlan: {
|
|
776
|
+
loadingShellPaths: failedRequests.filter(({ target }) => target.kind === "rsc-loading-shell").map(({ target }) => target.sourcePathname),
|
|
777
|
+
pagesDataPaths: failedRequests.filter(({ target }) => target.kind === "pages-data").map(({ target }) => target.sourcePathname),
|
|
778
|
+
paths: failedRequests.filter(({ target }) => target.kind === "html").map(({ target }) => target.sourcePathname),
|
|
779
|
+
rscPaths: failedRequests.filter(({ target }) => target.kind === "rsc-full").map(({ target }) => target.sourcePathname),
|
|
780
|
+
...failedRouteHandlerPaths.length > 0 ? { routeHandlerPaths: failedRouteHandlerPaths } : {}
|
|
781
|
+
}
|
|
168
782
|
};
|
|
169
|
-
if (options.strict && failures.length > 0) throw new Error(`CDN warmup failed for ${failures.length}/${
|
|
783
|
+
if (options.strict && failures.length > 0) throw new Error(`CDN warmup failed for ${failures.length}/${requests.length} request(s). First failure: ${failures[0].path}: ${failures[0].error}`);
|
|
170
784
|
return result;
|
|
171
785
|
}
|
|
172
786
|
async function warmCdnCacheFromPrerender(options) {
|
|
173
|
-
const
|
|
787
|
+
const plan = readPrerenderWarmPlan(options.root, {
|
|
174
788
|
includeFallbackShells: options.includeFallbackShells,
|
|
175
789
|
strict: options.strict
|
|
176
790
|
});
|
|
791
|
+
const warmPlan = {
|
|
792
|
+
deploymentId: plan.deploymentId,
|
|
793
|
+
loadingShellPaths: plan.loadingShellPaths,
|
|
794
|
+
pagesDataPaths: plan.pagesDataPaths,
|
|
795
|
+
paths: plan.paths,
|
|
796
|
+
routeHandlerPaths: plan.routeHandlerPaths,
|
|
797
|
+
rscPaths: plan.rscPaths
|
|
798
|
+
};
|
|
177
799
|
return warmCdnCache({
|
|
178
800
|
...options,
|
|
179
|
-
|
|
801
|
+
...warmPlan,
|
|
802
|
+
expectedBuildId: options.expectedBuildId ?? (options.validateBuildIdentity === false ? void 0 : plan.buildIdentity),
|
|
803
|
+
expectedRscBuildId: options.expectedRscBuildId ?? plan.rscBuildId
|
|
180
804
|
});
|
|
181
805
|
}
|
|
182
806
|
//#endregion
|
|
183
|
-
export { DEFAULT_CDN_WARM_TIMEOUT_MS, buildWarmupUrl, getWarmPathsFromPrerenderManifest, readPrerenderWarmPaths, warmCdnCache, warmCdnCacheFromPrerender };
|
|
807
|
+
export { CdnOperationProgress, DEFAULT_CDN_WARM_CONCURRENCY, DEFAULT_CDN_WARM_TIMEOUT_MS, DEFAULT_STAGED_READINESS_INTERVAL_MS, DEFAULT_STAGED_READINESS_PHASE_TIMEOUT_MS, DEFAULT_STAGED_READINESS_RETRIES, buildWarmupUrl, createCdnWarmTargets, getWarmPathsFromPrerenderManifest, readPrerenderWarmPaths, readPrerenderWarmPlan, waitForCdnWarmTargetReadiness, warmCdnCache, warmCdnCacheFromPrerender };
|