@hydranium/data-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 (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +126 -0
  3. package/lib/browser/channel-connection.d.ts +174 -0
  4. package/lib/browser/channel-connection.d.ts.map +1 -0
  5. package/lib/browser/channel-connection.js +264 -0
  6. package/lib/browser/channel-connection.js.map +1 -0
  7. package/lib/browser/data-service-frontend.d.ts +154 -0
  8. package/lib/browser/data-service-frontend.d.ts.map +1 -0
  9. package/lib/browser/data-service-frontend.js +169 -0
  10. package/lib/browser/data-service-frontend.js.map +1 -0
  11. package/lib/browser/diagnostics-data-service-frontend.d.ts +35 -0
  12. package/lib/browser/diagnostics-data-service-frontend.d.ts.map +1 -0
  13. package/lib/browser/diagnostics-data-service-frontend.js +54 -0
  14. package/lib/browser/diagnostics-data-service-frontend.js.map +1 -0
  15. package/lib/browser/host-diagnostics-frontend.d.ts +19 -0
  16. package/lib/browser/host-diagnostics-frontend.d.ts.map +1 -0
  17. package/lib/browser/host-diagnostics-frontend.js +31 -0
  18. package/lib/browser/host-diagnostics-frontend.js.map +1 -0
  19. package/lib/browser/index.d.ts +15 -0
  20. package/lib/browser/index.d.ts.map +1 -0
  21. package/lib/browser/index.js +31 -0
  22. package/lib/browser/index.js.map +1 -0
  23. package/lib/browser/references-data-service-frontend.d.ts +45 -0
  24. package/lib/browser/references-data-service-frontend.d.ts.map +1 -0
  25. package/lib/browser/references-data-service-frontend.js +55 -0
  26. package/lib/browser/references-data-service-frontend.js.map +1 -0
  27. package/lib/browser/workspace-gate.d.ts +21 -0
  28. package/lib/browser/workspace-gate.d.ts.map +1 -0
  29. package/lib/browser/workspace-gate.js +36 -0
  30. package/lib/browser/workspace-gate.js.map +1 -0
  31. package/lib/common/emitter-data-client.d.ts +40 -0
  32. package/lib/common/emitter-data-client.d.ts.map +1 -0
  33. package/lib/common/emitter-data-client.js +49 -0
  34. package/lib/common/emitter-data-client.js.map +1 -0
  35. package/lib/common/index.d.ts +10 -0
  36. package/lib/common/index.d.ts.map +1 -0
  37. package/lib/common/index.js +37 -0
  38. package/lib/common/index.js.map +1 -0
  39. package/lib/index.d.ts +10 -0
  40. package/lib/index.d.ts.map +1 -0
  41. package/lib/index.js +11 -0
  42. package/lib/index.js.map +1 -0
  43. package/lib/node/connection-container-module.d.ts +34 -0
  44. package/lib/node/connection-container-module.d.ts.map +1 -0
  45. package/lib/node/connection-container-module.js +53 -0
  46. package/lib/node/connection-container-module.js.map +1 -0
  47. package/lib/node/data-server-connection-handler.d.ts +56 -0
  48. package/lib/node/data-server-connection-handler.d.ts.map +1 -0
  49. package/lib/node/data-server-connection-handler.js +63 -0
  50. package/lib/node/data-server-connection-handler.js.map +1 -0
  51. package/lib/node/host-diagnostics-server.d.ts +31 -0
  52. package/lib/node/host-diagnostics-server.d.ts.map +1 -0
  53. package/lib/node/host-diagnostics-server.js +62 -0
  54. package/lib/node/host-diagnostics-server.js.map +1 -0
  55. package/lib/node/index.d.ts +13 -0
  56. package/lib/node/index.d.ts.map +1 -0
  57. package/lib/node/index.js +29 -0
  58. package/lib/node/index.js.map +1 -0
  59. package/lib/node/socket-channel-forwarder.d.ts +34 -0
  60. package/lib/node/socket-channel-forwarder.d.ts.map +1 -0
  61. package/lib/node/socket-channel-forwarder.js +63 -0
  62. package/lib/node/socket-channel-forwarder.js.map +1 -0
  63. package/package.json +101 -0
  64. package/src/browser/channel-connection.ts +413 -0
  65. package/src/browser/data-service-frontend.ts +208 -0
  66. package/src/browser/diagnostics-data-service-frontend.ts +70 -0
  67. package/src/browser/host-diagnostics-frontend.ts +30 -0
  68. package/src/browser/index.ts +15 -0
  69. package/src/browser/references-data-service-frontend.ts +70 -0
  70. package/src/browser/workspace-gate.ts +35 -0
  71. package/src/common/emitter-data-client.ts +64 -0
  72. package/src/common/index.ts +21 -0
  73. package/src/index.ts +18 -0
  74. package/src/node/connection-container-module.ts +51 -0
  75. package/src/node/data-server-connection-handler.ts +76 -0
  76. package/src/node/host-diagnostics-server.ts +57 -0
  77. package/src/node/index.ts +13 -0
  78. package/src/node/socket-channel-forwarder.ts +64 -0
@@ -0,0 +1,413 @@
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 Channel, DisposableCollection, Emitter, type Event, type MessageProvider } from '@theia/core';
11
+ import { type ServiceConnectionProvider } from '@theia/core/lib/browser';
12
+ import { Deferred } from '@theia/core/lib/common/promise-util';
13
+ import {
14
+ AbstractMessageReader,
15
+ AbstractMessageWriter,
16
+ createMessageConnection,
17
+ type DataCallback,
18
+ type Disposable,
19
+ type Logger,
20
+ type Message,
21
+ type MessageConnection,
22
+ type MessageReader,
23
+ type MessageWriter
24
+ // The `/browser` subpath, not the package root: `createMessageConnection`'s
25
+ // message queue needs a vscode-jsonrpc runtime abstraction layer, and only the
26
+ // `/node` and `/browser` entrypoints install one. The root resolves to the
27
+ // RAL-less common API, which throws 'No runtime abstraction layer installed'
28
+ // on the first message. This is frontend code, so `/browser`.
29
+ } from 'vscode-jsonrpc/browser';
30
+
31
+ /**
32
+ * A `vscode-jsonrpc` {@link MessageReader} that reads JSON-RPC messages off a
33
+ * Theia {@link Channel}. Each Theia channel message carries exactly one
34
+ * JSON-RPC message (the channel does its own length framing); the reader
35
+ * decodes the bytes and fires the parsed message.
36
+ */
37
+ class ChannelMessageReader extends AbstractMessageReader implements MessageReader {
38
+ protected readonly onMessageEmitter = new Emitter<Message>();
39
+ protected readonly toDispose = new DisposableCollection();
40
+
41
+ constructor(protected readonly channel: Channel) {
42
+ super();
43
+ this.toDispose.push(this.onMessageEmitter);
44
+ this.toDispose.push(channel.onMessage(provider => this.handleMessage(provider)));
45
+ this.toDispose.push(channel.onClose(() => this.fireClose()));
46
+ }
47
+
48
+ protected handleMessage(provider: MessageProvider): void {
49
+ const buffer = provider().readBytes();
50
+ const message = JSON.parse(new TextDecoder().decode(buffer)) as Message;
51
+ this.onMessageEmitter.fire(message);
52
+ }
53
+
54
+ listen(callback: DataCallback): Disposable {
55
+ return this.onMessageEmitter.event(callback);
56
+ }
57
+
58
+ override dispose(): void {
59
+ super.dispose();
60
+ this.toDispose.dispose();
61
+ }
62
+ }
63
+
64
+ /**
65
+ * A `vscode-jsonrpc` {@link MessageWriter} that writes JSON-RPC messages onto a
66
+ * Theia {@link Channel} — one channel message per JSON-RPC message, the dual of
67
+ * {@link ChannelMessageReader}. Uses `TextEncoder` (not node `Buffer`) so the
68
+ * writer is browser-native, since the frontend constructs the connection.
69
+ */
70
+ class ChannelMessageWriter extends AbstractMessageWriter implements MessageWriter {
71
+ protected readonly toDispose: Disposable;
72
+
73
+ constructor(protected readonly channel: Channel) {
74
+ super();
75
+ this.toDispose = channel.onClose(() => this.fireClose());
76
+ }
77
+
78
+ write(message: Message): Promise<void> {
79
+ const writeBuffer = this.channel.getWriteBuffer();
80
+ writeBuffer.writeBytes(new TextEncoder().encode(JSON.stringify(message)));
81
+ writeBuffer.commit();
82
+ return Promise.resolve();
83
+ }
84
+
85
+ end(): void {
86
+ this.dispose();
87
+ }
88
+
89
+ override dispose(): void {
90
+ super.dispose();
91
+ this.toDispose.dispose();
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Build a `vscode-jsonrpc` {@link MessageConnection} on top of a Theia
97
+ * {@link Channel}. This is the browser-side transport for the data-server
98
+ * head: the frontend obtains a channel to the backend forwarder (via
99
+ * `connectionProvider.listen`), wraps it here, and drives the typed
100
+ * `createRpcProxy` over the returned connection — so the frontend
101
+ * speaks the same vscode-jsonrpc protocol the model-server speaks, with the
102
+ * backend relaying bytes between the two.
103
+ *
104
+ * Mirrors `@eclipse-glsp/theia-integration`'s `createChannelConnection`, but
105
+ * lives here so the data-server head carries no GLSP dependency.
106
+ */
107
+ export function createChannelConnection(channel: Channel, logger?: Logger): MessageConnection {
108
+ const reader = new ChannelMessageReader(channel);
109
+ const writer = new ChannelMessageWriter(channel);
110
+ return createMessageConnection(reader, writer, logger);
111
+ }
112
+
113
+ /** Options for {@link openChannelConnection}. */
114
+ export interface OpenChannelConnectionOptions {
115
+ /**
116
+ * Resolves when it is safe to open the channel. The handler holds back the
117
+ * `connectionProvider.listen` call until this settles — e.g. pass
118
+ * `whenWorkspaceOpen` so the backend forwarder isn't asked for the
119
+ * model-server port before a workspace (and therefore the LSP launch that
120
+ * publishes the port) exists.
121
+ */
122
+ whenReady?: Promise<void>;
123
+ /** Optional `vscode-jsonrpc` logger threaded into the connection. */
124
+ logger?: Logger;
125
+ /**
126
+ * Re-establish the connection whenever the live one is lost, by RE-OPENING
127
+ * the channel on `path` — not merely by rebuilding over a channel Theia hands
128
+ * back.
129
+ *
130
+ * **Default `true`, and the default is the point.** Re-opening is the only
131
+ * thing that recovers a restarted language server. Theia replays a `listen`
132
+ * handler solely from `ServiceConnectionProvider.handleChannelCreated`, i.e.
133
+ * when the MAIN frontend-backend channel is (re)created — a page reload, a
134
+ * dropped socket, a backend restart. A language-server restart closes just the
135
+ * multiplexed sub-channel for this service path while the main channel stays
136
+ * open, so no replacement channel ever arrives on its own and a handle that
137
+ * only waits for one holds a connection to the dead process forever: its
138
+ * requests neither answer nor reject.
139
+ *
140
+ * **Why on by default, where the older rebuild-on-a-fresh-channel behaviour
141
+ * was opt-in.** That one had a working alternative to weigh against — the
142
+ * existing connection was fine, and opting in only decided whether to swap to
143
+ * a fresher one, so leaving a consumer's readiness gate (and the progress UI it
144
+ * drives) undisturbed was worth more. A CLOSED channel offers no such choice:
145
+ * the alternative to re-opening is a permanently dead handle. Re-running a
146
+ * readiness gate against a live replacement is what a restarted server should
147
+ * cost.
148
+ *
149
+ * Turn it off for a consumer that treats transport loss as terminal and tears
150
+ * itself down instead. Off means build-once: every channel after the first is
151
+ * ignored and {@link ChannelConnectionHandle.onDidLoseConnection} never fires.
152
+ */
153
+ reconnect?: boolean;
154
+
155
+ /**
156
+ * Delay before the re-open following each CONSECUTIVE connection loss, in ms;
157
+ * the last entry repeats once the schedule is exhausted. Defaults to
158
+ * {@link DEFAULT_RECONNECT_DELAYS}.
159
+ *
160
+ * A delay rather than an immediate re-open, for two independent reasons.
161
+ * Theia's `ChannelMultiplexer.handleClose` fires the close emitter BEFORE it
162
+ * deletes the id from `openChannels`, so a re-open issued from inside the
163
+ * close listener can still see the id as open — and `open()` throws
164
+ * "Another channel with the id '<id>' is already open" then, which
165
+ * `ServiceConnectionProvider.listen` neither catches nor reports: the handler
166
+ * is simply never invoked, and the consumer hangs rather than failing. And a
167
+ * server that is flapping would otherwise be re-opened against at whatever
168
+ * rate it can close a channel, which is what the escalation is for.
169
+ *
170
+ * The escalation resets once a connection has survived
171
+ * {@link RECONNECT_ESCALATION_RESET_MS}, so an hour-long session with
172
+ * occasional restarts does not converge on the longest delay.
173
+ */
174
+ reconnectDelays?: readonly number[];
175
+ }
176
+
177
+ /**
178
+ * Default {@link OpenChannelConnectionOptions.reconnectDelays}.
179
+ *
180
+ * The first entry only has to outlast the synchronous close dispatch, so it is
181
+ * short: the backend forwarder, not this delay, is what absorbs a server that is
182
+ * still down. `AbstractSocketForwardingConnectionHandler` re-runs its port command per
183
+ * connection and retries it indefinitely, so a channel re-opened while the
184
+ * server is restarting parks in `findPort` and connects when the replacement
185
+ * publishes its new port. The later entries exist for the case that machinery
186
+ * cannot absorb — a server that accepts a socket and then dies again.
187
+ */
188
+ export const DEFAULT_RECONNECT_DELAYS: readonly number[] = [250, 500, 1_000, 2_000, 4_000, 8_000];
189
+
190
+ /** How long a connection must survive before the reconnect escalation resets. */
191
+ export const RECONNECT_ESCALATION_RESET_MS = 30_000;
192
+
193
+ /**
194
+ * A live channel connection plus the two things a bare
195
+ * `Promise<MessageConnection>` cannot express: that the connection can be
196
+ * REPLACED, and that it can be released.
197
+ */
198
+ export interface ChannelConnectionHandle {
199
+ /**
200
+ * The current connection. Read it per use rather than caching it: after a
201
+ * reconnect this returns a NEW promise, and a cached one resolves to the
202
+ * dead connection forever. The same reasoning makes
203
+ * `AbstractDataServiceFrontend` rebuild its proxy rather than hold one.
204
+ */
205
+ readonly current: Promise<MessageConnection>;
206
+
207
+ /**
208
+ * Fires when the live connection is gone and a replacement is on the way.
209
+ * {@link current} has ALREADY been repointed at the replacement (still
210
+ * unresolved) by the time listeners run, so a listener re-derives from
211
+ * `current` and whatever it queues waits for the new channel instead of being
212
+ * addressed at the dead one.
213
+ *
214
+ * **This is the event a consumer wants, not {@link onDidReconnect}.** Anything
215
+ * derived from a connection — an `createRpcProxy`, a readiness gate, a
216
+ * `DataSession` generation — is dead from this moment, and a consumer that
217
+ * waits for the replacement to be live instead keeps sending into the dead one
218
+ * for the whole gap. A `DataPort` implementation translates this into the
219
+ * port's own `onDispose`, which is what makes `DataSession` drop its
220
+ * generation.
221
+ *
222
+ * Never fires when {@link OpenChannelConnectionOptions.reconnect} is off.
223
+ */
224
+ readonly onDidLoseConnection: Event<void>;
225
+
226
+ /**
227
+ * Fires with each connection built AFTER the first, once it is live. For a
228
+ * consumer that needs the connection object itself; for rebuilding what was
229
+ * derived from the previous one, use {@link onDidLoseConnection} instead —
230
+ * it fires at the start of the gap rather than at its end.
231
+ *
232
+ * Never fires when {@link OpenChannelConnectionOptions.reconnect} is off.
233
+ */
234
+ readonly onDidReconnect: Event<MessageConnection>;
235
+
236
+ /**
237
+ * Dispose the live connection and stop tracking the channel. Idempotent.
238
+ *
239
+ * Without it, a frontend being torn down leaves its connection and its
240
+ * inbound handler bindings attached — a leak that accumulates per cycle in
241
+ * tests and in a reloading workbench.
242
+ */
243
+ dispose(): void;
244
+ }
245
+
246
+ /**
247
+ * Open a Theia channel to a backend forwarder and wrap it as the
248
+ * `vscode-jsonrpc` {@link MessageConnection} the data-server proxy speaks over.
249
+ *
250
+ * Awaits `options.whenReady` (if given), then registers the channel handler via
251
+ * `connectionProvider.listen` and keeps a live connection to `path` for as long
252
+ * as the handle is undisposed — re-opening the channel whenever the current one
253
+ * closes, which {@link OpenChannelConnectionOptions.reconnect} governs. Callers
254
+ * build their `createRpcProxy` / `bindRpcMethods` over
255
+ * {@link ChannelConnectionHandle.current}, which queues outbound calls and
256
+ * inbound bindings until the channel is live.
257
+ *
258
+ * **The `reconnect` argument to Theia's `listen` is deliberately `false`**, in
259
+ * both modes. Passing `true` there registers the handler for replay from
260
+ * `handleChannelCreated`, and this handle then has two independent re-openers for
261
+ * one path: Theia's replay when the main frontend-backend channel is recreated,
262
+ * and the close-driven re-open below — which also fires then, because
263
+ * `ChannelMultiplexer.onUnderlyingChannelClose` closes every sub-channel. Two
264
+ * `open()` calls for one id means the loser throws "Another channel with the id
265
+ * '<id>' is already open" inside a promise `listen` neither awaits nor reports,
266
+ * so the consumer's request is left unsettled: a hang with a clean log, not an
267
+ * error. Owning the re-open here means owning it exclusively.
268
+ */
269
+ export function openChannelConnection(
270
+ connectionProvider: ServiceConnectionProvider,
271
+ path: string,
272
+ options: OpenChannelConnectionOptions = {}
273
+ ): ChannelConnectionHandle {
274
+ const reconnect = options.reconnect ?? true;
275
+ const reconnectDelays = options.reconnectDelays ?? DEFAULT_RECONNECT_DELAYS;
276
+ const reconnectEmitter = new Emitter<MessageConnection>();
277
+ const loseEmitter = new Emitter<void>();
278
+ let deferred = new Deferred<MessageConnection>();
279
+ let live: MessageConnection | undefined;
280
+ let generation = 0;
281
+ let disposed = false;
282
+ /** Set from the close that repointed `deferred`, cleared by the accept that resolves it. */
283
+ let awaitingReplacement = false;
284
+ /** Consecutive losses, indexing `reconnectDelays`. */
285
+ let losses = 0;
286
+ /** When the live connection was built, for the escalation reset. `0` before the first. */
287
+ let liveSince = 0;
288
+ let reopenTimer: ReturnType<typeof setTimeout> | undefined;
289
+ // Own synchronous flag, deliberately NOT `deferred.state`. Theia's
290
+ // `Deferred` sets `state` inside a `.then()` on its own promise, so it is
291
+ // still `'unresolved'` for a microtask after `resolve()` returns. Guarding on
292
+ // it means two channels arriving in the SAME synchronous turn both pass the
293
+ // guard: the second connection gets built and starts listening, the
294
+ // first-wins `resolve` keeps the promise pointed at the first, and the second
295
+ // leaks — attached to a channel, reachable by nobody, never disposed. A test
296
+ // that only checks which connection the promise resolves with cannot see
297
+ // that; it takes counting the connections built per channel.
298
+ let built = false;
299
+
300
+ const scheduleReopen = (): void => {
301
+ if (liveSince > 0 && Date.now() - liveSince >= RECONNECT_ESCALATION_RESET_MS) {
302
+ losses = 0;
303
+ }
304
+ const delay = reconnectDelays[Math.min(losses, reconnectDelays.length - 1)];
305
+ losses++;
306
+ reopenTimer = setTimeout(() => {
307
+ reopenTimer = undefined;
308
+ // `awaitingReplacement` is the guard against re-opening a path that
309
+ // already has a channel: a second `open()` for one id is the "already
310
+ // open" throw, and its symptom is a hang rather than an error.
311
+ if (disposed || !awaitingReplacement) {
312
+ return;
313
+ }
314
+ requestChannel();
315
+ }, delay);
316
+ };
317
+
318
+ /**
319
+ * The live connection's channel closed. Retire the generation and arm the
320
+ * replacement, in that order — a listener on `onDidLoseConnection` must
321
+ * already see `current` pointing at the replacement.
322
+ */
323
+ const handleChannelClosed = (): void => {
324
+ if (disposed || awaitingReplacement) {
325
+ return;
326
+ }
327
+ // Dispose rather than merely drop. vscode-jsonrpc rejects pending
328
+ // responses from `dispose`, never from a reader-side close — so a
329
+ // connection left undisposed keeps every in-flight request unsettled
330
+ // forever, which is the failure this whole path exists to end.
331
+ live?.dispose();
332
+ live = undefined;
333
+ deferred = new Deferred<MessageConnection>();
334
+ awaitingReplacement = true;
335
+ loseEmitter.fire(undefined);
336
+ scheduleReopen();
337
+ };
338
+
339
+ const acceptChannel = (channel: Channel): void => {
340
+ if (disposed) {
341
+ return;
342
+ }
343
+ if (built && !awaitingReplacement) {
344
+ // Unsolicited: a channel arrived while one is already live and no close
345
+ // asked for a replacement. Exactly one channel is outstanding at a time
346
+ // here, so this is either the build-once case (`reconnect` off) or a
347
+ // handler re-fire, and adopting it would leave the previous connection
348
+ // listening on its own channel, reachable by nobody and never disposed.
349
+ // The close-driven path is the only way a second generation is built.
350
+ return;
351
+ }
352
+ const connection = createChannelConnection(channel, options.logger);
353
+ connection.listen();
354
+ live = connection;
355
+ built = true;
356
+ awaitingReplacement = false;
357
+ generation++;
358
+ liveSince = Date.now();
359
+ if (reconnect) {
360
+ // Registered AFTER `createChannelConnection`, so the connection's own
361
+ // reader and writer see the close first and `connection.onClose` still
362
+ // fires for whoever is listening to it. Disposing before they run would
363
+ // put the connection in `Disposed`, from which vscode-jsonrpc
364
+ // deliberately emits no close event at all.
365
+ const acceptedGeneration = generation;
366
+ channel.onClose(() => {
367
+ if (acceptedGeneration === generation) {
368
+ handleChannelClosed();
369
+ }
370
+ });
371
+ }
372
+ deferred.resolve(connection);
373
+ if (generation > 1) {
374
+ reconnectEmitter.fire(connection);
375
+ }
376
+ };
377
+
378
+ /** Register the channel handler for one open attempt on `path`. */
379
+ function requestChannel(): void {
380
+ connectionProvider.listen(path, (_path, channel) => acceptChannel(channel), false);
381
+ }
382
+
383
+ const start = async (): Promise<void> => {
384
+ await options.whenReady;
385
+ if (disposed) {
386
+ return;
387
+ }
388
+ requestChannel();
389
+ };
390
+ start();
391
+
392
+ return {
393
+ get current(): Promise<MessageConnection> {
394
+ return deferred.promise;
395
+ },
396
+ onDidLoseConnection: loseEmitter.event,
397
+ onDidReconnect: reconnectEmitter.event,
398
+ dispose(): void {
399
+ if (disposed) {
400
+ return;
401
+ }
402
+ disposed = true;
403
+ if (reopenTimer !== undefined) {
404
+ clearTimeout(reopenTimer);
405
+ reopenTimer = undefined;
406
+ }
407
+ live?.dispose();
408
+ live = undefined;
409
+ loseEmitter.dispose();
410
+ reconnectEmitter.dispose();
411
+ }
412
+ };
413
+ }
@@ -0,0 +1,208 @@
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 { createRpcProxy } from '@hydranium/protocol';
11
+ import type { ServiceConnectionProvider } from '@theia/core/lib/browser';
12
+ import { Deferred } from '@theia/core/lib/common/promise-util';
13
+ import type { WorkspaceService } from '@theia/workspace/lib/browser';
14
+ import type { MessageConnection } from 'vscode-jsonrpc';
15
+ import { type ChannelConnectionHandle, openChannelConnection } from './channel-connection';
16
+ import { whenWorkspaceOpen } from './workspace-gate';
17
+
18
+ /**
19
+ * Base for a Theia frontend that owns the data-server vscode-jsonrpc
20
+ * connection directly (the data head's "frontend speaks the model-server's
21
+ * protocol over a relayed channel" pattern). Lifts the mechanical wiring —
22
+ * the workspace-gated connection, the combined server proxy + inbound client
23
+ * binding, and the lazy idempotent init gate — leaving the adopter to supply
24
+ * the connection seams and any progress UI / domain caching / request-method
25
+ * delegation on top.
26
+ *
27
+ * Generic over the server protocol `TServer` (must expose `waitForReady`) and
28
+ * the local notification target `TClient`. A subclass supplies the abstract
29
+ * members below, calls {@link start} from its `@postConstruct`, and awaits
30
+ * {@link ensureConnected} before its first `this.server.*` call.
31
+ */
32
+ export abstract class AbstractDataServiceFrontend<TServer extends { waitForReady(): Promise<void> }, TClient extends object> {
33
+ /**
34
+ * The workspace-gated connection to the backend forwarder. Set by
35
+ * {@link start}, and REPLACED whenever the connection is lost and
36
+ * {@link reconnectOnConnectionLoss} is on — so read it per use and never
37
+ * cache the resolved connection.
38
+ */
39
+ protected connectionPromise!: Promise<MessageConnection>;
40
+ /**
41
+ * Typed proxy over {@link connectionPromise}, addressing the server under
42
+ * {@link methodNamespace}. Set by {@link start}, and replaced alongside
43
+ * {@link connectionPromise} on reconnect.
44
+ *
45
+ * `createRpcProxy` resolves the connection promise once and binds to it for
46
+ * good, so a reconnect necessarily means a new proxy. Reading
47
+ * `this.server.foo()` per call — rather than hoisting `this.server` into a
48
+ * local or a constructor-time field — is what keeps a subclass correct
49
+ * across one.
50
+ */
51
+ protected server!: TServer;
52
+ /** The channel handle {@link start} opened; owns reconnect and disposal. */
53
+ protected channel?: ChannelConnectionHandle;
54
+ /** Shared init Deferred so concurrent {@link ensureConnected} callers await one initialization. */
55
+ protected initialized?: Deferred<void>;
56
+
57
+ /** Theia connection provider the channel is opened through. */
58
+ protected abstract readonly connectionProvider: ServiceConnectionProvider;
59
+ /**
60
+ * Optional workspace service. When provided, the default
61
+ * {@link connectionReadyGate} waits for a workspace before opening the
62
+ * channel; a head that is not workspace-scoped omits it (and may override
63
+ * {@link connectionReadyGate} for a different gate).
64
+ */
65
+ protected abstract readonly workspaceService?: WorkspaceService;
66
+ /** Local inbound-notification target bound on the connection (the `localTarget`). */
67
+ protected abstract readonly client: TClient;
68
+ /**
69
+ * Theia service path the backend forwarder is registered under.
70
+ *
71
+ * **Unique per frontend, not per server.** Theia keys a frontend channel by
72
+ * this path and throws `Another channel with the id '<path>' is already open`
73
+ * on a second opener — so a subclass sharing the framework default with any
74
+ * other consumer of the same head (a host-neutral `DataPort`, a sibling
75
+ * service frontend) breaks whichever opens second. The failure is remote from
76
+ * its cause: the throw escapes an `openChannelConnection` the other consumer
77
+ * awaited, leaving its request permanently unsettled rather than rejected,
78
+ * which presents as a view stuck on its loading state with a clean server
79
+ * log. Give each frontend its own path and register a forwarder per path;
80
+ * they still reach one server, since the shared `portCommand` is what names
81
+ * the process.
82
+ */
83
+ protected abstract readonly servicePath: string;
84
+ /** Wire namespace the server + client methods are addressed under. */
85
+ protected abstract readonly methodNamespace: string;
86
+ /** Allowlist of {@link client} methods to bind as inbound handlers. */
87
+ protected abstract readonly clientMethods: readonly (keyof TClient & string)[];
88
+
89
+ /**
90
+ * Rebuild the connection and the proxy when the current connection is lost,
91
+ * and re-run initialization against the replacement. Defaults to `true` —
92
+ * see `OpenChannelConnectionOptions.reconnect` for why re-opening the
93
+ * channel is the only thing that recovers a restarted language server, and
94
+ * why a dead connection leaves no alternative worth preserving.
95
+ *
96
+ * The subclass-facing cost is that {@link doInitialize} runs again per
97
+ * connection, so any progress UI it drives reappears. Turn this off for a
98
+ * frontend that would rather show nothing than show its warm-up twice, or
99
+ * that tears itself down on transport loss.
100
+ */
101
+ protected readonly reconnectOnConnectionLoss: boolean = true;
102
+
103
+ /**
104
+ * Readiness gate for the connection — the channel opens only once the
105
+ * returned promise settles. Default: waits for a workspace when
106
+ * {@link workspaceService} is provided, otherwise opens immediately
107
+ * (`undefined`). Override for a different gate (e.g. a fixed model store
108
+ * that is always ready, or a custom warm-up).
109
+ */
110
+ protected connectionReadyGate(): Promise<void> | undefined {
111
+ return this.workspaceService ? whenWorkspaceOpen(this.workspaceService) : undefined;
112
+ }
113
+
114
+ /**
115
+ * Open the connection (workspace-gated by default via
116
+ * {@link connectionReadyGate}) and build the combined server proxy +
117
+ * inbound client binding. Call once (typically from the adopter's
118
+ * `@postConstruct`). Outbound calls + inbound notifications queue over the
119
+ * connection promise until the channel is live.
120
+ */
121
+ protected start(): void {
122
+ this.channel = openChannelConnection(this.connectionProvider, this.servicePath, {
123
+ whenReady: this.connectionReadyGate(),
124
+ reconnect: this.reconnectOnConnectionLoss
125
+ });
126
+ this.bindConnection();
127
+ // The LOSS, not the replacement's arrival: rebinding when the channel
128
+ // closes points `server` at the queueing replacement promise, so a request
129
+ // made during the gap waits for the new server instead of being addressed
130
+ // at the dead one and never settling.
131
+ this.channel.onDidLoseConnection(() => this.handleConnectionLost());
132
+ }
133
+
134
+ /**
135
+ * Point {@link connectionPromise} and {@link server} at the channel's
136
+ * current connection. Called by {@link start} and again per reconnect.
137
+ */
138
+ protected bindConnection(): void {
139
+ if (!this.channel) {
140
+ throw new Error('bindConnection called before start');
141
+ }
142
+ this.connectionPromise = this.channel.current;
143
+ this.server = createRpcProxy<TServer, TClient>(this.connectionPromise, {
144
+ methodNamespace: this.methodNamespace,
145
+ localTarget: this.client,
146
+ localMethods: this.clientMethods
147
+ });
148
+ }
149
+
150
+ /**
151
+ * Rebind onto the replacement connection and arm initialization to run again.
152
+ *
153
+ * Clearing {@link initialized} is the load-bearing half. A restarted server
154
+ * has an unwarmed workspace, so its `waitForReady` gate has to be awaited
155
+ * afresh; leaving the old resolved Deferred in place would let the first
156
+ * request after a restart through against a server still walking the
157
+ * workspace, and be answered correctly from an empty registry — which reads
158
+ * as data loss rather than as a race.
159
+ */
160
+ protected handleConnectionLost(): void {
161
+ this.initialized = undefined;
162
+ this.bindConnection();
163
+ }
164
+
165
+ /**
166
+ * Release the connection and stop tracking the channel. Idempotent.
167
+ *
168
+ * Subclasses that are Theia `Disposable`s should route their own disposal
169
+ * here; nothing calls it automatically, because the base is not bound to a
170
+ * lifecycle of its own.
171
+ */
172
+ dispose(): void {
173
+ this.channel?.dispose();
174
+ this.channel = undefined;
175
+ this.initialized = undefined;
176
+ }
177
+
178
+ /**
179
+ * Lazily drive initialization, shared across concurrent callers via one
180
+ * {@link Deferred}. Request methods `await this.ensureConnected()` before
181
+ * their first `this.server.*` call.
182
+ */
183
+ protected ensureConnected(): Promise<void> {
184
+ if (!this.initialized) {
185
+ this.initialized = new Deferred<void>();
186
+ void this.doInitialize(this.initialized);
187
+ }
188
+ return this.initialized.promise;
189
+ }
190
+
191
+ /**
192
+ * Default initialization: await the connection, await the server's readiness
193
+ * gate, then resolve the passed Deferred. Initialization completion is
194
+ * observable by awaiting {@link ensureConnected} (which returns this same
195
+ * Deferred's promise) — there is no separate post-init hook. Override
196
+ * wholesale to interleave progress UI / extra warm-up; an override owns
197
+ * resolving/rejecting `initialized` (there is no `super` step to call).
198
+ */
199
+ protected async doInitialize(initialized: Deferred<void>): Promise<void> {
200
+ try {
201
+ await this.connectionPromise;
202
+ await this.server.waitForReady();
203
+ initialized.resolve();
204
+ } catch (error) {
205
+ initialized.reject(error instanceof Error ? error : new Error(String(error)));
206
+ }
207
+ }
208
+ }
@@ -0,0 +1,70 @@
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 {
11
+ DataServerDiagnosticsProtocol,
12
+ DumpServerStateArgs,
13
+ LatencyReport,
14
+ StartProfilingArgs,
15
+ StopProfilingArgs,
16
+ WriteServerHeapSnapshotArgs
17
+ } from '@hydranium/protocol';
18
+ import { AbstractDataServiceFrontend } from './data-service-frontend';
19
+
20
+ /**
21
+ * {@link AbstractDataServiceFrontend} specialised for a server that also exposes
22
+ * the framework {@link DataServerDiagnosticsProtocol} (which the framework
23
+ * `DataServer` implements by default). It implements each diagnostics method as
24
+ * a readiness-gated pass-through — `await this.ensureConnected()`, then delegate
25
+ * to `this.server` — so every adopter frontend gets the memory / state /
26
+ * profiling / latency surface without hand-writing identical one-liner bodies.
27
+ *
28
+ * Extend this instead of {@link AbstractDataServiceFrontend} whenever the head's
29
+ * `DataServer` keeps the default diagnostics registration; the `TServer` bound
30
+ * carries `DataServerDiagnosticsProtocol`, so the delegates are type-checked
31
+ * (no casts). A head that dropped the diagnostics methods via
32
+ * `DataServerOptions.excludedMethods` should extend the plain base instead.
33
+ */
34
+ export abstract class AbstractDiagnosticsDataServiceFrontend<
35
+ TServer extends { waitForReady(): Promise<void> } & DataServerDiagnosticsProtocol,
36
+ TClient extends object
37
+ >
38
+ extends AbstractDataServiceFrontend<TServer, TClient>
39
+ implements DataServerDiagnosticsProtocol
40
+ {
41
+ async dumpServerState(args: DumpServerStateArgs): Promise<string> {
42
+ await this.ensureConnected();
43
+ return this.server.dumpServerState(args);
44
+ }
45
+
46
+ async writeHeapSnapshot(args: WriteServerHeapSnapshotArgs): Promise<string> {
47
+ await this.ensureConnected();
48
+ return this.server.writeHeapSnapshot(args);
49
+ }
50
+
51
+ async dumpPodMemory(): Promise<string> {
52
+ await this.ensureConnected();
53
+ return this.server.dumpPodMemory();
54
+ }
55
+
56
+ async startProfiling(args: StartProfilingArgs): Promise<void> {
57
+ await this.ensureConnected();
58
+ return this.server.startProfiling(args);
59
+ }
60
+
61
+ async stopProfiling(args: StopProfilingArgs): Promise<string> {
62
+ await this.ensureConnected();
63
+ return this.server.stopProfiling(args);
64
+ }
65
+
66
+ async getLatency(): Promise<LatencyReport> {
67
+ await this.ensureConnected();
68
+ return this.server.getLatency();
69
+ }
70
+ }