@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.
@@ -0,0 +1,117 @@
1
+ import { Schema } from "effect"
2
+
3
+ export const REDACTED = "[REDACTED]"
4
+
5
+ const DEFAULT_REDACT_HEADERS = [
6
+ "authorization",
7
+ "cookie",
8
+ "proxy-authorization",
9
+ "set-cookie",
10
+ "x-api-key",
11
+ "x-amz-security-token",
12
+ "x-goog-api-key",
13
+ ]
14
+
15
+ const DEFAULT_REDACT_QUERY = [
16
+ "access_token",
17
+ "api-key",
18
+ "api_key",
19
+ "apikey",
20
+ "code",
21
+ "key",
22
+ "signature",
23
+ "sig",
24
+ "token",
25
+ "x-amz-credential",
26
+ "x-amz-security-token",
27
+ "x-amz-signature",
28
+ ]
29
+
30
+ const SECRET_PATTERNS: ReadonlyArray<{ readonly label: string; readonly pattern: RegExp }> = [
31
+ { label: "bearer token", pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b/i },
32
+ { label: "API key", pattern: /\bsk-[A-Za-z0-9][A-Za-z0-9_-]{20,}\b/ },
33
+ { label: "Anthropic API key", pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ },
34
+ { label: "Google API key", pattern: /\bAIza[0-9A-Za-z_-]{20,}\b/ },
35
+ { label: "AWS access key", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
36
+ { label: "GitHub token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/ },
37
+ { label: "private key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
38
+ ]
39
+
40
+ const ENV_SECRET_NAMES = /(?:API|AUTH|BEARER|CREDENTIAL|KEY|PASSWORD|SECRET|TOKEN)/i
41
+ const SAFE_ENV_VALUES = new Set(["fixture", "test", "test-key"])
42
+
43
+ const envSecrets = () =>
44
+ Object.entries(process.env).flatMap(([name, value]) => {
45
+ if (!value) return []
46
+ if (!ENV_SECRET_NAMES.test(name)) return []
47
+ if (value.length < 12) return []
48
+ if (SAFE_ENV_VALUES.has(value.toLowerCase())) return []
49
+ return [{ name, value }]
50
+ })
51
+
52
+ const pathFor = (base: string, key: string) => (base ? `${base}.${key}` : key)
53
+
54
+ const stringEntries = (value: unknown, base = ""): ReadonlyArray<{ readonly path: string; readonly value: string }> => {
55
+ if (typeof value === "string") return [{ path: base, value }]
56
+ if (Array.isArray(value)) return value.flatMap((item, index) => stringEntries(item, `${base}[${index}]`))
57
+ if (value && typeof value === "object") {
58
+ return Object.entries(value).flatMap(([key, child]) => stringEntries(child, pathFor(base, key)))
59
+ }
60
+ return []
61
+ }
62
+
63
+ const redactionSet = (values: ReadonlyArray<string> | undefined, defaults: ReadonlyArray<string>) =>
64
+ new Set([...defaults, ...(values ?? [])].map((value) => value.toLowerCase()))
65
+
66
+ export type UrlRedactor = (url: string) => string
67
+
68
+ export const redactUrl = (
69
+ raw: string,
70
+ query: ReadonlyArray<string> = DEFAULT_REDACT_QUERY,
71
+ urlRedactor?: UrlRedactor,
72
+ ) => {
73
+ if (!URL.canParse(raw)) return urlRedactor?.(raw) ?? raw
74
+ const url = new URL(raw)
75
+ if (url.username) url.username = REDACTED
76
+ if (url.password) url.password = REDACTED
77
+ const redacted = redactionSet(query, DEFAULT_REDACT_QUERY)
78
+ for (const key of url.searchParams.keys()) {
79
+ if (redacted.has(key.toLowerCase())) url.searchParams.set(key, REDACTED)
80
+ }
81
+ return urlRedactor?.(url.toString()) ?? url.toString()
82
+ }
83
+
84
+ export const redactHeaders = (
85
+ headers: Record<string, string>,
86
+ allow: ReadonlyArray<string>,
87
+ redact: ReadonlyArray<string> = DEFAULT_REDACT_HEADERS,
88
+ ) => {
89
+ const allowed = new Set(allow.map((name) => name.toLowerCase()))
90
+ const redacted = redactionSet(redact, DEFAULT_REDACT_HEADERS)
91
+ return Object.fromEntries(
92
+ Object.entries(headers)
93
+ .map(([name, value]) => [name.toLowerCase(), value] as const)
94
+ .filter(([name]) => allowed.has(name))
95
+ .map(([name, value]) => [name, redacted.has(name) ? REDACTED : value] as const)
96
+ .toSorted(([a], [b]) => a.localeCompare(b)),
97
+ )
98
+ }
99
+
100
+ export const SecretFindingSchema = Schema.Struct({
101
+ path: Schema.String,
102
+ reason: Schema.String,
103
+ })
104
+ export type SecretFinding = Schema.Schema.Type<typeof SecretFindingSchema>
105
+
106
+ export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> => {
107
+ const environment = envSecrets()
108
+ return stringEntries(value).flatMap((entry) => [
109
+ ...SECRET_PATTERNS.filter((item) => item.pattern.test(entry.value)).map((item) => ({
110
+ path: entry.path,
111
+ reason: item.label,
112
+ })),
113
+ ...environment
114
+ .filter((item) => entry.value.includes(item.value))
115
+ .map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })),
116
+ ])
117
+ }
@@ -0,0 +1,135 @@
1
+ import { Option } from "effect"
2
+ import { decodeJson } from "./matching.js"
3
+ import { REDACTED, redactHeaders, redactUrl } from "./redaction.js"
4
+ import type { RedactOptions, RequestSnapshot, ResponseSnapshot } from "./types.js"
5
+
6
+ export type { RedactOptions } from "./types.js"
7
+
8
+ export const DEFAULT_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
9
+ export const DEFAULT_RESPONSE_HEADERS: ReadonlyArray<string> = ["content-type"]
10
+
11
+ const identity = <T>(value: T) => value
12
+
13
+ export interface Redactor {
14
+ readonly request: (snapshot: RequestSnapshot) => RequestSnapshot
15
+ readonly response: (snapshot: ResponseSnapshot) => ResponseSnapshot
16
+ }
17
+
18
+ export const compose = (...redactors: ReadonlyArray<Partial<Redactor>>): Redactor => {
19
+ const requests = redactors.map((r) => r.request).filter((fn): fn is Redactor["request"] => fn !== undefined)
20
+ const responses = redactors.map((r) => r.response).filter((fn): fn is Redactor["response"] => fn !== undefined)
21
+ return {
22
+ request: requests.length === 0 ? identity : (snapshot) => requests.reduce((acc, fn) => fn(acc), snapshot),
23
+ response: responses.length === 0 ? identity : (snapshot) => responses.reduce((acc, fn) => fn(acc), snapshot),
24
+ }
25
+ }
26
+
27
+ export interface HeaderOptions {
28
+ readonly allow?: ReadonlyArray<string>
29
+ readonly redact?: ReadonlyArray<string>
30
+ }
31
+
32
+ export const requestHeaders = (options: HeaderOptions = {}): Partial<Redactor> => ({
33
+ request: (snapshot) => ({
34
+ ...snapshot,
35
+ headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_REQUEST_HEADERS, options.redact),
36
+ }),
37
+ })
38
+
39
+ export const responseHeaders = (options: HeaderOptions = {}): Partial<Redactor> => ({
40
+ response: (snapshot) => ({
41
+ ...snapshot,
42
+ headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_RESPONSE_HEADERS, options.redact),
43
+ }),
44
+ })
45
+
46
+ export interface UrlOptions {
47
+ readonly query?: ReadonlyArray<string>
48
+ readonly transform?: (url: string) => string
49
+ }
50
+
51
+ export const url = (options: UrlOptions = {}): Partial<Redactor> => ({
52
+ request: (snapshot) => ({ ...snapshot, url: redactUrl(snapshot.url, options.query, options.transform) }),
53
+ })
54
+
55
+ export const body = (transform: (parsed: unknown) => unknown): Partial<Redactor> => ({
56
+ request: (snapshot) => ({
57
+ ...snapshot,
58
+ body: Option.match(decodeJson(snapshot.body), {
59
+ onNone: () => snapshot.body,
60
+ onSome: (parsed) => JSON.stringify(transform(parsed)),
61
+ }),
62
+ }),
63
+ })
64
+
65
+ export interface DefaultRedactorOverrides {
66
+ readonly requestHeaders?: HeaderOptions
67
+ readonly responseHeaders?: HeaderOptions
68
+ readonly url?: UrlOptions
69
+ readonly body?: (parsed: unknown) => unknown
70
+ }
71
+
72
+ const DEFAULT_REDACT_JSON_FIELDS = [
73
+ "access_token",
74
+ "api_key",
75
+ "apikey",
76
+ "client_secret",
77
+ "password",
78
+ "refresh_token",
79
+ "secret",
80
+ "token",
81
+ ]
82
+
83
+ const normalizeField = (field: string) => field.replace(/[^a-z0-9]/gi, "").toLowerCase()
84
+
85
+ const redactJsonFields = (value: unknown, fields: ReadonlySet<string>): unknown => {
86
+ if (Array.isArray(value)) return value.map((item) => redactJsonFields(item, fields))
87
+ if (!value || typeof value !== "object") return value
88
+ return Object.fromEntries(
89
+ Object.entries(value).map(([key, child]) => [
90
+ key,
91
+ fields.has(normalizeField(key)) ? REDACTED : redactJsonFields(child, fields),
92
+ ]),
93
+ )
94
+ }
95
+
96
+ const redactBody = (value: string, fields: ReadonlySet<string>, transform: ((body: string) => string) | undefined) => {
97
+ const redacted = Option.match(decodeJson(value), {
98
+ onNone: () => value,
99
+ onSome: (parsed) => JSON.stringify(redactJsonFields(parsed, fields)),
100
+ })
101
+ return transform?.(redacted) ?? redacted
102
+ }
103
+
104
+ export const make = (options: RedactOptions = {}): Redactor => {
105
+ const fields = new Set([...DEFAULT_REDACT_JSON_FIELDS, ...(options.jsonFields ?? [])].map(normalizeField))
106
+ return compose(
107
+ requestHeaders({
108
+ allow: [...DEFAULT_REQUEST_HEADERS, ...(options.allowRequestHeaders ?? []), ...(options.headers ?? [])],
109
+ redact: options.headers,
110
+ }),
111
+ responseHeaders({
112
+ allow: [...DEFAULT_RESPONSE_HEADERS, ...(options.allowResponseHeaders ?? []), ...(options.headers ?? [])],
113
+ redact: options.headers,
114
+ }),
115
+ url({ query: options.queryParameters, transform: options.url }),
116
+ {
117
+ request: (snapshot) => ({
118
+ ...snapshot,
119
+ body: redactBody(snapshot.body, fields, options.body),
120
+ }),
121
+ response: (snapshot) => ({
122
+ ...snapshot,
123
+ body: redactBody(snapshot.body, fields, options.body),
124
+ }),
125
+ },
126
+ )
127
+ }
128
+
129
+ export const defaults = (overrides: DefaultRedactorOverrides = {}): Redactor =>
130
+ compose(
131
+ requestHeaders(overrides.requestHeaders),
132
+ responseHeaders(overrides.responseHeaders),
133
+ url(overrides.url),
134
+ ...(overrides.body ? [body(overrides.body)] : []),
135
+ )
package/src/schema.ts ADDED
@@ -0,0 +1,87 @@
1
+ import { Schema } from "effect"
2
+ import type {
3
+ CassetteMetadata,
4
+ HttpInteraction,
5
+ RequestSnapshot,
6
+ ResponseSnapshot,
7
+ WebSocketEvent,
8
+ WebSocketInteraction,
9
+ } from "./types.js"
10
+
11
+ export type {
12
+ CassetteMetadata,
13
+ HttpInteraction,
14
+ RequestSnapshot,
15
+ ResponseSnapshot,
16
+ WebSocketEvent,
17
+ WebSocketInteraction,
18
+ } from "./types.js"
19
+
20
+ export const RequestSnapshotSchema = Schema.Struct({
21
+ method: Schema.String,
22
+ url: Schema.String,
23
+ headers: Schema.Record(Schema.String, Schema.String),
24
+ body: Schema.String,
25
+ })
26
+
27
+ export const ResponseSnapshotSchema = Schema.Struct({
28
+ status: Schema.Number,
29
+ headers: Schema.Record(Schema.String, Schema.String),
30
+ body: Schema.String,
31
+ bodyEncoding: Schema.optional(Schema.Literals(["text", "base64"])),
32
+ })
33
+
34
+ export const CassetteMetadataSchema = Schema.Record(Schema.String, Schema.Unknown)
35
+
36
+ export const HttpInteractionSchema = Schema.Struct({
37
+ transport: Schema.tag("http"),
38
+ request: RequestSnapshotSchema,
39
+ response: ResponseSnapshotSchema,
40
+ })
41
+
42
+ export const WebSocketEventSchema = Schema.Union([
43
+ Schema.Struct({
44
+ direction: Schema.Literals(["client", "server"]),
45
+ kind: Schema.tag("text"),
46
+ body: Schema.String,
47
+ }),
48
+ Schema.Struct({
49
+ direction: Schema.Literals(["client", "server"]),
50
+ kind: Schema.tag("binary"),
51
+ body: Schema.String,
52
+ bodyEncoding: Schema.Literal("base64"),
53
+ }),
54
+ ])
55
+
56
+ export const WebSocketInteractionSchema = Schema.Struct({
57
+ transport: Schema.tag("websocket"),
58
+ open: Schema.Struct({
59
+ url: Schema.String,
60
+ headers: Schema.Record(Schema.String, Schema.String),
61
+ }),
62
+ events: Schema.Array(WebSocketEventSchema),
63
+ })
64
+
65
+ export const InteractionSchema = Schema.Union([HttpInteractionSchema, WebSocketInteractionSchema]).pipe(
66
+ Schema.toTaggedUnion("transport"),
67
+ )
68
+ export type Interaction = Schema.Schema.Type<typeof InteractionSchema>
69
+
70
+ export const isHttpInteraction = InteractionSchema.guards.http
71
+
72
+ export const isWebSocketInteraction = InteractionSchema.guards.websocket
73
+
74
+ export const httpInteractions = (interactions: ReadonlyArray<Interaction>) => interactions.filter(isHttpInteraction)
75
+
76
+ export const webSocketInteractions = (interactions: ReadonlyArray<Interaction>) =>
77
+ interactions.filter(isWebSocketInteraction)
78
+
79
+ export const CassetteSchema = Schema.Struct({
80
+ version: Schema.Literal(1),
81
+ metadata: Schema.optional(CassetteMetadataSchema),
82
+ interactions: Schema.Array(InteractionSchema),
83
+ })
84
+ export type Cassette = Schema.Schema.Type<typeof CassetteSchema>
85
+
86
+ export const decodeCassette = Schema.decodeUnknownSync(CassetteSchema)
87
+ export const encodeCassette = Schema.encodeSync(CassetteSchema)
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
+ )