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