Skip to content

GPIO state over HTTP + SSE

A button-and-LED control surface exposed over HTTP. Bun.serve provides three endpoints — GET /state (snapshot JSON), GET /events (server-sent events stream), POST /led/{0,1,auto} (manual override) — and one effect { } block does double duty: writes the LED based on the current button state AND pushes the new state into every connected SSE client.

This is the demo for “reactivity across the wire.” Adding another sensor or another sink (logging, MQTT, websockets) is one more line in the effect.

import gpio from "parabun:gpio";
import signals from "@para/signals";
import lifecycle from "@para/lifecycle";
await using chip = gpio.openDefaultChip(); // RP1 on Pi 5; gpiochip0 elsewhere
await using button = chip.line(27, { mode: "in", pull: "up", pollHz: 50 });
await using led = chip.line(17, { mode: "out", initial: 0 });
signal ledOverride = null; // null = follow button; 0/1 = pin
const sseClients = new Set();
// One effect: writes the LED AND broadcasts to every SSE client.
// Adding another sink (MQTT publish, Discord webhook, OLED draw) is one more line.
effect {
const pressed = button.value.get() === 0;
const value = ledOverride !== null ? ledOverride : pressed ? 1 : 0;
led.write(value);
const payload = JSON.stringify({ button: button.value.get(), led: value, override: ledOverride });
for (const send of sseClients) send(payload);
}
const server = Bun.serve({
port: 3000,
async fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/state") {
return Response.json({ button: button.value.get(), led: led.value.get(), override: ledOverride });
}
if (url.pathname === "/events") {
const stream = new ReadableStream({ start(controller) {
const send = p => { try { controller.enqueue(`data: ${p}\n\n`); } catch {} };
sseClients.add(send);
send(JSON.stringify({ button: button.value.get(), led: led.value.get(), override: ledOverride }));
req.signal.addEventListener("abort", () => sseClients.delete(send));
} });
return new Response(stream, { headers: { "content-type": "text/event-stream" } });
}
const ledRoutes: Record<string, 0 | 1 | null> = { "/led/0": 0, "/led/1": 1, "/led/auto": null };
if (req.method === "POST" && url.pathname in ledRoutes) {
ledOverride = ledRoutes[url.pathname];
return Response.json({ override: ledOverride });
}
},
});
// Block until SIGINT/SIGTERM, stop the server, then dispose.
await lifecycle.keepAlive({ onShutdown: () => server.stop() });

Full source in demos/iot-http-state.pts — about 110 lines.

  • button.value is a Signal<0 | 1> driven by parabun:gpio’s built-in poll loop (pollHz: 50). The kernel-event read is hidden inside the gpio module; from JS this looks like any other reactive cell.
  • signal ledOverride is a plain mutable cell — the HTTP handlers write to it; the effect reads it.
  • The effect { } block tracks both button.value and ledOverride. It re-runs whenever either changes, which means: (a) the LED follows the button automatically, (b) the override flips it on demand, (c) every SSE client sees the new state on the same tick. One block, three behaviors, no event-bus plumbing.
  • The HTTP fetch handler is plain Bun.serve. Each request is one short branch; nothing reactive about a request-response shape.
Terminal window
parabun src/server.pjs
Terminal window
# In another terminal:
curl localhost:3000/state # snapshot
curl -N localhost:3000/events # SSE — receives every change
curl -X POST localhost:3000/led/1 # force LED on
curl -X POST localhost:3000/led/auto # back to following the button

The SSE stream emits one event per state change — no polling, no client-side timer.

  • Linux SBC with GPIO (Raspberry Pi 5 used for development)
  • BCM 27 → button to ground (internal pull-up enabled in code)
  • BCM 17 → LED with current-limiting resistor