@neurocode-ai/http-recorder 1.18.14 → 1.18.15

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/src/socket.ts ADDED
@@ -0,0 +1,326 @@
1
+ import { NodeFileSystem } from "@effect/platform-node"
2
+ import { Deferred, Effect, Exit, FiberSet, Layer, Ref, Scope, Semaphore } from "effect"
3
+ import { Socket } from "effect/unstable/socket"
4
+ import * as CassetteService from "./cassette.js"
5
+ import { canonicalizeJson, decodeJson, safeText } from "./matching.js"
6
+ import { makeReplayState, resolveAutoMode } from "./recorder.js"
7
+ import { make, type Redactor } from "./redactor.js"
8
+ import { webSocketInteractions } from "./schema.js"
9
+ import type {
10
+ RecorderOptions,
11
+ WebSocketEvent,
12
+ WebSocketInteraction,
13
+ WebSocketRecorderOptions,
14
+ WebSocketRequest,
15
+ } from "./types.js"
16
+
17
+ interface ActiveReplay {
18
+ readonly interaction: WebSocketInteraction
19
+ readonly progress: Ref.Ref<{ readonly position: number; readonly changed: Deferred.Deferred<void> }>
20
+ readonly writeLock: Semaphore.Semaphore
21
+ readonly closed: Ref.Ref<boolean>
22
+ }
23
+
24
+ interface ActiveRecording {
25
+ readonly events: Array<WebSocketEvent>
26
+ readonly eventLock: Semaphore.Semaphore
27
+ readonly accepting: Ref.Ref<boolean>
28
+ opened: boolean
29
+ valid: boolean
30
+ }
31
+
32
+ type Frame = string | Uint8Array
33
+
34
+ const encodeEvent = (direction: "client" | "server", message: Frame): WebSocketEvent =>
35
+ typeof message === "string"
36
+ ? { direction, kind: "text", body: message }
37
+ : { direction, kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" }
38
+
39
+ const decodeEvent = (event: WebSocketEvent): Frame =>
40
+ event.kind === "text" ? event.body : new Uint8Array(Buffer.from(event.body, "base64"))
41
+
42
+ const redactEvent = (event: WebSocketEvent, redactor: Redactor): WebSocketEvent => {
43
+ if (event.kind === "binary") return event
44
+ const body =
45
+ event.direction === "client"
46
+ ? redactor.request({ method: "WEBSOCKET", url: "", headers: {}, body: event.body }).body
47
+ : redactor.response({ status: 101, headers: {}, body: event.body }).body
48
+ return { ...event, body }
49
+ }
50
+
51
+ const comparable = (event: WebSocketEvent, asJson: boolean) => {
52
+ if (!asJson || event.kind === "binary") return JSON.stringify(canonicalizeJson(event))
53
+ const decoded = decodeJson(event.body)
54
+ return JSON.stringify(
55
+ canonicalizeJson({
56
+ ...event,
57
+ body: decoded._tag === "None" ? event.body : canonicalizeJson(decoded.value),
58
+ }),
59
+ )
60
+ }
61
+
62
+ const assertEvent = (actual: WebSocketEvent, expected: WebSocketEvent | undefined, index: number, asJson: boolean) =>
63
+ Effect.sync(() => {
64
+ if (expected && comparable(actual, asJson) === comparable(expected, asJson)) return
65
+ throw new Error(`WebSocket event ${index + 1}: expected ${safeText(expected)}, received ${safeText(actual)}`)
66
+ })
67
+
68
+ const runHandler = <A, E, R>(handler: (value: A) => Effect.Effect<unknown, E, R> | void, value: A) =>
69
+ Effect.suspend(() => {
70
+ const result = handler(value)
71
+ return Effect.isEffect(result) ? Effect.asVoid(result) : Effect.void
72
+ })
73
+
74
+ const runReplay = <A, E, R>(
75
+ state: ActiveReplay,
76
+ handler: (value: A) => Effect.Effect<unknown, E, R> | void,
77
+ decode: (event: WebSocketEvent) => A,
78
+ onOpen: Effect.Effect<void> | undefined,
79
+ ) =>
80
+ Effect.scoped(
81
+ Effect.gen(function* () {
82
+ const handlers = yield* FiberSet.make<unknown, E>()
83
+ const run = yield* FiberSet.runtime(handlers)<R>()
84
+ if (onOpen) yield* onOpen
85
+
86
+ const drive = Effect.gen(function* () {
87
+ while (true) {
88
+ const current = yield* Ref.get(state.progress)
89
+ const event = state.interaction.events[current.position]
90
+ if (!event) return
91
+ if (yield* Ref.get(state.closed))
92
+ return yield* Effect.die(
93
+ new Error(
94
+ `WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`,
95
+ ),
96
+ )
97
+ if (event.direction === "server") {
98
+ yield* Ref.set(state.progress, {
99
+ position: current.position + 1,
100
+ changed: yield* Deferred.make<void>(),
101
+ })
102
+ run(runHandler(handler, decode(event)))
103
+ continue
104
+ }
105
+ yield* Deferred.await(current.changed)
106
+ }
107
+ })
108
+
109
+ yield* drive.pipe(Effect.raceFirst(FiberSet.join(handlers)))
110
+ yield* FiberSet.awaitEmpty(handlers).pipe(Effect.raceFirst(FiberSet.join(handlers)))
111
+ }),
112
+ )
113
+
114
+ const openSnapshot = (request: WebSocketRequest, redactor: Redactor) => {
115
+ const snapshot = redactor.request({ method: "GET", url: request.url, headers: request.headers ?? {}, body: "" })
116
+ return { url: snapshot.url, headers: snapshot.headers }
117
+ }
118
+
119
+ const makeRecordingSocket = (
120
+ upstream: Socket.Socket,
121
+ cassette: CassetteService.Interface,
122
+ name: string,
123
+ request: WebSocketRequest,
124
+ options: WebSocketRecorderOptions,
125
+ redactor: Redactor,
126
+ ) =>
127
+ Effect.gen(function* () {
128
+ const active = yield* Ref.make<ActiveRecording | undefined>(undefined)
129
+ const writeLock = yield* Semaphore.make(1)
130
+
131
+ return Socket.make({
132
+ runRaw: (handler, runOptions) =>
133
+ Effect.gen(function* () {
134
+ const state: ActiveRecording = {
135
+ events: [],
136
+ eventLock: yield* Semaphore.make(1),
137
+ accepting: yield* Ref.make(true),
138
+ opened: false,
139
+ valid: true,
140
+ }
141
+ const occupied = yield* Ref.modify(active, (current) => [current !== undefined, current ?? state])
142
+ if (occupied) return yield* Effect.die("Concurrent runs of a recorded WebSocket are not supported")
143
+ yield* upstream
144
+ .runRaw(
145
+ (message) => {
146
+ if (!Ref.getUnsafe(state.accepting)) throw new Error("WebSocket received a frame after closing")
147
+ state.events.push(redactEvent(encodeEvent("server", message), redactor))
148
+ return handler(message)
149
+ },
150
+ {
151
+ ...runOptions,
152
+ onOpen: Effect.gen(function* () {
153
+ state.opened = true
154
+ if (runOptions?.onOpen) yield* runOptions.onOpen
155
+ }),
156
+ },
157
+ )
158
+ .pipe(
159
+ Effect.onExit((exit) =>
160
+ writeLock.withPermit(
161
+ state.eventLock.withPermit(
162
+ Effect.gen(function* () {
163
+ yield* Ref.set(state.accepting, false)
164
+ yield* Ref.set(active, undefined)
165
+ if (!Exit.isSuccess(exit) || !state.opened || !state.valid) return
166
+ yield* cassette
167
+ .append(
168
+ name,
169
+ {
170
+ transport: "websocket",
171
+ open: openSnapshot(request, redactor),
172
+ events: [...state.events],
173
+ },
174
+ options.metadata,
175
+ )
176
+ .pipe(Effect.orDie)
177
+ }),
178
+ ),
179
+ ),
180
+ ),
181
+ )
182
+ }),
183
+ writer: upstream.writer.pipe(
184
+ Effect.map(
185
+ (write) => (message) =>
186
+ writeLock.withPermit(
187
+ Effect.gen(function* () {
188
+ if (Socket.isCloseEvent(message)) return yield* write(message)
189
+ const state = yield* Ref.get(active)
190
+ if (!state || !(yield* Ref.get(state.accepting)))
191
+ return yield* Effect.die("WebSocket writer used without an active socket run")
192
+ const event = redactEvent(encodeEvent("client", message), redactor)
193
+ yield* state.eventLock.withPermit(Effect.sync(() => state.events.push(event)))
194
+ return yield* write(message).pipe(Effect.onError(() => Effect.sync(() => (state.valid = false))))
195
+ }),
196
+ ),
197
+ ),
198
+ ),
199
+ })
200
+ })
201
+
202
+ const makeReplaySocket = (
203
+ cassette: CassetteService.Interface,
204
+ name: string,
205
+ request: WebSocketRequest,
206
+ options: WebSocketRecorderOptions,
207
+ redactor: Redactor,
208
+ ): Effect.Effect<Socket.Socket, never, Scope.Scope> =>
209
+ Effect.gen(function* () {
210
+ const replay = yield* makeReplayState(cassette, name, webSocketInteractions)
211
+ const active = yield* Ref.make<ActiveReplay | undefined>(undefined)
212
+
213
+ return Socket.make({
214
+ runRaw: (handler, runOptions) =>
215
+ Effect.gen(function* () {
216
+ const claimed = yield* replay
217
+ .claim((interaction, index) =>
218
+ Effect.sync(() => {
219
+ const incoming = openSnapshot(request, redactor)
220
+ if (
221
+ interaction &&
222
+ JSON.stringify(canonicalizeJson(incoming)) === JSON.stringify(canonicalizeJson(interaction.open))
223
+ )
224
+ return
225
+ throw new Error(
226
+ `WebSocket open ${index + 1}: expected ${safeText(interaction?.open)}, received ${safeText(incoming)}`,
227
+ )
228
+ }),
229
+ )
230
+ .pipe(Effect.orDie)
231
+ const progress = yield* Ref.make({ position: 0, changed: yield* Deferred.make<void>() })
232
+ const writeLock = yield* Semaphore.make(1)
233
+ const state = {
234
+ interaction: claimed.interaction,
235
+ progress,
236
+ writeLock,
237
+ closed: yield* Ref.make(false),
238
+ }
239
+ const occupied = yield* Ref.modify(active, (current) => [current !== undefined, current ?? state])
240
+ if (occupied) return yield* Effect.die("Concurrent runs of a replayed WebSocket are not supported")
241
+ yield* runReplay(state, handler, decodeEvent, runOptions?.onOpen).pipe(
242
+ Effect.ensuring(Ref.set(active, undefined)),
243
+ )
244
+ }),
245
+ writer: Effect.succeed((message) => {
246
+ return Ref.get(active).pipe(
247
+ Effect.flatMap((state) =>
248
+ state
249
+ ? state.writeLock.withPermit(
250
+ Effect.gen(function* () {
251
+ const current = yield* Ref.get(state.progress)
252
+ if (Socket.isCloseEvent(message)) {
253
+ yield* Ref.set(state.closed, true)
254
+ yield* Deferred.succeed(current.changed, undefined)
255
+ if (current.position === state.interaction.events.length) return
256
+ return yield* Effect.die(
257
+ new Error(
258
+ `WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`,
259
+ ),
260
+ )
261
+ }
262
+ const actual = redactEvent(encodeEvent("client", message), redactor)
263
+ yield* assertEvent(
264
+ actual,
265
+ state.interaction.events[current.position],
266
+ current.position,
267
+ options.compareClientMessagesAsJson === true,
268
+ )
269
+ yield* Ref.set(state.progress, {
270
+ position: current.position + 1,
271
+ changed: yield* Deferred.make<void>(),
272
+ })
273
+ yield* Deferred.succeed(current.changed, undefined)
274
+ }),
275
+ )
276
+ : Effect.die("WebSocket writer used without an active socket run"),
277
+ ),
278
+ )
279
+ }),
280
+ })
281
+ })
282
+
283
+ const recordingLayer = (
284
+ name: string,
285
+ request: WebSocketRequest,
286
+ options: WebSocketRecorderOptions,
287
+ forcedMode?: "record" | "replay",
288
+ ): Layer.Layer<Socket.Socket, never, Socket.Socket | CassetteService.Service> =>
289
+ Layer.effect(
290
+ Socket.Socket,
291
+ Effect.gen(function* () {
292
+ const upstream = yield* Socket.Socket
293
+ const cassette = yield* CassetteService.Service
294
+ const redactor = make(options.redact)
295
+ if ((forcedMode ?? (yield* resolveAutoMode(cassette, name))) === "record")
296
+ return yield* makeRecordingSocket(upstream, cassette, name, request, options, redactor)
297
+ return yield* makeReplaySocket(cassette, name, request, options, redactor)
298
+ }),
299
+ )
300
+
301
+ /**
302
+ * Wraps a provided `Socket.Socket` with cassette recording and replay.
303
+ *
304
+ * Supply the ordinary URL-bound Effect socket layer beneath this decorator.
305
+ * The cassette name identifies the connection; recorder configuration does not
306
+ * duplicate the transport URL.
307
+ */
308
+ export const socket = (name: string, options: RecorderOptions = {}): Layer.Layer<Socket.Socket, never, Socket.Socket> =>
309
+ provideCassette(recordingLayer(name, { url: "" }, { ...options, compareClientMessagesAsJson: true }), options)
310
+
311
+ /** @internal */
312
+ export const socketLayer = (
313
+ name: string,
314
+ request: WebSocketRequest,
315
+ options: WebSocketRecorderOptions & { readonly mode: "record" | "replay" },
316
+ ): Layer.Layer<Socket.Socket, never, Socket.Socket> =>
317
+ provideCassette(recordingLayer(name, request, options, options.mode), options)
318
+
319
+ const provideCassette = (
320
+ layer: Layer.Layer<Socket.Socket, never, Socket.Socket | CassetteService.Service>,
321
+ options: WebSocketRecorderOptions,
322
+ ) =>
323
+ layer.pipe(
324
+ Layer.provide(CassetteService.fileSystem({ directory: options.directory })),
325
+ Layer.provide(NodeFileSystem.layer),
326
+ )
package/src/types.ts ADDED
@@ -0,0 +1,108 @@
1
+ /** Additional JSON metadata stored with a cassette. */
2
+ export type CassetteMetadata = Record<string, unknown>
3
+
4
+ /** The normalized HTTP request representation used for matching. */
5
+ export interface RequestSnapshot {
6
+ /** HTTP method. */
7
+ readonly method: string
8
+ /** Fully qualified URL after redaction. */
9
+ readonly url: string
10
+ /** Allowed and redacted request headers. */
11
+ readonly headers: Record<string, string>
12
+ /** Request body after redaction. */
13
+ readonly body: string
14
+ }
15
+
16
+ /** @internal */
17
+ export interface ResponseSnapshot {
18
+ /** HTTP status code. */
19
+ readonly status: number
20
+ /** Allowed and redacted response headers. */
21
+ readonly headers: Record<string, string>
22
+ /** Text body or base64-encoded binary body. */
23
+ readonly body: string
24
+ /** Encoding used by `body`; omitted for ordinary text. */
25
+ readonly bodyEncoding?: "text" | "base64"
26
+ }
27
+
28
+ /** @internal */
29
+ export interface HttpInteraction {
30
+ readonly transport: "http"
31
+ readonly request: RequestSnapshot
32
+ readonly response: ResponseSnapshot
33
+ }
34
+
35
+ /** @internal */
36
+ export type WebSocketEvent =
37
+ | { readonly direction: "client" | "server"; readonly kind: "text"; readonly body: string }
38
+ | {
39
+ readonly direction: "client" | "server"
40
+ readonly kind: "binary"
41
+ readonly body: string
42
+ readonly bodyEncoding: "base64"
43
+ }
44
+
45
+ /** @internal */
46
+ export interface WebSocketInteraction {
47
+ readonly transport: "websocket"
48
+ readonly open: {
49
+ readonly url: string
50
+ readonly headers: Record<string, string>
51
+ }
52
+ readonly events: ReadonlyArray<WebSocketEvent>
53
+ }
54
+
55
+ /** Returns whether an incoming HTTP request matches a recorded request. */
56
+ export type RequestMatcher = (incoming: RequestSnapshot, recorded: RequestSnapshot) => boolean
57
+
58
+ /** Additive redaction and header-preservation policy. */
59
+ export interface RedactOptions {
60
+ /** Additional sensitive headers to retain as `[REDACTED]`. */
61
+ readonly headers?: ReadonlyArray<string>
62
+ /** Additional non-sensitive request headers to preserve for matching. */
63
+ readonly allowRequestHeaders?: ReadonlyArray<string>
64
+ /** Additional non-sensitive response headers to preserve for replay. */
65
+ readonly allowResponseHeaders?: ReadonlyArray<string>
66
+ /** Additional sensitive URL query parameter names. */
67
+ readonly queryParameters?: ReadonlyArray<string>
68
+ /** Additional JSON field names to redact recursively. */
69
+ readonly jsonFields?: ReadonlyArray<string>
70
+ /** Stabilizes a URL after built-in redaction. */
71
+ readonly url?: (url: string) => string
72
+ /** Stabilizes a request, response, or text-frame body after built-in redaction. */
73
+ readonly body?: (body: string) => string
74
+ }
75
+
76
+ /** Options shared by HTTP recorder layers. */
77
+ export interface RecorderOptions {
78
+ /** Cassette directory. Defaults to `<cwd>/test/fixtures/recordings`. */
79
+ readonly directory?: string
80
+ /** Additional metadata stored in the cassette. */
81
+ readonly metadata?: CassetteMetadata
82
+ /** Additive redaction and header-preservation policy. */
83
+ readonly redact?: RedactOptions
84
+ /** Custom HTTP request equivalence. */
85
+ readonly match?: RequestMatcher
86
+ }
87
+
88
+ /** @internal */
89
+ export interface WebSocketRequest {
90
+ /** WebSocket URL. */
91
+ readonly url: string
92
+ /** Headers used for redacted matching; the recorder does not send them. */
93
+ readonly headers?: Record<string, string>
94
+ }
95
+
96
+ /** @internal */
97
+ export interface WebSocketRecorderOptions {
98
+ /** Cassette directory. Defaults to `<cwd>/test/fixtures/recordings`. */
99
+ readonly directory?: string
100
+ /** Additional metadata stored in the cassette. */
101
+ readonly metadata?: CassetteMetadata
102
+ /** Additive handshake and text-frame redaction policy. */
103
+ readonly redact?: RedactOptions
104
+ /** Compare text client frames as canonical JSON instead of exact strings. */
105
+ readonly compareClientMessagesAsJson?: boolean
106
+ /** WebSocket subprotocols used by `layerWebSocket`. */
107
+ readonly protocols?: string | Array<string>
108
+ }
@@ -0,0 +1,173 @@
1
+ import { Effect, Option, Ref, Scope, Semaphore, Stream, SynchronizedRef } from "effect"
2
+ import type { Headers } from "effect/unstable/http"
3
+ import * as CassetteService from "./cassette.js"
4
+ import { canonicalizeJson, decodeJson, safeText } from "./matching.js"
5
+ import { makeReplayState, resolveAutoMode } from "./recorder.js"
6
+ import type { RecordReplayMode } from "./internal-effect.js"
7
+ import { make, type Redactor } from "./redactor.js"
8
+ import { webSocketInteractions, type CassetteMetadata, type WebSocketEvent } from "./schema.js"
9
+
10
+ export interface WebSocketRequest {
11
+ readonly url: string
12
+ readonly headers: Headers.Headers
13
+ }
14
+
15
+ export interface WebSocketConnection<E> {
16
+ readonly sendText: (message: string) => Effect.Effect<void, E>
17
+ readonly messages: Stream.Stream<string | Uint8Array, E>
18
+ readonly close: Effect.Effect<void>
19
+ }
20
+
21
+ export interface WebSocketExecutor<E> {
22
+ readonly open: (request: WebSocketRequest) => Effect.Effect<WebSocketConnection<E>, E>
23
+ }
24
+
25
+ export interface WebSocketRecordReplayOptions<E> {
26
+ readonly name: string
27
+ readonly mode?: RecordReplayMode
28
+ readonly metadata?: CassetteMetadata
29
+ readonly cassette: CassetteService.Interface
30
+ readonly live: WebSocketExecutor<E>
31
+ readonly redactor?: Redactor
32
+ readonly compareClientMessagesAsJson?: boolean
33
+ }
34
+
35
+ const headersRecord = (headers: Headers.Headers): Record<string, string> =>
36
+ Object.fromEntries(
37
+ Object.entries(headers as Record<string, unknown>).filter(
38
+ (entry): entry is [string, string] => typeof entry[1] === "string",
39
+ ),
40
+ )
41
+
42
+ const textEvent = (direction: "client" | "server", body: string): WebSocketEvent => ({
43
+ direction,
44
+ kind: "text",
45
+ body,
46
+ })
47
+
48
+ const decodeEvent = (event: WebSocketEvent) =>
49
+ event.kind === "text" ? event.body : new Uint8Array(Buffer.from(event.body, "base64"))
50
+
51
+ const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson })
52
+
53
+ const assertClientEvent = (actual: string, expected: WebSocketEvent | undefined, index: number, asJson: boolean) =>
54
+ Effect.sync(() => {
55
+ const matches =
56
+ expected?.direction === "client" &&
57
+ expected.kind === "text" &&
58
+ JSON.stringify(asJson ? jsonOrText(actual) : actual) ===
59
+ JSON.stringify(asJson ? jsonOrText(expected.body) : expected.body)
60
+ if (matches) return
61
+ throw new Error(`WebSocket client frame ${index + 1}: expected ${safeText(expected)}, received ${safeText(actual)}`)
62
+ })
63
+
64
+ export const makeWebSocketExecutor = <E>(
65
+ options: WebSocketRecordReplayOptions<E>,
66
+ ): Effect.Effect<WebSocketExecutor<E>, never, Scope.Scope> =>
67
+ Effect.gen(function* () {
68
+ const mode = options.mode ?? (yield* resolveAutoMode(options.cassette, options.name))
69
+ const redactor = options.redactor ?? make()
70
+ const openSnapshot = (request: WebSocketRequest) => {
71
+ const snapshot = redactor.request({
72
+ method: "GET",
73
+ url: request.url,
74
+ headers: headersRecord(request.headers),
75
+ body: "",
76
+ })
77
+ return { url: snapshot.url, headers: snapshot.headers }
78
+ }
79
+ const redactEvent = (event: WebSocketEvent) => {
80
+ if (event.kind === "binary") return event
81
+ const body =
82
+ event.direction === "client"
83
+ ? redactor.request({ method: "WEBSOCKET", url: "", headers: {}, body: event.body }).body
84
+ : redactor.response({ status: 101, headers: {}, body: event.body }).body
85
+ return { ...event, body }
86
+ }
87
+
88
+ if (mode === "passthrough") return options.live
89
+
90
+ if (mode === "record") {
91
+ return {
92
+ open: (request) =>
93
+ Effect.gen(function* () {
94
+ const events: WebSocketEvent[] = []
95
+ const connection = yield* options.live.open(request)
96
+ const closed = yield* Ref.make(false)
97
+ const closeLock = yield* Semaphore.make(1)
98
+ return {
99
+ sendText: (message) =>
100
+ Effect.sync(() => events.push(redactEvent(textEvent("client", message)))).pipe(
101
+ Effect.andThen(connection.sendText(message)),
102
+ ),
103
+ messages: connection.messages.pipe(
104
+ Stream.tap((message) =>
105
+ Effect.sync(() =>
106
+ events.push(
107
+ typeof message === "string"
108
+ ? redactEvent(textEvent("server", message))
109
+ : {
110
+ direction: "server",
111
+ kind: "binary",
112
+ body: Buffer.from(message).toString("base64"),
113
+ bodyEncoding: "base64",
114
+ },
115
+ ),
116
+ ),
117
+ ),
118
+ ),
119
+ close: closeLock.withPermit(
120
+ Effect.gen(function* () {
121
+ if (yield* Ref.get(closed)) return
122
+ yield* connection.close
123
+ yield* options.cassette
124
+ .append(
125
+ options.name,
126
+ { transport: "websocket", open: openSnapshot(request), events },
127
+ options.metadata,
128
+ )
129
+ .pipe(Effect.orDie)
130
+ yield* Ref.set(closed, true)
131
+ }),
132
+ ),
133
+ }
134
+ }),
135
+ }
136
+ }
137
+
138
+ const replay = yield* makeReplayState(options.cassette, options.name, webSocketInteractions)
139
+ return {
140
+ open: (request) =>
141
+ Effect.gen(function* () {
142
+ const claimed = yield* replay
143
+ .claim((interaction, index) =>
144
+ Effect.sync(() => {
145
+ const incoming = canonicalizeJson(openSnapshot(request))
146
+ if (interaction && JSON.stringify(incoming) === JSON.stringify(canonicalizeJson(interaction.open)))
147
+ return
148
+ throw new Error(`WebSocket open ${index + 1} does not match ${safeText(incoming)}`)
149
+ }),
150
+ )
151
+ .pipe(Effect.orDie)
152
+ const client = claimed.interaction.events.filter((event) => event.direction === "client")
153
+ const server = claimed.interaction.events.filter((event) => event.direction === "server")
154
+ const position = yield* SynchronizedRef.make(0)
155
+ return {
156
+ sendText: (message) =>
157
+ SynchronizedRef.updateEffect(position, (index) =>
158
+ assertClientEvent(message, client[index], index, options.compareClientMessagesAsJson === true).pipe(
159
+ Effect.as(index + 1),
160
+ ),
161
+ ),
162
+ messages: Stream.fromIterable(server).pipe(Stream.map(decodeEvent)),
163
+ close: Effect.gen(function* () {
164
+ const used = yield* SynchronizedRef.get(position)
165
+ if (used !== client.length)
166
+ return yield* Effect.die(
167
+ new Error(`WebSocket client frame count: expected ${client.length}, received ${used}`),
168
+ )
169
+ }),
170
+ }
171
+ }),
172
+ }
173
+ })