@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/package.json
CHANGED
|
@@ -1,60 +1,61 @@
|
|
|
1
|
-
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
"
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
"
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
"
|
|
59
|
-
|
|
60
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/package.json",
|
|
3
|
+
"version": "1.18.15",
|
|
4
|
+
"name": "@neurocode-ai/http-recorder",
|
|
5
|
+
"description": "Record and replay Effect HTTP client traffic with deterministic cassettes",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/anomalyco/opencode.git",
|
|
11
|
+
"directory": "packages/http-recorder"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/anomalyco/opencode/tree/dev/packages/http-recorder",
|
|
14
|
+
"bugs": "https://github.com/anomalyco/opencode/issues",
|
|
15
|
+
"keywords": [
|
|
16
|
+
"effect",
|
|
17
|
+
"http",
|
|
18
|
+
"recording",
|
|
19
|
+
"replay",
|
|
20
|
+
"testing",
|
|
21
|
+
"vcr"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": "\u003e=22"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "bun test --timeout 30000 --only-failures",
|
|
31
|
+
"typecheck": "tsgo --noEmit",
|
|
32
|
+
"build": "bun ./script/build.ts",
|
|
33
|
+
"verify:package": "bun ./script/verify-package.ts"
|
|
34
|
+
},
|
|
35
|
+
"exports": {
|
|
36
|
+
".": "./src/index.ts",
|
|
37
|
+
"./internal": "./src/internal.ts"
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"src",
|
|
41
|
+
"dist",
|
|
42
|
+
"README.md",
|
|
43
|
+
"CHANGELOG.md",
|
|
44
|
+
"LICENSE"
|
|
45
|
+
],
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@tsconfig/node22": "22.0.2",
|
|
48
|
+
"@types/bun": "1.3.13",
|
|
49
|
+
"@types/node": "24.12.2",
|
|
50
|
+
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
|
51
|
+
"effect": "4.0.0-beta.83",
|
|
52
|
+
"typescript": "5.8.2"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@effect/platform-node": "4.0.0-beta.83",
|
|
56
|
+
"@effect/platform-node-shared": "4.0.0-beta.83"
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"effect": "4.0.0-beta.83"
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/cassette.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { Context, Effect, FileSystem, Layer, Schema, Semaphore } from "effect"
|
|
2
|
+
import * as fs from "node:fs"
|
|
3
|
+
import * as path from "node:path"
|
|
4
|
+
import { secretFindings, SecretFindingSchema, type SecretFinding } from "./redaction.js"
|
|
5
|
+
import { CassetteSchema, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema.js"
|
|
6
|
+
|
|
7
|
+
const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
|
|
8
|
+
|
|
9
|
+
export class CassetteNotFoundError extends Schema.TaggedErrorClass<CassetteNotFoundError>()("CassetteNotFoundError", {
|
|
10
|
+
cassetteName: Schema.String,
|
|
11
|
+
}) {
|
|
12
|
+
override get message() {
|
|
13
|
+
return `Cassette "${this.cassetteName}" not found`
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class UnsafeCassetteError extends Schema.TaggedErrorClass<UnsafeCassetteError>()("UnsafeCassetteError", {
|
|
18
|
+
cassetteName: Schema.String,
|
|
19
|
+
findings: Schema.Array(SecretFindingSchema),
|
|
20
|
+
}) {
|
|
21
|
+
override get message() {
|
|
22
|
+
return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings
|
|
23
|
+
.map((finding) => `${finding.path} (${finding.reason})`)
|
|
24
|
+
.join(", ")}`
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface Interface {
|
|
29
|
+
readonly read: (name: string) => Effect.Effect<ReadonlyArray<Interaction>, CassetteNotFoundError>
|
|
30
|
+
readonly append: (
|
|
31
|
+
name: string,
|
|
32
|
+
interaction: Interaction,
|
|
33
|
+
metadata?: CassetteMetadata,
|
|
34
|
+
) => Effect.Effect<void, UnsafeCassetteError>
|
|
35
|
+
readonly exists: (name: string) => Effect.Effect<boolean>
|
|
36
|
+
readonly list: () => Effect.Effect<ReadonlyArray<string>>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class Service extends Context.Service<Service, Interface>()("@neurocode-ai/http-recorder/Cassette") {}
|
|
40
|
+
|
|
41
|
+
const cassettePath = (directory: string, name: string) => {
|
|
42
|
+
if (!name || path.isAbsolute(name) || path.win32.isAbsolute(name) || name.split(/[\\/]/).includes(".."))
|
|
43
|
+
throw new Error(`Invalid cassette name "${name}"`)
|
|
44
|
+
const root = path.resolve(directory)
|
|
45
|
+
const target = path.resolve(root, `${name}.json`)
|
|
46
|
+
const relative = path.relative(root, target)
|
|
47
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative))
|
|
48
|
+
throw new Error(`Invalid cassette name "${name}"`)
|
|
49
|
+
return target
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) =>
|
|
53
|
+
fs.existsSync(cassettePath(options.directory ?? DEFAULT_RECORDINGS_DIR, name))
|
|
54
|
+
|
|
55
|
+
const buildCassette = (
|
|
56
|
+
name: string,
|
|
57
|
+
interactions: ReadonlyArray<Interaction>,
|
|
58
|
+
metadata: CassetteMetadata | undefined,
|
|
59
|
+
): Cassette => ({
|
|
60
|
+
version: 1,
|
|
61
|
+
metadata: { name, recordedAt: new Date().toISOString(), ...metadata },
|
|
62
|
+
interactions,
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n`
|
|
66
|
+
|
|
67
|
+
const parseCassette = Schema.decodeUnknownSync(Schema.fromJsonString(CassetteSchema))
|
|
68
|
+
|
|
69
|
+
const failIfUnsafe = (name: string, findings: ReadonlyArray<SecretFinding>) =>
|
|
70
|
+
findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings }))
|
|
71
|
+
|
|
72
|
+
export const fileSystem = (
|
|
73
|
+
options: { readonly directory?: string } = {},
|
|
74
|
+
): Layer.Layer<Service, never, FileSystem.FileSystem> =>
|
|
75
|
+
Layer.effect(
|
|
76
|
+
Service,
|
|
77
|
+
Effect.gen(function* () {
|
|
78
|
+
const fs = yield* FileSystem.FileSystem
|
|
79
|
+
const directory = options.directory ?? DEFAULT_RECORDINGS_DIR
|
|
80
|
+
const recorded = new Map<string, { interactions: Interaction[]; findings: SecretFinding[] }>()
|
|
81
|
+
const appendLock = yield* Semaphore.make(1)
|
|
82
|
+
|
|
83
|
+
const pathFor = (name: string) => cassettePath(directory, name)
|
|
84
|
+
|
|
85
|
+
const walk = (current: string): Effect.Effect<ReadonlyArray<string>> =>
|
|
86
|
+
Effect.gen(function* () {
|
|
87
|
+
const entries = yield* fs.readDirectory(current).pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
|
88
|
+
const nested = yield* Effect.forEach(entries, (entry) => {
|
|
89
|
+
const full = path.join(current, entry)
|
|
90
|
+
return fs.stat(full).pipe(
|
|
91
|
+
Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))),
|
|
92
|
+
Effect.catch(() => Effect.succeed([] as string[])),
|
|
93
|
+
)
|
|
94
|
+
})
|
|
95
|
+
return nested.flat()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
return Service.of({
|
|
99
|
+
read: (name) =>
|
|
100
|
+
fs.readFileString(pathFor(name)).pipe(
|
|
101
|
+
Effect.map((raw) => parseCassette(raw).interactions),
|
|
102
|
+
Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))),
|
|
103
|
+
),
|
|
104
|
+
append: (name, interaction, metadata) =>
|
|
105
|
+
appendLock.withPermit(
|
|
106
|
+
Effect.gen(function* () {
|
|
107
|
+
const entry = recorded.get(name) ?? { interactions: [], findings: [] }
|
|
108
|
+
const interactions = [...entry.interactions, interaction]
|
|
109
|
+
const interactionFindings = [...entry.findings, ...secretFindings(interaction)]
|
|
110
|
+
const cassette = buildCassette(name, interactions, metadata)
|
|
111
|
+
const findings = [...interactionFindings, ...secretFindings(cassette.metadata ?? {})]
|
|
112
|
+
yield* failIfUnsafe(name, findings)
|
|
113
|
+
const target = pathFor(name)
|
|
114
|
+
yield* fs.makeDirectory(path.dirname(target), { recursive: true }).pipe(Effect.orDie)
|
|
115
|
+
const temporary = `${target}.${crypto.randomUUID()}.tmp`
|
|
116
|
+
yield* fs.writeFileString(temporary, formatCassette(cassette)).pipe(
|
|
117
|
+
Effect.flatMap(() => fs.rename(temporary, target)),
|
|
118
|
+
Effect.ensuring(fs.remove(temporary, { force: true }).pipe(Effect.catch(() => Effect.void))),
|
|
119
|
+
Effect.orDie,
|
|
120
|
+
)
|
|
121
|
+
recorded.set(name, { interactions, findings: interactionFindings })
|
|
122
|
+
}),
|
|
123
|
+
),
|
|
124
|
+
exists: (name) =>
|
|
125
|
+
fs.access(pathFor(name)).pipe(
|
|
126
|
+
Effect.as(true),
|
|
127
|
+
Effect.catch(() => Effect.succeed(false)),
|
|
128
|
+
),
|
|
129
|
+
list: () =>
|
|
130
|
+
walk(directory).pipe(
|
|
131
|
+
Effect.map((files) =>
|
|
132
|
+
files
|
|
133
|
+
.filter((file) => file.endsWith(".json"))
|
|
134
|
+
.map((file) =>
|
|
135
|
+
path
|
|
136
|
+
.relative(directory, file)
|
|
137
|
+
.replace(/\\/g, "/")
|
|
138
|
+
.replace(/\.json$/, ""),
|
|
139
|
+
)
|
|
140
|
+
.toSorted((a, b) => a.localeCompare(b)),
|
|
141
|
+
),
|
|
142
|
+
),
|
|
143
|
+
})
|
|
144
|
+
}),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
export const memory = (initial: Record<string, ReadonlyArray<Interaction>> = {}): Layer.Layer<Service> =>
|
|
148
|
+
Layer.sync(Service, () => {
|
|
149
|
+
const stored = new Map<string, Interaction[]>(
|
|
150
|
+
Object.entries(initial).map(([name, interactions]) => [name, [...interactions]]),
|
|
151
|
+
)
|
|
152
|
+
const accumulatedFindings = new Map<string, SecretFinding[]>()
|
|
153
|
+
const appendLock = Semaphore.makeUnsafe(1)
|
|
154
|
+
|
|
155
|
+
return Service.of({
|
|
156
|
+
read: (name) =>
|
|
157
|
+
stored.has(name)
|
|
158
|
+
? Effect.succeed(stored.get(name) ?? [])
|
|
159
|
+
: Effect.fail(new CassetteNotFoundError({ cassetteName: name })),
|
|
160
|
+
append: (name, interaction, metadata) =>
|
|
161
|
+
appendLock.withPermit(
|
|
162
|
+
Effect.suspend(() => {
|
|
163
|
+
const interactions = [...(stored.get(name) ?? []), interaction]
|
|
164
|
+
const findings = [...(accumulatedFindings.get(name) ?? []), ...secretFindings(interaction)]
|
|
165
|
+
const allFindings = metadata ? [...findings, ...secretFindings({ name, ...metadata })] : findings
|
|
166
|
+
return failIfUnsafe(name, allFindings).pipe(
|
|
167
|
+
Effect.tap(() =>
|
|
168
|
+
Effect.sync(() => {
|
|
169
|
+
stored.set(name, interactions)
|
|
170
|
+
accumulatedFindings.set(name, findings)
|
|
171
|
+
}),
|
|
172
|
+
),
|
|
173
|
+
)
|
|
174
|
+
}),
|
|
175
|
+
),
|
|
176
|
+
exists: (name) => Effect.sync(() => stored.has(name)),
|
|
177
|
+
list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()),
|
|
178
|
+
})
|
|
179
|
+
})
|
package/src/effect.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { NodeFileSystem } from "@effect/platform-node"
|
|
2
|
+
import * as Layer from "effect/Layer"
|
|
3
|
+
import { FetchHttpClient } from "effect/unstable/http"
|
|
4
|
+
import type * as HttpClient from "effect/unstable/http/HttpClient"
|
|
5
|
+
import * as CassetteService from "./cassette.js"
|
|
6
|
+
import { recordingLayer } from "./internal-effect.js"
|
|
7
|
+
import { make } from "./redactor.js"
|
|
8
|
+
import type { RecorderOptions } from "./types.js"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Provides a fetch-backed `HttpClient` with cassette recording and replay.
|
|
12
|
+
*
|
|
13
|
+
* Locally, a missing cassette is recorded from the real service. Existing
|
|
14
|
+
* cassettes are replayed, and `CI=true` makes a missing cassette fail.
|
|
15
|
+
*/
|
|
16
|
+
export const http = (name: string, options: RecorderOptions = {}): Layer.Layer<HttpClient.HttpClient> =>
|
|
17
|
+
recordingLayer(name, {
|
|
18
|
+
metadata: options.metadata,
|
|
19
|
+
redactor: make(options.redact),
|
|
20
|
+
match: options.match,
|
|
21
|
+
}).pipe(
|
|
22
|
+
Layer.provide(CassetteService.fileSystem({ directory: options.directory })),
|
|
23
|
+
Layer.provide(FetchHttpClient.layer),
|
|
24
|
+
Layer.provide(NodeFileSystem.layer),
|
|
25
|
+
)
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { http } from "./effect.js"
|
|
2
|
+
import { socket } from "./socket.js"
|
|
3
|
+
|
|
4
|
+
/** HTTP and WebSocket cassette recording. */
|
|
5
|
+
export const HttpRecorder = { http, socket } as const
|
|
6
|
+
|
|
7
|
+
export namespace HttpRecorder {
|
|
8
|
+
/** Additional JSON metadata stored with a cassette. */
|
|
9
|
+
export type CassetteMetadata = import("./types.js").CassetteMetadata
|
|
10
|
+
/** Recorder configuration. */
|
|
11
|
+
export type RecorderOptions = import("./types.js").RecorderOptions
|
|
12
|
+
/** Additive redaction and header-preservation policy. */
|
|
13
|
+
export type RedactOptions = import("./types.js").RedactOptions
|
|
14
|
+
/** Returns whether an incoming HTTP request matches a recorded request. */
|
|
15
|
+
export type RequestMatcher = import("./types.js").RequestMatcher
|
|
16
|
+
/** The normalized HTTP request representation used for matching. */
|
|
17
|
+
export type RequestSnapshot = import("./types.js").RequestSnapshot
|
|
18
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { NodeFileSystem } from "@effect/platform-node"
|
|
2
|
+
import { Deferred, Effect, Layer, Option, Ref } from "effect"
|
|
3
|
+
import {
|
|
4
|
+
FetchHttpClient,
|
|
5
|
+
Headers,
|
|
6
|
+
HttpBody,
|
|
7
|
+
HttpClient,
|
|
8
|
+
HttpClientError,
|
|
9
|
+
HttpClientRequest,
|
|
10
|
+
HttpClientResponse,
|
|
11
|
+
UrlParams,
|
|
12
|
+
} from "effect/unstable/http"
|
|
13
|
+
import * as CassetteService from "./cassette.js"
|
|
14
|
+
import { defaultMatcher, selectSequential } from "./matching.js"
|
|
15
|
+
import { makeReplayState, resolveAutoMode } from "./recorder.js"
|
|
16
|
+
import { make, type Redactor } from "./redactor.js"
|
|
17
|
+
import { redactUrl } from "./redaction.js"
|
|
18
|
+
import { httpInteractions } from "./schema.js"
|
|
19
|
+
import type { CassetteMetadata, HttpInteraction, RequestMatcher, ResponseSnapshot } from "./types.js"
|
|
20
|
+
|
|
21
|
+
export { defaultMatcher }
|
|
22
|
+
|
|
23
|
+
export type RecordReplayMode = "auto" | "record" | "replay" | "passthrough"
|
|
24
|
+
|
|
25
|
+
export interface RecordReplayOptions {
|
|
26
|
+
readonly mode?: RecordReplayMode
|
|
27
|
+
readonly directory?: string
|
|
28
|
+
readonly metadata?: CassetteMetadata
|
|
29
|
+
readonly redactor?: Redactor
|
|
30
|
+
readonly match?: RequestMatcher
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const TEXT_CONTENT_TYPES = new Set([
|
|
34
|
+
"application/graphql",
|
|
35
|
+
"application/javascript",
|
|
36
|
+
"application/json",
|
|
37
|
+
"application/sql",
|
|
38
|
+
"application/x-www-form-urlencoded",
|
|
39
|
+
"application/xml",
|
|
40
|
+
"application/yaml",
|
|
41
|
+
"image/svg+xml",
|
|
42
|
+
])
|
|
43
|
+
|
|
44
|
+
const isTextContentType = (contentType: string | undefined) => {
|
|
45
|
+
const mediaType = contentType?.split(";", 1)[0]?.trim().toLowerCase()
|
|
46
|
+
if (!mediaType) return false
|
|
47
|
+
return (
|
|
48
|
+
mediaType.startsWith("text/") ||
|
|
49
|
+
mediaType.endsWith("+json") ||
|
|
50
|
+
mediaType.endsWith("+xml") ||
|
|
51
|
+
TEXT_CONTENT_TYPES.has(mediaType)
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) =>
|
|
56
|
+
response.arrayBuffer.pipe(
|
|
57
|
+
Effect.map((bytes) =>
|
|
58
|
+
isTextContentType(contentType)
|
|
59
|
+
? { body: new TextDecoder().decode(bytes) }
|
|
60
|
+
: { body: Buffer.from(bytes).toString("base64"), bodyEncoding: "base64" as const },
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
const decodeResponseBody = (snapshot: ResponseSnapshot) =>
|
|
65
|
+
snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body
|
|
66
|
+
|
|
67
|
+
const responseFromSnapshot = (request: HttpClientRequest.HttpClientRequest, snapshot: ResponseSnapshot) =>
|
|
68
|
+
HttpClientResponse.fromWeb(
|
|
69
|
+
request,
|
|
70
|
+
new Response(
|
|
71
|
+
request.method === "HEAD" || snapshot.status === 204 || snapshot.status === 205 || snapshot.status === 304
|
|
72
|
+
? null
|
|
73
|
+
: decodeResponseBody(snapshot),
|
|
74
|
+
snapshot,
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) =>
|
|
79
|
+
HttpClientRequest.makeWith(
|
|
80
|
+
request.method,
|
|
81
|
+
redactUrl(request.url),
|
|
82
|
+
UrlParams.empty,
|
|
83
|
+
Option.none(),
|
|
84
|
+
Headers.empty,
|
|
85
|
+
HttpBody.empty,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
const transportError = (request: HttpClientRequest.HttpClientRequest, description: string) =>
|
|
89
|
+
new HttpClientError.HttpClientError({
|
|
90
|
+
reason: new HttpClientError.TransportError({ request: redactedErrorRequest(request), description }),
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
export const recordingLayer = (
|
|
94
|
+
name: string,
|
|
95
|
+
options: Omit<RecordReplayOptions, "directory"> = {},
|
|
96
|
+
): Layer.Layer<HttpClient.HttpClient, never, HttpClient.HttpClient | CassetteService.Service> =>
|
|
97
|
+
Layer.effect(
|
|
98
|
+
HttpClient.HttpClient,
|
|
99
|
+
Effect.gen(function* () {
|
|
100
|
+
const upstream = yield* HttpClient.HttpClient
|
|
101
|
+
const cassetteService = yield* CassetteService.Service
|
|
102
|
+
const redactor = options.redactor ?? make()
|
|
103
|
+
const match = options.match ?? defaultMatcher
|
|
104
|
+
const requested = options.mode ?? "auto"
|
|
105
|
+
const mode = requested === "auto" ? yield* resolveAutoMode(cassetteService, name) : requested
|
|
106
|
+
|
|
107
|
+
const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) =>
|
|
108
|
+
Effect.gen(function* () {
|
|
109
|
+
const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)
|
|
110
|
+
return redactor.request({
|
|
111
|
+
method: web.method,
|
|
112
|
+
url: web.url,
|
|
113
|
+
headers: Object.fromEntries(web.headers.entries()),
|
|
114
|
+
body: yield* Effect.promise(() => web.text()),
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
if (mode === "passthrough") return upstream
|
|
119
|
+
|
|
120
|
+
if (mode === "record") {
|
|
121
|
+
const initial = yield* Deferred.make<void>()
|
|
122
|
+
yield* Deferred.succeed(initial, undefined)
|
|
123
|
+
const tail = yield* Ref.make(initial)
|
|
124
|
+
return HttpClient.make((request) =>
|
|
125
|
+
Effect.gen(function* () {
|
|
126
|
+
const completed = yield* Deferred.make<void>()
|
|
127
|
+
const previous = yield* Ref.modify(tail, (current) => [current, completed])
|
|
128
|
+
return yield* Effect.gen(function* () {
|
|
129
|
+
const incoming = yield* snapshotRequest(request)
|
|
130
|
+
const response = yield* upstream.execute(request)
|
|
131
|
+
const captured = yield* captureResponseBody(response, response.headers["content-type"])
|
|
132
|
+
const responseSnapshot: ResponseSnapshot = {
|
|
133
|
+
status: response.status,
|
|
134
|
+
headers: response.headers as Record<string, string>,
|
|
135
|
+
...captured,
|
|
136
|
+
}
|
|
137
|
+
const interaction: HttpInteraction = {
|
|
138
|
+
transport: "http",
|
|
139
|
+
request: incoming,
|
|
140
|
+
response: redactor.response(responseSnapshot),
|
|
141
|
+
}
|
|
142
|
+
yield* Deferred.await(previous)
|
|
143
|
+
yield* cassetteService
|
|
144
|
+
.append(name, interaction, options.metadata)
|
|
145
|
+
.pipe(
|
|
146
|
+
Effect.catchTag("UnsafeCassetteError", (error) =>
|
|
147
|
+
Effect.fail(transportError(request, error.message)),
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
return responseFromSnapshot(request, responseSnapshot)
|
|
151
|
+
}).pipe(Effect.ensuring(Deferred.succeed(completed, undefined)))
|
|
152
|
+
}),
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const replay = yield* makeReplayState(cassetteService, name, httpInteractions)
|
|
157
|
+
return HttpClient.make((request) =>
|
|
158
|
+
Effect.gen(function* () {
|
|
159
|
+
const incoming = yield* snapshotRequest(request)
|
|
160
|
+
const claimed = yield* replay
|
|
161
|
+
.claim((interaction, index, interactions) => {
|
|
162
|
+
const result = selectSequential(interactions, incoming, match, index)
|
|
163
|
+
if (result.interaction) return Effect.void
|
|
164
|
+
return Effect.fail(
|
|
165
|
+
transportError(request, `Fixture "${name}" does not match the current request: ${result.detail}.`),
|
|
166
|
+
)
|
|
167
|
+
})
|
|
168
|
+
.pipe(
|
|
169
|
+
Effect.mapError((error) =>
|
|
170
|
+
error._tag === "CassetteNotFoundError"
|
|
171
|
+
? transportError(
|
|
172
|
+
request,
|
|
173
|
+
`Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`,
|
|
174
|
+
)
|
|
175
|
+
: error,
|
|
176
|
+
),
|
|
177
|
+
)
|
|
178
|
+
return responseFromSnapshot(request, claimed.interaction.response)
|
|
179
|
+
}),
|
|
180
|
+
)
|
|
181
|
+
}),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer<HttpClient.HttpClient> =>
|
|
185
|
+
recordingLayer(name, options).pipe(
|
|
186
|
+
Layer.provide(CassetteService.fileSystem({ directory: options.directory })),
|
|
187
|
+
Layer.provide(FetchHttpClient.layer),
|
|
188
|
+
Layer.provide(NodeFileSystem.layer),
|
|
189
|
+
)
|
package/src/internal.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { CassetteNotFoundError, hasCassetteSync, UnsafeCassetteError } from "./cassette.js"
|
|
2
|
+
export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./internal-effect.js"
|
|
3
|
+
export { redactHeaders, redactUrl, secretFindings, type SecretFinding } from "./redaction.js"
|
|
4
|
+
export { socketLayer } from "./socket.js"
|
|
5
|
+
export {
|
|
6
|
+
makeWebSocketExecutor,
|
|
7
|
+
type WebSocketConnection,
|
|
8
|
+
type WebSocketExecutor,
|
|
9
|
+
type WebSocketRecordReplayOptions,
|
|
10
|
+
type WebSocketRequest,
|
|
11
|
+
} from "./websocket.js"
|
|
12
|
+
export * as Cassette from "./cassette.js"
|
|
13
|
+
export * as Redactor from "./redactor.js"
|
|
14
|
+
|
|
15
|
+
export * as HttpRecorderInternal from "./internal.js"
|
package/src/matching.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { Option, Schema } from "effect"
|
|
2
|
+
import { REDACTED, secretFindings } from "./redaction.js"
|
|
3
|
+
import type { HttpInteraction, RequestMatcher, RequestSnapshot } from "./types.js"
|
|
4
|
+
|
|
5
|
+
const JsonValue = Schema.fromJsonString(Schema.Unknown)
|
|
6
|
+
export const decodeJson = Schema.decodeUnknownOption(JsonValue)
|
|
7
|
+
|
|
8
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
9
|
+
value !== null && typeof value === "object" && !Array.isArray(value)
|
|
10
|
+
|
|
11
|
+
export const canonicalizeJson = (value: unknown): unknown => {
|
|
12
|
+
if (Array.isArray(value)) return value.map(canonicalizeJson)
|
|
13
|
+
if (isRecord(value)) {
|
|
14
|
+
return Object.fromEntries(
|
|
15
|
+
Object.keys(value)
|
|
16
|
+
.toSorted()
|
|
17
|
+
.map((key) => [key, canonicalizeJson(value[key])]),
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
return value
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type { RequestMatcher } from "./types.js"
|
|
24
|
+
|
|
25
|
+
export const canonicalSnapshot = (snapshot: RequestSnapshot): string =>
|
|
26
|
+
JSON.stringify({
|
|
27
|
+
method: snapshot.method,
|
|
28
|
+
url: snapshot.url,
|
|
29
|
+
headers: canonicalizeJson(snapshot.headers),
|
|
30
|
+
body: Option.match(decodeJson(snapshot.body), {
|
|
31
|
+
onNone: () => snapshot.body,
|
|
32
|
+
onSome: canonicalizeJson,
|
|
33
|
+
}),
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
export const defaultMatcher: RequestMatcher = (incoming, recorded) =>
|
|
37
|
+
canonicalSnapshot(incoming) === canonicalSnapshot(recorded)
|
|
38
|
+
|
|
39
|
+
export const safeText = (value: unknown) => {
|
|
40
|
+
if (value === undefined) return "undefined"
|
|
41
|
+
if (secretFindings(value).length > 0) return JSON.stringify(REDACTED)
|
|
42
|
+
const text = JSON.stringify(value)
|
|
43
|
+
if (!text) return typeof value
|
|
44
|
+
return text.length > 300 ? `${text.slice(0, 300)}...` : text
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body))
|
|
48
|
+
|
|
49
|
+
const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray<string> => {
|
|
50
|
+
if (Object.is(expected, received)) return []
|
|
51
|
+
if (isRecord(expected) && isRecord(received)) {
|
|
52
|
+
return [...new Set([...Object.keys(expected), ...Object.keys(received)])]
|
|
53
|
+
.toSorted()
|
|
54
|
+
.flatMap((key) => valueDiffs(expected[key], received[key], `${base}.${key}`, limit))
|
|
55
|
+
.slice(0, limit)
|
|
56
|
+
}
|
|
57
|
+
if (Array.isArray(expected) && Array.isArray(received)) {
|
|
58
|
+
return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index)
|
|
59
|
+
.flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit))
|
|
60
|
+
.slice(0, limit)
|
|
61
|
+
}
|
|
62
|
+
return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const headerDiffs = (expected: Record<string, string>, received: Record<string, string>) =>
|
|
66
|
+
[...new Set([...Object.keys(expected), ...Object.keys(received)])].toSorted().flatMap((key) => {
|
|
67
|
+
if (expected[key] === received[key]) return []
|
|
68
|
+
if (expected[key] === undefined) return [` ${key} unexpected ${safeText(received[key])}`]
|
|
69
|
+
if (received[key] === undefined) return [` ${key} missing expected ${safeText(expected[key])}`]
|
|
70
|
+
return [` ${key} expected ${safeText(expected[key])}, received ${safeText(received[key])}`]
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot): ReadonlyArray<string> => {
|
|
74
|
+
const lines: string[] = []
|
|
75
|
+
if (expected.method !== received.method) {
|
|
76
|
+
lines.push("method:", ` expected ${expected.method}, received ${received.method}`)
|
|
77
|
+
}
|
|
78
|
+
if (expected.url !== received.url) {
|
|
79
|
+
lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`)
|
|
80
|
+
}
|
|
81
|
+
const headers = headerDiffs(expected.headers, received.headers)
|
|
82
|
+
if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8))
|
|
83
|
+
const expectedBody = jsonBody(expected.body)
|
|
84
|
+
const receivedBody = jsonBody(received.body)
|
|
85
|
+
const body =
|
|
86
|
+
expectedBody !== undefined && receivedBody !== undefined
|
|
87
|
+
? valueDiffs(expectedBody, receivedBody).map((line) => ` ${line}`)
|
|
88
|
+
: expected.body === received.body
|
|
89
|
+
? []
|
|
90
|
+
: [` expected ${safeText(expected.body)}, received ${safeText(received.body)}`]
|
|
91
|
+
if (body.length > 0) lines.push("body:", ...body)
|
|
92
|
+
return lines
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const selectSequential = (
|
|
96
|
+
interactions: ReadonlyArray<HttpInteraction>,
|
|
97
|
+
incoming: RequestSnapshot,
|
|
98
|
+
match: RequestMatcher,
|
|
99
|
+
index: number,
|
|
100
|
+
): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => {
|
|
101
|
+
const interaction = interactions[index]
|
|
102
|
+
if (!interaction) return { interaction, detail: `interaction ${index + 1} of ${interactions.length} not recorded` }
|
|
103
|
+
if (!match(incoming, interaction.request))
|
|
104
|
+
return { interaction: undefined, detail: requestDiff(interaction.request, incoming).join("\n") }
|
|
105
|
+
return { interaction, detail: "" }
|
|
106
|
+
}
|