@decocms/tanstack 7.2.2 → 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.2",
3
+ "version": "7.3.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework binding for TanStack Start + Cloudflare Workers",
6
6
  "repository": {
@@ -12,7 +12,8 @@
12
12
  "exports": {
13
13
  ".": "./src/index.ts",
14
14
  "./vite": "./src/vite/plugin.js",
15
- "./daemon": "./src/daemon/index.ts"
15
+ "./daemon": "./src/daemon/index.ts",
16
+ "./sdk/createInvoke": "./src/sdk/createInvoke.ts"
16
17
  },
17
18
  "scripts": {
18
19
  "build": "tsc",
@@ -21,9 +22,9 @@
21
22
  "lint:unused": "knip"
22
23
  },
23
24
  "dependencies": {
24
- "@decocms/blocks": "7.2.2",
25
- "@decocms/blocks-admin": "7.2.2",
26
- "@decocms/blocks-cli": "7.2.2",
25
+ "@decocms/blocks": "7.3.0",
26
+ "@decocms/blocks-admin": "7.3.0",
27
+ "@decocms/blocks-cli": "7.3.0",
27
28
  "@deco-cx/warp-node": "^0.3.16",
28
29
  "fast-json-patch": "^3.1.0",
29
30
  "ws": "^8.18.0"
package/src/index.ts CHANGED
@@ -28,5 +28,17 @@ export {
28
28
  decoStringifySearch,
29
29
  } from "./sdk/router";
30
30
  export type { CreateDecoRouterOptions } from "./sdk/router";
31
- export { createInvokeFn } from "./sdk/createInvoke";
32
- export type { InvokeFnOpts } from "./sdk/createInvoke";
31
+ // createInvokeFn is intentionally NOT re-exported from this root barrel.
32
+ // Its body contains a `createServerFn(...).handler(...)` call that is not a
33
+ // top-level variable declarator (it's returned from a factory function) --
34
+ // TanStack Start's compiler statically scans every file it processes for
35
+ // this pattern and throws "createServerFn must be assigned to a variable!"
36
+ // on ANY occurrence, whether or not the factory is ever actually called.
37
+ // Re-exporting it here would pull sdk/createInvoke.ts into the module graph
38
+ // of every site that imports anything else from this barrel (which is
39
+ // every site, for DecoRootLayout etc.), tripping that check even though
40
+ // createInvokeFn itself is never invoked at runtime -- it exists purely as
41
+ // a source-of-truth template that blocks-cli's generate-invoke.ts statically
42
+ // parses (never imports) to emit real top-level createServerFn declarations
43
+ // into each site's own src/server/invoke.gen.ts. Import it from the
44
+ // dedicated "@decocms/tanstack/sdk/createInvoke" subpath instead.
@@ -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
  });
@@ -1,12 +1,31 @@
1
1
  /**
2
- * Generic bridge that turns any async function into a TanStack Start server function.
2
+ * Type-only marker for apps-* `invoke.ts` codegen templates (e.g.
3
+ * `@decocms/apps-vtex/invoke.ts`) — NOT a real, callable createServerFn
4
+ * wrapper. Deliberately does not call `createServerFn` at runtime.
3
5
  *
4
- * Used by @decocms/apps to expose commerce actions/loaders as typed
5
- * `invoke.*` calls that execute on the server with full credentials.
6
+ * `invoke.ts` files are never imported/executed by any real site they
7
+ * are a source-of-truth that `blocks-cli`'s `generate-invoke.ts` statically
8
+ * parses (via AST inspection of each `createInvokeFn(action, opts)` call
9
+ * site's arguments) to emit real, literal top-level `createServerFn(...)`
10
+ * declarations into each site's own `src/server/invoke.gen.ts`. This
11
+ * function's ONLY job is to give those template files a correctly-typed
12
+ * call site to author against.
13
+ *
14
+ * It must not contain a real `createServerFn(...)` call: TanStack Start's
15
+ * compiler statically scans every file reachable by a site's SSR bundle for
16
+ * that literal pattern and throws "createServerFn must be assigned to a
17
+ * variable!" on ANY occurrence that isn't a top-level declarator — even one
18
+ * inside a function body that's never actually invoked. `invoke.ts` isn't
19
+ * supposed to be reachable by a site's bundler at all (it's excluded from
20
+ * every apps-* package's public exports map), but Vite's `optimizeDeps`
21
+ * pre-bundling has been observed sweeping it in regardless of that
22
+ * exports-map restriction — so the only fully robust fix is for this file
23
+ * to never contain the literal pattern the compiler is looking for, full
24
+ * stop, regardless of what does or doesn't end up bundled.
6
25
  *
7
26
  * @example
8
27
  * ```ts
9
- * import { createInvokeFn } from "@decocms/start/sdk/createInvoke";
28
+ * import { createInvokeFn } from "@decocms/tanstack/sdk/createInvoke";
10
29
  * import { addItemsToCart } from "./actions/checkout";
11
30
  *
12
31
  * export const invoke = {
@@ -20,12 +39,13 @@
20
39
  * },
21
40
  * },
22
41
  * };
23
- *
24
- * // Client-side usage:
25
- * await invoke.vtex.actions.addItemsToCart({ data: { orderFormId, orderItems } });
26
42
  * ```
43
+ *
44
+ * The real, callable version of each action above only exists in the
45
+ * site's generated `src/server/invoke.gen.ts` (run `npm run generate:invoke`),
46
+ * which uses real top-level `createServerFn(...)` calls directly — never
47
+ * this function.
27
48
  */
28
- import { createServerFn } from "@tanstack/react-start";
29
49
 
30
50
  export interface InvokeFnOpts {
31
51
  /**
@@ -37,21 +57,21 @@ export interface InvokeFnOpts {
37
57
  }
38
58
 
39
59
  /**
40
- * Transforms an async function into a `createServerFn` wrapper.
41
- *
42
- * - Client calls: `fn({ data: input })`
43
- * - Server executes: `action(input)`
44
- * - If `unwrap: true`, extracts `.data` from VtexFetchResult-shaped results
60
+ * Template-only see the module doc comment above. Throws if actually
61
+ * called at runtime, since that would mean something imported an
62
+ * `invoke.ts` template file directly instead of using the site's generated
63
+ * `invoke.gen.ts`, which is itself a bug worth surfacing loudly rather than
64
+ * silently returning wrong behavior.
45
65
  */
46
66
  export function createInvokeFn<TInput, TOutput>(
47
- action: (input: TInput) => Promise<TOutput>,
48
- opts?: InvokeFnOpts,
67
+ _action: (input: TInput) => Promise<TOutput>,
68
+ _opts?: InvokeFnOpts,
49
69
  ): (ctx: { data: TInput }) => Promise<TOutput> {
50
- return createServerFn({ method: "POST" }).handler(async (ctx) => {
51
- const result = await action(ctx.data as TInput);
52
- if (opts?.unwrap && result && typeof result === "object" && "data" in result) {
53
- return (result as any).data;
54
- }
55
- return result;
56
- }) as unknown as (ctx: { data: TInput }) => Promise<TOutput>;
70
+ return () => {
71
+ throw new Error(
72
+ "createInvokeFn() is a codegen-time-only template marker and was never meant to be called at " +
73
+ "runtime. If you're seeing this, something imported an apps-*/invoke.ts template file directly " +
74
+ "instead of using the site's generated src/server/invoke.gen.ts (run `npm run generate:invoke`).",
75
+ );
76
+ };
57
77
  }
@@ -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;
@@ -588,37 +588,27 @@ export function decoVitePlugin() {
588
588
  env.optimizeDeps.esbuildOptions.jsxImportSource = "react";
589
589
  }
590
590
 
591
- // Force these packages through the SSR transform pipeline so
592
- // TanStack Start's compiler can register their createServerFn handlers
591
+ // Force @decocms/tanstack through the SSR transform pipeline so
592
+ // TanStack Start's compiler can register its createServerFn handlers
593
593
  // (loadDeferredSection in routes/cmsRoute.ts, and loadCmsPage /
594
- // loadCmsHomePage alongside it, for @decocms/tanstack) in the
595
- // per-environment serverFnsById manifest. Without this, Vite
596
- // pre-bundles the package via optimizeDeps before plugins run, the
597
- // handler never enters the manifest, and every POST /_serverFn/* call
598
- // from the browser returns HTTP 500 ("Invalid server function ID").
599
- // See #197.
594
+ // loadCmsHomePage alongside it) in the per-environment serverFnsById
595
+ // manifest. Without this, Vite pre-bundles the package via
596
+ // optimizeDeps before plugins run, the handler never enters the
597
+ // manifest, and every POST /_serverFn/* call from the browser returns
598
+ // HTTP 500 ("Invalid server function ID"). See #197.
600
599
  //
601
- // @decocms/apps-vtex needs this too: its invoke.ts wraps 18 actions/
602
- // loaders via createInvokeFn (packages/tanstack/src/sdk/createInvoke.ts),
603
- // each a real `createServerFn(...)` call site. This was missed when
604
- // createInvokeFn's export was added to @decocms/tanstack's public
605
- // barrel (apps-monorepo-migration Task 7) worked by accident under
606
- // `bun link` (Vite treats symlinked/workspace packages as live source,
607
- // skipping optimizeDeps pre-bundling for them) but hard-crashes with
608
- // a real npm install: "createServerFn must be assigned to a variable!"
609
- // from the Cloudflare Workers runtime's static entry-export scan.
610
- //
611
- // @decocms/blocks does NOT need this: despite its
612
- // validateSection.ts / useScript.ts doc comments mentioning
613
- // `createServerFn`, neither file (nor anything else in runtime)
614
- // actually calls it — those are a JSDoc usage example for a site's
615
- // own server-fn file and a deprecation-message suggestion,
616
- // respectively. Only add a package here once it has a real
617
- // `createServerFn(...)` call site.
600
+ // @decocms/apps-vtex does NOT need this: its invoke.ts has zero real
601
+ // createServerFn call sites reachable by any site (it's a
602
+ // codegen-time-only template that blocks-cli's generate-invoke.ts
603
+ // statically parses, never imports, to emit each site's own
604
+ // src/server/invoke.gen.ts with real top-level createServerFn consts;
605
+ // createInvokeFn itself is a non-functional type-only marker for that
606
+ // template, see sdk/createInvoke.ts's doc comment). Only add a
607
+ // package here once it has a real `createServerFn(...)` call site.
618
608
  if (name === "ssr") {
619
609
  env.resolve = env.resolve || {};
620
610
  const existing = env.resolve.noExternal;
621
- const additions = ["@decocms/tanstack", "@decocms/apps-vtex"];
611
+ const additions = ["@decocms/tanstack"];
622
612
  if (existing === true) {
623
613
  // Already noExternal everything — nothing to add.
624
614
  } else if (Array.isArray(existing)) {
@@ -628,6 +618,34 @@ export function decoVitePlugin() {
628
618
  } else {
629
619
  env.resolve.noExternal = additions;
630
620
  }
621
+
622
+ // The noExternal setting above only controls whether Vite's SSR
623
+ // *output* inlines vs. externalizes this package — it does NOT
624
+ // stop Vite's dev-server-startup dependency optimizer (esbuild via
625
+ // `optimizeDeps`) from pre-bundling it. Some Cloudflare Workers
626
+ // targets (via @cloudflare/vite-plugin) already force
627
+ // `resolve.noExternal = true` unconditionally for this same "ssr"
628
+ // environment before this hook even runs, making the block above a
629
+ // complete no-op there — yet the crash below still happened,
630
+ // proving pre-bundling (not noExternal) was always the real cause.
631
+ //
632
+ // Confirmed via direct diagnostic instrumentation of
633
+ // @tanstack/start-plugin-core: once esbuild pre-bundles this
634
+ // package into a single node_modules/.vite/deps_ssr/*.js chunk,
635
+ // Cloudflare's separate worker-export static-analysis pass
636
+ // (getWorkerEntryExportTypes, a distinct pipeline stage from
637
+ // Vite's own SSR transform — see workers/runner-worker in the
638
+ // stack trace) throws "createServerFn must be assigned to a
639
+ // variable!" on this package's own legitimate, already-top-level
640
+ // createServerFn calls (loadCmsPage / loadCmsHomePage /
641
+ // loadDeferredSection) — apparently that scan doesn't recognize
642
+ // esbuild-bundled `var X = createServerFn(...)` output the same
643
+ // way it recognizes the original, unbundled source. Excluding this
644
+ // package from `optimizeDeps` entirely routes it through Vite's
645
+ // normal plugin pipeline (where TanStack Start's compiler
646
+ // transforms it correctly) instead of esbuild's pre-bundler.
647
+ env.optimizeDeps = env.optimizeDeps || {};
648
+ env.optimizeDeps.exclude = [...new Set([...(env.optimizeDeps.exclude || []), "@decocms/tanstack"])];
631
649
  }
632
650
  },
633
651
 
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
  }