@grafema/rfdb-client 0.2.11 → 0.3.0-beta

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.
@@ -0,0 +1,174 @@
1
+ /**
2
+ * RFDBWebSocketClient - WebSocket client for RFDB server
3
+ *
4
+ * Provides same API as RFDBClient but uses WebSocket transport instead of Unix socket.
5
+ * Designed for browser environments (VS Code web extension, vscode.dev).
6
+ *
7
+ * Key differences from Unix socket client:
8
+ * - No length-prefix framing (WebSocket handles message boundaries)
9
+ * - No streaming support (protocol v2 only, no NodesChunk)
10
+ * - Uses globalThis.WebSocket (works in both Node.js 22+ and browsers)
11
+ */
12
+
13
+ import { encode, decode } from '@msgpack/msgpack';
14
+ import { BaseRFDBClient } from './base-client.js';
15
+
16
+ import type {
17
+ RFDBCommand,
18
+ RFDBResponse,
19
+ HelloResponse,
20
+ } from '@grafema/types';
21
+
22
+ interface PendingRequest {
23
+ resolve: (value: RFDBResponse) => void;
24
+ reject: (error: Error) => void;
25
+ }
26
+
27
+ const DEFAULT_TIMEOUT_MS = 60_000;
28
+
29
+ export class RFDBWebSocketClient extends BaseRFDBClient {
30
+ readonly socketPath: string;
31
+ readonly clientName: string;
32
+ private ws: WebSocket | null = null;
33
+ connected: boolean = false;
34
+ private pending: Map<number, PendingRequest> = new Map();
35
+ private reqId: number = 0;
36
+
37
+ constructor(private url: string, clientName: string = 'unknown') {
38
+ super();
39
+ // socketPath returns the URL to satisfy IRFDBClient interface
40
+ this.socketPath = url;
41
+ this.clientName = clientName;
42
+ }
43
+
44
+ /**
45
+ * Connect to RFDB server via WebSocket.
46
+ */
47
+ async connect(): Promise<void> {
48
+ if (this.connected) return;
49
+
50
+ return new Promise((resolve, reject) => {
51
+ this.ws = new WebSocket(this.url);
52
+ this.ws.binaryType = 'arraybuffer';
53
+
54
+ this.ws.onopen = () => {
55
+ this.connected = true;
56
+ this.emit('connected');
57
+ resolve();
58
+ };
59
+
60
+ this.ws.onerror = () => {
61
+ const error = new Error(`WebSocket connection error: ${this.url}`);
62
+ if (!this.connected) {
63
+ reject(error);
64
+ } else {
65
+ this.emit('error', error);
66
+ }
67
+ };
68
+
69
+ this.ws.onclose = (event: CloseEvent) => {
70
+ this.connected = false;
71
+ this.emit('disconnected');
72
+ for (const [, { reject: rej }] of this.pending) {
73
+ rej(new Error(`Connection closed (code: ${event.code})`));
74
+ }
75
+ this.pending.clear();
76
+ };
77
+
78
+ this.ws.onmessage = (event: MessageEvent) => {
79
+ this._handleMessage(event.data as ArrayBuffer);
80
+ };
81
+ });
82
+ }
83
+
84
+ private _handleMessage(data: ArrayBuffer): void {
85
+ try {
86
+ const response = decode(new Uint8Array(data)) as RFDBResponse;
87
+ const id = this._parseRequestId(response.requestId);
88
+ if (id === null) {
89
+ return;
90
+ }
91
+
92
+ const pending = this.pending.get(id);
93
+ if (!pending) {
94
+ return;
95
+ }
96
+
97
+ this.pending.delete(id);
98
+
99
+ if (response.error) {
100
+ pending.reject(new Error(response.error));
101
+ } else {
102
+ pending.resolve(response);
103
+ }
104
+ } catch (err) {
105
+ this.emit('error', err);
106
+ }
107
+ }
108
+
109
+ private _parseRequestId(requestId: unknown): number | null {
110
+ if (typeof requestId === 'string' && requestId.startsWith('r')) {
111
+ const num = parseInt(requestId.slice(1), 10);
112
+ return isNaN(num) ? null : num;
113
+ }
114
+ return null;
115
+ }
116
+
117
+ /**
118
+ * Send a request and wait for response with timeout.
119
+ * No length prefix -- WebSocket handles framing.
120
+ */
121
+ protected async _send(
122
+ cmd: RFDBCommand,
123
+ payload: Record<string, unknown> = {},
124
+ timeoutMs: number = DEFAULT_TIMEOUT_MS,
125
+ ): Promise<RFDBResponse> {
126
+ if (!this.connected || !this.ws) {
127
+ throw new Error('Not connected to RFDB server');
128
+ }
129
+
130
+ return new Promise((resolve, reject) => {
131
+ const id = this.reqId++;
132
+ const request = { requestId: `r${id}`, cmd, ...payload };
133
+ const msgBytes = encode(request);
134
+
135
+ const timer = setTimeout(() => {
136
+ this.pending.delete(id);
137
+ reject(new Error(`Request timed out: ${cmd} (${timeoutMs}ms)`));
138
+ }, timeoutMs);
139
+
140
+ this.pending.set(id, {
141
+ resolve: (value) => {
142
+ clearTimeout(timer);
143
+ resolve(value);
144
+ },
145
+ reject: (error) => {
146
+ clearTimeout(timer);
147
+ reject(error);
148
+ },
149
+ });
150
+
151
+ this.ws!.send(msgBytes);
152
+ });
153
+ }
154
+
155
+ /**
156
+ * Negotiate protocol version with server.
157
+ * WebSocket client always uses protocol v2 (no streaming support in MVP).
158
+ */
159
+ override async hello(protocolVersion: number = 2): Promise<HelloResponse> {
160
+ const response = await this._send('hello' as RFDBCommand, {
161
+ protocolVersion: 2,
162
+ });
163
+ return response as HelloResponse;
164
+ }
165
+
166
+ async close(): Promise<void> {
167
+ if (this.ws) {
168
+ this.ws.close(1000, 'Client closed');
169
+ this.ws = null;
170
+ }
171
+ this.connected = false;
172
+ this.pending.clear();
173
+ }
174
+ }