Loopback HTTP bridge for spel bridge — the transport that lets an
in-page spel.js engine subscribe to a spel server and exchange commands
two ways.
Why this exists: in locked-down corporate environments the Chrome DevTools
Protocol (CDP) is disabled, so classic Playwright/CDP automation is dead.
But loopback traffic (127.0.0.1) never leaves the machine, so a page that
<script src>-embeds spel.js can talk to a local spel server the proxy
never sees. This bridge is that server side.
Transport: Server-Sent Events (inbound commands, server → browser) plus a
JSON POST endpoint (outbound results, browser → server). SSE + POST is used
rather than WebSocket because it is the JDK-native option (no extra deps,
com.sun.net.httpserver) and behaves predictably on loopback. spel.js
also speaks WebSocket first, but the bundled server implements the SSE
fallback path.
Endpoints (all under the configurable :path, default /spel):
GET /spel → SSE stream; each browser tab that connects is a client
POST /spel/result → browser posts an invoke result here (correlated by id)
POST /spel/command → an external client (the spel CLI) submits one command;
it is pushed to the connected tab and the browser's
result is returned as the HTTP response (this is what
lets regular spel <verb> route through the bridge)
POST /spel/hello → browser announces tab metadata (url, title, ua, transport)
on connect; stored against its SSE client id and surfaced
back as the connected-profiles list
GET /spel/clients → the connected profiles (tabs) currently subscribed,
as JSON; token-gated like every other endpoint
GET /spel.js → the embedded engine source (same file that ships in
the native image)
GET / → a tiny harness page that loads the engine and connects
Loopback HTTP bridge for `spel bridge` — the transport that lets an
in-page `spel.js` engine subscribe to a spel server and exchange commands
two ways.
Why this exists: in locked-down corporate environments the Chrome DevTools
Protocol (CDP) is disabled, so classic Playwright/CDP automation is dead.
But loopback traffic (`127.0.0.1`) never leaves the machine, so a page that
`<script src>`-embeds `spel.js` can talk to a local spel server the proxy
never sees. This bridge is that server side.
Transport: Server-Sent Events (inbound commands, server → browser) plus a
JSON POST endpoint (outbound results, browser → server). SSE + POST is used
rather than WebSocket because it is the JDK-native option (no extra deps,
`com.sun.net.httpserver`) and behaves predictably on loopback. `spel.js`
also speaks WebSocket first, but the bundled server implements the SSE
fallback path.
Endpoints (all under the configurable `:path`, default `/spel`):
GET /spel → SSE stream; each browser tab that connects is a client
POST /spel/result → browser posts an invoke result here (correlated by id)
POST /spel/command → an external client (the spel CLI) submits one command;
it is pushed to the connected tab and the browser's
result is returned as the HTTP response (this is what
lets regular `spel <verb>` route through the bridge)
POST /spel/hello → browser announces tab metadata (url, title, ua, transport)
on connect; stored against its SSE client id and surfaced
back as the connected-profiles list
GET /spel/clients → the connected profiles (tabs) currently subscribed,
as JSON; token-gated like every other endpoint
GET /spel.js → the embedded engine source (same file that ships in
the native image)
GET / → a tiny harness page that loads the engine and connects(bookmarklet origin path token)The loader as a ready javascript: bookmarklet URL — drag it to the bookmarks
bar (or Edge favorites); clicking it on any page injects + connects the engine.
Note: a page's Content-Security-Policy or a managed browser policy can still
block inline/bookmarklet execution — see spel bridge --help.
The loader as a ready `javascript:` bookmarklet URL — drag it to the bookmarks bar (or Edge favorites); clicking it on any page injects + connects the engine. Note: a page's Content-Security-Policy or a managed browser policy can still block inline/bookmarklet execution — see `spel bridge --help`.
(clear-runtime!)Removes the runtime discovery file (bridge shutting down).
Removes the runtime discovery file (bridge shutting down).
(clear-target!)(clear-target! path)Removes the persisted bridge target; regular commands go back to the daemon. Returns true if a target file was removed.
Removes the persisted bridge target; regular commands go back to the daemon. Returns true if a target file was removed.
(create-bridge &
{:keys [host port path token]
:or {host "127.0.0.1" port 8787 path "/spel"}})Creates and starts a loopback bridge. Returns a map:
:url the SSE/connect URL to hand to spel.js connect({url})
:page the harness page URL (open it in a browser to auto-connect)
:host :port :path
:clients count of currently-subscribed tabs (deref-able fn)
:clients-list fn returning the connected profiles (maps with :url,
:title, :user-agent, :transport, :connected-at) for the dialog
:send (fn [command] promise) — pushes a command to every connected
tab; the promise is delivered with the first result posted back
:send! (fn [command] result | (fn [command timeout-ms] result)) —
blocking convenience wrapper around :send
:stop (fn []) — stops the server and releases the port
opts: :host (default "127.0.0.1") :port (default 8787) :path (default "/spel"). Binds to loopback only by design — this never listens on a routable address.
Creates and starts a loopback bridge. Returns a map:
:url the SSE/connect URL to hand to `spel.js` `connect({url})`
:page the harness page URL (open it in a browser to auto-connect)
:host :port :path
:clients count of currently-subscribed tabs (deref-able fn)
:clients-list fn returning the connected profiles (maps with :url,
:title, :user-agent, :transport, :connected-at) for the dialog
:send (fn [command] promise) — pushes a command to every connected
tab; the promise is delivered with the first result posted back
:send! (fn [command] result | (fn [command timeout-ms] result)) —
blocking convenience wrapper around :send
:stop (fn []) — stops the server and releases the port
opts: :host (default "127.0.0.1") :port (default 8787) :path (default "/spel").
Binds to loopback only by design — this never listens on a routable address.(eject-origin url host port path)Resolves the (origin, path) an ejected loader/bookmarklet should target.
With an explicit url (e.g. http://host:port/spel) it is split into an
origin (scheme://authority) and a path; otherwise the serving
host/port/path defaults are used. Returns [origin path].
Resolves the (origin, path) an ejected loader/bookmarklet should target. With an explicit `url` (e.g. http://host:port/spel) it is split into an origin (`scheme://authority`) and a path; otherwise the serving `host`/`port`/`path` defaults are used. Returns `[origin path]`.
(engine-source)Returns the embedded spel.js source as a string, or throws if the resource
is missing from the classpath / native image.
Returns the embedded `spel.js` source as a string, or throws if the resource is missing from the classpath / native image.
(extension-bootstrap-script)MAIN-world bootstrap. Runs right after engine.js (which defines window.__spel)
and waits for the isolated content script to hand over the saved bridge route,
then connects the page engine to it.
MAIN-world bootstrap. Runs right after engine.js (which defines `window.__spel`) and waits for the isolated content script to hand over the saved bridge route, then connects the page engine to it.
(extension-content-script origin path token)ISOLATED-world content script. Reads the saved bridge {url, token} from
chrome.storage.local (set via the popup), falling back to the values baked in at
eject time, and dispatches them to the MAIN-world engine via a spel:connect
CustomEvent. Re-fires when the stored route changes.
ISOLATED-world content script. Reads the saved bridge `{url, token}` from
chrome.storage.local (set via the popup), falling back to the values baked in at
eject time, and dispatches them to the MAIN-world engine via a `spel:connect`
CustomEvent. Re-fires when the stored route changes.(extension-files origin path token)Returns the full set of files for the unpacked MV3 extension as an ordered
vector of [relative-path content-string]. Reuses the embedded engine + service
worker verbatim; the rest is thin MV3 glue with the bridge route baked in.
Returns the full set of files for the unpacked MV3 extension as an ordered vector of `[relative-path content-string]`. Reuses the embedded engine + service worker verbatim; the rest is thin MV3 glue with the bridge route baked in.
(extension-manifest)The Manifest V3 manifest.json for the spel bridge browser extension.
Two content-script entries run at document_start on every page: engine.js +
bootstrap.js in the page's MAIN world (so window.__spel is defined on the
page), and content.js in the ISOLATED world (it can read chrome.storage for the
saved bridge route and hand it to the MAIN world via a CustomEvent). A host
permission for loopback lets the injected engine fetch/connect the local bridge
without the Local Network Access prompt.
The Manifest V3 `manifest.json` for the spel bridge browser extension. Two content-script entries run at document_start on every page: engine.js + bootstrap.js in the page's MAIN world (so `window.__spel` is defined on the page), and content.js in the ISOLATED world (it can read chrome.storage for the saved bridge route and hand it to the MAIN world via a CustomEvent). A host permission for loopback lets the injected engine fetch/connect the local bridge without the Local Network Access prompt.
(extension-popup-html)The extension popup: enter/override the bridge URL + token; saved to chrome.storage.local so it persists across browser restarts on any site.
The extension popup: enter/override the bridge URL + token; saved to chrome.storage.local so it persists across browser restarts on any site.
(extension-popup-js)Popup logic: prefill from storage, persist the bridge URL + token on save.
Popup logic: prefill from storage, persist the bridge URL + token on save.
(extension-readme origin path token)A short install/usage README dropped into the ejected extension folder.
A short install/usage README dropped into the ejected extension folder.
(icon-bytes px)Returns the embedded extension icon PNG (icon-<px>.png) as a byte array,
or throws if the resource is missing from the classpath / native image.
Returns the embedded extension icon PNG (`icon-<px>.png`) as a byte array, or throws if the resource is missing from the classpath / native image.
(load-target)(load-target path)Reads the persisted bridge target, or nil when none is set. Shape:
{:url "http://127.0.0.1:8787/spel"}.
Reads the persisted bridge target, or nil when none is set. Shape:
`{:url "http://127.0.0.1:8787/spel"}`.(loader-script origin path token)A tiny, minified in-page loader (plain JS, no javascript: prefix) that
injects the embedded engine from <origin>/spel.js and subscribes it to the
bridge at <origin><path>. Idempotent: if window.__spel is already
installed it just (re)connects.
Local Network Access aware: modern Chromium (Edge 143+/Chrome 142+) gates a
public origin reaching 127.0.0.1 behind a per-origin permission, and a bare
<script src> no-cors subresource to loopback is DENIED silently instead of
prompting. So the loader fetches the engine with targetAddressSpace:'loopback'
— the sanctioned call that actually raises the grantable prompt (and is exempt
from mixed-content once allowed) — then inline-injects the text. It falls back
to a <script src> tag on older browsers where either path works.
Returned WITHOUT a prefix so it serves two uses — pasted into the DevTools
Console (or saved as a Sources Snippet), or prefixed with javascript: to
form a draggable bookmarklet.
A tiny, minified in-page loader (plain JS, no `javascript:` prefix) that injects the embedded engine from `<origin>/spel.js` and subscribes it to the bridge at `<origin><path>`. Idempotent: if `window.__spel` is already installed it just (re)connects. Local Network Access aware: modern Chromium (Edge 143+/Chrome 142+) gates a public origin reaching `127.0.0.1` behind a per-origin permission, and a bare `<script src>` no-cors subresource to loopback is DENIED silently instead of prompting. So the loader fetches the engine with `targetAddressSpace:'loopback'` — the sanctioned call that actually raises the grantable prompt (and is exempt from mixed-content once allowed) — then inline-injects the text. It falls back to a `<script src>` tag on older browsers where either path works. Returned WITHOUT a prefix so it serves two uses — pasted into the DevTools Console (or saved as a Sources Snippet), or prefixed with `javascript:` to form a draggable bookmarklet.
(logo-mark-svg px)The spel brand mark (theatre masks + curtains + play triangle) as an inline
SVG string, matching README's logo.svg. px sets the rendered width.
The spel brand mark (theatre masks + curtains + play triangle) as an inline SVG string, matching README's logo.svg. `px` sets the rendered width.
(persisted-token!)Returns the stable per-machine bridge token, generating and persisting one to ~/.spel/bridge-token on first use. Because it is reused across restarts, an extension or bookmarklet ejected once stays authorized against every later bridge — killing the 'unauthorized (bad or missing token)' churn you hit when the token was random per start.
Returns the stable per-machine bridge token, generating and persisting one to ~/.spel/bridge-token on first use. Because it is reused across restarts, an extension or bookmarklet ejected once stays authorized against every later bridge — killing the 'unauthorized (bad or missing token)' churn you hit when the token was random per start.
(read-runtime)Reads the running bridge's route, or nil when no bridge is up.
Reads the running bridge's route, or nil when no bridge is up.
(route-command! url command)(route-command! url command timeout-ms)(route-command! url command timeout-ms token)Submits ONE spel command map to a running bridge's /command endpoint and
returns the browser's result adapted to the daemon {:success :data :error}
shape, so CLI output is identical whether a command hit the daemon or the
bridge. url is the bridge connect URL (e.g. http://127.0.0.1:8787/spel).
Submits ONE spel command map to a running bridge's /command endpoint and
returns the browser's result adapted to the daemon `{:success :data :error}`
shape, so CLI output is identical whether a command hit the daemon or the
bridge. `url` is the bridge connect URL (e.g. http://127.0.0.1:8787/spel).(runtime-path)Filesystem location of the RUNNING bridge's connection details (port + token),
written while spel bridge is up so a same-box spel bridge use can pick up
the live token with zero friction. Cleared on shutdown.
Filesystem location of the RUNNING bridge's connection details (port + token), written while `spel bridge` is up so a same-box `spel bridge use` can pick up the live token with zero friction. Cleared on shutdown.
(save-target! target)(save-target! target path)Persists the active bridge target so subsequent spel <verb> invocations
route through the bridge. Returns the saved map.
Persists the active bridge target so subsequent `spel <verb>` invocations route through the bridge. Returns the saved map.
(serve! &
{:keys [host port path token]
:or {host "127.0.0.1" port 8787 path "/spel"}})Starts a bridge and blocks the current thread, printing connection details.
Used by the spel bridge CLI command. Returns never (until interrupted).
Starts a bridge and blocks the current thread, printing connection details. Used by the `spel bridge` CLI command. Returns never (until interrupted).
(sw-source)Returns the embedded spel-sw.js source as a string, or throws if it is
missing from the classpath / native image.
Returns the embedded `spel-sw.js` source as a string, or throws if it is missing from the classpath / native image.
(target-path)Filesystem location of the saved bridge target (the route regular spel commands follow when set).
Filesystem location of the saved bridge target (the route regular spel commands follow when set).
(token-path)Filesystem location of the STABLE bridge token, reused across spel bridge
restarts so a once-loaded bookmarklet / extension keeps working — the token no
longer rotates on every start. Delete this file (or pass an explicit --token)
to rotate the secret.
Filesystem location of the STABLE bridge token, reused across `spel bridge` restarts so a once-loaded bookmarklet / extension keeps working — the token no longer rotates on every start. Delete this file (or pass an explicit --token) to rotate the secret.
(write-runtime! m)Records the live bridge route (url/port/path/token) for local discovery.
Records the live bridge route (url/port/path/token) for local discovery.
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |