@livestore/sync-cf 0.3.0-dev.5 → 0.3.0-dev.51
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 +60 -46
- package/dist/cf-worker/durable-object.d.ts.map +1 -1
- package/dist/cf-worker/durable-object.js +230 -148
- package/dist/cf-worker/durable-object.js.map +1 -1
- package/dist/cf-worker/mod.d.ts +3 -0
- package/dist/cf-worker/mod.d.ts.map +1 -0
- package/dist/cf-worker/mod.js +3 -0
- package/dist/cf-worker/mod.js.map +1 -0
- package/dist/cf-worker/worker.d.ts +40 -0
- package/dist/cf-worker/worker.d.ts.map +1 -0
- package/dist/cf-worker/worker.js +92 -0
- package/dist/cf-worker/worker.js.map +1 -0
- package/dist/common/mod.d.ts +7 -0
- package/dist/common/mod.d.ts.map +1 -0
- package/dist/common/mod.js +7 -0
- package/dist/common/mod.js.map +1 -0
- 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/mod.d.ts +2 -0
- package/dist/sync-impl/mod.d.ts.map +1 -0
- package/dist/sync-impl/mod.js +2 -0
- package/dist/sync-impl/mod.js.map +1 -0
- package/dist/sync-impl/ws-impl.d.ts +3 -15
- 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 +15 -13
- package/src/cf-worker/durable-object.ts +311 -165
- package/src/cf-worker/mod.ts +2 -0
- package/src/cf-worker/worker.ts +129 -0
- package/src/common/mod.ts +8 -0
- package/src/common/ws-message-types.ts +22 -36
- package/src/sync-impl/ws-impl.ts +146 -100
- package/dist/cf-worker/index.d.ts +0 -8
- package/dist/cf-worker/index.d.ts.map +0 -1
- package/dist/cf-worker/index.js +0 -67
- package/dist/cf-worker/index.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/index.ts +0 -84
- package/src/common/index.ts +0 -1
- /package/src/sync-impl/{index.ts → mod.ts} +0 -0
|
@@ -0,0 +1,129 @@
|
|
|
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'
|
|
6
|
+
import type { Env } from './durable-object.js'
|
|
7
|
+
|
|
8
|
+
export type CFWorker = {
|
|
9
|
+
fetch: (request: Request, env: Env, ctx: ExecutionContext) => Promise<Response>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type MakeWorkerOptions = {
|
|
13
|
+
validatePayload?: (payload: Schema.JsonValue | undefined) => void | Promise<void>
|
|
14
|
+
/** @default false */
|
|
15
|
+
enableCORS?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const makeWorker = (options: MakeWorkerOptions = {}): CFWorker => {
|
|
19
|
+
return {
|
|
20
|
+
fetch: async (request, env, _ctx) => {
|
|
21
|
+
const url = new URL(request.url)
|
|
22
|
+
|
|
23
|
+
if (request.method === 'GET' && url.pathname === '/') {
|
|
24
|
+
return new Response('Info: WebSocket sync backend endpoint for @livestore/sync-cf.', {
|
|
25
|
+
status: 200,
|
|
26
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const corsHeaders: HeadersInit = options.enableCORS
|
|
31
|
+
? {
|
|
32
|
+
'Access-Control-Allow-Origin': '*',
|
|
33
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
34
|
+
'Access-Control-Allow-Headers': request.headers.get('Access-Control-Request-Headers') ?? '*',
|
|
35
|
+
}
|
|
36
|
+
: {}
|
|
37
|
+
|
|
38
|
+
if (request.method === 'OPTIONS' && options.enableCORS) {
|
|
39
|
+
return new Response(null, {
|
|
40
|
+
status: 204,
|
|
41
|
+
headers: corsHeaders,
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (url.pathname.endsWith('/websocket')) {
|
|
46
|
+
return handleWebSocket(request, env, _ctx, { headers: corsHeaders, validatePayload: options.validatePayload })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
console.error('Invalid path', url.pathname)
|
|
50
|
+
|
|
51
|
+
return new Response('Invalid path', {
|
|
52
|
+
status: 400,
|
|
53
|
+
statusText: 'Bad Request',
|
|
54
|
+
headers: {
|
|
55
|
+
...corsHeaders,
|
|
56
|
+
'Content-Type': 'text/plain',
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Handles `/websocket` endpoint.
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```ts
|
|
68
|
+
* const validatePayload = (payload: Schema.JsonValue | undefined) => {
|
|
69
|
+
* if (payload?.authToken !== 'insecure-token-change-me') {
|
|
70
|
+
* throw new Error('Invalid auth token')
|
|
71
|
+
* }
|
|
72
|
+
* }
|
|
73
|
+
*
|
|
74
|
+
* export default {
|
|
75
|
+
* fetch: async (request, env, ctx) => {
|
|
76
|
+
* if (request.url.endsWith('/websocket')) {
|
|
77
|
+
* return handleWebSocket(request, env, ctx, { headers: {}, validatePayload })
|
|
78
|
+
* }
|
|
79
|
+
*
|
|
80
|
+
* return new Response('Invalid path', { status: 400, headers: corsHeaders })
|
|
81
|
+
* }
|
|
82
|
+
* }
|
|
83
|
+
* ```
|
|
84
|
+
*
|
|
85
|
+
* @throws {UnexpectedError} If the payload is invalid
|
|
86
|
+
*/
|
|
87
|
+
export const handleWebSocket = (
|
|
88
|
+
request: Request,
|
|
89
|
+
env: Env,
|
|
90
|
+
_ctx: ExecutionContext,
|
|
91
|
+
options: { headers?: HeadersInit; validatePayload?: (payload: Schema.JsonValue | undefined) => void | Promise<void> },
|
|
92
|
+
): Promise<Response> =>
|
|
93
|
+
Effect.gen(function* () {
|
|
94
|
+
const url = new URL(request.url)
|
|
95
|
+
|
|
96
|
+
const urlParams = UrlParams.fromInput(url.searchParams)
|
|
97
|
+
const paramsResult = yield* UrlParams.schemaStruct(SearchParamsSchema)(urlParams).pipe(Effect.either)
|
|
98
|
+
|
|
99
|
+
if (paramsResult._tag === 'Left') {
|
|
100
|
+
return new Response(`Invalid search params: ${paramsResult.left.toString()}`, {
|
|
101
|
+
status: 500,
|
|
102
|
+
headers: options?.headers,
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const { storeId, payload } = paramsResult.right
|
|
107
|
+
|
|
108
|
+
if (options.validatePayload !== undefined) {
|
|
109
|
+
const result = yield* Effect.promise(async () => options.validatePayload!(payload)).pipe(
|
|
110
|
+
UnexpectedError.mapToUnexpectedError,
|
|
111
|
+
Effect.either,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if (result._tag === 'Left') {
|
|
115
|
+
console.error('Invalid payload', result.left)
|
|
116
|
+
return new Response(result.left.toString(), { status: 400, headers: options.headers })
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const id = env.WEBSOCKET_SERVER.idFromName(storeId)
|
|
121
|
+
const durableObject = env.WEBSOCKET_SERVER.get(id)
|
|
122
|
+
|
|
123
|
+
const upgradeHeader = request.headers.get('Upgrade')
|
|
124
|
+
if (!upgradeHeader || upgradeHeader !== 'websocket') {
|
|
125
|
+
return new Response('Durable Object expected Upgrade: websocket', { status: 426, headers: options?.headers })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return yield* Effect.promise(() => durableObject.fetch(request))
|
|
129
|
+
}).pipe(Effect.tapCauseLogPretty, Effect.runPromise)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Schema } from '@livestore/utils/effect'
|
|
2
|
+
|
|
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,
|
|
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,98 +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/
|
|
22
|
+
import { SearchParamsSchema, WSMessage } from '../common/mod.js'
|
|
22
23
|
import type { SyncMetadata } from '../common/ws-message-types.js'
|
|
23
24
|
|
|
24
|
-
export interface WsSyncOptions
|
|
25
|
-
type: 'cf'
|
|
25
|
+
export interface WsSyncOptions {
|
|
26
26
|
url: string
|
|
27
|
-
roomId: string
|
|
28
27
|
}
|
|
29
28
|
|
|
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
|
-
: {
|
|
65
|
-
batch: msg.events.map(({ mutationEventEncoded, metadata }) => ({
|
|
66
|
-
mutationEventEncoded,
|
|
67
|
-
metadata,
|
|
68
|
-
})),
|
|
69
|
-
remaining: msg.remaining,
|
|
70
|
-
},
|
|
71
|
-
),
|
|
72
|
-
)
|
|
73
|
-
}).pipe(Stream.unwrap),
|
|
74
|
-
|
|
75
|
-
push: (batch) =>
|
|
76
|
-
Effect.gen(function* () {
|
|
77
|
-
const ready = yield* Deferred.make<void, InvalidPushError>()
|
|
78
|
-
const requestId = nanoid()
|
|
79
|
-
|
|
80
|
-
yield* Stream.fromPubSub(incomingMessages).pipe(
|
|
81
|
-
Stream.filter((_) => _._tag !== 'WSMessage.PushBroadcast' && _.requestId === requestId),
|
|
82
|
-
Stream.tap((_) =>
|
|
83
|
-
_._tag === 'WSMessage.Error'
|
|
84
|
-
? Deferred.fail(ready, new InvalidPushError({ reason: { _tag: 'Unexpected', message: _.message } }))
|
|
85
|
-
: Effect.void,
|
|
86
|
-
),
|
|
87
|
-
Stream.filter(Schema.is(WSMessage.PushAck)),
|
|
88
|
-
// TODO bring back filterting of "own events"
|
|
89
|
-
// Stream.filter((_) => _.mutationId === mutationEventEncoded.id.global),
|
|
90
|
-
Stream.take(1),
|
|
91
|
-
Stream.tap(() => Deferred.succeed(ready, void 0)),
|
|
92
|
-
Stream.runDrain,
|
|
93
|
-
Effect.tapCauseLogPretty,
|
|
94
|
-
Effect.fork,
|
|
95
|
-
)
|
|
96
|
-
|
|
97
|
-
yield* send(WSMessage.PushReq.make({ batch, requestId }))
|
|
98
|
-
|
|
99
|
-
yield* ready
|
|
100
|
-
|
|
101
|
-
const createdAt = new Date().toISOString()
|
|
102
|
-
|
|
103
|
-
return { metadata: Array.from({ length: batch.length }, () => Option.some({ createdAt })) }
|
|
104
|
-
}),
|
|
105
|
-
} satisfies SyncBackend<SyncMetadata>
|
|
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
|
+
}
|
|
106
63
|
|
|
107
|
-
|
|
108
|
-
|
|
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
|
+
})
|
|
109
150
|
|
|
110
151
|
const connect = (wsUrl: string) =>
|
|
111
152
|
Effect.gen(function* () {
|
|
@@ -123,21 +164,23 @@ const connect = (wsUrl: string) =>
|
|
|
123
164
|
// Wait first until we're online
|
|
124
165
|
yield* waitUntilOnline
|
|
125
166
|
|
|
126
|
-
yield* Effect.spanEvent(
|
|
127
|
-
`Sending message: ${message._tag}`,
|
|
128
|
-
message._tag === 'WSMessage.PushReq'
|
|
129
|
-
? {
|
|
130
|
-
id: message.batch[0]!.id.global,
|
|
131
|
-
parentId: message.batch[0]!.parentId.global,
|
|
132
|
-
batchLength: message.batch.length,
|
|
133
|
-
}
|
|
134
|
-
: message._tag === 'WSMessage.PullReq'
|
|
135
|
-
? { cursor: message.cursor ?? '-' }
|
|
136
|
-
: {},
|
|
137
|
-
)
|
|
138
|
-
|
|
139
167
|
// TODO use MsgPack instead of JSON to speed up the serialization / reduce the size of the messages
|
|
140
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
|
+
}
|
|
141
184
|
})
|
|
142
185
|
|
|
143
186
|
const innerConnect = Effect.gen(function* () {
|
|
@@ -146,11 +189,13 @@ const connect = (wsUrl: string) =>
|
|
|
146
189
|
while (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
|
147
190
|
yield* Effect.sleep(1000)
|
|
148
191
|
}
|
|
192
|
+
// TODO bring this back in a cross-platform way
|
|
149
193
|
// if (navigator.onLine === false) {
|
|
150
194
|
// yield* Effect.async((cb) => self.addEventListener('online', () => cb(Effect.void)))
|
|
151
195
|
// }
|
|
152
196
|
|
|
153
197
|
const socket = yield* WebSocket.makeWebSocket({ url: wsUrl, reconnect: Schedule.exponential(100) })
|
|
198
|
+
// socket.binaryType = 'arraybuffer'
|
|
154
199
|
|
|
155
200
|
yield* SubscriptionRef.set(isConnected, true)
|
|
156
201
|
socketRef.current = socket
|
|
@@ -191,7 +236,8 @@ const connect = (wsUrl: string) =>
|
|
|
191
236
|
// NOTE it seems that this callback doesn't work reliably on a worker but only via `window.addEventListener`
|
|
192
237
|
// We might need to proxy the event from the main thread to the worker if we want this to work reliably.
|
|
193
238
|
// eslint-disable-next-line unicorn/prefer-global-this
|
|
194
|
-
if (typeof self !== 'undefined') {
|
|
239
|
+
if (typeof self !== 'undefined' && typeof self.addEventListener === 'function') {
|
|
240
|
+
// TODO support an Expo equivalent for this
|
|
195
241
|
// eslint-disable-next-line unicorn/prefer-global-this
|
|
196
242
|
yield* Effect.eventListener(self, 'offline', () => Deferred.succeed(connectionClosed, void 0))
|
|
197
243
|
}
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
/// <reference no-default-lib="true"/>
|
|
2
|
-
import type { Env } from './durable-object.js';
|
|
3
|
-
export * from './durable-object.js';
|
|
4
|
-
declare const _default: {
|
|
5
|
-
fetch: (request: Request, env: Env, _ctx: ExecutionContext) => Promise<Response>;
|
|
6
|
-
};
|
|
7
|
-
export default _default;
|
|
8
|
-
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cf-worker/index.ts"],"names":[],"mappings":";AAMA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,qBAAqB,CAAA;AAE9C,cAAc,qBAAqB,CAAA;;qBA4CV,OAAO,OAAO,GAAG,QAAQ,gBAAgB,KAAG,OAAO,CAAC,QAAQ,CAAC;;AADtF,wBAgCC"}
|
package/dist/cf-worker/index.js
DELETED
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
/// <reference no-default-lib="true"/>
|
|
2
|
-
/// <reference lib="esnext" />
|
|
3
|
-
export * from './durable-object.js';
|
|
4
|
-
// const handleRequest = (request: Request, env: Env) =>
|
|
5
|
-
// HttpServer.router.empty.pipe(
|
|
6
|
-
// HttpServer.router.get(
|
|
7
|
-
// '/websocket',
|
|
8
|
-
// Effect.gen(function* () {
|
|
9
|
-
// // This example will refer to the same Durable Object instance,
|
|
10
|
-
// // since the name "foo" is hardcoded.
|
|
11
|
-
// const id = env.WEBSOCKET_SERVER.idFromName('foo')
|
|
12
|
-
// const durableObject = env.WEBSOCKET_SERVER.get(id)
|
|
13
|
-
// HttpServer.
|
|
14
|
-
// // Expect to receive a WebSocket Upgrade request.
|
|
15
|
-
// // If there is one, accept the request and return a WebSocket Response.
|
|
16
|
-
// const headerRes = yield* HttpServer.request
|
|
17
|
-
// .schemaHeaders(
|
|
18
|
-
// Schema.Struct({
|
|
19
|
-
// Upgrade: Schema.Literal('websocket'),
|
|
20
|
-
// }),
|
|
21
|
-
// )
|
|
22
|
-
// .pipe(Effect.either)
|
|
23
|
-
// if (headerRes._tag === 'Left') {
|
|
24
|
-
// // return new Response('Durable Object expected Upgrade: websocket', { status: 426 })
|
|
25
|
-
// return yield* HttpServer.response.text('Durable Object expected Upgrade: websocket', { status: 426 })
|
|
26
|
-
// }
|
|
27
|
-
// HttpServer.response.empty
|
|
28
|
-
// return yield* Effect.promise(() => durableObject.fetch(request))
|
|
29
|
-
// }),
|
|
30
|
-
// ),
|
|
31
|
-
// HttpServer.router.catchAll((e) => {
|
|
32
|
-
// console.log(e)
|
|
33
|
-
// return HttpServer.response.empty({ status: 400 })
|
|
34
|
-
// }),
|
|
35
|
-
// (_) => HttpServer.app.toWebHandler(_)(request),
|
|
36
|
-
// // request
|
|
37
|
-
// )
|
|
38
|
-
// Worker
|
|
39
|
-
export default {
|
|
40
|
-
fetch: async (request, env, _ctx) => {
|
|
41
|
-
const url = new URL(request.url);
|
|
42
|
-
const searchParams = url.searchParams;
|
|
43
|
-
const roomId = searchParams.get('room');
|
|
44
|
-
if (roomId === null) {
|
|
45
|
-
return new Response('Room ID is required', { status: 400 });
|
|
46
|
-
}
|
|
47
|
-
// This example will refer to the same Durable Object instance,
|
|
48
|
-
// since the name "foo" is hardcoded.
|
|
49
|
-
const id = env.WEBSOCKET_SERVER.idFromName(roomId);
|
|
50
|
-
const durableObject = env.WEBSOCKET_SERVER.get(id);
|
|
51
|
-
if (url.pathname.endsWith('/websocket')) {
|
|
52
|
-
const upgradeHeader = request.headers.get('Upgrade');
|
|
53
|
-
if (!upgradeHeader || upgradeHeader !== 'websocket') {
|
|
54
|
-
return new Response('Durable Object expected Upgrade: websocket', { status: 426 });
|
|
55
|
-
}
|
|
56
|
-
return durableObject.fetch(request);
|
|
57
|
-
}
|
|
58
|
-
return new Response(null, {
|
|
59
|
-
status: 400,
|
|
60
|
-
statusText: 'Bad Request',
|
|
61
|
-
headers: {
|
|
62
|
-
'Content-Type': 'text/plain',
|
|
63
|
-
},
|
|
64
|
-
});
|
|
65
|
-
},
|
|
66
|
-
};
|
|
67
|
-
//# 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;AAO9B,cAAc,qBAAqB,CAAA;AAEnC,wDAAwD;AACxD,kCAAkC;AAClC,6BAA6B;AAC7B,sBAAsB;AACtB,kCAAkC;AAClC,0EAA0E;AAC1E,gDAAgD;AAChD,4DAA4D;AAC5D,6DAA6D;AAE7D,sBAAsB;AAEtB,4DAA4D;AAC5D,kFAAkF;AAClF,sDAAsD;AACtD,4BAA4B;AAC5B,8BAA8B;AAC9B,sDAAsD;AACtD,kBAAkB;AAClB,cAAc;AACd,iCAAiC;AAEjC,2CAA2C;AAC3C,kGAAkG;AAClG,kHAAkH;AAClH,YAAY;AAEZ,oCAAoC;AAEpC,2EAA2E;AAC3E,YAAY;AACZ,SAAS;AACT,0CAA0C;AAC1C,uBAAuB;AACvB,0DAA0D;AAC1D,UAAU;AACV,sDAAsD;AACtD,iBAAiB;AACjB,MAAM;AAEN,SAAS;AACT,eAAe;IACb,KAAK,EAAE,KAAK,EAAE,OAAgB,EAAE,GAAQ,EAAE,IAAsB,EAAqB,EAAE;QACrF,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAA;QAChC,MAAM,YAAY,GAAG,GAAG,CAAC,YAAY,CAAA;QACrC,MAAM,MAAM,GAAG,YAAY,CAAC,GAAG,CAAC,MAAM,CAAC,CAAA;QAEvC,IAAI,MAAM,KAAK,IAAI,EAAE,CAAC;YACpB,OAAO,IAAI,QAAQ,CAAC,qBAAqB,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAA;QAC7D,CAAC;QAED,+DAA+D;QAC/D,qCAAqC;QACrC,MAAM,EAAE,GAAG,GAAG,CAAC,gBAAgB,CAAC,UAAU,CAAC,MAAM,CAAC,CAAA;QAClD,MAAM,aAAa,GAAG,GAAG,CAAC,gBAAgB,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;QAElD,IAAI,GAAG,CAAC,QAAQ,CAAC,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC;YACxC,MAAM,aAAa,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,SAAS,CAAC,CAAA;YACpD,IAAI,CAAC,aAAa,IAAI,aAAa,KAAK,WAAW,EAAE,CAAC;gBACpD,OAAO,IAAI,QAAQ,CAAC,4CAA4C,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAA;YACpF,CAAC;YAED,OAAO,aAAa,CAAC,KAAK,CAAC,OAAO,CAAC,CAAA;QACrC,CAAC;QAED,OAAO,IAAI,QAAQ,CAAC,IAAI,EAAE;YACxB,MAAM,EAAE,GAAG;YACX,UAAU,EAAE,aAAa;YACzB,OAAO,EAAE;gBACP,cAAc,EAAE,YAAY;aAC7B;SACF,CAAC,CAAA;IACJ,CAAC;CACF,CAAA"}
|
package/dist/common/index.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/common/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,SAAS,MAAM,uBAAuB,CAAA"}
|