• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

streetsidesoftware / cspell / 21283815940

23 Jan 2026 10:59AM UTC coverage: 92.838% (+0.01%) from 92.825%
21283815940

Pull #8423

github

web-flow
Merge 74416f189 into 405411fa8
Pull Request #8423: fix: Add NotifyEmitter

8874 of 10620 branches covered (83.56%)

62 of 64 new or added lines in 3 files covered. (96.88%)

4 existing lines in 2 files now uncovered.

17734 of 19102 relevant lines covered (92.84%)

31510.24 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.41
/packages/cspell-lib/src/rpc/client.ts
1
import { AbortRPCRequestError, CanceledRPCRequestError, TimeoutRPCRequestError } from './errors.js';
2
import { Future } from './Future.js';
3
import type { MessagePortLike } from './messagePort.js';
4
import type { RCPBaseRequest, RequestID, RPCPendingClientRequest, RPCResponse } from './models.js';
5
import {
6
    createRPCCancelRequest,
7
    createRPCMethodRequest,
8
    createRPCOkRequest,
9
    createRPCReadyRequest,
10
    isBaseResponse,
11
    isRPCCanceledResponse,
12
    isRPCErrorResponse,
13
    isRPCReadyResponse,
14
    isRPCResponse,
15
} from './modelsHelpers.js';
16
import type { RPCProtocol, RPCProtocolMethodNames } from './protocol.js';
17

18
interface PendingRequest {
19
    readonly id: RequestID;
20
    readonly request: RCPBaseRequest;
21
    readonly response: Promise<RPCResponse>;
22
    readonly isResolved: boolean;
23
    readonly isCanceled: boolean;
24
    /** calling abort will cancel the request if it has not already been resolved. */
25
    abort: AbortController['abort'];
26
    handleResponse: (res: RPCResponse) => void;
27
    /**
28
     * Cancels the request by telling the server to cancel the request and waiting on the response.
29
     */
30
    cancel: () => Promise<boolean>;
31
}
32

33
export interface RPCClientOptions {
34
    /**
35
     * A function to generate random UUIDs.
36
     * @default undefined
37
     */
38
    randomUUID?: () => string;
39
    /**
40
     * If true, the client will close the port when disposed.
41
     * @default false
42
     */
43
    closePortOnDispose?: boolean;
44
    /**
45
     * Set the default timeout in milliseconds for requests.
46
     */
47
    timeoutMs?: number;
48
}
49

50
export interface RPCClientConfiguration extends RPCClientOptions {
51
    /**
52
     * The message port to use for communication.
53
     */
54
    port: MessagePortLike;
55
}
56

57
export interface RequestOptions {
58
    /**
59
     * An AbortSignal to abort the request.
60
     */
61
    signal?: AbortSignal | undefined;
62
    /**
63
     * Timeout in milliseconds to wait before aborting the request.
64
     */
65
    timeoutMs?: number | undefined;
66
}
67

68
const DefaultOkOptions: RequestOptions = {
3✔
69
    timeoutMs: 200,
70
};
71

72
/**
73
 * The RPC Client.
74
 */
75
class RPCClientImpl<
76
    T,
77
    P extends RPCProtocol<T> = RPCProtocol<T>,
78
    MethodNames extends RPCProtocolMethodNames<P> = RPCProtocolMethodNames<P>,
79
> {
80
    #port: MessagePortLike;
81
    #count: number = 0;
20✔
82
    #options: RPCClientOptions;
83

84
    #pendingRequests = new Map<RequestID, PendingRequest>();
20✔
85
    #pendingRequestsByPromise = new WeakMap<Promise<unknown>, RequestID>();
20✔
86

87
    #defaultTimeoutMs: number | undefined;
88

89
    #isReady: boolean;
90
    #ready: Future<boolean>;
91

92
    #onMessage: (msg: unknown) => void;
93

94
    /**
95
     * Create an RPC Client.
96
     * @param config - The client configuration.
97
     */
98
    constructor(config: RPCClientConfiguration) {
99
        const port = config.port;
20✔
100
        this.#port = port;
20✔
101
        this.#options = config;
20✔
102
        this.#defaultTimeoutMs = config.timeoutMs;
20✔
103
        this.#isReady = false;
20✔
104
        this.#ready = new Future<boolean>();
20✔
105

106
        this.#onMessage = (msg: unknown) => this.#processMessageFromServer(msg);
50✔
107

108
        port.addListener('message', this.#onMessage);
20✔
109
        port.start?.();
20✔
110
    }
111

112
    /**
113
     * Make a request to the RPC server.
114
     *
115
     * It is unlikely you need to use this method directly. Consider using `call` or `getApi` instead.
116
     *
117
     * @param method - The method name.
118
     * @param params - The method parameters.
119
     * @param options - Request options including abort signal and timeout.
120
     * @returns The pending client request.
121
     */
122
    request<M extends MethodNames>(
123
        method: M,
124
        params: Parameters<P[M]>,
125
        options?: RequestOptions,
126
    ): RPCPendingClientRequest<M, ReturnType<P[M]>> {
127
        // Register the request.
128

129
        const id = this.#calcId(method);
31✔
130
        const request = createRPCMethodRequest(id, method, params);
31✔
131

132
        const pendingRequest = this.#sendRequest(request, options);
31✔
133

134
        const response = pendingRequest.response.then(handleResponse) as ReturnType<P[M]>;
31✔
135

136
        // Record the promise to request ID mapping.
137
        this.#pendingRequestsByPromise.set(response, id);
31✔
138

139
        const clientRequest: RPCPendingClientRequest<M, ReturnType<P[M]>> = {
31✔
140
            id,
141
            method,
142
            response,
143
            abort: pendingRequest.abort,
144
            cancel: pendingRequest.cancel,
145
            get isResolved() {
146
                return pendingRequest.isResolved;
5✔
147
            },
148
            get isCanceled() {
149
                return pendingRequest.isCanceled;
5✔
150
            },
151
        };
152

153
        return clientRequest;
31✔
154

155
        function handleResponse(res: RPCResponse): ReturnType<P[M]> {
156
            if (isRPCErrorResponse(res)) {
24✔
157
                throw res.error;
2✔
158
            }
159
            if (isRPCCanceledResponse(res)) {
22✔
160
                throw new CanceledRPCRequestError(`Request ${id} was canceled`);
1✔
161
            }
162
            if (isRPCResponse(res)) {
21!
163
                return res.result as ReturnType<P[M]>;
21✔
164
            }
UNCOV
165
            throw new Error(`Malformed response for request ${id}`); // Should not happen.
×
166
        }
167
    }
168

169
    #sendRequest(request: RCPBaseRequest, options: RequestOptions = {}): PendingRequest {
34✔
170
        // Register the request.
171

172
        const id = request.id;
39✔
173
        const requestType = request.type;
39✔
174
        let isResolved: boolean = false;
39✔
175
        let isCanceled: boolean = false;
39✔
176

177
        const timeoutMs = options.timeoutMs ?? this.#defaultTimeoutMs;
39✔
178

179
        const timeoutSignal = timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined;
39✔
180

181
        const future = new Future<RPCResponse>();
39✔
182

183
        options.signal?.addEventListener('abort', abort);
39✔
184
        timeoutSignal?.addEventListener('abort', timeoutHandler);
39✔
185

186
        const response = future.promise;
39✔
187

188
        const cancelRequest = () => this.#port.postMessage(createRPCCancelRequest(id));
39✔
189

190
        const cancel = async (): Promise<boolean> => {
39✔
191
            if (isResolved || isCanceled) return isCanceled;
2✔
192
            cancelRequest();
1✔
193
            await response.catch(() => {});
1✔
194
            return isCanceled;
1✔
195
        };
196

197
        const pendingRequest: PendingRequest = {
39✔
198
            id,
199
            request,
200
            response,
201
            handleResponse,
202
            abort,
203
            cancel,
204
            get isResolved() {
205
                return isResolved;
8✔
206
            },
207
            get isCanceled() {
208
                return isCanceled;
10✔
209
            },
210
        };
211

212
        this.#pendingRequests.set(id, pendingRequest);
39✔
213
        this.#pendingRequestsByPromise.set(response, id);
39✔
214

215
        const cleanup = () => {
39✔
216
            options.signal?.removeEventListener('abort', abort);
39✔
217
            timeoutSignal?.removeEventListener('abort', timeoutHandler);
39✔
218
            this.#cleanupPendingRequest(pendingRequest);
39✔
219
        };
220

221
        this.#port.postMessage(request);
39✔
222

223
        return pendingRequest;
39✔
224

225
        function timeoutHandler(): void {
226
            abort(new TimeoutRPCRequestError(`Request ${requestType} ${id} timed out after ${timeoutMs} ms`));
4✔
227
        }
228

229
        function abort(reason?: unknown): void {
230
            if (isResolved || isCanceled) return;
9✔
231
            isCanceled = true;
8✔
232
            cancelRequest();
8✔
233
            reason = reason instanceof Event ? undefined : reason;
8!
234
            reason ??= `Request ${id} aborted`;
8✔
235
            reason = typeof reason === 'string' ? new AbortRPCRequestError(reason) : reason;
8✔
236
            cleanup();
8✔
237
            future.reject(reason);
8✔
238
        }
239

240
        function handleResponse(res: RPCResponse): void {
241
            // Do not process anything if already resolved or canceled.
242
            if (isResolved || isCanceled) return;
31!
243
            isResolved = true;
31✔
244
            if (isRPCCanceledResponse(res)) {
31✔
245
                isCanceled = true;
1✔
246
            }
247
            cleanup();
31✔
248
            future.resolve(res);
31✔
249
        }
250
    }
251

252
    /**
253
     * Check the health of the RPC server.
254
     * @param options - used to set timeout and abort signal.
255
     * @returns resolves to true if the server is OK, false on timeout.
256
     */
257
    async isOK(options: RequestOptions = DefaultOkOptions): Promise<boolean> {
2✔
258
        try {
2✔
259
            const req = this.#sendRequest(createRPCOkRequest(this.#calcId('isOK')), options);
2✔
260
            const res = await req.response;
2✔
261
            return isBaseResponse(res) && res.type === 'ok' && res.code === 200;
1✔
262
        } catch {
263
            return false;
1✔
264
        }
265
    }
266

267
    /**
268
     * The current known ready state of the RPC server.
269
     * - `true` - The server is ready.
270
     * - `false` - The server is not ready.
271
     */
272
    get isReady(): boolean {
273
        return this.#isReady;
2✔
274
    }
275

276
    /**
277
     * Check if the RPC server is ready. If already ready, returns true immediately.
278
     * If not ready, sends a 'ready' request to the server.
279
     * @param options - used to set timeout and abort signal.
280
     * @returns resolves to true when the server is ready, rejects if the request times out or fails.
281
     */
282
    async ready(options?: RequestOptions): Promise<boolean> {
283
        if (this.#isReady) return true;
6!
284
        // We send the request, but we do not care about the result other than it succeeded.
285
        await this.#sendRequest(createRPCReadyRequest(this.#calcId('ready')), options).response;
6✔
286
        return this.#isReady; // We are returning the current state.
6✔
287
    }
288

289
    /**
290
     * Call a method on the RPC server.
291
     * @param method - The method name.
292
     * @param params - The method parameters.
293
     * @param options - Call options including abort signal.
294
     * @returns A Promise with the method result.
295
     */
296
    call<M extends MethodNames>(method: M, params: Parameters<P[M]>, options?: RequestOptions): ReturnType<P[M]> {
297
        const req = this.request(method, params, options);
28✔
298
        return req.response;
28✔
299
    }
300

301
    /**
302
     * Get the API for the given method names.
303
     *
304
     * This is useful passing the API to other parts of the code that do not need to know about the RPCClient.
305
     *
306
     * @param methods - The method names to include in the API.
307
     * @returns A partial API with the requested methods.
308
     */
309
    getApi<M extends MethodNames>(methods: M[]): Pick<P, M> {
310
        const apiEntries: [M, P[M]][] = methods.map(
12✔
311
            (method) => [method, ((...params: Parameters<P[M]>) => this.call(method, params)) as P[M]] as const,
25✔
312
        );
313
        return Object.fromEntries(apiEntries) as Pick<P, M>;
12✔
314
    }
315

316
    /**
317
     * Get info about a pending request by its RequestID.
318
     * @param id - The RequestID of the pending request.
319
     * @returns The found pending request or undefined if not found.
320
     */
321
    getPendingRequestById(id: RequestID): PendingRequest | undefined {
322
        return this.#pendingRequests.get(id);
10✔
323
    }
324

325
    /**
326
     * Get info about a pending request by the promise returned using `call` or an api method.
327
     * @param id - The RequestID of the pending request.
328
     * @returns The found pending request or undefined if not found.
329
     */
330
    getPendingRequestByPromise(promise: Promise<unknown>): PendingRequest | undefined {
331
        const requestId = this.#pendingRequestsByPromise.get(promise);
8✔
332
        if (!requestId) return undefined;
8✔
333
        return this.getPendingRequestById(requestId);
7✔
334
    }
335

336
    /**
337
     * Get the number of pending requests.
338
     */
339
    get length(): number {
UNCOV
340
        return this.#pendingRequests.size;
×
341
    }
342

343
    #calcId(method: string): RequestID {
344
        const suffix = this.#options.randomUUID ? this.#options.randomUUID() : `${performance.now()}`;
39✔
345

346
        return `${method}-${++this.#count}-${suffix}`;
39✔
347
    }
348

349
    #cleanupPendingRequest(request: PendingRequest): void {
350
        this.#pendingRequests.delete(request.id);
39✔
351
        this.#pendingRequestsByPromise.delete(request.response);
39✔
352
    }
353

354
    #processMessageFromServer(msg: unknown): void {
355
        // Ignore messages that are not RPC messages
356
        if (!isBaseResponse(msg)) return;
50!
357
        this.#handleReadyResponse(msg);
50✔
358
        const pendingRequest = this.#pendingRequests.get(msg.id);
50✔
359
        if (!pendingRequest) return;
50✔
360
        pendingRequest.handleResponse(msg);
31✔
361
    }
362

363
    /**
364
     * Handle possible ready response messages.
365
     * @param msg - The message to handle.
366
     */
367
    #handleReadyResponse(msg: RPCResponse): void {
368
        if (!isRPCReadyResponse(msg)) return;
50✔
369
        if (this.#ready.isResolved) return;
20✔
370
        this.#isReady = msg.code === 200;
14✔
371
        this.#ready.resolve(this.#isReady);
14✔
372
    }
373

374
    /**
375
     * Abort a pending request by its promise.
376
     *
377
     * Note: the request promise will be rejected with an AbortRequestError.
378
     * @param promise - The promise returned by the request.
379
     * @param reason - The reason for aborting the request.
380
     * @returns True if the request was found and aborted, false otherwise.
381
     */
382
    abortPromise(promise: Promise<unknown>, reason: unknown): boolean {
383
        const pendingRequest = this.getPendingRequestByPromise(promise);
2✔
384
        if (!pendingRequest) return false;
2✔
385
        return this.abortRequest(pendingRequest.id, reason);
1✔
386
    }
387

388
    /**
389
     * Abort a pending request by its RequestId.
390
     *
391
     * Note: the request promise will be rejected with an AbortRequestError.
392
     * @param requestId - The RequestID of the request to abort.
393
     * @param reason - The reason for aborting the request.
394
     * @returns True if the request was found and aborted, false otherwise.
395
     */
396
    abortRequest(requestId: RequestID, reason?: unknown): boolean {
397
        const pendingRequest = this.getPendingRequestById(requestId);
1✔
398
        if (!pendingRequest) return false;
1!
399
        pendingRequest.abort(reason);
1✔
400
        return true;
1✔
401
    }
402

403
    /**
404
     * Abort all pending requests.
405
     *
406
     * Note: each request promise will be rejected with an AbortRequestError.
407
     *
408
     * @param reason - The reason for aborting the request.
409
     */
410
    abortAllRequests(reason?: unknown): void {
411
        for (const pendingRequest of this.#pendingRequests.values()) {
4✔
412
            try {
1✔
413
                pendingRequest.abort(reason);
1✔
414
            } catch {
415
                // ignore
416
            }
417
        }
418
    }
419

420
    /**
421
     * Cancel a pending request by its RequestID.
422
     *
423
     * Tries to cancel the request by sending a cancel request to the server and waiting for the response.
424
     * @param id - The RequestID of the request to cancel.
425
     * @returns resolves to true if the request was found and canceled, false otherwise.
426
     */
427
    async cancelRequest(id: RequestID): Promise<boolean> {
428
        const pendingRequest = this.getPendingRequestById(id);
2✔
429
        if (!pendingRequest) return false;
2✔
430
        return await pendingRequest.cancel();
1✔
431
    }
432

433
    /**
434
     * Cancel a pending request by its Promise.
435
     *
436
     * Tries to cancel the request by sending a cancel request to the server and waiting for the response.
437
     * @param id - The RequestID of the request to cancel.
438
     * @returns resolves to true if the request was found and canceled, false otherwise.
439
     */
440
    async cancelPromise(promise: Promise<unknown>): Promise<boolean> {
441
        const request = this.getPendingRequestByPromise(promise);
3✔
442
        if (!request) return false;
3✔
443
        return this.cancelRequest(request.id);
1✔
444
    }
445

446
    /**
447
     * Set the default timeout for requests. Requests can override this value.
448
     * @param timeoutMs - the timeout in milliseconds
449
     */
450
    setTimeout(timeoutMs: number | undefined): void {
451
        this.#defaultTimeoutMs = timeoutMs;
1✔
452
    }
453

454
    /**
455
     * Dispose of the RPC client, aborting all pending requests and closing the port if specified in options.
456
     */
457
    [Symbol.dispose](): void {
458
        this.abortAllRequests(new Error('RPC Client disposed'));
4✔
459
        this.#pendingRequests.clear();
4✔
460

461
        this.#port.removeListener('message', this.#onMessage);
4✔
462

463
        if (this.#options.closePortOnDispose) {
4✔
464
            this.#port.close?.();
1✔
465
        }
466
    }
467
}
468

469
/**
470
 * The RPC Client.
471
 */
472
export class RPCClient<T> extends RPCClientImpl<T> {
473
    /**
474
     * Create an RPC Client.
475
     * @param config - The client configuration.
476
     */
477
    constructor(config: RPCClientConfiguration) {
478
        super(config);
20✔
479
    }
480
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc