@automerge/automerge-repo 2.5.1 → 2.5.2-alpha.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.
@@ -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
+ }
@@ -0,0 +1,258 @@
1
+ import { describe, expect, it } from "vitest"
2
+
3
+ import { Presence } from "../src/presence/Presence.js"
4
+ import { PresenceEventHeartbeat } from "../src/presence/types.js"
5
+ import { Repo } from "../src/Repo.js"
6
+ import { PeerId } from "../src/types.js"
7
+ import { DummyNetworkAdapter } from "../src/helpers/DummyNetworkAdapter.js"
8
+ import { waitFor } from "./helpers/waitFor.js"
9
+ import { wait } from "./helpers/wait.js"
10
+
11
+ type PresenceState = { position: number }
12
+
13
+ describe("Presence", () => {
14
+ async function setup(opts?: { skipAnnounce?: boolean }) {
15
+ const alice = new Repo({ peerId: "alice" as PeerId })
16
+ const bob = new Repo({ peerId: "bob" as PeerId })
17
+ const [aliceToBob, bobToAlice] = DummyNetworkAdapter.createConnectedPair()
18
+ alice.networkSubsystem.addNetworkAdapter(aliceToBob)
19
+ bob.networkSubsystem.addNetworkAdapter(bobToAlice)
20
+ if (!opts?.skipAnnounce) {
21
+ aliceToBob.peerCandidate("bob" as PeerId)
22
+ bobToAlice.peerCandidate("alice" as PeerId)
23
+ }
24
+ await Promise.all([
25
+ alice.networkSubsystem.whenReady(),
26
+ bob.networkSubsystem.whenReady(),
27
+ ])
28
+
29
+ const aliceHandle = alice.create({
30
+ test: "doc",
31
+ })
32
+ const alicePresence = new Presence<PresenceState>({
33
+ handle: aliceHandle,
34
+ userId: "alice",
35
+ deviceId: "phone",
36
+ })
37
+
38
+ const bobHandle = await bob.find(aliceHandle.url)
39
+ const bobPresence = new Presence<PresenceState>({
40
+ handle: bobHandle,
41
+ userId: "bob",
42
+ deviceId: "phone",
43
+ })
44
+
45
+ return {
46
+ alice: {
47
+ repo: alice,
48
+ handle: aliceHandle,
49
+ presence: alicePresence,
50
+ network: aliceToBob,
51
+ },
52
+ bob: {
53
+ repo: bob,
54
+ handle: bobHandle,
55
+ presence: bobPresence,
56
+ network: bobToAlice,
57
+ },
58
+ }
59
+ }
60
+
61
+ describe("start", () => {
62
+ it("activates presence and shares initial state", async () => {
63
+ const { alice, bob } = await setup()
64
+
65
+ alice.presence.start({
66
+ initialState: {
67
+ position: 123,
68
+ },
69
+ })
70
+ expect(alice.presence.running).toBe(true)
71
+
72
+ bob.presence.start({
73
+ initialState: {
74
+ position: 456,
75
+ },
76
+ })
77
+ expect(bob.presence.running).toBe(true)
78
+
79
+ await waitFor(() => {
80
+ const bobPeerStates = bob.presence.getPeerStates().value
81
+ const bobPeers = Object.keys(bobPeerStates)
82
+
83
+ expect(bobPeers.length).toBe(1)
84
+ expect(bobPeers[0]).toBe(alice.repo.peerId)
85
+ expect(bobPeerStates[alice.repo.peerId].value.position).toBe(123)
86
+
87
+ const alicePeerStates = alice.presence.getPeerStates().value
88
+ const alicePeers = Object.keys(alicePeerStates)
89
+
90
+ expect(alicePeers.length).toBe(1)
91
+ expect(alicePeers[0]).toBe(bob.repo.peerId)
92
+ expect(alicePeerStates[bob.repo.peerId].value.position).toBe(456)
93
+ })
94
+ })
95
+
96
+ it("does nothing if invoked on an already-running Presence", async () => {
97
+ const { alice } = await setup()
98
+
99
+ alice.presence.start({
100
+ initialState: {
101
+ position: 123,
102
+ },
103
+ })
104
+ expect(alice.presence.running).toBe(true)
105
+
106
+ alice.presence.start({
107
+ initialState: {
108
+ position: 789,
109
+ },
110
+ })
111
+ expect(alice.presence.running).toBe(true)
112
+ expect(alice.presence.getLocalState().position).toBe(123)
113
+ })
114
+ })
115
+
116
+ describe("stop", () => {
117
+ it("stops running presence and ignores further broadcasts", async () => {
118
+ const { alice, bob } = await setup()
119
+
120
+ alice.presence.start({
121
+ initialState: {
122
+ position: 123,
123
+ },
124
+ })
125
+ expect(alice.presence.running).toBe(true)
126
+
127
+ bob.presence.start({
128
+ initialState: {
129
+ position: 456,
130
+ },
131
+ })
132
+
133
+ await waitFor(() => {
134
+ const bobPeerStates = bob.presence.getPeerStates().value
135
+ const bobPeers = Object.keys(bobPeerStates)
136
+
137
+ expect(bobPeers.length).toBe(1)
138
+ expect(bobPeers[0]).toBe(alice.repo.peerId)
139
+ expect(bobPeerStates[alice.repo.peerId].value.position).toBe(123)
140
+ })
141
+
142
+ alice.presence.stop()
143
+ expect(alice.presence.running).toBe(false)
144
+
145
+ console.log("waiting for peers to leave")
146
+ await waitFor(() => {
147
+ const bobPeerStates = bob.presence.getPeerStates().value
148
+ const bobPeers = Object.keys(bobPeerStates)
149
+
150
+ expect(bobPeers.length).toBe(0)
151
+ })
152
+ })
153
+
154
+ it("does nothing if invoked on a non-running Presence", async () => {
155
+ const { alice } = await setup()
156
+
157
+ expect(alice.presence.running).toBe(false)
158
+
159
+ alice.presence.stop()
160
+
161
+ expect(alice.presence.running).toBe(false)
162
+ })
163
+ })
164
+
165
+ describe("heartbeats", () => {
166
+ it("sends heartbeats on the configured interval", async () => {
167
+ const { alice, bob } = await setup()
168
+ alice.presence.start({
169
+ initialState: {
170
+ position: 123,
171
+ },
172
+ heartbeatMs: 10,
173
+ })
174
+
175
+ bob.presence.start({
176
+ initialState: {
177
+ position: 456,
178
+ },
179
+ })
180
+
181
+ let hbPeerMsg: PresenceEventHeartbeat
182
+ bob.presence.on("heartbeat", msg => {
183
+ hbPeerMsg = msg
184
+ })
185
+
186
+ await waitFor(() => {
187
+ expect(hbPeerMsg.peerId).toEqual(alice.repo.peerId)
188
+ expect(hbPeerMsg.type).toEqual("heartbeat")
189
+ })
190
+ })
191
+
192
+ it("delays heartbeats when there is a state update", async () => {
193
+ const { alice, bob } = await setup()
194
+ alice.presence.start({
195
+ initialState: {
196
+ position: 123,
197
+ },
198
+ heartbeatMs: 10,
199
+ })
200
+
201
+ bob.presence.start({
202
+ initialState: {
203
+ position: 456,
204
+ },
205
+ })
206
+
207
+ let hbPeerMsg: PresenceEventHeartbeat
208
+ bob.presence.on("heartbeat", msg => {
209
+ hbPeerMsg = msg
210
+ })
211
+
212
+ await wait(7)
213
+ alice.presence.broadcast("position", 789)
214
+ await wait(7)
215
+
216
+ expect(hbPeerMsg).toBeUndefined()
217
+
218
+ await wait(20)
219
+ expect(hbPeerMsg.peerId).toEqual(alice.repo.peerId)
220
+ expect(hbPeerMsg.type).toEqual("heartbeat")
221
+ })
222
+ })
223
+
224
+ describe("broadcast", () => {
225
+ it("sends updates to peers", async () => {
226
+ const { alice, bob } = await setup()
227
+ alice.presence.start({
228
+ initialState: {
229
+ position: 123,
230
+ },
231
+ })
232
+
233
+ bob.presence.start({
234
+ initialState: {
235
+ position: 456,
236
+ },
237
+ })
238
+
239
+ await waitFor(() => {
240
+ const bobPeerStates = bob.presence.getPeerStates().value
241
+ const bobPeers = Object.keys(bobPeerStates)
242
+
243
+ expect(bobPeers.length).toBe(1)
244
+ expect(bobPeerStates[alice.repo.peerId].value.position).toBe(123)
245
+ })
246
+
247
+ alice.presence.broadcast("position", 213)
248
+
249
+ await waitFor(() => {
250
+ const bobPeerStates = bob.presence.getPeerStates().value
251
+ const bobPeers = Object.keys(bobPeerStates)
252
+
253
+ expect(bobPeers.length).toBe(1)
254
+ expect(bobPeerStates[alice.repo.peerId].value.position).toBe(213)
255
+ })
256
+ })
257
+ })
258
+ })
@@ -0,0 +1,5 @@
1
+ export async function wait(ms: number) {
2
+ return new Promise(resolve => {
3
+ setTimeout(resolve, ms)
4
+ })
5
+ }
@@ -0,0 +1,14 @@
1
+ export async function waitFor(callback: () => void) {
2
+ let sleepMs = 10
3
+ while (true) {
4
+ try {
5
+ callback()
6
+ break
7
+ } catch (e) {
8
+ sleepMs *= 2
9
+ await new Promise(resolve => {
10
+ setTimeout(resolve, sleepMs)
11
+ })
12
+ }
13
+ }
14
+ }