@decocms/start 2.3.0 → 2.4.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "2.3.0",
3
+ "version": "2.4.1",
4
4
  "type": "module",
5
5
  "description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
6
6
  "main": "./src/index.ts",
@@ -1,11 +1,11 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { ResolvedSection } from "./resolve";
2
3
  import {
3
4
  registerCacheableSections,
4
5
  registerLayoutSections,
5
6
  registerSectionLoader,
6
7
  runSingleSectionLoader,
7
8
  } from "./sectionLoaders";
8
- import type { ResolvedSection } from "./resolve";
9
9
 
10
10
  const G = globalThis as any;
11
11
 
@@ -15,10 +15,7 @@ beforeEach(() => {
15
15
  G.__deco.cacheableSections.clear();
16
16
  });
17
17
 
18
- const makeSection = (
19
- component: string,
20
- props: Record<string, unknown> = {},
21
- ): ResolvedSection => ({
18
+ const makeSection = (component: string, props: Record<string, unknown> = {}): ResolvedSection => ({
22
19
  component,
23
20
  props,
24
21
  key: component,
@@ -75,10 +72,7 @@ describe("runSingleSectionLoader — page context injection", () => {
75
72
 
76
73
  it("returns section unchanged when no loader is registered", async () => {
77
74
  const section = makeSection("site/sections/NoLoader.tsx", { foo: 1 });
78
- const result = await runSingleSectionLoader(
79
- section,
80
- new Request("https://store.com/"),
81
- );
75
+ const result = await runSingleSectionLoader(section, new Request("https://store.com/"));
82
76
  expect(result).toBe(section);
83
77
  });
84
78
  });
@@ -135,10 +129,145 @@ describe("runSingleSectionLoader — error handling", () => {
135
129
  registerSectionLoader("site/sections/Boom.tsx", loader);
136
130
 
137
131
  const section = makeSection("site/sections/Boom.tsx", { x: 1 });
138
- const result = await runSingleSectionLoader(
139
- section,
140
- new Request("https://store.com/"),
141
- );
132
+ const result = await runSingleSectionLoader(section, new Request("https://store.com/"));
142
133
  expect(result).toEqual(section);
143
134
  });
144
135
  });
136
+
137
+ describe("runSingleSectionLoader — nested section recursion", () => {
138
+ it("runs the loader of a nested section in props", async () => {
139
+ const childLoader = vi.fn(async (props: Record<string, unknown>) => ({
140
+ ...props,
141
+ enriched: true,
142
+ }));
143
+ registerSectionLoader("site/sections/CategoryBanner.tsx", childLoader);
144
+
145
+ // Parent has no own loader, only a nested section in props
146
+ const parent = makeSection("site/sections/BackgroundWrapper.tsx", {
147
+ child: {
148
+ Component: "site/sections/CategoryBanner.tsx",
149
+ props: { matcher: "/foo" },
150
+ },
151
+ });
152
+
153
+ const result = await runSingleSectionLoader(parent, new Request("https://store.com/foo"));
154
+
155
+ expect(childLoader).toHaveBeenCalledTimes(1);
156
+ expect(result.props).toEqual({
157
+ child: {
158
+ Component: "site/sections/CategoryBanner.tsx",
159
+ props: {
160
+ matcher: "/foo",
161
+ enriched: true,
162
+ // page context is injected for nested sections too
163
+ __pageUrl: "https://store.com/foo",
164
+ __pagePath: "/foo",
165
+ },
166
+ },
167
+ });
168
+ });
169
+
170
+ it("runs nested loaders in arrays (e.g. sections: Section[])", async () => {
171
+ const banner = vi.fn(async (props: any) => ({ ...props, ranBanner: true }));
172
+ const shelf = vi.fn(async (props: any) => ({ ...props, ranShelf: true }));
173
+ registerSectionLoader("site/sections/Banner.tsx", banner);
174
+ registerSectionLoader("site/sections/Shelf.tsx", shelf);
175
+
176
+ const parent = makeSection("site/sections/Wrapper.tsx", {
177
+ sections: [
178
+ { Component: "site/sections/Banner.tsx", props: { id: 1 } },
179
+ { Component: "site/sections/Shelf.tsx", props: { id: 2 } },
180
+ ],
181
+ });
182
+
183
+ const result = await runSingleSectionLoader(parent, new Request("https://store.com/"));
184
+
185
+ expect(banner).toHaveBeenCalledTimes(1);
186
+ expect(shelf).toHaveBeenCalledTimes(1);
187
+ const sections = (result.props as any).sections;
188
+ expect(sections[0].props).toMatchObject({ id: 1, ranBanner: true });
189
+ expect(sections[1].props).toMatchObject({ id: 2, ranShelf: true });
190
+ });
191
+
192
+ it("returns same props reference when no nested sections (zero-alloc leaf path)", async () => {
193
+ const loader = vi.fn(async (props: Record<string, unknown>) => props);
194
+ registerSectionLoader("site/sections/Leaf.tsx", loader);
195
+
196
+ const props = { foo: "bar" };
197
+ const section = makeSection("site/sections/Leaf.tsx", props);
198
+
199
+ const result = await runSingleSectionLoader(section, new Request("https://store.com/"));
200
+
201
+ // Loader returned the SAME props ref → enrichNestedSections must also
202
+ // return the same ref → no { ...result, props } wrapping happens
203
+ expect(result.props).toMatchObject({
204
+ foo: "bar",
205
+ __pageUrl: "https://store.com/",
206
+ __pagePath: "/",
207
+ });
208
+ });
209
+
210
+ it("recurses into deeply nested sections (wrapper inside wrapper)", async () => {
211
+ const inner = vi.fn(async (props: any) => ({ ...props, deep: true }));
212
+ registerSectionLoader("site/sections/Inner.tsx", inner);
213
+
214
+ const parent = makeSection("site/sections/Outer.tsx", {
215
+ child: {
216
+ Component: "site/sections/MidWrapper.tsx",
217
+ props: {
218
+ grandchild: {
219
+ Component: "site/sections/Inner.tsx",
220
+ props: { tag: "deep" },
221
+ },
222
+ },
223
+ },
224
+ });
225
+
226
+ const result = await runSingleSectionLoader(parent, new Request("https://store.com/"));
227
+
228
+ expect(inner).toHaveBeenCalledTimes(1);
229
+ const grandchild = (result.props as any).child.props.grandchild;
230
+ expect(grandchild.props).toMatchObject({ tag: "deep", deep: true });
231
+ });
232
+
233
+ it("ignores nested objects that do not look like sections", async () => {
234
+ const loader = vi.fn(async (props: Record<string, unknown>) => props);
235
+ registerSectionLoader("site/sections/Leaf.tsx", loader);
236
+
237
+ const section = makeSection("site/sections/Leaf.tsx", {
238
+ // Plain config object, not a section. Has `Component: string` but
239
+ // missing the `props` field — must NOT be treated as a nested section.
240
+ config: { Component: "ButtonStyle", color: "red" },
241
+ });
242
+
243
+ const result = await runSingleSectionLoader(section, new Request("https://store.com/"));
244
+
245
+ expect(loader).toHaveBeenCalledTimes(1);
246
+ expect((result.props as any).config).toEqual({
247
+ Component: "ButtonStyle",
248
+ color: "red",
249
+ });
250
+ });
251
+
252
+ it("runs nested loaders even when parent has no own loader", async () => {
253
+ const childLoader = vi.fn(async (props: any) => ({ ...props, ran: true }));
254
+ registerSectionLoader("site/sections/Child.tsx", childLoader);
255
+
256
+ // Parent has no entry in registry — but it has a nested section in props
257
+ // (typical of a pure layout container that just renders children).
258
+ const parent = makeSection("site/sections/UnregisteredLayout.tsx", {
259
+ child: {
260
+ Component: "site/sections/Child.tsx",
261
+ props: { foo: "bar" },
262
+ },
263
+ });
264
+
265
+ const result = await runSingleSectionLoader(parent, new Request("https://store.com/"));
266
+
267
+ expect(childLoader).toHaveBeenCalledTimes(1);
268
+ expect((result.props as any).child.props).toMatchObject({
269
+ foo: "bar",
270
+ ran: true,
271
+ });
272
+ });
273
+ });
@@ -34,7 +34,9 @@ interface CacheableSectionConfig {
34
34
  maxAge: number;
35
35
  }
36
36
 
37
- export type CacheableSectionInput = CacheableSectionConfig | import("../sdk/cacheHeaders").CacheProfileName;
37
+ export type CacheableSectionInput =
38
+ | CacheableSectionConfig
39
+ | import("../sdk/cacheHeaders").CacheProfileName;
38
40
 
39
41
  function resolveSectionCacheConfig(input: CacheableSectionInput): CacheableSectionConfig {
40
42
  if (typeof input === "string") {
@@ -255,10 +257,7 @@ export async function runSectionLoaders(
255
257
  if (typeof process !== "undefined" && process.env?.NODE_ENV !== "production") {
256
258
  for (const s of sections) {
257
259
  const key = s.component.toLowerCase();
258
- if (
259
- (key.includes("header") || key.includes("footer")) &&
260
- !layoutSections.has(s.component)
261
- ) {
260
+ if ((key.includes("header") || key.includes("footer")) && !layoutSections.has(s.component)) {
262
261
  console.warn(
263
262
  `[SectionLoaders] "${s.component}" looks like a layout section but is not in registerLayoutSections(). ` +
264
263
  `Add it to registerLayoutSections() in setup.ts for consistent caching across navigations.`,
@@ -318,47 +317,180 @@ function withPageContext(loader: SectionLoaderFn): SectionLoaderFn {
318
317
  * 1. Layout sections (Header/Footer) — 5min TTL + in-flight dedup
319
318
  * 2. Cacheable sections (ProductShelf, FAQ) — SWR with configurable maxAge
320
319
  * 3. Regular sections — no cache, always fresh
320
+ *
321
+ * After the section's own loader runs, recursively runs loaders for any
322
+ * nested sections found in its resolved props (e.g. wrapper sections with
323
+ * a `sections: Section[]` prop). This eliminates the need for sites to
324
+ * manually walk + invoke `runSingleSectionLoader` on children.
321
325
  */
322
326
  export async function runSingleSectionLoader(
323
327
  section: ResolvedSection,
324
328
  request: Request,
325
329
  ): Promise<ResolvedSection> {
326
330
  const loader = loaderRegistry.get(section.component);
327
- if (!loader) return section;
328
-
329
- // Wrap the loader so __pageUrl/__pagePath are injected at the call site.
330
- // Cache keys (component name for layout, component+propsHash for cacheable)
331
- // are computed from the *original* section.props keeping cache entries
332
- // URL-agnostic and shared across pages.
333
- const wrapped = withPageContext(loader);
334
-
335
- if (layoutSections.has(section.component)) {
336
- try {
337
- return await resolveLayoutSection(section, wrapped, request);
338
- } catch (error) {
339
- console.error(`[SectionLoader] Error in layout "${section.component}":`, error);
340
- return section;
331
+
332
+ let result: ResolvedSection;
333
+
334
+ if (!loader) {
335
+ // No own-loader, but the section may still contain nested sections in
336
+ // its props (CMS-resolved children) that need their loaders run.
337
+ result = section;
338
+ } else {
339
+ // Wrap the loader so __pageUrl/__pagePath are injected at the call site.
340
+ // Cache keys (component name for layout, component+propsHash for cacheable)
341
+ // are computed from the *original* section.props keeping cache entries
342
+ // URL-agnostic and shared across pages.
343
+ const wrapped = withPageContext(loader);
344
+
345
+ if (layoutSections.has(section.component)) {
346
+ try {
347
+ result = await resolveLayoutSection(section, wrapped, request);
348
+ } catch (error) {
349
+ console.error(`[SectionLoader] Error in layout "${section.component}":`, error);
350
+ result = section;
351
+ }
352
+ } else {
353
+ const cacheConfig = cacheableSections.get(section.component);
354
+ if (cacheConfig) {
355
+ try {
356
+ result = await runCacheableSectionLoader(section, wrapped, request, cacheConfig);
357
+ } catch (error) {
358
+ console.error(`[SectionLoader] Error in cacheable "${section.component}":`, error);
359
+ result = section;
360
+ }
361
+ } else {
362
+ try {
363
+ const enrichedProps = await wrapped(section.props as Record<string, unknown>, request);
364
+ result = { ...section, props: enrichedProps };
365
+ } catch (error) {
366
+ console.error(`[SectionLoader] Error in "${section.component}":`, error);
367
+ result = section;
368
+ }
369
+ }
341
370
  }
342
371
  }
343
372
 
344
- const cacheConfig = cacheableSections.get(section.component);
345
- if (cacheConfig) {
346
- try {
347
- return await runCacheableSectionLoader(section, wrapped, request, cacheConfig);
348
- } catch (error) {
349
- console.error(`[SectionLoader] Error in cacheable "${section.component}":`, error);
350
- return section;
373
+ // Recurse into nested sections AFTER the parent's loader/cache lookup so
374
+ // child sections keep their own cache TTL independent from the parent's.
375
+ // For layout/cacheable parents, this means a 5-min layout cache hit still
376
+ // re-evaluates child sections (whose own caches are usually shorter, e.g.
377
+ // ProductShelf 60s). For leaf sections, `enrichNestedSections` returns
378
+ // the same reference (no allocation, no extra work).
379
+ const props = result.props as Record<string, unknown> | undefined;
380
+ if (props && typeof props === "object") {
381
+ const enrichedProps = await enrichNestedSections(props, request);
382
+ if (enrichedProps !== props) {
383
+ return { ...result, props: enrichedProps };
351
384
  }
352
385
  }
386
+ return result;
387
+ }
353
388
 
354
- try {
355
- const enrichedProps = await wrapped(
356
- section.props as Record<string, unknown>,
357
- request,
358
- );
359
- return { ...section, props: enrichedProps };
360
- } catch (error) {
361
- console.error(`[SectionLoader] Error in "${section.component}":`, error);
362
- return section;
389
+ // ---------------------------------------------------------------------------
390
+ // Nested section loader support
391
+ // ---------------------------------------------------------------------------
392
+
393
+ /**
394
+ * Type guard: matches the shape produced by `normalizeNestedSections` in
395
+ * resolve.ts — `{ Component: string, props: object }`. This is how the CMS
396
+ * resolver represents nested sections (children of wrapper sections).
397
+ *
398
+ * Note: the `Component` key uses capital C to match the runtime renderer's
399
+ * convention (mirrors deco-cx/deco's Fresh API). Not to be confused with
400
+ * the lowercase `component` on `ResolvedSection`.
401
+ */
402
+ function isNestedSection(
403
+ value: unknown,
404
+ ): value is { Component: string; props: Record<string, unknown> } {
405
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
406
+ const obj = value as Record<string, unknown>;
407
+ return (
408
+ typeof obj.Component === "string" &&
409
+ obj.props != null &&
410
+ typeof obj.props === "object" &&
411
+ !Array.isArray(obj.props)
412
+ );
413
+ }
414
+
415
+ /**
416
+ * Walk a props object and run section loaders for any nested sections.
417
+ * Handles direct child sections AND arrays of sections (e.g.
418
+ * `sections: Section[]`, `slides: Slide[]`).
419
+ *
420
+ * Returns the same reference if nothing changed — so leaf sections (the
421
+ * vast majority) incur zero allocation overhead.
422
+ *
423
+ * Concurrency: all nested loader calls run in parallel via Promise.all.
424
+ */
425
+ async function enrichNestedSections(
426
+ props: Record<string, unknown>,
427
+ request: Request,
428
+ ): Promise<Record<string, unknown>> {
429
+ type Pending = {
430
+ key: string;
431
+ index?: number;
432
+ promise: Promise<ResolvedSection>;
433
+ };
434
+ const pending: Pending[] = [];
435
+
436
+ for (const [key, value] of Object.entries(props)) {
437
+ if (isNestedSection(value)) {
438
+ pending.push({
439
+ key,
440
+ promise: runSingleSectionLoader(
441
+ {
442
+ component: value.Component,
443
+ props: value.props,
444
+ key: value.Component,
445
+ } as ResolvedSection,
446
+ request,
447
+ ),
448
+ });
449
+ continue;
450
+ }
451
+
452
+ if (Array.isArray(value)) {
453
+ for (let i = 0; i < value.length; i++) {
454
+ const item = value[i];
455
+ if (isNestedSection(item)) {
456
+ pending.push({
457
+ key,
458
+ index: i,
459
+ promise: runSingleSectionLoader(
460
+ {
461
+ component: item.Component,
462
+ props: item.props,
463
+ key: item.Component,
464
+ } as ResolvedSection,
465
+ request,
466
+ ),
467
+ });
468
+ }
469
+ }
470
+ }
363
471
  }
472
+
473
+ if (pending.length === 0) return props;
474
+
475
+ const results = await Promise.all(pending.map((p) => p.promise));
476
+ const updated: Record<string, unknown> = { ...props };
477
+
478
+ for (let i = 0; i < pending.length; i++) {
479
+ const { key, index } = pending[i];
480
+ const enriched = results[i];
481
+ const nestedValue = { Component: enriched.component, props: enriched.props };
482
+
483
+ if (index != null) {
484
+ // Array item — clone the array on first mutation for this key
485
+ const current = updated[key];
486
+ if (current === props[key]) {
487
+ updated[key] = [...(current as unknown[])];
488
+ }
489
+ (updated[key] as unknown[])[index] = nestedValue;
490
+ } else {
491
+ updated[key] = nestedValue;
492
+ }
493
+ }
494
+
495
+ return updated;
364
496
  }
package/src/sdk/index.ts CHANGED
@@ -11,12 +11,12 @@ export {
11
11
  type CacheProfileName,
12
12
  type CacheProfileOverrides,
13
13
  type CacheTimingWindow,
14
- type EdgeCacheConfig,
15
- type LoaderCacheOptions,
16
14
  cacheHeaders,
17
15
  detectCacheProfile,
16
+ type EdgeCacheConfig,
18
17
  edgeCacheConfig,
19
18
  getCacheProfile,
19
+ type LoaderCacheOptions,
20
20
  loaderCacheOptions,
21
21
  registerCachePattern,
22
22
  routeCacheDefaults,
@@ -24,15 +24,26 @@ export {
24
24
  } from "./cacheHeaders";
25
25
  export { clx } from "./clx";
26
26
  export { decodeCookie, deleteCookie, getCookie, getServerSideCookie, setCookie } from "./cookie";
27
+ export { forwardResponseCookies, getRequestCookieHeader } from "./cookiePassthrough";
27
28
  export { buildCSPHeaderValue, type CSPOptions, setCSPHeaders } from "./csp";
29
+ export { djb2, djb2Hex } from "./djb2";
28
30
  export { isDevMode } from "./env";
31
+ export { buildHtmlShell, type HtmlShellOptions } from "./htmlShell";
29
32
  export {
30
33
  createInstrumentedFetch,
31
34
  type FetchInstrumentationOptions,
32
35
  type FetchMetrics,
33
36
  instrumentFetch,
34
37
  } from "./instrumentedFetch";
35
- export { batchInvoke, createInvokeProxy, type InvokeProxy, invokeQueryOptions } from "./invoke";
38
+ export {
39
+ batchInvoke,
40
+ createAppInvoke,
41
+ createInvokeProxy,
42
+ type InvokeProxy,
43
+ invoke,
44
+ invokeQueryOptions,
45
+ type NestedFromFlat,
46
+ } from "./invoke";
36
47
  export { createCacheControlCollector, mergeCacheControl } from "./mergeCacheControl";
37
48
  export {
38
49
  getProductionOrigins,
@@ -59,8 +70,6 @@ export {
59
70
  registerTrackingParams,
60
71
  stripTrackingParams,
61
72
  } from "./urlUtils";
62
- export { djb2, djb2Hex } from "./djb2";
63
- export { buildHtmlShell, type HtmlShellOptions } from "./htmlShell";
64
73
  export {
65
74
  checkDesktop,
66
75
  checkMobile,
@@ -74,9 +83,14 @@ export {
74
83
  } from "./useDevice";
75
84
  export { useHydrated } from "./useHydrated";
76
85
  export { useId } from "./useId";
77
- export { inlineScript, usePartialSection, useScript, useScriptAsDataURI, useSection } from "./useScript";
86
+ export {
87
+ inlineScript,
88
+ usePartialSection,
89
+ useScript,
90
+ useScriptAsDataURI,
91
+ useSection,
92
+ } from "./useScript";
78
93
  export { createDecoWorkerEntry, type DecoWorkerEntryOptions } from "./workerEntry";
79
- export { forwardResponseCookies, getRequestCookieHeader } from "./cookiePassthrough";
80
94
  export {
81
95
  isWrappedError,
82
96
  unwrapError,
@@ -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
+ });
package/src/sdk/invoke.ts CHANGED
@@ -154,29 +154,21 @@ type SplitFirst<S extends string> = S extends `${infer Head}/${infer Tail}`
154
154
  ? [Head, Tail]
155
155
  : [S, never];
156
156
 
157
- type BuildNested<Key extends string, Value> = SplitFirst<Key> extends [
158
- infer H extends string,
159
- infer T,
160
- ]
161
- ? T extends string
162
- ? { [K in H]: BuildNested<T, Value> }
163
- : { [K in H]: Value }
164
- : never;
157
+ type BuildNested<Key extends string, Value> =
158
+ SplitFirst<Key> extends [infer H extends string, infer T]
159
+ ? T extends string
160
+ ? { [K in H]: BuildNested<T, Value> }
161
+ : { [K in H]: Value }
162
+ : never;
165
163
 
166
- type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
167
- k: infer I,
168
- ) => void
164
+ type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void
169
165
  ? I
170
166
  : never;
171
167
 
172
- type DeepMerge<T> = T extends object
173
- ? { [K in keyof T]: DeepMerge<T[K]> }
174
- : T;
168
+ type DeepMerge<T> = T extends object ? { [K in keyof T]: DeepMerge<T[K]> } : T;
175
169
 
176
170
  export type NestedFromFlat<T extends Record<string, any>> = DeepMerge<
177
- UnionToIntersection<
178
- { [K in keyof T & string]: BuildNested<K, T[K]> }[keyof T & string]
179
- >
171
+ UnionToIntersection<{ [K in keyof T & string]: BuildNested<K, T[K]> }[keyof T & string]>
180
172
  >;
181
173
 
182
174
  /**
@@ -203,10 +195,10 @@ export type NestedFromFlat<T extends Record<string, any>> = DeepMerge<
203
195
  * ```
204
196
  */
205
197
  export function createAppInvoke(basePath?: string): any;
206
- export function createAppInvoke<T extends Record<string, any>>(basePath?: string): NestedFromFlat<T>;
207
- export function createAppInvoke(
208
- basePath = "/deco/invoke",
209
- ): any {
198
+ export function createAppInvoke<T extends Record<string, any>>(
199
+ basePath?: string,
200
+ ): NestedFromFlat<T>;
201
+ export function createAppInvoke(basePath = "/deco/invoke"): any {
210
202
  function buildProxy(path: string[]): any {
211
203
  return new Proxy(
212
204
  Object.assign(async (props: any) => {
@@ -217,7 +209,10 @@ export function createAppInvoke(
217
209
  headers: { "Content-Type": "application/json" },
218
210
  body: JSON.stringify(props ?? {}),
219
211
  });
220
- if (response.status === 404) { await response.body?.cancel(); continue; }
212
+ if (response.status === 404) {
213
+ await response.body?.cancel();
214
+ continue;
215
+ }
221
216
  if (!response.ok) {
222
217
  const error = await response.json().catch(() => ({ error: response.statusText }));
223
218
  throw new Error(
@@ -242,3 +237,24 @@ export function createAppInvoke(
242
237
 
243
238
  return buildProxy([]);
244
239
  }
240
+
241
+ /**
242
+ * Default nested invoke proxy bound to `/deco/invoke`.
243
+ *
244
+ * Replaces site-level `~/runtime.ts` shims that wrap the same `createAppInvoke`
245
+ * call. Importing this singleton means no per-site boilerplate.
246
+ *
247
+ * @example
248
+ * ```ts
249
+ * import { invoke } from "@decocms/start/sdk/invoke";
250
+ *
251
+ * await invoke.vtex.actions.checkout.addItemsToCart({ orderFormId, orderItems });
252
+ * await invoke.site.loaders.Wishlist.getWishlist({});
253
+ * ```
254
+ *
255
+ * For a custom `basePath` or typed handlers, call `createAppInvoke()` directly:
256
+ * ```ts
257
+ * const invoke = createAppInvoke<Handlers>("/my/invoke");
258
+ * ```
259
+ */
260
+ export const invoke = createAppInvoke();