All posts
Guide

Talk to local hardware from a browser page with a small local service

Browsers cannot open raw UDP sockets. Run a thirty-line bridge on your own machine and a Playground page can drive anything on your LAN — no platform feature required.

Granrojo TeamPlatform
Local servicesNetworking

A Playground page is an ordinary web page. It can use fetch, WebSocket, WebSerial, WebUSB and Web Bluetooth — but it cannot open a raw UDP socket, and it never will. The browser sandbox does not expose datagram sockets to page scripts.

The shape of the fix

Put a small process on the same machine as the browser. The page speaks WebSocket to it; it speaks whatever the hardware needs. The browser side stays inside what it is allowed to do, and the awkward protocol work happens somewhere with real sockets.

A WebSocket-to-UDP bridge

Roughly thirty lines with Bun. It accepts JSON frames from the page, sends them as UDP datagrams, and forwards replies back.

bridge.ts
import {createSocket} from "node:dgram";

const udp = createSocket("udp4");
udp.bind(41234);

const server = Bun.serve({
  port: 8787,
  fetch(req, server) {
    if (server.upgrade(req)) return;
    return new Response("bridge", {status: 200});
  },
  websocket: {
    open(ws) {
      ws.subscribe("udp-in");
    },
    message(ws, raw) {
      // {host, port, payload} — the page decides the wire format.
      const {host, port, payload} = JSON.parse(String(raw));
      udp.send(Buffer.from(payload, "base64"), port, host);
    },
  },
});

udp.on("message", (buf, rinfo) => {
  server.publish(
    "udp-in",
    JSON.stringify({
      host: rinfo.address,
      port: rinfo.port,
      payload: buf.toString("base64"),
    }),
  );
});

console.log("bridge on ws://localhost:8787, udp :41234");
bash
bun run bridge.ts

The page side

web/index.html
<script type="module">
  const ws = new WebSocket("ws://localhost:8787");

  ws.addEventListener("message", (event) => {
    const {host, port, payload} = JSON.parse(event.data);
    const bytes = Uint8Array.from(atob(payload), (c) => c.charCodeAt(0));
    console.log(`${host}:${port}`, bytes);
  });

  function send(host, port, bytes) {
    ws.send(
      JSON.stringify({
        host,
        port,
        payload: btoa(String.fromCharCode(...bytes)),
      }),
    );
  }

  ws.addEventListener("open", () => {
    send("192.168.1.50", 41234, [0x01, 0xff]);
  });
</script>

Why this lives in your project

The bridge above is not a platform feature and should not become one. Port numbers, framing, retries, discovery and acknowledgement policy are application decisions — a lamp and a camera rig want different answers. Granrojo owns authoring, persistence, build and flash tooling, publishing and isolation. The wire format between your page and your hardware is yours.

How this compares to Node-RED

Close, with one real difference. Node-RED is itself the runtime: flows keep running whether or not a browser is open. Here the local service is a transport and the application logic lives in the page, so it runs while the tab is open.

Serving the page over HTTPS

A page on https:// may still open ws://localhost — loopback counts as a secure context. Reaching another machine on the LAN by IP is where Chromium's Local Network Access permission comes in, and the user has to grant it.