@inkanjs/query
The typed client for TanStack Query: keys and data from the contract, problems as errors, live events and WebSockets as hooks.
The typed inkan client, for TanStack Query: data typed from the API's contracts, and every failed answer an error that carries the problem document. One package for React, Vue, Svelte and Solid, because it only builds the options all of them take.
npm install @inkanjs/queryimport { } from "@vxnsin/inkan/client";
import { } from "@inkanjs/query";
import type { } from "../server/app";
const = <typeof >(.);
const = ();
// React: useQuery, Vue: useQuery, Svelte: createQuery, Solid: createQuery
const = useQuery(.get("/teas/:id", { : { } }));
.data?.name; // typed from the contract
const = useMutation(.post("/teas"));
.mutate({ : { : "Gyokuro" } }); // the input, checked against the contract
// every query of a path, for after a change
queryClient.invalidateQueries({ : .key("/teas/:id") });q.get keys a query by its path and input (["/teas/:id", { params: { id: 1 } }]), so
two components asking for the same tea share one request, and q.key(path) reaches all of
them. A path the API does not have, or a missing param, is a type error.
Problems are errors
A failed answer (a 400 for bad input, a 404, a 500) is thrown as a ProblemError, so the
query shows it as its error instead of caching it as data:
import { , } from "@inkanjs/query";
if ((tea.error, "tea-not-found")) {
tea.error.status; // 404
tea.error.problem; // the whole RFC 9457 document, `errors` and all
}
// tries again for a dropped connection, a 5xx or a 429, never for a 400 or a 404
new QueryClient({ : { : { : (3) } } });By hand
query(key, call) and mutation(call) take any call of the typed client, for a key of
your own or a call that does more than one request:
import { , } from "@inkanjs/query";
useQuery((["tea", id], () => shop.get("/teas/:id", { : { } })));
useMutation(((: { : string }) => shop.post("/teas", { : })));unwrap(answer) does the same for one answer anywhere else: its data, or a ProblemError.
This package needs no TanStack package itself; bring the adapter for your framework.
Live data: events and WebSockets
New in 0.3.0
q.events, q.ws, @inkanjs/query/react with useEvents and useSocket, and follow and
connect for other frameworks. Needs inkan 0.7 or later, for client.events and client.ws.
A stream of server-sent events and a WebSocket are no queries: they do not answer once, and
there is nothing to refetch. q.events and q.ws make live sources of them, typed from the
route like everything else, and @inkanjs/query/react follows them in a component:
import { useEvents, useSocket } from "@inkanjs/query/react";
// a GET that answers with t.events({ ... })
const feed = useEvents(q.events("/teas/:id/events", { params: { id } }));
feed.status; // "connecting" | "open" | "closed" | "error" ("idle" while not enabled)
feed.last; // the last event: feed.last?.event === "brewed" narrows feed.last.data
feed.byName.brewed; // the latest data of each event, by name
feed.error; // a stream that did not open: a ProblemError, isProblem(feed.error, "tea-not-found")
// an app.ws route: send takes its `message`, last is the last of its `send`
const room = useSocket(q.ws("/rooms/:room", { params: { room } }), { onMessage: (m) => log(m) });
room.send({ type: "say", text: "hello" }); // sent while opening, it goes once the socket is open
room.status; room.last; room.socket; // the TypedSocket underneath: on, ready, rawBoth open when the component mounts, open again when the path or input changes, and close
when it unmounts. enabled: false keeps them closed. A stream that ends stays closed, and
one that fails is not opened again unless retry says so: true, a number of times, or a
function as for TanStack, so retry: retry(3) tries again after a dropped connection or a
503, not after a 404. retryDelay is 1 s, doubling, at most 30 s.
The hooks need react (18 or later); nothing else here does. For Vue, Svelte or Solid,
follow(source, { onChange, onEvent, retry, signal }) and
connect(source, { onChange, onMessage }) are what the hooks run: the same state, handed to
onChange after every change, for a ref or a store of your own.
Events are not written to the cache
The hooks keep their state themselves and write nothing to the QueryClient: which query an
event belongs to is the app's to say, and onEvent says it in a line. A job's status and
end events carry the job as GET /exports/:id answers with it, so they can keep that
query fresh:
const = useQueryClient();
const = q.get("/exports/:id", { : { } });
useEvents(q.events("/exports/:id/events", { : { } }), {
: () => .event !== "progress" && .setQueryData(.queryKey, .data),
});Recipe: following a job
An app.job("/exports", ...) on the server gives POST /exports to start one,
GET /exports/:id/events to follow it (status, then progress, then end) and
GET /exports/:id/result for what it made. Start it with a mutation, follow it while it
runs, and fetch the result once it is done:
import { useMutation, useQuery } from "@tanstack/react-query";
import { queries } from "@inkanjs/query";
import { useEvents } from "@inkanjs/query/react";
const q = queries(api);
function Export() {
const start = useMutation(q.post("/exports"));
const id = start.data?.id; // the job, as the 202 answered with it
const job = useEvents(q.events("/exports/:id/events", { params: { id: id! } }), { enabled: !!id });
const state = job.byName.end?.state ?? job.byName.status?.state;
const result = useQuery({
...q.get("/exports/:id/result", { params: { id: id! } }),
enabled: state === "done",
});
if (!id) return <button onClick={() => start.mutate({ body: { rows: 1000 } })}>Export</button>;
if (job.error) return <p>{job.error.message}</p>;
if (state === "failed") return <p>{job.byName.end?.error?.detail}</p>;
if (result.data) return <a href={result.data.url}>Download</a>;
const p = job.byName.progress; // typed from the job's `progress` schema
return <progress value={p?.done ?? 0} max={p?.total ?? 1} />;
}The stream ends with end, so job.status turns closed by itself; nothing reconnects.