@emeryld/rrroutes-server 2.4.2 → 2.4.4

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 CHANGED
@@ -62,7 +62,6 @@ const leaves = resource('/api')
62
62
  const registry = finalize(leaves)
63
63
 
64
64
  // 2) Wire Express with ctx + derived upload middleware
65
- const upload = multer({ storage: multer.memoryStorage() })
66
65
  const app = express()
67
66
  const server = createRRRoute(app, {
68
67
  buildCtx: async (req) => ({
@@ -77,10 +76,8 @@ const server = createRRRoute(app, {
77
76
  },
78
77
  ],
79
78
  },
80
- fromCfg: {
81
- upload: (files) =>
82
- files && files.length > 0 ? [upload.fields(files)] : [],
83
- },
79
+ multerOptions: (files) =>
80
+ files && files.length > 0 ? { storage: multer.memoryStorage() } : undefined,
84
81
  validateOutput: true, // parse handler returns with outputSchema (default true)
85
82
  debug: {
86
83
  request: true,
@@ -153,13 +150,26 @@ bindExpressRoutes(
153
150
  )
154
151
  ```
155
152
 
153
+ If you need access to the parsed params/query/body inside `buildCtx`, destructure them from the single argument:
154
+
155
+ ```ts
156
+ const server = createRRRoute(app, {
157
+ buildCtx: ({ params, query, body }) => ({
158
+ user: lookupUser(params.id),
159
+ verbose: query?.verbose === 'yes',
160
+ }),
161
+ })
162
+ ```
163
+
164
+ > `buildCtx` now receives the `{ req, res, params, query, body }` object; the legacy `(req, res)` signature is no longer supported.
165
+
156
166
  - `defineControllers<Registry, Ctx>()(map)` keeps literal `"METHOD /path"` keys accurate and infers params/query/body/output types per leaf.
157
167
  - `registerControllers` accepts partial maps (missing routes are skipped); `bindAll` enforces completeness at compile time.
158
168
  - `warnMissingControllers(router, registry, logger)` inspects the Express stack and warns for any leaf without a handler.
159
169
 
160
170
  ### Middleware order and ctx usage
161
171
 
162
- Order: `buildCtx` → `global.before` → `fromCfg` (derived) → `route.before` → handler.
172
+ Order: `resolve` → `ctx` → `global.before` → `route.before` → handler.
163
173
 
164
174
  ```ts
165
175
  import { getCtx, CtxRequestHandler } from '@emeryld/rrroutes-server'
@@ -174,6 +184,11 @@ const server = createRRRoute(app, {
174
184
  globalMiddleware: { before: [audit] },
175
185
  })
176
186
 
187
+ const routeBefore = ({ params, query, body, ctx, next }) => {
188
+ ctx.routesLogger?.debug?.('route.before payload', { params, query, body })
189
+ next()
190
+ }
191
+
177
192
  // Inside any Express middleware (even outside route.before), use getCtx to retrieve typed ctx:
178
193
  app.use((req, res, next) => {
179
194
  const ctx = getCtx<Ctx>(res)
@@ -183,27 +198,36 @@ app.use((req, res, next) => {
183
198
  ```
184
199
 
185
200
  - `CtxRequestHandler` receives `{ req, res, next, ctx }` with your typed ctx.
201
+ - `route.before` handlers now receive the same parsed `params`, `query`, and `body` payload as the handler, alongside `req`, `res`, and `ctx`.
186
202
  - Need post-response hooks? Register a middleware that wires `res.on('finish', handler)` inside `route.before`/`global.before` instead of relying on a dedicated "after" stage.
187
203
 
188
- ### Derived middleware from route cfg (uploads)
204
+ ### Upload parsing
189
205
 
190
- Use `fromCfg.upload` to attach middleware when a leaf declares `bodyFiles`.
206
+ Routes that declare `bodyFiles` automatically run Multer before `ctx` using shared memory storage. Override or disable that behavior with `multerOptions`.
191
207
 
192
208
  ```ts
193
209
  import multer from 'multer'
194
210
  import { FileField } from '@emeryld/rrroutes-contract'
195
211
 
196
- const upload = multer({ storage: multer.memoryStorage() })
212
+ const diskStorage = multer.diskStorage({
213
+ destination: 'tmp/uploads',
214
+ filename: (_req, file, cb) => cb(null, `${Date.now()}-${file.originalname}`),
215
+ })
197
216
 
198
217
  const server = createRRRoute(app, {
199
218
  buildCtx,
200
- fromCfg: {
201
- upload: (files: FileField[] | undefined) =>
202
- files?.length ? [upload.fields(files)] : [],
203
- },
219
+ multerOptions: (files: FileField[] | undefined) =>
220
+ files?.length
221
+ ? {
222
+ storage: diskStorage,
223
+ limits: { fileSize: 5 * 1024 * 1024 },
224
+ }
225
+ : false,
204
226
  })
205
227
  ```
206
228
 
229
+ Return `false` from `multerOptions` when you want to skip Multer for a specific route even if `bodyFiles` are declared.
230
+
207
231
  ### Output validation and custom responders
208
232
 
209
233
  - `validateOutput: true` parses handler return values with the leaf `outputSchema`. Set to `false` to skip.
@@ -330,7 +354,7 @@ process.on('SIGTERM', () => sockets.destroy())
330
354
  - Post-response work should hook into `res.on('finish', handler)` from a middleware in the normal pipeline if you need to observe completed responses.
331
355
  - `compilePath`/param parsing exceptions bubble to Express error handlers; wrap `buildCtx`/middleware in try/catch if you need custom error shapes.
332
356
  - When `validateOutput` is true and no `outputSchema` exists, raw handler output is passed through.
333
- - `fromCfg.upload` runs only when `leaf.cfg.bodyFiles` is a non-empty array.
357
+ - `multerOptions` runs only when `leaf.cfg.bodyFiles` is a non-empty array; return `false` to disable the upload middleware for that route.
334
358
  - Socket `emit` will throw on invalid payloads; handle errors around broadcast loops.
335
359
 
336
360
  ## Scripts
package/dist/index.cjs CHANGED
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
+ var __create = Object.create;
2
3
  var __defProp = Object.defineProperty;
3
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
5
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
6
8
  var __export = (target, all) => {
7
9
  for (var name in all)
@@ -15,6 +17,14 @@ var __copyProps = (to, from, except, desc) => {
15
17
  }
16
18
  return to;
17
19
  };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
18
28
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
29
 
20
30
  // src/index.ts
@@ -36,6 +46,7 @@ var import_rrroutes_contract2 = require("@emeryld/rrroutes-contract");
36
46
 
37
47
  // src/routesV3.server.ts
38
48
  var import_rrroutes_contract = require("@emeryld/rrroutes-contract");
49
+ var import_multer = __toESM(require("multer"), 1);
39
50
  var serverDebugEventTypes = [
40
51
  "register",
41
52
  "request",
@@ -94,6 +105,59 @@ var decodeJsonLikeQueryValue = (value) => {
94
105
  return value;
95
106
  };
96
107
  var CTX_SYMBOL = /* @__PURE__ */ Symbol.for("typedLeaves.ctx");
108
+ var REQUEST_PAYLOAD_SYMBOL = /* @__PURE__ */ Symbol.for(
109
+ "typedLeaves.requestPayload"
110
+ );
111
+ function isMulterFile(value) {
112
+ if (!isPlainObject(value)) return false;
113
+ const candidate = value;
114
+ return typeof candidate.fieldname === "string";
115
+ }
116
+ function collectMulterFiles(req) {
117
+ const files = [];
118
+ const pushValue = (value) => {
119
+ if (value === void 0 || value === null) return;
120
+ if (Array.isArray(value)) {
121
+ value.forEach(pushValue);
122
+ return;
123
+ }
124
+ if (isMulterFile(value)) {
125
+ files.push(value);
126
+ return;
127
+ }
128
+ if (isPlainObject(value)) {
129
+ Object.values(value).forEach(pushValue);
130
+ }
131
+ };
132
+ pushValue(req.files);
133
+ pushValue(req.file);
134
+ return files;
135
+ }
136
+ function resolveBodyFilesFromRequest(req, fields) {
137
+ if (!fields?.length) return void 0;
138
+ const allowedNames = new Set(fields.map((field) => field.name));
139
+ const collected = collectMulterFiles(req);
140
+ if (collected.length === 0) return void 0;
141
+ const result = {};
142
+ for (const file of collected) {
143
+ if (!allowedNames.has(file.fieldname)) continue;
144
+ const bucket = result[file.fieldname] ?? [];
145
+ bucket.push(file);
146
+ result[file.fieldname] = bucket;
147
+ }
148
+ return Object.keys(result).length ? result : void 0;
149
+ }
150
+ function getRouteRequestPayload(res) {
151
+ const payload = res.locals[REQUEST_PAYLOAD_SYMBOL];
152
+ if (payload) {
153
+ return payload;
154
+ }
155
+ throw new Error("Request payload was not initialized before middleware");
156
+ }
157
+ function setRouteRequestPayload(res, payload) {
158
+ ;
159
+ res.locals[REQUEST_PAYLOAD_SYMBOL] = payload;
160
+ }
97
161
  function getCtx(res) {
98
162
  return res.locals[CTX_SYMBOL];
99
163
  }
@@ -111,6 +175,26 @@ function adaptCtxMw(mw) {
111
175
  }
112
176
  };
113
177
  }
178
+ function adaptRouteBeforeMw(mw) {
179
+ return (req, res, next) => {
180
+ try {
181
+ const result = mw({
182
+ req,
183
+ res,
184
+ next,
185
+ ctx: getCtx(res),
186
+ ...getRouteRequestPayload(res)
187
+ });
188
+ if (result && typeof result.then === "function") {
189
+ return result.catch((err) => next(err));
190
+ }
191
+ return result;
192
+ } catch (err) {
193
+ next(err);
194
+ return void 0;
195
+ }
196
+ };
197
+ }
114
198
  function logHandlerDebugWithRoutesLogger(logger, event) {
115
199
  if (!logger || event.type !== "handler") return;
116
200
  const payload = [
@@ -125,6 +209,9 @@ function logHandlerDebugWithRoutesLogger(logger, event) {
125
209
  ;
126
210
  (logger.debug ?? logger.verbose ?? logger.info ?? logger.log ?? logger.system)?.call(logger, ...payload);
127
211
  }
212
+ var defaultMulterOptions = {
213
+ storage: import_multer.default.memoryStorage()
214
+ };
128
215
  var defaultSend = (res, data) => {
129
216
  res.json(data);
130
217
  };
@@ -168,12 +255,22 @@ function createRRRoute(router, config) {
168
255
  (mw) => adaptCtxMw(mw)
169
256
  );
170
257
  const registered = getRegisteredRouteStore(router);
171
- const buildDerived = (leaf) => {
172
- const derived = [];
173
- if (config.fromCfg?.upload && Array.isArray(leaf.cfg.bodyFiles) && leaf.cfg.bodyFiles.length > 0) {
174
- derived.push(...config.fromCfg.upload(leaf.cfg.bodyFiles));
175
- }
176
- return derived;
258
+ const getMulterOptions = (fields) => {
259
+ if (!fields || fields.length === 0) return void 0;
260
+ const resolved = typeof config.multerOptions === "function" ? config.multerOptions(fields) : config.multerOptions;
261
+ if (resolved === false) return void 0;
262
+ return resolved ?? defaultMulterOptions;
263
+ };
264
+ const runMulterHandler = (handler, req, res) => {
265
+ return new Promise((resolve, reject) => {
266
+ handler(req, res, (err) => {
267
+ if (err) {
268
+ reject(err);
269
+ return;
270
+ }
271
+ resolve();
272
+ });
273
+ });
177
274
  };
178
275
  function register(leaf, def) {
179
276
  const method = leaf.method;
@@ -201,8 +298,84 @@ function createRRRoute(router, config) {
201
298
  const emit = (event) => activeEmit(event, debugName);
202
299
  const isVerboseDebug = activeDebugMode === "complete";
203
300
  emit({ type: "register", method: methodUpper, path });
204
- const routeSpecific = (def?.before ?? []).map((mw) => adaptCtxMw(mw));
205
- const derived = buildDerived(leaf);
301
+ const routeSpecific = (def?.before ?? []).map(
302
+ (mw) => adaptRouteBeforeMw(mw)
303
+ );
304
+ const resolvePayloadMw = async (req, res, next) => {
305
+ const requestUrl = req.originalUrl ?? path;
306
+ const startedAt = Date.now();
307
+ let params;
308
+ let query;
309
+ let body;
310
+ let bodyFiles;
311
+ try {
312
+ if (leaf.cfg.bodyFiles && leaf.cfg.bodyFiles.length > 0) {
313
+ const uploadOptions = getMulterOptions(leaf.cfg.bodyFiles);
314
+ if (uploadOptions) {
315
+ const fieldDefs = leaf.cfg.bodyFiles.map(({ name, maxCount }) => ({
316
+ name,
317
+ maxCount
318
+ }));
319
+ const uploader = (0, import_multer.default)(uploadOptions).fields(fieldDefs);
320
+ await runMulterHandler(uploader, req, res);
321
+ }
322
+ }
323
+ bodyFiles = resolveBodyFilesFromRequest(req, leaf.cfg.bodyFiles);
324
+ params = leaf.cfg.paramsSchema ? (0, import_rrroutes_contract.lowProfileParse)(leaf.cfg.paramsSchema, req.params) : Object.keys(req.params || {}).length ? req.params : void 0;
325
+ const hasQueryKeys = req.query && Object.keys(req.query || {}).length > 0;
326
+ const parsedQueryInput = leaf.cfg.querySchema && hasQueryKeys ? decodeJsonLikeQueryValue(req.query) : req.query;
327
+ if (leaf.cfg.querySchema) {
328
+ try {
329
+ query = (0, import_rrroutes_contract.lowProfileParse)(
330
+ leaf.cfg.querySchema,
331
+ parsedQueryInput
332
+ );
333
+ } catch (err) {
334
+ const parseError = new Error(
335
+ `Query parsing error: ${err.message ?? String(err)}`
336
+ );
337
+ parseError.raw = JSON.stringify(req.query);
338
+ parseError.cause = err;
339
+ throw parseError;
340
+ }
341
+ } else {
342
+ query = hasQueryKeys ? req.query : void 0;
343
+ }
344
+ body = leaf.cfg.bodySchema ? (0, import_rrroutes_contract.lowProfileParse)(leaf.cfg.bodySchema, req.body) : req.body !== void 0 ? req.body : void 0;
345
+ } catch (err) {
346
+ const payloadError = {
347
+ params,
348
+ query,
349
+ body,
350
+ bodyFiles
351
+ };
352
+ emit(
353
+ decorateDebugEvent(
354
+ isVerboseDebug,
355
+ {
356
+ type: "request",
357
+ stage: "error",
358
+ method: methodUpper,
359
+ path,
360
+ url: requestUrl,
361
+ durationMs: Date.now() - startedAt,
362
+ error: err
363
+ },
364
+ isVerboseDebug ? payloadError : void 0
365
+ )
366
+ );
367
+ next(err);
368
+ return;
369
+ }
370
+ const requestPayload = {
371
+ params,
372
+ query,
373
+ body,
374
+ bodyFiles
375
+ };
376
+ setRouteRequestPayload(res, requestPayload);
377
+ next();
378
+ };
206
379
  const ctxMw = async (req, res, next) => {
207
380
  const requestUrl = req.originalUrl ?? path;
208
381
  const startedAt = Date.now();
@@ -214,7 +387,10 @@ function createRRRoute(router, config) {
214
387
  url: requestUrl
215
388
  });
216
389
  try {
217
- const ctx = await config.buildCtx(req, res);
390
+ const ctx = await config.buildCtx({
391
+ req,
392
+ res
393
+ });
218
394
  res.locals[CTX_SYMBOL] = ctx;
219
395
  emit({
220
396
  type: "buildCtx",
@@ -239,9 +415,9 @@ function createRRRoute(router, config) {
239
415
  }
240
416
  };
241
417
  const before = [
418
+ resolvePayloadMw,
242
419
  ctxMw,
243
420
  ...globalBeforeMws,
244
- ...derived,
245
421
  ...routeSpecific
246
422
  ];
247
423
  const wrapped = async (req, res, next) => {
@@ -254,9 +430,11 @@ function createRRRoute(router, config) {
254
430
  path,
255
431
  url: requestUrl
256
432
  });
257
- let params;
258
- let query;
259
- let body;
433
+ const requestPayload = getRouteRequestPayload(res);
434
+ const params = requestPayload.params;
435
+ const query = requestPayload.query;
436
+ const body = requestPayload.body;
437
+ const bodyFiles = requestPayload.bodyFiles;
260
438
  let responsePayload;
261
439
  let hasResponsePayload = false;
262
440
  const downstreamNext = next;
@@ -274,26 +452,6 @@ function createRRRoute(router, config) {
274
452
  }
275
453
  };
276
454
  try {
277
- params = leaf.cfg.paramsSchema ? (0, import_rrroutes_contract.lowProfileParse)(leaf.cfg.paramsSchema, req.params) : Object.keys(req.params || {}).length ? req.params : void 0;
278
- try {
279
- const parsedQueryInput = leaf.cfg.querySchema && req.query ? decodeJsonLikeQueryValue(req.query) : req.query;
280
- query = leaf.cfg.querySchema ? (0, import_rrroutes_contract.lowProfileParse)(leaf.cfg.querySchema, parsedQueryInput) : Object.keys(req.query || {}).length ? req.query : void 0;
281
- } catch (e) {
282
- emitWithCtx({
283
- type: "request",
284
- stage: "error",
285
- method: methodUpper,
286
- path,
287
- url: requestUrl,
288
- error: {
289
- ...e,
290
- raw: JSON.stringify(req.query),
291
- message: `Query parsing error: ${e.message}`
292
- }
293
- });
294
- throw e;
295
- }
296
- body = leaf.cfg.bodySchema ? (0, import_rrroutes_contract.lowProfileParse)(leaf.cfg.bodySchema, req.body) : req.body !== void 0 ? req.body : void 0;
297
455
  const handlerStartedAt = Date.now();
298
456
  emitWithCtx(
299
457
  {
@@ -302,7 +460,7 @@ function createRRRoute(router, config) {
302
460
  method: methodUpper,
303
461
  path
304
462
  },
305
- isVerboseDebug ? { params, query, body } : void 0
463
+ isVerboseDebug ? { params, query, body, bodyFiles } : void 0
306
464
  );
307
465
  let result;
308
466
  try {
@@ -313,7 +471,8 @@ function createRRRoute(router, config) {
313
471
  ctx,
314
472
  params,
315
473
  query,
316
- body
474
+ body,
475
+ bodyFiles
317
476
  });
318
477
  emitWithCtx(
319
478
  {
@@ -327,6 +486,7 @@ function createRRRoute(router, config) {
327
486
  params,
328
487
  query,
329
488
  body,
489
+ bodyFiles,
330
490
  ...result !== void 0 ? { output: result } : {}
331
491
  } : void 0
332
492
  );
@@ -340,7 +500,7 @@ function createRRRoute(router, config) {
340
500
  durationMs: Date.now() - handlerStartedAt,
341
501
  error: e
342
502
  },
343
- isVerboseDebug ? { params, query, body } : void 0
503
+ isVerboseDebug ? { params, query, body, bodyFiles } : void 0
344
504
  );
345
505
  throw e;
346
506
  }
@@ -361,6 +521,7 @@ function createRRRoute(router, config) {
361
521
  params,
362
522
  query,
363
523
  body,
524
+ bodyFiles,
364
525
  ...hasResponsePayload ? { output: responsePayload } : {}
365
526
  } : void 0
366
527
  );
@@ -375,7 +536,7 @@ function createRRRoute(router, config) {
375
536
  durationMs: Date.now() - startedAt,
376
537
  error: err
377
538
  },
378
- isVerboseDebug ? { params, query, body } : void 0
539
+ isVerboseDebug ? { params, query, body, bodyFiles } : void 0
379
540
  );
380
541
  next(err);
381
542
  }
@@ -502,8 +663,12 @@ function createBuiltInConnectionHandlers(opts) {
502
663
  const pingEvent = "sys:ping";
503
664
  const pongEvent = "sys:pong";
504
665
  const heartbeatEnabled = heartbeat?.enabled !== false;
505
- const joinPayloadSchema = buildRoomPayloadSchema(config.joinMetaMessage);
506
- const leavePayloadSchema = buildRoomPayloadSchema(config.leaveMetaMessage);
666
+ const joinPayloadSchema = buildRoomPayloadSchema(
667
+ config.joinMetaMessage
668
+ );
669
+ const leavePayloadSchema = buildRoomPayloadSchema(
670
+ config.leaveMetaMessage
671
+ );
507
672
  const pingPayloadSchema = config.pingPayload;
508
673
  const pongPayloadSchema = config.pongPayload;
509
674
  const sysEvents = sys;