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

@richie-rpc/client

v2.0.0

Published

Type-safe fetch client for Richie RPC contracts

Readme

@richie-rpc/client

Type-safe fetch client for Richie RPC contracts.

Installation

bun add @richie-rpc/client @richie-rpc/core zod@^4

Usage

Creating a Client

import { createClient } from '@richie-rpc/client';
import { contract } from './contract';

const client = createClient(contract, {
  baseUrl: 'https://api.example.com',
  headers: {
    Authorization: 'Bearer token123',
  },
});

Client with basePath

The baseUrl supports both absolute and relative URLs:

// Absolute URL with path prefix
const client = createClient(contract, {
  baseUrl: 'https://api.example.com/api',
});

// Relative URL with path prefix (browser-friendly)
const client = createClient(contract, {
  baseUrl: '/api', // Resolves to current origin + /api
});

// Just the path (same origin)
const client = createClient(contract, {
  baseUrl: '/', // Resolves to current origin
});

How it works:

  • Absolute URLs (http://... or https://...): Used as-is
  • Relative URLs (starting with /): Automatically resolved using window.location.origin in browsers, or http://localhost in non-browser environments

Example: In a browser at https://example.com, if your contract defines /users:

  • With baseUrl: '/api' → actual URL is https://example.com/api/users
  • With baseUrl: '/' → actual URL is https://example.com/users

Making Requests

The client provides fully typed methods for each endpoint in your contract:

// GET request with path parameters
const user = await client.getUser({
  params: { id: '123' },
});
// user is typed based on the response schema

// POST request with body
const newUser = await client.createUser({
  body: {
    name: 'John Doe',
    email: '[email protected]',
  },
});

// Request with query parameters
const users = await client.listUsers({
  query: {
    limit: '10',
    offset: '0',
  },
});

// Request with custom headers
const data = await client.getData({
  headers: {
    'X-Custom-Header': 'value',
  },
});

File Uploads (multipart/form-data)

Upload files with full type safety. Files can be nested anywhere in the request body:

// Contract defines the file upload endpoint
// (see @richie-rpc/core for defining contentType: 'multipart/form-data')

// Client usage - just pass File objects in the body
const file1 = new File(['content'], 'report.pdf', { type: 'application/pdf' });
const file2 = new File(['data'], 'data.csv', { type: 'text/csv' });

const response = await client.uploadDocuments({
  body: {
    documents: [
      { file: file1, name: 'Q4 Report', tags: ['quarterly', 'finance'] },
      { file: file2, name: 'Sales Data' },
    ],
    category: 'reports',
  },
});

if (response.status === 201) {
  console.log(`Uploaded ${response.payload.uploadedCount} files`);
}

The client automatically:

  • Detects multipart/form-data content type from the contract
  • Serializes nested structures with File objects to FormData
  • Sets the correct Content-Type header with boundary

Canceling Requests

You can cancel in-flight requests using AbortController:

const controller = new AbortController();

// Pass the abort signal to the request
const promise = client.getUser({
  params: { id: '123' },
  abortSignal: controller.signal,
});

// Cancel the request
controller.abort();

try {
  await promise;
} catch (error) {
  if (error.name === 'AbortError') {
    console.log('Request was cancelled');
  }
}

React Example:

useEffect(() => {
  const controller = new AbortController();

  client
    .getConversation({
      params: { projectId, sessionId },
      abortSignal: controller.signal,
    })
    .then((response) => {
      setData(response.payload);
    })
    .catch((error) => {
      if (error.name !== 'AbortError') {
        console.error('Request failed:', error);
      }
    });

  // Cleanup: abort request if component unmounts
  return () => controller.abort();
}, [projectId, sessionId]);

Timeout Example:

const controller = new AbortController();

// Abort after 5 seconds
const timeoutId = setTimeout(() => controller.abort(), 5000);

try {
  const response = await client.getData({
    abortSignal: controller.signal,
  });
  clearTimeout(timeoutId);
  console.log(response.payload);
} catch (error) {
  if (error.name === 'AbortError') {
    console.log('Request timed out');
  }
}

Streaming Responses

For streaming endpoints, the client returns an event-based result:

const contract = defineContract({
  generateText: {
    type: 'streaming',
    method: 'POST',
    path: '/generate',
    body: z.object({ prompt: z.string() }),
    chunk: z.object({ text: z.string() }),
    finalResponse: z.object({ totalTokens: z.number() }),
  },
});

const client = createClient(contract, { baseUrl: 'http://localhost:3000' });

const result = client.generateText({ body: { prompt: 'Hello world' } });

// Listen for chunks
result.on('chunk', (chunk) => {
  process.stdout.write(chunk.text);
});

// Listen for stream completion
result.on('close', (final) => {
  if (final) {
    console.log(`\nTotal tokens: ${final.totalTokens}`);
  }
});

// Handle errors
result.on('error', (error) => {
  console.error('Stream error:', error.message);
});

// Abort if needed
result.abort();

StreamingResult Interface

  • on('chunk', handler) - Subscribe to chunks
  • on('close', handler) - Subscribe to stream close (with optional final response)
  • on('error', handler) - Subscribe to errors
  • abort() - Abort the stream
  • aborted - Check if the stream was aborted

Server-Sent Events (SSE)

For SSE endpoints, the client returns an event-based connection:

const contract = defineContract({
  notifications: {
    type: 'sse',
    method: 'GET',
    path: '/notifications',
    events: {
      message: z.object({ text: z.string(), timestamp: z.string() }),
      heartbeat: z.object({ timestamp: z.string() }),
    },
  },
});

const client = createClient(contract, { baseUrl: 'http://localhost:3000' });

const conn = client.notifications();

// Listen for specific event types
conn.on('message', (data) => {
  console.log(`Message: ${data.text} at ${data.timestamp}`);
});

conn.on('heartbeat', (data) => {
  console.log('Heartbeat:', data.timestamp);
});

// Handle connection errors
conn.on('error', (error) => {
  console.error('SSE error:', error.message);
});

// Close when done
conn.close();

// Check connection state
console.log('State:', conn.state); // 'connecting' | 'open' | 'closed'

SSEConnection Interface

  • on(event, handler) - Subscribe to a specific event type
  • on('error', handler) - Subscribe to connection errors
  • close() - Close the connection
  • state - Current connection state

WebSocket Client

For bidirectional real-time communication, use createWebSocketClient:

import { createWebSocketClient } from '@richie-rpc/client';
import { defineWebSocketContract } from '@richie-rpc/core';

const wsContract = defineWebSocketContract({
  chat: {
    path: '/ws/chat/:roomId',
    params: z.object({ roomId: z.string() }),
    clientMessages: {
      sendMessage: { payload: z.object({ text: z.string() }) },
    },
    serverMessages: {
      message: { payload: z.object({ userId: z.string(), text: z.string() }) },
      error: { payload: z.object({ code: z.string(), message: z.string() }) },
    },
  },
});

const wsClient = createWebSocketClient(wsContract, {
  baseUrl: 'ws://localhost:3000',
});

// Get a typed WebSocket instance
const chat = wsClient.chat({ params: { roomId: 'general' } });

// Connect (returns disconnect function)
const disconnect = chat.connect();

// Track connection state
chat.onStateChange((connected) => {
  console.log('Connected:', connected);
});

// Listen for specific message types
chat.on('message', (payload) => {
  console.log(`${payload.userId}: ${payload.text}`);
});

chat.on('error', (payload) => {
  console.error(`Error ${payload.code}: ${payload.message}`);
});

// Listen for all messages
chat.onMessage((message) => {
  console.log('Received:', message.type, message.payload);
});

// Handle connection errors
chat.onError((error) => {
  console.error('Connection error:', error.message);
});

// Send messages (validates before sending)
chat.send('sendMessage', { text: 'Hello!' });

// Disconnect when done
disconnect();

TypedWebSocket Interface

  • connect() - Connect to the WebSocket server, returns disconnect function
  • send(type, payload) - Send a typed message (validates before sending)
  • on(type, handler) - Subscribe to a specific message type
  • onMessage(handler) - Subscribe to all messages
  • onStateChange(handler) - Track connection state changes
  • onError(handler) - Handle connection errors
  • connected - Check current connection state

React Integration Example

function ChatRoom({ roomId }: { roomId: string }) {
  const [connected, setConnected] = useState(false);
  const [messages, setMessages] = useState<Message[]>([]);

  const chat = useMemo(
    () => wsClient.chat({ params: { roomId } }),
    [roomId]
  );

  // Connection lifecycle
  useEffect(() => {
    const disconnect = chat.connect();
    return () => disconnect();
  }, [chat]);

  // Track connection state
  useEffect(() => {
    return chat.onStateChange(setConnected);
  }, [chat]);

  // Subscribe to messages (only when connected)
  useEffect(() => {
    if (!connected) return;
    return chat.on('message', (payload) => {
      setMessages((prev) => [...prev, payload]);
    });
  }, [connected, chat]);

  const handleSend = (text: string) => {
    chat.send('sendMessage', { text });
  };

  return (
    <div>
      <div>Status: {connected ? 'Connected' : 'Disconnected'}</div>
      {messages.map((msg, i) => (
        <div key={i}>{msg.userId}: {msg.text}</div>
      ))}
      <button onClick={() => handleSend('Hello!')}>Send</button>
    </div>
  );
}

Features

  • ✅ Full type safety based on contract
  • ✅ Automatic path parameter interpolation
  • ✅ Query parameter encoding
  • ✅ BasePath support in baseUrl
  • ✅ HTTP Streaming with event-based API
  • ✅ Server-Sent Events (SSE) client
  • ✅ WebSocket client with typed messages
  • ✅ Request validation before sending
  • ✅ Response validation after receiving
  • ✅ Typed error responses (ErrorResponse thrown for errorResponses statuses)
  • ✅ Detailed error information
  • ✅ Support for all HTTP methods
  • ✅ Custom headers per request
  • ✅ Request cancellation with AbortController
  • ✅ File uploads with multipart/form-data
  • ✅ Nested file structures in request bodies

Configuration

ClientConfig Options

interface ClientConfig {
  baseUrl: string; // Base URL for all requests
  headers?: Record<string, string>; // Default headers
  validateRequest?: boolean; // Validate before sending (default: true)
  validateResponse?: boolean; // Validate after receiving (default: true)
}

Response Format

Responses include both the status code and payload:

const response = await client.getUser({ params: { id: '123' } });

console.log(response.status); // 200
console.log(response.payload); // Typed response body

Status codes defined in errorResponses are thrown as ErrorResponse instead of being returned. This means the response type only contains success statuses, eliminating the need for status discrimination on successful responses.

Error Handling

The client throws typed errors for different scenarios:

ErrorResponse

Thrown when the server returns a status code defined in errorResponses. The payload contains the parsed response body matching the schema from the contract.

import { isErrorResponse } from '@richie-rpc/client';
import { contract } from './contract';

try {
  await client.getUser({ params: { id: '999' } });
} catch (error) {
  // With endpoint — fully typed status and payload
  if (isErrorResponse(error, contract.getUser)) {
    console.log(error.status);  // 404
    console.log(error.payload); // { error: string } — typed from the contract
  }

  // Without endpoint — basic check (payload is unknown)
  if (isErrorResponse(error)) {
    console.log(error.status);  // number
    console.log(error.payload); // unknown
  }
}

The second argument is optional and only used for TypeScript type narrowing — the runtime check is always error instanceof ErrorResponse.

TypedErrorResponse

For use as a standalone type (e.g. in function signatures), TypedErrorResponse narrows ErrorResponse to the specific statuses and payloads from the contract:

import type { TypedErrorResponse } from '@richie-rpc/client';

type GetUserError = TypedErrorResponse<typeof contract.getUser>;
// => ErrorResponse & { status: 404; payload: { error: string } }

ClientValidationError

Thrown when request data fails validation:

try {
  await client.createUser({
    body: { email: 'invalid-email' },
  });
} catch (error) {
  if (error instanceof ClientValidationError) {
    console.log(error.field); // 'body'
    console.log(error.issues); // Zod validation issues
  }
}

HTTPError

Thrown for unexpected HTTP status codes (not defined in responses or errorResponses):

try {
  await client.getUser({ params: { id: '123' } });
} catch (error) {
  if (error instanceof HTTPError) {
    console.log(error.status); // e.g. 500
    console.log(error.statusText); // 'Internal Server Error'
    console.log(error.body); // Response body
  }
}

Type Safety

All client methods are fully typed based on your contract:

// ✅ Type-safe: required fields
await client.createUser({
  body: { name: 'John', email: '[email protected]' },
});

// ❌ Type error: missing required field
await client.createUser({
  body: { name: 'John' },
});

// ✅ Type-safe: response payload
const user = await client.getUser({ params: { id: '123' } });
console.log(user.payload.name); // string

// ❌ Type error: invalid property
console.log(user.payload.invalid);

Request Options

Each client method accepts an options object with the following fields (based on the endpoint definition):

  • params: Path parameters (if endpoint has params schema)
  • query: Query parameters (if endpoint has query schema)
  • headers: Custom headers (if endpoint has headers schema)
  • body: Request body (if endpoint has body schema)
  • abortSignal: AbortSignal for request cancellation (optional, always available)

Only the fields defined in the contract are available and typed (except abortSignal, which is always available).

Validation

By default, both request and response data are validated:

  • Request validation: Ensures data conforms to schema before sending
  • Response validation: Ensures server response matches expected schema

You can disable validation:

const client = createClient(contract, {
  baseUrl: 'https://api.example.com',
  validateRequest: false, // Skip request validation
  validateResponse: false, // Skip response validation
});

Links

  • npm: https://www.npmjs.com/package/@richie-rpc/client
  • Repository: https://github.com/ricsam/richie-rpc

License

MIT