#!/usr/bin/env tsx /** * Internal implementation detail of generate.ts (the unified orchestrator) * -- invoke `generate` instead; direct invocation remains possible but * undocumented. */ /** * Scans @decocms/apps-vtex's invoke.ts and generates a site-local invoke file * with top-level createServerFn declarations. * * TanStack Start's compiler only transforms createServerFn().handler() when * the call is at module top-level (assigned to a const). The factory pattern * used in @decocms/apps-vtex/invoke.ts causes the "fast path" in the compiler * to skip the .handler() calls because they're inside a function body. * * This script generates an equivalent file where each server function is a * top-level const, which the compiler can correctly transform into RPC stubs. * * Unlike the other four generators (generate-blocks, generate-loaders, * generate-sections, generate-schema), this one's default output stays under * `src/`, not `.deco/`. The other four emit inert framework artifacts (data * snapshots / metadata) that the framework loads at runtime — those belong * next to the rest of the framework's own generated state. This file emits * actual app server-function code (`createServerFn().handler()` calls) that * TanStack Start's own build-time compiler transforms into RPC stubs; it's * compiled and shipped as part of the site's application code, so it lives * in `src/` alongside the code it's compiled with, not in `.deco/`. * * This is empirically load-bearing, not taste (tested 2026-07-08 on a real * site): with the file moved to `.deco/invoke.gen.ts`, the CLIENT half of * the Start compiler works fine (stubs generated, IDs re-derived from the * new path), but the SERVER half cannot resolve the split module back to an * executable handler — every `/_serverFn/...` call returns an unhandled 500 * with no logged stack, i.e. every cart/session/MasterData action dies. * Same site, file back under `src/`: identical RPC probe returns 200 with a * real VTEX orderForm. Do not move this default without re-running that * round-trip test end to end. * * Usage (from site root): * npx tsx node_modules/@decocms/blocks-cli/scripts/generate-invoke.ts * * Env / CLI: * --out-file override output (default: src/server/invoke.gen.ts) * --apps-dir override @decocms/apps-vtex location (default: auto-resolve from node_modules) */ import fs from "node:fs"; import path from "node:path"; import { Project, type PropertyAssignment, SyntaxKind } from "ts-morph"; const args = process.argv.slice(2); function arg(name: string, fallback: string): string { const idx = args.indexOf(`--${name}`); return idx !== -1 && args[idx + 1] ? args[idx + 1] : fallback; } const cwd = process.cwd(); const outFile = path.resolve(cwd, arg("out-file", "src/server/invoke.gen.ts")); // Security guard: generic, admin-credentialed VTEX MasterData CRUD must never // be emitted as a client-callable /_serverFn endpoint. Each emitted action // becomes a public, unauthenticated createServerFn; a generic action that takes // a client-supplied `entity` and hits MasterData with the app's appKey/appToken // is broken access control (e.g. searchDocuments({ entity: "CL" }) dumps // customer PII). These names are refused even if present in invoke.vtex.actions, // so the exclusion can't silently regress via an edit to the source contract. // Sites needing MasterData define a narrow, entity-pinned, authenticated action // in their own src/server/invoke.ts instead (the masterData.ts functions stay // exported for that server-side use). const PRIVILEGED_ACTIONS = new Set([ "createDocument", "getDocument", "patchDocument", "searchDocuments", "searchDocumentsFull", "uploadAttachment", ]); function resolveAppsDir(): string { const explicit = arg("apps-dir", ""); if (explicit) return path.resolve(cwd, explicit); // Try common locations: the installed @decocms/apps-vtex package first, // then a raw apps-start checkout's vtex/ subdirectory as a legacy fallback // for anyone still developing against the pre-split monorepo. // // For each root, invoke.ts may sit directly at the root (legacy dev // checkouts) or under src/ — the published @decocms/apps-vtex tarball // ships its sources under src/ (`"files": ["src"]`, `"main": // "./src/index.ts"`), so on any site with npm-installed 7.x packages the // file lives at node_modules/@decocms/apps-vtex/src/invoke.ts. The src/ // nesting doesn't affect the emitted imports: relative `./actions/*` // specifiers are rewritten to `@decocms/apps-vtex/actions/*`, which the // package's exports map points back into src/. const roots = [ path.resolve(cwd, "node_modules/@decocms/apps-vtex"), path.resolve(cwd, "../apps-start/vtex"), ]; for (const root of roots) { for (const c of [root, path.join(root, "src")]) { if (fs.existsSync(path.join(c, "invoke.ts"))) return c; } } throw new Error("Could not find @decocms/apps-vtex. Use --apps-dir to specify its location."); } const appsDir = resolveAppsDir(); const invokeFile = path.join(appsDir, "invoke.ts"); if (!fs.existsSync(invokeFile)) { console.error(`invoke.ts not found at: ${invokeFile}`); process.exit(1); } // --------------------------------------------------------------------------- // Parse the source invoke.ts to extract action definitions // --------------------------------------------------------------------------- interface ActionDef { name: string; /** The import source for the action function (e.g., "@decocms/apps-vtex/actions/checkout") */ importSource: string; /** The imported function name (e.g., "addItemsToCart") */ importedFn: string; /** The input type as a string (e.g., "{ orderFormId: string; ... }") */ inputType: string; /** The return type as a string (e.g., "OrderForm") */ returnType: string; /** Whether to unwrap VtexFetchResult */ unwrap: boolean; /** The body of the action call (e.g., "addItemsToCart(input.orderFormId, input.orderItems)") */ callBody: string; } const project = new Project({ compilerOptions: { strict: true } }); const sourceFile = project.addSourceFileAtPath(invokeFile); // Collect all imports to know which functions come from where const importMap = new Map(); for (const imp of sourceFile.getImportDeclarations()) { const source = imp.getModuleSpecifierValue(); for (const named of imp.getNamedImports()) { const localName = named.getName(); const importedName = named.getAliasNode()?.getText() || localName; importMap.set(localName, { source: source.startsWith("./") ? `@decocms/apps-vtex/${source.slice(2)}` : source, importedName: localName, }); } } // Collect type imports const typeImportMap = new Map(); for (const imp of sourceFile.getImportDeclarations()) { if (!imp.isTypeOnly()) { for (const named of imp.getNamedImports()) { if (named.isTypeOnly()) { const localName = named.getName(); const source = imp.getModuleSpecifierValue(); typeImportMap.set(localName, { source: source.startsWith("./") ? `@decocms/apps-vtex/${source.slice(2)}` : source, importedName: localName, }); } } } if (imp.isTypeOnly()) { const source = imp.getModuleSpecifierValue(); for (const named of imp.getNamedImports()) { const localName = named.getName(); typeImportMap.set(localName, { source: source.startsWith("./") ? `@decocms/apps-vtex/${source.slice(2)}` : source, importedName: localName, }); } } } // Find the invoke const and extract actions const invokeVar = sourceFile.getVariableDeclaration("invoke"); if (!invokeVar) { console.error("Could not find 'export const invoke' in invoke.ts"); process.exit(1); } const actions: ActionDef[] = []; const invokeInit = invokeVar.getInitializer(); if (!invokeInit) { console.error("invoke variable has no initializer"); process.exit(1); } // Navigate: invoke → .vtex → .actions → each property const vtexProp = invokeInit .asKindOrThrow(SyntaxKind.AsExpression) .getExpression() .asKindOrThrow(SyntaxKind.ObjectLiteralExpression) .getProperty("vtex"); if (!vtexProp) { console.error("Could not find 'vtex' property in invoke object"); process.exit(1); } const vtexObj = (vtexProp as PropertyAssignment) .getInitializer()! .asKindOrThrow(SyntaxKind.ObjectLiteralExpression); const actionsProp = vtexObj.getProperty("actions"); if (!actionsProp) { console.error("Could not find 'actions' property in vtex object"); process.exit(1); } const actionsObj = (actionsProp as PropertyAssignment) .getInitializer()! .asKindOrThrow(SyntaxKind.ObjectLiteralExpression); for (const prop of actionsObj.getProperties()) { if (prop.getKind() !== SyntaxKind.PropertyAssignment) continue; const pa = prop as PropertyAssignment; const name = pa.getName(); // Never emit generic admin-credentialed MasterData CRUD as a serverFn — see // PRIVILEGED_ACTIONS above. Skipping here means no top-level createServerFn // const and no vtexActions entry is generated for it. if (PRIVILEGED_ACTIONS.has(name)) { console.warn( `⚠ Skipping privileged action "${name}" — generic MasterData CRUD is not exposed as a serverFn (security).`, ); continue; } const initText = pa.getInitializer()!.getText(); // Check if it uses createInvokeFn with unwrap const unwrap = initText.includes("unwrap: true"); // Extract the arrow function body from createInvokeFn((input: ...) => ...) // We'll parse the call expression to get the action call const callExpr = pa.getInitializer()!; let inputType = "any"; let callBody = ""; // Recursively unwrap AsExpression chains (e.g. `expr as unknown as Type`) let createInvokeFnCall = callExpr; while (createInvokeFnCall.getKind() === SyntaxKind.AsExpression) { createInvokeFnCall = createInvokeFnCall.asKindOrThrow(SyntaxKind.AsExpression).getExpression(); } // Now we have createInvokeFn(...) call if (createInvokeFnCall.getKind() === SyntaxKind.CallExpression) { const callArgs = createInvokeFnCall.asKindOrThrow(SyntaxKind.CallExpression).getArguments(); if (callArgs.length >= 1) { const arrowFn = callArgs[0]; if (arrowFn.getKind() === SyntaxKind.ArrowFunction) { const arrow = arrowFn.asKindOrThrow(SyntaxKind.ArrowFunction); const params = arrow.getParameters(); if (params.length >= 1) { const paramType = params[0].getTypeNode()?.getText() || "any"; inputType = paramType; } // Get the body (the actual action call) const body = arrow.getBody(); callBody = body.getText(); // If body is a block, extract the expression if (callBody.startsWith("{")) { // It's a block body — skip for now, use simplified version callBody = ""; } } } } // Determine which function is being called let importedFn = ""; let importSource = ""; for (const [fnName, info] of importMap.entries()) { if (callBody.includes(`${fnName}(`)) { importedFn = fnName; importSource = info.source; break; } } // Extract the return type from the outermost "as" assertion. // For `expr as unknown as (ctx: ...) => Promise`, the outermost // AsExpression has the function type with Promise. let returnType = "any"; if (callExpr.getKind() === SyntaxKind.AsExpression) { const asExpr = callExpr.asKindOrThrow(SyntaxKind.AsExpression); const typeText = asExpr.getTypeNode()?.getText() || ""; if (typeText !== "unknown") { const promiseMatch = typeText.match(/Promise<(.+)>$/s); if (promiseMatch) { returnType = promiseMatch[1].trim(); } } } actions.push({ name, importSource, importedFn, inputType, returnType, unwrap, callBody, }); } // --------------------------------------------------------------------------- // Generate the output file // --------------------------------------------------------------------------- // Collect unique imports needed const fnImports = new Map>(); const typeImports = new Map>(); for (const action of actions) { if (action.importSource && action.importedFn) { if (!fnImports.has(action.importSource)) { fnImports.set(action.importSource, new Set()); } fnImports.get(action.importSource)!.add(action.importedFn); } } // Add type imports referenced in inputType or returnType for (const action of actions) { const allText = action.inputType + action.returnType + action.callBody; for (const [typeName, info] of typeImportMap.entries()) { if (allText.includes(typeName)) { if (!typeImports.has(info.source)) { typeImports.set(info.source, new Set()); } typeImports.get(info.source)!.add(typeName); } } // Also check value imports that appear in the types (like SimulationItem) for (const [fnName, info] of importMap.entries()) { if (action.inputType.includes(fnName) && !fnImports.get(info.source)?.has(fnName)) { if (!typeImports.has(info.source)) { typeImports.set(info.source, new Set()); } typeImports.get(info.source)!.add(fnName); } } } // Count how many actually parsed vs. stubbed const parsed = actions.filter((a) => a.importedFn).length; const stubbed = actions.length - parsed; if (stubbed > 0) { console.warn(`⚠ ${stubbed} action(s) could not be parsed — generated as stubs:`); for (const a of actions) { if (!a.importedFn) console.warn(` - ${a.name}`); } } // Build output let out = `// Auto-generated by @decocms/blocks-cli/scripts/generate-invoke.ts // Do not edit manually. Re-run the generator to update. // // Each server function is a top-level const so TanStack Start's compiler // can transform createServerFn().handler() into RPC stubs on the client. // // Site-specific extensions: import { vtexActions } from this file and merge // with your own actions in a separate invoke.ts. import { createServerFn } from "@tanstack/react-start"; `; // Add function imports for (const [source, fns] of fnImports) { out += `import { ${[...fns].join(", ")} } from "${source}";\n`; } // Add type imports for (const [source, types] of typeImports) { // Don't duplicate if already imported as value const valueImports = fnImports.get(source); const onlyTypes = [...types].filter((t) => !valueImports?.has(t)); if (onlyTypes.length > 0) { out += `import type { ${onlyTypes.join(", ")} } from "${source}";\n`; } } // Imports required by the forwardResponseCookies bridge. They live next // to the framework's RequestContext (where vtexFetchWithCookies stashes // inbound Set-Cookie headers) and TanStack Start's response-header API // (where we copy them onto the actual HTTP response). out += `import { getResponseHeaders, setResponseHeader, } from "@tanstack/react-start/server"; import { RequestContext } from "@decocms/blocks/sdk/requestContext"; `; out += ` function unwrapResult(result: unknown): T { if (result && typeof result === "object" && "data" in result) { return (result as { data: T }).data; } return result as T; } /** * Forward Set-Cookie headers captured in RequestContext.responseHeaders * (by vtexFetchWithCookies) into TanStack Start's HTTP response. * * Without this bridge, HttpOnly cookies like \`checkout.vtex.com\` and * \`CheckoutOrderFormOwnership\` that VTEX returns on cart-action responses * stay trapped inside the AsyncLocalStorage-backed RequestContext and * never reach the browser. The storefront's mini-cart drifts away from * VTEX's server-side orderForm, and the user lands on /checkout with an * empty cart. * * Cheap no-op when no Set-Cookie was captured (e.g. read-only actions), * so every handler can call it unconditionally without branching on * whether the underlying action uses vtexFetchWithCookies. */ function forwardResponseCookies(): void { const ctx = RequestContext.current; if (!ctx) return; const captured = typeof ctx.responseHeaders.getSetCookie === "function" ? ctx.responseHeaders.getSetCookie() : []; if (captured.length === 0) return; const existing = typeof getResponseHeaders().getSetCookie === "function" ? getResponseHeaders().getSetCookie() : []; setResponseHeader("set-cookie", [...existing, ...captured]); } // --------------------------------------------------------------------------- // Top-level server function declarations // --------------------------------------------------------------------------- `; for (const action of actions) { const varName = `$${action.name}`; if (action.importedFn) { // Emit the wrapper body verbatim. The arrow function in // @decocms/apps-vtex/invoke.ts is the contract that maps the external // invoke shape (what storefront callers send) to the internal action // shape (what vtex/actions/* expects). Most wrappers are direct // pass-throughs (`actionFn(data)`) but some adapt the payload // (e.g. `createSession({ data })` wraps a flat session payload into // CreateSessionProps). Hard-coding `${importedFn}(data)` silently // dropped the wrap, producing typecheck errors at the call site of // every adapting wrapper. // // Invariant: when action.importedFn is set, action.callBody was // non-empty and contained `${importedFn}(` (that's how importedFn was // discovered in the first place — see the importMap scan above). // Block bodies clear callBody to "", which forces importedFn to "" // and routes to the stub branch below. // // The arrow's parameter is `data` by convention across every wrapper // in vtex/invoke.ts, and the generated handler destructures `{ data }` // from the validator output, so callBody's `data` references resolve // to the handler's local `data` without any rename. // // forwardResponseCookies() runs AFTER the action awaits, so any // Set-Cookie that vtexFetchWithCookies captured onto // RequestContext.responseHeaders gets promoted to the actual HTTP // response. Safe no-op when the action didn't touch responseHeaders // (e.g. masterData reads), so it's applied unconditionally — the // alternative (a static allow-list of cookie-bearing actions) // silently misses any new actions that start propagating cookies. if (action.unwrap) { out += `\nconst ${varName} = createServerFn({ method: "POST" }) .inputValidator((data: ${action.inputType}) => data) .handler(async ({ data }): Promise => { const result = await ${action.callBody}; forwardResponseCookies(); return unwrapResult(result); });\n`; } else { out += `\nconst ${varName} = createServerFn({ method: "POST" }) .inputValidator((data: ${action.inputType}) => data) .handler(async ({ data }): Promise => { const result = await ${action.callBody}; forwardResponseCookies(); return result; });\n`; } } else { // Fallback: couldn't parse — generate a stub out += `\n// TODO: could not auto-generate ${action.name} — add manually\nconst ${varName} = createServerFn({ method: "POST" }) .handler(async () => { throw new Error("${action.name}: not implemented — regenerate invoke"); });\n`; } } // Generate the vtexActions object (for composability with site-specific actions) out += ` // --------------------------------------------------------------------------- // Typed VTEX actions map — merge with site-specific actions in your invoke.ts // --------------------------------------------------------------------------- export const vtexActions = { `; for (const action of actions) { const varName = `$${action.name}`; if (action.returnType !== "any") { out += ` ${action.name}: ${varName} as unknown as (ctx: { data: ${action.inputType} }) => Promise<${action.returnType}>,\n`; } else { out += ` ${action.name}: ${varName},\n`; } } out += `} as const; // Re-export OrderForm type (commonly imported from invoke by site components) export type { OrderForm } from "@decocms/apps-vtex/types"; // --------------------------------------------------------------------------- // Default invoke object — import this if you don't need site extensions // --------------------------------------------------------------------------- export const invoke = { vtex: { actions: vtexActions, }, } as const; `; // Write output fs.mkdirSync(path.dirname(outFile), { recursive: true }); fs.writeFileSync(outFile, out); console.log(`Generated ${actions.length} server functions → ${path.relative(cwd, outFile)}`);