cynx-lib 1.3.49 → 1.3.51

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cynx-lib",
3
- "version": "1.3.49",
3
+ "version": "1.3.51",
4
4
  "type": "module",
5
5
  "main": "./index.cjs",
6
6
  "module": "./index.js",
@@ -196,6 +196,7 @@ async function sync_db(options = {}) {
196
196
  freshCheckTable,
197
197
  logger: rawLogger = console,
198
198
  dryRun = false,
199
+ syncColumnTypes = false,
199
200
  maxRetries = 3,
200
201
  retryDelayMs = 300,
201
202
  throwOnError = false
@@ -316,16 +317,21 @@ async function sync_db(options = {}) {
316
317
  log("Analyzing schema.sql and comparing with database catalogs (Zero-Lock)...");
317
318
  const [tablesRes, colsRes, idxRes, conRes] = await Promise.all([
318
319
  queryFn("SELECT table_name FROM information_schema.tables WHERE table_schema = $1 AND table_type = 'BASE TABLE'", [tableSchema]),
319
- queryFn("SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = $1", [tableSchema]),
320
+ queryFn("SELECT table_name, column_name, data_type, udt_name FROM information_schema.columns WHERE table_schema = $1", [tableSchema]),
320
321
  queryFn("SELECT indexname FROM pg_indexes WHERE schemaname = $1", [tableSchema]),
321
322
  queryFn("SELECT conname FROM pg_constraint")
322
323
  ]);
323
324
  const existingTables = new Set((tablesRes.rows || []).map((r) => String(r.table_name).toLowerCase()));
324
325
  const existingColumns = new Set((colsRes.rows || []).map((r) => `${String(r.table_name).toLowerCase()}.${String(r.column_name).toLowerCase()}`));
326
+ const existingColumnTypes = new Map((colsRes.rows || []).map((r) => [
327
+ `${String(r.table_name).toLowerCase()}.${String(r.column_name).toLowerCase()}`,
328
+ { dataType: String(r.data_type || "").toLowerCase(), udtName: String(r.udt_name || "").toLowerCase() }
329
+ ]));
325
330
  const existingIndexes = new Set((idxRes.rows || []).map((r) => String(r.indexname).toLowerCase()));
326
331
  const existingConstraints = new Set((conRes.rows || []).map((r) => String(r.conname).toLowerCase()));
327
332
  const createdTables = [];
328
333
  const addedColumns = [];
334
+ const alteredColumns = [];
329
335
  const createdIndexes = [];
330
336
  const droppedConstraints = [];
331
337
  const droppedIndexes = [];
@@ -373,6 +379,36 @@ async function sync_db(options = {}) {
373
379
  addedColumns.push(colKey);
374
380
  log(`[DryRun] Would add column: ${colKey}`);
375
381
  }
382
+ } else if (syncColumnTypes) {
383
+ const currentType = existingColumnTypes.get(colKey);
384
+ const defUpper = col.def.toUpperCase().trim();
385
+ let targetType = null;
386
+ if (/\bTIMESTAMPTZ\b|\bTIMESTAMP\s+WITH\s+TIME\s+ZONE\b/.test(defUpper)) {
387
+ if (currentType && (currentType.dataType === "timestamp without time zone" || currentType.udtName === "timestamp")) {
388
+ targetType = "TIMESTAMPTZ";
389
+ }
390
+ } else if (/\bBIGINT\b|\bBIGSERIAL\b/.test(defUpper)) {
391
+ if (currentType && (currentType.dataType === "integer" || currentType.udtName === "int4")) {
392
+ targetType = "BIGINT";
393
+ }
394
+ } else if (/\bTEXT\b/.test(defUpper)) {
395
+ if (currentType && (currentType.dataType === "character varying" || currentType.udtName === "varchar")) {
396
+ targetType = "TEXT";
397
+ }
398
+ }
399
+ if (targetType) {
400
+ const sql = `ALTER TABLE ${table.name} ALTER COLUMN ${col.name} TYPE ${targetType};`;
401
+ if (!dryRun) {
402
+ const ok = await runSafeDdl(queryFn, sql, `alter column ${colKey} to ${targetType}`, safeDdlOpts);
403
+ if (ok) {
404
+ alteredColumns.push(`${colKey} (${targetType})`);
405
+ log(`Altered column type: ${colKey} -> ${targetType}`);
406
+ }
407
+ } else {
408
+ alteredColumns.push(`${colKey} (${targetType})`);
409
+ log(`[DryRun] Would alter column type: ${colKey} -> ${targetType}`);
410
+ }
411
+ }
376
412
  }
377
413
  }
378
414
  }
@@ -445,11 +481,12 @@ async function sync_db(options = {}) {
445
481
  const summaryParts = [];
446
482
  if (createdTables.length > 0) summaryParts.push(`${createdTables.length} tables created`);
447
483
  if (addedColumns.length > 0) summaryParts.push(`${addedColumns.length} columns added`);
484
+ if (alteredColumns.length > 0) summaryParts.push(`${alteredColumns.length} columns altered`);
448
485
  if (createdIndexes.length > 0) summaryParts.push(`${createdIndexes.length} indexes created`);
449
486
  if (droppedConstraints.length > 0) summaryParts.push(`${droppedConstraints.length} constraints dropped`);
450
487
  if (droppedIndexes.length > 0) summaryParts.push(`${droppedIndexes.length} indexes dropped`);
451
488
  if (executedDoBlocks > 0) summaryParts.push(`${executedDoBlocks} migration blocks run`);
452
- const changesCount = createdTables.length + addedColumns.length + createdIndexes.length + droppedConstraints.length + droppedIndexes.length;
489
+ const changesCount = createdTables.length + addedColumns.length + alteredColumns.length + createdIndexes.length + droppedConstraints.length + droppedIndexes.length;
453
490
  const summary = summaryParts.length > 0 ? `Database synced in ${durationMs}ms: ${summaryParts.join(", ")}` : `Database schema is up to date (0 changes needed, ${durationMs}ms)`;
454
491
  log(summary);
455
492
  return {
@@ -457,6 +494,7 @@ async function sync_db(options = {}) {
457
494
  isFreshDb: false,
458
495
  createdTables,
459
496
  addedColumns,
497
+ alteredColumns,
460
498
  createdIndexes,
461
499
  droppedConstraints,
462
500
  droppedIndexes,
package/utils/utils/db.js CHANGED
@@ -281,6 +281,7 @@ export async function sync_db(options = {}) {
281
281
  freshCheckTable,
282
282
  logger: rawLogger = console,
283
283
  dryRun = false,
284
+ syncColumnTypes = false,
284
285
  maxRetries = 3,
285
286
  retryDelayMs = 300,
286
287
  throwOnError = false,
@@ -409,18 +410,23 @@ export async function sync_db(options = {}) {
409
410
  // ── 2. Query System Catalogs in parallel (AccessShareLock only - 0ms blocking) ──
410
411
  const [tablesRes, colsRes, idxRes, conRes] = await Promise.all([
411
412
  queryFn("SELECT table_name FROM information_schema.tables WHERE table_schema = $1 AND table_type = 'BASE TABLE'", [tableSchema]),
412
- queryFn("SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = $1", [tableSchema]),
413
+ queryFn("SELECT table_name, column_name, data_type, udt_name FROM information_schema.columns WHERE table_schema = $1", [tableSchema]),
413
414
  queryFn("SELECT indexname FROM pg_indexes WHERE schemaname = $1", [tableSchema]),
414
415
  queryFn("SELECT conname FROM pg_constraint"),
415
416
  ]);
416
417
 
417
418
  const existingTables = new Set((tablesRes.rows || []).map((r) => String(r.table_name).toLowerCase()));
418
419
  const existingColumns = new Set((colsRes.rows || []).map((r) => `${String(r.table_name).toLowerCase()}.${String(r.column_name).toLowerCase()}`));
420
+ const existingColumnTypes = new Map((colsRes.rows || []).map((r) => [
421
+ `${String(r.table_name).toLowerCase()}.${String(r.column_name).toLowerCase()}`,
422
+ { dataType: String(r.data_type || '').toLowerCase(), udtName: String(r.udt_name || '').toLowerCase() }
423
+ ]));
419
424
  const existingIndexes = new Set((idxRes.rows || []).map((r) => String(r.indexname).toLowerCase()));
420
425
  const existingConstraints = new Set((conRes.rows || []).map((r) => String(r.conname).toLowerCase()));
421
426
 
422
427
  const createdTables = [];
423
428
  const addedColumns = [];
429
+ const alteredColumns = [];
424
430
  const createdIndexes = [];
425
431
  const droppedConstraints = [];
426
432
  const droppedIndexes = [];
@@ -476,6 +482,39 @@ export async function sync_db(options = {}) {
476
482
  addedColumns.push(colKey);
477
483
  log(`[DryRun] Would add column: ${colKey}`);
478
484
  }
485
+ } else if (syncColumnTypes) {
486
+ // Check for safe column type discrepancies when syncColumnTypes flag is true
487
+ const currentType = existingColumnTypes.get(colKey);
488
+ const defUpper = col.def.toUpperCase().trim();
489
+
490
+ let targetType = null;
491
+ if (/\bTIMESTAMPTZ\b|\bTIMESTAMP\s+WITH\s+TIME\s+ZONE\b/.test(defUpper)) {
492
+ if (currentType && (currentType.dataType === 'timestamp without time zone' || currentType.udtName === 'timestamp')) {
493
+ targetType = 'TIMESTAMPTZ';
494
+ }
495
+ } else if (/\bBIGINT\b|\bBIGSERIAL\b/.test(defUpper)) {
496
+ if (currentType && (currentType.dataType === 'integer' || currentType.udtName === 'int4')) {
497
+ targetType = 'BIGINT';
498
+ }
499
+ } else if (/\bTEXT\b/.test(defUpper)) {
500
+ if (currentType && (currentType.dataType === 'character varying' || currentType.udtName === 'varchar')) {
501
+ targetType = 'TEXT';
502
+ }
503
+ }
504
+
505
+ if (targetType) {
506
+ const sql = `ALTER TABLE ${table.name} ALTER COLUMN ${col.name} TYPE ${targetType};`;
507
+ if (!dryRun) {
508
+ const ok = await runSafeDdl(queryFn, sql, `alter column ${colKey} to ${targetType}`, safeDdlOpts);
509
+ if (ok) {
510
+ alteredColumns.push(`${colKey} (${targetType})`);
511
+ log(`Altered column type: ${colKey} -> ${targetType}`);
512
+ }
513
+ } else {
514
+ alteredColumns.push(`${colKey} (${targetType})`);
515
+ log(`[DryRun] Would alter column type: ${colKey} -> ${targetType}`);
516
+ }
517
+ }
479
518
  }
480
519
  }
481
520
  }
@@ -556,12 +595,13 @@ export async function sync_db(options = {}) {
556
595
  const summaryParts = [];
557
596
  if (createdTables.length > 0) summaryParts.push(`${createdTables.length} tables created`);
558
597
  if (addedColumns.length > 0) summaryParts.push(`${addedColumns.length} columns added`);
598
+ if (alteredColumns.length > 0) summaryParts.push(`${alteredColumns.length} columns altered`);
559
599
  if (createdIndexes.length > 0) summaryParts.push(`${createdIndexes.length} indexes created`);
560
600
  if (droppedConstraints.length > 0) summaryParts.push(`${droppedConstraints.length} constraints dropped`);
561
601
  if (droppedIndexes.length > 0) summaryParts.push(`${droppedIndexes.length} indexes dropped`);
562
602
  if (executedDoBlocks > 0) summaryParts.push(`${executedDoBlocks} migration blocks run`);
563
603
 
564
- const changesCount = createdTables.length + addedColumns.length + createdIndexes.length + droppedConstraints.length + droppedIndexes.length;
604
+ const changesCount = createdTables.length + addedColumns.length + alteredColumns.length + createdIndexes.length + droppedConstraints.length + droppedIndexes.length;
565
605
  const summary = summaryParts.length > 0
566
606
  ? `Database synced in ${durationMs}ms: ${summaryParts.join(', ')}`
567
607
  : `Database schema is up to date (0 changes needed, ${durationMs}ms)`;
@@ -573,6 +613,7 @@ export async function sync_db(options = {}) {
573
613
  isFreshDb: false,
574
614
  createdTables,
575
615
  addedColumns,
616
+ alteredColumns,
576
617
  createdIndexes,
577
618
  droppedConstraints,
578
619
  droppedIndexes,
@@ -19,6 +19,9 @@ var realtime_exports = {};
19
19
  __export(realtime_exports, {
20
20
  ConnectedClient: () => ConnectedClient,
21
21
  EventBus: () => EventBus,
22
+ RealtimeClient: () => RealtimeClient,
23
+ RealtimeDispatcher: () => RealtimeDispatcher,
24
+ RealtimeProxy: () => RealtimeProxy,
22
25
  RealtimeServer: () => RealtimeServer,
23
26
  RealtimeSocket: () => RealtimeSocket,
24
27
  SSEStream: () => SSEStream,
@@ -26,11 +29,15 @@ __export(realtime_exports, {
26
29
  TabChannel: () => TabChannel,
27
30
  createEventBus: () => createEventBus,
28
31
  createPoller: () => createPoller,
32
+ createRealtimeClient: () => createRealtimeClient,
33
+ createRealtimeDispatcher: () => createRealtimeDispatcher,
34
+ createRealtimeProxy: () => createRealtimeProxy,
29
35
  createRealtimeServer: () => createRealtimeServer,
30
36
  createRealtimeSocket: () => createRealtimeSocket,
31
37
  createSSE: () => createSSE,
32
38
  createTabChannel: () => createTabChannel,
33
39
  default: () => realtime_default,
40
+ normalizeWsUrl: () => normalizeWsUrl,
34
41
  parseAIStream: () => parseAIStream
35
42
  });
36
43
  module.exports = __toCommonJS(realtime_exports);
@@ -844,10 +851,494 @@ class RealtimeServer {
844
851
  function createRealtimeServer(options) {
845
852
  return new RealtimeServer(options);
846
853
  }
854
+ function normalizeWsUrl(rawUrl, defaultPath = "/ws") {
855
+ if (!rawUrl || typeof rawUrl !== "string") {
856
+ if (isBrowser) {
857
+ const isHttps2 = window.location.protocol === "https:";
858
+ const defaultScheme2 = isHttps2 ? "wss:" : "ws:";
859
+ return `${defaultScheme2}//${window.location.host}${defaultPath}`;
860
+ }
861
+ return "";
862
+ }
863
+ let url = rawUrl.trim();
864
+ const isHttps = isBrowser ? window.location.protocol === "https:" : false;
865
+ const defaultScheme = isHttps ? "wss:" : "ws:";
866
+ if (url.startsWith("//")) {
867
+ url = `${defaultScheme}${url}`;
868
+ } else if (url.startsWith("/")) {
869
+ if (isBrowser) {
870
+ url = `${defaultScheme}//${window.location.host}${url}`;
871
+ }
872
+ } else if (/^http:\/\//i.test(url)) {
873
+ url = url.replace(/^http:\/\//i, "ws://");
874
+ } else if (/^https:\/\//i.test(url)) {
875
+ url = url.replace(/^https:\/\//i, "wss://");
876
+ } else if (!/^wss?:\/\//i.test(url)) {
877
+ const isLocal = url.includes("localhost") || url.includes("127.0.0.1");
878
+ const scheme = isLocal && !isHttps ? "ws://" : "wss://";
879
+ url = `${scheme}${url}`;
880
+ }
881
+ try {
882
+ const httpEquivalent = url.replace(/^wss:/i, "https:").replace(/^ws:/i, "http:");
883
+ const parsed = new URL(httpEquivalent);
884
+ let pathname = parsed.pathname || defaultPath;
885
+ if (!pathname || pathname === "/") {
886
+ pathname = defaultPath;
887
+ }
888
+ pathname = pathname.replace(/\/+/g, "/").replace(/\/ws\/ws(\/|$)/g, "/ws$1");
889
+ const scheme = url.toLowerCase().startsWith("wss:") ? "wss:" : "ws:";
890
+ return `${scheme}//${parsed.host}${pathname}`;
891
+ } catch {
892
+ return url;
893
+ }
894
+ }
895
+ class RealtimeClient {
896
+ constructor(options = {}) {
897
+ const defaultUrl = isBrowser ? (window.location.protocol === "https:" ? "wss:" : "ws:") + "//" + window.location.host + "/ws" : "/ws";
898
+ this.options = {
899
+ url: defaultUrl,
900
+ token: void 0,
901
+ // string or function: () => token
902
+ room: void 0,
903
+ // optional room name or function: () => room
904
+ autoConnect: true,
905
+ reconnect: true,
906
+ maxRetries: Infinity,
907
+ initialRetryDelay: 1e3,
908
+ maxRetryDelay: 3e4,
909
+ backoffFactor: 1.5,
910
+ jitter: true,
911
+ heartbeatInterval: 2e4,
912
+ pingPayload: { type: "PING" },
913
+ ...options
914
+ };
915
+ this.events = new EventBus();
916
+ this.ws = null;
917
+ this.status = "DISCONNECTED";
918
+ this.retries = 0;
919
+ this._pingTimer = null;
920
+ this._reconnectTimer = null;
921
+ this._isExplicitClose = false;
922
+ this._connecting = false;
923
+ if (this.options.autoConnect) {
924
+ this.init();
925
+ }
926
+ }
927
+ getToken() {
928
+ if (typeof this.options.token === "function") {
929
+ return this.options.token();
930
+ }
931
+ if (typeof this.options.token === "string") {
932
+ return this.options.token;
933
+ }
934
+ if (isBrowser) {
935
+ return localStorage.getItem("token") || sessionStorage.getItem("token") || "";
936
+ }
937
+ return "";
938
+ }
939
+ getRoom() {
940
+ if (typeof this.options.room === "function") {
941
+ return this.options.room();
942
+ }
943
+ if (typeof this.options.room === "string") {
944
+ return this.options.room;
945
+ }
946
+ return "";
947
+ }
948
+ init() {
949
+ if (this.ws && (this.ws.readyState === 0 || this.ws.readyState === 1)) {
950
+ return;
951
+ }
952
+ const token = this.getToken();
953
+ if (!token && !this.options.anonymous) {
954
+ return;
955
+ }
956
+ this.connect();
957
+ }
958
+ connect() {
959
+ if (this._connecting) return;
960
+ if (this.ws && (this.ws.readyState === 0 || this.ws.readyState === 1)) return;
961
+ this._connecting = true;
962
+ this._isExplicitClose = false;
963
+ this.cleanup();
964
+ const WS = getWebSocketConstructor(this.options.WebSocket);
965
+ if (!WS) {
966
+ this._connecting = false;
967
+ console.warn("[cynx/realtime] WebSocket implementation not found in this environment.");
968
+ return;
969
+ }
970
+ try {
971
+ const rawUrl = typeof this.options.url === "function" ? this.options.url() : this.options.url;
972
+ const normalized = normalizeWsUrl(rawUrl);
973
+ const token = this.getToken();
974
+ const room = this.getRoom();
975
+ const urlObj = new URL(normalized.replace(/^ws:/i, "http:").replace(/^wss:/i, "https:"));
976
+ if (token && !urlObj.searchParams.has("token")) {
977
+ urlObj.searchParams.set("token", token);
978
+ }
979
+ if (room && !urlObj.searchParams.has("room")) {
980
+ urlObj.searchParams.set("room", room);
981
+ }
982
+ const finalScheme = normalized.toLowerCase().startsWith("wss:") ? "wss:" : "ws:";
983
+ const finalUrl = `${finalScheme}//${urlObj.host}${urlObj.pathname}${urlObj.search}`;
984
+ this.ws = new WS(finalUrl);
985
+ this.status = this.retries > 0 ? "RECONNECTING" : "CONNECTING";
986
+ this.ws.onopen = (e) => {
987
+ this._connecting = false;
988
+ this.retries = 0;
989
+ this.status = "CONNECTED";
990
+ this.events.emit("open", e);
991
+ this.events.emit("status", this.status);
992
+ this._startHeartbeat();
993
+ };
994
+ this.ws.onmessage = (event) => {
995
+ try {
996
+ const raw = typeof event.data === "string" ? event.data : String(event.data);
997
+ const parsed = safeJSONParse(raw, raw);
998
+ const eventType = parsed && typeof parsed === "object" ? parsed.type || parsed.event || "message" : "message";
999
+ if (parsed && parsed.type === "PONG") {
1000
+ return;
1001
+ }
1002
+ this.events.emit(eventType, parsed);
1003
+ this.events.emit("message", parsed);
1004
+ this.events.emit("*", parsed);
1005
+ } catch (err) {
1006
+ console.warn("[cynx/realtime] Message parse warning:", err);
1007
+ }
1008
+ };
1009
+ this.ws.onerror = (err) => {
1010
+ this.events.emit("error", err);
1011
+ };
1012
+ this.ws.onclose = (e) => {
1013
+ this._connecting = false;
1014
+ this.status = "DISCONNECTED";
1015
+ this._stopHeartbeat();
1016
+ this.events.emit("close", e);
1017
+ this.events.emit("status", this.status);
1018
+ if (!this._isExplicitClose && this.options.reconnect && this.retries < this.options.maxRetries) {
1019
+ this._handleReconnect();
1020
+ }
1021
+ };
1022
+ } catch (err) {
1023
+ this._connecting = false;
1024
+ this._handleReconnect();
1025
+ }
1026
+ }
1027
+ send(eventOrData, payload = void 0) {
1028
+ if (!this.ws || this.ws.readyState !== 1) return false;
1029
+ let message;
1030
+ if (payload !== void 0) {
1031
+ message = JSON.stringify({ type: eventOrData, payload });
1032
+ } else if (typeof eventOrData === "object") {
1033
+ message = JSON.stringify(eventOrData);
1034
+ } else {
1035
+ message = String(eventOrData);
1036
+ }
1037
+ this.ws.send(message);
1038
+ return true;
1039
+ }
1040
+ emit(event, payload = void 0) {
1041
+ return this.send(event, payload);
1042
+ }
1043
+ subscribe(callback) {
1044
+ const unsub = this.events.on("*", callback);
1045
+ this.init();
1046
+ return unsub;
1047
+ }
1048
+ on(event, callback) {
1049
+ const unsub = this.events.on(event, callback);
1050
+ this.init();
1051
+ return unsub;
1052
+ }
1053
+ once(event, callback) {
1054
+ const unsub = this.events.once(event, callback);
1055
+ this.init();
1056
+ return unsub;
1057
+ }
1058
+ off(event, callback) {
1059
+ this.events.off(event, callback);
1060
+ }
1061
+ disconnect(code = 1e3, reason = "Client disconnected") {
1062
+ this._isExplicitClose = true;
1063
+ this.cleanup();
1064
+ if (this.ws) {
1065
+ try {
1066
+ this.ws.close(code, reason);
1067
+ } catch {
1068
+ }
1069
+ this.ws = null;
1070
+ }
1071
+ this.status = "DISCONNECTED";
1072
+ this.events.emit("status", this.status);
1073
+ }
1074
+ cleanup() {
1075
+ this._stopHeartbeat();
1076
+ if (this._reconnectTimer) {
1077
+ clearTimeout(this._reconnectTimer);
1078
+ this._reconnectTimer = null;
1079
+ }
1080
+ }
1081
+ _startHeartbeat() {
1082
+ this._stopHeartbeat();
1083
+ if (!this.options.heartbeatInterval) return;
1084
+ this._pingTimer = setInterval(() => {
1085
+ if (this.ws && this.ws.readyState === 1) {
1086
+ try {
1087
+ this.ws.send(JSON.stringify(this.options.pingPayload));
1088
+ } catch {
1089
+ }
1090
+ }
1091
+ }, this.options.heartbeatInterval);
1092
+ }
1093
+ _stopHeartbeat() {
1094
+ if (this._pingTimer) {
1095
+ clearInterval(this._pingTimer);
1096
+ this._pingTimer = null;
1097
+ }
1098
+ }
1099
+ _handleReconnect() {
1100
+ if (this._reconnectTimer) return;
1101
+ this.retries++;
1102
+ const delay = Math.min(
1103
+ this.options.initialRetryDelay * Math.pow(this.options.backoffFactor, this.retries - 1),
1104
+ this.options.maxRetryDelay
1105
+ );
1106
+ const finalDelay = this.options.jitter ? delay * (0.8 + Math.random() * 0.4) : delay;
1107
+ this._reconnectTimer = setTimeout(() => {
1108
+ this._reconnectTimer = null;
1109
+ this.connect();
1110
+ }, finalDelay);
1111
+ }
1112
+ }
1113
+ function createRealtimeClient(options) {
1114
+ return new RealtimeClient(options);
1115
+ }
1116
+ class RealtimeProxy {
1117
+ constructor(server, options = {}) {
1118
+ this.server = server;
1119
+ this.options = {
1120
+ path: "/ws",
1121
+ upstreamUrl: void 0,
1122
+ // process.env.WS_URL
1123
+ upstreamKey: void 0,
1124
+ // process.env.WS_KEY
1125
+ room: "default",
1126
+ // process.env.WS_ROOM
1127
+ authenticate: void 0,
1128
+ // async (req, token) => ({ userId })
1129
+ WebSocket: void 0,
1130
+ WebSocketServer: void 0,
1131
+ ...options
1132
+ };
1133
+ this.wss = null;
1134
+ this.events = new EventBus();
1135
+ if (this.server) {
1136
+ this.attach(this.server);
1137
+ }
1138
+ }
1139
+ attach(server) {
1140
+ this.server = server;
1141
+ let WSS = this.options.WebSocketServer;
1142
+ if (!WSS && typeof globalThis !== "undefined" && globalThis.WebSocketServer) {
1143
+ WSS = globalThis.WebSocketServer;
1144
+ }
1145
+ if (!WSS) {
1146
+ try {
1147
+ const wsModule = typeof require === "function" ? require("ws") : null;
1148
+ if (wsModule && wsModule.WebSocketServer) {
1149
+ WSS = wsModule.WebSocketServer;
1150
+ } else if (wsModule && wsModule.Server) {
1151
+ WSS = wsModule.Server;
1152
+ }
1153
+ } catch {
1154
+ }
1155
+ }
1156
+ if (!WSS) {
1157
+ console.warn("[cynx/realtime] WebSocketServer not available for RealtimeProxy.");
1158
+ return;
1159
+ }
1160
+ this.wss = new WSS({ noServer: true });
1161
+ server.on("upgrade", async (request, socket, head) => {
1162
+ try {
1163
+ const url = new URL(request.url, `http://${request.headers.host || "localhost"}`);
1164
+ if (url.pathname !== this.options.path) {
1165
+ return;
1166
+ }
1167
+ const token = (url.searchParams.get("token") || request.headers["authorization"]?.replace(/^Bearer\s+/i, "") || request.headers["x-auth-token"] || "").trim();
1168
+ let authData = null;
1169
+ if (typeof this.options.authenticate === "function") {
1170
+ authData = await this.options.authenticate(request, token);
1171
+ if (!authData) {
1172
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
1173
+ socket.destroy();
1174
+ return;
1175
+ }
1176
+ }
1177
+ const room = (url.searchParams.get("room") || this.options.room || "default").trim().toLowerCase();
1178
+ this.wss.handleUpgrade(request, socket, head, (clientWs) => {
1179
+ this.wss.emit("connection", clientWs, request, { authData, room });
1180
+ });
1181
+ } catch (err) {
1182
+ console.warn("[RealtimeProxy Upgrade Error]:", err.message);
1183
+ try {
1184
+ socket.write("HTTP/1.1 500 Internal Server Error\r\n\r\n");
1185
+ socket.destroy();
1186
+ } catch {
1187
+ }
1188
+ }
1189
+ });
1190
+ this.wss.on("connection", (clientWs, request, { authData, room }) => {
1191
+ this._handleConnection(clientWs, { authData, room });
1192
+ });
1193
+ }
1194
+ _handleConnection(clientWs, { authData, room }) {
1195
+ const WS_URL = this.options.upstreamUrl || (typeof process !== "undefined" ? process.env.WS_URL : "");
1196
+ const WS_KEY = this.options.upstreamKey || (typeof process !== "undefined" ? process.env.WS_KEY : "");
1197
+ const targetRoom = room || this.options.room || "default";
1198
+ if (!WS_URL || !WS_KEY) {
1199
+ clientWs.close(1011, "Real-Time upstream not configured");
1200
+ return;
1201
+ }
1202
+ const upstreamBase = WS_URL.replace(/^http:\/\//i, "ws://").replace(/^https:\/\//i, "wss://").replace(/\/ws\/?$/i, "").replace(/\/$/, "");
1203
+ const upstreamUrl = `${upstreamBase}/ws?room=${encodeURIComponent(targetRoom)}&token=${encodeURIComponent(WS_KEY)}`;
1204
+ let WS = this.options.WebSocket;
1205
+ if (!WS && typeof globalThis !== "undefined" && globalThis.WebSocket) {
1206
+ WS = globalThis.WebSocket;
1207
+ }
1208
+ if (!WS) {
1209
+ try {
1210
+ const wsModule = typeof require === "function" ? require("ws") : null;
1211
+ if (wsModule) WS = wsModule;
1212
+ } catch {
1213
+ }
1214
+ }
1215
+ if (!WS) {
1216
+ clientWs.close(1011, "WebSocket client constructor not available");
1217
+ return;
1218
+ }
1219
+ let upstreamWs = null;
1220
+ try {
1221
+ upstreamWs = new WS(upstreamUrl);
1222
+ } catch (err) {
1223
+ clientWs.close(1011, "Failed to connect upstream");
1224
+ return;
1225
+ }
1226
+ upstreamWs.onmessage = (event) => {
1227
+ if (clientWs.readyState === 1) {
1228
+ clientWs.send(event.data);
1229
+ }
1230
+ };
1231
+ if (typeof upstreamWs.on === "function") {
1232
+ upstreamWs.on("message", (data, isBinary) => {
1233
+ if (clientWs.readyState === 1) {
1234
+ clientWs.send(data, { binary: isBinary });
1235
+ }
1236
+ });
1237
+ upstreamWs.on("error", (err) => {
1238
+ console.warn("[RealtimeProxy Upstream Error]:", err.message);
1239
+ if (clientWs.readyState === 1) clientWs.close(1011, "Upstream error");
1240
+ });
1241
+ upstreamWs.on("close", (code, reason) => {
1242
+ if (clientWs.readyState === 1) clientWs.close(code, reason);
1243
+ });
1244
+ }
1245
+ if (typeof clientWs.on === "function") {
1246
+ clientWs.on("message", (data, isBinary) => {
1247
+ if (upstreamWs && upstreamWs.readyState === 1) {
1248
+ upstreamWs.send(data, { binary: isBinary });
1249
+ }
1250
+ });
1251
+ clientWs.on("error", () => {
1252
+ if (upstreamWs && upstreamWs.readyState === 1) upstreamWs.close();
1253
+ });
1254
+ clientWs.on("close", () => {
1255
+ if (upstreamWs && upstreamWs.readyState === 1) upstreamWs.close();
1256
+ });
1257
+ }
1258
+ }
1259
+ }
1260
+ function createRealtimeProxy(server, options) {
1261
+ return new RealtimeProxy(server, options);
1262
+ }
1263
+ class RealtimeDispatcher {
1264
+ constructor(options = {}) {
1265
+ this.options = {
1266
+ url: void 0,
1267
+ // process.env.WS_URL
1268
+ key: void 0,
1269
+ // process.env.WS_KEY
1270
+ room: void 0,
1271
+ // process.env.WS_ROOM
1272
+ fetch: typeof globalThis !== "undefined" ? globalThis.fetch : void 0,
1273
+ ...options
1274
+ };
1275
+ }
1276
+ async broadcast(eventType, payload = {}, options = {}) {
1277
+ const rawUrl = options.url || this.options.url || (typeof process !== "undefined" ? process.env.WS_URL : "");
1278
+ const key = options.key || this.options.key || (typeof process !== "undefined" ? process.env.WS_KEY : "");
1279
+ const room = (options.room || this.options.room || (typeof process !== "undefined" ? process.env.WS_ROOM : "") || "").trim().toLowerCase();
1280
+ if (!rawUrl || !key || !room) {
1281
+ return { ok: false, error: "Missing WS_URL, WS_KEY, or WS_ROOM configuration" };
1282
+ }
1283
+ const fetchFn = this.options.fetch || globalThis.fetch;
1284
+ if (!fetchFn) {
1285
+ return { ok: false, error: "fetch function not found in environment" };
1286
+ }
1287
+ const baseUrl = rawUrl.replace(/^ws:\/\//i, "http://").replace(/^wss:\/\//i, "https://").replace(/\/ws\/?$/i, "").replace(/\/$/, "");
1288
+ const endpoint = `${baseUrl}/api/broadcast?room=${encodeURIComponent(room)}`;
1289
+ const body = JSON.stringify({
1290
+ type: eventType,
1291
+ room,
1292
+ payload,
1293
+ timestamp: Date.now(),
1294
+ ...options.extra
1295
+ });
1296
+ const headers = {
1297
+ "Content-Type": "application/json",
1298
+ "X-Secret-Key": key,
1299
+ "x-ws-token": key,
1300
+ "x-room": room,
1301
+ "Authorization": `Bearer ${key}`
1302
+ };
1303
+ try {
1304
+ let res = await fetchFn(endpoint, {
1305
+ method: "POST",
1306
+ headers,
1307
+ body
1308
+ });
1309
+ if (res.status === 404) {
1310
+ const legacyEndpoint = `${baseUrl}/broadcast?room=${encodeURIComponent(room)}`;
1311
+ res = await fetchFn(legacyEndpoint, { method: "POST", headers, body });
1312
+ }
1313
+ if (!res.ok) {
1314
+ const errText = await res.text().catch(() => "");
1315
+ console.warn(`[cynx/realtime] Broadcast warning (${res.status}):`, errText);
1316
+ return { ok: false, status: res.status, error: errText };
1317
+ }
1318
+ const data = await res.json().catch(() => ({ status: "ok" }));
1319
+ return { ok: true, data };
1320
+ } catch (err) {
1321
+ console.warn("[cynx/realtime] Broadcast network error:", err.message);
1322
+ return { ok: false, error: err.message };
1323
+ }
1324
+ }
1325
+ async broadcastSession(token, eventType, payload = {}, options = {}) {
1326
+ return this.broadcast("session_event", payload, {
1327
+ ...options,
1328
+ extra: { token, eventType, sessionToken: token }
1329
+ });
1330
+ }
1331
+ }
1332
+ function createRealtimeDispatcher(options) {
1333
+ return new RealtimeDispatcher(options);
1334
+ }
847
1335
  var realtime_default = {
848
1336
  // Client
849
1337
  RealtimeSocket,
850
1338
  createRealtimeSocket,
1339
+ RealtimeClient,
1340
+ createRealtimeClient,
1341
+ normalizeWsUrl,
851
1342
  SSEStream,
852
1343
  createSSE,
853
1344
  parseAIStream,
@@ -860,12 +1351,19 @@ var realtime_default = {
860
1351
  // Server
861
1352
  ConnectedClient,
862
1353
  RealtimeServer,
863
- createRealtimeServer
1354
+ createRealtimeServer,
1355
+ RealtimeProxy,
1356
+ createRealtimeProxy,
1357
+ RealtimeDispatcher,
1358
+ createRealtimeDispatcher
864
1359
  };
865
1360
  // Annotate the CommonJS export names for ESM import in node:
866
1361
  0 && (module.exports = {
867
1362
  ConnectedClient,
868
1363
  EventBus,
1364
+ RealtimeClient,
1365
+ RealtimeDispatcher,
1366
+ RealtimeProxy,
869
1367
  RealtimeServer,
870
1368
  RealtimeSocket,
871
1369
  SSEStream,
@@ -873,9 +1371,13 @@ var realtime_default = {
873
1371
  TabChannel,
874
1372
  createEventBus,
875
1373
  createPoller,
1374
+ createRealtimeClient,
1375
+ createRealtimeDispatcher,
1376
+ createRealtimeProxy,
876
1377
  createRealtimeServer,
877
1378
  createRealtimeSocket,
878
1379
  createSSE,
879
1380
  createTabChannel,
1381
+ normalizeWsUrl,
880
1382
  parseAIStream
881
1383
  });
@@ -1018,12 +1018,602 @@ export function createRealtimeServer(options) {
1018
1018
  return new RealtimeServer(options);
1019
1019
  }
1020
1020
 
1021
+ // ─── 7. UNIVERSAL URL NORMALIZER ────────────────────────────────────────────────
1022
+ /**
1023
+ * Normalizes HTTP/HTTPS/WS/WSS and relative URLs into valid WebSocket endpoints.
1024
+ * Automatically removes accidental duplicate /ws paths or trailing slashes.
1025
+ */
1026
+ export function normalizeWsUrl(rawUrl, defaultPath = '/ws') {
1027
+ if (!rawUrl || typeof rawUrl !== 'string') {
1028
+ if (isBrowser) {
1029
+ const isHttps = window.location.protocol === 'https:';
1030
+ const defaultScheme = isHttps ? 'wss:' : 'ws:';
1031
+ return `${defaultScheme}//${window.location.host}${defaultPath}`;
1032
+ }
1033
+ return '';
1034
+ }
1035
+
1036
+ let url = rawUrl.trim();
1037
+ const isHttps = isBrowser ? window.location.protocol === 'https:' : false;
1038
+ const defaultScheme = isHttps ? 'wss:' : 'ws:';
1039
+
1040
+ // 1. Protocol-relative URL: //example.com/ws
1041
+ if (url.startsWith('//')) {
1042
+ url = `${defaultScheme}${url}`;
1043
+ }
1044
+ // 2. Relative path: /ws
1045
+ else if (url.startsWith('/')) {
1046
+ if (isBrowser) {
1047
+ url = `${defaultScheme}//${window.location.host}${url}`;
1048
+ }
1049
+ }
1050
+ // 3. HTTP -> WS
1051
+ else if (/^http:\/\//i.test(url)) {
1052
+ url = url.replace(/^http:\/\//i, 'ws://');
1053
+ }
1054
+ // 4. HTTPS -> WSS
1055
+ else if (/^https:\/\//i.test(url)) {
1056
+ url = url.replace(/^https:\/\//i, 'wss://');
1057
+ }
1058
+ // 5. Raw host without scheme
1059
+ else if (!/^wss?:\/\//i.test(url)) {
1060
+ const isLocal = url.includes('localhost') || url.includes('127.0.0.1');
1061
+ const scheme = isLocal && !isHttps ? 'ws://' : 'wss://';
1062
+ url = `${scheme}${url}`;
1063
+ }
1064
+
1065
+ // Ensure path is normalized without duplicate /ws/ws
1066
+ try {
1067
+ const httpEquivalent = url.replace(/^wss:/i, 'https:').replace(/^ws:/i, 'http:');
1068
+ const parsed = new URL(httpEquivalent);
1069
+ let pathname = parsed.pathname || defaultPath;
1070
+ if (!pathname || pathname === '/') {
1071
+ pathname = defaultPath;
1072
+ }
1073
+ pathname = pathname.replace(/\/+/g, '/').replace(/\/ws\/ws(\/|$)/g, '/ws$1');
1074
+ const scheme = url.toLowerCase().startsWith('wss:') ? 'wss:' : 'ws:';
1075
+ return `${scheme}//${parsed.host}${pathname}`;
1076
+ } catch {
1077
+ return url;
1078
+ }
1079
+ }
1080
+
1081
+ // ─── 8. UNIVERSAL REALTIME FRONTEND CLIENT ──────────────────────────────────────
1082
+ /**
1083
+ * Universal Realtime Client for frontends/browsers.
1084
+ * Manages WebSocket connections through proxy or direct, user token resolution,
1085
+ * automatic heartbeat ping-pong, typed event bus, and global subscriptions.
1086
+ */
1087
+ export class RealtimeClient {
1088
+ constructor(options = {}) {
1089
+ const defaultUrl = isBrowser ? (window.location.protocol === 'https:' ? 'wss:' : 'ws:') + '//' + window.location.host + '/ws' : '/ws';
1090
+ this.options = {
1091
+ url: defaultUrl,
1092
+ token: undefined, // string or function: () => token
1093
+ room: undefined, // optional room name or function: () => room
1094
+ autoConnect: true,
1095
+ reconnect: true,
1096
+ maxRetries: Infinity,
1097
+ initialRetryDelay: 1000,
1098
+ maxRetryDelay: 30000,
1099
+ backoffFactor: 1.5,
1100
+ jitter: true,
1101
+ heartbeatInterval: 20000,
1102
+ pingPayload: { type: 'PING' },
1103
+ ...options,
1104
+ };
1105
+
1106
+ this.events = new EventBus();
1107
+ this.ws = null;
1108
+ this.status = 'DISCONNECTED';
1109
+ this.retries = 0;
1110
+ this._pingTimer = null;
1111
+ this._reconnectTimer = null;
1112
+ this._isExplicitClose = false;
1113
+ this._connecting = false;
1114
+
1115
+ if (this.options.autoConnect) {
1116
+ this.init();
1117
+ }
1118
+ }
1119
+
1120
+ getToken() {
1121
+ if (typeof this.options.token === 'function') {
1122
+ return this.options.token();
1123
+ }
1124
+ if (typeof this.options.token === 'string') {
1125
+ return this.options.token;
1126
+ }
1127
+ if (isBrowser) {
1128
+ return localStorage.getItem('token') || sessionStorage.getItem('token') || '';
1129
+ }
1130
+ return '';
1131
+ }
1132
+
1133
+ getRoom() {
1134
+ if (typeof this.options.room === 'function') {
1135
+ return this.options.room();
1136
+ }
1137
+ if (typeof this.options.room === 'string') {
1138
+ return this.options.room;
1139
+ }
1140
+ return '';
1141
+ }
1142
+
1143
+ init() {
1144
+ if (this.ws && (this.ws.readyState === 0 || this.ws.readyState === 1)) {
1145
+ return;
1146
+ }
1147
+ const token = this.getToken();
1148
+ if (!token && !this.options.anonymous) {
1149
+ return; // Wait until token is available
1150
+ }
1151
+ this.connect();
1152
+ }
1153
+
1154
+ connect() {
1155
+ if (this._connecting) return;
1156
+ if (this.ws && (this.ws.readyState === 0 || this.ws.readyState === 1)) return;
1157
+
1158
+ this._connecting = true;
1159
+ this._isExplicitClose = false;
1160
+ this.cleanup();
1161
+
1162
+ const WS = getWebSocketConstructor(this.options.WebSocket);
1163
+ if (!WS) {
1164
+ this._connecting = false;
1165
+ console.warn('[cynx/realtime] WebSocket implementation not found in this environment.');
1166
+ return;
1167
+ }
1168
+
1169
+ try {
1170
+ const rawUrl = typeof this.options.url === 'function' ? this.options.url() : this.options.url;
1171
+ const normalized = normalizeWsUrl(rawUrl);
1172
+ const token = this.getToken();
1173
+ const room = this.getRoom();
1174
+
1175
+ const urlObj = new URL(normalized.replace(/^ws:/i, 'http:').replace(/^wss:/i, 'https:'));
1176
+ if (token && !urlObj.searchParams.has('token')) {
1177
+ urlObj.searchParams.set('token', token);
1178
+ }
1179
+ if (room && !urlObj.searchParams.has('room')) {
1180
+ urlObj.searchParams.set('room', room);
1181
+ }
1182
+
1183
+ const finalScheme = normalized.toLowerCase().startsWith('wss:') ? 'wss:' : 'ws:';
1184
+ const finalUrl = `${finalScheme}//${urlObj.host}${urlObj.pathname}${urlObj.search}`;
1185
+
1186
+ this.ws = new WS(finalUrl);
1187
+ this.status = this.retries > 0 ? 'RECONNECTING' : 'CONNECTING';
1188
+
1189
+ this.ws.onopen = (e) => {
1190
+ this._connecting = false;
1191
+ this.retries = 0;
1192
+ this.status = 'CONNECTED';
1193
+ this.events.emit('open', e);
1194
+ this.events.emit('status', this.status);
1195
+ this._startHeartbeat();
1196
+ };
1197
+
1198
+ this.ws.onmessage = (event) => {
1199
+ try {
1200
+ const raw = typeof event.data === 'string' ? event.data : String(event.data);
1201
+ const parsed = safeJSONParse(raw, raw);
1202
+ const eventType = (parsed && typeof parsed === 'object') ? (parsed.type || parsed.event || 'message') : 'message';
1203
+
1204
+ if (parsed && parsed.type === 'PONG') {
1205
+ return;
1206
+ }
1207
+
1208
+ this.events.emit(eventType, parsed);
1209
+ this.events.emit('message', parsed);
1210
+ this.events.emit('*', parsed);
1211
+ } catch (err) {
1212
+ console.warn('[cynx/realtime] Message parse warning:', err);
1213
+ }
1214
+ };
1215
+
1216
+ this.ws.onerror = (err) => {
1217
+ this.events.emit('error', err);
1218
+ };
1219
+
1220
+ this.ws.onclose = (e) => {
1221
+ this._connecting = false;
1222
+ this.status = 'DISCONNECTED';
1223
+ this._stopHeartbeat();
1224
+ this.events.emit('close', e);
1225
+ this.events.emit('status', this.status);
1226
+
1227
+ if (!this._isExplicitClose && this.options.reconnect && this.retries < this.options.maxRetries) {
1228
+ this._handleReconnect();
1229
+ }
1230
+ };
1231
+ } catch (err) {
1232
+ this._connecting = false;
1233
+ this._handleReconnect();
1234
+ }
1235
+ }
1236
+
1237
+ send(eventOrData, payload = undefined) {
1238
+ if (!this.ws || this.ws.readyState !== 1) return false;
1239
+ let message;
1240
+ if (payload !== undefined) {
1241
+ message = JSON.stringify({ type: eventOrData, payload });
1242
+ } else if (typeof eventOrData === 'object') {
1243
+ message = JSON.stringify(eventOrData);
1244
+ } else {
1245
+ message = String(eventOrData);
1246
+ }
1247
+ this.ws.send(message);
1248
+ return true;
1249
+ }
1250
+
1251
+ emit(event, payload = undefined) {
1252
+ return this.send(event, payload);
1253
+ }
1254
+
1255
+ subscribe(callback) {
1256
+ const unsub = this.events.on('*', callback);
1257
+ this.init();
1258
+ return unsub;
1259
+ }
1260
+
1261
+ on(event, callback) {
1262
+ const unsub = this.events.on(event, callback);
1263
+ this.init();
1264
+ return unsub;
1265
+ }
1266
+
1267
+ once(event, callback) {
1268
+ const unsub = this.events.once(event, callback);
1269
+ this.init();
1270
+ return unsub;
1271
+ }
1272
+
1273
+ off(event, callback) {
1274
+ this.events.off(event, callback);
1275
+ }
1276
+
1277
+ disconnect(code = 1000, reason = 'Client disconnected') {
1278
+ this._isExplicitClose = true;
1279
+ this.cleanup();
1280
+ if (this.ws) {
1281
+ try { this.ws.close(code, reason); } catch {}
1282
+ this.ws = null;
1283
+ }
1284
+ this.status = 'DISCONNECTED';
1285
+ this.events.emit('status', this.status);
1286
+ }
1287
+
1288
+ cleanup() {
1289
+ this._stopHeartbeat();
1290
+ if (this._reconnectTimer) {
1291
+ clearTimeout(this._reconnectTimer);
1292
+ this._reconnectTimer = null;
1293
+ }
1294
+ }
1295
+
1296
+ _startHeartbeat() {
1297
+ this._stopHeartbeat();
1298
+ if (!this.options.heartbeatInterval) return;
1299
+ this._pingTimer = setInterval(() => {
1300
+ if (this.ws && this.ws.readyState === 1) {
1301
+ try {
1302
+ this.ws.send(JSON.stringify(this.options.pingPayload));
1303
+ } catch {}
1304
+ }
1305
+ }, this.options.heartbeatInterval);
1306
+ }
1307
+
1308
+ _stopHeartbeat() {
1309
+ if (this._pingTimer) {
1310
+ clearInterval(this._pingTimer);
1311
+ this._pingTimer = null;
1312
+ }
1313
+ }
1314
+
1315
+ _handleReconnect() {
1316
+ if (this._reconnectTimer) return;
1317
+ this.retries++;
1318
+ const delay = Math.min(
1319
+ this.options.initialRetryDelay * Math.pow(this.options.backoffFactor, this.retries - 1),
1320
+ this.options.maxRetryDelay
1321
+ );
1322
+ const finalDelay = this.options.jitter ? delay * (0.8 + Math.random() * 0.4) : delay;
1323
+
1324
+ this._reconnectTimer = setTimeout(() => {
1325
+ this._reconnectTimer = null;
1326
+ this.connect();
1327
+ }, finalDelay);
1328
+ }
1329
+ }
1330
+
1331
+ export function createRealtimeClient(options) {
1332
+ return new RealtimeClient(options);
1333
+ }
1334
+
1335
+ // ─── 9. UNIVERSAL BACKEND WEBSOCKET PROXY / REPEATER ────────────────────────────
1336
+ /**
1337
+ * Universal WebSocket Repeater/Proxy for application backends.
1338
+ * Intercepts /ws upgrades, validates client sessions (protecting master WS_KEY),
1339
+ * and safely pipes traffic to/from upstream ws-server.
1340
+ */
1341
+ export class RealtimeProxy {
1342
+ constructor(server, options = {}) {
1343
+ this.server = server;
1344
+ this.options = {
1345
+ path: '/ws',
1346
+ upstreamUrl: undefined, // process.env.WS_URL
1347
+ upstreamKey: undefined, // process.env.WS_KEY
1348
+ room: 'default', // process.env.WS_ROOM
1349
+ authenticate: undefined,// async (req, token) => ({ userId })
1350
+ WebSocket: undefined,
1351
+ WebSocketServer: undefined,
1352
+ ...options,
1353
+ };
1354
+
1355
+ this.wss = null;
1356
+ this.events = new EventBus();
1357
+
1358
+ if (this.server) {
1359
+ this.attach(this.server);
1360
+ }
1361
+ }
1362
+
1363
+ attach(server) {
1364
+ this.server = server;
1365
+ let WSS = this.options.WebSocketServer;
1366
+ if (!WSS && typeof globalThis !== 'undefined' && globalThis.WebSocketServer) {
1367
+ WSS = globalThis.WebSocketServer;
1368
+ }
1369
+
1370
+ if (!WSS) {
1371
+ try {
1372
+ const wsModule = typeof require === 'function' ? require('ws') : null;
1373
+ if (wsModule && wsModule.WebSocketServer) {
1374
+ WSS = wsModule.WebSocketServer;
1375
+ } else if (wsModule && wsModule.Server) {
1376
+ WSS = wsModule.Server;
1377
+ }
1378
+ } catch {}
1379
+ }
1380
+
1381
+ if (!WSS) {
1382
+ console.warn('[cynx/realtime] WebSocketServer not available for RealtimeProxy.');
1383
+ return;
1384
+ }
1385
+
1386
+ this.wss = new WSS({ noServer: true });
1387
+
1388
+ server.on('upgrade', async (request, socket, head) => {
1389
+ try {
1390
+ const url = new URL(request.url, `http://${request.headers.host || 'localhost'}`);
1391
+ if (url.pathname !== this.options.path) {
1392
+ return;
1393
+ }
1394
+
1395
+ const token = (
1396
+ url.searchParams.get('token') ||
1397
+ request.headers['authorization']?.replace(/^Bearer\s+/i, '') ||
1398
+ request.headers['x-auth-token'] ||
1399
+ ''
1400
+ ).trim();
1401
+
1402
+ let authData = null;
1403
+ if (typeof this.options.authenticate === 'function') {
1404
+ authData = await this.options.authenticate(request, token);
1405
+ if (!authData) {
1406
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
1407
+ socket.destroy();
1408
+ return;
1409
+ }
1410
+ }
1411
+
1412
+ const room = (url.searchParams.get('room') || this.options.room || 'default').trim().toLowerCase();
1413
+
1414
+ this.wss.handleUpgrade(request, socket, head, (clientWs) => {
1415
+ this.wss.emit('connection', clientWs, request, { authData, room });
1416
+ });
1417
+ } catch (err) {
1418
+ console.warn('[RealtimeProxy Upgrade Error]:', err.message);
1419
+ try {
1420
+ socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n');
1421
+ socket.destroy();
1422
+ } catch {}
1423
+ }
1424
+ });
1425
+
1426
+ this.wss.on('connection', (clientWs, request, { authData, room }) => {
1427
+ this._handleConnection(clientWs, { authData, room });
1428
+ });
1429
+ }
1430
+
1431
+ _handleConnection(clientWs, { authData, room }) {
1432
+ const WS_URL = this.options.upstreamUrl || (typeof process !== 'undefined' ? process.env.WS_URL : '');
1433
+ const WS_KEY = this.options.upstreamKey || (typeof process !== 'undefined' ? process.env.WS_KEY : '');
1434
+ const targetRoom = room || this.options.room || 'default';
1435
+
1436
+ if (!WS_URL || !WS_KEY) {
1437
+ clientWs.close(1011, 'Real-Time upstream not configured');
1438
+ return;
1439
+ }
1440
+
1441
+ // Rock-solid URL normalization: strip duplicate /ws/
1442
+ const upstreamBase = WS_URL
1443
+ .replace(/^http:\/\//i, 'ws://')
1444
+ .replace(/^https:\/\//i, 'wss://')
1445
+ .replace(/\/ws\/?$/i, '')
1446
+ .replace(/\/$/, '');
1447
+
1448
+ const upstreamUrl = `${upstreamBase}/ws?room=${encodeURIComponent(targetRoom)}&token=${encodeURIComponent(WS_KEY)}`;
1449
+
1450
+ let WS = this.options.WebSocket;
1451
+ if (!WS && typeof globalThis !== 'undefined' && globalThis.WebSocket) {
1452
+ WS = globalThis.WebSocket;
1453
+ }
1454
+ if (!WS) {
1455
+ try {
1456
+ const wsModule = typeof require === 'function' ? require('ws') : null;
1457
+ if (wsModule) WS = wsModule;
1458
+ } catch {}
1459
+ }
1460
+
1461
+ if (!WS) {
1462
+ clientWs.close(1011, 'WebSocket client constructor not available');
1463
+ return;
1464
+ }
1465
+
1466
+ let upstreamWs = null;
1467
+ try {
1468
+ upstreamWs = new WS(upstreamUrl);
1469
+ } catch (err) {
1470
+ clientWs.close(1011, 'Failed to connect upstream');
1471
+ return;
1472
+ }
1473
+
1474
+ // Upstream -> Client
1475
+ upstreamWs.onmessage = (event) => {
1476
+ if (clientWs.readyState === 1) {
1477
+ clientWs.send(event.data);
1478
+ }
1479
+ };
1480
+ if (typeof upstreamWs.on === 'function') {
1481
+ upstreamWs.on('message', (data, isBinary) => {
1482
+ if (clientWs.readyState === 1) {
1483
+ clientWs.send(data, { binary: isBinary });
1484
+ }
1485
+ });
1486
+ upstreamWs.on('error', (err) => {
1487
+ console.warn('[RealtimeProxy Upstream Error]:', err.message);
1488
+ if (clientWs.readyState === 1) clientWs.close(1011, 'Upstream error');
1489
+ });
1490
+ upstreamWs.on('close', (code, reason) => {
1491
+ if (clientWs.readyState === 1) clientWs.close(code, reason);
1492
+ });
1493
+ }
1494
+
1495
+ // Client -> Upstream
1496
+ if (typeof clientWs.on === 'function') {
1497
+ clientWs.on('message', (data, isBinary) => {
1498
+ if (upstreamWs && upstreamWs.readyState === 1) {
1499
+ upstreamWs.send(data, { binary: isBinary });
1500
+ }
1501
+ });
1502
+ clientWs.on('error', () => {
1503
+ if (upstreamWs && upstreamWs.readyState === 1) upstreamWs.close();
1504
+ });
1505
+ clientWs.on('close', () => {
1506
+ if (upstreamWs && upstreamWs.readyState === 1) upstreamWs.close();
1507
+ });
1508
+ }
1509
+ }
1510
+ }
1511
+
1512
+ export function createRealtimeProxy(server, options) {
1513
+ return new RealtimeProxy(server, options);
1514
+ }
1515
+
1516
+ // ─── 10. UNIVERSAL REALTIME DISPATCHER (HTTP BROADCASTER) ───────────────────────
1517
+ /**
1518
+ * Universal Event Broadcaster for backend services and microservices.
1519
+ * Sends broadcast events to ws-server via HTTP REST (POST /api/broadcast and /broadcast).
1520
+ */
1521
+ export class RealtimeDispatcher {
1522
+ constructor(options = {}) {
1523
+ this.options = {
1524
+ url: undefined, // process.env.WS_URL
1525
+ key: undefined, // process.env.WS_KEY
1526
+ room: undefined, // process.env.WS_ROOM
1527
+ fetch: typeof globalThis !== 'undefined' ? globalThis.fetch : undefined,
1528
+ ...options,
1529
+ };
1530
+ }
1531
+
1532
+ async broadcast(eventType, payload = {}, options = {}) {
1533
+ const rawUrl = options.url || this.options.url || (typeof process !== 'undefined' ? process.env.WS_URL : '');
1534
+ const key = options.key || this.options.key || (typeof process !== 'undefined' ? process.env.WS_KEY : '');
1535
+ const room = (options.room || this.options.room || (typeof process !== 'undefined' ? process.env.WS_ROOM : '') || '').trim().toLowerCase();
1536
+
1537
+ if (!rawUrl || !key || !room) {
1538
+ return { ok: false, error: 'Missing WS_URL, WS_KEY, or WS_ROOM configuration' };
1539
+ }
1540
+
1541
+ const fetchFn = this.options.fetch || globalThis.fetch;
1542
+ if (!fetchFn) {
1543
+ return { ok: false, error: 'fetch function not found in environment' };
1544
+ }
1545
+
1546
+ const baseUrl = rawUrl
1547
+ .replace(/^ws:\/\//i, 'http://')
1548
+ .replace(/^wss:\/\//i, 'https://')
1549
+ .replace(/\/ws\/?$/i, '')
1550
+ .replace(/\/$/, '');
1551
+
1552
+ const endpoint = `${baseUrl}/api/broadcast?room=${encodeURIComponent(room)}`;
1553
+ const body = JSON.stringify({
1554
+ type: eventType,
1555
+ room,
1556
+ payload,
1557
+ timestamp: Date.now(),
1558
+ ...options.extra,
1559
+ });
1560
+
1561
+ const headers = {
1562
+ 'Content-Type': 'application/json',
1563
+ 'X-Secret-Key': key,
1564
+ 'x-ws-token': key,
1565
+ 'x-room': room,
1566
+ 'Authorization': `Bearer ${key}`,
1567
+ };
1568
+
1569
+ try {
1570
+ let res = await fetchFn(endpoint, {
1571
+ method: 'POST',
1572
+ headers,
1573
+ body,
1574
+ });
1575
+
1576
+ // Fallback to legacy /broadcast if /api/broadcast returned 404
1577
+ if (res.status === 404) {
1578
+ const legacyEndpoint = `${baseUrl}/broadcast?room=${encodeURIComponent(room)}`;
1579
+ res = await fetchFn(legacyEndpoint, { method: 'POST', headers, body });
1580
+ }
1581
+
1582
+ if (!res.ok) {
1583
+ const errText = await res.text().catch(() => '');
1584
+ console.warn(`[cynx/realtime] Broadcast warning (${res.status}):`, errText);
1585
+ return { ok: false, status: res.status, error: errText };
1586
+ }
1587
+
1588
+ const data = await res.json().catch(() => ({ status: 'ok' }));
1589
+ return { ok: true, data };
1590
+ } catch (err) {
1591
+ console.warn('[cynx/realtime] Broadcast network error:', err.message);
1592
+ return { ok: false, error: err.message };
1593
+ }
1594
+ }
1595
+
1596
+ async broadcastSession(token, eventType, payload = {}, options = {}) {
1597
+ return this.broadcast('session_event', payload, {
1598
+ ...options,
1599
+ extra: { token, eventType, sessionToken: token },
1600
+ });
1601
+ }
1602
+ }
1603
+
1604
+ export function createRealtimeDispatcher(options) {
1605
+ return new RealtimeDispatcher(options);
1606
+ }
1607
+
1021
1608
  // ─── MAIN EXPORT ────────────────────────────────────────────────────────────────
1022
1609
 
1023
1610
  export default {
1024
1611
  // Client
1025
1612
  RealtimeSocket,
1026
1613
  createRealtimeSocket,
1614
+ RealtimeClient,
1615
+ createRealtimeClient,
1616
+ normalizeWsUrl,
1027
1617
  SSEStream,
1028
1618
  createSSE,
1029
1619
  parseAIStream,
@@ -1037,4 +1627,8 @@ export default {
1037
1627
  ConnectedClient,
1038
1628
  RealtimeServer,
1039
1629
  createRealtimeServer,
1630
+ RealtimeProxy,
1631
+ createRealtimeProxy,
1632
+ RealtimeDispatcher,
1633
+ createRealtimeDispatcher,
1040
1634
  };