rpc-express-toolkit 4.2.0 → 4.3.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/src/index.js CHANGED
@@ -2,8 +2,19 @@ const path = require('path');
2
2
  const express = require('express');
3
3
  const expressPkg = require('express/package.json');
4
4
  const NestedError = require('nested-error-stacks');
5
+ const {
6
+ RpcClient,
7
+ RpcError,
8
+ RpcHttpError,
9
+ RpcSafeClient,
10
+ } = require('rpc-toolkit-js-client');
5
11
  const pkg = require('../package.json');
6
12
 
13
+ const rpcClientAssetsPath = path.dirname(
14
+ require.resolve('rpc-toolkit-js-client')
15
+ );
16
+ const DEFAULT_MAX_SERIALIZATION_DEPTH = 100;
17
+
7
18
  /** @typedef {import("express").Router} Router */
8
19
  /** @typedef {import("express").Request} Request */
9
20
  /** @typedef {import("express").Response} Response */
@@ -16,6 +27,7 @@ const {
16
27
  SchemaBuilder,
17
28
  } = require('./validation');
18
29
  const BatchHandler = require('./batch');
30
+ const { hasOwn, validateEnvelope } = require('./protocol');
19
31
 
20
32
  /**
21
33
  * Default properties to include in error serialization.
@@ -93,7 +105,7 @@ class RpcEndpoint {
93
105
  * The path from which client scripts will be served.
94
106
  */
95
107
  static serveScripts(router, url = '/vendor/rpc-client') {
96
- router.use(url, express.static(path.join(__dirname, 'clients')));
108
+ router.use(url, express.static(rpcClientAssetsPath));
97
109
  }
98
110
 
99
111
  /** @type {string} */
@@ -102,6 +114,12 @@ class RpcEndpoint {
102
114
  /** @type {{ [name: string]: any }} */
103
115
  #methods = {};
104
116
 
117
+ /** @type {string} */
118
+ #introspectionPrefix = '__rpc';
119
+
120
+ /** @type {boolean} */
121
+ #isInternalRegistration = false;
122
+
105
123
  /** @type {Logger} */
106
124
  #logger;
107
125
 
@@ -143,10 +161,21 @@ class RpcEndpoint {
143
161
  this.#options.safeEnabled = this.#options.safeEnabled === true; // Default false
144
162
  this.#options.warnOnUnsafe = this.#options.warnOnUnsafe !== false; // Default true
145
163
  this.#options.strictMode = this.#options.strictMode !== false; // Default true
164
+ this.#options.enableIntrospection =
165
+ this.#options.enableIntrospection === true; // Default false
166
+ this.#options.introspectionPrefix =
167
+ this.#options.introspectionPrefix || '__rpc'; // Default __rpc
168
+
169
+ this.#introspectionPrefix = this.#options.introspectionPrefix;
146
170
 
147
171
  // Validate that the router can handle JSON parsing
148
172
  this.#validateJsonMiddleware();
149
173
 
174
+ // Register introspection methods if enabled
175
+ if (this.#options.enableIntrospection) {
176
+ this.#registerIntrospectionMethods();
177
+ }
178
+
150
179
  // Setup built-in middleware if configured
151
180
  this.#setupBuiltInMiddleware();
152
181
 
@@ -184,6 +213,145 @@ class RpcEndpoint {
184
213
  }
185
214
  }
186
215
 
216
+ /**
217
+ * Register introspection methods (__rpc.*)
218
+ * @private
219
+ */
220
+ #registerIntrospectionMethods() {
221
+ this.#isInternalRegistration = true;
222
+
223
+ // __rpc.listMethods - List all user methods (excludes __rpc.* methods)
224
+ this.addMethod(
225
+ `${this.#introspectionPrefix}.listMethods`,
226
+ async (_req, _context, _params) =>
227
+ Object.keys(this.#methods).filter(
228
+ (name) => !name.startsWith(this.#introspectionPrefix)
229
+ ),
230
+ {
231
+ description: 'List all available RPC methods',
232
+ exposeSchema: true,
233
+ }
234
+ );
235
+
236
+ // __rpc.describe - Get schema and description of a specific method
237
+ this.addMethod(
238
+ `${this.#introspectionPrefix}.describe`,
239
+ async (_req, _context, params) => {
240
+ const methodName = params?.method;
241
+
242
+ if (!methodName || typeof methodName !== 'string') {
243
+ throw {
244
+ code: -32602,
245
+ message: 'Invalid params: method name required',
246
+ };
247
+ }
248
+
249
+ // Prevent introspection of __rpc.* methods
250
+ if (methodName.startsWith(this.#introspectionPrefix)) {
251
+ throw {
252
+ code: -32601,
253
+ message: 'Cannot describe introspection methods',
254
+ };
255
+ }
256
+
257
+ const methodConfig = this.#methods[methodName];
258
+ if (!methodConfig) {
259
+ throw { code: -32601, message: `Method not found: ${methodName}` };
260
+ }
261
+
262
+ // Check if schema is exposed
263
+ const config = typeof methodConfig === 'function' ? {} : methodConfig;
264
+ if (!config.exposeSchema) {
265
+ throw { code: -32601, message: 'Method schema not available' };
266
+ }
267
+
268
+ return {
269
+ name: methodName,
270
+ schema: config.schema || null,
271
+ description: config.description || '',
272
+ };
273
+ },
274
+ {
275
+ schema: {
276
+ type: 'object',
277
+ properties: {
278
+ method: { type: 'string' },
279
+ },
280
+ required: ['method'],
281
+ },
282
+ description: 'Get schema and description of a specific method',
283
+ exposeSchema: true,
284
+ }
285
+ );
286
+
287
+ // __rpc.describeAll - Get all methods with public schemas
288
+ this.addMethod(
289
+ `${this.#introspectionPrefix}.describeAll`,
290
+ async (_req, _context, _params) => {
291
+ const publicMethods = [];
292
+
293
+ for (const [name, methodConfig] of Object.entries(this.#methods)) {
294
+ // Skip introspection methods
295
+ if (name.startsWith(this.#introspectionPrefix)) continue;
296
+
297
+ const config = typeof methodConfig === 'function' ? {} : methodConfig;
298
+ if (config.exposeSchema) {
299
+ publicMethods.push({
300
+ name,
301
+ schema: config.schema || null,
302
+ description: config.description || '',
303
+ });
304
+ }
305
+ }
306
+
307
+ return publicMethods;
308
+ },
309
+ {
310
+ description: 'List all methods with public schemas',
311
+ exposeSchema: true,
312
+ }
313
+ );
314
+
315
+ // __rpc.version - Get toolkit version
316
+ this.addMethod(
317
+ `${this.#introspectionPrefix}.version`,
318
+ async (_req, _context, _params) => ({
319
+ toolkit: 'rpc-express-toolkit',
320
+ version: pkg.version,
321
+ expressVersion: expressPkg.version,
322
+ nodeVersion: process.version,
323
+ }),
324
+ {
325
+ description: 'Get RPC toolkit version information',
326
+ exposeSchema: true,
327
+ }
328
+ );
329
+
330
+ // __rpc.capabilities - Get server capabilities
331
+ this.addMethod(
332
+ `${this.#introspectionPrefix}.capabilities`,
333
+ async (_req, _context, _params) => ({
334
+ safeMode: this.#options.safeEnabled,
335
+ batch: true,
336
+ introspection: true,
337
+ introspectionPrefix: this.#introspectionPrefix,
338
+ validation: !!this.#options.validation,
339
+ cors: !!this.#options.cors,
340
+ auth: !!this.#options.auth,
341
+ rateLimit: !!this.#options.rateLimit,
342
+ methodCount: Object.keys(this.#methods).filter(
343
+ (n) => !n.startsWith(this.#introspectionPrefix)
344
+ ).length,
345
+ }),
346
+ {
347
+ description: 'Get server capabilities and configuration',
348
+ exposeSchema: true,
349
+ }
350
+ );
351
+
352
+ this.#isInternalRegistration = false;
353
+ }
354
+
187
355
  /**
188
356
  * Validate JSON middleware functionality and Express compatibility
189
357
  * @private
@@ -281,7 +449,13 @@ class RpcEndpoint {
281
449
  );
282
450
 
283
451
  // Only send response if there are results (non-notification requests)
284
- if (results.length > 0) {
452
+ if (!Array.isArray(results)) {
453
+ res.setHeader(
454
+ 'X-RPC-Safe-Enabled',
455
+ this.#options.safeEnabled ? 'true' : 'false'
456
+ );
457
+ res.json(results);
458
+ } else if (results.length > 0) {
285
459
  // Align batch responses with single-call header negotiation
286
460
  res.setHeader(
287
461
  'X-RPC-Safe-Enabled',
@@ -294,7 +468,7 @@ class RpcEndpoint {
294
468
 
295
469
  this.#logger.info('Batch request completed', {
296
470
  batchSize: req.body.length,
297
- responseCount: results.length,
471
+ responseCount: Array.isArray(results) ? results.length : 1,
298
472
  duration: Date.now() - startTime,
299
473
  });
300
474
 
@@ -330,34 +504,26 @@ class RpcEndpoint {
330
504
  * @param {number} startTime
331
505
  */
332
506
  async #processSingleRequest(req, res, context, startTime) {
333
- const { jsonrpc, method, params, id } = req.body || {};
507
+ const { method, params, id } = req.body || {};
508
+ const envelope = validateEnvelope(req.body);
334
509
 
335
510
  // Log the incoming request
336
511
  this.#logger.rpcCall(method, params, id, req);
337
512
 
338
- // Validate JSON-RPC 2.0 request
339
- if (jsonrpc !== '2.0') {
513
+ if (!envelope.valid) {
340
514
  return this.reply(res, {
341
- id,
342
- error: {
343
- code: -32600,
344
- message: `Invalid Request: 'jsonrpc' must be '2.0'.`,
345
- },
346
- });
347
- }
348
-
349
- if (typeof method !== 'string') {
350
- return this.reply(res, {
351
- id,
352
- error: {
353
- code: -32600,
354
- message: `Invalid Request: 'method' must be a string.`,
355
- },
515
+ id: envelope.responseId,
516
+ error: envelope.error,
356
517
  });
357
518
  }
358
519
 
359
520
  const methodConfig = this.#methods[method];
360
521
  if (!methodConfig) {
522
+ if (envelope.isNotification) {
523
+ res.status(204).end();
524
+ return null;
525
+ }
526
+
361
527
  return this.reply(res, {
362
528
  id,
363
529
  error: { code: -32601, message: `Method "${method}" not found` },
@@ -379,6 +545,11 @@ class RpcEndpoint {
379
545
  this.#options.safeEnabled &&
380
546
  !clientSafeHeader
381
547
  ) {
548
+ if (envelope.isNotification) {
549
+ res.status(204).end();
550
+ return null;
551
+ }
552
+
382
553
  return this.reply(res, {
383
554
  id,
384
555
  error: {
@@ -403,7 +574,7 @@ class RpcEndpoint {
403
574
  };
404
575
 
405
576
  // Deserialize parameters using client's safe options
406
- const deserializedParams = params
577
+ const deserializedParams = hasOwn(req.body, 'params')
407
578
  ? this.deserializeBigIntsAndDates(params, deserializationOptions)
408
579
  : params;
409
580
 
@@ -470,6 +641,11 @@ class RpcEndpoint {
470
641
 
471
642
  // Convert any BigInt/Date values before JSON-stringifying
472
643
  const safeResult = this.serializeBigIntsAndDates(result);
644
+ if (envelope.isNotification) {
645
+ res.status(204).end();
646
+ return null;
647
+ }
648
+
473
649
  this.reply(res, { id, result: safeResult });
474
650
  } catch (err) {
475
651
  const duration = Date.now() - startTime;
@@ -494,6 +670,11 @@ class RpcEndpoint {
494
670
  });
495
671
  }
496
672
 
673
+ if (envelope.isNotification) {
674
+ res.status(204).end();
675
+ return null;
676
+ }
677
+
497
678
  this.reply(res, {
498
679
  id,
499
680
  error: {
@@ -511,23 +692,60 @@ class RpcEndpoint {
511
692
  /**
512
693
  * Register a new JSON-RPC method.
513
694
  * @param {string} name The method name.
514
- * @param {Function|Object} handlerOrConfig The function to handle calls or config object.
515
- * @param {Object} [schema] Optional schema for parameter validation.
695
+ * @param {Function|Object} handlerOrConfig The function to handle calls or config object with handler, schema, exposeSchema, description.
696
+ * @param {Object} [optionsOrSchema] Optional config object or schema for backward compatibility.
516
697
  */
517
- addMethod(name, handlerOrConfig, schema = null) {
698
+ addMethod(name, handlerOrConfig, optionsOrSchema = null) {
699
+ // Prevent users from registering introspection methods
700
+ if (
701
+ name.startsWith(this.#introspectionPrefix) &&
702
+ !this.#isInternalRegistration
703
+ ) {
704
+ throw new Error(
705
+ `Method names starting with '${this.#introspectionPrefix}.' are reserved for RPC introspection`
706
+ );
707
+ }
708
+
709
+ let methodConfig;
710
+
518
711
  if (typeof handlerOrConfig === 'function') {
519
- this.#methods[name] = schema
520
- ? { handler: handlerOrConfig, schema }
521
- : handlerOrConfig;
712
+ // Function handler - optionsOrSchema can be schema (backward compat) or options object
713
+ if (
714
+ optionsOrSchema &&
715
+ (optionsOrSchema.schema ||
716
+ optionsOrSchema.exposeSchema ||
717
+ optionsOrSchema.description)
718
+ ) {
719
+ // New format: options object
720
+ methodConfig = {
721
+ handler: handlerOrConfig,
722
+ ...optionsOrSchema,
723
+ };
724
+ } else if (optionsOrSchema) {
725
+ // Old format: schema directly
726
+ methodConfig = {
727
+ handler: handlerOrConfig,
728
+ schema: optionsOrSchema,
729
+ };
730
+ } else {
731
+ // Just the handler
732
+ methodConfig = handlerOrConfig;
733
+ }
522
734
  } else if (typeof handlerOrConfig === 'object' && handlerOrConfig.handler) {
523
- this.#methods[name] = handlerOrConfig;
735
+ // Config object
736
+ methodConfig = handlerOrConfig;
524
737
  } else {
525
738
  throw new Error('Invalid handler configuration');
526
739
  }
527
740
 
741
+ this.#methods[name] = methodConfig;
742
+
743
+ const config = typeof methodConfig === 'function' ? {} : methodConfig;
528
744
  this.#logger.debug('Method registered', {
529
745
  method: name,
530
- hasSchema: !!schema,
746
+ hasSchema: !!config.schema,
747
+ exposeSchema: !!config.exposeSchema,
748
+ hasDescription: !!config.description,
531
749
  });
532
750
  }
533
751
 
@@ -603,6 +821,14 @@ class RpcEndpoint {
603
821
  return this.#validator;
604
822
  }
605
823
 
824
+ /**
825
+ * Get endpoint options
826
+ * @returns {Object}
827
+ */
828
+ get options() {
829
+ return { ...this.#options };
830
+ }
831
+
606
832
  /**
607
833
  * Get metrics data
608
834
  * @returns {Object}
@@ -658,14 +884,28 @@ class RpcEndpoint {
658
884
  * @returns {any} A version of `value` safe for JSON serialization.
659
885
  */
660
886
  serializeBigIntsAndDates(value) {
661
- return this.#serializeValue(value, false);
887
+ return this.#serializeValue(value, false, false, {
888
+ depth: 0,
889
+ seen: new WeakSet(),
890
+ });
662
891
  }
663
892
 
664
893
  /**
665
894
  * Internal serialization method with BigInt and Date warning tracking
666
895
  * @private
667
896
  */
668
- #serializeValue(value, hasBigInt = false, hasDate = false) {
897
+ #serializeValue(
898
+ value,
899
+ hasBigInt = false,
900
+ hasDate = false,
901
+ state = { depth: 0, seen: new WeakSet() }
902
+ ) {
903
+ const maxDepth =
904
+ this.#options.maxSerializationDepth || DEFAULT_MAX_SERIALIZATION_DEPTH;
905
+ if (state.depth > maxDepth) {
906
+ throw new Error('Serialization depth limit exceeded');
907
+ }
908
+
669
909
  if (typeof value === 'bigint') {
670
910
  // Warn if safeEnabled is disabled and we have BigInt
671
911
  if (
@@ -703,16 +943,47 @@ class RpcEndpoint {
703
943
  return value;
704
944
  }
705
945
  if (Array.isArray(value)) {
946
+ if (state.seen.has(value)) {
947
+ throw new Error('Circular reference detected during serialization');
948
+ }
949
+
950
+ state.seen.add(value);
706
951
  // Recurse into arrays - don't pass hasBigInt/hasDate to children
707
952
  // Each element should be checked independently for warnings
708
- return value.map((v) => this.#serializeValue(v, false, false));
953
+ try {
954
+ return value.map((v) =>
955
+ this.#serializeValue(v, false, false, {
956
+ depth: state.depth + 1,
957
+ seen: state.seen,
958
+ })
959
+ );
960
+ } finally {
961
+ state.seen.delete(value);
962
+ }
709
963
  }
710
964
  if (value && typeof value === 'object') {
965
+ if (state.seen.has(value)) {
966
+ throw new Error('Circular reference detected during serialization');
967
+ }
968
+
969
+ state.seen.add(value);
711
970
  // Recurse into plain objects - don't pass hasBigInt/hasDate to children
712
971
  // Each property should be checked independently for warnings
713
972
  const result = {};
714
- for (const [key, val] of Object.entries(value)) {
715
- result[key] = this.#serializeValue(val, false, false);
973
+ try {
974
+ Object.entries(value).forEach(([key, val]) => {
975
+ Object.defineProperty(result, key, {
976
+ value: this.#serializeValue(val, false, false, {
977
+ depth: state.depth + 1,
978
+ seen: state.seen,
979
+ }),
980
+ enumerable: true,
981
+ configurable: true,
982
+ writable: true,
983
+ });
984
+ });
985
+ } finally {
986
+ state.seen.delete(value);
716
987
  }
717
988
  return result;
718
989
  }
@@ -761,7 +1032,17 @@ class RpcEndpoint {
761
1032
  * @param {boolean} [options.safeEnabled] Whether to expect safe prefixes for strings and dates.
762
1033
  * @returns {any} The re-hydrated value.
763
1034
  */
764
- deserializeBigIntsAndDates(value, options = null) {
1035
+ deserializeBigIntsAndDates(value, options = null, state = null) {
1036
+ const traversalState = state || {
1037
+ depth: 0,
1038
+ seen: new WeakSet(),
1039
+ };
1040
+ const maxDepth =
1041
+ this.#options.maxDeserializationDepth || DEFAULT_MAX_SERIALIZATION_DEPTH;
1042
+ if (traversalState.depth > maxDepth) {
1043
+ throw new Error('Deserialization depth limit exceeded');
1044
+ }
1045
+
765
1046
  // Use provided options or fall back to server options
766
1047
  const safeEnabled = options
767
1048
  ? options.safeEnabled
@@ -808,17 +1089,47 @@ class RpcEndpoint {
808
1089
 
809
1090
  // 2. If it's an array, handle each element
810
1091
  if (Array.isArray(value)) {
811
- return value.map((v) => this.deserializeBigIntsAndDates(v, options));
1092
+ if (traversalState.seen.has(value)) {
1093
+ throw new Error('Circular reference detected during deserialization');
1094
+ }
1095
+
1096
+ traversalState.seen.add(value);
1097
+ try {
1098
+ return value.map((v) =>
1099
+ this.deserializeBigIntsAndDates(v, options, {
1100
+ depth: traversalState.depth + 1,
1101
+ seen: traversalState.seen,
1102
+ })
1103
+ );
1104
+ } finally {
1105
+ traversalState.seen.delete(value);
1106
+ }
812
1107
  }
813
1108
 
814
1109
  // 3. If it's a plain object, recurse into each property
815
1110
  if (value && typeof value === 'object') {
816
- return Object.fromEntries(
817
- Object.entries(value).map(([key, val]) => [
818
- key,
819
- this.deserializeBigIntsAndDates(val, options),
820
- ])
821
- );
1111
+ if (traversalState.seen.has(value)) {
1112
+ throw new Error('Circular reference detected during deserialization');
1113
+ }
1114
+
1115
+ traversalState.seen.add(value);
1116
+ const result = {};
1117
+ try {
1118
+ Object.entries(value).forEach(([key, val]) => {
1119
+ Object.defineProperty(result, key, {
1120
+ value: this.deserializeBigIntsAndDates(val, options, {
1121
+ depth: traversalState.depth + 1,
1122
+ seen: traversalState.seen,
1123
+ }),
1124
+ enumerable: true,
1125
+ configurable: true,
1126
+ writable: true,
1127
+ });
1128
+ });
1129
+ } finally {
1130
+ traversalState.seen.delete(value);
1131
+ }
1132
+ return result;
822
1133
  }
823
1134
 
824
1135
  // 4. For everything else (number, boolean, null, undefined, etc.), return as-is
@@ -826,13 +1137,13 @@ class RpcEndpoint {
826
1137
  }
827
1138
  }
828
1139
 
829
- // Import the client class
830
- const RpcClient = require('./clients/rpc-client');
831
-
832
1140
  // Export both server and client, plus utilities
833
1141
  module.exports = RpcEndpoint;
834
1142
  module.exports.RpcEndpoint = RpcEndpoint;
835
1143
  module.exports.RpcClient = RpcClient;
1144
+ module.exports.RpcError = RpcError;
1145
+ module.exports.RpcHttpError = RpcHttpError;
1146
+ module.exports.RpcSafeClient = RpcSafeClient;
836
1147
  module.exports.Logger = Logger;
837
1148
  module.exports.MiddlewareManager = MiddlewareManager;
838
1149
  module.exports.builtInMiddlewares = builtInMiddlewares;
package/src/logger.js CHANGED
@@ -54,9 +54,14 @@ class Logger {
54
54
  */
55
55
  safeStringify(obj) {
56
56
  try {
57
+ const seen = new WeakSet();
57
58
  return JSON.stringify(obj, (key, value) => {
58
59
  if (typeof value === 'bigint') return `${value.toString()}n`;
59
60
  if (value instanceof Date) return value.toISOString();
61
+ if (value && typeof value === 'object') {
62
+ if (seen.has(value)) return '[Circular]';
63
+ seen.add(value);
64
+ }
60
65
  return value;
61
66
  });
62
67
  } catch (e) {
@@ -156,7 +161,8 @@ class Logger {
156
161
  duration: `${duration}ms`,
157
162
  error: error.message,
158
163
  code: error.code,
159
- stack: error.stack,
164
+ stack:
165
+ this.levels[this.level] >= this.levels.debug ? error.stack : undefined,
160
166
  });
161
167
  }
162
168
 
@@ -175,14 +181,19 @@ class Logger {
175
181
  'secret',
176
182
  'auth',
177
183
  'authorization',
184
+ 'credential',
185
+ 'cookie',
186
+ 'session',
178
187
  ];
179
- const sanitized = Array.isArray(params) ? [...params] : { ...params };
188
+ const seen = new WeakSet();
180
189
 
181
190
  const sanitizeObj = (input) => {
182
191
  if (typeof input !== 'object' || input === null) return input;
192
+ if (seen.has(input)) return '[Circular]';
183
193
 
194
+ seen.add(input);
184
195
  const output = Array.isArray(input) ? [] : {};
185
- for (const [key, value] of Object.entries(input)) {
196
+ Object.entries(input).forEach(([key, value]) => {
186
197
  const isSensitive = sensitiveKeys.some((sensitive) =>
187
198
  key.toLowerCase().includes(sensitive)
188
199
  );
@@ -193,11 +204,12 @@ class Logger {
193
204
  } else {
194
205
  output[key] = value;
195
206
  }
196
- }
207
+ });
208
+ seen.delete(input);
197
209
  return output;
198
210
  };
199
211
 
200
- return sanitizeObj(sanitized);
212
+ return sanitizeObj(params);
201
213
  }
202
214
  }
203
215
 
package/src/middleware.js CHANGED
File without changes