@syncular/relay 0.0.1-100
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-role/forward-engine.d.ts +63 -0
- package/dist/client-role/forward-engine.d.ts.map +1 -0
- package/dist/client-role/forward-engine.js +257 -0
- package/dist/client-role/forward-engine.js.map +1 -0
- package/dist/client-role/index.d.ts +9 -0
- package/dist/client-role/index.d.ts.map +1 -0
- package/dist/client-role/index.js +9 -0
- package/dist/client-role/index.js.map +1 -0
- package/dist/client-role/pull-engine.d.ts +70 -0
- package/dist/client-role/pull-engine.d.ts.map +1 -0
- package/dist/client-role/pull-engine.js +247 -0
- package/dist/client-role/pull-engine.js.map +1 -0
- package/dist/client-role/sequence-mapper.d.ts +65 -0
- package/dist/client-role/sequence-mapper.d.ts.map +1 -0
- package/dist/client-role/sequence-mapper.js +161 -0
- package/dist/client-role/sequence-mapper.js.map +1 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +44 -0
- package/dist/index.js.map +1 -0
- package/dist/migrate.d.ts +18 -0
- package/dist/migrate.d.ts.map +1 -0
- package/dist/migrate.js +99 -0
- package/dist/migrate.js.map +1 -0
- package/dist/mode-manager.d.ts +60 -0
- package/dist/mode-manager.d.ts.map +1 -0
- package/dist/mode-manager.js +114 -0
- package/dist/mode-manager.js.map +1 -0
- package/dist/realtime.d.ts +102 -0
- package/dist/realtime.d.ts.map +1 -0
- package/dist/realtime.js +305 -0
- package/dist/realtime.js.map +1 -0
- package/dist/relay.d.ts +189 -0
- package/dist/relay.d.ts.map +1 -0
- package/dist/relay.js +319 -0
- package/dist/relay.js.map +1 -0
- package/dist/schema.d.ts +158 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +7 -0
- package/dist/schema.js.map +1 -0
- package/dist/server-role/index.d.ts +54 -0
- package/dist/server-role/index.d.ts.map +1 -0
- package/dist/server-role/index.js +195 -0
- package/dist/server-role/index.js.map +1 -0
- package/dist/server-role/pull.d.ts +25 -0
- package/dist/server-role/pull.d.ts.map +1 -0
- package/dist/server-role/pull.js +24 -0
- package/dist/server-role/pull.js.map +1 -0
- package/dist/server-role/push.d.ts +27 -0
- package/dist/server-role/push.d.ts.map +1 -0
- package/dist/server-role/push.js +94 -0
- package/dist/server-role/push.js.map +1 -0
- package/package.json +61 -0
- package/src/__tests__/relay.test.ts +781 -0
- package/src/bun-types.d.ts +50 -0
- package/src/client-role/forward-engine.ts +343 -0
- package/src/client-role/index.ts +9 -0
- package/src/client-role/pull-engine.ts +321 -0
- package/src/client-role/sequence-mapper.ts +201 -0
- package/src/index.ts +50 -0
- package/src/migrate.ts +113 -0
- package/src/mode-manager.ts +142 -0
- package/src/realtime.ts +370 -0
- package/src/relay.ts +424 -0
- package/src/schema.ts +171 -0
- package/src/server-role/index.ts +339 -0
- package/src/server-role/pull.ts +37 -0
- package/src/server-role/push.ts +123 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @syncular/relay - Mode Manager
|
|
3
|
+
*
|
|
4
|
+
* State machine for tracking relay online/offline status.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Relay operating modes.
|
|
9
|
+
*/
|
|
10
|
+
export type RelayMode = 'online' | 'offline' | 'reconnecting';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Mode manager options.
|
|
14
|
+
*/
|
|
15
|
+
export interface ModeManagerOptions {
|
|
16
|
+
healthCheckIntervalMs?: number;
|
|
17
|
+
reconnectBackoffMs?: number;
|
|
18
|
+
maxReconnectBackoffMs?: number;
|
|
19
|
+
onModeChange?: (mode: RelayMode) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Mode manager for tracking relay online/offline state.
|
|
24
|
+
*
|
|
25
|
+
* Uses health checks to detect connectivity to the main server
|
|
26
|
+
* and manages reconnection with exponential backoff.
|
|
27
|
+
*/
|
|
28
|
+
export class ModeManager {
|
|
29
|
+
private mode: RelayMode = 'offline';
|
|
30
|
+
private healthCheckIntervalMs: number;
|
|
31
|
+
private reconnectBackoffMs: number;
|
|
32
|
+
private maxReconnectBackoffMs: number;
|
|
33
|
+
private currentBackoffMs: number;
|
|
34
|
+
private onModeChange?: (mode: RelayMode) => void;
|
|
35
|
+
|
|
36
|
+
private running = false;
|
|
37
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
38
|
+
private healthCheckFn: (() => Promise<boolean>) | null = null;
|
|
39
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: assigned and incremented in healthCheck
|
|
40
|
+
private consecutiveFailures = 0;
|
|
41
|
+
|
|
42
|
+
constructor(options: ModeManagerOptions = {}) {
|
|
43
|
+
this.healthCheckIntervalMs = options.healthCheckIntervalMs ?? 30000;
|
|
44
|
+
this.reconnectBackoffMs = options.reconnectBackoffMs ?? 1000;
|
|
45
|
+
this.maxReconnectBackoffMs = options.maxReconnectBackoffMs ?? 60000;
|
|
46
|
+
this.currentBackoffMs = this.reconnectBackoffMs;
|
|
47
|
+
this.onModeChange = options.onModeChange;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Get the current mode.
|
|
52
|
+
*/
|
|
53
|
+
getMode(): RelayMode {
|
|
54
|
+
return this.mode;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Start the mode manager with a health check function.
|
|
59
|
+
*/
|
|
60
|
+
start(healthCheckFn: () => Promise<boolean>): void {
|
|
61
|
+
if (this.running) return;
|
|
62
|
+
this.running = true;
|
|
63
|
+
this.healthCheckFn = healthCheckFn;
|
|
64
|
+
|
|
65
|
+
// Start with immediate health check
|
|
66
|
+
this.scheduleHealthCheck(0);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Stop the mode manager.
|
|
71
|
+
*/
|
|
72
|
+
stop(): void {
|
|
73
|
+
this.running = false;
|
|
74
|
+
if (this.timer) {
|
|
75
|
+
clearTimeout(this.timer);
|
|
76
|
+
this.timer = null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Manually report a successful operation (resets backoff).
|
|
82
|
+
*/
|
|
83
|
+
reportSuccess(): void {
|
|
84
|
+
if (this.mode !== 'online') {
|
|
85
|
+
this.setMode('online');
|
|
86
|
+
}
|
|
87
|
+
this.consecutiveFailures = 0;
|
|
88
|
+
this.currentBackoffMs = this.reconnectBackoffMs;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Manually report a failed operation.
|
|
93
|
+
*/
|
|
94
|
+
reportFailure(): void {
|
|
95
|
+
this.consecutiveFailures++;
|
|
96
|
+
|
|
97
|
+
if (this.mode !== 'reconnecting') {
|
|
98
|
+
this.setMode('reconnecting');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Increase backoff for next attempt
|
|
102
|
+
this.currentBackoffMs = Math.min(
|
|
103
|
+
this.currentBackoffMs * 2,
|
|
104
|
+
this.maxReconnectBackoffMs
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private setMode(newMode: RelayMode): void {
|
|
109
|
+
if (this.mode === newMode) return;
|
|
110
|
+
this.mode = newMode;
|
|
111
|
+
this.onModeChange?.(newMode);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private scheduleHealthCheck(delayMs: number): void {
|
|
115
|
+
if (!this.running) return;
|
|
116
|
+
if (this.timer) return;
|
|
117
|
+
|
|
118
|
+
this.timer = setTimeout(async () => {
|
|
119
|
+
this.timer = null;
|
|
120
|
+
|
|
121
|
+
if (!this.healthCheckFn) {
|
|
122
|
+
this.scheduleHealthCheck(this.healthCheckIntervalMs);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const healthy = await this.healthCheckFn();
|
|
128
|
+
|
|
129
|
+
if (healthy) {
|
|
130
|
+
this.reportSuccess();
|
|
131
|
+
this.scheduleHealthCheck(this.healthCheckIntervalMs);
|
|
132
|
+
} else {
|
|
133
|
+
this.reportFailure();
|
|
134
|
+
this.scheduleHealthCheck(this.currentBackoffMs);
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
this.reportFailure();
|
|
138
|
+
this.scheduleHealthCheck(this.currentBackoffMs);
|
|
139
|
+
}
|
|
140
|
+
}, delayMs);
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/realtime.ts
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @syncular/relay - Realtime WebSocket Manager
|
|
3
|
+
*
|
|
4
|
+
* Manages WebSocket connections for local clients to receive
|
|
5
|
+
* instant notifications when data changes.
|
|
6
|
+
*
|
|
7
|
+
* Adapted from @syncular/server-hono/ws.ts for relay use.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* WebSocket event data for sync notifications.
|
|
12
|
+
*/
|
|
13
|
+
export interface RelayWebSocketEvent {
|
|
14
|
+
event: 'sync' | 'heartbeat' | 'error';
|
|
15
|
+
data: {
|
|
16
|
+
cursor?: number;
|
|
17
|
+
error?: string;
|
|
18
|
+
timestamp: number;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* WebSocket connection interface for the relay.
|
|
24
|
+
*/
|
|
25
|
+
export interface RelayWebSocketConnection {
|
|
26
|
+
sendSync(cursor: number): void;
|
|
27
|
+
sendHeartbeat(): void;
|
|
28
|
+
sendError(message: string): void;
|
|
29
|
+
close(code?: number, reason?: string): void;
|
|
30
|
+
isOpen: boolean;
|
|
31
|
+
actorId: string;
|
|
32
|
+
clientId: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Realtime manager for relay WebSocket connections.
|
|
37
|
+
*
|
|
38
|
+
* Tracks active connections by client ID and scope key for
|
|
39
|
+
* efficient notification routing.
|
|
40
|
+
*/
|
|
41
|
+
export class RelayRealtime {
|
|
42
|
+
private connectionsByClientId = new Map<
|
|
43
|
+
string,
|
|
44
|
+
Set<RelayWebSocketConnection>
|
|
45
|
+
>();
|
|
46
|
+
private scopeKeysByClientId = new Map<string, Set<string>>();
|
|
47
|
+
private connectionsByScopeKey = new Map<
|
|
48
|
+
string,
|
|
49
|
+
Set<RelayWebSocketConnection>
|
|
50
|
+
>();
|
|
51
|
+
|
|
52
|
+
private heartbeatIntervalMs: number;
|
|
53
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
54
|
+
|
|
55
|
+
constructor(options?: { heartbeatIntervalMs?: number }) {
|
|
56
|
+
this.heartbeatIntervalMs = options?.heartbeatIntervalMs ?? 30_000;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Register a connection for a client.
|
|
61
|
+
* Returns a cleanup function to unregister.
|
|
62
|
+
*/
|
|
63
|
+
register(
|
|
64
|
+
connection: RelayWebSocketConnection,
|
|
65
|
+
initialScopeKeys: string[] = []
|
|
66
|
+
): () => void {
|
|
67
|
+
const clientId = connection.clientId;
|
|
68
|
+
let clientConns = this.connectionsByClientId.get(clientId);
|
|
69
|
+
if (!clientConns) {
|
|
70
|
+
clientConns = new Set();
|
|
71
|
+
this.connectionsByClientId.set(clientId, clientConns);
|
|
72
|
+
}
|
|
73
|
+
clientConns.add(connection);
|
|
74
|
+
|
|
75
|
+
if (!this.scopeKeysByClientId.has(clientId)) {
|
|
76
|
+
this.scopeKeysByClientId.set(clientId, new Set(initialScopeKeys));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const scopeKeys =
|
|
80
|
+
this.scopeKeysByClientId.get(clientId) ?? new Set<string>();
|
|
81
|
+
for (const k of scopeKeys) {
|
|
82
|
+
let scopeConns = this.connectionsByScopeKey.get(k);
|
|
83
|
+
if (!scopeConns) {
|
|
84
|
+
scopeConns = new Set();
|
|
85
|
+
this.connectionsByScopeKey.set(k, scopeConns);
|
|
86
|
+
}
|
|
87
|
+
scopeConns.add(connection);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
this.ensureHeartbeat();
|
|
91
|
+
|
|
92
|
+
return () => {
|
|
93
|
+
this.unregister(connection);
|
|
94
|
+
this.ensureHeartbeat();
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Update the effective tables/scopes for an already-connected client.
|
|
100
|
+
* In the new scope model, this is called with table names.
|
|
101
|
+
*/
|
|
102
|
+
updateClientTables(clientId: string, tables: string[]): void {
|
|
103
|
+
this._updateScopeKeys(clientId, tables);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Alias for backwards compatibility.
|
|
108
|
+
*/
|
|
109
|
+
updateClientScopeKeys(clientId: string, scopeKeys: string[]): void {
|
|
110
|
+
this._updateScopeKeys(clientId, scopeKeys);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private _updateScopeKeys(clientId: string, keys: string[]): void {
|
|
114
|
+
const conns = this.connectionsByClientId.get(clientId);
|
|
115
|
+
if (!conns || conns.size === 0) return;
|
|
116
|
+
|
|
117
|
+
const next = new Set<string>(keys);
|
|
118
|
+
const prev = this.scopeKeysByClientId.get(clientId) ?? new Set<string>();
|
|
119
|
+
|
|
120
|
+
// No-op when unchanged
|
|
121
|
+
if (prev.size === next.size) {
|
|
122
|
+
let unchanged = true;
|
|
123
|
+
for (const k of prev) {
|
|
124
|
+
if (!next.has(k)) {
|
|
125
|
+
unchanged = false;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (unchanged) return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
this.scopeKeysByClientId.set(clientId, next);
|
|
133
|
+
|
|
134
|
+
// Remove from old scopes
|
|
135
|
+
for (const k of prev) {
|
|
136
|
+
if (next.has(k)) continue;
|
|
137
|
+
const set = this.connectionsByScopeKey.get(k);
|
|
138
|
+
if (!set) continue;
|
|
139
|
+
for (const conn of conns) set.delete(conn);
|
|
140
|
+
if (set.size === 0) this.connectionsByScopeKey.delete(k);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Add to new scopes
|
|
144
|
+
for (const k of next) {
|
|
145
|
+
if (prev.has(k)) continue;
|
|
146
|
+
let set = this.connectionsByScopeKey.get(k);
|
|
147
|
+
if (!set) {
|
|
148
|
+
set = new Set();
|
|
149
|
+
this.connectionsByScopeKey.set(k, set);
|
|
150
|
+
}
|
|
151
|
+
for (const conn of conns) set.add(conn);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Notify clients that new data is available for the given scopes.
|
|
157
|
+
*/
|
|
158
|
+
notifyScopeKeys(
|
|
159
|
+
scopeKeys: string[],
|
|
160
|
+
cursor: number,
|
|
161
|
+
opts?: { excludeClientIds?: string[] }
|
|
162
|
+
): void {
|
|
163
|
+
const exclude = new Set(opts?.excludeClientIds ?? []);
|
|
164
|
+
const targets = new Set<RelayWebSocketConnection>();
|
|
165
|
+
|
|
166
|
+
for (const k of scopeKeys) {
|
|
167
|
+
const conns = this.connectionsByScopeKey.get(k);
|
|
168
|
+
if (!conns) continue;
|
|
169
|
+
for (const conn of conns) targets.add(conn);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
for (const conn of targets) {
|
|
173
|
+
if (!conn.isOpen) continue;
|
|
174
|
+
if (exclude.has(conn.clientId)) continue;
|
|
175
|
+
conn.sendSync(cursor);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Get the number of active connections for a client.
|
|
181
|
+
*/
|
|
182
|
+
getConnectionCount(clientId: string): number {
|
|
183
|
+
return this.connectionsByClientId.get(clientId)?.size ?? 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Get total number of active connections.
|
|
188
|
+
*/
|
|
189
|
+
getTotalConnections(): number {
|
|
190
|
+
let total = 0;
|
|
191
|
+
for (const conns of this.connectionsByClientId.values()) {
|
|
192
|
+
total += conns.size;
|
|
193
|
+
}
|
|
194
|
+
return total;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Close all connections for a client.
|
|
199
|
+
*/
|
|
200
|
+
closeClientConnections(clientId: string): void {
|
|
201
|
+
const conns = this.connectionsByClientId.get(clientId);
|
|
202
|
+
if (!conns) return;
|
|
203
|
+
|
|
204
|
+
const scopeKeys =
|
|
205
|
+
this.scopeKeysByClientId.get(clientId) ?? new Set<string>();
|
|
206
|
+
for (const k of scopeKeys) {
|
|
207
|
+
const set = this.connectionsByScopeKey.get(k);
|
|
208
|
+
if (!set) continue;
|
|
209
|
+
for (const conn of conns) set.delete(conn);
|
|
210
|
+
if (set.size === 0) this.connectionsByScopeKey.delete(k);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
for (const conn of conns) {
|
|
214
|
+
conn.close(1000, 'client closed');
|
|
215
|
+
}
|
|
216
|
+
this.connectionsByClientId.delete(clientId);
|
|
217
|
+
this.scopeKeysByClientId.delete(clientId);
|
|
218
|
+
this.ensureHeartbeat();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Close all connections.
|
|
223
|
+
*/
|
|
224
|
+
closeAll(): void {
|
|
225
|
+
for (const conns of this.connectionsByClientId.values()) {
|
|
226
|
+
for (const conn of conns) {
|
|
227
|
+
conn.close(1000, 'server shutdown');
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
this.connectionsByClientId.clear();
|
|
231
|
+
this.scopeKeysByClientId.clear();
|
|
232
|
+
this.connectionsByScopeKey.clear();
|
|
233
|
+
this.ensureHeartbeat();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private ensureHeartbeat(): void {
|
|
237
|
+
if (this.heartbeatIntervalMs <= 0) return;
|
|
238
|
+
|
|
239
|
+
const total = this.getTotalConnections();
|
|
240
|
+
|
|
241
|
+
if (total === 0) {
|
|
242
|
+
if (this.heartbeatTimer) {
|
|
243
|
+
clearInterval(this.heartbeatTimer);
|
|
244
|
+
this.heartbeatTimer = null;
|
|
245
|
+
}
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (this.heartbeatTimer) return;
|
|
250
|
+
|
|
251
|
+
this.heartbeatTimer = setInterval(() => {
|
|
252
|
+
this.sendHeartbeats();
|
|
253
|
+
}, this.heartbeatIntervalMs);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private sendHeartbeats(): void {
|
|
257
|
+
const closed: RelayWebSocketConnection[] = [];
|
|
258
|
+
|
|
259
|
+
for (const conns of this.connectionsByClientId.values()) {
|
|
260
|
+
for (const conn of conns) {
|
|
261
|
+
if (!conn.isOpen) {
|
|
262
|
+
closed.push(conn);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
conn.sendHeartbeat();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
for (const conn of closed) {
|
|
270
|
+
this.unregister(conn);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
this.ensureHeartbeat();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private unregister(connection: RelayWebSocketConnection): void {
|
|
277
|
+
const clientId = connection.clientId;
|
|
278
|
+
|
|
279
|
+
const scopeKeys =
|
|
280
|
+
this.scopeKeysByClientId.get(clientId) ?? new Set<string>();
|
|
281
|
+
for (const k of scopeKeys) {
|
|
282
|
+
const set = this.connectionsByScopeKey.get(k);
|
|
283
|
+
if (!set) continue;
|
|
284
|
+
set.delete(connection);
|
|
285
|
+
if (set.size === 0) this.connectionsByScopeKey.delete(k);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const conns = this.connectionsByClientId.get(clientId);
|
|
289
|
+
if (!conns) return;
|
|
290
|
+
conns.delete(connection);
|
|
291
|
+
if (conns.size > 0) return;
|
|
292
|
+
|
|
293
|
+
this.connectionsByClientId.delete(clientId);
|
|
294
|
+
this.scopeKeysByClientId.delete(clientId);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Create a WebSocket connection wrapper.
|
|
300
|
+
*
|
|
301
|
+
* Use this with your WebSocket library to create connections
|
|
302
|
+
* compatible with RelayRealtime.
|
|
303
|
+
*/
|
|
304
|
+
export function createRelayWebSocketConnection(
|
|
305
|
+
ws: {
|
|
306
|
+
send(message: string): void;
|
|
307
|
+
close(code?: number, reason?: string): void;
|
|
308
|
+
readyState: number;
|
|
309
|
+
},
|
|
310
|
+
args: { actorId: string; clientId: string }
|
|
311
|
+
): RelayWebSocketConnection {
|
|
312
|
+
let closed = false;
|
|
313
|
+
|
|
314
|
+
function safeSend(message: string): boolean {
|
|
315
|
+
try {
|
|
316
|
+
ws.send(message);
|
|
317
|
+
return true;
|
|
318
|
+
} catch {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const connection: RelayWebSocketConnection = {
|
|
324
|
+
get isOpen() {
|
|
325
|
+
if (closed) return false;
|
|
326
|
+
return ws.readyState === 1;
|
|
327
|
+
},
|
|
328
|
+
actorId: args.actorId,
|
|
329
|
+
clientId: args.clientId,
|
|
330
|
+
sendSync(cursor: number) {
|
|
331
|
+
if (!connection.isOpen) return;
|
|
332
|
+
const ok = safeSend(
|
|
333
|
+
JSON.stringify({
|
|
334
|
+
event: 'sync',
|
|
335
|
+
data: { cursor, timestamp: Date.now() },
|
|
336
|
+
})
|
|
337
|
+
);
|
|
338
|
+
if (!ok) closed = true;
|
|
339
|
+
},
|
|
340
|
+
sendHeartbeat() {
|
|
341
|
+
if (!connection.isOpen) return;
|
|
342
|
+
const ok = safeSend(
|
|
343
|
+
JSON.stringify({ event: 'heartbeat', data: { timestamp: Date.now() } })
|
|
344
|
+
);
|
|
345
|
+
if (!ok) closed = true;
|
|
346
|
+
},
|
|
347
|
+
sendError(message: string) {
|
|
348
|
+
if (connection.isOpen) {
|
|
349
|
+
safeSend(
|
|
350
|
+
JSON.stringify({
|
|
351
|
+
event: 'error',
|
|
352
|
+
data: { error: message, timestamp: Date.now() },
|
|
353
|
+
})
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
connection.close(1011, 'server error');
|
|
357
|
+
},
|
|
358
|
+
close(code?: number, reason?: string) {
|
|
359
|
+
if (closed) return;
|
|
360
|
+
closed = true;
|
|
361
|
+
try {
|
|
362
|
+
ws.close(code, reason);
|
|
363
|
+
} catch {
|
|
364
|
+
// ignore
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
return connection;
|
|
370
|
+
}
|