@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,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Instrumented fetch wrapper that adds logging and tracing to outbound HTTP calls.
|
|
3
|
+
*
|
|
4
|
+
* Designed to be wired into commerce clients (VTEX, Shopify) so all
|
|
5
|
+
* API calls become visible in dev logs and production traces.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { createInstrumentedFetch } from "@decocms/start/sdk/instrumentedFetch";
|
|
10
|
+
*
|
|
11
|
+
* const vtexFetch = createInstrumentedFetch("vtex");
|
|
12
|
+
*
|
|
13
|
+
* // Use it instead of global fetch:
|
|
14
|
+
* const response = await vtexFetch("https://account.vtexcommercestable.com.br/api/...");
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { logger } from "./logger";
|
|
19
|
+
import { getTracer, injectTraceContext } from "./observability";
|
|
20
|
+
import { redactUrl } from "./urlRedaction";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Cloudflare / VTEX response headers that operators want to see as span
|
|
24
|
+
* attributes when debugging cache behavior. Mirrors `applyCustomAttributesOnSpan`
|
|
25
|
+
* in `deco-cx/deco/observability/otel/`.
|
|
26
|
+
*/
|
|
27
|
+
const CACHE_HEADERS_TO_SPAN: Array<{ header: string; attr: string }> = [
|
|
28
|
+
{ header: "cf-cache-status", attr: "cf.cache.status" },
|
|
29
|
+
{ header: "cf-ray", attr: "cf.ray" },
|
|
30
|
+
{ header: "x-vtex-io-cluster-id", attr: "vtex.io.cluster.id" },
|
|
31
|
+
{ header: "x-edge-cache-status", attr: "edge.cache.status" },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const TRUE_LITERAL = "true";
|
|
35
|
+
|
|
36
|
+
function envFlag(name: string): boolean {
|
|
37
|
+
const env = typeof globalThis.process !== "undefined" ? globalThis.process.env : undefined;
|
|
38
|
+
return env?.[name] === TRUE_LITERAL;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface FetchInstrumentationOptions {
|
|
42
|
+
/** Tag for log/trace grouping (e.g., "vtex", "shopify"). */
|
|
43
|
+
name: string;
|
|
44
|
+
/** Enable request/response logging. Default: true in development. */
|
|
45
|
+
logging?: boolean;
|
|
46
|
+
/** Enable tracing via the configured TracerAdapter. Default: true. */
|
|
47
|
+
tracing?: boolean;
|
|
48
|
+
/** Callback when a request completes (for custom metrics). */
|
|
49
|
+
onComplete?: (info: FetchMetrics) => void;
|
|
50
|
+
/**
|
|
51
|
+
* Underlying fetch implementation to wrap. Defaults to `globalThis.fetch`.
|
|
52
|
+
* Use this when the client already has a custom fetch (e.g. with cookies,
|
|
53
|
+
* custom headers, or a proxy) that must be preserved.
|
|
54
|
+
*/
|
|
55
|
+
baseFetch?: typeof fetch;
|
|
56
|
+
/**
|
|
57
|
+
* Query parameter names whose value should NOT be redacted in logs +
|
|
58
|
+
* span attributes. Default: empty — every value is redacted. Use for
|
|
59
|
+
* structural params that don't carry secrets, e.g. `["page", "sort"]`.
|
|
60
|
+
* See `redactUrl` in `./urlRedaction.ts`.
|
|
61
|
+
*/
|
|
62
|
+
keepQueryKeys?: ReadonlyArray<string>;
|
|
63
|
+
/**
|
|
64
|
+
* Inject the active span's W3C `traceparent` header onto outbound
|
|
65
|
+
* requests so downstream services that participate in OTel can join
|
|
66
|
+
* our trace. Default: true. Set to false for calls to endpoints that
|
|
67
|
+
* reject unknown headers (rare).
|
|
68
|
+
*/
|
|
69
|
+
injectTraceparent?: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Fallback operation name used when a call doesn't supply one via
|
|
72
|
+
* `init.operation`. Span name becomes `${name}.${defaultOperation}`.
|
|
73
|
+
* Useful when a client is single-purpose, e.g. a Resend client where
|
|
74
|
+
* every call is the literal `"emails.send"`. Default: not set (the
|
|
75
|
+
* resolver below runs next, then the literal `"fetch"`).
|
|
76
|
+
*/
|
|
77
|
+
defaultOperation?: string;
|
|
78
|
+
/**
|
|
79
|
+
* URL-derived operation router. Called when neither `init.operation`
|
|
80
|
+
* nor `defaultOperation` is set. Receives the rawUrl + method, returns
|
|
81
|
+
* an operation string or `undefined` to opt out (in which case the
|
|
82
|
+
* span falls back to `${name}.fetch`). Centralizes the long-tail of
|
|
83
|
+
* commerce endpoints that don't merit a hand-authored operation name
|
|
84
|
+
* at the call site while staying visibly debuggable in spans.
|
|
85
|
+
*
|
|
86
|
+
* Resolution precedence:
|
|
87
|
+
* `init.operation` ?? `defaultOperation` ?? `resolveOperation(url, method)` ?? `"fetch"`
|
|
88
|
+
*/
|
|
89
|
+
resolveOperation?: (url: string, method: string) => string | undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface FetchMetrics {
|
|
93
|
+
name: string;
|
|
94
|
+
url: string;
|
|
95
|
+
method: string;
|
|
96
|
+
status: number;
|
|
97
|
+
durationMs: number;
|
|
98
|
+
cached: boolean;
|
|
99
|
+
/** Resolved operation name (post-precedence-resolution). */
|
|
100
|
+
operation: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Init shape accepted by an instrumented fetch. Strictly a superset of
|
|
105
|
+
* `RequestInit` (the only extra property is optional), so an
|
|
106
|
+
* `InstrumentedFetch` is assignable wherever a `typeof fetch` is
|
|
107
|
+
* expected — existing callers that don't author an operation string
|
|
108
|
+
* keep compiling unchanged.
|
|
109
|
+
*/
|
|
110
|
+
export type InstrumentedFetchInit = RequestInit & {
|
|
111
|
+
/**
|
|
112
|
+
* Per-call operation override. Produces a span named
|
|
113
|
+
* `${name}.${operation}` instead of the default `${name}.fetch`.
|
|
114
|
+
* Stripped from the init before reaching `baseFetch` so it never
|
|
115
|
+
* surfaces to the network as a request property.
|
|
116
|
+
*/
|
|
117
|
+
operation?: string;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Fetch with an optional per-call `operation` extension on init. Returned
|
|
122
|
+
* by `createInstrumentedFetch`. Assignable to `typeof fetch` because the
|
|
123
|
+
* extra property is optional.
|
|
124
|
+
*/
|
|
125
|
+
export type InstrumentedFetch = (
|
|
126
|
+
input: RequestInfo | URL,
|
|
127
|
+
init?: InstrumentedFetchInit,
|
|
128
|
+
) => Promise<Response>;
|
|
129
|
+
|
|
130
|
+
const isDev =
|
|
131
|
+
typeof globalThis.process !== "undefined" && globalThis.process.env?.NODE_ENV === "development";
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Creates a fetch wrapper that instruments all requests for a given integration.
|
|
135
|
+
*/
|
|
136
|
+
export function createInstrumentedFetch(
|
|
137
|
+
nameOrOptions: string | FetchInstrumentationOptions,
|
|
138
|
+
): InstrumentedFetch {
|
|
139
|
+
const options: FetchInstrumentationOptions =
|
|
140
|
+
typeof nameOrOptions === "string" ? { name: nameOrOptions } : nameOrOptions;
|
|
141
|
+
|
|
142
|
+
const {
|
|
143
|
+
name,
|
|
144
|
+
logging = isDev,
|
|
145
|
+
tracing = true,
|
|
146
|
+
onComplete,
|
|
147
|
+
baseFetch = globalThis.fetch,
|
|
148
|
+
keepQueryKeys,
|
|
149
|
+
injectTraceparent = true,
|
|
150
|
+
defaultOperation,
|
|
151
|
+
resolveOperation,
|
|
152
|
+
} = options;
|
|
153
|
+
|
|
154
|
+
return async (input: RequestInfo | URL, init?: InstrumentedFetchInit): Promise<Response> => {
|
|
155
|
+
const rawUrl =
|
|
156
|
+
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
157
|
+
const safeUrl = redactUrl(rawUrl, { keepQueryKeys });
|
|
158
|
+
// Per the Fetch spec, when both a Request and an `init.method` are
|
|
159
|
+
// passed to `fetch()`, init.method replaces the Request's method.
|
|
160
|
+
// When init.method is omitted, the Request's method survives. So:
|
|
161
|
+
//
|
|
162
|
+
// - explicit `init.method` wins
|
|
163
|
+
// - else, if `input` is a Request, fall back to `input.method`
|
|
164
|
+
// - else, "GET" (matches `new Request(url).method` default)
|
|
165
|
+
//
|
|
166
|
+
// Without this, a caller like `fetch(new Request(url, { method: "POST" }))`
|
|
167
|
+
// would surface as a GET in `http.method` on the span AND in the URL
|
|
168
|
+
// router's `resolveOperation(url, method)` callback — mislabeling
|
|
169
|
+
// POST traffic as GET in dashboards and routing decisions.
|
|
170
|
+
const method =
|
|
171
|
+
init?.method ??
|
|
172
|
+
(typeof input !== "string" && !(input instanceof URL) ? input.method : undefined) ??
|
|
173
|
+
"GET";
|
|
174
|
+
const startTime = performance.now();
|
|
175
|
+
|
|
176
|
+
// Resolve the operation BEFORE we touch init, then strip `operation`
|
|
177
|
+
// off init so it never reaches `baseFetch` as an unknown RequestInit
|
|
178
|
+
// property (some runtimes warn / future runtimes might reject).
|
|
179
|
+
const explicitOp = init?.operation;
|
|
180
|
+
let initForFetch: RequestInit | undefined = init;
|
|
181
|
+
if (init && "operation" in init) {
|
|
182
|
+
const { operation: _drop, ...rest } = init;
|
|
183
|
+
initForFetch = rest;
|
|
184
|
+
}
|
|
185
|
+
const operation =
|
|
186
|
+
explicitOp ?? defaultOperation ?? resolveOperation?.(rawUrl, method) ?? "fetch";
|
|
187
|
+
const spanName = `${name}.${operation}`;
|
|
188
|
+
|
|
189
|
+
// Inject W3C traceparent onto outbound requests so upstream services
|
|
190
|
+
// that participate in OTel join our trace. No-op when no span is
|
|
191
|
+
// active; never throws (see `injectTraceContext`).
|
|
192
|
+
//
|
|
193
|
+
// Header semantics follow the Fetch spec: when both a Request and an
|
|
194
|
+
// `init` are passed to `fetch()`, `init.headers` REPLACES the
|
|
195
|
+
// Request's headers — they do NOT union. So:
|
|
196
|
+
//
|
|
197
|
+
// - If the caller supplied `init.headers`, start from those (the
|
|
198
|
+
// caller's explicit choice wins; we don't smuggle in Request
|
|
199
|
+
// headers behind their back).
|
|
200
|
+
// - Otherwise, if `input` is a Request, start from its headers (so
|
|
201
|
+
// its existing headers reach the wire alongside the injected
|
|
202
|
+
// traceparent).
|
|
203
|
+
// - Otherwise, start empty.
|
|
204
|
+
//
|
|
205
|
+
// In all cases, we mutate a fresh Headers object and pass it via the
|
|
206
|
+
// returned `init` — Request objects are immutable in modern runtimes
|
|
207
|
+
// and accepting `RequestInfo` means we may not own them.
|
|
208
|
+
let finalInit: RequestInit | undefined = initForFetch;
|
|
209
|
+
if (injectTraceparent) {
|
|
210
|
+
const base =
|
|
211
|
+
initForFetch?.headers !== undefined
|
|
212
|
+
? initForFetch.headers
|
|
213
|
+
: typeof input !== "string" && !(input instanceof URL)
|
|
214
|
+
? input.headers
|
|
215
|
+
: undefined;
|
|
216
|
+
const headers = new Headers(base ?? undefined);
|
|
217
|
+
injectTraceContext(headers);
|
|
218
|
+
finalInit = { ...(initForFetch ?? {}), headers };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const doFetch = async (): Promise<Response> => {
|
|
222
|
+
if (logging) {
|
|
223
|
+
console.log(`[${name}] ${method} ${truncateUrl(safeUrl)}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const response = await baseFetch(input, finalInit);
|
|
227
|
+
const durationMs = performance.now() - startTime;
|
|
228
|
+
const cached = response.headers.get("x-cache") === "HIT";
|
|
229
|
+
|
|
230
|
+
if (logging) {
|
|
231
|
+
const color = response.ok ? "\x1b[32m" : "\x1b[31m";
|
|
232
|
+
console.log(
|
|
233
|
+
`[${name}] ${color}${response.status}\x1b[0m ${method} ${truncateUrl(safeUrl)} ${durationMs.toFixed(0)}ms${cached ? " (cached)" : ""}`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Structured outgoing-fetch breadcrumb. Same field shape as the Fresh
|
|
238
|
+
// `@deco/deco/o11y` impl so log pipelines built off the old stack
|
|
239
|
+
// keep working unchanged. Off by default to avoid log explosion;
|
|
240
|
+
// enable with `OTEL_LOG_OUTGOING_FETCH=true`.
|
|
241
|
+
if (envFlag("OTEL_LOG_OUTGOING_FETCH")) {
|
|
242
|
+
let host = "";
|
|
243
|
+
let path = "";
|
|
244
|
+
try {
|
|
245
|
+
const u = new URL(rawUrl);
|
|
246
|
+
host = u.host;
|
|
247
|
+
path = u.pathname;
|
|
248
|
+
} catch {
|
|
249
|
+
/* unparseable URL — leave host/path blank */
|
|
250
|
+
}
|
|
251
|
+
logger.info("outgoing fetch", {
|
|
252
|
+
app: name,
|
|
253
|
+
host,
|
|
254
|
+
path,
|
|
255
|
+
method,
|
|
256
|
+
status: response.status,
|
|
257
|
+
ok: response.ok,
|
|
258
|
+
durationMs: Math.round(durationMs),
|
|
259
|
+
cached,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
onComplete?.({
|
|
264
|
+
name,
|
|
265
|
+
url: safeUrl,
|
|
266
|
+
method,
|
|
267
|
+
status: response.status,
|
|
268
|
+
durationMs,
|
|
269
|
+
cached,
|
|
270
|
+
operation,
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
return response;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
if (tracing) {
|
|
277
|
+
const tracer = getTracer();
|
|
278
|
+
if (tracer) {
|
|
279
|
+
const span = tracer.startSpan(spanName, {
|
|
280
|
+
"http.method": method,
|
|
281
|
+
// Redacted URL on the span attribute — once a CF Trace lands in
|
|
282
|
+
// the dashboard, we can't redact retroactively.
|
|
283
|
+
"http.url": safeUrl,
|
|
284
|
+
"fetch.integration": name,
|
|
285
|
+
// Stamp the resolved operation so it's queryable independent of
|
|
286
|
+
// span name (e.g. "GROUP BY SpanAttributes['fetch.operation']").
|
|
287
|
+
"fetch.operation": operation,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
const response = await doFetch();
|
|
292
|
+
// Promote CF / VTEX cache headers as span attributes — the plan
|
|
293
|
+
// calls out these four. `@microlabs/otel-cf-workers` does not
|
|
294
|
+
// expose the response inside its own fetch span lifecycle, so
|
|
295
|
+
// capturing them here on our wrapper span is the practical
|
|
296
|
+
// place to do it.
|
|
297
|
+
for (const { header, attr } of CACHE_HEADERS_TO_SPAN) {
|
|
298
|
+
const value = response.headers.get(header);
|
|
299
|
+
if (value) span.setAttribute?.(attr, value);
|
|
300
|
+
}
|
|
301
|
+
span.setAttribute?.("http.status_code", response.status);
|
|
302
|
+
if (response.status >= 400) {
|
|
303
|
+
span.setError?.(new Error(`HTTP ${response.status} ${response.statusText}`));
|
|
304
|
+
}
|
|
305
|
+
span.end();
|
|
306
|
+
return response;
|
|
307
|
+
} catch (error) {
|
|
308
|
+
span.setError?.(error);
|
|
309
|
+
span.end();
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return doFetch();
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function truncateUrl(url: string, maxLen = 120): string {
|
|
320
|
+
if (url.length <= maxLen) return url;
|
|
321
|
+
return url.slice(0, maxLen - 3) + "...";
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Wraps an existing fetch function with logging and tracing instrumentation.
|
|
326
|
+
* Unlike `createInstrumentedFetch`, this preserves the original fetch's
|
|
327
|
+
* behavior (custom headers, cookies, proxy logic) and adds observability on top.
|
|
328
|
+
*
|
|
329
|
+
* Accepts the same options as `createInstrumentedFetch` (sans `name` and
|
|
330
|
+
* `baseFetch`, which are positional), so callers can supply
|
|
331
|
+
* `defaultOperation` / `resolveOperation` here as well.
|
|
332
|
+
*/
|
|
333
|
+
export function instrumentFetch(
|
|
334
|
+
originalFetch: typeof fetch,
|
|
335
|
+
name: string,
|
|
336
|
+
options?: Omit<FetchInstrumentationOptions, "name" | "baseFetch">,
|
|
337
|
+
): InstrumentedFetch {
|
|
338
|
+
return createInstrumentedFetch({ ...(options ?? {}), name, baseFetch: originalFetch });
|
|
339
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createAppInvoke, invoke } from "./invoke";
|
|
3
|
+
|
|
4
|
+
describe("createAppInvoke", () => {
|
|
5
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
fetchMock = vi.fn();
|
|
9
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.unstubAllGlobals();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("collects nested property access into slash-separated key", async () => {
|
|
17
|
+
fetchMock.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
|
|
18
|
+
|
|
19
|
+
const proxy = createAppInvoke();
|
|
20
|
+
const result = await proxy.vtex.actions.checkout.addItemsToCart({ qty: 1 });
|
|
21
|
+
|
|
22
|
+
expect(result).toEqual({ ok: true });
|
|
23
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
24
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
25
|
+
expect(url).toBe("/deco/invoke/vtex/actions/checkout/addItemsToCart");
|
|
26
|
+
expect(init.method).toBe("POST");
|
|
27
|
+
expect(init.headers).toEqual({ "Content-Type": "application/json" });
|
|
28
|
+
expect(JSON.parse(init.body)).toEqual({ qty: 1 });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("falls back to .ts suffix when first key returns 404", async () => {
|
|
32
|
+
fetchMock
|
|
33
|
+
.mockResolvedValueOnce(new Response(null, { status: 404 }))
|
|
34
|
+
.mockResolvedValueOnce(new Response(JSON.stringify({ via: "ts" }), { status: 200 }));
|
|
35
|
+
|
|
36
|
+
const proxy = createAppInvoke();
|
|
37
|
+
const result = await proxy.site.loaders.Wishlist({});
|
|
38
|
+
|
|
39
|
+
expect(result).toEqual({ via: "ts" });
|
|
40
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
41
|
+
expect(fetchMock.mock.calls[0][0]).toBe("/deco/invoke/site/loaders/Wishlist");
|
|
42
|
+
expect(fetchMock.mock.calls[1][0]).toBe("/deco/invoke/site/loaders/Wishlist.ts");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("throws 'handler not found' when both key and .ts variant 404", async () => {
|
|
46
|
+
fetchMock.mockResolvedValue(new Response(null, { status: 404 }));
|
|
47
|
+
|
|
48
|
+
const proxy = createAppInvoke();
|
|
49
|
+
await expect(proxy.unknown({})).rejects.toThrow(/invoke\(unknown\) failed: handler not found/);
|
|
50
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("throws with status and error body on non-OK non-404 response", async () => {
|
|
54
|
+
fetchMock.mockResolvedValue(new Response(JSON.stringify({ error: "boom" }), { status: 500 }));
|
|
55
|
+
|
|
56
|
+
const proxy = createAppInvoke();
|
|
57
|
+
await expect(proxy.broken({})).rejects.toThrow(/invoke\(broken\) failed \(500\): boom/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("defaults to empty object body when called with no props", async () => {
|
|
61
|
+
fetchMock.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
|
62
|
+
|
|
63
|
+
const proxy = createAppInvoke();
|
|
64
|
+
await proxy.foo.bar();
|
|
65
|
+
|
|
66
|
+
const init = fetchMock.mock.calls[0][1];
|
|
67
|
+
expect(JSON.parse(init.body)).toEqual({});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("traps `then`, `catch`, `finally` to avoid being awaited as a thenable", () => {
|
|
71
|
+
const proxy = createAppInvoke();
|
|
72
|
+
expect(proxy.then).toBeUndefined();
|
|
73
|
+
expect(proxy.catch).toBeUndefined();
|
|
74
|
+
expect(proxy.finally).toBeUndefined();
|
|
75
|
+
// And on a deeper path:
|
|
76
|
+
expect(proxy.foo.bar.then).toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("returns undefined for symbol property access (no spurious sub-proxy)", () => {
|
|
80
|
+
const proxy = createAppInvoke();
|
|
81
|
+
expect(proxy[Symbol.toPrimitive]).toBeUndefined();
|
|
82
|
+
expect(proxy[Symbol.iterator]).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("respects custom basePath", async () => {
|
|
86
|
+
fetchMock.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
|
87
|
+
|
|
88
|
+
const proxy = createAppInvoke("/custom/invoke");
|
|
89
|
+
await proxy.foo.bar({});
|
|
90
|
+
|
|
91
|
+
expect(fetchMock.mock.calls[0][0]).toBe("/custom/invoke/foo/bar");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("default invoke singleton", () => {
|
|
96
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
97
|
+
|
|
98
|
+
beforeEach(() => {
|
|
99
|
+
fetchMock = vi.fn();
|
|
100
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
afterEach(() => {
|
|
104
|
+
vi.unstubAllGlobals();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("is a usable nested proxy bound to /deco/invoke", async () => {
|
|
108
|
+
fetchMock.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
|
|
109
|
+
|
|
110
|
+
await (invoke as any).site.loaders.example({ x: 1 });
|
|
111
|
+
|
|
112
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
113
|
+
expect(fetchMock.mock.calls[0][0]).toBe("/deco/invoke/site/loaders/example");
|
|
114
|
+
});
|
|
115
|
+
});
|