@doswiftly/storefront-sdk
v4.5.0
Published
Storefront runtime SDK for DoSwiftly Commerce — layered transport, middleware pipeline, React providers, Zustand stores, cache strategies. 0 runtime dependencies in core.
Maintainers
Readme
@doswiftly/storefront-sdk
Layered runtime SDK for DoSwiftly Commerce storefronts. Hydrogen-aligned architecture with 0 runtime dependencies in core.
Architecture
@doswiftly/storefront-sdk
├── core (.) — Framework-agnostic: transport, middleware, CartClient, AuthClient,
│ cache, format utilities, image types, sanitizeHtml,
│ normalizeConnection, auth cookie config/handlers/token client, route matching
├── react (./react) — React adapter: providers, Zustand stores (Context-based), hooks,
│ useHydrated, useDebouncedValue, createStoreContext
├── react/server — Server-side client factory
└── 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-sdkQuick Start
Core (framework-agnostic)
import {
createStorefrontClient,
authMiddleware,
currencyMiddleware,
retryMiddleware,
timeoutMiddleware,
errorMiddleware,
CartClient,
AuthClient,
} from '@doswiftly/storefront-sdk';
const client = createStorefrontClient({
apiUrl: 'https://api.doswiftly.pl',
shopSlug: 'my-shop',
middleware: [
authMiddleware(() => getToken()),
currencyMiddleware(() => getCurrency()),
retryMiddleware({ maxRetries: 2 }),
timeoutMiddleware({ timeout: 5000 }),
errorMiddleware(), // ALWAYS LAST
],
});
// Query (deduplicated, cached)
const data = await client.query(ProductQuery, { handle: 'foo' }, cacheLong());
// Mutation (never cached, never retried)
const result = await client.mutate(CartCreateMutation, { input: {} });React (Next.js)
// app/layout.tsx
import { StorefrontProvider } from '@doswiftly/storefront-sdk/react';
export default function Layout({ children }) {
return (
<StorefrontProvider
config={{ apiUrl: process.env.NEXT_PUBLIC_API_URL!, shopSlug: process.env.NEXT_PUBLIC_SHOP_SLUG! }}
shopData={shopData}
>
{children}
</StorefrontProvider>
);
}// Client Component
'use client';
import { useAuth, useCartManager, useAuthStore, useCurrencyStore, useAuthHydrated } from '@doswiftly/storefront-sdk/react';
const { login, logout } = useAuth({ onSetToken, onClearToken });
const { addItem, removeItem, lines, isLoading } = useCartManager();
const { isAuthenticated, customer } = useAuthStore();
const authHydrated = useAuthHydrated(); // true after persist rehydration
const { currency, setCurrency } = useCurrencyStore();Export Paths
| Path | Description | Dependencies |
|------|-------------|-------------|
| @doswiftly/storefront-sdk | Core: transport, middleware, clients, errors, format, image types, sanitize, auth handlers, route matching | 0 |
| @doswiftly/storefront-sdk/react | Providers, hooks, store hooks, useHydrated, useDebouncedValue, createStoreContext | react, zustand |
| @doswiftly/storefront-sdk/react/server | Server-side client factory | react |
| @doswiftly/storefront-sdk/cache | Cache strategy functions | 0 |
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, // log requests in dev
});
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, TypedDocumentString support.
Middleware Pipeline
Order matters: auth → currency → [custom] → retry → timeout → errors (LAST)
import {
authMiddleware, // Authorization: Bearer {token}
currencyMiddleware, // X-Preferred-Currency header
retryMiddleware, // Exponential backoff (queries only, not mutations)
timeoutMiddleware, // AbortController, edge-safe (default 5s)
errorMiddleware, // Normalizes all errors → StorefrontError (ALWAYS LAST)
} from '@doswiftly/storefront-sdk';
// Custom middleware
const logMiddleware: Middleware = async (req, next) => {
console.log('Request:', req.operationName);
const response = await next(req);
console.log('Response:', response.status);
return response;
};CartClient
const cartClient = new CartClient(client);
const cart = await cartClient.create();
const updated = await cartClient.addItems(cartId, [{ merchandiseId: 'v-123', quantity: 1 }]);
await cartClient.updateItems(cartId, [{ id: 'line-1', quantity: 3 }]);
await cartClient.removeItems(cartId, ['line-1']);
await cartClient.updateDiscountCodes(cartId, ['SAVE10']);
await cartClient.updateNote(cartId, 'Gift message');
await cartClient.updateBuyerIdentity(cartId, { email: '[email protected]' });
const existing = await cartClient.get(cartId);Auto-throws StorefrontError with code USER_ERROR on validation failures.
AuthClient
const authClient = new AuthClient(client);
const { accessToken, expiresAt } = await authClient.login('[email protected]', 'pass');
await authClient.logout(accessToken);
const renewed = await authClient.renewToken(accessToken);
const { accessToken, customer } = await authClient.register({ email, password, firstName });
const customer = await authClient.getCustomer(accessToken);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' | ...
err.status; // HTTP status (0 for network errors)
err.graphqlErrors; // GraphQL-level errors
err.userErrors; // Field-level validation errors
err.hasUserErrors; // boolean
err.isNetworkError; // boolean
err.isTimeout; // boolean
}
}Format Utilities
import {
formatPrice,
formatPriceRange,
formatAmount,
formatDate,
formatDateTime,
formatNumber,
formatPercentage,
getCurrencySymbol,
CURRENCY_SYMBOLS,
CURRENCY_LOCALES,
} from '@doswiftly/storefront-sdk';
formatPrice({ amount: '99.99', currencyCode: 'USD' }); // "$99.99"
formatPriceRange(minPrice, maxPrice); // "$10.00 - $50.00"
formatAmount('115.20', 'EUR'); // "115,20 €"
formatDate(new Date()); // "Dec 9, 2025"
formatPercentage(0.15); // "15%"Image Types
GraphQL API returns ready-to-use CDN URLs via url(transform: { maxWidth: 800 }). No client-side loader needed.
ImageData type matches GraphQL Image fragment: { url, altText?, width?, height?, id? }.
HTML Sanitizer
import { sanitizeHtml } from '@doswiftly/storefront-sdk';
// Defense-in-depth: strips <script>, event handlers, javascript: URLs
const safe = sanitizeHtml(userHtml);Connection Normalizer
import { normalizeConnection } from '@doswiftly/storefront-sdk';
// Relay connection → flat array
const { items, pageInfo, totalCount } = normalizeConnection(data.products);Auth Cookie Config (Platform Contract)
import { AUTH_COOKIE_NAME, AUTH_COOKIE_DEFAULTS } from '@doswiftly/storefront-sdk';
// AUTH_COOKIE_NAME = 'customerAccessToken'
// AUTH_COOKIE_DEFAULTS = { name, path, sameSite, httpOnly, secure, maxAge }Auth Cookie Handlers (API Route Factories)
import { createSetTokenHandler, createClearTokenHandler } from '@doswiftly/storefront-sdk';
// Next.js API route (2 lines):
// app/api/auth/set-token/route.ts
export const POST = createSetTokenHandler();
// app/api/auth/clear-token/route.ts
export const POST = createClearTokenHandler();Uses pure Web API (Request/Response) — 0 deps, framework-agnostic. Security: origin validation, Content-Type check, CSRF via SameSite=Lax, httpOnly cookie.
Auth Token Client (Client-side)
import { createAuthTokenClient } from '@doswiftly/storefront-sdk';
const { setToken, clearToken } = createAuthTokenClient();
await setToken(accessToken); // POST /api/auth/set-token
await clearToken(); // POST /api/auth/clear-tokenRoute Matching
import { matchesRoute } from '@doswiftly/storefront-sdk';
// Supports exact and prefix matching
matchesRoute('/account/orders', ['/account']); // true
matchesRoute('/products', ['/account']); // falseCache Strategies
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 swrReact Adapter
Providers
// Convenience (recommended)
<StorefrontProvider config={{ apiUrl, shopSlug }} shopData={shop}>
{children}
</StorefrontProvider>StorefrontProvider creates Zustand store instances via useRef and provides them through React Context. This eliminates module-level singleton issues with Turbopack/bundler module duplication.
useAuth
const {
login, // (email, password) => Promise<LoginResult>
logout, // () => Promise<LogoutResult>
renewToken, // () => Promise<TokenRenewResult>
isLoggingIn, isLoggingOut, isRenewingToken, isLoading,
error,
} = useAuth({
onSetToken: async (token) => { /* set httpOnly cookie via server route */ },
onClearToken: async () => { /* clear httpOnly cookie */ },
});Cart Store (DI-based) — recommended for templates
import { createCartStore, CartProvider, useCartStore, type CartActions } from '@doswiftly/storefront-sdk/react';
// Template provides CartActions implementation (transport layer)
const store = createCartStore({
getActions: () => cartActions, // getter — called per operation (fresh refs)
onMutationSuccess: (action, cart) => { /* toast, cache invalidation */ },
onMutationError: (action, error) => { /* error toast */ },
});
// Provider (separate from StorefrontProvider — template composes in StoresProvider)
<CartProvider store={store}>{children}</CartProvider>
// Hooks (Context-based, selector overloads)
const { cartId, isOpen, isLoading, addToCart, clearCart, openCart } = useCartStore();
const cartId = useCartStore(s => s.cartId); // with selector
// Selectors
import { selectCartId, selectIsCartOpen, selectCartIsLoading } from '@doswiftly/storefront-sdk/react';SDK orchestrates: auto-init (fetch or create), expired cart recovery, loading/error state, mutation callbacks. Template provides: CartActions DI implementation (GraphQL hooks, React Query, fetch — any transport).
useCartManager (alternative — cookie-based, simple)
const {
getCart, // () => Promise<Cart | null>
addItem, // (lines: CartLineInput[]) => Promise<Cart>
updateItem, // (lines: CartLineUpdateInput[]) => Promise<Cart>
removeItem, // (lineIds: string[]) => Promise<Cart>
updateDiscountCodes, updateNote, clearCart, getCartId,
isLoading, error,
} = useCartManager();Cart ID is persisted in a cookie (SSR/edge visible). Plain async + useState, no React Query dependency.
useHydrated
import { useHydrated } from '@doswiftly/storefront-sdk/react';
const isHydrated = useHydrated();
// false during SSR and first client render, true after hydration
// Use to guard browser-only state (localStorage, cookies, window)useDebouncedValue
import { useDebouncedValue } from '@doswiftly/storefront-sdk/react';
const debouncedQuery = useDebouncedValue(query, 300);createStoreContext
import { createStoreContext } from '@doswiftly/storefront-sdk/react';
import { createStore } from 'zustand/vanilla';
// Define store factory + Context-based hooks (eliminates module-level singletons)
const { Provider: CartProvider, useStore: useCartStore, useApi: useCartStoreApi } =
createStoreContext<CartState>('CartStore');
// In layout:
const cartStore = useRef(createCartStore()).current;
<CartProvider store={cartStore}>{children}</CartProvider>
// In components:
const isOpen = useCartStore((s) => s.isOpen);
const api = useCartStoreApi(); // for .getState() in callbacksZustand Stores (Context-based)
Stores use createStore() from zustand/vanilla + React Context pattern. All store hooks require StorefrontProvider wrapper.
import { useAuthStore, useAuthHydrated, useCurrencyStore } from '@doswiftly/storefront-sdk/react';
// Auth — state
const { isAuthenticated, customer, accessToken } = useAuthStore();
// Auth — with selector
const isAuthenticated = useAuthStore(s => s.isAuthenticated);
// Auth — persist hydration (replaces old isHydrated store field)
const authHydrated = useAuthHydrated(); // true after localStorage rehydration
// Currency
const { currency, baseCurrency, supportedCurrencies, setCurrency, isLoaded } = useCurrencyStore();
// For .getState() in callbacks (e.g. logout, renewToken)
import { useAuthStoreApi, useCurrencyStoreApi } from '@doswiftly/storefront-sdk/react';
const authStore = useAuthStoreApi();
const token = authStore.getState().accessToken;Server-side
import { getStorefrontClient } from '@doswiftly/storefront-sdk/react/server';
// Server Component or Route Handler
const client = getStorefrontClient({
apiUrl: process.env.API_URL!,
shopSlug: process.env.SHOP_SLUG!,
});Template Integration
Storefronts (Next.js templates) import SDK for infrastructure and own their data-fetching layer:
SDK provides: Template owns:
├── Transport + Middleware ├── codegen.ts + generated/graphql.ts
├── CartClient + AuthClient ├── lib/graphql/hooks.ts (React Query)
├── Cart Store (DI-based) ├── lib/graphql/server.ts (React cache)
├── Providers + Zustand stores ├── lib/graphql/fragments/
├── Format utilities ├── hooks/use-cart-di.ts (CartActions DI impl)
├── sanitizeHtml ├── hooks/use-cart-actions.ts (UX wrapper)
├── ImageData type ├── components/product/product-image.tsx
├── normalizeConnection ├── stores/ (checkout, wishlist via createStoreContext)
├── Auth handlers + token client ├── lib/auth/routes.ts (route config)
├── useHydrated + useDebouncedValue
├── createStoreContext └── components/providers/
├── AUTH_COOKIE_NAME + matchesRoute
└── Cache strategiesData-fetching hooks are generated locally via @graphql-codegen/client-preset (TypedDocumentString).
License
MIT
