Transports
A transport is the client-side code that performs a request. The
rewriter hands it "GET https://crllect.dev/, with these
headers" and expects a response back. How it gets one is the transport's
business.
That indirection is the point: the rewriter doesn't care whether the bytes came over wisp, over bare, or from somewhere else entirely.
The interface
Every transport implements the same small interface, defined by
@mercuryworkshop/proxy-transports:
interface ProxyTransport {
ready: boolean;
init(): Promise<void>;
request(
remote: URL,
method: string,
body: BodyInit | null,
headers: RawHeaders,
signal: AbortSignal | undefined
): Promise<TransferrableResponse>;
connect(
url: URL,
protocols: string[],
requestHeaders: RawHeaders,
onopen: (protocol: string, extensions: string) => void,
onmessage: (data: Blob | ArrayBuffer | string) => void,
onclose: (code: number, reason: string) => void,
onerror: (error: string) => void
): [send: Function, close: Function];
}
Two methods: one for HTTP, one for WebSockets. If you can implement those, you can write a transport, and every proxy engine that speaks this interface will work with it.
The three that exist
libcurl
libcurl.js, a build of curl compiled to WebAssembly, running in your browser, with its network layer wired to wisp.
It is curl. That means decades of accumulated correctness about HTTP: redirect edge cases, chunked encoding, content negotiation, cookie handling, HTTP/2, weird server behavior that only shows up on real sites.
- Broad protocol compatibility. It includes curl's handling for redirects, content negotiation, and unusual HTTP behavior.
- Heaviest. It is curl plus Mbed TLS in WebAssembly; the initial load is noticeable.
- Supports an upstream HTTP proxy via a
proxyoption, which epoxy doesn't.
const { default: LibcurlClient } = await import("/libcurl/index.mjs");
const transport = new LibcurlClient({ wisp: "wss://proxy.crllect.dev/wisp/" });
epoxy
epoxy-tls, a TLS and HTTP stack written in Rust, compiled to WebAssembly, also over wisp.
Purpose-built rather than ported, so it is smaller and starts faster. The tradeoff is that it has seen less of the internet's weirdness than curl has, so occasionally a site works under libcurl and not epoxy.
- Lighter and faster to initialise.
- Slightly pickier on unusual servers.
- Exposes wisp-level tuning (
wisp_v2, buffer sizes, redirect limits).
const { default: EpoxyTransport } = await import("/epoxy/index.mjs");
const transport = new EpoxyTransport({ wisp: "wss://proxy.crllect.dev/wisp/" });
bare
The original: it talks to a Bare server over plain HTTP. No WebAssembly, no WebSocket, and no client TLS stack.
- The all-in-one option on request/response hosts.
- Tiny, no startup cost.
- Your server sees all traffic in plaintext.
Get the package name right. The one you want is
@mercuryworkshop/bare-transport. There is an older
@mercuryworkshop/bare-as-module3, still on npm, which implements the bare-mux
interface instead and which Scramjet can't use.
The old name decodes as "the TompHTTP Bare client, packaged as a
bare-mux module, speaking Bare protocol version 3". That 3 is the
protocol version, not a package version, which is why the client talks to
/bare/v3/ and why no bare-as-module or bare-as-module2 ever existed. When
it was rewritten against proxy-transports the "bare-mux module" part stopped
being true, so both the GitHub repository and the npm package were renamed to
bare-transport. The repository is now
MercuryWorkshop/bare-transport;
the old URL still redirects.
A new npm name means a fresh version series, so the live package is 1.0.0 while the dead one sits at 2.2.5. The wrong answer looks newer.
proxy-bootstrap can't wire it either; it ships a stub that throws
"Bare transport not implemented yet". Bare builds use
manual wiring.
The constructor takes the Bare server URL directly, not a { wisp } object like
the other two:
const { default: BareClient } = await import("/baremod/index.mjs");
const transport = new BareClient(new URL("/bare/", location.href));
Theres also a pretty odd one with Brave: searching through Bare spawns a captcha, and captchas through Bare usually fail. Navigating back, then forward again with browser controls causes Brave to stop asking for the rest of the session. Not actually a transport bug, it is bot detection reacting to your server's IP, which is why the same trick does nothing on sites that fingerprint harder. See site compatibility.
Choosing
Deploying everything to a serverless function?
├── Yes → bare, and read the tradeoffs first. It is the only one that
│ works without a WebSocket, and it costs you WebSocket sites
│ plus TLS terminating on your server.
└── No → wisp. Then:
├── Default to libcurl. Best compatibility.
└── Offer epoxy as a user-switchable alternative.
Shipping both Wisp transports gives users a fallback when a site behaves differently between their HTTP/TLS implementations. That is why transport switching is a feature in the builder.
What each one costs to download
The transport is the biggest thing you ship. It isn't close.
Measured off the published packages at the pinned versions, gzipped, the way a browser actually pulls them:
| Package | Client bundle |
|---|---|
libcurl-transport 2.0.5 | 849 KB |
epoxy-transport 3.0.1 | 737 KB |
bare-transport 1.0.0 | 5 KB |
The engine is about 291 KB gzipped on top of that, scramjet.js at 88 plus the
wasm rewriter at 203, and the controller and utils are a rounding error at 10 KB
together. Add it up, in the same KB the table uses: libcurl puts you at about
1,150 KB before you write a single line, epoxy about 1,040 KB, Bare
about 305 KB.
The two big ones are big because they contain a TLS stack. You are asking a browser to do TLS, which is a ridiculous thing to be doing and the entire reason any of this works. Bare is small because your server does that part instead, which is the whole tradeoff in one number.
Shipping both wisp transports doesn't double it, thankfully. The import is lazy, so only the one your user picked gets fetched. And Bare being tiny genuinely matters on a school connection, which is worth putting on the scale next to everything Bare gives up in Wisp vs Bare.
Version compatibility
This trips people up constantly. There are two generations of the transport packages, and they aren't interchangeable:
| Interface | Used by | epoxy | libcurl |
|---|---|---|---|
proxy-transports | Scramjet 2.x, use these | ^3 | ^2 |
bare-mux | Ultraviolet 3.x, and old | ^2 | ^1 |
The new majors also removed the Node-side path helpers:
import { libcurlPath } from "@mercuryworkshop/libcurl-transport";
import { epoxyPath } from "@mercuryworkshop/epoxy-transport";
Those imports work with libcurl 1.x and epoxy 2.x, but throw with libcurl 2.x and epoxy 3.x.
With the newer packages you resolve the directory yourself:
import { createRequire } from "node:module";
import path from "node:path";
const require = createRequire(import.meta.url);
const libcurlDist = path.dirname(
require.resolve("@mercuryworkshop/libcurl-transport")
);
app.use("/libcurl/", express.static(libcurlDist));
See Version matrix and Breaking changes.
Two module formats
Both transports ship as UMD (dist/index.js, attaching
window.LibcurlTransport / window.EpoxyTransport) and as ESM
(dist/index.mjs, with a default export).
Prefer the ESM build with dynamic import(). It is unambiguous, and it means a
transport is only downloaded when selected:
const transportModules = {
libcurl: "/libcurl/index.mjs",
epoxy: "/epoxy/index.mjs"
};
const buildTransport = async (kind, wispUrl) => {
const { default: Transport } = await import(transportModules[kind]);
return new Transport({ wisp: wispUrl });
};
Switching at runtime
With Scramjet 2.x, hand the controller a new instance:
controller.setTransport(await buildTransport("epoxy", wispUrl));
Existing frames keep their DOM and their loaded pages; their next request goes over the new transport. To have the current page re-fetched, reload it.
Older proxies did this through bare-mux, which held one transport in a SharedWorker and named modules by path rather than passing objects. If you are reading code that calls
connection.setTransport("/epoxy/index.mjs", [...]), that is what you are looking at. See bare-mux and proxy-transports.
Bootstrap cannot do this.
@mercuryworkshop/proxy-bootstrapfixes the transport at server start and only serves that one client, so runtime switching is unavailable. Use manual wiring if you want it, the builder enforces this for you.
Switch only when the choice actually changed
Every setTransport call costs a transport. A new LibcurlClient is a fresh
curl-in-WebAssembly instance that opens its own wisp connection; the one it
replaces stays alive until it is garbage collected. Do that on each navigation,
or from a settings listener that fires for every field, and a browser that looks
idle is holding several WebAssembly clients and several sockets open.
The fix is to make the swap idempotent inside the engine rather than filtering at each call site. Resolve the config to the values the transport is actually built from, compare against the last applied set, and return early when nothing moved:
let activeTransport = "";
const resolveTransport = config => ({
path: transportModules[config.kind] ?? transportModules.libcurl,
wisp: config.wisp || defaultWispUrl()
});
const buildTransport = async config => {
const { path, wisp } = resolveTransport(config);
const { default: Transport } = await import(path);
activeTransport = JSON.stringify([path, wisp]);
return new Transport({ wisp });
};
const applyTransport = async config => {
const { path, wisp } = resolveTransport(config);
if (JSON.stringify([path, wisp]) === activeTransport) return;
controller.setTransport(await buildTransport(config));
};
Compare the resolved values, not the raw config. A blank wisp setting and an
explicit wss://this-host/wisp/ are the same endpoint, and treating them as
different rebuilds the transport on the first save after boot for no reason.
The same guard belongs in front of connection.setTransport() on bare-mux. It
is cheaper there, because the SharedWorker owns the connection, but a redundant
call still tears down and re-establishes it for every tab at once.
Seed the transport before boot, not after
A saved transport choice has to reach the engine before it constructs its first client. This ordering builds two:
await engine.init();
await engine.setTransport({ kind: settings.get("transport") });
init() builds the default transport, then setTransport throws it away and
builds the saved one. Any frame created in between is on the wrong transport.
Record the choice first and let boot consume it:
void engine.setTransport({ kind: settings.get("transport") });
await engine.init();
That requires setTransport to be callable before init(), it stores the
config, and only swaps when a controller already exists.
Writing your own
WOULD NOT RECOMMEND UNLESS YOU KNOW WHAT YOU ARE DOING
There are little reasons for building your own transport, but some common ones are: routing through infrastructure you already have, a different tunnel protocol, or instrumentation such as logging, metrics, and request rewriting.
Before you start, know what the hard part is. It isn't the interface, which is two methods. It is HTTP correctness: redirects, chunked encoding, content negotiation, and header edge cases are where naive implementations break on real sites. libcurl exists precisely because that is a lot of work.
The contract
Four members, from @mercuryworkshop/proxy-transports:
| Member | Purpose |
|---|---|
ready | false until init() has finished |
init() | One-time setup. Callers await it when ready is false |
request | One HTTP request, resolving to a TransferrableResponse |
connect | One WebSocket, returning [send, close] |
request resolves to a plain object rather than a Response, because it may
have to cross a postMessage boundary:
type TransferrableResponse = {
body: ReadableStream | ArrayBuffer | Blob | string;
headers: [string, string][];
status: number;
statusText: string;
};
Headers are [name, value] pairs, not a Headers object, for the same reason.
An instrumenting wrapper
The most useful custom transport is usually not a new one. It is a wrapper that delegates to a real transport and does something on the way past:
class LoggingTransport {
#inner;
constructor(inner) {
this.#inner = inner;
}
get ready() {
return this.#inner.ready;
}
init() {
return this.#inner.init();
}
async request(remote, method, body, headers, signal) {
const started = performance.now();
const response = await this.#inner.request(
remote,
method,
body,
headers,
signal
);
console.log(
method,
remote.href,
response.status,
`${Math.round(performance.now() - started)}ms`
);
return response;
}
connect(
url,
protocols,
requestHeaders,
onopen,
onmessage,
onclose,
onerror
) {
return this.#inner.connect(
url,
protocols,
requestHeaders,
onopen,
onmessage,
onclose,
onerror
);
}
}
Hand it over the same way as any other transport:
const { default: LibcurlClient } = await import("/libcurl/index.mjs");
const transport = new LoggingTransport(new LibcurlClient({ wisp: wispUrl }));
const controller = new Controller({ serviceworker, transport });
ready has to be a getter rather than a copied boolean, or it goes stale the
moment the inner transport finishes initialising.
Writing one from scratch
If you are implementing the network layer yourself rather than wrapping one, the parts that catch people out:
- Do not follow redirects. Return the 3xx as-is, headers included.
BareCompatibleClientowns the chain: it loops on 301, 302, 303, 307 and 308 when the caller asked forredirect: "follow", capped at 20 hops, resolving eachlocationagainst the URL it just requested. Scramjet asks forredirect: "manual"instead, because it rewriteslocationitself so the browser re-enters the proxy on the next hop. A transport that quietly follows redirects breaks that, and takes Scramjet's cross-site andSec-Fetch-Sitetracking with it. bodycan be a stream. Returning a fully bufferedArrayBufferworks but holds whole responses in memory, which is noticeable on video.signalis currently alwaysundefined. Both callers in this stack,BareCompatibleClient.fetchand the controller's remote-transport bridge, passundefinedfor it. Honour it if you get one, but don't build anything on the assumption that a closed frame aborts your requests, because today it doesn't.connectreturns synchronously with[send, close], before the socket is open. Queue anything sent beforeonopenfires.
For a reference implementation at a readable size, the transports in
@mercuryworkshop/proxy-transports
are the ones to read. Prefer wrapping over rewriting unless you genuinely need a
different tunnel.
Source: docs/concepts/transports.md
Verified against Scramjet 2.0.67-alpha.2 and controller 0.0.14 on 2026-08-04. If this page and upstream disagree, upstream is right.