Skip to content

Commit fceea70

Browse files
rustyconoverclaude
andcommitted
feat: serveStream / VgiRpcServer.serveConnection — serve over any byte stream
Add a public server-side serve-over-stream seam (the sibling of the client's pipeConnect), so a worker can be served over a duplex channel that isn't a process or socket — e.g. a Web Worker MessagePort bridge, or an in-memory pipe. - VgiRpcServer.serveConnection(readable, writable, transportKind?): the transport-agnostic core; run() (stdin/stdout) now delegates to it, so the stdio path is unchanged. - serveStream(protocol, { readable, writable }): convenience wrapper, exported next to the server. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bc437d1 commit fceea70

3 files changed

Lines changed: 70 additions & 10 deletions

File tree

src/index.core.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ export {
9292
uint64,
9393
} from "./schema.js";
9494
export { VgiRpcServer } from "./server.js";
95+
export { serveStream, type ServeStreamOptions } from "./serve-stream.js";
9596
export {
9697
type CallContext,
9798
type CallStatistics,

src/serve-stream.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Serve a protocol over a caller-provided byte-stream pair — the stream
5+
//! sibling of `serveTcp` / `serveUnix`, with no socket/listener of its own.
6+
//!
7+
//! Useful for transports the launcher helpers don't cover: a Web Worker /
8+
//! `MessagePort` bridge (postMessage), an in-memory pipe, or a pre-connected
9+
//! socket. The host side already has this symmetry via `pipeConnect`.
10+
11+
import type { Socket } from "node:net";
12+
13+
import type { Protocol } from "./protocol.js";
14+
import { VgiRpcServer } from "./server.js";
15+
import { TransportKind } from "./types.js";
16+
17+
export interface ServeStreamOptions {
18+
/** Incoming request bytes — a web `ReadableStream<Uint8Array>` or a Node
19+
* `Readable` (e.g. a `Duplex` bridging a MessagePort). */
20+
readable: ReadableStream<Uint8Array> | NodeJS.ReadableStream;
21+
/** Outgoing response sink — a stdout-like fd number, or a `net.Socket` /
22+
* structurally-compatible `Duplex`. Omit for the stdout fd. */
23+
writable?: number | Socket;
24+
/** Passed through to the `VgiRpcServer` constructor (describe, hooks, …). */
25+
serverOptions?: ConstructorParameters<typeof VgiRpcServer>[1];
26+
/** Reported to the `on_serve_start` hook. Defaults to `PIPE`. */
27+
transportKind?: TransportKind;
28+
}
29+
30+
/**
31+
* Serve `protocol` over the provided `readable`/`writable` until the readable
32+
* ends. Thin wrapper over {@link VgiRpcServer.serveConnection}. Resolves on
33+
* clean EOF; rejects on a real protocol/transport error.
34+
*/
35+
export async function serveStream(protocol: Protocol, options: ServeStreamOptions): Promise<void> {
36+
const server = new VgiRpcServer(protocol, options.serverOptions);
37+
await server.serveConnection(options.readable, options.writable, options.transportKind);
38+
}

src/server.ts

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -161,10 +161,8 @@ export class VgiRpcServer {
161161
);
162162
}
163163

164-
/** Start the server loop. Reads requests until stdin closes. */
164+
/** Start the server loop over stdin/stdout. Reads requests until stdin closes. */
165165
async run(): Promise<void> {
166-
const stdin = process.stdin as unknown as ReadableStream<Uint8Array>;
167-
168166
// Warn if running interactively
169167
if (process.stdin.isTTY || process.stdout.isTTY) {
170168
process.stderr.write(
@@ -174,21 +172,44 @@ export class VgiRpcServer {
174172
"(e.g. vgi_rpc.connect()).\n",
175173
);
176174
}
175+
const stdin = process.stdin as unknown as ReadableStream<Uint8Array>;
176+
// writable omitted → IpcStreamWriter defaults to the stdout fd.
177+
await this.serveConnection(stdin);
178+
}
177179

178-
const reader = await IpcStreamReader.create(stdin);
179-
const writer = new IpcStreamWriter();
180+
/**
181+
* Serve requests over an explicit byte-stream pair until the readable ends —
182+
* the transport-agnostic core that {@link run} (stdin/stdout) is built on.
183+
*
184+
* Use this to serve over any duplex channel that the stdio/unix/tcp helpers
185+
* don't cover: a Web Worker / `MessagePort` bridge, an in-memory pipe, or a
186+
* pre-connected socket. The loop, on_serve_start firing, and EOF/broken-pipe
187+
* handling are identical to {@link run}.
188+
*
189+
* @param readable incoming request bytes — a web `ReadableStream<Uint8Array>`
190+
* or a Node `Readable` (e.g. a `Duplex` bridging a MessagePort).
191+
* @param writable outgoing response sink — a stdout-like fd number, or a
192+
* `net.Socket` / structurally-compatible `Duplex`. Omit for the stdout fd.
193+
* @param transportKind reported to the `on_serve_start` hook (default `PIPE`).
194+
*/
195+
async serveConnection(
196+
readable: ReadableStream<Uint8Array> | NodeJS.ReadableStream,
197+
writable?: number | import("node:net").Socket,
198+
transportKind: TransportKind = TransportKind.PIPE,
199+
): Promise<void> {
200+
const reader = await IpcStreamReader.create(readable);
201+
const writer = new IpcStreamWriter(writable);
180202

181203
try {
182204
while (true) {
183-
// Fire on_serve_start lazily so the hook can do work that
184-
// depends on the transport binding (matches Python which fires
185-
// it inside serve()). Inside the loop so a failure on the very
205+
// Fire on_serve_start lazily so the hook can do work that depends on
206+
// the transport binding. Inside the loop so a failure on the very
186207
// first request can be retried.
187-
await this.notifyTransport(TransportKind.PIPE);
208+
await this.notifyTransport(transportKind);
188209
await this.serveOne(reader, writer);
189210
}
190211
} catch (e: any) {
191-
// EOF or broken pipe → clean exit
212+
// EOF or broken pipe / closed channel → clean exit
192213
if (
193214
e.message?.includes("closed") ||
194215
e.message?.includes("Expected Schema Message") ||

0 commit comments

Comments
 (0)