ProxDocs

Quickstart

A working Scramjet proxy, from nothing, in about two minutes.

Requirements

  • Node 22.13 or newer to run this repository's builder. Check with node -v.
  • A host that can hold a WebSocket open. Your own machine qualifies; Vercel Functions do not, though Vercel may serve the client while Wisp runs elsewhere. See Deployment.

Generate a project

node builder/cli.js --out ./my-proxy --preset barebones

Or start the documentation site and use the /build route to download the zip.

cd my-proxy
npm install
npm start

Open the generated app's / route on its configured port, type an address, and press enter. That is the whole thing.

The first npm start takes a few seconds longer than later ones. proxy-bootstrap resolves and caches its runtime package set on first run. Those versions are not part of the generated project's lockfile.


What you got

Ten files. The interesting ones:

server.js               static files + the wisp endpoint
public/index.html       the shell
public/js/engine.js     the only file that talks to Scramjet
public/js/app.js        DOM wiring
public/js/url.js        address-bar input -> URL

server.js

const { routeRequest, routeUpgrade } = await bootstrap({
	transport: "libcurl"
});

app.use((_req, res, next) => {
	res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
	res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
	next();
});

app.use((req, res, next) => {
	if (routeRequest(req, res)) return;
	next();
});
app.use(express.static("public"));

server.on("upgrade", routeUpgrade);

Three things are happening:

  1. bootstrap() downloads the Scramjet packages and returns two routers. It serves the Scramjet bundle, the wasm rewriter, the controller, scramjet-utils, the transport client, /sw.js, and /bootstrap-init.js.
  2. The COOP/COEP headers are not optional. Scramjet's rewriter needs SharedArrayBuffer, which browsers withhold from pages that are not cross-origin isolated. Remove them and the app loads and then quietly fails. See Cross-origin isolation.
  3. routeUpgrade runs the wisp endpoint. The WebSocket that carries all real traffic.

public/js/engine.js

controller = await initBootstrap();

const frame = controller.createFrame(iframeElement, {
	plugins: [
		new utils.HttpCachePlugin(),
		new utils.UrlWatcherPlugin(url => console.log(url)),
		new utils.CatchEscapedLinksPlugin(url => new URL(location.href))
	]
});

frame.go("https://crllect.dev");

initBootstrap() is a global from /bootstrap-init.js. It registers the service worker, loads every bundle, builds the transport, and hands back a ready Controller.

The plugins are worth knowing:

  • HttpCachePlugin caches subresources so a reload does not pull every asset back through the tunnel.
  • UrlWatcherPlugin is the only reliable way to learn where the page went. Scramjet 2.x has no urlchange event. It fires for real navigations, hash changes, and history.pushState.
  • CatchEscapedLinksPlugin catches window.open and target="_blank", which would otherwise escape the proxy entirely.

frame.go() is synchronous. It rewrites the URL and assigns iframe.src. Awaiting it does nothing useful. The real "it loaded" signal is the UrlWatcherPlugin callback. Some examples in the wild await it, which is harmless but misleading.


Adding features

The barebones build has no tabs, no settings, no history, on purpose. It is meant to be read in one sitting.

When you want more, regenerate:

node builder/cli.js --out ./my-proxy --preset standard --force

That gives you tabs, settings, transport switching, history and bookmarks, and switches to manual wiring: you install the Scramjet packages and mount them as static directories instead of letting bootstrap fetch them at runtime.

That switch is required for transport switching, because bootstrap only serves the single transport client it was configured with. The builder enforces it rather than letting you generate something that silently cannot work.

Or pick features individually:

node builder/cli.js --out ./my-proxy \
    --engine scramjet --wiring manual \
    --features tabs,settings,transportSwitch,history

If it does not work

Run this in the browser console first:

crossOriginIsolated;

false means the headers are missing or you are not on https:// or localhost. That is the single most common failure.

Then check whether the service worker took control:

navigator.serviceWorker.controller?.scriptURL;

undefined means it has not claimed the page yet, reload once. If it stays undefined, look for registration errors in the console.

Full list: Troubleshooting.


Next

Profile Views