@decocms/start 5.4.2 → 6.0.1

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": "5.4.2",
3
+ "version": "6.0.1",
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",
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Integration test for scripts/generate-invoke.ts.
3
+ *
4
+ * The generator scans a `vtex/invoke.ts` file from @decocms/apps and emits a
5
+ * site-local `src/server/invoke.gen.ts` with top-level `createServerFn`
6
+ * declarations. The piece we care most about locking is the Set-Cookie
7
+ * bridge: every handler must call `forwardResponseCookies()` after the
8
+ * action awaits, so VTEX-set cookies captured by `vtexFetchWithCookies`
9
+ * reach the browser via TanStack Start's HTTP response. Without this,
10
+ * `checkout.vtex.com` never reaches the browser, the storefront's
11
+ * mini-cart and VTEX's server-side orderForm drift apart, and /checkout
12
+ * loads with an empty cart.
13
+ *
14
+ * We exercise the generator end-to-end against a minimal fixture
15
+ * `invoke.ts` rather than unit-test internal helpers — the failure
16
+ * mode we want to prevent (a missing `forwardResponseCookies()` call
17
+ * in the emit string) only shows up in the produced source text.
18
+ */
19
+
20
+ import { spawnSync } from "node:child_process";
21
+ import * as fs from "node:fs";
22
+ import * as os from "node:os";
23
+ import * as path from "node:path";
24
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
25
+
26
+ const GENERATOR = path.resolve(__dirname, "generate-invoke.ts");
27
+
28
+ const FIXTURE_INVOKE_TS = `\
29
+ import { createInvokeFn } from "@decocms/start/sdk/createInvoke";
30
+ import { getOrCreateCart, simulateCart } from "./actions/checkout";
31
+ import { createSession } from "./actions/session";
32
+ import type { OrderForm } from "./types";
33
+
34
+ export const invoke = {
35
+ vtex: {
36
+ actions: {
37
+ // Direct pass-through wrapper — most common shape.
38
+ getOrCreateCart: createInvokeFn(
39
+ (data: { orderFormId?: string }) => getOrCreateCart(data),
40
+ ) as unknown as (ctx: { data: { orderFormId?: string } }) => Promise<OrderForm>,
41
+
42
+ simulateCart: createInvokeFn(
43
+ (data: { postalCode: string }) => simulateCart(data),
44
+ ),
45
+
46
+ // Adapting wrapper — payload gets wrapped into the action's
47
+ // props shape. The generator MUST preserve this wrap or the
48
+ // action call typechecks against the wrong props type.
49
+ createSession: createInvokeFn(
50
+ (data: Record<string, any>) => createSession({ data }),
51
+ ),
52
+ },
53
+ },
54
+ } as const;
55
+ `;
56
+
57
+ // Minimal stubs the generator's import-resolution doesn't *execute* but
58
+ // ts-morph parses these to populate the importMap. We only need names to
59
+ // resolve.
60
+ const FIXTURE_ACTIONS_CHECKOUT_TS = `\
61
+ export async function getOrCreateCart(_data: any): Promise<any> { return null; }
62
+ export async function simulateCart(_data: any): Promise<any> { return null; }
63
+ `;
64
+ const FIXTURE_ACTIONS_SESSION_TS = `\
65
+ export interface CreateSessionProps { data: Record<string, any>; }
66
+ export async function createSession(_props: CreateSessionProps): Promise<any> { return null; }
67
+ `;
68
+ const FIXTURE_TYPES_TS = `export type OrderForm = unknown;\n`;
69
+
70
+ describe("generate-invoke.ts — output shape", () => {
71
+ // The fixture is read-only across tests: every assertion runs against
72
+ // the same generated `invoke.gen.ts`. Running the generator once in
73
+ // `beforeAll` keeps the test fast (each `npx tsx` spawn is ~3-5s) and
74
+ // sidesteps the vitest 5s default per-test timeout that this suite was
75
+ // tipping over once we grew the fixture.
76
+ let appsDir: string;
77
+ let siteDir: string;
78
+ let outFile: string;
79
+ let generatedOutput: string;
80
+ let generatorStatus: number | null;
81
+
82
+ beforeAll(() => {
83
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gen-invoke-"));
84
+ appsDir = path.join(tmp, "apps");
85
+ siteDir = path.join(tmp, "site");
86
+ fs.mkdirSync(path.join(appsDir, "vtex", "actions"), { recursive: true });
87
+ fs.mkdirSync(path.join(siteDir, "src", "server"), { recursive: true });
88
+ fs.writeFileSync(path.join(appsDir, "vtex", "invoke.ts"), FIXTURE_INVOKE_TS);
89
+ fs.writeFileSync(
90
+ path.join(appsDir, "vtex", "actions", "checkout.ts"),
91
+ FIXTURE_ACTIONS_CHECKOUT_TS,
92
+ );
93
+ fs.writeFileSync(
94
+ path.join(appsDir, "vtex", "actions", "session.ts"),
95
+ FIXTURE_ACTIONS_SESSION_TS,
96
+ );
97
+ fs.writeFileSync(path.join(appsDir, "vtex", "types.ts"), FIXTURE_TYPES_TS);
98
+ outFile = path.join(siteDir, "src", "server", "invoke.gen.ts");
99
+
100
+ const result = spawnSync(
101
+ "npx",
102
+ ["tsx", GENERATOR, "--apps-dir", appsDir, "--out-file", outFile],
103
+ { cwd: siteDir, encoding: "utf8" },
104
+ );
105
+ generatorStatus = result.status;
106
+ generatedOutput = fs.readFileSync(outFile, "utf8");
107
+ }, 30_000);
108
+
109
+ afterAll(() => {
110
+ // Best-effort cleanup; tmp dirs leak otherwise.
111
+ try {
112
+ fs.rmSync(path.dirname(appsDir), { recursive: true, force: true });
113
+ } catch {
114
+ // ignore
115
+ }
116
+ });
117
+
118
+ it("runs to completion against a minimal fixture", () => {
119
+ // Sanity check — every subsequent assertion is wasted if the
120
+ // generator process bombed.
121
+ expect(generatorStatus).toBe(0);
122
+ });
123
+
124
+ it("imports the framework helpers needed for Set-Cookie propagation", () => {
125
+ // Both imports must be present — without them, forwardResponseCookies
126
+ // doesn't compile in the site, and the regression we're fixing
127
+ // resurfaces silently when someone deletes one of them.
128
+ expect(generatedOutput).toContain('from "@tanstack/react-start/server"');
129
+ expect(generatedOutput).toMatch(/getResponseHeaders\s*,?\s*\n?\s*setResponseHeader/);
130
+ expect(generatedOutput).toContain(
131
+ 'import { RequestContext } from "@decocms/start/sdk/requestContext"',
132
+ );
133
+ });
134
+
135
+ it("emits the forwardResponseCookies helper exactly once", () => {
136
+ const declMatches = generatedOutput.match(/function forwardResponseCookies\(\)/g);
137
+ expect(declMatches).toHaveLength(1);
138
+
139
+ // The helper must read from RequestContext.responseHeaders and call
140
+ // setResponseHeader. Locking the bridge ends-to-ends.
141
+ expect(generatedOutput).toContain("RequestContext.current");
142
+ expect(generatedOutput).toContain("ctx.responseHeaders.getSetCookie");
143
+ expect(generatedOutput).toContain('setResponseHeader("set-cookie"');
144
+ });
145
+
146
+ it("calls forwardResponseCookies() inside every generated handler", () => {
147
+ // Each .handler(async ({ data }) ...) block produced by the
148
+ // generator must contain a `forwardResponseCookies()` call. We
149
+ // count handlers vs. call sites — excluding the declaration site
150
+ // (`function forwardResponseCookies()`), which also matches the
151
+ // bare `forwardResponseCookies()` token.
152
+ const handlerCount = (generatedOutput.match(/\.handler\(async \(\{ data \}\)/g) ?? []).length;
153
+ const allOccurrences = (generatedOutput.match(/\bforwardResponseCookies\(\)/g) ?? []).length;
154
+ const declSites = (generatedOutput.match(/function\s+forwardResponseCookies\(\)/g) ?? [])
155
+ .length;
156
+ const callSites = allOccurrences - declSites;
157
+
158
+ expect(handlerCount).toBe(3);
159
+ expect(declSites).toBe(1);
160
+ expect(callSites).toBe(3);
161
+ });
162
+
163
+ it("calls forwardResponseCookies AFTER the action awaits (so RequestContext is populated)", () => {
164
+ // The action must complete (await result) before we read the
165
+ // captured Set-Cookies — otherwise we'd forward an empty set
166
+ // every time. The expression body after `await` can be any call
167
+ // shape the wrapper produces (`fn(data)` or `fn({ data })`),
168
+ // so we match across the whole expression up to the semicolon.
169
+ const pattern =
170
+ /const result = await [^;]+;\s+forwardResponseCookies\(\);\s+return (?:unwrapResult\(result\)|result);/g;
171
+ const orderedCalls = generatedOutput.match(pattern) ?? [];
172
+ expect(orderedCalls.length).toBe(3);
173
+ });
174
+
175
+ it("preserves adapting wrappers verbatim (does not collapse to actionFn(data))", () => {
176
+ // Regression for the createSession-shape wrapper: the generator
177
+ // previously hard-coded `${importedFn}(data)` in every handler,
178
+ // silently dropping the wrap that wrappers like
179
+ // createSession: createInvokeFn((data) => createSession({ data }))
180
+ // perform to bridge the external invoke shape to the internal
181
+ // action shape. Sites that regenerated against this hit
182
+ // `TS2345: Property 'data' is missing in type '{ [x: string]: any; }'`
183
+ // at the regenerated call site of every adapting wrapper.
184
+ expect(generatedOutput).toContain("const result = await createSession({ data });");
185
+ // And the broken shortcut must NOT appear for this action.
186
+ expect(generatedOutput).not.toMatch(/const result = await createSession\(data\);/);
187
+
188
+ // Direct pass-through wrappers are unaffected: their body is
189
+ // already `actionFn(data)`, so emitting verbatim produces the same
190
+ // output that the previous shortcut produced. Lock that too — a
191
+ // future refactor that breaks pass-throughs would be just as bad.
192
+ expect(generatedOutput).toContain("const result = await getOrCreateCart(data);");
193
+ expect(generatedOutput).toContain("const result = await simulateCart(data);");
194
+ });
195
+ });
@@ -317,6 +317,17 @@ for (const [source, types] of typeImports) {
317
317
  }
318
318
  }
319
319
 
320
+ // Imports required by the forwardResponseCookies bridge. They live next
321
+ // to the framework's RequestContext (where vtexFetchWithCookies stashes
322
+ // inbound Set-Cookie headers) and TanStack Start's response-header API
323
+ // (where we copy them onto the actual HTTP response).
324
+ out += `import {
325
+ getResponseHeaders,
326
+ setResponseHeader,
327
+ } from "@tanstack/react-start/server";
328
+ import { RequestContext } from "@decocms/start/sdk/requestContext";
329
+ `;
330
+
320
331
  out += `
321
332
  function unwrapResult<T>(result: unknown): T {
322
333
  if (result && typeof result === "object" && "data" in result) {
@@ -325,6 +336,36 @@ function unwrapResult<T>(result: unknown): T {
325
336
  return result as T;
326
337
  }
327
338
 
339
+ /**
340
+ * Forward Set-Cookie headers captured in RequestContext.responseHeaders
341
+ * (by vtexFetchWithCookies) into TanStack Start's HTTP response.
342
+ *
343
+ * Without this bridge, HttpOnly cookies like \`checkout.vtex.com\` and
344
+ * \`CheckoutOrderFormOwnership\` that VTEX returns on cart-action responses
345
+ * stay trapped inside the AsyncLocalStorage-backed RequestContext and
346
+ * never reach the browser. The storefront's mini-cart drifts away from
347
+ * VTEX's server-side orderForm, and the user lands on /checkout with an
348
+ * empty cart.
349
+ *
350
+ * Cheap no-op when no Set-Cookie was captured (e.g. read-only actions),
351
+ * so every handler can call it unconditionally without branching on
352
+ * whether the underlying action uses vtexFetchWithCookies.
353
+ */
354
+ function forwardResponseCookies(): void {
355
+ const ctx = RequestContext.current;
356
+ if (!ctx) return;
357
+ const captured =
358
+ typeof ctx.responseHeaders.getSetCookie === "function"
359
+ ? ctx.responseHeaders.getSetCookie()
360
+ : [];
361
+ if (captured.length === 0) return;
362
+ const existing =
363
+ typeof getResponseHeaders().getSetCookie === "function"
364
+ ? getResponseHeaders().getSetCookie()
365
+ : [];
366
+ setResponseHeader("set-cookie", [...existing, ...captured]);
367
+ }
368
+
328
369
  // ---------------------------------------------------------------------------
329
370
  // Top-level server function declarations
330
371
  // ---------------------------------------------------------------------------
@@ -334,21 +375,49 @@ for (const action of actions) {
334
375
  const varName = `$${action.name}`;
335
376
 
336
377
  if (action.importedFn) {
337
- // All @decocms/apps action functions take a single props object.
338
- // Pass the validated `data` object directly — never destructure into
339
- // positional arguments, which breaks when function signatures change.
378
+ // Emit the wrapper body verbatim. The arrow function in
379
+ // @decocms/apps/vtex/invoke.ts is the contract that maps the external
380
+ // invoke shape (what storefront callers send) to the internal action
381
+ // shape (what vtex/actions/* expects). Most wrappers are direct
382
+ // pass-throughs (`actionFn(data)`) but some adapt the payload
383
+ // (e.g. `createSession({ data })` wraps a flat session payload into
384
+ // CreateSessionProps). Hard-coding `${importedFn}(data)` silently
385
+ // dropped the wrap, producing typecheck errors at the call site of
386
+ // every adapting wrapper.
387
+ //
388
+ // Invariant: when action.importedFn is set, action.callBody was
389
+ // non-empty and contained `${importedFn}(` (that's how importedFn was
390
+ // discovered in the first place — see the importMap scan above).
391
+ // Block bodies clear callBody to "", which forces importedFn to ""
392
+ // and routes to the stub branch below.
393
+ //
394
+ // The arrow's parameter is `data` by convention across every wrapper
395
+ // in vtex/invoke.ts, and the generated handler destructures `{ data }`
396
+ // from the validator output, so callBody's `data` references resolve
397
+ // to the handler's local `data` without any rename.
398
+ //
399
+ // forwardResponseCookies() runs AFTER the action awaits, so any
400
+ // Set-Cookie that vtexFetchWithCookies captured onto
401
+ // RequestContext.responseHeaders gets promoted to the actual HTTP
402
+ // response. Safe no-op when the action didn't touch responseHeaders
403
+ // (e.g. masterData reads), so it's applied unconditionally — the
404
+ // alternative (a static allow-list of cookie-bearing actions)
405
+ // silently misses any new actions that start propagating cookies.
340
406
  if (action.unwrap) {
341
407
  out += `\nconst ${varName} = createServerFn({ method: "POST" })
342
408
  .inputValidator((data: ${action.inputType}) => data)
343
409
  .handler(async ({ data }): Promise<any> => {
344
- const result = await ${action.importedFn}(data);
410
+ const result = await ${action.callBody};
411
+ forwardResponseCookies();
345
412
  return unwrapResult(result);
346
413
  });\n`;
347
414
  } else {
348
415
  out += `\nconst ${varName} = createServerFn({ method: "POST" })
349
416
  .inputValidator((data: ${action.inputType}) => data)
350
417
  .handler(async ({ data }): Promise<any> => {
351
- return ${action.importedFn}(data);
418
+ const result = await ${action.callBody};
419
+ forwardResponseCookies();
420
+ return result;
352
421
  });\n`;
353
422
  }
354
423
  } else {