Skip to content

Commit 81eea42

Browse files
committed
feat(http-recorder): prepare independent beta release
1 parent f48f24e commit 81eea42

17 files changed

Lines changed: 485 additions & 85 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@opencode-ai/http-recorder": minor
3+
---
4+
5+
Publish the initial beta of the Effect HTTP and WebSocket record/replay library.

.changeset/config.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
3+
"changelog": "@changesets/cli/changelog",
4+
"commit": false,
5+
"fixed": [],
6+
"linked": [],
7+
"access": "public",
8+
"baseBranch": "dev",
9+
"updateInternalDependencies": "patch",
10+
"ignore": []
11+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: http-recorder release
2+
3+
on:
4+
workflow_dispatch:
5+
6+
concurrency: http-recorder-release
7+
8+
permissions:
9+
contents: read
10+
id-token: write
11+
12+
jobs:
13+
release:
14+
if: github.repository == 'anomalyco/opencode' && github.ref == 'refs/heads/dev'
15+
runs-on: ubuntu-latest
16+
environment: npm
17+
steps:
18+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
19+
20+
- uses: ./.github/actions/setup-bun
21+
22+
- name: Verify package
23+
working-directory: packages/http-recorder
24+
run: |
25+
bun run test
26+
bun typecheck
27+
28+
- name: Publish beta
29+
run: |
30+
if [ -n "$NODE_AUTH_TOKEN" ]; then
31+
npm config set //registry.npmjs.org/:_authToken "$NODE_AUTH_TOKEN"
32+
fi
33+
bun run release:http-recorder
34+
env:
35+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

bun.lock

Lines changed: 187 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@
1212
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
1313
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
1414
"dev:storybook": "bun --cwd packages/storybook storybook",
15+
"changeset": "changeset",
1516
"lint": "oxlint",
17+
"release:http-recorder": "bun ./packages/http-recorder/script/publish.ts",
1618
"typecheck": "bun turbo typecheck",
1719
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
20+
"version:http-recorder": "bun ./packages/http-recorder/script/version.ts",
1821
"postinstall": "bun run --cwd packages/core fix-node-pty",
1922
"prepare": "husky",
2023
"random": "echo 'Random script'",
@@ -94,6 +97,7 @@
9497
},
9598
"devDependencies": {
9699
"@actions/artifact": "5.0.1",
100+
"@changesets/cli": "2.31.0",
97101
"@tsconfig/bun": "catalog:",
98102
"@types/mime-types": "3.0.1",
99103
"@typescript/native-preview": "catalog:",

packages/http-recorder/CONTEXT.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# HTTP Recorder
2+
3+
The HTTP recorder is a public testing library for recording real Effect transport traffic into deterministic cassettes and replaying it without contacting the upstream service.
4+
5+
## Language
6+
7+
**Library Version**:
8+
The independently managed semantic version of `@opencode-ai/http-recorder` published to npm.
9+
_Avoid_: OpenCode version
10+
11+
**OpenCode Release Version**:
12+
The version applied to OpenCode applications and packages by the repository-wide release process. It does not determine the **Library Version**.
13+
14+
**Effect Compatibility Version**:
15+
The exact Effect 4 beta against which the package's unstable HTTP and socket integrations are built and verified.
16+
17+
**Provider Conversation**:
18+
A finite WebSocket connection in which either peer may send first, the client sends one or more JSON commands, server frames follow in causal order, and the application closes after a terminal event.
19+
20+
**Connection Identity**:
21+
The explicit cassette name supplied to the WebSocket recorder. It identifies the recorded conversation during replay.
22+
23+
**Completed Conversation**:
24+
A WebSocket socket run that opened and finished successfully after recording a valid finite transcript.
25+
26+
**Client Frame Match**:
27+
The replay check that requires an outgoing application frame to equal the next recorded client frame after redaction. Text JSON ignores object-key order; other text and binary frames match exactly.
28+
29+
## Relationships
30+
31+
- The **Library Version** begins with the public beta at `0.1.0` and advances independently through Changesets.
32+
- Repository-wide OpenCode release synchronization must not rewrite the **Library Version**.
33+
- The initial **Effect Compatibility Version** is `4.0.0-beta.83`; compatibility with later Effect betas is not implied.
34+
- Changing the **Effect Compatibility Version** requires a new **Library Version** and clean-consumer package verification.
35+
- A **Provider Conversation** is the canonical WebSocket scenario for evaluating the public beta contract; arbitrary socket emulation is not implied.
36+
- Application code owns WebSocket construction, including URL, protocols, timeout, authentication, and close policy; the recorder decorates the resulting Effect `Socket.Socket` service.
37+
- **Connection Identity**, not the live WebSocket destination, selects and validates a replay. The beta does not validate URL or handshake configuration during replay.
38+
- Only a **Completed Conversation** is committed to a cassette; failed, interrupted, unopened, or invalid runs do not produce a recording.
39+
- Replay requires every recorded frame to be consumed before application close.
40+
- Terminal close codes, close reasons, connection timing, and transport failures are not cassette events in the first public beta.
41+
- Every outgoing replay frame must satisfy the **Client Frame Match** before later server frames are released.
42+
- The first public beta does not expose a custom WebSocket frame matcher.
43+
- Replay starts incoming frame handlers in recorded order and may run them concurrently; it waits for every handler before the socket run completes but does not guarantee handler completion order.

packages/http-recorder/README.md

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@ Use it for provider integrations, retries, polling, multi-step flows, and any te
99
## Install
1010

1111
```sh
12-
bun add effect@4.0.0-beta.74
12+
bun add effect@4.0.0-beta.83 @effect/platform-node@4.0.0-beta.83
1313
bun add -d @opencode-ai/http-recorder@beta @effect/vitest vitest
1414
```
1515

1616
The package supports Node.js 22+ and Bun. It is not intended for browsers, workers, or Deno.
1717

18-
Effect `4.0.0-beta.74` has a known declaration error (`SchemaErrorTypeId` is missing). Until that upstream declaration is fixed, TypeScript consumers need:
18+
Effect `4.0.0-beta.83` currently contains unresolved symbols in its published declarations. Until those upstream declarations are fixed, TypeScript consumers need:
1919

2020
```json
2121
{
@@ -89,41 +89,58 @@ That is the complete public API. `http` provides a fetch-backed recorded `HttpCl
8989
9090
## WebSockets
9191
92-
WebSocket cassettes preserve one ordered transcript of client and server text or binary frames. Replay follows that chronology: server frames are released until the next recorded client frame, then replay waits for the application to send the matching frame before continuing.
92+
Effect models a WebSocket as a `Socket.Socket` service. A program obtains a scoped `writer` for outgoing frames and runs one receive loop for the lifetime of a connection. The application supplies the live URL-bound socket; the recorder decorates that service without owning its URL, protocols, authentication, timeout, or close policy.
93+
94+
The cassette name is the connection identity during replay. Replay does not validate the live URL or handshake configuration.
9395
9496
```ts
95-
import { assert, it } from "@effect/vitest"
97+
import { it } from "@effect/vitest"
9698
import { NodeSocket } from "@effect/platform-node"
9799
import { Effect, Layer } from "effect"
98100
import { Socket } from "effect/unstable/socket"
99101
import { HttpRecorder } from "@opencode-ai/http-recorder"
100102

101-
const echo = Effect.gen(function* () {
103+
const conversation = Effect.gen(function* () {
102104
const socket = yield* Socket.Socket
103105
const write = yield* socket.writer
104106

105-
yield* socket.runString(
106-
(message) =>
107-
Effect.gen(function* () {
108-
assert.strictEqual(message, "hello")
109-
yield* write(new Socket.CloseEvent(1000))
110-
}),
111-
{ onOpen: write("hello") },
107+
yield* socket.runString((message) =>
108+
Effect.gen(function* () {
109+
const event: unknown = JSON.parse(message)
110+
111+
if (typeof event !== "object" || event === null || !("type" in event)) return
112+
if (event.type === "session.created") {
113+
yield* write(JSON.stringify({ type: "response.create", prompt: "hello" }))
114+
}
115+
if (event.type === "response.completed") {
116+
yield* write(new Socket.CloseEvent(1000, "done"))
117+
}
118+
}),
112119
)
113120
})
114121

115-
const recordedSocket = HttpRecorder.socket("echo/hello").pipe(
122+
const recordedSocket = HttpRecorder.socket("provider/conversation").pipe(
116123
Layer.provide(
117-
NodeSocket.layerWebSocket("wss://ws.postman-echo.com/raw", {
124+
NodeSocket.layerWebSocket("wss://provider.example/realtime", {
118125
closeCodeIsError: (code) => code !== 1000,
119126
}),
120127
),
121128
)
122129

123-
it.effect("exchanges WebSocket frames", () => echo.pipe(Effect.provide(recordedSocket)))
130+
it.effect("completes a provider conversation", () => conversation.pipe(Effect.scoped, Effect.provide(recordedSocket)))
124131
```
125132
126-
The application owns the WebSocket URL and protocols through normal Effect layer wiring. The recorder wraps that socket without duplicating its URL in recorder configuration. Provide separate socket layers for separate endpoints or concurrent connections.
133+
`socket.runString` owns the receive loop and finishes when the connection closes or fails. Its optional `onOpen` effect is the safe place to send protocols whose client speaks first. The writer is scoped because sending is valid only while a connection run is active.
134+
135+
WebSocket cassettes preserve one ordered transcript of client and server text or binary frames. Replay releases recorded server frames until it reaches a client frame, waits for the application to write the matching frame, then continues. This preserves causal ordering without reproducing network timing.
136+
137+
Client text frames containing JSON compare canonically, so object-key order does not matter. Changed fields, extra fields, non-JSON text, and binary frames must match exactly after redaction. There is intentionally no custom WebSocket matcher in this beta.
138+
139+
Incoming frame handlers start in recorded order and may run concurrently, matching Effect's socket abstraction. Replay waits for all handlers before the socket run completes, but handler completion order is not guaranteed. Use Effect synchronization such as `Queue`, `Ref`, or `Deferred` instead of unsynchronized mutable state.
140+
141+
A cassette is written only after the live socket opened and its run completed successfully. Failed, interrupted, unopened, or invalid runs do not produce a recording. During replay, closing before every recorded frame is consumed fails the test.
142+
143+
The application owns the WebSocket URL and protocols through normal Effect layer wiring. Provide separate recorder and live socket layers for separate endpoints or concurrent connections. One recorder layer supports sequential reconnects, but rejects concurrent runs.
127144
128145
Text frames use the same JSON-field and body redaction as HTTP bodies. Binary frames are stored losslessly as base64. Client and server frame kinds must match during replay.
129146
@@ -195,6 +212,8 @@ interface RecorderOptions {
195212
readonly redact?: RedactOptions
196213
readonly match?: RequestMatcher
197214
}
215+
216+
type SocketRecorderOptions = Omit<RecorderOptions, "match">
198217
```
199218
200219
`directory` defaults to `<cwd>/test/fixtures/recordings`.
@@ -207,7 +226,7 @@ Cassettes are readable JSON files intended to be committed with your tests. HTTP
207226
208227
- Responses are buffered while recording and replaying, so this beta is not suitable for tests that assert streaming timing, cancellation, or backpressure.
209228
- WebSocket replay preserves frame chronology and content, not real network timing or backpressure.
210-
- WebSocket V1 cassettes do not reproduce terminal close codes, close reasons, or transport failures. Failed and interrupted live runs are not recorded.
229+
- WebSocket V1 cassettes do not reproduce terminal close codes, close reasons, handshake configuration, or transport failures. Failed and interrupted live runs are not recorded.
211230
- WebSocket transcripts are retained in memory until the connection finishes; avoid using this beta for unbounded sessions.
212231
- The package currently requires the exact Effect beta listed above.
213232
- Cassette format version `1` has no migration tooling yet.

packages/http-recorder/RELEASE.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Release
2+
3+
`@opencode-ai/http-recorder` is versioned independently from OpenCode and published under the npm `beta` tag.
4+
5+
## Prepare A Release
6+
7+
1. Add a Changeset for every user-facing package change.
8+
2. Run `bunx changeset status` and confirm that only `@opencode-ai/http-recorder` will be bumped.
9+
3. Merge the package changes into `dev`.
10+
4. From a branch based on the latest `dev`, run `bun run version:http-recorder`.
11+
5. Review the generated version and `packages/http-recorder/CHANGELOG.md`, then open and merge the release PR.
12+
13+
The first minor Changeset advances the unpublished `0.0.0` package to `0.1.0`. OpenCode's repository-wide release script deliberately excludes this package from its version synchronization.
14+
15+
## Verify And Publish
16+
17+
Before merging the release PR, run these commands from `packages/http-recorder`:
18+
19+
```sh
20+
bun run test
21+
bun typecheck
22+
bun run verify:package
23+
```
24+
25+
After the release PR reaches `dev`, manually dispatch the `http-recorder release` workflow from the `dev` branch. The workflow repeats the focused tests, builds and verifies the exact tarball in a clean npm consumer, and publishes it with provenance under the `beta` tag.
26+
27+
The bootstrap release requires an npm automation token in the repository's `NPM_TOKEN` secret. After the package exists, configure npm trusted publishing for `.github/workflows/http-recorder-release.yml` and the `npm` GitHub environment, then remove the token so later releases use GitHub OIDC.
28+
29+
Verify the result:
30+
31+
```sh
32+
npm view @opencode-ai/http-recorder version dist-tags --json
33+
```

packages/http-recorder/package.json

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"$schema": "https://json.schemastore.org/package.json",
3-
"version": "1.17.9",
3+
"version": "0.0.0",
44
"name": "@opencode-ai/http-recorder",
55
"description": "Record and replay Effect HTTP client traffic with deterministic cassettes",
66
"type": "module",
@@ -51,8 +51,7 @@
5151
"typescript": "catalog:"
5252
},
5353
"dependencies": {
54-
"@effect/platform-node": "4.0.0-beta.83",
55-
"@effect/platform-node-shared": "4.0.0-beta.83"
54+
"@effect/platform-node": "4.0.0-beta.83"
5655
},
5756
"peerDependencies": {
5857
"effect": "4.0.0-beta.83"
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
#!/usr/bin/env bun
2+
import { $ } from "bun"
3+
import { fileURLToPath } from "node:url"
4+
import { pack } from "./pack.js"
5+
import { verifyPackage } from "./verify-package.js"
6+
7+
const dir = fileURLToPath(new URL("..", import.meta.url))
8+
process.chdir(dir)
9+
10+
const published = async (name: string, version: string) => {
11+
const result = await $`npm view ${name}@${version} version`.quiet().nothrow()
12+
if (result.exitCode === 0) return true
13+
const stderr = result.stderr.toString()
14+
if (stderr.includes("E404")) return false
15+
throw new Error(`Failed to check whether ${name}@${version} is published:\n${stderr}`)
16+
}
17+
18+
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- package.json is validated by the package schema and build checks.
19+
const pkg = JSON.parse(await Bun.file("package.json").text()) as { readonly name: string; readonly version: string }
20+
if (pkg.version === "0.0.0") throw new Error("Version the HTTP recorder before publishing")
21+
if (!(await Bun.file("CHANGELOG.md").exists()))
22+
throw new Error("Generate the HTTP recorder changelog before publishing")
23+
24+
if (await published(pkg.name, pkg.version)) {
25+
console.log(`already published ${pkg.name}@${pkg.version}`)
26+
} else {
27+
const archive = await pack()
28+
try {
29+
await verifyPackage(archive)
30+
await $`npm publish ${archive} --tag beta --access public --provenance`
31+
} finally {
32+
await Bun.file(archive).delete()
33+
}
34+
}

0 commit comments

Comments
 (0)