@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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +113 -0
  3. package/lib/browser/browser-capture.d.ts +21 -0
  4. package/lib/browser/browser-capture.d.ts.map +1 -0
  5. package/lib/browser/browser-capture.js +61 -0
  6. package/lib/browser/browser-capture.js.map +1 -0
  7. package/lib/browser/channel-logger.d.ts +87 -0
  8. package/lib/browser/channel-logger.d.ts.map +1 -0
  9. package/lib/browser/channel-logger.js +149 -0
  10. package/lib/browser/channel-logger.js.map +1 -0
  11. package/lib/browser/index.d.ts +13 -0
  12. package/lib/browser/index.d.ts.map +1 -0
  13. package/lib/browser/index.js +30 -0
  14. package/lib/browser/index.js.map +1 -0
  15. package/lib/browser/log-level-preference.d.ts +82 -0
  16. package/lib/browser/log-level-preference.d.ts.map +1 -0
  17. package/lib/browser/log-level-preference.js +146 -0
  18. package/lib/browser/log-level-preference.js.map +1 -0
  19. package/lib/browser/memory-diagnostics-contribution.d.ts +105 -0
  20. package/lib/browser/memory-diagnostics-contribution.d.ts.map +1 -0
  21. package/lib/browser/memory-diagnostics-contribution.js +229 -0
  22. package/lib/browser/memory-diagnostics-contribution.js.map +1 -0
  23. package/lib/index.d.ts +10 -0
  24. package/lib/index.d.ts.map +1 -0
  25. package/lib/index.js +11 -0
  26. package/lib/index.js.map +1 -0
  27. package/lib/node/abstract-socket-forwarding-connection-handler.d.ts +96 -0
  28. package/lib/node/abstract-socket-forwarding-connection-handler.d.ts.map +1 -0
  29. package/lib/node/abstract-socket-forwarding-connection-handler.js +224 -0
  30. package/lib/node/abstract-socket-forwarding-connection-handler.js.map +1 -0
  31. package/lib/node/index.d.ts +10 -0
  32. package/lib/node/index.d.ts.map +1 -0
  33. package/lib/node/index.js +26 -0
  34. package/lib/node/index.js.map +1 -0
  35. package/lib/testing/index.d.ts +11 -0
  36. package/lib/testing/index.d.ts.map +1 -0
  37. package/lib/testing/index.js +29 -0
  38. package/lib/testing/index.js.map +1 -0
  39. package/lib/testing/stub-inversify-context.d.ts +15 -0
  40. package/lib/testing/stub-inversify-context.d.ts.map +1 -0
  41. package/lib/testing/stub-inversify-context.js +32 -0
  42. package/lib/testing/stub-inversify-context.js.map +1 -0
  43. package/lib/testing/stub-output-channel.d.ts +25 -0
  44. package/lib/testing/stub-output-channel.d.ts.map +1 -0
  45. package/lib/testing/stub-output-channel.js +33 -0
  46. package/lib/testing/stub-output-channel.js.map +1 -0
  47. package/package.json +95 -0
  48. package/src/browser/browser-capture.ts +75 -0
  49. package/src/browser/channel-logger.ts +160 -0
  50. package/src/browser/index.ts +14 -0
  51. package/src/browser/log-level-preference.ts +127 -0
  52. package/src/browser/memory-diagnostics-contribution.ts +248 -0
  53. package/src/index.ts +19 -0
  54. package/src/node/abstract-socket-forwarding-connection-handler.ts +218 -0
  55. package/src/node/index.ts +10 -0
  56. package/src/testing/index.ts +14 -0
  57. package/src/testing/stub-inversify-context.ts +32 -0
  58. 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
+ }