@pluv/io 2.1.0 → 2.2.1

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/src/PluvServer.ts CHANGED
@@ -7,6 +7,7 @@ import type {
7
7
  InferIOAuthorizeUser,
8
8
  JsonObject,
9
9
  Maybe,
10
+ NonNilProps,
10
11
  } from "@pluv/types";
11
12
  import colors from "kleur";
12
13
  import type {
@@ -50,12 +51,12 @@ export type PluvServerConfig<
50
51
  > = Partial<PluvIOListeners<TPlatform, TAuthorize, TContext, TEvents>> & {
51
52
  authorize?: TAuthorize;
52
53
  context?: PluvContext<TPlatform, TContext>;
53
- crdt?: { doc: (value: any) => AbstractCrdtDocFactory<any> };
54
+ crdt?: { doc: (value: any) => AbstractCrdtDocFactory<any, any> };
54
55
  debug?: boolean;
55
56
  getInitialStorage?: GetInitialStorageFn<TContext>;
56
57
  limits: PluvIOLimits;
57
58
  io: PluvIO<TPlatform, TAuthorize, TContext>;
58
- platform: TPlatform;
59
+ platform: () => TPlatform;
59
60
  router?: PluvRouter<TPlatform, TAuthorize, TContext, TEvents>;
60
61
  };
61
62
 
@@ -95,249 +96,268 @@ export class PluvServer<
95
96
  {
96
97
  public readonly version: string = __PLUV_VERSION as any;
97
98
 
98
- private readonly _authorize: TAuthorize = null as TAuthorize;
99
- private readonly _baseRouter: PluvRouter<TPlatform, TAuthorize, TContext, {}> = new PluvRouter({
100
- $getOthers: this._procedure.sync((data, { room, session, sessions }) => {
101
- const currentTime = Date.now();
99
+ private readonly _config: NonNilProps<
100
+ PluvServerConfig<TPlatform, TAuthorize, TContext, TEvents>
101
+ >;
102
102
 
103
- const others = sessions
104
- .filter((wsSession) => {
105
- if (wsSession.id === session?.id) return false;
106
- if (wsSession.quit) return false;
107
- if (currentTime - wsSession.timers.ping > PING_TIMEOUT_MS) return false;
103
+ public get fetch(): (...args: any[]) => Promise<any> {
104
+ const platform = this._config.platform();
108
105
 
109
- return true;
110
- })
111
- .reduce<{
112
- [connectionId: string]: {
113
- connectionId: string;
114
- room: string | null;
115
- user: JsonObject | null;
116
- };
117
- }>(
118
- (acc, { id, presence, timers, user }) => ({
119
- ...acc,
120
- [id]: {
121
- connectionId: id,
106
+ return ((...args: any[]): Promise<any> => {
107
+ if (!platform._fetch)
108
+ throw new Error(`\`${platform._name}\` does not support \`fetch\``);
109
+
110
+ return platform._fetch(...args);
111
+ }) as NonNullable<typeof platform._fetch>;
112
+ }
113
+
114
+ /**
115
+ * @ignore
116
+ * @readonly
117
+ * @deprecated Internal use only. Changes to this will never be marked as breaking.
118
+ */
119
+ public get _defs() {
120
+ return {
121
+ authorize: this._config.authorize,
122
+ context: this._config.context,
123
+ events: this._router._defs.events,
124
+ platform: this._config.platform(),
125
+ } as {
126
+ authorize: TAuthorize;
127
+ context: TContext;
128
+ events: TEvents;
129
+ platform: TPlatform;
130
+ };
131
+ }
132
+
133
+ private get _baseRouter(): PluvRouter<TPlatform, TAuthorize, TContext, {}> {
134
+ const listeners = this._getListeners();
135
+
136
+ return new PluvRouter({
137
+ $getOthers: this._procedure.sync((data, { room, session, sessions }) => {
138
+ const currentTime = Date.now();
139
+
140
+ const others = sessions
141
+ .filter((wsSession) => {
142
+ if (wsSession.id === session?.id) return false;
143
+ if (wsSession.quit) return false;
144
+ if (currentTime - wsSession.timers.ping > PING_TIMEOUT_MS) return false;
145
+
146
+ return true;
147
+ })
148
+ .reduce<{
149
+ [connectionId: string]: {
150
+ connectionId: string;
151
+ room: string | null;
152
+ user: JsonObject | null;
153
+ };
154
+ }>(
155
+ (acc, { id, presence, timers, user }) => ({
156
+ ...acc,
157
+ [id]: {
158
+ connectionId: id,
159
+ presence,
160
+ room,
161
+ timers: { presence: timers.presence },
162
+ user,
163
+ },
164
+ }),
165
+ {},
166
+ );
167
+
168
+ return { $othersReceived: { others } };
169
+ }),
170
+ $initializeSession: this._procedure
171
+ .broadcast((data, { session }) => {
172
+ const presence = (data as any)?.presence;
173
+ const timers = session.timers;
174
+
175
+ if (!session) return {};
176
+
177
+ session.presence = presence;
178
+
179
+ return {
180
+ $userJoined: {
181
+ connectionId: session.id,
182
+ user: session.user,
122
183
  presence,
123
- room,
124
184
  timers: { presence: timers.presence },
125
- user,
126
185
  },
127
- }),
128
- {},
129
- );
186
+ };
187
+ })
188
+ .self(async (data, { context, doc, platform, room, session }) => {
189
+ const oldState = await platform.persistence.getStorageState(room);
190
+ /**
191
+ * !HACK
192
+ * @description This is the frontend's initialStorage. We only want to
193
+ * apply this if the server's storage is empty (i.e. no initial storage
194
+ * has been applied)
195
+ */
196
+ const update = (data as any)?.update as Maybe<string>;
197
+
198
+ if (!oldState) {
199
+ const loadedState = await this._config.getInitialStorage?.({
200
+ context,
201
+ room,
202
+ });
130
203
 
131
- return { $othersReceived: { others } };
132
- }),
133
- $initializeSession: this._procedure
134
- .broadcast((data, { session }) => {
135
- const presence = (data as any)?.presence;
136
- const timers = session.timers;
204
+ if (!!loadedState) {
205
+ doc.applyEncodedState({ update: loadedState }).getEncodedState();
206
+ }
207
+ }
137
208
 
138
- if (!session) return {};
209
+ /**
210
+ * @description Storage was already initialized. Don't overwrite the current
211
+ * storage state with the incoming initial storage. Return what the current state
212
+ * is without changes.
213
+ * @date May 7, 2025
214
+ */
215
+ if (!doc.isEmpty()) {
216
+ const encodedState = doc.getEncodedState();
217
+
218
+ return {
219
+ $storageReceived: { changeKind: "unchanged", state: encodedState },
220
+ };
221
+ }
139
222
 
140
- session.presence = presence;
223
+ /**
224
+ * @description Storage was never initialized, and there is an incoming
225
+ * initialStorage from the client. Apply the initialStorage and persist the state
226
+ * as an update.
227
+ * @date May 7, 2025
228
+ */
229
+ if (!!update) {
230
+ const encodedState = doc.applyEncodedState({ update }).getEncodedState();
231
+ const storageSize = new TextEncoder().encode(encodedState).length;
232
+
233
+ if (
234
+ !!this._config.limits.storageMaxSize &&
235
+ storageSize > this._config.limits.storageMaxSize
236
+ ) {
237
+ throw new Error("Storage has exceeded the size limit");
238
+ }
239
+
240
+ await platform.persistence
241
+ .setStorageState(room, encodedState)
242
+ .catch((error) => {
243
+ this._logDebug(error);
244
+ });
245
+
246
+ listeners.onStorageUpdated({
247
+ context,
248
+ encodedState,
249
+ platform,
250
+ room,
251
+ user: session?.user,
252
+ webSocket: session?.webSocket.webSocket,
253
+ });
141
254
 
142
- return {
143
- $userJoined: {
144
- connectionId: session.id,
145
- user: session.user,
146
- presence,
147
- timers: { presence: timers.presence },
148
- },
149
- };
150
- })
151
- .self(async (data, { context, doc, room, session }) => {
152
- const oldState = await this._platform.persistence.getStorageState(room);
153
- /**
154
- * !HACK
155
- * @description This is the frontend's initialStorage. We only want to
156
- * apply this if the server's storage is empty (i.e. no initial storage
157
- * has been applied)
158
- */
159
- const update = (data as any)?.update as Maybe<string>;
160
-
161
- if (!oldState) {
162
- const loadedState = await this._getInitialStorage?.({ context, room });
163
-
164
- if (!!loadedState) {
165
- doc.applyEncodedState({ update: loadedState }).getEncodedState();
255
+ return {
256
+ $storageReceived: { changeKind: "initialized", state: encodedState },
257
+ };
166
258
  }
167
- }
168
259
 
169
- /**
170
- * @description Storage was already initialized. Don't overwrite the current
171
- * storage state with the incoming initial storage. Return what the current state
172
- * is without changes.
173
- * @date May 7, 2025
174
- */
175
- if (!doc.isEmpty()) {
176
260
  const encodedState = doc.getEncodedState();
177
261
 
178
- return { $storageReceived: { changeKind: "unchanged", state: encodedState } };
179
- }
262
+ return { $storageReceived: { changeKind: "empty", state: encodedState } };
263
+ }),
264
+ $ping: this._procedure.self((data, { platform, session }) => {
265
+ if (!session) return {};
180
266
 
181
- /**
182
- * @description Storage was never initialized, and there is an incoming
183
- * initialStorage from the client. Apply the initialStorage and persist the state
184
- * as an update.
185
- * @date May 7, 2025
186
- */
187
- if (!!update) {
188
- const encodedState = doc.applyEncodedState({ update }).getEncodedState();
189
- const storageSize = new TextEncoder().encode(encodedState).length;
190
-
191
- if (
192
- !!this._limits.storageMaxSize &&
193
- storageSize > this._limits.storageMaxSize
194
- ) {
195
- throw new Error("Storage has exceeded the size limit");
196
- }
267
+ const currentTime = new Date().getTime();
268
+ const prevState = session.webSocket.state;
197
269
 
198
- await this._platform.persistence
199
- .setStorageState(room, encodedState)
200
- .catch((error) => {
201
- this._logDebug(error);
202
- });
270
+ platform.setSerializedState(session.webSocket, {
271
+ ...prevState,
272
+ timers: {
273
+ ...prevState.timers,
274
+ ping: currentTime,
275
+ },
276
+ });
203
277
 
204
- this._getListeners().onStorageUpdated({
205
- context,
206
- encodedState,
207
- room,
208
- user: session?.user,
209
- webSocket: session?.webSocket.webSocket,
210
- });
278
+ return { $pong: {} };
279
+ }),
280
+ $updatePresence: this._procedure.broadcast((data, context) => {
281
+ const presence = (data as any)?.presence;
282
+ const { session } = context;
211
283
 
212
- return { $storageReceived: { changeKind: "initialized", state: encodedState } };
284
+ if (!session) return {};
285
+
286
+ const cleanedPatch = pickBy(
287
+ presence ?? {},
288
+ (value) => typeof value !== "undefined",
289
+ );
290
+ const updated = Object.assign(Object.create(null), session.presence, cleanedPatch);
291
+ const bytes = new TextEncoder().encode(JSON.stringify(updated)).length;
292
+
293
+ if (
294
+ !!this._config.limits.presenceMaxSize &&
295
+ bytes > this._config.limits.presenceMaxSize
296
+ ) {
297
+ throw new Error(
298
+ `Large presence. Presence must be at most 512 bytes. Current size: ${bytes.toLocaleString()}`,
299
+ );
213
300
  }
214
301
 
215
- const encodedState = doc.getEncodedState();
302
+ context.presence = updated;
216
303
 
217
- return { $storageReceived: { changeKind: "empty", state: encodedState } };
304
+ return {
305
+ $presenceUpdated: {
306
+ presence: updated,
307
+ timers: { presence: session.timers.presence },
308
+ },
309
+ };
218
310
  }),
219
- $ping: this._procedure.self((data, { session }) => {
220
- if (!session) return {};
221
-
222
- const currentTime = new Date().getTime();
223
- const prevState = session.webSocket.state;
224
-
225
- this._platform.setSerializedState(session.webSocket, {
226
- ...prevState,
227
- timers: {
228
- ...prevState.timers,
229
- ping: currentTime,
230
- },
231
- });
232
-
233
- return { $pong: {} };
234
- }),
235
- $updatePresence: this._procedure.broadcast((data, context) => {
236
- const presence = (data as any)?.presence;
237
- const { session } = context;
238
-
239
- if (!session) return {};
240
-
241
- const cleanedPatch = pickBy(presence ?? {}, (value) => typeof value !== "undefined");
242
- const updated = Object.assign(Object.create(null), session.presence, cleanedPatch);
243
- const bytes = new TextEncoder().encode(JSON.stringify(updated)).length;
244
-
245
- if (!!this._limits.presenceMaxSize && bytes > this._limits.presenceMaxSize) {
246
- throw new Error(
247
- `Large presence. Presence must be at most 512 bytes. Current size: ${bytes.toLocaleString()}`,
248
- );
249
- }
250
-
251
- context.presence = updated;
252
-
253
- return {
254
- $presenceUpdated: {
255
- presence: updated,
256
- timers: { presence: session.timers.presence },
257
- },
258
- };
259
- }),
260
- $updateStorage: this._procedure.broadcast((data, { context, doc, room }) => {
261
- const origin = (data as any)?.origin as Maybe<string>;
262
- const update: string | null = (data as any)?.update ?? null;
263
-
264
- if (origin === "$initialized" && Object.keys(doc.toJson()).length) return {};
265
-
266
- const updated = update === null ? doc : doc.applyEncodedState({ update });
267
- const encodedState = updated.getEncodedState();
268
- const storageSize = new TextEncoder().encode(encodedState).length;
269
-
270
- if (!!this._limits.storageMaxSize && storageSize > this._limits.storageMaxSize) {
271
- throw new Error("Storage has exceeded the size limit");
272
- }
273
-
274
- this._platform.persistence.setStorageState(room, encodedState).then(() => {
275
- this._getListeners().onStorageUpdated({
276
- context,
277
- encodedState,
278
- room,
279
- });
280
- });
311
+ $updateStorage: this._procedure.broadcast((data, { context, doc, platform, room }) => {
312
+ const origin = (data as any)?.origin as Maybe<string>;
313
+ const update: string | null = (data as any)?.update ?? null;
281
314
 
282
- return { $storageUpdated: { state: encodedState } };
283
- }),
284
- });
285
- private readonly _context: PluvContext<TPlatform, TContext> = {} as PluvContext<
286
- TPlatform,
287
- TContext
288
- >;
289
- private readonly _crdt: { doc: (value: any) => AbstractCrdtDocFactory<any> };
290
- private readonly _debug: boolean;
291
- private readonly _getInitialStorage: GetInitialStorageFn<TContext> | null = null;
292
- private readonly _io: PluvIO<TPlatform, TAuthorize, TContext>;
293
- private readonly _limits: PluvIOLimits;
294
- private readonly _platform: TPlatform;
295
- private readonly _router: PluvRouter<TPlatform, TAuthorize, TContext, TEvents>;
315
+ if (origin === "$initialized" && Object.keys(doc.toJson()).length) return {};
296
316
 
297
- public get fetch(): (...args: any[]) => Promise<any> {
298
- return ((...args: any[]): Promise<any> => {
299
- if (!this._platform._fetch)
300
- throw new Error(`\`${this._platform._name}\` does not support \`fetch\``);
317
+ const updated = update === null ? doc : doc.applyEncodedState({ update });
318
+ const encodedState = updated.getEncodedState();
319
+ const storageSize = new TextEncoder().encode(encodedState).length;
301
320
 
302
- return this._platform._fetch(...args);
303
- }) as NonNullable<typeof this._platform._fetch>;
304
- }
321
+ if (
322
+ !!this._config.limits.storageMaxSize &&
323
+ storageSize > this._config.limits.storageMaxSize
324
+ ) {
325
+ throw new Error("Storage has exceeded the size limit");
326
+ }
305
327
 
306
- /**
307
- * @ignore
308
- * @readonly
309
- * @deprecated Internal use only. Changes to this will never be marked as breaking.
310
- */
311
- public get _defs() {
312
- return {
313
- authorize: this._authorize,
314
- context: this._context,
315
- events: this._router._defs.events,
316
- platform: this._platform,
317
- } as {
318
- authorize: TAuthorize;
319
- context: TContext;
320
- events: TEvents;
321
- platform: TPlatform;
322
- };
328
+ platform.persistence.setStorageState(room, encodedState).then(() => {
329
+ listeners.onStorageUpdated({
330
+ context,
331
+ encodedState,
332
+ platform,
333
+ room,
334
+ });
335
+ });
336
+
337
+ return { $storageUpdated: { state: encodedState } };
338
+ }),
339
+ });
323
340
  }
324
341
 
325
342
  private get _procedure(): PluvProcedure<TPlatform, TAuthorize, TContext, {}, {}> {
326
343
  return new PluvProcedure();
327
344
  }
328
345
 
346
+ private get _router(): PluvRouter<TPlatform, TAuthorize, TContext, TEvents> {
347
+ return (
348
+ this._config.router
349
+ ? PluvRouter.merge(this._baseRouter, this._config.router)
350
+ : this._baseRouter
351
+ ) as PluvRouter<TPlatform, TAuthorize, TContext, TEvents>;
352
+ }
353
+
329
354
  constructor(options: PluvServerConfig<TPlatform, TAuthorize, TContext, TEvents>) {
330
- const {
331
- authorize,
332
- context,
333
- crdt = noop,
334
- debug = false,
335
- getInitialStorage,
336
- io,
337
- limits,
338
- platform,
339
- router = new PluvRouter<TPlatform, TAuthorize, TContext, TEvents>({} as TEvents),
340
- } = options;
355
+ this._config = {
356
+ crdt: noop,
357
+ debug: false,
358
+ router: new PluvRouter<TPlatform, TAuthorize, TContext, TEvents>({} as TEvents),
359
+ ...options,
360
+ } as NonNilProps<PluvServerConfig<TPlatform, TAuthorize, TContext, TEvents>>;
341
361
 
342
362
  const {
343
363
  onRoomDeleted,
@@ -347,21 +367,6 @@ export class PluvServer<
347
367
  onUserDisconnected,
348
368
  } = options as Partial<BasePluvIOListeners<TPlatform, TAuthorize, TContext, TEvents>>;
349
369
 
350
- platform.validateConfig(options);
351
-
352
- this._crdt = crdt;
353
- this._debug = debug;
354
- this._io = io;
355
- this._limits = limits;
356
- this._platform = platform;
357
- this._router = (
358
- router ? PluvRouter.merge(this._baseRouter, router) : this._baseRouter
359
- ) as PluvRouter<TPlatform, TAuthorize, TContext, TEvents>;
360
-
361
- if (authorize) this._authorize = authorize;
362
- if (context) this._context = context;
363
- if (getInitialStorage) this._getInitialStorage = getInitialStorage;
364
-
365
370
  (this as any)._listeners = {
366
371
  onRoomDeleted: (event) => onRoomDeleted?.(event),
367
372
  onRoomMessage: (event) => onRoomMessage?.(event),
@@ -378,8 +383,12 @@ export class PluvServer<
378
383
  const { _meta, debug, onDestroy, onMessage, ...platformRoomContext } = (options[0] ??
379
384
  {}) as CreateRoomOptions<TPlatform, TAuthorize, TContext, TEvents>[0] & { _meta?: any };
380
385
 
381
- if (this._platform._config.handleMode !== "io") {
382
- throw new Error(`\`createRoom\` is unsupported for \`${this._platform._name}\``);
386
+ const platform = this._config.platform();
387
+
388
+ platform.validateConfig(this._config);
389
+
390
+ if (platform._config.handleMode !== "io") {
391
+ throw new Error(`\`createRoom\` is unsupported for \`${platform._name}\``);
383
392
  }
384
393
 
385
394
  if (!/^[a-z0-9](?:[a-z0-9-_]*[a-z0-9])?$/i.test(room))
@@ -387,34 +396,38 @@ export class PluvServer<
387
396
 
388
397
  const roomContext = platformRoomContext as InferRoomContextType<TPlatform>;
389
398
  const context: TContext =
390
- typeof this._context === "function" ? this._context(roomContext) : this._context;
399
+ typeof this._config.context === "function"
400
+ ? this._config.context(roomContext)
401
+ : this._config.context;
402
+ const listeners = this._getListeners();
403
+ const logDebug = this._logDebug.bind(this);
391
404
 
392
405
  const newRoom = new IORoom<TPlatform, TAuthorize, TContext, TEvents>(room, {
393
406
  ...(!!_meta ? { _meta } : {}),
394
- authorize: this._authorize ?? undefined,
407
+ authorize: this._config.authorize ?? undefined,
395
408
  context,
396
- crdt: this._crdt,
397
- debug: debug ?? this._debug,
398
- onDestroy: async (event) => {
399
- this._logDebug(`${colors.blue("Deleting empty room:")} ${room}`);
409
+ crdt: this._config.crdt,
410
+ debug: debug ?? this._config.debug,
411
+ async onDestroy(event) {
412
+ logDebug(`${colors.blue("Deleting empty room:")} ${room}`);
400
413
 
401
414
  await Promise.resolve(onDestroy?.(event));
402
- await Promise.resolve(this._getListeners().onRoomDeleted(event));
403
- await this._platform.persistence.deleteStorageState(room);
415
+ await Promise.resolve(listeners.onRoomDeleted(event));
416
+ await event.platform.persistence.deleteStorageState(room);
404
417
 
405
- this._logDebug(`${colors.blue("Deleted room:")} ${room}`);
418
+ logDebug(`${colors.blue("Deleted room:")} ${room}`);
406
419
  },
407
- onMessage: async (event) => {
420
+ async onMessage(event) {
408
421
  await Promise.resolve(onMessage?.(event));
409
- await Promise.resolve(this._getListeners().onRoomMessage(event));
422
+ await Promise.resolve(listeners.onRoomMessage(event));
410
423
  },
411
- onUserConnected: async (event) => {
412
- await Promise.resolve(this._getListeners().onUserConnected(event));
424
+ async onUserConnected(event) {
425
+ await Promise.resolve(listeners.onUserConnected(event));
413
426
  },
414
- onUserDisconnected: async (event) => {
415
- await Promise.resolve(this._getListeners().onUserDisconnected(event));
427
+ async onUserDisconnected(event) {
428
+ await Promise.resolve(listeners.onUserDisconnected(event));
416
429
  },
417
- platform: this._platform,
430
+ platform,
418
431
  roomContext,
419
432
  router: this._router,
420
433
  });
@@ -427,7 +440,7 @@ export class PluvServer<
427
440
  public async createToken(
428
441
  params: JWTEncodeParams<InferIOAuthorizeUser<InferIOAuthorize<this>>, TPlatform>,
429
442
  ): Promise<string> {
430
- return await this._io.createToken(params);
443
+ return await this._config.io.createToken(params);
431
444
  }
432
445
 
433
446
  private _getListeners(): BasePluvIOListeners<TPlatform, TAuthorize, TContext, TEvents> {
@@ -435,6 +448,6 @@ export class PluvServer<
435
448
  }
436
449
 
437
450
  private _logDebug(...data: any[]): void {
438
- if (this._debug) console.log(...data);
451
+ if (this._config.debug) console.log(...data);
439
452
  }
440
453
  }
package/src/createIO.ts CHANGED
@@ -13,7 +13,7 @@ export type CreateIOParams<
13
13
  crdt?: CrdtLibraryType;
14
14
  debug?: boolean;
15
15
  limits?: PluvIOLimits;
16
- platform: TPlatform;
16
+ platform: () => TPlatform;
17
17
  };
18
18
 
19
19
  export const createIO = <