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; serverless functions do not, though a static host may serve the client while Wisp runs elsewhere. See Deployment.
Generate a project
node builder/cli.js --out ./my-proxy --preset minimal
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.
Every package is a normal dependency in package.json, resolved by
npm install and pinned in your lockfile. Nothing is fetched at runtime.
What you got
Eleven 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
app.use((_req, res, next) => {
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
next();
});
const dirOf = specifier => path.dirname(require.resolve(specifier));
app.use("/scram/", express.static(scramjetPath));
app.use("/utils/", express.static(dirOf("@mercuryworkshop/scramjet-utils")));
app.use(
"/controller/",
express.static(dirOf("@mercuryworkshop/scramjet-controller"))
);
app.use(
"/libcurl/",
express.static(dirOf("@mercuryworkshop/libcurl-transport"))
);
app.use(express.static("public"));
server.on("upgrade", (req, socket, head) => {
if (new URL(req.url, `http://${req.headers.host}`).pathname === "/wisp/") {
wisp.routeRequest(req, socket, head);
return;
}
socket.end();
});
Three things are happening:
- Four static mounts. Scramjet's bundle and wasm, the controller, utils,
and the transport, each served straight out of
node_modules. That is the entire server side of a proxy. See Wiring Scramjet. - The COOP/COEP headers. Keep them. The engine itself runs without them,
but they are what makes your page cross-origin isolated, and Scramjet passes
that isolation on to every site you proxy. Remove them and any proxied site
needing
SharedArrayBufferfails from inside its own bundle. See Cross-origin isolation. - The upgrade handler runs the wisp endpoint. The WebSocket that carries all real traffic.
public/js/engine.js
const controller = new api.Controller({
serviceworker,
transport: new LibcurlClient({ wisp: wispUrl }),
config: {
scramjetPath: "/scram/scramjet.js",
wasmPath: "/scram/scramjet.wasm",
injectPath: "/controller/controller.inject.js"
}
});
await controller.wait();
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");
The generated file also registers the service worker and loads the three runtime scripts before this runs. Wiring Scramjet walks through that boot sequence line by line.
What the three plugins do:
HttpCachePlugincaches subresources so a reload does not pull every asset back through the tunnel.UrlWatcherPluginis the only reliable way to learn where the page went. Scramjet 2.x has nourlchangeevent. It fires for real navigations, hash changes, andhistory.pushState.CatchEscapedLinksPlugincatcheswindow.openandtarget="_blank", which would otherwise escape the proxy entirely.
Five plugins ship with scramjet-utils, and you can write your own against the
same hooks. Plugins and hooks documents the
full surface.
frame.go()is synchronous. It rewrites the URL and assignsiframe.src. Awaiting it does nothing useful. The real "it loaded" signal is theUrlWatcherPlugincallback. Some examples in the wildawaitit, which is harmless but misleading.
Adding features
The minimal 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 and transport switching, and moves you to TypeScript, Vite and Tailwind. The server is the same four static mounts.
Or pick features individually:
node builder/cli.js --out ./my-proxy \
--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 aren't on https:// or
localhost.
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. Unfamiliar terminology: Glossary.
Next
- How a proxy works. What those four files do
- Multiple tabs. The first feature most people want
- Deployment. Getting it online, with HTTPS
Source: docs/guides/quickstart.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.