forgo.cloud
Sign in
Repo workspace

forkjoin-ai/gnosis

Hosted Edge Bridge — Skymesh Worker + `MonitorRelayDO`

distributed-inference/docs/MONITOR_BRIDGE_DESIGN.md
forkjoin-ai/gnosis

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 fetch a http://127.0.0.1 origin; the app already detects and surfaces this (apps/skymesh/client/lib/monitor-control.ts:82-86, :70-71 MONITOR_MIXED_CONTENT_HINT).
  • Not routable: 127.0.0.1 on 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)

  1. skymesh-bridge (node) subscribes to the LOCAL monitor: it opens an EventSource-equivalent on http://127.0.0.1:8722/monitor/stream and polls /monitor/scenes + /monitor/status on a timer.
  2. For each local SSE frame it sends UP the WSS uplink: {type:"frame", envelope} where envelope is a gnosis.monitor.v1 object (MONITOR_CLIENT_DESIGN.md:46-77). On status/scenes change it sends {type:"status", status} and {type:"scenes", scenes}.
  3. MonitorRelayDO.webSocketMessage caches frame into frames[envelope.scene], caches status/scenes, and fan-outs each frame to every connected SSE subscriber whose ?scene= matches.
  4. The deployed app's startLiveMonitor('/monitor', scene, cb) reads GET /monitor/stream?scene= (SSE) from the DO cache — same origin, https, no CORS. GET /monitor/frame.json?scene=, /scenes, /status read the same cache.

Control flow (app -> local, write path)

  1. The app calls control('/monitor', op, args) (apps/skymesh/client/lib/monitor-control.ts:127) which POSTs /monitor/control with {op, ...args} — same origin.
  2. The Worker forwards to MonitorRelayDO. The DO mints a request id, stores a pending promise in pending[id], and sends DOWN the bridge WS: {type:"control", id, op, args}.
  3. skymesh-bridge receives it, POSTs the SAME body to the local http://127.0.0.1:8722/monitor/control, and sends back UP: {type:"control-result", id, ok, status}.
  4. 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.

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 the GET /monitor/bridge WSS 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) — gates POST /monitor/control. If set, the deployed app must include Authorization: Bearer <CONTROL_TOKEN> (or the control bar is read-only). If unset, control falls back to BRIDGE_TOKEN-only protection at the bridge edge (i.e. anyone who can reach the public URL could drive the radio). Recommendation: SET CONTROL_TOKEN so 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:

  1. Connect WSS to wss://skymesh.forkjoin.ai/monitor/bridge with Authorization: Bearer <token> (or ?token= when the WS lib cannot set headers).
  2. 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 each data: line, parse the envelope and send {type:"frame", envelope}. To mirror multiple scenes, either open one SSE per --scenes entry (/monitor/stream?scene=<id>) or rely on the app issuing select_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/scenes once at startup (and on reconnect); send {type:"scenes", scenes}.
    • Poll GET /monitor/status every ~1 s (cheap); on change send {type:"status", status}. This carries activeScene so the relay can resolve no-?scene= reads.
  3. On a DOWN message {type:"control", id, op, args}: POST args (which already includes op) to http://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.
  4. 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 + status immediately 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). With base="/monitor" these become same-origin relative URLs the relay serves.
  • <ControlBar base={params.monitorBase} ... /> (App.tsx:334) and monitorControl(params.monitorBase, 'select_scene', ...) (App.tsx:271) -> getStatus/control hit ${base}/status and ${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 /monitor base 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, and env.ASSETS.fetch (used by serveXGnosisStaticAsset, src/index.ts:30) serves the SPA. This is exactly the shape aeon.toml already uses.
  • run_worker_first: ["/monitor/*", "/health"] routes only those paths to the Worker; all other paths are served directly from dist/client by the asset handler (fast path, no Worker invocation), with SPA fallback. This is the scoped form of aeon.toml's blanket run_worker_first = true and avoids paying a Worker hop for every static asset. (Blanket true also works and matches aeon.toml; the scoped array is the optimization.)
  • The DO binding MONITOR_RELAY + migration v1 with new_classes: ["MonitorRelayDO"] mirrors the working pattern in apps/bitwise-click/wrangler.toml:28-30. The DO class MUST be exported from main (section 2.1).
  • wrangler deploy ships 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/control

Set 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) — widen Env, route /monitor/* + /monitor/bridge to the DO, re-export MonitorRelayDO (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.ts next 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-memory addEventListener('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.
  • SSE over Workers: Workers stream SSE via TransformStream, not a blocking write loop. Persist the latest frame so a cold/hibernated DO answers frame.json and seeds a new SSE reader immediately (section 2.2-2.3). Add a ~25 s heartbeat 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 bridge socket. Multi-monitor would need keying the DO by token/identity; explicitly out of scope.
  • Token handling: BRIDGE_TOKEN/CONTROL_TOKEN are 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 in bin/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.v1 envelope, gnosis.monitor.status.v1 status, {ok,status}/{ok:false,error} control) breaks the no-contract-change guarantee. The relay must mirror, not reinterpret. A contract test that runs the same monitor-contract.ts normalizeEnvelope / monitor-control.ts isStatus validators against both the local monitor and the relay is the cheapest insurance.