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()}` }),
});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.