npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@routepact/client

v0.5.0

Published

Type-safe HTTP client for route pacts - validates responses against schemas

Readme

@routepact/client

Type-safe HTTP client for @routepact/core pacts built on native fetch. Pass a pact and get back a fully-typed response — params, payload, queries, and return type are all inferred automatically.

Installation

npm install @routepact/client @routepact/core

You also need a schema library that implements the Standard Schema interface (e.g. Zod, Valibot, ArkType) for defining your pacts. Examples below use Zod but any Standard Schema-compatible library works.

npm install zod    # or valibot, arktype, etc.

Setup

Create a client bound to a base URL, then call its request method. You typically do this once and export it for use across your app.

import { createClient } from "@routepact/client";

export const client = createClient("https://api.example.com");

Making requests

Pass a pact to client.request. TypeScript infers everything from the pact - what options are required, whether params, payload, or query are needed, and the return type.

The result is always { status, body } — a discriminated union over the statuses declared in the pact's response map. Narrow on status to reach the matching, validated body:

import { PostPacts } from "../shared/pacts/post.pact";

// GET /posts/:id — response: { 200: Post, 404: { error: string } }
const res = await client.request(PostPacts.getById, { params: { id: "abc" } });
// res: { status: 200; body: Post } | { status: 404; body: { error: string } }
if (res.status === 200) {
  console.log(res.body.title); // body is Post here
} else {
  console.error(res.body.error); // body is { error: string } here
}

// GET /posts — when only one status is declared, body is directly accessible
const list = await client.request(PostPacts.list);
// list: { status: 200; body: { items: ...; total: number } }
list.body.items;

// POST /posts - payload is required, typed from the request schema
const created = await client.request(PostPacts.create, {
  payload: { title: "Hello", body: "World" },
});
// created: { status: 201; body: Post }

// PATCH /posts/:id - both params and payload
const updated = await client.request(PostPacts.update, {
  params: { id: "abc" },
  payload: { title: "Updated title" },
});

// DELETE /posts/:id — response: { 204: null }
const removed = await client.request(PostPacts.delete, { params: { id: "abc" } });
// removed: { status: 204; body: undefined }

Path parameters

Each path parameter is percent-encoded into exactly one segment, so a value containing a separator is transmitted intact rather than changing the shape of the path — { id: "a/b" } against /posts/:id produces /posts/a%2Fb.

Three values cannot address a segment and are rejected with an error rather than sent: ".", "..", and "". The URL parser resolves them away before the request leaves, so { id: ".." } against /users/:id/profile would have requested /profile — a different endpoint, with your credentials attached. Encoding cannot avoid this: the URL parser reads %2e as ., so an escaped %2e%2e collapses just like ...

This matters when parameters come from user input — a route parameter read off the current page, a search field, a pasted identifier. If a value may legitimately be any of those three, it cannot be a path parameter; put it in the query string instead.

Query parameters

Query parameters are typed from the pact's query schema. If the schema has required fields, TypeScript will require query at the call site:

// pact defined with: query: z.object({ page: z.string().optional(), sort: z.string() })
const posts = await client.request(PostPacts.list, {
  query: { sort: "createdAt", page: "2" },
});
// -> GET /posts?sort=createdAt&page=2

If the pact has no query schema, query accepts never and TypeScript will prevent you from passing it.

Config

Pass a RequestConfig object as the second argument to createClient. It accepts fetchInit, fetchImpl, defaultResponses, beforeRequest hooks, and afterResponse hooks — all optional and composable.

fetchInit

Any standard RequestInit field (except method, body, and headers which are managed internally) is forwarded to every fetch call. This is the right place for credentials, mode, cache, signal, etc.

import { createClient } from "@routepact/client";

export const client = createClient("https://api.example.com", {
  fetchInit: {
    credentials: "include",             // send cookies on every request
    signal: AbortSignal.timeout(30_000), // 30 s timeout
  },
});

stream supplies its own signal — the one its cleanup function aborts — so a signal in fetchInit applies to request only. A timeout is the reason this matters: an SSE subscription is meant to outlive one.

fetchImpl

A stand-in for the global fetch, used by both request and stream. It receives the fully built Request — the one the beforeRequest hooks have already seen — and returns the Response.

This exists for React Native: its built-in fetch buffers the whole response body, so a stream opened with it delivers nothing until the endpoint closes. expo/fetch streams:

import { fetch as expoFetch } from "expo/fetch";
import { createClient, type FetchImpl } from "@routepact/client";

export const client = createClient("https://api.example.com", {
  fetchImpl: expoFetch as FetchImpl,
});

The default is the global fetch, which streams in browsers and on Node.

defaultResponses

A status-keyed map of fallback response schemas applied to every request made through the client. When the server returns a status that the pact's own response map doesn't declare, the client falls back to the schema for that status in defaultResponses — validating and returning it as a normal { status, body } result instead of throwing UnexpectedStatusError.

This is the place for statuses your whole API shares — a uniform error envelope, a 401, a 429 — so you don't have to repeat them in every pact:

import { z } from "zod";

export const client = createClient("https://api.example.com", {
  defaultResponses: {
    400: z.object({ error: z.string() }),
    500: z.object({ error: z.string() }),
  },
});

// For a pact declaring only { 200: User }, the result type now includes
// the default statuses too:
const res = await client.request(UserPacts.getById, { params: { id: "1" } });
// res: { status: 200; body: User }
//    | { status: 400; body: { error: string } }
//    | { status: 500; body: { error: string } }
if (res.status === 500) {
  console.error(res.body.error); // validated against the default 500 schema
}

A pact's own response map always takes precedence: if a route declares its own schema for a status, that one is used and the default is ignored. Statuses covered by neither the pact nor defaultResponses still throw UnexpectedStatusError. Use null for a default status that carries no body (e.g. { 204: null }).

Hooks

beforeRequest and afterResponse hook arrays run logic around each fetch. This is the place to add auth headers, log errors, or handle token refresh.

export const client = createClient("https://api.example.com", {
  fetchInit: { credentials: "include" },
  beforeRequest: [
    (req) =>
      new Request(req, {
        headers: {
          ...Object.fromEntries(req.headers),
          Authorization: `Bearer ${getToken()}`,
        },
      }),
  ],
  afterResponse: [
    (_req, res) => {
      if (res.status === 401) {
        logout();
      }
    },
  ],
});

Both hook types support async functions. A beforeRequest hook can return a new Request object to replace the outgoing request entirely, and an afterResponse hook can return a new Response to replace what the client sees.

Response validation and statuses

The client looks up the schema for the actual HTTP status it received and validates the body against it. This means declared error statuses (e.g. a 404) come back as a normal, typed result — the client does not throw on non-2xx responses:

const res = await client.request(PostPacts.getById, { params: { id: "abc" } });
if (res.status === 404) {
  // res.body is the validated 404 schema — handled, not thrown
}

Two things are thrown instead:

  • ClientValidationError — the body for a declared status fails its schema.
  • UnexpectedStatusError — the server returned a status that neither the pact's response map nor the client's defaultResponses declares. The contract can't describe its body, so it's surfaced as an error (carrying .status and the parsed JSON .body, or {} if the response wasn't valid JSON) rather than a mistyped value. This is what keeps the { status, body } return type sound: every value the client returns has a declared status whose body matches.
import { ClientValidationError, UnexpectedStatusError } from "@routepact/client";

try {
  const res = await client.request(PostPacts.getById, { params: { id: "abc" } });
  // ... narrow on res.status ...
} catch (error) {
  if (error instanceof ClientValidationError) {
    console.error(error.cause); // Standard Schema issues array
  } else if (error instanceof UnexpectedStatusError) {
    console.error(error.status, error.body); // undeclared status + raw body
  }
}

If you want to handle a status, declare it in the pact's response map (use null for a bodyless status like 204), or in the client's defaultResponses if it's common across your API. Anything you leave out of both becomes an UnexpectedStatusError. Note that afterResponse hooks still run for every response, including undeclared statuses, before this check — so you can observe e.g. a 401 in a hook even though the request itself will reject.

Multiple API instances

You can create multiple clients pointing to different APIs:

export const internalApi = createClient("https://internal.example.com", {
  beforeRequest: [(req) => req.headers.set("X-Internal", "1")],
});
export const externalApi = createClient("https://api.partner.com");

Server-Sent Events (SSE)

For SSE routes (marked with sse: true in the pact), use the client's stream method. It opens the connection with fetch, parses the event stream, validates each event against the pact's events schema, and returns a function that closes the connection.

import { createClient } from "@routepact/client";

const client = createClient("https://api.example.com");

const close = client.stream(
  EventPacts.stream,
  { params: { roomId: "general" } },
  (event) => {
    // event is fully typed from the pact's events schema
    if (event.type === "message") console.log(event.text);
    if (event.type === "ping") console.log("ping", event.timestamp);
  },
  (error) => {
    // see "Stream failures" below
    console.error(error);
  },
);

// later — aborts the request and stops reading
close();

When the pact has no required params or query, pass undefined as the options argument:

const close = client.stream(
  NotificationPacts.stream,
  undefined,
  (event) => console.log(event),
);

Because stream goes through fetch, it is the same request path as request: fetchInit, fetchImpl, and the beforeRequest / afterResponse hooks all apply, so an SSE route behind auth is reachable with the headers or cookies your hooks already set. It runs anywhere fetch streams — including React Native, where EventSource does not exist (see fetchImpl).

get and post

An SSE pact route may be get or post; the other three methods are a type error. get is the subscription form — parameters and query, no body — and stays consumable from a bare EventSource in a browser. Use post when the subscription needs a payload that doesn't fit in a query string:

const close = client.stream(
  AssistantPacts.ask,               // { path: "/ask", method: "post", sse: true, request, events }
  { payload: { prompt: "why is the sky blue?" } },
  (event) => console.log(event),
);

The payload is validated against the pact's request schema before anything is sent, exactly as in request.

Stream failures

Everything that goes wrong reaches onError; nothing is swallowed.

| what happened | what onError receives | | --- | --- | | the server answered non-2xx before the stream began | StreamStatusError with .status and the body validated against defaultResponses — an expired session arrives as a typed 401 | | that status is declared by neither the pact nor defaultResponses | UnexpectedStatusError, as in request | | the response was 2xx but not text/event-stream | StreamContentTypeError with .contentType — usually a proxy or a login page in front of the endpoint | | the payload or an event failed its schema | ClientValidationError | | an event was not valid JSON | the SyntaxError from parsing it | | the connection was cut mid-stream | the transport's error — a truncated chunked response never reaches its terminating chunk, so the reader rejects rather than reporting a clean end |

A malformed or invalid single event does not end the subscription — it is reported and reading continues. Everything else ends it. A stream that ends normally calls neither callback again, and closing it yourself is not an error: after close(), onError is not called.

No automatic reconnect. EventSource reconnects on its own after a dropped connection; fetch does not, and stream deliberately does not rebuild it. A silent reconnect replays the stream without the caller knowing — on an expensive or non-idempotent endpoint a visible error is better than an invisible retry. Reconnect in onError if you want it, where you can decide whether replaying is safe.


API reference

createClient(baseUrl, config?)

Returns a client object with request and stream methods. The client is generic over the config's defaultResponses, which widen every result's status union:

{
  request: <TPact extends AnyPactRoute>(
    pact: TPact,
    // options is required only when the pact needs params, payload, or query
    ...[options]: RouteOptionsRequired<TPact> extends true
      ? [options: RouteOptions<TPact>]
      : [options?: RouteOptions<TPact>]
  ) => Promise<RouteClientResult<TPact, TDefaultResponse>>;

  stream: <TPact extends AnyPactRoute & { sse: true }>(
    pact: TPact,
    options: RouteOptions<TPact> | undefined,
    onEvent: (event: RouteEvent<TPact>) => void,
    onError?: (error: unknown) => void,
  ) => () => void;
}
  • pact - a pact created with definePact from @routepact/core
  • options.params - required when the path contains :param segments
  • options.payload - required for post, patch, put when the pact has a request schema
  • options.query - typed from the pact's query schema; required if the schema has required fields
  • request returns RouteClientResult<TPact, TDefaultResponse> - a { status, body } discriminated union over the pact's declared response statuses plus any statuses added via defaultResponses. Throws ClientValidationError if the body fails its status schema, or UnexpectedStatusError if the status was declared by neither.
  • stream opens a fetch connection for an sse: true pact (get or post), validates each event against its events schema, and returns a cleanup () => void that aborts it. Failures go to onError. See Server-Sent Events.

RequestConfig

interface RequestConfig<TDefaultResponse extends ResponseSchemaMap> {
  /** Forwarded verbatim to every fetch call — use for credentials, mode, cache, signal, etc. */
  fetchInit?: Omit<RequestInit, "method" | "body" | "headers">;
  /** Fallback response schemas (status -> schema), used when the pact doesn't declare the returned status. */
  defaultResponses?: TDefaultResponse;
  /** Stand-in for the global fetch, used by both request and stream — e.g. `fetch` from `expo/fetch`. */
  fetchImpl?: (request: Request) => Promise<Response>;
  beforeRequest?: Array<(request: Request) => Request | void | Promise<Request | void>>;
  afterResponse?: Array<(request: Request, response: Response) => Response | void | Promise<Response | void>>;
}

Type reference

| Export | Description | | ---------------------------------- | ---------------------------------------------------------------------------------------------------- | | createClient(baseUrl, config?) | Creates a typed client bound to a base URL with optional config — exposes request and stream methods | | client.stream(...) | Opens a typed fetch subscription for an SSE pact route — returns a cleanup () => void | | RequestConfig | Config for createClient — fetchInit, fetchImpl, defaultResponses, beforeRequest, and afterResponse | | FetchImpl | (request: Request) => Promise<Response> — the shape of fetchImpl | | ClientValidationError | Thrown when response or payload validation fails — has field and cause: StandardSchemaV1.Issue[] | | UnexpectedStatusError | Thrown when the response status isn't declared in the pact's response map — has status and parsed body | | StreamStatusError | Passed to a stream's onError when the server answered non-2xx before the stream began — has status and validated body | | StreamContentTypeError | Passed to a stream's onError when the response was not text/event-stream — has contentType |