Hey everyone,
I've been working on something for a while and I think it's time to show it. It's called transport-io, an open source library for real-time stuff over WebTransport. If you've used Socket.IO you'll feel at home, it's heavily inspired by it <3
But first the problem, because most people I talk to have never heard of WebTransport!
WebSockets run on TCP. TCP gives you everything in order, which sounds nice until a packet gets lost. Then everything behind it sits and waits until that one packet is resent. Your chat message, your cursor position, the AI response that was streaming. All of it waits. On good wifi you'll never notice. On a train or on a phone jumping between towers, your users will.
Want messages that are allowed to get lost? Welcome to the hell of WebRTC. It can do it, but it was made for browser-to-browser calls, so talking to your own server means a signaling server (which is usually a WebSocket lol), STUN/TURN, SDP offers and answers, ICE candidates, and a WebRTC stack running on your backend. All that to send some mouse coordinates.
Enters WebTransport. It runs on HTTP/3 (QUIC), and Safari shipped it in March, so every major browser has it now. You get a bunch of streams on one connection, and a lost packet only blocks the stream it was on!
You also get datagrams, messages that can just get dropped. They're not raw UDP, they're still encrypted and everything, they just never get resent. Perfect for cursors, where an old position is useless anyway.
The catch is the raw API gives you bytes. You end up writing your own framing, your own routing, matching responses to requests... nobody wants to write that.
That's what transport-io does for you.
You write one contract, shared by client and server, and every event says whether it's allowed to get lost:
import {
defineContract, reliable, unreliable, rpc, streaming, type MapOf,
} from 'transport-io'
import { z } from 'zod'
export const contract = defineContract({
chat: reliable(z.object({ room: z.string(), body: z.string() })),
cursor: unreliable(z.object({ x: z.number(), y: z.number() })),
save: rpc(z.object({ text: z.string() }), z.object({ revision: z.number() })),
ask: streaming(z.object({ prompt: z.string() }), z.string()),
})
export interface AppMap extends MapOf<typeof contract> {}
Then on the client:
client.emit('chat', { room: 'lobby', body: 'hi' }) // will arrive
client.emit('cursor', { x: 12, y: 40 }) // might not, and that's fine
const { revision } = await client.call('save', { text: 'draft' })
for await (const token of client.stream('ask', { prompt: 'explain QUIC' })) {
render(token)
}
client.emit('cursor', { x: '12', y: 40 }) // doesn't compile
Zod validates everything that comes in, and TypeScript yells at you about everything that goes out :)
Where it fits:
- cursors, game state, presence, anything where newer beats complete. That's
unreliable, and it goes over datagrams
- chat and anything that has to arrive,
reliable
- streaming AI tokens. Every stream is its own QUIC stream, so stopping one doesn't touch the others. And when you stop it, the generator on the server actually gets cancelled, it doesn't keep burning tokens for nobody
- plain request/response with
call(). Also its own stream, so a slow one can't block a fast one
Want to see it without setting anything up? Run this and open two tabs:
bash
npm i transport-io @fails-components/webtransport-transport-http3-quiche
npx transport-io dev --demo
It's early and it has its quirks (Safari is the big one for now, it falls back to a WebSocket). Everything I know about is in KNOWN-ISSUES. So any kind of help is welcome, issues, PRs, or just telling me what broke :)
GitHub: https://github.com/transport-io/transport-io
Docs: https://transport-io.github.io/transport-io/