@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,364 @@
1
+ /**
2
+ * Server-side loader caching with stale-while-revalidate + stale-if-error.
3
+ *
4
+ * Provides an in-memory cache layer for commerce loaders during SSR.
5
+ * Supports:
6
+ * - Single-flight dedup (identical concurrent requests share one fetch)
7
+ * - SWR: serve stale immediately, refresh in background
8
+ * - SIE: on origin error, fall back to stale entry within a configurable window
9
+ *
10
+ * Can be configured with explicit options or by passing a cache profile name
11
+ * (e.g. "product") which derives timing from the unified profile system.
12
+ */
13
+
14
+ import {
15
+ recordCacheMetric,
16
+ recordLoaderError,
17
+ recordLoaderMetric,
18
+ withTracing,
19
+ } from "../middleware/observability";
20
+ import { type CacheProfileName, loaderCacheOptions } from "./cacheHeaders";
21
+ import { withInflightTimeout } from "./inflightTimeout";
22
+
23
+ export type CachePolicy = "no-store" | "no-cache" | "stale-while-revalidate";
24
+
25
+ export interface CachedLoaderOptions {
26
+ policy: CachePolicy;
27
+ /** Max age in milliseconds before an entry is considered stale. Default: 60_000 (1 min). */
28
+ maxAge?: number;
29
+ /** How long to serve stale on origin error, in ms. Default: 0 (no error fallback). */
30
+ staleIfError?: number;
31
+ /** Key function to generate a cache key from loader props. Default: JSON.stringify. */
32
+ keyFn?: (props: unknown) => string;
33
+ }
34
+
35
+ export interface LoaderModule<TProps = any, TResult = any> {
36
+ default: (props: TProps) => Promise<TResult>;
37
+ cache?: CachePolicy | { maxAge: number };
38
+ cacheKey?: (props: TProps) => string | null;
39
+ }
40
+
41
+ interface CacheEntry<T = unknown> {
42
+ value: T;
43
+ createdAt: number;
44
+ refreshing: boolean;
45
+ /** Estimated payload size in bytes (UTF-16 length of JSON.stringify). */
46
+ estimatedBytes: number;
47
+ }
48
+
49
+ const DEFAULT_MAX_AGE = 60_000;
50
+
51
+ /**
52
+ * Byte-cap for the in-memory loader cache. Default 32 MB — comfortably below
53
+ * the Cloudflare Workers 128 MB isolate limit even when other caches (router,
54
+ * VTEX fetch cache, V8 heap) are also resident.
55
+ *
56
+ * Override via env: `DECO_LOADER_CACHE_MAX_BYTES=67108864` (64 MB).
57
+ *
58
+ * Switched from entry-count to byte-based eviction because PLP payloads
59
+ * (~0.5–2 MB each) blew past 128 MB at well under the previous 500-entry cap.
60
+ */
61
+ const DEFAULT_MAX_CACHE_BYTES = 32 * 1024 * 1024;
62
+
63
+ function resolveMaxBytes(): number {
64
+ const env = typeof globalThis.process !== "undefined"
65
+ ? globalThis.process.env
66
+ : undefined;
67
+ const raw = env?.DECO_LOADER_CACHE_MAX_BYTES;
68
+ if (!raw) return DEFAULT_MAX_CACHE_BYTES;
69
+ const parsed = Number.parseInt(raw, 10);
70
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_CACHE_BYTES;
71
+ }
72
+
73
+ const MAX_CACHE_BYTES = resolveMaxBytes();
74
+
75
+ const cache = new Map<string, CacheEntry>();
76
+ let cacheBytes = 0;
77
+
78
+ // Floor each entry at 512 bytes so we never accumulate unbounded zero-cost
79
+ // entries when a loader legitimately returns `undefined` or an empty object
80
+ // — `JSON.stringify(undefined) === undefined` and the eviction loop would
81
+ // otherwise never reclaim them. JSON-length also under-counts V8's actual
82
+ // retention (structured objects retain 2–5× the JSON byte size); the 32 MB
83
+ // cap is conservative enough that this approximation is fine for an OOM
84
+ // safety net, but real RSS at the cap can land around 64–160 MB on
85
+ // object-heavy payloads.
86
+ const MIN_ENTRY_BYTES = 512;
87
+
88
+ function estimateBytes(value: unknown): number {
89
+ try {
90
+ // UTF-16 string length is an order-of-magnitude estimate of the bytes the
91
+ // object retains in V8 (V8 keeps a structured representation, not JSON).
92
+ // The absolute value is less important than the relative pressure signal.
93
+ const len = JSON.stringify(value)?.length ?? 0;
94
+ return Math.max(len, MIN_ENTRY_BYTES);
95
+ } catch {
96
+ // Circular refs / non-serializable values: fall back to a fixed budget so
97
+ // the entry still counts against the cap.
98
+ return 1024;
99
+ }
100
+ }
101
+
102
+ function setCacheEntry<T>(key: string, entry: CacheEntry<T>) {
103
+ const prev = cache.get(key);
104
+ if (prev) cacheBytes -= prev.estimatedBytes;
105
+ cacheBytes += entry.estimatedBytes;
106
+ cache.set(key, entry);
107
+ }
108
+
109
+ function deleteCacheEntry(key: string) {
110
+ const prev = cache.get(key);
111
+ if (!prev) return;
112
+ cacheBytes -= prev.estimatedBytes;
113
+ cache.delete(key);
114
+ }
115
+
116
+ function evictIfNeeded() {
117
+ if (cacheBytes <= MAX_CACHE_BYTES) return;
118
+ const oldest = [...cache.entries()].sort(
119
+ (a, b) => a[1].createdAt - b[1].createdAt,
120
+ );
121
+ for (const [key] of oldest) {
122
+ deleteCacheEntry(key);
123
+ if (cacheBytes <= MAX_CACHE_BYTES) break;
124
+ }
125
+ }
126
+
127
+ const inflightRequests = new Map<string, Promise<unknown>>();
128
+
129
+ function resolveOptions(
130
+ optionsOrProfile: CachedLoaderOptions | CacheProfileName,
131
+ ): CachedLoaderOptions {
132
+ if (typeof optionsOrProfile === "string") {
133
+ return loaderCacheOptions(optionsOrProfile);
134
+ }
135
+ return optionsOrProfile;
136
+ }
137
+
138
+ /**
139
+ * Wraps a loader function with server-side caching, single-flight dedup,
140
+ * and stale-if-error resilience.
141
+ *
142
+ * Accepts either explicit options or a cache profile name:
143
+ *
144
+ * @example
145
+ * ```ts
146
+ * // Profile-driven (recommended):
147
+ * const cachedPDP = createCachedLoader("vtex/productDetailsPage", pdpLoader, "product");
148
+ *
149
+ * // Explicit options (when loader needs different timing than its profile):
150
+ * const cachedSuggestions = createCachedLoader("vtex/suggestions", suggestionsLoader, {
151
+ * policy: "stale-while-revalidate",
152
+ * maxAge: 120_000,
153
+ * staleIfError: 300_000,
154
+ * });
155
+ * ```
156
+ */
157
+ export function createCachedLoader<TProps, TResult>(
158
+ name: string,
159
+ loaderFn: (props: TProps) => Promise<TResult>,
160
+ optionsOrProfile: CachedLoaderOptions | CacheProfileName,
161
+ ): (props: TProps) => Promise<TResult> {
162
+ const resolved = resolveOptions(optionsOrProfile);
163
+ const { policy, maxAge = DEFAULT_MAX_AGE, staleIfError = 0, keyFn = JSON.stringify } = resolved;
164
+
165
+ const env = typeof globalThis.process !== "undefined" ? globalThis.process.env : undefined;
166
+ const isDev = env?.DECO_CACHE_DISABLE === "true" || env?.NODE_ENV === "development";
167
+
168
+ if (policy === "no-store") return loaderFn;
169
+
170
+ return async (props: TProps): Promise<TResult> => {
171
+ const cacheKey = `${name}::${keyFn(props)}`;
172
+
173
+ const inflight = inflightRequests.get(cacheKey);
174
+ if (inflight) {
175
+ // Treat in-flight dedup as a cache hit — avoided the origin call.
176
+ recordCacheMetric(true, name, undefined, "cachedLoader");
177
+ const start = performance.now();
178
+ return inflight.then((r) => {
179
+ recordLoaderMetric(name, performance.now() - start, "HIT");
180
+ return r as TResult;
181
+ });
182
+ }
183
+
184
+ if (isDev) {
185
+ // Dev mode: no caching, but still useful to count attempts.
186
+ recordCacheMetric(false, name, undefined, "cachedLoader");
187
+ const devStart = performance.now();
188
+ const promise = withInflightTimeout(
189
+ withTracing(
190
+ "deco.cachedLoader",
191
+ () => loaderFn(props),
192
+ { "deco.loader": name, "deco.cache.policy": "no-cache-dev" },
193
+ ),
194
+ `cachedLoader:dev ${cacheKey}`,
195
+ )
196
+ .then((r) => {
197
+ recordLoaderMetric(name, performance.now() - devStart, "BYPASS");
198
+ return r;
199
+ })
200
+ .catch((err) => {
201
+ recordLoaderMetric(name, performance.now() - devStart, "BYPASS");
202
+ recordLoaderError(name);
203
+ throw err;
204
+ })
205
+ .finally(() => inflightRequests.delete(cacheKey));
206
+ inflightRequests.set(cacheKey, promise);
207
+ return promise;
208
+ }
209
+
210
+ const entry = cache.get(cacheKey) as CacheEntry<TResult> | undefined;
211
+ const now = Date.now();
212
+ const isStale = entry ? now - entry.createdAt > maxAge : true;
213
+
214
+ if (policy === "no-cache") {
215
+ if (entry && !isStale) {
216
+ recordCacheMetric(true, name, "HIT", "cachedLoader");
217
+ recordLoaderMetric(name, 0, "HIT");
218
+ return entry.value;
219
+ }
220
+ }
221
+
222
+ if (policy === "stale-while-revalidate") {
223
+ if (entry && !isStale) {
224
+ recordCacheMetric(true, name, "HIT", "cachedLoader");
225
+ recordLoaderMetric(name, 0, "HIT");
226
+ return entry.value;
227
+ }
228
+
229
+ if (entry && isStale && !entry.refreshing) {
230
+ // Stale-while-revalidate hit: serve stale, refresh in background.
231
+ recordCacheMetric(true, name, "STALE-HIT", "cachedLoader");
232
+ recordLoaderMetric(name, 0, "STALE-HIT");
233
+ entry.refreshing = true;
234
+ loaderFn(props)
235
+ .then((result) => {
236
+ setCacheEntry(cacheKey, {
237
+ value: result,
238
+ createdAt: Date.now(),
239
+ refreshing: false,
240
+ estimatedBytes: estimateBytes(result),
241
+ });
242
+ evictIfNeeded();
243
+ })
244
+ .catch(() => {
245
+ // Background refresh failed — entry stays stale.
246
+ // If past the SIE window, evict so we don't serve indefinitely stale data.
247
+ entry.refreshing = false;
248
+ if (staleIfError > 0 && now - entry.createdAt > maxAge + staleIfError) {
249
+ deleteCacheEntry(cacheKey);
250
+ }
251
+ });
252
+ return entry.value;
253
+ }
254
+
255
+ if (entry) {
256
+ // Past SIE window — still serve the stale value once but mark
257
+ // the decision as STALE-ERROR so dashboards can distinguish
258
+ // this from healthy SWR.
259
+ recordCacheMetric(true, name, "STALE-ERROR", "cachedLoader");
260
+ recordLoaderMetric(name, 0, "STALE-ERROR");
261
+ return entry.value;
262
+ }
263
+ }
264
+
265
+ // Cache miss — emit metric, then run loader inside a span so individual
266
+ // slow loaders are visible in traces.
267
+ recordCacheMetric(false, name, "MISS", "cachedLoader");
268
+ const loaderStart = performance.now();
269
+ const promise = withInflightTimeout(
270
+ withTracing("deco.cachedLoader", () => loaderFn(props), {
271
+ "deco.loader": name,
272
+ "deco.cache.policy": policy,
273
+ }),
274
+ `cachedLoader ${cacheKey}`,
275
+ )
276
+ .then((result) => {
277
+ recordLoaderMetric(name, performance.now() - loaderStart, "MISS");
278
+ setCacheEntry(cacheKey, {
279
+ value: result,
280
+ createdAt: Date.now(),
281
+ refreshing: false,
282
+ estimatedBytes: estimateBytes(result),
283
+ });
284
+ evictIfNeeded();
285
+ return result;
286
+ })
287
+ .catch((err) => {
288
+ // SIE fallback: if we have a stale entry within the error window, return it
289
+ if (staleIfError > 0 && entry) {
290
+ const age = now - entry.createdAt;
291
+ if (age < maxAge + staleIfError) {
292
+ console.warn(
293
+ `[cachedLoader] ${name}: origin error, serving stale entry (age=${Math.round(age / 1000)}s, sie=${Math.round(staleIfError / 1000)}s)`,
294
+ );
295
+ recordLoaderMetric(name, performance.now() - loaderStart, "STALE-ERROR");
296
+ return entry.value;
297
+ }
298
+ }
299
+ recordLoaderMetric(name, performance.now() - loaderStart, "MISS");
300
+ recordLoaderError(name);
301
+ throw err;
302
+ })
303
+ .finally(() => inflightRequests.delete(cacheKey));
304
+
305
+ inflightRequests.set(cacheKey, promise);
306
+ return promise;
307
+ };
308
+ }
309
+
310
+ /**
311
+ * Create a cached loader from a module that exports `cache` and/or `cacheKey`.
312
+ * Falls back to the provided defaults if the module doesn't declare them.
313
+ */
314
+ export function createCachedLoaderFromModule<TProps, TResult>(
315
+ name: string,
316
+ mod: LoaderModule<TProps, TResult>,
317
+ defaults?: Partial<CachedLoaderOptions>,
318
+ ): (props: TProps) => Promise<TResult> {
319
+ const moduleCache = mod.cache;
320
+ let policy: CachePolicy;
321
+ let maxAge: number | undefined;
322
+
323
+ if (typeof moduleCache === "string") {
324
+ policy = moduleCache;
325
+ } else if (moduleCache && typeof moduleCache === "object") {
326
+ policy = "stale-while-revalidate";
327
+ maxAge = moduleCache.maxAge;
328
+ } else {
329
+ policy = defaults?.policy ?? "stale-while-revalidate";
330
+ }
331
+
332
+ maxAge = maxAge ?? defaults?.maxAge;
333
+
334
+ const keyFn = mod.cacheKey
335
+ ? (props: unknown) => {
336
+ const key = mod.cacheKey!(props as TProps);
337
+ return key ?? JSON.stringify(props);
338
+ }
339
+ : defaults?.keyFn;
340
+
341
+ return createCachedLoader(name, mod.default, {
342
+ policy,
343
+ maxAge,
344
+ staleIfError: defaults?.staleIfError,
345
+ keyFn,
346
+ });
347
+ }
348
+
349
+ /** Clear all cached entries. Useful for decofile hot-reload. */
350
+ export function clearLoaderCache() {
351
+ cache.clear();
352
+ cacheBytes = 0;
353
+ inflightRequests.clear();
354
+ }
355
+
356
+ /** Get cache stats for diagnostics. */
357
+ export function getLoaderCacheStats() {
358
+ return {
359
+ entries: cache.size,
360
+ inflight: inflightRequests.size,
361
+ estimatedBytes: cacheBytes,
362
+ maxBytes: MAX_CACHE_BYTES,
363
+ };
364
+ }
package/src/sdk/clx.ts ADDED
@@ -0,0 +1,5 @@
1
+ /** Filter out nullable values, join and minify class names */
2
+ export const clx = (...args: (string | null | undefined | false)[]) =>
3
+ args.filter(Boolean).join(" ").replace(/\s\s+/g, " ");
4
+
5
+ export default clx;
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { clx, cn } from "./cn";
3
+
4
+ describe("cn", () => {
5
+ it("joins strings", () => {
6
+ expect(cn("a", "b", "c")).toBe("a b c");
7
+ });
8
+
9
+ it("supports conditional objects", () => {
10
+ expect(cn("a", { b: true, c: false }, "d")).toBe("a b d");
11
+ });
12
+
13
+ it("filters out falsy values", () => {
14
+ expect(cn("a", null, undefined, false, 0 as any, "b")).toBe("a b");
15
+ });
16
+
17
+ it("merges conflicting Tailwind utilities (last one wins)", () => {
18
+ expect(cn("p-2", "p-4")).toBe("p-4");
19
+ });
20
+
21
+ it("merges hover variants independently from base utilities", () => {
22
+ expect(cn("p-2", "hover:p-4")).toBe("p-2 hover:p-4");
23
+ });
24
+ });
25
+
26
+ describe("clx (re-exported)", () => {
27
+ it("filters falsy and joins with single spaces", () => {
28
+ expect(clx("a", null, "b", undefined, "c")).toBe("a b c");
29
+ });
30
+
31
+ it("does NOT merge conflicting utilities (that's cn's job)", () => {
32
+ expect(clx("p-2", "p-4")).toBe("p-2 p-4");
33
+ });
34
+ });
package/src/sdk/cn.ts ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `cn(...inputs)` — the canonical Tailwind class-name combinator
3
+ * (clsx + tailwind-merge). Every storefront we audited shipped a
4
+ * site-local copy of this; we promote it to the framework so sites
5
+ * can drop the duplicate.
6
+ *
7
+ * Behaviour:
8
+ * - Accepts the full `clsx` input format (strings, objects, arrays,
9
+ * conditionals, falsy values).
10
+ * - De-duplicates conflicting Tailwind utilities via `tailwind-merge`
11
+ * (e.g. `cn("p-2", "p-4")` → `"p-4"`).
12
+ *
13
+ * The simpler `clx` (no tailwind-merge, just `filter+join`) is still
14
+ * exported from `@decocms/start/sdk/clx` for cases where you want to
15
+ * keep the literal class string. Re-exported here so a single import
16
+ * covers both.
17
+ */
18
+
19
+ import { clsx, type ClassValue } from "clsx";
20
+ import { twMerge } from "tailwind-merge";
21
+
22
+ export { clx } from "./clx";
23
+
24
+ export function cn(...inputs: ClassValue[]): string {
25
+ return twMerge(clsx(inputs));
26
+ }
27
+
28
+ export type { ClassValue };
@@ -0,0 +1,121 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import type { MeterAdapter } from "../middleware/observability";
3
+ import { createCompositeLogger, createCompositeMeter } from "./composite";
4
+ import type { LoggerAdapter } from "./logger";
5
+
6
+ describe("createCompositeLogger", () => {
7
+ afterEach(() => vi.restoreAllMocks());
8
+
9
+ it("fans calls out to every adapter", () => {
10
+ const aCalls: any[][] = [];
11
+ const bCalls: any[][] = [];
12
+ const a: LoggerAdapter = { log: (...args) => void aCalls.push(args) };
13
+ const b: LoggerAdapter = { log: (...args) => void bCalls.push(args) };
14
+ const composite = createCompositeLogger([a, b]);
15
+
16
+ composite.log("info", "hello", { foo: 1 });
17
+
18
+ expect(aCalls).toEqual([["info", "hello", { foo: 1 }]]);
19
+ expect(bCalls).toEqual([["info", "hello", { foo: 1 }]]);
20
+ });
21
+
22
+ it("filters falsy entries (null/undefined/false)", () => {
23
+ const calls: any[] = [];
24
+ const a: LoggerAdapter = { log: (...args) => void calls.push(args) };
25
+ const composite = createCompositeLogger([null, a, undefined, false]);
26
+ composite.log("info", "ok");
27
+ // adapter receives the call with no third arg (undefined is omitted)
28
+ expect(calls.length).toBe(1);
29
+ expect(calls[0][0]).toBe("info");
30
+ expect(calls[0][1]).toBe("ok");
31
+ });
32
+
33
+ it("isolates errors so one bad adapter does not block others", () => {
34
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
35
+ const okCalls: any[][] = [];
36
+ const broken: LoggerAdapter = {
37
+ log() {
38
+ throw new Error("boom");
39
+ },
40
+ };
41
+ const ok: LoggerAdapter = { log: (...args) => void okCalls.push(args) };
42
+ const composite = createCompositeLogger([broken, ok]);
43
+
44
+ expect(() => composite.log("error", "still goes through")).not.toThrow();
45
+ expect(okCalls.length).toBe(1);
46
+ expect(okCalls[0][0]).toBe("error");
47
+ expect(okCalls[0][1]).toBe("still goes through");
48
+ expect(errSpy).toHaveBeenCalled(); // composite reports the failure
49
+ });
50
+
51
+ it("returns the single adapter directly when only one is provided", () => {
52
+ const a: LoggerAdapter = { log: () => {} };
53
+ expect(createCompositeLogger([a])).toBe(a);
54
+ });
55
+ });
56
+
57
+ describe("createCompositeMeter", () => {
58
+ afterEach(() => vi.restoreAllMocks());
59
+
60
+ function recorder() {
61
+ const counter: any[] = [];
62
+ const gauge: any[] = [];
63
+ const histo: any[] = [];
64
+ const meter: MeterAdapter = {
65
+ counterInc: (n, v, l) => void counter.push([n, v, l]),
66
+ gaugeSet: (n, v, l) => void gauge.push([n, v, l]),
67
+ histogramRecord: (n, v, l) => void histo.push([n, v, l]),
68
+ };
69
+ return { meter, counter, gauge, histo };
70
+ }
71
+
72
+ it("fans counter/gauge/histogram across meters", () => {
73
+ const a = recorder();
74
+ const b = recorder();
75
+ const composite = createCompositeMeter([a.meter, b.meter]);
76
+
77
+ composite.counterInc("c", 1, { p: "/" });
78
+ composite.gaugeSet?.("g", 5);
79
+ composite.histogramRecord?.("h", 100, { route: "/p" });
80
+
81
+ expect(a.counter[0].slice(0, 2)).toEqual(["c", 1]);
82
+ expect(a.counter[0][2]).toEqual({ p: "/" });
83
+ expect(b.counter[0].slice(0, 2)).toEqual(["c", 1]);
84
+ expect(a.gauge[0].slice(0, 2)).toEqual(["g", 5]);
85
+ expect(b.gauge[0].slice(0, 2)).toEqual(["g", 5]);
86
+ expect(a.histo[0]).toEqual(["h", 100, { route: "/p" }]);
87
+ expect(b.histo[0]).toEqual(["h", 100, { route: "/p" }]);
88
+ });
89
+
90
+ it("isolates errors per meter and per call type", () => {
91
+ vi.spyOn(console, "error").mockImplementation(() => {});
92
+ const broken: MeterAdapter = {
93
+ counterInc: () => {
94
+ throw new Error("c");
95
+ },
96
+ gaugeSet: () => {
97
+ throw new Error("g");
98
+ },
99
+ histogramRecord: () => {
100
+ throw new Error("h");
101
+ },
102
+ };
103
+ const ok = recorder();
104
+ const composite = createCompositeMeter([broken, ok.meter]);
105
+
106
+ composite.counterInc("c", 1);
107
+ composite.gaugeSet?.("g", 5);
108
+ composite.histogramRecord?.("h", 100);
109
+
110
+ expect(ok.counter[0].slice(0, 2)).toEqual(["c", 1]);
111
+ expect(ok.gauge[0].slice(0, 2)).toEqual(["g", 5]);
112
+ expect(ok.histo[0].slice(0, 2)).toEqual(["h", 100]);
113
+ });
114
+
115
+ it("skips meters that don't implement optional ops", () => {
116
+ const onlyCounter: MeterAdapter = { counterInc: () => {} };
117
+ const composite = createCompositeMeter([onlyCounter]);
118
+ expect(() => composite.gaugeSet?.("g", 1)).not.toThrow();
119
+ expect(() => composite.histogramRecord?.("h", 1)).not.toThrow();
120
+ });
121
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Composite adapters — fan out to multiple backends with try/catch isolation.
3
+ *
4
+ * Used by `instrumentWorker()` so the same logger/meter can dual-emit to
5
+ * (e.g.) console-JSON + OTLP, or AE + OTLP, without any one backend's
6
+ * failure taking down the others.
7
+ *
8
+ * The "always include console-JSON logger" guarantee in the observability
9
+ * plan relies on this: even if HyperDX is down, the local console adapter
10
+ * still records the line.
11
+ */
12
+
13
+ import type { MeterAdapter } from "../middleware/observability";
14
+ import type { LoggerAdapter, LogLevel } from "./logger";
15
+
16
+ type Labels = Record<string, string | number | boolean>;
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Logger
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Returns a single LoggerAdapter that fans every call out to all
24
+ * provided adapters. Falsy entries (e.g. `otelEnabled ? otelAdapter : null`)
25
+ * are filtered out so call sites can write:
26
+ *
27
+ * ```ts
28
+ * createCompositeLogger([defaultLoggerAdapter, otelEnabled ? otelLogger : null]);
29
+ * ```
30
+ *
31
+ * Each downstream `.log()` call is isolated in try/catch so a thrown
32
+ * error in one backend cannot suppress the others.
33
+ */
34
+ export function createCompositeLogger(
35
+ adapters: Array<LoggerAdapter | null | undefined | false>,
36
+ ): LoggerAdapter {
37
+ const list = adapters.filter((a): a is LoggerAdapter => Boolean(a));
38
+
39
+ if (list.length === 1) return list[0];
40
+
41
+ return {
42
+ log(level: LogLevel, msg: string, attrs?: Record<string, unknown>) {
43
+ for (const adapter of list) {
44
+ try {
45
+ adapter.log(level, msg, attrs);
46
+ } catch (error) {
47
+ // Use console.error directly — calling logger here would recurse.
48
+ try {
49
+ console.error("[composite-logger] adapter failed", String(error));
50
+ } catch {
51
+ /* swallow */
52
+ }
53
+ }
54
+ }
55
+ },
56
+ };
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Meter
61
+ // ---------------------------------------------------------------------------
62
+
63
+ /**
64
+ * Returns a single MeterAdapter that fans counter/gauge/histogram writes
65
+ * out to all provided meters. Same semantics as `createCompositeLogger`:
66
+ * falsy entries are dropped, each downstream call is try/catch isolated.
67
+ */
68
+ export function createCompositeMeter(
69
+ adapters: Array<MeterAdapter | null | undefined | false>,
70
+ ): MeterAdapter {
71
+ const list = adapters.filter((a): a is MeterAdapter => Boolean(a));
72
+
73
+ if (list.length === 1) return list[0];
74
+
75
+ return {
76
+ counterInc(name: string, value?: number, labels?: Labels) {
77
+ for (const m of list) {
78
+ try {
79
+ m.counterInc(name, value, labels);
80
+ } catch (error) {
81
+ warn("counterInc", name, error);
82
+ }
83
+ }
84
+ },
85
+ gaugeSet(name: string, value: number, labels?: Labels) {
86
+ for (const m of list) {
87
+ if (!m.gaugeSet) continue;
88
+ try {
89
+ m.gaugeSet(name, value, labels);
90
+ } catch (error) {
91
+ warn("gaugeSet", name, error);
92
+ }
93
+ }
94
+ },
95
+ histogramRecord(name: string, value: number, labels?: Labels) {
96
+ for (const m of list) {
97
+ if (!m.histogramRecord) continue;
98
+ try {
99
+ m.histogramRecord(name, value, labels);
100
+ } catch (error) {
101
+ warn("histogramRecord", name, error);
102
+ }
103
+ }
104
+ },
105
+ };
106
+ }
107
+
108
+ function warn(op: string, metric: string, error: unknown) {
109
+ try {
110
+ console.error(`[composite-meter] ${op}(${metric}) adapter failed`, String(error));
111
+ } catch {
112
+ /* swallow */
113
+ }
114
+ }