@decocms/blocks-cli 8.0.0 → 8.1.0-next.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 +5 -3
- package/scripts/analyze-traces.mjs +1 -0
- package/scripts/cdn-rules.test.ts +105 -0
- package/scripts/cdn-rules.ts +192 -0
- package/scripts/deco-migrate-cli.ts +6 -7
- package/scripts/fast-deploy-kv.test.ts +172 -0
- package/scripts/generate-blocks.test.ts +11 -0
- package/scripts/generate-blocks.ts +0 -10
- package/scripts/generate-invoke.test.ts +2 -2
- package/scripts/generate-invoke.ts +9 -21
- package/scripts/generate-loaders.test.ts +2 -2
- package/scripts/generate-loaders.ts +0 -10
- package/scripts/generate-schema-loader-aliases.test.ts +186 -0
- package/scripts/generate-schema-workspaces.test.ts +180 -0
- package/scripts/generate-schema.test.ts +68 -29
- package/scripts/generate-schema.ts +101 -89
- package/scripts/generate-sections.test.ts +104 -35
- package/scripts/generate-sections.ts +57 -11
- package/scripts/generate-workspaces.test.ts +179 -0
- package/scripts/generate.test.ts +1 -2
- package/scripts/generate.ts +43 -45
- package/scripts/lib/blocks-dedupe.test.ts +1 -1
- package/scripts/lib/cf-kv-rest.ts +49 -1
- package/scripts/lib/installedPackages.ts +42 -0
- package/scripts/lib/invokeSource.ts +18 -0
- package/scripts/lib/kv-snapshot.ts +44 -0
- package/scripts/lib/read-decofile.ts +13 -2
- package/scripts/lib/upgrade-6-to-7.ts +1 -1
- package/scripts/lib/wrangler-config.test.ts +49 -0
- package/scripts/lib/wrangler-config.ts +31 -0
- package/scripts/migrate/analyzers/htmx-analyze.test.ts +6 -6
- package/scripts/migrate/analyzers/tailwind-config.ts +95 -1
- package/scripts/migrate/config.ts +2 -2
- package/scripts/migrate/delete-sets.ts +75 -0
- package/scripts/migrate/fast-deploy-scaffold.test.ts +130 -0
- package/scripts/migrate/icon-casing.test.ts +63 -0
- package/scripts/migrate/phase-analyze.test.ts +66 -4
- package/scripts/migrate/phase-analyze.ts +79 -60
- package/scripts/migrate/phase-cleanup.test.ts +3 -1
- package/scripts/migrate/phase-cleanup.ts +1 -1
- package/scripts/migrate/phase-compile.test.ts +3 -1
- package/scripts/migrate/phase-report.test.ts +2 -0
- package/scripts/migrate/phase-report.ts +3 -4
- package/scripts/migrate/phase-scaffold.ts +150 -45
- package/scripts/migrate/phase-transform.ts +107 -2
- package/scripts/migrate/phase-verify-legacy-specifiers.test.ts +13 -0
- package/scripts/migrate/phase-verify.test.ts +3 -1
- package/scripts/migrate/phase-verify.ts +87 -33
- package/scripts/migrate/post-cleanup/rules.ts +29 -11
- package/scripts/migrate/post-cleanup/shim-classify.test.ts +1 -1
- package/scripts/migrate/post-cleanup/shim-classify.ts +2 -2
- package/scripts/migrate/source-layout.test.ts +43 -0
- package/scripts/migrate/source-layout.ts +32 -5
- package/scripts/migrate/templates/app-css.test.ts +24 -0
- package/scripts/migrate/templates/app-css.ts +27 -0
- package/scripts/migrate/templates/ci-workflows.test.ts +179 -0
- package/scripts/migrate/templates/ci-yml.ts +182 -0
- package/scripts/migrate/templates/cursor-rules.test.ts +3 -3
- package/scripts/migrate/templates/hooks.test.ts +3 -1
- package/scripts/migrate/templates/lib-utils.ts +3 -3
- package/scripts/migrate/templates/main-push-guard-yml.ts +45 -0
- package/scripts/migrate/templates/package-json.ts +10 -0
- package/scripts/migrate/templates/parity-yml.ts +136 -0
- package/scripts/migrate/templates/perf-yml.ts +449 -0
- package/scripts/migrate/templates/playwright-yml.ts +127 -0
- package/scripts/migrate/templates/react-doctor-yml.ts +46 -0
- package/scripts/migrate/templates/routes.test.ts +31 -0
- package/scripts/migrate/templates/routes.ts +19 -9
- package/scripts/migrate/templates/section-loaders.ts +3 -3
- package/scripts/migrate/templates/server-entry-vtex-invoke.test.ts +19 -0
- package/scripts/migrate/templates/server-entry.ts +19 -14
- package/scripts/migrate/templates/setup.ts +11 -3
- package/scripts/migrate/templates/sync-blocks-bot-yml.ts +201 -0
- package/scripts/migrate/templates/ui-components.ts +3 -0
- package/scripts/migrate/transforms/ctx-compat.test.ts +37 -0
- package/scripts/migrate/transforms/ctx-compat.ts +93 -1
- package/scripts/migrate/transforms/htmx-on-events.test.ts +1 -1
- package/scripts/migrate/transforms/htmx-on-events.ts +2 -2
- package/scripts/migrate/transforms/jsx-class-type.test.ts +28 -0
- package/scripts/migrate/transforms/jsx.ts +10 -0
- package/scripts/migrate/transforms/picture.test.ts +56 -0
- package/scripts/migrate/transforms/picture.ts +95 -0
- package/scripts/migrate/transforms/tailwind-renames.test.ts +29 -0
- package/scripts/migrate/transforms/tailwind-renames.ts +34 -0
- package/scripts/migrate/transforms/timer-types.test.ts +30 -0
- package/scripts/migrate/transforms/timer-types.ts +48 -0
- package/scripts/migrate/transforms/use-script-handlers.test.ts +37 -0
- package/scripts/migrate/transforms/use-script-handlers.ts +36 -0
- package/scripts/migrate/types.ts +13 -1
- package/scripts/migrate-blocks-to-kv.ts +16 -2
- package/scripts/migrate-to-cf-observability.test.ts +5 -5
- package/scripts/migrate-to-cf-observability.ts +1 -1
- package/scripts/migrate.ts +244 -6
- package/scripts/reconcile.test.ts +162 -0
- package/scripts/reconcile.ts +303 -0
- package/scripts/sync-blocks-bot.test.ts +276 -0
- package/scripts/sync-blocks-bot.ts +567 -0
- package/scripts/sync-blocks-to-kv.ts +20 -2
- package/scripts/upgrade-6-to-7.ts +0 -0
- package/scripts/lib/legacyArtifact.ts +0 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/blocks-cli",
|
|
3
|
-
"version": "8.0.0",
|
|
3
|
+
"version": "8.1.0-next.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": {
|
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
"deco-audit-observability": "./scripts/audit-observability-config.ts",
|
|
18
18
|
"deco-migrate-blocks-to-kv": "./scripts/migrate-blocks-to-kv.ts",
|
|
19
19
|
"deco-sync-blocks-to-kv": "./scripts/sync-blocks-to-kv.ts",
|
|
20
|
-
"deco-
|
|
20
|
+
"deco-sync-blocks-bot": "./scripts/sync-blocks-bot.ts",
|
|
21
|
+
"deco-upgrade-6-to-7": "./scripts/upgrade-6-to-7.ts",
|
|
22
|
+
"deco-reconcile": "./scripts/reconcile.ts"
|
|
21
23
|
},
|
|
22
24
|
"//exports": "Deliberately narrow. ./generate is the ONE public module entry (unified orchestrator); ./generate-blocks stays only because @decocms/tanstack's vite plugin tsImports it (readBlockDelta + programmatic generateBlocks) — each surviving entry must name its consumer here. The other scripts remain shipped FILES (the orchestrator spawns them; sites' existing `tsx node_modules/@decocms/blocks-cli/scripts/generate-*.ts` invocations keep working) but are internal implementation details of ./generate, not module subpaths. CLIs are exposed via bin, not exports.",
|
|
23
25
|
"exports": {
|
|
@@ -31,7 +33,7 @@
|
|
|
31
33
|
"lint:unused": "knip"
|
|
32
34
|
},
|
|
33
35
|
"dependencies": {
|
|
34
|
-
"@decocms/blocks": "8.0.0",
|
|
36
|
+
"@decocms/blocks": "8.1.0-next.0",
|
|
35
37
|
"ts-morph": "^27.0.0",
|
|
36
38
|
"tsx": "^4.22.5"
|
|
37
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
|
+
}
|
|
@@ -123,15 +123,15 @@ function showHelp() {
|
|
|
123
123
|
npx tsx scripts/deco-migrate-cli.ts https://github.com/org/my-site
|
|
124
124
|
|
|
125
125
|
${dim("# Migrate local dir, compare against golden reference:")}
|
|
126
|
-
npx tsx scripts/deco-migrate-cli.ts ./
|
|
127
|
-
--ref ./
|
|
126
|
+
npx tsx scripts/deco-migrate-cli.ts ./acme \\
|
|
127
|
+
--ref ./acme-storefront
|
|
128
128
|
|
|
129
129
|
${dim("# Quick re-run (wipe previous output first):")}
|
|
130
|
-
npx tsx scripts/deco-migrate-cli.ts ./
|
|
131
|
-
-o ./
|
|
130
|
+
npx tsx scripts/deco-migrate-cli.ts ./acme \\
|
|
131
|
+
-o ./acme-migrated --clean
|
|
132
132
|
|
|
133
133
|
${dim("# Dry run to preview what would change:")}
|
|
134
|
-
npx tsx scripts/deco-migrate-cli.ts ./
|
|
134
|
+
npx tsx scripts/deco-migrate-cli.ts ./acme --dry-run -v
|
|
135
135
|
`);
|
|
136
136
|
}
|
|
137
137
|
|
|
@@ -429,8 +429,7 @@ async function main() {
|
|
|
429
429
|
console.log(`\n ${bold("Next steps:")}`);
|
|
430
430
|
console.log(` cd ${outputDir}`);
|
|
431
431
|
console.log(` npm install`);
|
|
432
|
-
console.log(` npm run generate
|
|
433
|
-
console.log(` npm run generate:schema`);
|
|
432
|
+
console.log(` npm run generate`);
|
|
434
433
|
console.log(` npx tsr generate`);
|
|
435
434
|
console.log(` npm run dev`);
|
|
436
435
|
}
|
|
@@ -9,12 +9,15 @@ import {
|
|
|
9
9
|
revisionKey,
|
|
10
10
|
snapshotKey,
|
|
11
11
|
} from "@decocms/blocks/cms";
|
|
12
|
+
import { splitExactRedirects } from "@decocms/blocks/sdk/redirects";
|
|
12
13
|
import { createKvRestClient, type KvRestClient, kvConfigFromEnv } from "./lib/cf-kv-rest";
|
|
14
|
+
import { readDecofileFromDir } from "./lib/read-decofile";
|
|
13
15
|
import { kvNamespaceIdFromToml, kvNamespaceIdFromWrangler } from "./lib/wrangler-config";
|
|
14
16
|
import {
|
|
15
17
|
buildSnapshot,
|
|
16
18
|
recordAndGcDeployment,
|
|
17
19
|
setLiveDeployment,
|
|
20
|
+
syncRedirectsToKv,
|
|
18
21
|
verifySnapshotInKv,
|
|
19
22
|
writeSnapshotToKv,
|
|
20
23
|
} from "./lib/kv-snapshot";
|
|
@@ -45,6 +48,14 @@ function makeClient(initial: Record<string, string> = {}) {
|
|
|
45
48
|
Promise.resolve(
|
|
46
49
|
[...store.keys()].filter((k) => !prefix || k.startsWith(prefix)),
|
|
47
50
|
),
|
|
51
|
+
putMany: (entries) => {
|
|
52
|
+
for (const e of entries) store.set(e.key, e.value);
|
|
53
|
+
return Promise.resolve();
|
|
54
|
+
},
|
|
55
|
+
deleteMany: (keys) => {
|
|
56
|
+
for (const k of keys) store.delete(k);
|
|
57
|
+
return Promise.resolve();
|
|
58
|
+
},
|
|
48
59
|
};
|
|
49
60
|
return { client, store, putOrder };
|
|
50
61
|
}
|
|
@@ -341,3 +352,164 @@ describe("sync-helpers", () => {
|
|
|
341
352
|
expect(paths).not.toContain(undefined);
|
|
342
353
|
});
|
|
343
354
|
});
|
|
355
|
+
|
|
356
|
+
describe("readDecofileFromDir — CSV redirects", () => {
|
|
357
|
+
/** A site tree: `.deco/blocks/*.json` + `public/*.csv`. Returns the blocks dir. */
|
|
358
|
+
function makeSite(blocks: Record<string, unknown>, csv: Record<string, string>): string {
|
|
359
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "decofile-csv-"));
|
|
360
|
+
const blocksDir = path.join(root, ".deco", "blocks");
|
|
361
|
+
fs.mkdirSync(blocksDir, { recursive: true });
|
|
362
|
+
fs.mkdirSync(path.join(root, "public"), { recursive: true });
|
|
363
|
+
for (const [name, value] of Object.entries(blocks)) {
|
|
364
|
+
fs.writeFileSync(path.join(blocksDir, name), JSON.stringify(value));
|
|
365
|
+
}
|
|
366
|
+
for (const [name, value] of Object.entries(csv)) {
|
|
367
|
+
fs.writeFileSync(path.join(root, "public", name), value);
|
|
368
|
+
}
|
|
369
|
+
return blocksDir;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
it("materializes a CSV loader nested under site.routes[] into a top-level block", () => {
|
|
373
|
+
// The shape that broke production: the CSV loader is nested inside
|
|
374
|
+
// `site.json -> routes[]`, where `loadRedirects` (top-level only) can't see
|
|
375
|
+
// it, and the CSV itself was only ever read by the unported Fresh loader.
|
|
376
|
+
const blocksDir = makeSite(
|
|
377
|
+
{
|
|
378
|
+
"site.json": {
|
|
379
|
+
__resolveType: "site/apps/site.ts",
|
|
380
|
+
routes: [{ __resolveType: "website/loaders/redirectsFromCsv.ts", from: "static/r.csv" }],
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
{ "r.csv": "from,to,type\n/old,/new,permanent\n/tmp,/other\n" },
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
const { blocks } = readDecofileFromDir(blocksDir, { silent: true });
|
|
387
|
+
|
|
388
|
+
expect(blocks["__csv_redirects__r.csv"]).toEqual({
|
|
389
|
+
__resolveType: "website/loaders/redirects.ts",
|
|
390
|
+
redirects: [
|
|
391
|
+
{ from: "/old", to: "/new", type: "permanent" },
|
|
392
|
+
{ from: "/tmp", to: "/other", type: "temporary" },
|
|
393
|
+
],
|
|
394
|
+
});
|
|
395
|
+
// The real blocks are untouched.
|
|
396
|
+
expect(blocks.site).toBeDefined();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("splits CSV-derived exact redirects out of the snapshot", () => {
|
|
400
|
+
const blocksDir = makeSite(
|
|
401
|
+
{
|
|
402
|
+
"site.json": {
|
|
403
|
+
__resolveType: "site/apps/site.ts",
|
|
404
|
+
routes: [{ __resolveType: "website/loaders/redirectsFromCsv.ts", from: "static/r.csv" }],
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
{ "r.csv": "from,to,type\n/old,/new,permanent\n/g/*,/h/*\n" },
|
|
408
|
+
);
|
|
409
|
+
const { blocks } = readDecofileFromDir(blocksDir, { silent: true });
|
|
410
|
+
const split = splitExactRedirects(blocks);
|
|
411
|
+
|
|
412
|
+
expect(split.exact).toEqual([{ path: "/old", to: "/new", status: 301 }]);
|
|
413
|
+
expect(JSON.stringify(split.blocks)).not.toContain('"/old"');
|
|
414
|
+
expect(split.blocks["__csv_redirects__r.csv"]).toMatchObject({ redirects: [{ from: "/g/*" }] });
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("lets a real block win over the synthetic one on a key collision", () => {
|
|
418
|
+
const blocksDir = makeSite(
|
|
419
|
+
{
|
|
420
|
+
"site.json": {
|
|
421
|
+
routes: [{ __resolveType: "website/loaders/redirectsFromCsv.ts", from: "r.csv" }],
|
|
422
|
+
},
|
|
423
|
+
// Decodes to exactly the synthetic key the CSV materializer produces.
|
|
424
|
+
"__csv_redirects__r.csv.json": { curated: true },
|
|
425
|
+
},
|
|
426
|
+
{ "r.csv": "/old,/new\n" },
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
const { blocks } = readDecofileFromDir(blocksDir, { silent: true });
|
|
430
|
+
|
|
431
|
+
// `{ ...csv, ...blocks }` — the curated block wins, never the synthetic one.
|
|
432
|
+
expect(blocks["__csv_redirects__r.csv"]).toEqual({ curated: true });
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("is a no-op when no CSV loader is referenced", () => {
|
|
436
|
+
const blocksDir = makeSite({ "site.json": { __resolveType: "site/apps/site.ts" } }, {});
|
|
437
|
+
const { blocks } = readDecofileFromDir(blocksDir, { silent: true });
|
|
438
|
+
expect(Object.keys(blocks)).toEqual(["site"]);
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
describe("syncRedirectsToKv", () => {
|
|
443
|
+
/** In-memory client with the bulk ops the redirect sync uses. */
|
|
444
|
+
function makeBulkClient(initial: Record<string, string> = {}) {
|
|
445
|
+
const store = new Map(Object.entries(initial));
|
|
446
|
+
const client = {
|
|
447
|
+
get: (k: string) => Promise.resolve(store.get(k) ?? null),
|
|
448
|
+
put: (k: string, v: string) => {
|
|
449
|
+
store.set(k, v);
|
|
450
|
+
return Promise.resolve();
|
|
451
|
+
},
|
|
452
|
+
delete: (k: string) => {
|
|
453
|
+
store.delete(k);
|
|
454
|
+
return Promise.resolve();
|
|
455
|
+
},
|
|
456
|
+
list: (prefix?: string) =>
|
|
457
|
+
Promise.resolve([...store.keys()].filter((k) => !prefix || k.startsWith(prefix))),
|
|
458
|
+
putMany: (entries: Array<{ key: string; value: string }>) => {
|
|
459
|
+
for (const e of entries) store.set(e.key, e.value);
|
|
460
|
+
return Promise.resolve();
|
|
461
|
+
},
|
|
462
|
+
deleteMany: (keys: string[]) => {
|
|
463
|
+
for (const k of keys) store.delete(k);
|
|
464
|
+
return Promise.resolve();
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
return { client: client as unknown as KvRestClient, store };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const rule = (path: string, to: string, status: 301 | 302 = 301) => ({ path, to, status });
|
|
471
|
+
|
|
472
|
+
it("adds new rules under redirect:<id>:<path>", async () => {
|
|
473
|
+
const { client, store } = makeBulkClient();
|
|
474
|
+
const out = await syncRedirectsToKv(client, [rule("/old", "/new")], ID);
|
|
475
|
+
|
|
476
|
+
expect(out).toEqual({ written: 1, deleted: 0 });
|
|
477
|
+
expect(store.get(`redirect:${ID}:/old`)).toBe('{"to":"/new","status":301}');
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("updates a changed target in place", async () => {
|
|
481
|
+
const { client, store } = makeBulkClient({
|
|
482
|
+
[`redirect:${ID}:/old`]: '{"to":"/stale","status":302}',
|
|
483
|
+
});
|
|
484
|
+
await syncRedirectsToKv(client, [rule("/old", "/fresh")], ID);
|
|
485
|
+
expect(store.get(`redirect:${ID}:/old`)).toBe('{"to":"/fresh","status":301}');
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("removes rules that no longer exist", async () => {
|
|
489
|
+
// The reason this diffs against a prefix LIST: KV has no "replace
|
|
490
|
+
// everything under this prefix", so a deleted rule would redirect forever.
|
|
491
|
+
const { client, store } = makeBulkClient({
|
|
492
|
+
[`redirect:${ID}:/keep`]: '{"to":"/a","status":301}',
|
|
493
|
+
[`redirect:${ID}:/gone`]: '{"to":"/b","status":301}',
|
|
494
|
+
});
|
|
495
|
+
const out = await syncRedirectsToKv(client, [rule("/keep", "/a")], ID);
|
|
496
|
+
|
|
497
|
+
expect(out).toEqual({ written: 1, deleted: 1 });
|
|
498
|
+
expect(store.has(`redirect:${ID}:/gone`)).toBe(false);
|
|
499
|
+
expect(store.has(`redirect:${ID}:/keep`)).toBe(true);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it("never touches another deployment's keys", async () => {
|
|
503
|
+
const { client, store } = makeBulkClient({
|
|
504
|
+
"redirect:other-sha:/gone": '{"to":"/x","status":301}',
|
|
505
|
+
});
|
|
506
|
+
await syncRedirectsToKv(client, [], ID);
|
|
507
|
+
expect(store.has("redirect:other-sha:/gone")).toBe(true);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it("does not disturb the decofile keys", async () => {
|
|
511
|
+
const { client, store } = makeBulkClient({ [snapshotKey(ID)]: "{}" });
|
|
512
|
+
await syncRedirectsToKv(client, [rule("/a", "/b")], ID);
|
|
513
|
+
expect(store.has(snapshotKey(ID))).toBe(true);
|
|
514
|
+
});
|
|
515
|
+
});
|
|
@@ -1,9 +1,20 @@
|
|
|
1
|
+
import * as cp from "node:child_process";
|
|
1
2
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
3
|
import { tmpdir } from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
6
|
import { readBlockDelta } from "./generate-blocks";
|
|
6
7
|
|
|
8
|
+
const SCRIPT = path.resolve(__dirname, "generate-blocks.ts");
|
|
9
|
+
|
|
10
|
+
function runGenerator(
|
|
11
|
+
args: string[],
|
|
12
|
+
opts: { cwd?: string } = {},
|
|
13
|
+
): { stdout: string; stderr: string; code: number } {
|
|
14
|
+
const r = cp.spawnSync("npx", ["tsx", SCRIPT, ...args], { encoding: "utf8", cwd: opts.cwd });
|
|
15
|
+
return { stdout: r.stdout || "", stderr: r.stderr || "", code: r.status ?? 0 };
|
|
16
|
+
}
|
|
17
|
+
|
|
7
18
|
describe("readBlockDelta", () => {
|
|
8
19
|
let dir: string;
|
|
9
20
|
|
|
@@ -27,10 +27,6 @@
|
|
|
27
27
|
* --out-file by swapping its extension, so passing --out-file
|
|
28
28
|
* moves both artifacts together)
|
|
29
29
|
*
|
|
30
|
-
* If no `--out-file` is passed and the OLD default (src/server/cms/blocks.gen.ts)
|
|
31
|
-
* still exists on disk, a one-line legacy warning is printed to stderr and the
|
|
32
|
-
* NEW default is written anyway — see lib/legacyArtifact.ts.
|
|
33
|
-
*
|
|
34
30
|
* Programmatic:
|
|
35
31
|
* import { generateBlocks } from "@decocms/blocks-cli/generate-blocks";
|
|
36
32
|
* await generateBlocks({ blocksDir, outFile });
|
|
@@ -49,7 +45,6 @@ import {
|
|
|
49
45
|
singleDecodeBlockName,
|
|
50
46
|
} from "./lib/blocks-dedupe";
|
|
51
47
|
import { buildCsvRedirectBlocks } from "./lib/csv-redirects";
|
|
52
|
-
import { warnLegacyArtifact } from "./lib/legacyArtifact";
|
|
53
48
|
|
|
54
49
|
const TS_STUB = [
|
|
55
50
|
"// Auto-generated — thin wrapper around blocks.gen.json.",
|
|
@@ -321,13 +316,8 @@ if (isMainModule()) {
|
|
|
321
316
|
};
|
|
322
317
|
|
|
323
318
|
const blocksDir = path.resolve(process.cwd(), arg("blocks-dir", ".deco/blocks"));
|
|
324
|
-
const OUT_FILE_EXPLICIT = args.includes("--out-file");
|
|
325
319
|
const NEW_DEFAULT_OUT_FILE = ".deco/blocks.gen.ts";
|
|
326
|
-
const OLD_DEFAULT_OUT_FILE = "src/server/cms/blocks.gen.ts";
|
|
327
320
|
const outFile = path.resolve(process.cwd(), arg("out-file", NEW_DEFAULT_OUT_FILE));
|
|
328
|
-
if (!OUT_FILE_EXPLICIT && fs.existsSync(path.resolve(process.cwd(), OLD_DEFAULT_OUT_FILE))) {
|
|
329
|
-
warnLegacyArtifact(OLD_DEFAULT_OUT_FILE, NEW_DEFAULT_OUT_FILE);
|
|
330
|
-
}
|
|
331
321
|
|
|
332
322
|
generateBlocks({ blocksDir, outFile }).catch((err) => {
|
|
333
323
|
console.error(err);
|
|
@@ -197,8 +197,8 @@ describe("generate-invoke.ts — default --apps-dir resolution", () => {
|
|
|
197
197
|
// at node_modules/@decocms/apps-vtex/src/invoke.ts — NOT at the package
|
|
198
198
|
// root. The old default only probed the root, never resolved on a site
|
|
199
199
|
// with npm-installed packages, and forced sites to pass
|
|
200
|
-
// `--apps-dir node_modules/@decocms/apps-vtex/src` by hand (
|
|
201
|
-
// migration workaround). The default must probe <pkg>/invoke.ts first,
|
|
200
|
+
// `--apps-dir node_modules/@decocms/apps-vtex/src` by hand (a production
|
|
201
|
+
// site's migration workaround). The default must probe <pkg>/invoke.ts first,
|
|
202
202
|
// then <pkg>/src/invoke.ts.
|
|
203
203
|
|
|
204
204
|
function makeSite(layout: "root" | "src"): { siteDir: string; cleanup: () => void } {
|