@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.
- package/.wrangler/state/v3/do/websocket-server-WebSocketServer/33d6e9d204e8c535d55150b9258a9388ddf594a89f30fc1d557e64546a59e731.sqlite +0 -0
- package/.wrangler/tmp/dev-9rcIR8/index.js +18887 -0
- package/.wrangler/tmp/dev-9rcIR8/index.js.map +8 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/cf-worker/durable-object.d.ts +59 -0
- package/dist/cf-worker/durable-object.d.ts.map +1 -0
- package/dist/cf-worker/durable-object.js +132 -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 +156 -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 +122 -0
- package/dist/sync-impl/ws-impl.js.map +1 -0
- package/package.json +26 -0
- package/src/cf-worker/durable-object.ts +187 -0
- package/src/cf-worker/index.ts +84 -0
- package/src/common/index.ts +1 -0
- package/src/common/ws-message-types.ts +109 -0
- package/src/sync-impl/index.ts +1 -0
- package/src/sync-impl/ws-impl.ts +210 -0
- package/tsconfig.json +11 -0
- 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 = "..."
|