ProxDocs

Core API and types

Everything on the $scramjet global: the classes you construct, the functions you call, and every type that appears in a signature you will ever write.

This is the layer under the controller. The controller owns the service worker and the frames; core owns the rewriter, the cookie jar, the fetch pipeline, and the per-document client. Plugins touch both.

Verified against @mercuryworkshop/scramjet 2.0.67-alpha.2. See the version matrix.


Reaching it

scramjet.js is a classic script that assigns globalThis.$scramjet. The npm package's default export is a stub that destructures that global at module evaluation time, so importing values from it races the script tag and loses under most dev servers. Read the global inside a function, and import types separately:

import type { ScramjetClient, URLMeta } from "@mercuryworkshop/scramjet";

const scramjet = () => globalThis.$scramjet;

The full failure, its error message, and why type-only imports are safe are in reading Scramjet's exports.

You need skipLibCheck: true for the type-only import to work. The published dist/types/ still contains the build's internal path aliases, @/shared, @/fetch, @rewriters/url, @client/events, and nothing resolves them on your side, so a strict tsconfig.json reports a wall of TS2307 errors from inside node_modules and then claims @mercuryworkshop/scramjet has no exported member ScramjetFetchHandler. The controller's declarations have the same problem plus an import of ./types, which is not shipped at all. With skipLibCheck on, the default in most setups and in every project this builder generates, all of it is suppressed and the exported classes type correctly. What you lose is covered in every type in the package.

Two other entry points exist for bundler users, declared in the package's exports map:

SubpathWhat it is
./bundledThe real module, wasm fetched separately
./bundled-wasmThe module with the wasm inlined as base64
./pathNode-only, resolves dist/ for static hosts

./bundled-wasm is the one that lets you skip serving scramjet.wasm, at the cost of a much larger JavaScript payload. Most projects should serve the files and use the classic script; see Wiring Scramjet.


What is on the global

ExportKindOne line
ScramjetClientclassOne per proxied document, owns the patched globals
ScramjetFetchHandlerclassThe request pipeline, one per frame
ScramjetHeadersclassHeader bag that survives postMessage
CookieJarclassCookie storage, parsing, and matching
PluginclassBase class for anything that taps a hook
TapclassThe hook system itself, all static
BareResponseclassTransport-level response, re-exported from proxy-transports
rewriteUrlfunctionReal URL to proxied URL
unrewriteUrlfunctionProxied URL back to real URL
rewriteBlobfunctionBlob URL rewriting
unrewriteBlobfunctionBlob URL unrewriting
flagEnabledfunctionResolve a flag for a URL, siteFlags included
setWasmfunctionHand the rewriter its wasm bytes
defaultConfigconstThe shipped ScramjetConfig
defaultConfigDevconstSame, with the debugging flags flipped
versionInfoconst{ version, build, date }

The controller calls setWasm() for you during wait(). You need it only when you construct a ScramjetFetchHandler yourself, which is what the injected worker bootstrap does.


ScramjetClient

One instance per proxied document, created by the injected bootstrap. You never construct it; you receive it as context.client in init.pre and init.post.

MemberTypeWhat it is
urlURL (get/set)The real URL, already decoded
globalGlobalThisThe document's window or worker self
metaURLMetaBase and origin used for rewriting
contextScramjetContextConfig, prefix, cookie jar, codec
initHeadersScramjetHeadersResponse headers the document arrived with
historyTrackedHistoryState[]Tracked history for this document
bareBareCompatibleClientThe transport, as the page sees it
serviceWorkerServiceWorkerContainerThe proxied page's fake registration container
nativesNativeStoreUnpatched originals, keyed by path
descriptorsDescriptorStoreUnpatched property descriptors
hooks.rewriter.htmlTapInstance<HtmlRewriterHooks>Per-document HTML rewrite hooks
hooks.lifecycleTapInstance<LifecycleHooks>In-page navigation

Methods worth knowing:

client.rewriteUrl(url: string | URL, options?: RewriteUrlOptions): string;
client.unrewriteUrl(url: string | URL): string;
client.flagEnabled(flag: keyof ScramjetConfig["flags"]): boolean;

client.url is the one you reach for most: it answers "where is this document actually pointed" without any unrewriting of your own. Assigning to it navigates.

client.flagEnabled() resolves siteFlags for the document's real origin, so it can disagree with controller.scramjetConfig.flags on a site that has an override. Read flags through it rather than off the config object; see siteFlags.

natives and descriptors are how you call something the page may have overwritten. They are the escape hatch behind every "the site broke my plugin" problem, and they are also the fastest way to break a page if you write to them.

Constructing a second client for a global that already has one throws. That is the attempted to initialize a scramjet client, but one is already loaded error; it means two copies of Scramjet reached the same document.


ScramjetFetchHandler

The request pipeline. The controller creates one per Frame and exposes it as frame.fetchHandler; the injected worker bootstrap creates its own.

new ScramjetFetchHandler(init: FetchHandlerInit);
await handler.handleFetch(request: ScramjetFetchRequest): Promise<ScramjetFetchResponse>;
FetchHandlerInit fieldType
transportProxyTransport
contextScramjetContext
crossOriginIsolatedboolean?
sendSetCookie(cookies: CookieSyncEntry[], options?: CookieSyncOptions) => Promise<void>
fetchDataUrl(dataUrl: string) => Promise<BareResponse>
fetchBlobUrl(blobUrl: string) => Promise<BareResponse>
Handler memberType
clientBareCompatibleClient
contextScramjetContext
crossOriginIsolatedboolean
trackedClientsMap<string, ScramjetFetchTrackedClient>
hooks.fetchTapInstance<FetchHooks>
hooks.rewriter.htmlTapInstance<HtmlRewriterHooks>

handler.client.transport is what controller.setTransport() reassigns. If you hold a handler yourself, that is the field to swap, not init.transport, which is only read in the constructor.

trackedClients maps a service worker clientId to its navigation history, and is how a redirect chain keeps its Sec-Fetch-Site classification honest across documents. It grows for the life of the frame.


CookieJar

jar.setCookies(cookieString: string, url: URL): void;
jar.getCookies(url: URL, fromJs: boolean, sameSiteContext?: "strict" | "lax" | "cross-site"): string;
jar.load(cookies: string | Record<string, Cookie>): void;
jar.dump(): string;
jar.clear(): void;

dump() returns JSON, and load() accepts either that JSON or an already-parsed record. Those two are what the controller persists to IndexedDB and what the inject script carries into each document.

fromJs distinguishes a document.cookie read from a request header build, so httpOnly cookies are withheld from the page. Pass it correctly or you hand scripts a session cookie the real browser would have hidden.

sameSiteContext defaults to "strict", which is the conservative choice: it withholds SameSite=Lax and None cookies that a cross-site navigation should have sent. Pass the real context when you know it.

A Cookie is:

FieldTypeNotes
namestring
valuestring
pathstring?Defaults to the URL's directory
expiresnumber?Epoch milliseconds
maxAgenumber?
domainstring?Stored without the leading dot
hostOnlyboolean?Set when the cookie carried no Domain
secureboolean?Parsed but not enforced, see below
httpOnlyboolean?Enforced against fromJs reads
sameSitestring?Case varies by parser, compare lowercased

secure is deliberately not enforced on retrieval. Scramjet presents every proxied origin as HTTPS regardless of the real scheme, so enforcing it would drop cookies that the real browser would have sent. It is parsed and stored, so a plugin can still read it.

Cookie storage is per controller, not per frame. What that means for logins, and the three ways sessions break, is in Cookies and sessions.


ScramjetHeaders

A case-insensitive header bag that survives structured cloning.

headers.set(key: string, value: string): void;
headers.get(key: string): string | null;
headers.has(key: string): boolean;
headers.delete(key: string): void;
headers.clone(): ScramjetHeaders;
headers.toRawHeaders(): RawHeaders;
headers.toNativeHeaders(): Headers;
ScramjetHeaders.fromRawHeaders(raw: RawHeaders): ScramjetHeaders;
ScramjetHeaders.fromNativeHeaders(native: Headers): ScramjetHeaders;

RawHeaders is [name, value][], from proxy-transports. It is the only header shape that crosses a postMessage boundary intact, which is why TransferResponse uses it and why putting a Headers object there silently produces {}.

Convert at the boundary and work with whichever type the surrounding code uses. Mixing them in one function is where the header bugs come from.


Tap and Plugin

The hook system. Tap is entirely static; Plugin is what you subclass.

class Plugin {
	constructor(name: string, tapOrder?: TapOrder);
	tap<T>(hook: T, callback: (context, props) => void | Promise<void>, order?: TapOrder): void;
}

Tap.create<T>(): TapInstance<T>;
Tap.tap<T>(hook, callback, plugin?, order?): void;
Tap.dispatch<T>(hook, context, props): Promise<void[]> | null;
Tap.getTappers<T>(hook): Plugin[];

TapOrder is { before?: readonly string[]; after?: readonly string[] }, both lists of plugin names. A plugin's constructor order applies to every hook it taps; passing order to a single tap() call overrides it for that callback only.

class Blocker extends utils().ManagedPlugin {
	constructor() {
		super("blocker", []);
	}
	install(frame) {
		this.tap(frame.hooks.fetch.request, onRequest, {
			before: ["scramjet-http-cache"]
		});
	}
}

Three behaviours to know, because none of them are obvious from the signatures:

  • dispatch() returns null when nothing is tapped, not an empty promise. Awaiting it is fine; branching on truthiness is not.
  • Callbacks run concurrently, through Promise.all. Ordering constrains the order they are called in, not the order they finish. Two async callbacks that both mutate props can interleave.
  • Ordering is by name against tapped plugins only. A before naming a plugin that never tapped this hook is silently ignored, which is usually what you want, and is occasionally the reason your callback ran first anyway.

Tap.getTappers() throws rather than returning [] when nothing has tapped the hook, because it indexes the callback record before mapping it. Guard it.

Tap.create() returns a Proxy, so every property access on a hook map yields a hook object whether or not anyone has tapped it. There is no "unknown hook" error to catch: a typo in a hook name gives you a hook nobody dispatches.

Writing plugins, the two base classes, and the shipped ones are covered in Plugins and hooks.


URL rewriting

rewriteUrl(url: string | URL, context: ScramjetContext, meta: URLMeta, options?: RewriteUrlOptions): string;
unrewriteUrl(url: string | URL, context: ScramjetContext): string;
rewriteBlob(url: string, context: ScramjetContext, meta: URLMeta): string;
unrewriteBlob(url: string, context: ScramjetContext, meta: URLMeta): string;

From a plugin, prefer client.rewriteUrl() and client.unrewriteUrl(), which fill in context and meta from the document. Call the module-level functions when you have no client, which is the case on the controller side.

URLMeta is the answer to "relative to what":

FieldTypeWhat it is
originURLThe document's real origin
baseURLWhat relative URLs resolve against
topFrameNamestring?Frame targeting for _top
parentFrameNamestring?Frame targeting for _parent
referrerPolicystring?Policy in force for this document

origin and base are usually the same, and are not when the document carries a <base href>. Getting these wrong produces URLs that look right and resolve against the wrong host, which is the single most common way a hand-rolled rewrite goes wrong.

RewriteUrlOptions carries request metadata into the encoded URL, so the service worker can reconstruct it later:

FieldType
referrerPolicystring?
isModuleboolean?
navigateTypeNavigationType?
topFramestring?
parentFramestring?
isIframestring?
modestring?
credentialsstring?

These become the extra query parameters you see on proxied URLs, which is what parsed.hadExtraParams reports on. Do not strip them: they are how Sec-Fetch-* and module-versus-classic script handling survive the round trip.

javascript: URLs are special-cased at the top of both functions. rewriteUrl rewrites the body as JavaScript and re-prefixes it; unrewriteUrl currently returns the input unchanged, marked TODO upstream. Do not rely on round tripping one.


Context types

ScramjetContext is what every rewriting function needs. Frame builds one per access; see frame.context.

FieldTypeWhat it is
configScramjetConfigFlags, globals, siteFlags, maskedfiles
prefixURLAbsolute prefix for this frame
interfaceScramjetInterfaceCodec plus inject-script builders
cookieJarCookieJarShared with the controller
hooks?{ rewriter: { html } }Assigned by whoever owns the pipeline

ScramjetInterface is the seam a host fills in:

type ScramjetInterface = {
	codecEncode: (input: string) => string;
	codecDecode: (input: string) => string;
	getInjectScripts(meta, handler, htmlcontext, script): Element[];
	getWorkerInjectScripts?(meta, isModule, script): string;
};

getInjectScripts is what decides which files land in every proxied document, in what order. The controller's implementation is serialized into the page as source text, which is why it is written as a standalone function that closes over nothing: anything it captures would not survive toString().

ScramjetConfig itself, its flags, globals, siteFlags, and maskedfiles, is documented value by value in Config and flags. ScramjetVersionInfo is { version, build, date }, where build is the commit hash the bundle was built from. Quote it in bug reports; alpha versions move faster than the version string suggests.

ScramjetInitConfig is a 1.x leftover in types.ts: it extends ScramjetConfig with a codec and a partial flags. Nothing in 2.x constructs one. If you find it in a guide, that guide is for 1.x; see breaking changes.


Fetch pipeline types

These four appear in every fetch hook. What each hook can do with them is in the hooks; this is what the objects contain.

ScramjetFetchRequest, the request as it entered the pipeline:

FieldTypeNotes
rawUrlURLProxied URL, prefix included
rawReferrerstring | nullProxied referrer
rawDestinationRequestDestinationUse parsed.destination instead
modeRequestMode
referrerstring
methodstring
bodyBodyType | null
cacheRequestCache
initialHeadersScramjetHeadersNot RawHeaders, unlike TransferRequest
rawClientUrlURL?The client that made the request
clientIdstringService worker FetchEvent.clientId

rawDestination carries an upstream comment telling you not to use it, because a $dest parameter can override it. Read parsed.destination.

ScramjetFetchParsed, everything derived from it:

FieldTypeNotes
urlURLThe real destination
clientUrlURL?Real URL of the requesting document
referrerSourceUrlURL | null
destinationRequestDestinationHonours $dest
metaURLMetaOrigin and base for this request
isModulebooleanES module versus classic script
isFakeDataURLboolean
hadExtraParamsbooleanWhether metadata rode on the URL
crossSiteRedirectboolean
fetchSiteState"same-origin" | "same-site" | "cross-site"?Worst case across redirects
fetchInitiatorOriginstring?Sec-Fetch-Site tracking only
fetchCredentialsIncludeboolean?
fetchModeScramjetRequestMode?
isIframeboolean?Scramjet's definition, not the browser's
referrerPolicystring?
trackedClientScramjetFetchTrackedClient?Navigation history for this client

parsed.url is the field you filter on. Matching a hostname against rawUrl matches your own origin on every request.

fetchInitiatorOrigin carries an upstream warning: it diverges from clientUrl in some cases and exists only to keep Sec-Fetch-Site correct. Use clientUrl for anything else.

ScramjetFetchResponse is { body: BodyType; headers: ScramjetHeaders; status: number; statusText: string }.

BodyType is string | ArrayBuffer | Blob | ReadableStream<any>. Streams are transferred, not copied.

Two supporting types:

type CookieSyncEntry = { url: URL; cookie: string };
type CookieSyncOptions = { clear?: boolean; destination?: RequestDestination };
type TrackedHistoryState = { url: string; refererPolicy?: string };

CookieSyncEntry takes a real URL; its serialized cousin on the controller side takes a string. refererPolicy is spelled with the historical single r in this type and with two elsewhere; that inconsistency is upstream, and copying the wrong spelling silently drops the field.


Hook type maps

Four maps describe everything tappable. Field-level docs and examples are in Plugins and hooks; this is where they live and what generic they take.

MapInstance onHooks
FetchHooksfetchHandler.hooks.fetch, frame.hooks.fetchintercept, request, preresponse, response
HtmlRewriterHooksclient.hooks.rewriter.htmlpre, post
LifecycleHooksclient.hooks.lifecyclenavigate
FrameInitHooks, FrameErrorHooksframe.hooksController package, see there

HtmlRewriterHooks is reachable in two places that are not the same object. The frame's handler has one (fetchHandler.hooks.rewriter.html), and each proxied document's client has its own. Tapping the handler's copy from a plugin's install() covers every document that frame fetches; tapping context.client.hooks.rewriter.html from an init hook covers one document. Pick the first unless you need per-document state.

type LifecycleHooks = {
	navigate: {
		context: { type: "location" | "history" | "hashchange" };
		props: { url: string };
	};
};

That is the whole lifecycle map in 2.0.67-alpha.2. There is no load, no beforeunload, and no urlchange. For URL tracking use UrlWatcherPlugin; for document lifecycle use frame.hooks.init.post and add your own listeners to context.window.


Where to go next

Profile Views