@decocms/blocks-cli 7.58.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks-cli",
3
- "version": "7.58.0",
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.58.0",
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
+ }