Fetcher & Transports
createFetcher, custom transports (axios), and serializers (superjson).
@dudousxd/nestjs-client provides createFetcher — the typed client the generated
api.ts calls. It owns URL building, headers, error mapping (ApiHttpError), and the
payload transformer. The actual network call is a pluggable transport.
import { createFetcher } from '@dudousxd/nestjs-client';
const fetcher = createFetcher({
baseUrl: '/api',
headers: () => ({ authorization: `Bearer ${getToken()}` }),
});It has a method per HTTP verb — get, post, put, patch, delete, head and
options — each taking the path plus { params, query, body }. head resolves to
undefined once the status is 2xx (a HEAD response carries no body); use
fetchRaw(path, { method: 'HEAD' }) to read the response headers.
Bring your own HTTP client
By default the transport is native fetch. Pass transport to use anything else.
Use your existing axios instance
import axios from 'axios';
import { createFetcher, axiosTransport } from '@dudousxd/nestjs-client';
const http = axios.create({ baseURL: '/api', withCredentials: true });
const fetcher = createFetcher({ transport: axiosTransport(http) });Set the base URL on the axios instance (not createFetcher.baseUrl) so it isn't
prefixed twice.
A fully custom transport
A Transport takes a normalized request and returns a normalized response:
import type { Transport } from '@dudousxd/nestjs-client';
const transport: Transport = async (req) => {
const res = await myClient(req.url, { method: req.method, headers: req.headers, body: req.body });
return {
ok: res.ok,
status: res.status,
statusText: res.statusText,
contentType: res.headers.get('content-type'),
text: () => res.text(),
};
};
createFetcher({ transport });Array query params
When a query value is an array, arrayFormat controls how it is serialized:
const fetcher = createFetcher({ baseUrl: '/api', arrayFormat: 'repeat' });
// query: { ids: ['a', 'b'] }
// 'comma' (default) → ?ids=a,b
// 'repeat' → ?ids=a&ids=barrayFormat | { ids: ['a', 'b'] } | When to use |
|---|---|---|
'comma' (default) | ?ids=a,b | Back-compat: the historical behavior. The server must split the joined value itself. |
'repeat' | ?ids=a&ids=b | The form NestJS's default (Express qs) parser revives as a string[] — it matches the codegen's generated Array<string> query type without server-side normalization. |
The default is 'comma' so upgrading is non-breaking. Set 'repeat' at the fetcher level
for every request, or per request to override:
await api.get('/things', { query: { ids: ['a', 'b'] }, arrayFormat: 'repeat' });Whichever format you send, the single-value case is still a bare string on the wire
(?ids=a), and Express hands the server back a string, not a string[]. See
Receiving array query params for how to receive them
safely (and why ParseArrayPipe is the wrong tool for optional array params).
Global headers
setGlobalHeaders registers a function that supplies headers merged into every request
from every fetcher — before each fetcher's own headers() and the per-request headers.
Use it for cross-cutting values you don't want to thread through each createFetcher call,
such as a CSRF token, a tenant id, or a trace header set once at app boot:
import { setGlobalHeaders } from '@dudousxd/nestjs-client';
setGlobalHeaders(() => ({
'x-csrf-token': readCsrfCookie(),
'x-tenant-id': currentTenant(),
}));The function is called once per request, so returning values that change over time
(a rotating token, the active tenant) just works. Precedence is: global headers first, then
the fetcher's headers(), then request-level headers — so a fetcher can override a global
value if it needs to.
Global headers apply to SSE (sse / consumeSse) and the binary escape hatches
(fetchBlob / fetchRaw) too, not just the verb methods.
Server-sent events (SSE)
A NestJS @Sse() endpoint is discovered as a streaming route. On the client its handle
resolves to a typed async iterable — each yielded value is the JSON-parsed data: payload
of one SSE event, typed as the streamed element type the codegen carried through:
for await (const chunk of api.notifications.stream()) {
// chunk is typed from the controller's streamed element type
console.log(chunk);
}Under the hood the generated call uses fetcher.sse(path, opts), which builds the URL,
attaches accept: text/event-stream plus your global and fetcher headers,
and delegates to consumeSse. Pass an AbortSignal to stop the stream early:
const controller = new AbortController();
const stream = api.notifications.stream({ signal: controller.signal });
// later:
controller.abort();consumeSse directly
consumeSse is the standalone parser behind fetcher.sse — exported for when you want to
read an event stream without going through a fetcher (a URL your generated client doesn't
cover, a worker, a test). It's bring-your-own-fetch so it runs in any runtime:
import { consumeSse } from '@dudousxd/nestjs-client';
const events = consumeSse<{ id: string; body: string }>(
globalThis.fetch, // fetch implementation
'https://api.test/events', // absolute URL
{ accept: 'text/event-stream' },
undefined, // optional PayloadTransformer (e.g. superjson) — else JSON.parse
signal, // optional AbortSignal
);
for await (const event of events) {
console.log(event.id, event.body);
}It parses the SSE wire format (events separated by a blank line, data: lines
concatenated), JSON-parses each event's data via the transformer when supplied (otherwise
JSON.parse), and yields the typed values. A non-2xx response throws ApiHttpError; the
stream ends when the connection closes or the signal aborts.
superjson & transformer pipelines
A transformer is a { stringify, parse } pair. Pass superjson to round-trip rich
types (Date, Map, Set, BigInt) — the server must use the same transformer.
import superjson from 'superjson';
import { createFetcher } from '@dudousxd/nestjs-client';
const fetcher = createFetcher({ transformer: superjson });You can pass an array to compose a pipeline: a base value↔string serializer first, then string↔string wrappers (compression, encryption) applied in order and unwound on parse.
import superjson from 'superjson';
import { createFetcher } from '@dudousxd/nestjs-client';
import { compress } from './my-compress'; // { stringify, parse } over strings
const fetcher = createFetcher({ transformer: [superjson, compress] });Bring your own — a transformer is just an object that implements
{ stringify(value): string; parse(text): T }.
superjson runtime
The transformer above replaces serialization on both directions for every
consumer — the server must speak the exact same transformer, so adopting it is an atomic
cross-app flip. When you only need to revive rich types in responses (so Date,
Map, Set, BigInt round-trip) and want each client to opt in independently, use the
dedicated @dudousxd/nestjs-client/superjson subpath instead.
It is built on the fetcher's generic deserialize hook (applied to parsed JSON
responses) and a x-superjson header so plain-JSON consumers are never affected:
- Client sends
x-superjson: 1and deserializes the response withsuperjson. - Server
SuperjsonInterceptorsuperjson-serializes the response only when that header is present; every other request gets plain JSON.
This is the runtime complement to the
serialization: 'superjson' config:
turn off the compile-time Jsonify wrapping there, and revive the values at runtime here.
superjson, rxjs, and @nestjs/common are optional peer dependencies, pulled in
only by the /superjson subpath. Install superjson to use this runtime.
Opt the client in
superjsonFetcherOptions() returns the headers + deserialize pair to spread into
createFetcher:
import { createApi } from '../generated/api';
import { createFetcher } from '@dudousxd/nestjs-client';
import { superjsonFetcherOptions } from '@dudousxd/nestjs-client/superjson';
export const api = createApi(
createFetcher({ baseUrl: '/api', ...superjsonFetcherOptions() }),
);Already passing your own headers() (e.g. auth)? Use withSuperjson() — it composes your
headers with the x-superjson opt-in header so both are sent:
import { createFetcher } from '@dudousxd/nestjs-client';
import { withSuperjson } from '@dudousxd/nestjs-client/superjson';
const fetcher = createFetcher(
withSuperjson({ baseUrl: '/api', headers: () => ({ authorization: token() }) }),
);Add the server interceptor
Register SuperjsonInterceptor so responses are superjson-serialized only for
requests carrying the opt-in header. Plain-JSON consumers (and any app that hasn't flipped
yet) are untouched, so superjson can be adopted per-consumer without an atomic migration:
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { SuperjsonInterceptor } from '@dudousxd/nestjs-client/superjson';
@Module({
providers: [{ provide: APP_INTERCEPTOR, useClass: SuperjsonInterceptor }],
})
export class AppModule {}Drop the compile-time Jsonify wrapping
With responses revived at runtime, the raw controller return types are now correct. Set
serialization: 'superjson' so the codegen stops wrapping response in Jsonify<...>:
export default defineConfig({
// ...
serialization: 'superjson',
});Now api.users.show() resolves to { createdAt: Date } again — and the value really is a
Date at runtime.