@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.
- package/dist/.tsbuildinfo +1 -0
- package/dist/cf-worker/durable-object.d.ts +69 -0
- package/dist/cf-worker/durable-object.d.ts.map +1 -0
- package/dist/cf-worker/durable-object.js +164 -0
- package/dist/cf-worker/durable-object.js.map +1 -0
- package/dist/cf-worker/index.d.ts +8 -0
- package/dist/cf-worker/index.d.ts.map +1 -0
- package/dist/cf-worker/index.js +67 -0
- package/dist/cf-worker/index.js.map +1 -0
- package/dist/common/index.d.ts +2 -0
- package/dist/common/index.d.ts.map +1 -0
- package/dist/common/index.js +2 -0
- package/dist/common/index.js.map +1 -0
- package/dist/common/ws-message-types.d.ts +344 -0
- package/dist/common/ws-message-types.d.ts.map +1 -0
- package/dist/common/ws-message-types.js +58 -0
- package/dist/common/ws-message-types.js.map +1 -0
- package/dist/sync-impl/index.d.ts +2 -0
- package/dist/sync-impl/index.d.ts.map +1 -0
- package/dist/sync-impl/index.js +2 -0
- package/dist/sync-impl/index.js.map +1 -0
- package/dist/sync-impl/ws-impl.d.ts +18 -0
- package/dist/sync-impl/ws-impl.d.ts.map +1 -0
- package/dist/sync-impl/ws-impl.js +125 -0
- package/dist/sync-impl/ws-impl.js.map +1 -0
- package/package.json +26 -0
- package/src/cf-worker/durable-object.ts +230 -0
- package/src/cf-worker/index.ts +84 -0
- package/src/common/index.ts +1 -0
- package/src/common/ws-message-types.ts +119 -0
- package/src/sync-impl/index.ts +1 -0
- package/src/sync-impl/ws-impl.ts +221 -0
- package/tsconfig.json +12 -0
- package/wrangler.toml +21 -0
|
@@ -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 = "..."
|