@livestore/sync-cf 0.2.0 → 0.3.0-dev.10

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