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.
@@ -1,14 +1,16 @@
1
1
  /**
2
- * JSON-RPC 2.0 Client for browser and Node.js environments (ES Module).
2
+ * JSON-RPC 2.0 Client for browser and Node.js environments.
3
3
  * Handles BigInt and Date serialization/deserialization automatically.
4
- *
4
+ *
5
5
  * Note: We extend BigInt.prototype.toJSON to handle automatic serialization
6
6
  * when JSON.stringify() is called, as BigInt doesn't have native JSON support.
7
7
  */
8
8
 
9
9
  // Extend BigInt prototype to handle JSON serialization
10
10
  if (typeof BigInt !== 'undefined' && !BigInt.prototype.toJSON) {
11
- BigInt.prototype.toJSON = function() { return this.toString(); };
11
+ BigInt.prototype.toJSON = function () {
12
+ return this.toString();
13
+ };
12
14
  }
13
15
 
14
16
  // Polyfill for Node.js environments that don't have fetch
@@ -18,73 +20,97 @@ if (typeof fetch !== 'undefined') {
18
20
  } else {
19
21
  // Try to use node-fetch if available, otherwise use built-in fetch in Node.js 18+
20
22
  try {
21
- const { default: nodeFetch } = await import('node-fetch');
22
- fetchFn = nodeFetch;
23
+ fetchFn = require('node-fetch');
23
24
  } catch (e) {
24
25
  // For Node.js 18+, use global fetch
25
26
  if (typeof globalThis !== 'undefined' && globalThis.fetch) {
26
27
  fetchFn = globalThis.fetch;
27
28
  } else {
28
- throw new Error('fetch is not available. Please install node-fetch or use Node.js 18+');
29
+ throw new Error(
30
+ 'fetch is not available. Please install node-fetch or use Node.js 18+'
31
+ );
29
32
  }
30
33
  }
31
34
  }
32
35
 
33
- export class RpcClient {
36
+ class RpcClient {
34
37
  #endpoint;
35
38
  #defaultHeaders;
36
39
  #requestId;
37
40
  #fetchOptions;
41
+ #options;
38
42
 
39
43
  /**
40
44
  * @param {string} endpoint The JSON-RPC endpoint URL.
41
45
  * @param {Object} [defaultHeaders={}] Optional default headers to include in requests.
42
46
  * @param {Object} [options={}] Optional configuration options.
43
47
  * @param {boolean} [options.rejectUnauthorized=true] Whether to reject unauthorized SSL certificates. Set to false for development with self-signed certificates.
48
+ * @param {boolean} [options.safeStringEnabled=true] Whether to prefix strings with 'S:' to avoid confusion with BigInt values.
49
+ * @param {boolean} [options.safeDateEnabled=true] Whether to prefix dates with 'D:' to avoid confusion with ISO string values.
44
50
  */
45
51
  constructor(endpoint, defaultHeaders = {}, options = {}) {
46
52
  this.#endpoint = endpoint;
47
53
  this.#defaultHeaders = {
48
- "Content-Type": "application/json", // Default header
54
+ 'Content-Type': 'application/json', // Default header
49
55
  ...defaultHeaders, // Merge with user-provided defaults
50
56
  };
51
57
  // Initialize request ID counter with timestamp + random component for uniqueness
52
58
  this.#requestId = Date.now() * 1000 + Math.floor(Math.random() * 1000);
53
-
59
+
60
+ // Store options
61
+ this.#options = {
62
+ safeStringEnabled: options.safeStringEnabled !== false, // Default true
63
+ safeDateEnabled: options.safeDateEnabled !== false, // Default true
64
+ ...options
65
+ };
66
+
54
67
  // Store fetch options for Node.js environments
55
68
  this.#fetchOptions = {};
56
- // SSL validation: la gestione avanzata (agent/ca) è rimossa per semplicità e compatibilità.
57
- // Per bypassare i certificati self-signed in sviluppo, imposta:
69
+ // SSL validation: advanced options (agent/ca) have been removed for simplicity and compatibility.
70
+ // To bypass self-signed certificates in development, set:
58
71
  // process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
59
- // Vedi README per dettagli e best practice.
72
+ // See README for details and best practices.
73
+ }
74
+
75
+ /**
76
+ * Generate a unique request ID
77
+ * @returns {number} Unique request ID
78
+ */
79
+ #generateId() {
80
+ return ++this.#requestId;
60
81
  }
61
82
 
62
83
  /**
63
84
  * Make a JSON-RPC call to the server.
64
85
  * @param {string} method The RPC method name.
65
86
  * @param {any} params The parameters to pass to the RPC method.
66
- * @param {string|number|null} [id] The request ID (optional for notifications). If null, auto-generates unique ID.
87
+ * @param {string|number|null} [id] The request ID (auto-generated if not provided).
67
88
  * @param {Object} [overrideHeaders={}] Optional headers to override defaults for this request.
68
89
  * @returns {Promise<any>} The result of the RPC call.
69
90
  */
70
- async call(method, params = {}, id = null, overrideHeaders = {}) {
71
- // Auto-generate unique ID if not provided
72
- if (id === null) {
73
- id = ++this.#requestId;
74
- }
91
+ async call(method, params, id = undefined, overrideHeaders = {}) {
92
+ // Auto-generate ID if not provided (null means notification)
93
+ const requestId = id === undefined ? this.#generateId() : id;
75
94
 
95
+ // Build the payload according to the spec: params omitted if undefined/null
76
96
  const requestBody = {
77
- jsonrpc: "2.0",
97
+ jsonrpc: '2.0',
78
98
  method,
79
- params,
80
- id,
99
+ id: requestId,
81
100
  };
101
+ if (params !== undefined && params !== null) {
102
+ // Serialize parameters to handle BigInt and safe strings
103
+ requestBody.params = this.serializeBigIntsAndDates(params);
104
+ }
82
105
 
83
106
  try {
84
107
  const response = await fetchFn(this.#endpoint, {
85
- method: "POST",
108
+ method: 'POST',
86
109
  headers: {
87
110
  ...this.#defaultHeaders,
111
+ // Add safe options headers so server knows what client expects
112
+ 'X-RPC-SafeString-Enabled': this.#options.safeStringEnabled ? 'true' : 'false',
113
+ 'X-RPC-SafeDate-Enabled': this.#options.safeDateEnabled ? 'true' : 'false',
88
114
  ...overrideHeaders,
89
115
  },
90
116
  // Since we have BigInt.prototype.toJSON, we don't need manual serialization
@@ -94,112 +120,115 @@ export class RpcClient {
94
120
  });
95
121
 
96
122
  if (!response.ok) {
97
- throw new Error(`HTTP Error: ${response.status} ${response.statusText}`);
123
+ throw new Error(
124
+ `HTTP Error: ${response.status} ${response.statusText}`
125
+ );
98
126
  }
99
127
 
100
128
  const responseBody = await response.json();
101
129
 
102
- if (responseBody.error) { throw responseBody.error; }
130
+ if (responseBody.error) {
131
+ throw responseBody.error;
132
+ }
133
+
134
+ // Check server's safe options from response headers
135
+ const serverSafeStringEnabled = response.headers.get('X-RPC-SafeString-Enabled') === 'true';
136
+ const serverSafeDateEnabled = response.headers.get('X-RPC-SafeDate-Enabled') === 'true';
137
+
138
+ // Create deserialization options based on server's configuration
139
+ const deserializationOptions = {
140
+ safeStringEnabled: serverSafeStringEnabled,
141
+ safeDateEnabled: serverSafeDateEnabled
142
+ };
103
143
 
104
- // Convert back BigInts and Dates in the result
105
- return this.deserializeBigIntsAndDates(responseBody.result);
144
+ // Convert back BigInts and Dates in the result using server's options
145
+ return this.deserializeBigIntsAndDates(responseBody.result, deserializationOptions);
106
146
  } catch (error) {
107
- console.error("RPC call failed:", error);
147
+ console.error('RPC call failed:', error);
108
148
  throw error;
109
149
  }
110
150
  }
111
151
 
112
152
  /**
113
- * Make a JSON-RPC notification (no response expected).
153
+ * Send a notification (no response expected).
114
154
  * @param {string} method The RPC method name.
115
155
  * @param {any} params The parameters to pass to the RPC method.
116
156
  * @param {Object} [overrideHeaders={}] Optional headers to override defaults for this request.
117
- * @returns {Promise<void>}
157
+ * @returns {Promise<void>} Promise that resolves when notification is sent.
118
158
  */
119
159
  async notify(method, params = {}, overrideHeaders = {}) {
120
- const requestBody = {
121
- jsonrpc: "2.0",
122
- method,
123
- params,
124
- // No ID for notifications
125
- };
126
-
127
- try {
128
- const response = await fetchFn(this.#endpoint, {
129
- method: "POST",
130
- headers: {
131
- ...this.#defaultHeaders,
132
- ...overrideHeaders,
133
- },
134
- body: JSON.stringify(requestBody),
135
- ...this.#fetchOptions, // Include any additional fetch options (e.g., SSL settings)
136
- });
137
-
138
- if (!response.ok) {
139
- throw new Error(`HTTP Error: ${response.status} ${response.statusText}`);
140
- }
141
- } catch (error) {
142
- console.error("RPC notification failed:", error);
143
- throw error;
144
- }
160
+ await this.call(method, params, null, overrideHeaders);
145
161
  }
146
162
 
147
163
  /**
148
- * Make a batch JSON-RPC call to the server.
149
- * @param {Array<{method: string, params: any, id?: string|number|null, notification?: boolean}>} requests Array of request objects.
164
+ * Make multiple JSON-RPC calls in a single batch request.
165
+ * @param {Array<{method: string, params?: any, id?: string|number}>} requests Array of request objects.
150
166
  * @param {Object} [overrideHeaders={}] Optional headers to override defaults for this request.
151
- * @returns {Promise<Array<any>>} Array of results corresponding to the requests.
167
+ * @returns {Promise<Array<any>>} Array of results in the same order as requests.
152
168
  */
153
169
  async batch(requests, overrideHeaders = {}) {
154
- const requestBodies = requests.map(req => {
155
- const body = {
156
- jsonrpc: "2.0",
170
+ const batchRequests = requests.map((req) => {
171
+ const obj = {
172
+ jsonrpc: '2.0',
157
173
  method: req.method,
158
- params: req.params || {},
174
+ id: req.id !== undefined ? req.id : this.#generateId(),
159
175
  };
160
-
161
- // If it's not a notification, add ID
162
- if (!req.notification) {
163
- body.id = req.id !== undefined ? req.id : ++this.#requestId;
176
+ if (req.params !== undefined && req.params !== null) {
177
+ // Serialize parameters to handle BigInt and safe strings
178
+ obj.params = this.serializeBigIntsAndDates(req.params);
164
179
  }
165
-
166
- return body;
180
+ return obj;
167
181
  });
168
182
 
169
183
  try {
170
184
  const response = await fetchFn(this.#endpoint, {
171
- method: "POST",
185
+ method: 'POST',
172
186
  headers: {
173
187
  ...this.#defaultHeaders,
188
+ // Add safe options headers so server knows what client expects
189
+ 'X-RPC-SafeString-Enabled': this.#options.safeStringEnabled ? 'true' : 'false',
190
+ 'X-RPC-SafeDate-Enabled': this.#options.safeDateEnabled ? 'true' : 'false',
174
191
  ...overrideHeaders,
175
192
  },
176
- body: JSON.stringify(requestBodies),
193
+ body: JSON.stringify(batchRequests),
177
194
  ...this.#fetchOptions, // Include any additional fetch options (e.g., SSL settings)
178
195
  });
179
196
 
180
197
  if (!response.ok) {
181
- throw new Error(`HTTP Error: ${response.status} ${response.statusText}`);
198
+ throw new Error(
199
+ `HTTP Error: ${response.status} ${response.statusText}`
200
+ );
182
201
  }
183
202
 
184
203
  const responseBody = await response.json();
185
204
 
205
+ // Check server's safe options from response headers
206
+ const serverSafeStringEnabled = response.headers.get('X-RPC-SafeString-Enabled') === 'true';
207
+ const serverSafeDateEnabled = response.headers.get('X-RPC-SafeDate-Enabled') === 'true';
208
+
209
+ // Create deserialization options based on server's configuration
210
+ const deserializationOptions = {
211
+ safeStringEnabled: serverSafeStringEnabled,
212
+ safeDateEnabled: serverSafeDateEnabled
213
+ };
214
+
186
215
  // Handle batch response
187
216
  if (Array.isArray(responseBody)) {
188
- return responseBody.map(res => {
217
+ return responseBody.map((res) => {
189
218
  if (res.error) {
190
219
  throw res.error;
191
220
  }
192
- return this.deserializeBigIntsAndDates(res.result);
221
+ return this.deserializeBigIntsAndDates(res.result, deserializationOptions);
193
222
  });
194
223
  } else {
195
- // Single response (shouldn't happen with batch, but handle gracefully)
224
+ // Single response in batch
196
225
  if (responseBody.error) {
197
226
  throw responseBody.error;
198
227
  }
199
- return [this.deserializeBigIntsAndDates(responseBody.result)];
228
+ return [this.deserializeBigIntsAndDates(responseBody.result, deserializationOptions)];
200
229
  }
201
230
  } catch (error) {
202
- console.error("RPC batch call failed:", error);
231
+ console.error('Batch RPC call failed:', error);
203
232
  throw error;
204
233
  }
205
234
  }
@@ -211,16 +240,23 @@ export class RpcClient {
211
240
  * @returns {any}
212
241
  */
213
242
  serializeBigIntsAndDates(value) {
214
- if (typeof value === "bigint") {
215
- // Convert BigInt to string
216
- return value.toString();
243
+ if (typeof value === 'bigint') {
244
+ // Convert BigInt to string with 'n' suffix for proper deserialization
245
+ return value.toString() + 'n';
246
+ } else if (value instanceof Date) {
247
+ // Convert Date to ISO string with D: prefix if safeDateEnabled
248
+ const isoString = value.toISOString();
249
+ return this.#options.safeDateEnabled ? `D:${isoString}` : isoString;
250
+ } else if (typeof value === 'string') {
251
+ // Add S: prefix if safeStringEnabled is true
252
+ if (this.#options.safeStringEnabled) {
253
+ return 'S:' + value;
254
+ }
255
+ return value;
217
256
  } else if (Array.isArray(value)) {
218
257
  // Recurse into arrays
219
258
  return value.map((v) => this.serializeBigIntsAndDates(v));
220
- } else if (value instanceof Date) {
221
- // Convert Date to ISO string (UTC)
222
- return value.toISOString();
223
- } else if (value && typeof value === "object") {
259
+ } else if (value && typeof value === 'object') {
224
260
  // Recurse into plain objects
225
261
  const result = {};
226
262
  for (const [key, val] of Object.entries(value)) {
@@ -238,24 +274,48 @@ export class RpcClient {
238
274
  * and ISO 8601 date strings back to Date objects.
239
275
  *
240
276
  * @param {any} value The value to deserialize.
277
+ * @param {Object} [options] Custom deserialization options (uses client options if not provided).
278
+ * @param {boolean} [options.safeStringEnabled] Whether to expect S: prefixed strings.
279
+ * @param {boolean} [options.safeDateEnabled] Whether to expect D: prefixed dates.
241
280
  * @returns {any}
242
281
  */
243
- deserializeBigIntsAndDates(value) {
282
+ deserializeBigIntsAndDates(value, options = null) {
283
+ // Use provided options or fall back to client options
284
+ const safeStringEnabled = options ? options.safeStringEnabled : this.#options.safeStringEnabled;
285
+ const safeDateEnabled = options ? options.safeDateEnabled : this.#options.safeDateEnabled;
286
+
244
287
  // More comprehensive ISO date regex that handles:
245
288
  // - UTC: 2023-01-01T12:00:00.000Z
246
289
  // - With timezone: 2023-01-01T12:00:00.000+01:00
247
290
  // - Without timezone: 2023-01-01T12:00:00.000 (treated as local)
248
- const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/;
249
-
250
- // 1. Check if it's a string that might be a BigInt or a Date
251
- if (typeof value === "string") {
252
- // BigInt check: matches digits (including negative), optionally ending in "n"
253
- // e.g., "42n", "42", "-42n", "-42"
254
- if (/^-?\d+n?$/.test(value)) {
255
- return BigInt(value.replace(/n$/, ""));
291
+ const ISO_DATE_REGEX =
292
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/;
293
+
294
+ // 1. Check if it's a string that might be a BigInt, Date, or safe string
295
+ if (typeof value === 'string') {
296
+ // Safe string check: if safeStringEnabled and starts with S:
297
+ if (safeStringEnabled && value.startsWith('S:')) {
298
+ return value.substring(2); // Remove 'S:' prefix
299
+ }
300
+
301
+ // Safe date check: if safeDateEnabled and starts with D:
302
+ if (safeDateEnabled && value.startsWith('D:')) {
303
+ const isoString = value.substring(2); // Remove 'D:' prefix
304
+ const date = new Date(isoString);
305
+ // Double-check that we got a valid date
306
+ if (!isNaN(date.getTime())) {
307
+ return date;
308
+ }
256
309
  }
257
- // Date check: matches an ISO 8601 string
258
- if (ISO_DATE_REGEX.test(value)) {
310
+
311
+ // BigInt check: only convert strings that explicitly end with "n"
312
+ // e.g., "42n", "-42n" but NOT "42", "0123456"
313
+ if (/^-?\d+n$/.test(value)) {
314
+ return BigInt(value.slice(0, -1)); // Remove 'n' and convert
315
+ }
316
+
317
+ // Date check: matches an ISO 8601 string (only if safeDateEnabled is false)
318
+ if (!safeDateEnabled && ISO_DATE_REGEX.test(value)) {
259
319
  const date = new Date(value);
260
320
  // Ensure it's valid
261
321
  if (!isNaN(date.getTime())) {
@@ -266,15 +326,15 @@ export class RpcClient {
266
326
 
267
327
  // 2. If it's an array, handle each element
268
328
  if (Array.isArray(value)) {
269
- return value.map((v) => this.deserializeBigIntsAndDates(v));
329
+ return value.map((v) => this.deserializeBigIntsAndDates(v, options));
270
330
  }
271
331
 
272
332
  // 3. If it's a plain object, recurse into each property
273
- if (value && typeof value === "object") {
333
+ if (value && typeof value === 'object') {
274
334
  return Object.fromEntries(
275
335
  Object.entries(value).map(([key, val]) => [
276
336
  key,
277
- this.deserializeBigIntsAndDates(val),
337
+ this.deserializeBigIntsAndDates(val, options),
278
338
  ])
279
339
  );
280
340
  }
@@ -283,3 +343,6 @@ export class RpcClient {
283
343
  return value;
284
344
  }
285
345
  }
346
+
347
+ export default RpcClient;
348
+ module.exports.RpcClient = RpcClient;