@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/package.json +61 -60
- package/src/cassette.ts +179 -0
- package/src/effect.ts +25 -0
- package/src/index.ts +18 -0
- package/src/internal-effect.ts +189 -0
- package/src/internal.ts +15 -0
- package/src/matching.ts +106 -0
- package/src/recorder.ts +62 -0
- package/src/redaction.ts +117 -0
- package/src/redactor.ts +135 -0
- package/src/schema.ts +87 -0
- package/src/socket.ts +326 -0
- package/src/types.ts +108 -0
- package/src/websocket.ts +173 -0
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
|
+
}
|
package/src/websocket.ts
ADDED
|
@@ -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
|
+
})
|