@livestore/sync-cf 0.4.0-dev.8 → 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 +15 -14
- package/dist/cf-worker/do/durable-object.js.map +1 -1
- package/dist/cf-worker/do/layer.d.ts +6 -6
- 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 +8 -3
- package/dist/cf-worker/do/pull.d.ts.map +1 -1
- package/dist/cf-worker/do/pull.js +22 -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 +80 -41
- 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 +2 -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 +3 -1
- 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 +123 -30
- package/dist/cf-worker/shared.d.ts.map +1 -1
- package/dist/cf-worker/shared.js +50 -6
- package/dist/cf-worker/shared.js.map +1 -1
- package/dist/cf-worker/worker.d.ts +64 -71
- package/dist/cf-worker/worker.d.ts.map +1 -1
- package/dist/cf-worker/worker.js +70 -48
- 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 +2 -2
- 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 +23 -18
- package/src/cf-worker/do/layer.ts +35 -13
- package/src/cf-worker/do/pull.ts +43 -14
- package/src/cf-worker/do/push.ts +107 -46
- 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 +22 -9
- 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 +149 -25
- package/src/cf-worker/worker.ts +138 -108
- 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/src/cf-worker/shared.ts
CHANGED
|
@@ -1,26 +1,80 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { UnknownError } from '@livestore/common'
|
|
2
2
|
import type { CfTypes } from '@livestore/common-cf'
|
|
3
|
-
import { Effect,
|
|
3
|
+
import { Effect, Schema, UrlParams } from '@livestore/utils/effect'
|
|
4
|
+
|
|
5
|
+
import type { SearchParams } from '../common/mod.ts'
|
|
4
6
|
import { SearchParamsSchema, SyncMessage } from '../common/mod.ts'
|
|
5
7
|
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
10
26
|
}
|
|
11
27
|
|
|
12
28
|
export type MakeDurableObjectClassOptions = {
|
|
13
|
-
onPush?: (
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
) => Effect.SyncOrPromiseOrEffect<void>
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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 }
|
|
24
78
|
|
|
25
79
|
/**
|
|
26
80
|
* Enabled transports for sync backend
|
|
@@ -32,6 +86,26 @@ export type MakeDurableObjectClassOptions = {
|
|
|
32
86
|
*/
|
|
33
87
|
enabledTransports?: Set<'http' | 'ws' | 'do-rpc'>
|
|
34
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
|
+
|
|
35
109
|
otel?: {
|
|
36
110
|
baseUrl?: string
|
|
37
111
|
serviceName?: string
|
|
@@ -42,26 +116,42 @@ export type StoreId = string
|
|
|
42
116
|
export type DurableObjectId = string
|
|
43
117
|
|
|
44
118
|
/**
|
|
45
|
-
*
|
|
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
|
|
46
125
|
*
|
|
47
|
-
*
|
|
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
|
|
130
|
+
*
|
|
131
|
+
* Impact: Changing this version triggers a "soft reset" - new table names are created
|
|
132
|
+
* and old data becomes inaccessible (but remains in storage).
|
|
48
133
|
*/
|
|
49
134
|
export const PERSISTENCE_FORMAT_VERSION = 7
|
|
50
135
|
|
|
51
|
-
export const DEFAULT_SYNC_DURABLE_OBJECT_NAME = 'SYNC_BACKEND_DO'
|
|
52
|
-
|
|
53
136
|
export const encodeOutgoingMessage = Schema.encodeSync(Schema.parseJson(SyncMessage.BackendToClientMessage))
|
|
54
137
|
export const encodeIncomingMessage = Schema.encodeSync(Schema.parseJson(SyncMessage.ClientToBackendMessage))
|
|
55
138
|
|
|
56
|
-
|
|
139
|
+
/**
|
|
140
|
+
* Extracts the LiveStore sync search parameters from a request. Returns
|
|
141
|
+
* `undefined` when the request does not carry valid sync metadata so callers
|
|
142
|
+
* can fall back to custom routing.
|
|
143
|
+
*/
|
|
144
|
+
export const matchSyncRequest = (request: CfTypes.Request): SearchParams | undefined => {
|
|
57
145
|
const url = new URL(request.url)
|
|
58
146
|
const urlParams = UrlParams.fromInput(url.searchParams)
|
|
59
147
|
const paramsResult = UrlParams.schemaStruct(SearchParamsSchema)(urlParams).pipe(Effect.option, Effect.runSync)
|
|
60
148
|
|
|
61
|
-
|
|
62
|
-
|
|
149
|
+
if (paramsResult._tag === 'None') {
|
|
150
|
+
return undefined
|
|
151
|
+
}
|
|
63
152
|
|
|
64
|
-
|
|
153
|
+
return paramsResult.value
|
|
154
|
+
}
|
|
65
155
|
|
|
66
156
|
// RPC subscription storage (TODO refactor)
|
|
67
157
|
export type RpcSubscription = {
|
|
@@ -91,5 +181,39 @@ export const WebSocketAttachmentSchema = Schema.parseJson(
|
|
|
91
181
|
// Different for each websocket connection
|
|
92
182
|
payload: Schema.optional(Schema.JsonValue),
|
|
93
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 })),
|
|
94
186
|
}),
|
|
95
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,42 +1,16 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
import {
|
|
1
|
+
import { env as importedEnv } from 'cloudflare:workers'
|
|
2
|
+
|
|
3
|
+
import { UnknownError } from '@livestore/common'
|
|
4
|
+
import type { HelperTypes } from '@livestore/common-cf'
|
|
5
|
+
import { Effect, Schema } from '@livestore/utils/effect'
|
|
6
|
+
|
|
4
7
|
import type { CfTypes, SearchParams } from '../common/mod.ts'
|
|
5
8
|
import type { CfDeclare } from './mod.ts'
|
|
6
|
-
import {
|
|
9
|
+
import { type Env, type ForwardedHeaders, matchSyncRequest } from './shared.ts'
|
|
7
10
|
|
|
8
11
|
// NOTE We need to redeclare runtime types here to avoid type conflicts with the lib.dom Response type.
|
|
9
12
|
declare class Response extends CfDeclare.Response {}
|
|
10
13
|
|
|
11
|
-
export namespace HelperTypes {
|
|
12
|
-
type AnyDON = CfTypes.DurableObjectNamespace<undefined>
|
|
13
|
-
|
|
14
|
-
type DOKeys<T> = {
|
|
15
|
-
[K in keyof T]-?: T[K] extends AnyDON ? K : never
|
|
16
|
-
}[keyof T]
|
|
17
|
-
|
|
18
|
-
type NonBuiltins<T> = Omit<T, keyof Env>
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Helper type to extract DurableObject keys from Env to give consumer type safety.
|
|
22
|
-
*
|
|
23
|
-
* @example
|
|
24
|
-
* ```ts
|
|
25
|
-
* type PlatformEnv = {
|
|
26
|
-
* DB: D1Database
|
|
27
|
-
* ADMIN_TOKEN: string
|
|
28
|
-
* SYNC_BACKEND_DO: DurableObjectNamespace<SyncBackendDO>
|
|
29
|
-
* }
|
|
30
|
-
* export default makeWorker<PlatformEnv>({
|
|
31
|
-
* durableObject: { name: "SYNC_BACKEND_DO" },
|
|
32
|
-
* // ^ (property) name?: "SYNC_BACKEND_DO" | undefined
|
|
33
|
-
* });
|
|
34
|
-
*/
|
|
35
|
-
export type ExtractDurableObjectKeys<TEnv = Env> = DOKeys<NonBuiltins<TEnv>> extends never
|
|
36
|
-
? string
|
|
37
|
-
: DOKeys<NonBuiltins<TEnv>>
|
|
38
|
-
}
|
|
39
|
-
|
|
40
14
|
// HINT: If we ever extend user's custom worker RPC, type T can help here with expected return type safety. Currently unused.
|
|
41
15
|
export type CFWorker<TEnv extends Env = Env, _T extends CfTypes.Rpc.DurableObjectBranded | undefined = undefined> = {
|
|
42
16
|
fetch: <CFHostMetada = unknown>(
|
|
@@ -46,64 +20,97 @@ export type CFWorker<TEnv extends Env = Env, _T extends CfTypes.Rpc.DurableObjec
|
|
|
46
20
|
) => Promise<CfTypes.Response>
|
|
47
21
|
}
|
|
48
22
|
|
|
49
|
-
|
|
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
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Options accepted by {@link makeWorker}. The Durable Object binding has to be
|
|
32
|
+
* supplied explicitly so we never fall back to deprecated defaults when Cloudflare config changes.
|
|
33
|
+
*/
|
|
34
|
+
export type MakeWorkerOptions<TEnv extends Env = Env, TSyncPayload = Schema.JsonValue> = {
|
|
50
35
|
/**
|
|
51
|
-
*
|
|
36
|
+
* Binding name of the sync Durable Object declared in wrangler config.
|
|
37
|
+
*/
|
|
38
|
+
syncBackendBinding: HelperTypes.ExtractDurableObjectKeys<TEnv>
|
|
39
|
+
/**
|
|
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
|
+
*
|
|
52
59
|
* Note: This runs only at connection time, not for individual push events.
|
|
53
|
-
* For push event validation, use the `onPush` callback in the
|
|
60
|
+
* For push event validation, use the `onPush` callback in the Durable Object.
|
|
54
61
|
*/
|
|
55
|
-
validatePayload?: (payload:
|
|
62
|
+
validatePayload?: (payload: TSyncPayload, context: ValidatePayloadContext) => void | Promise<void>
|
|
56
63
|
/** @default false */
|
|
57
64
|
enableCORS?: boolean
|
|
58
|
-
durableObject?: {
|
|
59
|
-
/**
|
|
60
|
-
* Needs to match the binding name from the wrangler config
|
|
61
|
-
*
|
|
62
|
-
* @default 'SYNC_BACKEND_DO'
|
|
63
|
-
*/
|
|
64
|
-
name?: HelperTypes.ExtractDurableObjectKeys<TEnv>
|
|
65
|
-
}
|
|
66
65
|
}
|
|
67
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Produces a Cloudflare Worker `fetch` handler that delegates sync traffic to the
|
|
69
|
+
* Durable Object identified by `syncBackendBinding`.
|
|
70
|
+
*
|
|
71
|
+
* For more complex setups prefer implementing a custom `fetch` and call {@link handleSyncRequest}
|
|
72
|
+
* from the branch that handles LiveStore sync requests.
|
|
73
|
+
*/
|
|
68
74
|
export const makeWorker = <
|
|
69
75
|
TEnv extends Env = Env,
|
|
70
76
|
TDurableObjectRpc extends CfTypes.Rpc.DurableObjectBranded | undefined = undefined,
|
|
77
|
+
TSyncPayload = Schema.JsonValue,
|
|
71
78
|
>(
|
|
72
|
-
options: MakeWorkerOptions<TEnv
|
|
79
|
+
options: MakeWorkerOptions<TEnv, TSyncPayload>,
|
|
73
80
|
): CFWorker<TEnv, TDurableObjectRpc> => {
|
|
74
81
|
return {
|
|
75
82
|
fetch: async (request, env, _ctx) => {
|
|
76
83
|
const url = new URL(request.url)
|
|
77
84
|
|
|
78
|
-
const corsHeaders: CfTypes.HeadersInit =
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
+
: {}
|
|
85
93
|
|
|
86
|
-
if (request.method === 'OPTIONS' && options.enableCORS) {
|
|
94
|
+
if (request.method === 'OPTIONS' && options.enableCORS === true) {
|
|
87
95
|
return new Response(null, {
|
|
88
96
|
status: 204,
|
|
89
97
|
headers: corsHeaders,
|
|
90
98
|
})
|
|
91
99
|
}
|
|
92
100
|
|
|
93
|
-
const
|
|
101
|
+
const searchParams = matchSyncRequest(request)
|
|
94
102
|
|
|
95
103
|
// Check if this is a sync request first, before showing info message
|
|
96
|
-
if (
|
|
97
|
-
return handleSyncRequest<TEnv, TDurableObjectRpc>({
|
|
104
|
+
if (searchParams !== undefined) {
|
|
105
|
+
return handleSyncRequest<TEnv, TDurableObjectRpc, unknown, TSyncPayload>({
|
|
98
106
|
request,
|
|
99
|
-
searchParams
|
|
107
|
+
searchParams,
|
|
100
108
|
env,
|
|
101
109
|
ctx: _ctx,
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
},
|
|
110
|
+
syncBackendBinding: options.syncBackendBinding,
|
|
111
|
+
headers: corsHeaders,
|
|
112
|
+
validatePayload: options.validatePayload,
|
|
113
|
+
syncPayloadSchema: options.syncPayloadSchema,
|
|
107
114
|
})
|
|
108
115
|
}
|
|
109
116
|
|
|
@@ -129,89 +136,112 @@ export const makeWorker = <
|
|
|
129
136
|
}
|
|
130
137
|
}
|
|
131
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
|
+
|
|
132
148
|
/**
|
|
133
|
-
* Handles
|
|
149
|
+
* Handles LiveStore sync requests (e.g. with search params `?storeId=...&transport=...`).
|
|
134
150
|
*
|
|
135
|
-
* @example
|
|
151
|
+
* @example Token-based authentication
|
|
136
152
|
* ```ts
|
|
137
153
|
* const validatePayload = (payload: Schema.JsonValue | undefined, context: { storeId: string }) => {
|
|
138
|
-
* console.log(`Validating connection for store: ${context.storeId}`)
|
|
139
154
|
* if (payload?.authToken !== 'insecure-token-change-me') {
|
|
140
155
|
* throw new Error('Invalid auth token')
|
|
141
156
|
* }
|
|
142
157
|
* }
|
|
158
|
+
* ```
|
|
143
159
|
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
* return handleSyncRequest({
|
|
151
|
-
* request,
|
|
152
|
-
* searchParams: requestParamsResult.value,
|
|
153
|
-
* env,
|
|
154
|
-
* ctx,
|
|
155
|
-
* options: { headers: {}, validatePayload }
|
|
156
|
-
* })
|
|
157
|
-
* }
|
|
158
|
-
*
|
|
159
|
-
* return new Response('Invalid path', { status: 400 })
|
|
160
|
-
* }
|
|
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')
|
|
161
166
|
* }
|
|
162
167
|
* ```
|
|
163
168
|
*
|
|
164
|
-
* @throws {
|
|
169
|
+
* @throws {UnknownError} If the payload is invalid
|
|
165
170
|
*/
|
|
166
171
|
export const handleSyncRequest = <
|
|
167
172
|
TEnv extends Env = Env,
|
|
168
173
|
TDurableObjectRpc extends CfTypes.Rpc.DurableObjectBranded | undefined = undefined,
|
|
169
174
|
CFHostMetada = unknown,
|
|
175
|
+
TSyncPayload = Schema.JsonValue,
|
|
170
176
|
>({
|
|
171
177
|
request,
|
|
172
|
-
searchParams,
|
|
173
|
-
env,
|
|
174
|
-
|
|
178
|
+
searchParams: { storeId, payload, transport },
|
|
179
|
+
env: explicitlyProvidedEnv,
|
|
180
|
+
syncBackendBinding,
|
|
181
|
+
headers,
|
|
182
|
+
validatePayload,
|
|
183
|
+
syncPayloadSchema,
|
|
175
184
|
}: {
|
|
176
185
|
request: CfTypes.Request<CFHostMetada>
|
|
177
186
|
searchParams: SearchParams
|
|
178
|
-
env
|
|
187
|
+
env?: TEnv | undefined
|
|
179
188
|
/** Only there for type-level reasons */
|
|
180
189
|
ctx: CfTypes.ExecutionContext
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
190
|
+
/** Binding name of the sync backend Durable Object */
|
|
191
|
+
syncBackendBinding: MakeWorkerOptions<TEnv, TSyncPayload>['syncBackendBinding']
|
|
192
|
+
headers?: CfTypes.HeadersInit | undefined
|
|
193
|
+
validatePayload?: MakeWorkerOptions<TEnv, TSyncPayload>['validatePayload']
|
|
194
|
+
syncPayloadSchema?: MakeWorkerOptions<TEnv, TSyncPayload>['syncPayloadSchema']
|
|
186
195
|
}): Promise<CfTypes.Response> =>
|
|
187
196
|
Effect.gen(function* () {
|
|
188
|
-
|
|
197
|
+
if (validatePayload !== undefined) {
|
|
198
|
+
// Convert request headers to a Map for the validation context
|
|
199
|
+
const requestHeaders = requestHeadersToMap(request)
|
|
189
200
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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)
|
|
195
214
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
+
}
|
|
199
228
|
}
|
|
200
229
|
}
|
|
201
230
|
|
|
202
|
-
const
|
|
203
|
-
|
|
231
|
+
const env = explicitlyProvidedEnv ?? (importedEnv as TEnv)
|
|
232
|
+
|
|
233
|
+
if (!(syncBackendBinding in env)) {
|
|
204
234
|
return new Response(
|
|
205
|
-
`Failed dependency: Required Durable Object binding '${
|
|
235
|
+
`Failed dependency: Required Durable Object binding '${syncBackendBinding as string}' not available`,
|
|
206
236
|
{
|
|
207
237
|
status: 424,
|
|
208
|
-
headers:
|
|
238
|
+
...(headers !== undefined ? { headers } : {}),
|
|
209
239
|
},
|
|
210
240
|
)
|
|
211
241
|
}
|
|
212
242
|
|
|
213
243
|
const durableObjectNamespace = env[
|
|
214
|
-
|
|
244
|
+
syncBackendBinding as keyof TEnv
|
|
215
245
|
] as CfTypes.DurableObjectNamespace<TDurableObjectRpc>
|
|
216
246
|
|
|
217
247
|
const id = durableObjectNamespace.idFromName(storeId)
|
|
@@ -222,7 +252,7 @@ export const handleSyncRequest = <
|
|
|
222
252
|
if (transport === 'ws' && (upgradeHeader === null || upgradeHeader !== 'websocket')) {
|
|
223
253
|
return new Response('Durable Object expected Upgrade: websocket', {
|
|
224
254
|
status: 426,
|
|
225
|
-
headers:
|
|
255
|
+
...(headers !== undefined ? { headers } : {}),
|
|
226
256
|
})
|
|
227
257
|
}
|
|
228
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
|
|