@livestore/sync-cf 0.3.0-dev.8 → 0.3.0
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 +45 -28
- package/dist/cf-worker/durable-object.d.ts.map +1 -1
- package/dist/cf-worker/durable-object.js +224 -121
- package/dist/cf-worker/durable-object.js.map +1 -1
- package/dist/cf-worker/worker.d.ts +44 -1
- package/dist/cf-worker/worker.d.ts.map +1 -1
- package/dist/cf-worker/worker.js +83 -15
- package/dist/cf-worker/worker.js.map +1 -1
- package/dist/common/mod.d.ts +5 -0
- package/dist/common/mod.d.ts.map +1 -1
- package/dist/common/mod.js +5 -0
- package/dist/common/mod.js.map +1 -1
- package/dist/common/ws-message-types.d.ts +148 -98
- package/dist/common/ws-message-types.d.ts.map +1 -1
- package/dist/common/ws-message-types.js +19 -24
- package/dist/common/ws-message-types.js.map +1 -1
- package/dist/sync-impl/ws-impl.d.ts +2 -5
- package/dist/sync-impl/ws-impl.d.ts.map +1 -1
- package/dist/sync-impl/ws-impl.js +89 -36
- package/dist/sync-impl/ws-impl.js.map +1 -1
- package/package.json +4 -4
- package/src/cf-worker/durable-object.ts +273 -126
- package/src/cf-worker/worker.ts +125 -16
- package/src/common/mod.ts +7 -0
- package/src/common/ws-message-types.ts +22 -36
- package/src/sync-impl/ws-impl.ts +145 -90
- package/dist/cf-worker/index.d.ts +0 -3
- package/dist/cf-worker/index.d.ts.map +0 -1
- package/dist/cf-worker/index.js +0 -33
- package/dist/cf-worker/index.js.map +0 -1
- package/dist/cf-worker/make-worker.d.ts +0 -6
- package/dist/cf-worker/make-worker.d.ts.map +0 -1
- package/dist/cf-worker/make-worker.js +0 -31
- package/dist/cf-worker/make-worker.js.map +0 -1
- package/dist/cf-worker/types.d.ts +0 -2
- package/dist/cf-worker/types.d.ts.map +0 -1
- package/dist/cf-worker/types.js +0 -2
- package/dist/cf-worker/types.js.map +0 -1
- package/dist/common/index.d.ts +0 -2
- package/dist/common/index.d.ts.map +0 -1
- package/dist/common/index.js +0 -2
- package/dist/common/index.js.map +0 -1
- package/dist/sync-impl/index.d.ts +0 -2
- package/dist/sync-impl/index.d.ts.map +0 -1
- package/dist/sync-impl/index.js +0 -2
- package/dist/sync-impl/index.js.map +0 -1
package/src/cf-worker/worker.ts
CHANGED
|
@@ -1,41 +1,150 @@
|
|
|
1
|
+
import { UnexpectedError } from '@livestore/common'
|
|
2
|
+
import type { Schema } from '@livestore/utils/effect'
|
|
3
|
+
import { Effect, UrlParams } from '@livestore/utils/effect'
|
|
4
|
+
|
|
5
|
+
import { SearchParamsSchema } from '../common/mod.js'
|
|
1
6
|
import type { Env } from './durable-object.js'
|
|
2
7
|
|
|
3
8
|
export type CFWorker = {
|
|
4
9
|
fetch: (request: Request, env: Env, ctx: ExecutionContext) => Promise<Response>
|
|
5
10
|
}
|
|
6
11
|
|
|
7
|
-
export
|
|
12
|
+
export type MakeWorkerOptions = {
|
|
13
|
+
validatePayload?: (payload: Schema.JsonValue | undefined) => void | Promise<void>
|
|
14
|
+
/** @default false */
|
|
15
|
+
enableCORS?: boolean
|
|
16
|
+
durableObject?: {
|
|
17
|
+
/**
|
|
18
|
+
* Needs to match the binding name from the wrangler config
|
|
19
|
+
*
|
|
20
|
+
* @default 'WEBSOCKET_SERVER'
|
|
21
|
+
*/
|
|
22
|
+
name?: string
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const makeWorker = (options: MakeWorkerOptions = {}): CFWorker => {
|
|
8
27
|
return {
|
|
9
28
|
fetch: async (request, env, _ctx) => {
|
|
10
29
|
const url = new URL(request.url)
|
|
11
|
-
const searchParams = url.searchParams
|
|
12
|
-
const roomId = searchParams.get('room')
|
|
13
30
|
|
|
14
|
-
|
|
15
|
-
|
|
31
|
+
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
32
|
+
|
|
33
|
+
if (request.method === 'GET' && url.pathname === '/') {
|
|
34
|
+
return new Response('Info: WebSocket sync backend endpoint for @livestore/sync-cf.', {
|
|
35
|
+
status: 200,
|
|
36
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
37
|
+
})
|
|
16
38
|
}
|
|
17
39
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
40
|
+
const corsHeaders: HeadersInit = options.enableCORS
|
|
41
|
+
? {
|
|
42
|
+
'Access-Control-Allow-Origin': '*',
|
|
43
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
44
|
+
'Access-Control-Allow-Headers': request.headers.get('Access-Control-Request-Headers') ?? '*',
|
|
45
|
+
}
|
|
46
|
+
: {}
|
|
22
47
|
|
|
23
|
-
if (
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
48
|
+
if (request.method === 'OPTIONS' && options.enableCORS) {
|
|
49
|
+
return new Response(null, {
|
|
50
|
+
status: 204,
|
|
51
|
+
headers: corsHeaders,
|
|
52
|
+
})
|
|
53
|
+
}
|
|
28
54
|
|
|
29
|
-
|
|
55
|
+
if (url.pathname.endsWith('/websocket')) {
|
|
56
|
+
return handleWebSocket(request, env, _ctx, {
|
|
57
|
+
headers: corsHeaders,
|
|
58
|
+
validatePayload: options.validatePayload,
|
|
59
|
+
durableObject: options.durableObject,
|
|
60
|
+
})
|
|
30
61
|
}
|
|
31
62
|
|
|
32
|
-
|
|
63
|
+
console.error('Invalid path', url.pathname)
|
|
64
|
+
|
|
65
|
+
return new Response('Invalid path', {
|
|
33
66
|
status: 400,
|
|
34
67
|
statusText: 'Bad Request',
|
|
35
68
|
headers: {
|
|
69
|
+
...corsHeaders,
|
|
36
70
|
'Content-Type': 'text/plain',
|
|
37
71
|
},
|
|
38
72
|
})
|
|
39
73
|
},
|
|
40
74
|
}
|
|
41
75
|
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Handles `/websocket` endpoint.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* ```ts
|
|
82
|
+
* const validatePayload = (payload: Schema.JsonValue | undefined) => {
|
|
83
|
+
* if (payload?.authToken !== 'insecure-token-change-me') {
|
|
84
|
+
* throw new Error('Invalid auth token')
|
|
85
|
+
* }
|
|
86
|
+
* }
|
|
87
|
+
*
|
|
88
|
+
* export default {
|
|
89
|
+
* fetch: async (request, env, ctx) => {
|
|
90
|
+
* if (request.url.endsWith('/websocket')) {
|
|
91
|
+
* return handleWebSocket(request, env, ctx, { headers: {}, validatePayload })
|
|
92
|
+
* }
|
|
93
|
+
*
|
|
94
|
+
* return new Response('Invalid path', { status: 400, headers: corsHeaders })
|
|
95
|
+
* }
|
|
96
|
+
* }
|
|
97
|
+
* ```
|
|
98
|
+
*
|
|
99
|
+
* @throws {UnexpectedError} If the payload is invalid
|
|
100
|
+
*/
|
|
101
|
+
export const handleWebSocket = (
|
|
102
|
+
request: Request,
|
|
103
|
+
env: Env,
|
|
104
|
+
_ctx: ExecutionContext,
|
|
105
|
+
options: {
|
|
106
|
+
headers?: HeadersInit
|
|
107
|
+
durableObject?: MakeWorkerOptions['durableObject']
|
|
108
|
+
validatePayload?: (payload: Schema.JsonValue | undefined) => void | Promise<void>
|
|
109
|
+
},
|
|
110
|
+
): Promise<Response> =>
|
|
111
|
+
Effect.gen(function* () {
|
|
112
|
+
const url = new URL(request.url)
|
|
113
|
+
|
|
114
|
+
const urlParams = UrlParams.fromInput(url.searchParams)
|
|
115
|
+
const paramsResult = yield* UrlParams.schemaStruct(SearchParamsSchema)(urlParams).pipe(Effect.either)
|
|
116
|
+
|
|
117
|
+
if (paramsResult._tag === 'Left') {
|
|
118
|
+
return new Response(`Invalid search params: ${paramsResult.left.toString()}`, {
|
|
119
|
+
status: 500,
|
|
120
|
+
headers: options?.headers,
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const { storeId, payload } = paramsResult.right
|
|
125
|
+
|
|
126
|
+
if (options.validatePayload !== undefined) {
|
|
127
|
+
const result = yield* Effect.promise(async () => options.validatePayload!(payload)).pipe(
|
|
128
|
+
UnexpectedError.mapToUnexpectedError,
|
|
129
|
+
Effect.either,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if (result._tag === 'Left') {
|
|
133
|
+
console.error('Invalid payload', result.left)
|
|
134
|
+
return new Response(result.left.toString(), { status: 400, headers: options.headers })
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const durableObjectName = options.durableObject?.name ?? 'WEBSOCKET_SERVER'
|
|
139
|
+
const durableObjectNamespace = (env as any)[durableObjectName] as DurableObjectNamespace
|
|
140
|
+
|
|
141
|
+
const id = durableObjectNamespace.idFromName(storeId)
|
|
142
|
+
const durableObject = durableObjectNamespace.get(id)
|
|
143
|
+
|
|
144
|
+
const upgradeHeader = request.headers.get('Upgrade')
|
|
145
|
+
if (!upgradeHeader || upgradeHeader !== 'websocket') {
|
|
146
|
+
return new Response('Durable Object expected Upgrade: websocket', { status: 426, headers: options?.headers })
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return yield* Effect.promise(() => durableObject.fetch(request))
|
|
150
|
+
}).pipe(Effect.tapCauseLogPretty, Effect.runPromise)
|
package/src/common/mod.ts
CHANGED
|
@@ -1 +1,8 @@
|
|
|
1
|
+
import { Schema } from '@livestore/utils/effect'
|
|
2
|
+
|
|
1
3
|
export * as WSMessage from './ws-message-types.js'
|
|
4
|
+
|
|
5
|
+
export const SearchParamsSchema = Schema.Struct({
|
|
6
|
+
storeId: Schema.String,
|
|
7
|
+
payload: Schema.compose(Schema.StringFromUriComponent, Schema.parseJson(Schema.JsonValue)).pipe(Schema.UndefinedOr),
|
|
8
|
+
})
|
|
@@ -1,89 +1,83 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { LiveStoreEvent } from '@livestore/common/schema'
|
|
2
2
|
import { Schema } from '@livestore/utils/effect'
|
|
3
3
|
|
|
4
4
|
export const PullReq = Schema.TaggedStruct('WSMessage.PullReq', {
|
|
5
5
|
requestId: Schema.String,
|
|
6
6
|
/** Omitting the cursor will start from the beginning */
|
|
7
7
|
cursor: Schema.optional(Schema.Number),
|
|
8
|
-
})
|
|
8
|
+
}).annotations({ title: '@livestore/sync-cf:PullReq' })
|
|
9
9
|
|
|
10
10
|
export type PullReq = typeof PullReq.Type
|
|
11
11
|
|
|
12
12
|
export const SyncMetadata = Schema.Struct({
|
|
13
13
|
/** ISO date format */
|
|
14
14
|
createdAt: Schema.String,
|
|
15
|
-
})
|
|
15
|
+
}).annotations({ title: '@livestore/sync-cf:SyncMetadata' })
|
|
16
16
|
|
|
17
17
|
export type SyncMetadata = typeof SyncMetadata.Type
|
|
18
18
|
|
|
19
19
|
export const PullRes = Schema.TaggedStruct('WSMessage.PullRes', {
|
|
20
|
-
|
|
21
|
-
events: Schema.Array(
|
|
20
|
+
batch: Schema.Array(
|
|
22
21
|
Schema.Struct({
|
|
23
|
-
|
|
22
|
+
eventEncoded: LiveStoreEvent.AnyEncodedGlobal,
|
|
24
23
|
metadata: Schema.Option(SyncMetadata),
|
|
25
24
|
}),
|
|
26
25
|
),
|
|
26
|
+
requestId: Schema.Struct({ context: Schema.Literal('pull', 'push'), requestId: Schema.String }),
|
|
27
27
|
remaining: Schema.Number,
|
|
28
|
-
})
|
|
28
|
+
}).annotations({ title: '@livestore/sync-cf:PullRes' })
|
|
29
29
|
|
|
30
30
|
export type PullRes = typeof PullRes.Type
|
|
31
31
|
|
|
32
|
-
export const PushBroadcast = Schema.TaggedStruct('WSMessage.PushBroadcast', {
|
|
33
|
-
mutationEventEncoded: MutationEvent.AnyEncodedGlobal,
|
|
34
|
-
metadata: Schema.Option(SyncMetadata),
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
export type PushBroadcast = typeof PushBroadcast.Type
|
|
38
|
-
|
|
39
32
|
export const PushReq = Schema.TaggedStruct('WSMessage.PushReq', {
|
|
40
33
|
requestId: Schema.String,
|
|
41
|
-
batch: Schema.Array(
|
|
42
|
-
})
|
|
34
|
+
batch: Schema.Array(LiveStoreEvent.AnyEncodedGlobal),
|
|
35
|
+
}).annotations({ title: '@livestore/sync-cf:PushReq' })
|
|
43
36
|
|
|
44
37
|
export type PushReq = typeof PushReq.Type
|
|
45
38
|
|
|
46
39
|
export const PushAck = Schema.TaggedStruct('WSMessage.PushAck', {
|
|
47
40
|
requestId: Schema.String,
|
|
48
|
-
|
|
49
|
-
})
|
|
41
|
+
}).annotations({ title: '@livestore/sync-cf:PushAck' })
|
|
50
42
|
|
|
51
43
|
export type PushAck = typeof PushAck.Type
|
|
52
44
|
|
|
53
45
|
export const Error = Schema.TaggedStruct('WSMessage.Error', {
|
|
54
46
|
requestId: Schema.String,
|
|
55
47
|
message: Schema.String,
|
|
56
|
-
})
|
|
48
|
+
}).annotations({ title: '@livestore/sync-cf:Error' })
|
|
49
|
+
|
|
50
|
+
export type Error = typeof Error.Type
|
|
57
51
|
|
|
58
52
|
export const Ping = Schema.TaggedStruct('WSMessage.Ping', {
|
|
59
53
|
requestId: Schema.Literal('ping'),
|
|
60
|
-
})
|
|
54
|
+
}).annotations({ title: '@livestore/sync-cf:Ping' })
|
|
61
55
|
|
|
62
56
|
export type Ping = typeof Ping.Type
|
|
63
57
|
|
|
64
58
|
export const Pong = Schema.TaggedStruct('WSMessage.Pong', {
|
|
65
59
|
requestId: Schema.Literal('ping'),
|
|
66
|
-
})
|
|
60
|
+
}).annotations({ title: '@livestore/sync-cf:Pong' })
|
|
67
61
|
|
|
68
62
|
export type Pong = typeof Pong.Type
|
|
69
63
|
|
|
70
64
|
export const AdminResetRoomReq = Schema.TaggedStruct('WSMessage.AdminResetRoomReq', {
|
|
71
65
|
requestId: Schema.String,
|
|
72
66
|
adminSecret: Schema.String,
|
|
73
|
-
})
|
|
67
|
+
}).annotations({ title: '@livestore/sync-cf:AdminResetRoomReq' })
|
|
74
68
|
|
|
75
69
|
export type AdminResetRoomReq = typeof AdminResetRoomReq.Type
|
|
76
70
|
|
|
77
71
|
export const AdminResetRoomRes = Schema.TaggedStruct('WSMessage.AdminResetRoomRes', {
|
|
78
72
|
requestId: Schema.String,
|
|
79
|
-
})
|
|
73
|
+
}).annotations({ title: '@livestore/sync-cf:AdminResetRoomRes' })
|
|
80
74
|
|
|
81
75
|
export type AdminResetRoomRes = typeof AdminResetRoomRes.Type
|
|
82
76
|
|
|
83
77
|
export const AdminInfoReq = Schema.TaggedStruct('WSMessage.AdminInfoReq', {
|
|
84
78
|
requestId: Schema.String,
|
|
85
79
|
adminSecret: Schema.String,
|
|
86
|
-
})
|
|
80
|
+
}).annotations({ title: '@livestore/sync-cf:AdminInfoReq' })
|
|
87
81
|
|
|
88
82
|
export type AdminInfoReq = typeof AdminInfoReq.Type
|
|
89
83
|
|
|
@@ -92,14 +86,13 @@ export const AdminInfoRes = Schema.TaggedStruct('WSMessage.AdminInfoRes', {
|
|
|
92
86
|
info: Schema.Struct({
|
|
93
87
|
durableObjectId: Schema.String,
|
|
94
88
|
}),
|
|
95
|
-
})
|
|
89
|
+
}).annotations({ title: '@livestore/sync-cf:AdminInfoRes' })
|
|
96
90
|
|
|
97
91
|
export type AdminInfoRes = typeof AdminInfoRes.Type
|
|
98
92
|
|
|
99
93
|
export const Message = Schema.Union(
|
|
100
94
|
PullReq,
|
|
101
95
|
PullRes,
|
|
102
|
-
PushBroadcast,
|
|
103
96
|
PushReq,
|
|
104
97
|
PushAck,
|
|
105
98
|
Error,
|
|
@@ -109,19 +102,12 @@ export const Message = Schema.Union(
|
|
|
109
102
|
AdminResetRoomRes,
|
|
110
103
|
AdminInfoReq,
|
|
111
104
|
AdminInfoRes,
|
|
112
|
-
)
|
|
105
|
+
).annotations({ title: '@livestore/sync-cf:Message' })
|
|
106
|
+
|
|
113
107
|
export type Message = typeof Message.Type
|
|
114
108
|
export type MessageEncoded = typeof Message.Encoded
|
|
115
109
|
|
|
116
|
-
export const BackendToClientMessage = Schema.Union(
|
|
117
|
-
PullRes,
|
|
118
|
-
PushBroadcast,
|
|
119
|
-
PushAck,
|
|
120
|
-
AdminResetRoomRes,
|
|
121
|
-
AdminInfoRes,
|
|
122
|
-
Error,
|
|
123
|
-
Pong,
|
|
124
|
-
)
|
|
110
|
+
export const BackendToClientMessage = Schema.Union(PullRes, PushAck, AdminResetRoomRes, AdminInfoRes, Error, Pong)
|
|
125
111
|
export type BackendToClientMessage = typeof BackendToClientMessage.Type
|
|
126
112
|
|
|
127
113
|
export const ClientToBackendMessage = Schema.Union(PullReq, PushReq, AdminResetRoomReq, AdminInfoReq, Ping)
|
package/src/sync-impl/ws-impl.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/// <reference lib="dom" />
|
|
2
2
|
|
|
3
|
-
import type { SyncBackend } from '@livestore/common'
|
|
4
|
-
import { InvalidPullError, InvalidPushError } from '@livestore/common'
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
3
|
+
import type { SyncBackend, SyncBackendConstructor } from '@livestore/common'
|
|
4
|
+
import { InvalidPullError, InvalidPushError, UnexpectedError } from '@livestore/common'
|
|
5
|
+
import { EventSequenceNumber } from '@livestore/common/schema'
|
|
6
|
+
import { LS_DEV, shouldNeverHappen } from '@livestore/utils'
|
|
7
7
|
import {
|
|
8
8
|
Deferred,
|
|
9
9
|
Effect,
|
|
@@ -14,89 +14,139 @@ import {
|
|
|
14
14
|
Schema,
|
|
15
15
|
Stream,
|
|
16
16
|
SubscriptionRef,
|
|
17
|
+
UrlParams,
|
|
17
18
|
WebSocket,
|
|
18
19
|
} from '@livestore/utils/effect'
|
|
19
20
|
import { nanoid } from '@livestore/utils/nanoid'
|
|
20
21
|
|
|
21
|
-
import { WSMessage } from '../common/mod.js'
|
|
22
|
+
import { SearchParamsSchema, WSMessage } from '../common/mod.js'
|
|
22
23
|
import type { SyncMetadata } from '../common/ws-message-types.js'
|
|
23
24
|
|
|
24
25
|
export interface WsSyncOptions {
|
|
25
26
|
url: string
|
|
26
|
-
roomId: string
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
export const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
}).pipe(Stream.unwrap),
|
|
65
|
-
|
|
66
|
-
push: (batch) =>
|
|
67
|
-
Effect.gen(function* () {
|
|
68
|
-
const ready = yield* Deferred.make<void, InvalidPushError>()
|
|
69
|
-
const requestId = nanoid()
|
|
70
|
-
|
|
71
|
-
yield* Stream.fromPubSub(incomingMessages).pipe(
|
|
72
|
-
Stream.filter((_) => _._tag !== 'WSMessage.PushBroadcast' && _.requestId === requestId),
|
|
73
|
-
Stream.tap((_) =>
|
|
74
|
-
_._tag === 'WSMessage.Error'
|
|
75
|
-
? Deferred.fail(ready, new InvalidPushError({ reason: { _tag: 'Unexpected', message: _.message } }))
|
|
76
|
-
: Effect.void,
|
|
77
|
-
),
|
|
78
|
-
Stream.filter(Schema.is(WSMessage.PushAck)),
|
|
79
|
-
// TODO bring back filterting of "own events"
|
|
80
|
-
// Stream.filter((_) => _.mutationId === mutationEventEncoded.id.global),
|
|
81
|
-
Stream.take(1),
|
|
82
|
-
Stream.tap(() => Deferred.succeed(ready, void 0)),
|
|
83
|
-
Stream.runDrain,
|
|
84
|
-
Effect.tapCauseLogPretty,
|
|
85
|
-
Effect.fork,
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
yield* send(WSMessage.PushReq.make({ batch, requestId }))
|
|
89
|
-
|
|
90
|
-
yield* ready
|
|
29
|
+
export const makeCfSync =
|
|
30
|
+
(options: WsSyncOptions): SyncBackendConstructor<SyncMetadata> =>
|
|
31
|
+
({ storeId, payload }) =>
|
|
32
|
+
Effect.gen(function* () {
|
|
33
|
+
const urlParamsData = yield* Schema.encode(SearchParamsSchema)({
|
|
34
|
+
storeId,
|
|
35
|
+
payload,
|
|
36
|
+
}).pipe(UnexpectedError.mapToUnexpectedError)
|
|
37
|
+
|
|
38
|
+
const urlParams = UrlParams.fromInput(urlParamsData)
|
|
39
|
+
const wsUrl = `${options.url}/websocket?${UrlParams.toString(urlParams)}`
|
|
40
|
+
|
|
41
|
+
const { isConnected, incomingMessages, send } = yield* connect(wsUrl)
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* We need to account for the scenario where push-caused PullRes message arrive before the pull-caused PullRes message.
|
|
45
|
+
* i.e. a scenario where the WS connection is created but before the server processed the initial pull, a push from
|
|
46
|
+
* another client triggers a PullRes message sent to this client which we need to stash until our pull-caused
|
|
47
|
+
* PullRes message arrives at which point we can combine the stashed events with the pull-caused events and continue.
|
|
48
|
+
*/
|
|
49
|
+
const stashedPullBatch: WSMessage.PullRes['batch'][number][] = []
|
|
50
|
+
|
|
51
|
+
// We currently only support one pull stream for a sync backend.
|
|
52
|
+
let pullStarted = false
|
|
53
|
+
|
|
54
|
+
const api = {
|
|
55
|
+
isConnected,
|
|
56
|
+
// Currently we're already eagerly connecting when the sync backend is created but we might want to refactor this later to clean this up
|
|
57
|
+
connect: Effect.void,
|
|
58
|
+
pull: (args) =>
|
|
59
|
+
Effect.gen(function* () {
|
|
60
|
+
if (pullStarted) {
|
|
61
|
+
return shouldNeverHappen(`Pull already started for this sync backend.`)
|
|
62
|
+
}
|
|
91
63
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
64
|
+
pullStarted = true
|
|
65
|
+
|
|
66
|
+
let pullResponseReceived = false
|
|
67
|
+
|
|
68
|
+
const requestId = nanoid()
|
|
69
|
+
const cursor = Option.getOrUndefined(args)?.cursor.global
|
|
70
|
+
|
|
71
|
+
yield* send(WSMessage.PullReq.make({ cursor, requestId }))
|
|
72
|
+
|
|
73
|
+
return Stream.fromPubSub(incomingMessages).pipe(
|
|
74
|
+
Stream.tap((_) =>
|
|
75
|
+
_._tag === 'WSMessage.Error' && _.requestId === requestId
|
|
76
|
+
? new InvalidPullError({ message: _.message })
|
|
77
|
+
: Effect.void,
|
|
78
|
+
),
|
|
79
|
+
Stream.filterMap((msg) => {
|
|
80
|
+
if (msg._tag === 'WSMessage.PullRes') {
|
|
81
|
+
if (msg.requestId.context === 'pull') {
|
|
82
|
+
if (msg.requestId.requestId === requestId) {
|
|
83
|
+
pullResponseReceived = true
|
|
84
|
+
|
|
85
|
+
if (stashedPullBatch.length > 0 && msg.remaining === 0) {
|
|
86
|
+
const pullResHead = msg.batch.at(-1)?.eventEncoded.seqNum ?? EventSequenceNumber.ROOT.global
|
|
87
|
+
// Index where stashed events are greater than pullResHead
|
|
88
|
+
const newPartialBatchIndex = stashedPullBatch.findIndex(
|
|
89
|
+
(batchItem) => batchItem.eventEncoded.seqNum > pullResHead,
|
|
90
|
+
)
|
|
91
|
+
const batchWithNewStashedEvents =
|
|
92
|
+
newPartialBatchIndex === -1 ? [] : stashedPullBatch.slice(newPartialBatchIndex)
|
|
93
|
+
const combinedBatch = [...msg.batch, ...batchWithNewStashedEvents]
|
|
94
|
+
return Option.some({ ...msg, batch: combinedBatch, remaining: 0 })
|
|
95
|
+
} else {
|
|
96
|
+
return Option.some(msg)
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
// Ignore
|
|
100
|
+
return Option.none()
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
if (pullResponseReceived) {
|
|
104
|
+
return Option.some(msg)
|
|
105
|
+
} else {
|
|
106
|
+
stashedPullBatch.push(...msg.batch)
|
|
107
|
+
return Option.none()
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return Option.none()
|
|
113
|
+
}),
|
|
114
|
+
)
|
|
115
|
+
}).pipe(Stream.unwrap),
|
|
116
|
+
|
|
117
|
+
push: (batch) =>
|
|
118
|
+
Effect.gen(function* () {
|
|
119
|
+
const pushAck = yield* Deferred.make<void, InvalidPushError>()
|
|
120
|
+
const requestId = nanoid()
|
|
121
|
+
|
|
122
|
+
yield* Stream.fromPubSub(incomingMessages).pipe(
|
|
123
|
+
Stream.tap((_) =>
|
|
124
|
+
_._tag === 'WSMessage.Error' && _.requestId === requestId
|
|
125
|
+
? Deferred.fail(pushAck, new InvalidPushError({ reason: { _tag: 'Unexpected', message: _.message } }))
|
|
126
|
+
: Effect.void,
|
|
127
|
+
),
|
|
128
|
+
Stream.filter((_) => _._tag === 'WSMessage.PushAck' && _.requestId === requestId),
|
|
129
|
+
Stream.take(1),
|
|
130
|
+
Stream.tap(() => Deferred.succeed(pushAck, void 0)),
|
|
131
|
+
Stream.runDrain,
|
|
132
|
+
Effect.tapCauseLogPretty,
|
|
133
|
+
Effect.fork,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
yield* send(WSMessage.PushReq.make({ batch, requestId }))
|
|
137
|
+
|
|
138
|
+
yield* pushAck
|
|
139
|
+
}),
|
|
140
|
+
metadata: {
|
|
141
|
+
name: '@livestore/cf-sync',
|
|
142
|
+
description: 'LiveStore sync backend implementation using Cloudflare Workers & Durable Objects',
|
|
143
|
+
protocol: 'ws',
|
|
144
|
+
url: options.url,
|
|
145
|
+
},
|
|
146
|
+
} satisfies SyncBackend<SyncMetadata>
|
|
147
|
+
|
|
148
|
+
return api
|
|
149
|
+
})
|
|
100
150
|
|
|
101
151
|
const connect = (wsUrl: string) =>
|
|
102
152
|
Effect.gen(function* () {
|
|
@@ -114,21 +164,23 @@ const connect = (wsUrl: string) =>
|
|
|
114
164
|
// Wait first until we're online
|
|
115
165
|
yield* waitUntilOnline
|
|
116
166
|
|
|
117
|
-
yield* Effect.spanEvent(
|
|
118
|
-
`Sending message: ${message._tag}`,
|
|
119
|
-
message._tag === 'WSMessage.PushReq'
|
|
120
|
-
? {
|
|
121
|
-
id: message.batch[0]!.id,
|
|
122
|
-
parentId: message.batch[0]!.parentId,
|
|
123
|
-
batchLength: message.batch.length,
|
|
124
|
-
}
|
|
125
|
-
: message._tag === 'WSMessage.PullReq'
|
|
126
|
-
? { cursor: message.cursor ?? '-' }
|
|
127
|
-
: {},
|
|
128
|
-
)
|
|
129
|
-
|
|
130
167
|
// TODO use MsgPack instead of JSON to speed up the serialization / reduce the size of the messages
|
|
131
168
|
socketRef.current!.send(Schema.encodeSync(Schema.parseJson(WSMessage.Message))(message))
|
|
169
|
+
|
|
170
|
+
if (LS_DEV) {
|
|
171
|
+
yield* Effect.spanEvent(
|
|
172
|
+
`Sent message: ${message._tag}`,
|
|
173
|
+
message._tag === 'WSMessage.PushReq'
|
|
174
|
+
? {
|
|
175
|
+
seqNum: message.batch[0]!.seqNum,
|
|
176
|
+
parentSeqNum: message.batch[0]!.parentSeqNum,
|
|
177
|
+
batchLength: message.batch.length,
|
|
178
|
+
}
|
|
179
|
+
: message._tag === 'WSMessage.PullReq'
|
|
180
|
+
? { cursor: message.cursor ?? '-' }
|
|
181
|
+
: {},
|
|
182
|
+
)
|
|
183
|
+
}
|
|
132
184
|
})
|
|
133
185
|
|
|
134
186
|
const innerConnect = Effect.gen(function* () {
|
|
@@ -137,11 +189,13 @@ const connect = (wsUrl: string) =>
|
|
|
137
189
|
while (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
|
138
190
|
yield* Effect.sleep(1000)
|
|
139
191
|
}
|
|
192
|
+
// TODO bring this back in a cross-platform way
|
|
140
193
|
// if (navigator.onLine === false) {
|
|
141
194
|
// yield* Effect.async((cb) => self.addEventListener('online', () => cb(Effect.void)))
|
|
142
195
|
// }
|
|
143
196
|
|
|
144
197
|
const socket = yield* WebSocket.makeWebSocket({ url: wsUrl, reconnect: Schedule.exponential(100) })
|
|
198
|
+
// socket.binaryType = 'arraybuffer'
|
|
145
199
|
|
|
146
200
|
yield* SubscriptionRef.set(isConnected, true)
|
|
147
201
|
socketRef.current = socket
|
|
@@ -182,7 +236,8 @@ const connect = (wsUrl: string) =>
|
|
|
182
236
|
// NOTE it seems that this callback doesn't work reliably on a worker but only via `window.addEventListener`
|
|
183
237
|
// We might need to proxy the event from the main thread to the worker if we want this to work reliably.
|
|
184
238
|
// eslint-disable-next-line unicorn/prefer-global-this
|
|
185
|
-
if (typeof self !== 'undefined') {
|
|
239
|
+
if (typeof self !== 'undefined' && typeof self.addEventListener === 'function') {
|
|
240
|
+
// TODO support an Expo equivalent for this
|
|
186
241
|
// eslint-disable-next-line unicorn/prefer-global-this
|
|
187
242
|
yield* Effect.eventListener(self, 'offline', () => Deferred.succeed(connectionClosed, void 0))
|
|
188
243
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cf-worker/index.ts"],"names":[],"mappings":""}
|
package/dist/cf-worker/index.js
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
/// <reference no-default-lib="true"/>
|
|
2
|
-
/// <reference lib="esnext" />
|
|
3
|
-
export {};
|
|
4
|
-
// export * from './durable-object.js'
|
|
5
|
-
// export default {
|
|
6
|
-
// fetch: async (request: Request, env: Env, _ctx: ExecutionContext): Promise<Response> => {
|
|
7
|
-
// const url = new URL(request.url)
|
|
8
|
-
// const searchParams = url.searchParams
|
|
9
|
-
// const roomId = searchParams.get('room')
|
|
10
|
-
// if (roomId === null) {
|
|
11
|
-
// return new Response('Room ID is required', { status: 400 })
|
|
12
|
-
// }
|
|
13
|
-
// // This example will refer to the same Durable Object instance,
|
|
14
|
-
// // since the name "foo" is hardcoded.
|
|
15
|
-
// const id = env.WEBSOCKET_SERVER.idFromName(roomId)
|
|
16
|
-
// const durableObject = env.WEBSOCKET_SERVER.get(id)
|
|
17
|
-
// if (url.pathname.endsWith('/websocket')) {
|
|
18
|
-
// const upgradeHeader = request.headers.get('Upgrade')
|
|
19
|
-
// if (!upgradeHeader || upgradeHeader !== 'websocket') {
|
|
20
|
-
// return new Response('Durable Object expected Upgrade: websocket', { status: 426 })
|
|
21
|
-
// }
|
|
22
|
-
// return durableObject.fetch(request)
|
|
23
|
-
// }
|
|
24
|
-
// return new Response(null, {
|
|
25
|
-
// status: 400,
|
|
26
|
-
// statusText: 'Bad Request',
|
|
27
|
-
// headers: {
|
|
28
|
-
// 'Content-Type': 'text/plain',
|
|
29
|
-
// },
|
|
30
|
-
// })
|
|
31
|
-
// },
|
|
32
|
-
// }
|
|
33
|
-
//# sourceMappingURL=index.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/cf-worker/index.ts"],"names":[],"mappings":"AAAA,sCAAsC;AACtC,8BAA8B;;AAI9B,sCAAsC;AAEtC,mBAAmB;AACnB,8FAA8F;AAC9F,uCAAuC;AACvC,4CAA4C;AAC5C,8CAA8C;AAE9C,6BAA6B;AAC7B,oEAAoE;AACpE,QAAQ;AAER,sEAAsE;AACtE,4CAA4C;AAC5C,yDAAyD;AACzD,yDAAyD;AAEzD,iDAAiD;AACjD,6DAA6D;AAC7D,+DAA+D;AAC/D,6FAA6F;AAC7F,UAAU;AAEV,4CAA4C;AAC5C,QAAQ;AAER,kCAAkC;AAClC,qBAAqB;AACrB,mCAAmC;AACnC,mBAAmB;AACnB,wCAAwC;AACxC,WAAW;AACX,SAAS;AACT,OAAO;AACP,IAAI"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"make-worker.d.ts","sourceRoot":"","sources":["../../src/cf-worker/make-worker.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,qBAAqB,CAAA;AAE9C,MAAM,MAAM,QAAQ,GAAG;IACrB,KAAK,EAAE,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,gBAAgB,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;CAChF,CAAA;AAED,eAAO,MAAM,UAAU,QAAO,QAkC7B,CAAA"}
|