@syncular/server-hono 0.0.1-60
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/api-key-auth.d.ts +49 -0
- package/dist/api-key-auth.d.ts.map +1 -0
- package/dist/api-key-auth.js +110 -0
- package/dist/api-key-auth.js.map +1 -0
- package/dist/blobs.d.ts +69 -0
- package/dist/blobs.d.ts.map +1 -0
- package/dist/blobs.js +383 -0
- package/dist/blobs.js.map +1 -0
- package/dist/console/index.d.ts +8 -0
- package/dist/console/index.d.ts.map +1 -0
- package/dist/console/index.js +7 -0
- package/dist/console/index.js.map +1 -0
- package/dist/console/routes.d.ts +106 -0
- package/dist/console/routes.d.ts.map +1 -0
- package/dist/console/routes.js +1612 -0
- package/dist/console/routes.js.map +1 -0
- package/dist/console/schemas.d.ts +308 -0
- package/dist/console/schemas.d.ts.map +1 -0
- package/dist/console/schemas.js +201 -0
- package/dist/console/schemas.js.map +1 -0
- package/dist/create-server.d.ts +78 -0
- package/dist/create-server.d.ts.map +1 -0
- package/dist/create-server.js +99 -0
- package/dist/create-server.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/openapi.d.ts +45 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +59 -0
- package/dist/openapi.js.map +1 -0
- package/dist/proxy/connection-manager.d.ts +78 -0
- package/dist/proxy/connection-manager.d.ts.map +1 -0
- package/dist/proxy/connection-manager.js +251 -0
- package/dist/proxy/connection-manager.js.map +1 -0
- package/dist/proxy/index.d.ts +8 -0
- package/dist/proxy/index.d.ts.map +1 -0
- package/dist/proxy/index.js +8 -0
- package/dist/proxy/index.js.map +1 -0
- package/dist/proxy/routes.d.ts +74 -0
- package/dist/proxy/routes.d.ts.map +1 -0
- package/dist/proxy/routes.js +147 -0
- package/dist/proxy/routes.js.map +1 -0
- package/dist/rate-limit.d.ts +101 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +186 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/routes.d.ts +126 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +788 -0
- package/dist/routes.js.map +1 -0
- package/dist/ws.d.ts +230 -0
- package/dist/ws.d.ts.map +1 -0
- package/dist/ws.js +601 -0
- package/dist/ws.js.map +1 -0
- package/package.json +73 -0
- package/src/__tests__/create-server.test.ts +187 -0
- package/src/__tests__/pull-chunk-storage.test.ts +189 -0
- package/src/__tests__/rate-limit.test.ts +78 -0
- package/src/__tests__/realtime-bridge.test.ts +131 -0
- package/src/__tests__/ws-connection-manager.test.ts +176 -0
- package/src/api-key-auth.ts +179 -0
- package/src/blobs.ts +534 -0
- package/src/console/index.ts +17 -0
- package/src/console/routes.ts +2155 -0
- package/src/console/schemas.ts +299 -0
- package/src/create-server.ts +180 -0
- package/src/index.ts +42 -0
- package/src/openapi.ts +74 -0
- package/src/proxy/connection-manager.ts +340 -0
- package/src/proxy/index.ts +8 -0
- package/src/proxy/routes.ts +223 -0
- package/src/rate-limit.ts +321 -0
- package/src/routes.ts +1186 -0
- package/src/ws.ts +789 -0
package/src/ws.ts
ADDED
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @syncular/server-hono - WebSocket helpers for realtime sync wake-ups
|
|
3
|
+
*
|
|
4
|
+
* WebSockets are used only as a "wake up" mechanism; clients must still pull.
|
|
5
|
+
* Also supports presence tracking for collaborative features.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { WSContext } from 'hono/ws';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Presence entry for a client connected to a scope
|
|
12
|
+
*/
|
|
13
|
+
export interface PresenceEntry {
|
|
14
|
+
clientId: string;
|
|
15
|
+
actorId: string;
|
|
16
|
+
joinedAt: number;
|
|
17
|
+
metadata?: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Push response data sent back to the client over WS
|
|
22
|
+
*/
|
|
23
|
+
export interface WsPushResponseData {
|
|
24
|
+
requestId: string;
|
|
25
|
+
ok: boolean;
|
|
26
|
+
status: string;
|
|
27
|
+
commitSeq?: number;
|
|
28
|
+
results: Array<{ opIndex: number; status: string; [k: string]: unknown }>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* WebSocket event data for sync notifications
|
|
33
|
+
*/
|
|
34
|
+
export interface SyncWebSocketEvent {
|
|
35
|
+
/** Event type */
|
|
36
|
+
event: 'sync' | 'heartbeat' | 'error' | 'presence' | 'push-response';
|
|
37
|
+
/** Data payload */
|
|
38
|
+
data: {
|
|
39
|
+
/** New cursor position (for sync events) */
|
|
40
|
+
cursor?: number;
|
|
41
|
+
/** Error message (for error events) */
|
|
42
|
+
error?: string;
|
|
43
|
+
/** Presence data (for presence events) */
|
|
44
|
+
presence?: {
|
|
45
|
+
action: 'join' | 'leave' | 'update' | 'snapshot';
|
|
46
|
+
scopeKey: string;
|
|
47
|
+
clientId?: string;
|
|
48
|
+
actorId?: string;
|
|
49
|
+
metadata?: Record<string, unknown>;
|
|
50
|
+
entries?: PresenceEntry[];
|
|
51
|
+
};
|
|
52
|
+
/** Push response data (for push-response events) */
|
|
53
|
+
requestId?: string;
|
|
54
|
+
ok?: boolean;
|
|
55
|
+
status?: string;
|
|
56
|
+
commitSeq?: number;
|
|
57
|
+
results?: Array<{ opIndex: number; status: string; [k: string]: unknown }>;
|
|
58
|
+
/** Timestamp */
|
|
59
|
+
timestamp: number;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* WebSocket connection controller for managing active connections
|
|
65
|
+
*/
|
|
66
|
+
export interface WebSocketConnection {
|
|
67
|
+
/** Send a sync notification, optionally with inline change data */
|
|
68
|
+
sendSync(cursor: number, changes?: unknown[]): void;
|
|
69
|
+
/** Send a heartbeat */
|
|
70
|
+
sendHeartbeat(): void;
|
|
71
|
+
/** Send a presence event */
|
|
72
|
+
sendPresence(data: {
|
|
73
|
+
action: 'join' | 'leave' | 'update' | 'snapshot';
|
|
74
|
+
scopeKey: string;
|
|
75
|
+
clientId?: string;
|
|
76
|
+
actorId?: string;
|
|
77
|
+
metadata?: Record<string, unknown>;
|
|
78
|
+
entries?: PresenceEntry[];
|
|
79
|
+
}): void;
|
|
80
|
+
/** Send a push response back to the client */
|
|
81
|
+
sendPushResponse(data: WsPushResponseData): void;
|
|
82
|
+
/** Send an error and close */
|
|
83
|
+
sendError(message: string): void;
|
|
84
|
+
/** Close the connection */
|
|
85
|
+
close(code?: number, reason?: string): void;
|
|
86
|
+
/** Whether the connection is still open */
|
|
87
|
+
isOpen: boolean;
|
|
88
|
+
/** Actor ID for this connection */
|
|
89
|
+
actorId: string;
|
|
90
|
+
/** Client/device identifier for this connection */
|
|
91
|
+
clientId: string;
|
|
92
|
+
/** Transport path used by this connection. */
|
|
93
|
+
transportPath: 'direct' | 'relay';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function safeSend(ws: WSContext, message: string): boolean {
|
|
97
|
+
try {
|
|
98
|
+
ws.send(message);
|
|
99
|
+
return true;
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function createWebSocketConnection(
|
|
106
|
+
ws: WSContext,
|
|
107
|
+
args: { actorId: string; clientId: string; transportPath: 'direct' | 'relay' }
|
|
108
|
+
): WebSocketConnection {
|
|
109
|
+
let closed = false;
|
|
110
|
+
|
|
111
|
+
const connection: WebSocketConnection = {
|
|
112
|
+
get isOpen() {
|
|
113
|
+
if (closed) return false;
|
|
114
|
+
return ws.readyState === 1;
|
|
115
|
+
},
|
|
116
|
+
actorId: args.actorId,
|
|
117
|
+
clientId: args.clientId,
|
|
118
|
+
transportPath: args.transportPath,
|
|
119
|
+
sendSync(cursor: number, changes?: unknown[]) {
|
|
120
|
+
if (!connection.isOpen) return;
|
|
121
|
+
const payload: Record<string, unknown> = {
|
|
122
|
+
cursor,
|
|
123
|
+
timestamp: Date.now(),
|
|
124
|
+
};
|
|
125
|
+
if (changes && changes.length > 0) {
|
|
126
|
+
payload.changes = changes;
|
|
127
|
+
}
|
|
128
|
+
const ok = safeSend(ws, JSON.stringify({ event: 'sync', data: payload }));
|
|
129
|
+
if (!ok) closed = true;
|
|
130
|
+
},
|
|
131
|
+
sendHeartbeat() {
|
|
132
|
+
if (!connection.isOpen) return;
|
|
133
|
+
const ok = safeSend(
|
|
134
|
+
ws,
|
|
135
|
+
JSON.stringify({ event: 'heartbeat', data: { timestamp: Date.now() } })
|
|
136
|
+
);
|
|
137
|
+
if (!ok) closed = true;
|
|
138
|
+
},
|
|
139
|
+
sendPresence(data: {
|
|
140
|
+
action: 'join' | 'leave' | 'update' | 'snapshot';
|
|
141
|
+
scopeKey: string;
|
|
142
|
+
clientId?: string;
|
|
143
|
+
actorId?: string;
|
|
144
|
+
metadata?: Record<string, unknown>;
|
|
145
|
+
entries?: PresenceEntry[];
|
|
146
|
+
}) {
|
|
147
|
+
if (!connection.isOpen) return;
|
|
148
|
+
const ok = safeSend(
|
|
149
|
+
ws,
|
|
150
|
+
JSON.stringify({
|
|
151
|
+
event: 'presence',
|
|
152
|
+
data: { presence: data, timestamp: Date.now() },
|
|
153
|
+
})
|
|
154
|
+
);
|
|
155
|
+
if (!ok) closed = true;
|
|
156
|
+
},
|
|
157
|
+
sendPushResponse(data: WsPushResponseData) {
|
|
158
|
+
if (!connection.isOpen) return;
|
|
159
|
+
const ok = safeSend(
|
|
160
|
+
ws,
|
|
161
|
+
JSON.stringify({
|
|
162
|
+
event: 'push-response',
|
|
163
|
+
data: { ...data, timestamp: Date.now() },
|
|
164
|
+
})
|
|
165
|
+
);
|
|
166
|
+
if (!ok) closed = true;
|
|
167
|
+
},
|
|
168
|
+
sendError(message: string) {
|
|
169
|
+
if (connection.isOpen) {
|
|
170
|
+
safeSend(
|
|
171
|
+
ws,
|
|
172
|
+
JSON.stringify({
|
|
173
|
+
event: 'error',
|
|
174
|
+
data: { error: message, timestamp: Date.now() },
|
|
175
|
+
})
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
connection.close(1011, 'server error');
|
|
179
|
+
},
|
|
180
|
+
close(code?: number, reason?: string) {
|
|
181
|
+
if (closed) return;
|
|
182
|
+
closed = true;
|
|
183
|
+
try {
|
|
184
|
+
ws.close(code, reason);
|
|
185
|
+
} catch {
|
|
186
|
+
// ignore
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
return connection;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Connection manager for tracking active WebSocket connections.
|
|
196
|
+
* Scope-key based notifications and presence tracking.
|
|
197
|
+
*/
|
|
198
|
+
export class WebSocketConnectionManager {
|
|
199
|
+
private connectionsByClientId = new Map<string, Set<WebSocketConnection>>();
|
|
200
|
+
private scopeKeysByClientId = new Map<string, Set<string>>();
|
|
201
|
+
private connectionsByScopeKey = new Map<string, Set<WebSocketConnection>>();
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* In-memory presence tracking by scope key.
|
|
205
|
+
* Map<scopeKey, Map<clientId, PresenceEntry>>
|
|
206
|
+
*/
|
|
207
|
+
private presenceByScopeKey = new Map<string, Map<string, PresenceEntry>>();
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Callback for presence changes - allows integration with SyncRealtimeBroadcaster
|
|
211
|
+
*/
|
|
212
|
+
onPresenceChange?: (event: {
|
|
213
|
+
action: 'join' | 'leave' | 'update';
|
|
214
|
+
scopeKey: string;
|
|
215
|
+
clientId: string;
|
|
216
|
+
actorId: string;
|
|
217
|
+
metadata?: Record<string, unknown>;
|
|
218
|
+
}) => void;
|
|
219
|
+
|
|
220
|
+
private heartbeatIntervalMs: number;
|
|
221
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
222
|
+
|
|
223
|
+
constructor(options?: {
|
|
224
|
+
heartbeatIntervalMs?: number;
|
|
225
|
+
onPresenceChange?: WebSocketConnectionManager['onPresenceChange'];
|
|
226
|
+
}) {
|
|
227
|
+
this.heartbeatIntervalMs = options?.heartbeatIntervalMs ?? 30_000;
|
|
228
|
+
this.onPresenceChange = options?.onPresenceChange;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Register a connection for a client.
|
|
233
|
+
* Returns a cleanup function to unregister.
|
|
234
|
+
*/
|
|
235
|
+
register(
|
|
236
|
+
connection: WebSocketConnection,
|
|
237
|
+
initialScopeKeys: string[] = []
|
|
238
|
+
): () => void {
|
|
239
|
+
const clientId = connection.clientId;
|
|
240
|
+
let clientConns = this.connectionsByClientId.get(clientId);
|
|
241
|
+
if (!clientConns) {
|
|
242
|
+
clientConns = new Set();
|
|
243
|
+
this.connectionsByClientId.set(clientId, clientConns);
|
|
244
|
+
}
|
|
245
|
+
clientConns.add(connection);
|
|
246
|
+
|
|
247
|
+
if (!this.scopeKeysByClientId.has(clientId)) {
|
|
248
|
+
this.scopeKeysByClientId.set(clientId, new Set(initialScopeKeys));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const scopeKeys =
|
|
252
|
+
this.scopeKeysByClientId.get(clientId) ?? new Set<string>();
|
|
253
|
+
for (const k of scopeKeys) {
|
|
254
|
+
let scopeConns = this.connectionsByScopeKey.get(k);
|
|
255
|
+
if (!scopeConns) {
|
|
256
|
+
scopeConns = new Set();
|
|
257
|
+
this.connectionsByScopeKey.set(k, scopeConns);
|
|
258
|
+
}
|
|
259
|
+
scopeConns.add(connection);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
this.ensureHeartbeat();
|
|
263
|
+
|
|
264
|
+
return () => {
|
|
265
|
+
this.unregister(connection);
|
|
266
|
+
this.ensureHeartbeat();
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Update the effective scopes for an already-connected client.
|
|
272
|
+
* If the client has no active connections, this is a no-op.
|
|
273
|
+
*/
|
|
274
|
+
updateClientScopeKeys(clientId: string, scopeKeys: string[]): void {
|
|
275
|
+
const conns = this.connectionsByClientId.get(clientId);
|
|
276
|
+
if (!conns || conns.size === 0) return;
|
|
277
|
+
|
|
278
|
+
const next = new Set(scopeKeys);
|
|
279
|
+
const prev = this.scopeKeysByClientId.get(clientId) ?? new Set<string>();
|
|
280
|
+
|
|
281
|
+
// No-op when unchanged (reduces write load when clients pull frequently).
|
|
282
|
+
if (prev.size === next.size) {
|
|
283
|
+
let unchanged = true;
|
|
284
|
+
for (const k of prev) {
|
|
285
|
+
if (!next.has(k)) {
|
|
286
|
+
unchanged = false;
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (unchanged) return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
this.scopeKeysByClientId.set(clientId, next);
|
|
294
|
+
|
|
295
|
+
for (const k of prev) {
|
|
296
|
+
if (next.has(k)) continue;
|
|
297
|
+
const set = this.connectionsByScopeKey.get(k);
|
|
298
|
+
if (!set) continue;
|
|
299
|
+
for (const conn of conns) set.delete(conn);
|
|
300
|
+
if (set.size === 0) this.connectionsByScopeKey.delete(k);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
for (const k of next) {
|
|
304
|
+
if (prev.has(k)) continue;
|
|
305
|
+
let set = this.connectionsByScopeKey.get(k);
|
|
306
|
+
if (!set) {
|
|
307
|
+
set = new Set();
|
|
308
|
+
this.connectionsByScopeKey.set(k, set);
|
|
309
|
+
}
|
|
310
|
+
for (const conn of conns) set.add(conn);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Check whether a client is currently authorized/subscribed for a scope key.
|
|
316
|
+
*/
|
|
317
|
+
isClientSubscribedToScopeKey(clientId: string, scopeKey: string): boolean {
|
|
318
|
+
const scopeKeys = this.scopeKeysByClientId.get(clientId);
|
|
319
|
+
if (!scopeKeys || scopeKeys.size === 0) return false;
|
|
320
|
+
return scopeKeys.has(scopeKey);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// =========================================================================
|
|
324
|
+
// Presence Tracking
|
|
325
|
+
// =========================================================================
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Join presence for a scope key.
|
|
329
|
+
* Called when a client wants to be visible to others in a scope.
|
|
330
|
+
*/
|
|
331
|
+
joinPresence(
|
|
332
|
+
clientId: string,
|
|
333
|
+
scopeKey: string,
|
|
334
|
+
metadata?: Record<string, unknown>
|
|
335
|
+
): boolean {
|
|
336
|
+
const conns = this.connectionsByClientId.get(clientId);
|
|
337
|
+
if (!conns || conns.size === 0) return false;
|
|
338
|
+
if (!this.isClientSubscribedToScopeKey(clientId, scopeKey)) return false;
|
|
339
|
+
|
|
340
|
+
// Get actorId from first connection
|
|
341
|
+
const conn = conns.values().next().value;
|
|
342
|
+
if (!conn) return false;
|
|
343
|
+
const actorId = conn.actorId;
|
|
344
|
+
|
|
345
|
+
// Add to presence map
|
|
346
|
+
let scopePresence = this.presenceByScopeKey.get(scopeKey);
|
|
347
|
+
if (!scopePresence) {
|
|
348
|
+
scopePresence = new Map();
|
|
349
|
+
this.presenceByScopeKey.set(scopeKey, scopePresence);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const entry: PresenceEntry = {
|
|
353
|
+
clientId,
|
|
354
|
+
actorId,
|
|
355
|
+
joinedAt: Date.now(),
|
|
356
|
+
metadata,
|
|
357
|
+
};
|
|
358
|
+
scopePresence.set(clientId, entry);
|
|
359
|
+
|
|
360
|
+
// Notify other clients in this scope
|
|
361
|
+
this.broadcastPresenceEvent(scopeKey, {
|
|
362
|
+
action: 'join',
|
|
363
|
+
scopeKey,
|
|
364
|
+
clientId,
|
|
365
|
+
actorId,
|
|
366
|
+
metadata,
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// Callback for cross-instance broadcasting
|
|
370
|
+
this.onPresenceChange?.({
|
|
371
|
+
action: 'join',
|
|
372
|
+
scopeKey,
|
|
373
|
+
clientId,
|
|
374
|
+
actorId,
|
|
375
|
+
metadata,
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Leave presence for a scope key.
|
|
383
|
+
* Called when a client no longer wants to be visible in a scope.
|
|
384
|
+
*/
|
|
385
|
+
leavePresence(clientId: string, scopeKey: string): boolean {
|
|
386
|
+
const scopePresence = this.presenceByScopeKey.get(scopeKey);
|
|
387
|
+
if (!scopePresence) return false;
|
|
388
|
+
|
|
389
|
+
const entry = scopePresence.get(clientId);
|
|
390
|
+
if (!entry) return false;
|
|
391
|
+
|
|
392
|
+
scopePresence.delete(clientId);
|
|
393
|
+
if (scopePresence.size === 0) {
|
|
394
|
+
this.presenceByScopeKey.delete(scopeKey);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Notify other clients in this scope
|
|
398
|
+
this.broadcastPresenceEvent(scopeKey, {
|
|
399
|
+
action: 'leave',
|
|
400
|
+
scopeKey,
|
|
401
|
+
clientId,
|
|
402
|
+
actorId: entry.actorId,
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// Callback for cross-instance broadcasting
|
|
406
|
+
this.onPresenceChange?.({
|
|
407
|
+
action: 'leave',
|
|
408
|
+
scopeKey,
|
|
409
|
+
clientId,
|
|
410
|
+
actorId: entry.actorId,
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
return true;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Update presence metadata for a client in a scope.
|
|
418
|
+
* Used to update what entity a user is viewing/editing.
|
|
419
|
+
*/
|
|
420
|
+
updatePresenceMetadata(
|
|
421
|
+
clientId: string,
|
|
422
|
+
scopeKey: string,
|
|
423
|
+
metadata: Record<string, unknown>
|
|
424
|
+
): boolean {
|
|
425
|
+
if (!this.isClientSubscribedToScopeKey(clientId, scopeKey)) return false;
|
|
426
|
+
const scopePresence = this.presenceByScopeKey.get(scopeKey);
|
|
427
|
+
if (!scopePresence) return false;
|
|
428
|
+
|
|
429
|
+
const entry = scopePresence.get(clientId);
|
|
430
|
+
if (!entry) return false;
|
|
431
|
+
|
|
432
|
+
entry.metadata = metadata;
|
|
433
|
+
|
|
434
|
+
// Notify other clients in this scope
|
|
435
|
+
this.broadcastPresenceEvent(scopeKey, {
|
|
436
|
+
action: 'update',
|
|
437
|
+
scopeKey,
|
|
438
|
+
clientId,
|
|
439
|
+
actorId: entry.actorId,
|
|
440
|
+
metadata,
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// Callback for cross-instance broadcasting
|
|
444
|
+
this.onPresenceChange?.({
|
|
445
|
+
action: 'update',
|
|
446
|
+
scopeKey,
|
|
447
|
+
clientId,
|
|
448
|
+
actorId: entry.actorId,
|
|
449
|
+
metadata,
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Get presence entries for a scope key.
|
|
457
|
+
*/
|
|
458
|
+
getPresence(scopeKey: string): PresenceEntry[] {
|
|
459
|
+
const scopePresence = this.presenceByScopeKey.get(scopeKey);
|
|
460
|
+
if (!scopePresence) return [];
|
|
461
|
+
return Array.from(scopePresence.values());
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Get presence for multiple scopes.
|
|
466
|
+
*/
|
|
467
|
+
getPresenceMultiple(scopeKeys: string[]): Record<string, PresenceEntry[]> {
|
|
468
|
+
const result: Record<string, PresenceEntry[]> = {};
|
|
469
|
+
for (const scopeKey of scopeKeys) {
|
|
470
|
+
result[scopeKey] = this.getPresence(scopeKey);
|
|
471
|
+
}
|
|
472
|
+
return result;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Send current presence snapshot to a specific connection.
|
|
477
|
+
* Called when a client first subscribes to presence for a scope.
|
|
478
|
+
*/
|
|
479
|
+
sendPresenceSnapshot(
|
|
480
|
+
connection: WebSocketConnection,
|
|
481
|
+
scopeKey: string
|
|
482
|
+
): void {
|
|
483
|
+
const entries = this.getPresence(scopeKey);
|
|
484
|
+
connection.sendPresence({
|
|
485
|
+
action: 'snapshot',
|
|
486
|
+
scopeKey,
|
|
487
|
+
entries,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Handle a presence event from another server instance (via broadcaster).
|
|
493
|
+
* Updates local state and notifies local clients.
|
|
494
|
+
*/
|
|
495
|
+
handleRemotePresenceEvent(event: {
|
|
496
|
+
action: 'join' | 'leave' | 'update';
|
|
497
|
+
scopeKey: string;
|
|
498
|
+
clientId: string;
|
|
499
|
+
actorId: string;
|
|
500
|
+
metadata?: Record<string, unknown>;
|
|
501
|
+
}): void {
|
|
502
|
+
const { action, scopeKey, clientId, actorId, metadata } = event;
|
|
503
|
+
|
|
504
|
+
// Update local presence state
|
|
505
|
+
let scopePresence = this.presenceByScopeKey.get(scopeKey);
|
|
506
|
+
|
|
507
|
+
switch (action) {
|
|
508
|
+
case 'join': {
|
|
509
|
+
if (!scopePresence) {
|
|
510
|
+
scopePresence = new Map();
|
|
511
|
+
this.presenceByScopeKey.set(scopeKey, scopePresence);
|
|
512
|
+
}
|
|
513
|
+
scopePresence.set(clientId, {
|
|
514
|
+
clientId,
|
|
515
|
+
actorId,
|
|
516
|
+
joinedAt: Date.now(),
|
|
517
|
+
metadata,
|
|
518
|
+
});
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
case 'leave': {
|
|
522
|
+
if (scopePresence) {
|
|
523
|
+
scopePresence.delete(clientId);
|
|
524
|
+
if (scopePresence.size === 0) {
|
|
525
|
+
this.presenceByScopeKey.delete(scopeKey);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
break;
|
|
529
|
+
}
|
|
530
|
+
case 'update': {
|
|
531
|
+
if (scopePresence) {
|
|
532
|
+
const entry = scopePresence.get(clientId);
|
|
533
|
+
if (entry) {
|
|
534
|
+
entry.metadata = metadata;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Notify local clients
|
|
542
|
+
this.broadcastPresenceEvent(scopeKey, event);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Broadcast a presence event to all clients subscribed to a scope key.
|
|
547
|
+
*/
|
|
548
|
+
private broadcastPresenceEvent(
|
|
549
|
+
scopeKey: string,
|
|
550
|
+
event: {
|
|
551
|
+
action: 'join' | 'leave' | 'update';
|
|
552
|
+
scopeKey: string;
|
|
553
|
+
clientId?: string;
|
|
554
|
+
actorId?: string;
|
|
555
|
+
metadata?: Record<string, unknown>;
|
|
556
|
+
}
|
|
557
|
+
): void {
|
|
558
|
+
const conns = this.connectionsByScopeKey.get(scopeKey);
|
|
559
|
+
if (!conns) return;
|
|
560
|
+
|
|
561
|
+
for (const conn of conns) {
|
|
562
|
+
if (!conn.isOpen) continue;
|
|
563
|
+
// Don't send presence events back to the source client
|
|
564
|
+
if (event.clientId && conn.clientId === event.clientId) continue;
|
|
565
|
+
conn.sendPresence(event);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Clean up presence when a client fully disconnects (all connections closed).
|
|
571
|
+
*/
|
|
572
|
+
private cleanupClientPresence(clientId: string): void {
|
|
573
|
+
// Find all scopes this client has presence in
|
|
574
|
+
for (const [scopeKey, scopePresence] of this.presenceByScopeKey) {
|
|
575
|
+
const entry = scopePresence.get(clientId);
|
|
576
|
+
if (!entry) continue;
|
|
577
|
+
|
|
578
|
+
scopePresence.delete(clientId);
|
|
579
|
+
if (scopePresence.size === 0) {
|
|
580
|
+
this.presenceByScopeKey.delete(scopeKey);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Notify other clients
|
|
584
|
+
this.broadcastPresenceEvent(scopeKey, {
|
|
585
|
+
action: 'leave',
|
|
586
|
+
scopeKey,
|
|
587
|
+
clientId,
|
|
588
|
+
actorId: entry.actorId,
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
// Callback for cross-instance broadcasting
|
|
592
|
+
this.onPresenceChange?.({
|
|
593
|
+
action: 'leave',
|
|
594
|
+
scopeKey,
|
|
595
|
+
clientId,
|
|
596
|
+
actorId: entry.actorId,
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// =========================================================================
|
|
602
|
+
// Sync Notifications
|
|
603
|
+
// =========================================================================
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Notify clients that new data is available for the given scopes.
|
|
607
|
+
* Dedupes connections that match multiple scopes.
|
|
608
|
+
*/
|
|
609
|
+
/**
|
|
610
|
+
* Maximum serialized size (bytes) for inline WS change delivery.
|
|
611
|
+
* Larger payloads fall back to cursor-only notification.
|
|
612
|
+
*/
|
|
613
|
+
private static readonly WS_INLINE_MAX_BYTES = 64 * 1024;
|
|
614
|
+
|
|
615
|
+
notifyScopeKeys(
|
|
616
|
+
scopeKeys: string[],
|
|
617
|
+
cursor: number,
|
|
618
|
+
opts?: { excludeClientIds?: string[]; changes?: unknown[] }
|
|
619
|
+
): void {
|
|
620
|
+
const exclude = new Set(opts?.excludeClientIds ?? []);
|
|
621
|
+
const targets = new Set<WebSocketConnection>();
|
|
622
|
+
|
|
623
|
+
for (const k of scopeKeys) {
|
|
624
|
+
const conns = this.connectionsByScopeKey.get(k);
|
|
625
|
+
if (!conns) continue;
|
|
626
|
+
for (const conn of conns) targets.add(conn);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Size guard: only deliver inline changes if under threshold
|
|
630
|
+
let inlineChanges: unknown[] | undefined;
|
|
631
|
+
if (opts?.changes && opts.changes.length > 0) {
|
|
632
|
+
const serialized = JSON.stringify(opts.changes);
|
|
633
|
+
if (serialized.length <= WebSocketConnectionManager.WS_INLINE_MAX_BYTES) {
|
|
634
|
+
inlineChanges = opts.changes;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
for (const conn of targets) {
|
|
639
|
+
if (!conn.isOpen) continue;
|
|
640
|
+
if (exclude.has(conn.clientId)) continue;
|
|
641
|
+
if (inlineChanges) {
|
|
642
|
+
conn.sendSync(cursor, inlineChanges);
|
|
643
|
+
} else {
|
|
644
|
+
conn.sendSync(cursor);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Get the number of active connections for a client.
|
|
651
|
+
*/
|
|
652
|
+
getConnectionCount(clientId: string): number {
|
|
653
|
+
return this.connectionsByClientId.get(clientId)?.size ?? 0;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Get the current transport path for a client if connected.
|
|
658
|
+
*/
|
|
659
|
+
getClientTransportPath(clientId: string): 'direct' | 'relay' | null {
|
|
660
|
+
const conns = this.connectionsByClientId.get(clientId);
|
|
661
|
+
if (!conns || conns.size === 0) {
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
for (const conn of conns) {
|
|
666
|
+
if (conn.transportPath === 'relay') {
|
|
667
|
+
return 'relay';
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
return 'direct';
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Get total number of active connections.
|
|
676
|
+
*/
|
|
677
|
+
getTotalConnections(): number {
|
|
678
|
+
let total = 0;
|
|
679
|
+
for (const conns of this.connectionsByClientId.values()) {
|
|
680
|
+
total += conns.size;
|
|
681
|
+
}
|
|
682
|
+
return total;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Close all connections for a client.
|
|
687
|
+
*/
|
|
688
|
+
closeClientConnections(clientId: string): void {
|
|
689
|
+
const conns = this.connectionsByClientId.get(clientId);
|
|
690
|
+
if (!conns) return;
|
|
691
|
+
|
|
692
|
+
const scopeKeys =
|
|
693
|
+
this.scopeKeysByClientId.get(clientId) ?? new Set<string>();
|
|
694
|
+
for (const k of scopeKeys) {
|
|
695
|
+
const set = this.connectionsByScopeKey.get(k);
|
|
696
|
+
if (!set) continue;
|
|
697
|
+
for (const conn of conns) set.delete(conn);
|
|
698
|
+
if (set.size === 0) this.connectionsByScopeKey.delete(k);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
for (const conn of conns) {
|
|
702
|
+
conn.close(1000, 'client closed');
|
|
703
|
+
}
|
|
704
|
+
this.connectionsByClientId.delete(clientId);
|
|
705
|
+
this.scopeKeysByClientId.delete(clientId);
|
|
706
|
+
this.ensureHeartbeat();
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Close all connections.
|
|
711
|
+
*/
|
|
712
|
+
closeAll(): void {
|
|
713
|
+
for (const conns of this.connectionsByClientId.values()) {
|
|
714
|
+
for (const conn of conns) {
|
|
715
|
+
conn.close(1000, 'server shutdown');
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
this.connectionsByClientId.clear();
|
|
719
|
+
this.scopeKeysByClientId.clear();
|
|
720
|
+
this.connectionsByScopeKey.clear();
|
|
721
|
+
this.presenceByScopeKey.clear();
|
|
722
|
+
this.ensureHeartbeat();
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
private ensureHeartbeat(): void {
|
|
726
|
+
if (this.heartbeatIntervalMs <= 0) return;
|
|
727
|
+
|
|
728
|
+
const total = this.getTotalConnections();
|
|
729
|
+
|
|
730
|
+
if (total === 0) {
|
|
731
|
+
if (this.heartbeatTimer) {
|
|
732
|
+
clearInterval(this.heartbeatTimer);
|
|
733
|
+
this.heartbeatTimer = null;
|
|
734
|
+
}
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (this.heartbeatTimer) return;
|
|
739
|
+
|
|
740
|
+
this.heartbeatTimer = setInterval(() => {
|
|
741
|
+
this.sendHeartbeats();
|
|
742
|
+
}, this.heartbeatIntervalMs);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
private sendHeartbeats(): void {
|
|
746
|
+
const closed: WebSocketConnection[] = [];
|
|
747
|
+
|
|
748
|
+
for (const conns of this.connectionsByClientId.values()) {
|
|
749
|
+
for (const conn of conns) {
|
|
750
|
+
if (!conn.isOpen) {
|
|
751
|
+
closed.push(conn);
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
conn.sendHeartbeat();
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
for (const conn of closed) {
|
|
759
|
+
this.unregister(conn);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Might have removed last connection.
|
|
763
|
+
this.ensureHeartbeat();
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
private unregister(connection: WebSocketConnection): void {
|
|
767
|
+
const clientId = connection.clientId;
|
|
768
|
+
|
|
769
|
+
const scopeKeys =
|
|
770
|
+
this.scopeKeysByClientId.get(clientId) ?? new Set<string>();
|
|
771
|
+
for (const k of scopeKeys) {
|
|
772
|
+
const set = this.connectionsByScopeKey.get(k);
|
|
773
|
+
if (!set) continue;
|
|
774
|
+
set.delete(connection);
|
|
775
|
+
if (set.size === 0) this.connectionsByScopeKey.delete(k);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const conns = this.connectionsByClientId.get(clientId);
|
|
779
|
+
if (!conns) return;
|
|
780
|
+
conns.delete(connection);
|
|
781
|
+
if (conns.size > 0) return;
|
|
782
|
+
|
|
783
|
+
// Client fully disconnected - clean up presence
|
|
784
|
+
this.cleanupClientPresence(clientId);
|
|
785
|
+
|
|
786
|
+
this.connectionsByClientId.delete(clientId);
|
|
787
|
+
this.scopeKeysByClientId.delete(clientId);
|
|
788
|
+
}
|
|
789
|
+
}
|