@decocms/blocks 7.0.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 +97 -0
- package/src/cms/applySectionConventions.ts +128 -0
- package/src/cms/blockSource.test.ts +67 -0
- package/src/cms/blockSource.ts +124 -0
- package/src/cms/index.ts +104 -0
- package/src/cms/layoutCacheRace.test.ts +146 -0
- package/src/cms/loadDecofileDirectory.test.ts +52 -0
- package/src/cms/loadDecofileDirectory.ts +48 -0
- package/src/cms/loader.test.ts +184 -0
- package/src/cms/loader.ts +284 -0
- package/src/cms/registry.test.ts +118 -0
- package/src/cms/registry.ts +252 -0
- package/src/cms/resolve.test.ts +547 -0
- package/src/cms/resolve.ts +2003 -0
- package/src/cms/schema.ts +993 -0
- package/src/cms/sectionLoaders.test.ts +409 -0
- package/src/cms/sectionLoaders.ts +562 -0
- package/src/cms/sectionMixins.test.ts +163 -0
- package/src/cms/sectionMixins.ts +153 -0
- package/src/hooks/LazySection.tsx +121 -0
- package/src/hooks/LiveControls.tsx +122 -0
- package/src/hooks/RenderSection.test.tsx +81 -0
- package/src/hooks/RenderSection.tsx +91 -0
- package/src/hooks/SectionErrorFallback.tsx +97 -0
- package/src/hooks/index.ts +4 -0
- package/src/index.ts +8 -0
- package/src/matchers/builtins.test.ts +251 -0
- package/src/matchers/builtins.ts +437 -0
- package/src/matchers/countryNames.ts +104 -0
- package/src/matchers/override.test.ts +205 -0
- package/src/matchers/override.ts +136 -0
- package/src/matchers/posthog.ts +154 -0
- package/src/middleware/decoState.ts +55 -0
- package/src/middleware/healthMetrics.ts +133 -0
- package/src/middleware/hydrationContext.test.ts +61 -0
- package/src/middleware/hydrationContext.ts +79 -0
- package/src/middleware/index.ts +88 -0
- package/src/middleware/liveness.ts +21 -0
- package/src/middleware/observability.test.ts +238 -0
- package/src/middleware/observability.ts +620 -0
- package/src/middleware/validateSection.test.ts +147 -0
- package/src/middleware/validateSection.ts +100 -0
- package/src/sdk/abTesting.test.ts +326 -0
- package/src/sdk/abTesting.ts +499 -0
- package/src/sdk/analytics.ts +77 -0
- package/src/sdk/cacheHeaders.test.ts +115 -0
- package/src/sdk/cacheHeaders.ts +424 -0
- package/src/sdk/cachedLoader.ts +364 -0
- package/src/sdk/clx.ts +5 -0
- package/src/sdk/cn.test.ts +34 -0
- package/src/sdk/cn.ts +28 -0
- package/src/sdk/composite.test.ts +121 -0
- package/src/sdk/composite.ts +114 -0
- package/src/sdk/cookie.test.ts +108 -0
- package/src/sdk/cookie.ts +129 -0
- package/src/sdk/crypto.ts +185 -0
- package/src/sdk/csp.ts +59 -0
- package/src/sdk/djb2.ts +20 -0
- package/src/sdk/encoding.test.ts +71 -0
- package/src/sdk/encoding.ts +47 -0
- package/src/sdk/env.ts +31 -0
- package/src/sdk/http.test.ts +71 -0
- package/src/sdk/http.ts +124 -0
- package/src/sdk/index.ts +90 -0
- package/src/sdk/inflightTimeout.test.ts +35 -0
- package/src/sdk/inflightTimeout.ts +53 -0
- package/src/sdk/instrumentedFetch.test.ts +559 -0
- package/src/sdk/instrumentedFetch.ts +339 -0
- package/src/sdk/invoke.test.ts +115 -0
- package/src/sdk/invoke.ts +260 -0
- package/src/sdk/logger.test.ts +432 -0
- package/src/sdk/logger.ts +304 -0
- package/src/sdk/mergeCacheControl.ts +150 -0
- package/src/sdk/normalizeUrls.ts +91 -0
- package/src/sdk/observability.ts +109 -0
- package/src/sdk/otel.test.ts +526 -0
- package/src/sdk/otel.ts +981 -0
- package/src/sdk/otelAdapters/clickhouseCollector.ts +65 -0
- package/src/sdk/otelAdapters.test.ts +89 -0
- package/src/sdk/otelAdapters.ts +144 -0
- package/src/sdk/otelHttpLog.test.ts +457 -0
- package/src/sdk/otelHttpLog.ts +419 -0
- package/src/sdk/otelHttpMeter.test.ts +292 -0
- package/src/sdk/otelHttpMeter.ts +506 -0
- package/src/sdk/otelHttpTracer.test.ts +474 -0
- package/src/sdk/otelHttpTracer.ts +543 -0
- package/src/sdk/redirects.ts +225 -0
- package/src/sdk/requestContext.ts +281 -0
- package/src/sdk/requestContextStorage.browser.test.ts +29 -0
- package/src/sdk/requestContextStorage.browser.ts +43 -0
- package/src/sdk/requestContextStorage.ts +35 -0
- package/src/sdk/retry.ts +45 -0
- package/src/sdk/serverTimings.ts +68 -0
- package/src/sdk/signal.ts +42 -0
- package/src/sdk/sitemap.ts +160 -0
- package/src/sdk/urlRedaction.test.ts +73 -0
- package/src/sdk/urlRedaction.ts +82 -0
- package/src/sdk/urlUtils.ts +134 -0
- package/src/sdk/useDevice.test.ts +130 -0
- package/src/sdk/useDevice.ts +109 -0
- package/src/sdk/useDeviceContext.tsx +108 -0
- package/src/sdk/useId.ts +7 -0
- package/src/sdk/useScript.test.ts +128 -0
- package/src/sdk/useScript.ts +210 -0
- package/src/sdk/useSuggestions.test.ts +230 -0
- package/src/sdk/useSuggestions.ts +188 -0
- package/src/sdk/wrapCaughtErrors.ts +107 -0
- package/src/setup.ts +119 -0
- package/src/types/index.ts +39 -0
- package/src/types/widgets.ts +14 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { setBlocks } from "../cms/loader";
|
|
3
|
+
import type { MatcherContext } from "../cms/resolve";
|
|
4
|
+
import { evaluateMatcher, registerMatcher } from "../cms/resolve";
|
|
5
|
+
import { DECO_MATCHERS_OVERRIDE_PARAM, getMatchersOverride } from "./override";
|
|
6
|
+
|
|
7
|
+
const TEST_MATCHER_KEY = "test/matchers/flag.ts";
|
|
8
|
+
// Block names with spaces are only addressable via query string — the header
|
|
9
|
+
// format splits pairs on spaces (same limitation as the Deno runtime).
|
|
10
|
+
const SAVED_BLOCK = "Segmento Mobile";
|
|
11
|
+
const SAVED_BLOCK_NO_SPACE = "PromoAtiva";
|
|
12
|
+
|
|
13
|
+
const matcherFn = vi.fn(
|
|
14
|
+
(rule: Record<string, unknown>) => (rule.result as boolean | undefined) ?? false,
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
function ctxWithHeader(value: string): MatcherContext {
|
|
18
|
+
return {
|
|
19
|
+
url: "https://example.com/",
|
|
20
|
+
request: new Request("https://example.com/", {
|
|
21
|
+
headers: { [DECO_MATCHERS_OVERRIDE_PARAM]: value },
|
|
22
|
+
}),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function ctxWithQS(search: string): MatcherContext {
|
|
27
|
+
const url = `https://example.com/?${search}`;
|
|
28
|
+
return { url, request: new Request(url) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
matcherFn.mockClear();
|
|
33
|
+
registerMatcher(TEST_MATCHER_KEY, matcherFn);
|
|
34
|
+
setBlocks({
|
|
35
|
+
[SAVED_BLOCK]: { __resolveType: TEST_MATCHER_KEY, result: true },
|
|
36
|
+
[SAVED_BLOCK_NO_SPACE]: { __resolveType: TEST_MATCHER_KEY, result: true },
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("getMatchersOverride", () => {
|
|
41
|
+
it("parses space-separated pairs from the header", () => {
|
|
42
|
+
const overrides = getMatchersOverride(ctxWithHeader("a=1 b=0"));
|
|
43
|
+
expect(overrides).toEqual({ a: true, b: false });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("parses repeated query string params, including names with spaces", () => {
|
|
47
|
+
const overrides = getMatchersOverride(
|
|
48
|
+
ctxWithQS(
|
|
49
|
+
`${DECO_MATCHERS_OVERRIDE_PARAM}=Segmento%20Mobile%3D1&${DECO_MATCHERS_OVERRIDE_PARAM}=b%3D0`,
|
|
50
|
+
),
|
|
51
|
+
);
|
|
52
|
+
expect(overrides).toEqual({ "Segmento Mobile": true, b: false });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("prefers the header over the query string", () => {
|
|
56
|
+
const url = `https://example.com/?${DECO_MATCHERS_OVERRIDE_PARAM}=a%3D0`;
|
|
57
|
+
const ctx: MatcherContext = {
|
|
58
|
+
url,
|
|
59
|
+
request: new Request(url, {
|
|
60
|
+
headers: { [DECO_MATCHERS_OVERRIDE_PARAM]: "a=1" },
|
|
61
|
+
}),
|
|
62
|
+
};
|
|
63
|
+
expect(getMatchersOverride(ctx)).toEqual({ a: true });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("falls back to the headers record when no Request is present", () => {
|
|
67
|
+
const overrides = getMatchersOverride({
|
|
68
|
+
headers: { [DECO_MATCHERS_OVERRIDE_PARAM]: "a=1" },
|
|
69
|
+
});
|
|
70
|
+
expect(overrides).toEqual({ a: true });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("returns an empty object when no override is present", () => {
|
|
74
|
+
expect(getMatchersOverride({ url: "https://example.com/" })).toEqual({});
|
|
75
|
+
expect(getMatchersOverride({})).toEqual({});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("evaluateMatcher with overrides", () => {
|
|
80
|
+
it("forces a saved matcher block to false via header without running it", () => {
|
|
81
|
+
const result = evaluateMatcher(
|
|
82
|
+
{ __resolveType: SAVED_BLOCK_NO_SPACE },
|
|
83
|
+
ctxWithHeader(`${SAVED_BLOCK_NO_SPACE}=0`),
|
|
84
|
+
);
|
|
85
|
+
expect(result).toBe(false);
|
|
86
|
+
expect(matcherFn).not.toHaveBeenCalled();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("forces a spaced-name block to false via query string", () => {
|
|
90
|
+
const result = evaluateMatcher(
|
|
91
|
+
{ __resolveType: SAVED_BLOCK },
|
|
92
|
+
ctxWithQS(`${DECO_MATCHERS_OVERRIDE_PARAM}=${encodeURIComponent(`${SAVED_BLOCK}=0`)}`),
|
|
93
|
+
);
|
|
94
|
+
expect(result).toBe(false);
|
|
95
|
+
expect(matcherFn).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("forces a saved matcher block to true without running it", () => {
|
|
99
|
+
setBlocks({
|
|
100
|
+
[SAVED_BLOCK]: { __resolveType: TEST_MATCHER_KEY, result: false },
|
|
101
|
+
});
|
|
102
|
+
const result = evaluateMatcher(
|
|
103
|
+
{ __resolveType: SAVED_BLOCK },
|
|
104
|
+
ctxWithQS(`${DECO_MATCHERS_OVERRIDE_PARAM}=${encodeURIComponent(`${SAVED_BLOCK}=1`)}`),
|
|
105
|
+
);
|
|
106
|
+
expect(result).toBe(true);
|
|
107
|
+
expect(matcherFn).not.toHaveBeenCalled();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("runs the matcher normally when no override targets it", () => {
|
|
111
|
+
const result = evaluateMatcher(
|
|
112
|
+
{ __resolveType: SAVED_BLOCK },
|
|
113
|
+
ctxWithHeader("Outro Segmento=0"),
|
|
114
|
+
);
|
|
115
|
+
expect(result).toBe(true);
|
|
116
|
+
expect(matcherFn).toHaveBeenCalledTimes(1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("does not affect inline matchers keyed by raw type", () => {
|
|
120
|
+
const result = evaluateMatcher(
|
|
121
|
+
{ __resolveType: TEST_MATCHER_KEY, result: true },
|
|
122
|
+
ctxWithHeader(`${TEST_MATCHER_KEY}=0`),
|
|
123
|
+
);
|
|
124
|
+
expect(result).toBe(true);
|
|
125
|
+
expect(matcherFn).toHaveBeenCalledTimes(1);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("evaluateMatcher with inline rule path overrides", () => {
|
|
130
|
+
const PAGE_ID = "pages-home-c4bcbfb771e9";
|
|
131
|
+
|
|
132
|
+
function setPageBlocks() {
|
|
133
|
+
const page = {
|
|
134
|
+
__resolveType: "website/pages/Page.tsx",
|
|
135
|
+
sections: [
|
|
136
|
+
{ __resolveType: "site/sections/Header.tsx" },
|
|
137
|
+
{
|
|
138
|
+
__resolveType: "website/flags/multivariate/section.ts",
|
|
139
|
+
variants: [
|
|
140
|
+
{
|
|
141
|
+
rule: { __resolveType: TEST_MATCHER_KEY, result: true },
|
|
142
|
+
value: { __resolveType: "site/sections/A.tsx" },
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
rule: { __resolveType: TEST_MATCHER_KEY, result: false },
|
|
146
|
+
value: { __resolveType: "site/sections/B.tsx" },
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
};
|
|
152
|
+
setBlocks({ [PAGE_ID]: page });
|
|
153
|
+
return page;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
it("forces an inline rule by <blockId>@<prop.path> without running it", () => {
|
|
157
|
+
const page = setPageBlocks();
|
|
158
|
+
const rule = page.sections[1].variants![0].rule as Record<string, unknown>;
|
|
159
|
+
const result = evaluateMatcher(
|
|
160
|
+
rule,
|
|
161
|
+
ctxWithQS(
|
|
162
|
+
`${DECO_MATCHERS_OVERRIDE_PARAM}=${encodeURIComponent(
|
|
163
|
+
`${PAGE_ID}@sections.1.variants.0.rule=0`,
|
|
164
|
+
)}`,
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
expect(result).toBe(false);
|
|
168
|
+
expect(matcherFn).not.toHaveBeenCalled();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("forces an inline rule to true via header path key", () => {
|
|
172
|
+
const page = setPageBlocks();
|
|
173
|
+
const rule = page.sections[1].variants![1].rule as Record<string, unknown>;
|
|
174
|
+
const result = evaluateMatcher(rule, ctxWithHeader(`${PAGE_ID}@sections.1.variants.1.rule=1`));
|
|
175
|
+
expect(result).toBe(true);
|
|
176
|
+
expect(matcherFn).not.toHaveBeenCalled();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("evaluates normally when the path key targets a different position", () => {
|
|
180
|
+
const page = setPageBlocks();
|
|
181
|
+
const rule = page.sections[1].variants![0].rule as Record<string, unknown>;
|
|
182
|
+
const result = evaluateMatcher(rule, ctxWithHeader(`${PAGE_ID}@sections.1.variants.1.rule=0`));
|
|
183
|
+
expect(result).toBe(true);
|
|
184
|
+
expect(matcherFn).toHaveBeenCalledTimes(1);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("rebuilds the index when blocks are swapped", () => {
|
|
188
|
+
const first = setPageBlocks();
|
|
189
|
+
const overrideCtx = () => ctxWithHeader(`${PAGE_ID}@sections.1.variants.0.rule=0`);
|
|
190
|
+
expect(
|
|
191
|
+
evaluateMatcher(
|
|
192
|
+
first.sections[1].variants![0].rule as Record<string, unknown>,
|
|
193
|
+
overrideCtx(),
|
|
194
|
+
),
|
|
195
|
+
).toBe(false);
|
|
196
|
+
|
|
197
|
+
const second = setPageBlocks();
|
|
198
|
+
expect(
|
|
199
|
+
evaluateMatcher(
|
|
200
|
+
second.sections[1].variants![0].rule as Record<string, unknown>,
|
|
201
|
+
overrideCtx(),
|
|
202
|
+
),
|
|
203
|
+
).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request matcher override — port of the Deno runtime's
|
|
3
|
+
* `x-deco-matchers-override` flag (deco/blocks/matcher.ts).
|
|
4
|
+
*
|
|
5
|
+
* Forces matcher results without running them, keyed by the Deno-style
|
|
6
|
+
* uniqueId derived from the matcher's position in the decofile:
|
|
7
|
+
*
|
|
8
|
+
* - Saved matcher block referenced by name → the block name
|
|
9
|
+
* (`Segmento Mobile=1`), mirroring Deno's resolve chain stopping at the
|
|
10
|
+
* first resolvable.
|
|
11
|
+
* - Inline matcher → `<blockId>@<prop.path.to.rule>`
|
|
12
|
+
* (`pages-home-c4bcbfb771e9@sections.2.variants.0.rule=0`).
|
|
13
|
+
*
|
|
14
|
+
* Carried by:
|
|
15
|
+
* - Header: `x-deco-matchers-override: SegmentA=1 SegmentB=0`
|
|
16
|
+
* (space-separated pairs; `=1` forces true, anything else forces false)
|
|
17
|
+
* - Query string: `?x-deco-matchers-override=Segment%20A%3D1` (repeatable)
|
|
18
|
+
*
|
|
19
|
+
* Header takes precedence over query string when both are present.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { MatcherContext } from "../cms/resolve";
|
|
23
|
+
|
|
24
|
+
export const DECO_MATCHERS_OVERRIDE_PARAM = "x-deco-matchers-override";
|
|
25
|
+
|
|
26
|
+
/** Splits on the FIRST "=" so block names containing "=" still work. */
|
|
27
|
+
function addPair(values: Record<string, boolean>, pair: string): void {
|
|
28
|
+
const idx = pair.indexOf("=");
|
|
29
|
+
if (idx <= 0) return;
|
|
30
|
+
values[pair.slice(0, idx)] = pair.slice(idx + 1) === "1";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseFromHeaders(ctx: MatcherContext): Record<string, boolean> | undefined {
|
|
34
|
+
const val =
|
|
35
|
+
ctx.request?.headers.get(DECO_MATCHERS_OVERRIDE_PARAM) ??
|
|
36
|
+
ctx.headers?.[DECO_MATCHERS_OVERRIDE_PARAM];
|
|
37
|
+
if (!val) return undefined;
|
|
38
|
+
const values: Record<string, boolean> = {};
|
|
39
|
+
for (const pair of val.split(" ")) addPair(values, pair);
|
|
40
|
+
return values;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseFromQS(ctx: MatcherContext): Record<string, boolean> | undefined {
|
|
44
|
+
if (!ctx.url) return undefined;
|
|
45
|
+
let url: URL;
|
|
46
|
+
try {
|
|
47
|
+
url = new URL(ctx.url);
|
|
48
|
+
} catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
if (!url.searchParams.has(DECO_MATCHERS_OVERRIDE_PARAM)) return undefined;
|
|
52
|
+
const values: Record<string, boolean> = {};
|
|
53
|
+
for (const pair of url.searchParams.getAll(DECO_MATCHERS_OVERRIDE_PARAM)) {
|
|
54
|
+
addPair(values, pair);
|
|
55
|
+
}
|
|
56
|
+
return values;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const EMPTY: Record<string, boolean> = {};
|
|
60
|
+
|
|
61
|
+
// Memoized per Request so a page with many matchers parses once.
|
|
62
|
+
const overridesByRequest = new WeakMap<Request, Record<string, boolean>>();
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Parse the matcher overrides for this request (header first, then query
|
|
66
|
+
* string). Returns an empty object when no override is present.
|
|
67
|
+
*/
|
|
68
|
+
export function getMatchersOverride(ctx: MatcherContext): Record<string, boolean> {
|
|
69
|
+
const request = ctx.request;
|
|
70
|
+
if (request) {
|
|
71
|
+
const cached = overridesByRequest.get(request);
|
|
72
|
+
if (cached) return cached;
|
|
73
|
+
}
|
|
74
|
+
const values = parseFromHeaders(ctx) ?? parseFromQS(ctx) ?? EMPTY;
|
|
75
|
+
if (request) overridesByRequest.set(request, values);
|
|
76
|
+
return values;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function hasMatchersOverride(ctx: MatcherContext): boolean {
|
|
80
|
+
const overrides = getMatchersOverride(ctx);
|
|
81
|
+
for (const _ in overrides) return true;
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Rule uniqueId index — Deno resolveChain semantics
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
type RuleIdIndex = WeakMap<object, string>;
|
|
90
|
+
|
|
91
|
+
// Keyed by the blocks map identity, so the index is rebuilt on decofile
|
|
92
|
+
// hot-reload / KV swap (setBlocks replaces the map) and admin preview
|
|
93
|
+
// (withBlocksOverride returns a fresh merged map).
|
|
94
|
+
const indexByBlocks = new WeakMap<Record<string, unknown>, RuleIdIndex>();
|
|
95
|
+
|
|
96
|
+
function walkBlock(node: unknown, blockId: string, path: string, index: RuleIdIndex): void {
|
|
97
|
+
if (!node || typeof node !== "object") return;
|
|
98
|
+
if (Array.isArray(node)) {
|
|
99
|
+
for (let i = 0; i < node.length; i++) {
|
|
100
|
+
walkBlock(node[i], blockId, path ? `${path}.${i}` : `${i}`, index);
|
|
101
|
+
}
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const obj = node as Record<string, unknown>;
|
|
105
|
+
if (path && typeof obj.__resolveType === "string") {
|
|
106
|
+
index.set(obj, `${blockId}@${path}`);
|
|
107
|
+
}
|
|
108
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
109
|
+
if (key === "__resolveType") continue;
|
|
110
|
+
walkBlock(value, blockId, path ? `${path}.${key}` : key, index);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Resolve the Deno-style uniqueId (`<blockId>@<prop.path>`) for an inline
|
|
116
|
+
* rule object by its identity in the blocks map. Returns undefined for
|
|
117
|
+
* objects created during resolution (spreads/merges) — those are addressed
|
|
118
|
+
* by their saved-block name instead.
|
|
119
|
+
*
|
|
120
|
+
* The index is built lazily (only when a request carries an override) and
|
|
121
|
+
* cached per blocks map, so override-free traffic pays nothing.
|
|
122
|
+
*/
|
|
123
|
+
export function getRuleOverrideId(
|
|
124
|
+
blocks: Record<string, unknown>,
|
|
125
|
+
rule: object,
|
|
126
|
+
): string | undefined {
|
|
127
|
+
let index = indexByBlocks.get(blocks);
|
|
128
|
+
if (!index) {
|
|
129
|
+
index = new WeakMap();
|
|
130
|
+
for (const [blockId, block] of Object.entries(blocks)) {
|
|
131
|
+
walkBlock(block, blockId, "", index);
|
|
132
|
+
}
|
|
133
|
+
indexByBlocks.set(blocks, index);
|
|
134
|
+
}
|
|
135
|
+
return index.get(rule);
|
|
136
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostHog feature flag matcher bridge.
|
|
3
|
+
*
|
|
4
|
+
* Bridges deco's CMS matcher interface (`evaluateMatcher`) to PostHog's
|
|
5
|
+
* feature flag system for experiment-grade A/B testing with sticky sessions.
|
|
6
|
+
*
|
|
7
|
+
* This module is designed to be used with `registerMatcher()` from the CMS.
|
|
8
|
+
* PostHog itself is NOT a dependency of `@decocms/start` — the storefront
|
|
9
|
+
* provides the PostHog client via `configurePostHogMatcher()`.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* // In your storefront setup.ts
|
|
14
|
+
* import { registerMatcher } from "@decocms/start/cms";
|
|
15
|
+
* import { createPostHogMatcher, configurePostHogMatcher } from "@decocms/start/matchers/posthog";
|
|
16
|
+
* import posthog from "posthog-js";
|
|
17
|
+
*
|
|
18
|
+
* configurePostHogMatcher({
|
|
19
|
+
* isFeatureEnabled: (key) => posthog.isFeatureEnabled(key) ?? false,
|
|
20
|
+
* getFeatureFlagVariant: (key) => posthog.getFeatureFlag(key),
|
|
21
|
+
* });
|
|
22
|
+
*
|
|
23
|
+
* registerMatcher("posthog/matchers/featureFlag.ts", createPostHogMatcher());
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { MatcherContext } from "../cms/resolve";
|
|
28
|
+
|
|
29
|
+
export interface PostHogAdapter {
|
|
30
|
+
/**
|
|
31
|
+
* Evaluate a boolean feature flag.
|
|
32
|
+
* Should return `true` if the flag is enabled for the current user.
|
|
33
|
+
*/
|
|
34
|
+
isFeatureEnabled: (flagKey: string) => boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Get the variant key for a multivariate feature flag.
|
|
37
|
+
* Returns the variant string or `undefined`/`false` if not matched.
|
|
38
|
+
*/
|
|
39
|
+
getFeatureFlagVariant?: (flagKey: string) => string | boolean | undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let adapter: PostHogAdapter | null = null;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Provide the PostHog client adapter. Must be called before
|
|
46
|
+
* any PostHog matchers are evaluated.
|
|
47
|
+
*/
|
|
48
|
+
export function configurePostHogMatcher(ph: PostHogAdapter) {
|
|
49
|
+
adapter = ph;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Creates a matcher function compatible with `registerMatcher()`.
|
|
54
|
+
*
|
|
55
|
+
* CMS rule shape (in the decofile JSON):
|
|
56
|
+
* ```json
|
|
57
|
+
* {
|
|
58
|
+
* "__resolveType": "posthog/matchers/featureFlag.ts",
|
|
59
|
+
* "flagKey": "my-experiment",
|
|
60
|
+
* "variant": "treatment"
|
|
61
|
+
* }
|
|
62
|
+
* ```
|
|
63
|
+
*
|
|
64
|
+
* - If only `flagKey` is provided, matches when the flag is enabled (boolean).
|
|
65
|
+
* - If `variant` is also provided, matches when the flag's variant equals it.
|
|
66
|
+
*/
|
|
67
|
+
export function createPostHogMatcher() {
|
|
68
|
+
return (rule: Record<string, unknown>, _ctx: MatcherContext): boolean => {
|
|
69
|
+
if (!adapter) {
|
|
70
|
+
console.warn(
|
|
71
|
+
"[PostHog Matcher] No adapter configured. Call configurePostHogMatcher() first.",
|
|
72
|
+
);
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const flagKey = rule.flagKey as string | undefined;
|
|
77
|
+
if (!flagKey) {
|
|
78
|
+
console.warn("[PostHog Matcher] Missing `flagKey` in matcher rule.");
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const expectedVariant = rule.variant as string | undefined;
|
|
83
|
+
|
|
84
|
+
if (expectedVariant && adapter.getFeatureFlagVariant) {
|
|
85
|
+
const actual = adapter.getFeatureFlagVariant(flagKey);
|
|
86
|
+
return actual === expectedVariant;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return adapter.isFeatureEnabled(flagKey);
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Server-side PostHog evaluation for SSR.
|
|
95
|
+
*
|
|
96
|
+
* Uses the PostHog Node SDK to evaluate flags server-side,
|
|
97
|
+
* ensuring the first render has the correct variant without a flash.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* ```ts
|
|
101
|
+
* import { PostHog } from "posthog-node";
|
|
102
|
+
* import { configurePostHogMatcher, createServerPostHogAdapter } from "@decocms/start/matchers/posthog";
|
|
103
|
+
*
|
|
104
|
+
* const phServer = new PostHog(process.env.POSTHOG_API_KEY);
|
|
105
|
+
*
|
|
106
|
+
* // In your middleware, per-request:
|
|
107
|
+
* const adapter = createServerPostHogAdapter(phServer, distinctId);
|
|
108
|
+
* configurePostHogMatcher(adapter);
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
export function createServerPostHogAdapter(
|
|
112
|
+
client: {
|
|
113
|
+
isFeatureEnabled: (key: string, distinctId: string) => Promise<boolean> | boolean;
|
|
114
|
+
getFeatureFlag: (
|
|
115
|
+
key: string,
|
|
116
|
+
distinctId: string,
|
|
117
|
+
) => Promise<string | boolean | undefined> | string | boolean | undefined;
|
|
118
|
+
},
|
|
119
|
+
distinctId: string,
|
|
120
|
+
): PostHogAdapter {
|
|
121
|
+
const flagCache = new Map<string, boolean>();
|
|
122
|
+
const variantCache = new Map<string, string | boolean | undefined>();
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
isFeatureEnabled(key: string): boolean {
|
|
126
|
+
if (flagCache.has(key)) return flagCache.get(key)!;
|
|
127
|
+
|
|
128
|
+
// Synchronous path — pre-warm the cache in middleware before resolution
|
|
129
|
+
const result = client.isFeatureEnabled(key, distinctId);
|
|
130
|
+
if (result instanceof Promise) {
|
|
131
|
+
console.warn(
|
|
132
|
+
"[PostHog] Async flag evaluation used synchronously. Pre-warm flags in middleware.",
|
|
133
|
+
);
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
flagCache.set(key, result);
|
|
137
|
+
return result;
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
getFeatureFlagVariant(key: string): string | boolean | undefined {
|
|
141
|
+
if (variantCache.has(key)) return variantCache.get(key);
|
|
142
|
+
|
|
143
|
+
const result = client.getFeatureFlag(key, distinctId);
|
|
144
|
+
if (result instanceof Promise) {
|
|
145
|
+
console.warn(
|
|
146
|
+
"[PostHog] Async variant evaluation used synchronously. Pre-warm flags in middleware.",
|
|
147
|
+
);
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
variantCache.set(key, result);
|
|
151
|
+
return result;
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request Deco state that the middleware pipeline builds up
|
|
3
|
+
* and passes through the request lifecycle.
|
|
4
|
+
*/
|
|
5
|
+
import { createServerTimings, type ServerTimings } from "../sdk/serverTimings";
|
|
6
|
+
import type { Flag } from "../types/index";
|
|
7
|
+
|
|
8
|
+
export interface DecoState {
|
|
9
|
+
/** Site name / identifier. */
|
|
10
|
+
site: string;
|
|
11
|
+
/** Per-request Server-Timing tracker. */
|
|
12
|
+
timings: ServerTimings;
|
|
13
|
+
/** Active feature flags for this request. */
|
|
14
|
+
flags: Flag[];
|
|
15
|
+
/** Whether the request is from a deco admin origin. */
|
|
16
|
+
isAdmin: boolean;
|
|
17
|
+
/** Whether debug mode is enabled (via `?__d=true` or admin). */
|
|
18
|
+
debug: boolean;
|
|
19
|
+
/** Start time of the request (ms). */
|
|
20
|
+
startedAt: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function buildDecoState(request: Request, site?: string): DecoState {
|
|
24
|
+
const url = new URL(request.url);
|
|
25
|
+
const origin = request.headers.get("origin") || request.headers.get("referer") || "";
|
|
26
|
+
|
|
27
|
+
const ADMIN_ORIGINS = [
|
|
28
|
+
"https://admin.deco.cx",
|
|
29
|
+
"https://v0-admin.deco.cx",
|
|
30
|
+
"https://play.deco.cx",
|
|
31
|
+
"https://admin-cx.deco.page",
|
|
32
|
+
"https://deco.chat",
|
|
33
|
+
"https://admin.decocms.com",
|
|
34
|
+
"https://decocms.com",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const isAdmin =
|
|
38
|
+
origin.includes("localhost") ||
|
|
39
|
+
origin.includes("127.0.0.1") ||
|
|
40
|
+
ADMIN_ORIGINS.some((d) => origin.startsWith(d));
|
|
41
|
+
|
|
42
|
+
const debug = url.searchParams.has("__d") || isAdmin;
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
site:
|
|
46
|
+
site ||
|
|
47
|
+
(typeof globalThis.process !== "undefined" ? globalThis.process.env?.DECO_SITE : undefined) ||
|
|
48
|
+
"storefront",
|
|
49
|
+
timings: createServerTimings(),
|
|
50
|
+
flags: [],
|
|
51
|
+
isAdmin,
|
|
52
|
+
debug,
|
|
53
|
+
startedAt: performance.now(),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health metrics collection for request tracking and diagnostics.
|
|
3
|
+
*
|
|
4
|
+
* Provides atomic counters for requests (total, inflight, errors)
|
|
5
|
+
* and a health endpoint with cache stats and uptime.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { trackRequest, getHealthMetrics, handleHealthCheck } from "@decocms/start/middleware/healthMetrics";
|
|
10
|
+
*
|
|
11
|
+
* // In middleware:
|
|
12
|
+
* trackRequest.start();
|
|
13
|
+
* const response = await next();
|
|
14
|
+
* trackRequest.end(response.status);
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { getLoaderCacheStats } from "../sdk/cachedLoader";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Counters
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
let totalRequests = 0;
|
|
25
|
+
let inflightRequests = 0;
|
|
26
|
+
let totalErrors = 0;
|
|
27
|
+
const startedAt = Date.now();
|
|
28
|
+
|
|
29
|
+
const statusCounts: Record<string, number> = {};
|
|
30
|
+
|
|
31
|
+
export const trackRequest = {
|
|
32
|
+
start() {
|
|
33
|
+
totalRequests++;
|
|
34
|
+
inflightRequests++;
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
end(status: number) {
|
|
38
|
+
inflightRequests--;
|
|
39
|
+
const bucket = `${Math.floor(status / 100)}xx`;
|
|
40
|
+
statusCounts[bucket] = (statusCounts[bucket] || 0) + 1;
|
|
41
|
+
if (status >= 500) totalErrors++;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Metrics access
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
export interface HealthMetrics {
|
|
50
|
+
uptime: number;
|
|
51
|
+
uptimeHuman: string;
|
|
52
|
+
requests: {
|
|
53
|
+
total: number;
|
|
54
|
+
inflight: number;
|
|
55
|
+
errors: number;
|
|
56
|
+
statusCodes: Record<string, number>;
|
|
57
|
+
};
|
|
58
|
+
cache: {
|
|
59
|
+
entries: number;
|
|
60
|
+
inflight: number;
|
|
61
|
+
estimatedBytes: number;
|
|
62
|
+
maxBytes: number;
|
|
63
|
+
};
|
|
64
|
+
memory?: {
|
|
65
|
+
rss: number;
|
|
66
|
+
heapUsed: number;
|
|
67
|
+
heapTotal: number;
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function formatUptime(ms: number): string {
|
|
72
|
+
const seconds = Math.floor(ms / 1000);
|
|
73
|
+
const h = Math.floor(seconds / 3600);
|
|
74
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
75
|
+
const s = seconds % 60;
|
|
76
|
+
return `${h}h ${m}m ${s}s`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function getHealthMetrics(): HealthMetrics {
|
|
80
|
+
const uptime = Date.now() - startedAt;
|
|
81
|
+
const cacheStats = getLoaderCacheStats();
|
|
82
|
+
|
|
83
|
+
const metrics: HealthMetrics = {
|
|
84
|
+
uptime,
|
|
85
|
+
uptimeHuman: formatUptime(uptime),
|
|
86
|
+
requests: {
|
|
87
|
+
total: totalRequests,
|
|
88
|
+
inflight: inflightRequests,
|
|
89
|
+
errors: totalErrors,
|
|
90
|
+
statusCodes: { ...statusCounts },
|
|
91
|
+
},
|
|
92
|
+
cache: cacheStats,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Memory info is only available in Node.js / Deno environments
|
|
96
|
+
if (typeof globalThis.process !== "undefined" && globalThis.process.memoryUsage) {
|
|
97
|
+
try {
|
|
98
|
+
const mem = globalThis.process.memoryUsage();
|
|
99
|
+
metrics.memory = {
|
|
100
|
+
rss: mem.rss,
|
|
101
|
+
heapUsed: mem.heapUsed,
|
|
102
|
+
heapTotal: mem.heapTotal,
|
|
103
|
+
};
|
|
104
|
+
} catch {
|
|
105
|
+
// not available in this runtime
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return metrics;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Health check handler
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Handle `/deco/_health` requests with detailed metrics.
|
|
118
|
+
* Returns null for non-matching requests.
|
|
119
|
+
*/
|
|
120
|
+
export function handleHealthCheck(request: Request): Response | null {
|
|
121
|
+
const url = new URL(request.url);
|
|
122
|
+
if (url.pathname !== "/deco/_health") return null;
|
|
123
|
+
|
|
124
|
+
const metrics = getHealthMetrics();
|
|
125
|
+
|
|
126
|
+
return new Response(JSON.stringify(metrics, null, 2), {
|
|
127
|
+
status: 200,
|
|
128
|
+
headers: {
|
|
129
|
+
"Content-Type": "application/json",
|
|
130
|
+
"Cache-Control": "no-store",
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
}
|