@hydranium/client-theia 1.0.0-next.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +113 -0
- package/lib/browser/browser-capture.d.ts +21 -0
- package/lib/browser/browser-capture.d.ts.map +1 -0
- package/lib/browser/browser-capture.js +61 -0
- package/lib/browser/browser-capture.js.map +1 -0
- package/lib/browser/channel-logger.d.ts +87 -0
- package/lib/browser/channel-logger.d.ts.map +1 -0
- package/lib/browser/channel-logger.js +149 -0
- package/lib/browser/channel-logger.js.map +1 -0
- package/lib/browser/index.d.ts +13 -0
- package/lib/browser/index.d.ts.map +1 -0
- package/lib/browser/index.js +30 -0
- package/lib/browser/index.js.map +1 -0
- package/lib/browser/log-level-preference.d.ts +82 -0
- package/lib/browser/log-level-preference.d.ts.map +1 -0
- package/lib/browser/log-level-preference.js +146 -0
- package/lib/browser/log-level-preference.js.map +1 -0
- package/lib/browser/memory-diagnostics-contribution.d.ts +105 -0
- package/lib/browser/memory-diagnostics-contribution.d.ts.map +1 -0
- package/lib/browser/memory-diagnostics-contribution.js +229 -0
- package/lib/browser/memory-diagnostics-contribution.js.map +1 -0
- package/lib/index.d.ts +10 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +11 -0
- package/lib/index.js.map +1 -0
- package/lib/node/abstract-socket-forwarding-connection-handler.d.ts +96 -0
- package/lib/node/abstract-socket-forwarding-connection-handler.d.ts.map +1 -0
- package/lib/node/abstract-socket-forwarding-connection-handler.js +224 -0
- package/lib/node/abstract-socket-forwarding-connection-handler.js.map +1 -0
- package/lib/node/index.d.ts +10 -0
- package/lib/node/index.d.ts.map +1 -0
- package/lib/node/index.js +26 -0
- package/lib/node/index.js.map +1 -0
- package/lib/testing/index.d.ts +11 -0
- package/lib/testing/index.d.ts.map +1 -0
- package/lib/testing/index.js +29 -0
- package/lib/testing/index.js.map +1 -0
- package/lib/testing/stub-inversify-context.d.ts +15 -0
- package/lib/testing/stub-inversify-context.d.ts.map +1 -0
- package/lib/testing/stub-inversify-context.js +32 -0
- package/lib/testing/stub-inversify-context.js.map +1 -0
- package/lib/testing/stub-output-channel.d.ts +25 -0
- package/lib/testing/stub-output-channel.d.ts.map +1 -0
- package/lib/testing/stub-output-channel.js +33 -0
- package/lib/testing/stub-output-channel.js.map +1 -0
- package/package.json +95 -0
- package/src/browser/browser-capture.ts +75 -0
- package/src/browser/channel-logger.ts +160 -0
- package/src/browser/index.ts +14 -0
- package/src/browser/log-level-preference.ts +127 -0
- package/src/browser/memory-diagnostics-contribution.ts +248 -0
- package/src/index.ts +19 -0
- package/src/node/abstract-socket-forwarding-connection-handler.ts +218 -0
- package/src/node/index.ts +10 -0
- package/src/testing/index.ts +14 -0
- package/src/testing/stub-inversify-context.ts +32 -0
- package/src/testing/stub-output-channel.ts +47 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/********************************************************************************
|
|
2
|
+
* Copyright (c) 2026 CrossBreeze, EclipseSource and others.
|
|
3
|
+
*
|
|
4
|
+
* This program and the accompanying materials are made available under the
|
|
5
|
+
* terms of the MIT License which is available in the project root.
|
|
6
|
+
*
|
|
7
|
+
* SPDX-License-Identifier: MIT
|
|
8
|
+
********************************************************************************/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
type Channel,
|
|
12
|
+
CommandService,
|
|
13
|
+
type ConnectionHandler,
|
|
14
|
+
type Disposable,
|
|
15
|
+
ILogger,
|
|
16
|
+
type MessageProvider,
|
|
17
|
+
MessageService
|
|
18
|
+
} from '@theia/core';
|
|
19
|
+
import { ForwardingChannel } from '@theia/core/lib/common/message-rpc/channel';
|
|
20
|
+
import { Deferred } from '@theia/core/lib/common/promise-util';
|
|
21
|
+
import { inject, injectable } from '@theia/core/shared/inversify';
|
|
22
|
+
import * as net from 'node:net';
|
|
23
|
+
|
|
24
|
+
/** Resolved configuration for a {@link AbstractSocketForwardingConnectionHandler}.
|
|
25
|
+
* A head-specific subclass derives these from its own (adopter-facing) options
|
|
26
|
+
* and passes them to `super(...)`. */
|
|
27
|
+
export interface SocketForwardingConnectionHandlerOptions {
|
|
28
|
+
/** Theia service path the browser frontend opens a channel to. */
|
|
29
|
+
readonly path: string;
|
|
30
|
+
/** Command id whose return value is the target server's listening port (the
|
|
31
|
+
* server publishes it on the LSP connection at startup). */
|
|
32
|
+
readonly portCommand: string;
|
|
33
|
+
/** Short bracket prefix for this head's diagnostic logs, rendered as
|
|
34
|
+
* `[<logComponent>] …`. */
|
|
35
|
+
readonly logComponent: string;
|
|
36
|
+
/** Human-readable name of the server this head connects to — used in log and
|
|
37
|
+
* error messages. */
|
|
38
|
+
readonly serverName: string;
|
|
39
|
+
readonly findPortTimeout?: number;
|
|
40
|
+
readonly findPortAttempts?: number;
|
|
41
|
+
readonly connectTimeoutMs?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Optional diagnostic hook called immediately after the outbound
|
|
44
|
+
* `net.Socket` to the server is created, BEFORE `socket.connect()` is
|
|
45
|
+
* invoked. Adopters attach `'data'` / `'close'` listeners here for
|
|
46
|
+
* byte-level observability when debugging wire-level issues; attaching
|
|
47
|
+
* before `connect()` guarantees the very first bytes are observed.
|
|
48
|
+
*/
|
|
49
|
+
readonly onSocketCreated?: (socket: net.Socket) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Cross-head base for the Theia backend half of a protocol head's transport:
|
|
54
|
+
* it bridges a Theia browser-frontend {@link Channel} to a server's TCP socket
|
|
55
|
+
* by relaying bytes between the two.
|
|
56
|
+
*
|
|
57
|
+
* Theia delivers each frontend connection to a registered `ConnectionHandler`
|
|
58
|
+
* keyed by its `path`. The handler asks the language server (via a registered
|
|
59
|
+
* command) for the target server's listening port, opens a `net.Socket` to that
|
|
60
|
+
* port, and forwards the Theia channel onto the socket. The frontend terminates
|
|
61
|
+
* the same wire protocol the server speaks, so the backend performs no semantic
|
|
62
|
+
* re-proxy — it relays bytes only and stays oblivious to the server's method set.
|
|
63
|
+
*
|
|
64
|
+
* The single per-head variation — *which* byte forwarder bridges the channel and
|
|
65
|
+
* the socket — is the abstract {@link forwardToSocketConnection} hook: the GLSP
|
|
66
|
+
* head plugs in `@eclipse-glsp/theia-integration`'s `SocketConnectionForwarder`,
|
|
67
|
+
* the data-server head its own `SocketChannelForwarder`. Keeping the forwarder
|
|
68
|
+
* behind the hook is what lets the data head stay GLSP-free while sharing the
|
|
69
|
+
* port-discovery + buffer-and-replay race fix + connect orchestration here.
|
|
70
|
+
*/
|
|
71
|
+
@injectable()
|
|
72
|
+
export abstract class AbstractSocketForwardingConnectionHandler implements ConnectionHandler {
|
|
73
|
+
@inject(MessageService) protected messageService!: MessageService;
|
|
74
|
+
@inject(CommandService) protected commandService!: CommandService;
|
|
75
|
+
// Theia backend `ILogger` — this handler runs in the Theia backend process,
|
|
76
|
+
// not the spawned language-server process, so the framework `LspLogger`
|
|
77
|
+
// (which needs the Langium connection/services) is out of reach here.
|
|
78
|
+
@inject(ILogger) protected readonly logger!: ILogger;
|
|
79
|
+
|
|
80
|
+
readonly path: string;
|
|
81
|
+
|
|
82
|
+
protected readonly portCommand: string;
|
|
83
|
+
protected readonly logComponent: string;
|
|
84
|
+
protected readonly serverName: string;
|
|
85
|
+
protected readonly findPortTimeout: number;
|
|
86
|
+
protected readonly findPortAttempts: number;
|
|
87
|
+
protected readonly connectTimeoutMs: number;
|
|
88
|
+
protected readonly onSocketCreated?: (socket: net.Socket) => void;
|
|
89
|
+
|
|
90
|
+
constructor(options: SocketForwardingConnectionHandlerOptions) {
|
|
91
|
+
this.path = options.path;
|
|
92
|
+
this.portCommand = options.portCommand;
|
|
93
|
+
this.logComponent = options.logComponent;
|
|
94
|
+
this.serverName = options.serverName;
|
|
95
|
+
this.findPortTimeout = options.findPortTimeout ?? 500;
|
|
96
|
+
this.findPortAttempts = options.findPortAttempts ?? -1;
|
|
97
|
+
this.connectTimeoutMs = options.connectTimeoutMs ?? 10000;
|
|
98
|
+
this.onSocketCreated = options.onSocketCreated;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
onConnection(connection: Channel): void {
|
|
102
|
+
this.initializeServerConnection(connection);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
protected async initializeServerConnection(channel: Channel): Promise<void> {
|
|
106
|
+
// RACE FIX: subscribe to `channel.onMessage` synchronously and buffer
|
|
107
|
+
// every `MessageProvider` until the forwarder is wired. Theia's
|
|
108
|
+
// `ForwardingChannel.onMessage` is a plain `Emitter` — it does NOT replay
|
|
109
|
+
// to listeners that subscribe later. Without this buffer, frontend writes
|
|
110
|
+
// arriving between `onConnection` and the forwarder subscribing (worst
|
|
111
|
+
// case: the whole `findPort` delay plus the socket connect time) are
|
|
112
|
+
// dropped silently. A frontend builds its proxy synchronously on the
|
|
113
|
+
// channel and may send requests immediately, so it reliably hits this
|
|
114
|
+
// window.
|
|
115
|
+
const buffered: MessageProvider[] = [];
|
|
116
|
+
const bufferSub = channel.onMessage(provider => buffered.push(provider));
|
|
117
|
+
try {
|
|
118
|
+
const port = await this.findPort();
|
|
119
|
+
this.logger.info(`[${this.logComponent}] Connecting to ${this.serverName} on port ${port}...`);
|
|
120
|
+
await this.connectToServer(channel, port, { bufferSub, buffered });
|
|
121
|
+
this.logger.info(`[${this.logComponent}] Connected to ${this.serverName} on port ${port}.`);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
bufferSub.dispose();
|
|
124
|
+
const message = error && typeof error === 'object' && 'message' in error ? String(error.message) : String(error);
|
|
125
|
+
this.logger.error(`[${this.logComponent}] Could not connect to ${this.serverName}: ${message}`);
|
|
126
|
+
this.messageService.error(`Could not connect to ${this.serverName}: ` + message);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
protected async findPort(): Promise<number> {
|
|
131
|
+
const pendingContent = new Deferred<number>();
|
|
132
|
+
let counter = 0;
|
|
133
|
+
const tryQueryingPort = (): void => {
|
|
134
|
+
setTimeout(async () => {
|
|
135
|
+
try {
|
|
136
|
+
const port = await this.commandService.executeCommand<number>(this.portCommand);
|
|
137
|
+
if (port) {
|
|
138
|
+
pendingContent.resolve(port);
|
|
139
|
+
}
|
|
140
|
+
} catch (error) {
|
|
141
|
+
counter++;
|
|
142
|
+
if (this.findPortAttempts >= 0 && counter > this.findPortAttempts) {
|
|
143
|
+
pendingContent.reject(error);
|
|
144
|
+
} else {
|
|
145
|
+
tryQueryingPort();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}, this.findPortTimeout);
|
|
149
|
+
};
|
|
150
|
+
tryQueryingPort();
|
|
151
|
+
return pendingContent.promise;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
protected async connectToServer(
|
|
155
|
+
channel: Channel,
|
|
156
|
+
port: number,
|
|
157
|
+
preForwardBuffer?: { bufferSub: Disposable; buffered: MessageProvider[] }
|
|
158
|
+
): Promise<void> {
|
|
159
|
+
const connected = new Deferred<void>();
|
|
160
|
+
const socket = new net.Socket();
|
|
161
|
+
this.onSocketCreated?.(socket);
|
|
162
|
+
socket.on('ready', () => connected.resolve());
|
|
163
|
+
socket.on('close', () => connected.reject(`Socket to ${this.serverName} was closed.`));
|
|
164
|
+
socket.on('error', error => this.logger.error(`Error occurred with the ${this.serverName} socket: ${error.name}; ${error.message}`));
|
|
165
|
+
// Synchronous hand-off: dispose the pre-forward buffer FIRST, then wire the
|
|
166
|
+
// forwarder, then replay buffered messages. The event loop cannot
|
|
167
|
+
// interleave between these synchronous statements, so no message is both
|
|
168
|
+
// buffered AND forwarded (no double delivery), and none arriving this tick
|
|
169
|
+
// slips through without a subscriber.
|
|
170
|
+
preForwardBuffer?.bufferSub.dispose();
|
|
171
|
+
this.forwardToSocketConnection(channel, socket);
|
|
172
|
+
if (preForwardBuffer && preForwardBuffer.buffered.length > 0) {
|
|
173
|
+
this.replayBufferedMessages(channel, preForwardBuffer.buffered);
|
|
174
|
+
}
|
|
175
|
+
if (channel instanceof ForwardingChannel) {
|
|
176
|
+
socket.on('error', error => channel.onErrorEmitter.fire(error));
|
|
177
|
+
}
|
|
178
|
+
socket.connect({ port });
|
|
179
|
+
setTimeout(() => connected.reject('Timeout reached.'), this.connectTimeoutMs);
|
|
180
|
+
return connected.promise;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Bridge the Theia frontend `clientChannel` and the server `socket` so bytes
|
|
185
|
+
* relay both ways. Called once, after the socket is created and the
|
|
186
|
+
* pre-forward buffer is about to be replayed.
|
|
187
|
+
*/
|
|
188
|
+
protected abstract forwardToSocketConnection(clientChannel: Channel, socket: net.Socket): Disposable;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Re-fire pre-forward buffered `MessageProvider`s on the channel's internal
|
|
192
|
+
* `onMessageEmitter` so the now-subscribed forwarder picks them up in arrival
|
|
193
|
+
* order. `MessageProvider` is a thunk (`() => ReadBuffer`) — buffering does
|
|
194
|
+
* not consume the read position, so replay produces the same bytes the
|
|
195
|
+
* forwarder would have seen if wired earlier.
|
|
196
|
+
*
|
|
197
|
+
* `AbstractChannel.onMessageEmitter` is `protected` in `@theia/core` but
|
|
198
|
+
* reachable at runtime; the cast is the workaround. A future Theia rename
|
|
199
|
+
* surfaces as a clear warning here.
|
|
200
|
+
*/
|
|
201
|
+
protected replayBufferedMessages(channel: Channel, buffered: MessageProvider[]): void {
|
|
202
|
+
if (!(channel instanceof ForwardingChannel)) {
|
|
203
|
+
this.logger.warn(`[${this.logComponent}] dropping ${buffered.length} pre-forward message(s) — channel is not a ForwardingChannel`);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const internals = channel as unknown as { onMessageEmitter?: { fire(provider: MessageProvider): void } };
|
|
207
|
+
if (!internals.onMessageEmitter) {
|
|
208
|
+
this.logger.warn(
|
|
209
|
+
`[${this.logComponent}] dropping ${buffered.length} pre-forward message(s) — ForwardingChannel.onMessageEmitter not accessible`
|
|
210
|
+
);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
this.logger.info(`[${this.logComponent}] replaying ${buffered.length} pre-forward message(s) onto the wired socket forwarder`);
|
|
214
|
+
for (const provider of buffered) {
|
|
215
|
+
internals.onMessageEmitter.fire(provider);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/********************************************************************************
|
|
2
|
+
* Copyright (c) 2026 CrossBreeze, EclipseSource and others.
|
|
3
|
+
*
|
|
4
|
+
* This program and the accompanying materials are made available under the
|
|
5
|
+
* terms of the MIT License which is available in the project root.
|
|
6
|
+
*
|
|
7
|
+
* SPDX-License-Identifier: MIT
|
|
8
|
+
********************************************************************************/
|
|
9
|
+
|
|
10
|
+
export * from './abstract-socket-forwarding-connection-handler';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/********************************************************************************
|
|
2
|
+
* Copyright (c) 2026 CrossBreeze, EclipseSource and others.
|
|
3
|
+
*
|
|
4
|
+
* This program and the accompanying materials are made available under the
|
|
5
|
+
* terms of the MIT License which is available in the project root.
|
|
6
|
+
*
|
|
7
|
+
* SPDX-License-Identifier: MIT
|
|
8
|
+
********************************************************************************/
|
|
9
|
+
|
|
10
|
+
// Subpath barrel for `@hydranium/client-theia/testing` — cross-head Theia
|
|
11
|
+
// test doubles (`makeStubOutputChannelManager`, `makeStubInversifyContext`).
|
|
12
|
+
|
|
13
|
+
export * from './stub-output-channel';
|
|
14
|
+
export * from './stub-inversify-context';
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/********************************************************************************
|
|
2
|
+
* Copyright (c) 2026 CrossBreeze, EclipseSource and others.
|
|
3
|
+
*
|
|
4
|
+
* This program and the accompanying materials are made available under the
|
|
5
|
+
* terms of the MIT License which is available in the project root.
|
|
6
|
+
*
|
|
7
|
+
* SPDX-License-Identifier: MIT
|
|
8
|
+
********************************************************************************/
|
|
9
|
+
|
|
10
|
+
import { type interfaces } from '@theia/core/shared/inversify';
|
|
11
|
+
|
|
12
|
+
/** Builds the minimal slice of an Inversify Context needed by
|
|
13
|
+
* `getRequestParentName`: just the `currentRequest.parentRequest.bindings[0].implementationType.name`
|
|
14
|
+
* shape. Tests pass the desired parent class name (or undefined) and receive a
|
|
15
|
+
* cast context for the helper to walk. */
|
|
16
|
+
export function makeStubInversifyContext(parentClassName?: string): interfaces.Context {
|
|
17
|
+
const parentRequest =
|
|
18
|
+
parentClassName === undefined
|
|
19
|
+
? null
|
|
20
|
+
: {
|
|
21
|
+
bindings: [
|
|
22
|
+
{
|
|
23
|
+
implementationType: parentClassName === '' ? {} : { name: parentClassName }
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
};
|
|
27
|
+
return {
|
|
28
|
+
currentRequest: {
|
|
29
|
+
parentRequest
|
|
30
|
+
}
|
|
31
|
+
} as unknown as interfaces.Context;
|
|
32
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/********************************************************************************
|
|
2
|
+
* Copyright (c) 2026 CrossBreeze, EclipseSource and others.
|
|
3
|
+
*
|
|
4
|
+
* This program and the accompanying materials are made available under the
|
|
5
|
+
* terms of the MIT License which is available in the project root.
|
|
6
|
+
*
|
|
7
|
+
* SPDX-License-Identifier: MIT
|
|
8
|
+
********************************************************************************/
|
|
9
|
+
|
|
10
|
+
/** Minimal fake `OutputChannel` that captures appended lines into an array. */
|
|
11
|
+
export interface StubOutputChannel {
|
|
12
|
+
readonly name: string;
|
|
13
|
+
readonly lines: string[];
|
|
14
|
+
appendLine(line: string): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Minimal fake `OutputChannelManager` handing out {@link StubOutputChannel}s.
|
|
19
|
+
* Use in unit tests of `ChannelLogger` (and its subclasses) and of any consumer
|
|
20
|
+
* that injects an output channel.
|
|
21
|
+
*/
|
|
22
|
+
export interface StubOutputChannelManager {
|
|
23
|
+
readonly channels: ReadonlyMap<string, StubOutputChannel>;
|
|
24
|
+
getChannel(name: string): StubOutputChannel;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function makeStubOutputChannelManager(): StubOutputChannelManager {
|
|
28
|
+
const channels = new Map<string, StubOutputChannel>();
|
|
29
|
+
return {
|
|
30
|
+
channels,
|
|
31
|
+
getChannel(name: string): StubOutputChannel {
|
|
32
|
+
let channel = channels.get(name);
|
|
33
|
+
if (!channel) {
|
|
34
|
+
const lines: string[] = [];
|
|
35
|
+
channel = {
|
|
36
|
+
name,
|
|
37
|
+
lines,
|
|
38
|
+
appendLine(line: string): void {
|
|
39
|
+
lines.push(line);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
channels.set(name, channel);
|
|
43
|
+
}
|
|
44
|
+
return channel;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|