@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,758 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" class="h-full">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>WebSocket SO Playground</title>
7
+ <script src="https://cdn.tailwindcss.com?plugins=forms"></script>
8
+
9
+ <style>
10
+ /* ── JSON syntax colours ───────────────────────────── */
11
+ .jk {
12
+ color: #c084fc;
13
+ } /* key – purple-400 */
14
+ .js {
15
+ color: #4ade80;
16
+ } /* string – green-400 */
17
+ .jn {
18
+ color: #fb923c;
19
+ } /* number – orange-400 */
20
+ .jb {
21
+ color: #60a5fa;
22
+ } /* bool – blue-400 */
23
+ .jz {
24
+ color: #6b7280;
25
+ } /* null – gray-500 */
26
+
27
+ /* ── Log entry left-border colours ─────────────────── */
28
+ .log-info {
29
+ border-left: 3px solid #6366f1;
30
+ }
31
+ .log-open {
32
+ border-left: 3px solid #22c55e;
33
+ }
34
+ .log-close {
35
+ border-left: 3px solid #f59e0b;
36
+ }
37
+ .log-event {
38
+ border-left: 3px solid #818cf8;
39
+ }
40
+ .log-error {
41
+ border-left: 3px solid #f87171;
42
+ }
43
+ .log-sub {
44
+ border-left: 3px solid #34d399;
45
+ }
46
+ .log-unsub {
47
+ border-left: 3px solid #94a3b8;
48
+ }
49
+
50
+ /* ── Scrollable panels ─────────────────────────────── */
51
+ .scroll-panel {
52
+ overflow-y: auto;
53
+ }
54
+ pre {
55
+ white-space: pre-wrap;
56
+ word-break: break-all;
57
+ font-size: 0.75rem;
58
+ }
59
+
60
+ /* ── Guest container size ──────────────────────────── */
61
+ #guest-area {
62
+ height: 560px;
63
+ }
64
+ #guest-area iframe {
65
+ border: 0;
66
+ width: 100%;
67
+ height: 100%;
68
+ }
69
+ </style>
70
+ </head>
71
+
72
+ <body
73
+ class="h-full flex flex-col bg-gray-100 text-sm text-gray-800 font-sans"
74
+ >
75
+ <!-- ═══════════════════════════ HEADER ════════════════════════════ -->
76
+ <header
77
+ class="flex items-center justify-between px-4 py-2 bg-indigo-700 text-white shrink-0 shadow"
78
+ >
79
+ <div class="flex items-center gap-3">
80
+ <span class="font-semibold tracking-wide">WebSocket SO Playground</span>
81
+ <span class="text-indigo-300 text-xs hidden sm:inline"
82
+ >@elliemae/pui-websocket-so · Phase II Service Orders</span
83
+ >
84
+ </div>
85
+ <div class="flex items-center gap-4">
86
+ <span id="timer" class="text-xs text-indigo-200 tabular-nums hidden"
87
+ >00:00:00</span
88
+ >
89
+ <span id="stats" class="text-xs text-indigo-200 hidden">
90
+ <span title="Events dispatched"
91
+ >⚡ <span id="statEvents">0</span></span
92
+ >
93
+ <span class="ml-2" title="Active subscriptions across all guests"
94
+ >⬡ <span id="statSubs">0</span></span
95
+ >
96
+ </span>
97
+ <span
98
+ id="statusPill"
99
+ class="bg-gray-100 text-gray-600 inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium"
100
+ >
101
+ ● Disconnected
102
+ </span>
103
+ </div>
104
+ </header>
105
+
106
+ <!-- ════════════════════════ MAIN CONTENT ═════════════════════════ -->
107
+ <main class="flex flex-1 overflow-hidden">
108
+ <!-- ─────────────── LEFT PANEL ──────────────── -->
109
+ <aside
110
+ class="w-72 shrink-0 bg-white border-r border-gray-200 flex flex-col overflow-hidden"
111
+ >
112
+ <div class="scroll-panel flex-1 p-4 space-y-5">
113
+ <!-- § Server Details -->
114
+ <section>
115
+ <h2
116
+ class="text-xs font-semibold uppercase tracking-widest text-gray-400 mb-3"
117
+ >
118
+ Server Details
119
+ </h2>
120
+
121
+ <div class="mb-3">
122
+ <label
123
+ class="block text-xs font-medium text-gray-700 mb-1"
124
+ for="wsEnvironments"
125
+ >Environment</label
126
+ >
127
+ <select
128
+ id="wsEnvironments"
129
+ class="block w-full rounded border-gray-300 text-xs py-1.5 focus:ring-indigo-500 focus:border-indigo-500"
130
+ >
131
+ <option value="other" selected>Local (other)</option>
132
+ <option value="wss://dev.api.ellielabs.com">PSS-Dev</option>
133
+ <option value="wss://qa.api.ellielabs.com">PSS-Qa</option>
134
+ <option value="wss://int.api.ellielabs.com">PSS-Int</option>
135
+ <option value="wss://peg.api.ellielabs.com">PSS-Peg</option>
136
+ <option value="wss://stg.api.elliemae.com">PSS-Stg</option>
137
+ <option value="wss://api.elliemae.com">PSS-Prod</option>
138
+ </select>
139
+ </div>
140
+
141
+ <div id="wsCustomDomainField" class="mb-3">
142
+ <label
143
+ class="block text-xs font-medium text-gray-700 mb-1"
144
+ for="wsCustomDomain"
145
+ >Server Domain</label
146
+ >
147
+ <input
148
+ type="text"
149
+ id="wsCustomDomain"
150
+ autocomplete="off"
151
+ value="ws://localhost:5000"
152
+ placeholder="ws://localhost:5000"
153
+ class="block w-full rounded border-gray-300 text-xs py-1.5 focus:ring-indigo-500 focus:border-indigo-500"
154
+ />
155
+ </div>
156
+
157
+ <div class="mb-3">
158
+ <label
159
+ class="block text-xs font-medium text-gray-700 mb-1"
160
+ for="wsPath"
161
+ >WS Path</label
162
+ >
163
+ <input
164
+ type="text"
165
+ id="wsPath"
166
+ autocomplete="off"
167
+ value="encompass/v1/stream"
168
+ placeholder="encompass/v1/stream"
169
+ class="block w-full rounded border-gray-300 text-xs py-1.5 focus:ring-indigo-500 focus:border-indigo-500"
170
+ />
171
+ </div>
172
+
173
+ <div class="mb-4">
174
+ <label
175
+ class="block text-xs font-medium text-gray-700 mb-1"
176
+ for="wsToken"
177
+ >Auth Token</label
178
+ >
179
+ <input
180
+ type="text"
181
+ id="wsToken"
182
+ autocomplete="off"
183
+ value="12345"
184
+ placeholder="Bearer token..."
185
+ class="block w-full rounded border-gray-300 text-xs py-1.5 font-mono focus:ring-indigo-500 focus:border-indigo-500"
186
+ />
187
+ </div>
188
+
189
+ <div class="flex gap-2">
190
+ <button
191
+ id="btnConnect"
192
+ class="flex-1 rounded bg-indigo-600 text-white text-xs font-medium py-1.5 hover:bg-indigo-700 active:bg-indigo-800 transition"
193
+ >
194
+ Connect
195
+ </button>
196
+ <button
197
+ id="btnDisconnect"
198
+ disabled
199
+ class="flex-1 rounded bg-gray-200 text-gray-400 text-xs font-medium py-1.5 cursor-not-allowed transition"
200
+ >
201
+ Disconnect
202
+ </button>
203
+ </div>
204
+
205
+ <div
206
+ id="connectError"
207
+ class="hidden mt-2 text-xs text-red-600 bg-red-50 border border-red-200 rounded p-2"
208
+ ></div>
209
+ </section>
210
+
211
+ <!-- § Guest Panel -->
212
+ <section id="guestPanel" class="hidden">
213
+ <h2
214
+ class="text-xs font-semibold uppercase tracking-widest text-gray-400 mb-3"
215
+ >
216
+ Guest Simulator
217
+ </h2>
218
+ <div
219
+ id="guestStatus"
220
+ class="text-xs text-gray-500 bg-gray-50 rounded border border-gray-200 p-2"
221
+ >
222
+ Loading guest…
223
+ </div>
224
+ </section>
225
+
226
+ <!-- § About -->
227
+ <section class="text-xs text-gray-400 space-y-1 pt-2">
228
+ <p class="font-medium text-gray-500">How this works</p>
229
+ <p>
230
+ 1. Click <strong>Connect</strong> to open the WebSocket and
231
+ register the SO with SSF Host.
232
+ </p>
233
+ <p>
234
+ 2. The guest iframe loads automatically and connects to the host
235
+ via SSF Guest.
236
+ </p>
237
+ <p>3. Use the guest panel to subscribe and receive live events.</p>
238
+ </section>
239
+ </div>
240
+ </aside>
241
+
242
+ <!-- ──────────────── RIGHT AREA ─────────────── -->
243
+ <div class="flex-1 flex flex-col overflow-hidden">
244
+ <!-- ── Guest Simulator ── -->
245
+ <div
246
+ class="flex flex-col border-b border-gray-200"
247
+ style="height: 580px"
248
+ >
249
+ <div
250
+ class="flex items-center justify-between px-4 py-2 bg-white border-b border-gray-200 shrink-0"
251
+ >
252
+ <h2
253
+ class="text-xs font-semibold uppercase tracking-widest text-gray-400"
254
+ >
255
+ Guest Simulator
256
+ </h2>
257
+ <span id="guestFrameLabel" class="text-xs text-gray-400 italic"
258
+ >Connect to load guest</span
259
+ >
260
+ </div>
261
+
262
+ <!-- placeholder shown before connect -->
263
+ <div
264
+ id="guest-placeholder"
265
+ class="flex flex-col items-center justify-center flex-1 bg-gray-50 text-gray-400 gap-2"
266
+ >
267
+ <svg
268
+ class="w-10 h-10 text-gray-300"
269
+ fill="none"
270
+ stroke="currentColor"
271
+ viewBox="0 0 24 24"
272
+ >
273
+ <path
274
+ stroke-linecap="round"
275
+ stroke-linejoin="round"
276
+ stroke-width="1.5"
277
+ d="M9 17v-2a4 4 0 014-4h4m0 0l-3-3m3 3l-3 3M9 7H5a2 2 0 00-2 2v8a2 2 0 002 2h4"
278
+ />
279
+ </svg>
280
+ <p class="text-sm">Connect to load the guest simulator</p>
281
+ </div>
282
+
283
+ <!-- iframe container – SSFHost.loadGuest() appends the iframe here -->
284
+ <div
285
+ id="guest-area"
286
+ class="hidden flex-1 overflow-hidden bg-white"
287
+ ></div>
288
+ </div>
289
+
290
+ <!-- ── Host Event Log ── -->
291
+ <div class="flex flex-col flex-1 overflow-hidden">
292
+ <div
293
+ class="flex items-center justify-between px-4 py-2 bg-white border-b border-gray-200 shrink-0"
294
+ >
295
+ <h2
296
+ class="text-xs font-semibold uppercase tracking-widest text-gray-400"
297
+ >
298
+ Host Event Log
299
+ </h2>
300
+ <button
301
+ id="btnClearLog"
302
+ class="text-xs text-gray-500 hover:text-red-500 transition"
303
+ >
304
+ Clear
305
+ </button>
306
+ </div>
307
+ <div
308
+ id="hostLog"
309
+ class="scroll-panel flex-1 p-3 bg-gray-950 font-mono"
310
+ >
311
+ <p class="text-gray-600 italic text-xs text-center py-4">
312
+ Events dispatched through the SO will appear here.
313
+ </p>
314
+ </div>
315
+ </div>
316
+ </div>
317
+ </main>
318
+
319
+ <!-- ════════════════ CDN DEPENDENCIES ════════════════ -->
320
+ <!--
321
+ pui-diagnostics: sets window.emuiDiagnostics
322
+ ssf-host: sets window.ice.host.{SSFHost, ScriptingObject, Event, …}
323
+ -->
324
+ <script
325
+ src="https://assets.ellieservices.com/pui-diagnostics@3.2.0/umd/index.js"
326
+ async
327
+ ></script>
328
+ <script src="https://cdn.mortgagetech.ice.com/ssf-host" async></script>
329
+
330
+ <!-- SO UMD is injected here by pui-cli start → window.emuiWebsocketSo.WebSocketSO -->
331
+
332
+ <!-- ════════════════════ PLAYGROUND SCRIPT ═════════════════════ -->
333
+ <script type="module">
334
+ /* ── Constants ──────────────────────────────────────────────── */
335
+ const GUEST_ID = 'wsso-guest-1';
336
+ const GUEST_URL = '/guest.html';
337
+ const ANALYTICS_MOCK = {
338
+ sendBAEvent: async () => {},
339
+ startTiming: async () => {},
340
+ endTiming: async () => {},
341
+ };
342
+
343
+ /* ── State ──────────────────────────────────────────────────── */
344
+ let hostInstance = null;
345
+ let soInstance = null;
346
+ let connected = false;
347
+ let timerInterval = null;
348
+ let connectedAt = null;
349
+ let eventsDispatched = 0;
350
+ let activeSubs = 0;
351
+
352
+ /* ── UI refs ────────────────────────────────────────────────── */
353
+ const $pill = document.getElementById('statusPill');
354
+ const $timer = document.getElementById('timer');
355
+ const $stats = document.getElementById('stats');
356
+ const $statEvents = document.getElementById('statEvents');
357
+ const $statSubs = document.getElementById('statSubs');
358
+ const $btnConnect = document.getElementById('btnConnect');
359
+ const $btnDisconn = document.getElementById('btnDisconnect');
360
+ const $connectErr = document.getElementById('connectError');
361
+ const $guestPanel = document.getElementById('guestPanel');
362
+ const $guestStatus = document.getElementById('guestStatus');
363
+ const $guestLabel = document.getElementById('guestFrameLabel');
364
+ const $placeholder = document.getElementById('guest-placeholder');
365
+ const $guestArea = document.getElementById('guest-area');
366
+ const $hostLog = document.getElementById('hostLog');
367
+ const $clearBtn = document.getElementById('btnClearLog');
368
+
369
+ /* ── Environment picker ─────────────────────────────────────── */
370
+ const $env = document.getElementById('wsEnvironments');
371
+ const $domain = document.getElementById('wsCustomDomain');
372
+ const $path = document.getElementById('wsPath');
373
+
374
+ $env.addEventListener('change', () => {
375
+ const show = $env.value === 'other';
376
+ document.getElementById('wsCustomDomainField').style.display = show
377
+ ? ''
378
+ : 'none';
379
+ });
380
+
381
+ function getWsUrl() {
382
+ const base =
383
+ $env.value === 'other'
384
+ ? $domain.value.trim().replace(/\/$/, '')
385
+ : $env.value;
386
+ const path = $path.value.trim().replace(/^\//, '');
387
+ return `${base}/${path}`;
388
+ }
389
+
390
+ /* ── Status pill ────────────────────────────────────────────── */
391
+ function setStatus(label, colour) {
392
+ const colours = {
393
+ gray: 'bg-gray-100 text-gray-600',
394
+ yellow: 'bg-yellow-100 text-yellow-700',
395
+ green: 'bg-green-100 text-green-700',
396
+ red: 'bg-red-100 text-red-700',
397
+ };
398
+ $pill.className = `${
399
+ colours[colour] ?? colours.gray
400
+ } inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium`;
401
+ $pill.textContent = `● ${label}`;
402
+ }
403
+
404
+ /* ── Timer ──────────────────────────────────────────────────── */
405
+ function startTimer() {
406
+ connectedAt = Date.now();
407
+ $timer.classList.remove('hidden');
408
+ $stats.classList.remove('hidden');
409
+ timerInterval = setInterval(() => {
410
+ const s = Math.floor((Date.now() - connectedAt) / 1000);
411
+ const hh = String(Math.floor(s / 3600)).padStart(2, '0');
412
+ const mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0');
413
+ const ss = String(s % 60).padStart(2, '0');
414
+ $timer.textContent = `${hh}:${mm}:${ss}`;
415
+ }, 1000);
416
+ }
417
+ function stopTimer() {
418
+ clearInterval(timerInterval);
419
+ $timer.classList.add('hidden');
420
+ $stats.classList.add('hidden');
421
+ }
422
+
423
+ /* ── Dependency loader ──────────────────────────────────────── */
424
+ function waitForGlobals(paths, timeout = 15_000) {
425
+ return new Promise((resolve, reject) => {
426
+ const deadline = Date.now() + timeout;
427
+ const check = setInterval(() => {
428
+ const allReady = paths.every(
429
+ (p) =>
430
+ p.split('.').reduce((obj, k) => obj?.[k], window) !== undefined,
431
+ );
432
+ if (allReady) {
433
+ clearInterval(check);
434
+ resolve();
435
+ return;
436
+ }
437
+ if (Date.now() > deadline) {
438
+ clearInterval(check);
439
+ reject(new Error(`Timed out waiting for: ${paths.join(', ')}`));
440
+ }
441
+ }, 50);
442
+ });
443
+ }
444
+
445
+ /* ── Logger factory ─────────────────────────────────────────── */
446
+ function buildLogger(tag = 'WebSocketSO') {
447
+ const prefix = `[${tag}]`;
448
+ if (window.emuiDiagnostics) {
449
+ const { logger, http } = window.emuiDiagnostics;
450
+ try {
451
+ return logger({
452
+ transport: http(
453
+ 'https://int.api.ellielabs.com/diagnostics/v2/logging',
454
+ ),
455
+ index: 'wsso-playground',
456
+ team: 'ui platform',
457
+ appName: tag,
458
+ });
459
+ } catch {
460
+ /* fall through */
461
+ }
462
+ }
463
+ // Console fallback
464
+ return {
465
+ debug: (m, ...a) => console.debug(prefix, m, ...a),
466
+ info: (m, ...a) => console.info(prefix, m, ...a),
467
+ warn: (m, ...a) => console.warn(prefix, m, ...a),
468
+ error: (m, ...a) => console.error(prefix, m, ...a),
469
+ audit: (m, ...a) => console.info(prefix, '[AUDIT]', m, ...a),
470
+ setLogLevel: () => {},
471
+ getLogLevel: () => 'debug',
472
+ };
473
+ }
474
+
475
+ /* ── Host log ───────────────────────────────────────────────── */
476
+ function syntaxHighlight(obj) {
477
+ const json = JSON.stringify(obj, null, 2);
478
+ return json.replace(
479
+ /("(\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"(?:\s*:)?|\b(true|false|null)\b|-?\d+\.?\d*(?:[eE][+-]?\d+)?)/g,
480
+ (m) => {
481
+ if (/^"/.test(m))
482
+ return /:$/.test(m)
483
+ ? `<span class="jk">${m}</span>`
484
+ : `<span class="js">${m}</span>`;
485
+ if (/true|false/.test(m)) return `<span class="jb">${m}</span>`;
486
+ if (/null/.test(m)) return `<span class="jz">${m}</span>`;
487
+ return `<span class="jn">${m}</span>`;
488
+ },
489
+ );
490
+ }
491
+
492
+ function appendLog(type, label, data, cssClass = 'log-info') {
493
+ const wasAtBottom =
494
+ $hostLog.scrollHeight - $hostLog.scrollTop <=
495
+ $hostLog.clientHeight + 4;
496
+ const ts = new Date().toLocaleTimeString('en-US', { hour12: false });
497
+
498
+ const row = document.createElement('div');
499
+ row.className = `${cssClass} mb-2 pl-3 py-1 rounded-sm`;
500
+
501
+ const head = document.createElement('div');
502
+ head.className = 'flex items-center gap-2 mb-0.5';
503
+ head.innerHTML = `
504
+ <span class="text-gray-500 text-xs tabular-nums">${ts}</span>
505
+ <span class="text-gray-300 text-xs font-medium">${label}</span>`;
506
+ row.appendChild(head);
507
+
508
+ if (data !== undefined) {
509
+ const pre = document.createElement('pre');
510
+ pre.className = 'text-gray-300';
511
+ pre.innerHTML =
512
+ typeof data === 'object'
513
+ ? syntaxHighlight(data)
514
+ : `<span class="js">${String(data)}</span>`;
515
+ row.appendChild(pre);
516
+ }
517
+
518
+ // Replace initial placeholder
519
+ const placeholder = $hostLog.querySelector('p.italic');
520
+ if (placeholder) placeholder.remove();
521
+
522
+ $hostLog.appendChild(row);
523
+ if (wasAtBottom) $hostLog.scrollTop = $hostLog.scrollHeight;
524
+ }
525
+
526
+ $clearBtn.addEventListener('click', () => {
527
+ $hostLog.innerHTML =
528
+ '<p class="text-gray-600 italic text-xs text-center py-4">Cleared.</p>';
529
+ });
530
+
531
+ /* ── Guest area toggle ──────────────────────────────────────── */
532
+ function showGuestFrame() {
533
+ $placeholder.classList.add('hidden');
534
+ $guestArea.classList.remove('hidden');
535
+ $guestLabel.textContent = `Guest ID: ${GUEST_ID}`;
536
+ $guestPanel.classList.remove('hidden');
537
+ $guestStatus.textContent = 'Loading…';
538
+ }
539
+ function showGuestPlaceholder() {
540
+ $guestArea.classList.add('hidden');
541
+ $guestArea.innerHTML = '';
542
+ $placeholder.classList.remove('hidden');
543
+ $guestLabel.textContent = 'Connect to load guest';
544
+ $guestPanel.classList.add('hidden');
545
+ }
546
+
547
+ /* ── Dispatch-event interceptor ─────────────────────────────── */
548
+ /**
549
+ * Wraps the SSFHost so we can observe every dispatchEvent call
550
+ * and log it in the host log.
551
+ */
552
+ function patchHostDispatch(host) {
553
+ const original = host.dispatchEvent.bind(host);
554
+ host.dispatchEvent = async (params) => {
555
+ const { event, eventParams, eventOptions } = params;
556
+ eventsDispatched++;
557
+ $statEvents.textContent = String(eventsDispatched);
558
+
559
+ appendLog(
560
+ 'event',
561
+ `⚡ dispatch → guest "${eventOptions?.guestId ?? '*'}"`,
562
+ {
563
+ event,
564
+ eventParams,
565
+ eventOptions,
566
+ },
567
+ 'log-event',
568
+ );
569
+
570
+ return original(params);
571
+ };
572
+ }
573
+
574
+ /* ── Connect ────────────────────────────────────────────────── */
575
+ $btnConnect.addEventListener('click', async () => {
576
+ $connectErr.classList.add('hidden');
577
+ $btnConnect.disabled = true;
578
+ setStatus('Connecting…', 'yellow');
579
+
580
+ try {
581
+ await waitForGlobals([
582
+ 'emuiWebsocketSo.WebSocketSO',
583
+ 'ice.host.SSFHost',
584
+ ]);
585
+ } catch (e) {
586
+ showError(`CDN not loaded: ${e.message}`);
587
+ $btnConnect.disabled = false;
588
+ setStatus('Disconnected', 'gray');
589
+ return;
590
+ }
591
+
592
+ const url = getWsUrl();
593
+ const token = document.getElementById('wsToken').value.trim();
594
+ const logger = buildLogger('WebSocketSO');
595
+
596
+ try {
597
+ // Create SSFHost once; reuse on subsequent connects
598
+ if (!hostInstance) {
599
+ hostInstance = new ice.host.SSFHost('wsso-playground', {
600
+ logger,
601
+ analyticsObj: ANALYTICS_MOCK,
602
+ });
603
+ patchHostDispatch(hostInstance);
604
+ appendLog('info', '🏠 SSFHost created', {
605
+ hostId: 'wsso-playground',
606
+ });
607
+ }
608
+
609
+ // Remove stale SO from previous session (if any)
610
+ try {
611
+ hostInstance.removeScriptingObject('websocket');
612
+ } catch {
613
+ /* none registered */
614
+ }
615
+
616
+ // Create the WebSocket Scripting Object
617
+ soInstance = new emuiWebsocketSo.WebSocketSO({
618
+ logger,
619
+ host: hostInstance,
620
+ url,
621
+ token,
622
+ onError: (err) => {
623
+ appendLog(
624
+ 'error',
625
+ '🔴 SO error',
626
+ { message: err.message },
627
+ 'log-error',
628
+ );
629
+ setStatus('Error', 'red');
630
+ },
631
+ });
632
+ appendLog('info', '⚙️ WebSocketSO created', { url, token: '***' });
633
+
634
+ // Register SO with SSFHost
635
+ hostInstance.addScriptingObject(soInstance);
636
+ appendLog('info', '📋 SO registered with SSFHost as "websocket"');
637
+
638
+ // Open the WebSocket connection
639
+ await soInstance.open();
640
+ appendLog('open', '🟢 WebSocket opened', { url }, 'log-open');
641
+
642
+ connected = true;
643
+ setStatus('Connected', 'green');
644
+ startTimer();
645
+ eventsDispatched = 0;
646
+ $statEvents.textContent = '0';
647
+ $statSubs.textContent = '0';
648
+
649
+ $btnConnect.disabled = false;
650
+ $btnConnect.classList.add('opacity-50', 'cursor-not-allowed');
651
+ $btnConnect.disabled = true;
652
+ $btnDisconn.disabled = false;
653
+ $btnDisconn.classList.remove(
654
+ 'bg-gray-200',
655
+ 'text-gray-400',
656
+ 'cursor-not-allowed',
657
+ );
658
+ $btnDisconn.classList.add(
659
+ 'bg-red-600',
660
+ 'text-white',
661
+ 'hover:bg-red-700',
662
+ );
663
+
664
+ // Show guest area and load the guest iframe
665
+ showGuestFrame();
666
+ hostInstance.loadGuest({
667
+ id: GUEST_ID,
668
+ url: GUEST_URL,
669
+ title: 'Guest Simulator',
670
+ targetElement: $guestArea,
671
+ });
672
+ appendLog('info', `👤 loadGuest("${GUEST_ID}")`, { url: GUEST_URL });
673
+ $guestStatus.textContent = 'Guest loaded. Connecting via SSF…';
674
+ } catch (err) {
675
+ showError(err.message ?? String(err));
676
+ setStatus('Error', 'red');
677
+ $btnConnect.disabled = false;
678
+ }
679
+ });
680
+
681
+ /* ── Disconnect ─────────────────────────────────────────────── */
682
+ $btnDisconn.addEventListener('click', async () => {
683
+ $btnDisconn.disabled = true;
684
+
685
+ try {
686
+ hostInstance.unloadGuest(GUEST_ID);
687
+ } catch {
688
+ /* ignore */
689
+ }
690
+ showGuestPlaceholder();
691
+
692
+ if (soInstance) {
693
+ try {
694
+ soInstance.close();
695
+ appendLog('close', '🔴 WebSocket closed', undefined, 'log-close');
696
+ } catch {
697
+ /* ignore */
698
+ }
699
+ try {
700
+ hostInstance.removeScriptingObject('websocket');
701
+ } catch {
702
+ /* ignore */
703
+ }
704
+ soInstance = null;
705
+ }
706
+
707
+ connected = false;
708
+ setStatus('Disconnected', 'gray');
709
+ stopTimer();
710
+
711
+ $btnConnect.disabled = false;
712
+ $btnConnect.classList.remove('opacity-50', 'cursor-not-allowed');
713
+ $btnDisconn.disabled = true;
714
+ $btnDisconn.classList.add(
715
+ 'bg-gray-200',
716
+ 'text-gray-400',
717
+ 'cursor-not-allowed',
718
+ );
719
+ $btnDisconn.classList.remove(
720
+ 'bg-red-600',
721
+ 'text-white',
722
+ 'hover:bg-red-700',
723
+ );
724
+ });
725
+
726
+ /* ── Error display ──────────────────────────────────────────── */
727
+ function showError(msg) {
728
+ $connectErr.textContent = msg;
729
+ $connectErr.classList.remove('hidden');
730
+ }
731
+
732
+ /* ── Listen for guest status messages ───────────────────────── */
733
+ window.addEventListener('message', (ev) => {
734
+ if (ev.data?.__soPlayground !== true) return;
735
+ const { type, payload } = ev.data;
736
+
737
+ if (type === 'guest-ready') {
738
+ $guestStatus.innerHTML =
739
+ '<span class="text-green-600 font-medium">✓ Guest connected and SO proxy retrieved</span>';
740
+ appendLog('info', '👤 Guest ready', { guestId: GUEST_ID }, 'log-sub');
741
+ }
742
+ if (type === 'subscribe-ack') {
743
+ activeSubs++;
744
+ $statSubs.textContent = String(activeSubs);
745
+ appendLog('sub', '✅ Guest subscribed', payload, 'log-sub');
746
+ }
747
+ if (type === 'unsubscribe-ack') {
748
+ activeSubs = Math.max(0, activeSubs - 1);
749
+ $statSubs.textContent = String(activeSubs);
750
+ appendLog('unsub', '🗑️ Guest unsubscribed', payload, 'log-unsub');
751
+ }
752
+ if (type === 'guest-error') {
753
+ appendLog('error', '❌ Guest error', payload, 'log-error');
754
+ }
755
+ });
756
+ </script>
757
+ </body>
758
+ </html>