@decocms/start 5.4.2 → 6.0.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": "5.4.2",
3
+ "version": "6.0.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",
@@ -0,0 +1,168 @@
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 { afterEach, beforeEach, 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 type { OrderForm } from "./types";
32
+
33
+ export const invoke = {
34
+ vtex: {
35
+ actions: {
36
+ getOrCreateCart: createInvokeFn(
37
+ (data: { orderFormId?: string }) => getOrCreateCart(data),
38
+ ) as unknown as (ctx: { data: { orderFormId?: string } }) => Promise<OrderForm>,
39
+
40
+ simulateCart: createInvokeFn(
41
+ (data: { postalCode: string }) => simulateCart(data),
42
+ ),
43
+ },
44
+ },
45
+ } as const;
46
+ `;
47
+
48
+ // Minimal stubs the generator's import-resolution doesn't *execute* but
49
+ // ts-morph parses these to populate the importMap. We only need names to
50
+ // resolve.
51
+ const FIXTURE_ACTIONS_CHECKOUT_TS = `\
52
+ export async function getOrCreateCart(_data: any): Promise<any> { return null; }
53
+ export async function simulateCart(_data: any): Promise<any> { return null; }
54
+ `;
55
+ const FIXTURE_TYPES_TS = `export type OrderForm = unknown;\n`;
56
+
57
+ describe("generate-invoke.ts — output shape", () => {
58
+ let appsDir: string;
59
+ let siteDir: string;
60
+ let outFile: string;
61
+
62
+ beforeEach(() => {
63
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gen-invoke-"));
64
+ appsDir = path.join(tmp, "apps");
65
+ siteDir = path.join(tmp, "site");
66
+ fs.mkdirSync(path.join(appsDir, "vtex", "actions"), { recursive: true });
67
+ fs.mkdirSync(path.join(siteDir, "src", "server"), { recursive: true });
68
+ fs.writeFileSync(path.join(appsDir, "vtex", "invoke.ts"), FIXTURE_INVOKE_TS);
69
+ fs.writeFileSync(
70
+ path.join(appsDir, "vtex", "actions", "checkout.ts"),
71
+ FIXTURE_ACTIONS_CHECKOUT_TS,
72
+ );
73
+ fs.writeFileSync(path.join(appsDir, "vtex", "types.ts"), FIXTURE_TYPES_TS);
74
+ outFile = path.join(siteDir, "src", "server", "invoke.gen.ts");
75
+ });
76
+
77
+ afterEach(() => {
78
+ // Best-effort cleanup; tmp dirs leak otherwise.
79
+ try {
80
+ fs.rmSync(path.dirname(appsDir), { recursive: true, force: true });
81
+ } catch {
82
+ // ignore
83
+ }
84
+ });
85
+
86
+ function runGenerator(): { stdout: string; stderr: string; status: number | null } {
87
+ const result = spawnSync(
88
+ "npx",
89
+ [
90
+ "tsx",
91
+ GENERATOR,
92
+ "--apps-dir",
93
+ appsDir,
94
+ "--out-file",
95
+ outFile,
96
+ ],
97
+ {
98
+ cwd: siteDir,
99
+ encoding: "utf8",
100
+ },
101
+ );
102
+ return {
103
+ stdout: result.stdout ?? "",
104
+ stderr: result.stderr ?? "",
105
+ status: result.status,
106
+ };
107
+ }
108
+
109
+ it("imports the framework helpers needed for Set-Cookie propagation", () => {
110
+ const { status } = runGenerator();
111
+ expect(status).toBe(0);
112
+
113
+ const out = fs.readFileSync(outFile, "utf8");
114
+ // Both imports must be present — without them, forwardResponseCookies
115
+ // doesn't compile in the site, and the regression we're fixing
116
+ // resurfaces silently when someone deletes one of them.
117
+ expect(out).toContain('from "@tanstack/react-start/server"');
118
+ expect(out).toMatch(/getResponseHeaders\s*,?\s*\n?\s*setResponseHeader/);
119
+ expect(out).toContain('import { RequestContext } from "@decocms/start/sdk/requestContext"');
120
+ });
121
+
122
+ it("emits the forwardResponseCookies helper exactly once", () => {
123
+ runGenerator();
124
+ const out = fs.readFileSync(outFile, "utf8");
125
+ // Match the declaration, not call sites. There's only one helper.
126
+ const declMatches = out.match(/function forwardResponseCookies\(\)/g);
127
+ expect(declMatches).toHaveLength(1);
128
+
129
+ // The helper must read from RequestContext.responseHeaders and call
130
+ // setResponseHeader. Locking the bridge ends-to-ends.
131
+ expect(out).toContain("RequestContext.current");
132
+ expect(out).toContain("ctx.responseHeaders.getSetCookie");
133
+ expect(out).toContain('setResponseHeader("set-cookie"');
134
+ });
135
+
136
+ it("calls forwardResponseCookies() inside every generated handler", () => {
137
+ runGenerator();
138
+ const out = fs.readFileSync(outFile, "utf8");
139
+
140
+ // Each .handler(async ({ data }) ...) block produced by the
141
+ // generator must contain a `forwardResponseCookies()` call. We
142
+ // count handlers vs. call sites — excluding the declaration site
143
+ // (`function forwardResponseCookies()`), which also matches the
144
+ // bare `forwardResponseCookies()` token.
145
+ const handlerCount = (out.match(/\.handler\(async \(\{ data \}\)/g) ?? []).length;
146
+ const allOccurrences = (out.match(/\bforwardResponseCookies\(\)/g) ?? []).length;
147
+ const declSites = (out.match(/function\s+forwardResponseCookies\(\)/g) ?? []).length;
148
+ const callSites = allOccurrences - declSites;
149
+
150
+ expect(handlerCount).toBe(2);
151
+ expect(declSites).toBe(1);
152
+ expect(callSites).toBe(2);
153
+ });
154
+
155
+ it("calls forwardResponseCookies AFTER the action awaits (so RequestContext is populated)", () => {
156
+ runGenerator();
157
+ const out = fs.readFileSync(outFile, "utf8");
158
+
159
+ // The action must complete (await result) before we read the
160
+ // captured Set-Cookies — otherwise we'd forward an empty set
161
+ // every time. Verifying ordering via regex is brittle but the
162
+ // surface is small enough.
163
+ const pattern =
164
+ /const result = await \w+\(data\);\s+forwardResponseCookies\(\);\s+return (?:unwrapResult\(result\)|result);/g;
165
+ const orderedCalls = out.match(pattern) ?? [];
166
+ expect(orderedCalls.length).toBe(2);
167
+ });
168
+ });
@@ -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
  // ---------------------------------------------------------------------------
@@ -337,18 +378,30 @@ for (const action of actions) {
337
378
  // All @decocms/apps action functions take a single props object.
338
379
  // Pass the validated `data` object directly — never destructure into
339
380
  // positional arguments, which breaks when function signatures change.
381
+ //
382
+ // forwardResponseCookies() runs AFTER the action awaits, so any
383
+ // Set-Cookie that vtexFetchWithCookies captured onto
384
+ // RequestContext.responseHeaders gets promoted to the actual HTTP
385
+ // response. Safe no-op when the action didn't touch
386
+ // responseHeaders (e.g. masterData reads), so it's applied
387
+ // unconditionally — the alternative (a static allow-list of
388
+ // cookie-bearing actions) silently misses any new actions that
389
+ // start propagating cookies.
340
390
  if (action.unwrap) {
341
391
  out += `\nconst ${varName} = createServerFn({ method: "POST" })
342
392
  .inputValidator((data: ${action.inputType}) => data)
343
393
  .handler(async ({ data }): Promise<any> => {
344
394
  const result = await ${action.importedFn}(data);
395
+ forwardResponseCookies();
345
396
  return unwrapResult(result);
346
397
  });\n`;
347
398
  } else {
348
399
  out += `\nconst ${varName} = createServerFn({ method: "POST" })
349
400
  .inputValidator((data: ${action.inputType}) => data)
350
401
  .handler(async ({ data }): Promise<any> => {
351
- return ${action.importedFn}(data);
402
+ const result = await ${action.importedFn}(data);
403
+ forwardResponseCookies();
404
+ return result;
352
405
  });\n`;
353
406
  }
354
407
  } else {