@vinext/cloudflare 1.0.0-beta.7 → 1.0.0-beta.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -22
- package/dist/cache/cdn-adapter-config.d.ts +16 -8
- package/dist/cache/cdn-adapter-config.js +57 -17
- package/dist/cache/cdn-adapter.d.ts +25 -24
- package/dist/cache/cdn-adapter.js +24 -20
- package/dist/cache/cdn-adapter.runtime.d.ts +9 -3
- package/dist/cache/cdn-adapter.runtime.js +63 -40
- package/dist/cache/cdn-adapter.worker.d.ts +31 -0
- package/dist/cache/cdn-adapter.worker.js +373 -0
- package/dist/cache/cdn-build-id.d.ts +4 -3
- package/dist/cache/cdn-build-id.js +5 -1
- package/dist/cache/kv-data-adapter.d.ts +25 -5
- package/dist/cache/kv-data-adapter.js +5 -1
- package/dist/cache/kv-data-adapter.runtime.d.ts +38 -4
- package/dist/cache/kv-data-adapter.runtime.js +115 -41
- package/dist/cache/kv-key.d.ts +4 -5
- package/dist/cache/response-store-adapter.d.ts +70 -0
- package/dist/cache/response-store-adapter.js +45 -0
- package/dist/cache/response-store-adapter.self-contained.worker.d.ts +18 -0
- package/dist/cache/response-store-adapter.self-contained.worker.js +12 -0
- package/dist/cache/response-store-adapter.service-binding.worker.d.ts +16 -0
- package/dist/cache/response-store-adapter.service-binding.worker.js +11 -0
- package/dist/cache/response-store-adapter.worker.d.ts +14 -0
- package/dist/cache/response-store-adapter.worker.js +264 -0
- package/dist/cache/response-store-cdn.runtime.d.ts +5 -0
- package/dist/cache/response-store-cdn.runtime.js +41 -0
- package/dist/cache/response-store-data.runtime.d.ts +34 -0
- package/dist/cache/response-store-data.runtime.js +289 -0
- package/dist/cacheability-artifact.d.ts +2 -3
- package/dist/cacheability-artifact.js +27 -1
- package/dist/cacheability-manifest-limits.d.ts +3 -4
- package/dist/cacheability-manifest-limits.js +1 -1
- package/dist/cacheability-probe.d.ts +8 -9
- package/dist/cacheability-probe.js +255 -91
- package/dist/cdn-warm.d.ts +31 -25
- package/dist/cdn-warm.js +228 -54
- package/dist/deploy-config.d.ts +11 -12
- package/dist/deploy-config.js +8 -3
- package/dist/deploy-help.d.ts +3 -4
- package/dist/deploy-help.js +20 -14
- package/dist/deploy.d.ts +35 -28
- package/dist/deploy.js +204 -95
- package/dist/images/images-optimizer.d.ts +3 -4
- package/dist/prerender-kv-populate.d.ts +3 -4
- package/dist/prerender-kv-populate.js +3 -2
- package/dist/tpr.d.ts +26 -74
- package/dist/tpr.js +45 -603
- package/dist/utils/cache-control-metadata.d.ts +3 -4
- package/dist/version-deploy.d.ts +17 -17
- package/dist/version-deploy.js +10 -5
- package/dist/worker-deployment-url.d.ts +3 -4
- package/dist/workers-dev-url.d.ts +2 -3
- package/dist/wrangler-config.d.ts +19 -0
- package/dist/wrangler-config.js +294 -0
- package/dist/wrangler-version-metadata.d.ts +2 -3
- package/package.json +13 -11
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
|
|
13
|
-
|
|
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.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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,64 @@ 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
|
|
61
|
-
probe, and fill request reached the uploaded Worker version.
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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 deploy storage and cache entrypoints with the application instead, select
|
|
104
|
+
self-contained mode:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import { responseStoreAdapter } from "@vinext/cloudflare/cache/response-store-adapter";
|
|
108
|
+
|
|
109
|
+
vinext({ cache: responseStoreAdapter({ mode: "self-contained" }) });
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
In this mode `vinext init` places the required R2, SQLite Durable Object,
|
|
113
|
+
Workers Cache entrypoint, and version-metadata configuration in
|
|
114
|
+
`wrangler.jsonc`; no second Wrangler config is required.
|
|
73
115
|
|
|
74
116
|
## Deploy
|
|
75
117
|
|
|
@@ -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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
await fs.writeFile(generatedConfigPath, `${JSON.stringify(
|
|
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
|
|
14
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
|
11
|
-
*
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
24
|
-
*
|
|
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
|
-
*
|
|
31
|
-
* tags
|
|
32
|
-
*
|
|
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 =
|
|
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
|
|
111
|
-
*
|
|
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 =
|
|
115
|
-
const
|
|
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.
|
|
118
|
-
*
|
|
119
|
-
*
|
|
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
|
|
123
|
-
|
|
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":
|
|
171
|
-
"Cloudflare-CDN-Cache-Control":
|
|
172
|
-
"Cache-Tag":
|
|
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"
|
|
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 };
|