New Research: Supply Chain Attack on Axios Pulls Malicious Dependency from npm.Details →
Socket
Book a DemoSign in
Socket

@emeryld/rrroutes-server

Package Overview
Dependencies
Maintainers
1
Versions
106
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@emeryld/rrroutes-server

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

latest
Source
npmnpm
Version
2.6.9
Version published
Maintainers
1
Created
Source

@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.

Batch endpoint helper (batchLeaf)

Use batchLeaf to register one endpoint that dispatches multiple already-registered controllers by alias, while each entry carries its encoded route key.

import { batchLeaf } from '@emeryld/rrroutes-server'

const server = createRRRoute(app, { buildCtx })
server.registerControllers(registry, controllers)

// Defaults to POST. You can override with { method: 'put' } etc.
batchLeaf(server, '/v1/batch', registry)

Client request body shape:

{
  getUser: {
    encodedLeaf: encodeURIComponent('GET /v1/users/:userId'),
    params: { userId: 'u_1' },
  },
  updateUser: {
    encodedLeaf: encodeURIComponent('PATCH /v1/users/:userId'),
    params: { userId: 'u_1' },
    body: { name: 'Emery' },
  },
}

Response shape (same alias keys):

{
  getUser: { out: { ... }, meta: ... },
  updateUser: { out: { ... }, meta: ... },
}

Notes:

  • Register controllers before calling batchLeaf(...); unknown keys fail at runtime.
  • Dispatch uses server.invoke(...) in parallel for each entry, so each sub-call runs per-leaf parsing, buildCtx, route.before, handler execution, and output validation.
  • Batch dispatch does not replay the full global middleware chain of the original route registration (sanitizer, preCtx, postCtx, Multer).

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 blocked keys (__proto__, prototype, constructor) unless stripBlockedKeys: false
  • keeps whitespace unless trimStrings: true is set
  • can log per-target timings when profiler: true

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

FAQs

Package last updated on 20 Mar 2026

Did you know?

Socket

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Install

Related posts