@emeryld/rrroutes-server 2.2.17 → 2.3.0
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 +104 -65
- package/dist/index.cjs +40 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +40 -9
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -22,18 +22,20 @@ This package peers with `@emeryld/rrroutes-contract` and bundles `zod`.
|
|
|
22
22
|
## Quick start: HTTP routes
|
|
23
23
|
|
|
24
24
|
```ts
|
|
25
|
-
import express from 'express'
|
|
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'
|
|
25
|
+
import express from 'express'
|
|
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
30
|
|
|
31
31
|
// 1) Build & finalize contracts (usually elsewhere in your app)
|
|
32
32
|
const leaves = resource('/api')
|
|
33
33
|
.sub('profiles', (r) =>
|
|
34
34
|
r
|
|
35
35
|
.get({
|
|
36
|
-
outputSchema: z.array(
|
|
36
|
+
outputSchema: z.array(
|
|
37
|
+
z.object({ id: z.string().uuid(), name: z.string() }),
|
|
38
|
+
),
|
|
37
39
|
description: 'List profiles',
|
|
38
40
|
})
|
|
39
41
|
.routeParameter('profileId', z.string().uuid(), (p) =>
|
|
@@ -55,56 +57,73 @@ const leaves = resource('/api')
|
|
|
55
57
|
)
|
|
56
58
|
.done(),
|
|
57
59
|
)
|
|
58
|
-
.done()
|
|
60
|
+
.done()
|
|
59
61
|
|
|
60
|
-
const registry = finalize(leaves)
|
|
62
|
+
const registry = finalize(leaves)
|
|
61
63
|
|
|
62
64
|
// 2) Wire Express with ctx + derived upload middleware
|
|
63
|
-
const upload = multer({ storage: multer.memoryStorage() })
|
|
64
|
-
const app = express()
|
|
65
|
+
const upload = multer({ storage: multer.memoryStorage() })
|
|
66
|
+
const app = express()
|
|
65
67
|
const server = createRRRoute(app, {
|
|
66
|
-
buildCtx: async (req) => ({
|
|
68
|
+
buildCtx: async (req) => ({
|
|
69
|
+
user: await loadUser(req),
|
|
70
|
+
routesLogger: console,
|
|
71
|
+
}), // ctx lives on res.locals[CTX_SYMBOL]
|
|
67
72
|
globalMiddleware: {
|
|
68
73
|
before: [
|
|
69
74
|
({ ctx, next }) => {
|
|
70
|
-
if (!ctx.user) throw new Error('unauthorized')
|
|
71
|
-
next()
|
|
75
|
+
if (!ctx.user) throw new Error('unauthorized')
|
|
76
|
+
next()
|
|
72
77
|
},
|
|
73
78
|
],
|
|
74
79
|
},
|
|
75
80
|
fromCfg: {
|
|
76
|
-
upload: (files) =>
|
|
81
|
+
upload: (files) =>
|
|
82
|
+
files && files.length > 0 ? [upload.fields(files)] : [],
|
|
77
83
|
},
|
|
78
84
|
validateOutput: true, // parse handler returns with outputSchema (default true)
|
|
79
|
-
debug: {
|
|
80
|
-
|
|
85
|
+
debug: {
|
|
86
|
+
request: true,
|
|
87
|
+
handler: true,
|
|
88
|
+
verbose: true,
|
|
89
|
+
logger: (e) => console.debug(e),
|
|
90
|
+
},
|
|
91
|
+
})
|
|
81
92
|
|
|
82
93
|
// 3) Author controllers with enforced keys/types
|
|
83
|
-
const controllers = defineControllers<
|
|
94
|
+
const controllers = defineControllers<
|
|
95
|
+
typeof registry,
|
|
96
|
+
{ user: { id: string } }
|
|
97
|
+
>()({
|
|
84
98
|
'GET /api/profiles': {
|
|
85
99
|
handler: async ({ ctx }) => {
|
|
86
|
-
return fetchProfilesFor(ctx.user.id)
|
|
100
|
+
return fetchProfilesFor(ctx.user.id)
|
|
87
101
|
},
|
|
88
102
|
},
|
|
89
103
|
'PATCH /api/profiles/:profileId': {
|
|
90
|
-
before: [
|
|
104
|
+
before: [
|
|
105
|
+
({ ctx, params, next }) =>
|
|
106
|
+
params.profileId === ctx.user.id
|
|
107
|
+
? next()
|
|
108
|
+
: next(new Error('Forbidden')),
|
|
109
|
+
],
|
|
91
110
|
handler: async ({ params, body }) => {
|
|
92
|
-
return updateProfile(params.profileId, body)
|
|
111
|
+
return updateProfile(params.profileId, body)
|
|
93
112
|
},
|
|
94
113
|
},
|
|
95
114
|
'PUT /api/profiles/:profileId/avatar': {
|
|
96
115
|
handler: async ({ req, params }) => {
|
|
97
|
-
const avatar = (req.files as any)?.avatar?.[0]
|
|
98
|
-
await storeAvatar(params.profileId, avatar?.buffer)
|
|
99
|
-
return { ok: true }
|
|
116
|
+
const avatar = (req.files as any)?.avatar?.[0]
|
|
117
|
+
await storeAvatar(params.profileId, avatar?.buffer)
|
|
118
|
+
return { ok: true }
|
|
100
119
|
},
|
|
101
120
|
},
|
|
102
|
-
})
|
|
121
|
+
})
|
|
103
122
|
|
|
104
|
-
server.registerControllers(registry, controllers)
|
|
105
|
-
server.warnMissingControllers(registry, console)
|
|
123
|
+
server.registerControllers(registry, controllers)
|
|
124
|
+
server.warnMissingControllers(registry, console) // warns in dev about unhandled leaves
|
|
106
125
|
|
|
107
|
-
app.listen(3000)
|
|
126
|
+
app.listen(3000)
|
|
108
127
|
```
|
|
109
128
|
|
|
110
129
|
## Detailed usage (HTTP)
|
|
@@ -112,19 +131,26 @@ app.listen(3000);
|
|
|
112
131
|
### Controller maps and typing
|
|
113
132
|
|
|
114
133
|
```ts
|
|
115
|
-
import { defineControllers, bindExpressRoutes } from '@emeryld/rrroutes-server'
|
|
134
|
+
import { defineControllers, bindExpressRoutes } from '@emeryld/rrroutes-server'
|
|
116
135
|
|
|
117
136
|
const controllers = defineControllers<typeof registry, Ctx>()({
|
|
118
|
-
'POST /v1/articles': {
|
|
119
|
-
})
|
|
137
|
+
'POST /v1/articles': {
|
|
138
|
+
handler: async ({ body, ctx }) => createArticle(ctx.user.id, body),
|
|
139
|
+
},
|
|
140
|
+
})
|
|
120
141
|
|
|
121
142
|
// register only the controllers provided (missing keys are ignored)
|
|
122
143
|
bindExpressRoutes(app, registry, controllers, {
|
|
123
144
|
buildCtx: () => ({ user: { id: '123' } }),
|
|
124
|
-
})
|
|
145
|
+
})
|
|
125
146
|
|
|
126
147
|
// or enforce every key is present at compile time
|
|
127
|
-
bindExpressRoutes(
|
|
148
|
+
bindExpressRoutes(
|
|
149
|
+
app,
|
|
150
|
+
registry,
|
|
151
|
+
controllers as { [K in keyof typeof registry.byKey]: any },
|
|
152
|
+
{ buildCtx },
|
|
153
|
+
)
|
|
128
154
|
```
|
|
129
155
|
|
|
130
156
|
- `defineControllers<Registry, Ctx>()(map)` keeps literal `"METHOD /path"` keys accurate and infers params/query/body/output types per leaf.
|
|
@@ -136,24 +162,24 @@ bindExpressRoutes(app, registry, controllers as { [K in keyof typeof registry.by
|
|
|
136
162
|
Order: `buildCtx` → `global.before` → `fromCfg` (derived) → `route.before` → handler → `route.after` → `global.after`.
|
|
137
163
|
|
|
138
164
|
```ts
|
|
139
|
-
import { getCtx, CtxRequestHandler } from '@emeryld/rrroutes-server'
|
|
165
|
+
import { getCtx, CtxRequestHandler } from '@emeryld/rrroutes-server'
|
|
140
166
|
|
|
141
167
|
const audit: CtxRequestHandler<Ctx> = ({ ctx, req, next }) => {
|
|
142
|
-
ctx.routesLogger?.info?.('audit', { user: ctx.user?.id, path: req.path })
|
|
143
|
-
next()
|
|
144
|
-
}
|
|
168
|
+
ctx.routesLogger?.info?.('audit', { user: ctx.user?.id, path: req.path })
|
|
169
|
+
next()
|
|
170
|
+
}
|
|
145
171
|
|
|
146
172
|
const server = createRRRoute(app, {
|
|
147
173
|
buildCtx: (req, res) => ({ user: res.locals.user, routesLogger: console }),
|
|
148
174
|
globalMiddleware: { before: [audit] },
|
|
149
|
-
})
|
|
175
|
+
})
|
|
150
176
|
|
|
151
177
|
// Inside any Express middleware (even outside route.before/after), use getCtx to retrieve typed ctx:
|
|
152
178
|
app.use((req, res, next) => {
|
|
153
|
-
const ctx = getCtx<Ctx>(res)
|
|
154
|
-
ctx?.routesLogger?.debug?.('in arbitrary middleware')
|
|
155
|
-
next()
|
|
156
|
-
})
|
|
179
|
+
const ctx = getCtx<Ctx>(res)
|
|
180
|
+
ctx?.routesLogger?.debug?.('in arbitrary middleware')
|
|
181
|
+
next()
|
|
182
|
+
})
|
|
157
183
|
```
|
|
158
184
|
|
|
159
185
|
- `CtxRequestHandler` receives `{ req, res, next, ctx }` with your typed ctx.
|
|
@@ -164,17 +190,18 @@ app.use((req, res, next) => {
|
|
|
164
190
|
Use `fromCfg.upload` to attach middleware when a leaf declares `bodyFiles`.
|
|
165
191
|
|
|
166
192
|
```ts
|
|
167
|
-
import multer from 'multer'
|
|
168
|
-
import { FileField } from '@emeryld/rrroutes-contract'
|
|
193
|
+
import multer from 'multer'
|
|
194
|
+
import { FileField } from '@emeryld/rrroutes-contract'
|
|
169
195
|
|
|
170
|
-
const upload = multer({ storage: multer.memoryStorage() })
|
|
196
|
+
const upload = multer({ storage: multer.memoryStorage() })
|
|
171
197
|
|
|
172
198
|
const server = createRRRoute(app, {
|
|
173
199
|
buildCtx,
|
|
174
200
|
fromCfg: {
|
|
175
|
-
upload: (files: FileField[] | undefined) =>
|
|
201
|
+
upload: (files: FileField[] | undefined) =>
|
|
202
|
+
files?.length ? [upload.fields(files)] : [],
|
|
176
203
|
},
|
|
177
|
-
})
|
|
204
|
+
})
|
|
178
205
|
```
|
|
179
206
|
|
|
180
207
|
### Output validation and custom responders
|
|
@@ -186,7 +213,7 @@ const server = createRRRoute(app, {
|
|
|
186
213
|
const server = createRRRoute(app, {
|
|
187
214
|
buildCtx,
|
|
188
215
|
send: (res, data) => res.status(201).json({ data }),
|
|
189
|
-
})
|
|
216
|
+
})
|
|
190
217
|
```
|
|
191
218
|
|
|
192
219
|
### Debug logging
|
|
@@ -203,7 +230,7 @@ const server = createRRRoute(app, {
|
|
|
203
230
|
only: ['users:list'], // filter by RouteDef.debug?.debugName
|
|
204
231
|
logger: (event) => console.log('[route-debug]', event),
|
|
205
232
|
},
|
|
206
|
-
})
|
|
233
|
+
})
|
|
207
234
|
```
|
|
208
235
|
|
|
209
236
|
Per-route overrides:
|
|
@@ -212,7 +239,7 @@ Per-route overrides:
|
|
|
212
239
|
server.register(registry.byKey['GET /api/profiles'], {
|
|
213
240
|
debug: { handler: true, debugName: 'profiles:list' },
|
|
214
241
|
handler: async () => [],
|
|
215
|
-
})
|
|
242
|
+
})
|
|
216
243
|
```
|
|
217
244
|
|
|
218
245
|
Context logger passthrough: if `buildCtx` provides `routesLogger`, handler debug events also flow to that logger (useful for request-scoped loggers).
|
|
@@ -228,33 +255,45 @@ Context logger passthrough: if `buildCtx` provides `routesLogger`, handler debug
|
|
|
228
255
|
`@emeryld/rrroutes-server` also ships a typed Socket.IO wrapper that pairs with `defineSocketEvents` from the contract package.
|
|
229
256
|
|
|
230
257
|
```ts
|
|
231
|
-
import { Server } from 'socket.io'
|
|
232
|
-
import { defineSocketEvents } from '@emeryld/rrroutes-contract'
|
|
233
|
-
import {
|
|
234
|
-
|
|
258
|
+
import { Server } from 'socket.io'
|
|
259
|
+
import { defineSocketEvents } from '@emeryld/rrroutes-contract'
|
|
260
|
+
import {
|
|
261
|
+
createSocketConnections,
|
|
262
|
+
createConnectionLoggingMiddleware,
|
|
263
|
+
} from '@emeryld/rrroutes-server'
|
|
264
|
+
import { z } from 'zod'
|
|
235
265
|
|
|
236
266
|
const { config, events } = defineSocketEvents(
|
|
237
267
|
{
|
|
238
268
|
joinMetaMessage: z.object({ room: z.string() }),
|
|
239
269
|
leaveMetaMessage: z.object({ room: z.string() }),
|
|
240
270
|
pingPayload: z.object({ sentAt: z.string() }),
|
|
241
|
-
pongPayload: z.object({
|
|
271
|
+
pongPayload: z.object({
|
|
272
|
+
sentAt: z.string(),
|
|
273
|
+
sinceMs: z.number().optional(),
|
|
274
|
+
}),
|
|
242
275
|
},
|
|
243
276
|
{
|
|
244
|
-
'chat:message': {
|
|
277
|
+
'chat:message': {
|
|
278
|
+
message: z.object({
|
|
279
|
+
roomId: z.string(),
|
|
280
|
+
text: z.string(),
|
|
281
|
+
userId: z.string(),
|
|
282
|
+
}),
|
|
283
|
+
},
|
|
245
284
|
},
|
|
246
|
-
)
|
|
285
|
+
)
|
|
247
286
|
|
|
248
|
-
const io = new Server(3000, { cors: { origin: '*', credentials: true } })
|
|
249
|
-
io.use(createConnectionLoggingMiddleware({ includeHeaders: false }))
|
|
287
|
+
const io = new Server(3000, { cors: { origin: '*', credentials: true } })
|
|
288
|
+
io.use(createConnectionLoggingMiddleware({ includeHeaders: false }))
|
|
250
289
|
|
|
251
290
|
const sockets = createSocketConnections(io, events, {
|
|
252
291
|
config,
|
|
253
292
|
heartbeat: { enabled: true }, // enables sys:ping/sys:pong using config schemas
|
|
254
293
|
sys: {
|
|
255
294
|
'sys:connect': async ({ socket, complete }) => {
|
|
256
|
-
socket.data.user = await loadUserFromHandshake(socket.handshake)
|
|
257
|
-
await complete()
|
|
295
|
+
socket.data.user = await loadUserFromHandshake(socket.handshake)
|
|
296
|
+
await complete() // attach built-ins (ping/pong, join/leave)
|
|
258
297
|
},
|
|
259
298
|
'sys:ping': async ({ socket, ping }) => ({
|
|
260
299
|
sentAt: ping.sentAt,
|
|
@@ -268,17 +307,17 @@ const sockets = createSocketConnections(io, events, {
|
|
|
268
307
|
verbose: true,
|
|
269
308
|
logger: (e) => console.debug('[socket-debug]', e),
|
|
270
309
|
},
|
|
271
|
-
})
|
|
310
|
+
})
|
|
272
311
|
|
|
273
312
|
// Validate inbound payloads + emit envelopes
|
|
274
313
|
sockets.on('chat:message', async (payload, ctx) => {
|
|
275
|
-
await saveMessage(payload, ctx.user)
|
|
314
|
+
await saveMessage(payload, ctx.user)
|
|
276
315
|
// broadcast to room participants
|
|
277
|
-
sockets.emit('chat:message', payload, payload.roomId)
|
|
278
|
-
})
|
|
316
|
+
sockets.emit('chat:message', payload, payload.roomId)
|
|
317
|
+
})
|
|
279
318
|
|
|
280
319
|
// Graceful shutdown
|
|
281
|
-
process.on('SIGTERM', () => sockets.destroy())
|
|
320
|
+
process.on('SIGTERM', () => sockets.destroy())
|
|
282
321
|
```
|
|
283
322
|
|
|
284
323
|
- Payloads are validated on both emit and receive; invalid payloads trigger `<event>:error` with Zod issues.
|
package/dist/index.cjs
CHANGED
|
@@ -578,7 +578,12 @@ function createBuiltInConnectionHandlers(opts) {
|
|
|
578
578
|
const list = toArray(parsed.data.rooms);
|
|
579
579
|
const join = async (room) => {
|
|
580
580
|
await socket.join(room);
|
|
581
|
-
dbg(null, {
|
|
581
|
+
dbg(null, {
|
|
582
|
+
type: "rooms",
|
|
583
|
+
action: "join",
|
|
584
|
+
rooms: room,
|
|
585
|
+
socketId: socket.id
|
|
586
|
+
});
|
|
582
587
|
};
|
|
583
588
|
const run = async () => {
|
|
584
589
|
await getSysEvent("sys:room_join")({
|
|
@@ -592,7 +597,9 @@ function createBuiltInConnectionHandlers(opts) {
|
|
|
592
597
|
try {
|
|
593
598
|
await run();
|
|
594
599
|
} catch (error) {
|
|
595
|
-
socket.emit(`${roomJoinEvent}:error`, {
|
|
600
|
+
socket.emit(`${roomJoinEvent}:error`, {
|
|
601
|
+
error: normalizeError(error)
|
|
602
|
+
});
|
|
596
603
|
dbg(roomJoinEvent, {
|
|
597
604
|
type: "rooms",
|
|
598
605
|
action: "join",
|
|
@@ -630,7 +637,12 @@ function createBuiltInConnectionHandlers(opts) {
|
|
|
630
637
|
const list = toArray(parsed.data.rooms);
|
|
631
638
|
const leave = async (room) => {
|
|
632
639
|
await socket.leave(room);
|
|
633
|
-
dbg(null, {
|
|
640
|
+
dbg(null, {
|
|
641
|
+
type: "rooms",
|
|
642
|
+
action: "leave",
|
|
643
|
+
rooms: room,
|
|
644
|
+
socketId: socket.id
|
|
645
|
+
});
|
|
634
646
|
};
|
|
635
647
|
const run = async () => {
|
|
636
648
|
await getSysEvent("sys:room_leave")({
|
|
@@ -644,7 +656,9 @@ function createBuiltInConnectionHandlers(opts) {
|
|
|
644
656
|
try {
|
|
645
657
|
await run();
|
|
646
658
|
} catch (error) {
|
|
647
|
-
socket.emit(`${roomLeaveEvent}:error`, {
|
|
659
|
+
socket.emit(`${roomLeaveEvent}:error`, {
|
|
660
|
+
error: normalizeError(error)
|
|
661
|
+
});
|
|
648
662
|
dbg(roomJoinEvent, {
|
|
649
663
|
type: "rooms",
|
|
650
664
|
phase: "handler_error",
|
|
@@ -693,7 +707,12 @@ function createBuiltInConnectionHandlers(opts) {
|
|
|
693
707
|
});
|
|
694
708
|
let pongPayload;
|
|
695
709
|
try {
|
|
696
|
-
pongPayload = await getSysEvent("sys:ping")({
|
|
710
|
+
pongPayload = await getSysEvent("sys:ping")({
|
|
711
|
+
ping: parsedPing.data,
|
|
712
|
+
ctx,
|
|
713
|
+
socket,
|
|
714
|
+
helper
|
|
715
|
+
});
|
|
697
716
|
} catch (error) {
|
|
698
717
|
socket.emit(`${pingEvent}:error`, { error: normalizeError(error) });
|
|
699
718
|
dbg(null, {
|
|
@@ -858,7 +877,9 @@ function createSocketConnections(io, events, opts) {
|
|
|
858
877
|
},
|
|
859
878
|
metadata
|
|
860
879
|
});
|
|
861
|
-
throw new Error(
|
|
880
|
+
throw new Error(
|
|
881
|
+
`Invalid payload for "${String(eventName)}": ${check.error.message}`
|
|
882
|
+
);
|
|
862
883
|
}
|
|
863
884
|
const envelope = {
|
|
864
885
|
eventName,
|
|
@@ -919,7 +940,12 @@ function createSocketConnections(io, events, opts) {
|
|
|
919
940
|
}
|
|
920
941
|
}
|
|
921
942
|
registrations.delete(eventName);
|
|
922
|
-
dbg(eventName, {
|
|
943
|
+
dbg(eventName, {
|
|
944
|
+
type: "register",
|
|
945
|
+
action: "unregister",
|
|
946
|
+
event: eventName,
|
|
947
|
+
msg: "unregistered"
|
|
948
|
+
});
|
|
923
949
|
};
|
|
924
950
|
const {
|
|
925
951
|
builtInConnectionListener,
|
|
@@ -1005,7 +1031,10 @@ function createSocketConnections(io, events, opts) {
|
|
|
1005
1031
|
});
|
|
1006
1032
|
};
|
|
1007
1033
|
io.on("connection", connectionListener);
|
|
1008
|
-
addRegistration(String(eventName), {
|
|
1034
|
+
addRegistration(String(eventName), {
|
|
1035
|
+
connectionListener,
|
|
1036
|
+
socketListeners
|
|
1037
|
+
});
|
|
1009
1038
|
dbg(String(eventName), {
|
|
1010
1039
|
type: "register",
|
|
1011
1040
|
action: "register",
|
|
@@ -1151,7 +1180,9 @@ var createConnectionLoggingMiddleware = (options = {}) => {
|
|
|
1151
1180
|
const logger = options.logger ?? defaultLogger;
|
|
1152
1181
|
const includeHeaders = options.includeHeaders ?? false;
|
|
1153
1182
|
const redactKeys = new Set(
|
|
1154
|
-
(options.redactAuthKeys ?? ["authorization", "token"]).map(
|
|
1183
|
+
(options.redactAuthKeys ?? ["authorization", "token"]).map(
|
|
1184
|
+
(key) => key.toLowerCase()
|
|
1185
|
+
)
|
|
1155
1186
|
);
|
|
1156
1187
|
return (socket, next) => {
|
|
1157
1188
|
const context = buildContext(socket, includeHeaders, redactKeys);
|