Move Request, Response, and async iterators over any byte channel —
MessagePort, WebSocket, ServiceWorker, in-process pipe, real HTTP —
with the same handler code on both ends.
webrun-wire is a pnpm workspace that builds up, layer by layer, the
ability to write ordinary (Request) ⇒ Response handlers and RPC
service objects and run them anywhere bytes can flow. The "server" can
live in the same tab, in a sibling tab, inside a relay iframe, behind a
MessagePort, over a WebSocket, or on a real HTTP endpoint — callers use
standard fetch() and don't know the difference.
The web platform gives browsers everything they need to be an HTTP
server: Request, Response, ReadableStream, ServiceWorker. What's
missing from the raw APIs is:
- A portable wire format so you can move HTTP semantics over any byte channel (MessagePort, WebSocket, IPC, in-memory).
- ServiceWorker plumbing — URL routing, MessageChannel wiring, recovery after SW restarts — and a way to use a SW from a page that isn't on the SW's origin.
- Stream primitives (backpressure-aware iterators, WHATWG ReadableStream ↔ async iterator) shared across all the above without duplication.
- A service-RPC layer that takes a plain object and exposes its methods as HTTP endpoints — same code running over real HTTP, an in-browser SW, a MessagePort, or a WebSocket.
This workspace solves all four as small, composable packages, each
publishable on its own and each carrying zero runtime dependencies
beyond other @statewalker/webrun-* packages in the same workspace.
- In-browser full-stack prototypes — back-end and client live in the same page, no external services to start.
- Notebook / Observable / unpkg demos — ship a working app where the reader doesn't have to install anything.
- Local-disk or OPFS servers — expose File System Access API content
as a plain HTTP site you can
<iframe>orfetch(). - Offline-first apps — your back-end is literally a JS function; it works without network.
- WebSocket-backed services — write ordinary HTTP handlers, run them over a persistent socket.
- Portable handlers — the same async
(Request) ⇒ Responsefunction runs here today and in Deno / Cloudflare Workers / Node tomorrow.
webrun-streams (foundation — iterator + stream + error + text/jsonl/lines primitives)
webrun-msgpack (foundation — length-prefixed MessagePack frame codec)
▲
├── webrun-ports (MessagePort RPC)
│ ▲
│ └── webrun-ports-ws (WebSocket ↔ MessagePort bridge)
│
├── webrun-http (Request/Response over any byte channel)
│ ▲
│ ├── webrun-http-browser (ServiceWorker hosting, relay mode)
│ └── webrun-rpc-http (service-RPC on top of webrun-http)
│
├── webrun-site-builder (files + endpoints + auth → (Request)⇒Response)
│ ▲
│ └── webrun-site-host (SiteBuilder + SwHttpAdapter wired up in one call)
│ (peer: @statewalker/webrun-files for the FilesApi interface)
│
└── (all of the above use webrun-streams for chunks + errors;
scanners / chat pipelines additionally use webrun-msgpack for framing)
Every arrow is a workspace:* dep. Nothing deeper than
webrun-streams has runtime dependencies outside this repo except
webrun-http-browser, which pulls in idb-keyval (≈1 KB) to survive
SW restarts.
Async-iterator and ReadableStream primitives:
collect/collectBytes/collectString— drain an async iterable into an array /Uint8Array/string(zero-copy when possible).encodeText/decodeText— UTF-8string↔Uint8Arraystreams.splitLines/joinLines— line splitting overstringstreams (cross-chunk safe) and reverse.encodeJsonl/decodeJsonl— JSON values ↔\n-delimited string stream.map— stream-map over anAsyncIterable<T>.newAsyncGenerator— backpressure-aware queue generator that turns imperativenext/donecallbacks into an async generator.sendIterator/recieveIterator— a{done, value, error}chunk protocol for shipping an async iterator across any transport.toReadableStream/fromReadableStream— one-way converters betweenAsyncIterator<Uint8Array>andReadableStream<Uint8Array>.serializeError/deserializeError— preserveErrorstack and custom fields across JSON / structured-clone boundaries.
Zero runtime deps. Every other package in the workspace depends on it.
Length-prefixed MessagePack frame codec for async iterables:
encodeMsgpack/decodeMsgpack— stream arbitrary values as[4-byte BE length][msgpack payload]frames; decoder buffers across chunk boundaries and never yields a partial trailing frame.encodeFloat32Arrays/decodeFloat32Arrays— zero-copy specialisation forFloat32Arraystreams (the msgpackbinpayload is reinterpreted as floats).
One runtime dep: @ygoe/msgpack. Used by downstream scanners and chat pipelines for value framing over any byte transport.
MessagePort utilities — request/response, streaming, bidirectional calls
— multiplexed over a single MessagePort via a channelName tag.
callPort/listenPort— request/response with timeout.send/recieve— async-iterator streams.ioSend/ioHandle— bidirectional half-duplex primitives.callBidi/listenBidi— high-level full-duplex streaming calls.
Zero runtime dependencies. The narrow-waist transport any higher-level MessagePort protocol can build on.
WebSocket ↔ MessagePort bridge. Wire a WebSocket to a
MessagePort with bindWebSocketToPort(ws, port) and every helper in
webrun-ports (request/response, streaming, bidi) runs unchanged.
Transport-neutral: JSON text frames, binary as transferable
ArrayBuffer, idempotent cleanup, works with browser WebSocket or
Node's ws package. No RPC layer,
no new wire format.
Zero runtime dependencies.
Transport-agnostic Request / Response streaming over async
iterators. Two layers:
- Stubs —
newHttpClientStub/newHttpServerStub(de)serialise HTTP envelopes against any(envelope) ⇒ envelopetransport you provide. - Pipes —
newHttpServer/newHttpClientgive you a server that isAsyncIterable<Uint8Array> ⇒ AsyncIterable<Uint8Array>, and a client that wires aRequestthrough such a pipe.
Plus HttpError, and toReadableStream / fromReadableStream helpers
re-exported from webrun-streams.
Zero runtime dependencies. Peers on standard Request / Response /
ReadableStream / TextEncoder / TextDecoder.
ServiceWorker-based HTTP server that runs entirely in the browser.
Register handlers in JavaScript, call them with standard fetch() /
Request / Response.
Two operating modes:
- Same-origin (
.../swsubpath) — your app registers its own SW next to its pages and mounts handlers under<scope>/<key>/…. - Relay (main entry) — a SW running at a shared relay origin handles requests for any page that embeds a hidden relay iframe. Cross-origin friendly; works from notebooks, Observable, unpkg, third-party hosts.
See
packages/webrun-http-browser/README.md
for architecture, public API, design notes, constraints, and runnable
demos (Hono-routed dynamic site and a File System Access API browser).
HTTP-based service RPC. Expose plain object methods as a standard
(Request) ⇒ Response handler; call them from anywhere with fetch:
newRpcServer(services, {path?})→ a webrun-http handler that routesGET /,GET /{service},GET|POST /{service}/{method}into method calls.newRpcClient({baseUrl, fetch?})→{ loadService<T>(name) }with lazy descriptor caching; typed method proxies round-trip throughfetch.
Because the server is a webrun-http handler and the client takes an
injectable fetch, the same RPC code runs unchanged over real HTTP, an
in-browser ServiceWorker, a MessagePort bridge, or a WebSocket — wire it
to whichever transport fits the deployment.
Depends on @statewalker/webrun-streams for error serialization.
Compose a (Request) ⇒ Response site from three ingredients:
static files mounted from any FilesApi (memory / Node FS / S3 /
browser FSAA / composite), dynamic endpoints with URLPattern-based
routing, and pluggable auth hooks (ships with an HTTP basic-auth
factory):
new SiteBuilder()
.setFiles("/", files)
.setAuth("/admin/*", newBasicAuth({ tom: "!jerry!" }))
.setEndpoint("/api/todo/:id", "GET", handler)
.build(); // ⇒ (Request) ⇒ ResponseThe builder is deliberately framework-free: URLPattern for routing,
a small MIME map, Range/HEAD support driven by
FilesApi.stats() + read({start, length}). Zero runtime deps
beyond a peer @statewalker/webrun-files.
One-call in-browser hosting for a webrun-site-builder site.
HostedSiteBuilder wraps SiteBuilder + SwHttpAdapter into a
single fluent API — you register files, endpoints, and auth hooks the
same way, and .build() takes care of the SW registration, URL
rewriting, and routing under a site key:
const site = await new HostedSiteBuilder()
.setSiteKey("demo")
.setFiles("/client", clientFiles)
.setFiles("/server", serverFiles)
.setServerRunner("/api", "/server/api/index.js")
.build();
// site.baseUrl → http://localhost:5173/demo/
// site.stop() unhooks the handlersetServerRunner(pattern, modulePath) inlines the common pattern of
"the /api endpoint is a JS module served by my own site" — the
builder generates a dynamic-import endpoint under the hood.
| Demo | Path | What it shows |
|---|---|---|
| site-builder-demo | apps/site-builder-demo |
Vite + TypeScript app; HostedSiteBuilder mounts a full site (static client + /api dynamic-import endpoint + iframe preview) in ~40 lines. Highest-level wrapping; server-side code is a JS file served by the site itself. |
| Hono dynamic site | packages/webrun-http-browser/demo/demo-1.html |
A Hono router running in the browser as the back-end for a relay-SW-hosted site. Demonstrates relay mode + full-framework compatibility. |
| Local-disk file server | packages/webrun-http-browser/demo/demo-2.html |
User picks a folder via showDirectoryPicker; the relay SW exposes its contents as a browsable in-browser HTTP site. ~20-line handler. |
| Minimal same-origin SW | packages/webrun-http-browser/public/index.html |
The unwrapped SwHttpAdapter pattern, ~40 lines of inline JS. Good baseline for debugging the SW lifecycle. |
Each demo has a "Why it's interesting" blurb in its neighbouring README or inside the relevant package README.
The packages are designed to compose into end-to-end stacks. A few concrete combinations:
| Use case | Stack |
|---|---|
In-browser service RPC with offline-capable fetch() |
webrun-rpc-http + webrun-http-browser (same-origin mode) + webrun-http |
| Cross-origin RPC from an embed (Observable, unpkg) | webrun-rpc-http + webrun-http-browser (relay mode) + webrun-http |
| Static site + dynamic API + auth, served from anywhere | webrun-site-builder + any FilesApi + a transport of your choice |
| In-browser static site + dynamic API with zero SW boilerplate | webrun-site-host — wraps the builder + the SW adapter in one .build() call |
| Node ↔ browser RPC over a WebSocket | webrun-ports + webrun-ports-ws on each end; optionally pipe webrun-http through for Request/Response semantics |
| Unit tests for an RPC service | webrun-rpc-http with fetch: (req) => handler(req) — no network at all |
| Deploying the same handler to a real edge runtime | webrun-rpc-http handler drops straight into Deno / Cloudflare Workers / Bun |
pnpm install
pnpm test # turbo runs `test` in every package
pnpm run build # turbo runs `build` in every package
pnpm lint # biome check .
pnpm format:fix # biome check --write --unsafe .Tooling: pnpm workspace, turborepo, biome, vitest, rolldown, TypeScript. No eslint / prettier / rollup / mocha.
Every package emits a single ESM bundle at dist/index.js with zero
bare import specifiers surviving into the output (workspace deps are
inlined). Packages load cleanly from a static host without an import
map or extra bundler on the consumer side.
The browser package additionally ships IIFE bundles for its SW
runtimes — loadable via classic importScripts(...).
Via Changesets.
MIT © statewalker