@livestore/sync-cf 0.4.0-dev.1 → 0.4.0-dev.10
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 +150 -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} +77 -70
- package/dist/cf-worker/do/sqlite.d.ts.map +1 -0
- package/dist/cf-worker/do/sqlite.js +27 -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 +190 -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 +7 -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 +24 -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 +147 -0
- package/dist/cf-worker/shared.d.ts.map +1 -0
- package/dist/cf-worker/shared.js +32 -0
- package/dist/cf-worker/shared.js.map +1 -0
- package/dist/cf-worker/worker.d.ts +45 -45
- package/dist/cf-worker/worker.d.ts.map +1 -1
- package/dist/cf-worker/worker.js +51 -39
- 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 +117 -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 +45 -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 +237 -0
- package/src/cf-worker/do/layer.ts +128 -0
- package/src/cf-worker/do/pull.ts +77 -0
- package/src/cf-worker/do/push.ts +205 -0
- package/src/cf-worker/do/sqlite.ts +28 -0
- package/src/cf-worker/do/sync-storage.ts +321 -0
- package/src/cf-worker/do/transport/do-rpc-server.ts +84 -0
- package/src/cf-worker/do/transport/http-rpc-server.ts +37 -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 +112 -0
- package/src/cf-worker/worker.ts +91 -105
- package/src/client/mod.ts +3 -0
- package/src/client/transport/do-rpc-client.ts +191 -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
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { InvalidPullError, InvalidPushError } from '@livestore/common'
|
|
2
|
+
import { Effect, identity, Layer, RpcServer, Stream } from '@livestore/utils/effect'
|
|
3
|
+
import { SyncWsRpc } from '../../../common/ws-rpc-schema.ts'
|
|
4
|
+
import { DoCtx, type DoCtxInput } from '../layer.ts'
|
|
5
|
+
import { makeEndingPullStream } from '../pull.ts'
|
|
6
|
+
import { makePush } from '../push.ts'
|
|
7
|
+
|
|
8
|
+
export const makeRpcServer = ({ doSelf, doOptions }: Omit<DoCtxInput, 'from'>) => {
|
|
9
|
+
// TODO implement admin requests
|
|
10
|
+
const handlersLayer = SyncWsRpc.toLayer({
|
|
11
|
+
'SyncWsRpc.Pull': (req) =>
|
|
12
|
+
makeEndingPullStream(req, req.payload).pipe(
|
|
13
|
+
// Needed to keep the stream alive on the client side for phase 2 (i.e. not send the `Exit` stream RPC message)
|
|
14
|
+
req.live ? Stream.concat(Stream.never) : identity,
|
|
15
|
+
Stream.provideLayer(DoCtx.Default({ doSelf, doOptions, from: { storeId: req.storeId } })),
|
|
16
|
+
Stream.mapError((cause) => (cause._tag === 'InvalidPullError' ? cause : InvalidPullError.make({ cause }))),
|
|
17
|
+
// Stream.tapErrorCause(Effect.log),
|
|
18
|
+
),
|
|
19
|
+
'SyncWsRpc.Push': (req) =>
|
|
20
|
+
Effect.gen(function* () {
|
|
21
|
+
const { doOptions, storeId, ctx, env } = yield* DoCtx
|
|
22
|
+
|
|
23
|
+
const push = makePush({ options: doOptions, storeId, payload: req.payload, ctx, env })
|
|
24
|
+
|
|
25
|
+
return yield* push(req)
|
|
26
|
+
}).pipe(
|
|
27
|
+
Effect.provide(DoCtx.Default({ doSelf, doOptions, from: { storeId: req.storeId } })),
|
|
28
|
+
Effect.mapError((cause) => (cause._tag === 'InvalidPushError' ? cause : InvalidPushError.make({ cause }))),
|
|
29
|
+
Effect.tapCauseLogPretty,
|
|
30
|
+
),
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
return RpcServer.layer(SyncWsRpc).pipe(Layer.provide(handlersLayer))
|
|
34
|
+
}
|
package/src/cf-worker/mod.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
export
|
|
2
|
-
export
|
|
1
|
+
export type { CfTypes } from '@livestore/common-cf'
|
|
2
|
+
export { CfDeclare } from '@livestore/common-cf/declare'
|
|
3
|
+
export * from './do/durable-object.ts'
|
|
4
|
+
export * from './shared.ts'
|
|
3
5
|
export * from './worker.ts'
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { InvalidPullError, InvalidPushError } from '@livestore/common'
|
|
2
|
+
import type { CfTypes } from '@livestore/common-cf'
|
|
3
|
+
import { Effect, Schema, UrlParams } from '@livestore/utils/effect'
|
|
4
|
+
|
|
5
|
+
import type { SearchParams } from '../common/mod.ts'
|
|
6
|
+
import { SearchParamsSchema, SyncMessage } from '../common/mod.ts'
|
|
7
|
+
|
|
8
|
+
export interface Env {
|
|
9
|
+
ADMIN_SECRET: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type MakeDurableObjectClassOptions = {
|
|
13
|
+
onPush?: (
|
|
14
|
+
message: SyncMessage.PushRequest,
|
|
15
|
+
context: { storeId: StoreId; payload?: Schema.JsonValue },
|
|
16
|
+
) => Effect.SyncOrPromiseOrEffect<void>
|
|
17
|
+
onPushRes?: (message: SyncMessage.PushAck | InvalidPushError) => Effect.SyncOrPromiseOrEffect<void>
|
|
18
|
+
onPull?: (
|
|
19
|
+
message: SyncMessage.PullRequest,
|
|
20
|
+
context: { storeId: StoreId; payload?: Schema.JsonValue },
|
|
21
|
+
) => Effect.SyncOrPromiseOrEffect<void>
|
|
22
|
+
onPullRes?: (message: SyncMessage.PullResponse | InvalidPullError) => Effect.SyncOrPromiseOrEffect<void>
|
|
23
|
+
/**
|
|
24
|
+
* Storage engine for event persistence.
|
|
25
|
+
* - Default: `{ _tag: 'do-sqlite' }` (Durable Object SQLite)
|
|
26
|
+
* - D1: `{ _tag: 'd1', binding: string }` where `binding` is the D1 binding name in wrangler.toml.
|
|
27
|
+
*
|
|
28
|
+
* If omitted, the runtime defaults to DO SQLite. For backwards-compatibility, if an env binding named
|
|
29
|
+
* `DB` exists and looks like a D1Database, D1 will be used.
|
|
30
|
+
*
|
|
31
|
+
* Trade-offs:
|
|
32
|
+
* - DO SQLite: simpler deploy, data co-located with DO, not externally queryable
|
|
33
|
+
* - D1: centralized DB, inspectable with DB tools, extra network hop and JSON size limits
|
|
34
|
+
*/
|
|
35
|
+
storage?: { _tag: 'do-sqlite' } | { _tag: 'd1'; binding: string }
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Enabled transports for sync backend
|
|
39
|
+
* - `http`: HTTP JSON-RPC
|
|
40
|
+
* - `ws`: WebSocket
|
|
41
|
+
* - `do-rpc`: Durable Object RPC calls (only works in combination with `@livestore/adapter-cf`)
|
|
42
|
+
*
|
|
43
|
+
* @default Set(['http', 'ws', 'do-rpc'])
|
|
44
|
+
*/
|
|
45
|
+
enabledTransports?: Set<'http' | 'ws' | 'do-rpc'>
|
|
46
|
+
|
|
47
|
+
otel?: {
|
|
48
|
+
baseUrl?: string
|
|
49
|
+
serviceName?: string
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type StoreId = string
|
|
54
|
+
export type DurableObjectId = string
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Needs to be bumped when the storage format changes (e.g. eventlogTable schema changes)
|
|
58
|
+
*
|
|
59
|
+
* Changing this version number will lead to a "soft reset".
|
|
60
|
+
*/
|
|
61
|
+
export const PERSISTENCE_FORMAT_VERSION = 7
|
|
62
|
+
|
|
63
|
+
export const encodeOutgoingMessage = Schema.encodeSync(Schema.parseJson(SyncMessage.BackendToClientMessage))
|
|
64
|
+
export const encodeIncomingMessage = Schema.encodeSync(Schema.parseJson(SyncMessage.ClientToBackendMessage))
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Extracts the LiveStore sync search parameters from a request. Returns
|
|
68
|
+
* `undefined` when the request does not carry valid sync metadata so callers
|
|
69
|
+
* can fall back to custom routing.
|
|
70
|
+
*/
|
|
71
|
+
export const matchSyncRequest = (request: CfTypes.Request): SearchParams | undefined => {
|
|
72
|
+
const url = new URL(request.url)
|
|
73
|
+
const urlParams = UrlParams.fromInput(url.searchParams)
|
|
74
|
+
const paramsResult = UrlParams.schemaStruct(SearchParamsSchema)(urlParams).pipe(Effect.option, Effect.runSync)
|
|
75
|
+
|
|
76
|
+
if (paramsResult._tag === 'None') {
|
|
77
|
+
return undefined
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return paramsResult.value
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// RPC subscription storage (TODO refactor)
|
|
84
|
+
export type RpcSubscription = {
|
|
85
|
+
storeId: StoreId
|
|
86
|
+
payload?: Schema.JsonValue
|
|
87
|
+
subscribedAt: number
|
|
88
|
+
/** Effect RPC request ID */
|
|
89
|
+
requestId: string
|
|
90
|
+
callerContext: {
|
|
91
|
+
bindingName: string
|
|
92
|
+
durableObjectId: string
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Durable Object interface supporting the DO RPC protocol for DO <> DO syncing.
|
|
98
|
+
*/
|
|
99
|
+
export interface SyncBackendRpcInterface {
|
|
100
|
+
__DURABLE_OBJECT_BRAND: never
|
|
101
|
+
rpc(payload: Uint8Array): Promise<Uint8Array | CfTypes.ReadableStream>
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const WebSocketAttachmentSchema = Schema.parseJson(
|
|
105
|
+
Schema.Struct({
|
|
106
|
+
// Same across all websocket connections
|
|
107
|
+
storeId: Schema.String,
|
|
108
|
+
// Different for each websocket connection
|
|
109
|
+
payload: Schema.optional(Schema.JsonValue),
|
|
110
|
+
pullRequestIds: Schema.Array(Schema.String),
|
|
111
|
+
}),
|
|
112
|
+
)
|
package/src/cf-worker/worker.ts
CHANGED
|
@@ -1,54 +1,32 @@
|
|
|
1
|
-
import type * as CfWorker from '@cloudflare/workers-types'
|
|
2
1
|
import { UnexpectedError } from '@livestore/common'
|
|
2
|
+
import type { HelperTypes } from '@livestore/common-cf'
|
|
3
3
|
import type { Schema } from '@livestore/utils/effect'
|
|
4
|
-
import { Effect
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
import type { Env } from './durable-object.ts'
|
|
4
|
+
import { Effect } 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
|
|
|
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
|
+
*/
|
|
51
25
|
export type MakeWorkerOptions<TEnv extends Env = Env> = {
|
|
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.
|
|
@@ -57,36 +35,26 @@ export type MakeWorkerOptions<TEnv extends Env = Env> = {
|
|
|
57
35
|
validatePayload?: (payload: Schema.JsonValue | undefined, context: { storeId: string }) => void | Promise<void>
|
|
58
36
|
/** @default false */
|
|
59
37
|
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
38
|
}
|
|
69
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Produces a Cloudflare Worker `fetch` handler that delegates sync traffic to the
|
|
42
|
+
* Durable Object identified by `syncBackendBinding`.
|
|
43
|
+
*
|
|
44
|
+
* For more complex setups prefer implementing a custom `fetch` and call {@link handleSyncRequest}
|
|
45
|
+
* from the branch that handles LiveStore sync requests.
|
|
46
|
+
*/
|
|
70
47
|
export const makeWorker = <
|
|
71
48
|
TEnv extends Env = Env,
|
|
72
|
-
TDurableObjectRpc extends
|
|
49
|
+
TDurableObjectRpc extends CfTypes.Rpc.DurableObjectBranded | undefined = undefined,
|
|
73
50
|
>(
|
|
74
|
-
options: MakeWorkerOptions<TEnv
|
|
51
|
+
options: MakeWorkerOptions<TEnv>,
|
|
75
52
|
): CFWorker<TEnv, TDurableObjectRpc> => {
|
|
76
53
|
return {
|
|
77
54
|
fetch: async (request, env, _ctx) => {
|
|
78
55
|
const url = new URL(request.url)
|
|
79
56
|
|
|
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
|
|
57
|
+
const corsHeaders: CfTypes.HeadersInit = options.enableCORS
|
|
90
58
|
? {
|
|
91
59
|
'Access-Control-Allow-Origin': '*',
|
|
92
60
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
@@ -101,11 +69,26 @@ export const makeWorker = <
|
|
|
101
69
|
})
|
|
102
70
|
}
|
|
103
71
|
|
|
104
|
-
|
|
105
|
-
|
|
72
|
+
const searchParams = matchSyncRequest(request)
|
|
73
|
+
|
|
74
|
+
// Check if this is a sync request first, before showing info message
|
|
75
|
+
if (searchParams !== undefined) {
|
|
76
|
+
return handleSyncRequest<TEnv, TDurableObjectRpc>({
|
|
77
|
+
request,
|
|
78
|
+
searchParams,
|
|
79
|
+
env,
|
|
80
|
+
ctx: _ctx,
|
|
81
|
+
syncBackendBinding: options.syncBackendBinding,
|
|
106
82
|
headers: corsHeaders,
|
|
107
83
|
validatePayload: options.validatePayload,
|
|
108
|
-
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Only show info message for GET requests to / without sync parameters
|
|
88
|
+
if (request.method === 'GET' && url.pathname === '/') {
|
|
89
|
+
return new Response('Info: Sync backend endpoint for @livestore/sync-cf.', {
|
|
90
|
+
status: 200,
|
|
91
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
109
92
|
})
|
|
110
93
|
}
|
|
111
94
|
|
|
@@ -124,7 +107,7 @@ export const makeWorker = <
|
|
|
124
107
|
}
|
|
125
108
|
|
|
126
109
|
/**
|
|
127
|
-
* Handles
|
|
110
|
+
* Handles LiveStore sync requests (e.g. with search params `?storeId=...&transport=...`).
|
|
128
111
|
*
|
|
129
112
|
* @example
|
|
130
113
|
* ```ts
|
|
@@ -137,85 +120,88 @@ export const makeWorker = <
|
|
|
137
120
|
*
|
|
138
121
|
* export default {
|
|
139
122
|
* fetch: async (request, env, ctx) => {
|
|
140
|
-
*
|
|
141
|
-
*
|
|
123
|
+
* const searchParams = matchSyncRequest(request)
|
|
124
|
+
*
|
|
125
|
+
* // Is LiveStore sync request
|
|
126
|
+
* if (searchParams !== undefined) {
|
|
127
|
+
* return handleSyncRequest({
|
|
128
|
+
* request,
|
|
129
|
+
* searchParams,
|
|
130
|
+
* env,
|
|
131
|
+
* ctx,
|
|
132
|
+
* syncBackendBinding: 'SYNC_BACKEND_DO',
|
|
133
|
+
* headers: {},
|
|
134
|
+
* validatePayload,
|
|
135
|
+
* })
|
|
142
136
|
* }
|
|
143
137
|
*
|
|
144
138
|
* return new Response('Invalid path', { status: 400 })
|
|
145
|
-
* return new Response('Invalid path', { status: 400 })
|
|
146
139
|
* }
|
|
147
140
|
* }
|
|
148
141
|
* ```
|
|
149
142
|
*
|
|
150
143
|
* @throws {UnexpectedError} If the payload is invalid
|
|
151
144
|
*/
|
|
152
|
-
export const
|
|
145
|
+
export const handleSyncRequest = <
|
|
153
146
|
TEnv extends Env = Env,
|
|
154
|
-
TDurableObjectRpc extends
|
|
147
|
+
TDurableObjectRpc extends CfTypes.Rpc.DurableObjectBranded | undefined = undefined,
|
|
155
148
|
CFHostMetada = unknown,
|
|
156
|
-
>(
|
|
157
|
-
request
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
149
|
+
>({
|
|
150
|
+
request,
|
|
151
|
+
searchParams: { storeId, payload, transport },
|
|
152
|
+
env,
|
|
153
|
+
syncBackendBinding,
|
|
154
|
+
headers,
|
|
155
|
+
validatePayload,
|
|
156
|
+
}: {
|
|
157
|
+
request: CfTypes.Request<CFHostMetada>
|
|
158
|
+
searchParams: SearchParams
|
|
159
|
+
env: TEnv
|
|
160
|
+
/** Only there for type-level reasons */
|
|
161
|
+
ctx: CfTypes.ExecutionContext
|
|
162
|
+
/** Binding name of the sync backend Durable Object */
|
|
163
|
+
syncBackendBinding: MakeWorkerOptions<TEnv>['syncBackendBinding']
|
|
164
|
+
headers?: CfTypes.HeadersInit | undefined
|
|
165
|
+
validatePayload?: (payload: Schema.JsonValue | undefined, context: { storeId: string }) => void | Promise<void>
|
|
166
|
+
}): Promise<CfTypes.Response> =>
|
|
166
167
|
Effect.gen(function* () {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const urlParams = UrlParams.fromInput(url.searchParams)
|
|
170
|
-
const paramsResult = yield* UrlParams.schemaStruct(SearchParamsSchema)(urlParams).pipe(Effect.either)
|
|
171
|
-
|
|
172
|
-
if (paramsResult._tag === 'Left') {
|
|
173
|
-
return new Response(`Invalid search params: ${paramsResult.left.toString()}`, {
|
|
174
|
-
status: 500,
|
|
175
|
-
headers: options?.headers,
|
|
176
|
-
})
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const { storeId, payload } = paramsResult.right
|
|
180
|
-
|
|
181
|
-
if (options.validatePayload !== undefined) {
|
|
182
|
-
const result = yield* Effect.promise(async () => options.validatePayload!(payload, { storeId })).pipe(
|
|
168
|
+
if (validatePayload !== undefined) {
|
|
169
|
+
const result = yield* Effect.promise(async () => validatePayload!(payload, { storeId })).pipe(
|
|
183
170
|
UnexpectedError.mapToUnexpectedError,
|
|
184
171
|
Effect.either,
|
|
185
172
|
)
|
|
186
173
|
|
|
187
174
|
if (result._tag === 'Left') {
|
|
188
175
|
console.error('Invalid payload', result.left)
|
|
189
|
-
return new Response(result.left.toString(), { status: 400, headers
|
|
176
|
+
return new Response(result.left.toString(), { status: 400, headers })
|
|
190
177
|
}
|
|
191
178
|
}
|
|
192
179
|
|
|
193
|
-
|
|
194
|
-
if (!(durableObjectName in env)) {
|
|
180
|
+
if (!(syncBackendBinding in env)) {
|
|
195
181
|
return new Response(
|
|
196
|
-
`Failed dependency: Required Durable Object binding '${
|
|
182
|
+
`Failed dependency: Required Durable Object binding '${syncBackendBinding as string}' not available`,
|
|
197
183
|
{
|
|
198
184
|
status: 424,
|
|
199
|
-
headers
|
|
185
|
+
headers,
|
|
200
186
|
},
|
|
201
187
|
)
|
|
202
188
|
}
|
|
203
189
|
|
|
204
190
|
const durableObjectNamespace = env[
|
|
205
|
-
|
|
206
|
-
] as
|
|
191
|
+
syncBackendBinding as keyof TEnv
|
|
192
|
+
] as CfTypes.DurableObjectNamespace<TDurableObjectRpc>
|
|
207
193
|
|
|
208
194
|
const id = durableObjectNamespace.idFromName(storeId)
|
|
209
195
|
const durableObject = durableObjectNamespace.get(id)
|
|
210
196
|
|
|
197
|
+
// Handle WebSocket upgrade request
|
|
211
198
|
const upgradeHeader = request.headers.get('Upgrade')
|
|
212
|
-
if (
|
|
199
|
+
if (transport === 'ws' && (upgradeHeader === null || upgradeHeader !== 'websocket')) {
|
|
213
200
|
return new Response('Durable Object expected Upgrade: websocket', {
|
|
214
201
|
status: 426,
|
|
215
|
-
headers
|
|
202
|
+
headers,
|
|
216
203
|
})
|
|
217
204
|
}
|
|
218
205
|
|
|
219
|
-
// Cloudflare Durable Object type clashing with lib.dom Response type, which is why we need the casts here.
|
|
220
206
|
return yield* Effect.promise(() => durableObject.fetch(request))
|
|
221
207
|
}).pipe(Effect.tapCauseLogPretty, Effect.runPromise)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { InvalidPullError, InvalidPushError, SyncBackend, UnexpectedError } 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 UnexpectedError({ 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'
|
|
127
|
+
? cause
|
|
128
|
+
: InvalidPushError.make({ cause: new UnexpectedError({ cause }) }),
|
|
129
|
+
),
|
|
130
|
+
Effect.withSpan('rpc-sync-client:push'),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
const ping: SyncBackend.SyncBackend<{ createdAt: string }>['ping'] = rpcClient.SyncDoRpc.Ping({
|
|
134
|
+
storeId,
|
|
135
|
+
payload,
|
|
136
|
+
}).pipe(UnexpectedError.mapToUnexpectedError, Effect.withSpan('rpc-sync-client:ping'))
|
|
137
|
+
|
|
138
|
+
return SyncBackend.of({
|
|
139
|
+
connect,
|
|
140
|
+
isConnected,
|
|
141
|
+
pull,
|
|
142
|
+
push,
|
|
143
|
+
ping,
|
|
144
|
+
metadata: {
|
|
145
|
+
name: 'rpc-sync-client',
|
|
146
|
+
description: 'Cloudflare Durable Object RPC Sync Client',
|
|
147
|
+
protocol: 'rpc',
|
|
148
|
+
storeId,
|
|
149
|
+
},
|
|
150
|
+
supports: {
|
|
151
|
+
pullPageInfoKnown: true,
|
|
152
|
+
pullLive: true,
|
|
153
|
+
},
|
|
154
|
+
})
|
|
155
|
+
}).pipe(Effect.withSpan('rpc-sync-client:makeDoRpcSync'))
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
*
|
|
159
|
+
* ```ts
|
|
160
|
+
* import { DurableObject } from 'cloudflare:workers'
|
|
161
|
+
* import { ClientDoWithRpcCallback } from '@livestore/common-cf'
|
|
162
|
+
*
|
|
163
|
+
* export class MyDurableObject extends DurableObject implements ClientDoWithRpcCallback {
|
|
164
|
+
* // ...
|
|
165
|
+
*
|
|
166
|
+
* async syncUpdateRpc(payload: RpcMessage.ResponseChunkEncoded) {
|
|
167
|
+
* return handleSyncUpdateRpc(payload)
|
|
168
|
+
* }
|
|
169
|
+
* }
|
|
170
|
+
* ```
|
|
171
|
+
*/
|
|
172
|
+
export const handleSyncUpdateRpc = (payload: unknown) =>
|
|
173
|
+
Effect.gen(function* () {
|
|
174
|
+
const decodedPayload = yield* Schema.decodeUnknown(ResponseChunkEncoded)(payload)
|
|
175
|
+
const decoded = yield* Schema.decodeUnknown(SyncMessage.PullResponse)(decodedPayload.values[0]!)
|
|
176
|
+
|
|
177
|
+
const pullStreamMailbox = requestIdMailboxMap.get(decodedPayload.requestId)
|
|
178
|
+
|
|
179
|
+
if (pullStreamMailbox === undefined) {
|
|
180
|
+
// Case: DO was hibernated, so we need to manually update the store
|
|
181
|
+
yield* Effect.log(`No mailbox found for ${decodedPayload.requestId}`)
|
|
182
|
+
} else {
|
|
183
|
+
// Case: DO was still alive, so the existing `pull` will pick up the new events
|
|
184
|
+
yield* pullStreamMailbox.offer(decoded)
|
|
185
|
+
}
|
|
186
|
+
}).pipe(Effect.withSpan('rpc-sync-client:rpcCallback'), Effect.tapCauseLogPretty, Effect.runPromise)
|
|
187
|
+
|
|
188
|
+
const ResponseChunkEncoded = Schema.Struct({
|
|
189
|
+
requestId: Schema.String,
|
|
190
|
+
values: Schema.Array(Schema.Any),
|
|
191
|
+
})
|