@livestore/sync-cf 0.4.0-dev.20 → 0.4.0-dev.22

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 (51) hide show
  1. package/README.md +1 -1
  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 +9 -5
  6. package/dist/cf-worker/do/durable-object.js.map +1 -1
  7. package/dist/cf-worker/do/layer.d.ts +2 -2
  8. package/dist/cf-worker/do/layer.js +1 -1
  9. package/dist/cf-worker/do/pull.d.ts +6 -1
  10. package/dist/cf-worker/do/pull.d.ts.map +1 -1
  11. package/dist/cf-worker/do/pull.js +2 -2
  12. package/dist/cf-worker/do/pull.js.map +1 -1
  13. package/dist/cf-worker/do/push.d.ts +3 -2
  14. package/dist/cf-worker/do/push.d.ts.map +1 -1
  15. package/dist/cf-worker/do/push.js +3 -3
  16. package/dist/cf-worker/do/push.js.map +1 -1
  17. package/dist/cf-worker/do/sqlite.d.ts +1 -1
  18. package/dist/cf-worker/do/sqlite.js +1 -1
  19. package/dist/cf-worker/do/transport/do-rpc-server.d.ts.map +1 -1
  20. package/dist/cf-worker/do/transport/do-rpc-server.js +4 -2
  21. package/dist/cf-worker/do/transport/do-rpc-server.js.map +1 -1
  22. package/dist/cf-worker/do/transport/http-rpc-server.d.ts +2 -1
  23. package/dist/cf-worker/do/transport/http-rpc-server.d.ts.map +1 -1
  24. package/dist/cf-worker/do/transport/http-rpc-server.js +16 -13
  25. package/dist/cf-worker/do/transport/http-rpc-server.js.map +1 -1
  26. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts +2 -1
  27. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts.map +1 -1
  28. package/dist/cf-worker/do/transport/ws-rpc-server.js +24 -6
  29. package/dist/cf-worker/do/transport/ws-rpc-server.js.map +1 -1
  30. package/dist/cf-worker/shared.d.ts +53 -8
  31. package/dist/cf-worker/shared.d.ts.map +1 -1
  32. package/dist/cf-worker/shared.js +27 -0
  33. package/dist/cf-worker/shared.js.map +1 -1
  34. package/dist/cf-worker/worker.d.ts +31 -31
  35. package/dist/cf-worker/worker.d.ts.map +1 -1
  36. package/dist/cf-worker/worker.js +20 -23
  37. package/dist/cf-worker/worker.js.map +1 -1
  38. package/dist/common/do-rpc-schema.d.ts +3 -3
  39. package/dist/common/do-rpc-schema.js +1 -1
  40. package/package.json +4 -4
  41. package/src/cf-worker/do/durable-object.ts +10 -4
  42. package/src/cf-worker/do/layer.ts +1 -1
  43. package/src/cf-worker/do/pull.ts +13 -5
  44. package/src/cf-worker/do/push.ts +10 -2
  45. package/src/cf-worker/do/sqlite.ts +1 -1
  46. package/src/cf-worker/do/transport/do-rpc-server.ts +4 -2
  47. package/src/cf-worker/do/transport/http-rpc-server.ts +11 -5
  48. package/src/cf-worker/do/transport/ws-rpc-server.ts +29 -10
  49. package/src/cf-worker/shared.ts +86 -8
  50. package/src/cf-worker/worker.ts +48 -34
  51. package/src/common/do-rpc-schema.ts +1 -1
@@ -7,17 +7,61 @@ import { SearchParamsSchema, SyncMessage } from '../common/mod.ts'
7
7
 
8
8
  export type Env = {}
9
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
26
+ }
27
+
10
28
  export type MakeDurableObjectClassOptions = {
11
- onPush?: (
12
- message: SyncMessage.PushRequest,
13
- context: { storeId: StoreId; payload?: Schema.JsonValue },
14
- ) => Effect.SyncOrPromiseOrEffect<void>
29
+ onPush?: (message: SyncMessage.PushRequest, context: CallbackContext) => Effect.SyncOrPromiseOrEffect<void>
15
30
  onPushRes?: (message: SyncMessage.PushAck | InvalidPushError) => Effect.SyncOrPromiseOrEffect<void>
16
- onPull?: (
17
- message: SyncMessage.PullRequest,
18
- context: { storeId: StoreId; payload?: Schema.JsonValue },
19
- ) => Effect.SyncOrPromiseOrEffect<void>
31
+ onPull?: (message: SyncMessage.PullRequest, context: CallbackContext) => Effect.SyncOrPromiseOrEffect<void>
20
32
  onPullRes?: (message: SyncMessage.PullResponse | InvalidPullError) => 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
21
65
  /**
22
66
  * Storage engine for event persistence.
23
67
  * - Default: `{ _tag: 'do-sqlite' }` (Durable Object SQLite)
@@ -137,5 +181,39 @@ export const WebSocketAttachmentSchema = Schema.parseJson(
137
181
  // Different for each websocket connection
138
182
  payload: Schema.optional(Schema.JsonValue),
139
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 })),
140
186
  }),
141
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
+ }
@@ -4,7 +4,7 @@ import type { HelperTypes } from '@livestore/common-cf'
4
4
  import { Effect, Schema } from '@livestore/utils/effect'
5
5
  import type { CfTypes, SearchParams } from '../common/mod.ts'
6
6
  import type { CfDeclare } from './mod.ts'
7
- import { type Env, matchSyncRequest } from './shared.ts'
7
+ import { type Env, type ForwardedHeaders, matchSyncRequest } from './shared.ts'
8
8
 
9
9
  // NOTE We need to redeclare runtime types here to avoid type conflicts with the lib.dom Response type.
10
10
  declare class Response extends CfDeclare.Response {}
@@ -18,6 +18,13 @@ export type CFWorker<TEnv extends Env = Env, _T extends CfTypes.Rpc.DurableObjec
18
18
  ) => Promise<CfTypes.Response>
19
19
  }
20
20
 
21
+ /** Context passed to validatePayload callback */
22
+ export type ValidatePayloadContext = {
23
+ storeId: string
24
+ /** Request headers (raw, not filtered by forwardHeaders) */
25
+ headers: ForwardedHeaders
26
+ }
27
+
21
28
  /**
22
29
  * Options accepted by {@link makeWorker}. The Durable Object binding has to be
23
30
  * supplied explicitly so we never fall back to deprecated defaults when Cloudflare config changes.
@@ -27,11 +34,6 @@ export type MakeWorkerOptions<TEnv extends Env = Env, TSyncPayload = Schema.Json
27
34
  * Binding name of the sync Durable Object declared in wrangler config.
28
35
  */
29
36
  syncBackendBinding: HelperTypes.ExtractDurableObjectKeys<TEnv>
30
- /**
31
- * Validates the payload during WebSocket connection establishment.
32
- * 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.
34
- */
35
37
  /**
36
38
  * Optionally pass a schema to decode the client-provided payload into a typed object
37
39
  * before calling {@link validatePayload}. If omitted, the raw JSON value is forwarded.
@@ -39,9 +41,23 @@ export type MakeWorkerOptions<TEnv extends Env = Env, TSyncPayload = Schema.Json
39
41
  syncPayloadSchema?: Schema.Schema<TSyncPayload>
40
42
  /**
41
43
  * Validates the (optionally decoded) payload during WebSocket connection establishment.
42
- * If {@link syncPayloadSchema} is provided, `payload` will be of the schemas inferred type.
44
+ * If {@link syncPayloadSchema} is provided, `payload` will be of the schema's inferred type.
45
+ *
46
+ * The context includes request headers for cookie-based or header-based authentication.
47
+ *
48
+ * @example Cookie-based authentication
49
+ * ```ts
50
+ * validatePayload: async (payload, { storeId, headers }) => {
51
+ * const cookie = headers.get('cookie')
52
+ * const session = await validateSessionFromCookie(cookie)
53
+ * if (!session) throw new Error('Unauthorized')
54
+ * }
55
+ * ```
56
+ *
57
+ * Note: This runs only at connection time, not for individual push events.
58
+ * For push event validation, use the `onPush` callback in the Durable Object.
43
59
  */
44
- validatePayload?: (payload: TSyncPayload, context: { storeId: string }) => void | Promise<void>
60
+ validatePayload?: (payload: TSyncPayload, context: ValidatePayloadContext) => void | Promise<void>
45
61
  /** @default false */
46
62
  enableCORS?: boolean
47
63
  }
@@ -117,37 +133,33 @@ export const makeWorker = <
117
133
  }
118
134
  }
119
135
 
136
+ /** Convert CF Request headers to a ForwardedHeaders map */
137
+ const requestHeadersToMap = (request: CfTypes.Request): ForwardedHeaders => {
138
+ const result = new Map<string, string>()
139
+ request.headers.forEach((value, key) => {
140
+ result.set(key.toLowerCase(), value)
141
+ })
142
+ return result
143
+ }
144
+
120
145
  /**
121
146
  * Handles LiveStore sync requests (e.g. with search params `?storeId=...&transport=...`).
122
147
  *
123
- * @example
148
+ * @example Token-based authentication
124
149
  * ```ts
125
150
  * const validatePayload = (payload: Schema.JsonValue | undefined, context: { storeId: string }) => {
126
- * console.log(`Validating connection for store: ${context.storeId}`)
127
151
  * if (payload?.authToken !== 'insecure-token-change-me') {
128
152
  * throw new Error('Invalid auth token')
129
153
  * }
130
154
  * }
155
+ * ```
131
156
  *
132
- * export default {
133
- * fetch: async (request, env, ctx) => {
134
- * const searchParams = matchSyncRequest(request)
135
- *
136
- * // Is LiveStore sync request
137
- * if (searchParams !== undefined) {
138
- * return handleSyncRequest({
139
- * request,
140
- * searchParams,
141
- * env,
142
- * ctx,
143
- * syncBackendBinding: 'SYNC_BACKEND_DO',
144
- * headers: {},
145
- * validatePayload,
146
- * })
147
- * }
148
- *
149
- * return new Response('Invalid path', { status: 400 })
150
- * }
157
+ * @example Cookie-based authentication
158
+ * ```ts
159
+ * const validatePayload = async (payload: Schema.JsonValue | undefined, { storeId, headers }) => {
160
+ * const cookie = headers.get('cookie')
161
+ * const session = await validateSessionFromCookie(cookie)
162
+ * if (!session) throw new Error('Unauthorized')
151
163
  * }
152
164
  * ```
153
165
  *
@@ -180,6 +192,9 @@ export const handleSyncRequest = <
180
192
  }): Promise<CfTypes.Response> =>
181
193
  Effect.gen(function* () {
182
194
  if (validatePayload !== undefined) {
195
+ // Convert request headers to a Map for the validation context
196
+ const requestHeaders = requestHeadersToMap(request)
197
+
183
198
  // Always decode with the supplied schema when present, even if payload is undefined.
184
199
  // This ensures required payloads are enforced by the schema.
185
200
  if (syncPayloadSchema !== undefined) {
@@ -191,7 +206,7 @@ export const handleSyncRequest = <
191
206
  }
192
207
 
193
208
  const result = yield* Effect.promise(async () =>
194
- validatePayload(decodedEither.right as TSyncPayload, { storeId }),
209
+ validatePayload(decodedEither.right as TSyncPayload, { storeId, headers: requestHeaders }),
195
210
  ).pipe(UnknownError.mapToUnknownError, Effect.either)
196
211
 
197
212
  if (result._tag === 'Left') {
@@ -199,10 +214,9 @@ export const handleSyncRequest = <
199
214
  return new Response(result.left.toString(), { status: 400, headers })
200
215
  }
201
216
  } else {
202
- const result = yield* Effect.promise(async () => validatePayload(payload as TSyncPayload, { storeId })).pipe(
203
- UnknownError.mapToUnknownError,
204
- Effect.either,
205
- )
217
+ const result = yield* Effect.promise(async () =>
218
+ validatePayload(payload as TSyncPayload, { storeId, headers: requestHeaders }),
219
+ ).pipe(UnknownError.mapToUnknownError, Effect.either)
206
220
 
207
221
  if (result._tag === 'Left') {
208
222
  console.error('Invalid payload (validation failed)', result.left)
@@ -4,7 +4,7 @@ import * as SyncMessage from './sync-message-types.ts'
4
4
 
5
5
  const commonPayloadFields = {
6
6
  /**
7
- * While the storeId is already implied by the durable object, we still need the explicit storeId
7
+ * While the storeId is already implied by the Durable Object, we still need the explicit storeId
8
8
  * since a DO doesn't know its own id.name value. 🫠
9
9
  * https://community.cloudflare.com/t/how-can-i-get-the-name-of-a-durable-object-from-itself/505961/8
10
10
  */