@lspeasy/client 1.0.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/LICENSE +21 -0
- package/README.md +589 -0
- package/dist/capability-guard.d.ts +62 -0
- package/dist/capability-guard.d.ts.map +1 -0
- package/dist/capability-guard.js +230 -0
- package/dist/capability-guard.js.map +1 -0
- package/dist/capability-proxy.d.ts +16 -0
- package/dist/capability-proxy.d.ts.map +1 -0
- package/dist/capability-proxy.js +69 -0
- package/dist/capability-proxy.js.map +1 -0
- package/dist/client.d.ts +184 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +692 -0
- package/dist/client.js.map +1 -0
- package/dist/connection/health.d.ts +14 -0
- package/dist/connection/health.d.ts.map +1 -0
- package/dist/connection/health.js +77 -0
- package/dist/connection/health.js.map +1 -0
- package/dist/connection/heartbeat.d.ts +18 -0
- package/dist/connection/heartbeat.d.ts.map +1 -0
- package/dist/connection/heartbeat.js +57 -0
- package/dist/connection/heartbeat.js.map +1 -0
- package/dist/connection/index.d.ts +5 -0
- package/dist/connection/index.d.ts.map +1 -0
- package/dist/connection/index.js +4 -0
- package/dist/connection/index.js.map +1 -0
- package/dist/connection/types.d.ts +32 -0
- package/dist/connection/types.d.ts.map +1 -0
- package/dist/connection/types.js +9 -0
- package/dist/connection/types.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/notifications/index.d.ts +3 -0
- package/dist/notifications/index.d.ts.map +1 -0
- package/dist/notifications/index.js +2 -0
- package/dist/notifications/index.js.map +1 -0
- package/dist/notifications/wait.d.ts +19 -0
- package/dist/notifications/wait.d.ts.map +1 -0
- package/dist/notifications/wait.js +46 -0
- package/dist/notifications/wait.js.map +1 -0
- package/dist/progress.d.ts +54 -0
- package/dist/progress.d.ts.map +1 -0
- package/dist/progress.js +52 -0
- package/dist/progress.js.map +1 -0
- package/dist/types.d.ts +82 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/dist/validation.d.ts +43 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +56 -0
- package/dist/validation.js.map +1 -0
- package/package.json +58 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LSP Client implementation
|
|
3
|
+
*/
|
|
4
|
+
import { CancellationTokenSource, ConsoleLogger, executeMiddlewarePipeline, LogLevel } from '@lspeasy/core';
|
|
5
|
+
import { DisposableEventEmitter, HandlerRegistry } from '@lspeasy/core/utils';
|
|
6
|
+
import { PendingRequestTracker, TransportAttachment } from '@lspeasy/core/utils/internal';
|
|
7
|
+
import { initializeCapabilityMethods, updateCapabilityMethods } from './capability-proxy.js';
|
|
8
|
+
import { CapabilityGuard, ClientCapabilityGuard } from './capability-guard.js';
|
|
9
|
+
import { ConnectionHealthTracker, ConnectionState, HeartbeatMonitor } from './connection/index.js';
|
|
10
|
+
/**
|
|
11
|
+
* Interface for dynamically added namespace methods
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Type alias to add capability-aware methods to LSPClient
|
|
15
|
+
* These methods are added at runtime via capability-proxy.ts
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* LSP Client for connecting to language servers
|
|
19
|
+
*
|
|
20
|
+
* This class dynamically extends with capability-aware methods based on server capabilities.
|
|
21
|
+
* Use `.expect<ServerCaps>()` after initialization to get typed access to server capabilities.
|
|
22
|
+
*
|
|
23
|
+
* @template ClientCaps - Client capabilities (defaults to ClientCapabilities)
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* // Create a client, then narrow server capabilities after connecting
|
|
27
|
+
* const client = new LSPClient<MyClientCaps>();
|
|
28
|
+
* await client.connect(transport);
|
|
29
|
+
* const typed = client.expect<{ hoverProvider: true; completionProvider: {} }>();
|
|
30
|
+
* // typed.textDocument.hover is available (typed)
|
|
31
|
+
* // typed.textDocument.completion is available (typed)
|
|
32
|
+
*/
|
|
33
|
+
class BaseLSPClient {
|
|
34
|
+
transport;
|
|
35
|
+
connected;
|
|
36
|
+
initialized;
|
|
37
|
+
pendingRequests;
|
|
38
|
+
requestHandlers;
|
|
39
|
+
notificationHandlers;
|
|
40
|
+
events;
|
|
41
|
+
transportAttachment;
|
|
42
|
+
logger;
|
|
43
|
+
middlewareRegistrations;
|
|
44
|
+
options;
|
|
45
|
+
capabilities;
|
|
46
|
+
serverCapabilities;
|
|
47
|
+
serverInfo;
|
|
48
|
+
onValidationError;
|
|
49
|
+
capabilityGuard;
|
|
50
|
+
clientCapabilityGuard;
|
|
51
|
+
healthTracker;
|
|
52
|
+
heartbeatMonitor;
|
|
53
|
+
notificationWaiters;
|
|
54
|
+
constructor(options = {}) {
|
|
55
|
+
this.connected = false;
|
|
56
|
+
this.initialized = false;
|
|
57
|
+
// Set up options with defaults
|
|
58
|
+
this.options = {
|
|
59
|
+
name: options.name ?? 'lspeasy-client',
|
|
60
|
+
version: options.version ?? '1.0.0',
|
|
61
|
+
logger: options.logger ?? new ConsoleLogger(options.logLevel ?? LogLevel.Info),
|
|
62
|
+
logLevel: options.logLevel ?? LogLevel.Info,
|
|
63
|
+
requestTimeout: options.requestTimeout,
|
|
64
|
+
strictCapabilities: options.strictCapabilities ?? false,
|
|
65
|
+
heartbeat: options.heartbeat
|
|
66
|
+
};
|
|
67
|
+
this.logger = this.options.logger;
|
|
68
|
+
this.middlewareRegistrations = options.middleware ?? [];
|
|
69
|
+
this.pendingRequests = new PendingRequestTracker(this.options.requestTimeout);
|
|
70
|
+
this.requestHandlers = new HandlerRegistry();
|
|
71
|
+
this.notificationHandlers = new HandlerRegistry();
|
|
72
|
+
this.events = new DisposableEventEmitter();
|
|
73
|
+
this.transportAttachment = new TransportAttachment();
|
|
74
|
+
this.notificationWaiters = new Set();
|
|
75
|
+
this.healthTracker = new ConnectionHealthTracker();
|
|
76
|
+
if (options.capabilities) {
|
|
77
|
+
this.capabilities = options.capabilities;
|
|
78
|
+
}
|
|
79
|
+
if (options.onValidationError) {
|
|
80
|
+
this.onValidationError = options.onValidationError;
|
|
81
|
+
}
|
|
82
|
+
// Initialize capability guard for server-to-client handlers
|
|
83
|
+
this.clientCapabilityGuard = new ClientCapabilityGuard(this.capabilities ?? {}, this.logger, this.options.strictCapabilities);
|
|
84
|
+
// Initialize capability-aware methods on the client object
|
|
85
|
+
// These will be empty initially and populated after server capabilities are received
|
|
86
|
+
initializeCapabilityMethods(this);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Connect to server and complete initialization
|
|
90
|
+
*/
|
|
91
|
+
async connect(transport) {
|
|
92
|
+
if (this.connected) {
|
|
93
|
+
throw new Error('Client is already connected');
|
|
94
|
+
}
|
|
95
|
+
this.healthTracker.setState(ConnectionState.Connecting);
|
|
96
|
+
this.transport = transport;
|
|
97
|
+
this.connected = true;
|
|
98
|
+
this.transportAttachment.attach(transport, {
|
|
99
|
+
onMessage: (message) => this.handleMessage(message),
|
|
100
|
+
onError: (error) => this.handleError(error),
|
|
101
|
+
onClose: () => this.handleClose()
|
|
102
|
+
});
|
|
103
|
+
try {
|
|
104
|
+
// Send initialize request
|
|
105
|
+
this.logger.debug('Sending initialize request');
|
|
106
|
+
const initializeParams = {
|
|
107
|
+
processId: process.pid,
|
|
108
|
+
clientInfo: {
|
|
109
|
+
name: this.options.name,
|
|
110
|
+
version: this.options.version
|
|
111
|
+
},
|
|
112
|
+
capabilities: this.capabilities ?? {},
|
|
113
|
+
rootUri: null
|
|
114
|
+
};
|
|
115
|
+
const result = await this.sendRequest('initialize', initializeParams);
|
|
116
|
+
this.serverCapabilities = result.capabilities;
|
|
117
|
+
if (result.serverInfo) {
|
|
118
|
+
this.serverInfo = result.serverInfo;
|
|
119
|
+
}
|
|
120
|
+
this.initialized = true;
|
|
121
|
+
// Create capability guard to validate outgoing requests
|
|
122
|
+
this.capabilityGuard = new CapabilityGuard(result.capabilities, this.logger, this.options.strictCapabilities);
|
|
123
|
+
// Update capability-aware methods based on server capabilities
|
|
124
|
+
updateCapabilityMethods(this);
|
|
125
|
+
// Send initialized notification
|
|
126
|
+
await this.sendNotification('initialized', {});
|
|
127
|
+
this.healthTracker.setState(ConnectionState.Connected);
|
|
128
|
+
this.startHeartbeatIfConfigured(this.options.heartbeat);
|
|
129
|
+
this.logger.info('Client initialized', {
|
|
130
|
+
serverName: this.serverInfo?.name,
|
|
131
|
+
serverVersion: this.serverInfo?.version
|
|
132
|
+
});
|
|
133
|
+
this.events.emit('connected');
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
// Ensure we clean up state and transport on initialization failure
|
|
138
|
+
try {
|
|
139
|
+
if (this.transport) {
|
|
140
|
+
await this.transport.close();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch (closeError) {
|
|
144
|
+
this.logger.error('Error closing transport after failed initialize', closeError);
|
|
145
|
+
}
|
|
146
|
+
// Reset client state and detach listeners via common close handler
|
|
147
|
+
this.handleClose();
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Disconnect from server
|
|
153
|
+
*/
|
|
154
|
+
async disconnect() {
|
|
155
|
+
if (!this.connected) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
this.healthTracker.setState(ConnectionState.Disconnecting);
|
|
159
|
+
try {
|
|
160
|
+
// Send shutdown request
|
|
161
|
+
if (this.initialized) {
|
|
162
|
+
this.logger.debug('Sending shutdown request');
|
|
163
|
+
await this.sendRequest('shutdown');
|
|
164
|
+
// Send exit notification
|
|
165
|
+
this.logger.debug('Sending exit notification');
|
|
166
|
+
await this.sendNotification('exit');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
this.logger.error('Error during shutdown', error);
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
// Close transport
|
|
174
|
+
if (this.transport) {
|
|
175
|
+
await this.transport.close();
|
|
176
|
+
}
|
|
177
|
+
this.handleClose();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Check if client is connected
|
|
182
|
+
*/
|
|
183
|
+
isConnected() {
|
|
184
|
+
return this.connected && this.initialized;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Send a request to the server
|
|
188
|
+
*/
|
|
189
|
+
async sendRequest(method, params, token) {
|
|
190
|
+
if (!this.connected || !this.transport) {
|
|
191
|
+
throw new Error('Client is not connected');
|
|
192
|
+
}
|
|
193
|
+
// Validate request against server capabilities
|
|
194
|
+
if (this.capabilityGuard && !this.capabilityGuard.canSendRequest(method)) {
|
|
195
|
+
this.logger.warn(`Server does not support request method ${method}`);
|
|
196
|
+
}
|
|
197
|
+
// Check if already cancelled before doing anything
|
|
198
|
+
if (token?.isCancellationRequested) {
|
|
199
|
+
return Promise.reject(new Error('Request was cancelled'));
|
|
200
|
+
}
|
|
201
|
+
const { id, promise } = this.pendingRequests.create(this.options.requestTimeout, method);
|
|
202
|
+
const request = {
|
|
203
|
+
jsonrpc: '2.0',
|
|
204
|
+
id,
|
|
205
|
+
method,
|
|
206
|
+
params
|
|
207
|
+
};
|
|
208
|
+
this.logger.debug(`Sending request ${method}`, { id, params });
|
|
209
|
+
let cancellationDisposable;
|
|
210
|
+
if (token) {
|
|
211
|
+
cancellationDisposable = token.onCancellationRequested(() => {
|
|
212
|
+
this.sendNotification('$/cancelRequest', { id }).catch((err) => {
|
|
213
|
+
this.logger.error('Failed to send cancellation', err);
|
|
214
|
+
});
|
|
215
|
+
// Use process.nextTick to defer rejection slightly
|
|
216
|
+
// This gives the caller a chance to attach rejection handlers before the rejection occurs
|
|
217
|
+
process.nextTick(() => {
|
|
218
|
+
this.pendingRequests.reject(id, new Error('Request was cancelled'));
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
void promise.then(() => {
|
|
223
|
+
cancellationDisposable?.dispose();
|
|
224
|
+
}, () => {
|
|
225
|
+
cancellationDisposable?.dispose();
|
|
226
|
+
});
|
|
227
|
+
this.sendWithMiddleware({
|
|
228
|
+
direction: 'clientToServer',
|
|
229
|
+
messageType: 'request',
|
|
230
|
+
method,
|
|
231
|
+
message: request,
|
|
232
|
+
onSend: async () => {
|
|
233
|
+
this.healthTracker.markMessageSent();
|
|
234
|
+
await this.transport.send(request);
|
|
235
|
+
},
|
|
236
|
+
onShortCircuit: (result) => {
|
|
237
|
+
this.resolveShortCircuitRequest(id, result);
|
|
238
|
+
},
|
|
239
|
+
onError: (error) => {
|
|
240
|
+
this.pendingRequests.reject(id, error);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
return promise;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Send a notification to the server
|
|
247
|
+
*/
|
|
248
|
+
async sendNotification(method, params) {
|
|
249
|
+
if (!this.connected || !this.transport) {
|
|
250
|
+
throw new Error('Client is not connected');
|
|
251
|
+
}
|
|
252
|
+
// Validate notification against server capabilities
|
|
253
|
+
if (this.capabilityGuard && !this.capabilityGuard.canSendNotification(method)) {
|
|
254
|
+
this.logger.warn(`Server does not support notification method ${method}`);
|
|
255
|
+
}
|
|
256
|
+
const notification = {
|
|
257
|
+
jsonrpc: '2.0',
|
|
258
|
+
method,
|
|
259
|
+
params
|
|
260
|
+
};
|
|
261
|
+
this.logger.debug(`Sending notification ${method}`, { params });
|
|
262
|
+
await this.sendWithMiddleware({
|
|
263
|
+
direction: 'clientToServer',
|
|
264
|
+
messageType: 'notification',
|
|
265
|
+
method,
|
|
266
|
+
message: notification,
|
|
267
|
+
onSend: async () => {
|
|
268
|
+
this.healthTracker.markMessageSent();
|
|
269
|
+
await this.transport.send(notification);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Send a cancellable request
|
|
275
|
+
*/
|
|
276
|
+
sendCancellableRequest(method, params) {
|
|
277
|
+
const cancelSource = new CancellationTokenSource();
|
|
278
|
+
const promise = this.sendRequest(method, params, cancelSource.token);
|
|
279
|
+
return {
|
|
280
|
+
promise,
|
|
281
|
+
cancel: () => cancelSource.cancel()
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Register handler for server-to-client requests
|
|
286
|
+
*/
|
|
287
|
+
onRequest(method, handler) {
|
|
288
|
+
if (this.clientCapabilityGuard && !this.clientCapabilityGuard.canRegisterHandler(method)) {
|
|
289
|
+
this.logger.warn(`Client capability not declared for handler ${method}`);
|
|
290
|
+
}
|
|
291
|
+
return this.requestHandlers.register(method, handler);
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Register handler for server notifications
|
|
295
|
+
*/
|
|
296
|
+
onNotification(method, handler) {
|
|
297
|
+
if (this.clientCapabilityGuard && !this.clientCapabilityGuard.canRegisterHandler(method)) {
|
|
298
|
+
this.logger.warn(`Client capability not declared for handler ${method}`);
|
|
299
|
+
}
|
|
300
|
+
return this.notificationHandlers.register(method, handler);
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Wait for the next matching server notification.
|
|
304
|
+
*/
|
|
305
|
+
waitForNotification(method, options) {
|
|
306
|
+
return new Promise((resolve, reject) => {
|
|
307
|
+
let timeoutHandle;
|
|
308
|
+
const waiter = {
|
|
309
|
+
method,
|
|
310
|
+
resolve: (params) => {
|
|
311
|
+
resolve(params);
|
|
312
|
+
},
|
|
313
|
+
reject: (error) => {
|
|
314
|
+
reject(error);
|
|
315
|
+
},
|
|
316
|
+
cleanup: () => {
|
|
317
|
+
if (timeoutHandle) {
|
|
318
|
+
clearTimeout(timeoutHandle);
|
|
319
|
+
timeoutHandle = undefined;
|
|
320
|
+
}
|
|
321
|
+
this.notificationWaiters.delete(waiter);
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
if (options.filter) {
|
|
325
|
+
waiter.filter = options.filter;
|
|
326
|
+
}
|
|
327
|
+
this.notificationWaiters.add(waiter);
|
|
328
|
+
timeoutHandle = setTimeout(() => {
|
|
329
|
+
waiter.cleanup();
|
|
330
|
+
reject(new Error(`Timed out waiting for notification '${method}' after ${options.timeout}ms`));
|
|
331
|
+
}, options.timeout);
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Subscribe to connection events
|
|
336
|
+
*/
|
|
337
|
+
onConnected(handler) {
|
|
338
|
+
return this.events.on('connected', handler);
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Subscribe to disconnection events
|
|
342
|
+
*/
|
|
343
|
+
onDisconnected(handler) {
|
|
344
|
+
return this.events.on('disconnected', handler);
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Subscribe to error events
|
|
348
|
+
*/
|
|
349
|
+
onError(handler) {
|
|
350
|
+
return this.events.on('error', handler);
|
|
351
|
+
}
|
|
352
|
+
onConnectionStateChange(handler) {
|
|
353
|
+
const dispose = this.healthTracker.onStateChange(handler);
|
|
354
|
+
return { dispose };
|
|
355
|
+
}
|
|
356
|
+
onConnectionHealthChange(handler) {
|
|
357
|
+
const dispose = this.healthTracker.onHealthChange(handler);
|
|
358
|
+
return { dispose };
|
|
359
|
+
}
|
|
360
|
+
getConnectionHealth() {
|
|
361
|
+
return this.healthTracker.getHealth();
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Get server capabilities
|
|
365
|
+
*/
|
|
366
|
+
getServerCapabilities() {
|
|
367
|
+
return this.serverCapabilities;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Get server info
|
|
371
|
+
*/
|
|
372
|
+
getServerInfo() {
|
|
373
|
+
return this.serverInfo;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Set client capabilities
|
|
377
|
+
*/
|
|
378
|
+
setCapabilities(capabilities) {
|
|
379
|
+
this.capabilities = capabilities;
|
|
380
|
+
this.clientCapabilityGuard = new ClientCapabilityGuard(capabilities, this.logger, this.options.strictCapabilities);
|
|
381
|
+
// Note: Client capabilities are sent during initialize, so this only affects
|
|
382
|
+
// the local reference. To update server-side, would need client/registerCapability request.
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Get client capabilities
|
|
386
|
+
*/
|
|
387
|
+
getClientCapabilities() {
|
|
388
|
+
return this.capabilities;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Register a single client capability, returning a new typed reference via intersection.
|
|
392
|
+
* The returned reference is the same instance, with a narrowed type that includes
|
|
393
|
+
* the newly registered capability.
|
|
394
|
+
*
|
|
395
|
+
* Note: This updates the local capability reference. To notify the server of capability
|
|
396
|
+
* changes, the LSP 3.17 client/registerCapability request should be used separately.
|
|
397
|
+
*
|
|
398
|
+
* @template K - The capability key to register
|
|
399
|
+
* @param key - The client capability key
|
|
400
|
+
* @param value - The capability value
|
|
401
|
+
* @returns The same client instance with an expanded capability type
|
|
402
|
+
*
|
|
403
|
+
* @example
|
|
404
|
+
* const client = new LSPClient();
|
|
405
|
+
* const withWorkspace = client.registerCapability('workspace', { workspaceFolders: true });
|
|
406
|
+
* // withWorkspace is typed as LSPClient<ClientCaps & Pick<ClientCapabilities, 'workspace'>>
|
|
407
|
+
*/
|
|
408
|
+
registerCapability(key, value) {
|
|
409
|
+
const current = this.capabilities ?? {};
|
|
410
|
+
const updated = { ...current, [key]: value };
|
|
411
|
+
this.setCapabilities(updated);
|
|
412
|
+
return this;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Zero-cost type narrowing for server capabilities.
|
|
416
|
+
* Returns `this` cast to include capability-aware methods for the given ServerCaps.
|
|
417
|
+
*
|
|
418
|
+
* @template S - The expected server capabilities shape
|
|
419
|
+
* @returns The same client instance, typed with capability-aware methods
|
|
420
|
+
*
|
|
421
|
+
* @example
|
|
422
|
+
* const client = new LSPClient<MyClientCaps>();
|
|
423
|
+
* await client.connect(transport);
|
|
424
|
+
* const typed = client.expect<{ hoverProvider: true }>();
|
|
425
|
+
* const result = await typed.textDocument.hover(params);
|
|
426
|
+
*/
|
|
427
|
+
expect() {
|
|
428
|
+
return this;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Handle incoming message from transport
|
|
432
|
+
*/
|
|
433
|
+
handleMessage(message) {
|
|
434
|
+
this.healthTracker.markMessageReceived();
|
|
435
|
+
this.heartbeatMonitor?.markPong();
|
|
436
|
+
if (this.heartbeatMonitor) {
|
|
437
|
+
this.healthTracker.setHeartbeat(this.heartbeatMonitor.getStatus());
|
|
438
|
+
}
|
|
439
|
+
// Response message
|
|
440
|
+
if ('id' in message && ('result' in message || 'error' in message)) {
|
|
441
|
+
void this.handleResponse(message).catch((error) => {
|
|
442
|
+
this.logger.error('Error handling response', error);
|
|
443
|
+
});
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
// Request message
|
|
447
|
+
if ('id' in message && 'method' in message) {
|
|
448
|
+
// Fire and forget with error handling
|
|
449
|
+
void this.handleRequest(message).catch((error) => {
|
|
450
|
+
this.logger.error('Error handling server request', error);
|
|
451
|
+
});
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
// Notification message
|
|
455
|
+
if ('method' in message && !('id' in message)) {
|
|
456
|
+
this.handleNotification(message);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
this.logger.warn('Received unknown message type', message);
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Handle response message
|
|
463
|
+
*/
|
|
464
|
+
async handleResponse(response) {
|
|
465
|
+
const { id } = response;
|
|
466
|
+
const method = this.pendingRequests.getMetadata(String(id));
|
|
467
|
+
if (!method) {
|
|
468
|
+
this.logger.warn(`Received response for unknown request ${id}`);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
await this.sendWithMiddleware({
|
|
472
|
+
direction: 'serverToClient',
|
|
473
|
+
messageType: 'error' in response ? 'error' : 'response',
|
|
474
|
+
method,
|
|
475
|
+
message: response,
|
|
476
|
+
onSend: async () => {
|
|
477
|
+
if ('error' in response && response.error) {
|
|
478
|
+
this.logger.error(`Request ${method} failed`, response.error);
|
|
479
|
+
this.pendingRequests.reject(String(id), new Error(`${response.error.message} (code: ${response.error.code})`));
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if ('result' in response) {
|
|
483
|
+
this.logger.debug(`Request ${method} succeeded`, { id });
|
|
484
|
+
this.pendingRequests.resolve(String(id), response.result);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
this.pendingRequests.reject(String(id), new Error('Invalid response message'));
|
|
488
|
+
},
|
|
489
|
+
onShortCircuit: (result) => {
|
|
490
|
+
this.resolveShortCircuitRequest(String(id), result);
|
|
491
|
+
},
|
|
492
|
+
onError: (error) => {
|
|
493
|
+
this.pendingRequests.reject(String(id), error);
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
resolveShortCircuitRequest(id, result) {
|
|
498
|
+
if (result.error) {
|
|
499
|
+
this.pendingRequests.reject(id, new Error(`${result.error.error.message} (code: ${result.error.error.code})`));
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (result.response && 'error' in result.response && result.response.error) {
|
|
503
|
+
this.pendingRequests.reject(id, new Error(`${result.response.error.message} (code: ${result.response.error.code})`));
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
if (result.response && 'result' in result.response) {
|
|
507
|
+
this.pendingRequests.resolve(id, result.response.result);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
this.pendingRequests.reject(id, new Error('Middleware short-circuit missing response payload'));
|
|
511
|
+
}
|
|
512
|
+
buildMiddlewareContext(direction, messageType, method, message) {
|
|
513
|
+
return {
|
|
514
|
+
direction,
|
|
515
|
+
messageType,
|
|
516
|
+
method,
|
|
517
|
+
message,
|
|
518
|
+
metadata: {},
|
|
519
|
+
transport: this.transport?.constructor.name ?? 'unknown'
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
async sendWithMiddleware(options) {
|
|
523
|
+
const context = this.buildMiddlewareContext(options.direction, options.messageType, options.method, options.message);
|
|
524
|
+
try {
|
|
525
|
+
const result = await executeMiddlewarePipeline(this.middlewareRegistrations, context, async () => {
|
|
526
|
+
await options.onSend();
|
|
527
|
+
return undefined;
|
|
528
|
+
});
|
|
529
|
+
if (result?.shortCircuit && options.onShortCircuit) {
|
|
530
|
+
options.onShortCircuit(result);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
catch (error) {
|
|
534
|
+
const normalized = error instanceof Error ? error : new Error(String(error));
|
|
535
|
+
this.handleError(normalized);
|
|
536
|
+
if (options.onError) {
|
|
537
|
+
options.onError(normalized);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
throw normalized;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Handle request message from server
|
|
545
|
+
*/
|
|
546
|
+
async handleRequest(request) {
|
|
547
|
+
const { id, method, params } = request;
|
|
548
|
+
const handler = this.requestHandlers.get(method);
|
|
549
|
+
if (!handler) {
|
|
550
|
+
this.logger.warn(`No handler for server request ${method}`);
|
|
551
|
+
// Send method not found error
|
|
552
|
+
const response = {
|
|
553
|
+
jsonrpc: '2.0',
|
|
554
|
+
id,
|
|
555
|
+
error: {
|
|
556
|
+
code: -32601,
|
|
557
|
+
message: `Method not found: ${method}`
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
try {
|
|
561
|
+
if (this.transport) {
|
|
562
|
+
await this.transport.send(response);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
catch (error) {
|
|
566
|
+
this.logger.error('Failed to send error response', error);
|
|
567
|
+
}
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
try {
|
|
571
|
+
const result = await handler(params);
|
|
572
|
+
const response = {
|
|
573
|
+
jsonrpc: '2.0',
|
|
574
|
+
id,
|
|
575
|
+
result
|
|
576
|
+
};
|
|
577
|
+
try {
|
|
578
|
+
if (this.transport) {
|
|
579
|
+
await this.transport.send(response);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
catch (error) {
|
|
583
|
+
this.logger.error('Failed to send success response', error);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
catch (error) {
|
|
587
|
+
this.logger.error(`Handler for ${method} threw error`, error);
|
|
588
|
+
const response = {
|
|
589
|
+
jsonrpc: '2.0',
|
|
590
|
+
id,
|
|
591
|
+
error: {
|
|
592
|
+
code: -32603,
|
|
593
|
+
message: error instanceof Error ? error.message : 'Internal error'
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
try {
|
|
597
|
+
if (this.transport) {
|
|
598
|
+
await this.transport.send(response);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
catch (sendError) {
|
|
602
|
+
this.logger.error('Failed to send error response', sendError);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Handle notification message from server
|
|
608
|
+
*/
|
|
609
|
+
handleNotification(notification) {
|
|
610
|
+
const { method, params } = notification;
|
|
611
|
+
for (const waiter of Array.from(this.notificationWaiters)) {
|
|
612
|
+
if (waiter.method !== method) {
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
try {
|
|
616
|
+
if (waiter.filter && !waiter.filter(params)) {
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
waiter.cleanup();
|
|
620
|
+
waiter.resolve(params);
|
|
621
|
+
}
|
|
622
|
+
catch (error) {
|
|
623
|
+
waiter.cleanup();
|
|
624
|
+
const normalized = error instanceof Error ? error : new Error(String(error));
|
|
625
|
+
waiter.reject(normalized);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
const handler = this.notificationHandlers.get(method);
|
|
629
|
+
if (!handler) {
|
|
630
|
+
this.logger.debug(`No handler for server notification ${method}`);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
try {
|
|
634
|
+
handler(params);
|
|
635
|
+
}
|
|
636
|
+
catch (error) {
|
|
637
|
+
this.logger.error(`Handler for ${method} threw error`, error);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Handle transport error
|
|
642
|
+
*/
|
|
643
|
+
handleError(error) {
|
|
644
|
+
this.logger.error('Transport error', error);
|
|
645
|
+
this.events.emit('error', error);
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Handle connection close
|
|
649
|
+
*/
|
|
650
|
+
handleClose() {
|
|
651
|
+
if (!this.connected) {
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
this.logger.info('Connection closed');
|
|
655
|
+
this.connected = false;
|
|
656
|
+
this.initialized = false;
|
|
657
|
+
delete this.transport;
|
|
658
|
+
this.heartbeatMonitor?.stop();
|
|
659
|
+
this.heartbeatMonitor = undefined;
|
|
660
|
+
this.healthTracker.setState(ConnectionState.Disconnected);
|
|
661
|
+
this.transportAttachment.detach();
|
|
662
|
+
this.pendingRequests.clear(new Error('Connection closed'));
|
|
663
|
+
for (const waiter of Array.from(this.notificationWaiters)) {
|
|
664
|
+
waiter.cleanup();
|
|
665
|
+
waiter.reject(new Error('Connection closed before notification was received'));
|
|
666
|
+
}
|
|
667
|
+
this.events.emit('disconnected');
|
|
668
|
+
}
|
|
669
|
+
startHeartbeatIfConfigured(config) {
|
|
670
|
+
if (!config || !config.enabled) {
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
this.heartbeatMonitor?.stop();
|
|
674
|
+
this.heartbeatMonitor = new HeartbeatMonitor({
|
|
675
|
+
config,
|
|
676
|
+
onPing: () => {
|
|
677
|
+
this.healthTracker.setHeartbeat(this.heartbeatMonitor.getStatus());
|
|
678
|
+
},
|
|
679
|
+
onUnresponsive: () => {
|
|
680
|
+
this.healthTracker.setHeartbeat(this.heartbeatMonitor.getStatus());
|
|
681
|
+
},
|
|
682
|
+
onResponsive: () => {
|
|
683
|
+
this.healthTracker.setHeartbeat(this.heartbeatMonitor.getStatus());
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
this.healthTracker.setHeartbeat(this.heartbeatMonitor.getStatus());
|
|
687
|
+
this.heartbeatMonitor.start();
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
// Generic constructor that preserves type parameters
|
|
691
|
+
export const LSPClient = BaseLSPClient;
|
|
692
|
+
//# sourceMappingURL=client.js.map
|