@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.
Files changed (111) hide show
  1. package/package.json +97 -0
  2. package/src/cms/applySectionConventions.ts +128 -0
  3. package/src/cms/blockSource.test.ts +67 -0
  4. package/src/cms/blockSource.ts +124 -0
  5. package/src/cms/index.ts +104 -0
  6. package/src/cms/layoutCacheRace.test.ts +146 -0
  7. package/src/cms/loadDecofileDirectory.test.ts +52 -0
  8. package/src/cms/loadDecofileDirectory.ts +48 -0
  9. package/src/cms/loader.test.ts +184 -0
  10. package/src/cms/loader.ts +284 -0
  11. package/src/cms/registry.test.ts +118 -0
  12. package/src/cms/registry.ts +252 -0
  13. package/src/cms/resolve.test.ts +547 -0
  14. package/src/cms/resolve.ts +2003 -0
  15. package/src/cms/schema.ts +993 -0
  16. package/src/cms/sectionLoaders.test.ts +409 -0
  17. package/src/cms/sectionLoaders.ts +562 -0
  18. package/src/cms/sectionMixins.test.ts +163 -0
  19. package/src/cms/sectionMixins.ts +153 -0
  20. package/src/hooks/LazySection.tsx +121 -0
  21. package/src/hooks/LiveControls.tsx +122 -0
  22. package/src/hooks/RenderSection.test.tsx +81 -0
  23. package/src/hooks/RenderSection.tsx +91 -0
  24. package/src/hooks/SectionErrorFallback.tsx +97 -0
  25. package/src/hooks/index.ts +4 -0
  26. package/src/index.ts +8 -0
  27. package/src/matchers/builtins.test.ts +251 -0
  28. package/src/matchers/builtins.ts +437 -0
  29. package/src/matchers/countryNames.ts +104 -0
  30. package/src/matchers/override.test.ts +205 -0
  31. package/src/matchers/override.ts +136 -0
  32. package/src/matchers/posthog.ts +154 -0
  33. package/src/middleware/decoState.ts +55 -0
  34. package/src/middleware/healthMetrics.ts +133 -0
  35. package/src/middleware/hydrationContext.test.ts +61 -0
  36. package/src/middleware/hydrationContext.ts +79 -0
  37. package/src/middleware/index.ts +88 -0
  38. package/src/middleware/liveness.ts +21 -0
  39. package/src/middleware/observability.test.ts +238 -0
  40. package/src/middleware/observability.ts +620 -0
  41. package/src/middleware/validateSection.test.ts +147 -0
  42. package/src/middleware/validateSection.ts +100 -0
  43. package/src/sdk/abTesting.test.ts +326 -0
  44. package/src/sdk/abTesting.ts +499 -0
  45. package/src/sdk/analytics.ts +77 -0
  46. package/src/sdk/cacheHeaders.test.ts +115 -0
  47. package/src/sdk/cacheHeaders.ts +424 -0
  48. package/src/sdk/cachedLoader.ts +364 -0
  49. package/src/sdk/clx.ts +5 -0
  50. package/src/sdk/cn.test.ts +34 -0
  51. package/src/sdk/cn.ts +28 -0
  52. package/src/sdk/composite.test.ts +121 -0
  53. package/src/sdk/composite.ts +114 -0
  54. package/src/sdk/cookie.test.ts +108 -0
  55. package/src/sdk/cookie.ts +129 -0
  56. package/src/sdk/crypto.ts +185 -0
  57. package/src/sdk/csp.ts +59 -0
  58. package/src/sdk/djb2.ts +20 -0
  59. package/src/sdk/encoding.test.ts +71 -0
  60. package/src/sdk/encoding.ts +47 -0
  61. package/src/sdk/env.ts +31 -0
  62. package/src/sdk/http.test.ts +71 -0
  63. package/src/sdk/http.ts +124 -0
  64. package/src/sdk/index.ts +90 -0
  65. package/src/sdk/inflightTimeout.test.ts +35 -0
  66. package/src/sdk/inflightTimeout.ts +53 -0
  67. package/src/sdk/instrumentedFetch.test.ts +559 -0
  68. package/src/sdk/instrumentedFetch.ts +339 -0
  69. package/src/sdk/invoke.test.ts +115 -0
  70. package/src/sdk/invoke.ts +260 -0
  71. package/src/sdk/logger.test.ts +432 -0
  72. package/src/sdk/logger.ts +304 -0
  73. package/src/sdk/mergeCacheControl.ts +150 -0
  74. package/src/sdk/normalizeUrls.ts +91 -0
  75. package/src/sdk/observability.ts +109 -0
  76. package/src/sdk/otel.test.ts +526 -0
  77. package/src/sdk/otel.ts +981 -0
  78. package/src/sdk/otelAdapters/clickhouseCollector.ts +65 -0
  79. package/src/sdk/otelAdapters.test.ts +89 -0
  80. package/src/sdk/otelAdapters.ts +144 -0
  81. package/src/sdk/otelHttpLog.test.ts +457 -0
  82. package/src/sdk/otelHttpLog.ts +419 -0
  83. package/src/sdk/otelHttpMeter.test.ts +292 -0
  84. package/src/sdk/otelHttpMeter.ts +506 -0
  85. package/src/sdk/otelHttpTracer.test.ts +474 -0
  86. package/src/sdk/otelHttpTracer.ts +543 -0
  87. package/src/sdk/redirects.ts +225 -0
  88. package/src/sdk/requestContext.ts +281 -0
  89. package/src/sdk/requestContextStorage.browser.test.ts +29 -0
  90. package/src/sdk/requestContextStorage.browser.ts +43 -0
  91. package/src/sdk/requestContextStorage.ts +35 -0
  92. package/src/sdk/retry.ts +45 -0
  93. package/src/sdk/serverTimings.ts +68 -0
  94. package/src/sdk/signal.ts +42 -0
  95. package/src/sdk/sitemap.ts +160 -0
  96. package/src/sdk/urlRedaction.test.ts +73 -0
  97. package/src/sdk/urlRedaction.ts +82 -0
  98. package/src/sdk/urlUtils.ts +134 -0
  99. package/src/sdk/useDevice.test.ts +130 -0
  100. package/src/sdk/useDevice.ts +109 -0
  101. package/src/sdk/useDeviceContext.tsx +108 -0
  102. package/src/sdk/useId.ts +7 -0
  103. package/src/sdk/useScript.test.ts +128 -0
  104. package/src/sdk/useScript.ts +210 -0
  105. package/src/sdk/useSuggestions.test.ts +230 -0
  106. package/src/sdk/useSuggestions.ts +188 -0
  107. package/src/sdk/wrapCaughtErrors.ts +107 -0
  108. package/src/setup.ts +119 -0
  109. package/src/types/index.ts +39 -0
  110. package/src/types/widgets.ts +14 -0
  111. package/tsconfig.json +7 -0
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Pluggable structured logger for @decocms/start.
3
+ *
4
+ * Mirrors the public shape of `@deco/deco/o11y` logger so site code that
5
+ * does `logger.info("...", { key: "value" })` keeps working unchanged
6
+ * after the Fresh → TanStack migration.
7
+ *
8
+ * Backed by a `LoggerAdapter`. The default adapter writes one JSON line
9
+ * to `console.log` per call — that line is what Cloudflare Logs / Logpush
10
+ * captures, so logging works out of the box on Workers without any
11
+ * additional configuration.
12
+ *
13
+ * To dual-emit to OTLP (HyperDX, etc.), wrap the default with
14
+ * `createCompositeLogger([defaultLoggerAdapter, otelLoggerAdapter])`
15
+ * inside `instrumentWorker()`.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * import { logger } from "@decocms/start/sdk/logger";
20
+ *
21
+ * logger.info("checkout started", { orderFormId, items: cart.items.length });
22
+ * logger.warn("retrying vtex call", { attempt, host });
23
+ * logger.error("payment failed", { reason, code, traceId });
24
+ * ```
25
+ */
26
+
27
+ import { getActiveSpan } from "./observability";
28
+ import { RequestContext } from "./requestContext";
29
+
30
+ export type LogLevel = "debug" | "info" | "warn" | "error";
31
+
32
+ const LEVEL_RANK: Record<LogLevel, number> = {
33
+ debug: 10,
34
+ info: 20,
35
+ warn: 30,
36
+ error: 40,
37
+ };
38
+
39
+ export interface LoggerAdapter {
40
+ log(level: LogLevel, msg: string, attrs?: Record<string, unknown>): void;
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Default adapter — structured JSON to console.log
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Cloudflare-Logs-friendly default. One JSON object per call, on stdout.
49
+ * Always safe to use — never throws, never depends on env, never makes
50
+ * a network call.
51
+ */
52
+ export const defaultLoggerAdapter: LoggerAdapter = {
53
+ log(level, msg, attrs) {
54
+ const payload: Record<string, unknown> = {
55
+ level,
56
+ msg,
57
+ timestamp: new Date().toISOString(),
58
+ };
59
+ if (attrs) {
60
+ // Spread last so explicit attrs win over our defaults except
61
+ // for `level` / `msg` / `timestamp` which we always want canonical.
62
+ for (const [k, v] of Object.entries(attrs)) {
63
+ if (k !== "level" && k !== "msg" && k !== "timestamp") {
64
+ payload[k] = v;
65
+ }
66
+ }
67
+ }
68
+ // Route by level so Cloudflare's log dashboard colorises correctly.
69
+ const fn =
70
+ level === "error"
71
+ ? console.error
72
+ : level === "warn"
73
+ ? console.warn
74
+ : level === "debug"
75
+ ? console.debug
76
+ : console.log;
77
+ try {
78
+ fn(JSON.stringify(payload));
79
+ } catch {
80
+ // Last-resort: fall back to plain string. Never crash the request
81
+ // because of a circular-ref or non-serialisable attribute.
82
+ fn(`${level} ${msg}`);
83
+ }
84
+ },
85
+ };
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Configurable global state
89
+ // ---------------------------------------------------------------------------
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Shared module state — pinned to globalThis via Symbol.for so multiple
93
+ // inlined copies of this module (one per bundled entry file in dist/) all
94
+ // converge on the SAME state. See the matching comment in
95
+ // `observability.ts` for the rationale — `configureLogger()` from one
96
+ // entry file's copy of this module would otherwise write to an
97
+ // `activeAdapter` that `logger.error()` in another entry's copy never
98
+ // reads, and direct-POST error-log capture silently no-ops.
99
+ // ---------------------------------------------------------------------------
100
+
101
+ interface LoggerState {
102
+ activeAdapter: LoggerAdapter;
103
+ minLevel: LogLevel;
104
+ attributeFloor: Record<string, unknown>;
105
+ }
106
+
107
+ const STATE_KEY = Symbol.for("@decocms/start/logger/state.v1");
108
+
109
+ function getState(): LoggerState {
110
+ const g = globalThis as Record<symbol, unknown>;
111
+ if (!g[STATE_KEY]) {
112
+ g[STATE_KEY] = {
113
+ activeAdapter: defaultLoggerAdapter,
114
+ minLevel: "info" as LogLevel,
115
+ attributeFloor: {},
116
+ } satisfies LoggerState;
117
+ }
118
+ return g[STATE_KEY] as LoggerState;
119
+ }
120
+
121
+ /**
122
+ * Replace the active logger adapter.
123
+ * Call once at worker boot from `instrumentWorker()`.
124
+ */
125
+ export function configureLogger(adapter: LoggerAdapter): void {
126
+ getState().activeAdapter = adapter;
127
+ }
128
+
129
+ /**
130
+ * Replace the per-record attribute floor — keys here will be added to
131
+ * every log line UNLESS the caller passes the same key in their `attrs`
132
+ * (caller wins). Set once at worker boot from `instrumentWorker()`.
133
+ *
134
+ * Used to stamp `deco.runtime.version`, `deco.apps.version`,
135
+ * `deployment.environment` on every log so HyperDX panels filtering on
136
+ * these dimensions keep working under Cloudflare-managed log export
137
+ * (which otherwise strips our resource attributes — only the JSON record
138
+ * body survives).
139
+ */
140
+ export function setLoggerAttributeFloor(attrs: Record<string, unknown>): void {
141
+ getState().attributeFloor = { ...attrs };
142
+ }
143
+
144
+ /**
145
+ * Test-only: read the current attribute floor. Do not call from app code.
146
+ */
147
+ export function _getLoggerAttributeFloorForTests(): Record<string, unknown> {
148
+ return { ...getState().attributeFloor };
149
+ }
150
+
151
+ /**
152
+ * Get the current active logger adapter (for tests / advanced wiring).
153
+ */
154
+ export function getLoggerAdapter(): LoggerAdapter {
155
+ return getState().activeAdapter;
156
+ }
157
+
158
+ /**
159
+ * Set the minimum log level. Calls below this level are dropped before
160
+ * reaching any adapter — useful to silence `debug` in production.
161
+ *
162
+ * Defaults to `info`. Override per environment via `setLogLevel("debug")`
163
+ * or by reading an env var at boot.
164
+ */
165
+ export function setLogLevel(level: LogLevel): void {
166
+ getState().minLevel = level;
167
+ }
168
+
169
+ export function getLogLevel(): LogLevel {
170
+ return getState().minLevel;
171
+ }
172
+
173
+ function shouldLog(level: LogLevel): boolean {
174
+ return LEVEL_RANK[level] >= LEVEL_RANK[getState().minLevel];
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Public logger surface
179
+ // ---------------------------------------------------------------------------
180
+
181
+ /**
182
+ * Strict structured logger. Mirrors `@deco/deco/o11y`:
183
+ * - first arg is a human-readable message string
184
+ * - optional second arg is a flat attributes object
185
+ *
186
+ * Adapters decide the destination (stdout JSON, OTLP, both, …). The
187
+ * contract is intentionally narrow so structured output stays predictable
188
+ * across all sinks.
189
+ *
190
+ * @example
191
+ * ```ts
192
+ * logger.info("checkout started", { orderFormId, items });
193
+ * logger.warn("retrying vtex call", { attempt, host });
194
+ *
195
+ * // For Errors, serialize explicitly into the attrs payload:
196
+ * try { ... } catch (err) {
197
+ * const e = serializeError(err);
198
+ * logger.error(e.message, { error: e, stage: "checkout" });
199
+ * }
200
+ * ```
201
+ */
202
+ export interface Logger {
203
+ debug(msg: string, attrs?: Record<string, unknown>): void;
204
+ info(msg: string, attrs?: Record<string, unknown>): void;
205
+ warn(msg: string, attrs?: Record<string, unknown>): void;
206
+ error(msg: string, attrs?: Record<string, unknown>): void;
207
+ }
208
+
209
+ /**
210
+ * Normalised, JSON-safe error shape suitable for inclusion in logger
211
+ * attributes. `serializeError` always returns this shape regardless of
212
+ * what was thrown.
213
+ */
214
+ export interface SerializedError {
215
+ name: string;
216
+ message: string;
217
+ stack?: string;
218
+ }
219
+
220
+ /**
221
+ * Convert any thrown value into a flat, structured object that survives
222
+ * `JSON.stringify` and round-trips cleanly to OTel / Cloudflare Logs.
223
+ * Strict logger sites should call this from their catch blocks rather
224
+ * than passing the Error directly.
225
+ *
226
+ * @example
227
+ * ```ts
228
+ * try { ... } catch (err) {
229
+ * const e = serializeError(err);
230
+ * logger.error(e.message, { error: e });
231
+ * }
232
+ * ```
233
+ */
234
+ export function serializeError(err: unknown): SerializedError {
235
+ if (err instanceof Error) {
236
+ return { name: err.name, message: err.message, stack: err.stack };
237
+ }
238
+ if (err && typeof err === "object") {
239
+ let body: string;
240
+ try {
241
+ body = JSON.stringify(err);
242
+ } catch {
243
+ body = String(err);
244
+ }
245
+ return { name: "NonError", message: body };
246
+ }
247
+ return { name: "NonError", message: String(err) };
248
+ }
249
+
250
+ function emit(level: LogLevel, msg: string, attrs?: Record<string, unknown>): void {
251
+ if (!shouldLog(level)) return;
252
+ // Pull trace context from the active span so every log line correlates
253
+ // to its trace in ClickStack/HyperDX. No active span → no-op; caller
254
+ // attrs always win so explicit `trace_id` overrides keep working.
255
+ const ctx = getActiveSpan()?.spanContext?.();
256
+ // Pull request.id from the AsyncLocalStorage-backed RequestContext so
257
+ // every log line in the request also carries the join key used by
258
+ // direct-POST metrics + tail-worker rows. Single read, no allocation
259
+ // when outside a request scope.
260
+ const requestId = RequestContext.requestId;
261
+ const requestAttrs: Record<string, unknown> | undefined =
262
+ ctx || requestId
263
+ ? {
264
+ ...(ctx ? { trace_id: ctx.traceId, span_id: ctx.spanId } : {}),
265
+ ...(requestId ? { "request.id": requestId } : {}),
266
+ }
267
+ : undefined;
268
+ // Merge order: floor → trace / request context → caller attrs. Caller
269
+ // wins; the request-scoped context only overrides floor keys (which
270
+ // never set `trace_id` / `request.id` anyway).
271
+ const s = getState();
272
+ const hasFloor = Object.keys(s.attributeFloor).length > 0;
273
+ let merged: Record<string, unknown> | undefined;
274
+ if (!hasFloor && !requestAttrs && !attrs) {
275
+ merged = undefined;
276
+ } else {
277
+ merged = {
278
+ ...(hasFloor ? s.attributeFloor : {}),
279
+ ...(requestAttrs ?? {}),
280
+ ...(attrs ?? {}),
281
+ };
282
+ }
283
+ try {
284
+ s.activeAdapter.log(level, msg, merged);
285
+ } catch {
286
+ // Adapter blew up. Fall back to default so we don't lose the line.
287
+ if (s.activeAdapter !== defaultLoggerAdapter) {
288
+ try {
289
+ defaultLoggerAdapter.log(level, msg, merged);
290
+ } catch {
291
+ /* swallow */
292
+ }
293
+ }
294
+ }
295
+ }
296
+
297
+ export const logger: Logger = {
298
+ debug: (msg, attrs) => emit("debug", msg, attrs),
299
+ info: (msg, attrs) => emit("info", msg, attrs),
300
+ warn: (msg, attrs) => emit("warn", msg, attrs),
301
+ error: (msg, attrs) => emit("error", msg, attrs),
302
+ };
303
+
304
+ export default logger;
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Cache-Control merge utility.
3
+ *
4
+ * When a page makes multiple backend calls with different cache lifetimes,
5
+ * the final page response must use the most restrictive (shortest) cache
6
+ * values. This utility merges multiple Cache-Control headers following
7
+ * the "most restrictive wins" strategy.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * import { mergeCacheControl } from "@decocms/start/sdk/mergeCacheControl";
12
+ *
13
+ * // Product loader returns 60s, mega menu returns 3600s
14
+ * const merged = mergeCacheControl([
15
+ * "public, s-maxage=60, stale-while-revalidate=300",
16
+ * "public, s-maxage=3600, stale-while-revalidate=86400",
17
+ * ]);
18
+ * // => "public, s-maxage=60, stale-while-revalidate=300"
19
+ * ```
20
+ */
21
+
22
+ interface ParsedCacheControl {
23
+ isPublic: boolean;
24
+ isPrivate: boolean;
25
+ noCache: boolean;
26
+ noStore: boolean;
27
+ maxAge?: number;
28
+ sMaxAge?: number;
29
+ staleWhileRevalidate?: number;
30
+ staleIfError?: number;
31
+ mustRevalidate: boolean;
32
+ }
33
+
34
+ function safeParseInt(value: string | undefined): number | undefined {
35
+ if (!value) return undefined;
36
+ const n = parseInt(value, 10);
37
+ return Number.isFinite(n) ? n : undefined;
38
+ }
39
+
40
+ function parse(header: string): ParsedCacheControl {
41
+ const directives = header.split(",").map((d) => d.trim().toLowerCase());
42
+
43
+ const result: ParsedCacheControl = {
44
+ isPublic: false,
45
+ isPrivate: false,
46
+ noCache: false,
47
+ noStore: false,
48
+ mustRevalidate: false,
49
+ };
50
+
51
+ for (const directive of directives) {
52
+ if (directive === "public") result.isPublic = true;
53
+ else if (directive === "private") result.isPrivate = true;
54
+ else if (directive === "no-cache") result.noCache = true;
55
+ else if (directive === "no-store") result.noStore = true;
56
+ else if (directive === "must-revalidate") result.mustRevalidate = true;
57
+ else if (directive.startsWith("max-age=")) {
58
+ result.maxAge = safeParseInt(directive.split("=")[1]);
59
+ } else if (directive.startsWith("s-maxage=")) {
60
+ result.sMaxAge = safeParseInt(directive.split("=")[1]);
61
+ } else if (directive.startsWith("stale-while-revalidate=")) {
62
+ result.staleWhileRevalidate = safeParseInt(directive.split("=")[1]);
63
+ } else if (directive.startsWith("stale-if-error=")) {
64
+ result.staleIfError = safeParseInt(directive.split("=")[1]);
65
+ }
66
+ }
67
+
68
+ return result;
69
+ }
70
+
71
+ function minDefined(...values: (number | undefined)[]): number | undefined {
72
+ const defined = values.filter((v): v is number => v != null);
73
+ return defined.length > 0 ? Math.min(...defined) : undefined;
74
+ }
75
+
76
+ /**
77
+ * Merge multiple Cache-Control headers using "most restrictive wins".
78
+ *
79
+ * - If any header is `private`, the result is `private`
80
+ * - If any header has `no-store`, the result has `no-store`
81
+ * - Numeric values (max-age, s-maxage, swr) use the minimum
82
+ */
83
+ export function mergeCacheControl(headers: string[]): string {
84
+ if (headers.length === 0) return "public, s-maxage=0";
85
+ if (headers.length === 1) return headers[0];
86
+
87
+ const parsed = headers.map(parse);
88
+
89
+ const anyPrivate = parsed.some((p) => p.isPrivate);
90
+ const anyNoStore = parsed.some((p) => p.noStore);
91
+ const anyNoCache = parsed.some((p) => p.noCache);
92
+ const anyMustRevalidate = parsed.some((p) => p.mustRevalidate);
93
+
94
+ if (anyNoStore) {
95
+ return "private, no-cache, no-store, must-revalidate";
96
+ }
97
+
98
+ if (anyPrivate) {
99
+ const maxAge = minDefined(...parsed.map((p) => p.maxAge));
100
+ const parts = ["private"];
101
+ if (anyNoCache) parts.push("no-cache");
102
+ if (maxAge != null) parts.push(`max-age=${maxAge}`);
103
+ if (anyMustRevalidate) parts.push("must-revalidate");
104
+ return parts.join(", ");
105
+ }
106
+
107
+ const maxAge = minDefined(...parsed.map((p) => p.maxAge));
108
+ const sMaxAge = minDefined(...parsed.map((p) => p.sMaxAge));
109
+ const swr = minDefined(...parsed.map((p) => p.staleWhileRevalidate));
110
+ const sie = minDefined(...parsed.map((p) => p.staleIfError));
111
+
112
+ const parts: string[] = ["public"];
113
+ if (maxAge != null) parts.push(`max-age=${maxAge}`);
114
+ if (sMaxAge != null) parts.push(`s-maxage=${sMaxAge}`);
115
+ if (swr != null) parts.push(`stale-while-revalidate=${swr}`);
116
+ if (sie != null) parts.push(`stale-if-error=${sie}`);
117
+ if (anyMustRevalidate) parts.push("must-revalidate");
118
+
119
+ return parts.join(", ");
120
+ }
121
+
122
+ /**
123
+ * Accumulator for collecting cache control headers across loaders.
124
+ *
125
+ * Use in middleware to collect headers from each loader call and
126
+ * compute the final merged header at the end.
127
+ *
128
+ * @example
129
+ * ```ts
130
+ * const collector = createCacheControlCollector();
131
+ * collector.add("public, s-maxage=60");
132
+ * collector.add("public, s-maxage=3600");
133
+ * response.headers.set("Cache-Control", collector.result());
134
+ * // => "public, s-maxage=60"
135
+ * ```
136
+ */
137
+ export function createCacheControlCollector() {
138
+ const headers: string[] = [];
139
+ return {
140
+ add(header: string) {
141
+ headers.push(header);
142
+ },
143
+ result(): string {
144
+ return mergeCacheControl(headers);
145
+ },
146
+ get count() {
147
+ return headers.length;
148
+ },
149
+ };
150
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * URL normalization for CMS-resolved props.
3
+ *
4
+ * Strips registered production origins from absolute URLs, converting them to
5
+ * relative paths. This allows staging/preview deployments to work without
6
+ * every CMS-authored link sending users to the production domain.
7
+ *
8
+ * Only affects strings that START with a registered origin + "/" — image CDN
9
+ * URLs, API endpoints on different domains, and non-URL strings are untouched.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * // In site's setup.ts:
14
+ * import { registerProductionOrigins } from "@decocms/start/sdk/normalizeUrls";
15
+ * registerProductionOrigins([
16
+ * "https://www.casaevideo.com.br",
17
+ * "https://casaevideo.com.br",
18
+ * ]);
19
+ * ```
20
+ */
21
+
22
+ let origins: string[] = [];
23
+
24
+ /**
25
+ * Register production origins that should be stripped from CMS-resolved URLs.
26
+ * Call once in your site's setup.ts before any page loads.
27
+ */
28
+ export function registerProductionOrigins(productionOrigins: string[]) {
29
+ origins = productionOrigins.map((o) => o.replace(/\/+$/, ""));
30
+ }
31
+
32
+ export function getProductionOrigins(): readonly string[] {
33
+ return origins;
34
+ }
35
+
36
+ function normalizeString(str: string): string {
37
+ for (const origin of origins) {
38
+ if (str.startsWith(origin + "/")) {
39
+ return str.slice(origin.length);
40
+ }
41
+ if (str === origin) {
42
+ return "/";
43
+ }
44
+ }
45
+ return str;
46
+ }
47
+
48
+ /**
49
+ * Deep-walk an object tree and rewrite any string value that starts with a
50
+ * registered production origin to a relative path. Returns the same reference
51
+ * if nothing was changed (structural sharing).
52
+ */
53
+ export function normalizeUrlsInObject<T>(obj: T): T {
54
+ if (!origins.length) return obj;
55
+ return deepNormalize(obj) as T;
56
+ }
57
+
58
+ function deepNormalize(val: unknown): unknown {
59
+ if (val == null) return val;
60
+
61
+ if (typeof val === "string") {
62
+ return normalizeString(val);
63
+ }
64
+
65
+ if (Array.isArray(val)) {
66
+ let changed = false;
67
+ const result = val.map((item) => {
68
+ const normalized = deepNormalize(item);
69
+ if (normalized !== item) changed = true;
70
+ return normalized;
71
+ });
72
+ return changed ? result : val;
73
+ }
74
+
75
+ if (typeof val === "object") {
76
+ // Skip React elements, Dates, RegExps, and other non-plain objects
77
+ const proto = Object.getPrototypeOf(val);
78
+ if (proto !== Object.prototype && proto !== null) return val;
79
+
80
+ let changed = false;
81
+ const result: Record<string, unknown> = {};
82
+ for (const [key, value] of Object.entries(val as Record<string, unknown>)) {
83
+ const normalized = deepNormalize(value);
84
+ result[key] = normalized;
85
+ if (normalized !== value) changed = true;
86
+ }
87
+ return changed ? result : val;
88
+ }
89
+
90
+ return val;
91
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * One-stop import for everything observability-related in `@decocms/start`.
3
+ *
4
+ * Consumers (sites, apps) should prefer importing from here so future
5
+ * re-organisations don't ripple through every site:
6
+ *
7
+ * ```ts
8
+ * import {
9
+ * instrumentWorker,
10
+ * logger,
11
+ * setLogLevel,
12
+ * withTracing,
13
+ * recordRequestMetric,
14
+ * recordCacheMetric,
15
+ * MetricNames,
16
+ * } from "@decocms/start/sdk/observability";
17
+ * ```
18
+ *
19
+ * The granular modules (`@decocms/start/sdk/logger`, `.../otelAdapters`)
20
+ * remain importable for advanced use cases (custom adapters, tests, etc.)
21
+ * but the common path stays here.
22
+ *
23
+ * **5.0.0 surface change.** The OTLP exporters (`createOtelLoggerAdapter`,
24
+ * `createOtelMeterAdapter`), the flush registry (`flushOtelProviders`,
25
+ * `registerOtelFlushHandler`), and the URL-based sampler
26
+ * (`URLBasedSampler`, `decodeSamplingConfig`, `createUrlBasedHeadSampler`,
27
+ * `SamplingConfig`, `SamplingRule`) were removed. They will be reintroduced
28
+ * via `./otelAdapters/clickhouseCollector.ts` when the platform-side OTel
29
+ * collector ships. The `instrumentWorker` / `withTracing` / `logger` /
30
+ * `recordRequestMetric` / `recordCacheMetric` surface is unchanged — only
31
+ * the transport layer was stripped.
32
+ */
33
+
34
+ // Composite helpers (for advanced multi-backend wiring — e.g. AE + future
35
+ // ClickHouse-collector meter, or default-console + future-collector logger)
36
+ export { createCompositeLogger, createCompositeMeter } from "./composite";
37
+ // Logger surface
38
+ export {
39
+ configureLogger,
40
+ defaultLoggerAdapter,
41
+ getLoggerAdapter,
42
+ getLogLevel,
43
+ type Logger,
44
+ type LoggerAdapter,
45
+ type LogLevel,
46
+ logger,
47
+ type SerializedError,
48
+ serializeError,
49
+ setLoggerAttributeFloor,
50
+ setLogLevel,
51
+ } from "./logger";
52
+ // Tracer / meter / request log primitives (re-exported from the middleware)
53
+ export {
54
+ type CacheDecision,
55
+ type CacheLayer,
56
+ type CommerceMetricLabels,
57
+ configureMeter,
58
+ configureTracer,
59
+ getActiveSpan,
60
+ getMeter,
61
+ getTracer,
62
+ injectTraceContext,
63
+ logRequest,
64
+ type MeterAdapter,
65
+ MetricNames,
66
+ recordCacheMetric,
67
+ recordCommerceMetric,
68
+ recordLoaderError,
69
+ recordLoaderMetric,
70
+ recordRequestMetric,
71
+ type RequestMetricLabels,
72
+ type RequestStore,
73
+ type Span,
74
+ setObservabilitySpanStore,
75
+ setSpanAttribute,
76
+ statusClassFor,
77
+ type TracerAdapter,
78
+ withTracing,
79
+ } from "../middleware/observability";
80
+ // Worker-entry wrapper + adapter wiring
81
+ export { instrumentWorker, type OtelOptions } from "./otel";
82
+ // Direct-POST OTLP trace exporter (Phase 3 / D-12). Exported for sites
83
+ // that need to wire a custom traces endpoint outside `instrumentWorker`,
84
+ // and for the audit tooling that asserts framework spans are flowing.
85
+ export {
86
+ createOtlpHttpTracerAdapter,
87
+ newSpanId,
88
+ newTraceId,
89
+ type OtlpHttpTracer,
90
+ type OtlpHttpTracerOptions,
91
+ parseTraceparent,
92
+ shouldSampleTrace,
93
+ type TraceContext,
94
+ } from "./otelHttpTracer";
95
+ // AE meter adapter + runtime env helpers (for tests / custom wiring)
96
+ export {
97
+ type AnalyticsEngineDataset,
98
+ type AnalyticsEngineMeterAdapterOptions,
99
+ createAnalyticsEngineMeterAdapter,
100
+ getRuntimeEnv,
101
+ setRuntimeEnv,
102
+ } from "./otelAdapters";
103
+ // ClickHouse collector adapter — stub today, real exporter when the
104
+ // collector lands. Re-exported from here so site code can import the
105
+ // future-target symbol via the canonical observability barrel.
106
+ export {
107
+ type ClickhouseCollectorOptions,
108
+ createClickhouseCollectorAdapter,
109
+ } from "./otelAdapters/clickhouseCollector";