@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.
Files changed (104) 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 +13 -8
  6. package/dist/cf-worker/do/durable-object.js.map +1 -1
  7. package/dist/cf-worker/do/layer.d.ts +5 -5
  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 +7 -2
  12. package/dist/cf-worker/do/pull.d.ts.map +1 -1
  13. package/dist/cf-worker/do/pull.js +16 -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 +25 -17
  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.map +1 -1
  28. package/dist/cf-worker/do/transport/do-rpc-server.js +13 -7
  29. package/dist/cf-worker/do/transport/do-rpc-server.js.map +1 -1
  30. package/dist/cf-worker/do/transport/http-rpc-server.d.ts +4 -2
  31. package/dist/cf-worker/do/transport/http-rpc-server.d.ts.map +1 -1
  32. package/dist/cf-worker/do/transport/http-rpc-server.js +24 -15
  33. package/dist/cf-worker/do/transport/http-rpc-server.js.map +1 -1
  34. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts +2 -1
  35. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts.map +1 -1
  36. package/dist/cf-worker/do/transport/ws-rpc-server.js +30 -8
  37. package/dist/cf-worker/do/transport/ws-rpc-server.js.map +1 -1
  38. package/dist/cf-worker/shared.d.ts +118 -31
  39. package/dist/cf-worker/shared.d.ts.map +1 -1
  40. package/dist/cf-worker/shared.js +40 -7
  41. package/dist/cf-worker/shared.js.map +1 -1
  42. package/dist/cf-worker/worker.d.ts +46 -38
  43. package/dist/cf-worker/worker.d.ts.map +1 -1
  44. package/dist/cf-worker/worker.js +51 -34
  45. package/dist/cf-worker/worker.js.map +1 -1
  46. package/dist/client/transport/do-rpc-client.d.ts.map +1 -1
  47. package/dist/client/transport/do-rpc-client.js +27 -10
  48. package/dist/client/transport/do-rpc-client.js.map +1 -1
  49. package/dist/client/transport/http-rpc-client.d.ts.map +1 -1
  50. package/dist/client/transport/http-rpc-client.js +29 -9
  51. package/dist/client/transport/http-rpc-client.js.map +1 -1
  52. package/dist/client/transport/ws-rpc-client.d.ts +2 -1
  53. package/dist/client/transport/ws-rpc-client.d.ts.map +1 -1
  54. package/dist/client/transport/ws-rpc-client.js +31 -17
  55. package/dist/client/transport/ws-rpc-client.js.map +1 -1
  56. package/dist/common/constants.d.ts +7 -0
  57. package/dist/common/constants.d.ts.map +1 -0
  58. package/dist/common/constants.js +17 -0
  59. package/dist/common/constants.js.map +1 -0
  60. package/dist/common/do-rpc-schema.d.ts +6 -6
  61. package/dist/common/do-rpc-schema.d.ts.map +1 -1
  62. package/dist/common/do-rpc-schema.js +4 -4
  63. package/dist/common/do-rpc-schema.js.map +1 -1
  64. package/dist/common/http-rpc-schema.d.ts +4 -4
  65. package/dist/common/http-rpc-schema.d.ts.map +1 -1
  66. package/dist/common/http-rpc-schema.js +4 -4
  67. package/dist/common/http-rpc-schema.js.map +1 -1
  68. package/dist/common/mod.d.ts +4 -1
  69. package/dist/common/mod.d.ts.map +1 -1
  70. package/dist/common/mod.js +4 -1
  71. package/dist/common/mod.js.map +1 -1
  72. package/dist/common/sync-message-types.d.ts +7 -7
  73. package/dist/common/sync-message-types.js +3 -3
  74. package/dist/common/sync-message-types.js.map +1 -1
  75. package/dist/common/ws-rpc-schema.d.ts +3 -3
  76. package/dist/common/ws-rpc-schema.d.ts.map +1 -1
  77. package/dist/common/ws-rpc-schema.js +3 -3
  78. package/dist/common/ws-rpc-schema.js.map +1 -1
  79. package/package.json +72 -14
  80. package/src/cf-worker/do/durable-object.ts +19 -10
  81. package/src/cf-worker/do/layer.ts +35 -13
  82. package/src/cf-worker/do/pull.ts +31 -14
  83. package/src/cf-worker/do/push.ts +49 -34
  84. package/src/cf-worker/do/sqlite.ts +14 -4
  85. package/src/cf-worker/do/sync-storage.ts +151 -31
  86. package/src/cf-worker/do/transport/do-rpc-server.ts +18 -7
  87. package/src/cf-worker/do/transport/http-rpc-server.ts +33 -13
  88. package/src/cf-worker/do/transport/ws-rpc-server.ts +40 -12
  89. package/src/cf-worker/shared.ts +136 -25
  90. package/src/cf-worker/worker.ts +107 -54
  91. package/src/client/transport/do-rpc-client.ts +41 -17
  92. package/src/client/transport/http-rpc-client.ts +43 -17
  93. package/src/client/transport/ws-rpc-client.ts +42 -19
  94. package/src/common/constants.ts +18 -0
  95. package/src/common/do-rpc-schema.ts +5 -4
  96. package/src/common/http-rpc-schema.ts +5 -4
  97. package/src/common/mod.ts +4 -2
  98. package/src/common/sync-message-types.ts +3 -3
  99. package/src/common/ws-rpc-schema.ts +4 -3
  100. package/dist/cf-worker/do/ws-chunking.d.ts +0 -22
  101. package/dist/cf-worker/do/ws-chunking.d.ts.map +0 -1
  102. package/dist/cf-worker/do/ws-chunking.js +0 -49
  103. package/dist/cf-worker/do/ws-chunking.js.map +0 -1
  104. package/src/cf-worker/do/ws-chunking.ts +0 -76
@@ -1,34 +1,62 @@
1
- import { InvalidPullError, InvalidPushError } from '@livestore/common'
2
- import { Effect, identity, Layer, RpcServer, Stream } from '@livestore/utils/effect'
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
- makeEndingPullStream(req, req.payload).pipe(
13
- // Needed to keep the stream alive on the client side for phase 2 (i.e. not send the `Exit` stream RPC message)
14
- req.live ? Stream.concat(Stream.never) : identity,
15
- Stream.provideLayer(DoCtx.Default({ doSelf, doOptions, from: { storeId: req.storeId } })),
16
- Stream.mapError((cause) => (cause._tag === 'InvalidPullError' ? cause : InvalidPullError.make({ cause }))),
17
- // Stream.tapErrorCause(Effect.log),
18
- ),
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) => (cause._tag === 'InvalidPushError' ? cause : InvalidPushError.make({ 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
+ })
@@ -1,27 +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
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 interface Env {
8
- /** Eventlog database */
9
- DB: CfTypes.D1Database
10
- 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
11
26
  }
12
27
 
13
28
  export type MakeDurableObjectClassOptions = {
14
- onPush?: (
15
- message: SyncMessage.PushRequest,
16
- context: { storeId: StoreId; payload?: Schema.JsonValue },
17
- ) => Effect.SyncOrPromiseOrEffect<void>
18
- onPushRes?: (message: SyncMessage.PushAck | InvalidPushError) => Effect.SyncOrPromiseOrEffect<void>
19
- onPull?: (
20
- message: SyncMessage.PullRequest,
21
- context: { storeId: StoreId; payload?: Schema.JsonValue },
22
- ) => Effect.SyncOrPromiseOrEffect<void>
23
- onPullRes?: (message: SyncMessage.PullResponse | InvalidPullError) => Effect.SyncOrPromiseOrEffect<void>
24
- // 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 }
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
- * 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
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 number will lead to a "soft reset".
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
+ }
@@ -1,10 +1,12 @@
1
- import { UnexpectedError } from '@livestore/common'
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 type { Schema } from '@livestore/utils/effect'
4
- import { Effect } from '@livestore/utils/effect'
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
- * Validates the payload during WebSocket connection establishment.
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 durable object.
60
+ * For push event validation, use the `onPush` callback in the Durable Object.
34
61
  */
35
- validatePayload?: (payload: Schema.JsonValue | undefined, context: { storeId: string }) => void | Promise<void>
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 = options.enableCORS
58
- ? {
59
- 'Access-Control-Allow-Origin': '*',
60
- 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
61
- 'Access-Control-Allow-Headers': request.headers.get('Access-Control-Request-Headers') ?? '*',
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 `/sync` endpoint.
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
- * export default {
122
- * fetch: async (request, env, ctx) => {
123
- * const searchParams = matchSyncRequest(request)
124
- *
125
- * // Is LiveStore sync request
126
- * if (searchParams !== undefined) {
127
- * return handleSyncRequest({
128
- * request,
129
- * searchParams,
130
- * env,
131
- * ctx,
132
- * syncBackendBinding: 'SYNC_BACKEND_DO',
133
- * headers: {},
134
- * validatePayload,
135
- * })
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 {UnexpectedError} If the payload is invalid
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: TEnv
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?: (payload: Schema.JsonValue | undefined, context: { storeId: string }) => void | Promise<void>
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
- const result = yield* Effect.promise(async () => validatePayload!(payload, { storeId })).pipe(
170
- UnexpectedError.mapToUnexpectedError,
171
- Effect.either,
172
- )
198
+ // Convert request headers to a Map for the validation context
199
+ const requestHeaders = requestHeadersToMap(request)
173
200
 
174
- if (result._tag === 'Left') {
175
- console.error('Invalid payload', result.left)
176
- return new Response(result.left.toString(), { status: 400, headers })
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 { 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