@livestore/sync-cf 0.4.0-dev.0 → 0.4.0-dev.10

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 +150 -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} +77 -70
  20. package/dist/cf-worker/do/sqlite.d.ts.map +1 -0
  21. package/dist/cf-worker/do/sqlite.js +27 -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 +190 -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 +7 -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 +24 -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 +147 -0
  44. package/dist/cf-worker/shared.d.ts.map +1 -0
  45. package/dist/cf-worker/shared.js +32 -0
  46. package/dist/cf-worker/shared.js.map +1 -0
  47. package/dist/cf-worker/worker.d.ts +45 -45
  48. package/dist/cf-worker/worker.d.ts.map +1 -1
  49. package/dist/cf-worker/worker.js +51 -39
  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 +117 -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 +45 -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 +237 -0
  93. package/src/cf-worker/do/layer.ts +128 -0
  94. package/src/cf-worker/do/pull.ts +77 -0
  95. package/src/cf-worker/do/push.ts +205 -0
  96. package/src/cf-worker/do/sqlite.ts +28 -0
  97. package/src/cf-worker/do/sync-storage.ts +321 -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 +37 -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 +112 -0
  103. package/src/cf-worker/worker.ts +91 -105
  104. package/src/client/mod.ts +3 -0
  105. package/src/client/transport/do-rpc-client.ts +191 -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
@@ -0,0 +1,34 @@
1
+ import { InvalidPullError, InvalidPushError } from '@livestore/common'
2
+ import { Effect, identity, Layer, RpcServer, Stream } from '@livestore/utils/effect'
3
+ import { SyncWsRpc } from '../../../common/ws-rpc-schema.ts'
4
+ import { DoCtx, type DoCtxInput } from '../layer.ts'
5
+ import { makeEndingPullStream } from '../pull.ts'
6
+ import { makePush } from '../push.ts'
7
+
8
+ export const makeRpcServer = ({ doSelf, doOptions }: Omit<DoCtxInput, 'from'>) => {
9
+ // TODO implement admin requests
10
+ const handlersLayer = SyncWsRpc.toLayer({
11
+ '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
+ ),
19
+ 'SyncWsRpc.Push': (req) =>
20
+ Effect.gen(function* () {
21
+ const { doOptions, storeId, ctx, env } = yield* DoCtx
22
+
23
+ const push = makePush({ options: doOptions, storeId, payload: req.payload, ctx, env })
24
+
25
+ return yield* push(req)
26
+ }).pipe(
27
+ Effect.provide(DoCtx.Default({ doSelf, doOptions, from: { storeId: req.storeId } })),
28
+ Effect.mapError((cause) => (cause._tag === 'InvalidPushError' ? cause : InvalidPushError.make({ cause }))),
29
+ Effect.tapCauseLogPretty,
30
+ ),
31
+ })
32
+
33
+ return RpcServer.layer(SyncWsRpc).pipe(Layer.provide(handlersLayer))
34
+ }
@@ -1,3 +1,5 @@
1
- export * from './cf-types.ts'
2
- export * from './durable-object.ts'
1
+ export type { CfTypes } from '@livestore/common-cf'
2
+ export { CfDeclare } from '@livestore/common-cf/declare'
3
+ export * from './do/durable-object.ts'
4
+ export * from './shared.ts'
3
5
  export * from './worker.ts'
@@ -0,0 +1,112 @@
1
+ import type { InvalidPullError, InvalidPushError } from '@livestore/common'
2
+ import type { CfTypes } from '@livestore/common-cf'
3
+ import { Effect, Schema, UrlParams } from '@livestore/utils/effect'
4
+
5
+ import type { SearchParams } from '../common/mod.ts'
6
+ import { SearchParamsSchema, SyncMessage } from '../common/mod.ts'
7
+
8
+ export interface Env {
9
+ ADMIN_SECRET: string
10
+ }
11
+
12
+ export type MakeDurableObjectClassOptions = {
13
+ onPush?: (
14
+ message: SyncMessage.PushRequest,
15
+ context: { storeId: StoreId; payload?: Schema.JsonValue },
16
+ ) => Effect.SyncOrPromiseOrEffect<void>
17
+ onPushRes?: (message: SyncMessage.PushAck | InvalidPushError) => Effect.SyncOrPromiseOrEffect<void>
18
+ onPull?: (
19
+ message: SyncMessage.PullRequest,
20
+ context: { storeId: StoreId; payload?: Schema.JsonValue },
21
+ ) => Effect.SyncOrPromiseOrEffect<void>
22
+ onPullRes?: (message: SyncMessage.PullResponse | InvalidPullError) => Effect.SyncOrPromiseOrEffect<void>
23
+ /**
24
+ * Storage engine for event persistence.
25
+ * - Default: `{ _tag: 'do-sqlite' }` (Durable Object SQLite)
26
+ * - D1: `{ _tag: 'd1', binding: string }` where `binding` is the D1 binding name in wrangler.toml.
27
+ *
28
+ * If omitted, the runtime defaults to DO SQLite. For backwards-compatibility, if an env binding named
29
+ * `DB` exists and looks like a D1Database, D1 will be used.
30
+ *
31
+ * Trade-offs:
32
+ * - DO SQLite: simpler deploy, data co-located with DO, not externally queryable
33
+ * - D1: centralized DB, inspectable with DB tools, extra network hop and JSON size limits
34
+ */
35
+ storage?: { _tag: 'do-sqlite' } | { _tag: 'd1'; binding: string }
36
+
37
+ /**
38
+ * Enabled transports for sync backend
39
+ * - `http`: HTTP JSON-RPC
40
+ * - `ws`: WebSocket
41
+ * - `do-rpc`: Durable Object RPC calls (only works in combination with `@livestore/adapter-cf`)
42
+ *
43
+ * @default Set(['http', 'ws', 'do-rpc'])
44
+ */
45
+ enabledTransports?: Set<'http' | 'ws' | 'do-rpc'>
46
+
47
+ otel?: {
48
+ baseUrl?: string
49
+ serviceName?: string
50
+ }
51
+ }
52
+
53
+ export type StoreId = string
54
+ export type DurableObjectId = string
55
+
56
+ /**
57
+ * Needs to be bumped when the storage format changes (e.g. eventlogTable schema changes)
58
+ *
59
+ * Changing this version number will lead to a "soft reset".
60
+ */
61
+ export const PERSISTENCE_FORMAT_VERSION = 7
62
+
63
+ export const encodeOutgoingMessage = Schema.encodeSync(Schema.parseJson(SyncMessage.BackendToClientMessage))
64
+ export const encodeIncomingMessage = Schema.encodeSync(Schema.parseJson(SyncMessage.ClientToBackendMessage))
65
+
66
+ /**
67
+ * Extracts the LiveStore sync search parameters from a request. Returns
68
+ * `undefined` when the request does not carry valid sync metadata so callers
69
+ * can fall back to custom routing.
70
+ */
71
+ export const matchSyncRequest = (request: CfTypes.Request): SearchParams | undefined => {
72
+ const url = new URL(request.url)
73
+ const urlParams = UrlParams.fromInput(url.searchParams)
74
+ const paramsResult = UrlParams.schemaStruct(SearchParamsSchema)(urlParams).pipe(Effect.option, Effect.runSync)
75
+
76
+ if (paramsResult._tag === 'None') {
77
+ return undefined
78
+ }
79
+
80
+ return paramsResult.value
81
+ }
82
+
83
+ // RPC subscription storage (TODO refactor)
84
+ export type RpcSubscription = {
85
+ storeId: StoreId
86
+ payload?: Schema.JsonValue
87
+ subscribedAt: number
88
+ /** Effect RPC request ID */
89
+ requestId: string
90
+ callerContext: {
91
+ bindingName: string
92
+ durableObjectId: string
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Durable Object interface supporting the DO RPC protocol for DO <> DO syncing.
98
+ */
99
+ export interface SyncBackendRpcInterface {
100
+ __DURABLE_OBJECT_BRAND: never
101
+ rpc(payload: Uint8Array): Promise<Uint8Array | CfTypes.ReadableStream>
102
+ }
103
+
104
+ export const WebSocketAttachmentSchema = Schema.parseJson(
105
+ Schema.Struct({
106
+ // Same across all websocket connections
107
+ storeId: Schema.String,
108
+ // Different for each websocket connection
109
+ payload: Schema.optional(Schema.JsonValue),
110
+ pullRequestIds: Schema.Array(Schema.String),
111
+ }),
112
+ )
@@ -1,54 +1,32 @@
1
- import type * as CfWorker from '@cloudflare/workers-types'
2
1
  import { UnexpectedError } from '@livestore/common'
2
+ import type { HelperTypes } from '@livestore/common-cf'
3
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'
4
+ import { Effect } 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
 
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
+ */
51
25
  export type MakeWorkerOptions<TEnv extends Env = Env> = {
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.
@@ -57,36 +35,26 @@ export type MakeWorkerOptions<TEnv extends Env = Env> = {
57
35
  validatePayload?: (payload: Schema.JsonValue | undefined, context: { storeId: string }) => void | Promise<void>
58
36
  /** @default false */
59
37
  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
38
  }
69
39
 
40
+ /**
41
+ * Produces a Cloudflare Worker `fetch` handler that delegates sync traffic to the
42
+ * Durable Object identified by `syncBackendBinding`.
43
+ *
44
+ * For more complex setups prefer implementing a custom `fetch` and call {@link handleSyncRequest}
45
+ * from the branch that handles LiveStore sync requests.
46
+ */
70
47
  export const makeWorker = <
71
48
  TEnv extends Env = Env,
72
- TDurableObjectRpc extends CfWorker.Rpc.DurableObjectBranded | undefined = undefined,
49
+ TDurableObjectRpc extends CfTypes.Rpc.DurableObjectBranded | undefined = undefined,
73
50
  >(
74
- options: MakeWorkerOptions<TEnv> = {},
51
+ options: MakeWorkerOptions<TEnv>,
75
52
  ): CFWorker<TEnv, TDurableObjectRpc> => {
76
53
  return {
77
54
  fetch: async (request, env, _ctx) => {
78
55
  const url = new URL(request.url)
79
56
 
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
57
+ const corsHeaders: CfTypes.HeadersInit = options.enableCORS
90
58
  ? {
91
59
  'Access-Control-Allow-Origin': '*',
92
60
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
@@ -101,11 +69,26 @@ export const makeWorker = <
101
69
  })
102
70
  }
103
71
 
104
- if (url.pathname.endsWith('/websocket')) {
105
- return handleWebSocket<TEnv, TDurableObjectRpc>(request, env, _ctx, {
72
+ const searchParams = matchSyncRequest(request)
73
+
74
+ // Check if this is a sync request first, before showing info message
75
+ if (searchParams !== undefined) {
76
+ return handleSyncRequest<TEnv, TDurableObjectRpc>({
77
+ request,
78
+ searchParams,
79
+ env,
80
+ ctx: _ctx,
81
+ syncBackendBinding: options.syncBackendBinding,
106
82
  headers: corsHeaders,
107
83
  validatePayload: options.validatePayload,
108
- durableObject: options.durableObject,
84
+ })
85
+ }
86
+
87
+ // Only show info message for GET requests to / without sync parameters
88
+ if (request.method === 'GET' && url.pathname === '/') {
89
+ return new Response('Info: Sync backend endpoint for @livestore/sync-cf.', {
90
+ status: 200,
91
+ headers: { 'Content-Type': 'text/plain' },
109
92
  })
110
93
  }
111
94
 
@@ -124,7 +107,7 @@ export const makeWorker = <
124
107
  }
125
108
 
126
109
  /**
127
- * Handles `/websocket` endpoint.
110
+ * Handles LiveStore sync requests (e.g. with search params `?storeId=...&transport=...`).
128
111
  *
129
112
  * @example
130
113
  * ```ts
@@ -137,85 +120,88 @@ export const makeWorker = <
137
120
  *
138
121
  * export default {
139
122
  * fetch: async (request, env, ctx) => {
140
- * if (request.url.endsWith('/websocket')) {
141
- * return handleWebSocket(request, env, ctx, { headers: {}, validatePayload })
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
+ * })
142
136
  * }
143
137
  *
144
138
  * return new Response('Invalid path', { status: 400 })
145
- * return new Response('Invalid path', { status: 400 })
146
139
  * }
147
140
  * }
148
141
  * ```
149
142
  *
150
143
  * @throws {UnexpectedError} If the payload is invalid
151
144
  */
152
- export const handleWebSocket = <
145
+ export const handleSyncRequest = <
153
146
  TEnv extends Env = Env,
154
- TDurableObjectRpc extends CfWorker.Rpc.DurableObjectBranded | undefined = undefined,
147
+ TDurableObjectRpc extends CfTypes.Rpc.DurableObjectBranded | undefined = undefined,
155
148
  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> =>
149
+ >({
150
+ request,
151
+ searchParams: { storeId, payload, transport },
152
+ env,
153
+ syncBackendBinding,
154
+ headers,
155
+ validatePayload,
156
+ }: {
157
+ request: CfTypes.Request<CFHostMetada>
158
+ searchParams: SearchParams
159
+ env: TEnv
160
+ /** Only there for type-level reasons */
161
+ ctx: CfTypes.ExecutionContext
162
+ /** Binding name of the sync backend Durable Object */
163
+ syncBackendBinding: MakeWorkerOptions<TEnv>['syncBackendBinding']
164
+ headers?: CfTypes.HeadersInit | undefined
165
+ validatePayload?: (payload: Schema.JsonValue | undefined, context: { storeId: string }) => void | Promise<void>
166
+ }): Promise<CfTypes.Response> =>
166
167
  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(
168
+ if (validatePayload !== undefined) {
169
+ const result = yield* Effect.promise(async () => validatePayload!(payload, { storeId })).pipe(
183
170
  UnexpectedError.mapToUnexpectedError,
184
171
  Effect.either,
185
172
  )
186
173
 
187
174
  if (result._tag === 'Left') {
188
175
  console.error('Invalid payload', result.left)
189
- return new Response(result.left.toString(), { status: 400, headers: options.headers })
176
+ return new Response(result.left.toString(), { status: 400, headers })
190
177
  }
191
178
  }
192
179
 
193
- const durableObjectName = options.durableObject?.name ?? 'WEBSOCKET_SERVER'
194
- if (!(durableObjectName in env)) {
180
+ if (!(syncBackendBinding in env)) {
195
181
  return new Response(
196
- `Failed dependency: Required Durable Object binding '${durableObjectName as string}' not available`,
182
+ `Failed dependency: Required Durable Object binding '${syncBackendBinding as string}' not available`,
197
183
  {
198
184
  status: 424,
199
- headers: options.headers,
185
+ headers,
200
186
  },
201
187
  )
202
188
  }
203
189
 
204
190
  const durableObjectNamespace = env[
205
- durableObjectName as keyof TEnv
206
- ] as CfWorker.DurableObjectNamespace<TDurableObjectRpc>
191
+ syncBackendBinding as keyof TEnv
192
+ ] as CfTypes.DurableObjectNamespace<TDurableObjectRpc>
207
193
 
208
194
  const id = durableObjectNamespace.idFromName(storeId)
209
195
  const durableObject = durableObjectNamespace.get(id)
210
196
 
197
+ // Handle WebSocket upgrade request
211
198
  const upgradeHeader = request.headers.get('Upgrade')
212
- if (!upgradeHeader || upgradeHeader !== 'websocket') {
199
+ if (transport === 'ws' && (upgradeHeader === null || upgradeHeader !== 'websocket')) {
213
200
  return new Response('Durable Object expected Upgrade: websocket', {
214
201
  status: 426,
215
- headers: options?.headers,
202
+ headers,
216
203
  })
217
204
  }
218
205
 
219
- // Cloudflare Durable Object type clashing with lib.dom Response type, which is why we need the casts here.
220
206
  return yield* Effect.promise(() => durableObject.fetch(request))
221
207
  }).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,191 @@
1
+ import { InvalidPullError, InvalidPushError, SyncBackend, UnexpectedError } 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 UnexpectedError({ 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'
127
+ ? cause
128
+ : InvalidPushError.make({ cause: new UnexpectedError({ cause }) }),
129
+ ),
130
+ Effect.withSpan('rpc-sync-client:push'),
131
+ )
132
+
133
+ const ping: SyncBackend.SyncBackend<{ createdAt: string }>['ping'] = rpcClient.SyncDoRpc.Ping({
134
+ storeId,
135
+ payload,
136
+ }).pipe(UnexpectedError.mapToUnexpectedError, Effect.withSpan('rpc-sync-client:ping'))
137
+
138
+ return SyncBackend.of({
139
+ connect,
140
+ isConnected,
141
+ pull,
142
+ push,
143
+ ping,
144
+ metadata: {
145
+ name: 'rpc-sync-client',
146
+ description: 'Cloudflare Durable Object RPC Sync Client',
147
+ protocol: 'rpc',
148
+ storeId,
149
+ },
150
+ supports: {
151
+ pullPageInfoKnown: true,
152
+ pullLive: true,
153
+ },
154
+ })
155
+ }).pipe(Effect.withSpan('rpc-sync-client:makeDoRpcSync'))
156
+
157
+ /**
158
+ *
159
+ * ```ts
160
+ * import { DurableObject } from 'cloudflare:workers'
161
+ * import { ClientDoWithRpcCallback } from '@livestore/common-cf'
162
+ *
163
+ * export class MyDurableObject extends DurableObject implements ClientDoWithRpcCallback {
164
+ * // ...
165
+ *
166
+ * async syncUpdateRpc(payload: RpcMessage.ResponseChunkEncoded) {
167
+ * return handleSyncUpdateRpc(payload)
168
+ * }
169
+ * }
170
+ * ```
171
+ */
172
+ export const handleSyncUpdateRpc = (payload: unknown) =>
173
+ Effect.gen(function* () {
174
+ const decodedPayload = yield* Schema.decodeUnknown(ResponseChunkEncoded)(payload)
175
+ const decoded = yield* Schema.decodeUnknown(SyncMessage.PullResponse)(decodedPayload.values[0]!)
176
+
177
+ const pullStreamMailbox = requestIdMailboxMap.get(decodedPayload.requestId)
178
+
179
+ if (pullStreamMailbox === undefined) {
180
+ // Case: DO was hibernated, so we need to manually update the store
181
+ yield* Effect.log(`No mailbox found for ${decodedPayload.requestId}`)
182
+ } else {
183
+ // Case: DO was still alive, so the existing `pull` will pick up the new events
184
+ yield* pullStreamMailbox.offer(decoded)
185
+ }
186
+ }).pipe(Effect.withSpan('rpc-sync-client:rpcCallback'), Effect.tapCauseLogPretty, Effect.runPromise)
187
+
188
+ const ResponseChunkEncoded = Schema.Struct({
189
+ requestId: Schema.String,
190
+ values: Schema.Array(Schema.Any),
191
+ })