eventx-bridge
v0.2.0
Published
Node.js OSC bridge — subscribes to Supabase Realtime and forwards EventX events to Notch via OSC.
Readme
eventx-bridge
Node.js OSC bridge — subscribes to Supabase Realtime and forwards EventX events to Notch via OSC.
Quick start (no install)
Step 1 — Get your .env
In the EventX dashboard, go to your event → Bridge tab → click Download .env.
Save it to ~/.eventx-bridge/.env (auto-discovered) or keep it anywhere and use --env.
Step 2 — Run
npx eventx-bridgeCLI options
npx eventx-bridge [options]
--env <path> Path to .env file
Default: ./.env then ~/.eventx-bridge/.env
--event <id> Active event ID override (ACTIVE_EVENT_ID)
--mode <mode> production | test (default: production)
--notch-host <ip> Override NOTCH_HOST
--notch-port <port> Override NOTCH_PORT
--log-level <level> debug | info | warn | error
--version, -v Show version
--help, -h Show this helpLocal dev (monorepo)
# test mode — generates fake OSC traffic, no Supabase required
pnpm --filter eventx-bridge dev
# production mode — requires SUPABASE_URL + SUPABASE_SERVICE_KEY in .env
pnpm --filter eventx-bridge devProduction mode behavior
In production mode the bridge:
- Connects to Supabase with service role key (bypasses RLS)
- Fetches active sessions (
activity_sessions WHERE status='live') on startup and seeds the in-memory session tracker - Subscribes to two Realtime channels:
eventx-aggregations— listens forUPDATEonaggregationstable; routes tohandleAggregationUpdate(OSC diff logic in task 006)eventx-activity-sessions— listens for all changes onactivity_sessionstable; routes tohandleSessionStatusChangewhich emits/control/start,/control/endOSC messages on status transitions
- Reconnects automatically via supabase-js built-in Realtime reconnect. Bridge logs channel status events (
SUBSCRIBED,TIMED_OUT,CHANNEL_ERROR). - Retry on error: if a channel hits
CHANNEL_ERRORorTIMED_OUT, bridge retries once after 5 s. On second failure it exits (LaunchAgent/PM2 restarts the process).
Required env vars for production
| Variable | Description |
|---|---|
| SUPABASE_URL | Supabase project URL (e.g. https://xxx.supabase.co) |
| SUPABASE_SERVICE_KEY | Service role key — full DB access, never expose to client |
OSC addresses emitted
| Address | Args | Trigger |
|---|---|---|
| /eventx/{shortId}/control/start | — | activity_sessions.status → live |
| /eventx/{shortId}/control/end | — | activity_sessions.status → ended |
| /eventx/{shortId}/wordcloud/add | string word, int count | New or increased word in aggregation diff |
| /eventx/{shortId}/wordcloud/remove | string word | Word dropped from aggregation or moderated hidden |
| /eventx/{shortId}/wordcloud/state | string json | Periodic full snapshot every 2 s (JSON-encoded [word, count][]) |
shortId = first 10 hex chars of SHA-1 of activity_sessions.id.
OSC arg types (per spec section 4.2)
/wordcloud/add— two args:s(word) +i(count as integer,Math.floorapplied)/wordcloud/remove— one arg:s(word)/wordcloud/state— one arg:s(JSON string ofArray<[string, number]>)/control/start,/control/end— no args
node-osc infers OSC types from JS values: string → s, number with no decimal → i, float → f.
Environment variables
| Variable | Required | Default | Description |
|---|---|---|---|
| BRIDGE_MODE | no | production | test or production |
| SUPABASE_URL | prod only | — | Supabase project URL |
| SUPABASE_SERVICE_KEY | prod only | — | Supabase service role key |
| NOTCH_HOST | no | 127.0.0.1 | OSC target host |
| NOTCH_PORT | no | 7000 | OSC target port |
| BRIDGE_LOG_LEVEL | no | info | pino log level |
| BRIDGE_STATE_FILE | no | /tmp/bridge-state.json | crash recovery state file |
Copy .env.example from repo root and fill in your values.
Recovery behavior
The bridge persists its in-memory session state to a local JSON file after every state change (debounced 500 ms). On restart it merges the file with a fresh DB query: the DB is authoritative for session status, the file provides the last known wordcloud snapshot so Notch can be restored immediately.
State file location
Controlled by BRIDGE_STATE_FILE (default /tmp/bridge-state.json). The file is overwritten atomically on every flush.
Restart workflow
- Bridge crashes or is stopped with SIGTERM/SIGINT.
- On SIGTERM/SIGINT the bridge flushes state synchronously and exits cleanly (
process.exit(0)).
On a hard crash the last debounced flush (≤ 500 ms old) is already on disk. - LaunchAgent/PM2 restarts the process automatically.
initSessionTrackerloads live sessions from DB and merges the snapshot file.- The periodic state push (
/wordcloud/stateevery 2 s) re-delivers the last snapshot to Notch — full recovery within 3 s of restart.
Fault tolerance
| Failure | Behavior |
|---|---|
| Notch unreachable | send() wrapped in try/catch; logs warn, never crashes |
| Supabase outage | supabase-js handles reconnect; bridge logs channel status events; no panic |
| State file corrupt | loadState() catches parse errors, returns empty Map, logs warn |
| Process crash | Last flushed state ≤ 500 ms old; LaunchAgent restarts immediately |
Heartbeat
The bridge sends a heartbeat INSERT to bridge_heartbeats every 10 seconds so the operator dashboard can show connection status.
bridge_id
On startup the bridge generates a persistent bridge_id from {mode}-{hostname}-{pid} (max 64 chars). In test mode the id starts with test-.
Notch reachability
Every OSC send callback updates an in-memory flag. The heartbeat row includes notch_reachable: true if the most recent UDP send did not error, false otherwise (best-effort — UDP has no acknowledgement).
Failure tolerance
| Scenario | Behavior |
|---|---|
| Supabase outage | Insert fails silently; bridge logs warn and keeps running |
| No SUPABASE_URL (test mode without env) | Heartbeat disabled; warn logged on startup |
| Bridge stopped | stopHeartbeat() called in SIGTERM/SIGINT handler before exit |
Show control
When ACTIVE_EVENT_ID is set the bridge subscribes to show_control_triggers (Realtime INSERT, filtered to that event). Per trigger row it:
- Re-validates
osc_addressagainst the zod regex (server-side guard; rejects withwarn, no throw). - Coerces OSC args:
boolean → 0/1,numberstays, everything elseString(). - Sends the OSC message via the same
OscSenderused for all other channels. - Appends a tab-separated line to
packages/bridge/logs/show_control.log:ISO8601\tevent_id\tbutton_id\tosc_address\targs_json
Missed-trigger behavior
Show control is a fire-and-forget real-time trigger pad. There is no delivery-confirmation column (delivered_at) and no replay queue in v1. If the bridge is offline when a button is pressed, that trigger is lost. The operator sees the result directly on the Notch scene (visual feedback) — no automatic retransmit on reconnect.
This is an intentional v1 trade-off. A retry/delivery queue is deferred to a future spec (PHASE_SHOW_CONTROL_V2_SPEC.md).
Required env var
| Variable | Required | Description |
|---|---|---|
| ACTIVE_EVENT_ID | yes (for show control) | UUID of the event whose show_control_triggers the bridge should watch. Leave unset to disable show_control channel. |
Sensor Race integration
When an activity_sessions row transitions to status='live' for a sensor_race activity, the bridge:
- Instantiates a
SensorStreamSession(src/handlers/sensor-stream.ts) - Subscribes to the Supabase Realtime broadcast channel
sensor:{sessionId} - On each received
sensorbroadcast event, callssession.onSample(input, participantId, teamId) - Runs a 30 Hz physics loop:
setInterval(() => session.tick(1/30), 33) - Persists aggregated state to
aggregationsat 1 Hz:setInterval(() => session.persistPeriodic(), 1000) - Sends OSC messages for each physics tick (car positions, winner)
Bridge must run for sensor activities
# Local dev (operator's machine)
pnpm --filter eventx-bridge dev
# Or build + start
pnpm --filter eventx-bridge build && pnpm --filter eventx-bridge startRequires .env with SUPABASE_URL + SUPABASE_SERVICE_KEY (service key — bridge writes to aggregations table which requires elevated permissions).
When the session ends (status='ended'), bridge stops intervals and unsubscribes from the broadcast channel.
OSC output for sensor_race
| Address | Args | Cadence |
|---|---|---|
| /eventx/{sessionId}/race/team/{teamId}/x | float | 30 Hz (only on change) |
| /eventx/{sessionId}/race/team/{teamId}/y | float | 30 Hz |
| /eventx/{sessionId}/race/team/{teamId}/heading | float | 30 Hz |
| /eventx/{sessionId}/race/team/{teamId}/speed | float | 30 Hz |
| /eventx/{sessionId}/race/team/{teamId}/lap | int | on lap change |
| /eventx/{sessionId}/race/winner | string teamId | once, on finish |
OSC sends are no-ops (logged as warn) when Notch is not configured — bridge continues running for HTML projection.
Smoke test
scripts/smoke-show-control.mjs verifies the full show-control chain:
dashboard button click → show_control_triggers INSERT → Realtime → bridge → OSC dispatch to mock sink.
Run from repo root:
# With bridge active (requires SUPABASE_URL + SUPABASE_SERVICE_KEY + ACTIVE_EVENT_ID):
SUPABASE_URL=... SUPABASE_SERVICE_KEY=... ACTIVE_EVENT_ID=<uuid> pnpm smoke:show-control
# Without bridge (assertions 2 + latency are skipped; assertions 1/3/4 still run):
pnpm smoke:show-controlThe mock OSC sink (scripts/mock-osc-sink.mjs) is spawned automatically and logs all received OSC packets to /tmp/eventx-bridge-mock-osc.log.
Assertions checked:
| # | Assertion | Requires |
|---|---|---|
| 1 | show_control_triggers DB row has correct osc_address + osc_args | SUPABASE_URL + SUPABASE_ANON_KEY |
| 2 | Bridge OSC log contains /test/cue + arg 42 | Bridge running |
| 3 | Pulse CSS class applied on clicked button (DOM check, 300ms window) | Always |
| 4 | Recent triggers sidebar updates within 2 s (Realtime) | Always |
| 5 | Latency p95 < 500 ms over 10 clicks (click → OSC log entry) | Bridge running |
Screenshots are saved to scripts/screenshots/sc-*.png.
Test
pnpm --filter eventx-bridge test
pnpm --filter eventx-bridge typecheckVerify OSC output (test mode)
In a separate terminal, listen on UDP 7000:
nc -u -l 7000Then start the bridge in test mode — you should see OSC bytes arriving every 500 ms.
