Aviary
Client

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=b
arrayFormat{ ids: ['a', 'b'] }When to use
'comma' (default)?ids=a,bBack-compat: the historical behavior. The server must split the joined value itself.
'repeat'?ids=a&ids=bThe 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: 1 and deserializes the response with superjson.
  • Server SuperjsonInterceptor superjson-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:

src/lib/api.ts
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:

src/lib/api.ts
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:

src/app.module.ts
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<...>:

nestjs-codegen.config.ts
export default defineConfig({
  // ...
  serialization: 'superjson',
});

Now api.users.show() resolves to { createdAt: Date } again — and the value really is a Date at runtime.

On this page