@pluv/io 5.0.1 → 5.0.2
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/index.d.mts +542 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1424 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +8 -5
- package/CHANGELOG.md +0 -2353
- package/eslint.config.mjs +0 -4
- package/src/AbstractPersistence.ts +0 -29
- package/src/AbstractPlatform.ts +0 -105
- package/src/AbstractPubSub.ts +0 -20
- package/src/AbstractWebSocket.ts +0 -134
- package/src/IORoom.ts +0 -1245
- package/src/Persistence.ts +0 -81
- package/src/PluvIO.ts +0 -239
- package/src/PluvProcedure.ts +0 -203
- package/src/PluvRouter.ts +0 -61
- package/src/PluvServer.ts +0 -487
- package/src/PubSub.ts +0 -70
- package/src/authorize.ts +0 -76
- package/src/constants.ts +0 -6
- package/src/createIO.ts +0 -55
- package/src/index.ts +0 -55
- package/src/types.ts +0 -287
- package/src/utils/index.ts +0 -2
- package/src/utils/oneLine.ts +0 -7
- package/src/utils/pickBy.ts +0 -16
- package/src/version.ts +0 -3
- package/tsconfig.json +0 -9
- package/tsdown.config.ts +0 -14
package/src/IORoom.ts
DELETED
|
@@ -1,1245 +0,0 @@
|
|
|
1
|
-
import type { AbstractCrdtDocFactory } from "@pluv/crdt";
|
|
2
|
-
import { noop } from "@pluv/crdt";
|
|
3
|
-
import type {
|
|
4
|
-
BaseIOEventRecord,
|
|
5
|
-
BaseUser,
|
|
6
|
-
CrdtDocLike,
|
|
7
|
-
CrdtLibraryType,
|
|
8
|
-
EventMessage,
|
|
9
|
-
IOEventMessage,
|
|
10
|
-
IOLike,
|
|
11
|
-
Id,
|
|
12
|
-
InferEventMessage,
|
|
13
|
-
InferEventsOutput,
|
|
14
|
-
InferIOAuthorize,
|
|
15
|
-
InferIOAuthorizeUser,
|
|
16
|
-
InferIOInput,
|
|
17
|
-
JsonObject,
|
|
18
|
-
Maybe,
|
|
19
|
-
} from "@pluv/types";
|
|
20
|
-
import colors from "kleur";
|
|
21
|
-
import type {
|
|
22
|
-
AbstractPlatform,
|
|
23
|
-
InferInitContextType,
|
|
24
|
-
InferPlatformWebSocketSource,
|
|
25
|
-
InferRoomContextType,
|
|
26
|
-
} from "./AbstractPlatform";
|
|
27
|
-
import type {
|
|
28
|
-
AbstractCloseEvent,
|
|
29
|
-
AbstractErrorEvent,
|
|
30
|
-
AbstractMessageEvent,
|
|
31
|
-
} from "./AbstractWebSocket";
|
|
32
|
-
import { AbstractWebSocket } from "./AbstractWebSocket";
|
|
33
|
-
import type { PluvRouter, PluvRouterEventConfig } from "./PluvRouter";
|
|
34
|
-
import { authorize } from "./authorize";
|
|
35
|
-
import { GARBAGE_COLLECT_INTERVAL_MS, PING_TIMEOUT_MS } from "./constants";
|
|
36
|
-
import type {
|
|
37
|
-
EventResolverContext,
|
|
38
|
-
EventResolverKind,
|
|
39
|
-
GetInitialStorageFn,
|
|
40
|
-
IORoomDestroyedEvent,
|
|
41
|
-
IORoomListenerEvent,
|
|
42
|
-
IORoomMessageEvent,
|
|
43
|
-
IOUserConnectedEvent,
|
|
44
|
-
IOUserDisconnectedEvent,
|
|
45
|
-
PluvContext,
|
|
46
|
-
PluvIOAuthorize,
|
|
47
|
-
ResolvedPluvIOAuthorize,
|
|
48
|
-
WebSocketSession,
|
|
49
|
-
WebSocketType,
|
|
50
|
-
} from "./types";
|
|
51
|
-
import { oneLine } from "./utils";
|
|
52
|
-
|
|
53
|
-
type BroadcastMessage<TIO extends IORoom<any, any, any, any, any>> =
|
|
54
|
-
| InferEventMessage<InferIOInput<TIO>>
|
|
55
|
-
| InferEventMessage<BaseIOEventRecord<InferIOAuthorize<TIO>>>;
|
|
56
|
-
|
|
57
|
-
interface BroadcastParams<TIO extends IORoom<any, any, any, any, any>> {
|
|
58
|
-
message: BroadcastMessage<TIO>;
|
|
59
|
-
senderId?: string;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export interface IORoomListeners<
|
|
63
|
-
TPlatform extends AbstractPlatform<any>,
|
|
64
|
-
TAuthorize extends PluvIOAuthorize<TPlatform, any, InferInitContextType<TPlatform>> | null,
|
|
65
|
-
TContext extends Record<string, any>,
|
|
66
|
-
TEvents extends PluvRouterEventConfig<TPlatform, TAuthorize, TContext>,
|
|
67
|
-
> {
|
|
68
|
-
onRoomDestroyed: (event: IORoomDestroyedEvent<TPlatform, TContext>) => void;
|
|
69
|
-
onStorageDestroyed: (event: IORoomListenerEvent<TPlatform, TContext>) => void;
|
|
70
|
-
onMessage: (event: IORoomMessageEvent<TPlatform, TAuthorize, TContext, TEvents>) => void;
|
|
71
|
-
onUserConnected: (event: IOUserConnectedEvent<TPlatform, TAuthorize, TContext>) => void;
|
|
72
|
-
onUserDisconnected: (event: IOUserDisconnectedEvent<TPlatform, TAuthorize, TContext>) => void;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export type BroadcastProxy<TIO extends IORoom<any, any, any, any, any>> = (<
|
|
76
|
-
TEvent extends keyof InferIOInput<TIO>,
|
|
77
|
-
>(
|
|
78
|
-
event: TEvent,
|
|
79
|
-
data: Id<InferIOInput<TIO>[TEvent]>,
|
|
80
|
-
) => Promise<void>) & {
|
|
81
|
-
[event in keyof InferIOInput<TIO>]: (data: Id<InferIOInput<TIO>>[event]) => Promise<void>;
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
export type IORoomConfig<
|
|
85
|
-
TPlatform extends AbstractPlatform<any> = AbstractPlatform<any>,
|
|
86
|
-
TAuthorize extends PluvIOAuthorize<TPlatform, any, InferInitContextType<TPlatform>> | null =
|
|
87
|
-
any,
|
|
88
|
-
TContext extends Record<string, any> = {},
|
|
89
|
-
TEvents extends PluvRouterEventConfig<TPlatform, TAuthorize, TContext> = {},
|
|
90
|
-
> = Partial<IORoomListeners<TPlatform, TAuthorize, TContext, TEvents>> & {
|
|
91
|
-
authorize?: TAuthorize;
|
|
92
|
-
context: PluvContext<TPlatform, TContext>;
|
|
93
|
-
crdt?: { doc: (value: any) => AbstractCrdtDocFactory<any, any> };
|
|
94
|
-
debug: boolean;
|
|
95
|
-
getInitialStorage: GetInitialStorageFn<TContext>;
|
|
96
|
-
platform: TPlatform;
|
|
97
|
-
roomContext: InferRoomContextType<TPlatform>;
|
|
98
|
-
router: PluvRouter<TPlatform, TAuthorize, TContext, TEvents>;
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
interface SendMessageSender {
|
|
102
|
-
sessionId: string | null;
|
|
103
|
-
user: JsonObject | null;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
interface PatchPresenceParams {
|
|
107
|
-
presence: JsonObject | null;
|
|
108
|
-
sessionId: string;
|
|
109
|
-
timer?: number | null;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
export type WebSocketRegisterConfig<
|
|
113
|
-
TPlatform extends AbstractPlatform<any> = AbstractPlatform<any>,
|
|
114
|
-
> = {
|
|
115
|
-
token?: string | null;
|
|
116
|
-
} & InferInitContextType<TPlatform>;
|
|
117
|
-
|
|
118
|
-
export class IORoom<
|
|
119
|
-
TPlatform extends AbstractPlatform<any> = AbstractPlatform<any>,
|
|
120
|
-
TAuthorize extends PluvIOAuthorize<TPlatform, any, InferInitContextType<TPlatform>> | null =
|
|
121
|
-
null,
|
|
122
|
-
TContext extends Record<string, any> = {},
|
|
123
|
-
TCrdt extends CrdtLibraryType<any> = CrdtLibraryType<any>,
|
|
124
|
-
TEvents extends PluvRouterEventConfig<TPlatform, TAuthorize, TContext> = {},
|
|
125
|
-
> implements IOLike<TAuthorize, TCrdt, TEvents> {
|
|
126
|
-
public readonly id: string;
|
|
127
|
-
|
|
128
|
-
private _doc: Promise<CrdtDocLike<any, any>>;
|
|
129
|
-
private _lastGarbageCollectMs: number = -1 * (GARBAGE_COLLECT_INTERVAL_MS + 1);
|
|
130
|
-
private _uninitialize: Promise<() => Promise<void>> | null = null;
|
|
131
|
-
|
|
132
|
-
private readonly _authorize: TAuthorize = null as TAuthorize;
|
|
133
|
-
private readonly _context: PluvContext<TPlatform, TContext>;
|
|
134
|
-
private readonly _crdt: TCrdt;
|
|
135
|
-
private readonly _debug: boolean;
|
|
136
|
-
private readonly _docFactory: AbstractCrdtDocFactory<any, any>;
|
|
137
|
-
private readonly _getInitialStorage: GetInitialStorageFn<TContext>;
|
|
138
|
-
private readonly _listeners: IORoomListeners<TPlatform, TAuthorize, TContext, TEvents>;
|
|
139
|
-
private readonly _platform: TPlatform;
|
|
140
|
-
private readonly _roomContext: InferRoomContextType<TPlatform>;
|
|
141
|
-
private readonly _router: PluvRouter<TPlatform, TAuthorize, TContext, TEvents>;
|
|
142
|
-
private readonly _sessions = new Map<
|
|
143
|
-
[sessionId: string][0],
|
|
144
|
-
AbstractWebSocket<any, TAuthorize>
|
|
145
|
-
>();
|
|
146
|
-
private readonly _userSessionss = new Map<[userId: string][0], Set<[sessionId: string][0]>>();
|
|
147
|
-
|
|
148
|
-
private _wasDocEmptyOnInit: boolean = true;
|
|
149
|
-
private _storageInitializedViaSession: boolean = false;
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* @ignore
|
|
153
|
-
* @readonly
|
|
154
|
-
* @deprecated Internal use only. Changes to this will never be marked as breaking.
|
|
155
|
-
*/
|
|
156
|
-
public get _defs() {
|
|
157
|
-
return {
|
|
158
|
-
authorize: this._authorize,
|
|
159
|
-
context: this._context,
|
|
160
|
-
crdt: this._crdt,
|
|
161
|
-
events: this._router._defs.events,
|
|
162
|
-
platform: this._platform,
|
|
163
|
-
} as {
|
|
164
|
-
authorize: TAuthorize;
|
|
165
|
-
context: TContext;
|
|
166
|
-
crdt: TCrdt;
|
|
167
|
-
events: TEvents;
|
|
168
|
-
platform: TPlatform;
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
public get broadcast(): BroadcastProxy<this> {
|
|
173
|
-
const _broadcast = <TEvent extends keyof InferIOInput<this>>(
|
|
174
|
-
event: TEvent,
|
|
175
|
-
data: Id<InferIOInput<this>[TEvent]>,
|
|
176
|
-
): Promise<void> => {
|
|
177
|
-
const message = { type: event, data } as BroadcastMessage<this>;
|
|
178
|
-
|
|
179
|
-
return Promise.resolve(this._broadcast({ message }));
|
|
180
|
-
};
|
|
181
|
-
|
|
182
|
-
return new Proxy(_broadcast, {
|
|
183
|
-
get(fn, prop) {
|
|
184
|
-
return (data: any) => fn(prop as any, data);
|
|
185
|
-
},
|
|
186
|
-
}) as BroadcastProxy<this>;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
private get _initialized(): Promise<boolean> {
|
|
190
|
-
return Promise.resolve(!!this._uninitialize?.then((result) => !!result));
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
constructor(id: string, config: IORoomConfig<TPlatform, TAuthorize, TContext, TEvents>) {
|
|
194
|
-
const {
|
|
195
|
-
_meta,
|
|
196
|
-
authorize,
|
|
197
|
-
context,
|
|
198
|
-
crdt = noop,
|
|
199
|
-
debug,
|
|
200
|
-
getInitialStorage,
|
|
201
|
-
onRoomDestroyed,
|
|
202
|
-
onStorageDestroyed,
|
|
203
|
-
onMessage,
|
|
204
|
-
onUserConnected,
|
|
205
|
-
onUserDisconnected,
|
|
206
|
-
platform,
|
|
207
|
-
roomContext,
|
|
208
|
-
router,
|
|
209
|
-
} = config as IORoomConfig<TPlatform, TAuthorize, TContext, TEvents> & { _meta?: any };
|
|
210
|
-
|
|
211
|
-
this.id = id;
|
|
212
|
-
|
|
213
|
-
this._context = context;
|
|
214
|
-
this._crdt = crdt as TCrdt;
|
|
215
|
-
this._debug = debug;
|
|
216
|
-
this._docFactory = crdt.doc(() => ({}));
|
|
217
|
-
this._getInitialStorage = getInitialStorage;
|
|
218
|
-
this._roomContext = roomContext;
|
|
219
|
-
this._router = router;
|
|
220
|
-
this._platform = platform.initialize({ ...(!!_meta ? { _meta } : {}), roomContext });
|
|
221
|
-
|
|
222
|
-
// Listeners are provided from server-level configuration via createRoom
|
|
223
|
-
this._listeners = {
|
|
224
|
-
onRoomDestroyed: (event) => onRoomDestroyed?.(event),
|
|
225
|
-
onStorageDestroyed: (event) => onStorageDestroyed?.(event),
|
|
226
|
-
onMessage: (event) => onMessage?.(event),
|
|
227
|
-
onUserConnected: (event) => onUserConnected?.(event),
|
|
228
|
-
onUserDisconnected: (event) => onUserDisconnected?.(event),
|
|
229
|
-
};
|
|
230
|
-
|
|
231
|
-
if (authorize) this._authorize = authorize;
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* @description Everything below this line relates to waking up from hibernation.
|
|
235
|
-
* @see https://developers.cloudflare.com/durable-objects/best-practices/websockets/#websocket-hibernation-api
|
|
236
|
-
* @date April 17, 2025
|
|
237
|
-
*/
|
|
238
|
-
const webSockets =
|
|
239
|
-
this._platform.getWebSockets() as readonly InferPlatformWebSocketSource<TPlatform>[];
|
|
240
|
-
|
|
241
|
-
webSockets.forEach((webSocket) => {
|
|
242
|
-
const deserialized = this._platform.getSerializedState(webSocket);
|
|
243
|
-
const sessionId = this._platform.getSessionId(webSocket);
|
|
244
|
-
|
|
245
|
-
if (!deserialized) return;
|
|
246
|
-
if (typeof sessionId !== "string") return;
|
|
247
|
-
|
|
248
|
-
const pluvWs = this._platform.convertWebSocket(webSocket, { room: this.id });
|
|
249
|
-
|
|
250
|
-
this._sessions.set(sessionId, pluvWs);
|
|
251
|
-
|
|
252
|
-
const userId = pluvWs.session.user?.id;
|
|
253
|
-
|
|
254
|
-
if (!!userId) this._addUserSession(userId, sessionId);
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
const { doc, uninitialize } = this._initialize();
|
|
258
|
-
|
|
259
|
-
this._doc = doc;
|
|
260
|
-
this._uninitialize = uninitialize;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* @description Closes a connection with the specified sessionId
|
|
265
|
-
* @param sessionId The session id of the connection to close
|
|
266
|
-
*/
|
|
267
|
-
public async evict(sessionId: string): Promise<void> {
|
|
268
|
-
const session = this._sessions.get(sessionId);
|
|
269
|
-
|
|
270
|
-
if (session) session.state.quit = true;
|
|
271
|
-
|
|
272
|
-
await this._emitQuitters();
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* @description Closes all connections to this room, effectively destroying
|
|
277
|
-
* the room
|
|
278
|
-
*/
|
|
279
|
-
public async evictAll(): Promise<void> {
|
|
280
|
-
const sessions = Array.from(this._sessions.values());
|
|
281
|
-
|
|
282
|
-
sessions.forEach((session) => {
|
|
283
|
-
session.state.quit = true;
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
await this._emitQuitters();
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* @description The IORoom will garbage collect occasionally as connections ping/pong the
|
|
291
|
-
* server
|
|
292
|
-
*/
|
|
293
|
-
public async garbageCollect(): Promise<void> {
|
|
294
|
-
this._lastGarbageCollectMs = Date.now();
|
|
295
|
-
this._emitQuitters();
|
|
296
|
-
this._emitSyncState();
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
public getSize(): number {
|
|
300
|
-
const currentTime = new Date().getTime();
|
|
301
|
-
|
|
302
|
-
/**
|
|
303
|
-
* @description Doing this instead of .size because some sessions
|
|
304
|
-
* in the map can be considered as "omitted".
|
|
305
|
-
* @date December 21, 2022
|
|
306
|
-
*/
|
|
307
|
-
return Array.from(this._sessions.values()).reduce((count, pluvWs) => {
|
|
308
|
-
if (pluvWs.state.quit) return count;
|
|
309
|
-
|
|
310
|
-
const pingTime = this._platform.getLastPing(pluvWs) ?? pluvWs.state.timers.ping;
|
|
311
|
-
|
|
312
|
-
return currentTime - pingTime > PING_TIMEOUT_MS ? count : count + 1;
|
|
313
|
-
}, 0);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
public onClose(
|
|
317
|
-
webSocket: WebSocketType<TPlatform>,
|
|
318
|
-
): (event: AbstractCloseEvent) => Promise<void> {
|
|
319
|
-
this._ensureDetached();
|
|
320
|
-
|
|
321
|
-
const wsSession = this._getAbstractWs(webSocket);
|
|
322
|
-
|
|
323
|
-
if (!wsSession) return async () => undefined;
|
|
324
|
-
|
|
325
|
-
return this._onClose(wsSession);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
public onError(
|
|
329
|
-
webSocket: WebSocketType<TPlatform>,
|
|
330
|
-
): (event: AbstractErrorEvent) => Promise<void> {
|
|
331
|
-
this._ensureDetached();
|
|
332
|
-
|
|
333
|
-
const wsSession = this._getAbstractWs(webSocket);
|
|
334
|
-
|
|
335
|
-
if (!wsSession) return async () => undefined;
|
|
336
|
-
|
|
337
|
-
return this._onClose(wsSession);
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
public onMessage(
|
|
341
|
-
webSocket: WebSocketType<TPlatform>,
|
|
342
|
-
): (event: AbstractMessageEvent) => Promise<void> {
|
|
343
|
-
this._ensureDetached();
|
|
344
|
-
|
|
345
|
-
const wsSession = this._getAbstractWs(webSocket);
|
|
346
|
-
|
|
347
|
-
if (!wsSession) return async () => undefined;
|
|
348
|
-
|
|
349
|
-
return this._onMessage(wsSession);
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
public async register(
|
|
353
|
-
webSocket: InferPlatformWebSocketSource<TPlatform>,
|
|
354
|
-
...options: keyof InferInitContextType<TPlatform> extends never
|
|
355
|
-
? [{ token?: string }?]
|
|
356
|
-
: [WebSocketRegisterConfig<TPlatform>]
|
|
357
|
-
): Promise<void> {
|
|
358
|
-
const _options = (options[0] ?? {}) as WebSocketRegisterConfig<TPlatform>;
|
|
359
|
-
const token = _options.token ?? null;
|
|
360
|
-
|
|
361
|
-
/**
|
|
362
|
-
* TODO
|
|
363
|
-
* @description Update the sessionId strategy so that the userId can be used to retrieve
|
|
364
|
-
* the session in O(1) time if authorized.
|
|
365
|
-
* @date April 15, 2025
|
|
366
|
-
*/
|
|
367
|
-
const sessionId = this._platform.getSessionId(webSocket);
|
|
368
|
-
const sessionExists = typeof sessionId === "string" && this._sessions.has(sessionId);
|
|
369
|
-
|
|
370
|
-
if (sessionExists) return;
|
|
371
|
-
|
|
372
|
-
if (!(await this._initialized)) {
|
|
373
|
-
this._initialize();
|
|
374
|
-
await this._initialized;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
const user: BaseUser | null = await this._getAuthorizedUser(token, _options);
|
|
378
|
-
const ioAuthorize = this._getIOAuthorize(_options);
|
|
379
|
-
const isUnauthorized = !!ioAuthorize && !user;
|
|
380
|
-
const pluvWs = this._platform.convertWebSocket(webSocket, { room: this.id });
|
|
381
|
-
|
|
382
|
-
if (isUnauthorized) {
|
|
383
|
-
this._logDebug(`${colors.blue("Authorization failed for connection")}`);
|
|
384
|
-
pluvWs.handleError({ error: new Error("Not authorized"), room: this.id });
|
|
385
|
-
pluvWs.close(3000, "WebSocket unauthorized.");
|
|
386
|
-
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
if (!!user) {
|
|
391
|
-
const latest = this._getLatestPresence(user.id);
|
|
392
|
-
const prevState = pluvWs.state;
|
|
393
|
-
|
|
394
|
-
pluvWs.user = user;
|
|
395
|
-
|
|
396
|
-
this._platform.setSerializedState(pluvWs, {
|
|
397
|
-
...prevState,
|
|
398
|
-
presence: latest.presence,
|
|
399
|
-
timers: { ...prevState.timers, presence: latest.timer },
|
|
400
|
-
});
|
|
401
|
-
this._addUserSession(user.id, pluvWs.sessionId);
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
this._logDebug(
|
|
405
|
-
`${colors.blue(`Registering connection for room ${this.id}:`)} ${pluvWs.sessionId}`,
|
|
406
|
-
);
|
|
407
|
-
|
|
408
|
-
await this._platform.acceptWebSocket(pluvWs);
|
|
409
|
-
this._sessions.set(pluvWs.sessionId, pluvWs);
|
|
410
|
-
await this._platform.persistence.addUser(this.id, pluvWs.sessionId, user ?? {});
|
|
411
|
-
|
|
412
|
-
if (this._platform._config.registrationMode === "attached") {
|
|
413
|
-
const onClose = this._onClose(pluvWs).bind(this);
|
|
414
|
-
const onMessage = this._onMessage(pluvWs).bind(this);
|
|
415
|
-
|
|
416
|
-
pluvWs.addEventListener("close", onClose);
|
|
417
|
-
pluvWs.addEventListener("error", onClose);
|
|
418
|
-
pluvWs.addEventListener("message", onMessage);
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
await this._emitRegistered(pluvWs);
|
|
422
|
-
|
|
423
|
-
const size = this.getSize();
|
|
424
|
-
|
|
425
|
-
this._logDebug(oneLine`
|
|
426
|
-
${colors.blue(`Registered connection for room ${this.id}:`)}
|
|
427
|
-
${pluvWs.sessionId}
|
|
428
|
-
`);
|
|
429
|
-
this._logDebug(`${colors.blue(`Room ${this.id} size:`)} ${size}`);
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
private _addUserSession(userId: string, sessionId: string): Set<[sessionId: string][0]> {
|
|
433
|
-
const set = this._userSessionss.get(userId) ?? new Set<string>();
|
|
434
|
-
const updated = set.add(sessionId);
|
|
435
|
-
|
|
436
|
-
this._userSessionss.set(userId, updated);
|
|
437
|
-
|
|
438
|
-
return updated;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
private async _broadcast(params: BroadcastParams<this>): Promise<void> {
|
|
442
|
-
const { message, senderId } = params;
|
|
443
|
-
|
|
444
|
-
const sender = senderId ? (this._sessions.get(senderId) ?? null) : null;
|
|
445
|
-
const session = sender?.session ?? null;
|
|
446
|
-
const user = session?.user ?? null;
|
|
447
|
-
|
|
448
|
-
if (typeof senderId !== "string") return;
|
|
449
|
-
|
|
450
|
-
this._platform.pubSub.publish(this.id, {
|
|
451
|
-
connectionId: senderId ?? null,
|
|
452
|
-
room: this.id,
|
|
453
|
-
user,
|
|
454
|
-
...message,
|
|
455
|
-
});
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
private async _closeWebSockets(
|
|
459
|
-
webSockets: readonly AbstractWebSocket<any, TAuthorize>[],
|
|
460
|
-
): Promise<void> {
|
|
461
|
-
const closeWebSocket = async (
|
|
462
|
-
webSocket: AbstractWebSocket<any, TAuthorize>,
|
|
463
|
-
): Promise<void> => {
|
|
464
|
-
webSocket.state = { ...webSocket.state, quit: true };
|
|
465
|
-
|
|
466
|
-
const sessionId = webSocket.sessionId;
|
|
467
|
-
|
|
468
|
-
this._logDebug(
|
|
469
|
-
`${colors.blue(`Unregistering connection for room ${this.id}:`)} ${sessionId}`,
|
|
470
|
-
);
|
|
471
|
-
this._sessions.delete(sessionId);
|
|
472
|
-
|
|
473
|
-
await this._platform.persistence.deleteUser(this.id, sessionId).catch(() => null);
|
|
474
|
-
await this._broadcast({
|
|
475
|
-
message: { type: "$exit", data: { sessionId } },
|
|
476
|
-
senderId: sessionId,
|
|
477
|
-
});
|
|
478
|
-
|
|
479
|
-
const session = webSocket.session;
|
|
480
|
-
const user = session.user;
|
|
481
|
-
|
|
482
|
-
if (!!user) this._removeUserSession(user.id, sessionId);
|
|
483
|
-
|
|
484
|
-
try {
|
|
485
|
-
const [doc, context] = await Promise.all([this._doc, this._getContext()]);
|
|
486
|
-
const encodedState = doc.getEncodedState();
|
|
487
|
-
|
|
488
|
-
await Promise.resolve(
|
|
489
|
-
this._listeners.onUserDisconnected({
|
|
490
|
-
context,
|
|
491
|
-
encodedState,
|
|
492
|
-
platform: this._platform,
|
|
493
|
-
room: this.id,
|
|
494
|
-
user,
|
|
495
|
-
}),
|
|
496
|
-
);
|
|
497
|
-
} catch (error) {
|
|
498
|
-
console.error(error);
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
this._logDebug(
|
|
502
|
-
`${colors.blue(`Unregistered connection for room ${this.id}:`)} ${webSocket.sessionId}`,
|
|
503
|
-
);
|
|
504
|
-
};
|
|
505
|
-
|
|
506
|
-
const promises = webSockets.map(async (webSocket) => {
|
|
507
|
-
await closeWebSocket(webSocket).catch(() => null);
|
|
508
|
-
});
|
|
509
|
-
|
|
510
|
-
await Promise.all(promises).catch(() => null);
|
|
511
|
-
|
|
512
|
-
const size = this.getSize();
|
|
513
|
-
|
|
514
|
-
this._logDebug(`${colors.blue(`Room ${this.id} size:`)} ${size}`);
|
|
515
|
-
|
|
516
|
-
if (!!size) return;
|
|
517
|
-
if (!this._uninitialize) return;
|
|
518
|
-
|
|
519
|
-
const uninitialize = (await this._uninitialize).bind(this);
|
|
520
|
-
await uninitialize();
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
private async _emitQuitters(): Promise<void> {
|
|
524
|
-
const currentTime = new Date().getTime();
|
|
525
|
-
const quitters = Array.from(this._sessions.values()).filter((pluvWs) => {
|
|
526
|
-
return pluvWs.state.quit || currentTime - pluvWs.state.timers.ping > PING_TIMEOUT_MS;
|
|
527
|
-
});
|
|
528
|
-
|
|
529
|
-
this._closeWebSockets(quitters);
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
private async _emitRegistered(pluvWs: AbstractWebSocket<any, TAuthorize>): Promise<void> {
|
|
533
|
-
const session = pluvWs.session;
|
|
534
|
-
const sessionId = session.id;
|
|
535
|
-
const presence = session.presence;
|
|
536
|
-
const user = session.user;
|
|
537
|
-
|
|
538
|
-
const [doc, context] = await Promise.all([this._doc, this._getContext()]);
|
|
539
|
-
const encodedState = doc.isEmpty() ? null : doc.getEncodedState();
|
|
540
|
-
|
|
541
|
-
await this._sendSelfMessage(
|
|
542
|
-
{
|
|
543
|
-
type: "$registered",
|
|
544
|
-
data: {
|
|
545
|
-
presence,
|
|
546
|
-
sessionId,
|
|
547
|
-
state: encodedState,
|
|
548
|
-
timers: { presence: session.timers.presence },
|
|
549
|
-
},
|
|
550
|
-
},
|
|
551
|
-
{ sessionId, user },
|
|
552
|
-
);
|
|
553
|
-
|
|
554
|
-
try {
|
|
555
|
-
await Promise.resolve(
|
|
556
|
-
this._listeners.onUserConnected({
|
|
557
|
-
context,
|
|
558
|
-
encodedState,
|
|
559
|
-
platform: this._platform,
|
|
560
|
-
room: this.id,
|
|
561
|
-
user,
|
|
562
|
-
webSocket: pluvWs.webSocket,
|
|
563
|
-
}),
|
|
564
|
-
);
|
|
565
|
-
} catch (err) {
|
|
566
|
-
console.error(err);
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
private async _emitSyncState(): Promise<void> {
|
|
571
|
-
const connectionIds = await this._platform.persistence
|
|
572
|
-
.getUsers(this.id)
|
|
573
|
-
.then((map) => Object.keys(map));
|
|
574
|
-
|
|
575
|
-
await this._broadcast({
|
|
576
|
-
message: {
|
|
577
|
-
type: "$syncStateReceived",
|
|
578
|
-
data: { connectionIds },
|
|
579
|
-
},
|
|
580
|
-
});
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
private _ensureDetached(): void {
|
|
584
|
-
if (this._platform._config.registrationMode === "detached") return;
|
|
585
|
-
|
|
586
|
-
throw new Error("Platform must use detached mode");
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
private _getAbstractWs(
|
|
590
|
-
webSocket: WebSocketType<TPlatform>,
|
|
591
|
-
): AbstractWebSocket<any, TAuthorize> | null {
|
|
592
|
-
if ((webSocket as unknown as any) instanceof AbstractWebSocket) return webSocket;
|
|
593
|
-
|
|
594
|
-
const sessionId = this._platform.getSessionId(webSocket);
|
|
595
|
-
|
|
596
|
-
if (typeof sessionId === "string") {
|
|
597
|
-
const session = this._sessions.get(sessionId) ?? null;
|
|
598
|
-
|
|
599
|
-
if (session) return session;
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
const sessions = Array.from(this._sessions.values());
|
|
603
|
-
|
|
604
|
-
return sessions.find((pluvWs) => pluvWs.webSocket === webSocket) ?? null;
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
/**
|
|
608
|
-
* TODO
|
|
609
|
-
* @description Revisit any cast types
|
|
610
|
-
* @date July 17, 2025
|
|
611
|
-
*/
|
|
612
|
-
private async _getAuthorizedUser(
|
|
613
|
-
token: Maybe<string>,
|
|
614
|
-
options: WebSocketRegisterConfig<TPlatform>,
|
|
615
|
-
): Promise<InferIOAuthorizeUser<TAuthorize>> {
|
|
616
|
-
const ioAuthorize = this._getIOAuthorize(options);
|
|
617
|
-
|
|
618
|
-
if (!ioAuthorize) return null as any;
|
|
619
|
-
if (!token) return null as any;
|
|
620
|
-
|
|
621
|
-
if (!ioAuthorize.secret)
|
|
622
|
-
throw new Error("`authorize` was specified without a valid secret");
|
|
623
|
-
|
|
624
|
-
const payload = await authorize({
|
|
625
|
-
platform: this._platform,
|
|
626
|
-
secret: ioAuthorize.secret,
|
|
627
|
-
}).decode(token);
|
|
628
|
-
|
|
629
|
-
if (!payload) {
|
|
630
|
-
this._logDebug(colors.blue("Could not decode token:"));
|
|
631
|
-
this._logDebug(token);
|
|
632
|
-
|
|
633
|
-
return null as any;
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
if (payload.room !== this.id) {
|
|
637
|
-
this._logDebug(colors.blue(`Token is not authorized for room ${this.id}:`));
|
|
638
|
-
this._logDebug(colors.blue("Received:"), payload.room);
|
|
639
|
-
this._logDebug(token);
|
|
640
|
-
|
|
641
|
-
return null as any;
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
try {
|
|
645
|
-
return ioAuthorize.user.parse(payload.user) ?? null;
|
|
646
|
-
} catch {
|
|
647
|
-
this._logDebug(`${colors.blue("Token fails validation:")} ${token}`);
|
|
648
|
-
|
|
649
|
-
return null as any;
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
private async _getContext(): Promise<TContext> {
|
|
654
|
-
return typeof this._context === "function"
|
|
655
|
-
? await Promise.resolve(this._context(this._roomContext))
|
|
656
|
-
: await Promise.resolve(this._context);
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
private async _getInitialDoc(): Promise<CrdtDocLike<any, any>> {
|
|
660
|
-
const doc = this._docFactory.getEmpty();
|
|
661
|
-
|
|
662
|
-
const [encodedState, context] = await Promise.all([
|
|
663
|
-
this._platform.persistence.getStorageState(this.id),
|
|
664
|
-
this._getContext(),
|
|
665
|
-
]);
|
|
666
|
-
|
|
667
|
-
if (!encodedState) {
|
|
668
|
-
const loadedState = await this._getInitialStorage({
|
|
669
|
-
context,
|
|
670
|
-
room: this.id,
|
|
671
|
-
});
|
|
672
|
-
|
|
673
|
-
if (!!loadedState && !this._docFactory.isEmpty(loadedState)) {
|
|
674
|
-
doc.applyEncodedState({ update: loadedState });
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
if (typeof encodedState === "string") {
|
|
679
|
-
doc.applyEncodedState({ update: encodedState });
|
|
680
|
-
|
|
681
|
-
// If we loaded storage from persistence and it's non-empty, storage was previously initialized.
|
|
682
|
-
// This handles hibernation wake-up: restore the initialization state so that onStorageDestroyed
|
|
683
|
-
// will fire correctly when the room is destroyed.
|
|
684
|
-
if (!doc.isEmpty()) {
|
|
685
|
-
this._storageInitializedViaSession = true;
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
return doc;
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
private _getIOAuthorize(
|
|
693
|
-
options: WebSocketRegisterConfig<TPlatform>,
|
|
694
|
-
): ResolvedPluvIOAuthorize<any, any> | null {
|
|
695
|
-
if (typeof this._authorize === "function") return this._authorize(options);
|
|
696
|
-
|
|
697
|
-
return this._authorize as ResolvedPluvIOAuthorize<any, any> | null;
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
private _getLatestPresence(userId: string): {
|
|
701
|
-
timer: number | null;
|
|
702
|
-
presence: JsonObject | null;
|
|
703
|
-
} {
|
|
704
|
-
if (!this._authorize) return { timer: null, presence: null };
|
|
705
|
-
|
|
706
|
-
const sessionIds = Array.from(this._userSessionss.get(userId)?.values() ?? []);
|
|
707
|
-
|
|
708
|
-
if (!sessionIds.length) return { timer: null, presence: null };
|
|
709
|
-
|
|
710
|
-
return sessionIds.reduce(
|
|
711
|
-
(state, sessionId) => {
|
|
712
|
-
const pluvWs = this._sessions.get(sessionId) ?? null;
|
|
713
|
-
|
|
714
|
-
if (!pluvWs) return state;
|
|
715
|
-
|
|
716
|
-
const session = pluvWs.session;
|
|
717
|
-
const presence = session.presence;
|
|
718
|
-
const timer = session.timers.presence;
|
|
719
|
-
|
|
720
|
-
if (session.user?.id !== userId) return state;
|
|
721
|
-
if (typeof state.timer !== "number") return { presence, timer };
|
|
722
|
-
if (typeof timer !== "number") return state;
|
|
723
|
-
|
|
724
|
-
return timer > state.timer ? { presence, timer } : state;
|
|
725
|
-
},
|
|
726
|
-
{ presence: null, timer: null } as {
|
|
727
|
-
presence: JsonObject | null;
|
|
728
|
-
timer: number | null;
|
|
729
|
-
},
|
|
730
|
-
);
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
private _getProcedure(
|
|
734
|
-
message: EventMessage<string, any>,
|
|
735
|
-
):
|
|
736
|
-
| (typeof this._router)["_defs"]["events"][keyof (typeof this._router)["_defs"]["events"]]
|
|
737
|
-
| null {
|
|
738
|
-
return (
|
|
739
|
-
this._router._defs.events[
|
|
740
|
-
message.type as keyof (typeof this._router)["_defs"]["events"]
|
|
741
|
-
] ?? null
|
|
742
|
-
);
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
private _getProcedureInputs(
|
|
746
|
-
message: EventMessage<string, any>,
|
|
747
|
-
): InferIOInput<this>[keyof TEvents] {
|
|
748
|
-
const procedure = this._getProcedure(message);
|
|
749
|
-
|
|
750
|
-
if (!procedure) return message.data;
|
|
751
|
-
|
|
752
|
-
return procedure.config.input ? procedure.config.input.parse(message.data) : message.data;
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
private _getSession(webSocket: WebSocketType<TPlatform>): WebSocketSession<TAuthorize> {
|
|
756
|
-
const pluvWs = this._getAbstractWs(webSocket);
|
|
757
|
-
|
|
758
|
-
if (!pluvWs) throw new Error("Session could not be found");
|
|
759
|
-
|
|
760
|
-
return pluvWs.session;
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
private _getSessions() {
|
|
764
|
-
const sessions = Array.from(this._sessions.values()).map((pluvWs) => pluvWs.session);
|
|
765
|
-
|
|
766
|
-
return sessions;
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
private _initialize() {
|
|
770
|
-
const promise = (async () => {
|
|
771
|
-
this._logDebug(`${colors.blue(`Initializing room: ${this.id}`)}`);
|
|
772
|
-
|
|
773
|
-
if (!!this._uninitialize) {
|
|
774
|
-
const uninitialize = (await this._uninitialize).bind(this);
|
|
775
|
-
await uninitialize();
|
|
776
|
-
|
|
777
|
-
this._uninitialize = null;
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
const pubSubId = await this._platform.pubSub.subscribe(
|
|
781
|
-
this.id,
|
|
782
|
-
async ({ options = {}, ...message }): Promise<void> => {
|
|
783
|
-
await promise;
|
|
784
|
-
|
|
785
|
-
const sender: SendMessageSender = {
|
|
786
|
-
sessionId: message.connectionId,
|
|
787
|
-
user: message.user,
|
|
788
|
-
};
|
|
789
|
-
|
|
790
|
-
switch (options.type) {
|
|
791
|
-
case "self": {
|
|
792
|
-
await this._sendSelfMessage(message, sender);
|
|
793
|
-
return;
|
|
794
|
-
}
|
|
795
|
-
case "sync": {
|
|
796
|
-
await this._sendSyncMessage(message, sender);
|
|
797
|
-
return;
|
|
798
|
-
}
|
|
799
|
-
case "broadcast":
|
|
800
|
-
default: {
|
|
801
|
-
const sessionIds = options.sessionIds;
|
|
802
|
-
|
|
803
|
-
await this._sendBroadcastMessage(message, sender, sessionIds);
|
|
804
|
-
}
|
|
805
|
-
}
|
|
806
|
-
},
|
|
807
|
-
);
|
|
808
|
-
|
|
809
|
-
const doc = await this._getInitialDoc();
|
|
810
|
-
|
|
811
|
-
// Track if doc was empty when room was first initialized
|
|
812
|
-
this._wasDocEmptyOnInit = doc.isEmpty();
|
|
813
|
-
|
|
814
|
-
const uninitialize = async () => {
|
|
815
|
-
this._platform.pubSub.unsubscribe(pubSubId);
|
|
816
|
-
|
|
817
|
-
const [doc, context] = await Promise.all([this._doc, this._getContext()]);
|
|
818
|
-
const encodedState = doc.getEncodedState();
|
|
819
|
-
|
|
820
|
-
doc.destroy();
|
|
821
|
-
this._doc = Promise.resolve(this._docFactory.getEmpty());
|
|
822
|
-
|
|
823
|
-
// Always emit onRoomDestroyed
|
|
824
|
-
await Promise.resolve(
|
|
825
|
-
this._listeners.onRoomDestroyed({
|
|
826
|
-
...("_meta" in this._platform && !!this._platform._meta
|
|
827
|
-
? { _meta: this._platform._meta }
|
|
828
|
-
: {}),
|
|
829
|
-
context,
|
|
830
|
-
platform: this._platform,
|
|
831
|
-
room: this.id,
|
|
832
|
-
}),
|
|
833
|
-
);
|
|
834
|
-
|
|
835
|
-
// Only emit onStorageDestroyed if storage was initialized via initializeSession
|
|
836
|
-
if (this._storageInitializedViaSession) {
|
|
837
|
-
await Promise.resolve(
|
|
838
|
-
this._listeners.onStorageDestroyed({
|
|
839
|
-
...("_meta" in this._platform && !!this._platform._meta
|
|
840
|
-
? { _meta: this._platform._meta }
|
|
841
|
-
: {}),
|
|
842
|
-
context,
|
|
843
|
-
encodedState,
|
|
844
|
-
platform: this._platform,
|
|
845
|
-
room: this.id,
|
|
846
|
-
}),
|
|
847
|
-
);
|
|
848
|
-
|
|
849
|
-
this._storageInitializedViaSession = false;
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
this._uninitialize = null;
|
|
853
|
-
};
|
|
854
|
-
|
|
855
|
-
return { doc, uninitialize };
|
|
856
|
-
})();
|
|
857
|
-
|
|
858
|
-
this._doc = promise.then((result) => result.doc);
|
|
859
|
-
this._uninitialize = promise.then((result) => result.uninitialize);
|
|
860
|
-
|
|
861
|
-
return { doc: this._doc, uninitialize: this._uninitialize };
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
private _logDebug(...data: any[]): void {
|
|
865
|
-
if (this._debug) console.log(...data);
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
private _onClose(webSocket: AbstractWebSocket<any, TAuthorize>): () => Promise<void> {
|
|
869
|
-
return async (): Promise<void> => {
|
|
870
|
-
if (!(await this._initialized)) return;
|
|
871
|
-
await this._closeWebSockets([webSocket]);
|
|
872
|
-
};
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
private _onMessage(
|
|
876
|
-
webSocket: AbstractWebSocket<any, TAuthorize>,
|
|
877
|
-
): (event: AbstractMessageEvent) => Promise<void> {
|
|
878
|
-
return async (event: AbstractMessageEvent): Promise<void> => {
|
|
879
|
-
if (!(await this._initialized)) return;
|
|
880
|
-
|
|
881
|
-
const pluvWs = this._getAbstractWs(webSocket as WebSocketType<TPlatform>);
|
|
882
|
-
|
|
883
|
-
if (!pluvWs) throw new Error("Could not get session");
|
|
884
|
-
|
|
885
|
-
const session = this._getSession(pluvWs as WebSocketType<TPlatform>);
|
|
886
|
-
const sessions = this._getSessions();
|
|
887
|
-
|
|
888
|
-
const setPresence = (params: PatchPresenceParams): void => {
|
|
889
|
-
this._setPresence.bind(this)(params);
|
|
890
|
-
};
|
|
891
|
-
|
|
892
|
-
const [doc, context] = await Promise.all([this._doc, this._getContext()]);
|
|
893
|
-
const eventContext: EventResolverContext<
|
|
894
|
-
EventResolverKind,
|
|
895
|
-
TPlatform,
|
|
896
|
-
TAuthorize,
|
|
897
|
-
TContext
|
|
898
|
-
> = {
|
|
899
|
-
context,
|
|
900
|
-
doc,
|
|
901
|
-
garbageCollect: async () => {
|
|
902
|
-
await this.garbageCollect();
|
|
903
|
-
},
|
|
904
|
-
platform: this._platform,
|
|
905
|
-
get presence() {
|
|
906
|
-
return session.presence as JsonObject | null;
|
|
907
|
-
},
|
|
908
|
-
set presence(presence: JsonObject | null) {
|
|
909
|
-
const sessionId = this.session?.id;
|
|
910
|
-
|
|
911
|
-
if (!sessionId) return;
|
|
912
|
-
|
|
913
|
-
setPresence({ presence, sessionId, timer: this.time });
|
|
914
|
-
},
|
|
915
|
-
room: this.id,
|
|
916
|
-
session,
|
|
917
|
-
sessions,
|
|
918
|
-
time: new Date().getTime(),
|
|
919
|
-
};
|
|
920
|
-
|
|
921
|
-
if (pluvWs.state.quit) {
|
|
922
|
-
await this._closeWebSockets([pluvWs]).catch(() => null);
|
|
923
|
-
pluvWs.close(1011, "WebSocket broken.");
|
|
924
|
-
|
|
925
|
-
return;
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
const message = this._parseMessage(event);
|
|
929
|
-
|
|
930
|
-
if (!message) return;
|
|
931
|
-
|
|
932
|
-
const procedure = this._getProcedure(message);
|
|
933
|
-
|
|
934
|
-
this._listeners.onMessage({
|
|
935
|
-
...("_meta" in this._platform && !!this._platform._meta
|
|
936
|
-
? { _meta: this._platform._meta }
|
|
937
|
-
: {}),
|
|
938
|
-
context,
|
|
939
|
-
encodedState: doc.getEncodedState(),
|
|
940
|
-
message: message as InferEventMessage<
|
|
941
|
-
InferEventsOutput<TEvents>,
|
|
942
|
-
keyof InferEventsOutput<TEvents>
|
|
943
|
-
>,
|
|
944
|
-
platform: this._platform,
|
|
945
|
-
room: this.id,
|
|
946
|
-
user: session.user,
|
|
947
|
-
webSocket: session.webSocket.webSocket,
|
|
948
|
-
});
|
|
949
|
-
|
|
950
|
-
const sessionId = session.id;
|
|
951
|
-
const user = session.user;
|
|
952
|
-
|
|
953
|
-
if (!procedure) {
|
|
954
|
-
await this._broadcast({
|
|
955
|
-
message: message as any,
|
|
956
|
-
senderId: sessionId,
|
|
957
|
-
});
|
|
958
|
-
|
|
959
|
-
return;
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
let inputs: InferIOInput<this>[keyof TEvents];
|
|
963
|
-
|
|
964
|
-
try {
|
|
965
|
-
inputs = this._getProcedureInputs(message);
|
|
966
|
-
} catch (error) {
|
|
967
|
-
pluvWs.handleError({
|
|
968
|
-
error,
|
|
969
|
-
message: "Invalid input",
|
|
970
|
-
room: this.id,
|
|
971
|
-
session,
|
|
972
|
-
});
|
|
973
|
-
|
|
974
|
-
return;
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
await Promise.all([
|
|
978
|
-
procedure.config.broadcast?.(inputs, eventContext),
|
|
979
|
-
procedure.config.self?.(inputs, eventContext),
|
|
980
|
-
procedure.config.sync?.(inputs, eventContext),
|
|
981
|
-
]).then(async ([broadcast, self, sync]) => {
|
|
982
|
-
const handleBroadcast = async () => {
|
|
983
|
-
if (!broadcast) return;
|
|
984
|
-
|
|
985
|
-
const messages = Object.entries(broadcast).map(async ([type, data]: any) => {
|
|
986
|
-
await this._broadcast({
|
|
987
|
-
message: { data, type },
|
|
988
|
-
senderId: sessionId,
|
|
989
|
-
});
|
|
990
|
-
});
|
|
991
|
-
|
|
992
|
-
await Promise.all(messages);
|
|
993
|
-
};
|
|
994
|
-
|
|
995
|
-
const handleSelf = async () => {
|
|
996
|
-
if (!self) return;
|
|
997
|
-
|
|
998
|
-
const messages = Object.entries(self).map(async ([type, data]) => {
|
|
999
|
-
await this._sendSelfMessage({ data, type }, { sessionId, user });
|
|
1000
|
-
});
|
|
1001
|
-
|
|
1002
|
-
await Promise.all(messages);
|
|
1003
|
-
};
|
|
1004
|
-
|
|
1005
|
-
const handleSync = async () => {
|
|
1006
|
-
if (!sync) return;
|
|
1007
|
-
|
|
1008
|
-
await this._platform.pubSub.publish(this.id, {
|
|
1009
|
-
connectionId: session.id,
|
|
1010
|
-
options: { type: "sync" },
|
|
1011
|
-
room: this.id,
|
|
1012
|
-
user,
|
|
1013
|
-
...message,
|
|
1014
|
-
});
|
|
1015
|
-
|
|
1016
|
-
const messages = Object.entries(sync).map(async ([type, data]) => {
|
|
1017
|
-
await this._sendSelfMessage({ data, type }, { sessionId, user });
|
|
1018
|
-
});
|
|
1019
|
-
|
|
1020
|
-
await Promise.all(messages);
|
|
1021
|
-
};
|
|
1022
|
-
|
|
1023
|
-
await Promise.all([handleBroadcast(), handleSelf(), handleSync()]);
|
|
1024
|
-
});
|
|
1025
|
-
|
|
1026
|
-
// After processing messages that might update storage (like $initializeSession),
|
|
1027
|
-
// check if storage was initialized via initializeSession
|
|
1028
|
-
await this._checkStorageInitializedViaSession();
|
|
1029
|
-
};
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
private _parseMessage(message: {
|
|
1033
|
-
data: string | ArrayBuffer;
|
|
1034
|
-
}): EventMessage<string, any> | null {
|
|
1035
|
-
try {
|
|
1036
|
-
const parsed = this._platform.parseData(message.data);
|
|
1037
|
-
|
|
1038
|
-
if (typeof parsed !== "object") return null;
|
|
1039
|
-
if (typeof parsed.type !== "string") return null;
|
|
1040
|
-
if (typeof parsed.data !== "object") return null;
|
|
1041
|
-
|
|
1042
|
-
return parsed as EventMessage<string, any>;
|
|
1043
|
-
} catch {
|
|
1044
|
-
return null;
|
|
1045
|
-
}
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
private _setPresence(params: PatchPresenceParams): void {
|
|
1049
|
-
const { presence, sessionId, timer: _timer } = params;
|
|
1050
|
-
|
|
1051
|
-
const timer = _timer ?? new Date().getTime();
|
|
1052
|
-
const pluvWs = this._sessions.get(sessionId) ?? null;
|
|
1053
|
-
|
|
1054
|
-
if (!pluvWs) return;
|
|
1055
|
-
|
|
1056
|
-
const wsSession = pluvWs.session;
|
|
1057
|
-
const user = wsSession.user as BaseUser | null;
|
|
1058
|
-
|
|
1059
|
-
const sessionIds = user
|
|
1060
|
-
? new Set<string>([...(this._userSessionss.get(user.id) ?? []), sessionId])
|
|
1061
|
-
: new Set([sessionId]);
|
|
1062
|
-
|
|
1063
|
-
sessionIds.forEach((sId) => {
|
|
1064
|
-
const pWs = this._sessions.get(sId);
|
|
1065
|
-
const session = pWs?.session;
|
|
1066
|
-
|
|
1067
|
-
if (!session) return;
|
|
1068
|
-
|
|
1069
|
-
const prevState = session.webSocket.state;
|
|
1070
|
-
|
|
1071
|
-
this._platform.setSerializedState(session.webSocket, {
|
|
1072
|
-
...prevState,
|
|
1073
|
-
presence,
|
|
1074
|
-
timers: {
|
|
1075
|
-
...prevState.timers,
|
|
1076
|
-
presence: timer,
|
|
1077
|
-
},
|
|
1078
|
-
});
|
|
1079
|
-
});
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
private _removeUserSession(
|
|
1083
|
-
userId: string,
|
|
1084
|
-
sessionId: string,
|
|
1085
|
-
): Set<[sessionId: string][0]> | null {
|
|
1086
|
-
const set = this._userSessionss.get(userId);
|
|
1087
|
-
|
|
1088
|
-
if (!set) return null;
|
|
1089
|
-
|
|
1090
|
-
set.delete(sessionId);
|
|
1091
|
-
|
|
1092
|
-
if (!!set.size) return set;
|
|
1093
|
-
|
|
1094
|
-
this._userSessionss.delete(userId);
|
|
1095
|
-
|
|
1096
|
-
return set;
|
|
1097
|
-
}
|
|
1098
|
-
|
|
1099
|
-
private async _checkStorageInitializedViaSession(): Promise<void> {
|
|
1100
|
-
if (this._storageInitializedViaSession) return;
|
|
1101
|
-
|
|
1102
|
-
// Only mark as initialized if:
|
|
1103
|
-
// 1. Doc was empty when room was first initialized
|
|
1104
|
-
// 2. Doc is now non-empty
|
|
1105
|
-
// 3. At least one session has been registered
|
|
1106
|
-
const doc = await this._doc;
|
|
1107
|
-
if (this._wasDocEmptyOnInit && !doc.isEmpty() && this._sessions.size > 0) {
|
|
1108
|
-
this._storageInitializedViaSession = true;
|
|
1109
|
-
}
|
|
1110
|
-
}
|
|
1111
|
-
|
|
1112
|
-
private async _sendMessage(
|
|
1113
|
-
pluvWs: AbstractWebSocket<any, TAuthorize>,
|
|
1114
|
-
message: IOEventMessage<any>,
|
|
1115
|
-
): Promise<void> {
|
|
1116
|
-
if (!(await this._initialized)) return;
|
|
1117
|
-
|
|
1118
|
-
await Promise.resolve(pluvWs.sendMessage(message));
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
private async _sendBroadcastMessage(
|
|
1122
|
-
message: EventMessage<string, any>,
|
|
1123
|
-
sender: SendMessageSender | null,
|
|
1124
|
-
sessionIds?: readonly string[],
|
|
1125
|
-
): Promise<void> {
|
|
1126
|
-
const { data, type } = message;
|
|
1127
|
-
const connectionId = sender?.sessionId ?? null;
|
|
1128
|
-
const room = this.id;
|
|
1129
|
-
|
|
1130
|
-
if (typeof connectionId !== "string") return;
|
|
1131
|
-
|
|
1132
|
-
const webSockets =
|
|
1133
|
-
sessionIds?.reduce((dict, id) => {
|
|
1134
|
-
const pluvWs = this._sessions.get(id);
|
|
1135
|
-
|
|
1136
|
-
return pluvWs ? dict.set(id, pluvWs) : dict;
|
|
1137
|
-
}, new Map<string, AbstractWebSocket<any, TAuthorize>>()) ?? this._sessions;
|
|
1138
|
-
|
|
1139
|
-
const promises = Array.from(webSockets.values()).map(async (pluvWs) => {
|
|
1140
|
-
const session = pluvWs.session;
|
|
1141
|
-
const user = session.user ?? null;
|
|
1142
|
-
|
|
1143
|
-
this._sendMessage(pluvWs, { connectionId, data, room, type, user });
|
|
1144
|
-
});
|
|
1145
|
-
|
|
1146
|
-
await Promise.all(promises);
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
private async _sendSelfMessage(
|
|
1150
|
-
message: EventMessage<string, any>,
|
|
1151
|
-
sender: SendMessageSender | null,
|
|
1152
|
-
): Promise<void> {
|
|
1153
|
-
const senderId = sender?.sessionId ?? null;
|
|
1154
|
-
|
|
1155
|
-
if (typeof senderId !== "string") return;
|
|
1156
|
-
|
|
1157
|
-
const pluvWs = this._sessions.get(senderId);
|
|
1158
|
-
|
|
1159
|
-
if (!pluvWs) return;
|
|
1160
|
-
|
|
1161
|
-
/**
|
|
1162
|
-
* @description Note that this will not fire for Cloudflare's hibernatable websockets
|
|
1163
|
-
* because the server will auto-respond with the $pong event without calling any
|
|
1164
|
-
* functions.
|
|
1165
|
-
* @date April 18, 2025
|
|
1166
|
-
*/
|
|
1167
|
-
if (message.type === "$pong") {
|
|
1168
|
-
const elapsedMs = Date.now() - this._lastGarbageCollectMs;
|
|
1169
|
-
|
|
1170
|
-
if (elapsedMs > GARBAGE_COLLECT_INTERVAL_MS) {
|
|
1171
|
-
await this.garbageCollect();
|
|
1172
|
-
}
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
const session = pluvWs.session;
|
|
1176
|
-
const user = session.user ?? null;
|
|
1177
|
-
|
|
1178
|
-
this._sendMessage(pluvWs, {
|
|
1179
|
-
connectionId: senderId,
|
|
1180
|
-
room: this.id,
|
|
1181
|
-
user,
|
|
1182
|
-
...message,
|
|
1183
|
-
});
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
private async _sendSyncMessage(
|
|
1187
|
-
message: EventMessage<string, any>,
|
|
1188
|
-
sender: SendMessageSender | null,
|
|
1189
|
-
): Promise<void> {
|
|
1190
|
-
if (!sender) return;
|
|
1191
|
-
|
|
1192
|
-
const connectionId = sender.sessionId;
|
|
1193
|
-
|
|
1194
|
-
if (typeof connectionId !== "string") return;
|
|
1195
|
-
|
|
1196
|
-
const [doc, context] = await Promise.all([this._doc, this._getContext()]);
|
|
1197
|
-
const resolverCtx: EventResolverContext<"sync", TPlatform, TAuthorize, TContext> = {
|
|
1198
|
-
context,
|
|
1199
|
-
doc,
|
|
1200
|
-
garbageCollect: async () => {
|
|
1201
|
-
await this.garbageCollect();
|
|
1202
|
-
},
|
|
1203
|
-
platform: this._platform,
|
|
1204
|
-
get presence() {
|
|
1205
|
-
return null;
|
|
1206
|
-
},
|
|
1207
|
-
set presence(presence: JsonObject | null) {
|
|
1208
|
-
return;
|
|
1209
|
-
},
|
|
1210
|
-
room: this.id,
|
|
1211
|
-
session: null,
|
|
1212
|
-
sessions: this._getSessions(),
|
|
1213
|
-
time: new Date().getTime(),
|
|
1214
|
-
};
|
|
1215
|
-
|
|
1216
|
-
const resolver = this._getProcedure(message)?.config.sync;
|
|
1217
|
-
|
|
1218
|
-
if (!resolver) return;
|
|
1219
|
-
|
|
1220
|
-
let inputs: InferIOInput<this>[keyof TEvents];
|
|
1221
|
-
|
|
1222
|
-
try {
|
|
1223
|
-
inputs = this._getProcedureInputs(message);
|
|
1224
|
-
} catch {
|
|
1225
|
-
return;
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
Promise.resolve(resolver(inputs, resolverCtx)).then((output) => {
|
|
1229
|
-
if (!output) return;
|
|
1230
|
-
|
|
1231
|
-
Object.keys(output).forEach((type: string) => {
|
|
1232
|
-
const data = output[type] ?? {};
|
|
1233
|
-
|
|
1234
|
-
this._platform.pubSub.publish(this.id, {
|
|
1235
|
-
connectionId,
|
|
1236
|
-
data,
|
|
1237
|
-
options: { type: "self" },
|
|
1238
|
-
room: this.id,
|
|
1239
|
-
type,
|
|
1240
|
-
user: (sender.user ?? null) as BaseUser | null,
|
|
1241
|
-
});
|
|
1242
|
-
});
|
|
1243
|
-
});
|
|
1244
|
-
}
|
|
1245
|
-
}
|