@decocms/start 6.2.0 → 6.2.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/.agents/skills/deco-migrate-script/SKILL.md +4 -3
- package/.cursor/skills/deco-apps-vtex-porting/cookie-auth-patterns.md +13 -0
- package/.cursor/skills/deco-apps-vtex-review/SKILL.md +15 -0
- package/.cursor/skills/deco-server-functions-invoke/troubleshooting.md +22 -0
- package/package.json +1 -1
- package/scripts/migrate/post-cleanup/rules.ts +90 -0
- package/scripts/migrate/post-cleanup/runner.test.ts +103 -0
- package/scripts/migrate.ts +13 -0
- package/src/admin/invoke.test.ts +141 -0
- package/src/admin/invoke.ts +47 -14
|
@@ -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. `
|
|
297
|
-
2. `
|
|
298
|
-
3. `
|
|
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
|
@@ -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 = [
|
package/scripts/migrate.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/admin/invoke.ts
CHANGED
|
@@ -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
|
|
206
|
-
//
|
|
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
|
-
|
|
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);
|