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

@emeryld/rrroutes-client

v2.10.12

Published

Typed React Query and Socket.IO client for RRRoutes contracts: endpoints, hooks, fetchers, cache keys, and socket bindings built directly from finalized leaves.

Readme

@emeryld/rrroutes-client

Typed React Query + Socket.IO helpers that sit on top of RRRoutes contracts. Build endpoints directly from finalized leaves, get strongly-typed hooks/fetchers, ready-to-use cache keys, debug logging, and optional socket utilities (client + React provider + socketed routes).

Installation

pnpm add @emeryld/rrroutes-client

Most apps also want React Query, which the hook-based entry points peer on:

pnpm add @emeryld/rrroutes-client @tanstack/react-query

@emeryld/rrroutes-contract and zod come along as dependencies; you supply React Query.

Prerequisites

  • zod ^4.0.0

React, TanStack Query, and Socket.IO are optional peers; install only those used by the selected entry points.

Entry points

Choose the smallest entry point needed by a shared client folder:

| Import path | Purpose | Runtime | Status | Additional requirements | | --------------------------------------- | ----------------------------------------------------------------------------------------- | -------------- | ------ | ---------------------------------------------- | | @emeryld/rrroutes-client | Typed React Query and Socket.IO helpers built on finalized RRRoutes contracts. | react, browser | stable | @tanstack/react-query, react | | @emeryld/rrroutes-client/augments | Composable endpoint behavior and hook-time TanStack Query options. | react, browser | stable | @tanstack/react-query, react | | @emeryld/rrroutes-client/bindings | Reusable typed factories that bind an application resource to route-client helpers. | react, browser | stable | @tanstack/react-query, react | | @emeryld/rrroutes-client/http | Transport types, the default fetcher, and HttpError — no React required. | browser, node | stable | — | | @emeryld/rrroutes-client/pagination | Application-wide feed pagination presets and cursor helpers. | universal | stable | — | | @emeryld/rrroutes-client/react-query | Route client and React Query helpers for typed endpoints built from finalized leaves. | react, browser | stable | @tanstack/react-query, react | | @emeryld/rrroutes-client/socket | Framework-independent Socket.IO client core for socketed RRRoutes resources. | browser, node | stable | socket.io-client | | @emeryld/rrroutes-client/socket/cache | Immutable array and feed cache reducers for applying socket events to React Query caches. | browser | stable | @tanstack/react-query | | @emeryld/rrroutes-client/socket/react | React provider and hooks for socketed RRRoutes resources. | react, browser | stable | react, socket.io-client, @tanstack/react-query |

Compatibility

Ships ESM + CJS with per-subpath type declarations. ./http and ./pagination are framework-free and run in Node or the browser; ./socket runs in both but needs socket.io-client. React is only required by the hook and provider entry points.

Quick start (typed GET with React Query)

import { QueryClient } from '@tanstack/react-query';
import { createRouteClient } from '@emeryld/rrroutes-client';
import { registry } from '../routes'; // from @emeryld/rrroutes-contract + finalize(...)

const routeClient = createRouteClient({
  baseUrl: '/api',                   // prepended to all paths
  queryClient: new QueryClient(),    // shared React Query instance
});

const listUsers = routeClient.build(registry.byKey['GET /v1/users'], {
  staleTime: 60_000,
  onReceive: (data) => console.log('fresh users', data),
});

export function Users() {
  const { data, isLoading } = listUsers.useEndpoint({ query: { search: 'emery' } });
  if (isLoading) return <p>Loading…</p>;
  return <pre>{JSON.stringify(data, null, 2)}</pre>;
}

How it works

  • createRouteClient wires your QueryClient, base URL, optional custom fetcher, and debug settings.
  • dispatchEndpoint({ leaf, input, ...requestOptions }) imperatively executes any leaf through that shared client configuration.
  • build(leaf, options?, meta?) returns a helper that exposes:
    • useEndpoint(args?, options?) — React hook for GET/feeds/mutations (typed params/query/body/output) with optional per-call TanStack options.
    • fetch(...) — direct fetcher (no cache). Mutations require the body as the last argument.
    • getQueryKeys(...) — deterministic cache key used by React Query + invalidation.
    • invalidate(...) — invalidate this exact endpoint instance.
    • setData(updater, args?) — mutate cache (infinite-aware).
  • For feed endpoints (cfg.feed === true), cursors are handled automatically; cache keys omit the cursor so pages merge correctly.
  • Feed endpoints automatically expose useFeed; mutations automatically expose fetchMany, fetchManyWithOptions, and useEndpointMany.

Application-specific endpoint behavior can be layered with the public augment API. See the endpoint augment authoring guide.


Detailed usage

1) Configure the client

The recommended setup is to export one configured client from your app's shared client folder. Web, mobile, and embeds can import that same module while the custom fetcher handles platform auth consistently. Per request, fetchWithOptions(...) accepts headers, signal, and credentials; hook queries forward TanStack Query's cancellation signal automatically. Custom fetchers also receive the originating contract leaf as req.leaf.

import { QueryClient } from '@tanstack/react-query'
import { createRouteClient, defaultFetcher } from '@emeryld/rrroutes-client'

const queryClient = new QueryClient()

const client = createRouteClient({
  baseUrl: 'https://api.example.com',
  queryClient,
  fetcher: async (req) => {
    // Attach auth headers, reuse defaultFetcher for JSON parsing + error handling
    return defaultFetcher({
      ...req,
      headers: { ...req.headers, Authorization: `Bearer ${getToken()}` },
    })
  },
  environment: process.env.NODE_ENV, // disables debug when "production"
  debug: {
    fetch: true,
    invalidate: true,
    verbose: true, // include params/query/output in debug events
  },
})

The client also exposes a leaf-aware dispatcher for integrations such as an MCP server, jobs, and other dynamic route registries:

const result = await client.dispatchEndpoint({
  leaf: registry.byKey['PATCH /v1/users/:userId'],
  input: {
    params: { userId: 'user_123' },
    query: { notify: true },
    body: { displayName: 'Ada' },
  },
  signal: abortController.signal,
})

It uses the same fetcher, authentication, request/response validation, multipart conversion, and debug pipeline as helpers created with build.

Pass the client-visible error shape directly as the first generic:

import { createRouteClient } from '@emeryld/rrroutes-client/react-query'
import type { ApiError } from '../shared/errors'

export const client = createRouteClient<ApiError>({
  baseUrl: API_URL,
  queryClient,
  fetcher: (req) => defaultFetcher(req),
})

const users = client.build(registry.byKey['GET /v1/users'])
// users.useEndpoint().error is ApiError | null

await users.fetchWithOptions(
  { credentials: 'omit', signal: abortController.signal },
  { query: { search: 'emery' } },
)

If you prefer a typed client-wide hook error without passing one generic at client construction, augment the global RRRoutes client register once:

import { createRouteClient, type HttpError } from '@emeryld/rrroutes-client'

declare global {
  namespace RRRoutesClient {
    interface Register {
      error: HttpError
    }
  }
}

const client = createRouteClient({
  baseUrl: 'https://api.example.com',
  queryClient,
})

const users = client.build(registry.byKey['GET /v1/users'])
const result = users.useEndpoint()
// result.error is now typed as HttpError | null

2) Build a reusable shared client resource

Use defineRouteResource in application-shared code to select and name the contract leaves. Then define the client behavior with createClientBindings. The resource remains server-compatible through its byKey and all fields.

// shared/users.resource.ts
import { defineRouteResource } from '@emeryld/rrroutes-contract/resource'

export const usersResource = defineRouteResource(registry.byKey, {
  listUsers: 'GET /v1/users',
  getUser: 'GET /v1/users/:userId',
  updateUser: 'PATCH /v1/users/:userId',
})

For a complete networking resource, use object form and attach the socket contract once:

export const usersResource = defineRouteResource({
  routes: registry.byKey,
  keys: {
    listUsers: 'GET /v1/users',
    getUser: 'GET /v1/users/:userId',
    updateUser: 'PATCH /v1/users/:userId',
  },
  sockets,
})

Create the client socket layer without repeating its events or config:

import { createResourceSocketClient } from '@emeryld/rrroutes-client/socket'
import { buildResourceSocketProvider } from '@emeryld/rrroutes-client/socket/react'

const socketClient = createResourceSocketClient(usersResource, {
  socket,
  sys: socketSystemHandlers,
})

const socketReact = buildResourceSocketProvider(usersResource, {
  options: { sys: socketSystemHandlers },
})
// client/users.client.ts
import {
  createClientBindings,
  type ClientBindingAdapter,
} from '@emeryld/rrroutes-client/bindings'
import { withSocketRoute } from '@emeryld/rrroutes-client/socket/react'
import type { SocketClient } from '@emeryld/rrroutes-client/socket'

interface AppAdapter extends ClientBindingAdapter<ApiError> {
  useSocketClient: () => SocketClient<AppEvents, AppSocketConfig> | null
}

const defineClientResource = createClientBindings<ApiError, AppAdapter>()

export const createUsersClient = defineClientResource({
  resource: usersResource,
  defaults: {
    listUsers: { feed: { getItemId: (user) => user.id } },
    updateUser: { singleton: true },
  },
  compose: {
    getUser: (endpoint, { adapter }) =>
      withSocketRoute(endpoint, {
        id: 'users.get.live',
        useSocketClient: adapter.useSocketClient,
        toRooms: ({ args }) => ({
          rooms: args?.params.userId ? [`users:${args.params.userId}`] : [],
        }),
        applySocket: {
          'users:updated': ({ prev, payload }) =>
            prev?.id === payload.id ? { ...prev, ...payload } : null,
        },
        roomStrategy: { kind: 'by-route-param', name: 'userId' },
        cacheReducers: [
          { event: 'users:updated', kind: 'merge', name: 'merge-user' },
        ],
      }),
  },
})

export const users = createUsersClient({
  routeClient: client,
  useSocketClient,
  finalize: (endpoint) => withApplicationCachePolicy(endpoint),
})

ClientBindingAdapter requires only a configured route client's build method. Extend it with socket hooks, current-user hooks, or platform services needed by composition. Its optional finalize callback runs once per built endpoint before resource composition.

roomStrategy and cacheReducers are inspection descriptors, not executable callbacks. Their fields are JSON-safe so hosted inspectors, checkpoints, and semantic diffs can expose them without serializing application runtime state.

Use compose for independent endpoint wrappers. Its context contains adapter, all built routes, and the exact shared resource (including its socket contract). Use decorate(routes, adapter, resource) when several endpoints depend on each other or the returned resource needs additional helpers. The two forms are mutually exclusive.

Build options are applied in this order:

  1. Definition defaults.
  2. Overrides passed to createUsersClient(adapter, overrides).
  3. Adapter finalize.
  4. compose or decorate.

Nested feed and many options are merged. Other options are right-biased. The inferred BuiltResource, ClientResourceOptions, and ClientResourceDefinitionBase types are exported for framework adapters that need to describe this surface explicitly.

3) Configure pagination once

Every feed: true leaf inherits the client-wide pagination policy. The standard cursor preset supports forward, previous, and anchored page descriptors:

import { createCursorPagination } from '@emeryld/rrroutes-client/pagination'

const pagination = createCursorPagination({
  query: {
    cursor: 'cursor',
    direction: 'direction',
    anchor: 'anchorId',
  },
  meta: {
    nextCursor: 'nextCursor',
    previousCursor: 'previousCursor',
  },
})

const client = createRouteClient({
  baseUrl: '/api',
  queryClient,
  pagination,
})

Use defineFeedPagination({...}) for a custom reusable preset. A feed can override the global policy with build option pagination: {...}, or disable inheritance with pagination: false. Flat cursor options on the feed build remain supported and take precedence for that endpoint.

4) Build individual endpoints from your registry

import { registry } from '../routes'

// Plain GET
const getUser = client.build(registry.byKey['GET /v1/users/:userId'], {
  staleTime: 30_000,
})

// Infinite/feed GET (cfg.feed === true)
const listFeed = client.build(registry.byKey['GET /v1/posts'], {
  cursorQueryKey: 'page', // defaults to "pagination_cursor"
  getNextPageCursor: (last) => last.nextCursor, // cursor extraction override
})

// Mutation
const updateUser = client.build(registry.byKey['PATCH /v1/users/:userId'], {
  onSuccess: () => client.invalidate(['get', 'v1', 'users']), // prefix invalidate
})

5) Use GET hooks (with params/query/body)

type User = Awaited<ReturnType<typeof getUser.fetch>>; // fully typed output

function Profile({ userId }: { userId: string }) {
  const result = getUser.useEndpoint({ params: { userId } });
  if (result.isLoading) return <p>Loading…</p>;
  if (result.error) return <p>Failed: {String(result.error)}</p>;

  // Register a listener for push-based updates (e.g., sockets) against this hook
  result.onReceive((freshUser) => {
    console.log('pushed update', freshUser);
  });

  return <div>{result.data.name}</div>;
}
  • For GET leaves that define a bodySchema, pass the body after the args tuple:
const auditStatus = client.build(registry.byKey['GET /v1/audit'])
await auditStatus.fetch({}, { includeExternal: true }) // body matches the leaf's bodySchema

6) Use infinite feeds

function PostFeed() {
  const feed = listFeed.useEndpoint({ query: { cursor: undefined, limit: 20 } });

  return (
    <>
      {(feed.data?.pages ?? []).map((page) =>
        page.items.map((post) => <article key={post.id}>{post.title}</article>),
      )}
      <button
        disabled={!feed.hasNextPage || feed.isFetchingNextPage}
        onClick={() => feed.fetchNextPage()}
      >
        Load more
      </button>
    </>
  );
}
  • Cursor params are stripped from cache keys automatically so pages share the same base key.
  • Infinite feeds may use object page descriptors with pageParamToQuery, getPreviousPageParam, and getNextPageParam for bidirectional or anchored pagination. Object-valued initial page parameters are appended to the query key automatically, so different anchored windows do not share cached pages. Invalidating the base feed key also invalidates those anchored windows.

7) Use mutations (with optimistic cache helpers)

async function rename(userId: string, name: string) {
  // Direct fetch (server action / non-React usage)
  await updateUser.fetch({ params: { userId } }, { name });
}

export function RenameForm({ userId }: { userId: string }) {
  const mutation = updateUser.useEndpoint({ params: { userId } });

  async function submit(e: React.FormEvent) {
    e.preventDefault();
    const name = new FormData(e.currentTarget).get('name') as string;

    // Optimistically update cache for both the detail and list
    updateUser.setData((prev) => (prev ? { ...prev, name } : prev), { params: { userId } });
    client.invalidate(['get', 'v1', 'users']);

    await mutation.mutateAsync({ name });
  }

  return (
    <form onSubmit={submit}>
      <input name="name" defaultValue="" />
      <button disabled={mutation.isLoading}>Save</button>
      {mutation.error && <p>Error: {String(mutation.error)}</p>}
    </form>
  );
}

All mutation leaves include batch methods automatically:

await updateUser.fetchMany({ params: { userId } }, [bodyA, bodyB])

const batch = updateUser.useEndpointMany(
  { params: { userId } },
  { onSuccess: (outputs, bodies) => console.log(outputs, bodies) },
)
await batch.mutateAsync([bodyA, bodyB])

useEndpointMany is one aggregate TanStack mutation with one status/error and an ordered output array. fetchMany is the imperative equivalent and does not run TanStack lifecycle callbacks.

Set singleton: true at mutation build time to share status, data, and error between hook instances with the same route params/query:

const requestLoginCode = client.build(loginCodeLeaf, { singleton: true })

Bodies are not part of the singleton key. Use singleton state for a logical route action whose UI should agree across multiple components, not as a general mutation-result cache.

8) Cache keys, invalidation, and manual cache writes

const keys = getUser.getQueryKeys({ params: { userId: 'u_1' } }) // ['get','v1','users','u_1', {}]
await getUser.invalidate({ params: { userId: 'u_1' } }) // invalidate exact detail
await client.invalidate(['get', 'v1', 'users']) // invalidate any users endpoints

getUser.setData((prev) => (prev ? { ...prev, status: 'online' } : prev), {
  params: { userId: 'u_1' },
})

setData respects feeds (updates InfiniteData shape when cfg.feed === true).

9) Router helper (build by name instead of leaf)

import { buildRouter } from '@emeryld/rrroutes-client'
import { registry } from '../routes'

const routes = {
  listUsers: registry.byKey['GET /v1/users'],
  updateUser: registry.byKey['PATCH /v1/users/:userId'],
} as const

const buildRoute = buildRouter(client, routes)

const listUsers = buildRoute('listUsers') // builds from routes.listUsers
const updateUser = buildRoute('updateUser', {}, { name: 'profile' }) // debug name filtering

10) Batch multiple built endpoints (buildBranch)

buildBranch lets you batch already-built endpoints behind one batch path.

const getUser = client.build(registry.byKey['GET /v1/users/:userId'])
const updateUser = client.build(registry.byKey['PATCH /v1/users/:userId'])

const userBatch = client.buildBranch(
  { getUser, updateUser },
  { path: '/v1/batch', method: 'post' }, // method defaults to POST
)

const result = await userBatch.fetch({
  getUser: {
    args: { params: { userId: 'u_1' } },
  },
  updateUser: {
    args: { params: { userId: 'u_1' } },
    body: { name: 'Emery' }, // required for mutation leaves
  },
})

// result keys map back to your aliases:
// { getUser: ..., updateUser: ... }

The request body sent to /v1/batch is keyed by URL-encoded leaf keys:

{
  [encodeURIComponent('GET /v1/users/:userId')]: { params: { userId: 'u_1' } },
  [encodeURIComponent('PATCH /v1/users/:userId')]: {
    params: { userId: 'u_1' },
    body: { name: 'Emery' },
  },
}

buildBranch also provides:

  • useEndpoint(input, options?) for a single React Query hook over the batched response.
  • getQueryKeys(input?) to derive per-alias keys.
  • invalidate(input?) to invalidate each leaf alias exactly.
  • setData(input) to write cache per alias.

11) File uploads (FormData)

If a leaf has bodyFiles set in its contract, the client automatically converts the body to FormData when at least one declared file field contains a file. Calls without an actual file remain JSON requests. For each declared file field name, pass files using file${name} in the input body. The raw field name is also accepted for backward compatibility.

const uploadAvatar = client.build(
  registry.byKey['PUT /v1/users/:userId/avatar'],
)

await uploadAvatar.fetch(
  { params: { userId: 'u_1' } },
  {
    // bodyFiles: [{ name: 'avatar', maxCount: 1 }]
    fileavatar: new File([blob], 'avatar.png', { type: 'image/png' }),
    // any non-file body fields still go here and are validated by bodySchema
    note: 'profile image',
  },
)

For multi-file fields (maxCount > 1), pass Blob[] or FileList:

await uploadAvatar.fetch(
  { params: { userId: 'u_1' } },
  { filephotos: [fileA, fileB] }, // bodyFiles: [{ name: 'photos', maxCount: 5 }]
)

12) Debug logging

const client = createRouteClient({
  baseUrl: '/api',
  queryClient,
  debug: {
    build: true,
    fetch: true,
    invalidate: true,
    setData: true,
    useEndpoint: true,
    verbose: true,
    // Limit to specific endpoints by name (third arg to build)
    only: ['profile', 'feed'],
    logger: (e) => console.info('[rrroutes-client]', e),
  },
})

const profile = client.build(
  registry.byKey['GET /v1/me'],
  {},
  { name: 'profile' },
)

Inspection tools can observe the same stream without enabling console logging:

const unsubscribe = client.subscribeDebug((event) => {
  // fetch events share a requestId across start/fetched/parsed/error stages
  diagnostics.push(event)
})

Set environment: 'production' to silence all debug output regardless of the debug option.


Socket utilities (optional)

The package also ships a typed Socket.IO client, React provider hooks, and a helper to merge socket events into React Query caches.

Define events + config

import { z } from 'zod'
import { defineSocketEvents } from '@emeryld/rrroutes-contract'

const { events, config } = defineSocketEvents(
  {
    joinMetaMessage: z.object({ source: z.string().optional() }),
    leaveMetaMessage: z.object({ source: z.string().optional() }),
    pingPayload: z.object({
      clientEcho: z.object({ sentAt: z.string() }).optional(),
    }),
    pongPayload: z.object({
      clientEcho: z.object({ sentAt: z.string() }).optional(),
      sinceMs: z.number().optional(),
    }),
  },
  {
    'chat:message': {
      message: z.object({
        roomId: z.string(),
        text: z.string(),
        userId: z.string(),
      }),
    },
  },
)

Vanilla SocketClient

import { io } from 'socket.io-client'
import { SocketClient } from '@emeryld/rrroutes-client'
import { events, config } from './socketContract'

const socket = io('https://socket.example.com', { transports: ['websocket'] })

const client = new SocketClient(events, {
  socket,
  config,
  sys: {
    'sys:ping': async () => ({
      clientEcho: { sentAt: new Date().toISOString() },
    }),
    'sys:pong': async ({ payload }) => {
      console.log('pong latency', payload.sinceMs)
    },
    'sys:room_join': async ({ rooms }) => {
      console.log('joining rooms', rooms)
      return true // allow join
    },
    'sys:room_leave': async ({ rooms }) => {
      console.log('leaving rooms', rooms)
      return true
    },
  },
  heartbeat: { intervalMs: 15_000, timeoutMs: 7_500 },
  debug: { receive: true, emit: true, verbose: true, logger: console.log },
})

client.on('chat:message', (payload, meta) => {
  console.log('socket message', payload.text, 'latency', meta?.ctx?.latencyMs)
})

void client.emit('chat:message', {
  roomId: 'general',
  text: 'hi',
  userId: 'u_1',
})

Key methods: emit, on, joinRooms / leaveRooms, startHeartbeat / stopHeartbeat, connect / disconnect, stats(), destroy().

React provider + hooks

import { buildSocketProvider } from '@emeryld/rrroutes-client';
import { io } from 'socket.io-client';
import { events, config } from './socketContract';

const { SocketProvider, useSocketClient, useSocketConnection } = buildSocketProvider({
  events,
  options: {
    config,
    sys: {
      'sys:ping': async () => ({ clientEcho: { sentAt: new Date().toISOString() } }),
      'sys:pong': async () => {},
      'sys:room_join': async () => true,
      'sys:room_leave': async () => true,
    },
    heartbeat: { intervalMs: 10_000 },
    debug: { receive: true, hook: true, logger: console.log },
  },
});

function App({ children }: { children: React.ReactNode }) {
  return (
    <SocketProvider
      getSocket={() => io('https://socket.example.com')}
      destroyLeaveMeta={{ source: 'app:unmount' }}
      fallback={<p>Connecting…</p>}
    >
      {children}
    </SocketProvider>
  );
}

function RoomMessages({ roomId }: { roomId: string }) {
  const client = useSocketClient<typeof events, typeof config>();

  useSocketConnection({
    event: 'chat:message',
    rooms: roomId,
    joinMeta: { source: 'room-hydration' },
    leaveMeta: { source: 'room-hydration' },
    onMessage: (payload) => console.log('message for room', payload.text),
  });

  return (
    <button onClick={() => client.emit('chat:message', { roomId, text: 'ping', userId: 'me' })}>
      Send
    </button>
  );
}

Socket + React Query: withSocketRoute

Automatically join rooms based on fetched data and patch the cache when socket messages arrive.

import { withSocketRoute } from '@emeryld/rrroutes-client/socket/react';
import { useSocketClient } from './socketProvider';

const listRooms = client.build(registry.byKey['GET /v1/rooms'], { staleTime: 120_000 });
const useAppSocketContext = () => ({ workspaceId: 'acme' });

const socketedRooms = withSocketRoute(listRooms, {
  useContext: useAppSocketContext, // optional app-level context for hooks
  toRooms: ({ data, meta }) => ({
    rooms: data.items.map((r) => `${meta?.appContext?.workspaceId}:${r.id}`), // derive rooms from data (feeds supported)
    joinMeta: { source: 'rooms:list' },
    leaveMeta: { source: 'rooms:list' },
  }),
  useSocketClient,
  applySocket: {
    'chat:message': ({ prev, payload, args, meta }) => {
      if (!prev) return null; // explicit no-op update
      console.debug('socket patch args', args, meta.appContext?.workspaceId, meta.ctx?.socketId);
      // Example: bump unread count in cache
      const apply = (items: any[]) =>
        items.map((room) =>
          room.id === payload.roomId ? { ...room, unread: (room.unread ?? 0) + 1 } : room,
        );
      return 'pages' in prev
        ? { ...prev, pages: prev.pages.map((p) => ({ ...p, items: apply(p.items) })) }
        : { ...prev, items: apply(prev.items) };
    },
  },
});

function RoomList() {
  const { data, rooms } = socketedRooms.useEndpoint();
  return (
    <>
      <p>Subscribed rooms: {rooms.join(', ')}</p>
      <ul>{data?.items.map((r) => <li key={r.id}>{r.name}</li>)}</ul>
    </>
  );
}

withSocketRoute(...) returns a built endpoint object with a socket-aware useEndpoint() plus the original built helpers. It is the recommended endpoint-first composition form. buildSocketedRoute({ built, ...options }) remains the equivalent lower-level object form.

  • toRooms receives { data, args, meta } where meta.appContext comes from useContext (if provided).
  • applySocket handlers receive { prev, payload, args, meta }, where meta contains envelope?, ctx?, and appContext?.
  • Return null from applySocket to skip cache updates.

applySocket functions are pure cache reducers, not subscriptions. They need the wrapper because room membership, listeners, and cleanup follow the React endpoint hook lifecycle. For application-global events that do not belong to a route cache or room lifecycle, subscribe directly with SocketClient.on(...) or the provider's useSocketEvent hook.

Built-in socket cache reducers

RRRoutes owns the reusable immutable cache mechanics for array and infinite feed responses. Applications only decide item identity, query membership, rooms, and how domain events map to the conventional socketDelete tombstone.

import { applyFeedUpsert } from '@emeryld/rrroutes-client/socket/cache'
import { withSocketRoute } from '@emeryld/rrroutes-client/socket/react'

const socketedMessages = withSocketRoute(messages, {
  useSocketClient,
  toRooms: ({ args }) => ({ rooms: [`chat:${args?.params.chatId}`] }),
  applySocket: {
    'message:upsert': ({ prev, payload, args }) =>
      applyFeedUpsert(
        {
          prev,
          payload: {
            ...payload,
            socketDelete: payload.operation === 'delete',
          },
          args,
        },
        {
          getId: (message) => message.id,
          matchQuery: (message, routeArgs) =>
            message.chatId === routeArgs?.params.chatId,
          insertionEdge: 'previous',
        },
      ),
  },
})

applyFeedUpsert updates existing items across pages, removes tombstones, maintains numeric meta.total, and avoids inserting into incomplete anchored windows. applyArrayUpsert provides the same update/delete behavior for plain { out: [] } responses. Both return null when the cache should retain its current reference. Custom pagination metadata can provide isEdgeLoaded and adjustTotal callbacks.


Edge cases & notes

  • Mutation fetch requires a body argument; GET fetch only requires a body when the leaf defines one.
  • Path and query params are validated with the contract schemas before fetch; missing params throw synchronously.
  • Query objects that contain arrays/objects are JSON-stringified in the URL query string.
  • Feed cache keys omit the cursor so invalidate(['get','v1','posts']) clears all pages.
  • setData runs your updater against the current cache value; return undefined to leave the cache untouched.
  • When environment is 'production', debug logs are disabled even if debug is set.

Full-stack guide

This package's chapters of the full-stack guide. Group 2 is the definition web and native share; group 3 starts the web app by supplying it with a transport.

Feeds are cursor-paginated, bidirectional, and can open anchored on a specific item. createCursorPagination is the preset for exactly that shape; naming its query fields to match the contract's queryExtensionSchema is what makes the wiring disappear. Set on the route client, every feed: true leaf inherits it.

import { createCursorPagination } from '@emeryld/rrroutes-client/pagination'

/**
 * Field names on both sides of the wire. The request keys match
 * `FeedPagination` in the contract; the response keys match the `meta`
 * `exposeRRRoutesResource` sends back by default.
 */
export const feedPagination = createCursorPagination({
  query: { cursor: 'cursor', direction: 'direction', anchor: 'anchorItemId' },
  meta: { nextCursor: 'nextCursor', previousCursor: 'previousCursor' },
})

Note — Per-endpoint override A feed with a different cursor contract passes its own preset as the endpoint's pagination build option, or false to opt out of the inherited one entirely.

Reference@emeryld/rrroutes-client/pagination

The adapter is the whole platform boundary. A route client to build endpoints with, a hook that returns the socket client, and anything else your composed endpoints need. Web and native each satisfy it their own way; the resource below never learns which one it got.

import type { ClientBindingAdapter } from '@emeryld/rrroutes-client/bindings'
import type { SocketClient } from '@emeryld/rrroutes-client/socket/react'

import type { PostEvents } from '../contract/posts.events'

export interface AppAdapter extends ClientBindingAdapter<Error> {
  /** Reads the socket client out of the provider mounted by each app. */
  useSocketClient: () => SocketClient<PostEvents>
  /** Where the app is running. Used for cache sizing, not for behavior. */
  platform: 'web' | 'native'
}

Note — The optional finalize ClientBindingAdapter also accepts a finalize(endpoint) hook, run once per endpoint after build and before any compose. It is where a platform-wide concern — request logging, an offline queue — is attached without touching the resource definition.

Reference@emeryld/rrroutes-client/bindings

This is the centre of the client story. defaults sets TanStack options per public endpoint name. compose wraps individual endpoints — here with withSocketRoute, which binds room membership and cache reducers to the query cache entry rather than to a component. A post screen and a timeline both mounted on the same post share one subscription, and a post updated while nothing is rendering still lands in the cache.

import { createClientBindings } from '@emeryld/rrroutes-client/bindings'
import { applyFeedUpsert } from '@emeryld/rrroutes-client/socket/cache'
import { withSocketRoute } from '@emeryld/rrroutes-client/socket/react'

import { postsResource } from '../contract/posts.resource'
import type { AppAdapter } from './adapter'

const defineClientResource = createClientBindings<Error, AppAdapter>()

export const createPostsClient = defineClientResource({
  resource: postsResource,
  name: 'posts',

  defaults: {
    getPost: { staleTime: 60_000, gcTime: 30 * 60_000 },
    listPosts: {
      gcTime: 5 * 60_000,
      feed: { getItemId: (post) => post.id },
    },
  },

  compose: {
    /** One post: join its room, patch it in place when it changes. */
    getPost: (endpoint, { adapter }) =>
      withSocketRoute(endpoint, {
        useSocketClient: adapter.useSocketClient,
        toRooms: ({ data }) => ({
          rooms: data?.out ? [`post:${data.out.id}`] : [],
        }),
        applySocket: {
          'post:updated': ({ prev, payload }) =>
            prev?.out.id === payload.id
              ? { ...prev, out: { ...prev.out, ...payload } }
              : null,
        },
      }),

    /** The feed: one room for the whole list, reducers for all three events. */
    listPosts: (endpoint, { adapter }) =>
      withSocketRoute(endpoint, {
        useSocketClient: adapter.useSocketClient,
        toRooms: () => ({ rooms: ['posts:all'] }),
        applySocket: {
          'post:created': (input) =>
            applyFeedUpsert(input, {
              getId: (post) => post.id,
              // A new post belongs in this cache only if it matches the tag
              // the feed is currently filtered by.
              matchQuery: (post, args) => {
                const tag = args?.[0]?.query?.tag
                return !tag || post.tag === tag
              },
              insertionEdge: 'previous',
            }),
          'post:updated': (input) =>
            applyFeedUpsert(input, {
              getId: (post) => post.id,
              matchQuery: () => true,
            }),
          'post:deleted': (input) =>
            applyFeedUpsert(input, {
              getId: (post) => post.id,
              matchQuery: () => true,
            }),
        },
      }),
  },
})

Note — Socket lifetime follows gcTime, not mount Handlers register when the query is created and stay until it is removed from the cache. Unmounting the last observer only starts the gcTime timer; a socket event arriving in that window updates the cache and does not extend the deadline. Remounting before collection reuses the existing registration — no duplicate joins.

Note — Reducers return a new reference or null null and undefined both mean "no change". Mutating prev and returning it is the one thing that will not work, because the wrapper compares references to decide whether to write.

Note — One owner per query Wrapping the same exact query with two socket routes throws rather than installing competing reducers. If two features need the same live data, they should share the endpoint — which is what putting it in this file achieves.

Note — decorate when endpoints need each other compose wraps endpoints independently. When the resource needs to return extra helpers, or an endpoint must reference a sibling, use decorate(routes, adapter, resource) instead and return whatever shape the app should consume. The two forms are mutually exclusive.

Reference@emeryld/rrroutes-client/bindings, @emeryld/rrroutes-client/socket/cache, @emeryld/rrroutes-client/socket/react

buildSocketProvider closes over the event map and every option except the socket instance itself, and returns a provider plus hooks typed to your events. Sharing the factory is what makes the heartbeat, the system events and the debug configuration identical on web and native; each app supplies only the connection, in step 3.1 and step 4.1.

import { buildSocketProvider } from '@emeryld/rrroutes-client/socket/react'
import { z } from 'zod'

import { events } from '../contract/posts.events'

export const { SocketProvider, useSocketClient, useSocketConnection } =
  buildSocketProvider<typeof events>({
    events,
    options: {
      environment:
        process.env.NODE_ENV === 'production' ? 'production' : 'development',
      heartbeat: {
        intervalMs: 15_000,
        timeoutMs: 7_500,
        onPong: ({ latencyMs }) => metrics.gauge('socket.latency', latencyMs),
      },
      sys: {
        'sys:connect': {
          sysHandler: ({ startHeartbeat, next }) => {
            startHeartbeat()
            next()
          },
        },
        'sys:disconnect': {
          sysHandler: ({ stopHeartbeat, next }) => {
            stopHeartbeat()
            next()
          },
        },
        'sys:ping': { message: z.object({ sentAt: z.string() }) },
        'sys:pong': {
          message: z.object({ sentAt: z.string(), sinceMs: z.number() }),
        },
        'sys:room_join': { message: z.object({ rooms: z.array(z.string()) }) },
        'sys:room_leave': { message: z.object({ rooms: z.array(z.string()) }) },
      },
    },
  })

Note — Rooms are reference counted Three components joining posts:all emit one sys:room_join; the third to leave emits one sys:room_leave. This is why withSocketRoute and a hand-written useSocketConnection can both ask for the same room without fighting.

Note — A null socket is safe emit, on, joinRooms and leaveRooms are all no-ops while the underlying socket is null, warning in development and silent in production. Code does not need to guard the bootstrap window.

Reference@emeryld/rrroutes-client/socket/react

Three providers, in one place. TanStack Query owns the cache, the shared SocketProvider owns the connection, and RRRoutesUIProvider owns the defaults every boundary inherits — the error grace period, the skeleton count, and the event hook a logger listens on.

import { createRouteClient } from '@emeryld/rrroutes-client/react-query'
import { RRRoutesUIProvider } from '@emeryld/rrroutes-ui'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { io } from 'socket.io-client'
import { useState, type PropsWithChildren } from 'react'

import { feedPagination } from '@app/shared/client/pagination'
import { SocketProvider } from '@app/shared/client/socket'

export const queryClient = new QueryClient()

export const routeClient = createRouteClient({
  baseUrl: '/api',
  queryClient,
  pagination: feedPagination,
  validateResponses: true,
  environment: import.meta.env.PROD ? 'production' : 'development',
  debug: { logger: console.debug },
})

export function Providers({ children }: PropsWithChildren) {
  const [socket] = useState(() =>
    io(import.meta.env.VITE_SOCKET_URL, {
      transports: ['websocket'],
      withCredentials: true,
    }),
  )

  return (
    <QueryClientProvider client={queryClient}>
      <SocketProvider socket={socket} fallback={<AppSplash />}>
        <RRRoutesUIProvider
          errorGraceMs={4_000}
          skeletonCount={4}
          slots={{
            loader: <Spinner />,
            error: ({ refetch }) => <Retry onPress={refetch} />,
          }}
          onEvent={(event) => logger.info(event.type, event)}
        >
          {children}
        </RRRoutesUIProvider>
      </SocketProvider>
    </QueryClientProvider>
  )
}

Note — Why responses are validated validateResponses parses every response against its leaf's output schema before it reaches the cache. It costs a parse per request and turns a backend that quietly changed shape into an error at the boundary that received it, rather than a crash three components deep.

Reference@emeryld/rrroutes-client/react-query, @emeryld/rrroutes-ui

Four lines. The adapter is satisfied with the web transport, and createPostsClient returns the endpoints — already socket-bound, already carrying their cache defaults, because all of that was decided in step 2.3.

import { createPostsClient } from '@app/shared/client/posts.client'
import { useSocketClient } from '@app/shared/client/socket'

import { routeClient } from './providers'

export const posts = createPostsClient({
  routeClient,
  useSocketClient,
  platform: 'web',
})

// posts.getPost      — live single post
// posts.listPosts    — live infinite feed
// posts.createPost   — mutation
// posts.updatePost   — mutation
// posts.deletePost   — mutation

Reference@emeryld/rrroutes-client/bindings

Scripts (monorepo)

Run from the repo root:

pnpm --filter @emeryld/rrroutes-client build
pnpm --filter @emeryld/rrroutes-client typecheck
pnpm --filter @emeryld/rrroutes-client test