Path: blob/main/components/gitpod-protocol/src/messaging/proxy-factory.ts
2500 views
/*1* Copyright (C) 2017 TypeFox and others.2*3* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.4* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.05*/67import { MessageConnection } from "vscode-jsonrpc";8import { Event, Emitter } from "../util/event";9import { Disposable } from "../util/disposable";10import { ConnectionHandler } from "./handler";11import { log } from "../util/logging";12import { ApplicationError } from "./error";1314export type JsonRpcServer<Client> = Disposable & {15/**16* If this server is a proxy to a remote server then17* a client is used as a local object18* to handle JSON-RPC messages from the remote server.19*/20setClient(client: Client | undefined): void;21};2223export interface JsonRpcConnectionEventEmitter {24readonly onDidOpenConnection: Event<void>;25readonly onDidCloseConnection: Event<void>;26}27export type JsonRpcProxy<T> = T & JsonRpcConnectionEventEmitter;2829export class JsonRpcConnectionHandler<T extends object> implements ConnectionHandler {30constructor(readonly path: string, readonly targetFactory: (proxy: JsonRpcProxy<T>, request?: object) => any) {}3132onConnection(connection: MessageConnection, request?: object): void {33const factory = new JsonRpcProxyFactory<T>();34const proxy = factory.createProxy();35factory.target = this.targetFactory(proxy, request);36factory.listen(connection);37}38}3940/**41* Factory for JSON-RPC proxy objects.42*43* A JSON-RPC proxy exposes the programmatic interface of an object through44* JSON-RPC. This allows remote programs to call methods of this objects by45* sending JSON-RPC requests. This takes place over a bi-directional stream,46* where both ends can expose an object and both can call methods each other's47* exposed object.48*49* For example, assuming we have an object of the following type on one end:50*51* class Foo {52* bar(baz: number): number { return baz + 1 }53* }54*55* which we want to expose through a JSON-RPC interface. We would do:56*57* let target = new Foo()58* let factory = new JsonRpcProxyFactory<Foo>('/foo', target)59* factory.onConnection(connection)60*61* The party at the other end of the `connection`, in order to remotely call62* methods on this object would do:63*64* let factory = new JsonRpcProxyFactory<Foo>('/foo')65* factory.onConnection(connection)66* let proxy = factory.createProxy();67* let result = proxy.bar(42)68* // result is equal to 4369*70* One the wire, it would look like this:71*72* --> {"jsonrpc": "2.0", "id": 0, "method": "bar", "params": {"baz": 42}}73* <-- {"jsonrpc": "2.0", "id": 0, "result": 43}74*75* Note that in the code of the caller, we didn't pass a target object to76* JsonRpcProxyFactory, because we don't want/need to expose an object.77* If we had passed a target object, the other side could've called methods on78* it.79*80* @param <T> - The type of the object to expose to JSON-RPC.81*/82export class JsonRpcProxyFactory<T extends object> implements ProxyHandler<T> {83protected readonly onDidOpenConnectionEmitter = new Emitter<void>();84protected readonly onDidCloseConnectionEmitter = new Emitter<void>();8586protected connectionPromiseResolve: (connection: MessageConnection) => void;87protected connectionPromise: Promise<MessageConnection>;8889/**90* Build a new JsonRpcProxyFactory.91*92* @param target - The object to expose to JSON-RPC methods calls. If this93* is omitted, the proxy won't be able to handle requests, only send them.94*/95constructor(public target?: any) {96this.waitForConnection();97}9899protected waitForConnection(): void {100this.connectionPromise = new Promise((resolve) => (this.connectionPromiseResolve = resolve));101this.connectionPromise102.then((connection) => {103connection.onClose(() => this.fireConnectionClosed());104this.fireConnectionOpened();105})106.catch((err) => {107log.error("Error while waiting for connection", err);108});109}110111fireConnectionClosed() {112this.onDidCloseConnectionEmitter.fire(undefined);113}114115fireConnectionOpened() {116this.onDidOpenConnectionEmitter.fire(undefined);117}118119/**120* Connect a MessageConnection to the factory.121*122* This connection will be used to send/receive JSON-RPC requests and123* response.124*/125listen(connection: MessageConnection) {126// eslint-disable-next-line @typescript-eslint/no-unsafe-argument127connection.onRequest((method: string, ...params: any[]) => this.onRequest(method, ...params));128// eslint-disable-next-line @typescript-eslint/no-unsafe-argument129connection.onNotification((method: string, ...params: any[]) => this.onNotification(method, ...params));130connection.onDispose(() => this.waitForConnection());131connection.listen();132this.connectionPromiseResolve(connection);133}134135/**136* Process an incoming JSON-RPC method call.137*138* onRequest is called when the JSON-RPC connection received a method call139* request. It calls the corresponding method on [[target]].140*141* The return value is a Promise object that is resolved with the return142* value of the method call, if it is successful. The promise is rejected143* if the called method does not exist or if it throws.144*145* @returns A promise of the method call completion.146*/147protected async onRequest(method: string, ...args: any[]): Promise<any> {148try {149return await this.target[method](...args);150} catch (e) {151if (ApplicationError.hasErrorCode(e)) {152log.info(`Request ${method} unsuccessful: ${e.code}/"${e.message}"`, { method, args });153} else {154log.error(`Request ${method} failed with internal server error`, e, { method, args });155}156throw e;157}158}159160/**161* Process an incoming JSON-RPC notification.162*163* Same as [[onRequest]], but called on incoming notifications rather than164* methods calls.165*/166protected onNotification(method: string, ...args: any[]): void {167if (this.target[method]) {168this.target[method](...args);169}170}171172/**173* Create a Proxy exposing the interface of an object of type T. This Proxy174* can be used to do JSON-RPC method calls on the remote target object as175* if it was local.176*177* If `T` implements `JsonRpcServer` then a client is used as a target object for a remote target object.178*/179createProxy(): JsonRpcProxy<T> {180const result = new Proxy<T>(this as unknown as T, this);181return result as any;182}183184/**185* Get a callable object that executes a JSON-RPC method call.186*187* Getting a property on the Proxy object returns a callable that, when188* called, executes a JSON-RPC call. The name of the property defines the189* method to be called. The callable takes a variable number of arguments,190* which are passed in the JSON-RPC method call.191*192* For example, if you have a Proxy object:193*194* let fooProxyFactory = JsonRpcProxyFactory<Foo>('/foo')195* let fooProxy = fooProxyFactory.createProxy()196*197* accessing `fooProxy.bar` will return a callable that, when called,198* executes a JSON-RPC method call to method `bar`. Therefore, doing199* `fooProxy.bar()` will call the `bar` method on the remote Foo object.200*201* @param target - unused.202* @param p - The property accessed on the Proxy object.203* @param receiver - unused.204* @returns A callable that executes the JSON-RPC call.205*/206get(target: T, p: PropertyKey, receiver: any): any {207if (p === "setClient") {208return (client: any) => {209this.target = client;210};211}212if (p === "onDidOpenConnection") {213return this.onDidOpenConnectionEmitter.event;214}215if (p === "onDidCloseConnection") {216return this.onDidCloseConnectionEmitter.event;217}218const isNotify = this.isNotification(p);219return (...args: any[]) =>220this.connectionPromise.then(221(connection) =>222new Promise((resolve, reject) => {223try {224if (isNotify) {225// eslint-disable-next-line @typescript-eslint/no-unsafe-argument226connection.sendNotification(p.toString(), ...args);227resolve(undefined);228} else {229// eslint-disable-next-line @typescript-eslint/no-unsafe-argument230const resultPromise = connection.sendRequest(p.toString(), ...args) as Promise<any>;231resultPromise.then(resolve, reject);232}233} catch (err) {234reject(err);235}236}),237);238}239240/**241* Return whether the given property represents a notification.242*243* A property leads to a notification rather than a method call if its name244* begins with `notify` or `on`.245*246* @param p - The property being called on the proxy.247* @return Whether `p` represents a notification.248*/249protected isNotification(p: PropertyKey): boolean {250return p.toString().startsWith("notify") || p.toString().startsWith("on");251}252}253254255