@automerge/automerge-repo 2.5.2-alpha.0 → 2.5.2-alpha.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.
@@ -0,0 +1,311 @@
1
+ import { EventEmitter } from "eventemitter3"
2
+
3
+ import { DocHandle, DocHandleEphemeralMessagePayload } from "../DocHandle.js"
4
+ import {
5
+ DeviceId,
6
+ PresenceConfig,
7
+ PresenceEvents,
8
+ PresenceMessage,
9
+ PresenceMessageType,
10
+ PresenceState,
11
+ UserId,
12
+ } from "./types.js"
13
+ import {
14
+ DEFAULT_HEARTBEAT_INTERVAL_MS,
15
+ DEFAULT_PEER_TTL_MS,
16
+ PRESENCE_MESSAGE_MARKER,
17
+ } from "./constants.js"
18
+ import { PeerPresenceInfo } from "./PeerPresenceInfo.js"
19
+
20
+ /**
21
+ * Presence encapsulates ephemeral state communication for a specific doc
22
+ * handle. It tracks caller-provided local state and broadcasts that state to
23
+ * all peers. It sends periodic heartbeats when there are no state updates.
24
+ *
25
+ * It also tracks ephemeral state broadcast by peers and emits events when peers
26
+ * send ephemeral state updates (see {@link PresenceEvents}).
27
+ *
28
+ * Presence starts out in an inactive state. Call {@link start} and {@link stop}
29
+ * to activate and deactivate it.
30
+ */
31
+ export class Presence<
32
+ State extends PresenceState,
33
+ DocType = any
34
+ > extends EventEmitter<PresenceEvents> {
35
+ #handle: DocHandle<DocType>
36
+ readonly deviceId?: DeviceId
37
+ readonly userId?: UserId
38
+ #peers: PeerPresenceInfo<State>
39
+ #localState: State
40
+ #heartbeatMs?: number
41
+
42
+ #handleEphemeralMessage:
43
+ | ((e: DocHandleEphemeralMessagePayload<DocType>) => void)
44
+ | undefined
45
+
46
+ #heartbeatInterval: ReturnType<typeof setInterval> | undefined
47
+ #pruningInterval: ReturnType<typeof setInterval> | undefined
48
+ #hellos: ReturnType<typeof setTimeout>[] = []
49
+
50
+ #running = false
51
+
52
+ /**
53
+ * Create a new Presence to share ephemeral state with peers.
54
+ *
55
+ * @param config see {@link PresenceConfig}
56
+ * @returns
57
+ */
58
+ constructor({
59
+ handle,
60
+ deviceId,
61
+ userId,
62
+ }: {
63
+ handle: DocHandle<DocType>
64
+ /** Our device id (like userId, this is unverified; peers can send anything) */
65
+ deviceId?: DeviceId
66
+ /** Our user id (this is unverified; peers can send anything) */
67
+ userId?: UserId
68
+ }) {
69
+ super()
70
+ this.#handle = handle
71
+ this.#peers = new PeerPresenceInfo<State>(DEFAULT_PEER_TTL_MS)
72
+ this.#localState = {} as State
73
+ this.userId = userId
74
+ this.deviceId = deviceId
75
+ }
76
+
77
+ /**
78
+ * Start listening to ephemeral messages on the handle, broadcast initial
79
+ * state to peers, and start sending heartbeats.
80
+ */
81
+ start({ initialState, heartbeatMs, peerTtlMs }: PresenceConfig<State>) {
82
+ if (this.#running) {
83
+ return
84
+ }
85
+ this.#running = true
86
+
87
+ this.#heartbeatMs = heartbeatMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS
88
+ this.#peers = new PeerPresenceInfo<State>(peerTtlMs ?? DEFAULT_PEER_TTL_MS)
89
+ this.#localState = initialState
90
+
91
+ // N.B.: We can't use a regular member function here since member functions
92
+ // of two distinct objects are identical, and we need to be able to stop
93
+ // listening to the handle for just this Presence instance in stop()
94
+ this.#handleEphemeralMessage = (
95
+ e: DocHandleEphemeralMessagePayload<DocType>
96
+ ) => {
97
+ const peerId = e.senderId
98
+ const envelope = e.message as PresenceMessage
99
+
100
+ if (!(PRESENCE_MESSAGE_MARKER in envelope)) {
101
+ return
102
+ }
103
+
104
+ const message = envelope[PRESENCE_MESSAGE_MARKER]
105
+ const { deviceId, userId } = message
106
+
107
+ if (!this.#peers.has(peerId)) {
108
+ this.announce()
109
+ }
110
+
111
+ switch (message.type) {
112
+ case "heartbeat":
113
+ this.#peers.markSeen(peerId)
114
+ this.emit("heartbeat", { type: "heartbeat", peerId })
115
+ break
116
+ case "goodbye":
117
+ this.#peers.delete(peerId)
118
+ this.emit("goodbye", { type: "goodbye", peerId })
119
+ break
120
+ case "update":
121
+ this.#peers.update({
122
+ peerId,
123
+ deviceId,
124
+ userId,
125
+ value: { [message.channel]: message.value } as Partial<State>,
126
+ })
127
+ this.emit("update", {
128
+ type: "update",
129
+ peerId,
130
+ deviceId,
131
+ userId,
132
+ channel: message.channel,
133
+ value: message.value,
134
+ })
135
+ break
136
+ case "snapshot":
137
+ this.#peers.update({
138
+ peerId,
139
+ deviceId,
140
+ userId,
141
+ value: message.state as State,
142
+ })
143
+ this.emit("snapshot", {
144
+ type: "snapshot",
145
+ peerId,
146
+ deviceId,
147
+ userId,
148
+ state: message.state,
149
+ })
150
+ break
151
+ }
152
+ }
153
+ this.#handle.on("ephemeral-message", this.#handleEphemeralMessage)
154
+
155
+ this.broadcastLocalState() // also starts heartbeats
156
+ this.startPruningPeers()
157
+ }
158
+
159
+ /**
160
+ * Return a view of current peer states.
161
+ */
162
+ getPeerStates() {
163
+ return this.#peers.states
164
+ }
165
+
166
+ /**
167
+ * Return a view of current local state.
168
+ */
169
+ getLocalState() {
170
+ return this.#localState
171
+ }
172
+
173
+ /**
174
+ * Update state for the specific channel, and broadcast new state to all
175
+ * peers.
176
+ *
177
+ * @param channel
178
+ * @param value
179
+ */
180
+ broadcast<Channel extends keyof State>(
181
+ channel: Channel,
182
+ value: State[Channel]
183
+ ) {
184
+ this.#localState = Object.assign({}, this.#localState, {
185
+ [channel]: value,
186
+ })
187
+ this.broadcastChannelState(channel)
188
+ }
189
+
190
+ /**
191
+ * Whether this Presence is currently active. See
192
+ * {@link start} and {@link stop}.
193
+ */
194
+ get running() {
195
+ return this.#running
196
+ }
197
+
198
+ /**
199
+ * Stop this Presence: broadcast a "goodbye" message (when received, other
200
+ * peers will immediately forget the sender), stop sending heartbeats, and
201
+ * stop listening to ephemeral-messages broadcast from peers.
202
+ *
203
+ * This can be used with browser events like
204
+ * {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/pagehide_event | "pagehide"}
205
+ * or
206
+ * {@link https://developer.mozilla.org/en-US/docs/Web/API/Document/visibilitychange_event | "visibilitychange"}
207
+ * to stop sending and receiving updates when not active.
208
+ */
209
+ stop() {
210
+ if (!this.#running) {
211
+ return
212
+ }
213
+ this.#hellos.forEach(timeoutId => {
214
+ clearTimeout(timeoutId)
215
+ })
216
+ this.#hellos = []
217
+ this.#handle.off("ephemeral-message", this.#handleEphemeralMessage)
218
+ this.stopHeartbeats()
219
+ this.stopPruningPeers()
220
+ this.send({ type: "goodbye" })
221
+ this.#running = false
222
+ }
223
+
224
+ private announce() {
225
+ // Broadcast our current state whenever we see new peers
226
+ // TODO: We currently need to wait for the peer to be ready, but waiting
227
+ // some arbitrary amount of time is brittle
228
+ const helloId = setTimeout(() => {
229
+ this.broadcastLocalState()
230
+ this.#hellos = this.#hellos.filter(id => id !== helloId)
231
+ }, 500)
232
+ this.#hellos.push(helloId)
233
+ }
234
+
235
+ private broadcastLocalState() {
236
+ this.doBroadcast("snapshot", { state: this.#localState })
237
+ this.resetHeartbeats()
238
+ }
239
+
240
+ private broadcastChannelState<Channel extends keyof State>(channel: Channel) {
241
+ const value = this.#localState[channel]
242
+ this.doBroadcast("update", { channel, value })
243
+ this.resetHeartbeats()
244
+ }
245
+
246
+ private resetHeartbeats() {
247
+ // Reset heartbeats every time we broadcast a message to avoid sending
248
+ // unnecessary heartbeats when there is plenty of actual update activity
249
+ // happening.
250
+ this.stopHeartbeats()
251
+ this.startHeartbeats()
252
+ }
253
+
254
+ private doBroadcast(
255
+ type: PresenceMessageType,
256
+ extra?: Record<string, unknown>
257
+ ) {
258
+ this.send({
259
+ userId: this.userId,
260
+ deviceId: this.deviceId,
261
+ type,
262
+ ...extra,
263
+ })
264
+ }
265
+
266
+ private send(message: Record<string, unknown>) {
267
+ if (!this.#running) {
268
+ return
269
+ }
270
+ this.#handle.broadcast({
271
+ [PRESENCE_MESSAGE_MARKER]: message,
272
+ })
273
+ }
274
+
275
+ private startHeartbeats() {
276
+ if (this.#heartbeatInterval !== undefined) {
277
+ return
278
+ }
279
+ this.#heartbeatInterval = setInterval(() => {
280
+ this.send({ type: "heartbeat" })
281
+ }, this.#heartbeatMs)
282
+ }
283
+
284
+ private stopHeartbeats() {
285
+ if (this.#heartbeatInterval === undefined) {
286
+ return
287
+ }
288
+ clearInterval(this.#heartbeatInterval)
289
+ this.#heartbeatInterval = undefined
290
+ }
291
+
292
+ private startPruningPeers() {
293
+ if (this.#pruningInterval !== undefined) {
294
+ return
295
+ }
296
+ // Pruning happens at the heartbeat frequency, not on a peer ttl frequency,
297
+ // to minimize variance between peer expiration, since the heartbeat frequency
298
+ // is expected to be several times higher.
299
+ this.#pruningInterval = setInterval(() => {
300
+ this.#peers.prune()
301
+ }, this.#heartbeatMs)
302
+ }
303
+
304
+ private stopPruningPeers() {
305
+ if (this.#pruningInterval === undefined) {
306
+ return
307
+ }
308
+ clearInterval(this.#pruningInterval)
309
+ this.#pruningInterval = undefined
310
+ }
311
+ }
@@ -0,0 +1,3 @@
1
+ export const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000
2
+ export const DEFAULT_PEER_TTL_MS = 3 * DEFAULT_HEARTBEAT_INTERVAL_MS
3
+ export const PRESENCE_MESSAGE_MARKER = "__presence"
@@ -0,0 +1,94 @@
1
+ import { PeerId } from "../types.js"
2
+ import { PRESENCE_MESSAGE_MARKER } from "./constants.js"
3
+
4
+ export type UserId = unknown
5
+ export type DeviceId = unknown
6
+
7
+ export type PresenceState = Record<string, any>
8
+
9
+ export type PeerStatesValue<State extends PresenceState> = Record<
10
+ PeerId,
11
+ PeerState<State>
12
+ >
13
+
14
+ export type PeerState<State extends PresenceState> = {
15
+ peerId: PeerId
16
+ lastActiveAt: number
17
+ lastUpdateAt: number
18
+ deviceId?: DeviceId
19
+ userId?: UserId
20
+ value: State
21
+ }
22
+
23
+ type PresenceMessageBase = {
24
+ deviceId?: DeviceId
25
+ userId?: UserId
26
+ }
27
+
28
+ type PresenceMessageUpdate = PresenceMessageBase & {
29
+ type: "update"
30
+ channel: string
31
+ value: any
32
+ }
33
+
34
+ type PresenceMessageSnapshot = PresenceMessageBase & {
35
+ type: "snapshot"
36
+ state: any
37
+ }
38
+
39
+ type PresenceMessageHeartbeat = PresenceMessageBase & {
40
+ type: "heartbeat"
41
+ }
42
+
43
+ type PresenceMessageGoodbye = PresenceMessageBase & {
44
+ type: "goodbye"
45
+ }
46
+
47
+ export type PresenceMessage = {
48
+ [PRESENCE_MESSAGE_MARKER]:
49
+ | PresenceMessageUpdate
50
+ | PresenceMessageSnapshot
51
+ | PresenceMessageHeartbeat
52
+ | PresenceMessageGoodbye
53
+ }
54
+
55
+ export type PresenceMessageType =
56
+ PresenceMessage[typeof PRESENCE_MESSAGE_MARKER]["type"]
57
+
58
+ type WithPeerId = { peerId: PeerId }
59
+
60
+ export type PresenceEventUpdate = PresenceMessageUpdate & WithPeerId
61
+ export type PresenceEventSnapshot = PresenceMessageSnapshot & WithPeerId
62
+ export type PresenceEventHeartbeat = PresenceMessageHeartbeat & WithPeerId
63
+ export type PresenceEventGoodbye = PresenceMessageGoodbye & WithPeerId
64
+
65
+ /**
66
+ * Events emitted by Presence when ephemeral messages are received from peers.
67
+ */
68
+ export type PresenceEvents = {
69
+ /**
70
+ * Handle a state update broadcast by a peer.
71
+ */
72
+ update: (msg: PresenceEventUpdate) => void
73
+ /**
74
+ * Handle a full state snapshot broadcast by a peer.
75
+ */
76
+ snapshot: (msg: PresenceEventSnapshot) => void
77
+ /**
78
+ * Handle a heartbeat broadcast by a peer.
79
+ */
80
+ heartbeat: (msg: PresenceEventHeartbeat) => void
81
+ /**
82
+ * Handle a disconnection broadcast by a peer.
83
+ */
84
+ goodbye: (msg: PresenceEventGoodbye) => void
85
+ }
86
+
87
+ export type PresenceConfig<State extends PresenceState> = {
88
+ /** The full initial state to broadcast to peers */
89
+ initialState: State
90
+ /** How frequently to send heartbeats (default {@link DEFAULT_HEARTBEAT_INTERVAL_MS}) */
91
+ heartbeatMs?: number
92
+ /** How long to wait until forgetting peers with no activity (default {@link DEFAULT_PEER_TTL_MS}) */
93
+ peerTtlMs?: number
94
+ }
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from "vitest"
2
2
 
3
- import { Presence, PresenceEventHeartbeat } from "../src/Presence.js"
3
+ import { Presence } from "../src/presence/Presence.js"
4
+ import { PresenceEventHeartbeat } from "../src/presence/types.js"
4
5
  import { Repo } from "../src/Repo.js"
5
6
  import { PeerId } from "../src/types.js"
6
7
  import { DummyNetworkAdapter } from "../src/helpers/DummyNetworkAdapter.js"
@@ -76,21 +77,19 @@ describe("Presence", () => {
76
77
  expect(bob.presence.running).toBe(true)
77
78
 
78
79
  await waitFor(() => {
79
- const bobPeerStates = bob.presence.getPeerStates()
80
- const bobPeers = bobPeerStates.getPeers()
80
+ const bobPeerStates = bob.presence.getPeerStates().value
81
+ const bobPeers = Object.keys(bobPeerStates)
81
82
 
82
83
  expect(bobPeers.length).toBe(1)
83
84
  expect(bobPeers[0]).toBe(alice.repo.peerId)
84
- expect(bobPeerStates.getPeerState(bobPeers[0], "position")).toBe(123)
85
+ expect(bobPeerStates[alice.repo.peerId].value.position).toBe(123)
85
86
 
86
- const alicePeerStates = alice.presence.getPeerStates()
87
- const alicePeers = alicePeerStates.getPeers()
87
+ const alicePeerStates = alice.presence.getPeerStates().value
88
+ const alicePeers = Object.keys(alicePeerStates)
88
89
 
89
90
  expect(alicePeers.length).toBe(1)
90
91
  expect(alicePeers[0]).toBe(bob.repo.peerId)
91
- expect(alicePeerStates.getPeerState(alicePeers[0], "position")).toBe(
92
- 456
93
- )
92
+ expect(alicePeerStates[bob.repo.peerId].value.position).toBe(456)
94
93
  })
95
94
  })
96
95
 
@@ -132,20 +131,21 @@ describe("Presence", () => {
132
131
  })
133
132
 
134
133
  await waitFor(() => {
135
- const bobPeerStates = bob.presence.getPeerStates()
136
- const bobPeers = bobPeerStates.getPeers()
134
+ const bobPeerStates = bob.presence.getPeerStates().value
135
+ const bobPeers = Object.keys(bobPeerStates)
137
136
 
138
137
  expect(bobPeers.length).toBe(1)
139
138
  expect(bobPeers[0]).toBe(alice.repo.peerId)
140
- expect(bobPeerStates.getPeerState(bobPeers[0], "position")).toBe(123)
139
+ expect(bobPeerStates[alice.repo.peerId].value.position).toBe(123)
141
140
  })
142
141
 
143
142
  alice.presence.stop()
144
143
  expect(alice.presence.running).toBe(false)
145
144
 
145
+ console.log("waiting for peers to leave")
146
146
  await waitFor(() => {
147
- const bobPeerStates = bob.presence.getPeerStates()
148
- const bobPeers = bobPeerStates.getPeers()
147
+ const bobPeerStates = bob.presence.getPeerStates().value
148
+ const bobPeers = Object.keys(bobPeerStates)
149
149
 
150
150
  expect(bobPeers.length).toBe(0)
151
151
  })
@@ -186,7 +186,6 @@ describe("Presence", () => {
186
186
  await waitFor(() => {
187
187
  expect(hbPeerMsg.peerId).toEqual(alice.repo.peerId)
188
188
  expect(hbPeerMsg.type).toEqual("heartbeat")
189
- expect(hbPeerMsg.userId).toEqual("alice")
190
189
  })
191
190
  })
192
191
 
@@ -219,7 +218,6 @@ describe("Presence", () => {
219
218
  await wait(20)
220
219
  expect(hbPeerMsg.peerId).toEqual(alice.repo.peerId)
221
220
  expect(hbPeerMsg.type).toEqual("heartbeat")
222
- expect(hbPeerMsg.userId).toEqual("alice")
223
221
  })
224
222
  })
225
223
 
@@ -239,25 +237,21 @@ describe("Presence", () => {
239
237
  })
240
238
 
241
239
  await waitFor(() => {
242
- const bobPeerStates = bob.presence.getPeerStates()
243
- const bobPeers = bobPeerStates.getPeers()
240
+ const bobPeerStates = bob.presence.getPeerStates().value
241
+ const bobPeers = Object.keys(bobPeerStates)
244
242
 
245
243
  expect(bobPeers.length).toBe(1)
246
- expect(bobPeerStates.getPeerState(alice.repo.peerId, "position")).toBe(
247
- 123
248
- )
244
+ expect(bobPeerStates[alice.repo.peerId].value.position).toBe(123)
249
245
  })
250
246
 
251
247
  alice.presence.broadcast("position", 213)
252
248
 
253
249
  await waitFor(() => {
254
- const bobPeerStates = bob.presence.getPeerStates()
255
- const bobPeers = bobPeerStates.getPeers()
250
+ const bobPeerStates = bob.presence.getPeerStates().value
251
+ const bobPeers = Object.keys(bobPeerStates)
256
252
 
257
253
  expect(bobPeers.length).toBe(1)
258
- expect(bobPeerStates.getPeerState(alice.repo.peerId, "position")).toBe(
259
- 213
260
- )
254
+ expect(bobPeerStates[alice.repo.peerId].value.position).toBe(213)
261
255
  })
262
256
  })
263
257
  })