@hydranium/data-client-theia 1.0.0-next.6 → 1.0.0-next.61

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 (31) hide show
  1. package/README.md +3 -3
  2. package/lib/browser/channel-data-port.d.ts +65 -0
  3. package/lib/browser/channel-data-port.d.ts.map +1 -0
  4. package/lib/browser/channel-data-port.js +115 -0
  5. package/lib/browser/channel-data-port.js.map +1 -0
  6. package/lib/browser/data-service-frontend.d.ts +39 -97
  7. package/lib/browser/data-service-frontend.d.ts.map +1 -1
  8. package/lib/browser/data-service-frontend.js +77 -98
  9. package/lib/browser/data-service-frontend.js.map +1 -1
  10. package/lib/browser/index.d.ts +1 -0
  11. package/lib/browser/index.d.ts.map +1 -1
  12. package/lib/browser/index.js +1 -0
  13. package/lib/browser/index.js.map +1 -1
  14. package/lib/common/emitter-data-client.d.ts +9 -1
  15. package/lib/common/emitter-data-client.d.ts.map +1 -1
  16. package/lib/common/emitter-data-client.js +12 -0
  17. package/lib/common/emitter-data-client.js.map +1 -1
  18. package/lib/node/data-server-connection-handler.d.ts +10 -0
  19. package/lib/node/data-server-connection-handler.d.ts.map +1 -1
  20. package/lib/node/data-server-connection-handler.js +1 -1
  21. package/lib/node/data-server-connection-handler.js.map +1 -1
  22. package/lib/node/socket-channel-forwarder.d.ts.map +1 -1
  23. package/lib/node/socket-channel-forwarder.js +6 -1
  24. package/lib/node/socket-channel-forwarder.js.map +1 -1
  25. package/package.json +13 -12
  26. package/src/browser/channel-data-port.ts +100 -0
  27. package/src/browser/data-service-frontend.ts +94 -125
  28. package/src/browser/index.ts +1 -0
  29. package/src/common/emitter-data-client.ts +18 -0
  30. package/src/node/data-server-connection-handler.ts +11 -1
  31. package/src/node/socket-channel-forwarder.ts +6 -1
@@ -7,11 +7,17 @@
7
7
  * SPDX-License-Identifier: MIT
8
8
  ********************************************************************************/
9
9
 
10
- import { createRpcProxy } from '@hydranium/protocol';
10
+ import {
11
+ type DataPort,
12
+ type ReadyServer,
13
+ type ResolvedMessage,
14
+ RpcConnection,
15
+ type RpcProxy,
16
+ renderFrameworkMessage
17
+ } from '@hydranium/protocol';
18
+ import { Emitter, nls, type MessageService } from '@theia/core';
11
19
  import type { ServiceConnectionProvider } from '@theia/core/lib/browser';
12
- import { Deferred } from '@theia/core/lib/common/promise-util';
13
20
  import type { WorkspaceService } from '@theia/workspace/lib/browser';
14
- import type { MessageConnection } from 'vscode-jsonrpc';
15
21
  import { type ChannelConnectionHandle, openChannelConnection } from './channel-connection';
16
22
  import { whenWorkspaceOpen } from './workspace-gate';
17
23
 
@@ -28,138 +34,116 @@ import { whenWorkspaceOpen } from './workspace-gate';
28
34
  * the local notification target `TClient`. A subclass supplies the abstract
29
35
  * members below, calls {@link start} from its `@postConstruct`, and awaits
30
36
  * {@link ensureConnected} before its first `this.server.*` call.
37
+ *
38
+ * The lifecycle is `RpcConnection`'s — the same proxy, readiness gate and
39
+ * reconnect generation the host-neutral tier uses. What stays here is the Theia
40
+ * half: the channel, the workspace gate and the notification sink.
31
41
  */
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. */
42
+ export abstract class AbstractDataServiceFrontend<TServer extends ReadyServer, TClient extends object> {
58
43
  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
44
  protected abstract readonly workspaceService?: WorkspaceService;
66
- /** Local inbound-notification target bound on the connection (the `localTarget`). */
67
45
  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
- */
46
+ /** Frontend service path the backend forwarder for this head is registered under. */
83
47
  protected abstract readonly servicePath: string;
84
- /** Wire namespace the server + client methods are addressed under. */
85
48
  protected abstract readonly methodNamespace: string;
86
49
  /** Allowlist of {@link client} methods to bind as inbound handlers. */
87
50
  protected abstract readonly clientMethods: readonly (keyof TClient & string)[];
88
51
 
52
+ /** Surfaces a transport failure. Optional: a frontend with no UI of its own omits it. */
53
+ protected readonly messageService?: MessageService;
54
+
89
55
  /**
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.
56
+ * Rebuild the connection when the current one is lost. Defaults to `true` —
57
+ * re-opening the channel is the only thing that recovers a restarted
58
+ * language server, which binds new ephemeral ports. Turn it off for a
59
+ * frontend that would rather tear itself down than show its warm-up twice.
100
60
  */
101
61
  protected readonly reconnectOnConnectionLoss: boolean = true;
102
62
 
63
+ protected channel?: ChannelConnectionHandle;
64
+ protected connection?: RpcConnection<TServer, TClient>;
65
+ protected readonly lossEmitter = new Emitter<void>();
66
+
103
67
  /**
104
68
  * Readiness gate for the connection — the channel opens only once the
105
69
  * 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).
70
+ * {@link workspaceService} is provided, otherwise opens immediately.
109
71
  */
110
72
  protected connectionReadyGate(): Promise<void> | undefined {
111
73
  return this.workspaceService ? whenWorkspaceOpen(this.workspaceService) : undefined;
112
74
  }
113
75
 
76
+ /** A connection generation is opening, including on each reconnect. */
77
+ protected onConnecting(): void {
78
+ // nothing by default
79
+ }
80
+
81
+ /** The server's readiness gate has settled for a generation. */
82
+ protected onReady(): void {
83
+ // nothing by default
84
+ }
85
+
86
+ /** A generation failed; the awaiting caller still rejects. */
87
+ protected onFailed(_error: unknown): void {
88
+ // nothing by default
89
+ }
90
+
114
91
  /**
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.
92
+ * Open the channel and build the connection over it. Call once, typically
93
+ * from the adopter's `@postConstruct`. Outbound calls and inbound
94
+ * notifications queue until the channel is live.
120
95
  */
121
96
  protected start(): void {
122
97
  this.channel = openChannelConnection(this.connectionProvider, this.servicePath, {
123
98
  whenReady: this.connectionReadyGate(),
124
99
  reconnect: this.reconnectOnConnectionLoss
125
100
  });
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());
101
+ // The LOSS, not the replacement's arrival: the connection drops its
102
+ // generation here, so a request made during the gap waits for the fresh
103
+ // one instead of addressing the dead one and never settling.
104
+ this.channel.onDidLoseConnection(() => this.lossEmitter.fire(undefined));
105
+ this.connection = new RpcConnection<TServer, TClient>(this.channelPort(), this.client, {
106
+ methodNamespace: this.methodNamespace,
107
+ clientMethods: this.clientMethods,
108
+ lifecycle: {
109
+ onConnecting: () => this.onConnecting(),
110
+ onReady: () => this.onReady(),
111
+ onFailed: error => this.onFailed(error)
112
+ }
113
+ });
114
+ }
115
+
116
+ /** The channel as a {@link DataPort} — the whole Theia-specific half. */
117
+ protected channelPort(): DataPort {
118
+ return {
119
+ // Read per call: `current` is repointed on every re-open, so reaching
120
+ // through the handle is what makes a later generation find the live
121
+ // server.
122
+ connect: () => this.requireChannel().current,
123
+ reportError: (_error, reported) => this.reportError(reported),
124
+ onDispose: this.lossEmitter.event
125
+ };
126
+ }
127
+
128
+ /** Surface a transport failure the way this host does. */
129
+ protected reportError(reported: ResolvedMessage): void {
130
+ this.messageService?.error(renderFrameworkMessage(reported, nls.localization?.translations));
132
131
  }
133
132
 
134
133
  /**
135
- * Point {@link connectionPromise} and {@link server} at the channel's
136
- * current connection. Called by {@link start} and again per reconnect.
134
+ * The server proxy. Calls queue against the connection, so await
135
+ * {@link ensureConnected} first wherever the server's readiness matters.
137
136
  */
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
- });
137
+ protected get server(): RpcProxy<TServer> {
138
+ return this.requireConnection().server;
148
139
  }
149
140
 
150
141
  /**
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.
142
+ * Await the connection and the server's readiness gate, shared across
143
+ * concurrent callers and re-run once per connection generation.
159
144
  */
160
- protected handleConnectionLost(): void {
161
- this.initialized = undefined;
162
- this.bindConnection();
145
+ protected async ensureConnected(): Promise<void> {
146
+ await this.requireConnection().connected();
163
147
  }
164
148
 
165
149
  /**
@@ -170,39 +154,24 @@ export abstract class AbstractDataServiceFrontend<TServer extends { waitForReady
170
154
  * lifecycle of its own.
171
155
  */
172
156
  dispose(): void {
157
+ this.connection?.dispose();
158
+ this.connection = undefined;
173
159
  this.channel?.dispose();
174
160
  this.channel = undefined;
175
- this.initialized = undefined;
161
+ this.lossEmitter.dispose();
176
162
  }
177
163
 
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);
164
+ protected requireConnection(): RpcConnection<TServer, TClient> {
165
+ if (!this.connection) {
166
+ throw new Error('the connection is not open: call start() first');
187
167
  }
188
- return this.initialized.promise;
168
+ return this.connection;
189
169
  }
190
170
 
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)));
171
+ protected requireChannel(): ChannelConnectionHandle {
172
+ if (!this.channel) {
173
+ throw new Error('the channel is not open: call start() first');
206
174
  }
175
+ return this.channel;
207
176
  }
208
177
  }
@@ -8,6 +8,7 @@
8
8
  ********************************************************************************/
9
9
 
10
10
  export * from './channel-connection';
11
+ export * from './channel-data-port';
11
12
  export * from './data-service-frontend';
12
13
  export * from './diagnostics-data-service-frontend';
13
14
  export * from './host-diagnostics-frontend';
@@ -12,7 +12,9 @@ import type {
12
12
  Project,
13
13
  ProjectsChangedEvent,
14
14
  TransferDiagnostic,
15
+ TransferDocumentDeletedEvent,
15
16
  TransferDocumentSavedEvent,
17
+ TransferDocumentsBuiltEvent,
16
18
  TransferDocumentUpdatedEvent,
17
19
  TransferElement
18
20
  } from '@hydranium/protocol';
@@ -46,6 +48,14 @@ export class EmitterDataClient<
46
48
  /** Fires for each inbound {@link onDocumentSaved} notification. */
47
49
  readonly onDidSaveDocument: Event<TransferDocumentSavedEvent<TTransfer, TDiagnostic>> = this.onDocumentSavedEmitter.event;
48
50
 
51
+ protected readonly onDocumentDeletedEmitter = new Emitter<TransferDocumentDeletedEvent>();
52
+ /** Fires for each inbound {@link onDocumentDeleted} notification. */
53
+ readonly onDidDeleteDocument: Event<TransferDocumentDeletedEvent> = this.onDocumentDeletedEmitter.event;
54
+
55
+ protected readonly onDocumentsBuiltEmitter = new Emitter<TransferDocumentsBuiltEvent>();
56
+ /** Fires for each inbound {@link onDocumentsBuilt} notification. */
57
+ readonly onDidBuildDocuments: Event<TransferDocumentsBuiltEvent> = this.onDocumentsBuiltEmitter.event;
58
+
49
59
  protected readonly onProjectsChangedEmitter = new Emitter<ProjectsChangedEvent<TProject>>();
50
60
  /** Fires for each inbound {@link onProjectsChanged} notification. */
51
61
  readonly onDidChangeProjects: Event<ProjectsChangedEvent<TProject>> = this.onProjectsChangedEmitter.event;
@@ -58,6 +68,14 @@ export class EmitterDataClient<
58
68
  this.onDocumentSavedEmitter.fire(event);
59
69
  }
60
70
 
71
+ onDocumentDeleted(event: TransferDocumentDeletedEvent): void {
72
+ this.onDocumentDeletedEmitter.fire(event);
73
+ }
74
+
75
+ onDocumentsBuilt(event: TransferDocumentsBuiltEvent): void {
76
+ this.onDocumentsBuiltEmitter.fire(event);
77
+ }
78
+
61
79
  onProjectsChanged(event: ProjectsChangedEvent<TProject>): void {
62
80
  this.onProjectsChangedEmitter.fire(event);
63
81
  }
@@ -30,6 +30,16 @@ export interface DataServerConnectionHandlerOptions {
30
30
  * Defaults to the framework `DATA_SERVER_PORT_COMMAND`; override to match
31
31
  * an adopter's established id. */
32
32
  readonly portCommand?: string;
33
+ /**
34
+ * Product name for the connect-failure dialog this handler raises.
35
+ *
36
+ * It reaches the adopter's UI verbatim, so the framework default leaks a
37
+ * framework noun into a product that is not ours. Not a translation concern —
38
+ * routing it through a catalogue would ask an adopter to "translate" English
39
+ * into their own product name, and would make their branding
40
+ * locale-dependent.
41
+ */
42
+ readonly serverName?: string;
33
43
  readonly findPortTimeout?: number;
34
44
  readonly findPortAttempts?: number;
35
45
  readonly connectTimeoutMs?: number;
@@ -62,7 +72,7 @@ export class DataServerConnectionHandler extends AbstractSocketForwardingConnect
62
72
  path: options.servicePath ?? DATA_SERVER_PATH,
63
73
  portCommand: options.portCommand ?? DATA_SERVER_PORT_COMMAND,
64
74
  logComponent: 'DataServer',
65
- serverName: 'Model Server',
75
+ serverName: options.serverName ?? 'Model Server',
66
76
  findPortTimeout: options.findPortTimeout,
67
77
  findPortAttempts: options.findPortAttempts,
68
78
  connectTimeoutMs: options.connectTimeoutMs,
@@ -34,8 +34,13 @@ export class SocketChannelForwarder implements Disposable {
34
34
  const reader = new SocketMessageReader(socket);
35
35
  const writer = new SocketMessageWriter(socket);
36
36
  const connection = createMessageConnection(reader, writer);
37
+ // Nothing here destroys the socket, and adding it back would be dead
38
+ // code in both directions. `SocketMessageWriter.dispose()` destroys it
39
+ // itself, which covers the dispose path; and `connection.onClose` fires
40
+ // only from the reader's or writer's own close, which for a socket means
41
+ // the socket has already gone — so a destroy handler there is downstream
42
+ // of the effect it would be trying to cause.
37
43
  this.toDispose.pushAll([
38
- connection.onClose(() => socket.destroy()),
39
44
  reader.listen(message => this.writeToChannel(message)),
40
45
  channel.onMessage(provider => void writer.write(this.decodeChannelMessage(provider))),
41
46
  channel.onClose(() => connection.dispose()),