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 does not 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 behaviour 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 does not.
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 cannot 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 cannot 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).href);
Theres also a pretty odd bug, where searching something with brave causes a captcha to spawn. Bare usually fails with captchas, but navigating back, then forward again with browser controls causes brave to not ask for captchas anymore.
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.
Version compatibility
This trips people up constantly. There are two generations of the transport packages, and they are not 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 is not 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:
- Redirects are yours to follow. Nothing above you does it. Cap the chain,
20 is the conventional limit, and resolve each
locationagainst the URL you just requested rather than the original. bodycan be a stream. Returning a fully bufferedArrayBufferworks but holds whole responses in memory, which is noticeable on video.signalmust actually abort. Frames are destroyed while requests are in flight, and ignoring it leaks a request per closed tab.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, controller 0.0.14, and Ultraviolet undefined on 2026-08-02. If this page and upstream disagree, upstream is right.