Hosted Edge Bridge — Skymesh Worker + MonitorRelayDO
Status: DESIGN (no code written beyond this doc). Author handoff doc for three implementation workstreams. No emojis anywhere in the wire, server, client, or prose.
Sibling docs: MONITOR_CLIENT_DESIGN.md (the gnosis.monitor.v1 envelope +
the local serve pipeline). This doc REUSES that contract verbatim; it does not
change a single field name. The relay's read+control HTTP surface is
byte-identical to the local monitor's so nothing downstream (app, MCP) changes.
0. Problem statement
The deployed Skymesh site (https://skymesh.forkjoin.ai) is a Cloudflare Worker
serving static assets only (apps/skymesh/wrangler.jsonc:9-13 — an assets
block, no main). The live data producer, aeon-monitor --serve, runs on a
developer's LOCAL machine over plaintext HTTP on port 8722
(open-source/gnosis/bin/skymesh-live:41,
apps/skymesh/client/lib/live-monitor.ts:24 =
http://127.0.0.1:8722/monitor).
The deployed app physically cannot read or drive that monitor:
- Mixed content: an https page cannot
fetchahttp://127.0.0.1origin; the app already detects and surfaces this (apps/skymesh/client/lib/monitor-control.ts:82-86,:70-71MONITOR_MIXED_CONTENT_HINT). - Not routable:
127.0.0.1on the developer's box is not reachable from the public edge or from a remote browser.
We want the public site to show REAL live monitor data AND send control ops to
the local radio, with no CORS and no mixed content. The fix is a public HTTPS
relay that the LOCAL monitor connects OUT to (outbound WSS, no inbound firewall
hole), folded into the SAME Skymesh Worker that already serves the app. The app
then talks only to its own origin (/monitor/*), so there is no cross-origin
request and no plaintext hop.
1. Topology
DEVELOPER'S LOCAL MACHINE CLOUDFLARE EDGE (skymesh.forkjoin.ai)
+---------------------------+ +------------------------------------------+
| aeon-monitor --serve | | Skymesh Worker (src/index.ts) |
| HTTP 127.0.0.1:8722 | | route /monitor/* -> MonitorRelayDO |
| /monitor/stream (SSE) | | route /health -> worker |
| /monitor/scenes | | everything else -> ASSETS (the app) |
| /monitor/status | | |
| /monitor/frame.json | | +----------------------------------+ |
| POST /monitor/control | | | MonitorRelayDO (singleton) | |
+-------------+-------------+ | | - 1 active bridge WS (uplink) | |
| | | - latest frame per scene | |
| (1) skymesh-bridge.mts | | - latest status, latest scenes | |
| reads local /monitor/* | | - SSE subscriber set | |
| forwards UP over WSS | | - pending control map (id->cb) | |
v | +----------------------------------+ |
+---------------------------+ WSS (token auth) +------------------+-----------------------+
| skymesh-bridge (node) | ===================> | GET /monitor/bridge (WS upgrade) |
| wss://skymesh.forkjoin | frame/status/scenes | |
| .ai/monitor/bridge | UP; control DOWN | |
+---------------------------+ <=================== +------------------------------------------+
^ |
same-origin HTTPS | | (served from DO cache)
| v
+--------------------------------------+---+------------------+
| DEPLOYED APP (browser) + aeon-monitor-mcp (AEON_MONITOR_ |
| monitor base = SAME ORIGIN "/monitor" URL=https://skymesh) |
| GET /monitor/frame.json | /stream | /scenes | /status |
| POST /monitor/control |
+--------------------------------------------------------------+Data flow (local -> app, read path)
skymesh-bridge(node) subscribes to the LOCAL monitor: it opens anEventSource-equivalent onhttp://127.0.0.1:8722/monitor/streamand polls/monitor/scenes+/monitor/statuson a timer.- For each local SSE frame it sends UP the WSS uplink:
{type:"frame", envelope}whereenvelopeis agnosis.monitor.v1object (MONITOR_CLIENT_DESIGN.md:46-77). On status/scenes change it sends{type:"status", status}and{type:"scenes", scenes}. MonitorRelayDO.webSocketMessagecachesframeintoframes[envelope.scene], cachesstatus/scenes, and fan-outs eachframeto every connected SSE subscriber whose?scene=matches.- The deployed app's
startLiveMonitor('/monitor', scene, cb)readsGET /monitor/stream?scene=(SSE) from the DO cache — same origin, https, no CORS.GET /monitor/frame.json?scene=,/scenes,/statusread the same cache.
Control flow (app -> local, write path)
- The app calls
control('/monitor', op, args)(apps/skymesh/client/lib/monitor-control.ts:127) which POSTs/monitor/controlwith{op, ...args}— same origin. - The Worker forwards to
MonitorRelayDO. The DO mints a requestid, stores a pending promise inpending[id], and sends DOWN the bridge WS:{type:"control", id, op, args}. skymesh-bridgereceives it, POSTs the SAME body to the localhttp://127.0.0.1:8722/monitor/control, and sends back UP:{type:"control-result", id, ok, status}.- The DO resolves
pending[id]and the Worker returns the body to the app, byte-identical to what the local monitor would have returned ({ok:true,status}or{ok:false,error},aeon-monitor.rs:5062-5068). If no bridge is connected or the result does not arrive within a timeout, the Worker returns 503 with{ok:false,error:"no bridge connected"}(the control client already degrades gracefully on any non-ok body,monitor-control.ts:148-155).
2. The relay Worker + MonitorRelayDO
2.1 Worker routing (apps/skymesh/src/index.ts)
Add a MonitorRelayDO binding (env.MONITOR_RELAY) and route the /monitor/*
and /monitor/bridge paths to a single DO instance before falling through to
assets. The DO is a SINGLETON: env.MONITOR_RELAY.idFromName("global") — there
is one local monitor, one relay. (If multi-tenant later, key by a path segment
or token claim; out of scope here.)
interface Env {
ASSETS: Fetcher;
MONITOR_RELAY: DurableObjectNamespace;
BRIDGE_TOKEN: string; // Worker secret; the bridge presents it
CONTROL_TOKEN?: string; // optional; if set, gates POST /monitor/control
}
async function fetchInternal(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (request.method === 'GET' && url.pathname === '/health') {
return new Response(JSON.stringify({ status: 'ok', service: 'skymesh' }), {
status: 200, headers: { 'content-type': 'application/json' },
});
}
// All monitor traffic (uplink + reads + control) goes to the singleton DO.
if (url.pathname === '/monitor/bridge' || url.pathname.startsWith('/monitor/')) {
const id = env.MONITOR_RELAY.idFromName('global');
return env.MONITOR_RELAY.get(id).fetch(request);
}
const assetResponse = await serveXGnosisStaticAsset(request, env.ASSETS, {
includeSpaFallback: true,
});
return assetResponse ?? new Response('Not Found', { status: 404 });
}Keep the existing createKnotchainApp wrapper (src/index.ts:36-42) — it just
needs env widened to the new Env. The DO class is exported from the same
module entry (Workers requires the DO class be a named export of main):
export { MonitorRelayDO } from './durables/MonitorRelayDO';
export default createKnotchainApp({ name: 'skymesh', routes: { '*': ... }, ... });Note: run_worker_first (see section 6) makes /monitor/* reach the worker
even though assets is configured; without the worker the request would be
served (404) by the static asset handler.
2.2 DO storage / in-memory state
The DO mirrors the simpler AeonPageSession session-map pattern
(durable-object.ts:143-278) for the readable-side fan-out, and adopts the
hibernation API from SyncRoomDO (SyncRoomDO.ts:98-161,
getWebSockets() + acceptWebSocket + webSocketMessage/webSocketClose) for
the long-lived bridge uplink so the DO can hibernate between frames without
dropping the socket.
In-memory (rebuilt on wake from hibernation, plus persisted latest-state in storage so a cold read after hibernation still has data):
export class MonitorRelayDO implements DurableObject {
private state: DurableObjectState;
private env: Env;
// The single active bridge uplink (outbound from the local monitor).
private bridge: WebSocket | null = null;
// Latest cached state, served to readers. Also persisted to storage so a
// hibernated/cold DO answers GET /frame.json before the next live frame.
private frames: Map<string, unknown> = new Map(); // scene -> gnosis.monitor.v1 envelope
private latestStatus: unknown | null = null; // gnosis.monitor.status.v1
private latestScenes: unknown | null = null; // scene catalog array
// SSE readers: each is a writable stream controller + the scene it follows.
private sseClients: Set<{ scene: string | null; write: (s: string) => void; close: () => void }> = new Set();
// Pending control ops awaiting a control-result from the bridge.
private pending: Map<string, { resolve: (v: unknown) => void; timer: ReturnType<typeof setTimeout> }> = new Map();
constructor(state: DurableObjectState, env: Env) {
this.state = state;
this.env = env;
// Re-adopt the hibernated bridge socket (tagged "bridge") on wake.
for (const ws of this.state.getWebSockets('bridge')) this.bridge = ws;
this.state.blockConcurrencyWhile(async () => {
this.latestStatus = (await this.state.storage.get('status')) ?? null;
this.latestScenes = (await this.state.storage.get('scenes')) ?? null;
const fr = (await this.state.storage.get<Record<string, unknown>>('frames')) ?? {};
this.frames = new Map(Object.entries(fr));
});
}
}Persistence policy: write status/scenes to storage on change; write
frames (the latest envelope per scene) at a throttled cadence (e.g. coalesced,
at most every ~1 s) so we are not doing a storage put at 4 fps. The SSE fan-out
is purely in-memory and does not touch storage. SSE readers are NOT WebSockets,
so they are not hibernation-eligible; a DO with open SSE responses stays awake,
which is the desired behaviour while a viewer is watching.
2.3 Routes inside the DO fetch
GET /monitor/bridge -> WS upgrade, auth, becomes the single uplink
GET /monitor/scenes -> latestScenes (or a built-in default catalog)
GET /monitor/status -> latestStatus (or a "no bridge" status stub)
GET /monitor/frame.json -> frames[resolveScene(query)] (or a demo/empty env)
GET /monitor/stream -> SSE; subscribe to live fan-out for ?scene=
POST /monitor/control -> forward op down the bridge, await result
OPTIONS * -> 204 + CORS preflight (mirror aeon-monitor.rs:5271)These are the EXACT same paths and response bodies the local monitor serves
(aeon-monitor.rs:5414-5493 reads, :5281-5325 control,
:4933-4962 status). A client cannot tell whether it is talking to the local
monitor or the relay — that is the requirement.
GET /monitor/bridge (the uplink)
private handleBridge(request: Request): Response {
if (!this.authOk(request, this.env.BRIDGE_TOKEN)) {
return new Response('unauthorized', { status: 401 });
}
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
// Hibernatable accept, tagged so the constructor can re-adopt it on wake.
this.state.acceptWebSocket(server, ['bridge']);
// Single-bridge policy: if one is already live, close the old one.
if (this.bridge && this.bridge !== server) {
try { this.bridge.close(4002, 'replaced by new bridge'); } catch {}
}
this.bridge = server;
return new Response(null, { status: 101, webSocket: client });
}Uplink messages handled in webSocketMessage (hibernation API,
mirror SyncRoomDO.ts:163-190):
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
let msg: any;
try { msg = JSON.parse(typeof message === 'string' ? message : new TextDecoder().decode(message)); }
catch { return; }
switch (msg.type) {
case 'frame': { // { type:"frame", envelope }
const env = msg.envelope;
if (!env || typeof env.scene !== 'string') return;
this.frames.set(env.scene, env);
this.persistFramesThrottled();
this.fanout(env); // push to matching SSE readers
break;
}
case 'status': // { type:"status", status }
this.latestStatus = msg.status;
this.state.storage.put('status', msg.status);
break;
case 'scenes': // { type:"scenes", scenes }
this.latestScenes = msg.scenes;
this.state.storage.put('scenes', msg.scenes);
break;
case 'control-result': { // { type:"control-result", id, ok, status }
const p = this.pending.get(msg.id);
if (p) { clearTimeout(p.timer); this.pending.delete(msg.id);
p.resolve({ ok: msg.ok, status: msg.status, error: msg.error }); }
break;
}
}
}
async webSocketClose(ws: WebSocket) { if (ws === this.bridge) this.bridge = null; }
async webSocketError(ws: WebSocket) { if (ws === this.bridge) this.bridge = null; }GET /monitor/frame.json, /scenes, /status (cache reads)
// frame.json
const scene = resolveScene(url.searchParams.get('scene')); // null -> active scene from status, else "skymesh"
const env = this.frames.get(scene) ?? emptyEnvelope(scene); // never empty: stub w/ kind by scene
return jsonCors(env);
// scenes
return jsonCors(this.latestScenes ?? DEFAULT_SCENE_CATALOG);
// status
return jsonCors(this.latestStatus ?? { schemaVersion:'gnosis.monitor.status.v1',
activeScene:'skymesh', source:'relay', capture:{ running:false,
lastError:'no bridge connected' }, scenes: this.latestScenes ?? [] });resolveScene mirrors the server's "no ?scene= -> active scene" behaviour
(aeon-monitor.rs:5443, :5473): with no query, use
latestStatus.activeScene, else "skymesh". jsonCors sets
Access-Control-Allow-Origin: * + Cache-Control: no-store, mirroring
SKYMESH_CORS (aeon-monitor.rs:5273-5274) — although same-origin readers do
not need CORS, keeping * preserves byte-identity with the local monitor and
lets a tool point at the relay cross-origin if it wants.
GET /monitor/stream (SSE fan-out)
Workers stream SSE via a TransformStream; write data: <json>\n\n chunks
exactly as the local monitor does (aeon-monitor.rs:5483-5491):
private handleStream(url: URL): Response {
const scene = url.searchParams.get('scene'); // null = follow active scene
const { readable, writable } = new TransformStream();
const writer = writable.getWriter();
const enc = new TextEncoder();
const client = {
scene,
write: (s: string) => writer.write(enc.encode(s)).catch(() => this.dropClient(client)),
close: () => { try { writer.close(); } catch {} },
};
this.sseClients.add(client);
// Emit the current cached frame immediately so the viewer is not blank.
const initial = this.frames.get(resolveScene(scene));
if (initial) client.write(`data: ${JSON.stringify(initial)}\n\n`);
return new Response(readable, { status: 200, headers: {
'content-type': 'text/event-stream',
'cache-control': 'no-store',
'access-control-allow-origin': '*',
connection: 'keep-alive',
}});
}
private fanout(env: any) {
const data = `data: ${JSON.stringify(env)}\n\n`;
for (const c of this.sseClients) {
const want = c.scene ?? resolveScene(null); // null follows active scene
if (want === env.scene) c.write(data);
}
}Frames are PUSHED on bridge arrival (event-driven), not polled on a 250 ms timer
like the local serve loop. This is strictly better (no idle wakeups) and the
client cannot tell the difference: it still receives data: <envelope>\n\n SSE
events. A periodic comment heartbeat (: ping\n\n every ~25 s) keeps the edge
connection from idling out and lets write failures prune dead readers.
POST /monitor/control (forward + await)
private async handleControl(request: Request): Promise<Response> {
if (this.env.CONTROL_TOKEN && !this.authOk(request, this.env.CONTROL_TOKEN)) {
return jsonCors({ ok: false, error: 'control unauthorized' }, 403);
}
if (!this.bridge) {
return jsonCors({ ok: false, error: 'no bridge connected' }, 503);
}
let body: any;
try { body = await request.json(); }
catch (e) { return jsonCors({ ok: false, error: `invalid JSON body: ${e}` }, 200); }
const id = crypto.randomUUID();
const result = await new Promise<any>((resolve) => {
const timer = setTimeout(() => {
this.pending.delete(id);
resolve({ ok: false, error: 'bridge control timeout' });
}, 5000);
this.pending.set(id, { resolve, timer });
try { this.bridge!.send(JSON.stringify({ type: 'control', id, op: body.op, args: body })); }
catch { clearTimeout(timer); this.pending.delete(id); resolve({ ok: false, error: 'bridge send failed' }); }
});
// Byte-identical to the local monitor's control response shape.
return jsonCors(result, result.ok === false && result.error === 'no bridge connected' ? 503 : 200);
}The response shape {ok, status} / {ok:false, error} matches
monitor_dispatch_control exactly (aeon-monitor.rs:5062-5068), so
monitor-control.ts:148-157 parses it unchanged. The args: body passthrough
forwards the FULL original body (which already contains op plus
scene/centerHz/kind/gain/sourceId), so the bridge can re-POST it
verbatim to the local /monitor/control.
2.4 Auth helper
private authOk(request: Request, expected?: string): boolean {
if (!expected) return true; // unset secret = open (dev). Production sets it.
const url = new URL(request.url);
const got = request.headers.get('authorization')?.replace(/^Bearer\s+/i, '')
?? url.searchParams.get('token');
return !!got && timingSafeEqual(got, expected);
}Authorization: Bearer <token> is preferred; the ?token= query fallback
exists because some WS clients cannot set headers on the upgrade. Compare with a
constant-time equal.
3. Auth
Two secrets, both Worker secrets (never in wrangler.jsonc, never in the
client bundle):
BRIDGE_TOKEN(required in production) — the LOCAL monitor's bridge presents it on theGET /monitor/bridgeWSS upgrade (Authorization: Bearer <BRIDGE_TOKEN>, or?token=fallback). Without it, the upgrade returns 401, so no rando can register as the uplink and inject fake frames or intercept control ops.CONTROL_TOKEN(optional, recommended) — gatesPOST /monitor/control. If set, the deployed app must includeAuthorization: Bearer <CONTROL_TOKEN>(or the control bar is read-only). If unset, control falls back toBRIDGE_TOKEN-only protection at the bridge edge (i.e. anyone who can reach the public URL could drive the radio). Recommendation: SETCONTROL_TOKENso control is gated even though reads are public.
Reads are PUBLIC by deliberate design: frames are RF spectra / teleport demo
data, non-sensitive, and the whole point is a shareable public view. GET /monitor/frame.json|stream|scenes|status require no token.
Threat model: the dangerous capability is POST /monitor/control (it spawns
rtl_sdr/retunes the radio on the developer's machine,
aeon-monitor.rs:4881-4928). That is exactly what the token gates. The bridge
upgrade token additionally prevents a hostile uplink from impersonating the
monitor. Both are shared secrets; if a separate-token posture is wanted, keep
them distinct (the design already supports two).
How the app gets CONTROL_TOKEN: it is NOT shipped in the bundle. The operator
pastes it into the control bar (held in sessionStorage) or appends
?control=<token> to the URL when they want to drive the radio. The default
public view is read-only. This keeps the secret out of the static asset and out
of any CDN cache.
4. The bridge client (open-source/gnosis/rtlsdr-mock-sim/skymesh-bridge.mts)
A small Node program (no heavy deps; ws for the WebSocket client, or the
built-in WebSocket global on Node 22+, which the repo runs —
CLAUDE.md Runtime defaults: Node 22+). It is the inverse of the relay: it
reads the LOCAL monitor and speaks the uplink protocol UP.
Usage: bin/skymesh-bridge [--relay wss://skymesh.forkjoin.ai/monitor/bridge]
[--local http://127.0.0.1:8722]
[--token $SKYMESH_BRIDGE_TOKEN]
[--scenes skymesh,spectrum-waterfall,...]Responsibilities:
- Connect WSS to
wss://skymesh.forkjoin.ai/monitor/bridgewithAuthorization: Bearer <token>(or?token=when the WS lib cannot set headers). - Subscribe to the local read surface and forward UP:
- Open SSE on
http://127.0.0.1:8722/monitor/stream(no?scene=-> the monitor's active scene). For eachdata:line, parse the envelope and send{type:"frame", envelope}. To mirror multiple scenes, either open one SSE per--scenesentry (/monitor/stream?scene=<id>) or rely on the app issuingselect_scene(the active-scene stream redirects mid-flight,aeon-monitor.rs:5470-5472). Recommended default: a single active-scene stream plus per-scene streams for any scene the relay has an SSE subscriber for (the DO can tell the bridge which scenes to mirror via a future{type:"subscribe", scenes}down-message; v1 just mirrors all of--scenes). - Poll
GET /monitor/scenesonce at startup (and on reconnect); send{type:"scenes", scenes}. - Poll
GET /monitor/statusevery ~1 s (cheap); on change send{type:"status", status}. This carriesactiveSceneso the relay can resolve no-?scene=reads.
- Open SSE on
- On a DOWN message
{type:"control", id, op, args}: POSTargs(which already includesop) tohttp://127.0.0.1:8722/monitor/control, then send UP{type:"control-result", id, ok, status}(or{...ok:false, error}on a local failure). The local control endpoint is fail-soft (aeon-monitor.rs:4869-4871), so this never throws. - Reconnect/backoff: on WSS close/error, reconnect with exponential backoff
(e.g. 1 s, 2 s, 4 s, capped at 30 s, with jitter). On reconnect, re-send
scenes+statusimmediately so the relay cache is fresh. The local SSE subscription is independent and also retried; if the local monitor is down, the bridge keeps the relay connection alive and the relay serves its last cache + a "no live frame" status until the local monitor returns.
Skeleton:
// skymesh-bridge.mts (Node 22+, run via gnode or node)
const RELAY = arg('--relay') ?? 'wss://skymesh.forkjoin.ai/monitor/bridge';
const LOCAL = arg('--local') ?? 'http://127.0.0.1:8722';
const TOKEN = arg('--token') ?? process.env.SKYMESH_BRIDGE_TOKEN ?? '';
const SCENES = (arg('--scenes') ?? 'skymesh').split(',');
function connect() {
const ws = new WebSocket(`${RELAY}?token=${encodeURIComponent(TOKEN)}`);
ws.addEventListener('open', () => { sendScenes(ws); sendStatus(ws); subscribeLocal(ws); });
ws.addEventListener('message', async (ev) => {
const m = JSON.parse(ev.data);
if (m.type === 'control') {
const res = await fetch(`${LOCAL}/monitor/control`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify(m.args),
}).then(r => r.json()).catch((e) => ({ ok: false, error: String(e) }));
ws.send(JSON.stringify({ type: 'control-result', id: m.id, ok: res.ok, status: res.status, error: res.error }));
}
});
ws.addEventListener('close', () => scheduleReconnect());
ws.addEventListener('error', () => { try { ws.close(); } catch {} });
}subscribeLocal consumes the local SSE (the bridge runs server-side, so it uses
fetch with a ReadableStream body reader or a tiny SSE parser, not the
browser EventSource). sendStatus is on a ~1 s setInterval.
Runner: open-source/gnosis/bin/skymesh-bridge
Mirror bin/rtlsdr-mock-sim (a thin exec node ... wrapper,
bin/rtlsdr-mock-sim:1-3) and the docs style of bin/skymesh-live:
#!/usr/bin/env bash
set -euo pipefail
GNOSIS_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SIM="$GNOSIS_ROOT/rtlsdr-mock-sim/skymesh-bridge.mts"
: "${SKYMESH_BRIDGE_TOKEN:?set SKYMESH_BRIDGE_TOKEN (matches the Worker secret)}"
echo "[skymesh-bridge] local http://127.0.0.1:8722 -> wss://skymesh.forkjoin.ai/monitor/bridge"
exec "$GNOSIS_ROOT/bin/gnode" run "$SIM" "$@"(Uses the repo gnode runner per CLAUDE.md; the bridge is a plain .mts
entrypoint so the gnode fast-path falls back to Node, per the
gnode runner fixed memory note.)
5. App wiring (apps/skymesh/client)
The only app change is base detection. Today the default monitor base is the
local http://127.0.0.1:8722/monitor regardless of where the app is served
(live-monitor.ts:24 DEFAULT_MONITOR_BASE; App.tsx:46-52
monitorBaseFrom; App.tsx:54-66 readParams). On the deployed site that base
is unreachable (mixed content + not routable). Change the DEFAULT so a non-local
origin defaults to the SAME ORIGIN /monitor, while local dev keeps
http://127.0.0.1:8722.
Change monitorBaseFrom / readParams (App.tsx:46-66)
// True when the page is served from a real origin (the deployed site), not a
// local dev host. On the deployed site we relay through our own origin.
function isDeployedOrigin(): boolean {
if (typeof window === 'undefined') return false;
const h = window.location.hostname;
return h !== 'localhost' && h !== '127.0.0.1' && h !== '0.0.0.0' && !h.endsWith('.local');
}
// Same-origin relative base; works under https with no CORS, no mixed content.
const SAME_ORIGIN_MONITOR_BASE = '/monitor';
function monitorBaseFrom(liveBase: string, liveWasExplicit: boolean): string {
// An explicit ?live= always wins (lets an operator point anywhere).
if (liveWasExplicit) {
if (/\/monitor\/?$/.test(liveBase)) return liveBase;
if (/\/skymesh\/?$/.test(liveBase)) return liveBase.replace(/\/skymesh\/?$/, '/monitor');
return liveBase.replace(/\/+$/, '') + '/monitor';
}
// No ?live=: deployed site -> same origin; local dev -> the legacy localhost.
return isDeployedOrigin() ? SAME_ORIGIN_MONITOR_BASE : DEFAULT_MONITOR_BASE;
}
function readParams() {
const p = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '');
const liveParam = p.get('live');
const liveBase = liveParam || DEFAULT_LIVE_BASE;
return {
liveBase,
monitorBase: monitorBaseFrom(liveBase, liveParam != null),
flowUrl: p.get('flow') || p.get('udp') || '',
mode: (p.get('mode') as RenderMode) || 'stage',
source: (p.get('source') as DataSource) || 'demo',
scenario: (p.get('scenario') as Scenario) || 'hit',
scene: p.get('scene') || 'skymesh',
};
}Behaviour matrix:
| context | ?live= |
resulting monitorBase |
|---|---|---|
| deployed (skymesh.forkjoin.ai) | absent | /monitor (same origin -> relay) |
| deployed | https://other/monitor |
https://other/monitor (operator override) |
| local dev (vite on localhost) | absent | http://127.0.0.1:8722/monitor |
| local dev | http://127.0.0.1:8722/skymesh |
.../monitor (legacy swap) |
Everything downstream is unchanged because the base is consumed uniformly:
startLiveMonitor(params.monitorBase, scene, cb)(App.tsx:79,92) -> hits${base}/stream?scene=then${base}/frame.json?scene=(live-monitor.ts:49-78). Withbase="/monitor"these become same-origin relative URLs the relay serves.<ControlBar base={params.monitorBase} ... />(App.tsx:334) andmonitorControl(params.monitorBase, 'select_scene', ...)(App.tsx:271) ->getStatus/controlhit${base}/statusand${base}/control(monitor-control.ts:108,136). Same-origin POST to the relay.<SceneNav base={params.monitorBase} fetchCatalog={source === 'live'} />(App.tsx:302-303) ->${base}/scenes.isMixedContentBlocked(monitor-control.ts:82-86) now returns false on the deployed site (a relative/monitorbase is same-scheme https), so the mixed-content hint stops firing — the relay path actually works.
No contract change, no new envelope, no new control op. The default-source is
still demo (App.tsx:62,121); a viewer flips to live with ?source=live (or
the Controls toggle), exactly as today.
MCP (no change)
apps/aeon-monitor-mcp/src/client.ts:14-17 already reads
AEON_MONITOR_URL (default http://127.0.0.1:8722). Pointing it at the relay is
a one-line env change with NO code change:
AEON_MONITOR_URL=https://skymesh.forkjoin.ai. Its getScenes/getStatus/
getFrame/control calls (client.ts:92-110) hit the byte-identical relay
surface. If CONTROL_TOKEN is set, the MCP needs to send the bearer header;
that is the one optional follow-up (add an AEON_MONITOR_TOKEN env to the MCP's
request helper) — reads work with no token regardless.
6. wrangler / deploy
The deployed config is apps/skymesh/wrangler.jsonc (the static-only one,
:9-13). aeon.toml is the sovereign Aeon Forge target and already declares
main = "src/index.ts" + run_worker_first = true (aeon.toml:8,16-19); we
align wrangler.jsonc to it and add the DO.
Updated apps/skymesh/wrangler.jsonc
{
"name": "skymesh",
"main": "src/index.ts", // ADD: ship a real Worker, not assets-only
"compatibility_date": "2026-04-08",
"compatibility_flags": ["nodejs_compat"],
"assets": {
"directory": "./dist/client",
"binding": "ASSETS", // ADD: bind so the worker can fetch assets
"not_found_handling": "single-page-application",
"run_worker_first": ["/monitor/*", "/health"] // ADD: these hit the worker; else assets
},
"durable_objects": { // ADD
"bindings": [
{ "name": "MONITOR_RELAY", "class_name": "MonitorRelayDO" }
]
},
"migrations": [ // ADD
{ "tag": "v1", "new_classes": ["MonitorRelayDO"] }
// If WS hibernation needs SQLite-backed DO storage, use new_sqlite_classes
// instead; plain new_classes is fine for the in-memory + KV-storage model here.
],
"observability": { "enabled": true },
"routes": [{ "pattern": "skymesh.forkjoin.ai", "custom_domain": true }]
}Notes:
main+assets.binding: "ASSETS"is the documented way to run a Worker that ALSO serves static assets: the Worker handles requests, andenv.ASSETS.fetch(used byserveXGnosisStaticAsset,src/index.ts:30) serves the SPA. This is exactly the shapeaeon.tomlalready uses.run_worker_first: ["/monitor/*", "/health"]routes only those paths to the Worker; all other paths are served directly fromdist/clientby the asset handler (fast path, no Worker invocation), with SPA fallback. This is the scoped form ofaeon.toml's blanketrun_worker_first = trueand avoids paying a Worker hop for every static asset. (Blankettruealso works and matchesaeon.toml; the scoped array is the optimization.)- The DO binding
MONITOR_RELAY+ migrationv1withnew_classes: ["MonitorRelayDO"]mirrors the working pattern inapps/bitwise-click/wrangler.toml:28-30. The DO class MUST be exported frommain(section 2.1). wrangler deployships the Worker script + the DO class + the static assets in ONE deploy (assets are uploaded and bound; the DO is registered via the migration). No separate steps.
Secrets
wrangler secret put BRIDGE_TOKEN # the bridge presents this on the WSS upgrade
wrangler secret put CONTROL_TOKEN # optional: gate POST /monitor/controlSet the SAME BRIDGE_TOKEN value as the local SKYMESH_BRIDGE_TOKEN env the
bin/skymesh-bridge runner requires (section 4). Secrets are encrypted at rest
and never appear in wrangler.jsonc or the client bundle.
Build
apps/skymesh already builds dist/client (the assets.directory). Keep that
build; the only new build input is src/index.ts (now the real main) and
src/durables/MonitorRelayDO.ts, both bundled by wrangler/esbuild at deploy.
Per the a0 subpath resolver bug memory note, prefer npx wrangler deploy
(esbuild honors package exports) over an a0 build wrapper if the
@a0n/aeon-flux-knotchain-app / @a0n/aeon-ux/edge subpath imports
(src/index.ts:13-14) trip the a0 resolver.
7. Implementation breakdown (3 workstreams)
Dependency order: WS-R (the relay protocol contract) defines the uplink message shapes; WS-Bridge and WS-App can then proceed in parallel against that contract. WS-App's same-origin change is independent of the relay being live (it degrades to the existing "source unreachable" state) and can land first as a no-op on local dev.
WS-R — relay Worker + MonitorRelayDO
Files:
apps/skymesh/src/durables/MonitorRelayDO.ts(NEW) — the DO (section 2): bridge WS uplink (hibernation API), frame/status/scenes cache, SSE fan-out, control forward-and-await, auth helper.apps/skymesh/src/index.ts(EDIT) — widenEnv, route/monitor/*+/monitor/bridgeto the DO, re-exportMonitorRelayDO(section 2.1).apps/skymesh/wrangler.jsonc(EDIT) —main,assets.binding,run_worker_first,durable_objects,migrations(section 6).
Order: DO first (testable with wrangler dev + a wscat uplink + curl
reads), then wire index.ts, then config + secrets + wrangler deploy.
Smallest honest verification: wrangler dev, open a WS to /monitor/bridge
with the token, push a {type:"frame",envelope}, then curl http://127.0.0.1:8787/monitor/frame.json and confirm the same envelope comes
back; curl -N .../monitor/stream and confirm fan-out; POST /monitor/control
with no bridge and confirm 503.
WS-Bridge — local bridge client
Files:
open-source/gnosis/rtlsdr-mock-sim/skymesh-bridge.mts(NEW) — section 4.open-source/gnosis/bin/skymesh-bridge(NEW) — the runner (section 4).- Optionally a
skymesh-bridge.test.tsnext to the other rtlsdr-mock-sim tests (a mock relay WS + mock local HTTP; assert frame forward, control round-trip, reconnect backoff).
Order: build against a local aeon-monitor --serve (via bin/skymesh-live) and
a wrangler dev relay; verify a frame appears in the relay cache and a control
op round-trips. Verify with the gnosis test runner (./bin/monster test per the
gnosis tests run via ./bin/monster test memory note), not raw vitest.
WS-App — same-origin wiring
Files:
apps/skymesh/client/App.tsx(EDIT) —monitorBaseFrom+readParams+isDeployedOrigin(section 5). Roughly 20 lines; no other file changes.
Order: independent; can land first. Verify on local dev (base unchanged ->
http://127.0.0.1:8722/monitor) and against the deployed relay (base ->
/monitor, same origin). Build with npx vite build (the a0 build subpath
resolver bug, memory note).
Risks / flags
- DO WS hibernation: the bridge socket is long-lived; use the hibernation API
(
acceptWebSocket(server, ['bridge'])+webSocketMessage/webSocketClose- re-adopt via
getWebSockets('bridge')on wake,SyncRoomDO.ts:103-108,150,163,192). Do NOT use the in-memoryaddEventListener('message')style (AeonPageSession,durable-object.ts:248) for the uplink — that socket dies on hibernation. SSE response streams keep the DO awake while a viewer watches, which is fine; the heartbeat prunes dead readers.
- re-adopt via
- SSE over Workers: Workers stream SSE via
TransformStream, not a blocking write loop. Persist the latest frame so a cold/hibernated DO answersframe.jsonand seeds a new SSE reader immediately (section 2.2-2.3). Add a~25 sheartbeat comment to survive edge idle timeouts and detect dead writers. - Single-bridge assumption: exactly one local monitor. The DO enforces it
(a new bridge upgrade closes the prior one, section 2.3) and tracks one
bridgesocket. Multi-monitor would need keying the DO by token/identity; explicitly out of scope. - Token handling:
BRIDGE_TOKEN/CONTROL_TOKENare Worker secrets, compared constant-time. The control token must NOT ship in the client bundle (operator pastes it /?control=); reads are public so no token leaks via the public view. The bridge token lives in the developer's env (SKYMESH_BRIDGE_TOKEN), matched to the Worker secret. - Local-dev vs deployed base detection:
isDeployedOrigin()keys on hostname (localhost/127.0.0.1/.local -> local; else deployed). An explicit?live=always overrides both, preserving the operator escape hatch and the existing e2e/demo URLs inbin/skymesh-live:38. A developer testing the relay locally can force it with?live=https://skymesh.forkjoin.ai/monitor. - Byte-identity guard: any drift between the relay's response shapes and
aeon-monitor.rs(gnosis.monitor.v1envelope,gnosis.monitor.status.v1status,{ok,status}/{ok:false,error}control) breaks the no-contract-change guarantee. The relay must mirror, not reinterpret. A contract test that runs the samemonitor-contract.ts normalizeEnvelope/monitor-control.ts isStatusvalidators against both the local monitor and the relay is the cheapest insurance.