<!-- 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
npm install @emeryld/rrroutes-serverExpress/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.
``sh`
pnpm add @emeryld/rrroutes-server express socket.ioor
npm install @emeryld/rrroutes-server express socket.io
This package peers with @emeryld/rrroutes-contract and bundles zod.
`ts
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]
globalMiddleware: {
before: [
({ 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)
`
`ts
import { defineControllers, bindExpressRoutes } from '@emeryld/rrroutes-server'
const controllers = defineControllers
'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:
`ts`
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 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.
Order: resolve → ctx → global.before → route.before → handler.
`ts
import { getCtx, CtxRequestHandler } from '@emeryld/rrroutes-server'
const audit: CtxRequestHandler
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 }),
globalMiddleware: { before: [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?.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.res.on('finish', handler)
- Need post-response hooks? Register a middleware that wires inside route.before/global.before instead of relying on a dedicated "after" stage.
Routes that declare bodyFiles automatically run Multer before ctx using shared memory storage. Override or disable that behavior with multerOptions.
`ts
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.
- validateOutput: true parses handler return values with the leaf outputSchema. Set to false to skip.send
- Override to change response behavior (e.g., res.status(201).json(data)).
`ts`
const server = createRRRoute(app, {
buildCtx,
send: (res, data) => res.status(201).json({ data }),
})
Global debug options:
`ts`
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:
`ts`
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).
- Combine registries: build leaves per domain, spread before finalize([...usersLeaves, ...projectsLeaves]), then register once.bindAll(...)
- Fail fast on missing controllers: use for compile-time coverage or call warnMissingControllers(...) during startup to surface missing routes.route.before
- Operator-specific middleware: attach per controller (e.g., role checks) and keep global.before minimal (auth/session parsing).
@emeryld/rrroutes-server also ships a typed Socket.IO wrapper that pairs with defineSocketEvents from the contract package.
`ts
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 with Zod issues.sys:connect
- Built-in system events: , sys:disconnect, sys:ping, sys:pong, sys:room_join, sys:room_leave.heartbeat.enabled !== false
- Heartbeat is enabled by default () and uses config.pingPayload / config.pongPayload schemas.destroy()
- removes listeners, room handlers, and connection hooks—safe for test teardown.
- 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.validateOutput
- When 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.emit
- Socket will throw on invalid payloads; handle errors around broadcast loops.
Run from repo root:
`sh``
pnpm --filter @emeryld/rrroutes-server build # tsup + d.ts
pnpm --filter @emeryld/rrroutes-server typecheck
pnpm --filter @emeryld/rrroutes-server test