@hanzo/base 0.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.
- package/dist/chunk-5NHFZRMO.js +1011 -0
- package/dist/chunk-5NHFZRMO.js.map +1 -0
- package/dist/chunk-LBAV5X5P.js +996 -0
- package/dist/chunk-LBAV5X5P.js.map +1 -0
- package/dist/compat/index.d.ts +1 -0
- package/dist/compat/index.js +3 -0
- package/dist/compat/index.js.map +1 -0
- package/dist/core/index.d.ts +368 -0
- package/dist/core/index.js +3 -0
- package/dist/core/index.js.map +1 -0
- package/dist/crdt/index.d.ts +372 -0
- package/dist/crdt/index.js +3 -0
- package/dist/crdt/index.js.map +1 -0
- package/dist/react/index.d.ts +144 -0
- package/dist/react/index.js +283 -0
- package/dist/react/index.js.map +1 -0
- package/package.json +71 -0
- package/src/compat/index.ts +24 -0
- package/src/core/client.ts +432 -0
- package/src/core/collection.ts +474 -0
- package/src/core/index.ts +37 -0
- package/src/core/realtime.ts +303 -0
- package/src/core/state.ts +112 -0
- package/src/core/store.ts +241 -0
- package/src/crdt/clock.ts +78 -0
- package/src/crdt/counter.ts +130 -0
- package/src/crdt/document.ts +194 -0
- package/src/crdt/index.ts +56 -0
- package/src/crdt/operations.ts +101 -0
- package/src/crdt/register.ts +106 -0
- package/src/crdt/set.ts +172 -0
- package/src/crdt/sync.ts +412 -0
- package/src/crdt/text.ts +274 -0
- package/src/react/context.tsx +87 -0
- package/src/react/hooks.ts +489 -0
- package/src/react/index.ts +56 -0
package/src/crdt/set.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CRDTSet<T> -- Observed-Remove Set (OR-Set).
|
|
3
|
+
*
|
|
4
|
+
* Each add() generates a unique tag. A remove() records the set of tags
|
|
5
|
+
* observed for that value at removal time. The value is present iff it has
|
|
6
|
+
* any tag not covered by a remove.
|
|
7
|
+
*
|
|
8
|
+
* This gives add-wins semantics: a concurrent add and remove of the same
|
|
9
|
+
* value results in the value being present (the new add's tag survives).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { HybridLogicalClock } from './clock.js'
|
|
13
|
+
import type { Operation, SetAddPayload, SetRemovePayload } from './operations.js'
|
|
14
|
+
import { makeOpId } from './operations.js'
|
|
15
|
+
|
|
16
|
+
export type SetChangeCallback<T> = (values: T[]) => void
|
|
17
|
+
|
|
18
|
+
interface TaggedEntry<T> {
|
|
19
|
+
value: T
|
|
20
|
+
tags: Set<string>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class CRDTSet<T = unknown> {
|
|
24
|
+
/** Map from serialized value key to tagged entry. */
|
|
25
|
+
private _entries = new Map<string, TaggedEntry<T>>()
|
|
26
|
+
|
|
27
|
+
private readonly _clock: HybridLogicalClock
|
|
28
|
+
private readonly _documentId: string
|
|
29
|
+
private readonly _field: string
|
|
30
|
+
private readonly _pendingOps: Operation[] = []
|
|
31
|
+
private _listeners = new Set<SetChangeCallback<T>>()
|
|
32
|
+
|
|
33
|
+
constructor(documentId: string, field: string, clock: HybridLogicalClock) {
|
|
34
|
+
this._documentId = documentId
|
|
35
|
+
this._field = field
|
|
36
|
+
this._clock = clock
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---- Public API ---------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
get values(): T[] {
|
|
42
|
+
const result: T[] = []
|
|
43
|
+
for (const entry of this._entries.values()) {
|
|
44
|
+
if (entry.tags.size > 0) {
|
|
45
|
+
result.push(entry.value)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return result
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get size(): number {
|
|
52
|
+
let count = 0
|
|
53
|
+
for (const entry of this._entries.values()) {
|
|
54
|
+
if (entry.tags.size > 0) count++
|
|
55
|
+
}
|
|
56
|
+
return count
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
has(item: T): boolean {
|
|
60
|
+
const key = this._keyOf(item)
|
|
61
|
+
const entry = this._entries.get(key)
|
|
62
|
+
return entry !== undefined && entry.tags.size > 0
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
add(item: T): Operation {
|
|
66
|
+
const hlc = this._clock.now()
|
|
67
|
+
const tag = makeOpId(hlc)
|
|
68
|
+
const key = this._keyOf(item)
|
|
69
|
+
|
|
70
|
+
let entry = this._entries.get(key)
|
|
71
|
+
if (!entry) {
|
|
72
|
+
entry = { value: item, tags: new Set() }
|
|
73
|
+
this._entries.set(key, entry)
|
|
74
|
+
}
|
|
75
|
+
entry.tags.add(tag)
|
|
76
|
+
|
|
77
|
+
const op: Operation = {
|
|
78
|
+
id: tag,
|
|
79
|
+
documentId: this._documentId,
|
|
80
|
+
field: this._field,
|
|
81
|
+
hlc,
|
|
82
|
+
type: 'set.add',
|
|
83
|
+
payload: { value: item, tag } satisfies SetAddPayload,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
this._pendingOps.push(op)
|
|
87
|
+
this._notifyChange()
|
|
88
|
+
return op
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
remove(item: T): Operation {
|
|
92
|
+
const key = this._keyOf(item)
|
|
93
|
+
const entry = this._entries.get(key)
|
|
94
|
+
const observedTags = entry ? Array.from(entry.tags) : []
|
|
95
|
+
|
|
96
|
+
// Remove all observed tags.
|
|
97
|
+
if (entry) {
|
|
98
|
+
entry.tags.clear()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const hlc = this._clock.now()
|
|
102
|
+
const op: Operation = {
|
|
103
|
+
id: makeOpId(hlc),
|
|
104
|
+
documentId: this._documentId,
|
|
105
|
+
field: this._field,
|
|
106
|
+
hlc,
|
|
107
|
+
type: 'set.remove',
|
|
108
|
+
payload: { value: item, tags: observedTags } satisfies SetRemovePayload,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
this._pendingOps.push(op)
|
|
112
|
+
this._notifyChange()
|
|
113
|
+
return op
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Apply a remote operation. */
|
|
117
|
+
applyRemote(op: Operation): void {
|
|
118
|
+
this._clock.receive(op.hlc)
|
|
119
|
+
|
|
120
|
+
if (op.type === 'set.add') {
|
|
121
|
+
const payload = op.payload as SetAddPayload
|
|
122
|
+
const key = this._keyOf(payload.value as T)
|
|
123
|
+
let entry = this._entries.get(key)
|
|
124
|
+
if (!entry) {
|
|
125
|
+
entry = { value: payload.value as T, tags: new Set() }
|
|
126
|
+
this._entries.set(key, entry)
|
|
127
|
+
}
|
|
128
|
+
entry.tags.add(payload.tag)
|
|
129
|
+
} else if (op.type === 'set.remove') {
|
|
130
|
+
const payload = op.payload as SetRemovePayload
|
|
131
|
+
const key = this._keyOf(payload.value as T)
|
|
132
|
+
const entry = this._entries.get(key)
|
|
133
|
+
if (entry) {
|
|
134
|
+
for (const tag of payload.tags) {
|
|
135
|
+
entry.tags.delete(tag)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
this._notifyChange()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
drainOps(): Operation[] {
|
|
144
|
+
return this._pendingOps.splice(0, this._pendingOps.length)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
onChange(callback: SetChangeCallback<T>): () => void {
|
|
148
|
+
this._listeners.add(callback)
|
|
149
|
+
return () => {
|
|
150
|
+
this._listeners.delete(callback)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---- Internal -----------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
private _keyOf(value: T): string {
|
|
157
|
+
// Use JSON serialization as the key for value identity.
|
|
158
|
+
return JSON.stringify(value)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private _notifyChange(): void {
|
|
162
|
+
if (this._listeners.size === 0) return
|
|
163
|
+
const vals = this.values
|
|
164
|
+
for (const cb of this._listeners) {
|
|
165
|
+
try {
|
|
166
|
+
cb(vals)
|
|
167
|
+
} catch {
|
|
168
|
+
// listener errors must not break notification
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
package/src/crdt/sync.ts
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CRDTSync -- WebSocket-based sync protocol handler.
|
|
3
|
+
*
|
|
4
|
+
* Connects to the Base server's CRDT sync endpoint and manages
|
|
5
|
+
* bidirectional operation exchange. Handles:
|
|
6
|
+
*
|
|
7
|
+
* - Buffering and batching local operations
|
|
8
|
+
* - Applying remote operations to the CRDTDocument
|
|
9
|
+
* - Reconnection with exponential backoff
|
|
10
|
+
* - Acknowledgment tracking
|
|
11
|
+
* - Presence (peer awareness) via the same channel
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { CRDTDocument } from './document.js'
|
|
15
|
+
import type { Operation } from './operations.js'
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Types
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
export type SyncState = 'disconnected' | 'connecting' | 'connected' | 'syncing'
|
|
22
|
+
|
|
23
|
+
export interface PeerState {
|
|
24
|
+
siteId: string
|
|
25
|
+
/** Arbitrary JSON-serializable state (cursor position, name, color, etc.). */
|
|
26
|
+
meta: Record<string, unknown>
|
|
27
|
+
/** Last seen timestamp (local clock). */
|
|
28
|
+
lastSeen: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type SyncStateCallback = (state: SyncState) => void
|
|
32
|
+
export type PeerCallback = (peers: Map<string, PeerState>) => void
|
|
33
|
+
export type RemoteChangeCallback = (ops: Operation[]) => void
|
|
34
|
+
|
|
35
|
+
/** Wire message types sent over WebSocket. */
|
|
36
|
+
interface SyncMessage {
|
|
37
|
+
type: 'ops' | 'ack' | 'presence' | 'state_request' | 'state_response'
|
|
38
|
+
documentId: string
|
|
39
|
+
siteId: string
|
|
40
|
+
payload: unknown
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface OpsPayload {
|
|
44
|
+
ops: Operation[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface AckPayload {
|
|
48
|
+
lastOpId: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface PresencePayload {
|
|
52
|
+
meta: Record<string, unknown>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// CRDTSync
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
export class CRDTSync {
|
|
60
|
+
private _ws: WebSocket | null = null
|
|
61
|
+
private _state: SyncState = 'disconnected'
|
|
62
|
+
private _document: CRDTDocument | null = null
|
|
63
|
+
private _wsUrl: string | null = null
|
|
64
|
+
|
|
65
|
+
/** Flush interval for batching local ops. */
|
|
66
|
+
private _flushTimer: ReturnType<typeof setInterval> | null = null
|
|
67
|
+
private _flushIntervalMs = 50
|
|
68
|
+
|
|
69
|
+
/** Reconnection. */
|
|
70
|
+
private _reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
71
|
+
private _reconnectAttempts = 0
|
|
72
|
+
private _maxReconnectDelay = 30_000
|
|
73
|
+
private _baseReconnectDelay = 500
|
|
74
|
+
private _intentionalClose = false
|
|
75
|
+
|
|
76
|
+
/** Presence. */
|
|
77
|
+
private _peers = new Map<string, PeerState>()
|
|
78
|
+
private _localPresence: Record<string, unknown> = {}
|
|
79
|
+
private _presenceTimer: ReturnType<typeof setInterval> | null = null
|
|
80
|
+
private _presenceIntervalMs = 2_000
|
|
81
|
+
private _peerTimeoutMs = 10_000
|
|
82
|
+
|
|
83
|
+
/** Listeners. */
|
|
84
|
+
private _stateListeners = new Set<SyncStateCallback>()
|
|
85
|
+
private _peerListeners = new Set<PeerCallback>()
|
|
86
|
+
private _remoteChangeListeners = new Set<RemoteChangeCallback>()
|
|
87
|
+
|
|
88
|
+
// ---- Public API ---------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
get state(): SyncState {
|
|
91
|
+
return this._state
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
get peers(): ReadonlyMap<string, PeerState> {
|
|
95
|
+
return this._peers
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Connect to the CRDT sync endpoint and start syncing a document.
|
|
100
|
+
*
|
|
101
|
+
* @param wsUrl - WebSocket URL, e.g. "wss://myapp.hanzo.ai/api/crdt"
|
|
102
|
+
* @param document - The CRDTDocument to sync.
|
|
103
|
+
* @param token - Optional auth token.
|
|
104
|
+
*/
|
|
105
|
+
connect(wsUrl: string, document: CRDTDocument, token?: string): void {
|
|
106
|
+
if (this._ws) {
|
|
107
|
+
this.disconnect()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
this._wsUrl = wsUrl
|
|
111
|
+
this._document = document
|
|
112
|
+
this._intentionalClose = false
|
|
113
|
+
this._setState('connecting')
|
|
114
|
+
|
|
115
|
+
const url = new URL(wsUrl)
|
|
116
|
+
url.searchParams.set('documentId', document.id)
|
|
117
|
+
url.searchParams.set('siteId', document.clock.siteId)
|
|
118
|
+
if (token) {
|
|
119
|
+
url.searchParams.set('token', token)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
this._ws = new WebSocket(url.toString())
|
|
123
|
+
this._ws.binaryType = 'arraybuffer'
|
|
124
|
+
|
|
125
|
+
this._ws.onopen = () => {
|
|
126
|
+
this._reconnectAttempts = 0
|
|
127
|
+
this._setState('connected')
|
|
128
|
+
|
|
129
|
+
// Request full state from server if we have no unsynced ops.
|
|
130
|
+
if (document.getUnsyncedOps().length === 0) {
|
|
131
|
+
this._send({
|
|
132
|
+
type: 'state_request',
|
|
133
|
+
documentId: document.id,
|
|
134
|
+
siteId: document.clock.siteId,
|
|
135
|
+
payload: {},
|
|
136
|
+
})
|
|
137
|
+
} else {
|
|
138
|
+
// Re-send unsynced ops.
|
|
139
|
+
this._sendOps(Array.from(document.getUnsyncedOps()))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
this._startFlush()
|
|
143
|
+
this._startPresence()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
this._ws.onmessage = (e: MessageEvent) => {
|
|
147
|
+
this._handleMessage(e.data)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
this._ws.onclose = () => {
|
|
151
|
+
this._stopFlush()
|
|
152
|
+
this._stopPresence()
|
|
153
|
+
this._ws = null
|
|
154
|
+
this._setState('disconnected')
|
|
155
|
+
|
|
156
|
+
if (!this._intentionalClose) {
|
|
157
|
+
this._scheduleReconnect()
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
this._ws.onerror = () => {
|
|
162
|
+
// The close event will fire next -- handle reconnection there.
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
disconnect(): void {
|
|
167
|
+
this._intentionalClose = true
|
|
168
|
+
this._stopFlush()
|
|
169
|
+
this._stopPresence()
|
|
170
|
+
this._clearReconnect()
|
|
171
|
+
|
|
172
|
+
if (this._ws) {
|
|
173
|
+
this._ws.close()
|
|
174
|
+
this._ws = null
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
this._peers.clear()
|
|
178
|
+
this._setState('disconnected')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Update local presence metadata (cursor, name, etc.). */
|
|
182
|
+
updatePresence(meta: Record<string, unknown>): void {
|
|
183
|
+
this._localPresence = meta
|
|
184
|
+
this._broadcastPresence()
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---- Listeners ----------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
onStateChange(cb: SyncStateCallback): () => void {
|
|
190
|
+
this._stateListeners.add(cb)
|
|
191
|
+
return () => { this._stateListeners.delete(cb) }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
onPeersChange(cb: PeerCallback): () => void {
|
|
195
|
+
this._peerListeners.add(cb)
|
|
196
|
+
return () => { this._peerListeners.delete(cb) }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
onRemoteChange(cb: RemoteChangeCallback): () => void {
|
|
200
|
+
this._remoteChangeListeners.add(cb)
|
|
201
|
+
return () => { this._remoteChangeListeners.delete(cb) }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---- Message handling ---------------------------------------------------
|
|
205
|
+
|
|
206
|
+
private _handleMessage(raw: string | ArrayBuffer): void {
|
|
207
|
+
let text: string
|
|
208
|
+
if (raw instanceof ArrayBuffer) {
|
|
209
|
+
text = new TextDecoder().decode(raw)
|
|
210
|
+
} else {
|
|
211
|
+
text = raw as string
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let msg: SyncMessage
|
|
215
|
+
try {
|
|
216
|
+
msg = JSON.parse(text)
|
|
217
|
+
} catch {
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!this._document || msg.documentId !== this._document.id) return
|
|
222
|
+
|
|
223
|
+
switch (msg.type) {
|
|
224
|
+
case 'ops': {
|
|
225
|
+
const payload = msg.payload as OpsPayload
|
|
226
|
+
if (!payload.ops || payload.ops.length === 0) return
|
|
227
|
+
|
|
228
|
+
// Skip our own operations echoed back.
|
|
229
|
+
const remoteOps = payload.ops.filter(
|
|
230
|
+
(op) => op.hlc.siteId !== this._document!.clock.siteId,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
if (remoteOps.length > 0) {
|
|
234
|
+
this._document.applyRemoteOps(remoteOps)
|
|
235
|
+
for (const cb of this._remoteChangeListeners) {
|
|
236
|
+
try {
|
|
237
|
+
cb(remoteOps)
|
|
238
|
+
} catch {
|
|
239
|
+
// listener errors must not break processing
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
break
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
case 'ack': {
|
|
247
|
+
const payload = msg.payload as AckPayload
|
|
248
|
+
this._document.acknowledge(payload.lastOpId)
|
|
249
|
+
if (this._state === 'syncing' && this._document.getUnsyncedOps().length === 0) {
|
|
250
|
+
this._setState('connected')
|
|
251
|
+
}
|
|
252
|
+
break
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
case 'presence': {
|
|
256
|
+
const payload = msg.payload as PresencePayload
|
|
257
|
+
if (msg.siteId === this._document.clock.siteId) return
|
|
258
|
+
this._peers.set(msg.siteId, {
|
|
259
|
+
siteId: msg.siteId,
|
|
260
|
+
meta: payload.meta,
|
|
261
|
+
lastSeen: Date.now(),
|
|
262
|
+
})
|
|
263
|
+
this._notifyPeers()
|
|
264
|
+
break
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
case 'state_response': {
|
|
268
|
+
// Full state from server -- apply as remote ops.
|
|
269
|
+
const payload = msg.payload as OpsPayload
|
|
270
|
+
if (payload.ops && payload.ops.length > 0) {
|
|
271
|
+
this._document.applyRemoteOps(payload.ops)
|
|
272
|
+
}
|
|
273
|
+
break
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---- Flush (batched sending of local ops) -------------------------------
|
|
279
|
+
|
|
280
|
+
private _startFlush(): void {
|
|
281
|
+
this._stopFlush()
|
|
282
|
+
this._flushTimer = setInterval(() => {
|
|
283
|
+
this._flush()
|
|
284
|
+
}, this._flushIntervalMs)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private _stopFlush(): void {
|
|
288
|
+
if (this._flushTimer !== null) {
|
|
289
|
+
clearInterval(this._flushTimer)
|
|
290
|
+
this._flushTimer = null
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private _flush(): void {
|
|
295
|
+
if (!this._document || !this._ws || this._ws.readyState !== WebSocket.OPEN) return
|
|
296
|
+
|
|
297
|
+
const ops = this._document.collectOps()
|
|
298
|
+
if (ops.length === 0) return
|
|
299
|
+
|
|
300
|
+
this._sendOps(ops)
|
|
301
|
+
this._setState('syncing')
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private _sendOps(ops: Operation[]): void {
|
|
305
|
+
if (!this._document || !this._ws || this._ws.readyState !== WebSocket.OPEN) return
|
|
306
|
+
|
|
307
|
+
this._send({
|
|
308
|
+
type: 'ops',
|
|
309
|
+
documentId: this._document.id,
|
|
310
|
+
siteId: this._document.clock.siteId,
|
|
311
|
+
payload: { ops } satisfies OpsPayload,
|
|
312
|
+
})
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---- Presence -----------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
private _startPresence(): void {
|
|
318
|
+
this._stopPresence()
|
|
319
|
+
this._broadcastPresence()
|
|
320
|
+
this._presenceTimer = setInterval(() => {
|
|
321
|
+
this._broadcastPresence()
|
|
322
|
+
this._pruneStalePresence()
|
|
323
|
+
}, this._presenceIntervalMs)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private _stopPresence(): void {
|
|
327
|
+
if (this._presenceTimer !== null) {
|
|
328
|
+
clearInterval(this._presenceTimer)
|
|
329
|
+
this._presenceTimer = null
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private _broadcastPresence(): void {
|
|
334
|
+
if (!this._document || !this._ws || this._ws.readyState !== WebSocket.OPEN) return
|
|
335
|
+
|
|
336
|
+
this._send({
|
|
337
|
+
type: 'presence',
|
|
338
|
+
documentId: this._document.id,
|
|
339
|
+
siteId: this._document.clock.siteId,
|
|
340
|
+
payload: { meta: this._localPresence } satisfies PresencePayload,
|
|
341
|
+
})
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private _pruneStalePresence(): void {
|
|
345
|
+
const now = Date.now()
|
|
346
|
+
let changed = false
|
|
347
|
+
for (const [siteId, peer] of this._peers) {
|
|
348
|
+
if (now - peer.lastSeen > this._peerTimeoutMs) {
|
|
349
|
+
this._peers.delete(siteId)
|
|
350
|
+
changed = true
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (changed) {
|
|
354
|
+
this._notifyPeers()
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ---- Reconnection -------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
private _scheduleReconnect(): void {
|
|
361
|
+
this._clearReconnect()
|
|
362
|
+
const delay = Math.min(
|
|
363
|
+
this._baseReconnectDelay * Math.pow(2, this._reconnectAttempts),
|
|
364
|
+
this._maxReconnectDelay,
|
|
365
|
+
)
|
|
366
|
+
const jitter = delay * (0.75 + Math.random() * 0.5)
|
|
367
|
+
this._reconnectAttempts++
|
|
368
|
+
|
|
369
|
+
this._reconnectTimer = setTimeout(() => {
|
|
370
|
+
this._reconnectTimer = null
|
|
371
|
+
if (this._document && this._wsUrl) {
|
|
372
|
+
this.connect(this._wsUrl, this._document)
|
|
373
|
+
}
|
|
374
|
+
}, jitter)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private _clearReconnect(): void {
|
|
378
|
+
if (this._reconnectTimer !== null) {
|
|
379
|
+
clearTimeout(this._reconnectTimer)
|
|
380
|
+
this._reconnectTimer = null
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ---- Internal helpers ---------------------------------------------------
|
|
385
|
+
|
|
386
|
+
private _send(msg: SyncMessage): void {
|
|
387
|
+
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return
|
|
388
|
+
this._ws.send(JSON.stringify(msg))
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
private _setState(state: SyncState): void {
|
|
392
|
+
if (this._state === state) return
|
|
393
|
+
this._state = state
|
|
394
|
+
for (const cb of this._stateListeners) {
|
|
395
|
+
try {
|
|
396
|
+
cb(state)
|
|
397
|
+
} catch {
|
|
398
|
+
// listener errors must not break state management
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private _notifyPeers(): void {
|
|
404
|
+
for (const cb of this._peerListeners) {
|
|
405
|
+
try {
|
|
406
|
+
cb(this._peers)
|
|
407
|
+
} catch {
|
|
408
|
+
// listener errors must not break peer notification
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|