Custom protocols
Browsers give their own pages a private scheme: chrome://settings,
about:config, edge://flags. You can do the same, myproxy://settings, and
it is worth doing for a concrete reason, not just aesthetics.
What you cannot do
Be clear about this first, because it saves you an afternoon.
You cannot register a real URL scheme. navigator.registerProtocolHandler
only accepts a short allowlist (mailto, web+*, a few others), requires user
consent, and routes to an HTTPS URL anyway. Service workers cannot intercept a
scheme the browser does not already route over HTTP.
So myproxy://settings is not a URL as far as the network stack is
concerned. Typing it into the real browser address bar does nothing.
What you are building
A routing convention, resolved entirely inside your app:
- Your URL parser recognises the scheme and returns
{ kind: "internal" }. - A registry maps the host part to a renderer.
- The renderer's HTML goes into the tab's iframe via
srcdoc.
Three steps, all in your own code, no browser cooperation needed.
Why bother
It gives internal pages an address that can never collide with a proxied
site. Without a scheme, you end up with /settings as a real route, and now
you have to make sure a proxied page can never be at /settings, that your
service worker does not intercept it, and that navigating to it does not leave
the proxy shell.
With a scheme, the URL parser routes it before the proxy engine is ever involved, so it cannot collide with a proxied HTTP route.
It also gives you shareable, typeable addresses for internal pages, and it makes
the address bar honest. The user sees myproxy://history, not a blank bar or a
lie.
This feature is optional. If settings, history, or bookmarks should behave like an overlay or sidebar instead of a destination, leave custom protocols off. The builder opens those pages in a separate popup iframe, so the active tab and its Back/Forward history do not change. See Settings: Popup or custom protocol.
Implementation
1. Recognise the scheme
In your URL parser, before anything else:
export const internalScheme = "myproxy:";
export const parseScheme = input => {
if (!/^[a-z][a-z0-9+.-]*:/i.test(input)) return null;
const parsed = new URL(input);
if (parsed.protocol === internalScheme) {
return { url: parsed.href, kind: "internal" };
}
return { url: parsed.href, kind: "external" };
};
new URL("myproxy://settings") works. The URL parser accepts any syntactically
valid scheme. Note that for a non-special scheme, whether the name lands in
.hostname or .pathname depends on whether you wrote //:
new URL("myproxy://settings").hostname;
new URL("myproxy:settings").pathname;
The first expression returns "settings"; the second also returns "settings",
but from pathname.
Accept both:
const name = parsed.hostname || parsed.pathname.replace(/^\/+/, "");
2. A registry
const pages = new Map();
export const definePage = (name, { title, render }) => {
pages.set(name, { title, render });
};
export const render = rawUrl => {
const parsed = new URL(rawUrl);
const name = parsed.hostname || parsed.pathname.replace(/^\/+/, "");
const page = pages.get(name);
if (!page) return errorDocument(name);
return documentPage(page.title, page.render(parsed.searchParams));
};
Passing searchParams through means myproxy://history?q=wikipedia works
without any extra plumbing.
3. Render into the tab
const openInternal = url => {
const html = internal.render(url);
if (html === null) return;
const tab = tabs.active ?? tabs.open();
tab.internalHistory.push(url);
tab.url = url;
tab.element.removeAttribute("src");
tab.element.srcdoc = html;
tabs.emit();
};
Internal pages occupy a tab like any other page, so tab switching and the tab
strip work normally. They do need a per-tab internal history stack because
successive srcdoc assignments do not give the custom URLs useful native
history entries. Clear that stack in tab.go() before handing an HTTP URL to
the proxy engine. See
URL parsing and history.
Keep data-backed pages live
When custom protocols are enabled, History and Bookmarks are open srcdoc tabs
rather than popups. Subscribe to their data modules and re-render every matching
internal tab when data changes, including hidden background tabs, so switching
back never requires a reload:
const refreshInternalPages = names => {
for (const tab of tabs.tabs) {
if (!names.includes(internal.pageName(tab.url))) continue;
const html = internal.render(tab.url);
if (html !== null) tab.element.srcdoc = html;
}
};
visitLog.onChange(() => refreshInternalPages(["history"]));
bookmarks.onChange(() => refreshInternalPages(["bookmarks"]));
Do this only for protocol-backed internal pages. Popup-menu builds already have their own refresh path and should not replace the active proxy tab.
Why srcdoc and not a real route
This is the security-relevant decision.
A srcdoc assignment navigates the iframe to an about:srcdoc document, but it
does not make a network request and the service worker does not receive one.
This means:
A proxied site cannot fetch your settings page. If internal pages lived at
/internal/settings, a proxied page, running on your origin, remember, could
fetch("/internal/settings") and read whatever is rendered there. With srcdoc
there is nothing to fetch.
The document still inherits your origin, so it can use your stylesheet and talk to the shell. It just has no address.
Fake origins, and why internal pages do not use them
Scramjet can intercept a request before it leaves for the network and answer it
locally. A plugin taps the frame's fetch hook and sets props.earlyResponse:
import { Plugin } from "@mercuryworkshop/scramjet";
const plugin = new Plugin("internal-pages");
plugin.tap(frame.hooks.fetch.request, (context, props) => {
if (context.parsed.url.origin !== "https://internal.myproxy") return;
props.earlyResponse = new Response(html, {
headers: { "content-type": "text/html; charset=utf-8" }
});
});
Nothing is fetched. Scramjet renders a site that has no server behind it, on an
origin you invented. Scramjet's own demo playground does exactly this, which is
where the placeholder name fakeorigin.com comes from; it is a domain in an
example, not an API.
This is an excellent tool, and it is worth knowing about. Serving a whole
origin from a plugin covers offline pages, blocklist interstitials, injected
homepages, and stubbing a real site so you can test against it without touching
the network. The other three taps, intercept, preresponse, and response,
let you rewrite real responses on the way back.
It would also, on the face of it, be a better fit for internal pages than
srcdoc. The pages would have real URLs, so native history would work and the
per-tab stack below would be unnecessary, the origin: "null" postMessage
dance would go away, and pages could load real stylesheets and scripts instead
of one inlined document.
The generated builds still use srcdoc, for two reasons.
Fetch hooks are Scramjet-only. Ultraviolet has no equivalent, so internal
pages built this way would not exist in an Ultraviolet build. srcdoc is an
iframe attribute; it works the same under either engine, which is why custom
protocols are offered as a feature independent of your engine choice.
It fails when Scramjet does A fake-origin response is produced inside the
service worker, by a plugin tapped onto a frame that controller.createFrame()
returned, after initBootstrap() registered the service worker, loaded every
bundle, and built the transport, on a page whose COOP/COEP headers let the wasm
rewriter have SharedArrayBuffer. If any of that is broken, the fake origin
cannot be served. That is the failure an error page exists to explain, so the
mechanism that renders your error page must not depend on the machinery that
just failed. srcdoc depends on none of it.
There is also an isolation difference. earlyResponse short-circuits the
transport and returns straight to the service worker; there is no CORS layer in
front of it, so a proxied page can read a fake-origin URL that a plugin serves.
You can narrow this by checking context.parsed.destination and serving only
navigations. With srcdoc the question does not arise, because there is no
address to request.
Use fake origins for plugins. Use srcdoc for internal pages unless you know
what you are doing.
Talking to the shell
A srcdoc document does not share the parent document's existing module scope.
It could import modules by URL, but the generated page keeps the boundary small
and uses postMessage:
document.addEventListener("click", event => {
const link = event.target.closest("[data-open]");
if (!link) return;
event.preventDefault();
parent.postMessage(
{ type: "internal:open", url: link.dataset.open },
parent.location.origin
);
});
addEventListener("message", event => {
if (event.origin !== location.origin) return;
if (!internal.isInternal(currentUrl())) return;
if (event.source !== tabs.active?.element.contentWindow) return;
const data = event.data;
if (!data || typeof data !== "object") return;
if (data.type === "internal:open" && typeof data.url === "string") {
navigate(data.url);
}
});
about:srcdoc reports location.origin as "null", so the child uses
parent.location.origin as the target. In the shell, verify the active URL is
internal as well as checking event.source, event.origin, and the message
shape. Proxied content is same-origin and uses the same iframe window after a
navigation, so neither check identifies an internal page by itself.
Choosing a scheme name
- Letters first, then letters, digits,
+,-,. - Avoid real schemes:
http,https,ftp,file,data,blob,about,chrome,edge,view-source - Keep it short, people type it
The generator derives one from your project name, so my-proxy produces
myproxy://settings. If the cleaned project name is a reserved scheme such as
http, https, data, or javascript, the generator appends app.
Useful pages
home (a new-tab page), settings, history, bookmarks, and about cover
almost everything. Two suggestions:
Make an about page that reports real versions:
definePage("about", {
title: "About",
render: () => `
<main class="internal-shell">
<h1>About</h1>
<dl>
<dt>Engine</dt><dd>${engine.label}</dd>
<dt>Scramjet</dt><dd>${window.$scramjet?.versionInfo?.version ?? "unknown"}</dd>
<dt>Transport</dt><dd>${engine.getTransport?.().kind ?? "n/a"}</dd>
</dl>
</main>`
});
That page will save you more support time than any documentation you write.
And always ship a fallback for unknown pages that lists the real ones:
const errorDocument = name => {
return documentPage(
"Page not found",
`
<main class="internal-shell">
<h1>No such page</h1>
<p><code>myproxy://${escapeHtml(name)}</code> does not exist.</p>
<ul>${listPages()
.map(p => `<li><a href="#" data-open="${p.url}">${p.url}</a></li>`)
.join("")}</ul>
</main>`
);
};
Escaping
Everything you interpolate into these templates must be escaped. Internal pages
render history entries, bookmark titles, and settings values, all of which come
from proxied sites, and any of which can contain <script>:
export const escapeHtml = value => {
return String(value).replace(
/[&<>"']/g,
c =>
({
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'"
})[c]
);
};
A page title is attacker-controlled input. Treat it that way.
Source: docs/guides/custom-protocols.md