WebSocket
WebSocket transports with FastAPIWebsocketTransport (bring your own socket), SingleClientWebsocketServerTransport, WebsocketClientTransport and RTVIClientTransport, plus endpoint helpers for each runtime.
Overview
@pipedog/transport-websocket carries frames over a WebSocket, encoded by a
serializer. It ports Pipecat's four WebSocket transports, and adds
the helpers that accept a socket on each runtime:
FastAPIWebsocketTransport: runs a pipeline on a socket your server already accepted. Upstream takes the socket a FastAPI endpoint accepted; here it takes aWebsocketConnectionfrom any runtime. This is the one to use for a web server, a Worker or telephony.SingleClientWebsocketServerTransport: starts its own WebSocket server and serves one client at a time. For local development and single-session bots.WebsocketClientTransport: connects out to a WebSocket server.RTVIClientTransport: aWebsocketClientTransportthat speaks RTVI as a client to a bot.WebsocketConnection: the runtime-agnostic socket all four transports talk to.- Endpoint helpers that produce a
WebsocketConnection:websocketFetchHandler(fetch handlers, withupgradeWebSocketandbunWebSocket),serveFetch(serve a fetch handler with WebSocket upgrades on Bun, Node and Deno),websocketServe(listen on a port) andhonoWebsocket(Hono).
For a browser voice client, WebRTC handles network loss and jitter better. See SmallWebRTC. WebSockets fit server-to-server links, telephony and runtimes without UDP, such as Cloudflare Workers.
Upstream: FastAPI WebSocket
Pipecat's page for FastAPIWebsocketTransport.
Upstream: WebSocket server
Pipecat's page for the WebSocket server transport.
Source
fastapi.ts, server.ts, client.ts, rtvi_client.ts, socket.ts, fetch.ts, serve.ts
and hono.ts.
Cloudflare Workers
Running a voice agent in a Durable Object over a WebSocket.
Installation
bun add @pipedog/transport-websocketTo use serveFetch, websocketServe or SingleClientWebsocketServerTransport on Node, also
install the ws package (an optional peer dependency):
npm install wsimport {
FastAPIWebsocketParams,
FastAPIWebsocketTransport,
} from "@pipedog/transport-websocket/fastapi";
import {
SingleClientWebsocketServerParams,
SingleClientWebsocketServerTransport,
} from "@pipedog/transport-websocket/server";
import { WebsocketClientParams, WebsocketClientTransport } from "@pipedog/transport-websocket/client";
import { RTVIClientTransport } from "@pipedog/transport-websocket/rtvi_client";
import { WebsocketConnection } from "@pipedog/transport-websocket/socket";
import { bunWebSocket, upgradeWebSocket, websocketFetchHandler } from "@pipedog/transport-websocket/fetch";
import { serveFetch, websocketServe } from "@pipedog/transport-websocket/serve";
import { honoWebsocket } from "@pipedog/transport-websocket/hono";The package depends on @pipedog/serializer-protobuf and @pipedog/serializer-rtvi-client, the
default serializers of the two client transports.
Prerequisites
- A serializer that matches the other end:
ProtobufFrameSerializerfor Pipecat's WebSocket clients, a telephony serializer such as Telnyx for phone calls. - For
allowedOrigins, the list of origins allowed to connect, or thePIPECAT_ALLOWED_ORIGINSenvironment variable (comma-separated).
FastAPIWebsocketTransport
Configuration
new FastAPIWebsocketTransport(websocket, params, options?). websocket is any object that
implements the FastAPIWebSocket interface; a WebsocketConnection does. params is a
FastAPIWebsocketParams:
Prop
Type
options:
Prop
Type
Usage
Build the transport inside a function that takes the accepted connection. The endpoint helpers below call it once per connection:
import { createTask } from "@pipedog/asyncio";
import { Pipeline } from "@pipedog/core/pipeline/pipeline";
import { PipelineWorker } from "@pipedog/core/pipeline/worker";
import { WorkerRunner } from "@pipedog/core/workers/runner";
import { ProtobufFrameSerializer } from "@pipedog/serializer-protobuf/protobuf";
import {
FastAPIWebsocketParams,
FastAPIWebsocketTransport,
} from "@pipedog/transport-websocket/fastapi";
import { websocketFetchHandler } from "@pipedog/transport-websocket/fetch";
import { honoWebsocket } from "@pipedog/transport-websocket/hono";
import { serveFetch, websocketServe } from "@pipedog/transport-websocket/serve";
import { WebsocketConnection } from "@pipedog/transport-websocket/socket";
/** Runs one bot on an accepted WebSocket connection. */
async function runBot(connection: WebsocketConnection): Promise<void> {
const transport = new FastAPIWebsocketTransport(
connection,
new FastAPIWebsocketParams({
audioInEnabled: true,
audioOutEnabled: true,
serializer: new ProtobufFrameSerializer(),
sessionTimeout: 300,
}),
);
const pipeline = new Pipeline([
transport.input(),
// ... STT, LLM context aggregators, LLM, TTS
transport.output(),
]);
const worker = new PipelineWorker(pipeline);
const runner = new WorkerRunner({ handleSigint: false });
transport.eventHandler("on_client_connected", async () => {
console.log("client connected");
});
transport.eventHandler("on_client_disconnected", async () => {
await runner.cancel();
});
await runner.addWorkers(worker);
await runner.run();
}websocketFetchHandler turns it into a fetch handler,
(request, server?) => Promise<Response>. It answers 426 to a request that is not a WebSocket
upgrade. Cloudflare Workers, Durable Objects and Deno upgrade from the request alone:
// Cloudflare Workers (and Durable Objects) and Deno upgrade the request from fetch.
const handler = websocketFetchHandler(runBot);
const workerEntry = {
fetch(request: Request): Promise<Response> {
return handler(request);
},
};Bun upgrades with its server, so pass the server Bun gives fetch and serve with the
bunWebSocket handlers. serveFetch serves a fetch handler with WebSocket upgrades on Bun, Node
(with the ws package) and Deno, so one entry point runs on all three. upgradeWebSocket(request, { server }), from the same module, is the upgrade on its own, for your own handlers:
// Bun, Node (with the `ws` package) and Deno: serve the same fetch handler, with upgrades.
const fetchServer = await serveFetch(handler, { host: "0.0.0.0", port: 8765 });
// Later: stop listening.
await fetchServer.close();Bun.serve({
fetch: (request, server) => handler(request, server),
websocket: bunWebSocket, // from @pipedog/transport-websocket/fetch
});websocketServe listens on a host and port with the runtime's own server, on Bun, Node and Deno,
and takes the connection handler directly:
// Bun, Node (with the `ws` package) and Deno: listen on a port.
const server = await websocketServe({
handler: runBot,
host: "0.0.0.0",
port: 8765,
origins: ["https://app.example.com"],
});
// Later: stop listening, close the open connections and wait for their handlers.
await server.close();With Hono, honoWebsocket returns the events that Hono's upgradeWebSocket() expects, on any
Hono adapter. Pass the upgrade request's headers so allowedOrigins can be checked:
// Pass the result to Hono's upgradeWebSocket() (hono/bun, hono/deno,
// hono/cloudflare-workers or @hono/node-ws).
const honoEvents = (request: Request) => honoWebsocket(runBot, { headers: request.headers });app.get(
"/ws",
upgradeWebSocket((c) => honoEvents(c.req.raw)),
);A full Hono app, and the same bot mounted in Express, Next.js, SvelteKit and other frameworks, is
in examples/framework-snippets.
See also Frameworks.
Event handlers
| Event | Description |
|---|---|
on_client_connected | The input transport started. Receives the socket. |
on_client_disconnected | The peer closed the socket (not when the transport closes it). Receives the socket. |
on_session_timeout | sessionTimeout seconds passed since the transport started. Receives the socket. |
Notes
- The constructor throws if
allowedOriginsis non-empty and the socket'sOriginheader is missing or not listed. The caller closes the socket in that case;websocketFetchHandler,websocketServeandhonoWebsocketlog the error and close it for you. - When the input starts it pushes a
ClientConnectedFrame, then reads messages and deserializes them withserializer. Without a serializer, incoming messages are dropped and nothing is sent. - Output audio is paced to real time.
fixedAudioPacketSizecuts raw PCM into fixed-size packets, for media endpoints that need strict framing.addWavHeaderwraps each chunk in a WAV header. wsCloseTimeout(default 0.5 seconds) bounds how long a disconnect waits for the peer to acknowledge the close, so a dead peer does not stall shutdown.
SingleClientWebsocketServerTransport
Configuration
new SingleClientWebsocketServerTransport(params, options?). params is a
SingleClientWebsocketServerParams:
Prop
Type
options:
Prop
Type
Usage
import { Pipeline } from "@pipedog/core/pipeline/pipeline";
import { PipelineWorker } from "@pipedog/core/pipeline/worker";
import { WorkerRunner } from "@pipedog/core/workers/runner";
import { ProtobufFrameSerializer } from "@pipedog/serializer-protobuf/protobuf";
import {
SingleClientWebsocketServerParams,
SingleClientWebsocketServerTransport,
} from "@pipedog/transport-websocket/server";
const transport = new SingleClientWebsocketServerTransport(
new SingleClientWebsocketServerParams({
audioInEnabled: true,
audioOutEnabled: true,
serializer: new ProtobufFrameSerializer(),
}),
{ host: "localhost", port: 8765 },
);
transport.eventHandler("on_websocket_ready", async () => {
console.log("listening on ws://localhost:8765");
});
transport.eventHandler("on_client_connected", async () => {
console.log("client connected");
});
const pipeline = new Pipeline([
transport.input(),
// ... STT, LLM context aggregators, LLM, TTS
transport.output(),
]);
const runner = new WorkerRunner();
await runner.addWorkers(new PipelineWorker(pipeline));
await runner.run();Event handlers
| Event | Description |
|---|---|
on_websocket_ready | The server is listening. |
on_client_connected | A client connected. Receives the WebsocketConnection. |
on_client_disconnected | The client disconnected. Receives the WebsocketConnection. |
on_session_timeout | sessionTimeout seconds passed since the client connected. Receives the WebsocketConnection. |
Notes
- One client at a time. While a client is connected, a new connection is closed with code 1013 and the existing client is kept.
- The server starts when the pipeline starts, with
options.serve(defaultwebsocketServe). Pass your ownWebsocketServefunction to listen some other way. - A non-empty
allowedOriginsis passed to the server asorigins; other origins get HTTP 403. - The server closes only after both the input and the output have finished, so output still
being flushed after the
EndFrameis not cut off. - The deprecated
WebsocketServer*aliases are not ported. Use theSingleClientWebsocketServer*names.
WebsocketClientTransport
Configuration
new WebsocketClientTransport(uri, params?). params is a WebsocketClientParams:
Prop
Type
Usage
import { Pipeline } from "@pipedog/core/pipeline/pipeline";
import {
WebsocketClientParams,
WebsocketClientTransport,
} from "@pipedog/transport-websocket/client";
import { RTVIClientTransport } from "@pipedog/transport-websocket/rtvi_client";
// The serializer defaults to ProtobufFrameSerializer.
const client = new WebsocketClientTransport(
"ws://localhost:8765",
new WebsocketClientParams({
audioInEnabled: true,
audioOutEnabled: true,
additionalHeaders: { Authorization: `Bearer ${process.env.BOT_TOKEN ?? ""}` },
}),
);
client.eventHandler("on_connected", async () => {
console.log("connected to the bot");
});
const pipeline = new Pipeline([client.input(), client.output()]);Event handlers
| Event | Description |
|---|---|
on_connected | Connected to the server. Receives the WebsocketConnection. |
on_disconnected | The connection closed. Receives the WebsocketConnection. |
Notes
serializerdefaults toProtobufFrameSerializer.addWavHeaderdefaults totruehere, unlike the server transports.- The connection opens with
websocketConnect, which uses the globalWebSocket, and on Cloudflare Workers opens the socket throughfetchsoadditionalHeadersare sent there too. Browsers can't send handshake headers.
RTVIClientTransport
new RTVIClientTransport(uri, params?), with the same WebsocketClientParams. It connects to a
bot that runs an RTVI server transport, such as an eval harness, and exchanges frames with it.
Usage
// Talks RTVI to a bot: sends client-ready on connect and waits for bot-ready.
const rtvi = new RTVIClientTransport("ws://localhost:7860");
rtvi.eventHandler("on_bot_ready", async () => {
console.log("bot ready:", rtvi.botReady);
});Event handlers
| Event | Description |
|---|---|
on_connected | Connected to the bot. |
on_disconnected | The connection closed. |
on_bot_ready | The bot answered the client-ready message with bot-ready. |
Notes
serializerdefaults toRTVIClientSerializer.addWavHeaderis always set tofalse: an RTVI audio message carries bare PCM.- On connect it sends an RTVI
client-readymessage.botReadyturnstrueoncebot-readyarrives.
WebsocketConnection
The socket the transports talk to. It exposes both APIs the upstream transports use: the
websockets one (state, recv(), send(), close(), async iteration) and the Starlette one
(clientState, applicationState, receive(), sendBytes(), sendText(), headers). Build one
in either of two ways:
WebsocketConnection.fromWebSocket(ws, options)wraps a standardWebSocket: the global client, the server side of a CloudflareWebSocketPair, Deno's upgraded socket or a Nodewssocket.new WebsocketConnection(sink, options)wraps anything else that can send and close. Report what arrives withfeedMessage()andfeedClose(). This is whatbunWebSocketdoes for you; to wire Bun'sServerWebSocketby hand, inside your ownBun.servehandlers:
// Bun's ServerWebSocket reports its events to Bun.serve, so feed them to a
// WebsocketConnection built on a sink.
type SocketData = { connection: WebsocketConnection | null };
Bun.serve<SocketData>({
port: 3000,
fetch(request, bunServer) {
if (bunServer.upgrade(request, { data: { connection: null } })) return undefined;
return new Response("not found", { status: 404 });
},
websocket: {
open(ws) {
const connection = new WebsocketConnection({
send: (message) => ws.send(message),
close: (code, reason) => ws.close(code, reason),
});
ws.data.connection = connection;
createTask(() => runBot(connection), "bun-socket");
},
message(ws, message) {
ws.data.connection?.feedMessage(message);
},
close(ws, code, reason) {
ws.data.connection?.feedClose(code, reason);
},
},
});Options for both:
Prop
Type
Options for websocketServe:
Prop
Type
Runtime notes
| Runtime | Works | What it needs |
|---|---|---|
| Bun | ✅ yes | Nothing. |
| Node.js | ✅ yes | Nothing. |
| Deno | ✅ yes | Nothing. |
| Cloudflare Workers | ✅ yes | Nothing. |
Generated from pipecat.runtimes and pipecat.compat in packages/transport-websocket/package.json: ✅ means the compat smoke test passes on that runtime.
| Bun | Node | Deno | Cloudflare Workers | |
|---|---|---|---|---|
FastAPIWebsocketTransport | yes | yes | yes | yes |
websocketFetchHandler, upgradeWebSocket | yes (pass the server, serve with bunWebSocket) | yes (with serveFetch) | yes (Deno.upgradeWebSocket) | yes (WebSocketPair) |
serveFetch | yes (Bun.serve) | yes (ws peer) | yes (Deno.serve) | no |
websocketServe | yes (Bun.serve) | yes (ws peer) | yes (Deno.serve) | no |
honoWebsocket | yes | yes (@hono/node-ws) | yes | yes |
SingleClientWebsocketServerTransport | yes | yes (ws peer) | yes | no |
WebsocketClientTransport, RTVIClientTransport | yes | yes | yes | yes |
websocketFetchHandlerneeds the upgrading server on Bun (the second argument offetch) and on Node (fromserveFetch); without one it throws, naming the fix.websocketServeandserveFetchthrow on Cloudflare Workers, which can't listen on a port. Closing the server closes the open connections with code 1001 and waits for their handlers.- Both fetch and Hono helpers copy the request headers during the upgrade: on Deno the request is closed once upgraded, and reading it then throws.
- Each connection's handler runs in its own task, and the connection closes when the handler returns or throws.
Not ported yet: the WebsocketServer* deprecated aliases (by design) and the eval harness's
FastAPI transport (pipecat.evals.fastapi_transport).
pipedog is an unofficial community TypeScript port of Pipecat. It is not affiliated with or endorsed by Daily or the Pipecat project.
Portions adapted from the Pipecat documentation, Copyright (c) 2024–2025 Daily, BSD-2-Clause (see apps/docs/LICENSE-pipecat-docs).