@livestore/sync-cf 0.0.0-snapshot-909cdd1ac2fd591945c2be2b0f53e14d87f3c9d4

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,119 @@
1
+ import { mutationEventSchemaEncodedAny } from '@livestore/common/schema'
2
+ import { Schema } from '@livestore/utils/effect'
3
+
4
+ export const PullReq = Schema.TaggedStruct('WSMessage.PullReq', {
5
+ requestId: Schema.String,
6
+ /** Omitting the cursor will start from the beginning */
7
+ cursor: Schema.optional(Schema.Number),
8
+ })
9
+
10
+ export type PullReq = typeof PullReq.Type
11
+
12
+ export const PullRes = Schema.TaggedStruct('WSMessage.PullRes', {
13
+ requestId: Schema.String,
14
+ // /** The */
15
+ // cursor: Schema.String,
16
+ events: Schema.Array(mutationEventSchemaEncodedAny),
17
+ hasMore: Schema.Boolean,
18
+ })
19
+
20
+ export type PullRes = typeof PullRes.Type
21
+
22
+ export const PushBroadcast = Schema.TaggedStruct('WSMessage.PushBroadcast', {
23
+ mutationEventEncoded: mutationEventSchemaEncodedAny,
24
+ persisted: Schema.Boolean,
25
+ })
26
+
27
+ export type PushBroadcast = typeof PushBroadcast.Type
28
+
29
+ export const PushReq = Schema.TaggedStruct('WSMessage.PushReq', {
30
+ requestId: Schema.String,
31
+ mutationEventEncoded: mutationEventSchemaEncodedAny,
32
+ persisted: Schema.Boolean,
33
+ })
34
+
35
+ export type PushReq = typeof PushReq.Type
36
+
37
+ export const PushAck = Schema.TaggedStruct('WSMessage.PushAck', {
38
+ requestId: Schema.String,
39
+ mutationId: Schema.Number,
40
+ })
41
+
42
+ export type PushAck = typeof PushAck.Type
43
+
44
+ export const Error = Schema.TaggedStruct('WSMessage.Error', {
45
+ requestId: Schema.String,
46
+ message: Schema.String,
47
+ })
48
+
49
+ export const Ping = Schema.TaggedStruct('WSMessage.Ping', {
50
+ requestId: Schema.Literal('ping'),
51
+ })
52
+
53
+ export type Ping = typeof Ping.Type
54
+
55
+ export const Pong = Schema.TaggedStruct('WSMessage.Pong', {
56
+ requestId: Schema.Literal('ping'),
57
+ })
58
+
59
+ export type Pong = typeof Pong.Type
60
+
61
+ export const AdminResetRoomReq = Schema.TaggedStruct('WSMessage.AdminResetRoomReq', {
62
+ requestId: Schema.String,
63
+ adminSecret: Schema.String,
64
+ })
65
+
66
+ export type AdminResetRoomReq = typeof AdminResetRoomReq.Type
67
+
68
+ export const AdminResetRoomRes = Schema.TaggedStruct('WSMessage.AdminResetRoomRes', {
69
+ requestId: Schema.String,
70
+ })
71
+
72
+ export type AdminResetRoomRes = typeof AdminResetRoomRes.Type
73
+
74
+ export const AdminInfoReq = Schema.TaggedStruct('WSMessage.AdminInfoReq', {
75
+ requestId: Schema.String,
76
+ adminSecret: Schema.String,
77
+ })
78
+
79
+ export type AdminInfoReq = typeof AdminInfoReq.Type
80
+
81
+ export const AdminInfoRes = Schema.TaggedStruct('WSMessage.AdminInfoRes', {
82
+ requestId: Schema.String,
83
+ info: Schema.Struct({
84
+ durableObjectId: Schema.String,
85
+ }),
86
+ })
87
+
88
+ export type AdminInfoRes = typeof AdminInfoRes.Type
89
+
90
+ export const Message = Schema.Union(
91
+ PullReq,
92
+ PullRes,
93
+ PushBroadcast,
94
+ PushReq,
95
+ PushAck,
96
+ Error,
97
+ Ping,
98
+ Pong,
99
+ AdminResetRoomReq,
100
+ AdminResetRoomRes,
101
+ AdminInfoReq,
102
+ AdminInfoRes,
103
+ )
104
+ export type Message = typeof Message.Type
105
+ export type MessageEncoded = typeof Message.Encoded
106
+
107
+ export const BackendToClientMessage = Schema.Union(
108
+ PullRes,
109
+ PushBroadcast,
110
+ PushAck,
111
+ AdminResetRoomRes,
112
+ AdminInfoRes,
113
+ Error,
114
+ Pong,
115
+ )
116
+ export type BackendToClientMessage = typeof BackendToClientMessage.Type
117
+
118
+ export const ClientToBackendMessage = Schema.Union(PullReq, PushReq, AdminResetRoomReq, AdminInfoReq, Ping)
119
+ export type ClientToBackendMessage = typeof ClientToBackendMessage.Type
@@ -0,0 +1 @@
1
+ export * from './ws-impl.js'
@@ -0,0 +1,221 @@
1
+ /// <reference lib="dom" />
2
+
3
+ import type { SyncBackend, SyncBackendOptionsBase } from '@livestore/common'
4
+ import { InvalidPullError, InvalidPushError } from '@livestore/common'
5
+ import type { Scope } from '@livestore/utils/effect'
6
+ import { Deferred, Effect, Option, PubSub, Queue, Schema, Stream, SubscriptionRef } from '@livestore/utils/effect'
7
+ import { nanoid } from '@livestore/utils/nanoid'
8
+
9
+ import { WSMessage } from '../common/index.js'
10
+
11
+ export interface WsSyncOptions extends SyncBackendOptionsBase {
12
+ type: 'cf'
13
+ url: string
14
+ roomId: string
15
+ }
16
+
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> =>
26
+ Effect.gen(function* () {
27
+ const wsUrl = `${options.url}/websocket?room=${options.roomId}`
28
+
29
+ const { isConnected, incomingMessages, send } = yield* connect(wsUrl)
30
+
31
+ const metadata = Option.none()
32
+
33
+ const api = {
34
+ 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) =>
80
+ Effect.gen(function* () {
81
+ const ready = yield* Deferred.make<void, InvalidPushError>()
82
+ const requestId = nanoid()
83
+
84
+ yield* Stream.fromPubSub(incomingMessages).pipe(
85
+ Stream.filter((_) => _._tag !== 'WSMessage.PushBroadcast' && _.requestId === requestId),
86
+ Stream.tap((_) =>
87
+ _._tag === 'WSMessage.Error'
88
+ ? Deferred.fail(ready, new InvalidPushError({ message: _.message }))
89
+ : Effect.void,
90
+ ),
91
+ Stream.filter(Schema.is(WSMessage.PushAck)),
92
+ Stream.filter((_) => _.mutationId === mutationEventEncoded.id.global),
93
+ Stream.take(1),
94
+ Stream.tap(() => Deferred.succeed(ready, void 0)),
95
+ Stream.runDrain,
96
+ Effect.tapCauseLogPretty,
97
+ Effect.fork,
98
+ )
99
+
100
+ yield* send(WSMessage.PushReq.make({ mutationEventEncoded, requestId, persisted }))
101
+
102
+ yield* Deferred.await(ready)
103
+
104
+ return { metadata }
105
+ }),
106
+ } satisfies SyncBackend<null>
107
+
108
+ return api
109
+ })
110
+
111
+ const connect = (wsUrl: string) =>
112
+ Effect.gen(function* () {
113
+ const isConnected = yield* SubscriptionRef.make(false)
114
+ const wsRef: { current: WebSocket | undefined } = { current: undefined }
115
+
116
+ const incomingMessages = yield* PubSub.unbounded<Exclude<WSMessage.BackendToClientMessage, WSMessage.Pong>>()
117
+
118
+ const waitUntilOnline = isConnected.changes.pipe(Stream.filter(Boolean), Stream.take(1), Stream.runDrain)
119
+
120
+ const send = (message: WSMessage.Message) =>
121
+ Effect.gen(function* () {
122
+ // Wait first until we're online
123
+ yield* waitUntilOnline
124
+
125
+ wsRef.current!.send(Schema.encodeSync(Schema.parseJson(WSMessage.Message))(message))
126
+ })
127
+
128
+ const innerConnect = Effect.gen(function* () {
129
+ // If the browser already tells us we're offline, then we'll at least wait until the browser
130
+ // thinks we're online again. (We'll only know for sure once the WS conneciton is established.)
131
+ while (navigator.onLine === false) {
132
+ yield* Effect.sleep(1000)
133
+ }
134
+ // if (navigator.onLine === false) {
135
+ // yield* Effect.async((cb) => self.addEventListener('online', () => cb(Effect.void)))
136
+ // }
137
+
138
+ const ws = new WebSocket(wsUrl)
139
+ const connectionClosed = yield* Deferred.make<void>()
140
+
141
+ const pongMessages = yield* Queue.unbounded<WSMessage.Pong>()
142
+
143
+ const messageHandler = (event: MessageEvent<any>): void => {
144
+ const decodedEventRes = Schema.decodeUnknownEither(Schema.parseJson(WSMessage.BackendToClientMessage))(
145
+ event.data,
146
+ )
147
+
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)
154
+ } else {
155
+ PubSub.publish(incomingMessages, decodedEventRes.right).pipe(Effect.runSync)
156
+ }
157
+ }
158
+ }
159
+
160
+ const offlineHandler = () => {
161
+ Deferred.succeed(connectionClosed, void 0).pipe(Effect.runSync)
162
+ }
163
+
164
+ // NOTE it seems that this callback doesn't work reliably on a worker but only via `window.addEventListener`
165
+ // 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)
167
+
168
+ yield* Effect.addFinalizer(() =>
169
+ Effect.gen(function* () {
170
+ ws.removeEventListener('message', messageHandler)
171
+ self.removeEventListener('offline', offlineHandler)
172
+ wsRef.current?.close()
173
+ wsRef.current = undefined
174
+ yield* SubscriptionRef.set(isConnected, false)
175
+ }),
176
+ )
177
+
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
+ const checkPingPong = Effect.gen(function* () {
200
+ // TODO include pong latency infomation in network status
201
+ yield* send({ _tag: 'WSMessage.Ping', requestId: 'ping' })
202
+
203
+ // NOTE those numbers might need more fine-tuning to allow for bad network conditions
204
+ yield* Queue.take(pongMessages).pipe(Effect.timeout(5000))
205
+
206
+ yield* Effect.sleep(25_000)
207
+ }).pipe(Effect.withSpan('@livestore/sync-cf:connect:checkPingPong'))
208
+
209
+ yield* waitUntilOnline.pipe(
210
+ Effect.andThen(checkPingPong.pipe(Effect.forever)),
211
+ Effect.tapErrorCause(() => Deferred.succeed(connectionClosed, void 0)),
212
+ Effect.forkScoped,
213
+ )
214
+
215
+ yield* Deferred.await(connectionClosed)
216
+ }).pipe(Effect.scoped)
217
+
218
+ yield* innerConnect.pipe(Effect.forever, Effect.tapCauseLogPretty, Effect.forkScoped)
219
+
220
+ return { isConnected, incomingMessages, send }
221
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "target": "es2022",
7
+ "tsBuildInfoFile": "./dist/.tsbuildinfo",
8
+ "types": ["@cloudflare/workers-types"]
9
+ },
10
+ "include": ["./src"],
11
+ "references": [{ "path": "../common" }, { "path": "../utils" }]
12
+ }
package/wrangler.toml ADDED
@@ -0,0 +1,21 @@
1
+ name = "websocket-server"
2
+ main = "./src/cf-worker/index.ts"
3
+ compatibility_date = "2024-05-12"
4
+
5
+ [[durable_objects.bindings]]
6
+ name = "WEBSOCKET_SERVER"
7
+ class_name = "WebSocketServer"
8
+
9
+ [[migrations]]
10
+ tag = "v1"
11
+ new_classes = ["WebSocketServer"]
12
+
13
+ [[d1_databases]]
14
+ binding = "DB"
15
+ database_name = "livestore-sync-cf-demo"
16
+ database_id = "1c9b5dae-f1fa-49d8-83fa-7bd5b39c4121"
17
+ # database_id = "${LIVESTORE_CF_SYNC_DATABASE_ID}"
18
+
19
+ [vars]
20
+ # should be set via CF dashboard (as secret)
21
+ # ADMIN_SECRET = "..."