Skip to main content
The ScramjetServiceWorker is the core component that intercepts all network requests and proxies them through the configured transport. This page explains how it works.

Overview

Service Workers provide a programmable network proxy that runs in the browser. Scramjet leverages this to intercept fetch requests, decode URLs, fetch content, rewrite it, and return the modified response.
Service Workers run in a separate context from your web pages. They persist across page loads and can intercept requests from multiple clients (tabs/windows).

Class structure

The ScramjetServiceWorker class is defined in src/worker/index.ts:
export class ScramjetServiceWorker extends EventTarget {
  client: BareClient;              // Transport for making proxied requests
  config: ScramjetConfig;          // Current configuration
  syncPool: Record<number, (val?: any) => void> = {}; // Message queue
  synctoken: number = 0;           // Current sync token
  cookieStore: CookieStore;        // Cookie emulation
  serviceWorkers: FakeServiceWorker[]; // Fake SW registrations
}

Initialization

When the Service Worker starts, it creates a ScramjetServiceWorker instance:
const scramjet = new ScramjetServiceWorker();

self.addEventListener("fetch", async (event) => {
  if (scramjet.route(event)) {
    event.respondWith(scramjet.fetch(event));
  }
});
The constructor initializes:
  1. BareClient for transport
  2. CookieStore for cookie emulation
  3. Message listeners for client communication
  4. IndexedDB to load saved cookies

Loading configuration

The Service Worker loads its configuration from IndexedDB:
await scramjet.loadConfig();
This is called automatically by scramjet.fetch() if the config hasn’t been loaded yet. The config contains:
  • URL prefix for routing
  • Feature flags
  • Codec functions for URL encoding/decoding
  • File paths for injected scripts
Configuration can be dynamically updated at runtime via messages from the controller. Changes are persisted to IndexedDB.

Request routing

The route() method determines if a request should be handled by Scramjet:
route({ request }: FetchEvent): boolean {
  if (request.url.startsWith(location.origin + this.config.prefix))
    return true;
  else if (request.url.startsWith(location.origin + this.config.files.wasm))
    return true;
  else return false;
}
Requests are routed if they:
  • Start with the configured prefix (e.g., /scramjet/)
  • Request the WASM rewriter file

Request handling

The fetch() method orchestrates the entire proxy flow:
async fetch({ request, clientId }: FetchEvent): Promise<Response> {
  if (!this.config) await this.loadConfig();
  const client = await self.clients.get(clientId);
  return handleFetch.call(this, request, client);
}
The actual logic is in handleFetch() from src/worker/fetch.ts.

Fetch flow

1

Parse query parameters

Extract metadata from the URL’s query string:
const extraParams: Record<string, string> = {};
for (const [param, value] of requestUrl.searchParams.entries()) {
  switch (param) {
    case "type":      // Script type (module, classic)
      scriptType = value;
      break;
    case "topFrame":  // Top frame name
      topFrameName = value;
      break;
    case "parentFrame": // Parent frame name
      parentFrameName = value;
      break;
    default:
      extraParams[param] = value; // Form parameters
      break;
  }
  requestUrl.searchParams.delete(param);
}
2

Decode URL

Convert the proxied URL back to the real destination:
const url = new URL(unrewriteUrl(requestUrl));
// Add back any extra params (e.g., from forms)
for (const [param, value] of Object.entries(extraParams)) {
  url.searchParams.set(param, value);
}
3

Build metadata context

Create the URLMeta object for rewriters:
const meta: URLMeta = {
  origin: url,
  base: url,
  topFrameName,
  parentFrameName,
};
4

Handle special URLs

Blob and data URLs are handled directly:
if (requestUrl.pathname.startsWith(`${this.config.prefix}blob:`) ||
    requestUrl.pathname.startsWith(`${this.config.prefix}data:`)) {
  let dataUrl = requestUrl.pathname.substring(this.config.prefix.length);
  if (dataUrl.startsWith("blob:")) {
    dataUrl = unrewriteBlob(dataUrl);
  }
  const response = await fetch(dataUrl);
  // Rewrite body and return
}
5

Process request headers

Rewrite headers for the real request:
const headers = new ScramjetHeaders();
for (const [key, value] of request.headers.entries()) {
  headers.set(key, value);
}

// Set correct Origin and Referer
if (client && new URL(client.url).pathname.startsWith(config.prefix)) {
  const clientURL = new URL(unrewriteUrl(client.url));
  headers.set("Referer", clientURL.href);
  headers.set("Origin", clientURL.origin);
}

// Add cookies from the cookie store
const cookies = this.cookieStore.getCookies(url, false);
if (cookies.length) {
  headers.set("Cookie", cookies);
}
6

Dispatch request event

Allow external code to modify the request:
const ev = new ScramjetRequestEvent(
  url,
  headers.headers,
  request.body,
  request.method,
  request.destination,
  client
);
this.dispatchEvent(ev);
7

Fetch via transport

Use BareClient to make the actual request:
const response = await this.client.fetch(ev.url, {
  method: ev.method,
  body: ev.body,
  headers: ev.requestHeaders,
  credentials: "omit",
  mode: request.mode === "cors" ? request.mode : "same-origin",
  cache: request.cache,
  redirect: "manual",
  duplex: "half",
}) as BareResponseFetch;
8

Process response

Rewrite headers, handle cookies, and rewrite the body based on content type.

Response handling

The handleResponse() function processes the fetched response:

Header rewriting

const responseHeaders = await rewriteHeaders(
  response.rawHeaders,
  meta,
  bareClient,
  { get: getReferrerPolicy, set: storeReferrerPolicy }
);
This:
  • Rewrites Location headers for redirects
  • Processes Set-Cookie headers
  • Removes Permissions-Policy headers that might block Scramjet features
  • Handles CORS headers
const maybeHeaders = responseHeaders["set-cookie"] || [];

// Send cookies to clients
for (const cookie in maybeHeaders) {
  if (client) {
    const promise = swtarget.dispatch(client, {
      scramjet$type: "cookie",
      cookie,
      url: url.href,
    });
  }
}

// Store in the Service Worker's cookie jar
await cookieStore.setCookies(
  maybeHeaders instanceof Array ? maybeHeaders : [maybeHeaders],
  url
);

Body rewriting

Based on the request destination, the response body is rewritten:
async function rewriteBody(
  response: BareResponseFetch,
  meta: URLMeta,
  destination: RequestDestination,
  workertype: string,
  cookieStore: CookieStore
): Promise<BodyType> {
  switch (destination) {
    case "iframe":
    case "document":
      if (response.headers.get("content-type")?.startsWith("text/html")) {
        return rewriteHtml(await response.text(), cookieStore, meta, true);
      }
      return response.body;
      
    case "script":
      return rewriteJs(
        new Uint8Array(await response.arrayBuffer()),
        response.finalURL,
        meta,
        workertype === "module"
      );
      
    case "style":
      return rewriteCss(await response.text(), meta);
      
    case "sharedworker":
    case "worker":
      return rewriteWorkers(
        new Uint8Array(await response.arrayBuffer()),
        workertype,
        response.finalURL,
        meta
      );
      
    default:
      return response.body;
  }
}
HTML rewriting injects Scramjet’s client scripts into the <head> and rewrites:
  • URL attributes (src, href, action, etc.)
  • Inline <script> and <style> tags
  • Event handler attributes (onclick, etc.)
  • Import maps
  • <base> tags
  • Meta refresh tags
  • CSP meta tags (removed)
JavaScript is rewritten using an oxc-based WASM rewriter that:
  • Wraps function calls to intercept APIs
  • Rewrites property accesses
  • Injects runtime helpers
  • Generates source maps for debugging
  • Handles both classic scripts and ES modules

Download interception

When the interceptDownloads flag is enabled, Scramjet can intercept file downloads:
if (isDownload(responseHeaders, destination) && !isRedirect(response)) {
  if (flagEnabled("interceptDownloads", url)) {
    const download: ScramjetDownload = {
      filename,
      url: url.href,
      type: responseHeaders["content-type"],
      body: response.body,
      length: Number(length),
    };
    
    // Send to controller client
    clis[0].postMessage({
      scramjet$type: "download",
      download,
    }, [response.body]);
    
    // Prevent the download from completing
    await new Promise(() => {});
  }
}

Message passing

The Service Worker communicates with clients via postMessage:

Receiving messages

addEventListener("message", async ({ data }: { data: MessageC2W }) => {
  if (!("scramjet$type" in data)) return;

  if ("scramjet$token" in data) {
    // Acknowledge message
    const cb = this.syncPool[data.scramjet$token];
    delete this.syncPool[data.scramjet$token];
    cb(data);
    return;
  }

  if (data.scramjet$type === "registerServiceWorker") {
    this.serviceWorkers.push(new FakeServiceWorker(data.port, data.origin));
  }

  if (data.scramjet$type === "cookie") {
    this.cookieStore.setCookies([data.cookie], new URL(data.url));
    const db = await openDB<ScramjetDB>("$scramjet", 1);
    await db.put("cookies", JSON.parse(this.cookieStore.dump()), "cookies");
  }

  if (data.scramjet$type === "loadConfig") {
    this.config = data.config;
  }
});

Dispatching messages

The dispatch() method sends messages and waits for responses:
async dispatch(client: Client, data: MessageW2C): Promise<MessageC2W> {
  const token = this.synctoken++;
  let cb: (val: MessageC2W) => void;
  const promise: Promise<MessageC2W> = new Promise((r) => (cb = r));
  this.syncPool[token] = cb;
  data.scramjet$token = token;

  client.postMessage(data);

  return await promise;
}
Scramjet emulates cookies using the CookieStore class because:
  1. Service Workers cannot access document.cookie
  2. Real cookies would leak the proxy origin
  3. Cookie attributes (domain, path, etc.) need special handling
Cookies are:
  • Stored in the Service Worker’s CookieStore instance
  • Persisted to IndexedDB
  • Synced to clients via postMessage
  • Added to outgoing requests via the Cookie header

Events

The Service Worker dispatches custom events:

ScramjetRequestEvent

Fired before making a request, allowing modification:
scramjet.addEventListener("request", (event) => {
  console.log("Requesting:", event.url.href);
  // Modify event.requestHeaders, event.body, etc.
});

ScramjetHandleResponseEvent

Fired after receiving a response:
scramjet.addEventListener("handleResponse", (event) => {
  console.log("Received:", event.url.href, event.status);
  // Modify event.responseHeaders, event.responseBody, etc.
});

Best practices

Never modify the Service Worker config directly. Always use the controller’s modifyConfig() method to ensure changes are persisted.
Use the request event to add custom headers or authentication to proxied requests.

Next steps

URL rewriting

Learn how URLs are encoded, decoded, and rewritten

Configuration

Explore Service Worker configuration options

Build docs developers (and LLMs) love