Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/ading2210/sandstone/llms.txt

Use this file to discover all available pages before exploring further.

Sandstone works by splitting its code into two isolated bundles that communicate through a MessageChannel. The host bundle (sandstone.mjs) runs in your page and owns the network layer. The frame bundle (sandstone_frame.js) is embedded inside a sandboxed <iframe> and owns the HTML, CSS, and JavaScript rewriting pipeline. Neither bundle can access the other’s globals directly — all coordination goes through a structured RPC system. This architecture is what allows Sandstone to proxy arbitrary web content while keeping that content completely isolated from your host page.

Two-bundle architecture

When you build Sandstone with npm run build:prod, Webpack produces two output files:
  • dist/sandstone.mjs — the host bundle, imported by your page
  • dist/sandstone_frame.js — the frame bundle, inlined as a string inside the host bundle
At runtime the host bundle constructs the frame document by embedding the frame bundle inside a <script> tag, converts it to a Blob, and creates an object URL from it:
// src/host/controller.mjs
let frame_html = `
  <!DOCTYPE html>
  <head>
    <meta charset="utf-8">
    <script>${frame_js}</script>
    ...
  </head>
  <body>
    <p id="loading_text">Loading...</p>
    ...
  </body>
`;

function get_frame_bundle() {
  if (!frame_url) {
    let frame_blob = new Blob([frame_html], {type: "text/html"});
    frame_url = URL.createObjectURL(frame_blob);
  }
  return frame_url;
}
The <iframe> is pointed at this blob URL every time navigate_to is called, which resets the frame’s entire execution context before new page content is injected.

Sandboxed iframe security model

Every ProxyFrame creates an <iframe> with a tightly restricted sandbox attribute:
// src/host/controller.mjs
this.iframe = document.createElement("iframe");
this.iframe.sandbox = "allow-scripts allow-forms allow-modals allow-pointer-lock";
this.iframe.allowFullscreen = true;
The sandbox attribute defaults to denying everything. The four tokens that Sandstone adds have precise meanings:
TokenPurpose
allow-scriptsLets the frame bundle and rewritten page scripts execute
allow-formsLets proxied <form> elements submit (Sandstone intercepts these before they leave the frame)
allow-modalsLets proxied pages call alert(), confirm(), and prompt()
allow-pointer-lockRequired by some games and interactive sites
Notably absent are allow-same-origin and allow-top-navigation. Without allow-same-origin the frame runs in a unique opaque origin, so it cannot read cookies, access localStorage on the host origin, or make same-origin requests to your server. Without allow-top-navigation the proxied page cannot redirect your host page.

RPC system

Because the host and frame run in separate origins, they cannot share JavaScript objects. Sandstone implements a lightweight remote procedure call (RPC) layer over MessageChannel in src/rpc.mjs. Setup. When navigate_to is called, the host creates a MessageChannel, keeps port1 for itself, and transfers port2 into the freshly loaded frame via postMessage:
// src/host/controller.mjs
await rpc.wait_on_frame(this.iframe);
let msg_channel = new MessageChannel();
this.rpc_target.set_target(msg_channel.port1);
msg_channel.port1.start();
await rpc.set_host(this.iframe, msg_channel.port2);
Calling a procedure. Either side sends a message of type "procedure" with a unique id and an arguments array. The receiving side runs the registered handler and sends back a "reply" message with the same id:
// src/rpc.mjs
export async function call_procedure(target, procedure, args) {
  let msg = {
    type: "procedure",
    id: Math.random() + "",
    procedure: procedure,
    arguments: args
  };
  return await new Promise((resolve, reject) => {
    rpc_requests[msg.id] = (reply) => {
      if (reply.success) resolve(reply.value);
      else reject(reply.value);
    }
    target.postMessage(msg, {targetOrigin: "*"});
  });
}
Convenience wrappers. create_rpc_wrapper turns a procedure name into a plain async function so callers do not need to know about the messaging layer:
// src/host/controller.mjs
this.send_page = rpc.create_rpc_wrapper(this.rpc_target, "html");
this.get_favicon = rpc.create_rpc_wrapper(this.rpc_target, "favicon");
this.eval_js = rpc.create_rpc_wrapper(this.rpc_target, "eval");
Registered handlers. Each side registers handlers in a shared rpc_handlers object. For example, the host registers handlers for fetch, ws_new, ws_event, ws_send, and ws_close. The frame registers handlers for html, favicon, and eval.

Wisp connection and network layer

All network traffic flows through libcurl.js, which runs WebAssembly in the host page and tunnels requests through a Wisp WebSocket connection. When libcurl.js finishes loading, the host creates an HTTPSession with cookie support enabled:
// src/host/network.mjs
libcurl.events.addEventListener("libcurl_load", () => {
  console.log(`libcurl.js v${libcurl.version.lib} loaded`);
  session = new libcurl.HTTPSession({enable_cookies: true})
});
The navigate_to method on ProxyFrame fetches the initial HTML through this session before sending it to the frame:
// src/host/controller.mjs
let response = await network.session.fetch(url, options);
html = await response.text();
url = response.url; // follow redirects
Inside the frame, subsequent fetch() and XMLHttpRequest calls are intercepted and forwarded to the host via RPC. The host’s fetch handler retrieves the response body as a Blob, strips any charset suffix from the MIME type, collects the response headers, and returns everything serialisable back to the frame:
// src/host/network.mjs
rpc_handlers["fetch"] = async function(url, options) {
  var response = await session.fetch(url, options);
  var payload = {
    body: await response.blob(),
    headers: [],
    items: {}
  };
  for (let pair of response.headers.entries()) {
    payload.headers.push(pair);
  }
  return payload;
}
WebSocket connections follow a similar pattern. The host creates a libcurl.CurlWebSocket and exposes ws_new, ws_send, ws_event, and ws_close RPC handlers. The frame polls ws_event to receive events, which returns immediately if events are queued or waits on a Promise until the next event arrives.

JavaScript rewriting pipeline

Proxied JavaScript must be rewritten before it runs so that references to browser globals like window, document, location, and localStorage are redirected to Sandstone’s own controlled versions instead of the real iframe globals.

The context object (__ctx__)

src/frame/context.mjs defines CustomCTX, a class whose getters and setters intercept every access to security-sensitive globals. At startup, update_ctx() installs it on globalThis under the name __ctx__:
// src/frame/context.mjs
export function update_ctx() {
  internal.location = new polyfill.FakeLocation();
  internal.localStorage = new polyfill.FakeStorage("local");
  // ...
  globalThis.__ctx__ = ctx.__proxy__;
  globalThis.__get_this__ = ctx.__get_this__;
  globalThis.__get_var__ = ctx.__get_var__;
}
FakeLocation, FakeStorage, and the other polyfills route reads and writes through the RPC layer or the host’s proxied copies of those values.

AST-based rewriting

src/frame/parser.mjs rewrites JavaScript source text using Meriyah as the parser and astray as the AST walker. For each script, it produces a list of source-level patches and applies them in a single linear pass. The ASTVisitor class identifies two categories of node that need rewriting: this expressions — rewritten to __get_this__(this), which returns the __ctx__ proxy when this is the real globalThis and returns the original value otherwise:
// src/frame/parser.mjs
ThisExpression(node) {
  this.rewrites.push({type: "this", pos: node.start, parentheses: parentheses});
}

// Generated replacement:
// __get_this__(this)
Global identifiers — any identifier whose name appears in ctx_vars (the list of properties on CustomCTX) is rewritten. Most are wrapped with __get_var__, which checks whether the local variable still holds the original global value and substitutes the __ctx__ version if so. Identifiers like location that can appear on the left-hand side of an assignment are rewritten as __ctx__.location directly:
// src/frame/parser.mjs
Identifier(node) {
  if (!ctx_vars.includes(node.name)) return;
  // ...
  this.rewrites.push({type: "global", pos: node.start, name: node.name, simple: simple});
}

// Generated replacements:
// window      →  (__get_var__(window, "window"))
// location =  →  __ctx__.location =
// localStorage → __ctx__.localStorage
The final rewriter splices the replacement strings into the original source at the recorded positions:
// src/frame/parser.mjs
export function rewrite_js(js) {
  let ast = meriyah.parse(js, {ranges: true, webcompat: true});
  let ast_visitor = new ASTVisitor(ast);
  astray.walk(ast, ast_visitor);
  ast_visitor.rewrites.sort((a, b) => a.pos - b.pos);

  let rewritten_js = "";
  let prev_offset = 0;
  for (let rewrite of ast_visitor.rewrites) {
    rewritten_js += js.substring(prev_offset, rewrite.pos);
    let [replacement, offset] = gen_rewrite_code(rewrite);
    rewritten_js += replacement;
    prev_offset = offset;
  }
  rewritten_js += js.substring(prev_offset);
  return rewritten_js || js;
}
Scripts that contain "use asm" are skipped entirely — asm.js modules are sensitive to source mutations and cannot be safely rewritten.

HTML and CSS rewriting pipeline

When the host sends an HTML string to the frame via the html RPC handler, src/frame/loader.mjs parses it with the browser’s own DOMParser and then walks every element through the rewrite layer:
// src/frame/loader.mjs
let parser = new DOMParser();
let html = parser.parseFromString(options.html, "text/html");

// Rewrite all HTML elements (URLs, inline styles, scripts, etc.)
await rewrite.element(html.documentElement);

// Apply the rewritten document tree to the live frame
document.documentElement.replaceWith(html.documentElement);

// Evaluate collected inline and external scripts in order
if (site_settings.allow_js) {
  evaluate_scripts();
}
After the DOM is applied, loader.mjs fires synthetic DOMContentLoaded, readystatechange, and load events on the proxied document and window objects so that page code that listens for those events runs at the right time. Anchor tag navigation is handled by a global click listener that intercepts every click, resolves the target href against the current proxied origin, and calls the navigate RPC procedure on the host — triggering a new navigate_to cycle without ever letting the browser follow the link natively:
// src/frame/loader.mjs
document.addEventListener("click", (event) => {
  let element = event.target;
  while (element && !(element instanceof HTMLAnchorElement)) {
    element = element.parentElement;
  }
  if (!element) return;

  event.preventDefault();
  event.stopImmediatePropagation();

  let original_href = convert_url(element.href, ctx.location.href);
  navigate(frame_id, original_href);
});

Build docs developers (and LLMs) love