@myko/core 4.2.0-canary.3
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/dist/client.d.ts +252 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1316 -0
- package/dist/generated/CancelSubscription.d.ts +7 -0
- package/dist/generated/CancelSubscription.d.ts.map +1 -0
- package/dist/generated/CancelSubscription.js +2 -0
- package/dist/generated/ChildEntities.d.ts +8 -0
- package/dist/generated/ChildEntities.d.ts.map +1 -0
- package/dist/generated/ChildEntities.js +2 -0
- package/dist/generated/ChildEntitiesAllTime.d.ts +8 -0
- package/dist/generated/ChildEntitiesAllTime.d.ts.map +1 -0
- package/dist/generated/ChildEntitiesAllTime.js +2 -0
- package/dist/generated/ClearClientWindbackTime.d.ts +6 -0
- package/dist/generated/ClearClientWindbackTime.d.ts.map +1 -0
- package/dist/generated/ClearClientWindbackTime.js +2 -0
- package/dist/generated/ClearClientWindbackTimeArgs.d.ts +2 -0
- package/dist/generated/ClearClientWindbackTimeArgs.d.ts.map +1 -0
- package/dist/generated/ClearClientWindbackTimeArgs.js +2 -0
- package/dist/generated/Client.d.ts +12 -0
- package/dist/generated/Client.d.ts.map +1 -0
- package/dist/generated/Client.js +1 -0
- package/dist/generated/ClientCount.d.ts +4 -0
- package/dist/generated/ClientCount.d.ts.map +1 -0
- package/dist/generated/ClientCount.js +2 -0
- package/dist/generated/ClientId.d.ts +2 -0
- package/dist/generated/ClientId.d.ts.map +1 -0
- package/dist/generated/ClientId.js +2 -0
- package/dist/generated/ClientStatus.d.ts +8 -0
- package/dist/generated/ClientStatus.d.ts.map +1 -0
- package/dist/generated/ClientStatus.js +1 -0
- package/dist/generated/ClientStatusOutput.d.ts +4 -0
- package/dist/generated/ClientStatusOutput.d.ts.map +1 -0
- package/dist/generated/ClientStatusOutput.js +2 -0
- package/dist/generated/CommandError.d.ts +6 -0
- package/dist/generated/CommandError.d.ts.map +1 -0
- package/dist/generated/CommandError.js +2 -0
- package/dist/generated/CommandResponse.d.ts +6 -0
- package/dist/generated/CommandResponse.d.ts.map +1 -0
- package/dist/generated/CommandResponse.js +1 -0
- package/dist/generated/CountAllClients.d.ts +2 -0
- package/dist/generated/CountAllClients.d.ts.map +1 -0
- package/dist/generated/CountAllClients.js +2 -0
- package/dist/generated/CountAllServers.d.ts +2 -0
- package/dist/generated/CountAllServers.d.ts.map +1 -0
- package/dist/generated/CountAllServers.js +2 -0
- package/dist/generated/CountClients.d.ts +3 -0
- package/dist/generated/CountClients.d.ts.map +1 -0
- package/dist/generated/CountClients.js +1 -0
- package/dist/generated/CountServers.d.ts +3 -0
- package/dist/generated/CountServers.d.ts.map +1 -0
- package/dist/generated/CountServers.js +1 -0
- package/dist/generated/DeleteClient.d.ts +8 -0
- package/dist/generated/DeleteClient.d.ts.map +1 -0
- package/dist/generated/DeleteClient.js +1 -0
- package/dist/generated/DeleteClientArgs.d.ts +5 -0
- package/dist/generated/DeleteClientArgs.d.ts.map +1 -0
- package/dist/generated/DeleteClientArgs.js +1 -0
- package/dist/generated/DeleteClientResult.d.ts +7 -0
- package/dist/generated/DeleteClientResult.d.ts.map +1 -0
- package/dist/generated/DeleteClientResult.js +2 -0
- package/dist/generated/DeleteClients.d.ts +8 -0
- package/dist/generated/DeleteClients.d.ts.map +1 -0
- package/dist/generated/DeleteClients.js +1 -0
- package/dist/generated/DeleteClientsArgs.d.ts +5 -0
- package/dist/generated/DeleteClientsArgs.d.ts.map +1 -0
- package/dist/generated/DeleteClientsArgs.js +1 -0
- package/dist/generated/DeleteClientsResult.d.ts +7 -0
- package/dist/generated/DeleteClientsResult.d.ts.map +1 -0
- package/dist/generated/DeleteClientsResult.js +2 -0
- package/dist/generated/DeleteServer.d.ts +8 -0
- package/dist/generated/DeleteServer.d.ts.map +1 -0
- package/dist/generated/DeleteServer.js +1 -0
- package/dist/generated/DeleteServerArgs.d.ts +5 -0
- package/dist/generated/DeleteServerArgs.d.ts.map +1 -0
- package/dist/generated/DeleteServerArgs.js +1 -0
- package/dist/generated/DeleteServerResult.d.ts +7 -0
- package/dist/generated/DeleteServerResult.d.ts.map +1 -0
- package/dist/generated/DeleteServerResult.js +2 -0
- package/dist/generated/DeleteServers.d.ts +8 -0
- package/dist/generated/DeleteServers.d.ts.map +1 -0
- package/dist/generated/DeleteServers.js +1 -0
- package/dist/generated/DeleteServersArgs.d.ts +5 -0
- package/dist/generated/DeleteServersArgs.d.ts.map +1 -0
- package/dist/generated/DeleteServersArgs.js +1 -0
- package/dist/generated/DeleteServersResult.d.ts +7 -0
- package/dist/generated/DeleteServersResult.d.ts.map +1 -0
- package/dist/generated/DeleteServersResult.js +2 -0
- package/dist/generated/EntitySearch.d.ts +21 -0
- package/dist/generated/EntitySearch.d.ts.map +1 -0
- package/dist/generated/EntitySearch.js +2 -0
- package/dist/generated/EntitySearchResult.d.ts +10 -0
- package/dist/generated/EntitySearchResult.d.ts.map +1 -0
- package/dist/generated/EntitySearchResult.js +2 -0
- package/dist/generated/EntitySnapshotDifference.d.ts +8 -0
- package/dist/generated/EntitySnapshotDifference.d.ts.map +1 -0
- package/dist/generated/EntitySnapshotDifference.js +2 -0
- package/dist/generated/EntitySnapshotDifferenceData.d.ts +10 -0
- package/dist/generated/EntitySnapshotDifferenceData.d.ts.map +1 -0
- package/dist/generated/EntitySnapshotDifferenceData.js +1 -0
- package/dist/generated/EntityTreeExport.d.ts +27 -0
- package/dist/generated/EntityTreeExport.d.ts.map +1 -0
- package/dist/generated/EntityTreeExport.js +1 -0
- package/dist/generated/EventContainer.d.ts +9 -0
- package/dist/generated/EventContainer.d.ts.map +1 -0
- package/dist/generated/EventContainer.js +1 -0
- package/dist/generated/EventOptions.d.ts +21 -0
- package/dist/generated/EventOptions.d.ts.map +1 -0
- package/dist/generated/EventOptions.js +2 -0
- package/dist/generated/EventsForTransaction.d.ts +7 -0
- package/dist/generated/EventsForTransaction.d.ts.map +1 -0
- package/dist/generated/EventsForTransaction.js +2 -0
- package/dist/generated/ExportEntityTree.d.ts +24 -0
- package/dist/generated/ExportEntityTree.d.ts.map +1 -0
- package/dist/generated/ExportEntityTree.js +2 -0
- package/dist/generated/ExportedEntity.d.ts +15 -0
- package/dist/generated/ExportedEntity.d.ts.map +1 -0
- package/dist/generated/ExportedEntity.js +1 -0
- package/dist/generated/FullChildEntities.d.ts +8 -0
- package/dist/generated/FullChildEntities.d.ts.map +1 -0
- package/dist/generated/FullChildEntities.js +2 -0
- package/dist/generated/GetAllClients.d.ts +2 -0
- package/dist/generated/GetAllClients.d.ts.map +1 -0
- package/dist/generated/GetAllClients.js +2 -0
- package/dist/generated/GetAllServers.d.ts +2 -0
- package/dist/generated/GetAllServers.d.ts.map +1 -0
- package/dist/generated/GetAllServers.js +2 -0
- package/dist/generated/GetClientById.d.ts +5 -0
- package/dist/generated/GetClientById.d.ts.map +1 -0
- package/dist/generated/GetClientById.js +1 -0
- package/dist/generated/GetClientsByIds.d.ts +5 -0
- package/dist/generated/GetClientsByIds.d.ts.map +1 -0
- package/dist/generated/GetClientsByIds.js +1 -0
- package/dist/generated/GetClientsByQuery.d.ts +3 -0
- package/dist/generated/GetClientsByQuery.d.ts.map +1 -0
- package/dist/generated/GetClientsByQuery.js +1 -0
- package/dist/generated/GetConnectedServer.d.ts +2 -0
- package/dist/generated/GetConnectedServer.d.ts.map +1 -0
- package/dist/generated/GetConnectedServer.js +2 -0
- package/dist/generated/GetItemsByTypeAndIds.d.ts +14 -0
- package/dist/generated/GetItemsByTypeAndIds.d.ts.map +1 -0
- package/dist/generated/GetItemsByTypeAndIds.js +2 -0
- package/dist/generated/GetPeerServers.d.ts +2 -0
- package/dist/generated/GetPeerServers.d.ts.map +1 -0
- package/dist/generated/GetPeerServers.js +2 -0
- package/dist/generated/GetPersistHealth.d.ts +7 -0
- package/dist/generated/GetPersistHealth.d.ts.map +1 -0
- package/dist/generated/GetPersistHealth.js +2 -0
- package/dist/generated/GetServerById.d.ts +5 -0
- package/dist/generated/GetServerById.d.ts.map +1 -0
- package/dist/generated/GetServerById.js +1 -0
- package/dist/generated/GetServersByIds.d.ts +5 -0
- package/dist/generated/GetServersByIds.d.ts.map +1 -0
- package/dist/generated/GetServersByIds.js +1 -0
- package/dist/generated/GetServersByQuery.d.ts +3 -0
- package/dist/generated/GetServersByQuery.d.ts.map +1 -0
- package/dist/generated/GetServersByQuery.js +1 -0
- package/dist/generated/ImportItems.d.ts +19 -0
- package/dist/generated/ImportItems.d.ts.map +1 -0
- package/dist/generated/ImportItems.js +1 -0
- package/dist/generated/ImportItemsArgs.d.ts +13 -0
- package/dist/generated/ImportItemsArgs.d.ts.map +1 -0
- package/dist/generated/ImportItemsArgs.js +1 -0
- package/dist/generated/ItemStub.d.ts +10 -0
- package/dist/generated/ItemStub.d.ts.map +1 -0
- package/dist/generated/ItemStub.js +2 -0
- package/dist/generated/LogLevel.d.ts +5 -0
- package/dist/generated/LogLevel.d.ts.map +1 -0
- package/dist/generated/LogLevel.js +2 -0
- package/dist/generated/Loggers.d.ts +5 -0
- package/dist/generated/Loggers.d.ts.map +1 -0
- package/dist/generated/Loggers.js +2 -0
- package/dist/generated/MEvent.d.ts +16 -0
- package/dist/generated/MEvent.d.ts.map +1 -0
- package/dist/generated/MEvent.js +1 -0
- package/dist/generated/MEventType.d.ts +2 -0
- package/dist/generated/MEventType.d.ts.map +1 -0
- package/dist/generated/MEventType.js +2 -0
- package/dist/generated/MykoMessage.d.ts +86 -0
- package/dist/generated/MykoMessage.d.ts.map +1 -0
- package/dist/generated/MykoMessage.js +1 -0
- package/dist/generated/PartialClient.d.ts +12 -0
- package/dist/generated/PartialClient.d.ts.map +1 -0
- package/dist/generated/PartialClient.js +1 -0
- package/dist/generated/PartialServer.d.ts +9 -0
- package/dist/generated/PartialServer.d.ts.map +1 -0
- package/dist/generated/PartialServer.js +1 -0
- package/dist/generated/PeerAlive.d.ts +8 -0
- package/dist/generated/PeerAlive.d.ts.map +1 -0
- package/dist/generated/PeerAlive.js +2 -0
- package/dist/generated/PersistHealthStatus.d.ts +34 -0
- package/dist/generated/PersistHealthStatus.d.ts.map +1 -0
- package/dist/generated/PersistHealthStatus.js +2 -0
- package/dist/generated/PingData.d.ts +14 -0
- package/dist/generated/PingData.d.ts.map +1 -0
- package/dist/generated/PingData.js +2 -0
- package/dist/generated/QueryError.d.ts +6 -0
- package/dist/generated/QueryError.d.ts.map +1 -0
- package/dist/generated/QueryError.js +2 -0
- package/dist/generated/QueryWindow.d.ts +5 -0
- package/dist/generated/QueryWindow.d.ts.map +1 -0
- package/dist/generated/QueryWindow.js +2 -0
- package/dist/generated/QueryWindowUpdate.d.ts +6 -0
- package/dist/generated/QueryWindowUpdate.d.ts.map +1 -0
- package/dist/generated/QueryWindowUpdate.js +1 -0
- package/dist/generated/ReportError.d.ts +6 -0
- package/dist/generated/ReportError.d.ts.map +1 -0
- package/dist/generated/ReportError.js +2 -0
- package/dist/generated/ReportResponse.d.ts +6 -0
- package/dist/generated/ReportResponse.d.ts.map +1 -0
- package/dist/generated/ReportResponse.js +1 -0
- package/dist/generated/Server.d.ts +9 -0
- package/dist/generated/Server.d.ts.map +1 -0
- package/dist/generated/Server.js +1 -0
- package/dist/generated/ServerCount.d.ts +4 -0
- package/dist/generated/ServerCount.d.ts.map +1 -0
- package/dist/generated/ServerCount.js +2 -0
- package/dist/generated/ServerId.d.ts +2 -0
- package/dist/generated/ServerId.d.ts.map +1 -0
- package/dist/generated/ServerId.js +2 -0
- package/dist/generated/ServerLogLevel.d.ts +7 -0
- package/dist/generated/ServerLogLevel.d.ts.map +1 -0
- package/dist/generated/ServerLogLevel.js +2 -0
- package/dist/generated/ServerStats.d.ts +26 -0
- package/dist/generated/ServerStats.d.ts.map +1 -0
- package/dist/generated/ServerStats.js +2 -0
- package/dist/generated/ServerStatsOutput.d.ts +20 -0
- package/dist/generated/ServerStatsOutput.d.ts.map +1 -0
- package/dist/generated/ServerStatsOutput.js +1 -0
- package/dist/generated/SetClientWindbackTime.d.ts +11 -0
- package/dist/generated/SetClientWindbackTime.d.ts.map +1 -0
- package/dist/generated/SetClientWindbackTime.js +2 -0
- package/dist/generated/SetClientWindbackTimeArgs.d.ts +7 -0
- package/dist/generated/SetClientWindbackTimeArgs.d.ts.map +1 -0
- package/dist/generated/SetClientWindbackTimeArgs.js +2 -0
- package/dist/generated/SetLogLevel.d.ts +9 -0
- package/dist/generated/SetLogLevel.d.ts.map +1 -0
- package/dist/generated/SetLogLevel.js +1 -0
- package/dist/generated/SetLogLevelArgs.d.ts +6 -0
- package/dist/generated/SetLogLevelArgs.d.ts.map +1 -0
- package/dist/generated/SetLogLevelArgs.js +1 -0
- package/dist/generated/ViewError.d.ts +6 -0
- package/dist/generated/ViewError.d.ts.map +1 -0
- package/dist/generated/ViewError.js +2 -0
- package/dist/generated/ViewWindowUpdate.d.ts +6 -0
- package/dist/generated/ViewWindowUpdate.d.ts.map +1 -0
- package/dist/generated/ViewWindowUpdate.js +1 -0
- package/dist/generated/WindbackStatus.d.ts +2 -0
- package/dist/generated/WindbackStatus.d.ts.map +1 -0
- package/dist/generated/WindbackStatus.js +2 -0
- package/dist/generated/WindbackStatusOutput.d.ts +11 -0
- package/dist/generated/WindbackStatusOutput.d.ts.map +1 -0
- package/dist/generated/WindbackStatusOutput.js +2 -0
- package/dist/generated/WrappedCommand.d.ts +6 -0
- package/dist/generated/WrappedCommand.d.ts.map +1 -0
- package/dist/generated/WrappedCommand.js +1 -0
- package/dist/generated/WrappedItem.d.ts +9 -0
- package/dist/generated/WrappedItem.d.ts.map +1 -0
- package/dist/generated/WrappedItem.js +1 -0
- package/dist/generated/WrappedQuery.d.ts +9 -0
- package/dist/generated/WrappedQuery.d.ts.map +1 -0
- package/dist/generated/WrappedQuery.js +1 -0
- package/dist/generated/WrappedReport.d.ts +6 -0
- package/dist/generated/WrappedReport.d.ts.map +1 -0
- package/dist/generated/WrappedReport.js +1 -0
- package/dist/generated/WrappedView.d.ts +9 -0
- package/dist/generated/WrappedView.d.ts.map +1 -0
- package/dist/generated/WrappedView.js +1 -0
- package/dist/generated/index.d.ts +421 -0
- package/dist/generated/index.d.ts.map +1 -0
- package/dist/generated/index.js +349 -0
- package/dist/generated/serde_json/JsonValue.d.ts +4 -0
- package/dist/generated/serde_json/JsonValue.d.ts.map +1 -0
- package/dist/generated/serde_json/JsonValue.js +2 -0
- package/dist/index.d.ts +76 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +68 -0
- package/package.json +41 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,1316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure TypeScript WebSocket client for Myko servers
|
|
3
|
+
*
|
|
4
|
+
* Maintains connections to all known servers for instant failover.
|
|
5
|
+
* When the current server disconnects, instantly switches to another open connection.
|
|
6
|
+
*/
|
|
7
|
+
import { GetPeerServers, MykoEvent, } from './generated';
|
|
8
|
+
import { Packr, Unpackr } from 'msgpackr';
|
|
9
|
+
import { bufferCount, bufferTime, catchError, combineLatest, filter, finalize, firstValueFrom, interval, map, merge, Observable, of, ReplaySubject, scan, shareReplay, Subject, switchMap, } from 'rxjs';
|
|
10
|
+
import { v4 as uuid } from 'uuid';
|
|
11
|
+
// msgpackr defaults can emit extension types for values that don't exist in JSON (notably
|
|
12
|
+
// `undefined`). Our Rust server deserializes msgpack into `serde_json::Value`, so ensure we
|
|
13
|
+
// encode `undefined` as nil/null instead of an extension.
|
|
14
|
+
const packr = new Packr({ encodeUndefinedAsNil: true });
|
|
15
|
+
const unpackr = new Unpackr({});
|
|
16
|
+
const EVENT_BATCH = 'ws:m:event-batch';
|
|
17
|
+
function stableStringify(value) {
|
|
18
|
+
const seen = new WeakSet();
|
|
19
|
+
try {
|
|
20
|
+
return JSON.stringify(value, (_key, raw) => {
|
|
21
|
+
if (typeof raw === 'bigint')
|
|
22
|
+
return `__bigint:${raw.toString()}`;
|
|
23
|
+
if (!raw || typeof raw !== 'object')
|
|
24
|
+
return raw;
|
|
25
|
+
if (seen.has(raw))
|
|
26
|
+
return '__circular__';
|
|
27
|
+
seen.add(raw);
|
|
28
|
+
if (Array.isArray(raw))
|
|
29
|
+
return raw;
|
|
30
|
+
const sorted = {};
|
|
31
|
+
for (const key of Object.keys(raw).sort()) {
|
|
32
|
+
sorted[key] = raw[key];
|
|
33
|
+
}
|
|
34
|
+
return sorted;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Connection status */
|
|
42
|
+
export var ConnectionStatus;
|
|
43
|
+
(function (ConnectionStatus) {
|
|
44
|
+
ConnectionStatus["Connected"] = "Connected";
|
|
45
|
+
ConnectionStatus["Disconnected"] = "Disconnected";
|
|
46
|
+
ConnectionStatus["Connecting"] = "Connecting";
|
|
47
|
+
})(ConnectionStatus || (ConnectionStatus = {}));
|
|
48
|
+
/** Wire protocol for encoding messages */
|
|
49
|
+
export var MykoProtocol;
|
|
50
|
+
(function (MykoProtocol) {
|
|
51
|
+
MykoProtocol["JSON"] = "JSON";
|
|
52
|
+
MykoProtocol["MSGPACK"] = "MSGPACK";
|
|
53
|
+
})(MykoProtocol || (MykoProtocol = {}));
|
|
54
|
+
function queryCacheKey(query, options) {
|
|
55
|
+
const queryPayload = stableStringify(query.query) ?? '__unstable_query__';
|
|
56
|
+
const windowPayload = stableStringify(options?.window ?? null) ?? '__unstable_window__';
|
|
57
|
+
return `query:${query.queryId}:${queryPayload}:${windowPayload}`;
|
|
58
|
+
}
|
|
59
|
+
function viewCacheKey(view, options) {
|
|
60
|
+
const viewPayload = stableStringify(view.view) ?? '__unstable_view__';
|
|
61
|
+
const windowPayload = stableStringify(options?.window ?? null) ?? '__unstable_window__';
|
|
62
|
+
return `view:${view.viewId}:${viewPayload}:${windowPayload}`;
|
|
63
|
+
}
|
|
64
|
+
function reportCacheKey(report) {
|
|
65
|
+
const payload = stableStringify(report.report) ?? '__unstable_report__';
|
|
66
|
+
return `report:${report.reportId}:${payload}`;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Reactive WebSocket client for Myko servers with automatic failover.
|
|
70
|
+
*/
|
|
71
|
+
export class MykoClient {
|
|
72
|
+
// Socket management
|
|
73
|
+
sockets = new Map();
|
|
74
|
+
reconnectTimers = new Map();
|
|
75
|
+
endpointSockets = new Map();
|
|
76
|
+
// Main server: the socket used for outbound sends.
|
|
77
|
+
// Other open sockets are warm standbys for failover.
|
|
78
|
+
currentServer = null;
|
|
79
|
+
shouldReconnect = true;
|
|
80
|
+
// Message routing
|
|
81
|
+
queryResponses = new Subject();
|
|
82
|
+
reportResponses = new Subject();
|
|
83
|
+
commandResponses = new Subject();
|
|
84
|
+
commandErrors = new Subject();
|
|
85
|
+
queryErrors = new Subject();
|
|
86
|
+
reportErrors = new Subject();
|
|
87
|
+
pingResponses = new Subject();
|
|
88
|
+
commandIncoming = new Subject();
|
|
89
|
+
// State observables
|
|
90
|
+
connectionStatusSubject = new ReplaySubject(1);
|
|
91
|
+
currentServerSubject = new ReplaySubject(1);
|
|
92
|
+
// Subscription tracking
|
|
93
|
+
activeQueries = new Map();
|
|
94
|
+
activeViews = new Map();
|
|
95
|
+
activeReports = new Map();
|
|
96
|
+
activeQueryNames = new Map();
|
|
97
|
+
activeViewNames = new Map();
|
|
98
|
+
activeReportNames = new Map();
|
|
99
|
+
sharedQueries = new Map();
|
|
100
|
+
sharedViews = new Map();
|
|
101
|
+
sharedQueryDiffs = new Map();
|
|
102
|
+
sharedViewDiffs = new Map();
|
|
103
|
+
sharedReports = new Map();
|
|
104
|
+
subscriptionStartMs = new Map();
|
|
105
|
+
firstResponseLogged = new Set();
|
|
106
|
+
messageQueue = [];
|
|
107
|
+
pendingEventBatch = [];
|
|
108
|
+
eventBatchFlushScheduled = false;
|
|
109
|
+
eventBatchMaxSize = 256;
|
|
110
|
+
connectionLogLevelThreshold = MykoClient.resolveDefaultConnectionLogLevel();
|
|
111
|
+
// Stats
|
|
112
|
+
downMsgCounter = new Subject();
|
|
113
|
+
upMsgCounter = new Subject();
|
|
114
|
+
// Auth & peer discovery
|
|
115
|
+
userToken = null;
|
|
116
|
+
peerDiscoveryEnabled = true;
|
|
117
|
+
peerDiscoverySubscription = null;
|
|
118
|
+
useSecureWebSocket = false;
|
|
119
|
+
// Protocol defaults to JSON for maximum compatibility (no msgpack extensions, bigint issues, etc).
|
|
120
|
+
protocol = MykoProtocol.JSON;
|
|
121
|
+
constructor() {
|
|
122
|
+
this.setConnectionStatus(ConnectionStatus.Disconnected, 'init');
|
|
123
|
+
this.setCurrentServer(null, 'init');
|
|
124
|
+
}
|
|
125
|
+
/** Set connection log verbosity at runtime. */
|
|
126
|
+
setConnectionLogLevel(level) {
|
|
127
|
+
this.connectionLogLevelThreshold = level;
|
|
128
|
+
}
|
|
129
|
+
/** Set the wire protocol (JSON or MSGPACK). Default is MSGPACK. */
|
|
130
|
+
setProtocol(protocol) {
|
|
131
|
+
this.protocol = protocol;
|
|
132
|
+
}
|
|
133
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
134
|
+
// Connection Management
|
|
135
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
136
|
+
/** Set a single server address, clearing any existing connections */
|
|
137
|
+
setAddress(address) {
|
|
138
|
+
this.shouldReconnect = true; // Re-enable autoreconnect when setting new address
|
|
139
|
+
this.closeAllSockets();
|
|
140
|
+
if (address) {
|
|
141
|
+
this.useSecureWebSocket = address.startsWith('wss://');
|
|
142
|
+
this.createSocket(address, true);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** Set multiple server addresses, clearing any existing connections */
|
|
146
|
+
setAddresses(addresses) {
|
|
147
|
+
this.shouldReconnect = true; // Re-enable autoreconnect when setting new addresses
|
|
148
|
+
this.closeAllSockets();
|
|
149
|
+
for (const addr of addresses) {
|
|
150
|
+
this.createSocket(addr, true);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** Add additional servers (connects immediately) */
|
|
154
|
+
addServers(addresses, reconnectOnClose = true) {
|
|
155
|
+
this.shouldReconnect = true; // Re-enable autoreconnect when adding servers
|
|
156
|
+
for (const addr of addresses) {
|
|
157
|
+
if (!this.hasConnectionTo(addr)) {
|
|
158
|
+
this.createSocket(addr, reconnectOnClose);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/** Disconnect from all servers */
|
|
163
|
+
disconnect() {
|
|
164
|
+
this.shouldReconnect = false;
|
|
165
|
+
this.stopPeerDiscovery();
|
|
166
|
+
this.closeAllSockets();
|
|
167
|
+
}
|
|
168
|
+
/** Get the currently active server address */
|
|
169
|
+
getCurrentServer() {
|
|
170
|
+
return this.currentServer;
|
|
171
|
+
}
|
|
172
|
+
/** Get the current main server address used for outbound sends */
|
|
173
|
+
getMainServer() {
|
|
174
|
+
return this.currentServer;
|
|
175
|
+
}
|
|
176
|
+
/** Get all server addresses */
|
|
177
|
+
getServers() {
|
|
178
|
+
return Array.from(this.sockets.values()).map((m) => m.address);
|
|
179
|
+
}
|
|
180
|
+
/** Get addresses of all open connections */
|
|
181
|
+
getOpenServers() {
|
|
182
|
+
return Array.from(this.sockets.values())
|
|
183
|
+
.filter((m) => m.ws.readyState === WebSocket.OPEN)
|
|
184
|
+
.map((m) => m.address);
|
|
185
|
+
}
|
|
186
|
+
/** Observable of current server changes */
|
|
187
|
+
get currentServer$() {
|
|
188
|
+
return this.currentServerSubject.asObservable();
|
|
189
|
+
}
|
|
190
|
+
/** Observable of main server changes */
|
|
191
|
+
get mainServer$() {
|
|
192
|
+
return this.currentServer$;
|
|
193
|
+
}
|
|
194
|
+
/** Get current connection status */
|
|
195
|
+
getConnectionStatus() {
|
|
196
|
+
if (this.currentServer)
|
|
197
|
+
return ConnectionStatus.Connected;
|
|
198
|
+
for (const m of this.sockets.values()) {
|
|
199
|
+
if (m.ws.readyState === WebSocket.CONNECTING)
|
|
200
|
+
return ConnectionStatus.Connecting;
|
|
201
|
+
}
|
|
202
|
+
return ConnectionStatus.Disconnected;
|
|
203
|
+
}
|
|
204
|
+
/** Observable of connection status changes */
|
|
205
|
+
get connectionStatus$() {
|
|
206
|
+
return this.connectionStatusSubject.asObservable();
|
|
207
|
+
}
|
|
208
|
+
/** Observable of incoming command messages (ws:m:command) from the server */
|
|
209
|
+
get commandIncoming$() {
|
|
210
|
+
return this.commandIncoming.asObservable();
|
|
211
|
+
}
|
|
212
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
213
|
+
// Peer Discovery
|
|
214
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
215
|
+
/** Enable automatic peer discovery via GetPeerServers query */
|
|
216
|
+
enablePeerDiscovery(enabled, secure = false) {
|
|
217
|
+
this.peerDiscoveryEnabled = enabled;
|
|
218
|
+
this.useSecureWebSocket = secure;
|
|
219
|
+
if (!enabled && this.peerDiscoverySubscription) {
|
|
220
|
+
this.peerDiscoverySubscription.unsubscribe();
|
|
221
|
+
this.peerDiscoverySubscription = null;
|
|
222
|
+
}
|
|
223
|
+
else if (enabled && this.hasOpenConnection()) {
|
|
224
|
+
this.startPeerDiscovery();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
startPeerDiscovery() {
|
|
228
|
+
this.peerDiscoverySubscription?.unsubscribe();
|
|
229
|
+
this.logConnection('peer_discovery_started', {
|
|
230
|
+
secure: this.useSecureWebSocket,
|
|
231
|
+
via: 'query:GetPeerServers',
|
|
232
|
+
});
|
|
233
|
+
this.peerDiscoverySubscription = this.watchQuery(new GetPeerServers({})).subscribe((servers) => {
|
|
234
|
+
const addresses = servers.map((s) => this.useSecureWebSocket
|
|
235
|
+
? `wss://${s.address}/myko`
|
|
236
|
+
: `ws://${s.address}:${s.port}/myko`);
|
|
237
|
+
this.logConnection('peer_discovery_update', {
|
|
238
|
+
peers: servers.length,
|
|
239
|
+
addresses,
|
|
240
|
+
});
|
|
241
|
+
// Discovered peers are ephemeral: if they disconnect, wait for discovery
|
|
242
|
+
// to advertise them again rather than actively redialing.
|
|
243
|
+
if (addresses.length > 0)
|
|
244
|
+
this.addServers(addresses, false);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
stopPeerDiscovery() {
|
|
248
|
+
this.peerDiscoverySubscription?.unsubscribe();
|
|
249
|
+
this.peerDiscoverySubscription = null;
|
|
250
|
+
}
|
|
251
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
252
|
+
// Auth & Stats
|
|
253
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
254
|
+
/** Set authentication token for commands */
|
|
255
|
+
setToken(token) {
|
|
256
|
+
this.userToken = token;
|
|
257
|
+
}
|
|
258
|
+
/** Observable of all errors */
|
|
259
|
+
get errors$() {
|
|
260
|
+
const toError = (e) => ({ event: e.event, tx: e.data.tx, message: e.data.message });
|
|
261
|
+
return merge(this.queryErrors.pipe(map(toError)), this.commandErrors.pipe(map(toError)), this.reportErrors.pipe(map(toError)));
|
|
262
|
+
}
|
|
263
|
+
/** Observable of successful command completions (tx id) */
|
|
264
|
+
get successes$() {
|
|
265
|
+
return this.commandResponses.pipe(map((r) => r.data.tx));
|
|
266
|
+
}
|
|
267
|
+
/** Measure round-trip latency */
|
|
268
|
+
async ping() {
|
|
269
|
+
const id = uuid();
|
|
270
|
+
const nowMs = Date.now();
|
|
271
|
+
// IMPORTANT: the Rust server expects `timestamp: i64`.
|
|
272
|
+
// - JSON cannot encode bigint, so use number in JSON mode.
|
|
273
|
+
// - msgpack can encode bigint as int64, so use bigint in MSGPACK mode.
|
|
274
|
+
const timestamp = this.protocol === MykoProtocol.MSGPACK ? BigInt(nowMs) : nowMs;
|
|
275
|
+
this.send({
|
|
276
|
+
event: MykoEvent.Ping,
|
|
277
|
+
data: { id, timestamp },
|
|
278
|
+
});
|
|
279
|
+
return firstValueFrom(this.pingResponses.pipe(filter((p) => p.data.id === id), map((p) => Date.now() - Number(p.data.timestamp))));
|
|
280
|
+
}
|
|
281
|
+
/** Get real-time client statistics (emits every second) */
|
|
282
|
+
stats() {
|
|
283
|
+
const pingLatency = interval(1000).pipe(switchMap(() => this.ping()), catchError(() => of(0)));
|
|
284
|
+
const mpsDown = this.downMsgCounter.pipe(bufferTime(100), bufferCount(10), map((b) => b.flat().length));
|
|
285
|
+
const mpsUp = this.upMsgCounter.pipe(bufferTime(100), bufferCount(10), map((b) => b.flat().length));
|
|
286
|
+
return combineLatest([pingLatency, mpsDown, mpsUp]).pipe(map(([ping, down, up]) => ({ ping, mpsDown: down, mpsUp: up })));
|
|
287
|
+
}
|
|
288
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
289
|
+
// Queries & Reports
|
|
290
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
291
|
+
/** Start a query subscription, returns [tx, responses$] */
|
|
292
|
+
startQuery(query, options) {
|
|
293
|
+
if (!query.queryId || !query.queryItemType || !query.query) {
|
|
294
|
+
const details = {
|
|
295
|
+
ctor: query.constructor?.name ?? 'unknown',
|
|
296
|
+
keys: Object.keys(query ?? {}),
|
|
297
|
+
queryId: query.queryId,
|
|
298
|
+
queryItemType: query.queryItemType,
|
|
299
|
+
};
|
|
300
|
+
this.logConnection('query_shape_invalid', details);
|
|
301
|
+
throw new Error(`Invalid query shape for ${details.ctor}: expected { queryId, queryItemType, query }`);
|
|
302
|
+
}
|
|
303
|
+
const tx = uuid();
|
|
304
|
+
const window = options?.window ?? undefined;
|
|
305
|
+
const queryName = query.constructor?.name ?? query.queryId;
|
|
306
|
+
const wrappedQuery = {
|
|
307
|
+
query: { ...query.query, tx, createdAt: new Date().toISOString() },
|
|
308
|
+
queryId: query.queryId,
|
|
309
|
+
queryItemType: query.queryItemType,
|
|
310
|
+
...(window ? { window } : {}),
|
|
311
|
+
};
|
|
312
|
+
this.activeQueries.set(tx, wrappedQuery);
|
|
313
|
+
this.activeQueryNames.set(tx, queryName);
|
|
314
|
+
this.subscriptionStartMs.set(tx, this.nowMs());
|
|
315
|
+
this.firstResponseLogged.delete(tx);
|
|
316
|
+
this.logConnection('query_subscribe', {
|
|
317
|
+
tx,
|
|
318
|
+
queryId: wrappedQuery.queryId,
|
|
319
|
+
queryItemType: wrappedQuery.queryItemType,
|
|
320
|
+
queryName,
|
|
321
|
+
window: window ?? null,
|
|
322
|
+
activeQueries: this.activeQueries.size,
|
|
323
|
+
});
|
|
324
|
+
this.send({ event: MykoEvent.Query, data: wrappedQuery });
|
|
325
|
+
const responses$ = new Observable((subscriber) => {
|
|
326
|
+
const responseSub = this.queryResponses
|
|
327
|
+
.pipe(filter((r) => r.data.tx === tx))
|
|
328
|
+
.subscribe({
|
|
329
|
+
next: (response) => subscriber.next(response),
|
|
330
|
+
error: (error) => subscriber.error(error),
|
|
331
|
+
});
|
|
332
|
+
const errorSub = this.queryErrors
|
|
333
|
+
.pipe(filter((error) => error.data.tx === tx))
|
|
334
|
+
.subscribe((error) => {
|
|
335
|
+
subscriber.error(new Error(error.data.message));
|
|
336
|
+
});
|
|
337
|
+
return () => {
|
|
338
|
+
responseSub.unsubscribe();
|
|
339
|
+
errorSub.unsubscribe();
|
|
340
|
+
};
|
|
341
|
+
}).pipe(finalize(() => {
|
|
342
|
+
this.logConnection('query_cancel', {
|
|
343
|
+
tx,
|
|
344
|
+
queryId: wrappedQuery.queryId,
|
|
345
|
+
queryItemType: wrappedQuery.queryItemType,
|
|
346
|
+
queryName,
|
|
347
|
+
activeQueriesBefore: this.activeQueries.size,
|
|
348
|
+
});
|
|
349
|
+
this.activeQueries.delete(tx);
|
|
350
|
+
this.activeQueryNames.delete(tx);
|
|
351
|
+
this.subscriptionStartMs.delete(tx);
|
|
352
|
+
this.firstResponseLogged.delete(tx);
|
|
353
|
+
this.send({ event: MykoEvent.QueryCancel, data: { tx } });
|
|
354
|
+
}));
|
|
355
|
+
return [tx, responses$];
|
|
356
|
+
}
|
|
357
|
+
/** Update server-side window for an active query subscription */
|
|
358
|
+
setQueryWindow(tx, window) {
|
|
359
|
+
const active = this.activeQueries.get(tx);
|
|
360
|
+
if (!active)
|
|
361
|
+
return;
|
|
362
|
+
const updated = {
|
|
363
|
+
...active,
|
|
364
|
+
...(window ? { window } : {}),
|
|
365
|
+
};
|
|
366
|
+
if (!window) {
|
|
367
|
+
delete updated.window;
|
|
368
|
+
}
|
|
369
|
+
this.activeQueries.set(tx, updated);
|
|
370
|
+
this.logConnection('query_window_set', {
|
|
371
|
+
tx,
|
|
372
|
+
queryId: active.queryId,
|
|
373
|
+
queryItemType: active.queryItemType,
|
|
374
|
+
queryName: this.activeQueryNames.get(tx) ?? active.queryId,
|
|
375
|
+
window,
|
|
376
|
+
});
|
|
377
|
+
this.send({ event: MykoEvent.QueryWindow, data: { tx, window } });
|
|
378
|
+
}
|
|
379
|
+
/** Start a view subscription, returns [tx, responses$] */
|
|
380
|
+
startView(view, options) {
|
|
381
|
+
if (!view.viewId || !view.viewItemType || !view.view) {
|
|
382
|
+
const details = {
|
|
383
|
+
ctor: view.constructor?.name ?? 'unknown',
|
|
384
|
+
keys: Object.keys(view ?? {}),
|
|
385
|
+
viewId: view.viewId,
|
|
386
|
+
viewItemType: view.viewItemType,
|
|
387
|
+
};
|
|
388
|
+
this.logConnection('view_shape_invalid', details);
|
|
389
|
+
throw new Error(`Invalid view shape for ${details.ctor}: expected { viewId, viewItemType, view }`);
|
|
390
|
+
}
|
|
391
|
+
const tx = uuid();
|
|
392
|
+
const window = options?.window ?? undefined;
|
|
393
|
+
const viewName = view.constructor?.name ?? view.viewId;
|
|
394
|
+
const wrappedView = {
|
|
395
|
+
view: { ...view.view, tx, createdAt: new Date().toISOString() },
|
|
396
|
+
viewId: view.viewId,
|
|
397
|
+
viewItemType: view.viewItemType,
|
|
398
|
+
...(window ? { window } : {}),
|
|
399
|
+
};
|
|
400
|
+
this.activeViews.set(tx, wrappedView);
|
|
401
|
+
this.activeViewNames.set(tx, viewName);
|
|
402
|
+
this.subscriptionStartMs.set(tx, this.nowMs());
|
|
403
|
+
this.firstResponseLogged.delete(tx);
|
|
404
|
+
this.logConnection('view_subscribe', {
|
|
405
|
+
tx,
|
|
406
|
+
viewId: wrappedView.viewId,
|
|
407
|
+
viewItemType: wrappedView.viewItemType,
|
|
408
|
+
viewName,
|
|
409
|
+
window: wrappedView.window ?? null,
|
|
410
|
+
activeViews: this.activeViews.size,
|
|
411
|
+
});
|
|
412
|
+
this.send({ event: MykoEvent.View, data: wrappedView });
|
|
413
|
+
const responses$ = new Observable((subscriber) => {
|
|
414
|
+
const responseSub = this.queryResponses
|
|
415
|
+
.pipe(filter((r) => r.data.tx === tx))
|
|
416
|
+
.subscribe({
|
|
417
|
+
next: (response) => subscriber.next(response),
|
|
418
|
+
error: (error) => subscriber.error(error),
|
|
419
|
+
});
|
|
420
|
+
const errorSub = this.queryErrors
|
|
421
|
+
.pipe(filter((error) => error.data.tx === tx))
|
|
422
|
+
.subscribe((error) => {
|
|
423
|
+
subscriber.error(new Error(error.data.message));
|
|
424
|
+
});
|
|
425
|
+
return () => {
|
|
426
|
+
responseSub.unsubscribe();
|
|
427
|
+
errorSub.unsubscribe();
|
|
428
|
+
};
|
|
429
|
+
}).pipe(finalize(() => {
|
|
430
|
+
this.logConnection('view_cancel', {
|
|
431
|
+
tx,
|
|
432
|
+
viewId: wrappedView.viewId,
|
|
433
|
+
viewName,
|
|
434
|
+
activeViewsBefore: this.activeViews.size,
|
|
435
|
+
});
|
|
436
|
+
this.activeViews.delete(tx);
|
|
437
|
+
this.activeViewNames.delete(tx);
|
|
438
|
+
this.subscriptionStartMs.delete(tx);
|
|
439
|
+
this.firstResponseLogged.delete(tx);
|
|
440
|
+
this.send({ event: MykoEvent.ViewCancel, data: { tx } });
|
|
441
|
+
}));
|
|
442
|
+
return [tx, responses$];
|
|
443
|
+
}
|
|
444
|
+
/** Update server-side window for an active view subscription */
|
|
445
|
+
setViewWindow(tx, window) {
|
|
446
|
+
const active = this.activeViews.get(tx);
|
|
447
|
+
if (!active)
|
|
448
|
+
return;
|
|
449
|
+
const updated = {
|
|
450
|
+
...active,
|
|
451
|
+
...(window ? { window } : {}),
|
|
452
|
+
};
|
|
453
|
+
if (!window) {
|
|
454
|
+
delete updated.window;
|
|
455
|
+
}
|
|
456
|
+
this.activeViews.set(tx, updated);
|
|
457
|
+
this.logConnection('view_window_set', {
|
|
458
|
+
tx,
|
|
459
|
+
viewId: active.viewId,
|
|
460
|
+
viewName: this.activeViewNames.get(tx) ?? active.viewId,
|
|
461
|
+
window,
|
|
462
|
+
});
|
|
463
|
+
this.send({ event: MykoEvent.ViewWindow, data: { tx, window } });
|
|
464
|
+
}
|
|
465
|
+
/** Watch a query and receive live updates with automatic deduplication */
|
|
466
|
+
watchQuery(query, options) {
|
|
467
|
+
const cacheKey = queryCacheKey(query, options);
|
|
468
|
+
const existing = this.sharedQueries.get(cacheKey);
|
|
469
|
+
if (existing)
|
|
470
|
+
return existing;
|
|
471
|
+
const [, responses$] = this.startQuery(query, options);
|
|
472
|
+
const shared$ = responses$.pipe(scan((acc, update) => {
|
|
473
|
+
if (BigInt(update.data.sequence) === 0n)
|
|
474
|
+
acc.clear();
|
|
475
|
+
for (const id of update.data.deletes)
|
|
476
|
+
acc.delete(id);
|
|
477
|
+
for (const wrapped of update.data.upserts) {
|
|
478
|
+
const item = wrapped.item;
|
|
479
|
+
if (item?.id)
|
|
480
|
+
acc.set(item.id, wrapped);
|
|
481
|
+
}
|
|
482
|
+
return acc;
|
|
483
|
+
}, new Map()), map((items) => [...items.values()].map((w) => w.item)), finalize(() => {
|
|
484
|
+
this.sharedQueries.delete(cacheKey);
|
|
485
|
+
}), shareReplay({ bufferSize: 1, refCount: true }),
|
|
486
|
+
// Defensive copy: shareReplay replays the same array reference to all
|
|
487
|
+
// subscribers, so a mutation (e.g. .shift()) by one subscriber would
|
|
488
|
+
// corrupt the shared value for others. Cloning per-subscriber prevents this.
|
|
489
|
+
map((x) => (Array.isArray(x) ? x.slice() : x)));
|
|
490
|
+
this.sharedQueries.set(cacheKey, shared$);
|
|
491
|
+
return shared$;
|
|
492
|
+
}
|
|
493
|
+
/** Watch a view and receive live updates with automatic deduplication */
|
|
494
|
+
watchView(view, options) {
|
|
495
|
+
const cacheKey = viewCacheKey(view, options);
|
|
496
|
+
const existing = this.sharedViews.get(cacheKey);
|
|
497
|
+
if (existing)
|
|
498
|
+
return existing;
|
|
499
|
+
const [, responses$] = this.startView(view, options);
|
|
500
|
+
const shared$ = responses$.pipe(scan((acc, update) => {
|
|
501
|
+
if (BigInt(update.data.sequence) === 0n)
|
|
502
|
+
acc.clear();
|
|
503
|
+
for (const id of update.data.deletes)
|
|
504
|
+
acc.delete(id);
|
|
505
|
+
for (const wrapped of update.data.upserts) {
|
|
506
|
+
const item = wrapped.item;
|
|
507
|
+
if (item?.id)
|
|
508
|
+
acc.set(item.id, wrapped);
|
|
509
|
+
}
|
|
510
|
+
return acc;
|
|
511
|
+
}, new Map()), map((items) => [...items.values()].map((w) => w.item)), finalize(() => {
|
|
512
|
+
this.sharedViews.delete(cacheKey);
|
|
513
|
+
}), shareReplay({ bufferSize: 1, refCount: true }), map((x) => (Array.isArray(x) ? x.slice() : x)));
|
|
514
|
+
this.sharedViews.set(cacheKey, shared$);
|
|
515
|
+
return shared$;
|
|
516
|
+
}
|
|
517
|
+
/** Watch a query and receive raw diff events */
|
|
518
|
+
watchQueryDiff(query, options) {
|
|
519
|
+
const cacheKey = queryCacheKey(query, options);
|
|
520
|
+
const existing = this.sharedQueryDiffs.get(cacheKey);
|
|
521
|
+
if (existing)
|
|
522
|
+
return existing;
|
|
523
|
+
const [, responses$] = this.startQuery(query, options);
|
|
524
|
+
const shared$ = responses$.pipe(map((r) => ({
|
|
525
|
+
sequence: BigInt(r.data.sequence),
|
|
526
|
+
deletes: r.data.deletes.slice(),
|
|
527
|
+
upserts: r.data.upserts.map((w) => w.item),
|
|
528
|
+
})), finalize(() => {
|
|
529
|
+
this.sharedQueryDiffs.delete(cacheKey);
|
|
530
|
+
}), shareReplay({ bufferSize: 1, refCount: true }), map((diff) => ({
|
|
531
|
+
sequence: diff.sequence,
|
|
532
|
+
deletes: diff.deletes.slice(),
|
|
533
|
+
upserts: diff.upserts.slice(),
|
|
534
|
+
})));
|
|
535
|
+
this.sharedQueryDiffs.set(cacheKey, shared$);
|
|
536
|
+
return shared$;
|
|
537
|
+
}
|
|
538
|
+
/** Watch a view and receive raw diff events */
|
|
539
|
+
watchViewDiff(view, options) {
|
|
540
|
+
const cacheKey = viewCacheKey(view, options);
|
|
541
|
+
const existing = this.sharedViewDiffs.get(cacheKey);
|
|
542
|
+
if (existing)
|
|
543
|
+
return existing;
|
|
544
|
+
const [, responses$] = this.startView(view, options);
|
|
545
|
+
const shared$ = responses$.pipe(map((r) => ({
|
|
546
|
+
sequence: BigInt(r.data.sequence),
|
|
547
|
+
deletes: r.data.deletes.slice(),
|
|
548
|
+
upserts: r.data.upserts.map((w) => w.item),
|
|
549
|
+
})), finalize(() => {
|
|
550
|
+
this.sharedViewDiffs.delete(cacheKey);
|
|
551
|
+
}), shareReplay({ bufferSize: 1, refCount: true }), map((diff) => ({
|
|
552
|
+
sequence: diff.sequence,
|
|
553
|
+
deletes: diff.deletes.slice(),
|
|
554
|
+
upserts: diff.upserts.slice(),
|
|
555
|
+
})));
|
|
556
|
+
this.sharedViewDiffs.set(cacheKey, shared$);
|
|
557
|
+
return shared$;
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Start a live query with a mutable server-side window.
|
|
561
|
+
* Use `setWindow` to scroll without re-subscribing.
|
|
562
|
+
*/
|
|
563
|
+
watchQueryWindowed(query, options) {
|
|
564
|
+
const [tx, responses$] = this.startQuery(query, options);
|
|
565
|
+
const sharedResponses$ = responses$.pipe(shareReplay({ bufferSize: 1, refCount: true }));
|
|
566
|
+
const results$ = sharedResponses$.pipe(scan((state, update) => {
|
|
567
|
+
const data = update.data;
|
|
568
|
+
if (BigInt(update.data.sequence) === 0n) {
|
|
569
|
+
state.cache.clear();
|
|
570
|
+
state.visibleIds = [];
|
|
571
|
+
}
|
|
572
|
+
for (const id of update.data.deletes)
|
|
573
|
+
state.cache.delete(id);
|
|
574
|
+
for (const wrapped of update.data.upserts) {
|
|
575
|
+
const item = wrapped.item;
|
|
576
|
+
if (item?.id)
|
|
577
|
+
state.cache.set(item.id, wrapped);
|
|
578
|
+
}
|
|
579
|
+
const order = data.changes?.find((change) => change.kind === 'windowOrder');
|
|
580
|
+
if (order) {
|
|
581
|
+
state.visibleIds = order.ids.slice();
|
|
582
|
+
}
|
|
583
|
+
else {
|
|
584
|
+
// Fallback when window-order diffs are unavailable: derive visible ids
|
|
585
|
+
// from current cache contents (in insertion order).
|
|
586
|
+
state.visibleIds = [...state.cache.keys()];
|
|
587
|
+
}
|
|
588
|
+
return state;
|
|
589
|
+
}, {
|
|
590
|
+
cache: new Map(),
|
|
591
|
+
visibleIds: [],
|
|
592
|
+
}), map((state) => state.visibleIds
|
|
593
|
+
.map((id) => state.cache.get(id)?.item)
|
|
594
|
+
.filter((item) => item !== undefined)), map((x) => x.slice()));
|
|
595
|
+
const windowInfo$ = sharedResponses$.pipe(map((update) => {
|
|
596
|
+
const data = update.data;
|
|
597
|
+
const order = data.changes?.find((change) => change.kind === 'windowOrder');
|
|
598
|
+
const orderTotalCount = order?.totalCount ?? order?.total_count;
|
|
599
|
+
return {
|
|
600
|
+
totalCount: typeof data.totalCount === 'number'
|
|
601
|
+
? data.totalCount
|
|
602
|
+
: typeof data.total_count === 'number'
|
|
603
|
+
? data.total_count
|
|
604
|
+
: typeof orderTotalCount === 'number'
|
|
605
|
+
? orderTotalCount
|
|
606
|
+
: null,
|
|
607
|
+
window: data.window ?? order?.window ?? null,
|
|
608
|
+
};
|
|
609
|
+
}), shareReplay({ bufferSize: 1, refCount: true }));
|
|
610
|
+
return {
|
|
611
|
+
tx,
|
|
612
|
+
results$,
|
|
613
|
+
windowInfo$,
|
|
614
|
+
setWindow: (window) => this.setQueryWindow(tx, window),
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
/** Start a live view with a mutable server-side window. */
|
|
618
|
+
watchViewWindowed(view, options) {
|
|
619
|
+
const [tx, responses$] = this.startView(view, options);
|
|
620
|
+
const sharedResponses$ = responses$.pipe(shareReplay({ bufferSize: 1, refCount: true }));
|
|
621
|
+
const results$ = sharedResponses$.pipe(scan((state, update) => {
|
|
622
|
+
const data = update.data;
|
|
623
|
+
if (BigInt(update.data.sequence) === 0n) {
|
|
624
|
+
state.cache.clear();
|
|
625
|
+
state.visibleIds = [];
|
|
626
|
+
}
|
|
627
|
+
for (const id of update.data.deletes)
|
|
628
|
+
state.cache.delete(id);
|
|
629
|
+
for (const wrapped of update.data.upserts) {
|
|
630
|
+
const item = wrapped.item;
|
|
631
|
+
if (item?.id)
|
|
632
|
+
state.cache.set(item.id, wrapped);
|
|
633
|
+
}
|
|
634
|
+
const order = data.changes?.find((change) => change.kind === 'windowOrder');
|
|
635
|
+
if (order) {
|
|
636
|
+
state.visibleIds = order.ids.slice();
|
|
637
|
+
}
|
|
638
|
+
else {
|
|
639
|
+
state.visibleIds = [...state.cache.keys()];
|
|
640
|
+
}
|
|
641
|
+
return state;
|
|
642
|
+
}, {
|
|
643
|
+
cache: new Map(),
|
|
644
|
+
visibleIds: [],
|
|
645
|
+
}), map((state) => state.visibleIds
|
|
646
|
+
.map((id) => state.cache.get(id)?.item)
|
|
647
|
+
.filter((item) => item !== undefined)), map((x) => x.slice()));
|
|
648
|
+
const windowInfo$ = sharedResponses$.pipe(map((update) => {
|
|
649
|
+
const data = update.data;
|
|
650
|
+
const order = data.changes?.find((change) => change.kind === 'windowOrder');
|
|
651
|
+
const orderTotalCount = order?.totalCount ?? order?.total_count;
|
|
652
|
+
return {
|
|
653
|
+
totalCount: typeof data.totalCount === 'number'
|
|
654
|
+
? data.totalCount
|
|
655
|
+
: typeof data.total_count === 'number'
|
|
656
|
+
? data.total_count
|
|
657
|
+
: typeof orderTotalCount === 'number'
|
|
658
|
+
? orderTotalCount
|
|
659
|
+
: null,
|
|
660
|
+
window: data.window ?? order?.window ?? null,
|
|
661
|
+
};
|
|
662
|
+
}), shareReplay({ bufferSize: 1, refCount: true }));
|
|
663
|
+
return {
|
|
664
|
+
tx,
|
|
665
|
+
results$,
|
|
666
|
+
windowInfo$,
|
|
667
|
+
setWindow: (window) => this.setViewWindow(tx, window),
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
/** Watch a report with automatic deduplication */
|
|
671
|
+
watchReport(report) {
|
|
672
|
+
const cacheKey = reportCacheKey(report);
|
|
673
|
+
const existing = this.sharedReports.get(cacheKey);
|
|
674
|
+
if (existing)
|
|
675
|
+
return existing;
|
|
676
|
+
const tx = uuid();
|
|
677
|
+
const reportName = report.constructor?.name ??
|
|
678
|
+
report.reportId;
|
|
679
|
+
const wrappedReport = {
|
|
680
|
+
report: { ...report.report, tx },
|
|
681
|
+
reportId: report.reportId,
|
|
682
|
+
};
|
|
683
|
+
this.activeReports.set(tx, wrappedReport);
|
|
684
|
+
this.activeReportNames.set(tx, reportName);
|
|
685
|
+
this.logConnection('report_subscribe', {
|
|
686
|
+
tx,
|
|
687
|
+
reportId: wrappedReport.reportId,
|
|
688
|
+
reportName,
|
|
689
|
+
report: report.report,
|
|
690
|
+
activeReports: this.activeReports.size,
|
|
691
|
+
});
|
|
692
|
+
this.send({ event: MykoEvent.Report, data: wrappedReport });
|
|
693
|
+
const shared$ = this.reportResponses.pipe(filter((r) => r.data.tx === tx), map((r) => {
|
|
694
|
+
this.logConnection('report_response', {
|
|
695
|
+
tx,
|
|
696
|
+
reportId: wrappedReport.reportId,
|
|
697
|
+
reportName,
|
|
698
|
+
});
|
|
699
|
+
return r;
|
|
700
|
+
}), map((r) => r.data.response), finalize(() => {
|
|
701
|
+
this.logConnection('report_cancel', {
|
|
702
|
+
tx,
|
|
703
|
+
reportId: wrappedReport.reportId,
|
|
704
|
+
reportName,
|
|
705
|
+
activeReportsBefore: this.activeReports.size,
|
|
706
|
+
});
|
|
707
|
+
this.sharedReports.delete(cacheKey);
|
|
708
|
+
this.activeReports.delete(tx);
|
|
709
|
+
this.activeReportNames.delete(tx);
|
|
710
|
+
this.send({ event: MykoEvent.ReportCancel, data: { tx } });
|
|
711
|
+
}), shareReplay({ bufferSize: 1, refCount: true }),
|
|
712
|
+
// Defensive copy: prevent one subscriber's mutations from affecting others
|
|
713
|
+
map((x) => {
|
|
714
|
+
if (Array.isArray(x))
|
|
715
|
+
return x.slice();
|
|
716
|
+
if (x && typeof x === 'object') {
|
|
717
|
+
return { ...x };
|
|
718
|
+
}
|
|
719
|
+
return x;
|
|
720
|
+
}));
|
|
721
|
+
this.sharedReports.set(cacheKey, shared$);
|
|
722
|
+
return shared$;
|
|
723
|
+
}
|
|
724
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
725
|
+
// Commands & Events
|
|
726
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
727
|
+
/** Send an event to the server */
|
|
728
|
+
sendEvent(event) {
|
|
729
|
+
// Pulses are latency-sensitive; bypass batching and send immediately.
|
|
730
|
+
if (event.itemType === 'Pulse') {
|
|
731
|
+
this.flushPendingEventBatch();
|
|
732
|
+
this.sendNow({ event: MykoEvent.Event, data: event });
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
this.sendEventBatch([event]);
|
|
736
|
+
}
|
|
737
|
+
/** Send a batch of events to the server */
|
|
738
|
+
sendEventBatch(events) {
|
|
739
|
+
if (events.length === 0)
|
|
740
|
+
return;
|
|
741
|
+
const buffered = [];
|
|
742
|
+
const immediatePulses = [];
|
|
743
|
+
for (const event of events) {
|
|
744
|
+
if (event.itemType === 'Pulse') {
|
|
745
|
+
immediatePulses.push(event);
|
|
746
|
+
}
|
|
747
|
+
else {
|
|
748
|
+
buffered.push(event);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
if (buffered.length > 0) {
|
|
752
|
+
this.pendingEventBatch.push(...buffered);
|
|
753
|
+
}
|
|
754
|
+
if (immediatePulses.length > 0) {
|
|
755
|
+
this.flushPendingEventBatch();
|
|
756
|
+
for (const pulse of immediatePulses) {
|
|
757
|
+
this.sendNow({ event: MykoEvent.Event, data: pulse });
|
|
758
|
+
}
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
if (this.pendingEventBatch.length >= this.eventBatchMaxSize) {
|
|
762
|
+
this.flushPendingEventBatch();
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
this.scheduleEventBatchFlush();
|
|
766
|
+
}
|
|
767
|
+
/** Send a command and wait for response */
|
|
768
|
+
sendCommand(command) {
|
|
769
|
+
const tx = uuid();
|
|
770
|
+
const wrappedCommand = {
|
|
771
|
+
command: {
|
|
772
|
+
...command.command,
|
|
773
|
+
tx,
|
|
774
|
+
createdAt: new Date().toISOString(),
|
|
775
|
+
...(this.userToken && { userToken: this.userToken }),
|
|
776
|
+
},
|
|
777
|
+
commandId: command.commandId,
|
|
778
|
+
};
|
|
779
|
+
return new Promise((resolve, reject) => {
|
|
780
|
+
const responseSub = this.commandResponses
|
|
781
|
+
.pipe(filter((r) => r.data.tx === tx))
|
|
782
|
+
.subscribe((r) => {
|
|
783
|
+
cleanup();
|
|
784
|
+
resolve(r.data.response);
|
|
785
|
+
});
|
|
786
|
+
const errorSub = this.commandErrors
|
|
787
|
+
.pipe(filter((r) => r.data.tx === tx))
|
|
788
|
+
.subscribe((r) => {
|
|
789
|
+
cleanup();
|
|
790
|
+
reject(new Error(r.data.message));
|
|
791
|
+
});
|
|
792
|
+
const cleanup = () => {
|
|
793
|
+
responseSub.unsubscribe();
|
|
794
|
+
errorSub.unsubscribe();
|
|
795
|
+
};
|
|
796
|
+
this.send({ event: MykoEvent.Command, data: wrappedCommand });
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
800
|
+
// Private: Socket Management
|
|
801
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
802
|
+
getFirstOpenSocket() {
|
|
803
|
+
for (const m of this.sockets.values()) {
|
|
804
|
+
if (m.ws.readyState === WebSocket.OPEN)
|
|
805
|
+
return m;
|
|
806
|
+
}
|
|
807
|
+
return null;
|
|
808
|
+
}
|
|
809
|
+
hasOpenConnection() {
|
|
810
|
+
return this.getFirstOpenSocket() !== null;
|
|
811
|
+
}
|
|
812
|
+
hasConnectionTo(address) {
|
|
813
|
+
return this.endpointSockets.has(this.endpointKey(address));
|
|
814
|
+
}
|
|
815
|
+
parseAddress(address) {
|
|
816
|
+
try {
|
|
817
|
+
const url = new URL(address);
|
|
818
|
+
const port = url.port
|
|
819
|
+
? parseInt(url.port, 10)
|
|
820
|
+
: url.protocol === 'wss:'
|
|
821
|
+
? 443
|
|
822
|
+
: 80;
|
|
823
|
+
return { host: url.hostname.toLowerCase(), port };
|
|
824
|
+
}
|
|
825
|
+
catch {
|
|
826
|
+
return null;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
endpointKey(address) {
|
|
830
|
+
const parsed = this.parseAddress(address);
|
|
831
|
+
if (!parsed)
|
|
832
|
+
return address;
|
|
833
|
+
const host = this.hostsEquivalent(parsed.host, 'localhost')
|
|
834
|
+
? 'localhost'
|
|
835
|
+
: parsed.host;
|
|
836
|
+
return `${host}:${parsed.port}`;
|
|
837
|
+
}
|
|
838
|
+
hostsEquivalent(a, b) {
|
|
839
|
+
if (a === b)
|
|
840
|
+
return true;
|
|
841
|
+
const loopback = new Set(['localhost', '127.0.0.1', '::1']);
|
|
842
|
+
return loopback.has(a) && loopback.has(b);
|
|
843
|
+
}
|
|
844
|
+
closeAllSockets() {
|
|
845
|
+
for (const timer of this.reconnectTimers.values())
|
|
846
|
+
clearTimeout(timer);
|
|
847
|
+
this.reconnectTimers.clear();
|
|
848
|
+
this.endpointSockets.clear();
|
|
849
|
+
for (const m of this.sockets.values()) {
|
|
850
|
+
m.ws.onclose = null;
|
|
851
|
+
m.ws.onerror = null;
|
|
852
|
+
m.ws.onopen = null;
|
|
853
|
+
m.ws.onmessage = null;
|
|
854
|
+
m.ws.close();
|
|
855
|
+
}
|
|
856
|
+
this.sockets.clear();
|
|
857
|
+
this.setCurrentServer(null, 'all sockets closed');
|
|
858
|
+
this.setConnectionStatus(ConnectionStatus.Disconnected, 'all sockets closed');
|
|
859
|
+
}
|
|
860
|
+
createSocket(address, reconnectOnClose = true) {
|
|
861
|
+
const endpointKey = this.endpointKey(address);
|
|
862
|
+
if (this.endpointSockets.has(endpointKey))
|
|
863
|
+
return;
|
|
864
|
+
this.endpointSockets.set(endpointKey, address);
|
|
865
|
+
const reconnectTimer = this.reconnectTimers.get(endpointKey);
|
|
866
|
+
if (reconnectTimer) {
|
|
867
|
+
clearTimeout(reconnectTimer);
|
|
868
|
+
this.reconnectTimers.delete(endpointKey);
|
|
869
|
+
}
|
|
870
|
+
this.logConnection('socket_connecting', {
|
|
871
|
+
address,
|
|
872
|
+
openServers: this.getOpenServers(),
|
|
873
|
+
knownServers: this.getServers(),
|
|
874
|
+
});
|
|
875
|
+
const ws = new WebSocket(address);
|
|
876
|
+
ws.binaryType = 'arraybuffer'; // Receive binary messages as ArrayBuffer for msgpack
|
|
877
|
+
const managed = {
|
|
878
|
+
ws,
|
|
879
|
+
address,
|
|
880
|
+
endpointKey,
|
|
881
|
+
reconnectOnClose,
|
|
882
|
+
};
|
|
883
|
+
this.sockets.set(address, managed);
|
|
884
|
+
if (this.sockets.size === 1) {
|
|
885
|
+
this.setConnectionStatus(ConnectionStatus.Connecting, `connecting to ${address}`);
|
|
886
|
+
}
|
|
887
|
+
ws.onopen = () => {
|
|
888
|
+
if (this.sockets.get(address) !== managed) {
|
|
889
|
+
ws.close();
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
if (!this.currentServer) {
|
|
893
|
+
this.setCurrentServer(address, 'socket open');
|
|
894
|
+
this.setConnectionStatus(ConnectionStatus.Connected, `connected to ${address}`);
|
|
895
|
+
this.flushQueue();
|
|
896
|
+
this.resendSubscriptions();
|
|
897
|
+
if (this.peerDiscoveryEnabled)
|
|
898
|
+
this.startPeerDiscovery();
|
|
899
|
+
}
|
|
900
|
+
};
|
|
901
|
+
ws.onclose = () => {
|
|
902
|
+
if (this.sockets.get(address) !== managed)
|
|
903
|
+
return;
|
|
904
|
+
this.sockets.delete(address);
|
|
905
|
+
this.endpointSockets.delete(endpointKey);
|
|
906
|
+
if (this.currentServer === address) {
|
|
907
|
+
const next = this.getFirstOpenSocket();
|
|
908
|
+
if (next) {
|
|
909
|
+
this.setCurrentServer(next.address, `failover from ${address} to ${next.address}`);
|
|
910
|
+
this.setConnectionStatus(ConnectionStatus.Connected, `main failover to ${next.address}`);
|
|
911
|
+
this.resendSubscriptions();
|
|
912
|
+
return; // Peer discovery will re-add this server when it's back
|
|
913
|
+
}
|
|
914
|
+
this.setCurrentServer(null, `disconnected from ${address}`);
|
|
915
|
+
this.setConnectionStatus(ConnectionStatus.Disconnected, `no open servers after ${address} closed`);
|
|
916
|
+
}
|
|
917
|
+
// Only retry explicitly configured sockets when completely disconnected.
|
|
918
|
+
// Discovered peers are expected to reappear via peer discovery.
|
|
919
|
+
if (managed.reconnectOnClose &&
|
|
920
|
+
this.shouldReconnect &&
|
|
921
|
+
!this.hasOpenConnection()) {
|
|
922
|
+
this.scheduleReconnect(address, endpointKey);
|
|
923
|
+
}
|
|
924
|
+
};
|
|
925
|
+
ws.onerror = () => { };
|
|
926
|
+
ws.onmessage = (event) => {
|
|
927
|
+
if (this.sockets.get(address) !== managed)
|
|
928
|
+
return;
|
|
929
|
+
this.onMessage(event.data);
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
scheduleReconnect(address, endpointKey) {
|
|
933
|
+
if (this.reconnectTimers.has(endpointKey))
|
|
934
|
+
return;
|
|
935
|
+
this.logConnection('reconnect_scheduled', { address, delayMs: 1000 });
|
|
936
|
+
const timer = setTimeout(() => {
|
|
937
|
+
this.reconnectTimers.delete(endpointKey);
|
|
938
|
+
if (this.shouldReconnect &&
|
|
939
|
+
!this.hasOpenConnection() &&
|
|
940
|
+
!this.endpointSockets.has(endpointKey)) {
|
|
941
|
+
this.logConnection('reconnect_attempt', { address });
|
|
942
|
+
this.createSocket(address);
|
|
943
|
+
}
|
|
944
|
+
}, 1000);
|
|
945
|
+
this.reconnectTimers.set(endpointKey, timer);
|
|
946
|
+
}
|
|
947
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
948
|
+
// Private: Message Handling
|
|
949
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
950
|
+
onMessage(data) {
|
|
951
|
+
this.downMsgCounter.next();
|
|
952
|
+
try {
|
|
953
|
+
let message;
|
|
954
|
+
if (typeof data === 'string') {
|
|
955
|
+
// JSON text message
|
|
956
|
+
message = JSON.parse(data);
|
|
957
|
+
}
|
|
958
|
+
else if (data instanceof ArrayBuffer) {
|
|
959
|
+
// Binary msgpack message
|
|
960
|
+
message = unpackr.unpack(new Uint8Array(data));
|
|
961
|
+
}
|
|
962
|
+
else if (data instanceof Blob) {
|
|
963
|
+
// Handle Blob asynchronously - convert to ArrayBuffer first
|
|
964
|
+
data.arrayBuffer().then((buffer) => {
|
|
965
|
+
const decoded = unpackr.unpack(new Uint8Array(buffer));
|
|
966
|
+
this.routeMessage(decoded);
|
|
967
|
+
});
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
else {
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
this.routeMessage(message);
|
|
974
|
+
}
|
|
975
|
+
catch {
|
|
976
|
+
// Ignore parse errors
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
routeMessage(message) {
|
|
980
|
+
switch (message.event) {
|
|
981
|
+
case MykoEvent.QueryResponse:
|
|
982
|
+
this.maybeLogFirstResponseTiming('query', message.data.tx, {
|
|
983
|
+
queryId: this.activeQueries.get(message.data.tx)?.queryId,
|
|
984
|
+
queryName: this.activeQueryNames.get(message.data.tx) ??
|
|
985
|
+
this.activeQueries.get(message.data.tx)?.queryId ??
|
|
986
|
+
'unknown',
|
|
987
|
+
sequence: message.data.sequence,
|
|
988
|
+
upserts: message.data.upserts.length,
|
|
989
|
+
deletes: message.data.deletes.length,
|
|
990
|
+
});
|
|
991
|
+
this.logConnection('query_response', {
|
|
992
|
+
tx: message.data.tx,
|
|
993
|
+
queryId: this.activeQueries.get(message.data.tx)?.queryId,
|
|
994
|
+
queryItemType: this.activeQueries.get(message.data.tx)?.queryItemType,
|
|
995
|
+
queryName: this.activeQueryNames.get(message.data.tx) ??
|
|
996
|
+
this.activeQueries.get(message.data.tx)?.queryId ??
|
|
997
|
+
'unknown',
|
|
998
|
+
sequence: message.data.sequence,
|
|
999
|
+
upserts: message.data.upserts.length,
|
|
1000
|
+
deletes: message.data.deletes.length,
|
|
1001
|
+
changes: message.data.changes?.length ?? 0,
|
|
1002
|
+
totalCount: message.data.totalCount ??
|
|
1003
|
+
message.data.total_count ??
|
|
1004
|
+
null,
|
|
1005
|
+
window: message.data.window ?? null,
|
|
1006
|
+
});
|
|
1007
|
+
const queryPublishStarted = this.nowMs();
|
|
1008
|
+
this.queryResponses.next(message);
|
|
1009
|
+
this.logConnection('query_publish_ms', {
|
|
1010
|
+
tx: message.data.tx,
|
|
1011
|
+
sequence: message.data.sequence,
|
|
1012
|
+
publishMs: Number((this.nowMs() - queryPublishStarted).toFixed(2)),
|
|
1013
|
+
});
|
|
1014
|
+
break;
|
|
1015
|
+
case MykoEvent.ViewResponse:
|
|
1016
|
+
// ViewResponse and QueryResponse share the same payload shape.
|
|
1017
|
+
const viewMessage = message;
|
|
1018
|
+
this.maybeLogFirstResponseTiming('view', viewMessage.data.tx, {
|
|
1019
|
+
viewId: this.activeViews.get(viewMessage.data.tx)?.viewId,
|
|
1020
|
+
viewName: this.activeViewNames.get(viewMessage.data.tx) ??
|
|
1021
|
+
this.activeViews.get(viewMessage.data.tx)?.viewId ??
|
|
1022
|
+
'unknown',
|
|
1023
|
+
sequence: viewMessage.data.sequence,
|
|
1024
|
+
upserts: viewMessage.data.upserts.length,
|
|
1025
|
+
deletes: viewMessage.data.deletes.length,
|
|
1026
|
+
});
|
|
1027
|
+
this.logConnection('view_response', {
|
|
1028
|
+
tx: viewMessage.data.tx,
|
|
1029
|
+
sequence: viewMessage.data.sequence,
|
|
1030
|
+
upserts: viewMessage.data.upserts.length,
|
|
1031
|
+
deletes: viewMessage.data.deletes.length,
|
|
1032
|
+
changes: viewMessage.data.changes?.length ?? 0,
|
|
1033
|
+
totalCount: viewMessage.data
|
|
1034
|
+
.totalCount ??
|
|
1035
|
+
viewMessage.data.total_count ??
|
|
1036
|
+
null,
|
|
1037
|
+
window: viewMessage.data.window ?? null,
|
|
1038
|
+
});
|
|
1039
|
+
const viewPublishStarted = this.nowMs();
|
|
1040
|
+
this.queryResponses.next(viewMessage);
|
|
1041
|
+
this.logConnection('view_publish_ms', {
|
|
1042
|
+
tx: viewMessage.data.tx,
|
|
1043
|
+
sequence: viewMessage.data.sequence,
|
|
1044
|
+
publishMs: Number((this.nowMs() - viewPublishStarted).toFixed(2)),
|
|
1045
|
+
});
|
|
1046
|
+
break;
|
|
1047
|
+
case MykoEvent.ReportResponse:
|
|
1048
|
+
this.reportResponses.next(message);
|
|
1049
|
+
break;
|
|
1050
|
+
case MykoEvent.CommandResponse:
|
|
1051
|
+
this.commandResponses.next(message);
|
|
1052
|
+
break;
|
|
1053
|
+
case MykoEvent.CommandError:
|
|
1054
|
+
this.commandErrors.next(message);
|
|
1055
|
+
break;
|
|
1056
|
+
case MykoEvent.QueryError:
|
|
1057
|
+
this.queryErrors.next(message);
|
|
1058
|
+
this.logConnection('query_error', {
|
|
1059
|
+
tx: message.data.tx,
|
|
1060
|
+
message: message.data.message,
|
|
1061
|
+
queryId: this.activeQueries.get(message.data.tx)?.queryId,
|
|
1062
|
+
queryItemType: this.activeQueries.get(message.data.tx)?.queryItemType,
|
|
1063
|
+
queryName: this.activeQueryNames.get(message.data.tx) ??
|
|
1064
|
+
this.activeQueries.get(message.data.tx)?.queryId ??
|
|
1065
|
+
'unknown',
|
|
1066
|
+
});
|
|
1067
|
+
break;
|
|
1068
|
+
case MykoEvent.ViewError:
|
|
1069
|
+
this.queryErrors.next(message);
|
|
1070
|
+
this.logConnection('view_error', {
|
|
1071
|
+
tx: message.data.tx,
|
|
1072
|
+
message: message.data.message,
|
|
1073
|
+
viewId: this.activeViews.get(message.data.tx)?.viewId,
|
|
1074
|
+
viewItemType: this.activeViews.get(message.data.tx)?.viewItemType,
|
|
1075
|
+
viewName: this.activeViewNames.get(message.data.tx) ??
|
|
1076
|
+
this.activeViews.get(message.data.tx)?.viewId ??
|
|
1077
|
+
'unknown',
|
|
1078
|
+
});
|
|
1079
|
+
break;
|
|
1080
|
+
case MykoEvent.ReportError:
|
|
1081
|
+
this.reportErrors.next(message);
|
|
1082
|
+
this.logConnection('report_error', {
|
|
1083
|
+
tx: message.data.tx,
|
|
1084
|
+
message: message.data.message,
|
|
1085
|
+
reportId: this.activeReports.get(message.data.tx)?.reportId,
|
|
1086
|
+
reportName: this.activeReportNames.get(message.data.tx) ??
|
|
1087
|
+
this.activeReports.get(message.data.tx)?.reportId ??
|
|
1088
|
+
'unknown',
|
|
1089
|
+
});
|
|
1090
|
+
break;
|
|
1091
|
+
case MykoEvent.Ping:
|
|
1092
|
+
this.pingResponses.next(message);
|
|
1093
|
+
break;
|
|
1094
|
+
case MykoEvent.Command:
|
|
1095
|
+
this.commandIncoming.next(message);
|
|
1096
|
+
break;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
scheduleEventBatchFlush() {
|
|
1100
|
+
if (this.eventBatchFlushScheduled)
|
|
1101
|
+
return;
|
|
1102
|
+
this.eventBatchFlushScheduled = true;
|
|
1103
|
+
queueMicrotask(() => {
|
|
1104
|
+
this.eventBatchFlushScheduled = false;
|
|
1105
|
+
this.flushPendingEventBatch();
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
flushPendingEventBatch() {
|
|
1109
|
+
if (this.pendingEventBatch.length === 0)
|
|
1110
|
+
return;
|
|
1111
|
+
while (this.pendingEventBatch.length > 0) {
|
|
1112
|
+
const batch = this.pendingEventBatch.splice(0, this.eventBatchMaxSize);
|
|
1113
|
+
this.sendNow({ event: EVENT_BATCH, data: batch });
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
send(message) {
|
|
1117
|
+
if (message.event !== EVENT_BATCH) {
|
|
1118
|
+
this.flushPendingEventBatch();
|
|
1119
|
+
}
|
|
1120
|
+
this.sendNow(message);
|
|
1121
|
+
}
|
|
1122
|
+
messageTx(message) {
|
|
1123
|
+
const data = message.data;
|
|
1124
|
+
return typeof data?.tx === 'string' ? data.tx : null;
|
|
1125
|
+
}
|
|
1126
|
+
sendNow(message) {
|
|
1127
|
+
const event = message.event;
|
|
1128
|
+
const tx = this.messageTx(message);
|
|
1129
|
+
if (this.currentServer) {
|
|
1130
|
+
const managed = this.sockets.get(this.currentServer);
|
|
1131
|
+
if (managed?.ws.readyState === WebSocket.OPEN) {
|
|
1132
|
+
const encoded = this.protocol === MykoProtocol.MSGPACK
|
|
1133
|
+
? new Uint8Array(packr.pack(message))
|
|
1134
|
+
: JSON.stringify(message);
|
|
1135
|
+
managed.ws.send(encoded);
|
|
1136
|
+
this.upMsgCounter.next();
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
this.messageQueue.push(message);
|
|
1141
|
+
this.logConnection('ws_enqueue', {
|
|
1142
|
+
event,
|
|
1143
|
+
tx,
|
|
1144
|
+
queueDepth: this.messageQueue.length,
|
|
1145
|
+
currentServer: this.currentServer,
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
flushQueue() {
|
|
1149
|
+
const queue = this.messageQueue;
|
|
1150
|
+
this.messageQueue = [];
|
|
1151
|
+
this.logConnection('ws_flush_queue', {
|
|
1152
|
+
queuedMessages: queue.length,
|
|
1153
|
+
currentServer: this.currentServer,
|
|
1154
|
+
});
|
|
1155
|
+
for (const msg of queue)
|
|
1156
|
+
this.send(msg);
|
|
1157
|
+
}
|
|
1158
|
+
withReconnectSequenceReset(query) {
|
|
1159
|
+
const queryPayload = query.query;
|
|
1160
|
+
if (queryPayload &&
|
|
1161
|
+
typeof queryPayload === 'object' &&
|
|
1162
|
+
!Array.isArray(queryPayload)) {
|
|
1163
|
+
return {
|
|
1164
|
+
...query,
|
|
1165
|
+
query: {
|
|
1166
|
+
...queryPayload,
|
|
1167
|
+
seq: 0,
|
|
1168
|
+
},
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
return query;
|
|
1172
|
+
}
|
|
1173
|
+
resendSubscriptions() {
|
|
1174
|
+
for (const q of this.activeQueries.values()) {
|
|
1175
|
+
this.send({
|
|
1176
|
+
event: MykoEvent.Query,
|
|
1177
|
+
data: this.withReconnectSequenceReset(q),
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
for (const v of this.activeViews.values()) {
|
|
1181
|
+
this.send({ event: MykoEvent.View, data: v });
|
|
1182
|
+
}
|
|
1183
|
+
for (const r of this.activeReports.values()) {
|
|
1184
|
+
this.send({ event: MykoEvent.Report, data: r });
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
setConnectionStatus(status, reason) {
|
|
1188
|
+
this.connectionStatusSubject.next(status);
|
|
1189
|
+
this.logConnection('status', {
|
|
1190
|
+
status,
|
|
1191
|
+
reason,
|
|
1192
|
+
currentServer: this.currentServer,
|
|
1193
|
+
openServers: this.getOpenServers(),
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
setCurrentServer(server, reason) {
|
|
1197
|
+
this.currentServer = server;
|
|
1198
|
+
this.currentServerSubject.next(server);
|
|
1199
|
+
this.logConnection('current_server', { server, reason });
|
|
1200
|
+
}
|
|
1201
|
+
nowMs() {
|
|
1202
|
+
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
|
|
1203
|
+
return performance.now();
|
|
1204
|
+
}
|
|
1205
|
+
return Date.now();
|
|
1206
|
+
}
|
|
1207
|
+
maybeLogFirstResponseTiming(kind, tx, details) {
|
|
1208
|
+
if (this.firstResponseLogged.has(tx))
|
|
1209
|
+
return;
|
|
1210
|
+
const startedAt = this.subscriptionStartMs.get(tx);
|
|
1211
|
+
if (startedAt === undefined)
|
|
1212
|
+
return;
|
|
1213
|
+
const elapsedMs = this.nowMs() - startedAt;
|
|
1214
|
+
this.firstResponseLogged.add(tx);
|
|
1215
|
+
this.logConnection(`${kind}_first_response_timing`, {
|
|
1216
|
+
tx,
|
|
1217
|
+
subscribeToFirstResponseMs: Number(elapsedMs.toFixed(2)),
|
|
1218
|
+
...details,
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
connectionLogLevel(event) {
|
|
1222
|
+
// Main lifecycle state transitions remain visible at info.
|
|
1223
|
+
const infoEvents = new Set(['status', 'current_server']);
|
|
1224
|
+
if (infoEvents.has(event))
|
|
1225
|
+
return 'info';
|
|
1226
|
+
// Subscription setup and first-response timing are debug-level diagnostics.
|
|
1227
|
+
const debugEvents = new Set([
|
|
1228
|
+
'socket_connecting',
|
|
1229
|
+
'peer_discovery_started',
|
|
1230
|
+
'peer_discovery_update',
|
|
1231
|
+
'query_subscribe',
|
|
1232
|
+
'view_subscribe',
|
|
1233
|
+
'report_subscribe',
|
|
1234
|
+
'query_cancel',
|
|
1235
|
+
'view_cancel',
|
|
1236
|
+
'report_cancel',
|
|
1237
|
+
'query_first_response_timing',
|
|
1238
|
+
'view_first_response_timing',
|
|
1239
|
+
'reconnect_scheduled',
|
|
1240
|
+
'reconnect_attempt',
|
|
1241
|
+
'query_shape_invalid',
|
|
1242
|
+
]);
|
|
1243
|
+
if (debugEvents.has(event))
|
|
1244
|
+
return 'debug';
|
|
1245
|
+
// Follow-up response churn and transport internals are verbose-level.
|
|
1246
|
+
const verboseEvents = new Set([
|
|
1247
|
+
'ws_enqueue',
|
|
1248
|
+
'ws_flush_queue',
|
|
1249
|
+
'query_response',
|
|
1250
|
+
'view_response',
|
|
1251
|
+
'report_response',
|
|
1252
|
+
'query_publish_ms',
|
|
1253
|
+
'view_publish_ms',
|
|
1254
|
+
]);
|
|
1255
|
+
if (verboseEvents.has(event))
|
|
1256
|
+
return 'verbose';
|
|
1257
|
+
if (event.endsWith('_error'))
|
|
1258
|
+
return 'error';
|
|
1259
|
+
return 'debug';
|
|
1260
|
+
}
|
|
1261
|
+
static resolveDefaultConnectionLogLevel() {
|
|
1262
|
+
const readGlobal = () => {
|
|
1263
|
+
const globalObj = globalThis;
|
|
1264
|
+
const value = globalObj.MYKO_CLIENT_LOG_LEVEL;
|
|
1265
|
+
return typeof value === 'string' ? value : undefined;
|
|
1266
|
+
};
|
|
1267
|
+
const readProcessEnv = () => {
|
|
1268
|
+
const processLike = globalThis.process;
|
|
1269
|
+
return processLike?.env?.MYKO_CLIENT_LOG_LEVEL;
|
|
1270
|
+
};
|
|
1271
|
+
const value = (readGlobal() ?? readProcessEnv() ?? '').toLowerCase();
|
|
1272
|
+
switch (value) {
|
|
1273
|
+
case 'silent':
|
|
1274
|
+
case 'error':
|
|
1275
|
+
case 'warn':
|
|
1276
|
+
case 'info':
|
|
1277
|
+
case 'debug':
|
|
1278
|
+
case 'verbose':
|
|
1279
|
+
return value;
|
|
1280
|
+
default:
|
|
1281
|
+
return 'warn';
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
shouldLogConnection(level) {
|
|
1285
|
+
const priority = {
|
|
1286
|
+
silent: 0,
|
|
1287
|
+
error: 1,
|
|
1288
|
+
warn: 2,
|
|
1289
|
+
info: 3,
|
|
1290
|
+
debug: 4,
|
|
1291
|
+
verbose: 5,
|
|
1292
|
+
};
|
|
1293
|
+
return priority[level] <= priority[this.connectionLogLevelThreshold];
|
|
1294
|
+
}
|
|
1295
|
+
logConnection(event, details) {
|
|
1296
|
+
const level = this.connectionLogLevel(event);
|
|
1297
|
+
if (!this.shouldLogConnection(level))
|
|
1298
|
+
return;
|
|
1299
|
+
const maybeVerbose = console.verbose;
|
|
1300
|
+
const logger = level === 'info'
|
|
1301
|
+
? console.info
|
|
1302
|
+
: level === 'warn'
|
|
1303
|
+
? console.warn
|
|
1304
|
+
: level === 'error'
|
|
1305
|
+
? console.error
|
|
1306
|
+
: level === 'verbose' || level === 'debug'
|
|
1307
|
+
? maybeVerbose ?? console.debug ?? console.log
|
|
1308
|
+
: console.log;
|
|
1309
|
+
const levelPrefix = level === 'verbose' ? '[verbose] ' : '';
|
|
1310
|
+
logger(`${levelPrefix}[MykoClient] ${event}`, {
|
|
1311
|
+
tsIso: new Date().toISOString(),
|
|
1312
|
+
tsMs: Date.now(),
|
|
1313
|
+
...details,
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
}
|