@livestore/sync-cf 0.4.0-dev.9 → 0.5.0-dev.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 (113) 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 +36 -18
  6. package/dist/cf-worker/do/durable-object.js.map +1 -1
  7. package/dist/cf-worker/do/layer.d.ts +24 -20
  8. package/dist/cf-worker/do/layer.d.ts.map +1 -1
  9. package/dist/cf-worker/do/layer.js +57 -24
  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 +26 -21
  14. package/dist/cf-worker/do/pull.js.map +1 -1
  15. package/dist/cf-worker/do/push.d.ts +20 -6
  16. package/dist/cf-worker/do/push.d.ts.map +1 -1
  17. package/dist/cf-worker/do/push.js +84 -56
  18. package/dist/cf-worker/do/push.js.map +1 -1
  19. package/dist/cf-worker/do/push.test.d.ts +2 -0
  20. package/dist/cf-worker/do/push.test.d.ts.map +1 -0
  21. package/dist/cf-worker/do/push.test.js +30 -0
  22. package/dist/cf-worker/do/push.test.js.map +1 -0
  23. package/dist/cf-worker/do/sqlite.d.ts +49 -52
  24. package/dist/cf-worker/do/sqlite.d.ts.map +1 -1
  25. package/dist/cf-worker/do/sqlite.js +14 -5
  26. package/dist/cf-worker/do/sqlite.js.map +1 -1
  27. package/dist/cf-worker/do/sync-storage.d.ts +15 -9
  28. package/dist/cf-worker/do/sync-storage.d.ts.map +1 -1
  29. package/dist/cf-worker/do/sync-storage.js +111 -25
  30. package/dist/cf-worker/do/sync-storage.js.map +1 -1
  31. package/dist/cf-worker/do/transport/do-rpc-server.d.ts +2 -2
  32. package/dist/cf-worker/do/transport/do-rpc-server.d.ts.map +1 -1
  33. package/dist/cf-worker/do/transport/do-rpc-server.js +31 -23
  34. package/dist/cf-worker/do/transport/do-rpc-server.js.map +1 -1
  35. package/dist/cf-worker/do/transport/http-rpc-server.d.ts +5 -3
  36. package/dist/cf-worker/do/transport/http-rpc-server.d.ts.map +1 -1
  37. package/dist/cf-worker/do/transport/http-rpc-server.js +28 -19
  38. package/dist/cf-worker/do/transport/http-rpc-server.js.map +1 -1
  39. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts +3 -2
  40. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts.map +1 -1
  41. package/dist/cf-worker/do/transport/ws-rpc-server.js +32 -10
  42. package/dist/cf-worker/do/transport/ws-rpc-server.js.map +1 -1
  43. package/dist/cf-worker/shared.d.ts +124 -34
  44. package/dist/cf-worker/shared.d.ts.map +1 -1
  45. package/dist/cf-worker/shared.js +52 -17
  46. package/dist/cf-worker/shared.js.map +1 -1
  47. package/dist/cf-worker/worker.d.ts +47 -39
  48. package/dist/cf-worker/worker.d.ts.map +1 -1
  49. package/dist/cf-worker/worker.js +54 -35
  50. package/dist/cf-worker/worker.js.map +1 -1
  51. package/dist/client/transport/do-rpc-client.d.ts +13 -4
  52. package/dist/client/transport/do-rpc-client.d.ts.map +1 -1
  53. package/dist/client/transport/do-rpc-client.js +74 -28
  54. package/dist/client/transport/do-rpc-client.js.map +1 -1
  55. package/dist/client/transport/http-rpc-client.d.ts +3 -3
  56. package/dist/client/transport/http-rpc-client.d.ts.map +1 -1
  57. package/dist/client/transport/http-rpc-client.js +51 -22
  58. package/dist/client/transport/http-rpc-client.js.map +1 -1
  59. package/dist/client/transport/ws-rpc-client.d.ts +4 -3
  60. package/dist/client/transport/ws-rpc-client.d.ts.map +1 -1
  61. package/dist/client/transport/ws-rpc-client.js +40 -22
  62. package/dist/client/transport/ws-rpc-client.js.map +1 -1
  63. package/dist/common/constants.d.ts +7 -0
  64. package/dist/common/constants.d.ts.map +1 -0
  65. package/dist/common/constants.js +17 -0
  66. package/dist/common/constants.js.map +1 -0
  67. package/dist/common/do-rpc-schema.d.ts +42 -43
  68. package/dist/common/do-rpc-schema.d.ts.map +1 -1
  69. package/dist/common/do-rpc-schema.js +5 -5
  70. package/dist/common/do-rpc-schema.js.map +1 -1
  71. package/dist/common/http-rpc-schema.d.ts +37 -38
  72. package/dist/common/http-rpc-schema.d.ts.map +1 -1
  73. package/dist/common/http-rpc-schema.js +7 -7
  74. package/dist/common/http-rpc-schema.js.map +1 -1
  75. package/dist/common/mod.d.ts +5 -3
  76. package/dist/common/mod.d.ts.map +1 -1
  77. package/dist/common/mod.js +4 -2
  78. package/dist/common/mod.js.map +1 -1
  79. package/dist/common/sync-message-types.d.ts +113 -130
  80. package/dist/common/sync-message-types.d.ts.map +1 -1
  81. package/dist/common/sync-message-types.js +29 -17
  82. package/dist/common/sync-message-types.js.map +1 -1
  83. package/dist/common/ws-rpc-schema.d.ts +34 -35
  84. package/dist/common/ws-rpc-schema.d.ts.map +1 -1
  85. package/dist/common/ws-rpc-schema.js +5 -5
  86. package/dist/common/ws-rpc-schema.js.map +1 -1
  87. package/package.json +57 -15
  88. package/src/cf-worker/do/durable-object.ts +59 -41
  89. package/src/cf-worker/do/layer.ts +153 -88
  90. package/src/cf-worker/do/pull.ts +48 -31
  91. package/src/cf-worker/do/push.test.ts +40 -0
  92. package/src/cf-worker/do/push.ts +163 -125
  93. package/src/cf-worker/do/sqlite.ts +15 -5
  94. package/src/cf-worker/do/sync-storage.ts +171 -38
  95. package/src/cf-worker/do/transport/do-rpc-server.ts +40 -40
  96. package/src/cf-worker/do/transport/http-rpc-server.ts +37 -20
  97. package/src/cf-worker/do/transport/ws-rpc-server.ts +44 -16
  98. package/src/cf-worker/shared.ts +152 -36
  99. package/src/cf-worker/worker.ts +110 -58
  100. package/src/client/transport/do-rpc-client.ts +106 -40
  101. package/src/client/transport/http-rpc-client.ts +74 -38
  102. package/src/client/transport/ws-rpc-client.ts +55 -29
  103. package/src/common/constants.ts +18 -0
  104. package/src/common/do-rpc-schema.ts +6 -5
  105. package/src/common/http-rpc-schema.ts +8 -7
  106. package/src/common/mod.ts +4 -3
  107. package/src/common/sync-message-types.ts +19 -19
  108. package/src/common/ws-rpc-schema.ts +6 -5
  109. package/dist/cf-worker/do/ws-chunking.d.ts +0 -22
  110. package/dist/cf-worker/do/ws-chunking.d.ts.map +0 -1
  111. package/dist/cf-worker/do/ws-chunking.js +0 -49
  112. package/dist/cf-worker/do/ws-chunking.js.map +0 -1
  113. package/src/cf-worker/do/ws-chunking.ts +0 -76
@@ -1,27 +1,81 @@
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, Schema, UrlParams } from '@livestore/utils/effect'
3
+ import type { Effect } from '@livestore/utils/effect'
4
+ import { Result, Schema } from '@livestore/utils/effect'
5
+
4
6
  import type { SearchParams } from '../common/mod.ts'
5
7
  import { SearchParamsSchema, SyncMessage } from '../common/mod.ts'
6
8
 
7
- export interface Env {
8
- /** Eventlog database */
9
- DB: CfTypes.D1Database
10
- ADMIN_SECRET: string
9
+ export type Env = {}
10
+
11
+ /** Headers forwarded from the request to callbacks */
12
+ export type ForwardedHeaders = ReadonlyMap<string, string>
13
+
14
+ /**
15
+ * Configuration for forwarding request headers to DO callbacks.
16
+ * - `string[]`: List of header names to forward (case-insensitive)
17
+ * - `(request) => Record<string, string>`: Custom extraction function (sync)
18
+ */
19
+ export type ForwardHeadersOption = readonly string[] | ((request: CfTypes.Request) => Record<string, string>)
20
+
21
+ /** Context passed to onPush/onPull callbacks */
22
+ export type CallbackContext = {
23
+ storeId: StoreId
24
+ payload?: Schema.Json
25
+ /** Headers forwarded from the request (only present if `forwardHeaders` is configured) */
26
+ headers?: ForwardedHeaders
11
27
  }
12
28
 
13
29
  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
30
+ onPush?: (message: SyncMessage.PushRequest, context: CallbackContext) => Effect.SyncOrPromiseOrEffect<void>
31
+ onPushRes?: (message: SyncMessage.PushAck | UnknownError) => Effect.SyncOrPromiseOrEffect<void>
32
+ onPull?: (message: SyncMessage.PullRequest, context: CallbackContext) => Effect.SyncOrPromiseOrEffect<void>
33
+ onPullRes?: (message: SyncMessage.PullResponse | UnknownError) => Effect.SyncOrPromiseOrEffect<void>
34
+
35
+ /**
36
+ * Forward request headers to `onPush`/`onPull` callbacks for authentication.
37
+ *
38
+ * This enables cookie-based or header-based authentication patterns where
39
+ * you need access to request headers inside the Durable Object.
40
+ *
41
+ * @example Forward specific headers by name (case-insensitive)
42
+ * ```ts
43
+ * makeDurableObject({
44
+ * forwardHeaders: ['cookie', 'authorization'],
45
+ * onPush: async (message, { headers }) => {
46
+ * const cookie = headers?.get('cookie')
47
+ * const session = await validateSession(cookie)
48
+ * },
49
+ * })
50
+ * ```
51
+ *
52
+ * @example Custom extraction function for derived values
53
+ * ```ts
54
+ * makeDurableObject({
55
+ * forwardHeaders: (request) => ({
56
+ * 'x-user-id': request.headers.get('x-user-id') ?? '',
57
+ * 'x-session': request.headers.get('cookie')?.split('session=')[1]?.split(';')[0] ?? '',
58
+ * }),
59
+ * onPush: async (message, { headers }) => {
60
+ * const userId = headers?.get('x-user-id')
61
+ * },
62
+ * })
63
+ * ```
64
+ */
65
+ forwardHeaders?: ForwardHeadersOption
66
+ /**
67
+ * Storage engine for event persistence.
68
+ * - Default: `{ _tag: 'do-sqlite' }` (Durable Object SQLite)
69
+ * - D1: `{ _tag: 'd1', binding: string }` where `binding` is the D1 binding name in wrangler.toml.
70
+ *
71
+ * If omitted, the runtime defaults to DO SQLite. For backwards-compatibility, if an env binding named
72
+ * `DB` exists and looks like a D1Database, D1 will be used.
73
+ *
74
+ * Trade-offs:
75
+ * - DO SQLite: simpler deploy, data co-located with DO, not externally queryable
76
+ * - D1: centralized DB, inspectable with DB tools, extra network hop and JSON size limits
77
+ */
78
+ storage?: { _tag: 'do-sqlite' } | { _tag: 'd1'; binding: string }
25
79
 
26
80
  /**
27
81
  * Enabled transports for sync backend
@@ -33,6 +87,26 @@ export type MakeDurableObjectClassOptions = {
33
87
  */
34
88
  enabledTransports?: Set<'http' | 'ws' | 'do-rpc'>
35
89
 
90
+ /**
91
+ * Custom HTTP response headers for HTTP transport
92
+ * These headers will be added to all HTTP RPC responses (Pull, Push, Ping)
93
+ *
94
+ * @example
95
+ * ```ts
96
+ * {
97
+ * http: {
98
+ * responseHeaders: {
99
+ * 'Access-Control-Allow-Origin': '*',
100
+ * 'Cache-Control': 'no-cache'
101
+ * }
102
+ * }
103
+ * }
104
+ * ```
105
+ */
106
+ http?: {
107
+ responseHeaders?: Record<string, string>
108
+ }
109
+
36
110
  otel?: {
37
111
  baseUrl?: string
38
112
  serviceName?: string
@@ -43,14 +117,27 @@ export type StoreId = string
43
117
  export type DurableObjectId = string
44
118
 
45
119
  /**
46
- * Needs to be bumped when the storage format changes (e.g. eventlogTable schema changes)
120
+ * CRITICAL: Increment this version whenever you modify the database schema structure.
121
+ *
122
+ * Bump required when:
123
+ * - Adding/removing/renaming columns in eventlogTable or contextTable (see sqlite.ts)
124
+ * - Changing column types or constraints
125
+ * - Modifying primary keys or indexes
47
126
  *
48
- * Changing this version number will lead to a "soft reset".
127
+ * Bump NOT required when:
128
+ * - Changing query patterns, pagination logic, or streaming behavior
129
+ * - Adding new tables (as long as existing table schemas remain unchanged)
130
+ * - Updating implementation details in sync-storage.ts
131
+ *
132
+ * Impact: Changing this version triggers a "soft reset" - new table names are created
133
+ * and old data becomes inaccessible (but remains in storage).
49
134
  */
50
135
  export const PERSISTENCE_FORMAT_VERSION = 7
51
136
 
52
- export const encodeOutgoingMessage = Schema.encodeSync(Schema.parseJson(SyncMessage.BackendToClientMessage))
53
- export const encodeIncomingMessage = Schema.encodeSync(Schema.parseJson(SyncMessage.ClientToBackendMessage))
137
+ export const encodeOutgoingMessage = Schema.encodeSync(Schema.fromJsonString(SyncMessage.BackendToClientMessage))
138
+ export const encodeIncomingMessage = Schema.encodeSync(Schema.fromJsonString(SyncMessage.ClientToBackendMessage))
139
+
140
+ const SearchParamsFromUrlSearchParams = Schema.fromURLSearchParams(SearchParamsSchema)
54
141
 
55
142
  /**
56
143
  * Extracts the LiveStore sync search parameters from a request. Returns
@@ -59,27 +146,19 @@ export const encodeIncomingMessage = Schema.encodeSync(Schema.parseJson(SyncMess
59
146
  */
60
147
  export const matchSyncRequest = (request: CfTypes.Request): SearchParams | undefined => {
61
148
  const url = new URL(request.url)
62
- const urlParams = UrlParams.fromInput(url.searchParams)
63
- const paramsResult = UrlParams.schemaStruct(SearchParamsSchema)(urlParams).pipe(Effect.option, Effect.runSync)
149
+ const paramsResult = Schema.decodeUnknownResult(SearchParamsFromUrlSearchParams)(url.searchParams)
64
150
 
65
- if (paramsResult._tag === 'None') {
151
+ if (Result.isFailure(paramsResult) === true) {
66
152
  return undefined
67
153
  }
68
154
 
69
- return paramsResult.value
155
+ return paramsResult.success
70
156
  }
71
157
 
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
158
  // RPC subscription storage (TODO refactor)
80
159
  export type RpcSubscription = {
81
160
  storeId: StoreId
82
- payload?: Schema.JsonValue
161
+ payload?: Schema.Json
83
162
  subscribedAt: number
84
163
  /** Effect RPC request ID */
85
164
  requestId: string
@@ -89,6 +168,9 @@ export type RpcSubscription = {
89
168
  }
90
169
  }
91
170
 
171
+ /** Key prefix for DO-RPC subscriptions persisted in the DO's synchronous KV storage. */
172
+ export const rpcSubscriptionKeyPrefix = 'rpc-sub:'
173
+
92
174
  /**
93
175
  * Durable Object interface supporting the DO RPC protocol for DO <> DO syncing.
94
176
  */
@@ -97,12 +179,46 @@ export interface SyncBackendRpcInterface {
97
179
  rpc(payload: Uint8Array): Promise<Uint8Array | CfTypes.ReadableStream>
98
180
  }
99
181
 
100
- export const WebSocketAttachmentSchema = Schema.parseJson(
182
+ export const WebSocketAttachmentSchema = Schema.fromJsonString(
101
183
  Schema.Struct({
102
184
  // Same across all websocket connections
103
185
  storeId: Schema.String,
104
186
  // Different for each websocket connection
105
- payload: Schema.optional(Schema.JsonValue),
106
- pullRequestIds: Schema.Array(Schema.String),
187
+ payload: Schema.optional(Schema.Json),
188
+ pullRequestIds: Schema.Array(Schema.Union([Schema.String, Schema.Finite])),
189
+ // Headers forwarded from the initial request (via forwardHeaders option)
190
+ headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
107
191
  }),
108
192
  )
193
+
194
+ /** Helper to extract headers from a request based on the forwardHeaders option */
195
+ export const extractForwardedHeaders = (
196
+ request: CfTypes.Request,
197
+ forwardHeaders: ForwardHeadersOption | undefined,
198
+ ): Record<string, string> | undefined => {
199
+ if (forwardHeaders === undefined) {
200
+ return undefined
201
+ }
202
+
203
+ if (typeof forwardHeaders === 'function') {
204
+ return forwardHeaders(request)
205
+ }
206
+
207
+ // Array of header names - extract them case-insensitively
208
+ const result: Record<string, string> = {}
209
+ for (const name of forwardHeaders) {
210
+ const value = request.headers.get(name)
211
+ if (value !== null) {
212
+ result[name.toLowerCase()] = value
213
+ }
214
+ }
215
+ return Object.keys(result).length > 0 ? result : undefined
216
+ }
217
+
218
+ /** Convert a headers record to a ReadonlyMap */
219
+ export const headersRecordToMap = (headers: Record<string, string> | undefined): ForwardedHeaders | undefined => {
220
+ if (headers === undefined) {
221
+ return undefined
222
+ }
223
+ return new Map(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]))
224
+ }
@@ -1,13 +1,14 @@
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 { CfDeclare } from '@livestore/common-cf/declare'
6
+ import { Effect, Result, Schema } from '@livestore/utils/effect'
7
+
5
8
  import type { CfTypes, SearchParams } from '../common/mod.ts'
6
- 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
- // NOTE We need to redeclare runtime types here to avoid type conflicts with the lib.dom Response type.
10
- declare class Response extends CfDeclare.Response {}
11
+ const Response = CfDeclare.Response
11
12
 
12
13
  // HINT: If we ever extend user's custom worker RPC, type T can help here with expected return type safety. Currently unused.
13
14
  export type CFWorker<TEnv extends Env = Env, _T extends CfTypes.Rpc.DurableObjectBranded | undefined = undefined> = {
@@ -18,21 +19,46 @@ export type CFWorker<TEnv extends Env = Env, _T extends CfTypes.Rpc.DurableObjec
18
19
  ) => Promise<CfTypes.Response>
19
20
  }
20
21
 
22
+ /** Context passed to validatePayload callback */
23
+ export type ValidatePayloadContext = {
24
+ storeId: string
25
+ /** Request headers (raw, not filtered by forwardHeaders) */
26
+ headers: ForwardedHeaders
27
+ }
28
+
21
29
  /**
22
30
  * Options accepted by {@link makeWorker}. The Durable Object binding has to be
23
31
  * supplied explicitly so we never fall back to deprecated defaults when Cloudflare config changes.
24
32
  */
25
- export type MakeWorkerOptions<TEnv extends Env = Env> = {
33
+ export type MakeWorkerOptions<TEnv extends Env = Env, TSyncPayload = Schema.Json> = {
26
34
  /**
27
35
  * Binding name of the sync Durable Object declared in wrangler config.
28
36
  */
29
37
  syncBackendBinding: HelperTypes.ExtractDurableObjectKeys<TEnv>
30
38
  /**
31
- * Validates the payload during WebSocket connection establishment.
39
+ * Optionally pass a schema to decode the client-provided payload into a typed object
40
+ * before calling {@link validatePayload}. If omitted, the raw JSON value is forwarded.
41
+ */
42
+ syncPayloadSchema?: Schema.Decoder<TSyncPayload>
43
+ /**
44
+ * Validates the (optionally decoded) payload during WebSocket connection establishment.
45
+ * If {@link syncPayloadSchema} is provided, `payload` will be of the schema's inferred type.
46
+ *
47
+ * The context includes request headers for cookie-based or header-based authentication.
48
+ *
49
+ * @example Cookie-based authentication
50
+ * ```ts
51
+ * validatePayload: async (payload, { storeId, headers }) => {
52
+ * const cookie = headers.get('cookie')
53
+ * const session = await validateSessionFromCookie(cookie)
54
+ * if (!session) throw new Error('Unauthorized')
55
+ * }
56
+ * ```
57
+ *
32
58
  * 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.
59
+ * For push event validation, use the `onPush` callback in the Durable Object.
34
60
  */
35
- validatePayload?: (payload: Schema.JsonValue | undefined, context: { storeId: string }) => void | Promise<void>
61
+ validatePayload?: (payload: TSyncPayload, context: ValidatePayloadContext) => void | Promise<void>
36
62
  /** @default false */
37
63
  enableCORS?: boolean
38
64
  }
@@ -47,22 +73,24 @@ export type MakeWorkerOptions<TEnv extends Env = Env> = {
47
73
  export const makeWorker = <
48
74
  TEnv extends Env = Env,
49
75
  TDurableObjectRpc extends CfTypes.Rpc.DurableObjectBranded | undefined = undefined,
76
+ TSyncPayload = Schema.Json,
50
77
  >(
51
- options: MakeWorkerOptions<TEnv>,
78
+ options: MakeWorkerOptions<TEnv, TSyncPayload>,
52
79
  ): CFWorker<TEnv, TDurableObjectRpc> => {
53
80
  return {
54
81
  fetch: async (request, env, _ctx) => {
55
82
  const url = new URL(request.url)
56
83
 
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
- : {}
84
+ const corsHeaders: CfTypes.HeadersInit =
85
+ options.enableCORS === true
86
+ ? {
87
+ 'Access-Control-Allow-Origin': '*',
88
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
89
+ 'Access-Control-Allow-Headers': request.headers.get('Access-Control-Request-Headers') ?? '*',
90
+ }
91
+ : {}
64
92
 
65
- if (request.method === 'OPTIONS' && options.enableCORS) {
93
+ if (request.method === 'OPTIONS' && options.enableCORS === true) {
66
94
  return new Response(null, {
67
95
  status: 204,
68
96
  headers: corsHeaders,
@@ -73,7 +101,7 @@ export const makeWorker = <
73
101
 
74
102
  // Check if this is a sync request first, before showing info message
75
103
  if (searchParams !== undefined) {
76
- return handleSyncRequest<TEnv, TDurableObjectRpc>({
104
+ return handleSyncRequest<TEnv, TDurableObjectRpc, unknown, TSyncPayload>({
77
105
  request,
78
106
  searchParams,
79
107
  env,
@@ -81,6 +109,7 @@ export const makeWorker = <
81
109
  syncBackendBinding: options.syncBackendBinding,
82
110
  headers: corsHeaders,
83
111
  validatePayload: options.validatePayload,
112
+ syncPayloadSchema: options.syncPayloadSchema,
84
113
  })
85
114
  }
86
115
 
@@ -106,83 +135,106 @@ export const makeWorker = <
106
135
  }
107
136
  }
108
137
 
138
+ /** Convert CF Request headers to a ForwardedHeaders map */
139
+ const requestHeadersToMap = (request: CfTypes.Request): ForwardedHeaders => {
140
+ const result = new Map<string, string>()
141
+ request.headers.forEach((value, key) => {
142
+ result.set(key.toLowerCase(), value)
143
+ })
144
+ return result
145
+ }
146
+
109
147
  /**
110
- * Handles `/sync` endpoint.
148
+ * Handles LiveStore sync requests (e.g. with search params `?storeId=...&transport=...`).
111
149
  *
112
- * @example
150
+ * @example Token-based authentication
113
151
  * ```ts
114
- * const validatePayload = (payload: Schema.JsonValue | undefined, context: { storeId: string }) => {
115
- * console.log(`Validating connection for store: ${context.storeId}`)
152
+ * const validatePayload = (payload: Schema.Json | undefined, context: { storeId: string }) => {
116
153
  * if (payload?.authToken !== 'insecure-token-change-me') {
117
154
  * throw new Error('Invalid auth token')
118
155
  * }
119
156
  * }
157
+ * ```
120
158
  *
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
- * }
159
+ * @example Cookie-based authentication
160
+ * ```ts
161
+ * const validatePayload = async (payload: Schema.Json | undefined, { storeId, headers }) => {
162
+ * const cookie = headers.get('cookie')
163
+ * const session = await validateSessionFromCookie(cookie)
164
+ * if (!session) throw new Error('Unauthorized')
140
165
  * }
141
166
  * ```
142
167
  *
143
- * @throws {UnexpectedError} If the payload is invalid
168
+ * @throws {UnknownError} If the payload is invalid
144
169
  */
145
170
  export const handleSyncRequest = <
146
171
  TEnv extends Env = Env,
147
172
  TDurableObjectRpc extends CfTypes.Rpc.DurableObjectBranded | undefined = undefined,
148
173
  CFHostMetada = unknown,
174
+ TSyncPayload = Schema.Json,
149
175
  >({
150
176
  request,
151
177
  searchParams: { storeId, payload, transport },
152
- env,
178
+ env: explicitlyProvidedEnv,
153
179
  syncBackendBinding,
154
180
  headers,
155
181
  validatePayload,
182
+ syncPayloadSchema,
156
183
  }: {
157
184
  request: CfTypes.Request<CFHostMetada>
158
185
  searchParams: SearchParams
159
- env: TEnv
186
+ env?: TEnv | undefined
160
187
  /** Only there for type-level reasons */
161
188
  ctx: CfTypes.ExecutionContext
162
189
  /** Binding name of the sync backend Durable Object */
163
- syncBackendBinding: MakeWorkerOptions<TEnv>['syncBackendBinding']
190
+ syncBackendBinding: MakeWorkerOptions<TEnv, TSyncPayload>['syncBackendBinding']
164
191
  headers?: CfTypes.HeadersInit | undefined
165
- validatePayload?: (payload: Schema.JsonValue | undefined, context: { storeId: string }) => void | Promise<void>
192
+ validatePayload?: MakeWorkerOptions<TEnv, TSyncPayload>['validatePayload']
193
+ syncPayloadSchema?: MakeWorkerOptions<TEnv, TSyncPayload>['syncPayloadSchema']
166
194
  }): Promise<CfTypes.Response> =>
167
195
  Effect.gen(function* () {
168
196
  if (validatePayload !== undefined) {
169
- const result = yield* Effect.promise(async () => validatePayload!(payload, { storeId })).pipe(
170
- UnexpectedError.mapToUnexpectedError,
171
- Effect.either,
172
- )
197
+ // Convert request headers to a Map for the validation context
198
+ const requestHeaders = requestHeadersToMap(request)
173
199
 
174
- if (result._tag === 'Left') {
175
- console.error('Invalid payload', result.left)
176
- return new Response(result.left.toString(), { status: 400, headers })
200
+ // Always decode with the supplied schema when present, even if payload is undefined.
201
+ // This ensures required payloads are enforced by the schema.
202
+ if (syncPayloadSchema !== undefined) {
203
+ const decodedResult = Schema.decodeUnknownResult(syncPayloadSchema)(payload)
204
+ if (Result.isFailure(decodedResult) === true) {
205
+ const message = decodedResult.failure.toString()
206
+ console.error('Invalid payload (decode failed)', message)
207
+ return new Response(message, { status: 400, ...(headers !== undefined ? { headers } : {}) })
208
+ }
209
+
210
+ const result = yield* Effect.promise(async () =>
211
+ validatePayload(decodedResult.success, { storeId, headers: requestHeaders }),
212
+ ).pipe(UnknownError.mapToUnknownError, Effect.result)
213
+
214
+ if (Result.isFailure(result) === true) {
215
+ console.error('Invalid payload (validation failed)', result.failure)
216
+ return new Response(result.failure.toString(), { status: 400, ...(headers !== undefined ? { headers } : {}) })
217
+ }
218
+ } else {
219
+ const result = yield* Effect.promise(async () =>
220
+ validatePayload(payload as TSyncPayload, { storeId, headers: requestHeaders }),
221
+ ).pipe(UnknownError.mapToUnknownError, Effect.result)
222
+
223
+ if (Result.isFailure(result) === true) {
224
+ console.error('Invalid payload (validation failed)', result.failure)
225
+ return new Response(result.failure.toString(), { status: 400, ...(headers !== undefined ? { headers } : {}) })
226
+ }
177
227
  }
178
228
  }
179
229
 
230
+ const env = explicitlyProvidedEnv ?? (importedEnv as TEnv)
231
+
180
232
  if (!(syncBackendBinding in env)) {
181
233
  return new Response(
182
234
  `Failed dependency: Required Durable Object binding '${syncBackendBinding as string}' not available`,
183
235
  {
184
236
  status: 424,
185
- headers,
237
+ ...(headers !== undefined ? { headers } : {}),
186
238
  },
187
239
  )
188
240
  }
@@ -199,7 +251,7 @@ export const handleSyncRequest = <
199
251
  if (transport === 'ws' && (upgradeHeader === null || upgradeHeader !== 'websocket')) {
200
252
  return new Response('Durable Object expected Upgrade: websocket', {
201
253
  status: 426,
202
- headers,
254
+ ...(headers !== undefined ? { headers } : {}),
203
255
  })
204
256
  }
205
257