@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/package.json +2 -1
- 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/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
|
+
})
|