Skip to content

transport-io

One connection. Independent streams and unreliable datagrams. Choose reliability per message.

Getting startedRead the limitations first

Two panels streaming tokens over one session. The left panel is stopped at 61 tokens; the right is still streaming at 93 tokens and shows +41 since agent-a stopped. The header reads open streams 1.
Two streams on one session, one of them stopped, the other not noticing. Each stream() is its own QUIC stream, so the stop is a reset on one of them and costs the other nothing.

One file says what every event is and whether it can be dropped. Both ends import it.

contract.ts
import { defineContract, type MapOf, reliable, unreliable } from 'transport-io'
export const contract = defineContract({
chat: reliable<{ from: string; body: string }>(),
cursor: unreliable<{ x: number; y: number }>(),
})
export interface AppMap extends MapOf<typeof contract> {}
server.ts
import { createServer } from 'transport-io'
import { listenDev } from 'transport-io/node-transport'
const server = createServer<AppMap>({ contract })
server.onSession((peer) => {
void peer.join('lobby')
peer.on('chat', (m) => void server.to('lobby').emit('chat', m))
})
await server.listen(await listenDev())
client.ts
import { devClient } from 'transport-io/dev-transport'
const client = await devClient<AppMap>({ contract })
client.on('chat', (m) => console.log(m.from, m.body))
client.emit('chat', { from: 'me', body: 'hi' }) // arrives
client.emit('cursor', { x: 12, y: 40 }) // may not

chat arrives. cursor may not. The contract is the only place that says so, and both sides infer from it: a wrong event name or payload does not compile.

AppMap is passed once on each end, the way you would pass a router type to a typed client.

The server yields, the client consumes, and the loop ends when the server stops. Stopping early is a QUIC stream reset: the handler’s ctx.signal fires and its finally runs.

client.ts
import { type Client, defineContract, type MapOf, streaming } from 'transport-io'
const gen = defineContract({
ask: streaming<{ prompt: string }, string>(),
})
interface GenMap extends MapOf<typeof gen> {}
declare const gpt: Client<GenMap>
declare const prompt: string
for await (const token of gpt.stream('ask', { prompt })) {
console.log(token)
}
server.ts
import type { Server } from 'transport-io'
declare const gptServer: Server<GenMap>
declare function model(text: string): AsyncIterable<string>
gptServer.handle('ask', async function* ({ prompt }) {
for await (const token of model(prompt)) {
yield token
}
})

Each stream() and each call() owns its own QUIC stream, so a stalled one does not block the others.

Run it locally in one command: npx transport-io dev —demo, then open the printed URL in two tabs. There is no live demo on this page: the server needs raw UDP ingress, which GitHub Pages does not have.

These are properties of the library, not things it will grow out of.

  • Chrome and Firefox over WebTransport. Safari establishes a session and then never sends; the fallback reaches it after 5 seconds.
  • One fallback, emits only. A WebSocket carries the emit lane where the contract declares it; calls and streams need WebTransport.
  • It needs raw UDP ingress to your process. Many managed platforms do not provide it. Check yours before building on this.
  • 0.x, so a minor bump may break you. A minor release is allowed to change the wire, so two peers on different minors may refuse each other. Pin the minor.

Read the full list.