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

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=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