@livestore/adapter-node 0.4.0-dev.9 → 0.5.0-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 (49) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/client-session/adapter.d.ts +69 -3
  3. package/dist/client-session/adapter.d.ts.map +1 -1
  4. package/dist/client-session/adapter.js +160 -62
  5. package/dist/client-session/adapter.js.map +1 -1
  6. package/dist/devtools/devtools-server.d.ts.map +1 -1
  7. package/dist/devtools/devtools-server.js +25 -19
  8. package/dist/devtools/devtools-server.js.map +1 -1
  9. package/dist/devtools/mod.d.ts +1 -1
  10. package/dist/devtools/mod.d.ts.map +1 -1
  11. package/dist/devtools/mod.js +1 -1
  12. package/dist/devtools/mod.js.map +1 -1
  13. package/dist/devtools/vite-dev-server.d.ts +22 -4
  14. package/dist/devtools/vite-dev-server.d.ts.map +1 -1
  15. package/dist/devtools/vite-dev-server.js +45 -11
  16. package/dist/devtools/vite-dev-server.js.map +1 -1
  17. package/dist/leader-thread-shared.d.ts +8 -7
  18. package/dist/leader-thread-shared.d.ts.map +1 -1
  19. package/dist/leader-thread-shared.js +19 -16
  20. package/dist/leader-thread-shared.js.map +1 -1
  21. package/dist/make-leader-worker.d.ts +5 -3
  22. package/dist/make-leader-worker.d.ts.map +1 -1
  23. package/dist/make-leader-worker.js +75 -59
  24. package/dist/make-leader-worker.js.map +1 -1
  25. package/dist/rpc-worker.d.ts +8 -0
  26. package/dist/rpc-worker.d.ts.map +1 -0
  27. package/dist/rpc-worker.js +5 -0
  28. package/dist/rpc-worker.js.map +1 -0
  29. package/dist/shutdown-channel.d.ts.map +1 -1
  30. package/dist/shutdown-channel.js.map +1 -1
  31. package/dist/webchannel.d.ts +1 -1
  32. package/dist/webchannel.d.ts.map +1 -1
  33. package/dist/webchannel.js +17 -18
  34. package/dist/webchannel.js.map +1 -1
  35. package/dist/worker-schema.d.ts +274 -138
  36. package/dist/worker-schema.d.ts.map +1 -1
  37. package/dist/worker-schema.js +79 -45
  38. package/dist/worker-schema.js.map +1 -1
  39. package/package.json +51 -20
  40. package/src/client-session/adapter.ts +228 -124
  41. package/src/devtools/devtools-server.ts +50 -35
  42. package/src/devtools/mod.ts +1 -1
  43. package/src/devtools/vite-dev-server.ts +65 -15
  44. package/src/leader-thread-shared.ts +37 -34
  45. package/src/make-leader-worker.ts +164 -115
  46. package/src/rpc-worker.ts +28 -0
  47. package/src/shutdown-channel.ts +1 -0
  48. package/src/webchannel.ts +21 -28
  49. package/src/worker-schema.ts +132 -117
@@ -1,30 +1,32 @@
1
1
  import './thread-polyfill.ts'
2
-
3
2
  import inspector from 'node:inspector'
4
3
 
5
- if (process.execArgv.includes('--inspect')) {
4
+ if (process.execArgv.includes('--inspect') === true) {
6
5
  inspector.open()
7
6
  inspector.waitForDebugger()
8
7
  }
9
8
 
10
9
  import type { SyncOptions } from '@livestore/common'
11
- import { UnexpectedError } from '@livestore/common'
12
- import { Eventlog, LeaderThreadCtx } from '@livestore/common/leader-thread'
10
+ import { LogConfig, UnknownError } from '@livestore/common'
11
+ import type { StreamEventsOptions } from '@livestore/common/leader-thread'
12
+ import { Eventlog, LeaderThreadCtx, streamEventsWithSyncState } from '@livestore/common/leader-thread'
13
13
  import type { LiveStoreSchema } from '@livestore/common/schema'
14
14
  import { LiveStoreEvent } from '@livestore/common/schema'
15
15
  import { loadSqlite3Wasm } from '@livestore/sqlite-wasm/load-wasm'
16
16
  import { sqliteDbFactory } from '@livestore/sqlite-wasm/node'
17
+ import { isDevEnv } from '@livestore/utils'
17
18
  import {
18
19
  Effect,
19
20
  FetchHttpClient,
20
- identity,
21
21
  Layer,
22
- Logger,
23
- LogLevel,
24
22
  OtelTracer,
23
+ Queue,
24
+ References,
25
+ RpcServer,
26
+ RpcWorker,
25
27
  Schema,
28
+ Scope,
26
29
  Stream,
27
- WorkerRunner,
28
30
  } from '@livestore/utils/effect'
29
31
  import { PlatformNode } from '@livestore/utils/node'
30
32
  import type * as otel from '@opentelemetry/api'
@@ -36,132 +38,179 @@ import * as WorkerSchema from './worker-schema.ts'
36
38
  export type WorkerOptions = {
37
39
  schema: LiveStoreSchema
38
40
  sync?: SyncOptions
41
+ syncPayloadSchema?: Schema.Schema<any>
39
42
  otelOptions?: {
40
43
  tracer?: otel.Tracer
41
44
  /** @default 'livestore-node-leader-thread' */
42
45
  serviceName?: string
43
46
  }
44
47
  testing?: TestingOverrides
45
- }
48
+ } & LogConfig.LoggerOptions
46
49
 
47
50
  export const getWorkerArgs = () => Schema.decodeSync(WorkerSchema.WorkerArgv)(process.argv[2]!)
48
51
 
49
52
  export const makeWorker = (options: WorkerOptions) => {
50
- makeWorkerEffect(options).pipe(
51
- Effect.provide(Logger.prettyWithThread(options.otelOptions?.serviceName ?? 'livestore-node-leader-thread')),
52
- PlatformNode.NodeRuntime.runMain,
53
- )
53
+ makeWorkerEffect(options).pipe(PlatformNode.NodeRuntime.runMain)
54
54
  }
55
55
 
56
56
  export const makeWorkerEffect = (options: WorkerOptions) => {
57
- const TracingLive = options.otelOptions?.tracer
58
- ? Layer.unwrapEffect(Effect.map(OtelTracer.make, Layer.setTracer)).pipe(
59
- Layer.provideMerge(Layer.succeed(OtelTracer.OtelTracer, options.otelOptions.tracer)),
60
- )
61
- : undefined
62
-
63
- return WorkerRunner.layerSerialized(WorkerSchema.LeaderWorkerInnerRequest, {
64
- InitialMessage: (args) =>
65
- Effect.gen(function* () {
66
- const sqlite3 = yield* Effect.promise(() => loadSqlite3Wasm()).pipe(
67
- Effect.withSpan('@livestore/adapter-node:leader-thread:loadSqlite3Wasm'),
57
+ const TracingLive =
58
+ options.otelOptions?.tracer !== undefined
59
+ ? OtelTracer.layerWithoutOtelTracer.pipe(
60
+ Layer.provideMerge(Layer.succeed(OtelTracer.OtelTracer, options.otelOptions.tracer)),
68
61
  )
69
- const makeSqliteDb = yield* sqliteDbFactory({ sqlite3 })
70
- return yield* makeLeaderThread({
71
- ...args,
72
- syncOptions: options.sync,
73
- schema: options.schema,
74
- testing: options.testing,
75
- makeSqliteDb,
76
- })
77
- }).pipe(Layer.unwrapScoped),
78
- PushToLeader: ({ batch }) =>
79
- Effect.andThen(LeaderThreadCtx, (_) =>
80
- _.syncProcessor.push(
81
- batch.map((item) => new LiveStoreEvent.EncodedWithMeta(item)),
82
- // We'll wait in order to keep back pressure on the client session
83
- { waitForProcessing: true },
84
- ),
85
- ).pipe(Effect.uninterruptible, Effect.withSpan('@livestore/adapter-node:worker:PushToLeader')),
86
- BootStatusStream: () =>
87
- Effect.andThen(LeaderThreadCtx, (_) => Stream.fromQueue(_.bootStatusQueue)).pipe(Stream.unwrap),
88
- PullStream: ({ cursor }) =>
89
- Effect.gen(function* () {
90
- const { syncProcessor } = yield* LeaderThreadCtx
91
- return syncProcessor.pull({ cursor })
92
- }).pipe(Stream.unwrapScoped),
93
- Export: () =>
94
- Effect.andThen(LeaderThreadCtx, (_) => _.dbState.export()).pipe(
95
- UnexpectedError.mapToUnexpectedError,
96
- Effect.withSpan('@livestore/adapter-node:worker:Export'),
97
- ),
98
- ExportEventlog: () =>
99
- Effect.andThen(LeaderThreadCtx, (_) => _.dbEventlog.export()).pipe(
100
- UnexpectedError.mapToUnexpectedError,
101
- Effect.withSpan('@livestore/adapter-node:worker:ExportEventlog'),
102
- ),
103
- GetLeaderHead: () =>
104
- Effect.gen(function* () {
105
- const workerCtx = yield* LeaderThreadCtx
106
- return Eventlog.getClientHeadFromDb(workerCtx.dbEventlog)
107
- }).pipe(UnexpectedError.mapToUnexpectedError, Effect.withSpan('@livestore/adapter-node:worker:GetLeaderHead')),
108
- GetLeaderSyncState: () =>
109
- Effect.gen(function* () {
110
- const workerCtx = yield* LeaderThreadCtx
111
- return yield* workerCtx.syncProcessor.syncState
112
- }).pipe(
113
- UnexpectedError.mapToUnexpectedError,
114
- Effect.withSpan('@livestore/adapter-node:worker:GetLeaderSyncState'),
115
- ),
116
- GetRecreateSnapshot: () =>
117
- Effect.gen(function* () {
118
- const workerCtx = yield* LeaderThreadCtx
119
- // const result = yield* Deferred.await(workerCtx.initialSetupDeferred)
120
- // NOTE we can only return the cached snapshot once as it's transferred (i.e. disposed), so we need to set it to undefined
121
- // const cachedSnapshot =
122
- // result._tag === 'Recreate' ? yield* Ref.getAndSet(result.snapshotRef, undefined) : undefined
123
- // return cachedSnapshot ?? workerCtx.db.export()
124
- const snapshot = workerCtx.dbState.export()
125
- return { snapshot, migrationsReport: workerCtx.initialState.migrationsReport }
126
- }).pipe(
127
- UnexpectedError.mapToUnexpectedError,
128
- Effect.withSpan('@livestore/adapter-node:worker:GetRecreateSnapshot'),
129
- ),
130
- Shutdown: () =>
131
- Effect.gen(function* () {
132
- // const { db, dbEventlog } = yield* LeaderThreadCtx
133
- yield* Effect.logDebug('[@livestore/adapter-node:worker] Shutdown')
134
-
135
- // if (devtools.enabled) {
136
- // yield* FiberSet.clear(devtools.connections)
137
- // }
138
- // db.close()
139
- // dbEventlog.close()
140
-
141
- // Buy some time for Otel to flush
142
- // TODO find a cleaner way to do this
143
- // yield* Effect.sleep(1000)
144
- }).pipe(UnexpectedError.mapToUnexpectedError, Effect.withSpan('@livestore/adapter-node:worker:Shutdown')),
145
- ExtraDevtoolsMessage: ({ message }) =>
146
- Effect.andThen(LeaderThreadCtx, (_) => _.extraIncomingMessagesQueue.offer(message)).pipe(
147
- UnexpectedError.mapToUnexpectedError,
148
- Effect.withSpan('@livestore/adapter-node:worker:ExtraDevtoolsMessage'),
149
- ),
150
- }).pipe(
151
- Layer.provide(PlatformNode.NodeWorkerRunner.layer),
152
- WorkerRunner.launch,
62
+ : Layer.empty
63
+
64
+ // Merge the runtime dependencies once so we can provide them together without chaining Effect.provide.
65
+ const runtimeLayer = Layer.mergeAll(FetchHttpClient.layer, PlatformNode.NodeFileSystem.layer, TracingLive)
66
+
67
+ return RpcServer.make(WorkerSchema.LeaderWorkerInnerRpcs).pipe(
68
+ Effect.provide(makeWorkerRunnerInner(options)),
69
+ Effect.provide(RpcServer.layerProtocolWorkerRunner),
70
+ Effect.provide(PlatformNode.NodeWorkerRunner.layer),
153
71
  Effect.scoped,
154
72
  Effect.tapCauseLogPretty,
155
73
  Effect.annotateLogs({
156
74
  thread: options.otelOptions?.serviceName ?? 'livestore-node-leader-thread',
157
75
  processId: process.pid,
158
76
  }),
159
- // TODO bring back with Effect 4 once it's easier to work with replacing loggers.
160
- // We basically only want to provide this logger if it's replacing the default logger, not if there's a custom logger already provided.
161
- // Effect.provide(Logger.prettyWithThread(options.otelOptions?.serviceName ?? 'livestore-node-leader-thread')),
162
- Effect.provide(FetchHttpClient.layer),
163
- Effect.provide(PlatformNode.NodeFileSystem.layer),
164
- TracingLive ? Effect.provide(TracingLive) : identity,
165
- Logger.withMinimumLogLevel(LogLevel.Debug),
77
+ Effect.provide(
78
+ Layer.mergeAll(
79
+ options.logger ?? Layer.empty,
80
+ Layer.succeed(References.MinimumLogLevel, options.logLevel ?? (isDevEnv() === true ? 'Debug' : 'Info')),
81
+ ),
82
+ ),
83
+ Effect.provide(runtimeLayer),
166
84
  )
167
85
  }
86
+
87
+ const makeWorkerRunnerInner = (options: WorkerOptions) =>
88
+ WorkerSchema.LeaderWorkerInnerRpcs.toLayer(
89
+ Effect.gen(function* () {
90
+ const leaderThreadScope = yield* Scope.make()
91
+ yield* Effect.addFinalizer((exit) => Scope.close(leaderThreadScope, exit))
92
+
93
+ const leaderThreadContextOnce = yield* Effect.cached(
94
+ Effect.gen(function* () {
95
+ const { storeId, clientId, storage, devtools, syncPayloadEncoded } = yield* RpcWorker.initialMessage(
96
+ WorkerSchema.LeaderWorkerInnerInitialMessage.payloadSchema,
97
+ )
98
+
99
+ const sqlite3 = yield* Effect.promise(() => loadSqlite3Wasm()).pipe(
100
+ Effect.withSpan('@livestore/adapter-node:leader-thread:loadSqlite3Wasm'),
101
+ )
102
+ const makeSqliteDb = yield* sqliteDbFactory({ sqlite3 })
103
+
104
+ const layer = yield* makeLeaderThread({
105
+ storeId,
106
+ clientId,
107
+ storage,
108
+ devtools,
109
+ syncOptions: options.sync,
110
+ schema: options.schema,
111
+ testing: options.testing,
112
+ makeSqliteDb,
113
+ syncPayloadEncoded,
114
+ syncPayloadSchema: options.syncPayloadSchema,
115
+ })
116
+
117
+ return yield* Layer.buildWithScope(layer, leaderThreadScope)
118
+ }).pipe(
119
+ Scope.provide(leaderThreadScope),
120
+ Effect.tapCauseLogPretty,
121
+ UnknownError.mapToUnknownError,
122
+ Effect.withSpan('@livestore/adapter-node:worker:InitialMessage'),
123
+ Effect.orDie,
124
+ ),
125
+ )
126
+
127
+ const provideLeaderThread = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
128
+ Effect.gen(function* () {
129
+ const leaderThreadContext = yield* leaderThreadContextOnce
130
+ return yield* effect.pipe(Effect.provide(leaderThreadContext))
131
+ })
132
+
133
+ return WorkerSchema.LeaderWorkerInnerRpcs.of({
134
+ GetRecreateSnapshot: () =>
135
+ Effect.gen(function* () {
136
+ const workerCtx = yield* LeaderThreadCtx
137
+ const snapshot = workerCtx.dbState.export()
138
+ return { snapshot, migrationsReport: workerCtx.initialState.migrationsReport }
139
+ }).pipe(provideLeaderThread, Effect.withSpan('@livestore/adapter-node:worker:GetRecreateSnapshot')),
140
+ PullStream: ({ cursor }) =>
141
+ Effect.gen(function* () {
142
+ const { syncProcessor } = yield* LeaderThreadCtx
143
+ return syncProcessor.pull({ cursor })
144
+ }).pipe(provideLeaderThread, Stream.unwrap),
145
+ PushToLeader: ({ batch }) =>
146
+ Effect.andThen(LeaderThreadCtx, ({ syncProcessor }) =>
147
+ syncProcessor.push(batch.map((event) => new LiveStoreEvent.Client.EncodedWithMeta(event))),
148
+ ).pipe(provideLeaderThread, Effect.uninterruptible, Effect.withSpan('@livestore/adapter-node:worker:PushToLeader')),
149
+ StreamEvents: (options) =>
150
+ LeaderThreadCtx.pipe(
151
+ Effect.map(({ dbEventlog, syncProcessor }) =>
152
+ streamEventsWithSyncState({
153
+ dbEventlog,
154
+ syncState: syncProcessor.syncState,
155
+ options: options as StreamEventsOptions,
156
+ }),
157
+ ),
158
+ provideLeaderThread,
159
+ Stream.unwrap,
160
+ Stream.withSpan('@livestore/adapter-node:worker:StreamEvents'),
161
+ ),
162
+ Export: () =>
163
+ LeaderThreadCtx.pipe(
164
+ Effect.flatMap((_) => Effect.sync(() => _.dbState.export())),
165
+ provideLeaderThread,
166
+ Effect.withSpan('@livestore/adapter-node:worker:Export'),
167
+ ),
168
+ ExportEventlog: () =>
169
+ LeaderThreadCtx.pipe(
170
+ Effect.flatMap((_) => Effect.sync(() => _.dbEventlog.export())),
171
+ provideLeaderThread,
172
+ Effect.withSpan('@livestore/adapter-node:worker:ExportEventlog'),
173
+ ),
174
+ BootStatusStream: () =>
175
+ LeaderThreadCtx.pipe(
176
+ Effect.map((_) => Stream.fromQueue(_.bootStatusQueue)),
177
+ provideLeaderThread,
178
+ Stream.unwrap,
179
+ ),
180
+ GetLeaderHead: () =>
181
+ Effect.gen(function* () {
182
+ const workerCtx = yield* LeaderThreadCtx
183
+ return Eventlog.getClientHeadFromDb(workerCtx.dbEventlog)
184
+ }).pipe(provideLeaderThread, Effect.withSpan('@livestore/adapter-node:worker:GetLeaderHead')),
185
+ GetLeaderSyncState: () =>
186
+ Effect.gen(function* () {
187
+ const workerCtx = yield* LeaderThreadCtx
188
+ return yield* workerCtx.syncProcessor.syncState
189
+ }).pipe(provideLeaderThread, Effect.withSpan('@livestore/adapter-node:worker:GetLeaderSyncState')),
190
+ SyncStateStream: () =>
191
+ Effect.gen(function* () {
192
+ const workerCtx = yield* LeaderThreadCtx
193
+ return workerCtx.syncProcessor.syncState.changes
194
+ }).pipe(provideLeaderThread, Stream.unwrap),
195
+ GetNetworkStatus: () =>
196
+ Effect.gen(function* () {
197
+ const workerCtx = yield* LeaderThreadCtx
198
+ return yield* workerCtx.networkStatus
199
+ }).pipe(provideLeaderThread, Effect.withSpan('@livestore/adapter-node:worker:GetNetworkStatus')),
200
+ NetworkStatusStream: () =>
201
+ Effect.gen(function* () {
202
+ const workerCtx = yield* LeaderThreadCtx
203
+ return workerCtx.networkStatus.changes
204
+ }).pipe(provideLeaderThread, Stream.unwrap),
205
+ Shutdown: Effect.fn('@livestore/adapter-node:worker:Shutdown')(function* () {
206
+ yield* Effect.logDebug('[@livestore/adapter-node:worker] Shutdown')
207
+ }),
208
+ ExtraDevtoolsMessage: ({ message }) =>
209
+ Effect.andThen(LeaderThreadCtx, (_) => Queue.offer(_.extraIncomingMessagesQueue, message)).pipe(
210
+ provideLeaderThread,
211
+ Effect.asVoid,
212
+ Effect.withSpan('@livestore/adapter-node:worker:ExtraDevtoolsMessage'),
213
+ ),
214
+ })
215
+ }),
216
+ )
@@ -0,0 +1,28 @@
1
+ import { Effect, RpcClientError, Schema, Stream } from '@livestore/utils/effect'
2
+
3
+ export type WithoutRpcClientError<E> = E extends { readonly _tag: infer Tag }
4
+ ? 'RpcClientError' extends Tag
5
+ ? never
6
+ : E
7
+ : E
8
+
9
+ export const isRpcClientError = (error: unknown): error is RpcClientError.RpcClientError =>
10
+ Schema.is(RpcClientError.RpcClientError)(error)
11
+
12
+ export const dieOnRpcClientError = <A, E, R>(
13
+ effect: Effect.Effect<A, E, R>,
14
+ ): Effect.Effect<A, WithoutRpcClientError<E>, R> =>
15
+ (effect as Effect.Effect<A, RpcClientError.RpcClientError | WithoutRpcClientError<E>, R>).pipe(
16
+ Effect.catchIf(isRpcClientError, (error) => Effect.die(error)),
17
+ ) as Effect.Effect<A, WithoutRpcClientError<E>, R>
18
+
19
+ export const dieOnRpcClientErrorStream = <A, E, R>(
20
+ stream: Stream.Stream<A, E, R>,
21
+ ): Stream.Stream<A, WithoutRpcClientError<E>, R> =>
22
+ (stream as Stream.Stream<A, RpcClientError.RpcClientError | WithoutRpcClientError<E>, R>).pipe(
23
+ Stream.catchIf(
24
+ isRpcClientError,
25
+ (error) => Stream.die(error),
26
+ (error) => Stream.fail(error),
27
+ ),
28
+ ) as Stream.Stream<A, WithoutRpcClientError<E>, R>
@@ -1,5 +1,6 @@
1
1
  import { ShutdownChannel } from '@livestore/common/leader-thread'
2
2
  import type { Effect, Scope } from '@livestore/utils/effect'
3
+
3
4
  import { makeBroadcastChannel } from './webchannel.ts'
4
5
 
5
6
  export const makeShutdownChannel = (
package/src/webchannel.ts CHANGED
@@ -1,14 +1,13 @@
1
1
  import type { BroadcastChannel as NodeBroadcastChannel } from 'node:worker_threads'
2
2
 
3
- import type { Either, ParseResult } from '@livestore/utils/effect'
4
- import { Deferred, Effect, Exit, Schema, Scope, Stream, WebChannel } from '@livestore/utils/effect'
3
+ import { Deferred, Effect, Exit, Queue, Schema, Scope, Stream, WebChannel } from '@livestore/utils/effect'
5
4
 
6
5
  export const makeBroadcastChannel = <Msg, MsgEncoded>({
7
6
  channelName,
8
7
  schema,
9
8
  }: {
10
9
  channelName: string
11
- schema: Schema.Schema<Msg, MsgEncoded>
10
+ schema: Schema.Codec<Msg, MsgEncoded>
12
11
  }): Effect.Effect<WebChannel.WebChannel<Msg, Msg>, never, Scope.Scope> =>
13
12
  Effect.scopeWithCloseable((scope) =>
14
13
  Effect.gen(function* () {
@@ -21,39 +20,33 @@ export const makeBroadcastChannel = <Msg, MsgEncoded>({
21
20
  // from `node:worker_threads` is not yet stable in Deno
22
21
  const channel = new globalThis.BroadcastChannel(channelName) as any as NodeBroadcastChannel
23
22
 
24
- yield* Effect.addFinalizer(() => Effect.try(() => channel.close()).pipe(Effect.ignoreLogged))
23
+ yield* Effect.addFinalizer(() =>
24
+ Effect.try(() => {
25
+ // NOTE not all BroadcastChannel implementations have the `unref` method
26
+ if (typeof channel.unref === 'function') {
27
+ channel.unref()
28
+ }
29
+ channel.close()
30
+ }).pipe(Effect.ignore),
31
+ )
25
32
 
26
33
  const send = (message: Msg) =>
27
34
  Effect.gen(function* () {
28
- const messageEncoded = yield* Schema.encode(schema)(message)
35
+ const messageEncoded = yield* Schema.encodeEffect(schema)(message)
29
36
  channel.postMessage(messageEncoded)
30
37
  })
31
38
 
32
- // TODO also listen to `messageerror` in parallel
33
- // const listen = Stream.fromEventListener<MessageEvent>(channel, 'message').pipe(
34
- // Stream.map((_) => Schema.decodeEither(listenSchema)(_.data)),
35
- // )
36
-
37
- const listen = Stream.asyncPush<Either.Either<Msg, ParseResult.ParseError>>((emit) =>
38
- Effect.acquireRelease(
39
- Effect.gen(function* () {
40
- channel.onmessage = (event: any) => {
41
- return emit.single(Schema.decodeEither(schema)(event.data))
42
- }
39
+ // In Effect v4, forking a consumer is not a readiness handshake. Install the message
40
+ // listener while constructing the channel and buffer into a scoped queue so early messages
41
+ // are not lost before `listen` is pulled.
42
+ const messageQueue = yield* Effect.acquireRelease(Queue.unbounded<unknown>(), Queue.shutdown)
43
+ channel.onmessage = (event: any) => {
44
+ Queue.offerUnsafe(messageQueue, event.data)
45
+ }
43
46
 
44
- return channel
45
- }),
46
- (channel) =>
47
- Effect.sync(() => {
48
- // NOTE not all BroadcastChannel implementations have the `unref` method
49
- if (typeof channel.unref === 'function') {
50
- channel.unref()
51
- }
52
- }),
53
- ),
54
- )
47
+ const listen = Stream.fromQueue(messageQueue).pipe(Stream.map((data) => Schema.decodeUnknownResult(schema)(data)))
55
48
 
56
- const closedDeferred = yield* Deferred.make<void>().pipe(Effect.acquireRelease(Deferred.done(Exit.void)))
49
+ const closedDeferred = yield* Effect.acquireRelease(Deferred.make<void>(), Deferred.done(Exit.void))
57
50
  const supportsTransferables = false
58
51
 
59
52
  return {