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

v2.5.3

Published

<!-- Summary: - Added full quick start for binding finalized contracts to Express with typed controllers, ctx builders, derived upload middleware, and output validation. - Documented debug options, per-route overrides, partial/complete registration helper

Readme

@emeryld/rrroutes-server

Express/Socket.IO bindings for RRRoutes contracts. Map finalized leaves to an Express router with Zod-validated params/query/body/output, typed controller maps, ctx-aware middleware, optional upload derivation, and structured debug logging. Socket helpers add validated events, heartbeat, room hooks, and lifecycle debugging.

Installation

pnpm add @emeryld/rrroutes-server express socket.io
# or
npm install @emeryld/rrroutes-server express socket.io

This package peers with @emeryld/rrroutes-contract and bundles zod.

Quick start: HTTP routes

import express from 'express'
import { finalize, resource } from '@emeryld/rrroutes-contract'
import { createRRRoute, defineControllers } from '@emeryld/rrroutes-server'
import multer from 'multer'
import { z } from 'zod'

// 1) Build & finalize contracts (usually elsewhere in your app)
const leaves = resource('/api')
  .sub(
    resource('profiles')
      .get({
        outputSchema: z.array(
          z.object({ id: z.string().uuid(), name: z.string() }),
        ),
        description: 'List profiles',
      })
      .sub(
        resource(':profileId', undefined, z.string().uuid())
          .patch({
            bodySchema: z.object({ name: z.string().min(1) }),
            outputSchema: z.object({ id: z.string().uuid(), name: z.string() }),
          })
          .sub(
            resource('avatar')
              .put({
                bodyFiles: [{ name: 'avatar', maxCount: 1 }], // derive upload middleware
                bodySchema: z.object({ avatar: z.instanceof(Blob) }),
                outputSchema: z.object({ ok: z.literal(true) }),
              })
              .done(),
          )
          .done(),
      )
      .done(),
  )
  .done()

const registry = finalize(leaves)

// 2) Wire Express with ctx + derived upload middleware
const app = express()
const server = createRRRoute(app, {
  buildCtx: async (req) => ({
    user: await loadUser(req),
    routesLogger: console,
  }), // ctx lives on res.locals[CTX_SYMBOL]
  middleware: {
    postCtx: [
      ({ ctx, next }) => {
        if (!ctx.user) throw new Error('unauthorized')
        next()
      },
    ],
  },
  multerOptions: (files) =>
    files && files.length > 0 ? { storage: multer.memoryStorage() } : undefined,
  validateOutput: true, // parse handler returns with outputSchema (default true)
  debug: {
    request: true,
    handler: true,
    verbose: true,
    logger: (e) => console.debug(e),
  },
})

// 3) Author controllers with enforced keys/types
const controllers = defineControllers<
  typeof registry,
  { user: { id: string } }
>()({
  'GET /api/profiles': {
    handler: async ({ ctx }) => {
      return fetchProfilesFor(ctx.user.id)
    },
  },
  'PATCH /api/profiles/:profileId': {
    before: [
      ({ ctx, params, next }) =>
        params.profileId === ctx.user.id
          ? next()
          : next(new Error('Forbidden')),
    ],
    handler: async ({ params, body }) => {
      return updateProfile(params.profileId, body)
    },
  },
  'PUT /api/profiles/:profileId/avatar': {
    handler: async ({ req, params }) => {
      const avatar = (req.files as any)?.avatar?.[0]
      await storeAvatar(params.profileId, avatar?.buffer)
      return { ok: true }
    },
  },
})

server.registerControllers(registry, controllers)
server.warnMissingControllers(registry, console) // warns in dev about unhandled leaves

app.listen(3000)

Detailed usage (HTTP)

Controller maps and typing

import { defineControllers, bindExpressRoutes } from '@emeryld/rrroutes-server'

const controllers = defineControllers<typeof registry, Ctx>()({
  'POST /v1/articles': {
    handler: async ({ body, ctx }) => createArticle(ctx.user.id, body),
  },
})

// register only the controllers provided (missing keys are ignored)
bindExpressRoutes(app, registry, controllers, {
  buildCtx: () => ({ user: { id: '123' } }),
})

// or enforce every key is present at compile time
bindExpressRoutes(
  app,
  registry,
  controllers as { [K in keyof typeof registry.byKey]: any },
  { buildCtx },
)

If you need access to the parsed params/query/body inside buildCtx, destructure them from the single argument:

const server = createRRRoute(app, {
  buildCtx: ({ params, query, body }) => ({
    user: lookupUser(params.id),
    verbose: query?.verbose === 'yes',
  }),
})

buildCtx now receives the { req, res, params, query, body } object; the legacy (req, res) signature is no longer supported.

  • defineControllers<Registry, Ctx>()(map) keeps literal "METHOD /path" keys accurate and infers params/query/body/output types per leaf.
  • registerControllers accepts partial maps (missing routes are skipped); bindAll enforces completeness at compile time.
  • warnMissingControllers(router, registry, logger) inspects the Express stack and warns for any leaf without a handler.

Middleware order and ctx usage

Order: sanitizerpreCtxresolvectxpostCtxroute.before → handler.

import { getCtx, CtxRequestHandler } from '@emeryld/rrroutes-server'

const audit: CtxRequestHandler<Ctx> = ({ ctx, req, next }) => {
  ctx.routesLogger?.info?.('audit', { user: ctx.user?.id, path: req.path })
  next()
}

const server = createRRRoute(app, {
  buildCtx: (req, res) => ({ user: res.locals.user, routesLogger: console }),
  middleware: { postCtx: [audit] },
})

const routeBefore = ({ params, query, body, ctx, next }) => {
  ctx.routesLogger?.debug?.('route.before payload', { params, query, body })
  next()
}

// Inside any Express middleware (even outside route.before), use getCtx to retrieve typed ctx:
app.use((req, res, next) => {
  const ctx = getCtx<Ctx>(res)
  ctx?.routesLogger?.debug?.('in arbitrary middleware')
  next()
})
  • CtxRequestHandler receives { req, res, next, ctx } with your typed ctx.
  • route.before handlers now receive the same parsed params, query, and body payload as the handler, alongside req, res, and ctx.
  • Need post-response hooks? Register a middleware that wires res.on('finish', handler) inside route.before/middleware.postCtx instead of relying on a dedicated "after" stage.

Request sanitization

Use middleware.sanitizer when you want to sanitize raw request data before RRRoutes parses params/query/body.

const server = createRRRoute(app, {
  buildCtx,
  middleware: {
    sanitizer: {
      trimStrings: true,
      customSanitizer: (value, context) => {
        if (context.target === 'query' && typeof value === 'string') {
          return value.toLowerCase()
        }
        return value
      },
    },
  },
})

By default, the sanitizer:

  • strips null bytes from strings
  • removes prototype-pollution keys (__proto__, prototype, constructor)
  • keeps whitespace unless trimStrings: true is set

blockedKeys exists to prevent prototype-pollution payloads from surviving into downstream object merges.

For full sanitizer docs/options, see ./SANITIZER.md.

Upload parsing

Routes that declare bodyFiles automatically run Multer before ctx using shared memory storage. Override or disable that behavior with multerOptions.

import multer from 'multer'
import { FileField } from '@emeryld/rrroutes-contract'

const diskStorage = multer.diskStorage({
  destination: 'tmp/uploads',
  filename: (_req, file, cb) => cb(null, `${Date.now()}-${file.originalname}`),
})

const server = createRRRoute(app, {
  buildCtx,
  multerOptions: (files: FileField[] | undefined) =>
    files?.length
      ? {
          storage: diskStorage,
          limits: { fileSize: 5 * 1024 * 1024 },
        }
      : false,
})

Return false from multerOptions when you want to skip Multer for a specific route even if bodyFiles are declared.

Output validation and custom responders

  • validateOutput: true parses handler return values with the leaf outputSchema. Set to false to skip.
  • Override send to change response behavior (e.g., res.status(201).json(data)).
const server = createRRRoute(app, {
  buildCtx,
  send: (res, data) => res.status(201).json({ data }),
})

Debug logging

Global debug options:

const server = createRRRoute(app, {
  buildCtx,
  debug: {
    request: true, // register/request/handler/buildCtx event toggles
    handler: true,
    verbose: true, // include params/query/body/output/errors
    only: ['users:list'], // filter by RouteDef.debug?.debugName
    logger: (event) => console.log('[route-debug]', event),
  },
})

Per-route overrides:

server.register(registry.byKey['GET /api/profiles'], {
  debug: { handler: true, debugName: 'profiles:list' },
  handler: async () => [],
})

Context logger passthrough: if buildCtx provides routesLogger, handler debug events also flow to that logger (useful for request-scoped loggers).

Recipes

  • Combine registries: build leaves per domain, spread before finalize([...usersLeaves, ...projectsLeaves]), then register once.
  • Fail fast on missing controllers: use bindAll(...) for compile-time coverage or call warnMissingControllers(...) during startup to surface missing routes.
  • Operator-specific middleware: attach route.before per controller (e.g., role checks) and keep middleware.postCtx minimal (auth/session parsing).

Socket server (typed events, heartbeat, rooms)

@emeryld/rrroutes-server also ships a typed Socket.IO wrapper that pairs with defineSocketEvents from the contract package.

import { Server } from 'socket.io'
import { defineSocketEvents } from '@emeryld/rrroutes-contract'
import {
  createSocketConnections,
  createConnectionLoggingMiddleware,
} from '@emeryld/rrroutes-server'
import { z } from 'zod'

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

const io = new Server(3000, { cors: { origin: '*', credentials: true } })
io.use(createConnectionLoggingMiddleware({ includeHeaders: false }))

const sockets = createSocketConnections(io, events, {
  config,
  heartbeat: { enabled: true }, // enables sys:ping/sys:pong using config schemas
  sys: {
    'sys:connect': async ({ socket, complete }) => {
      socket.data.user = await loadUserFromHandshake(socket.handshake)
      await complete() // attach built-ins (ping/pong, join/leave)
    },
    'sys:ping': async ({ socket, ping }) => ({
      sentAt: ping.sentAt,
      sinceMs: Date.now() - Date.parse(ping.sentAt),
    }),
  },
  debug: {
    register: true,
    handler: true,
    emit: true,
    verbose: true,
    logger: (e) => console.debug('[socket-debug]', e),
  },
})

// Validate inbound payloads + emit envelopes
sockets.on('chat:message', async (payload, ctx) => {
  await saveMessage(payload, ctx.user)
  // broadcast to room participants
  sockets.emit('chat:message', payload, payload.roomId)
})

// Graceful shutdown
process.on('SIGTERM', () => sockets.destroy())
  • Payloads are validated on both emit and receive; invalid payloads trigger <event>:error with Zod issues.
  • Built-in system events: sys:connect, sys:disconnect, sys:ping, sys:pong, sys:room_join, sys:room_leave.
  • Heartbeat is enabled by default (heartbeat.enabled !== false) and uses config.pingPayload / config.pongPayload schemas.
  • destroy() removes listeners, room handlers, and connection hooks—safe for test teardown.

Edge cases and notes

  • Post-response work should hook into res.on('finish', handler) from a middleware in the normal pipeline if you need to observe completed responses.
  • compilePath/param parsing exceptions bubble to Express error handlers; wrap buildCtx/middleware in try/catch if you need custom error shapes.
  • When validateOutput is true and no outputSchema exists, raw handler output is passed through.
  • multerOptions runs only when leaf.cfg.bodyFiles is a non-empty array; return false to disable the upload middleware for that route.
  • Socket emit will throw on invalid payloads; handle errors around broadcast loops.

Scripts

Run from repo root:

pnpm --filter @emeryld/rrroutes-server build    # tsup + d.ts
pnpm --filter @emeryld/rrroutes-server typecheck
pnpm --filter @emeryld/rrroutes-server test