@livestore/sync-cf 0.0.58-dev.6

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 (37) hide show
  1. package/.wrangler/state/v3/do/websocket-server-WebSocketServer/33d6e9d204e8c535d55150b9258a9388ddf594a89f30fc1d557e64546a59e731.sqlite +0 -0
  2. package/.wrangler/tmp/dev-9rcIR8/index.js +18887 -0
  3. package/.wrangler/tmp/dev-9rcIR8/index.js.map +8 -0
  4. package/dist/.tsbuildinfo +1 -0
  5. package/dist/cf-worker/durable-object.d.ts +59 -0
  6. package/dist/cf-worker/durable-object.d.ts.map +1 -0
  7. package/dist/cf-worker/durable-object.js +132 -0
  8. package/dist/cf-worker/durable-object.js.map +1 -0
  9. package/dist/cf-worker/index.d.ts +8 -0
  10. package/dist/cf-worker/index.d.ts.map +1 -0
  11. package/dist/cf-worker/index.js +67 -0
  12. package/dist/cf-worker/index.js.map +1 -0
  13. package/dist/common/index.d.ts +2 -0
  14. package/dist/common/index.d.ts.map +1 -0
  15. package/dist/common/index.js +2 -0
  16. package/dist/common/index.js.map +1 -0
  17. package/dist/common/ws-message-types.d.ts +156 -0
  18. package/dist/common/ws-message-types.d.ts.map +1 -0
  19. package/dist/common/ws-message-types.js +58 -0
  20. package/dist/common/ws-message-types.js.map +1 -0
  21. package/dist/sync-impl/index.d.ts +2 -0
  22. package/dist/sync-impl/index.d.ts.map +1 -0
  23. package/dist/sync-impl/index.js +2 -0
  24. package/dist/sync-impl/index.js.map +1 -0
  25. package/dist/sync-impl/ws-impl.d.ts +18 -0
  26. package/dist/sync-impl/ws-impl.d.ts.map +1 -0
  27. package/dist/sync-impl/ws-impl.js +122 -0
  28. package/dist/sync-impl/ws-impl.js.map +1 -0
  29. package/package.json +26 -0
  30. package/src/cf-worker/durable-object.ts +187 -0
  31. package/src/cf-worker/index.ts +84 -0
  32. package/src/common/index.ts +1 -0
  33. package/src/common/ws-message-types.ts +109 -0
  34. package/src/sync-impl/index.ts +1 -0
  35. package/src/sync-impl/ws-impl.ts +210 -0
  36. package/tsconfig.json +11 -0
  37. package/wrangler.toml +21 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ws-impl.js","sourceRoot":"","sources":["../../src/sync-impl/ws-impl.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@livestore/sync-cf",
3
+ "version": "0.0.58-dev.6",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "types": "./dist/sync-impl/index.d.ts",
8
+ "default": "./dist/sync-impl/index.js"
9
+ }
10
+ },
11
+ "dependencies": {
12
+ "@livestore/common": "0.0.58-dev.6",
13
+ "@livestore/utils": "0.0.58-dev.6"
14
+ },
15
+ "devDependencies": {
16
+ "@cloudflare/workers-types": "4.20240729.0",
17
+ "wrangler": "^3.68.0"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "scripts": {
23
+ "deploy": "wrangler publish",
24
+ "test": "echo 'No tests yet'"
25
+ }
26
+ }
@@ -0,0 +1,187 @@
1
+ import { makeColumnSpec } from '@livestore/common'
2
+ import { DbSchema, type MutationEvent } from '@livestore/common/schema'
3
+ import { shouldNeverHappen } from '@livestore/utils'
4
+ import { Effect, Schema } from '@livestore/utils/effect'
5
+ import { DurableObject } from 'cloudflare:workers'
6
+
7
+ import { WSMessage } from '../common/index.js'
8
+
9
+ export interface Env {
10
+ WEBSOCKET_SERVER: DurableObjectNamespace<WebSocketServer>
11
+ DB: D1Database
12
+ ADMIN_SECRET: string
13
+ }
14
+
15
+ type WebSocketClient = WebSocket
16
+
17
+ const encodeMessage = Schema.encodeSync(Schema.parseJson(WSMessage.Message))
18
+ const decodeMessage = Schema.decodeUnknownEither(Schema.parseJson(WSMessage.Message))
19
+
20
+ export const mutationLogTable = DbSchema.table('__unused', {
21
+ // TODO add parent ids (see https://vlcn.io/blog/crdt-substrate)
22
+ id: DbSchema.text({ primaryKey: true }),
23
+ mutation: DbSchema.text({ nullable: false }),
24
+ args: DbSchema.text({ nullable: false, schema: Schema.parseJson(Schema.Any) }),
25
+ })
26
+
27
+ // Durable Object
28
+ export class WebSocketServer extends DurableObject<Env> {
29
+ dbName = `mutation_log_${this.ctx.id.toString()}`
30
+ storage = makeStorage(this.ctx, this.env, this.dbName)
31
+
32
+ constructor(ctx: DurableObjectState, env: Env) {
33
+ super(ctx, env)
34
+ }
35
+
36
+ fetch = async (_request: Request) =>
37
+ Effect.gen(this, function* () {
38
+ const { 0: client, 1: server } = new WebSocketPair()
39
+
40
+ // See https://developers.cloudflare.com/durable-objects/examples/websocket-hibernation-server
41
+
42
+ this.ctx.acceptWebSocket(server)
43
+
44
+ this.ctx.setWebSocketAutoResponse(
45
+ new WebSocketRequestResponsePair(
46
+ encodeMessage(WSMessage.Ping.make({ requestId: 'ping' })),
47
+ encodeMessage(WSMessage.Pong.make({ requestId: 'ping' })),
48
+ ),
49
+ )
50
+
51
+ const colSpec = makeColumnSpec(mutationLogTable.sqliteDef.ast)
52
+ this.env.DB.exec(`CREATE TABLE IF NOT EXISTS ${this.dbName} (${colSpec}) strict`)
53
+
54
+ return new Response(null, {
55
+ status: 101,
56
+ webSocket: client,
57
+ })
58
+ }).pipe(Effect.tapCauseLogPretty, Effect.runPromise)
59
+
60
+ webSocketMessage = async (ws: WebSocketClient, message: ArrayBuffer | string) => {
61
+ const decodedMessageRes = decodeMessage(message)
62
+
63
+ if (decodedMessageRes._tag === 'Left') {
64
+ console.error('Invalid message received', decodedMessageRes.left)
65
+ return
66
+ }
67
+
68
+ const decodedMessage = decodedMessageRes.right
69
+ const requestId = decodedMessage.requestId
70
+
71
+ switch (decodedMessage._tag) {
72
+ case 'WSMessage.PullReq': {
73
+ const cursor = decodedMessage.cursor
74
+ const CHUNK_SIZE = 100
75
+
76
+ // TODO use streaming
77
+ const remainingEvents = [...(await this.storage.getEvents(cursor))]
78
+
79
+ // NOTE we want to make sure the WS server responds at least once with `InitRes` even if `events` is empty
80
+ while (true) {
81
+ const events = remainingEvents.splice(0, CHUNK_SIZE)
82
+ const hasMore = remainingEvents.length > 0
83
+
84
+ ws.send(encodeMessage(WSMessage.PullRes.make({ events, hasMore, requestId })))
85
+
86
+ if (hasMore === false) {
87
+ break
88
+ }
89
+ }
90
+
91
+ break
92
+ }
93
+ case 'WSMessage.PushReq': {
94
+ // NOTE we're currently not blocking on this to allow broadcasting right away
95
+ // however we should do some mutation validation first (e.g. checking parent event id)
96
+ const storePromise = decodedMessage.persisted
97
+ ? this.storage.appendEvent(decodedMessage.mutationEventEncoded)
98
+ : Promise.resolve()
99
+
100
+ ws.send(
101
+ encodeMessage(WSMessage.PushAck.make({ mutationId: decodedMessage.mutationEventEncoded.id, requestId })),
102
+ )
103
+
104
+ // console.debug(`Broadcasting mutation event to ${this.subscribedWebSockets.size} clients`)
105
+
106
+ const connectedClients = this.ctx.getWebSockets()
107
+
108
+ if (connectedClients.length > 0) {
109
+ const broadcastMessage = encodeMessage(
110
+ WSMessage.PushBroadcast.make({
111
+ mutationEventEncoded: decodedMessage.mutationEventEncoded,
112
+ requestId,
113
+ persisted: decodedMessage.persisted,
114
+ }),
115
+ )
116
+
117
+ for (const conn of connectedClients) {
118
+ console.log('Broadcasting to client', conn === ws ? 'self' : 'other')
119
+ if (conn !== ws) {
120
+ conn.send(broadcastMessage)
121
+ }
122
+ }
123
+ }
124
+
125
+ await storePromise
126
+
127
+ break
128
+ }
129
+ case 'WSMessage.AdminResetRoomReq': {
130
+ if (decodedMessage.adminSecret !== this.env.ADMIN_SECRET) {
131
+ ws.send(encodeMessage(WSMessage.Error.make({ message: 'Invalid admin secret', requestId })))
132
+ return
133
+ }
134
+
135
+ await this.storage.resetRoom()
136
+ ws.send(encodeMessage(WSMessage.AdminResetRoomRes.make({ requestId })))
137
+
138
+ break
139
+ }
140
+ case 'WSMessage.AdminInfoReq': {
141
+ if (decodedMessage.adminSecret !== this.env.ADMIN_SECRET) {
142
+ ws.send(encodeMessage(WSMessage.Error.make({ message: 'Invalid admin secret', requestId })))
143
+ return
144
+ }
145
+
146
+ ws.send(
147
+ encodeMessage(WSMessage.AdminInfoRes.make({ requestId, info: { durableObjectId: this.ctx.id.toString() } })),
148
+ )
149
+
150
+ break
151
+ }
152
+ default: {
153
+ console.error('unsupported message', decodedMessage)
154
+ return shouldNeverHappen()
155
+ }
156
+ }
157
+ }
158
+
159
+ webSocketClose = async (ws: WebSocketClient, code: number, _reason: string, _wasClean: boolean) => {
160
+ // If the client closes the connection, the runtime will invoke the webSocketClose() handler.
161
+ ws.close(code, 'Durable Object is closing WebSocket')
162
+ }
163
+ }
164
+
165
+ const makeStorage = (ctx: DurableObjectState, env: Env, dbName: string) => {
166
+ const getEvents = async (cursor: string | undefined): Promise<ReadonlyArray<MutationEvent.Any>> => {
167
+ const whereClause = cursor ? `WHERE id > '${cursor}'` : ''
168
+ // TODO handle case where `cursor` was not found
169
+ const rawEvents = await env.DB.prepare(`SELECT * FROM ${dbName} ${whereClause} ORDER BY id ASC`).all()
170
+ if (rawEvents.error) {
171
+ throw new Error(rawEvents.error)
172
+ }
173
+ const events = Schema.decodeUnknownSync(Schema.Array(mutationLogTable.schema))(rawEvents.results)
174
+ return events
175
+ }
176
+
177
+ const appendEvent = async (event: MutationEvent.Any) => {
178
+ const sql = `INSERT INTO ${dbName} (id, args, mutation) VALUES (?, ?, ?)`
179
+ await env.DB.prepare(sql).bind(event.id, JSON.stringify(event.args), event.mutation).run()
180
+ }
181
+
182
+ const resetRoom = async () => {
183
+ await ctx.storage.deleteAll()
184
+ }
185
+
186
+ return { getEvents, appendEvent, resetRoom }
187
+ }
@@ -0,0 +1,84 @@
1
+ /// <reference no-default-lib="true"/>
2
+ /// <reference lib="esnext" />
3
+
4
+ // import { mutationEventSchemaEncodedAny } from '@livestore/common/schema'
5
+ // import { Effect, HttpServer, Schema } from '@livestore/utils/effect'
6
+
7
+ import type { Env } from './durable-object.js'
8
+
9
+ export * from './durable-object.js'
10
+
11
+ // const handleRequest = (request: Request, env: Env) =>
12
+ // HttpServer.router.empty.pipe(
13
+ // HttpServer.router.get(
14
+ // '/websocket',
15
+ // Effect.gen(function* () {
16
+ // // This example will refer to the same Durable Object instance,
17
+ // // since the name "foo" is hardcoded.
18
+ // const id = env.WEBSOCKET_SERVER.idFromName('foo')
19
+ // const durableObject = env.WEBSOCKET_SERVER.get(id)
20
+
21
+ // HttpServer.
22
+
23
+ // // Expect to receive a WebSocket Upgrade request.
24
+ // // If there is one, accept the request and return a WebSocket Response.
25
+ // const headerRes = yield* HttpServer.request
26
+ // .schemaHeaders(
27
+ // Schema.Struct({
28
+ // Upgrade: Schema.Literal('websocket'),
29
+ // }),
30
+ // )
31
+ // .pipe(Effect.either)
32
+
33
+ // if (headerRes._tag === 'Left') {
34
+ // // return new Response('Durable Object expected Upgrade: websocket', { status: 426 })
35
+ // return yield* HttpServer.response.text('Durable Object expected Upgrade: websocket', { status: 426 })
36
+ // }
37
+
38
+ // HttpServer.response.empty
39
+
40
+ // return yield* Effect.promise(() => durableObject.fetch(request))
41
+ // }),
42
+ // ),
43
+ // HttpServer.router.catchAll((e) => {
44
+ // console.log(e)
45
+ // return HttpServer.response.empty({ status: 400 })
46
+ // }),
47
+ // (_) => HttpServer.app.toWebHandler(_)(request),
48
+ // // request
49
+ // )
50
+
51
+ // Worker
52
+ export default {
53
+ fetch: async (request: Request, env: Env, _ctx: ExecutionContext): Promise<Response> => {
54
+ const url = new URL(request.url)
55
+ const searchParams = url.searchParams
56
+ const roomId = searchParams.get('room')
57
+
58
+ if (roomId === null) {
59
+ return new Response('Room ID is required', { status: 400 })
60
+ }
61
+
62
+ // This example will refer to the same Durable Object instance,
63
+ // since the name "foo" is hardcoded.
64
+ const id = env.WEBSOCKET_SERVER.idFromName(roomId)
65
+ const durableObject = env.WEBSOCKET_SERVER.get(id)
66
+
67
+ if (url.pathname.endsWith('/websocket')) {
68
+ const upgradeHeader = request.headers.get('Upgrade')
69
+ if (!upgradeHeader || upgradeHeader !== 'websocket') {
70
+ return new Response('Durable Object expected Upgrade: websocket', { status: 426 })
71
+ }
72
+
73
+ return durableObject.fetch(request)
74
+ }
75
+
76
+ return new Response(null, {
77
+ status: 400,
78
+ statusText: 'Bad Request',
79
+ headers: {
80
+ 'Content-Type': 'text/plain',
81
+ },
82
+ })
83
+ },
84
+ }
@@ -0,0 +1 @@
1
+ export * as WSMessage from './ws-message-types.js'
@@ -0,0 +1,109 @@
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.String),
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
+ requestId: Schema.String,
24
+ mutationEventEncoded: mutationEventSchemaEncodedAny,
25
+ persisted: Schema.Boolean,
26
+ })
27
+
28
+ export type PushBroadcast = typeof PushBroadcast.Type
29
+
30
+ export const PushReq = Schema.TaggedStruct('WSMessage.PushReq', {
31
+ requestId: Schema.String,
32
+ mutationEventEncoded: mutationEventSchemaEncodedAny,
33
+ persisted: Schema.Boolean,
34
+ })
35
+
36
+ export type PushReq = typeof PushReq.Type
37
+
38
+ export const PushAck = Schema.TaggedStruct('WSMessage.PushAck', {
39
+ requestId: Schema.String,
40
+ mutationId: Schema.String,
41
+ })
42
+
43
+ export type PushAck = typeof PushAck.Type
44
+
45
+ export const Error = Schema.TaggedStruct('WSMessage.Error', {
46
+ requestId: Schema.String,
47
+ message: Schema.String,
48
+ })
49
+
50
+ export const Ping = Schema.TaggedStruct('WSMessage.Ping', {
51
+ requestId: Schema.Literal('ping'),
52
+ })
53
+
54
+ export type Ping = typeof Ping.Type
55
+
56
+ export const Pong = Schema.TaggedStruct('WSMessage.Pong', {
57
+ requestId: Schema.Literal('ping'),
58
+ })
59
+
60
+ export type Pong = typeof Pong.Type
61
+
62
+ export const AdminResetRoomReq = Schema.TaggedStruct('WSMessage.AdminResetRoomReq', {
63
+ requestId: Schema.String,
64
+ adminSecret: Schema.String,
65
+ })
66
+
67
+ export type AdminResetRoomReq = typeof AdminResetRoomReq.Type
68
+
69
+ export const AdminResetRoomRes = Schema.TaggedStruct('WSMessage.AdminResetRoomRes', {
70
+ requestId: Schema.String,
71
+ })
72
+
73
+ export type AdminResetRoomRes = typeof AdminResetRoomRes.Type
74
+
75
+ export const AdminInfoReq = Schema.TaggedStruct('WSMessage.AdminInfoReq', {
76
+ requestId: Schema.String,
77
+ adminSecret: Schema.String,
78
+ })
79
+
80
+ export type AdminInfoReq = typeof AdminInfoReq.Type
81
+
82
+ export const AdminInfoRes = Schema.TaggedStruct('WSMessage.AdminInfoRes', {
83
+ requestId: Schema.String,
84
+ info: Schema.Struct({
85
+ durableObjectId: Schema.String,
86
+ }),
87
+ })
88
+
89
+ export type AdminInfoRes = typeof AdminInfoRes.Type
90
+
91
+ export const Message = Schema.Union(
92
+ PullReq,
93
+ PullRes,
94
+ PushBroadcast,
95
+ PushReq,
96
+ PushAck,
97
+ Error,
98
+ Ping,
99
+ Pong,
100
+ AdminResetRoomReq,
101
+ AdminResetRoomRes,
102
+ AdminInfoReq,
103
+ AdminInfoRes,
104
+ )
105
+ export type Message = typeof Message.Type
106
+ export type MessageEncoded = typeof Message.Encoded
107
+
108
+ export const IncomingMessage = Schema.Union(PullRes, PushBroadcast, PushAck, Error, Pong)
109
+ export type IncomingMessage = typeof IncomingMessage.Type
@@ -0,0 +1 @@
1
+ export * from './ws-impl.js'
@@ -0,0 +1,210 @@
1
+ /// <reference lib="dom" />
2
+
3
+ import type { SyncBackend, SyncBackendOptionsBase } from '@livestore/common'
4
+ import { InvalidPullError, InvalidPushError } from '@livestore/common'
5
+ import { cuid } from '@livestore/utils/cuid'
6
+ import type { Scope } from '@livestore/utils/effect'
7
+ import { Deferred, Effect, Option, PubSub, Queue, Schema, Stream, SubscriptionRef } from '@livestore/utils/effect'
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
+ ? Stream.fromPubSub(incomingMessages).pipe(
38
+ Stream.tap((_) =>
39
+ _._tag === 'WSMessage.Error' ? new InvalidPullError({ message: _.message }) : Effect.void,
40
+ ),
41
+ Stream.filter(Schema.is(WSMessage.PushBroadcast)),
42
+ Stream.map((_) => ({
43
+ mutationEventEncoded: _.mutationEventEncoded,
44
+ persisted: _.persisted,
45
+ metadata,
46
+ })),
47
+ )
48
+ : Effect.gen(function* () {
49
+ const requestId = cuid()
50
+ const cursor = Option.getOrUndefined(args)?.cursor
51
+
52
+ yield* send(WSMessage.PullReq.make({ cursor, requestId }))
53
+
54
+ return Stream.fromPubSub(incomingMessages).pipe(
55
+ Stream.filter((_) => _.requestId === requestId),
56
+ Stream.tap((_) =>
57
+ _._tag === 'WSMessage.Error' ? new InvalidPullError({ message: _.message }) : Effect.void,
58
+ ),
59
+ Stream.filter(Schema.is(WSMessage.PullRes)),
60
+ Stream.takeUntil((_) => _.hasMore === false),
61
+ Stream.map((_) => _.events),
62
+ Stream.flattenIterables,
63
+ Stream.map((mutationEventEncoded) => ({
64
+ mutationEventEncoded,
65
+ metadata,
66
+ persisted: true,
67
+ })),
68
+ )
69
+ }).pipe(Stream.unwrap),
70
+ push: (mutationEventEncoded, persisted) =>
71
+ Effect.gen(function* () {
72
+ const ready = yield* Deferred.make<void, InvalidPushError>()
73
+ const requestId = cuid()
74
+
75
+ yield* Stream.fromPubSub(incomingMessages).pipe(
76
+ Stream.filter((_) => _.requestId === requestId),
77
+ Stream.tap((_) =>
78
+ _._tag === 'WSMessage.Error'
79
+ ? Deferred.fail(ready, new InvalidPushError({ message: _.message }))
80
+ : Effect.void,
81
+ ),
82
+ Stream.filter(Schema.is(WSMessage.PushAck)),
83
+ Stream.filter((_) => _.mutationId === mutationEventEncoded.id),
84
+ Stream.take(1),
85
+ Stream.tap(() => Deferred.succeed(ready, void 0)),
86
+ Stream.runDrain,
87
+ Effect.tapCauseLogPretty,
88
+ Effect.fork,
89
+ )
90
+
91
+ yield* send(WSMessage.PushReq.make({ mutationEventEncoded, requestId, persisted }))
92
+
93
+ yield* Deferred.await(ready)
94
+
95
+ return { metadata }
96
+ }),
97
+ } satisfies SyncBackend<null>
98
+
99
+ return api
100
+ })
101
+
102
+ const connect = (wsUrl: string) =>
103
+ Effect.gen(function* () {
104
+ const isConnected = yield* SubscriptionRef.make(false)
105
+ const wsRef: { current: WebSocket | undefined } = { current: undefined }
106
+
107
+ const incomingMessages = yield* PubSub.unbounded<Exclude<WSMessage.IncomingMessage, WSMessage.Pong>>()
108
+
109
+ const waitUntilOnline = isConnected.changes.pipe(Stream.filter(Boolean), Stream.take(1), Stream.runDrain)
110
+
111
+ const send = (message: WSMessage.Message) =>
112
+ Effect.gen(function* () {
113
+ // Wait first until we're online
114
+ yield* waitUntilOnline
115
+
116
+ wsRef.current!.send(Schema.encodeSync(Schema.parseJson(WSMessage.Message))(message))
117
+ })
118
+
119
+ const innerConnect = Effect.gen(function* () {
120
+ // If the browser already tells us we're offline, then we'll at least wait until the browser
121
+ // thinks we're online again. (We'll only know for sure once the WS conneciton is established.)
122
+ while (navigator.onLine === false) {
123
+ yield* Effect.sleep(1000)
124
+ }
125
+ // if (navigator.onLine === false) {
126
+ // yield* Effect.async((cb) => self.addEventListener('online', () => cb(Effect.void)))
127
+ // }
128
+
129
+ const ws = new WebSocket(wsUrl)
130
+ const connectionClosed = yield* Deferred.make<void>()
131
+
132
+ const pongMessages = yield* Queue.unbounded<WSMessage.Pong>()
133
+
134
+ const messageHandler = (event: MessageEvent<any>): void => {
135
+ const decodedEventRes = Schema.decodeUnknownEither(Schema.parseJson(WSMessage.IncomingMessage))(event.data)
136
+
137
+ if (decodedEventRes._tag === 'Left') {
138
+ console.error('Sync: Invalid message received', decodedEventRes.left)
139
+ return
140
+ } else {
141
+ if (decodedEventRes.right._tag === 'WSMessage.Pong') {
142
+ Queue.offer(pongMessages, decodedEventRes.right).pipe(Effect.runSync)
143
+ } else {
144
+ PubSub.publish(incomingMessages, decodedEventRes.right).pipe(Effect.runSync)
145
+ }
146
+ }
147
+ }
148
+
149
+ const offlineHandler = () => {
150
+ Deferred.succeed(connectionClosed, void 0).pipe(Effect.runSync)
151
+ }
152
+
153
+ // NOTE it seems that this callback doesn't work reliably on a worker but only via `window.addEventListener`
154
+ // We might need to proxy the event from the main thread to the worker if we want this to work reliably.
155
+ self.addEventListener('offline', offlineHandler)
156
+
157
+ yield* Effect.addFinalizer(() =>
158
+ Effect.gen(function* () {
159
+ ws.removeEventListener('message', messageHandler)
160
+ self.removeEventListener('offline', offlineHandler)
161
+ wsRef.current?.close()
162
+ wsRef.current = undefined
163
+ yield* SubscriptionRef.set(isConnected, false)
164
+ }),
165
+ )
166
+
167
+ ws.addEventListener('message', messageHandler)
168
+
169
+ if (ws.readyState === WebSocket.OPEN) {
170
+ wsRef.current = ws
171
+ SubscriptionRef.set(isConnected, true).pipe(Effect.runSync)
172
+ } else {
173
+ ws.addEventListener('open', () => {
174
+ wsRef.current = ws
175
+ SubscriptionRef.set(isConnected, true).pipe(Effect.runSync)
176
+ })
177
+ }
178
+
179
+ ws.addEventListener('close', () => {
180
+ Deferred.succeed(connectionClosed, void 0).pipe(Effect.runSync)
181
+ })
182
+
183
+ ws.addEventListener('error', () => {
184
+ ws.close()
185
+ Deferred.succeed(connectionClosed, void 0).pipe(Effect.runSync)
186
+ })
187
+
188
+ const checkPingPong = Effect.gen(function* () {
189
+ // TODO include pong latency infomation in network status
190
+ yield* send({ _tag: 'WSMessage.Ping', requestId: 'ping' })
191
+
192
+ // NOTE those numbers might need more fine-tuning to allow for bad network conditions
193
+ yield* Queue.take(pongMessages).pipe(Effect.timeout(5000))
194
+
195
+ yield* Effect.sleep(25_000)
196
+ }).pipe(Effect.withSpan('@livestore/sync-cf:connect:checkPingPong'))
197
+
198
+ yield* waitUntilOnline.pipe(
199
+ Effect.andThen(checkPingPong.pipe(Effect.forever)),
200
+ Effect.tapErrorCause(() => Deferred.succeed(connectionClosed, void 0)),
201
+ Effect.forkScoped,
202
+ )
203
+
204
+ yield* Deferred.await(connectionClosed)
205
+ }).pipe(Effect.scoped)
206
+
207
+ yield* innerConnect.pipe(Effect.forever, Effect.tapCauseLogPretty, Effect.forkScoped)
208
+
209
+ return { isConnected, incomingMessages, send }
210
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "tsBuildInfoFile": "./dist/.tsbuildinfo",
7
+ "types": ["@cloudflare/workers-types"]
8
+ },
9
+ "include": ["./src"],
10
+ "references": [{ "path": "../common" }, { "path": "../utils" }]
11
+ }
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 = "..."