@emeryld/rrroutes-server 2.6.1 → 2.6.2

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
@@ -1,7 +1,7 @@
1
1
  <!--
2
2
  Summary:
3
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).
4
+ - Documented debug options, per-route overrides, partial/complete registration helpers, batch endpoint registration (`batchLeaf`), missing-controller warnings, and socket server helpers (typed events, heartbeat, room hooks).
5
5
  - Missing: cluster/multi-process Socket.IO deployment notes and an end-to-end auth example (ctx + room allow/deny) to add later.
6
6
  -->
7
7
 
@@ -167,6 +167,49 @@ const server = createRRRoute(app, {
167
167
  - `registerControllers` accepts partial maps (missing routes are skipped); `bindAll` enforces completeness at compile time.
168
168
  - `warnMissingControllers(router, registry, logger)` inspects the Express stack and warns for any leaf without a handler.
169
169
 
170
+ ### Batch endpoint helper (`batchLeaf`)
171
+
172
+ Use `batchLeaf` to register one endpoint that dispatches multiple already-registered controllers by encoded route keys.
173
+
174
+ ```ts
175
+ import { batchLeaf } from '@emeryld/rrroutes-server'
176
+
177
+ const server = createRRRoute(app, { buildCtx })
178
+ server.registerControllers(registry, controllers)
179
+
180
+ // Defaults to POST. You can override with { method: 'put' } etc.
181
+ batchLeaf(server, '/v1/batch', registry)
182
+ ```
183
+
184
+ Client request body shape:
185
+
186
+ ```ts
187
+ {
188
+ [encodeURIComponent('GET /v1/users/:userId')]: {
189
+ params: { userId: 'u_1' },
190
+ },
191
+ [encodeURIComponent('PATCH /v1/users/:userId')]: {
192
+ params: { userId: 'u_1' },
193
+ body: { name: 'Emery' },
194
+ },
195
+ }
196
+ ```
197
+
198
+ Response shape (same encoded keys):
199
+
200
+ ```ts
201
+ {
202
+ [encodeURIComponent('GET /v1/users/:userId')]: { out: { ... }, meta: ... },
203
+ [encodeURIComponent('PATCH /v1/users/:userId')]: { out: { ... }, meta: ... },
204
+ }
205
+ ```
206
+
207
+ Notes:
208
+
209
+ - Register controllers before calling `batchLeaf(...)`; unknown keys fail at runtime.
210
+ - Dispatch uses `server.invoke(...)` for each entry, so each sub-call runs per-leaf parsing, `buildCtx`, `route.before`, handler execution, and output validation.
211
+ - Batch dispatch does not replay the full global middleware chain of the original route registration (`sanitizer`, `preCtx`, `postCtx`, Multer).
212
+
170
213
  ### Middleware order and ctx usage
171
214
 
172
215
  Order: `sanitizer` → `preCtx` → `resolve` → `ctx` → `postCtx` → `route.before` → handler.
package/dist/index.cjs CHANGED
@@ -31,6 +31,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  CTX_SYMBOL: () => CTX_SYMBOL,
34
+ batchLeaf: () => batchLeaf,
34
35
  bindAll: () => bindAll,
35
36
  bindExpressRoutes: () => bindExpressRoutes,
36
37
  buildLowProfileLeaf: () => import_rrroutes_contract.buildLowProfileLeaf,
@@ -383,6 +384,44 @@ function adaptRouteBeforeMw(mw) {
383
384
  }
384
385
  };
385
386
  }
387
+ function runRouteBeforeHandler(mw, args) {
388
+ return new Promise((resolve, reject) => {
389
+ let settled = false;
390
+ const next = (err) => {
391
+ if (settled) return;
392
+ settled = true;
393
+ if (err) {
394
+ reject(err);
395
+ return;
396
+ }
397
+ resolve();
398
+ };
399
+ try {
400
+ const result = mw({ ...args, next });
401
+ if (result && typeof result.then === "function") {
402
+ ;
403
+ result.then(() => {
404
+ if (settled) return;
405
+ settled = true;
406
+ resolve();
407
+ }).catch((err) => {
408
+ if (settled) return;
409
+ settled = true;
410
+ reject(err);
411
+ });
412
+ return;
413
+ }
414
+ if (!settled) {
415
+ settled = true;
416
+ resolve();
417
+ }
418
+ } catch (err) {
419
+ if (settled) return;
420
+ settled = true;
421
+ reject(err);
422
+ }
423
+ });
424
+ }
386
425
  function logHandlerDebugWithRoutesLogger(logger, event) {
387
426
  if (!logger || event.type !== "handler") return;
388
427
  const payload = [
@@ -435,6 +474,7 @@ function createRRRoute(router, config) {
435
474
  const send = config.send ?? defaultSend;
436
475
  const { emit: defaultEmitDebug, mode: defaultDebugMode } = createServerDebugEmitter(config.debug);
437
476
  const knownLeaves = /* @__PURE__ */ new Map();
477
+ const registeredDefs = /* @__PURE__ */ new Map();
438
478
  const decorateDebugEvent = (isVerbose, event, details) => {
439
479
  if (!isVerbose || !details) return event;
440
480
  return { ...event, ...details };
@@ -736,6 +776,71 @@ function createRRRoute(router, config) {
736
776
  };
737
777
  router[method](path, ...before, wrapped);
738
778
  registered.add(key);
779
+ registeredDefs.set(key, {
780
+ leaf,
781
+ def
782
+ });
783
+ }
784
+ async function invoke(key, args) {
785
+ const registration = registeredDefs.get(key);
786
+ if (!registration) {
787
+ throw new Error(`No controller registered for route: ${key}`);
788
+ }
789
+ const { leaf, def } = registration;
790
+ const req = args.req;
791
+ const res = args.res;
792
+ const next = args.next ?? (() => void 0);
793
+ const parsedParams = leaf.cfg.paramsSchema ? (0, import_rrroutes_contract.lowProfileParse)(leaf.cfg.paramsSchema, args.params) : args.params;
794
+ const parsedQueryInput = leaf.cfg.querySchema ? decodeJsonLikeQueryValue(args.query) : args.query;
795
+ let parsedQuery = parsedQueryInput;
796
+ if (leaf.cfg.querySchema) {
797
+ try {
798
+ parsedQuery = (0, import_rrroutes_contract.lowProfileParse)(
799
+ leaf.cfg.querySchema,
800
+ parsedQueryInput
801
+ );
802
+ } catch (err) {
803
+ const parseError = new Error(
804
+ `Query parsing error: ${err.message ?? String(err)}`
805
+ );
806
+ parseError.raw = JSON.stringify(args.query);
807
+ parseError.cause = err;
808
+ throw parseError;
809
+ }
810
+ }
811
+ const parsedBody = leaf.cfg.bodySchema ? (0, import_rrroutes_contract.lowProfileParse)(leaf.cfg.bodySchema, args.body) : args.body;
812
+ const payload = {
813
+ params: parsedParams,
814
+ query: parsedQuery,
815
+ body: parsedBody,
816
+ bodyFiles: args.bodyFiles
817
+ };
818
+ setRouteRequestPayload(res, payload);
819
+ const ctx = args.ctx ?? await config.buildCtx({
820
+ req,
821
+ res
822
+ });
823
+ res.locals[CTX_SYMBOL] = ctx;
824
+ for (const before of def.before ?? []) {
825
+ await runRouteBeforeHandler(before, {
826
+ req,
827
+ res,
828
+ ctx,
829
+ ...payload
830
+ });
831
+ }
832
+ const result = await def.handler({
833
+ req,
834
+ res,
835
+ next,
836
+ ctx,
837
+ params: payload.params,
838
+ query: payload.query,
839
+ body: payload.body,
840
+ bodyFiles: payload.bodyFiles
841
+ });
842
+ const output = validateOutput && leaf.cfg.outputSchema ? (0, import_rrroutes_contract.lowProfileParse)(leaf.cfg.outputSchema, result) : result;
843
+ return output;
739
844
  }
740
845
  function registerControllers(registry, controllers, all) {
741
846
  for (const leaf of registry.all) {
@@ -780,6 +885,7 @@ function createRRRoute(router, config) {
780
885
  register,
781
886
  registerControllers,
782
887
  warnMissingControllers: warnMissing,
888
+ invoke,
783
889
  getRegisteredKeys: () => Array.from(registered)
784
890
  };
785
891
  }
@@ -793,6 +899,51 @@ function bindAll(router, registry, controllers, config) {
793
899
  server.registerControllers(registry, controllers);
794
900
  return router;
795
901
  }
902
+ function batchLeaf(server, path, registry, options) {
903
+ const method = String(options?.method ?? "post").toLowerCase();
904
+ const allowedMethods = ["get", "post", "put", "patch", "delete"];
905
+ if (!allowedMethods.includes(method)) {
906
+ throw new Error(
907
+ `Invalid batch method "${String(options?.method)}". Expected one of: ${allowedMethods.join(", ")}.`
908
+ );
909
+ }
910
+ ;
911
+ server.router[method](
912
+ path,
913
+ async (req, res, next) => {
914
+ try {
915
+ const body = req.body;
916
+ if (!isPlainObject2(body)) {
917
+ throw new Error(
918
+ "Batch request body must be a plain object keyed by encoded route identifiers."
919
+ );
920
+ }
921
+ const output = {};
922
+ for (const [encodedKey, value] of Object.entries(body)) {
923
+ const decodedKey = decodeURIComponent(encodedKey);
924
+ const leaf = registry.byKey[decodedKey];
925
+ if (!leaf) {
926
+ throw new Error(`Unknown batch route key: ${decodedKey}`);
927
+ }
928
+ const payload = isPlainObject2(value) ? value : {};
929
+ output[encodedKey] = await server.invoke(decodedKey, {
930
+ req,
931
+ res,
932
+ next,
933
+ params: payload.params,
934
+ query: payload.query,
935
+ body: payload.body,
936
+ bodyFiles: payload.bodyFiles
937
+ });
938
+ }
939
+ res.json(output);
940
+ } catch (err) {
941
+ next(err);
942
+ }
943
+ }
944
+ );
945
+ return server.router;
946
+ }
796
947
  var defineControllers = () => (m) => m;
797
948
  function warnMissingControllers(router, registry, logger) {
798
949
  const registeredStore = router[REGISTERED_ROUTES_SYMBOL];
@@ -1621,6 +1772,7 @@ var createConnectionLoggingMiddleware = (options = {}) => {
1621
1772
  // Annotate the CommonJS export names for ESM import in node:
1622
1773
  0 && (module.exports = {
1623
1774
  CTX_SYMBOL,
1775
+ batchLeaf,
1624
1776
  bindAll,
1625
1777
  bindExpressRoutes,
1626
1778
  buildLowProfileLeaf,