Sandstone works by splitting its code into two isolated bundles that communicate through aDocumentation 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.
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 withnpm run build:prod, Webpack produces two output files:
dist/sandstone.mjs— the host bundle, imported by your pagedist/sandstone_frame.js— the frame bundle, inlined as a string inside the host bundle
<script> tag, converts it to a Blob, and creates an object URL from it:
<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
EveryProxyFrame creates an <iframe> with a tightly restricted sandbox attribute:
sandbox attribute defaults to denying everything. The four tokens that Sandstone adds have precise meanings:
| Token | Purpose |
|---|---|
allow-scripts | Lets the frame bundle and rewritten page scripts execute |
allow-forms | Lets proxied <form> elements submit (Sandstone intercepts these before they leave the frame) |
allow-modals | Lets proxied pages call alert(), confirm(), and prompt() |
allow-pointer-lock | Required by some games and interactive sites |
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 overMessageChannel 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:
"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:
create_rpc_wrapper turns a procedure name into a plain async function so callers do not need to know about the messaging layer:
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 anHTTPSession with cookie support enabled:
navigate_to method on ProxyFrame fetches the initial HTML through this session before sending it to the frame:
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:
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 likewindow, 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__:
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:
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:
"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 thehtml RPC handler, src/frame/loader.mjs parses it with the browser’s own DOMParser and then walks every element through the rewrite layer:
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: