@decocms/apps-magento 8.0.0 → 8.1.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/apps-magento",
3
- "version": "8.0.0",
3
+ "version": "8.1.0-next.0",
4
4
  "type": "module",
5
5
  "description": "Deco commerce app: Magento integration",
6
6
  "repository": {
@@ -26,9 +26,9 @@
26
26
  "lint:unused": "knip"
27
27
  },
28
28
  "dependencies": {
29
- "@decocms/blocks": "8.0.0",
30
- "@decocms/apps-commerce": "8.0.0",
31
- "@decocms/tanstack": "8.0.0"
29
+ "@decocms/blocks": "8.1.0-next.0",
30
+ "@decocms/apps-commerce": "8.1.0-next.0",
31
+ "@decocms/tanstack": "8.1.0-next.0"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "react": "^19.0.0",
package/src/README.md CHANGED
@@ -13,7 +13,7 @@ the original deco-cx/apps repo and need adaptation passes (Deno → Node,
13
13
  ctx-based to client-based state access, cookie helpers from
14
14
  `@decocms/start/sdk/cookie`).
15
15
 
16
- A real-world consumer (deco-sites/granadobr-tanstack) is wiring magento
16
+ A real-world consumer (a production Magento storefront) is wiring magento
17
17
  in-site today using a thin adapter that wraps the legacy `magento/mod.ts`
18
18
  shape. Their adapter is the migration target — once this package covers
19
19
  the surface area they need, the in-site copy goes away.
@@ -54,7 +54,7 @@ above is a hard requirement of this port.
54
54
 
55
55
  ## Why a stub now
56
56
 
57
- The deco-sites/granadobr-tanstack migration hit a HIGH parity finding:
57
+ A production Magento storefront's migration hit a HIGH parity finding:
58
58
 
59
59
  ```
60
60
  invoke(magento/loaders/features) failed: handler not found
@@ -148,15 +148,15 @@ describe("transformFilterGraphQL — merge order", () => {
148
148
 
149
149
  describe("formatUrlSuffix", () => {
150
150
  it("strips a single leading slash", () => {
151
- expect(formatUrlSuffix("/granado/")).toBe("granado/");
151
+ expect(formatUrlSuffix("/acme/")).toBe("acme/");
152
152
  });
153
153
 
154
154
  it("appends trailing slash when missing", () => {
155
- expect(formatUrlSuffix("granado")).toBe("granado/");
155
+ expect(formatUrlSuffix("acme")).toBe("acme/");
156
156
  });
157
157
 
158
158
  it("leaves trailing slash alone", () => {
159
- expect(formatUrlSuffix("granado/")).toBe("granado/");
159
+ expect(formatUrlSuffix("acme/")).toBe("acme/");
160
160
  });
161
161
  });
162
162
 
@@ -0,0 +1,166 @@
1
+ // @vitest-environment node
2
+ /**
3
+ * The v8 Magento client (/next/upstream-clients): every request goes through
4
+ * the instrumented fetch as provider "magento", named by its operation; the
5
+ * store's credentials only go to the store; errors never carry bodies.
6
+ */
7
+ import { beforeEach, describe, expect, it, vi } from "vitest";
8
+
9
+ const instrumented = vi.hoisted(() => ({
10
+ providers: [] as string[],
11
+ operations: [] as (string | undefined)[],
12
+ }));
13
+
14
+ vi.mock("@decocms/blocks/fetch", async (importOriginal) => {
15
+ const real = await importOriginal<typeof import("@decocms/blocks/fetch")>();
16
+ return {
17
+ createInstrumentedFetch: (options: Parameters<typeof real.createInstrumentedFetch>[0]) => {
18
+ instrumented.providers.push(options.provider);
19
+ const request = real.createInstrumentedFetch(options);
20
+ return (input: string | URL | Request, init?: RequestInit & { operation?: string }) => {
21
+ instrumented.operations.push(init?.operation);
22
+ return request(input, init);
23
+ };
24
+ },
25
+ };
26
+ });
27
+
28
+ import { createMagentoClient, MagentoError } from "../magentoClient";
29
+
30
+ /** The error a call rejects with (fails the test if it resolves). */
31
+ const failure = (call: Promise<unknown>): Promise<Error> =>
32
+ call.then(
33
+ () => {
34
+ throw new Error("expected the call to fail");
35
+ },
36
+ (error: Error) => error,
37
+ );
38
+
39
+ function upstream(body: unknown, status = 200) {
40
+ return vi.fn(
41
+ async (_input: string | URL | Request, _init?: RequestInit) =>
42
+ new Response(JSON.stringify(body), { status }),
43
+ );
44
+ }
45
+
46
+ const config = { baseUrl: "https://store.example.com/", apiKey: "key", originHeader: "origin" };
47
+
48
+ beforeEach(() => {
49
+ instrumented.providers.length = 0;
50
+ instrumented.operations.length = 0;
51
+ });
52
+
53
+ describe("createMagentoClient", () => {
54
+ it("calls REST with the store credentials, labeled by operation", async () => {
55
+ const fetch = upstream({ id: 7 });
56
+ const magento = createMagentoClient(config, { fetch });
57
+
58
+ expect(
59
+ await magento.rest<{ id: number }>("/rest/default/V1/carts/7", { operation: "getCart" }),
60
+ ).toEqual({ id: 7 });
61
+ expect(instrumented.providers).toEqual(["magento"]);
62
+ expect(instrumented.operations).toEqual(["getCart"]);
63
+ const [url, init] = fetch.mock.calls[0]!;
64
+ expect(String(url)).toBe("https://store.example.com/rest/default/V1/carts/7");
65
+ const headers = new Headers(init?.headers);
66
+ expect(headers.get("authorization")).toBe("Bearer key");
67
+ expect(headers.get("x-origin-header")).toBe("origin");
68
+ });
69
+
70
+ it("omits the token when a call opts out", async () => {
71
+ const fetch = upstream({});
72
+ const magento = createMagentoClient(config, { fetch });
73
+ await magento.request("/customer/section/load", {
74
+ operation: "loadSections",
75
+ authenticated: false,
76
+ });
77
+ expect(new Headers(fetch.mock.calls[0]![1]?.headers).get("authorization")).toBeNull();
78
+ });
79
+
80
+ it("refuses URLs on another origin, so the token never leaves the store", async () => {
81
+ const fetch = upstream({});
82
+ const magento = createMagentoClient(config, { fetch });
83
+ await expect(magento.request("https://evil.example/steal", { operation: "x" })).rejects.toThrow(
84
+ /only paths on the configured store/,
85
+ );
86
+ await expect(magento.request("//evil.example/steal", { operation: "x" })).rejects.toThrow(
87
+ /only paths on the configured store/,
88
+ );
89
+ expect(fetch).not.toHaveBeenCalled();
90
+ });
91
+
92
+ it("keeps a customer Authorization header instead of the store's token", async () => {
93
+ const fetch = upstream({ data: { customer: { email: "a" } }, items: [] });
94
+ const magento = createMagentoClient(config, { fetch });
95
+ await magento.graphql("query Customer { customer { email } }", undefined, {
96
+ operationName: "Customer",
97
+ headers: { Authorization: "Bearer customer" },
98
+ });
99
+ await magento.rest("/rest/default/V1/carts/mine", {
100
+ operation: "getCart",
101
+ headers: { Authorization: "Bearer customer" },
102
+ });
103
+ for (const [, init] of fetch.mock.calls) {
104
+ expect(new Headers(init?.headers).get("authorization")).toBe("Bearer customer");
105
+ }
106
+ });
107
+
108
+ it("sends GraphQL without the store's token unless asked, and keeps the JSON content type", async () => {
109
+ const fetch = upstream({ data: {} });
110
+ const magento = createMagentoClient(config, { fetch });
111
+ await magento.graphql("query Q { a }", undefined, {
112
+ operationName: "Q",
113
+ headers: { "Content-Type": "text/plain" },
114
+ });
115
+ await magento.graphql("query Q { a }", undefined, { operationName: "Q", authenticated: true });
116
+ const [first, second] = fetch.mock.calls.map(([, init]) => new Headers(init?.headers));
117
+ expect(first!.get("authorization")).toBeNull();
118
+ expect(first!.get("content-type")).toBe("application/json");
119
+ expect(second!.get("authorization")).toBe("Bearer key");
120
+ });
121
+
122
+ it("returns undefined for a 204 REST response", async () => {
123
+ const fetch = vi.fn(async () => new Response(null, { status: 204 }));
124
+ const magento = createMagentoClient(config, { fetch });
125
+ await expect(
126
+ magento.rest("/rest/default/V1/carts/mine/items/1", {
127
+ operation: "removeCartItem",
128
+ method: "DELETE",
129
+ }),
130
+ ).resolves.toBeUndefined();
131
+ });
132
+
133
+ it("sends GraphQL with its operationName, which is the label", async () => {
134
+ const fetch = upstream({ data: { productStockAlert: { status: true } } });
135
+ const magento = createMagentoClient(config, { fetch });
136
+ const data = await magento.graphql<{ productStockAlert: { status: boolean } }>(
137
+ "mutation ProductStockAlert($sku: String!) { productStockAlert(sku: $sku) { status } }",
138
+ { sku: "A1" },
139
+ { operationName: "ProductStockAlert", headers: { Store: "default" } },
140
+ );
141
+ expect(data.productStockAlert.status).toBe(true);
142
+ expect(instrumented.operations).toEqual(["ProductStockAlert"]);
143
+ const [url, init] = fetch.mock.calls[0]!;
144
+ expect(String(url)).toBe("https://store.example.com/graphql");
145
+ expect(new Headers(init?.headers).get("store")).toBe("default");
146
+ expect(JSON.parse(String(init?.body))).toMatchObject({
147
+ operationName: "ProductStockAlert",
148
+ variables: { sku: "A1" },
149
+ });
150
+ });
151
+
152
+ it("throws a MagentoError without the body", async () => {
153
+ const magento = createMagentoClient(config, {
154
+ fetch: upstream({ message: "token key invalid" }, 401),
155
+ });
156
+ const error = await failure(magento.rest("/rest/V1/carts/mine", { operation: "getCart" }));
157
+ expect(error).toBeInstanceOf(MagentoError);
158
+ expect(error.message).toBe("magento getCart failed with HTTP 401");
159
+
160
+ const gql = createMagentoClient(config, {
161
+ fetch: upstream({ errors: [{ message: "x@example.com" }] }),
162
+ });
163
+ const gqlError = await failure(gql.graphql("query Q { a }", undefined, { operationName: "Q" }));
164
+ expect(gqlError.message).toBe("magento Q returned 1 GraphQL error(s)");
165
+ });
166
+ });
package/src/client.ts CHANGED
@@ -15,6 +15,8 @@
15
15
  * Magento has consistent muscle memory.
16
16
  */
17
17
 
18
+ import { type FetchFn, withFetchTimeout } from "@decocms/blocks/sdk/fetchTimeout";
19
+
18
20
  // ---------------------------------------------------------------------------
19
21
  // Config shapes
20
22
  // ---------------------------------------------------------------------------
@@ -56,7 +58,7 @@ export interface MagentoCartConfigs {
56
58
  }
57
59
 
58
60
  export interface MagentoConfig {
59
- /** Magento storefront base URL, e.g. `https://loja.granado.com.br/` */
61
+ /** Magento storefront base URL, e.g. `https://loja.acme.com.br/` */
60
62
  baseUrl: string;
61
63
  /** Bearer token for `Authorization` header on admin REST calls */
62
64
  apiKey: string;
@@ -88,6 +90,28 @@ export interface MagentoConfig {
88
90
 
89
91
  let config: MagentoConfig | null = null;
90
92
 
93
+ /**
94
+ * Underlying fetch used by {@link magentoFetch}. Defaults to `globalThis.fetch`;
95
+ * override once at boot with {@link setMagentoFetch} to plug in the instrumented
96
+ * fetch (spans + `http.client.request.duration` histogram, `provider:"magento"`).
97
+ * Mirrors VTEX's `setVtexFetch` / Shopify's `setShopifyFetch` so every commerce
98
+ * app funnels egress through a single instrumented `_fetch`.
99
+ */
100
+ let _fetch: FetchFn = withFetchTimeout();
101
+
102
+ /**
103
+ * Override the fetch used by every Magento egress call.
104
+ *
105
+ * @example
106
+ * ```ts
107
+ * import { createMagentoFetch, setMagentoFetch } from "@decocms/apps/magento";
108
+ * setMagentoFetch(createMagentoFetch());
109
+ * ```
110
+ */
111
+ export function setMagentoFetch(fetchFn: FetchFn): void {
112
+ _fetch = fetchFn;
113
+ }
114
+
91
115
  export function configureMagento(c: MagentoConfig): void {
92
116
  config = c;
93
117
  }
@@ -224,5 +248,5 @@ export function magentoFetch(path: string, opts: MagentoFetchOpts = {}): Promise
224
248
  // clarity at the call site.
225
249
  const sameOrigin = target.origin === baseUrl.origin;
226
250
 
227
- return fetch(target, { ...opts, headers: buildHeaders(opts, c, sameOrigin) });
251
+ return _fetch(target, { ...opts, headers: buildHeaders(opts, c, sameOrigin) });
228
252
  }
package/src/index.ts CHANGED
@@ -1,3 +1,6 @@
1
+ // v8: the thin Magento client (see /next/upstream-clients).
2
+
3
+ // v7 (kept for v7 consumers until the v7 modules are dropped)
1
4
  /**
2
5
  * Magento app entry point for @decocms/apps.
3
6
  * Re-exports client config + initializer.
@@ -8,4 +11,26 @@
8
11
  * import { magentoFetch } from "@decocms/apps/magento/client"
9
12
  */
10
13
  export * from "./client";
14
+ export {
15
+ createMagentoClient,
16
+ type MagentoClient,
17
+ type MagentoClientConfig,
18
+ type MagentoClientOptions,
19
+ MagentoError,
20
+ type MagentoRequestInit,
21
+ } from "./magentoClient";
11
22
  export type { MagentoCart } from "./types";
23
+ export {
24
+ clearFetchCache,
25
+ type FetchCacheOptions,
26
+ getFetchCacheStats,
27
+ magentoCachedFetch,
28
+ } from "./utils/fetchCache";
29
+ // Observability wiring — sites call `setMagentoFetch(createMagentoFetch())` at
30
+ // boot to route every Magento egress call through the instrumented fetch
31
+ // (upstream latency/status), and use `magentoCachedFetch` for cacheable GETs
32
+ // (SWR hit/miss → `deco.cache.requests{layer="swr",profile="magento"}`).
33
+ export {
34
+ type CreateMagentoFetchOptions,
35
+ createMagentoFetch,
36
+ } from "./utils/instrumentedFetch";
@@ -0,0 +1,129 @@
1
+ /**
2
+ * The Magento client: a thin client for a Magento store's REST and GraphQL
3
+ * APIs, over the framework's instrumented fetch (provider "magento"). See
4
+ * /next/upstream-clients.
5
+ *
6
+ * Configuration comes in as arguments; the site reads its environment (or a
7
+ * `secret` block) where it creates the client. Converters, hooks, cart and
8
+ * session flows, caching and feature toggles belong to the site (platform
9
+ * templates), not here.
10
+ */
11
+ import { createInstrumentedFetch } from "@decocms/blocks/fetch";
12
+
13
+ export interface MagentoClientConfig {
14
+ /** The store's origin, e.g. `https://store.example.com`. Every request goes here. */
15
+ baseUrl: string;
16
+ /** Integration access token, sent as `Authorization: Bearer` unless a call opts out. */
17
+ apiKey?: string;
18
+ /** Optional value sent as `x-origin-header`, for stores behind an origin guard. */
19
+ originHeader?: string;
20
+ }
21
+
22
+ export interface MagentoClientOptions {
23
+ /** The fetch underneath, e.g. a fake in tests. Defaults to `globalThis.fetch`. */
24
+ fetch?: Parameters<typeof createInstrumentedFetch>[0]["fetch"];
25
+ }
26
+
27
+ export interface MagentoRequestInit extends RequestInit {
28
+ /** The operation label, the API's own name for the call (e.g. `getCart`), never a URL. */
29
+ operation: string;
30
+ /**
31
+ * Send the `apiKey` bearer token. Default true for `request`/`rest`, false
32
+ * for `graphql`. Never replaces an `Authorization` header the call sets
33
+ * (e.g. a customer token).
34
+ */
35
+ authenticated?: boolean;
36
+ }
37
+
38
+ /** Thrown on a non-2xx response or a GraphQL `errors` payload. Never carries bodies or tokens. */
39
+ export class MagentoError extends Error {
40
+ constructor(
41
+ readonly operation: string,
42
+ readonly status: number,
43
+ /** How many GraphQL errors the response carried (0 for an HTTP failure). */
44
+ readonly graphqlErrors = 0,
45
+ ) {
46
+ super(
47
+ graphqlErrors > 0
48
+ ? `magento ${operation} returned ${graphqlErrors} GraphQL error(s)`
49
+ : `magento ${operation} failed with HTTP ${status}`,
50
+ );
51
+ this.name = "MagentoError";
52
+ }
53
+ }
54
+
55
+ export type MagentoClient = ReturnType<typeof createMagentoClient>;
56
+
57
+ export function createMagentoClient(
58
+ config: MagentoClientConfig,
59
+ options: MagentoClientOptions = {},
60
+ ) {
61
+ const instrumented = createInstrumentedFetch({ provider: "magento", fetch: options.fetch });
62
+ const origin = new URL(config.baseUrl).origin;
63
+
64
+ /**
65
+ * A request to a path on the store, e.g. `/rest/default/V1/carts/mine` or
66
+ * `/customer/section/load`, returning the raw response. Paths only: the
67
+ * store's credentials never go to another origin.
68
+ */
69
+ async function request(path: string, init: MagentoRequestInit): Promise<Response> {
70
+ const { operation, authenticated = true, ...rest } = init;
71
+ const url = new URL(path, origin);
72
+ if (url.origin !== origin) {
73
+ throw new Error(`magento ${operation}: only paths on the configured store are allowed`);
74
+ }
75
+ const headers = new Headers(rest.headers);
76
+ if (authenticated && config.apiKey && !headers.has("authorization")) {
77
+ headers.set("authorization", `Bearer ${config.apiKey}`);
78
+ }
79
+ if (config.originHeader) headers.set("x-origin-header", config.originHeader);
80
+ return instrumented(url, { ...rest, headers, operation });
81
+ }
82
+
83
+ return {
84
+ request,
85
+
86
+ /** A REST call (`/rest/<store>/V1/...`) returning its parsed JSON body. */
87
+ async rest<T>(path: string, init: MagentoRequestInit): Promise<T> {
88
+ const headers = new Headers(init.headers);
89
+ if (init.body !== undefined && !headers.has("content-type")) {
90
+ headers.set("content-type", "application/json");
91
+ }
92
+ const response = await request(path, { ...init, headers });
93
+ if (!response.ok) throw new MagentoError(init.operation, response.status);
94
+ if (response.status === 204) return undefined as T;
95
+ return (await response.json()) as T;
96
+ },
97
+
98
+ /**
99
+ * A GraphQL operation on `/graphql`. `operationName` is sent with the
100
+ * request and is the operation label. `headers` adds per-call headers,
101
+ * such as `Store` or a customer `Authorization` token. Storefront
102
+ * GraphQL is public, so the `apiKey` is sent only with
103
+ * `authenticated: true`, and never over a customer token.
104
+ */
105
+ async graphql<TData, TVariables = Record<string, unknown>>(
106
+ query: string,
107
+ variables: TVariables | undefined,
108
+ init: { operationName: string; headers?: Record<string, string>; authenticated?: boolean },
109
+ ): Promise<TData> {
110
+ const { operationName, ...rest } = init;
111
+ const headers = new Headers(rest.headers);
112
+ headers.set("content-type", "application/json");
113
+ const response = await request("/graphql", {
114
+ operation: operationName,
115
+ authenticated: rest.authenticated ?? false,
116
+ method: "POST",
117
+ headers,
118
+ body: JSON.stringify({ query, variables, operationName }),
119
+ });
120
+ if (!response.ok) throw new MagentoError(operationName, response.status);
121
+ const body = (await response.json()) as { data?: TData; errors?: unknown[] };
122
+ if (body.errors?.length) {
123
+ throw new MagentoError(operationName, response.status, body.errors.length);
124
+ }
125
+ if (body.data === undefined) throw new MagentoError(operationName, response.status);
126
+ return body.data;
127
+ },
128
+ };
129
+ }
package/src/middleware.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  * after checkout (`changeCardIdAfterCheckout`) and seeded the
6
6
  * `form_key` for anonymous sessions. Both flows touched response
7
7
  * headers and `customer/section/load` endpoints — non-trivial port,
8
- * deferred to a follow-up PR. Today the consumer site (granadobr-
9
- * tanstack) handles cart reconciliation on the client.
8
+ * deferred to a follow-up PR. Today a production Magento storefront
9
+ * handles cart reconciliation on the client.
10
10
  *
11
11
  * Shape matches `@decocms/apps-commerce/app-types` so it can be
12
12
  * plugged into the autoconfig pipeline once magento is registered
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Coverage for the Magento observability wiring added so cache/upstream
3
+ * telemetry flows automatically:
4
+ * - `magentoOperationRouter` names REST resources + GraphQL.
5
+ * - `setMagentoFetch` actually reroutes `magentoFetch`'s egress (the hook
6
+ * that lets `createMagentoFetch()`'s instrumentation take effect).
7
+ */
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9
+ import { magentoOperationRouter } from "../operationRouter";
10
+
11
+ describe("magentoOperationRouter", () => {
12
+ it("names REST resources by their first V1 segment", () => {
13
+ expect(magentoOperationRouter("https://x.com/rest/default/V1/products/42", "GET")).toBe(
14
+ "rest.products",
15
+ );
16
+ expect(magentoOperationRouter("https://x.com/rest/V1/carts/mine", "POST")).toBe("rest.carts");
17
+ });
18
+
19
+ it("names GraphQL calls `graphql`", () => {
20
+ expect(magentoOperationRouter("https://x.com/graphql", "POST")).toBe("graphql");
21
+ });
22
+
23
+ it("returns undefined when nothing matches (framework falls back)", () => {
24
+ expect(magentoOperationRouter("https://x.com/media/logo.png", "GET")).toBeUndefined();
25
+ });
26
+
27
+ it("tolerates non-absolute URLs", () => {
28
+ expect(magentoOperationRouter("/rest/default/V1/orders?x=1", "GET")).toBe("rest.orders");
29
+ });
30
+ });
31
+
32
+ describe("setMagentoFetch routing", () => {
33
+ beforeEach(() => {
34
+ vi.resetModules();
35
+ });
36
+ afterEach(() => {
37
+ vi.restoreAllMocks();
38
+ });
39
+
40
+ it("routes magentoFetch egress through the fetch set via setMagentoFetch", async () => {
41
+ const { configureMagento, setMagentoFetch, magentoFetch } = await import("../../client");
42
+ configureMagento({
43
+ baseUrl: "https://loja.example.com/",
44
+ apiKey: "k",
45
+ storeId: 1,
46
+ site: "example",
47
+ });
48
+ const custom = vi
49
+ .fn()
50
+ .mockResolvedValue(new Response("{}", { status: 200 })) as unknown as typeof fetch;
51
+ const globalSpy = vi.spyOn(globalThis, "fetch");
52
+ setMagentoFetch(custom);
53
+
54
+ await magentoFetch("/rest/default/V1/products/1");
55
+
56
+ expect(custom).toHaveBeenCalledOnce();
57
+ expect(globalSpy).not.toHaveBeenCalled(); // did NOT hit the default fetch
58
+ });
59
+ });
@@ -24,7 +24,7 @@ export interface Customer {
24
24
  }
25
25
 
26
26
  /**
27
- * `carbono-customer` slice. Granado-specific overlay that mirrors the
27
+ * `carbono-customer` slice. A storefront-specific overlay that mirrors the
28
28
  * `customer` slice plus a website/store id pair and a normalized email.
29
29
  * Other magento sites that don't run the Carbono module will get this
30
30
  * absent; loaders/user.ts checks for it before mapping to a Person.
@@ -12,6 +12,31 @@
12
12
 
13
13
  import type { FiltersGraphQL } from "../client";
14
14
 
15
+ // ---------------------------------------------------------------------------
16
+ // SWR fetch-cache tuning (consumed by utils/fetchCache.ts via the shared
17
+ // `createFetchCache` in @decocms/blocks/sdk/fetchCache). Mirrors the shape of
18
+ // VTEX's constants so Magento's cache posture is tuned in one place.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /** Max distinct cache keys kept in memory; oldest `createdAt` evicted first. */
22
+ export const FETCH_CACHE_MAX_ENTRIES = 500;
23
+
24
+ /** How long a cached Magento response stays FRESH, keyed by status class. */
25
+ export const FETCH_CACHE_FRESH_TTL_MS = {
26
+ /** 2xx — 3 min. Catalog/price data doesn't need to be second-fresh. */
27
+ success: 180_000,
28
+ /** 404 — 10s. A just-published product shouldn't 404 for long. */
29
+ notFound: 10_000,
30
+ /** 5xx — never treated as a good cache hit. */
31
+ serverError: 0,
32
+ } as const;
33
+
34
+ /** Stale-if-error window: serve last-good this long past freshness on outage. */
35
+ export const FETCH_CACHE_STALE_IF_ERROR_MS = 86_400_000; // 24h
36
+
37
+ /** Inflight-slot backstop: bounds how long one hung fetch holds a dedup slot. */
38
+ export const FETCH_CACHE_INFLIGHT_BACKSTOP_MS = 15_000;
39
+
15
40
  export const URL_KEY = "url_key";
16
41
 
17
42
  // Schema.org availability mapping (used by utils/transform.ts to
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Magento SWR fetch cache — a thin binding over the shared, instrumented
3
+ * `createFetchCache` in `@decocms/blocks/sdk/fetchCache`.
4
+ *
5
+ * Same shared implementation VTEX uses (in-flight dedup, stale-while-
6
+ * revalidate, stale-if-error, inflight backstop), wired with Magento's tuning
7
+ * constants and the `provider: "magento"` label. Every call emits
8
+ * `deco.cache.requests{layer="swr",profile="magento"}` automatically.
9
+ *
10
+ * Cache key is caller-supplied (not required to be a URL): REST GETs key by
11
+ * their URL; GraphQL POSTs can key by a hash of `query + variables`. Callers
12
+ * pass the closure that performs the actual `magentoFetch`, so a HIT never
13
+ * touches the network.
14
+ */
15
+
16
+ import {
17
+ createFetchCache,
18
+ type FetchCacheOptions as SharedFetchCacheOptions,
19
+ } from "@decocms/blocks/sdk/fetchCache";
20
+ import {
21
+ FETCH_CACHE_FRESH_TTL_MS,
22
+ FETCH_CACHE_INFLIGHT_BACKSTOP_MS,
23
+ FETCH_CACHE_MAX_ENTRIES,
24
+ FETCH_CACHE_STALE_IF_ERROR_MS,
25
+ } from "./constants";
26
+
27
+ export type FetchCacheOptions = SharedFetchCacheOptions;
28
+
29
+ const cache = createFetchCache({
30
+ provider: "magento",
31
+ maxEntries: FETCH_CACHE_MAX_ENTRIES,
32
+ freshTtlMs: FETCH_CACHE_FRESH_TTL_MS,
33
+ staleIfErrorMs: FETCH_CACHE_STALE_IF_ERROR_MS,
34
+ inflightBackstopMs: FETCH_CACHE_INFLIGHT_BACKSTOP_MS,
35
+ });
36
+
37
+ /**
38
+ * Wrap a Magento GET with SWR caching + in-flight dedup. Returns the parsed
39
+ * JSON body, or `null` for cacheable non-2xx responses (e.g. 404). 5xx throw.
40
+ */
41
+ export function magentoCachedFetch<T>(
42
+ cacheKey: string,
43
+ doFetch: () => Promise<Response>,
44
+ opts?: FetchCacheOptions,
45
+ ): Promise<T | null> {
46
+ return cache.fetchWithCache<T>(cacheKey, doFetch, opts);
47
+ }
48
+
49
+ export function clearFetchCache() {
50
+ cache.clear();
51
+ }
52
+
53
+ export function getFetchCacheStats() {
54
+ return cache.getStats();
55
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Pre-wired instrumented fetch factory for Magento.
3
+ *
4
+ * Mirrors `vtex/utils/instrumentedFetch.ts` and `shopify/utils/instrumentedFetch.ts`.
5
+ * Bundles:
6
+ *
7
+ * 1. `createInstrumentedFetch` from `@decocms/blocks/sdk/instrumentedFetch`
8
+ * (spans, traceparent injection, URL redaction, cache-header span attrs).
9
+ * 2. `magentoOperationRouter` as the URL→operation fallback.
10
+ * 3. An `onComplete` that records the canonical
11
+ * `http.client.request.duration` histogram via the framework's
12
+ * `recordCommerceMetric(...)` helper with `provider: "magento"`.
13
+ *
14
+ * Sites opt in once at startup:
15
+ *
16
+ * ```ts
17
+ * import { createMagentoFetch, setMagentoFetch } from "@decocms/apps/magento";
18
+ * setMagentoFetch(createMagentoFetch());
19
+ * ```
20
+ *
21
+ * With this wired, every Magento egress call (GraphQL + REST) funnels through
22
+ * one instrumented boundary, so upstream latency/status lands in ClickHouse.
23
+ * SWR hit/miss for cached GETs is emitted separately by
24
+ * `@decocms/blocks/sdk/fetchCache` (see `./fetchCache.ts`).
25
+ */
26
+
27
+ import {
28
+ createInstrumentedFetch,
29
+ type InstrumentedFetch,
30
+ } from "@decocms/blocks/sdk/instrumentedFetch";
31
+ import { recordCommerceMetric, statusClassFor } from "@decocms/blocks/sdk/observability";
32
+ import { magentoOperationRouter } from "./operationRouter";
33
+
34
+ export interface CreateMagentoFetchOptions {
35
+ /** Underlying fetch to wrap. Defaults to `globalThis.fetch`. */
36
+ baseFetch?: typeof fetch;
37
+ /**
38
+ * Disable the `http.client.request.duration` histogram for Magento calls.
39
+ * Spans + structured logs still emit. Default: false.
40
+ */
41
+ disableHistogram?: boolean;
42
+ }
43
+
44
+ /**
45
+ * Construct a pre-wired Magento `InstrumentedFetch`. Pass the result to
46
+ * `setMagentoFetch(...)`.
47
+ */
48
+ export function createMagentoFetch(options: CreateMagentoFetchOptions = {}): InstrumentedFetch {
49
+ const { baseFetch, disableHistogram = false } = options;
50
+ return createInstrumentedFetch({
51
+ name: "magento",
52
+ baseFetch,
53
+ resolveOperation: magentoOperationRouter,
54
+ onComplete: disableHistogram ? undefined : (r) =>
55
+ recordCommerceMetric(r.durationMs, { provider: "magento", operation: r.operation, status_class: statusClassFor(r.status), cached: r.cached }),
56
+ });
57
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * URL-derived operation name router for Magento API calls.
3
+ *
4
+ * Plugged into `@decocms/blocks/sdk/instrumentedFetch`'s `resolveOperation`
5
+ * option. Mirrors the VTEX/Shopify routers. Magento's surface from this repo is
6
+ * a mix of GraphQL (`/graphql`) and REST (`/rest/<store>/V1/...`); the URL alone
7
+ * can name the REST resource, while GraphQL calls fall back to a single
8
+ * `graphql` operation (the semantic name lives in the document body, which
9
+ * callers may stamp as `init.operation` to override this router).
10
+ */
11
+
12
+ type OperationResolver = string | ((match: RegExpMatchArray, method: string) => string);
13
+
14
+ interface Matcher {
15
+ pattern: RegExp;
16
+ operation: OperationResolver;
17
+ }
18
+
19
+ const m = (pattern: RegExp, operation: OperationResolver): Matcher => ({ pattern, operation });
20
+
21
+ const MATCHERS: ReadonlyArray<Matcher> = [
22
+ m(/\/graphql\b/, "graphql"),
23
+ // REST: /rest/<storeCode>/V1/<resource>/... — name by the first resource segment.
24
+ m(/\/rest\/[^/]+\/V1\/([^/?#]+)/, (match) => `rest.${match[1]}`),
25
+ m(/\/rest\/V1\/([^/?#]+)/, (match) => `rest.${match[1]}`),
26
+ ];
27
+
28
+ /**
29
+ * Resolve an operation name for a Magento URL. Returns `undefined` when no
30
+ * matcher fires, so the framework falls back to `magento.fetch`.
31
+ */
32
+ import { extractPathname } from "@decocms/blocks/sdk/urlUtils";
33
+
34
+ export function magentoOperationRouter(url: string, _method: string): string | undefined {
35
+ const pathname = extractPathname(url);
36
+
37
+ for (const { pattern, operation } of MATCHERS) {
38
+ const match = pathname.match(pattern);
39
+ if (!match) continue;
40
+ return typeof operation === "function" ? operation(match, _method) : operation;
41
+ }
42
+ return undefined;
43
+ }
@@ -6,8 +6,8 @@
6
6
  * Subset of `deco-cx/apps/magento/utils/transform.ts` — only the
7
7
  * functions the PDP loader needs (toProduct, toOffer, toImages, toURL,
8
8
  * toBreadcrumbList, toSeo). The GraphQL-side helpers (toProductGraphQL,
9
- * toAggOfferGraphQL, toProductListingPageGraphQL, …) and the Granado-
10
- * specific helpers (toReviewAmasty, toLiveloPoints) are intentionally
9
+ * toAggOfferGraphQL, toProductListingPageGraphQL, …) and the
10
+ * storefront-specific helpers (toReviewAmasty, toLiveloPoints) are intentionally
11
11
  * excluded — they land in separate follow-up PRs alongside the loaders
12
12
  * that consume them.
13
13
  *