ProxDocs

Controller and Frame API

The class surface of @mercuryworkshop/scramjet-controller. Two classes and a handful of module-level functions: Controller owns the service worker connection, the transport, and the cookie jar; Frame owns one iframe and its plugins.

This page is the reference for the objects themselves. What the config values mean lives in Config and flags, what you can tap lives in Plugins and hooks, and how to get any of it running lives in Wiring Scramjet.

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


Where the API comes from

The package ships three separate bundles, and all three assign the same global name in their own scope. Which one you get depends on which file you loaded.

FileLoaded inGives you
controller.api.jsthe pageController, Frame, ManagedPlugin, config, the version helpers
controller.sw.jsthe workershouldRoute(), route()
controller.inject.jsproxied pagesload(), called by injected bootstrap only

So $scramjetController.Controller in a service worker is undefined, and $scramjetController.route on the page is too. They are not the same object.

Read the global at call time, never at module scope. The npm entry point is a stub that destructures the global when the module evaluates, which loses a race against the classic script under most dev servers. That failure and its error message are covered in reading Scramjet's exports; the same applies to every name on this page.

You never call controller.inject.js yourself. Scramjet injects it into each proxied document, and its load() receives a serialized copy of the config, prefix, and cookie jar. It is listed here so you recognise it in a network tab.


new Controller(init)

KeyTypeRequiredWhat it is
serviceworkerServiceWorkeryesThe active worker, not a Registration
transportProxyTransportyesAn epoxy, libcurl, or bare client instance
configPartial<Config>noPaths, prefix, codec
scramjetConfigPartial<ScramjetConfig>noRewriter flags and globals

serviceworker has to be a ServiceWorker object. Passing the registration is the common mistake, and it fails later and elsewhere, when the first postMessage goes nowhere. Get it from navigator.serviceWorker.controller, falling back to registration.active. See client boot.

The constructor is synchronous and starts three async jobs: the worker handshake, the wasm fetch, and the first cookie-jar load. All three are what wait() waits for.

It throws immediately, before any of that, if $scramjet is missing or its version does not match the build. See version guards.

How the two configs merge

Both go through @fastify/deepmerge, but not in the same direction, and the difference is visible in behaviour.

config merges the way you expect. Your values win over the defaults, key by key, so passing { prefix: "/p/" } leaves every path default intact.

scramjetConfig does not. The controller starts from its own preset, merges Scramjet's defaults over it, then merges yours on top. Since the defaults are applied last of the two, the controller's own preset loses:

const scramjetConfig = {
	flags: { ...scramjetDefaultConfig.flags, allowFailedIntercepts: true },
	maskedfiles: ["inject.js", "scramjet.wasm.js"]
};

That allowFailedIntercepts: true never survives. defaultConfig.flags sets it to false, the merge applies that afterwards, and the flag is false on every controller that does not set it explicitly. If you want the behaviour the controller appears to be asking for, pass it yourself:

new Controller({
	serviceworker,
	transport,
	scramjetConfig: { flags: { allowFailedIntercepts: true } }
});

Arrays concatenate, they do not replace. maskedfiles survives the same merge because concatenating onto an empty array is a no-op, and your own entries are appended to the built-in two rather than replacing them:

new Controller({
	serviceworker,
	transport,
	scramjetConfig: { maskedfiles: ["mine.js"] }
});
// controller.scramjetConfig.maskedfiles
// → ["inject.js", "scramjet.wasm.js", "mine.js"]

There is no way to remove inject.js or scramjet.wasm.js from that list through config, which is fine, because unmasking them breaks the frame.


Controller members

MemberTypeWhat it is
idstringRandom 8-character id, second segment of the prefix
prefixstringconfig.prefix + id + "/"
configConfigThe merged path/codec config
scramjetConfigScramjetConfigThe merged rewriter config
transportProxyTransportCurrent transport, swap it with setTransport()
cookieJarCookieJarOne jar for every frame on this controller
framesFrame[]Every frame ever created, never pruned
serviceWorkerControllerServiceWorkerWhatever you passed as init.serviceworker
rpcRpcHelperThe channel to the worker, internal
isReadybooleanAlways false, see below
guardServiceWorkerRevivebooleanSuppresses revive handling, see below

wait()

await controller.wait();

Resolves once the worker has acknowledged the handshake and the wasm has been fetched and installed. Call it before creating frames.

It does not wait for the cookie jar. That loads lazily and the controller holds proxied requests until it is ready, so you do not have to sequence it. See Cookies and sessions.

createFrame(element?, options?)

const frame = controller.createFrame(iframe, {
	plugins: [new utils.HttpCachePlugin()]
});

element is optional. Omit it and the controller creates a detached <iframe> for you, reachable as frame.element. You still have to append it to the document yourself or nothing renders.

options.plugins is a ManagedPlugin[]. The constructor validates each plugin's dependencies against the names in that same array and throws Dependency X not found for plugin Y if one is missing, then calls install(frame) in array order. Nothing is resolved from anywhere else; see the two plugin base classes.

Every frame is pushed onto controller.frames and never removed.

Nothing stops you calling this too early. The method opens with a readiness guard, but it tests a promise that the constructor always assigns, so the guard can never fire:

if (!this.ready) throw new Error("Controller is not ready! …");

A frame created before wait() resolves is therefore returned normally, with the wasm possibly not yet installed and the worker possibly not yet routing its prefix. The symptom is a frame that loads nothing, or a first navigation that lands on your own 404. Await wait(); the error you were counting on to catch this does not exist.

setTransport(transport)

controller.setTransport(new LibcurlClient({ wisp: wispUrl }));

Replaces the transport on the controller and walks controller.frames, reassigning it on every frame's fetch handler. Existing in-flight requests are not migrated, and the old transport is not closed. Dropping the reference is your job.

Because frames never shrinks, this walks every frame you have ever created, including ones whose iframes are long gone. Call it on an actual transport change, not per navigation. See Transports and Multiple tabs.

persistCookies() and propagateCookieSync(cookies, options?)

Both are public, and both are already called for you on every set-cookie that comes back through a frame. You need them only when you mutate cookieJar yourself.

controller.cookieJar.setCookies(
	"session=abc; Path=/",
	new URL("https://example.com")
);
await controller.persistCookies();

persistCookies() writes the jar to IndexedDB (database __scramjet_controller, store state, key cookies) under a monotonic updatedAt, then announces it on the __scramjet_controller_channel BroadcastChannel so other tabs reload it. Without the call, your mutation lives only in memory and dies with the tab.

propagateCookieSync(cookies, options) pushes entries into the worker so already-loaded proxied documents update their own jars. cookies is a SerializedCookieSyncEntry[]; options accepts clear (wipe before applying) and destination (a RequestDestination, which the worker uses to decide whether to wait for an acknowledgement).

isReady and guardServiceWorkerRevive

isReady is declared false and assigned nowhere in 0.0.14. Reading it tells you nothing about the controller. Use await wait(), or keep your own flag next to it.

guardServiceWorkerRevive exists because a freshly spawned worker announces itself as revived even though it never died. The controller ignores revive messages for the first five seconds after construction, then clears the flag. Setting it to true yourself suppresses message-port re-establishment after a genuine worker death, which is the bug the keepalive ping exists to avoid. Leave it alone.


Frame members

MemberTypeWhat it is
idstringRandom 8-character id
prefixstringcontroller.prefix + id + "/"
elementHTMLIFrameElementThe iframe, yours or the one it created
controllerControllerThe owning controller
pluginsManagedPlugin[]Exactly what you passed
optionsFrameOptionsThe options object you passed
hooks{ fetch, init, error }Everything tappable, see below
fetchHandlerScramjetFetchHandlerThe per-frame request pipeline
contextScramjetContextGetter, rebuilt on every access

hooks.fetch is not the frame's own object. It is fetchHandler.hooks.fetch, re-exposed. hooks.init and hooks.error are Tap instances the frame creates itself. All three are documented field by field in the hooks.

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

go() is synchronous: it rewrites the URL against the frame's context and assigns element.src. It does not wait for the load, and it does not validate the input. Pass it a bare example.com and you get a relative navigation. Parse before you call it, per URL parsing and history.

The other three drive element.contentWindow.history directly and silently do nothing when the frame has not loaded a document yet. There is no canGoBack-style state to read; Scramjet does not expose one, and reading history.length from the parent gives you the proxied document's own count.

getPlugin(name)

const cache = frame.getPlugin("scramjet-http-cache");

Looks a plugin up by its registered name and throws Plugin X not found if it is absent. Prefer it to frame.plugins.find(...) in your own code, because you get an error naming the plugin instead of undefined surfacing three frames later.

Inside a plugin that runs per document you still want frame.plugins.find(...), because that is what the shipped plugins do and the lookup has to happen at tap time rather than install time.

frame.context

A getter that rebuilds a fresh ScramjetContext on every access:

FieldWhat it is
configThe controller's ScramjetConfig
prefixframe.prefix as an absolute URL
cookieJarThe controller's jar, shared
interfaceInject-script builders plus codecEncode/codecDecode

This is what you pass to rewriteUrl and friends when you need to encode or decode a URL the same way the frame does:

const { rewriteUrl } = globalThis.$scramjet;
const encoded = rewriteUrl(url, frame.context, {
	origin: new URL(location.href),
	base: new URL(location.href)
});

Because it is rebuilt per access, mutating frame.context accomplishes nothing. Change controller.scramjetConfig instead, and note that flags are read per request, so the change lands on the next navigation and not on the current document.

There is no frame.destroy()

Removing the iframe from the DOM is all you can do. The Frame stays in controller.frames, its plugins stay installed, and its prefix stays registered in the worker. This is the leak behind the guidance in Multiple tabs: a long session that opens and closes tabs accumulates frames, and setTransport() walks all of them.


Every type in the package

The package's type entry point re-exports index.ts and declares the global, so a type-only import gives you the classes without touching the runtime:

import type { Frame, Config } from "@mercuryworkshop/scramjet-controller";

That import is erased at compile time, which is why it is safe when reading the value side is not.

Most of the package's types are not importable by name. They live in src/types.d.ts, and index.ts imports them without re-exporting, so they never reach typesEntry.d.ts. The exports map declares only ".", so there is no deep import path to reach around it either. The table below says which is which.

TypeImportableWhere you meet it
ControlleryesThe class
FrameyesThe class
ManagedPluginyesPlugin base class
Configyesinit.config, controller.config
TransferRequestnoerror.request context, worker payload
TransferResponsenoerror.request props, worker payload
BodyTypenoRequest and response bodies
SerializedCookieSyncEntrynopropagateCookieSync()
FrameInitHooksnoframe.hooks.init
FrameErrorHooksnoframe.hooks.error
WebSocketDatanoTransport RPC payloads
WebSocketMessagenoTransport RPC payloads
ControllerboundnoRPC method map, worker to page
SWboundnoRPC method map, page to worker
TransportToControllernoRPC method map, in-frame transport
ControllerToTransportnoRPC method map, in-frame transport
ControllerInitnoThe constructor argument
FrameOptionsnoThe createFrame() options

Everything from TransferRequest down is unreachable for a different reason than the last two. The types.d.ts group is exported from its own file and then dropped on the way out of the package; ControllerInit and FrameOptions are declared in index.ts without export at all. The effect is the same.

It is worse than a missing export. The published package does not ship types.d.ts at all, and dist/types/index.d.ts still imports from ./types, so that import dangles. Under skipLibCheck: true, the default in most setups and in every project this builder generates, TypeScript swallows the dangling import and everything that came from that file silently becomes any.

So deriving them through the class does not work either, even though it compiles:

import type { Frame } from "@mercuryworkshop/scramjet-controller";

// resolves to `any`, not to TransferRequest
type Req = Frame["hooks"]["error"]["request"]["context"]["rawrequest"];

Controller and Frame themselves are typed properly: misspell a member and tsc catches it. Anything reached through frame.hooks is not. Restate the shape from the tables below instead, which at least type-checks:

type TransferResponse = {
	body: string | ArrayBuffer | Blob | ReadableStream<Uint8Array>;
	status: number;
	statusText: string;
	headers: [string, string][];
};

None of these has changed since 0.0.10. Exporting them upstream is a one-line change to index.ts plus shipping types.d.ts, and it is a reasonable first pull request.

Types you will actually touch

TransferRequest and TransferResponse cross a postMessage boundary between the worker and the page, which is why they are plain objects rather than Request and Response, neither of which is structured-cloneable.

You get a TransferRequest as context.rawrequest in error.request:

FieldTypeNotes
rawUrlstringThe proxied URL, prefix included
rawReferrerstring | nullProxied referrer
referrerstringThe request's referrer
destinationRequestDestinationdocument, image, script, …
modeRequestModenavigate, cors, …
methodstring
bodyBodyType | nullString, ArrayBuffer, Blob, or stream
cacheRequestCache
initialHeadersRawHeadersArray of [name, value] pairs
forceCrossOriginIsolatedboolean
rawClientUrlstring?URL of the client that made the request
clientIdstring?

rawUrl is the proxied URL. Filtering on the real destination means decoding it with config.codec.decode after stripping frame.prefix, or reading client.url from an init hook instead.

TransferResponse is what you hand back:

FieldType
bodyBodyType
statusnumber
statusTextstring
headersRawHeaders

RawHeaders is [name, value][], not a Headers object, for the same cloning reason. A Headers instance here silently serialises to {}.

SerializedCookieSyncEntry is { url: string; cookie: string }, the shape propagateCookieSync() takes. The url is the real destination URL, and the cookie is one raw set-cookie string.

BodyType is string | ArrayBuffer | Blob | ReadableStream<Uint8Array<ArrayBufferLike>>. A ReadableStream or ArrayBuffer body is transferred, not copied, so the sending side loses it. Handing the same body to two places fails on the second.

The RPC method maps

Four types describe the message plumbing. You never call these methods yourself; they are here so the types make sense when you read the source or a stack trace, and so a fork knows what it has to keep compatible.

Each entry is a tuple: [argument] for a call with no return, or [argument, result] for one that resolves with something.

Controllerbound, what the worker can call on the page:

MethodSignaturePurpose
ready[]Handshake, resolves wait()
request[TransferRequest, TransferResponse]The proxied request itself
initRemoteTransport[MessagePort]Hands the page a port for a subframe transport

SWbound, what the page can call on the worker:

MethodSignature
sendSetCookie[{ cookies: SerializedCookieSyncEntry[]; options?: CookieSyncOptions }]

TransportToController and ControllerToTransport describe the second channel, the one a proxied document uses when it needs the tunnel directly:

MethodDirectionSignature
requesttransport to controller[{ remote, method, body, headers }, TransferrableResponse]
sendSetCookietransport to controller[{ cookies, options }]
connecttransport to controller[{ url, protocols, requestHeaders, port }, success | failure]
readycontroller to transport[]

connect resolves with { result: "success", protocol, extensions } or { result: "failure", error }. It never rejects, so a failed WebSocket comes back as a value and not a thrown error. That is deliberate: the RPC layer cannot clone an Error with its stack.

Data on an open socket then flows over the MessagePort as WebSocketMessage:

type WebSocketData = string | ArrayBuffer | Blob;

type WebSocketMessage =
	| { type: "data"; data: WebSocketData }
	| { type: "close"; code: number; reason: string };

Both sides post the same union, so one handler covers send and receive. There is no open message; the connect result is the open signal.

The hook types

FrameInitHooks and FrameErrorHooks are the two Tap maps the frame creates itself. FetchHooks comes from core and is re-exposed on frame.hooks.fetch. All three are documented field by field in the hooks; the shapes are:

type FrameInitHooks = {
	pre: {
		context: {
			window: Window;
			client: ScramjetClient;
			isTopLevel: boolean;
		};
		props: {};
	};
	post: {
		context: {
			window: Window;
			client: ScramjetClient;
			isTopLevel: boolean;
		};
		props: {};
	};
};

type FrameErrorHooks = {
	request: {
		context: { rawrequest: TransferRequest; error: unknown };
		props: { setResponse?: TransferResponse; suppressError?: boolean };
	};
};

props: {} on the init hooks is not a placeholder for a future field. There is nothing to set, and the second callback argument is an empty object at runtime. Everything you do in an init hook, you do to context.window.

Types that come from elsewhere and appear in these signatures, ScramjetConfig, ScramjetClient, CookieJar, CookieSyncOptions, RawHeaders, TransferrableResponse, are covered in the core API.


The service worker module

Two exports, both from controller.sw.js:

importScripts("/controller/controller.sw.js");

self.addEventListener("fetch", event => {
	if ($scramjetController.shouldRoute(event)) {
		event.respondWith($scramjetController.route(event));
	}
});

shouldRoute(event) returns true when the request path starts with a registered frame prefix. Check it before calling route(), or the worker intercepts its own runtime files.

route(event) forwards the request to the owning page over the message port and resolves with the response. It never rejects: on failure it logs and returns a 500 whose body is Internal Service Worker Error: <message>. Seeing that string in a frame means the page side threw, so look at the tab's console, not the worker's.

The worker keeps its routing table in module scope, so a terminated worker forgets every prefix. That is the whole reason for the keepalive ping, and the messages below are how the two sides recover.

MessageDirectionPurpose
$controller$initpage → SWRegisters a prefix and hands over a MessagePort
$controller$swreviveSW → page"I just started, send me a new port"
$controller$setCookieSW → pageCookie sync into loaded documents
$sw$setCookieDonepage → SWAcknowledgement, keyed by id
$sw$initRemoteTransportpage → SWHands a transport port to the right controller
"keepalive" (plain string)page → SWResets the idle timer and runs no handler

Both worker-side listeners return early on anything that is not an object, which is why the keepalive is deliberately a bare string.


Version guards

controller.api.js exports two things you can use directly:

console.log($scramjetController.VERSION);
$scramjetController.assertRuntimeScramjetVersion();

VERSION is the controller's own version, baked in at build time.

assertRuntimeScramjetVersion() compares the version of @mercuryworkshop/scramjet the controller was built against with the one actually loaded, and throws on a mismatch:

@mercuryworkshop/scramjet version mismatch: this build expects 2.0.67-alpha.2, but the loaded runtime is 2.0.66

or, if the core bundle never loaded at all:

@mercuryworkshop/scramjet is not loaded. Load scramjet before the controller.

The Controller constructor calls this for you, first thing, so a skewed install fails at construction rather than on the first navigation. Both errors mean the same class of problem: a stale node_modules, a cached scramjet.js served by a service worker that outlived a deploy, or script tags in the wrong order. Load order is core, controller, then utils. See the version matrix for the combinations that are known to work.


Where to go next

Profile Views