@pluv/client 0.0.0
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/.eslintrc.js +4 -0
- package/.turbo/turbo-build.log +17 -0
- package/dist/index.d.ts +193 -0
- package/dist/index.js +1001 -0
- package/dist/index.mjs +977 -0
- package/package.json +31 -0
- package/src/CrdtManager.ts +40 -0
- package/src/CrdtNotifier.ts +36 -0
- package/src/EventNotifier.ts +38 -0
- package/src/OtherNotifier.ts +35 -0
- package/src/PluvClient.ts +81 -0
- package/src/PluvRoom.ts +993 -0
- package/src/StateNotifier.ts +55 -0
- package/src/UsersManager.ts +128 -0
- package/src/createClient.ts +9 -0
- package/src/index.ts +16 -0
- package/src/types.ts +40 -0
- package/src/y.ts +1 -0
- package/tsconfig.json +5 -0
package/src/PluvRoom.ts
ADDED
|
@@ -0,0 +1,993 @@
|
|
|
1
|
+
import { doc, InferYjsSharedTypeJson, YjsDoc } from "@pluv/crdt-yjs";
|
|
2
|
+
import type {
|
|
3
|
+
BaseIOEventRecord,
|
|
4
|
+
EventMessage,
|
|
5
|
+
Id,
|
|
6
|
+
InferEventMessage,
|
|
7
|
+
InferIOAuthorize,
|
|
8
|
+
InferIOAuthorizeRequired,
|
|
9
|
+
InferIOAuthorizeUser,
|
|
10
|
+
InferIOInput,
|
|
11
|
+
InferIOOutput,
|
|
12
|
+
IOEventMessage,
|
|
13
|
+
IOLike,
|
|
14
|
+
JsonObject,
|
|
15
|
+
} from "@pluv/types";
|
|
16
|
+
import type { AbstractType } from "yjs";
|
|
17
|
+
import type { CrdtManagerOptions } from "./CrdtManager";
|
|
18
|
+
import { CrdtManager } from "./CrdtManager";
|
|
19
|
+
import { CrdtNotifier } from "./CrdtNotifier";
|
|
20
|
+
import type { EventNotifierSubscriptionCallback } from "./EventNotifier";
|
|
21
|
+
import { EventNotifier } from "./EventNotifier";
|
|
22
|
+
import type { OtherNotifierSubscriptionCallback } from "./OtherNotifier";
|
|
23
|
+
import { OtherNotifier } from "./OtherNotifier";
|
|
24
|
+
import type {
|
|
25
|
+
StateNotifierSubjects,
|
|
26
|
+
SubscriptionCallback,
|
|
27
|
+
} from "./StateNotifier";
|
|
28
|
+
import { StateNotifier } from "./StateNotifier";
|
|
29
|
+
import type {
|
|
30
|
+
AuthorizationState,
|
|
31
|
+
UserInfo,
|
|
32
|
+
WebSocketConnection,
|
|
33
|
+
WebSocketState,
|
|
34
|
+
} from "./types";
|
|
35
|
+
import { ConnectionState } from "./types";
|
|
36
|
+
import type { UsersManagerConfig } from "./UsersManager";
|
|
37
|
+
import { UsersManager } from "./UsersManager";
|
|
38
|
+
|
|
39
|
+
const HEARTBEAT_INTERVAL_MS = 10_000;
|
|
40
|
+
const PONG_TIMEOUT_MS = 2_000;
|
|
41
|
+
const RECONNECT_TIMEOUT_MS = 30_000;
|
|
42
|
+
|
|
43
|
+
declare global {
|
|
44
|
+
var process: {
|
|
45
|
+
env: {
|
|
46
|
+
[key: string]: string | undefined;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface WindowListeners {
|
|
52
|
+
onNavigatorOnline: () => void;
|
|
53
|
+
onVisibilityChange: () => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface WebSocketListeners {
|
|
57
|
+
onClose: (event: CloseEvent) => void;
|
|
58
|
+
onError: () => void;
|
|
59
|
+
onMessage: (message: MessageEvent<string>) => void;
|
|
60
|
+
onOpen: () => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface IntervalIds {
|
|
64
|
+
heartbeat: number | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface TimeoutIds {
|
|
68
|
+
pong: number | null;
|
|
69
|
+
reconnect: number | null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type AuthEndpoint = string | ((room: string) => FetchOptions);
|
|
73
|
+
export type WsEndpoint = string | ((room: string) => string);
|
|
74
|
+
|
|
75
|
+
export interface RoomConfigOptions {
|
|
76
|
+
authEndpoint?: AuthEndpoint;
|
|
77
|
+
wsEndpoint: WsEndpoint;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type FetchOptions = string | { url: string; options?: RequestInit };
|
|
81
|
+
|
|
82
|
+
export type RoomEndpoints<TIO extends IOLike> = {
|
|
83
|
+
wsEndpoint: WsEndpoint;
|
|
84
|
+
} & (InferIOAuthorizeRequired<InferIOAuthorize<TIO>> extends true
|
|
85
|
+
? { authEndpoint: AuthEndpoint }
|
|
86
|
+
: { authEndpoint?: AuthEndpoint });
|
|
87
|
+
|
|
88
|
+
interface InternalListeners {
|
|
89
|
+
onAuthorizationFail: (error: Error) => void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface InternalSubscriptions {
|
|
93
|
+
observeCrdt: (() => void) | null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type PluvRoomDebug<TIO extends IOLike> = Id<{
|
|
97
|
+
output: readonly (keyof InferIOOutput<TIO>)[];
|
|
98
|
+
input: readonly (keyof InferIOInput<TIO>)[];
|
|
99
|
+
}>;
|
|
100
|
+
|
|
101
|
+
export type PluvRoomOptions<
|
|
102
|
+
TIO extends IOLike,
|
|
103
|
+
TPresence extends JsonObject = {},
|
|
104
|
+
TStorage extends Record<string, AbstractType<any>> = {}
|
|
105
|
+
> = {
|
|
106
|
+
debug?: boolean | PluvRoomDebug<TIO>;
|
|
107
|
+
onAuthorizationFail?: (error: Error) => void;
|
|
108
|
+
} & CrdtManagerOptions<TStorage> &
|
|
109
|
+
UsersManagerConfig<TPresence>;
|
|
110
|
+
|
|
111
|
+
export type RoomConfig<
|
|
112
|
+
TIO extends IOLike,
|
|
113
|
+
TPresence extends JsonObject = {},
|
|
114
|
+
TStorage extends Record<string, AbstractType<any>> = {}
|
|
115
|
+
> = RoomEndpoints<TIO> & PluvRoomOptions<TIO, TPresence, TStorage>;
|
|
116
|
+
|
|
117
|
+
export class PluvRoom<
|
|
118
|
+
TIO extends IOLike,
|
|
119
|
+
TPresence extends JsonObject = {},
|
|
120
|
+
TStorage extends Record<string, AbstractType<any>> = {}
|
|
121
|
+
> {
|
|
122
|
+
readonly _endpoints: RoomEndpoints<TIO>;
|
|
123
|
+
|
|
124
|
+
public readonly room: string;
|
|
125
|
+
|
|
126
|
+
private _crdtManager: CrdtManager<TStorage> | null = null;
|
|
127
|
+
private _crdtNotifier = new CrdtNotifier<TStorage>();
|
|
128
|
+
private _debug: boolean | PluvRoomDebug<TIO>;
|
|
129
|
+
private _eventNotifier = new EventNotifier<TIO>();
|
|
130
|
+
private _initialStorage: (() => TStorage) | null = null;
|
|
131
|
+
private _intervals: IntervalIds = {
|
|
132
|
+
heartbeat: null,
|
|
133
|
+
};
|
|
134
|
+
private _listeners: InternalListeners;
|
|
135
|
+
private _otherNotifier = new OtherNotifier<TIO>();
|
|
136
|
+
private _state: WebSocketState<TIO> = {
|
|
137
|
+
authorization: {
|
|
138
|
+
token: null,
|
|
139
|
+
user: null,
|
|
140
|
+
},
|
|
141
|
+
connection: {
|
|
142
|
+
id: null,
|
|
143
|
+
state: ConnectionState.Untouched,
|
|
144
|
+
},
|
|
145
|
+
webSocket: null,
|
|
146
|
+
};
|
|
147
|
+
private _stateNotifier = new StateNotifier<TIO, TPresence>();
|
|
148
|
+
private _subscriptions: InternalSubscriptions = {
|
|
149
|
+
observeCrdt: null,
|
|
150
|
+
};
|
|
151
|
+
private _timeouts: TimeoutIds = {
|
|
152
|
+
pong: null,
|
|
153
|
+
reconnect: null,
|
|
154
|
+
};
|
|
155
|
+
private _windowListeners: WindowListeners | null = null;
|
|
156
|
+
private _wsListeners: WebSocketListeners | null = null;
|
|
157
|
+
private _usersManager: UsersManager<TIO, TPresence>;
|
|
158
|
+
|
|
159
|
+
constructor(room: string, options: RoomConfig<TIO, TPresence, TStorage>) {
|
|
160
|
+
const {
|
|
161
|
+
authEndpoint,
|
|
162
|
+
debug = false,
|
|
163
|
+
initialPresence,
|
|
164
|
+
initialStorage,
|
|
165
|
+
onAuthorizationFail,
|
|
166
|
+
presence,
|
|
167
|
+
wsEndpoint,
|
|
168
|
+
} = options;
|
|
169
|
+
|
|
170
|
+
this.room = room;
|
|
171
|
+
this._debug = debug;
|
|
172
|
+
|
|
173
|
+
this._endpoints = {
|
|
174
|
+
authEndpoint: authEndpoint as
|
|
175
|
+
| string
|
|
176
|
+
| ((room: string) => FetchOptions),
|
|
177
|
+
wsEndpoint,
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
this._listeners = {
|
|
181
|
+
onAuthorizationFail: (error) => {
|
|
182
|
+
this._clearTimeout(this._timeouts.reconnect);
|
|
183
|
+
this._timeouts.reconnect = setTimeout(
|
|
184
|
+
this._reconnect.bind(this),
|
|
185
|
+
RECONNECT_TIMEOUT_MS
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
onAuthorizationFail?.(error);
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
this._usersManager = new UsersManager<TIO, TPresence>({
|
|
193
|
+
initialPresence,
|
|
194
|
+
presence,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
if (initialStorage) this._initialStorage = initialStorage;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
public get webSocket(): WebSocket | null {
|
|
201
|
+
return this._state.webSocket;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
public broadcast(message: InferEventMessage<InferIOInput<TIO>>): void {
|
|
205
|
+
if (!this._state.webSocket) return;
|
|
206
|
+
if (this._state.connection.state !== ConnectionState.Open) return;
|
|
207
|
+
|
|
208
|
+
this._sendMessage(message);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
public async connect(): Promise<void> {
|
|
212
|
+
if (!this._endpoints.wsEndpoint) {
|
|
213
|
+
throw new Error("Must provide an wsEndpoint.");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const canConnect = [
|
|
217
|
+
ConnectionState.Closed,
|
|
218
|
+
ConnectionState.Untouched,
|
|
219
|
+
].some((state) => this._state.connection.state === state);
|
|
220
|
+
|
|
221
|
+
if (!canConnect) return;
|
|
222
|
+
|
|
223
|
+
this._clearTimeout(this._timeouts.reconnect);
|
|
224
|
+
|
|
225
|
+
this._updateState((oldState) => {
|
|
226
|
+
oldState.connection.state = ConnectionState.Connecting;
|
|
227
|
+
|
|
228
|
+
return oldState;
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const url = new URL(this._getWsEndpoint(this.room));
|
|
232
|
+
|
|
233
|
+
let authToken: string | null = null;
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
authToken = await this._getAuthorization(this.room);
|
|
237
|
+
} catch (err) {
|
|
238
|
+
this._onAuthorizationFail(err);
|
|
239
|
+
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
authToken &&
|
|
244
|
+
url.searchParams.set("token", encodeURIComponent(authToken));
|
|
245
|
+
|
|
246
|
+
let webSocket: WebSocket;
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
webSocket = new WebSocket(url.toString());
|
|
250
|
+
} catch (err) {
|
|
251
|
+
this._onAuthorizationFail(err);
|
|
252
|
+
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
this._updateState((oldState) => {
|
|
257
|
+
oldState.authorization.token = authToken;
|
|
258
|
+
oldState.webSocket = webSocket;
|
|
259
|
+
|
|
260
|
+
return oldState;
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
this._attachWindowListeners();
|
|
264
|
+
this._attachWsListeners();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
public disconnect(): void {
|
|
268
|
+
if (!this._endpoints.wsEndpoint) {
|
|
269
|
+
throw new Error("Must provide an wsEndpoint.");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!this._state.webSocket) return;
|
|
273
|
+
|
|
274
|
+
const canDisconnect = [
|
|
275
|
+
ConnectionState.Connecting,
|
|
276
|
+
ConnectionState.Open,
|
|
277
|
+
].some((state) => this._state.connection.state === state);
|
|
278
|
+
|
|
279
|
+
if (!canDisconnect) return;
|
|
280
|
+
|
|
281
|
+
this._closeWs();
|
|
282
|
+
this._crdtManager?.destroy();
|
|
283
|
+
|
|
284
|
+
this._updateState((oldState) => {
|
|
285
|
+
oldState.authorization.token = null;
|
|
286
|
+
oldState.connection.state = ConnectionState.Closed;
|
|
287
|
+
oldState.webSocket = null;
|
|
288
|
+
|
|
289
|
+
return oldState;
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
public event = <TEvent extends keyof InferIOOutput<TIO>>(
|
|
294
|
+
name: TEvent,
|
|
295
|
+
callback: EventNotifierSubscriptionCallback<TIO, TEvent>
|
|
296
|
+
): (() => void) => {
|
|
297
|
+
return this._eventNotifier.subscribe(name, callback);
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
public getConnection(): WebSocketConnection {
|
|
301
|
+
// Create a read-only clone of the connection state
|
|
302
|
+
return Object.freeze(
|
|
303
|
+
JSON.parse(JSON.stringify(this._state.connection))
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
public getMyPresence = (): TPresence | null => {
|
|
308
|
+
return this._usersManager.myself?.presence ?? null;
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
public getMyself = (): Id<UserInfo<TIO, TPresence>> | null => {
|
|
312
|
+
return this._usersManager.myself;
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
public getOther = (
|
|
316
|
+
connectionId: string
|
|
317
|
+
): Id<UserInfo<TIO, TPresence>> | null => {
|
|
318
|
+
return this._usersManager.getOther(connectionId);
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
public getOthers = (): readonly Id<UserInfo<TIO, TPresence>>[] => {
|
|
322
|
+
return this._usersManager.getOthers();
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
public getStorage = <TKey extends keyof TStorage>(
|
|
326
|
+
key: TKey
|
|
327
|
+
): TStorage[TKey] | null => {
|
|
328
|
+
return this._crdtManager?.get(key) ?? null;
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
public other = (
|
|
332
|
+
connectionId: string,
|
|
333
|
+
callback: OtherNotifierSubscriptionCallback<TIO>
|
|
334
|
+
): (() => void) => {
|
|
335
|
+
return this._otherNotifier.subscribe(connectionId, callback);
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
public storage = <TKey extends keyof TStorage>(
|
|
339
|
+
key: TKey,
|
|
340
|
+
fn: (value: InferYjsSharedTypeJson<TStorage[TKey]>) => void
|
|
341
|
+
): (() => void) => {
|
|
342
|
+
return this._crdtNotifier.subscribe(key, fn);
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
public subscribe = <
|
|
346
|
+
TSubject extends keyof StateNotifierSubjects<TIO, TPresence>
|
|
347
|
+
>(
|
|
348
|
+
name: TSubject,
|
|
349
|
+
callback: SubscriptionCallback<TIO, TPresence, TSubject>
|
|
350
|
+
): (() => void) => {
|
|
351
|
+
return this._stateNotifier.subscribe(name, callback);
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
private _attachWindowListeners(): void {
|
|
355
|
+
if (typeof window === "undefined") return;
|
|
356
|
+
if (typeof document === "undefined") return;
|
|
357
|
+
|
|
358
|
+
this._windowListeners = {
|
|
359
|
+
onNavigatorOnline: this._onNavigatorOnline.bind(this),
|
|
360
|
+
onVisibilityChange: this._onVisibilityChange.bind(this),
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
window.addEventListener(
|
|
364
|
+
"online",
|
|
365
|
+
this._windowListeners.onNavigatorOnline
|
|
366
|
+
);
|
|
367
|
+
document.addEventListener(
|
|
368
|
+
"visibilitychange",
|
|
369
|
+
this._windowListeners.onVisibilityChange
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private _attachWsListeners(): void {
|
|
374
|
+
if (!this._state.webSocket) return;
|
|
375
|
+
|
|
376
|
+
this._wsListeners = {
|
|
377
|
+
onClose: this._onClose.bind(this),
|
|
378
|
+
onError: this._onError.bind(this),
|
|
379
|
+
onMessage: this._onMessage.bind(this),
|
|
380
|
+
onOpen: this._onOpen.bind(this),
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
this._state.webSocket.addEventListener(
|
|
384
|
+
"close",
|
|
385
|
+
this._wsListeners.onClose
|
|
386
|
+
);
|
|
387
|
+
this._state.webSocket.addEventListener(
|
|
388
|
+
"error",
|
|
389
|
+
this._wsListeners.onError
|
|
390
|
+
);
|
|
391
|
+
this._state.webSocket.addEventListener(
|
|
392
|
+
"message",
|
|
393
|
+
this._wsListeners.onMessage
|
|
394
|
+
);
|
|
395
|
+
this._state.webSocket.addEventListener(
|
|
396
|
+
"open",
|
|
397
|
+
this._wsListeners.onOpen
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private _clearInterval(interval: number | null) {
|
|
402
|
+
if (Number.isNaN(interval)) return;
|
|
403
|
+
|
|
404
|
+
clearInterval(interval as number);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private _clearTimeout(timeout: number | null) {
|
|
408
|
+
if (Number.isNaN(timeout)) return;
|
|
409
|
+
|
|
410
|
+
clearTimeout(timeout as number);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
private _closeWs(): void {
|
|
414
|
+
if (!this._state.webSocket) return;
|
|
415
|
+
|
|
416
|
+
this._subscriptions.observeCrdt?.();
|
|
417
|
+
|
|
418
|
+
this._clearInterval(this._intervals.heartbeat);
|
|
419
|
+
this._clearTimeout(this._timeouts.pong);
|
|
420
|
+
this._clearTimeout(this._timeouts.reconnect);
|
|
421
|
+
|
|
422
|
+
this._detachWindowListeners();
|
|
423
|
+
this._usersManager.removeMyself();
|
|
424
|
+
|
|
425
|
+
const canClose = [WebSocket.CONNECTING, WebSocket.OPEN].some(
|
|
426
|
+
(readyState) => readyState === this._state.webSocket?.readyState
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
if (canClose) this._state.webSocket.close();
|
|
430
|
+
|
|
431
|
+
this._detachWsListeners();
|
|
432
|
+
|
|
433
|
+
this._stateNotifier.subjects["my-presence"].next(null);
|
|
434
|
+
this._stateNotifier.subjects["myself"].next(null);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
private _detachWindowListeners(): void {
|
|
438
|
+
if (!this._windowListeners) return;
|
|
439
|
+
if (typeof window === "undefined") return;
|
|
440
|
+
if (typeof document === "undefined") return;
|
|
441
|
+
|
|
442
|
+
window.removeEventListener(
|
|
443
|
+
"online",
|
|
444
|
+
this._windowListeners.onNavigatorOnline
|
|
445
|
+
);
|
|
446
|
+
document.removeEventListener(
|
|
447
|
+
"visibilitychange",
|
|
448
|
+
this._windowListeners.onVisibilityChange
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
this._windowListeners = null;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
private _detachWsListeners(): void {
|
|
455
|
+
if (!this._state.webSocket || !this._wsListeners) return;
|
|
456
|
+
|
|
457
|
+
this._state.webSocket.removeEventListener(
|
|
458
|
+
"close",
|
|
459
|
+
this._wsListeners.onClose
|
|
460
|
+
);
|
|
461
|
+
this._state.webSocket.removeEventListener(
|
|
462
|
+
"error",
|
|
463
|
+
this._wsListeners.onError
|
|
464
|
+
);
|
|
465
|
+
this._state.webSocket.removeEventListener(
|
|
466
|
+
"message",
|
|
467
|
+
this._wsListeners.onMessage
|
|
468
|
+
);
|
|
469
|
+
this._state.webSocket.removeEventListener(
|
|
470
|
+
"open",
|
|
471
|
+
this._wsListeners.onOpen
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
this._wsListeners = null;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private async _getAuthorization(room: string): Promise<string | null> {
|
|
478
|
+
if (!this._endpoints.authEndpoint) return null;
|
|
479
|
+
|
|
480
|
+
if (typeof this._endpoints.authEndpoint === "string") {
|
|
481
|
+
const token = await fetch(this._endpoints.authEndpoint)
|
|
482
|
+
.then((res) => res.text())
|
|
483
|
+
.then((text) => text.trim());
|
|
484
|
+
|
|
485
|
+
return token;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const config = this._endpoints.authEndpoint(room);
|
|
489
|
+
const endpoint = typeof config === "string" ? config : config.url;
|
|
490
|
+
const options = typeof config === "string" ? undefined : config.options;
|
|
491
|
+
|
|
492
|
+
const token = await fetch(endpoint, options)
|
|
493
|
+
.then((res) => res.text())
|
|
494
|
+
.then((text) => text.trim());
|
|
495
|
+
|
|
496
|
+
return token;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
private _getWsEndpoint(room: string): string {
|
|
500
|
+
if (typeof this._endpoints.wsEndpoint === "string") {
|
|
501
|
+
return this._endpoints.wsEndpoint;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return this._endpoints.wsEndpoint(room);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
private _handleExit(message: IOEventMessage<TIO>): void {
|
|
508
|
+
const { connectionId } = message;
|
|
509
|
+
|
|
510
|
+
if (!connectionId) return;
|
|
511
|
+
// Should not reach here
|
|
512
|
+
if (!this._state.webSocket) throw new Error("Could not find WebSocket");
|
|
513
|
+
|
|
514
|
+
this._usersManager.removeUser(connectionId);
|
|
515
|
+
|
|
516
|
+
const others = this._usersManager.getOthers();
|
|
517
|
+
|
|
518
|
+
this._stateNotifier.subjects.others.next(others);
|
|
519
|
+
this._otherNotifier.subject(connectionId).next(null);
|
|
520
|
+
this._otherNotifier.subjects.delete(connectionId);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private _handlePresenceUpdatedMessage(message: IOEventMessage<TIO>): void {
|
|
524
|
+
const { connectionId } = message;
|
|
525
|
+
|
|
526
|
+
if (!connectionId) return;
|
|
527
|
+
// Should not reach here
|
|
528
|
+
if (!this._state.webSocket) throw new Error("Could not find WebSocket");
|
|
529
|
+
|
|
530
|
+
const data = message.data as BaseIOEventRecord<
|
|
531
|
+
InferIOAuthorize<TIO>
|
|
532
|
+
>["$PRESENCE_UPDATED"];
|
|
533
|
+
|
|
534
|
+
this._usersManager.patchPresence(
|
|
535
|
+
connectionId,
|
|
536
|
+
data.presence as TPresence
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
const myPresence = this._usersManager.myself?.presence ?? null;
|
|
540
|
+
const myself = this._usersManager.myself ?? null;
|
|
541
|
+
|
|
542
|
+
if (myself?.connectionId === connectionId) {
|
|
543
|
+
this._stateNotifier.subjects["my-presence"].next(myPresence);
|
|
544
|
+
this._stateNotifier.subjects["myself"].next(myself);
|
|
545
|
+
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const other = this._usersManager.getOther(connectionId);
|
|
550
|
+
const others = this._usersManager.getOthers();
|
|
551
|
+
|
|
552
|
+
this._otherNotifier.subject(connectionId).next(other);
|
|
553
|
+
this._stateNotifier.subjects["others"].next(others);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
private _handleReceiveOthers(message: IOEventMessage<TIO>): void {
|
|
557
|
+
if (!message.connectionId) return;
|
|
558
|
+
// Should not reach here
|
|
559
|
+
if (!this._state.webSocket) throw new Error("Could not find WebSocket");
|
|
560
|
+
|
|
561
|
+
const data = message.data as BaseIOEventRecord<
|
|
562
|
+
InferIOAuthorize<TIO>
|
|
563
|
+
>["$OTHERS_RECEIVED"];
|
|
564
|
+
|
|
565
|
+
Object.keys(data.others).forEach((connectionId) => {
|
|
566
|
+
const { presence, user } = data.others[connectionId];
|
|
567
|
+
|
|
568
|
+
if (!user) return;
|
|
569
|
+
|
|
570
|
+
this._usersManager.setUser(connectionId, user);
|
|
571
|
+
|
|
572
|
+
const other = this._usersManager.getOther(connectionId);
|
|
573
|
+
|
|
574
|
+
!!presence &&
|
|
575
|
+
this._usersManager.patchPresence(
|
|
576
|
+
connectionId,
|
|
577
|
+
presence as TPresence
|
|
578
|
+
);
|
|
579
|
+
|
|
580
|
+
this._otherNotifier.subject(connectionId).next(other);
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
const others = this._usersManager.getOthers();
|
|
584
|
+
|
|
585
|
+
this._stateNotifier.subjects["others"].next(others);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
private _handleRegisteredMessage(message: IOEventMessage<TIO>): void {
|
|
589
|
+
const { connectionId, user } = message;
|
|
590
|
+
|
|
591
|
+
if (!connectionId) return;
|
|
592
|
+
// Should not reach here
|
|
593
|
+
if (!this._state.webSocket) throw new Error("Could not find WebSocket");
|
|
594
|
+
|
|
595
|
+
this._updateState((oldState) => {
|
|
596
|
+
oldState.connection.id = connectionId;
|
|
597
|
+
oldState.authorization.user = user;
|
|
598
|
+
|
|
599
|
+
return oldState;
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
this._usersManager.setMyself(
|
|
603
|
+
connectionId,
|
|
604
|
+
user as Id<InferIOAuthorizeUser<InferIOAuthorize<TIO>>>
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
const myPresence = this._usersManager.myself?.presence ?? null;
|
|
608
|
+
const myself = this._usersManager.myself ?? null;
|
|
609
|
+
|
|
610
|
+
this._stateNotifier.subjects["my-presence"].next(myPresence);
|
|
611
|
+
this._stateNotifier.subjects["myself"].next(myself);
|
|
612
|
+
|
|
613
|
+
this._sendMessage({ type: "$GET_OTHERS", data: {} });
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
private _handleStorageReceivedMessage(message: IOEventMessage<TIO>): void {
|
|
617
|
+
const { connectionId } = message;
|
|
618
|
+
|
|
619
|
+
if (!connectionId) return;
|
|
620
|
+
// Should not reach here
|
|
621
|
+
if (!this._state.webSocket) throw new Error("Could not find WebSocket");
|
|
622
|
+
|
|
623
|
+
const data = message.data as BaseIOEventRecord<
|
|
624
|
+
InferIOAuthorize<TIO>
|
|
625
|
+
>["$STORAGE_RECEIVED"];
|
|
626
|
+
|
|
627
|
+
this._crdtManager = new CrdtManager<TStorage>({
|
|
628
|
+
encodedState: data.state,
|
|
629
|
+
initialStorage: this._initialStorage ?? undefined,
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
this._observeCrdt();
|
|
633
|
+
|
|
634
|
+
const sharedTypes = this._crdtManager.doc.getSharedTypes();
|
|
635
|
+
|
|
636
|
+
Object.keys(sharedTypes).forEach((key) => {
|
|
637
|
+
const sharedType = sharedTypes[key];
|
|
638
|
+
const serialized = sharedType.toJSON();
|
|
639
|
+
|
|
640
|
+
this._crdtNotifier.subject(key).next(serialized);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
const update = this._crdtManager.doc.encodeStateAsUpdate();
|
|
644
|
+
|
|
645
|
+
this._sendMessage({
|
|
646
|
+
type: "$UPDATE_STORAGE",
|
|
647
|
+
data: { origin: "$STORAGE_RECEIVED", update },
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
private _handleStorageUpdatedMessage(message: IOEventMessage<TIO>): void {
|
|
652
|
+
const { connectionId } = message;
|
|
653
|
+
|
|
654
|
+
if (!connectionId) return;
|
|
655
|
+
// Should not reach here
|
|
656
|
+
if (!this._state.webSocket) throw new Error("Could not find WebSocket");
|
|
657
|
+
|
|
658
|
+
const data = message.data as BaseIOEventRecord<
|
|
659
|
+
InferIOAuthorize<TIO>
|
|
660
|
+
>["$STORAGE_UPDATED"];
|
|
661
|
+
|
|
662
|
+
if (!this._crdtManager) return;
|
|
663
|
+
|
|
664
|
+
const test = doc({});
|
|
665
|
+
test.applyUpdate(data.state);
|
|
666
|
+
|
|
667
|
+
this._crdtManager.doc.applyUpdate(data.state, "$STORAGE_UPDATED");
|
|
668
|
+
|
|
669
|
+
const sharedTypes = this._crdtManager.doc.getSharedTypes();
|
|
670
|
+
|
|
671
|
+
Object.keys(sharedTypes).forEach((key) => {
|
|
672
|
+
if (!this._crdtManager) return;
|
|
673
|
+
|
|
674
|
+
const sharedType = sharedTypes[key];
|
|
675
|
+
|
|
676
|
+
if (!sharedType) return;
|
|
677
|
+
|
|
678
|
+
const serialized = sharedType.toJSON();
|
|
679
|
+
|
|
680
|
+
this._crdtNotifier.subject(key).next(serialized);
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
private _handleUserJoinedMessage(message: IOEventMessage<TIO>): void {
|
|
685
|
+
const { connectionId } = message;
|
|
686
|
+
|
|
687
|
+
if (!connectionId) return;
|
|
688
|
+
// Should not reach here
|
|
689
|
+
if (!this._state.webSocket) throw new Error("Could not find WebSocket");
|
|
690
|
+
if (!this._usersManager.myself) return;
|
|
691
|
+
|
|
692
|
+
const data = message.data as BaseIOEventRecord<
|
|
693
|
+
InferIOAuthorize<TIO>
|
|
694
|
+
>["$USER_JOINED"];
|
|
695
|
+
|
|
696
|
+
this._usersManager.setUser(connectionId, data.user);
|
|
697
|
+
|
|
698
|
+
const other = this._usersManager.getOther(connectionId);
|
|
699
|
+
const others = this._usersManager.getOthers();
|
|
700
|
+
|
|
701
|
+
this._otherNotifier.subject(connectionId).next(other);
|
|
702
|
+
this._stateNotifier.subjects["others"].next(others);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
private _heartbeat(): void {
|
|
706
|
+
if (!this._state.webSocket) return;
|
|
707
|
+
if (!this._state.connection.id) return;
|
|
708
|
+
if (this._state.webSocket.readyState !== this._state.webSocket.OPEN) {
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
this._clearTimeout(this._timeouts.pong);
|
|
713
|
+
this._timeouts.pong = setTimeout(
|
|
714
|
+
this._reconnect.bind(this),
|
|
715
|
+
PONG_TIMEOUT_MS
|
|
716
|
+
);
|
|
717
|
+
|
|
718
|
+
this._sendMessage({ type: "$PING", data: {} });
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
private _logDebug(...data: any[]): void {
|
|
722
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
723
|
+
if (process?.env?.NODE_ENV === "production") return;
|
|
724
|
+
|
|
725
|
+
this._debug && console.log(...data);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
private _observeCrdt(): void {
|
|
729
|
+
this._subscriptions.observeCrdt?.();
|
|
730
|
+
|
|
731
|
+
if (!this._crdtManager) return;
|
|
732
|
+
|
|
733
|
+
const unsubscribe = this._crdtManager.doc.subscribe(
|
|
734
|
+
(update, _origin) => {
|
|
735
|
+
const origin = _origin ?? null;
|
|
736
|
+
|
|
737
|
+
if (!this._crdtManager) return;
|
|
738
|
+
if (!this._state.webSocket) return;
|
|
739
|
+
if (!this._state.connection.id) return;
|
|
740
|
+
if (this._state.webSocket.readyState !== WebSocket.OPEN) return;
|
|
741
|
+
|
|
742
|
+
if (origin === "$STORAGE_UPDATED") return;
|
|
743
|
+
|
|
744
|
+
this._sendMessage({
|
|
745
|
+
type: "$UPDATE_STORAGE",
|
|
746
|
+
data: { origin, update },
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
this._subscriptions.observeCrdt = unsubscribe;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
private _onAuthorizationFail(error: unknown): void {
|
|
755
|
+
const authError =
|
|
756
|
+
error instanceof Error ? error : new Error("Authorization failed");
|
|
757
|
+
|
|
758
|
+
this._listeners.onAuthorizationFail(authError);
|
|
759
|
+
|
|
760
|
+
this._updateState((oldState) => {
|
|
761
|
+
oldState.connection.state = ConnectionState.Unavailable;
|
|
762
|
+
|
|
763
|
+
return oldState;
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* TODO
|
|
769
|
+
* @description Determine if ws was closed with a code for closing without retries.
|
|
770
|
+
* Otherwise, attempt to retry again with incremental backoff
|
|
771
|
+
* @author leedavidcs
|
|
772
|
+
* @date August 19, 2022
|
|
773
|
+
*/
|
|
774
|
+
private _onClose(event: CloseEvent): void {
|
|
775
|
+
this._logDebug("WebSocket closed");
|
|
776
|
+
!!event.reason && this._logDebug(event.reason);
|
|
777
|
+
|
|
778
|
+
this._clearInterval(this._intervals.heartbeat);
|
|
779
|
+
this._clearTimeout(this._timeouts.pong);
|
|
780
|
+
|
|
781
|
+
this._updateState((oldState) => {
|
|
782
|
+
oldState.connection.state = ConnectionState.Closed;
|
|
783
|
+
oldState.webSocket = null;
|
|
784
|
+
|
|
785
|
+
return oldState;
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* TODO
|
|
791
|
+
* @description Handle websocket errors in a meaningful way, should they occur
|
|
792
|
+
* @author leedavidcs
|
|
793
|
+
* @date August 19, 2022
|
|
794
|
+
*/
|
|
795
|
+
private _onError(): void {}
|
|
796
|
+
|
|
797
|
+
private _onMessage(event: MessageEvent<string>): void {
|
|
798
|
+
const message = this._parseMessage(event);
|
|
799
|
+
|
|
800
|
+
if (!message) return;
|
|
801
|
+
|
|
802
|
+
const shouldLog =
|
|
803
|
+
typeof this._debug === "boolean"
|
|
804
|
+
? this._debug
|
|
805
|
+
: this._debug.output.find((value) => value === message.type);
|
|
806
|
+
|
|
807
|
+
if (shouldLog) {
|
|
808
|
+
this._logDebug("WebSocket event received: ", message.type, message);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
this._eventNotifier
|
|
812
|
+
.subject(message.type as keyof InferIOOutput<TIO>)
|
|
813
|
+
.next(message as any);
|
|
814
|
+
|
|
815
|
+
const { connectionId } = message;
|
|
816
|
+
|
|
817
|
+
if (!connectionId) return;
|
|
818
|
+
|
|
819
|
+
switch (message.type) {
|
|
820
|
+
case "$EXIT": {
|
|
821
|
+
this._handleExit(message);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
case "$OTHERS_RECEIVED": {
|
|
825
|
+
this._handleReceiveOthers(message);
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
case "$PONG": {
|
|
829
|
+
this._clearTimeout(this._timeouts.pong);
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
case "$PRESENCE_UPDATED": {
|
|
833
|
+
this._handlePresenceUpdatedMessage(message);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
case "$REGISTERED": {
|
|
837
|
+
this._handleRegisteredMessage(message);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
case "$STORAGE_RECEIVED": {
|
|
841
|
+
this._handleStorageReceivedMessage(message);
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
case "$STORAGE_UPDATED": {
|
|
845
|
+
this._handleStorageUpdatedMessage(message);
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
case "$USER_JOINED": {
|
|
849
|
+
this._handleUserJoinedMessage(message);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
default:
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
private _onOpen(): void {
|
|
857
|
+
if (this._state.connection.state !== ConnectionState.Connecting) return;
|
|
858
|
+
|
|
859
|
+
this._logDebug("WebSocket connected");
|
|
860
|
+
|
|
861
|
+
this._updateState((oldState) => {
|
|
862
|
+
oldState.connection.state = ConnectionState.Open;
|
|
863
|
+
|
|
864
|
+
return oldState;
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
this._clearInterval(this._intervals.heartbeat);
|
|
868
|
+
this._intervals.heartbeat = setInterval(
|
|
869
|
+
this._heartbeat.bind(this),
|
|
870
|
+
HEARTBEAT_INTERVAL_MS
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
private async _onNavigatorOnline(): Promise<void> {
|
|
875
|
+
if (this._state.connection.state !== ConnectionState.Unavailable) {
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
await this._reconnect();
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
private async _onVisibilityChange(): Promise<void> {
|
|
883
|
+
if (typeof document === "undefined") return;
|
|
884
|
+
if (document.visibilityState !== "visible") return;
|
|
885
|
+
|
|
886
|
+
switch (this._state.connection.state) {
|
|
887
|
+
case ConnectionState.Open:
|
|
888
|
+
this._clearInterval(this._intervals.heartbeat);
|
|
889
|
+
setInterval(this._heartbeat.bind(this), HEARTBEAT_INTERVAL_MS);
|
|
890
|
+
|
|
891
|
+
return;
|
|
892
|
+
case ConnectionState.Unavailable:
|
|
893
|
+
await this._reconnect();
|
|
894
|
+
|
|
895
|
+
return;
|
|
896
|
+
default:
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
private _parseMessage(message: {
|
|
901
|
+
data: string | ArrayBuffer;
|
|
902
|
+
}): IOEventMessage<TIO> | null {
|
|
903
|
+
/**
|
|
904
|
+
* !HACK
|
|
905
|
+
* @description We'll only handle stringified JSONs for now
|
|
906
|
+
* @author leedavidcs
|
|
907
|
+
* @date August 9, 2022
|
|
908
|
+
*/
|
|
909
|
+
if (typeof message.data !== "string") return null;
|
|
910
|
+
|
|
911
|
+
try {
|
|
912
|
+
const config = JSON.parse(message.data);
|
|
913
|
+
|
|
914
|
+
if (typeof config !== "object") return null;
|
|
915
|
+
if (typeof config.type !== "string") return null;
|
|
916
|
+
if (typeof config.data !== "object") return null;
|
|
917
|
+
|
|
918
|
+
return config as IOEventMessage<TIO>;
|
|
919
|
+
} catch {
|
|
920
|
+
return null;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
private async _reconnect(): Promise<void> {
|
|
925
|
+
if (!this._endpoints.wsEndpoint) {
|
|
926
|
+
throw new Error("Must provide an wsEndpoint.");
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if (!this._state.webSocket) return;
|
|
930
|
+
|
|
931
|
+
this._closeWs();
|
|
932
|
+
|
|
933
|
+
this._updateState((oldState) => {
|
|
934
|
+
oldState.authorization.token = null;
|
|
935
|
+
oldState.connection.state = ConnectionState.Unavailable;
|
|
936
|
+
oldState.webSocket = null;
|
|
937
|
+
|
|
938
|
+
return oldState;
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
await this.connect();
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
private _sendMessage<
|
|
945
|
+
TMessage extends EventMessage<string, any> = EventMessage<string, any>
|
|
946
|
+
>(data: TMessage): void {
|
|
947
|
+
const webSocket = this._state.webSocket;
|
|
948
|
+
|
|
949
|
+
if (webSocket?.readyState !== WebSocket.OPEN) return;
|
|
950
|
+
|
|
951
|
+
const shouldLog =
|
|
952
|
+
typeof this._debug === "boolean"
|
|
953
|
+
? this._debug
|
|
954
|
+
: this._debug.input.find((value) => value === data.type);
|
|
955
|
+
|
|
956
|
+
if (shouldLog) {
|
|
957
|
+
this._logDebug("WebSocket event sent: ", data.type, data);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
webSocket.send(JSON.stringify(data));
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
private _updateState(
|
|
964
|
+
updater: (oldState: WebSocketState<TIO>) => WebSocketState<TIO>
|
|
965
|
+
): WebSocketState<TIO> {
|
|
966
|
+
let authorization: AuthorizationState<TIO>;
|
|
967
|
+
|
|
968
|
+
try {
|
|
969
|
+
authorization = JSON.parse(
|
|
970
|
+
JSON.stringify(this._state.authorization)
|
|
971
|
+
);
|
|
972
|
+
} catch {
|
|
973
|
+
throw new Error("User is not JSON serializable");
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const oldState: WebSocketState<TIO> = {
|
|
977
|
+
...this._state,
|
|
978
|
+
authorization,
|
|
979
|
+
connection: JSON.parse(JSON.stringify(this._state.connection)),
|
|
980
|
+
webSocket: this._state.webSocket,
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
const newState = updater(oldState);
|
|
984
|
+
|
|
985
|
+
this._logDebug("WebSocket new state: ", newState);
|
|
986
|
+
|
|
987
|
+
this._state = newState;
|
|
988
|
+
|
|
989
|
+
this._stateNotifier.subjects.connection.next(newState);
|
|
990
|
+
|
|
991
|
+
return newState;
|
|
992
|
+
}
|
|
993
|
+
}
|