@decocms/apps-wake 7.72.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-wake",
3
- "version": "7.72.0",
3
+ "version": "8.1.0-next.0",
4
4
  "type": "module",
5
5
  "description": "Deco commerce app: Wake Commerce integration",
6
6
  "repository": {
@@ -17,6 +17,8 @@
17
17
  ".": "./src/index.ts",
18
18
  "./mod": "./src/mod.ts",
19
19
  "./client": "./src/client.ts",
20
+ "./storefront": "./src/storefront.ts",
21
+ "./storefront/types": "./src/storefrontTypes.ts",
20
22
  "./commerceLoaders": "./src/commerceLoaders.ts",
21
23
  "./loaders/*": "./src/loaders/*.ts",
22
24
  "./actions/*": "./src/actions/*.ts",
@@ -37,9 +39,9 @@
37
39
  "lint:unused": "knip"
38
40
  },
39
41
  "dependencies": {
40
- "@decocms/blocks": "7.72.0",
41
- "@decocms/apps-commerce": "7.72.0",
42
- "@decocms/tanstack": "7.72.0"
42
+ "@decocms/blocks": "8.1.0-next.0",
43
+ "@decocms/apps-commerce": "8.1.0-next.0",
44
+ "@decocms/tanstack": "8.1.0-next.0"
43
45
  },
44
46
  "peerDependencies": {
45
47
  "react": "^19.0.0",
@@ -0,0 +1,136 @@
1
+ // @vitest-environment node
2
+ /**
3
+ * The v8 Wake client (/next/upstream-clients): every request goes through the
4
+ * instrumented fetch as provider "wake", named by its operation, and errors
5
+ * never carry bodies or tokens.
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 { GetProduct } from "../storefront";
29
+ import { createWakeClient, WakeError } from "../wakeClient";
30
+
31
+ /** The error a call rejects with (fails the test if it resolves). */
32
+ const failure = (call: Promise<unknown>): Promise<Error> =>
33
+ call.then(
34
+ () => {
35
+ throw new Error("expected the call to fail");
36
+ },
37
+ (error: Error) => error,
38
+ );
39
+
40
+ function upstream(body: unknown, status = 200) {
41
+ return vi.fn(
42
+ async (_input: string | URL | Request, _init?: RequestInit) =>
43
+ new Response(JSON.stringify(body), { status }),
44
+ );
45
+ }
46
+
47
+ beforeEach(() => {
48
+ instrumented.providers.length = 0;
49
+ instrumented.operations.length = 0;
50
+ });
51
+
52
+ describe("createWakeClient", () => {
53
+ it("runs a storefront operation through the instrumented fetch, labeled by its name", async () => {
54
+ const fetch = upstream({ data: { product: { productId: 1 } } });
55
+ const wake = createWakeClient({ storefrontToken: "tok" }, { fetch });
56
+
57
+ const data = await wake.graphql<{ product: { productId: number } }>(
58
+ GetProduct,
59
+ { productId: 1 },
60
+ {
61
+ headers: {
62
+ "x-forwarded-for": "203.0.113.7",
63
+ "TCS-Access-Token": "spoofed",
64
+ "Content-Type": "text/plain",
65
+ },
66
+ },
67
+ );
68
+
69
+ expect(data).toEqual({ product: { productId: 1 } });
70
+ expect(instrumented.providers).toEqual(["wake"]);
71
+ expect(instrumented.operations).toEqual(["GetProduct"]);
72
+ const [url, init] = fetch.mock.calls[0]!;
73
+ expect(String(url)).toBe("https://storefront-api.fbits.net/graphql");
74
+ const headers = new Headers(init?.headers);
75
+ // Per-call headers can't override the token or the content type, whatever their case.
76
+ expect(headers.get("tcs-access-token")).toBe("tok");
77
+ expect(headers.get("content-type")).toBe("application/json");
78
+ expect(headers.get("x-forwarded-for")).toBe("203.0.113.7");
79
+ const body = JSON.parse(String(init?.body));
80
+ expect(body.variables).toEqual({ productId: 1 });
81
+ expect(body.query).toContain("fragment"); // fragments are sent with the document
82
+ });
83
+
84
+ it("throws a WakeError without the body on HTTP and GraphQL failures", async () => {
85
+ const failing = createWakeClient(
86
+ { storefrontToken: "tok" },
87
+ { fetch: upstream({ secret: "x" }, 502) },
88
+ );
89
+ const httpError = await failure(failing.graphql("query Shop { shop { name } }"));
90
+ expect(httpError).toBeInstanceOf(WakeError);
91
+ expect(httpError.message).toBe("wake Shop failed with HTTP 502");
92
+
93
+ const erroring = createWakeClient(
94
+ { storefrontToken: "tok" },
95
+ { fetch: upstream({ errors: [{ message: "customer jane@example.com not found" }] }) },
96
+ );
97
+ const gqlError = await failure(erroring.graphql("query Shop { shop { name } }"));
98
+ expect(gqlError.message).toBe("wake Shop returned 1 GraphQL error(s)");
99
+ expect(gqlError.message).not.toContain("jane");
100
+ expect(gqlError.message).not.toContain("tok");
101
+ });
102
+
103
+ it("reads the signed-in shopper from the checkout, or null when signed out", async () => {
104
+ const fetch = upstream({ CustomerAccessToken: "cat", Email: "a@example.com" });
105
+ const wake = createWakeClient(
106
+ { storefrontToken: "tok", checkoutUrl: "https://checkout.example.com" },
107
+ { fetch },
108
+ );
109
+ expect(await wake.getLogin("fbits-login=abc")).toMatchObject({ CustomerAccessToken: "cat" });
110
+ const [url, init] = fetch.mock.calls[0]!;
111
+ expect(String(url)).toBe("https://checkout.example.com/api/Login/Get");
112
+ expect(new Headers(init?.headers).get("cookie")).toBe("fbits-login=abc");
113
+ expect(instrumented.operations).toEqual(["getLogin"]);
114
+
115
+ const signedOut = createWakeClient(
116
+ { storefrontToken: "tok", checkoutUrl: "https://checkout.example.com" },
117
+ { fetch: upstream({}, 401) },
118
+ );
119
+ expect(await signedOut.getLogin("")).toBeNull();
120
+ });
121
+
122
+ it("throws on a rate limit instead of reporting the shopper as signed out", async () => {
123
+ const limited = createWakeClient(
124
+ { storefrontToken: "tok", checkoutUrl: "https://checkout.example.com" },
125
+ { fetch: upstream({}, 429) },
126
+ );
127
+ const error = await failure(limited.getLogin("fbits-login=abc"));
128
+ expect(error).toBeInstanceOf(WakeError);
129
+ expect(error.message).toBe("wake getLogin failed with HTTP 429");
130
+ });
131
+
132
+ it("requires checkoutUrl for getLogin", async () => {
133
+ const wake = createWakeClient({ storefrontToken: "tok" }, { fetch: upstream({}) });
134
+ await expect(wake.getLogin("")).rejects.toThrow(/set checkoutUrl/);
135
+ });
136
+ });
package/src/index.ts CHANGED
@@ -1,3 +1,16 @@
1
+ // v8: the thin Wake client (see /next/upstream-clients). Operations are in
2
+ // `@decocms/apps-wake/storefront`, their types in `.../storefront/types`.
3
+
4
+ export type { UserAuthenticate } from "./utils/client";
5
+ export {
6
+ createWakeClient,
7
+ type WakeClient,
8
+ type WakeClientConfig,
9
+ type WakeClientOptions,
10
+ WakeError,
11
+ } from "./wakeClient";
12
+
13
+ // v7 (kept for v7 consumers until the v7 modules are dropped)
1
14
  // App contract
2
15
 
3
16
  // Cart actions
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `@decocms/apps-wake/storefront`: Wake's Storefront GraphQL operations, to
3
+ * pass to `createWakeClient`'s `graphql`. Their generated request and response
4
+ * types are in `@decocms/apps-wake/storefront/types`:
5
+ *
6
+ * ```ts
7
+ * import { GetProduct } from "@decocms/apps-wake/storefront";
8
+ * import type { GetProductQuery, GetProductQueryVariables } from "@decocms/apps-wake/storefront/types";
9
+ *
10
+ * const data = await wake.graphql<GetProductQuery, GetProductQueryVariables>(GetProduct, { productId: 1 });
11
+ * ```
12
+ */
13
+ export * from "./utils/graphql/queries";
@@ -0,0 +1,6 @@
1
+ /**
2
+ * `@decocms/apps-wake/storefront/types`: types generated from Wake's
3
+ * Storefront GraphQL schema, one `<Operation>Query`/`<Operation>Mutation` and
4
+ * `...Variables` pair per operation in `@decocms/apps-wake/storefront`.
5
+ */
6
+ export type * from "./utils/graphql/storefront.graphql.gen";
@@ -0,0 +1,24 @@
1
+ /**
2
+ * GraphQL document helpers shared by the v8 client (`../wakeClient.ts`) and
3
+ * the v7 GraphQL client (`./graphql.ts`). Pure: no I/O, no framework imports.
4
+ */
5
+
6
+ /**
7
+ * Tagged-template helper that composes a GraphQL document string.
8
+ * Kept identical to the Deno `gql` so `graphql/queries.ts` (which defines
9
+ * `{ fragments, query }` objects) works verbatim.
10
+ */
11
+ export function gql(strings: TemplateStringsArray, ...values: unknown[]): string {
12
+ return strings.reduce((acc, str, i) => acc + str + (values[i] ?? ""), "");
13
+ }
14
+
15
+ /** A Wake storefront operation: its document plus the fragments it spreads. */
16
+ export interface QueryDefinition {
17
+ fragments?: string[];
18
+ query: string;
19
+ }
20
+
21
+ export function buildQuery(def: QueryDefinition): string {
22
+ const fragments = def.fragments?.join("\n") ?? "";
23
+ return fragments ? `${fragments}\n${def.query}` : def.query;
24
+ }
@@ -1,4 +1,4 @@
1
- import { gql } from "../graphql";
1
+ import { gql } from "../gql";
2
2
 
3
3
  const Checkout = gql`
4
4
  fragment Checkout on Checkout {
@@ -1,25 +1,9 @@
1
1
  import { type FetchFn, withFetchTimeout } from "@decocms/blocks/sdk/fetchTimeout";
2
2
  import type { InstrumentedFetchInit } from "@decocms/blocks/sdk/instrumentedFetch";
3
+ import { buildQuery, type QueryDefinition } from "./gql";
3
4
  import { extractGraphqlOperationName } from "./graphqlOperationName";
4
5
 
5
- /**
6
- * Tagged-template helper that composes a GraphQL document string.
7
- * Kept identical to the Deno `gql` so the ported `graphql/queries.ts`
8
- * (which defines `{ fragments, query }` objects) works verbatim.
9
- */
10
- export function gql(strings: TemplateStringsArray, ...values: unknown[]): string {
11
- return strings.reduce((acc, str, i) => acc + str + (values[i] ?? ""), "");
12
- }
13
-
14
- export interface QueryDefinition {
15
- fragments?: string[];
16
- query: string;
17
- }
18
-
19
- export function buildQuery(def: QueryDefinition): string {
20
- const fragments = def.fragments?.join("\n") ?? "";
21
- return fragments ? `${fragments}\n${def.query}` : def.query;
22
- }
6
+ export { buildQuery, gql, type QueryDefinition } from "./gql";
23
7
 
24
8
  export interface GraphQLClient {
25
9
  /**
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The Wake client: a thin, typed client for Wake's Storefront GraphQL API and
3
+ * the one checkout REST call a storefront needs, over the framework's
4
+ * instrumented fetch (provider "wake"). See /next/upstream-clients.
5
+ *
6
+ * Configuration comes in as arguments; the site reads its environment where
7
+ * it creates the client. Converters, hooks, cart/session flows, caching and
8
+ * proxying belong to the site (platform templates), not here.
9
+ */
10
+ import { createInstrumentedFetch } from "@decocms/blocks/fetch";
11
+ import type { UserAuthenticate } from "./utils/client";
12
+ import { buildQuery, type QueryDefinition } from "./utils/gql";
13
+ import { extractGraphqlOperationName } from "./utils/graphqlOperationName";
14
+
15
+ const WAKE_STOREFRONT_ENDPOINT = "https://storefront-api.fbits.net/graphql";
16
+
17
+ export interface WakeClientConfig {
18
+ /** Wake Storefront API token, sent as `TCS-Access-Token`. */
19
+ storefrontToken: string;
20
+ /** Storefront GraphQL endpoint; defaults to Wake's multi-tenant endpoint. */
21
+ storefrontEndpoint?: string;
22
+ /** Checkout origin, e.g. `https://checkout.example.com`. Required by `getLogin`. */
23
+ checkoutUrl?: string;
24
+ }
25
+
26
+ export interface WakeClientOptions {
27
+ /** The fetch underneath, e.g. a fake in tests. Defaults to `globalThis.fetch`. */
28
+ fetch?: Parameters<typeof createInstrumentedFetch>[0]["fetch"];
29
+ }
30
+
31
+ /** Thrown on a non-2xx response or a GraphQL `errors` payload. Never carries bodies or tokens. */
32
+ export class WakeError extends Error {
33
+ constructor(
34
+ readonly operation: string,
35
+ readonly status: number,
36
+ /** How many GraphQL errors the response carried (0 for an HTTP failure). */
37
+ readonly graphqlErrors = 0,
38
+ ) {
39
+ super(
40
+ graphqlErrors > 0
41
+ ? `wake ${operation} returned ${graphqlErrors} GraphQL error(s)`
42
+ : `wake ${operation} failed with HTTP ${status}`,
43
+ );
44
+ this.name = "WakeError";
45
+ }
46
+ }
47
+
48
+ export type WakeClient = ReturnType<typeof createWakeClient>;
49
+
50
+ export function createWakeClient(config: WakeClientConfig, options: WakeClientOptions = {}) {
51
+ const request = createInstrumentedFetch({ provider: "wake", fetch: options.fetch });
52
+ const endpoint = config.storefrontEndpoint || WAKE_STOREFRONT_ENDPOINT;
53
+ const checkoutUrl = config.checkoutUrl;
54
+
55
+ return {
56
+ /**
57
+ * Run a Storefront GraphQL operation (see `@decocms/apps-wake/storefront`).
58
+ * The operation label is the document's operation name. `headers` adds
59
+ * per-call headers, such as `X-Forwarded-For` or a customer access token;
60
+ * they never override the token or the content type.
61
+ */
62
+ async graphql<TData, TVariables = Record<string, unknown>>(
63
+ document: string | QueryDefinition,
64
+ variables?: TVariables,
65
+ init: { headers?: Record<string, string> } = {},
66
+ ): Promise<TData> {
67
+ const query = typeof document === "string" ? document : buildQuery(document);
68
+ const operation = extractGraphqlOperationName(query) ?? "graphql";
69
+ const headers = new Headers(init.headers);
70
+ headers.set("content-type", "application/json");
71
+ headers.set("tcs-access-token", config.storefrontToken);
72
+ const response = await request(endpoint, {
73
+ operation,
74
+ method: "POST",
75
+ headers,
76
+ body: JSON.stringify({ query, variables }),
77
+ });
78
+ if (!response.ok) throw new WakeError(operation, response.status);
79
+ const body = (await response.json()) as { data?: TData; errors?: unknown[] };
80
+ if (body.errors?.length) throw new WakeError(operation, response.status, body.errors.length);
81
+ if (body.data === undefined) throw new WakeError(operation, response.status);
82
+ return body.data;
83
+ },
84
+
85
+ /**
86
+ * `GET /api/Login/Get` on the checkout: exchanges the shopper's checkout
87
+ * cookies (the `fbits-login` cookie) for their account, or `null` when
88
+ * they aren't signed in (401, 403 or 404). Any other failure, such as
89
+ * a 429, throws a `WakeError`.
90
+ */
91
+ async getLogin(cookie: string): Promise<UserAuthenticate | null> {
92
+ if (!checkoutUrl) throw new Error("wake getLogin: set checkoutUrl");
93
+ const response = await request(new URL("/api/Login/Get", checkoutUrl), {
94
+ operation: "getLogin",
95
+ headers: { cookie },
96
+ });
97
+ if (response.status === 401 || response.status === 403 || response.status === 404) {
98
+ return null; // not signed in
99
+ }
100
+ if (!response.ok) throw new WakeError("getLogin", response.status);
101
+ return (await response.json()) as UserAuthenticate | null;
102
+ },
103
+ };
104
+ }