@decocms/apps-salesforce 7.2.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.
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Salesforce Marketing Cloud Personalization — campaign list loader.
3
+ *
4
+ * Hits `POST {baseUrl}/api2/event/:dataset` with the user identifier
5
+ * extracted from the Evergage cookie and returns the products attached
6
+ * to the matching campaign payload. When no cookie exists (first visit
7
+ * / parity bot / SSR), `parseUserCookie` falls back to the literal
8
+ * `anonymous` so Evergage still responds with a default campaign
9
+ * instead of erroring.
10
+ *
11
+ * Cookies are read via `getCookies()` from `@tanstack/react-start/server`
12
+ * — the request object isn't propagated through the framework's
13
+ * `commerceLoader(resolvedProps)` call, so we rely on AsyncLocalStorage
14
+ * to recover the original request from inside the deferred section
15
+ * server function context.
16
+ */
17
+ import type { Product } from "@decocms/apps-commerce/types";
18
+ import type {
19
+ CampaignResponse,
20
+ PersonalizationBody,
21
+ PersonalizationResponse,
22
+ SalesforceProduct,
23
+ } from "../../types";
24
+ import { createHttpClient } from "../../utils/httpClient";
25
+ import { parseUserCookie } from "../../utils/parseUserCookie";
26
+ import { createProductTransformer, type PropertyMapper } from "../../utils/transform";
27
+
28
+ export interface SalesforceListLoaderProps {
29
+ /**
30
+ * @title Personalization Base URL
31
+ * @description e.g. `https://<account>.us-5.evergage.com`
32
+ */
33
+ baseUrl: string;
34
+ /** @title Personalization Dataset */
35
+ dataset: string;
36
+ /** @title Campaign Id */
37
+ campaignId: string;
38
+ /**
39
+ * @title Cookie Name
40
+ * @description Cookie name Evergage drops on the browser
41
+ * (e.g. `_evga_<accountSlug>`)
42
+ */
43
+ cookieName: string;
44
+ /** @title Currency Code (ISO 4217) */
45
+ currencyCode?: string;
46
+ /** Custom property mapper passed by site wrappers. */
47
+ propertyMapper?: PropertyMapper;
48
+ }
49
+
50
+ export interface SalesforceListResult {
51
+ "@type": "ProductList";
52
+ list: Product[];
53
+ additionalData: {
54
+ title?: string;
55
+ campaignId: string;
56
+ experienceId?: string;
57
+ userGroup?: string;
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Read the named cookie from the in-flight request. Imported lazily so
63
+ * the loader stays runtime-agnostic for unit tests (a manual cookie
64
+ * value can be passed via `__testCookieOverride`).
65
+ */
66
+ async function readCookie(cookieName: string): Promise<string | undefined> {
67
+ try {
68
+ const { getCookies } = await import("@tanstack/react-start/server");
69
+ const cookies = getCookies();
70
+ return cookies?.[cookieName];
71
+ } catch {
72
+ return undefined;
73
+ }
74
+ }
75
+
76
+ export default async function salesforceListLoader(
77
+ props: SalesforceListLoaderProps,
78
+ _req?: Request,
79
+ ): Promise<SalesforceListResult | null> {
80
+ const { baseUrl, dataset, campaignId, cookieName, currencyCode, propertyMapper } = props;
81
+
82
+ try {
83
+ const rawCookie = await readCookie(cookieName);
84
+ const userData = parseUserCookie(rawCookie);
85
+
86
+ const client = createHttpClient({
87
+ base: baseUrl,
88
+ headers: { "x-requested-with": "XMLHttpRequest" },
89
+ });
90
+
91
+ const requestBody: PersonalizationBody = {
92
+ source: { channel: "WebServer", url: `mcp_campaign=${campaignId}` },
93
+ interaction: { name: "Personalization Campaigns" },
94
+ user: userData,
95
+ flags: { nonInteractive: true, doNotTrack: false },
96
+ pageView: false,
97
+ };
98
+
99
+ const response = (await client["POST /api2/event/:dataset"](
100
+ { dataset },
101
+ { body: requestBody },
102
+ ).then((res: { json: () => Promise<PersonalizationResponse> }) =>
103
+ res.json(),
104
+ )) as PersonalizationResponse;
105
+
106
+ const payload =
107
+ response.campaignResponses?.find((item: CampaignResponse) => item.campaignId === campaignId)
108
+ ?.payload ?? response.campaignResponses?.[0]?.payload;
109
+
110
+ if (!payload?.products?.length) {
111
+ return null;
112
+ }
113
+
114
+ const transform = createProductTransformer({ propertyMapper });
115
+
116
+ return {
117
+ "@type": "ProductList",
118
+ list: payload.products.map((product: SalesforceProduct) =>
119
+ transform({
120
+ product,
121
+ options: { currencyCode: currencyCode ?? product.currency },
122
+ }),
123
+ ),
124
+ additionalData: {
125
+ title: payload.headerText,
126
+ campaignId,
127
+ experienceId: payload.experience,
128
+ userGroup: payload.userGroup,
129
+ },
130
+ };
131
+ } catch (err) {
132
+ // The legacy Deno loader swallowed errors and returned null. We
133
+ // keep the same return shape (so consumers don't have to handle
134
+ // rejections) but log the failure — silent null hides API outages
135
+ // and CORS regressions during parity validation.
136
+ console.error("[salesforce/products/list] failed:", (err as Error)?.message);
137
+ return null;
138
+ }
139
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Salesforce Marketing Cloud Personalization — cart-aware recommendations.
3
+ *
4
+ * Same wire format as `list.ts`, but the interaction name is
5
+ * `"Replace Cart"` and the request body carries the current cart's line
6
+ * items so Evergage can seed cross-sell / "frequently bought together"
7
+ * campaigns from the items already in the basket.
8
+ *
9
+ * Sites typically call this loader from a "cart drawer" / "cart side panel"
10
+ * section. The cart state itself isn't read from cookies here — sites
11
+ * resolve their own cart (Magento, Shopify, custom) and pass `items` as
12
+ * a flat array. When `items` is empty, the loader short-circuits and
13
+ * returns `null` (no cart → no cross-sell).
14
+ */
15
+ import type { Product } from "@decocms/apps-commerce/types";
16
+ import type {
17
+ CampaignResponse,
18
+ PersonalizationBody,
19
+ PersonalizationResponse,
20
+ SalesforceProduct,
21
+ } from "../../types";
22
+ import { createHttpClient } from "../../utils/httpClient";
23
+ import { parseUserCookie } from "../../utils/parseUserCookie";
24
+ import { createProductTransformer, type PropertyMapper } from "../../utils/transform";
25
+
26
+ export interface SalesforceListCartItem {
27
+ sku: string;
28
+ qty: number;
29
+ price: number;
30
+ }
31
+
32
+ export interface SalesforceListCartProps {
33
+ /**
34
+ * @title Cart Items
35
+ * @description Items currently in the user's cart. Site loaders resolve
36
+ * this from their commerce backend (Magento, Shopify, etc.) and pass
37
+ * it through.
38
+ */
39
+ items: SalesforceListCartItem[];
40
+ /** @title Personalization Base URL */
41
+ baseUrl: string;
42
+ /** @title Personalization Dataset */
43
+ dataset: string;
44
+ /** @title Campaign Id */
45
+ campaignId: string;
46
+ /** @title Cookie Name */
47
+ cookieName: string;
48
+ /** @title Currency Code (ISO 4217) */
49
+ currencyCode?: string;
50
+ /**
51
+ * @title Fallback Title
52
+ * @description Shown when the campaign payload has no `headerText`
53
+ * (e.g. Granado uses the configured `label` from the CMS block).
54
+ */
55
+ title?: string;
56
+ /** Custom property mapper. */
57
+ propertyMapper?: PropertyMapper;
58
+ }
59
+
60
+ export interface SalesforceListCartResult {
61
+ "@type": "ProductList";
62
+ list: Product[];
63
+ additionalData: {
64
+ title?: string;
65
+ campaignId: string;
66
+ experienceId?: string;
67
+ userGroup?: string;
68
+ };
69
+ }
70
+
71
+ async function readCookie(cookieName: string): Promise<string | undefined> {
72
+ try {
73
+ const { getCookies } = await import("@tanstack/react-start/server");
74
+ const cookies = getCookies();
75
+ return cookies?.[cookieName];
76
+ } catch {
77
+ return undefined;
78
+ }
79
+ }
80
+
81
+ export default async function salesforceListCartLoader(
82
+ props: SalesforceListCartProps,
83
+ _req?: Request,
84
+ ): Promise<SalesforceListCartResult | null> {
85
+ const { items, baseUrl, dataset, campaignId, cookieName, currencyCode, title, propertyMapper } =
86
+ props;
87
+
88
+ if (!items?.length) return null;
89
+
90
+ try {
91
+ const rawCookie = await readCookie(cookieName);
92
+ const userData = parseUserCookie(rawCookie);
93
+
94
+ const client = createHttpClient({
95
+ base: baseUrl,
96
+ headers: { "x-requested-with": "XMLHttpRequest" },
97
+ });
98
+
99
+ const requestBody: PersonalizationBody = {
100
+ source: { channel: "WebServer", url: `mcp_campaign=${campaignId}` },
101
+ interaction: {
102
+ name: "Replace Cart",
103
+ lineItems: items.map(({ sku, qty, price }) => ({
104
+ catalogObjectType: "Product",
105
+ catalogObjectId: sku,
106
+ quantity: qty,
107
+ price,
108
+ })),
109
+ },
110
+ user: userData,
111
+ flags: { nonInteractive: true, doNotTrack: false },
112
+ pageView: false,
113
+ };
114
+
115
+ const response = (await client["POST /api2/event/:dataset"](
116
+ { dataset },
117
+ { body: requestBody },
118
+ ).then((res: { json: () => Promise<PersonalizationResponse> }) =>
119
+ res.json(),
120
+ )) as PersonalizationResponse;
121
+
122
+ const payload =
123
+ response.campaignResponses?.find((item: CampaignResponse) => item.campaignId === campaignId)
124
+ ?.payload ?? response.campaignResponses?.[0]?.payload;
125
+
126
+ if (!payload?.products?.length) {
127
+ return null;
128
+ }
129
+
130
+ const transform = createProductTransformer({ propertyMapper });
131
+
132
+ return {
133
+ "@type": "ProductList",
134
+ list: payload.products.map((product: SalesforceProduct) =>
135
+ transform({
136
+ product,
137
+ options: { currencyCode: currencyCode ?? product.currency },
138
+ }),
139
+ ),
140
+ additionalData: {
141
+ title: payload.headerText || title,
142
+ campaignId,
143
+ experienceId: payload.experience,
144
+ userGroup: payload.userGroup,
145
+ },
146
+ };
147
+ } catch (err) {
148
+ console.error("[salesforce/products/listCart] failed:", (err as Error)?.message);
149
+ return null;
150
+ }
151
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Salesforce Marketing Cloud Personalization — recommendations loader.
3
+ *
4
+ * Same wire format as `list.ts`, but the campaign Evergage runs is the
5
+ * "related products" one and the request body includes the
6
+ * `viewedProductId` attribute so Evergage can use the in-context PDP
7
+ * to seed its recommendation model.
8
+ *
9
+ * Used on PDP / PDC pages; the upstream site passes the resolved
10
+ * `ProductDetailsPage` so we can read `product.sku`. When no product
11
+ * context is available the loader still sends the campaign request —
12
+ * Evergage will return its default fallback list.
13
+ */
14
+ import type { Product, ProductDetailsPage } from "@decocms/apps-commerce/types";
15
+ import type { CampaignResponse, PersonalizationResponse, SalesforceProduct } from "../../types";
16
+ import { createHttpClient } from "../../utils/httpClient";
17
+ import { parseUserCookie } from "../../utils/parseUserCookie";
18
+ import { createProductTransformer, type PropertyMapper } from "../../utils/transform";
19
+
20
+ export interface SalesforceListRecommendedProps {
21
+ /** @title Product Id (resolved PDP) */
22
+ productId: ProductDetailsPage | null;
23
+ /** @title Personalization Base URL */
24
+ baseUrl: string;
25
+ /** @title Personalization Dataset */
26
+ dataset: string;
27
+ /** @title Campaign Id */
28
+ campaignId: string;
29
+ /** @title Cookie Name */
30
+ cookieName: string;
31
+ /** @title Currency Code (ISO 4217) */
32
+ currencyCode?: string;
33
+ /** Custom property mapper. */
34
+ propertyMapper?: PropertyMapper;
35
+ }
36
+
37
+ export interface SalesforceListRecommendedResult {
38
+ "@type": "ProductList";
39
+ list: Product[];
40
+ additionalData: {
41
+ title?: string;
42
+ campaignId: string;
43
+ experienceId?: string;
44
+ userGroup?: string;
45
+ };
46
+ }
47
+
48
+ async function readCookie(cookieName: string): Promise<string | undefined> {
49
+ try {
50
+ const { getCookies } = await import("@tanstack/react-start/server");
51
+ const cookies = getCookies();
52
+ return cookies?.[cookieName];
53
+ } catch {
54
+ return undefined;
55
+ }
56
+ }
57
+
58
+ export default async function salesforceListRecommendedLoader(
59
+ props: SalesforceListRecommendedProps,
60
+ _req?: Request,
61
+ ): Promise<SalesforceListRecommendedResult | null> {
62
+ const { baseUrl, dataset, productId, cookieName, campaignId, currencyCode, propertyMapper } =
63
+ props;
64
+
65
+ try {
66
+ const rawCookie = await readCookie(cookieName);
67
+ const userData = parseUserCookie(rawCookie);
68
+
69
+ const client = createHttpClient({
70
+ base: baseUrl,
71
+ headers: { "x-requested-with": "XMLHttpRequest" },
72
+ });
73
+
74
+ const requestBody = {
75
+ source: { channel: "WebServer", url: `mcp_campaign=${campaignId}` },
76
+ interaction: { name: "Personalization Campaigns" },
77
+ user: {
78
+ ...userData,
79
+ attributes: { viewedProductId: productId?.product?.sku ?? "" },
80
+ },
81
+ flags: { nonInteractive: true, doNotTrack: false },
82
+ pageView: false,
83
+ };
84
+
85
+ const response = (await client["POST /api2/event/:dataset"](
86
+ { dataset },
87
+ { body: requestBody },
88
+ ).then((res: { json: () => Promise<PersonalizationResponse> }) =>
89
+ res.json(),
90
+ )) as PersonalizationResponse;
91
+
92
+ const payload =
93
+ response.campaignResponses?.find((item: CampaignResponse) => item.campaignId === campaignId)
94
+ ?.payload ?? response.campaignResponses?.[0]?.payload;
95
+
96
+ if (!payload?.products?.length) {
97
+ return null;
98
+ }
99
+
100
+ const transform = createProductTransformer({ propertyMapper });
101
+
102
+ return {
103
+ "@type": "ProductList",
104
+ list: payload.products.map((product: SalesforceProduct) =>
105
+ transform({
106
+ product,
107
+ options: { currencyCode: currencyCode ?? product.currency },
108
+ }),
109
+ ),
110
+ additionalData: {
111
+ title: payload.headerText,
112
+ campaignId,
113
+ experienceId: payload.experience,
114
+ userGroup: payload.userGroup,
115
+ },
116
+ };
117
+ } catch (err) {
118
+ console.error("[salesforce/products/listRecomended] failed:", (err as Error)?.message);
119
+ return null;
120
+ }
121
+ }
package/src/types.ts ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Salesforce Marketing Cloud Personalization (Evergage) — request/response
3
+ * shapes for the campaign personalization API.
4
+ *
5
+ * The Evergage product schema is configurable per dataset (each customer
6
+ * chooses which fields to expose), so `SalesforceProduct` keeps a minimal
7
+ * required surface and accepts arbitrary extras via index signature.
8
+ * Site-level transformers can downcast to their own product shape to
9
+ * read store-specific custom fields (e.g. `marca`, `linha`, brand-tags).
10
+ */
11
+
12
+ export interface SalesforceProduct {
13
+ /** Internal Evergage id (string token, often a SKU or catalog id). */
14
+ id: string;
15
+ /** Display name. */
16
+ name: string;
17
+ /** Base price (list). */
18
+ price: number;
19
+ /** Discounted price; falls back to `price` when no promotion is active. */
20
+ salePrice?: number;
21
+ /** Stock count from Evergage's catalog feed. */
22
+ inventoryCount: number;
23
+ /** Pre-built absolute URLs of product images. */
24
+ imageUrls: string[];
25
+ /** Canonical product URL (full origin + path). */
26
+ url: string;
27
+ /** ISO 4217 currency code (e.g. "BRL"). */
28
+ currency: string;
29
+ /** Common Evergage column — short product description. */
30
+ description?: string;
31
+ /** Optional cross-system identifier (e.g. Magento `entity_id`). */
32
+ idMagento?: string;
33
+ /** Item type tag (e.g. "configurable", "simple"). */
34
+ itemType?: string;
35
+ /** Generic category trail (Evergage stores these as arrays). */
36
+ categories?: string[];
37
+ /** Site-specific extras — Evergage exposes whatever the dataset schema
38
+ * defines (e.g. `Marca`, `Volume`, `Linha`, `freeShipping`). The
39
+ * product transformer can read these via the attributeMapper hook. */
40
+ [customField: string]: unknown;
41
+ }
42
+
43
+ /**
44
+ * Single line item in a cart-interaction request body. Evergage uses
45
+ * these to seed cart-aware recommendations ("people who bought X also
46
+ * bought Y") and abandoned-cart campaigns.
47
+ */
48
+ export interface PersonalizationLineItem {
49
+ catalogObjectType: string;
50
+ catalogObjectId: string;
51
+ quantity: number;
52
+ price: number;
53
+ }
54
+
55
+ /**
56
+ * Body sent to `POST {baseUrl}/api2/event/:dataset`. The Evergage API is
57
+ * "interaction"-based — every request describes an interaction the user
58
+ * had, and the response contains the campaigns triggered by that
59
+ * interaction (recommendations, popups, etc.).
60
+ *
61
+ * `interaction.lineItems` is only used by cart-aware campaigns; PDP /
62
+ * homepage requests omit it. `user.attributes` carries page-context
63
+ * hints (e.g. `viewedProductId` for related-product campaigns).
64
+ */
65
+ export interface PersonalizationBody {
66
+ source: {
67
+ channel: string;
68
+ url: string;
69
+ };
70
+ interaction: {
71
+ name: string;
72
+ lineItems?: PersonalizationLineItem[];
73
+ };
74
+ user: {
75
+ anonymousId?: string;
76
+ encryptedId?: string;
77
+ attributes?: Record<string, unknown>;
78
+ };
79
+ flags: {
80
+ nonInteractive: boolean;
81
+ doNotTrack: boolean;
82
+ };
83
+ pageView: boolean;
84
+ }
85
+
86
+ export interface CampaignResponse {
87
+ campaignId: string;
88
+ payload: {
89
+ experience?: string;
90
+ headerText?: string;
91
+ products?: SalesforceProduct[];
92
+ userGroup?: string;
93
+ };
94
+ }
95
+
96
+ export interface PersonalizationResponse {
97
+ campaignResponses?: CampaignResponse[];
98
+ }
99
+
100
+ /**
101
+ * Cookie shape Evergage drops on the browser (`puid` = persistent user
102
+ * id after sign-in, `uuid` = anonymous device id). The site loaders
103
+ * read this cookie to send the right user identifier on each request.
104
+ */
105
+ export interface ParsedUserCookie {
106
+ encryptedId?: string;
107
+ anonymousId?: string;
108
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Typed HTTP client compatible with deco-cx/apps' `createHttpClient`
3
+ * signature. Accepts both the simple `.get(path)` / `.post(path, body)`
4
+ * shape and the indexed-route shape used by legacy Deno loaders:
5
+ *
6
+ * ```ts
7
+ * const client = createHttpClient({ base: "https://api.example.com" });
8
+ * await client["POST /api2/event/:dataset"]({ dataset: "prod" }, { body });
9
+ * await client.get("/healthcheck");
10
+ * ```
11
+ *
12
+ * Runtime-agnostic: only requires the global `fetch`. Works on
13
+ * Cloudflare Workers, Bun, Deno, and modern Node — no `node:http`
14
+ * dependency. Returns `{ json, ok, status, headers }` from indexed
15
+ * routes (mirrors the legacy `apps/utils/http.ts` shape so existing
16
+ * loaders that call `.then(res => res.json())` keep working).
17
+ */
18
+
19
+ export interface HttpClientOptions {
20
+ base: string;
21
+ headers?: Record<string, string> | Headers;
22
+ fetcher?: typeof fetch;
23
+ }
24
+
25
+ interface IndexedRouteResponse {
26
+ json: <T = unknown>() => Promise<T>;
27
+ ok: boolean;
28
+ status: number;
29
+ headers: Headers;
30
+ }
31
+
32
+ export function createHttpClient<_Routes = unknown>(options: HttpClientOptions) {
33
+ const base = options.base.replace(/\/$/, "");
34
+ const fetchImpl = options.fetcher ?? fetch;
35
+ const defaultHeaders: Record<string, string> =
36
+ options.headers instanceof Headers
37
+ ? Object.fromEntries(options.headers.entries())
38
+ : (options.headers ?? {});
39
+
40
+ const handler: ProxyHandler<Record<string, unknown>> = {
41
+ get(_target, prop) {
42
+ if (prop === "get") {
43
+ return async <R = unknown>(path: string, init?: RequestInit): Promise<R> => {
44
+ const res = await fetchImpl(`${base}${path}`, {
45
+ ...init,
46
+ headers: {
47
+ ...defaultHeaders,
48
+ ...((init?.headers as Record<string, string>) ?? {}),
49
+ },
50
+ });
51
+ return res.json() as Promise<R>;
52
+ };
53
+ }
54
+ if (prop === "post") {
55
+ return async <R = unknown>(path: string, body: unknown, init?: RequestInit): Promise<R> => {
56
+ const res = await fetchImpl(`${base}${path}`, {
57
+ method: "POST",
58
+ ...init,
59
+ headers: {
60
+ "Content-Type": "application/json",
61
+ ...defaultHeaders,
62
+ ...((init?.headers as Record<string, string>) ?? {}),
63
+ },
64
+ body: JSON.stringify(body),
65
+ });
66
+ return res.json() as Promise<R>;
67
+ };
68
+ }
69
+ if (typeof prop === "string" && /^(GET|POST|PUT|PATCH|DELETE)\s+/.test(prop)) {
70
+ const spaceIdx = prop.indexOf(" ");
71
+ const method = prop.slice(0, spaceIdx);
72
+ let apiPath = prop.slice(spaceIdx + 1);
73
+
74
+ return async (
75
+ params: Record<string, unknown> = {},
76
+ init?: RequestInit & { body?: unknown },
77
+ ): Promise<IndexedRouteResponse> => {
78
+ const cleanParams = { ...params };
79
+
80
+ // `:name` path placeholders — replaced with the matching
81
+ // param value, then removed from the body/query object.
82
+ for (const [key, value] of Object.entries(cleanParams)) {
83
+ const placeholder = `:${key}`;
84
+ if (apiPath.includes(placeholder) && value != null) {
85
+ apiPath = apiPath.replace(placeholder, encodeURIComponent(String(value)));
86
+ delete cleanParams[key];
87
+ }
88
+ }
89
+
90
+ // Legacy `*name` placeholders (Deno-era convention).
91
+ const starMatch = apiPath.match(/\*(\w+)/);
92
+ if (starMatch) {
93
+ const paramName = starMatch[1];
94
+ if (cleanParams[paramName] != null) {
95
+ apiPath = apiPath.replace(`*${paramName}`, String(cleanParams[paramName]));
96
+ delete cleanParams[paramName];
97
+ } else {
98
+ apiPath = apiPath.replace(/\/\*\w+/, "");
99
+ }
100
+ }
101
+
102
+ let url = `${base}${apiPath}`;
103
+
104
+ if (method === "GET") {
105
+ const sp = new URLSearchParams();
106
+ for (const [k, v] of Object.entries(cleanParams)) {
107
+ if (v !== undefined && v !== null) sp.set(k, String(v));
108
+ }
109
+ const qs = sp.toString();
110
+ if (qs) url += (url.includes("?") ? "&" : "?") + qs;
111
+ }
112
+
113
+ // Indexed-route callers can either embed the body in the
114
+ // remaining params (legacy style) OR pass `{ body }` in the
115
+ // second arg (newer call sites). The `body` key in init
116
+ // takes precedence when present.
117
+ const explicitBody = init && "body" in init ? init.body : undefined;
118
+ const fetchBody =
119
+ method === "GET"
120
+ ? undefined
121
+ : explicitBody !== undefined
122
+ ? JSON.stringify(explicitBody)
123
+ : Object.keys(cleanParams).length > 0
124
+ ? JSON.stringify(cleanParams)
125
+ : undefined;
126
+
127
+ const fetchInit: RequestInit = {
128
+ method,
129
+ ...(init ?? {}),
130
+ headers: {
131
+ "Content-Type": "application/json",
132
+ ...defaultHeaders,
133
+ ...(init?.headers instanceof Headers
134
+ ? Object.fromEntries(init.headers.entries())
135
+ : ((init?.headers as Record<string, string>) ?? {})),
136
+ },
137
+ ...(fetchBody !== undefined ? { body: fetchBody } : {}),
138
+ };
139
+
140
+ const res = await fetchImpl(url, fetchInit);
141
+ return {
142
+ json: <T = unknown>() => res.json() as Promise<T>,
143
+ ok: res.ok,
144
+ status: res.status,
145
+ headers: res.headers,
146
+ };
147
+ };
148
+ }
149
+ return undefined;
150
+ },
151
+ };
152
+
153
+ return new Proxy({} as Record<string, unknown>, handler) as Record<string, any>;
154
+ }