@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/redaction.ts
ADDED
|
@@ -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
|
+
}
|
package/src/redactor.ts
ADDED
|
@@ -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
|
+
)
|