rpc-express-toolkit 2.3.2 → 3.0.0

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,7 +1,5 @@
1
- "use strict";
2
-
3
- const path = require("path");
4
- const express = require("express");
1
+ const path = require('path');
2
+ const express = require('express');
5
3
 
6
4
  /** @typedef {import("express").Router} Router */
7
5
  /** @typedef {import("express").Request} Request */
@@ -10,7 +8,11 @@ const express = require("express");
10
8
  const NestedError = require('nested-error-stacks');
11
9
  const Logger = require('./logger');
12
10
  const { MiddlewareManager, builtInMiddlewares } = require('./middleware');
13
- const { SchemaValidator, commonSchemas, SchemaBuilder } = require('./validation');
11
+ const {
12
+ SchemaValidator,
13
+ commonSchemas,
14
+ SchemaBuilder,
15
+ } = require('./validation');
14
16
  const BatchHandler = require('./batch');
15
17
 
16
18
  /**
@@ -18,9 +20,22 @@ const BatchHandler = require('./batch');
18
20
  * @type {Array<string>}
19
21
  */
20
22
  const defaultProperties = [
21
- 'stackTraceLimit', 'cause', 'code', 'message', 'stack', 'address',
22
- 'dest', 'errno', 'info', 'path', 'port', 'syscall', 'opensslErrorStack',
23
- 'function', 'library', 'reason'
23
+ 'stackTraceLimit',
24
+ 'cause',
25
+ 'code',
26
+ 'message',
27
+ 'stack',
28
+ 'address',
29
+ 'dest',
30
+ 'errno',
31
+ 'info',
32
+ 'path',
33
+ 'port',
34
+ 'syscall',
35
+ 'opensslErrorStack',
36
+ 'function',
37
+ 'library',
38
+ 'reason',
24
39
  ];
25
40
 
26
41
  /**
@@ -30,31 +45,36 @@ const defaultProperties = [
30
45
  * @param {Array<string>} properties - Properties to include in serialization.
31
46
  * @returns {Object} The serialized error.
32
47
  */
33
- const serializeError = (error, sanitize = false, properties = defaultProperties) => {
48
+ const serializeError = (
49
+ error,
50
+ sanitize = false,
51
+ properties = defaultProperties
52
+ ) => {
34
53
  if (error === null || error === undefined) return error;
35
54
 
36
55
  const result = {};
37
56
  const includeProperties = sanitize
38
- ? properties.filter(prop => !['address', 'path'].includes(prop))
39
- : properties;
57
+ ? properties.filter((prop) => !['address', 'path'].includes(prop))
58
+ : properties;
40
59
 
41
- includeProperties.forEach(prop => {
42
- if (error?.[prop] !== undefined) {
43
- result[prop] = error[prop];
44
- }
60
+ includeProperties.forEach((prop) => {
61
+ if (error?.[prop] !== undefined) {
62
+ result[prop] = error[prop];
63
+ }
45
64
  });
46
65
 
47
66
  if (error instanceof NestedError) {
48
- result.type = 'NestedError';
49
- if (error.nested) result.nested = serializeError(error.nested, sanitize, properties);
67
+ result.type = 'NestedError';
68
+ if (error.nested)
69
+ result.nested = serializeError(error.nested, sanitize, properties);
50
70
  } else if (error instanceof Error) {
51
- result.type = 'Error';
71
+ result.type = 'Error';
52
72
  } else if (error instanceof Object) {
53
- result.type = 'Object';
54
- Object.assign(result, error);
73
+ result.type = 'Object';
74
+ Object.assign(result, error);
55
75
  } else {
56
- result.type = 'Default';
57
- result.instance = error;
76
+ result.type = 'Default';
77
+ result.instance = error;
58
78
  }
59
79
 
60
80
  return result;
@@ -70,8 +90,8 @@ class RpcEndpoint {
70
90
  * @param {string} [url="/vendor/rpc-client"]
71
91
  * The path from which client scripts will be served.
72
92
  */
73
- static serveScripts(router, url = "/vendor/rpc-client") {
74
- router.use(url, express.static(path.join(__dirname, "clients")));
93
+ static serveScripts(router, url = '/vendor/rpc-client') {
94
+ router.use(url, express.static(path.join(__dirname, 'clients')));
75
95
  }
76
96
 
77
97
  /** @type {string} */
@@ -101,13 +121,13 @@ class RpcEndpoint {
101
121
  * @param {string|Object} [endpointOrOptions="/api"] The endpoint path or options object.
102
122
  * @param {Object} [options={}] Options object if first param is string.
103
123
  */
104
- constructor(router, context, endpointOrOptions = "/api", options = {}) {
124
+ constructor(router, context, endpointOrOptions = '/api', options = {}) {
105
125
  // Handle both signatures: (router, context, "/api", options) and (router, context, options)
106
126
  if (typeof endpointOrOptions === 'string') {
107
127
  this.#endpoint = endpointOrOptions;
108
128
  this.#options = { ...options };
109
129
  } else {
110
- this.#endpoint = endpointOrOptions.endpoint || "/api";
130
+ this.#endpoint = endpointOrOptions.endpoint || '/api';
111
131
  this.#options = { ...endpointOrOptions };
112
132
  }
113
133
 
@@ -117,6 +137,15 @@ class RpcEndpoint {
117
137
  this.#validator = new SchemaValidator(this.#options.validation || {});
118
138
  this.#batchHandler = new BatchHandler(this);
119
139
 
140
+ // 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
145
+
146
+ // Validate that the router can handle JSON parsing
147
+ this.#validateJsonMiddleware();
148
+
120
149
  // Setup built-in middleware if configured
121
150
  this.#setupBuiltInMiddleware();
122
151
 
@@ -147,23 +176,73 @@ class RpcEndpoint {
147
176
  }
148
177
 
149
178
  if (methodWhitelist && Array.isArray(methodWhitelist)) {
150
- this.use('beforeCall', builtInMiddlewares.methodWhitelist(methodWhitelist));
179
+ this.use(
180
+ 'beforeCall',
181
+ builtInMiddlewares.methodWhitelist(methodWhitelist)
182
+ );
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Validate JSON middleware functionality and Express compatibility
188
+ * @private
189
+ */
190
+ #validateJsonMiddleware() {
191
+ try {
192
+ // Check if express.json is available (Express 4.16.0+)
193
+ 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
+ }
196
+
197
+ // Test JSON middleware creation with default options
198
+ const jsonMiddleware = express.json({ limit: '10mb' });
199
+
200
+ if (typeof jsonMiddleware !== 'function') {
201
+ throw new Error('express.json() did not return a valid middleware function');
202
+ }
203
+
204
+ // Verify middleware has correct signature (req, res, next)
205
+ if (jsonMiddleware.length !== 3) {
206
+ throw new Error('JSON middleware has invalid signature - expected (req, res, next)');
207
+ }
208
+
209
+ this.#logger.debug('JSON middleware validation passed', {
210
+ expressJsonAvailable: true,
211
+ middlewareValid: true,
212
+ endpoint: this.#endpoint
213
+ });
214
+
215
+ } catch (error) {
216
+ const errorMsg = `JSON middleware validation failed: ${error.message}\n` +
217
+ `This router cannot handle JSON-RPC requests properly.\n\n` +
218
+ `Possible solutions:\n` +
219
+ `- Update Express: npm install express@latest (requires >=4.16.0)\n` +
220
+ `- Add JSON parsing middleware manually: router.use(express.json())\n` +
221
+ `- Install body-parser: npm install body-parser`;
222
+
223
+ this.#logger.error('JSON middleware validation failed', {
224
+ error: error.message,
225
+ expressVersion: require('express/package.json')?.version || 'unknown',
226
+ endpoint: this.#endpoint
227
+ });
228
+
229
+ throw new Error(errorMsg);
151
230
  }
152
231
  }
153
232
 
154
233
  /**
155
234
  * Setup Express routes
156
- * @param {Router} router
157
- * @param {C} context
235
+ * @param {Router} router
236
+ * @param {C} context
158
237
  */
159
238
  #setupRoutes(router, context) {
160
239
  // Health check endpoint
161
240
  if (this.#options.healthCheck !== false) {
162
241
  router.get(`${this.#endpoint}/health`, (req, res) => {
163
- res.json({
164
- status: 'ok',
242
+ res.json({
243
+ status: 'ok',
165
244
  timestamp: new Date().toISOString(),
166
- version: require('../package.json').version
245
+ version: require('../package.json').version,
167
246
  });
168
247
  });
169
248
  }
@@ -178,47 +257,54 @@ class RpcEndpoint {
178
257
  // Main JSON-RPC endpoint
179
258
  router.post(this.#endpoint, async (req, res) => {
180
259
  const startTime = Date.now();
181
-
260
+
182
261
  try {
183
262
  // Check if it's a batch request
184
263
  if (this.#batchHandler.isBatchRequest(req.body)) {
185
264
  this.#logger.info('Batch request received', {
186
265
  batchSize: req.body.length,
187
- ip: req.ip
266
+ ip: req.ip,
188
267
  });
189
268
 
190
- const results = await this.#batchHandler.processBatch(req.body, req, res, context);
191
-
269
+ const results = await this.#batchHandler.processBatch(
270
+ req.body,
271
+ req,
272
+ res,
273
+ context
274
+ );
275
+
192
276
  // Only send response if there are results (non-notification requests)
193
277
  if (results.length > 0) {
194
278
  res.json(results);
195
279
  } else {
196
280
  res.status(204).end(); // No content for all-notification batch
197
281
  }
198
-
282
+
199
283
  this.#logger.info('Batch request completed', {
200
284
  batchSize: req.body.length,
201
285
  responseCount: results.length,
202
- duration: Date.now() - startTime
286
+ duration: Date.now() - startTime,
203
287
  });
204
-
288
+
205
289
  return;
206
290
  }
207
291
 
208
292
  // Single request processing
209
293
  await this.#processSingleRequest(req, res, context, startTime);
210
-
211
294
  } catch (error) {
212
- this.#logger.error('Endpoint error', { error: error.message, stack: error.stack });
213
-
295
+ this.#logger.error('Endpoint error', {
296
+ error: error.message,
297
+ stack: error.stack,
298
+ });
299
+
214
300
  res.json({
215
- jsonrpc: "2.0",
301
+ jsonrpc: '2.0',
216
302
  id: null,
217
- error: {
218
- code: -32603,
219
- message: "Internal error",
220
- data: serializeError(error, true)
221
- }
303
+ error: {
304
+ code: -32603,
305
+ message: 'Internal error',
306
+ data: serializeError(error, true),
307
+ },
222
308
  });
223
309
  }
224
310
  });
@@ -226,10 +312,10 @@ class RpcEndpoint {
226
312
 
227
313
  /**
228
314
  * Process a single JSON-RPC request
229
- * @param {Request} req
230
- * @param {Response} res
231
- * @param {C} context
232
- * @param {number} startTime
315
+ * @param {Request} req
316
+ * @param {Response} res
317
+ * @param {C} context
318
+ * @param {number} startTime
233
319
  */
234
320
  async #processSingleRequest(req, res, context, startTime) {
235
321
  const { jsonrpc, method, params, id } = req.body || {};
@@ -238,17 +324,23 @@ class RpcEndpoint {
238
324
  this.#logger.rpcCall(method, params, id, req);
239
325
 
240
326
  // Validate JSON-RPC 2.0 request
241
- if (jsonrpc !== "2.0") {
327
+ if (jsonrpc !== '2.0') {
242
328
  return this.reply(res, {
243
329
  id,
244
- error: { code: -32600, message: `Invalid Request: 'jsonrpc' must be '2.0'.` }
330
+ error: {
331
+ code: -32600,
332
+ message: `Invalid Request: 'jsonrpc' must be '2.0'.`,
333
+ },
245
334
  });
246
335
  }
247
336
 
248
- if (typeof method !== "string") {
337
+ if (typeof method !== 'string') {
249
338
  return this.reply(res, {
250
339
  id,
251
- error: { code: -32600, message: `Invalid Request: 'method' must be a string.` }
340
+ error: {
341
+ code: -32600,
342
+ message: `Invalid Request: 'method' must be a string.`,
343
+ },
252
344
  });
253
345
  }
254
346
 
@@ -256,53 +348,82 @@ class RpcEndpoint {
256
348
  if (!methodConfig) {
257
349
  return this.reply(res, {
258
350
  id,
259
- error: { code: -32601, message: `Method "${method}" not found` }
351
+ error: { code: -32601, message: `Method "${method}" not found` },
260
352
  });
261
353
  }
262
354
 
263
- const handler = typeof methodConfig === 'function' ? methodConfig : methodConfig.handler;
264
- const schema = typeof methodConfig === 'object' ? methodConfig.schema : null;
355
+ const handler =
356
+ typeof methodConfig === 'function' ? methodConfig : methodConfig.handler;
357
+ const schema =
358
+ typeof methodConfig === 'object' ? methodConfig.schema : null;
265
359
 
266
360
  try {
361
+ // 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';
364
+
365
+ // Create deserialization options based on client's configuration
366
+ const deserializationOptions = {
367
+ safeStringEnabled: clientSafeStringEnabled,
368
+ safeDateEnabled: clientSafeDateEnabled
369
+ };
370
+
371
+ // Deserialize parameters using client's safe options
372
+ const deserializedParams = params ? this.deserializeBigIntsAndDates(params, deserializationOptions) : params;
373
+
267
374
  // Prepare middleware context
268
375
  let middlewareContext = {
269
376
  req,
270
377
  res,
271
378
  method,
272
- params,
379
+ params: deserializedParams,
273
380
  context,
274
381
  id,
275
- startTime
382
+ startTime,
276
383
  };
277
384
 
278
385
  // Execute beforeCall middleware
279
- middlewareContext = await this.#middleware.execute('beforeCall', middlewareContext);
386
+ middlewareContext = await this.#middleware.execute(
387
+ 'beforeCall',
388
+ middlewareContext
389
+ );
280
390
 
281
391
  // Validate parameters if schema is provided
282
392
  if (schema) {
283
- middlewareContext = await this.#middleware.execute('beforeValidation', middlewareContext);
284
-
285
- const validation = this.#validator.validate(middlewareContext.params, schema);
393
+ middlewareContext = await this.#middleware.execute(
394
+ 'beforeValidation',
395
+ middlewareContext
396
+ );
397
+
398
+ const validation = this.#validator.validate(
399
+ middlewareContext.params,
400
+ schema
401
+ );
286
402
  if (!validation.valid) {
287
403
  const error = new Error('Validation failed');
288
404
  error.code = -32602;
289
405
  error.data = {
290
- validationErrors: validation.errors.map(err => ({
406
+ validationErrors: validation.errors.map((err) => ({
291
407
  field: err.instancePath || err.schemaPath,
292
408
  message: err.message,
293
- value: err.data
294
- }))
409
+ value: err.data,
410
+ })),
295
411
  };
296
412
  throw error;
297
413
  }
298
-
414
+
299
415
  middlewareContext.params = validation.data;
300
- middlewareContext = await this.#middleware.execute('afterValidation', middlewareContext);
416
+ middlewareContext = await this.#middleware.execute(
417
+ 'afterValidation',
418
+ middlewareContext
419
+ );
301
420
  }
302
421
 
303
422
  // Invoke the handler
304
- const result = await Promise.resolve(handler(req, context, middlewareContext.params));
305
-
423
+ const result = await Promise.resolve(
424
+ handler(req, context, middlewareContext.params)
425
+ );
426
+
306
427
  // Execute afterCall middleware
307
428
  middlewareContext.result = result;
308
429
  await this.#middleware.execute('afterCall', middlewareContext);
@@ -314,7 +435,6 @@ class RpcEndpoint {
314
435
  // Convert any BigInt/Date values before JSON-stringifying
315
436
  const safeResult = this.serializeBigIntsAndDates(result);
316
437
  this.reply(res, { id, result: safeResult });
317
-
318
438
  } catch (err) {
319
439
  const duration = Date.now() - startTime;
320
440
  this.#logger.rpcError(method, id, duration, err);
@@ -330,20 +450,22 @@ class RpcEndpoint {
330
450
  id,
331
451
  error: err,
332
452
  startTime,
333
- duration
453
+ duration,
334
454
  });
335
455
  } catch (middlewareError) {
336
- this.#logger.error('Error middleware failed', { error: middlewareError.message });
456
+ this.#logger.error('Error middleware failed', {
457
+ error: middlewareError.message,
458
+ });
337
459
  }
338
460
 
339
461
  this.reply(res, {
340
462
  id,
341
- error: {
342
- code: err.code || -32603,
343
- message: err.message || `Internal error`,
463
+ error: {
464
+ code: err.code || -32603,
465
+ message: err.message || `Internal error`,
344
466
  ...(err.data && { data: err.data }),
345
- error: serializeError(err, true)
346
- }
467
+ error: serializeError(err, true),
468
+ },
347
469
  });
348
470
  }
349
471
  }
@@ -356,20 +478,25 @@ class RpcEndpoint {
356
478
  */
357
479
  addMethod(name, handlerOrConfig, schema = null) {
358
480
  if (typeof handlerOrConfig === 'function') {
359
- this.#methods[name] = schema ? { handler: handlerOrConfig, schema } : handlerOrConfig;
481
+ this.#methods[name] = schema
482
+ ? { handler: handlerOrConfig, schema }
483
+ : handlerOrConfig;
360
484
  } else if (typeof handlerOrConfig === 'object' && handlerOrConfig.handler) {
361
485
  this.#methods[name] = handlerOrConfig;
362
486
  } else {
363
487
  throw new Error('Invalid handler configuration');
364
488
  }
365
489
 
366
- this.#logger.debug('Method registered', { method: name, hasSchema: !!schema });
490
+ this.#logger.debug('Method registered', {
491
+ method: name,
492
+ hasSchema: !!schema,
493
+ });
367
494
  }
368
495
 
369
496
  /**
370
497
  * Add middleware for specific hooks
371
- * @param {string} hook
372
- * @param {Function} middleware
498
+ * @param {string} hook
499
+ * @param {Function} middleware
373
500
  */
374
501
  use(hook, middleware) {
375
502
  this.#middleware.use(hook, middleware);
@@ -377,7 +504,7 @@ class RpcEndpoint {
377
504
 
378
505
  /**
379
506
  * Remove method
380
- * @param {string} name
507
+ * @param {string} name
381
508
  */
382
509
  removeMethod(name) {
383
510
  delete this.#methods[name];
@@ -386,7 +513,7 @@ class RpcEndpoint {
386
513
 
387
514
  /**
388
515
  * Get method configuration
389
- * @param {string} name
516
+ * @param {string} name
390
517
  * @returns {any}
391
518
  */
392
519
  getMethod(name) {
@@ -448,11 +575,11 @@ class RpcEndpoint {
448
575
  middleware: {
449
576
  beforeCall: this.#middleware.getMiddlewares('beforeCall').length,
450
577
  afterCall: this.#middleware.getMiddlewares('afterCall').length,
451
- onError: this.#middleware.getMiddlewares('onError').length
578
+ onError: this.#middleware.getMiddlewares('onError').length,
452
579
  },
453
580
  uptime: process.uptime(),
454
581
  memory: process.memoryUsage(),
455
- timestamp: new Date().toISOString()
582
+ timestamp: new Date().toISOString(),
456
583
  };
457
584
  }
458
585
 
@@ -466,7 +593,7 @@ class RpcEndpoint {
466
593
  * }} responsePayload The JSON-RPC response fields.
467
594
  */
468
595
  reply(res, { id, result, error }) {
469
- const response = { jsonrpc: "2.0", id: id === undefined ? null : id };
596
+ const response = { jsonrpc: '2.0', id: id === undefined ? null : id };
470
597
 
471
598
  if (error) {
472
599
  response.error = error;
@@ -474,6 +601,10 @@ class RpcEndpoint {
474
601
  response.result = result;
475
602
  }
476
603
 
604
+ // 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');
607
+
477
608
  res.json(response);
478
609
  }
479
610
 
@@ -486,20 +617,45 @@ class RpcEndpoint {
486
617
  * @returns {any} A version of `value` safe for JSON serialization.
487
618
  */
488
619
  serializeBigIntsAndDates(value) {
489
- if (typeof value === "bigint") {
490
- // Convert BigInt to string
491
- return value.toString();
492
- } else if (Array.isArray(value)) {
493
- // Recurse into arrays
494
- return value.map((v) => this.serializeBigIntsAndDates(v));
620
+ return this.#serializeValue(value, false);
621
+ }
622
+
623
+ /**
624
+ * Internal serialization method with BigInt and Date warning tracking
625
+ * @private
626
+ */
627
+ #serializeValue(value, hasBigInt = false, hasDate = false) {
628
+ 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.');
632
+ }
633
+ // Convert BigInt to string with 'n' suffix for proper deserialization
634
+ return value.toString() + 'n';
495
635
  } else if (value instanceof Date) {
496
- // Convert Date to ISO string (UTC)
497
- return value.toISOString();
498
- } else if (value && typeof value === "object") {
499
- // Recurse into plain objects
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.');
639
+ }
640
+ // Convert Date to ISO string with D: prefix if safeDateEnabled
641
+ 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;
647
+ }
648
+ return value;
649
+ } else if (Array.isArray(value)) {
650
+ // Recurse into arrays - don't pass hasBigInt/hasDate to children
651
+ // Each element should be checked independently for warnings
652
+ return value.map((v) => this.#serializeValue(v, false, false));
653
+ } else if (value && typeof value === 'object') {
654
+ // Recurse into plain objects - don't pass hasBigInt/hasDate to children
655
+ // Each property should be checked independently for warnings
500
656
  const result = {};
501
657
  for (const [key, val] of Object.entries(value)) {
502
- result[key] = this.serializeBigIntsAndDates(val);
658
+ result[key] = this.#serializeValue(val, false, false);
503
659
  }
504
660
  return result;
505
661
  }
@@ -508,6 +664,32 @@ class RpcEndpoint {
508
664
  return value;
509
665
  }
510
666
 
667
+ /**
668
+ * Check if an object has BigInt values deep inside
669
+ * @private
670
+ */
671
+ #hasDeepBigInt(obj) {
672
+ if (typeof obj === 'bigint') return true;
673
+ if (Array.isArray(obj)) return obj.some(item => this.#hasDeepBigInt(item));
674
+ if (obj && typeof obj === 'object') {
675
+ return Object.values(obj).some(val => this.#hasDeepBigInt(val));
676
+ }
677
+ return false;
678
+ }
679
+
680
+ /**
681
+ * Check if an object has Date values deep inside
682
+ * @private
683
+ */
684
+ #hasDeepDate(obj) {
685
+ if (obj instanceof Date) return true;
686
+ if (Array.isArray(obj)) return obj.some(item => this.#hasDeepDate(item));
687
+ if (obj && typeof obj === 'object') {
688
+ return Object.values(obj).some(val => this.#hasDeepDate(val));
689
+ }
690
+ return false;
691
+ }
692
+
511
693
  /**
512
694
  * Recursively convert stringified BigInts back to BigInt,
513
695
  * and ISO 8601 date strings back to Date objects.
@@ -517,25 +699,47 @@ class RpcEndpoint {
517
699
  * the original types.
518
700
  *
519
701
  * @param {any} value The value to deserialize.
702
+ * @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.
520
705
  * @returns {any} The re-hydrated value.
521
706
  */
522
- deserializeBigIntsAndDates(value) {
707
+ deserializeBigIntsAndDates(value, options = null) {
708
+ // 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;
711
+
523
712
  // More comprehensive ISO date regex that handles:
524
713
  // - UTC: 2023-01-01T12:00:00.000Z
525
- // - With timezone: 2023-01-01T12:00:00.000+01:00
714
+ // - With timezone: 2023-01-01T12:00:00.000+01:00
526
715
  // - Without timezone: 2023-01-01T12:00:00.000 (treated as local)
527
- const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/;
528
-
529
- // 1. Check if it's a string that might be a BigInt or a Date
530
- if (typeof value === "string") {
531
- // BigInt check: matches digits (including negative), optionally ending in "n"
532
- // e.g., "42n", "42", "-42n", "-42"
533
- if (/^-?\d+n?$/.test(value)) {
534
- return BigInt(value.replace(/n$/, ""));
716
+ const ISO_DATE_REGEX =
717
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/;
718
+
719
+ // 1. Check if it's a string that might be a BigInt, Date, or safe string
720
+ if (typeof value === 'string') {
721
+ // Safe string check: if safeStringEnabled and starts with S:
722
+ if (safeStringEnabled && value.startsWith('S:')) {
723
+ return value.substring(2); // Remove 'S:' prefix
724
+ }
725
+
726
+ // Safe date check: if safeDateEnabled and starts with D:
727
+ if (safeDateEnabled && value.startsWith('D:')) {
728
+ const isoString = value.substring(2); // Remove 'D:' prefix
729
+ const date = new Date(isoString);
730
+ // Double-check that we got a valid date
731
+ if (!isNaN(date.getTime())) {
732
+ return date;
733
+ }
734
+ }
735
+
736
+ // BigInt check: matches digits (including negative), ending in "n"
737
+ if (/^-?\d+n$/.test(value)) {
738
+ return BigInt(value.slice(0, -1)); // Remove 'n' and convert
535
739
  }
536
740
 
537
- // Date check: matches an ISO 8601 string
538
- if (ISO_DATE_REGEX.test(value)) {
741
+ // Date check: matches an ISO 8601 string (only if safeDateEnabled is false)
742
+ if (!safeDateEnabled && ISO_DATE_REGEX.test(value)) {
539
743
  const date = new Date(value);
540
744
  // Double-check that we got a valid date
541
745
  if (!isNaN(date.getTime())) {
@@ -546,15 +750,15 @@ class RpcEndpoint {
546
750
 
547
751
  // 2. If it's an array, handle each element
548
752
  if (Array.isArray(value)) {
549
- return value.map((v) => this.deserializeBigIntsAndDates(v));
753
+ return value.map((v) => this.deserializeBigIntsAndDates(v, options));
550
754
  }
551
755
 
552
756
  // 3. If it's a plain object, recurse into each property
553
- if (value && typeof value === "object") {
757
+ if (value && typeof value === 'object') {
554
758
  return Object.fromEntries(
555
759
  Object.entries(value).map(([key, val]) => [
556
760
  key,
557
- this.deserializeBigIntsAndDates(val)
761
+ this.deserializeBigIntsAndDates(val, options),
558
762
  ])
559
763
  );
560
764
  }