@vinext/cloudflare 1.0.0-beta.6 → 1.0.0-beta.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/dist/cache/cdn-adapter-config.d.ts +22 -0
- package/dist/cache/cdn-adapter-config.js +47 -0
- package/dist/cache/cdn-adapter.d.ts +34 -3
- package/dist/cache/cdn-adapter.js +34 -2
- package/dist/cache/cdn-adapter.runtime.d.ts +17 -1
- package/dist/cache/cdn-adapter.runtime.js +80 -5
- package/dist/cache/cdn-build-id.d.ts +5 -0
- package/dist/cache/cdn-build-id.js +5 -0
- package/dist/cache/kv-data-adapter.runtime.js +1 -1
- package/dist/cacheability-artifact.d.ts +10 -0
- package/dist/cacheability-artifact.js +165 -0
- package/dist/cacheability-manifest-limits.d.ts +5 -0
- package/dist/cacheability-manifest-limits.js +7 -0
- package/dist/cacheability-probe.d.ts +50 -0
- package/dist/cacheability-probe.js +496 -0
- package/dist/cdn-warm.d.ts +95 -2
- package/dist/cdn-warm.js +704 -80
- package/dist/cli.js +15 -1
- package/dist/deploy-config.d.ts +5 -1
- package/dist/deploy-config.js +15 -2
- package/dist/deploy-help.js +43 -6
- package/dist/deploy.d.ts +61 -6
- package/dist/deploy.js +631 -82
- package/dist/prerender-kv-populate.js +1 -1
- package/dist/tpr.js +2 -2
- package/dist/version-deploy.d.ts +12 -9
- package/dist/version-deploy.js +24 -10
- package/dist/version-headers.d.ts +2 -0
- package/dist/version-headers.js +2 -0
- package/dist/worker-deployment-url.d.ts +7 -1
- package/dist/worker-deployment-url.js +23 -1
- package/dist/wrangler-version-metadata.d.ts +8 -0
- package/dist/wrangler-version-metadata.js +9 -0
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -45,6 +45,9 @@ export default defineConfig({
|
|
|
45
45
|
"cache": {
|
|
46
46
|
"enabled": true,
|
|
47
47
|
},
|
|
48
|
+
"version_metadata": {
|
|
49
|
+
"binding": "CF_VERSION_METADATA",
|
|
50
|
+
},
|
|
48
51
|
}
|
|
49
52
|
```
|
|
50
53
|
|
|
@@ -54,6 +57,20 @@ import { cdnAdapter } from "@vinext/cloudflare/cache/cdn-adapter";
|
|
|
54
57
|
vinext({ cache: { cdn: cdnAdapter() } });
|
|
55
58
|
```
|
|
56
59
|
|
|
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.
|
|
64
|
+
|
|
65
|
+
Use `--experimental-warm-cdn-cache` for the two-stage deploy. The default flow
|
|
66
|
+
makes one final fill request per admitted identity. Add `--warm-cdn-certify`
|
|
67
|
+
only when you want an opt-in second, header-only request that must prove every
|
|
68
|
+
planned entry reusable before promotion.
|
|
69
|
+
|
|
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.
|
|
73
|
+
|
|
57
74
|
## Deploy
|
|
58
75
|
|
|
59
76
|
Deploy Cloudflare Workers projects with the package CLI:
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//#region src/cache/cdn-adapter-config.d.ts
|
|
2
|
+
type WranglerOutputConfig = {
|
|
3
|
+
version_metadata?: unknown;
|
|
4
|
+
[key: string]: unknown;
|
|
5
|
+
};
|
|
6
|
+
type VersionMetadataOptions = {
|
|
7
|
+
binding: string;
|
|
8
|
+
bindingIsExplicit: boolean;
|
|
9
|
+
};
|
|
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 }: {
|
|
16
|
+
outDir: string;
|
|
17
|
+
isPrimaryServerOutput: boolean;
|
|
18
|
+
binding: string;
|
|
19
|
+
bindingIsExplicit: boolean;
|
|
20
|
+
}): Promise<void>;
|
|
21
|
+
//#endregion
|
|
22
|
+
export { configureCdnVersionMetadata, finalizeCdnAdapterBuildOutput };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
//#region src/cache/cdn-adapter-config.ts
|
|
4
|
+
function isRecord(value) {
|
|
5
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
function configureCdnVersionMetadata(config, options) {
|
|
8
|
+
if (!Object.hasOwn(config, "version_metadata")) return {
|
|
9
|
+
...config,
|
|
10
|
+
version_metadata: { binding: options.binding }
|
|
11
|
+
};
|
|
12
|
+
if (!isRecord(config.version_metadata)) throw new Error("[vinext] The generated Wrangler config has an invalid version_metadata value.");
|
|
13
|
+
const existingBinding = config.version_metadata.binding;
|
|
14
|
+
if (typeof existingBinding !== "string" || existingBinding.length === 0) throw new Error("[vinext] The generated Wrangler config has an invalid version_metadata.binding value.");
|
|
15
|
+
if (existingBinding === options.binding) return config;
|
|
16
|
+
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)} }).`);
|
|
17
|
+
return {
|
|
18
|
+
...config,
|
|
19
|
+
version_metadata: { binding: options.binding }
|
|
20
|
+
};
|
|
21
|
+
}
|
|
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
|
+
});
|
|
32
|
+
}
|
|
33
|
+
async function configureGeneratedCdnVersionMetadata(generatedConfigPath, options) {
|
|
34
|
+
let generatedConfig;
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(await fs.readFile(generatedConfigPath, "utf8"));
|
|
37
|
+
if (!isRecord(parsed)) throw new TypeError("the root value must be an object");
|
|
38
|
+
generatedConfig = parsed;
|
|
39
|
+
} catch (cause) {
|
|
40
|
+
throw new Error(`[vinext] Could not read the generated Wrangler config at ${generatedConfigPath}.`, { cause });
|
|
41
|
+
}
|
|
42
|
+
const configured = configureCdnVersionMetadata(generatedConfig, options);
|
|
43
|
+
if (configured === generatedConfig) return;
|
|
44
|
+
await fs.writeFile(generatedConfigPath, `${JSON.stringify(configured, null, 2)}\n`);
|
|
45
|
+
}
|
|
46
|
+
//#endregion
|
|
47
|
+
export { configureCdnVersionMetadata, finalizeCdnAdapterBuildOutput };
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
//#region src/cache/cdn-adapter.d.ts
|
|
2
|
+
declare const DEFAULT_CDN_VERSION_METADATA_BINDING = "CF_VERSION_METADATA";
|
|
3
|
+
/** Options accepted by {@link cdnAdapter}, forwarded to the runtime factory. */
|
|
4
|
+
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.
|
|
@@ -14,12 +20,37 @@
|
|
|
14
20
|
* "cache": {
|
|
15
21
|
* "enabled": true,
|
|
16
22
|
* },
|
|
23
|
+
* "version_metadata": {
|
|
24
|
+
* "binding": "CF_VERSION_METADATA"
|
|
25
|
+
* }
|
|
17
26
|
* }
|
|
18
27
|
* ```
|
|
28
|
+
* Wrangler does not inherit `version_metadata` into named environments. Repeat
|
|
29
|
+
* the binding in every `env.<name>` used for CDN warmup.
|
|
30
|
+
*
|
|
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.
|
|
19
34
|
*/
|
|
20
|
-
declare function cdnAdapter(options?:
|
|
35
|
+
declare function cdnAdapter(options?: CdnAdapterOptions): {
|
|
21
36
|
adapter: string;
|
|
22
|
-
options:
|
|
37
|
+
options: CdnAdapterOptions | undefined;
|
|
38
|
+
output: {
|
|
39
|
+
matchesBuild({ plugins }: {
|
|
40
|
+
plugins: readonly {
|
|
41
|
+
name?: string;
|
|
42
|
+
}[];
|
|
43
|
+
}): boolean;
|
|
44
|
+
finalizeBuildOutput({ outDir, isPrimaryServerOutput }: {
|
|
45
|
+
outDir: string;
|
|
46
|
+
isPrimaryServerOutput: boolean;
|
|
47
|
+
}): Promise<void>;
|
|
48
|
+
};
|
|
49
|
+
capabilities: {
|
|
50
|
+
buildIdentity: "response-header";
|
|
51
|
+
responseVary: "verbatim";
|
|
52
|
+
routeCacheability: "probe-manifest";
|
|
53
|
+
};
|
|
23
54
|
};
|
|
24
55
|
//#endregion
|
|
25
|
-
export { cdnAdapter };
|
|
56
|
+
export { CdnAdapterOptions, DEFAULT_CDN_VERSION_METADATA_BINDING, cdnAdapter };
|
|
@@ -1,5 +1,7 @@
|
|
|
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";
|
|
3
5
|
/**
|
|
4
6
|
* Cloudflare CDN cache adapter - edge-managed page-level ISR backed by the
|
|
5
7
|
* Cloudflare Workers Cache.
|
|
@@ -15,14 +17,44 @@ import { fileURLToPath } from "node:url";
|
|
|
15
17
|
* "cache": {
|
|
16
18
|
* "enabled": true,
|
|
17
19
|
* },
|
|
20
|
+
* "version_metadata": {
|
|
21
|
+
* "binding": "CF_VERSION_METADATA"
|
|
22
|
+
* }
|
|
18
23
|
* }
|
|
19
24
|
* ```
|
|
25
|
+
* Wrangler does not inherit `version_metadata` into named environments. Repeat
|
|
26
|
+
* the binding in every `env.<name>` used for CDN warmup.
|
|
27
|
+
*
|
|
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.
|
|
20
31
|
*/
|
|
21
32
|
function cdnAdapter(options) {
|
|
33
|
+
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
|
+
const versionMetadataBinding = options?.versionMetadataBinding ?? "CF_VERSION_METADATA";
|
|
35
|
+
const bindingIsExplicit = options?.versionMetadataBinding !== void 0;
|
|
22
36
|
return {
|
|
23
37
|
adapter: fileURLToPath(import.meta.resolve("./cdn-adapter.runtime.js")),
|
|
24
|
-
options
|
|
38
|
+
options,
|
|
39
|
+
output: {
|
|
40
|
+
matchesBuild({ plugins }) {
|
|
41
|
+
return plugins.some(({ name }) => name === "vite-plugin-cloudflare" || name?.startsWith("vite-plugin-cloudflare:"));
|
|
42
|
+
},
|
|
43
|
+
finalizeBuildOutput({ outDir, isPrimaryServerOutput }) {
|
|
44
|
+
return finalizeCdnAdapterBuildOutput({
|
|
45
|
+
outDir,
|
|
46
|
+
isPrimaryServerOutput,
|
|
47
|
+
binding: versionMetadataBinding,
|
|
48
|
+
bindingIsExplicit
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
capabilities: {
|
|
53
|
+
buildIdentity: "response-header",
|
|
54
|
+
responseVary: "verbatim",
|
|
55
|
+
routeCacheability: "probe-manifest"
|
|
56
|
+
}
|
|
25
57
|
};
|
|
26
58
|
}
|
|
27
59
|
//#endregion
|
|
28
|
-
export { cdnAdapter };
|
|
60
|
+
export { DEFAULT_CDN_VERSION_METADATA_BINDING, cdnAdapter };
|
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import { CdnCacheAdapter, CdnCacheableHeaderInput, CdnResponseHeaders } from "vinext/shims/cdn-cache";
|
|
2
2
|
import { CacheHandlerValue, IncrementalCacheValue } from "vinext/shims/cache";
|
|
3
3
|
//#region src/cache/cdn-adapter.runtime.d.ts
|
|
4
|
+
type WorkerVersionMetadata = {
|
|
5
|
+
id: string;
|
|
6
|
+
};
|
|
7
|
+
type CdnAdapterOptions = {
|
|
8
|
+
versionMetadataBinding?: string;
|
|
9
|
+
};
|
|
4
10
|
declare class CloudflareCdnCacheAdapter implements CdnCacheAdapter {
|
|
11
|
+
private readonly versionMetadata?;
|
|
12
|
+
private readonly versionMetadataBinding;
|
|
13
|
+
readonly requiresCompletedResponseAdmission = true;
|
|
14
|
+
readonly responseVary: "verbatim";
|
|
15
|
+
constructor(versionMetadata?: WorkerVersionMetadata | undefined, versionMetadataBinding?: string);
|
|
16
|
+
validateRequest(request: Request): Response | null;
|
|
5
17
|
readonly ownsBackgroundRevalidation = false;
|
|
6
18
|
/**
|
|
7
19
|
* The origin keeps no page store — return null so the request renders fresh.
|
|
@@ -10,6 +22,7 @@ declare class CloudflareCdnCacheAdapter implements CdnCacheAdapter {
|
|
|
10
22
|
get(): Promise<CacheHandlerValue | null>;
|
|
11
23
|
/** No-op: the platform caches the response via its headers, not an origin store. */
|
|
12
24
|
set(_key: string, _data: IncrementalCacheValue | null, _ctx?: Record<string, unknown>): Promise<void>;
|
|
25
|
+
buildResponseIdentityHeaders(): CdnResponseHeaders;
|
|
13
26
|
buildResponseHeaders(input: CdnCacheableHeaderInput): CdnResponseHeaders;
|
|
14
27
|
hasExplicitNonCacheableResponsePolicy(headers: Headers): boolean;
|
|
15
28
|
/** Purge edge-cached responses by tag via the request context's `cache.purge`. */
|
|
@@ -17,6 +30,9 @@ declare class CloudflareCdnCacheAdapter implements CdnCacheAdapter {
|
|
|
17
30
|
expire?: number;
|
|
18
31
|
}): Promise<void>;
|
|
19
32
|
}
|
|
20
|
-
declare const createCloudflareCdnCacheAdapter: (
|
|
33
|
+
declare const createCloudflareCdnCacheAdapter: ({ env, options }?: {
|
|
34
|
+
env?: Record<string, unknown>;
|
|
35
|
+
options?: CdnAdapterOptions;
|
|
36
|
+
}) => CdnCacheAdapter;
|
|
21
37
|
//#endregion
|
|
22
38
|
export { CloudflareCdnCacheAdapter, createCloudflareCdnCacheAdapter as default };
|
|
@@ -1,11 +1,61 @@
|
|
|
1
|
+
import { VINEXT_CDN_BUILD_ID_HEADER } from "./cdn-build-id.js";
|
|
2
|
+
import { VINEXT_EXPECTED_WORKER_VERSION_HEADER } from "../version-headers.js";
|
|
3
|
+
import { isNonCacheableCacheControl } from "vinext/shims/cdn-cache";
|
|
1
4
|
import { getRequestExecutionContext } from "vinext/shims/request-context";
|
|
2
5
|
//#region src/cache/cdn-adapter.runtime.ts
|
|
3
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Cloudflare CDN cache adapter — edge-managed page-level ISR backed by the
|
|
8
|
+
* Cloudflare Workers Cache (`ctx.cache`).
|
|
9
|
+
*
|
|
10
|
+
* Unlike the origin-managed default adapter (which stores rendered artifacts in
|
|
11
|
+
* the data cache and serves HIT/STALE itself), this adapter delegates serving
|
|
12
|
+
* to Cloudflare's edge:
|
|
13
|
+
*
|
|
14
|
+
* - The origin never serves from a store — `get` returns `null`, so any
|
|
15
|
+
* request that reaches the Worker renders fresh. The edge absorbs HIT/STALE
|
|
16
|
+
* traffic and revalidates in the background (the `UPDATING` cache status).
|
|
17
|
+
* - `set` is a no-op: the platform caches the *response* based on its
|
|
18
|
+
* cache headers, so there is nothing to persist at the origin.
|
|
19
|
+
* - `buildResponseHeaders` emits the SWR policy as `CDN-Cache-Control`
|
|
20
|
+
* (`public, max-age=…, stale-while-revalidate=…`) so the edge caches and
|
|
21
|
+
* revalidates, while the browser-facing `Cache-Control` is
|
|
22
|
+
* `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`):
|
|
25
|
+
* 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
|
|
27
|
+
* for the edge to honor max-age + stale-while-revalidate.
|
|
28
|
+
* - `revalidateTag` purges the edge via the request context's `cache.purge({ tags })`.
|
|
29
|
+
*
|
|
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.
|
|
35
|
+
*
|
|
36
|
+
* The default export is the adapter factory the generated
|
|
37
|
+
* `virtual:vinext-cache-adapters` registration imports; configure it from
|
|
38
|
+
* vite.config via the {@link cdnAdapter} builder in `./cdn-adapter.ts` (which
|
|
39
|
+
* `require.resolve`s this file).
|
|
40
|
+
*/
|
|
41
|
+
const DEFAULT_VERSION_METADATA_BINDING = "CF_VERSION_METADATA";
|
|
42
|
+
const WORKER_VERSION_OVERRIDE_HEADER = "Cloudflare-Workers-Version-Overrides";
|
|
43
|
+
function versionValidationFailure(message, status = 500) {
|
|
44
|
+
return Response.json({ error: message }, {
|
|
45
|
+
status,
|
|
46
|
+
headers: { "Cache-Control": "no-store" }
|
|
47
|
+
});
|
|
48
|
+
}
|
|
4
49
|
const CACHEABLE_EDGE_DIRECTIVE_RE = /(?:^|,)\s*(?:s-maxage|max-age)\s*=/i;
|
|
5
50
|
const EDGE_POLICY_HEADERS = ["CDN-Cache-Control", "Cloudflare-CDN-Cache-Control"];
|
|
51
|
+
function getBuildIdentityResponseHeader() {
|
|
52
|
+
const buildId = process.env.__VINEXT_RSC_BUILD_IDENTITY ?? process.env.__VINEXT_BUILD_ID;
|
|
53
|
+
return buildId ? { [VINEXT_CDN_BUILD_ID_HEADER]: buildId } : {};
|
|
54
|
+
}
|
|
6
55
|
/** Remove every response header whose cache semantics are owned by Cloudflare. */
|
|
7
56
|
function clearCloudflareCdnResponseHeaders(cacheControl) {
|
|
8
57
|
return {
|
|
58
|
+
...getBuildIdentityResponseHeader(),
|
|
9
59
|
"Cache-Control": cacheControl,
|
|
10
60
|
"CDN-Cache-Control": null,
|
|
11
61
|
"Cloudflare-CDN-Cache-Control": null,
|
|
@@ -15,10 +65,10 @@ function clearCloudflareCdnResponseHeaders(cacheControl) {
|
|
|
15
65
|
/** Interpret Cloudflare's edge policy before core applies a replacement policy. */
|
|
16
66
|
function hasExplicitCloudflareNonCacheableResponsePolicy(headers) {
|
|
17
67
|
const edgePolicies = EDGE_POLICY_HEADERS.map((name) => headers.get(name));
|
|
18
|
-
if (edgePolicies.some((value) => value &&
|
|
68
|
+
if (edgePolicies.some((value) => value && isNonCacheableCacheControl(value))) return true;
|
|
19
69
|
const hasCacheableEdgePolicy = edgePolicies.some((value) => value && CACHEABLE_EDGE_DIRECTIVE_RE.test(value));
|
|
20
70
|
const browserPolicy = headers.get("Cache-Control");
|
|
21
|
-
return Boolean(!hasCacheableEdgePolicy && browserPolicy &&
|
|
71
|
+
return Boolean(!hasCacheableEdgePolicy && browserPolicy && isNonCacheableCacheControl(browserPolicy));
|
|
22
72
|
}
|
|
23
73
|
function getWorkersCache() {
|
|
24
74
|
const cache = getRequestExecutionContext()?.cache;
|
|
@@ -81,6 +131,22 @@ function formatCacheTag(tags) {
|
|
|
81
131
|
return parts.length > 0 ? parts.join(",") : null;
|
|
82
132
|
}
|
|
83
133
|
var CloudflareCdnCacheAdapter = class {
|
|
134
|
+
versionMetadata;
|
|
135
|
+
versionMetadataBinding;
|
|
136
|
+
requiresCompletedResponseAdmission = true;
|
|
137
|
+
responseVary = "verbatim";
|
|
138
|
+
constructor(versionMetadata, versionMetadataBinding = DEFAULT_VERSION_METADATA_BINDING) {
|
|
139
|
+
this.versionMetadata = versionMetadata;
|
|
140
|
+
this.versionMetadataBinding = versionMetadataBinding;
|
|
141
|
+
}
|
|
142
|
+
validateRequest(request) {
|
|
143
|
+
const expectedVersionId = request.headers.get(VINEXT_EXPECTED_WORKER_VERSION_HEADER);
|
|
144
|
+
if (expectedVersionId === null) return null;
|
|
145
|
+
if (!request.headers.has(WORKER_VERSION_OVERRIDE_HEADER)) return versionValidationFailure(`received ${VINEXT_EXPECTED_WORKER_VERSION_HEADER} without ${WORKER_VERSION_OVERRIDE_HEADER}.`);
|
|
146
|
+
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.`);
|
|
147
|
+
if (expectedVersionId !== this.versionMetadata.id) return versionValidationFailure(`Cloudflare invoked Worker version ${this.versionMetadata.id}, but vinext warmup expected ${expectedVersionId}.`, 503);
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
84
150
|
ownsBackgroundRevalidation = false;
|
|
85
151
|
/**
|
|
86
152
|
* The origin keeps no page store — return null so the request renders fresh.
|
|
@@ -91,10 +157,15 @@ var CloudflareCdnCacheAdapter = class {
|
|
|
91
157
|
}
|
|
92
158
|
/** No-op: the platform caches the response via its headers, not an origin store. */
|
|
93
159
|
async set(_key, _data, _ctx) {}
|
|
160
|
+
buildResponseIdentityHeaders() {
|
|
161
|
+
return getBuildIdentityResponseHeader();
|
|
162
|
+
}
|
|
94
163
|
buildResponseHeaders(input) {
|
|
164
|
+
if (input.pendingDynamicCheck) return clearCloudflareCdnResponseHeaders(NO_STORE);
|
|
95
165
|
if (!input.cacheControl) return clearCloudflareCdnResponseHeaders(NO_STORE);
|
|
96
|
-
if (
|
|
166
|
+
if (isNonCacheableCacheControl(input.cacheControl)) return clearCloudflareCdnResponseHeaders(input.cacheControl);
|
|
97
167
|
return {
|
|
168
|
+
...getBuildIdentityResponseHeader(),
|
|
98
169
|
"Cache-Control": BROWSER_REVALIDATE,
|
|
99
170
|
"CDN-Cache-Control": toEdgeCacheControl(input.cacheControl),
|
|
100
171
|
"Cloudflare-CDN-Cache-Control": null,
|
|
@@ -113,6 +184,10 @@ var CloudflareCdnCacheAdapter = class {
|
|
|
113
184
|
await cache.purge({ tags: tagList });
|
|
114
185
|
}
|
|
115
186
|
};
|
|
116
|
-
const createCloudflareCdnCacheAdapter = () =>
|
|
187
|
+
const createCloudflareCdnCacheAdapter = ({ env, options } = {}) => {
|
|
188
|
+
const binding = options?.versionMetadataBinding ?? DEFAULT_VERSION_METADATA_BINDING;
|
|
189
|
+
const candidate = env?.[binding];
|
|
190
|
+
return new CloudflareCdnCacheAdapter(candidate && typeof candidate === "object" && "id" in candidate && typeof candidate.id === "string" ? { id: candidate.id } : void 0, binding);
|
|
191
|
+
};
|
|
117
192
|
//#endregion
|
|
118
193
|
export { CloudflareCdnCacheAdapter, createCloudflareCdnCacheAdapter as default };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ENTRY_PREFIX, createKvKeySpace } from "./kv-key.js";
|
|
2
2
|
import { isUnknownRecord, readCacheControlNumberField } from "../utils/cache-control-metadata.js";
|
|
3
|
-
import { getRequestExecutionContext } from "vinext/shims/request-context";
|
|
4
3
|
import { Buffer } from "node:buffer";
|
|
4
|
+
import { getRequestExecutionContext } from "vinext/shims/request-context";
|
|
5
5
|
//#region src/cache/kv-data-adapter.runtime.ts
|
|
6
6
|
/**
|
|
7
7
|
* Cloudflare KV data cache — implementation + runtime factory.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { CacheabilityManifest } from "vinext/internal/server/cacheability-manifest";
|
|
2
|
+
//#region src/cacheability-artifact.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Write the version-specific manifest into the built Worker artifact.
|
|
5
|
+
* The application build already imports this stable module asset, so the
|
|
6
|
+
* completed dist directory remains the exact input to the final upload.
|
|
7
|
+
*/
|
|
8
|
+
declare function writeCacheabilityManifestArtifact(root: string, configuredPath: string | undefined, manifest: CacheabilityManifest): string;
|
|
9
|
+
//#endregion
|
|
10
|
+
export { writeCacheabilityManifestArtifact };
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { cacheabilityManifestByteLimitError } from "./cacheability-manifest-limits.js";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { Buffer } from "node:buffer";
|
|
5
|
+
import { CACHEABILITY_MANIFEST_MODULE } from "vinext/internal/server/cacheability-manifest";
|
|
6
|
+
//#region src/cacheability-artifact.ts
|
|
7
|
+
function tokenizeStaticImports(source) {
|
|
8
|
+
const tokens = [];
|
|
9
|
+
let index = 0;
|
|
10
|
+
const canStartRegularExpression = () => {
|
|
11
|
+
const previous = tokens.at(-1);
|
|
12
|
+
if (!previous) return true;
|
|
13
|
+
if (previous.kind === "punctuator" && "({[=,:;!?&|+-*%^~<>".includes(previous.value)) return true;
|
|
14
|
+
return previous.kind === "identifier" && /^(?:await|case|delete|do|else|in|instanceof|of|return|throw|typeof|void|yield)$/.test(previous.value);
|
|
15
|
+
};
|
|
16
|
+
while (index < source.length) {
|
|
17
|
+
const character = source[index];
|
|
18
|
+
if (/\s/.test(character)) {
|
|
19
|
+
index++;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (character === "/" && source[index + 1] === "/") {
|
|
23
|
+
index = source.indexOf("\n", index + 2);
|
|
24
|
+
if (index === -1) break;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (character === "/" && source[index + 1] === "*") {
|
|
28
|
+
const end = source.indexOf("*/", index + 2);
|
|
29
|
+
index = end === -1 ? source.length : end + 2;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (character === "/" && canStartRegularExpression()) {
|
|
33
|
+
let inCharacterClass = false;
|
|
34
|
+
index++;
|
|
35
|
+
while (index < source.length) {
|
|
36
|
+
if (source[index] === "\\") {
|
|
37
|
+
index += 2;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (source[index] === "[") inCharacterClass = true;
|
|
41
|
+
if (source[index] === "]") inCharacterClass = false;
|
|
42
|
+
if (source[index++] === "/" && !inCharacterClass) break;
|
|
43
|
+
}
|
|
44
|
+
while (index < source.length && /[A-Za-z]/.test(source[index])) index++;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (character === "`") {
|
|
48
|
+
index++;
|
|
49
|
+
while (index < source.length) if (source[index] === "\\") index += 2;
|
|
50
|
+
else if (source[index++] === "`") break;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (character === "\"" || character === "'") {
|
|
54
|
+
const quote = character;
|
|
55
|
+
const start = ++index;
|
|
56
|
+
while (index < source.length && source[index] !== quote) index += source[index] === "\\" ? 2 : 1;
|
|
57
|
+
tokens.push({
|
|
58
|
+
kind: "string",
|
|
59
|
+
value: source.slice(start, index)
|
|
60
|
+
});
|
|
61
|
+
index++;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (/[A-Za-z_$]/.test(character)) {
|
|
65
|
+
const start = index++;
|
|
66
|
+
while (index < source.length && /[\w$]/.test(source[index])) index++;
|
|
67
|
+
tokens.push({
|
|
68
|
+
kind: "identifier",
|
|
69
|
+
value: source.slice(start, index)
|
|
70
|
+
});
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
tokens.push({
|
|
74
|
+
kind: "punctuator",
|
|
75
|
+
value: character
|
|
76
|
+
});
|
|
77
|
+
index++;
|
|
78
|
+
}
|
|
79
|
+
return tokens;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Check only the generated entry module's static dependency declarations. This
|
|
83
|
+
* deliberately avoids an AST parse or module-graph walk on the deploy path,
|
|
84
|
+
* while rejecting comments, strings, templates, and dynamic imports that only
|
|
85
|
+
* happen to mention the manifest filename.
|
|
86
|
+
*/
|
|
87
|
+
function hasStaticModuleSpecifier(source, expected) {
|
|
88
|
+
const tokens = tokenizeStaticImports(source);
|
|
89
|
+
let braceDepth = 0;
|
|
90
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
91
|
+
const token = tokens[index];
|
|
92
|
+
if (token.value === "{") {
|
|
93
|
+
braceDepth++;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (token.value === "}") {
|
|
97
|
+
braceDepth = Math.max(0, braceDepth - 1);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (braceDepth !== 0 || token.kind !== "identifier") continue;
|
|
101
|
+
if (token.value === "import") {
|
|
102
|
+
const next = tokens[index + 1];
|
|
103
|
+
if (next?.kind === "string" && next.value === expected) return true;
|
|
104
|
+
if (next?.value === "(" || next?.value === ".") continue;
|
|
105
|
+
} else if (token.value !== "export") continue;
|
|
106
|
+
for (let cursor = index + 1; cursor < tokens.length; cursor++) {
|
|
107
|
+
const candidate = tokens[cursor];
|
|
108
|
+
if (candidate.value === ";") break;
|
|
109
|
+
if (candidate.kind === "identifier" && candidate.value === "from") {
|
|
110
|
+
const specifier = tokens[cursor + 1];
|
|
111
|
+
if (specifier?.kind === "string" && specifier.value === expected) return true;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
function resolveGeneratedServerConfig(root, configuredPath) {
|
|
119
|
+
const distDirectory = path.join(root, "dist");
|
|
120
|
+
const configPath = path.resolve(root, configuredPath ?? "dist/server/wrangler.json");
|
|
121
|
+
const relativeConfigPath = path.relative(distDirectory, configPath);
|
|
122
|
+
if (relativeConfigPath.startsWith("..") || path.isAbsolute(relativeConfigPath)) throw new Error("Two-stage CDN warming requires a generated Wrangler config under dist/. Pass --config dist/server/wrangler.json (or another generated dist config).");
|
|
123
|
+
if (!fs.existsSync(configPath) || !fs.lstatSync(configPath).isFile()) throw new Error(`Two-stage CDN warming requires the generated Wrangler config at ${path.relative(root, configPath)}. Rebuild the app before deploying.`);
|
|
124
|
+
return configPath;
|
|
125
|
+
}
|
|
126
|
+
function assertManifestModuleReachable(configPath) {
|
|
127
|
+
const serverDirectory = path.dirname(configPath);
|
|
128
|
+
let config;
|
|
129
|
+
try {
|
|
130
|
+
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
131
|
+
} catch (cause) {
|
|
132
|
+
throw new Error("Two-stage CDN warming could not parse the generated Wrangler config.", { cause });
|
|
133
|
+
}
|
|
134
|
+
const main = config && typeof config === "object" && !Array.isArray(config) ? config.main : void 0;
|
|
135
|
+
if (typeof main !== "string" || main.length === 0) throw new Error("Two-stage CDN warming requires a generated Wrangler main module.");
|
|
136
|
+
const mainPath = path.resolve(serverDirectory, main);
|
|
137
|
+
if (!fs.existsSync(mainPath) || !fs.lstatSync(mainPath).isFile()) throw new Error("Two-stage CDN warming could not find the generated Wrangler main module.");
|
|
138
|
+
if (!hasStaticModuleSpecifier(fs.readFileSync(mainPath, "utf8"), `./${CACHEABILITY_MANIFEST_MODULE}`)) throw new Error(`Two-stage CDN warming requires the generated Worker main module to import ${CACHEABILITY_MANIFEST_MODULE}.`);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Write the version-specific manifest into the built Worker artifact.
|
|
142
|
+
* The application build already imports this stable module asset, so the
|
|
143
|
+
* completed dist directory remains the exact input to the final upload.
|
|
144
|
+
*/
|
|
145
|
+
function writeCacheabilityManifestArtifact(root, configuredPath, manifest) {
|
|
146
|
+
const configPath = resolveGeneratedServerConfig(root, configuredPath);
|
|
147
|
+
assertManifestModuleReachable(configPath);
|
|
148
|
+
const serverDirectory = path.dirname(configPath);
|
|
149
|
+
const manifestPath = path.join(serverDirectory, CACHEABILITY_MANIFEST_MODULE);
|
|
150
|
+
if (!fs.existsSync(manifestPath) || !fs.lstatSync(manifestPath).isFile()) throw new Error(`Two-stage CDN warming requires ${CACHEABILITY_MANIFEST_MODULE} in the generated Worker artifact. Rebuild the app before deploying.`);
|
|
151
|
+
const serializedManifest = JSON.stringify(manifest);
|
|
152
|
+
const manifestBytes = Buffer.byteLength(serializedManifest);
|
|
153
|
+
if (manifestBytes > 2097152) throw cacheabilityManifestByteLimitError(manifestBytes);
|
|
154
|
+
const manifestSource = `export default ${JSON.stringify(serializedManifest)};\n`;
|
|
155
|
+
const pendingManifestPath = `${manifestPath}.${process.pid}.tmp`;
|
|
156
|
+
try {
|
|
157
|
+
fs.writeFileSync(pendingManifestPath, manifestSource, "utf8");
|
|
158
|
+
fs.renameSync(pendingManifestPath, manifestPath);
|
|
159
|
+
} finally {
|
|
160
|
+
if (fs.existsSync(pendingManifestPath)) fs.unlinkSync(pendingManifestPath);
|
|
161
|
+
}
|
|
162
|
+
return path.relative(root, configPath);
|
|
163
|
+
}
|
|
164
|
+
//#endregion
|
|
165
|
+
export { writeCacheabilityManifestArtifact };
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
//#region src/cacheability-manifest-limits.d.ts
|
|
2
|
+
declare const MAX_CACHEABILITY_MANIFEST_BYTES: number;
|
|
3
|
+
declare function cacheabilityManifestByteLimitError(manifestBytes: number, limit?: number): Error;
|
|
4
|
+
//#endregion
|
|
5
|
+
export { MAX_CACHEABILITY_MANIFEST_BYTES, cacheabilityManifestByteLimitError };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
//#region src/cacheability-manifest-limits.ts
|
|
2
|
+
const MAX_CACHEABILITY_MANIFEST_BYTES = 2 * 1024 * 1024;
|
|
3
|
+
function cacheabilityManifestByteLimitError(manifestBytes, limit = MAX_CACHEABILITY_MANIFEST_BYTES) {
|
|
4
|
+
return /* @__PURE__ */ new Error(`Two-stage CDN warming produced a ${manifestBytes}-byte route-pattern cacheability manifest; the limit is ${limit} bytes. Reduce the number or length of route patterns, or split the deployment before retrying.`);
|
|
5
|
+
}
|
|
6
|
+
//#endregion
|
|
7
|
+
export { MAX_CACHEABILITY_MANIFEST_BYTES, cacheabilityManifestByteLimitError };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { CdnWarmTarget } from "./cdn-warm.js";
|
|
2
|
+
import { CacheabilityManifest } from "vinext/internal/server/cacheability-manifest";
|
|
3
|
+
import { PrerenderRoutePattern } from "vinext/internal/build/prerender-paths";
|
|
4
|
+
//#region src/cacheability-probe.d.ts
|
|
5
|
+
declare const DEFAULT_CACHEABILITY_PROBE_PHASE_TIMEOUT_MS = 120000;
|
|
6
|
+
declare const DEFAULT_CACHEABILITY_PROBE_RETRIES = 2;
|
|
7
|
+
declare const DEFAULT_CACHEABILITY_PROBE_RETRY_DELAY_MS = 1000;
|
|
8
|
+
type CacheabilityProbeProgress = {
|
|
9
|
+
completed: number;
|
|
10
|
+
dynamic: number;
|
|
11
|
+
failed: number;
|
|
12
|
+
probed: number;
|
|
13
|
+
skipped: number;
|
|
14
|
+
static: number;
|
|
15
|
+
total: number;
|
|
16
|
+
};
|
|
17
|
+
type CacheabilityProbeResult = {
|
|
18
|
+
cacheableTargets: CdnWarmTarget[];
|
|
19
|
+
classified: number;
|
|
20
|
+
dynamic: number;
|
|
21
|
+
failures: string[];
|
|
22
|
+
manifest: CacheabilityManifest;
|
|
23
|
+
probed: number;
|
|
24
|
+
skipped: number;
|
|
25
|
+
/** Paired representations admitted only if their own final warm render remains cacheable. */
|
|
26
|
+
speculativeTargets: CdnWarmTarget[];
|
|
27
|
+
};
|
|
28
|
+
declare function readPrerenderSecret(root: string): string;
|
|
29
|
+
declare function probeStagedWorkerCacheability(options: {
|
|
30
|
+
buildId: string;
|
|
31
|
+
concurrency?: number;
|
|
32
|
+
expectedResponseBuildId?: string;
|
|
33
|
+
fallbackRoutePatterns?: readonly PrerenderRoutePattern[];
|
|
34
|
+
fetchImpl?: typeof fetch;
|
|
35
|
+
headers?: HeadersInit;
|
|
36
|
+
retries?: number;
|
|
37
|
+
retryDelayMs?: number;
|
|
38
|
+
root: string;
|
|
39
|
+
targets: readonly CdnWarmTarget[];
|
|
40
|
+
targetUrl: string;
|
|
41
|
+
timeoutMs?: number;
|
|
42
|
+
phaseTimeoutMs?: number;
|
|
43
|
+
onProgress?: (progress: CacheabilityProbeProgress) => void;
|
|
44
|
+
/** @internal Apply stricter artifact bounds for focused coordinator tests. */
|
|
45
|
+
manifestLimits?: {
|
|
46
|
+
maxBytes?: number;
|
|
47
|
+
};
|
|
48
|
+
}): Promise<CacheabilityProbeResult>;
|
|
49
|
+
//#endregion
|
|
50
|
+
export { CacheabilityProbeProgress, CacheabilityProbeResult, DEFAULT_CACHEABILITY_PROBE_PHASE_TIMEOUT_MS, DEFAULT_CACHEABILITY_PROBE_RETRIES, DEFAULT_CACHEABILITY_PROBE_RETRY_DELAY_MS, probeStagedWorkerCacheability, readPrerenderSecret };
|