@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.
@@ -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
+ }
@@ -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
+ }