@livestore/sync-cf 0.4.0-dev.9 → 0.4.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/README.md +7 -8
- package/dist/.tsbuildinfo +1 -1
- package/dist/cf-worker/do/durable-object.d.ts +1 -1
- package/dist/cf-worker/do/durable-object.d.ts.map +1 -1
- package/dist/cf-worker/do/durable-object.js +13 -8
- package/dist/cf-worker/do/durable-object.js.map +1 -1
- package/dist/cf-worker/do/layer.d.ts +5 -5
- package/dist/cf-worker/do/layer.d.ts.map +1 -1
- package/dist/cf-worker/do/layer.js +32 -9
- package/dist/cf-worker/do/layer.js.map +1 -1
- package/dist/cf-worker/do/pull.d.ts +7 -2
- package/dist/cf-worker/do/pull.d.ts.map +1 -1
- package/dist/cf-worker/do/pull.js +16 -10
- package/dist/cf-worker/do/pull.js.map +1 -1
- package/dist/cf-worker/do/push.d.ts +5 -4
- package/dist/cf-worker/do/push.d.ts.map +1 -1
- package/dist/cf-worker/do/push.js +25 -17
- package/dist/cf-worker/do/push.js.map +1 -1
- package/dist/cf-worker/do/sqlite.d.ts +10 -1
- package/dist/cf-worker/do/sqlite.d.ts.map +1 -1
- package/dist/cf-worker/do/sqlite.js +13 -4
- package/dist/cf-worker/do/sqlite.js.map +1 -1
- package/dist/cf-worker/do/sync-storage.d.ts +14 -9
- package/dist/cf-worker/do/sync-storage.d.ts.map +1 -1
- package/dist/cf-worker/do/sync-storage.js +92 -18
- package/dist/cf-worker/do/sync-storage.js.map +1 -1
- package/dist/cf-worker/do/transport/do-rpc-server.d.ts.map +1 -1
- package/dist/cf-worker/do/transport/do-rpc-server.js +13 -7
- package/dist/cf-worker/do/transport/do-rpc-server.js.map +1 -1
- package/dist/cf-worker/do/transport/http-rpc-server.d.ts +4 -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 +24 -15
- 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 -1
- package/dist/cf-worker/do/transport/ws-rpc-server.d.ts.map +1 -1
- package/dist/cf-worker/do/transport/ws-rpc-server.js +30 -8
- package/dist/cf-worker/do/transport/ws-rpc-server.js.map +1 -1
- package/dist/cf-worker/shared.d.ts +118 -31
- package/dist/cf-worker/shared.d.ts.map +1 -1
- package/dist/cf-worker/shared.js +40 -7
- package/dist/cf-worker/shared.js.map +1 -1
- package/dist/cf-worker/worker.d.ts +46 -38
- package/dist/cf-worker/worker.d.ts.map +1 -1
- package/dist/cf-worker/worker.js +51 -34
- package/dist/cf-worker/worker.js.map +1 -1
- package/dist/client/transport/do-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/do-rpc-client.js +27 -10
- package/dist/client/transport/do-rpc-client.js.map +1 -1
- package/dist/client/transport/http-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/http-rpc-client.js +29 -9
- package/dist/client/transport/http-rpc-client.js.map +1 -1
- package/dist/client/transport/ws-rpc-client.d.ts +2 -1
- package/dist/client/transport/ws-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/ws-rpc-client.js +31 -17
- package/dist/client/transport/ws-rpc-client.js.map +1 -1
- 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 +6 -6
- package/dist/common/do-rpc-schema.d.ts.map +1 -1
- package/dist/common/do-rpc-schema.js +4 -4
- package/dist/common/do-rpc-schema.js.map +1 -1
- package/dist/common/http-rpc-schema.d.ts +4 -4
- package/dist/common/http-rpc-schema.d.ts.map +1 -1
- package/dist/common/http-rpc-schema.js +4 -4
- package/dist/common/http-rpc-schema.js.map +1 -1
- package/dist/common/mod.d.ts +4 -1
- package/dist/common/mod.d.ts.map +1 -1
- package/dist/common/mod.js +4 -1
- package/dist/common/mod.js.map +1 -1
- package/dist/common/sync-message-types.d.ts +7 -7
- package/dist/common/sync-message-types.js +3 -3
- package/dist/common/sync-message-types.js.map +1 -1
- package/dist/common/ws-rpc-schema.d.ts +3 -3
- package/dist/common/ws-rpc-schema.d.ts.map +1 -1
- package/dist/common/ws-rpc-schema.js +3 -3
- package/dist/common/ws-rpc-schema.js.map +1 -1
- package/package.json +72 -14
- package/src/cf-worker/do/durable-object.ts +19 -10
- package/src/cf-worker/do/layer.ts +35 -13
- package/src/cf-worker/do/pull.ts +31 -14
- package/src/cf-worker/do/push.ts +49 -34
- package/src/cf-worker/do/sqlite.ts +14 -4
- package/src/cf-worker/do/sync-storage.ts +151 -31
- package/src/cf-worker/do/transport/do-rpc-server.ts +18 -7
- package/src/cf-worker/do/transport/http-rpc-server.ts +33 -13
- package/src/cf-worker/do/transport/ws-rpc-server.ts +40 -12
- package/src/cf-worker/shared.ts +136 -25
- package/src/cf-worker/worker.ts +107 -54
- package/src/client/transport/do-rpc-client.ts +41 -17
- package/src/client/transport/http-rpc-client.ts +43 -17
- package/src/client/transport/ws-rpc-client.ts +42 -19
- package/src/common/constants.ts +18 -0
- package/src/common/do-rpc-schema.ts +5 -4
- package/src/common/http-rpc-schema.ts +5 -4
- package/src/common/mod.ts +4 -2
- package/src/common/sync-message-types.ts +3 -3
- package/src/common/ws-rpc-schema.ts +4 -3
- package/dist/cf-worker/do/ws-chunking.d.ts +0 -22
- package/dist/cf-worker/do/ws-chunking.d.ts.map +0 -1
- package/dist/cf-worker/do/ws-chunking.js +0 -49
- package/dist/cf-worker/do/ws-chunking.js.map +0 -1
- package/src/cf-worker/do/ws-chunking.ts +0 -76
|
@@ -1,34 +1,62 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { UnknownError } from '@livestore/common'
|
|
2
|
+
import { WsContext } from '@livestore/common-cf'
|
|
3
|
+
import { Effect, identity, Layer, RpcServer, Schema, Stream } from '@livestore/utils/effect'
|
|
4
|
+
|
|
3
5
|
import { SyncWsRpc } from '../../../common/ws-rpc-schema.ts'
|
|
6
|
+
import { headersRecordToMap, WebSocketAttachmentSchema } from '../../shared.ts'
|
|
4
7
|
import { DoCtx, type DoCtxInput } from '../layer.ts'
|
|
5
8
|
import { makeEndingPullStream } from '../pull.ts'
|
|
6
9
|
import { makePush } from '../push.ts'
|
|
7
10
|
|
|
8
11
|
export const makeRpcServer = ({ doSelf, doOptions }: Omit<DoCtxInput, 'from'>) => {
|
|
9
|
-
// TODO implement admin requests
|
|
10
12
|
const handlersLayer = SyncWsRpc.toLayer({
|
|
11
13
|
'SyncWsRpc.Pull': (req) =>
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
req.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
Effect.gen(function* () {
|
|
15
|
+
const headers = yield* getForwardedHeaders
|
|
16
|
+
return makeEndingPullStream({ req, payload: req.payload, headers }).pipe(
|
|
17
|
+
// Needed to keep the stream alive on the client side for phase 2 (i.e. not send the `Exit` stream RPC message)
|
|
18
|
+
req.live === true ? Stream.concat(Stream.never) : identity,
|
|
19
|
+
Stream.provideLayer(DoCtx.Default({ doSelf, doOptions, from: { storeId: req.storeId } })),
|
|
20
|
+
Stream.mapError((cause) =>
|
|
21
|
+
cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError'
|
|
22
|
+
? cause
|
|
23
|
+
: new UnknownError({ cause }),
|
|
24
|
+
),
|
|
25
|
+
)
|
|
26
|
+
}).pipe(Stream.unwrap),
|
|
19
27
|
'SyncWsRpc.Push': (req) =>
|
|
20
28
|
Effect.gen(function* () {
|
|
21
29
|
const { doOptions, storeId, ctx, env } = yield* DoCtx
|
|
30
|
+
const headers = yield* getForwardedHeaders
|
|
22
31
|
|
|
23
|
-
const push = makePush({ options: doOptions, storeId, payload: req.payload, ctx, env })
|
|
32
|
+
const push = makePush({ options: doOptions, storeId, payload: req.payload, headers, ctx, env })
|
|
24
33
|
|
|
25
34
|
return yield* push(req)
|
|
26
35
|
}).pipe(
|
|
27
36
|
Effect.provide(DoCtx.Default({ doSelf, doOptions, from: { storeId: req.storeId } })),
|
|
28
|
-
Effect.mapError((cause) =>
|
|
37
|
+
Effect.mapError((cause) =>
|
|
38
|
+
cause._tag === 'UnknownError' || cause._tag === 'ServerAheadError' || cause._tag === 'BackendIdMismatchError'
|
|
39
|
+
? cause
|
|
40
|
+
: new UnknownError({ cause }),
|
|
41
|
+
),
|
|
29
42
|
Effect.tapCauseLogPretty,
|
|
30
43
|
),
|
|
31
44
|
})
|
|
32
45
|
|
|
33
46
|
return RpcServer.layer(SyncWsRpc).pipe(Layer.provide(handlersLayer))
|
|
34
47
|
}
|
|
48
|
+
|
|
49
|
+
/** Extracts forwarded headers from the WebSocket attachment */
|
|
50
|
+
const getForwardedHeaders = Effect.gen(function* () {
|
|
51
|
+
const { ws } = yield* WsContext
|
|
52
|
+
const attachment = ws.deserializeAttachment()
|
|
53
|
+
const decoded = Schema.decodeUnknownEither(WebSocketAttachmentSchema)(attachment)
|
|
54
|
+
if (decoded._tag === 'Left') {
|
|
55
|
+
yield* Effect.logError('Failed to decode WebSocket attachment for forwarded headers', { error: decoded.left })
|
|
56
|
+
ws.close(1011, 'invalid-attachment')
|
|
57
|
+
return yield* Effect.die('Invalid WebSocket attachment (headers decode failed)')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const headers = headersRecordToMap(decoded.right.headers)
|
|
61
|
+
return headers
|
|
62
|
+
})
|
package/src/cf-worker/shared.ts
CHANGED
|
@@ -1,27 +1,80 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { UnknownError } from '@livestore/common'
|
|
2
2
|
import type { CfTypes } from '@livestore/common-cf'
|
|
3
3
|
import { Effect, Schema, UrlParams } from '@livestore/utils/effect'
|
|
4
|
+
|
|
4
5
|
import type { SearchParams } from '../common/mod.ts'
|
|
5
6
|
import { SearchParamsSchema, SyncMessage } from '../common/mod.ts'
|
|
6
7
|
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
export type Env = {}
|
|
9
|
+
|
|
10
|
+
/** Headers forwarded from the request to callbacks */
|
|
11
|
+
export type ForwardedHeaders = ReadonlyMap<string, string>
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Configuration for forwarding request headers to DO callbacks.
|
|
15
|
+
* - `string[]`: List of header names to forward (case-insensitive)
|
|
16
|
+
* - `(request) => Record<string, string>`: Custom extraction function (sync)
|
|
17
|
+
*/
|
|
18
|
+
export type ForwardHeadersOption = readonly string[] | ((request: CfTypes.Request) => Record<string, string>)
|
|
19
|
+
|
|
20
|
+
/** Context passed to onPush/onPull callbacks */
|
|
21
|
+
export type CallbackContext = {
|
|
22
|
+
storeId: StoreId
|
|
23
|
+
payload?: Schema.JsonValue
|
|
24
|
+
/** Headers forwarded from the request (only present if `forwardHeaders` is configured) */
|
|
25
|
+
headers?: ForwardedHeaders
|
|
11
26
|
}
|
|
12
27
|
|
|
13
28
|
export type MakeDurableObjectClassOptions = {
|
|
14
|
-
onPush?: (
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
) => Effect.SyncOrPromiseOrEffect<void>
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
29
|
+
onPush?: (message: SyncMessage.PushRequest, context: CallbackContext) => Effect.SyncOrPromiseOrEffect<void>
|
|
30
|
+
onPushRes?: (message: SyncMessage.PushAck | UnknownError) => Effect.SyncOrPromiseOrEffect<void>
|
|
31
|
+
onPull?: (message: SyncMessage.PullRequest, context: CallbackContext) => Effect.SyncOrPromiseOrEffect<void>
|
|
32
|
+
onPullRes?: (message: SyncMessage.PullResponse | UnknownError) => Effect.SyncOrPromiseOrEffect<void>
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Forward request headers to `onPush`/`onPull` callbacks for authentication.
|
|
36
|
+
*
|
|
37
|
+
* This enables cookie-based or header-based authentication patterns where
|
|
38
|
+
* you need access to request headers inside the Durable Object.
|
|
39
|
+
*
|
|
40
|
+
* @example Forward specific headers by name (case-insensitive)
|
|
41
|
+
* ```ts
|
|
42
|
+
* makeDurableObject({
|
|
43
|
+
* forwardHeaders: ['cookie', 'authorization'],
|
|
44
|
+
* onPush: async (message, { headers }) => {
|
|
45
|
+
* const cookie = headers?.get('cookie')
|
|
46
|
+
* const session = await validateSession(cookie)
|
|
47
|
+
* },
|
|
48
|
+
* })
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* @example Custom extraction function for derived values
|
|
52
|
+
* ```ts
|
|
53
|
+
* makeDurableObject({
|
|
54
|
+
* forwardHeaders: (request) => ({
|
|
55
|
+
* 'x-user-id': request.headers.get('x-user-id') ?? '',
|
|
56
|
+
* 'x-session': request.headers.get('cookie')?.split('session=')[1]?.split(';')[0] ?? '',
|
|
57
|
+
* }),
|
|
58
|
+
* onPush: async (message, { headers }) => {
|
|
59
|
+
* const userId = headers?.get('x-user-id')
|
|
60
|
+
* },
|
|
61
|
+
* })
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
forwardHeaders?: ForwardHeadersOption
|
|
65
|
+
/**
|
|
66
|
+
* Storage engine for event persistence.
|
|
67
|
+
* - Default: `{ _tag: 'do-sqlite' }` (Durable Object SQLite)
|
|
68
|
+
* - D1: `{ _tag: 'd1', binding: string }` where `binding` is the D1 binding name in wrangler.toml.
|
|
69
|
+
*
|
|
70
|
+
* If omitted, the runtime defaults to DO SQLite. For backwards-compatibility, if an env binding named
|
|
71
|
+
* `DB` exists and looks like a D1Database, D1 will be used.
|
|
72
|
+
*
|
|
73
|
+
* Trade-offs:
|
|
74
|
+
* - DO SQLite: simpler deploy, data co-located with DO, not externally queryable
|
|
75
|
+
* - D1: centralized DB, inspectable with DB tools, extra network hop and JSON size limits
|
|
76
|
+
*/
|
|
77
|
+
storage?: { _tag: 'do-sqlite' } | { _tag: 'd1'; binding: string }
|
|
25
78
|
|
|
26
79
|
/**
|
|
27
80
|
* Enabled transports for sync backend
|
|
@@ -33,6 +86,26 @@ export type MakeDurableObjectClassOptions = {
|
|
|
33
86
|
*/
|
|
34
87
|
enabledTransports?: Set<'http' | 'ws' | 'do-rpc'>
|
|
35
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Custom HTTP response headers for HTTP transport
|
|
91
|
+
* These headers will be added to all HTTP RPC responses (Pull, Push, Ping)
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* ```ts
|
|
95
|
+
* {
|
|
96
|
+
* http: {
|
|
97
|
+
* responseHeaders: {
|
|
98
|
+
* 'Access-Control-Allow-Origin': '*',
|
|
99
|
+
* 'Cache-Control': 'no-cache'
|
|
100
|
+
* }
|
|
101
|
+
* }
|
|
102
|
+
* }
|
|
103
|
+
* ```
|
|
104
|
+
*/
|
|
105
|
+
http?: {
|
|
106
|
+
responseHeaders?: Record<string, string>
|
|
107
|
+
}
|
|
108
|
+
|
|
36
109
|
otel?: {
|
|
37
110
|
baseUrl?: string
|
|
38
111
|
serviceName?: string
|
|
@@ -43,9 +116,20 @@ export type StoreId = string
|
|
|
43
116
|
export type DurableObjectId = string
|
|
44
117
|
|
|
45
118
|
/**
|
|
46
|
-
*
|
|
119
|
+
* CRITICAL: Increment this version whenever you modify the database schema structure.
|
|
120
|
+
*
|
|
121
|
+
* Bump required when:
|
|
122
|
+
* - Adding/removing/renaming columns in eventlogTable or contextTable (see sqlite.ts)
|
|
123
|
+
* - Changing column types or constraints
|
|
124
|
+
* - Modifying primary keys or indexes
|
|
125
|
+
*
|
|
126
|
+
* Bump NOT required when:
|
|
127
|
+
* - Changing query patterns, pagination logic, or streaming behavior
|
|
128
|
+
* - Adding new tables (as long as existing table schemas remain unchanged)
|
|
129
|
+
* - Updating implementation details in sync-storage.ts
|
|
47
130
|
*
|
|
48
|
-
* Changing this version
|
|
131
|
+
* Impact: Changing this version triggers a "soft reset" - new table names are created
|
|
132
|
+
* and old data becomes inaccessible (but remains in storage).
|
|
49
133
|
*/
|
|
50
134
|
export const PERSISTENCE_FORMAT_VERSION = 7
|
|
51
135
|
|
|
@@ -69,13 +153,6 @@ export const matchSyncRequest = (request: CfTypes.Request): SearchParams | undef
|
|
|
69
153
|
return paramsResult.value
|
|
70
154
|
}
|
|
71
155
|
|
|
72
|
-
export const MAX_PULL_EVENTS_PER_MESSAGE = 100
|
|
73
|
-
|
|
74
|
-
// Cloudflare hibernated WebSocket frames begin failing just below 1MB. Keep our
|
|
75
|
-
// payloads comfortably beneath that ceiling so we don't rely on implementation
|
|
76
|
-
// quirks of local dev servers.
|
|
77
|
-
export const MAX_WS_MESSAGE_BYTES = 900_000
|
|
78
|
-
|
|
79
156
|
// RPC subscription storage (TODO refactor)
|
|
80
157
|
export type RpcSubscription = {
|
|
81
158
|
storeId: StoreId
|
|
@@ -104,5 +181,39 @@ export const WebSocketAttachmentSchema = Schema.parseJson(
|
|
|
104
181
|
// Different for each websocket connection
|
|
105
182
|
payload: Schema.optional(Schema.JsonValue),
|
|
106
183
|
pullRequestIds: Schema.Array(Schema.String),
|
|
184
|
+
// Headers forwarded from the initial request (via forwardHeaders option)
|
|
185
|
+
headers: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.String })),
|
|
107
186
|
}),
|
|
108
187
|
)
|
|
188
|
+
|
|
189
|
+
/** Helper to extract headers from a request based on the forwardHeaders option */
|
|
190
|
+
export const extractForwardedHeaders = (
|
|
191
|
+
request: CfTypes.Request,
|
|
192
|
+
forwardHeaders: ForwardHeadersOption | undefined,
|
|
193
|
+
): Record<string, string> | undefined => {
|
|
194
|
+
if (forwardHeaders === undefined) {
|
|
195
|
+
return undefined
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (typeof forwardHeaders === 'function') {
|
|
199
|
+
return forwardHeaders(request)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Array of header names - extract them case-insensitively
|
|
203
|
+
const result: Record<string, string> = {}
|
|
204
|
+
for (const name of forwardHeaders) {
|
|
205
|
+
const value = request.headers.get(name)
|
|
206
|
+
if (value !== null) {
|
|
207
|
+
result[name.toLowerCase()] = value
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return Object.keys(result).length > 0 ? result : undefined
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Convert a headers record to a ReadonlyMap */
|
|
214
|
+
export const headersRecordToMap = (headers: Record<string, string> | undefined): ForwardedHeaders | undefined => {
|
|
215
|
+
if (headers === undefined) {
|
|
216
|
+
return undefined
|
|
217
|
+
}
|
|
218
|
+
return new Map(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]))
|
|
219
|
+
}
|
package/src/cf-worker/worker.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { env as importedEnv } from 'cloudflare:workers'
|
|
2
|
+
|
|
3
|
+
import { UnknownError } from '@livestore/common'
|
|
2
4
|
import type { HelperTypes } from '@livestore/common-cf'
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
+
import { Effect, Schema } from '@livestore/utils/effect'
|
|
6
|
+
|
|
5
7
|
import type { CfTypes, SearchParams } from '../common/mod.ts'
|
|
6
8
|
import type { CfDeclare } from './mod.ts'
|
|
7
|
-
import { type Env, matchSyncRequest } from './shared.ts'
|
|
9
|
+
import { type Env, type ForwardedHeaders, matchSyncRequest } from './shared.ts'
|
|
8
10
|
|
|
9
11
|
// NOTE We need to redeclare runtime types here to avoid type conflicts with the lib.dom Response type.
|
|
10
12
|
declare class Response extends CfDeclare.Response {}
|
|
@@ -18,21 +20,46 @@ export type CFWorker<TEnv extends Env = Env, _T extends CfTypes.Rpc.DurableObjec
|
|
|
18
20
|
) => Promise<CfTypes.Response>
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
/** Context passed to validatePayload callback */
|
|
24
|
+
export type ValidatePayloadContext = {
|
|
25
|
+
storeId: string
|
|
26
|
+
/** Request headers (raw, not filtered by forwardHeaders) */
|
|
27
|
+
headers: ForwardedHeaders
|
|
28
|
+
}
|
|
29
|
+
|
|
21
30
|
/**
|
|
22
31
|
* Options accepted by {@link makeWorker}. The Durable Object binding has to be
|
|
23
32
|
* supplied explicitly so we never fall back to deprecated defaults when Cloudflare config changes.
|
|
24
33
|
*/
|
|
25
|
-
export type MakeWorkerOptions<TEnv extends Env = Env> = {
|
|
34
|
+
export type MakeWorkerOptions<TEnv extends Env = Env, TSyncPayload = Schema.JsonValue> = {
|
|
26
35
|
/**
|
|
27
36
|
* Binding name of the sync Durable Object declared in wrangler config.
|
|
28
37
|
*/
|
|
29
38
|
syncBackendBinding: HelperTypes.ExtractDurableObjectKeys<TEnv>
|
|
30
39
|
/**
|
|
31
|
-
*
|
|
40
|
+
* Optionally pass a schema to decode the client-provided payload into a typed object
|
|
41
|
+
* before calling {@link validatePayload}. If omitted, the raw JSON value is forwarded.
|
|
42
|
+
*/
|
|
43
|
+
syncPayloadSchema?: Schema.Schema<TSyncPayload>
|
|
44
|
+
/**
|
|
45
|
+
* Validates the (optionally decoded) payload during WebSocket connection establishment.
|
|
46
|
+
* If {@link syncPayloadSchema} is provided, `payload` will be of the schema's inferred type.
|
|
47
|
+
*
|
|
48
|
+
* The context includes request headers for cookie-based or header-based authentication.
|
|
49
|
+
*
|
|
50
|
+
* @example Cookie-based authentication
|
|
51
|
+
* ```ts
|
|
52
|
+
* validatePayload: async (payload, { storeId, headers }) => {
|
|
53
|
+
* const cookie = headers.get('cookie')
|
|
54
|
+
* const session = await validateSessionFromCookie(cookie)
|
|
55
|
+
* if (!session) throw new Error('Unauthorized')
|
|
56
|
+
* }
|
|
57
|
+
* ```
|
|
58
|
+
*
|
|
32
59
|
* Note: This runs only at connection time, not for individual push events.
|
|
33
|
-
* For push event validation, use the `onPush` callback in the
|
|
60
|
+
* For push event validation, use the `onPush` callback in the Durable Object.
|
|
34
61
|
*/
|
|
35
|
-
validatePayload?: (payload:
|
|
62
|
+
validatePayload?: (payload: TSyncPayload, context: ValidatePayloadContext) => void | Promise<void>
|
|
36
63
|
/** @default false */
|
|
37
64
|
enableCORS?: boolean
|
|
38
65
|
}
|
|
@@ -47,22 +74,24 @@ export type MakeWorkerOptions<TEnv extends Env = Env> = {
|
|
|
47
74
|
export const makeWorker = <
|
|
48
75
|
TEnv extends Env = Env,
|
|
49
76
|
TDurableObjectRpc extends CfTypes.Rpc.DurableObjectBranded | undefined = undefined,
|
|
77
|
+
TSyncPayload = Schema.JsonValue,
|
|
50
78
|
>(
|
|
51
|
-
options: MakeWorkerOptions<TEnv>,
|
|
79
|
+
options: MakeWorkerOptions<TEnv, TSyncPayload>,
|
|
52
80
|
): CFWorker<TEnv, TDurableObjectRpc> => {
|
|
53
81
|
return {
|
|
54
82
|
fetch: async (request, env, _ctx) => {
|
|
55
83
|
const url = new URL(request.url)
|
|
56
84
|
|
|
57
|
-
const corsHeaders: CfTypes.HeadersInit =
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
85
|
+
const corsHeaders: CfTypes.HeadersInit =
|
|
86
|
+
options.enableCORS === true
|
|
87
|
+
? {
|
|
88
|
+
'Access-Control-Allow-Origin': '*',
|
|
89
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
90
|
+
'Access-Control-Allow-Headers': request.headers.get('Access-Control-Request-Headers') ?? '*',
|
|
91
|
+
}
|
|
92
|
+
: {}
|
|
64
93
|
|
|
65
|
-
if (request.method === 'OPTIONS' && options.enableCORS) {
|
|
94
|
+
if (request.method === 'OPTIONS' && options.enableCORS === true) {
|
|
66
95
|
return new Response(null, {
|
|
67
96
|
status: 204,
|
|
68
97
|
headers: corsHeaders,
|
|
@@ -73,7 +102,7 @@ export const makeWorker = <
|
|
|
73
102
|
|
|
74
103
|
// Check if this is a sync request first, before showing info message
|
|
75
104
|
if (searchParams !== undefined) {
|
|
76
|
-
return handleSyncRequest<TEnv, TDurableObjectRpc>({
|
|
105
|
+
return handleSyncRequest<TEnv, TDurableObjectRpc, unknown, TSyncPayload>({
|
|
77
106
|
request,
|
|
78
107
|
searchParams,
|
|
79
108
|
env,
|
|
@@ -81,6 +110,7 @@ export const makeWorker = <
|
|
|
81
110
|
syncBackendBinding: options.syncBackendBinding,
|
|
82
111
|
headers: corsHeaders,
|
|
83
112
|
validatePayload: options.validatePayload,
|
|
113
|
+
syncPayloadSchema: options.syncPayloadSchema,
|
|
84
114
|
})
|
|
85
115
|
}
|
|
86
116
|
|
|
@@ -106,83 +136,106 @@ export const makeWorker = <
|
|
|
106
136
|
}
|
|
107
137
|
}
|
|
108
138
|
|
|
139
|
+
/** Convert CF Request headers to a ForwardedHeaders map */
|
|
140
|
+
const requestHeadersToMap = (request: CfTypes.Request): ForwardedHeaders => {
|
|
141
|
+
const result = new Map<string, string>()
|
|
142
|
+
request.headers.forEach((value, key) => {
|
|
143
|
+
result.set(key.toLowerCase(), value)
|
|
144
|
+
})
|
|
145
|
+
return result
|
|
146
|
+
}
|
|
147
|
+
|
|
109
148
|
/**
|
|
110
|
-
* Handles
|
|
149
|
+
* Handles LiveStore sync requests (e.g. with search params `?storeId=...&transport=...`).
|
|
111
150
|
*
|
|
112
|
-
* @example
|
|
151
|
+
* @example Token-based authentication
|
|
113
152
|
* ```ts
|
|
114
153
|
* const validatePayload = (payload: Schema.JsonValue | undefined, context: { storeId: string }) => {
|
|
115
|
-
* console.log(`Validating connection for store: ${context.storeId}`)
|
|
116
154
|
* if (payload?.authToken !== 'insecure-token-change-me') {
|
|
117
155
|
* throw new Error('Invalid auth token')
|
|
118
156
|
* }
|
|
119
157
|
* }
|
|
158
|
+
* ```
|
|
120
159
|
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* return handleSyncRequest({
|
|
128
|
-
* request,
|
|
129
|
-
* searchParams,
|
|
130
|
-
* env,
|
|
131
|
-
* ctx,
|
|
132
|
-
* syncBackendBinding: 'SYNC_BACKEND_DO',
|
|
133
|
-
* headers: {},
|
|
134
|
-
* validatePayload,
|
|
135
|
-
* })
|
|
136
|
-
* }
|
|
137
|
-
*
|
|
138
|
-
* return new Response('Invalid path', { status: 400 })
|
|
139
|
-
* }
|
|
160
|
+
* @example Cookie-based authentication
|
|
161
|
+
* ```ts
|
|
162
|
+
* const validatePayload = async (payload: Schema.JsonValue | undefined, { storeId, headers }) => {
|
|
163
|
+
* const cookie = headers.get('cookie')
|
|
164
|
+
* const session = await validateSessionFromCookie(cookie)
|
|
165
|
+
* if (!session) throw new Error('Unauthorized')
|
|
140
166
|
* }
|
|
141
167
|
* ```
|
|
142
168
|
*
|
|
143
|
-
* @throws {
|
|
169
|
+
* @throws {UnknownError} If the payload is invalid
|
|
144
170
|
*/
|
|
145
171
|
export const handleSyncRequest = <
|
|
146
172
|
TEnv extends Env = Env,
|
|
147
173
|
TDurableObjectRpc extends CfTypes.Rpc.DurableObjectBranded | undefined = undefined,
|
|
148
174
|
CFHostMetada = unknown,
|
|
175
|
+
TSyncPayload = Schema.JsonValue,
|
|
149
176
|
>({
|
|
150
177
|
request,
|
|
151
178
|
searchParams: { storeId, payload, transport },
|
|
152
|
-
env,
|
|
179
|
+
env: explicitlyProvidedEnv,
|
|
153
180
|
syncBackendBinding,
|
|
154
181
|
headers,
|
|
155
182
|
validatePayload,
|
|
183
|
+
syncPayloadSchema,
|
|
156
184
|
}: {
|
|
157
185
|
request: CfTypes.Request<CFHostMetada>
|
|
158
186
|
searchParams: SearchParams
|
|
159
|
-
env
|
|
187
|
+
env?: TEnv | undefined
|
|
160
188
|
/** Only there for type-level reasons */
|
|
161
189
|
ctx: CfTypes.ExecutionContext
|
|
162
190
|
/** Binding name of the sync backend Durable Object */
|
|
163
|
-
syncBackendBinding: MakeWorkerOptions<TEnv>['syncBackendBinding']
|
|
191
|
+
syncBackendBinding: MakeWorkerOptions<TEnv, TSyncPayload>['syncBackendBinding']
|
|
164
192
|
headers?: CfTypes.HeadersInit | undefined
|
|
165
|
-
validatePayload?:
|
|
193
|
+
validatePayload?: MakeWorkerOptions<TEnv, TSyncPayload>['validatePayload']
|
|
194
|
+
syncPayloadSchema?: MakeWorkerOptions<TEnv, TSyncPayload>['syncPayloadSchema']
|
|
166
195
|
}): Promise<CfTypes.Response> =>
|
|
167
196
|
Effect.gen(function* () {
|
|
168
197
|
if (validatePayload !== undefined) {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
Effect.either,
|
|
172
|
-
)
|
|
198
|
+
// Convert request headers to a Map for the validation context
|
|
199
|
+
const requestHeaders = requestHeadersToMap(request)
|
|
173
200
|
|
|
174
|
-
if
|
|
175
|
-
|
|
176
|
-
|
|
201
|
+
// Always decode with the supplied schema when present, even if payload is undefined.
|
|
202
|
+
// This ensures required payloads are enforced by the schema.
|
|
203
|
+
if (syncPayloadSchema !== undefined) {
|
|
204
|
+
const decodedEither = Schema.decodeUnknownEither(syncPayloadSchema)(payload)
|
|
205
|
+
if (decodedEither._tag === 'Left') {
|
|
206
|
+
const message = decodedEither.left.toString()
|
|
207
|
+
console.error('Invalid payload (decode failed)', message)
|
|
208
|
+
return new Response(message, { status: 400, ...(headers !== undefined ? { headers } : {}) })
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const result = yield* Effect.promise(async () =>
|
|
212
|
+
validatePayload(decodedEither.right, { storeId, headers: requestHeaders }),
|
|
213
|
+
).pipe(UnknownError.mapToUnknownError, Effect.either)
|
|
214
|
+
|
|
215
|
+
if (result._tag === 'Left') {
|
|
216
|
+
console.error('Invalid payload (validation failed)', result.left)
|
|
217
|
+
return new Response(result.left.toString(), { status: 400, ...(headers !== undefined ? { headers } : {}) })
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
const result = yield* Effect.promise(async () =>
|
|
221
|
+
validatePayload(payload as TSyncPayload, { storeId, headers: requestHeaders }),
|
|
222
|
+
).pipe(UnknownError.mapToUnknownError, Effect.either)
|
|
223
|
+
|
|
224
|
+
if (result._tag === 'Left') {
|
|
225
|
+
console.error('Invalid payload (validation failed)', result.left)
|
|
226
|
+
return new Response(result.left.toString(), { status: 400, ...(headers !== undefined ? { headers } : {}) })
|
|
227
|
+
}
|
|
177
228
|
}
|
|
178
229
|
}
|
|
179
230
|
|
|
231
|
+
const env = explicitlyProvidedEnv ?? (importedEnv as TEnv)
|
|
232
|
+
|
|
180
233
|
if (!(syncBackendBinding in env)) {
|
|
181
234
|
return new Response(
|
|
182
235
|
`Failed dependency: Required Durable Object binding '${syncBackendBinding as string}' not available`,
|
|
183
236
|
{
|
|
184
237
|
status: 424,
|
|
185
|
-
headers,
|
|
238
|
+
...(headers !== undefined ? { headers } : {}),
|
|
186
239
|
},
|
|
187
240
|
)
|
|
188
241
|
}
|
|
@@ -199,7 +252,7 @@ export const handleSyncRequest = <
|
|
|
199
252
|
if (transport === 'ws' && (upgradeHeader === null || upgradeHeader !== 'websocket')) {
|
|
200
253
|
return new Response('Durable Object expected Upgrade: websocket', {
|
|
201
254
|
status: 426,
|
|
202
|
-
headers,
|
|
255
|
+
...(headers !== undefined ? { headers } : {}),
|
|
203
256
|
})
|
|
204
257
|
}
|
|
205
258
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { SyncBackend, UnknownError } from '@livestore/common'
|
|
2
|
+
import { splitChunkBySize } from '@livestore/common/sync'
|
|
2
3
|
import { type CfTypes, layerProtocolDurableObject } from '@livestore/common-cf'
|
|
3
4
|
import { omit, shouldNeverHappen } from '@livestore/utils'
|
|
4
5
|
import {
|
|
6
|
+
Chunk,
|
|
5
7
|
Effect,
|
|
6
8
|
identity,
|
|
7
9
|
Layer,
|
|
@@ -13,7 +15,9 @@ import {
|
|
|
13
15
|
Stream,
|
|
14
16
|
SubscriptionRef,
|
|
15
17
|
} from '@livestore/utils/effect'
|
|
18
|
+
|
|
16
19
|
import type { SyncBackendRpcInterface } from '../../cf-worker/shared.ts'
|
|
20
|
+
import { MAX_DO_RPC_REQUEST_BYTES, MAX_PUSH_EVENTS_PER_REQUEST } from '../../common/constants.ts'
|
|
17
21
|
import { SyncDoRpc } from '../../common/do-rpc-schema.ts'
|
|
18
22
|
import { SyncMessage } from '../../common/mod.ts'
|
|
19
23
|
import type { SyncMetadata } from '../../common/sync-message-types.ts'
|
|
@@ -70,9 +74,9 @@ export const makeDoRpcSync =
|
|
|
70
74
|
})),
|
|
71
75
|
),
|
|
72
76
|
storeId,
|
|
73
|
-
rpcContext: options?.live ? { callerContext: durableObjectContext } : undefined,
|
|
77
|
+
rpcContext: options?.live === true ? { callerContext: durableObjectContext } : undefined,
|
|
74
78
|
}).pipe(
|
|
75
|
-
options?.live
|
|
79
|
+
options?.live === true
|
|
76
80
|
? Stream.concatWithLastElement((res) =>
|
|
77
81
|
Effect.gen(function* () {
|
|
78
82
|
if (res._tag === 'None')
|
|
@@ -90,30 +94,50 @@ export const makeDoRpcSync =
|
|
|
90
94
|
: identity,
|
|
91
95
|
Stream.tap((res) => backendIdHelper.lazySet(res.backendId)),
|
|
92
96
|
Stream.map((res) => omit(res, ['backendId'])),
|
|
93
|
-
Stream.mapError((cause) =>
|
|
97
|
+
Stream.mapError((cause) =>
|
|
98
|
+
cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError'
|
|
99
|
+
? cause
|
|
100
|
+
: new UnknownError({ cause }),
|
|
101
|
+
),
|
|
94
102
|
Stream.withSpan('rpc-sync-client:pull'),
|
|
95
103
|
)
|
|
96
104
|
|
|
97
|
-
const push: SyncBackend.SyncBackend<{ createdAt: string }>['push'] = (
|
|
98
|
-
|
|
105
|
+
const push: SyncBackend.SyncBackend<{ createdAt: string }>['push'] = Effect.fn('rpc-sync-client:push')(
|
|
106
|
+
function* (batch) {
|
|
99
107
|
if (batch.length === 0) {
|
|
100
108
|
return
|
|
101
109
|
}
|
|
102
110
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
:
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
111
|
+
const backendId = backendIdHelper.get()
|
|
112
|
+
const batchChunks = yield* Chunk.fromIterable(batch).pipe(
|
|
113
|
+
splitChunkBySize({
|
|
114
|
+
maxItems: MAX_PUSH_EVENTS_PER_REQUEST,
|
|
115
|
+
maxBytes: MAX_DO_RPC_REQUEST_BYTES,
|
|
116
|
+
encode: (items) => ({
|
|
117
|
+
batch: items,
|
|
118
|
+
storeId,
|
|
119
|
+
backendId,
|
|
120
|
+
}),
|
|
121
|
+
}),
|
|
122
|
+
Effect.mapError((cause) => new UnknownError({ cause })),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
for (const chunk of Chunk.toReadonlyArray(batchChunks)) {
|
|
126
|
+
const chunkArray = Chunk.toReadonlyArray(chunk)
|
|
127
|
+
yield* rpcClient.SyncDoRpc.Push({ batch: chunkArray, storeId, backendId })
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
Effect.mapError((cause) =>
|
|
131
|
+
cause._tag === 'UnknownError' || cause._tag === 'ServerAheadError' || cause._tag === 'BackendIdMismatchError'
|
|
132
|
+
? cause
|
|
133
|
+
: new UnknownError({ cause }),
|
|
134
|
+
),
|
|
135
|
+
)
|
|
112
136
|
|
|
113
137
|
const ping: SyncBackend.SyncBackend<{ createdAt: string }>['ping'] = rpcClient.SyncDoRpc.Ping({
|
|
114
138
|
storeId,
|
|
115
139
|
payload,
|
|
116
|
-
}).pipe(
|
|
140
|
+
}).pipe(UnknownError.mapToUnknownError, Effect.withSpan('rpc-sync-client:ping'))
|
|
117
141
|
|
|
118
142
|
return SyncBackend.of({
|
|
119
143
|
connect,
|
|
@@ -152,7 +176,7 @@ export const makeDoRpcSync =
|
|
|
152
176
|
export const handleSyncUpdateRpc = (payload: unknown) =>
|
|
153
177
|
Effect.gen(function* () {
|
|
154
178
|
const decodedPayload = yield* Schema.decodeUnknown(ResponseChunkEncoded)(payload)
|
|
155
|
-
const decoded = yield* Schema.decodeUnknown(SyncMessage.PullResponse)(decodedPayload.values[0]
|
|
179
|
+
const decoded = yield* Schema.decodeUnknown(SyncMessage.PullResponse)(decodedPayload.values[0])
|
|
156
180
|
|
|
157
181
|
const pullStreamMailbox = requestIdMailboxMap.get(decodedPayload.requestId)
|
|
158
182
|
|