npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@doswiftly/storefront-sdk

v24.5.0

Published

Storefront runtime SDK for DoSwiftly Commerce — layered transport, middleware pipeline, React providers, Zustand stores, cache strategies. 0 runtime dependencies in core.

Readme

@doswiftly/storefront-sdk

Layered runtime SDK for DoSwiftly Commerce storefronts. Framework-agnostic core + React adapter, 0 runtime dependencies in core.

Architecture

@doswiftly/storefront-sdk
├── core (.)              — Framework-agnostic: transport + middleware pipeline,
│                           createStorefrontClient / createCustomerClient,
│                           CartClient / AuthClient, cart recovery runner,
│                           cart capability cookie (id + secret), auth route helpers,
│                           bot-protection managers, errors, format utilities,
│                           sanitizeHtml, normalizeConnection, schema enums
├── react (./react)       — React adapter: StorefrontProvider, CartManagerProvider,
│                           CustomerClientProvider, Zustand stores (Context-based),
│                           auth/cart/session hooks, pre-built headless components
├── react/server          — Server-side: client factory, SDK-BFF auth route
│                           (createStorefrontAuthRoute), getInitialAuth,
│                           first-party cookie readers, server cart-secret middleware
└── cache (./cache)       — Cache strategy functions

Core works everywhere: Node.js, Edge Workers, Deno, Bun, CLI scripts — without React. React adapter requires react ^18 || ^19 and zustand ^5 as peer dependencies.

Installation

pnpm add @doswiftly/storefront-sdk

Configuration

The framework-agnostic core (createStorefrontClient, createCustomerClient) takes apiUrl and shopSlug as explicit config — it never reads environment variables, sniffs hostnames, or inspects request headers. The storefront supplies the values; the client uses them verbatim.

The React providers (<StorefrontProvider>, <StorefrontClientProvider>, <CustomerClientProvider>) add one convenience on top: config is optional. When apiUrl / shopSlug are omitted they fall back to the NEXT_PUBLIC_API_URL / NEXT_PUBLIC_SHOP_SLUG env vars, so a standard Next.js deploy is zero-config. An explicit config value always wins over the environment, and a clear error is thrown at mount if neither source supplies a value.

Scaffolded storefronts (doswiftly init) ship a graphqlConfig helper (lib/graphql/config.ts) that resolves both values from three sources in order:

| Source | When | What it gives you | | --- | --- | --- | | doswiftly.config.ts (preferred) | doswiftly init storefronts | A committed config file with both values. No env wiring needed in normal use. | | NEXT_PUBLIC_API_URL + NEXT_PUBLIC_SHOP_SLUG (fallback) | Local development; storefronts scaffolded outside doswiftly init | Standard Next.js public env vars. doswiftly dev rewrites NEXT_PUBLIC_API_URL to a local CORS proxy at runtime so client-side calls don't hit production CORS headers. | | http://localhost:8000 + demo-shop (defaults) | Empty smoke test | Last-resort placeholders so the project boots before any config is written. |

If you go the env-var route, use exactly these namesdoswiftly dev keys off them when overriding. Inventing API_URL, STOREFRONT_URL, or TENANT_SLUG means the dev proxy starts, but your storefront still calls the production API directly and you only learn about it on the first client-side mutation (build and SSR pass silently).

Scratch-built storefronts can read process.env.NEXT_PUBLIC_* directly and pass the values into config={} — the resolution helper is a convenience, not a requirement.

Visitor telemetry

<StorefrontProvider> includes lightweight, cookieless visitor telemetry, enabled by default. On page load it sends a single ping and, while the tab is visible, a periodic heartbeat that powers "traffic" and "visitors online" figures in your shop dashboard.

  • What is collected: the page path, the referrer, the browser user agent, and an ephemeral, in-memory session id (a random value that lives only for the visit and is regenerated on the next load).
  • No cookies, no local storage, no PII. Nothing is persisted in the browser, and the SDK sends no personal data. Traffic source and device are classified server-side from the raw referrer and user agent — the SDK ships the raw values only.
  • Well-behaved by design: browser-only (never runs during SSR), fire-and-forget (a failed send never affects the page), pauses while the tab is hidden, and stops sending if the endpoint reports the shop is unknown.

Opt out, or tune the heartbeat cadence, on the client config:

// Disable entirely
<StorefrontProvider config={{ telemetry: false }} shopData={shopData}>…</StorefrontProvider>

// Keep it on but change the heartbeat interval (ms)
<StorefrontProvider config={{ telemetry: { heartbeatIntervalMs: 60000 } }} shopData={shopData}>…</StorefrontProvider>

The heartbeat interval has a minimum of 15 seconds; smaller values are raised to that floor.

Quick start — Next.js App Router

Four files give you a production-grade storefront runtime: first-party auth cookies with automatic session refresh, a shared cart with stale-cart auto-recovery, and a typed GraphQL client with the full middleware pipeline.

1. app/api/auth/[action]/route.ts — one file mounts the whole auth surface (POST /api/auth/login | signup | refresh | logout, GET /api/auth/whoami). The handlers run on the storefront's own domain, call the backend server-to-server, and own the first-party httpOnly cookies — the refresh token never reaches browser JavaScript. signup is symmetric to login: it both creates the account and establishes the durable session in one call.

import {
  createStorefrontAuthRoute,
  trustedForwardedHostValidator,
} from '@doswiftly/storefront-sdk/react/server';

export const { GET, POST } = createStorefrontAuthRoute({
  apiUrl: process.env.NEXT_PUBLIC_API_URL!,
  shopSlug: process.env.NEXT_PUBLIC_SHOP_SLUG!,
  // Pass when the storefront runs behind a reverse proxy that rewrites Host
  // (DoSwiftly hosting, Vercel). Omit for bare deployments / local dev.
  isTrustedOrigin: trustedForwardedHostValidator,
});

2. app/layout.tsx — seed the first render from the first-party cookies via getInitialAuth() (no signed-out flash, no whoami round-trip) and wrap the tree in StorefrontProvider:

import { StorefrontProvider } from '@doswiftly/storefront-sdk/react';
import { getStorefrontClient, getInitialAuth } from '@doswiftly/storefront-sdk/react/server';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const serverClient = getStorefrontClient({
    apiUrl: process.env.NEXT_PUBLIC_API_URL!,
    shopSlug: process.env.NEXT_PUBLIC_SHOP_SLUG!,
  });

  // `SHOP_CONFIG_QUERY` is your own operation — generated by graphql-codegen from
  // `@doswiftly/storefront-operations/schema.graphql`. It must request the fields
  // of the `ShopConfig` shape: `currencyCode`, `supportedCurrencies`,
  // `defaultLanguage`, `supportedLanguages`, `botProtection`.
  const [{ shop }, initialAuth] = await Promise.all([
    serverClient.query(SHOP_CONFIG_QUERY),
    getInitialAuth(),
  ]);

  return (
    <html lang="pl">
      <body>
        <StorefrontProvider
          config={{
            apiUrl: process.env.NEXT_PUBLIC_API_URL!,
            shopSlug: process.env.NEXT_PUBLIC_SHOP_SLUG!,
          }}
          shopData={shop}
          initialIsAuthenticated={initialAuth.isAuthenticated}
          initialAccessToken={initialAuth.accessToken}
          initialExpiresAt={initialAuth.expiresAt}
        >
          {children}
        </StorefrontProvider>
      </body>
    </html>
  );
}

Session refresh is automatic — the provider defaults to autoRefresh in the browser: a scheduler renews the access token shortly before it expires, and a 401 on a read query triggers a single deduped refresh + replay. Pass autoRefresh={false} to drive refreshing yourself.

3. Cart — wrap the shopping subtree in CartManagerProvider (one shared cart manager: one loading state, one recovery queue) and read it with useCartManagerContext():

'use client';
import { CartManagerProvider, useCartManagerContext } from '@doswiftly/storefront-sdk/react';
import { toast } from 'sonner';

export function ShopProviders({ children }: { children: React.ReactNode }) {
  return (
    <CartManagerProvider
      onMutationError={(operation, error) => toast.error(error.message)}
    >
      {children}
    </CartManagerProvider>
  );
}

export function AddToCart({ variantId }: { variantId: string }) {
  const { addItem, status } = useCartManagerContext();
  return (
    <button
      onClick={() => addItem([{ variantId, quantity: 1 }])}
      disabled={status.type === 'loading'}
    >
      Add to cart
    </button>
  );
}

The cart cookie, the cart access secret, creation on first add, and stale-cart recovery are all handled for you — see Cart.

4. Global session + cart expiry handling — mount once near the root:

'use client';
import { useEffect } from 'react';
import { useSessionExpired, useCartManagerContext } from '@doswiftly/storefront-sdk/react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';

export function GlobalGuards() {
  const router = useRouter();
  const { onExpired } = useCartManagerContext();

  // Fired when the SDK can no longer keep the customer session alive.
  useSessionExpired(() => router.replace('/auth/login?reason=session_expired'));

  // Fired when a stale cart cannot be transparently recovered.
  useEffect(
    () => onExpired(() => toast.error('Your cart expired — please add the items again')),
    [onExpired],
  );
  return null;
}

Quick start — Core (framework-agnostic)

import {
  createStorefrontClient,
  cartSecretMiddleware,
  retryMiddleware,
  timeoutMiddleware,
  errorMiddleware,
  CartClient,
  formatCartCookieValue,
} from '@doswiftly/storefront-sdk';

let cartSecret: string | null = null;

const client = createStorefrontClient({
  apiUrl: 'https://api.doswiftly.pl',
  shopSlug: 'my-shop',
  middleware: [
    cartSecretMiddleware(() => cartSecret), // lazy getter; secret rides cart ops only
    retryMiddleware({ maxRetries: 2 }),
    timeoutMiddleware({ timeout: 5000 }),
    errorMiddleware(), // ALWAYS LAST
  ],
});

const cartClient = new CartClient(client);

// `create` reveals a one-time cart access secret — store it immediately.
// Possession of the secret is what authorizes cart reads and writes.
const { cart, secret } = await cartClient.create();
cartSecret = secret;
if (secret) {
  persistCookie('cart-id', formatCartCookieValue({ cartId: cart.id, cartSecret: secret }));
}

const { cart: updated, warnings } = await cartClient.addItems(cart.id, [
  { variantId: 'variant-123', quantity: 1 },
]);

Queries are deduplicated and cacheable; mutations are never cached and never retried.

Export paths

| Path | Description | Dependencies | |------|-------------|-------------| | @doswiftly/storefront-sdk | Core: transport, middleware, clients, recovery, errors, format, enums, cookie contracts | 0 | | @doswiftly/storefront-sdk/react | Providers, hooks, stores, pre-built UI components | react, zustand | | @doswiftly/storefront-sdk/react/server | Server client factory, SDK-BFF auth route, cookie readers | react (peer; getInitialAuth additionally requires Next.js) | | @doswiftly/storefront-sdk/next | Zero-config Next.js adapters — createImageLoader() for images.loaderFile | 0 | | @doswiftly/storefront-sdk/next/config | getAssetPrefix() for next.config — loads under Node's native module resolver | 0 | | @doswiftly/storefront-sdk/cache | Cache strategy functions | 0 |

Authentication

Session model

Auth runs through BFF route handlers on the storefront's own domain (createStorefrontAuthRoute). The browser never talks to the backend directly for auth — the route handlers do, server-to-server. First-party cookies work identically on a platform subdomain, a custom domain, and off-platform hosting.

| Cookie | httpOnly | Path | Purpose | |--------|----------|------|---------| | customerAccessToken | yes | / | Access token — read server-side to seed SSR and the in-memory store | | customerRefreshToken | yes | /api/auth | Refresh token — read exclusively server-side by the BFF route | | session-expiry | no | / | Readable absolute expiry (ISO 8601) for the refresh scheduler |

Renewal is automatic (<StorefrontProvider autoRefresh> — default ON in the browser):

  • Proactive — a scheduler calls POST {authBasePath}/refresh shortly before expiresAt; the route rotates the refresh cookie and returns a fresh access token.
  • Reactive — a 401 on a read query triggers one deduped refresh and replays the query. A 401 on a mutation never retries — it fires the session-expired signal instead (replaying a mutation, e.g. a payment, would be unsafe).

When the session cannot be kept alive, subscribe globally:

'use client';
import { useSessionExpired } from '@doswiftly/storefront-sdk/react';

useSessionExpired((event) => router.replace('/auth/login'));

Sign-in form (BFF login) — useBffLogin

useBffLogin posts to POST {authBasePath}/login — the only flow that sets the full cookie set (access + refresh + expiry) on the storefront domain, required for automatic session refresh. It seeds the client-side store and returns { success, userErrors }, symmetric to useRegister — no hand-rolled fetch:

'use client';
import { useBffLogin } from '@doswiftly/storefront-sdk/react';

export function LoginForm() {
  const { login, isLoggingIn, error } = useBffLogin();

  async function onSubmit(email: string, password: string) {
    const result = await login(email, password);
    if (!result.success) {
      // Backend errors are passed through verbatim (already localized).
      showErrors(result.userErrors);
      return;
    }
    // Signed in — the store is seeded and the first-party cookies are set.
  }
  // ...
}

useBffLogin mirrors useRegister: same { success, userErrors } shape, same store seeding. (Login is not bot-protected on the backend, so unlike useRegister it fetches no verification challenge.) login accepts an optional loginPath via useBffLogin({ loginPath }) when the route is mounted off the default /api/auth.

After a successful sign-in, merge the guest cart into the customer's cart with cartClient.merge(guestCartId) — see Auth ↔ cart lifecycle.

GraphQL auth hooks — useLogin / useLogout / useAuth

The focused hooks drive auth over the GraphQL transport (customerLogin / customerLogout mutations) and keep the token in the in-memory store. The backend sets/clears its own httpOnly cookie on these mutations, on the API domain — fine for same-site setups and non-browser clients. For first-party cookies on the storefront domain (and the refresh cookie required by autoRefresh) prefer useBffLogin above; the optional onSetToken / onClearToken callbacks let you sync a cookie through your own route during migration from older setups.

import { useLogin, useLogout } from '@doswiftly/storefront-sdk/react';

const { login, isLoggingIn, error } = useLogin();
const { logout, isLoggingOut } = useLogout();

const result = await login(email, password);
if (!result.success) showErrors(result.userErrors); // backend-translated messages

useLogout additionally downgrades the active cart to guest before logging out (clears the customer's contact details, addresses and saved-payment selection so they do not linger on a shared device) — best-effort, never blocks sign-out.

Registration with auto sign-in — useRegister

useRegister posts to the BFF signup route, so a new account gets the same durable first-party session as login (access and refresh cookies) in a single call — no separate sign-in step. The new access token is seeded into the store, so the buyer is signed in on the next render. Bot protection is automatic — when the shop has it configured, the hook fetches a verification token from the same manager that protects GraphQL mutations and forwards it; you never handle tokens yourself.

import { useRegister } from '@doswiftly/storefront-sdk/react';

const { register, isRegistering, error } = useRegister();

const result = await register({
  // Beyond email + password every field is optional — including the newsletter opt-in.
  email,
  password,
  firstName,
  lastName,
  acceptsMarketing: true,
});
if (!result.success) showErrors(result.userErrors); // backend-translated messages

The registration input is a CustomerCreateInput. Beyond email + password every field is optional, including the newsletter opt-in: acceptsMarketing (a true subscribes the customer; omit it to leave consent unchanged) and marketingOptInLevel (SINGLE_OPT_IN by default, or CONFIRMED_OPT_IN to send a double-opt-in confirmation email and leave consent pending until the buyer confirms).

Mount the signup action with createStorefrontAuthRoute (the Next.js App Router quick start above) — the same route file already serves it.

useAuth(options?) is a convenience facade aggregating useLogin, useLogout and useRefreshToken:

const {
  login, logout, refreshToken,
  isLoggingIn, isLoggingOut, isRefreshingToken, isLoading,
  error,
} = useAuth();

Auth state (customer, flags) lives in the store, not in the hooks:

import { useAuthStore, useAuthHydrated } from '@doswiftly/storefront-sdk/react';

const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const customer = useAuthStore((s) => s.customer);
const authHydrated = useAuthHydrated(); // true after persist rehydration

E-mail verification — useEmailVerification

Every account e-mail (welcome / verification) carries a confirmation link. By default the link lands on a built-in platform confirmation page — your storefront needs no code for verification to work. To host your own branded page instead, build it with this hook, then in the admin panel turn off the Built-in e-mail verification page switch (Settings → Store → Customer account) and enter your page's path; from then on e-mails link to your page with the raw token in the ?token= query param.

'use client';
import { useEmailVerification } from '@doswiftly/storefront-sdk/react';

const { verifyEmail, resendVerificationEmail, isVerifying, isResending } = useEmailVerification();

// Verification page (e.g. /account/verify-email?token=…) — public, no session needed:
const result = await verifyEmail(token);
if (result.success) showSuccess();                       // idempotent — safe on refresh
else if (result.error) showRetryNotice();                // request failed — supply your own copy
else if (result.userErrors[0]?.code === 'TOKEN_EXPIRED') offerResend();

// Account settings CTA for a signed-in customer:
const resend = await resendVerificationEmail();          // recipient comes from the session
if (!resend.success && resend.userErrors[0]?.code === 'ALREADY_VERIFIED') showVerifiedBadge();

Both actions resolve with { success, userErrors, error } (no throw on backend rejections). Stable userErrors[].code values: TOKEN_EXPIRED, TOKEN_INVALID, TOKEN_USED for verifyEmail; ALREADY_VERIFIED, TOKEN_INVALID (no session) for the resend — their message is already localised, render it as-is.

A failed request (rate limit, network, server error) arrives as error, never as a userErrors entry: the backend authors no copy for it, so the SDK invents none. Supply your own wording and pick it with error.code, error.isNetworkError or error.retryAfterMs. After a successful verification refetch the customer to pick up the updated isEmailVerified field.

Cold-start gating — wait for the session to settle

On a hard reload the short-lived access cookie may have lapsed while the long-lived session-expiry hint still marks the buyer authenticated. The refresh scheduler recovers the token immediately — via the idempotent whoami route (no refresh-token rotation, not rate-limited), falling back to a rotating refresh only if the access token is actually gone — but a customer-account query that fires in that window races ahead with no token and gets a guest response — showing a logged-in buyer as "signed out".

Gate customer-account reads on useAuthReady() so the first read waits for the cold-start refresh:

import { useAuthReady } from '@doswiftly/storefront-sdk/react';

const authReady = useAuthReady(); // false only while authenticated-but-no-token-yet

const { data } = useQuery({
  queryKey: ['account'],
  queryFn: fetchAccount,
  enabled: authReady, // don't fetch as a guest mid-refresh
});

useAuthReady() is true for a guest or an authenticated buyer with a token in memory, and false only while the identity is settling (useAuthSettling() is the inverse). Combine with useAuthHydrated() to also wait for localStorage rehydration.

AuthClient (no React)

import { AuthClient } from '@doswiftly/storefront-sdk';

const authClient = new AuthClient(client, { authBasePath: '/api/auth' });

| Method | Returns | Notes | |---|---|---| | login(email, password) | Promise<AuthResult> | GraphQL mutation; throws StorefrontError with backend-translated userErrors | | register(input) | Promise<AuthResult> | CustomerCreateInput; result carries customer | | logout() | Promise<void> | Never throws (token may already be expired) | | refreshSession() | Promise<SessionRefreshResult> | Same-origin POST {authBasePath}/refresh (BFF) — works with an expired access token; throws SESSION_EXPIRED on failure, or RATE_LIMITED on a 429 (transient — retry, not a logout) | | recoverSession() | Promise<SessionRecoverResult \| null> | Same-origin GET {authBasePath}/whoami (BFF) — recovers the in-memory access token without rotating the refresh token (idempotent, not rate-limited). null when there is no live session (fall back to refreshSession()) | | getCustomer() | Promise<Customer \| null> | null when unauthenticated | | getAddresses() | Promise<MailingAddress[] \| null> | Saved address book incl. B2B fields (taxId, vatNumber) and isDefault; null when unauthenticated | | verifyEmail(token) | Promise<void> | Consumes the raw token from the verification e-mail URL; public (no session needed), idempotent after success. Throws StorefrontError — read userErrors[0].code (TOKEN_EXPIRED / TOKEN_INVALID / TOKEN_USED) | | resendVerificationEmail() | Promise<void> | Queues a fresh verification e-mail for the signed-in customer (no arguments — recipient from session). Throws StorefrontError with ALREADY_VERIFIED when the address is already confirmed | | refreshToken() | Promise<AuthResult> | Deprecated — GraphQL refresh requires a still-valid access token; use refreshSession() |

Low-level route handlers (escape hatch)

createStorefrontAuthRoute is the recommended mount. The standalone Web API factories remain available for custom setups and migrations:

import {
  createSetTokenHandler,   // POST — sets the httpOnly access-token cookie
  createClearTokenHandler, // POST — clears it
  createWhoamiHandler,     // GET  — hydrates { isAuthenticated, customer } from the cookie
} from '@doswiftly/storefront-sdk';

export const POST = createSetTokenHandler();

All handlers are pure Web API (Request/Response) — they run in Next.js Route Handlers, Cloudflare Workers, Deno, etc. Security baked in: strict origin validation, Content-Type check, SameSite=Lax, httpOnly cookies.

Behind a reverse proxy

When a proxy rewrites/strips Host (DoSwiftly hosting, Vercel, NGINX), strict Origin host === Host validation would 403 every auth call. Every handler (and createStorefrontAuthRoute) accepts an isTrustedOrigin predicate:

import {
  trustedForwardedHostValidator, // trust X-Forwarded-Host (set by the proxy)
  originAllowlistValidator,      // static allowlist of origins
} from '@doswiftly/storefront-sdk';

createStorefrontAuthRoute({ apiUrl, shopSlug, isTrustedOrigin: trustedForwardedHostValidator });
// or
createSetTokenHandler({
  isTrustedOrigin: originAllowlistValidator(['https://shop.example.com']),
});

Custom predicates get ({ origin, originHost, request }) and may be async. A throwing predicate fails closed (falls back to strict matching).

Auth token client (client-side helper)

import { createAuthTokenClient } from '@doswiftly/storefront-sdk';

const { setToken, clearToken } = createAuthTokenClient();
await setToken(accessToken);  // POST /api/auth/set-token
await clearToken();           // POST /api/auth/clear-token

Newsletter

useNewsletter() powers a subscribe / unsubscribe widget. No customer session is required, so it works in a footer form for anonymous visitors. Subscribing is double opt-in: a confirmation e-mail goes out and the address joins the list only after the recipient clicks the link.

'use client';
import { useNewsletter } from '@doswiftly/storefront-sdk/react';

export function NewsletterForm() {
  const { subscribe, isSubscribing } = useNewsletter();
  const [notice, setNotice] = useState('');

  async function onSubmit(email: string) {
    const { accepted, userErrors, error } = await subscribe(email);
    if (accepted) return setNotice('Check your inbox to confirm.');
    if (error) return setNotice('Could not send it right now — please try again.');
    setNotice(userErrors[0]?.message ?? 'Please check the address.');
  }

  return /* your form */;
}

Reading the response:

| Field | Meaning | |---|---| | accepted: true | The request was taken. It does not mean the address is on the list — an address that already exists returns the same response, so the endpoint cannot be used to probe the list. Show one neutral confirmation. | | userErrors | The submitted address was rejected. Each entry carries a stable code (INVALID_EMAIL_FORMAT, TOO_LONG) and a message already localised by the backend — render it as-is, branch on the code. One address can produce more than one entry, so scan the array. | | error | The request never reached a verdict: rate limit, bot-protection rejection, network failure, server error. The backend authors no buyer-facing copy for these, so the SDK invents none — supply your own and pick it with error.code, error.isNetworkError or error.retryAfterMs. |

The hook never throws — a rejected address is ordinary form feedback, and a failed request is reported through error. unsubscribe(email) follows the same contract. Marketing e-mails also carry a one-click unsubscribe link, so a dedicated unsubscribe page is optional.

The opt-in level is not a parameter — guest subscriptions always use double opt-in and the server ignores any value sent for it.

Both mutations are rate limited to 10 requests per minute. When the shop has bot protection configured, StorefrontProvider attaches the token automatically — that is also why the hook is the supported surface: it inherits the provider's middleware pipeline (bot protection, error normalisation) without extra wiring.

Customer account surface

The GraphQL API has two surfaces:

| Surface | Endpoint | Use for | Caching | |---|---|---|---| | Public catalog | /storefront/graphql | Products, collections, search, cart, checkout | Cacheable (shared-edge eligible) | | Customer account | /storefront/customer/graphql | The signed-in buyer's orders, addresses, wishlist, loyalty | Always served fresh (never shared-cached) |

StorefrontProvider and createStorefrontClient target the public surface by default. To talk to the customer-account surface, use a dedicated client — it carries the buyer's token and never serves a shared-cached response.

React — <CustomerClientProvider> + useCustomerClient()

Render <CustomerClientProvider> inside <StorefrontProvider> (it reads the same auth, currency and language stores from context) and read the client with useCustomerClient():

'use client';

import { CustomerClientProvider, useCustomerClient } from '@doswiftly/storefront-sdk/react';

// Wrap the signed-in area once — e.g. app/(account)/layout.tsx.
// `config` is optional: apiUrl/shopSlug default from NEXT_PUBLIC_API_URL /
// NEXT_PUBLIC_SHOP_SLUG. Pass config={{ apiUrl, shopSlug }} to override the env.
export function AccountProviders({ children }: { children: React.ReactNode }) {
  return <CustomerClientProvider>{children}</CustomerClientProvider>;
}

// Anywhere below it
function OrderList() {
  const client = useCustomerClient();
  // client.query(...) → /storefront/customer/graphql, with the buyer's bearer token
}

The provider builds a second client (the public one from StorefrontProvider is untouched) with the full pipeline: session-retry → auth → currency → language → retry → timeout → errors. config.graphqlPath defaults to /storefront/customer/graphql; override it only if you have a custom mount.

Because the account surface carries the buyer's token, it is the one most likely to see a 401. When automatic session refresh is on (the default), it shares the same deduped renewal as the public client — a 401 renews the session and replays the request, and two surfaces hitting a 401 at once make a single same-origin /refresh (one cookie rotation, no race).

Core — createCustomerClient (no React)

For Node, Edge Workers or any non-React runtime, createCustomerClient is the framework-agnostic factory. It is createStorefrontClient with graphqlPath defaulted to the customer-account surface — supply your own auth middleware:

import { createCustomerClient, authMiddleware } from '@doswiftly/storefront-sdk';

const customerClient = createCustomerClient({
  apiUrl: 'https://api.doswiftly.pl',
  shopSlug: 'my-shop',
  middleware: [authMiddleware(() => getToken())],
});

const { customer } = await customerClient.query(MY_ORDERS_QUERY);

Data fetching

Reading data in a Client Component? Two options — pick per need. Both run on the same typed client.query, so the SDK never locks you into a data library.

Zero-config — useStorefrontQuery / useCustomerQuery

Drop-in hooks with data / error / loading flags, an enabled gate, refetch, request cancellation, and re-keying (a previous result is never shown for new inputs).

'use client';
import { useStorefrontQuery, useCustomerQuery } from '@doswiftly/storefront-sdk/react';

// Catalog read (search box, "load more", client-side filters)
const { data, isFetching } = useStorefrontQuery(SearchProductsQuery, {
  variables: { query }, enabled: query.length > 1,
});

// Account read — waits for the session (useAuthReady) and resets on account switch.
// Use `isLoading` (not `isPending`) for a spinner: a gated guest query keeps `isPending`
// true forever, but `isLoading` is false, so you render the empty state instead.
const { data, isLoading, error } = useCustomerQuery(RecentOrdersQuery, {
  variables: { first: 5 },
});

| Field | Meaning | |---|---| | data | Result for the current inputs, or undefined while pending / on error. | | error | A StorefrontError, or null. | | isPending | true until the current inputs first settle — including while gated (enabled: false). Use isLoading for a spinner. | | isFetching | true while a request is in flight (initial load or refetch). | | isLoading | true while a request is in flight and there's no data yet (isPending && isFetching) — the spinner gate. false when the query is gated, so a signed-out account view shows its empty state, not an endless spinner. | | isError / isSuccess | Terminal state for the current inputs. | | refetch() | Re-run the query. Stable reference (including through a library adapter). |

useStorefrontQuery options: variables, enabled, cache (public catalog only), client (defaults to the public client), resetKey (extra reset axis). useCustomerQuery targets the customer-account surface, gates on the session, and re-keys on the signed-in customer — so it takes only variables and enabled.

These hooks keep per-component state only — no cross-component cache, focus refetch, or devtools. To add those, register a server-state library on the provider (below); the same hooks then read through it.

Browser-side cache — register a server-state library

Account data (orders, profile) isn't edge-cached, so a browser-side cache is where a returning visitor avoids a re-fetch. Register your library once (a memoized value or a module-level singleton, not toggled at runtime) on <StorefrontProvider queryAdapter={…}>. The data hooks then read through it — keyed per customer, gated on the session, and cleared on sign-out / session expiry / account switch automatically:

// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { StorefrontProvider } from '@doswiftly/storefront-sdk/react';
import { createTanstackQueryAdapter } from '@doswiftly/storefront-sdk/react/tanstack';

const queryClient = new QueryClient();

export function Providers({ children, shopData }) {
  return (
    <QueryClientProvider client={queryClient}>
      <StorefrontProvider shopData={shopData} queryAdapter={createTanstackQueryAdapter(queryClient)}>
        {children}
      </StorefrontProvider>
    </QueryClientProvider>
  );
}
// The SAME hook — now cached cross-component, keyed per customer, cleared on sign-out.
const { data, isLoading } = useCustomerQuery(RecentOrdersQuery, { variables: { first: 20 } });

@tanstack/react-query is an optional peer dependency (install it only if you use this adapter). For advanced library features the adapter doesn't surface (select, infinite, optimistic, …), call your library directly with useCustomerClient() + client.query. Bring another library (SWR, urql) by implementing the StorefrontQueryAdapter port yourself.

See the storefront Data fetching guide for the full pattern.

Cart

Capability model — cart id + secret

Cart access is authorized by possession of a secret, not by the customer session. The cart-id cookie stores a composite value "<cartId>.<secret>" (30 days, SSR/edge-visible, not httpOnly — the cart carries no payment data). CartClient.create() and recoveryRedeem() reveal the secret once; the SDK persists it into the cookie for you. Cart operations then carry the secret in the x-cart-secret header via middleware — public reads (product listings, search, recommendations, payment methods) do not, so they stay eligible for the shared cache even when the visitor has a cart:

  • Browser: StorefrontProvider wires cartSecretMiddleware automatically — the secret is read lazily from the cookie and attached only to cart operations, so a rotated secret is picked up without rebuilding the client.
  • Server (SSR/edge): prepend serverCartSecretMiddleware(await readCartCredentials()) to your server client — see Server-side.
  • Custom runtimes: cartSecretMiddleware(() => secret) + the parseCartCookieValue / formatCartCookieValue helpers.

Cart operations are recognised by a Cart operation-name prefix (isCartScopedOperation). If you issue custom cart queries, name them with a Cart prefix, or pass your own classifier as the second argument: cartSecretMiddleware(getSecret, (operationName) => ...).

A cookie without the secret half (or a stale capability) makes the cart unreachable — mutations reject with CART_NOT_FOUND and the standard recovery flow recreates a fresh cart.

useCartManager — cookie-driven cart + checkout lifecycle

The primary React cart API. Owns the cart-id cookie, auto-creates the cart on first add, persists the secret, and recovers from stale carts per operation:

'use client';
import { useCartManager } from '@doswiftly/storefront-sdk/react';

const {
  // Read
  getCart, getCartId,
  // Mutations (all return Promise<CartMutationOutcome> = { cart, warnings })
  addItem, updateItem, removeItem,
  updateBuyerIdentity, setShippingAddress, setBillingAddress,
  updateDiscountCodes, updateNote, updateAttributes,
  selectShippingMethod, selectPaymentMethod, clearPaymentSelection,
  applyGiftCard, removeGiftCard, updateGiftCardRecipient,
  // Completion
  complete,      // Promise<CartCompleteOutcome> = { order, warnings }
  createPayment, // Promise<PaymentSession> — post-completion, works on orderId
  // Lifecycle
  clearCart, onExpired,
  // Reactive state
  status,        // tagged union — see below
  isLoading, error, // derived selectors over `status`
} = useCartManager(options?);

Per-operation recovery strategy — when a write hits a stale cart (userErrors[].codeCART_NOT_FOUND / ALREADY_COMPLETED):

| Strategy | Operations | Behaviour | |---|---|---| | Auto-replay | addItem, updateBuyerIdentity, setShippingAddress, updateDiscountCodes, updateNote, updateAttributes | Transparently recreates the cart via an atomic cartCreate(input) and resolves as success | | Bail + event | updateItem, removeItem, setBillingAddress, selectShippingMethod, selectPaymentMethod, clearPaymentSelection, applyGiftCard, removeGiftCard, updateGiftCardRecipient, complete | Clears the cookie, throws CartRecoveryNotPossibleError, and fires every onExpired listener — subscribe once globally instead of try/catching every call site | | Out of scope | createPayment | Operates on orderId (post-completion) — no cart to recover |

Status is a tagged union for exhaustive rendering:

const { status } = useCartManager();
if (status.type === 'loading') return <Spinner label={status.operation} />;
if (status.type === 'error') return <ErrorBanner error={status.error} />;
// status.type ∈ { 'idle', 'success' }

Options (UseCartManagerOptions) — all additive:

| Option | Purpose | |---|---| | initialCartId | Server-known cart-id seed used when the cookie is empty on mount (cookie wins). Accepts a bare id or the composite "<cartId>.<secret>" value — pass the composite when the secret is known server-side. Use cases: SSR checkout, magic-link, embedded iframe, customer-service "view this cart", multi-cart B2B. | | onMutationStart / onMutationSuccess / onMutationError | Lifecycle callbacks around every operation — centralize toasts / router refresh / loading indicators. Cart expiry goes to onExpired, not onMutationError. | | cookieDebug | Debug sink for cart-id cookie writes — see Debug logging. |

<CartManagerProvider> — one shared instance

useCartManager keeps per-mount state, so calling it in several components creates independent managers. Wrap the subtree (inside StorefrontProvider) and read the shared instance with useCartManagerContext():

'use client';
import { CartManagerProvider, useCartManagerContext } from '@doswiftly/storefront-sdk/react';

<CartManagerProvider
  initialCartId={initialCartId}
  onMutationSuccess={() => router.refresh()}
  onMutationError={(operation, error) => toast.error(error.message)}
>
  <CheckoutForm />
</CartManagerProvider>;

// CheckoutForm.tsx
const { addItem, complete, status } = useCartManagerContext();

For deliberately independent managers (multi-cart B2B, an admin "view this cart" panel) call useCartManager() directly instead.

Checkout completion + payment

const { complete, createPayment } = useCartManagerContext();

// 1. Finalize the cart into an Order. On success the cart-id cookie is cleared
//    and status resets to idle — a follow-up addItem creates a fresh cart.
const { order } = await complete();

// 2. Decide the payment flow from the Order itself — no hardcoded brand checks.
if (order.canCreatePayment) {
  const session = await createPayment({
    orderId: order.id,
    returnUrl: `${origin}/checkout/return`, // optional — must point to a verified shop domain
  });
  // Branch on session.flow: redirectUrl (redirect), clientSecret (embedded
  // widget), status (instant settlement). Failures throw with
  // `err.userErrors[0].code` of the PAYMENT_* family.
}

getBrowserDataForPayment() (from /react) is a standalone helper collecting the browser context fields used by strong-customer-authentication flows (user agent, screen, timezone, language); it throws BrowserDataNotAvailableError outside the browser — call it in an event handler, never during SSR.

The completed order also carries order.accessToken — an opaque token for guest order lookup via cartClient.getOrderByToken(token, email?).

Discovery queries (CartClient)

const cartClient = new CartClient(client);

// Cart-aware shipping preview for an address. Returns null when the cart is gone.
const payload = await cartClient.getAvailableShippingMethods(cartId, address);
if (payload) {
  if (payload.userErrors.length > 0) {
    // Backend business condition with a translated message
    // (e.g. a digital-only cart needs no shipping).
    show(payload.userErrors[0].message);
  } else {
    render(payload.methods, payload.freeShippingProgress);
    // each method carries deliveryType: HOME | PICKUP_POINT | LOCKER
  }
}

// Shop-level payment methods + the default pre-selection signal.
const { methods, defaultMethod } = await cartClient.getAvailablePaymentMethods();

// Validate a discount code before applying it.
const result = await cartClient.validateDiscountCode(cartId, 'SAVE10');
if (!result.isValid) show(result.error?.message); // backend-translated

// Guest order lookup by the opaque token from complete().
const order = await cartClient.getOrderByToken(token, email);

Error-handling contract across the SDK (messages are always backend-translated per Accept-Language — the SDK never synthesizes copy):

| Backend shape | SDK behaviour | You handle | |---|---|---| | Mutation with userErrors[] | Throws StorefrontError; err.userErrors[0].message is translated | try/catch, branch on err.userErrors[0].code | | Nullable query root | Returns T \| null | if (!result) … | | Structured payload (errors inside) | Returns the raw payload | Branch on payload.userErrors[].code / payload.error.code |

Auth ↔ cart lifecycle

// After a successful sign-in: merge the guest cart into the customer context.
// The secret is preserved — the same cookie keeps working.
await cartClient.merge(guestCartId);

// On sign-out: strip customer PII from the cart (contact details, addresses,
// saved-payment selection). `useLogout` calls this automatically.
await cartClient.downgradeOnLogout(cartId);

// Cart recovery links (e.g. from an abandoned-cart email): redeem the token.
// Rotates the secret — persist the new composite cookie value.
const { cart, secret } = await cartClient.recoveryRedeem(token);

Cart recovery without React

The same recovery semantics ship in core for Vue/Svelte/CLI/mobile consumers:

import {
  CartClient,
  createCartRecoveryRunner,
  recreateWithInput,
  CartRecoveryNotPossibleError,
  type CartCookieStore,
} from '@doswiftly/storefront-sdk';

// Implement the cookie port for your runtime
// (browsers can use createBrowserCartCookieStore from /react).
const cookieStore: CartCookieStore = {
  get: () => readCartCookie(),                    // returns the cart id
  set: (cartId, opts) => writeCartCookie(cartId, opts?.secret),
  clear: () => deleteCartCookie(),
};

const runner = createCartRecoveryRunner({ cartClient, cookieStore });

runner.onExpired((event) => {
  console.warn(`Cart expired (${event.reason}) — reset local state.`);
});

// Auto-replay: the caller never thinks about stale carts.
const { cart, warnings } = await runner.execute({
  name: 'addItems',
  run: (cartId) => cartClient.addItems(cartId, [{ variantId: 'v-123', quantity: 1 }]),
  recreateAndRun: recreateWithInput({ lines: [{ variantId: 'v-123', quantity: 1 }] }),
});

// Bail-on-stale: no recreateAndRun — throws CartRecoveryNotPossibleError instead.

Detection inspects err.userErrors[].code (CART_NOT_FOUND / ALREADY_COMPLETED) — locale-independent. The runner also creates the cart on first use (deduped across concurrent calls) and persists the revealed secret through the cookie store.

useCart(cartId) — server-driven cart

Sister of useCartManager bound to an explicit cartId prop — never touches the cookie, no auto-recovery. For SSR-rendered checkout, deep-link order recovery, and admin "view this cart" UIs:

'use client';
import { useCart } from '@doswiftly/storefront-sdk/react';

const {
  cart, isLoading, error, operation,
  refetch,
  addItems, updateItems, removeItems,
  updateBuyerIdentity, setShippingAddress,
  updateDiscountCodes, updateNote, updateAttributes,
} = useCart(cartId, {
  autoFetch: false,   // skip the mount fetch when the server already rendered the cart
  initialCart,        // SSR seed — combine with autoFetch: false
});

Referral program

When the shop's loyalty referral program is enabled, customers share links like https://shop.example/register?ref=REF-AB12CD34 (the code and share URL come from the referralStats / loyaltyGenerateReferralCode GraphQL operations). The SDK bridges the gap between landing on such a link and the actual signup:

// 1. Capture the code on landing — mount once near the top of the app.
'use client';
import { useReferralCapture } from '@doswiftly/storefront-sdk/react';

export function ReferralCapture() {
  useReferralCapture(); // reads ?ref=... and stores it in the `referral-code` cookie (30 days)
  return null;
}
// 2. At signup, pass the stored code in the customerSignup input and clear it on success.
import { getReferralCodeCookie, clearReferralCodeCookie } from '@doswiftly/storefront-sdk/react';

const referralCode = getReferralCodeCookie();
await signup({ email, password, firstName, lastName, referralCode: referralCode ?? undefined });
clearReferralCodeCookie();

Codes are case-insensitive and an invalid, malformed, expired or own code is silently ignored by the API — the signup always succeeds. The capture step only persists values matching the platform code shape (letters, digits, dashes — REFERRAL_CODE_PATTERN), so junk ?ref= values never reach the cookie. Server Components can read the stored code with the async readReferralCodeCookie() from @doswiftly/storefront-sdk/react/server (e.g. to pre-fill an SSR-rendered form) — note the deliberate naming split: synchronous getReferralCodeCookie() on the client entry vs server-first readReferralCodeCookie() on the server entry. Non-React runtimes can use the core helpers directly: extractReferralCodeFromUrl(url) plus the REFERRAL_COOKIE_NAME / REFERRAL_COOKIE_MAX_AGE constants.

The cookie lifetime (30 days, override via captureReferralCode({ maxAge })) is the landing → signup window. It is independent of the shop's referral validity setting — that one starts at signup, limits the time the referred customer has to place their first order, and is enforced by the backend.

Pre-built React components

Headless, accessibility-aware, zero styling — pass className to integrate with your CSS approach. Available from @doswiftly/storefront-sdk/react:

| Component | Purpose | |-----------|---------| | <Money amount currency locale?> | Locale-formatted price string from minor units | | <Image data sizes priority> | <img> with thumbhash blur placeholder + sane defaults | | <CartCount count label> | Aria-live cart item count | | <AddToCartButton variantId quantity> | Button wired to useCartManager().addItem (loading state + a11y error surfacing) | | <PriceDisplay price compareAtPrice currency locale?> | Price + optional strikethrough sale price | | <CartTotals subtotal tax shipping discount total currency> | Cart financial breakdown <dl> | | <PaymentInstrumentTile instrument> | One selectable payment instrument (card brand, wallet, bank) | | <PaymentInstrumentSection method> | Instrument group for a payment method (renders tiles) |

import { Money, PriceDisplay, CartCount } from '@doswiftly/storefront-sdk/react';

<Money amount={9990} currency="PLN" locale="pl-PL" />          {/* "99,90 zł" */}
<PriceDisplay price={7990} compareAtPrice={9990} currency="PLN" locale="pl-PL" />
<CartCount count={3} label="items" />

Images — zero-config next/image loader

createImageLoader() is a drop-in loader for Next.js images.loaderFile. It routes every <Image> through the image CDN so each one is resized to the viewport (real responsive srcset) and format-negotiated (AVIF/WebP from the browser Accept header) — no per-image configuration.

Create the loader file (zero arguments):

// lib/image-loader.ts
import { createImageLoader } from '@doswiftly/storefront-sdk/next';

export default createImageLoader();

Point Next.js at it, and bound the generated sizes so a fixed set of widths is produced (fewer transforms, better cache hit rate):

// next.config.ts
const nextConfig = {
  images: {
    loader: 'custom',
    loaderFile: './lib/image-loader.ts',
    deviceSizes: [640, 750, 828, 1080, 1200, 1920],
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
  },
};
export default nextConfig;

The loader is global and handles each <Image src> by category:

| src | Handling | |-------|----------| | Product image (CDN URL from the GraphQL API) | Width set per srcset entry — drop transform: { maxWidth }, the loader owns the width | | Local public/ image (e.g. /hero.webp) | Routed through the CDN, resized + format-negotiated | | Image imported in code (import hero from './hero.webp') | Routed through the CDN — the framework bundles it under /_next/static/media/, the loader resizes it per srcset (no code change needed) | | Other build asset (/_next/* JS/CSS, /_nuxt/, /_astro/, /_app/), absolute external URL, data: URI, SVG | Returned unchanged |

The loader reads NEXT_PUBLIC_SHOP_ID, NEXT_PUBLIC_DEPLOYMENT_COMMIT, NEXT_PUBLIC_IMGPROXY_BASE, and NEXT_PUBLIC_ASSET_CLEAN_URL — all injected by the platform at build/deploy time. When they are absent (e.g. local doswiftly dev, where public/ images are served by the dev server) the loader leaves local public/ images untouched; product images, being absolute CDN URLs, are unaffected. NEXT_PUBLIC_ASSET_CLEAN_URL is set to true only when your storefront is served from a custom domain configured for clean URLs — then the built URLs omit the internal storage prefix ({host}/_next/static/media/... instead of {host}/s/{shopId}/_next/...); the stored object is unchanged. <Image quality> is a no-op — quality is applied server-side (the format is still auto-negotiated AVIF/WebP). Pass explicit overrides only for non-standard setups: createImageLoader({ shopId, version, cdnBase, cleanUrl }).

For non-<Image> usage (CSS backgrounds, raw <img>), build CDN URLs yourself with the pure helper buildImageLoaderUrl from @doswiftly/storefront-sdk.

Middleware pipeline

Default order (wired automatically by StorefrontProvider):

auth → cart-secret → currency → language → bot-protection → [custom] → retry → timeout → errors (ALWAYS LAST)

When session refresh is active, a reactive-401 middleware wraps the whole pipeline: a 401 on a read query triggers one deduped refreshSession() and a replay; a 401 on a mutation fires the session-expired signal instead. The public catalog client and the customer-account client share a single renewal, so a 401 arriving on both surfaces at once still makes only one same-origin /refresh call (one cookie rotation, never two racing ones).

import {
  authMiddleware,           // Authorization: Bearer <token> (lazy getter)
  cartSecretMiddleware,     // x-cart-secret header (cart operations only)
  currencyMiddleware,       // X-Preferred-Currency header
  languageMiddleware,       // X-Language header (skipped when null — intentional)
  botProtectionMiddleware,  // challenge token for protected mutations only
  retryMiddleware,          // exponential backoff + jitter, honours Retry-After — queries only, never mutations
  concurrencyMiddleware,    // caps requests in flight (process-wide budget) — queues, never drops
  timeoutMiddleware,        // AbortController, edge-safe (default 5s)
  errorMiddleware,          // normalizes all errors → StorefrontError (ALWAYS LAST)
  sessionRetryMiddleware,   // reactive-401 refresh + replay (queries only)
} from '@doswiftly/storefront-sdk';

// Custom middleware
const logMiddleware: Middleware = async (request, next) => {
  console.log('Request:', request.operationName);
  const response = await next(request);
  return response;
};

Middleware that reads mutable state takes a lazy getter (authMiddleware(() => store.getState().accessToken)) so rotated values are picked up without rebuilding the client.

A server-only forwardedIpMiddleware is also available for server-rendered (BFF) storefronts — it forwards the real buyer IP to the backend so per-IP rate limits do not collapse onto the storefront server's address. See Server-side.

Core API

createStorefrontClient

const client = createStorefrontClient({
  apiUrl: string,
  shopSlug: string,
  middleware?: Middleware[],
  defaultHeaders?: Record<string, string>,
  fetch?: typeof globalThis.fetch,        // custom fetch (polyfill, test mocks)
  debug?: boolean | 'verbose' | DebugOptions,
});

client.query<T, V>(document, variables?, cache?): Promise<T>
client.mutate<T, V>(document, variables?): Promise<T>
client.use(middleware): void // imperative middleware add

Features: lazy pipeline compilation, same-tick request deduplication (queries only), TypedDocumentString support from graphql-codegen.

createCustomerClient(config) is the same factory with graphqlPath defaulted to the customer-account surface (/storefront/customer/graphql) — see Customer account surface.

Debug logging

createStorefrontClient({ apiUrl, shopSlug, debug: 'verbose' });
  • debug: true — minimal: operation name + variables on request, status + userErrors on response.
  • debug: 'verbose' — everything: full query, variables, headers, response body, timing, userErrors.
  • debug: { request?, response?, headers?, timing?, userErrors?, log? } — granular per-dimension opt-in, plus a custom sink (log: (event: DebugEvent) => void) for routing into your logger.
  • Env fallback: DOSWIFTLY_SDK_DEBUG=verbose|true|minimal when the option is omitted — disabled in NODE_ENV=production (PII safety).
  • Authorization: Bearer … and auth-cookie values are unconditionally redacted to ***<last4> whenever headers are logged.

createRemoteDebugTransport builds a shared remote sink — pass it as debug: { remote: transport } on the client and as cookieDebug on the providers so GraphQL operations and cookie writes land on one timeline with a single session id.

StorefrontError

import { StorefrontError, ErrorCodes } from '@doswiftly/storefront-sdk';

try {
  await client.query(ProductQuery, { handle: 'missing' });
} catch (err) {
  if (err instanceof StorefrontError) {
    err.code;           // 'GRAPHQL_ERROR' | 'NETWORK_ERROR' | 'TIMEOUT' | 'USER_ERROR' | 'SESSION_EXPIRED' | ...
    err.status;         // HTTP status (0 for network errors)
    err.graphqlErrors;  // GraphQL-level errors
    err.userErrors;     // field-level validation errors (backend-translated messages)
    err.hasUserErrors;  // boolean
    err.isNetworkError; // boolean
    err.isTimeout;      // boolean
  }
}

assertNoUserErrors(payload) — the helper the clients use internally — is also exported for custom operations: it throws a StorefrontError carrying the first backend-translated userErrors[].message.

Non-JSON responses (NON_JSON_RESPONSE)

A response whose body cannot be parsed as JSON — most often an HTML page substituted by a proxy, a WAF challenge, or an edge block (e.g. Cloudflare) in front of the GraphQL endpoint — throws a StorefrontError with code: 'NON_JSON_RESPONSE' and the real HTTP status from the response (never 0 — a response was received, this is not a network failure):

import { StorefrontError, ErrorCodes, type NonJsonResponseDiagnostics } from '@doswiftly/storefront-sdk';

try {
  await client.query(ProductQuery, { handle: 'x' });
} catch (err) {
  if (err instanceof StorefrontError && err.code === ErrorCodes.NON_JSON_RESPONSE) {
    err.status; // real HTTP status, e.g. 403
    const diagnostics = err.cause as NonJsonResponseDiagnostics;
    diagnostics.contentType; // response Content-Type header, or null
    diagnostics.cfRay;       // Cloudflare cf-ray header, or null — handy for support correlation
    diagnostics.bodySnippet; // first ~200 characters of the body (trimmed) — enough to spot a challenge/error page
  }
}

err.isNonJsonResponse is a convenience getter for the same check, alongside the existing isNetworkError / isTimeout / isAborted.

Retry behaviour: retryMiddleware treats a status < 500 non-JSON response as a deterministic block (a WAF rule, an interactive challenge, a block page) — retrying would hit the same page again, so the request is not retried. A status >= 500 non-JSON body (e.g. an nginx or load-balancer HTML error page) is treated as transient and retried like any other 5xx. The one exception is HTTP 429: a rate limit is transient whatever the body looks like, so it is retried (see below).

Rate limits (HTTP 429)

429 is the only 4xx retryMiddleware retries — the identical request succeeds once the limit window rolls. Queries only; a mutation is never retried, because it may already have applied.

When the server sends Retry-After, that window is honoured exactly (jitter can push the wait up, never below it). Without one, the middleware falls back to its own exponential backoff. A window longer than maxRetryAfterMs (default 30_000) is refused outright rather than waited out — a page render or a build needs an answer more than it needs to sit idle:

client.use(retryMiddleware({ maxRetries: 2, maxRetryAfterMs: 30_000 }));

The parsed window is on the error, so your own code can react to it:

catch (err) {
  if (err instanceof StorefrontError && err.isRateLimited) {
    err.retryAfterMs; // ms the server asked you to wait, or undefined if it did not say
  }
}

If you fetch outside the SDK, parseRetryAfterMs(header) is exported for the same purpose — it accepts both header forms (a delay in seconds and an HTTP-date) and returns undefined when the value is absent, unparsable, or already elapsed.

Where a build reads its data from

If your shop is served from its own api.{apex}, that host sits behind your Cloudflare zone's bot protection. A build is precisely what that protection challenges — one address issuing hundreds of requests in seconds — and it cannot be waived for it, so builds could be challenged or rate-limited on the very host your storefront uses at runtime.

getStorefrontClient therefore reads from the platform host while a build is running, and from your configured apiUrl everywhere else. The browser bundle is untouched, so shoppers keep using your own API host:

const client = getStorefrontClient({
  apiUrl: process.env.NEXT_PUBLIC_API_URL!,  // shoppers, at runtime
  shopSlug: process.env.NEXT_PUBLIC_SHOP_SLUG!,
  // buildApiUrl: 'https://api.doswiftly.pl',  // optional — pin it yourself
  // buildApiUrl: null,                        // optional — never swap hosts
});

The deploy pipeline supplies the build host as DOSWIFTLY_BUILD_API_URL.

If you fetch data with your own code rather than the SDK client, you do not have to do anything: the deploy pipeline redirects the build's requests to the platform host from inside the build process, so your own helpers are covered too.

Bounding requests in flight

Unbounded fan-out from one process is what rate limits punish. A production build renders every page in one process and fires its data requests as fast as it schedules them — hundreds within seconds, all from one address; to a per-address rate limit that is indistinguishable from a scraper, and the tail of it comes back 429, leaving pages rendered with empty data. A sitemap route walking the whole catalog or a bulk migration script has the same shape.

getStorefrontClient takes two bounds. Requests are queued, never dropped — they change when a request runs, never whether:

const client = getStorefrontClient({
  apiUrl,
  shopSlug,
  maxConcurrency: 6,        // at most 6 requests in flight
  minRequestIntervalMs: 200, // starts no closer than 200 ms apart (≈ 300 req/min)
});

Both matter, and for different reasons. A ceiling on requests in flight stops a burst from opening hundreds of sockets at once, but it does not bound requests per minute — which is what a rate limit measures. Six slots against 100 ms responses still sustains ~60 requests per second. Spacing is what bounds that.

The budget belongs to the process, not to the client object, so building a fresh client per request (which request-scoped headers force you to do) still counts against one budget.

When you pass nothing, a deploy build supplies both bounds itself (DOSWIFTLY_BUILD_CONCURRENCY, default 6; DOSWIFTLY_BUILD_MIN_INTERVAL_MS, default 200) and live server rendering stays unpaced, so no visitor request ever queues behind another. An explicit value always wins over those defaults; 0 disables that bound.

For a client built with createStorefrontClient, wire concurrencyMiddleware({ max, minIntervalMs }) yourself. Two calls with the same settings return the same gate — that is what makes the budget process-wide. Distinct settings get distinct budgets, so pick one configuration per process.

Schema enums — runtime constants

Every schema enum is exported as a runtime const + type alias pair, so both import type { DeliveryType } and Object.values(DeliveryType) work:

import { DeliveryType, PaymentMethodType, CountryCode } from '@doswiftly/storefront-sdk';

const schema = z.enum(Object.values(PaymentMethodType)); // runtime validation
type T = DeliveryType;                                   // 'HOME' | 'PICKUP_POINT' | 'LOCKER'

Available: DeliveryType, PaymentMethodType, PaymentInitiationFlow, CurrencyCode, CountryCode, LanguageCode, ProductTypeEnum, WeightUnit, CartWarningCode, AttributeType, AttributeFillingMode, AttributeBillingMode, AttributeOptionSurchargeType, StorefrontOrderStatus, OrderPaymentStatus, OrderFulfillmentStatus, DiscountErrorCode, DiscountApplicationType, PaymentProvider, PaymentInstrumentType, PaymentInstrumentDisplayHint, PaymentMethodUnavailableReason.

Format utilities

import {
  formatPrice, formatPriceRange, formatAmount,
  formatDate, formatDateTime, formatNumber, formatPercentage,
  getCurrencySymbol,
} from '@doswiftly/storefront-sdk';

formatPrice({ amount: '99.99', currencyCode: 'USD' }); // "$99.99"
formatAmount('115.20', 'EUR');                          // "115,20 €"
formatPercentage(0.15);                                 // "15%"

Locale-bound versions that follow the active storefront language are available as React hooks — see Format hooks.

HTML sanitizer + connection normalizer

import { sanitizeHtml, normalizeConnection } from '@doswiftly/storefront-sdk';

const safe = sanitizeHtml(userHtml); // strips <script>, event handlers, javascript: URLs

const { items, pageInfo, totalCount } = normalizeConnection(data.products); // Relay → flat array

Cookie contracts (platform constants)

All first-party cookie names/defaults the platform relies on are exported — never hardcode the strings:

| Constant | Cookie | Notes | |---|---|---| | AUTH_COOKIE_NAME / AUTH_COOKIE_DEFAULTS | customerAccessToken | httpOnly access token | | REFRESH_COOKIE_NAME / REFRESH_COOKIE_DEFAULTS | customerRefreshToken | httpOnly, path-scoped to the auth route | | SESSION_EXPIRY_COOKIE_NAME / SESSION_EXPIRY_COOKIE_DEFAULTS | session-expiry | readable expiry hint for the scheduler | | CART_COOKIE_NAME / CART_COOKIE_MAX_AGE | cart-id | composite "<cartId>.<secret>", 30 days | | CURRENCY_COOKIE_NAME / CURRENCY_COOKIE_MAX_AGE / CURRENCY_HEADER_NAME | preferred-currency | | | LANGUAGE_COOKIE_NAME / LANGUAGE_COOKIE_MAX_AGE / LANGUAGE_HEADER_NAME | preferred-language | |

import { parseCartCookieValue, formatCartCookieValue } from '@doswiftly/storefront-sdk';

parseCartCookieValue('abc.s3cret'); // { cartId: 'abc', cartSecret: 's3cret' }
parseCartCookieValue('abc');        // { cartId: 'abc'