@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,338 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+ var websocketSO_exports = {};
20
+ __export(websocketSO_exports, {
21
+ WebSocketSO: () => WebSocketSO
22
+ });
23
+ module.exports = __toCommonJS(websocketSO_exports);
24
+ var import_ssf_host = require("@elliemae/ssf-host");
25
+ var import_pui_wsclient = require("@elliemae/pui-wsclient");
26
+ var import_subscriptionManager = require("./subscriptionManager.js");
27
+ var import_messageRouter = require("./messageRouter.js");
28
+ const SCRIPTING_OBJECT_NAME = "websocket";
29
+ const EVENT_NAME = "message";
30
+ const EVENT_ID = `${SCRIPTING_OBJECT_NAME}.${EVENT_NAME}`;
31
+ class WebSocketSO extends import_ssf_host.ScriptingObject {
32
+ #client;
33
+ #logger;
34
+ #host;
35
+ #subscriptionManager;
36
+ #messageRouter;
37
+ #isOpen = false;
38
+ /**
39
+ * Tracks in-flight subscribe promises keyed by lookup key.
40
+ * Prevents concurrent identical subscribes from creating duplicate
41
+ * server-side subscriptions.
42
+ */
43
+ #pendingSubscribes = /* @__PURE__ */ new Map();
44
+ /** Event dispatched to subscribed guests when the server sends an event message. */
45
+ Message = new import_ssf_host.Event({
46
+ name: EVENT_NAME,
47
+ objectId: SCRIPTING_OBJECT_NAME
48
+ });
49
+ /**
50
+ * Creates a new WebSocket scripting object.
51
+ * @param {WebSocketSOOptions} options - configuration options
52
+ */
53
+ constructor({ logger, host, url, token, onError }) {
54
+ super(SCRIPTING_OBJECT_NAME);
55
+ if (!logger) throw new Error("logger is required");
56
+ if (!host) throw new Error("host is required");
57
+ if (!url) throw new Error("url is required");
58
+ if (!token) throw new Error("token is required");
59
+ this.#logger = logger;
60
+ this.#host = host;
61
+ this.#subscriptionManager = new import_subscriptionManager.SubscriptionManager();
62
+ this.#messageRouter = new import_messageRouter.MessageRouter(
63
+ this.#subscriptionManager,
64
+ this.#handleEvent,
65
+ this.#logger
66
+ );
67
+ this.#client = new import_pui_wsclient.WSClient({
68
+ url,
69
+ token,
70
+ logger,
71
+ contentType: import_pui_wsclient.WSContentType.JSON,
72
+ onMessage: (message) => {
73
+ this.#messageRouter.handleMessage(message);
74
+ },
75
+ onError
76
+ });
77
+ this.#client.onOpen = this.#handleReconnect;
78
+ Object.defineProperty(this, "open", { enumerable: false });
79
+ Object.defineProperty(this, "close", { enumerable: false });
80
+ }
81
+ /**
82
+ * Opens the WebSocket connection to the server.
83
+ * Must be called before guests can subscribe. Idempotent — calling
84
+ * while already open is a no-op.
85
+ * @returns {Promise<void>} promise that resolves when the connection is established
86
+ */
87
+ open = async () => {
88
+ if (this.#isOpen) return;
89
+ await this.#client.open();
90
+ this.#isOpen = true;
91
+ };
92
+ /**
93
+ * Closes the WebSocket connection and cleans up all subscriptions.
94
+ * Idempotent — calling while already closed is a no-op.
95
+ */
96
+ close = () => {
97
+ if (!this.#isOpen) return;
98
+ this.#isOpen = false;
99
+ this.#pendingSubscribes.clear();
100
+ this.#client.close();
101
+ this.#subscriptionManager.dispose();
102
+ };
103
+ /**
104
+ * Validates subscribe preconditions and returns the guestId + destructured options.
105
+ * @param {SubscribeOptions} options - subscribe options to validate
106
+ * @returns {{ guestId: string; resource: string; resourceId?: string; filter?: Record<string, string[]> }} validated fields
107
+ * @throws {Error} if any validation fails
108
+ */
109
+ #validateSubscribe = (options) => {
110
+ const guestId = this.subscribe.callContext?.guest?.id;
111
+ if (!guestId) {
112
+ throw new Error("unable to identify calling guest from callContext");
113
+ }
114
+ if (!this.#isOpen) {
115
+ throw new Error("WebSocket connection is not open. Call open() first.");
116
+ }
117
+ const { resource, resourceId, filter } = options;
118
+ if (!resource) {
119
+ throw new Error("resource is required");
120
+ }
121
+ if (!resourceId && !filter) {
122
+ throw new Error("either resourceId or filter must be provided");
123
+ }
124
+ return { guestId, resource, resourceId, filter };
125
+ };
126
+ /**
127
+ * Subscribe to a WebSocket resource on behalf of the calling guest.
128
+ * If an identical subscription already exists for this guest, returns
129
+ * the existing subscriptionId and increments the reference count.
130
+ * @param {SubscribeOptions} options - resource, resourceId or filter
131
+ * @returns {Promise<SubscribeResult>} resolves with the subscriptionId
132
+ */
133
+ subscribe = (options) => {
134
+ let validated;
135
+ try {
136
+ validated = this.#validateSubscribe(options);
137
+ } catch (err) {
138
+ return Promise.reject(err);
139
+ }
140
+ const { guestId, resource, resourceId, filter } = validated;
141
+ const criteria = { resource, guestId, resourceId, filter };
142
+ const existing = this.#subscriptionManager.findExistingSubscription(criteria);
143
+ if (existing) {
144
+ this.#subscriptionManager.incrementRefCount(existing.subscriptionId);
145
+ return Promise.resolve({ subscriptionId: existing.subscriptionId });
146
+ }
147
+ const lookupKey = import_subscriptionManager.SubscriptionManager.buildLookupKey(criteria);
148
+ const inflight = this.#pendingSubscribes.get(lookupKey);
149
+ if (inflight) {
150
+ return inflight.then((result) => {
151
+ this.#subscriptionManager.incrementRefCount(result.subscriptionId);
152
+ return result;
153
+ });
154
+ }
155
+ return this.#sendSubscribeCommand(criteria, lookupKey);
156
+ };
157
+ /**
158
+ * Sends the subscribe command to the server and tracks the in-flight promise.
159
+ * @param {Omit<Subscription, 'subscriptionId' | 'refCount'>} criteria - subscription criteria
160
+ * @param {string} lookupKey - key for in-flight dedup
161
+ * @returns {Promise<SubscribeResult>} resolves with the subscriptionId
162
+ */
163
+ #sendSubscribeCommand = (criteria, lookupKey) => {
164
+ const { guestId, resource, resourceId, filter } = criteria;
165
+ const correlationId = crypto.randomUUID();
166
+ const command = {
167
+ type: "subscribe",
168
+ correlationId,
169
+ resource,
170
+ ...resourceId != null && { resourceId },
171
+ ...filter != null && { filter }
172
+ };
173
+ const promise = new Promise((resolve, reject) => {
174
+ this.#subscriptionManager.addPendingRequest(
175
+ correlationId,
176
+ (result) => {
177
+ this.#subscriptionManager.addSubscription({
178
+ subscriptionId: result.subscriptionId,
179
+ resource,
180
+ guestId,
181
+ resourceId,
182
+ filter
183
+ });
184
+ this.#logger.debug(
185
+ `Guest ${guestId} subscribed to ${resource} with subscriptionId: ${result.subscriptionId}`
186
+ );
187
+ resolve(result);
188
+ },
189
+ reject
190
+ );
191
+ this.#sendCommand(command).catch((err) => {
192
+ this.#subscriptionManager.rejectPendingRequest(correlationId, err);
193
+ });
194
+ });
195
+ this.#pendingSubscribes.set(lookupKey, promise);
196
+ promise.finally(() => this.#pendingSubscribes.delete(lookupKey)).catch(() => {
197
+ });
198
+ return promise;
199
+ };
200
+ /**
201
+ * Unsubscribe from an active subscription. Decrements the reference
202
+ * count; the server-side unsubscribe is only sent when the last
203
+ * consumer releases the subscription.
204
+ * @param {string} subscriptionId - id of the subscription to release
205
+ * @returns {Promise<void>} resolves when the unsubscribe completes
206
+ */
207
+ unsubscribe = (subscriptionId) => {
208
+ if (!subscriptionId) {
209
+ return Promise.reject(new Error("subscriptionId is required"));
210
+ }
211
+ if (!this.#isOpen) {
212
+ return Promise.reject(
213
+ new Error("WebSocket connection is not open. Call open() first.")
214
+ );
215
+ }
216
+ const guestId = this.unsubscribe.callContext?.guest?.id;
217
+ if (!guestId) {
218
+ return Promise.reject(
219
+ new Error("unable to identify calling guest from callContext")
220
+ );
221
+ }
222
+ const subscription = this.#subscriptionManager.getSubscription(subscriptionId);
223
+ if (!subscription || subscription.guestId !== guestId) {
224
+ return Promise.reject(
225
+ new Error(
226
+ `subscription ${subscriptionId} is not available for this guest`
227
+ )
228
+ );
229
+ }
230
+ const remaining = this.#subscriptionManager.decrementRefCount(subscriptionId);
231
+ if (remaining > 0) {
232
+ this.#logger.debug(
233
+ `Decremented refCount for ${subscriptionId} to ${remaining}`
234
+ );
235
+ return Promise.resolve();
236
+ }
237
+ const correlationId = crypto.randomUUID();
238
+ const command = {
239
+ type: "unsubscribe",
240
+ correlationId,
241
+ subscriptionId
242
+ };
243
+ return new Promise((resolve, reject) => {
244
+ this.#subscriptionManager.addPendingRequest(
245
+ correlationId,
246
+ () => {
247
+ this.#logger.debug(
248
+ `Unsubscribed from subscriptionId: ${subscriptionId}`
249
+ );
250
+ resolve();
251
+ },
252
+ reject
253
+ );
254
+ this.#sendCommand(command).catch((err) => {
255
+ this.#subscriptionManager.rejectPendingRequest(correlationId, err);
256
+ });
257
+ });
258
+ };
259
+ /**
260
+ * Sends a command to the WebSocket server via the underlying WSClient.
261
+ * @param {ClientMessage} command - subscribe or unsubscribe command
262
+ * @returns {Promise<void>} resolves when the message is sent
263
+ */
264
+ #sendCommand = (command) => this.#client.send(command);
265
+ /**
266
+ * Re-subscribes all active subscriptions after a WSClient reconnection.
267
+ * Server-side subscription state is lost on disconnect, so we must
268
+ * re-establish them.
269
+ */
270
+ #handleReconnect = () => {
271
+ const subscriptions = this.#subscriptionManager.getAllSubscriptions();
272
+ if (subscriptions.length === 0) return;
273
+ this.#logger.debug(
274
+ `WebSocket reconnected \u2014 re-subscribing ${subscriptions.length} subscription(s)`
275
+ );
276
+ subscriptions.forEach((sub) => {
277
+ const correlationId = crypto.randomUUID();
278
+ const command = {
279
+ type: "subscribe",
280
+ correlationId,
281
+ resource: sub.resource,
282
+ ...sub.resourceId != null && { resourceId: sub.resourceId },
283
+ ...sub.filter != null && { filter: sub.filter }
284
+ };
285
+ this.#subscriptionManager.addPendingRequest(
286
+ correlationId,
287
+ (result) => {
288
+ this.#subscriptionManager.removeSubscription(sub.subscriptionId);
289
+ this.#subscriptionManager.addSubscription({
290
+ ...sub,
291
+ subscriptionId: result.subscriptionId
292
+ });
293
+ this.#logger.debug(
294
+ `Re-subscribed guest ${sub.guestId} to ${sub.resource}: ${sub.subscriptionId} \u2192 ${result.subscriptionId}`
295
+ );
296
+ },
297
+ (err) => {
298
+ this.#subscriptionManager.removeSubscription(sub.subscriptionId);
299
+ this.#logger.error(
300
+ `Failed to re-subscribe guest ${sub.guestId} to ${sub.resource}: ${err.message}. Subscription removed.`
301
+ );
302
+ }
303
+ );
304
+ this.#sendCommand(command).catch((err) => {
305
+ this.#subscriptionManager.rejectPendingRequest(correlationId, err);
306
+ });
307
+ });
308
+ };
309
+ /**
310
+ * Dispatches a server event to the owning guest via the host's dispatchEvent.
311
+ * @param {EventMessage} event - the server event message
312
+ * @param {Subscription} subscription - the matching subscription with guestId
313
+ */
314
+ #handleEvent = (event, subscription) => {
315
+ const eventParams = {
316
+ subscriptionId: event.subscriptionId,
317
+ resource: event.resource,
318
+ payload: event.payload
319
+ };
320
+ this.#host.dispatchEvent({
321
+ event: { id: EVENT_ID, name: EVENT_NAME },
322
+ eventParams,
323
+ eventOptions: { guestId: subscription.guestId }
324
+ }).catch((err) => {
325
+ this.#logger.error(
326
+ `Failed to dispatch event to guest ${subscription.guestId}: ${err.message}`
327
+ );
328
+ });
329
+ };
330
+ /**
331
+ * Lifecycle hook called by the host framework when the SO is removed.
332
+ * Closes the WebSocket connection and cleans up all state.
333
+ */
334
+ // eslint-disable-next-line no-underscore-dangle
335
+ _dispose = () => {
336
+ this.close();
337
+ };
338
+ }