@decocms/blocks-cli 7.57.0 → 7.59.0
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/package.json +2 -2
- package/scripts/cdn-rules.test.ts +105 -0
- package/scripts/cdn-rules.ts +192 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/blocks-cli",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.59.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco codegen (generate-blocks, generate-schema, generate-invoke) and Fresh-to-TanStack migration tooling",
|
|
6
6
|
"repository": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"lint:unused": "knip"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@decocms/blocks": "7.
|
|
36
|
+
"@decocms/blocks": "7.59.0",
|
|
37
37
|
"ts-morph": "^27.0.0",
|
|
38
38
|
"tsx": "^4.22.5"
|
|
39
39
|
},
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { BOT_UA_SUBSTRINGS, isBot } from "@decocms/blocks/cms";
|
|
2
|
+
import { DECO_MATCHERS_OVERRIDE_PARAM } from "@decocms/blocks/matchers/override";
|
|
3
|
+
import { SEGMENT_COOKIE } from "@decocms/blocks/sdk/flags";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { AUTH_COOKIE_PREFIXES, bypassExpression, cacheRuleset } from "./cdn-rules";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* These are drift tests, not behaviour tests. The CDN rules and the Worker's
|
|
9
|
+
* cache key are two descriptions of the same segmentation; when they disagree,
|
|
10
|
+
* one visitor gets another's response. Each assertion below pins one dimension
|
|
11
|
+
* to the constant the Worker actually uses.
|
|
12
|
+
*/
|
|
13
|
+
describe("CDN bypass expression tracks the worker cache key", () => {
|
|
14
|
+
const expr = bypassExpression();
|
|
15
|
+
|
|
16
|
+
it("bypasses authenticated visitors", () => {
|
|
17
|
+
// Without this, a logged-in request is answered from the anonymous CDN
|
|
18
|
+
// entry and the Worker never runs — no header can save it.
|
|
19
|
+
for (const name of AUTH_COOKIE_PREFIXES) {
|
|
20
|
+
expect(expr).toContain(`http.cookie contains "${name}"`);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("bypasses the A/B cohort cookie by its real name (__abf)", () => {
|
|
25
|
+
expect(expr).toContain(`${SEGMENT_COOKIE}=`);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("bypasses every bot UA the framework renders eagerly (__bot)", () => {
|
|
29
|
+
for (const ua of BOT_UA_SUBSTRINGS) {
|
|
30
|
+
expect(expr).toContain(ua);
|
|
31
|
+
// The list is only meaningful if it really is what isBot() matches.
|
|
32
|
+
expect(isBot(`Mozilla/5.0 (compatible; ${ua}/1.0)`)).toBe(true);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("bypasses programmatic fetches (__fetch)", () => {
|
|
37
|
+
expect(expr).toContain("sec-fetch-dest");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("bypasses matcher overrides by their real param name", () => {
|
|
41
|
+
expect(expr).toContain(DECO_MATCHERS_OVERRIDE_PARAM);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("bypasses draft preview", () => {
|
|
45
|
+
expect(expr).toContain("__draft=");
|
|
46
|
+
expect(expr).toContain("__deco_draft");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("is scoped to hostnames that opted in", () => {
|
|
50
|
+
// A shared zone: an unscoped rule would enable every site at once.
|
|
51
|
+
expect(expr).toContain("cf.hostname.metadata");
|
|
52
|
+
for (const rule of cacheRuleset().rules) {
|
|
53
|
+
expect(rule.expression).toContain("cf.hostname.metadata");
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("cache ruleset", () => {
|
|
59
|
+
const [bypass, cache] = cacheRuleset().rules;
|
|
60
|
+
|
|
61
|
+
it("does not rely on rule order — the two rules are mutually exclusive", () => {
|
|
62
|
+
// Cloudflare's cache phase is LAST-match-wins for non-terminating actions,
|
|
63
|
+
// so a catch-all `cache: true` listed after `cache: false` silently
|
|
64
|
+
// overrides it and the whole bypass list becomes inert. An earlier version
|
|
65
|
+
// of this file had exactly that bug, and an earlier version of THIS test
|
|
66
|
+
// asserted "bypass is evaluated first", certifying it. Correctness must
|
|
67
|
+
// come from the expressions, not the array order.
|
|
68
|
+
expect(bypass.action_parameters.cache).toBe(false);
|
|
69
|
+
expect(cache.action_parameters.cache).toBe(true);
|
|
70
|
+
|
|
71
|
+
const clauses = bypass.expression.slice(bypass.expression.indexOf(") and (") + 7, -1);
|
|
72
|
+
expect(cache.expression).toContain(`and not (${clauses})`);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("does not bypass /_serverFn on sec-fetch-dest, which is what it exists to cache", () => {
|
|
76
|
+
// Every /_serverFn call is an XHR and sends `sec-fetch-dest: empty`. A bare
|
|
77
|
+
// clause would bypass exactly the traffic `serverfn-segment` caches,
|
|
78
|
+
// reducing the feature to a no-op. `buildCacheKey` excludes server-fn paths
|
|
79
|
+
// from `__fetch` for the same reason.
|
|
80
|
+
expect(bypass.expression).toContain('not starts_with(http.request.uri.path, "/_serverFn/")');
|
|
81
|
+
expect(bypass.expression).toContain('not starts_with(http.request.uri.path, "/_server/")');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("keys by device, since deviceSpecificKeys defaults to true", () => {
|
|
85
|
+
expect(cache.action_parameters.cache_key?.cache_by_device_type).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("takes the TTL from the origin, not a copy of the profile table", () => {
|
|
89
|
+
expect(cache.action_parameters.edge_ttl?.mode).toBe("respect_origin");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("geo in the cache key", () => {
|
|
94
|
+
it("is off by default — a site without regional content must not pay for it", () => {
|
|
95
|
+
const [, cache] = cacheRuleset().rules;
|
|
96
|
+
expect(cache.action_parameters.cache_key?.custom_key).toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("is opt-in, for sites whose content actually varies by region", () => {
|
|
100
|
+
const [, cache] = cacheRuleset({ geo: true }).rules;
|
|
101
|
+
expect(cache.action_parameters.cache_key?.custom_key?.user?.geo).toBe(true);
|
|
102
|
+
// device stays keyed either way
|
|
103
|
+
expect(cache.action_parameters.cache_key?.cache_by_device_type).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Generate (and optionally apply) the Cloudflare Cache Rules that let the CDN
|
|
4
|
+
* serve deco storefronts without invoking the Worker.
|
|
5
|
+
*
|
|
6
|
+
* ## Why this is generated and not hand-written
|
|
7
|
+
*
|
|
8
|
+
* The Worker keys its edge cache on a SYNTHETIC Request carrying
|
|
9
|
+
* `__seg`/`__cf_device`/`__cf_geo`/`__bot`/`__fetch`/`__abf` (`buildCacheKey`
|
|
10
|
+
* in `@decocms/tanstack`). Cloudflare's CDN keys on the raw URL and ignores
|
|
11
|
+
* `Vary` beyond `Accept-Encoding`. Every dimension the CDN cannot reproduce has
|
|
12
|
+
* to become a bypass here — and each one corresponds to a constant on the
|
|
13
|
+
* Worker side. Two hand-maintained copies of that list drift, and the failure
|
|
14
|
+
* mode is not a slow page, it is one visitor being served another's response.
|
|
15
|
+
* So the expressions below are DERIVED from the same constants the Worker uses.
|
|
16
|
+
*
|
|
17
|
+
* ## Division of labour
|
|
18
|
+
*
|
|
19
|
+
* The Worker stays the source of truth for *whether* a response may be cached:
|
|
20
|
+
* private routes, logged-in requests, drafts and set-cookie responses already
|
|
21
|
+
* go out with `CDN-Cache-Control: no-store`, and Cloudflare honours that. So
|
|
22
|
+
* these rules deliberately do NOT enumerate private paths — a site adding
|
|
23
|
+
* `registerPrivatePaths([...])` propagates to the CDN on its own.
|
|
24
|
+
*
|
|
25
|
+
* What the rules must cover is the narrower case the header cannot reach: when
|
|
26
|
+
* the CDN answers from cache WITHOUT consulting the Worker, and would hand one
|
|
27
|
+
* visitor an entry that belongs to another segment.
|
|
28
|
+
*
|
|
29
|
+
* ## Scoping in a shared zone
|
|
30
|
+
*
|
|
31
|
+
* Sites are custom hostnames under one deco zone (Cloudflare for SaaS), so a
|
|
32
|
+
* rule applies to every site at once. Enablement therefore rides on per-hostname
|
|
33
|
+
* custom metadata rather than a rule per site:
|
|
34
|
+
*
|
|
35
|
+
* curl "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/custom_hostnames/$ID" \
|
|
36
|
+
* --request PATCH --header "Authorization: Bearer $TOKEN" \
|
|
37
|
+
* --json '{"custom_metadata": {"deco_cdn_html": "on"}}'
|
|
38
|
+
*
|
|
39
|
+
* Rolling out to a site is that PATCH; rolling back is setting it to "off".
|
|
40
|
+
* Neither touches the ruleset.
|
|
41
|
+
*
|
|
42
|
+
* ## Why zone rules, and not Workers Cache
|
|
43
|
+
*
|
|
44
|
+
* `/_serverFn` is already handled without touching the zone: the client carries
|
|
45
|
+
* a segment marker in the URL (`sdk/cdnSegment`), which makes the URL a
|
|
46
|
+
* complete key. HTML cannot do that — the initial navigation is a browser
|
|
47
|
+
* request with no hook to attach a marker to.
|
|
48
|
+
*
|
|
49
|
+
* Workers Cache does not close the gap either. Its documented mechanisms are
|
|
50
|
+
* `Vary` and per-entrypoint disable, and neither expresses "not for a logged-in
|
|
51
|
+
* visitor": `Vary: Cookie` compares verbatim, so every visitor's analytics
|
|
52
|
+
* cookies produce a distinct entry and the cache is dead on arrival. There is
|
|
53
|
+
* no per-request opt-out before the Worker runs — and on a cache HIT the Worker
|
|
54
|
+
* does not run, so the `no-store` it would have emitted never exists.
|
|
55
|
+
*
|
|
56
|
+
* Zone rules are the only layer that can decline BEFORE the Worker, which is
|
|
57
|
+
* exactly what a logged-in visitor needs.
|
|
58
|
+
*
|
|
59
|
+
* Usage:
|
|
60
|
+
* tsx scripts/cdn-rules.ts # print the ruleset as JSON
|
|
61
|
+
* tsx scripts/cdn-rules.ts --expression # print just the bypass expression
|
|
62
|
+
* tsx scripts/cdn-rules.ts --geo # include geo in the cache key
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
import { BOT_UA_SUBSTRINGS } from "@decocms/blocks/cms";
|
|
66
|
+
import { DECO_MATCHERS_OVERRIDE_PARAM } from "@decocms/blocks/matchers/override";
|
|
67
|
+
import { SEGMENT_COOKIE } from "@decocms/blocks/sdk/flags";
|
|
68
|
+
|
|
69
|
+
/** Metadata key read off the custom hostname to enable CDN caching per site. */
|
|
70
|
+
export const CDN_ENABLED_METADATA_KEY = "deco_cdn_html";
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Auth cookie names that mean "this visitor must never be served a shared CDN
|
|
74
|
+
* entry". VTEX sets both the bare name and an account-suffixed variant
|
|
75
|
+
* (`VtexIdclientAutCookie_<account>`); matching on the prefix covers both.
|
|
76
|
+
*/
|
|
77
|
+
export const AUTH_COOKIE_PREFIXES = ["VtexIdclientAutCookie"];
|
|
78
|
+
|
|
79
|
+
const enabled = `lookup_json_string(cf.hostname.metadata, "${CDN_ENABLED_METADATA_KEY}") eq "on"`;
|
|
80
|
+
|
|
81
|
+
/** Path prefix for TanStack server-function requests. */
|
|
82
|
+
const SERVER_FN_PREFIXES = ["/_serverFn/", "/_server/"];
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The individual bypass clauses, each annotated with the `buildCacheKey`
|
|
86
|
+
* dimension it mirrors. If you add a dimension to the cache key, it belongs
|
|
87
|
+
* here too.
|
|
88
|
+
*/
|
|
89
|
+
function bypassClauses(): string[] {
|
|
90
|
+
const notServerFn = SERVER_FN_PREFIXES.map(
|
|
91
|
+
(p) => `not starts_with(http.request.uri.path, "${p}")`,
|
|
92
|
+
).join(" and ");
|
|
93
|
+
|
|
94
|
+
return [
|
|
95
|
+
// Worker key: no equivalent — an authenticated visitor would otherwise be
|
|
96
|
+
// served the cached anonymous entry without the Worker ever running. This
|
|
97
|
+
// is the single most important clause in the file.
|
|
98
|
+
...AUTH_COOKIE_PREFIXES.map((name) => `http.cookie contains "${name}"`),
|
|
99
|
+
|
|
100
|
+
// Worker key: `__abf` — the sticky A/B cohort cookie.
|
|
101
|
+
`http.cookie contains "${SEGMENT_COOKIE}="`,
|
|
102
|
+
|
|
103
|
+
// Worker key: `__bot` — bots render every section eagerly (~10x payload).
|
|
104
|
+
`lower(http.user_agent) matches "${BOT_UA_SUBSTRINGS.join("|")}"`,
|
|
105
|
+
|
|
106
|
+
// Worker key: `__fetch` — programmatic fetches also render eagerly.
|
|
107
|
+
//
|
|
108
|
+
// Carved out for `/_serverFn`, which ALWAYS sends `sec-fetch-dest: empty`
|
|
109
|
+
// (it is an XHR). Without the carve-out this single clause would bypass
|
|
110
|
+
// exactly the traffic `cdnCacheControl: "serverfn-segment"` exists to
|
|
111
|
+
// cache, silently reducing the whole feature to a no-op. `buildCacheKey`
|
|
112
|
+
// excludes server-fn paths from `__fetch` for the same reason, so this
|
|
113
|
+
// mirrors it rather than diverging.
|
|
114
|
+
`(http.request.headers["sec-fetch-dest"][0] eq "empty" and ${notServerFn})`,
|
|
115
|
+
|
|
116
|
+
// Worker: `isCacheable()` bypasses these outright. Duplicated here so a
|
|
117
|
+
// draft never depends on the response header alone.
|
|
118
|
+
'http.request.uri.query contains "__draft="',
|
|
119
|
+
'http.request.uri.query contains "__deco_preview"',
|
|
120
|
+
'http.request.uri.query contains "pathTemplate"',
|
|
121
|
+
`http.request.uri.query contains "${DECO_MATCHERS_OVERRIDE_PARAM}"`,
|
|
122
|
+
`any(http.request.headers.names[*] eq "${DECO_MATCHERS_OVERRIDE_PARAM}")`,
|
|
123
|
+
'http.cookie contains "__deco_draft"',
|
|
124
|
+
];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Requests that must never be served from a shared CDN entry. */
|
|
128
|
+
export function bypassExpression(): string {
|
|
129
|
+
return `(${enabled}) and (${bypassClauses().join(" or ")})`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The ruleset.
|
|
134
|
+
*
|
|
135
|
+
* The two rules are MUTUALLY EXCLUSIVE by expression, not by ordering. That is
|
|
136
|
+
* deliberate and load-bearing: Cloudflare's cache phase is **last-match-wins**
|
|
137
|
+
* for non-terminating actions like `set_cache_settings`, so a catch-all
|
|
138
|
+
* `cache: true` rule listed after a `cache: false` one silently overrides it.
|
|
139
|
+
* Scoping rule 2 with `and not (<bypass clauses>)` means correctness no longer
|
|
140
|
+
* depends on rule order at all — reorder them freely, or apply them via an API
|
|
141
|
+
* that does not preserve order, and the behaviour is unchanged.
|
|
142
|
+
*
|
|
143
|
+
* https://developers.cloudflare.com/cache/how-to/cache-rules/order/
|
|
144
|
+
*
|
|
145
|
+
* Note what is NOT set here: the edge TTL. `respect_origin` keeps the TTL
|
|
146
|
+
* coming from the `CDN-Cache-Control` the Worker already derives per cache
|
|
147
|
+
* profile, so the profile table stays in one place.
|
|
148
|
+
*/
|
|
149
|
+
/**
|
|
150
|
+
* @param opts.geo Include country/region in the cache key. Required for a site
|
|
151
|
+
* whose content varies by region (a `website/matchers/location.ts` in the
|
|
152
|
+
* decofile). Leave it off otherwise: it multiplies the number of entries that
|
|
153
|
+
* have to warm, for content that is identical across them.
|
|
154
|
+
*
|
|
155
|
+
* NOTE: this keys on country+region. A site whose matchers discriminate by
|
|
156
|
+
* CITY or coordinates needs finer granularity than this, and enabling CDN
|
|
157
|
+
* caching there serves the wrong variant — worse than today, since the Worker
|
|
158
|
+
* at least evaluates the matcher on every request.
|
|
159
|
+
*/
|
|
160
|
+
export function cacheRuleset(opts: { geo?: boolean } = {}) {
|
|
161
|
+
const clauses = bypassClauses().join(" or ");
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
rules: [
|
|
165
|
+
{
|
|
166
|
+
description: "deco: bypass CDN for segment-sensitive requests",
|
|
167
|
+
expression: `(${enabled}) and (${clauses})`,
|
|
168
|
+
action: "set_cache_settings",
|
|
169
|
+
action_parameters: { cache: false },
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
description: "deco: cache by device type, TTL from origin",
|
|
173
|
+
expression: `(${enabled}) and not (${clauses})`,
|
|
174
|
+
action: "set_cache_settings",
|
|
175
|
+
action_parameters: {
|
|
176
|
+
cache: true,
|
|
177
|
+
cache_key: {
|
|
178
|
+
cache_by_device_type: true,
|
|
179
|
+
...(opts.geo ? { custom_key: { user: { geo: true } } } : {}),
|
|
180
|
+
},
|
|
181
|
+
edge_ttl: { mode: "respect_origin" },
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
189
|
+
const expressionOnly = process.argv.includes("--expression");
|
|
190
|
+
const geo = process.argv.includes("--geo");
|
|
191
|
+
console.log(expressionOnly ? bypassExpression() : JSON.stringify(cacheRuleset({ geo }), null, 2));
|
|
192
|
+
}
|