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

v2.8.0

Published

TypeScript contract definitions for RRRoutes

Readme

@emeryld/rrroutes-contract

Type-safe contract toolkit for RRRoutes. Ship the HTTP route DSL (resource + withCrud), registry/finalization helpers, cache-key builder used by the client package, and shared socket event contracts.

Looking for the docs/playground UI? It now lives in @emeryld/rrroutes-openapi.

Installation

pnpm add @emeryld/rrroutes-contract
# or
npm install @emeryld/rrroutes-contract

zod ships as a dependency—nothing extra to install.

Quick start (build + consume a registry)

import {
  buildCacheKey,
  compilePath,
  finalize,
  InferOutput,
  InferParams,
  InferQuery,
  resource,
} from '@emeryld/rrroutes-contract'
import { z } from 'zod'

// 1) Describe your API
const leaves = resource('/v1')
  .sub(
    resource('users')
      .get({
        querySchema: z.object({
          search: z.string().optional(),
          limit: z.coerce.number().min(1).max(50).default(20),
        }),
        outputSchema: z.array(
          z.object({ id: z.string().uuid(), email: z.string().email() }),
        ),
        description: 'Find users',
      })
      .sub(
        resource(':userId', undefined, z.string().uuid())
          .patch({
            bodySchema: z.object({ name: z.string().min(1) }),
            outputSchema: z.object({ ok: z.literal(true) }),
          })
          .done(),
      )
      .done(),
  )
  .done()

// 2) Freeze it into a registry for typed lookups
export const registry = finalize(leaves)

// 3) Consume a leaf with full types
const leaf = registry.byKey['PATCH /v1/users/:userId']
type Params = InferParams<typeof leaf> // { userId: string }
type Query = InferQuery<typeof leaf> // never (no query)
type Output = InferOutput<typeof leaf> // { ok: true }

// 4) Build URLs + cache keys (React Query friendly)
const url = compilePath(leaf.path, {
  userId: 'f2b2e72a-7f6d-4c3f-9c6f-7f0d8f3ac9e2',
})
const key = buildCacheKey({
  leaf,
  params: { userId: 'f2b2e72a-7f6d-4c3f-9c6f-7f0d8f3ac9e2' },
})
// key => ['patch', 'v1', 'users', ['f2b2e72a-7f6d-4c3f-9c6f-7f0d8f3ac9e2'], {}]

Detailed usage

Fluent route builder

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

const leaves = resource('/api')
  .sub(
    resource('projects')
      .get({
        feed: true, // infinite/feed for clients
        querySchema: z.object({
          cursor: z.string().optional(),
          limit: z.coerce.number().default(25),
        }),
        outputSchema: z.object({
          items: z.array(z.object({ id: z.string(), name: z.string() })),
          nextCursor: z.string().optional(),
        }),
      })
      .post({
        bodySchema: z.object({ name: z.string().min(1) }),
        outputSchema: z.object({ id: z.string(), name: z.string() }),
        description: 'Create a project',
      })
      .sub(
        resource(':projectId', undefined, z.string().uuid())
          .get({ outputSchema: z.object({ id: z.string(), name: z.string() }) })
          .patch({
            bodySchema: z.object({ name: z.string().min(1) }),
            outputSchema: z.object({ id: z.string(), name: z.string() }),
          })
          .sub(
            resource('avatar')
              .put({
                bodyFiles: [{ name: 'avatar', maxCount: 1 }], // signals multipart upload
                // bodySchema is for non-file fields only
                bodySchema: z.object({ note: z.string().optional() }),
                outputSchema: z.object({ ok: z.literal(true) }),
              })
              .done(),
          )
          .done(),
      )
      .done(),
  )
  .done()
  • resource(segment, nodeCfg?, idSchema?) scopes a branch. Pass a segment name (e.g. 'projects', ':projectId') plus optional per-node config. idSchema is strict-mode: only valid with a dynamic base segment (':param' or '*param') and required for those dynamic segments. Dynamic segments are only allowed in the first segment of resource(...).
  • sub(childA, childB, ...) mounts one or more child resources built elsewhere via resource(...).get(...).done(). Call it once per branch; pass multiple children at once when needed.
  • Methods (get/post/put/patch/delete) merge the active param schema unless you override via paramsSchema.
  • done() closes a branch and returns the collected readonly tuple of leaves.

Registry helpers, URL building, and typing

import {
  buildCacheKey,
  compilePath,
  finalize,
  InferBody,
  InferOutput,
  SubsetRoutes,
} from '@emeryld/rrroutes-contract'

const registry = finalize(leaves)
const leaf = registry.byKey['PATCH /api/projects/:projectId']

// TypeScript helpers
type Body = InferBody<typeof leaf> // { name: string }
type Output = InferOutput<typeof leaf> // { id: string; name: string }

// Runtime helpers
const url = compilePath(leaf.path, { projectId: '123' }) // "/api/projects/123"
const cacheKey = buildCacheKey({ leaf, params: { projectId: '123' } })
// cacheKey => ['patch', 'api', 'projects', ['123'], {}]

// Typed subsets for routers/microfrontends
type ProjectRoutes = SubsetRoutes<typeof registry.all, '/api/projects'>
  • finalize(leaves) freezes the tuple and provides byKey['METHOD /path'], all, and log(logger).
  • compilePath throws if required params are missing; wrap user-provided values in try/catch.
  • buildCacheKey produces the deterministic tuple the client package uses for React Query; reuse it for manual invalidation.

CRUD helper (withCrud / resourceWithCrud)

import {
  CrudDefaultPagination,
  finalize,
  resource,
  withCrud,
} from '@emeryld/rrroutes-contract'
import { z } from 'zod'

const r = withCrud(resource('/v1'))

const leaves = r
  .crud(
    'articles',
    {
      paramSchema: z.string().uuid(), // value schema; becomes :articlesId
      itemOutputSchema: z.object({
        id: z.string().uuid(),
        title: z.string(),
        body: z.string(),
      }),
      list: { querySchema: CrudDefaultPagination },
      create: { bodySchema: z.object({ title: z.string(), body: z.string() }) },
      update: {
        bodySchema: z.object({
          title: z.string().optional(),
          body: z.string().optional(),
        }),
      },
      enable: { remove: false }, // opt out of DELETE
    },
    ({ collection }) =>
      collection
        .sub(
          resource('stats')
            .get({
              outputSchema: z.object({ total: z.number() }),
              description: 'Extra endpoint alongside CRUD',
            })
            .done(),
        )
        .done(),
  )
  .done()

const registry = finalize(leaves)
// registry.byKey now includes the CRUD + extras routes with full types
  • Generated routes (unless disabled): GET feed list, POST create (requires create.bodySchema), GET item, PATCH update (requires update.bodySchema), DELETE remove.
  • Defaults: list output { items: Item[], nextCursor?: string }, remove output { ok: true }.
  • Pass paramSchema as a value schema; a compatible z.object({ <name>Id: schema }) also works at runtime.
  • resourceWithCrud('/v1', {}) is a convenience wrapper if you want the .crud method available immediately.

Socket event contracts

Share a typed event map between client and server.

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

const { config, events } = defineSocketEvents(
  {
    joinMetaMessage: z.object({ room: z.string() }),
    leaveMetaMessage: z.object({ room: z.string() }),
    pingPayload: z.object({ clientEcho: z.object({ sentAt: z.string() }) }),
    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(),
      }),
    },
    'typing:update': {
      message: z.object({
        roomId: z.string(),
        userId: z.string(),
        typing: z.boolean(),
      }),
    },
  },
)

// Typed payload extraction
type ChatPayload = Payload<typeof events, 'chat:message'>
// ChatPayload -> { roomId: string; text: string; userId: string }

// Server-side guard example
function onChatMessage(raw: unknown) {
  const parsed = events['chat:message'].message.parse(raw)
  // parsed is strongly typed; safe to broadcast
}

config mirrors the system events used by the socket client/server packages; events holds your app-specific payload schemas.

Common patterns/recipes

  • Module-per-area: export leaf tuples per domain (usersLeaves, projectsLeaves), then spread before finalize([...usersLeaves, ...projectsLeaves]).
  • Shared defaults: wrap resource('/api') in your own helper that immediately calls .with(...) for cross-cutting flags you add via declaration merging (e.g., auth tags, tracing hints).
  • React Query integration: use buildCacheKey + queryClient.invalidateQueries({ queryKey }) to keep caches in sync, or rely on the client package’s helpers which wrap the same logic.
  • Error handling at boundaries: catch compilePath errors when interpolating user-provided params; surface a 400 instead of crashing your handler.

Edge cases and notes

  • paramsSchema on a method overrides the merged schema from parent segments—useful when you need stricter validation per verb.
  • bodyFiles marks a route as multipart; servers can attach upload middleware, and clients should send FormData.
  • Client input keys for files are file${name} (and ${name} remains accepted for backward compatibility). Example: bodyFiles: [{ name: 'avatar', maxCount: 1 }] -> { fileavatar: File }.
  • Keep file fields out of bodySchema; use bodySchema for non-file fields (e.g. captions, notes, flags).
  • CRUD helper only emits create/update routes when the matching bodySchema is provided; delete can be disabled via enable.remove: false.
  • Feed-only behavior (feed: true) is intended for GET endpoints; clients treat them as infinite queries.

Scripts (monorepo)

Run from the repo root:

pnpm --filter @emeryld/rrroutes-contract build    # tsup + d.ts
pnpm --filter @emeryld/rrroutes-contract typecheck
pnpm --filter @emeryld/rrroutes-contract test     # optional Jest suite

Finalized Leaves Export (JSON)

You can export finalized leaves (plus flattened schema paths) to strict JSON.

What --module means

--module is the path to a JS/TS module file that exports your leaves or registry.

  • The file can export:
    • a finalized registry (finalize(leaves))
    • or a leaf array/tuple (the output of .done())
  • Use --export to select which exported symbol to read from that module.

CLI usage

From the repo root:

pnpm --filter @emeryld/rrroutes-export export:finalized-leaves -- \
  --module ./path/to/contract-module.ts \
  --export registry \
  --out ./finalized-leaves.export.json \
  --with-source

Arguments:

  • --module required path to the module that exports your data.
  • --export optional export name (default: leaves).
  • --out optional output file path (default: finalized-leaves.export.json).
  • --with-source optional flag to enrich leaves with AST definition/schema source metadata.
  • --tsconfig optional tsconfig path used for AST analysis (default: first tsconfig.json found from cwd).

Published package CLI (no ts-node wiring needed):

npx rrroutes-export-finalized-leaves \
  --module ./path/to/contract-module.ts \
  --export registry \
  --out ./finalized-leaves.export.json \
  --with-source

Example module shapes

Registry export:

import { finalize, resource } from '@emeryld/rrroutes-contract'
import { z } from 'zod'

const leaves = resource('/v1')
  .get({ outputSchema: z.object({ ok: z.literal(true) }) })
  .done()

export const registry = finalize(leaves)

Leaves export:

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

export const leaves = resource('/v1')
  .get({ outputSchema: z.object({ ok: z.literal(true) }) })
  .done()

Runtime API

If you want to run this in code instead of CLI:

import { exportFinalizedLeaves } from '@emeryld/rrroutes-export'

const payload = await exportFinalizedLeaves(registry, {
  outFile: './finalized-leaves.export.json',
  htmlFile: './finalized-leaves-viewer.baked.html',
  openOnFinish: true,
  includeSource: true,
  sourceModulePath: './path/to/contract-module.ts',
  sourceExportName: 'registry',
  tsconfigPath: './tsconfig.json',
})

payload contains:

  • _meta: export/documentation metadata
    • when source extraction is enabled, _meta.sourceExtraction also includes diagnostics (reason, stats)
  • leaves: contract-native serialized leaves
  • schemaFlatByLeaf: flattened schema map per leaf
  • sourceByLeaf (when includeSource is true): AST-derived definition + schema source metadata keyed by METHOD path

htmlFile writes a self-contained viewer HTML with the export payload baked in (no file picker needed). viewerTemplateFile optionally points to a custom viewer HTML template instead of the default bundled viewer. openOnFinish opens the generated htmlFile in your default browser after write completes.

Custom viewerTemplateFile

Use viewerTemplateFile when you want your own branded/layout HTML while still baking export data directly into the page.

Behavior:

  • If omitted, RRRoutes uses the bundled finalized-leaves-viewer.html template.
  • Resolution order: package-bundled viewer, then local repo paths (tools/..., packages/export/tools/...), then built-in string fallback.
  • If provided, RRRoutes reads your template and injects a script like:
    • window.__FINALIZED_LEAVES_PAYLOAD = {...}
  • If your template contains this marker comment, payload is injected exactly there:
    • <!--__FINALIZED_LEAVES_BAKED_PAYLOAD__-->
  • If marker is missing, payload script is inserted before </body> (or prepended if no </body> exists).

Minimal custom template example:

<!doctype html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>My Leaves Viewer</title>
  </head>
  <body>
    <h1>My API Routes</h1>
    <div id="app"></div>

    <!--__FINALIZED_LEAVES_BAKED_PAYLOAD__-->

    <script>
      const payload = window.__FINALIZED_LEAVES_PAYLOAD
      document.getElementById('app').textContent = payload
        ? `Loaded ${payload.leaves.length} leaves`
        : 'No baked payload found'
    </script>
  </body>
</html>

Runtime usage with custom template:

await exportFinalizedLeaves(registry, {
  htmlFile: './dist/leaves-viewer.html',
  viewerTemplateFile: './tools/my-viewer-template.html',
  openOnFinish: true,
})

Viewer HTML (searchable UI)

A simple local viewer is included at:

  • packages/export/tools/finalized-leaves-viewer.html

How to use:

  1. Generate an export JSON with export:finalized-leaves.
  2. Open the HTML file in your browser.
  3. Load the JSON file using the file picker.
  4. Use the search box, quick filter toggles, grouped field chips, and advanced filter panel to filter routes.

Each result is rendered as a collapsible block with title METHOD path.

To access it from your project:

  • Quick local use: open the HTML file directly.
  • Team/shared use: serve it as a static file (Express example):
import express from 'express'
import path from 'node:path'

const app = express()

app.use(
  '/tools/finalized-leaves-viewer',
  express.static(
    path.resolve(process.cwd(), 'packages/export/tools'),
  ),
)

app.listen(3000)
// open http://localhost:3000/tools/finalized-leaves-viewer/finalized-leaves-viewer.html