@matter-server/ws-client 0.2.0-alpha.0-00000000-000000000

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.
Files changed (40) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +165 -0
  3. package/dist/esm/client.d.ts +67 -0
  4. package/dist/esm/client.d.ts.map +1 -0
  5. package/dist/esm/client.js +286 -0
  6. package/dist/esm/client.js.map +6 -0
  7. package/dist/esm/connection.d.ts +40 -0
  8. package/dist/esm/connection.d.ts.map +1 -0
  9. package/dist/esm/connection.js +75 -0
  10. package/dist/esm/connection.js.map +6 -0
  11. package/dist/esm/exceptions.d.ts +10 -0
  12. package/dist/esm/exceptions.d.ts.map +1 -0
  13. package/dist/esm/exceptions.js +14 -0
  14. package/dist/esm/exceptions.js.map +6 -0
  15. package/dist/esm/index.d.ts +15 -0
  16. package/dist/esm/index.d.ts.map +1 -0
  17. package/dist/esm/index.js +12 -0
  18. package/dist/esm/index.js.map +6 -0
  19. package/dist/esm/json-utils.d.ts +19 -0
  20. package/dist/esm/json-utils.d.ts.map +1 -0
  21. package/dist/esm/json-utils.js +50 -0
  22. package/dist/esm/json-utils.js.map +6 -0
  23. package/dist/esm/models/model.d.ts +379 -0
  24. package/dist/esm/models/model.d.ts.map +1 -0
  25. package/dist/esm/models/model.js +15 -0
  26. package/dist/esm/models/model.js.map +6 -0
  27. package/dist/esm/models/node.d.ts +40 -0
  28. package/dist/esm/models/node.d.ts.map +1 -0
  29. package/dist/esm/models/node.js +59 -0
  30. package/dist/esm/models/node.js.map +6 -0
  31. package/dist/esm/package.json +3 -0
  32. package/package.json +38 -0
  33. package/src/client.ts +405 -0
  34. package/src/connection.ts +103 -0
  35. package/src/exceptions.ts +9 -0
  36. package/src/index.ts +25 -0
  37. package/src/json-utils.ts +75 -0
  38. package/src/models/model.ts +344 -0
  39. package/src/models/node.ts +75 -0
  40. package/src/tsconfig.json +7 -0
package/src/client.ts ADDED
@@ -0,0 +1,405 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { Connection, WebSocketFactory } from "./connection.js";
8
+ import { InvalidServerVersion } from "./exceptions.js";
9
+ import {
10
+ AccessControlEntry,
11
+ APICommands,
12
+ BindingTarget,
13
+ CommissionableNodeData,
14
+ CommissioningParameters,
15
+ ErrorResultMessage,
16
+ EventMessage,
17
+ MatterFabricData,
18
+ MatterSoftwareVersion,
19
+ NodePingResult,
20
+ SuccessResultMessage,
21
+ } from "./models/model.js";
22
+ import { MatterNode } from "./models/node.js";
23
+
24
+ /** Union type for all incoming WebSocket messages */
25
+ type IncomingMessage = EventMessage | ErrorResultMessage | SuccessResultMessage;
26
+
27
+ /** Converts node_id to string for use as object key (works for both number and bigint without precision loss) */
28
+ function toNodeKey(nodeId: number | bigint): string {
29
+ return String(nodeId);
30
+ }
31
+
32
+ export class MatterClient {
33
+ public connection: Connection;
34
+ public nodes: Record<string, MatterNode> = {};
35
+ public serverBaseAddress: string;
36
+ /** Whether this client is connected to a production server (optional, for UI purposes) */
37
+ public isProduction: boolean = false;
38
+ // Using 'unknown' for resolve since the actual types vary by command
39
+ private result_futures: Record<string, { resolve: (value: unknown) => void; reject: (reason?: unknown) => void }> =
40
+ {};
41
+ private msgId = 0;
42
+ private eventListeners: Record<string, Array<() => void>> = {};
43
+
44
+ /**
45
+ * Create a new MatterClient.
46
+ * @param url WebSocket URL to connect to
47
+ * @param wsFactory Optional factory function to create WebSocket instances.
48
+ * For Node.js, pass: (url) => new WebSocket(url) from the 'ws' package.
49
+ * For browser, leave undefined to use native WebSocket.
50
+ */
51
+ constructor(
52
+ public url: string,
53
+ wsFactory?: WebSocketFactory,
54
+ ) {
55
+ this.url = url;
56
+ this.connection = new Connection(this.url, wsFactory);
57
+ this.serverBaseAddress = this.url.split("://")[1].split(":")[0] || "";
58
+ }
59
+
60
+ get serverInfo() {
61
+ return this.connection.serverInfo!;
62
+ }
63
+
64
+ addEventListener(event: string, listener: () => void) {
65
+ if (!this.eventListeners[event]) {
66
+ this.eventListeners[event] = [];
67
+ }
68
+ this.eventListeners[event].push(listener);
69
+ return () => {
70
+ this.eventListeners[event] = this.eventListeners[event].filter(l => l !== listener);
71
+ };
72
+ }
73
+
74
+ async commissionWithCode(code: string, networkOnly = true): Promise<MatterNode> {
75
+ // Commission a device using a QR Code or Manual Pairing Code.
76
+ // code: The QR Code or Manual Pairing Code for device commissioning.
77
+ // network_only: If True, restricts device discovery to network only.
78
+ // Returns: The NodeInfo of the commissioned device.
79
+ return await this.sendCommand("commission_with_code", 0, {
80
+ code: code,
81
+ network_only: networkOnly,
82
+ });
83
+ }
84
+
85
+ async setWifiCredentials(ssid: string, credentials: string) {
86
+ // Set WiFi credentials for commissioning to a (new) device.
87
+ await this.sendCommand("set_wifi_credentials", 0, { ssid, credentials });
88
+ }
89
+
90
+ async setThreadOperationalDataset(dataset: string) {
91
+ // Set Thread Operational dataset in the stack.
92
+ await this.sendCommand("set_thread_dataset", 0, { dataset });
93
+ }
94
+
95
+ async openCommissioningWindow(
96
+ nodeId: number | bigint,
97
+ timeout?: number,
98
+ iteration?: number,
99
+ option?: number,
100
+ discriminator?: number,
101
+ ): Promise<CommissioningParameters> {
102
+ // Open a commissioning window to commission a device present on this controller to another.
103
+ // Returns code to use as discriminator.
104
+ return await this.sendCommand("open_commissioning_window", 0, {
105
+ node_id: nodeId,
106
+ timeout,
107
+ iteration,
108
+ option,
109
+ discriminator,
110
+ });
111
+ }
112
+
113
+ async discoverCommissionableNodes(): Promise<CommissionableNodeData[]> {
114
+ // Discover Commissionable Nodes (discovered on BLE or mDNS).
115
+ return await this.sendCommand("discover_commissionable_nodes", 0, {});
116
+ }
117
+
118
+ async getMatterFabrics(nodeId: number | bigint): Promise<MatterFabricData[]> {
119
+ // Get Matter fabrics from a device.
120
+ // Returns a list of MatterFabricData objects.
121
+ return await this.sendCommand("get_matter_fabrics", 3, { node_id: nodeId });
122
+ }
123
+
124
+ async removeMatterFabric(nodeId: number | bigint, fabricIndex: number) {
125
+ // Remove a Matter fabric from a device.
126
+ await this.sendCommand("remove_matter_fabric", 3, { node_id: nodeId, fabric_index: fabricIndex });
127
+ }
128
+
129
+ async pingNode(nodeId: number | bigint, attempts = 1): Promise<NodePingResult> {
130
+ // Ping node on the currently known IP-address(es).
131
+ return await this.sendCommand("ping_node", 0, { node_id: nodeId, attempts });
132
+ }
133
+
134
+ async getNodeIPAddresses(nodeId: number | bigint, preferCache?: boolean, scoped?: boolean): Promise<string[]> {
135
+ // Return the currently known (scoped) IP-address(es).
136
+ return await this.sendCommand("get_node_ip_addresses", 8, {
137
+ node_id: nodeId,
138
+ prefer_cache: preferCache,
139
+ scoped: scoped,
140
+ });
141
+ }
142
+
143
+ async removeNode(nodeId: number | bigint) {
144
+ // Remove a Matter node/device from the fabric.
145
+ await this.sendCommand("remove_node", 0, { node_id: nodeId });
146
+ }
147
+
148
+ async interviewNode(nodeId: number | bigint) {
149
+ // Interview a node.
150
+ await this.sendCommand("interview_node", 0, { node_id: nodeId });
151
+ }
152
+
153
+ async importTestNode(dump: string) {
154
+ // Import test node(s) from a HA or Matter server diagnostics dump.
155
+ await this.sendCommand("import_test_node", 0, { dump });
156
+ }
157
+
158
+ async readAttribute(nodeId: number | bigint, attributePath: string | string[]): Promise<Record<string, unknown>> {
159
+ // Read one or more attribute(s) on a node by specifying an attributepath.
160
+ return await this.sendCommand("read_attribute", 0, { node_id: nodeId, attribute_path: attributePath });
161
+ }
162
+
163
+ async writeAttribute(nodeId: number | bigint, attributePath: string, value: unknown): Promise<unknown> {
164
+ // Write an attribute(value) on a target node.
165
+ return await this.sendCommand("write_attribute", 0, {
166
+ node_id: nodeId,
167
+ attribute_path: attributePath,
168
+ value: value,
169
+ });
170
+ }
171
+
172
+ async checkNodeUpdate(nodeId: number | bigint): Promise<MatterSoftwareVersion | null> {
173
+ // Check if there is an update for a particular node.
174
+ // Reads the current software version and checks the DCL if there is an update
175
+ // available. If there is an update available, the command returns the version
176
+ // information of the latest update available.
177
+ return await this.sendCommand("check_node_update", 10, { node_id: nodeId });
178
+ }
179
+
180
+ async updateNode(nodeId: number | bigint, softwareVersion: number | string) {
181
+ // Update a node to a new software version.
182
+ // This command checks if the requested software version is indeed still available
183
+ // and if so, it will start the update process. The update process will be handled
184
+ // by the built-in OTA provider. The OTA provider will download the update and
185
+ // notify the node about the new update.
186
+ await this.sendCommand("update_node", 10, { node_id: nodeId, software_version: softwareVersion });
187
+ }
188
+
189
+ async setACLEntry(nodeId: number | bigint, entry: AccessControlEntry[]) {
190
+ return await this.sendCommand("set_acl_entry", 0, {
191
+ node_id: nodeId,
192
+ entry: entry,
193
+ });
194
+ }
195
+
196
+ async setNodeBinding(nodeId: number | bigint, endpoint: number, bindings: BindingTarget[]) {
197
+ return await this.sendCommand("set_node_binding", 0, {
198
+ node_id: nodeId,
199
+ endpoint: endpoint,
200
+ bindings: bindings,
201
+ });
202
+ }
203
+
204
+ async deviceCommand(
205
+ nodeId: number | bigint,
206
+ endpointId: number,
207
+ clusterId: number,
208
+ commandName: string,
209
+ payload: Record<string, unknown> = {},
210
+ ): Promise<unknown> {
211
+ return await this.sendCommand("device_command", 0, {
212
+ node_id: nodeId,
213
+ endpoint_id: endpointId,
214
+ cluster_id: clusterId,
215
+ command_name: commandName,
216
+ payload,
217
+ response_type: null,
218
+ });
219
+ }
220
+
221
+ async getNodes(onlyAvailable = false): Promise<MatterNode[]> {
222
+ return await this.sendCommand("get_nodes", 0, { only_available: onlyAvailable });
223
+ }
224
+
225
+ async getNode(nodeId: number | bigint): Promise<MatterNode> {
226
+ return await this.sendCommand("get_node", 0, { node_id: nodeId });
227
+ }
228
+
229
+ async getVendorNames(filterVendors?: number[]): Promise<Record<string, string>> {
230
+ return await this.sendCommand("get_vendor_names", 0, { filter_vendors: filterVendors });
231
+ }
232
+
233
+ async fetchServerInfo() {
234
+ return await this.sendCommand("server_info", 0, {});
235
+ }
236
+
237
+ async setDefaultFabricLabel(label: string | null) {
238
+ await this.sendCommand("set_default_fabric_label", 0, { label });
239
+ }
240
+
241
+ sendCommand<T extends keyof APICommands>(
242
+ command: T,
243
+ require_schema: number | undefined = undefined,
244
+ args: APICommands[T]["requestArgs"],
245
+ ): Promise<APICommands[T]["response"]> {
246
+ if (require_schema && this.serverInfo.schema_version < require_schema) {
247
+ throw new InvalidServerVersion(
248
+ "Command not available due to incompatible server version. Update the Matter " +
249
+ `Server to a version that supports at least api schema ${require_schema}.`,
250
+ );
251
+ }
252
+
253
+ const messageId = ++this.msgId;
254
+
255
+ const message = {
256
+ message_id: messageId.toString(),
257
+ command,
258
+ args,
259
+ };
260
+
261
+ const messagePromise = new Promise<APICommands[T]["response"]>((resolve, reject) => {
262
+ // Type-erased storage: resolve/reject are stored as unknown handlers
263
+ this.result_futures[messageId] = {
264
+ resolve: resolve as (value: unknown) => void,
265
+ reject,
266
+ };
267
+ this.connection.sendMessage(message);
268
+ });
269
+
270
+ return messagePromise.finally(() => {
271
+ delete this.result_futures[messageId];
272
+ });
273
+ }
274
+
275
+ async connect() {
276
+ if (this.connection.connected) {
277
+ return;
278
+ }
279
+ await this.connection.connect(
280
+ msg => this._handleIncomingMessage(msg as IncomingMessage),
281
+ () => this.fireEvent("connection_lost"),
282
+ );
283
+ }
284
+
285
+ disconnect(clearStorage = false) {
286
+ // disconnect from the server
287
+ if (this.connection && this.connection.connected) {
288
+ this.connection.disconnect();
289
+ }
290
+ if (clearStorage && typeof localStorage !== "undefined") {
291
+ localStorage.removeItem("matterURL");
292
+ location.reload();
293
+ }
294
+ }
295
+
296
+ async startListening() {
297
+ await this.connect();
298
+
299
+ const nodesArray = await this.sendCommand("start_listening", 0, {});
300
+
301
+ const nodes: Record<string, MatterNode> = {};
302
+ for (const node of nodesArray) {
303
+ nodes[toNodeKey(node.node_id)] = new MatterNode(node);
304
+ }
305
+ this.nodes = nodes;
306
+ }
307
+
308
+ private _handleIncomingMessage(msg: IncomingMessage) {
309
+ if ("event" in msg) {
310
+ this._handleEventMessage(msg);
311
+ return;
312
+ }
313
+
314
+ if ("error_code" in msg) {
315
+ const promise = this.result_futures[msg.message_id];
316
+ if (promise) {
317
+ promise.reject(new Error(msg.details));
318
+ delete this.result_futures[msg.message_id];
319
+ }
320
+ return;
321
+ }
322
+
323
+ if ("result" in msg) {
324
+ const promise = this.result_futures[msg.message_id];
325
+ if (promise) {
326
+ promise.resolve(msg.result);
327
+ delete this.result_futures[msg.message_id];
328
+ }
329
+ return;
330
+ }
331
+
332
+ console.warn("Received message with unknown format", msg);
333
+ }
334
+
335
+ private _handleEventMessage(event: EventMessage) {
336
+ console.log("Incoming event", event);
337
+
338
+ // Allow subclasses to hook into raw events (for testing)
339
+ this.onRawEvent(event);
340
+
341
+ if (event.event === "node_added") {
342
+ const node = new MatterNode(event.data);
343
+ this.nodes = { ...this.nodes, [toNodeKey(node.node_id)]: node };
344
+ this.fireEvent("nodes_changed");
345
+ return;
346
+ }
347
+ if (event.event === "node_removed") {
348
+ delete this.nodes[toNodeKey(event.data)];
349
+ this.nodes = { ...this.nodes };
350
+ this.fireEvent("nodes_changed");
351
+ return;
352
+ }
353
+
354
+ if (event.event === "node_updated") {
355
+ const node = new MatterNode(event.data);
356
+ this.nodes = { ...this.nodes, [toNodeKey(node.node_id)]: node };
357
+ this.fireEvent("nodes_changed");
358
+ return;
359
+ }
360
+
361
+ if (event.event === "attribute_updated") {
362
+ const [nodeId, attributeKey, attributeValue] = event.data;
363
+ const nodeKey = toNodeKey(nodeId);
364
+ const existingNode = this.nodes[nodeKey];
365
+ if (existingNode) {
366
+ const node = new MatterNode(existingNode.data);
367
+ node.attributes[attributeKey] = attributeValue;
368
+ this.nodes = { ...this.nodes, [nodeKey]: node };
369
+ this.fireEvent("nodes_changed");
370
+ }
371
+ return;
372
+ }
373
+
374
+ if (event.event === "server_info_updated") {
375
+ this.connection.serverInfo = event.data;
376
+ this.fireEvent("server_info_updated");
377
+ return;
378
+ }
379
+
380
+ if (event.event === "server_shutdown") {
381
+ this.fireEvent("server_shutdown");
382
+ this.disconnect();
383
+ return;
384
+ }
385
+ }
386
+
387
+ private fireEvent(event: string) {
388
+ const listeners = this.eventListeners[event];
389
+ if (listeners) {
390
+ for (const listener of listeners) {
391
+ listener();
392
+ }
393
+ }
394
+ }
395
+
396
+ /**
397
+ * Hook for subclasses to receive raw events.
398
+ * Override this method to intercept all incoming events.
399
+ * @param event The raw event message
400
+ */
401
+ protected onRawEvent(_event: EventMessage): void {
402
+ // Default implementation does nothing
403
+ // Subclasses can override to collect or process raw events
404
+ }
405
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { parseBigIntAwareJson, toBigIntAwareJson } from "./json-utils.js";
8
+ import { CommandMessage, ServerInfoMessage } from "./models/model.js";
9
+
10
+ /**
11
+ * WebSocket interface that works with both browser WebSocket and Node.js ws library.
12
+ */
13
+ export interface WebSocketLike {
14
+ readyState: number;
15
+ onopen: ((event: unknown) => void) | null;
16
+ onclose: ((event: unknown) => void) | null;
17
+ onerror: ((event: unknown) => void) | null;
18
+ onmessage: ((event: { data: unknown }) => void) | null;
19
+ send(data: string): void;
20
+ close(): void;
21
+ }
22
+
23
+ export type WebSocketFactory = (url: string) => WebSocketLike;
24
+
25
+ export class Connection {
26
+ public serverInfo?: ServerInfoMessage = undefined;
27
+
28
+ private socket?: WebSocketLike;
29
+ private wsFactory: WebSocketFactory;
30
+
31
+ /**
32
+ * Create a new connection.
33
+ * @param ws_server_url WebSocket server URL
34
+ * @param wsFactory Optional factory function to create WebSocket instances.
35
+ * If not provided, uses the global WebSocket constructor.
36
+ */
37
+ constructor(
38
+ public ws_server_url: string,
39
+ wsFactory?: WebSocketFactory,
40
+ ) {
41
+ this.ws_server_url = ws_server_url;
42
+ this.wsFactory = wsFactory ?? (url => new WebSocket(url) as unknown as WebSocketLike);
43
+ }
44
+
45
+ get connected() {
46
+ return this.socket?.readyState === 1; // WebSocket.OPEN = 1
47
+ }
48
+
49
+ async connect(onMessage: (msg: unknown) => void, onConnectionLost: () => void) {
50
+ if (this.socket) {
51
+ throw new Error("Already connected");
52
+ }
53
+
54
+ console.debug("Trying to connect");
55
+
56
+ return new Promise<void>((resolve, reject) => {
57
+ this.socket = this.wsFactory(this.ws_server_url);
58
+
59
+ this.socket.onopen = () => {
60
+ console.log("WebSocket Connected");
61
+ };
62
+
63
+ this.socket.onclose = () => {
64
+ console.log("WebSocket Closed");
65
+ onConnectionLost();
66
+ };
67
+
68
+ this.socket.onerror = error => {
69
+ console.error("WebSocket Error: ", error);
70
+ reject(new Error("WebSocket Error"));
71
+ };
72
+
73
+ this.socket.onmessage = (event: { data: unknown }) => {
74
+ const dataStr = typeof event.data === "string" ? event.data : String(event.data);
75
+ const data = parseBigIntAwareJson(dataStr);
76
+ console.log("WebSocket OnMessage", data);
77
+ if (!this.serverInfo) {
78
+ this.serverInfo = data as ServerInfoMessage;
79
+ resolve();
80
+ return;
81
+ }
82
+ onMessage(data);
83
+ };
84
+ });
85
+ }
86
+
87
+ disconnect() {
88
+ if (this.socket) {
89
+ this.socket.close();
90
+ this.socket = undefined;
91
+ }
92
+ }
93
+
94
+ sendMessage(message: CommandMessage): void {
95
+ if (!this.socket) {
96
+ throw new Error("Not connected");
97
+ }
98
+ console.log("WebSocket send message", message);
99
+ this.socket.send(toBigIntAwareJson(message));
100
+ }
101
+ }
102
+
103
+ export default Connection;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ export class MatterError extends Error {}
8
+
9
+ export class InvalidServerVersion extends MatterError {}
package/src/index.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ /**
8
+ * @matter-server/ws-client - WebSocket client library for Matter server
9
+ */
10
+
11
+ // Export client
12
+ export * from "./client.js";
13
+
14
+ // Export connection
15
+ export * from "./connection.js";
16
+
17
+ // Export exceptions
18
+ export * from "./exceptions.js";
19
+
20
+ // Export JSON utilities
21
+ export * from "./json-utils.js";
22
+
23
+ // Export models
24
+ export * from "./models/model.js";
25
+ export * from "./models/node.js";
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ /**
8
+ * JSON utilities for handling BigInt values in WebSocket communication.
9
+ * These functions ensure proper serialization/deserialization of large numbers
10
+ * that exceed JavaScript's MAX_SAFE_INTEGER (e.g., Matter node IDs, fabric IDs).
11
+ */
12
+
13
+ /** Marker prefix for large numbers that need BigInt conversion */
14
+ const BIGINT_MARKER = "__BIGINT__";
15
+
16
+ /**
17
+ * Serialize to JSON with BigInt support.
18
+ * - BigInt values within safe integer range are converted to numbers
19
+ * - Large BigInt values are output as raw decimal numbers (not quoted strings)
20
+ * Use this for outgoing WebSocket messages and displaying values.
21
+ */
22
+ export function toBigIntAwareJson(value: unknown, spaces?: number): string {
23
+ const replacements = new Array<{ from: string; to: string }>();
24
+ let result = JSON.stringify(
25
+ value,
26
+ (_key, val) => {
27
+ if (typeof val === "bigint") {
28
+ if (val > Number.MAX_SAFE_INTEGER) {
29
+ // Store replacement: quoted hex string -> raw decimal number
30
+ replacements.push({ from: `"0x${val.toString(16)}"`, to: val.toString() });
31
+ return `0x${val.toString(16)}`;
32
+ } else {
33
+ return Number(val);
34
+ }
35
+ }
36
+ return val;
37
+ },
38
+ spaces,
39
+ );
40
+ // Large numbers need to be raw (not quoted) in the output, so replace hex placeholders with decimal
41
+ // This handles both object values and array elements
42
+ if (replacements.length > 0) {
43
+ replacements.forEach(({ from, to }) => {
44
+ result = result.replaceAll(from, to);
45
+ });
46
+ }
47
+
48
+ return result;
49
+ }
50
+
51
+ /**
52
+ * Parse JSON with BigInt support for large numbers that exceed JavaScript precision.
53
+ * Numbers with 15+ digits that exceed MAX_SAFE_INTEGER are converted to BigInt.
54
+ * Use this for incoming WebSocket messages.
55
+ */
56
+ export function parseBigIntAwareJson(json: string): unknown {
57
+ // Pre-process: Replace large numbers (15+ digits) with marked string placeholders
58
+ // This must happen before JSON.parse to preserve precision
59
+ // Match numbers after colon (object values) or after [ or , (array elements)
60
+ const processed = json.replace(/([:,[])\s*(\d{15,})(?=[,}\]\s])/g, (match, prefix, number) => {
61
+ const num = BigInt(number);
62
+ if (num > Number.MAX_SAFE_INTEGER) {
63
+ return `${prefix}"${BIGINT_MARKER}${number}"`;
64
+ }
65
+ return match;
66
+ });
67
+
68
+ // Parse with reviver to convert marked strings back to BigInt
69
+ return JSON.parse(processed, (_key, value) => {
70
+ if (typeof value === "string" && value.startsWith(BIGINT_MARKER)) {
71
+ return BigInt(value.slice(BIGINT_MARKER.length));
72
+ }
73
+ return value;
74
+ });
75
+ }