@emeryld/rrroutes-server 2.1.5 → 2.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +276 -24
- package/dist/index.cjs.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/sockets/socket.server.index.d.ts +2 -2
- package/dist/sockets/socket.server.sys.d.ts +3 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,53 +1,305 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Summary:
|
|
3
|
+
- Added full quick start for binding finalized contracts to Express with typed controllers, ctx builders, derived upload middleware, and output validation.
|
|
4
|
+
- Documented debug options, per-route overrides, partial/complete registration helpers, missing-controller warnings, and socket server helpers (typed events, heartbeat, room hooks).
|
|
5
|
+
- Missing: cluster/multi-process Socket.IO deployment notes and an end-to-end auth example (ctx + room allow/deny) to add later.
|
|
6
|
+
-->
|
|
7
|
+
|
|
1
8
|
# @emeryld/rrroutes-server
|
|
2
9
|
|
|
3
|
-
Express bindings for RRRoutes contracts.
|
|
10
|
+
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.
|
|
4
11
|
|
|
5
12
|
## Installation
|
|
6
13
|
|
|
7
14
|
```sh
|
|
8
|
-
pnpm add @emeryld/rrroutes-server express
|
|
15
|
+
pnpm add @emeryld/rrroutes-server express socket.io
|
|
9
16
|
# or
|
|
10
|
-
npm install @emeryld/rrroutes-server express
|
|
17
|
+
npm install @emeryld/rrroutes-server express socket.io
|
|
11
18
|
```
|
|
12
19
|
|
|
13
|
-
|
|
20
|
+
This package peers with `@emeryld/rrroutes-contract` and bundles `zod`.
|
|
14
21
|
|
|
15
|
-
##
|
|
22
|
+
## Quick start: HTTP routes
|
|
16
23
|
|
|
17
24
|
```ts
|
|
18
25
|
import express from 'express';
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import
|
|
26
|
+
import { finalize, resource } from '@emeryld/rrroutes-contract';
|
|
27
|
+
import { createRRRoute, defineControllers } from '@emeryld/rrroutes-server';
|
|
28
|
+
import multer from 'multer';
|
|
29
|
+
import { z } from 'zod';
|
|
30
|
+
|
|
31
|
+
// 1) Build & finalize contracts (usually elsewhere in your app)
|
|
32
|
+
const leaves = resource('/api')
|
|
33
|
+
.sub('profiles', (r) =>
|
|
34
|
+
r
|
|
35
|
+
.get({
|
|
36
|
+
outputSchema: z.array(z.object({ id: z.string().uuid(), name: z.string() })),
|
|
37
|
+
description: 'List profiles',
|
|
38
|
+
})
|
|
39
|
+
.routeParameter('profileId', z.string().uuid(), (p) =>
|
|
40
|
+
p
|
|
41
|
+
.patch({
|
|
42
|
+
bodySchema: z.object({ name: z.string().min(1) }),
|
|
43
|
+
outputSchema: z.object({ id: z.string().uuid(), name: z.string() }),
|
|
44
|
+
})
|
|
45
|
+
.sub('avatar', (a) =>
|
|
46
|
+
a
|
|
47
|
+
.put({
|
|
48
|
+
bodyFiles: [{ name: 'avatar', maxCount: 1 }], // derive upload middleware
|
|
49
|
+
bodySchema: z.object({ avatar: z.instanceof(Blob) }),
|
|
50
|
+
outputSchema: z.object({ ok: z.literal(true) }),
|
|
51
|
+
})
|
|
52
|
+
.done(),
|
|
53
|
+
)
|
|
54
|
+
.done(),
|
|
55
|
+
)
|
|
56
|
+
.done(),
|
|
57
|
+
)
|
|
58
|
+
.done();
|
|
22
59
|
|
|
23
|
-
const
|
|
60
|
+
const registry = finalize(leaves);
|
|
24
61
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
62
|
+
// 2) Wire Express with ctx + derived upload middleware
|
|
63
|
+
const upload = multer({ storage: multer.memoryStorage() });
|
|
64
|
+
const app = express();
|
|
65
|
+
const server = createRRRoute(app, {
|
|
66
|
+
buildCtx: async (req) => ({ user: await loadUser(req), routesLogger: console }), // ctx lives on res.locals[CTX_SYMBOL]
|
|
67
|
+
globalMiddleware: {
|
|
68
|
+
before: [
|
|
69
|
+
({ ctx, next }) => {
|
|
70
|
+
if (!ctx.user) throw new Error('unauthorized');
|
|
71
|
+
next();
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
},
|
|
28
75
|
fromCfg: {
|
|
76
|
+
upload: (files) => (files && files.length > 0 ? [upload.fields(files)] : []),
|
|
77
|
+
},
|
|
78
|
+
validateOutput: true, // parse handler returns with outputSchema (default true)
|
|
79
|
+
debug: { request: true, handler: true, verbose: true, logger: (e) => console.debug(e) },
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// 3) Author controllers with enforced keys/types
|
|
83
|
+
const controllers = defineControllers<typeof registry, { user: { id: string } }>()({
|
|
84
|
+
'GET /api/profiles': {
|
|
85
|
+
handler: async ({ ctx }) => {
|
|
86
|
+
return fetchProfilesFor(ctx.user.id);
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
'PATCH /api/profiles/:profileId': {
|
|
90
|
+
before: [({ ctx, params, next }) => (params.profileId === ctx.user.id ? next() : next(new Error('Forbidden')))],
|
|
91
|
+
handler: async ({ params, body }) => {
|
|
92
|
+
return updateProfile(params.profileId, body);
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
'PUT /api/profiles/:profileId/avatar': {
|
|
96
|
+
handler: async ({ req, params }) => {
|
|
97
|
+
const avatar = (req.files as any)?.avatar?.[0];
|
|
98
|
+
await storeAvatar(params.profileId, avatar?.buffer);
|
|
99
|
+
return { ok: true };
|
|
100
|
+
},
|
|
29
101
|
},
|
|
30
102
|
});
|
|
31
103
|
|
|
32
104
|
server.registerControllers(registry, controllers);
|
|
33
|
-
server.warnMissingControllers(registry, console);
|
|
105
|
+
server.warnMissingControllers(registry, console); // warns in dev about unhandled leaves
|
|
34
106
|
|
|
35
|
-
|
|
107
|
+
app.listen(3000);
|
|
36
108
|
```
|
|
37
109
|
|
|
38
|
-
##
|
|
110
|
+
## Detailed usage (HTTP)
|
|
39
111
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
112
|
+
### Controller maps and typing
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
import { defineControllers, bindExpressRoutes } from '@emeryld/rrroutes-server';
|
|
116
|
+
|
|
117
|
+
const controllers = defineControllers<typeof registry, Ctx>()({
|
|
118
|
+
'POST /v1/articles': { handler: async ({ body, ctx }) => createArticle(ctx.user.id, body) },
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// register only the controllers provided (missing keys are ignored)
|
|
122
|
+
bindExpressRoutes(app, registry, controllers, {
|
|
123
|
+
buildCtx: () => ({ user: { id: '123' } }),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// or enforce every key is present at compile time
|
|
127
|
+
bindExpressRoutes(app, registry, controllers as { [K in keyof typeof registry.byKey]: any }, { buildCtx });
|
|
44
128
|
```
|
|
45
129
|
|
|
46
|
-
|
|
130
|
+
- `defineControllers<Registry, Ctx>()(map)` keeps literal `"METHOD /path"` keys accurate and infers params/query/body/output types per leaf.
|
|
131
|
+
- `registerControllers` accepts partial maps (missing routes are skipped); `bindAll` enforces completeness at compile time.
|
|
132
|
+
- `warnMissingControllers(router, registry, logger)` inspects the Express stack and warns for any leaf without a handler.
|
|
47
133
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
134
|
+
### Middleware order and ctx usage
|
|
135
|
+
|
|
136
|
+
Order: `buildCtx` → `global.before` → `fromCfg` (derived) → `route.before` → handler → `route.after` → `global.after`.
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
import { getCtx, CtxRequestHandler } from '@emeryld/rrroutes-server';
|
|
140
|
+
|
|
141
|
+
const audit: CtxRequestHandler<Ctx> = ({ ctx, req, next }) => {
|
|
142
|
+
ctx.routesLogger?.info?.('audit', { user: ctx.user?.id, path: req.path });
|
|
143
|
+
next();
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const server = createRRRoute(app, {
|
|
147
|
+
buildCtx: (req, res) => ({ user: res.locals.user, routesLogger: console }),
|
|
148
|
+
globalMiddleware: { before: [audit] },
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Inside any Express middleware (even outside route.before/after), use getCtx to retrieve typed ctx:
|
|
152
|
+
app.use((req, res, next) => {
|
|
153
|
+
const ctx = getCtx<Ctx>(res);
|
|
154
|
+
ctx?.routesLogger?.debug?.('in arbitrary middleware');
|
|
155
|
+
next();
|
|
156
|
+
});
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
- `CtxRequestHandler` receives `{ req, res, next, ctx }` with your typed ctx.
|
|
160
|
+
- `route.after` middlewares run **only when the handler calls `next()`** (useful for fall-through auth or response post-processing).
|
|
161
|
+
|
|
162
|
+
### Derived middleware from route cfg (uploads)
|
|
163
|
+
|
|
164
|
+
Use `fromCfg.upload` to attach middleware when a leaf declares `bodyFiles`.
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
import multer from 'multer';
|
|
168
|
+
import { FileField } from '@emeryld/rrroutes-contract';
|
|
169
|
+
|
|
170
|
+
const upload = multer({ storage: multer.memoryStorage() });
|
|
171
|
+
|
|
172
|
+
const server = createRRRoute(app, {
|
|
173
|
+
buildCtx,
|
|
174
|
+
fromCfg: {
|
|
175
|
+
upload: (files: FileField[] | undefined) => (files?.length ? [upload.fields(files)] : []),
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Output validation and custom responders
|
|
181
|
+
|
|
182
|
+
- `validateOutput: true` parses handler return values with the leaf `outputSchema`. Set to `false` to skip.
|
|
183
|
+
- Override `send` to change response behavior (e.g., `res.status(201).json(data)`).
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
const server = createRRRoute(app, {
|
|
187
|
+
buildCtx,
|
|
188
|
+
send: (res, data) => res.status(201).json({ data }),
|
|
189
|
+
});
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Debug logging
|
|
193
|
+
|
|
194
|
+
Global debug options:
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
const server = createRRRoute(app, {
|
|
198
|
+
buildCtx,
|
|
199
|
+
debug: {
|
|
200
|
+
request: true, // register/request/handler/buildCtx event toggles
|
|
201
|
+
handler: true,
|
|
202
|
+
verbose: true, // include params/query/body/output/errors
|
|
203
|
+
only: ['users:list'], // filter by RouteDef.debug?.debugName
|
|
204
|
+
logger: (event) => console.log('[route-debug]', event),
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Per-route overrides:
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
server.register(registry.byKey['GET /api/profiles'], {
|
|
213
|
+
debug: { handler: true, debugName: 'profiles:list' },
|
|
214
|
+
handler: async () => [],
|
|
215
|
+
});
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Context logger passthrough: if `buildCtx` provides `routesLogger`, handler debug events also flow to that logger (useful for request-scoped loggers).
|
|
219
|
+
|
|
220
|
+
### Recipes
|
|
221
|
+
|
|
222
|
+
- **Combine registries:** build leaves per domain, spread before `finalize([...usersLeaves, ...projectsLeaves])`, then register once.
|
|
223
|
+
- **Fail fast on missing controllers:** call `assertAllControllersPresent(router, registry, controllers)` pattern by invoking `registerControllers(registry, controllers, true as any)` or running `warnMissingControllers` during startup.
|
|
224
|
+
- **Operator-specific middleware:** attach `route.before` per controller (e.g., role checks) and keep `global.before` minimal (auth/session parsing).
|
|
225
|
+
|
|
226
|
+
## Socket server (typed events, heartbeat, rooms)
|
|
227
|
+
|
|
228
|
+
`@emeryld/rrroutes-server` also ships a typed Socket.IO wrapper that pairs with `defineSocketEvents` from the contract package.
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
import { Server } from 'socket.io';
|
|
232
|
+
import { defineSocketEvents } from '@emeryld/rrroutes-contract';
|
|
233
|
+
import { createSocketConnections, createConnectionLoggingMiddleware } from '@emeryld/rrroutes-server';
|
|
234
|
+
import { z } from 'zod';
|
|
235
|
+
|
|
236
|
+
const { config, events } = defineSocketEvents(
|
|
237
|
+
{
|
|
238
|
+
joinMetaMessage: z.object({ room: z.string() }),
|
|
239
|
+
leaveMetaMessage: z.object({ room: z.string() }),
|
|
240
|
+
pingPayload: z.object({ sentAt: z.string() }),
|
|
241
|
+
pongPayload: z.object({ sentAt: z.string(), sinceMs: z.number().optional() }),
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
'chat:message': { message: z.object({ roomId: z.string(), text: z.string(), userId: z.string() }) },
|
|
245
|
+
},
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
const io = new Server(3000, { cors: { origin: '*', credentials: true } });
|
|
249
|
+
io.use(createConnectionLoggingMiddleware({ includeHeaders: false }));
|
|
250
|
+
|
|
251
|
+
const sockets = createSocketConnections(io, events, {
|
|
252
|
+
config,
|
|
253
|
+
heartbeat: { enabled: true }, // enables sys:ping/sys:pong using config schemas
|
|
254
|
+
sys: {
|
|
255
|
+
'sys:connect': async ({ socket, complete }) => {
|
|
256
|
+
socket.data.user = await loadUserFromHandshake(socket.handshake);
|
|
257
|
+
await complete(); // attach built-ins (ping/pong, join/leave)
|
|
258
|
+
},
|
|
259
|
+
'sys:ping': async ({ socket, ping }) => ({
|
|
260
|
+
sentAt: ping.sentAt,
|
|
261
|
+
sinceMs: Date.now() - Date.parse(ping.sentAt),
|
|
262
|
+
}),
|
|
263
|
+
},
|
|
264
|
+
debug: {
|
|
265
|
+
register: true,
|
|
266
|
+
handler: true,
|
|
267
|
+
emit: true,
|
|
268
|
+
verbose: true,
|
|
269
|
+
logger: (e) => console.debug('[socket-debug]', e),
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// Validate inbound payloads + emit envelopes
|
|
274
|
+
sockets.on('chat:message', async (payload, ctx) => {
|
|
275
|
+
await saveMessage(payload, ctx.user);
|
|
276
|
+
// broadcast to room participants
|
|
277
|
+
sockets.emit('chat:message', payload, payload.roomId);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// Graceful shutdown
|
|
281
|
+
process.on('SIGTERM', () => sockets.destroy());
|
|
51
282
|
```
|
|
52
283
|
|
|
53
|
-
|
|
284
|
+
- Payloads are validated on both emit and receive; invalid payloads trigger `<event>:error` with Zod issues.
|
|
285
|
+
- Built-in system events: `sys:connect`, `sys:disconnect`, `sys:ping`, `sys:pong`, `sys:room_join`, `sys:room_leave`.
|
|
286
|
+
- Heartbeat is enabled by default (`heartbeat.enabled !== false`) and uses `config.pingPayload` / `config.pongPayload` schemas.
|
|
287
|
+
- `destroy()` removes listeners, room handlers, and connection hooks—safe for test teardown.
|
|
288
|
+
|
|
289
|
+
## Edge cases and notes
|
|
290
|
+
|
|
291
|
+
- `route.after` only runs if the handler calls `next()` (mirrors Express semantics).
|
|
292
|
+
- `compilePath`/param parsing exceptions bubble to Express error handlers; wrap `buildCtx`/middleware in try/catch if you need custom error shapes.
|
|
293
|
+
- When `validateOutput` is true and no `outputSchema` exists, raw handler output is passed through.
|
|
294
|
+
- `fromCfg.upload` runs only when `leaf.cfg.bodyFiles` is a non-empty array.
|
|
295
|
+
- Socket `emit` will throw on invalid payloads; handle errors around broadcast loops.
|
|
296
|
+
|
|
297
|
+
## Scripts
|
|
298
|
+
|
|
299
|
+
Run from repo root:
|
|
300
|
+
|
|
301
|
+
```sh
|
|
302
|
+
pnpm --filter @emeryld/rrroutes-server build # tsup + d.ts
|
|
303
|
+
pnpm --filter @emeryld/rrroutes-server typecheck
|
|
304
|
+
pnpm --filter @emeryld/rrroutes-server test
|
|
305
|
+
```
|