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.2.5

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 }

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

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 an EventSource connection, validates each incoming event against the pact's events schema, and returns a cleanup function that closes the stream.

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) => {
    // called on connection errors (Event) or validation failures (ClientValidationError)
    console.error(error);
  },
);

// later — closes the EventSource
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),
);

stream uses the browser's native EventSource API under the hood. It is intended for browser environments — for Node.js SSE clients, use fetch with a readable stream instead. Note that EventSource cannot send custom headers or a request body, so the client's fetchInit and request hooks do not apply to SSE subscriptions.


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 an EventSource for an sse: true pact, validates each event against its events schema, and returns a cleanup () => void. 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;
  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 EventSource subscription for an SSE pact route — returns a cleanup () => void | | RequestConfig | Config for createClientfetchInit, defaultResponses, beforeRequest, and afterResponse | | 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 |