@doswiftly/storefront-sdk
v21.0.1
Published
Storefront runtime SDK for DoSwiftly Commerce — layered transport, middleware pipeline, React providers, Zustand stores, cache strategies. 0 runtime dependencies in core.
Downloads
3,368
Maintainers
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,
│ 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,
│ 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 functionsCore 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-sdkConfiguration
createStorefrontClient and <StorefrontProvider> take apiUrl and shopSlug as explicit config — the SDK does not read environment variables, sniff hostnames, or inspect request headers. The storefront supplies the values; the SDK uses them verbatim.
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 names — doswiftly 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.
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 | 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:
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 — picks up rotation
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/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}/refreshshortly beforeexpiresAt; 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-expiredsignal 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)
POST {authBasePath}/login is the only flow that sets the full cookie set
(access + refresh + expiry) on the storefront domain — required for automatic
session refresh. Post the credentials, then seed the client-side store with the
returned token:
'use client';
import { useAuthStore } from '@doswiftly/storefront-sdk/react';
export function LoginForm() {
const setAuth = useAuthStore((s) => s.setAuth);
async function onSubmit(email: string, password: string) {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
// Backend errors are passed through verbatim (already localized).
const body = await res.json().catch(() => null);
showError(body);
return;
}
const { accessToken, expiresAt, customer } = await res.json();
setAuth(customer ?? null, accessToken, expiresAt);
}
// ...
}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 the BFF login 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 messagesuseLogout 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.
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 rehydrationAuthClient (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 |
| getCustomer() | Promise<Customer \| null> | null when unauthenticated |
| getAddresses() | Promise<MailingAddress[] \| null> | Saved address book incl. B2B fields (taxId, vatNumber) and isDefault; null when unauthenticated |
| 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-tokenCart
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. Every request then carries the secret in
the x-cart-secret header via middleware:
- Browser:
StorefrontProviderwirescartSecretMiddlewareautomatically — the secret is read lazily from the cookie on every request, 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)+ theparseCartCookieValue/formatCartCookieValuehelpers.
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[].code ∈ CART_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
});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-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> | 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" /> {/* "99,90 zł" */}
<PriceDisplay price={7990} compareAtPrice={9990} currency="PLN" />
<CartCount count={3} label="items" />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.
import {
authMiddleware, // Authorization: Bearer <token> (lazy getter)
cartSecretMiddleware, // x-cart-secret header (lazy getter)
currencyMiddleware, // X-Preferred-Currency header
languageMiddleware, // X-Language header (skipped when null — intentional)
botProtectionMiddleware, // challenge token for protected mutations only
retryMiddleware, // exponential backoff + jitter — queries only, never mutations
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.
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 addFeatures: lazy pipeline compilation, same-tick request deduplication (queries
only), TypedDocumentString support from graphql-codegen.
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|minimalwhen the option is omitted — disabled inNODE_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.
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 arrayCookie 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', cartSecret: null } (legacy)
formatCartCookieValue({ cartId: 'abc', cartSecret: 's3cret' }); // 'abc.s3cret'Route matching
import { matchesRoute } from '@doswiftly/storefront-sdk';
matchesRoute('/account/orders', ['/account']); // true (exact + prefix matching)React adapter
<StorefrontProvider> props
| Prop | Type | Purpose |
|---|---|---|
| config | { apiUrl, shopSlug, … } | Client config (full StorefrontClientConfig, incl. debug) |
| shopData | ShopConfig | currencyCode, supportedCurrencies, localeToCurrencyMap?, defaultLanguage?, supportedLanguages?, botProtection? |
| middleware | Middleware[] | Extra middleware, inserted after the built-in header middleware |
| initialIsAuthenticated | boolean | Server-side auth hint — no "Sign in" flash. Defaults to !!initialAccessToken |
| initialAccessToken | string \| null | Server-side raw JWT seed (kept in memory, never persisted) |
| initialExpiresAt | string \| null | Session expiry seed (ISO 8601) — arms the refresh scheduler on cold start |
| initialLanguage | string | Server-side locale hint — no language flash |
| autoRefresh | boolean | Proactive session refresh — default ON in the browser |
| authBasePath | string | Where the BFF auth route is mounted (default /api/auth) |
| cookieDebug | (event: DebugEvent) => void | Debug sink for currency/language cookie writes |
The provider creates all store instances per mount (no module-level singletons — safe under bundler module duplication), wires the default middleware pipeline, mounts the bot-protection widget when the shop has it configured, and runs the session-refresh scheduler.
StorefrontClientProvider, CurrencyProvider, LanguageProvider are exported
separately for custom composition.
Stores (Context-based)
All store hooks require the StorefrontProvider wrapper and accept an optional
selector:
import {
useAuthStore, useAuthStoreApi, useAuthHydrated,
useCurrencyStore, useCurrencyStoreApi,
useLanguageStore, useLanguageStoreApi,
} from '@doswiftly/storefront-sdk/react';
// Auth
const { isAuthenticated, customer, accessToken, expiresAt, setAuth, clearAuth } = useAuthStore();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated); // with selector
const authHydrated = useAuthHydrated(); // true after localStorage rehydration
// Currency
const { currency, baseCurrency, supportedCurrencies, setCurrency, isLoaded } = useCurrencyStore();
// Language
const { language, setLanguage } = useLanguageStore();
// `*StoreApi` variants return the raw store for .getState() in callbacks
const token = useAuthStoreApi().getState().accessToken;Pre-built selectors: selectCurrency, selectBaseCurrency,
selectSupportedCurrencies, selectIsLoaded, selectLanguage,
selectDefaultLanguage, selectSupportedLanguages, selectLanguageIsLoaded.
Security note: the access token is never persisted to
localStorage/sessionStorage — it lives in memory (and in the httpOnly
cookie). Persisted auth state covers only customer + isAuthenticated.
useCurrency() is a convenience aggregator over the currency store.
Format hooks
Locale-aware formatters bound to the active storefront language:
import {
useFormatPrice, useFormatAmount, useFormatPriceRange,
useFormatDate, useFormatDateTime, useFormatNumber, useGetCurrencySymbol,
} from '@doswiftly/storefront-sdk/react';
const formatPrice = useFormatPrice();
formatPrice({ amount: '99.99', currencyCode: 'PLN' }); // honors the active localeGeneric hooks
import { useHydrated, useDebouncedValue, useStorefrontClient } from '@doswiftly/storefront-sdk/react';
const isHydrated = useHydrated(); // false during SSR + first client render
const debounced = useDebouncedValue(query, 300);
const client = useStorefrontClient(); // the StorefrontClient from contextcreateStoreContext
Build your own Context-based Zustand stores (the same pattern the SDK uses — no module-level singletons):
import { createStoreContext } from '@doswiftly/storefront-sdk/react';
const { Provider: WishlistProvider, useStore: useWishlistStore, useApi: useWishlistStoreApi } =
createStoreContext<WishlistState>('WishlistStore');Bot protection
When the shop has bot protection configured (shopData.botProtection), the
provider loads the challenge widget and botProtectionMiddleware attaches
tokens to protected mutations automatically. useBotProtection() exposes
execute() for manual token acquisition; createBotProtectionManager /
FallbackBotProtectionManager are exported from core for custom wiring.
Fail-open by default — a challenge outage never blocks checkout.
Server-side (/react/server)
import {
getStorefrontClient, // server client factory (10s timeout, retry, errors)
createStorefrontAuthRoute, // SDK-BFF auth route — see Authentication
getInitialAuth, // cold-start auth seed from first-party cookies (Next.js)
readCartIdCookie, // cart id (string | null)
readCartCredentials, // { cartId, cartSecret } | null — composite cookie
readCurrencyCookie, // preferred currency (string | null)
serverCartSecretMiddleware, // attach x-cart-secret on SSR/edge cart reads
trustedForwardedHostValidator,
originAllowlistValidator,
} from '@doswiftly/storefront-sdk/react/server';SSR cart read with the capability secret:
// Server Component / Route Handler
const credentials = await readCartCredentials();
const client = getStorefrontClient({
apiUrl: process.env.NEXT_PUBLIC_API_URL!,
shopSlug: process.env.NEXT_PUBLIC_SHOP_SLUG!,
middleware: [serverCartSecretMiddleware(credentials)],
});
const cart = credentials ? await new CartClient(client).get(credentials.cartId) : null;The server factory ships without auth/currency middleware (there are no stores on the server) — add request-scoped headers via custom middleware. For authenticated SSR reads, forward the access token from the httpOnly cookie:
middleware: [
async (request, next) => {
const token = (await cookies()).get(AUTH_COOKIE_NAME)?.value;
if (token) request.headers['Authorization'] = `Bearer ${token}`;
return next(request);
},
],Caching
import { cacheLong, cacheShort, cacheNone, cachePrivate, cacheCustom } from '@doswiftly/storefront-sdk/cache';
cacheLong() // 1h + 23h stale-while-revalidate
cacheLong({ tags: ['product', slug] }) // with Next.js revalidation tags
cacheShort() // 1s + 9s swr
cacheNone() // no-store
cachePrivate() // private, 1s + 9s swr
cacheCustom({ maxAge: 300, swr: 600 }) // 5min + 10min swr
const data = await client.query(ProductQuery, { handle }, cacheLong());GraphQL schema for codegen
The GraphQL SDL ships in the linked @doswiftly/storefront-operations package
(installed alongside the SDK) — point your codegen / IDE plugin at it directly.
No live backend required:
// codegen.ts — uses the raw .graphql file from the operations package
const config = {
schema: 'node_modules/@doswiftly/storefront-operations/schema.graphql',
documents: ['./app/**/*.{ts,tsx,graphql}'],
generates: { './generated/graphql.ts': { /* … */ } },
};
export default config;For the VS Code GraphQL extension, point graphql-config at the same path.
client.query/client.mutate accept the generated TypedDocumentString
documents directly.
Deprecated
| Symbol | Replacement |
|---|---|
| createCartStore, CartProvider, useCartStore, useCartStoreApi | useCartManager / <CartManagerProvider> — the legacy store predates capability carts and does not carry the cart secret |
| AuthClient.refreshToken(), useRefreshToken | Automatic refresh (autoRefresh) / AuthClient.refreshSession() — the GraphQL refresh requires a still-valid access token |
| ShopCurrencyData | ShopConfig |
Deprecated symbols remain exported for backward compatibility and will be removed in a future major release.
License
MIT
