@decocms/blocks 7.22.2 → 7.23.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/cms/index.ts +2 -0
- package/src/cms/sectionLoaderContext.test.ts +111 -0
- package/src/cms/sectionLoaderContext.ts +113 -0
- package/src/cms/sectionLoaders.test.ts +55 -7
- package/src/cms/sectionLoaders.ts +14 -2
- package/src/cms/sectionMixins.ts +6 -4
- package/src/sdk/invoke.ts +60 -22
- package/src/types/index.ts +14 -0
package/package.json
CHANGED
package/src/cms/index.ts
CHANGED
|
@@ -81,6 +81,8 @@ export {
|
|
|
81
81
|
WELL_KNOWN_TYPES,
|
|
82
82
|
} from "./resolve";
|
|
83
83
|
export type { SectionLoaderFn } from "./sectionLoaders";
|
|
84
|
+
export type { SectionLoaderContext } from "./sectionLoaderContext";
|
|
85
|
+
export { buildSectionLoaderContext } from "./sectionLoaderContext";
|
|
84
86
|
export {
|
|
85
87
|
isLayoutSection,
|
|
86
88
|
registerCacheableSections,
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { RequestContext } from "../sdk/requestContext";
|
|
3
|
+
import { buildSectionLoaderContext } from "./sectionLoaderContext";
|
|
4
|
+
|
|
5
|
+
const MOBILE_UA =
|
|
6
|
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148";
|
|
7
|
+
const DESKTOP_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36";
|
|
8
|
+
const TABLET_UA = "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15";
|
|
9
|
+
|
|
10
|
+
function reqWithUA(ua: string, url = "https://store.example/path?q=shoes"): Request {
|
|
11
|
+
return new Request(url, { headers: { "user-agent": ua } });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("buildSectionLoaderContext", () => {
|
|
15
|
+
describe("device", () => {
|
|
16
|
+
it("detects mobile / desktop / tablet from the request User-Agent", () => {
|
|
17
|
+
expect(buildSectionLoaderContext(reqWithUA(MOBILE_UA)).device).toBe("mobile");
|
|
18
|
+
expect(buildSectionLoaderContext(reqWithUA(DESKTOP_UA)).device).toBe("desktop");
|
|
19
|
+
expect(buildSectionLoaderContext(reqWithUA(TABLET_UA)).device).toBe("tablet");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("defaults to desktop when there is no User-Agent", () => {
|
|
23
|
+
expect(buildSectionLoaderContext(new Request("https://store.example/")).device).toBe(
|
|
24
|
+
"desktop",
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("never throws for a minimal/mock request without headers", () => {
|
|
29
|
+
const badReq = { url: "not a url" } as unknown as Request;
|
|
30
|
+
expect(() => buildSectionLoaderContext(badReq)).not.toThrow();
|
|
31
|
+
expect(buildSectionLoaderContext(badReq).device).toBe("desktop");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("app state", () => {
|
|
36
|
+
it("resolves ctx.<appName> to the app's registered state inside a request scope", async () => {
|
|
37
|
+
const req = reqWithUA(DESKTOP_UA);
|
|
38
|
+
await RequestContext.run(req, async () => {
|
|
39
|
+
RequestContext.setBag("app:vtex:state", { config: { account: "acme" } });
|
|
40
|
+
const ctx = buildSectionLoaderContext(req);
|
|
41
|
+
expect((ctx as any).vtex).toEqual({ config: { account: "acme" } });
|
|
42
|
+
expect(ctx.getAppState("vtex")).toEqual({ config: { account: "acme" } });
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns undefined for an unconfigured app (no fake object)", async () => {
|
|
47
|
+
const req = reqWithUA(DESKTOP_UA);
|
|
48
|
+
await RequestContext.run(req, async () => {
|
|
49
|
+
const ctx = buildSectionLoaderContext(req);
|
|
50
|
+
expect((ctx as any).salesforce).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns undefined for app state outside a request scope", () => {
|
|
55
|
+
const ctx = buildSectionLoaderContext(reqWithUA(DESKTOP_UA));
|
|
56
|
+
expect((ctx as any).vtex).toBeUndefined();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("response.headers", () => {
|
|
61
|
+
it("writes through to RequestContext.responseHeaders inside a scope", async () => {
|
|
62
|
+
const req = reqWithUA(DESKTOP_UA);
|
|
63
|
+
await RequestContext.run(req, async () => {
|
|
64
|
+
const ctx = buildSectionLoaderContext(req);
|
|
65
|
+
ctx.response.headers.set("set-cookie", "a=1");
|
|
66
|
+
expect(RequestContext.responseHeaders.get("set-cookie")).toBe("a=1");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("degrades to an inert Headers outside a scope (does not throw)", () => {
|
|
71
|
+
const ctx = buildSectionLoaderContext(reqWithUA(DESKTOP_UA));
|
|
72
|
+
expect(() => ctx.response.headers.set("x", "y")).not.toThrow();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("invoke (server-side self-fetch)", () => {
|
|
77
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
78
|
+
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
fetchMock = vi
|
|
81
|
+
.fn()
|
|
82
|
+
.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
|
|
83
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
vi.unstubAllGlobals();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("POSTs to the request's absolute origin /deco/invoke/<key>", async () => {
|
|
91
|
+
const ctx = buildSectionLoaderContext(reqWithUA(DESKTOP_UA, "https://store.example/pdp"));
|
|
92
|
+
const result = await ctx.invoke.vtex.loaders.product.detailsPageGQL({ slug: "x" });
|
|
93
|
+
|
|
94
|
+
expect(result).toEqual({ ok: true });
|
|
95
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
96
|
+
expect(url).toBe("https://store.example/deco/invoke/vtex/loaders/product/detailsPageGQL");
|
|
97
|
+
expect(init.method).toBe("POST");
|
|
98
|
+
expect(JSON.parse(init.body)).toEqual({ slug: "x" });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("injects the request AbortSignal when invoked inside a request scope", async () => {
|
|
102
|
+
const req = reqWithUA(DESKTOP_UA, "https://store.example/pdp");
|
|
103
|
+
await RequestContext.run(req, async () => {
|
|
104
|
+
const ctx = buildSectionLoaderContext(req);
|
|
105
|
+
await ctx.invoke.vtex.loaders.x({});
|
|
106
|
+
const [, init] = fetchMock.mock.calls[0];
|
|
107
|
+
expect(init.signal).toBeInstanceOf(AbortSignal);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section-loader compatibility context (`ctx`) — issue #305.
|
|
3
|
+
*
|
|
4
|
+
* deco.cx (Fresh) section loaders use a 3-arg signature
|
|
5
|
+
* `(props, req, ctx: AppContext)`, where `ctx` exposes device detection,
|
|
6
|
+
* `ctx.invoke.*`, per-app state (`ctx.vtex`, `ctx.salesforce`, …) and
|
|
7
|
+
* `ctx.response.headers`. `@decocms/blocks` invokes loaders with only
|
|
8
|
+
* `(props, req)`, so migrated loaders read `undefined` and throw.
|
|
9
|
+
*
|
|
10
|
+
* Rather than delete `ctx` (which would force rewriting every migrated
|
|
11
|
+
* loader by hand), we re-assemble a **real** compat `ctx` from the primitives
|
|
12
|
+
* the framework already has — no fabricated stubs (policy D3):
|
|
13
|
+
*
|
|
14
|
+
* - `device` → {@link detectDevice} on the request User-Agent. Derived from
|
|
15
|
+
* `req` (NOT `RequestContext`) on purpose: the `createServerFn` path used in
|
|
16
|
+
* dev / SPA navigation is not wrapped in `RequestContext.run`, so a
|
|
17
|
+
* RequestContext-only device would silently degrade to "desktop" there.
|
|
18
|
+
* - `invoke` → the same nested invoke proxy the client uses, but pointed at
|
|
19
|
+
* an absolute self-origin and routed through `RequestContext.fetch` so the
|
|
20
|
+
* request's AbortSignal propagates. `ctx.invoke.vtex.loaders.x(props)` works
|
|
21
|
+
* server-side via a self-fetch to `/deco/invoke`.
|
|
22
|
+
* - app state → `RequestContext.getAppState(name)`; any unknown property access
|
|
23
|
+
* (`ctx.vtex`, `ctx.salesforce`, …) resolves to the app's registered state
|
|
24
|
+
* or `undefined`. Real lookup, not a fake object.
|
|
25
|
+
* - `response.headers` → `RequestContext.responseHeaders` when in a request
|
|
26
|
+
* scope; an inert `Headers` otherwise (writes don't propagate in dev/SPA but
|
|
27
|
+
* never crash).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { type Device, detectDevice } from "../sdk/detectDevice";
|
|
31
|
+
import { createAppInvokeWith } from "../sdk/invoke";
|
|
32
|
+
import { RequestContext } from "../sdk/requestContext";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The compat context handed to section loaders as the 3rd argument.
|
|
36
|
+
*
|
|
37
|
+
* It is indexable (`[appName: string]: unknown`) because Fresh loaders read
|
|
38
|
+
* per-app state off `ctx` directly (`ctx.vtex`, `ctx.salesforce`, …). Those
|
|
39
|
+
* reads resolve through {@link RequestContext.getAppState}. Migrated code
|
|
40
|
+
* should still optional-chain deep app-state reads (`ctx.vtex?.config`), since
|
|
41
|
+
* an app that isn't configured yields `undefined`.
|
|
42
|
+
*/
|
|
43
|
+
export interface SectionLoaderContext {
|
|
44
|
+
/** Device type detected from the request User-Agent. */
|
|
45
|
+
device: Device;
|
|
46
|
+
/**
|
|
47
|
+
* Nested invoke proxy (`ctx.invoke.vtex.loaders.x(props)`), bound to this
|
|
48
|
+
* request's origin and AbortSignal. Works server-side via self-fetch.
|
|
49
|
+
*/
|
|
50
|
+
invoke: any;
|
|
51
|
+
/** Outgoing response headers (e.g. for Set-Cookie forwarding). */
|
|
52
|
+
response: { headers: Headers };
|
|
53
|
+
/** Typed access to an app's request-scoped state. */
|
|
54
|
+
getAppState: <T>(appName: string) => T | undefined;
|
|
55
|
+
/** Per-app state read directly off ctx (Fresh compatibility). */
|
|
56
|
+
[appName: string]: unknown;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build the compat {@link SectionLoaderContext} for a single loader call.
|
|
61
|
+
* Cheap to construct (device detect + lazy proxies), so it's fine to build
|
|
62
|
+
* per section loader invocation.
|
|
63
|
+
*/
|
|
64
|
+
export function buildSectionLoaderContext(req: Request): SectionLoaderContext {
|
|
65
|
+
// Defensive: `req` may be a minimal/mock request without a real `headers`
|
|
66
|
+
// object. Building the ctx must never throw — that would take down the
|
|
67
|
+
// loader before it runs.
|
|
68
|
+
const device = detectDevice(req.headers?.get?.("user-agent") ?? "");
|
|
69
|
+
|
|
70
|
+
let origin = "";
|
|
71
|
+
try {
|
|
72
|
+
origin = new URL(req.url).origin;
|
|
73
|
+
} catch {
|
|
74
|
+
// Relative/opaque request URL — fall back to a relative base path.
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const invoke = createAppInvokeWith({
|
|
78
|
+
basePath: `${origin}/deco/invoke`,
|
|
79
|
+
fetcher: (input, init) => RequestContext.fetch(input, init),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const base = {
|
|
83
|
+
device,
|
|
84
|
+
invoke,
|
|
85
|
+
get response(): { headers: Headers } {
|
|
86
|
+
// `responseHeaders` throws outside a request scope (dev/SPA serverFn
|
|
87
|
+
// path). Degrade to an inert Headers rather than crash the loader.
|
|
88
|
+
let headers: Headers;
|
|
89
|
+
try {
|
|
90
|
+
headers = RequestContext.responseHeaders;
|
|
91
|
+
} catch {
|
|
92
|
+
headers = new Headers();
|
|
93
|
+
}
|
|
94
|
+
return { headers };
|
|
95
|
+
},
|
|
96
|
+
getAppState<T>(appName: string): T | undefined {
|
|
97
|
+
return RequestContext.getAppState<T>(appName);
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const known = new Set(["device", "invoke", "response", "getAppState"]);
|
|
102
|
+
|
|
103
|
+
// Wrap so unknown property access (`ctx.vtex`, `ctx.salesforce`, …) resolves
|
|
104
|
+
// to the app's registered state. Known fields fall through to `base`.
|
|
105
|
+
return new Proxy(base as SectionLoaderContext, {
|
|
106
|
+
get(target, prop, receiver) {
|
|
107
|
+
if (typeof prop === "string" && !known.has(prop) && !(prop in target)) {
|
|
108
|
+
return RequestContext.getAppState(prop);
|
|
109
|
+
}
|
|
110
|
+
return Reflect.get(target, prop, receiver);
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
}
|
|
@@ -82,6 +82,59 @@ describe("runSingleSectionLoader — page context injection", () => {
|
|
|
82
82
|
const result = await runSingleSectionLoader(section, new Request("https://store.com/"));
|
|
83
83
|
expect(result).toBe(section);
|
|
84
84
|
});
|
|
85
|
+
|
|
86
|
+
it("passes a 3rd-arg compat ctx (device from UA) — regular path (#305)", async () => {
|
|
87
|
+
const loader = vi.fn(
|
|
88
|
+
async (props: Record<string, unknown>, _req: Request, ctx?: { device?: string }) => ({
|
|
89
|
+
...props,
|
|
90
|
+
seenDevice: ctx?.device,
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
registerSectionLoader("site/sections/Ctx.tsx", loader);
|
|
94
|
+
|
|
95
|
+
const section = makeSection("site/sections/Ctx.tsx", {});
|
|
96
|
+
const mobileReq = new Request("https://store.com/", {
|
|
97
|
+
headers: { "user-agent": "iPhone Mobile Safari" },
|
|
98
|
+
});
|
|
99
|
+
const result = await runSingleSectionLoader(section, mobileReq);
|
|
100
|
+
|
|
101
|
+
expect((result.props as Record<string, unknown>).seenDevice).toBe("mobile");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("passes the compat ctx through layout and cacheable paths (#305)", async () => {
|
|
105
|
+
const layoutLoader = vi.fn(
|
|
106
|
+
async (props: Record<string, unknown>, _req: Request, ctx?: { device?: string }) => ({
|
|
107
|
+
...props,
|
|
108
|
+
seenDevice: ctx?.device,
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
const cacheableLoader = vi.fn(
|
|
112
|
+
async (props: Record<string, unknown>, _req: Request, ctx?: { device?: string }) => ({
|
|
113
|
+
...props,
|
|
114
|
+
seenDevice: ctx?.device,
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
registerSectionLoader("site/sections/CtxHeader.tsx", layoutLoader);
|
|
118
|
+
registerLayoutSections(["site/sections/CtxHeader.tsx"]);
|
|
119
|
+
registerSectionLoader("site/sections/CtxShelf.tsx", cacheableLoader);
|
|
120
|
+
registerCacheableSections({ "site/sections/CtxShelf.tsx": { maxAge: 60_000 } });
|
|
121
|
+
|
|
122
|
+
const mobileReq = new Request("https://store.com/", {
|
|
123
|
+
headers: { "user-agent": "iPhone Mobile Safari" },
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const layoutResult = await runSingleSectionLoader(
|
|
127
|
+
makeSection("site/sections/CtxHeader.tsx"),
|
|
128
|
+
mobileReq,
|
|
129
|
+
);
|
|
130
|
+
const cacheableResult = await runSingleSectionLoader(
|
|
131
|
+
makeSection("site/sections/CtxShelf.tsx"),
|
|
132
|
+
mobileReq,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect((layoutResult.props as Record<string, unknown>).seenDevice).toBe("mobile");
|
|
136
|
+
expect((cacheableResult.props as Record<string, unknown>).seenDevice).toBe("mobile");
|
|
137
|
+
});
|
|
85
138
|
});
|
|
86
139
|
|
|
87
140
|
describe("runSingleSectionLoader — cache keying", () => {
|
|
@@ -151,9 +204,7 @@ describe("unregisterLayoutSections", () => {
|
|
|
151
204
|
});
|
|
152
205
|
|
|
153
206
|
it("is a no-op for keys that were never registered", () => {
|
|
154
|
-
expect(() =>
|
|
155
|
-
unregisterLayoutSections(["site/sections/Never.tsx"]),
|
|
156
|
-
).not.toThrow();
|
|
207
|
+
expect(() => unregisterLayoutSections(["site/sections/Never.tsx"])).not.toThrow();
|
|
157
208
|
expect(isLayoutSection("site/sections/Never.tsx")).toBe(false);
|
|
158
209
|
});
|
|
159
210
|
});
|
|
@@ -190,10 +241,7 @@ describe("registerSectionLoaders — request-dependent + layout warning (#206)",
|
|
|
190
241
|
it("warns when a composed loader contains any request-dependent mixin", () => {
|
|
191
242
|
registerLayoutSections(["site/sections/Footer.tsx"]);
|
|
192
243
|
registerSectionLoaders({
|
|
193
|
-
"site/sections/Footer.tsx": compose(
|
|
194
|
-
withSearchParam(),
|
|
195
|
-
async (props) => props,
|
|
196
|
-
),
|
|
244
|
+
"site/sections/Footer.tsx": compose(withSearchParam(), async (props) => props),
|
|
197
245
|
});
|
|
198
246
|
expect(warnSpy).toHaveBeenCalled();
|
|
199
247
|
});
|
|
@@ -14,10 +14,17 @@ import { djb2 } from "../sdk/djb2";
|
|
|
14
14
|
import { withInflightTimeout } from "../sdk/inflightTimeout";
|
|
15
15
|
import { withTracing } from "../sdk/observability";
|
|
16
16
|
import type { ResolvedSection } from "./resolve";
|
|
17
|
+
import { buildSectionLoaderContext, type SectionLoaderContext } from "./sectionLoaderContext";
|
|
17
18
|
|
|
18
19
|
export type SectionLoaderFn = (
|
|
19
20
|
props: Record<string, unknown>,
|
|
20
21
|
req: Request,
|
|
22
|
+
/**
|
|
23
|
+
* Compat context (issue #305). Optional so 2-arg loaders/mixins remain
|
|
24
|
+
* valid — they simply ignore the extra argument. Framework-supplied at the
|
|
25
|
+
* call site via {@link buildSectionLoaderContext}.
|
|
26
|
+
*/
|
|
27
|
+
ctx?: SectionLoaderContext,
|
|
21
28
|
) => Promise<Record<string, unknown>> | Record<string, unknown>;
|
|
22
29
|
|
|
23
30
|
// globalThis-backed: server function split modules need access
|
|
@@ -368,9 +375,14 @@ function injectPageContext(
|
|
|
368
375
|
return enriched;
|
|
369
376
|
}
|
|
370
377
|
|
|
371
|
-
/**
|
|
378
|
+
/**
|
|
379
|
+
* Wrap a loader so it receives __pageUrl/__pagePath in its props AND the
|
|
380
|
+
* 3rd-arg compat `ctx` (issue #305). This is the single choke point that all
|
|
381
|
+
* four invocation paths (regular, layout, cacheable, SWR refresh) route
|
|
382
|
+
* through, so building `ctx` here covers every path.
|
|
383
|
+
*/
|
|
372
384
|
function withPageContext(loader: SectionLoaderFn): SectionLoaderFn {
|
|
373
|
-
return (props, req) => loader(injectPageContext(props, req), req);
|
|
385
|
+
return (props, req) => loader(injectPageContext(props, req), req, buildSectionLoaderContext(req));
|
|
374
386
|
}
|
|
375
387
|
|
|
376
388
|
/**
|
package/src/cms/sectionMixins.ts
CHANGED
|
@@ -83,10 +83,10 @@ export function withSearchParam(): SectionLoaderFn {
|
|
|
83
83
|
* ```
|
|
84
84
|
*/
|
|
85
85
|
export function compose(...mixins: SectionLoaderFn[]): SectionLoaderFn {
|
|
86
|
-
const composed: SectionLoaderFn = async (props, req) => {
|
|
86
|
+
const composed: SectionLoaderFn = async (props, req, ctx) => {
|
|
87
87
|
let result = { ...props };
|
|
88
88
|
for (const mixin of mixins) {
|
|
89
|
-
const partial = await mixin(result, req);
|
|
89
|
+
const partial = await mixin(result, req, ctx);
|
|
90
90
|
result = { ...result, ...partial };
|
|
91
91
|
}
|
|
92
92
|
return result;
|
|
@@ -138,12 +138,14 @@ export function compose(...mixins: SectionLoaderFn[]): SectionLoaderFn {
|
|
|
138
138
|
export function withSectionLoader(
|
|
139
139
|
modImport: () => Promise<unknown>,
|
|
140
140
|
): SectionLoaderFn {
|
|
141
|
-
return async (props, req) => {
|
|
141
|
+
return async (props, req, ctx) => {
|
|
142
142
|
const mod = (await modImport()) as { loader?: unknown } | undefined;
|
|
143
143
|
const loader = mod?.loader;
|
|
144
144
|
if (typeof loader !== "function") return props;
|
|
145
145
|
try {
|
|
146
|
-
|
|
146
|
+
// Forward the compat `ctx` (issue #305) so migrated Fresh loaders that
|
|
147
|
+
// read `ctx.device`/`ctx.invoke`/`ctx.<app>` get the real 3rd argument.
|
|
148
|
+
const result = await (loader as SectionLoaderFn)(props, req, ctx);
|
|
147
149
|
return result ?? props;
|
|
148
150
|
} catch (error) {
|
|
149
151
|
console.error("[withSectionLoader] section loader threw:", error);
|
package/src/sdk/invoke.ts
CHANGED
|
@@ -199,12 +199,71 @@ export function createAppInvoke<T extends Record<string, any>>(
|
|
|
199
199
|
basePath?: string,
|
|
200
200
|
): NestedFromFlat<T>;
|
|
201
201
|
export function createAppInvoke(basePath = "/deco/invoke"): any {
|
|
202
|
+
return createAppInvokeWith({ basePath });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Default nested invoke proxy bound to `/deco/invoke`.
|
|
207
|
+
*
|
|
208
|
+
* Replaces site-level `~/runtime.ts` shims that wrap the same `createAppInvoke`
|
|
209
|
+
* call. Importing this singleton means no per-site boilerplate.
|
|
210
|
+
*
|
|
211
|
+
* @example
|
|
212
|
+
* ```ts
|
|
213
|
+
* import { invoke } from "@decocms/start/sdk/invoke";
|
|
214
|
+
*
|
|
215
|
+
* await invoke.vtex.actions.checkout.addItemsToCart({ orderFormId, orderItems });
|
|
216
|
+
* await invoke.site.loaders.Wishlist.getWishlist({});
|
|
217
|
+
* ```
|
|
218
|
+
*
|
|
219
|
+
* For a custom `basePath` or typed handlers, call `createAppInvoke()` directly:
|
|
220
|
+
* ```ts
|
|
221
|
+
* const invoke = createAppInvoke<Handlers>("/my/invoke");
|
|
222
|
+
* ```
|
|
223
|
+
*/
|
|
224
|
+
export const invoke = createAppInvoke();
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* A `fetch`-compatible function. Lets callers route the invoke proxy's HTTP
|
|
228
|
+
* calls through a custom transport — e.g. server-side, an absolute-origin
|
|
229
|
+
* self-fetch that injects the request's AbortSignal (`RequestContext.fetch`).
|
|
230
|
+
*/
|
|
231
|
+
export type InvokeFetcher = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Like {@link createAppInvoke}, but with an injectable `fetcher`.
|
|
235
|
+
*
|
|
236
|
+
* The default `createAppInvoke` targets a relative path (`/deco/invoke`) via
|
|
237
|
+
* the global `fetch`, which only works in the browser. Server-side callers
|
|
238
|
+
* (e.g. a section loader's `ctx.invoke`) need an **absolute** base URL plus a
|
|
239
|
+
* fetcher that forwards the request's AbortSignal. This factory keeps the exact
|
|
240
|
+
* same nested-proxy semantics and 404→`.ts` retry, only swapping the transport.
|
|
241
|
+
*
|
|
242
|
+
* @example
|
|
243
|
+
* ```ts
|
|
244
|
+
* const origin = new URL(req.url).origin;
|
|
245
|
+
* const invoke = createAppInvokeWith({
|
|
246
|
+
* basePath: `${origin}/deco/invoke`,
|
|
247
|
+
* fetcher: (input, init) => RequestContext.fetch(input, init),
|
|
248
|
+
* });
|
|
249
|
+
* await invoke.vtex.loaders.product.detailsPageGQL({ slug });
|
|
250
|
+
* ```
|
|
251
|
+
*/
|
|
252
|
+
export function createAppInvokeWith(options?: { basePath?: string; fetcher?: InvokeFetcher }): any;
|
|
253
|
+
export function createAppInvokeWith<T extends Record<string, any>>(options?: {
|
|
254
|
+
basePath?: string;
|
|
255
|
+
fetcher?: InvokeFetcher;
|
|
256
|
+
}): NestedFromFlat<T>;
|
|
257
|
+
export function createAppInvokeWith(options?: { basePath?: string; fetcher?: InvokeFetcher }): any {
|
|
258
|
+
const basePath = options?.basePath ?? "/deco/invoke";
|
|
259
|
+
const fetcher: InvokeFetcher = options?.fetcher ?? ((input, init) => fetch(input, init));
|
|
260
|
+
|
|
202
261
|
function buildProxy(path: string[]): any {
|
|
203
262
|
return new Proxy(
|
|
204
263
|
Object.assign(async (props: any) => {
|
|
205
264
|
const key = path.join("/");
|
|
206
265
|
for (const k of [key, `${key}.ts`]) {
|
|
207
|
-
const response = await
|
|
266
|
+
const response = await fetcher(`${basePath}/${k}`, {
|
|
208
267
|
method: "POST",
|
|
209
268
|
headers: { "Content-Type": "application/json" },
|
|
210
269
|
body: JSON.stringify(props ?? {}),
|
|
@@ -237,24 +296,3 @@ export function createAppInvoke(basePath = "/deco/invoke"): any {
|
|
|
237
296
|
|
|
238
297
|
return buildProxy([]);
|
|
239
298
|
}
|
|
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();
|
package/src/types/index.ts
CHANGED
|
@@ -4,8 +4,22 @@
|
|
|
4
4
|
* without depending on the Deno-specific @deco/deco package.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Compat context handed to ported deco.cx (Fresh) section loaders as the 3rd
|
|
9
|
+
* argument (issue #305). `state` is the app state; `device`/`invoke`/`response`
|
|
10
|
+
* mirror what Fresh's `ctx` exposed, and the index signature lets migrated
|
|
11
|
+
* loaders read per-app state directly off `ctx` (`ctx.vtex`, `ctx.salesforce`).
|
|
12
|
+
* Deep reads should still be optional-chained — an unconfigured app is
|
|
13
|
+
* `undefined`. See `@decocms/blocks/cms`'s `SectionLoaderContext` for the
|
|
14
|
+
* runtime shape.
|
|
15
|
+
*/
|
|
7
16
|
export interface FnContext<TState = any> {
|
|
8
17
|
state: TState;
|
|
18
|
+
device?: "mobile" | "tablet" | "desktop";
|
|
19
|
+
invoke?: any;
|
|
20
|
+
response?: { headers: Headers };
|
|
21
|
+
getAppState?: <T>(appName: string) => T | undefined;
|
|
22
|
+
[key: string]: any;
|
|
9
23
|
}
|
|
10
24
|
|
|
11
25
|
export type App<TManifest = any, TState = any, TDeps extends any[] = any[]> = {
|