@livestore/sync-cf 0.4.0-dev.2 → 0.4.0-dev.20
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/README.md +60 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/cf-worker/do/durable-object.d.ts +45 -0
- package/dist/cf-worker/do/durable-object.d.ts.map +1 -0
- package/dist/cf-worker/do/durable-object.js +151 -0
- package/dist/cf-worker/do/durable-object.js.map +1 -0
- package/dist/cf-worker/do/layer.d.ts +34 -0
- package/dist/cf-worker/do/layer.d.ts.map +1 -0
- package/dist/cf-worker/do/layer.js +91 -0
- package/dist/cf-worker/do/layer.js.map +1 -0
- package/dist/cf-worker/do/pull.d.ts +6 -0
- package/dist/cf-worker/do/pull.d.ts.map +1 -0
- package/dist/cf-worker/do/pull.js +47 -0
- package/dist/cf-worker/do/pull.js.map +1 -0
- package/dist/cf-worker/do/push.d.ts +14 -0
- package/dist/cf-worker/do/push.d.ts.map +1 -0
- package/dist/cf-worker/do/push.js +131 -0
- package/dist/cf-worker/do/push.js.map +1 -0
- package/dist/cf-worker/{durable-object.d.ts → do/sqlite.d.ts} +83 -67
- package/dist/cf-worker/do/sqlite.d.ts.map +1 -0
- package/dist/cf-worker/do/sqlite.js +36 -0
- package/dist/cf-worker/do/sqlite.js.map +1 -0
- package/dist/cf-worker/do/sync-storage.d.ts +25 -0
- package/dist/cf-worker/do/sync-storage.d.ts.map +1 -0
- package/dist/cf-worker/do/sync-storage.js +191 -0
- package/dist/cf-worker/do/sync-storage.js.map +1 -0
- package/dist/cf-worker/do/transport/do-rpc-server.d.ts +9 -0
- package/dist/cf-worker/do/transport/do-rpc-server.d.ts.map +1 -0
- package/dist/cf-worker/do/transport/do-rpc-server.js +45 -0
- package/dist/cf-worker/do/transport/do-rpc-server.js.map +1 -0
- package/dist/cf-worker/do/transport/http-rpc-server.d.ts +8 -0
- package/dist/cf-worker/do/transport/http-rpc-server.d.ts.map +1 -0
- package/dist/cf-worker/do/transport/http-rpc-server.js +30 -0
- package/dist/cf-worker/do/transport/http-rpc-server.js.map +1 -0
- package/dist/cf-worker/do/transport/ws-rpc-server.d.ts +4 -0
- package/dist/cf-worker/do/transport/ws-rpc-server.d.ts.map +1 -0
- package/dist/cf-worker/do/transport/ws-rpc-server.js +21 -0
- package/dist/cf-worker/do/transport/ws-rpc-server.js.map +1 -0
- package/dist/cf-worker/mod.d.ts +4 -2
- package/dist/cf-worker/mod.d.ts.map +1 -1
- package/dist/cf-worker/mod.js +3 -2
- package/dist/cf-worker/mod.js.map +1 -1
- package/dist/cf-worker/shared.d.ts +175 -0
- package/dist/cf-worker/shared.d.ts.map +1 -0
- package/dist/cf-worker/shared.js +43 -0
- package/dist/cf-worker/shared.js.map +1 -0
- package/dist/cf-worker/worker.d.ts +59 -51
- package/dist/cf-worker/worker.d.ts.map +1 -1
- package/dist/cf-worker/worker.js +75 -43
- package/dist/cf-worker/worker.js.map +1 -1
- package/dist/client/mod.d.ts +4 -0
- package/dist/client/mod.d.ts.map +1 -0
- package/dist/client/mod.js +4 -0
- package/dist/client/mod.js.map +1 -0
- package/dist/client/transport/do-rpc-client.d.ts +40 -0
- package/dist/client/transport/do-rpc-client.d.ts.map +1 -0
- package/dist/client/transport/do-rpc-client.js +115 -0
- package/dist/client/transport/do-rpc-client.js.map +1 -0
- package/dist/client/transport/http-rpc-client.d.ts +43 -0
- package/dist/client/transport/http-rpc-client.d.ts.map +1 -0
- package/dist/client/transport/http-rpc-client.js +103 -0
- package/dist/client/transport/http-rpc-client.js.map +1 -0
- package/dist/client/transport/ws-rpc-client.d.ts +46 -0
- package/dist/client/transport/ws-rpc-client.d.ts.map +1 -0
- package/dist/client/transport/ws-rpc-client.js +108 -0
- package/dist/client/transport/ws-rpc-client.js.map +1 -0
- package/dist/common/constants.d.ts +7 -0
- package/dist/common/constants.d.ts.map +1 -0
- package/dist/common/constants.js +17 -0
- package/dist/common/constants.js.map +1 -0
- package/dist/common/do-rpc-schema.d.ts +76 -0
- package/dist/common/do-rpc-schema.d.ts.map +1 -0
- package/dist/common/do-rpc-schema.js +48 -0
- package/dist/common/do-rpc-schema.js.map +1 -0
- package/dist/common/http-rpc-schema.d.ts +58 -0
- package/dist/common/http-rpc-schema.d.ts.map +1 -0
- package/dist/common/http-rpc-schema.js +37 -0
- package/dist/common/http-rpc-schema.js.map +1 -0
- package/dist/common/mod.d.ts +8 -1
- package/dist/common/mod.d.ts.map +1 -1
- package/dist/common/mod.js +7 -1
- package/dist/common/mod.js.map +1 -1
- package/dist/common/{ws-message-types.d.ts → sync-message-types.d.ts} +119 -153
- package/dist/common/sync-message-types.d.ts.map +1 -0
- package/dist/common/sync-message-types.js +60 -0
- package/dist/common/sync-message-types.js.map +1 -0
- package/dist/common/ws-rpc-schema.d.ts +55 -0
- package/dist/common/ws-rpc-schema.d.ts.map +1 -0
- package/dist/common/ws-rpc-schema.js +32 -0
- package/dist/common/ws-rpc-schema.js.map +1 -0
- package/package.json +7 -8
- package/src/cf-worker/do/durable-object.ts +238 -0
- package/src/cf-worker/do/layer.ts +128 -0
- package/src/cf-worker/do/pull.ts +75 -0
- package/src/cf-worker/do/push.ts +205 -0
- package/src/cf-worker/do/sqlite.ts +37 -0
- package/src/cf-worker/do/sync-storage.ts +323 -0
- package/src/cf-worker/do/transport/do-rpc-server.ts +84 -0
- package/src/cf-worker/do/transport/http-rpc-server.ts +51 -0
- package/src/cf-worker/do/transport/ws-rpc-server.ts +34 -0
- package/src/cf-worker/mod.ts +4 -2
- package/src/cf-worker/shared.ts +141 -0
- package/src/cf-worker/worker.ts +138 -116
- package/src/client/mod.ts +3 -0
- package/src/client/transport/do-rpc-client.ts +189 -0
- package/src/client/transport/http-rpc-client.ts +225 -0
- package/src/client/transport/ws-rpc-client.ts +202 -0
- package/src/common/constants.ts +18 -0
- package/src/common/do-rpc-schema.ts +54 -0
- package/src/common/http-rpc-schema.ts +40 -0
- package/src/common/mod.ts +10 -1
- package/src/common/sync-message-types.ts +117 -0
- package/src/common/ws-rpc-schema.ts +36 -0
- package/dist/cf-worker/cf-types.d.ts +0 -2
- package/dist/cf-worker/cf-types.d.ts.map +0 -1
- package/dist/cf-worker/cf-types.js +0 -2
- package/dist/cf-worker/cf-types.js.map +0 -1
- package/dist/cf-worker/durable-object.d.ts.map +0 -1
- package/dist/cf-worker/durable-object.js +0 -317
- package/dist/cf-worker/durable-object.js.map +0 -1
- package/dist/common/ws-message-types.d.ts.map +0 -1
- package/dist/common/ws-message-types.js +0 -57
- package/dist/common/ws-message-types.js.map +0 -1
- package/dist/sync-impl/mod.d.ts +0 -2
- package/dist/sync-impl/mod.d.ts.map +0 -1
- package/dist/sync-impl/mod.js +0 -2
- package/dist/sync-impl/mod.js.map +0 -1
- package/dist/sync-impl/ws-impl.d.ts +0 -7
- package/dist/sync-impl/ws-impl.d.ts.map +0 -1
- package/dist/sync-impl/ws-impl.js +0 -175
- package/dist/sync-impl/ws-impl.js.map +0 -1
- package/src/cf-worker/cf-types.ts +0 -12
- package/src/cf-worker/durable-object.ts +0 -478
- package/src/common/ws-message-types.ts +0 -114
- package/src/sync-impl/mod.ts +0 -1
- package/src/sync-impl/ws-impl.ts +0 -274
package/src/cf-worker/worker.ts
CHANGED
|
@@ -1,92 +1,70 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import type {
|
|
4
|
-
import { Effect,
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
import type { Env } from './durable-object.ts'
|
|
1
|
+
import { env as importedEnv } from 'cloudflare:workers'
|
|
2
|
+
import { UnknownError } from '@livestore/common'
|
|
3
|
+
import type { HelperTypes } from '@livestore/common-cf'
|
|
4
|
+
import { Effect, Schema } from '@livestore/utils/effect'
|
|
5
|
+
import type { CfTypes, SearchParams } from '../common/mod.ts'
|
|
6
|
+
import type { CfDeclare } from './mod.ts'
|
|
7
|
+
import { type Env, matchSyncRequest } from './shared.ts'
|
|
9
8
|
|
|
10
9
|
// NOTE We need to redeclare runtime types here to avoid type conflicts with the lib.dom Response type.
|
|
11
|
-
declare class Response extends
|
|
12
|
-
|
|
13
|
-
export namespace HelperTypes {
|
|
14
|
-
type AnyDON = CfWorker.DurableObjectNamespace<undefined>
|
|
15
|
-
|
|
16
|
-
type DOKeys<T> = {
|
|
17
|
-
[K in keyof T]-?: T[K] extends AnyDON ? K : never
|
|
18
|
-
}[keyof T]
|
|
19
|
-
|
|
20
|
-
type NonBuiltins<T> = Omit<T, keyof Env>
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Helper type to extract DurableObject keys from Env to give consumer type safety.
|
|
24
|
-
*
|
|
25
|
-
* @example
|
|
26
|
-
* ```ts
|
|
27
|
-
* type PlatformEnv = {
|
|
28
|
-
* DB: D1Database
|
|
29
|
-
* ADMIN_TOKEN: string
|
|
30
|
-
* WEBSOCKET_SERVER: DurableObjectNamespace<WebSocketServer>
|
|
31
|
-
* }
|
|
32
|
-
* export default makeWorker<PlatformEnv>({
|
|
33
|
-
* durableObject: { name: "WEBSOCKET_SERVER" },
|
|
34
|
-
* // ^ (property) name?: "WEBSOCKET_SERVER" | undefined
|
|
35
|
-
* });
|
|
36
|
-
*/
|
|
37
|
-
export type ExtractDurableObjectKeys<TEnv = Env> = DOKeys<NonBuiltins<TEnv>> extends never
|
|
38
|
-
? string
|
|
39
|
-
: DOKeys<NonBuiltins<TEnv>>
|
|
40
|
-
}
|
|
10
|
+
declare class Response extends CfDeclare.Response {}
|
|
41
11
|
|
|
42
12
|
// HINT: If we ever extend user's custom worker RPC, type T can help here with expected return type safety. Currently unused.
|
|
43
|
-
export type CFWorker<TEnv extends Env = Env, _T extends
|
|
13
|
+
export type CFWorker<TEnv extends Env = Env, _T extends CfTypes.Rpc.DurableObjectBranded | undefined = undefined> = {
|
|
44
14
|
fetch: <CFHostMetada = unknown>(
|
|
45
|
-
request:
|
|
15
|
+
request: CfTypes.Request<CFHostMetada>,
|
|
46
16
|
env: TEnv,
|
|
47
|
-
ctx:
|
|
48
|
-
) => Promise<
|
|
17
|
+
ctx: CfTypes.ExecutionContext,
|
|
18
|
+
) => Promise<CfTypes.Response>
|
|
49
19
|
}
|
|
50
20
|
|
|
51
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Options accepted by {@link makeWorker}. The Durable Object binding has to be
|
|
23
|
+
* supplied explicitly so we never fall back to deprecated defaults when Cloudflare config changes.
|
|
24
|
+
*/
|
|
25
|
+
export type MakeWorkerOptions<TEnv extends Env = Env, TSyncPayload = Schema.JsonValue> = {
|
|
26
|
+
/**
|
|
27
|
+
* Binding name of the sync Durable Object declared in wrangler config.
|
|
28
|
+
*/
|
|
29
|
+
syncBackendBinding: HelperTypes.ExtractDurableObjectKeys<TEnv>
|
|
52
30
|
/**
|
|
53
31
|
* Validates the payload during WebSocket connection establishment.
|
|
54
32
|
* Note: This runs only at connection time, not for individual push events.
|
|
55
33
|
* For push event validation, use the `onPush` callback in the durable object.
|
|
56
34
|
*/
|
|
57
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Optionally pass a schema to decode the client-provided payload into a typed object
|
|
37
|
+
* before calling {@link validatePayload}. If omitted, the raw JSON value is forwarded.
|
|
38
|
+
*/
|
|
39
|
+
syncPayloadSchema?: Schema.Schema<TSyncPayload>
|
|
40
|
+
/**
|
|
41
|
+
* Validates the (optionally decoded) payload during WebSocket connection establishment.
|
|
42
|
+
* If {@link syncPayloadSchema} is provided, `payload` will be of the schema’s inferred type.
|
|
43
|
+
*/
|
|
44
|
+
validatePayload?: (payload: TSyncPayload, context: { storeId: string }) => void | Promise<void>
|
|
58
45
|
/** @default false */
|
|
59
46
|
enableCORS?: boolean
|
|
60
|
-
durableObject?: {
|
|
61
|
-
/**
|
|
62
|
-
* Needs to match the binding name from the wrangler config
|
|
63
|
-
*
|
|
64
|
-
* @default 'WEBSOCKET_SERVER'
|
|
65
|
-
*/
|
|
66
|
-
name?: HelperTypes.ExtractDurableObjectKeys<TEnv>
|
|
67
|
-
}
|
|
68
47
|
}
|
|
69
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Produces a Cloudflare Worker `fetch` handler that delegates sync traffic to the
|
|
51
|
+
* Durable Object identified by `syncBackendBinding`.
|
|
52
|
+
*
|
|
53
|
+
* For more complex setups prefer implementing a custom `fetch` and call {@link handleSyncRequest}
|
|
54
|
+
* from the branch that handles LiveStore sync requests.
|
|
55
|
+
*/
|
|
70
56
|
export const makeWorker = <
|
|
71
57
|
TEnv extends Env = Env,
|
|
72
|
-
TDurableObjectRpc extends
|
|
58
|
+
TDurableObjectRpc extends CfTypes.Rpc.DurableObjectBranded | undefined = undefined,
|
|
59
|
+
TSyncPayload = Schema.JsonValue,
|
|
73
60
|
>(
|
|
74
|
-
options: MakeWorkerOptions<TEnv
|
|
61
|
+
options: MakeWorkerOptions<TEnv, TSyncPayload>,
|
|
75
62
|
): CFWorker<TEnv, TDurableObjectRpc> => {
|
|
76
63
|
return {
|
|
77
64
|
fetch: async (request, env, _ctx) => {
|
|
78
65
|
const url = new URL(request.url)
|
|
79
66
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (request.method === 'GET' && url.pathname === '/') {
|
|
83
|
-
return new Response('Info: WebSocket sync backend endpoint for @livestore/sync-cf.', {
|
|
84
|
-
status: 200,
|
|
85
|
-
headers: { 'Content-Type': 'text/plain' },
|
|
86
|
-
})
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const corsHeaders: CfWorker.HeadersInit = options.enableCORS
|
|
67
|
+
const corsHeaders: CfTypes.HeadersInit = options.enableCORS
|
|
90
68
|
? {
|
|
91
69
|
'Access-Control-Allow-Origin': '*',
|
|
92
70
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
@@ -101,11 +79,27 @@ export const makeWorker = <
|
|
|
101
79
|
})
|
|
102
80
|
}
|
|
103
81
|
|
|
104
|
-
|
|
105
|
-
|
|
82
|
+
const searchParams = matchSyncRequest(request)
|
|
83
|
+
|
|
84
|
+
// Check if this is a sync request first, before showing info message
|
|
85
|
+
if (searchParams !== undefined) {
|
|
86
|
+
return handleSyncRequest<TEnv, TDurableObjectRpc, unknown, TSyncPayload>({
|
|
87
|
+
request,
|
|
88
|
+
searchParams,
|
|
89
|
+
env,
|
|
90
|
+
ctx: _ctx,
|
|
91
|
+
syncBackendBinding: options.syncBackendBinding,
|
|
106
92
|
headers: corsHeaders,
|
|
107
93
|
validatePayload: options.validatePayload,
|
|
108
|
-
|
|
94
|
+
syncPayloadSchema: options.syncPayloadSchema,
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Only show info message for GET requests to / without sync parameters
|
|
99
|
+
if (request.method === 'GET' && url.pathname === '/') {
|
|
100
|
+
return new Response('Info: Sync backend endpoint for @livestore/sync-cf.', {
|
|
101
|
+
status: 200,
|
|
102
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
109
103
|
})
|
|
110
104
|
}
|
|
111
105
|
|
|
@@ -124,7 +118,7 @@ export const makeWorker = <
|
|
|
124
118
|
}
|
|
125
119
|
|
|
126
120
|
/**
|
|
127
|
-
* Handles
|
|
121
|
+
* Handles LiveStore sync requests (e.g. with search params `?storeId=...&transport=...`).
|
|
128
122
|
*
|
|
129
123
|
* @example
|
|
130
124
|
* ```ts
|
|
@@ -137,85 +131,113 @@ export const makeWorker = <
|
|
|
137
131
|
*
|
|
138
132
|
* export default {
|
|
139
133
|
* fetch: async (request, env, ctx) => {
|
|
140
|
-
*
|
|
141
|
-
*
|
|
134
|
+
* const searchParams = matchSyncRequest(request)
|
|
135
|
+
*
|
|
136
|
+
* // Is LiveStore sync request
|
|
137
|
+
* if (searchParams !== undefined) {
|
|
138
|
+
* return handleSyncRequest({
|
|
139
|
+
* request,
|
|
140
|
+
* searchParams,
|
|
141
|
+
* env,
|
|
142
|
+
* ctx,
|
|
143
|
+
* syncBackendBinding: 'SYNC_BACKEND_DO',
|
|
144
|
+
* headers: {},
|
|
145
|
+
* validatePayload,
|
|
146
|
+
* })
|
|
142
147
|
* }
|
|
143
148
|
*
|
|
144
149
|
* return new Response('Invalid path', { status: 400 })
|
|
145
|
-
* return new Response('Invalid path', { status: 400 })
|
|
146
150
|
* }
|
|
147
151
|
* }
|
|
148
152
|
* ```
|
|
149
153
|
*
|
|
150
|
-
* @throws {
|
|
154
|
+
* @throws {UnknownError} If the payload is invalid
|
|
151
155
|
*/
|
|
152
|
-
export const
|
|
156
|
+
export const handleSyncRequest = <
|
|
153
157
|
TEnv extends Env = Env,
|
|
154
|
-
TDurableObjectRpc extends
|
|
158
|
+
TDurableObjectRpc extends CfTypes.Rpc.DurableObjectBranded | undefined = undefined,
|
|
155
159
|
CFHostMetada = unknown,
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
160
|
+
TSyncPayload = Schema.JsonValue,
|
|
161
|
+
>({
|
|
162
|
+
request,
|
|
163
|
+
searchParams: { storeId, payload, transport },
|
|
164
|
+
env: explicitlyProvidedEnv,
|
|
165
|
+
syncBackendBinding,
|
|
166
|
+
headers,
|
|
167
|
+
validatePayload,
|
|
168
|
+
syncPayloadSchema,
|
|
169
|
+
}: {
|
|
170
|
+
request: CfTypes.Request<CFHostMetada>
|
|
171
|
+
searchParams: SearchParams
|
|
172
|
+
env?: TEnv | undefined
|
|
173
|
+
/** Only there for type-level reasons */
|
|
174
|
+
ctx: CfTypes.ExecutionContext
|
|
175
|
+
/** Binding name of the sync backend Durable Object */
|
|
176
|
+
syncBackendBinding: MakeWorkerOptions<TEnv, TSyncPayload>['syncBackendBinding']
|
|
177
|
+
headers?: CfTypes.HeadersInit | undefined
|
|
178
|
+
validatePayload?: MakeWorkerOptions<TEnv, TSyncPayload>['validatePayload']
|
|
179
|
+
syncPayloadSchema?: MakeWorkerOptions<TEnv, TSyncPayload>['syncPayloadSchema']
|
|
180
|
+
}): Promise<CfTypes.Response> =>
|
|
166
181
|
Effect.gen(function* () {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
182
|
+
if (validatePayload !== undefined) {
|
|
183
|
+
// Always decode with the supplied schema when present, even if payload is undefined.
|
|
184
|
+
// This ensures required payloads are enforced by the schema.
|
|
185
|
+
if (syncPayloadSchema !== undefined) {
|
|
186
|
+
const decodedEither = Schema.decodeUnknownEither(syncPayloadSchema)(payload)
|
|
187
|
+
if (decodedEither._tag === 'Left') {
|
|
188
|
+
const message = decodedEither.left.toString()
|
|
189
|
+
console.error('Invalid payload (decode failed)', message)
|
|
190
|
+
return new Response(message, { status: 400, headers })
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const result = yield* Effect.promise(async () =>
|
|
194
|
+
validatePayload(decodedEither.right as TSyncPayload, { storeId }),
|
|
195
|
+
).pipe(UnknownError.mapToUnknownError, Effect.either)
|
|
196
|
+
|
|
197
|
+
if (result._tag === 'Left') {
|
|
198
|
+
console.error('Invalid payload (validation failed)', result.left)
|
|
199
|
+
return new Response(result.left.toString(), { status: 400, headers })
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
const result = yield* Effect.promise(async () => validatePayload(payload as TSyncPayload, { storeId })).pipe(
|
|
203
|
+
UnknownError.mapToUnknownError,
|
|
204
|
+
Effect.either,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if (result._tag === 'Left') {
|
|
208
|
+
console.error('Invalid payload (validation failed)', result.left)
|
|
209
|
+
return new Response(result.left.toString(), { status: 400, headers })
|
|
210
|
+
}
|
|
190
211
|
}
|
|
191
212
|
}
|
|
192
213
|
|
|
193
|
-
const
|
|
194
|
-
|
|
214
|
+
const env = explicitlyProvidedEnv ?? (importedEnv as TEnv)
|
|
215
|
+
|
|
216
|
+
if (!(syncBackendBinding in env)) {
|
|
195
217
|
return new Response(
|
|
196
|
-
`Failed dependency: Required Durable Object binding '${
|
|
218
|
+
`Failed dependency: Required Durable Object binding '${syncBackendBinding as string}' not available`,
|
|
197
219
|
{
|
|
198
220
|
status: 424,
|
|
199
|
-
headers
|
|
221
|
+
headers,
|
|
200
222
|
},
|
|
201
223
|
)
|
|
202
224
|
}
|
|
203
225
|
|
|
204
226
|
const durableObjectNamespace = env[
|
|
205
|
-
|
|
206
|
-
] as
|
|
227
|
+
syncBackendBinding as keyof TEnv
|
|
228
|
+
] as CfTypes.DurableObjectNamespace<TDurableObjectRpc>
|
|
207
229
|
|
|
208
230
|
const id = durableObjectNamespace.idFromName(storeId)
|
|
209
231
|
const durableObject = durableObjectNamespace.get(id)
|
|
210
232
|
|
|
233
|
+
// Handle WebSocket upgrade request
|
|
211
234
|
const upgradeHeader = request.headers.get('Upgrade')
|
|
212
|
-
if (
|
|
235
|
+
if (transport === 'ws' && (upgradeHeader === null || upgradeHeader !== 'websocket')) {
|
|
213
236
|
return new Response('Durable Object expected Upgrade: websocket', {
|
|
214
237
|
status: 426,
|
|
215
|
-
headers
|
|
238
|
+
headers,
|
|
216
239
|
})
|
|
217
240
|
}
|
|
218
241
|
|
|
219
|
-
// Cloudflare Durable Object type clashing with lib.dom Response type, which is why we need the casts here.
|
|
220
242
|
return yield* Effect.promise(() => durableObject.fetch(request))
|
|
221
243
|
}).pipe(Effect.tapCauseLogPretty, Effect.runPromise)
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { InvalidPullError, InvalidPushError, SyncBackend, UnknownError } from '@livestore/common'
|
|
2
|
+
import { splitChunkBySize } from '@livestore/common/sync'
|
|
3
|
+
import { type CfTypes, layerProtocolDurableObject } from '@livestore/common-cf'
|
|
4
|
+
import { omit, shouldNeverHappen } from '@livestore/utils'
|
|
5
|
+
import {
|
|
6
|
+
Chunk,
|
|
7
|
+
Effect,
|
|
8
|
+
identity,
|
|
9
|
+
Layer,
|
|
10
|
+
Mailbox,
|
|
11
|
+
Option,
|
|
12
|
+
RpcClient,
|
|
13
|
+
RpcSerialization,
|
|
14
|
+
Schema,
|
|
15
|
+
Stream,
|
|
16
|
+
SubscriptionRef,
|
|
17
|
+
} from '@livestore/utils/effect'
|
|
18
|
+
import type { SyncBackendRpcInterface } from '../../cf-worker/shared.ts'
|
|
19
|
+
import { MAX_DO_RPC_REQUEST_BYTES, MAX_PUSH_EVENTS_PER_REQUEST } from '../../common/constants.ts'
|
|
20
|
+
import { SyncDoRpc } from '../../common/do-rpc-schema.ts'
|
|
21
|
+
import { SyncMessage } from '../../common/mod.ts'
|
|
22
|
+
import type { SyncMetadata } from '../../common/sync-message-types.ts'
|
|
23
|
+
|
|
24
|
+
export interface SyncBackendRpcStub extends CfTypes.DurableObjectStub, SyncBackendRpcInterface {}
|
|
25
|
+
|
|
26
|
+
// TODO we probably need better scoping for the requestIdMailboxMap (i.e. support multiple stores, ...)
|
|
27
|
+
type EffectRpcRequestId = string // 0, 1, 2, ...
|
|
28
|
+
const requestIdMailboxMap = new Map<EffectRpcRequestId, Mailbox.Mailbox<SyncMessage.PullResponse>>()
|
|
29
|
+
|
|
30
|
+
export interface DoRpcSyncOptions {
|
|
31
|
+
/** Durable Object stub that implements the SyncDoRpc interface */
|
|
32
|
+
syncBackendStub: SyncBackendRpcStub
|
|
33
|
+
/** Information about this DurableObject instance so the Sync DO instance can call back to this instance */
|
|
34
|
+
durableObjectContext: {
|
|
35
|
+
/** See `wrangler.toml` for the binding name */
|
|
36
|
+
bindingName: string
|
|
37
|
+
/** `state.id.toString()` in the DO */
|
|
38
|
+
durableObjectId: string
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Creates a sync backend that uses Durable Object RPC to communicate with the sync backend.
|
|
44
|
+
*
|
|
45
|
+
* Used internally by `@livestore/adapter-cf` to connect to the sync backend.
|
|
46
|
+
*/
|
|
47
|
+
export const makeDoRpcSync =
|
|
48
|
+
({ syncBackendStub, durableObjectContext }: DoRpcSyncOptions): SyncBackend.SyncBackendConstructor<SyncMetadata> =>
|
|
49
|
+
({ storeId, payload }) =>
|
|
50
|
+
Effect.gen(function* () {
|
|
51
|
+
const isConnected = yield* SubscriptionRef.make(true)
|
|
52
|
+
|
|
53
|
+
const ProtocolLive = layerProtocolDurableObject({
|
|
54
|
+
callRpc: (payload) => syncBackendStub.rpc(payload),
|
|
55
|
+
callerContext: durableObjectContext,
|
|
56
|
+
}).pipe(Layer.provide(RpcSerialization.layerJson))
|
|
57
|
+
|
|
58
|
+
const context = yield* Layer.build(ProtocolLive)
|
|
59
|
+
|
|
60
|
+
const rpcClient = yield* RpcClient.make(SyncDoRpc).pipe(Effect.provide(context))
|
|
61
|
+
|
|
62
|
+
// Nothing to do here
|
|
63
|
+
const connect = Effect.void
|
|
64
|
+
|
|
65
|
+
const backendIdHelper = yield* SyncBackend.makeBackendIdHelper
|
|
66
|
+
|
|
67
|
+
const pull: SyncBackend.SyncBackend<SyncMetadata>['pull'] = (cursor, options) =>
|
|
68
|
+
rpcClient.SyncDoRpc.Pull({
|
|
69
|
+
cursor: cursor.pipe(
|
|
70
|
+
Option.map((a) => ({
|
|
71
|
+
eventSequenceNumber: a.eventSequenceNumber,
|
|
72
|
+
backendId: backendIdHelper.get().pipe(Option.getOrThrow),
|
|
73
|
+
})),
|
|
74
|
+
),
|
|
75
|
+
storeId,
|
|
76
|
+
rpcContext: options?.live ? { callerContext: durableObjectContext } : undefined,
|
|
77
|
+
}).pipe(
|
|
78
|
+
options?.live
|
|
79
|
+
? Stream.concatWithLastElement((res) =>
|
|
80
|
+
Effect.gen(function* () {
|
|
81
|
+
if (res._tag === 'None')
|
|
82
|
+
return shouldNeverHappen('There should at least be a no-more page info response')
|
|
83
|
+
|
|
84
|
+
const mailbox = yield* Mailbox.make<SyncMessage.PullResponse>().pipe(
|
|
85
|
+
Effect.acquireRelease((mailbox) => mailbox.shutdown),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
requestIdMailboxMap.set(res.value.rpcRequestId, mailbox)
|
|
89
|
+
|
|
90
|
+
return Mailbox.toStream(mailbox)
|
|
91
|
+
}).pipe(Stream.unwrapScoped),
|
|
92
|
+
)
|
|
93
|
+
: identity,
|
|
94
|
+
Stream.tap((res) => backendIdHelper.lazySet(res.backendId)),
|
|
95
|
+
Stream.map((res) => omit(res, ['backendId'])),
|
|
96
|
+
Stream.mapError((cause) => (cause._tag === 'InvalidPullError' ? cause : InvalidPullError.make({ cause }))),
|
|
97
|
+
Stream.withSpan('rpc-sync-client:pull'),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
const push: SyncBackend.SyncBackend<{ createdAt: string }>['push'] = (batch) =>
|
|
101
|
+
Effect.gen(function* () {
|
|
102
|
+
if (batch.length === 0) {
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const backendId = backendIdHelper.get()
|
|
107
|
+
const batchChunks = yield* Chunk.fromIterable(batch).pipe(
|
|
108
|
+
splitChunkBySize({
|
|
109
|
+
maxItems: MAX_PUSH_EVENTS_PER_REQUEST,
|
|
110
|
+
maxBytes: MAX_DO_RPC_REQUEST_BYTES,
|
|
111
|
+
encode: (items) => ({
|
|
112
|
+
batch: items,
|
|
113
|
+
storeId,
|
|
114
|
+
backendId,
|
|
115
|
+
}),
|
|
116
|
+
}),
|
|
117
|
+
Effect.mapError((cause) => new InvalidPushError({ cause: new UnknownError({ cause }) })),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
for (const chunk of Chunk.toReadonlyArray(batchChunks)) {
|
|
121
|
+
const chunkArray = Chunk.toReadonlyArray(chunk)
|
|
122
|
+
yield* rpcClient.SyncDoRpc.Push({ batch: chunkArray, storeId, backendId })
|
|
123
|
+
}
|
|
124
|
+
}).pipe(
|
|
125
|
+
Effect.mapError((cause) =>
|
|
126
|
+
cause._tag === 'InvalidPushError' ? cause : InvalidPushError.make({ cause: new UnknownError({ cause }) }),
|
|
127
|
+
),
|
|
128
|
+
Effect.withSpan('rpc-sync-client:push'),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
const ping: SyncBackend.SyncBackend<{ createdAt: string }>['ping'] = rpcClient.SyncDoRpc.Ping({
|
|
132
|
+
storeId,
|
|
133
|
+
payload,
|
|
134
|
+
}).pipe(UnknownError.mapToUnknownError, Effect.withSpan('rpc-sync-client:ping'))
|
|
135
|
+
|
|
136
|
+
return SyncBackend.of({
|
|
137
|
+
connect,
|
|
138
|
+
isConnected,
|
|
139
|
+
pull,
|
|
140
|
+
push,
|
|
141
|
+
ping,
|
|
142
|
+
metadata: {
|
|
143
|
+
name: 'rpc-sync-client',
|
|
144
|
+
description: 'Cloudflare Durable Object RPC Sync Client',
|
|
145
|
+
protocol: 'rpc',
|
|
146
|
+
storeId,
|
|
147
|
+
},
|
|
148
|
+
supports: {
|
|
149
|
+
pullPageInfoKnown: true,
|
|
150
|
+
pullLive: true,
|
|
151
|
+
},
|
|
152
|
+
})
|
|
153
|
+
}).pipe(Effect.withSpan('rpc-sync-client:makeDoRpcSync'))
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
*
|
|
157
|
+
* ```ts
|
|
158
|
+
* import { DurableObject } from 'cloudflare:workers'
|
|
159
|
+
* import { ClientDoWithRpcCallback } from '@livestore/common-cf'
|
|
160
|
+
*
|
|
161
|
+
* export class MyDurableObject extends DurableObject implements ClientDoWithRpcCallback {
|
|
162
|
+
* // ...
|
|
163
|
+
*
|
|
164
|
+
* async syncUpdateRpc(payload: RpcMessage.ResponseChunkEncoded) {
|
|
165
|
+
* return handleSyncUpdateRpc(payload)
|
|
166
|
+
* }
|
|
167
|
+
* }
|
|
168
|
+
* ```
|
|
169
|
+
*/
|
|
170
|
+
export const handleSyncUpdateRpc = (payload: unknown) =>
|
|
171
|
+
Effect.gen(function* () {
|
|
172
|
+
const decodedPayload = yield* Schema.decodeUnknown(ResponseChunkEncoded)(payload)
|
|
173
|
+
const decoded = yield* Schema.decodeUnknown(SyncMessage.PullResponse)(decodedPayload.values[0]!)
|
|
174
|
+
|
|
175
|
+
const pullStreamMailbox = requestIdMailboxMap.get(decodedPayload.requestId)
|
|
176
|
+
|
|
177
|
+
if (pullStreamMailbox === undefined) {
|
|
178
|
+
// Case: DO was hibernated, so we need to manually update the store
|
|
179
|
+
yield* Effect.log(`No mailbox found for ${decodedPayload.requestId}`)
|
|
180
|
+
} else {
|
|
181
|
+
// Case: DO was still alive, so the existing `pull` will pick up the new events
|
|
182
|
+
yield* pullStreamMailbox.offer(decoded)
|
|
183
|
+
}
|
|
184
|
+
}).pipe(Effect.withSpan('rpc-sync-client:rpcCallback'), Effect.tapCauseLogPretty, Effect.runPromise)
|
|
185
|
+
|
|
186
|
+
const ResponseChunkEncoded = Schema.Struct({
|
|
187
|
+
requestId: Schema.String,
|
|
188
|
+
values: Schema.Array(Schema.Any),
|
|
189
|
+
})
|