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

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 (136) hide show
  1. package/README.md +60 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/cf-worker/do/durable-object.d.ts +45 -0
  4. package/dist/cf-worker/do/durable-object.d.ts.map +1 -0
  5. package/dist/cf-worker/do/durable-object.js +151 -0
  6. package/dist/cf-worker/do/durable-object.js.map +1 -0
  7. package/dist/cf-worker/do/layer.d.ts +34 -0
  8. package/dist/cf-worker/do/layer.d.ts.map +1 -0
  9. package/dist/cf-worker/do/layer.js +91 -0
  10. package/dist/cf-worker/do/layer.js.map +1 -0
  11. package/dist/cf-worker/do/pull.d.ts +6 -0
  12. package/dist/cf-worker/do/pull.d.ts.map +1 -0
  13. package/dist/cf-worker/do/pull.js +47 -0
  14. package/dist/cf-worker/do/pull.js.map +1 -0
  15. package/dist/cf-worker/do/push.d.ts +14 -0
  16. package/dist/cf-worker/do/push.d.ts.map +1 -0
  17. package/dist/cf-worker/do/push.js +131 -0
  18. package/dist/cf-worker/do/push.js.map +1 -0
  19. package/dist/cf-worker/{durable-object.d.ts → do/sqlite.d.ts} +83 -67
  20. package/dist/cf-worker/do/sqlite.d.ts.map +1 -0
  21. package/dist/cf-worker/do/sqlite.js +36 -0
  22. package/dist/cf-worker/do/sqlite.js.map +1 -0
  23. package/dist/cf-worker/do/sync-storage.d.ts +25 -0
  24. package/dist/cf-worker/do/sync-storage.d.ts.map +1 -0
  25. package/dist/cf-worker/do/sync-storage.js +191 -0
  26. package/dist/cf-worker/do/sync-storage.js.map +1 -0
  27. package/dist/cf-worker/do/transport/do-rpc-server.d.ts +9 -0
  28. package/dist/cf-worker/do/transport/do-rpc-server.d.ts.map +1 -0
  29. package/dist/cf-worker/do/transport/do-rpc-server.js +45 -0
  30. package/dist/cf-worker/do/transport/do-rpc-server.js.map +1 -0
  31. package/dist/cf-worker/do/transport/http-rpc-server.d.ts +8 -0
  32. package/dist/cf-worker/do/transport/http-rpc-server.d.ts.map +1 -0
  33. package/dist/cf-worker/do/transport/http-rpc-server.js +30 -0
  34. package/dist/cf-worker/do/transport/http-rpc-server.js.map +1 -0
  35. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts +4 -0
  36. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts.map +1 -0
  37. package/dist/cf-worker/do/transport/ws-rpc-server.js +21 -0
  38. package/dist/cf-worker/do/transport/ws-rpc-server.js.map +1 -0
  39. package/dist/cf-worker/mod.d.ts +4 -2
  40. package/dist/cf-worker/mod.d.ts.map +1 -1
  41. package/dist/cf-worker/mod.js +3 -2
  42. package/dist/cf-worker/mod.js.map +1 -1
  43. package/dist/cf-worker/shared.d.ts +175 -0
  44. package/dist/cf-worker/shared.d.ts.map +1 -0
  45. package/dist/cf-worker/shared.js +43 -0
  46. package/dist/cf-worker/shared.js.map +1 -0
  47. package/dist/cf-worker/worker.d.ts +59 -51
  48. package/dist/cf-worker/worker.d.ts.map +1 -1
  49. package/dist/cf-worker/worker.js +75 -43
  50. package/dist/cf-worker/worker.js.map +1 -1
  51. package/dist/client/mod.d.ts +4 -0
  52. package/dist/client/mod.d.ts.map +1 -0
  53. package/dist/client/mod.js +4 -0
  54. package/dist/client/mod.js.map +1 -0
  55. package/dist/client/transport/do-rpc-client.d.ts +40 -0
  56. package/dist/client/transport/do-rpc-client.d.ts.map +1 -0
  57. package/dist/client/transport/do-rpc-client.js +115 -0
  58. package/dist/client/transport/do-rpc-client.js.map +1 -0
  59. package/dist/client/transport/http-rpc-client.d.ts +43 -0
  60. package/dist/client/transport/http-rpc-client.d.ts.map +1 -0
  61. package/dist/client/transport/http-rpc-client.js +103 -0
  62. package/dist/client/transport/http-rpc-client.js.map +1 -0
  63. package/dist/client/transport/ws-rpc-client.d.ts +46 -0
  64. package/dist/client/transport/ws-rpc-client.d.ts.map +1 -0
  65. package/dist/client/transport/ws-rpc-client.js +108 -0
  66. package/dist/client/transport/ws-rpc-client.js.map +1 -0
  67. package/dist/common/constants.d.ts +7 -0
  68. package/dist/common/constants.d.ts.map +1 -0
  69. package/dist/common/constants.js +17 -0
  70. package/dist/common/constants.js.map +1 -0
  71. package/dist/common/do-rpc-schema.d.ts +76 -0
  72. package/dist/common/do-rpc-schema.d.ts.map +1 -0
  73. package/dist/common/do-rpc-schema.js +48 -0
  74. package/dist/common/do-rpc-schema.js.map +1 -0
  75. package/dist/common/http-rpc-schema.d.ts +58 -0
  76. package/dist/common/http-rpc-schema.d.ts.map +1 -0
  77. package/dist/common/http-rpc-schema.js +37 -0
  78. package/dist/common/http-rpc-schema.js.map +1 -0
  79. package/dist/common/mod.d.ts +8 -1
  80. package/dist/common/mod.d.ts.map +1 -1
  81. package/dist/common/mod.js +7 -1
  82. package/dist/common/mod.js.map +1 -1
  83. package/dist/common/{ws-message-types.d.ts → sync-message-types.d.ts} +119 -153
  84. package/dist/common/sync-message-types.d.ts.map +1 -0
  85. package/dist/common/sync-message-types.js +60 -0
  86. package/dist/common/sync-message-types.js.map +1 -0
  87. package/dist/common/ws-rpc-schema.d.ts +55 -0
  88. package/dist/common/ws-rpc-schema.d.ts.map +1 -0
  89. package/dist/common/ws-rpc-schema.js +32 -0
  90. package/dist/common/ws-rpc-schema.js.map +1 -0
  91. package/package.json +7 -8
  92. package/src/cf-worker/do/durable-object.ts +238 -0
  93. package/src/cf-worker/do/layer.ts +128 -0
  94. package/src/cf-worker/do/pull.ts +75 -0
  95. package/src/cf-worker/do/push.ts +205 -0
  96. package/src/cf-worker/do/sqlite.ts +37 -0
  97. package/src/cf-worker/do/sync-storage.ts +323 -0
  98. package/src/cf-worker/do/transport/do-rpc-server.ts +84 -0
  99. package/src/cf-worker/do/transport/http-rpc-server.ts +51 -0
  100. package/src/cf-worker/do/transport/ws-rpc-server.ts +34 -0
  101. package/src/cf-worker/mod.ts +4 -2
  102. package/src/cf-worker/shared.ts +141 -0
  103. package/src/cf-worker/worker.ts +138 -116
  104. package/src/client/mod.ts +3 -0
  105. package/src/client/transport/do-rpc-client.ts +189 -0
  106. package/src/client/transport/http-rpc-client.ts +225 -0
  107. package/src/client/transport/ws-rpc-client.ts +202 -0
  108. package/src/common/constants.ts +18 -0
  109. package/src/common/do-rpc-schema.ts +54 -0
  110. package/src/common/http-rpc-schema.ts +40 -0
  111. package/src/common/mod.ts +10 -1
  112. package/src/common/sync-message-types.ts +117 -0
  113. package/src/common/ws-rpc-schema.ts +36 -0
  114. package/dist/cf-worker/cf-types.d.ts +0 -2
  115. package/dist/cf-worker/cf-types.d.ts.map +0 -1
  116. package/dist/cf-worker/cf-types.js +0 -2
  117. package/dist/cf-worker/cf-types.js.map +0 -1
  118. package/dist/cf-worker/durable-object.d.ts.map +0 -1
  119. package/dist/cf-worker/durable-object.js +0 -317
  120. package/dist/cf-worker/durable-object.js.map +0 -1
  121. package/dist/common/ws-message-types.d.ts.map +0 -1
  122. package/dist/common/ws-message-types.js +0 -57
  123. package/dist/common/ws-message-types.js.map +0 -1
  124. package/dist/sync-impl/mod.d.ts +0 -2
  125. package/dist/sync-impl/mod.d.ts.map +0 -1
  126. package/dist/sync-impl/mod.js +0 -2
  127. package/dist/sync-impl/mod.js.map +0 -1
  128. package/dist/sync-impl/ws-impl.d.ts +0 -7
  129. package/dist/sync-impl/ws-impl.d.ts.map +0 -1
  130. package/dist/sync-impl/ws-impl.js +0 -175
  131. package/dist/sync-impl/ws-impl.js.map +0 -1
  132. package/src/cf-worker/cf-types.ts +0 -12
  133. package/src/cf-worker/durable-object.ts +0 -478
  134. package/src/common/ws-message-types.ts +0 -114
  135. package/src/sync-impl/mod.ts +0 -1
  136. package/src/sync-impl/ws-impl.ts +0 -274
@@ -1,92 +1,70 @@
1
- import type * as CfWorker from '@cloudflare/workers-types'
2
- import { UnexpectedError } from '@livestore/common'
3
- import type { Schema } from '@livestore/utils/effect'
4
- import { Effect, UrlParams } from '@livestore/utils/effect'
5
-
6
- import { SearchParamsSchema } from '../common/mod.ts'
7
-
8
- import type { Env } from './durable-object.ts'
1
+ import { env as importedEnv } from 'cloudflare:workers'
2
+ import { UnknownError } from '@livestore/common'
3
+ import type { HelperTypes } from '@livestore/common-cf'
4
+ import { Effect, Schema } from '@livestore/utils/effect'
5
+ import type { CfTypes, SearchParams } from '../common/mod.ts'
6
+ import type { CfDeclare } from './mod.ts'
7
+ import { type Env, matchSyncRequest } from './shared.ts'
9
8
 
10
9
  // NOTE We need to redeclare runtime types here to avoid type conflicts with the lib.dom Response type.
11
- declare class Response extends CfWorker.Response {}
12
-
13
- export namespace HelperTypes {
14
- type AnyDON = CfWorker.DurableObjectNamespace<undefined>
15
-
16
- type DOKeys<T> = {
17
- [K in keyof T]-?: T[K] extends AnyDON ? K : never
18
- }[keyof T]
19
-
20
- type NonBuiltins<T> = Omit<T, keyof Env>
21
-
22
- /**
23
- * Helper type to extract DurableObject keys from Env to give consumer type safety.
24
- *
25
- * @example
26
- * ```ts
27
- * type PlatformEnv = {
28
- * DB: D1Database
29
- * ADMIN_TOKEN: string
30
- * WEBSOCKET_SERVER: DurableObjectNamespace<WebSocketServer>
31
- * }
32
- * export default makeWorker<PlatformEnv>({
33
- * durableObject: { name: "WEBSOCKET_SERVER" },
34
- * // ^ (property) name?: "WEBSOCKET_SERVER" | undefined
35
- * });
36
- */
37
- export type ExtractDurableObjectKeys<TEnv = Env> = DOKeys<NonBuiltins<TEnv>> extends never
38
- ? string
39
- : DOKeys<NonBuiltins<TEnv>>
40
- }
10
+ declare class Response extends CfDeclare.Response {}
41
11
 
42
12
  // HINT: If we ever extend user's custom worker RPC, type T can help here with expected return type safety. Currently unused.
43
- export type CFWorker<TEnv extends Env = Env, _T extends CfWorker.Rpc.DurableObjectBranded | undefined = undefined> = {
13
+ export type CFWorker<TEnv extends Env = Env, _T extends CfTypes.Rpc.DurableObjectBranded | undefined = undefined> = {
44
14
  fetch: <CFHostMetada = unknown>(
45
- request: CfWorker.Request<CFHostMetada>,
15
+ request: CfTypes.Request<CFHostMetada>,
46
16
  env: TEnv,
47
- ctx: CfWorker.ExecutionContext,
48
- ) => Promise<CfWorker.Response>
17
+ ctx: CfTypes.ExecutionContext,
18
+ ) => Promise<CfTypes.Response>
49
19
  }
50
20
 
51
- export type MakeWorkerOptions<TEnv extends Env = Env> = {
21
+ /**
22
+ * Options accepted by {@link makeWorker}. The Durable Object binding has to be
23
+ * supplied explicitly so we never fall back to deprecated defaults when Cloudflare config changes.
24
+ */
25
+ export type MakeWorkerOptions<TEnv extends Env = Env, TSyncPayload = Schema.JsonValue> = {
26
+ /**
27
+ * Binding name of the sync Durable Object declared in wrangler config.
28
+ */
29
+ syncBackendBinding: HelperTypes.ExtractDurableObjectKeys<TEnv>
52
30
  /**
53
31
  * Validates the payload during WebSocket connection establishment.
54
32
  * Note: This runs only at connection time, not for individual push events.
55
33
  * For push event validation, use the `onPush` callback in the durable object.
56
34
  */
57
- validatePayload?: (payload: Schema.JsonValue | undefined, context: { storeId: string }) => void | Promise<void>
35
+ /**
36
+ * Optionally pass a schema to decode the client-provided payload into a typed object
37
+ * before calling {@link validatePayload}. If omitted, the raw JSON value is forwarded.
38
+ */
39
+ syncPayloadSchema?: Schema.Schema<TSyncPayload>
40
+ /**
41
+ * Validates the (optionally decoded) payload during WebSocket connection establishment.
42
+ * If {@link syncPayloadSchema} is provided, `payload` will be of the schema’s inferred type.
43
+ */
44
+ validatePayload?: (payload: TSyncPayload, context: { storeId: string }) => void | Promise<void>
58
45
  /** @default false */
59
46
  enableCORS?: boolean
60
- durableObject?: {
61
- /**
62
- * Needs to match the binding name from the wrangler config
63
- *
64
- * @default 'WEBSOCKET_SERVER'
65
- */
66
- name?: HelperTypes.ExtractDurableObjectKeys<TEnv>
67
- }
68
47
  }
69
48
 
49
+ /**
50
+ * Produces a Cloudflare Worker `fetch` handler that delegates sync traffic to the
51
+ * Durable Object identified by `syncBackendBinding`.
52
+ *
53
+ * For more complex setups prefer implementing a custom `fetch` and call {@link handleSyncRequest}
54
+ * from the branch that handles LiveStore sync requests.
55
+ */
70
56
  export const makeWorker = <
71
57
  TEnv extends Env = Env,
72
- TDurableObjectRpc extends CfWorker.Rpc.DurableObjectBranded | undefined = undefined,
58
+ TDurableObjectRpc extends CfTypes.Rpc.DurableObjectBranded | undefined = undefined,
59
+ TSyncPayload = Schema.JsonValue,
73
60
  >(
74
- options: MakeWorkerOptions<TEnv> = {},
61
+ options: MakeWorkerOptions<TEnv, TSyncPayload>,
75
62
  ): CFWorker<TEnv, TDurableObjectRpc> => {
76
63
  return {
77
64
  fetch: async (request, env, _ctx) => {
78
65
  const url = new URL(request.url)
79
66
 
80
- await new Promise((resolve) => setTimeout(resolve, 500))
81
-
82
- if (request.method === 'GET' && url.pathname === '/') {
83
- return new Response('Info: WebSocket sync backend endpoint for @livestore/sync-cf.', {
84
- status: 200,
85
- headers: { 'Content-Type': 'text/plain' },
86
- })
87
- }
88
-
89
- const corsHeaders: CfWorker.HeadersInit = options.enableCORS
67
+ const corsHeaders: CfTypes.HeadersInit = options.enableCORS
90
68
  ? {
91
69
  'Access-Control-Allow-Origin': '*',
92
70
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
@@ -101,11 +79,27 @@ export const makeWorker = <
101
79
  })
102
80
  }
103
81
 
104
- if (url.pathname.endsWith('/websocket')) {
105
- return handleWebSocket<TEnv, TDurableObjectRpc>(request, env, _ctx, {
82
+ const searchParams = matchSyncRequest(request)
83
+
84
+ // Check if this is a sync request first, before showing info message
85
+ if (searchParams !== undefined) {
86
+ return handleSyncRequest<TEnv, TDurableObjectRpc, unknown, TSyncPayload>({
87
+ request,
88
+ searchParams,
89
+ env,
90
+ ctx: _ctx,
91
+ syncBackendBinding: options.syncBackendBinding,
106
92
  headers: corsHeaders,
107
93
  validatePayload: options.validatePayload,
108
- durableObject: options.durableObject,
94
+ syncPayloadSchema: options.syncPayloadSchema,
95
+ })
96
+ }
97
+
98
+ // Only show info message for GET requests to / without sync parameters
99
+ if (request.method === 'GET' && url.pathname === '/') {
100
+ return new Response('Info: Sync backend endpoint for @livestore/sync-cf.', {
101
+ status: 200,
102
+ headers: { 'Content-Type': 'text/plain' },
109
103
  })
110
104
  }
111
105
 
@@ -124,7 +118,7 @@ export const makeWorker = <
124
118
  }
125
119
 
126
120
  /**
127
- * Handles `/websocket` endpoint.
121
+ * Handles LiveStore sync requests (e.g. with search params `?storeId=...&transport=...`).
128
122
  *
129
123
  * @example
130
124
  * ```ts
@@ -137,85 +131,113 @@ export const makeWorker = <
137
131
  *
138
132
  * export default {
139
133
  * fetch: async (request, env, ctx) => {
140
- * if (request.url.endsWith('/websocket')) {
141
- * return handleWebSocket(request, env, ctx, { headers: {}, validatePayload })
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
+ * })
142
147
  * }
143
148
  *
144
149
  * return new Response('Invalid path', { status: 400 })
145
- * return new Response('Invalid path', { status: 400 })
146
150
  * }
147
151
  * }
148
152
  * ```
149
153
  *
150
- * @throws {UnexpectedError} If the payload is invalid
154
+ * @throws {UnknownError} If the payload is invalid
151
155
  */
152
- export const handleWebSocket = <
156
+ export const handleSyncRequest = <
153
157
  TEnv extends Env = Env,
154
- TDurableObjectRpc extends CfWorker.Rpc.DurableObjectBranded | undefined = undefined,
158
+ TDurableObjectRpc extends CfTypes.Rpc.DurableObjectBranded | undefined = undefined,
155
159
  CFHostMetada = unknown,
156
- >(
157
- request: CfWorker.Request<CFHostMetada>,
158
- env: TEnv,
159
- _ctx: CfWorker.ExecutionContext,
160
- options: {
161
- headers?: CfWorker.HeadersInit
162
- durableObject?: MakeWorkerOptions<TEnv>['durableObject']
163
- validatePayload?: (payload: Schema.JsonValue | undefined, context: { storeId: string }) => void | Promise<void>
164
- } = {},
165
- ): Promise<CfWorker.Response> =>
160
+ TSyncPayload = Schema.JsonValue,
161
+ >({
162
+ request,
163
+ searchParams: { storeId, payload, transport },
164
+ env: explicitlyProvidedEnv,
165
+ syncBackendBinding,
166
+ headers,
167
+ validatePayload,
168
+ syncPayloadSchema,
169
+ }: {
170
+ request: CfTypes.Request<CFHostMetada>
171
+ searchParams: SearchParams
172
+ env?: TEnv | undefined
173
+ /** Only there for type-level reasons */
174
+ ctx: CfTypes.ExecutionContext
175
+ /** Binding name of the sync backend Durable Object */
176
+ syncBackendBinding: MakeWorkerOptions<TEnv, TSyncPayload>['syncBackendBinding']
177
+ headers?: CfTypes.HeadersInit | undefined
178
+ validatePayload?: MakeWorkerOptions<TEnv, TSyncPayload>['validatePayload']
179
+ syncPayloadSchema?: MakeWorkerOptions<TEnv, TSyncPayload>['syncPayloadSchema']
180
+ }): Promise<CfTypes.Response> =>
166
181
  Effect.gen(function* () {
167
- const url = new URL(request.url)
168
-
169
- const urlParams = UrlParams.fromInput(url.searchParams)
170
- const paramsResult = yield* UrlParams.schemaStruct(SearchParamsSchema)(urlParams).pipe(Effect.either)
171
-
172
- if (paramsResult._tag === 'Left') {
173
- return new Response(`Invalid search params: ${paramsResult.left.toString()}`, {
174
- status: 500,
175
- headers: options?.headers,
176
- })
177
- }
178
-
179
- const { storeId, payload } = paramsResult.right
180
-
181
- if (options.validatePayload !== undefined) {
182
- const result = yield* Effect.promise(async () => options.validatePayload!(payload, { storeId })).pipe(
183
- UnexpectedError.mapToUnexpectedError,
184
- Effect.either,
185
- )
186
-
187
- if (result._tag === 'Left') {
188
- console.error('Invalid payload', result.left)
189
- return new Response(result.left.toString(), { status: 400, headers: options.headers })
182
+ if (validatePayload !== undefined) {
183
+ // Always decode with the supplied schema when present, even if payload is undefined.
184
+ // This ensures required payloads are enforced by the schema.
185
+ if (syncPayloadSchema !== undefined) {
186
+ const decodedEither = Schema.decodeUnknownEither(syncPayloadSchema)(payload)
187
+ if (decodedEither._tag === 'Left') {
188
+ const message = decodedEither.left.toString()
189
+ console.error('Invalid payload (decode failed)', message)
190
+ return new Response(message, { status: 400, headers })
191
+ }
192
+
193
+ const result = yield* Effect.promise(async () =>
194
+ validatePayload(decodedEither.right as TSyncPayload, { storeId }),
195
+ ).pipe(UnknownError.mapToUnknownError, Effect.either)
196
+
197
+ if (result._tag === 'Left') {
198
+ console.error('Invalid payload (validation failed)', result.left)
199
+ return new Response(result.left.toString(), { status: 400, headers })
200
+ }
201
+ } else {
202
+ const result = yield* Effect.promise(async () => validatePayload(payload as TSyncPayload, { storeId })).pipe(
203
+ UnknownError.mapToUnknownError,
204
+ Effect.either,
205
+ )
206
+
207
+ if (result._tag === 'Left') {
208
+ console.error('Invalid payload (validation failed)', result.left)
209
+ return new Response(result.left.toString(), { status: 400, headers })
210
+ }
190
211
  }
191
212
  }
192
213
 
193
- const durableObjectName = options.durableObject?.name ?? 'WEBSOCKET_SERVER'
194
- if (!(durableObjectName in env)) {
214
+ const env = explicitlyProvidedEnv ?? (importedEnv as TEnv)
215
+
216
+ if (!(syncBackendBinding in env)) {
195
217
  return new Response(
196
- `Failed dependency: Required Durable Object binding '${durableObjectName as string}' not available`,
218
+ `Failed dependency: Required Durable Object binding '${syncBackendBinding as string}' not available`,
197
219
  {
198
220
  status: 424,
199
- headers: options.headers,
221
+ headers,
200
222
  },
201
223
  )
202
224
  }
203
225
 
204
226
  const durableObjectNamespace = env[
205
- durableObjectName as keyof TEnv
206
- ] as CfWorker.DurableObjectNamespace<TDurableObjectRpc>
227
+ syncBackendBinding as keyof TEnv
228
+ ] as CfTypes.DurableObjectNamespace<TDurableObjectRpc>
207
229
 
208
230
  const id = durableObjectNamespace.idFromName(storeId)
209
231
  const durableObject = durableObjectNamespace.get(id)
210
232
 
233
+ // Handle WebSocket upgrade request
211
234
  const upgradeHeader = request.headers.get('Upgrade')
212
- if (!upgradeHeader || upgradeHeader !== 'websocket') {
235
+ if (transport === 'ws' && (upgradeHeader === null || upgradeHeader !== 'websocket')) {
213
236
  return new Response('Durable Object expected Upgrade: websocket', {
214
237
  status: 426,
215
- headers: options?.headers,
238
+ headers,
216
239
  })
217
240
  }
218
241
 
219
- // Cloudflare Durable Object type clashing with lib.dom Response type, which is why we need the casts here.
220
242
  return yield* Effect.promise(() => durableObject.fetch(request))
221
243
  }).pipe(Effect.tapCauseLogPretty, Effect.runPromise)
@@ -0,0 +1,3 @@
1
+ export * from './transport/do-rpc-client.ts'
2
+ export * from './transport/http-rpc-client.ts'
3
+ export * from './transport/ws-rpc-client.ts'
@@ -0,0 +1,189 @@
1
+ import { InvalidPullError, InvalidPushError, SyncBackend, UnknownError } from '@livestore/common'
2
+ import { splitChunkBySize } from '@livestore/common/sync'
3
+ import { type CfTypes, layerProtocolDurableObject } from '@livestore/common-cf'
4
+ import { omit, shouldNeverHappen } from '@livestore/utils'
5
+ import {
6
+ Chunk,
7
+ Effect,
8
+ identity,
9
+ Layer,
10
+ Mailbox,
11
+ Option,
12
+ RpcClient,
13
+ RpcSerialization,
14
+ Schema,
15
+ Stream,
16
+ SubscriptionRef,
17
+ } from '@livestore/utils/effect'
18
+ import type { SyncBackendRpcInterface } from '../../cf-worker/shared.ts'
19
+ import { MAX_DO_RPC_REQUEST_BYTES, MAX_PUSH_EVENTS_PER_REQUEST } from '../../common/constants.ts'
20
+ import { SyncDoRpc } from '../../common/do-rpc-schema.ts'
21
+ import { SyncMessage } from '../../common/mod.ts'
22
+ import type { SyncMetadata } from '../../common/sync-message-types.ts'
23
+
24
+ export interface SyncBackendRpcStub extends CfTypes.DurableObjectStub, SyncBackendRpcInterface {}
25
+
26
+ // TODO we probably need better scoping for the requestIdMailboxMap (i.e. support multiple stores, ...)
27
+ type EffectRpcRequestId = string // 0, 1, 2, ...
28
+ const requestIdMailboxMap = new Map<EffectRpcRequestId, Mailbox.Mailbox<SyncMessage.PullResponse>>()
29
+
30
+ export interface DoRpcSyncOptions {
31
+ /** Durable Object stub that implements the SyncDoRpc interface */
32
+ syncBackendStub: SyncBackendRpcStub
33
+ /** Information about this DurableObject instance so the Sync DO instance can call back to this instance */
34
+ durableObjectContext: {
35
+ /** See `wrangler.toml` for the binding name */
36
+ bindingName: string
37
+ /** `state.id.toString()` in the DO */
38
+ durableObjectId: string
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Creates a sync backend that uses Durable Object RPC to communicate with the sync backend.
44
+ *
45
+ * Used internally by `@livestore/adapter-cf` to connect to the sync backend.
46
+ */
47
+ export const makeDoRpcSync =
48
+ ({ syncBackendStub, durableObjectContext }: DoRpcSyncOptions): SyncBackend.SyncBackendConstructor<SyncMetadata> =>
49
+ ({ storeId, payload }) =>
50
+ Effect.gen(function* () {
51
+ const isConnected = yield* SubscriptionRef.make(true)
52
+
53
+ const ProtocolLive = layerProtocolDurableObject({
54
+ callRpc: (payload) => syncBackendStub.rpc(payload),
55
+ callerContext: durableObjectContext,
56
+ }).pipe(Layer.provide(RpcSerialization.layerJson))
57
+
58
+ const context = yield* Layer.build(ProtocolLive)
59
+
60
+ const rpcClient = yield* RpcClient.make(SyncDoRpc).pipe(Effect.provide(context))
61
+
62
+ // Nothing to do here
63
+ const connect = Effect.void
64
+
65
+ const backendIdHelper = yield* SyncBackend.makeBackendIdHelper
66
+
67
+ const pull: SyncBackend.SyncBackend<SyncMetadata>['pull'] = (cursor, options) =>
68
+ rpcClient.SyncDoRpc.Pull({
69
+ cursor: cursor.pipe(
70
+ Option.map((a) => ({
71
+ eventSequenceNumber: a.eventSequenceNumber,
72
+ backendId: backendIdHelper.get().pipe(Option.getOrThrow),
73
+ })),
74
+ ),
75
+ storeId,
76
+ rpcContext: options?.live ? { callerContext: durableObjectContext } : undefined,
77
+ }).pipe(
78
+ options?.live
79
+ ? Stream.concatWithLastElement((res) =>
80
+ Effect.gen(function* () {
81
+ if (res._tag === 'None')
82
+ return shouldNeverHappen('There should at least be a no-more page info response')
83
+
84
+ const mailbox = yield* Mailbox.make<SyncMessage.PullResponse>().pipe(
85
+ Effect.acquireRelease((mailbox) => mailbox.shutdown),
86
+ )
87
+
88
+ requestIdMailboxMap.set(res.value.rpcRequestId, mailbox)
89
+
90
+ return Mailbox.toStream(mailbox)
91
+ }).pipe(Stream.unwrapScoped),
92
+ )
93
+ : identity,
94
+ Stream.tap((res) => backendIdHelper.lazySet(res.backendId)),
95
+ Stream.map((res) => omit(res, ['backendId'])),
96
+ Stream.mapError((cause) => (cause._tag === 'InvalidPullError' ? cause : InvalidPullError.make({ cause }))),
97
+ Stream.withSpan('rpc-sync-client:pull'),
98
+ )
99
+
100
+ const push: SyncBackend.SyncBackend<{ createdAt: string }>['push'] = (batch) =>
101
+ Effect.gen(function* () {
102
+ if (batch.length === 0) {
103
+ return
104
+ }
105
+
106
+ const backendId = backendIdHelper.get()
107
+ const batchChunks = yield* Chunk.fromIterable(batch).pipe(
108
+ splitChunkBySize({
109
+ maxItems: MAX_PUSH_EVENTS_PER_REQUEST,
110
+ maxBytes: MAX_DO_RPC_REQUEST_BYTES,
111
+ encode: (items) => ({
112
+ batch: items,
113
+ storeId,
114
+ backendId,
115
+ }),
116
+ }),
117
+ Effect.mapError((cause) => new InvalidPushError({ cause: new UnknownError({ cause }) })),
118
+ )
119
+
120
+ for (const chunk of Chunk.toReadonlyArray(batchChunks)) {
121
+ const chunkArray = Chunk.toReadonlyArray(chunk)
122
+ yield* rpcClient.SyncDoRpc.Push({ batch: chunkArray, storeId, backendId })
123
+ }
124
+ }).pipe(
125
+ Effect.mapError((cause) =>
126
+ cause._tag === 'InvalidPushError' ? cause : InvalidPushError.make({ cause: new UnknownError({ cause }) }),
127
+ ),
128
+ Effect.withSpan('rpc-sync-client:push'),
129
+ )
130
+
131
+ const ping: SyncBackend.SyncBackend<{ createdAt: string }>['ping'] = rpcClient.SyncDoRpc.Ping({
132
+ storeId,
133
+ payload,
134
+ }).pipe(UnknownError.mapToUnknownError, Effect.withSpan('rpc-sync-client:ping'))
135
+
136
+ return SyncBackend.of({
137
+ connect,
138
+ isConnected,
139
+ pull,
140
+ push,
141
+ ping,
142
+ metadata: {
143
+ name: 'rpc-sync-client',
144
+ description: 'Cloudflare Durable Object RPC Sync Client',
145
+ protocol: 'rpc',
146
+ storeId,
147
+ },
148
+ supports: {
149
+ pullPageInfoKnown: true,
150
+ pullLive: true,
151
+ },
152
+ })
153
+ }).pipe(Effect.withSpan('rpc-sync-client:makeDoRpcSync'))
154
+
155
+ /**
156
+ *
157
+ * ```ts
158
+ * import { DurableObject } from 'cloudflare:workers'
159
+ * import { ClientDoWithRpcCallback } from '@livestore/common-cf'
160
+ *
161
+ * export class MyDurableObject extends DurableObject implements ClientDoWithRpcCallback {
162
+ * // ...
163
+ *
164
+ * async syncUpdateRpc(payload: RpcMessage.ResponseChunkEncoded) {
165
+ * return handleSyncUpdateRpc(payload)
166
+ * }
167
+ * }
168
+ * ```
169
+ */
170
+ export const handleSyncUpdateRpc = (payload: unknown) =>
171
+ Effect.gen(function* () {
172
+ const decodedPayload = yield* Schema.decodeUnknown(ResponseChunkEncoded)(payload)
173
+ const decoded = yield* Schema.decodeUnknown(SyncMessage.PullResponse)(decodedPayload.values[0]!)
174
+
175
+ const pullStreamMailbox = requestIdMailboxMap.get(decodedPayload.requestId)
176
+
177
+ if (pullStreamMailbox === undefined) {
178
+ // Case: DO was hibernated, so we need to manually update the store
179
+ yield* Effect.log(`No mailbox found for ${decodedPayload.requestId}`)
180
+ } else {
181
+ // Case: DO was still alive, so the existing `pull` will pick up the new events
182
+ yield* pullStreamMailbox.offer(decoded)
183
+ }
184
+ }).pipe(Effect.withSpan('rpc-sync-client:rpcCallback'), Effect.tapCauseLogPretty, Effect.runPromise)
185
+
186
+ const ResponseChunkEncoded = Schema.Struct({
187
+ requestId: Schema.String,
188
+ values: Schema.Array(Schema.Any),
189
+ })