@decocms/apps 1.14.0 → 1.15.0-next.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/README.md +28 -4
- package/package.json +3 -3
- package/shopify/index.ts +6 -0
- package/shopify/utils/graphql.ts +13 -2
- package/shopify/utils/graphqlOperationName.ts +67 -0
- package/shopify/utils/instrumentedFetch.ts +57 -0
- package/shopify/utils/operationRouter.ts +70 -0
- package/vtex/actions/analytics/sendEvent.ts +3 -2
- package/vtex/actions/masterData.ts +3 -2
- package/vtex/actions/misc.ts +5 -3
- package/vtex/actions/newsletter.ts +3 -2
- package/vtex/actions/profile.ts +3 -2
- package/vtex/client.ts +42 -11
- package/vtex/index.ts +2 -0
- package/vtex/utils/authHelpers.ts +3 -2
- package/vtex/utils/instrumentedFetch.ts +81 -0
- package/vtex/utils/operationRouter.ts +139 -0
- package/vtex/utils/proxy.ts +8 -3
package/README.md
CHANGED
|
@@ -57,8 +57,11 @@ A working VTEX storefront needs three things: a `deco-vtex` config block, an `in
|
|
|
57
57
|
|
|
58
58
|
```ts
|
|
59
59
|
import { createSiteSetup } from "@decocms/start/setup";
|
|
60
|
-
import {
|
|
61
|
-
|
|
60
|
+
import {
|
|
61
|
+
createVtexFetch,
|
|
62
|
+
initVtexFromBlocks,
|
|
63
|
+
setVtexFetch,
|
|
64
|
+
} from "@decocms/apps/vtex";
|
|
62
65
|
import { createVtexCommerceLoaders } from "@decocms/apps/vtex/commerceLoaders";
|
|
63
66
|
|
|
64
67
|
createSiteSetup({
|
|
@@ -69,9 +72,28 @@ createSiteSetup({
|
|
|
69
72
|
getCommerceLoaders: () => createVtexCommerceLoaders(),
|
|
70
73
|
});
|
|
71
74
|
|
|
72
|
-
|
|
75
|
+
// Plumbs spans, traceparent injection, URL redaction, and the
|
|
76
|
+
// `commerce_request_duration_ms` histogram into every outbound
|
|
77
|
+
// VTEX call. Operation names are derived from the URL via
|
|
78
|
+
// `vtexOperationRouter` (overridable per call via `init.operation`).
|
|
79
|
+
setVtexFetch(createVtexFetch());
|
|
73
80
|
```
|
|
74
81
|
|
|
82
|
+
For Shopify storefronts the equivalent factory is `createShopifyFetch()`:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import { createShopifyFetch, setShopifyFetch } from "@decocms/apps/shopify";
|
|
86
|
+
|
|
87
|
+
setShopifyFetch(createShopifyFetch());
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Shopify's GraphQL operation name (`query Foo { ... }` → `Foo`) is
|
|
91
|
+
extracted from the document body and stamped automatically — spans
|
|
92
|
+
become `shopify.Foo` instead of the generic `shopify.storefront.graphql`.
|
|
93
|
+
|
|
94
|
+
> **Heads up:** the factories require `@decocms/start@>=5.3.0-rc.0`
|
|
95
|
+
> for the per-call `init.operation` API used to label spans.
|
|
96
|
+
|
|
75
97
|
#### 3. Hooks in components
|
|
76
98
|
|
|
77
99
|
```tsx
|
|
@@ -134,7 +156,9 @@ await sendEmail({
|
|
|
134
156
|
| Subpath | Purpose |
|
|
135
157
|
|---------|---------|
|
|
136
158
|
| `@decocms/apps/vtex` | Barrel index |
|
|
137
|
-
| `@decocms/apps/vtex/client` | `vtexFetch`, `vtexFetchWithCookies`, `intelligentSearch`, `setVtexFetch`, `initVtexFromBlocks`, `configureVtex` |
|
|
159
|
+
| `@decocms/apps/vtex/client` | `vtexFetch`, `vtexFetchWithCookies`, `intelligentSearch`, `setVtexFetch`, `getVtexFetch`, `initVtexFromBlocks`, `configureVtex` |
|
|
160
|
+
| `@decocms/apps/vtex` (barrel) | All of `client`, plus `createVtexFetch`, `vtexOperationRouter` for observability wiring |
|
|
161
|
+
| `@decocms/apps/shopify` (barrel) | `createShopifyFetch`, `setShopifyFetch`, `shopifyOperationRouter`, `extractGraphqlOperationName` for observability wiring |
|
|
138
162
|
| `@decocms/apps/vtex/commerceLoaders` | `createVtexCommerceLoaders` |
|
|
139
163
|
| `@decocms/apps/vtex/loaders/*` | Cart, user, wishlist, search, catalog, sessions, orders, autocomplete |
|
|
140
164
|
| `@decocms/apps/vtex/actions/*` | Cart mutations, auth, profile, address, wishlist, newsletter |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/apps",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.0-next.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco commerce apps for TanStack Start - Shopify, VTEX, commerce types, analytics utils",
|
|
6
6
|
"exports": {
|
|
@@ -109,14 +109,14 @@
|
|
|
109
109
|
"access": "public"
|
|
110
110
|
},
|
|
111
111
|
"peerDependencies": {
|
|
112
|
-
"@decocms/start": ">=
|
|
112
|
+
"@decocms/start": ">=5.3.0-rc.0",
|
|
113
113
|
"@tanstack/react-query": ">=5",
|
|
114
114
|
"react": ">=18",
|
|
115
115
|
"react-dom": ">=18"
|
|
116
116
|
},
|
|
117
117
|
"devDependencies": {
|
|
118
118
|
"@biomejs/biome": "^2.4.7",
|
|
119
|
-
"@decocms/start": "
|
|
119
|
+
"@decocms/start": "5.3.0-rc.0",
|
|
120
120
|
"@semantic-release/exec": "^7.1.0",
|
|
121
121
|
"@tanstack/react-query": "^5.90.21",
|
|
122
122
|
"@types/react": "^19.0.0",
|
package/shopify/index.ts
CHANGED
|
@@ -34,4 +34,10 @@ export { default as userLoader } from "./loaders/user";
|
|
|
34
34
|
export { getCartCookie, setCartCookie } from "./utils/cart";
|
|
35
35
|
// Cookie utils
|
|
36
36
|
export { getCookies, setCookie } from "./utils/cookies";
|
|
37
|
+
export { extractGraphqlOperationName } from "./utils/graphqlOperationName";
|
|
38
|
+
export {
|
|
39
|
+
type CreateShopifyFetchOptions,
|
|
40
|
+
createShopifyFetch,
|
|
41
|
+
} from "./utils/instrumentedFetch";
|
|
42
|
+
export { shopifyOperationRouter } from "./utils/operationRouter";
|
|
37
43
|
export { getUserCookie, setUserCookie } from "./utils/user";
|
package/shopify/utils/graphql.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import type { InstrumentedFetchInit } from "@decocms/start/sdk/instrumentedFetch";
|
|
2
|
+
import { extractGraphqlOperationName } from "./graphqlOperationName";
|
|
3
|
+
|
|
1
4
|
export function gql(strings: TemplateStringsArray, ...values: unknown[]): string {
|
|
2
5
|
return strings.reduce((acc, str, i) => acc + str + (values[i] ?? ""), "");
|
|
3
6
|
}
|
|
@@ -29,14 +32,22 @@ export function createGraphqlClient(
|
|
|
29
32
|
): Promise<T> {
|
|
30
33
|
const query = typeof queryOrDef === "string" ? queryOrDef : buildQuery(queryOrDef);
|
|
31
34
|
|
|
32
|
-
|
|
35
|
+
// Stamp the GraphQL operation as init.operation so the framework's
|
|
36
|
+
// span name becomes `shopify.<OperationName>` instead of the
|
|
37
|
+
// generic `shopify.storefront.graphql` from the URL router. The
|
|
38
|
+
// extra field is silently dropped by plain `fetch` and read by
|
|
39
|
+
// any `InstrumentedFetch` configured via `setShopifyFetch`.
|
|
40
|
+
const operation = extractGraphqlOperationName(query);
|
|
41
|
+
const init: InstrumentedFetchInit = {
|
|
33
42
|
method: "POST",
|
|
34
43
|
headers: {
|
|
35
44
|
"Content-Type": "application/json",
|
|
36
45
|
...headers,
|
|
37
46
|
},
|
|
38
47
|
body: JSON.stringify({ query, variables }),
|
|
39
|
-
|
|
48
|
+
...(operation ? { operation } : {}),
|
|
49
|
+
};
|
|
50
|
+
const response = await _fetch(endpoint, init);
|
|
40
51
|
|
|
41
52
|
if (!response.ok) {
|
|
42
53
|
throw new Error(`Shopify GraphQL error: ${response.status} ${response.statusText}`);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract a semantic operation name from a GraphQL document.
|
|
3
|
+
*
|
|
4
|
+
* Used at the Shopify GraphQL client layer to stamp `init.operation`
|
|
5
|
+
* on the outbound fetch. The framework then suffixes the integration
|
|
6
|
+
* name (`shopify.<operation>`) onto the span and uses the same string
|
|
7
|
+
* as the `fetch.operation` attribute + histogram label.
|
|
8
|
+
*
|
|
9
|
+
* Resolution order:
|
|
10
|
+
*
|
|
11
|
+
* 1. An explicit `operationName` argument (e.g. when the client
|
|
12
|
+
* received one alongside a multi-operation document) wins.
|
|
13
|
+
* 2. If the document has exactly one named operation, that name
|
|
14
|
+
* is used.
|
|
15
|
+
* 3. If the document has zero or many anonymous operations, we
|
|
16
|
+
* return `undefined` so the caller can fall back (typically to
|
|
17
|
+
* the URL-derived `storefront.graphql` / `admin.graphql`).
|
|
18
|
+
*
|
|
19
|
+
* The parser is deliberately a small regex pass, not a full GraphQL
|
|
20
|
+
* tokenizer:
|
|
21
|
+
*
|
|
22
|
+
* - GraphQL operation definitions live at the top level of the
|
|
23
|
+
* document, never nested inside other operations, fragments, or
|
|
24
|
+
* selection sets, so positional context isn't required to find
|
|
25
|
+
* them — only to not match the literal words `query` /
|
|
26
|
+
* `mutation` / `subscription` inside string values.
|
|
27
|
+
* - We strip block strings (`""" … """`), string literals
|
|
28
|
+
* (`"…"`), and `# …` comments before matching, which is enough
|
|
29
|
+
* to make false-positive matches inside comments / docs vanish.
|
|
30
|
+
*
|
|
31
|
+
* If a Shopify operation is ever sufficiently mis-named to break
|
|
32
|
+
* this (unlikely, since the storefront SDK names them deliberately),
|
|
33
|
+
* the caller can always set `init.operation` explicitly.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const OPERATION_RE = /\b(?:query|mutation|subscription)\s+([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
37
|
+
|
|
38
|
+
const stripCommentsAndStrings = (doc: string): string =>
|
|
39
|
+
doc
|
|
40
|
+
.replace(/"""[\s\S]*?"""/g, '""')
|
|
41
|
+
.replace(/"(?:\\.|[^"\\])*"/g, '""')
|
|
42
|
+
.replace(/#[^\n]*/g, "");
|
|
43
|
+
|
|
44
|
+
export function extractGraphqlOperationName(
|
|
45
|
+
document: string,
|
|
46
|
+
explicit?: string,
|
|
47
|
+
): string | undefined {
|
|
48
|
+
if (explicit) return explicit;
|
|
49
|
+
if (!document) return undefined;
|
|
50
|
+
|
|
51
|
+
const stripped = stripCommentsAndStrings(document);
|
|
52
|
+
const names: string[] = [];
|
|
53
|
+
|
|
54
|
+
OPERATION_RE.lastIndex = 0;
|
|
55
|
+
for (
|
|
56
|
+
let match = OPERATION_RE.exec(stripped);
|
|
57
|
+
match !== null;
|
|
58
|
+
match = OPERATION_RE.exec(stripped)
|
|
59
|
+
) {
|
|
60
|
+
const [, name] = match;
|
|
61
|
+
if (name) names.push(name);
|
|
62
|
+
if (names.length > 1) break;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (names.length === 1) return names[0];
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-wired instrumented fetch factory for Shopify.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `vtex/utils/instrumentedFetch.ts`. Bundles:
|
|
5
|
+
*
|
|
6
|
+
* 1. `createInstrumentedFetch` from `@decocms/start` (spans,
|
|
7
|
+
* traceparent, URL redaction).
|
|
8
|
+
* 2. `shopifyOperationRouter` as the URL fallback for non-GraphQL
|
|
9
|
+
* and unnamed-GraphQL calls.
|
|
10
|
+
* 3. An `onComplete` that records `commerce_request_duration_ms`
|
|
11
|
+
* with `provider: "shopify"`.
|
|
12
|
+
*
|
|
13
|
+
* Sites do:
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* import { setShopifyFetch, createShopifyFetch } from "@decocms/apps/shopify";
|
|
17
|
+
* setShopifyFetch(createShopifyFetch());
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* Per-call operation names come from `extractGraphqlOperationName`
|
|
21
|
+
* (wired in `./graphql.ts`); the URL router fires only when the
|
|
22
|
+
* extractor returns `undefined`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
createInstrumentedFetch,
|
|
27
|
+
type InstrumentedFetch,
|
|
28
|
+
} from "@decocms/start/sdk/instrumentedFetch";
|
|
29
|
+
import { getMeter } from "@decocms/start/sdk/observability";
|
|
30
|
+
import { shopifyOperationRouter } from "./operationRouter";
|
|
31
|
+
|
|
32
|
+
const HISTOGRAM_NAME = "commerce_request_duration_ms";
|
|
33
|
+
|
|
34
|
+
export interface CreateShopifyFetchOptions {
|
|
35
|
+
baseFetch?: typeof fetch;
|
|
36
|
+
disableHistogram?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createShopifyFetch(options: CreateShopifyFetchOptions = {}): InstrumentedFetch {
|
|
40
|
+
const { baseFetch, disableHistogram = false } = options;
|
|
41
|
+
return createInstrumentedFetch({
|
|
42
|
+
name: "shopify",
|
|
43
|
+
baseFetch,
|
|
44
|
+
resolveOperation: shopifyOperationRouter,
|
|
45
|
+
onComplete: disableHistogram
|
|
46
|
+
? undefined
|
|
47
|
+
: ({ operation, status, durationMs, cached }) => {
|
|
48
|
+
const meter = getMeter();
|
|
49
|
+
meter?.histogramRecord?.(HISTOGRAM_NAME, durationMs, {
|
|
50
|
+
provider: "shopify",
|
|
51
|
+
operation,
|
|
52
|
+
status_code: String(status),
|
|
53
|
+
cached: cached ? "true" : "false",
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL-derived operation name router for Shopify API calls.
|
|
3
|
+
*
|
|
4
|
+
* Plugged into `@decocms/start`'s `createInstrumentedFetch` via the
|
|
5
|
+
* `resolveOperation(url, method)` option. Mirrors the shape of the
|
|
6
|
+
* VTEX router in `../../vtex/utils/operationRouter.ts`.
|
|
7
|
+
*
|
|
8
|
+
* Shopify's API surface from this repo is overwhelmingly GraphQL —
|
|
9
|
+
* a single endpoint per environment (storefront vs admin). That means
|
|
10
|
+
* the URL alone can only tell us *which GraphQL surface* a call is
|
|
11
|
+
* hitting, not what the call actually does. The semantic operation
|
|
12
|
+
* name lives in the GraphQL document itself (`query Foo { ... }`),
|
|
13
|
+
* and is extracted by `extractGraphqlOperationName` (see
|
|
14
|
+
* `./graphqlOperationName.ts`) at the client layer and stamped as
|
|
15
|
+
* `init.operation`, which always wins over this router.
|
|
16
|
+
*
|
|
17
|
+
* So this router exists for:
|
|
18
|
+
*
|
|
19
|
+
* - non-GraphQL Shopify REST endpoints we may add later
|
|
20
|
+
* (cart API, customer accounts, billing, etc.);
|
|
21
|
+
* - giving the GraphQL endpoints a *fallback* operation when the
|
|
22
|
+
* extractor can't parse a name (anonymous queries, missing body).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
type OperationResolver = string | ((match: RegExpMatchArray, method: string) => string);
|
|
26
|
+
|
|
27
|
+
interface Matcher {
|
|
28
|
+
pattern: RegExp;
|
|
29
|
+
operation: OperationResolver;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const m = (pattern: RegExp, operation: OperationResolver): Matcher => ({ pattern, operation });
|
|
33
|
+
|
|
34
|
+
const MATCHERS: ReadonlyArray<Matcher> = [
|
|
35
|
+
m(/^\/admin\/api\/[0-9]{4}-[0-9]{2}\/graphql\.json/, "admin.graphql"),
|
|
36
|
+
m(/^\/api\/[0-9]{4}-[0-9]{2}\/graphql\.json/, "storefront.graphql"),
|
|
37
|
+
|
|
38
|
+
m(/^\/admin\/api\/[0-9]{4}-[0-9]{2}\/products/, "admin.products"),
|
|
39
|
+
m(/^\/admin\/api\/[0-9]{4}-[0-9]{2}\/orders/, "admin.orders"),
|
|
40
|
+
m(/^\/admin\/api\/[0-9]{4}-[0-9]{2}\/customers/, "admin.customers"),
|
|
41
|
+
m(/^\/admin\/api\/[0-9]{4}-[0-9]{2}\/inventory/, "admin.inventory"),
|
|
42
|
+
|
|
43
|
+
m(/^\/api\/[0-9]{4}-[0-9]{2}\/checkouts/, "storefront.checkout"),
|
|
44
|
+
m(/^\/cart(?:\/|\.js|$)/, "storefront.cart"),
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve an operation name for a Shopify URL. Returns `undefined`
|
|
49
|
+
* if no matcher fires, which causes the framework to fall back to
|
|
50
|
+
* `shopify.fetch`.
|
|
51
|
+
*/
|
|
52
|
+
export function shopifyOperationRouter(url: string, method: string): string | undefined {
|
|
53
|
+
let pathname: string;
|
|
54
|
+
try {
|
|
55
|
+
pathname = new URL(url).pathname;
|
|
56
|
+
} catch {
|
|
57
|
+
const qs = url.indexOf("?");
|
|
58
|
+
const hash = url.indexOf("#");
|
|
59
|
+
const end = [qs, hash].filter((i) => i >= 0).sort((a, b) => a - b)[0];
|
|
60
|
+
pathname = end === undefined ? url : url.slice(0, end);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const upperMethod = method.toUpperCase();
|
|
64
|
+
for (const { pattern, operation } of MATCHERS) {
|
|
65
|
+
const match = pathname.match(pattern);
|
|
66
|
+
if (!match) continue;
|
|
67
|
+
return typeof operation === "function" ? operation(match, upperMethod) : operation;
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* @see https://developers.vtex.com/docs/api-reference/intelligent-search-api#post-/event-api/v1/-account-/event
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { getVtexConfig } from "../../client";
|
|
11
|
+
import { getVtexConfig, getVtexFetch } from "../../client";
|
|
12
12
|
import { ANONYMOUS_COOKIE, SESSION_COOKIE } from "../../utils/intelligentSearch";
|
|
13
13
|
|
|
14
14
|
export type Props =
|
|
@@ -68,7 +68,7 @@ const action = async (props: Props, req: Request): Promise<null> => {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
const url = `https://sp.vtex.com/event-api/v1/${account}/event`;
|
|
71
|
-
await
|
|
71
|
+
await getVtexFetch()(url, {
|
|
72
72
|
method: "POST",
|
|
73
73
|
headers: { "content-type": "application/json" },
|
|
74
74
|
body: JSON.stringify({
|
|
@@ -77,6 +77,7 @@ const action = async (props: Props, req: Request): Promise<null> => {
|
|
|
77
77
|
anonymous,
|
|
78
78
|
agent: req.headers.get("user-agent") || "deco-sites/apps",
|
|
79
79
|
}),
|
|
80
|
+
operation: "analytics.event",
|
|
80
81
|
});
|
|
81
82
|
|
|
82
83
|
return null;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* VTEX MasterData v2 API actions.
|
|
3
3
|
* Generic CRUD operations on data entities.
|
|
4
4
|
*/
|
|
5
|
-
import { getVtexConfig, vtexFetch, vtexFetchResponse } from "../client";
|
|
5
|
+
import { getVtexConfig, getVtexFetch, vtexFetch, vtexFetchResponse } from "../client";
|
|
6
6
|
|
|
7
7
|
function removeEmptyFields(obj: Record<string, any>): Record<string, any> {
|
|
8
8
|
return Object.fromEntries(
|
|
@@ -153,10 +153,11 @@ export async function uploadAttachment(opts: UploadAttachmentOpts): Promise<{ ok
|
|
|
153
153
|
headers["X-VTEX-API-AppToken"] = config.appToken;
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
-
const response = await
|
|
156
|
+
const response = await getVtexFetch()(url, {
|
|
157
157
|
method: "POST",
|
|
158
158
|
headers,
|
|
159
159
|
body: formData,
|
|
160
|
+
operation: "masterdata.attachment.upload",
|
|
160
161
|
});
|
|
161
162
|
|
|
162
163
|
if (!response.ok) {
|
package/vtex/actions/misc.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* - vtex/actions/payment/deletePaymentToken.ts
|
|
8
8
|
* @see https://developers.vtex.com/docs/api-reference
|
|
9
9
|
*/
|
|
10
|
-
import { getVtexConfig, vtexFetch } from "../client";
|
|
10
|
+
import { getVtexConfig, getVtexFetch, vtexFetch } from "../client";
|
|
11
11
|
import { buildAuthCookieHeader, VTEX_AUTH_COOKIE } from "../utils/vtexId";
|
|
12
12
|
|
|
13
13
|
// ---------------------------------------------------------------------------
|
|
@@ -127,9 +127,10 @@ export async function notifyMe(props: NotifyMeProps): Promise<void> {
|
|
|
127
127
|
form.append("notifymeClientEmail", email);
|
|
128
128
|
form.append("notifymeIdSku", skuId);
|
|
129
129
|
|
|
130
|
-
await
|
|
130
|
+
await getVtexFetch()(`https://${account}.vtexcommercestable.com.br/no-cache/AviseMe.aspx`, {
|
|
131
131
|
method: "POST",
|
|
132
132
|
body: form,
|
|
133
|
+
operation: "notifyme",
|
|
133
134
|
});
|
|
134
135
|
}
|
|
135
136
|
|
|
@@ -148,7 +149,7 @@ export async function sendEvent(
|
|
|
148
149
|
): Promise<void> {
|
|
149
150
|
const { account } = getVtexConfig();
|
|
150
151
|
|
|
151
|
-
await
|
|
152
|
+
await getVtexFetch()(`https://sp.vtex.com/event-api/v1/${account}/event`, {
|
|
152
153
|
method: "POST",
|
|
153
154
|
headers: { "Content-Type": "application/json" },
|
|
154
155
|
body: JSON.stringify({
|
|
@@ -156,6 +157,7 @@ export async function sendEvent(
|
|
|
156
157
|
...isCookies,
|
|
157
158
|
agent: userAgent || "deco-sites/apps",
|
|
158
159
|
}),
|
|
160
|
+
operation: "analytics.event",
|
|
159
161
|
});
|
|
160
162
|
}
|
|
161
163
|
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* - vtex/actions/newsletter/updateNewsletterOptIn.ts
|
|
6
6
|
* @see https://developers.vtex.com/docs/guides/newsletter
|
|
7
7
|
*/
|
|
8
|
-
import { getVtexConfig, vtexFetch } from "../client";
|
|
8
|
+
import { getVtexConfig, getVtexFetch, vtexFetch } from "../client";
|
|
9
9
|
import { buildAuthCookieHeader } from "../utils/vtexId";
|
|
10
10
|
|
|
11
11
|
// ---------------------------------------------------------------------------
|
|
@@ -83,9 +83,10 @@ export async function subscribe(props: SubscribeProps): Promise<void> {
|
|
|
83
83
|
form.append("newsInternalPart", part);
|
|
84
84
|
form.append("newsInternalCampaign", campaing);
|
|
85
85
|
|
|
86
|
-
await
|
|
86
|
+
await getVtexFetch()(`https://${account}.vtexcommercestable.com.br/no-cache/Newsletter.aspx`, {
|
|
87
87
|
method: "POST",
|
|
88
88
|
body: form,
|
|
89
|
+
operation: "newsletter.subscribe",
|
|
89
90
|
});
|
|
90
91
|
}
|
|
91
92
|
|
package/vtex/actions/profile.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* - vtex/actions/profile/updateProfile.ts
|
|
5
5
|
* @see https://developers.vtex.com/docs/guides/profile-system
|
|
6
6
|
*/
|
|
7
|
-
import { getVtexConfig, vtexFetch } from "../client";
|
|
7
|
+
import { getVtexConfig, getVtexFetch, vtexFetch } from "../client";
|
|
8
8
|
import { buildAuthCookieHeader } from "../utils/vtexId";
|
|
9
9
|
|
|
10
10
|
// ---------------------------------------------------------------------------
|
|
@@ -166,10 +166,11 @@ export async function updateProfileFromRequest(
|
|
|
166
166
|
corporateDocument tradeName stateRegistration
|
|
167
167
|
}
|
|
168
168
|
}`;
|
|
169
|
-
const res = await
|
|
169
|
+
const res = await getVtexFetch()(`https://${account}.myvtex.com/_v/private/graphql/v1`, {
|
|
170
170
|
method: "POST",
|
|
171
171
|
body: JSON.stringify({ query: QUERY, variables: { profile } }),
|
|
172
172
|
headers: { "Content-Type": "application/json", cookie },
|
|
173
|
+
operation: "io.graphql.UpdateProfile",
|
|
173
174
|
});
|
|
174
175
|
return res.json();
|
|
175
176
|
}
|
package/vtex/client.ts
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
* Uses VTEX's public REST APIs (Intelligent Search + Catalog + Checkout).
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import type {
|
|
7
|
+
InstrumentedFetch,
|
|
8
|
+
InstrumentedFetchInit,
|
|
9
|
+
} from "@decocms/start/sdk/instrumentedFetch";
|
|
6
10
|
import { RequestContext } from "@decocms/start/sdk/requestContext";
|
|
7
11
|
import { sanitizeOutboundCookieHeader, warnDroppedCookies } from "./utils/cookieSanitizer";
|
|
8
12
|
import { type FetchCacheOptions, fetchWithCache } from "./utils/fetchCache";
|
|
@@ -96,7 +100,7 @@ export interface VtexConfig {
|
|
|
96
100
|
}
|
|
97
101
|
|
|
98
102
|
let _config: VtexConfig | null = null;
|
|
99
|
-
let _fetch: typeof fetch = globalThis.fetch;
|
|
103
|
+
let _fetch: typeof fetch | InstrumentedFetch = globalThis.fetch;
|
|
100
104
|
|
|
101
105
|
export function configureVtex(config: VtexConfig) {
|
|
102
106
|
_config = config;
|
|
@@ -105,19 +109,40 @@ export function configureVtex(config: VtexConfig) {
|
|
|
105
109
|
|
|
106
110
|
/**
|
|
107
111
|
* Override the fetch function used by all VTEX client calls.
|
|
108
|
-
*
|
|
112
|
+
* Pass an `InstrumentedFetch` to get spans, traceparent injection,
|
|
113
|
+
* URL redaction, and the `commerce_request_duration_ms` histogram —
|
|
114
|
+
* use the pre-wired `createVtexFetch()` factory:
|
|
109
115
|
*
|
|
110
|
-
* @example
|
|
111
116
|
* ```ts
|
|
112
|
-
* import {
|
|
113
|
-
*
|
|
114
|
-
* setVtexFetch(createInstrumentedFetch("vtex"));
|
|
117
|
+
* import { setVtexFetch, createVtexFetch } from "@decocms/apps/vtex";
|
|
118
|
+
* setVtexFetch(createVtexFetch());
|
|
115
119
|
* ```
|
|
120
|
+
*
|
|
121
|
+
* Accepts a plain `typeof fetch` too; in that mode VTEX calls are
|
|
122
|
+
* uninstrumented (useful for tests + sites that haven't onboarded
|
|
123
|
+
* the observability stack yet).
|
|
116
124
|
*/
|
|
117
|
-
export function setVtexFetch(fetchFn: typeof fetch) {
|
|
125
|
+
export function setVtexFetch(fetchFn: typeof fetch | InstrumentedFetch) {
|
|
118
126
|
_fetch = fetchFn;
|
|
119
127
|
}
|
|
120
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Read-only accessor for the configured VTEX fetch. Used by ad-hoc
|
|
131
|
+
* callsites that don't fit the `vtexFetch*` helpers (FormData
|
|
132
|
+
* uploads, the storefront proxies, .aspx endpoints) but still want
|
|
133
|
+
* to participate in the instrumentation set up via `setVtexFetch`.
|
|
134
|
+
*
|
|
135
|
+
* Callers can stamp a per-call operation through the init:
|
|
136
|
+
*
|
|
137
|
+
* ```ts
|
|
138
|
+
* const fetch = getVtexFetch();
|
|
139
|
+
* await fetch(url, { method: "POST", operation: "notifyme" });
|
|
140
|
+
* ```
|
|
141
|
+
*/
|
|
142
|
+
export function getVtexFetch(): InstrumentedFetch {
|
|
143
|
+
return _fetch as InstrumentedFetch;
|
|
144
|
+
}
|
|
145
|
+
|
|
121
146
|
export function getVtexConfig(): VtexConfig {
|
|
122
147
|
if (!_config) throw new Error("VTEX not configured. Call configureVtex() first.");
|
|
123
148
|
return _config;
|
|
@@ -198,7 +223,10 @@ function hasCookieHeader(headers: HeadersInit | undefined): boolean {
|
|
|
198
223
|
return Object.keys(headers).some((k) => k.toLowerCase() === "cookie");
|
|
199
224
|
}
|
|
200
225
|
|
|
201
|
-
export async function vtexFetchResponse(
|
|
226
|
+
export async function vtexFetchResponse(
|
|
227
|
+
path: string,
|
|
228
|
+
init?: InstrumentedFetchInit,
|
|
229
|
+
): Promise<Response> {
|
|
202
230
|
const raw = path.startsWith("http") ? path : `${baseUrl()}${path}`;
|
|
203
231
|
const url = sanitizeUrl(raw);
|
|
204
232
|
|
|
@@ -225,7 +253,7 @@ export async function vtexFetchResponse(path: string, init?: RequestInit): Promi
|
|
|
225
253
|
return response;
|
|
226
254
|
}
|
|
227
255
|
|
|
228
|
-
export async function vtexFetch<T>(path: string, init?:
|
|
256
|
+
export async function vtexFetch<T>(path: string, init?: InstrumentedFetchInit): Promise<T> {
|
|
229
257
|
const response = await vtexFetchResponse(path, init);
|
|
230
258
|
return response.json();
|
|
231
259
|
}
|
|
@@ -242,7 +270,7 @@ export interface VtexCachedFetchOptions {
|
|
|
242
270
|
*/
|
|
243
271
|
export async function vtexCachedFetch<T>(
|
|
244
272
|
path: string,
|
|
245
|
-
init?:
|
|
273
|
+
init?: InstrumentedFetchInit,
|
|
246
274
|
cacheOpts?: VtexCachedFetchOptions,
|
|
247
275
|
): Promise<T | null> {
|
|
248
276
|
const method = (init?.method ?? "GET").toUpperCase();
|
|
@@ -276,7 +304,10 @@ export async function vtexCachedFetch<T>(
|
|
|
276
304
|
*
|
|
277
305
|
* This mirrors deco-cx/deco's `proxySetCookie(response.headers, ctx.response.headers)`.
|
|
278
306
|
*/
|
|
279
|
-
export async function vtexFetchWithCookies<T>(
|
|
307
|
+
export async function vtexFetchWithCookies<T>(
|
|
308
|
+
path: string,
|
|
309
|
+
init?: InstrumentedFetchInit,
|
|
310
|
+
): Promise<T> {
|
|
280
311
|
// Auto-inject request cookies from RequestContext.
|
|
281
312
|
//
|
|
282
313
|
// We sanitize the forwarded Cookie header before sending it to VTEX:
|
package/vtex/index.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* createServerFn itself must live in site source (not node_modules) because
|
|
7
7
|
* TanStack Start's Vite plugin only transforms source files.
|
|
8
8
|
*/
|
|
9
|
-
import { getVtexConfig } from "../client";
|
|
9
|
+
import { getVtexConfig, getVtexFetch } from "../client";
|
|
10
10
|
import { extractVtexCookies } from "./cookieSanitizer";
|
|
11
11
|
|
|
12
12
|
const DOMAIN_RE = /;\s*domain=[^;]*/gi;
|
|
@@ -49,10 +49,11 @@ export async function performVtexLogout(cookies: string): Promise<{ setCookies:
|
|
|
49
49
|
const domain = config.domain ?? "com.br";
|
|
50
50
|
const logoutUrl = `https://${config.account}.vtexcommercestable.${domain}/api/vtexid/pub/logout?scope=${config.account}&returnUrl=/`;
|
|
51
51
|
|
|
52
|
-
const res = await
|
|
52
|
+
const res = await getVtexFetch()(logoutUrl, {
|
|
53
53
|
method: "GET",
|
|
54
54
|
headers: { cookie: cookies },
|
|
55
55
|
redirect: "manual",
|
|
56
|
+
operation: "vtexid.logout",
|
|
56
57
|
});
|
|
57
58
|
|
|
58
59
|
const upstreamCookies = res.headers.getSetCookie?.() ?? [];
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-wired instrumented fetch factory for VTEX.
|
|
3
|
+
*
|
|
4
|
+
* Bundles the three pieces a storefront would otherwise have to wire
|
|
5
|
+
* by hand:
|
|
6
|
+
*
|
|
7
|
+
* 1. The `createInstrumentedFetch` boundary from `@decocms/start`
|
|
8
|
+
* (spans, traceparent injection, URL redaction, cache-header
|
|
9
|
+
* span attributes).
|
|
10
|
+
* 2. The `vtexOperationRouter` URL→operation mapping so unannotated
|
|
11
|
+
* callsites still get semantic span names + histogram labels.
|
|
12
|
+
* 3. An `onComplete` callback that records every call into the
|
|
13
|
+
* `commerce_request_duration_ms` histogram via the meter
|
|
14
|
+
* configured by `instrumentWorker(...)` in
|
|
15
|
+
* `@decocms/start/sdk/observability` — `provider`, `operation`,
|
|
16
|
+
* `status_code`, and `cached` labels.
|
|
17
|
+
*
|
|
18
|
+
* Sites opt in once at startup:
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* import { setVtexFetch } from "@decocms/apps/vtex";
|
|
22
|
+
* import { createVtexFetch } from "@decocms/apps/vtex";
|
|
23
|
+
*
|
|
24
|
+
* setVtexFetch(createVtexFetch());
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* Sites that need to wrap a custom underlying fetch (cookie passthrough,
|
|
28
|
+
* proxy, retry, etc.) pass it as `baseFetch`. The instrumentation
|
|
29
|
+
* still applies — `createInstrumentedFetch` preserves the wrapped
|
|
30
|
+
* behavior.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
createInstrumentedFetch,
|
|
35
|
+
type InstrumentedFetch,
|
|
36
|
+
} from "@decocms/start/sdk/instrumentedFetch";
|
|
37
|
+
import { getMeter } from "@decocms/start/sdk/observability";
|
|
38
|
+
import { vtexOperationRouter } from "./operationRouter";
|
|
39
|
+
|
|
40
|
+
const HISTOGRAM_NAME = "commerce_request_duration_ms";
|
|
41
|
+
|
|
42
|
+
export interface CreateVtexFetchOptions {
|
|
43
|
+
/**
|
|
44
|
+
* Underlying fetch to wrap. Defaults to `globalThis.fetch`.
|
|
45
|
+
* Pass an existing custom fetch (e.g. one that injects auth cookies
|
|
46
|
+
* or routes through a proxy) to preserve its behavior while adding
|
|
47
|
+
* the VTEX instrumentation layer on top.
|
|
48
|
+
*/
|
|
49
|
+
baseFetch?: typeof fetch;
|
|
50
|
+
/**
|
|
51
|
+
* Disable the `commerce_request_duration_ms` histogram emission.
|
|
52
|
+
* The framework's span and structured logs still emit. Useful when
|
|
53
|
+
* the consumer wants to record its own histogram with a custom shape.
|
|
54
|
+
* Default: false.
|
|
55
|
+
*/
|
|
56
|
+
disableHistogram?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Construct a pre-wired VTEX `InstrumentedFetch`. Pass the result to
|
|
61
|
+
* `setVtexFetch(...)`. See module docstring for details.
|
|
62
|
+
*/
|
|
63
|
+
export function createVtexFetch(options: CreateVtexFetchOptions = {}): InstrumentedFetch {
|
|
64
|
+
const { baseFetch, disableHistogram = false } = options;
|
|
65
|
+
return createInstrumentedFetch({
|
|
66
|
+
name: "vtex",
|
|
67
|
+
baseFetch,
|
|
68
|
+
resolveOperation: vtexOperationRouter,
|
|
69
|
+
onComplete: disableHistogram
|
|
70
|
+
? undefined
|
|
71
|
+
: ({ operation, status, durationMs, cached }) => {
|
|
72
|
+
const meter = getMeter();
|
|
73
|
+
meter?.histogramRecord?.(HISTOGRAM_NAME, durationMs, {
|
|
74
|
+
provider: "vtex",
|
|
75
|
+
operation,
|
|
76
|
+
status_code: String(status),
|
|
77
|
+
cached: cached ? "true" : "false",
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL-derived operation name router for VTEX API calls.
|
|
3
|
+
*
|
|
4
|
+
* Plugged into `@decocms/start`'s `createInstrumentedFetch` via the
|
|
5
|
+
* `resolveOperation(url, method)` option. The resolved string becomes the
|
|
6
|
+
* span suffix (`vtex.<operation>`) and the `fetch.operation` span +
|
|
7
|
+
* histogram label, so it must be:
|
|
8
|
+
*
|
|
9
|
+
* - low-cardinality (no IDs, slugs, search terms, account names);
|
|
10
|
+
* - stable across deploys (used for alerting + dashboards);
|
|
11
|
+
* - human-debuggable in a trace view.
|
|
12
|
+
*
|
|
13
|
+
* The router is intentionally a flat ordered list of regex matchers,
|
|
14
|
+
* not a tree. Adding/auditing routes is a one-line patch and routes
|
|
15
|
+
* are evaluated in priority order (most specific first). Unknown URLs
|
|
16
|
+
* return `undefined` so the framework falls back to the generic
|
|
17
|
+
* `vtex.fetch` span name — observable, just less specific.
|
|
18
|
+
*
|
|
19
|
+
* Callers that need finer granularity than the URL can express (e.g.
|
|
20
|
+
* `POST /orderForm/{id}/items` is one URL but covers add / update /
|
|
21
|
+
* remove flows) should set `init.operation` explicitly per call; that
|
|
22
|
+
* always wins over the router.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
type OperationResolver = string | ((match: RegExpMatchArray, method: string) => string);
|
|
26
|
+
|
|
27
|
+
interface Matcher {
|
|
28
|
+
pattern: RegExp;
|
|
29
|
+
operation: OperationResolver;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const m = (pattern: RegExp, operation: OperationResolver): Matcher => ({ pattern, operation });
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Ordered list of `(regex, operation)` matchers. The first match wins.
|
|
36
|
+
*
|
|
37
|
+
* Patterns match against the URL pathname (the host is ignored — VTEX
|
|
38
|
+
* spreads the same API surface across `*.vtexcommercestable.*`,
|
|
39
|
+
* `*.myvtex.com`, and storefront origins, all on identical paths).
|
|
40
|
+
*
|
|
41
|
+
* Operation strings are bare (no `vtex.` prefix) — the framework
|
|
42
|
+
* prefixes them with the integration name at span time.
|
|
43
|
+
*/
|
|
44
|
+
const MATCHERS: ReadonlyArray<Matcher> = [
|
|
45
|
+
m(/^\/api\/io\/_v\/api\/intelligent-search\/([a-z_-]+)/, (mm) => `intelligent-search.${mm[1]}`),
|
|
46
|
+
m(/^\/_v\/private\/graphql\/v1/, "io.graphql"),
|
|
47
|
+
m(/^\/_v\/segment\//, "io.segment"),
|
|
48
|
+
|
|
49
|
+
m(/^\/api\/checkout\/pub\/orderForm\/[^/]+\/items\/update/, (_mm, method) =>
|
|
50
|
+
method === "POST" ? "checkout.orderform.items.update" : "checkout.orderform.items",
|
|
51
|
+
),
|
|
52
|
+
m(/^\/api\/checkout\/pub\/orderForm\/[^/]+\/items/, (_mm, method) => {
|
|
53
|
+
if (method === "DELETE") return "checkout.orderform.items.remove";
|
|
54
|
+
if (method === "PATCH" || method === "PUT") return "checkout.orderform.items.update";
|
|
55
|
+
return "checkout.orderform.items.add";
|
|
56
|
+
}),
|
|
57
|
+
m(/^\/api\/checkout\/pub\/orderForm\/[^/]+\/coupons/, "checkout.orderform.coupons"),
|
|
58
|
+
m(/^\/api\/checkout\/pub\/orderForm\/[^/]+\/profile/, "checkout.orderform.profile"),
|
|
59
|
+
m(/^\/api\/checkout\/pub\/orderForm\/[^/]+\/shippingData/, "checkout.orderform.shipping"),
|
|
60
|
+
m(/^\/api\/checkout\/pub\/orderForm\/[^/]+\/paymentData/, "checkout.orderform.payment"),
|
|
61
|
+
m(/^\/api\/checkout\/pub\/orderForm\/[^/]+/, (_mm, method) =>
|
|
62
|
+
method === "GET" ? "checkout.orderform.get" : "checkout.orderform.update",
|
|
63
|
+
),
|
|
64
|
+
m(/^\/api\/checkout\/pub\/orderForm(?:\/?$)/, (_mm, method) =>
|
|
65
|
+
method === "POST" ? "checkout.orderform.create" : "checkout.orderform.get",
|
|
66
|
+
),
|
|
67
|
+
m(/^\/api\/checkout\/pub\/orderForms\/simulation/, "checkout.simulation"),
|
|
68
|
+
m(/^\/api\/checkout\/pub\/regions/, "checkout.regions"),
|
|
69
|
+
m(/^\/api\/checkout\/pub\/postal-code/, "checkout.postal-code"),
|
|
70
|
+
|
|
71
|
+
m(/^\/api\/sessions/, (_mm, method) => (method === "POST" ? "sessions.update" : "sessions.get")),
|
|
72
|
+
m(/^\/api\/segments\//, "segments.get"),
|
|
73
|
+
|
|
74
|
+
m(/^\/api\/catalog_system\/pub\/portal\/pagetype\//, "catalog.pagetype"),
|
|
75
|
+
m(
|
|
76
|
+
/^\/api\/catalog_system\/pub\/products\/crossselling\/([^/]+)/,
|
|
77
|
+
(mm) => `catalog.crossselling.${mm[1]}`,
|
|
78
|
+
),
|
|
79
|
+
m(/^\/api\/catalog_system\/pub\/products\/variations\//, "catalog.products.variations"),
|
|
80
|
+
m(/^\/api\/catalog_system\/pub\/products\/search/, "catalog.products.search"),
|
|
81
|
+
m(/^\/api\/catalog_system\/pub\/facets\/search/, "catalog.facets.search"),
|
|
82
|
+
m(/^\/api\/catalog_system\/pub\/category\/tree/, "catalog.category.tree"),
|
|
83
|
+
m(/^\/api\/catalog_system\/(?:pub|pvt)\/specification/, "catalog.specification"),
|
|
84
|
+
m(/^\/api\/catalog_system\/pub\/brand/, "catalog.brand"),
|
|
85
|
+
m(/^\/api\/catalog_system\/pvt\/sku\//, "catalog.sku"),
|
|
86
|
+
m(/^\/api\/catalog_system\//, "catalog.other"),
|
|
87
|
+
|
|
88
|
+
m(/^\/api\/wishlist\//, "wishlist"),
|
|
89
|
+
m(/^\/api\/profile-system\/profile\//, "profile"),
|
|
90
|
+
m(/^\/api\/dataentities\/([^/]+)/, (mm) => `masterdata.${mm[1]}`),
|
|
91
|
+
|
|
92
|
+
m(/^\/api\/oms\/user\/orders\/[^/]+\/cancel/, "oms.orders.cancel"),
|
|
93
|
+
m(/^\/api\/oms\/user\/orders/, "oms.orders"),
|
|
94
|
+
m(/^\/api\/oms\/pvt\/orders/, "oms.orders.pvt"),
|
|
95
|
+
|
|
96
|
+
m(/^\/api\/vtexid\/pub\/logout/, "vtexid.logout"),
|
|
97
|
+
m(/^\/api\/vtexid\/pub\/authentication\/start/, "vtexid.authentication.start"),
|
|
98
|
+
m(/^\/api\/vtexid\/pub\/authentication\/[a-z]+\/validate/, "vtexid.authentication.validate"),
|
|
99
|
+
m(/^\/api\/vtexid\/pub\/authenticated\/user/, "vtexid.user"),
|
|
100
|
+
m(/^\/api\/vtexid\//, "vtexid.other"),
|
|
101
|
+
|
|
102
|
+
m(/^\/api\/events\/v1\//, "events.send"),
|
|
103
|
+
m(/^\/sitemap.*\.xml$/, "sitemap"),
|
|
104
|
+
m(/^\/api\/license-manager/, "license-manager"),
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Resolve an operation name for a VTEX URL. Returns `undefined` if no
|
|
109
|
+
* matcher fires, which causes the framework to fall back to
|
|
110
|
+
* `vtex.fetch`.
|
|
111
|
+
*
|
|
112
|
+
* Designed to be passed directly to `createInstrumentedFetch`:
|
|
113
|
+
*
|
|
114
|
+
* ```ts
|
|
115
|
+
* createInstrumentedFetch({
|
|
116
|
+
* name: "vtex",
|
|
117
|
+
* resolveOperation: vtexOperationRouter,
|
|
118
|
+
* });
|
|
119
|
+
* ```
|
|
120
|
+
*/
|
|
121
|
+
export function vtexOperationRouter(url: string, method: string): string | undefined {
|
|
122
|
+
let pathname: string;
|
|
123
|
+
try {
|
|
124
|
+
pathname = new URL(url).pathname;
|
|
125
|
+
} catch {
|
|
126
|
+
const qs = url.indexOf("?");
|
|
127
|
+
const hash = url.indexOf("#");
|
|
128
|
+
const end = [qs, hash].filter((i) => i >= 0).sort((a, b) => a - b)[0];
|
|
129
|
+
pathname = end === undefined ? url : url.slice(0, end);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const upperMethod = method.toUpperCase();
|
|
133
|
+
for (const { pattern, operation } of MATCHERS) {
|
|
134
|
+
const match = pathname.match(pattern);
|
|
135
|
+
if (!match) continue;
|
|
136
|
+
return typeof operation === "function" ? operation(match, upperMethod) : operation;
|
|
137
|
+
}
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
package/vtex/utils/proxy.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* fetch handlers.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { getVtexConfig, type VtexConfig, vtexHost } from "../client";
|
|
17
|
+
import { getVtexConfig, getVtexFetch, type VtexConfig, vtexHost } from "../client";
|
|
18
18
|
import { proxySetCookie } from "./cookies";
|
|
19
19
|
|
|
20
20
|
export interface VtexProxyOptions {
|
|
@@ -173,7 +173,12 @@ export async function proxyToVtex(request: Request, options?: VtexProxyOptions):
|
|
|
173
173
|
init.duplex = "half";
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
|
|
176
|
+
// Route through the configured VTEX fetch so traces / metrics / logs
|
|
177
|
+
// see the proxied origin call. The URL router classifies the call
|
|
178
|
+
// into the right `vtex.<area>.<op>` bucket (e.g. `vtex.checkout.*`,
|
|
179
|
+
// `vtex.vtexid.logout`, `vtex.io.segment`) — no per-callsite hint
|
|
180
|
+
// needed because we're a generic forwarder.
|
|
181
|
+
const originResponse = await getVtexFetch()(originUrl.toString(), init);
|
|
177
182
|
|
|
178
183
|
const responseHeaders = filterHeaders(new Headers(originResponse.headers));
|
|
179
184
|
|
|
@@ -380,7 +385,7 @@ export function createVtexCheckoutProxy(
|
|
|
380
385
|
init.duplex = "half";
|
|
381
386
|
}
|
|
382
387
|
|
|
383
|
-
const originRes = await
|
|
388
|
+
const originRes = await getVtexFetch()(originUrl.toString(), init);
|
|
384
389
|
const resHeaders = filterHeadersStrict(new Headers(originRes.headers));
|
|
385
390
|
rewriteSetCookieDomain(originRes.headers, resHeaders, url.hostname);
|
|
386
391
|
|