@decocms/blocks 7.22.2 → 7.24.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 +32 -26
- package/src/cms/resolve.ts +6 -1
- package/src/cms/sectionLoaderContext.test.ts +111 -0
- package/src/cms/sectionLoaderContext.ts +113 -0
- package/src/cms/sectionLoaders.test.ts +131 -7
- package/src/cms/sectionLoaders.ts +69 -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
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export type { ApplySectionConventionsInput, SectionMetaEntry } from "./applySectionConventions";
|
|
2
|
+
export { applySectionConventions } from "./applySectionConventions";
|
|
1
3
|
export type { BlockSnapshot, BlockSource, KVNamespace } from "./blockSource";
|
|
2
4
|
export {
|
|
3
5
|
BUILD_HASH_ENV,
|
|
@@ -50,10 +52,11 @@ export type {
|
|
|
50
52
|
} from "./resolve";
|
|
51
53
|
export {
|
|
52
54
|
addSkipResolveType,
|
|
55
|
+
cacheDeferredRawProps,
|
|
56
|
+
clearCommerceLoaders,
|
|
53
57
|
evaluateMatcher,
|
|
54
58
|
extractSeoFromProps,
|
|
55
59
|
extractSeoFromSections,
|
|
56
|
-
cacheDeferredRawProps,
|
|
57
60
|
getAsyncRenderingConfig,
|
|
58
61
|
getDeferredRawProps,
|
|
59
62
|
isBot,
|
|
@@ -63,43 +66,24 @@ export {
|
|
|
63
66
|
registerBotPattern,
|
|
64
67
|
registerCommerceLoader,
|
|
65
68
|
registerCommerceLoaders,
|
|
66
|
-
unregisterCommerceLoader,
|
|
67
|
-
clearCommerceLoaders,
|
|
68
|
-
registerMatcher,
|
|
69
69
|
registerEagerSections,
|
|
70
|
+
registerMatcher,
|
|
70
71
|
registerNeverDeferSections,
|
|
71
72
|
registerSeoSections,
|
|
72
73
|
resolveDecoPage,
|
|
73
|
-
resolvePageSections,
|
|
74
|
-
resolvePageSeoBlock,
|
|
75
74
|
resolveDeferredSection,
|
|
76
75
|
resolveDeferredSectionFull,
|
|
76
|
+
resolvePageSections,
|
|
77
|
+
resolvePageSeoBlock,
|
|
77
78
|
resolveValue,
|
|
78
79
|
setAsyncRenderingConfig,
|
|
79
80
|
setDanglingReferenceHandler,
|
|
80
81
|
setResolveErrorHandler,
|
|
82
|
+
unregisterCommerceLoader,
|
|
81
83
|
WELL_KNOWN_TYPES,
|
|
82
84
|
} from "./resolve";
|
|
83
|
-
export type {
|
|
84
|
-
export {
|
|
85
|
-
isLayoutSection,
|
|
86
|
-
registerCacheableSections,
|
|
87
|
-
registerLayoutSections,
|
|
88
|
-
registerSectionLoader,
|
|
89
|
-
registerSectionLoaders,
|
|
90
|
-
runSectionLoaders,
|
|
91
|
-
runSingleSectionLoader,
|
|
92
|
-
unregisterLayoutSections,
|
|
93
|
-
} from "./sectionLoaders";
|
|
94
|
-
export {
|
|
95
|
-
compose,
|
|
96
|
-
withDevice,
|
|
97
|
-
withMobile,
|
|
98
|
-
withSearchParam,
|
|
99
|
-
withSectionLoader,
|
|
100
|
-
} from "./sectionMixins";
|
|
101
|
-
export type { ApplySectionConventionsInput, SectionMetaEntry } from "./applySectionConventions";
|
|
102
|
-
export { applySectionConventions } from "./applySectionConventions";
|
|
85
|
+
export type { SectionLoaderContext } from "./sectionLoaderContext";
|
|
86
|
+
export { buildSectionLoaderContext } from "./sectionLoaderContext";
|
|
103
87
|
export type {
|
|
104
88
|
ActionConfig,
|
|
105
89
|
AppSchemas,
|
|
@@ -121,3 +105,25 @@ export {
|
|
|
121
105
|
registerMatcherSchema,
|
|
122
106
|
registerMatcherSchemas,
|
|
123
107
|
} from "./schema";
|
|
108
|
+
export type { SectionLoaderFn } from "./sectionLoaders";
|
|
109
|
+
export {
|
|
110
|
+
getDegradedSections,
|
|
111
|
+
isCriticalSection,
|
|
112
|
+
isLayoutSection,
|
|
113
|
+
markSectionDegraded,
|
|
114
|
+
registerCacheableSections,
|
|
115
|
+
registerLayoutSections,
|
|
116
|
+
registerNonCriticalSections,
|
|
117
|
+
registerSectionLoader,
|
|
118
|
+
registerSectionLoaders,
|
|
119
|
+
runSectionLoaders,
|
|
120
|
+
runSingleSectionLoader,
|
|
121
|
+
unregisterLayoutSections,
|
|
122
|
+
} from "./sectionLoaders";
|
|
123
|
+
export {
|
|
124
|
+
compose,
|
|
125
|
+
withDevice,
|
|
126
|
+
withMobile,
|
|
127
|
+
withSearchParam,
|
|
128
|
+
withSectionLoader,
|
|
129
|
+
} from "./sectionMixins";
|
package/src/cms/resolve.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { withInflightTimeout } from "../sdk/inflightTimeout";
|
|
|
13
13
|
import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
|
|
14
14
|
import { findPageByPath, loadBlocks } from "./loader";
|
|
15
15
|
import { getOnBeforeResolveProps, getSection, registerOnBeforeResolveProps } from "./registry";
|
|
16
|
-
import { isLayoutSection, runSingleSectionLoader } from "./sectionLoaders";
|
|
16
|
+
import { isLayoutSection, markSectionDegraded, runSingleSectionLoader } from "./sectionLoaders";
|
|
17
17
|
|
|
18
18
|
// globalThis-backed: share state across Vite server function split modules
|
|
19
19
|
const G = globalThis as any;
|
|
@@ -911,6 +911,11 @@ async function internalResolve(value: unknown, rctx: ResolveContext): Promise<un
|
|
|
911
911
|
return await commerceLoader(resolvedProps);
|
|
912
912
|
} catch (error) {
|
|
913
913
|
onResolveError(error, resolveType, "Commerce loader");
|
|
914
|
+
// Commerce loaders (VTEX product/PLP/PDP data) are the primary source of
|
|
915
|
+
// page-body data — this is exactly the layer that fails during a VTEX
|
|
916
|
+
// outage. Flag the page degraded so the edge won't cache the empty
|
|
917
|
+
// result as a healthy 200 (see X-Deco-Degraded in cmsRoute/workerEntry).
|
|
918
|
+
markSectionDegraded(resolveType);
|
|
914
919
|
return null;
|
|
915
920
|
}
|
|
916
921
|
}
|
|
@@ -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
|
+
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { configureTracer, type Span } from "../sdk/observability";
|
|
3
|
+
import { RequestContext } from "../sdk/requestContext";
|
|
3
4
|
import type { ResolvedSection } from "./resolve";
|
|
4
5
|
import {
|
|
6
|
+
getDegradedSections,
|
|
5
7
|
isLayoutSection,
|
|
6
8
|
registerCacheableSections,
|
|
7
9
|
registerLayoutSections,
|
|
10
|
+
registerNonCriticalSections,
|
|
8
11
|
registerSectionLoader,
|
|
9
12
|
registerSectionLoaders,
|
|
10
13
|
runSectionLoaders,
|
|
@@ -19,6 +22,7 @@ beforeEach(() => {
|
|
|
19
22
|
G.__deco.sectionLoaderRegistry.clear();
|
|
20
23
|
G.__deco.layoutSections.clear();
|
|
21
24
|
G.__deco.cacheableSections.clear();
|
|
25
|
+
G.__deco.nonCriticalSections.clear();
|
|
22
26
|
});
|
|
23
27
|
|
|
24
28
|
const makeSection = (component: string, props: Record<string, unknown> = {}): ResolvedSection => ({
|
|
@@ -28,6 +32,78 @@ const makeSection = (component: string, props: Record<string, unknown> = {}): Re
|
|
|
28
32
|
index: 0,
|
|
29
33
|
});
|
|
30
34
|
|
|
35
|
+
describe("section degradation tracking (anti-cache-poisoning)", () => {
|
|
36
|
+
const failing = async () => {
|
|
37
|
+
throw new Error("VTEX down");
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
it("marks a critical section as degraded when its loader throws", async () => {
|
|
41
|
+
registerSectionLoader("site/sections/ProductShelf.tsx", failing);
|
|
42
|
+
const section = makeSection("site/sections/ProductShelf.tsx");
|
|
43
|
+
const request = new Request("https://store.com/p");
|
|
44
|
+
|
|
45
|
+
const degraded = await RequestContext.run(request, async () => {
|
|
46
|
+
// Loader throws → section degrades to raw props (does not reject).
|
|
47
|
+
const result = await runSingleSectionLoader(section, request);
|
|
48
|
+
expect(result.component).toBe("site/sections/ProductShelf.tsx");
|
|
49
|
+
return getDegradedSections();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(degraded).toContain("site/sections/ProductShelf.tsx");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("does NOT mark a non-critical section as degraded", async () => {
|
|
56
|
+
registerSectionLoader("site/sections/Decorative.tsx", failing);
|
|
57
|
+
registerNonCriticalSections(["site/sections/Decorative.tsx"]);
|
|
58
|
+
const section = makeSection("site/sections/Decorative.tsx");
|
|
59
|
+
const request = new Request("https://store.com/p");
|
|
60
|
+
|
|
61
|
+
const degraded = await RequestContext.run(request, async () => {
|
|
62
|
+
await runSingleSectionLoader(section, request);
|
|
63
|
+
return getDegradedSections();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(degraded).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("does NOT mark a failing LAYOUT section as degraded (avoids site-wide poisoning)", async () => {
|
|
70
|
+
registerSectionLoader("site/sections/Header.tsx", failing);
|
|
71
|
+
registerLayoutSections(["site/sections/Header.tsx"]);
|
|
72
|
+
const section = makeSection("site/sections/Header.tsx");
|
|
73
|
+
const request = new Request("https://store.com/p");
|
|
74
|
+
|
|
75
|
+
const degraded = await RequestContext.run(request, async () => {
|
|
76
|
+
await runSingleSectionLoader(section, request);
|
|
77
|
+
return getDegradedSections();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// A flaky Header/Footer must not flip every page to X-Deco-Degraded.
|
|
81
|
+
expect(degraded).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("does not flag a page whose loaders all succeed", async () => {
|
|
85
|
+
registerSectionLoader("site/sections/Ok.tsx", async (p: Record<string, unknown>) => p);
|
|
86
|
+
const section = makeSection("site/sections/Ok.tsx");
|
|
87
|
+
const request = new Request("https://store.com/p");
|
|
88
|
+
|
|
89
|
+
const degraded = await RequestContext.run(request, async () => {
|
|
90
|
+
await runSingleSectionLoader(section, request);
|
|
91
|
+
return getDegradedSections();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(degraded).toEqual([]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("is a no-op outside a request scope (does not throw)", async () => {
|
|
98
|
+
registerSectionLoader("site/sections/NoScope.tsx", failing);
|
|
99
|
+
const section = makeSection("site/sections/NoScope.tsx");
|
|
100
|
+
const request = new Request("https://store.com/p");
|
|
101
|
+
// No RequestContext.run wrapper — must not throw.
|
|
102
|
+
await expect(runSingleSectionLoader(section, request)).resolves.toBeTruthy();
|
|
103
|
+
expect(getDegradedSections()).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
31
107
|
describe("runSingleSectionLoader — page context injection", () => {
|
|
32
108
|
it("injects __pageUrl and __pagePath into loader props", async () => {
|
|
33
109
|
// Two-arg signature so loader.mock.calls[0] types as [props, req].
|
|
@@ -82,6 +158,59 @@ describe("runSingleSectionLoader — page context injection", () => {
|
|
|
82
158
|
const result = await runSingleSectionLoader(section, new Request("https://store.com/"));
|
|
83
159
|
expect(result).toBe(section);
|
|
84
160
|
});
|
|
161
|
+
|
|
162
|
+
it("passes a 3rd-arg compat ctx (device from UA) — regular path (#305)", async () => {
|
|
163
|
+
const loader = vi.fn(
|
|
164
|
+
async (props: Record<string, unknown>, _req: Request, ctx?: { device?: string }) => ({
|
|
165
|
+
...props,
|
|
166
|
+
seenDevice: ctx?.device,
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
registerSectionLoader("site/sections/Ctx.tsx", loader);
|
|
170
|
+
|
|
171
|
+
const section = makeSection("site/sections/Ctx.tsx", {});
|
|
172
|
+
const mobileReq = new Request("https://store.com/", {
|
|
173
|
+
headers: { "user-agent": "iPhone Mobile Safari" },
|
|
174
|
+
});
|
|
175
|
+
const result = await runSingleSectionLoader(section, mobileReq);
|
|
176
|
+
|
|
177
|
+
expect((result.props as Record<string, unknown>).seenDevice).toBe("mobile");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("passes the compat ctx through layout and cacheable paths (#305)", async () => {
|
|
181
|
+
const layoutLoader = vi.fn(
|
|
182
|
+
async (props: Record<string, unknown>, _req: Request, ctx?: { device?: string }) => ({
|
|
183
|
+
...props,
|
|
184
|
+
seenDevice: ctx?.device,
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
const cacheableLoader = vi.fn(
|
|
188
|
+
async (props: Record<string, unknown>, _req: Request, ctx?: { device?: string }) => ({
|
|
189
|
+
...props,
|
|
190
|
+
seenDevice: ctx?.device,
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
registerSectionLoader("site/sections/CtxHeader.tsx", layoutLoader);
|
|
194
|
+
registerLayoutSections(["site/sections/CtxHeader.tsx"]);
|
|
195
|
+
registerSectionLoader("site/sections/CtxShelf.tsx", cacheableLoader);
|
|
196
|
+
registerCacheableSections({ "site/sections/CtxShelf.tsx": { maxAge: 60_000 } });
|
|
197
|
+
|
|
198
|
+
const mobileReq = new Request("https://store.com/", {
|
|
199
|
+
headers: { "user-agent": "iPhone Mobile Safari" },
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const layoutResult = await runSingleSectionLoader(
|
|
203
|
+
makeSection("site/sections/CtxHeader.tsx"),
|
|
204
|
+
mobileReq,
|
|
205
|
+
);
|
|
206
|
+
const cacheableResult = await runSingleSectionLoader(
|
|
207
|
+
makeSection("site/sections/CtxShelf.tsx"),
|
|
208
|
+
mobileReq,
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
expect((layoutResult.props as Record<string, unknown>).seenDevice).toBe("mobile");
|
|
212
|
+
expect((cacheableResult.props as Record<string, unknown>).seenDevice).toBe("mobile");
|
|
213
|
+
});
|
|
85
214
|
});
|
|
86
215
|
|
|
87
216
|
describe("runSingleSectionLoader — cache keying", () => {
|
|
@@ -151,9 +280,7 @@ describe("unregisterLayoutSections", () => {
|
|
|
151
280
|
});
|
|
152
281
|
|
|
153
282
|
it("is a no-op for keys that were never registered", () => {
|
|
154
|
-
expect(() =>
|
|
155
|
-
unregisterLayoutSections(["site/sections/Never.tsx"]),
|
|
156
|
-
).not.toThrow();
|
|
283
|
+
expect(() => unregisterLayoutSections(["site/sections/Never.tsx"])).not.toThrow();
|
|
157
284
|
expect(isLayoutSection("site/sections/Never.tsx")).toBe(false);
|
|
158
285
|
});
|
|
159
286
|
});
|
|
@@ -190,10 +317,7 @@ describe("registerSectionLoaders — request-dependent + layout warning (#206)",
|
|
|
190
317
|
it("warns when a composed loader contains any request-dependent mixin", () => {
|
|
191
318
|
registerLayoutSections(["site/sections/Footer.tsx"]);
|
|
192
319
|
registerSectionLoaders({
|
|
193
|
-
"site/sections/Footer.tsx": compose(
|
|
194
|
-
withSearchParam(),
|
|
195
|
-
async (props) => props,
|
|
196
|
-
),
|
|
320
|
+
"site/sections/Footer.tsx": compose(withSearchParam(), async (props) => props),
|
|
197
321
|
});
|
|
198
322
|
expect(warnSpy).toHaveBeenCalled();
|
|
199
323
|
});
|
|
@@ -9,15 +9,23 @@
|
|
|
9
9
|
* inside the TanStack Start server function.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { RequestContext } from "@decocms/blocks/sdk/requestContext";
|
|
12
13
|
import { getCacheProfile } from "../sdk/cacheHeaders";
|
|
13
14
|
import { djb2 } from "../sdk/djb2";
|
|
14
15
|
import { withInflightTimeout } from "../sdk/inflightTimeout";
|
|
15
16
|
import { withTracing } from "../sdk/observability";
|
|
16
17
|
import type { ResolvedSection } from "./resolve";
|
|
18
|
+
import { buildSectionLoaderContext, type SectionLoaderContext } from "./sectionLoaderContext";
|
|
17
19
|
|
|
18
20
|
export type SectionLoaderFn = (
|
|
19
21
|
props: Record<string, unknown>,
|
|
20
22
|
req: Request,
|
|
23
|
+
/**
|
|
24
|
+
* Compat context (issue #305). Optional so 2-arg loaders/mixins remain
|
|
25
|
+
* valid — they simply ignore the extra argument. Framework-supplied at the
|
|
26
|
+
* call site via {@link buildSectionLoaderContext}.
|
|
27
|
+
*/
|
|
28
|
+
ctx?: SectionLoaderContext,
|
|
21
29
|
) => Promise<Record<string, unknown>> | Record<string, unknown>;
|
|
22
30
|
|
|
23
31
|
// globalThis-backed: server function split modules need access
|
|
@@ -26,9 +34,56 @@ if (!G.__deco) G.__deco = {};
|
|
|
26
34
|
if (!G.__deco.sectionLoaderRegistry) G.__deco.sectionLoaderRegistry = new Map();
|
|
27
35
|
if (!G.__deco.layoutSections) G.__deco.layoutSections = new Set();
|
|
28
36
|
if (!G.__deco.cacheableSections) G.__deco.cacheableSections = new Map();
|
|
37
|
+
if (!G.__deco.nonCriticalSections) G.__deco.nonCriticalSections = new Set();
|
|
29
38
|
|
|
30
39
|
const loaderRegistry: Map<string, SectionLoaderFn> = G.__deco.sectionLoaderRegistry;
|
|
31
40
|
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Degradation tracking (anti-cache-poisoning)
|
|
43
|
+
//
|
|
44
|
+
// When a section loader throws, the section renders with raw (un-enriched)
|
|
45
|
+
// props — e.g. a product shelf with no products during a VTEX outage. Today
|
|
46
|
+
// that degraded page is emitted as a healthy 200 and the edge caches it for the
|
|
47
|
+
// full retention window, so stale-if-error never fires and users see an empty
|
|
48
|
+
// page for hours. We record each CRITICAL section degradation on a
|
|
49
|
+
// request-scoped collector; the CMS route reads it and emits `X-Deco-Degraded`
|
|
50
|
+
// so the worker refuses to cache the broken page and serves stale instead.
|
|
51
|
+
//
|
|
52
|
+
// Every section is critical by default (a registered loader failing means
|
|
53
|
+
// intended data is missing). Decorative sections whose empty state is harmless
|
|
54
|
+
// can opt out via `registerNonCriticalSections`.
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
const DEGRADED_BAG_KEY = "deco:degraded-sections";
|
|
58
|
+
const nonCriticalSections: Set<string> = G.__deco.nonCriticalSections;
|
|
59
|
+
|
|
60
|
+
/** Mark section keys whose loader failure should NOT make the page uncacheable. */
|
|
61
|
+
export function registerNonCriticalSections(keys: string[]): void {
|
|
62
|
+
for (const k of keys) nonCriticalSections.add(k);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A section is critical (its failure degrades the page) unless opted out. */
|
|
66
|
+
export function isCriticalSection(key: string): boolean {
|
|
67
|
+
return !nonCriticalSections.has(key);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Record that a critical section rendered with raw props because its loader
|
|
72
|
+
* threw. No-op outside a request scope or for non-critical sections.
|
|
73
|
+
*/
|
|
74
|
+
export function markSectionDegraded(component: string): void {
|
|
75
|
+
if (!isCriticalSection(component)) return;
|
|
76
|
+
const set = RequestContext.getBag<Set<string>>(DEGRADED_BAG_KEY) ?? new Set<string>();
|
|
77
|
+
set.add(component);
|
|
78
|
+
RequestContext.setBag(DEGRADED_BAG_KEY, set);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Component keys of critical sections that degraded during this request. */
|
|
82
|
+
export function getDegradedSections(): string[] {
|
|
83
|
+
const set = RequestContext.getBag<Set<string>>(DEGRADED_BAG_KEY);
|
|
84
|
+
return set ? [...set] : [];
|
|
85
|
+
}
|
|
86
|
+
|
|
32
87
|
// ---------------------------------------------------------------------------
|
|
33
88
|
// Cacheable section loaders — SWR cache for section loader results
|
|
34
89
|
// ---------------------------------------------------------------------------
|
|
@@ -368,9 +423,14 @@ function injectPageContext(
|
|
|
368
423
|
return enriched;
|
|
369
424
|
}
|
|
370
425
|
|
|
371
|
-
/**
|
|
426
|
+
/**
|
|
427
|
+
* Wrap a loader so it receives __pageUrl/__pagePath in its props AND the
|
|
428
|
+
* 3rd-arg compat `ctx` (issue #305). This is the single choke point that all
|
|
429
|
+
* four invocation paths (regular, layout, cacheable, SWR refresh) route
|
|
430
|
+
* through, so building `ctx` here covers every path.
|
|
431
|
+
*/
|
|
372
432
|
function withPageContext(loader: SectionLoaderFn): SectionLoaderFn {
|
|
373
|
-
return (props, req) => loader(injectPageContext(props, req), req);
|
|
433
|
+
return (props, req) => loader(injectPageContext(props, req), req, buildSectionLoaderContext(req));
|
|
374
434
|
}
|
|
375
435
|
|
|
376
436
|
/**
|
|
@@ -420,6 +480,11 @@ async function runSingleSectionLoaderImpl(
|
|
|
420
480
|
result = await resolveLayoutSection(section, wrapped, request);
|
|
421
481
|
} catch (error) {
|
|
422
482
|
console.error(`[SectionLoader] Error in layout "${section.component}":`, error);
|
|
483
|
+
// Deliberately NOT marked degraded: layout sections (Header/Footer/Theme)
|
|
484
|
+
// render on every page, so a flaky layout loader would flip the whole
|
|
485
|
+
// site to X-Deco-Degraded and defeat edge caching everywhere. A failed
|
|
486
|
+
// layout renders raw chrome, which is acceptable — page-body data
|
|
487
|
+
// integrity (product shelves/PLP/PDP) is what the degraded signal guards.
|
|
423
488
|
result = section;
|
|
424
489
|
}
|
|
425
490
|
} else {
|
|
@@ -429,6 +494,7 @@ async function runSingleSectionLoaderImpl(
|
|
|
429
494
|
result = await runCacheableSectionLoader(section, wrapped, request, cacheConfig);
|
|
430
495
|
} catch (error) {
|
|
431
496
|
console.error(`[SectionLoader] Error in cacheable "${section.component}":`, error);
|
|
497
|
+
markSectionDegraded(section.component);
|
|
432
498
|
result = section;
|
|
433
499
|
}
|
|
434
500
|
} else {
|
|
@@ -437,6 +503,7 @@ async function runSingleSectionLoaderImpl(
|
|
|
437
503
|
result = { ...section, props: enrichedProps };
|
|
438
504
|
} catch (error) {
|
|
439
505
|
console.error(`[SectionLoader] Error in "${section.component}":`, error);
|
|
506
|
+
markSectionDegraded(section.component);
|
|
440
507
|
result = section;
|
|
441
508
|
}
|
|
442
509
|
}
|
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[]> = {
|