@livestore/sync-cf 0.3.0-dev.9 → 0.3.1-dev.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 (48) hide show
  1. package/LICENSE +201 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/cf-worker/durable-object.d.ts +45 -28
  4. package/dist/cf-worker/durable-object.d.ts.map +1 -1
  5. package/dist/cf-worker/durable-object.js +214 -129
  6. package/dist/cf-worker/durable-object.js.map +1 -1
  7. package/dist/cf-worker/worker.d.ts +44 -1
  8. package/dist/cf-worker/worker.d.ts.map +1 -1
  9. package/dist/cf-worker/worker.js +83 -13
  10. package/dist/cf-worker/worker.js.map +1 -1
  11. package/dist/common/mod.d.ts +5 -0
  12. package/dist/common/mod.d.ts.map +1 -1
  13. package/dist/common/mod.js +5 -0
  14. package/dist/common/mod.js.map +1 -1
  15. package/dist/common/ws-message-types.d.ts +148 -98
  16. package/dist/common/ws-message-types.d.ts.map +1 -1
  17. package/dist/common/ws-message-types.js +19 -24
  18. package/dist/common/ws-message-types.js.map +1 -1
  19. package/dist/sync-impl/ws-impl.d.ts +2 -5
  20. package/dist/sync-impl/ws-impl.d.ts.map +1 -1
  21. package/dist/sync-impl/ws-impl.js +89 -37
  22. package/dist/sync-impl/ws-impl.js.map +1 -1
  23. package/package.json +5 -4
  24. package/src/cf-worker/durable-object.ts +253 -141
  25. package/src/cf-worker/worker.ts +125 -14
  26. package/src/common/mod.ts +7 -0
  27. package/src/common/ws-message-types.ts +22 -36
  28. package/src/sync-impl/ws-impl.ts +145 -91
  29. package/dist/cf-worker/index.d.ts +0 -3
  30. package/dist/cf-worker/index.d.ts.map +0 -1
  31. package/dist/cf-worker/index.js +0 -33
  32. package/dist/cf-worker/index.js.map +0 -1
  33. package/dist/cf-worker/make-worker.d.ts +0 -6
  34. package/dist/cf-worker/make-worker.d.ts.map +0 -1
  35. package/dist/cf-worker/make-worker.js +0 -31
  36. package/dist/cf-worker/make-worker.js.map +0 -1
  37. package/dist/cf-worker/types.d.ts +0 -2
  38. package/dist/cf-worker/types.d.ts.map +0 -1
  39. package/dist/cf-worker/types.js +0 -2
  40. package/dist/cf-worker/types.js.map +0 -1
  41. package/dist/common/index.d.ts +0 -2
  42. package/dist/common/index.d.ts.map +0 -1
  43. package/dist/common/index.js +0 -2
  44. package/dist/common/index.js.map +0 -1
  45. package/dist/sync-impl/index.d.ts +0 -2
  46. package/dist/sync-impl/index.d.ts.map +0 -1
  47. package/dist/sync-impl/index.js +0 -2
  48. package/dist/sync-impl/index.js.map +0 -1
@@ -1,39 +1,150 @@
1
+ import { UnexpectedError } from '@livestore/common'
2
+ import type { Schema } from '@livestore/utils/effect'
3
+ import { Effect, UrlParams } from '@livestore/utils/effect'
4
+
5
+ import { SearchParamsSchema } from '../common/mod.js'
1
6
  import type { Env } from './durable-object.js'
2
7
 
3
8
  export type CFWorker = {
4
9
  fetch: (request: Request, env: Env, ctx: ExecutionContext) => Promise<Response>
5
10
  }
6
11
 
7
- export const makeWorker = (): CFWorker => {
12
+ export type MakeWorkerOptions = {
13
+ validatePayload?: (payload: Schema.JsonValue | undefined) => void | Promise<void>
14
+ /** @default false */
15
+ enableCORS?: boolean
16
+ durableObject?: {
17
+ /**
18
+ * Needs to match the binding name from the wrangler config
19
+ *
20
+ * @default 'WEBSOCKET_SERVER'
21
+ */
22
+ name?: string
23
+ }
24
+ }
25
+
26
+ export const makeWorker = (options: MakeWorkerOptions = {}): CFWorker => {
8
27
  return {
9
28
  fetch: async (request, env, _ctx) => {
10
29
  const url = new URL(request.url)
11
- const searchParams = url.searchParams
12
- const storeId = searchParams.get('storeId')
13
30
 
14
- if (storeId === null) {
15
- return new Response('storeId search param is required', { status: 400 })
31
+ await new Promise((resolve) => setTimeout(resolve, 500))
32
+
33
+ if (request.method === 'GET' && url.pathname === '/') {
34
+ return new Response('Info: WebSocket sync backend endpoint for @livestore/sync-cf.', {
35
+ status: 200,
36
+ headers: { 'Content-Type': 'text/plain' },
37
+ })
16
38
  }
17
39
 
18
- const id = env.WEBSOCKET_SERVER.idFromName(storeId)
19
- const durableObject = env.WEBSOCKET_SERVER.get(id)
40
+ const corsHeaders: HeadersInit = options.enableCORS
41
+ ? {
42
+ 'Access-Control-Allow-Origin': '*',
43
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
44
+ 'Access-Control-Allow-Headers': request.headers.get('Access-Control-Request-Headers') ?? '*',
45
+ }
46
+ : {}
20
47
 
21
- if (url.pathname.endsWith('/websocket')) {
22
- const upgradeHeader = request.headers.get('Upgrade')
23
- if (!upgradeHeader || upgradeHeader !== 'websocket') {
24
- return new Response('Durable Object expected Upgrade: websocket', { status: 426 })
25
- }
48
+ if (request.method === 'OPTIONS' && options.enableCORS) {
49
+ return new Response(null, {
50
+ status: 204,
51
+ headers: corsHeaders,
52
+ })
53
+ }
26
54
 
27
- return durableObject.fetch(request)
55
+ if (url.pathname.endsWith('/websocket')) {
56
+ return handleWebSocket(request, env, _ctx, {
57
+ headers: corsHeaders,
58
+ validatePayload: options.validatePayload,
59
+ durableObject: options.durableObject,
60
+ })
28
61
  }
29
62
 
30
- return new Response(null, {
63
+ console.error('Invalid path', url.pathname)
64
+
65
+ return new Response('Invalid path', {
31
66
  status: 400,
32
67
  statusText: 'Bad Request',
33
68
  headers: {
69
+ ...corsHeaders,
34
70
  'Content-Type': 'text/plain',
35
71
  },
36
72
  })
37
73
  },
38
74
  }
39
75
  }
76
+
77
+ /**
78
+ * Handles `/websocket` endpoint.
79
+ *
80
+ * @example
81
+ * ```ts
82
+ * const validatePayload = (payload: Schema.JsonValue | undefined) => {
83
+ * if (payload?.authToken !== 'insecure-token-change-me') {
84
+ * throw new Error('Invalid auth token')
85
+ * }
86
+ * }
87
+ *
88
+ * export default {
89
+ * fetch: async (request, env, ctx) => {
90
+ * if (request.url.endsWith('/websocket')) {
91
+ * return handleWebSocket(request, env, ctx, { headers: {}, validatePayload })
92
+ * }
93
+ *
94
+ * return new Response('Invalid path', { status: 400, headers: corsHeaders })
95
+ * }
96
+ * }
97
+ * ```
98
+ *
99
+ * @throws {UnexpectedError} If the payload is invalid
100
+ */
101
+ export const handleWebSocket = (
102
+ request: Request,
103
+ env: Env,
104
+ _ctx: ExecutionContext,
105
+ options: {
106
+ headers?: HeadersInit
107
+ durableObject?: MakeWorkerOptions['durableObject']
108
+ validatePayload?: (payload: Schema.JsonValue | undefined) => void | Promise<void>
109
+ },
110
+ ): Promise<Response> =>
111
+ Effect.gen(function* () {
112
+ const url = new URL(request.url)
113
+
114
+ const urlParams = UrlParams.fromInput(url.searchParams)
115
+ const paramsResult = yield* UrlParams.schemaStruct(SearchParamsSchema)(urlParams).pipe(Effect.either)
116
+
117
+ if (paramsResult._tag === 'Left') {
118
+ return new Response(`Invalid search params: ${paramsResult.left.toString()}`, {
119
+ status: 500,
120
+ headers: options?.headers,
121
+ })
122
+ }
123
+
124
+ const { storeId, payload } = paramsResult.right
125
+
126
+ if (options.validatePayload !== undefined) {
127
+ const result = yield* Effect.promise(async () => options.validatePayload!(payload)).pipe(
128
+ UnexpectedError.mapToUnexpectedError,
129
+ Effect.either,
130
+ )
131
+
132
+ if (result._tag === 'Left') {
133
+ console.error('Invalid payload', result.left)
134
+ return new Response(result.left.toString(), { status: 400, headers: options.headers })
135
+ }
136
+ }
137
+
138
+ const durableObjectName = options.durableObject?.name ?? 'WEBSOCKET_SERVER'
139
+ const durableObjectNamespace = (env as any)[durableObjectName] as DurableObjectNamespace
140
+
141
+ const id = durableObjectNamespace.idFromName(storeId)
142
+ const durableObject = durableObjectNamespace.get(id)
143
+
144
+ const upgradeHeader = request.headers.get('Upgrade')
145
+ if (!upgradeHeader || upgradeHeader !== 'websocket') {
146
+ return new Response('Durable Object expected Upgrade: websocket', { status: 426, headers: options?.headers })
147
+ }
148
+
149
+ return yield* Effect.promise(() => durableObject.fetch(request))
150
+ }).pipe(Effect.tapCauseLogPretty, Effect.runPromise)
package/src/common/mod.ts CHANGED
@@ -1 +1,8 @@
1
+ import { Schema } from '@livestore/utils/effect'
2
+
1
3
  export * as WSMessage from './ws-message-types.js'
4
+
5
+ export const SearchParamsSchema = Schema.Struct({
6
+ storeId: Schema.String,
7
+ payload: Schema.compose(Schema.StringFromUriComponent, Schema.parseJson(Schema.JsonValue)).pipe(Schema.UndefinedOr),
8
+ })
@@ -1,89 +1,83 @@
1
- import { MutationEvent } from '@livestore/common/schema'
1
+ import { LiveStoreEvent } from '@livestore/common/schema'
2
2
  import { Schema } from '@livestore/utils/effect'
3
3
 
4
4
  export const PullReq = Schema.TaggedStruct('WSMessage.PullReq', {
5
5
  requestId: Schema.String,
6
6
  /** Omitting the cursor will start from the beginning */
7
7
  cursor: Schema.optional(Schema.Number),
8
- })
8
+ }).annotations({ title: '@livestore/sync-cf:PullReq' })
9
9
 
10
10
  export type PullReq = typeof PullReq.Type
11
11
 
12
12
  export const SyncMetadata = Schema.Struct({
13
13
  /** ISO date format */
14
14
  createdAt: Schema.String,
15
- })
15
+ }).annotations({ title: '@livestore/sync-cf:SyncMetadata' })
16
16
 
17
17
  export type SyncMetadata = typeof SyncMetadata.Type
18
18
 
19
19
  export const PullRes = Schema.TaggedStruct('WSMessage.PullRes', {
20
- requestId: Schema.String,
21
- events: Schema.Array(
20
+ batch: Schema.Array(
22
21
  Schema.Struct({
23
- mutationEventEncoded: MutationEvent.AnyEncodedGlobal,
22
+ eventEncoded: LiveStoreEvent.AnyEncodedGlobal,
24
23
  metadata: Schema.Option(SyncMetadata),
25
24
  }),
26
25
  ),
26
+ requestId: Schema.Struct({ context: Schema.Literal('pull', 'push'), requestId: Schema.String }),
27
27
  remaining: Schema.Number,
28
- })
28
+ }).annotations({ title: '@livestore/sync-cf:PullRes' })
29
29
 
30
30
  export type PullRes = typeof PullRes.Type
31
31
 
32
- export const PushBroadcast = Schema.TaggedStruct('WSMessage.PushBroadcast', {
33
- mutationEventEncoded: MutationEvent.AnyEncodedGlobal,
34
- metadata: Schema.Option(SyncMetadata),
35
- })
36
-
37
- export type PushBroadcast = typeof PushBroadcast.Type
38
-
39
32
  export const PushReq = Schema.TaggedStruct('WSMessage.PushReq', {
40
33
  requestId: Schema.String,
41
- batch: Schema.Array(MutationEvent.AnyEncodedGlobal),
42
- })
34
+ batch: Schema.Array(LiveStoreEvent.AnyEncodedGlobal),
35
+ }).annotations({ title: '@livestore/sync-cf:PushReq' })
43
36
 
44
37
  export type PushReq = typeof PushReq.Type
45
38
 
46
39
  export const PushAck = Schema.TaggedStruct('WSMessage.PushAck', {
47
40
  requestId: Schema.String,
48
- mutationId: Schema.Number,
49
- })
41
+ }).annotations({ title: '@livestore/sync-cf:PushAck' })
50
42
 
51
43
  export type PushAck = typeof PushAck.Type
52
44
 
53
45
  export const Error = Schema.TaggedStruct('WSMessage.Error', {
54
46
  requestId: Schema.String,
55
47
  message: Schema.String,
56
- })
48
+ }).annotations({ title: '@livestore/sync-cf:Error' })
49
+
50
+ export type Error = typeof Error.Type
57
51
 
58
52
  export const Ping = Schema.TaggedStruct('WSMessage.Ping', {
59
53
  requestId: Schema.Literal('ping'),
60
- })
54
+ }).annotations({ title: '@livestore/sync-cf:Ping' })
61
55
 
62
56
  export type Ping = typeof Ping.Type
63
57
 
64
58
  export const Pong = Schema.TaggedStruct('WSMessage.Pong', {
65
59
  requestId: Schema.Literal('ping'),
66
- })
60
+ }).annotations({ title: '@livestore/sync-cf:Pong' })
67
61
 
68
62
  export type Pong = typeof Pong.Type
69
63
 
70
64
  export const AdminResetRoomReq = Schema.TaggedStruct('WSMessage.AdminResetRoomReq', {
71
65
  requestId: Schema.String,
72
66
  adminSecret: Schema.String,
73
- })
67
+ }).annotations({ title: '@livestore/sync-cf:AdminResetRoomReq' })
74
68
 
75
69
  export type AdminResetRoomReq = typeof AdminResetRoomReq.Type
76
70
 
77
71
  export const AdminResetRoomRes = Schema.TaggedStruct('WSMessage.AdminResetRoomRes', {
78
72
  requestId: Schema.String,
79
- })
73
+ }).annotations({ title: '@livestore/sync-cf:AdminResetRoomRes' })
80
74
 
81
75
  export type AdminResetRoomRes = typeof AdminResetRoomRes.Type
82
76
 
83
77
  export const AdminInfoReq = Schema.TaggedStruct('WSMessage.AdminInfoReq', {
84
78
  requestId: Schema.String,
85
79
  adminSecret: Schema.String,
86
- })
80
+ }).annotations({ title: '@livestore/sync-cf:AdminInfoReq' })
87
81
 
88
82
  export type AdminInfoReq = typeof AdminInfoReq.Type
89
83
 
@@ -92,14 +86,13 @@ export const AdminInfoRes = Schema.TaggedStruct('WSMessage.AdminInfoRes', {
92
86
  info: Schema.Struct({
93
87
  durableObjectId: Schema.String,
94
88
  }),
95
- })
89
+ }).annotations({ title: '@livestore/sync-cf:AdminInfoRes' })
96
90
 
97
91
  export type AdminInfoRes = typeof AdminInfoRes.Type
98
92
 
99
93
  export const Message = Schema.Union(
100
94
  PullReq,
101
95
  PullRes,
102
- PushBroadcast,
103
96
  PushReq,
104
97
  PushAck,
105
98
  Error,
@@ -109,19 +102,12 @@ export const Message = Schema.Union(
109
102
  AdminResetRoomRes,
110
103
  AdminInfoReq,
111
104
  AdminInfoRes,
112
- )
105
+ ).annotations({ title: '@livestore/sync-cf:Message' })
106
+
113
107
  export type Message = typeof Message.Type
114
108
  export type MessageEncoded = typeof Message.Encoded
115
109
 
116
- export const BackendToClientMessage = Schema.Union(
117
- PullRes,
118
- PushBroadcast,
119
- PushAck,
120
- AdminResetRoomRes,
121
- AdminInfoRes,
122
- Error,
123
- Pong,
124
- )
110
+ export const BackendToClientMessage = Schema.Union(PullRes, PushAck, AdminResetRoomRes, AdminInfoRes, Error, Pong)
125
111
  export type BackendToClientMessage = typeof BackendToClientMessage.Type
126
112
 
127
113
  export const ClientToBackendMessage = Schema.Union(PullReq, PushReq, AdminResetRoomReq, AdminInfoReq, Ping)
@@ -1,9 +1,9 @@
1
1
  /// <reference lib="dom" />
2
2
 
3
- import type { SyncBackend } from '@livestore/common'
4
- import { InvalidPullError, InvalidPushError } from '@livestore/common'
5
- import { pick } from '@livestore/utils'
6
- import type { Scope } from '@livestore/utils/effect'
3
+ import type { SyncBackend, SyncBackendConstructor } from '@livestore/common'
4
+ import { InvalidPullError, InvalidPushError, UnexpectedError } from '@livestore/common'
5
+ import { EventSequenceNumber } from '@livestore/common/schema'
6
+ import { LS_DEV, shouldNeverHappen } from '@livestore/utils'
7
7
  import {
8
8
  Deferred,
9
9
  Effect,
@@ -14,90 +14,139 @@ import {
14
14
  Schema,
15
15
  Stream,
16
16
  SubscriptionRef,
17
+ UrlParams,
17
18
  WebSocket,
18
19
  } from '@livestore/utils/effect'
19
20
  import { nanoid } from '@livestore/utils/nanoid'
20
21
 
21
- import { WSMessage } from '../common/mod.js'
22
+ import { SearchParamsSchema, WSMessage } from '../common/mod.js'
22
23
  import type { SyncMetadata } from '../common/ws-message-types.js'
23
24
 
24
25
  export interface WsSyncOptions {
25
26
  url: string
26
- storeId: string
27
27
  }
28
28
 
29
- export const makeWsSync = (options: WsSyncOptions): Effect.Effect<SyncBackend<SyncMetadata>, never, Scope.Scope> =>
30
- Effect.gen(function* () {
31
- // TODO also allow for auth scenarios
32
- const wsUrl = `${options.url}/websocket?storeId=${options.storeId}`
33
-
34
- const { isConnected, incomingMessages, send } = yield* connect(wsUrl)
35
-
36
- const api = {
37
- isConnected,
38
- pull: (args) =>
39
- Effect.gen(function* () {
40
- const requestId = nanoid()
41
- const cursor = Option.getOrUndefined(args)?.cursor.global
42
-
43
- yield* send(WSMessage.PullReq.make({ cursor, requestId }))
44
-
45
- return Stream.fromPubSub(incomingMessages).pipe(
46
- Stream.filter((_) => (_._tag === 'WSMessage.PullRes' ? _.requestId === requestId : true)),
47
- Stream.tap((_) =>
48
- _._tag === 'WSMessage.Error' && _.requestId === requestId
49
- ? new InvalidPullError({ message: _.message })
50
- : Effect.void,
51
- ),
52
- Stream.filter(Schema.is(Schema.Union(WSMessage.PushBroadcast, WSMessage.PullRes))),
53
- Stream.map((msg) =>
54
- msg._tag === 'WSMessage.PushBroadcast'
55
- ? { batch: [pick(msg, ['mutationEventEncoded', 'metadata'])], remaining: 0 }
56
- : {
57
- batch: msg.events.map(({ mutationEventEncoded, metadata }) => ({
58
- mutationEventEncoded,
59
- metadata,
60
- })),
61
- remaining: msg.remaining,
62
- },
63
- ),
64
- )
65
- }).pipe(Stream.unwrap),
66
-
67
- push: (batch) =>
68
- Effect.gen(function* () {
69
- const ready = yield* Deferred.make<void, InvalidPushError>()
70
- const requestId = nanoid()
71
-
72
- yield* Stream.fromPubSub(incomingMessages).pipe(
73
- Stream.filter((_) => _._tag !== 'WSMessage.PushBroadcast' && _.requestId === requestId),
74
- Stream.tap((_) =>
75
- _._tag === 'WSMessage.Error'
76
- ? Deferred.fail(ready, new InvalidPushError({ reason: { _tag: 'Unexpected', message: _.message } }))
77
- : Effect.void,
78
- ),
79
- Stream.filter(Schema.is(WSMessage.PushAck)),
80
- // TODO bring back filterting of "own events"
81
- // Stream.filter((_) => _.mutationId === mutationEventEncoded.id.global),
82
- Stream.take(1),
83
- Stream.tap(() => Deferred.succeed(ready, void 0)),
84
- Stream.runDrain,
85
- Effect.tapCauseLogPretty,
86
- Effect.fork,
87
- )
88
-
89
- yield* send(WSMessage.PushReq.make({ batch, requestId }))
90
-
91
- yield* ready
29
+ export const makeCfSync =
30
+ (options: WsSyncOptions): SyncBackendConstructor<SyncMetadata> =>
31
+ ({ storeId, payload }) =>
32
+ Effect.gen(function* () {
33
+ const urlParamsData = yield* Schema.encode(SearchParamsSchema)({
34
+ storeId,
35
+ payload,
36
+ }).pipe(UnexpectedError.mapToUnexpectedError)
37
+
38
+ const urlParams = UrlParams.fromInput(urlParamsData)
39
+ const wsUrl = `${options.url}/websocket?${UrlParams.toString(urlParams)}`
40
+
41
+ const { isConnected, incomingMessages, send } = yield* connect(wsUrl)
42
+
43
+ /**
44
+ * We need to account for the scenario where push-caused PullRes message arrive before the pull-caused PullRes message.
45
+ * i.e. a scenario where the WS connection is created but before the server processed the initial pull, a push from
46
+ * another client triggers a PullRes message sent to this client which we need to stash until our pull-caused
47
+ * PullRes message arrives at which point we can combine the stashed events with the pull-caused events and continue.
48
+ */
49
+ const stashedPullBatch: WSMessage.PullRes['batch'][number][] = []
50
+
51
+ // We currently only support one pull stream for a sync backend.
52
+ let pullStarted = false
53
+
54
+ const api = {
55
+ isConnected,
56
+ // Currently we're already eagerly connecting when the sync backend is created but we might want to refactor this later to clean this up
57
+ connect: Effect.void,
58
+ pull: (args) =>
59
+ Effect.gen(function* () {
60
+ if (pullStarted) {
61
+ return shouldNeverHappen(`Pull already started for this sync backend.`)
62
+ }
92
63
 
93
- const createdAt = new Date().toISOString()
94
-
95
- return { metadata: Array.from({ length: batch.length }, () => Option.some({ createdAt })) }
96
- }),
97
- } satisfies SyncBackend<SyncMetadata>
98
-
99
- return api
100
- })
64
+ pullStarted = true
65
+
66
+ let pullResponseReceived = false
67
+
68
+ const requestId = nanoid()
69
+ const cursor = Option.getOrUndefined(args)?.cursor.global
70
+
71
+ yield* send(WSMessage.PullReq.make({ cursor, requestId }))
72
+
73
+ return Stream.fromPubSub(incomingMessages).pipe(
74
+ Stream.tap((_) =>
75
+ _._tag === 'WSMessage.Error' && _.requestId === requestId
76
+ ? new InvalidPullError({ message: _.message })
77
+ : Effect.void,
78
+ ),
79
+ Stream.filterMap((msg) => {
80
+ if (msg._tag === 'WSMessage.PullRes') {
81
+ if (msg.requestId.context === 'pull') {
82
+ if (msg.requestId.requestId === requestId) {
83
+ pullResponseReceived = true
84
+
85
+ if (stashedPullBatch.length > 0 && msg.remaining === 0) {
86
+ const pullResHead = msg.batch.at(-1)?.eventEncoded.seqNum ?? EventSequenceNumber.ROOT.global
87
+ // Index where stashed events are greater than pullResHead
88
+ const newPartialBatchIndex = stashedPullBatch.findIndex(
89
+ (batchItem) => batchItem.eventEncoded.seqNum > pullResHead,
90
+ )
91
+ const batchWithNewStashedEvents =
92
+ newPartialBatchIndex === -1 ? [] : stashedPullBatch.slice(newPartialBatchIndex)
93
+ const combinedBatch = [...msg.batch, ...batchWithNewStashedEvents]
94
+ return Option.some({ ...msg, batch: combinedBatch, remaining: 0 })
95
+ } else {
96
+ return Option.some(msg)
97
+ }
98
+ } else {
99
+ // Ignore
100
+ return Option.none()
101
+ }
102
+ } else {
103
+ if (pullResponseReceived) {
104
+ return Option.some(msg)
105
+ } else {
106
+ stashedPullBatch.push(...msg.batch)
107
+ return Option.none()
108
+ }
109
+ }
110
+ }
111
+
112
+ return Option.none()
113
+ }),
114
+ )
115
+ }).pipe(Stream.unwrap),
116
+
117
+ push: (batch) =>
118
+ Effect.gen(function* () {
119
+ const pushAck = yield* Deferred.make<void, InvalidPushError>()
120
+ const requestId = nanoid()
121
+
122
+ yield* Stream.fromPubSub(incomingMessages).pipe(
123
+ Stream.tap((_) =>
124
+ _._tag === 'WSMessage.Error' && _.requestId === requestId
125
+ ? Deferred.fail(pushAck, new InvalidPushError({ reason: { _tag: 'Unexpected', message: _.message } }))
126
+ : Effect.void,
127
+ ),
128
+ Stream.filter((_) => _._tag === 'WSMessage.PushAck' && _.requestId === requestId),
129
+ Stream.take(1),
130
+ Stream.tap(() => Deferred.succeed(pushAck, void 0)),
131
+ Stream.runDrain,
132
+ Effect.tapCauseLogPretty,
133
+ Effect.fork,
134
+ )
135
+
136
+ yield* send(WSMessage.PushReq.make({ batch, requestId }))
137
+
138
+ yield* pushAck
139
+ }),
140
+ metadata: {
141
+ name: '@livestore/cf-sync',
142
+ description: 'LiveStore sync backend implementation using Cloudflare Workers & Durable Objects',
143
+ protocol: 'ws',
144
+ url: options.url,
145
+ },
146
+ } satisfies SyncBackend<SyncMetadata>
147
+
148
+ return api
149
+ })
101
150
 
102
151
  const connect = (wsUrl: string) =>
103
152
  Effect.gen(function* () {
@@ -115,21 +164,23 @@ const connect = (wsUrl: string) =>
115
164
  // Wait first until we're online
116
165
  yield* waitUntilOnline
117
166
 
118
- yield* Effect.spanEvent(
119
- `Sending message: ${message._tag}`,
120
- message._tag === 'WSMessage.PushReq'
121
- ? {
122
- id: message.batch[0]!.id,
123
- parentId: message.batch[0]!.parentId,
124
- batchLength: message.batch.length,
125
- }
126
- : message._tag === 'WSMessage.PullReq'
127
- ? { cursor: message.cursor ?? '-' }
128
- : {},
129
- )
130
-
131
167
  // TODO use MsgPack instead of JSON to speed up the serialization / reduce the size of the messages
132
168
  socketRef.current!.send(Schema.encodeSync(Schema.parseJson(WSMessage.Message))(message))
169
+
170
+ if (LS_DEV) {
171
+ yield* Effect.spanEvent(
172
+ `Sent message: ${message._tag}`,
173
+ message._tag === 'WSMessage.PushReq'
174
+ ? {
175
+ seqNum: message.batch[0]!.seqNum,
176
+ parentSeqNum: message.batch[0]!.parentSeqNum,
177
+ batchLength: message.batch.length,
178
+ }
179
+ : message._tag === 'WSMessage.PullReq'
180
+ ? { cursor: message.cursor ?? '-' }
181
+ : {},
182
+ )
183
+ }
133
184
  })
134
185
 
135
186
  const innerConnect = Effect.gen(function* () {
@@ -138,11 +189,13 @@ const connect = (wsUrl: string) =>
138
189
  while (typeof navigator !== 'undefined' && navigator.onLine === false) {
139
190
  yield* Effect.sleep(1000)
140
191
  }
192
+ // TODO bring this back in a cross-platform way
141
193
  // if (navigator.onLine === false) {
142
194
  // yield* Effect.async((cb) => self.addEventListener('online', () => cb(Effect.void)))
143
195
  // }
144
196
 
145
197
  const socket = yield* WebSocket.makeWebSocket({ url: wsUrl, reconnect: Schedule.exponential(100) })
198
+ // socket.binaryType = 'arraybuffer'
146
199
 
147
200
  yield* SubscriptionRef.set(isConnected, true)
148
201
  socketRef.current = socket
@@ -183,7 +236,8 @@ const connect = (wsUrl: string) =>
183
236
  // NOTE it seems that this callback doesn't work reliably on a worker but only via `window.addEventListener`
184
237
  // We might need to proxy the event from the main thread to the worker if we want this to work reliably.
185
238
  // eslint-disable-next-line unicorn/prefer-global-this
186
- if (typeof self !== 'undefined') {
239
+ if (typeof self !== 'undefined' && typeof self.addEventListener === 'function') {
240
+ // TODO support an Expo equivalent for this
187
241
  // eslint-disable-next-line unicorn/prefer-global-this
188
242
  yield* Effect.eventListener(self, 'offline', () => Deferred.succeed(connectionClosed, void 0))
189
243
  }
@@ -1,3 +0,0 @@
1
- /// <reference no-default-lib="true"/>
2
- export {};
3
- //# sourceMappingURL=index.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cf-worker/index.ts"],"names":[],"mappings":""}
@@ -1,33 +0,0 @@
1
- /// <reference no-default-lib="true"/>
2
- /// <reference lib="esnext" />
3
- export {};
4
- // export * from './durable-object.js'
5
- // export default {
6
- // fetch: async (request: Request, env: Env, _ctx: ExecutionContext): Promise<Response> => {
7
- // const url = new URL(request.url)
8
- // const searchParams = url.searchParams
9
- // const roomId = searchParams.get('room')
10
- // if (roomId === null) {
11
- // return new Response('Room ID is required', { status: 400 })
12
- // }
13
- // // This example will refer to the same Durable Object instance,
14
- // // since the name "foo" is hardcoded.
15
- // const id = env.WEBSOCKET_SERVER.idFromName(roomId)
16
- // const durableObject = env.WEBSOCKET_SERVER.get(id)
17
- // if (url.pathname.endsWith('/websocket')) {
18
- // const upgradeHeader = request.headers.get('Upgrade')
19
- // if (!upgradeHeader || upgradeHeader !== 'websocket') {
20
- // return new Response('Durable Object expected Upgrade: websocket', { status: 426 })
21
- // }
22
- // return durableObject.fetch(request)
23
- // }
24
- // return new Response(null, {
25
- // status: 400,
26
- // statusText: 'Bad Request',
27
- // headers: {
28
- // 'Content-Type': 'text/plain',
29
- // },
30
- // })
31
- // },
32
- // }
33
- //# sourceMappingURL=index.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/cf-worker/index.ts"],"names":[],"mappings":"AAAA,sCAAsC;AACtC,8BAA8B;;AAI9B,sCAAsC;AAEtC,mBAAmB;AACnB,8FAA8F;AAC9F,uCAAuC;AACvC,4CAA4C;AAC5C,8CAA8C;AAE9C,6BAA6B;AAC7B,oEAAoE;AACpE,QAAQ;AAER,sEAAsE;AACtE,4CAA4C;AAC5C,yDAAyD;AACzD,yDAAyD;AAEzD,iDAAiD;AACjD,6DAA6D;AAC7D,+DAA+D;AAC/D,6FAA6F;AAC7F,UAAU;AAEV,4CAA4C;AAC5C,QAAQ;AAER,kCAAkC;AAClC,qBAAqB;AACrB,mCAAmC;AACnC,mBAAmB;AACnB,wCAAwC;AACxC,WAAW;AACX,SAAS;AACT,OAAO;AACP,IAAI"}
@@ -1,6 +0,0 @@
1
- import type { Env } from './durable-object.js';
2
- export type CFWorker = {
3
- fetch: (request: Request, env: Env, ctx: ExecutionContext) => Promise<Response>;
4
- };
5
- export declare const makeWorker: () => CFWorker;
6
- //# sourceMappingURL=make-worker.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"make-worker.d.ts","sourceRoot":"","sources":["../../src/cf-worker/make-worker.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,qBAAqB,CAAA;AAE9C,MAAM,MAAM,QAAQ,GAAG;IACrB,KAAK,EAAE,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,gBAAgB,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;CAChF,CAAA;AAED,eAAO,MAAM,UAAU,QAAO,QAkC7B,CAAA"}