@livestore/sync-cf 0.3.1 → 0.3.2-dev.1

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.
@@ -1,2 +1,2 @@
1
- export * from './durable-object.js'
2
- export * from './worker.js'
1
+ export * from './durable-object.ts'
2
+ export * from './worker.ts'
@@ -1,16 +1,57 @@
1
+ import type * as CfWorker from '@cloudflare/workers-types'
1
2
  import { UnexpectedError } from '@livestore/common'
2
3
  import type { Schema } from '@livestore/utils/effect'
3
4
  import { Effect, UrlParams } from '@livestore/utils/effect'
4
5
 
5
- import { SearchParamsSchema } from '../common/mod.js'
6
- import type { Env } from './durable-object.js'
6
+ import { SearchParamsSchema } from '../common/mod.ts'
7
+ import type { Env } from './durable-object.ts'
7
8
 
8
- export type CFWorker = {
9
- fetch: (request: Request, env: Env, ctx: ExecutionContext) => Promise<Response>
9
+ // Redeclaring Response to Cloudflare Worker Response type to avoid lib.dom type clashing
10
+ declare const Response: typeof CfWorker.Response
11
+
12
+ /**
13
+ * Helper type to extract DurableObject keys from Env to give consumer type safety.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * type PlatformEnv = {
18
+ * DB: D1Database
19
+ * ADMIN_TOKEN: string
20
+ * WEBSOCKET_SERVER: DurableObjectNamespace<WebSocketServer>
21
+ * }
22
+ * export default makeWorker<PlatformEnv>({
23
+ * durableObject: { name: "WEBSOCKET_SERVER" },
24
+ * // ^ (property) name?: "WEBSOCKET_SERVER" | undefined
25
+ * });
26
+ */
27
+ type ExtractDurableObjectKeys<TEnv = Env> = TEnv extends Env
28
+ ? [keyof TEnv] extends [keyof Env]
29
+ ? string
30
+ : keyof {
31
+ [K in keyof TEnv as K extends keyof Env
32
+ ? never
33
+ : TEnv[K] extends CfWorker.DurableObjectNamespace<any>
34
+ ? K
35
+ : never]: TEnv[K]
36
+ }
37
+ : never
38
+
39
+ // HINT: If we ever extend user's custom worker RPC, type T can help here with expected return type safety. Currently unused.
40
+ export type CFWorker<TEnv extends Env = Env, _T extends CfWorker.Rpc.DurableObjectBranded | undefined = undefined> = {
41
+ fetch: <CFHostMetada = unknown>(
42
+ request: CfWorker.Request<CFHostMetada>,
43
+ env: TEnv,
44
+ ctx: CfWorker.ExecutionContext,
45
+ ) => Promise<CfWorker.Response>
10
46
  }
11
47
 
12
- export type MakeWorkerOptions = {
13
- validatePayload?: (payload: Schema.JsonValue | undefined) => void | Promise<void>
48
+ export type MakeWorkerOptions<TEnv extends Env = Env> = {
49
+ /**
50
+ * Validates the payload during WebSocket connection establishment.
51
+ * Note: This runs only at connection time, not for individual push events.
52
+ * For push event validation, use the `onPush` callback in the durable object.
53
+ */
54
+ validatePayload?: (payload: Schema.JsonValue | undefined, context: { storeId: string }) => void | Promise<void>
14
55
  /** @default false */
15
56
  enableCORS?: boolean
16
57
  durableObject?: {
@@ -19,11 +60,16 @@ export type MakeWorkerOptions = {
19
60
  *
20
61
  * @default 'WEBSOCKET_SERVER'
21
62
  */
22
- name?: string
63
+ name?: ExtractDurableObjectKeys<TEnv>
23
64
  }
24
65
  }
25
66
 
26
- export const makeWorker = (options: MakeWorkerOptions = {}): CFWorker => {
67
+ export const makeWorker = <
68
+ TEnv extends Env = Env,
69
+ TDurableObjectRpc extends CfWorker.Rpc.DurableObjectBranded | undefined = undefined,
70
+ >(
71
+ options: MakeWorkerOptions<TEnv> = {},
72
+ ): CFWorker<TEnv, TDurableObjectRpc> => {
27
73
  return {
28
74
  fetch: async (request, env, _ctx) => {
29
75
  const url = new URL(request.url)
@@ -37,7 +83,7 @@ export const makeWorker = (options: MakeWorkerOptions = {}): CFWorker => {
37
83
  })
38
84
  }
39
85
 
40
- const corsHeaders: HeadersInit = options.enableCORS
86
+ const corsHeaders: CfWorker.HeadersInit = options.enableCORS
41
87
  ? {
42
88
  'Access-Control-Allow-Origin': '*',
43
89
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
@@ -53,7 +99,7 @@ export const makeWorker = (options: MakeWorkerOptions = {}): CFWorker => {
53
99
  }
54
100
 
55
101
  if (url.pathname.endsWith('/websocket')) {
56
- return handleWebSocket(request, env, _ctx, {
102
+ return handleWebSocket<TEnv, TDurableObjectRpc>(request, env, _ctx, {
57
103
  headers: corsHeaders,
58
104
  validatePayload: options.validatePayload,
59
105
  durableObject: options.durableObject,
@@ -79,7 +125,8 @@ export const makeWorker = (options: MakeWorkerOptions = {}): CFWorker => {
79
125
  *
80
126
  * @example
81
127
  * ```ts
82
- * const validatePayload = (payload: Schema.JsonValue | undefined) => {
128
+ * const validatePayload = (payload: Schema.JsonValue | undefined, context: { storeId: string }) => {
129
+ * console.log(`Validating connection for store: ${context.storeId}`)
83
130
  * if (payload?.authToken !== 'insecure-token-change-me') {
84
131
  * throw new Error('Invalid auth token')
85
132
  * }
@@ -98,16 +145,20 @@ export const makeWorker = (options: MakeWorkerOptions = {}): CFWorker => {
98
145
  *
99
146
  * @throws {UnexpectedError} If the payload is invalid
100
147
  */
101
- export const handleWebSocket = (
102
- request: Request,
103
- env: Env,
104
- _ctx: ExecutionContext,
148
+ export const handleWebSocket = <
149
+ TEnv extends Env = Env,
150
+ TDurableObjectRpc extends CfWorker.Rpc.DurableObjectBranded | undefined = undefined,
151
+ CFHostMetada = unknown,
152
+ >(
153
+ request: CfWorker.Request<CFHostMetada>,
154
+ env: TEnv,
155
+ _ctx: CfWorker.ExecutionContext,
105
156
  options: {
106
- headers?: HeadersInit
107
- durableObject?: MakeWorkerOptions['durableObject']
108
- validatePayload?: (payload: Schema.JsonValue | undefined) => void | Promise<void>
157
+ headers?: CfWorker.HeadersInit
158
+ durableObject?: MakeWorkerOptions<TEnv>['durableObject']
159
+ validatePayload?: (payload: Schema.JsonValue | undefined, context: { storeId: string }) => void | Promise<void>
109
160
  },
110
- ): Promise<Response> =>
161
+ ): Promise<CfWorker.Response> =>
111
162
  Effect.gen(function* () {
112
163
  const url = new URL(request.url)
113
164
 
@@ -124,7 +175,7 @@ export const handleWebSocket = (
124
175
  const { storeId, payload } = paramsResult.right
125
176
 
126
177
  if (options.validatePayload !== undefined) {
127
- const result = yield* Effect.promise(async () => options.validatePayload!(payload)).pipe(
178
+ const result = yield* Effect.promise(async () => options.validatePayload!(payload, { storeId })).pipe(
128
179
  UnexpectedError.mapToUnexpectedError,
129
180
  Effect.either,
130
181
  )
@@ -136,15 +187,28 @@ export const handleWebSocket = (
136
187
  }
137
188
 
138
189
  const durableObjectName = options.durableObject?.name ?? 'WEBSOCKET_SERVER'
139
- const durableObjectNamespace = (env as any)[durableObjectName] as DurableObjectNamespace
190
+ if (!(durableObjectName in env)) {
191
+ return new Response(`Failed dependency: Required Durable Object binding '${durableObjectName}' not available`, {
192
+ status: 424,
193
+ headers: options.headers,
194
+ })
195
+ }
196
+
197
+ const durableObjectNamespace = env[
198
+ durableObjectName as keyof TEnv
199
+ ] as CfWorker.DurableObjectNamespace<TDurableObjectRpc>
140
200
 
141
201
  const id = durableObjectNamespace.idFromName(storeId)
142
202
  const durableObject = durableObjectNamespace.get(id)
143
203
 
144
204
  const upgradeHeader = request.headers.get('Upgrade')
145
205
  if (!upgradeHeader || upgradeHeader !== 'websocket') {
146
- return new Response('Durable Object expected Upgrade: websocket', { status: 426, headers: options?.headers })
206
+ return new Response('Durable Object expected Upgrade: websocket', {
207
+ status: 426,
208
+ headers: options?.headers,
209
+ })
147
210
  }
148
211
 
149
- return yield* Effect.promise(() => durableObject.fetch(request))
212
+ // Cloudflare Durable Object type clashing with lib.dom Response type, which is why we need the casts here.
213
+ return yield* Effect.promise(() => durableObject.fetch(request as any) as unknown as Promise<CfWorker.Response>)
150
214
  }).pipe(Effect.tapCauseLogPretty, Effect.runPromise)
package/src/common/mod.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Schema } from '@livestore/utils/effect'
2
2
 
3
- export * as WSMessage from './ws-message-types.js'
3
+ export * as WSMessage from './ws-message-types.ts'
4
4
 
5
5
  export const SearchParamsSchema = Schema.Struct({
6
6
  storeId: Schema.String,
@@ -1 +1 @@
1
- export * from './ws-impl.js'
1
+ export * from './ws-impl.ts'
@@ -19,8 +19,8 @@ import {
19
19
  } from '@livestore/utils/effect'
20
20
  import { nanoid } from '@livestore/utils/nanoid'
21
21
 
22
- import { SearchParamsSchema, WSMessage } from '../common/mod.js'
23
- import type { SyncMetadata } from '../common/ws-message-types.js'
22
+ import { SearchParamsSchema, WSMessage } from '../common/mod.ts'
23
+ import type { SyncMetadata } from '../common/ws-message-types.ts'
24
24
 
25
25
  export interface WsSyncOptions {
26
26
  url: string
@@ -235,10 +235,10 @@ const connect = (wsUrl: string) =>
235
235
 
236
236
  // NOTE it seems that this callback doesn't work reliably on a worker but only via `window.addEventListener`
237
237
  // We might need to proxy the event from the main thread to the worker if we want this to work reliably.
238
- // eslint-disable-next-line unicorn/prefer-global-this
238
+
239
239
  if (typeof self !== 'undefined' && typeof self.addEventListener === 'function') {
240
240
  // TODO support an Expo equivalent for this
241
- // eslint-disable-next-line unicorn/prefer-global-this
241
+
242
242
  yield* Effect.eventListener(self, 'offline', () => Deferred.succeed(connectionClosed, void 0))
243
243
  }
244
244