@decocms/tanstack 7.2.3 → 7.3.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.2.3",
3
+ "version": "7.3.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework binding for TanStack Start + Cloudflare Workers",
6
6
  "repository": {
@@ -22,9 +22,9 @@
22
22
  "lint:unused": "knip"
23
23
  },
24
24
  "dependencies": {
25
- "@decocms/blocks": "7.2.3",
26
- "@decocms/blocks-admin": "7.2.3",
27
- "@decocms/blocks-cli": "7.2.3",
25
+ "@decocms/blocks": "7.3.0",
26
+ "@decocms/blocks-admin": "7.3.0",
27
+ "@decocms/blocks-cli": "7.3.0",
28
28
  "@deco-cx/warp-node": "^0.3.16",
29
29
  "fast-json-patch": "^3.1.0",
30
30
  "ws": "^8.18.0"
@@ -27,6 +27,7 @@ import {
27
27
  getRequest,
28
28
  getRequestHeader,
29
29
  getRequestUrl,
30
+ setCookie,
30
31
  setResponseHeader,
31
32
  } from "@tanstack/react-start/server";
32
33
  import { createElement } from "react";
@@ -44,6 +45,12 @@ import {
44
45
  runSingleSectionLoader,
45
46
  } from "@decocms/blocks/cms";
46
47
  import type { DeferredSection, MatcherContext, PageSeo, ResolvedSection } from "@decocms/blocks/cms";
48
+ import {
49
+ parseSegmentCookie,
50
+ SEGMENT_COOKIE,
51
+ serializeSegmentCookie,
52
+ type StoredFlag,
53
+ } from "@decocms/blocks/sdk/flags";
47
54
  import { withTracing } from "@decocms/blocks/middleware/observability";
48
55
  import {
49
56
  type CacheProfileName,
@@ -78,6 +85,45 @@ export function setSectionChunkMap(map: Record<string, string>): void {
78
85
  type PageResult = Awaited<ReturnType<typeof loadCmsPageInternal>>;
79
86
  const pageInflight = new Map<string, Promise<PageResult>>();
80
87
 
88
+ /** One year — the cohort is sticky; the `pct` fingerprint handles re-rolls. */
89
+ const SEGMENT_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
90
+
91
+ /**
92
+ * Persist sticky A/B decisions recorded during resolution and return them for
93
+ * the client (analytics).
94
+ *
95
+ * Merges the freshly recorded flags over any already in the `deco_segment`
96
+ * cookie (so navigating to a second A/B page doesn't drop the first page's
97
+ * cohort), and only re-issues the cookie when the merged value changed.
98
+ * Setting the cookie also makes the worker skip caching THIS response
99
+ * (`deco_segment` is not a "safe" cookie), so an unassigned visitor never
100
+ * poisons the shared entry — their next request carries the cookie and hits
101
+ * the per-cohort cache entry.
102
+ *
103
+ * `encode: (v) => v` keeps the raw base64 intact — @decocms/apps
104
+ * OneDollarStats reads the cookie with `atob()` directly.
105
+ */
106
+ function persistFlags(matcherCtx: MatcherContext): StoredFlag[] {
107
+ const recorded = matcherCtx.flags ?? [];
108
+ if (!recorded.length) return [];
109
+
110
+ const raw = matcherCtx.cookies?.[SEGMENT_COOKIE];
111
+ const merged = new Map<string, StoredFlag>();
112
+ for (const f of parseSegmentCookie(raw)) merged.set(f.name, f);
113
+ for (const f of recorded) merged.set(f.name, f);
114
+
115
+ const serialized = serializeSegmentCookie([...merged.values()]);
116
+ if (serialized !== (raw ?? "")) {
117
+ setCookie(SEGMENT_COOKIE, serialized, {
118
+ path: "/",
119
+ maxAge: SEGMENT_COOKIE_MAX_AGE,
120
+ sameSite: "lax",
121
+ encode: (v) => v,
122
+ });
123
+ }
124
+ return recorded;
125
+ }
126
+
81
127
  async function loadCmsPageInternal(fullPath: string) {
82
128
  const [basePath] = fullPath.split("?");
83
129
  // On client-side navigation getRequestUrl() is the /_serverFn/... endpoint,
@@ -94,9 +140,11 @@ async function loadCmsPageInternal(fullPath: string) {
94
140
  cookies: getCookies(),
95
141
  request: originRequest,
96
142
  isClientNavigation: isClientNavigation(fullPath, serverUrl),
143
+ flags: [],
97
144
  };
98
145
  const page = await resolveDecoPage(basePath, matcherCtx);
99
146
  if (!page) return null;
147
+ const flags = persistFlags(matcherCtx);
100
148
 
101
149
  const request = new Request(urlWithSearch, {
102
150
  headers: originRequest.headers,
@@ -144,6 +192,7 @@ async function loadCmsPageInternal(fullPath: string) {
144
192
  pagePath: basePath,
145
193
  seo,
146
194
  device,
195
+ flags,
147
196
  siteGlobals: { rawRefs: globals.rawRefs },
148
197
  };
149
198
  }
@@ -179,9 +228,11 @@ export const loadCmsHomePage = createServerFn({ method: "GET" }).handler(async (
179
228
  cookies: getCookies(),
180
229
  request,
181
230
  isClientNavigation: isClientNavigation("/", serverUrl),
231
+ flags: [],
182
232
  };
183
233
  const page = await resolveDecoPage("/", matcherCtx);
184
234
  if (!page) return null;
235
+ const flags = persistFlags(matcherCtx);
185
236
  const [enrichedSections, globals] = await Promise.all([
186
237
  runSectionLoaders(page.resolvedSections, request),
187
238
  resolveSiteGlobals(),
@@ -208,6 +259,7 @@ export const loadCmsHomePage = createServerFn({ method: "GET" }).handler(async (
208
259
  pageUrl: serverUrl.toString(),
209
260
  seo,
210
261
  device,
262
+ flags,
211
263
  siteGlobals: { rawRefs: globals.rawRefs },
212
264
  };
213
265
  });
@@ -0,0 +1,113 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ _uninstallDefaultUserAgentForTests,
4
+ DECO_POWERED_BY,
5
+ DECO_USER_AGENT,
6
+ installDefaultUserAgent,
7
+ } from "./outboundHeaders";
8
+
9
+ /** Headers the wrapped baseFetch would put on the wire for a given call. */
10
+ function wireHeaders(call: readonly [RequestInfo | URL, RequestInit?]): Headers {
11
+ const [input, init] = call;
12
+ if (init?.headers !== undefined) return new Headers(init.headers);
13
+ if (input instanceof Request) return new Headers(input.headers);
14
+ return new Headers();
15
+ }
16
+
17
+ describe("installDefaultUserAgent", () => {
18
+ const realFetch = globalThis.fetch;
19
+
20
+ afterEach(() => {
21
+ _uninstallDefaultUserAgentForTests();
22
+ globalThis.fetch = realFetch;
23
+ vi.restoreAllMocks();
24
+ });
25
+
26
+ function install(userAgent?: string) {
27
+ const baseFetch = vi.fn(
28
+ async (_input: RequestInfo | URL, _init?: RequestInit) => new Response("ok"),
29
+ );
30
+ globalThis.fetch = baseFetch as unknown as typeof fetch;
31
+ installDefaultUserAgent(userAgent);
32
+ return baseFetch;
33
+ }
34
+
35
+ it("sets the default UA on a bare string fetch", async () => {
36
+ const baseFetch = install();
37
+ await fetch("https://api.example.com/products");
38
+
39
+ const headers = wireHeaders(baseFetch.mock.calls[0]);
40
+ expect(headers.get("user-agent")).toBe(DECO_USER_AGENT);
41
+ });
42
+
43
+ it("keeps the caller's UA from init.headers", async () => {
44
+ const baseFetch = install();
45
+ await fetch("https://api.example.com/", {
46
+ headers: { "user-agent": "deco-aws-app/1.0" },
47
+ });
48
+
49
+ // Untouched call: init passed through verbatim.
50
+ expect(baseFetch.mock.calls[0][1]).toEqual({
51
+ headers: { "user-agent": "deco-aws-app/1.0" },
52
+ });
53
+ });
54
+
55
+ it("keeps the UA of a Request input", async () => {
56
+ const baseFetch = install();
57
+ const req = new Request("https://api.example.com/", {
58
+ headers: { "User-Agent": "decocx/1.0" },
59
+ });
60
+ await fetch(req);
61
+
62
+ expect(baseFetch.mock.calls[0][0]).toBe(req);
63
+ expect(baseFetch.mock.calls[0][1]).toBeUndefined();
64
+ });
65
+
66
+ it("preserves a Request's other headers when injecting the UA", async () => {
67
+ const baseFetch = install();
68
+ await fetch(
69
+ new Request("https://api.example.com/", {
70
+ headers: { accept: "application/json", "x-api-key": "k" },
71
+ }),
72
+ );
73
+
74
+ const headers = wireHeaders(baseFetch.mock.calls[0]);
75
+ expect(headers.get("user-agent")).toBe(DECO_USER_AGENT);
76
+ expect(headers.get("accept")).toBe("application/json");
77
+ expect(headers.get("x-api-key")).toBe("k");
78
+ });
79
+
80
+ it("preserves non-header init members (method, body, signal)", async () => {
81
+ const baseFetch = install();
82
+ const controller = new AbortController();
83
+ await fetch("https://api.example.com/", {
84
+ method: "POST",
85
+ body: "{}",
86
+ signal: controller.signal,
87
+ });
88
+
89
+ const init = baseFetch.mock.calls[0][1]!;
90
+ expect(init.method).toBe("POST");
91
+ expect(init.body).toBe("{}");
92
+ expect(init.signal).toBe(controller.signal);
93
+ expect(new Headers(init.headers).get("user-agent")).toBe(DECO_USER_AGENT);
94
+ });
95
+
96
+ it("honors a custom UA value", async () => {
97
+ const baseFetch = install("MyStore-Migration/1.0");
98
+ await fetch("https://api.example.com/");
99
+
100
+ expect(wireHeaders(baseFetch.mock.calls[0]).get("user-agent")).toBe("MyStore-Migration/1.0");
101
+ });
102
+
103
+ it("is idempotent — a second install does not re-wrap", () => {
104
+ install();
105
+ const wrapped = globalThis.fetch;
106
+ installDefaultUserAgent();
107
+ expect(globalThis.fetch).toBe(wrapped);
108
+ });
109
+
110
+ it("exposes the old-runtime-parity x-powered-by value", () => {
111
+ expect(DECO_POWERED_BY).toMatch(/^deco@\d+\.\d+\.\d+/);
112
+ });
113
+ });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Runtime identification headers.
3
+ *
4
+ * Outbound: Cloudflare Workers' `fetch` sends NO `User-Agent` header at
5
+ * all — unlike the old Deno runtime, where every outbound request carried
6
+ * Deno's implicit `User-Agent: Deno/x.y.z`. UA-less requests are a strong
7
+ * bot signal: Cloudflare managed WAF rules and Bot Fight Mode on partner
8
+ * origins block them outright. `installDefaultUserAgent()` restores the
9
+ * pre-migration behavior with an honest, allowlistable value, applied only
10
+ * when the caller didn't set a User-Agent of its own.
11
+ *
12
+ * Responses: the old runtime stamped `x-powered-by: deco@<version>` on
13
+ * every response (deco-cx/deco `utils/http.ts` defaultHeaders). The worker
14
+ * entry reuses `DECO_POWERED_BY` for parity.
15
+ */
16
+
17
+ import pkg from "../../package.json";
18
+
19
+ /**
20
+ * Default outbound User-Agent. Stable prefix (`Deco/`) so partners can
21
+ * write one WAF allowlist rule that survives version bumps and platform
22
+ * changes; the URL comment tells whoever reads an access log who we are.
23
+ */
24
+ export const DECO_USER_AGENT = `Deco/${pkg.version} (+https://deco.cx)`;
25
+
26
+ /** Response identification header value — parity with deco-cx/deco. */
27
+ export const DECO_POWERED_BY = `deco@${pkg.version}`;
28
+
29
+ /**
30
+ * Cross-module guard on `globalThis` (same trick as logger.ts STATE_KEY):
31
+ * bundlers may duplicate this module across chunks, so a module-local
32
+ * boolean would not prevent double-wrapping.
33
+ */
34
+ const INSTALLED_KEY = Symbol.for("deco.outboundHeaders.installed");
35
+
36
+ let restoreBaseFetch: (() => void) | undefined;
37
+
38
+ /**
39
+ * Patch `globalThis.fetch` so every outbound request carries a User-Agent.
40
+ *
41
+ * Set-if-absent only: app-specific UAs (`deco-aws-app/1.0`, `decocx/1.0`,
42
+ * ...) always win, matching the old runtime where Deno's default applied
43
+ * only when unset. All other RequestInit members (method, body, signal,
44
+ * `cf`, ...) pass through untouched. Idempotent — safe to call more than
45
+ * once (e.g. worker entry + tests).
46
+ */
47
+ export function installDefaultUserAgent(userAgent: string = DECO_USER_AGENT): void {
48
+ const g = globalThis as Record<symbol, unknown>;
49
+ if (g[INSTALLED_KEY]) return;
50
+ g[INSTALLED_KEY] = true;
51
+
52
+ const baseFetch = globalThis.fetch;
53
+ restoreBaseFetch = () => {
54
+ globalThis.fetch = baseFetch;
55
+ delete g[INSTALLED_KEY];
56
+ };
57
+
58
+ globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
59
+ // Fetch-spec semantics (same rule instrumentedFetch.ts documents):
60
+ // when both a Request and `init.headers` are passed, `init.headers`
61
+ // REPLACES the Request's headers — they do not union. So the headers
62
+ // that will actually hit the wire are init.headers if present, else
63
+ // the Request's own headers, else none.
64
+ const base =
65
+ init?.headers !== undefined
66
+ ? init.headers
67
+ : input instanceof Request
68
+ ? input.headers
69
+ : undefined;
70
+ const headers = new Headers(base ?? undefined);
71
+ if (headers.has("user-agent")) return baseFetch(input, init);
72
+ headers.set("user-agent", userAgent);
73
+ return baseFetch(input, { ...init, headers });
74
+ }) as typeof fetch;
75
+ }
76
+
77
+ /** Test-only: restore the unpatched fetch. Do not call from app code. */
78
+ export function _uninstallDefaultUserAgentForTests(): void {
79
+ restoreBaseFetch?.();
80
+ restoreBaseFetch = undefined;
81
+ }
@@ -45,6 +45,7 @@ import {
45
45
  getCacheProfile,
46
46
  serverFnPagePath,
47
47
  } from "@decocms/blocks/sdk/cacheHeaders";
48
+ import { parseSegmentCookie, SEGMENT_COOKIE, segmentCacheToken } from "@decocms/blocks/sdk/flags";
48
49
  import { buildHtmlShell } from "@decocms/blocks-admin/sdk/htmlShell";
49
50
  import { ensureBlocksHydrated, maybePollRevision } from "./kvHydration";
50
51
  import {
@@ -61,6 +62,7 @@ import {
61
62
  instrumentWorker,
62
63
  type OtelOptions,
63
64
  } from "@decocms/blocks/sdk/otel";
65
+ import { DECO_POWERED_BY, installDefaultUserAgent } from "./outboundHeaders";
64
66
  import { setRuntimeEnv } from "@decocms/blocks/sdk/otelAdapters";
65
67
  import { parseTraceparent } from "@decocms/blocks/sdk/otelHttpTracer";
66
68
  import { RequestContext } from "@decocms/blocks/sdk/requestContext";
@@ -434,6 +436,25 @@ export interface DecoWorkerEntryOptions {
434
436
  * no-op — no buffers, no flushes, no network calls.
435
437
  */
436
438
  observability?: OtelOptions | false;
439
+
440
+ /**
441
+ * Default `User-Agent` for OUTBOUND `fetch` calls (loaders, commerce
442
+ * APIs, proxies). Cloudflare Workers sends no User-Agent at all, and
443
+ * UA-less requests are blocked by common WAF setups (Cloudflare managed
444
+ * rules / Bot Fight Mode) on partner origins. The old Deno runtime
445
+ * implicitly sent `Deno/x.y.z` on every outbound request, so this
446
+ * restores pre-migration behavior with an identifiable value.
447
+ *
448
+ * Applied only when the caller didn't set a User-Agent — app-specific
449
+ * UAs always win.
450
+ *
451
+ * - Pass a string to override the value (e.g. to match a partner's
452
+ * existing WAF allowlist during migration).
453
+ * - Pass `false` to leave `globalThis.fetch` untouched.
454
+ *
455
+ * @default `Deco/<version> (+https://deco.cx)` — see DECO_USER_AGENT.
456
+ */
457
+ outboundUserAgent?: string | false;
437
458
  }
438
459
 
439
460
  // ---------------------------------------------------------------------------
@@ -707,8 +728,18 @@ export function createDecoWorkerEntry(
707
728
  staticPaths: staticPathsOpt = DEFAULT_STATIC_PATHS,
708
729
  cdnCacheControl: cdnCacheControlOpt = "no-store",
709
730
  observability: observabilityOpt,
731
+ outboundUserAgent: outboundUserAgentOpt,
710
732
  } = options;
711
733
 
734
+ // Patch global fetch once so every outbound request — apps calling
735
+ // `fetch` directly, RequestContext.fetch, instrumentedFetch — carries a
736
+ // User-Agent. Workers sends none by default, which WAFs on partner
737
+ // origins read as bot traffic. Set-if-absent: callers that set their
738
+ // own UA are untouched.
739
+ if (outboundUserAgentOpt !== false) {
740
+ installDefaultUserAgent(outboundUserAgentOpt);
741
+ }
742
+
712
743
  // Backfill `regionId` from Cloudflare geo when the consumer's buildSegment
713
744
  // doesn't set one. Without this, sites using website/matchers/location.ts
714
745
  // get a single cached response per device that leaks across regions: the
@@ -830,6 +861,25 @@ export function createDecoWorkerEntry(
830
861
  return typeof __DECO_BUILD_HASH__ !== "undefined" ? __DECO_BUILD_HASH__ : "";
831
862
  }
832
863
 
864
+ function readRequestCookie(request: Request, name: string): string | undefined {
865
+ for (const pair of (request.headers.get("cookie") ?? "").split(";")) {
866
+ const idx = pair.indexOf("=");
867
+ if (idx <= 0) continue;
868
+ if (pair.slice(0, idx).trim() !== name) continue;
869
+ const raw = pair.slice(idx + 1).trim();
870
+ // Decode once so this matches TanStack's getCookies() used during SSR
871
+ // (see cms/resolve.ts) — otherwise the worker's cache key and the render
872
+ // could disagree on the cohort. The deco_segment value is raw base64 (no
873
+ // percent-escapes), so decoding is a no-op for it and safe in general.
874
+ try {
875
+ return decodeURIComponent(raw);
876
+ } catch {
877
+ return raw;
878
+ }
879
+ }
880
+ return undefined;
881
+ }
882
+
833
883
  function buildCacheKey(
834
884
  request: Request,
835
885
  env: Record<string, unknown>,
@@ -904,6 +954,18 @@ export function createDecoWorkerEntry(
904
954
  url.searchParams.set("__fetch", "1");
905
955
  }
906
956
 
957
+ // A/B cohort split: fold the sticky `deco_segment` cookie into the cache
958
+ // key so each variant keeps its own cached HTML. The cookie carries the
959
+ // traffic fingerprint (see sdk/flags.ts), so a `traffic` change lands
960
+ // cohorts in fresh buckets. Empty for non-A/B visitors (no cookie) → they
961
+ // keep sharing one entry. A freshly-assigned visitor sets the cookie on the
962
+ // response, which is not a "safe" cookie, so that one response bypasses the
963
+ // cache and never poisons the shared entry.
964
+ const flagToken = segmentCacheToken(parseSegmentCookie(readRequestCookie(request, SEGMENT_COOKIE)));
965
+ if (flagToken) {
966
+ url.searchParams.set("__abf", flagToken);
967
+ }
968
+
907
969
  if (buildSegment) {
908
970
  const segment = buildSegment(request);
909
971
  url.searchParams.set("__seg", hashSegment(segment));
@@ -1311,6 +1373,18 @@ export function createDecoWorkerEntry(
1311
1373
  }
1312
1374
  }
1313
1375
 
1376
+ // Old-runtime parity: deco-cx/deco stamped `x-powered-by: deco@<ver>`
1377
+ // on every response (utils/http.ts defaultHeaders); partner-side logs
1378
+ // and ops tooling use it to identify deco traffic. Set-if-absent so
1379
+ // an upstream origin's own value wins.
1380
+ if (!finalResponse.headers.has("x-powered-by")) {
1381
+ try {
1382
+ finalResponse.headers.set("x-powered-by", DECO_POWERED_BY);
1383
+ } catch {
1384
+ /* sealed headers (cached replay) — identification is best-effort */
1385
+ }
1386
+ }
1387
+
1314
1388
  // Metrics + structured request log. Done after security headers so
1315
1389
  // the recorded status reflects what the client actually receives.
1316
1390
  // Both calls are no-ops when no meter / logger is configured.
@@ -1318,8 +1392,9 @@ export function createDecoWorkerEntry(
1318
1392
  try {
1319
1393
  // Phase 2 / D-11 canonical labels — lift the cache decision +
1320
1394
  // profile + region off the response we just built so dashboards
1321
- // can answer "cache hit rate per route" from `http_requests_total`
1322
- // alone, no join to `cache_*_total` required.
1395
+ // can answer "cache hit rate per route" from
1396
+ // `http.server.request.duration` alone, no join to a cache counter
1397
+ // required.
1323
1398
  const xCacheRaw = finalResponse.headers.get("X-Cache");
1324
1399
  const cacheDecision = isCacheDecision(xCacheRaw) ? xCacheRaw : undefined;
1325
1400
  const colo = (request as unknown as { cf?: { colo?: string } }).cf?.colo;
package/tsconfig.json CHANGED
@@ -3,5 +3,5 @@
3
3
  "compilerOptions": {
4
4
  "outDir": "dist"
5
5
  },
6
- "include": ["src/**/*"]
6
+ "include": ["src/**/*", "package.json"]
7
7
  }