@nxara/olio-client
A typed GraphQL client for olio apps. You write operations in .graphql files, codegen turns them into typed documents (the way graphql-codegen does), and the client gives you one typed call per operation.
const { data } = await client.mutation.login({ email, password }, { headers });Install
Section titled “Install”bun add @nxara/olio-client graphql@16graphql 16 is a peer dependency. For subscriptions, add graphql-ws, graphql-sse, or both: the client uses your own clients of them.
Operations
Section titled “Operations”Operations are plain GraphQL documents, one or more per file:
mutation Login($email: String!, $password: String!) { login(email: $email, password: $password) { __typename ... on Session { token } ... on UserError { message } }}Codegen
Section titled “Codegen”codegenClient from @nxara/olio-codegen reads the schema (a file, or the running server’s URL by introspection) and the operations. It checks the operations against the schema (errors point at file:line:column) and writes the types:
import { codegenClient } from "@nxara/olio-codegen";
await codegenClient({ schema: "../server/src/schema.gql", // or "http://localhost:4000/graphql" documents: "src/operations/*.graphql", output: "src/generated.ts", headers: { authorization: "Bearer …" } // only for a URL that needs it});For each operation it writes three types, named the way graphql-codegen names them: LoginMutation (the result), LoginMutationVariables and LoginDocument. It also writes operations, every document by root and name, which createClient takes.
import { createClient } from "@nxara/olio-client";
import { operations } from "./generated.js";
const client = createClient(operations, { url: "https://erp.example/graphql", // Nothing before sign-in: an `undefined` value is left out, not sent as text. headers: () => ({ authorization: token && `Bearer ${token}` })});
const { data, errors } = await client.mutation.login({ email, password });if (data?.login.__typename === "Session") data.login.token;- Calls are
client.<query | mutation | subscription>.<operation>(variables?, options?), named after the operation with its first letter lowercased:mutation Loginisclient.mutation.login. - Variables are typed from the operation. They may be left out when none are required.
datais the operation’s result, typed from its selection: fragments narrow on__typename, and fields under@include/@skipare optional.errorsare the GraphQL errors as the server sent them. A response that isn’t GraphQL at all (a proxy’s 502 page) rejects.optionstakes per-callheadersand asignal. A subscription takes onlysignal, since its headers belong to its transport client.- Headers, on the client or per call, are an object, a
Headers, or entries. In an object,undefinedvalues are left out (token ? { authorization } : {}works too). Per-call headers win over the client’s.
Custom fetch
Section titled “Custom fetch”fetch takes anything fetch-shaped, such as a ky instance:
import ky from "ky";
createClient(operations, { url, fetch: ky.create({ throwHttpErrors: false }) });GraphQL errors can come with 4xx or 5xx statuses, so ky needs throwHttpErrors: false. A library that isn’t fetch-shaped, such as axios, needs a few lines that return a Response:
import type { Fetch } from "@nxara/olio-client";import axios from "axios";
const axiosFetch: Fetch = async (url, init) => { const res = await axios.request({ url, method: init.method, headers: Object.fromEntries(new Headers(init.headers)), data: init.body, signal: init.signal ?? undefined, responseType: "text", validateStatus: () => true // GraphQL errors come with 4xx/5xx too }); return new Response(res.data, { status: res.status, headers: res.headers as Record<string, string> });};fetch is for queries and mutations; subscriptions go through your own clients.
Uploading files
Section titled “Uploading files”The client sends JSON, and a File in JSON is {}. But fetch also receives the request before it’s turned into JSON, with the variables as you passed them, so it can send them as a multipart request instead:
import type { Fetch } from "@nxara/olio-client";
const multipart: Fetch = (url, init, { query, variables, operationName }) => { const files = new Map<File, string[]>(); // Files leave the variables (null in their place); `map` says where each goes back. const strip = (value: unknown, path: string): unknown => { if (value instanceof File) { files.set(value, [...(files.get(value) ?? []), path]); return null; } if (Array.isArray(value)) { return value.map((v, i) => strip(v, `${path}.${i}`)); } if (value && typeof value === "object") { return Object.fromEntries( Object.entries(value).map(([k, v]) => [k, strip(v, `${path}.${k}`)]) ); } return value; }; const stripped = strip(variables, "variables"); if (files.size === 0) return fetch(url, init);
const form = new FormData(); form.set( "operations", JSON.stringify({ query, variables: stripped, operationName }) ); form.set( "map", JSON.stringify(Object.fromEntries([...files.values()].entries())) ); for (const [i, file] of [...files.keys()].entries()) form.set(String(i), file);
const headers = new Headers(init.headers); headers.delete("content-type"); // fetch sets it, with the boundary headers.set("x-upload", "1"); // browsers can't add it cross-site, so the server can refuse CSRF return fetch(url, { ...init, headers, body: form });};
const client = createClient(operations, { url, fetch: multipart });await client.mutation.uploadAvatar({ file });Register the scalar as File on the client (interface ClientScalars { Upload: File }). On the server, @nxara/olio-http passes graphql-http’s parseRequestParams through, and it puts the files back:
fetchHandler(app, { context, parseRequestParams: async (req) => { const request = req.raw as Request; if (!request.headers.get("content-type")?.startsWith("multipart/form-data")) return; // JSON, as usual // graphql-http's response, [body, init], not a Fetch Response. if (!request.headers.get("x-upload")) return [null, { status: 400, statusText: "Bad Request" }]; const form = await request.formData(); const params = JSON.parse(String(form.get("operations"))); const map: Record<string, string[]> = JSON.parse(String(form.get("map"))); for (const [key, paths] of Object.entries(map)) { for (const path of paths) setPath(params, path, form.get(key)); // e.g. lodash's set } return params; }});The server’s SDL declares scalar Upload, typed File in its CustomScalars. Size limits are the server’s (or its proxy’s).
Many apps skip all this: a mutation returns a signed upload URL, and the browser sends the file straight to storage.
Scalars
Section titled “Scalars”ClientScalars works like the server’s CustomScalars: one type, or { input; output } when variables and results differ. Custom scalars are unknown until registered:
declare module "@nxara/olio-client" { interface ClientScalars { DateTime: { input: Date | string; output: string }; }}Put it in a .ts file, or in a .d.ts file with an import or export {}.
Enums arrive by name ("CONFIRMED"), not as the server’s internal values.
Subscriptions
Section titled “Subscriptions”Subscriptions run over WebSocket by default (graphql-ws), or as server-sent events on the same URL (graphql-sse) for an operation marked @sse. The choice lives in the operation. The directive is for the client only: codegen removes it from the query the server receives.
subscription StockChanged($sku: String) { # WebSocket stockChanged(sku: $sku) { sku onHand }}
subscription Notifications @sse { # server-sent events notifications { message }}for await (const { data } of client.subscription.stockChanged({ sku: "CHAIR-01" })) { console.log(data?.stockChanged.onHand);}They go through your own graphql-ws and graphql-sse clients. createClient requires each one (as ws or sse) once an operation uses that transport, and doesn’t accept one that no operation uses:
import { createClient as createSseClient } from "graphql-sse";import { createClient as createWsClient } from "graphql-ws";
// graphql-sse sends an `undefined` value as the text "undefined": leave the key out.const headers = (): Record<string, string> => token ? { authorization: `Bearer ${token}` } : {};const ws = createWsClient({ url: "wss://erp.example/graphql", // Browsers can't set headers on a WebSocket: send them here. connectionParams: () => ({ headers: headers() })});const client = createClient(operations, { url: "https://erp.example/graphql", headers, ws, sse: createSseClient({ url: "https://erp.example/graphql", headers })});The client calls their iterate and types the results. Everything else is theirs, and yours to configure: connecting, retries, keep-alive. Breaking out of the loop, or aborting signal, ends the subscription.
- New credentials: graphql-ws sends
connectionParamseach time the socket connects. After the token changes,ws.terminate()reconnects, and graphql-ws resubscribes live subscriptions on the new socket. It waits out its retry backoff first (retryWait, about 1.3 to 4 s by default), and events sent in between are lost. An open SSE stream keeps its headers until you subscribe again. - Refusals: by default, both libraries retry a refused connection for about 40 s before reporting it (graphql-ws on a 4403 close, graphql-sse on any response that isn’t 2xx). With
retryAttempts: 0or graphql-ws’sshouldRetry, they report it at once. Then a WebSocket closed with a 4xxx code is an error result (extensions: { code: "WEBSOCKET_CLOSED", closeCode }), and graphql-sse throws itsNetworkError(itsresponseholds the status). - Rejected operations (the server’s
onSubscribereturning errors) are an error result on both, as over HTTP.
Bundle size
Section titled “Bundle size”The client imports neither library: an app bundles only the ones it creates clients with. The client’s own code is about 1 KB gzipped.
testClient from @nxara/olio-client/test is the same client, answered in-process like Hono’s testClient. It goes through the app’s own servers:
- Queries and mutations go through its fetch handler.
- Subscriptions go through the graphql-ws options it passes to
useServer(run over an in-memory socket) and its graphql-sse handler.
So tests run production’s context, onConnect, onSubscribe, error masking and cleanup, and no server is started:
import { expect } from "bun:test";
import { testClient } from "@nxara/olio-client/test";
// The same objects the server uses.import { graphqlHandler } from "../src/http.js";import { sseHandler, wsOptions } from "../src/subscriptions.js";import { operations } from "./generated.js";
const client = testClient( operations, { http: graphqlHandler, ws: wsOptions, sse: sseHandler }, { headers: { authorization: `Bearer ${token}` } });expect((await client.query.me()).data).toEqual({ me: { name: "Mia" } });wsandsseare required exactly when an operation uses that transport. Without subscriptions, the fetch handler alone is enough:testClient(operations, graphqlHandler).- Over WebSocket, the headers arrive as
connectionParams: { headers }, andctx.extrais empty (there’s no socket or request in memory). - No retries. Its clients never retry, since a failure in memory is final: a refusal over SSE throws at once.
- Not bundled. It’s a separate import, so it never ends up in an app’s bundle.
createClient(operations, { url, headers?, fetch?, ws?, sse? }) |
headers: an object, or a function for values that change. fetch: defaults to the global one; called with (url, init, params), params being the request before JSON (see Uploading files). ws / sse: your graphql-ws / graphql-sse client, required once an operation uses that transport |
testClient(operations, handler | { http, ws?, sse? }, { headers?, url? }) |
From @nxara/olio-client/test: the same client, answered in-process |
ClientScalars |
Augment it with how custom scalars arrive and are sent |
TypedDocument, Result, Client, ClientOptions, Operations, SubscriptionClient |
The types behind the calls |