Skip to content
This repository has been archived by the owner on Jul 13, 2018. It is now read-only.

Latest commit

 

History

History
476 lines (343 loc) · 14.3 KB

Internals.md

File metadata and controls

476 lines (343 loc) · 14.3 KB

This file contains information about the internals of Theia. It assumes you already know the basics about Theia's architecture as described in: Architecture.md.

How create a backend service and connect to it over JSON-RPC

In this section I will explain how you can create a backend service and then connect to it over JSON-RPC.

I will use the debug logging system as a small example of that.

Overview

This works by creating a service exposed by the express framework and then connecting to that over a websocket connection.

Registering a service

So the first thing you will want to do is expose your service so that the frontend can connect to it.

You will need to create backend server module file similar to this (logger-server-module.ts):

import { ContainerModule } from 'inversify';
import { ConnectionHandler, JsonRpcConnectionHandler } from "../../messaging/common";
import { ILoggerServer, ILoggerClient } from '../../application/common/logger-protocol';

export const loggerServerModule = new ContainerModule(bind => {
    bind(ConnectionHandler).toDynamicValue(ctx =>
        new JsonRpcConnectionHandler<ILoggerClient>("/services/logger", client => {
            const loggerServer = ctx.container.get<ILoggerServer>(ILoggerServer);
            loggerServer.setClient(client);
            return loggerServer;
        })
    ).inSingletonScope()
});

Let's go over that in detail:

import { ConnectionHandler, JsonRpcConnectionHandler } from "../../messaging/common";

This imports the JsonRpcConnectionHandler, this factory enables you to create a connection handler that onConnection creates proxy object to the object that is called in the backend over JSON-RPC and expose a local object to JSON-RPC.

We'll see more on how this is done as we go.

The ConnectionHandler is a simple interface that specifies the path of the connection and what happens on connection creation.

It looks like this:

import { MessageConnection } from "vscode-jsonrpc";

export const ConnectionHandler = Symbol('ConnectionHandler');

export interface ConnectionHandler {
    readonly path: string;
    onConnection(connection: MessageConnection): void;
}
import { ILoggerServer, ILoggerClient } from '../../application/common/logger-protocol';

The logger-protocol.ts file contains the interfaces that the server and the client need to implement.

The server here means the backend object that will be called over JSON-RPC and the client is a client object that can receive notifications from the backend object.

I'll get more into that later.

    bind<ConnectionHandler>(ConnectionHandler).toDynamicValue(ctx => {

Here a bit of magic happens, at first glance we're just saying here's an implementation of a ConnectionHandler.

The magic here is that this ConnectionHandler type is bound to a ContributionProvider in messaging-module.ts

So as the MessagingContribution starts (onStart is called) it creates a websocket connection for all bound ConnectionHandlers.

like so (from messaging-module.ts):

constructor( @inject(ContributionProvider) @named(ConnectionHandler) protected readonly handlers: ContributionProvider<ConnectionHandler>) {
    }

    onStart(server: http.Server): void {
        for (const handler of this.handlers.getContributions()) {
            const path = handler.path;
            try {
                createServerWebSocketConnection({
                    server,
                    path
                }, connection => handler.onConnection(connection));
            } catch (error) {
                console.error(error)
            }
        }
    }

To dig more into ContributionProvider see this section.

So now:

new JsonRpcConnectionHandler<ILoggerClient>("/services/logger", client => {

This does a few things if we look at this class implementation:

export class JsonRpcConnectionHandler<T extends object> implements ConnectionHandler {
    constructor(
        readonly path: string,
        readonly targetFactory: (proxy: JsonRpcProxy<T>) => any
    ) { }

    onConnection(connection: MessageConnection): void {
        const factory = new JsonRpcProxyFactory<T>(this.path);
        const proxy = factory.createProxy();
        factory.target = this.targetFactory(proxy);
        factory.listen(connection);
    }
}

We see that a websocket connection is created on path: "logger" by the extension of the ConnectionHandler class with the path attribute set to "logger".

And let's look at what it does onConnection :

    onConnection(connection: MessageConnection): void {
        const factory = new JsonRpcProxyFactory<T>(this.path);
        const proxy = factory.createProxy();
        factory.target = this.targetFactory(proxy);
        factory.listen(connection);

Let's go over this line by line:

    const factory = new JsonRpcProxyFactory<T>(this.path);

This creates a JsonRpcProxy on path "logger".

    const proxy = factory.createProxy();

Here we create a proxy object from the factory, this will be used to call the other end of the JSON-RPC connection using the ILoggerClient interface.

    factory.target = this.targetFactory(proxy);

This will call the function we've passed in parameter so:

        client => { 
            const loggerServer = ctx.container.get<ILoggerServer>(ILoggerServer);
            loggerServer.setClient(client);
            return loggerServer;
        }

This sets the client on the loggerServer, in this case this is used to send notifications to the frontend about a log level change.

And it returns the loggerServer as the object that will be exposed over JSON-RPC.

 factory.listen(connection);

This connects the factory to the connection.

The endpoints with services/* path are served by the webpack dev server, see webpack.config.js:

    '/services/*': {
        target: 'ws://localhost:3000',
        ws: true
    },

Connecting to a service

So now that we have a backend service let's see how to connect to it from the frontend.

To do that you will need something like this:

(From logger-frontend-module.ts)

import { ContainerModule, Container } from 'inversify';
import { WebSocketConnectionProvider } from '../../messaging/browser/connection';
import { ILogger, LoggerFactory, LoggerOptions, Logger } from '../common/logger';
import { ILoggerServer } from '../common/logger-protocol';
import { LoggerWatcher } from '../common/logger-watcher';

export const loggerFrontendModule = new ContainerModule(bind => {
    bind(ILogger).to(Logger).inSingletonScope();
    bind(LoggerWatcher).toSelf().inSingletonScope();
    bind(ILoggerServer).toDynamicValue(ctx => {
        const loggerWatcher = ctx.container.get(LoggerWatcher);
        const connection = ctx.container.get(WebSocketConnectionProvider);
        return connection.createProxy<ILoggerServer>("/services/logger", loggerWatcher.getLoggerClient());
    }).inSingletonScope();
});

The important bit here are those lines:

    bind(ILoggerServer).toDynamicValue(ctx => {
        const loggerWatcher = ctx.container.get(LoggerWatcher);
        const connection = ctx.container.get(WebSocketConnectionProvider);
        return connection.createProxy<ILoggerServer>("/services/logger", loggerWatcher.getLoggerClient());
    }).inSingletonScope();

Let's go line by line:

        const loggerWatcher = ctx.container.get(LoggerWatcher);

Here we're creating a watcher, this is used to get notified about events from the backend by using the loggerWatcher client (loggerWatcher.getLoggerClient())

See more information about how events work in theia here.

        const connection = ctx.container.get(WebSocketConnectionProvider);

Here we're getting the websocket connection, this will be used to create a proxy from.

        return connection.createProxy<ILoggerServer>("/services/logger", loggerWatcher.getLoggerClient());

As the second argument, we pass a local object to handle JSON-RPC messages from the remote object. Sometimes the local object depends on the proxy and cannot be instantiated before the proxy is instantiated. In such cases, the proxy interface should implement JsonRpcServer and the local object should be provided as a client.

export type JsonRpcServer<Client> = Disposable & {
    setClient(client: Client | undefined): void;
};

export interface ILoggerServer extends JsonRpcServery<ILoggerClient> {
    // ...
}

const serverProxy = connection.createProxy<ILoggerServer>("/services/logger");
const client = loggerWatcher.getLoggerClient();
serverProxy.setClient(client);

So here at the last line we're binding the ILoggerServer interface to a JsonRpc proxy.

Note that his under the hood calls:

 createProxy<T extends object>(path: string, target?: object, options?: WebSocketOptions): T {
        const factory = new JsonRpcProxyFactory<T>(path, target);
        this.listen(factory, options);
        return factory.createProxy();
    }

So it's very similar to the backend example.

Maybe you've noticed too but as far as the connection is concerned the frontend is the server and the backend is the client. But that doesn't really matter in our logic.

So again there's multiple things going on here what this does is that:

  • it creates a JsonRpc Proxy on path "logger".
  • it exposes the loggerWatcher.getLoggerClient() object.
  • it returns a proxy of type ILoggerServer.

So now instances of ILoggerServer are proxied over JSON-RPC to the backend's LoggerServer object.

Loading the modules in the example backend and frontend

So now that we have these modules we need to wire them into the example. We will use the browser example for this, note that it's the same code for the electron example.

Backend

In examples/browser/src/backend/main.ts you will need something like:

import { loggerServerModule } from 'theia-core/lib/application/node/logger-server-module';

And than load that into the main container:

container.load(loggerServerModule);

Frontend

In examples/browser/src/frontend/main.ts you will need something like:

import { loggerFrontendModule } from 'theia-core/lib/application/browser/logger-frontend-module';
container.load(frontendLanguagesModule);

Complete example

If you wish to see the complete implementation of what I referred too in this documentation see this commit.

Contribution Providers

A contribution provider is basically a container for contributions where contributions are instances of a bound type.

It is very generic.

To bind a type to a contribution provider you can do like this:

(From messaging-module.ts)

export const messagingModule = new ContainerModule(bind => {
    bind<BackendApplicationContribution>(BackendApplicationContribution).to(MessagingContribution);
    bindContributionProvider(bind, ConnectionHandler)
});

The last line will bind a ContributionProvider to one that contains all ConnectionHandler bound instances.

It is used as such:

(From messaging-module.ts)

    constructor( @inject(ContributionProvider) @named(ConnectionHandler) protected readonly handlers: ContributionProvider<ConnectionHandler>) {
    }

So here we're injecting a ContributionProvider with the named ConnectionHandler value that was bound before by bindContributionProvider.

This enables anyone to bind a ConnectionHandler and now when the messagingModule is started all the ConnectionHandlers will be initiated.

Events

Events in Theia can be confusing, hopefully we can clarify things.

Let's consider this code:

(From logger-watcher.ts)

@injectable()
export class LoggerWatcher {

    getLoggerClient(): ILoggerClient {
        const emitter = this.onLogLevelChangedEmitter
        return {
            onLogLevelChanged(event: ILogLevelChangedEvent) {
                emitter.fire(event)
            }
        }
    }

    private onLogLevelChangedEmitter = new Emitter<ILogLevelChangedEvent>();

    get onLogLevelChanged(): Event<ILogLevelChangedEvent> {
        return this.onLogLevelChangedEmitter.event;
    }
}

Let's start with:

    private onLogLevelChangedEmitter = new Emitter<ILogLevelChangedEvent>();

So first what is an Emitter?

An Emitter is an event handler container, it allows for event handlers to be registered on it and triggered with an event of type X in this case an ILogLevelChangedEvent.

So here we just create an Emitter that will have events of type ILogLevelChangedEvent;

Next we want to be able to register an event handler on this Emitter to do so we do this:

    get onLogLevelChanged(): Event<ILogLevelChangedEvent> {
        return this.onLogLevelChangedEmitter.event;
    }

What this actually returns is a function that will register an event handler so you just pass it your event handler function and it will register it so that it's called when the event fires.

so you can call:

(From logger.ts)

 /* Update the root logger log level if it changes in the backend. */
        loggerWatcher.onLogLevelChanged(event => {
            this.id.then(id => {
                if (id === this.rootLoggerId) {
                    this._logLevel = Promise.resolve(event.newLogLevel);
                }
            });
        });

This registers the anonymous function passed as param on this emitter.

Next we will need to trigger this event handler by firing an event:

 onLogLevelChanged(event: ILogLevelChangedEvent) {
                emitter.fire(event)
            }

When calling this function, the emitter fires and all the event handlers are called.

So if you need to trigger events in theia:

  • Create an emitter
  • Register events with the emitter.event function
  • Fire events with emitter.fire(event)