@decocms/tanstack 7.2.3 → 7.3.1
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 +4 -4
- package/src/routes/cmsRoute.ts +52 -0
- package/src/sdk/outboundHeaders.test.ts +113 -0
- package/src/sdk/outboundHeaders.ts +81 -0
- package/src/sdk/workerEntry.ts +77 -2
- package/tsconfig.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/tanstack",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.3.1",
|
|
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.
|
|
26
|
-
"@decocms/blocks-admin": "7.
|
|
27
|
-
"@decocms/blocks-cli": "7.
|
|
25
|
+
"@decocms/blocks": "7.3.1",
|
|
26
|
+
"@decocms/blocks-admin": "7.3.1",
|
|
27
|
+
"@decocms/blocks-cli": "7.3.1",
|
|
28
28
|
"@deco-cx/warp-node": "^0.3.16",
|
|
29
29
|
"fast-json-patch": "^3.1.0",
|
|
30
30
|
"ws": "^8.18.0"
|
package/src/routes/cmsRoute.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/sdk/workerEntry.ts
CHANGED
|
@@ -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
|
|
1322
|
-
// alone, no join to
|
|
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