cynx-lib 1.3.48 → 1.3.50
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 +1 -1
- package/ui/components/ActionsMenu.jsx +2 -2
- package/utils/utils/realtime.cjs +503 -1
- package/utils/utils/realtime.js +594 -0
package/package.json
CHANGED
|
@@ -120,11 +120,11 @@ export function ActionsMenu({ items, align = 'right' }) {
|
|
|
120
120
|
display: 'flex', alignItems: 'center', gap: 8,
|
|
121
121
|
padding: '6px 10px', borderRadius: 'var(--rads,8px)',
|
|
122
122
|
fontSize: 12, cursor: 'pointer', transition: 'all .14s',
|
|
123
|
-
color: item.variant === 'danger' ? 'var(--danger, #
|
|
123
|
+
color: (item.variant === 'danger' || item.danger) ? 'var(--danger, #ef4444)' : item.variant === 'success' ? 'var(--success, #16a34a)' : item.variant === 'warning' ? 'var(--warning, #f59e0b)' : 'var(--primary-text, #18181b)',
|
|
124
124
|
fontWeight: 500,
|
|
125
125
|
}}
|
|
126
126
|
onMouseEnter={e => {
|
|
127
|
-
e.currentTarget.style.background = item.variant === 'danger' ? 'var(--
|
|
127
|
+
e.currentTarget.style.background = (item.variant === 'danger' || item.danger) ? 'var(--danger-bg, rgba(239,68,68,.10))' : item.variant === 'success' ? 'var(--success-bg, rgba(22,163,74,.10))' : item.variant === 'warning' ? 'var(--warning-bg, rgba(245,158,11,.12))' : 'var(--grey-1, #f4f4f5)';
|
|
128
128
|
}}
|
|
129
129
|
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}
|
|
130
130
|
>
|
package/utils/utils/realtime.cjs
CHANGED
|
@@ -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
|
});
|
package/utils/utils/realtime.js
CHANGED
|
@@ -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
|
};
|