@livestore/sync-cf 0.2.0 → 0.3.0-dev.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.
Files changed (34) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/cf-worker/durable-object.d.ts +21 -8
  3. package/dist/cf-worker/durable-object.d.ts.map +1 -1
  4. package/dist/cf-worker/durable-object.js +62 -48
  5. package/dist/cf-worker/durable-object.js.map +1 -1
  6. package/dist/common/ws-message-types.d.ts +113 -84
  7. package/dist/common/ws-message-types.d.ts.map +1 -1
  8. package/dist/common/ws-message-types.js +13 -9
  9. package/dist/common/ws-message-types.js.map +1 -1
  10. package/dist/sync-impl/ws-impl.d.ts +2 -1
  11. package/dist/sync-impl/ws-impl.d.ts.map +1 -1
  12. package/dist/sync-impl/ws-impl.js +66 -67
  13. package/dist/sync-impl/ws-impl.js.map +1 -1
  14. package/package.json +10 -3
  15. package/src/cf-worker/durable-object.ts +138 -109
  16. package/src/cf-worker/index.ts +1 -1
  17. package/src/common/ws-message-types.ts +18 -9
  18. package/src/sync-impl/ws-impl.ts +114 -107
  19. package/.netlify/state.json +0 -3
  20. package/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/8ee300993f9a2c909aede59646369fa0cc28d25304b9b9af94f54d4543ad60f4.sqlite +0 -0
  21. package/.wrangler/state/v3/do/websocket-server-WebSocketServer/2c3676d859102e448b271e1b4336b1e40778992b14728e2a2bd642c0f225e9c5.sqlite +0 -0
  22. package/.wrangler/state/v3/do/websocket-server-WebSocketServer/6eb1111cecdc6ae7e3a38c3d7db0f3ec7de5b318e682cdcf0ff0dc881cbbcee7.sqlite +0 -0
  23. package/.wrangler/state/v3/do/websocket-server-WebSocketServer/c4aa0e61dcd784604bee52282348e0538ab69ec76e1652322bbccd539d9ff3ee.sqlite +0 -0
  24. package/.wrangler/state/v3/do/websocket-server-WebSocketServer/d87433156774c933da83e962eba16107cf02ade2f133d7205963558fe47db3ff.sqlite +0 -0
  25. package/.wrangler/tmp/dev-33iU4b/index.js +0 -42167
  26. package/.wrangler/tmp/dev-33iU4b/index.js.map +0 -8
  27. package/.wrangler/tmp/dev-9rcIR8/index.js +0 -18887
  28. package/.wrangler/tmp/dev-9rcIR8/index.js.map +0 -8
  29. package/.wrangler/tmp/dev-rI63Kk/index.js +0 -42165
  30. package/.wrangler/tmp/dev-rI63Kk/index.js.map +0 -8
  31. package/.wrangler/tmp/dev-txPodK/index.js +0 -42118
  32. package/.wrangler/tmp/dev-txPodK/index.js.map +0 -8
  33. package/tsconfig.json +0 -12
  34. package/wrangler.toml +0 -21
@@ -2,11 +2,24 @@
2
2
 
3
3
  import type { SyncBackend, SyncBackendOptionsBase } from '@livestore/common'
4
4
  import { InvalidPullError, InvalidPushError } from '@livestore/common'
5
+ import { pick } from '@livestore/utils'
5
6
  import type { Scope } from '@livestore/utils/effect'
6
- import { Deferred, Effect, Option, PubSub, Queue, Schema, Stream, SubscriptionRef } from '@livestore/utils/effect'
7
+ import {
8
+ Deferred,
9
+ Effect,
10
+ Option,
11
+ PubSub,
12
+ Queue,
13
+ Schedule,
14
+ Schema,
15
+ Stream,
16
+ SubscriptionRef,
17
+ WebSocket,
18
+ } from '@livestore/utils/effect'
7
19
  import { nanoid } from '@livestore/utils/nanoid'
8
20
 
9
21
  import { WSMessage } from '../common/index.js'
22
+ import type { SyncMetadata } from '../common/ws-message-types.js'
10
23
 
11
24
  export interface WsSyncOptions extends SyncBackendOptionsBase {
12
25
  type: 'cf'
@@ -22,61 +35,44 @@ declare global {
22
35
  interface LiveStoreGlobal extends LiveStoreGlobalCf {}
23
36
  }
24
37
 
25
- export const makeWsSync = (options: WsSyncOptions): Effect.Effect<SyncBackend<null>, never, Scope.Scope> =>
38
+ export const makeWsSync = (options: WsSyncOptions): Effect.Effect<SyncBackend<SyncMetadata>, never, Scope.Scope> =>
26
39
  Effect.gen(function* () {
27
40
  const wsUrl = `${options.url}/websocket?room=${options.roomId}`
28
41
 
29
42
  const { isConnected, incomingMessages, send } = yield* connect(wsUrl)
30
43
 
31
- const metadata = Option.none()
32
-
33
44
  const api = {
34
45
  isConnected,
35
- pull: (args, { listenForNew }) =>
36
- listenForNew
37
- ? Effect.gen(function* () {
38
- const requestId = nanoid()
39
- const cursor = Option.getOrUndefined(args)?.cursor.global
40
-
41
- yield* send(WSMessage.PullReq.make({ cursor, requestId }))
42
-
43
- return Stream.fromPubSub(incomingMessages).pipe(
44
- Stream.filter((_) => (_._tag === 'WSMessage.PullRes' ? _.requestId === requestId : true)),
45
- Stream.tap((_) =>
46
- _._tag === 'WSMessage.Error' ? new InvalidPullError({ message: _.message }) : Effect.void,
47
- ),
48
- Stream.filter(Schema.is(Schema.Union(WSMessage.PushBroadcast, WSMessage.PullRes))),
49
- Stream.map((msg) =>
50
- msg._tag === 'WSMessage.PushBroadcast'
51
- ? [{ mutationEventEncoded: msg.mutationEventEncoded, persisted: msg.persisted, metadata }]
52
- : msg.events.map((_) => ({ mutationEventEncoded: _, metadata, persisted: true })),
53
- ),
54
- Stream.flattenIterables,
55
- )
56
- }).pipe(Stream.unwrap)
57
- : Effect.gen(function* () {
58
- const requestId = nanoid()
59
- const cursor = Option.getOrUndefined(args)?.cursor.global
60
-
61
- yield* send(WSMessage.PullReq.make({ cursor, requestId }))
62
-
63
- return Stream.fromPubSub(incomingMessages).pipe(
64
- Stream.filter((_) => _._tag !== 'WSMessage.PushBroadcast' && _.requestId === requestId),
65
- Stream.tap((_) =>
66
- _._tag === 'WSMessage.Error' ? new InvalidPullError({ message: _.message }) : Effect.void,
67
- ),
68
- Stream.filter(Schema.is(WSMessage.PullRes)),
69
- Stream.takeUntil((_) => _.hasMore === false),
70
- Stream.map((_) => _.events),
71
- Stream.flattenIterables,
72
- Stream.map((mutationEventEncoded) => ({
73
- mutationEventEncoded,
74
- metadata,
75
- persisted: true,
76
- })),
77
- )
78
- }).pipe(Stream.unwrap),
79
- push: (mutationEventEncoded, persisted) =>
46
+ pull: (args) =>
47
+ Effect.gen(function* () {
48
+ const requestId = nanoid()
49
+ const cursor = Option.getOrUndefined(args)?.cursor.global
50
+
51
+ yield* send(WSMessage.PullReq.make({ cursor, requestId }))
52
+
53
+ return Stream.fromPubSub(incomingMessages).pipe(
54
+ Stream.filter((_) => (_._tag === 'WSMessage.PullRes' ? _.requestId === requestId : true)),
55
+ Stream.tap((_) =>
56
+ _._tag === 'WSMessage.Error' && _.requestId === requestId
57
+ ? new InvalidPullError({ message: _.message })
58
+ : Effect.void,
59
+ ),
60
+ Stream.filter(Schema.is(Schema.Union(WSMessage.PushBroadcast, WSMessage.PullRes))),
61
+ Stream.map((msg) =>
62
+ msg._tag === 'WSMessage.PushBroadcast'
63
+ ? { batch: [pick(msg, ['mutationEventEncoded', 'metadata'])], remaining: 0 }
64
+ : {
65
+ batch: msg.events.map(({ mutationEventEncoded, metadata }) => ({
66
+ mutationEventEncoded,
67
+ metadata,
68
+ })),
69
+ remaining: msg.remaining,
70
+ },
71
+ ),
72
+ )
73
+ }).pipe(Stream.unwrap),
74
+
75
+ push: (batch) =>
80
76
  Effect.gen(function* () {
81
77
  const ready = yield* Deferred.make<void, InvalidPushError>()
82
78
  const requestId = nanoid()
@@ -85,11 +81,12 @@ export const makeWsSync = (options: WsSyncOptions): Effect.Effect<SyncBackend<nu
85
81
  Stream.filter((_) => _._tag !== 'WSMessage.PushBroadcast' && _.requestId === requestId),
86
82
  Stream.tap((_) =>
87
83
  _._tag === 'WSMessage.Error'
88
- ? Deferred.fail(ready, new InvalidPushError({ message: _.message }))
84
+ ? Deferred.fail(ready, new InvalidPushError({ reason: { _tag: 'Unexpected', message: _.message } }))
89
85
  : Effect.void,
90
86
  ),
91
87
  Stream.filter(Schema.is(WSMessage.PushAck)),
92
- Stream.filter((_) => _.mutationId === mutationEventEncoded.id.global),
88
+ // TODO bring back filterting of "own events"
89
+ // Stream.filter((_) => _.mutationId === mutationEventEncoded.id.global),
93
90
  Stream.take(1),
94
91
  Stream.tap(() => Deferred.succeed(ready, void 0)),
95
92
  Stream.runDrain,
@@ -97,13 +94,15 @@ export const makeWsSync = (options: WsSyncOptions): Effect.Effect<SyncBackend<nu
97
94
  Effect.fork,
98
95
  )
99
96
 
100
- yield* send(WSMessage.PushReq.make({ mutationEventEncoded, requestId, persisted }))
97
+ yield* send(WSMessage.PushReq.make({ batch, requestId }))
98
+
99
+ yield* ready
101
100
 
102
- yield* Deferred.await(ready)
101
+ const createdAt = new Date().toISOString()
103
102
 
104
- return { metadata }
103
+ return { metadata: Array.from({ length: batch.length }, () => Option.some({ createdAt })) }
105
104
  }),
106
- } satisfies SyncBackend<null>
105
+ } satisfies SyncBackend<SyncMetadata>
107
106
 
108
107
  return api
109
108
  })
@@ -111,9 +110,11 @@ export const makeWsSync = (options: WsSyncOptions): Effect.Effect<SyncBackend<nu
111
110
  const connect = (wsUrl: string) =>
112
111
  Effect.gen(function* () {
113
112
  const isConnected = yield* SubscriptionRef.make(false)
114
- const wsRef: { current: WebSocket | undefined } = { current: undefined }
113
+ const socketRef: { current: globalThis.WebSocket | undefined } = { current: undefined }
115
114
 
116
- const incomingMessages = yield* PubSub.unbounded<Exclude<WSMessage.BackendToClientMessage, WSMessage.Pong>>()
115
+ const incomingMessages = yield* PubSub.unbounded<Exclude<WSMessage.BackendToClientMessage, WSMessage.Pong>>().pipe(
116
+ Effect.acquireRelease(PubSub.shutdown),
117
+ )
117
118
 
118
119
  const waitUntilOnline = isConnected.changes.pipe(Stream.filter(Boolean), Stream.take(1), Stream.runDrain)
119
120
 
@@ -122,80 +123,86 @@ const connect = (wsUrl: string) =>
122
123
  // Wait first until we're online
123
124
  yield* waitUntilOnline
124
125
 
125
- wsRef.current!.send(Schema.encodeSync(Schema.parseJson(WSMessage.Message))(message))
126
+ yield* Effect.spanEvent(
127
+ `Sending message: ${message._tag}`,
128
+ message._tag === 'WSMessage.PushReq'
129
+ ? {
130
+ id: message.batch[0]!.id.global,
131
+ parentId: message.batch[0]!.parentId.global,
132
+ batchLength: message.batch.length,
133
+ }
134
+ : message._tag === 'WSMessage.PullReq'
135
+ ? { cursor: message.cursor ?? '-' }
136
+ : {},
137
+ )
138
+
139
+ // TODO use MsgPack instead of JSON to speed up the serialization / reduce the size of the messages
140
+ socketRef.current!.send(Schema.encodeSync(Schema.parseJson(WSMessage.Message))(message))
126
141
  })
127
142
 
128
143
  const innerConnect = Effect.gen(function* () {
129
144
  // If the browser already tells us we're offline, then we'll at least wait until the browser
130
145
  // thinks we're online again. (We'll only know for sure once the WS conneciton is established.)
131
- while (navigator.onLine === false) {
146
+ while (typeof navigator !== 'undefined' && navigator.onLine === false) {
132
147
  yield* Effect.sleep(1000)
133
148
  }
134
149
  // if (navigator.onLine === false) {
135
150
  // yield* Effect.async((cb) => self.addEventListener('online', () => cb(Effect.void)))
136
151
  // }
137
152
 
138
- const ws = new WebSocket(wsUrl)
153
+ const socket = yield* WebSocket.makeWebSocket({ url: wsUrl, reconnect: Schedule.exponential(100) })
154
+
155
+ yield* SubscriptionRef.set(isConnected, true)
156
+ socketRef.current = socket
157
+
139
158
  const connectionClosed = yield* Deferred.make<void>()
140
159
 
141
- const pongMessages = yield* Queue.unbounded<WSMessage.Pong>()
160
+ const pongMessages = yield* Queue.unbounded<WSMessage.Pong>().pipe(Effect.acquireRelease(Queue.shutdown))
142
161
 
143
- const messageHandler = (event: MessageEvent<any>): void => {
144
- const decodedEventRes = Schema.decodeUnknownEither(Schema.parseJson(WSMessage.BackendToClientMessage))(
145
- event.data,
146
- )
162
+ yield* Effect.eventListener(socket, 'message', (event: MessageEvent) =>
163
+ Effect.gen(function* () {
164
+ const decodedEventRes = Schema.decodeUnknownEither(Schema.parseJson(WSMessage.BackendToClientMessage))(
165
+ event.data,
166
+ )
147
167
 
148
- if (decodedEventRes._tag === 'Left') {
149
- console.error('Sync: Invalid message received', decodedEventRes.left)
150
- return
151
- } else {
152
- if (decodedEventRes.right._tag === 'WSMessage.Pong') {
153
- Queue.offer(pongMessages, decodedEventRes.right).pipe(Effect.runSync)
168
+ if (decodedEventRes._tag === 'Left') {
169
+ console.error('Sync: Invalid message received', decodedEventRes.left)
170
+ return
154
171
  } else {
155
- PubSub.publish(incomingMessages, decodedEventRes.right).pipe(Effect.runSync)
172
+ if (decodedEventRes.right._tag === 'WSMessage.Pong') {
173
+ yield* Queue.offer(pongMessages, decodedEventRes.right)
174
+ } else {
175
+ // yield* Effect.logDebug(`decodedEventRes: ${decodedEventRes.right._tag}`)
176
+ yield* PubSub.publish(incomingMessages, decodedEventRes.right)
177
+ }
156
178
  }
157
- }
158
- }
179
+ }),
180
+ )
159
181
 
160
- const offlineHandler = () => {
161
- Deferred.succeed(connectionClosed, void 0).pipe(Effect.runSync)
162
- }
182
+ yield* Effect.eventListener(socket, 'close', () => Deferred.succeed(connectionClosed, void 0))
183
+
184
+ yield* Effect.eventListener(socket, 'error', () =>
185
+ Effect.gen(function* () {
186
+ socket.close(3000, 'Sync: WebSocket error')
187
+ yield* Deferred.succeed(connectionClosed, void 0)
188
+ }),
189
+ )
163
190
 
164
191
  // NOTE it seems that this callback doesn't work reliably on a worker but only via `window.addEventListener`
165
192
  // We might need to proxy the event from the main thread to the worker if we want this to work reliably.
166
- self.addEventListener('offline', offlineHandler)
193
+ // eslint-disable-next-line unicorn/prefer-global-this
194
+ if (typeof self !== 'undefined') {
195
+ // eslint-disable-next-line unicorn/prefer-global-this
196
+ yield* Effect.eventListener(self, 'offline', () => Deferred.succeed(connectionClosed, void 0))
197
+ }
167
198
 
168
199
  yield* Effect.addFinalizer(() =>
169
200
  Effect.gen(function* () {
170
- ws.removeEventListener('message', messageHandler)
171
- self.removeEventListener('offline', offlineHandler)
172
- wsRef.current?.close()
173
- wsRef.current = undefined
201
+ socketRef.current = undefined
174
202
  yield* SubscriptionRef.set(isConnected, false)
175
203
  }),
176
204
  )
177
205
 
178
- ws.addEventListener('message', messageHandler)
179
-
180
- if (ws.readyState === WebSocket.OPEN) {
181
- wsRef.current = ws
182
- SubscriptionRef.set(isConnected, true).pipe(Effect.runSync)
183
- } else {
184
- ws.addEventListener('open', () => {
185
- wsRef.current = ws
186
- SubscriptionRef.set(isConnected, true).pipe(Effect.runSync)
187
- })
188
- }
189
-
190
- ws.addEventListener('close', () => {
191
- Deferred.succeed(connectionClosed, void 0).pipe(Effect.runSync)
192
- })
193
-
194
- ws.addEventListener('error', () => {
195
- ws.close()
196
- Deferred.succeed(connectionClosed, void 0).pipe(Effect.runSync)
197
- })
198
-
199
206
  const checkPingPong = Effect.gen(function* () {
200
207
  // TODO include pong latency infomation in network status
201
208
  yield* send({ _tag: 'WSMessage.Ping', requestId: 'ping' })
@@ -204,7 +211,7 @@ const connect = (wsUrl: string) =>
204
211
  yield* Queue.take(pongMessages).pipe(Effect.timeout(5000))
205
212
 
206
213
  yield* Effect.sleep(25_000)
207
- }).pipe(Effect.withSpan('@livestore/sync-cf:connect:checkPingPong'))
214
+ }).pipe(Effect.withSpan('@livestore/sync-cf:connect:checkPingPong'), Effect.ignore)
208
215
 
209
216
  yield* waitUntilOnline.pipe(
210
217
  Effect.andThen(checkPingPong.pipe(Effect.forever)),
@@ -212,10 +219,10 @@ const connect = (wsUrl: string) =>
212
219
  Effect.forkScoped,
213
220
  )
214
221
 
215
- yield* Deferred.await(connectionClosed)
216
- }).pipe(Effect.scoped)
222
+ yield* connectionClosed
223
+ }).pipe(Effect.scoped, Effect.withSpan('@livestore/sync-cf:connect'))
217
224
 
218
- yield* innerConnect.pipe(Effect.forever, Effect.tapCauseLogPretty, Effect.forkScoped)
225
+ yield* innerConnect.pipe(Effect.forever, Effect.interruptible, Effect.tapCauseLogPretty, Effect.forkScoped)
219
226
 
220
227
  return { isConnected, incomingMessages, send }
221
228
  })
@@ -1,3 +0,0 @@
1
- {
2
- "siteId": "7d10806a-dc4e-4216-a781-adde1adc649f"
3
- }