ProxDocs

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 proxy option, 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:

PackageClient bundle
libcurl-transport 2.0.5849 KB
epoxy-transport 3.0.1737 KB
bare-transport 1.0.05 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:

InterfaceUsed byepoxylibcurl
proxy-transportsScramjet 2.x, use these^3^2
bare-muxUltraviolet 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-bootstrap fixes 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:

MemberPurpose
readyfalse until init() has finished
init()One-time setup. Callers await it when ready is false
requestOne HTTP request, resolving to a TransferrableResponse
connectOne 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. BareCompatibleClient owns the chain: it loops on 301, 302, 303, 307 and 308 when the caller asked for redirect: "follow", capped at 20 hops, resolving each location against the URL it just requested. Scramjet asks for redirect: "manual" instead, because it rewrites location itself 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 and Sec-Fetch-Site tracking with it.
  • body can be a stream. Returning a fully buffered ArrayBuffer works but holds whole responses in memory, which is noticeable on video.
  • signal is currently always undefined. Both callers in this stack, BareCompatibleClient.fetch and the controller's remote-transport bridge, pass undefined for 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.
  • connect returns synchronously with [send, close], before the socket is open. Queue anything sent before onopen fires.

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.

Profile Views