ProxDocs

Inside Scramjet

Everything else on this site treats Scramjet as a dependency. This page treats it as a codebase: what the packages are, where a given behaviour lives, how to build it, and how to run its tests.

Read it if you are about to file a bug with a diagnosis attached, patch something locally, or contribute upstream. If you are building a proxy, you do not need any of this.

Verified against MercuryWorkshop/scramjet at 2.0.67-alpha.2. Upstream moves fast, and when this page and the repository disagree, the repository wins.


The repository

Scramjet is a pnpm workspace. Not npm, not bun: the root package.json runs only-allow pnpm in preinstall, so the other two abort on install.

git clone https://github.com/MercuryWorkshop/scramjet
cd scramjet
pnpm install
cd packages/core && pnpm rewriter:build && pnpm build && cd ../..
pnpm dev

pnpm install on its own is not enough, and pnpm dev will not run after it. rspack.config.ts reads packages/core/dist/scramjet.wasm while the config itself is evaluating, and that file is not in the repository, so every build and the dev server fail on a fresh clone until the rewriter has been built once. That is the step above, and it needs the Rust toolchain described in the next section.

pnpm dev starts the development server in devserver.ts: Vite for the demo page, rspack in watch mode for the bundles, and a wisp-js server in the same process, so one command gives you a working proxy pointed at your own build. It prints the commit and branch it built from, which is worth quoting in a bug report.

PackagePublishes asWhat lives there
packages/core@mercuryworkshop/scramjetRewriter, client, fetch pipeline
packages/controller@mercuryworkshop/scramjet-controllerWindow API, service worker, inject
packages/utils@mercuryworkshop/scramjet-utilsThe five shipped plugins
packages/rpc@mercuryworkshop/rpcThe typed postMessage layer
packages/bootstrap@mercuryworkshop/proxy-bootstrapThe one-call wiring path
packages/create-proxy-appcreate-proxy-appThe upstream scaffolder
packages/demo@mercuryworkshop/scramjet-demoThe page pnpm dev serves
packages/runwayworkspace onlyThe browser test harness

The three published runtime packages are versioned together and assert each other's versions at load; see version guards.


Where behaviour lives

The question this page exists to answer is "I am seeing X, which file do I open".

You are looking atOpen
A flag, or what a flag defaults tocore/src/types.ts, core/src/index.ts
A URL that encoded wrongcore/src/shared/rewriters/url.ts
A cookie that did not stickcore/src/shared/cookie.ts
Headers added or strippedcore/src/shared/headers.ts, core/src/fetch/
A request that took the wrong pathcore/src/fetch/fetch.ts, core/src/fetch/parse.ts
A patched global misbehaving on a sitecore/src/client/
HTML that came out wrongcore/src/shared/htmlRules.ts, the rewriter
JavaScript that came out wrongcore/rewriter/ (Rust)
Something the service worker didcontroller/src/sw.ts
Something the page side didcontroller/src/index.ts
A proxied document's bootstrapcontroller/src/inject.ts
A shipped pluginutils/src/

core/src/shared/snapshot.ts is the file that surprises people. Scramjet captures native functions (_URL, _Map, JSON_parse, Promise_all) at load and uses those captures everywhere, because a proxied page is free to replace URL or Array.prototype.map with something hostile. Code that calls the live global instead of the snapshot is a bug, and there is an ESLint rule (scramjet-core/no-globals) that says so.


The rewriter is Rust

The part that rewrites JavaScript is not JavaScript. packages/core/rewriter/ is a Cargo workspace built on oxc, compiled to wasm32-unknown-unknown and shipped as scramjet.wasm.

CrateJob
transformThe rewrite rules over the oxc AST
jsThe JavaScript-facing entry, wraps transform
nativeA native build, for profiling and fast iteration
wasmThe wasm build and build.sh
coverage-macroInstrumentation for coverage runs

Building it needs four things, and build.sh checks for the last three before it starts:

ToolWhy
Rust nightlyrust-toolchain.toml pins it, with wasm32-unknown-unknown and rust-src
wasm-bindgen-cliExactly 0.2.105. The script compares the version and refuses a mismatch
wasm-optFrom binaryen
wasm-snipThe r58playz fork, not the original

codespace-basic-setup.sh in the repository root installs all four and then runs the build, and is the fastest way to get a working environment.

cd packages/core/rewriter/wasm
bash build.sh          # debug build, with the `debug` feature on
RELEASE=1 bash build.sh

build.sh hashes every source file and skips the build when the hash matches out/.build-hash and dist/scramjet.wasm exists. That cache is why an edit to a .rs file sometimes appears to do nothing: touch nothing else and the hash changes, but a checkout that restores an older file can hit a stale hit. Delete out/.build-hash when in doubt.

There is no Rust-free path through a source build. Even a change confined to the TypeScript needs the wasm on disk first, because the rspack config reads it at evaluation time. Build the rewriter once, and after that you can stay in JavaScript; pnpm dev rebuilds only the bundles.


What gets built

rspack.config.ts at the root produces every artifact, and the same source file is emitted several ways:

OutputFormatWasmGlobal
scramjet.jsIIFEseparate$scramjet
scramjet_bundled.jsIIFEinlined$scramjet
scramjet.mjsES moduleseparatenone
scramjet_bundled.mjsES moduleinlinednone
scramjet-external.mjsES modulenonereads $scramjet
controller.api.jsIIFEnone$scramjetController
controller.sw.jsIIFEnone$scramjetController
controller.inject.jsIIFEnone$scramjetController
scramjet-utils.jsIIFEnone$scramjetUtils

scramjet-external.mjs is the one that causes the most confusion downstream. It is generated by an rspack plugin, it is what npm's main points at, and its entire body destructures globalThis.$scramjet. That is the file behind the Cannot destructure property 'BareResponse' error; see reading Scramjet's exports.

Version strings are injected at build time through DefinePlugin: VERSION, COMMITHASH, and BUILDDATE become versionInfo, and the controller gets SCRAMJET_EXPECTED_VERSION so it can assert the pair at load.


Testing

packages/runway is the browser harness. It drives real Chromium through Playwright against a real proxy, because nearly everything Scramjet does is only observable in a browser.

cd packages/runway
pnpm test           # headless
pnpm test:headed    # watch it happen
pnpm test:coverage  # v8 coverage, mapped back through istanbul
pnpm inspect        # one page, left open, for poking at

The suites under src/tests/ are worth knowing by name, because a good bug report says which one your case belongs next to:

SuiteCovers
sanity.tsThe proxy loads at all
rewrites.ts, rewriter-css.tsRewriter output
cookies.tsJar behaviour end to end
eval.ts, documentwrite.tsDynamic code paths
postmessage.ts, foreigncontext.tsCross-context messaging
websockets.tsTunnelled sockets
referer.ts, incumbent.ts, linkheader.tsHeader and origin semantics
custom-schemes.ts, encoding.tsURL handling
adversarial/Pages that actively try to escape
wpt/Vendored Web Platform Tests
regressions.tsEverything that broke once
site/Real sites, which fail for reasons of their own

The harness serves two of its own targets (harness/scramjet, harness/bare) so most tests do not depend on the public internet. site/ does, and a failure there is often the site changing rather than a regression; see site compatibility.

There is also pnpm test:package at the root, an ava suite that validates the published package layout. It catches "the tarball is missing a file", which is a real and recurring class of break.


Reporting something upstream

A report that gets fixed quickly has, roughly in order of value:

  1. The exact versions of all three packages and versionInfo.build. Alpha version numbers move faster than they look; the commit hash is the fact.
  2. Which layer it is. A URL that came out wrong is the rewriter; a request that never left is the fetch pipeline or the transport; a page that loaded and then broke is the client patches. The table above turns a symptom into a directory.
  3. A minimal page, ideally one that fits in runway/src/tests/. A failing test case is the difference between a fix this week and a fix eventually.
  4. What the browser did, not what you think it did: the failing request in the network tab, the console error with captureErrors on, and the rewritten source if the complaint is about rewriting. Turning on rewriterLogs and debugSourceURL makes that readable; see getting the rewriter to tell you what it is doing.

Scramjet is AGPL-3.0-only, and so is anything you build on it that users can reach over a network. What that obliges you to publish is summarised in official docs and licensing.


Where to go next

Profile Views