@decocms/start 0.37.3 → 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.
@@ -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";
@@ -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 loader/action returns a Response object,
130
- // forward it as-is (preserving headers like Set-Cookie). This matches
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
- return new Response(JSON.stringify(filtered), { status: 200, headers: JSON_HEADERS });
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
  }
@@ -8,7 +8,13 @@
8
8
  * import from "@decocms/start/admin" instead.
9
9
  */
10
10
 
11
- export { type InvokeAction, type InvokeLoader, setInvokeActions, setInvokeLoaders } from "./invoke";
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,
@@ -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") and:
5
- * 1. Configures the app client with CMS-provided credentials
6
- * 2. Registers invoke handlers so `invoke.app.actions.*` works via the proxy
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 + configure
23
+ // Known app block keys → dynamic import of their mod.ts
20
24
  // ---------------------------------------------------------------------------
21
25
 
22
- interface AppAutoconfigurator {
23
- /** Try to import, configure, and return invoke actions for this app */
24
- (blockData: unknown): Promise<Record<string, InvokeAction>>;
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
- const KNOWN_APPS: Record<string, AppAutoconfigurator> = {
28
- "deco-resend": async (block: any): Promise<Record<string, InvokeAction>> => {
29
- try {
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
- const apiKey = await resolveSecret(block.apiKey, "RESEND_API_KEY");
38
- if (!apiKey) {
39
- console.warn(
40
- "[autoconfig] deco-resend: no API key found." +
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
- configureResend({
47
- apiKey,
48
- emailFrom: block.emailFrom
49
- ? `${block.emailFrom.name || "Contact"} ${block.emailFrom.domain || "<onboarding@resend.dev>"}`
50
- : undefined,
51
- emailTo: block.emailTo,
52
- subject: block.subject,
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 handler: InvokeAction = async (props: any) => sendEmail(props);
56
- return {
57
- "resend/actions/emails/send": handler,
58
- "resend/actions/emails/send.ts": handler,
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
- // @decocms/apps not installed or doesn't have resend — skip
62
- return {};
62
+ // App not installed or configure failed — skip silently
63
63
  }
64
- },
65
- };
64
+ }
66
65
 
67
- // ---------------------------------------------------------------------------
68
- // Main
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 actions: Record<string, InvokeAction> = {};
79
-
80
- for (const [blockKey, configurator] of Object.entries(KNOWN_APPS)) {
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 updatedActions: Record<string, InvokeAction> = {};
100
- for (const [blockKey, configurator] of Object.entries(KNOWN_APPS)) {
101
- const block = newBlocks[blockKey];
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 proxy for client-side RPC to deco loaders/actions.
2
+ * Typed invoke proxies for client-side RPC to deco loaders/actions.
3
3
  *
4
- * Creates a Proxy object that maps loader keys to `POST /deco/invoke/:key` calls,
5
- * providing a type-safe, ergonomic API for client-side data fetching.
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
- * // Define your loader registry type
10
- * type Loaders = {
11
- * "vtex/loaders/intelligentSearch/productList.ts": (props: { query: string }) => Promise<Product[]>;
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
- * const invoke = createInvokeProxy<Loaders>("/deco/invoke");
16
- *
17
- * // Type-safe calls:
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
  };