@livestore/sync-cf 0.5.0-dev.0 → 0.5.0-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.
- package/README.md +5 -5
- package/dist/.tsbuildinfo +1 -1
- package/dist/cf-worker/do/durable-object.d.ts.map +1 -1
- package/dist/cf-worker/do/durable-object.js +34 -30
- package/dist/cf-worker/do/durable-object.js.map +1 -1
- package/dist/cf-worker/do/layer.d.ts.map +1 -1
- package/dist/cf-worker/do/layer.js +2 -2
- package/dist/cf-worker/do/observability.d.ts +23 -0
- package/dist/cf-worker/do/observability.d.ts.map +1 -0
- package/dist/cf-worker/do/observability.js +52 -0
- package/dist/cf-worker/do/observability.js.map +1 -0
- package/dist/cf-worker/do/observability.test.d.ts +2 -0
- package/dist/cf-worker/do/observability.test.d.ts.map +1 -0
- package/dist/cf-worker/do/observability.test.js +204 -0
- package/dist/cf-worker/do/observability.test.js.map +1 -0
- package/dist/cf-worker/do/pull.d.ts.map +1 -1
- package/dist/cf-worker/do/pull.js +3 -3
- package/dist/cf-worker/do/push.d.ts +2 -2
- package/dist/cf-worker/do/push.d.ts.map +1 -1
- package/dist/cf-worker/do/push.js +7 -6
- package/dist/cf-worker/do/push.js.map +1 -1
- package/dist/cf-worker/do/push.test.js +1 -1
- package/dist/cf-worker/do/sqlite.js +1 -1
- package/dist/cf-worker/do/sync-storage.d.ts.map +1 -1
- package/dist/cf-worker/do/sync-storage.js +3 -3
- package/dist/cf-worker/do/transport/do-rpc-server.d.ts +15 -0
- package/dist/cf-worker/do/transport/do-rpc-server.d.ts.map +1 -1
- package/dist/cf-worker/do/transport/do-rpc-server.js +23 -7
- package/dist/cf-worker/do/transport/do-rpc-server.js.map +1 -1
- package/dist/cf-worker/do/transport/do-rpc-server.test.d.ts +2 -0
- package/dist/cf-worker/do/transport/do-rpc-server.test.d.ts.map +1 -0
- package/dist/cf-worker/do/transport/do-rpc-server.test.js +28 -0
- package/dist/cf-worker/do/transport/do-rpc-server.test.js.map +1 -0
- package/dist/cf-worker/do/transport/http-rpc-server.js +6 -6
- package/dist/cf-worker/do/transport/ws-rpc-server.d.ts +8 -1
- package/dist/cf-worker/do/transport/ws-rpc-server.d.ts.map +1 -1
- package/dist/cf-worker/do/transport/ws-rpc-server.js +32 -24
- package/dist/cf-worker/do/transport/ws-rpc-server.js.map +1 -1
- package/dist/cf-worker/mod.js +3 -3
- package/dist/cf-worker/shared.d.ts +17 -12
- package/dist/cf-worker/shared.d.ts.map +1 -1
- package/dist/cf-worker/shared.js +2 -2
- package/dist/cf-worker/shared.js.map +1 -1
- package/dist/cf-worker/worker.d.ts +3 -3
- package/dist/cf-worker/worker.d.ts.map +1 -1
- package/dist/cf-worker/worker.js +2 -2
- package/dist/cf-worker/worker.js.map +1 -1
- package/dist/client/mod.js +3 -3
- package/dist/client/transport/do-rpc-client.d.ts +6 -3
- package/dist/client/transport/do-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/do-rpc-client.js +65 -20
- package/dist/client/transport/do-rpc-client.js.map +1 -1
- package/dist/client/transport/do-rpc-client.test.d.ts +2 -0
- package/dist/client/transport/do-rpc-client.test.d.ts.map +1 -0
- package/dist/client/transport/do-rpc-client.test.js +268 -0
- package/dist/client/transport/do-rpc-client.test.js.map +1 -0
- package/dist/client/transport/http-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/http-rpc-client.js +20 -10
- package/dist/client/transport/http-rpc-client.js.map +1 -1
- package/dist/client/transport/rpc-client-error.d.ts +5 -0
- package/dist/client/transport/rpc-client-error.d.ts.map +1 -0
- package/dist/client/transport/rpc-client-error.js +8 -0
- package/dist/client/transport/rpc-client-error.js.map +1 -0
- package/dist/client/transport/rpc-client-error.test.d.ts +2 -0
- package/dist/client/transport/rpc-client-error.test.d.ts.map +1 -0
- package/dist/client/transport/rpc-client-error.test.js +33 -0
- package/dist/client/transport/rpc-client-error.test.js.map +1 -0
- package/dist/client/transport/ws-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/ws-rpc-client.js +18 -12
- package/dist/client/transport/ws-rpc-client.js.map +1 -1
- package/dist/common/do-rpc-schema.d.ts +24 -11
- package/dist/common/do-rpc-schema.d.ts.map +1 -1
- package/dist/common/do-rpc-schema.js +10 -1
- package/dist/common/do-rpc-schema.js.map +1 -1
- package/dist/common/http-rpc-schema.d.ts +8 -8
- package/dist/common/http-rpc-schema.js +1 -1
- package/dist/common/mod.d.ts +1 -0
- package/dist/common/mod.d.ts.map +1 -1
- package/dist/common/mod.js +4 -3
- package/dist/common/mod.js.map +1 -1
- package/dist/common/sync-message-types.d.ts.map +1 -1
- package/dist/common/ws-rpc-schema.d.ts +1 -1
- package/dist/common/ws-rpc-schema.js +1 -1
- package/package.json +41 -41
- package/src/cf-worker/do/durable-object.ts +42 -45
- package/src/cf-worker/do/observability.test.ts +337 -0
- package/src/cf-worker/do/observability.ts +121 -0
- package/src/cf-worker/do/push.ts +3 -2
- package/src/cf-worker/do/transport/do-rpc-server.test.ts +32 -0
- package/src/cf-worker/do/transport/do-rpc-server.ts +23 -1
- package/src/cf-worker/do/transport/ws-rpc-server.ts +45 -21
- package/src/cf-worker/shared.ts +13 -6
- package/src/cf-worker/worker.ts +1 -1
- package/src/client/transport/do-rpc-client.test.ts +411 -0
- package/src/client/transport/do-rpc-client.ts +100 -18
- package/src/client/transport/http-rpc-client.ts +20 -9
- package/src/client/transport/rpc-client-error.test.ts +41 -0
- package/src/client/transport/rpc-client-error.ts +9 -0
- package/src/client/transport/ws-rpc-client.ts +15 -10
- package/src/common/do-rpc-schema.ts +10 -0
- package/src/common/mod.ts +1 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { ROOT_CONTEXT, type SpanStatus, SpanStatusCode, trace } from '@opentelemetry/api'
|
|
2
|
+
|
|
3
|
+
import { BackendIdMismatchError, UnknownError } from '@livestore/common'
|
|
4
|
+
import { type CfTypes, layerProtocolDurableObject, WsContext } from '@livestore/common-cf'
|
|
5
|
+
import { makeNoopSpan, makeNoopTracer } from '@livestore/utils'
|
|
6
|
+
import { Vitest } from '@livestore/utils-dev/node-vitest'
|
|
7
|
+
import {
|
|
8
|
+
Deferred,
|
|
9
|
+
Effect,
|
|
10
|
+
FetchHttpClient,
|
|
11
|
+
Fiber,
|
|
12
|
+
Layer,
|
|
13
|
+
Option,
|
|
14
|
+
OtelTracer,
|
|
15
|
+
RpcClient,
|
|
16
|
+
RpcTest,
|
|
17
|
+
Schema,
|
|
18
|
+
Stream,
|
|
19
|
+
TestClock,
|
|
20
|
+
Tracer,
|
|
21
|
+
} from '@livestore/utils/effect'
|
|
22
|
+
|
|
23
|
+
import { SyncDoRpc } from '../../common/do-rpc-schema.ts'
|
|
24
|
+
import { SyncWsRpc } from '../../common/ws-rpc-schema.ts'
|
|
25
|
+
import { WebSocketAttachmentSchema } from '../shared.ts'
|
|
26
|
+
import type * as DoCtx from './layer.ts'
|
|
27
|
+
import { makeObservability, rpcSpanOptions, withRpcSpan, withRpcStreamSpan } from './observability.ts'
|
|
28
|
+
import { createDoRpcHandler } from './transport/do-rpc-server.ts'
|
|
29
|
+
import { makeRpcHandlers } from './transport/ws-rpc-server.ts'
|
|
30
|
+
|
|
31
|
+
Vitest.describe('sync-cf telemetry ownership', () => {
|
|
32
|
+
Vitest.afterEach(() => Vitest.vi.restoreAllMocks())
|
|
33
|
+
|
|
34
|
+
Vitest.effect('runs sync effects and streams without exporting when telemetry is omitted', () =>
|
|
35
|
+
Effect.gen(function* () {
|
|
36
|
+
const fetch: typeof globalThis.fetch = Vitest.vi.fn(async () => new Response('{}'))
|
|
37
|
+
const observability = makeObservability(undefined)
|
|
38
|
+
const result = yield* Effect.succeed('ack').pipe(
|
|
39
|
+
Effect.withSpan('push'),
|
|
40
|
+
observability.effect,
|
|
41
|
+
Effect.provideService(FetchHttpClient.Fetch, fetch),
|
|
42
|
+
)
|
|
43
|
+
const history = yield* Stream.make('event').pipe(
|
|
44
|
+
Stream.withSpan('pull'),
|
|
45
|
+
observability.stream,
|
|
46
|
+
Stream.runCollect,
|
|
47
|
+
Effect.provideService(FetchHttpClient.Fetch, fetch),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
yield* TestClock.adjust('4 seconds')
|
|
51
|
+
Vitest.expect(result).toBe('ack')
|
|
52
|
+
Vitest.expect(history).toEqual(['event'])
|
|
53
|
+
Vitest.expect(fetch).not.toHaveBeenCalled()
|
|
54
|
+
}),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
Vitest.effect('builds and finalizes a supplied layer independently for overlapping operations', () =>
|
|
58
|
+
Effect.gen(function* () {
|
|
59
|
+
const firstStarted = yield* Deferred.make<void>()
|
|
60
|
+
const secondStarted = yield* Deferred.make<void>()
|
|
61
|
+
const firstGate = yield* Deferred.make<void>()
|
|
62
|
+
const secondGate = yield* Deferred.make<void>()
|
|
63
|
+
const firstReleased = yield* Deferred.make<void>()
|
|
64
|
+
const secondReleased = yield* Deferred.make<void>()
|
|
65
|
+
const builds: string[] = []
|
|
66
|
+
const releases: string[] = []
|
|
67
|
+
const layer = Layer.effectDiscard(
|
|
68
|
+
Effect.acquireRelease(
|
|
69
|
+
Effect.currentSpan.pipe(
|
|
70
|
+
Effect.orDie,
|
|
71
|
+
Effect.tap((span) => Effect.sync(() => builds.push(span.name))),
|
|
72
|
+
),
|
|
73
|
+
(span) =>
|
|
74
|
+
Effect.sync(() => releases.push(span.name)).pipe(
|
|
75
|
+
Effect.andThen(Deferred.succeed(span.name === 'first' ? firstReleased : secondReleased, undefined)),
|
|
76
|
+
),
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
const observability = makeObservability(layer)
|
|
80
|
+
const first = yield* Deferred.succeed(firstStarted, undefined).pipe(
|
|
81
|
+
Effect.andThen(Deferred.await(firstGate)),
|
|
82
|
+
observability.effect,
|
|
83
|
+
Effect.withSpan('first'),
|
|
84
|
+
Effect.forkChild,
|
|
85
|
+
)
|
|
86
|
+
yield* Deferred.await(firstStarted)
|
|
87
|
+
const second = yield* Deferred.succeed(secondStarted, undefined).pipe(
|
|
88
|
+
Effect.andThen(Deferred.await(secondGate)),
|
|
89
|
+
observability.effect,
|
|
90
|
+
Effect.withSpan('second'),
|
|
91
|
+
Effect.forkChild,
|
|
92
|
+
)
|
|
93
|
+
yield* Deferred.await(secondStarted)
|
|
94
|
+
Vitest.expect(builds).toEqual(['first', 'second'])
|
|
95
|
+
Vitest.expect(releases).toEqual([])
|
|
96
|
+
|
|
97
|
+
yield* Deferred.succeed(firstGate, undefined)
|
|
98
|
+
yield* Fiber.join(first)
|
|
99
|
+
yield* Deferred.await(firstReleased)
|
|
100
|
+
Vitest.expect(releases).toEqual(['first'])
|
|
101
|
+
Vitest.expect(second.pollUnsafe()).toBeUndefined()
|
|
102
|
+
|
|
103
|
+
yield* Deferred.succeed(secondGate, undefined)
|
|
104
|
+
yield* Fiber.join(second)
|
|
105
|
+
yield* Deferred.await(secondReleased)
|
|
106
|
+
Vitest.expect(releases).toEqual(['first', 'second'])
|
|
107
|
+
}),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
Vitest.effect('finalizes finite history while the live subscription is still open', () =>
|
|
111
|
+
Effect.gen(function* () {
|
|
112
|
+
const { layer, ended } = makeRecordingTracer()
|
|
113
|
+
const finalized = yield* Deferred.make<ReadonlyArray<string>>()
|
|
114
|
+
const live = yield* Deferred.make<void>()
|
|
115
|
+
const observability = makeObservability(
|
|
116
|
+
Layer.mergeAll(layer, Layer.effectDiscard(Effect.addFinalizer(() => Deferred.succeed(finalized, [...ended])))),
|
|
117
|
+
)
|
|
118
|
+
const fiber = yield* Stream.make('history').pipe(
|
|
119
|
+
Stream.withSpan('pull-history'),
|
|
120
|
+
observability.stream,
|
|
121
|
+
Stream.concat(Stream.fromEffect(Deferred.succeed(live, undefined)).pipe(Stream.concat(Stream.never))),
|
|
122
|
+
Stream.runDrain,
|
|
123
|
+
Effect.forkChild,
|
|
124
|
+
)
|
|
125
|
+
yield* Effect.addFinalizer(() => Fiber.interrupt(fiber))
|
|
126
|
+
yield* Deferred.await(live)
|
|
127
|
+
Vitest.expect(yield* Deferred.await(finalized)).toContain('pull-history')
|
|
128
|
+
Vitest.expect(fiber.pollUnsafe()).toBeUndefined()
|
|
129
|
+
}),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
Vitest.effect('ends WebSocket telemetry before the production handler enters its live tail', () =>
|
|
133
|
+
Effect.gen(function* () {
|
|
134
|
+
const { layer, ended } = makeRecordingTracer()
|
|
135
|
+
const finalized = yield* Deferred.make<void>()
|
|
136
|
+
const observability = makeObservability(
|
|
137
|
+
Layer.mergeAll(layer, Layer.effectDiscard(Effect.addFinalizer(() => Deferred.succeed(finalized, undefined)))),
|
|
138
|
+
)
|
|
139
|
+
const input = makeDoInput()
|
|
140
|
+
const handlers = makeRpcHandlers({ ...input, observability }).pipe(
|
|
141
|
+
Layer.provide(Layer.succeed(WsContext, WsContext.of({ ws: makeWebSocket() }))),
|
|
142
|
+
)
|
|
143
|
+
const client = yield* RpcTest.makeClient(SyncWsRpc).pipe(Effect.provide(handlers))
|
|
144
|
+
const pullFiber = yield* client['SyncWsRpc.Pull']({
|
|
145
|
+
storeId: 'store',
|
|
146
|
+
cursor: Option.none(),
|
|
147
|
+
live: true,
|
|
148
|
+
}).pipe(Stream.runDrain, Effect.forkChild)
|
|
149
|
+
yield* Effect.addFinalizer(() => Fiber.interrupt(pullFiber))
|
|
150
|
+
|
|
151
|
+
yield* Deferred.await(finalized).pipe(Effect.timeout('1 second'))
|
|
152
|
+
Vitest.expect(ended).toContain('RpcServer.SyncWsRpc.Pull')
|
|
153
|
+
Vitest.expect(pullFiber.pollUnsafe()).toBeUndefined()
|
|
154
|
+
}),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
Vitest.effect('installs telemetry inside the Durable Object RPC stream runtime', () =>
|
|
158
|
+
Effect.gen(function* () {
|
|
159
|
+
const { layer, ended } = makeRecordingTracer()
|
|
160
|
+
const observability = makeObservability(layer)
|
|
161
|
+
const input = makeDoInput()
|
|
162
|
+
const protocol = layerProtocolDurableObject({
|
|
163
|
+
callerContext: { bindingName: 'TEST', durableObjectId: 'client' },
|
|
164
|
+
callRpc: (payload) =>
|
|
165
|
+
createDoRpcHandler({
|
|
166
|
+
// oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Effect RPC owns this ArrayBuffer-backed payload.
|
|
167
|
+
payload: payload as Uint8Array<ArrayBuffer>,
|
|
168
|
+
input,
|
|
169
|
+
observability,
|
|
170
|
+
}).pipe(Effect.runPromise),
|
|
171
|
+
})
|
|
172
|
+
const responses = yield* Effect.gen(function* () {
|
|
173
|
+
const client = yield* RpcClient.make(SyncDoRpc)
|
|
174
|
+
return yield* client['SyncDoRpc.Pull']({ storeId: 'store', cursor: Option.none() }).pipe(Stream.runCollect)
|
|
175
|
+
}).pipe(Effect.provide(protocol))
|
|
176
|
+
|
|
177
|
+
Vitest.expect(responses).toHaveLength(1)
|
|
178
|
+
Vitest.expect(ended).toContain('RpcServer.SyncDoRpc.Pull')
|
|
179
|
+
}),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
Vitest.effect('attaches a finite span to a sampled caller across an unexported RPC envelope', () =>
|
|
183
|
+
Effect.gen(function* () {
|
|
184
|
+
const { layer, spans, ended } = makeRecordingTracer()
|
|
185
|
+
const observability = makeObservability(layer)
|
|
186
|
+
const caller = Tracer.externalSpan({ traceId: '1'.repeat(32), spanId: '2222222222222222', sampled: true })
|
|
187
|
+
yield* Effect.flatMap(rpcSpanOptions, (options) =>
|
|
188
|
+
withRpcSpan(Effect.void.pipe(Effect.withSpan('push')), 'finite-rpc', options),
|
|
189
|
+
).pipe(observability.effect, Effect.withSpan('unexported-rpc-envelope'), Effect.withParentSpan(caller))
|
|
190
|
+
|
|
191
|
+
Vitest.expect(spans.get('finite-rpc')?.parentId).toBe(caller.spanId)
|
|
192
|
+
Vitest.expect(spans.get('push')?.parentId).toBe(spans.get('finite-rpc')?.spanId)
|
|
193
|
+
Vitest.expect(ended).toEqual(['push', 'finite-rpc'])
|
|
194
|
+
}),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
Vitest.effect('starts a backend root when the RPC caller is unsampled', () =>
|
|
198
|
+
Effect.gen(function* () {
|
|
199
|
+
const { layer, spans, ended } = makeRecordingTracer()
|
|
200
|
+
const observability = makeObservability(layer)
|
|
201
|
+
const caller = Tracer.externalSpan({ traceId: '1'.repeat(32), spanId: '2222222222222222', sampled: false })
|
|
202
|
+
yield* Effect.flatMap(rpcSpanOptions, (options) => withRpcSpan(Effect.void, 'finite-rpc', options)).pipe(
|
|
203
|
+
observability.effect,
|
|
204
|
+
Effect.withSpan('unexported-rpc-envelope'),
|
|
205
|
+
Effect.withParentSpan(caller),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
Vitest.expect(spans.get('finite-rpc')?.parentId).toBeUndefined()
|
|
209
|
+
Vitest.expect(ended).toEqual(['finite-rpc'])
|
|
210
|
+
}),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
Vitest.effect('keeps anticipated RPC recovery failures out of boundary error telemetry', () =>
|
|
214
|
+
Effect.gen(function* () {
|
|
215
|
+
const { layer, statuses } = makeRecordingTracer()
|
|
216
|
+
const observability = makeObservability(layer)
|
|
217
|
+
const expected = new BackendIdMismatchError({ expected: 'backend-a', received: 'backend-b' })
|
|
218
|
+
const unexpected = new UnknownError({ cause: new Error('boom') })
|
|
219
|
+
|
|
220
|
+
const effectError = yield* withRpcSpan(Effect.fail(expected), 'expected-effect').pipe(
|
|
221
|
+
observability.effect,
|
|
222
|
+
Effect.flip,
|
|
223
|
+
)
|
|
224
|
+
const streamError = yield* Stream.fail(expected).pipe(
|
|
225
|
+
(_) => withRpcStreamSpan(_, 'expected-stream'),
|
|
226
|
+
observability.stream,
|
|
227
|
+
Stream.runDrain,
|
|
228
|
+
Effect.flip,
|
|
229
|
+
)
|
|
230
|
+
const unexpectedError = yield* withRpcSpan(Effect.fail(unexpected), 'unexpected-effect').pipe(
|
|
231
|
+
observability.effect,
|
|
232
|
+
Effect.flip,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
Vitest.expect(effectError).toBe(expected)
|
|
236
|
+
Vitest.expect(streamError).toBe(expected)
|
|
237
|
+
Vitest.expect(unexpectedError).toBe(unexpected)
|
|
238
|
+
Vitest.expect(statuses.get('expected-effect')).toBe(SpanStatusCode.OK)
|
|
239
|
+
Vitest.expect(statuses.get('expected-stream')).toBe(SpanStatusCode.OK)
|
|
240
|
+
Vitest.expect(statuses.get('unexpected-effect')).toBe(SpanStatusCode.ERROR)
|
|
241
|
+
}),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
Vitest.effect('forwards the operation exit to supplied layer finalizers', () =>
|
|
245
|
+
Effect.gen(function* () {
|
|
246
|
+
const released = yield* Deferred.make<string>()
|
|
247
|
+
const expected = new BackendIdMismatchError({ expected: 'backend-a', received: 'backend-b' })
|
|
248
|
+
const observability = makeObservability(
|
|
249
|
+
Layer.effectDiscard(
|
|
250
|
+
Effect.acquireRelease(Effect.void, (_, exit) => Deferred.succeed(released, exit._tag).pipe(Effect.asVoid)),
|
|
251
|
+
),
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
const error = yield* Effect.fail(expected).pipe(observability.effect, Effect.flip)
|
|
255
|
+
|
|
256
|
+
Vitest.expect(error).toBe(expected)
|
|
257
|
+
Vitest.expect(yield* Deferred.await(released)).toBe('Failure')
|
|
258
|
+
}),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
Vitest.live('acknowledges before the owned OTLP collector responds', () =>
|
|
262
|
+
Effect.gen(function* () {
|
|
263
|
+
const started = Promise.withResolvers<void>()
|
|
264
|
+
const gate = Promise.withResolvers<void>()
|
|
265
|
+
const acknowledged = yield* Deferred.make<void>()
|
|
266
|
+
const fetch: typeof globalThis.fetch = Vitest.vi.fn(() => {
|
|
267
|
+
started.resolve()
|
|
268
|
+
return gate.promise.then(() => new Response('{}', { headers: { 'content-type': 'application/json' } }))
|
|
269
|
+
})
|
|
270
|
+
yield* Effect.addFinalizer(() => Effect.sync(() => gate.resolve()))
|
|
271
|
+
const observability = makeObservability({ baseUrl: 'http://collector.invalid' })
|
|
272
|
+
yield* Effect.void.pipe(
|
|
273
|
+
Effect.withSpan('push'),
|
|
274
|
+
observability.effect,
|
|
275
|
+
Effect.andThen(Deferred.succeed(acknowledged, undefined)),
|
|
276
|
+
Effect.provideService(FetchHttpClient.Fetch, fetch),
|
|
277
|
+
Effect.forkChild,
|
|
278
|
+
)
|
|
279
|
+
yield* Effect.promise(() => started.promise)
|
|
280
|
+
yield* Effect.yieldNow
|
|
281
|
+
Vitest.expect(yield* Deferred.isDone(acknowledged)).toBe(true)
|
|
282
|
+
}),
|
|
283
|
+
)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
/** Record the public OTel boundary; span IDs are compared relationally, not fixed to creation order. */
|
|
287
|
+
const makeRecordingTracer = () => {
|
|
288
|
+
const tracer = makeNoopTracer()
|
|
289
|
+
const ended: string[] = []
|
|
290
|
+
const statuses = new Map<string, SpanStatusCode>()
|
|
291
|
+
const spans = new Map<string, { spanId: string; parentId: string | undefined }>()
|
|
292
|
+
let nextId = 0
|
|
293
|
+
Vitest.vi.spyOn(tracer, 'startSpan').mockImplementation((name, _options, context) => {
|
|
294
|
+
const spanId = (++nextId).toString(16).padStart(16, '0')
|
|
295
|
+
spans.set(name, { spanId, parentId: trace.getSpanContext(context ?? ROOT_CONTEXT)?.spanId })
|
|
296
|
+
const noopSpan = makeNoopSpan()
|
|
297
|
+
return {
|
|
298
|
+
...noopSpan,
|
|
299
|
+
spanContext: () => ({ traceId: '1'.repeat(32), spanId, traceFlags: 1 }),
|
|
300
|
+
setStatus: (status: SpanStatus) => {
|
|
301
|
+
statuses.set(name, status.code)
|
|
302
|
+
return noopSpan
|
|
303
|
+
},
|
|
304
|
+
end: () => {
|
|
305
|
+
ended.push(name)
|
|
306
|
+
},
|
|
307
|
+
}
|
|
308
|
+
})
|
|
309
|
+
const layer = OtelTracer.layerWithoutOtelTracer.pipe(Layer.provide(Layer.succeed(OtelTracer.OtelTracer, tracer)))
|
|
310
|
+
return { layer, ended, spans, statuses }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Minimal empty SQLite-backed Durable Object boundary for transport composition tests. */
|
|
314
|
+
const makeDoInput = (): Omit<DoCtx.DoCtxInput, 'from'> => {
|
|
315
|
+
const emptyCursor = Object.assign([], { toArray: () => [] })
|
|
316
|
+
const doSelf = {
|
|
317
|
+
env: {},
|
|
318
|
+
ctx: {
|
|
319
|
+
storage: {
|
|
320
|
+
sql: { exec: () => emptyCursor },
|
|
321
|
+
kv: { get: () => undefined, put: () => undefined, delete: () => undefined },
|
|
322
|
+
deleteAll: async () => undefined,
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- The test intentionally stubs the narrow Worker APIs reached by an empty pull.
|
|
328
|
+
return { doSelf: doSelf as unknown as DoCtx.DoCtxInput['doSelf'], doOptions: undefined }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const makeWebSocket = (): CfTypes.WebSocket => {
|
|
332
|
+
const attachment = Schema.encodeSync(WebSocketAttachmentSchema)({ storeId: 'store', pullRequestIds: [] })
|
|
333
|
+
const ws = { deserializeAttachment: () => attachment, close: Vitest.vi.fn() }
|
|
334
|
+
|
|
335
|
+
// oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- The handler only reads the attachment and closes invalid sockets.
|
|
336
|
+
return ws as unknown as CfTypes.WebSocket
|
|
337
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { BackendIdMismatchError, ServerAheadError, UnknownError } from '@livestore/common'
|
|
2
|
+
import {
|
|
3
|
+
Effect,
|
|
4
|
+
FetchHttpClient,
|
|
5
|
+
Layer,
|
|
6
|
+
Option,
|
|
7
|
+
Otlp,
|
|
8
|
+
Ref,
|
|
9
|
+
Result,
|
|
10
|
+
Scope,
|
|
11
|
+
Stream,
|
|
12
|
+
type Tracer,
|
|
13
|
+
} from '@livestore/utils/effect'
|
|
14
|
+
|
|
15
|
+
import type { SyncBackendOtelOptions } from '../shared.ts'
|
|
16
|
+
|
|
17
|
+
/** Keep exporter ownership separate from sync scopes: their finalizers must not delay acknowledgments. */
|
|
18
|
+
export const makeObservability = (options: SyncBackendOtelOptions | undefined) => {
|
|
19
|
+
const layer = makeLayer(options)
|
|
20
|
+
|
|
21
|
+
const backgroundLayer = Layer.effectContext(
|
|
22
|
+
Effect.acquireRelease(Scope.make(), (scope, exit) => Scope.close(scope, exit).pipe(runInBackground)).pipe(
|
|
23
|
+
Effect.flatMap((scope) => Layer.buildWithScope(layer, scope)),
|
|
24
|
+
),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
effect: <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
|
|
29
|
+
options === undefined ? effect : effect.pipe(Effect.provide(backgroundLayer, { local: true })),
|
|
30
|
+
stream: <A, E, R>(stream: Stream.Stream<A, E, R>) =>
|
|
31
|
+
options === undefined ? stream : stream.pipe(Stream.provide(backgroundLayer, { local: true })),
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Effect RPC's envelope lives until a live subscription closes and may never be exported.
|
|
37
|
+
* Give finite sync work its own exported boundary, attached to a sampled caller or a new backend root.
|
|
38
|
+
*/
|
|
39
|
+
export const rpcSpanOptions = Effect.currentSpan.pipe(
|
|
40
|
+
Effect.map((span) => makeRpcSpanOptions(span.parent)),
|
|
41
|
+
Effect.catchTag('NoSuchElementError', () => Effect.succeed({ root: true })),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
export const makeRpcSpanOptions = (parent: Option.Option<Tracer.AnySpan>): Tracer.SpanOptions => ({
|
|
45
|
+
// Client runtimes use an unsampled no-op span by default. Parenting to it would suppress backend-only telemetry.
|
|
46
|
+
parent: parent.pipe(
|
|
47
|
+
Option.filter((span) => span.sampled),
|
|
48
|
+
Option.getOrUndefined,
|
|
49
|
+
),
|
|
50
|
+
root: true,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
/** Keep expected sync-recovery results out of error telemetry while preserving the typed failure for RPC callers. */
|
|
54
|
+
export const withRpcSpan = <A, E extends SyncRpcError, R>(
|
|
55
|
+
effect: Effect.Effect<A, E, R>,
|
|
56
|
+
name: string,
|
|
57
|
+
options?: Tracer.SpanOptions,
|
|
58
|
+
): Effect.Effect<A, E, R> =>
|
|
59
|
+
effect.pipe(
|
|
60
|
+
Effect.map(Result.succeed),
|
|
61
|
+
Effect.catchIf(isExpectedSyncError, (error) => Effect.succeed(Result.fail(error))),
|
|
62
|
+
Effect.withSpan(name, options),
|
|
63
|
+
Effect.flatMap(resultToEffect),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
/** Stream equivalent of `withRpcSpan`; expected failures are restored after the boundary span ends successfully. */
|
|
67
|
+
export const withRpcStreamSpan = <A, E extends SyncRpcError, R>(
|
|
68
|
+
stream: Stream.Stream<A, E, R>,
|
|
69
|
+
name: string,
|
|
70
|
+
options?: Tracer.SpanOptions,
|
|
71
|
+
): Stream.Stream<A, E, R> =>
|
|
72
|
+
Stream.unwrap(
|
|
73
|
+
Effect.gen(function* () {
|
|
74
|
+
const expectedError = yield* Ref.make(Option.none<E>())
|
|
75
|
+
const instrumented = stream.pipe(
|
|
76
|
+
Stream.catchIf(isExpectedSyncError, (error) =>
|
|
77
|
+
Stream.fromEffect(Ref.set(expectedError, Option.some(error))).pipe(Stream.drain),
|
|
78
|
+
),
|
|
79
|
+
Stream.withSpan(name, options),
|
|
80
|
+
)
|
|
81
|
+
const restoreExpectedError = Stream.fromEffect(Ref.get(expectedError)).pipe(
|
|
82
|
+
Stream.flatMap(
|
|
83
|
+
Option.match({
|
|
84
|
+
onNone: () => Stream.empty,
|
|
85
|
+
onSome: Stream.fail,
|
|
86
|
+
}),
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return instrumented.pipe(Stream.concat(restoreExpectedError))
|
|
91
|
+
}),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
type SyncRpcError = UnknownError | ServerAheadError | BackendIdMismatchError
|
|
95
|
+
|
|
96
|
+
const makeLayer = (options: SyncBackendOtelOptions | undefined): Layer.Layer<never> => {
|
|
97
|
+
if (isTracerLayer(options) === true) return options
|
|
98
|
+
if (options?.baseUrl !== undefined) {
|
|
99
|
+
return Otlp.layerJson({
|
|
100
|
+
baseUrl: options.baseUrl,
|
|
101
|
+
tracerExportInterval: 50,
|
|
102
|
+
shutdownTimeout: 3000,
|
|
103
|
+
resource: { serviceName: options.serviceName ?? 'sync-cf-do' },
|
|
104
|
+
}).pipe(Layer.provide(FetchHttpClient.layer))
|
|
105
|
+
}
|
|
106
|
+
return Layer.empty
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** DOs stay active for pending work; no shutdown hook or connection-wide timer is required. */
|
|
110
|
+
const runInBackground = (effect: Effect.Effect<void>) =>
|
|
111
|
+
effect.pipe(Effect.ignoreCause, Effect.withTracerEnabled(false), Effect.forkDetach, Effect.asVoid)
|
|
112
|
+
|
|
113
|
+
/** Preserve the known Layer requirements when narrowing the configuration union. */
|
|
114
|
+
const isTracerLayer = (options: SyncBackendOtelOptions | undefined): options is Layer.Layer<never> =>
|
|
115
|
+
Layer.isLayer(options)
|
|
116
|
+
|
|
117
|
+
const isExpectedSyncError = (error: SyncRpcError): boolean =>
|
|
118
|
+
error._tag === 'ServerAheadError' || error._tag === 'BackendIdMismatchError'
|
|
119
|
+
|
|
120
|
+
const resultToEffect = <A, E>(result: Result.Result<A, E>): Effect.Effect<A, E> =>
|
|
121
|
+
Result.isSuccess(result) === true ? Effect.succeed(result.success) : Effect.fail(result.failure)
|
package/src/cf-worker/do/push.ts
CHANGED
|
@@ -173,13 +173,14 @@ export const makePush =
|
|
|
173
173
|
)
|
|
174
174
|
if (rpcSubscriptions.length > 0) {
|
|
175
175
|
for (const [, subscription] of rpcSubscriptions) {
|
|
176
|
-
for (const {
|
|
176
|
+
for (const { response } of responses) {
|
|
177
177
|
yield* emitStreamResponse({
|
|
178
178
|
callerContext: subscription.callerContext,
|
|
179
179
|
env,
|
|
180
180
|
requestId: subscription.requestId,
|
|
181
181
|
storeId: subscription.storeId,
|
|
182
|
-
|
|
182
|
+
schema: SyncMessage.PullResponse,
|
|
183
|
+
values: [response],
|
|
183
184
|
}).pipe(Effect.tapCauseLogPretty, Effect.exit)
|
|
184
185
|
}
|
|
185
186
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Vitest } from '@livestore/utils-dev/node-vitest'
|
|
2
|
+
|
|
3
|
+
import { type RpcSubscription, rpcSubscriptionKeyPrefix } from '../../shared.ts'
|
|
4
|
+
import { dropRpcSubscription } from './do-rpc-server.ts'
|
|
5
|
+
|
|
6
|
+
const durableObjectId = 'client-do'
|
|
7
|
+
const key = `${rpcSubscriptionKeyPrefix}${durableObjectId}`
|
|
8
|
+
|
|
9
|
+
const subscribedWith = (requestId: string) => {
|
|
10
|
+
const rows = new Map<string, RpcSubscription>([
|
|
11
|
+
[
|
|
12
|
+
key,
|
|
13
|
+
{ storeId: 'store', subscribedAt: 0, requestId, callerContext: { bindingName: 'CLIENT_DO', durableObjectId } },
|
|
14
|
+
],
|
|
15
|
+
])
|
|
16
|
+
return { rows, kv: { get: (k: string) => rows.get(k), delete: (k: string) => rows.delete(k) } }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
Vitest.describe('sync-cf DO-RPC unsubscribe', () => {
|
|
20
|
+
// A replacement pull overwrote the row with its own request id; the earlier pull's late unsubscribe must be a no-op.
|
|
21
|
+
Vitest.test('keeps the row when the request id was superseded', () => {
|
|
22
|
+
const { rows, kv } = subscribedWith('pull-2')
|
|
23
|
+
dropRpcSubscription(kv, { durableObjectId, requestId: 'pull-1' })
|
|
24
|
+
Vitest.expect(rows.has(key)).toBe(true)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
Vitest.test('drops the row for the pull that registered it', () => {
|
|
28
|
+
const { rows, kv } = subscribedWith('pull-1')
|
|
29
|
+
dropRpcSubscription(kv, { durableObjectId, requestId: 'pull-1' })
|
|
30
|
+
Vitest.expect(rows.has(key)).toBe(false)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -5,23 +5,42 @@ import { Effect, Headers, Option, Stream } from '@livestore/utils/effect'
|
|
|
5
5
|
import { SyncDoRpc } from '../../../common/do-rpc-schema.ts'
|
|
6
6
|
import { rpcSubscriptionKeyPrefix, type RpcSubscription } from '../../shared.ts'
|
|
7
7
|
import * as DoCtx from '../layer.ts'
|
|
8
|
+
import { makeRpcSpanOptions, type makeObservability, withRpcStreamSpan } from '../observability.ts'
|
|
8
9
|
import { makeEndingPullStream } from '../pull.ts'
|
|
9
10
|
import { makePush } from '../push.ts'
|
|
10
11
|
|
|
11
12
|
export interface DoRpcHandlerOptions {
|
|
12
13
|
payload: Uint8Array<ArrayBuffer>
|
|
13
14
|
input: Omit<DoCtx.DoCtxInput, 'from'>
|
|
15
|
+
observability: ReturnType<typeof makeObservability>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Drops a live-pull subscription row only if it still belongs to the pull that asked. A replacement store booted on
|
|
20
|
+
* the same client DO while a slow shutdown is still draining overwrites the row with its own request id, and the
|
|
21
|
+
* earlier pull's late unsubscribe must not take that subscription down.
|
|
22
|
+
*/
|
|
23
|
+
export const dropRpcSubscription = (
|
|
24
|
+
kv: { get: (key: string) => RpcSubscription | undefined; delete: (key: string) => unknown },
|
|
25
|
+
{ durableObjectId, requestId }: { durableObjectId: string; requestId: string },
|
|
26
|
+
): void => {
|
|
27
|
+
const key = `${rpcSubscriptionKeyPrefix}${durableObjectId}`
|
|
28
|
+
if (kv.get(key)?.requestId === requestId) {
|
|
29
|
+
kv.delete(key)
|
|
30
|
+
}
|
|
14
31
|
}
|
|
15
32
|
|
|
16
33
|
export const createDoRpcHandler = (
|
|
17
34
|
options: DoRpcHandlerOptions,
|
|
18
35
|
): Effect.Effect<Uint8Array<ArrayBuffer> | CfTypes.ReadableStream> =>
|
|
19
36
|
Effect.gen({ self: this }, function* () {
|
|
20
|
-
const { payload, input } = options
|
|
37
|
+
const { payload, input, observability } = options
|
|
38
|
+
const parent = yield* Effect.currentParentSpan.pipe(Effect.option)
|
|
21
39
|
|
|
22
40
|
// TODO add admin RPCs
|
|
23
41
|
const RpcLive = SyncDoRpc.toLayer({
|
|
24
42
|
'SyncDoRpc.Ping': () => Effect.void,
|
|
43
|
+
'SyncDoRpc.Unsubscribe': (req) => Effect.sync(() => dropRpcSubscription(input.doSelf.ctx.storage.kv, req)),
|
|
25
44
|
'SyncDoRpc.Pull': (req, { headers }) =>
|
|
26
45
|
Effect.gen({ self: this }, function* () {
|
|
27
46
|
const { ctx } = yield* DoCtx.DoCtx
|
|
@@ -55,6 +74,9 @@ export const createDoRpcHandler = (
|
|
|
55
74
|
: new UnknownError({ cause }),
|
|
56
75
|
),
|
|
57
76
|
Stream.tapCause(Effect.log),
|
|
77
|
+
// The bridge runs this stream in a separate runtime. Install telemetry there too.
|
|
78
|
+
(_) => withRpcStreamSpan(_, 'RpcServer.SyncDoRpc.Pull', makeRpcSpanOptions(parent)),
|
|
79
|
+
observability.stream,
|
|
58
80
|
),
|
|
59
81
|
'SyncDoRpc.Push': (req) =>
|
|
60
82
|
Effect.gen({ self: this }, function* () {
|
|
@@ -5,47 +5,71 @@ import { Effect, identity, Layer, Result, RpcServer, Schema, Stream } from '@liv
|
|
|
5
5
|
import { SyncWsRpc } from '../../../common/ws-rpc-schema.ts'
|
|
6
6
|
import { headersRecordToMap, WebSocketAttachmentSchema } from '../../shared.ts'
|
|
7
7
|
import * as DoCtx from '../layer.ts'
|
|
8
|
+
import { type makeObservability, rpcSpanOptions, withRpcSpan, withRpcStreamSpan } from '../observability.ts'
|
|
8
9
|
import { makeEndingPullStream } from '../pull.ts'
|
|
9
10
|
import { makePush } from '../push.ts'
|
|
10
11
|
|
|
11
|
-
export const makeRpcServer = ({
|
|
12
|
-
|
|
12
|
+
export const makeRpcServer = ({
|
|
13
|
+
doSelf,
|
|
14
|
+
doOptions,
|
|
15
|
+
observability,
|
|
16
|
+
}: Omit<DoCtx.DoCtxInput, 'from'> & { observability: ReturnType<typeof makeObservability> }) => {
|
|
17
|
+
const handlersLayer = makeRpcHandlers({ doSelf, doOptions, observability })
|
|
18
|
+
|
|
19
|
+
return RpcServer.layer(SyncWsRpc).pipe(Layer.provide(handlersLayer))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Production handler layer, exported so transport tests can exercise the exact RPC composition in memory. */
|
|
23
|
+
export const makeRpcHandlers = ({
|
|
24
|
+
doSelf,
|
|
25
|
+
doOptions,
|
|
26
|
+
observability,
|
|
27
|
+
}: Omit<DoCtx.DoCtxInput, 'from'> & { observability: ReturnType<typeof makeObservability> }) =>
|
|
28
|
+
SyncWsRpc.toLayer({
|
|
13
29
|
'SyncWsRpc.Pull': (req) =>
|
|
14
30
|
Effect.gen(function* () {
|
|
31
|
+
const spanOptions = yield* rpcSpanOptions
|
|
15
32
|
const headers = yield* getForwardedHeaders
|
|
16
33
|
return makeEndingPullStream({ req, payload: req.payload, headers }).pipe(
|
|
17
|
-
// Needed to keep the stream alive on the client side for phase 2 (i.e. not send the `Exit` stream RPC message)
|
|
18
|
-
req.live === true ? Stream.concat(Stream.never) : identity,
|
|
19
34
|
Stream.provide(DoCtx.layer({ doSelf, doOptions, from: { storeId: req.storeId } })),
|
|
20
35
|
Stream.mapError((cause) =>
|
|
21
36
|
cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError'
|
|
22
37
|
? cause
|
|
23
38
|
: new UnknownError({ cause }),
|
|
24
39
|
),
|
|
40
|
+
(_) => withRpcStreamSpan(_, 'RpcServer.SyncWsRpc.Pull', spanOptions),
|
|
25
41
|
)
|
|
26
|
-
}).pipe(
|
|
42
|
+
}).pipe(
|
|
43
|
+
Stream.unwrap,
|
|
44
|
+
// Drain finite history before entering the live phase, which survives DO hibernation.
|
|
45
|
+
observability.stream,
|
|
46
|
+
// Keep the client subscription open without retaining an exporter scope or timer.
|
|
47
|
+
req.live === true ? Stream.concat(Stream.never) : identity,
|
|
48
|
+
),
|
|
27
49
|
'SyncWsRpc.Push': (req) =>
|
|
28
|
-
Effect.
|
|
29
|
-
|
|
30
|
-
|
|
50
|
+
Effect.flatMap(rpcSpanOptions, (spanOptions) =>
|
|
51
|
+
Effect.gen(function* () {
|
|
52
|
+
const { doOptions, storeId, ctx, env } = yield* DoCtx.DoCtx
|
|
53
|
+
const headers = yield* getForwardedHeaders
|
|
31
54
|
|
|
32
|
-
|
|
55
|
+
const push = makePush({ options: doOptions, storeId, payload: req.payload, headers, ctx, env })
|
|
33
56
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
57
|
+
return yield* push(req)
|
|
58
|
+
}).pipe(
|
|
59
|
+
Effect.provide(DoCtx.layer({ doSelf, doOptions, from: { storeId: req.storeId } })),
|
|
60
|
+
Effect.mapError((cause) =>
|
|
61
|
+
cause._tag === 'UnknownError' ||
|
|
62
|
+
cause._tag === 'ServerAheadError' ||
|
|
63
|
+
cause._tag === 'BackendIdMismatchError'
|
|
64
|
+
? cause
|
|
65
|
+
: new UnknownError({ cause }),
|
|
66
|
+
),
|
|
67
|
+
Effect.tapCauseLogPretty,
|
|
68
|
+
(_) => withRpcSpan(_, 'RpcServer.SyncWsRpc.Push', spanOptions),
|
|
41
69
|
),
|
|
42
|
-
|
|
43
|
-
),
|
|
70
|
+
).pipe(observability.effect),
|
|
44
71
|
})
|
|
45
72
|
|
|
46
|
-
return RpcServer.layer(SyncWsRpc).pipe(Layer.provide(handlersLayer))
|
|
47
|
-
}
|
|
48
|
-
|
|
49
73
|
/** Extracts forwarded headers from the WebSocket attachment */
|
|
50
74
|
const getForwardedHeaders = Effect.gen(function* () {
|
|
51
75
|
const { ws } = yield* WsContext
|