@vinext/cloudflare 1.0.0-beta.7 → 1.0.0-beta.8

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 (52) hide show
  1. package/README.md +55 -22
  2. package/dist/cache/cdn-adapter-config.d.ts +16 -8
  3. package/dist/cache/cdn-adapter-config.js +57 -17
  4. package/dist/cache/cdn-adapter.d.ts +25 -24
  5. package/dist/cache/cdn-adapter.js +24 -20
  6. package/dist/cache/cdn-adapter.runtime.d.ts +9 -3
  7. package/dist/cache/cdn-adapter.runtime.js +63 -40
  8. package/dist/cache/cdn-adapter.worker.d.ts +30 -0
  9. package/dist/cache/cdn-adapter.worker.js +371 -0
  10. package/dist/cache/cdn-build-id.d.ts +4 -3
  11. package/dist/cache/cdn-build-id.js +5 -1
  12. package/dist/cache/kv-data-adapter.d.ts +21 -5
  13. package/dist/cache/kv-data-adapter.runtime.d.ts +38 -4
  14. package/dist/cache/kv-data-adapter.runtime.js +115 -41
  15. package/dist/cache/kv-key.d.ts +4 -5
  16. package/dist/cache/response-store-adapter.d.ts +37 -0
  17. package/dist/cache/response-store-adapter.js +39 -0
  18. package/dist/cache/response-store-adapter.self-contained.worker.d.ts +17 -0
  19. package/dist/cache/response-store-adapter.self-contained.worker.js +10 -0
  20. package/dist/cache/response-store-adapter.service-binding.worker.d.ts +15 -0
  21. package/dist/cache/response-store-adapter.service-binding.worker.js +9 -0
  22. package/dist/cache/response-store-adapter.worker.d.ts +14 -0
  23. package/dist/cache/response-store-adapter.worker.js +254 -0
  24. package/dist/cache/response-store-cdn.runtime.d.ts +5 -0
  25. package/dist/cache/response-store-cdn.runtime.js +41 -0
  26. package/dist/cache/response-store-data.runtime.d.ts +34 -0
  27. package/dist/cache/response-store-data.runtime.js +289 -0
  28. package/dist/cacheability-artifact.d.ts +2 -3
  29. package/dist/cacheability-artifact.js +27 -1
  30. package/dist/cacheability-manifest-limits.d.ts +3 -4
  31. package/dist/cacheability-manifest-limits.js +1 -1
  32. package/dist/cacheability-probe.d.ts +8 -9
  33. package/dist/cacheability-probe.js +255 -91
  34. package/dist/cdn-warm.d.ts +31 -25
  35. package/dist/cdn-warm.js +208 -50
  36. package/dist/deploy-config.d.ts +11 -12
  37. package/dist/deploy-config.js +8 -3
  38. package/dist/deploy-help.d.ts +3 -4
  39. package/dist/deploy.d.ts +23 -24
  40. package/dist/deploy.js +44 -11
  41. package/dist/images/images-optimizer.d.ts +3 -4
  42. package/dist/prerender-kv-populate.d.ts +3 -4
  43. package/dist/prerender-kv-populate.js +3 -2
  44. package/dist/tpr.d.ts +9 -10
  45. package/dist/tpr.js +1 -1
  46. package/dist/utils/cache-control-metadata.d.ts +3 -4
  47. package/dist/version-deploy.d.ts +17 -17
  48. package/dist/version-deploy.js +6 -3
  49. package/dist/worker-deployment-url.d.ts +3 -4
  50. package/dist/workers-dev-url.d.ts +2 -3
  51. package/dist/wrangler-version-metadata.d.ts +2 -3
  52. package/package.json +13 -7
package/README.md CHANGED
@@ -9,8 +9,11 @@ This package provides Cloudflare-specific cache and image backends for vinext:
9
9
  data cache (`fetch`, `"use cache"`, `unstable_cache`) with a Workers KV
10
10
  namespace.
11
11
  - **`cdnAdapter()`** (`@vinext/cloudflare/cache/cdn-adapter`) — delegates
12
- page-level ISR serving and revalidation to Cloudflare Workers Cache. This is
13
- opt-in and requires Workers Cache to be enabled in Wrangler config.
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.
14
17
  - **`imagesOptimizer()`** (`@vinext/cloudflare/images/images-optimizer`) — backs
15
18
  `next/image` transformations with a Cloudflare Images binding.
16
19
 
@@ -37,19 +40,13 @@ export default defineConfig({
37
40
 
38
41
  ### Workers Cache
39
42
 
40
- `cdnAdapter()` is optional. Only configure it when Workers Cache is enabled in
41
- `wrangler.jsonc`:
42
-
43
- ```jsonc
44
- {
45
- "cache": {
46
- "enabled": true,
47
- },
48
- "version_metadata": {
49
- "binding": "CF_VERSION_METADATA",
50
- },
51
- }
52
- ```
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.
53
50
 
54
51
  ```ts
55
52
  import { cdnAdapter } from "@vinext/cloudflare/cache/cdn-adapter";
@@ -57,19 +54,55 @@ import { cdnAdapter } from "@vinext/cloudflare/cache/cdn-adapter";
57
54
  vinext({ cache: { cdn: cdnAdapter() } });
58
55
  ```
59
56
 
60
- The version metadata binding lets staged warmup prove that every discovery,
61
- probe, and fill request reached the uploaded Worker version. Wrangler named
62
- environments do not inherit `version_metadata`; repeat the binding inside every
63
- `env.<name>` used with CDN warming.
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.
64
61
 
65
62
  Use `--experimental-warm-cdn-cache` for the two-stage deploy. The default flow
66
63
  makes one final fill request per admitted identity. Add `--warm-cdn-certify`
67
64
  only when you want an opt-in second, header-only request that must prove every
68
65
  planned entry reusable before promotion.
69
66
 
70
- Do not configure a Cache Rule that ignores or normalizes query strings for
71
- these responses. The two-stage cacheability manifest authorizes the full
72
- pathname + query identity, but an edge HIT happens before Worker admission.
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
+ To deploy storage and cache entrypoints with the application instead, select
95
+ self-contained mode:
96
+
97
+ ```ts
98
+ import { responseStoreAdapter } from "@vinext/cloudflare/cache/response-store-adapter";
99
+
100
+ vinext({ cache: responseStoreAdapter({ mode: "self-contained" }) });
101
+ ```
102
+
103
+ In this mode `vinext init` places the required R2, SQLite Durable Object,
104
+ Workers Cache entrypoint, and version-metadata configuration in
105
+ `wrangler.jsonc`; no second Wrangler config is required.
73
106
 
74
107
  ## Deploy
75
108
 
@@ -1,5 +1,15 @@
1
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
+ };
2
9
  type WranglerOutputConfig = {
10
+ compatibility_date?: string;
11
+ compatibility_flags?: string[];
12
+ exports?: Record<string, WranglerWorkerExport>;
3
13
  version_metadata?: unknown;
4
14
  [key: string]: unknown;
5
15
  };
@@ -7,16 +17,14 @@ type VersionMetadataOptions = {
7
17
  binding: string;
8
18
  bindingIsExplicit: boolean;
9
19
  };
10
- declare function configureCdnVersionMetadata(config: WranglerOutputConfig, options: VersionMetadataOptions): WranglerOutputConfig;
11
- /**
12
- * Add the CDN adapter's version metadata binding to the Cloudflare Vite
13
- * plugin's primary generated deployment config.
14
- */
15
- declare function finalizeCdnAdapterBuildOutput({ outDir, isPrimaryServerOutput, binding, bindingIsExplicit }: {
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 }: {
16
25
  outDir: string;
17
26
  isPrimaryServerOutput: boolean;
18
27
  binding: string;
19
28
  bindingIsExplicit: boolean;
20
29
  }): Promise<void>;
21
- //#endregion
22
- export { configureCdnVersionMetadata, finalizeCdnAdapterBuildOutput };
30
+ //#endregion
@@ -1,6 +1,9 @@
1
1
  import path from "node:path";
2
2
  import fs from "node:fs/promises";
3
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";
4
7
  function isRecord(value) {
5
8
  return typeof value === "object" && value !== null && !Array.isArray(value);
6
9
  }
@@ -19,29 +22,66 @@ function configureCdnVersionMetadata(config, options) {
19
22
  version_metadata: { binding: options.binding }
20
23
  };
21
24
  }
22
- /**
23
- * Add the CDN adapter's version metadata binding to the Cloudflare Vite
24
- * plugin's primary generated deployment config.
25
- */
26
- async function finalizeCdnAdapterBuildOutput({ outDir, isPrimaryServerOutput, binding, bindingIsExplicit }) {
27
- if (!isPrimaryServerOutput) return;
28
- await configureGeneratedCdnVersionMetadata(path.resolve(outDir, "wrangler.json"), {
29
- binding,
30
- bindingIsExplicit
31
- });
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
+ };
32
61
  }
33
- async function configureGeneratedCdnVersionMetadata(generatedConfigPath, options) {
34
- let generatedConfig;
62
+ async function readGeneratedConfig(generatedConfigPath, allowMissing) {
35
63
  try {
36
64
  const parsed = JSON.parse(await fs.readFile(generatedConfigPath, "utf8"));
37
65
  if (!isRecord(parsed)) throw new TypeError("the root value must be an object");
38
- generatedConfig = parsed;
66
+ return parsed;
39
67
  } catch (cause) {
68
+ if (allowMissing && cause !== null && typeof cause === "object" && "code" in cause && cause.code === "ENOENT") return null;
40
69
  throw new Error(`[vinext] Could not read the generated Wrangler config at ${generatedConfigPath}.`, { cause });
41
70
  }
42
- const configured = configureCdnVersionMetadata(generatedConfig, options);
43
- if (configured === generatedConfig) return;
44
- await fs.writeFile(generatedConfigPath, `${JSON.stringify(configured, null, 2)}\n`);
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
+ })));
45
85
  }
46
86
  //#endregion
47
- export { configureCdnVersionMetadata, finalizeCdnAdapterBuildOutput };
87
+ export { configureCdnVersionMetadata, configureWorkersCacheEntrypoints, finalizeCdnAdapterBuildOutput };
@@ -1,7 +1,7 @@
1
1
  //#region src/cache/cdn-adapter.d.ts
2
- declare const DEFAULT_CDN_VERSION_METADATA_BINDING = "CF_VERSION_METADATA";
2
+ export declare const DEFAULT_CDN_VERSION_METADATA_BINDING = "CF_VERSION_METADATA";
3
3
  /** Options accepted by {@link cdnAdapter}, forwarded to the runtime factory. */
4
- type CdnAdapterOptions = {
4
+ export type CdnAdapterOptions = {
5
5
  /** Version metadata binding used to verify version-overridden requests. */
6
6
  versionMetadataBinding?: string;
7
7
  };
@@ -10,47 +10,48 @@ type CdnAdapterOptions = {
10
10
  * Cloudflare Workers Cache.
11
11
  *
12
12
  * Unlike the data adapter (which stores cache entries in a durable store and
13
- * serves HIT/STALE itself), this adapter delegates serving to Cloudflare's
14
- * 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.
15
17
  *
16
- * Workers Cache must be enabled in your Wrangler config for this to work.
17
- * ```jsonc
18
- * // wrangler.jsonc
19
- * {
20
- * "cache": {
21
- * "enabled": true,
22
- * },
23
- * "version_metadata": {
24
- * "binding": "CF_VERSION_METADATA"
25
- * }
26
- * }
27
- * ```
28
- * Wrangler does not inherit `version_metadata` into named environments. Repeat
29
- * the binding in every `env.<name>` used for CDN warmup.
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.
30
24
  *
31
- * Cache Rules must preserve the full query string in the incoming cache key.
32
- * Two-stage admission certifies exact pathname + search identities, while an
33
- * edge HIT is served before the Worker can reject a differently keyed request.
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.
34
28
  */
35
- declare function cdnAdapter(options?: CdnAdapterOptions): {
29
+ export declare function cdnAdapter(options?: CdnAdapterOptions): {
36
30
  adapter: string;
37
31
  options: CdnAdapterOptions | undefined;
38
32
  output: {
33
+ entry: string;
39
34
  matchesBuild({ plugins }: {
40
35
  plugins: readonly {
41
36
  name?: string;
42
37
  }[];
43
38
  }): boolean;
39
+ transformHostEntry({ code, id }: {
40
+ code: string;
41
+ id: string;
42
+ }): string | null;
44
43
  finalizeBuildOutput({ outDir, isPrimaryServerOutput }: {
45
44
  outDir: string;
46
45
  isPrimaryServerOutput: boolean;
47
46
  }): Promise<void>;
47
+ type: "multi-stage";
48
48
  };
49
49
  capabilities: {
50
50
  buildIdentity: "response-header";
51
+ isResponsePolicyHeader: (name: string) => boolean;
52
+ requestRouting: "uncached-stage";
51
53
  responseVary: "verbatim";
52
54
  routeCacheability: "probe-manifest";
53
55
  };
54
56
  };
55
- //#endregion
56
- export { CdnAdapterOptions, DEFAULT_CDN_VERSION_METADATA_BINDING, cdnAdapter };
57
+ //#endregion
@@ -2,44 +2,45 @@ import { finalizeCdnAdapterBuildOutput } from "./cdn-adapter-config.js";
2
2
  import { fileURLToPath } from "node:url";
3
3
  //#region src/cache/cdn-adapter.ts
4
4
  const DEFAULT_CDN_VERSION_METADATA_BINDING = "CF_VERSION_METADATA";
5
+ const CLOUDFLARE_WORKER_ENTRY_ID = "virtual:cloudflare/worker-entry";
5
6
  /**
6
7
  * Cloudflare CDN cache adapter - edge-managed page-level ISR backed by the
7
8
  * Cloudflare Workers Cache.
8
9
  *
9
10
  * Unlike the data adapter (which stores cache entries in a durable store and
10
- * serves HIT/STALE itself), this adapter delegates serving to Cloudflare's
11
- * 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.
12
15
  *
13
- * Workers Cache must be enabled in your Wrangler config for this to work.
14
- * ```jsonc
15
- * // wrangler.jsonc
16
- * {
17
- * "cache": {
18
- * "enabled": true,
19
- * },
20
- * "version_metadata": {
21
- * "binding": "CF_VERSION_METADATA"
22
- * }
23
- * }
24
- * ```
25
- * Wrangler does not inherit `version_metadata` into named environments. Repeat
26
- * the binding in every `env.<name>` used for CDN warmup.
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.
27
22
  *
28
- * Cache Rules must preserve the full query string in the incoming cache key.
29
- * Two-stage admission certifies exact pathname + search identities, while an
30
- * edge HIT is served before the Worker can reject a differently keyed request.
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.
31
26
  */
32
27
  function cdnAdapter(options) {
33
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.");
34
29
  const versionMetadataBinding = options?.versionMetadataBinding ?? "CF_VERSION_METADATA";
35
30
  const bindingIsExplicit = options?.versionMetadataBinding !== void 0;
31
+ const workerEntry = fileURLToPath(import.meta.resolve("./cdn-adapter.worker.js"));
36
32
  return {
37
33
  adapter: fileURLToPath(import.meta.resolve("./cdn-adapter.runtime.js")),
38
34
  options,
39
35
  output: {
36
+ entry: workerEntry,
40
37
  matchesBuild({ plugins }) {
41
38
  return plugins.some(({ name }) => name === "vite-plugin-cloudflare" || name?.startsWith("vite-plugin-cloudflare:"));
42
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
+ },
43
44
  finalizeBuildOutput({ outDir, isPrimaryServerOutput }) {
44
45
  return finalizeCdnAdapterBuildOutput({
45
46
  outDir,
@@ -47,10 +48,13 @@ function cdnAdapter(options) {
47
48
  binding: versionMetadataBinding,
48
49
  bindingIsExplicit
49
50
  });
50
- }
51
+ },
52
+ type: "multi-stage"
51
53
  },
52
54
  capabilities: {
53
55
  buildIdentity: "response-header",
56
+ isResponsePolicyHeader: (name) => name.toLowerCase() === "cdn-cache-control" || name.toLowerCase() === "cloudflare-cdn-cache-control",
57
+ requestRouting: "uncached-stage",
54
58
  responseVary: "verbatim",
55
59
  routeCacheability: "probe-manifest"
56
60
  }
@@ -7,11 +7,18 @@ type WorkerVersionMetadata = {
7
7
  type CdnAdapterOptions = {
8
8
  versionMetadataBinding?: string;
9
9
  };
10
- declare class CloudflareCdnCacheAdapter implements CdnCacheAdapter {
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 {
11
13
  private readonly versionMetadata?;
12
14
  private readonly versionMetadataBinding;
13
15
  readonly requiresCompletedResponseAdmission = true;
14
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
+ };
15
22
  constructor(versionMetadata?: WorkerVersionMetadata | undefined, versionMetadataBinding?: string);
16
23
  validateRequest(request: Request): Response | null;
17
24
  readonly ownsBackgroundRevalidation = false;
@@ -24,7 +31,6 @@ declare class CloudflareCdnCacheAdapter implements CdnCacheAdapter {
24
31
  set(_key: string, _data: IncrementalCacheValue | null, _ctx?: Record<string, unknown>): Promise<void>;
25
32
  buildResponseIdentityHeaders(): CdnResponseHeaders;
26
33
  buildResponseHeaders(input: CdnCacheableHeaderInput): CdnResponseHeaders;
27
- hasExplicitNonCacheableResponsePolicy(headers: Headers): boolean;
28
34
  /** Purge edge-cached responses by tag via the request context's `cache.purge`. */
29
35
  revalidateTag(tags: string | string[], _durations?: {
30
36
  expire?: number;
@@ -35,4 +41,4 @@ declare const createCloudflareCdnCacheAdapter: ({ env, options }?: {
35
41
  options?: CdnAdapterOptions;
36
42
  }) => CdnCacheAdapter;
37
43
  //#endregion
38
- export { CloudflareCdnCacheAdapter, createCloudflareCdnCacheAdapter as default };
44
+ export { createCloudflareCdnCacheAdapter as default };
@@ -1,6 +1,7 @@
1
- import { VINEXT_CDN_BUILD_ID_HEADER } from "./cdn-build-id.js";
1
+ import { VINEXT_CDN_BUILD_ID_HEADER, getVinextCdnBuildIdentity } from "./cdn-build-id.js";
2
2
  import { VINEXT_EXPECTED_WORKER_VERSION_HEADER } from "../version-headers.js";
3
3
  import { isNonCacheableCacheControl } from "vinext/shims/cdn-cache";
4
+ import { fnv1a64 } from "vinext/internal/utils/hash";
4
5
  import { getRequestExecutionContext } from "vinext/shims/request-context";
5
6
  //#region src/cache/cdn-adapter.runtime.ts
6
7
  /**
@@ -16,22 +17,23 @@ import { getRequestExecutionContext } from "vinext/shims/request-context";
16
17
  * traffic and revalidates in the background (the `UPDATING` cache status).
17
18
  * - `set` is a no-op: the platform caches the *response* based on its
18
19
  * cache headers, so there is nothing to persist at the origin.
19
- * - `buildResponseHeaders` emits the SWR policy as `CDN-Cache-Control`
20
+ * - `buildResponseHeaders` emits the SWR policy as `Cloudflare-CDN-Cache-Control`
20
21
  * (`public, max-age=…, stale-while-revalidate=…`) so the edge caches and
21
- * revalidates, while the browser-facing `Cache-Control` is
22
+ * revalidates. The inner response's `Cache-Control` is
22
23
  * `public, max-age=0, must-revalidate` so a browser never serves a stored copy
23
- * without revalidating against the edge. A `Cache-Tag` header lets entries be
24
- * purged by tag. Note the edge directive uses `max-age` (not `s-maxage`):
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`):
25
29
  * the framework computes the policy with `s-maxage` for shared caches, but
26
- * `CDN-Cache-Control` is already CDN-scoped so `max-age` is the correct knob
30
+ * `Cloudflare-CDN-Cache-Control` is already CDN-scoped so `max-age` is the correct knob
27
31
  * for the edge to honor max-age + stale-while-revalidate.
28
32
  * - `revalidateTag` purges the edge via the request context's `cache.purge({ tags })`.
29
33
  *
30
- * Tag alignment: the tags emitted in `Cache-Tag` come from the page's render
31
- * tags (already canonicalised via `encodeCacheTag`), and the framework's
32
- * `revalidateTag` / `revalidatePath` pass the same canonical form to this
33
- * adapter's `revalidateTag`, so a purge targets exactly the responses that
34
- * carried the tag.
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.
35
37
  *
36
38
  * The default export is the adapter factory the generated
37
39
  * `virtual:vinext-cache-adapters` registration imports; configure it from
@@ -49,7 +51,7 @@ function versionValidationFailure(message, status = 500) {
49
51
  const CACHEABLE_EDGE_DIRECTIVE_RE = /(?:^|,)\s*(?:s-maxage|max-age)\s*=/i;
50
52
  const EDGE_POLICY_HEADERS = ["CDN-Cache-Control", "Cloudflare-CDN-Cache-Control"];
51
53
  function getBuildIdentityResponseHeader() {
52
- const buildId = process.env.__VINEXT_RSC_BUILD_IDENTITY ?? process.env.__VINEXT_BUILD_ID;
54
+ const buildId = getVinextCdnBuildIdentity();
53
55
  return buildId ? { [VINEXT_CDN_BUILD_ID_HEADER]: buildId } : {};
54
56
  }
55
57
  /** Remove every response header whose cache semantics are owned by Cloudflare. */
@@ -70,6 +72,9 @@ function hasExplicitCloudflareNonCacheableResponsePolicy(headers) {
70
72
  const browserPolicy = headers.get("Cache-Control");
71
73
  return Boolean(!hasCacheableEdgePolicy && browserPolicy && isNonCacheableCacheControl(browserPolicy));
72
74
  }
75
+ function readCloudflareResponseCacheControl(headers) {
76
+ return headers.get("Cloudflare-CDN-Cache-Control") ?? headers.get("CDN-Cache-Control") ?? headers.get("Cache-Control");
77
+ }
73
78
  function getWorkersCache() {
74
79
  const cache = getRequestExecutionContext()?.cache;
75
80
  if (cache && typeof cache.purge === "function") return cache;
@@ -98,7 +103,7 @@ const UNBOUNDED_SWR_SECONDS = 31536e3;
98
103
  /**
99
104
  * Convert the framework's shared-cache policy into a CDN-scoped one:
100
105
  * `s-maxage=…` → `max-age=…` (the edge honors `max-age` inside
101
- * `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
102
107
  * seconds value (Cloudflare ignores the bare directive), and ensure a leading
103
108
  * `public`.
104
109
  */
@@ -107,34 +112,49 @@ function toEdgeCacheControl(cacheControl) {
107
112
  return /\bpublic\b/.test(withMaxAge) ? withMaxAge : `public, ${withMaxAge}`;
108
113
  }
109
114
  /**
110
- * Cloudflare's `Cache-Tag` header budget is 16 KB total with each tag capped at
111
- * 1024 bytes. Keep a conservative ceiling so a page with a large tag set never
112
- * 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.
113
117
  */
114
- const MAX_CACHE_TAG_BYTES = 8 * 1024;
115
- 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
+ }
116
124
  /**
117
- * Build a `Cache-Tag` header value from canonicalised tags. Tags containing a
118
- * comma (the header separator) or exceeding the per-tag size are skipped, and
119
- * 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.
120
128
  */
121
129
  function formatCacheTag(tags) {
122
- const parts = [];
123
- let total = 0;
124
- for (const tag of tags) {
125
- if (!tag || tag.includes(",") || tag.length > MAX_SINGLE_TAG_BYTES) continue;
126
- const next = total + tag.length + (parts.length > 0 ? 1 : 0);
127
- if (next > MAX_CACHE_TAG_BYTES) break;
128
- parts.push(tag);
129
- total = next;
130
- }
131
- 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;
132
132
  }
133
133
  var CloudflareCdnCacheAdapter = class {
134
134
  versionMetadata;
135
135
  versionMetadataBinding;
136
136
  requiresCompletedResponseAdmission = true;
137
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
+ };
138
158
  constructor(versionMetadata, versionMetadataBinding = DEFAULT_VERSION_METADATA_BINDING) {
139
159
  this.versionMetadata = versionMetadata;
140
160
  this.versionMetadataBinding = versionMetadataBinding;
@@ -164,24 +184,27 @@ var CloudflareCdnCacheAdapter = class {
164
184
  if (input.pendingDynamicCheck) return clearCloudflareCdnResponseHeaders(NO_STORE);
165
185
  if (!input.cacheControl) return clearCloudflareCdnResponseHeaders(NO_STORE);
166
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);
167
189
  return {
168
190
  ...getBuildIdentityResponseHeader(),
169
191
  "Cache-Control": BROWSER_REVALIDATE,
170
- "CDN-Cache-Control": toEdgeCacheControl(input.cacheControl),
171
- "Cloudflare-CDN-Cache-Control": null,
172
- "Cache-Tag": input.tags ? formatCacheTag(input.tags) : null
192
+ "CDN-Cache-Control": null,
193
+ "Cloudflare-CDN-Cache-Control": toEdgeCacheControl(input.cacheControl),
194
+ "Cache-Tag": cacheTag
173
195
  };
174
196
  }
175
- hasExplicitNonCacheableResponsePolicy(headers) {
176
- return hasExplicitCloudflareNonCacheableResponsePolicy(headers);
177
- }
178
197
  /** Purge edge-cached responses by tag via the request context's `cache.purge`. */
179
198
  async revalidateTag(tags, _durations) {
180
199
  const cache = getWorkersCache();
181
200
  if (!cache) return;
182
- 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");
183
202
  if (tagList.length === 0) return;
184
- 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
+ }
185
208
  }
186
209
  };
187
210
  const createCloudflareCdnCacheAdapter = ({ env, options } = {}) => {
@@ -190,4 +213,4 @@ const createCloudflareCdnCacheAdapter = ({ env, options } = {}) => {
190
213
  return new CloudflareCdnCacheAdapter(candidate && typeof candidate === "object" && "id" in candidate && typeof candidate.id === "string" ? { id: candidate.id } : void 0, binding);
191
214
  };
192
215
  //#endregion
193
- export { CloudflareCdnCacheAdapter, createCloudflareCdnCacheAdapter as default };
216
+ export { CloudflareCdnCacheAdapter, createCloudflareCdnCacheAdapter as default, encodeCloudflareCacheTag };
@@ -0,0 +1,30 @@
1
+ import { WorkerEntrypoint } from "cloudflare:workers";
2
+ import { VinextAssetFetcher } from "vinext/server/multi-stage";
3
+ //#region src/cache/cdn-adapter.worker.d.ts
4
+ type CloudflareStageContext = {
5
+ assets?: VinextAssetFetcher;
6
+ cache?: unknown;
7
+ exports?: Record<string, unknown>;
8
+ props?: unknown;
9
+ hostRuntime?: "worker";
10
+ passThroughOnException?(): void;
11
+ waitUntil?(promise: Promise<unknown>): void;
12
+ };
13
+ type CachePurgeOptions = {
14
+ tags: string[];
15
+ };
16
+ /** Cache-bearing entrypoint. Workers Cache HITs bypass this class entirely. */
17
+ export declare class VinextCachedResponse extends WorkerEntrypoint<unknown, unknown> {
18
+ fetch(request: Request): Promise<Response>;
19
+ purge(options: CachePurgeOptions): Promise<unknown>;
20
+ }
21
+ /** Uncached response entrypoint. Bypass and probe renders execute only here. */
22
+ export declare class VinextUncachedResponse extends WorkerEntrypoint<unknown, unknown> {
23
+ fetch(request: Request): Promise<Response>;
24
+ }
25
+ /** Uncached gateway: request routing and middleware always execute here. */
26
+ declare const _default: {
27
+ fetch(request: Request, env: unknown, context: CloudflareStageContext | undefined): Promise<Response>;
28
+ };
29
+ //#endregion
30
+ export { _default as default };