socketspec 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,6 @@
1
+ # Copyright (c) 2025 Laiba Shahab. All rights reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0
4
+ # See LICENSE file in the project root for full license information.
5
+
6
+ """Static assets for the SocketSpec interactive docs UI."""
@@ -0,0 +1,72 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="description" content="SocketSpec interactive WebSocket event browser — try events, inspect payloads, and view live responses." />
7
+ <title>SocketSpec Docs</title>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
11
+ <style>{{INLINE_CSS}}</style>
12
+ </head>
13
+ <body>
14
+ <header class="topbar">
15
+ <div class="brand">
16
+ <h1>SocketSpec</h1>
17
+ <span id="version" class="version"></span>
18
+ </div>
19
+ <div class="controls">
20
+ <label class="ws-url-label">
21
+ <span>WebSocket URL</span>
22
+ <input id="ws-url" type="text" value="ws://localhost:8000/ws" />
23
+ </label>
24
+ <button id="connect-btn" type="button">Connect</button>
25
+ <button id="auth-btn" type="button">Authorize</button>
26
+ </div>
27
+ </header>
28
+
29
+ <div class="status-bar" id="status-bar">
30
+ <div class="status-indicator">
31
+ <div class="status-dot disconnected" id="status-dot"></div>
32
+ <span id="status-text">Disconnected</span>
33
+ </div>
34
+ <span class="conn-id" id="conn-id-display"></span>
35
+ <span class="conn-hint" id="conn-hint"></span>
36
+ </div>
37
+
38
+ <main class="layout">
39
+ <aside id="sidebar" class="sidebar"></aside>
40
+ <section id="content" class="content"></section>
41
+ </main>
42
+
43
+ <footer class="log-drawer">
44
+ <div class="log-header">
45
+ <strong>Live Log</strong>
46
+ <input id="log-filter" type="text" placeholder="Filter events…" />
47
+ <button id="clear-log" type="button">Clear</button>
48
+ </div>
49
+ <div id="log-output"></div>
50
+ </footer>
51
+
52
+ <dialog id="auth-modal">
53
+ <form method="dialog">
54
+ <h2>Authorize</h2>
55
+ <label>
56
+ <span>JWT / Bearer Token</span>
57
+ <input id="auth-token" type="text" placeholder="eyJhbGciOiJIUzI1NiIs…" />
58
+ </label>
59
+ <label>
60
+ <span>API Key</span>
61
+ <input id="auth-api-key" type="text" placeholder="x-api-key value" />
62
+ </label>
63
+ <menu>
64
+ <button value="cancel" type="submit" class="cancel-btn">Cancel</button>
65
+ <button id="auth-save" value="default" type="submit" class="send-btn">Save</button>
66
+ </menu>
67
+ </form>
68
+ </dialog>
69
+
70
+ <script>{{INLINE_JS}}</script>
71
+ </body>
72
+ </html>
@@ -0,0 +1,557 @@
1
+ /**
2
+ * SocketSpec Docs UI — main.js
3
+ *
4
+ * Swagger-style interactive event browser for WebSocket APIs.
5
+ * Grouped by tag, collapsible tag sections, property tables,
6
+ * exact Swagger-style Try-it-out flow, and live response blocks.
7
+ *
8
+ * Emojis are strictly forbidden in this UI.
9
+ */
10
+
11
+ /* ─── State ──────────────────────────────────────────────────────────────── */
12
+ const DOCS_BASE = window.location.pathname.replace(/\/+$/, '');
13
+
14
+ let schema = { version: "", events: [] };
15
+ let socket = null;
16
+ let authToken = "";
17
+ let authApiKey = "";
18
+ let connId = null;
19
+
20
+ /** Maps event name → { editor, responseBody, tryItArea, tryItBtn, executeBtn } */
21
+ const tryItContexts = {};
22
+
23
+ /** Maps event name → set of "server sends" event names, for response routing. */
24
+ const responseEventIndex = {};
25
+
26
+ /* ─── DOM refs ───────────────────────────────────────────────────────────── */
27
+ const versionEl = document.getElementById("version");
28
+ const sidebarEl = document.getElementById("sidebar");
29
+ const contentEl = document.getElementById("content");
30
+ const connectBtn = document.getElementById("connect-btn");
31
+ const wsUrlInput = document.getElementById("ws-url");
32
+ const logOutput = document.getElementById("log-output");
33
+ const logFilter = document.getElementById("log-filter");
34
+ const authModal = document.getElementById("auth-modal");
35
+ const statusDot = document.getElementById("status-dot");
36
+ const statusText = document.getElementById("status-text");
37
+ const connIdDisplay = document.getElementById("conn-id-display");
38
+ const connHint = document.getElementById("conn-hint");
39
+
40
+ /* ─── Logging ────────────────────────────────────────────────────────────── */
41
+ function logMessage(direction, data) {
42
+ const timestamp = new Date().toTimeString().split(' ')[0];
43
+ const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
44
+ const line = `[${timestamp}] [${direction}] ${text}`;
45
+
46
+ if (logFilter.value && !line.toLowerCase().includes(logFilter.value.toLowerCase())) {
47
+ return;
48
+ }
49
+
50
+ const div = document.createElement("div");
51
+ div.className = `log-line ${direction.toLowerCase()}`;
52
+ div.textContent = line;
53
+ logOutput.appendChild(div);
54
+ logOutput.scrollTop = logOutput.scrollHeight;
55
+ }
56
+
57
+ /* ─── Schema helpers ─────────────────────────────────────────────────────── */
58
+ function exampleFromSchema(modelSchema) {
59
+ if (!modelSchema || !modelSchema.properties) return {};
60
+ const example = {};
61
+ for (const [key, value] of Object.entries(modelSchema.properties)) {
62
+ const t = value.type;
63
+ if (t === "string") example[key] = value.examples?.[0] ?? "";
64
+ else if (t === "integer") example[key] = 0;
65
+ else if (t === "number") example[key] = 0.0;
66
+ else if (t === "boolean") example[key] = false;
67
+ else if (t === "array") example[key] = [];
68
+ else if (t === "object") example[key] = {};
69
+ else example[key] = null;
70
+ }
71
+ return example;
72
+ }
73
+
74
+ function buildSchemaTable(modelSchema) {
75
+ if (!modelSchema || !modelSchema.properties) {
76
+ const p = document.createElement("p");
77
+ p.className = "schema-empty";
78
+ p.textContent = "No payload schema defined.";
79
+ return p;
80
+ }
81
+
82
+ const required = new Set(modelSchema.required || []);
83
+ const table = document.createElement("table");
84
+ table.className = "schema-table";
85
+
86
+ const thead = table.createTHead();
87
+ const hrow = thead.insertRow();
88
+ for (const col of ["Name", "Type", "Required", "Description"]) {
89
+ const th = document.createElement("th");
90
+ th.textContent = col;
91
+ hrow.appendChild(th);
92
+ }
93
+
94
+ const tbody = table.createTBody();
95
+ for (const [name, def] of Object.entries(modelSchema.properties)) {
96
+ const row = tbody.insertRow();
97
+
98
+ const tdName = row.insertCell();
99
+ tdName.className = "col-name";
100
+ tdName.textContent = name;
101
+
102
+ const tdType = row.insertCell();
103
+ tdType.className = "col-type";
104
+ tdType.textContent = def.type ?? (def.$ref ? "object" : "any");
105
+
106
+ const tdReq = row.insertCell();
107
+ tdReq.className = "col-req";
108
+ if (required.has(name)) {
109
+ tdReq.innerHTML = '<span class="required-asterisk">*</span>';
110
+ } else {
111
+ tdReq.textContent = "";
112
+ }
113
+
114
+ const tdDesc = row.insertCell();
115
+ tdDesc.className = "col-desc";
116
+ tdDesc.textContent = def.description ?? def.title ?? "";
117
+ }
118
+
119
+ return table;
120
+ }
121
+
122
+ /* ─── Card building ──────────────────────────────────────────────────────── */
123
+ function primaryDirection(event) {
124
+ if (event.emits?.length || event.broadcasts?.length) return "emit";
125
+ return "listen";
126
+ }
127
+
128
+ function makeBadge(cls, label) {
129
+ const span = document.createElement("span");
130
+ span.className = `badge ${cls}`;
131
+ span.textContent = label;
132
+ return span;
133
+ }
134
+
135
+ function makeSectionBlock(labelText, labelBadge, bodyEl) {
136
+ const block = document.createElement("div");
137
+ block.className = "section-block";
138
+
139
+ const label = document.createElement("div");
140
+ label.className = "section-label";
141
+ label.textContent = labelText;
142
+ if (labelBadge) label.appendChild(labelBadge);
143
+
144
+ block.appendChild(label);
145
+ block.appendChild(bodyEl);
146
+ return block;
147
+ }
148
+
149
+ function showResponse(block, data, cls) {
150
+ const responseBlock = block.closest('.response-block');
151
+ responseBlock.style.display = "block";
152
+ responseBlock.className = `response-block ${cls}`;
153
+ block.textContent = JSON.stringify(data, null, 2);
154
+ }
155
+
156
+ /**
157
+ * Build the Try-it-out section for an event card.
158
+ * Implements the exact Swagger UI flow:
159
+ * - A "Try it out" button is shown.
160
+ * - Clicking it changes the button text to "Cancel".
161
+ * - It displays the payload textarea editor and a blue "Execute" button.
162
+ * - Clicking "Execute" sends the WebSocket event.
163
+ * - Server responses are shown in a Response block below the editor.
164
+ * - Clicking "Cancel" reverts all state.
165
+ */
166
+ function buildTryItSection(event) {
167
+ const wrapper = document.createElement("div");
168
+ wrapper.className = "try-it-container";
169
+
170
+ const tryItBtn = document.createElement("button");
171
+ tryItBtn.className = "try-it-btn";
172
+ tryItBtn.type = "button";
173
+ tryItBtn.textContent = "Try it out";
174
+
175
+ const tryItArea = document.createElement("div");
176
+ tryItArea.className = "try-it-area";
177
+
178
+ const editor = document.createElement("textarea");
179
+ editor.className = "payload-editor";
180
+ editor.rows = 8;
181
+ editor.value = JSON.stringify(exampleFromSchema(event.payload), null, 2);
182
+
183
+ const executeBtn = document.createElement("button");
184
+ executeBtn.type = "button";
185
+ executeBtn.className = "execute-btn";
186
+ executeBtn.textContent = "Execute";
187
+
188
+ const responseBlock = document.createElement("div");
189
+ responseBlock.className = "response-block";
190
+ responseBlock.style.display = "none";
191
+
192
+ const responseLabel = document.createElement("div");
193
+ responseLabel.className = "response-label";
194
+ responseLabel.textContent = "Responses";
195
+
196
+ const responseBody = document.createElement("pre");
197
+ responseBody.className = "response-body";
198
+
199
+ responseBlock.appendChild(responseLabel);
200
+ responseBlock.appendChild(responseBody);
201
+
202
+ tryItArea.appendChild(editor);
203
+ tryItArea.appendChild(executeBtn);
204
+ tryItArea.appendChild(responseBlock);
205
+
206
+ // Try it out toggle flow
207
+ tryItBtn.addEventListener("click", () => {
208
+ if (tryItBtn.textContent === "Try it out") {
209
+ tryItBtn.textContent = "Cancel";
210
+ tryItBtn.classList.add("cancel-mode");
211
+ tryItArea.style.display = "block";
212
+ } else {
213
+ tryItBtn.textContent = "Try it out";
214
+ tryItBtn.classList.remove("cancel-mode");
215
+ tryItArea.style.display = "none";
216
+ responseBlock.style.display = "none";
217
+ }
218
+ });
219
+
220
+ executeBtn.addEventListener("click", () => {
221
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
222
+ showResponse(responseBody, { error: "WebSocket is not connected." }, "error");
223
+ return;
224
+ }
225
+ let payload;
226
+ try {
227
+ payload = JSON.parse(editor.value || "{}");
228
+ } catch (err) {
229
+ showResponse(responseBody, { error: `Invalid JSON: ${err.message}` }, "error");
230
+ return;
231
+ }
232
+ const message = { event: event.name, payload };
233
+ socket.send(JSON.stringify(message));
234
+ logMessage("OUT", message);
235
+ showResponse(responseBody, { status: "Sent event, waiting for response..." }, "success");
236
+ });
237
+
238
+ tryItContexts[event.name] = { editor, responseBody, tryItArea, tryItBtn, executeBtn };
239
+
240
+ wrapper.appendChild(tryItBtn);
241
+ wrapper.appendChild(tryItArea);
242
+ return wrapper;
243
+ }
244
+
245
+ function buildCard(event) {
246
+ const dir = primaryDirection(event);
247
+ const isDeprecated = !!event.deprecated;
248
+
249
+ const card = document.createElement("article");
250
+ const dirClass = isDeprecated ? "deprecated" : dir;
251
+ card.className = `card dir-${dirClass}`;
252
+ card.id = `card-${event.name}`;
253
+
254
+ // ── Header (Swagger operation row style) ──
255
+ const header = document.createElement("div");
256
+ header.className = "card-header";
257
+
258
+ const badgeText = isDeprecated ? "DEPRECATED" : dir.toUpperCase();
259
+ header.appendChild(makeBadge(dirClass, badgeText));
260
+
261
+ const nameEl = document.createElement("span");
262
+ nameEl.className = "event-name";
263
+ nameEl.textContent = event.name;
264
+
265
+ const descEl = document.createElement("span");
266
+ descEl.className = "event-desc";
267
+ descEl.textContent = event.description || "";
268
+
269
+ const chevron = document.createElement("span");
270
+ chevron.className = "chevron-card";
271
+ chevron.textContent = "▼";
272
+
273
+ header.appendChild(nameEl);
274
+ header.appendChild(descEl);
275
+ header.appendChild(chevron);
276
+
277
+ header.addEventListener("click", () => card.classList.toggle("open"));
278
+ card.appendChild(header);
279
+
280
+ // ── Body ──
281
+ const body = document.createElement("div");
282
+ body.className = "card-body";
283
+
284
+ // Section 1 — Parameters (Client sends)
285
+ const parametersContainer = document.createElement("div");
286
+ parametersContainer.className = "parameters-container";
287
+ parametersContainer.appendChild(buildSchemaTable(event.payload));
288
+
289
+ const section1 = makeSectionBlock("Parameters", null, parametersContainer);
290
+ // Add Try-it-out flow at the top-right of the Parameters section
291
+ section1.querySelector(".section-label").appendChild(buildTryItSection(event));
292
+ body.appendChild(section1);
293
+
294
+ // Section 2 — Server responds
295
+ if (event.emits && event.emits.length > 0) {
296
+ const emitsWrap = document.createElement("div");
297
+ for (const emit of event.emits) {
298
+ const subLabel = document.createElement("div");
299
+ subLabel.className = "section-sub";
300
+ subLabel.textContent = `socket.on("${emit.event}") ${emit.description ? "— " + emit.description : ""}`;
301
+ emitsWrap.appendChild(subLabel);
302
+ emitsWrap.appendChild(buildSchemaTable(emit.schema));
303
+ }
304
+ body.appendChild(
305
+ makeSectionBlock("Server responds to sender", makeBadge("listen", "LISTEN"), emitsWrap)
306
+ );
307
+
308
+ responseEventIndex[event.name] = responseEventIndex[event.name] || new Set();
309
+ for (const emit of event.emits) {
310
+ responseEventIndex[event.name].add(emit.event);
311
+ }
312
+ }
313
+
314
+ // Section 3 — Room broadcast
315
+ if (event.broadcasts && event.broadcasts.length > 0) {
316
+ const bcastWrap = document.createElement("div");
317
+ for (const bcast of event.broadcasts) {
318
+ const subLabel = document.createElement("div");
319
+ subLabel.className = "section-sub";
320
+ subLabel.textContent = `Room: ${bcast.room} — socket.on("${bcast.event}") ${bcast.description ? "— " + bcast.description : ""}`;
321
+ bcastWrap.appendChild(subLabel);
322
+ bcastWrap.appendChild(buildSchemaTable(bcast.schema));
323
+ }
324
+ body.appendChild(
325
+ makeSectionBlock("Broadcast to room", makeBadge("broadcast", "BROADCAST"), bcastWrap)
326
+ );
327
+ }
328
+
329
+ // Section 4 — Error responses (always shown)
330
+ const errorTable = document.createElement("table");
331
+ errorTable.className = "schema-table";
332
+
333
+ const errorHead = errorTable.createTHead();
334
+ const errorHeadRow = errorHead.insertRow();
335
+ const errorH1 = document.createElement("th");
336
+ errorH1.textContent = "Code";
337
+ const errorH2 = document.createElement("th");
338
+ errorH2.textContent = "When it occurs";
339
+ errorHeadRow.appendChild(errorH1);
340
+ errorHeadRow.appendChild(errorH2);
341
+
342
+ const errorBody = errorTable.createTBody();
343
+ const errorCodesMap = [
344
+ { code: "VALIDATION_ERROR", desc: "Payload failed Pydantic validation constraints or JSON parsing failed." },
345
+ { code: "RATE_LIMIT_ERROR", desc: "The connection exceeded its allowed rate limit." },
346
+ { code: "UNKNOWN_EVENT", desc: "The sent event name has no registered handler." },
347
+ { code: "PAYLOAD_TOO_LARGE", desc: "The incoming frame exceeded the maximum payload size." }
348
+ ];
349
+ for (const item of errorCodesMap) {
350
+ const errorRow = errorBody.insertRow();
351
+ const cellCode = errorRow.insertCell();
352
+ cellCode.className = "col-name";
353
+ cellCode.textContent = item.code;
354
+ const cellDesc = errorRow.insertCell();
355
+ cellDesc.className = "col-desc";
356
+ cellDesc.textContent = item.desc;
357
+ }
358
+
359
+ body.appendChild(
360
+ makeSectionBlock("Error responses", null, errorTable)
361
+ );
362
+
363
+ card.appendChild(body);
364
+ return card;
365
+ }
366
+
367
+ /* ─── Render ──────────────────────────────────────────────────────────────── */
368
+ function renderEvents() {
369
+ sidebarEl.innerHTML = "";
370
+ contentEl.innerHTML = "";
371
+
372
+ const groups = {};
373
+ for (const event of schema.events) {
374
+ const tag = event.tags?.[0] || event.namespace || "default";
375
+ (groups[tag] = groups[tag] || []).push(event);
376
+ }
377
+
378
+ for (const [tag, events] of Object.entries(groups)) {
379
+ // Sidebar Group Header
380
+ const sidebarTagLabel = document.createElement("div");
381
+ sidebarTagLabel.className = "sidebar-tag";
382
+ sidebarTagLabel.textContent = tag;
383
+ sidebarEl.appendChild(sidebarTagLabel);
384
+
385
+ for (const event of events) {
386
+ const dir = primaryDirection(event);
387
+ const navBtn = document.createElement("button");
388
+ navBtn.className = "sidebar-nav-btn";
389
+
390
+ const navBadge = document.createElement("span");
391
+ navBadge.className = `nav-badge ${dir}`;
392
+ navBadge.textContent = dir === "emit" ? "E" : "L";
393
+
394
+ navBtn.appendChild(navBadge);
395
+ navBtn.appendChild(document.createTextNode(event.name));
396
+ navBtn.type = "button";
397
+ navBtn.addEventListener("click", () => {
398
+ const card = document.getElementById(`card-${event.name}`);
399
+ if (card) {
400
+ card.classList.add("open");
401
+ card.scrollIntoView({ behavior: "smooth", block: "start" });
402
+ }
403
+ });
404
+ sidebarEl.appendChild(navBtn);
405
+ }
406
+
407
+ // Content tag group
408
+ const group = document.createElement("div");
409
+ group.className = "tag-group";
410
+
411
+ const groupHeader = document.createElement("button");
412
+ groupHeader.className = "tag-group-header";
413
+ groupHeader.type = "button";
414
+ groupHeader.innerHTML = `<span>${tag}</span><span class="tag-chevron">▼</span>`;
415
+ groupHeader.addEventListener("click", () => {
416
+ group.classList.toggle("collapsed");
417
+ });
418
+
419
+ const cardsWrapper = document.createElement("div");
420
+ cardsWrapper.className = "tag-cards";
421
+
422
+ for (const event of events) {
423
+ cardsWrapper.appendChild(buildCard(event));
424
+ }
425
+
426
+ group.appendChild(groupHeader);
427
+ group.appendChild(cardsWrapper);
428
+ contentEl.appendChild(group);
429
+ }
430
+ }
431
+
432
+ /* ─── Schema loading ─────────────────────────────────────────────────────── */
433
+ async function loadSchema() {
434
+ try {
435
+ const response = await fetch(`${DOCS_BASE}/schema`);
436
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
437
+ schema = await response.json();
438
+ versionEl.textContent = `v${schema.version}`;
439
+ renderEvents();
440
+ } catch (err) {
441
+ logMessage("SYS", { error: `Schema load failed: ${err.message}` });
442
+ }
443
+ }
444
+
445
+ /* ─── Connection status ──────────────────────────────────────────────────── */
446
+ function setConnected(connected, id) {
447
+ connId = id || null;
448
+ statusDot.className = `status-dot ${connected ? "connected" : "disconnected"}`;
449
+ statusText.textContent = connected ? "Connected" : "Disconnected";
450
+ connIdDisplay.textContent = connected && id ? id : "";
451
+ connHint.textContent = connected
452
+ ? "Open another tab to test as a different user"
453
+ : "";
454
+ connectBtn.textContent = connected ? "Disconnect" : "Connect";
455
+ connectBtn.classList.toggle("connected", connected);
456
+ }
457
+
458
+ /* ─── Incoming message routing ───────────────────────────────────────────── */
459
+ function routeIncomingToCard(data) {
460
+ const serverEvent = data?.event;
461
+ if (!serverEvent) return;
462
+
463
+ for (const [triggerEvent, listenEvents] of Object.entries(responseEventIndex)) {
464
+ if (listenEvents.has(serverEvent)) {
465
+ const ctx = tryItContexts[triggerEvent];
466
+ if (ctx && ctx.tryItArea.style.display !== "none") {
467
+ showResponse(ctx.responseBody, data, "success");
468
+ }
469
+ }
470
+ }
471
+
472
+ if (serverEvent === "__error__") {
473
+ for (const ctx of Object.values(tryItContexts)) {
474
+ if (ctx.tryItArea.style.display !== "none") {
475
+ showResponse(ctx.responseBody, data, "error");
476
+ }
477
+ }
478
+ }
479
+ }
480
+
481
+ /* ─── WebSocket URL builder ──────────────────────────────────────────────── */
482
+ function buildWsUrl() {
483
+ let url = wsUrlInput.value.trim();
484
+ const params = new URLSearchParams();
485
+ if (authToken) params.set("token", authToken);
486
+ if (authApiKey) params.set("api_key", authApiKey);
487
+ const query = params.toString();
488
+ if (query) url += (url.includes("?") ? "&" : "?") + query;
489
+ return url;
490
+ }
491
+
492
+ /* ─── Event listeners ────────────────────────────────────────────────────── */
493
+ connectBtn.addEventListener("click", () => {
494
+ if (socket && socket.readyState === WebSocket.OPEN) {
495
+ socket.close();
496
+ return;
497
+ }
498
+
499
+ try {
500
+ socket = new WebSocket(buildWsUrl());
501
+ } catch (err) {
502
+ logMessage("ERR", { error: `Invalid URL: ${err.message}` });
503
+ return;
504
+ }
505
+
506
+ socket.onopen = () => {
507
+ const shortId = "conn_" + Math.random().toString(36).slice(2, 7);
508
+ setConnected(true, shortId);
509
+ logMessage("SYS", { status: "connected", url: wsUrlInput.value });
510
+ };
511
+
512
+ socket.onclose = (ev) => {
513
+ setConnected(false, null);
514
+ logMessage("SYS", { status: "disconnected", code: ev.code, reason: ev.reason || "—" });
515
+ };
516
+
517
+ socket.onmessage = (ev) => {
518
+ try {
519
+ const parsed = JSON.parse(ev.data);
520
+ if (parsed.event === '__ping__') {
521
+ socket.send(JSON.stringify({ event: '__pong__', payload: {} }));
522
+ return;
523
+ }
524
+ logMessage("IN", parsed);
525
+ routeIncomingToCard(parsed);
526
+ } catch {
527
+ logMessage("IN", ev.data);
528
+ }
529
+ };
530
+
531
+ socket.onerror = () => logMessage("ERR", { status: "WebSocket error" });
532
+ });
533
+
534
+ document.getElementById("auth-btn").addEventListener("click", () => authModal.showModal());
535
+
536
+ document.getElementById("auth-save").addEventListener("click", () => {
537
+ authToken = document.getElementById("auth-token").value.trim();
538
+ authApiKey = document.getElementById("auth-api-key").value.trim();
539
+ logMessage("SYS", { status: "credentials saved" });
540
+ });
541
+
542
+ document.getElementById("clear-log").addEventListener("click", () => {
543
+ logOutput.innerHTML = "";
544
+ });
545
+
546
+ logFilter.addEventListener("input", () => {
547
+ const val = logFilter.value.toLowerCase();
548
+ for (const line of logOutput.children) {
549
+ line.style.display = val && !line.textContent.toLowerCase().includes(val)
550
+ ? "none"
551
+ : "";
552
+ }
553
+ });
554
+
555
+ /* ─── Boot ───────────────────────────────────────────────────────────────── */
556
+ setConnected(false, null);
557
+ loadSchema();