@livestore/sync-cf 0.4.0 → 0.5.0-dev.1
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 +5 -5
- 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 +53 -36
- 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 +45 -35
- package/dist/cf-worker/do/layer.js.map +1 -1
- package/dist/cf-worker/do/observability.d.ts +23 -0
- package/dist/cf-worker/do/observability.d.ts.map +1 -0
- package/dist/cf-worker/do/observability.js +52 -0
- package/dist/cf-worker/do/observability.js.map +1 -0
- package/dist/cf-worker/do/observability.test.d.ts +2 -0
- package/dist/cf-worker/do/observability.test.d.ts.map +1 -0
- package/dist/cf-worker/do/observability.test.js +204 -0
- package/dist/cf-worker/do/observability.test.js.map +1 -0
- 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 +19 -20
- 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 +74 -53
- 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 +2 -2
- 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 +29 -17
- package/dist/cf-worker/do/sync-storage.js.map +1 -1
- package/dist/cf-worker/do/transport/do-rpc-server.d.ts +17 -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 +41 -23
- package/dist/cf-worker/do/transport/do-rpc-server.js.map +1 -1
- package/dist/cf-worker/do/transport/do-rpc-server.test.d.ts +2 -0
- package/dist/cf-worker/do/transport/do-rpc-server.test.d.ts.map +1 -0
- package/dist/cf-worker/do/transport/do-rpc-server.test.js +28 -0
- package/dist/cf-worker/do/transport/do-rpc-server.test.js.map +1 -0
- 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 +12 -12
- package/dist/cf-worker/do/transport/http-rpc-server.js.map +1 -1
- package/dist/cf-worker/do/transport/ws-rpc-server.d.ts +9 -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 +37 -29
- package/dist/cf-worker/do/transport/ws-rpc-server.js.map +1 -1
- package/dist/cf-worker/mod.js +3 -3
- package/dist/cf-worker/shared.d.ts +37 -29
- package/dist/cf-worker/shared.d.ts.map +1 -1
- package/dist/cf-worker/shared.js +14 -12
- package/dist/cf-worker/shared.js.map +1 -1
- package/dist/cf-worker/worker.d.ts +9 -9
- package/dist/cf-worker/worker.d.ts.map +1 -1
- package/dist/cf-worker/worker.js +17 -15
- package/dist/cf-worker/worker.js.map +1 -1
- package/dist/client/mod.js +3 -3
- package/dist/client/transport/do-rpc-client.d.ts +18 -6
- package/dist/client/transport/do-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/do-rpc-client.js +112 -38
- package/dist/client/transport/do-rpc-client.js.map +1 -1
- package/dist/client/transport/do-rpc-client.test.d.ts +2 -0
- package/dist/client/transport/do-rpc-client.test.d.ts.map +1 -0
- package/dist/client/transport/do-rpc-client.test.js +268 -0
- package/dist/client/transport/do-rpc-client.test.js.map +1 -0
- 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 +48 -29
- package/dist/client/transport/http-rpc-client.js.map +1 -1
- package/dist/client/transport/rpc-client-error.d.ts +5 -0
- package/dist/client/transport/rpc-client-error.d.ts.map +1 -0
- package/dist/client/transport/rpc-client-error.js +8 -0
- package/dist/client/transport/rpc-client-error.js.map +1 -0
- package/dist/client/transport/rpc-client-error.test.d.ts +2 -0
- package/dist/client/transport/rpc-client-error.test.d.ts.map +1 -0
- package/dist/client/transport/rpc-client-error.test.js +33 -0
- package/dist/client/transport/rpc-client-error.test.js.map +1 -0
- 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 +33 -23
- package/dist/client/transport/ws-rpc-client.js.map +1 -1
- package/dist/common/do-rpc-schema.d.ts +53 -41
- package/dist/common/do-rpc-schema.d.ts.map +1 -1
- package/dist/common/do-rpc-schema.js +13 -4
- package/dist/common/do-rpc-schema.js.map +1 -1
- package/dist/common/http-rpc-schema.d.ts +38 -39
- package/dist/common/http-rpc-schema.d.ts.map +1 -1
- package/dist/common/http-rpc-schema.js +6 -6
- package/dist/common/http-rpc-schema.js.map +1 -1
- package/dist/common/mod.d.ts +5 -5
- package/dist/common/mod.d.ts.map +1 -1
- package/dist/common/mod.js +7 -7
- 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 +5 -5
- package/dist/common/ws-rpc-schema.js.map +1 -1
- package/package.json +42 -58
- package/src/cf-worker/do/durable-object.ts +70 -64
- package/src/cf-worker/do/layer.ts +142 -99
- package/src/cf-worker/do/observability.test.ts +337 -0
- package/src/cf-worker/do/observability.ts +121 -0
- 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 +139 -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.test.ts +32 -0
- package/src/cf-worker/do/transport/do-rpc-server.ts +46 -35
- package/src/cf-worker/do/transport/http-rpc-server.ts +7 -10
- package/src/cf-worker/do/transport/ws-rpc-server.ts +52 -28
- package/src/cf-worker/shared.ts +29 -17
- package/src/cf-worker/worker.ts +21 -22
- package/src/client/transport/do-rpc-client.test.ts +411 -0
- package/src/client/transport/do-rpc-client.ts +175 -51
- package/src/client/transport/http-rpc-client.ts +66 -45
- package/src/client/transport/rpc-client-error.test.ts +41 -0
- package/src/client/transport/rpc-client-error.ts +9 -0
- package/src/client/transport/ws-rpc-client.ts +40 -32
- package/src/common/do-rpc-schema.ts +13 -3
- package/src/common/http-rpc-schema.ts +5 -5
- package/src/common/mod.ts +4 -4
- package/src/common/sync-message-types.ts +16 -16
- package/src/common/ws-rpc-schema.ts +4 -4
|
@@ -1,53 +1,62 @@
|
|
|
1
1
|
import { UnknownError } from '@livestore/common'
|
|
2
2
|
import { type CfTypes, toDurableObjectHandler } from '@livestore/common-cf'
|
|
3
|
-
import {
|
|
4
|
-
Effect,
|
|
5
|
-
Headers,
|
|
6
|
-
HttpServer,
|
|
7
|
-
Layer,
|
|
8
|
-
Logger,
|
|
9
|
-
LogLevel,
|
|
10
|
-
Option,
|
|
11
|
-
RpcSerialization,
|
|
12
|
-
Stream,
|
|
13
|
-
} from '@livestore/utils/effect'
|
|
3
|
+
import { Effect, Headers, Option, Stream } from '@livestore/utils/effect'
|
|
14
4
|
|
|
15
5
|
import { SyncDoRpc } from '../../../common/do-rpc-schema.ts'
|
|
16
|
-
import {
|
|
17
|
-
import
|
|
6
|
+
import { rpcSubscriptionKeyPrefix, type RpcSubscription } from '../../shared.ts'
|
|
7
|
+
import * as DoCtx from '../layer.ts'
|
|
8
|
+
import { makeRpcSpanOptions, type makeObservability, withRpcStreamSpan } from '../observability.ts'
|
|
18
9
|
import { makeEndingPullStream } from '../pull.ts'
|
|
19
10
|
import { makePush } from '../push.ts'
|
|
20
11
|
|
|
21
12
|
export interface DoRpcHandlerOptions {
|
|
22
13
|
payload: Uint8Array<ArrayBuffer>
|
|
23
|
-
input: Omit<DoCtxInput, 'from'>
|
|
14
|
+
input: Omit<DoCtx.DoCtxInput, 'from'>
|
|
15
|
+
observability: ReturnType<typeof makeObservability>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Drops a live-pull subscription row only if it still belongs to the pull that asked. A replacement store booted on
|
|
20
|
+
* the same client DO while a slow shutdown is still draining overwrites the row with its own request id, and the
|
|
21
|
+
* earlier pull's late unsubscribe must not take that subscription down.
|
|
22
|
+
*/
|
|
23
|
+
export const dropRpcSubscription = (
|
|
24
|
+
kv: { get: (key: string) => RpcSubscription | undefined; delete: (key: string) => unknown },
|
|
25
|
+
{ durableObjectId, requestId }: { durableObjectId: string; requestId: string },
|
|
26
|
+
): void => {
|
|
27
|
+
const key = `${rpcSubscriptionKeyPrefix}${durableObjectId}`
|
|
28
|
+
if (kv.get(key)?.requestId === requestId) {
|
|
29
|
+
kv.delete(key)
|
|
30
|
+
}
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
export const createDoRpcHandler = (
|
|
27
34
|
options: DoRpcHandlerOptions,
|
|
28
35
|
): Effect.Effect<Uint8Array<ArrayBuffer> | CfTypes.ReadableStream> =>
|
|
29
|
-
Effect.gen(this, function* () {
|
|
30
|
-
const { payload, input } = options
|
|
31
|
-
|
|
36
|
+
Effect.gen({ self: this }, function* () {
|
|
37
|
+
const { payload, input, observability } = options
|
|
38
|
+
const parent = yield* Effect.currentParentSpan.pipe(Effect.option)
|
|
32
39
|
|
|
33
40
|
// TODO add admin RPCs
|
|
34
41
|
const RpcLive = SyncDoRpc.toLayer({
|
|
35
|
-
'SyncDoRpc.Ping': (
|
|
36
|
-
|
|
37
|
-
},
|
|
42
|
+
'SyncDoRpc.Ping': () => Effect.void,
|
|
43
|
+
'SyncDoRpc.Unsubscribe': (req) => Effect.sync(() => dropRpcSubscription(input.doSelf.ctx.storage.kv, req)),
|
|
38
44
|
'SyncDoRpc.Pull': (req, { headers }) =>
|
|
39
|
-
Effect.gen(this, function* () {
|
|
40
|
-
const {
|
|
45
|
+
Effect.gen({ self: this }, function* () {
|
|
46
|
+
const { ctx } = yield* DoCtx.DoCtx
|
|
41
47
|
|
|
42
48
|
// TODO rename `req.rpcContext` to something more appropriate
|
|
43
49
|
if (req.rpcContext !== undefined) {
|
|
44
|
-
|
|
50
|
+
const subscription: RpcSubscription = {
|
|
45
51
|
storeId: req.storeId,
|
|
46
52
|
subscribedAt: Date.now(),
|
|
47
53
|
requestId: Headers.get(headers, 'x-rpc-request-id').pipe(Option.getOrThrow),
|
|
48
54
|
callerContext: req.rpcContext.callerContext,
|
|
49
55
|
...(req.payload !== undefined ? { payload: req.payload } : {}),
|
|
50
|
-
}
|
|
56
|
+
}
|
|
57
|
+
// Keyed by client DO id (not storeId — one backend serves many client DOs). The DO's synchronous KV
|
|
58
|
+
// storage is the single source of truth, so the entry outlives backend reconstruction.
|
|
59
|
+
ctx.storage.kv.put(`${rpcSubscriptionKeyPrefix}${subscription.callerContext.durableObjectId}`, subscription)
|
|
51
60
|
}
|
|
52
61
|
|
|
53
62
|
// DO-RPC doesn't have HTTP headers context - headers are undefined
|
|
@@ -58,25 +67,30 @@ export const createDoRpcHandler = (
|
|
|
58
67
|
...res,
|
|
59
68
|
rpcRequestId: Headers.get(headers, 'x-rpc-request-id').pipe(Option.getOrThrow),
|
|
60
69
|
})),
|
|
61
|
-
Stream.
|
|
70
|
+
Stream.provide(DoCtx.layer({ ...input, from: { storeId: req.storeId } })),
|
|
62
71
|
Stream.mapError((cause) =>
|
|
63
72
|
cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError'
|
|
64
73
|
? cause
|
|
65
74
|
: new UnknownError({ cause }),
|
|
66
75
|
),
|
|
67
|
-
Stream.
|
|
76
|
+
Stream.tapCause(Effect.log),
|
|
77
|
+
// The bridge runs this stream in a separate runtime. Install telemetry there too.
|
|
78
|
+
(_) => withRpcStreamSpan(_, 'RpcServer.SyncDoRpc.Pull', makeRpcSpanOptions(parent)),
|
|
79
|
+
observability.stream,
|
|
68
80
|
),
|
|
69
81
|
'SyncDoRpc.Push': (req) =>
|
|
70
|
-
Effect.gen(this, function* () {
|
|
71
|
-
const { doOptions, ctx, env, storeId } = yield* DoCtx
|
|
82
|
+
Effect.gen({ self: this }, function* () {
|
|
83
|
+
const { doOptions, ctx, env, storeId } = yield* DoCtx.DoCtx
|
|
72
84
|
// DO-RPC doesn't have HTTP headers context - headers are undefined
|
|
73
85
|
const push = makePush({ storeId, payload: req.payload, headers: undefined, options: doOptions, ctx, env })
|
|
74
86
|
|
|
75
87
|
return yield* push(req)
|
|
76
88
|
}).pipe(
|
|
77
|
-
Effect.provide(DoCtx.
|
|
89
|
+
Effect.provide(DoCtx.layer({ ...input, from: { storeId: req.storeId } })),
|
|
78
90
|
Effect.mapError((cause) =>
|
|
79
|
-
cause._tag === 'UnknownError' ||
|
|
91
|
+
cause._tag === 'UnknownError' ||
|
|
92
|
+
cause._tag === 'ServerAheadError' ||
|
|
93
|
+
cause._tag === 'BackendIdMismatchError'
|
|
80
94
|
? cause
|
|
81
95
|
: new UnknownError({ cause }),
|
|
82
96
|
),
|
|
@@ -85,11 +99,8 @@ export const createDoRpcHandler = (
|
|
|
85
99
|
})
|
|
86
100
|
|
|
87
101
|
const handler = toDurableObjectHandler(SyncDoRpc, {
|
|
88
|
-
layer:
|
|
89
|
-
Layer.provide(Logger.consoleWithThread('SyncDo')),
|
|
90
|
-
Layer.provide(Logger.minimumLogLevel(LogLevel.Debug)),
|
|
91
|
-
),
|
|
102
|
+
layer: RpcLive,
|
|
92
103
|
})
|
|
93
104
|
|
|
94
|
-
return yield* handler(payload)
|
|
105
|
+
return yield* handler(payload).pipe(Effect.annotateLogs({ thread: 'SyncDo' }))
|
|
95
106
|
}).pipe(Effect.withSpan('createDoRpcHandler'))
|
|
@@ -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,62 +1,86 @@
|
|
|
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
|
+
import { type makeObservability, rpcSpanOptions, withRpcSpan, withRpcStreamSpan } from '../observability.ts'
|
|
8
9
|
import { makeEndingPullStream } from '../pull.ts'
|
|
9
10
|
import { makePush } from '../push.ts'
|
|
10
11
|
|
|
11
|
-
export const makeRpcServer = ({
|
|
12
|
-
|
|
12
|
+
export const makeRpcServer = ({
|
|
13
|
+
doSelf,
|
|
14
|
+
doOptions,
|
|
15
|
+
observability,
|
|
16
|
+
}: Omit<DoCtx.DoCtxInput, 'from'> & { observability: ReturnType<typeof makeObservability> }) => {
|
|
17
|
+
const handlersLayer = makeRpcHandlers({ doSelf, doOptions, observability })
|
|
18
|
+
|
|
19
|
+
return RpcServer.layer(SyncWsRpc).pipe(Layer.provide(handlersLayer))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Production handler layer, exported so transport tests can exercise the exact RPC composition in memory. */
|
|
23
|
+
export const makeRpcHandlers = ({
|
|
24
|
+
doSelf,
|
|
25
|
+
doOptions,
|
|
26
|
+
observability,
|
|
27
|
+
}: Omit<DoCtx.DoCtxInput, 'from'> & { observability: ReturnType<typeof makeObservability> }) =>
|
|
28
|
+
SyncWsRpc.toLayer({
|
|
13
29
|
'SyncWsRpc.Pull': (req) =>
|
|
14
30
|
Effect.gen(function* () {
|
|
31
|
+
const spanOptions = yield* rpcSpanOptions
|
|
15
32
|
const headers = yield* getForwardedHeaders
|
|
16
33
|
return makeEndingPullStream({ req, payload: req.payload, headers }).pipe(
|
|
17
|
-
|
|
18
|
-
req.live === true ? Stream.concat(Stream.never) : identity,
|
|
19
|
-
Stream.provideLayer(DoCtx.Default({ doSelf, doOptions, from: { storeId: req.storeId } })),
|
|
34
|
+
Stream.provide(DoCtx.layer({ doSelf, doOptions, from: { storeId: req.storeId } })),
|
|
20
35
|
Stream.mapError((cause) =>
|
|
21
36
|
cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError'
|
|
22
37
|
? cause
|
|
23
38
|
: new UnknownError({ cause }),
|
|
24
39
|
),
|
|
40
|
+
(_) => withRpcStreamSpan(_, 'RpcServer.SyncWsRpc.Pull', spanOptions),
|
|
25
41
|
)
|
|
26
|
-
}).pipe(
|
|
42
|
+
}).pipe(
|
|
43
|
+
Stream.unwrap,
|
|
44
|
+
// Drain finite history before entering the live phase, which survives DO hibernation.
|
|
45
|
+
observability.stream,
|
|
46
|
+
// Keep the client subscription open without retaining an exporter scope or timer.
|
|
47
|
+
req.live === true ? Stream.concat(Stream.never) : identity,
|
|
48
|
+
),
|
|
27
49
|
'SyncWsRpc.Push': (req) =>
|
|
28
|
-
Effect.
|
|
29
|
-
|
|
30
|
-
|
|
50
|
+
Effect.flatMap(rpcSpanOptions, (spanOptions) =>
|
|
51
|
+
Effect.gen(function* () {
|
|
52
|
+
const { doOptions, storeId, ctx, env } = yield* DoCtx.DoCtx
|
|
53
|
+
const headers = yield* getForwardedHeaders
|
|
31
54
|
|
|
32
|
-
|
|
55
|
+
const push = makePush({ options: doOptions, storeId, payload: req.payload, headers, ctx, env })
|
|
33
56
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
57
|
+
return yield* push(req)
|
|
58
|
+
}).pipe(
|
|
59
|
+
Effect.provide(DoCtx.layer({ doSelf, doOptions, from: { storeId: req.storeId } })),
|
|
60
|
+
Effect.mapError((cause) =>
|
|
61
|
+
cause._tag === 'UnknownError' ||
|
|
62
|
+
cause._tag === 'ServerAheadError' ||
|
|
63
|
+
cause._tag === 'BackendIdMismatchError'
|
|
64
|
+
? cause
|
|
65
|
+
: new UnknownError({ cause }),
|
|
66
|
+
),
|
|
67
|
+
Effect.tapCauseLogPretty,
|
|
68
|
+
(_) => withRpcSpan(_, 'RpcServer.SyncWsRpc.Push', spanOptions),
|
|
41
69
|
),
|
|
42
|
-
|
|
43
|
-
),
|
|
70
|
+
).pipe(observability.effect),
|
|
44
71
|
})
|
|
45
72
|
|
|
46
|
-
return RpcServer.layer(SyncWsRpc).pipe(Layer.provide(handlersLayer))
|
|
47
|
-
}
|
|
48
|
-
|
|
49
73
|
/** Extracts forwarded headers from the WebSocket attachment */
|
|
50
74
|
const getForwardedHeaders = Effect.gen(function* () {
|
|
51
75
|
const { ws } = yield* WsContext
|
|
52
76
|
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.
|
|
77
|
+
const decoded = Schema.decodeUnknownResult(WebSocketAttachmentSchema)(attachment)
|
|
78
|
+
if (Result.isFailure(decoded) === true) {
|
|
79
|
+
yield* Effect.logError('Failed to decode WebSocket attachment for forwarded headers', { error: decoded.failure })
|
|
56
80
|
ws.close(1011, 'invalid-attachment')
|
|
57
81
|
return yield* Effect.die('Invalid WebSocket attachment (headers decode failed)')
|
|
58
82
|
}
|
|
59
83
|
|
|
60
|
-
const headers = headersRecordToMap(decoded.
|
|
84
|
+
const headers = headersRecordToMap(decoded.success.headers)
|
|
61
85
|
return headers
|
|
62
86
|
})
|
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, Layer } 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
|
}
|
|
@@ -106,12 +107,19 @@ export type MakeDurableObjectClassOptions = {
|
|
|
106
107
|
responseHeaders?: Record<string, string>
|
|
107
108
|
}
|
|
108
109
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
serviceName?: string
|
|
112
|
-
}
|
|
110
|
+
/** Optional telemetry. Omit to avoid creating an exporter or sending telemetry. */
|
|
111
|
+
otel?: SyncBackendOtelOptions
|
|
113
112
|
}
|
|
114
113
|
|
|
114
|
+
export type SyncBackendOtelOptions =
|
|
115
|
+
/** Application-supplied tracer layer; LiveStore builds and finalizes its resources per sync operation. */
|
|
116
|
+
| Layer.Layer<never>
|
|
117
|
+
| {
|
|
118
|
+
/** Convenience OTLP/HTTP JSON exporter owned by LiveStore for each operation. */
|
|
119
|
+
baseUrl?: string
|
|
120
|
+
serviceName?: string
|
|
121
|
+
}
|
|
122
|
+
|
|
115
123
|
export type StoreId = string
|
|
116
124
|
export type DurableObjectId = string
|
|
117
125
|
|
|
@@ -133,8 +141,10 @@ export type DurableObjectId = string
|
|
|
133
141
|
*/
|
|
134
142
|
export const PERSISTENCE_FORMAT_VERSION = 7
|
|
135
143
|
|
|
136
|
-
export const encodeOutgoingMessage = Schema.encodeSync(Schema.
|
|
137
|
-
export const encodeIncomingMessage = Schema.encodeSync(Schema.
|
|
144
|
+
export const encodeOutgoingMessage = Schema.encodeSync(Schema.fromJsonString(SyncMessage.BackendToClientMessage))
|
|
145
|
+
export const encodeIncomingMessage = Schema.encodeSync(Schema.fromJsonString(SyncMessage.ClientToBackendMessage))
|
|
146
|
+
|
|
147
|
+
const SearchParamsFromUrlSearchParams = Schema.fromURLSearchParams(SearchParamsSchema)
|
|
138
148
|
|
|
139
149
|
/**
|
|
140
150
|
* Extracts the LiveStore sync search parameters from a request. Returns
|
|
@@ -143,20 +153,19 @@ export const encodeIncomingMessage = Schema.encodeSync(Schema.parseJson(SyncMess
|
|
|
143
153
|
*/
|
|
144
154
|
export const matchSyncRequest = (request: CfTypes.Request): SearchParams | undefined => {
|
|
145
155
|
const url = new URL(request.url)
|
|
146
|
-
const
|
|
147
|
-
const paramsResult = UrlParams.schemaStruct(SearchParamsSchema)(urlParams).pipe(Effect.option, Effect.runSync)
|
|
156
|
+
const paramsResult = Schema.decodeResult(SearchParamsFromUrlSearchParams)(url.searchParams)
|
|
148
157
|
|
|
149
|
-
if (paramsResult
|
|
158
|
+
if (Result.isFailure(paramsResult) === true) {
|
|
150
159
|
return undefined
|
|
151
160
|
}
|
|
152
161
|
|
|
153
|
-
return paramsResult.
|
|
162
|
+
return paramsResult.success
|
|
154
163
|
}
|
|
155
164
|
|
|
156
165
|
// RPC subscription storage (TODO refactor)
|
|
157
166
|
export type RpcSubscription = {
|
|
158
167
|
storeId: StoreId
|
|
159
|
-
payload?: Schema.
|
|
168
|
+
payload?: Schema.Json
|
|
160
169
|
subscribedAt: number
|
|
161
170
|
/** Effect RPC request ID */
|
|
162
171
|
requestId: string
|
|
@@ -166,6 +175,9 @@ export type RpcSubscription = {
|
|
|
166
175
|
}
|
|
167
176
|
}
|
|
168
177
|
|
|
178
|
+
/** Key prefix for DO-RPC subscriptions persisted in the DO's synchronous KV storage. */
|
|
179
|
+
export const rpcSubscriptionKeyPrefix = 'rpc-sub:'
|
|
180
|
+
|
|
169
181
|
/**
|
|
170
182
|
* Durable Object interface supporting the DO RPC protocol for DO <> DO syncing.
|
|
171
183
|
*/
|
|
@@ -174,15 +186,15 @@ export interface SyncBackendRpcInterface {
|
|
|
174
186
|
rpc(payload: Uint8Array): Promise<Uint8Array | CfTypes.ReadableStream>
|
|
175
187
|
}
|
|
176
188
|
|
|
177
|
-
export const WebSocketAttachmentSchema = Schema.
|
|
189
|
+
export const WebSocketAttachmentSchema = Schema.fromJsonString(
|
|
178
190
|
Schema.Struct({
|
|
179
191
|
// Same across all websocket connections
|
|
180
192
|
storeId: Schema.String,
|
|
181
193
|
// Different for each websocket connection
|
|
182
|
-
payload: Schema.optional(Schema.
|
|
183
|
-
pullRequestIds: Schema.Array(Schema.String),
|
|
194
|
+
payload: Schema.optional(Schema.Json),
|
|
195
|
+
pullRequestIds: Schema.Array(Schema.Union([Schema.String, Schema.Finite])),
|
|
184
196
|
// Headers forwarded from the initial request (via forwardHeaders option)
|
|
185
|
-
headers: Schema.optional(Schema.Record(
|
|
197
|
+
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
|
186
198
|
}),
|
|
187
199
|
)
|
|
188
200
|
|
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.decodeResult(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
|
}
|