@decocms/start 6.2.0 → 6.3.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.
@@ -293,9 +293,10 @@ Generates `MIGRATION_REPORT.md` with:
293
293
  ### Phase 7: Bootstrap
294
294
 
295
295
  Runs automatically after all phases (skipped in `--dry-run`):
296
- 1. `npm install` (or `bun install`)
297
- 2. `npx tsx node_modules/@decocms/start/scripts/generate-blocks.ts`
298
- 3. `npx tsr generate`
296
+ 1. `bun install`
297
+ 2. `bunx tsx node_modules/@decocms/start/scripts/generate-blocks.ts`
298
+ 3. `bunx tsx node_modules/@decocms/start/scripts/generate-invoke.ts` — emits `src/server/invoke.gen.ts` (top-level `createServerFn` declarations for every VTEX action, plus the `forwardResponseCookies()` Set-Cookie bridge). Without this step the site falls back to the `/deco/invoke/...` proxy and the cart breaks at `/checkout` after addItemToCart. See `.cursor/skills/deco-server-functions-invoke/troubleshooting.md` ("Cart 'forgets' items between requests") for the failure mode.
299
+ 4. `bunx tsr generate`
299
300
 
300
301
  ### Phase 8: Compile
301
302
 
@@ -31,6 +31,19 @@ export async function vtexFetchWithCookies<T>(
31
31
 
32
32
  Use in: `checkout.ts`, `auth.ts`, `session.ts` (create/edit).
33
33
 
34
+ ### Pitfall: never copy with `Headers.entries()` or `forEach`
35
+
36
+ When a caller pushes the captured cookies onto the request scope's response headers (e.g. `RequestContext.responseHeaders.append("set-cookie", c)`), the eventual HTTP-response bridge must read them back with `Headers.getSetCookie()`. **Do not** iterate `headers.entries()` (or `forEach`) and re-append, because both collapse multiple `Set-Cookie` values into a single comma-joined string, which browsers silently discard. The result: the cart appears empty after addItemToCart even though the action returned an OrderForm with items.
37
+
38
+ Two bridges where this rule applies in a TanStack Start site:
39
+
40
+ - `src/server/invoke.gen.ts` — the auto-generated `forwardResponseCookies()` already uses `getSetCookie()`. Re-run `bunx tsx node_modules/@decocms/start/scripts/generate-invoke.ts` if the file is missing or stale.
41
+ - `@decocms/start/src/admin/invoke.ts` — `forwardCtxHeadersTo()` does the same for the `/deco/invoke/...` HTTP path. Versions ≥ 5.0.0 ship the fix.
42
+
43
+ ### Client-side `setOrderFormIdCookie` is defense-in-depth, not the fix
44
+
45
+ Some migrated `useCart` hooks (e.g. miess-01-tanstack) manually call `document.cookie = "checkout.vtex.com__orderFormId=..."` after each cart action. That only patches one cookie — `segment`, `sc`, `vtex_session` etc. are still dropped. Keep the workaround if you like (cheap, idempotent), but the real fix is the two server-side bridges above. Once both are in place, the manual cookie write can be removed without regressing the cart.
46
+
34
47
  ## buildAuthCookieHeader
35
48
 
36
49
  VTEX IO GraphQL at `{account}.myvtex.com` requires both cookie names:
@@ -63,6 +63,21 @@ const result = await vtexFetchWithCookies<OrderForm>(url, opts);
63
63
 
64
64
  **Where NOT needed**: Read-only loaders, GraphQL queries.
65
65
 
66
+ **Server→browser bridge** — the cookies that `vtexFetchWithCookies` captures must be forwarded onto the outgoing HTTP response, or the browser never sees them and the cart appears empty on the next request. There are two bridge points in a TanStack Start site, and both must be wired:
67
+
68
+ 1. **`src/server/invoke.gen.ts`** (TanStack RPC path). Generated by `bunx tsx node_modules/@decocms/start/scripts/generate-invoke.ts`. Audit:
69
+ - File exists?
70
+ - Contains `function forwardResponseCookies()`?
71
+ - Every action handler calls `forwardResponseCookies()` after the `await`?
72
+
73
+ If any answer is "no", regenerate with the script above. Then make sure `useCart`, `useUser`, `useWishlist` import `invoke` from `~/server/invoke.gen` (or a barrel that re-exports it), not from the proxy `~/runtime.ts`.
74
+
75
+ 2. **`@decocms/start/src/admin/invoke.ts`** (`/deco/invoke/...` HTTP path). The framework's single + batch invoke handlers must use `Headers.getSetCookie()` (not `entries()`!) when copying `RequestContext.responseHeaders` onto the response. Pin to a version ≥ 5.0.0 that ships `forwardCtxHeadersTo`.
76
+
77
+ The historical failure mode: a `for…of headers.entries()` loop collapsed N `Set-Cookie` values into one comma-joined string, which browsers silently discard. Every VTEX cart action returns 3–5 cookies (`checkout.vtex.com__orderFormId`, `segment`, `sc`, `vtex_session`…), so even one collapse breaks the entire cart flow.
78
+
79
+ **Quick diagnosis**: Add an item, watch DevTools → Network → the cart-action response should have **multiple** `Set-Cookie:` rows, not one comma-joined line.
80
+
66
81
  ### 2. Auth Cookie Headers
67
82
 
68
83
  All authenticated VTEX IO GraphQL calls need both cookie variants:
@@ -1,5 +1,27 @@
1
1
  # Troubleshooting
2
2
 
3
+ ## Cart "forgets" items between requests / /checkout opens empty after addItemToCart
4
+
5
+ **Symptom**: `invoke.vtex.actions.addItemsToCart(...)` succeeds (returns an `OrderForm` with items), but the next page load — or clicking the cart icon — shows an empty cart, and `/checkout` lands on a fresh empty order. Sometimes the orderFormId is different from the one just returned.
6
+
7
+ **Root cause**: The VTEX cart cookies (`checkout.vtex.com__orderFormId`, `segment`, `sc`, `vtex_session`) never reach the browser because somewhere in the chain, multiple `Set-Cookie` headers got collapsed into a single comma-joined string. Browsers silently discard malformed `Set-Cookie` values, so every subsequent request hits VTEX without authentication and gets a new empty orderForm.
8
+
9
+ **Two places this can break**:
10
+
11
+ 1. **`src/server/invoke.gen.ts` missing or stale**. This is the TanStack RPC path. Each action must call `forwardResponseCookies()` after awaiting the underlying VTEX call. The helper uses `Headers.getSetCookie()` (not `entries()`!) to read the un-collapsed list and writes each value to TanStack's response via `setResponseHeader("set-cookie", [...])`. If the file doesn't exist, the site falls back to the `/deco/invoke/...` proxy.
12
+
13
+ 2. **`/deco/invoke/...` HTTP proxy** (`~/runtime.ts` pattern). The framework's admin handler (`@decocms/start/src/admin/invoke.ts`) used to iterate `RequestContext.responseHeaders.entries()` which collapses Set-Cookie. Fixed in @decocms/start ≥ 5.0.0 by switching to `getSetCookie()` + a `forwardCtxHeadersTo()` helper applied on both single and batch paths.
14
+
15
+ **Diagnosis**: Open DevTools → Network → response to the cart action. You should see **multiple distinct** `Set-Cookie:` rows. If you see a single `Set-Cookie: foo=1, bar=2; Path=/, baz=3` line, that's the collapse bug.
16
+
17
+ **Fix**:
18
+ 1. Upgrade `@decocms/start` to the version with `forwardCtxHeadersTo` in `src/admin/invoke.ts` (search the file — both single and batch handlers should call it).
19
+ 2. Run `bunx tsx node_modules/@decocms/start/scripts/generate-invoke.ts` to regenerate `src/server/invoke.gen.ts`. Verify it has `function forwardResponseCookies()` and that every emitted handler calls it.
20
+ 3. Make sure `useCart` (and other VTEX hooks) imports `invoke` from `~/server/invoke.gen` (or a barrel re-export of it), not from `~/runtime`.
21
+ 4. The migration script (`scripts/migrate.ts` bootstrap) runs `generate-invoke.ts` automatically on freshly-migrated sites — if a site was migrated before that, run the generator manually.
22
+
23
+ **Client-side workaround** (defense-in-depth, removable): some sites manually `document.cookie = "checkout.vtex.com__orderFormId=..."` inside `useCart`. That only patches one cookie of many. With the server-side fix in place, the workaround is harmless but no longer load-bearing — see `~/conductor/workspaces/miess-01-tanstack/newport-beach/src/hooks/useCart.ts` for an example.
24
+
3
25
  ## CORS Error on Add to Cart / Checkout
4
26
 
5
27
  **Symptom**: Browser console shows CORS error when calling VTEX API directly.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "6.2.0",
3
+ "version": "6.3.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",
@@ -1578,12 +1578,101 @@ const rulePackageManagerMissing: Rule = {
1578
1578
  },
1579
1579
  };
1580
1580
 
1581
+ /* ------------------------------------------------------------------ */
1582
+ /* Rule N — `vtex-proxy-handler-missing` — worker-entry without proxy */
1583
+ /* ------------------------------------------------------------------ */
1584
+
1585
+ /**
1586
+ * Every VTEX storefront on @decocms/start needs a reverse proxy for
1587
+ * `/checkout/*`, `/account/*`, `/api/*`, `/files/*`, etc. — those paths
1588
+ * must hit the VTEX origin (not TanStack Start) so the user lands on
1589
+ * the real checkout UI carrying their VTEX session cookies.
1590
+ *
1591
+ * The canonical wiring lives in `src/worker-entry.ts` via
1592
+ * `createDecoWorkerEntry(..., { proxyHandler })`, where the handler
1593
+ * calls `shouldProxyToVtex(url.pathname)` + a `createVtexCheckoutProxy`
1594
+ * instance. The migration scaffold (`scripts/migrate/templates/server-entry.ts`)
1595
+ * emits this by default for VTEX sites, but two regressions sneak it out:
1596
+ *
1597
+ * 1. A site migrated by a pre-scaffold version of the script (e.g.
1598
+ * before the VTEX worker-entry template existed).
1599
+ * 2. Someone refactors `worker-entry.ts` and drops the proxy block.
1600
+ *
1601
+ * Without the proxy, add-to-cart still "succeeds" (the action runs
1602
+ * server-side via TanStack RPC), but clicking "Finalizar" navigates
1603
+ * to `/checkout` on the storefront — which returns the SPA shell —
1604
+ * and the user never reaches VTEX checkout. The VTEX-side orderForm
1605
+ * lives at vtexcommercestable.com.br with no way to see it.
1606
+ *
1607
+ * Detection is cheap: VTEX sites should import `shouldProxyToVtex`
1608
+ * (or wire a `proxyHandler:` to `createDecoWorkerEntry`). We flag
1609
+ * absence as `info` (not warning) because old in-prod sites we
1610
+ * deliberately don't touch would otherwise stay noisy.
1611
+ */
1612
+ const ruleVtexProxyHandlerMissing: Rule = {
1613
+ id: "vtex-proxy-handler-missing",
1614
+ title: "VTEX worker-entry missing the checkout/API proxy handler",
1615
+ run({ siteDir, fs }: RuleContext): Finding[] {
1616
+ // Only run when the site is actually VTEX. The cheapest signal is
1617
+ // any import from `@decocms/apps/vtex/...` in src/ — every VTEX
1618
+ // site has at least one (commerceLoaders, hooks, types, etc.).
1619
+ const srcFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
1620
+ const isVtex = srcFiles.some((abs) =>
1621
+ fs.readText(abs).includes("@decocms/apps/vtex"),
1622
+ );
1623
+ if (!isVtex) return [];
1624
+
1625
+ const workerEntryAbs = `${siteDir}/src/worker-entry.ts`;
1626
+ if (!fs.exists(workerEntryAbs)) {
1627
+ return [
1628
+ {
1629
+ rule: "vtex-proxy-handler-missing",
1630
+ severity: "info",
1631
+ file: "src/worker-entry.ts",
1632
+ message:
1633
+ "VTEX site has no src/worker-entry.ts — /checkout proxy can't run, the user will see the SPA shell instead of VTEX checkout",
1634
+ fix: "Re-run `deco-migrate` (the scaffold emits a worker-entry with createVtexCheckoutProxy), or copy from scripts/migrate/templates/server-entry.ts",
1635
+ },
1636
+ ];
1637
+ }
1638
+
1639
+ const content = fs.readText(workerEntryAbs);
1640
+ // Match either symbol — sites use the factory function OR the
1641
+ // shouldProxyToVtex predicate as the entry point. Presence of
1642
+ // either is a strong signal the proxy block exists; absence of
1643
+ // both means it was dropped.
1644
+ const hasProxyImport =
1645
+ /from\s+["']@decocms\/apps\/vtex\/utils\/proxy["']/.test(content);
1646
+ // Match both long form (`proxyHandler: async (...) => ...`) and
1647
+ // object-shorthand wiring (`{ proxyHandler }`, `{ proxyHandler, admin }`).
1648
+ // The anchor `[{,]` requires the identifier to appear as a property —
1649
+ // not as a bare `const proxyHandler = ...` declaration, which is
1650
+ // followed by `=` and wouldn't match either branch.
1651
+ const hasProxyHandler = /[{,]\s*proxyHandler\s*[:,}]/.test(content);
1652
+
1653
+ if (hasProxyImport && hasProxyHandler) return [];
1654
+
1655
+ return [
1656
+ {
1657
+ rule: "vtex-proxy-handler-missing",
1658
+ severity: "info",
1659
+ file: "src/worker-entry.ts",
1660
+ message: hasProxyImport
1661
+ ? "imports proxy helpers but no `proxyHandler:` is wired into createDecoWorkerEntry — /checkout requests will fall through to TanStack and render the SPA shell"
1662
+ : "no `@decocms/apps/vtex/utils/proxy` import — VTEX /checkout, /account, /api won't be proxied to the origin",
1663
+ fix: "Add `proxyHandler` to createDecoWorkerEntry; see scripts/migrate/templates/server-entry.ts (generateVtexWorkerEntry) for the canonical block",
1664
+ },
1665
+ ];
1666
+ },
1667
+ };
1668
+
1581
1669
  export const ALL_RULES: Rule[] = [
1582
1670
  ruleDeadLibShims,
1583
1671
  ruleObsoleteVitePlugins,
1584
1672
  ruleDeadRuntimeShim,
1585
1673
  ruleSiteLocalGlobals,
1586
1674
  ruleVtexShimRegression,
1675
+ ruleVtexProxyHandlerMissing,
1587
1676
  ruleLocalWidgetsTypes,
1588
1677
  ruleFrameworkTodos,
1589
1678
  ruleLocalFrameworkDuplicate,
@@ -1606,6 +1695,7 @@ export const _internals = {
1606
1695
  ruleDeadRuntimeShim,
1607
1696
  ruleSiteLocalGlobals,
1608
1697
  ruleVtexShimRegression,
1698
+ ruleVtexProxyHandlerMissing,
1609
1699
  ruleHtmxResidue,
1610
1700
  ruleLocalWidgetsTypes,
1611
1701
  ruleFrameworkTodos,
@@ -628,6 +628,109 @@ describe("rule: framework-todos", () => {
628
628
  });
629
629
  });
630
630
 
631
+ describe("rule: vtex-proxy-handler-missing", () => {
632
+ // Canonical wiring matching scripts/migrate/templates/server-entry.ts (generateVtexWorkerEntry):
633
+ // imports from @decocms/apps/vtex/utils/proxy and passes proxyHandler to createDecoWorkerEntry.
634
+ const okWorkerEntry = `
635
+ import { createDecoWorkerEntry } from "@decocms/start/sdk/workerEntry";
636
+ import { shouldProxyToVtex, createVtexCheckoutProxy } from "@decocms/apps/vtex/utils/proxy";
637
+ const proxy = createVtexCheckoutProxy({ account: "x", checkoutOrigin: "x.vtexcommercestable.com.br" });
638
+ export default createDecoWorkerEntry(serverEntry, {
639
+ proxyHandler: async (req, url) => {
640
+ if (!shouldProxyToVtex(url.pathname)) return null;
641
+ return proxy(req, url);
642
+ },
643
+ });
644
+ `;
645
+
646
+ it("does not flag non-VTEX sites", () => {
647
+ const fs = makeFs({
648
+ "/site/src/index.ts": "export const x = 1;",
649
+ // Worker entry intentionally without the proxy import — non-VTEX
650
+ // sites don't need it and shouldn't be flagged.
651
+ "/site/src/worker-entry.ts": "export default {};",
652
+ });
653
+ const report = runAudit(SITE, fs);
654
+ const r = report.rules.find((r) => r.rule === "vtex-proxy-handler-missing")!;
655
+ expect(r.findings).toEqual([]);
656
+ });
657
+
658
+ it("does not flag VTEX site whose worker-entry has the proxyHandler wired", () => {
659
+ const fs = makeFs({
660
+ "/site/src/commerceLoaders.ts": "import {} from \"@decocms/apps/vtex/mod\";",
661
+ "/site/src/worker-entry.ts": okWorkerEntry,
662
+ });
663
+ const report = runAudit(SITE, fs);
664
+ const r = report.rules.find((r) => r.rule === "vtex-proxy-handler-missing")!;
665
+ expect(r.findings).toEqual([]);
666
+ });
667
+
668
+ it("flags VTEX site missing src/worker-entry.ts entirely", () => {
669
+ const fs = makeFs({
670
+ "/site/src/commerceLoaders.ts": "import {} from \"@decocms/apps/vtex/mod\";",
671
+ });
672
+ const report = runAudit(SITE, fs);
673
+ const r = report.rules.find((r) => r.rule === "vtex-proxy-handler-missing")!;
674
+ expect(r.findings).toHaveLength(1);
675
+ expect(r.findings[0].severity).toBe("info");
676
+ expect(r.findings[0].message).toContain("no src/worker-entry.ts");
677
+ });
678
+
679
+ it("flags VTEX site whose worker-entry omits the proxy import", () => {
680
+ const fs = makeFs({
681
+ "/site/src/commerceLoaders.ts": "import {} from \"@decocms/apps/vtex/mod\";",
682
+ "/site/src/worker-entry.ts": `
683
+ import { createDecoWorkerEntry } from "@decocms/start/sdk/workerEntry";
684
+ export default createDecoWorkerEntry(serverEntry, { admin: {} });
685
+ `,
686
+ });
687
+ const report = runAudit(SITE, fs);
688
+ const r = report.rules.find((r) => r.rule === "vtex-proxy-handler-missing")!;
689
+ expect(r.findings).toHaveLength(1);
690
+ expect(r.findings[0].message).toContain("no `@decocms/apps/vtex/utils/proxy` import");
691
+ });
692
+
693
+ it("does not flag VTEX site using object-shorthand proxyHandler wiring", () => {
694
+ // Hand-written worker entries commonly hoist proxyHandler to a top-level
695
+ // const and pass it via shorthand. The detector must treat
696
+ // `{ proxyHandler }`, `{ proxyHandler, admin }`, and `{ admin, proxyHandler }`
697
+ // the same as `proxyHandler: ...` — otherwise the audit cries wolf.
698
+ const shorthandWorkerEntry = `
699
+ import { createDecoWorkerEntry } from "@decocms/start/sdk/workerEntry";
700
+ import { shouldProxyToVtex, createVtexCheckoutProxy } from "@decocms/apps/vtex/utils/proxy";
701
+ const proxy = createVtexCheckoutProxy({ account: "x", checkoutOrigin: "x.vtexcommercestable.com.br" });
702
+ const proxyHandler = async (req: Request, url: URL) => {
703
+ if (!shouldProxyToVtex(url.pathname)) return null;
704
+ return proxy(req, url);
705
+ };
706
+ export default createDecoWorkerEntry(serverEntry, { admin: {}, proxyHandler });
707
+ `;
708
+ const fs = makeFs({
709
+ "/site/src/commerceLoaders.ts": "import {} from \"@decocms/apps/vtex/mod\";",
710
+ "/site/src/worker-entry.ts": shorthandWorkerEntry,
711
+ });
712
+ const report = runAudit(SITE, fs);
713
+ const r = report.rules.find((r) => r.rule === "vtex-proxy-handler-missing")!;
714
+ expect(r.findings).toEqual([]);
715
+ });
716
+
717
+ it("flags VTEX site that imports proxy helpers but never wires proxyHandler", () => {
718
+ const fs = makeFs({
719
+ "/site/src/commerceLoaders.ts": "import {} from \"@decocms/apps/vtex/mod\";",
720
+ "/site/src/worker-entry.ts": `
721
+ import { createDecoWorkerEntry } from "@decocms/start/sdk/workerEntry";
722
+ import { shouldProxyToVtex } from "@decocms/apps/vtex/utils/proxy";
723
+ // note: shouldProxyToVtex imported but not used in a proxyHandler.
724
+ export default createDecoWorkerEntry(serverEntry, { admin: {} });
725
+ `,
726
+ });
727
+ const report = runAudit(SITE, fs);
728
+ const r = report.rules.find((r) => r.rule === "vtex-proxy-handler-missing")!;
729
+ expect(r.findings).toHaveLength(1);
730
+ expect(r.findings[0].message).toContain("no `proxyHandler:` is wired");
731
+ });
732
+ });
733
+
631
734
  describe("internals", () => {
632
735
  it("extractExports parses common forms (top-level, unindented)", () => {
633
736
  const code = [
@@ -255,6 +255,19 @@ function bootstrap(ctx: { sourceDir: string }) {
255
255
  const pm = "bun";
256
256
  if (!run(`${pm} install`, "Install dependencies", true)) return;
257
257
  run("bunx tsx node_modules/@decocms/start/scripts/generate-blocks.ts", "Generate CMS blocks");
258
+ // generate-invoke emits src/server/invoke.gen.ts with top-level
259
+ // createServerFn declarations + the forwardResponseCookies bridge that
260
+ // propagates VTEX Set-Cookie headers (orderFormId, segment, sc…) to the
261
+ // browser. Without this file, the site falls back to the proxy
262
+ // `~/runtime.ts` route which hits /deco/invoke and used to drop cookies,
263
+ // making the cart appear empty at /checkout after addItemToCart. The
264
+ // upstream invoke handler now also forwards cookies correctly, but
265
+ // running the generator gives every freshly-migrated site the canonical
266
+ // RPC path so VTEX hooks (useCart, useUser, useWishlist) work end-to-end.
267
+ run(
268
+ "bunx tsx node_modules/@decocms/start/scripts/generate-invoke.ts",
269
+ "Generate VTEX invoke server functions",
270
+ );
258
271
  run("bunx tsr generate", "Generate TanStack routes");
259
272
 
260
273
  if (failures > 0) {
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Regression tests for /deco/invoke Set-Cookie propagation.
3
+ *
4
+ * The historical bug: the single- and batch-invoke paths copied
5
+ * `RequestContext.responseHeaders` to the HTTP response via
6
+ * `headers.entries()`, which collapses multiple `Set-Cookie` values
7
+ * into a single comma-joined string. Browsers silently discard those,
8
+ * so every VTEX cart action lost its session cookies and the user
9
+ * ended up at /checkout with an empty cart.
10
+ *
11
+ * These tests pin the fix: when a handler appends multiple
12
+ * Set-Cookie values to `RequestContext.responseHeaders`, the response
13
+ * returned by `handleInvoke` must surface them as N distinct
14
+ * Set-Cookie headers (readable via `response.headers.getSetCookie()`).
15
+ */
16
+
17
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
18
+ import { RequestContext } from "../sdk/requestContext";
19
+ import {
20
+ clearInvokeHandlers,
21
+ handleInvoke,
22
+ registerInvokeHandlers,
23
+ } from "./invoke";
24
+
25
+ const COOKIE_A = "checkout.vtex.com__orderFormId=of-123; Path=/; HttpOnly";
26
+ const COOKIE_B = "segment=eyJjYW1wYWlnbnMiOiJ4In0=; Path=/; HttpOnly";
27
+ const COOKIE_C = "sc=1; Path=/; HttpOnly";
28
+
29
+ function makeInvokeRequest(key: string, body: unknown = {}): Request {
30
+ return new Request(`http://localhost/deco/invoke/${key}`, {
31
+ method: "POST",
32
+ headers: { "content-type": "application/json" },
33
+ body: JSON.stringify(body),
34
+ });
35
+ }
36
+
37
+ function makeBatchRequest(body: Record<string, unknown>): Request {
38
+ return new Request("http://localhost/deco/invoke", {
39
+ method: "POST",
40
+ headers: { "content-type": "application/json" },
41
+ body: JSON.stringify(body),
42
+ });
43
+ }
44
+
45
+ describe("handleInvoke — Set-Cookie propagation (single)", () => {
46
+ beforeEach(() => clearInvokeHandlers());
47
+ afterEach(() => clearInvokeHandlers());
48
+
49
+ it("forwards multiple Set-Cookie values as distinct headers", async () => {
50
+ registerInvokeHandlers({
51
+ "vtex/actions/addItemsToCart": async () => {
52
+ RequestContext.responseHeaders.append("set-cookie", COOKIE_A);
53
+ RequestContext.responseHeaders.append("set-cookie", COOKIE_B);
54
+ RequestContext.responseHeaders.append("set-cookie", COOKIE_C);
55
+ return { orderFormId: "of-123" };
56
+ },
57
+ });
58
+
59
+ const request = makeInvokeRequest("vtex/actions/addItemsToCart");
60
+ const response = await RequestContext.run(request, () => handleInvoke(request));
61
+
62
+ const cookies = response.headers.getSetCookie();
63
+ expect(cookies).toHaveLength(3);
64
+ expect(cookies).toContain(COOKIE_A);
65
+ expect(cookies).toContain(COOKIE_B);
66
+ expect(cookies).toContain(COOKIE_C);
67
+ });
68
+
69
+ it("does not collapse cookies into a single Set-Cookie entry", async () => {
70
+ registerInvokeHandlers({
71
+ "vtex/actions/foo": async () => {
72
+ RequestContext.responseHeaders.append("set-cookie", COOKIE_A);
73
+ RequestContext.responseHeaders.append("set-cookie", COOKIE_B);
74
+ return {};
75
+ },
76
+ });
77
+
78
+ const request = makeInvokeRequest("vtex/actions/foo");
79
+ const response = await RequestContext.run(request, () => handleInvoke(request));
80
+
81
+ // The regressed bug appended a single comma-joined string, so
82
+ // `getSetCookie()` returned a 1-element array. The fix appends each
83
+ // value individually — verifying the count alone catches the regression.
84
+ expect(response.headers.getSetCookie()).toHaveLength(2);
85
+ });
86
+
87
+ it("forwards non-cookie headers unchanged", async () => {
88
+ registerInvokeHandlers({
89
+ "vtex/actions/withHeader": async () => {
90
+ RequestContext.responseHeaders.append("x-vtex-trace-id", "abc-123");
91
+ return {};
92
+ },
93
+ });
94
+
95
+ const request = makeInvokeRequest("vtex/actions/withHeader");
96
+ const response = await RequestContext.run(request, () => handleInvoke(request));
97
+ expect(response.headers.get("x-vtex-trace-id")).toBe("abc-123");
98
+ });
99
+
100
+ it("does not forward Set-Cookie when handler writes none", async () => {
101
+ registerInvokeHandlers({
102
+ "vtex/loaders/productList": async () => ({ items: [] }),
103
+ });
104
+
105
+ const request = makeInvokeRequest("vtex/loaders/productList");
106
+ const response = await RequestContext.run(request, () => handleInvoke(request));
107
+ expect(response.headers.getSetCookie()).toEqual([]);
108
+ });
109
+ });
110
+
111
+ describe("handleInvoke — Set-Cookie propagation (batch)", () => {
112
+ beforeEach(() => clearInvokeHandlers());
113
+ afterEach(() => clearInvokeHandlers());
114
+
115
+ it("forwards cookies that batch handlers append to the shared context", async () => {
116
+ registerInvokeHandlers({
117
+ "vtex/actions/addItemsToCart": async () => {
118
+ RequestContext.responseHeaders.append("set-cookie", COOKIE_A);
119
+ RequestContext.responseHeaders.append("set-cookie", COOKIE_B);
120
+ return { orderFormId: "of-123" };
121
+ },
122
+ "vtex/loaders/productList": async () => {
123
+ // Loader writes its own cookie (e.g. segment) — must also propagate.
124
+ RequestContext.responseHeaders.append("set-cookie", COOKIE_C);
125
+ return { items: [] };
126
+ },
127
+ });
128
+
129
+ const request = makeBatchRequest({
130
+ "vtex/actions/addItemsToCart": { orderFormId: "x" },
131
+ "vtex/loaders/productList": {},
132
+ });
133
+ const response = await RequestContext.run(request, () => handleInvoke(request));
134
+
135
+ const cookies = response.headers.getSetCookie();
136
+ expect(cookies).toHaveLength(3);
137
+ expect(cookies).toContain(COOKIE_A);
138
+ expect(cookies).toContain(COOKIE_B);
139
+ expect(cookies).toContain(COOKIE_C);
140
+ });
141
+ });
@@ -58,6 +58,41 @@ export function clearInvokeHandlers(): void {
58
58
 
59
59
  const JSON_HEADERS = { "Content-Type": "application/json" } as const;
60
60
 
61
+ /**
62
+ * Copy headers that handlers wrote to `RequestContext.responseHeaders`
63
+ * onto an outgoing Response.
64
+ *
65
+ * Why this exists and is not a `for…of headers.entries()` one-liner:
66
+ * `Headers.entries()` (and `forEach`) collapses multiple `Set-Cookie`
67
+ * values into a single comma-joined string (per the Fetch spec).
68
+ * Browsers silently discard cookies whose value contains an unescaped
69
+ * comma, so every VTEX cart action that returns multiple cookies
70
+ * (`checkout.vtex.com__orderFormId`, `segment`, `sc`, `vtex_session`…)
71
+ * loses them in transit. The next request creates a fresh empty
72
+ * orderForm and the user lands on /checkout with an empty cart.
73
+ *
74
+ * `Headers.getSetCookie()` is the spec-blessed way to read the
75
+ * un-collapsed list. We append each value individually onto the
76
+ * response so the browser sees N distinct `Set-Cookie` headers, and
77
+ * use `forEach` to copy any non-cookie headers as-is.
78
+ */
79
+ function forwardCtxHeadersTo(response: Response): void {
80
+ const ctx = RequestContext.current;
81
+ if (!ctx) return;
82
+ const cookies =
83
+ typeof ctx.responseHeaders.getSetCookie === "function"
84
+ ? ctx.responseHeaders.getSetCookie()
85
+ : [];
86
+ for (const cookie of cookies) {
87
+ response.headers.append("set-cookie", cookie);
88
+ }
89
+ ctx.responseHeaders.forEach((value, key) => {
90
+ if (key.toLowerCase() !== "set-cookie") {
91
+ response.headers.append(key, value);
92
+ }
93
+ });
94
+ }
95
+
61
96
  const isDev =
62
97
  typeof globalThis.process !== "undefined" && globalThis.process.env?.NODE_ENV === "development";
63
98
 
@@ -171,17 +206,7 @@ export async function handleInvoke(request: Request): Promise<Response> {
171
206
  }
172
207
  const filtered = selectFields(result, select);
173
208
  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
-
209
+ forwardCtxHeadersTo(response);
185
210
  return response;
186
211
  } catch (error) {
187
212
  return errorResponse((error as Error).message, 500, error);
@@ -202,8 +227,11 @@ export async function handleInvoke(request: Request): Promise<Response> {
202
227
  try {
203
228
  let result = await found.handler(payload, request);
204
229
  // If a loader returns a Response, extract its JSON body for batching.
205
- // Set-Cookie headers from batch items are not forwarded individually
206
- // (use single invoke for auth loaders that need cookie passthrough).
230
+ // Set-Cookie values from a handler-returned Response are *not*
231
+ // forwarded those leave the AsyncLocalStorage scope. Handlers
232
+ // that need cookie passthrough must write to
233
+ // RequestContext.responseHeaders (which forwardCtxHeadersTo()
234
+ // below propagates onto the batch response).
207
235
  if (result instanceof Response) {
208
236
  try { result = await result.json(); } catch { result = null; }
209
237
  }
@@ -217,7 +245,12 @@ export async function handleInvoke(request: Request): Promise<Response> {
217
245
  }),
218
246
  );
219
247
 
220
- return new Response(JSON.stringify(results), { status: 200, headers: JSON_HEADERS });
248
+ const response = new Response(JSON.stringify(results), { status: 200, headers: JSON_HEADERS });
249
+ // All batch handlers share the same RequestContext, so any Set-Cookie
250
+ // they appended (e.g. from VTEX vtexFetchWithCookies) is in
251
+ // `responseHeaders` by now. Forward it as N distinct Set-Cookie headers.
252
+ forwardCtxHeadersTo(response);
253
+ return response;
221
254
  }
222
255
 
223
256
  return errorResponse("No invoke key specified", 400);
@@ -253,6 +253,22 @@ export const MetricNames = {
253
253
  * `MIGRATION_TOOLING_PLAN.md` for the rationale.
254
254
  */
255
255
  COMMERCE_REQUEST_DURATION_MS: "commerce_request_duration_ms",
256
+ /**
257
+ * Per-loader execution duration. Emitted by `cachedLoader` for every
258
+ * loader call — cached or not. The `cache_status` label lets dashboards
259
+ * separate origin latency from in-memory hit latency without needing
260
+ * to join on traces.
261
+ *
262
+ * Canonical labels: `loader`, `cache_status`.
263
+ */
264
+ LOADER_DURATION_MS: "loader_duration_ms",
265
+ /**
266
+ * Counter incremented when a loader throws. Complements
267
+ * `loader_duration_ms` for error-rate dashboards.
268
+ *
269
+ * Canonical labels: `loader`.
270
+ */
271
+ LOADER_ERRORS_TOTAL: "loader_errors_total",
256
272
  } as const;
257
273
 
258
274
  /**
@@ -455,6 +471,35 @@ export function recordCommerceMetric(
455
471
  m.histogramRecord?.(MetricNames.COMMERCE_REQUEST_DURATION_MS, durationMs, merged);
456
472
  }
457
473
 
474
+ /**
475
+ * Record a loader execution sample. Call from `cachedLoader` after the
476
+ * loader resolves or rejects. `cache_status` mirrors `CacheDecision` so
477
+ * dashboards can distinguish HIT (fresh) from STALE-HIT (SWR), STALE-ERROR
478
+ * (SIE fallback), MISS (origin fetch), and BYPASS (dev / no-store).
479
+ */
480
+ export function recordLoaderMetric(
481
+ name: string,
482
+ durationMs: number,
483
+ cacheStatus: CacheDecision | "BYPASS",
484
+ ) {
485
+ const m = getState().meter;
486
+ if (!m) return;
487
+ m.histogramRecord?.(MetricNames.LOADER_DURATION_MS, durationMs, {
488
+ loader: name,
489
+ cache_status: cacheStatus,
490
+ });
491
+ }
492
+
493
+ /**
494
+ * Increment the loader error counter. Call when a loader throws and the
495
+ * error is not swallowed by a SIE fallback.
496
+ */
497
+ export function recordLoaderError(name: string) {
498
+ const m = getState().meter;
499
+ if (!m) return;
500
+ m.counterInc(MetricNames.LOADER_ERRORS_TOTAL, 1, { loader: name });
501
+ }
502
+
458
503
  function normalizePath(path: string): string {
459
504
  // Collapse dynamic segments to reduce cardinality
460
505
  return path
@@ -11,7 +11,12 @@
11
11
  * (e.g. "product") which derives timing from the unified profile system.
12
12
  */
13
13
 
14
- import { recordCacheMetric, withTracing } from "../middleware/observability";
14
+ import {
15
+ recordCacheMetric,
16
+ recordLoaderError,
17
+ recordLoaderMetric,
18
+ withTracing,
19
+ } from "../middleware/observability";
15
20
  import { type CacheProfileName, loaderCacheOptions } from "./cacheHeaders";
16
21
 
17
22
  export type CachePolicy = "no-store" | "no-cache" | "stale-while-revalidate";
@@ -100,17 +105,32 @@ export function createCachedLoader<TProps, TResult>(
100
105
  if (inflight) {
101
106
  // Treat in-flight dedup as a cache hit — avoided the origin call.
102
107
  recordCacheMetric(true, name, undefined, "cachedLoader");
103
- return inflight as Promise<TResult>;
108
+ const start = performance.now();
109
+ return inflight.then((r) => {
110
+ recordLoaderMetric(name, performance.now() - start, "HIT");
111
+ return r as TResult;
112
+ });
104
113
  }
105
114
 
106
115
  if (isDev) {
107
116
  // Dev mode: no caching, but still useful to count attempts.
108
117
  recordCacheMetric(false, name, undefined, "cachedLoader");
118
+ const devStart = performance.now();
109
119
  const promise = withTracing(
110
120
  "deco.cachedLoader",
111
- () => loaderFn(props).finally(() => inflightRequests.delete(cacheKey)),
121
+ () => loaderFn(props),
112
122
  { "deco.loader": name, "deco.cache.policy": "no-cache-dev" },
113
- );
123
+ )
124
+ .then((r) => {
125
+ recordLoaderMetric(name, performance.now() - devStart, "BYPASS");
126
+ return r;
127
+ })
128
+ .catch((err) => {
129
+ recordLoaderMetric(name, performance.now() - devStart, "BYPASS");
130
+ recordLoaderError(name);
131
+ throw err;
132
+ })
133
+ .finally(() => inflightRequests.delete(cacheKey));
114
134
  inflightRequests.set(cacheKey, promise);
115
135
  return promise;
116
136
  }
@@ -122,6 +142,7 @@ export function createCachedLoader<TProps, TResult>(
122
142
  if (policy === "no-cache") {
123
143
  if (entry && !isStale) {
124
144
  recordCacheMetric(true, name, "HIT", "cachedLoader");
145
+ recordLoaderMetric(name, 0, "HIT");
125
146
  return entry.value;
126
147
  }
127
148
  }
@@ -129,12 +150,14 @@ export function createCachedLoader<TProps, TResult>(
129
150
  if (policy === "stale-while-revalidate") {
130
151
  if (entry && !isStale) {
131
152
  recordCacheMetric(true, name, "HIT", "cachedLoader");
153
+ recordLoaderMetric(name, 0, "HIT");
132
154
  return entry.value;
133
155
  }
134
156
 
135
157
  if (entry && isStale && !entry.refreshing) {
136
158
  // Stale-while-revalidate hit: serve stale, refresh in background.
137
159
  recordCacheMetric(true, name, "STALE-HIT", "cachedLoader");
160
+ recordLoaderMetric(name, 0, "STALE-HIT");
138
161
  entry.refreshing = true;
139
162
  loaderFn(props)
140
163
  .then((result) => {
@@ -160,6 +183,7 @@ export function createCachedLoader<TProps, TResult>(
160
183
  // the decision as STALE-ERROR so dashboards can distinguish
161
184
  // this from healthy SWR.
162
185
  recordCacheMetric(true, name, "STALE-ERROR", "cachedLoader");
186
+ recordLoaderMetric(name, 0, "STALE-ERROR");
163
187
  return entry.value;
164
188
  }
165
189
  }
@@ -167,11 +191,13 @@ export function createCachedLoader<TProps, TResult>(
167
191
  // Cache miss — emit metric, then run loader inside a span so individual
168
192
  // slow loaders are visible in traces.
169
193
  recordCacheMetric(false, name, "MISS", "cachedLoader");
194
+ const loaderStart = performance.now();
170
195
  const promise = withTracing("deco.cachedLoader", () => loaderFn(props), {
171
196
  "deco.loader": name,
172
197
  "deco.cache.policy": policy,
173
198
  })
174
199
  .then((result) => {
200
+ recordLoaderMetric(name, performance.now() - loaderStart, "MISS");
175
201
  cache.set(cacheKey, {
176
202
  value: result,
177
203
  createdAt: Date.now(),
@@ -190,9 +216,12 @@ export function createCachedLoader<TProps, TResult>(
190
216
  console.warn(
191
217
  `[cachedLoader] ${name}: origin error, serving stale entry (age=${Math.round(age / 1000)}s, sie=${Math.round(staleIfError / 1000)}s)`,
192
218
  );
219
+ recordLoaderMetric(name, performance.now() - loaderStart, "STALE-ERROR");
193
220
  return entry.value;
194
221
  }
195
222
  }
223
+ recordLoaderMetric(name, performance.now() - loaderStart, "MISS");
224
+ recordLoaderError(name);
196
225
  throw err;
197
226
  });
198
227
 
@@ -65,6 +65,8 @@ export {
65
65
  MetricNames,
66
66
  recordCacheMetric,
67
67
  recordCommerceMetric,
68
+ recordLoaderError,
69
+ recordLoaderMetric,
68
70
  recordRequestMetric,
69
71
  type RequestMetricLabels,
70
72
  type RequestStore,