@decocms/start 2.3.0 → 2.4.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 +1 -1
- package/src/sdk/index.ts +21 -7
- package/src/sdk/invoke.test.ts +115 -0
- package/src/sdk/invoke.ts +38 -22
package/package.json
CHANGED
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 {
|
|
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 {
|
|
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> =
|
|
158
|
-
infer H extends string,
|
|
159
|
-
|
|
160
|
-
]
|
|
161
|
-
|
|
162
|
-
|
|
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>>(
|
|
207
|
-
|
|
208
|
-
|
|
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) {
|
|
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();
|