@verdant-web/server 4.1.0 → 4.2.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.
@@ -7,89 +7,125 @@ import {
7
7
  import { UserProfiles } from '../Profiles.js';
8
8
 
9
9
  export type PresenceEvents = {
10
- lost: (connectionKey: string, userId: string) => void;
10
+ lost: (userId: string) => void;
11
11
  };
12
12
 
13
- /**
14
- * Stores client presence in-memory for connected
15
- * clients
16
- */
17
- export class Presence extends EventSubscriber<PresenceEvents> {
18
- private presences: Record<string, UserInfo<any, any>> = {};
19
- private connectionToUser: Record<string, string> = {};
20
- private userToConnection: Record<string, Set<string>> = {};
21
- private keepalives = new Map<string, NodeJS.Timeout>();
13
+ export type PresenceStorageItem = UserInfo<any, any> & { expiresAt: number };
22
14
 
23
- constructor(readonly profiles: UserProfiles<any>) {
24
- super();
25
- }
15
+ export interface PresenceStorage {
16
+ set: (
17
+ userId: string,
18
+ userInfo: UserInfoUpdate,
19
+ profile: any,
20
+ expiresAt: number,
21
+ ) => Promise<UserInfo<any, any>>;
22
+ setExpiresAt: (userId: string, expiresAt: number) => Promise<void>;
23
+ get: (
24
+ userId: string,
25
+ time: number,
26
+ ) => Promise<UserInfo<any, any> | undefined>;
27
+ remove: (userId: string) => Promise<void>;
28
+ all: () => Promise<Record<string, UserInfo<any, any>>>;
29
+ clear: () => Promise<void>;
30
+ }
31
+
32
+ const DEFAULT_EXPIRATION_TIME = 60 * 1000;
33
+
34
+ export class PresenceMemoryStorage implements PresenceStorage {
35
+ private presences: Record<string, PresenceStorageItem> = {};
26
36
 
27
37
  set = async (
28
- connectionKey: string,
29
38
  userId: string,
30
39
  userInfo: UserInfoUpdate,
40
+ profile: any,
41
+ expiresAt: number,
31
42
  ) => {
32
43
  if (!this.presences[userId]) {
33
44
  this.presences[userId] = {
34
45
  ...userInfo,
35
- profile: await this.profiles.get(userId),
46
+ expiresAt,
47
+ profile,
36
48
  internal: userInfo.internal || initialInternalPresence,
37
49
  };
38
50
  } else {
39
51
  Object.assign(this.presences[userId], userInfo);
40
52
  }
41
- this.connectionToUser[connectionKey] = userId;
42
- this.userToConnection[userId] =
43
- this.userToConnection[userId] || new Set<string>();
44
- this.userToConnection[userId].add(connectionKey);
53
+ return this.presences[userId]!;
54
+ };
45
55
 
46
- this.keepAlive(connectionKey);
56
+ setExpiresAt = async (userId: string, expiresAt: number) => {
57
+ const presence = this.presences[userId];
58
+ if (presence) {
59
+ presence.expiresAt = expiresAt;
60
+ }
61
+ };
47
62
 
48
- return this.presences[userId]!;
63
+ get = async (userId: string, time: number) => {
64
+ const value = this.presences[userId];
65
+ if (value && value.expiresAt > time) {
66
+ return value;
67
+ }
68
+ return undefined;
49
69
  };
50
70
 
51
- keepAlive = (connectionKey: string, duration = 60 * 1000) => {
52
- const existing = this.keepalives.get(connectionKey);
53
- if (existing) {
54
- clearTimeout(existing);
71
+ remove = async (userId: string) => {
72
+ delete this.presences[userId];
73
+ if (Object.keys(this.presences).length === 0) {
74
+ this.clear();
55
75
  }
76
+ };
56
77
 
57
- this.keepalives.set(
58
- connectionKey,
59
- setTimeout(() => {
60
- this.removeConnection(connectionKey);
61
- this.keepalives.delete(connectionKey);
62
- }, duration),
63
- );
78
+ all = async () => {
79
+ return this.presences;
64
80
  };
65
81
 
66
- get = (userId: string) => {
67
- return this.presences[userId];
82
+ clear = async () => {
83
+ this.presences = {};
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Stores client presence for connected
89
+ * clients
90
+ */
91
+ export class Presence extends EventSubscriber<PresenceEvents> {
92
+ constructor(
93
+ readonly profiles: UserProfiles<any>,
94
+ readonly storage: PresenceStorage = new PresenceMemoryStorage(),
95
+ ) {
96
+ super();
97
+ }
98
+
99
+ set = async (userId: string, userInfo: UserInfoUpdate) => {
100
+ const value = await this.storage.set(
101
+ userId,
102
+ userInfo,
103
+ await this.profiles.get(userId),
104
+ Date.now() + DEFAULT_EXPIRATION_TIME,
105
+ );
106
+
107
+ return value;
68
108
  };
69
109
 
70
- removeConnection = (connectionKey: string) => {
71
- const userId = this.connectionToUser[connectionKey];
72
- if (!userId) return;
110
+ keepAlive = (userId: string, duration = DEFAULT_EXPIRATION_TIME) => {
111
+ const time = Date.now() + duration;
112
+ this.storage.setExpiresAt(userId, time);
113
+ };
73
114
 
74
- this.userToConnection[userId].delete(connectionKey);
75
- if (this.userToConnection[userId].size === 0) {
76
- delete this.presences[userId];
77
- this.emit('lost', connectionKey, userId);
115
+ get = (userId: string) => {
116
+ return this.storage.get(userId, Date.now());
117
+ };
78
118
 
79
- // memory cleanup
80
- if (Object.keys(this.presences).length === 0) {
81
- this.clear();
82
- }
83
- }
119
+ remove = async (userId: string) => {
120
+ await this.storage.remove(userId);
121
+ this.emit('lost', userId);
84
122
  };
85
123
 
86
124
  all = () => {
87
- return this.presences;
125
+ return this.storage.all();
88
126
  };
89
127
 
90
128
  clear = () => {
91
- this.presences = {};
92
- this.connectionToUser = {};
93
- this.userToConnection = {};
129
+ this.storage.clear();
94
130
  };
95
131
  }
package/src/internals.ts CHANGED
@@ -2,6 +2,7 @@ export { errorHandler } from './adapters/node/errorHandler.js';
2
2
  export { createHttpRouter as createHonoRouter } from './adapters/node/httpRouter.js';
3
3
  export { tokenMiddleware } from './adapters/node/tokenMiddleware.js';
4
4
  export { ClientConnectionManager } from './connections/ClientConnection.js';
5
+ export type * from './connections/Presence.js';
5
6
  export { FileStorageLibraryDelegate } from './files/FileStorage.js';
6
7
  export type * from './libraries/generic.js';
7
8
  export { Library, type LibraryEvents } from './libraries/Library.js';
@@ -21,7 +21,13 @@ const profiles = new UserProfileLoader<{ id: string; name: string }>({
21
21
  },
22
22
  });
23
23
 
24
- const baseOptions = async () => ({
24
+ const baseOptions = async ({
25
+ onBroadcast,
26
+ onRespond,
27
+ }: {
28
+ onBroadcast?: (message: ServerMessage, omitKeys?: string[]) => void;
29
+ onRespond?: (clientKey: string, message: ServerMessage) => void;
30
+ } = {}) => ({
25
31
  id: 'library-1',
26
32
  profiles,
27
33
  storage: await sqlShardStorage({
@@ -29,14 +35,22 @@ const baseOptions = async () => ({
29
35
  fileDeleteExpirationDays: 7,
30
36
  replicaTruancyMinutes: 60 * 24,
31
37
  })('library-1'),
32
- presence: new Presence({
33
- get: async (userId: string) => {
34
- return {
35
- id: userId,
36
- name: 'Alice',
37
- };
38
- },
39
- }),
38
+ clientConnections: {
39
+ presence: new Presence({
40
+ get: async (userId: string) => {
41
+ return {
42
+ id: userId,
43
+ name: 'Alice',
44
+ };
45
+ },
46
+ }),
47
+ broadcast: vi.fn((message: ServerMessage, omitKeys?: string[]) => {
48
+ if (onBroadcast) onBroadcast(message, omitKeys);
49
+ }),
50
+ respond: vi.fn((clientKey: string, message: ServerMessage) => {
51
+ if (onRespond) onRespond(clientKey, message);
52
+ }),
53
+ } as any,
40
54
  });
41
55
 
42
56
  let time = 0;
@@ -80,11 +94,6 @@ function getAllReceivedReplicaOperations(replica: TestReplica) {
80
94
  describe('Library', () => {
81
95
  describe('sync handling', () => {
82
96
  it('should initialize a new library from a replica', async () => {
83
- const sender: MessageSender = {
84
- broadcast: vi.fn(),
85
- respond: vi.fn((_: string, message: ServerMessage) => {}),
86
- };
87
-
88
97
  const tokenInfo: TokenInfo = {
89
98
  libraryId: 'library-1',
90
99
  syncEndpoint: 'http://localhost:3000/sync',
@@ -93,10 +102,9 @@ describe('Library', () => {
93
102
  userId: 'user-1',
94
103
  };
95
104
 
96
- const library = new Library({
97
- ...(await baseOptions()),
98
- sender,
99
- });
105
+ const options = await baseOptions();
106
+
107
+ const library = new Library(options);
100
108
 
101
109
  const messages: ClientMessage[] = [
102
110
  {
@@ -146,7 +154,7 @@ describe('Library', () => {
146
154
  );
147
155
 
148
156
  // expect sender.broadcast for the operations, and a reply with sync response
149
- expect(sender.broadcast).toHaveBeenCalledWith(
157
+ expect(options.clientConnections.broadcast).toHaveBeenCalledWith(
150
158
  {
151
159
  type: 'op-re',
152
160
  baselines: [
@@ -187,16 +195,19 @@ describe('Library', () => {
187
195
  ['clientKey-1'],
188
196
  );
189
197
 
190
- expect(sender.respond).toHaveBeenCalledWith('clientKey-1', {
191
- type: 'sync-resp',
192
- operations: [],
193
- baselines: [],
194
- ackThisNonce: undefined,
195
- ackedTimestamp: 'faketime-3',
196
- globalAckTimestamp: undefined,
197
- overwriteLocalData: false,
198
- peerPresence: {},
199
- });
198
+ expect(options.clientConnections.respond).toHaveBeenCalledWith(
199
+ 'clientKey-1',
200
+ {
201
+ type: 'sync-resp',
202
+ operations: [],
203
+ baselines: [],
204
+ ackThisNonce: undefined,
205
+ ackedTimestamp: 'faketime-3',
206
+ globalAckTimestamp: undefined,
207
+ overwriteLocalData: false,
208
+ peerPresence: {},
209
+ },
210
+ );
200
211
  });
201
212
 
202
213
  it('should sync up to latest data, while also rebroadcasting concurrent new operations', async () => {
@@ -222,11 +233,24 @@ describe('Library', () => {
222
233
  },
223
234
  };
224
235
 
225
- const library = new Library({
226
- ...(await baseOptions()),
227
- sender,
236
+ const options = await baseOptions({
237
+ onBroadcast: (message: ServerMessage, omitKeys?: string[]) => {
238
+ replicas.forEach((replica) => {
239
+ if (!omitKeys?.includes(replica.key)) {
240
+ replica.messages.push(message);
241
+ }
242
+ });
243
+ },
244
+ onRespond: (clientKey: string, message: ServerMessage) => {
245
+ const replica = replicas.find((r) => r.key === clientKey);
246
+ if (replica) {
247
+ replica.messages.push(message);
248
+ }
249
+ },
228
250
  });
229
251
 
252
+ const library = new Library(options);
253
+
230
254
  // replica A sends a sync message to initialize library
231
255
  const syncMessageA: ClientMessage = {
232
256
  type: 'sync',
@@ -323,11 +347,6 @@ describe('Library', () => {
323
347
  });
324
348
 
325
349
  it('should reject syncs with data from read replicas', async () => {
326
- const sender = {
327
- broadcast: vi.fn(),
328
- respond: vi.fn(),
329
- };
330
-
331
350
  const tokenInfo: TokenInfo = {
332
351
  libraryId: 'library-1',
333
352
  syncEndpoint: 'http://localhost:3000/sync',
@@ -336,10 +355,9 @@ describe('Library', () => {
336
355
  userId: 'user-1',
337
356
  };
338
357
 
339
- const library = new Library({
340
- ...(await baseOptions()),
341
- sender,
342
- });
358
+ const options = await baseOptions();
359
+
360
+ const library = new Library(options);
343
361
 
344
362
  const messages: ClientMessage[] = [
345
363
  {
@@ -388,18 +406,16 @@ describe('Library', () => {
388
406
  messages.map((m) => library.handleMessage(m, 'clientKey-1', tokenInfo)),
389
407
  );
390
408
 
391
- expect(sender.respond).toHaveBeenCalledOnce();
392
- expect(sender.respond).toHaveBeenCalledWith('clientKey-1', {
393
- type: 'forbidden',
394
- });
409
+ expect(options.clientConnections.respond).toHaveBeenCalledOnce();
410
+ expect(options.clientConnections.respond).toHaveBeenCalledWith(
411
+ 'clientKey-1',
412
+ {
413
+ type: 'forbidden',
414
+ },
415
+ );
395
416
  });
396
417
 
397
418
  it('should reject messages from a replica claimed by a user other than the one recorded on the server', async () => {
398
- const sender = {
399
- broadcast: vi.fn(),
400
- respond: vi.fn((_: string, message: ServerMessage) => {}),
401
- };
402
-
403
419
  const tokenInfo: TokenInfo = {
404
420
  libraryId: 'library-1',
405
421
  syncEndpoint: 'http://localhost:3000/sync',
@@ -408,10 +424,9 @@ describe('Library', () => {
408
424
  userId: 'user-1',
409
425
  };
410
426
 
411
- const library = new Library({
412
- ...(await baseOptions()),
413
- sender,
414
- });
427
+ const options = await baseOptions();
428
+
429
+ const library = new Library(options);
415
430
 
416
431
  // purposefully do not await in order - these all come at the same time
417
432
  await library.handleMessage(
@@ -427,7 +442,7 @@ describe('Library', () => {
427
442
  'clientKey-1',
428
443
  tokenInfo,
429
444
  );
430
- sender.respond.mockReset();
445
+ options.clientConnections.respond.mockReset();
431
446
 
432
447
  // now try to send a message from a different user with the same replica
433
448
  const tokenInfo2: TokenInfo = {
@@ -450,23 +465,19 @@ describe('Library', () => {
450
465
 
451
466
  await library.handleMessage(message, 'clientKey-1', tokenInfo2);
452
467
 
453
- expect(sender.respond).toHaveBeenCalledOnce();
454
- expect(sender.respond).toHaveBeenCalledWith('clientKey-1', {
455
- type: 'forbidden',
456
- });
468
+ expect(options.clientConnections.respond).toHaveBeenCalledOnce();
469
+ expect(options.clientConnections.respond).toHaveBeenCalledWith(
470
+ 'clientKey-1',
471
+ {
472
+ type: 'forbidden',
473
+ },
474
+ );
457
475
  });
458
476
  });
459
477
 
460
478
  it('should fully destroy itself', async () => {
461
- const sender = {
462
- broadcast: vi.fn(),
463
- respond: vi.fn(),
464
- };
465
-
466
- const library = new Library({
467
- ...(await baseOptions()),
468
- sender,
469
- });
479
+ const options = await baseOptions();
480
+ const library = new Library(options);
470
481
 
471
482
  const opMessage: ClientMessage = {
472
483
  type: 'op',
@@ -16,9 +16,8 @@ import {
16
16
  rewriteAuthzOriginator,
17
17
  SyncMessage,
18
18
  } from '@verdant-web/common';
19
- import { MessageSender } from '../connections/MessageSender.js';
20
- import { Presence } from '../connections/Presence.js';
21
19
  import { FileInfo, FileStorageLibraryDelegate } from '../files/FileStorage.js';
20
+ import { ClientConnectionManager } from '../internals.js';
22
21
  import { Logger } from '../logger.js';
23
22
  import { Storage } from '../storage/Storage.js';
24
23
  import { TokenInfo } from '../TokenVerifier.js';
@@ -47,42 +46,44 @@ export interface LibraryFileInfo {
47
46
 
48
47
  export class Library {
49
48
  private storage;
50
- private sender;
51
49
  private disableRebasing: boolean;
52
50
  private fileStorage;
53
51
  private events;
54
52
  readonly id: string;
55
- private presence: Presence;
53
+ private clientConnections: ClientConnectionManager;
54
+ private get presence() {
55
+ return this.clientConnections.presence;
56
+ }
57
+ private get sender() {
58
+ return this.clientConnections;
59
+ }
56
60
 
57
61
  private log: Logger;
58
62
 
59
63
  constructor({
60
64
  storage,
61
- sender,
62
65
  log = () => {},
63
66
  disableRebasing,
64
67
  fileStorage,
65
68
  events,
66
69
  id,
67
- presence,
70
+ clientConnections,
68
71
  }: {
69
72
  storage: Storage;
70
- sender: MessageSender;
71
73
  log?: Logger;
72
74
  disableRebasing?: boolean;
73
75
  fileStorage?: FileStorageLibraryDelegate;
74
76
  events?: EventSubscriber<LibraryEvents>;
75
77
  id: string;
76
- presence: Presence;
78
+ clientConnections: ClientConnectionManager;
77
79
  }) {
78
80
  this.id = id;
79
81
  this.storage = storage;
80
- this.sender = sender;
81
82
  this.log = log;
82
83
  this.disableRebasing = !!disableRebasing;
83
84
  this.fileStorage = fileStorage;
84
85
  this.events = events;
85
- this.presence = presence;
86
+ this.clientConnections = clientConnections;
86
87
  this.presence.subscribe('lost', this.onPresenceLost);
87
88
  }
88
89
 
@@ -490,13 +491,16 @@ export class Library {
490
491
 
491
492
  // respond to client
492
493
 
494
+ const peerPresence = await this.presence.all();
495
+
493
496
  this.log(
494
497
  'info',
495
498
  'Sending sync response with',
496
- ops.length,
497
- 'operations and',
498
- baselines.length,
499
- 'baselines',
499
+ {
500
+ ops: ops.length,
501
+ baselines: baselines.length,
502
+ peers: Object.keys(peerPresence),
503
+ },
500
504
  'to client key',
501
505
  clientKey,
502
506
  );
@@ -511,7 +515,7 @@ export class Library {
511
515
  baselines: this.removeBaselineExtras(baselines),
512
516
  globalAckTimestamp:
513
517
  (await this.storage.replicas.getGlobalAck()) ?? undefined,
514
- peerPresence: this.presence.all(),
518
+ peerPresence,
515
519
  // only request the client to overwrite local data if a reset is requested
516
520
  // and there is data to overwrite it. otherwise the client may still
517
521
  // send its own history to us.
@@ -578,7 +582,7 @@ export class Library {
578
582
  clientKey: string,
579
583
  _info: TokenInfo,
580
584
  ) => {
581
- this.presence.removeConnection(clientKey);
585
+ await this.clientConnections.remove(clientKey);
582
586
  };
583
587
 
584
588
  private updateHighwater = async (
@@ -633,7 +637,7 @@ export class Library {
633
637
  // for global ack, to determine consensus, also allow
634
638
  // for all actively connected replicas to ack regardless of their
635
639
  // type.
636
- const activeReplicaIds = Object.values(this.presence.all()).map(
640
+ const activeReplicaIds = Object.values(await this.presence.all()).map(
637
641
  (p) => p.replicaId,
638
642
  );
639
643
  const globalAck =
@@ -711,7 +715,7 @@ export class Library {
711
715
  clientKey: string,
712
716
  info: TokenInfo,
713
717
  ) => {
714
- const updated = await this.presence.set(clientKey, info.userId, {
718
+ const updated = await this.presence.set(info.userId, {
715
719
  presence: message.presence,
716
720
  internal: message.internal,
717
721
  replicaId: message.replicaId,
@@ -728,14 +732,13 @@ export class Library {
728
732
  );
729
733
  };
730
734
 
731
- private onPresenceLost = async (replicaId: string, userId: string) => {
735
+ private onPresenceLost = async (userId: string) => {
732
736
  this.log('info', 'User disconnected from all replicas:', userId);
733
737
  this.sender.broadcast({
734
738
  type: 'presence-offline',
735
- replicaId,
736
739
  userId,
737
740
  });
738
- if (Object.keys(this.presence.all()).length === 0) {
741
+ if (Object.keys(await this.presence.all()).length === 0) {
739
742
  this.log('info', `All users have disconnected`);
740
743
  // could happen - if the server is shutting down manually
741
744
  if (!this.storage.open) {
@@ -803,7 +806,7 @@ export class Library {
803
806
  };
804
807
 
805
808
  getPresence = () => {
806
- return Promise.resolve(this.presence.all());
809
+ return this.presence.all();
807
810
  };
808
811
 
809
812
  getInfo = async (): Promise<LibraryInfo | null> => {
@@ -75,8 +75,7 @@ export class SingleNodeLibraryManager {
75
75
  storage,
76
76
  id: libraryId,
77
77
  events: this.events,
78
- sender: clientConnections,
79
- presence: clientConnections.presence,
78
+ clientConnections,
80
79
  log: (level, ...args) =>
81
80
  this.ctx.log?.(level, `[Library ${libraryId}]`, ...args),
82
81
  fileStorage: this.ctx.fileStorage