main
js 747 lines 21.1 KB
Raw
1 import { io } from "/vendor/socket.io.esm.min.js";
2 import { getCsrfToken, getRuntimeId, invalidateCsrfToken } from "/js/api.js";
3 import { getCurrentUserISOString } from "/js/time-utils.js";
4
5 const MAX_PAYLOAD_BYTES = 50 * 1024 * 1024; // 50MB hard cap per contract
6 const DEFAULT_TIMEOUT_MS = 0;
7
8 const _UUID_HEX = [..."0123456789abcdef"];
9 const _OPTION_KEYS = new Set(["correlationId", "includeHandlers", "excludeHandlers", "excludeSids"]);
10
11 /**
12 * @param {unknown} value
13 * @param {string} fieldName
14 * @returns {Record<string, any>}
15 */
16 function assertPlainObject(value, fieldName) {
17 if (!value || typeof value !== "object" || Array.isArray(value)) {
18 throw new Error(`${fieldName} must be a plain object`);
19 }
20 return /** @type {Record<string, any>} */ (value);
21 }
22
23 /**
24 * @returns {string}
25 */
26 function generateUuid() {
27 if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
28 return crypto.randomUUID();
29 }
30
31 const buffer = new Uint8Array(16);
32 if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
33 crypto.getRandomValues(buffer);
34 } else {
35 for (let i = 0; i < buffer.length; i += 1) {
36 buffer[i] = Math.floor(Math.random() * 256);
37 }
38 }
39
40 buffer[6] = (buffer[6] & 0x0f) | 0x40; // version 4
41 buffer[8] = (buffer[8] & 0x3f) | 0x80; // variant 10
42
43 let uuid = "";
44 for (let i = 0; i < buffer.length; i += 1) {
45 if (i === 4 || i === 6 || i === 8 || i === 10) {
46 uuid += "-";
47 }
48 uuid += _UUID_HEX[buffer[i] >> 4];
49 uuid += _UUID_HEX[buffer[i] & 0x0f];
50 }
51 return uuid;
52 }
53
54 /**
55 * @param {unknown} value
56 * @param {string} fieldName
57 * @param {{ allowEmpty?: boolean }} [options]
58 * @returns {string[] | undefined}
59 */
60 function normalizeStringList(value, fieldName, options = {}) {
61 if (value == null) return undefined;
62 const raw = Array.isArray(value) ? value : [value];
63 const normalized = [];
64 for (const item of raw) {
65 if (typeof item !== "string" || item.trim().length === 0) {
66 throw new Error(`${fieldName} must contain non-empty strings`);
67 }
68 normalized.push(item.trim());
69 }
70 const deduped = Array.from(new Set(normalized));
71 if (!options.allowEmpty && deduped.length === 0) {
72 throw new Error(`${fieldName} must contain at least one value`);
73 }
74 return deduped.length > 0 ? deduped : undefined;
75 }
76
77 /**
78 * @param {unknown} value
79 * @returns {string[] | undefined}
80 */
81 function normalizeSidList(value) {
82 return normalizeStringList(value, "excludeSids", { allowEmpty: true });
83 }
84
85 /**
86 * @param {unknown} value
87 * @returns {string | undefined}
88 */
89 function normalizeCorrelationId(value) {
90 if (value == null) return undefined;
91 if (typeof value !== "string") {
92 throw new Error("correlationId must be a non-empty string");
93 }
94 const trimmed = value.trim();
95 if (!trimmed) {
96 throw new Error("correlationId must be a non-empty string");
97 }
98 return trimmed;
99 }
100
101 /**
102 * @param {unknown} value
103 * @returns {string}
104 */
105 function normalizeNamespace(value) {
106 if (typeof value !== "string") {
107 throw new Error("namespace must be a non-empty string");
108 }
109 const trimmed = value.trim();
110 if (!trimmed) {
111 throw new Error("namespace must be a non-empty string");
112 }
113 if (trimmed === "/") {
114 return "/";
115 }
116 return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
117 }
118
119 function hasWildcardPattern(value) {
120 return typeof value === "string" && value.includes("*");
121 }
122
123 function compileEventPattern(value) {
124 const escaped = value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
125 return new RegExp(`^${escaped.replaceAll("*", ".*")}$`);
126 }
127
128 /**
129 * Generate a correlation identifier using UUIDv4 semantics.
130 *
131 * @param {string} [prefix]
132 * @returns {string}
133 */
134 export function createCorrelationId(prefix) {
135 const uuid = generateUuid();
136 if (typeof prefix !== "string" || prefix.trim().length === 0) {
137 return uuid;
138 }
139
140 const normalizedPrefix = prefix.trim();
141 const suffix = normalizedPrefix.endsWith("-") ? "" : "-";
142 return `${normalizedPrefix}${suffix}${uuid}`;
143 }
144
145 /**
146 * @typedef {Object} NormalizedProducerOptions
147 * @property {string[]=} includeHandlers
148 * @property {string[]=} excludeHandlers
149 * @property {string[]=} excludeSids
150 * @property {string=} correlationId
151 */
152
153 /**
154 * Normalise producer options used for emit/request/broadcast helpers.
155 *
156 * @param {Record<string, any> | undefined} options
157 * @returns {NormalizedProducerOptions}
158 */
159 export function normalizeProducerOptions(options) {
160 if (options == null) return {};
161 const source = assertPlainObject(options, "options");
162
163 const unknownKeys = Object.keys(source).filter((key) => !_OPTION_KEYS.has(key));
164 if (unknownKeys.length > 0) {
165 throw new Error(`Unsupported producer option(s): ${unknownKeys.join(", ")}`);
166 }
167
168 const normalized = {};
169
170 const includeHandlers = normalizeStringList(source.includeHandlers, "includeHandlers");
171 if (includeHandlers) {
172 normalized.includeHandlers = includeHandlers;
173 }
174
175 const excludeHandlers = normalizeStringList(
176 source.excludeHandlers,
177 "excludeHandlers",
178 { allowEmpty: true },
179 );
180 if (excludeHandlers && excludeHandlers.length > 0) {
181 normalized.excludeHandlers = excludeHandlers;
182 }
183
184 const excludeSids = normalizeSidList(source.excludeSids);
185 if (excludeSids && excludeSids.length > 0) {
186 normalized.excludeSids = excludeSids;
187 }
188
189 const correlationId = normalizeCorrelationId(source.correlationId);
190 if (correlationId) {
191 normalized.correlationId = correlationId;
192 }
193
194 if (normalized.includeHandlers && normalized.excludeHandlers) {
195 throw new Error("includeHandlers and excludeHandlers cannot be used together");
196 }
197
198 return normalized;
199 }
200
201 /**
202 * @typedef {Object} ServerDeliveryEnvelope
203 * @property {string} handlerId
204 * @property {string} eventId
205 * @property {string} correlationId
206 * @property {string} ts
207 * @property {Record<string, any>} data
208 */
209
210 /**
211 * Validate a server-sent delivery envelope before invoking subscribers.
212 *
213 * @param {unknown} envelope
214 * @returns {ServerDeliveryEnvelope}
215 */
216 export function validateServerEnvelope(envelope) {
217 const value = assertPlainObject(envelope, "envelope");
218
219 const handlerId = normalizeCorrelationId(value.handlerId)?.trim();
220 if (!handlerId) {
221 throw new Error("Server envelope missing handlerId");
222 }
223
224 const eventId = normalizeCorrelationId(value.eventId)?.trim();
225 if (!eventId) {
226 throw new Error("Server envelope missing eventId");
227 }
228
229 const correlationId = normalizeCorrelationId(value.correlationId);
230 if (!correlationId) {
231 throw new Error("Server envelope missing correlationId");
232 }
233
234 if (typeof value.ts !== "string" || value.ts.trim().length === 0) {
235 throw new Error("Server envelope missing timestamp");
236 }
237 const timestamp = value.ts.trim();
238 if (Number.isNaN(Date.parse(timestamp))) {
239 throw new Error("Server envelope timestamp is invalid");
240 }
241
242 let data = value.data;
243 if (data == null) {
244 data = {};
245 } else if (typeof data !== "object" || Array.isArray(data)) {
246 throw new Error("Server envelope data must be a plain object");
247 }
248
249 const normalized = {
250 handlerId,
251 eventId,
252 correlationId,
253 ts: timestamp,
254 data: Object.freeze({ ...data }),
255 };
256
257 return Object.freeze(normalized);
258 }
259
260 class WebSocketClient {
261 constructor(namespace = "/") {
262 this.namespace = normalizeNamespace(namespace);
263 this.socket = null;
264 this.connected = false;
265 this.connecting = false;
266 this.connectPromise = null;
267 this.subscriptions = new Map(); // eventType -> { handler, callbacks: Set<Function> }
268 this.connectCallbacks = new Set();
269 this.disconnectCallbacks = new Set();
270 this.errorCallbacks = new Set();
271 this.isDevelopment = Boolean(window.runtimeInfo?.isDevelopment);
272 this._manualDisconnect = false;
273 this._hasConnectedOnce = false;
274 this._lastRuntimeId = null;
275 this._csrfInvalidatedForConnectError = false;
276 this._connectErrorRetryTimer = null;
277 this._connectErrorRetryAttempt = 0;
278 this._handlers = new Set();
279 }
280
281 /**
282 * Declare WS handler paths to activate on connect (e.g. "ws_webui").
283 * Must be called before connect().
284 * @param {string[]} handlers
285 */
286 addHandlers(handlers) {
287 if (!Array.isArray(handlers)) return;
288 let changed = false;
289 for (const h of handlers) {
290 if (typeof h === "string" && h.trim()) {
291 const key = h.trim();
292 if (!this._handlers.has(key)) {
293 this._handlers.add(key);
294 changed = true;
295 }
296 }
297 }
298 // If new handlers were added while already connected, reconnect so the
299 // updated handler list is sent to the server via the auth callback.
300 if (changed && this.socket && this.socket.connected) {
301 this.debugLog("addHandlers: reconnecting to activate new handlers", [...this._handlers]);
302 this.socket.disconnect();
303 this.socket.connect();
304 }
305 }
306
307 _clearConnectErrorRetryTimer() {
308 if (this._connectErrorRetryTimer) {
309 clearTimeout(this._connectErrorRetryTimer);
310 this._connectErrorRetryTimer = null;
311 }
312 }
313
314 _scheduleConnectErrorRetry(reason) {
315 if (this._manualDisconnect) return;
316 if (this.connected) return;
317 if (!this.socket) return;
318 if (this.socket.connected) return;
319 if (this._connectErrorRetryTimer) return;
320
321 const attempt = Math.max(0, Number(this._connectErrorRetryAttempt) || 0);
322 const baseMs = 250;
323 const capMs = 10000;
324 const delayMs = Math.min(capMs, baseMs * 2 ** attempt);
325 this._connectErrorRetryAttempt = attempt + 1;
326
327 this.debugLog("schedule connect retry", { reason, attempt, delayMs });
328 this._connectErrorRetryTimer = setTimeout(() => {
329 this._connectErrorRetryTimer = null;
330 if (this._manualDisconnect) return;
331 if (this.connected) return;
332 this.connect().catch(() => {});
333 }, delayMs);
334 }
335
336 buildPayload(data) {
337 const ts = getCurrentUserISOString();
338 if (data == null) {
339 return { ts, data: {} };
340 }
341 if (typeof data !== "object" || Array.isArray(data)) {
342 throw new Error("WebSocket payload must be a plain object");
343 }
344 return { ts, data: { ...data } };
345 }
346
347 applyProducerOptions(payload, normalizedOptions, allowances) {
348 const result = payload;
349
350 if (normalizedOptions.includeHandlers) {
351 if (!allowances.includeHandlers) {
352 throw new Error("This operation does not support includeHandlers");
353 }
354 result.includeHandlers = [...normalizedOptions.includeHandlers];
355 }
356
357 if (normalizedOptions.excludeHandlers) {
358 if (!allowances.excludeHandlers) {
359 throw new Error("This operation does not support excludeHandlers");
360 }
361 result.excludeHandlers = [...normalizedOptions.excludeHandlers];
362 }
363
364 if (normalizedOptions.excludeSids) {
365 if (!allowances.excludeSids) {
366 throw new Error("This operation does not support excludeSids");
367 }
368 result.excludeSids = [...normalizedOptions.excludeSids];
369 }
370
371 if (normalizedOptions.correlationId) {
372 result.correlationId = normalizedOptions.correlationId;
373 }
374
375 return result;
376 }
377
378 setDevelopmentFlag(value) {
379 const normalized = Boolean(value);
380 this.isDevelopment = normalized;
381 window.runtimeInfo = { ...(window.runtimeInfo || {}), isDevelopment: normalized };
382 }
383
384 debugLog(...args) {
385 if (this.isDevelopment) {
386 console.debug(`[websocket:${this.namespace}]`, ...args);
387 }
388 }
389
390 async connect() {
391 if (this.connected) return;
392 if (this.connectPromise) return this.connectPromise;
393
394 this._manualDisconnect = false;
395 this.connecting = true;
396 this.connectPromise = (async () => {
397 if (!this.socket) {
398 this.initializeSocket();
399 }
400
401 if (this.socket.connected) return;
402
403 // Ensure the current runtime-bound session + CSRF cookies exist before initiating
404 // the Engine.IO handshake. This is required for seamless reconnect after backend
405 // restarts that rotate runtime_id and session cookie names.
406 try {
407 await getCsrfToken();
408 } catch (error) {
409 this.debugLog("csrf prefetch failed - continuing", {
410 error: error instanceof Error ? error.message : String(error),
411 });
412 }
413
414 await new Promise((resolve, reject) => {
415 const onConnect = () => {
416 this.socket.off("connect_error", onError);
417 resolve();
418 };
419 const onError = (error) => {
420 this.socket.off("connect", onConnect);
421 reject(error instanceof Error ? error : new Error(String(error)));
422 };
423
424 this.socket.once("connect", onConnect);
425 this.socket.once("connect_error", onError);
426 this.socket.connect();
427 });
428 })()
429 .catch((error) => {
430 throw new Error(`WebSocket connection failed: ${error.message || error}`);
431 })
432 .finally(() => {
433 this.connecting = false;
434 this.connectPromise = null;
435 });
436
437 return this.connectPromise;
438 }
439
440 async disconnect() {
441 if (!this.socket) return;
442 this._manualDisconnect = true;
443 this.socket.disconnect();
444 this.connected = false;
445 }
446
447 isConnected() {
448 return this.connected;
449 }
450
451 async emit(eventType, data, options = {}) {
452 const correlationId =
453 normalizeCorrelationId(options?.correlationId) || createCorrelationId("emit");
454 const payload = this.buildPayload(data);
455 payload.correlationId = correlationId;
456
457 this.debugLog("emit", {
458 eventType,
459 correlationId,
460 });
461 this.ensurePayloadSize(payload);
462 await this.connect();
463 if (!this.isConnected()) {
464 throw new Error("Not connected");
465 }
466 this.socket.emit(eventType, payload);
467 }
468
469 async request(eventType, data, options = {}) {
470 const correlationId =
471 normalizeCorrelationId(options?.correlationId) ||
472 createCorrelationId("request");
473 const payload = this.buildPayload(data);
474 payload.correlationId = correlationId;
475
476 const timeoutMs = Number(options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
477 this.debugLog("request", { eventType, correlationId, timeoutMs });
478 if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
479 throw new Error("timeoutMs must be a non-negative number");
480 }
481 this.ensurePayloadSize(payload);
482 await this.connect();
483 if (!this.isConnected()) {
484 throw new Error("Not connected");
485 }
486
487 return new Promise((resolve, reject) => {
488 if (timeoutMs > 0) {
489 this.socket
490 .timeout(timeoutMs)
491 .emit(eventType, payload, (err, response) => {
492 if (err) {
493 reject(new Error("Request timeout"));
494 return;
495 }
496 resolve(this.normalizeRequestResponse(response));
497 });
498 return;
499 }
500
501 this.socket.emit(eventType, payload, (response) => {
502 resolve(this.normalizeRequestResponse(response));
503 });
504 });
505 }
506
507 normalizeRequestResponse(response) {
508 if (!response || typeof response !== "object") {
509 return { correlationId: null, results: [] };
510 }
511 const correlationId =
512 typeof response.correlationId === "string" && response.correlationId.trim().length > 0
513 ? response.correlationId.trim()
514 : null;
515 const results = Array.isArray(response.results) ? response.results : [];
516 return { correlationId, results };
517 }
518
519 async on(eventType, callback) {
520 if (typeof callback !== "function") {
521 throw new Error("Callback must be a function");
522 }
523
524 await this.connect();
525
526 if (!this.subscriptions.has(eventType)) {
527 const isWildcard = hasWildcardPattern(eventType);
528 const eventPattern = isWildcard ? compileEventPattern(eventType) : null;
529 const handler = (...args) => {
530 const entry = this.subscriptions.get(eventType);
531 if (!entry) return;
532 const currentIsWildcard = Boolean(entry.eventPattern);
533 const payload = isWildcard ? args[1] : args[0];
534 const incomingEventType = isWildcard ? args[0] : eventType;
535 if (currentIsWildcard) {
536 if (typeof incomingEventType !== "string") return;
537 if (!entry.eventPattern.test(incomingEventType)) return;
538 }
539 let envelope;
540 try {
541 envelope = validateServerEnvelope(payload);
542 } catch (error) {
543 console.error("WebSocket envelope validation failed:", error);
544 this.invokeErrorCallbacks(error);
545 return;
546 }
547
548 entry.callbacks.forEach((cb) => {
549 try {
550 if (currentIsWildcard) {
551 cb(incomingEventType, envelope);
552 return;
553 }
554 cb(envelope);
555 } catch (error) {
556 console.error("WebSocket callback error:", error);
557 }
558 });
559 };
560
561 this.subscriptions.set(eventType, {
562 eventPattern,
563 handler,
564 callbacks: new Set(),
565 });
566
567 if (isWildcard) {
568 this.socket.onAny(handler);
569 } else {
570 this.socket.on(eventType, handler);
571 }
572 }
573
574 const entry = this.subscriptions.get(eventType);
575 entry.callbacks.add(callback);
576 }
577
578 off(eventType, callback) {
579 const entry = this.subscriptions.get(eventType);
580 if (!entry) return;
581
582 if (callback) {
583 entry.callbacks.delete(callback);
584 } else {
585 entry.callbacks.clear();
586 }
587
588 if (entry.callbacks.size === 0) {
589 if (this.socket) {
590 if (entry.eventPattern) {
591 this.socket.offAny(entry.handler);
592 } else {
593 this.socket.off(eventType, entry.handler);
594 }
595 }
596 this.subscriptions.delete(eventType);
597 }
598 }
599
600 onConnect(callback) {
601 if (typeof callback === "function") {
602 this.connectCallbacks.add(callback);
603 }
604 }
605
606 onDisconnect(callback) {
607 if (typeof callback === "function") {
608 this.disconnectCallbacks.add(callback);
609 }
610 }
611
612 onError(callback) {
613 if (typeof callback === "function") {
614 this.errorCallbacks.add(callback);
615 }
616 }
617
618 initializeSocket() {
619 this.socket = io(this.namespace, {
620 autoConnect: false,
621 reconnection: true,
622 transports: ["websocket", "polling"],
623 withCredentials: true,
624 auth: (cb) => {
625 const handlers = [...this._handlers];
626 getCsrfToken()
627 .then((token) => cb({ csrf_token: token, handlers }))
628 .catch((error) => {
629 console.error("[websocket] failed to fetch CSRF token for connect", error);
630 cb({ handlers });
631 });
632 },
633 });
634
635 this.socket.on("connect", () => {
636 this.connected = true;
637 this._csrfInvalidatedForConnectError = false;
638 this._connectErrorRetryAttempt = 0;
639 this._clearConnectErrorRetryTimer();
640
641 const runtimeId = getRuntimeId();
642 const runtimeChanged = Boolean(
643 this._lastRuntimeId &&
644 runtimeId &&
645 this._lastRuntimeId !== runtimeId
646 );
647 const firstConnect = !this._hasConnectedOnce;
648 this._hasConnectedOnce = true;
649 this._lastRuntimeId = runtimeId;
650
651 this.debugLog("socket connected", {
652 sid: this.socket.id,
653 runtimeId,
654 runtimeChanged,
655 firstConnect,
656 });
657 this.connectCallbacks.forEach((cb) => {
658 try {
659 cb({ runtimeId, runtimeChanged, firstConnect });
660 } catch (error) {
661 console.error("WebSocket onConnect callback error:", error);
662 }
663 });
664 });
665
666 this.socket.on("disconnect", (reason) => {
667 this.connected = false;
668 this.debugLog("socket disconnected", { reason });
669 this.disconnectCallbacks.forEach((cb) => {
670 try {
671 cb(reason);
672 } catch (error) {
673 console.error("WebSocket onDisconnect callback error:", error);
674 }
675 });
676 });
677
678 this.socket.on("connect_error", (error) => {
679 this.debugLog("socket connect_error", error);
680 this.invokeErrorCallbacks(error);
681 if (!this._csrfInvalidatedForConnectError) {
682 this._csrfInvalidatedForConnectError = true;
683 invalidateCsrfToken();
684 }
685 this._scheduleConnectErrorRetry("connect_error");
686 });
687
688 this.socket.on("error", (error) => {
689 this.debugLog("socket error", error);
690 this.invokeErrorCallbacks(error);
691 });
692 }
693
694 invokeErrorCallbacks(error) {
695 this.errorCallbacks.forEach((cb) => {
696 try {
697 cb(error);
698 } catch (err) {
699 console.error("WebSocket onError callback error:", err);
700 }
701 });
702 }
703
704 ensurePayloadSize(data) {
705 const size = this.calculatePayloadSize(data);
706 if (size > MAX_PAYLOAD_BYTES) {
707 throw new Error("Payload too large");
708 }
709 }
710
711 calculatePayloadSize(data) {
712 try {
713 return new TextEncoder().encode(JSON.stringify(data ?? null)).length;
714 } catch (_error) {
715 // Fallback: rough estimate if stringify fails
716 const stringified = String(data);
717 return stringified.length * 2;
718 }
719 }
720 }
721
722 const _namespacedClients = new Map();
723
724 /**
725 * Create a new Socket.IO client bound to a specific namespace.
726 *
727 * @param {string} namespace
728 * @returns {WebSocketClient}
729 */
730 export function createNamespacedClient(namespace) {
731 return new WebSocketClient(namespace);
732 }
733
734 /**
735 * Return a cached Socket.IO client for the given namespace (one per browser tab/window).
736 *
737 * @param {string} namespace
738 * @returns {WebSocketClient}
739 */
740 export function getNamespacedClient(namespace) {
741 const key = normalizeNamespace(namespace);
742 const existing = _namespacedClients.get(key);
743 if (existing) return existing;
744 const client = new WebSocketClient(key);
745 _namespacedClients.set(key, client);
746 return client;
747 }