Skip to main content
Scramjet uses transport libraries to proxy HTTP requests and WebSocket connections. The primary transport system is bare-mux, which provides a pluggable architecture for different backend protocols.

Transport architecture

Scramjet’s transport layer consists of:
  1. BareClient - Main client interface from @mercuryworkshop/bare-mux
  2. Transport backends - Pluggable implementations (bare-server, epoxy, wisp, libcurl)
  3. BareMuxConnection - Worker-to-client communication bridge
┌─────────────────┐
│  Scramjet Client │
└────────┬─────────┘
         │ fetch()/WebSocket()

┌─────────────────┐
│   BareClient    │
└────────┬─────────┘
         │ bare-mux protocol

┌─────────────────┐
│   Transport     │  ← bare-server, epoxy, wisp, etc.
└────────┬─────────┘


   Target Server

bare-mux integration

bare-mux is Scramjet’s default transport multiplexer, allowing runtime transport switching.

Installation

{
  "dependencies": {
    "@mercuryworkshop/bare-mux": "^2.1.7"
  }
}

Service worker setup

The service worker creates a BareClient instance:
import BareClient from "@mercuryworkshop/bare-mux";

export class ScramjetServiceWorker extends EventTarget {
  client: BareClient;
  
  constructor() {
    super();
    this.client = new BareClient();
  }
  
  async fetch(event: FetchEvent) {
    // Use BareClient to proxy the request
    const response = await this.client.fetch(url, {
      method: request.method,
      headers: requestHeaders,
      body: request.body,
      credentials: "omit",
      redirect: "manual",
    });
    
    return response;
  }
}
BareClient provides a fetch()-like API that automatically routes requests through the configured transport.

Window/client setup

In the window context, Scramjet creates a separate BareClient:
import BareClient from "@mercuryworkshop/bare-mux";

export class ScramjetClient {
  bare: BareClient;
  
  constructor(public global: typeof globalThis) {
    if (iswindow) {
      this.bare = new BareClient();
    } else {
      // Workers receive BareMuxConnection via postMessage
      this.bare = new BareClient(
        new Promise((resolve) => {
          addEventListener("message", ({ data }) => {
            if (data.$scramjet$type === "baremuxinit") {
              resolve(data.port);
            }
          });
        })
      );
    }
  }
}

Worker transport bridge

Workers need a MessagePort to communicate with the parent’s BareClient:
import { BareMuxConnection } from "@mercuryworkshop/bare-mux";

client.Proxy("Worker", {
  construct(ctx) {
    ctx.args[0] = rewriteUrl(ctx.args[0], client.meta) + "?dest=worker";
    const worker = ctx.call();
    
    // Create BareMux connection
    const conn = new BareMuxConnection();
    
    (async () => {
      const port = await conn.getInnerPort();
      
      // Send port to worker
      client.natives.call(
        "Worker.prototype.postMessage",
        worker,
        {
          $scramjet$type: "baremuxinit",
          port,
        },
        [port] // Transfer port
      );
    })();
  },
});
Workers cannot directly access the transport because:
  1. Isolation: Workers run in separate contexts without DOM access
  2. Shared state: Multiple workers need to share the same transport configuration
  3. Performance: Centralized transport reduces overhead
BareMuxConnection creates a MessageChannel that bridges the worker to the main context’s BareClient.

Transport backends

bare-server (default)

The standard Bare server protocol:
import BareClient from "@mercuryworkshop/bare-mux";
import { createBareServer } from "@nebula-services/bare-server-node";

// Server-side setup
const bareServer = createBareServer("/bare/");

app.use((req, res, next) => {
  if (bareServer.shouldRoute(req)) {
    bareServer.routeRequest(req, res);
  } else {
    next();
  }
});
Client configuration:
// Set transport to bare-server
await BareClient.SetTransport("/bare/", "bare");

epoxy-transport

Epoxy uses WebTransport for improved performance:
{
  "devDependencies": {
    "@mercuryworkshop/epoxy-transport": "^2.1.28"
  }
}
Setup:
import { EpoxyClient } from "@mercuryworkshop/epoxy-transport";

// Initialize epoxy transport
await BareClient.SetTransport("https://epoxy.example.com/", "epoxy");

// Use BareClient as normal
const response = await bareClient.fetch(url);
Epoxy requires browser support for WebTransport API (Chromium-based browsers). Fallback to bare-server for compatibility.

wisp protocol

Wisp provides WebSocket-based proxying:
{
  "devDependencies": {
    "@mercuryworkshop/wisp-js": "^0.3.3"
  }
}
Configuration:
import { WispClient } from "@mercuryworkshop/wisp-js";

// Set wisp transport
await BareClient.SetTransport("wss://wisp.example.com/", "wisp");
Wisp is particularly useful for:
  • Environments where HTTP proxying is restricted
  • Bypassing certain network filters
  • Multiplexing connections over a single WebSocket

libcurl-transport

Native performance using libcurl:
{
  "devDependencies": {
    "@mercuryworkshop/libcurl-transport": "^1.5.0"
  }
}
libcurl-transport requires native bindings and is primarily used in Electron or Node.js environments.

Setting the transport

Static configuration

Set transport during Scramjet initialization:
const { ScramjetController } = $scramjetLoadController();
const { BareClient } = await import("@mercuryworkshop/bare-mux");

const controller = new ScramjetController({
  prefix: "/service/",
  files: {
    wasm: "/scramjet.wasm.wasm",
    all: "/scramjet.all.js",
    sync: "/scramjet.sync.js",
  },
});

// Set default transport
await BareClient.SetTransport("/bare/", "bare");

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

Dynamic transport switching

Users can switch transports at runtime:
// Switch to epoxy
await BareClient.SetTransport("https://epoxy.example.com/", "epoxy");

// Switch to wisp
await BareClient.SetTransport("wss://wisp.example.com/", "wisp");

// Switch back to bare-server
await BareClient.SetTransport("/bare/", "bare");
Transport switching is seamless - existing connections continue using the old transport while new requests use the updated configuration.

WebSocket proxying

Scramjet uses bare-mux’s BareWebSocket class for WebSocket connections:
import { type BareWebSocket } from "@mercuryworkshop/bare-mux";

client.Proxy("WebSocket", {
  construct(ctx) {
    const url = new URL(ctx.args[0], client.meta.base);
    const protocols = ctx.args[1];
    
    // Create bare WebSocket
    const ws = new client.bare.WebSocket(url.href, protocols) as BareWebSocket;
    
    // Proxy properties
    Object.defineProperty(ws, "url", {
      get: () => url.href,
      enumerable: true,
    });
    
    ctx.return(ws);
  },
});

WebSocket protocol handling

Different transports handle WebSockets differently:
  • bare-server: Upgrades HTTP connection to WebSocket
  • wisp: Multiplexes over existing wisp WebSocket connection
  • epoxy: Uses WebTransport streams

Request/response flow

Standard fetch request

// 1. User code
fetch("https://example.com/api");

// 2. Scramjet intercepts (client hooks)
fetch(rewriteUrl("https://example.com/api"));
// → fetch("/service/aHR0cHM6Ly9leGFtcGxlLmNvbS9hcGk=");

// 3. Service worker routes to handleFetch
const url = unrewriteUrl(request.url);
// → "https://example.com/api"

// 4. BareClient proxies request
const response = await bareClient.fetch(url, {
  method: "GET",
  headers: rewrittenHeaders,
});

// 5. Transport executes request
// (bare-server makes actual HTTP request)

// 6. Response flows back through rewriters
const body = await rewriteBody(response, meta, destination);
return new Response(body, { headers: rewrittenHeaders });

Header rewriting

BareClient handles header transformations:
import { rewriteHeaders } from "@rewriters/headers";
import type { BareHeaders } from "@mercuryworkshop/bare-mux";

export async function rewriteHeaders(
  rawHeaders: BareHeaders,
  meta: URLMeta,
  client: BareClient
) {
  const headers = {};
  
  // Lowercase all headers
  for (const key in rawHeaders) {
    headers[key.toLowerCase()] = rawHeaders[key];
  }
  
  // Remove security headers
  const SEC_HEADERS = new Set([
    "content-security-policy",
    "x-frame-options",
    "cross-origin-opener-policy",
    // ...
  ]);
  
  for (const header of SEC_HEADERS) {
    delete headers[header];
  }
  
  // Rewrite URL headers
  if (headers["location"]) {
    headers["location"] = rewriteUrl(headers["location"], meta);
  }
  
  return headers;
}

Response types

BareClient returns a BareResponseFetch object:
import type { BareResponseFetch } from "@mercuryworkshop/bare-mux";

const response: BareResponseFetch = await bareClient.fetch(url);

// Standard Response properties
response.status;      // 200
response.statusText;  // "OK"
response.headers;     // Headers object
response.body;        // ReadableStream

// Additional bare-mux properties
response.finalURL;    // Final URL after redirects
response.rawHeaders;  // Raw headers object

Debugging transports

Check current transport

import BareClient from "@mercuryworkshop/bare-mux";

const transport = await BareClient.GetTransport();
console.log("Current transport:", transport);
// { name: "bare", url: "/bare/" }

Monitor requests

client.addEventListener("request", (event) => {
  console.log("Request:", event.url);
  console.log("Headers:", event.requestHeaders);
});

client.addEventListener("handleResponse", (event) => {
  console.log("Response:", event.url);
  console.log("Status:", event.status);
  console.log("Headers:", event.responseHeaders);
});

Error handling

try {
  const response = await bareClient.fetch(url);
} catch (err) {
  if (err.message.includes("transport")) {
    console.error("Transport error - check backend connectivity");
  }
  
  if (err.message.includes("CORS")) {
    console.error("Transport backend has CORS issues");
  }
  
  throw err;
}

Advanced patterns

Custom transport implementation

You can implement custom transports by following the bare-mux protocol:
class CustomTransport {
  async fetch(url: URL, init: RequestInit) {
    // Custom logic here
    const response = await myCustomFetch(url, init);
    
    return {
      status: response.status,
      statusText: response.statusText,
      headers: response.headers,
      body: response.body,
      finalURL: response.url,
      rawHeaders: response.rawHeaders,
    };
  }
  
  createWebSocket(url: URL, protocols?: string[]) {
    return new MyCustomWebSocket(url, protocols);
  }
}

// Register custom transport
await BareClient.SetTransport("/custom/", "custom", CustomTransport);

Transport fallback chain

const transports = [
  { url: "/epoxy/", type: "epoxy" },
  { url: "/wisp/", type: "wisp" },
  { url: "/bare/", type: "bare" },
];

for (const { url, type } of transports) {
  try {
    await BareClient.SetTransport(url, type);
    
    // Test transport
    await bareClient.fetch("https://example.com/");
    console.log(`Using ${type} transport`);
    break;
  } catch (err) {
    console.warn(`${type} failed, trying next...`);
  }
}

Conditional transport selection

function selectTransport() {
  // Use epoxy for Chrome
  if (navigator.userAgent.includes("Chrome")) {
    return { url: "/epoxy/", type: "epoxy" };
  }
  
  // Use wisp for restricted networks
  if (isRestrictedNetwork()) {
    return { url: "wss://wisp.example.com/", type: "wisp" };
  }
  
  // Default to bare-server
  return { url: "/bare/", type: "bare" };
}

const transport = selectTransport();
await BareClient.SetTransport(transport.url, transport.type);

Common issues

Transport not initialized

// Error: BareClient transport not set

// Solution: Set transport before using BareClient
await BareClient.SetTransport("/bare/", "bare");

Worker transport errors

// Error: Worker cannot access BareClient

// Solution: Ensure BareMuxConnection is properly initialized
const conn = new BareMuxConnection();
const port = await conn.getInnerPort();
worker.postMessage({ $scramjet$type: "baremuxinit", port }, [port]);

CORS errors

// Error: CORS policy blocked request

// Solution: Ensure transport backend allows requests from your origin
// In bare-server:
app.use((req, res, next) => {
  res.header("Access-Control-Allow-Origin", "*");
  next();
});

Build docs developers (and LLMs) love