rpc-express-toolkit 4.1.0 → 4.2.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.js CHANGED
@@ -1,842 +1,1035 @@
1
- const path = require('path');
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');
6
-
7
- /** @typedef {import("express").Router} Router */
8
- /** @typedef {import("express").Request} Request */
9
- /** @typedef {import("express").Response} Response */
10
-
11
- const Logger = require('./logger');
12
- const { MiddlewareManager, builtInMiddlewares } = require('./middleware');
13
- const {
14
- SchemaValidator,
15
- commonSchemas,
16
- SchemaBuilder,
17
- } = require('./validation');
18
- const BatchHandler = require('./batch');
19
-
20
- /**
21
- * Default properties to include in error serialization.
22
- * @type {Array<string>}
23
- */
24
- const defaultProperties = [
25
- 'stackTraceLimit',
26
- 'cause',
27
- 'code',
28
- 'message',
29
- 'stack',
30
- 'address',
31
- 'dest',
32
- 'errno',
33
- 'info',
34
- 'path',
35
- 'port',
36
- 'syscall',
37
- 'opensslErrorStack',
38
- 'function',
39
- 'library',
40
- 'reason',
41
- ];
42
-
43
- /**
44
- * Serializes an error into a JSON-compatible format, with optional sanitization.
45
- * @param {Error | NestedError | Object} error - The error to serialize.
46
- * @param {boolean} sanitize - Whether to remove sensitive fields.
47
- * @param {Array<string>} properties - Properties to include in serialization.
48
- * @returns {Object} The serialized error.
49
- */
50
- const serializeError = (
51
- error,
52
- sanitize = false,
53
- properties = defaultProperties
54
- ) => {
55
- if (error === null || error === undefined) return error;
56
-
57
- const result = {};
58
- const includeProperties = sanitize
59
- ? properties.filter((prop) => !['address', 'path'].includes(prop))
60
- : properties;
61
-
62
- includeProperties.forEach((prop) => {
63
- if (error?.[prop] !== undefined) {
64
- result[prop] = error[prop];
65
- }
66
- });
67
-
68
- if (error instanceof NestedError) {
69
- result.type = 'NestedError';
70
- if (error.nested)
71
- result.nested = serializeError(error.nested, sanitize, properties);
72
- } else if (error instanceof Error) {
73
- result.type = 'Error';
74
- } else if (error instanceof Object) {
75
- result.type = 'Object';
76
- Object.assign(result, error);
77
- } else {
78
- result.type = 'Default';
79
- result.instance = error;
80
- }
81
-
82
- return result;
83
- };
84
-
85
- /**
86
- * @template C
87
- */
88
- class RpcEndpoint {
89
- /**
90
- * Serve client scripts for making JSON-RPC calls from the browser.
91
- * @param {Router} router Express.Router instance
92
- * @param {string} [url="/vendor/rpc-client"]
93
- * The path from which client scripts will be served.
94
- */
95
- static serveScripts(router, url = '/vendor/rpc-client') {
96
- router.use(url, express.static(path.join(__dirname, 'clients')));
97
- }
98
-
99
- /** @type {string} */
100
- #endpoint;
101
-
102
- /** @type {{ [name: string]: any }} */
103
- #methods = {};
104
-
105
- /** @type {Logger} */
106
- #logger;
107
-
108
- /** @type {MiddlewareManager} */
109
- #middleware;
110
-
111
- /** @type {SchemaValidator} */
112
- #validator;
113
-
114
- /** @type {BatchHandler} */
115
- #batchHandler;
116
-
117
- /** @type {Object} */
118
- #options;
119
-
120
- /**
121
- * @param {Router} router The Express router to attach the endpoint to.
122
- * @param {C} context The context object to pass to the method handlers.
123
- * @param {string|Object} [endpointOrOptions="/api"] The endpoint path or options object.
124
- * @param {Object} [options={}] Options object if first param is string.
125
- */
126
- constructor(router, context, endpointOrOptions = '/api', options = {}) {
127
- // Handle both signatures: (router, context, "/api", options) and (router, context, options)
128
- if (typeof endpointOrOptions === 'string') {
129
- this.#endpoint = endpointOrOptions;
130
- this.#options = { ...options };
131
- } else {
132
- this.#endpoint = endpointOrOptions.endpoint || '/api';
133
- this.#options = { ...endpointOrOptions };
134
- }
135
-
136
- // Initialize components
137
- this.#logger = new Logger(this.#options.logging || {});
138
- this.#middleware = new MiddlewareManager();
139
- this.#validator = new SchemaValidator(this.#options.validation || {});
140
- this.#batchHandler = new BatchHandler(this);
141
-
142
- // Set default options
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
146
-
147
- // Validate that the router can handle JSON parsing
148
- this.#validateJsonMiddleware();
149
-
150
- // Setup built-in middleware if configured
151
- this.#setupBuiltInMiddleware();
152
-
153
- // Wire up routes
154
- this.#setupRoutes(router, context);
155
- }
156
-
157
- /**
158
- * Setup built-in middleware based on options
159
- */
160
- #setupBuiltInMiddleware() {
161
- const { rateLimit, cors, auth, timing, methodWhitelist } = this.#options;
162
-
163
- if (timing !== false) {
164
- this.use('beforeCall', builtInMiddlewares.timing());
165
- }
166
-
167
- if (cors) {
168
- this.use('beforeCall', builtInMiddlewares.cors(cors));
169
- }
170
-
171
- if (rateLimit) {
172
- this.use('beforeCall', builtInMiddlewares.rateLimit(rateLimit));
173
- }
174
-
175
- if (auth) {
176
- this.use('beforeCall', builtInMiddlewares.auth(auth));
177
- }
178
-
179
- if (methodWhitelist && Array.isArray(methodWhitelist)) {
180
- this.use(
181
- 'beforeCall',
182
- builtInMiddlewares.methodWhitelist(methodWhitelist)
183
- );
184
- }
185
- }
186
-
187
- /**
188
- * Validate JSON middleware functionality and Express compatibility
189
- * @private
190
- */
191
- #validateJsonMiddleware() {
192
- try {
193
- // Check if express.json is available (Express 4.16.0+)
194
- if (typeof express.json !== 'function') {
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
- );
198
- }
199
-
200
- // Test JSON middleware creation with default options
201
- const jsonMiddleware = express.json({ limit: '10mb' });
202
-
203
- if (typeof jsonMiddleware !== 'function') {
204
- throw new Error(
205
- 'express.json() did not return a valid middleware function'
206
- );
207
- }
208
-
209
- // Verify middleware has correct signature (req, res, next)
210
- if (jsonMiddleware.length !== 3) {
211
- throw new Error(
212
- 'JSON middleware has invalid signature - expected (req, res, next)'
213
- );
214
- }
215
-
216
- this.#logger.debug('JSON middleware validation passed', {
217
- expressJsonAvailable: true,
218
- middlewareValid: true,
219
- endpoint: this.#endpoint,
220
- });
221
- } catch (error) {
222
- const errorMsg =
223
- `JSON middleware validation failed: ${error.message}\n` +
224
- `This router cannot handle JSON-RPC requests properly.\n\n` +
225
- `Possible solutions:\n` +
226
- `- Update Express: npm install express@latest (requires >=4.16.0)\n` +
227
- `- Add JSON parsing middleware manually: router.use(express.json())\n` +
228
- `- Install body-parser: npm install body-parser`;
229
-
230
- this.#logger.error('JSON middleware validation failed', {
231
- error: error.message,
232
- expressVersion: expressPkg?.version || 'unknown',
233
- endpoint: this.#endpoint,
234
- });
235
-
236
- throw new Error(errorMsg);
237
- }
238
- }
239
-
240
- /**
241
- * Setup Express routes
242
- * @param {Router} router
243
- * @param {C} context
244
- */
245
- #setupRoutes(router, context) {
246
- // Health check endpoint
247
- if (this.#options.healthCheck !== false) {
248
- router.get(`${this.#endpoint}/health`, (req, res) => {
249
- res.json({
250
- status: 'ok',
251
- timestamp: new Date().toISOString(),
252
- version: pkg.version,
253
- });
254
- });
255
- }
256
-
257
- // Metrics endpoint
258
- if (this.#options.metrics) {
259
- router.get(`${this.#endpoint}/metrics`, (req, res) => {
260
- res.json(this.getMetrics());
261
- });
262
- }
263
-
264
- // Main JSON-RPC endpoint
265
- router.post(this.#endpoint, async (req, res) => {
266
- const startTime = Date.now();
267
-
268
- try {
269
- // Check if it's a batch request
270
- if (this.#batchHandler.isBatchRequest(req.body)) {
271
- this.#logger.info('Batch request received', {
272
- batchSize: req.body.length,
273
- ip: req.ip,
274
- });
275
-
276
- const results = await this.#batchHandler.processBatch(
277
- req.body,
278
- req,
279
- res,
280
- context
281
- );
282
-
283
- // Only send response if there are results (non-notification requests)
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
- );
290
- res.json(results);
291
- } else {
292
- res.status(204).end(); // No content for all-notification batch
293
- }
294
-
295
- this.#logger.info('Batch request completed', {
296
- batchSize: req.body.length,
297
- responseCount: results.length,
298
- duration: Date.now() - startTime,
299
- });
300
-
301
- return;
302
- }
303
-
304
- // Single request processing
305
- await this.#processSingleRequest(req, res, context, startTime);
306
- } catch (error) {
307
- this.#logger.error('Endpoint error', {
308
- error: error.message,
309
- stack: error.stack,
310
- });
311
-
312
- res.json({
313
- jsonrpc: '2.0',
314
- id: null,
315
- error: {
316
- code: -32603,
317
- message: 'Internal error',
318
- data: serializeError(error, true),
319
- },
320
- });
321
- }
322
- });
323
- }
324
-
325
- /**
326
- * Process a single JSON-RPC request
327
- * @param {Request} req
328
- * @param {Response} res
329
- * @param {C} context
330
- * @param {number} startTime
331
- */
332
- async #processSingleRequest(req, res, context, startTime) {
333
- const { jsonrpc, method, params, id } = req.body || {};
334
-
335
- // Log the incoming request
336
- this.#logger.rpcCall(method, params, id, req);
337
-
338
- // Validate JSON-RPC 2.0 request
339
- if (jsonrpc !== '2.0') {
340
- 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
- },
356
- });
357
- }
358
-
359
- const methodConfig = this.#methods[method];
360
- if (!methodConfig) {
361
- return this.reply(res, {
362
- id,
363
- error: { code: -32601, message: `Method "${method}" not found` },
364
- });
365
- }
366
-
367
- const handler =
368
- typeof methodConfig === 'function' ? methodConfig : methodConfig.handler;
369
- const schema =
370
- typeof methodConfig === 'object' ? methodConfig.schema : null;
371
-
372
- try {
373
- // Check client's safe options from request headers
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
- }
398
-
399
- // Use client's safe options for parameter deserialization
400
- const clientSafeEnabled = clientSafeHeader === 'true';
401
- const deserializationOptions = {
402
- safeEnabled: clientSafeEnabled,
403
- };
404
-
405
- // Deserialize parameters using client's safe options
406
- const deserializedParams = params
407
- ? this.deserializeBigIntsAndDates(params, deserializationOptions)
408
- : params;
409
-
410
- // Prepare middleware context
411
- let middlewareContext = {
412
- req,
413
- res,
414
- method,
415
- params: deserializedParams,
416
- context,
417
- id,
418
- startTime,
419
- };
420
-
421
- // Execute beforeCall middleware
422
- middlewareContext = await this.#middleware.execute(
423
- 'beforeCall',
424
- middlewareContext
425
- );
426
-
427
- // Validate parameters if schema is provided
428
- if (schema) {
429
- middlewareContext = await this.#middleware.execute(
430
- 'beforeValidation',
431
- middlewareContext
432
- );
433
-
434
- const validation = this.#validator.validate(
435
- middlewareContext.params,
436
- schema
437
- );
438
- if (!validation.valid) {
439
- const error = new Error('Validation failed');
440
- error.code = -32602;
441
- error.data = {
442
- validationErrors: validation.errors.map((err) => ({
443
- field: err.instancePath || err.schemaPath,
444
- message: err.message,
445
- value: err.data,
446
- })),
447
- };
448
- throw error;
449
- }
450
-
451
- middlewareContext.params = validation.data;
452
- middlewareContext = await this.#middleware.execute(
453
- 'afterValidation',
454
- middlewareContext
455
- );
456
- }
457
-
458
- // Invoke the handler
459
- const result = await Promise.resolve(
460
- handler(req, context, middlewareContext.params)
461
- );
462
-
463
- // Execute afterCall middleware
464
- middlewareContext.result = result;
465
- await this.#middleware.execute('afterCall', middlewareContext);
466
-
467
- // Log success
468
- const duration = Date.now() - startTime;
469
- this.#logger.rpcSuccess(method, id, duration, result);
470
-
471
- // Convert any BigInt/Date values before JSON-stringifying
472
- const safeResult = this.serializeBigIntsAndDates(result);
473
- this.reply(res, { id, result: safeResult });
474
- } catch (err) {
475
- const duration = Date.now() - startTime;
476
- this.#logger.rpcError(method, id, duration, err);
477
-
478
- // Execute error middleware
479
- try {
480
- await this.#middleware.execute('onError', {
481
- req,
482
- res,
483
- method,
484
- params,
485
- context,
486
- id,
487
- error: err,
488
- startTime,
489
- duration,
490
- });
491
- } catch (middlewareError) {
492
- this.#logger.error('Error middleware failed', {
493
- error: middlewareError.message,
494
- });
495
- }
496
-
497
- this.reply(res, {
498
- id,
499
- error: {
500
- code: err.code || -32603,
501
- message: err.message || `Internal error`,
502
- ...(err.data && { data: err.data }),
503
- error: serializeError(err, true),
504
- },
505
- });
506
- }
507
- // Ensure explicit return for consistent-return rule
508
- return null;
509
- }
510
-
511
- /**
512
- * Register a new JSON-RPC method.
513
- * @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.
516
- */
517
- addMethod(name, handlerOrConfig, schema = null) {
518
- if (typeof handlerOrConfig === 'function') {
519
- this.#methods[name] = schema
520
- ? { handler: handlerOrConfig, schema }
521
- : handlerOrConfig;
522
- } else if (typeof handlerOrConfig === 'object' && handlerOrConfig.handler) {
523
- this.#methods[name] = handlerOrConfig;
524
- } else {
525
- throw new Error('Invalid handler configuration');
526
- }
527
-
528
- this.#logger.debug('Method registered', {
529
- method: name,
530
- hasSchema: !!schema,
531
- });
532
- }
533
-
534
- /**
535
- * Add middleware for specific hooks
536
- * @param {string} hook
537
- * @param {Function} middleware
538
- */
539
- use(hook, middleware) {
540
- this.#middleware.use(hook, middleware);
541
- }
542
-
543
- /**
544
- * Remove method
545
- * @param {string} name
546
- */
547
- removeMethod(name) {
548
- delete this.#methods[name];
549
- this.#logger.debug('Method removed', { method: name });
550
- }
551
-
552
- /**
553
- * Get method configuration
554
- * @param {string} name
555
- * @returns {any}
556
- */
557
- getMethod(name) {
558
- return this.#methods[name];
559
- }
560
-
561
- /**
562
- * List all registered methods
563
- * @returns {string[]}
564
- */
565
- listMethods() {
566
- return Object.keys(this.#methods);
567
- }
568
-
569
- /** @returns {string} The endpoint path (e.g. "/api"). */
570
- get endpoint() {
571
- return this.#endpoint;
572
- }
573
-
574
- /**
575
- * @returns {{ [name: string]: any }}
576
- * All registered methods.
577
- */
578
- get methods() {
579
- return this.#methods;
580
- }
581
-
582
- /**
583
- * Get logger instance
584
- * @returns {Logger}
585
- */
586
- get logger() {
587
- return this.#logger;
588
- }
589
-
590
- /**
591
- * Get middleware manager
592
- * @returns {MiddlewareManager}
593
- */
594
- get middleware() {
595
- return this.#middleware;
596
- }
597
-
598
- /**
599
- * Get validator instance
600
- * @returns {SchemaValidator}
601
- */
602
- get validator() {
603
- return this.#validator;
604
- }
605
-
606
- /**
607
- * Get metrics data
608
- * @returns {Object}
609
- */
610
- getMetrics() {
611
- return {
612
- methods: Object.keys(this.#methods).length,
613
- middleware: {
614
- beforeCall: this.#middleware.getMiddlewares('beforeCall').length,
615
- afterCall: this.#middleware.getMiddlewares('afterCall').length,
616
- onError: this.#middleware.getMiddlewares('onError').length,
617
- },
618
- uptime: process.uptime(),
619
- memory: process.memoryUsage(),
620
- timestamp: new Date().toISOString(),
621
- };
622
- }
623
-
624
- /**
625
- * Send a JSON-RPC 2.0 response.
626
- * @param {Response} res The Express response object.
627
- * @param {{
628
- * id?: string|number|null,
629
- * result?: any,
630
- * error?: { code: number, message: string, data?: any }
631
- * }} responsePayload The JSON-RPC response fields.
632
- */
633
- reply(res, { id, result, error }) {
634
- const response = { jsonrpc: '2.0', id: id === undefined ? null : id };
635
-
636
- if (error) {
637
- response.error = error;
638
- } else {
639
- response.result = result;
640
- }
641
-
642
- // Add safe options headers so client knows how to deserialize
643
- // Send safe options headers to client
644
- res.setHeader(
645
- 'X-RPC-Safe-Enabled',
646
- this.#options.safeEnabled ? 'true' : 'false'
647
- );
648
-
649
- res.json(response);
650
- }
651
-
652
- /**
653
- * Recursively convert BigInt values to strings and Date objects to ISO strings.
654
- * This is crucial because JSON.stringify() cannot handle BigInt natively
655
- * and Date objects are best transmitted in a standardized format.
656
- *
657
- * @param {any} value The value to serialize.
658
- * @returns {any} A version of `value` safe for JSON serialization.
659
- */
660
- serializeBigIntsAndDates(value) {
661
- return this.#serializeValue(value, false);
662
- }
663
-
664
- /**
665
- * Internal serialization method with BigInt and Date warning tracking
666
- * @private
667
- */
668
- #serializeValue(value, hasBigInt = false, hasDate = false) {
669
- if (typeof value === 'bigint') {
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
- );
679
- }
680
- // Convert BigInt to string with 'n' suffix for proper deserialization
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
- );
693
- }
694
- // Convert Date to ISO string with D: prefix if safeEnabled
695
- const isoString = value.toISOString();
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}`;
702
- }
703
- return value;
704
- }
705
- if (Array.isArray(value)) {
706
- // Recurse into arrays - don't pass hasBigInt/hasDate to children
707
- // Each element should be checked independently for warnings
708
- return value.map((v) => this.#serializeValue(v, false, false));
709
- }
710
- if (value && typeof value === 'object') {
711
- // Recurse into plain objects - don't pass hasBigInt/hasDate to children
712
- // Each property should be checked independently for warnings
713
- const result = {};
714
- for (const [key, val] of Object.entries(value)) {
715
- result[key] = this.#serializeValue(val, false, false);
716
- }
717
- return result;
718
- }
719
-
720
- // If it's none of the above, return as-is
721
- return value;
722
- }
723
-
724
- /**
725
- * Check if an object has BigInt values deep inside
726
- * @private
727
- */
728
- #hasDeepBigInt(obj) {
729
- if (typeof obj === 'bigint') return true;
730
- if (Array.isArray(obj))
731
- return obj.some((item) => this.#hasDeepBigInt(item));
732
- if (obj && typeof obj === 'object') {
733
- return Object.values(obj).some((val) => this.#hasDeepBigInt(val));
734
- }
735
- return false;
736
- }
737
-
738
- /**
739
- * Check if an object has Date values deep inside
740
- * @private
741
- */
742
- #hasDeepDate(obj) {
743
- if (obj instanceof Date) return true;
744
- if (Array.isArray(obj)) return obj.some((item) => this.#hasDeepDate(item));
745
- if (obj && typeof obj === 'object') {
746
- return Object.values(obj).some((val) => this.#hasDeepDate(val));
747
- }
748
- return false;
749
- }
750
-
751
- /**
752
- * Recursively convert stringified BigInts back to BigInt,
753
- * and ISO 8601 date strings back to Date objects.
754
- *
755
- * This is useful if you receive JSON that was serialized
756
- * by `serializeBigIntsAndDates()` and want to re-hydrate
757
- * the original types.
758
- *
759
- * @param {any} value The value to deserialize.
760
- * @param {Object} [options] Custom deserialization options (uses server options if not provided).
761
- * @param {boolean} [options.safeEnabled] Whether to expect safe prefixes for strings and dates.
762
- * @returns {any} The re-hydrated value.
763
- */
764
- deserializeBigIntsAndDates(value, options = null) {
765
- // Use provided options or fall back to server options
766
- const safeEnabled = options
767
- ? options.safeEnabled
768
- : this.#options.safeEnabled;
769
-
770
- // More comprehensive ISO date regex that handles:
771
- // - UTC: 2023-01-01T12:00:00.000Z
772
- // - With timezone: 2023-01-01T12:00:00.000+01:00
773
- // - Without timezone: 2023-01-01T12:00:00.000 (treated as local)
774
- const ISO_DATE_REGEX =
775
- /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/;
776
-
777
- // 1. Check if it's a string that might be a BigInt, Date, or safe string
778
- if (typeof value === 'string') {
779
- // Safe string check: if safeEnabled and starts with S:
780
- if (safeEnabled && value.startsWith('S:')) {
781
- return value.substring(2); // Remove 'S:' prefix
782
- }
783
-
784
- // Safe date check: if safeEnabled and starts with D:
785
- if (safeEnabled && value.startsWith('D:')) {
786
- const isoString = value.substring(2); // Remove 'D:' prefix
787
- const date = new Date(isoString);
788
- // Double-check that we got a valid date
789
- if (!Number.isNaN(date.getTime())) {
790
- return date;
791
- }
792
- }
793
-
794
- // BigInt check: matches digits (including negative), ending in "n"
795
- if (/^-?\d+n$/.test(value)) {
796
- return BigInt(value.slice(0, -1)); // Remove 'n' and convert
797
- }
798
-
799
- // Date check: matches an ISO 8601 string (only if safeEnabled is false)
800
- if (!safeEnabled && ISO_DATE_REGEX.test(value)) {
801
- const date = new Date(value);
802
- // Double-check that we got a valid date
803
- if (!Number.isNaN(date.getTime())) {
804
- return date;
805
- }
806
- }
807
- }
808
-
809
- // 2. If it's an array, handle each element
810
- if (Array.isArray(value)) {
811
- return value.map((v) => this.deserializeBigIntsAndDates(v, options));
812
- }
813
-
814
- // 3. If it's a plain object, recurse into each property
815
- 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
- );
822
- }
823
-
824
- // 4. For everything else (number, boolean, null, undefined, etc.), return as-is
825
- return value;
826
- }
827
- }
828
-
829
- // Import the client class
830
- const RpcClient = require('./clients/rpc-client');
831
-
832
- // Export both server and client, plus utilities
833
- module.exports = RpcEndpoint;
834
- module.exports.RpcEndpoint = RpcEndpoint;
835
- module.exports.RpcClient = RpcClient;
836
- module.exports.Logger = Logger;
837
- module.exports.MiddlewareManager = MiddlewareManager;
838
- module.exports.builtInMiddlewares = builtInMiddlewares;
839
- module.exports.SchemaValidator = SchemaValidator;
840
- module.exports.commonSchemas = commonSchemas;
841
- module.exports.SchemaBuilder = SchemaBuilder;
842
- module.exports.BatchHandler = BatchHandler;
1
+ const path = require('path');
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');
6
+
7
+ /** @typedef {import("express").Router} Router */
8
+ /** @typedef {import("express").Request} Request */
9
+ /** @typedef {import("express").Response} Response */
10
+
11
+ const Logger = require('./logger');
12
+ const { MiddlewareManager, builtInMiddlewares } = require('./middleware');
13
+ const {
14
+ SchemaValidator,
15
+ commonSchemas,
16
+ SchemaBuilder,
17
+ } = require('./validation');
18
+ const BatchHandler = require('./batch');
19
+
20
+ /**
21
+ * Default properties to include in error serialization.
22
+ * @type {Array<string>}
23
+ */
24
+ const defaultProperties = [
25
+ 'stackTraceLimit',
26
+ 'cause',
27
+ 'code',
28
+ 'message',
29
+ 'stack',
30
+ 'address',
31
+ 'dest',
32
+ 'errno',
33
+ 'info',
34
+ 'path',
35
+ 'port',
36
+ 'syscall',
37
+ 'opensslErrorStack',
38
+ 'function',
39
+ 'library',
40
+ 'reason',
41
+ ];
42
+
43
+ /**
44
+ * Serializes an error into a JSON-compatible format, with optional sanitization.
45
+ * @param {Error | NestedError | Object} error - The error to serialize.
46
+ * @param {boolean} sanitize - Whether to remove sensitive fields.
47
+ * @param {Array<string>} properties - Properties to include in serialization.
48
+ * @returns {Object} The serialized error.
49
+ */
50
+ const serializeError = (
51
+ error,
52
+ sanitize = false,
53
+ properties = defaultProperties
54
+ ) => {
55
+ if (error === null || error === undefined) return error;
56
+
57
+ const result = {};
58
+ const includeProperties = sanitize
59
+ ? properties.filter((prop) => !['address', 'path'].includes(prop))
60
+ : properties;
61
+
62
+ includeProperties.forEach((prop) => {
63
+ if (error?.[prop] !== undefined) {
64
+ result[prop] = error[prop];
65
+ }
66
+ });
67
+
68
+ if (error instanceof NestedError) {
69
+ result.type = 'NestedError';
70
+ if (error.nested)
71
+ result.nested = serializeError(error.nested, sanitize, properties);
72
+ } else if (error instanceof Error) {
73
+ result.type = 'Error';
74
+ } else if (error instanceof Object) {
75
+ result.type = 'Object';
76
+ Object.assign(result, error);
77
+ } else {
78
+ result.type = 'Default';
79
+ result.instance = error;
80
+ }
81
+
82
+ return result;
83
+ };
84
+
85
+ /**
86
+ * @template C
87
+ */
88
+ class RpcEndpoint {
89
+ /**
90
+ * Serve client scripts for making JSON-RPC calls from the browser.
91
+ * @param {Router} router Express.Router instance
92
+ * @param {string} [url="/vendor/rpc-client"]
93
+ * The path from which client scripts will be served.
94
+ */
95
+ static serveScripts(router, url = '/vendor/rpc-client') {
96
+ router.use(url, express.static(path.join(__dirname, 'clients')));
97
+ }
98
+
99
+ /** @type {string} */
100
+ #endpoint;
101
+
102
+ /** @type {{ [name: string]: any }} */
103
+ #methods = {};
104
+
105
+ /** @type {string} */
106
+ #introspectionPrefix = '__rpc';
107
+
108
+ /** @type {boolean} */
109
+ #isInternalRegistration = false;
110
+
111
+ /** @type {Logger} */
112
+ #logger;
113
+
114
+ /** @type {MiddlewareManager} */
115
+ #middleware;
116
+
117
+ /** @type {SchemaValidator} */
118
+ #validator;
119
+
120
+ /** @type {BatchHandler} */
121
+ #batchHandler;
122
+
123
+ /** @type {Object} */
124
+ #options;
125
+
126
+ /**
127
+ * @param {Router} router The Express router to attach the endpoint to.
128
+ * @param {C} context The context object to pass to the method handlers.
129
+ * @param {string|Object} [endpointOrOptions="/api"] The endpoint path or options object.
130
+ * @param {Object} [options={}] Options object if first param is string.
131
+ */
132
+ constructor(router, context, endpointOrOptions = '/api', options = {}) {
133
+ // Handle both signatures: (router, context, "/api", options) and (router, context, options)
134
+ if (typeof endpointOrOptions === 'string') {
135
+ this.#endpoint = endpointOrOptions;
136
+ this.#options = { ...options };
137
+ } else {
138
+ this.#endpoint = endpointOrOptions.endpoint || '/api';
139
+ this.#options = { ...endpointOrOptions };
140
+ }
141
+
142
+ // Initialize components
143
+ this.#logger = new Logger(this.#options.logging || {});
144
+ this.#middleware = new MiddlewareManager();
145
+ this.#validator = new SchemaValidator(this.#options.validation || {});
146
+ this.#batchHandler = new BatchHandler(this);
147
+
148
+ // Set default options
149
+ this.#options.safeEnabled = this.#options.safeEnabled === true; // Default false
150
+ this.#options.warnOnUnsafe = this.#options.warnOnUnsafe !== false; // Default true
151
+ this.#options.strictMode = this.#options.strictMode !== false; // Default true
152
+ this.#options.enableIntrospection =
153
+ this.#options.enableIntrospection === true; // Default false
154
+ this.#options.introspectionPrefix =
155
+ this.#options.introspectionPrefix || '__rpc'; // Default __rpc
156
+
157
+ this.#introspectionPrefix = this.#options.introspectionPrefix;
158
+
159
+ // Validate that the router can handle JSON parsing
160
+ this.#validateJsonMiddleware();
161
+
162
+ // Register introspection methods if enabled
163
+ if (this.#options.enableIntrospection) {
164
+ this.#registerIntrospectionMethods();
165
+ }
166
+
167
+ // Setup built-in middleware if configured
168
+ this.#setupBuiltInMiddleware();
169
+
170
+ // Wire up routes
171
+ this.#setupRoutes(router, context);
172
+ }
173
+
174
+ /**
175
+ * Setup built-in middleware based on options
176
+ */
177
+ #setupBuiltInMiddleware() {
178
+ const { rateLimit, cors, auth, timing, methodWhitelist } = this.#options;
179
+
180
+ if (timing !== false) {
181
+ this.use('beforeCall', builtInMiddlewares.timing());
182
+ }
183
+
184
+ if (cors) {
185
+ this.use('beforeCall', builtInMiddlewares.cors(cors));
186
+ }
187
+
188
+ if (rateLimit) {
189
+ this.use('beforeCall', builtInMiddlewares.rateLimit(rateLimit));
190
+ }
191
+
192
+ if (auth) {
193
+ this.use('beforeCall', builtInMiddlewares.auth(auth));
194
+ }
195
+
196
+ if (methodWhitelist && Array.isArray(methodWhitelist)) {
197
+ this.use(
198
+ 'beforeCall',
199
+ builtInMiddlewares.methodWhitelist(methodWhitelist)
200
+ );
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Register introspection methods (__rpc.*)
206
+ * @private
207
+ */
208
+ #registerIntrospectionMethods() {
209
+ this.#isInternalRegistration = true;
210
+
211
+ // __rpc.listMethods - List all user methods (excludes __rpc.* methods)
212
+ this.addMethod(
213
+ `${this.#introspectionPrefix}.listMethods`,
214
+ async (_req, _context, _params) =>
215
+ Object.keys(this.#methods).filter(
216
+ (name) => !name.startsWith(this.#introspectionPrefix)
217
+ ),
218
+ {
219
+ description: 'List all available RPC methods',
220
+ exposeSchema: true,
221
+ }
222
+ );
223
+
224
+ // __rpc.describe - Get schema and description of a specific method
225
+ this.addMethod(
226
+ `${this.#introspectionPrefix}.describe`,
227
+ async (_req, _context, params) => {
228
+ const methodName = params?.method;
229
+
230
+ if (!methodName || typeof methodName !== 'string') {
231
+ throw {
232
+ code: -32602,
233
+ message: 'Invalid params: method name required',
234
+ };
235
+ }
236
+
237
+ // Prevent introspection of __rpc.* methods
238
+ if (methodName.startsWith(this.#introspectionPrefix)) {
239
+ throw {
240
+ code: -32601,
241
+ message: 'Cannot describe introspection methods',
242
+ };
243
+ }
244
+
245
+ const methodConfig = this.#methods[methodName];
246
+ if (!methodConfig) {
247
+ throw { code: -32601, message: `Method not found: ${methodName}` };
248
+ }
249
+
250
+ // Check if schema is exposed
251
+ const config = typeof methodConfig === 'function' ? {} : methodConfig;
252
+ if (!config.exposeSchema) {
253
+ throw { code: -32601, message: 'Method schema not available' };
254
+ }
255
+
256
+ return {
257
+ name: methodName,
258
+ schema: config.schema || null,
259
+ description: config.description || '',
260
+ };
261
+ },
262
+ {
263
+ schema: {
264
+ type: 'object',
265
+ properties: {
266
+ method: { type: 'string' },
267
+ },
268
+ required: ['method'],
269
+ },
270
+ description: 'Get schema and description of a specific method',
271
+ exposeSchema: true,
272
+ }
273
+ );
274
+
275
+ // __rpc.describeAll - Get all methods with public schemas
276
+ this.addMethod(
277
+ `${this.#introspectionPrefix}.describeAll`,
278
+ async (_req, _context, _params) => {
279
+ const publicMethods = [];
280
+
281
+ for (const [name, methodConfig] of Object.entries(this.#methods)) {
282
+ // Skip introspection methods
283
+ if (name.startsWith(this.#introspectionPrefix)) continue;
284
+
285
+ const config = typeof methodConfig === 'function' ? {} : methodConfig;
286
+ if (config.exposeSchema) {
287
+ publicMethods.push({
288
+ name,
289
+ schema: config.schema || null,
290
+ description: config.description || '',
291
+ });
292
+ }
293
+ }
294
+
295
+ return publicMethods;
296
+ },
297
+ {
298
+ description: 'List all methods with public schemas',
299
+ exposeSchema: true,
300
+ }
301
+ );
302
+
303
+ // __rpc.version - Get toolkit version
304
+ this.addMethod(
305
+ `${this.#introspectionPrefix}.version`,
306
+ async (_req, _context, _params) => ({
307
+ toolkit: 'rpc-express-toolkit',
308
+ version: pkg.version,
309
+ expressVersion: expressPkg.version,
310
+ nodeVersion: process.version,
311
+ }),
312
+ {
313
+ description: 'Get RPC toolkit version information',
314
+ exposeSchema: true,
315
+ }
316
+ );
317
+
318
+ // __rpc.capabilities - Get server capabilities
319
+ this.addMethod(
320
+ `${this.#introspectionPrefix}.capabilities`,
321
+ async (_req, _context, _params) => ({
322
+ safeMode: this.#options.safeEnabled,
323
+ batch: true,
324
+ introspection: true,
325
+ introspectionPrefix: this.#introspectionPrefix,
326
+ validation: !!this.#options.validation,
327
+ cors: !!this.#options.cors,
328
+ auth: !!this.#options.auth,
329
+ rateLimit: !!this.#options.rateLimit,
330
+ methodCount: Object.keys(this.#methods).filter(
331
+ (n) => !n.startsWith(this.#introspectionPrefix)
332
+ ).length,
333
+ }),
334
+ {
335
+ description: 'Get server capabilities and configuration',
336
+ exposeSchema: true,
337
+ }
338
+ );
339
+
340
+ this.#isInternalRegistration = false;
341
+ }
342
+
343
+ /**
344
+ * Validate JSON middleware functionality and Express compatibility
345
+ * @private
346
+ */
347
+ #validateJsonMiddleware() {
348
+ try {
349
+ // Check if express.json is available (Express 4.16.0+)
350
+ if (typeof express.json !== 'function') {
351
+ throw new Error(
352
+ 'express.json() is not available. Please ensure you are using Express 4.16.0 or later, or install body-parser separately.'
353
+ );
354
+ }
355
+
356
+ // Test JSON middleware creation with default options
357
+ const jsonMiddleware = express.json({ limit: '10mb' });
358
+
359
+ if (typeof jsonMiddleware !== 'function') {
360
+ throw new Error(
361
+ 'express.json() did not return a valid middleware function'
362
+ );
363
+ }
364
+
365
+ // Verify middleware has correct signature (req, res, next)
366
+ if (jsonMiddleware.length !== 3) {
367
+ throw new Error(
368
+ 'JSON middleware has invalid signature - expected (req, res, next)'
369
+ );
370
+ }
371
+
372
+ this.#logger.debug('JSON middleware validation passed', {
373
+ expressJsonAvailable: true,
374
+ middlewareValid: true,
375
+ endpoint: this.#endpoint,
376
+ });
377
+ } catch (error) {
378
+ const errorMsg =
379
+ `JSON middleware validation failed: ${error.message}\n` +
380
+ `This router cannot handle JSON-RPC requests properly.\n\n` +
381
+ `Possible solutions:\n` +
382
+ `- Update Express: npm install express@latest (requires >=4.16.0)\n` +
383
+ `- Add JSON parsing middleware manually: router.use(express.json())\n` +
384
+ `- Install body-parser: npm install body-parser`;
385
+
386
+ this.#logger.error('JSON middleware validation failed', {
387
+ error: error.message,
388
+ expressVersion: expressPkg?.version || 'unknown',
389
+ endpoint: this.#endpoint,
390
+ });
391
+
392
+ throw new Error(errorMsg);
393
+ }
394
+ }
395
+
396
+ /**
397
+ * Setup Express routes
398
+ * @param {Router} router
399
+ * @param {C} context
400
+ */
401
+ #setupRoutes(router, context) {
402
+ // Health check endpoint
403
+ if (this.#options.healthCheck !== false) {
404
+ router.get(`${this.#endpoint}/health`, (req, res) => {
405
+ res.json({
406
+ status: 'ok',
407
+ timestamp: new Date().toISOString(),
408
+ version: pkg.version,
409
+ });
410
+ });
411
+ }
412
+
413
+ // Metrics endpoint
414
+ if (this.#options.metrics) {
415
+ router.get(`${this.#endpoint}/metrics`, (req, res) => {
416
+ res.json(this.getMetrics());
417
+ });
418
+ }
419
+
420
+ // Main JSON-RPC endpoint
421
+ router.post(this.#endpoint, async (req, res) => {
422
+ const startTime = Date.now();
423
+
424
+ try {
425
+ // Check if it's a batch request
426
+ if (this.#batchHandler.isBatchRequest(req.body)) {
427
+ this.#logger.info('Batch request received', {
428
+ batchSize: req.body.length,
429
+ ip: req.ip,
430
+ });
431
+
432
+ const results = await this.#batchHandler.processBatch(
433
+ req.body,
434
+ req,
435
+ res,
436
+ context
437
+ );
438
+
439
+ // Only send response if there are results (non-notification requests)
440
+ if (results.length > 0) {
441
+ // Align batch responses with single-call header negotiation
442
+ res.setHeader(
443
+ 'X-RPC-Safe-Enabled',
444
+ this.#options.safeEnabled ? 'true' : 'false'
445
+ );
446
+ res.json(results);
447
+ } else {
448
+ res.status(204).end(); // No content for all-notification batch
449
+ }
450
+
451
+ this.#logger.info('Batch request completed', {
452
+ batchSize: req.body.length,
453
+ responseCount: results.length,
454
+ duration: Date.now() - startTime,
455
+ });
456
+
457
+ return;
458
+ }
459
+
460
+ // Single request processing
461
+ await this.#processSingleRequest(req, res, context, startTime);
462
+ } catch (error) {
463
+ this.#logger.error('Endpoint error', {
464
+ error: error.message,
465
+ stack: error.stack,
466
+ });
467
+
468
+ res.json({
469
+ jsonrpc: '2.0',
470
+ id: null,
471
+ error: {
472
+ code: -32603,
473
+ message: 'Internal error',
474
+ data: serializeError(error, true),
475
+ },
476
+ });
477
+ }
478
+ });
479
+ }
480
+
481
+ /**
482
+ * Process a single JSON-RPC request
483
+ * @param {Request} req
484
+ * @param {Response} res
485
+ * @param {C} context
486
+ * @param {number} startTime
487
+ */
488
+ async #processSingleRequest(req, res, context, startTime) {
489
+ const { jsonrpc, method, params, id } = req.body || {};
490
+
491
+ // Log the incoming request
492
+ this.#logger.rpcCall(method, params, id, req);
493
+
494
+ // Validate JSON-RPC 2.0 request
495
+ if (jsonrpc !== '2.0') {
496
+ return this.reply(res, {
497
+ id,
498
+ error: {
499
+ code: -32600,
500
+ message: `Invalid Request: 'jsonrpc' must be '2.0'.`,
501
+ },
502
+ });
503
+ }
504
+
505
+ if (typeof method !== 'string') {
506
+ return this.reply(res, {
507
+ id,
508
+ error: {
509
+ code: -32600,
510
+ message: `Invalid Request: 'method' must be a string.`,
511
+ },
512
+ });
513
+ }
514
+
515
+ const methodConfig = this.#methods[method];
516
+ if (!methodConfig) {
517
+ return this.reply(res, {
518
+ id,
519
+ error: { code: -32601, message: `Method "${method}" not found` },
520
+ });
521
+ }
522
+
523
+ const handler =
524
+ typeof methodConfig === 'function' ? methodConfig : methodConfig.handler;
525
+ const schema =
526
+ typeof methodConfig === 'object' ? methodConfig.schema : null;
527
+
528
+ try {
529
+ // Check client's safe options from request headers
530
+ const clientSafeHeader = req.headers['x-rpc-safe-enabled'];
531
+
532
+ // Strict mode: se server ha safe enabled ma client non invia header → JSON-RPC ERROR
533
+ if (
534
+ this.#options.strictMode &&
535
+ this.#options.safeEnabled &&
536
+ !clientSafeHeader
537
+ ) {
538
+ return this.reply(res, {
539
+ id,
540
+ error: {
541
+ code: -32600, // Invalid Request
542
+ message:
543
+ 'RPC Compatibility Error: Server requires safe serialization header but client did not provide it.',
544
+ data: {
545
+ serverSafeEnabled: this.#options.safeEnabled,
546
+ requiredHeader: 'X-RPC-Safe-Enabled',
547
+ strictMode: true,
548
+ solution:
549
+ 'Update client to rpc-express-toolkit v4+ or disable server strict mode',
550
+ },
551
+ },
552
+ });
553
+ }
554
+
555
+ // Use client's safe options for parameter deserialization
556
+ const clientSafeEnabled = clientSafeHeader === 'true';
557
+ const deserializationOptions = {
558
+ safeEnabled: clientSafeEnabled,
559
+ };
560
+
561
+ // Deserialize parameters using client's safe options
562
+ const deserializedParams = params
563
+ ? this.deserializeBigIntsAndDates(params, deserializationOptions)
564
+ : params;
565
+
566
+ // Prepare middleware context
567
+ let middlewareContext = {
568
+ req,
569
+ res,
570
+ method,
571
+ params: deserializedParams,
572
+ context,
573
+ id,
574
+ startTime,
575
+ };
576
+
577
+ // Execute beforeCall middleware
578
+ middlewareContext = await this.#middleware.execute(
579
+ 'beforeCall',
580
+ middlewareContext
581
+ );
582
+
583
+ // Validate parameters if schema is provided
584
+ if (schema) {
585
+ middlewareContext = await this.#middleware.execute(
586
+ 'beforeValidation',
587
+ middlewareContext
588
+ );
589
+
590
+ const validation = this.#validator.validate(
591
+ middlewareContext.params,
592
+ schema
593
+ );
594
+ if (!validation.valid) {
595
+ const error = new Error('Validation failed');
596
+ error.code = -32602;
597
+ error.data = {
598
+ validationErrors: validation.errors.map((err) => ({
599
+ field: err.instancePath || err.schemaPath,
600
+ message: err.message,
601
+ value: err.data,
602
+ })),
603
+ };
604
+ throw error;
605
+ }
606
+
607
+ middlewareContext.params = validation.data;
608
+ middlewareContext = await this.#middleware.execute(
609
+ 'afterValidation',
610
+ middlewareContext
611
+ );
612
+ }
613
+
614
+ // Invoke the handler
615
+ const result = await Promise.resolve(
616
+ handler(req, context, middlewareContext.params)
617
+ );
618
+
619
+ // Execute afterCall middleware
620
+ middlewareContext.result = result;
621
+ await this.#middleware.execute('afterCall', middlewareContext);
622
+
623
+ // Log success
624
+ const duration = Date.now() - startTime;
625
+ this.#logger.rpcSuccess(method, id, duration, result);
626
+
627
+ // Convert any BigInt/Date values before JSON-stringifying
628
+ const safeResult = this.serializeBigIntsAndDates(result);
629
+ this.reply(res, { id, result: safeResult });
630
+ } catch (err) {
631
+ const duration = Date.now() - startTime;
632
+ this.#logger.rpcError(method, id, duration, err);
633
+
634
+ // Execute error middleware
635
+ try {
636
+ await this.#middleware.execute('onError', {
637
+ req,
638
+ res,
639
+ method,
640
+ params,
641
+ context,
642
+ id,
643
+ error: err,
644
+ startTime,
645
+ duration,
646
+ });
647
+ } catch (middlewareError) {
648
+ this.#logger.error('Error middleware failed', {
649
+ error: middlewareError.message,
650
+ });
651
+ }
652
+
653
+ this.reply(res, {
654
+ id,
655
+ error: {
656
+ code: err.code || -32603,
657
+ message: err.message || `Internal error`,
658
+ ...(err.data && { data: err.data }),
659
+ error: serializeError(err, true),
660
+ },
661
+ });
662
+ }
663
+ // Ensure explicit return for consistent-return rule
664
+ return null;
665
+ }
666
+
667
+ /**
668
+ * Register a new JSON-RPC method.
669
+ * @param {string} name The method name.
670
+ * @param {Function|Object} handlerOrConfig The function to handle calls or config object with handler, schema, exposeSchema, description.
671
+ * @param {Object} [optionsOrSchema] Optional config object or schema for backward compatibility.
672
+ */
673
+ addMethod(name, handlerOrConfig, optionsOrSchema = null) {
674
+ // Prevent users from registering introspection methods
675
+ if (
676
+ name.startsWith(this.#introspectionPrefix) &&
677
+ !this.#isInternalRegistration
678
+ ) {
679
+ throw new Error(
680
+ `Method names starting with '${this.#introspectionPrefix}.' are reserved for RPC introspection`
681
+ );
682
+ }
683
+
684
+ let methodConfig;
685
+
686
+ if (typeof handlerOrConfig === 'function') {
687
+ // Function handler - optionsOrSchema can be schema (backward compat) or options object
688
+ if (
689
+ optionsOrSchema &&
690
+ (optionsOrSchema.schema ||
691
+ optionsOrSchema.exposeSchema ||
692
+ optionsOrSchema.description)
693
+ ) {
694
+ // New format: options object
695
+ methodConfig = {
696
+ handler: handlerOrConfig,
697
+ ...optionsOrSchema,
698
+ };
699
+ } else if (optionsOrSchema) {
700
+ // Old format: schema directly
701
+ methodConfig = {
702
+ handler: handlerOrConfig,
703
+ schema: optionsOrSchema,
704
+ };
705
+ } else {
706
+ // Just the handler
707
+ methodConfig = handlerOrConfig;
708
+ }
709
+ } else if (typeof handlerOrConfig === 'object' && handlerOrConfig.handler) {
710
+ // Config object
711
+ methodConfig = handlerOrConfig;
712
+ } else {
713
+ throw new Error('Invalid handler configuration');
714
+ }
715
+
716
+ this.#methods[name] = methodConfig;
717
+
718
+ const config = typeof methodConfig === 'function' ? {} : methodConfig;
719
+ this.#logger.debug('Method registered', {
720
+ method: name,
721
+ hasSchema: !!config.schema,
722
+ exposeSchema: !!config.exposeSchema,
723
+ hasDescription: !!config.description,
724
+ });
725
+ }
726
+
727
+ /**
728
+ * Add middleware for specific hooks
729
+ * @param {string} hook
730
+ * @param {Function} middleware
731
+ */
732
+ use(hook, middleware) {
733
+ this.#middleware.use(hook, middleware);
734
+ }
735
+
736
+ /**
737
+ * Remove method
738
+ * @param {string} name
739
+ */
740
+ removeMethod(name) {
741
+ delete this.#methods[name];
742
+ this.#logger.debug('Method removed', { method: name });
743
+ }
744
+
745
+ /**
746
+ * Get method configuration
747
+ * @param {string} name
748
+ * @returns {any}
749
+ */
750
+ getMethod(name) {
751
+ return this.#methods[name];
752
+ }
753
+
754
+ /**
755
+ * List all registered methods
756
+ * @returns {string[]}
757
+ */
758
+ listMethods() {
759
+ return Object.keys(this.#methods);
760
+ }
761
+
762
+ /** @returns {string} The endpoint path (e.g. "/api"). */
763
+ get endpoint() {
764
+ return this.#endpoint;
765
+ }
766
+
767
+ /**
768
+ * @returns {{ [name: string]: any }}
769
+ * All registered methods.
770
+ */
771
+ get methods() {
772
+ return this.#methods;
773
+ }
774
+
775
+ /**
776
+ * Get logger instance
777
+ * @returns {Logger}
778
+ */
779
+ get logger() {
780
+ return this.#logger;
781
+ }
782
+
783
+ /**
784
+ * Get middleware manager
785
+ * @returns {MiddlewareManager}
786
+ */
787
+ get middleware() {
788
+ return this.#middleware;
789
+ }
790
+
791
+ /**
792
+ * Get validator instance
793
+ * @returns {SchemaValidator}
794
+ */
795
+ get validator() {
796
+ return this.#validator;
797
+ }
798
+
799
+ /**
800
+ * Get metrics data
801
+ * @returns {Object}
802
+ */
803
+ getMetrics() {
804
+ return {
805
+ methods: Object.keys(this.#methods).length,
806
+ middleware: {
807
+ beforeCall: this.#middleware.getMiddlewares('beforeCall').length,
808
+ afterCall: this.#middleware.getMiddlewares('afterCall').length,
809
+ onError: this.#middleware.getMiddlewares('onError').length,
810
+ },
811
+ uptime: process.uptime(),
812
+ memory: process.memoryUsage(),
813
+ timestamp: new Date().toISOString(),
814
+ };
815
+ }
816
+
817
+ /**
818
+ * Send a JSON-RPC 2.0 response.
819
+ * @param {Response} res The Express response object.
820
+ * @param {{
821
+ * id?: string|number|null,
822
+ * result?: any,
823
+ * error?: { code: number, message: string, data?: any }
824
+ * }} responsePayload The JSON-RPC response fields.
825
+ */
826
+ reply(res, { id, result, error }) {
827
+ const response = { jsonrpc: '2.0', id: id === undefined ? null : id };
828
+
829
+ if (error) {
830
+ response.error = error;
831
+ } else {
832
+ response.result = result;
833
+ }
834
+
835
+ // Add safe options headers so client knows how to deserialize
836
+ // Send safe options headers to client
837
+ res.setHeader(
838
+ 'X-RPC-Safe-Enabled',
839
+ this.#options.safeEnabled ? 'true' : 'false'
840
+ );
841
+
842
+ res.json(response);
843
+ }
844
+
845
+ /**
846
+ * Recursively convert BigInt values to strings and Date objects to ISO strings.
847
+ * This is crucial because JSON.stringify() cannot handle BigInt natively
848
+ * and Date objects are best transmitted in a standardized format.
849
+ *
850
+ * @param {any} value The value to serialize.
851
+ * @returns {any} A version of `value` safe for JSON serialization.
852
+ */
853
+ serializeBigIntsAndDates(value) {
854
+ return this.#serializeValue(value, false);
855
+ }
856
+
857
+ /**
858
+ * Internal serialization method with BigInt and Date warning tracking
859
+ * @private
860
+ */
861
+ #serializeValue(value, hasBigInt = false, hasDate = false) {
862
+ if (typeof value === 'bigint') {
863
+ // Warn if safeEnabled is disabled and we have BigInt
864
+ if (
865
+ !this.#options.safeEnabled &&
866
+ !hasBigInt &&
867
+ this.#options.warnOnUnsafe
868
+ ) {
869
+ this.#logger.warn(
870
+ 'BigInt detected in serialization. Consider enabling safeEnabled option to avoid potential string/BigInt confusion.'
871
+ );
872
+ }
873
+ // Convert BigInt to string with 'n' suffix for proper deserialization
874
+ return `${value.toString()}n`;
875
+ }
876
+ if (value instanceof Date) {
877
+ // Warn if safeEnabled is disabled and we have Date
878
+ if (
879
+ !this.#options.safeEnabled &&
880
+ !hasDate &&
881
+ this.#options.warnOnUnsafe
882
+ ) {
883
+ this.#logger.warn(
884
+ 'Date detected in serialization. Consider enabling safeEnabled option to avoid potential string/Date confusion.'
885
+ );
886
+ }
887
+ // Convert Date to ISO string with D: prefix if safeEnabled
888
+ const isoString = value.toISOString();
889
+ return this.#options.safeEnabled ? `D:${isoString}` : isoString;
890
+ }
891
+ if (typeof value === 'string') {
892
+ // Add S: prefix if safeEnabled is true
893
+ if (this.#options.safeEnabled) {
894
+ return `S:${value}`;
895
+ }
896
+ return value;
897
+ }
898
+ if (Array.isArray(value)) {
899
+ // Recurse into arrays - don't pass hasBigInt/hasDate to children
900
+ // Each element should be checked independently for warnings
901
+ return value.map((v) => this.#serializeValue(v, false, false));
902
+ }
903
+ if (value && typeof value === 'object') {
904
+ // Recurse into plain objects - don't pass hasBigInt/hasDate to children
905
+ // Each property should be checked independently for warnings
906
+ const result = {};
907
+ for (const [key, val] of Object.entries(value)) {
908
+ result[key] = this.#serializeValue(val, false, false);
909
+ }
910
+ return result;
911
+ }
912
+
913
+ // If it's none of the above, return as-is
914
+ return value;
915
+ }
916
+
917
+ /**
918
+ * Check if an object has BigInt values deep inside
919
+ * @private
920
+ */
921
+ #hasDeepBigInt(obj) {
922
+ if (typeof obj === 'bigint') return true;
923
+ if (Array.isArray(obj))
924
+ return obj.some((item) => this.#hasDeepBigInt(item));
925
+ if (obj && typeof obj === 'object') {
926
+ return Object.values(obj).some((val) => this.#hasDeepBigInt(val));
927
+ }
928
+ return false;
929
+ }
930
+
931
+ /**
932
+ * Check if an object has Date values deep inside
933
+ * @private
934
+ */
935
+ #hasDeepDate(obj) {
936
+ if (obj instanceof Date) return true;
937
+ if (Array.isArray(obj)) return obj.some((item) => this.#hasDeepDate(item));
938
+ if (obj && typeof obj === 'object') {
939
+ return Object.values(obj).some((val) => this.#hasDeepDate(val));
940
+ }
941
+ return false;
942
+ }
943
+
944
+ /**
945
+ * Recursively convert stringified BigInts back to BigInt,
946
+ * and ISO 8601 date strings back to Date objects.
947
+ *
948
+ * This is useful if you receive JSON that was serialized
949
+ * by `serializeBigIntsAndDates()` and want to re-hydrate
950
+ * the original types.
951
+ *
952
+ * @param {any} value The value to deserialize.
953
+ * @param {Object} [options] Custom deserialization options (uses server options if not provided).
954
+ * @param {boolean} [options.safeEnabled] Whether to expect safe prefixes for strings and dates.
955
+ * @returns {any} The re-hydrated value.
956
+ */
957
+ deserializeBigIntsAndDates(value, options = null) {
958
+ // Use provided options or fall back to server options
959
+ const safeEnabled = options
960
+ ? options.safeEnabled
961
+ : this.#options.safeEnabled;
962
+
963
+ // More comprehensive ISO date regex that handles:
964
+ // - UTC: 2023-01-01T12:00:00.000Z
965
+ // - With timezone: 2023-01-01T12:00:00.000+01:00
966
+ // - Without timezone: 2023-01-01T12:00:00.000 (treated as local)
967
+ const ISO_DATE_REGEX =
968
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/;
969
+
970
+ // 1. Check if it's a string that might be a BigInt, Date, or safe string
971
+ if (typeof value === 'string') {
972
+ // Safe string check: if safeEnabled and starts with S:
973
+ if (safeEnabled && value.startsWith('S:')) {
974
+ return value.substring(2); // Remove 'S:' prefix
975
+ }
976
+
977
+ // Safe date check: if safeEnabled and starts with D:
978
+ if (safeEnabled && value.startsWith('D:')) {
979
+ const isoString = value.substring(2); // Remove 'D:' prefix
980
+ const date = new Date(isoString);
981
+ // Double-check that we got a valid date
982
+ if (!Number.isNaN(date.getTime())) {
983
+ return date;
984
+ }
985
+ }
986
+
987
+ // BigInt check: matches digits (including negative), ending in "n"
988
+ if (/^-?\d+n$/.test(value)) {
989
+ return BigInt(value.slice(0, -1)); // Remove 'n' and convert
990
+ }
991
+
992
+ // Date check: matches an ISO 8601 string (only if safeEnabled is false)
993
+ if (!safeEnabled && ISO_DATE_REGEX.test(value)) {
994
+ const date = new Date(value);
995
+ // Double-check that we got a valid date
996
+ if (!Number.isNaN(date.getTime())) {
997
+ return date;
998
+ }
999
+ }
1000
+ }
1001
+
1002
+ // 2. If it's an array, handle each element
1003
+ if (Array.isArray(value)) {
1004
+ return value.map((v) => this.deserializeBigIntsAndDates(v, options));
1005
+ }
1006
+
1007
+ // 3. If it's a plain object, recurse into each property
1008
+ if (value && typeof value === 'object') {
1009
+ return Object.fromEntries(
1010
+ Object.entries(value).map(([key, val]) => [
1011
+ key,
1012
+ this.deserializeBigIntsAndDates(val, options),
1013
+ ])
1014
+ );
1015
+ }
1016
+
1017
+ // 4. For everything else (number, boolean, null, undefined, etc.), return as-is
1018
+ return value;
1019
+ }
1020
+ }
1021
+
1022
+ // Import the client class
1023
+ const RpcClient = require('./clients/rpc-client');
1024
+
1025
+ // Export both server and client, plus utilities
1026
+ module.exports = RpcEndpoint;
1027
+ module.exports.RpcEndpoint = RpcEndpoint;
1028
+ module.exports.RpcClient = RpcClient;
1029
+ module.exports.Logger = Logger;
1030
+ module.exports.MiddlewareManager = MiddlewareManager;
1031
+ module.exports.builtInMiddlewares = builtInMiddlewares;
1032
+ module.exports.SchemaValidator = SchemaValidator;
1033
+ module.exports.commonSchemas = commonSchemas;
1034
+ module.exports.SchemaBuilder = SchemaBuilder;
1035
+ module.exports.BatchHandler = BatchHandler;