Plugins and hooks
Plugins are how you change what Scramjet does without forking it. A plugin registers callbacks on hooks, which are named points in the request and page lifecycle where Scramjet stops and asks whether anyone wants to intervene.
That is the whole extension surface of Scramjet 2.x. Anything the shipped plugins do (caching, URL watching, catching escaped links) you can do yourself with the same API.
Verified against @mercuryworkshop/scramjet 2.0.67-alpha.2 and
@mercuryworkshop/scramjet-controller 0.0.14. See the
version matrix.
Reading Scramjet's exports
Start here. Getting this wrong produces an error that points nowhere near the line that caused it.
Scramjet ships as a classic script that assigns a $scramjet global. The npm
package's main, module, and exports["."] all point at
dist/scramjet-external.mjs. That file is not the library. Its entire body is a
re-export of the global:
const __external = globalThis.$scramjet;
export const { BareResponse, CookieJar, Plugin, Tap /* … */ } = __external;
That destructuring runs at module evaluation time. If the module graph
evaluates before the classic script has run, globalThis.$scramjet is still
undefined and you get:
Cannot destructure property 'BareResponse' of 'globalThis.$scramjet' as it is undefined
Under a bundler's dev server the ordering is not guaranteed either way. ESM
module-graph evaluation is not sequenced against <script defer> tags, so the
same code can work in a production build and fail in dev, or work on one machine
and fail on another.
Read the globals at call time instead, inside a constructor or method:
const scramjet = () => globalThis.$scramjet;
const utils = () => globalThis.$scramjetUtils;
class MyPlugin extends utils().ManagedPlugin {
install(frame) {
const { BareResponse } = scramjet();
}
}
The same applies to @mercuryworkshop/scramjet-utils. Its source imports
@mercuryworkshop/scramjet at the top level, so importing it from your page
code drags the stub into your module graph and reintroduces the problem.
Type-only imports are always safe, because they are erased before the code runs:
import type { BareResponse } from "@mercuryworkshop/scramjet";
The globals become available once scramjet.js and controller.api.js have
loaded. If you follow manual wiring, that is inside
boot(), before anything else touches them.
Two plugin base classes
There are two, they are not interchangeable, and picking the wrong one produces a confusing failure.
| Class | From | Constructor | Use for |
|---|---|---|---|
Plugin | $scramjet | (name, tapOrder?) | Tapping a hook on an existing frame |
ManagedPlugin | $scramjetController | (name, dependencies) | Anything passed to createFrame() |
ManagedPlugin is defined in scramjet-controller and re-exported by
scramjet-utils, so $scramjetUtils.ManagedPlugin works too. You do not need
to load utils to subclass it.
ManagedPlugin extends Plugin and adds two things: a dependencies array and
an install(frame) method.
When you pass plugins to createFrame(), the Frame constructor does this:
for (const plugin of this.plugins) {
for (const dependency of plugin.dependencies) {
const found = this.plugins.find(p => p.name === dependency);
if (!found) throw new Error(`Dependency ${dependency} not found …`);
}
plugin.install(this);
}
Two things follow from that:
- A bare
Pluginin that array throws onplugin.dependenciesbeingundefined, becausefor…ofcannot iterate it. - Dependencies are checked by name against the same array, not resolved from anywhere else. It asserts load order within one frame and does no resolution.
LinkHandlerPlugin is the real example. It declares
super("link-handler", ["event-handler"]) and then looks its dependency up by
name from inside its init.post tap, so the lookup happens per proxied document
rather than once at install:
const eventHandler = frame.plugins.find(p => p.name === "event-handler");
So passing LinkHandlerPlugin without also passing EventHandlerPlugin throws
Dependency event-handler not found for plugin link-handler before the frame is
usable. The mechanism is doing its job there: a missing dependency fails loudly
at construction instead of silently doing nothing later.
Writing one
class TitlePlugin extends globalThis.$scramjetUtils.ManagedPlugin {
#onTitle;
constructor(onTitle) {
super("title-watcher", []);
this.#onTitle = onTitle;
}
install(frame) {
super.install(frame);
this.tap(frame.hooks.init.post, context => {
if (!context.isTopLevel) return;
const doc = context.window.document;
this.#onTitle(doc.title);
new MutationObserver(() => this.#onTitle(doc.title)).observe(
doc.querySelector("title") ?? doc.head,
{ childList: true, subtree: true, characterData: true }
);
});
}
}
What that shape is obeying:
- Pass both constructor arguments.
dependencieshas no default. - Tap inside
install, not in the constructor. The frame does not exist yet when the constructor runs, and hooks live on the frame. - One instance per frame.
ManagedPlugin.install(frame)storesthis.frame, so sharing an instance across frames leaves it pointing at whichever installed last. The class can be shared; the instance cannot. - Call
super.install(frame)if you overrideinstall. That is what setsthis.frame. Some of the shipped plugins skip it and close over theframeargument instead, which works but leavesthis.frameunset.
A
MutationObserverwatches for DOM changes and fires a callback (MDN). It is used here because Scramjet has no title event.
Ordering between plugins
Every tap call takes an optional tapOrder describing which plugins to run
around:
this.tap(frame.hooks.fetch.request, callback, {
before: ["scramjet-http-cache"],
after: ["url-watcher"]
});
Names refer to other plugins' name values. Use it when two taps on the same
hook would otherwise fight. An early-response tap has to run before a caching
tap, or the cache stores a response that was never fetched.
Plugin also takes a tapOrder as its second constructor argument, which
becomes the default for every tap that does not pass its own. ManagedPlugin
does not: its second argument is dependencies, and it passes only the name up
to Plugin. On a ManagedPlugin, set ordering per tap.
The hooks
Hooks live in three places. Fetch hooks fire per request. Frame hooks fire on the frame. Client hooks live on the proxied page's own Scramjet client and are only reachable once a document exists.
frame.hooks.fetch.intercept ─┐
frame.hooks.fetch.request │ per request
frame.hooks.fetch.preresponse │
frame.hooks.fetch.response ─┘
frame.hooks.init.pre ─┐ per proxied document,
frame.hooks.init.post ─┘ including subframes
frame.hooks.error.request ─ per failed request
context.client.hooks.lifecycle.navigate ─┐ reached from inside
context.client.hooks.rewriter.html ─┘ an init hook
Every callback has the same signature:
(context, props) => {};
context is read-only information about what is happening. props is the part
you mutate to change the outcome. Callbacks may be async; Scramjet awaits them.
fetch.intercept
Fires first, before Scramjet has decided anything. Set props.response to
answer the request without any of Scramjet's normal handling.
| Field | Type |
|---|---|
context.request | ScramjetFetchRequest |
context.parsed | ScramjetFetchParsed |
props.response | ScramjetFetchResponse |
Use it to block a request outright, or to serve something Scramjet should not touch. It is the earliest and bluntest of the four.
fetch.request
Fires after parsing, before the transport is called. Most of what you write will go here.
| Field | Type |
|---|---|
context.request | ScramjetFetchRequest |
context.parsed | ScramjetFetchParsed |
context.client | BareCompatibleClient |
props.init | BareRequestInit |
props.url | URL |
props.earlyResponse | BareResponse |
- Rewrite
props.urlto send the request somewhere else. - Mutate
props.initto change method, headers, or body. - Set
props.earlyResponseto answer locally and skip the network entirely.
earlyResponse is what makes fake origins
work: you invent an origin, match on context.parsed.url.origin, and hand back
a Response for a site that has no server. A native Response is fine here;
Scramjet converts it with BareResponse.fromNativeResponse if you did not
already hand it a BareResponse.
this.tap(frame.hooks.fetch.request, (context, props) => {
if (blocklist.has(props.url.hostname)) {
props.earlyResponse = new Response("Blocked", { status: 403 });
}
});
fetch.preresponse
Fires after the transport returns, before Scramjet rewrites the body. You get the raw upstream response.
| Field | Type |
|---|---|
context.request | ScramjetFetchRequest |
context.parsed | ScramjetFetchParsed |
props.response | BareResponse |
Response caching belongs here, along with any header change that would otherwise
affect rewriting, a content-type correction being the usual one.
Do not strip security headers here. Scramjet already removes CSP, HSTS,
x-frame-options and the cross-origin-* policies from every response on its
own. See site compatibility.
fetch.response
Fires after rewriting, immediately before the response reaches the page.
| Field | Type |
|---|---|
context.request | ScramjetFetchRequest |
context.parsed | ScramjetFetchParsed |
props.response | ScramjetFetchResponse |
Use it for logging and metrics. Rewriting the body here means undoing work
Scramjet just did, so prefer preresponse for content changes.
init.pre and init.post
Fire once per proxied document as its environment is built. pre runs before
Scramjet's own patches are applied, post runs after.
| Field | Type |
|---|---|
context.window | Window |
context.client | ScramjetClient |
context.isTopLevel | boolean |
props is empty. You are here to touch context.window.
isTopLevel matters more than it looks. These hooks fire for every
document, including nested iframes the proxied site created. A plugin that
patches history or reads document.title without checking isTopLevel will
also do it inside every ad frame on the page.
this.tap(frame.hooks.init.post, context => {
if (!context.isTopLevel) return;
context.window.addEventListener("beforeunload", () => this.save());
});
Use pre when you need to install something before the site's own code can see
Scramjet's patches. Use post for almost everything else.
error.request
Fires when a request fails. This is how you serve an error page instead of a blank frame.
| Field | Type |
|---|---|
context.rawrequest | TransferRequest |
context.error | unknown |
props.setResponse | TransferResponse |
props.suppressError | boolean |
Set both: suppressError stops Scramjet from propagating the failure, and
setResponse supplies what to show instead. Setting only setResponse still
lets the error surface.
this.tap(frame.hooks.error.request, (context, props) => {
if (context.error?.name === "AbortError") return;
if (context.rawrequest?.destination !== "document") return;
props.suppressError = true;
props.setResponse = {
body: "<h1>Could not load</h1>",
headers: [["content-type", "text/html; charset=utf-8"]],
status: 502,
statusText: "Bad Gateway"
};
});
Both filters matter. AbortError fires whenever someone navigates away
mid-load, so without that check every fast click shows an error page. The
destination check stops a failed image or tracking pixel from replacing the
whole document.
TransferResponse is a plain object rather than a Response. It has to cross a
postMessage boundary to the service worker, and a Response is not
structured-cloneable.
client.hooks.lifecycle.navigate
Not on the frame. It lives on the proxied page's own client, so you reach it through an init hook:
this.tap(frame.hooks.init.post, context => {
if (!context.isTopLevel) return;
this.tap(context.client.hooks.lifecycle.navigate, (context, props) => {
console.log(context.type, props.url);
});
});
| Field | Type |
|---|---|
context.type | "location" | "history" | "hashchange" |
props.url | string |
This fires for in-page navigation (history.pushState, hash changes, location
assignments) which init.post misses, because no new document gets created.
UrlWatcherPlugin wraps this same pattern, so unless you need context.type
use the plugin instead of tapping it yourself.
The plugins you get for free
From $scramjetUtils. All are ManagedPlugins, so they go straight into
createFrame().
| Plugin | Constructor | Name | Depends on |
|---|---|---|---|
HttpCachePlugin | (options?) | scramjet-http-cache | nothing |
UrlWatcherPlugin | (onUrlChange, options?) | url-watcher | nothing |
CatchEscapedLinksPlugin | (toLocation) | catch-escaped-links | nothing |
EventHandlerPlugin | (options?) | event-handler | nothing |
LinkHandlerPlugin | (onNewTab, options?) | link-handler | event-handler |
HttpCachePlugincaches subresources so a reload does not pull every asset back through the tunnel.UrlWatcherPlugincalls back with the frame's URL on every change.CatchEscapedLinksPlugintakes(url: URL) => string | URLand redirects navigations that would leave the proxy to whatever you return.EventHandlerPluginlets you register listeners that run after the page's own, including afterstopPropagation(). Itsoptions.eventsis documented upstream as defaulting toclick,auxclickandcontextmenu, but 0.0.3 never reads the option: the captured-event list starts empty and onlyaddEventToCapture(name)adds to it. Call that for each event type you need. The list is consulted live, but only as the page registers listeners, so anything the page registered before you added the type is already missed. Register your types before the frame loads a document.LinkHandlerPluginturns anchor clicks and middle-clicks into aonNewTab(url)callback instead of a navigation. RequiresEventHandlerPluginon the same frame.
const utils = globalThis.$scramjetUtils;
const frame = controller.createFrame(iframe, {
plugins: [
new utils.HttpCachePlugin(),
new utils.UrlWatcherPlugin(url => (address.value = url)),
new utils.CatchEscapedLinksPlugin(url => new URL("/", location.origin))
]
});
You almost certainly want UrlWatcherPlugin. Scramjet 2.x has no
urlchange event, so it is the only reliable way to know where a frame went. It
fires on real navigations, hash changes, and history.pushState. See
URL parsing and history.
CatchEscapedLinksPlugin takes a function that returns a URL, and that URL
is where the escaping navigation gets sent instead. Returning
new URL(location.href) cancels it in place; returning a data: URL shows a
message; routing back through your own shell lets you open it in a new tab.
Where to go next
- Config and flags. The other half of controlling Scramjet, and the one that decides how the rewriter behaves.
- Custom protocols.
fetch.requestandearlyResponseapplied to internal pages. - Cookies and sessions. What the controller owns, and why plugins should not manage cookies themselves.
packages/utils/src/upstream is five readable plugins, and the best templates you will find. When this page and that source disagree, the source wins.
Source: docs/reference/plugins-and-hooks.md