@decocms/tanstack 7.12.0 → 7.14.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/tanstack",
3
- "version": "7.12.0",
3
+ "version": "7.14.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework binding for TanStack Start + Cloudflare Workers",
6
6
  "repository": {
@@ -24,9 +24,9 @@
24
24
  "lint:unused": "knip"
25
25
  },
26
26
  "dependencies": {
27
- "@decocms/blocks": "7.12.0",
28
- "@decocms/blocks-admin": "7.12.0",
29
- "@decocms/blocks-cli": "7.12.0",
27
+ "@decocms/blocks": "7.14.0",
28
+ "@decocms/blocks-admin": "7.14.0",
29
+ "@decocms/blocks-cli": "7.14.0",
30
30
  "@deco-cx/warp-node": "^0.3.16",
31
31
  "fast-json-patch": "^3.1.0",
32
32
  "ws": "^8.18.0"
@@ -205,11 +205,19 @@ export const loadCmsPage = createServerFn({ method: "GET" })
205
205
  // Use the full path (including query string) as the dedup key.
206
206
  // Using basePath only caused /s?q=a and /s?q=b to share one promise,
207
207
  // returning wrong/empty results for search and filtered PLPs.
208
- const existing = pageInflight.get(fullPath);
208
+ //
209
+ // Client-nav resolves all sections eagerly (#277). An in-flight SSR response
210
+ // for the same path may carry deferredSections; sharing that promise with a
211
+ // concurrent SPA transition would smuggle those deferred sections in, causing
212
+ // loadDeferredSection to run without the per-request commerce app context.
213
+ // Use separate dedup buckets for SSR vs client-nav.
214
+ const clientNav = isClientNavigation(fullPath, getRequestUrl());
215
+ const inflightKey = clientNav ? `__nav:${fullPath}` : fullPath;
216
+ const existing = pageInflight.get(inflightKey);
209
217
  if (existing) return existing;
210
218
 
211
- const promise = loadCmsPageInternal(fullPath).finally(() => pageInflight.delete(fullPath));
212
- pageInflight.set(fullPath, promise);
219
+ const promise = loadCmsPageInternal(fullPath).finally(() => pageInflight.delete(inflightKey));
220
+ pageInflight.set(inflightKey, promise);
213
221
  return promise;
214
222
  });
215
223
 
@@ -1,5 +1,13 @@
1
- import { describe, expect, it } from "vitest";
2
- import { injectGeoCookies } from "./workerEntry";
1
+ import { setBlocks } from "@decocms/blocks/cms";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { buildGeoCacheParam, createDecoWorkerEntry, detectLocationMatcher, injectGeoCookies } from "./workerEntry";
4
+ import { __resetKvHydrationStateForTests } from "./kvHydration";
5
+
6
+ const EMPTY_ENV = {};
7
+ const MOCK_CTX = { waitUntil: (_p: Promise<unknown>) => {} };
8
+ const MOCK_SERVER_ENTRY = {
9
+ fetch: async (_req: Request) => new Response("page content", { status: 200 }),
10
+ };
3
11
 
4
12
  function parseCookies(header: string): Record<string, string> {
5
13
  return Object.fromEntries(
@@ -104,3 +112,204 @@ describe("injectGeoCookies", () => {
104
112
  expect(out.headers.get("cf-ray")).toBe("9ff5b26cf9bc067a");
105
113
  });
106
114
  });
115
+
116
+ describe("buildGeoCacheParam", () => {
117
+ const cf = { country: "BR", region: "São Paulo", city: "SP" };
118
+
119
+ it("returns undefined when granularity is off", () => {
120
+ expect(buildGeoCacheParam(cf, "off")).toBeUndefined();
121
+ });
122
+
123
+ it("returns undefined when cf is undefined", () => {
124
+ expect(buildGeoCacheParam(undefined, "city")).toBeUndefined();
125
+ });
126
+
127
+ it("returns country only when granularity is country", () => {
128
+ expect(buildGeoCacheParam(cf, "country")).toBe("BR");
129
+ });
130
+
131
+ it("returns country|region when granularity is region", () => {
132
+ expect(buildGeoCacheParam(cf, "region")).toBe("BR|São Paulo");
133
+ });
134
+
135
+ it("returns country|region|city when granularity is city", () => {
136
+ expect(buildGeoCacheParam(cf, "city")).toBe("BR|São Paulo|SP");
137
+ });
138
+
139
+ it("omits missing fields gracefully", () => {
140
+ expect(buildGeoCacheParam({ country: "BR" }, "city")).toBe("BR");
141
+ expect(buildGeoCacheParam({ country: "BR", region: "MG" }, "city")).toBe("BR|MG");
142
+ });
143
+
144
+ it("returns undefined when cf has none of country/region/city", () => {
145
+ expect(buildGeoCacheParam({ asn: "12345" }, "city")).toBeUndefined();
146
+ });
147
+ });
148
+
149
+ describe("detectLocationMatcher", () => {
150
+ it("returns true when decofile has a website/matchers/location.ts __resolveType", () => {
151
+ const blocks = {
152
+ "audiences/geo-audience.json": {
153
+ "__resolveType": "website/flags/audience.ts",
154
+ "matcher": { "__resolveType": "website/matchers/location.ts", "includeLocations": [{ "country": "BR" }] },
155
+ },
156
+ };
157
+ expect(detectLocationMatcher(blocks)).toBe(true);
158
+ });
159
+
160
+ it("returns false when decofile has no location matcher", () => {
161
+ const blocks = {
162
+ "audiences/device-audience.json": {
163
+ "__resolveType": "website/flags/audience.ts",
164
+ "matcher": { "__resolveType": "website/matchers/device.ts" },
165
+ },
166
+ };
167
+ expect(detectLocationMatcher(blocks)).toBe(false);
168
+ });
169
+
170
+ it("returns false for an empty decofile", () => {
171
+ expect(detectLocationMatcher({})).toBe(false);
172
+ });
173
+
174
+ it("returns true when the matcher is nested deeply", () => {
175
+ const blocks = {
176
+ "pages/home.json": {
177
+ "variant": {
178
+ "matcher": { "__resolveType": "website/matchers/location.ts" },
179
+ },
180
+ },
181
+ };
182
+ expect(detectLocationMatcher(blocks)).toBe(true);
183
+ });
184
+
185
+ it("returns false when location.ts appears only in a non-resolveType string value (no false positive)", () => {
186
+ const blocks = {
187
+ "content/help.json": {
188
+ "__resolveType": "website/sections/RichText.tsx",
189
+ "body": "This page is controlled by website/matchers/location.ts for geo targeting.",
190
+ },
191
+ };
192
+ expect(detectLocationMatcher(blocks)).toBe(false);
193
+ });
194
+ });
195
+
196
+ describe("CMS redirects", () => {
197
+ afterEach(() => {
198
+ setBlocks({});
199
+ __resetKvHydrationStateForTests();
200
+ });
201
+
202
+ it("returns a 301 redirect for a permanent redirect block", async () => {
203
+ setBlocks({
204
+ "redirect-1": {
205
+ __resolveType: "website/loaders/redirect.ts",
206
+ redirects: [{ from: "/old", to: "/new", type: "permanent" }],
207
+ },
208
+ });
209
+ const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, { observability: false });
210
+ const res = await worker.fetch(
211
+ new Request("https://example.com/old"),
212
+ EMPTY_ENV,
213
+ MOCK_CTX,
214
+ );
215
+ expect(res.status).toBe(301);
216
+ expect(res.headers.get("Location")).toBe("/new");
217
+ });
218
+
219
+ it("redirects ?asJson requests too (does not fall through to page resolver)", async () => {
220
+ setBlocks({
221
+ "redirect-1": {
222
+ __resolveType: "website/loaders/redirect.ts",
223
+ redirects: [{ from: "/old", to: "/new", type: "permanent" }],
224
+ },
225
+ });
226
+ const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, { observability: false });
227
+ const res = await worker.fetch(
228
+ new Request("https://example.com/old?asJson"),
229
+ EMPTY_ENV,
230
+ MOCK_CTX,
231
+ );
232
+ expect(res.status).toBe(301);
233
+ expect(res.headers.get("Location")).toBe("/new");
234
+ });
235
+
236
+ it("URI-encodes the Location header for non-ASCII destinations", async () => {
237
+ setBlocks({
238
+ "redirect-1": {
239
+ __resolveType: "website/loaders/redirect.ts",
240
+ redirects: [{ from: "/promo", to: "/promoção", type: "temporary" }],
241
+ },
242
+ });
243
+ const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, { observability: false });
244
+ const res = await worker.fetch(
245
+ new Request("https://example.com/promo"),
246
+ EMPTY_ENV,
247
+ MOCK_CTX,
248
+ );
249
+ expect(res.status).toBe(302);
250
+ expect(res.headers.get("Location")).toBe("/promo%C3%A7%C3%A3o");
251
+ });
252
+
253
+ it("returns a 302 redirect for a temporary redirect block", async () => {
254
+ setBlocks({
255
+ "redirect-1": {
256
+ __resolveType: "website/loaders/redirect.ts",
257
+ redirects: [{ from: "/promo", to: "/sale", type: "temporary" }],
258
+ },
259
+ });
260
+ const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, { observability: false });
261
+ const res = await worker.fetch(
262
+ new Request("https://example.com/promo"),
263
+ EMPTY_ENV,
264
+ MOCK_CTX,
265
+ );
266
+ expect(res.status).toBe(302);
267
+ expect(res.headers.get("Location")).toBe("/sale");
268
+ });
269
+
270
+ it("falls through to serverEntry for paths with no redirect", async () => {
271
+ setBlocks({
272
+ "redirect-1": {
273
+ __resolveType: "website/loaders/redirect.ts",
274
+ redirects: [{ from: "/old", to: "/new", type: "permanent" }],
275
+ },
276
+ });
277
+ const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, { observability: false });
278
+ const res = await worker.fetch(
279
+ new Request("https://example.com/other"),
280
+ EMPTY_ENV,
281
+ MOCK_CTX,
282
+ );
283
+ expect(res.status).toBe(200);
284
+ });
285
+
286
+ it("picks up new redirects after setBlocks hot-reload", async () => {
287
+ setBlocks({});
288
+ const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, { observability: false });
289
+
290
+ // First request with no redirects — falls through
291
+ const res1 = await worker.fetch(
292
+ new Request("https://example.com/v1"),
293
+ EMPTY_ENV,
294
+ MOCK_CTX,
295
+ );
296
+ expect(res1.status).toBe(200);
297
+
298
+ // Hot-reload: add a redirect
299
+ setBlocks({
300
+ "redirect-1": {
301
+ __resolveType: "website/loaders/redirect.ts",
302
+ redirects: [{ from: "/v1", to: "/v2", type: "permanent" }],
303
+ },
304
+ });
305
+
306
+ // Same path should now redirect
307
+ const res2 = await worker.fetch(
308
+ new Request("https://example.com/v1"),
309
+ EMPTY_ENV,
310
+ MOCK_CTX,
311
+ );
312
+ expect(res2.status).toBe(301);
313
+ expect(res2.headers.get("Location")).toBe("/v2");
314
+ });
315
+ });
@@ -30,12 +30,15 @@ import { getAppMiddleware } from "@decocms/blocks-admin/sdk/setupApps";
30
30
  import {
31
31
  isBot,
32
32
  loadBlocks,
33
+ getRevision,
33
34
  type MatcherContext,
35
+ onChange,
34
36
  resolveDecoPage,
35
37
  runSectionLoaders,
36
38
  runSingleSectionLoader,
37
39
  } from "@decocms/blocks/cms";
38
40
  import { DECO_MATCHERS_OVERRIDE_PARAM } from "@decocms/blocks/matchers/override";
41
+ import { loadRedirects, matchRedirect, type RedirectMap } from "@decocms/blocks/sdk/redirects";
39
42
  import {
40
43
  type CacheProfileName,
41
44
  cacheHeaders,
@@ -363,6 +366,29 @@ export interface DecoWorkerEntryOptions {
363
366
  */
364
367
  autoInjectGeoCookies?: boolean;
365
368
 
369
+ /**
370
+ * Controls how Cloudflare geo data is folded into edge cache keys.
371
+ *
372
+ * - `"auto"` (default): inspects the loaded decofile on every `setBlocks()`
373
+ * call (startup + fast-deploy swaps). If any block is configured with
374
+ * `website/matchers/location.ts`, geo keying is activated at `"region"`
375
+ * granularity (country+state); otherwise it is set to `"off"`. `"region"`
376
+ * covers VTEX regionalization and most geo matchers without city-level
377
+ * fragmentation. Sites that need city precision should set `"city"`
378
+ * explicitly. No manual configuration needed for the common case.
379
+ * - `"city"`: full `country|region|city` key — maximum isolation for sites
380
+ * with city-level location matchers.
381
+ * - `"region"`: `country|region` — good for region/state matchers (e.g.
382
+ * VTEX regionalization), avoids city-level fragmentation.
383
+ * - `"country"`: country only.
384
+ * - `"off"`: no `__cf_geo` param is added and the automatic `regionId`
385
+ * backfill from CF geo is disabled. Use for single-catalog storefronts
386
+ * that do not vary content by location — recovers the full edge HIT rate.
387
+ *
388
+ * @default "auto"
389
+ */
390
+ geoCacheKey?: "auto" | "off" | "country" | "region" | "city";
391
+
366
392
  /**
367
393
  * Cookie names considered "safe" for caching — these are public/anonymous
368
394
  * cookies that do not carry per-user session or auth data.
@@ -501,6 +527,43 @@ function buildPreviewShell(): string {
501
527
  * };
502
528
  * ```
503
529
  */
530
+ /**
531
+ * Returns `true` when the decofile contains at least one block whose
532
+ * `__resolveType` is `website/matchers/location.ts`. Used by
533
+ * `geoCacheKey: "auto"` to decide whether geo cache key fragmentation
534
+ * should be active.
535
+ *
536
+ * Matches only resolver-type references (not arbitrary string values) to
537
+ * avoid false positives from content/label fields that happen to mention
538
+ * the path.
539
+ */
540
+ export function detectLocationMatcher(blocks: Record<string, unknown>): boolean {
541
+ return JSON.stringify(blocks).includes('"__resolveType":"website/matchers/location.ts"');
542
+ }
543
+
544
+ /**
545
+ * Build the `__cf_geo` cache-key fragment from Cloudflare's `request.cf`
546
+ * object at the requested granularity.
547
+ *
548
+ * Returns `undefined` when `granularity` is `"off"`, `cf` is absent, or no
549
+ * relevant fields are present.
550
+ *
551
+ * @example
552
+ * buildGeoCacheParam({ country: "BR", region: "São Paulo", city: "SP" }, "region")
553
+ * // → "BR|São Paulo"
554
+ */
555
+ export function buildGeoCacheParam(
556
+ cf: Record<string, string> | undefined,
557
+ granularity: "off" | "country" | "region" | "city",
558
+ ): string | undefined {
559
+ if (granularity === "off" || !cf) return undefined;
560
+ const parts: string[] = [];
561
+ if (cf.country) parts.push(cf.country);
562
+ if (granularity !== "country" && cf.region) parts.push(cf.region);
563
+ if (granularity === "city" && cf.city) parts.push(cf.city);
564
+ return parts.length ? parts.join("|") : undefined;
565
+ }
566
+
504
567
  export function injectGeoCookies(request: Request): Request {
505
568
  const cf = (request as unknown as { cf?: Record<string, string> }).cf;
506
569
  if (!cf) return request;
@@ -677,6 +740,28 @@ function deduplicateSetCookies(response: Response): void {
677
740
 
678
741
  const FINGERPRINTED_ASSET_RE = /(?:\/_build)?\/assets\/.*-[a-zA-Z0-9_-]{8,}\.\w+$/;
679
742
 
743
+ // ---------------------------------------------------------------------------
744
+ // Auto geo-key detection (module-level singleton)
745
+ // ---------------------------------------------------------------------------
746
+ // Registered once so that multiple createDecoWorkerEntry calls in the same
747
+ // isolate (e.g. integration tests) don't accumulate listeners. Uses "region"
748
+ // (country+state) as the auto granularity — covers VTEX regionalization and
749
+ // most geo matchers without the city-level fragmentation (~hundreds of buckets).
750
+ // Sites that need city precision should set geoCacheKey: "city" explicitly.
751
+
752
+ let _autoGeoKey: "off" | "region" = "off";
753
+ let _autoGeoListenerInstalled = false;
754
+
755
+ function installAutoGeoListener(): void {
756
+ if (_autoGeoListenerInstalled) return;
757
+ _autoGeoListenerInstalled = true;
758
+ const update = (blocks: Record<string, unknown>) => {
759
+ _autoGeoKey = detectLocationMatcher(blocks) ? "region" : "off";
760
+ };
761
+ onChange(update);
762
+ update(loadBlocks());
763
+ }
764
+
680
765
  const IMMUTABLE_HEADERS: Record<string, string> = {
681
766
  "Cache-Control": `public, max-age=${ONE_YEAR}, immutable`,
682
767
  Vary: "Accept-Encoding",
@@ -691,6 +776,14 @@ async function hashText(text: string): Promise<string> {
691
776
  .join("");
692
777
  }
693
778
 
779
+ // ---------------------------------------------------------------------------
780
+ // CMS redirect cache — keyed by revision so cross-bundle setBlocks() calls
781
+ // (Vite server-function split) are detected via globalThis.__deco.revision.
782
+ // ---------------------------------------------------------------------------
783
+
784
+ let _redirectMap: RedirectMap | null = null;
785
+ let _redirectMapRevision: string | null = null;
786
+
694
787
  // ---------------------------------------------------------------------------
695
788
  // Factory
696
789
  // ---------------------------------------------------------------------------
@@ -724,6 +817,7 @@ export function createDecoWorkerEntry(
724
817
  securityHeaders: securityHeadersOpt,
725
818
  csp: cspOpt,
726
819
  autoInjectGeoCookies: geoOpt = true,
820
+ geoCacheKey: geoCacheKeyOpt = "auto",
727
821
  safeCookies: safeCookiesOpt = DEFAULT_SAFE_COOKIES,
728
822
  staticPaths: staticPathsOpt = DEFAULT_STATIC_PATHS,
729
823
  cdnCacheControl: cdnCacheControlOpt = "no-store",
@@ -740,6 +834,19 @@ export function createDecoWorkerEntry(
740
834
  installDefaultUserAgent(outboundUserAgentOpt);
741
835
  }
742
836
 
837
+ // When geoCacheKey is "auto", install the module-level listener that watches
838
+ // the decofile for website/matchers/location.ts. The listener is registered
839
+ // only once regardless of how many times this factory is called, so there is
840
+ // no listener accumulation in tests or dynamic worker-composition scenarios.
841
+ if (geoCacheKeyOpt === "auto") {
842
+ installAutoGeoListener();
843
+ }
844
+
845
+ /** Resolve the effective geo granularity for the current request. */
846
+ function effectiveGeoKey(): "off" | "country" | "region" | "city" {
847
+ return geoCacheKeyOpt === "auto" ? _autoGeoKey : geoCacheKeyOpt;
848
+ }
849
+
743
850
  // Backfill `regionId` from Cloudflare geo when the consumer's buildSegment
744
851
  // doesn't set one. Without this, sites using website/matchers/location.ts
745
852
  // get a single cached response per device that leaks across regions: the
@@ -760,6 +867,7 @@ export function createDecoWorkerEntry(
760
867
  ? (request: Request): SegmentKey => {
761
868
  const seg = rawBuildSegment(request);
762
869
  if (seg.regionId) return seg;
870
+ if (effectiveGeoKey() === "off") return seg;
763
871
  const region = readRegionFromRequest(request);
764
872
  return region ? { ...seg, regionId: region } : seg;
765
873
  }
@@ -913,17 +1021,14 @@ export function createDecoWorkerEntry(
913
1021
  }
914
1022
 
915
1023
  // Include CF geo data in cache key so location matcher results don't leak
916
- // across different geos. Applies to both segment and device-based keys.
917
- const cf = (request as unknown as { cf?: Record<string, string> }).cf;
918
- if (cf) {
919
- const geoParts: string[] = [];
920
- if (cf.country) geoParts.push(cf.country);
921
- if (cf.region) geoParts.push(cf.region);
922
- if (cf.city) geoParts.push(cf.city);
923
- if (geoParts.length) {
924
- url.searchParams.set("__cf_geo", geoParts.join("|"));
925
- }
926
- }
1024
+ // across different geos. Granularity is controlled by `geoCacheKey`:
1025
+ // "city" (default) country|region|city, "region" country|region,
1026
+ // "country" → country only, "off" → omitted entirely.
1027
+ const geoParam = buildGeoCacheParam(
1028
+ (request as unknown as { cf?: Record<string, string> }).cf,
1029
+ effectiveGeoKey(),
1030
+ );
1031
+ if (geoParam) url.searchParams.set("__cf_geo", geoParam);
927
1032
 
928
1033
  // Bots render every section eagerly (shouldDeferSection short-circuits in
929
1034
  // resolve.ts), producing a ~10x larger HTML payload (all eager-section
@@ -1449,6 +1554,23 @@ export function createDecoWorkerEntry(
1449
1554
  return handlePurge(request, env);
1450
1555
  }
1451
1556
 
1557
+ // CMS redirects (website/loaders/redirect.ts blocks) — checked before
1558
+ // ?asJson and commerce proxy so every request path respects redirects.
1559
+ // Revision-keyed so cross-bundle setBlocks() calls are detected even when
1560
+ // onChange listeners don't fire across Vite split-bundle module instances.
1561
+ const currentRevision = getRevision();
1562
+ if (_redirectMapRevision !== currentRevision) {
1563
+ _redirectMap = loadRedirects(loadBlocks());
1564
+ _redirectMapRevision = currentRevision;
1565
+ }
1566
+ const cmsRedirect = matchRedirect(url.pathname, _redirectMap!);
1567
+ if (cmsRedirect) {
1568
+ return new Response(null, {
1569
+ status: cmsRedirect.status,
1570
+ headers: { Location: encodeURI(cmsRedirect.to) },
1571
+ });
1572
+ }
1573
+
1452
1574
  // ?asJson — return resolved page data as JSON (legacy deco compat)
1453
1575
  if (url.searchParams.has("asJson") && request.method === "GET") {
1454
1576
  const basePath = url.pathname;
@@ -1644,15 +1766,14 @@ export function createDecoWorkerEntry(
1644
1766
  const device = isMobileUA(request.headers.get("user-agent") ?? "") ? "mobile" : "desktop";
1645
1767
  cacheKeyUrl.searchParams.set("__cf_device", device);
1646
1768
  }
1647
- // Include CF geo data so location-based content doesn't leak across geos
1648
- const cf = (request as unknown as { cf?: Record<string, string> }).cf;
1649
- if (cf) {
1650
- const geoParts: string[] = [];
1651
- if (cf.country) geoParts.push(cf.country);
1652
- if (cf.region) geoParts.push(cf.region);
1653
- if (cf.city) geoParts.push(cf.city);
1654
- if (geoParts.length) cacheKeyUrl.searchParams.set("__cf_geo", geoParts.join("|"));
1655
- }
1769
+ // Include CF geo data so location-based content doesn't leak across geos.
1770
+ // Uses the same effectiveGeoKey() as the GET path so geoCacheKey config
1771
+ // is respected consistently for both HTML and server-fn responses.
1772
+ const sfnGeoParam = buildGeoCacheParam(
1773
+ (request as unknown as { cf?: Record<string, string> }).cf,
1774
+ effectiveGeoKey(),
1775
+ );
1776
+ if (sfnGeoParam) cacheKeyUrl.searchParams.set("__cf_geo", sfnGeoParam);
1656
1777
  const sfnCacheKey = new Request(cacheKeyUrl.toString(), { method: "GET" });
1657
1778
 
1658
1779
  // Use "listing" profile for server function responses