@decocms/start 0.38.0 → 0.39.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 +2 -1
- package/src/admin/index.ts +2 -0
- package/src/admin/invoke.ts +53 -5
- package/src/admin/setup.ts +7 -1
- package/src/apps/autoconfig.ts +50 -72
- package/src/sdk/invoke.ts +123 -12
- package/src/sdk/requestContext.ts +42 -0
- package/src/sdk/setupApps.ts +211 -0
- package/src/sdk/workerEntry.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/start",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.0",
|
|
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",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"./sdk/createInvoke": "./src/sdk/createInvoke.ts",
|
|
43
43
|
"./matchers/posthog": "./src/matchers/posthog.ts",
|
|
44
44
|
"./apps/autoconfig": "./src/apps/autoconfig.ts",
|
|
45
|
+
"./sdk/setupApps": "./src/sdk/setupApps.ts",
|
|
45
46
|
"./matchers/builtins": "./src/matchers/builtins.ts",
|
|
46
47
|
"./types/widgets": "./src/types/widgets.ts",
|
|
47
48
|
"./routes": "./src/routes/index.ts",
|
package/src/admin/index.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
export { corsHeaders, isAdminOrLocalhost, registerAdminOrigin, registerAdminOrigins } from "./cors";
|
|
2
2
|
export { handleDecofileRead, handleDecofileReload } from "./decofile";
|
|
3
3
|
export {
|
|
4
|
+
clearInvokeHandlers,
|
|
4
5
|
handleInvoke,
|
|
5
6
|
type InvokeAction,
|
|
6
7
|
type InvokeLoader,
|
|
8
|
+
registerInvokeHandlers,
|
|
7
9
|
setInvokeActions,
|
|
8
10
|
setInvokeLoaders,
|
|
9
11
|
} from "./invoke";
|
package/src/admin/invoke.ts
CHANGED
|
@@ -7,11 +7,21 @@
|
|
|
7
7
|
* - FormData parsing for file uploads and form submissions
|
|
8
8
|
* - `?select=field1,field2` to pick fields from the result
|
|
9
9
|
* - Resolves __resolveType in batch payloads
|
|
10
|
+
*
|
|
11
|
+
* Handlers can write to `RequestContext.responseHeaders` to forward
|
|
12
|
+
* headers (e.g., Set-Cookie from VTEX checkout). The invoke endpoint
|
|
13
|
+
* copies those headers into the final HTTP Response.
|
|
10
14
|
*/
|
|
11
15
|
|
|
16
|
+
import { RequestContext } from "../sdk/requestContext";
|
|
17
|
+
|
|
12
18
|
export type InvokeLoader = (props: any, request: Request) => Promise<any>;
|
|
13
19
|
export type InvokeAction = (props: any, request: Request) => Promise<any>;
|
|
14
20
|
|
|
21
|
+
// Additive handler registry — registerInvokeHandlers() adds to this map.
|
|
22
|
+
const handlerRegistry = new Map<string, InvokeLoader | InvokeAction>();
|
|
23
|
+
|
|
24
|
+
// Legacy getter-based registries (backward compat for setInvokeLoaders/Actions).
|
|
15
25
|
let getRegisteredLoaders: () => Record<string, InvokeLoader> = () => ({});
|
|
16
26
|
let getRegisteredActions: () => Record<string, InvokeAction> = () => ({});
|
|
17
27
|
|
|
@@ -23,6 +33,29 @@ export function setInvokeActions(getter: () => Record<string, InvokeAction>) {
|
|
|
23
33
|
getRegisteredActions = getter;
|
|
24
34
|
}
|
|
25
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Additive handler registration — adds handlers without replacing existing ones.
|
|
38
|
+
* First registration wins: if a key already exists, it is NOT overwritten.
|
|
39
|
+
* Use this for automatic manifest-based registration from setupApps().
|
|
40
|
+
*/
|
|
41
|
+
export function registerInvokeHandlers(
|
|
42
|
+
handlers: Record<string, InvokeLoader | InvokeAction>,
|
|
43
|
+
): void {
|
|
44
|
+
for (const [key, handler] of Object.entries(handlers)) {
|
|
45
|
+
if (!handlerRegistry.has(key)) {
|
|
46
|
+
handlerRegistry.set(key, handler);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Clear all registered invoke handlers.
|
|
53
|
+
* Called by setupApps() before re-registering on hot-reload.
|
|
54
|
+
*/
|
|
55
|
+
export function clearInvokeHandlers(): void {
|
|
56
|
+
handlerRegistry.clear();
|
|
57
|
+
}
|
|
58
|
+
|
|
26
59
|
const JSON_HEADERS = { "Content-Type": "application/json" } as const;
|
|
27
60
|
|
|
28
61
|
const isDev =
|
|
@@ -100,6 +133,11 @@ async function parseBody(request: Request): Promise<any> {
|
|
|
100
133
|
function findHandler(
|
|
101
134
|
key: string,
|
|
102
135
|
): { handler: InvokeLoader | InvokeAction; type: "loader" | "action" } | null {
|
|
136
|
+
// 1. Check additive registry first (from registerInvokeHandlers / setupApps)
|
|
137
|
+
const registered = handlerRegistry.get(key);
|
|
138
|
+
if (registered) return { handler: registered, type: "action" };
|
|
139
|
+
|
|
140
|
+
// 2. Fall back to legacy getter-based registries
|
|
103
141
|
const loaders = getRegisteredLoaders();
|
|
104
142
|
if (loaders[key]) return { handler: loaders[key], type: "loader" };
|
|
105
143
|
|
|
@@ -126,15 +164,25 @@ export async function handleInvoke(request: Request): Promise<Response> {
|
|
|
126
164
|
|
|
127
165
|
try {
|
|
128
166
|
const result = await found.handler(body, request);
|
|
129
|
-
// Response passthrough: if the
|
|
130
|
-
// forward it as-is (preserving headers like Set-Cookie).
|
|
131
|
-
// deco-cx/deco's invokeToHttpResponse behavior where auth loaders return
|
|
132
|
-
// Response objects with Set-Cookie headers for HttpOnly cookies.
|
|
167
|
+
// Response passthrough: if the handler returns a Response object,
|
|
168
|
+
// forward it as-is (preserving headers like Set-Cookie).
|
|
133
169
|
if (result instanceof Response) {
|
|
134
170
|
return result;
|
|
135
171
|
}
|
|
136
172
|
const filtered = selectFields(result, select);
|
|
137
|
-
|
|
173
|
+
const response = new Response(JSON.stringify(filtered), { status: 200, headers: JSON_HEADERS });
|
|
174
|
+
|
|
175
|
+
// Copy any headers that handlers wrote to RequestContext.responseHeaders
|
|
176
|
+
// (e.g., Set-Cookie from proxySetCookie). This mirrors deco-cx/deco's
|
|
177
|
+
// ctx.response.headers → HTTP Response forwarding.
|
|
178
|
+
const ctx = RequestContext.current;
|
|
179
|
+
if (ctx) {
|
|
180
|
+
for (const [key, value] of ctx.responseHeaders.entries()) {
|
|
181
|
+
response.headers.append(key, value);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return response;
|
|
138
186
|
} catch (error) {
|
|
139
187
|
return errorResponse((error as Error).message, 500, error);
|
|
140
188
|
}
|
package/src/admin/setup.ts
CHANGED
|
@@ -8,7 +8,13 @@
|
|
|
8
8
|
* import from "@decocms/start/admin" instead.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
export {
|
|
11
|
+
export {
|
|
12
|
+
type InvokeAction,
|
|
13
|
+
type InvokeLoader,
|
|
14
|
+
registerInvokeHandlers,
|
|
15
|
+
setInvokeActions,
|
|
16
|
+
setInvokeLoaders,
|
|
17
|
+
} from "./invoke";
|
|
12
18
|
export { setMetaData } from "./meta";
|
|
13
19
|
export {
|
|
14
20
|
type LoaderConfig,
|
package/src/apps/autoconfig.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Auto-configures known apps from CMS blocks.
|
|
2
|
+
* Auto-configures known apps from CMS blocks using AppModContract.
|
|
3
3
|
*
|
|
4
|
-
* Scans the decofile for known app block keys (e.g. "deco-resend")
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Scans the decofile for known app block keys (e.g. "deco-vtex", "deco-resend")
|
|
5
|
+
* and calls each app's `configure()` function from its mod.ts.
|
|
6
|
+
* Then delegates to `setupApps()` for invoke handler registration, middleware, etc.
|
|
7
7
|
*
|
|
8
8
|
* Usage in setup.ts:
|
|
9
9
|
* import { autoconfigApps } from "@decocms/start/apps/autoconfig";
|
|
@@ -11,62 +11,60 @@
|
|
|
11
11
|
* await autoconfigApps(generatedBlocks);
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { setInvokeActions, type InvokeAction } from "../admin/invoke";
|
|
15
14
|
import { onChange } from "../cms/loader";
|
|
16
15
|
import { resolveSecret } from "../sdk/crypto";
|
|
16
|
+
import {
|
|
17
|
+
setupApps,
|
|
18
|
+
type AppDefinition,
|
|
19
|
+
type AppDefinitionWithHandlers,
|
|
20
|
+
} from "../sdk/setupApps";
|
|
17
21
|
|
|
18
22
|
// ---------------------------------------------------------------------------
|
|
19
|
-
// Known app block keys → dynamic import
|
|
23
|
+
// Known app block keys → dynamic import of their mod.ts
|
|
20
24
|
// ---------------------------------------------------------------------------
|
|
21
25
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
const APP_MODS: Record<string, () => Promise<any>> = {
|
|
27
|
+
"deco-vtex": () => import("@decocms/apps/vtex/mod" as string),
|
|
28
|
+
"deco-shopify": () => import("@decocms/apps/shopify/mod" as string),
|
|
29
|
+
"deco-resend": () => import("@decocms/apps/resend/mod" as string),
|
|
30
|
+
};
|
|
26
31
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const [resendClient, resendActions] = await Promise.all([
|
|
31
|
-
import("@decocms/apps/resend/client" as string),
|
|
32
|
-
import("@decocms/apps/resend/actions/send" as string),
|
|
33
|
-
]);
|
|
34
|
-
const { configureResend } = resendClient as { configureResend: (cfg: any) => void };
|
|
35
|
-
const { sendEmail } = resendActions as { sendEmail: (props: any) => Promise<any> };
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Main
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
36
35
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
" Set DECO_CRYPTO_KEY to decrypt CMS secrets, or set RESEND_API_KEY as fallback.",
|
|
42
|
-
);
|
|
43
|
-
return {} as Record<string, InvokeAction>;
|
|
44
|
-
}
|
|
36
|
+
async function configureAllApps(
|
|
37
|
+
blocks: Record<string, unknown>,
|
|
38
|
+
): Promise<AppDefinitionWithHandlers[]> {
|
|
39
|
+
const apps: AppDefinitionWithHandlers[] = [];
|
|
45
40
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
});
|
|
41
|
+
for (const [blockKey, importMod] of Object.entries(APP_MODS)) {
|
|
42
|
+
const block = blocks[blockKey];
|
|
43
|
+
if (!block) continue;
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const mod = await importMod();
|
|
47
|
+
if (typeof mod.configure !== "function") continue;
|
|
54
48
|
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
49
|
+
const appDef: AppDefinition | null = await mod.configure(
|
|
50
|
+
block,
|
|
51
|
+
resolveSecret,
|
|
52
|
+
);
|
|
53
|
+
if (!appDef) continue;
|
|
54
|
+
|
|
55
|
+
// Attach explicit handlers from mod.ts (e.g. resend's pre-wrapped handlers)
|
|
56
|
+
const withHandlers: AppDefinitionWithHandlers = {
|
|
57
|
+
...appDef,
|
|
58
|
+
handlers: mod.handlers,
|
|
59
59
|
};
|
|
60
|
+
apps.push(withHandlers);
|
|
60
61
|
} catch {
|
|
61
|
-
//
|
|
62
|
-
return {};
|
|
62
|
+
// App not installed or configure failed — skip silently
|
|
63
63
|
}
|
|
64
|
-
}
|
|
65
|
-
};
|
|
64
|
+
}
|
|
66
65
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
// ---------------------------------------------------------------------------
|
|
66
|
+
return apps;
|
|
67
|
+
}
|
|
70
68
|
|
|
71
69
|
/**
|
|
72
70
|
* Auto-configure apps from CMS blocks.
|
|
@@ -75,37 +73,17 @@ const KNOWN_APPS: Record<string, AppAutoconfigurator> = {
|
|
|
75
73
|
export async function autoconfigApps(blocks: Record<string, unknown>) {
|
|
76
74
|
if (typeof document !== "undefined") return; // server-only
|
|
77
75
|
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const block = blocks[blockKey];
|
|
82
|
-
if (!block) continue;
|
|
83
|
-
|
|
84
|
-
try {
|
|
85
|
-
const appActions = await configurator(block);
|
|
86
|
-
Object.assign(actions, appActions);
|
|
87
|
-
} catch (e) {
|
|
88
|
-
console.warn(`[autoconfig] ${blockKey}:`, e);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
if (Object.keys(actions).length > 0) {
|
|
93
|
-
setInvokeActions(() => ({ ...actions }));
|
|
76
|
+
const apps = await configureAllApps(blocks);
|
|
77
|
+
if (apps.length > 0) {
|
|
78
|
+
await setupApps(apps);
|
|
94
79
|
}
|
|
95
80
|
|
|
96
81
|
// Re-configure on admin hot-reload
|
|
97
82
|
onChange(async (newBlocks) => {
|
|
98
83
|
if (typeof document !== "undefined") return;
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (!block) continue;
|
|
103
|
-
try {
|
|
104
|
-
Object.assign(updatedActions, await configurator(block));
|
|
105
|
-
} catch {}
|
|
106
|
-
}
|
|
107
|
-
if (Object.keys(updatedActions).length > 0) {
|
|
108
|
-
setInvokeActions(() => ({ ...updatedActions }));
|
|
84
|
+
const updatedApps = await configureAllApps(newBlocks);
|
|
85
|
+
if (updatedApps.length > 0) {
|
|
86
|
+
await setupApps(updatedApps);
|
|
109
87
|
}
|
|
110
88
|
});
|
|
111
89
|
}
|
package/src/sdk/invoke.ts
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Typed invoke
|
|
2
|
+
* Typed invoke proxies for client-side RPC to deco loaders/actions.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Two flavors:
|
|
5
|
+
*
|
|
6
|
+
* 1. `createInvokeProxy<T>()` — flat keys (e.g. `invoke["vtex/loaders/productList.ts"](props)`)
|
|
7
|
+
* 2. `createAppInvoke<T>()` — nested keys (e.g. `invoke.vtex.actions.checkout.addItemsToCart(props)`)
|
|
8
|
+
*
|
|
9
|
+
* Both POST to `/deco/invoke/{key}` under the hood.
|
|
6
10
|
*
|
|
7
11
|
* @example
|
|
8
12
|
* ```ts
|
|
9
|
-
* //
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* "vtex/loaders/intelligentSearch/productDetailsPage.ts": (props: { slug: string }) => Promise<ProductDetailsPage>;
|
|
13
|
-
* };
|
|
13
|
+
* // Flat proxy (legacy):
|
|
14
|
+
* const invoke = createInvokeProxy<Loaders>();
|
|
15
|
+
* await invoke["vtex/loaders/productList.ts"]({ query: "shoes" });
|
|
14
16
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* const products = await invoke["vtex/loaders/intelligentSearch/productList.ts"]({ query: "shoes" });
|
|
17
|
+
* // Nested proxy (recommended):
|
|
18
|
+
* const invoke = createAppInvoke();
|
|
19
|
+
* await invoke.vtex.actions.checkout.addItemsToCart({ orderFormId, orderItems });
|
|
19
20
|
* ```
|
|
20
21
|
*/
|
|
21
22
|
|
|
@@ -131,3 +132,113 @@ export function invokeQueryOptions<TResult = unknown>(
|
|
|
131
132
|
gcTime: options?.gcTime,
|
|
132
133
|
};
|
|
133
134
|
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Nested invoke proxy — createAppInvoke
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Converts flat slash-separated keys into a nested object type.
|
|
142
|
+
*
|
|
143
|
+
* @example
|
|
144
|
+
* ```ts
|
|
145
|
+
* type Map = {
|
|
146
|
+
* "vtex/actions/checkout/addItemsToCart": (props: CartInput) => Promise<OrderForm>;
|
|
147
|
+
* "vtex/loaders/catalog/getProduct": (props: { slug: string }) => Promise<Product>;
|
|
148
|
+
* };
|
|
149
|
+
* type Nested = NestedFromFlat<Map>;
|
|
150
|
+
* // { vtex: { actions: { checkout: { addItemsToCart: (props: CartInput) => Promise<OrderForm> } } } }
|
|
151
|
+
* ```
|
|
152
|
+
*/
|
|
153
|
+
type SplitFirst<S extends string> = S extends `${infer Head}/${infer Tail}`
|
|
154
|
+
? [Head, Tail]
|
|
155
|
+
: [S, never];
|
|
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;
|
|
165
|
+
|
|
166
|
+
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
|
|
167
|
+
k: infer I,
|
|
168
|
+
) => void
|
|
169
|
+
? I
|
|
170
|
+
: never;
|
|
171
|
+
|
|
172
|
+
type DeepMerge<T> = T extends object
|
|
173
|
+
? { [K in keyof T]: DeepMerge<T[K]> }
|
|
174
|
+
: T;
|
|
175
|
+
|
|
176
|
+
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
|
+
>
|
|
180
|
+
>;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Creates a typed nested invoke proxy.
|
|
184
|
+
*
|
|
185
|
+
* Each property access accumulates path segments. When called as a function,
|
|
186
|
+
* the segments are joined with "/" and POSTed to `/deco/invoke/{key}`.
|
|
187
|
+
* If the primary key returns 404, a `.ts` suffix variant is tried.
|
|
188
|
+
*
|
|
189
|
+
* @example
|
|
190
|
+
* ```ts
|
|
191
|
+
* import { createAppInvoke } from "@decocms/start/sdk/invoke";
|
|
192
|
+
*
|
|
193
|
+
* // Untyped (any):
|
|
194
|
+
* const invoke = createAppInvoke();
|
|
195
|
+
* await invoke.vtex.actions.checkout.addItemsToCart({ orderFormId, orderItems });
|
|
196
|
+
*
|
|
197
|
+
* // Typed (with handler map):
|
|
198
|
+
* type Handlers = {
|
|
199
|
+
* "vtex/actions/checkout/addItemsToCart": (props: CartInput) => Promise<OrderForm>;
|
|
200
|
+
* };
|
|
201
|
+
* const invoke = createAppInvoke<Handlers>();
|
|
202
|
+
* await invoke.vtex.actions.checkout.addItemsToCart({ orderFormId, orderItems });
|
|
203
|
+
* ```
|
|
204
|
+
*/
|
|
205
|
+
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 {
|
|
210
|
+
function buildProxy(path: string[]): any {
|
|
211
|
+
return new Proxy(
|
|
212
|
+
Object.assign(async (props: any) => {
|
|
213
|
+
const key = path.join("/");
|
|
214
|
+
for (const k of [key, `${key}.ts`]) {
|
|
215
|
+
const response = await fetch(`${basePath}/${k}`, {
|
|
216
|
+
method: "POST",
|
|
217
|
+
headers: { "Content-Type": "application/json" },
|
|
218
|
+
body: JSON.stringify(props ?? {}),
|
|
219
|
+
});
|
|
220
|
+
if (response.status === 404) { await response.body?.cancel(); continue; }
|
|
221
|
+
if (!response.ok) {
|
|
222
|
+
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
223
|
+
throw new Error(
|
|
224
|
+
`invoke(${k}) failed (${response.status}): ${(error as any).error || response.statusText}`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
return response.json();
|
|
228
|
+
}
|
|
229
|
+
throw new Error(`invoke(${key}) failed: handler not found`);
|
|
230
|
+
}, {}),
|
|
231
|
+
{
|
|
232
|
+
get(_target: any, prop: string | symbol) {
|
|
233
|
+
if (typeof prop === "symbol") return undefined;
|
|
234
|
+
if (prop === "then" || prop === "catch" || prop === "finally") {
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
return buildProxy([...path, prop]);
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return buildProxy([]);
|
|
244
|
+
}
|
|
@@ -44,6 +44,17 @@ export interface RequestContextData {
|
|
|
44
44
|
_isBot?: boolean;
|
|
45
45
|
/** Arbitrary bag for middleware to attach custom data. */
|
|
46
46
|
bag: Map<string, unknown>;
|
|
47
|
+
/**
|
|
48
|
+
* Outgoing response headers that handlers can write to.
|
|
49
|
+
* Invoke handlers (actions/loaders) use this to forward Set-Cookie
|
|
50
|
+
* and other headers from upstream APIs (e.g., VTEX checkout).
|
|
51
|
+
* The invoke HTTP handler copies these into the final Response.
|
|
52
|
+
*
|
|
53
|
+
* This mirrors deco-cx/deco's `ctx.response.headers` pattern where
|
|
54
|
+
* `proxySetCookie(apiResponse.headers, ctx.response.headers)` forwards
|
|
55
|
+
* cookies transparently.
|
|
56
|
+
*/
|
|
57
|
+
responseHeaders: Headers;
|
|
47
58
|
}
|
|
48
59
|
|
|
49
60
|
// -------------------------------------------------------------------------
|
|
@@ -87,6 +98,7 @@ export const RequestContext = {
|
|
|
87
98
|
signal: controller.signal,
|
|
88
99
|
startedAt: Date.now(),
|
|
89
100
|
bag: new Map(),
|
|
101
|
+
responseHeaders: new Headers(),
|
|
90
102
|
};
|
|
91
103
|
|
|
92
104
|
return storage.run(ctx, fn);
|
|
@@ -169,6 +181,17 @@ export const RequestContext = {
|
|
|
169
181
|
});
|
|
170
182
|
},
|
|
171
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Outgoing response headers. Handlers write here; the invoke endpoint
|
|
186
|
+
* copies them into the HTTP Response (mirroring ctx.response.headers
|
|
187
|
+
* from deco-cx/deco).
|
|
188
|
+
*/
|
|
189
|
+
get responseHeaders(): Headers {
|
|
190
|
+
const ctx = storage.getStore();
|
|
191
|
+
if (!ctx) throw new Error("RequestContext.responseHeaders accessed outside a request scope");
|
|
192
|
+
return ctx.responseHeaders;
|
|
193
|
+
},
|
|
194
|
+
|
|
172
195
|
/**
|
|
173
196
|
* Get/set arbitrary values in the request bag.
|
|
174
197
|
* Useful for middleware to pass data to loaders.
|
|
@@ -182,4 +205,23 @@ export const RequestContext = {
|
|
|
182
205
|
const ctx = storage.getStore();
|
|
183
206
|
ctx?.bag.set(key, value);
|
|
184
207
|
},
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Get an app's state from the request bag.
|
|
211
|
+
* Apps register their state via `setupApps()` which injects it
|
|
212
|
+
* into the bag as `app:{name}:state` before each request.
|
|
213
|
+
*
|
|
214
|
+
* @example
|
|
215
|
+
* ```ts
|
|
216
|
+
* import { RequestContext } from "@decocms/start/sdk/requestContext";
|
|
217
|
+
* import type { VtexState } from "@decocms/apps/vtex/mod";
|
|
218
|
+
*
|
|
219
|
+
* const vtex = RequestContext.getAppState<VtexState>("vtex");
|
|
220
|
+
* if (vtex) console.log(vtex.config.account);
|
|
221
|
+
* ```
|
|
222
|
+
*/
|
|
223
|
+
getAppState<T>(appName: string): T | undefined {
|
|
224
|
+
const ctx = storage.getStore();
|
|
225
|
+
return ctx?.bag.get(`app:${appName}:state`) as T | undefined;
|
|
226
|
+
},
|
|
185
227
|
};
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App system integration pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Consumes AppDefinition objects from @decocms/apps and automates:
|
|
5
|
+
* 1. Invoke handler registration (from manifest + explicit handlers)
|
|
6
|
+
* 2. Section registration (when manifest.sections is available)
|
|
7
|
+
* 3. App middleware registration (with state injection into RequestContext)
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { setupApps } from "@decocms/start/sdk/setupApps";
|
|
12
|
+
* import * as vtexApp from "@decocms/apps/vtex/mod";
|
|
13
|
+
* import * as resendApp from "@decocms/apps/resend/mod";
|
|
14
|
+
*
|
|
15
|
+
* const vtex = await vtexApp.configure(blocks["deco-vtex"], resolveSecret);
|
|
16
|
+
* const resend = await resendApp.configure(blocks["deco-resend"], resolveSecret);
|
|
17
|
+
*
|
|
18
|
+
* await setupApps([vtex, resend].filter(Boolean));
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { clearInvokeHandlers, registerInvokeHandlers } from "../admin/invoke";
|
|
23
|
+
import { registerSections } from "../cms/registry";
|
|
24
|
+
import { RequestContext } from "./requestContext";
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Types — mirrors @decocms/apps/commerce/app-types without importing it
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
export interface AppManifest {
|
|
31
|
+
name: string;
|
|
32
|
+
loaders: Record<string, Record<string, unknown>>;
|
|
33
|
+
actions: Record<string, Record<string, unknown>>;
|
|
34
|
+
sections?: Record<string, () => Promise<any>>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AppMiddleware {
|
|
38
|
+
(request: Request, next: () => Promise<Response>): Promise<Response>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface AppDefinition<TState = unknown> {
|
|
42
|
+
name: string;
|
|
43
|
+
manifest: AppManifest;
|
|
44
|
+
state: TState;
|
|
45
|
+
middleware?: AppMiddleware;
|
|
46
|
+
dependencies?: AppDefinition[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Extended definition with optional explicit handlers.
|
|
51
|
+
* autoconfigApps() attaches mod.handlers here before calling setupApps().
|
|
52
|
+
*/
|
|
53
|
+
export interface AppDefinitionWithHandlers<TState = unknown>
|
|
54
|
+
extends AppDefinition<TState> {
|
|
55
|
+
/** Pre-wrapped handlers from the app's mod.ts (e.g. unwrapped VTEX actions). */
|
|
56
|
+
handlers?: Record<string, (props: any, request: Request) => Promise<any>>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// App middleware registry
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/** Per-app state entries — injected into RequestContext.bag on every request. */
|
|
64
|
+
const appStates: Array<{ name: string; state: unknown }> = [];
|
|
65
|
+
|
|
66
|
+
const appMiddlewares: Array<{
|
|
67
|
+
name: string;
|
|
68
|
+
middleware: AppMiddleware;
|
|
69
|
+
}> = [];
|
|
70
|
+
|
|
71
|
+
function registerAppState(name: string, state: unknown) {
|
|
72
|
+
appStates.push({ name, state });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function registerAppMiddleware(
|
|
76
|
+
name: string,
|
|
77
|
+
mw: AppMiddleware,
|
|
78
|
+
) {
|
|
79
|
+
appMiddlewares.push({ name, middleware: mw });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Clear all registrations. Called before re-running setupApps()
|
|
84
|
+
* on admin hot-reload to prevent duplicate middleware/state entries.
|
|
85
|
+
*/
|
|
86
|
+
function clearRegistrations() {
|
|
87
|
+
appStates.length = 0;
|
|
88
|
+
appMiddlewares.length = 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Returns a chained middleware that runs all registered app middlewares.
|
|
93
|
+
* The site wires this into its own createMiddleware() chain.
|
|
94
|
+
*
|
|
95
|
+
* Before running app middlewares, all app states are injected into
|
|
96
|
+
* RequestContext.bag so loaders can access them via getAppState().
|
|
97
|
+
*
|
|
98
|
+
* Returns undefined if no app states or middlewares were registered.
|
|
99
|
+
*/
|
|
100
|
+
export function getAppMiddleware(): AppMiddleware | undefined {
|
|
101
|
+
if (appStates.length === 0 && appMiddlewares.length === 0) return undefined;
|
|
102
|
+
|
|
103
|
+
return async (request, next) => {
|
|
104
|
+
// Inject all app states into RequestContext bag
|
|
105
|
+
for (const { name, state } of appStates) {
|
|
106
|
+
RequestContext.setBag(`app:${name}:state`, state);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Chain app middlewares (first registered runs outermost)
|
|
110
|
+
if (appMiddlewares.length === 0) return next();
|
|
111
|
+
const run = async (i: number): Promise<Response> => {
|
|
112
|
+
if (i >= appMiddlewares.length) return next();
|
|
113
|
+
return appMiddlewares[i].middleware(request, () => run(i + 1));
|
|
114
|
+
};
|
|
115
|
+
return run(0);
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Dependency flattening
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Topological sort: dependencies before parents.
|
|
125
|
+
* Combined with first-wins registration in registerInvokeHandlers,
|
|
126
|
+
* this means parent apps can override handlers from their dependencies
|
|
127
|
+
* by providing explicit `handlers` (registered before manifest flatten).
|
|
128
|
+
*/
|
|
129
|
+
function flattenDependencies(apps: AppDefinition[]): AppDefinition[] {
|
|
130
|
+
const seen = new Set<string>();
|
|
131
|
+
const result: AppDefinition[] = [];
|
|
132
|
+
|
|
133
|
+
function visit(app: AppDefinition) {
|
|
134
|
+
if (seen.has(app.name)) return;
|
|
135
|
+
seen.add(app.name);
|
|
136
|
+
if (app.dependencies) {
|
|
137
|
+
for (const dep of app.dependencies) visit(dep);
|
|
138
|
+
}
|
|
139
|
+
result.push(app);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for (const app of apps) visit(app);
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Main pipeline
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Initialize apps from their AppDefinitions.
|
|
152
|
+
*
|
|
153
|
+
* Call once in setup.ts after configuring apps via their mod.configure().
|
|
154
|
+
* Handles: invoke handler registration, section registration, middleware setup.
|
|
155
|
+
*/
|
|
156
|
+
export async function setupApps(
|
|
157
|
+
apps: Array<AppDefinitionWithHandlers | AppDefinition>,
|
|
158
|
+
): Promise<void> {
|
|
159
|
+
if (typeof document !== "undefined") return; // server-only
|
|
160
|
+
|
|
161
|
+
// Clear previous registrations (safe for hot-reload via onChange)
|
|
162
|
+
clearRegistrations();
|
|
163
|
+
clearInvokeHandlers();
|
|
164
|
+
|
|
165
|
+
for (const app of flattenDependencies(apps as AppDefinition[])) {
|
|
166
|
+
const appWithHandlers = app as AppDefinitionWithHandlers;
|
|
167
|
+
|
|
168
|
+
// 1. Register explicit handlers (pre-unwrapped by the app, e.g. resend)
|
|
169
|
+
if (appWithHandlers.handlers) {
|
|
170
|
+
registerInvokeHandlers(appWithHandlers.handlers);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 2. Flatten manifest modules → individual invoke handlers
|
|
174
|
+
// manifest.actions["vtex/actions/checkout"] = { getOrCreateCart, addItemsToCart, ... }
|
|
175
|
+
// → register "vtex/actions/checkout/getOrCreateCart" as handler
|
|
176
|
+
for (const category of ["loaders", "actions"] as const) {
|
|
177
|
+
const modules = app.manifest[category];
|
|
178
|
+
if (!modules) continue;
|
|
179
|
+
|
|
180
|
+
for (const [moduleKey, moduleExports] of Object.entries(modules)) {
|
|
181
|
+
for (const [fnName, fn] of Object.entries(
|
|
182
|
+
moduleExports as Record<string, unknown>,
|
|
183
|
+
)) {
|
|
184
|
+
if (typeof fn !== "function") continue;
|
|
185
|
+
const key = `${moduleKey}/${fnName}`;
|
|
186
|
+
const handler = (props: any, req: Request) =>
|
|
187
|
+
(fn as Function)(props, req);
|
|
188
|
+
registerInvokeHandlers({
|
|
189
|
+
[key]: handler,
|
|
190
|
+
[`${key}.ts`]: handler,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 3. Register sections from manifest (future — when apps export sections)
|
|
197
|
+
if (app.manifest.sections) {
|
|
198
|
+
registerSections(
|
|
199
|
+
app.manifest.sections as Record<string, () => Promise<any>>,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 4. Always register app state (so getAppState() works for all apps)
|
|
204
|
+
registerAppState(app.name, app.state);
|
|
205
|
+
|
|
206
|
+
// 5. Register middleware (optional — not all apps have middleware)
|
|
207
|
+
if (app.middleware) {
|
|
208
|
+
registerAppMiddleware(app.name, app.middleware);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
package/src/sdk/workerEntry.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { buildHtmlShell } from "./htmlShell";
|
|
|
36
36
|
import { cleanPathForCacheKey } from "./urlUtils";
|
|
37
37
|
import { isMobileUA } from "./useDevice";
|
|
38
38
|
import { getRenderShellConfig } from "../admin/setup";
|
|
39
|
+
import { RequestContext } from "./requestContext";
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
42
|
* Append Link preload headers for CSS and fonts so the browser starts
|
|
@@ -653,6 +654,10 @@ export function createDecoWorkerEntry(
|
|
|
653
654
|
env: Record<string, unknown>,
|
|
654
655
|
ctx: WorkerExecutionContext,
|
|
655
656
|
): Promise<Response> {
|
|
657
|
+
// Wrap the entire request in a RequestContext so that all code
|
|
658
|
+
// in the call stack (loaders, invoke handlers, vtexFetchWithCookies)
|
|
659
|
+
// can access the request and write response headers.
|
|
660
|
+
return RequestContext.run(request, async () => {
|
|
656
661
|
const url = new URL(request.url);
|
|
657
662
|
|
|
658
663
|
// Admin routes (/_meta, /.decofile, /live/previews) — always handled first
|
|
@@ -879,6 +884,7 @@ export function createDecoWorkerEntry(
|
|
|
879
884
|
// the stream in Workers runtime, causing Error 1101.
|
|
880
885
|
storeInCache(origin);
|
|
881
886
|
return dressResponse(origin, "MISS");
|
|
887
|
+
}); // end RequestContext.run()
|
|
882
888
|
},
|
|
883
889
|
};
|
|
884
890
|
}
|