diff --git a/dev-packages/application-package/src/application-props.ts b/dev-packages/application-package/src/application-props.ts index e0c9ffca031fa..850d0e2556c78 100644 --- a/dev-packages/application-package/src/application-props.ts +++ b/dev-packages/application-package/src/application-props.ts @@ -86,7 +86,8 @@ export namespace FrontendApplicationConfig { defaultIconTheme: 'theia-file-icons', electron: ElectronFrontendApplicationConfig.DEFAULT, defaultLocale: '', - validatePreferencesSchema: true + validatePreferencesSchema: true, + reloadOnReconnect: false }; export interface Partial extends ApplicationConfig { @@ -132,6 +133,12 @@ export namespace FrontendApplicationConfig { * Defaults to `true`. */ readonly validatePreferencesSchema?: boolean; + + /** + * When 'true', the window will reload in case the front end reconnects to a back-end, + * but the back end does not have a connection context for this front end anymore. + */ + readonly reloadOnReconnect?: boolean; } } @@ -142,6 +149,7 @@ export type BackendApplicationConfig = RequiredRecursive { bind(SampleUpdaterImpl).toSelf().inSingletonScope(); bind(SampleUpdater).toService(SampleUpdaterImpl); bind(ElectronMainApplicationContribution).toService(SampleUpdater); - bind(ElectronConnectionHandler).toDynamicValue(context => + bind(ConnectionHandler).toDynamicValue(context => new RpcConnectionHandler(SampleUpdaterPath, client => { const server = context.container.get(SampleUpdater); server.setClient(client); diff --git a/examples/browser/package.json b/examples/browser/package.json index d843e492b16f5..30df238ea6599 100644 --- a/examples/browser/package.json +++ b/examples/browser/package.json @@ -9,8 +9,15 @@ "applicationName": "Theia Browser Example", "preferences": { "files.enableTrash": false - } + }, + "reloadOnReconnect": true } + }, + "backend": { + "config": { + "frontendConnectionTimeout": 3000 + } + } }, "dependencies": { diff --git a/examples/electron/package.json b/examples/electron/package.json index 4906055cc7edf..4c4ed8f6af7dd 100644 --- a/examples/electron/package.json +++ b/examples/electron/package.json @@ -9,7 +9,13 @@ "target": "electron", "frontend": { "config": { - "applicationName": "Theia Electron Example" + "applicationName": "Theia Electron Example", + "reloadOnReconnect": true + } + }, + "backend": { + "config": { + "frontendConnectionTimeout": -1 } } }, diff --git a/packages/core/src/browser/connection-status-service.spec.ts b/packages/core/src/browser/connection-status-service.spec.ts index 2d570f0ec62f6..9625b28e29242 100644 --- a/packages/core/src/browser/connection-status-service.spec.ts +++ b/packages/core/src/browser/connection-status-service.spec.ts @@ -33,8 +33,8 @@ import { MockConnectionStatusService } from './test/mock-connection-status-servi import * as sinon from 'sinon'; import { Container } from 'inversify'; -import { WebSocketConnectionProvider } from './messaging/ws-connection-provider'; import { ILogger, Emitter, Loggable } from '../common'; +import { WebsocketConnectionSource } from './messaging/ws-connection-source'; disableJSDOM(); @@ -101,7 +101,7 @@ describe('frontend-connection-status', function (): void { let timer: sinon.SinonFakeTimers; let pingSpy: sinon.SinonSpy; beforeEach(() => { - const mockWebSocketConnectionProvider = sinon.createStubInstance(WebSocketConnectionProvider); + const mockWebSocketConnectionSource = sinon.createStubInstance(WebsocketConnectionSource); const mockPingService: PingService = { ping(): Promise { return Promise.resolve(undefined); @@ -118,11 +118,11 @@ describe('frontend-connection-status', function (): void { testContainer.bind(PingService).toConstantValue(mockPingService); testContainer.bind(ILogger).toConstantValue(mockILogger); testContainer.bind(ConnectionStatusOptions).toConstantValue({ offlineTimeout: OFFLINE_TIMEOUT }); - testContainer.bind(WebSocketConnectionProvider).toConstantValue(mockWebSocketConnectionProvider); + testContainer.bind(WebsocketConnectionSource).toConstantValue(mockWebSocketConnectionSource); - sinon.stub(mockWebSocketConnectionProvider, 'onSocketDidOpen').value(mockSocketOpenedEmitter.event); - sinon.stub(mockWebSocketConnectionProvider, 'onSocketDidClose').value(mockSocketClosedEmitter.event); - sinon.stub(mockWebSocketConnectionProvider, 'onIncomingMessageActivity').value(mockIncomingMessageActivityEmitter.event); + sinon.stub(mockWebSocketConnectionSource, 'onSocketDidOpen').value(mockSocketOpenedEmitter.event); + sinon.stub(mockWebSocketConnectionSource, 'onSocketDidClose').value(mockSocketClosedEmitter.event); + sinon.stub(mockWebSocketConnectionSource, 'onIncomingMessageActivity').value(mockIncomingMessageActivityEmitter.event); timer = sinon.useFakeTimers(); diff --git a/packages/core/src/browser/connection-status-service.ts b/packages/core/src/browser/connection-status-service.ts index 790a702480f00..dea99f4341f1c 100644 --- a/packages/core/src/browser/connection-status-service.ts +++ b/packages/core/src/browser/connection-status-service.ts @@ -19,8 +19,8 @@ import { ILogger } from '../common/logger'; import { Event, Emitter } from '../common/event'; import { DefaultFrontendApplicationContribution } from './frontend-application-contribution'; import { StatusBar, StatusBarAlignment } from './status-bar/status-bar'; -import { WebSocketConnectionProvider } from './messaging/ws-connection-provider'; import { Disposable, DisposableCollection, nls } from '../common'; +import { WebsocketConnectionSource } from './messaging/ws-connection-source'; /** * Service for listening on backend connection changes. @@ -119,7 +119,7 @@ export class FrontendConnectionStatusService extends AbstractConnectionStatusSer private scheduledPing: number | undefined; - @inject(WebSocketConnectionProvider) protected readonly wsConnectionProvider: WebSocketConnectionProvider; + @inject(WebsocketConnectionSource) protected readonly wsConnectionProvider: WebsocketConnectionSource; @inject(PingService) protected readonly pingService: PingService; @postConstruct() diff --git a/packages/core/src/browser/messaging/connection-source.ts b/packages/core/src/browser/messaging/connection-source.ts new file mode 100644 index 0000000000000..9c1553ece0e16 --- /dev/null +++ b/packages/core/src/browser/messaging/connection-source.ts @@ -0,0 +1,26 @@ +// ***************************************************************************** +// Copyright (C) 2023 STMicroelectronics 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 +// ***************************************************************************** + +import { Channel, Event } from "../../common"; + +export const ConnectionSource = Symbol('ConnectionSource'); + +/** + * A ConnectionSource creates a Channel. The channel is valid until it sends a close event. + */ +export interface ConnectionSource { + onConnectionDidOpen: Event; +} \ No newline at end of file diff --git a/packages/core/src/browser/messaging/frontend-id-provider.ts b/packages/core/src/browser/messaging/frontend-id-provider.ts new file mode 100644 index 0000000000000..3eaea1fedfe74 --- /dev/null +++ b/packages/core/src/browser/messaging/frontend-id-provider.ts @@ -0,0 +1,38 @@ +// ***************************************************************************** +// Copyright (C) 2023 STMicroelectronics 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 +// ***************************************************************************** + +import { injectable } from "inversify"; +import { generateUuid } from "../../common/uuid"; + +export const FrontendIdProvider = Symbol('FrontendIdProvider'); + +/** + * A FronendIdProvider computes an id for an instance of the front end that may be reconnected to a back end + * connection context. + */ +export interface FrontendIdProvider { + getId(): string; +} + +@injectable() +export class BrowserFrontendIdProvider implements FrontendIdProvider { + protected readonly id = generateUuid(); // generate a new id each time we load the application + + getId(): string { + return this.id; + } + +} \ No newline at end of file diff --git a/packages/core/src/browser/messaging/messaging-frontend-module.ts b/packages/core/src/browser/messaging/messaging-frontend-module.ts index f852c8f651231..96bc16d87fa73 100644 --- a/packages/core/src/browser/messaging/messaging-frontend-module.ts +++ b/packages/core/src/browser/messaging/messaging-frontend-module.ts @@ -15,9 +15,29 @@ // ***************************************************************************** import { ContainerModule } from 'inversify'; -import { LocalWebSocketConnectionProvider, WebSocketConnectionProvider } from './ws-connection-provider'; +import { BrowserFrontendIdProvider, FrontendIdProvider } from './frontend-id-provider'; +import { WebsocketConnectionSource } from './ws-connection-source'; +import { LocalConnectionProvider, RemoteConnectionProvider, ServiceConnectionProvider } from './service-connection-provider'; +import { ConnectionSource } from './connection-source'; +import { ConnectionCloseService, connectionCloseServicePath } from '../../common/messaging/connection-management'; +import { WebSocketConnectionProvider } from './ws-connection-provider'; + +const backendServiceProvider = Symbol('backendServiceProvider'); export const messagingFrontendModule = new ContainerModule(bind => { + bind(ConnectionCloseService).toDynamicValue(ctx => { + return WebSocketConnectionProvider.createProxy(ctx.container, connectionCloseServicePath); + }).inSingletonScope(); + bind(BrowserFrontendIdProvider).toSelf().inSingletonScope(); + bind(FrontendIdProvider).toService(BrowserFrontendIdProvider); + bind(WebsocketConnectionSource).toSelf().inSingletonScope(); + bind(backendServiceProvider).toDynamicValue(ctx => { + bind(ServiceConnectionProvider).toSelf().inSingletonScope(); + const container = ctx.container.createChild(); + container.bind(ConnectionSource).toService(WebsocketConnectionSource); + return container.get(ServiceConnectionProvider); + }).inSingletonScope(); + bind(LocalConnectionProvider).toService(backendServiceProvider); + bind(RemoteConnectionProvider).toService(backendServiceProvider); bind(WebSocketConnectionProvider).toSelf().inSingletonScope(); - bind(LocalWebSocketConnectionProvider).toService(WebSocketConnectionProvider); }); diff --git a/packages/core/src/browser/messaging/service-connection-provider.ts b/packages/core/src/browser/messaging/service-connection-provider.ts new file mode 100644 index 0000000000000..0d0aa0cc04023 --- /dev/null +++ b/packages/core/src/browser/messaging/service-connection-provider.ts @@ -0,0 +1,127 @@ +// ***************************************************************************** +// Copyright (C) 2020 Ericsson 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 +// ***************************************************************************** + +import { inject, injectable, interfaces, postConstruct } from 'inversify'; +import { Channel, RpcProxy, RpcProxyFactory } from '../../common'; +import { ChannelMultiplexer } from '../../common/message-rpc/channel'; +import { Deferred } from '../../common/promise-util'; +import { ConnectionSource } from './connection-source'; + + +export const LocalConnectionProvider = Symbol('LocalConnectionProvider'); +export const RemoteConnectionProvider = Symbol('RemoteConnectionProvider'); + +export namespace ServiceConnectionProvider { + export type ConnectionHandler = (path: String, channel: Channel) => void; +} + +/** + * This class manages the channels for remote services in the back end + */ +@injectable() +export class ServiceConnectionProvider { + + static createProxy(container: interfaces.Container, path: string, arg?: object): RpcProxy { + return container.get(RemoteConnectionProvider).createProxy(path, arg); + } + + static createLocalProxy(container: interfaces.Container, path: string, arg?: object): RpcProxy { + return container.get(LocalConnectionProvider).createProxy(path, arg); + } + + static createHandler(container: interfaces.Container, path: string, arg?: object): void { + const remote = container.get(RemoteConnectionProvider); + const local = container.get(LocalConnectionProvider); + remote.createProxy(path, arg); + if (remote !== local) { + local.createProxy(path, arg); + } + } + + protected readonly channelHandlers = new Map(); + + /** + * Create a proxy object to remote interface of T type + * over a web socket connection for the given path and proxy factory. + */ + createProxy(path: string, factory: RpcProxyFactory): RpcProxy; + /** + * Create a proxy object to remote interface of T type + * over a web socket connection for the given path. + * + * An optional target can be provided to handle + * notifications and requests from a remote side. + */ + createProxy(path: string, target?: object): RpcProxy; + createProxy(path: string, arg?: object): RpcProxy { + const factory = arg instanceof RpcProxyFactory ? arg : new RpcProxyFactory(arg); + this.listen(path, (_, c) => factory.listen(c), true); + return factory.createProxy(); + } + + protected channelMultiplexer: ChannelMultiplexer; + + private channelReadyDeferred = new Deferred(); + protected get channelReady(): Promise { + return this.channelReadyDeferred.promise; + } + + @postConstruct() + init(): void { + this.connectionSource.onConnectionDidOpen(channel => this.handleChannelCreated(channel)); + } + + @inject(ConnectionSource) + protected connectionSource: ConnectionSource; + + /** + * This method must be invoked by subclasses when they have created the main channel. + * @param mainChannel + */ + protected handleChannelCreated(channel: Channel): void { + channel.onClose(() => { + this.handleChannelClosed(channel); + }); + + this.channelMultiplexer = new ChannelMultiplexer(channel); + this.channelReadyDeferred.resolve(); + for (const entry of this.channelHandlers.entries()) { + this.openChannel(entry[0], entry[1]); + } + } + + handleChannelClosed(channel: Channel): void { + this.channelReadyDeferred = new Deferred(); + } + + /** + * Install a connection handler for the given path. + */ + listen(path: string, handler: ServiceConnectionProvider.ConnectionHandler, reconnect: boolean): void { + this.openChannel(path, handler).then(() => { + if (reconnect) { + this.channelHandlers.set(path, handler); + } + }); + + } + + private async openChannel(path: string, handler: ServiceConnectionProvider.ConnectionHandler): Promise { + await this.channelReady; + const newChannel = await this.channelMultiplexer.open(path); + handler(path, newChannel); + } +} diff --git a/packages/core/src/browser/messaging/ws-connection-provider.ts b/packages/core/src/browser/messaging/ws-connection-provider.ts index 822110c58fd8b..4f3d658282c45 100644 --- a/packages/core/src/browser/messaging/ws-connection-provider.ts +++ b/packages/core/src/browser/messaging/ws-connection-provider.ts @@ -14,160 +14,37 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { injectable, interfaces, decorate, unmanaged } from 'inversify'; -import { RpcProxyFactory, RpcProxy, Emitter, Event, Channel } from '../../common'; -import { Endpoint } from '../endpoint'; -import { AbstractConnectionProvider } from '../../common/messaging/abstract-connection-provider'; -import { io, Socket } from 'socket.io-client'; -import { IWebSocket, WebSocketChannel } from '../../common/messaging/web-socket-channel'; +import { injectable, interfaces, decorate, unmanaged, inject } from 'inversify'; +import { RpcProxyFactory, RpcProxy } from '../../common'; +import { RemoteConnectionProvider, ServiceConnectionProvider } from './service-connection-provider'; decorate(injectable(), RpcProxyFactory); decorate(unmanaged(), RpcProxyFactory, 0); -export const LocalWebSocketConnectionProvider = Symbol('LocalWebSocketConnectionProvider'); - -export interface WebSocketOptions { - /** - * True by default. - */ - reconnecting?: boolean; -} - +/** + * @deprecated This class serves to keep API compatiliblity for a while. Use {@linkcode ServiceConnectionProvider} instead. + */ @injectable() -export class WebSocketConnectionProvider extends AbstractConnectionProvider { - - protected readonly onSocketDidOpenEmitter: Emitter = new Emitter(); - get onSocketDidOpen(): Event { - return this.onSocketDidOpenEmitter.event; - } - - protected readonly onSocketDidCloseEmitter: Emitter = new Emitter(); - get onSocketDidClose(): Event { - return this.onSocketDidCloseEmitter.event; - } +export class WebSocketConnectionProvider { + @inject(RemoteConnectionProvider) + private readonly remoteConnectionProvider: ServiceConnectionProvider; - static override createProxy(container: interfaces.Container, path: string, arg?: object): RpcProxy { - return container.get(WebSocketConnectionProvider).createProxy(path, arg); + static createProxy(container: interfaces.Container, path: string, arg?: object): RpcProxy { + return ServiceConnectionProvider.createProxy(container, path, arg); } static createLocalProxy(container: interfaces.Container, path: string, arg?: object): RpcProxy { - return container.get(LocalWebSocketConnectionProvider).createProxy(path, arg); + return ServiceConnectionProvider.createProxy(container, path, arg); } static createHandler(container: interfaces.Container, path: string, arg?: object): void { - const remote = container.get(WebSocketConnectionProvider); - const local = container.get(LocalWebSocketConnectionProvider); - remote.createProxy(path, arg); - if (remote !== local) { - local.createProxy(path, arg); - } + return ServiceConnectionProvider.createHandler(container, path, arg); } - protected readonly socket: Socket; - - constructor() { - super(); - const url = this.createWebSocketUrl(WebSocketChannel.wsPath); - this.socket = this.createWebSocket(url); - this.socket.on('connect', () => { - this.initializeMultiplexer(); - if (this.reconnectChannelOpeners.length > 0) { - this.reconnectChannelOpeners.forEach(opener => opener()); - this.reconnectChannelOpeners = []; - } - this.socket.on('disconnect', () => this.fireSocketDidClose()); - this.socket.on('message', () => this.onIncomingMessageActivityEmitter.fire(undefined)); - this.fireSocketDidOpen(); - }); - this.socket.connect(); - } - - protected createMainChannel(): Channel { - return new WebSocketChannel(this.toIWebSocket(this.socket)); + createProxy(path: string, target?: object): RpcProxy; + createProxy(path: string, factory: RpcProxyFactory): RpcProxy { + return this.remoteConnectionProvider.createProxy(path, factory); } - protected toIWebSocket(socket: Socket): IWebSocket { - return { - close: () => { - socket.removeAllListeners('disconnect'); - socket.removeAllListeners('error'); - socket.removeAllListeners('message'); - }, - isConnected: () => socket.connected, - onClose: cb => socket.on('disconnect', reason => cb(reason)), - onError: cb => socket.on('error', reason => cb(reason)), - onMessage: cb => socket.on('message', data => cb(data)), - send: message => socket.emit('message', message) - }; - } - override async openChannel(path: string, handler: (channel: Channel) => void, options?: WebSocketOptions): Promise { - if (this.socket.connected) { - return super.openChannel(path, handler, options); - } else { - const openChannel = () => { - this.socket.off('connect', openChannel); - this.openChannel(path, handler, options); - }; - this.socket.on('connect', openChannel); - } - } - - /** - * @param path The handler to reach in the backend. - */ - protected createWebSocketUrl(path: string): string { - // Since we are using Socket.io, the path should look like the following: - // proto://domain.com/{path} - return this.createEndpoint(path).getWebSocketUrl().withPath(path).toString(); - } - - protected createHttpWebSocketUrl(path: string): string { - return this.createEndpoint(path).getRestUrl().toString(); - } - - protected createEndpoint(path: string): Endpoint { - return new Endpoint({ path }); - } - - /** - * Creates a web socket for the given url - */ - protected createWebSocket(url: string): Socket { - return io(url, { - path: this.createSocketIoPath(url), - reconnection: true, - reconnectionDelay: 1000, - reconnectionDelayMax: 10000, - reconnectionAttempts: Infinity, - extraHeaders: { - // Socket.io strips the `origin` header - // We need to provide our own for validation - 'fix-origin': window.location.origin - } - }); - } - - /** - * Path for Socket.io to make its requests to. - */ - protected createSocketIoPath(url: string): string | undefined { - if (location.protocol === Endpoint.PROTO_FILE) { - return '/socket.io'; - } - let { pathname } = location; - if (!pathname.endsWith('/')) { - pathname += '/'; - } - return pathname + 'socket.io'; - } - - protected fireSocketDidOpen(): void { - this.onSocketDidOpenEmitter.fire(undefined); - } - - protected fireSocketDidClose(): void { - this.onSocketDidCloseEmitter.fire(undefined); - } } - diff --git a/packages/core/src/browser/messaging/ws-connection-source.ts b/packages/core/src/browser/messaging/ws-connection-source.ts new file mode 100644 index 0000000000000..3f8cec9ce25ae --- /dev/null +++ b/packages/core/src/browser/messaging/ws-connection-source.ts @@ -0,0 +1,205 @@ +// ***************************************************************************** +// Copyright (C) 2023 STMicroelectronics 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 +// ***************************************************************************** + +import { AbstractChannel, Channel, Disposable, DisposableCollection, Emitter, Event, servicesPath } from "../../common"; +import { ConnectionSource } from "./connection-source"; +import { Socket, io } from "socket.io-client"; +import { Endpoint } from "../endpoint"; +import { ForwardingChannel } from "../../common/message-rpc/channel"; +import { Uint8ArrayReadBuffer, Uint8ArrayWriteBuffer } from "../../common/message-rpc/uint8-array-message-buffer"; +import { inject, injectable } from "inversify"; +import { FrontendIdProvider } from "./frontend-id-provider"; +import { FrontendApplicationConfigProvider } from "../frontend-application-config-provider"; +import { SocketWriteBuffer } from "../../common/messaging/socket-write-buffer"; +import { ConnectionManagementMessages } from "../../common/messaging/connection-management"; + +@injectable() +export class WebsocketConnectionSource implements ConnectionSource { + static readonly NO_CONNECTION = ''; + + @inject(FrontendIdProvider) + protected readonly frontendIdProvider: FrontendIdProvider; + + private readonly writeBuffer = new SocketWriteBuffer(); + + protected readonly _socket: Socket; + get socket(): Socket { + return this._socket; + } + + protected currentChannel: AbstractChannel; + + protected readonly onConnectionDidOpenEmitter: Emitter = new Emitter(); + get onConnectionDidOpen(): Event { + return this.onConnectionDidOpenEmitter.event; + } + + protected readonly onSocketDidOpenEmitter: Emitter = new Emitter(); + get onSocketDidOpen(): Event { + return this.onSocketDidOpenEmitter.event; + } + + protected readonly onSocketDidCloseEmitter: Emitter = new Emitter(); + get onSocketDidClose(): Event { + return this.onSocketDidCloseEmitter.event; + } + + protected readonly onIncomingMessageActivityEmitter: Emitter = new Emitter(); + get onIncomingMessageActivity(): Event { + return this.onIncomingMessageActivityEmitter.event; + } + + constructor() { + const url = this.createWebSocketUrl(servicesPath); + this._socket = this.createWebSocket(url); + this._socket.connect(); + + this._socket.on('connect', () => { + this.onSocketDidOpenEmitter.fire(); + this.handleSocketConnected(); + }); + + this._socket.on('disconnect', () => { + this.onSocketDidCloseEmitter.fire(); + }) + + this._socket.on('error', reason => { + if (this.currentChannel) { + this.currentChannel.onErrorEmitter.fire(reason); + }; + }); + } + + protected handleSocketConnected() { + if (this.currentChannel) { + const reconnectListener = (hasConnection: boolean) => { + this._socket.off(ConnectionManagementMessages.RECONNECT, reconnectListener); + if (hasConnection) { + this.writeBuffer.flush(this.socket); + } else { + if (FrontendApplicationConfigProvider.get().reloadOnReconnect) { + window.location.reload(); // this might happen in the preload module, when we have no window service yet + } else { + this.connectNewChannel(); + } + } + }; + this._socket.on(ConnectionManagementMessages.RECONNECT, reconnectListener); + this._socket.emit(ConnectionManagementMessages.RECONNECT, this.frontendIdProvider.getId()); + } else { + const initialConnectListener = () => { + this._socket.off(ConnectionManagementMessages.INITIAL_CONNECT, initialConnectListener); + this.connectNewChannel(); + }; + this._socket.on(ConnectionManagementMessages.INITIAL_CONNECT, initialConnectListener); + this._socket.emit(ConnectionManagementMessages.INITIAL_CONNECT, this.frontendIdProvider.getId()); + } + } + + connectNewChannel(): void { + if (this.currentChannel) { + this.writeBuffer.drain(); + this.currentChannel.close(); + this.currentChannel.onCloseEmitter.fire({ reason: 'reconnecting channel' }); + } + this.currentChannel = this.createChannel(); + this.onConnectionDidOpenEmitter.fire(this.currentChannel); + } + + protected createChannel(): AbstractChannel { + const toDispose = new DisposableCollection(); + + const messageHandler = (data: any) => { + this.onIncomingMessageActivityEmitter.fire(); + if (this.currentChannel) { + // In the browser context socketIO receives binary messages as ArrayBuffers. + // So we have to convert them to a Uint8Array before delegating the message to the read buffer. + const buffer = data instanceof ArrayBuffer ? new Uint8Array(data) : data; + this.currentChannel.onMessageEmitter.fire(() => new Uint8ArrayReadBuffer(buffer)); + }; + }; + this._socket.on('message', messageHandler); + toDispose.push(Disposable.create(() => { + this.socket.off('message', messageHandler); + })); + + const channel = new ForwardingChannel('any', () => { + toDispose.dispose(); + }, () => { + const result = new Uint8ArrayWriteBuffer(); + + result.onCommit(buffer => { + if (this.socket.connected) { + this.socket.send(buffer); + } else { + this.writeBuffer.buffer(buffer); + } + }); + + return result; + }); + return channel; + } + + /** + * @param path The handler to reach in the backend. + */ + protected createWebSocketUrl(path: string): string { + // Since we are using Socket.io, the path should look like the following: + // proto://domain.com/{path} + return this.createEndpoint(path).getWebSocketUrl().withPath(path).toString(); + } + + protected createHttpWebSocketUrl(path: string): string { + return this.createEndpoint(path).getRestUrl().toString(); + } + + protected createEndpoint(path: string): Endpoint { + return new Endpoint({ path }); + } + + /** + * Creates a web socket for the given url + */ + protected createWebSocket(url: string): Socket { + return io(url, { + path: this.createSocketIoPath(url), + reconnection: true, + reconnectionDelay: 1000, + reconnectionDelayMax: 10000, + reconnectionAttempts: Infinity, + extraHeaders: { + // Socket.io strips the `origin` header + // We need to provide our own for validation + 'fix-origin': window.location.origin + } + }); + } + + /** + * Path for Socket.io to make its requests to. + */ + protected createSocketIoPath(url: string): string | undefined { + if (location.protocol === Endpoint.PROTO_FILE) { + return '/socket.io'; + } + let { pathname } = location; + if (!pathname.endsWith('/')) { + pathname += '/'; + } + return pathname + 'socket.io'; + } +} \ No newline at end of file diff --git a/packages/core/src/common/message-rpc/channel.ts b/packages/core/src/common/message-rpc/channel.ts index 05a9ba2426dfc..1f2b5047801d8 100644 --- a/packages/core/src/common/message-rpc/channel.ts +++ b/packages/core/src/common/message-rpc/channel.ts @@ -220,6 +220,8 @@ export class ChannelMultiplexer implements Disposable { this.openChannels.set(id, channel); resolve(channel); this.onOpenChannelEmitter.fire({ id, channel }); + } else { + console.error(`not expecting ack-open on for ${id}`); } } @@ -234,6 +236,8 @@ export class ChannelMultiplexer implements Disposable { } this.underlyingChannel.getWriteBuffer().writeUint8(MessageTypes.AckOpen).writeString(id).commit(); this.onOpenChannelEmitter.fire({ id, channel }); + } else { + console.error(`channel already open: ${id}`); } } @@ -275,7 +279,7 @@ export class ChannelMultiplexer implements Disposable { } open(id: string): Promise { - if (this.openChannels.has(id)) { + if (this.openChannels.has(id) || this.pendingOpen.has(id)) { throw new Error(`Another channel with the id '${id}' is already open.`); } const result = new Promise((resolve, reject) => { diff --git a/packages/core/src/common/message-rpc/message-buffer.ts b/packages/core/src/common/message-rpc/message-buffer.ts index d0b2fad0e351a..a27c41f3c77ca 100644 --- a/packages/core/src/common/message-rpc/message-buffer.ts +++ b/packages/core/src/common/message-rpc/message-buffer.ts @@ -25,6 +25,7 @@ export interface WriteBuffer { writeBytes(value: Uint8Array): this writeNumber(value: number): this writeLength(value: number): this + writeRaw(bytes: Uint8Array): this; /** * Makes any writes to the buffer permanent, for example by sending the writes over a channel. * You must obtain a new write buffer after committing @@ -71,6 +72,11 @@ export class ForwardingWriteBuffer implements WriteBuffer { return this; } + writeRaw(bytes: Uint8Array): this { + this.underlying.writeRaw(bytes); + return this; + } + commit(): void { this.underlying.commit(); } diff --git a/packages/core/src/common/message-rpc/uint8-array-message-buffer.ts b/packages/core/src/common/message-rpc/uint8-array-message-buffer.ts index 5b4294b3d57aa..af07a35a48461 100644 --- a/packages/core/src/common/message-rpc/uint8-array-message-buffer.ts +++ b/packages/core/src/common/message-rpc/uint8-array-message-buffer.ts @@ -76,6 +76,13 @@ export class Uint8ArrayWriteBuffer implements WriteBuffer, Disposable { return this; } + writeRaw(bytes: Uint8Array): this { + this.ensureCapacity(bytes.byteLength); + this.buffer.set(bytes, this.offset); + this.offset += bytes.byteLength; + return this; + } + writeUint16(value: number): this { this.ensureCapacity(2); this.msg.setUint16(this.offset, value); diff --git a/packages/core/src/common/messaging/abstract-connection-provider.ts b/packages/core/src/common/messaging/abstract-connection-provider.ts deleted file mode 100644 index 2e2c8c8956232..0000000000000 --- a/packages/core/src/common/messaging/abstract-connection-provider.ts +++ /dev/null @@ -1,115 +0,0 @@ -// ***************************************************************************** -// Copyright (C) 2020 Ericsson 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 -// ***************************************************************************** - -import { injectable, interfaces } from 'inversify'; -import { Emitter, Event } from '../event'; -import { ConnectionHandler } from './handler'; -import { RpcProxy, RpcProxyFactory } from './proxy-factory'; -import { Channel, ChannelMultiplexer } from '../message-rpc/channel'; - -/** - * Factor common logic according to `ElectronIpcConnectionProvider` and - * `WebSocketConnectionProvider`. This class handles channels in a somewhat - * generic way. - */ -@injectable() -export abstract class AbstractConnectionProvider { - - /** - * Create a proxy object to remote interface of T type - * over an electron ipc connection for the given path and proxy factory. - */ - static createProxy(container: interfaces.Container, path: string, factory: RpcProxyFactory): RpcProxy; - /** - * Create a proxy object to remote interface of T type - * over an electron ipc connection for the given path. - * - * An optional target can be provided to handle - * notifications and requests from a remote side. - */ - static createProxy(container: interfaces.Container, path: string, target?: object): RpcProxy { - throw new Error('abstract'); - } - - protected readonly onIncomingMessageActivityEmitter: Emitter = new Emitter(); - get onIncomingMessageActivity(): Event { - return this.onIncomingMessageActivityEmitter.event; - } - - /** - * Create a proxy object to remote interface of T type - * over a web socket connection for the given path and proxy factory. - */ - createProxy(path: string, factory: RpcProxyFactory): RpcProxy; - /** - * Create a proxy object to remote interface of T type - * over a web socket connection for the given path. - * - * An optional target can be provided to handle - * notifications and requests from a remote side. - */ - createProxy(path: string, target?: object): RpcProxy; - createProxy(path: string, arg?: object): RpcProxy { - const factory = arg instanceof RpcProxyFactory ? arg : new RpcProxyFactory(arg); - this.listen({ - path, - onConnection: c => factory.listen(c) - }); - return factory.createProxy(); - } - - protected channelMultiplexer?: ChannelMultiplexer; - - // A set of channel opening functions that are executed if the backend reconnects to restore the - // the channels that were open before the disconnect occurred. - protected reconnectChannelOpeners: Array<() => Promise> = []; - - protected initializeMultiplexer(): void { - const mainChannel = this.createMainChannel(); - mainChannel.onMessage(() => this.onIncomingMessageActivityEmitter.fire()); - this.channelMultiplexer = new ChannelMultiplexer(mainChannel); - } - - /** - * Install a connection handler for the given path. - */ - listen(handler: ConnectionHandler, options?: AbstractOptions): void { - this.openChannel(handler.path, channel => { - handler.onConnection(channel); - }, options); - } - - async openChannel(path: string, handler: (channel: Channel) => void, options?: AbstractOptions): Promise { - if (!this.channelMultiplexer) { - throw new Error('The channel multiplexer has not been initialized yet!'); - } - const newChannel = await this.channelMultiplexer.open(path); - newChannel.onClose(() => { - const { reconnecting } = { reconnecting: true, ...options }; - if (reconnecting) { - this.reconnectChannelOpeners.push(() => this.openChannel(path, handler, options)); - } - }); - - handler(newChannel); - } - - /** - * Create the main connection that is used for multiplexing all service channels. - */ - protected abstract createMainChannel(): Channel; - -} diff --git a/packages/core/src/common/messaging/connection-management.ts b/packages/core/src/common/messaging/connection-management.ts new file mode 100644 index 0000000000000..32f1102cf8416 --- /dev/null +++ b/packages/core/src/common/messaging/connection-management.ts @@ -0,0 +1,43 @@ + +// ***************************************************************************** +// Copyright (C) 2017 TypeFox 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 +// ***************************************************************************** + +export const ConnectionCloseService = Symbol('ConnectionCloseService'); +export const connectionCloseServicePath = '/services/ChannelCloseService'; + +/** + * These messages are used to negotiate service reconnection between a front ends and back end. + * Whenever a front end first connects to a back end, it sends the ${@link ConnectionManagementMessages#INITIAL_CONNECT} message + * together with its front end id. + * The back end then starts a new front end connection context for that front end. If the back end already had another connection context + * for the given front end id, it gets discarded. + * If the front end reconnects after a websocket disconnect, it sends the ${@link ConnectionManagementMessages#RECONNECT} message + * together with its front end id.. + * If the back end still has a connection context for the front end id, the context is reconnected and the back end replies with the value true. + * If there is no context anymore, the back end replies with value false. The front end can then either do an initial connect or reload + * the whole UI. + */ +export namespace ConnectionManagementMessages { + export const INITIAL_CONNECT = 'initialConnection'; + export const RECONNECT = 'reconnect'; +} + +/** + * A service to mark a front end as unused. As soon as it disconnects from the back end, the connection context will be discarded. + */ +export interface ConnectionCloseService { + markForClose(frontEndId: string): Promise; +} \ No newline at end of file diff --git a/packages/core/src/common/messaging/handler.ts b/packages/core/src/common/messaging/handler.ts index 204125be8a203..0bdfac3e34e47 100644 --- a/packages/core/src/common/messaging/handler.ts +++ b/packages/core/src/common/messaging/handler.ts @@ -16,6 +16,8 @@ import { Channel } from '../message-rpc/channel'; +export const servicesPath = '/services'; + export const ConnectionHandler = Symbol('ConnectionHandler'); export interface ConnectionHandler { diff --git a/packages/core/src/common/messaging/socket-write-buffer.ts b/packages/core/src/common/messaging/socket-write-buffer.ts new file mode 100644 index 0000000000000..6ecac02e2db3a --- /dev/null +++ b/packages/core/src/common/messaging/socket-write-buffer.ts @@ -0,0 +1,57 @@ +// ***************************************************************************** +// Copyright (C) 2018 TypeFox 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 { Socket as ClientSocket } from 'socket.io-client'; +import { Socket as ServerSocket } from 'socket.io'; + +export type WebSocket = ClientSocket | ServerSocket; + +export class SocketWriteBuffer { + private static DISCONNECTED_BUFFER_SIZE = 100 * 1024; + + private disconnectedBuffer: Uint8Array | undefined; + private bufferWritePosition = 0; + + buffer(data: Uint8Array) { + this.ensureWriteBuffer(data.byteLength); + this.disconnectedBuffer?.set(data, this.bufferWritePosition); + this.bufferWritePosition += data.byteLength; + } + + protected ensureWriteBuffer(byteLength: number): void { + if (!this.disconnectedBuffer) { + this.disconnectedBuffer = new Uint8Array(SocketWriteBuffer.DISCONNECTED_BUFFER_SIZE); + this.bufferWritePosition = 0; + } + + if (this.bufferWritePosition + byteLength > this.disconnectedBuffer.byteLength) { + throw new Error(`Max disconnected buffer size exceeded by adding ${byteLength} bytes`); + } + } + + flush(socket: WebSocket) { + if (this.disconnectedBuffer) { + socket.send(this.disconnectedBuffer.slice(0, this.bufferWritePosition)); + this.disconnectedBuffer = undefined; + } + } + + drain(): void { + this.disconnectedBuffer = undefined; + } +} \ No newline at end of file diff --git a/packages/core/src/common/messaging/web-socket-channel.ts b/packages/core/src/common/messaging/web-socket-channel.ts index 4f98d5269fb32..9b4c61fee9d7e 100644 --- a/packages/core/src/common/messaging/web-socket-channel.ts +++ b/packages/core/src/common/messaging/web-socket-channel.ts @@ -19,7 +19,11 @@ import { WriteBuffer } from '../message-rpc'; import { Uint8ArrayReadBuffer, Uint8ArrayWriteBuffer } from '../message-rpc/uint8-array-message-buffer'; import { AbstractChannel } from '../message-rpc/channel'; -import { Disposable } from '../disposable'; +import { Socket as ClientSocket } from 'socket.io-client'; +import { Socket as ServerSocket } from 'socket.io'; +import { Emitter } from 'vscode-languageserver-protocol'; + +export type WebSocket = ClientSocket | ServerSocket; /** * A channel that manages the main websocket connection between frontend and backend. All service channels @@ -29,65 +33,44 @@ import { Disposable } from '../disposable'; export class WebSocketChannel extends AbstractChannel { static wsPath = '/services'; - constructor(protected readonly socket: IWebSocket) { + private onDidConnectEmitter = new Emitter(); + onDidConnect = this.onDidConnectEmitter.event; + + constructor(protected readonly socket: WebSocket) { super(); - this.toDispose.push(Disposable.create(() => socket.close())); - socket.onClose((reason, code) => this.onCloseEmitter.fire({ reason, code })); - socket.onClose(() => this.close()); - socket.onError(error => this.onErrorEmitter.fire(error)); - socket.onMessage(data => this.onMessageEmitter.fire(() => { + socket.on('connect', () => { + this.onDidConnectEmitter.fire(); + }); + + socket.on('disconnect', reason => { + this.onCloseEmitter.fire({ + reason: reason + }); + }); + + socket.on('error', reason => this.onErrorEmitter.fire(reason)); + socket.on('message', data => { // In the browser context socketIO receives binary messages as ArrayBuffers. // So we have to convert them to a Uint8Array before delegating the message to the read buffer. const buffer = data instanceof ArrayBuffer ? new Uint8Array(data) : data; - return new Uint8ArrayReadBuffer(buffer); - })); + this.onMessageEmitter.fire(() => new Uint8ArrayReadBuffer(buffer)); + }); } getWriteBuffer(): WriteBuffer { const result = new Uint8ArrayWriteBuffer(); result.onCommit(buffer => { - if (this.socket.isConnected()) { + if (this.socket.connected) { this.socket.send(buffer); } }); return result; } -} -/** - * An abstraction that enables reuse of the `{@link WebSocketChannel} class in the frontend and backend - * independent of the actual underlying socket implementation. - */ -export interface IWebSocket { - /** - * Sends the given message over the web socket in binary format. - * @param message The binary message. - */ - send(message: Uint8Array): void; - /** - * Closes the websocket from the local side. - */ - close(): void; - /** - * The connection state of the web socket. - */ - isConnected(): boolean; - /** - * Listener callback to handle incoming messages. - * @param cb The callback. - */ - onMessage(cb: (message: Uint8Array) => void): void; - /** - * Listener callback to handle socket errors. - * @param cb The callback. - */ - onError(cb: (reason: any) => void): void; - /** - * Listener callback to handle close events (Remote side). - * @param cb The callback. - */ - onClose(cb: (reason: string, code?: number) => void): void; + override close(): void { + this.socket.disconnect(); + super.close(); + } } - diff --git a/packages/core/src/electron-browser/messaging/electron-frontend-id-provider.ts b/packages/core/src/electron-browser/messaging/electron-frontend-id-provider.ts new file mode 100644 index 0000000000000..6cd01a6deff43 --- /dev/null +++ b/packages/core/src/electron-browser/messaging/electron-frontend-id-provider.ts @@ -0,0 +1,25 @@ +// ***************************************************************************** +// Copyright (C) 2023 STMicroelectronics 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 +// ***************************************************************************** + +import { injectable } from "inversify"; +import { FrontendIdProvider } from "../../browser/messaging/frontend-id-provider"; + +@injectable() +export class ElectronFrontendIdProvider implements FrontendIdProvider { + getId(): string { + return window.electronTheiaCore.WindowMetadata.webcontentId; + } +} \ No newline at end of file diff --git a/packages/core/src/electron-browser/messaging/electron-ipc-connection-provider.ts b/packages/core/src/electron-browser/messaging/electron-ipc-connection-source.ts similarity index 60% rename from packages/core/src/electron-browser/messaging/electron-ipc-connection-provider.ts rename to packages/core/src/electron-browser/messaging/electron-ipc-connection-source.ts index 1551e3ab9c8ed..bc7a05de4f36a 100644 --- a/packages/core/src/electron-browser/messaging/electron-ipc-connection-provider.ts +++ b/packages/core/src/electron-browser/messaging/electron-ipc-connection-source.ts @@ -16,32 +16,40 @@ import { injectable, interfaces } from 'inversify'; import { RpcProxy } from '../../common/messaging'; -import { AbstractConnectionProvider } from '../../common/messaging/abstract-connection-provider'; -import { AbstractChannel, Channel, WriteBuffer } from '../../common'; +import { AbstractChannel, Channel, Event, MaybePromise, WriteBuffer } from '../../common'; import { Uint8ArrayReadBuffer, Uint8ArrayWriteBuffer } from '../../common/message-rpc/uint8-array-message-buffer'; +import { ServiceConnectionProvider } from '../../browser/messaging/service-connection-provider'; +import { ConnectionSource } from '../../browser/messaging/connection-source'; +import { Emitter } from 'vscode-languageserver-protocol'; +import { FrontendApplicationContribution } from 'src/browser'; export interface ElectronIpcOptions { } +export const ElectronMainConnectionProvider = Symbol('ElectronMainConnectionProvider'); + /** * Connection provider between the Theia frontend and the electron-main process via IPC. */ -@injectable() -export class ElectronIpcConnectionProvider extends AbstractConnectionProvider { +export namespace ElectronIpcConnectionProvider { - static override createProxy(container: interfaces.Container, path: string, arg?: object): RpcProxy { - return container.get(ElectronIpcConnectionProvider).createProxy(path, arg); + export function createProxy(container: interfaces.Container, path: string, arg?: object): RpcProxy { + return container.get(ElectronMainConnectionProvider).createProxy(path, arg); } - constructor() { - super(); - this.initializeMultiplexer(); - } - protected createMainChannel(): Channel { - return new ElectronIpcRendererChannel(); - } +} + +@injectable() +export class ElectronIpcConnectionSource implements ConnectionSource, FrontendApplicationContribution { + protected readonly onConnectionDidOpenEmitter: Emitter = new Emitter(); + onConnectionDidOpen: Event = this.onConnectionDidOpenEmitter.event; + + onStart(): MaybePromise { + const channel = new ElectronIpcRendererChannel(); + this.onConnectionDidOpenEmitter.fire(channel); + } } export class ElectronIpcRendererChannel extends AbstractChannel { diff --git a/packages/core/src/electron-browser/messaging/electron-local-ws-connection-provider.ts b/packages/core/src/electron-browser/messaging/electron-local-ws-connection-source.ts similarity index 89% rename from packages/core/src/electron-browser/messaging/electron-local-ws-connection-provider.ts rename to packages/core/src/electron-browser/messaging/electron-local-ws-connection-source.ts index 01c7912cfe089..42143eaf052ce 100644 --- a/packages/core/src/electron-browser/messaging/electron-local-ws-connection-provider.ts +++ b/packages/core/src/electron-browser/messaging/electron-local-ws-connection-source.ts @@ -15,8 +15,8 @@ // ***************************************************************************** import { injectable } from 'inversify'; -import { WebSocketConnectionProvider } from '../../browser/messaging/ws-connection-provider'; import { Endpoint } from '../../browser/endpoint'; +import { WebsocketConnectionSource } from '../../browser/messaging/ws-connection-source'; export function getLocalPort(): string | undefined { const params = new URLSearchParams(location.search); @@ -24,7 +24,7 @@ export function getLocalPort(): string | undefined { } @injectable() -export class ElectronLocalWebSocketConnectionProvider extends WebSocketConnectionProvider { +export class ElectronLocalWebSocketConnectionSource extends WebsocketConnectionSource { protected override createEndpoint(path: string): Endpoint { const localPort = getLocalPort(); diff --git a/packages/core/src/electron-browser/messaging/electron-messaging-frontend-module.ts b/packages/core/src/electron-browser/messaging/electron-messaging-frontend-module.ts index d62fc02a00607..8519bc35c9e55 100644 --- a/packages/core/src/electron-browser/messaging/electron-messaging-frontend-module.ts +++ b/packages/core/src/electron-browser/messaging/electron-messaging-frontend-module.ts @@ -16,26 +16,65 @@ import { ContainerModule } from 'inversify'; import { FrontendApplicationContribution } from '../../browser/frontend-application-contribution'; -import { LocalWebSocketConnectionProvider, WebSocketConnectionProvider } from '../../browser/messaging/ws-connection-provider'; -import { ElectronWebSocketConnectionProvider } from './electron-ws-connection-provider'; -import { ElectronIpcConnectionProvider } from './electron-ipc-connection-provider'; -import { ElectronLocalWebSocketConnectionProvider, getLocalPort } from './electron-local-ws-connection-provider'; +import { ElectronWebSocketConnectionSource } from './electron-ws-connection-source'; +import { ElectronIpcConnectionSource, ElectronMainConnectionProvider } from './electron-ipc-connection-source'; +import { ElectronLocalWebSocketConnectionSource, getLocalPort } from './electron-local-ws-connection-source'; +import { ElectronFrontendIdProvider } from './electron-frontend-id-provider'; +import { FrontendIdProvider } from '../../browser/messaging/frontend-id-provider'; +import { ConnectionSource } from '../../browser/messaging/connection-source'; +import { LocalConnectionProvider, RemoteConnectionProvider, ServiceConnectionProvider } from '../../browser/messaging/service-connection-provider'; +import { WebSocketConnectionProvider } from '../../browser'; +import { ConnectionCloseService, connectionCloseServicePath } from '../../common/messaging/connection-management'; +import { WebsocketConnectionSource } from '../../browser/messaging/ws-connection-source'; + +const backendServiceProvider = Symbol('backendServiceProvider2'); +const localServiceProvider = Symbol('localServiceProvider'); export const messagingFrontendModule = new ContainerModule(bind => { - bind(ElectronWebSocketConnectionProvider).toSelf().inSingletonScope(); - bind(FrontendApplicationContribution).toService(ElectronWebSocketConnectionProvider); - bind(WebSocketConnectionProvider).toService(ElectronWebSocketConnectionProvider); - bind(ElectronLocalWebSocketConnectionProvider).toSelf().inSingletonScope(); - bind(LocalWebSocketConnectionProvider).toDynamicValue(ctx => { + bind(ConnectionCloseService).toDynamicValue(ctx => { + return WebSocketConnectionProvider.createProxy(ctx.container, connectionCloseServicePath); + }).inSingletonScope(); + bind(ElectronWebSocketConnectionSource).toSelf().inSingletonScope(); + bind(WebsocketConnectionSource).toService(ElectronWebSocketConnectionSource); + bind(ElectronIpcConnectionSource).toSelf().inSingletonScope(); + bind(FrontendApplicationContribution).toService(ElectronIpcConnectionSource); + bind(ElectronLocalWebSocketConnectionSource).toSelf().inSingletonScope(); + bind(backendServiceProvider).toDynamicValue(ctx => { + const container = ctx.container.createChild(); + container.bind(ServiceConnectionProvider).toSelf().inSingletonScope(); + container.bind(ConnectionSource).toService(ElectronWebSocketConnectionSource); + return container.get(ServiceConnectionProvider); + }).inSingletonScope(); + + bind(localServiceProvider).toDynamicValue(ctx => { + const container = ctx.container.createChild(); + container.bind(ServiceConnectionProvider).toSelf().inSingletonScope(); + container.bind(ConnectionSource).toService(ElectronLocalWebSocketConnectionSource); + return container.get(ServiceConnectionProvider); + }).inSingletonScope(); + + bind(ElectronMainConnectionProvider).toDynamicValue(ctx => { + const container = ctx.container.createChild(); + container.bind(ServiceConnectionProvider).toSelf().inSingletonScope(); + container.bind(ConnectionSource).toService(ElectronIpcConnectionSource); + return container.get(ServiceConnectionProvider); + }).inSingletonScope(); + + bind(LocalConnectionProvider).toDynamicValue(ctx => { const localPort = getLocalPort(); if (localPort) { // Return new web socket provider that connects to local app - return ctx.container.get(ElectronLocalWebSocketConnectionProvider); + return ctx.container.get(localServiceProvider); } else { // Return the usual web socket provider that already established its connection // That way we don't create a second socket connection - return ctx.container.get(WebSocketConnectionProvider); + return ctx.container.get(backendServiceProvider); } }).inSingletonScope(); - bind(ElectronIpcConnectionProvider).toSelf().inSingletonScope(); + bind(RemoteConnectionProvider).toService(backendServiceProvider); + + bind(FrontendApplicationContribution).toService(ElectronWebSocketConnectionSource); + bind(ElectronFrontendIdProvider).toSelf().inSingletonScope(); + bind(FrontendIdProvider).toService(ElectronFrontendIdProvider); + bind(WebSocketConnectionProvider).toSelf().inSingletonScope(); }); diff --git a/packages/core/src/electron-browser/messaging/electron-ws-connection-provider.ts b/packages/core/src/electron-browser/messaging/electron-ws-connection-source.ts similarity index 67% rename from packages/core/src/electron-browser/messaging/electron-ws-connection-provider.ts rename to packages/core/src/electron-browser/messaging/electron-ws-connection-source.ts index a9d0f161ab0df..d5b714e89a5f5 100644 --- a/packages/core/src/electron-browser/messaging/electron-ws-connection-provider.ts +++ b/packages/core/src/electron-browser/messaging/electron-ws-connection-source.ts @@ -15,9 +15,8 @@ // ***************************************************************************** import { injectable } from 'inversify'; -import { WebSocketConnectionProvider, WebSocketOptions } from '../../browser/messaging/ws-connection-provider'; import { FrontendApplicationContribution } from '../../browser/frontend-application-contribution'; -import { Channel } from '../../common'; +import { WebsocketConnectionSource } from '../../browser/messaging/ws-connection-source'; /** * Customized connection provider between the frontend and the backend in electron environment. @@ -25,25 +24,15 @@ import { Channel } from '../../common'; * once the electron-browser window is refreshed. Otherwise, backend resources are not disposed. */ @injectable() -export class ElectronWebSocketConnectionProvider extends WebSocketConnectionProvider implements FrontendApplicationContribution { - - /** - * Do not try to reconnect when the frontend application is stopping. The browser is navigating away from this page. - */ - protected stopping = false; +export class ElectronWebSocketConnectionSource extends WebsocketConnectionSource implements FrontendApplicationContribution { + constructor() { + super(); + } onStop(): void { - this.stopping = true; // Manually close the websocket connections `onStop`. Otherwise, the channels will be closed with 30 sec (`MessagingContribution#checkAliveTimeout`) delay. // https://github.com/eclipse-theia/theia/issues/6499 // `1001` indicates that an endpoint is "going away", such as a server going down or a browser having navigated away from a page. - this.channelMultiplexer?.onUnderlyingChannelClose({ reason: 'The frontend is "going away"', code: 1001 }); + this.socket.close(); } - - override async openChannel(path: string, handler: (channel: Channel) => void, options?: WebSocketOptions): Promise { - if (!this.stopping) { - super.openChannel(path, handler, options); - } - } - } diff --git a/packages/core/src/electron-browser/preload.ts b/packages/core/src/electron-browser/preload.ts index b9c05e337f34b..10e2f043e5433 100644 --- a/packages/core/src/electron-browser/preload.ts +++ b/packages/core/src/electron-browser/preload.ts @@ -25,7 +25,7 @@ import { CHANNEL_ON_WINDOW_EVENT, CHANNEL_GET_ZOOM_LEVEL, CHANNEL_SET_ZOOM_LEVEL, CHANNEL_IS_FULL_SCREENABLE, CHANNEL_TOGGLE_FULL_SCREEN, CHANNEL_IS_FULL_SCREEN, CHANNEL_SET_MENU_BAR_VISIBLE, CHANNEL_REQUEST_CLOSE, CHANNEL_SET_TITLE_STYLE, CHANNEL_RESTART, CHANNEL_REQUEST_RELOAD, CHANNEL_APP_STATE_CHANGED, CHANNEL_SHOW_ITEM_IN_FOLDER, CHANNEL_READ_CLIPBOARD, CHANNEL_WRITE_CLIPBOARD, - CHANNEL_KEYBOARD_LAYOUT_CHANGED, CHANNEL_IPC_CONNECTION, InternalMenuDto, CHANNEL_REQUEST_SECONDARY_CLOSE, CHANNEL_SET_BACKGROUND_COLOR + CHANNEL_KEYBOARD_LAYOUT_CHANGED, CHANNEL_IPC_CONNECTION, InternalMenuDto, CHANNEL_REQUEST_SECONDARY_CLOSE, CHANNEL_SET_BACKGROUND_COLOR, CHANNEL_WC_METADATA, CHANNEL_ABOUT_TO_CLOSE } from '../electron-common/electron-api'; // eslint-disable-next-line import/no-extraneous-dependencies @@ -65,6 +65,7 @@ function convertMenu(menu: MenuDto[] | undefined, handlerMap: Map } const api: TheiaCoreAPI = { + WindowMetadata: { webcontentId: 'none' }, setMenuBarVisible: (visible: boolean, windowName?: string) => ipcRenderer.send(CHANNEL_SET_MENU_BAR_VISIBLE, visible, windowName), setMenu: (menu: MenuDto[] | undefined) => { commandHandlers.delete(mainMenuId); @@ -119,6 +120,17 @@ const api: TheiaCoreAPI = { close: function (): void { ipcRenderer.send(CHANNEL_CLOSE); }, + + onAboutToClose(handler: () => void): Disposable { + const h = (event: Electron.IpcRendererEvent, replyChannel: string) => { + handler(); + event.sender.send(replyChannel); + } + + ipcRenderer.on(CHANNEL_ABOUT_TO_CLOSE, h); + return Disposable.create(() => ipcRenderer.off(CHANNEL_ABOUT_TO_CLOSE, h)); + }, + onWindowEvent: function (event: WindowEvent, handler: () => void): Disposable { const h = (_event: unknown, evt: WindowEvent) => { if (event === evt) { @@ -226,6 +238,7 @@ export function preload(): void { } } }); + api.WindowMetadata.webcontentId = ipcRenderer.sendSync(CHANNEL_WC_METADATA); contextBridge.exposeInMainWorld('electronTheiaCore', api); } diff --git a/packages/core/src/electron-browser/window/electron-secondary-window-service.ts b/packages/core/src/electron-browser/window/electron-secondary-window-service.ts index bcaae900e9915..a5bd35cdca469 100644 --- a/packages/core/src/electron-browser/window/electron-secondary-window-service.ts +++ b/packages/core/src/electron-browser/window/electron-secondary-window-service.ts @@ -16,7 +16,7 @@ import { injectable } from 'inversify'; import { DefaultSecondaryWindowService } from '../../browser/window/default-secondary-window-service'; -import { ApplicationShell, ExtractableWidget } from 'src/browser'; +import { ApplicationShell, ExtractableWidget } from '../../browser'; @injectable() export class ElectronSecondaryWindowService extends DefaultSecondaryWindowService { diff --git a/packages/core/src/electron-browser/window/electron-window-module.ts b/packages/core/src/electron-browser/window/electron-window-module.ts index 360ba55aa36e4..b50e21267b4cd 100644 --- a/packages/core/src/electron-browser/window/electron-window-module.ts +++ b/packages/core/src/electron-browser/window/electron-window-module.ts @@ -21,7 +21,7 @@ import { FrontendApplicationContribution } from '../../browser/frontend-applicat import { ElectronClipboardService } from '../electron-clipboard-service'; import { ClipboardService } from '../../browser/clipboard-service'; import { ElectronMainWindowService, electronMainWindowServicePath } from '../../electron-common/electron-main-window-service'; -import { ElectronIpcConnectionProvider } from '../messaging/electron-ipc-connection-provider'; +import { ElectronIpcConnectionProvider } from '../messaging/electron-ipc-connection-source'; import { bindWindowPreferences } from './electron-window-preferences'; import { FrontendApplicationStateService } from '../../browser/frontend-application-state'; import { ElectronFrontendApplicationStateService } from './electron-frontend-application-state'; diff --git a/packages/core/src/electron-browser/window/electron-window-service.ts b/packages/core/src/electron-browser/window/electron-window-service.ts index 44442ed5d4d21..7777063b67e0c 100644 --- a/packages/core/src/electron-browser/window/electron-window-service.ts +++ b/packages/core/src/electron-browser/window/electron-window-service.ts @@ -19,6 +19,8 @@ import { NewWindowOptions, WindowSearchParams } from '../../common/window'; import { DefaultWindowService } from '../../browser/window/default-window-service'; import { ElectronMainWindowService } from '../../electron-common/electron-main-window-service'; import { ElectronWindowPreferences } from './electron-window-preferences'; +import { ConnectionCloseService } from '../../common/messaging/connection-management'; +import { FrontendIdProvider } from '../../browser/messaging/frontend-id-provider'; @injectable() export class ElectronWindowService extends DefaultWindowService { @@ -33,12 +35,18 @@ export class ElectronWindowService extends DefaultWindowService { */ protected closeOnUnload: boolean = false; + @inject(FrontendIdProvider) + protected readonly frontendIdProvider: FrontendIdProvider; + @inject(ElectronMainWindowService) protected readonly delegate: ElectronMainWindowService; @inject(ElectronWindowPreferences) protected readonly electronWindowPreferences: ElectronWindowPreferences; + @inject(ConnectionCloseService) + protected readonly connectionCloseService: ConnectionCloseService; + override openNewWindow(url: string, { external }: NewWindowOptions = {}): undefined { this.delegate.openNewWindow(url, { external }); return undefined; @@ -56,6 +64,9 @@ export class ElectronWindowService extends DefaultWindowService { this.updateWindowZoomLevel(); } }); + window.electronTheiaCore.onAboutToClose(() => { + this.connectionCloseService.markForClose(this.frontendIdProvider.getId()); + }); } protected override registerUnloadListeners(): void { diff --git a/packages/core/src/electron-common/electron-api.ts b/packages/core/src/electron-common/electron-api.ts index 64fa5625a309c..e9b713b114349 100644 --- a/packages/core/src/electron-common/electron-api.ts +++ b/packages/core/src/electron-common/electron-api.ts @@ -41,6 +41,9 @@ export type InternalMenuDto = Omit & { export type WindowEvent = 'maximize' | 'unmaximize' | 'focus'; export interface TheiaCoreAPI { + WindowMetadata: { + webcontentId: string; + } getSecurityToken: () => string; attachSecurityToken: (endpoint: string) => Promise; @@ -63,6 +66,7 @@ export interface TheiaCoreAPI { unMaximize(): void; close(): void; onWindowEvent(event: WindowEvent, handler: () => void): Disposable; + onAboutToClose(handler: () => void): Disposable; setCloseRequestHandler(handler: (reason: StopReason) => Promise): void; setSecondaryWindowCloseRequestHandler(windowName: string, handler: () => Promise): void; @@ -95,6 +99,7 @@ declare global { } } +export const CHANNEL_WC_METADATA = 'WebContentMetadata'; export const CHANNEL_SET_MENU = 'SetMenu'; export const CHANNEL_SET_MENU_BAR_VISIBLE = 'SetMenuBarVisible'; export const CHANNEL_INVOKE_MENU = 'InvokeMenu'; @@ -116,6 +121,8 @@ export const CHANNEL_MINIMIZE = 'Minimize'; export const CHANNEL_MAXIMIZE = 'Maximize'; export const CHANNEL_IS_MAXIMIZED = 'IsMaximized'; +export const CHANNEL_ABOUT_TO_CLOSE = "AboutToClose"; + export const CHANNEL_UNMAXIMIZE = 'UnMaximize'; export const CHANNEL_ON_WINDOW_EVENT = 'OnWindowEvent'; export const CHANNEL_TOGGLE_DEVTOOLS = 'ToggleDevtools'; diff --git a/packages/core/src/electron-main/electron-api-main.ts b/packages/core/src/electron-main/electron-api-main.ts index 4eeff065ad556..add9606bbdad1 100644 --- a/packages/core/src/electron-main/electron-api-main.ts +++ b/packages/core/src/electron-main/electron-api-main.ts @@ -51,7 +51,9 @@ import { CHANNEL_TOGGLE_FULL_SCREEN, CHANNEL_IS_MAXIMIZED, CHANNEL_REQUEST_SECONDARY_CLOSE, - CHANNEL_SET_BACKGROUND_COLOR + CHANNEL_SET_BACKGROUND_COLOR, + CHANNEL_WC_METADATA, + CHANNEL_ABOUT_TO_CLOSE } from '../electron-common/electron-api'; import { ElectronMainApplication, ElectronMainApplicationContribution } from './electron-main-application'; import { Disposable, DisposableCollection, isOSX, MaybePromise } from '../common'; @@ -65,6 +67,10 @@ export class TheiaMainApi implements ElectronMainApplicationContribution { protected readonly openPopups = new Map(); onStart(application: ElectronMainApplication): MaybePromise { + ipcMain.on(CHANNEL_WC_METADATA, event => { + event.returnValue = event.sender.id.toString(); + }); + // electron security token ipcMain.on(CHANNEL_GET_SECURITY_TOKEN, event => { event.returnValue = this.electronSecurityToken.value; @@ -254,6 +260,19 @@ export namespace TheiaRendererAPI { wc.send(CHANNEL_ON_WINDOW_EVENT, event); } + export function sendAboutToClose(wc: WebContents): Promise { + return new Promise(resolve => { + const channelNr = nextReplyChannel++; + const replyChannel = `aboutToClose${channelNr}`; + const l = createDisposableListener(ipcMain, replyChannel, e => { + l.dispose(); + resolve(); + }); + + wc.send(CHANNEL_ABOUT_TO_CLOSE, replyChannel); + }); + } + export function requestClose(wc: WebContents, stopReason: StopReason): Promise { const channelNr = nextReplyChannel++; const confirmChannel = `confirm-${channelNr}`; diff --git a/packages/core/src/electron-main/electron-main-application-module.ts b/packages/core/src/electron-main/electron-main-application-module.ts index 0c789b617d675..0148dbc6fd77c 100644 --- a/packages/core/src/electron-main/electron-main-application-module.ts +++ b/packages/core/src/electron-main/electron-main-application-module.ts @@ -22,12 +22,12 @@ import { ElectronSecurityToken } from '../electron-common/electron-token'; import { ElectronMainWindowService, electronMainWindowServicePath } from '../electron-common/electron-main-window-service'; import { ElectronMainApplication, ElectronMainApplicationContribution, ElectronMainProcessArgv } from './electron-main-application'; import { ElectronMainWindowServiceImpl } from './electron-main-window-service-impl'; -import { ElectronMessagingContribution } from './messaging/electron-messaging-contribution'; -import { ElectronMessagingService } from './messaging/electron-messaging-service'; -import { ElectronConnectionHandler } from '../electron-common/messaging/electron-connection-handler'; -import { ElectronSecurityTokenService } from './electron-security-token-service'; import { TheiaBrowserWindowOptions, TheiaElectronWindow, TheiaElectronWindowFactory, WindowApplicationConfig } from './theia-electron-window'; import { TheiaMainApi } from './electron-api-main'; +import { ElectronMessagingContribution } from './messaging/electron-messaging-contribution'; +import { ElectronSecurityTokenService } from './electron-security-token-service'; +import { ElectronMessagingService } from './messaging/electron-messaging-service'; +import { ElectronConnectionHandler } from './messaging/electron-connection-handler'; const electronSecurityToken: ElectronSecurityToken = { value: v4() }; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -36,6 +36,7 @@ const electronSecurityToken: ElectronSecurityToken = { value: v4() }; export default new ContainerModule(bind => { bind(ElectronMainApplication).toSelf().inSingletonScope(); bind(ElectronMessagingContribution).toSelf().inSingletonScope(); + bind(ElectronMainApplicationContribution).toService(ElectronMessagingContribution); bind(ElectronSecurityToken).toConstantValue(electronSecurityToken); bind(ElectronSecurityTokenService).toSelf().inSingletonScope(); @@ -43,7 +44,6 @@ export default new ContainerModule(bind => { bindContributionProvider(bind, ElectronMessagingService.Contribution); bindContributionProvider(bind, ElectronMainApplicationContribution); - bind(ElectronMainApplicationContribution).toService(ElectronMessagingContribution); bind(TheiaMainApi).toSelf().inSingletonScope(); bind(ElectronMainApplicationContribution).toService(TheiaMainApi); diff --git a/packages/core/src/electron-common/messaging/electron-connection-handler.ts b/packages/core/src/electron-main/messaging/electron-connection-handler.ts similarity index 100% rename from packages/core/src/electron-common/messaging/electron-connection-handler.ts rename to packages/core/src/electron-main/messaging/electron-connection-handler.ts diff --git a/packages/core/src/electron-main/messaging/electron-messaging-contribution.ts b/packages/core/src/electron-main/messaging/electron-messaging-contribution.ts index 421d5d01497ad..0e862ed4ab0f6 100644 --- a/packages/core/src/electron-main/messaging/electron-messaging-contribution.ts +++ b/packages/core/src/electron-main/messaging/electron-messaging-contribution.ts @@ -16,15 +16,15 @@ import { WebContents } from '@theia/electron/shared/electron'; import { inject, injectable, named, postConstruct } from 'inversify'; -import { ContributionProvider } from '../../common/contribution-provider'; -import { MessagingContribution } from '../../node/messaging/messaging-contribution'; -import { ElectronConnectionHandler } from '../../electron-common/messaging/electron-connection-handler'; -import { ElectronMainApplicationContribution } from '../electron-main-application'; -import { ElectronMessagingService } from './electron-messaging-service'; +import { ConnectionHandlers } from '../../node/messaging/default-messaging-service'; import { AbstractChannel, Channel, ChannelMultiplexer, MessageProvider } from '../../common/message-rpc/channel'; -import { ConnectionHandler, Emitter, WriteBuffer } from '../../common'; +import { ConnectionHandler, ContributionProvider, Emitter, WriteBuffer } from '../../common'; import { Uint8ArrayReadBuffer, Uint8ArrayWriteBuffer } from '../../common/message-rpc/uint8-array-message-buffer'; import { TheiaRendererAPI } from '../electron-api-main'; +import { MessagingService } from '../../node'; +import { ElectronMessagingService } from './electron-messaging-service'; +import { ElectronConnectionHandler } from './electron-connection-handler'; +import { ElectronMainApplicationContribution } from '../electron-main-application'; /** * This component replicates the role filled by `MessagingContribution` but for Electron. @@ -36,37 +36,55 @@ import { TheiaRendererAPI } from '../electron-api-main'; @injectable() export class ElectronMessagingContribution implements ElectronMainApplicationContribution, ElectronMessagingService { - @inject(ContributionProvider) @named(ElectronMessagingService.Contribution) protected readonly messagingContributions: ContributionProvider; @inject(ContributionProvider) @named(ElectronConnectionHandler) protected readonly connectionHandlers: ContributionProvider; - protected readonly channelHandlers = new MessagingContribution.ConnectionHandlers(); + protected readonly channelHandlers = new ConnectionHandlers(); + /** * Each electron window has a main channel and its own multiplexer to route multiple client messages the same IPC connection. */ - protected readonly windowChannelMultiplexer = new Map(); + protected readonly openChannels = new Map(); @postConstruct() protected init(): void { TheiaRendererAPI.onIpcData((sender, data) => this.handleIpcEvent(sender, data)); } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ipcChannel(spec: string, callback: (params: any, channel: Channel) => void): void { + this.channelHandlers.push(spec, callback); + } + + onStart(): void { + for (const contribution of this.messagingContributions.getContributions()) { + contribution.configure(this); + } + for (const connectionHandler of this.connectionHandlers.getContributions()) { + this.channelHandlers.push(connectionHandler.path, (params, channel) => { + connectionHandler.onConnection(channel); + }); + } + } + protected handleIpcEvent(sender: WebContents, data: Uint8Array): void { // Get the multiplexer for a given window id try { - const windowChannelData = this.windowChannelMultiplexer.get(sender.id) ?? this.createWindowChannelData(sender); - windowChannelData!.channel.onMessageEmitter.fire(() => new Uint8ArrayReadBuffer(data)); + const windowChannel = this.openChannels.get(sender.id) ?? this.createWindowChannel(sender); + windowChannel.onMessageEmitter.fire(() => new Uint8ArrayReadBuffer(data)); } catch (error) { console.error('IPC: Failed to handle message', { error, data }); } } - // Creates a new multiplexer for a given sender/window - protected createWindowChannelData(sender: Electron.WebContents): { channel: ElectronWebContentChannel, multiplexer: ChannelMultiplexer } { - const mainChannel = this.createWindowMainChannel(sender); + // Creates a new channel for a given sender/window + protected createWindowChannel(sender: Electron.WebContents): ElectronWebContentChannel { + const mainChannel = new ElectronWebContentChannel(sender); + const multiplexer = new ChannelMultiplexer(mainChannel); multiplexer.onDidOpenChannel(openEvent => { const { channel, id } = openEvent; @@ -75,41 +93,26 @@ export class ElectronMessagingContribution implements ElectronMainApplicationCon channel.onClose(() => console.debug(`Closing channel on service path '${id}'.`)); } }); - - sender.once('did-navigate', () => this.disposeMultiplexer(sender.id, multiplexer, 'Window was refreshed')); // When refreshing the browser window. - sender.once('destroyed', () => this.disposeMultiplexer(sender.id, multiplexer, 'Window was closed')); // When closing the browser window. - const data = { channel: mainChannel, multiplexer }; - this.windowChannelMultiplexer.set(sender.id, data); - return data; + sender.once('did-navigate', () => this.deleteChannel(sender.id, 'Window was refreshed')); + sender.once('destroyed', () => this.deleteChannel(sender.id, 'Window was closed')); + this.openChannels.set(sender.id, mainChannel); + return mainChannel; } - /** - * Creates the main channel to a window. - * @param sender The window that the channel should be established to. - */ - protected createWindowMainChannel(sender: WebContents): ElectronWebContentChannel { - return new ElectronWebContentChannel(sender); - } - - protected disposeMultiplexer(windowId: number, multiplexer: ChannelMultiplexer, reason: string): void { - multiplexer.onUnderlyingChannelClose({ reason }); - this.windowChannelMultiplexer.delete(windowId); - } - - onStart(): void { - for (const contribution of this.messagingContributions.getContributions()) { - contribution.configure(this); - } - for (const connectionHandler of this.connectionHandlers.getContributions()) { - this.channelHandlers.push(connectionHandler.path, (params, channel) => { - connectionHandler.onConnection(channel); + protected deleteChannel(senderId: number, reason: string) { + const channel = this.openChannels.get(senderId); + if (channel) { + this.openChannels.delete(senderId); + channel.onCloseEmitter.fire({ + reason: reason }); } } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ipcChannel(spec: string, callback: (params: any, channel: Channel) => void): void { - this.channelHandlers.push(spec, callback); + protected readonly wsHandlers = new ConnectionHandlers(); + + registerConnectionHandler(spec: string, callback: (params: MessagingService.PathParams, channel: Channel) => void): void { + this.wsHandlers.push(spec, callback); } } diff --git a/packages/core/src/electron-main/messaging/electron-messaging-service.ts b/packages/core/src/electron-main/messaging/electron-messaging-service.ts index d2f5aab7c086f..ac4a0d2831ba1 100644 --- a/packages/core/src/electron-main/messaging/electron-messaging-service.ts +++ b/packages/core/src/electron-main/messaging/electron-messaging-service.ts @@ -23,6 +23,7 @@ export interface ElectronMessagingService { */ ipcChannel(path: string, callback: (params: ElectronMessagingService.PathParams, socket: Channel) => void): void; } + export namespace ElectronMessagingService { export interface PathParams { [name: string]: string diff --git a/packages/core/src/electron-main/theia-electron-window.ts b/packages/core/src/electron-main/theia-electron-window.ts index 5c473ffdab2f6..855939e1ca9dc 100644 --- a/packages/core/src/electron-main/theia-electron-window.ts +++ b/packages/core/src/electron-main/theia-electron-window.ts @@ -129,8 +129,9 @@ export class TheiaElectronWindow { }, this.toDispose); } - protected doCloseWindow(): void { + protected async doCloseWindow(): Promise { this.closeIsConfirmed = true; + await TheiaRendererAPI.sendAboutToClose(this._window.webContents); this._window.close(); } @@ -139,13 +140,13 @@ export class TheiaElectronWindow { } protected reload(): void { - this.handleStopRequest(() => { + this.handleStopRequest(async () => { this.applicationState = 'init'; this._window.reload(); }, StopReason.Reload); } - protected async handleStopRequest(onSafeCallback: () => unknown, reason: StopReason): Promise { + protected async handleStopRequest(onSafeCallback: () => Promise, reason: StopReason): Promise { // Only confirm close to windows that have loaded our frontend. // Both the windows's URL and the FS path of the `index.html` should be converted to the "same" format to be able to compare them. (#11226) // Notes: diff --git a/packages/core/src/electron-node/token/electron-token-messaging-contribution.ts b/packages/core/src/electron-node/token/electron-token-messaging-contribution.ts deleted file mode 100644 index 631ba2c99a2c6..0000000000000 --- a/packages/core/src/electron-node/token/electron-token-messaging-contribution.ts +++ /dev/null @@ -1,41 +0,0 @@ -// ***************************************************************************** -// Copyright (C) 2020 Ericsson 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 -// ***************************************************************************** - -import * as http from 'http'; -import { injectable, inject } from 'inversify'; -import { MessagingContribution } from '../../node/messaging/messaging-contribution'; -import { ElectronTokenValidator } from './electron-token-validator'; - -/** - * Override the browser MessagingContribution class to refuse connections that do not include a specific token. - * @deprecated since 1.8.0 - */ -@injectable() -export class ElectronMessagingContribution extends MessagingContribution { - - @inject(ElectronTokenValidator) - protected readonly tokenValidator: ElectronTokenValidator; - - /** - * Only allow token-bearers. - */ - protected override async allowConnect(request: http.IncomingMessage): Promise { - if (this.tokenValidator.allowRequest(request)) { - return super.allowConnect(request); - } - return false; - } -} diff --git a/packages/core/src/node/i18n/localization-server.ts b/packages/core/src/node/i18n/localization-server.ts index 539617ca6075e..77483c80c99ff 100644 --- a/packages/core/src/node/i18n/localization-server.ts +++ b/packages/core/src/node/i18n/localization-server.ts @@ -15,7 +15,7 @@ // ***************************************************************************** import { inject, injectable } from 'inversify'; -import { Localization } from 'src/common/i18n/localization'; +import { Localization } from '../../common/i18n/localization'; import { LocalizationServer } from '../../common/i18n/localization-server'; import { nls } from '../../common/nls'; import { Deferred } from '../../common/promise-util'; diff --git a/packages/core/src/node/messaging/default-messaging-service.ts b/packages/core/src/node/messaging/default-messaging-service.ts new file mode 100644 index 0000000000000..37836a846e650 --- /dev/null +++ b/packages/core/src/node/messaging/default-messaging-service.ts @@ -0,0 +1,129 @@ +// ***************************************************************************** +// Copyright (C) 2018 TypeFox 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 +// ***************************************************************************** + +import { injectable, inject, named, interfaces, Container } from 'inversify'; +import { ContributionProvider, ConnectionHandler, bindContributionProvider, servicesPath } from '../../common'; +import { MessagingService } from './messaging-service'; +import { ConnectionContainerModule } from './connection-container-module'; +import Route = require('route-parser'); +import { Channel, ChannelMultiplexer } from '../../common/message-rpc/channel'; +import { FrontendConnectionService } from './frontend-connection-service'; +import { BackendApplicationContribution } from '../backend-application'; + +export const MessagingContainer = Symbol('MessagingContainer'); +export const MainChannel = Symbol('MainChannel'); + +@injectable() +export class DefaultMessagingService implements MessagingService, BackendApplicationContribution { + @inject(MessagingContainer) + protected readonly container: interfaces.Container; + + @inject(FrontendConnectionService) + protected readonly frontendConnectionService: FrontendConnectionService; + + @inject(ContributionProvider) @named(ConnectionContainerModule) + protected readonly connectionModules: ContributionProvider; + + @inject(ContributionProvider) @named(MessagingService.Contribution) + protected readonly contributions: ContributionProvider; + + protected readonly channelHandlers = new ConnectionHandlers(); + + initialize(): void { + this.registerConnectionHandler(servicesPath, (_, socket) => this.handleConnection(socket)); + for (const contribution of this.contributions.getContributions()) { + contribution.configure(this); + } + } + + registerConnectionHandler(path: string, callback: (params: MessagingService.PathParams, mainChannel: Channel) => void): void { + this.frontendConnectionService.registerConnectionHandler(path, callback); + } + + registerChannelHandler(spec: string, callback: (params: MessagingService.PathParams, channel: Channel) => void): void { + this.channelHandlers.push(spec, (params, channel) => callback(params, channel)); + } + + protected handleConnection(channel: Channel): void { + const multiplexer = new ChannelMultiplexer(channel); + const channelHandlers = this.getConnectionChannelHandlers(channel); + multiplexer.onDidOpenChannel(event => { + if (channelHandlers.route(event.id, event.channel)) { + console.debug(`Opening channel for service path '${event.id}'.`); + event.channel.onClose(() => console.info(`Closing channel on service path '${event.id}'.`)); + } + }); + } + + protected createMainChannelContainer(socket: Channel): Container { + const connectionContainer: Container = this.container.createChild() as Container; + connectionContainer.bind(MainChannel).toConstantValue(socket); + return connectionContainer; + } + + protected getConnectionChannelHandlers(socket: Channel): ConnectionHandlers { + const connectionContainer = this.createMainChannelContainer(socket); + bindContributionProvider(connectionContainer, ConnectionHandler); + connectionContainer.load(...this.connectionModules.getContributions()); + const connectionChannelHandlers = new ConnectionHandlers(this.channelHandlers); + const connectionHandlers = connectionContainer.getNamed>(ContributionProvider, ConnectionHandler); + for (const connectionHandler of connectionHandlers.getContributions(true)) { + connectionChannelHandlers.push(connectionHandler.path, (_, channel) => { + connectionHandler.onConnection(channel); + }); + } + return connectionChannelHandlers; + } + +} + +export class ConnectionHandlers { + protected readonly handlers: ((path: string, connection: T) => string | false)[] = []; + + constructor( + protected readonly parent?: ConnectionHandlers + ) { } + + push(spec: string, callback: (params: MessagingService.PathParams, connection: T) => void): void { + const route = new Route(spec); + const handler = (path: string, channel: T): string | false => { + const params = route.match(path); + if (!params) { + return false; + } + callback(params, channel); + return route.reverse(params); + }; + this.handlers.push(handler); + } + + route(path: string, connection: T): string | false { + for (const handler of this.handlers) { + try { + const result = handler(path, connection); + if (result) { + return result; + } + } catch (e) { + console.error(e); + } + } + if (this.parent) { + return this.parent.route(path, connection); + } + return false; + } +} diff --git a/packages/core/src/node/messaging/frontend-connection-service.ts b/packages/core/src/node/messaging/frontend-connection-service.ts new file mode 100644 index 0000000000000..5657e137b3512 --- /dev/null +++ b/packages/core/src/node/messaging/frontend-connection-service.ts @@ -0,0 +1,24 @@ +// ***************************************************************************** +// Copyright (C) 2023 STMicroelectronics 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 + +import { Channel } from '../../common/message-rpc/'; +import { MessagingService } from './messaging-service'; + +export const FrontendConnectionService = Symbol('FrontendConnectionService'); + +export interface FrontendConnectionService { + registerConnectionHandler(path: string, callback: (params: MessagingService.PathParams, mainChannel: Channel) => void): void; +} + diff --git a/packages/core/src/node/messaging/messaging-backend-module.ts b/packages/core/src/node/messaging/messaging-backend-module.ts index 4f549efb75922..aceed3e3fa180 100644 --- a/packages/core/src/node/messaging/messaging-backend-module.ts +++ b/packages/core/src/node/messaging/messaging-backend-module.ts @@ -15,23 +15,40 @@ // ***************************************************************************** import { ContainerModule } from 'inversify'; -import { bindContributionProvider } from '../../common'; -import { BackendApplicationContribution } from '../backend-application'; -import { MessagingContribution, MessagingContainer } from './messaging-contribution'; +import { ConnectionHandler, RpcConnectionHandler, bindContributionProvider } from '../../common'; +// import { BackendApplicationContribution } from '../backend-application'; +import { DefaultMessagingService, MessagingContainer } from './default-messaging-service'; import { ConnectionContainerModule } from './connection-container-module'; import { MessagingService } from './messaging-service'; import { MessagingListener, MessagingListenerContribution } from './messaging-listeners'; +import { FrontendConnectionService } from './frontend-connection-service'; +import { BackendApplicationContribution } from '../backend-application'; +import { connectionCloseServicePath } from '../../common/messaging/connection-management'; +import { WebsocketFrontendConnectionService } from './websocket-frontend-connection-service'; +import { WebsocketEndpoint } from './websocket-endpoint'; export const messagingBackendModule = new ContainerModule(bind => { bindContributionProvider(bind, ConnectionContainerModule); bindContributionProvider(bind, MessagingService.Contribution); - bind(MessagingService.Identifier).to(MessagingContribution).inSingletonScope(); - bind(MessagingContribution).toDynamicValue(({ container }) => { - const child = container.createChild(); - child.bind(MessagingContainer).toConstantValue(container); - return child.get(MessagingService.Identifier); - }).inSingletonScope(); - bind(BackendApplicationContribution).toService(MessagingContribution); + bind(DefaultMessagingService).toSelf().inSingletonScope(); + bind(MessagingService.Identifier).toService(DefaultMessagingService); + bind(BackendApplicationContribution).toService(DefaultMessagingService); + bind(MessagingContainer).toDynamicValue(({ container }) => container).inSingletonScope(); + bind(WebsocketEndpoint).toSelf().inSingletonScope(); + bind(BackendApplicationContribution).toService(WebsocketEndpoint); + bind(WebsocketFrontendConnectionService).toSelf().inSingletonScope(); + bind(FrontendConnectionService).toService(WebsocketFrontendConnectionService); bind(MessagingListener).toSelf().inSingletonScope(); bindContributionProvider(bind, MessagingListenerContribution); + + bind(ConnectionHandler).toDynamicValue(context => { + const connectionService = context.container.get(FrontendConnectionService); + return new RpcConnectionHandler(connectionCloseServicePath, () => { + return { + markForClose: (channelId: string) => { + connectionService.markForClose(channelId); + } + }; + }); + }).inSingletonScope(); }); diff --git a/packages/core/src/node/messaging/messaging-contribution.ts b/packages/core/src/node/messaging/messaging-contribution.ts deleted file mode 100644 index 2e628a1795762..0000000000000 --- a/packages/core/src/node/messaging/messaging-contribution.ts +++ /dev/null @@ -1,197 +0,0 @@ -// ***************************************************************************** -// Copyright (C) 2018 TypeFox 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 -// ***************************************************************************** - -import * as http from 'http'; -import * as https from 'https'; -import { Server, Socket } from 'socket.io'; -import { injectable, inject, named, postConstruct, interfaces, Container } from 'inversify'; -import { ContributionProvider, ConnectionHandler, bindContributionProvider } from '../../common'; -import { IWebSocket, WebSocketChannel } from '../../common/messaging/web-socket-channel'; -import { BackendApplicationContribution } from '../backend-application'; -import { MessagingService } from './messaging-service'; -import { ConnectionContainerModule } from './connection-container-module'; -import Route = require('route-parser'); -import { WsRequestValidator } from '../ws-request-validators'; -import { MessagingListener } from './messaging-listeners'; -import { Channel, ChannelMultiplexer } from '../../common/message-rpc/channel'; - -export const MessagingContainer = Symbol('MessagingContainer'); - -@injectable() -export class MessagingContribution implements BackendApplicationContribution, MessagingService { - - @inject(MessagingContainer) - protected readonly container: interfaces.Container; - - @inject(ContributionProvider) @named(ConnectionContainerModule) - protected readonly connectionModules: ContributionProvider; - - @inject(ContributionProvider) @named(MessagingService.Contribution) - protected readonly contributions: ContributionProvider; - - @inject(WsRequestValidator) - protected readonly wsRequestValidator: WsRequestValidator; - - @inject(MessagingListener) - protected readonly messagingListener: MessagingListener; - - protected readonly wsHandlers = new MessagingContribution.ConnectionHandlers(); - protected readonly channelHandlers = new MessagingContribution.ConnectionHandlers(); - - @postConstruct() - protected init(): void { - this.ws(WebSocketChannel.wsPath, (_, socket) => this.handleChannels(socket)); - for (const contribution of this.contributions.getContributions()) { - contribution.configure(this); - } - } - - wsChannel(spec: string, callback: (params: MessagingService.PathParams, channel: Channel) => void): void { - this.channelHandlers.push(spec, (params, channel) => callback(params, channel)); - } - - ws(spec: string, callback: (params: MessagingService.PathParams, socket: Socket) => void): void { - this.wsHandlers.push(spec, callback); - } - - protected checkAliveTimeout = 30000; // 30 seconds - protected maxHttpBufferSize = 1e8; // 100 MB - - onStart(server: http.Server | https.Server): void { - const socketServer = new Server(server, { - pingInterval: this.checkAliveTimeout, - pingTimeout: this.checkAliveTimeout * 2, - maxHttpBufferSize: this.maxHttpBufferSize - }); - // Accept every namespace by using /.*/ - socketServer.of(/.*/).on('connection', async socket => { - const request = socket.request; - // Socket.io strips the `origin` header of the incoming request - // We provide a `fix-origin` header in the `WebSocketConnectionProvider` - request.headers.origin = request.headers['fix-origin'] as string; - if (await this.allowConnect(socket.request)) { - await this.handleConnection(socket); - this.messagingListener.onDidWebSocketUpgrade(socket.request, socket); - } else { - socket.disconnect(true); - } - }); - } - - protected async handleConnection(socket: Socket): Promise { - const pathname = socket.nsp.name; - if (pathname && !this.wsHandlers.route(pathname, socket)) { - console.error('Cannot find a ws handler for the path: ' + pathname); - } - } - - protected async allowConnect(request: http.IncomingMessage): Promise { - try { - return this.wsRequestValidator.allowWsUpgrade(request); - } catch (e) { - return false; - } - } - - protected handleChannels(socket: Socket): void { - const socketChannel = new WebSocketChannel(this.toIWebSocket(socket)); - const multiplexer = new ChannelMultiplexer(socketChannel); - const channelHandlers = this.getConnectionChannelHandlers(socket); - multiplexer.onDidOpenChannel(event => { - if (channelHandlers.route(event.id, event.channel)) { - console.debug(`Opening channel for service path '${event.id}'.`); - event.channel.onClose(() => console.debug(`Closing channel on service path '${event.id}'.`)); - } - }); - } - - protected toIWebSocket(socket: Socket): IWebSocket { - return { - close: () => { - socket.removeAllListeners('disconnect'); - socket.removeAllListeners('error'); - socket.removeAllListeners('message'); - socket.disconnect(); - }, - isConnected: () => socket.connected, - onClose: cb => socket.on('disconnect', reason => cb(reason)), - onError: cb => socket.on('error', error => cb(error)), - onMessage: cb => socket.on('message', data => cb(data)), - send: message => socket.emit('message', message) - }; - } - - protected createSocketContainer(socket: Socket): Container { - const connectionContainer: Container = this.container.createChild() as Container; - connectionContainer.bind(Socket).toConstantValue(socket); - return connectionContainer; - } - - protected getConnectionChannelHandlers(socket: Socket): MessagingContribution.ConnectionHandlers { - const connectionContainer = this.createSocketContainer(socket); - bindContributionProvider(connectionContainer, ConnectionHandler); - connectionContainer.load(...this.connectionModules.getContributions()); - const connectionChannelHandlers = new MessagingContribution.ConnectionHandlers(this.channelHandlers); - const connectionHandlers = connectionContainer.getNamed>(ContributionProvider, ConnectionHandler); - for (const connectionHandler of connectionHandlers.getContributions(true)) { - connectionChannelHandlers.push(connectionHandler.path, (_, channel) => { - connectionHandler.onConnection(channel); - }); - } - return connectionChannelHandlers; - } - -} - -export namespace MessagingContribution { - export class ConnectionHandlers { - protected readonly handlers: ((path: string, connection: T) => string | false)[] = []; - - constructor( - protected readonly parent?: ConnectionHandlers - ) { } - - push(spec: string, callback: (params: MessagingService.PathParams, connection: T) => void): void { - const route = new Route(spec); - const handler = (path: string, channel: T): string | false => { - const params = route.match(path); - if (!params) { - return false; - } - callback(params, channel); - return route.reverse(params); - }; - this.handlers.push(handler); - } - - route(path: string, connection: T): string | false { - for (const handler of this.handlers) { - try { - const result = handler(path, connection); - if (result) { - return result; - } - } catch (e) { - console.error(e); - } - } - if (this.parent) { - return this.parent.route(path, connection); - } - return false; - } - } -} diff --git a/packages/core/src/node/messaging/messaging-service.ts b/packages/core/src/node/messaging/messaging-service.ts index 7d4ad45432e4e..38fa34643e2f0 100644 --- a/packages/core/src/node/messaging/messaging-service.ts +++ b/packages/core/src/node/messaging/messaging-service.ts @@ -14,7 +14,6 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { Socket } from 'socket.io'; import { Channel } from '../../common/message-rpc/channel'; export interface MessagingService { @@ -22,7 +21,7 @@ export interface MessagingService { * Accept a web socket channel on the given path. * A path supports the route syntax: https://github.com/rcs/route-parser#what-can-i-use-in-my-routes. */ - wsChannel(path: string, callback: (params: MessagingService.PathParams, channel: Channel) => void): void; + registerChannelHandler(path: string, handler: (params: MessagingService.PathParams, channel: Channel) => void): void; /** * Accept a web socket connection on the given path. * A path supports the route syntax: https://github.com/rcs/route-parser#what-can-i-use-in-my-routes. @@ -31,8 +30,9 @@ export interface MessagingService { * Prefer using web socket channels over establishing new web socket connection. Clients can handle only limited amount of web sockets * and excessive amount can cause performance degradation. All web socket channels share a single web socket connection. */ - ws(path: string, callback: (params: MessagingService.PathParams, socket: Socket) => void): void; + registerConnectionHandler(path: string, callback: (params: MessagingService.PathParams, mainChannel: Channel) => void): void; } + export namespace MessagingService { /** Inversify container identifier for the `MessagingService` component. */ export const Identifier = Symbol('MessagingService'); diff --git a/packages/core/src/node/messaging/test/test-web-socket-channel.ts b/packages/core/src/node/messaging/test/test-web-socket-channel.ts index 65c4ed1e641e9..bc4e16fc532cc 100644 --- a/packages/core/src/node/messaging/test/test-web-socket-channel.ts +++ b/packages/core/src/node/messaging/test/test-web-socket-channel.ts @@ -17,9 +17,10 @@ import * as http from 'http'; import * as https from 'https'; import { AddressInfo } from 'net'; -import { io, Socket } from 'socket.io-client'; +import { io } from 'socket.io-client'; import { Channel, ChannelMultiplexer } from '../../../common/message-rpc/channel'; -import { IWebSocket, WebSocketChannel } from '../../../common/messaging/web-socket-channel'; +import { servicesPath } from '../../../common'; +import { WebSocketChannel } from '../../../common/messaging/web-socket-channel'; export class TestWebSocketChannelSetup { public readonly multiplexer: ChannelMultiplexer; @@ -29,8 +30,8 @@ export class TestWebSocketChannelSetup { server: http.Server | https.Server, path: string }) { - const socket = io(`ws://localhost:${(server.address() as AddressInfo).port}${WebSocketChannel.wsPath}`); - this.channel = new WebSocketChannel(toIWebSocket(socket)); + const socket = io(`ws://localhost:${(server.address() as AddressInfo).port}${servicesPath}`); + this.channel = new WebSocketChannel(socket); this.multiplexer = new ChannelMultiplexer(this.channel); socket.on('connect', () => { this.multiplexer.open(path); @@ -38,19 +39,3 @@ export class TestWebSocketChannelSetup { socket.connect(); } } - -function toIWebSocket(socket: Socket): IWebSocket { - return { - close: () => { - socket.removeAllListeners('disconnect'); - socket.removeAllListeners('error'); - socket.removeAllListeners('message'); - socket.close(); - }, - isConnected: () => socket.connected, - onClose: cb => socket.on('disconnect', reason => cb(reason)), - onError: cb => socket.on('error', reason => cb(reason)), - onMessage: cb => socket.on('message', data => cb(data)), - send: message => socket.emit('message', message) - }; -} diff --git a/packages/core/src/node/messaging/websocket-endpoint.ts b/packages/core/src/node/messaging/websocket-endpoint.ts new file mode 100644 index 0000000000000..187603c77db6e --- /dev/null +++ b/packages/core/src/node/messaging/websocket-endpoint.ts @@ -0,0 +1,79 @@ +// ***************************************************************************** +// Copyright (C) 2023 STMicroelectronics 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 + +import { MessagingService } from './messaging-service'; +import * as http from 'http'; +import * as https from 'https'; +import { inject, injectable } from 'inversify'; +import { Server, Socket } from 'socket.io'; +import { WsRequestValidator } from '../ws-request-validators'; +import { MessagingListener } from './messaging-listeners'; +import { ConnectionHandlers } from './default-messaging-service'; +import { BackendApplicationContribution } from '../backend-application'; + +@injectable() +export class WebsocketEndpoint implements BackendApplicationContribution { + @inject(WsRequestValidator) + protected readonly wsRequestValidator: WsRequestValidator; + + @inject(MessagingListener) + protected readonly messagingListener: MessagingListener; + + protected checkAliveTimeout = 30000; // 30 seconds + protected maxHttpBufferSize = 1e8; // 100 MB + + protected readonly wsHandlers = new ConnectionHandlers(); + + registerConnectionHandler(spec: string, callback: (params: MessagingService.PathParams, socket: Socket) => void): void { + this.wsHandlers.push(spec, callback); + } + + onStart(server: http.Server | https.Server): void { + const socketServer = new Server(server, { + pingInterval: this.checkAliveTimeout, + pingTimeout: this.checkAliveTimeout * 2, + maxHttpBufferSize: this.maxHttpBufferSize + }); + // Accept every namespace by using /.*/ + socketServer.of(/.*/).on('connection', async socket => { + const request = socket.request; + // Socket.io strips the `origin` header of the incoming request + // We provide a `fix-origin` header in the `WebSocketConnectionProvider` + request.headers.origin = request.headers['fix-origin'] as string; + if (await this.allowConnect(socket.request)) { + await this.handleConnection(socket); + this.messagingListener.onDidWebSocketUpgrade(socket.request, socket); + } else { + socket.disconnect(true); + } + }); + } + + protected async allowConnect(request: http.IncomingMessage): Promise { + try { + return this.wsRequestValidator.allowWsUpgrade(request); + } catch (e) { + return false; + } + } + + protected async handleConnection(socket: Socket): Promise { + const pathname = socket.nsp.name; + if (pathname && !this.wsHandlers.route(pathname, socket)) { + console.error('Cannot find a ws handler for the path: ' + pathname); + } + } +} + diff --git a/packages/core/src/node/messaging/websocket-frontend-connection-service.ts b/packages/core/src/node/messaging/websocket-frontend-connection-service.ts new file mode 100644 index 0000000000000..7c602b86ad481 --- /dev/null +++ b/packages/core/src/node/messaging/websocket-frontend-connection-service.ts @@ -0,0 +1,173 @@ +// ***************************************************************************** +// Copyright (C) 2023 STMicroelectronics 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 + +import { Channel, WriteBuffer } from '../../common/message-rpc'; +import { MessagingService } from './messaging-service'; +import { inject, injectable, postConstruct } from 'inversify'; +import { Socket } from 'socket.io'; +import { ConnectionHandlers } from './default-messaging-service'; +import { SocketWriteBuffer } from '../../common/messaging/socket-write-buffer'; +import { FrontendConnectionService } from './frontend-connection-service'; +import { AbstractChannel } from '../../common/message-rpc/channel'; +import { Uint8ArrayReadBuffer, Uint8ArrayWriteBuffer } from '../../common/message-rpc/uint8-array-message-buffer'; +import { BackendApplicationConfigProvider } from '../backend-application-config-provider'; +import { WebsocketEndpoint } from './websocket-endpoint'; +import { ConnectionManagementMessages } from '../../common/messaging/connection-management'; +import { Disposable, DisposableCollection } from '../../common'; + +@injectable() +export class WebsocketFrontendConnectionService implements FrontendConnectionService { + + @inject(WebsocketEndpoint) + protected readonly websocketServer: WebsocketEndpoint; + + protected readonly wsHandlers = new ConnectionHandlers(); + protected readonly connectionsByFrontend = new Map(); + protected readonly closeTimeouts = new Map(); + protected readonly channelsMarkedForClose = new Set(); + + @postConstruct() + init(): void { + } + + registerConnectionHandler(spec: string, callback: (params: MessagingService.PathParams, channel: Channel) => void): void { + this.websocketServer.registerConnectionHandler(spec, (params, socket) => this.handleConnection(socket, channel => callback(params, channel))); + } + + protected async handleConnection(socket: Socket, channelCreatedHandler: (channel: Channel) => void): Promise { + let reconnectListener: (frontEndId: string) => void; + const initialConnectListener = (frontEndId: string) => { + socket.off(ConnectionManagementMessages.INITIAL_CONNECT, initialConnectListener); + socket.off(ConnectionManagementMessages.RECONNECT, reconnectListener); + if (this.connectionsByFrontend.has(frontEndId)) { + this.closeConnection(frontEndId, 'reconnecting same front end'); + } + channelCreatedHandler(this.createConnection(socket, frontEndId)); + socket.emit(ConnectionManagementMessages.INITIAL_CONNECT); + }; + + reconnectListener = (frontEndId: string) => { + socket.off(ConnectionManagementMessages.INITIAL_CONNECT, initialConnectListener); + socket.off(ConnectionManagementMessages.RECONNECT, reconnectListener); + const channel = this.connectionsByFrontend.get(frontEndId); + if (channel) { + console.info(`Reconnecting to front end ${frontEndId}`); + socket.emit(ConnectionManagementMessages.RECONNECT, true); + channel.connect(socket); + const pendingTimeout = this.closeTimeouts.get(frontEndId); + clearTimeout(pendingTimeout); + this.closeTimeouts.delete(frontEndId); + } else { + console.info(`Reconnecting failed for ${frontEndId}`); + socket.emit(ConnectionManagementMessages.RECONNECT, false); + } + }; + socket.on(ConnectionManagementMessages.INITIAL_CONNECT, initialConnectListener); + socket.on(ConnectionManagementMessages.RECONNECT, reconnectListener); + } + + protected closeConnection(frontEndId: string, reason: string): void { + console.info(`closing connection for ${frontEndId}`); + const connection = this.connectionsByFrontend.get(frontEndId)!; // not called when no connection is present + + this.connectionsByFrontend.delete(frontEndId); + + const pendingTimeout = this.closeTimeouts.get(frontEndId); + clearTimeout(pendingTimeout); + this.closeTimeouts.delete(frontEndId); + + connection.onCloseEmitter.fire({ reason }); + connection.close(); + } + + protected createConnection(socket: Socket, frontEndId: string): Channel { + console.info(`creating connection for ${frontEndId}`); + const channel = new ReconnectableSocketChannel(); + channel.connect(socket); + + socket.on('disconnect', evt => { + console.info('socked closed'); + channel.disconnect(); + const timeout = BackendApplicationConfigProvider.get().frontendConnectionTimeout; + const isMarkedForClose = this.channelsMarkedForClose.delete(frontEndId); + if (timeout === 0 || isMarkedForClose) { + this.closeConnection(frontEndId, evt); + } else if (timeout > 0) { + console.info(`setting close timeout for id ${frontEndId} to ${timeout}`); + const handle = setTimeout(() => { + this.closeConnection(frontEndId, evt); + }, timeout); + this.closeTimeouts.set(frontEndId, handle); + } else { + // timeout < 0: never close the back end + } + }); + + this.connectionsByFrontend.set(frontEndId, channel); + return channel; + } + + markForClose(channelId: string) { + this.channelsMarkedForClose.add(channelId); + } +} + +class ReconnectableSocketChannel extends AbstractChannel { + private socket: Socket | undefined; + private socketBuffer = new SocketWriteBuffer(); + private disposables = new DisposableCollection(); + + connect(socket: Socket): void { + this.disposables = new DisposableCollection(); + this.socket = socket; + const errorHandler = (err: Error) => { + this.onErrorEmitter.fire(err); + }; + this.disposables.push(Disposable.create(() => { + socket.off('error', errorHandler); + })); + socket.on('error', errorHandler); + + const dataListener = (data: any) => { + // In the browser context socketIO receives binary messages as ArrayBuffers. + // So we have to convert them to a Uint8Array before delegating the message to the read buffer. + const buffer = data instanceof ArrayBuffer ? new Uint8Array(data) : data; + this.onMessageEmitter.fire(() => new Uint8ArrayReadBuffer(buffer)); + } + this.disposables.push(Disposable.create(() => { + socket.off('message', dataListener); + })); + socket.on('message', dataListener); + this.socketBuffer.flush(socket); + } + + disconnect() { + this.disposables.dispose(); + this.socket = undefined; + } + + override getWriteBuffer(): WriteBuffer { + const writeBuffer = new Uint8ArrayWriteBuffer(); + writeBuffer.onCommit(data => { + if (this.socket?.connected) { + this.socket.send(data); + } else { + this.socketBuffer.buffer(data); + } + }); + return writeBuffer; + } +} + diff --git a/packages/debug/src/browser/debug-session-contribution.ts b/packages/debug/src/browser/debug-session-contribution.ts index 04d36c3c645fb..907e64e531aaf 100644 --- a/packages/debug/src/browser/debug-session-contribution.ts +++ b/packages/debug/src/browser/debug-session-contribution.ts @@ -19,7 +19,6 @@ import { MessageClient } from '@theia/core/lib/common'; import { LabelProvider } from '@theia/core/lib/browser'; import { EditorManager } from '@theia/editor/lib/browser'; import { TerminalService } from '@theia/terminal/lib/browser/base/terminal-service'; -import { WebSocketConnectionProvider } from '@theia/core/lib/browser/messaging/ws-connection-provider'; import { DebugSession } from './debug-session'; import { BreakpointManager } from './breakpoint/breakpoint-manager'; import { DebugConfigurationSessionOptions, DebugSessionOptions } from './debug-session-options'; @@ -31,6 +30,7 @@ import { ContributionProvider } from '@theia/core/lib/common/contribution-provid import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { DebugContribution } from './debug-contribution'; import { WorkspaceService } from '@theia/workspace/lib/browser'; +import { RemoteConnectionProvider, ServiceConnectionProvider } from '@theia/core/lib/browser/messaging/service-connection-provider'; /** * DebugSessionContribution symbol for DI. @@ -95,8 +95,8 @@ export interface DebugSessionFactory { @injectable() export class DefaultDebugSessionFactory implements DebugSessionFactory { - @inject(WebSocketConnectionProvider) - protected readonly connectionProvider: WebSocketConnectionProvider; + @inject(RemoteConnectionProvider) + protected readonly connectionProvider: ServiceConnectionProvider; @inject(TerminalService) protected readonly terminalService: TerminalService; @inject(EditorManager) @@ -122,9 +122,9 @@ export class DefaultDebugSessionFactory implements DebugSessionFactory { const connection = new DebugSessionConnection( sessionId, () => new Promise(resolve => - this.connectionProvider.openChannel(`${DebugAdapterPath}/${sessionId}`, wsChannel => { + this.connectionProvider.listen(`${DebugAdapterPath}/${sessionId}`, (_, wsChannel) => { resolve(new ForwardingDebugChannel(wsChannel)); - }, { reconnecting: false }) + }, false) ), this.getTraceOutputChannel()); return new DebugSession( diff --git a/packages/debug/src/node/debug-adapter-session-manager.ts b/packages/debug/src/node/debug-adapter-session-manager.ts index ff4290e73eae7..4f5501b120038 100644 --- a/packages/debug/src/node/debug-adapter-session-manager.ts +++ b/packages/debug/src/node/debug-adapter-session-manager.ts @@ -37,7 +37,7 @@ export class DebugAdapterSessionManager implements MessagingService.Contribution protected readonly debugAdapterFactory: DebugAdapterFactory; configure(service: MessagingService): void { - service.wsChannel(`${DebugAdapterPath}/:id`, ({ id }: { id: string }, wsChannel) => { + service.registerChannelHandler(`${DebugAdapterPath}/:id`, ({ id }: { id: string }, wsChannel) => { const session = this.find(id); if (!session) { wsChannel.close(); diff --git a/packages/plugin-ext/src/common/proxy-handler.ts b/packages/plugin-ext/src/common/proxy-handler.ts index 75beca868c6ec..dcfe7d4a2e722 100644 --- a/packages/plugin-ext/src/common/proxy-handler.ts +++ b/packages/plugin-ext/src/common/proxy-handler.ts @@ -48,11 +48,13 @@ export class ClientProxyHandler implements ProxyHandler { } private initializeRpc(): void { + // we need to set the flag to true before waiting for the channel provider. Otherwise `get` might + // get called again and we'll try to open a channel more than once + this.isRpcInitialized = true; const clientOptions: RpcProtocolOptions = { encoder: this.encoder, decoder: this.decoder, mode: 'clientOnly' }; this.channelProvider().then(channel => { const rpc = new RpcProtocol(channel, undefined, clientOptions); this.rpcDeferred.resolve(rpc); - this.isRpcInitialized = true; }); } diff --git a/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts index 13679f53bd200..bb64b318bc89c 100644 --- a/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts +++ b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts @@ -541,6 +541,7 @@ export class HostedPluginSupport { if (toDisconnect.disposed) { return undefined; } + this.activationEvents.forEach(event => manager!.$activateByEvent(event)); } return manager; } diff --git a/packages/terminal/src/browser/terminal-widget-impl.ts b/packages/terminal/src/browser/terminal-widget-impl.ts index 5cb3dd8755d22..93bf528ca0cc4 100644 --- a/packages/terminal/src/browser/terminal-widget-impl.ts +++ b/packages/terminal/src/browser/terminal-widget-impl.ts @@ -19,7 +19,7 @@ import { FitAddon } from 'xterm-addon-fit'; import { inject, injectable, named, postConstruct } from '@theia/core/shared/inversify'; import { ContributionProvider, Disposable, Event, Emitter, ILogger, DisposableCollection, Channel, OS } from '@theia/core'; import { - Widget, Message, WebSocketConnectionProvider, StatefulWidget, isFirefox, MessageLoop, KeyCode, codicon, ExtractableWidget, ContextMenuRenderer + Widget, Message, StatefulWidget, isFirefox, MessageLoop, KeyCode, codicon, ExtractableWidget, ContextMenuRenderer } from '@theia/core/lib/browser'; import { isOSX } from '@theia/core/lib/common'; import { WorkspaceService } from '@theia/workspace/lib/browser'; @@ -46,6 +46,7 @@ import debounce = require('p-debounce'); import { MarkdownString, MarkdownStringImpl } from '@theia/core/lib/common/markdown-rendering/markdown-string'; import { EnhancedPreviewWidget } from '@theia/core/lib/browser/widgets/enhanced-preview-widget'; import { MarkdownRenderer, MarkdownRendererFactory } from '@theia/core/lib/browser/markdown-rendering/markdown-renderer'; +import { RemoteConnectionProvider, ServiceConnectionProvider } from '@theia/core/lib/browser/messaging/service-connection-provider'; export const TERMINAL_WIDGET_FACTORY_ID = 'terminal'; @@ -88,7 +89,7 @@ export class TerminalWidgetImpl extends TerminalWidget implements StatefulWidget override lastCwd = new URI(); @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; - @inject(WebSocketConnectionProvider) protected readonly webSocketConnectionProvider: WebSocketConnectionProvider; + @inject(RemoteConnectionProvider) protected readonly conectionProvider: ServiceConnectionProvider; @inject(TerminalWidgetOptions) options: TerminalWidgetOptions; @inject(ShellTerminalServerProxy) protected readonly shellTerminalServer: ShellTerminalServerProxy; @inject(TerminalWatcher) protected readonly terminalWatcher: TerminalWatcher; @@ -629,9 +630,9 @@ export class TerminalWidgetImpl extends TerminalWidget implements StatefulWidget this.toDisposeOnConnect.dispose(); this.toDispose.push(this.toDisposeOnConnect); const waitForConnection = this.waitForConnection = new Deferred(); - this.webSocketConnectionProvider.listen({ - path: `${terminalsPath}/${this.terminalId}`, - onConnection: connection => { + this.conectionProvider.listen( + `${terminalsPath}/${this.terminalId}`, + (path, connection) => { connection.onMessage(e => { this.write(e().readString()); }); @@ -652,8 +653,7 @@ export class TerminalWidgetImpl extends TerminalWidget implements StatefulWidget if (waitForConnection) { waitForConnection.resolve(connection); } - } - }, { reconnecting: false }); + }, false); } protected async reconnectTerminalProcess(): Promise { if (this.options.isPseudoTerminal) { diff --git a/packages/terminal/src/node/terminal-backend-contribution.ts b/packages/terminal/src/node/terminal-backend-contribution.ts index d538ff697bd62..07649685cf377 100644 --- a/packages/terminal/src/node/terminal-backend-contribution.ts +++ b/packages/terminal/src/node/terminal-backend-contribution.ts @@ -32,7 +32,7 @@ export class TerminalBackendContribution implements MessagingService.Contributio protected readonly logger: ILogger; configure(service: MessagingService): void { - service.wsChannel(`${terminalsPath}/:id`, (params: { id: string }, channel) => { + service.registerChannelHandler(`${terminalsPath}/:id`, (params: { id: string }, channel) => { const id = parseInt(params.id, 10); const termProcess = this.processManager.get(id); if (termProcess instanceof TerminalProcess) {