@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.
- package/dist/helpers/array.d.ts +2 -0
- package/dist/helpers/array.d.ts.map +1 -0
- package/dist/helpers/array.js +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/presence/PeerPresenceInfo.d.ts +50 -0
- package/dist/presence/PeerPresenceInfo.d.ts.map +1 -0
- package/dist/presence/PeerPresenceInfo.js +83 -0
- package/dist/presence/PeerStateView.d.ts +69 -0
- package/dist/presence/PeerStateView.d.ts.map +1 -0
- package/dist/presence/PeerStateView.js +131 -0
- package/dist/presence/Presence.d.ts +81 -0
- package/dist/presence/Presence.d.ts.map +1 -0
- package/dist/presence/Presence.js +245 -0
- package/dist/presence/constants.d.ts +4 -0
- package/dist/presence/constants.d.ts.map +1 -0
- package/dist/presence/constants.js +3 -0
- package/dist/presence/types.d.ts +75 -0
- package/dist/presence/types.d.ts.map +1 -0
- package/dist/presence/types.js +1 -0
- package/package.json +2 -2
- package/src/helpers/array.ts +3 -0
- package/src/index.ts +10 -0
- package/src/presence/PeerPresenceInfo.ts +108 -0
- package/src/presence/PeerStateView.ts +154 -0
- package/src/presence/Presence.ts +311 -0
- package/src/presence/constants.ts +3 -0
- package/src/presence/types.ts +94 -0
- package/test/Presence.test.ts +258 -0
- package/test/helpers/wait.ts +5 -0
- package/test/helpers/waitFor.ts +14 -0
|
@@ -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,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
|
+
})
|