@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,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,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
|
+
}
|