@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,9 +1,10 @@
1
- import { UnexpectedError } from '@livestore/common'
2
- import type { LiveStoreEvent } from '@livestore/common/schema'
1
+ import { UnknownError } from '@livestore/common'
3
2
  import type { CfTypes } from '@livestore/common-cf'
3
+ import type { LiveStoreEvent } from '@livestore/common/schema'
4
4
  import { Chunk, Effect, Option, Schema, Stream } from '@livestore/utils/effect'
5
+
5
6
  import { SyncMetadata } from '../../common/sync-message-types.ts'
6
- import { type Env, PERSISTENCE_FORMAT_VERSION, type StoreId } from '../shared.ts'
7
+ import { PERSISTENCE_FORMAT_VERSION, type StoreId } from '../shared.ts'
7
8
  import { eventlogTable } from './sqlite.ts'
8
9
 
9
10
  export type SyncStorage = {
@@ -12,26 +13,30 @@ export type SyncStorage = {
12
13
  {
13
14
  total: number
14
15
  stream: Stream.Stream<
15
- { eventEncoded: LiveStoreEvent.AnyEncodedGlobal; metadata: Option.Option<SyncMetadata> },
16
- UnexpectedError
16
+ { eventEncoded: LiveStoreEvent.Global.Encoded; metadata: Option.Option<SyncMetadata> },
17
+ UnknownError
17
18
  >
18
19
  },
19
- UnexpectedError
20
+ UnknownError
20
21
  >
21
22
  appendEvents: (
22
- batch: ReadonlyArray<LiveStoreEvent.AnyEncodedGlobal>,
23
+ batch: ReadonlyArray<LiveStoreEvent.Global.Encoded>,
23
24
  createdAt: string,
24
- ) => Effect.Effect<void, UnexpectedError>
25
- resetStore: Effect.Effect<void, UnexpectedError>
25
+ ) => Effect.Effect<void, UnknownError>
26
+ resetStore: Effect.Effect<void, UnknownError>
26
27
  }
27
28
 
28
- export const makeStorage = (ctx: CfTypes.DurableObjectState, env: Env, storeId: StoreId): SyncStorage => {
29
+ export const makeStorage = (
30
+ ctx: CfTypes.DurableObjectState,
31
+ storeId: StoreId,
32
+ engine: { _tag: 'd1'; db: CfTypes.D1Database } | { _tag: 'do-sqlite' },
33
+ ): SyncStorage => {
29
34
  const dbName = `eventlog_${PERSISTENCE_FORMAT_VERSION}_${toValidTableName(storeId)}`
30
35
 
31
36
  const execDb = <T>(cb: (db: CfTypes.D1Database) => Promise<CfTypes.D1Result<T>>) =>
32
37
  Effect.tryPromise({
33
- try: () => cb(env.DB),
34
- catch: (error) => new UnexpectedError({ cause: error, payload: { dbName } }),
38
+ try: () => cb(engine._tag === 'd1' ? engine.db : (undefined as never)),
39
+ catch: (error) => new UnknownError({ cause: error, payload: { dbName } }),
35
40
  }).pipe(
36
41
  Effect.map((_) => _.results),
37
42
  Effect.withSpan('@livestore/sync-cf:durable-object:execDb'),
@@ -47,6 +52,7 @@ export const makeStorage = (ctx: CfTypes.DurableObjectState, env: Env, storeId:
47
52
  const D1_MIN_PAGE_SIZE = 1
48
53
 
49
54
  const decodeEventlogRows = Schema.decodeUnknownSync(Schema.Array(eventlogTable.rowSchema))
55
+ const jsonStringify = Schema.encodeSync(Schema.parseJson())
50
56
  const textEncoder = new TextEncoder()
51
57
 
52
58
  const decreaseLimit = (limit: number) => Math.max(D1_MIN_PAGE_SIZE, Math.floor(limit / 2))
@@ -66,17 +72,17 @@ export const makeStorage = (ctx: CfTypes.DurableObjectState, env: Env, storeId:
66
72
  return limit
67
73
  }
68
74
 
69
- const getEvents = (
75
+ const getEventsD1 = (
70
76
  cursor: number | undefined,
71
77
  ): Effect.Effect<
72
78
  {
73
79
  total: number
74
80
  stream: Stream.Stream<
75
- { eventEncoded: LiveStoreEvent.AnyEncodedGlobal; metadata: Option.Option<SyncMetadata> },
76
- UnexpectedError
81
+ { eventEncoded: LiveStoreEvent.Global.Encoded; metadata: Option.Option<SyncMetadata> },
82
+ UnknownError
77
83
  >
78
84
  },
79
- UnexpectedError
85
+ UnknownError
80
86
  > =>
81
87
  Effect.gen(function* () {
82
88
  const countStatement =
@@ -92,13 +98,13 @@ export const makeStorage = (ctx: CfTypes.DurableObjectState, env: Env, storeId:
92
98
  const total = Number(countRows[0]?.total ?? 0)
93
99
 
94
100
  type State = { cursor: number | undefined; limit: number }
95
- type EmittedEvent = { eventEncoded: LiveStoreEvent.AnyEncodedGlobal; metadata: Option.Option<SyncMetadata> }
101
+ type EmittedEvent = { eventEncoded: LiveStoreEvent.Global.Encoded; metadata: Option.Option<SyncMetadata> }
96
102
 
97
103
  const initialState: State = { cursor, limit: D1_INITIAL_PAGE_SIZE }
98
104
 
99
105
  const fetchPage = (
100
106
  state: State,
101
- ): Effect.Effect<Option.Option<readonly [Chunk.Chunk<EmittedEvent>, State]>, UnexpectedError> =>
107
+ ): Effect.Effect<Option.Option<readonly [Chunk.Chunk<EmittedEvent>, State]>, UnknownError> =>
102
108
  Effect.gen(function* () {
103
109
  const statement =
104
110
  state.cursor === undefined
@@ -116,7 +122,7 @@ export const makeStorage = (ctx: CfTypes.DurableObjectState, env: Env, storeId:
116
122
  return Option.none()
117
123
  }
118
124
 
119
- const encodedSize = textEncoder.encode(JSON.stringify(rawEvents)).byteLength
125
+ const encodedSize = textEncoder.encode(jsonStringify(rawEvents)).byteLength
120
126
 
121
127
  if (encodedSize > D1_TARGET_RESPONSE_BYTES && state.limit > D1_MIN_PAGE_SIZE) {
122
128
  const nextLimit = decreaseLimit(state.limit)
@@ -143,11 +149,13 @@ export const makeStorage = (ctx: CfTypes.DurableObjectState, env: Env, storeId:
143
149
 
144
150
  return { total, stream }
145
151
  }).pipe(
146
- UnexpectedError.mapToUnexpectedError,
147
- Effect.withSpan('@livestore/sync-cf:durable-object:getEvents', { attributes: { dbName, cursor } }),
152
+ UnknownError.mapToUnknownError,
153
+ Effect.withSpan('@livestore/sync-cf:durable-object:getEvents', {
154
+ attributes: { dbName, cursor, engine: engine._tag },
155
+ }),
148
156
  )
149
157
 
150
- const appendEvents: SyncStorage['appendEvents'] = (batch, createdAt) =>
158
+ const appendEventsD1: SyncStorage['appendEvents'] = (batch, createdAt) =>
151
159
  Effect.gen(function* () {
152
160
  // If there are no events, do nothing.
153
161
  if (batch.length === 0) return
@@ -182,24 +190,136 @@ export const makeStorage = (ctx: CfTypes.DurableObjectState, env: Env, storeId:
182
190
  )
183
191
  }
184
192
  }).pipe(
185
- UnexpectedError.mapToUnexpectedError,
193
+ UnknownError.mapToUnknownError,
186
194
  Effect.withSpan('@livestore/sync-cf:durable-object:appendEvents', {
187
- attributes: { dbName, batchLength: batch.length },
195
+ attributes: { dbName, batchLength: batch.length, engine: engine._tag },
188
196
  }),
189
197
  )
190
198
 
191
199
  const resetStore = Effect.promise(() => ctx.storage.deleteAll()).pipe(
192
- UnexpectedError.mapToUnexpectedError,
200
+ UnknownError.mapToUnknownError,
193
201
  Effect.withSpan('@livestore/sync-cf:durable-object:resetStore'),
194
202
  )
195
203
 
196
- return {
197
- dbName,
198
- // getHead,
199
- getEvents,
200
- appendEvents,
201
- resetStore,
204
+ // DO SQLite engine implementation
205
+ const getEventsDoSqlite = (
206
+ cursor: number | undefined,
207
+ ): Effect.Effect<
208
+ {
209
+ total: number
210
+ stream: Stream.Stream<
211
+ { eventEncoded: LiveStoreEvent.Global.Encoded; metadata: Option.Option<SyncMetadata> },
212
+ UnknownError
213
+ >
214
+ },
215
+ UnknownError
216
+ > =>
217
+ Effect.gen(function* () {
218
+ const selectCountSql =
219
+ cursor === undefined
220
+ ? `SELECT COUNT(*) as total FROM "${dbName}"`
221
+ : `SELECT COUNT(*) as total FROM "${dbName}" WHERE seqNum > ?`
222
+
223
+ const total = yield* Effect.try({
224
+ try: () => {
225
+ const cursorIter =
226
+ cursor === undefined ? ctx.storage.sql.exec(selectCountSql) : ctx.storage.sql.exec(selectCountSql, cursor)
227
+ let computed = 0
228
+ for (const row of cursorIter) {
229
+ computed = Number((row as any).total ?? 0)
230
+ }
231
+ return computed
232
+ },
233
+ catch: (error) => new UnknownError({ cause: error, payload: { dbName, stage: 'count' } }),
234
+ })
235
+
236
+ type State = { cursor: number | undefined }
237
+ type EmittedEvent = { eventEncoded: LiveStoreEvent.Global.Encoded; metadata: Option.Option<SyncMetadata> }
238
+
239
+ const DO_PAGE_SIZE = 256
240
+ const initialState: State = { cursor }
241
+
242
+ const fetchPage = (
243
+ state: State,
244
+ ): Effect.Effect<Option.Option<readonly [Chunk.Chunk<EmittedEvent>, State]>, UnknownError> =>
245
+ Effect.try({
246
+ try: () => {
247
+ const sql =
248
+ state.cursor === undefined
249
+ ? `SELECT * FROM "${dbName}" ORDER BY seqNum ASC LIMIT ?`
250
+ : `SELECT * FROM "${dbName}" WHERE seqNum > ? ORDER BY seqNum ASC LIMIT ?`
251
+
252
+ const iter =
253
+ state.cursor === undefined
254
+ ? ctx.storage.sql.exec(sql, DO_PAGE_SIZE)
255
+ : ctx.storage.sql.exec(sql, state.cursor, DO_PAGE_SIZE)
256
+
257
+ const rows: any[] = []
258
+ for (const row of iter) rows.push(row)
259
+
260
+ if (rows.length === 0) {
261
+ return Option.none()
262
+ }
263
+
264
+ const decodedRows = Chunk.fromIterable(decodeEventlogRows(rows))
265
+ const eventsChunk = Chunk.map(decodedRows, ({ createdAt, ...eventEncoded }) => ({
266
+ eventEncoded,
267
+ metadata: Option.some(SyncMetadata.make({ createdAt })),
268
+ }))
269
+
270
+ const lastSeqNum = Chunk.unsafeLast(decodedRows).seqNum
271
+ const nextState: State = { cursor: lastSeqNum }
272
+
273
+ return Option.some([eventsChunk, nextState] as const)
274
+ },
275
+ catch: (error) => new UnknownError({ cause: error, payload: { dbName, stage: 'select' } }),
276
+ })
277
+
278
+ const stream = Stream.unfoldChunkEffect(initialState, fetchPage)
279
+
280
+ return { total, stream }
281
+ }).pipe(
282
+ UnknownError.mapToUnknownError,
283
+ Effect.withSpan('@livestore/sync-cf:durable-object:getEvents', {
284
+ attributes: { dbName, cursor, engine: engine._tag },
285
+ }),
286
+ )
287
+
288
+ const appendEventsDoSqlite: SyncStorage['appendEvents'] = (batch, createdAt) =>
289
+ Effect.try({
290
+ try: () => {
291
+ if (batch.length === 0) return
292
+ // Keep params per statement within conservative limits (align with D1 bound params ~100)
293
+ const CHUNK_SIZE = 14
294
+ for (let i = 0; i < batch.length; i += CHUNK_SIZE) {
295
+ const chunk = batch.slice(i, i + CHUNK_SIZE)
296
+ const placeholders = chunk.map(() => '(?, ?, ?, ?, ?, ?, ?)').join(', ')
297
+ const sql = `INSERT INTO "${dbName}" (seqNum, parentSeqNum, args, name, createdAt, clientId, sessionId) VALUES ${placeholders}`
298
+ const params = chunk.flatMap((event) => [
299
+ event.seqNum,
300
+ event.parentSeqNum,
301
+ event.args === undefined ? null : JSON.stringify(event.args),
302
+ event.name,
303
+ createdAt,
304
+ event.clientId,
305
+ event.sessionId,
306
+ ])
307
+ ctx.storage.sql.exec(sql, ...params)
308
+ }
309
+ },
310
+ catch: (error) => new UnknownError({ cause: error, payload: { dbName, stage: 'insert' } }),
311
+ }).pipe(
312
+ Effect.withSpan('@livestore/sync-cf:durable-object:appendEvents', {
313
+ attributes: { dbName, batchLength: batch.length, engine: engine._tag },
314
+ }),
315
+ UnknownError.mapToUnknownError,
316
+ )
317
+
318
+ if (engine._tag === 'd1') {
319
+ return { dbName, getEvents: getEventsD1, appendEvents: appendEventsD1, resetStore }
202
320
  }
321
+
322
+ return { dbName, getEvents: getEventsDoSqlite, appendEvents: appendEventsDoSqlite, resetStore }
203
323
  }
204
324
 
205
325
  const toValidTableName = (str: string) => str.replaceAll(/[^a-zA-Z0-9]/g, '_')
@@ -1,5 +1,5 @@
1
- import { InvalidPullError, InvalidPushError } from '@livestore/common'
2
- import { toDurableObjectHandler } from '@livestore/common-cf'
1
+ import { UnknownError } from '@livestore/common'
2
+ import { type CfTypes, toDurableObjectHandler } from '@livestore/common-cf'
3
3
  import {
4
4
  Effect,
5
5
  Headers,
@@ -11,6 +11,7 @@ import {
11
11
  RpcSerialization,
12
12
  Stream,
13
13
  } from '@livestore/utils/effect'
14
+
14
15
  import { SyncDoRpc } from '../../../common/do-rpc-schema.ts'
15
16
  import { SyncMessage } from '../../../common/mod.ts'
16
17
  import { DoCtx, type DoCtxInput } from '../layer.ts'
@@ -22,7 +23,9 @@ export interface DoRpcHandlerOptions {
22
23
  input: Omit<DoCtxInput, 'from'>
23
24
  }
24
25
 
25
- export const createDoRpcHandler = (options: DoRpcHandlerOptions) =>
26
+ export const createDoRpcHandler = (
27
+ options: DoRpcHandlerOptions,
28
+ ): Effect.Effect<Uint8Array<ArrayBuffer> | CfTypes.ReadableStream> =>
26
29
  Effect.gen(this, function* () {
27
30
  const { payload, input } = options
28
31
  // const { rpcSubscriptions, backendId, doOptions, ctx, env } = yield* DoCtx
@@ -37,17 +40,18 @@ export const createDoRpcHandler = (options: DoRpcHandlerOptions) =>
37
40
  const { rpcSubscriptions } = yield* DoCtx
38
41
 
39
42
  // TODO rename `req.rpcContext` to something more appropriate
40
- if (req.rpcContext) {
43
+ if (req.rpcContext !== undefined) {
41
44
  rpcSubscriptions.set(req.storeId, {
42
45
  storeId: req.storeId,
43
- payload: req.payload,
44
46
  subscribedAt: Date.now(),
45
47
  requestId: Headers.get(headers, 'x-rpc-request-id').pipe(Option.getOrThrow),
46
48
  callerContext: req.rpcContext.callerContext,
49
+ ...(req.payload !== undefined ? { payload: req.payload } : {}),
47
50
  })
48
51
  }
49
52
 
50
- return makeEndingPullStream(req, req.payload)
53
+ // DO-RPC doesn't have HTTP headers context - headers are undefined
54
+ return makeEndingPullStream({ req, payload: req.payload, headers: undefined })
51
55
  }).pipe(
52
56
  Stream.unwrap,
53
57
  Stream.map((res) => ({
@@ -55,18 +59,27 @@ export const createDoRpcHandler = (options: DoRpcHandlerOptions) =>
55
59
  rpcRequestId: Headers.get(headers, 'x-rpc-request-id').pipe(Option.getOrThrow),
56
60
  })),
57
61
  Stream.provideLayer(DoCtx.Default({ ...input, from: { storeId: req.storeId } })),
58
- Stream.mapError((cause) => (cause._tag === 'InvalidPullError' ? cause : InvalidPullError.make({ cause }))),
62
+ Stream.mapError((cause) =>
63
+ cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError'
64
+ ? cause
65
+ : new UnknownError({ cause }),
66
+ ),
59
67
  Stream.tapErrorCause(Effect.log),
60
68
  ),
61
69
  'SyncDoRpc.Push': (req) =>
62
70
  Effect.gen(this, function* () {
63
71
  const { doOptions, ctx, env, storeId } = yield* DoCtx
64
- const push = makePush({ storeId, payload: req.payload, options: doOptions, ctx, env })
72
+ // DO-RPC doesn't have HTTP headers context - headers are undefined
73
+ const push = makePush({ storeId, payload: req.payload, headers: undefined, options: doOptions, ctx, env })
65
74
 
66
75
  return yield* push(req)
67
76
  }).pipe(
68
77
  Effect.provide(DoCtx.Default({ ...input, from: { storeId: req.storeId } })),
69
- Effect.mapError((cause) => (cause._tag === 'InvalidPushError' ? cause : InvalidPushError.make({ cause }))),
78
+ Effect.mapError((cause) =>
79
+ cause._tag === 'UnknownError' || cause._tag === 'ServerAheadError' || cause._tag === 'BackendIdMismatchError'
80
+ ? cause
81
+ : new UnknownError({ cause }),
82
+ ),
70
83
  Effect.tapCauseLogPretty,
71
84
  ),
72
85
  })
@@ -1,31 +1,50 @@
1
1
  import type { CfTypes } from '@livestore/common-cf'
2
2
  import { Effect, HttpApp, Layer, RpcSerialization, RpcServer } from '@livestore/utils/effect'
3
+
3
4
  import { SyncHttpRpc } from '../../../common/http-rpc-schema.ts'
4
5
  import * as SyncMessage from '../../../common/sync-message-types.ts'
6
+ import { headersRecordToMap } from '../../shared.ts'
5
7
  import { DoCtx } from '../layer.ts'
6
8
  import { makeEndingPullStream } from '../pull.ts'
7
9
  import { makePush } from '../push.ts'
8
10
 
9
- export const createHttpRpcHandler = ({ request }: { request: CfTypes.Request }) =>
10
- Effect.gen(function* () {
11
- const handlerLayer = createHttpRpcLayer
12
- const httpApp = RpcServer.toHttpApp(SyncHttpRpc).pipe(Effect.provide(handlerLayer))
13
- const webHandler = yield* httpApp.pipe(Effect.map(HttpApp.toWebHandler))
11
+ export const createHttpRpcHandler = Effect.fn('createHttpRpcHandler')(function* ({
12
+ request,
13
+ responseHeaders,
14
+ forwardedHeaders,
15
+ }: {
16
+ request: CfTypes.Request
17
+ responseHeaders?: Record<string, string>
18
+ forwardedHeaders?: Record<string, string>
19
+ }) {
20
+ const handlerLayer = createHttpRpcLayer(forwardedHeaders)
21
+ const httpApp = RpcServer.toHttpApp(SyncHttpRpc).pipe(Effect.provide(handlerLayer))
22
+ const webHandler = yield* httpApp.pipe(Effect.map(HttpApp.toWebHandler))
23
+
24
+ const response = yield* Effect.promise(
25
+ () => webHandler(request as TODO as Request) as TODO as Promise<CfTypes.Response>,
26
+ ).pipe(Effect.timeout(10000))
27
+
28
+ if (responseHeaders !== undefined) {
29
+ for (const [key, value] of Object.entries(responseHeaders)) {
30
+ response.headers.set(key, value)
31
+ }
32
+ }
33
+
34
+ return response
35
+ })
14
36
 
15
- return yield* Effect.promise(
16
- () => webHandler(request as TODO as Request) as TODO as Promise<CfTypes.Response>,
17
- ).pipe(Effect.timeout(10000))
18
- }).pipe(Effect.withSpan('createHttpRpcHandler'))
37
+ const createHttpRpcLayer = (forwardedHeaders: Record<string, string> | undefined) => {
38
+ const headers = headersRecordToMap(forwardedHeaders)
19
39
 
20
- const createHttpRpcLayer =
21
40
  // TODO implement admin requests
22
- SyncHttpRpc.toLayer({
23
- 'SyncHttpRpc.Pull': (req) => makeEndingPullStream(req, req.payload),
41
+ return SyncHttpRpc.toLayer({
42
+ 'SyncHttpRpc.Pull': (req) => makeEndingPullStream({ req, payload: req.payload, headers }),
24
43
 
25
44
  'SyncHttpRpc.Push': (req) =>
26
45
  Effect.gen(function* () {
27
46
  const { ctx, env, doOptions, storeId } = yield* DoCtx
28
- const push = makePush({ payload: undefined, options: doOptions, storeId, ctx, env })
47
+ const push = makePush({ payload: undefined, headers, options: doOptions, storeId, ctx, env })
29
48
 
30
49
  return yield* push(req)
31
50
  }),
@@ -35,3 +54,4 @@ const createHttpRpcLayer =
35
54
  Layer.provideMerge(RpcServer.layerProtocolHttp({ path: '/http-rpc' })),
36
55
  Layer.provideMerge(RpcSerialization.layerJson),
37
56
  )
57
+ }
@@ -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
+ })