@emeryld/rrroutes-server 2.1.6 → 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.
Files changed (2) hide show
  1. package/README.md +276 -24
  2. package/package.json +1 -1
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. This package maps finalized leaves to an Express router, validating params/query/body/output with Zod, wiring ctx-aware middleware, and warning about missing controllers.
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
- The server package depends on `@emeryld/rrroutes-contract` and `zod` internally.
20
+ This package peers with `@emeryld/rrroutes-contract` and bundles `zod`.
14
21
 
15
- ## Usage
22
+ ## Quick start: HTTP routes
16
23
 
17
24
  ```ts
18
25
  import express from 'express';
19
- import { createRRRoute } from '@emeryld/rrroutes-server';
20
- import { registry } from '../routes';
21
- import { controllers } from './controllers';
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 router = express.Router();
60
+ const registry = finalize(leaves);
24
61
 
25
- const server = createRRRoute(router, {
26
- baseUrl: '/api',
27
- buildCtx: async (req) => ({ user: await loadUser(req) }),
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
- `buildCtx` can optionally include `routesLogger` to route handler debug events to a per-request logger.
107
+ app.listen(3000);
36
108
  ```
37
109
 
38
- ## Scripts
110
+ ## Detailed usage (HTTP)
39
111
 
40
- ```sh
41
- pnpm install
42
- pnpm --filter @emeryld/rrroutes-server build
43
- pnpm --filter @emeryld/rrroutes-server test
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
- ## Publishing
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
- ```sh
49
- cd packages/server
50
- npm publish --access public
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
- Double-check `packages/server/package.json` for the version bump and ensure the bundled `dist/` output is up to date before publishing.
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
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emeryld/rrroutes-server",
3
- "version": "2.1.6",
3
+ "version": "2.1.7",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",