Typed SDK Client
The SDK does not ship pre-generated types. You generate types from your own server’s OpenAPI spec, so the types match your exact configuration — including plugin routes.
Your server → GET /api/doc → openapi-typescript → src/generated/api-types.tsInstall
Section titled “Install”bun add @porulle/sdkbun add @tanstack/react-query openapi-react-query # for React hooksbun add -d openapi-typescript # for codegenGenerate types
Section titled “Generate types”Start your server, then run the codegen CLI:
bunx @porulle/sdk generate
# Custom URLbunx @porulle/sdk generate --url http://localhost:4000/api/doc
# Custom output pathbunx @porulle/sdk generate --output src/generated/api-types.tsThis produces a paths type covering every route — core and installed plugins. Commit the generated file and regenerate when you add plugins or change routes.
Create a typed client
Section titled “Create a typed client”import { createClient } from "@porulle/sdk";import type { paths } from "./generated/api-types";
// API key auth (server-to-server, CI, scripts)const client = createClient<paths>({ baseUrl: "http://localhost:4000", auth: { type: "api_key", key: "dev-staff-key" },});
// Bearer token auth (mobile apps, SPAs)const client = createClient<paths>({ baseUrl: "http://localhost:4000", auth: { type: "bearer", token: sessionToken },});Make typed requests
Section titled “Make typed requests”Every path, method, body, query parameter, and response is compile-time validated:
// Catalogconst { data } = await client.GET("/api/catalog/entities", { params: { query: { type: "product", limit: "20" } },});
// Cartconst { data: cart } = await client.POST("/api/carts", { body: { currency: "USD" },});await client.POST("/api/carts/{id}/items", { params: { path: { id: cart.data.id } }, body: { entityId: "entity-uuid", quantity: 2 },});
// Checkoutconst { data: order } = await client.POST("/api/checkout", { body: { cartId: cart.data.id, paymentMethodId: "stripe", currency: "USD", shippingAddress: { line1: "123 Main St", city: "New York", country: "US", firstName: "Jane", lastName: "Doe", }, },});
// Plugin routes are fully typed tooconst { data: points } = await client.GET("/api/loyalty/points/{customerId}", { params: { path: { customerId: "..." } },});If you pass a wrong field name, TypeScript reports it at compile time:
await client.POST("/api/catalog/entities", { body: { typo: "product" } });// ^^^^ TS errorHandle errors
Section titled “Handle errors”Every method returns { data, error, response }. Only one of data or error is populated:
const { data, error } = await client.GET("/api/catalog/entities/{idOrSlug}", { params: { path: { idOrSlug: "nonexistent" } },});
if (error) { console.log(error.error.code); // "NOT_FOUND" console.log(error.error.message); // "Entity not found" return;}
// data is guaranteed non-null hereconsole.log(data.data.id);React hooks
Section titled “React hooks”import { createClient } from "@porulle/sdk";import { createCommerceHooks } from "@porulle/sdk/react";import { QueryClient, QueryClientProvider } from "@tanstack/react-query";import type { paths } from "./generated/api-types";
const client = createClient<paths>({ baseUrl: "", auth: { type: "cookie" } });const commerce = createCommerceHooks(client);const queryClient = new QueryClient();
function App() { return ( <QueryClientProvider client={queryClient}> <ProductList /> </QueryClientProvider> );}Use commerce.useQuery for reads and commerce.useMutation for writes:
function ProductList() { const { data, isLoading } = commerce.useQuery("get", "/api/catalog/entities", { params: { query: { type: "product", limit: "20" } }, });
if (isLoading) return <p>Loading...</p>;
return ( <ul> {data?.data.map((product) => ( <li key={product.id}>{product.slug}</li> ))} </ul> );}
function AddToCartButton({ cartId, entityId }: { cartId: string; entityId: string }) { const addItem = commerce.useMutation("post", "/api/carts/{id}/items");
return ( <button disabled={addItem.isPending} onClick={() => addItem.mutate({ params: { path: { id: cartId } }, body: { entityId, quantity: 1 }, })} > Add to Cart </button> );}Next.js in-process mount
Section titled “Next.js in-process mount”When the Hono app is mounted inside a Next.js catch-all route, use an empty baseUrl so SDK calls route through the same process:
const client = createClient<paths>({ baseUrl: "", auth: { type: "cookie" } });API calls from client components go to /api/... which Next.js routes to your Hono handler. No CORS, no proxy, no separate port.
Untyped convenience wrapper
Section titled “Untyped convenience wrapper”For one-off scripts where you don’t need compile-time validation, createSDK() provides domain namespaces without codegen:
import { createSDK } from "@porulle/sdk";
const sdk = createSDK({ baseUrl: "http://localhost:4000", auth: { type: "api_key", key: "..." } });await sdk.catalog.list({ type: "product" });await sdk.cart.create({ currency: "USD" });Bodies and responses are untyped. For production code, always use createClient<paths>() with generated types.
Regenerate types
Section titled “Regenerate types”After adding, removing, or modifying routes:
bun run dev # start serverbunx @porulle/sdk generate # regenerate typesgit add src/generated/api-types.ts # commitAdd a sdk:generate script to package.json to make this repeatable:
{ "scripts": { "sdk:generate": "bunx @porulle/sdk generate --url http://localhost:4000/api/doc --output src/generated/api-types.ts" }}Offline queue
Section titled “Offline queue”POS and field clients often lose connectivity mid-shift. OfflineQueue is an offline-first operation queue: you enqueue write operations (like a sale) while offline, and they replay FIFO on reconnect. Each op is stamped with an idempotency key that pairs with core’s order/checkout replay, so a queued sale lands exactly once — even if it was partially sent before the connection dropped.
Create a queue
Section titled “Create a queue”import { OfflineQueue, webStorage } from "@porulle/sdk";
const queue = new OfflineQueue({ baseUrl: "http://localhost:4000", headers: { "x-api-key": "dev-staff-key" }, storage: webStorage(localStorage),});storage defaults to memoryStorage() (volatile); pass webStorage(localStorage) to persist queued ops across restarts. Other options: maxAttempts (default 5), backoffMs (default 1000), autoFlush (default true — flushes on the window online event when addEventListener exists), and idempotencyField (default "idempotencyKey", the body field the op id is stamped into).
Enqueue while offline, flush on reconnect
Section titled “Enqueue while offline, flush on reconnect”enqueue persists the op — it does not send. Delivery happens on flush(), the online event, or the backoff timer:
// Persist a sale while offlineawait queue.enqueue("/api/checkout", { cartId, paymentMethodId, currency });
// Watch queue changes (pending count, failures)const unsubscribe = queue.onChange((s) => { console.log(`${s.pending} pending, ${s.failed} failed`);});
// Drain pending ops FIFO when back onlineawait queue.flush();The op id (your idempotencyKey or an auto-generated one) is written into the body under idempotencyField, so a replay never double-charges. enqueue accepts an optional { method, idempotencyKey } — method defaults to "POST".
Retry, backoff, and failures
Section titled “Retry, backoff, and failures”On a 2xx the op is removed from the queue. A network throw stops the drain and schedules a backoff retry — min(backoffMs * 2 ** (attempts-1), 60000). Retryable HTTP statuses (>=500, 408, 429) retry with exponential backoff up to maxAttempts, then mark the op failed; all other 4xx go straight to failed with the server error body captured in lastError. There are only two states — pending and failed — since successful ops are deleted, not marked done. Use retry(id) to re-queue a failed op (resets attempts and triggers a flush) or drop(id) to remove it. Call dispose() to clear timers and detach the online listener.
Queued ops only survive a restart when you pass webStorage — memoryStorage() is volatile and clears on reload.
Related
Section titled “Related”- Point of Sale — the in-store surface the queue backs
- Next.js guide — in-process mounting and SDK setup
- TanStack Start guide — same pattern for TanStack Start
- REST API Reference — all endpoints and response shapes