@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.
Files changed (100) hide show
  1. package/README.md +7 -8
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/cf-worker/do/durable-object.d.ts +1 -1
  4. package/dist/cf-worker/do/durable-object.d.ts.map +1 -1
  5. package/dist/cf-worker/do/durable-object.js +15 -14
  6. package/dist/cf-worker/do/durable-object.js.map +1 -1
  7. package/dist/cf-worker/do/layer.d.ts +6 -6
  8. package/dist/cf-worker/do/layer.d.ts.map +1 -1
  9. package/dist/cf-worker/do/layer.js +32 -9
  10. package/dist/cf-worker/do/layer.js.map +1 -1
  11. package/dist/cf-worker/do/pull.d.ts +8 -3
  12. package/dist/cf-worker/do/pull.d.ts.map +1 -1
  13. package/dist/cf-worker/do/pull.js +22 -10
  14. package/dist/cf-worker/do/pull.js.map +1 -1
  15. package/dist/cf-worker/do/push.d.ts +5 -4
  16. package/dist/cf-worker/do/push.d.ts.map +1 -1
  17. package/dist/cf-worker/do/push.js +80 -41
  18. package/dist/cf-worker/do/push.js.map +1 -1
  19. package/dist/cf-worker/do/sqlite.d.ts +10 -1
  20. package/dist/cf-worker/do/sqlite.d.ts.map +1 -1
  21. package/dist/cf-worker/do/sqlite.js +13 -4
  22. package/dist/cf-worker/do/sqlite.js.map +1 -1
  23. package/dist/cf-worker/do/sync-storage.d.ts +14 -9
  24. package/dist/cf-worker/do/sync-storage.d.ts.map +1 -1
  25. package/dist/cf-worker/do/sync-storage.js +92 -18
  26. package/dist/cf-worker/do/sync-storage.js.map +1 -1
  27. package/dist/cf-worker/do/transport/do-rpc-server.d.ts +2 -1
  28. package/dist/cf-worker/do/transport/do-rpc-server.d.ts.map +1 -1
  29. package/dist/cf-worker/do/transport/do-rpc-server.js +13 -7
  30. package/dist/cf-worker/do/transport/do-rpc-server.js.map +1 -1
  31. package/dist/cf-worker/do/transport/http-rpc-server.d.ts +3 -1
  32. package/dist/cf-worker/do/transport/http-rpc-server.d.ts.map +1 -1
  33. package/dist/cf-worker/do/transport/http-rpc-server.js +24 -15
  34. package/dist/cf-worker/do/transport/http-rpc-server.js.map +1 -1
  35. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts +2 -1
  36. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts.map +1 -1
  37. package/dist/cf-worker/do/transport/ws-rpc-server.js +30 -8
  38. package/dist/cf-worker/do/transport/ws-rpc-server.js.map +1 -1
  39. package/dist/cf-worker/shared.d.ts +123 -30
  40. package/dist/cf-worker/shared.d.ts.map +1 -1
  41. package/dist/cf-worker/shared.js +50 -6
  42. package/dist/cf-worker/shared.js.map +1 -1
  43. package/dist/cf-worker/worker.d.ts +64 -71
  44. package/dist/cf-worker/worker.d.ts.map +1 -1
  45. package/dist/cf-worker/worker.js +70 -48
  46. package/dist/cf-worker/worker.js.map +1 -1
  47. package/dist/client/transport/do-rpc-client.d.ts.map +1 -1
  48. package/dist/client/transport/do-rpc-client.js +27 -10
  49. package/dist/client/transport/do-rpc-client.js.map +1 -1
  50. package/dist/client/transport/http-rpc-client.d.ts.map +1 -1
  51. package/dist/client/transport/http-rpc-client.js +29 -9
  52. package/dist/client/transport/http-rpc-client.js.map +1 -1
  53. package/dist/client/transport/ws-rpc-client.d.ts +2 -1
  54. package/dist/client/transport/ws-rpc-client.d.ts.map +1 -1
  55. package/dist/client/transport/ws-rpc-client.js +31 -17
  56. package/dist/client/transport/ws-rpc-client.js.map +1 -1
  57. package/dist/common/constants.d.ts +7 -0
  58. package/dist/common/constants.d.ts.map +1 -0
  59. package/dist/common/constants.js +17 -0
  60. package/dist/common/constants.js.map +1 -0
  61. package/dist/common/do-rpc-schema.d.ts +6 -6
  62. package/dist/common/do-rpc-schema.d.ts.map +1 -1
  63. package/dist/common/do-rpc-schema.js +4 -4
  64. package/dist/common/do-rpc-schema.js.map +1 -1
  65. package/dist/common/http-rpc-schema.d.ts +4 -4
  66. package/dist/common/http-rpc-schema.d.ts.map +1 -1
  67. package/dist/common/http-rpc-schema.js +4 -4
  68. package/dist/common/http-rpc-schema.js.map +1 -1
  69. package/dist/common/mod.d.ts +4 -1
  70. package/dist/common/mod.d.ts.map +1 -1
  71. package/dist/common/mod.js +4 -1
  72. package/dist/common/mod.js.map +1 -1
  73. package/dist/common/sync-message-types.d.ts +2 -2
  74. package/dist/common/sync-message-types.js +3 -3
  75. package/dist/common/sync-message-types.js.map +1 -1
  76. package/dist/common/ws-rpc-schema.d.ts +3 -3
  77. package/dist/common/ws-rpc-schema.d.ts.map +1 -1
  78. package/dist/common/ws-rpc-schema.js +3 -3
  79. package/dist/common/ws-rpc-schema.js.map +1 -1
  80. package/package.json +72 -14
  81. package/src/cf-worker/do/durable-object.ts +23 -18
  82. package/src/cf-worker/do/layer.ts +35 -13
  83. package/src/cf-worker/do/pull.ts +43 -14
  84. package/src/cf-worker/do/push.ts +107 -46
  85. package/src/cf-worker/do/sqlite.ts +14 -4
  86. package/src/cf-worker/do/sync-storage.ts +151 -31
  87. package/src/cf-worker/do/transport/do-rpc-server.ts +22 -9
  88. package/src/cf-worker/do/transport/http-rpc-server.ts +33 -13
  89. package/src/cf-worker/do/transport/ws-rpc-server.ts +40 -12
  90. package/src/cf-worker/shared.ts +149 -25
  91. package/src/cf-worker/worker.ts +138 -108
  92. package/src/client/transport/do-rpc-client.ts +41 -17
  93. package/src/client/transport/http-rpc-client.ts +43 -17
  94. package/src/client/transport/ws-rpc-client.ts +42 -19
  95. package/src/common/constants.ts +18 -0
  96. package/src/common/do-rpc-schema.ts +5 -4
  97. package/src/common/http-rpc-schema.ts +5 -4
  98. package/src/common/mod.ts +4 -2
  99. package/src/common/sync-message-types.ts +3 -3
  100. package/src/common/ws-rpc-schema.ts +4 -3
@@ -1,26 +1,80 @@
1
- import type { InvalidPullError, InvalidPushError } from '@livestore/common'
1
+ import type { UnknownError } from '@livestore/common'
2
2
  import type { CfTypes } from '@livestore/common-cf'
3
- import { Effect, type Option, Schema, UrlParams } from '@livestore/utils/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 interface Env {
7
- /** Eventlog database */
8
- DB: CfTypes.D1Database
9
- ADMIN_SECRET: string
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
- message: SyncMessage.PushRequest,
15
- context: { storeId: StoreId; payload?: Schema.JsonValue },
16
- ) => Effect.SyncOrPromiseOrEffect<void>
17
- onPushRes?: (message: SyncMessage.PushAck | InvalidPushError) => Effect.SyncOrPromiseOrEffect<void>
18
- onPull?: (
19
- message: SyncMessage.PullRequest,
20
- context: { storeId: StoreId; payload?: Schema.JsonValue },
21
- ) => Effect.SyncOrPromiseOrEffect<void>
22
- onPullRes?: (message: SyncMessage.PullResponse | InvalidPullError) => Effect.SyncOrPromiseOrEffect<void>
23
- // TODO make storage configurable: D1, DO SQLite, later: external SQLite
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
- * Needs to be bumped when the storage format changes (e.g. eventlogTable schema changes)
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
- * Changing this version number will lead to a "soft reset".
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
- export const getSyncRequestSearchParams = (request: CfTypes.Request): Option.Option<typeof SearchParamsSchema.Type> => {
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
- return paramsResult
62
- }
149
+ if (paramsResult._tag === 'None') {
150
+ return undefined
151
+ }
63
152
 
64
- export const PULL_CHUNK_SIZE = 100
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
+ }
@@ -1,42 +1,16 @@
1
- import { UnexpectedError } from '@livestore/common'
2
- import type { Schema } from '@livestore/utils/effect'
3
- import { Effect } from '@livestore/utils/effect'
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 { DEFAULT_SYNC_DURABLE_OBJECT_NAME, type Env, getSyncRequestSearchParams } from './shared.ts'
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
- export type MakeWorkerOptions<TEnv extends Env = Env> = {
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
- * Validates the payload during WebSocket connection establishment.
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 durable object.
60
+ * For push event validation, use the `onPush` callback in the Durable Object.
54
61
  */
55
- validatePayload?: (payload: Schema.JsonValue | undefined, context: { storeId: string }) => void | Promise<void>
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 = options.enableCORS
79
- ? {
80
- 'Access-Control-Allow-Origin': '*',
81
- 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
82
- 'Access-Control-Allow-Headers': request.headers.get('Access-Control-Request-Headers') ?? '*',
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 requestParamsResult = getSyncRequestSearchParams(request)
101
+ const searchParams = matchSyncRequest(request)
94
102
 
95
103
  // Check if this is a sync request first, before showing info message
96
- if (requestParamsResult._tag === 'Some') {
97
- return handleSyncRequest<TEnv, TDurableObjectRpc>({
104
+ if (searchParams !== undefined) {
105
+ return handleSyncRequest<TEnv, TDurableObjectRpc, unknown, TSyncPayload>({
98
106
  request,
99
- searchParams: requestParamsResult.value,
107
+ searchParams,
100
108
  env,
101
109
  ctx: _ctx,
102
- options: {
103
- headers: corsHeaders,
104
- validatePayload: options.validatePayload,
105
- durableObject: options.durableObject,
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 `/sync` endpoint.
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
- * export default {
145
- * fetch: async (request, env, ctx) => {
146
- * const requestParamsResult = getSyncRequestSearchParams(request)
147
- *
148
- * // Is LiveStore sync request
149
- * if (requestParamsResult._tag === 'Some') {
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 {UnexpectedError} If the payload is invalid
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
- options = {},
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: TEnv
187
+ env?: TEnv | undefined
179
188
  /** Only there for type-level reasons */
180
189
  ctx: CfTypes.ExecutionContext
181
- options?: {
182
- headers?: CfTypes.HeadersInit
183
- durableObject?: MakeWorkerOptions<TEnv>['durableObject']
184
- validatePayload?: (payload: Schema.JsonValue | undefined, context: { storeId: string }) => void | Promise<void>
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
- const { storeId, payload, transport } = searchParams
197
+ if (validatePayload !== undefined) {
198
+ // Convert request headers to a Map for the validation context
199
+ const requestHeaders = requestHeadersToMap(request)
189
200
 
190
- if (options.validatePayload !== undefined) {
191
- const result = yield* Effect.promise(async () => options.validatePayload!(payload, { storeId })).pipe(
192
- UnexpectedError.mapToUnexpectedError,
193
- Effect.either,
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
- if (result._tag === 'Left') {
197
- console.error('Invalid payload', result.left)
198
- return new Response(result.left.toString(), { status: 400, headers: options.headers })
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 durableObjectName = options.durableObject?.name ?? DEFAULT_SYNC_DURABLE_OBJECT_NAME
203
- if (!(durableObjectName in env)) {
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 '${durableObjectName as string}' not available`,
235
+ `Failed dependency: Required Durable Object binding '${syncBackendBinding as string}' not available`,
206
236
  {
207
237
  status: 424,
208
- headers: options.headers,
238
+ ...(headers !== undefined ? { headers } : {}),
209
239
  },
210
240
  )
211
241
  }
212
242
 
213
243
  const durableObjectNamespace = env[
214
- durableObjectName as keyof TEnv
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: options?.headers,
255
+ ...(headers !== undefined ? { headers } : {}),
226
256
  })
227
257
  }
228
258
 
@@ -1,7 +1,9 @@
1
- import { InvalidPullError, InvalidPushError, SyncBackend, UnexpectedError } from '@livestore/common'
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) => (cause._tag === 'InvalidPullError' ? cause : InvalidPullError.make({ 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'] = (batch) =>
98
- Effect.gen(function* () {
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
- yield* rpcClient.SyncDoRpc.Push({ batch, storeId, backendId: backendIdHelper.get() })
104
- }).pipe(
105
- Effect.mapError((cause) =>
106
- cause._tag === 'InvalidPushError'
107
- ? cause
108
- : InvalidPushError.make({ cause: new UnexpectedError({ cause }) }),
109
- ),
110
- Effect.withSpan('rpc-sync-client:push'),
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(UnexpectedError.mapToUnexpectedError, Effect.withSpan('rpc-sync-client:ping'))
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