@elliemae/pui-websocket-so 2.0.0

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 (36) hide show
  1. package/dist/cjs/index.html +758 -0
  2. package/dist/cjs/index.js +24 -0
  3. package/dist/cjs/messageRouter.js +111 -0
  4. package/dist/cjs/package.json +7 -0
  5. package/dist/cjs/subscriptionManager.js +224 -0
  6. package/dist/cjs/types.js +16 -0
  7. package/dist/cjs/websocketSO.js +338 -0
  8. package/dist/esm/index.html +758 -0
  9. package/dist/esm/index.js +4 -0
  10. package/dist/esm/messageRouter.js +91 -0
  11. package/dist/esm/package.json +7 -0
  12. package/dist/esm/subscriptionManager.js +204 -0
  13. package/dist/esm/types.js +0 -0
  14. package/dist/esm/websocketSO.js +318 -0
  15. package/dist/public/guest.html +523 -0
  16. package/dist/public/index.html +1 -0
  17. package/dist/public/js/emuiWebsocketSo.cc1f5b5e1d095fc3a34e.js +3 -0
  18. package/dist/public/js/emuiWebsocketSo.cc1f5b5e1d095fc3a34e.js.br +0 -0
  19. package/dist/public/js/emuiWebsocketSo.cc1f5b5e1d095fc3a34e.js.gz +0 -0
  20. package/dist/public/js/emuiWebsocketSo.cc1f5b5e1d095fc3a34e.js.map +1 -0
  21. package/dist/types/lib/index.d.ts +2 -0
  22. package/dist/types/lib/messageRouter.d.ts +30 -0
  23. package/dist/types/lib/subscriptionManager.d.ts +101 -0
  24. package/dist/types/lib/tests/messageRouter.test.d.ts +1 -0
  25. package/dist/types/lib/tests/subscriptionManager.test.d.ts +1 -0
  26. package/dist/types/lib/tests/websocketSO.test.d.ts +1 -0
  27. package/dist/types/lib/types.d.ts +118 -0
  28. package/dist/types/lib/websocketSO.d.ts +56 -0
  29. package/dist/types/tsconfig.tsbuildinfo +1 -0
  30. package/dist/umd/guest.html +523 -0
  31. package/dist/umd/index.html +1 -0
  32. package/dist/umd/index.js +3 -0
  33. package/dist/umd/index.js.br +0 -0
  34. package/dist/umd/index.js.gz +0 -0
  35. package/dist/umd/index.js.map +1 -0
  36. package/package.json +69 -0
@@ -0,0 +1,4 @@
1
+ import { WebSocketSO } from "./websocketSO.js";
2
+ export {
3
+ WebSocketSO
4
+ };
@@ -0,0 +1,91 @@
1
+ class MessageRouter {
2
+ #subscriptionManager;
3
+ #onEvent;
4
+ #logger;
5
+ /**
6
+ * @param {SubscriptionManager} subscriptionManager - manages pending requests and active subscriptions
7
+ * @param {EventCallback} onEvent - callback invoked when a server event matches an active subscription
8
+ * @param {Logger} logger - logger instance for debug/error output
9
+ */
10
+ constructor(subscriptionManager, onEvent, logger) {
11
+ this.#subscriptionManager = subscriptionManager;
12
+ this.#onEvent = onEvent;
13
+ this.#logger = logger;
14
+ }
15
+ /**
16
+ * Entry point for all messages received from the WebSocket server.
17
+ * Dispatches to the correct handler based on message type.
18
+ * @param {ServerMessage} message - incoming server message
19
+ */
20
+ handleMessage = (message) => {
21
+ switch (message.type) {
22
+ case "subscribe_ack":
23
+ this.#handleSubscribeAck(message);
24
+ break;
25
+ case "unsubscribe_ack":
26
+ this.#handleUnsubscribeAck(message);
27
+ break;
28
+ case "error":
29
+ this.#handleError(message);
30
+ break;
31
+ case "event":
32
+ this.#handleEvent(message);
33
+ break;
34
+ default:
35
+ this.#logger.debug(
36
+ `Unknown message type received: ${message.type}`
37
+ );
38
+ }
39
+ };
40
+ #handleSubscribeAck(message) {
41
+ const { correlationId, subscriptionId } = message;
42
+ const resolved = this.#subscriptionManager.resolvePendingRequest(
43
+ correlationId,
44
+ { subscriptionId }
45
+ );
46
+ if (!resolved) {
47
+ this.#logger.debug(
48
+ `Received subscribe_ack for unknown correlationId: ${correlationId}`
49
+ );
50
+ }
51
+ }
52
+ #handleUnsubscribeAck(message) {
53
+ const { correlationId } = message;
54
+ const resolved = this.#subscriptionManager.resolvePendingRequest(
55
+ correlationId,
56
+ void 0
57
+ );
58
+ if (!resolved) {
59
+ this.#logger.debug(
60
+ `Received unsubscribe_ack for unknown correlationId: ${correlationId}`
61
+ );
62
+ }
63
+ }
64
+ #handleError(message) {
65
+ const { correlationId, errors } = message;
66
+ const errorMessages = errors.map((e) => `${e.code}: ${e.message}`).join("; ");
67
+ const rejected = this.#subscriptionManager.rejectPendingRequest(
68
+ correlationId,
69
+ new Error(errorMessages)
70
+ );
71
+ if (!rejected) {
72
+ this.#logger.debug(
73
+ `Received error for unknown correlationId: ${correlationId}. Errors: ${errorMessages}`
74
+ );
75
+ }
76
+ }
77
+ #handleEvent(message) {
78
+ const { subscriptionId } = message;
79
+ const subscription = this.#subscriptionManager.getSubscription(subscriptionId);
80
+ if (!subscription) {
81
+ this.#logger.debug(
82
+ `Received event for unknown subscriptionId: ${subscriptionId}`
83
+ );
84
+ return;
85
+ }
86
+ this.#onEvent(message, subscription);
87
+ }
88
+ }
89
+ export {
90
+ MessageRouter
91
+ };
@@ -0,0 +1,7 @@
1
+ {
2
+ "type": "module",
3
+ "sideEffects": false,
4
+ "publishConfig": {
5
+ "access": "public"
6
+ }
7
+ }
@@ -0,0 +1,204 @@
1
+ const DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
2
+ class SubscriptionManager {
3
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
4
+ #pendingRequests = /* @__PURE__ */ new Map();
5
+ #subscriptions = /* @__PURE__ */ new Map();
6
+ /** Reverse index: composite lookup key → subscriptionId for O(1) duplicate detection. */
7
+ #keyIndex = /* @__PURE__ */ new Map();
8
+ #requestTimeoutMs;
9
+ /**
10
+ * @param {number} [requestTimeoutMs=30000] - timeout in ms before a pending request is auto-rejected
11
+ */
12
+ constructor(requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
13
+ this.#requestTimeoutMs = requestTimeoutMs;
14
+ }
15
+ /**
16
+ * Register a pending request that will be resolved/rejected when the
17
+ * server responds with a message matching the given correlationId.
18
+ * @param {string} correlationId - unique id correlating request to response
19
+ * @param {Function} resolve - callback invoked on success
20
+ * @param {Function} reject - callback invoked on failure or timeout
21
+ */
22
+ addPendingRequest(correlationId, resolve, reject) {
23
+ const existing = this.#pendingRequests.get(correlationId);
24
+ if (existing) {
25
+ clearTimeout(existing.timeoutId);
26
+ existing.reject(
27
+ new Error(`Pending request replaced (correlationId: ${correlationId})`)
28
+ );
29
+ }
30
+ const timeoutId = setTimeout(() => {
31
+ this.#pendingRequests.delete(correlationId);
32
+ reject(
33
+ new Error(
34
+ `Request timed out after ${this.#requestTimeoutMs}ms (correlationId: ${correlationId})`
35
+ )
36
+ );
37
+ }, this.#requestTimeoutMs);
38
+ this.#pendingRequests.set(correlationId, { resolve, reject, timeoutId });
39
+ }
40
+ /**
41
+ * Resolve a pending request with a successful result.
42
+ * @param {string} correlationId - unique id of the pending request
43
+ * @param {*} value - the value to resolve with
44
+ * @returns {boolean} true if the request was found and resolved
45
+ */
46
+ resolvePendingRequest(correlationId, value) {
47
+ const pending = this.#pendingRequests.get(correlationId);
48
+ if (!pending) return false;
49
+ clearTimeout(pending.timeoutId);
50
+ this.#pendingRequests.delete(correlationId);
51
+ pending.resolve(value);
52
+ return true;
53
+ }
54
+ /**
55
+ * Reject a pending request with an error.
56
+ * @param {string} correlationId - unique id of the pending request
57
+ * @param {Error} error - the error to reject with
58
+ * @returns {boolean} true if the request was found and rejected
59
+ */
60
+ rejectPendingRequest(correlationId, error) {
61
+ const pending = this.#pendingRequests.get(correlationId);
62
+ if (!pending) return false;
63
+ clearTimeout(pending.timeoutId);
64
+ this.#pendingRequests.delete(correlationId);
65
+ pending.reject(error);
66
+ return true;
67
+ }
68
+ /**
69
+ * Find an existing subscription matching the given resource criteria and guest.
70
+ * Uses a secondary key index for O(1) lookup.
71
+ * @param {Omit<Subscription, 'subscriptionId' | 'refCount'>} criteria - fields to match
72
+ * @returns {Subscription | undefined} matching subscription or undefined
73
+ */
74
+ findExistingSubscription(criteria) {
75
+ const key = SubscriptionManager.#buildLookupKey(criteria);
76
+ const subscriptionId = this.#keyIndex.get(key);
77
+ if (!subscriptionId) return void 0;
78
+ return this.#subscriptions.get(subscriptionId);
79
+ }
80
+ /**
81
+ * Register a new active subscription. Initialises refCount to 1.
82
+ * @param {Omit<Subscription, 'refCount'>} subscription - subscription data (refCount is set internally)
83
+ */
84
+ addSubscription(subscription) {
85
+ const entry = {
86
+ ...subscription,
87
+ filter: subscription.filter ? JSON.parse(JSON.stringify(subscription.filter)) : void 0,
88
+ refCount: 1
89
+ };
90
+ this.#subscriptions.set(subscription.subscriptionId, entry);
91
+ this.#keyIndex.set(
92
+ SubscriptionManager.#buildLookupKey(entry),
93
+ subscription.subscriptionId
94
+ );
95
+ }
96
+ /**
97
+ * Increment the reference count for an existing subscription.
98
+ * @param {string} subscriptionId - subscription to increment
99
+ * @returns {number} the new refCount, or -1 if the subscription was not found
100
+ */
101
+ incrementRefCount(subscriptionId) {
102
+ const sub = this.#subscriptions.get(subscriptionId);
103
+ if (!sub) return -1;
104
+ sub.refCount += 1;
105
+ return sub.refCount;
106
+ }
107
+ /**
108
+ * Decrement the reference count and remove when it reaches 0.
109
+ * @param {string} subscriptionId - subscription to decrement
110
+ * @returns {number} the new refCount (0 means removed), or -1 if not found
111
+ */
112
+ decrementRefCount(subscriptionId) {
113
+ const sub = this.#subscriptions.get(subscriptionId);
114
+ if (!sub) return -1;
115
+ sub.refCount -= 1;
116
+ if (sub.refCount <= 0) {
117
+ this.#keyIndex.delete(SubscriptionManager.#buildLookupKey(sub));
118
+ this.#subscriptions.delete(subscriptionId);
119
+ return 0;
120
+ }
121
+ return sub.refCount;
122
+ }
123
+ /**
124
+ * Unconditionally remove a subscription regardless of refCount.
125
+ * Used during reconnection to replace old entries.
126
+ * @param {string} subscriptionId - subscription to remove
127
+ * @returns {boolean} true if the subscription existed and was removed
128
+ */
129
+ removeSubscription(subscriptionId) {
130
+ const sub = this.#subscriptions.get(subscriptionId);
131
+ if (sub) {
132
+ this.#keyIndex.delete(SubscriptionManager.#buildLookupKey(sub));
133
+ }
134
+ return this.#subscriptions.delete(subscriptionId);
135
+ }
136
+ /**
137
+ * Retrieve subscription metadata by id.
138
+ * @param {string} subscriptionId - subscription to look up
139
+ * @returns {Subscription | undefined} the subscription, or undefined if not found
140
+ */
141
+ getSubscription(subscriptionId) {
142
+ return this.#subscriptions.get(subscriptionId);
143
+ }
144
+ /**
145
+ * Check whether a subscription is currently active.
146
+ * @param {string} subscriptionId - subscription to check
147
+ * @returns {boolean} true if the subscription exists
148
+ */
149
+ hasSubscription(subscriptionId) {
150
+ return this.#subscriptions.has(subscriptionId);
151
+ }
152
+ /**
153
+ * Returns all active subscriptions.
154
+ * Used for re-subscribing after a reconnection.
155
+ * @returns {Subscription[]} array of active subscriptions
156
+ */
157
+ getAllSubscriptions() {
158
+ return Array.from(this.#subscriptions.values());
159
+ }
160
+ /**
161
+ * Clean up all pending requests and subscriptions.
162
+ * Rejects any outstanding pending requests.
163
+ */
164
+ dispose() {
165
+ this.#pendingRequests.forEach((pending) => {
166
+ clearTimeout(pending.timeoutId);
167
+ pending.reject(new Error("WebSocket scripting object disposed"));
168
+ });
169
+ this.#pendingRequests.clear();
170
+ this.#subscriptions.clear();
171
+ this.#keyIndex.clear();
172
+ }
173
+ /**
174
+ * Compute the canonical lookup key for a set of subscription criteria.
175
+ * Exposed so callers can track in-flight subscribes by the same key.
176
+ * @param {Omit<Subscription, 'subscriptionId' | 'refCount'>} criteria - subscription fields
177
+ * @returns {string} composite lookup key
178
+ */
179
+ static buildLookupKey(criteria) {
180
+ return SubscriptionManager.#buildLookupKey(criteria);
181
+ }
182
+ /**
183
+ * Builds a canonical composite key for O(1) subscription lookup.
184
+ * Uses NUL (\0) as separator to avoid collisions with real values.
185
+ * Filter keys are sorted for order-independent comparison.
186
+ * @param {Pick<Subscription, 'guestId' | 'resource' | 'resourceId' | 'filter'>} sub - subscription fields
187
+ * @returns {string} composite lookup key
188
+ */
189
+ static #buildLookupKey(sub) {
190
+ const { guestId, resource, resourceId, filter } = sub;
191
+ if (resourceId != null) {
192
+ return `${guestId}\0${resource}\0rid:${resourceId}`;
193
+ }
194
+ if (filter != null) {
195
+ const sortedKeys = Object.keys(filter).sort();
196
+ const canonical = sortedKeys.map((k) => `${k}=${filter[k].join(",")}`).join("&");
197
+ return `${guestId}\0${resource}\0f:${canonical}`;
198
+ }
199
+ return `${guestId}\0${resource}`;
200
+ }
201
+ }
202
+ export {
203
+ SubscriptionManager
204
+ };
File without changes
@@ -0,0 +1,318 @@
1
+ import { ScriptingObject, Event } from "@elliemae/ssf-host";
2
+ import { WSClient, WSContentType } from "@elliemae/pui-wsclient";
3
+ import { SubscriptionManager } from "./subscriptionManager.js";
4
+ import { MessageRouter } from "./messageRouter.js";
5
+ const SCRIPTING_OBJECT_NAME = "websocket";
6
+ const EVENT_NAME = "message";
7
+ const EVENT_ID = `${SCRIPTING_OBJECT_NAME}.${EVENT_NAME}`;
8
+ class WebSocketSO extends ScriptingObject {
9
+ #client;
10
+ #logger;
11
+ #host;
12
+ #subscriptionManager;
13
+ #messageRouter;
14
+ #isOpen = false;
15
+ /**
16
+ * Tracks in-flight subscribe promises keyed by lookup key.
17
+ * Prevents concurrent identical subscribes from creating duplicate
18
+ * server-side subscriptions.
19
+ */
20
+ #pendingSubscribes = /* @__PURE__ */ new Map();
21
+ /** Event dispatched to subscribed guests when the server sends an event message. */
22
+ Message = new Event({
23
+ name: EVENT_NAME,
24
+ objectId: SCRIPTING_OBJECT_NAME
25
+ });
26
+ /**
27
+ * Creates a new WebSocket scripting object.
28
+ * @param {WebSocketSOOptions} options - configuration options
29
+ */
30
+ constructor({ logger, host, url, token, onError }) {
31
+ super(SCRIPTING_OBJECT_NAME);
32
+ if (!logger) throw new Error("logger is required");
33
+ if (!host) throw new Error("host is required");
34
+ if (!url) throw new Error("url is required");
35
+ if (!token) throw new Error("token is required");
36
+ this.#logger = logger;
37
+ this.#host = host;
38
+ this.#subscriptionManager = new SubscriptionManager();
39
+ this.#messageRouter = new MessageRouter(
40
+ this.#subscriptionManager,
41
+ this.#handleEvent,
42
+ this.#logger
43
+ );
44
+ this.#client = new WSClient({
45
+ url,
46
+ token,
47
+ logger,
48
+ contentType: WSContentType.JSON,
49
+ onMessage: (message) => {
50
+ this.#messageRouter.handleMessage(message);
51
+ },
52
+ onError
53
+ });
54
+ this.#client.onOpen = this.#handleReconnect;
55
+ Object.defineProperty(this, "open", { enumerable: false });
56
+ Object.defineProperty(this, "close", { enumerable: false });
57
+ }
58
+ /**
59
+ * Opens the WebSocket connection to the server.
60
+ * Must be called before guests can subscribe. Idempotent — calling
61
+ * while already open is a no-op.
62
+ * @returns {Promise<void>} promise that resolves when the connection is established
63
+ */
64
+ open = async () => {
65
+ if (this.#isOpen) return;
66
+ await this.#client.open();
67
+ this.#isOpen = true;
68
+ };
69
+ /**
70
+ * Closes the WebSocket connection and cleans up all subscriptions.
71
+ * Idempotent — calling while already closed is a no-op.
72
+ */
73
+ close = () => {
74
+ if (!this.#isOpen) return;
75
+ this.#isOpen = false;
76
+ this.#pendingSubscribes.clear();
77
+ this.#client.close();
78
+ this.#subscriptionManager.dispose();
79
+ };
80
+ /**
81
+ * Validates subscribe preconditions and returns the guestId + destructured options.
82
+ * @param {SubscribeOptions} options - subscribe options to validate
83
+ * @returns {{ guestId: string; resource: string; resourceId?: string; filter?: Record<string, string[]> }} validated fields
84
+ * @throws {Error} if any validation fails
85
+ */
86
+ #validateSubscribe = (options) => {
87
+ const guestId = this.subscribe.callContext?.guest?.id;
88
+ if (!guestId) {
89
+ throw new Error("unable to identify calling guest from callContext");
90
+ }
91
+ if (!this.#isOpen) {
92
+ throw new Error("WebSocket connection is not open. Call open() first.");
93
+ }
94
+ const { resource, resourceId, filter } = options;
95
+ if (!resource) {
96
+ throw new Error("resource is required");
97
+ }
98
+ if (!resourceId && !filter) {
99
+ throw new Error("either resourceId or filter must be provided");
100
+ }
101
+ return { guestId, resource, resourceId, filter };
102
+ };
103
+ /**
104
+ * Subscribe to a WebSocket resource on behalf of the calling guest.
105
+ * If an identical subscription already exists for this guest, returns
106
+ * the existing subscriptionId and increments the reference count.
107
+ * @param {SubscribeOptions} options - resource, resourceId or filter
108
+ * @returns {Promise<SubscribeResult>} resolves with the subscriptionId
109
+ */
110
+ subscribe = (options) => {
111
+ let validated;
112
+ try {
113
+ validated = this.#validateSubscribe(options);
114
+ } catch (err) {
115
+ return Promise.reject(err);
116
+ }
117
+ const { guestId, resource, resourceId, filter } = validated;
118
+ const criteria = { resource, guestId, resourceId, filter };
119
+ const existing = this.#subscriptionManager.findExistingSubscription(criteria);
120
+ if (existing) {
121
+ this.#subscriptionManager.incrementRefCount(existing.subscriptionId);
122
+ return Promise.resolve({ subscriptionId: existing.subscriptionId });
123
+ }
124
+ const lookupKey = SubscriptionManager.buildLookupKey(criteria);
125
+ const inflight = this.#pendingSubscribes.get(lookupKey);
126
+ if (inflight) {
127
+ return inflight.then((result) => {
128
+ this.#subscriptionManager.incrementRefCount(result.subscriptionId);
129
+ return result;
130
+ });
131
+ }
132
+ return this.#sendSubscribeCommand(criteria, lookupKey);
133
+ };
134
+ /**
135
+ * Sends the subscribe command to the server and tracks the in-flight promise.
136
+ * @param {Omit<Subscription, 'subscriptionId' | 'refCount'>} criteria - subscription criteria
137
+ * @param {string} lookupKey - key for in-flight dedup
138
+ * @returns {Promise<SubscribeResult>} resolves with the subscriptionId
139
+ */
140
+ #sendSubscribeCommand = (criteria, lookupKey) => {
141
+ const { guestId, resource, resourceId, filter } = criteria;
142
+ const correlationId = crypto.randomUUID();
143
+ const command = {
144
+ type: "subscribe",
145
+ correlationId,
146
+ resource,
147
+ ...resourceId != null && { resourceId },
148
+ ...filter != null && { filter }
149
+ };
150
+ const promise = new Promise((resolve, reject) => {
151
+ this.#subscriptionManager.addPendingRequest(
152
+ correlationId,
153
+ (result) => {
154
+ this.#subscriptionManager.addSubscription({
155
+ subscriptionId: result.subscriptionId,
156
+ resource,
157
+ guestId,
158
+ resourceId,
159
+ filter
160
+ });
161
+ this.#logger.debug(
162
+ `Guest ${guestId} subscribed to ${resource} with subscriptionId: ${result.subscriptionId}`
163
+ );
164
+ resolve(result);
165
+ },
166
+ reject
167
+ );
168
+ this.#sendCommand(command).catch((err) => {
169
+ this.#subscriptionManager.rejectPendingRequest(correlationId, err);
170
+ });
171
+ });
172
+ this.#pendingSubscribes.set(lookupKey, promise);
173
+ promise.finally(() => this.#pendingSubscribes.delete(lookupKey)).catch(() => {
174
+ });
175
+ return promise;
176
+ };
177
+ /**
178
+ * Unsubscribe from an active subscription. Decrements the reference
179
+ * count; the server-side unsubscribe is only sent when the last
180
+ * consumer releases the subscription.
181
+ * @param {string} subscriptionId - id of the subscription to release
182
+ * @returns {Promise<void>} resolves when the unsubscribe completes
183
+ */
184
+ unsubscribe = (subscriptionId) => {
185
+ if (!subscriptionId) {
186
+ return Promise.reject(new Error("subscriptionId is required"));
187
+ }
188
+ if (!this.#isOpen) {
189
+ return Promise.reject(
190
+ new Error("WebSocket connection is not open. Call open() first.")
191
+ );
192
+ }
193
+ const guestId = this.unsubscribe.callContext?.guest?.id;
194
+ if (!guestId) {
195
+ return Promise.reject(
196
+ new Error("unable to identify calling guest from callContext")
197
+ );
198
+ }
199
+ const subscription = this.#subscriptionManager.getSubscription(subscriptionId);
200
+ if (!subscription || subscription.guestId !== guestId) {
201
+ return Promise.reject(
202
+ new Error(
203
+ `subscription ${subscriptionId} is not available for this guest`
204
+ )
205
+ );
206
+ }
207
+ const remaining = this.#subscriptionManager.decrementRefCount(subscriptionId);
208
+ if (remaining > 0) {
209
+ this.#logger.debug(
210
+ `Decremented refCount for ${subscriptionId} to ${remaining}`
211
+ );
212
+ return Promise.resolve();
213
+ }
214
+ const correlationId = crypto.randomUUID();
215
+ const command = {
216
+ type: "unsubscribe",
217
+ correlationId,
218
+ subscriptionId
219
+ };
220
+ return new Promise((resolve, reject) => {
221
+ this.#subscriptionManager.addPendingRequest(
222
+ correlationId,
223
+ () => {
224
+ this.#logger.debug(
225
+ `Unsubscribed from subscriptionId: ${subscriptionId}`
226
+ );
227
+ resolve();
228
+ },
229
+ reject
230
+ );
231
+ this.#sendCommand(command).catch((err) => {
232
+ this.#subscriptionManager.rejectPendingRequest(correlationId, err);
233
+ });
234
+ });
235
+ };
236
+ /**
237
+ * Sends a command to the WebSocket server via the underlying WSClient.
238
+ * @param {ClientMessage} command - subscribe or unsubscribe command
239
+ * @returns {Promise<void>} resolves when the message is sent
240
+ */
241
+ #sendCommand = (command) => this.#client.send(command);
242
+ /**
243
+ * Re-subscribes all active subscriptions after a WSClient reconnection.
244
+ * Server-side subscription state is lost on disconnect, so we must
245
+ * re-establish them.
246
+ */
247
+ #handleReconnect = () => {
248
+ const subscriptions = this.#subscriptionManager.getAllSubscriptions();
249
+ if (subscriptions.length === 0) return;
250
+ this.#logger.debug(
251
+ `WebSocket reconnected \u2014 re-subscribing ${subscriptions.length} subscription(s)`
252
+ );
253
+ subscriptions.forEach((sub) => {
254
+ const correlationId = crypto.randomUUID();
255
+ const command = {
256
+ type: "subscribe",
257
+ correlationId,
258
+ resource: sub.resource,
259
+ ...sub.resourceId != null && { resourceId: sub.resourceId },
260
+ ...sub.filter != null && { filter: sub.filter }
261
+ };
262
+ this.#subscriptionManager.addPendingRequest(
263
+ correlationId,
264
+ (result) => {
265
+ this.#subscriptionManager.removeSubscription(sub.subscriptionId);
266
+ this.#subscriptionManager.addSubscription({
267
+ ...sub,
268
+ subscriptionId: result.subscriptionId
269
+ });
270
+ this.#logger.debug(
271
+ `Re-subscribed guest ${sub.guestId} to ${sub.resource}: ${sub.subscriptionId} \u2192 ${result.subscriptionId}`
272
+ );
273
+ },
274
+ (err) => {
275
+ this.#subscriptionManager.removeSubscription(sub.subscriptionId);
276
+ this.#logger.error(
277
+ `Failed to re-subscribe guest ${sub.guestId} to ${sub.resource}: ${err.message}. Subscription removed.`
278
+ );
279
+ }
280
+ );
281
+ this.#sendCommand(command).catch((err) => {
282
+ this.#subscriptionManager.rejectPendingRequest(correlationId, err);
283
+ });
284
+ });
285
+ };
286
+ /**
287
+ * Dispatches a server event to the owning guest via the host's dispatchEvent.
288
+ * @param {EventMessage} event - the server event message
289
+ * @param {Subscription} subscription - the matching subscription with guestId
290
+ */
291
+ #handleEvent = (event, subscription) => {
292
+ const eventParams = {
293
+ subscriptionId: event.subscriptionId,
294
+ resource: event.resource,
295
+ payload: event.payload
296
+ };
297
+ this.#host.dispatchEvent({
298
+ event: { id: EVENT_ID, name: EVENT_NAME },
299
+ eventParams,
300
+ eventOptions: { guestId: subscription.guestId }
301
+ }).catch((err) => {
302
+ this.#logger.error(
303
+ `Failed to dispatch event to guest ${subscription.guestId}: ${err.message}`
304
+ );
305
+ });
306
+ };
307
+ /**
308
+ * Lifecycle hook called by the host framework when the SO is removed.
309
+ * Closes the WebSocket connection and cleans up all state.
310
+ */
311
+ // eslint-disable-next-line no-underscore-dangle
312
+ _dispose = () => {
313
+ this.close();
314
+ };
315
+ }
316
+ export {
317
+ WebSocketSO
318
+ };