@hydranium/data-client-vscode 0.0.0-stage → 1.0.0-next.315

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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -2
  3. package/lib/browser/index.d.ts +10 -0
  4. package/lib/browser/index.d.ts.map +1 -0
  5. package/lib/browser/index.js +26 -0
  6. package/lib/browser/index.js.map +1 -0
  7. package/lib/browser/webview-data-port.d.ts +64 -0
  8. package/lib/browser/webview-data-port.d.ts.map +1 -0
  9. package/lib/browser/webview-data-port.js +97 -0
  10. package/lib/browser/webview-data-port.js.map +1 -0
  11. package/lib/index.d.ts +10 -0
  12. package/lib/index.d.ts.map +1 -0
  13. package/lib/index.js +29 -0
  14. package/lib/index.js.map +1 -0
  15. package/lib/messenger-channel.d.ts +155 -0
  16. package/lib/messenger-channel.d.ts.map +1 -0
  17. package/lib/messenger-channel.js +106 -0
  18. package/lib/messenger-channel.js.map +1 -0
  19. package/lib/node/data-server-connection.d.ts +57 -0
  20. package/lib/node/data-server-connection.d.ts.map +1 -0
  21. package/lib/node/data-server-connection.js +92 -0
  22. package/lib/node/data-server-connection.js.map +1 -0
  23. package/lib/node/index.d.ts +10 -0
  24. package/lib/node/index.d.ts.map +1 -0
  25. package/lib/node/index.js +26 -0
  26. package/lib/node/index.js.map +1 -0
  27. package/lib/testing/index.d.ts +10 -0
  28. package/lib/testing/index.d.ts.map +1 -0
  29. package/lib/testing/index.js +26 -0
  30. package/lib/testing/index.js.map +1 -0
  31. package/lib/testing/messenger-harness.d.ts +48 -0
  32. package/lib/testing/messenger-harness.d.ts.map +1 -0
  33. package/lib/testing/messenger-harness.js +91 -0
  34. package/lib/testing/messenger-harness.js.map +1 -0
  35. package/package.json +99 -4
  36. package/src/browser/index.ts +10 -0
  37. package/src/browser/webview-data-port.ts +115 -0
  38. package/src/index.ts +13 -0
  39. package/src/messenger-channel.ts +224 -0
  40. package/src/node/data-server-connection.ts +85 -0
  41. package/src/node/index.ts +10 -0
  42. package/src/testing/index.ts +10 -0
  43. package/src/testing/messenger-harness.ts +121 -0
@@ -0,0 +1,115 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 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
+ createPostMessageTransport,
12
+ renderFrameworkMessage,
13
+ type DataPort,
14
+ type Logger,
15
+ type PostMessageChannel,
16
+ type ResolvedMessage
17
+ } from '@hydranium/protocol';
18
+ import { Emitter, type Event, type MessageConnection } from 'vscode-jsonrpc';
19
+ import { createMessageConnection } from 'vscode-jsonrpc/browser';
20
+
21
+ /** Options for {@link WebviewDataPort}. */
22
+ export interface WebviewDataPortOptions {
23
+ /**
24
+ * Surface a data-head failure. Without one it goes to the webview's console;
25
+ * a webview whose extension shows notifications forwards it there instead.
26
+ */
27
+ readonly reportError?: (error: unknown, reported: ResolvedMessage) => void;
28
+ /** Where the connection logs its protocol faults, and what it does on its own. */
29
+ readonly logger?: Logger;
30
+ }
31
+
32
+ /**
33
+ * The webview's {@link DataPort}: the sandbox side of the data head, over the
34
+ * extension host's messenger hop.
35
+ *
36
+ * `vscode-jsonrpc/browser`, not the package root, which installs no runtime
37
+ * abstraction layer and throws on the first message rather than at wire-up.
38
+ */
39
+ export class WebviewDataPort implements DataPort {
40
+ protected readonly disposeEmitter = new Emitter<void>();
41
+ readonly onDispose: Event<void> = this.disposeEmitter.event;
42
+ readonly logger?: Logger;
43
+ protected disposed = false;
44
+ /** The connection every generation gets until it is disposed. */
45
+ protected connection?: MessageConnection;
46
+
47
+ constructor(
48
+ protected readonly channel: PostMessageChannel,
49
+ protected readonly options: WebviewDataPortOptions = {}
50
+ ) {
51
+ this.logger = options.logger;
52
+ }
53
+
54
+ /**
55
+ * Hand back a listening connection over the hop: the same one until it is
56
+ * disposed, and then a fresh transport, so a disposed connection leaves no
57
+ * second reader on the pipe.
58
+ *
59
+ * Every connection reaches the one relay behind the pipe, and each numbers
60
+ * its requests from zero, so a second live one would take answers meant for
61
+ * the first. A retry after a failed readiness check therefore gets the
62
+ * connection the check used.
63
+ */
64
+ async connect(): Promise<MessageConnection> {
65
+ if (this.disposed) {
66
+ throw new Error('WebviewDataPort: the port is disposed');
67
+ }
68
+ if (!this.connection) {
69
+ const transport = createPostMessageTransport(this.channel);
70
+ const connection = createMessageConnection(transport.reader, transport.writer, this.logger);
71
+ connection.onDispose(() => {
72
+ transport.dispose();
73
+ if (this.connection === connection) {
74
+ this.connection = undefined;
75
+ }
76
+ });
77
+ connection.listen();
78
+ this.connection = connection;
79
+ }
80
+ return this.connection;
81
+ }
82
+
83
+ reportError(error: unknown, reported: ResolvedMessage): void {
84
+ if (this.options.reportError) {
85
+ this.options.reportError(error, reported);
86
+ } else {
87
+ console.error(`[data head] ${renderFrameworkMessage(reported)}`, error);
88
+ }
89
+ }
90
+
91
+ /**
92
+ * The extension host reports the relay's framed side died.
93
+ *
94
+ * A `PostMessageChannel` has no `close()`, so the pipe still looks open from
95
+ * here: firing `onDispose` makes `DataConnection` drop its generation, and
96
+ * disposing the connection rejects everything in flight, also when no
97
+ * generation holds it. The connection is forgotten before the event, so a
98
+ * listener that connects gets a fresh one rather than the one about to be
99
+ * disposed.
100
+ */
101
+ connectionLost(): void {
102
+ const lost = this.connection;
103
+ this.connection = undefined;
104
+ this.disposeEmitter.fire();
105
+ lost?.dispose();
106
+ }
107
+
108
+ /** Refuses to connect afterwards: the consumer's reconnect would otherwise open a reader nothing releases. */
109
+ dispose(): void {
110
+ this.disposed = true;
111
+ this.disposeEmitter.fire();
112
+ this.disposeEmitter.dispose();
113
+ this.connection?.dispose();
114
+ }
115
+ }
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 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
+ // Neutral: the messenger channel both ends of the hop build on, free of
11
+ // `vscode`, Node and the DOM. The extension host's end is `./node`, the
12
+ // webview's `./browser`.
13
+ export * from './messenger-channel';
@@ -0,0 +1,224 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 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 { PostMessageChannel } from '@hydranium/protocol';
11
+ import type { Disposable, Message } from 'vscode-jsonrpc';
12
+
13
+ /**
14
+ * A notification type as `vscode-messenger` defines it: a method name, with the
15
+ * payload type carried only in the generic parameter.
16
+ *
17
+ * Restated rather than imported: `vscode-messenger-common`'s
18
+ * `NotificationType<P>` is `{ method: string }` with `P` phantom, and the import
19
+ * would pin the adopter's messenger copy, which GLSP's VS Code integration
20
+ * chooses.
21
+ */
22
+ export interface NotificationTypeLike {
23
+ readonly method: string;
24
+ }
25
+
26
+ /**
27
+ * The one method both ends of the hop share: hand a message to a participant.
28
+ *
29
+ * `TParticipant` stays generic because the two ends address different things,
30
+ * the extension side a registered webview and the webview side
31
+ * `HOST_EXTENSION`.
32
+ */
33
+ export interface MessengerSendLike<TParticipant> {
34
+ sendNotification(type: NotificationTypeLike, receiver: TParticipant, params?: Message): void;
35
+ }
36
+
37
+ /**
38
+ * The two methods the channel needs from the **extension-side** messenger.
39
+ *
40
+ * Two methods rather than the class, so any messenger with them fits, GLSP's
41
+ * included, without pinning a `vscode-messenger` copy.
42
+ */
43
+ export interface MessengerLike<TParticipant> extends MessengerSendLike<TParticipant> {
44
+ onNotification(
45
+ type: NotificationTypeLike,
46
+ handler: (params: Message, sender: TParticipant) => void,
47
+ options?: { sender?: TParticipant }
48
+ ): Disposable;
49
+ }
50
+
51
+ /**
52
+ * The **webview-side** messenger, which differs from the extension side in
53
+ * every point the channel depends on:
54
+ *
55
+ * - **`onNotification` returns the messenger itself**, not a `Disposable`, and
56
+ * there is no unregister API, so a registration lasts as long as the webview.
57
+ * {@link createMessengerChannel} therefore stops delivering through a flag.
58
+ * - **There is no `sender` option**: a webview has one peer.
59
+ * - **`start()` must be called** before anything arrives, since it attaches the
60
+ * `window` listener. It is absent here because the channel does not call it;
61
+ * the webview's bootstrap does.
62
+ *
63
+ * A separate interface rather than a wider {@link MessengerLike}: one shape for
64
+ * both ends would promise a webview an unregisterable handler and a sender
65
+ * filter it cannot honour.
66
+ */
67
+ export interface WebviewMessengerLike<TParticipant> extends MessengerSendLike<TParticipant> {
68
+ onNotification(type: NotificationTypeLike, handler: (params: Message, sender: TParticipant) => void): unknown;
69
+ }
70
+
71
+ /**
72
+ * The data head's two notification types, one per direction.
73
+ *
74
+ * `vscode-messenger` addresses a notification to its receiver, so one method
75
+ * for both directions would also deliver correctly. Two keep the direction
76
+ * readable on the wire and in a messenger trace, and keep a delivery that
77
+ * routes by method alone, such as `BROADCAST`, from reaching the sender's own
78
+ * handler.
79
+ */
80
+ export const DATA_TO_WEBVIEW: NotificationTypeLike = { method: 'hydranium/data/toWebview' };
81
+ export const DATA_TO_EXTENSION: NotificationTypeLike = { method: 'hydranium/data/toExtension' };
82
+
83
+ /**
84
+ * Options for {@link createMessengerChannel}.
85
+ *
86
+ * Generic over the registration type as well as the participant, because the two
87
+ * sides disagree on what `onNotification` hands back: a `Disposable` on the
88
+ * extension side, the messenger itself on the webview side. The type parameter
89
+ * keeps {@link releaseRegistration} typed; probing an `unknown` for a `dispose`
90
+ * method would dispose the whole webview messenger if it ever gained one.
91
+ */
92
+ export interface MessengerChannelOptions<TParticipant, TRegistration> {
93
+ readonly messenger: {
94
+ sendNotification(type: NotificationTypeLike, receiver: TParticipant, params?: Message): void;
95
+ onNotification(
96
+ type: NotificationTypeLike,
97
+ handler: (params: Message, sender: TParticipant) => void,
98
+ options?: { sender?: TParticipant }
99
+ ): TRegistration;
100
+ };
101
+ /** Where this side's outbound messages are addressed. */
102
+ readonly receiver: TParticipant;
103
+ /** The notification type this side SENDS on. */
104
+ readonly outbound: NotificationTypeLike;
105
+ /** The notification type this side LISTENS on. */
106
+ readonly inbound: NotificationTypeLike;
107
+ /**
108
+ * Accept inbound notifications only from this participant. Supply it
109
+ * whenever the messenger is shared, as GLSP's is with every diagram
110
+ * webview, so a second webview cannot inject traffic into this connection.
111
+ *
112
+ * Extension side only: the webview-side messenger ignores it.
113
+ */
114
+ readonly sender?: TParticipant;
115
+ /**
116
+ * Observe the hop going away, the webview being disposed. Passed through to
117
+ * {@link PostMessageChannel.onClose}, whose reader and writer use it to fire
118
+ * their own close.
119
+ */
120
+ readonly onClose?: (listener: () => void) => Disposable;
121
+ /**
122
+ * Release what `onNotification` returned, where the messenger hands back
123
+ * something releasable: on the extension side, whose registrations would
124
+ * otherwise accumulate on a hop that outlives every webview on it.
125
+ */
126
+ readonly releaseRegistration?: (registration: TRegistration) => void;
127
+ }
128
+
129
+ /**
130
+ * Present a messenger as a {@link PostMessageChannel}, so the data head's
131
+ * transport rides a host's existing notification hop.
132
+ *
133
+ * `createPostMessageTransport` turns the channel into a reader and writer,
134
+ * `relayToPostMessageChannel` pumps the extension host's socket onto it from
135
+ * the other side, and everything above, `createRpcProxy` and `DataSession`
136
+ * included, is host-invariant. Sharing GLSP's `Messenger` rather than opening a
137
+ * second hop gives a diagram and a data webview one lifecycle and one dispose.
138
+ *
139
+ * **`onMessage`'s `Disposable` means "stop delivering", not necessarily
140
+ * "unregister".** The webview-side messenger cannot unregister, so a flag keeps
141
+ * the promise on both sides. Where unregistering is possible,
142
+ * {@link MessengerChannelOptions.releaseRegistration} does it too, or every
143
+ * disposed webview would leave a handler on a shared hop.
144
+ */
145
+ export function createMessengerChannel<TParticipant, TRegistration>(
146
+ options: MessengerChannelOptions<TParticipant, TRegistration>
147
+ ): PostMessageChannel {
148
+ const { messenger, receiver, outbound, inbound, sender, onClose, releaseRegistration } = options;
149
+ return {
150
+ post(message: Message): void {
151
+ messenger.sendNotification(outbound, receiver, message);
152
+ },
153
+ onMessage(listener: (message: Message) => void): Disposable {
154
+ let delivering = true;
155
+ const registration = messenger.onNotification(
156
+ inbound,
157
+ message => {
158
+ if (delivering) {
159
+ listener(message);
160
+ }
161
+ },
162
+ sender ? { sender } : undefined
163
+ );
164
+ return {
165
+ dispose: () => {
166
+ delivering = false;
167
+ releaseRegistration?.(registration);
168
+ }
169
+ };
170
+ },
171
+ ...(onClose ? { onClose } : {})
172
+ };
173
+ }
174
+
175
+ /**
176
+ * The extension host's end: posts toward the webview, listens for what the
177
+ * webview sends.
178
+ */
179
+ export function createExtensionSideChannel<TParticipant>(
180
+ messenger: MessengerLike<TParticipant>,
181
+ webview: TParticipant,
182
+ onClose?: (listener: () => void) => Disposable
183
+ ): PostMessageChannel {
184
+ return createMessengerChannel<TParticipant, Disposable>({
185
+ messenger,
186
+ receiver: webview,
187
+ outbound: DATA_TO_WEBVIEW,
188
+ inbound: DATA_TO_EXTENSION,
189
+ // Only this webview's traffic, since the hop is shared.
190
+ sender: webview,
191
+ onClose,
192
+ // This side can unregister, and must: the hop outlives every webview on it.
193
+ releaseRegistration: registration => registration.dispose()
194
+ });
195
+ }
196
+
197
+ /**
198
+ * The webview's end: the mirror image, addressed at the extension host.
199
+ *
200
+ * Two preconditions the webview's bootstrap owns, both of which present as "no
201
+ * data ever arrives" when missed:
202
+ *
203
+ * - **Read the channel through `WebviewDataPort`**, or build its connection
204
+ * with `createMessageConnection` from `vscode-jsonrpc/browser`: the package
205
+ * root installs no runtime abstraction layer and throws on the first
206
+ * message.
207
+ * - **Call the messenger's own `start()`**, which attaches the `window`
208
+ * listener.
209
+ *
210
+ * No `sender` filter: a webview has one peer.
211
+ */
212
+ export function createWebviewSideChannel<TParticipant>(
213
+ messenger: WebviewMessengerLike<TParticipant>,
214
+ hostExtension: TParticipant,
215
+ onClose?: (listener: () => void) => Disposable
216
+ ): PostMessageChannel {
217
+ return createMessengerChannel<TParticipant, unknown>({
218
+ messenger,
219
+ receiver: hostExtension,
220
+ outbound: DATA_TO_EXTENSION,
221
+ inbound: DATA_TO_WEBVIEW,
222
+ onClose
223
+ });
224
+ }
@@ -0,0 +1,85 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 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
+ /**
11
+ * The extension host's end of the data head.
12
+ *
13
+ * A webview has no `net`, so the extension host holds the socket to the
14
+ * data-server and moves whole JSON-RPC messages between it and the webview over
15
+ * a `vscode-messenger` hop, typically GLSP's own, so a diagram and a data
16
+ * webview share one lifecycle and one dispose.
17
+ */
18
+
19
+ import { relayToPostMessageChannel, type MessageRelay, type RelayTransport, type ResolvedMessage } from '@hydranium/protocol';
20
+ import * as net from 'node:net';
21
+ import type { Disposable } from 'vscode-jsonrpc';
22
+ // `/node` because the extension host is Node and holds the socket. The webview
23
+ // imports `/browser`; the package root installs no runtime abstraction layer.
24
+ import { SocketMessageReader, SocketMessageWriter } from 'vscode-jsonrpc/node';
25
+ import { createExtensionSideChannel, type MessengerLike } from '../messenger-channel';
26
+
27
+ /** Connect to a head's socket and present it as a relay's framed side. */
28
+ export function openSocketTransport(port: number, host = '127.0.0.1'): Promise<RelayTransport> {
29
+ return new Promise<RelayTransport>((resolve, reject) => {
30
+ const socket = net.createConnection({ port, host });
31
+ socket.once('error', reject);
32
+ socket.once('connect', () => {
33
+ // Drop the construction-time handler so a later socket error goes to
34
+ // the relay's reader rather than rejecting an already-settled promise.
35
+ socket.removeAllListeners('error');
36
+ resolve({
37
+ reader: new SocketMessageReader(socket),
38
+ writer: new SocketMessageWriter(socket),
39
+ dispose: () => socket.destroy()
40
+ });
41
+ });
42
+ });
43
+ }
44
+
45
+ /** What {@link connectDataServer} needs from the host. */
46
+ export interface DataServerConnectionOptions<TParticipant> {
47
+ /**
48
+ * The extension-side messenger, typically `GlspVscodeConnector.messenger`.
49
+ * It must be constructed with `ignoreHiddenViews: false`, or a hidden
50
+ * webview misses every message the data-server sends it.
51
+ */
52
+ readonly messenger: MessengerLike<TParticipant>;
53
+ /** The registered webview this connection serves. */
54
+ readonly webview: TParticipant;
55
+ /**
56
+ * Resolve the data-server's listening port. Called once per connection
57
+ * generation, so a restarted server is re-discovered rather than cached —
58
+ * the language server binds a fresh ephemeral port each start.
59
+ */
60
+ readonly findPort: () => Promise<number>;
61
+ /** Observe the webview going away, so the relay releases with it. */
62
+ readonly onWebviewDisposed?: (listener: () => void) => Disposable;
63
+ /**
64
+ * Surface a relay failure. `reported` is a complete sentence plus the
65
+ * identity needed to render it in another language — the extension host holds
66
+ * the locale, so it renders and the relay does not.
67
+ */
68
+ readonly reportError?: (error: unknown, reported: ResolvedMessage) => void;
69
+ }
70
+
71
+ /**
72
+ * Wire one webview to the data head.
73
+ *
74
+ * The returned relay's `onClose` is the host's ONLY notice that the framed side
75
+ * died — a `PostMessageChannel` has no `close()`, so a dead data-server is
76
+ * invisible to the webview, whose pending requests would otherwise hang with no
77
+ * rejection. Tell the webview, whose `WebviewDataPort.connectionLost` rejects
78
+ * them, or dispose the webview; doing nothing is the one wrong choice.
79
+ */
80
+ export function connectDataServer<TParticipant>(options: DataServerConnectionOptions<TParticipant>): MessageRelay {
81
+ const channel = createExtensionSideChannel(options.messenger, options.webview, options.onWebviewDisposed);
82
+ return relayToPostMessageChannel(channel, async () => openSocketTransport(await options.findPort()), {
83
+ reportError: options.reportError
84
+ });
85
+ }
@@ -0,0 +1,10 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 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 './data-server-connection';
@@ -0,0 +1,10 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 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 './messenger-harness';
@@ -0,0 +1,121 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 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 { Messenger } from 'vscode-messenger';
11
+ import type { WebviewIdMessageParticipant } from 'vscode-messenger-common';
12
+ import { Messenger as WebviewMessenger, type VsCodeApi } from 'vscode-messenger-webview';
13
+
14
+ /** One webview on a {@link MessengerHarness}. */
15
+ export interface MessengerHarnessWebview {
16
+ /** How the extension-side messenger addresses this webview. */
17
+ readonly participant: WebviewIdMessageParticipant;
18
+ /** The webview's own messenger, already started. */
19
+ readonly messenger: WebviewMessenger;
20
+ /** Close the webview, as VS Code disposing its panel does: the extension-side messenger forgets it. */
21
+ dispose(): void;
22
+ }
23
+
24
+ /** The real `vscode-messenger` classes, joined without VS Code. */
25
+ export interface MessengerHarness {
26
+ /** The extension host's messenger, constructed as `GlspVscodeConnector` constructs its own. */
27
+ readonly extension: Messenger;
28
+ /** Open a webview: a panel registered with {@link extension}, and the webview's messenger behind it. */
29
+ openWebview(): MessengerHarnessWebview;
30
+ /**
31
+ * Restore the global `window` this harness replaced. A webview messenger
32
+ * reads `window.crypto` for every request it sends, so its requests throw
33
+ * once the harness is disposed; notifications still go.
34
+ */
35
+ dispose(): void;
36
+ }
37
+
38
+ /** What the webview messenger reads from the global `window`. */
39
+ interface HarnessWindow {
40
+ addEventListener(type: string, listener: (event: { data: unknown }) => void): void;
41
+ readonly crypto: typeof globalThis.crypto;
42
+ }
43
+
44
+ /**
45
+ * Join the real extension-side and webview-side messengers in a Node test,
46
+ * through a fake webview panel on one end and a fake `window` on the other.
47
+ *
48
+ * Both classes run unchanged, so their routing is the real one: delivery is
49
+ * asynchronous, the webview side keeps one handler per method, and the
50
+ * extension side filters by sender. The webview messenger listens on the
51
+ * global `window`, so the harness installs one for its lifetime and routes each
52
+ * listener to the webview that registered it.
53
+ *
54
+ * One harness at a time: a second replaces the `window` the first routes its
55
+ * webviews through, and disposing them out of order restores the wrong one.
56
+ */
57
+ export function makeMessengerHarness(): MessengerHarness {
58
+ const host = globalThis as { window?: unknown };
59
+ const previousWindow = host.window;
60
+ let starting: Array<(event: { data: unknown }) => void> | undefined;
61
+ const harnessWindow: HarnessWindow = {
62
+ addEventListener: (type, listener) => {
63
+ if (type === 'message') {
64
+ starting?.push(listener);
65
+ }
66
+ },
67
+ crypto: globalThis.crypto
68
+ };
69
+ host.window = harnessWindow;
70
+ const extension = new Messenger({ ignoreHiddenViews: false });
71
+
72
+ return {
73
+ extension,
74
+ openWebview: () => {
75
+ const toWebview: Array<(event: { data: unknown }) => void> = [];
76
+ const toExtension: Array<(message: unknown) => void> = [];
77
+ const disposeListeners: Array<() => void> = [];
78
+ const panel = {
79
+ viewType: 'hydranium.messengerHarness',
80
+ visible: true,
81
+ webview: {
82
+ postMessage: async (message: unknown) => {
83
+ [...toWebview].forEach(listener => listener({ data: message }));
84
+ return true;
85
+ },
86
+ onDidReceiveMessage: (listener: (message: unknown) => void) => {
87
+ toExtension.push(listener);
88
+ return {
89
+ dispose: () => {
90
+ const index = toExtension.indexOf(listener);
91
+ if (index >= 0) {
92
+ toExtension.splice(index, 1);
93
+ }
94
+ }
95
+ };
96
+ }
97
+ },
98
+ onDidDispose: (listener: () => void) => {
99
+ disposeListeners.push(listener);
100
+ return { dispose: () => undefined };
101
+ },
102
+ onDidChangeViewState: () => ({ dispose: () => undefined })
103
+ };
104
+ // The members the extension-side messenger reads; the rest of a panel is VS Code's.
105
+ const participant = extension.registerWebviewPanel(panel as unknown as Parameters<Messenger['registerWebviewPanel']>[0]);
106
+ const vscodeApi: VsCodeApi = {
107
+ postMessage: message => [...toExtension].forEach(listener => listener(message)),
108
+ getState: () => undefined,
109
+ setState: () => undefined
110
+ };
111
+ const messenger = new WebviewMessenger(vscodeApi);
112
+ starting = toWebview;
113
+ messenger.start();
114
+ starting = undefined;
115
+ return { participant, messenger, dispose: () => disposeListeners.forEach(listener => listener()) };
116
+ },
117
+ dispose: () => {
118
+ host.window = previousWindow;
119
+ }
120
+ };
121
+ }