diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..cc5b8593 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,19 @@ +FROM mcr.microsoft.com/devcontainers/base:ubuntu + +USER root + +RUN apt-get update \ + && apt-get install -y \ + curl \ + unzip \ + git \ + python3 \ + python3-pip \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +USER vscode + +RUN curl -fsSL https://bun.sh/install | bash + +ENV PATH="/home/vscode/.bun/bin:${PATH}" \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..88b7925c --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,7 @@ +{ + "name": "1A Dev Container", + "build": { + "dockerfile": "Dockerfile" + }, + "remoteUser": "vscode" +} \ No newline at end of file diff --git a/packages/llm/http-recorder/src/cassette.ts.gcov.html b/packages/llm/http-recorder/src/cassette.ts.gcov.html new file mode 100644 index 00000000..0ca5807e --- /dev/null +++ b/packages/llm/http-recorder/src/cassette.ts.gcov.html @@ -0,0 +1,255 @@ + + + + +
+ +| LCOV - code coverage report | ||||||||||||||||||||||
+
|
+ ||||||||||||||||||||||
+Line data Source code+ + 1 41 : import { Context, Effect, FileSystem, Layer, Schema, Semaphore } from "effect" + 2 30 : import * as fs from "node:fs" + 3 34 : import * as path from "node:path" + 4 69 : import { secretFindings, SecretFindingSchema, type SecretFinding } from "./redaction.js" + 5 61 : import { CassetteSchema, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema.js" + 6 : + 7 93 : const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings") + 8 : + 9 96 : export class CassetteNotFoundError extends Schema.TaggedErrorClass<CassetteNotFoundError>()("CassetteNotFoundError", { + 10 28 : cassetteName: Schema.String, + 11 0 : }) { + 12 0 : override get message() { + 13 1 : return `Cassette "${this.cassetteName}" not found` + 14 : } + 15 1 : } + 16 : + 17 92 : export class UnsafeCassetteError extends Schema.TaggedErrorClass<UnsafeCassetteError>()("UnsafeCassetteError", { + 18 30 : cassetteName: Schema.String, + 19 44 : findings: Schema.Array(SecretFindingSchema), + 20 0 : }) { + 21 0 : override get message() { + 22 0 : return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings + 23 0 : .map((finding) => `${finding.path} (${finding.reason})`) + 24 1 : .join(", ")}` + 25 : } + 26 1 : } + 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 88 : export class Service extends Context.Service<Service, Interface>()("@opencode-ai/http-recorder/Cassette") {} + 40 : + 41 43 : const cassettePath = (directory: string, name: string) => { + 42 107 : if (!name || path.isAbsolute(name) || path.win32.isAbsolute(name) || name.split(/[\\/]/).includes("..")) + 43 2 : throw new Error(`Invalid cassette name "${name}"`) + 44 39 : const root = path.resolve(directory) + 45 52 : const target = path.resolve(root, `${name}.json`) + 46 47 : const relative = path.relative(root, target) + 47 75 : if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) + 48 2 : throw new Error(`Invalid cassette name "${name}"`) + 49 15 : return target + 50 : } + 51 : + 52 53 : export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) => + 53 79 : fs.existsSync(cassettePath(options.directory ?? DEFAULT_RECORDINGS_DIR, name)) + 54 : + 55 0 : const buildCassette = ( + 56 0 : name: string, + 57 0 : interactions: ReadonlyArray<Interaction>, + 58 0 : metadata: CassetteMetadata | undefined, + 59 0 : ): Cassette => ({ + 60 0 : version: 1, + 61 0 : metadata: { name, recordedAt: new Date().toISOString(), ...metadata }, + 62 : interactions, + 63 2 : }) + 64 : + 65 23 : const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n` + 66 : + 67 86 : const parseCassette = Schema.decodeUnknownSync(Schema.fromJsonString(CassetteSchema)) + 68 : + 69 0 : const failIfUnsafe = (name: string, findings: ReadonlyArray<SecretFinding>) => + 70 2 : findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings })) + 71 : + 72 25 : export const fileSystem = ( + 73 17 : options: { readonly directory?: string } = {}, + 74 : ): Layer.Layer<Service, never, FileSystem.FileSystem> => + 75 13 : Layer.effect( + 76 9 : Service, + 77 15 : Effect.gen(function* () { + 78 42 : const fs = yield* FileSystem.FileSystem + 79 64 : const directory = options.directory ?? DEFAULT_RECORDINGS_DIR + 80 27 : const recorded = new Map<string, { interactions: Interaction[]; findings: SecretFinding[] }>() + 81 46 : const appendLock = yield* Semaphore.make(1) + 82 : + 83 56 : const pathFor = (name: string) => cassettePath(directory, name) + 84 : + 85 0 : const walk = (current: string): Effect.Effect<ReadonlyArray<string>> => + 86 0 : Effect.gen(function* () { + 87 0 : const entries = yield* fs.readDirectory(current).pipe(Effect.catch(() => Effect.succeed([] as string[]))) + 88 0 : const nested = yield* Effect.forEach(entries, (entry) => { + 89 0 : const full = path.join(current, entry) + 90 0 : return fs.stat(full).pipe( + 91 0 : Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))), + 92 0 : Effect.catch(() => Effect.succeed([] as string[])), + 93 0 : ) + 94 0 : }) + 95 : return nested.flat() + 96 3 : }) + 97 : + 98 23 : return Service.of({ + 99 15 : read: (name) => + 100 38 : fs.readFileString(pathFor(name)).pipe( + 101 52 : Effect.map((raw) => parseCassette(raw).interactions), + 102 13 : Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))), + 103 5 : ), + 104 0 : append: (name, interaction, metadata) => + 105 0 : appendLock.withPermit( + 106 0 : Effect.gen(function* () { + 107 0 : const entry = recorded.get(name) ?? { interactions: [], findings: [] } + 108 0 : const interactions = [...entry.interactions, interaction] + 109 0 : const interactionFindings = [...entry.findings, ...secretFindings(interaction)] + 110 0 : const cassette = buildCassette(name, interactions, metadata) + 111 0 : const findings = [...interactionFindings, ...secretFindings(cassette.metadata ?? {})] + 112 0 : yield* failIfUnsafe(name, findings) + 113 0 : const target = pathFor(name) + 114 0 : yield* fs.makeDirectory(path.dirname(target), { recursive: true }).pipe(Effect.orDie) + 115 0 : const temporary = `${target}.${crypto.randomUUID()}.tmp` + 116 0 : yield* fs.writeFileString(temporary, formatCassette(cassette)).pipe( + 117 0 : Effect.flatMap(() => fs.rename(temporary, target)), + 118 0 : Effect.ensuring(fs.remove(temporary, { force: true }).pipe(Effect.catch(() => Effect.void))), + 119 0 : Effect.orDie, + 120 0 : ) + 121 0 : recorded.set(name, { interactions, findings: interactionFindings }) + 122 : }), + 123 5 : ), + 124 0 : exists: (name) => + 125 0 : fs.access(pathFor(name)).pipe( + 126 0 : Effect.as(true), + 127 : Effect.catch(() => Effect.succeed(false)), + 128 5 : ), + 129 0 : list: () => + 130 0 : walk(directory).pipe( + 131 0 : Effect.map((files) => + 132 0 : files + 133 0 : .filter((file) => file.endsWith(".json")) + 134 0 : .map((file) => + 135 0 : path + 136 0 : .relative(directory, file) + 137 0 : .replace(/\\/g, "/") + 138 0 : .replace(/\.json$/, ""), + 139 0 : ) + 140 0 : .toSorted((a, b) => a.localeCompare(b)), + 141 : ), + 142 2 : ), + 143 2 : }) + 144 1 : }), + 145 2 : ) + 146 : + 147 0 : export const memory = (initial: Record<string, ReadonlyArray<Interaction>> = {}): Layer.Layer<Service> => + 148 0 : Layer.sync(Service, () => { + 149 0 : const stored = new Map<string, Interaction[]>( + 150 0 : Object.entries(initial).map(([name, interactions]) => [name, [...interactions]]), + 151 0 : ) + 152 0 : const accumulatedFindings = new Map<string, SecretFinding[]>() + 153 0 : const appendLock = Semaphore.makeUnsafe(1) + 154 0 : + 155 0 : return Service.of({ + 156 0 : read: (name) => + 157 0 : stored.has(name) + 158 0 : ? Effect.succeed(stored.get(name) ?? []) + 159 0 : : Effect.fail(new CassetteNotFoundError({ cassetteName: name })), + 160 0 : append: (name, interaction, metadata) => + 161 0 : appendLock.withPermit( + 162 0 : Effect.suspend(() => { + 163 0 : const interactions = [...(stored.get(name) ?? []), interaction] + 164 0 : const findings = [...(accumulatedFindings.get(name) ?? []), ...secretFindings(interaction)] + 165 0 : const allFindings = metadata ? [...findings, ...secretFindings({ name, ...metadata })] : findings + 166 0 : return failIfUnsafe(name, allFindings).pipe( + 167 0 : Effect.tap(() => + 168 0 : Effect.sync(() => { + 169 0 : stored.set(name, interactions) + 170 0 : accumulatedFindings.set(name, findings) + 171 0 : }), + 172 0 : ), + 173 0 : ) + 174 0 : }), + 175 0 : ), + 176 0 : exists: (name) => Effect.sync(() => stored.has(name)), + 177 0 : list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()), + 178 : }) + 179 1 : }) ++ |
+
| Generated by: LCOV version 2.0-1 |
| LCOV - code coverage report | ||||||||||||||||||||||
+
|
+ ||||||||||||||||||||||
| + | + | + | + | ||
| File |
+ Line Coverage |
+ ||||
| Rate | +Total | +Hit | +|||
| cassette.ts | +
+ |
+ 38.5 % | +143 | +55 | +|
| internal-effect.ts | +
+ |
+ 54.2 % | +144 | +78 | +|
| internal.ts | +
+ |
+ 100.0 % | +8 | +8 | +|
| matching.ts | +
+ |
+ 51.2 % | +86 | +44 | +|
| recorder.ts | +
+ |
+ 76.2 % | +42 | +32 | +|
| redaction.ts | +
+ |
+ 77.1 % | +96 | +74 | +|
| redactor.ts | +
+ |
+ 80.2 % | +91 | +73 | +|
| schema.ts | +
+ |
+ 98.2 % | +55 | +54 | +|
| socket.ts | +
+ |
+ 8.1 % | +270 | +22 | +|
| websocket.ts | +
+ |
+ 19.5 % | +128 | +25 | +|
| Note: 'Function Coverage' columns elided as function owner is not identified. | +|||||
| Generated by: LCOV version 2.0-1 |
| LCOV - code coverage report | ||||||||||||||||||||||
+
|
+ ||||||||||||||||||||||
| + | + | + | + | ||
| File |
+ Line Coverage |
+ ||||
| Rate | +Total | +Hit | +|||
| socket.ts | +
+ |
+ 8.1 % | +270 | +22 | +|
| websocket.ts | +
+ |
+ 19.5 % | +128 | +25 | +|
| cassette.ts | +
+ |
+ 38.5 % | +143 | +55 | +|
| matching.ts | +
+ |
+ 51.2 % | +86 | +44 | +|
| internal-effect.ts | +
+ |
+ 54.2 % | +144 | +78 | +|
| recorder.ts | +
+ |
+ 76.2 % | +42 | +32 | +|
| redaction.ts | +
+ |
+ 77.1 % | +96 | +74 | +|
| redactor.ts | +
+ |
+ 80.2 % | +91 | +73 | +|
| schema.ts | +
+ |
+ 98.2 % | +55 | +54 | +|
| internal.ts | +
+ |
+ 100.0 % | +8 | +8 | +|
| Note: 'Function Coverage' columns elided as function owner is not identified. | +|||||
| Generated by: LCOV version 2.0-1 |
| LCOV - code coverage report | ||||||||||||||||||||||
+
|
+ ||||||||||||||||||||||
| + | + | + | + | ||
| File |
+ Line Coverage |
+ ||||
| Rate | +Total | +Hit | +|||
| cassette.ts | +
+ |
+ 38.5 % | +143 | +55 | +|
| internal-effect.ts | +
+ |
+ 54.2 % | +144 | +78 | +|
| internal.ts | +
+ |
+ 100.0 % | +8 | +8 | +|
| matching.ts | +
+ |
+ 51.2 % | +86 | +44 | +|
| recorder.ts | +
+ |
+ 76.2 % | +42 | +32 | +|
| redaction.ts | +
+ |
+ 77.1 % | +96 | +74 | +|
| redactor.ts | +
+ |
+ 80.2 % | +91 | +73 | +|
| schema.ts | +
+ |
+ 98.2 % | +55 | +54 | +|
| socket.ts | +
+ |
+ 8.1 % | +270 | +22 | +|
| websocket.ts | +
+ |
+ 19.5 % | +128 | +25 | +|
| Note: 'Function Coverage' columns elided as function owner is not identified. | +|||||
| Generated by: LCOV version 2.0-1 |
| LCOV - code coverage report | ||||||||||||||||||||||
+
|
+ ||||||||||||||||||||||
+Line data Source code+ + 1 55 : import { NodeFileSystem } from "@effect/platform-node" + 2 62 : import { Deferred, Effect, Layer, Option, Ref } from "effect" + 3 160 : import { + 4 : FetchHttpClient, + 5 : Headers, + 6 : HttpBody, + 7 : HttpClient, + 8 : HttpClientError, + 9 : HttpClientRequest, + 10 : HttpClientResponse, + 11 : UrlParams, + 12 : } from "effect/unstable/http" + 13 49 : import * as CassetteService from "./cassette.js" + 14 65 : import { defaultMatcher, selectSequential } from "./matching.js" + 15 65 : import { makeReplayState, resolveAutoMode } from "./recorder.js" + 16 37 : import { make, type Redactor } from "./redactor.js" + 17 43 : import { redactUrl } from "./redaction.js" + 18 47 : import { httpInteractions } from "./schema.js" + 19 : import type { CassetteMetadata, HttpInteraction, RequestMatcher, ResponseSnapshot } from "./types.js" + 20 : + 21 26 : 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 37 : const TEXT_CONTENT_TYPES = new Set([ + 34 24 : "application/graphql", + 35 27 : "application/javascript", + 36 21 : "application/json", + 37 20 : "application/sql", + 38 38 : "application/x-www-form-urlencoded", + 39 20 : "application/xml", + 40 21 : "application/yaml", + 41 16 : "image/svg+xml", + 42 3 : ]) + 43 : + 44 0 : const isTextContentType = (contentType: string | undefined) => { + 45 0 : const mediaType = contentType?.split(";", 1)[0]?.trim().toLowerCase() + 46 0 : if (!mediaType) return false + 47 0 : return ( + 48 0 : mediaType.startsWith("text/") || + 49 0 : mediaType.endsWith("+json") || + 50 0 : mediaType.endsWith("+xml") || + 51 2 : TEXT_CONTENT_TYPES.has(mediaType) + 52 : ) + 53 : } + 54 : + 55 0 : const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) => + 56 0 : response.arrayBuffer.pipe( + 57 0 : Effect.map((bytes) => + 58 0 : isTextContentType(contentType) + 59 0 : ? { body: new TextDecoder().decode(bytes) } + 60 0 : : { body: Buffer.from(bytes).toString("base64"), bodyEncoding: "base64" as const }, + 61 : ), + 62 2 : ) + 63 : + 64 39 : const decodeResponseBody = (snapshot: ResponseSnapshot) => + 65 89 : snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body + 66 : + 67 50 : const responseFromSnapshot = (request: HttpClientRequest.HttpClientRequest, snapshot: ResponseSnapshot) => + 68 27 : HttpClientResponse.fromWeb( + 69 9 : request, + 70 13 : new Response( + 71 108 : request.method === "HEAD" || snapshot.status === 204 || snapshot.status === 205 || snapshot.status === 304 + 72 2 : ? null + 73 29 : : decodeResponseBody(snapshot), + 74 8 : snapshot, + 75 1 : ), + 76 2 : ) + 77 : + 78 0 : export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) => + 79 0 : HttpClientRequest.makeWith( + 80 0 : request.method, + 81 0 : redactUrl(request.url), + 82 0 : UrlParams.empty, + 83 0 : Option.none(), + 84 0 : Headers.empty, + 85 : HttpBody.empty, + 86 2 : ) + 87 : + 88 0 : const transportError = (request: HttpClientRequest.HttpClientRequest, description: string) => + 89 0 : new HttpClientError.HttpClientError({ + 90 : reason: new HttpClientError.TransportError({ request: redactedErrorRequest(request), description }), + 91 2 : }) + 92 : + 93 29 : export const recordingLayer = ( + 94 6 : name: string, + 95 17 : options: Omit<RecordReplayOptions, "directory"> = {}, + 96 : ): Layer.Layer<HttpClient.HttpClient, never, HttpClient.HttpClient | CassetteService.Service> => + 97 13 : Layer.effect( + 98 23 : HttpClient.HttpClient, + 99 15 : Effect.gen(function* () { + 100 48 : const upstream = yield* HttpClient.HttpClient + 101 57 : const cassetteService = yield* CassetteService.Service + 102 46 : const redactor = options.redactor ?? make() + 103 48 : const match = options.match ?? defaultMatcher + 104 43 : const requested = options.mode ?? "auto" + 105 48 : const mode = requested === "auto" ? yield* resolveAutoMode(cassetteService, name) : requested + 106 : + 107 36 : const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) => + 108 17 : Effect.gen(function* () { + 109 75 : const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie) + 110 31 : return redactor.request({ + 111 25 : method: web.method, + 112 19 : url: web.url, + 113 57 : headers: Object.fromEntries(web.headers.entries()), + 114 47 : body: yield* Effect.promise(() => web.text()), + 115 3 : }) + 116 3 : }) + 117 : + 118 32 : if (mode === "passthrough") return upstream + 119 : + 120 22 : if (mode === "record") { + 121 0 : const initial = yield* Deferred.make<void>() + 122 0 : yield* Deferred.succeed(initial, undefined) + 123 0 : const tail = yield* Ref.make(initial) + 124 0 : return HttpClient.make((request) => + 125 0 : Effect.gen(function* () { + 126 0 : const completed = yield* Deferred.make<void>() + 127 0 : const previous = yield* Ref.modify(tail, (current) => [current, completed]) + 128 0 : return yield* Effect.gen(function* () { + 129 0 : const incoming = yield* snapshotRequest(request) + 130 0 : const response = yield* upstream.execute(request) + 131 0 : const captured = yield* captureResponseBody(response, response.headers["content-type"]) + 132 0 : const responseSnapshot: ResponseSnapshot = { + 133 0 : status: response.status, + 134 0 : headers: response.headers as Record<string, string>, + 135 0 : ...captured, + 136 0 : } + 137 0 : const interaction: HttpInteraction = { + 138 0 : transport: "http", + 139 0 : request: incoming, + 140 0 : response: redactor.response(responseSnapshot), + 141 0 : } + 142 0 : yield* Deferred.await(previous) + 143 0 : yield* cassetteService + 144 0 : .append(name, interaction, options.metadata) + 145 0 : .pipe( + 146 0 : Effect.catchTag("UnsafeCassetteError", (error) => + 147 0 : Effect.fail(transportError(request, error.message)), + 148 0 : ), + 149 0 : ) + 150 0 : return responseFromSnapshot(request, responseSnapshot) + 151 : }).pipe(Effect.ensuring(Deferred.succeed(completed, undefined))) + 152 : }), + 153 0 : ) + 154 2 : } + 155 : + 156 81 : const replay = yield* makeReplayState(cassetteService, name, httpInteractions) + 157 35 : return HttpClient.make((request) => + 158 17 : Effect.gen(function* () { + 159 53 : const incoming = yield* snapshotRequest(request) + 160 30 : const claimed = yield* replay + 161 50 : .claim((interaction, index, interactions) => { + 162 76 : const result = selectSequential(interactions, incoming, match, index) + 163 48 : if (result.interaction) return Effect.void + 164 0 : return Effect.fail( + 165 0 : transportError(request, `Fixture "${name}" does not match the current request: ${result.detail}.`), + 166 0 : ) + 167 2 : }) + 168 5 : .pipe( + 169 0 : Effect.mapError((error) => + 170 0 : error._tag === "CassetteNotFoundError" + 171 0 : ? transportError( + 172 0 : request, + 173 0 : `Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`, + 174 0 : ) + 175 : : error, + 176 1 : ), + 177 6 : ) + 178 67 : return responseFromSnapshot(request, claimed.interaction.response) + 179 : }), + 180 1 : ) + 181 1 : }), + 182 2 : ) + 183 : + 184 0 : export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer<HttpClient.HttpClient> => + 185 0 : recordingLayer(name, options).pipe( + 186 0 : Layer.provide(CassetteService.fileSystem({ directory: options.directory })), + 187 0 : Layer.provide(FetchHttpClient.layer), + 188 : Layer.provide(NodeFileSystem.layer), + 189 1 : ) ++ |
+
| Generated by: LCOV version 2.0-1 |
| LCOV - code coverage report | ||||||||||||||||||||||
+
|
+ ||||||||||||||||||||||
+Line data Source code+ + 1 92 : export { CassetteNotFoundError, hasCassetteSync, UnsafeCassetteError } from "./cassette.js" + 2 69 : export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./internal-effect.js" + 3 74 : export { redactHeaders, redactUrl, secretFindings, type SecretFinding } from "./redaction.js" + 4 42 : export { socketLayer } from "./socket.js" + 5 55 : export { + 6 : makeWebSocketExecutor, + 7 : type WebSocketConnection, + 8 : type WebSocketExecutor, + 9 : type WebSocketRecordReplayOptions, + 10 : type WebSocketRequest, + 11 : } from "./websocket.js" + 12 42 : export * as Cassette from "./cassette.js" + 13 42 : export * as Redactor from "./redactor.js" + 14 : + 15 53 : export * as HttpRecorderInternal from "./internal.js" ++ |
+
| Generated by: LCOV version 2.0-1 |
| LCOV - code coverage report | ||||||||||||||||||||||
+
|
+ ||||||||||||||||||||||
+Line data Source code+ + 1 40 : import { Option, Schema } from "effect" + 2 58 : import { REDACTED, secretFindings } from "./redaction.js" + 3 : import type { HttpInteraction, RequestMatcher, RequestSnapshot } from "./types.js" + 4 : + 5 56 : const JsonValue = Schema.fromJsonString(Schema.Unknown) + 6 64 : export const decodeJson = Schema.decodeUnknownOption(JsonValue) + 7 : + 8 26 : const isRecord = (value: unknown): value is Record<string, unknown> => + 9 69 : value !== null && typeof value === "object" && !Array.isArray(value) + 10 : + 11 44 : export const canonicalizeJson = (value: unknown): unknown => { + 12 64 : if (Array.isArray(value)) return value.map(canonicalizeJson) + 13 25 : if (isRecord(value)) { + 14 26 : return Object.fromEntries( + 15 19 : Object.keys(value) + 16 11 : .toSorted() + 17 47 : .map((key) => [key, canonicalizeJson(value[key])]), + 18 1 : ) + 19 2 : } + 20 14 : return value + 21 : } + 22 : + 23 : export type { RequestMatcher } from "./types.js" + 24 : + 25 45 : export const canonicalSnapshot = (snapshot: RequestSnapshot): string => + 26 18 : JSON.stringify({ + 27 26 : method: snapshot.method, + 28 20 : url: snapshot.url, + 29 46 : headers: canonicalizeJson(snapshot.headers), + 30 51 : body: Option.match(decodeJson(snapshot.body), { + 31 12 : onNone: () => snapshot.body, + 32 26 : onSome: canonicalizeJson, + 33 3 : }), + 34 2 : }) + 35 : + 36 52 : export const defaultMatcher: RequestMatcher = (incoming, recorded) => + 37 60 : canonicalSnapshot(incoming) === canonicalSnapshot(recorded) + 38 : + 39 0 : export const safeText = (value: unknown) => { + 40 0 : if (value === undefined) return "undefined" + 41 0 : if (secretFindings(value).length > 0) return JSON.stringify(REDACTED) + 42 0 : const text = JSON.stringify(value) + 43 0 : if (!text) return typeof value + 44 2 : return text.length > 300 ? `${text.slice(0, 300)}...` : text + 45 : } + 46 : + 47 17 : const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body)) + 48 : + 49 0 : const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray<string> => { + 50 0 : if (Object.is(expected, received)) return [] + 51 0 : if (isRecord(expected) && isRecord(received)) { + 52 0 : return [...new Set([...Object.keys(expected), ...Object.keys(received)])] + 53 0 : .toSorted() + 54 0 : .flatMap((key) => valueDiffs(expected[key], received[key], `${base}.${key}`, limit)) + 55 0 : .slice(0, limit) + 56 0 : } + 57 0 : if (Array.isArray(expected) && Array.isArray(received)) { + 58 0 : return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index) + 59 0 : .flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit)) + 60 0 : .slice(0, limit) + 61 0 : } + 62 2 : return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`] + 63 : } + 64 : + 65 0 : const headerDiffs = (expected: Record<string, string>, received: Record<string, string>) => + 66 0 : [...new Set([...Object.keys(expected), ...Object.keys(received)])].toSorted().flatMap((key) => { + 67 0 : if (expected[key] === received[key]) return [] + 68 0 : if (expected[key] === undefined) return [` ${key} unexpected ${safeText(received[key])}`] + 69 0 : if (received[key] === undefined) return [` ${key} missing expected ${safeText(expected[key])}`] + 70 : return [` ${key} expected ${safeText(expected[key])}, received ${safeText(received[key])}`] + 71 2 : }) + 72 : + 73 0 : export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot): ReadonlyArray<string> => { + 74 0 : const lines: string[] = [] + 75 0 : if (expected.method !== received.method) { + 76 0 : lines.push("method:", ` expected ${expected.method}, received ${received.method}`) + 77 0 : } + 78 0 : if (expected.url !== received.url) { + 79 0 : lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`) + 80 0 : } + 81 0 : const headers = headerDiffs(expected.headers, received.headers) + 82 0 : if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8)) + 83 0 : const expectedBody = jsonBody(expected.body) + 84 0 : const receivedBody = jsonBody(received.body) + 85 0 : const body = + 86 0 : expectedBody !== undefined && receivedBody !== undefined + 87 0 : ? valueDiffs(expectedBody, receivedBody).map((line) => ` ${line}`) + 88 0 : : expected.body === received.body + 89 0 : ? [] + 90 0 : : [` expected ${safeText(expected.body)}, received ${safeText(received.body)}`] + 91 0 : if (body.length > 0) lines.push("body:", ...body) + 92 2 : return lines + 93 : } + 94 : + 95 31 : export const selectSequential = ( + 96 14 : interactions: ReadonlyArray<HttpInteraction>, + 97 10 : incoming: RequestSnapshot, + 98 7 : match: RequestMatcher, + 99 10 : index: number, + 100 3 : ): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => { + 101 42 : const interaction = interactions[index] + 102 22 : if (!interaction) return { interaction, detail: `interaction ${index + 1} of ${interactions.length} not recorded` } + 103 45 : if (!match(incoming, interaction.request)) + 104 2 : return { interaction: undefined, detail: requestDiff(interaction.request, incoming).join("\n") } + 105 35 : return { interaction, detail: "" } + 106 : } ++ |
+
| Generated by: LCOV version 2.0-1 |
| LCOV - code coverage report | ||||||||||||||||||||||
+
|
+ ||||||||||||||||||||||
+Line data Source code+ + 1 49 : import { Effect, Scope, SynchronizedRef } from "effect" + 2 : import type * as CassetteService from "./cassette.js" + 3 : import type { CassetteNotFoundError } from "./cassette.js" + 4 : import type { Interaction } from "./schema.js" + 5 : + 6 0 : const isCI = () => { + 7 0 : const value = process.env.CI + 8 2 : return value !== undefined && value !== "" && value !== "false" && value !== "0" + 9 : } + 10 : + 11 0 : export const resolveAutoMode = ( + 12 0 : cassette: CassetteService.Interface, + 13 0 : name: string, + 14 0 : ): Effect.Effect<"record" | "replay" | "passthrough"> => + 15 0 : Effect.gen(function* () { + 16 0 : if (isCI()) return "replay" + 17 : return (yield* cassette.exists(name)) ? "replay" : "record" + 18 2 : }) + 19 : + 20 : export interface ReplayState<T> { + 21 : readonly claim: <E>( + 22 : validate: (interaction: T | undefined, index: number, interactions: ReadonlyArray<T>) => Effect.Effect<void, E>, + 23 : ) => Effect.Effect<{ readonly interaction: T; readonly index: number }, CassetteNotFoundError | E> + 24 : } + 25 : + 26 30 : export const makeReplayState = <T>( + 27 10 : cassette: CassetteService.Interface, + 28 6 : name: string, + 29 12 : project: (interactions: ReadonlyArray<Interaction>) => ReadonlyArray<T>, + 30 : ): Effect.Effect<ReplayState<T>, never, Scope.Scope> => + 31 15 : Effect.gen(function* () { + 32 83 : const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project))) + 33 50 : const position = yield* SynchronizedRef.make(0) + 34 : + 35 32 : yield* Effect.addFinalizer(() => + 36 17 : Effect.gen(function* () { + 37 54 : const used = yield* SynchronizedRef.get(position) + 38 49 : if (used === 0) return yield* Effect.void + 39 56 : const interactions = yield* load.pipe(Effect.orDie) + 40 36 : if (used < interactions.length) + 41 0 : return yield* Effect.die( + 42 0 : new Error(`Unused recorded interactions in ${name}: used ${used} of ${interactions.length}`), + 43 4 : ) + 44 26 : return yield* Effect.void + 45 : }), + 46 4 : ) + 47 : + 48 12 : return { + 49 20 : claim: (validate) => + 50 38 : Effect.flatMap(load, (interactions) => + 51 49 : SynchronizedRef.modifyEffect(position, (index) => + 52 19 : Effect.gen(function* () { + 53 46 : const interaction = interactions[index] + 54 56 : yield* validate(interaction, index, interactions) + 55 37 : if (interaction === undefined) + 56 6 : return yield* Effect.die("Replay validation accepted a missing interaction") + 57 43 : return [{ interaction, index }, index + 1] as const + 58 : }), + 59 : ), + 60 2 : ), + 61 1 : } + 62 1 : }) ++ |
+
| Generated by: LCOV version 2.0-1 |
| LCOV - code coverage report | ||||||||||||||||||||||
+
|
+ ||||||||||||||||||||||
+Line data Source code+ + 1 32 : import { Schema } from "effect" + 2 : + 3 37 : export const REDACTED = "[REDACTED]" + 4 : + 5 33 : const DEFAULT_REDACT_HEADERS = [ + 6 18 : "authorization", + 7 11 : "cookie", + 8 24 : "proxy-authorization", + 9 15 : "set-cookie", + 10 14 : "x-api-key", + 11 25 : "x-amz-security-token", + 12 17 : "x-goog-api-key", + 13 2 : ] + 14 : + 15 31 : const DEFAULT_REDACT_QUERY = [ + 16 17 : "access_token", + 17 12 : "api-key", + 18 12 : "api_key", + 19 11 : "apikey", + 20 9 : "code", + 21 8 : "key", + 22 14 : "signature", + 23 8 : "sig", + 24 10 : "token", + 25 21 : "x-amz-credential", + 26 25 : "x-amz-security-token", + 27 18 : "x-amz-signature", + 28 2 : ] + 29 : + 30 26 : const SECRET_PATTERNS: ReadonlyArray<{ readonly label: string; readonly pattern: RegExp }> = [ + 31 78 : { label: "bearer token", pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b/i }, + 32 72 : { label: "API key", pattern: /\bsk-[A-Za-z0-9][A-Za-z0-9_-]{20,}\b/ }, + 33 75 : { label: "Anthropic API key", pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ }, + 34 69 : { label: "Google API key", pattern: /\bAIza[0-9A-Za-z_-]{20,}\b/ }, + 35 72 : { label: "AWS access key", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ }, + 36 72 : { label: "GitHub token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/ }, + 37 72 : { label: "private key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ }, + 38 2 : ] + 39 : + 40 85 : const ENV_SECRET_NAMES = /(?:API|AUTH|BEARER|CREDENTIAL|KEY|PASSWORD|SECRET|TOKEN)/i + 41 65 : const SAFE_ENV_VALUES = new Set(["fixture", "test", "test-key"]) + 42 : + 43 0 : const envSecrets = () => + 44 0 : Object.entries(process.env).flatMap(([name, value]) => { + 45 0 : if (!value) return [] + 46 0 : if (!ENV_SECRET_NAMES.test(name)) return [] + 47 0 : if (value.length < 12) return [] + 48 0 : if (SAFE_ENV_VALUES.has(value.toLowerCase())) return [] + 49 : return [{ name, value }] + 50 2 : }) + 51 : + 52 16 : const pathFor = (base: string, key: string) => (base ? `${base}.${key}` : key) + 53 : + 54 0 : const stringEntries = (value: unknown, base = ""): ReadonlyArray<{ readonly path: string; readonly value: string }> => { + 55 0 : if (typeof value === "string") return [{ path: base, value }] + 56 0 : if (Array.isArray(value)) return value.flatMap((item, index) => stringEntries(item, `${base}[${index}]`)) + 57 0 : if (value && typeof value === "object") { + 58 0 : return Object.entries(value).flatMap(([key, child]) => stringEntries(child, pathFor(base, key))) + 59 0 : } + 60 2 : return [] + 61 : } + 62 : + 63 41 : const redactionSet = (values: ReadonlyArray<string> | undefined, defaults: ReadonlyArray<string>) => + 64 74 : new Set([...defaults, ...(values ?? [])].map((value) => value.toLowerCase())) + 65 : + 66 : export type UrlRedactor = (url: string) => string + 67 : + 68 24 : export const redactUrl = ( + 69 5 : raw: string, + 70 30 : query: ReadonlyArray<string> = DEFAULT_REDACT_QUERY, + 71 16 : urlRedactor?: UrlRedactor, + 72 3 : ) => { + 73 28 : if (!URL.canParse(raw)) return urlRedactor?.(raw) ?? raw + 74 27 : const url = new URL(raw) + 75 22 : if (url.username) url.username = REDACTED + 76 22 : if (url.password) url.password = REDACTED + 77 61 : const redacted = redactionSet(query, DEFAULT_REDACT_QUERY) + 78 47 : for (const key of url.searchParams.keys()) { + 79 43 : if (redacted.has(key.toLowerCase())) url.searchParams.set(key, REDACTED) + 80 2 : } + 81 56 : return urlRedactor?.(url.toString()) ?? url.toString() + 82 : } + 83 : + 84 28 : export const redactHeaders = ( + 85 9 : headers: Record<string, string>, + 86 7 : allow: ReadonlyArray<string>, + 87 36 : redact: ReadonlyArray<string> = DEFAULT_REDACT_HEADERS, + 88 3 : ) => { + 89 65 : const allowed = new Set(allow.map((name) => name.toLowerCase())) + 90 64 : const redacted = redactionSet(redact, DEFAULT_REDACT_HEADERS) + 91 26 : return Object.fromEntries( + 92 24 : Object.entries(headers) + 93 50 : .map(([name, value]) => [name.toLowerCase(), value] as const) + 94 36 : .filter(([name]) => allowed.has(name)) + 95 58 : .map(([name, value]) => [name, redacted.has(name) ? REDACTED : value] as const) + 96 40 : .toSorted(([a], [b]) => a.localeCompare(b)), + 97 3 : ) + 98 : } + 99 : + 100 51 : export const SecretFindingSchema = Schema.Struct({ + 101 22 : path: Schema.String, + 102 22 : reason: Schema.String, + 103 3 : }) + 104 : export type SecretFinding = Schema.Schema.Type<typeof SecretFindingSchema> + 105 : + 106 0 : export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> => { + 107 0 : const environment = envSecrets() + 108 0 : return stringEntries(value).flatMap((entry) => [ + 109 0 : ...SECRET_PATTERNS.filter((item) => item.pattern.test(entry.value)).map((item) => ({ + 110 0 : path: entry.path, + 111 0 : reason: item.label, + 112 0 : })), + 113 0 : ...environment + 114 0 : .filter((item) => entry.value.includes(item.value)) + 115 0 : .map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })), + 116 1 : ]) + 117 : } ++ |
+
| Generated by: LCOV version 2.0-1 |
| LCOV - code coverage report | ||||||||||||||||||||||
+
|
+ ||||||||||||||||||||||
+Line data Source code+ + 1 32 : import { Option } from "effect" + 2 43 : import { decodeJson } from "./matching.js" + 3 68 : 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 81 : export const DEFAULT_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"] + 9 57 : export const DEFAULT_RESPONSE_HEADERS: ReadonlyArray<string> = ["content-type"] + 10 : + 11 17 : 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 42 : export const compose = (...redactors: ReadonlyArray<Partial<Redactor>>): Redactor => { + 19 80 : const requests = redactors.map((r) => r.request).filter((fn): fn is Redactor["request"] => fn !== undefined) + 20 82 : const responses = redactors.map((r) => r.response).filter((fn): fn is Redactor["response"] => fn !== undefined) + 21 12 : return { + 22 95 : request: requests.length === 0 ? identity : (snapshot) => requests.reduce((acc, fn) => fn(acc), snapshot), + 23 36 : response: responses.length === 0 ? identity : (snapshot) => responses.reduce((acc, fn) => fn(acc), snapshot), + 24 3 : } + 25 : } + 26 : + 27 : export interface HeaderOptions { + 28 : readonly allow?: ReadonlyArray<string> + 29 : readonly redact?: ReadonlyArray<string> + 30 : } + 31 : + 32 50 : export const requestHeaders = (options: HeaderOptions = {}): Partial<Redactor> => ({ + 33 31 : request: (snapshot) => ({ + 34 13 : ...snapshot, + 35 100 : headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_REQUEST_HEADERS, options.redact), + 36 2 : }), + 37 2 : }) + 38 : + 39 51 : export const responseHeaders = (options: HeaderOptions = {}): Partial<Redactor> => ({ + 40 0 : response: (snapshot) => ({ + 41 0 : ...snapshot, + 42 0 : headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_RESPONSE_HEADERS, options.redact), + 43 1 : }), + 44 2 : }) + 45 : + 46 : export interface UrlOptions { + 47 : readonly query?: ReadonlyArray<string> + 48 : readonly transform?: (url: string) => string + 49 : } + 50 : + 51 39 : export const url = (options: UrlOptions = {}): Partial<Redactor> => ({ + 52 103 : request: (snapshot) => ({ ...snapshot, url: redactUrl(snapshot.url, options.query, options.transform) }), + 53 2 : }) + 54 : + 55 0 : export const body = (transform: (parsed: unknown) => unknown): Partial<Redactor> => ({ + 56 0 : request: (snapshot) => ({ + 57 0 : ...snapshot, + 58 0 : body: Option.match(decodeJson(snapshot.body), { + 59 0 : onNone: () => snapshot.body, + 60 0 : onSome: (parsed) => JSON.stringify(transform(parsed)), + 61 0 : }), + 62 : }), + 63 2 : }) + 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 37 : const DEFAULT_REDACT_JSON_FIELDS = [ + 73 17 : "access_token", + 74 12 : "api_key", + 75 11 : "apikey", + 76 18 : "client_secret", + 77 13 : "password", + 78 18 : "refresh_token", + 79 11 : "secret", + 80 8 : "token", + 81 2 : ] + 82 : + 83 79 : const normalizeField = (field: string) => field.replace(/[^a-z0-9]/gi, "").toLowerCase() + 84 : + 85 45 : const redactJsonFields = (value: unknown, fields: ReadonlySet<string>): unknown => { + 86 86 : if (Array.isArray(value)) return value.map((item) => redactJsonFields(item, fields)) + 87 57 : if (!value || typeof value !== "object") return value + 88 26 : return Object.fromEntries( + 89 48 : Object.entries(value).map(([key, child]) => [ + 90 8 : key, + 91 67 : fields.has(normalizeField(key)) ? REDACTED : redactJsonFields(child, fields), + 92 1 : ]), + 93 3 : ) + 94 : } + 95 : + 96 50 : const redactBody = (value: string, fields: ReadonlySet<string>, transform: ((body: string) => string) | undefined) => { + 97 54 : const redacted = Option.match(decodeJson(value), { + 98 12 : onNone: () => value, + 99 68 : onSome: (parsed) => JSON.stringify(redactJsonFields(parsed, fields)), + 100 5 : }) + 101 42 : return transform?.(redacted) ?? redacted + 102 : } + 103 : + 104 39 : export const make = (options: RedactOptions = {}): Redactor => { + 105 107 : const fields = new Set([...DEFAULT_REDACT_JSON_FIELDS, ...(options.jsonFields ?? [])].map(normalizeField)) + 106 15 : return compose( + 107 20 : requestHeaders({ + 108 104 : allow: [...DEFAULT_REQUEST_HEADERS, ...(options.allowRequestHeaders ?? []), ...(options.headers ?? [])], + 109 25 : redact: options.headers, + 110 4 : }), + 111 21 : responseHeaders({ + 112 106 : allow: [...DEFAULT_RESPONSE_HEADERS, ...(options.allowResponseHeaders ?? []), ...(options.headers ?? [])], + 113 25 : redact: options.headers, + 114 4 : }), + 115 65 : url({ query: options.queryParameters, transform: options.url }), + 116 5 : { + 117 33 : request: (snapshot) => ({ + 118 15 : ...snapshot, + 119 57 : body: redactBody(snapshot.body, fields, options.body), + 120 6 : }), + 121 0 : response: (snapshot) => ({ + 122 0 : ...snapshot, + 123 0 : body: redactBody(snapshot.body, fields, options.body), + 124 2 : }), + 125 1 : }, + 126 3 : ) + 127 : } + 128 : + 129 0 : export const defaults = (overrides: DefaultRedactorOverrides = {}): Redactor => + 130 0 : compose( + 131 0 : requestHeaders(overrides.requestHeaders), + 132 0 : responseHeaders(overrides.responseHeaders), + 133 0 : url(overrides.url), + 134 : ...(overrides.body ? [body(overrides.body)] : []), + 135 1 : ) ++ |
+
| Generated by: LCOV version 2.0-1 |
| LCOV - code coverage report | ||||||||||||||||||||||
+
|
+ ||||||||||||||||||||||
+Line data Source code+ + 1 32 : 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 53 : export const RequestSnapshotSchema = Schema.Struct({ + 21 24 : method: Schema.String, + 22 21 : url: Schema.String, + 23 55 : headers: Schema.Record(Schema.String, Schema.String), + 24 20 : body: Schema.String, + 25 3 : }) + 26 : + 27 54 : export const ResponseSnapshotSchema = Schema.Struct({ + 28 24 : status: Schema.Number, + 29 55 : headers: Schema.Record(Schema.String, Schema.String), + 30 22 : body: Schema.String, + 31 67 : bodyEncoding: Schema.optional(Schema.Literals(["text", "base64"])), + 32 3 : }) + 33 : + 34 83 : export const CassetteMetadataSchema = Schema.Record(Schema.String, Schema.Unknown) + 35 : + 36 53 : export const HttpInteractionSchema = Schema.Struct({ + 37 32 : transport: Schema.tag("http"), + 38 33 : request: RequestSnapshotSchema, + 39 33 : response: ResponseSnapshotSchema, + 40 3 : }) + 41 : + 42 51 : export const WebSocketEventSchema = Schema.Union([ + 43 19 : Schema.Struct({ + 44 53 : direction: Schema.Literals(["client", "server"]), + 45 29 : kind: Schema.tag("text"), + 46 21 : body: Schema.String, + 47 5 : }), + 48 19 : Schema.Struct({ + 49 53 : direction: Schema.Literals(["client", "server"]), + 50 31 : kind: Schema.tag("binary"), + 51 24 : body: Schema.String, + 52 40 : bodyEncoding: Schema.Literal("base64"), + 53 3 : }), + 54 3 : ]) + 55 : + 56 58 : export const WebSocketInteractionSchema = Schema.Struct({ + 57 37 : transport: Schema.tag("websocket"), + 58 25 : open: Schema.Struct({ + 59 23 : url: Schema.String, + 60 54 : headers: Schema.Record(Schema.String, Schema.String), + 61 5 : }), + 62 43 : events: Schema.Array(WebSocketEventSchema), + 63 3 : }) + 64 : + 65 103 : export const InteractionSchema = Schema.Union([HttpInteractionSchema, WebSocketInteractionSchema]).pipe( + 66 33 : Schema.toTaggedUnion("transport"), + 67 3 : ) + 68 : export type Interaction = Schema.Schema.Type<typeof InteractionSchema> + 69 : + 70 63 : export const isHttpInteraction = InteractionSchema.guards.http + 71 : + 72 73 : export const isWebSocketInteraction = InteractionSchema.guards.websocket + 73 : + 74 87 : export const httpInteractions = (interactions: ReadonlyArray<Interaction>) => interactions.filter(isHttpInteraction) + 75 : + 76 0 : export const webSocketInteractions = (interactions: ReadonlyArray<Interaction>) => + 77 2 : interactions.filter(isWebSocketInteraction) + 78 : + 79 46 : export const CassetteSchema = Schema.Struct({ + 80 29 : version: Schema.Literal(1), + 81 52 : metadata: Schema.optional(CassetteMetadataSchema), + 82 46 : interactions: Schema.Array(InteractionSchema), + 83 3 : }) + 84 : export type Cassette = Schema.Schema.Type<typeof CassetteSchema> + 85 : + 86 71 : export const decodeCassette = Schema.decodeUnknownSync(CassetteSchema) + 87 63 : export const encodeCassette = Schema.encodeSync(CassetteSchema) ++ |
+
| Generated by: LCOV version 2.0-1 |
| LCOV - code coverage report | ||||||||||||||||||||||
+
|
+ ||||||||||||||||||||||
+Line data Source code+ + 1 55 : import { NodeFileSystem } from "@effect/platform-node" + 2 81 : import { Deferred, Effect, Exit, FiberSet, Layer, Ref, Scope, Semaphore } from "effect" + 3 48 : import { Socket } from "effect/unstable/socket" + 4 49 : import * as CassetteService from "./cassette.js" + 5 71 : import { canonicalizeJson, decodeJson, safeText } from "./matching.js" + 6 65 : import { makeReplayState, resolveAutoMode } from "./recorder.js" + 7 37 : import { make, type Redactor } from "./redactor.js" + 8 52 : 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 0 : const encodeEvent = (direction: "client" | "server", message: Frame): WebSocketEvent => + 35 0 : typeof message === "string" + 36 0 : ? { direction, kind: "text", body: message } + 37 2 : : { direction, kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" } + 38 : + 39 0 : const decodeEvent = (event: WebSocketEvent): Frame => + 40 2 : event.kind === "text" ? event.body : new Uint8Array(Buffer.from(event.body, "base64")) + 41 : + 42 0 : const redactEvent = (event: WebSocketEvent, redactor: Redactor): WebSocketEvent => { + 43 0 : if (event.kind === "binary") return event + 44 0 : const body = + 45 0 : event.direction === "client" + 46 0 : ? redactor.request({ method: "WEBSOCKET", url: "", headers: {}, body: event.body }).body + 47 0 : : redactor.response({ status: 101, headers: {}, body: event.body }).body + 48 2 : return { ...event, body } + 49 : } + 50 : + 51 0 : const comparable = (event: WebSocketEvent, asJson: boolean) => { + 52 0 : if (!asJson || event.kind === "binary") return JSON.stringify(canonicalizeJson(event)) + 53 0 : const decoded = decodeJson(event.body) + 54 0 : return JSON.stringify( + 55 0 : canonicalizeJson({ + 56 0 : ...event, + 57 0 : body: decoded._tag === "None" ? event.body : canonicalizeJson(decoded.value), + 58 0 : }), + 59 2 : ) + 60 : } + 61 : + 62 0 : const assertEvent = (actual: WebSocketEvent, expected: WebSocketEvent | undefined, index: number, asJson: boolean) => + 63 0 : Effect.sync(() => { + 64 0 : if (expected && comparable(actual, asJson) === comparable(expected, asJson)) return + 65 : throw new Error(`WebSocket event ${index + 1}: expected ${safeText(expected)}, received ${safeText(actual)}`) + 66 2 : }) + 67 : + 68 0 : const runHandler = <A, E, R>(handler: (value: A) => Effect.Effect<unknown, E, R> | void, value: A) => + 69 0 : Effect.suspend(() => { + 70 0 : const result = handler(value) + 71 : return Effect.isEffect(result) ? Effect.asVoid(result) : Effect.void + 72 2 : }) + 73 : + 74 0 : const runReplay = <A, E, R>( + 75 0 : state: ActiveReplay, + 76 0 : handler: (value: A) => Effect.Effect<unknown, E, R> | void, + 77 0 : decode: (event: WebSocketEvent) => A, + 78 0 : onOpen: Effect.Effect<void> | undefined, + 79 0 : ) => + 80 0 : Effect.scoped( + 81 0 : Effect.gen(function* () { + 82 0 : const handlers = yield* FiberSet.make<unknown, E>() + 83 0 : const run = yield* FiberSet.runtime(handlers)<R>() + 84 0 : if (onOpen) yield* onOpen + 85 0 : + 86 0 : const drive = Effect.gen(function* () { + 87 0 : while (true) { + 88 0 : const current = yield* Ref.get(state.progress) + 89 0 : const event = state.interaction.events[current.position] + 90 0 : if (!event) return + 91 0 : if (yield* Ref.get(state.closed)) + 92 0 : return yield* Effect.die( + 93 0 : new Error( + 94 0 : `WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`, + 95 0 : ), + 96 0 : ) + 97 0 : if (event.direction === "server") { + 98 0 : yield* Ref.set(state.progress, { + 99 0 : position: current.position + 1, + 100 0 : changed: yield* Deferred.make<void>(), + 101 0 : }) + 102 0 : run(runHandler(handler, decode(event))) + 103 0 : continue + 104 0 : } + 105 0 : yield* Deferred.await(current.changed) + 106 0 : } + 107 0 : }) + 108 0 : + 109 0 : yield* drive.pipe(Effect.raceFirst(FiberSet.join(handlers))) + 110 0 : yield* FiberSet.awaitEmpty(handlers).pipe(Effect.raceFirst(FiberSet.join(handlers))) + 111 : }), + 112 2 : ) + 113 : + 114 0 : const openSnapshot = (request: WebSocketRequest, redactor: Redactor) => { + 115 0 : const snapshot = redactor.request({ method: "GET", url: request.url, headers: request.headers ?? {}, body: "" }) + 116 2 : return { url: snapshot.url, headers: snapshot.headers } + 117 : } + 118 : + 119 0 : const makeRecordingSocket = ( + 120 0 : upstream: Socket.Socket, + 121 0 : cassette: CassetteService.Interface, + 122 0 : name: string, + 123 0 : request: WebSocketRequest, + 124 0 : options: WebSocketRecorderOptions, + 125 0 : redactor: Redactor, + 126 0 : ) => + 127 0 : Effect.gen(function* () { + 128 0 : const active = yield* Ref.make<ActiveRecording | undefined>(undefined) + 129 0 : const writeLock = yield* Semaphore.make(1) + 130 0 : + 131 0 : return Socket.make({ + 132 0 : runRaw: (handler, runOptions) => + 133 0 : Effect.gen(function* () { + 134 0 : const state: ActiveRecording = { + 135 0 : events: [], + 136 0 : eventLock: yield* Semaphore.make(1), + 137 0 : accepting: yield* Ref.make(true), + 138 0 : opened: false, + 139 0 : valid: true, + 140 0 : } + 141 0 : const occupied = yield* Ref.modify(active, (current) => [current !== undefined, current ?? state]) + 142 0 : if (occupied) return yield* Effect.die("Concurrent runs of a recorded WebSocket are not supported") + 143 0 : yield* upstream + 144 0 : .runRaw( + 145 0 : (message) => { + 146 0 : if (!Ref.getUnsafe(state.accepting)) throw new Error("WebSocket received a frame after closing") + 147 0 : state.events.push(redactEvent(encodeEvent("server", message), redactor)) + 148 0 : return handler(message) + 149 0 : }, + 150 0 : { + 151 0 : ...runOptions, + 152 0 : onOpen: Effect.gen(function* () { + 153 0 : state.opened = true + 154 0 : if (runOptions?.onOpen) yield* runOptions.onOpen + 155 0 : }), + 156 0 : }, + 157 0 : ) + 158 0 : .pipe( + 159 0 : Effect.onExit((exit) => + 160 0 : writeLock.withPermit( + 161 0 : state.eventLock.withPermit( + 162 0 : Effect.gen(function* () { + 163 0 : yield* Ref.set(state.accepting, false) + 164 0 : yield* Ref.set(active, undefined) + 165 0 : if (!Exit.isSuccess(exit) || !state.opened || !state.valid) return + 166 0 : yield* cassette + 167 0 : .append( + 168 0 : name, + 169 0 : { + 170 0 : transport: "websocket", + 171 0 : open: openSnapshot(request, redactor), + 172 0 : events: [...state.events], + 173 0 : }, + 174 0 : options.metadata, + 175 0 : ) + 176 0 : .pipe(Effect.orDie) + 177 0 : }), + 178 0 : ), + 179 0 : ), + 180 0 : ), + 181 0 : ) + 182 0 : }), + 183 0 : writer: upstream.writer.pipe( + 184 0 : Effect.map( + 185 0 : (write) => (message) => + 186 0 : writeLock.withPermit( + 187 0 : Effect.gen(function* () { + 188 0 : if (Socket.isCloseEvent(message)) return yield* write(message) + 189 0 : const state = yield* Ref.get(active) + 190 0 : if (!state || !(yield* Ref.get(state.accepting))) + 191 0 : return yield* Effect.die("WebSocket writer used without an active socket run") + 192 0 : const event = redactEvent(encodeEvent("client", message), redactor) + 193 0 : yield* state.eventLock.withPermit(Effect.sync(() => state.events.push(event))) + 194 0 : return yield* write(message).pipe(Effect.onError(() => Effect.sync(() => (state.valid = false)))) + 195 0 : }), + 196 0 : ), + 197 0 : ), + 198 0 : ), + 199 : }) + 200 2 : }) + 201 : + 202 0 : const makeReplaySocket = ( + 203 0 : cassette: CassetteService.Interface, + 204 0 : name: string, + 205 0 : request: WebSocketRequest, + 206 0 : options: WebSocketRecorderOptions, + 207 0 : redactor: Redactor, + 208 0 : ): Effect.Effect<Socket.Socket, never, Scope.Scope> => + 209 0 : Effect.gen(function* () { + 210 0 : const replay = yield* makeReplayState(cassette, name, webSocketInteractions) + 211 0 : const active = yield* Ref.make<ActiveReplay | undefined>(undefined) + 212 0 : + 213 0 : return Socket.make({ + 214 0 : runRaw: (handler, runOptions) => + 215 0 : Effect.gen(function* () { + 216 0 : const claimed = yield* replay + 217 0 : .claim((interaction, index) => + 218 0 : Effect.sync(() => { + 219 0 : const incoming = openSnapshot(request, redactor) + 220 0 : if ( + 221 0 : interaction && + 222 0 : JSON.stringify(canonicalizeJson(incoming)) === JSON.stringify(canonicalizeJson(interaction.open)) + 223 0 : ) + 224 0 : return + 225 0 : throw new Error( + 226 0 : `WebSocket open ${index + 1}: expected ${safeText(interaction?.open)}, received ${safeText(incoming)}`, + 227 0 : ) + 228 0 : }), + 229 0 : ) + 230 0 : .pipe(Effect.orDie) + 231 0 : const progress = yield* Ref.make({ position: 0, changed: yield* Deferred.make<void>() }) + 232 0 : const writeLock = yield* Semaphore.make(1) + 233 0 : const state = { + 234 0 : interaction: claimed.interaction, + 235 0 : progress, + 236 0 : writeLock, + 237 0 : closed: yield* Ref.make(false), + 238 0 : } + 239 0 : const occupied = yield* Ref.modify(active, (current) => [current !== undefined, current ?? state]) + 240 0 : if (occupied) return yield* Effect.die("Concurrent runs of a replayed WebSocket are not supported") + 241 0 : yield* runReplay(state, handler, decodeEvent, runOptions?.onOpen).pipe( + 242 0 : Effect.ensuring(Ref.set(active, undefined)), + 243 0 : ) + 244 0 : }), + 245 0 : writer: Effect.succeed((message) => { + 246 0 : return Ref.get(active).pipe( + 247 0 : Effect.flatMap((state) => + 248 0 : state + 249 0 : ? state.writeLock.withPermit( + 250 0 : Effect.gen(function* () { + 251 0 : const current = yield* Ref.get(state.progress) + 252 0 : if (Socket.isCloseEvent(message)) { + 253 0 : yield* Ref.set(state.closed, true) + 254 0 : yield* Deferred.succeed(current.changed, undefined) + 255 0 : if (current.position === state.interaction.events.length) return + 256 0 : return yield* Effect.die( + 257 0 : new Error( + 258 0 : `WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`, + 259 0 : ), + 260 0 : ) + 261 0 : } + 262 0 : const actual = redactEvent(encodeEvent("client", message), redactor) + 263 0 : yield* assertEvent( + 264 0 : actual, + 265 0 : state.interaction.events[current.position], + 266 0 : current.position, + 267 0 : options.compareClientMessagesAsJson === true, + 268 0 : ) + 269 0 : yield* Ref.set(state.progress, { + 270 0 : position: current.position + 1, + 271 0 : changed: yield* Deferred.make<void>(), + 272 0 : }) + 273 0 : yield* Deferred.succeed(current.changed, undefined) + 274 0 : }), + 275 0 : ) + 276 0 : : Effect.die("WebSocket writer used without an active socket run"), + 277 0 : ), + 278 0 : ) + 279 0 : }), + 280 : }) + 281 2 : }) + 282 : + 283 0 : const recordingLayer = ( + 284 0 : name: string, + 285 0 : request: WebSocketRequest, + 286 0 : options: WebSocketRecorderOptions, + 287 0 : forcedMode?: "record" | "replay", + 288 0 : ): Layer.Layer<Socket.Socket, never, Socket.Socket | CassetteService.Service> => + 289 0 : Layer.effect( + 290 0 : Socket.Socket, + 291 0 : Effect.gen(function* () { + 292 0 : const upstream = yield* Socket.Socket + 293 0 : const cassette = yield* CassetteService.Service + 294 0 : const redactor = make(options.redact) + 295 0 : if ((forcedMode ?? (yield* resolveAutoMode(cassette, name))) === "record") + 296 0 : return yield* makeRecordingSocket(upstream, cassette, name, request, options, redactor) + 297 0 : return yield* makeReplaySocket(cassette, name, request, options, redactor) + 298 : }), + 299 2 : ) + 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 0 : export const socket = (name: string, options: RecorderOptions = {}): Layer.Layer<Socket.Socket, never, Socket.Socket> => + 309 2 : provideCassette(recordingLayer(name, { url: "" }, { ...options, compareClientMessagesAsJson: true }), options) + 310 : + 311 : /** @internal */ + 312 0 : export const socketLayer = ( + 313 0 : name: string, + 314 0 : request: WebSocketRequest, + 315 0 : options: WebSocketRecorderOptions & { readonly mode: "record" | "replay" }, + 316 0 : ): Layer.Layer<Socket.Socket, never, Socket.Socket> => + 317 2 : provideCassette(recordingLayer(name, request, options, options.mode), options) + 318 : + 319 0 : const provideCassette = ( + 320 0 : layer: Layer.Layer<Socket.Socket, never, Socket.Socket | CassetteService.Service>, + 321 0 : options: WebSocketRecorderOptions, + 322 0 : ) => + 323 0 : layer.pipe( + 324 0 : Layer.provide(CassetteService.fileSystem({ directory: options.directory })), + 325 : Layer.provide(NodeFileSystem.layer), + 326 1 : ) ++ |
+
| Generated by: LCOV version 2.0-1 |
| LCOV - code coverage report | ||||||||||||||||||||||
+
|
+ ||||||||||||||||||||||
+Line data Source code+ + 1 81 : 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 71 : import { canonicalizeJson, decodeJson, safeText } from "./matching.js" + 5 65 : import { makeReplayState, resolveAutoMode } from "./recorder.js" + 6 : import type { RecordReplayMode } from "./internal-effect.js" + 7 37 : import { make, type Redactor } from "./redactor.js" + 8 52 : 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 0 : const headersRecord = (headers: Headers.Headers): Record<string, string> => + 36 0 : Object.fromEntries( + 37 0 : Object.entries(headers as Record<string, unknown>).filter( + 38 0 : (entry): entry is [string, string] => typeof entry[1] === "string", + 39 : ), + 40 2 : ) + 41 : + 42 0 : const textEvent = (direction: "client" | "server", body: string): WebSocketEvent => ({ + 43 0 : direction, + 44 0 : kind: "text", + 45 : body, + 46 2 : }) + 47 : + 48 0 : const decodeEvent = (event: WebSocketEvent) => + 49 2 : event.kind === "text" ? event.body : new Uint8Array(Buffer.from(event.body, "base64")) + 50 : + 51 19 : const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson }) + 52 : + 53 0 : const assertClientEvent = (actual: string, expected: WebSocketEvent | undefined, index: number, asJson: boolean) => + 54 0 : Effect.sync(() => { + 55 0 : const matches = + 56 0 : expected?.direction === "client" && + 57 0 : expected.kind === "text" && + 58 0 : JSON.stringify(asJson ? jsonOrText(actual) : actual) === + 59 0 : JSON.stringify(asJson ? jsonOrText(expected.body) : expected.body) + 60 0 : if (matches) return + 61 : throw new Error(`WebSocket client frame ${index + 1}: expected ${safeText(expected)}, received ${safeText(actual)}`) + 62 2 : }) + 63 : + 64 36 : export const makeWebSocketExecutor = <E>( + 65 12 : options: WebSocketRecordReplayOptions<E>, + 66 : ): Effect.Effect<WebSocketExecutor<E>, never, Scope.Scope> => + 67 15 : Effect.gen(function* () { + 68 88 : const mode = options.mode ?? (yield* resolveAutoMode(options.cassette, options.name)) + 69 46 : const redactor = options.redactor ?? make() + 70 0 : const openSnapshot = (request: WebSocketRequest) => { + 71 0 : const snapshot = redactor.request({ + 72 0 : method: "GET", + 73 0 : url: request.url, + 74 0 : headers: headersRecord(request.headers), + 75 0 : body: "", + 76 0 : }) + 77 3 : return { url: snapshot.url, headers: snapshot.headers } + 78 : } + 79 0 : const redactEvent = (event: WebSocketEvent) => { + 80 0 : if (event.kind === "binary") return event + 81 0 : const body = + 82 0 : event.direction === "client" + 83 0 : ? redactor.request({ method: "WEBSOCKET", url: "", headers: {}, body: event.body }).body + 84 0 : : redactor.response({ status: 101, headers: {}, body: event.body }).body + 85 3 : return { ...event, body } + 86 : } + 87 : + 88 32 : if (mode === "passthrough") return options.live + 89 : + 90 22 : if (mode === "record") { + 91 0 : return { + 92 0 : open: (request) => + 93 0 : Effect.gen(function* () { + 94 0 : const events: WebSocketEvent[] = [] + 95 0 : const connection = yield* options.live.open(request) + 96 0 : const closed = yield* Ref.make(false) + 97 0 : const closeLock = yield* Semaphore.make(1) + 98 0 : return { + 99 0 : sendText: (message) => + 100 0 : Effect.sync(() => events.push(redactEvent(textEvent("client", message)))).pipe( + 101 0 : Effect.andThen(connection.sendText(message)), + 102 0 : ), + 103 0 : messages: connection.messages.pipe( + 104 0 : Stream.tap((message) => + 105 0 : Effect.sync(() => + 106 0 : events.push( + 107 0 : typeof message === "string" + 108 0 : ? redactEvent(textEvent("server", message)) + 109 0 : : { + 110 0 : direction: "server", + 111 0 : kind: "binary", + 112 0 : body: Buffer.from(message).toString("base64"), + 113 0 : bodyEncoding: "base64", + 114 0 : }, + 115 0 : ), + 116 0 : ), + 117 0 : ), + 118 0 : ), + 119 0 : close: closeLock.withPermit( + 120 0 : Effect.gen(function* () { + 121 0 : if (yield* Ref.get(closed)) return + 122 0 : yield* connection.close + 123 0 : yield* options.cassette + 124 0 : .append( + 125 0 : options.name, + 126 0 : { transport: "websocket", open: openSnapshot(request), events }, + 127 0 : options.metadata, + 128 0 : ) + 129 0 : .pipe(Effect.orDie) + 130 0 : yield* Ref.set(closed, true) + 131 0 : }), + 132 0 : ), + 133 : } + 134 0 : }), + 135 0 : } + 136 2 : } + 137 : + 138 95 : const replay = yield* makeReplayState(options.cassette, options.name, webSocketInteractions) + 139 12 : return { + 140 0 : open: (request) => + 141 0 : Effect.gen(function* () { + 142 0 : const claimed = yield* replay + 143 0 : .claim((interaction, index) => + 144 0 : Effect.sync(() => { + 145 0 : const incoming = canonicalizeJson(openSnapshot(request)) + 146 0 : if (interaction && JSON.stringify(incoming) === JSON.stringify(canonicalizeJson(interaction.open))) + 147 0 : return + 148 0 : throw new Error(`WebSocket open ${index + 1} does not match ${safeText(incoming)}`) + 149 0 : }), + 150 0 : ) + 151 0 : .pipe(Effect.orDie) + 152 0 : const client = claimed.interaction.events.filter((event) => event.direction === "client") + 153 0 : const server = claimed.interaction.events.filter((event) => event.direction === "server") + 154 0 : const position = yield* SynchronizedRef.make(0) + 155 0 : return { + 156 0 : sendText: (message) => + 157 0 : SynchronizedRef.updateEffect(position, (index) => + 158 0 : assertClientEvent(message, client[index], index, options.compareClientMessagesAsJson === true).pipe( + 159 0 : Effect.as(index + 1), + 160 0 : ), + 161 0 : ), + 162 0 : messages: Stream.fromIterable(server).pipe(Stream.map(decodeEvent)), + 163 0 : close: Effect.gen(function* () { + 164 0 : const used = yield* SynchronizedRef.get(position) + 165 0 : if (used !== client.length) + 166 0 : return yield* Effect.die( + 167 0 : new Error(`WebSocket client frame count: expected ${client.length}, received ${used}`), + 168 0 : ) + 169 0 : }), + 170 : } + 171 2 : }), + 172 1 : } + 173 1 : }) ++ |
+
| Generated by: LCOV version 2.0-1 |
| LCOV - code coverage report | ||||||||||||||||||||||
+
|
+ ||||||||||||||||||||||
| + | + | + | + | ||
| File |
+ Line Coverage |
+ ||||
| Rate | +Total | +Hit | +|||
| llm.ts | +
+ |
+ 100.0 % | +19 | +19 | +|
| schema.ts | +
+ |
+ 90.9 % | +22 | +20 | +|
| Note: 'Function Coverage' columns elided as function owner is not identified. | +|||||
| Generated by: LCOV version 2.0-1 |
| LCOV - code coverage report | ||||||||||||||||||||||
+
|
+ ||||||||||||||||||||||
| + | + | + | + | ||
| File |
+ Line Coverage |
+ ||||
| Rate | +Total | +Hit | +|||
| schema.ts | +
+ |
+ 90.9 % | +22 | +20 | +|
| llm.ts | +
+ |
+ 100.0 % | +19 | +19 | +|
| Note: 'Function Coverage' columns elided as function owner is not identified. | +|||||
| Generated by: LCOV version 2.0-1 |
| LCOV - code coverage report | ||||||||||||||||||||||
+
|
+ ||||||||||||||||||||||
| + | + | + | + | ||
| File |
+ Line Coverage |
+ ||||
| Rate | +Total | +Hit | +|||
| llm.ts | +
+ |
+ 100.0 % | +19 | +19 | +|
| schema.ts | +
+ |
+ 90.9 % | +22 | +20 | +|
| Note: 'Function Coverage' columns elided as function owner is not identified. | +|||||
| Generated by: LCOV version 2.0-1 |
| LCOV - code coverage report | ||||||||||||||||||||||
+
|
+ ||||||||||||||||||||||
+Line data Source code+ + 1 29 : export * as LLM from "./llm" + 2 : + 3 32 : import { Schema } from "effect" + 4 36 : import { optional } from "./schema" + 5 : + 6 118 : export const ProviderMetadata = Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown)).annotate({ + 7 35 : identifier: "LLM.ProviderMetadata", + 8 3 : }) + 9 : export type ProviderMetadata = Schema.Schema.Type<typeof ProviderMetadata> + 10 : + 11 : export interface ToolTextContent extends Schema.Schema.Type<typeof ToolTextContent> {} + 12 47 : export const ToolTextContent = Schema.Struct({ + 13 31 : type: Schema.Literal("text"), + 14 20 : text: Schema.String, + 15 48 : }).annotate({ identifier: "Tool.TextContent" }) + 16 : + 17 : export interface ToolFileContent extends Schema.Schema.Type<typeof ToolFileContent> {} + 18 47 : export const ToolFileContent = Schema.Struct({ + 19 31 : type: Schema.Literal("file"), + 20 21 : uri: Schema.String, + 21 22 : mime: Schema.String, + 22 30 : name: optional(Schema.String), + 23 48 : }).annotate({ identifier: "Tool.FileContent" }) + 24 : + 25 75 : export const ToolContent = Schema.Union([ToolTextContent, ToolFileContent]) + 26 35 : .pipe(Schema.toTaggedUnion("type")) + 27 44 : .annotate({ identifier: "LLM.ToolContent" }) + 28 : export type ToolContent = Schema.Schema.Type<typeof ToolContent> ++ |
+
| Generated by: LCOV version 2.0-1 |
| LCOV - code coverage report | ||||||||||||||||||||||
+
|
+ ||||||||||||||||||||||
+Line data Source code+ + 1 64 : import { DateTime, Option, Schema, SchemaGetter } from "effect" + 2 : + 3 69 : export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0)) + 4 81 : export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) + 5 : + 6 77 : export const RelativePath = Schema.String.pipe(Schema.brand("RelativePath")) + 7 : export type RelativePath = typeof RelativePath.Type + 8 : + 9 77 : export const AbsolutePath = Schema.String.pipe(Schema.brand("AbsolutePath")) + 10 : export type AbsolutePath = typeof AbsolutePath.Type + 11 : + 12 34 : export const optional = <S extends Schema.Top>(schema: S) => + 13 32 : Schema.optionalKey(schema).pipe( + 14 59 : Schema.decodeTo(Schema.optional(Schema.toType(schema)), { + 15 54 : decode: SchemaGetter.passthrough({ strict: false }), + 16 55 : encode: SchemaGetter.transformOptional(Option.filter((value) => value !== undefined)), + 17 1 : }), + 18 2 : ) + 19 : + 20 21 : export const statics = + 21 0 : <S extends object, M extends Record<string, unknown>>(methods: (schema: S) => M) => + 22 0 : (schema: S): S & M => + 23 2 : Object.assign(schema, methods(schema)) + 24 : + 25 55 : export const DateTimeUtcFromMillis = Schema.Finite.pipe( + 26 39 : Schema.decodeTo(Schema.DateTimeUtc, { + 27 34 : decode: SchemaGetter.transform((value) => DateTime.makeUnsafe(value)), + 28 32 : encode: SchemaGetter.transform((value) => DateTime.toEpochMillis(value)), + 29 1 : }), + 30 2 : ) ++ |
+
| Generated by: LCOV version 2.0-1 |