@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "0.38.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",
@@ -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
  };
@@ -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
+ }
@@ -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
  }