@livestore/sync-cf 0.4.0 → 0.5.0-dev.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/do/durable-object.d.ts.map +1 -1
- package/dist/cf-worker/do/durable-object.js +24 -11
- package/dist/cf-worker/do/durable-object.js.map +1 -1
- package/dist/cf-worker/do/layer.d.ts +22 -18
- package/dist/cf-worker/do/layer.d.ts.map +1 -1
- package/dist/cf-worker/do/layer.js +43 -33
- package/dist/cf-worker/do/layer.js.map +1 -1
- package/dist/cf-worker/do/pull.d.ts +3 -3
- package/dist/cf-worker/do/pull.d.ts.map +1 -1
- package/dist/cf-worker/do/pull.js +17 -18
- package/dist/cf-worker/do/pull.js.map +1 -1
- package/dist/cf-worker/do/push.d.ts +16 -3
- package/dist/cf-worker/do/push.d.ts.map +1 -1
- package/dist/cf-worker/do/push.js +69 -49
- package/dist/cf-worker/do/push.js.map +1 -1
- package/dist/cf-worker/do/push.test.d.ts +2 -0
- package/dist/cf-worker/do/push.test.d.ts.map +1 -0
- package/dist/cf-worker/do/push.test.js +30 -0
- package/dist/cf-worker/do/push.test.js.map +1 -0
- package/dist/cf-worker/do/sqlite.d.ts +34 -46
- package/dist/cf-worker/do/sqlite.d.ts.map +1 -1
- package/dist/cf-worker/do/sqlite.js +1 -1
- package/dist/cf-worker/do/sqlite.js.map +1 -1
- package/dist/cf-worker/do/sync-storage.d.ts +7 -6
- package/dist/cf-worker/do/sync-storage.d.ts.map +1 -1
- package/dist/cf-worker/do/sync-storage.js +26 -14
- package/dist/cf-worker/do/sync-storage.js.map +1 -1
- package/dist/cf-worker/do/transport/do-rpc-server.d.ts +2 -2
- package/dist/cf-worker/do/transport/do-rpc-server.d.ts.map +1 -1
- package/dist/cf-worker/do/transport/do-rpc-server.js +21 -19
- package/dist/cf-worker/do/transport/do-rpc-server.js.map +1 -1
- package/dist/cf-worker/do/transport/http-rpc-server.d.ts +2 -2
- package/dist/cf-worker/do/transport/http-rpc-server.d.ts.map +1 -1
- package/dist/cf-worker/do/transport/http-rpc-server.js +7 -7
- package/dist/cf-worker/do/transport/http-rpc-server.js.map +1 -1
- package/dist/cf-worker/do/transport/ws-rpc-server.d.ts +2 -2
- package/dist/cf-worker/do/transport/ws-rpc-server.d.ts.map +1 -1
- package/dist/cf-worker/do/transport/ws-rpc-server.js +9 -9
- package/dist/cf-worker/do/transport/ws-rpc-server.js.map +1 -1
- package/dist/cf-worker/shared.d.ts +22 -19
- package/dist/cf-worker/shared.d.ts.map +1 -1
- package/dist/cf-worker/shared.js +13 -11
- package/dist/cf-worker/shared.js.map +1 -1
- package/dist/cf-worker/worker.d.ts +6 -6
- package/dist/cf-worker/worker.d.ts.map +1 -1
- package/dist/cf-worker/worker.js +16 -14
- package/dist/cf-worker/worker.js.map +1 -1
- package/dist/client/transport/do-rpc-client.d.ts +13 -4
- package/dist/client/transport/do-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/do-rpc-client.js +55 -26
- package/dist/client/transport/do-rpc-client.js.map +1 -1
- package/dist/client/transport/http-rpc-client.d.ts +3 -3
- package/dist/client/transport/http-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/http-rpc-client.js +30 -21
- package/dist/client/transport/http-rpc-client.js.map +1 -1
- package/dist/client/transport/ws-rpc-client.d.ts +3 -3
- package/dist/client/transport/ws-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/ws-rpc-client.js +20 -16
- package/dist/client/transport/ws-rpc-client.js.map +1 -1
- package/dist/common/do-rpc-schema.d.ts +38 -39
- package/dist/common/do-rpc-schema.d.ts.map +1 -1
- package/dist/common/do-rpc-schema.js +3 -3
- package/dist/common/do-rpc-schema.js.map +1 -1
- package/dist/common/http-rpc-schema.d.ts +36 -37
- package/dist/common/http-rpc-schema.d.ts.map +1 -1
- package/dist/common/http-rpc-schema.js +5 -5
- package/dist/common/http-rpc-schema.js.map +1 -1
- package/dist/common/mod.d.ts +4 -5
- package/dist/common/mod.d.ts.map +1 -1
- package/dist/common/mod.js +3 -4
- package/dist/common/mod.js.map +1 -1
- package/dist/common/sync-message-types.d.ts +116 -133
- package/dist/common/sync-message-types.d.ts.map +1 -1
- package/dist/common/sync-message-types.js +26 -14
- package/dist/common/sync-message-types.js.map +1 -1
- package/dist/common/ws-rpc-schema.d.ts +33 -34
- package/dist/common/ws-rpc-schema.d.ts.map +1 -1
- package/dist/common/ws-rpc-schema.js +4 -4
- package/dist/common/ws-rpc-schema.js.map +1 -1
- package/package.json +21 -37
- package/src/cf-worker/do/durable-object.ts +45 -36
- package/src/cf-worker/do/layer.ts +142 -99
- package/src/cf-worker/do/pull.ts +28 -28
- package/src/cf-worker/do/push.test.ts +40 -0
- package/src/cf-worker/do/push.ts +138 -115
- package/src/cf-worker/do/sqlite.ts +1 -1
- package/src/cf-worker/do/sync-storage.ts +34 -21
- package/src/cf-worker/do/transport/do-rpc-server.ts +23 -34
- package/src/cf-worker/do/transport/http-rpc-server.ts +7 -10
- package/src/cf-worker/do/transport/ws-rpc-server.ts +10 -10
- package/src/cf-worker/shared.ts +18 -13
- package/src/cf-worker/worker.ts +21 -22
- package/src/client/transport/do-rpc-client.ts +82 -40
- package/src/client/transport/http-rpc-client.ts +47 -37
- package/src/client/transport/ws-rpc-client.ts +29 -26
- package/src/common/do-rpc-schema.ts +3 -3
- package/src/common/http-rpc-schema.ts +5 -5
- package/src/common/mod.ts +3 -4
- package/src/common/sync-message-types.ts +16 -16
- package/src/common/ws-rpc-schema.ts +4 -4
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { CfTypes } from '@livestore/common-cf'
|
|
2
|
-
import { Effect,
|
|
2
|
+
import { Effect, HttpEffect, Layer, RpcSerialization, RpcServer } from '@livestore/utils/effect'
|
|
3
3
|
|
|
4
4
|
import { SyncHttpRpc } from '../../../common/http-rpc-schema.ts'
|
|
5
5
|
import * as SyncMessage from '../../../common/sync-message-types.ts'
|
|
6
6
|
import { headersRecordToMap } from '../../shared.ts'
|
|
7
|
-
import
|
|
7
|
+
import * as DoCtx from '../layer.ts'
|
|
8
8
|
import { makeEndingPullStream } from '../pull.ts'
|
|
9
9
|
import { makePush } from '../push.ts'
|
|
10
10
|
|
|
@@ -18,8 +18,8 @@ export const createHttpRpcHandler = Effect.fn('createHttpRpcHandler')(function*
|
|
|
18
18
|
forwardedHeaders?: Record<string, string>
|
|
19
19
|
}) {
|
|
20
20
|
const handlerLayer = createHttpRpcLayer(forwardedHeaders)
|
|
21
|
-
const
|
|
22
|
-
const webHandler =
|
|
21
|
+
const httpEffect = yield* RpcServer.toHttpEffect(SyncHttpRpc).pipe(Effect.provide(handlerLayer))
|
|
22
|
+
const webHandler = HttpEffect.toWebHandler(httpEffect)
|
|
23
23
|
|
|
24
24
|
const response = yield* Effect.promise(
|
|
25
25
|
() => webHandler(request as TODO as Request) as TODO as Promise<CfTypes.Response>,
|
|
@@ -43,15 +43,12 @@ const createHttpRpcLayer = (forwardedHeaders: Record<string, string> | undefined
|
|
|
43
43
|
|
|
44
44
|
'SyncHttpRpc.Push': (req) =>
|
|
45
45
|
Effect.gen(function* () {
|
|
46
|
-
const { ctx, env, doOptions, storeId } = yield* DoCtx
|
|
47
|
-
const push = makePush({ payload:
|
|
46
|
+
const { ctx, env, doOptions, storeId } = yield* DoCtx.DoCtx
|
|
47
|
+
const push = makePush({ payload: req.payload, headers, options: doOptions, storeId, ctx, env })
|
|
48
48
|
|
|
49
49
|
return yield* push(req)
|
|
50
50
|
}),
|
|
51
51
|
|
|
52
52
|
'SyncHttpRpc.Ping': () => Effect.succeed(SyncMessage.Pong.make({})),
|
|
53
|
-
}).pipe(
|
|
54
|
-
Layer.provideMerge(RpcServer.layerProtocolHttp({ path: '/http-rpc' })),
|
|
55
|
-
Layer.provideMerge(RpcSerialization.layerJson),
|
|
56
|
-
)
|
|
53
|
+
}).pipe(Layer.provideMerge(RpcSerialization.layerJson))
|
|
57
54
|
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { UnknownError } from '@livestore/common'
|
|
2
2
|
import { WsContext } from '@livestore/common-cf'
|
|
3
|
-
import { Effect, identity, Layer, RpcServer, Schema, Stream } from '@livestore/utils/effect'
|
|
3
|
+
import { Effect, identity, Layer, Result, RpcServer, Schema, Stream } from '@livestore/utils/effect'
|
|
4
4
|
|
|
5
5
|
import { SyncWsRpc } from '../../../common/ws-rpc-schema.ts'
|
|
6
6
|
import { headersRecordToMap, WebSocketAttachmentSchema } from '../../shared.ts'
|
|
7
|
-
import
|
|
7
|
+
import * as DoCtx from '../layer.ts'
|
|
8
8
|
import { makeEndingPullStream } from '../pull.ts'
|
|
9
9
|
import { makePush } from '../push.ts'
|
|
10
10
|
|
|
11
|
-
export const makeRpcServer = ({ doSelf, doOptions }: Omit<DoCtxInput, 'from'>) => {
|
|
11
|
+
export const makeRpcServer = ({ doSelf, doOptions }: Omit<DoCtx.DoCtxInput, 'from'>) => {
|
|
12
12
|
const handlersLayer = SyncWsRpc.toLayer({
|
|
13
13
|
'SyncWsRpc.Pull': (req) =>
|
|
14
14
|
Effect.gen(function* () {
|
|
@@ -16,7 +16,7 @@ export const makeRpcServer = ({ doSelf, doOptions }: Omit<DoCtxInput, 'from'>) =
|
|
|
16
16
|
return makeEndingPullStream({ req, payload: req.payload, headers }).pipe(
|
|
17
17
|
// Needed to keep the stream alive on the client side for phase 2 (i.e. not send the `Exit` stream RPC message)
|
|
18
18
|
req.live === true ? Stream.concat(Stream.never) : identity,
|
|
19
|
-
Stream.
|
|
19
|
+
Stream.provide(DoCtx.layer({ doSelf, doOptions, from: { storeId: req.storeId } })),
|
|
20
20
|
Stream.mapError((cause) =>
|
|
21
21
|
cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError'
|
|
22
22
|
? cause
|
|
@@ -26,14 +26,14 @@ export const makeRpcServer = ({ doSelf, doOptions }: Omit<DoCtxInput, 'from'>) =
|
|
|
26
26
|
}).pipe(Stream.unwrap),
|
|
27
27
|
'SyncWsRpc.Push': (req) =>
|
|
28
28
|
Effect.gen(function* () {
|
|
29
|
-
const { doOptions, storeId, ctx, env } = yield* DoCtx
|
|
29
|
+
const { doOptions, storeId, ctx, env } = yield* DoCtx.DoCtx
|
|
30
30
|
const headers = yield* getForwardedHeaders
|
|
31
31
|
|
|
32
32
|
const push = makePush({ options: doOptions, storeId, payload: req.payload, headers, ctx, env })
|
|
33
33
|
|
|
34
34
|
return yield* push(req)
|
|
35
35
|
}).pipe(
|
|
36
|
-
Effect.provide(DoCtx.
|
|
36
|
+
Effect.provide(DoCtx.layer({ doSelf, doOptions, from: { storeId: req.storeId } })),
|
|
37
37
|
Effect.mapError((cause) =>
|
|
38
38
|
cause._tag === 'UnknownError' || cause._tag === 'ServerAheadError' || cause._tag === 'BackendIdMismatchError'
|
|
39
39
|
? cause
|
|
@@ -50,13 +50,13 @@ export const makeRpcServer = ({ doSelf, doOptions }: Omit<DoCtxInput, 'from'>) =
|
|
|
50
50
|
const getForwardedHeaders = Effect.gen(function* () {
|
|
51
51
|
const { ws } = yield* WsContext
|
|
52
52
|
const attachment = ws.deserializeAttachment()
|
|
53
|
-
const decoded = Schema.
|
|
54
|
-
if (decoded
|
|
55
|
-
yield* Effect.logError('Failed to decode WebSocket attachment for forwarded headers', { error: decoded.
|
|
53
|
+
const decoded = Schema.decodeUnknownResult(WebSocketAttachmentSchema)(attachment)
|
|
54
|
+
if (Result.isFailure(decoded) === true) {
|
|
55
|
+
yield* Effect.logError('Failed to decode WebSocket attachment for forwarded headers', { error: decoded.failure })
|
|
56
56
|
ws.close(1011, 'invalid-attachment')
|
|
57
57
|
return yield* Effect.die('Invalid WebSocket attachment (headers decode failed)')
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
const headers = headersRecordToMap(decoded.
|
|
60
|
+
const headers = headersRecordToMap(decoded.success.headers)
|
|
61
61
|
return headers
|
|
62
62
|
})
|
package/src/cf-worker/shared.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { UnknownError } from '@livestore/common'
|
|
2
2
|
import type { CfTypes } from '@livestore/common-cf'
|
|
3
|
-
import { Effect
|
|
3
|
+
import type { Effect } from '@livestore/utils/effect'
|
|
4
|
+
import { Result, Schema } from '@livestore/utils/effect'
|
|
4
5
|
|
|
5
6
|
import type { SearchParams } from '../common/mod.ts'
|
|
6
7
|
import { SearchParamsSchema, SyncMessage } from '../common/mod.ts'
|
|
@@ -20,7 +21,7 @@ export type ForwardHeadersOption = readonly string[] | ((request: CfTypes.Reques
|
|
|
20
21
|
/** Context passed to onPush/onPull callbacks */
|
|
21
22
|
export type CallbackContext = {
|
|
22
23
|
storeId: StoreId
|
|
23
|
-
payload?: Schema.
|
|
24
|
+
payload?: Schema.Json
|
|
24
25
|
/** Headers forwarded from the request (only present if `forwardHeaders` is configured) */
|
|
25
26
|
headers?: ForwardedHeaders
|
|
26
27
|
}
|
|
@@ -133,8 +134,10 @@ export type DurableObjectId = string
|
|
|
133
134
|
*/
|
|
134
135
|
export const PERSISTENCE_FORMAT_VERSION = 7
|
|
135
136
|
|
|
136
|
-
export const encodeOutgoingMessage = Schema.encodeSync(Schema.
|
|
137
|
-
export const encodeIncomingMessage = Schema.encodeSync(Schema.
|
|
137
|
+
export const encodeOutgoingMessage = Schema.encodeSync(Schema.fromJsonString(SyncMessage.BackendToClientMessage))
|
|
138
|
+
export const encodeIncomingMessage = Schema.encodeSync(Schema.fromJsonString(SyncMessage.ClientToBackendMessage))
|
|
139
|
+
|
|
140
|
+
const SearchParamsFromUrlSearchParams = Schema.fromURLSearchParams(SearchParamsSchema)
|
|
138
141
|
|
|
139
142
|
/**
|
|
140
143
|
* Extracts the LiveStore sync search parameters from a request. Returns
|
|
@@ -143,20 +146,19 @@ export const encodeIncomingMessage = Schema.encodeSync(Schema.parseJson(SyncMess
|
|
|
143
146
|
*/
|
|
144
147
|
export const matchSyncRequest = (request: CfTypes.Request): SearchParams | undefined => {
|
|
145
148
|
const url = new URL(request.url)
|
|
146
|
-
const
|
|
147
|
-
const paramsResult = UrlParams.schemaStruct(SearchParamsSchema)(urlParams).pipe(Effect.option, Effect.runSync)
|
|
149
|
+
const paramsResult = Schema.decodeUnknownResult(SearchParamsFromUrlSearchParams)(url.searchParams)
|
|
148
150
|
|
|
149
|
-
if (paramsResult
|
|
151
|
+
if (Result.isFailure(paramsResult) === true) {
|
|
150
152
|
return undefined
|
|
151
153
|
}
|
|
152
154
|
|
|
153
|
-
return paramsResult.
|
|
155
|
+
return paramsResult.success
|
|
154
156
|
}
|
|
155
157
|
|
|
156
158
|
// RPC subscription storage (TODO refactor)
|
|
157
159
|
export type RpcSubscription = {
|
|
158
160
|
storeId: StoreId
|
|
159
|
-
payload?: Schema.
|
|
161
|
+
payload?: Schema.Json
|
|
160
162
|
subscribedAt: number
|
|
161
163
|
/** Effect RPC request ID */
|
|
162
164
|
requestId: string
|
|
@@ -166,6 +168,9 @@ export type RpcSubscription = {
|
|
|
166
168
|
}
|
|
167
169
|
}
|
|
168
170
|
|
|
171
|
+
/** Key prefix for DO-RPC subscriptions persisted in the DO's synchronous KV storage. */
|
|
172
|
+
export const rpcSubscriptionKeyPrefix = 'rpc-sub:'
|
|
173
|
+
|
|
169
174
|
/**
|
|
170
175
|
* Durable Object interface supporting the DO RPC protocol for DO <> DO syncing.
|
|
171
176
|
*/
|
|
@@ -174,15 +179,15 @@ export interface SyncBackendRpcInterface {
|
|
|
174
179
|
rpc(payload: Uint8Array): Promise<Uint8Array | CfTypes.ReadableStream>
|
|
175
180
|
}
|
|
176
181
|
|
|
177
|
-
export const WebSocketAttachmentSchema = Schema.
|
|
182
|
+
export const WebSocketAttachmentSchema = Schema.fromJsonString(
|
|
178
183
|
Schema.Struct({
|
|
179
184
|
// Same across all websocket connections
|
|
180
185
|
storeId: Schema.String,
|
|
181
186
|
// Different for each websocket connection
|
|
182
|
-
payload: Schema.optional(Schema.
|
|
183
|
-
pullRequestIds: Schema.Array(Schema.String),
|
|
187
|
+
payload: Schema.optional(Schema.Json),
|
|
188
|
+
pullRequestIds: Schema.Array(Schema.Union([Schema.String, Schema.Finite])),
|
|
184
189
|
// Headers forwarded from the initial request (via forwardHeaders option)
|
|
185
|
-
headers: Schema.optional(Schema.Record(
|
|
190
|
+
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
|
186
191
|
}),
|
|
187
192
|
)
|
|
188
193
|
|
package/src/cf-worker/worker.ts
CHANGED
|
@@ -2,14 +2,13 @@ import { env as importedEnv } from 'cloudflare:workers'
|
|
|
2
2
|
|
|
3
3
|
import { UnknownError } from '@livestore/common'
|
|
4
4
|
import type { HelperTypes } from '@livestore/common-cf'
|
|
5
|
-
import {
|
|
5
|
+
import { CfDeclare } from '@livestore/common-cf/declare'
|
|
6
|
+
import { Effect, Result, Schema } from '@livestore/utils/effect'
|
|
6
7
|
|
|
7
8
|
import type { CfTypes, SearchParams } from '../common/mod.ts'
|
|
8
|
-
import type { CfDeclare } from './mod.ts'
|
|
9
9
|
import { type Env, type ForwardedHeaders, matchSyncRequest } from './shared.ts'
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
declare class Response extends CfDeclare.Response {}
|
|
11
|
+
const Response = CfDeclare.Response
|
|
13
12
|
|
|
14
13
|
// HINT: If we ever extend user's custom worker RPC, type T can help here with expected return type safety. Currently unused.
|
|
15
14
|
export type CFWorker<TEnv extends Env = Env, _T extends CfTypes.Rpc.DurableObjectBranded | undefined = undefined> = {
|
|
@@ -31,7 +30,7 @@ export type ValidatePayloadContext = {
|
|
|
31
30
|
* Options accepted by {@link makeWorker}. The Durable Object binding has to be
|
|
32
31
|
* supplied explicitly so we never fall back to deprecated defaults when Cloudflare config changes.
|
|
33
32
|
*/
|
|
34
|
-
export type MakeWorkerOptions<TEnv extends Env = Env, TSyncPayload = Schema.
|
|
33
|
+
export type MakeWorkerOptions<TEnv extends Env = Env, TSyncPayload = Schema.Json> = {
|
|
35
34
|
/**
|
|
36
35
|
* Binding name of the sync Durable Object declared in wrangler config.
|
|
37
36
|
*/
|
|
@@ -40,7 +39,7 @@ export type MakeWorkerOptions<TEnv extends Env = Env, TSyncPayload = Schema.Json
|
|
|
40
39
|
* Optionally pass a schema to decode the client-provided payload into a typed object
|
|
41
40
|
* before calling {@link validatePayload}. If omitted, the raw JSON value is forwarded.
|
|
42
41
|
*/
|
|
43
|
-
syncPayloadSchema?: Schema.
|
|
42
|
+
syncPayloadSchema?: Schema.Decoder<TSyncPayload>
|
|
44
43
|
/**
|
|
45
44
|
* Validates the (optionally decoded) payload during WebSocket connection establishment.
|
|
46
45
|
* If {@link syncPayloadSchema} is provided, `payload` will be of the schema's inferred type.
|
|
@@ -74,7 +73,7 @@ export type MakeWorkerOptions<TEnv extends Env = Env, TSyncPayload = Schema.Json
|
|
|
74
73
|
export const makeWorker = <
|
|
75
74
|
TEnv extends Env = Env,
|
|
76
75
|
TDurableObjectRpc extends CfTypes.Rpc.DurableObjectBranded | undefined = undefined,
|
|
77
|
-
TSyncPayload = Schema.
|
|
76
|
+
TSyncPayload = Schema.Json,
|
|
78
77
|
>(
|
|
79
78
|
options: MakeWorkerOptions<TEnv, TSyncPayload>,
|
|
80
79
|
): CFWorker<TEnv, TDurableObjectRpc> => {
|
|
@@ -150,7 +149,7 @@ const requestHeadersToMap = (request: CfTypes.Request): ForwardedHeaders => {
|
|
|
150
149
|
*
|
|
151
150
|
* @example Token-based authentication
|
|
152
151
|
* ```ts
|
|
153
|
-
* const validatePayload = (payload: Schema.
|
|
152
|
+
* const validatePayload = (payload: Schema.Json | undefined, context: { storeId: string }) => {
|
|
154
153
|
* if (payload?.authToken !== 'insecure-token-change-me') {
|
|
155
154
|
* throw new Error('Invalid auth token')
|
|
156
155
|
* }
|
|
@@ -159,7 +158,7 @@ const requestHeadersToMap = (request: CfTypes.Request): ForwardedHeaders => {
|
|
|
159
158
|
*
|
|
160
159
|
* @example Cookie-based authentication
|
|
161
160
|
* ```ts
|
|
162
|
-
* const validatePayload = async (payload: Schema.
|
|
161
|
+
* const validatePayload = async (payload: Schema.Json | undefined, { storeId, headers }) => {
|
|
163
162
|
* const cookie = headers.get('cookie')
|
|
164
163
|
* const session = await validateSessionFromCookie(cookie)
|
|
165
164
|
* if (!session) throw new Error('Unauthorized')
|
|
@@ -172,7 +171,7 @@ export const handleSyncRequest = <
|
|
|
172
171
|
TEnv extends Env = Env,
|
|
173
172
|
TDurableObjectRpc extends CfTypes.Rpc.DurableObjectBranded | undefined = undefined,
|
|
174
173
|
CFHostMetada = unknown,
|
|
175
|
-
TSyncPayload = Schema.
|
|
174
|
+
TSyncPayload = Schema.Json,
|
|
176
175
|
>({
|
|
177
176
|
request,
|
|
178
177
|
searchParams: { storeId, payload, transport },
|
|
@@ -201,29 +200,29 @@ export const handleSyncRequest = <
|
|
|
201
200
|
// Always decode with the supplied schema when present, even if payload is undefined.
|
|
202
201
|
// This ensures required payloads are enforced by the schema.
|
|
203
202
|
if (syncPayloadSchema !== undefined) {
|
|
204
|
-
const
|
|
205
|
-
if (
|
|
206
|
-
const message =
|
|
203
|
+
const decodedResult = Schema.decodeUnknownResult(syncPayloadSchema)(payload)
|
|
204
|
+
if (Result.isFailure(decodedResult) === true) {
|
|
205
|
+
const message = decodedResult.failure.toString()
|
|
207
206
|
console.error('Invalid payload (decode failed)', message)
|
|
208
207
|
return new Response(message, { status: 400, ...(headers !== undefined ? { headers } : {}) })
|
|
209
208
|
}
|
|
210
209
|
|
|
211
210
|
const result = yield* Effect.promise(async () =>
|
|
212
|
-
validatePayload(
|
|
213
|
-
).pipe(UnknownError.mapToUnknownError, Effect.
|
|
211
|
+
validatePayload(decodedResult.success, { storeId, headers: requestHeaders }),
|
|
212
|
+
).pipe(UnknownError.mapToUnknownError, Effect.result)
|
|
214
213
|
|
|
215
|
-
if (result
|
|
216
|
-
console.error('Invalid payload (validation failed)', result.
|
|
217
|
-
return new Response(result.
|
|
214
|
+
if (Result.isFailure(result) === true) {
|
|
215
|
+
console.error('Invalid payload (validation failed)', result.failure)
|
|
216
|
+
return new Response(result.failure.toString(), { status: 400, ...(headers !== undefined ? { headers } : {}) })
|
|
218
217
|
}
|
|
219
218
|
} else {
|
|
220
219
|
const result = yield* Effect.promise(async () =>
|
|
221
220
|
validatePayload(payload as TSyncPayload, { storeId, headers: requestHeaders }),
|
|
222
|
-
).pipe(UnknownError.mapToUnknownError, Effect.
|
|
221
|
+
).pipe(UnknownError.mapToUnknownError, Effect.result)
|
|
223
222
|
|
|
224
|
-
if (result
|
|
225
|
-
console.error('Invalid payload (validation failed)', result.
|
|
226
|
-
return new Response(result.
|
|
223
|
+
if (Result.isFailure(result) === true) {
|
|
224
|
+
console.error('Invalid payload (validation failed)', result.failure)
|
|
225
|
+
return new Response(result.failure.toString(), { status: 400, ...(headers !== undefined ? { headers } : {}) })
|
|
227
226
|
}
|
|
228
227
|
}
|
|
229
228
|
}
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import { SyncBackend, UnknownError } from '@livestore/common'
|
|
2
|
-
import { splitChunkBySize } from '@livestore/common/sync'
|
|
3
2
|
import { type CfTypes, layerProtocolDurableObject } from '@livestore/common-cf'
|
|
4
|
-
import {
|
|
3
|
+
import { splitArrayBySize } from '@livestore/common/sync'
|
|
4
|
+
import { shouldNeverHappen } from '@livestore/utils'
|
|
5
5
|
import {
|
|
6
|
-
Chunk,
|
|
7
6
|
Effect,
|
|
8
7
|
identity,
|
|
9
8
|
Layer,
|
|
10
|
-
Mailbox,
|
|
11
9
|
Option,
|
|
10
|
+
Queue,
|
|
11
|
+
ReadonlyArray as EffectArray,
|
|
12
12
|
RpcClient,
|
|
13
13
|
RpcSerialization,
|
|
14
14
|
Schema,
|
|
15
15
|
Stream,
|
|
16
|
+
Struct,
|
|
16
17
|
SubscriptionRef,
|
|
17
18
|
} from '@livestore/utils/effect'
|
|
18
19
|
|
|
@@ -22,15 +23,18 @@ import { SyncDoRpc } from '../../common/do-rpc-schema.ts'
|
|
|
22
23
|
import { SyncMessage } from '../../common/mod.ts'
|
|
23
24
|
import type { SyncMetadata } from '../../common/sync-message-types.ts'
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
type PushBatchItem = SyncMessage.PushRequest['batch'][number]
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
type EffectRpcRequestId = string // 0, 1, 2, ...
|
|
29
|
-
const requestIdMailboxMap = new Map<EffectRpcRequestId, Mailbox.Mailbox<SyncMessage.PullResponse>>()
|
|
28
|
+
export interface SyncBackendRpcStub extends CfTypes.DurableObjectStub, SyncBackendRpcInterface {}
|
|
30
29
|
|
|
31
30
|
export interface DoRpcSyncOptions {
|
|
32
31
|
/** Durable Object stub that implements the SyncDoRpc interface */
|
|
33
32
|
syncBackendStub: SyncBackendRpcStub
|
|
33
|
+
/**
|
|
34
|
+
* State handle of the client DurableObject running this sync backend. Scopes live-pull routing to
|
|
35
|
+
* this instance so it resets when the DO is reconstructed (see {@link handleSyncUpdateRpc}).
|
|
36
|
+
*/
|
|
37
|
+
durableObjectState: CfTypes.DurableObjectState
|
|
34
38
|
/** Information about this DurableObject instance so the Sync DO instance can call back to this instance */
|
|
35
39
|
durableObjectContext: {
|
|
36
40
|
/** See `wrangler.toml` for the binding name */
|
|
@@ -46,7 +50,11 @@ export interface DoRpcSyncOptions {
|
|
|
46
50
|
* Used internally by `@livestore/adapter-cf` to connect to the sync backend.
|
|
47
51
|
*/
|
|
48
52
|
export const makeDoRpcSync =
|
|
49
|
-
({
|
|
53
|
+
({
|
|
54
|
+
syncBackendStub,
|
|
55
|
+
durableObjectState,
|
|
56
|
+
durableObjectContext,
|
|
57
|
+
}: DoRpcSyncOptions): SyncBackend.SyncBackendConstructor<SyncMetadata> =>
|
|
50
58
|
({ storeId, payload }) =>
|
|
51
59
|
Effect.gen(function* () {
|
|
52
60
|
const isConnected = yield* SubscriptionRef.make(true)
|
|
@@ -66,7 +74,7 @@ export const makeDoRpcSync =
|
|
|
66
74
|
const backendIdHelper = yield* SyncBackend.makeBackendIdHelper
|
|
67
75
|
|
|
68
76
|
const pull: SyncBackend.SyncBackend<SyncMetadata>['pull'] = (cursor, options) =>
|
|
69
|
-
rpcClient
|
|
77
|
+
rpcClient['SyncDoRpc.Pull']({
|
|
70
78
|
cursor: cursor.pipe(
|
|
71
79
|
Option.map((a) => ({
|
|
72
80
|
eventSequenceNumber: a.eventSequenceNumber,
|
|
@@ -82,18 +90,22 @@ export const makeDoRpcSync =
|
|
|
82
90
|
if (res._tag === 'None')
|
|
83
91
|
return shouldNeverHappen('There should at least be a no-more page info response')
|
|
84
92
|
|
|
85
|
-
const
|
|
86
|
-
|
|
93
|
+
const requestId = res.value.rpcRequestId
|
|
94
|
+
const routing = pullRoutingFor(durableObjectState)
|
|
95
|
+
|
|
96
|
+
const queue = yield* Effect.acquireRelease(Queue.unbounded<SyncMessage.PullResponse>(), (queue) =>
|
|
97
|
+
// Drop the routing entry on release so it can't outlive its (now shut-down) queue
|
|
98
|
+
Effect.sync(() => routing.delete(requestId)).pipe(Effect.andThen(Queue.shutdown(queue))),
|
|
87
99
|
)
|
|
88
100
|
|
|
89
|
-
|
|
101
|
+
routing.set(requestId, queue)
|
|
90
102
|
|
|
91
|
-
return
|
|
92
|
-
}).pipe(Stream.
|
|
103
|
+
return Stream.fromQueue(queue)
|
|
104
|
+
}).pipe(Stream.unwrap),
|
|
93
105
|
)
|
|
94
106
|
: identity,
|
|
95
107
|
Stream.tap((res) => backendIdHelper.lazySet(res.backendId)),
|
|
96
|
-
Stream.map((res) => omit(res, ['backendId'])),
|
|
108
|
+
Stream.map((res) => Struct.omit(res, ['backendId'])),
|
|
97
109
|
Stream.mapError((cause) =>
|
|
98
110
|
cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError'
|
|
99
111
|
? cause
|
|
@@ -109,22 +121,22 @@ export const makeDoRpcSync =
|
|
|
109
121
|
}
|
|
110
122
|
|
|
111
123
|
const backendId = backendIdHelper.get()
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
124
|
+
if (EffectArray.isReadonlyArrayNonEmpty(batch) === false) {
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const batchChunks = yield* splitArrayBySize({
|
|
129
|
+
maxItems: MAX_PUSH_EVENTS_PER_REQUEST,
|
|
130
|
+
maxBytes: MAX_DO_RPC_REQUEST_BYTES,
|
|
131
|
+
encode: (items: ReadonlyArray<PushBatchItem>) => ({
|
|
132
|
+
batch: items,
|
|
133
|
+
storeId,
|
|
134
|
+
backendId,
|
|
121
135
|
}),
|
|
122
|
-
|
|
123
|
-
)
|
|
136
|
+
})(batch).pipe(Effect.mapError((cause) => new UnknownError({ cause })))
|
|
124
137
|
|
|
125
|
-
for (const
|
|
126
|
-
|
|
127
|
-
yield* rpcClient.SyncDoRpc.Push({ batch: chunkArray, storeId, backendId })
|
|
138
|
+
for (const batchChunk of batchChunks) {
|
|
139
|
+
yield* rpcClient['SyncDoRpc.Push']({ batch: batchChunk, storeId, backendId })
|
|
128
140
|
}
|
|
129
141
|
},
|
|
130
142
|
Effect.mapError((cause) =>
|
|
@@ -134,7 +146,7 @@ export const makeDoRpcSync =
|
|
|
134
146
|
),
|
|
135
147
|
)
|
|
136
148
|
|
|
137
|
-
const ping: SyncBackend.SyncBackend<{ createdAt: string }>['ping'] = rpcClient
|
|
149
|
+
const ping: SyncBackend.SyncBackend<{ createdAt: string }>['ping'] = rpcClient['SyncDoRpc.Ping']({
|
|
138
150
|
storeId,
|
|
139
151
|
payload,
|
|
140
152
|
}).pipe(UnknownError.mapToUnknownError, Effect.withSpan('rpc-sync-client:ping'))
|
|
@@ -159,6 +171,9 @@ export const makeDoRpcSync =
|
|
|
159
171
|
}).pipe(Effect.withSpan('rpc-sync-client:makeDoRpcSync'))
|
|
160
172
|
|
|
161
173
|
/**
|
|
174
|
+
* Routes an update from the sync backend into this client's live pull.
|
|
175
|
+
*
|
|
176
|
+
* Only `ctx` and `payload` go here; `storeId` is for reloading your store on a rebuilt DO (see example).
|
|
162
177
|
*
|
|
163
178
|
* ```ts
|
|
164
179
|
* import { DurableObject } from 'cloudflare:workers'
|
|
@@ -167,25 +182,31 @@ export const makeDoRpcSync =
|
|
|
167
182
|
* export class MyDurableObject extends DurableObject implements ClientDoWithRpcCallback {
|
|
168
183
|
* // ...
|
|
169
184
|
*
|
|
170
|
-
* async syncUpdateRpc(payload:
|
|
171
|
-
*
|
|
185
|
+
* async syncUpdateRpc(payload: Uint8Array<ArrayBuffer>, storeId: string) {
|
|
186
|
+
* await this.getStore(storeId)
|
|
187
|
+
* return handleSyncUpdateRpc(this.ctx, payload)
|
|
172
188
|
* }
|
|
173
189
|
* }
|
|
174
190
|
* ```
|
|
175
191
|
*/
|
|
176
|
-
export const handleSyncUpdateRpc = (payload:
|
|
192
|
+
export const handleSyncUpdateRpc = (ctx: CfTypes.DurableObjectState, payload: Uint8Array<ArrayBuffer>) =>
|
|
177
193
|
Effect.gen(function* () {
|
|
178
|
-
const
|
|
179
|
-
const
|
|
194
|
+
const parser = RpcSerialization.msgPack.makeUnsafe()
|
|
195
|
+
const decodedMessage = parser.decode(payload)
|
|
196
|
+
const [response] = Array.isArray(decodedMessage) === true ? decodedMessage.flat(1) : [decodedMessage]
|
|
197
|
+
const decodedPayload = yield* Schema.decodeUnknownEffect(ResponseChunkEncoded)(response)
|
|
198
|
+
const decoded = yield* Schema.decodeUnknownEffect(Schema.toCodecJson(SyncMessage.PullResponse))(
|
|
199
|
+
decodedPayload.values[0],
|
|
200
|
+
)
|
|
180
201
|
|
|
181
|
-
const
|
|
202
|
+
const pullStreamQueue = pullRoutingFor(ctx).get(decodedPayload.requestId)
|
|
182
203
|
|
|
183
|
-
if (
|
|
204
|
+
if (pullStreamQueue === undefined) {
|
|
184
205
|
// Case: DO was hibernated, so we need to manually update the store
|
|
185
|
-
yield* Effect.log(`No
|
|
206
|
+
yield* Effect.log(`No pull stream queue found for ${decodedPayload.requestId}`)
|
|
186
207
|
} else {
|
|
187
208
|
// Case: DO was still alive, so the existing `pull` will pick up the new events
|
|
188
|
-
yield*
|
|
209
|
+
yield* Queue.offer(pullStreamQueue, decoded)
|
|
189
210
|
}
|
|
190
211
|
}).pipe(Effect.withSpan('rpc-sync-client:rpcCallback'), Effect.tapCauseLogPretty, Effect.runPromise)
|
|
191
212
|
|
|
@@ -193,3 +214,24 @@ const ResponseChunkEncoded = Schema.Struct({
|
|
|
193
214
|
requestId: Schema.String,
|
|
194
215
|
values: Schema.Array(Schema.Any),
|
|
195
216
|
})
|
|
217
|
+
|
|
218
|
+
type EffectRpcRequestId = string // 0, 1, 2, ...
|
|
219
|
+
type PullRouting = Map<EffectRpcRequestId, Queue.Queue<SyncMessage.PullResponse>>
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Per-instance map from a live pull's request id to its response queue, keyed off the client DO's
|
|
223
|
+
* `DurableObjectState` (not a module global) for two independent reasons:
|
|
224
|
+
* - Per-instance: a reconstructed DO starts empty, so a reverse-RPC whose store is gone hits the
|
|
225
|
+
* `handleSyncUpdateRpc` drop branch (the recovery hook) instead of a stale queue that outlived it.
|
|
226
|
+
* - Keyed by request id: a late chunk from a superseded pull generation finds no entry and is
|
|
227
|
+
* dropped here — never reaching `SyncState.merge`, which dies on an event at/below the upstream head.
|
|
228
|
+
*/
|
|
229
|
+
const pullRoutingByInstance = new WeakMap<CfTypes.DurableObjectState, PullRouting>()
|
|
230
|
+
|
|
231
|
+
const pullRoutingFor = (ctx: CfTypes.DurableObjectState): PullRouting => {
|
|
232
|
+
const existing = pullRoutingByInstance.get(ctx)
|
|
233
|
+
if (existing !== undefined) return existing
|
|
234
|
+
const routing: PullRouting = new Map()
|
|
235
|
+
pullRoutingByInstance.set(ctx, routing)
|
|
236
|
+
return routing
|
|
237
|
+
}
|