@neurocode-ai/http-recorder 1.18.13 → 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/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
+ })