rpc-express-toolkit 3.0.2 → 4.0.1

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.d.ts CHANGED
@@ -96,17 +96,16 @@ interface RpcEndpointOptions {
96
96
  prefix?: string;
97
97
  maxBodySize?: string;
98
98
  timeout?: number;
99
- cors?: boolean | CorsOptions;
99
+ cors?: boolean | CorsConfig;
100
100
  auth?: AuthConfig;
101
101
  validation?: ValidationConfig;
102
- safeStringEnabled?: boolean;
103
- safeDateEnabled?: boolean;
102
+ safeEnabled?: boolean;
104
103
  crossConfigurationEnabled?: boolean;
105
104
  enableSchema?: boolean;
106
105
  rateLimit?: RateLimitConfig;
107
106
  logging?: LoggingConfig;
108
- warnOnUnsafeString?: boolean;
109
- warnOnUnsafeDate?: boolean;
107
+ warnOnUnsafe?: boolean;
108
+ strictMode?: boolean;
110
109
  autoJsonMiddleware?: boolean;
111
110
  jsonOptions?: {
112
111
  limit?: string;
@@ -114,24 +113,26 @@ interface RpcEndpointOptions {
114
113
  type?: string | string[] | ((req: any) => boolean);
115
114
  verify?: (req: any, res: any, buf: Buffer, encoding: string) => void;
116
115
  };
116
+ // Additional options supported by implementation
117
+ methodWhitelist?: string[];
118
+ timing?: boolean;
119
+ metrics?: boolean;
120
+ healthCheck?: boolean;
117
121
  }
118
122
 
119
123
  /**
120
124
  * RPC client configuration options
121
125
  */
122
126
  interface RpcClientOptions {
123
- safeStringEnabled?: boolean;
124
- safeDateEnabled?: boolean;
125
- warnOnUnsafeString?: boolean;
126
- warnOnUnsafeDate?: boolean;
127
+ safeEnabled?: boolean;
128
+ warnOnUnsafe?: boolean;
127
129
  }
128
130
 
129
131
  /**
130
132
  * Deserialization options for safe prefixes
131
133
  */
132
134
  interface DeserializationOptions {
133
- safeStringEnabled?: boolean;
134
- safeDateEnabled?: boolean;
135
+ safeEnabled?: boolean;
135
136
  }
136
137
 
137
138
  /**
@@ -236,12 +237,12 @@ declare class RpcClient {
236
237
  /**
237
238
  * Make a JSON-RPC call to the server.
238
239
  */
239
- call(
240
+ call<T = any>(
240
241
  method: string,
241
242
  params?: any,
242
243
  id?: string | number | null,
243
244
  overrideHeaders?: Record<string, string>
244
- ): Promise<any>;
245
+ ): Promise<T>;
245
246
 
246
247
  /**
247
248
  * Make a JSON-RPC notification (no response expected).
@@ -255,7 +256,7 @@ declare class RpcClient {
255
256
  /**
256
257
  * Make a batch JSON-RPC call.
257
258
  */
258
- batch(
259
+ batch<T = any>(
259
260
  requests: Array<{
260
261
  method: string;
261
262
  params?: any;
@@ -263,7 +264,7 @@ declare class RpcClient {
263
264
  notification?: boolean;
264
265
  }>,
265
266
  overrideHeaders?: Record<string, string>
266
- ): Promise<any[]>;
267
+ ): Promise<T[]>;
267
268
 
268
269
  /**
269
270
  * Recursively serialize BigInt and Date to JSON-safe string formats.
package/src/index.js CHANGED
@@ -1,11 +1,13 @@
1
1
  const path = require('path');
2
2
  const express = require('express');
3
+ const expressPkg = require('express/package.json');
4
+ const NestedError = require('nested-error-stacks');
5
+ const pkg = require('../package.json');
3
6
 
4
7
  /** @typedef {import("express").Router} Router */
5
8
  /** @typedef {import("express").Request} Request */
6
9
  /** @typedef {import("express").Response} Response */
7
10
 
8
- const NestedError = require('nested-error-stacks');
9
11
  const Logger = require('./logger');
10
12
  const { MiddlewareManager, builtInMiddlewares } = require('./middleware');
11
13
  const {
@@ -138,10 +140,9 @@ class RpcEndpoint {
138
140
  this.#batchHandler = new BatchHandler(this);
139
141
 
140
142
  // Set default options
141
- this.#options.safeStringEnabled = this.#options.safeStringEnabled !== false; // Default true
142
- this.#options.safeDateEnabled = this.#options.safeDateEnabled !== false; // Default true
143
- this.#options.warnOnUnsafeString = this.#options.warnOnUnsafeString !== false; // Default true
144
- this.#options.warnOnUnsafeDate = this.#options.warnOnUnsafeDate !== false; // Default true
143
+ this.#options.safeEnabled = this.#options.safeEnabled === true; // Default false
144
+ this.#options.warnOnUnsafe = this.#options.warnOnUnsafe !== false; // Default true
145
+ this.#options.strictMode = this.#options.strictMode !== false; // Default true
145
146
 
146
147
  // Validate that the router can handle JSON parsing
147
148
  this.#validateJsonMiddleware();
@@ -191,41 +192,47 @@ class RpcEndpoint {
191
192
  try {
192
193
  // Check if express.json is available (Express 4.16.0+)
193
194
  if (typeof express.json !== 'function') {
194
- throw new Error('express.json() is not available. Please ensure you are using Express 4.16.0 or later, or install body-parser separately.');
195
+ throw new Error(
196
+ 'express.json() is not available. Please ensure you are using Express 4.16.0 or later, or install body-parser separately.'
197
+ );
195
198
  }
196
-
199
+
197
200
  // Test JSON middleware creation with default options
198
201
  const jsonMiddleware = express.json({ limit: '10mb' });
199
-
202
+
200
203
  if (typeof jsonMiddleware !== 'function') {
201
- throw new Error('express.json() did not return a valid middleware function');
204
+ throw new Error(
205
+ 'express.json() did not return a valid middleware function'
206
+ );
202
207
  }
203
-
208
+
204
209
  // Verify middleware has correct signature (req, res, next)
205
210
  if (jsonMiddleware.length !== 3) {
206
- throw new Error('JSON middleware has invalid signature - expected (req, res, next)');
211
+ throw new Error(
212
+ 'JSON middleware has invalid signature - expected (req, res, next)'
213
+ );
207
214
  }
208
-
215
+
209
216
  this.#logger.debug('JSON middleware validation passed', {
210
217
  expressJsonAvailable: true,
211
218
  middlewareValid: true,
212
- endpoint: this.#endpoint
219
+ endpoint: this.#endpoint,
213
220
  });
214
-
215
221
  } catch (error) {
216
- const errorMsg = `JSON middleware validation failed: ${error.message}\n` +
222
+ const errorMsg =
223
+ `JSON middleware validation failed: ${error.message}\n` +
217
224
  `This router cannot handle JSON-RPC requests properly.\n\n` +
218
225
  `Possible solutions:\n` +
219
226
  `- Update Express: npm install express@latest (requires >=4.16.0)\n` +
220
227
  `- Add JSON parsing middleware manually: router.use(express.json())\n` +
221
228
  `- Install body-parser: npm install body-parser`;
222
-
229
+
223
230
  this.#logger.error('JSON middleware validation failed', {
224
231
  error: error.message,
225
- expressVersion: require('express/package.json')?.version || 'unknown',
226
- endpoint: this.#endpoint
232
+ expressVersion: expressPkg?.version || 'unknown',
233
+ endpoint: this.#endpoint,
227
234
  });
228
-
235
+
229
236
  throw new Error(errorMsg);
230
237
  }
231
238
  }
@@ -242,7 +249,7 @@ class RpcEndpoint {
242
249
  res.json({
243
250
  status: 'ok',
244
251
  timestamp: new Date().toISOString(),
245
- version: require('../package.json').version,
252
+ version: pkg.version,
246
253
  });
247
254
  });
248
255
  }
@@ -275,6 +282,11 @@ class RpcEndpoint {
275
282
 
276
283
  // Only send response if there are results (non-notification requests)
277
284
  if (results.length > 0) {
285
+ // Align batch responses with single-call header negotiation
286
+ res.setHeader(
287
+ 'X-RPC-Safe-Enabled',
288
+ this.#options.safeEnabled ? 'true' : 'false'
289
+ );
278
290
  res.json(results);
279
291
  } else {
280
292
  res.status(204).end(); // No content for all-notification batch
@@ -359,17 +371,41 @@ class RpcEndpoint {
359
371
 
360
372
  try {
361
373
  // Check client's safe options from request headers
362
- const clientSafeStringEnabled = req.headers['x-rpc-safestring-enabled'] === 'true';
363
- const clientSafeDateEnabled = req.headers['x-rpc-safedate-enabled'] === 'true';
374
+ const clientSafeHeader = req.headers['x-rpc-safe-enabled'];
375
+
376
+ // Strict mode: se server ha safe enabled ma client non invia header → JSON-RPC ERROR
377
+ if (
378
+ this.#options.strictMode &&
379
+ this.#options.safeEnabled &&
380
+ !clientSafeHeader
381
+ ) {
382
+ return this.reply(res, {
383
+ id,
384
+ error: {
385
+ code: -32600, // Invalid Request
386
+ message:
387
+ 'RPC Compatibility Error: Server requires safe serialization header but client did not provide it.',
388
+ data: {
389
+ serverSafeEnabled: this.#options.safeEnabled,
390
+ requiredHeader: 'X-RPC-Safe-Enabled',
391
+ strictMode: true,
392
+ solution:
393
+ 'Update client to rpc-express-toolkit v4+ or disable server strict mode',
394
+ },
395
+ },
396
+ });
397
+ }
364
398
 
365
- // Create deserialization options based on client's configuration
399
+ // Use client's safe options for parameter deserialization
400
+ const clientSafeEnabled = clientSafeHeader === 'true';
366
401
  const deserializationOptions = {
367
- safeStringEnabled: clientSafeStringEnabled,
368
- safeDateEnabled: clientSafeDateEnabled
402
+ safeEnabled: clientSafeEnabled,
369
403
  };
370
404
 
371
405
  // Deserialize parameters using client's safe options
372
- const deserializedParams = params ? this.deserializeBigIntsAndDates(params, deserializationOptions) : params;
406
+ const deserializedParams = params
407
+ ? this.deserializeBigIntsAndDates(params, deserializationOptions)
408
+ : params;
373
409
 
374
410
  // Prepare middleware context
375
411
  let middlewareContext = {
@@ -468,6 +504,8 @@ class RpcEndpoint {
468
504
  },
469
505
  });
470
506
  }
507
+ // Ensure explicit return for consistent-return rule
508
+ return null;
471
509
  }
472
510
 
473
511
  /**
@@ -602,8 +640,11 @@ class RpcEndpoint {
602
640
  }
603
641
 
604
642
  // Add safe options headers so client knows how to deserialize
605
- res.setHeader('X-RPC-SafeString-Enabled', this.#options.safeStringEnabled ? 'true' : 'false');
606
- res.setHeader('X-RPC-SafeDate-Enabled', this.#options.safeDateEnabled ? 'true' : 'false');
643
+ // Send safe options headers to client
644
+ res.setHeader(
645
+ 'X-RPC-Safe-Enabled',
646
+ this.#options.safeEnabled ? 'true' : 'false'
647
+ );
607
648
 
608
649
  res.json(response);
609
650
  }
@@ -626,31 +667,47 @@ class RpcEndpoint {
626
667
  */
627
668
  #serializeValue(value, hasBigInt = false, hasDate = false) {
628
669
  if (typeof value === 'bigint') {
629
- // Warn if safeStringEnabled is disabled and we have BigInt
630
- if (!this.#options.safeStringEnabled && !hasBigInt && this.#options.warnOnUnsafeString) {
631
- this.#logger.warn('BigInt detected in serialization. Consider enabling safeStringEnabled option to avoid potential string/BigInt confusion.');
670
+ // Warn if safeEnabled is disabled and we have BigInt
671
+ if (
672
+ !this.#options.safeEnabled &&
673
+ !hasBigInt &&
674
+ this.#options.warnOnUnsafe
675
+ ) {
676
+ this.#logger.warn(
677
+ 'BigInt detected in serialization. Consider enabling safeEnabled option to avoid potential string/BigInt confusion.'
678
+ );
632
679
  }
633
680
  // Convert BigInt to string with 'n' suffix for proper deserialization
634
- return value.toString() + 'n';
635
- } else if (value instanceof Date) {
636
- // Warn if safeDateEnabled is disabled and we have Date
637
- if (!this.#options.safeDateEnabled && !hasDate && this.#options.warnOnUnsafeDate) {
638
- this.#logger.warn('Date detected in serialization. Consider enabling safeDateEnabled option to avoid potential string/Date confusion.');
681
+ return `${value.toString()}n`;
682
+ }
683
+ if (value instanceof Date) {
684
+ // Warn if safeEnabled is disabled and we have Date
685
+ if (
686
+ !this.#options.safeEnabled &&
687
+ !hasDate &&
688
+ this.#options.warnOnUnsafe
689
+ ) {
690
+ this.#logger.warn(
691
+ 'Date detected in serialization. Consider enabling safeEnabled option to avoid potential string/Date confusion.'
692
+ );
639
693
  }
640
- // Convert Date to ISO string with D: prefix if safeDateEnabled
694
+ // Convert Date to ISO string with D: prefix if safeEnabled
641
695
  const isoString = value.toISOString();
642
- return this.#options.safeDateEnabled ? `D:${isoString}` : isoString;
643
- } else if (typeof value === 'string') {
644
- // Add S: prefix if safeStringEnabled is true
645
- if (this.#options.safeStringEnabled) {
646
- return 'S:' + value;
696
+ return this.#options.safeEnabled ? `D:${isoString}` : isoString;
697
+ }
698
+ if (typeof value === 'string') {
699
+ // Add S: prefix if safeEnabled is true
700
+ if (this.#options.safeEnabled) {
701
+ return `S:${value}`;
647
702
  }
648
703
  return value;
649
- } else if (Array.isArray(value)) {
704
+ }
705
+ if (Array.isArray(value)) {
650
706
  // Recurse into arrays - don't pass hasBigInt/hasDate to children
651
707
  // Each element should be checked independently for warnings
652
708
  return value.map((v) => this.#serializeValue(v, false, false));
653
- } else if (value && typeof value === 'object') {
709
+ }
710
+ if (value && typeof value === 'object') {
654
711
  // Recurse into plain objects - don't pass hasBigInt/hasDate to children
655
712
  // Each property should be checked independently for warnings
656
713
  const result = {};
@@ -670,9 +727,10 @@ class RpcEndpoint {
670
727
  */
671
728
  #hasDeepBigInt(obj) {
672
729
  if (typeof obj === 'bigint') return true;
673
- if (Array.isArray(obj)) return obj.some(item => this.#hasDeepBigInt(item));
730
+ if (Array.isArray(obj))
731
+ return obj.some((item) => this.#hasDeepBigInt(item));
674
732
  if (obj && typeof obj === 'object') {
675
- return Object.values(obj).some(val => this.#hasDeepBigInt(val));
733
+ return Object.values(obj).some((val) => this.#hasDeepBigInt(val));
676
734
  }
677
735
  return false;
678
736
  }
@@ -683,9 +741,9 @@ class RpcEndpoint {
683
741
  */
684
742
  #hasDeepDate(obj) {
685
743
  if (obj instanceof Date) return true;
686
- if (Array.isArray(obj)) return obj.some(item => this.#hasDeepDate(item));
744
+ if (Array.isArray(obj)) return obj.some((item) => this.#hasDeepDate(item));
687
745
  if (obj && typeof obj === 'object') {
688
- return Object.values(obj).some(val => this.#hasDeepDate(val));
746
+ return Object.values(obj).some((val) => this.#hasDeepDate(val));
689
747
  }
690
748
  return false;
691
749
  }
@@ -700,14 +758,14 @@ class RpcEndpoint {
700
758
  *
701
759
  * @param {any} value The value to deserialize.
702
760
  * @param {Object} [options] Custom deserialization options (uses server options if not provided).
703
- * @param {boolean} [options.safeStringEnabled] Whether to expect S: prefixed strings.
704
- * @param {boolean} [options.safeDateEnabled] Whether to expect D: prefixed dates.
761
+ * @param {boolean} [options.safeEnabled] Whether to expect safe prefixes for strings and dates.
705
762
  * @returns {any} The re-hydrated value.
706
763
  */
707
764
  deserializeBigIntsAndDates(value, options = null) {
708
765
  // Use provided options or fall back to server options
709
- const safeStringEnabled = options ? options.safeStringEnabled : this.#options.safeStringEnabled;
710
- const safeDateEnabled = options ? options.safeDateEnabled : this.#options.safeDateEnabled;
766
+ const safeEnabled = options
767
+ ? options.safeEnabled
768
+ : this.#options.safeEnabled;
711
769
 
712
770
  // More comprehensive ISO date regex that handles:
713
771
  // - UTC: 2023-01-01T12:00:00.000Z
@@ -718,17 +776,17 @@ class RpcEndpoint {
718
776
 
719
777
  // 1. Check if it's a string that might be a BigInt, Date, or safe string
720
778
  if (typeof value === 'string') {
721
- // Safe string check: if safeStringEnabled and starts with S:
722
- if (safeStringEnabled && value.startsWith('S:')) {
779
+ // Safe string check: if safeEnabled and starts with S:
780
+ if (safeEnabled && value.startsWith('S:')) {
723
781
  return value.substring(2); // Remove 'S:' prefix
724
782
  }
725
783
 
726
- // Safe date check: if safeDateEnabled and starts with D:
727
- if (safeDateEnabled && value.startsWith('D:')) {
784
+ // Safe date check: if safeEnabled and starts with D:
785
+ if (safeEnabled && value.startsWith('D:')) {
728
786
  const isoString = value.substring(2); // Remove 'D:' prefix
729
787
  const date = new Date(isoString);
730
788
  // Double-check that we got a valid date
731
- if (!isNaN(date.getTime())) {
789
+ if (!Number.isNaN(date.getTime())) {
732
790
  return date;
733
791
  }
734
792
  }
@@ -738,11 +796,11 @@ class RpcEndpoint {
738
796
  return BigInt(value.slice(0, -1)); // Remove 'n' and convert
739
797
  }
740
798
 
741
- // Date check: matches an ISO 8601 string (only if safeDateEnabled is false)
742
- if (!safeDateEnabled && ISO_DATE_REGEX.test(value)) {
799
+ // Date check: matches an ISO 8601 string (only if safeEnabled is false)
800
+ if (!safeEnabled && ISO_DATE_REGEX.test(value)) {
743
801
  const date = new Date(value);
744
802
  // Double-check that we got a valid date
745
- if (!isNaN(date.getTime())) {
803
+ if (!Number.isNaN(date.getTime())) {
746
804
  return date;
747
805
  }
748
806
  }
package/src/logger.js CHANGED
@@ -47,6 +47,24 @@ class Logger {
47
47
  };
48
48
  }
49
49
 
50
+ /**
51
+ * Safely stringify objects that may contain BigInt/Date
52
+ * @param {any} obj
53
+ * @returns {string}
54
+ */
55
+ safeStringify(obj) {
56
+ try {
57
+ return JSON.stringify(obj, (key, value) => {
58
+ if (typeof value === 'bigint') return `${value.toString()}n`;
59
+ if (value instanceof Date) return value.toISOString();
60
+ return value;
61
+ });
62
+ } catch (e) {
63
+ // Fallback: best-effort toString
64
+ return String(obj);
65
+ }
66
+ }
67
+
50
68
  /**
51
69
  * Generic log method
52
70
  * @param {string} level
@@ -63,7 +81,7 @@ class Logger {
63
81
  } else {
64
82
  const logMessage = `${this.prefix} ${logData.timestamp} [${logData.level}] ${message}`;
65
83
  const metaStr =
66
- Object.keys(meta).length > 0 ? ` | ${JSON.stringify(meta)}` : '';
84
+ Object.keys(meta).length > 0 ? ` | ${this.safeStringify(meta)}` : '';
67
85
  console[level === 'debug' || level === 'trace' ? 'log' : level](
68
86
  `${logMessage}${metaStr}`
69
87
  );
@@ -120,7 +138,7 @@ class Logger {
120
138
  id,
121
139
  duration: `${duration}ms`,
122
140
  resultType: typeof result,
123
- resultSize: result ? JSON.stringify(result).length : 0,
141
+ resultSize: result ? this.safeStringify(result).length : 0,
124
142
  });
125
143
  }
126
144
 
@@ -160,21 +178,23 @@ class Logger {
160
178
  ];
161
179
  const sanitized = Array.isArray(params) ? [...params] : { ...params };
162
180
 
163
- const sanitizeObj = (obj) => {
164
- if (typeof obj !== 'object' || obj === null) return obj;
165
-
166
- for (const [key, value] of Object.entries(obj)) {
167
- if (
168
- sensitiveKeys.some((sensitive) =>
169
- key.toLowerCase().includes(sensitive)
170
- )
171
- ) {
172
- obj[key] = '[REDACTED]';
173
- } else if (typeof value === 'object') {
174
- sanitizeObj(value);
181
+ const sanitizeObj = (input) => {
182
+ if (typeof input !== 'object' || input === null) return input;
183
+
184
+ const output = Array.isArray(input) ? [] : {};
185
+ for (const [key, value] of Object.entries(input)) {
186
+ const isSensitive = sensitiveKeys.some((sensitive) =>
187
+ key.toLowerCase().includes(sensitive)
188
+ );
189
+ if (isSensitive) {
190
+ output[key] = '[REDACTED]';
191
+ } else if (typeof value === 'object' && value !== null) {
192
+ output[key] = sanitizeObj(value);
193
+ } else {
194
+ output[key] = value;
175
195
  }
176
196
  }
177
- return obj;
197
+ return output;
178
198
  };
179
199
 
180
200
  return sanitizeObj(sanitized);
package/src/middleware.js CHANGED
@@ -42,32 +42,47 @@ class MiddlewareManager {
42
42
  */
43
43
  async execute(hook, context) {
44
44
  const middlewares = this.middlewares[hook] || [];
45
- let result = context;
46
-
47
- for (const middleware of middlewares) {
48
- try {
49
- const middlewareResult = await middleware(result);
50
-
51
- // If middleware returns an object, merge it with context
52
- if (middlewareResult && typeof middlewareResult === 'object') {
53
- result = { ...result, ...middlewareResult };
54
- }
55
-
56
- // If middleware explicitly returns false, stop execution
57
- if (middlewareResult === false) {
58
- throw new Error('Middleware execution stopped');
59
- }
60
- } catch (error) {
61
- // For error hooks, continue even if middleware fails
62
- if (hook === 'onError') {
63
- console.error('Error middleware failed:', error);
64
- continue;
65
- }
66
- throw error;
67
- }
45
+ const isErrorHook = hook === 'onError';
46
+
47
+ const finalResult = await middlewares.reduce(
48
+ (promise, middleware) =>
49
+ promise.then(async (acc) => {
50
+ // If a previous middleware requested stop, propagate without executing others
51
+ if (acc && acc.__middlewareStopped) {
52
+ return acc;
53
+ }
54
+ try {
55
+ const middlewareResult = await middleware(acc);
56
+
57
+ if (middlewareResult === false) {
58
+ // Mark chain as stopped
59
+ return { ...acc, __middlewareStopped: true };
60
+ }
61
+
62
+ // If middleware returns an object, merge it with context
63
+ if (middlewareResult && typeof middlewareResult === 'object') {
64
+ return { ...acc, ...middlewareResult };
65
+ }
66
+
67
+ return acc;
68
+ } catch (error) {
69
+ if (isErrorHook) {
70
+ console.error('Error middleware failed:', error);
71
+ return acc;
72
+ }
73
+ throw error;
74
+ }
75
+ }),
76
+ Promise.resolve(context)
77
+ );
78
+
79
+ // Do not expose internal marker
80
+ if (finalResult && finalResult.__middlewareStopped) {
81
+ const rest = { ...finalResult };
82
+ delete rest.__middlewareStopped;
83
+ return rest;
68
84
  }
69
-
70
- return result;
85
+ return finalResult;
71
86
  }
72
87
 
73
88
  /**
package/src/validation.js CHANGED
@@ -1,3 +1,4 @@
1
+ /* eslint max-classes-per-file: off */
1
2
  const Ajv = require('ajv');
2
3
  const addFormats = require('ajv-formats');
3
4