Skip to content

Commit c9882f0

Browse files
Nick Ficanoclaude
andcommitted
effect(core-transport): add Stream-shaped factories alongside legacy classes
Introduce TransportEffect interface and *TransportEffect factories (memory, stdio, websocket) that expose inbound frames as Stream<WireFrame, TaggedTransportError> and Effects for send/close. Legacy MemoryTransport / StdioTransport / WebSocketTransport classes are unchanged — the new factories adapt them, preserving the published Transport contract. Adds TaggedTransportError (Schema.TaggedError, cause + optional kind) since the §12 ARCP error catalog does not cover the transport seam. Closes #41 Closes #21 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8101b07 commit c9882f0

10 files changed

Lines changed: 440 additions & 5 deletions

File tree

packages/core/src/errors-tagged.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,27 @@ export class TaggedAgentVersionNotAvailable extends Schema.TaggedError<TaggedAge
163163
readonly code = "AGENT_VERSION_NOT_AVAILABLE" as const;
164164
}
165165

166+
/**
167+
* Transport-layer failure surfaced through Effect's typed-error channel.
168+
*
169+
* Not part of the §12 ARCP error catalog — `Transport` is the seam below
170+
* protocol logic, so its failures are categorically distinct from the
171+
* `TaggedSdkError` union. Effect-shaped transports (`memoryTransportEffect`,
172+
* `stdioTransportEffect`, `websocketTransportEffect`) fail their `incoming`
173+
* stream and `send` Effect with this error.
174+
*
175+
* `kind` is a free-form, opt-in tag for upstream pattern matching
176+
* (`"send"`, `"receive"`, `"parse"`, `"closed"`, etc.). `cause` carries the
177+
* underlying defect (typically a Node `Error` from `ws` / `readline`).
178+
*/
179+
export class TaggedTransportError extends Schema.TaggedError<TaggedTransportError>()(
180+
"TransportError",
181+
{
182+
cause: Schema.Defect,
183+
kind: Schema.optional(Schema.String),
184+
},
185+
) {}
186+
166187
/**
167188
* Discriminated union of every Effect-native ARCP error. Mirrors `SdkError`
168189
* but in the typed-error channel of an `Effect`.

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export {
6868
TaggedResumeWindowExpired,
6969
type TaggedSdkError,
7070
TaggedTimeout,
71+
TaggedTransportError,
7172
TaggedUnauthenticated,
7273
} from "./errors-tagged.js";
7374
export {
Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
1-
export { MemoryTransport, pairMemoryTransports } from "./memory.js";
2-
export { StdioTransport } from "./stdio.js";
1+
export {
2+
MemoryTransport,
3+
memoryTransportEffect,
4+
pairMemoryTransports,
5+
pairMemoryTransportsEffect,
6+
} from "./memory.js";
7+
export { StdioTransport, stdioTransportEffect } from "./stdio.js";
38
export type {
49
FrameHandler,
510
SendableFrame,
611
Transport,
12+
TransportEffect,
713
WebSocketServerHandle,
814
WireFrame,
915
} from "./types.js";
10-
export { startWebSocketServer, WebSocketTransport } from "./websocket.js";
16+
export {
17+
startWebSocketServer,
18+
WebSocketTransport,
19+
websocketTransportEffect,
20+
} from "./websocket.js";

packages/core/src/transport/memory.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
1+
import { Effect, Stream } from "effect";
2+
3+
import { TaggedTransportError } from "../errors-tagged.js";
14
import { InvalidRequestError } from "../errors.js";
25

3-
import type { FrameHandler, SendableFrame, Transport } from "./types.js";
6+
import type {
7+
FrameHandler,
8+
SendableFrame,
9+
Transport,
10+
TransportEffect,
11+
WireFrame,
12+
} from "./types.js";
413

514
/**
615
* Two transports sharing a Promise-coupled queue. Used by tests to drive a
@@ -99,3 +108,55 @@ export function pairMemoryTransports(): [MemoryTransport, MemoryTransport] {
99108
b.connect(a);
100109
return [a, b];
101110
}
111+
112+
/**
113+
* Effect-shaped factory that wraps a legacy {@link MemoryTransport} as a
114+
* {@link TransportEffect}. The legacy class API is unchanged; this adapter
115+
* exposes an `incoming` {@link Stream.Stream} and an `Effect`-returning
116+
* `send`/`close` for Effect-native call sites.
117+
*
118+
* Use {@link pairMemoryTransportsEffect} to get a wired pair. This factory
119+
* is exposed standalone for callers that already own a `MemoryTransport`.
120+
*/
121+
export function memoryTransportEffect(
122+
transport: MemoryTransport,
123+
): TransportEffect {
124+
const incoming = Stream.async<WireFrame, TaggedTransportError>((emit) => {
125+
transport.onFrame((frame) => {
126+
void emit.single(frame);
127+
});
128+
transport.onClose((err) => {
129+
if (err === undefined) {
130+
void emit.end();
131+
} else {
132+
void emit.fail(
133+
new TaggedTransportError({ cause: err, kind: "closed" }),
134+
);
135+
}
136+
});
137+
});
138+
const send = (frame: SendableFrame): Effect.Effect<void, TaggedTransportError> =>
139+
Effect.tryPromise({
140+
try: () => transport.send(frame),
141+
catch: (cause) => new TaggedTransportError({ cause, kind: "send" }),
142+
});
143+
return {
144+
incoming,
145+
send,
146+
close: Effect.promise(() => transport.close()),
147+
isClosed: () => transport.closed,
148+
};
149+
}
150+
151+
/**
152+
* Effect-shaped twin of {@link pairMemoryTransports}. Returns
153+
* `[clientSideEffect, serverSideEffect]` — two cross-wired
154+
* {@link TransportEffect}s suitable for Effect-native tests.
155+
*/
156+
export function pairMemoryTransportsEffect(): readonly [
157+
TransportEffect,
158+
TransportEffect,
159+
] {
160+
const [a, b] = pairMemoryTransports();
161+
return [memoryTransportEffect(a), memoryTransportEffect(b)];
162+
}

packages/core/src/transport/stdio.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import { createInterface, type Interface } from "node:readline";
22
import type { Readable, Writable } from "node:stream";
33

4+
import { Effect, Stream } from "effect";
5+
6+
import { TaggedTransportError } from "../errors-tagged.js";
47
import { InvalidRequestError } from "../errors.js";
58

69
import type {
710
FrameHandler,
811
SendableFrame,
912
Transport,
13+
TransportEffect,
1014
WireFrame,
1115
} from "./types.js";
1216

@@ -130,3 +134,39 @@ export class StdioTransport implements Transport {
130134
}
131135
}
132136
}
137+
138+
/**
139+
* Effect-shaped factory that adapts a {@link StdioTransport} to a
140+
* {@link TransportEffect}. The legacy class is unchanged; this wrapper
141+
* exposes `incoming` as a {@link Stream.Stream} and returns `Effect`s for
142+
* `send` and `close` so Effect-native pipelines can consume stdio frames.
143+
*/
144+
export function stdioTransportEffect(
145+
transport: StdioTransport,
146+
): TransportEffect {
147+
const incoming = Stream.async<WireFrame, TaggedTransportError>((emit) => {
148+
transport.onFrame((frame) => {
149+
void emit.single(frame);
150+
});
151+
transport.onClose((err) => {
152+
if (err === undefined) {
153+
void emit.end();
154+
} else {
155+
void emit.fail(
156+
new TaggedTransportError({ cause: err, kind: "closed" }),
157+
);
158+
}
159+
});
160+
});
161+
const send = (frame: SendableFrame): Effect.Effect<void, TaggedTransportError> =>
162+
Effect.tryPromise({
163+
try: () => transport.send(frame),
164+
catch: (cause) => new TaggedTransportError({ cause, kind: "send" }),
165+
});
166+
return {
167+
incoming,
168+
send,
169+
close: Effect.promise(() => transport.close()),
170+
isClosed: () => transport.closed,
171+
};
172+
}

packages/core/src/transport/types.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
* guarantee the protocol relies on.
1212
*/
1313

14+
import type { Effect, Stream } from "effect";
15+
1416
import type { BaseEnvelope } from "../envelope.js";
17+
import type { TaggedTransportError } from "../errors-tagged.js";
1518

1619
/** Raw message frame: a JSON-encodable object. */
1720
export type WireFrame = Record<string, unknown>;
@@ -54,6 +57,33 @@ export interface Transport {
5457
readonly closed: boolean;
5558
}
5659

60+
/**
61+
* Effect-shaped twin of {@link Transport}. Returned by the
62+
* `*TransportEffect` factories (`memoryTransportEffect`,
63+
* `stdioTransportEffect`, `websocketTransportEffect`).
64+
*
65+
* The legacy {@link Transport} class API is preserved for downstream
66+
* consumers; this interface exists alongside it so Effect-native call sites
67+
* can compose `incoming` with the rest of an Effect pipeline without an
68+
* intermediary callback.
69+
*
70+
* `incoming` MUST emit frames in receive order. Backpressure is delegated
71+
* to Stream consumers (each `Stream.async` factory uses an unbounded queue
72+
* by default, matching the legacy class's buffering behavior).
73+
*/
74+
export interface TransportEffect {
75+
/** Stream of inbound frames; terminates on peer close. */
76+
readonly incoming: Stream.Stream<WireFrame, TaggedTransportError>;
77+
/** Send a frame to the peer. Fails with `TaggedTransportError` on I/O error or after close. */
78+
readonly send: (
79+
frame: SendableFrame,
80+
) => Effect.Effect<void, TaggedTransportError>;
81+
/** Close the transport. Idempotent; never fails. */
82+
readonly close: Effect.Effect<void>;
83+
/** Synchronous closed-state check, mirroring the legacy `closed` getter. */
84+
readonly isClosed: () => boolean;
85+
}
86+
5787
/** Resolved handle returned by `startWebSocketServer`. */
5888
export interface WebSocketServerHandle {
5989
/** Resolved port the server bound to. */

packages/core/src/transport/websocket.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
import { Effect, Stream } from "effect";
12
import { WebSocket, WebSocketServer } from "ws";
23

4+
import { TaggedTransportError } from "../errors-tagged.js";
35
import { InternalError, InvalidRequestError } from "../errors.js";
46

57
import type {
68
FrameHandler,
79
SendableFrame,
810
Transport,
11+
TransportEffect,
912
WebSocketServerHandle,
1013
WireFrame,
1114
} from "./types.js";
@@ -210,3 +213,79 @@ export async function startWebSocketServer(args: {
210213
}),
211214
};
212215
}
216+
217+
/**
218+
* Effect-shaped factory for a WebSocket transport. Wraps a raw `ws` socket
219+
* (NOT a {@link WebSocketTransport} instance) so the inbound side is a
220+
* `Stream<WireFrame, TaggedTransportError>` instead of a callback. The
221+
* legacy {@link WebSocketTransport} class is unchanged and is the path
222+
* currently used by the runtime / client.
223+
*
224+
* The socket is expected to be OPEN before this factory is called — see
225+
* {@link WebSocketTransport.connect} for the open-handshake helper used on
226+
* the client side.
227+
*/
228+
export function websocketTransportEffect(socket: WebSocket): TransportEffect {
229+
const state = { closed: false };
230+
return {
231+
incoming: makeIncoming(socket, state),
232+
send: (frame) => makeSend(socket, frame),
233+
close: Effect.sync(() => {
234+
if (state.closed) return;
235+
state.closed = true;
236+
if (
237+
socket.readyState === WebSocket.OPEN ||
238+
socket.readyState === WebSocket.CONNECTING
239+
) {
240+
socket.close();
241+
}
242+
}),
243+
isClosed: () => state.closed || socket.readyState === WebSocket.CLOSED,
244+
};
245+
}
246+
247+
function makeIncoming(
248+
socket: WebSocket,
249+
state: { closed: boolean },
250+
): Stream.Stream<WireFrame, TaggedTransportError> {
251+
return Stream.async<WireFrame, TaggedTransportError>((emit) => {
252+
const onMessage = (data: unknown, isBinary: boolean): void => {
253+
if (isBinary) return;
254+
const frame = parseWireFrame(data);
255+
if (frame === null) return;
256+
void emit.single(frame);
257+
};
258+
const onClose = (): void => {
259+
state.closed = true;
260+
void emit.end();
261+
};
262+
const onError = (err: Error): void => {
263+
state.closed = true;
264+
void emit.fail(new TaggedTransportError({ cause: err, kind: "receive" }));
265+
};
266+
socket.on("message", onMessage);
267+
socket.on("close", onClose);
268+
socket.on("error", onError);
269+
return Effect.sync(() => {
270+
socket.off("message", onMessage);
271+
socket.off("close", onClose);
272+
socket.off("error", onError);
273+
});
274+
});
275+
}
276+
277+
function makeSend(
278+
socket: WebSocket,
279+
frame: SendableFrame,
280+
): Effect.Effect<void, TaggedTransportError> {
281+
return Effect.tryPromise({
282+
try: () =>
283+
new Promise<void>((resolve, reject) => {
284+
socket.send(JSON.stringify(frame), (err) => {
285+
if (err) reject(err);
286+
else resolve();
287+
});
288+
}),
289+
catch: (cause) => new TaggedTransportError({ cause, kind: "send" }),
290+
});
291+
}

packages/core/test/transport-memory.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
import { Effect, Stream } from "effect";
12
import { describe, expect, it } from "vitest";
23

3-
import { pairMemoryTransports } from "@arcp/core";
4+
import {
5+
pairMemoryTransports,
6+
pairMemoryTransportsEffect,
7+
} from "@arcp/core";
48

59
describe("pairMemoryTransports", () => {
610
it("delivers frames in FIFO order from a to b", async () => {
@@ -46,3 +50,36 @@ describe("pairMemoryTransports", () => {
4650
await expect(a.send({})).rejects.toThrow();
4751
});
4852
});
53+
54+
describe("pairMemoryTransportsEffect", () => {
55+
it("delivers 10 frames a→b in order via Stream.take", async () => {
56+
const [a, b] = pairMemoryTransportsEffect();
57+
const take = Effect.runPromise(
58+
Stream.runCollect(Stream.take(b.incoming, 10)),
59+
);
60+
for (let i = 0; i < 10; i++) {
61+
await Effect.runPromise(a.send({ n: i }));
62+
}
63+
const chunk = await take;
64+
const ns = [...chunk].map((f) => f["n"] as number);
65+
expect(ns).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
66+
});
67+
68+
it("close on one side ends the peer's incoming stream", async () => {
69+
const [a, b] = pairMemoryTransportsEffect();
70+
const collected = Effect.runPromise(Stream.runCollect(b.incoming));
71+
await Effect.runPromise(a.send({ k: "v" }));
72+
await Effect.runPromise(a.close);
73+
const chunk = await collected;
74+
expect([...chunk]).toEqual([{ k: "v" }]);
75+
expect(b.isClosed()).toBe(true);
76+
});
77+
78+
it("send after close fails with TaggedTransportError", async () => {
79+
const [a, b] = pairMemoryTransportsEffect();
80+
void b;
81+
await Effect.runPromise(a.close);
82+
const exit = await Effect.runPromiseExit(a.send({ n: 1 }));
83+
expect(exit._tag).toBe("Failure");
84+
});
85+
});

0 commit comments

Comments
 (0)