@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/cf-worker/durable-object.d.ts +21 -8
- package/dist/cf-worker/durable-object.d.ts.map +1 -1
- package/dist/cf-worker/durable-object.js +62 -48
- package/dist/cf-worker/durable-object.js.map +1 -1
- package/dist/common/ws-message-types.d.ts +113 -84
- package/dist/common/ws-message-types.d.ts.map +1 -1
- package/dist/common/ws-message-types.js +13 -9
- package/dist/common/ws-message-types.js.map +1 -1
- package/dist/sync-impl/ws-impl.d.ts +2 -1
- package/dist/sync-impl/ws-impl.d.ts.map +1 -1
- package/dist/sync-impl/ws-impl.js +66 -67
- package/dist/sync-impl/ws-impl.js.map +1 -1
- package/package.json +10 -3
- package/src/cf-worker/durable-object.ts +138 -109
- package/src/cf-worker/index.ts +1 -1
- package/src/common/ws-message-types.ts +18 -9
- package/src/sync-impl/ws-impl.ts +114 -107
- package/.netlify/state.json +0 -3
- package/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/8ee300993f9a2c909aede59646369fa0cc28d25304b9b9af94f54d4543ad60f4.sqlite +0 -0
- package/.wrangler/state/v3/do/websocket-server-WebSocketServer/2c3676d859102e448b271e1b4336b1e40778992b14728e2a2bd642c0f225e9c5.sqlite +0 -0
- package/.wrangler/state/v3/do/websocket-server-WebSocketServer/6eb1111cecdc6ae7e3a38c3d7db0f3ec7de5b318e682cdcf0ff0dc881cbbcee7.sqlite +0 -0
- package/.wrangler/state/v3/do/websocket-server-WebSocketServer/c4aa0e61dcd784604bee52282348e0538ab69ec76e1652322bbccd539d9ff3ee.sqlite +0 -0
- package/.wrangler/state/v3/do/websocket-server-WebSocketServer/d87433156774c933da83e962eba16107cf02ade2f133d7205963558fe47db3ff.sqlite +0 -0
- package/.wrangler/tmp/dev-33iU4b/index.js +0 -42167
- package/.wrangler/tmp/dev-33iU4b/index.js.map +0 -8
- package/.wrangler/tmp/dev-9rcIR8/index.js +0 -18887
- package/.wrangler/tmp/dev-9rcIR8/index.js.map +0 -8
- package/.wrangler/tmp/dev-rI63Kk/index.js +0 -42165
- package/.wrangler/tmp/dev-rI63Kk/index.js.map +0 -8
- package/.wrangler/tmp/dev-txPodK/index.js +0 -42118
- package/.wrangler/tmp/dev-txPodK/index.js.map +0 -8
- package/tsconfig.json +0 -12
- package/wrangler.toml +0 -21
package/src/sync-impl/ws-impl.ts
CHANGED
|
@@ -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 {
|
|
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<
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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({
|
|
97
|
+
yield* send(WSMessage.PushReq.make({ batch, requestId }))
|
|
98
|
+
|
|
99
|
+
yield* ready
|
|
101
100
|
|
|
102
|
-
|
|
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<
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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*
|
|
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
|
})
|
package/.netlify/state.json
DELETED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|