@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.ioThis 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',
}),
})
buildCtxnow 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.registerControllersaccepts partial maps (missing routes are skipped);bindAllenforces 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: sanitizer → preCtx → resolve → ctx → postCtx → route.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()
})CtxRequestHandlerreceives{ req, res, next, ctx }with your typed ctx.route.beforehandlers now receive the same parsedparams,query, andbodypayload as the handler, alongsidereq,res, andctx.- Need post-response hooks? Register a middleware that wires
res.on('finish', handler)insideroute.before/middleware.postCtxinstead 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: trueis 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: trueparses handler return values with the leafoutputSchema. Set tofalseto skip.- Override
sendto 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 callwarnMissingControllers(...)during startup to surface missing routes. - Operator-specific middleware: attach
route.beforeper controller (e.g., role checks) and keepmiddleware.postCtxminimal (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>:errorwith 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 usesconfig.pingPayload/config.pongPayloadschemas. 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; wrapbuildCtx/middleware in try/catch if you need custom error shapes.- When
validateOutputis true and nooutputSchemaexists, raw handler output is passed through. multerOptionsruns only whenleaf.cfg.bodyFilesis a non-empty array; returnfalseto disable the upload middleware for that route.- Socket
emitwill 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