@theia/core
Version:
Theia is a cloud & desktop IDE framework implemented in TypeScript.
236 lines (205 loc) • 10.3 kB
text/typescript
// *****************************************************************************
// Copyright (C) 2021 Red Hat, Inc. and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
/* eslint-disable @typescript-eslint/no-explicit-any */
import { CancellationToken, CancellationTokenSource } from '../cancellation';
import { Disposable, DisposableCollection } from '../disposable';
import { Emitter, Event } from '../event';
import { Deferred } from '../promise-util';
import { Channel } from './channel';
import { MsgPackMessageDecoder, MsgPackMessageEncoder, RpcMessage, RpcMessageDecoder, RpcMessageEncoder, RpcMessageType } from './rpc-message-encoder';
/**
* Handles request messages received by the {@link RPCProtocol}.
*/
export type RequestHandler = (method: string, args: any[]) => Promise<any>;
/**
* Initialization options for a {@link RpcProtocol}.
*/
export interface RpcProtocolOptions {
/**
* The message encoder that should be used. If `undefined` the default {@link RpcMessageEncoder} will be used.
*/
encoder?: RpcMessageEncoder,
/**
* The message decoder that should be used. If `undefined` the default {@link RpcMessageDecoder} will be used.
*/
decoder?: RpcMessageDecoder,
/**
* The runtime mode determines whether the RPC protocol is bi-directional (default) or acts as a client or server only.
*/
mode?: 'default' | 'clientOnly' | 'serverOnly'
}
/**
* Establish a RPC protocol on top of a given channel. By default the rpc protocol is bi-directional, meaning it is possible to send
* requests and notifications to the remote side (i.e. acts as client) as well as receiving requests and notifications from the remote side (i.e. acts as a server).
* Clients can get a promise for a remote request result that will be either resolved or
* rejected depending on the success of the request. Keeps track of outstanding requests and matches replies to the appropriate request
* Currently, there is no timeout handling for long running requests implemented.
* The bi-directional mode can be reconfigured using the {@link RpcProtocolOptions} to construct an RPC protocol instance that acts only as client or server instead.
*/
export class RpcProtocol {
static readonly CANCELLATION_TOKEN_KEY = 'add.cancellation.token';
protected readonly pendingRequests: Map<number, Deferred<any>> = new Map();
protected nextMessageId: number = 0;
protected readonly encoder: RpcMessageEncoder;
protected readonly decoder: RpcMessageDecoder;
protected readonly mode: 'default' | 'clientOnly' | 'serverOnly';
protected readonly onNotificationEmitter: Emitter<{ method: string; args: any[]; }> = new Emitter();
protected readonly cancellationTokenSources = new Map<number, CancellationTokenSource>();
get onNotification(): Event<{ method: string; args: any[]; }> {
return this.onNotificationEmitter.event;
}
protected toDispose = new DisposableCollection();
constructor(public readonly channel: Channel, public readonly requestHandler: RequestHandler | undefined, options: RpcProtocolOptions = {}) {
this.encoder = options.encoder ?? new MsgPackMessageEncoder();
this.decoder = options.decoder ?? new MsgPackMessageDecoder();
this.toDispose.push(this.onNotificationEmitter);
channel.onClose(event => {
this.pendingRequests.forEach(pending => pending.reject(new Error(event.reason)));
this.pendingRequests.clear();
this.toDispose.dispose();
});
this.toDispose.push(channel.onMessage(readBuffer => this.handleMessage(this.decoder.parse(readBuffer()))));
this.mode = options.mode ?? 'default';
if (this.mode !== 'clientOnly' && requestHandler === undefined) {
console.error('RPCProtocol was initialized without a request handler but was not set to clientOnly mode.');
}
}
handleMessage(message: RpcMessage): void {
if (this.mode !== 'clientOnly') {
switch (message.type) {
case RpcMessageType.Cancel: {
this.handleCancel(message.id);
return;
}
case RpcMessageType.Request: {
this.handleRequest(message.id, message.method, message.args);
return;
}
case RpcMessageType.Notification: {
this.handleNotify(message.method, message.args, message.id);
return;
}
}
}
if (this.mode !== 'serverOnly') {
switch (message.type) {
case RpcMessageType.Reply: {
this.handleReply(message.id, message.res);
return;
}
case RpcMessageType.ReplyErr: {
this.handleReplyErr(message.id, message.err);
return;
}
}
}
// If the message was not handled until here, it is incompatible with the mode.
console.warn(`Received message incompatible with this RPCProtocol's mode '${this.mode}'. Type: ${message.type}. ID: ${message.id}.`);
}
protected handleReply(id: number, value: any): void {
const replyHandler = this.pendingRequests.get(id);
if (replyHandler) {
this.pendingRequests.delete(id);
replyHandler.resolve(value);
} else {
throw new Error(`No reply handler for reply with id: ${id}`);
}
}
protected handleReplyErr(id: number, error: any): void {
const replyHandler = this.pendingRequests.get(id);
if (replyHandler) {
this.pendingRequests.delete(id);
replyHandler.reject(error);
} else {
throw new Error(`No reply handler for error reply with id: ${id}`);
}
}
sendRequest<T>(method: string, args: any[]): Promise<T> {
// The last element of the request args might be a cancellation token. As these tokens are not serializable we have to remove it from the
// args array and the `CANCELLATION_TOKEN_KEY` string instead.
const cancellationToken: CancellationToken | undefined = args.length && CancellationToken.is(args[args.length - 1]) ? args.pop() : undefined;
const id = this.nextMessageId++;
const reply = new Deferred<T>();
if (cancellationToken) {
args.push(RpcProtocol.CANCELLATION_TOKEN_KEY);
}
this.pendingRequests.set(id, reply);
const output = this.channel.getWriteBuffer();
this.encoder.request(output, id, method, args);
output.commit();
if (cancellationToken?.isCancellationRequested) {
this.sendCancel(id);
} else {
cancellationToken?.onCancellationRequested(() => this.sendCancel(id));
}
return reply.promise;
}
sendNotification(method: string, args: any[]): void {
// If the notification supports a CancellationToken, it needs to be treated like a request
// because cancellation does not work with the simplified "fire and forget" approach of simple notifications.
if (args.length && CancellationToken.is(args[args.length - 1])) {
this.sendRequest(method, args);
return;
}
const output = this.channel.getWriteBuffer();
this.encoder.notification(output, method, args, this.nextMessageId++);
output.commit();
}
sendCancel(requestId: number): void {
const output = this.channel.getWriteBuffer();
this.encoder.cancel(output, requestId);
output.commit();
}
protected handleCancel(id: number): void {
const cancellationTokenSource = this.cancellationTokenSources.get(id);
if (cancellationTokenSource) {
cancellationTokenSource.cancel();
}
}
protected async handleRequest(id: number, method: string, args: any[]): Promise<void> {
const output = this.channel.getWriteBuffer();
// Check if the last argument of the received args is the key for indicating that a cancellation token should be used
// If so remove the key from the args and create a new cancellation token.
const addToken = args.length && args[args.length - 1] === RpcProtocol.CANCELLATION_TOKEN_KEY ? args.pop() : false;
if (addToken) {
const tokenSource = new CancellationTokenSource();
this.cancellationTokenSources.set(id, tokenSource);
args.push(tokenSource.token);
}
try {
const result = await this.requestHandler!(method, args);
this.cancellationTokenSources.delete(id);
this.encoder.replyOK(output, id, result);
output.commit();
} catch (err) {
// In case of an error the output buffer might already contains parts of an message.
// => Dispose the current buffer and retrieve a new, clean one for writing the response error.
if (Disposable.is(output)) {
output.dispose();
}
const errorOutput = this.channel.getWriteBuffer();
this.cancellationTokenSources.delete(id);
this.encoder.replyErr(errorOutput, id, err);
errorOutput.commit();
}
}
protected async handleNotify(method: string, args: any[], id?: number): Promise<void> {
if (this.toDispose.disposed) {
return;
}
this.onNotificationEmitter.fire({ method, args });
}
}