@vinext/cloudflare 1.0.0-beta.9 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +165 -37
  2. package/dist/cache/browser-cache-policy.d.ts +7 -0
  3. package/dist/cache/browser-cache-policy.js +39 -0
  4. package/dist/cache/cdn-adapter-config.d.ts +2 -1
  5. package/dist/cache/cdn-adapter-config.js +13 -5
  6. package/dist/cache/cdn-adapter.d.ts +5 -56
  7. package/dist/cache/cdn-adapter.js +3 -61
  8. package/dist/cache/cdn-adapter.runtime.d.ts +1 -0
  9. package/dist/cache/cdn-adapter.runtime.js +33 -19
  10. package/dist/cache/cdn-adapter.worker.d.ts +1 -1
  11. package/dist/cache/cdn-adapter.worker.js +10 -35
  12. package/dist/cache/cdn-build-id.d.ts +1 -1
  13. package/dist/cache/cdn-build-id.js +1 -1
  14. package/dist/cache/config.d.ts +137 -0
  15. package/dist/cache/config.js +87 -0
  16. package/dist/cache/kv-data-adapter.d.ts +1 -1
  17. package/dist/cache/kv-data-adapter.js +1 -1
  18. package/dist/cache/kv-data-adapter.runtime.js +17 -15
  19. package/dist/cache/kv-key.d.ts +7 -4
  20. package/dist/cache/kv-key.js +17 -6
  21. package/dist/cache/response-store-adapter.d.ts +9 -6
  22. package/dist/cache/response-store-adapter.js +20 -1
  23. package/dist/cache/response-store-adapter.worker.js +121 -48
  24. package/dist/cache/response-store-cdn.runtime.js +1 -0
  25. package/dist/cache/response-store-data.runtime.js +53 -31
  26. package/dist/cache/static-assets-adapter.build.d.ts +6 -0
  27. package/dist/cache/static-assets-adapter.build.js +102 -0
  28. package/dist/cache/static-assets-adapter.d.ts +27 -0
  29. package/dist/cache/static-assets-adapter.js +26 -0
  30. package/dist/cache/static-assets-adapter.runtime.d.ts +27 -0
  31. package/dist/cache/static-assets-adapter.runtime.js +94 -0
  32. package/dist/cache/static-assets-adapter.shared.d.ts +12 -0
  33. package/dist/cache/static-assets-adapter.shared.js +4 -0
  34. package/dist/cache/workers-cache-cdn-adapter.d.ts +58 -0
  35. package/dist/cache/workers-cache-cdn-adapter.js +65 -0
  36. package/dist/cacheability-artifact.d.ts +7 -1
  37. package/dist/cacheability-artifact.js +60 -21
  38. package/dist/cacheability-probe.d.ts +2 -0
  39. package/dist/cacheability-probe.js +107 -13
  40. package/dist/cdn-warm.d.ts +1 -0
  41. package/dist/cdn-warm.js +4 -3
  42. package/dist/deploy-config.d.ts +3 -8
  43. package/dist/deploy-config.js +5 -23
  44. package/dist/deploy-help.js +47 -42
  45. package/dist/deploy.d.ts +18 -16
  46. package/dist/deploy.js +355 -216
  47. package/dist/tpr.d.ts +4 -0
  48. package/dist/tpr.js +16 -7
  49. package/dist/version-deploy.d.ts +27 -0
  50. package/dist/version-deploy.js +121 -3
  51. package/dist/wrangler-version-metadata.js +1 -1
  52. package/package.json +16 -5
  53. package/dist/prerender-kv-populate.d.ts +0 -24
  54. package/dist/prerender-kv-populate.js +0 -153
@@ -0,0 +1,94 @@
1
+ import { STATIC_ASSET_CACHE_PATH } from "./static-assets-adapter.shared.js";
2
+ import { DefaultCdnCacheAdapter } from "vinext/shims/cdn-cache";
3
+ //#region src/cache/static-assets-adapter.runtime.ts
4
+ async function cacheAssetId(key) {
5
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(key));
6
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
7
+ }
8
+ function isMetadata(value) {
9
+ return value !== null && typeof value === "object" && "kind" in value && (value.kind === "html" || value.kind === "rsc" || value.kind === "route" || value.kind === "pages" || value.kind === "redirect" || value.kind === "not-found") && "lastModified" in value && typeof value.lastModified === "number";
10
+ }
11
+ var StaticAssetsCacheAdapter = class {
12
+ assets;
13
+ hasPrerenderedPages = true;
14
+ ownsBackgroundRevalidation = false;
15
+ indexPromise;
16
+ constructor(assets) {
17
+ this.assets = assets;
18
+ }
19
+ loadIndex() {
20
+ return this.indexPromise ??= this.assets.fetch(`https://vinext.invalid${STATIC_ASSET_CACHE_PATH}/index.json`).then(async (response) => response.ok ? await response.json() : null).then((value) => value !== null && typeof value === "object" ? value : null);
21
+ }
22
+ async get(key) {
23
+ const id = await cacheAssetId(key);
24
+ const metadata = (await this.loadIndex())?.[id];
25
+ if (!isMetadata(metadata)) return null;
26
+ const extension = metadata.kind;
27
+ const bodyResponse = await this.assets.fetch(`https://vinext.invalid${STATIC_ASSET_CACHE_PATH}/${id}.${extension}`);
28
+ if (!bodyResponse.ok) return null;
29
+ let value;
30
+ if (metadata.kind === "not-found") {
31
+ await bodyResponse.body?.cancel();
32
+ value = null;
33
+ } else if (metadata.kind === "redirect") value = {
34
+ kind: "REDIRECT",
35
+ props: await bodyResponse.json()
36
+ };
37
+ else if (metadata.kind === "pages") {
38
+ const { html, pageData } = await bodyResponse.json();
39
+ value = {
40
+ kind: "PAGES",
41
+ html,
42
+ pageData,
43
+ headers: metadata.headers,
44
+ status: metadata.status
45
+ };
46
+ } else if (metadata.kind === "html") value = {
47
+ kind: "APP_PAGE",
48
+ html: await bodyResponse.text(),
49
+ rscData: void 0,
50
+ headers: metadata.headers,
51
+ postponed: void 0,
52
+ prerendered: true,
53
+ status: metadata.status
54
+ };
55
+ else if (metadata.kind === "rsc") value = {
56
+ kind: "APP_PAGE",
57
+ html: "",
58
+ rscData: await bodyResponse.arrayBuffer(),
59
+ headers: void 0,
60
+ postponed: void 0,
61
+ prerendered: true,
62
+ status: void 0
63
+ };
64
+ else value = {
65
+ kind: "APP_ROUTE",
66
+ body: await bodyResponse.arrayBuffer(),
67
+ headers: metadata.headers ?? {},
68
+ status: metadata.status ?? 200
69
+ };
70
+ return {
71
+ lastModified: metadata.lastModified,
72
+ ...metadata.cacheControl ? { cacheControl: metadata.cacheControl } : {},
73
+ value
74
+ };
75
+ }
76
+ async set(_key, _data, _ctx) {}
77
+ buildResponseHeaders(input) {
78
+ return { "Cache-Control": input.pendingDynamicCheck ? "no-store, must-revalidate" : input.cacheControl };
79
+ }
80
+ buildResponseIdentityHeaders() {
81
+ const buildId = process.env.__VINEXT_RSC_BUILD_IDENTITY || process.env.__VINEXT_BUILD_ID;
82
+ return buildId ? { "X-Vinext-Build-Id": buildId } : {};
83
+ }
84
+ async revalidateTag(_tags, _durations) {}
85
+ };
86
+ function createStaticAssetsCacheAdapter({ env, options } = {}) {
87
+ if (process.env.VINEXT_PRERENDER === "1") return new DefaultCdnCacheAdapter(process.env.__VINEXT_RSC_BUILD_IDENTITY || process.env.__VINEXT_BUILD_ID);
88
+ const binding = options?.binding ?? "ASSETS";
89
+ const assets = env?.[binding];
90
+ if (!assets || typeof assets !== "object" || !("fetch" in assets) || typeof assets.fetch !== "function") throw new Error(`[vinext] Static Assets binding \`${binding}\` is not configured.`);
91
+ return new StaticAssetsCacheAdapter(assets);
92
+ }
93
+ //#endregion
94
+ export { StaticAssetsCacheAdapter, createStaticAssetsCacheAdapter as default };
@@ -0,0 +1,12 @@
1
+ import { CacheControlMetadata } from "vinext/shims/cache-handler";
2
+ //#region src/cache/static-assets-adapter.shared.d.ts
3
+ export declare const STATIC_ASSET_CACHE_PATH = "/_vinext/static-cache";
4
+ export type StaticAssetCacheMetadata = {
5
+ kind: "html" | "rsc" | "route" | "pages" | "redirect" | "not-found";
6
+ lastModified: number;
7
+ cacheControl?: CacheControlMetadata;
8
+ headers?: Record<string, string | string[]>;
9
+ status?: number;
10
+ };
11
+ export type StaticAssetCacheIndex = Record<string, StaticAssetCacheMetadata>;
12
+ //#endregion
@@ -0,0 +1,4 @@
1
+ //#region src/cache/static-assets-adapter.shared.ts
2
+ const STATIC_ASSET_CACHE_PATH = "/_vinext/static-cache";
3
+ //#endregion
4
+ export { STATIC_ASSET_CACHE_PATH };
@@ -0,0 +1,58 @@
1
+ //#region src/cache/workers-cache-cdn-adapter.d.ts
2
+ export declare const DEFAULT_CDN_VERSION_METADATA_BINDING = "CF_VERSION_METADATA";
3
+ /** Options accepted by {@link workersCacheCdnAdapter}, forwarded to the runtime factory. */
4
+ export type CdnAdapterOptions = {
5
+ /** Version metadata binding used to verify version-overridden requests. */
6
+ versionMetadataBinding?: string;
7
+ };
8
+ /**
9
+ * Cloudflare CDN cache adapter - edge-managed page-level ISR backed by the
10
+ * Cloudflare Workers Cache.
11
+ *
12
+ * Unlike the data adapter (which stores cache entries in a durable store and
13
+ * serves HIT/STALE itself), this adapter delegates serving to Workers Cache on
14
+ * a named Worker entrypoint. The default entrypoint always runs middleware and
15
+ * request-time routing, then renders bypass requests inline or dispatches shared
16
+ * requests to the cached response-stage entrypoint.
17
+ *
18
+ * The deployment configuration enables Workers Cache only for that response-stage
19
+ * export, so cache hits do not start the application stage. Legacy Cloudflare
20
+ * builds receive this policy in their generated Wrangler config. Cloudflare Vite
21
+ * plugin v2 builds declare the same entrypoint policy in `cloudflare.config.ts`.
22
+ * Deployment readiness probes still use the uncached response entrypoint to
23
+ * verify that named response entrypoints are available.
24
+ *
25
+ * The adapter adds a transport-only URL digest so distinct response-stage
26
+ * identities cannot collide. Workers Cache owns this key independently of
27
+ * zone Cache Rules.
28
+ */
29
+ export declare function workersCacheCdnAdapter(options?: CdnAdapterOptions): {
30
+ adapter: string;
31
+ options: CdnAdapterOptions | undefined;
32
+ output: {
33
+ entry: string;
34
+ matchesBuild({ plugins }: {
35
+ plugins: readonly {
36
+ name?: string;
37
+ }[];
38
+ }): boolean;
39
+ transformHostEntry({ code, id }: {
40
+ code: string;
41
+ id: string;
42
+ }): string | null;
43
+ finalizeBuildOutput({ root, outDir, isPrimaryServerOutput }: {
44
+ root: string;
45
+ outDir: string;
46
+ isPrimaryServerOutput: boolean;
47
+ }): Promise<void>;
48
+ type: "multi-stage";
49
+ };
50
+ capabilities: {
51
+ buildIdentity: "response-header";
52
+ isResponsePolicyHeader: (name: string) => boolean;
53
+ requestRouting: "uncached-stage";
54
+ responseVary: "verbatim";
55
+ routeCacheability: "probe-manifest";
56
+ };
57
+ };
58
+ //#endregion
@@ -0,0 +1,65 @@
1
+ import { finalizeCdnAdapterBuildOutput } from "./cdn-adapter-config.js";
2
+ import { fileURLToPath } from "node:url";
3
+ //#region src/cache/workers-cache-cdn-adapter.ts
4
+ const DEFAULT_CDN_VERSION_METADATA_BINDING = "CF_VERSION_METADATA";
5
+ const CLOUDFLARE_WORKER_ENTRY_ID = "virtual:cloudflare/worker-entry";
6
+ /**
7
+ * Cloudflare CDN cache adapter - edge-managed page-level ISR backed by the
8
+ * Cloudflare Workers Cache.
9
+ *
10
+ * Unlike the data adapter (which stores cache entries in a durable store and
11
+ * serves HIT/STALE itself), this adapter delegates serving to Workers Cache on
12
+ * a named Worker entrypoint. The default entrypoint always runs middleware and
13
+ * request-time routing, then renders bypass requests inline or dispatches shared
14
+ * requests to the cached response-stage entrypoint.
15
+ *
16
+ * The deployment configuration enables Workers Cache only for that response-stage
17
+ * export, so cache hits do not start the application stage. Legacy Cloudflare
18
+ * builds receive this policy in their generated Wrangler config. Cloudflare Vite
19
+ * plugin v2 builds declare the same entrypoint policy in `cloudflare.config.ts`.
20
+ * Deployment readiness probes still use the uncached response entrypoint to
21
+ * verify that named response entrypoints are available.
22
+ *
23
+ * The adapter adds a transport-only URL digest so distinct response-stage
24
+ * identities cannot collide. Workers Cache owns this key independently of
25
+ * zone Cache Rules.
26
+ */
27
+ function workersCacheCdnAdapter(options) {
28
+ if (options?.versionMetadataBinding !== void 0 && (typeof options.versionMetadataBinding !== "string" || options.versionMetadataBinding.length === 0)) throw new TypeError("[vinext] workersCacheCdnAdapter({ versionMetadataBinding }) must be a non-empty string binding name.");
29
+ const versionMetadataBinding = options?.versionMetadataBinding ?? "CF_VERSION_METADATA";
30
+ const bindingIsExplicit = options?.versionMetadataBinding !== void 0;
31
+ const workerEntry = fileURLToPath(import.meta.resolve("./cdn-adapter.worker.js"));
32
+ return {
33
+ adapter: fileURLToPath(import.meta.resolve("./cdn-adapter.runtime.js")),
34
+ options,
35
+ output: {
36
+ entry: workerEntry,
37
+ matchesBuild({ plugins }) {
38
+ return plugins.some(({ name }) => name === "vite-plugin-cloudflare" || name?.startsWith("vite-plugin-cloudflare:"));
39
+ },
40
+ transformHostEntry({ code, id }) {
41
+ if ((id.charCodeAt(0) === 0 ? id.slice(1) : id) !== CLOUDFLARE_WORKER_ENTRY_ID) return null;
42
+ return `${code}\nexport { VinextCachedResponse, VinextUncachedResponse } from ${JSON.stringify(workerEntry)};\n`;
43
+ },
44
+ finalizeBuildOutput({ root, outDir, isPrimaryServerOutput }) {
45
+ return finalizeCdnAdapterBuildOutput({
46
+ root,
47
+ outDir,
48
+ isPrimaryServerOutput,
49
+ binding: versionMetadataBinding,
50
+ bindingIsExplicit
51
+ });
52
+ },
53
+ type: "multi-stage"
54
+ },
55
+ capabilities: {
56
+ buildIdentity: "response-header",
57
+ isResponsePolicyHeader: (name) => name.toLowerCase() === "cdn-cache-control" || name.toLowerCase() === "cloudflare-cdn-cache-control",
58
+ requestRouting: "uncached-stage",
59
+ responseVary: "verbatim",
60
+ routeCacheability: "probe-manifest"
61
+ }
62
+ };
63
+ }
64
+ //#endregion
65
+ export { DEFAULT_CDN_VERSION_METADATA_BINDING, workersCacheCdnAdapter };
@@ -1,9 +1,15 @@
1
1
  import { CacheabilityManifest } from "vinext/internal/server/cacheability-manifest";
2
2
  //#region src/cacheability-artifact.d.ts
3
+ export type CloudflareDeploymentTool = "cf" | "wrangler";
3
4
  /**
4
5
  * Write the version-specific manifest into the built Worker artifact.
5
6
  * The application build already imports this stable module asset, so the
6
7
  * completed dist directory remains the exact input to the final upload.
8
+ *
9
+ * App Router builds also emit the request stage's projection module. It
10
+ * carries the App page routes that can admit a query-free entry, so the
11
+ * request stage can strip the query from those dispatches without loading the
12
+ * full manifest.
7
13
  */
8
- export declare function writeCacheabilityManifestArtifact(root: string, configuredPath: string | undefined, manifest: CacheabilityManifest): string;
14
+ export declare function writeCacheabilityManifestArtifact(root: string, configuredPath: string | undefined, manifest: CacheabilityManifest, deploymentTool?: CloudflareDeploymentTool): string;
9
15
  //#endregion
@@ -2,7 +2,7 @@ import { cacheabilityManifestByteLimitError } from "./cacheability-manifest-limi
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { Buffer } from "node:buffer";
5
- import { CACHEABILITY_MANIFEST_MODULE } from "vinext/internal/server/cacheability-manifest";
5
+ import { CACHEABILITY_MANIFEST_MODULE, CACHEABILITY_REQUEST_PROJECTION_MODULE, projectCacheabilityManifestForRequestStage } from "vinext/internal/server/cacheability-manifest";
6
6
  //#region src/cacheability-artifact.ts
7
7
  function tokenizeStaticImports(source) {
8
8
  const tokens = [];
@@ -115,16 +115,12 @@ function hasStaticModuleSpecifier(source, expected) {
115
115
  }
116
116
  return false;
117
117
  }
118
- function resolveGeneratedServerConfig(root, configuredPath) {
118
+ function resolveGeneratedWranglerArtifact(root, configuredPath) {
119
119
  const distDirectory = path.join(root, "dist");
120
120
  const configPath = path.resolve(root, configuredPath ?? "dist/server/wrangler.json");
121
121
  const relativeConfigPath = path.relative(distDirectory, configPath);
122
122
  if (relativeConfigPath.startsWith("..") || path.isAbsolute(relativeConfigPath)) throw new Error("Two-stage CDN warming requires a generated Wrangler config under dist/. Pass --config dist/server/wrangler.json (or another generated dist config).");
123
123
  if (!fs.existsSync(configPath) || !fs.lstatSync(configPath).isFile()) throw new Error(`Two-stage CDN warming requires the generated Wrangler config at ${path.relative(root, configPath)}. Rebuild the app before deploying.`);
124
- return configPath;
125
- }
126
- function assertManifestModuleReachable(configPath) {
127
- const serverDirectory = path.dirname(configPath);
128
124
  let config;
129
125
  try {
130
126
  config = JSON.parse(fs.readFileSync(configPath, "utf8"));
@@ -133,8 +129,36 @@ function assertManifestModuleReachable(configPath) {
133
129
  }
134
130
  const main = config && typeof config === "object" && !Array.isArray(config) ? config.main : void 0;
135
131
  if (typeof main !== "string" || main.length === 0) throw new Error("Two-stage CDN warming requires a generated Wrangler main module.");
132
+ return {
133
+ configPath,
134
+ main,
135
+ serverDirectory: path.dirname(configPath)
136
+ };
137
+ }
138
+ function resolveBuildOutputArtifact(root) {
139
+ const outputDirectory = path.join(root, ".cloudflare", "output");
140
+ const configPaths = fs.existsSync(outputDirectory) ? fs.readdirSync(outputDirectory, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => path.join(outputDirectory, entry.name, "workers", "default", "worker.config.json")).filter((candidate) => fs.existsSync(candidate) && fs.lstatSync(candidate).isFile()) : [];
141
+ if (configPaths.length !== 1) throw new Error("Two-stage CDN warming requires one default Worker Build Output config under .cloudflare/output/. Rebuild the app before deploying.");
142
+ const configPath = configPaths[0];
143
+ let config;
144
+ try {
145
+ config = JSON.parse(fs.readFileSync(configPath, "utf8"));
146
+ } catch (cause) {
147
+ throw new Error("Two-stage CDN warming could not parse the generated Build Output config.", { cause });
148
+ }
149
+ const manifest = config && typeof config === "object" && !Array.isArray(config) ? config.manifest : void 0;
150
+ const main = manifest && typeof manifest === "object" && !Array.isArray(manifest) ? manifest.mainModule : void 0;
151
+ if (typeof main !== "string" || main.length === 0) throw new Error("Two-stage CDN warming requires a generated Build Output main module.");
152
+ return {
153
+ configPath,
154
+ main,
155
+ serverDirectory: path.join(path.dirname(configPath), "bundle")
156
+ };
157
+ }
158
+ function assertModuleReachable(artifact, moduleName) {
159
+ const { main, serverDirectory } = artifact;
136
160
  const mainPath = path.resolve(serverDirectory, main);
137
- if (!fs.existsSync(mainPath) || !fs.lstatSync(mainPath).isFile()) throw new Error("Two-stage CDN warming could not find the generated Wrangler main module.");
161
+ if (!fs.existsSync(mainPath) || !fs.lstatSync(mainPath).isFile()) throw new Error("Two-stage CDN warming could not find the generated Worker main module.");
138
162
  const viteManifestPath = path.join(serverDirectory, ".vite", "manifest.json");
139
163
  let viteManifest;
140
164
  try {
@@ -155,36 +179,51 @@ function assertManifestModuleReachable(configPath) {
155
179
  if (!entry || typeof entry.file !== "string") continue;
156
180
  const modulePath = path.resolve(serverDirectory, entry.file);
157
181
  if (fs.existsSync(modulePath) && fs.lstatSync(modulePath).isFile()) {
158
- const relativeManifest = path.relative(path.dirname(modulePath), path.join(serverDirectory, CACHEABILITY_MANIFEST_MODULE)).split(path.sep).join("/");
159
- const specifier = relativeManifest.startsWith(".") ? relativeManifest : `./${relativeManifest}`;
182
+ const relativeModule = path.relative(path.dirname(modulePath), path.join(serverDirectory, moduleName)).split(path.sep).join("/");
183
+ const specifier = relativeModule.startsWith(".") ? relativeModule : `./${relativeModule}`;
160
184
  reachable = hasStaticModuleSpecifier(fs.readFileSync(modulePath, "utf8"), specifier);
161
185
  }
162
186
  for (const references of [entry.imports, entry.dynamicImports]) if (Array.isArray(references)) queue.push(...references.filter((value) => typeof value === "string"));
163
187
  }
164
- if (!reachable) throw new Error(`Two-stage CDN warming requires the generated Worker graph to statically import ${CACHEABILITY_MANIFEST_MODULE}.`);
188
+ if (!reachable) throw new Error(`Two-stage CDN warming requires the generated Worker graph to statically import ${moduleName}.`);
189
+ }
190
+ function writeStringModule(modulePath, value) {
191
+ const source = `export default ${JSON.stringify(value)};\n`;
192
+ const pendingPath = `${modulePath}.${process.pid}.tmp`;
193
+ try {
194
+ fs.writeFileSync(pendingPath, source, "utf8");
195
+ fs.renameSync(pendingPath, modulePath);
196
+ } finally {
197
+ if (fs.existsSync(pendingPath)) fs.unlinkSync(pendingPath);
198
+ }
165
199
  }
166
200
  /**
167
201
  * Write the version-specific manifest into the built Worker artifact.
168
202
  * The application build already imports this stable module asset, so the
169
203
  * completed dist directory remains the exact input to the final upload.
204
+ *
205
+ * App Router builds also emit the request stage's projection module. It
206
+ * carries the App page routes that can admit a query-free entry, so the
207
+ * request stage can strip the query from those dispatches without loading the
208
+ * full manifest.
170
209
  */
171
- function writeCacheabilityManifestArtifact(root, configuredPath, manifest) {
172
- const configPath = resolveGeneratedServerConfig(root, configuredPath);
173
- assertManifestModuleReachable(configPath);
174
- const serverDirectory = path.dirname(configPath);
210
+ function writeCacheabilityManifestArtifact(root, configuredPath, manifest, deploymentTool = "wrangler") {
211
+ const artifact = deploymentTool === "cf" ? resolveBuildOutputArtifact(root) : resolveGeneratedWranglerArtifact(root, configuredPath);
212
+ assertModuleReachable(artifact, CACHEABILITY_MANIFEST_MODULE);
213
+ const { configPath, serverDirectory } = artifact;
175
214
  const manifestPath = path.join(serverDirectory, CACHEABILITY_MANIFEST_MODULE);
176
215
  if (!fs.existsSync(manifestPath) || !fs.lstatSync(manifestPath).isFile()) throw new Error(`Two-stage CDN warming requires ${CACHEABILITY_MANIFEST_MODULE} in the generated Worker artifact. Rebuild the app before deploying.`);
177
216
  const serializedManifest = JSON.stringify(manifest);
178
217
  const manifestBytes = Buffer.byteLength(serializedManifest);
179
218
  if (manifestBytes > 2097152) throw cacheabilityManifestByteLimitError(manifestBytes);
180
- const manifestSource = `export default ${JSON.stringify(serializedManifest)};\n`;
181
- const pendingManifestPath = `${manifestPath}.${process.pid}.tmp`;
182
- try {
183
- fs.writeFileSync(pendingManifestPath, manifestSource, "utf8");
184
- fs.renameSync(pendingManifestPath, manifestPath);
185
- } finally {
186
- if (fs.existsSync(pendingManifestPath)) fs.unlinkSync(pendingManifestPath);
219
+ const projectionPath = path.join(serverDirectory, CACHEABILITY_REQUEST_PROJECTION_MODULE);
220
+ const hasProjectionModule = fs.existsSync(projectionPath) && fs.lstatSync(projectionPath).isFile();
221
+ if (Object.values(manifest.routes).some((route) => route.kind === "app-page")) {
222
+ if (!hasProjectionModule) throw new Error(`Two-stage CDN warming requires ${CACHEABILITY_REQUEST_PROJECTION_MODULE} in the generated Worker artifact. Rebuild the app before deploying.`);
223
+ assertModuleReachable(artifact, CACHEABILITY_REQUEST_PROJECTION_MODULE);
187
224
  }
225
+ writeStringModule(manifestPath, serializedManifest);
226
+ if (hasProjectionModule) writeStringModule(projectionPath, JSON.stringify(projectCacheabilityManifestForRequestStage(manifest)));
188
227
  return path.relative(root, configPath);
189
228
  }
190
229
  //#endregion
@@ -32,6 +32,8 @@ export declare function probeStagedWorkerCacheability(options: {
32
32
  expectedResponseBuildId?: string;
33
33
  fallbackRoutePatterns?: readonly PrerenderRoutePattern[];
34
34
  fetchImpl?: typeof fetch;
35
+ /** App page route patterns with a main-tree loading boundary. */
36
+ loadingBoundaryRoutePatterns?: readonly string[];
35
37
  headers?: HeadersInit;
36
38
  retries?: number;
37
39
  retryDelayMs?: number;
@@ -30,13 +30,19 @@ function sharedPathPrefix(pathnames) {
30
30
  }
31
31
  /** Remove a shared pathname prefix only when it makes the serialized route smaller. */
32
32
  function compactManifestRoutePaths(route) {
33
- const pathPrefix = sharedPathPrefix([...route.runtimePaths ?? [], ...Object.values(route.staticPaths ?? {}).flatMap((paths) => paths ?? [])]);
33
+ const pathPrefix = sharedPathPrefix([
34
+ ...route.runtimePaths ?? [],
35
+ ...Object.values(route.runtimeRepresentationPaths ?? {}).flatMap((paths) => paths ?? []),
36
+ ...Object.values(route.staticPaths ?? {}).flatMap((paths) => paths ?? [])
37
+ ]);
34
38
  if (!pathPrefix) return route;
39
+ const compactPathLists = (lists) => Object.fromEntries(Object.entries(lists).map(([representation, paths]) => [representation, paths.map((pathname) => pathname.slice(pathPrefix.length))]));
35
40
  const compacted = {
36
41
  ...route,
37
42
  pathPrefix,
38
43
  ...route.runtimePaths ? { runtimePaths: route.runtimePaths.map((pathname) => pathname.slice(pathPrefix.length)) } : {},
39
- ...route.staticPaths ? { staticPaths: Object.fromEntries(Object.entries(route.staticPaths).map(([representation, paths]) => [representation, paths.map((pathname) => pathname.slice(pathPrefix.length))])) } : {}
44
+ ...route.runtimeRepresentationPaths ? { runtimeRepresentationPaths: compactPathLists(route.runtimeRepresentationPaths) } : {},
45
+ ...route.staticPaths ? { staticPaths: compactPathLists(route.staticPaths) } : {}
40
46
  };
41
47
  return Buffer.byteLength(JSON.stringify(compacted)) < Buffer.byteLength(JSON.stringify(route)) ? compacted : route;
42
48
  }
@@ -261,6 +267,14 @@ async function probeStagedWorkerCacheability(options) {
261
267
  group.targets.push(target);
262
268
  targetGroups.set(concreteKey, group);
263
269
  }
270
+ const buildTimeListing = /* @__PURE__ */ new Map();
271
+ const isListedAt = (route, routePathname) => {
272
+ const key = cacheabilityManifestRouteKey(route.kind, route.pattern);
273
+ const listed = buildTimeListing.get(`${key}\0${routePathname}`);
274
+ if (listed !== void 0) return listed;
275
+ return !/(^|\/):/.test(route.pattern) && normalizeCacheabilityRoutePathname(route.pattern) === routePathname;
276
+ };
277
+ const isListedGroup = (group) => isListedAt(group.pattern.route, group.routePathname);
264
278
  const groups = Array.from(targetGroups.values(), (targetGroup) => {
265
279
  targetGroup.targets.sort((first, second) => {
266
280
  return targetPreference(first) - targetPreference(second) || first.sourcePathname.localeCompare(second.sourcePathname);
@@ -270,6 +284,7 @@ async function probeStagedWorkerCacheability(options) {
270
284
  deferred: false,
271
285
  primary: targetGroup.targets[0]
272
286
  };
287
+ buildTimeListing.set(`${group.pattern.key}\0${group.routePathname}`, group.primary.route.cacheabilityProbe?.unlisted !== true);
273
288
  targetGroup.pattern.groups.push(group);
274
289
  return group;
275
290
  });
@@ -391,12 +406,24 @@ async function probeStagedWorkerCacheability(options) {
391
406
  if (result.state === "probe-failed" && result.retryable === true && attemptOptions.deferRetry) return "retry";
392
407
  lastProgressAt = Date.now();
393
408
  probed += 1;
394
- if (result.version !== 1 || result.kind !== "app-page" && result.kind !== "app-route" && result.kind !== "pages-page" || typeof result.pattern !== "string" || !result.pattern.startsWith("/") || !isProbeRouteState(result.state) || result.scope !== void 0 && result.scope !== "identity" && result.scope !== "pattern" || result.scope === "pattern" && result.state !== "dynamic" || result.routePathname !== void 0 && (typeof result.routePathname !== "string" || !result.routePathname.startsWith("/") || result.routePathname.includes("?") || result.routePathname.includes("#")) || result.terminal !== void 0 && result.terminal !== true || result.terminal === true && (result.state !== "dynamic" || result.scope !== "identity" || !group.pattern.requestStageMayTerminate) || result.rendererStatic !== void 0 && typeof result.rendererStatic !== "boolean" || result.retryable !== void 0 && result.retryable !== true || result.retryable === true && result.state !== "probe-failed" || !Number.isInteger(result.status) || result.status < 100 || result.status > 599) {
409
+ if (result.version !== 1 || result.kind !== "app-page" && result.kind !== "app-route" && result.kind !== "pages-page" || typeof result.pattern !== "string" || !result.pattern.startsWith("/") || !isProbeRouteState(result.state) || result.scope !== void 0 && result.scope !== "identity" && result.scope !== "pattern" || result.scope === "pattern" && result.state !== "dynamic" || result.routePathname !== void 0 && (typeof result.routePathname !== "string" || !result.routePathname.startsWith("/") || result.routePathname.includes("?") || result.routePathname.includes("#")) || result.terminal !== void 0 && result.terminal !== true || result.terminal === true && (result.state !== "dynamic" || result.scope !== "identity" || !group.pattern.requestStageMayTerminate) || result.rendererStatic !== void 0 && typeof result.rendererStatic !== "boolean" || result.dynamicUsage !== void 0 && result.dynamicUsage !== true || result.explicitConfigCachePolicy !== void 0 && result.explicitConfigCachePolicy !== true || result.retryable !== void 0 && result.retryable !== true || result.retryable === true && result.state !== "probe-failed" || !Number.isInteger(result.status) || result.status < 100 || result.status > 599) {
395
410
  failures.push(`${target.label}: ${result.reason ?? "probe returned an invalid envelope"}`);
396
411
  completedPathCount += 1;
397
412
  reportProgress();
398
413
  return "done";
399
414
  }
415
+ if (result.state === "probe-failed" && result.kind === "app-page" && result.status >= 500 && result.reason === `route returned HTTP ${result.status}` && target.route && (result.kind === target.route.kind && result.pattern === target.route.pattern || target.route.cacheabilityProbe?.routeMayResolve === true && result.routePathname !== void 0) && !isListedAt({
416
+ kind: result.kind,
417
+ pattern: result.pattern
418
+ }, result.routePathname === void 0 ? group.routePathname : normalizeCacheabilityRoutePathname(result.routePathname))) {
419
+ if (target.route.cacheabilityProbe?.routeMayResolve === true && (result.kind !== target.route.kind || result.pattern !== target.route.pattern || result.routePathname !== void 0 && normalizeCacheabilityRoutePathname(result.routePathname) !== group.routePathname)) deferPairedRepresentationsAtOriginalRoute(group);
420
+ group.dropped = true;
421
+ if (!group.pattern.groups.some((candidate) => candidate !== group && candidate.resultKey === group.resultKey)) group.pattern.resultKeys.delete(group.resultKey);
422
+ skippedPathCount += 1;
423
+ completedPathCount += 1;
424
+ reportProgress();
425
+ return "done";
426
+ }
400
427
  if (result.state === "probe-failed") {
401
428
  failures.push(`${target.label}: ${result.reason ?? "probe failed"}`);
402
429
  completedPathCount += 1;
@@ -432,6 +459,8 @@ async function probeStagedWorkerCacheability(options) {
432
459
  });
433
460
  if (result.routePathname !== void 0) updateGroupRoutePathname(group, result.routePathname);
434
461
  const classification = {
462
+ dynamicUsage: result.dynamicUsage === true,
463
+ explicitConfigCachePolicy: result.explicitConfigCachePolicy === true,
435
464
  rendererStatic: result.rendererStatic === true,
436
465
  representation: target.kind,
437
466
  state: result.state,
@@ -439,7 +468,12 @@ async function probeStagedWorkerCacheability(options) {
439
468
  };
440
469
  group.result = classification;
441
470
  const previousClassification = group.pattern.results.get(group.resultKey);
442
- if (!previousClassification || previousClassification.state === "static-candidate" && classification.state === "dynamic" || previousClassification.state === classification.state && previousClassification.rendererStatic && !classification.rendererStatic) group.pattern.results.set(group.resultKey, classification);
471
+ const retainedClassification = !previousClassification || previousClassification.state === "static-candidate" && classification.state === "dynamic" || previousClassification.state === classification.state && previousClassification.rendererStatic && !classification.rendererStatic ? classification : previousClassification;
472
+ group.pattern.results.set(group.resultKey, {
473
+ ...retainedClassification,
474
+ dynamicUsage: classification.dynamicUsage || previousClassification?.dynamicUsage === true,
475
+ explicitConfigCachePolicy: classification.explicitConfigCachePolicy || previousClassification?.explicitConfigCachePolicy === true
476
+ });
443
477
  if (result.state === "dynamic" && result.scope === "pattern" && group.pattern.canPrune) {
444
478
  group.pattern.pruned = true;
445
479
  dynamicPathCount += 1;
@@ -543,6 +577,7 @@ async function probeStagedWorkerCacheability(options) {
543
577
  classified += 1;
544
578
  }
545
579
  if (limitFailure) throw limitFailure;
580
+ const loadingBoundaryRoutePatterns = new Set(options.loadingBoundaryRoutePatterns);
546
581
  for (const pattern of patterns.values()) {
547
582
  if (pattern.pruned) {
548
583
  classified += 1;
@@ -562,12 +597,47 @@ async function probeStagedWorkerCacheability(options) {
562
597
  }
563
598
  continue;
564
599
  }
565
- if (pattern.results.size === 0 && !pattern.groups.some((group) => group.deferred)) continue;
600
+ const hasStaticFallback = fallbackRoutes.has(pattern.key);
601
+ if (pattern.results.size === 0 && !pattern.groups.some((group) => group.deferred)) {
602
+ if (hasStaticFallback) {
603
+ const route = {
604
+ kind: pattern.route.kind,
605
+ pattern: pattern.route.pattern,
606
+ state: "static-candidate"
607
+ };
608
+ if (!addRouteWithinManifestLimits(pattern.key, route)) break;
609
+ classified += 1;
610
+ }
611
+ continue;
612
+ }
566
613
  classified += 1;
567
614
  if (Array.from(pattern.results.values()).some((result) => result.state === "dynamic")) dynamic += 1;
615
+ const isAppPage = pattern.route.kind === "app-page";
616
+ const listedResults = pattern.groups.flatMap((group) => {
617
+ const result = !group.deferred && !group.dropped && isListedGroup(group) ? pattern.results.get(group.resultKey) : void 0;
618
+ return result ? [result] : [];
619
+ });
620
+ const hasOnDemandIsr = hasStaticFallback || /(^|\/):/.test(pattern.route.pattern) && listedResults.length > 0 && !listedResults.some((result) => result.dynamicUsage);
621
+ const hasNoState = (group) => {
622
+ const result = pattern.results.get(group.resultKey);
623
+ if (!isAppPage || !result) return false;
624
+ if (result.state === "static-candidate" && !result.rendererStatic && result.explicitConfigCachePolicy) return false;
625
+ return result.dynamicUsage || !hasOnDemandIsr && !isListedGroup(group);
626
+ };
568
627
  const rendererStaticTargets = /* @__PURE__ */ new Map();
569
628
  const runtimePathSet = /* @__PURE__ */ new Set();
629
+ const loadingShellRuntimePathSet = /* @__PURE__ */ new Set();
570
630
  for (const group of pattern.groups) {
631
+ if (group.dropped) continue;
632
+ if (!group.deferred && hasNoState(group)) {
633
+ const loadingShellTargets = group.targets.filter((target) => target.kind === "rsc-loading-shell");
634
+ if (loadingShellTargets.length > 0 && pattern.results.get(group.resultKey)?.dynamicUsage && (hasOnDemandIsr || isListedGroup(group))) {
635
+ loadingShellRuntimePathSet.add(group.routePathname);
636
+ cacheableTargets.push(...loadingShellTargets);
637
+ speculativeTargets.push(...loadingShellTargets);
638
+ }
639
+ continue;
640
+ }
571
641
  if (group.deferred) {
572
642
  runtimePathSet.add(group.routePathname);
573
643
  cacheableTargets.push(...group.targets);
@@ -591,26 +661,48 @@ async function probeStagedWorkerCacheability(options) {
591
661
  speculativeTargets.push(...pairedTargets);
592
662
  }
593
663
  }
664
+ const staticRepresentations = (representation) => isAppPage && representation === "html" ? [
665
+ "html",
666
+ "rsc-full",
667
+ ...loadingBoundaryRoutePatterns.has(pattern.route.pattern) ? ["rsc-loading-shell"] : []
668
+ ] : [representation];
594
669
  const staticPaths = {};
595
670
  for (const [routePathname, staticTarget] of rendererStaticTargets) {
596
671
  if (runtimePathSet.has(routePathname)) continue;
597
- const paths = staticPaths[staticTarget.kind] ?? [];
598
- paths.push(routePathname);
599
- staticPaths[staticTarget.kind] = paths;
672
+ for (const representation of staticRepresentations(staticTarget.kind)) {
673
+ const paths = staticPaths[representation] ?? [];
674
+ paths.push(routePathname);
675
+ staticPaths[representation] = paths;
676
+ }
600
677
  }
601
678
  for (const paths of Object.values(staticPaths)) paths?.sort();
679
+ const loadingShellRuntimePaths = Array.from(loadingShellRuntimePathSet).filter((routePathname) => !runtimePathSet.has(routePathname) && !staticPaths["rsc-loading-shell"]?.includes(routePathname)).sort();
680
+ const runtimeRepresentationPaths = loadingShellRuntimePaths.length > 0 ? { "rsc-loading-shell": loadingShellRuntimePaths } : void 0;
602
681
  const allObservedPathsStaticallyGenerated = pattern.results.size === pattern.resultKeys.size && Array.from(pattern.results.values()).every((result) => result.state === "static-candidate") && Array.from(pattern.results.values()).every((result) => result.rendererStatic);
603
- const hasStaticFallback = fallbackRoutes.has(pattern.key);
682
+ const allowsUnknown = isAppPage ? hasOnDemandIsr : allObservedPathsStaticallyGenerated;
604
683
  const soleGroup = pattern.groups.length === 1 ? pattern.groups[0] : null;
605
684
  const literalPatternNamesSolePath = soleGroup !== null && !/(^|\/):/.test(pattern.route.pattern) && normalizeCacheabilityRoutePathname(pattern.route.pattern) === soleGroup.routePathname;
606
685
  let route;
607
- if (literalPatternNamesSolePath) {
686
+ if (literalPatternNamesSolePath && !soleGroup.deferred && hasNoState(soleGroup)) {
687
+ if (!runtimeRepresentationPaths) continue;
688
+ route = {
689
+ kind: pattern.route.kind,
690
+ pattern: pattern.route.pattern,
691
+ runtimeRepresentation: "rsc-loading-shell",
692
+ state: "runtime-check"
693
+ };
694
+ } else if (literalPatternNamesSolePath) {
608
695
  const result = pattern.results.get(soleGroup.resultKey);
609
696
  route = result?.state === "static-candidate" ? pattern.route.kind === "app-route" ? {
610
697
  kind: pattern.route.kind,
611
698
  pattern: pattern.route.pattern,
612
699
  state: "static-candidate"
613
- } : result.rendererStatic ? {
700
+ } : result.rendererStatic && isAppPage && result.representation === "html" ? compactManifestRoutePaths({
701
+ kind: pattern.route.kind,
702
+ pattern: pattern.route.pattern,
703
+ state: "runtime-check",
704
+ staticPaths: Object.fromEntries(staticRepresentations("html").map((representation) => [representation, [soleGroup.routePathname]]))
705
+ }) : result.rendererStatic ? {
614
706
  kind: pattern.route.kind,
615
707
  pattern: pattern.route.pattern,
616
708
  state: "runtime-check",
@@ -624,15 +716,17 @@ async function probeStagedWorkerCacheability(options) {
624
716
  pattern: pattern.route.pattern,
625
717
  state: "runtime-check"
626
718
  };
627
- } else route = compactManifestRoutePaths({
719
+ } else if (isAppPage && !hasOnDemandIsr && runtimePathSet.size === 0 && !runtimeRepresentationPaths && Object.keys(staticPaths).length === 0) continue;
720
+ else route = compactManifestRoutePaths({
628
721
  kind: pattern.route.kind,
629
722
  pattern: pattern.route.pattern,
630
723
  state: "runtime-check",
631
- ...hasStaticFallback || allObservedPathsStaticallyGenerated ? {
724
+ ...hasStaticFallback || allowsUnknown ? {
632
725
  allowUnknown: true,
633
726
  unknownState: "static-candidate"
634
727
  } : {},
635
728
  ...runtimePathSet.size > 0 ? { runtimePaths: Array.from(runtimePathSet).sort() } : {},
729
+ ...runtimeRepresentationPaths ? { runtimeRepresentationPaths } : {},
636
730
  ...Object.keys(staticPaths).length > 0 ? { staticPaths } : {}
637
731
  });
638
732
  if (!addRouteWithinManifestLimits(pattern.key, route)) break;
@@ -80,6 +80,7 @@ export type PrerenderWarmPlan = {
80
80
  buildIdentity?: string;
81
81
  deploymentId?: string;
82
82
  fallbackRoutePatterns?: PrerenderRoutePattern[];
83
+ loadingBoundaryRoutePatterns?: string[];
83
84
  loadingShellPaths: string[];
84
85
  pagesDataPaths?: string[];
85
86
  pagesPaths?: string[];