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 +1 -1
- package/utils/utils/db.cjs +40 -2
- package/utils/utils/db.js +43 -2
- package/utils/utils/realtime.cjs +503 -1
- package/utils/utils/realtime.js +594 -0
package/package.json
CHANGED
package/utils/utils/db.cjs
CHANGED
|
@@ -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,
|
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
|
};
|