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 🙏

© 2025 – Pkg Stats / Ryan Hefner

@emeryld/rrroutes-client

v2.4.1

Published

<!-- Summary: - Added comprehensive usage for `createRouteClient`, built endpoints (GET/feeds/mutations), cache helpers, invalidation, debug modes, custom fetchers, and React Query integration. - Added new sections for router helpers, infinite/feeds, Form

Downloads

8,202

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 @tanstack/react-query
# or
npm install @emeryld/rrroutes-client @tanstack/react-query

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

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.
  • build(leaf, options?, meta?) returns a helper that exposes:
    • useEndpoint(args?) — React hook for GET/feeds/mutations (typed params/query/body/output).
    • 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.

Detailed usage

1) Configure the client

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

2) Build 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'], {
  cursorParam: 'page', // defaults to "pagination_cursor"
  getNextPageParam: (last) => last.nextCursor, // React Query option override
})

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

3) 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

4) 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.

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

6) 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).

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

8) File uploads (FormData)

If a leaf has bodyFiles set in its contract, the client automatically converts the body to FormData:

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

await uploadAvatar.fetch(
  { params: { userId: 'u_1' } },
  { avatar: new File([blob], 'avatar.png', { type: 'image/png' }) },
)

9) 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' },
)

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, { ctx }) => {
  console.log('socket message', payload.text, 'latency', 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: buildSocketedRoute

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

import { buildSocketedRoute } from '@emeryld/rrroutes-client';
import { useSocketClient } from './socketProvider';

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

const useSocketedRooms = buildSocketedRoute({
  built: listRooms,
  toRooms: (page) => ({
    rooms: page.items.map((r) => r.id), // derive rooms from data (feeds supported)
    joinMeta: { source: 'rooms:list' },
    leaveMeta: { source: 'rooms:list' },
  }),
  useSocketClient,
  applySocket: {
    'chat:message': (prev, payload) => {
      if (!prev) return prev;
      // 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 } = useSocketedRooms();
  return (
    <>
      <p>Subscribed rooms: {rooms.join(', ')}</p>
      <ul>{data?.items.map((r) => <li key={r.id}>{r.name}</li>)}</ul>
    </>
  );
}

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.

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