@vinext/cloudflare 1.0.0-beta.0 → 1.0.0-beta.10

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 (64) hide show
  1. package/README.md +94 -1
  2. package/dist/cache/cdn-adapter-config.d.ts +30 -0
  3. package/dist/cache/cdn-adapter-config.js +87 -0
  4. package/dist/cache/cdn-adapter.d.ts +47 -15
  5. package/dist/cache/cdn-adapter.js +49 -13
  6. package/dist/cache/cdn-adapter.runtime.d.ts +26 -4
  7. package/dist/cache/cdn-adapter.runtime.js +150 -37
  8. package/dist/cache/cdn-adapter.worker.d.ts +31 -0
  9. package/dist/cache/cdn-adapter.worker.js +373 -0
  10. package/dist/cache/cdn-build-id.d.ts +6 -0
  11. package/dist/cache/cdn-build-id.js +9 -0
  12. package/dist/cache/kv-data-adapter.d.ts +32 -8
  13. package/dist/cache/kv-data-adapter.js +5 -1
  14. package/dist/cache/kv-data-adapter.runtime.d.ts +45 -13
  15. package/dist/cache/kv-data-adapter.runtime.js +133 -53
  16. package/dist/cache/kv-key.d.ts +15 -0
  17. package/dist/cache/kv-key.js +43 -0
  18. package/dist/cache/response-store-adapter.d.ts +73 -0
  19. package/dist/cache/response-store-adapter.js +64 -0
  20. package/dist/cache/response-store-adapter.self-contained.worker.d.ts +18 -0
  21. package/dist/cache/response-store-adapter.self-contained.worker.js +12 -0
  22. package/dist/cache/response-store-adapter.service-binding.worker.d.ts +16 -0
  23. package/dist/cache/response-store-adapter.service-binding.worker.js +11 -0
  24. package/dist/cache/response-store-adapter.worker.d.ts +14 -0
  25. package/dist/cache/response-store-adapter.worker.js +278 -0
  26. package/dist/cache/response-store-cdn.runtime.d.ts +5 -0
  27. package/dist/cache/response-store-cdn.runtime.js +41 -0
  28. package/dist/cache/response-store-data.runtime.d.ts +34 -0
  29. package/dist/cache/response-store-data.runtime.js +300 -0
  30. package/dist/cacheability-artifact.d.ts +9 -0
  31. package/dist/cacheability-artifact.js +191 -0
  32. package/dist/cacheability-manifest-limits.d.ts +4 -0
  33. package/dist/cacheability-manifest-limits.js +7 -0
  34. package/dist/cacheability-probe.d.ts +49 -0
  35. package/dist/cacheability-probe.js +660 -0
  36. package/dist/cdn-warm.d.ts +110 -12
  37. package/dist/cdn-warm.js +880 -82
  38. package/dist/cli.d.ts +1 -1
  39. package/dist/cli.js +15 -1
  40. package/dist/deploy-config.d.ts +13 -11
  41. package/dist/deploy-config.js +21 -3
  42. package/dist/deploy-help.d.ts +3 -4
  43. package/dist/deploy-help.js +61 -18
  44. package/dist/deploy.d.ts +121 -41
  45. package/dist/deploy.js +787 -121
  46. package/dist/images/images-optimizer.d.ts +5 -5
  47. package/dist/images/images-optimizer.runtime.d.ts +1 -5
  48. package/dist/prerender-kv-populate.d.ts +3 -4
  49. package/dist/prerender-kv-populate.js +51 -18
  50. package/dist/tpr.d.ts +32 -70
  51. package/dist/tpr.js +45 -602
  52. package/dist/utils/cache-control-metadata.d.ts +3 -4
  53. package/dist/version-deploy.d.ts +19 -17
  54. package/dist/version-deploy.js +32 -13
  55. package/dist/version-headers.d.ts +2 -0
  56. package/dist/version-headers.js +2 -0
  57. package/dist/worker-deployment-url.d.ts +9 -0
  58. package/dist/worker-deployment-url.js +45 -0
  59. package/dist/workers-dev-url.d.ts +2 -3
  60. package/dist/wrangler-config.d.ts +19 -0
  61. package/dist/wrangler-config.js +294 -0
  62. package/dist/wrangler-version-metadata.d.ts +7 -0
  63. package/dist/wrangler-version-metadata.js +9 -0
  64. package/package.json +14 -10
package/README.md CHANGED
@@ -7,7 +7,13 @@ This package provides Cloudflare-specific cache and image backends for vinext:
7
7
 
8
8
  - **`kvDataAdapter()`** (`@vinext/cloudflare/cache/kv-data-adapter`) — backs the
9
9
  data cache (`fetch`, `"use cache"`, `unstable_cache`) with a Workers KV
10
- namespace. Also used for ISR in the absence of a CDN adapter.
10
+ namespace.
11
+ - **`cdnAdapter()`** (`@vinext/cloudflare/cache/cdn-adapter`) — delegates
12
+ page-level ISR serving and revalidation to Cloudflare Workers Cache through
13
+ an automatically generated cache-enabled response entrypoint.
14
+ - **`responseStoreAdapter()`** (`@vinext/cloudflare/cache/response-store-adapter`) —
15
+ uses Workers Response Store for both response and data caching, either in a
16
+ separate cache Worker or inside the application Worker.
11
17
  - **`imagesOptimizer()`** (`@vinext/cloudflare/images/images-optimizer`) — backs
12
18
  `next/image` transformations with a Cloudflare Images binding.
13
19
 
@@ -32,6 +38,93 @@ export default defineConfig({
32
38
  });
33
39
  ```
34
40
 
41
+ ### Workers Cache
42
+
43
+ `cdnAdapter()` is optional. Configuring it asks the Cloudflare build for two
44
+ Worker entrypoints: the default entrypoint runs middleware and request-time
45
+ routing with caching disabled, while `VinextCachedResponse` lazily loads the
46
+ render stage with Workers Cache enabled. These settings are written to the
47
+ generated `dist/server/wrangler.json`; do not enable Workers Cache on the
48
+ default entrypoint in your source config. The generated config also declares
49
+ the version metadata binding used for staged warmup.
50
+
51
+ ```ts
52
+ import { cdnAdapter } from "@vinext/cloudflare/cache/cdn-adapter";
53
+
54
+ vinext({ cache: { cdn: cdnAdapter() } });
55
+ ```
56
+
57
+ The generated version metadata binding lets staged warmup prove that every
58
+ discovery, probe, and fill request reached the uploaded Worker version. Pass
59
+ `versionMetadataBinding` to `cdnAdapter()` only when the deployment needs a
60
+ custom binding name.
61
+
62
+ Use `--experimental-warm-cdn-cache` for the two-stage deploy. The default flow
63
+ makes one final fill request per admitted identity. Add `--warm-cdn-certify`
64
+ only when you want an opt-in second, header-only request that must prove every
65
+ planned entry reusable before promotion.
66
+
67
+ The response entrypoint hashes the complete transport identity into its
68
+ Workers Cache URL, independently of zone Cache Rules, so distinct query and
69
+ representation variants cannot collide.
70
+
71
+ ### Workers Response Store
72
+
73
+ `responseStoreAdapter()` replaces both `cdnAdapter()` and `kvDataAdapter()`.
74
+ It defaults to a separate cache Worker reached through the `RESPONSE_STORE`
75
+ service binding. `vinext init` writes two collocated source configs:
76
+ `wrangler.jsonc` for the application and `wrangler.response-store.jsonc` for the
77
+ cache Worker. The latter points directly at the installed
78
+ `@cloudflare/workers-response-store` implementation and owns its R2 bucket,
79
+ SQLite Durable Object, Worker name, and cache settings. Edit those configs to
80
+ choose or reuse names, keeping the application service binding aligned with the
81
+ cache Worker name.
82
+
83
+ The two Workers are deliberately deployed separately. Deploy the Response Store
84
+ when its package or config changes, then deploy the application normally:
85
+
86
+ ```sh
87
+ npx wrangler deploy --config wrangler.response-store.jsonc
88
+ npx @vinext/cloudflare deploy
89
+ ```
90
+
91
+ `vinext-cloudflare deploy` never creates, rewrites, or deploys the Response
92
+ Store Worker.
93
+
94
+ Metadata sharding is opt-in and works in either deployment mode:
95
+
96
+ ```ts
97
+ vinext({ cache: responseStoreAdapter({ shards: 16 }) });
98
+ ```
99
+
100
+ Keys remain pinned to one shard while tag/path mutations fan out across every
101
+ shard. Omit `shards` to retain the original single metadata Durable Object.
102
+
103
+ To place newly created metadata Durable Objects near a stable traffic and R2
104
+ region, pass a Cloudflare location hint:
105
+
106
+ ```ts
107
+ vinext({ cache: responseStoreAdapter({ locationHint: "weur" }) });
108
+ ```
109
+
110
+ The hint works in both deployment modes, is best-effort, and only affects each
111
+ Durable Object's first creation. Changing it does not relocate existing
112
+ objects; treat the change as a cache-cold deployment and align it with the R2
113
+ bucket's location.
114
+
115
+ To deploy storage and cache entrypoints with the application instead, select
116
+ self-contained mode:
117
+
118
+ ```ts
119
+ import { responseStoreAdapter } from "@vinext/cloudflare/cache/response-store-adapter";
120
+
121
+ vinext({ cache: responseStoreAdapter({ mode: "self-contained" }) });
122
+ ```
123
+
124
+ In this mode `vinext init` places the required R2, SQLite Durable Object,
125
+ Workers Cache entrypoint, and version-metadata configuration in
126
+ `wrangler.jsonc`; no second Wrangler config is required.
127
+
35
128
  ## Deploy
36
129
 
37
130
  Deploy Cloudflare Workers projects with the package CLI:
@@ -0,0 +1,30 @@
1
+ //#region src/cache/cdn-adapter-config.d.ts
2
+ type WranglerWorkerExport = {
3
+ cache?: {
4
+ enabled: boolean;
5
+ };
6
+ type?: string;
7
+ [key: string]: unknown;
8
+ };
9
+ type WranglerOutputConfig = {
10
+ compatibility_date?: string;
11
+ compatibility_flags?: string[];
12
+ exports?: Record<string, WranglerWorkerExport>;
13
+ version_metadata?: unknown;
14
+ [key: string]: unknown;
15
+ };
16
+ type VersionMetadataOptions = {
17
+ binding: string;
18
+ bindingIsExplicit: boolean;
19
+ };
20
+ export declare function configureCdnVersionMetadata(config: WranglerOutputConfig, options: VersionMetadataOptions): WranglerOutputConfig;
21
+ /** Add the per-entrypoint Workers Cache policy to an emitted Wrangler config. */
22
+ export declare function configureWorkersCacheEntrypoints(config: WranglerOutputConfig): WranglerOutputConfig;
23
+ /** Apply the complete CDN adapter policy to the primary generated config. */
24
+ export declare function finalizeCdnAdapterBuildOutput({ outDir, isPrimaryServerOutput, binding, bindingIsExplicit }: {
25
+ outDir: string;
26
+ isPrimaryServerOutput: boolean;
27
+ binding: string;
28
+ bindingIsExplicit: boolean;
29
+ }): Promise<void>;
30
+ //#endregion
@@ -0,0 +1,87 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs/promises";
3
+ //#region src/cache/cdn-adapter-config.ts
4
+ const CACHED_RESPONSE_STAGE_EXPORT = "VinextCachedResponse";
5
+ const UNCACHED_RESPONSE_STAGE_EXPORT = "VinextUncachedResponse";
6
+ const CTX_EXPORTS_DEFAULT_DATE = "2025-11-17";
7
+ function isRecord(value) {
8
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9
+ }
10
+ function configureCdnVersionMetadata(config, options) {
11
+ if (!Object.hasOwn(config, "version_metadata")) return {
12
+ ...config,
13
+ version_metadata: { binding: options.binding }
14
+ };
15
+ if (!isRecord(config.version_metadata)) throw new Error("[vinext] The generated Wrangler config has an invalid version_metadata value.");
16
+ const existingBinding = config.version_metadata.binding;
17
+ if (typeof existingBinding !== "string" || existingBinding.length === 0) throw new Error("[vinext] The generated Wrangler config has an invalid version_metadata.binding value.");
18
+ if (existingBinding === options.binding) return config;
19
+ if (!options.bindingIsExplicit) throw new Error(`[vinext] cdnAdapter() uses the default version metadata binding ${JSON.stringify(options.binding)}, but the generated Wrangler config already declares ${JSON.stringify(existingBinding)}. Align the Wrangler binding or configure cdnAdapter({ versionMetadataBinding: ${JSON.stringify(existingBinding)} }).`);
20
+ return {
21
+ ...config,
22
+ version_metadata: { binding: options.binding }
23
+ };
24
+ }
25
+ /** Add the per-entrypoint Workers Cache policy to an emitted Wrangler config. */
26
+ function configureWorkersCacheEntrypoints(config) {
27
+ const configuredExports = config.exports ?? {};
28
+ const compatibilityFlags = config.compatibility_flags ?? [];
29
+ if (compatibilityFlags.includes("disable_ctx_exports")) throw new Error("[vinext] cdnAdapter() requires ctx.exports, but the generated Wrangler config explicitly disables it with disable_ctx_exports.");
30
+ const configuredCompatibilityFlags = config.compatibility_date === void 0 || config.compatibility_date < CTX_EXPORTS_DEFAULT_DATE ? compatibilityFlags.includes("enable_ctx_exports") ? compatibilityFlags : [...compatibilityFlags, "enable_ctx_exports"] : compatibilityFlags.filter((flag) => flag !== "enable_ctx_exports");
31
+ for (const name of [
32
+ "default",
33
+ CACHED_RESPONSE_STAGE_EXPORT,
34
+ UNCACHED_RESPONSE_STAGE_EXPORT
35
+ ]) {
36
+ const existing = configuredExports[name];
37
+ if (existing?.type !== void 0 && existing.type !== "worker") throw new Error(`[vinext] cdnAdapter() cannot configure the reserved Worker export ${JSON.stringify(name)} because it is already declared as ${JSON.stringify(existing.type)}.`);
38
+ }
39
+ return {
40
+ ...config,
41
+ compatibility_flags: configuredCompatibilityFlags,
42
+ exports: {
43
+ ...configuredExports,
44
+ default: {
45
+ ...configuredExports.default,
46
+ type: "worker",
47
+ cache: { enabled: false }
48
+ },
49
+ [CACHED_RESPONSE_STAGE_EXPORT]: {
50
+ ...configuredExports[CACHED_RESPONSE_STAGE_EXPORT],
51
+ type: "worker",
52
+ cache: { enabled: true }
53
+ },
54
+ [UNCACHED_RESPONSE_STAGE_EXPORT]: {
55
+ ...configuredExports[UNCACHED_RESPONSE_STAGE_EXPORT],
56
+ type: "worker",
57
+ cache: { enabled: false }
58
+ }
59
+ }
60
+ };
61
+ }
62
+ async function readGeneratedConfig(generatedConfigPath, allowMissing) {
63
+ try {
64
+ const parsed = JSON.parse(await fs.readFile(generatedConfigPath, "utf8"));
65
+ if (!isRecord(parsed)) throw new TypeError("the root value must be an object");
66
+ return parsed;
67
+ } catch (cause) {
68
+ if (allowMissing && cause !== null && typeof cause === "object" && "code" in cause && cause.code === "ENOENT") return null;
69
+ throw new Error(`[vinext] Could not read the generated Wrangler config at ${generatedConfigPath}.`, { cause });
70
+ }
71
+ }
72
+ async function writeGeneratedConfig(generatedConfigPath, config) {
73
+ await fs.writeFile(generatedConfigPath, `${JSON.stringify(config, null, 2)}\n`);
74
+ }
75
+ /** Apply the complete CDN adapter policy to the primary generated config. */
76
+ async function finalizeCdnAdapterBuildOutput({ outDir, isPrimaryServerOutput, binding, bindingIsExplicit }) {
77
+ if (!isPrimaryServerOutput) return;
78
+ const generatedConfigPath = path.resolve(outDir, "wrangler.json");
79
+ const generatedConfig = await readGeneratedConfig(generatedConfigPath, false);
80
+ if (!generatedConfig) return;
81
+ await writeGeneratedConfig(generatedConfigPath, configureWorkersCacheEntrypoints(configureCdnVersionMetadata(generatedConfig, {
82
+ binding,
83
+ bindingIsExplicit
84
+ })));
85
+ }
86
+ //#endregion
87
+ export { configureCdnVersionMetadata, configureWorkersCacheEntrypoints, finalizeCdnAdapterBuildOutput };
@@ -1,25 +1,57 @@
1
1
  //#region src/cache/cdn-adapter.d.ts
2
+ export declare const DEFAULT_CDN_VERSION_METADATA_BINDING = "CF_VERSION_METADATA";
3
+ /** Options accepted by {@link cdnAdapter}, forwarded to the runtime factory. */
4
+ export type CdnAdapterOptions = {
5
+ /** Version metadata binding used to verify version-overridden requests. */
6
+ versionMetadataBinding?: string;
7
+ };
2
8
  /**
3
9
  * Cloudflare CDN cache adapter - edge-managed page-level ISR backed by the
4
10
  * Cloudflare Workers Cache.
5
11
  *
6
12
  * Unlike the data adapter (which stores cache entries in a durable store and
7
- * serves HIT/STALE itself), this adapter delegates serving to Cloudflare's
8
- * edge cache.
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 before dispatching to cached or uncached response-stage
16
+ * entrypoints.
17
+ *
18
+ * The emitted Wrangler configuration enables Workers Cache only for that
19
+ * response-stage export, so cache hits do not start the application stage.
20
+ * The generated deployment config automatically enables Workers Cache for the
21
+ * cached response entrypoint and adds the version metadata binding used by
22
+ * warmup. The uncached response entrypoint keeps bypass and probe renders out
23
+ * of the gateway without enabling Workers Cache for them.
9
24
  *
10
- * Workers Cache must be enabled in your Wrangler config for this to work.
11
- * ```jsonc
12
- * // wrangler.jsonc
13
- * {
14
- * "cache": {
15
- * "enabled": true,
16
- * },
17
- * }
18
- * ```
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.
19
28
  */
20
- declare function cdnAdapter(options?: Record<string, never>): {
29
+ export declare function cdnAdapter(options?: CdnAdapterOptions): {
21
30
  adapter: string;
22
- options: Record<string, never> | undefined;
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({ outDir, isPrimaryServerOutput }: {
44
+ outDir: string;
45
+ isPrimaryServerOutput: boolean;
46
+ }): Promise<void>;
47
+ type: "multi-stage";
48
+ };
49
+ capabilities: {
50
+ buildIdentity: "response-header";
51
+ isResponsePolicyHeader: (name: string) => boolean;
52
+ requestRouting: "uncached-stage";
53
+ responseVary: "verbatim";
54
+ routeCacheability: "probe-manifest";
55
+ };
23
56
  };
24
- //#endregion
25
- export { cdnAdapter };
57
+ //#endregion
@@ -1,28 +1,64 @@
1
+ import { finalizeCdnAdapterBuildOutput } from "./cdn-adapter-config.js";
1
2
  import { fileURLToPath } from "node:url";
2
3
  //#region src/cache/cdn-adapter.ts
4
+ const DEFAULT_CDN_VERSION_METADATA_BINDING = "CF_VERSION_METADATA";
5
+ const CLOUDFLARE_WORKER_ENTRY_ID = "virtual:cloudflare/worker-entry";
3
6
  /**
4
7
  * Cloudflare CDN cache adapter - edge-managed page-level ISR backed by the
5
8
  * Cloudflare Workers Cache.
6
9
  *
7
10
  * Unlike the data adapter (which stores cache entries in a durable store and
8
- * serves HIT/STALE itself), this adapter delegates serving to Cloudflare's
9
- * edge cache.
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 before dispatching to cached or uncached response-stage
14
+ * entrypoints.
10
15
  *
11
- * Workers Cache must be enabled in your Wrangler config for this to work.
12
- * ```jsonc
13
- * // wrangler.jsonc
14
- * {
15
- * "cache": {
16
- * "enabled": true,
17
- * },
18
- * }
19
- * ```
16
+ * The emitted Wrangler configuration enables Workers Cache only for that
17
+ * response-stage export, so cache hits do not start the application stage.
18
+ * The generated deployment config automatically enables Workers Cache for the
19
+ * cached response entrypoint and adds the version metadata binding used by
20
+ * warmup. The uncached response entrypoint keeps bypass and probe renders out
21
+ * of the gateway without enabling Workers Cache for them.
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.
20
26
  */
21
27
  function cdnAdapter(options) {
28
+ if (options?.versionMetadataBinding !== void 0 && (typeof options.versionMetadataBinding !== "string" || options.versionMetadataBinding.length === 0)) throw new TypeError("[vinext] cdnAdapter({ 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"));
22
32
  return {
23
33
  adapter: fileURLToPath(import.meta.resolve("./cdn-adapter.runtime.js")),
24
- options
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({ outDir, isPrimaryServerOutput }) {
45
+ return finalizeCdnAdapterBuildOutput({
46
+ outDir,
47
+ isPrimaryServerOutput,
48
+ binding: versionMetadataBinding,
49
+ bindingIsExplicit
50
+ });
51
+ },
52
+ type: "multi-stage"
53
+ },
54
+ capabilities: {
55
+ buildIdentity: "response-header",
56
+ isResponsePolicyHeader: (name) => name.toLowerCase() === "cdn-cache-control" || name.toLowerCase() === "cloudflare-cdn-cache-control",
57
+ requestRouting: "uncached-stage",
58
+ responseVary: "verbatim",
59
+ routeCacheability: "probe-manifest"
60
+ }
25
61
  };
26
62
  }
27
63
  //#endregion
28
- export { cdnAdapter };
64
+ export { DEFAULT_CDN_VERSION_METADATA_BINDING, cdnAdapter };
@@ -1,8 +1,26 @@
1
1
  import { CdnCacheAdapter, CdnCacheableHeaderInput, CdnResponseHeaders } from "vinext/shims/cdn-cache";
2
2
  import { CacheHandlerValue, IncrementalCacheValue } from "vinext/shims/cache";
3
-
4
3
  //#region src/cache/cdn-adapter.runtime.d.ts
5
- declare class CloudflareCdnCacheAdapter implements CdnCacheAdapter {
4
+ type WorkerVersionMetadata = {
5
+ id: string;
6
+ };
7
+ type CdnAdapterOptions = {
8
+ versionMetadataBinding?: string;
9
+ };
10
+ /** Encode a case-sensitive Next.js tag into a fixed Workers Cache tag. */
11
+ export declare function encodeCloudflareCacheTag(tag: string): string;
12
+ export declare class CloudflareCdnCacheAdapter implements CdnCacheAdapter {
13
+ private readonly versionMetadata?;
14
+ private readonly versionMetadataBinding;
15
+ readonly requiresCompletedResponseAdmission = true;
16
+ readonly responseVary: "verbatim";
17
+ readonly responsePolicy: {
18
+ isHeader(name: string): boolean;
19
+ readCacheControl(headers: Headers): string | null;
20
+ hasExplicitNonCacheablePolicy(headers: Headers, baseline?: Headers): boolean;
21
+ };
22
+ constructor(versionMetadata?: WorkerVersionMetadata | undefined, versionMetadataBinding?: string);
23
+ validateRequest(request: Request): Response | null;
6
24
  readonly ownsBackgroundRevalidation = false;
7
25
  /**
8
26
  * The origin keeps no page store — return null so the request renders fresh.
@@ -11,12 +29,16 @@ declare class CloudflareCdnCacheAdapter implements CdnCacheAdapter {
11
29
  get(): Promise<CacheHandlerValue | null>;
12
30
  /** No-op: the platform caches the response via its headers, not an origin store. */
13
31
  set(_key: string, _data: IncrementalCacheValue | null, _ctx?: Record<string, unknown>): Promise<void>;
32
+ buildResponseIdentityHeaders(): CdnResponseHeaders;
14
33
  buildResponseHeaders(input: CdnCacheableHeaderInput): CdnResponseHeaders;
15
34
  /** Purge edge-cached responses by tag via the request context's `cache.purge`. */
16
35
  revalidateTag(tags: string | string[], _durations?: {
17
36
  expire?: number;
18
37
  }): Promise<void>;
19
38
  }
20
- declare const createCloudflareCdnCacheAdapter: () => CdnCacheAdapter;
39
+ declare const createCloudflareCdnCacheAdapter: ({ env, options }?: {
40
+ env?: Record<string, unknown>;
41
+ options?: CdnAdapterOptions;
42
+ }) => CdnCacheAdapter;
21
43
  //#endregion
22
- export { CloudflareCdnCacheAdapter, createCloudflareCdnCacheAdapter as default };
44
+ export { createCloudflareCdnCacheAdapter as default };
@@ -1,5 +1,80 @@
1
+ import { VINEXT_CDN_BUILD_ID_HEADER, getVinextCdnBuildIdentity } from "./cdn-build-id.js";
2
+ import { VINEXT_EXPECTED_WORKER_VERSION_HEADER } from "../version-headers.js";
3
+ import { isNonCacheableCacheControl } from "vinext/shims/cdn-cache";
4
+ import { fnv1a64 } from "vinext/internal/utils/hash";
1
5
  import { getRequestExecutionContext } from "vinext/shims/request-context";
2
6
  //#region src/cache/cdn-adapter.runtime.ts
7
+ /**
8
+ * Cloudflare CDN cache adapter — edge-managed page-level ISR backed by the
9
+ * Cloudflare Workers Cache (`ctx.cache`).
10
+ *
11
+ * Unlike the origin-managed default adapter (which stores rendered artifacts in
12
+ * the data cache and serves HIT/STALE itself), this adapter delegates serving
13
+ * to Cloudflare's edge:
14
+ *
15
+ * - The origin never serves from a store — `get` returns `null`, so any
16
+ * request that reaches the Worker renders fresh. The edge absorbs HIT/STALE
17
+ * traffic and revalidates in the background (the `UPDATING` cache status).
18
+ * - `set` is a no-op: the platform caches the *response* based on its
19
+ * cache headers, so there is nothing to persist at the origin.
20
+ * - `buildResponseHeaders` emits the SWR policy as `Cloudflare-CDN-Cache-Control`
21
+ * (`public, max-age=…, stale-while-revalidate=…`) so the edge caches and
22
+ * revalidates. The inner response's `Cache-Control` is
23
+ * `public, max-age=0, must-revalidate` so a browser never serves a stored copy
24
+ * without revalidating against the edge; the uncached gateway changes that to
25
+ * `private, max-age=0, must-revalidate` before public egress so personalized
26
+ * request-stage headers cannot enter another shared cache. A `Cache-Tag`
27
+ * header lets entries be purged by tag. Note the edge directive uses `max-age`
28
+ * (not `s-maxage`):
29
+ * the framework computes the policy with `s-maxage` for shared caches, but
30
+ * `Cloudflare-CDN-Cache-Control` is already CDN-scoped so `max-age` is the correct knob
31
+ * for the edge to honor max-age + stale-while-revalidate.
32
+ * - `revalidateTag` purges the edge via the request context's `cache.purge({ tags })`.
33
+ *
34
+ * Tags use fixed-size lowercase digests before emission and purge because
35
+ * Workers Cache tags are case-insensitive printable ASCII, while Next.js tags
36
+ * are case-sensitive arbitrary strings.
37
+ *
38
+ * The default export is the adapter factory the generated
39
+ * `virtual:vinext-cache-adapters` registration imports; configure it from
40
+ * vite.config via the {@link cdnAdapter} builder in `./cdn-adapter.ts` (which
41
+ * `require.resolve`s this file).
42
+ */
43
+ const DEFAULT_VERSION_METADATA_BINDING = "CF_VERSION_METADATA";
44
+ const WORKER_VERSION_OVERRIDE_HEADER = "Cloudflare-Workers-Version-Overrides";
45
+ function versionValidationFailure(message, status = 500) {
46
+ return Response.json({ error: message }, {
47
+ status,
48
+ headers: { "Cache-Control": "no-store" }
49
+ });
50
+ }
51
+ const CACHEABLE_EDGE_DIRECTIVE_RE = /(?:^|,)\s*(?:s-maxage|max-age)\s*=/i;
52
+ const EDGE_POLICY_HEADERS = ["CDN-Cache-Control", "Cloudflare-CDN-Cache-Control"];
53
+ function getBuildIdentityResponseHeader() {
54
+ const buildId = getVinextCdnBuildIdentity();
55
+ return buildId ? { [VINEXT_CDN_BUILD_ID_HEADER]: buildId } : {};
56
+ }
57
+ /** Remove every response header whose cache semantics are owned by Cloudflare. */
58
+ function clearCloudflareCdnResponseHeaders(cacheControl) {
59
+ return {
60
+ ...getBuildIdentityResponseHeader(),
61
+ "Cache-Control": cacheControl,
62
+ "CDN-Cache-Control": null,
63
+ "Cloudflare-CDN-Cache-Control": null,
64
+ "Cache-Tag": null
65
+ };
66
+ }
67
+ /** Interpret Cloudflare's edge policy before core applies a replacement policy. */
68
+ function hasExplicitCloudflareNonCacheableResponsePolicy(headers) {
69
+ const edgePolicies = EDGE_POLICY_HEADERS.map((name) => headers.get(name));
70
+ if (edgePolicies.some((value) => value && isNonCacheableCacheControl(value))) return true;
71
+ const hasCacheableEdgePolicy = edgePolicies.some((value) => value && CACHEABLE_EDGE_DIRECTIVE_RE.test(value));
72
+ const browserPolicy = headers.get("Cache-Control");
73
+ return Boolean(!hasCacheableEdgePolicy && browserPolicy && isNonCacheableCacheControl(browserPolicy));
74
+ }
75
+ function readCloudflareResponseCacheControl(headers) {
76
+ return headers.get("Cloudflare-CDN-Cache-Control") ?? headers.get("CDN-Cache-Control") ?? headers.get("Cache-Control");
77
+ }
3
78
  function getWorkersCache() {
4
79
  const cache = getRequestExecutionContext()?.cache;
5
80
  if (cache && typeof cache.purge === "function") return cache;
@@ -28,7 +103,7 @@ const UNBOUNDED_SWR_SECONDS = 31536e3;
28
103
  /**
29
104
  * Convert the framework's shared-cache policy into a CDN-scoped one:
30
105
  * `s-maxage=…` → `max-age=…` (the edge honors `max-age` inside
31
- * `CDN-Cache-Control`), give a value-less `stale-while-revalidate` an explicit
106
+ * `Cloudflare-CDN-Cache-Control`), give a value-less `stale-while-revalidate` an explicit
32
107
  * seconds value (Cloudflare ignores the bare directive), and ensure a leading
33
108
  * `public`.
34
109
  */
@@ -37,30 +112,61 @@ function toEdgeCacheControl(cacheControl) {
37
112
  return /\bpublic\b/.test(withMaxAge) ? withMaxAge : `public, ${withMaxAge}`;
38
113
  }
39
114
  /**
40
- * Cloudflare's `Cache-Tag` header budget is 16 KB total with each tag capped at
41
- * 1024 bytes. Keep a conservative ceiling so a page with a large tag set never
42
- * produces an oversized (silently-dropped) header.
115
+ * Cloudflare's `Cache-Tag` header budget is 16 KB total. Fixed-size digests let
116
+ * the full Next.js tag set fit without dropping valid long or Unicode tags.
43
117
  */
44
- const MAX_CACHE_TAG_BYTES = 8 * 1024;
45
- const MAX_SINGLE_TAG_BYTES = 1024;
118
+ const MAX_CACHE_TAG_BYTES = 16384;
119
+ const CACHE_TAG_PREFIX = "vinext-";
120
+ /** Encode a case-sensitive Next.js tag into a fixed Workers Cache tag. */
121
+ function encodeCloudflareCacheTag(tag) {
122
+ return `${CACHE_TAG_PREFIX}${fnv1a64(`0:${tag}`)}${fnv1a64(`1:${tag}`)}`;
123
+ }
46
124
  /**
47
- * Build a `Cache-Tag` header value from canonicalised tags. Tags containing a
48
- * comma (the header separator) or exceeding the per-tag size are skipped, and
49
- * the whole value is bounded to stay within Cloudflare's limit.
125
+ * Build a complete `Cache-Tag` header value from canonicalised tags. Returning
126
+ * null makes the response uncacheable rather than caching with incomplete
127
+ * invalidation metadata.
50
128
  */
51
129
  function formatCacheTag(tags) {
52
- const parts = [];
53
- let total = 0;
54
- for (const tag of tags) {
55
- if (!tag || tag.includes(",") || tag.length > MAX_SINGLE_TAG_BYTES) continue;
56
- const next = total + tag.length + (parts.length > 0 ? 1 : 0);
57
- if (next > MAX_CACHE_TAG_BYTES) break;
58
- parts.push(tag);
59
- total = next;
60
- }
61
- return parts.length > 0 ? parts.join(",") : null;
130
+ const value = tags.map(encodeCloudflareCacheTag).join(",");
131
+ return value && value.length <= MAX_CACHE_TAG_BYTES ? value : null;
62
132
  }
63
133
  var CloudflareCdnCacheAdapter = class {
134
+ versionMetadata;
135
+ versionMetadataBinding;
136
+ requiresCompletedResponseAdmission = true;
137
+ responseVary = "verbatim";
138
+ responsePolicy = {
139
+ isHeader(name) {
140
+ const normalized = name.toLowerCase();
141
+ return EDGE_POLICY_HEADERS.some((header) => header.toLowerCase() === normalized);
142
+ },
143
+ readCacheControl(headers) {
144
+ return readCloudflareResponseCacheControl(headers);
145
+ },
146
+ hasExplicitNonCacheablePolicy(headers, baseline) {
147
+ if (baseline) {
148
+ for (const name of EDGE_POLICY_HEADERS) {
149
+ const value = headers.get(name);
150
+ if (value !== baseline.get(name) && value !== null && isNonCacheableCacheControl(value)) return true;
151
+ }
152
+ const browserPolicy = headers.get("Cache-Control");
153
+ return Boolean(browserPolicy !== baseline.get("Cache-Control") && browserPolicy && isNonCacheableCacheControl(browserPolicy));
154
+ }
155
+ return hasExplicitCloudflareNonCacheableResponsePolicy(headers);
156
+ }
157
+ };
158
+ constructor(versionMetadata, versionMetadataBinding = DEFAULT_VERSION_METADATA_BINDING) {
159
+ this.versionMetadata = versionMetadata;
160
+ this.versionMetadataBinding = versionMetadataBinding;
161
+ }
162
+ validateRequest(request) {
163
+ const expectedVersionId = request.headers.get(VINEXT_EXPECTED_WORKER_VERSION_HEADER);
164
+ if (expectedVersionId === null) return null;
165
+ if (!request.headers.has(WORKER_VERSION_OVERRIDE_HEADER)) return versionValidationFailure(`received ${VINEXT_EXPECTED_WORKER_VERSION_HEADER} without ${WORKER_VERSION_OVERRIDE_HEADER}.`);
166
+ if (!this.versionMetadata) return versionValidationFailure(`Cloudflare CDN prewarming requires the \`${this.versionMetadataBinding}\` version metadata binding. Add "version_metadata": { "binding": "${this.versionMetadataBinding}" } to the active Wrangler environment. Named environments do not inherit this binding from the top level.`);
167
+ if (expectedVersionId !== this.versionMetadata.id) return versionValidationFailure(`Cloudflare invoked Worker version ${this.versionMetadata.id}, but vinext warmup expected ${expectedVersionId}.`, 503);
168
+ return null;
169
+ }
64
170
  ownsBackgroundRevalidation = false;
65
171
  /**
66
172
  * The origin keeps no page store — return null so the request renders fresh.
@@ -71,33 +177,40 @@ var CloudflareCdnCacheAdapter = class {
71
177
  }
72
178
  /** No-op: the platform caches the response via its headers, not an origin store. */
73
179
  async set(_key, _data, _ctx) {}
180
+ buildResponseIdentityHeaders() {
181
+ return getBuildIdentityResponseHeader();
182
+ }
74
183
  buildResponseHeaders(input) {
75
- if (!input.cacheControl) return { "Cache-Control": NO_STORE };
76
- if (/\b(?:no-store|no-cache|private)\b/.test(input.cacheControl)) return {
77
- "Cache-Control": input.cacheControl,
78
- "CDN-Cache-Control": null,
79
- "Cloudflare-CDN-Cache-Control": null,
80
- "Cache-Tag": null
81
- };
82
- const headers = {
184
+ if (input.pendingDynamicCheck) return clearCloudflareCdnResponseHeaders(NO_STORE);
185
+ if (!input.cacheControl) return clearCloudflareCdnResponseHeaders(NO_STORE);
186
+ if (isNonCacheableCacheControl(input.cacheControl)) return clearCloudflareCdnResponseHeaders(input.cacheControl);
187
+ const cacheTag = input.tags?.length ? formatCacheTag(input.tags) : null;
188
+ if (input.tags?.length && !cacheTag) return clearCloudflareCdnResponseHeaders(NO_STORE);
189
+ return {
190
+ ...getBuildIdentityResponseHeader(),
83
191
  "Cache-Control": BROWSER_REVALIDATE,
84
- "CDN-Cache-Control": toEdgeCacheControl(input.cacheControl)
192
+ "CDN-Cache-Control": null,
193
+ "Cloudflare-CDN-Cache-Control": toEdgeCacheControl(input.cacheControl),
194
+ "Cache-Tag": cacheTag
85
195
  };
86
- if (input.tags && input.tags.length > 0) {
87
- const cacheTag = formatCacheTag(input.tags);
88
- if (cacheTag) headers["Cache-Tag"] = cacheTag;
89
- }
90
- return headers;
91
196
  }
92
197
  /** Purge edge-cached responses by tag via the request context's `cache.purge`. */
93
198
  async revalidateTag(tags, _durations) {
94
199
  const cache = getWorkersCache();
95
200
  if (!cache) return;
96
- const tagList = (Array.isArray(tags) ? tags : [tags]).filter((t) => typeof t === "string" && t.length > 0);
201
+ const tagList = (Array.isArray(tags) ? tags : [tags]).filter((t) => typeof t === "string");
97
202
  if (tagList.length === 0) return;
98
- await cache.purge({ tags: tagList });
203
+ const result = await cache.purge({ tags: tagList.map(encodeCloudflareCacheTag) });
204
+ if (result?.success === false) {
205
+ const errors = result.errors.map(({ code, message }) => `${code}: ${message}`).join(", ");
206
+ throw new Error(`[vinext] Workers Cache purge failed${errors ? `: ${errors}` : ""}`);
207
+ }
99
208
  }
100
209
  };
101
- const createCloudflareCdnCacheAdapter = () => new CloudflareCdnCacheAdapter();
210
+ const createCloudflareCdnCacheAdapter = ({ env, options } = {}) => {
211
+ const binding = options?.versionMetadataBinding ?? DEFAULT_VERSION_METADATA_BINDING;
212
+ const candidate = env?.[binding];
213
+ return new CloudflareCdnCacheAdapter(candidate && typeof candidate === "object" && "id" in candidate && typeof candidate.id === "string" ? { id: candidate.id } : void 0, binding);
214
+ };
102
215
  //#endregion
103
- export { CloudflareCdnCacheAdapter, createCloudflareCdnCacheAdapter as default };
216
+ export { CloudflareCdnCacheAdapter, createCloudflareCdnCacheAdapter as default, encodeCloudflareCacheTag };