Transports — Node UDP / React Native UDP / Browser helper
The JS/TS SDK has one API and three transports (send paths).
Even though you call connect() the same way, the internal send method differs between
Node, React Native, and the browser.
- Node (Electron / server / CLI / creative coding) → sends a Wi-Fi UDP directly to the device.
- React Native (Android / iOS phone apps) → sends Wi-Fi UDP directly
from the phone via the optional
react-native-udp. Because a phone is not sandboxed like a browser, it can open a real UDP socket, so no hapbeat-helper is needed. - Browser (WebXR / three.js / p5.js / jsPsych, etc.) → relays over
WebSocket (
ws://localhost:7703) to the locally running hapbeat-helper. Since browsers cannot open a raw UDP socket, the helper sends on its behalf.
You don’t need to choose which one to use. The package’s exports map looks at the
runtime / bundler and automatically picks the correct build.
Why three builds are needed
Section titled “Why three builds are needed”Hapbeat devices receive UDP on the LAN and self-filter on the in-packet target,
(details in Address System). Node can send UDP directly with
node:dgram and React Native with react-native-udp, but the browser sandbox does not
permit raw UDP.
So the browser side hands instructions to the local helper daemon over WebSocket, and the
helper performs the UDP send in its place.
To absorb this difference, the SDK has three entry points with different internals.
| Build | Entry | Dependency | Send path |
|---|---|---|---|
| Node | dist/node.js | node:dgram | direct UDP (unicast by default) |
| React Native | dist/react-native.js | react-native-udp | direct UDP (unicast by default) |
| Browser | dist/browser.js | WebSocket | via hapbeat-helper |
The transport implementations are split per entry so that node:dgram does not leak into
the browser bundle.
Automatic selection via the exports map
Section titled “Automatic selection via the exports map”The package.json of @hapbeat/sdk selects builds via exports conditions.
"exports": { ".": { "node": { "default": "./dist/node.js" }, // Node runtime "react-native": { "default": "./dist/react-native.js" }, // React Native runtime "browser": { "default": "./dist/browser.js" }, // a bundler's browser condition "default": { "default": "./dist/browser.js" } // everything else (WebXR, etc.) }}- Run in Node → matches the
nodecondition → UDP build. - Bundle with React Native (Metro) → matches the
react-nativecondition → RN UDP build. - Bundle with Vite / webpack / esbuild → matches the
browsercondition → helper build. - Any other runtime falls back to
default(= browser build).
Your code stays the same either way.
import { connect } from "@hapbeat/sdk"; // exports decides which buildconst hb = await connect({ appName: "MyApp" });Node — direct UDP
Section titled “Node — direct UDP”The Node build opens a UDP4 socket with node:dgram and sends packets such as
PLAY / STOP / CONNECT_STATUS directly.
const hb = await connect({ appName: "MyApp", // OLED display name (up to 16 characters) port: 7700, // default 7700 (command destination port) broadcastAddr: "255.255.255.255", // default (used when falling back) keepalive: true, // default true unicast: true, // default true (see below) // deviceTtlMs: 15000, // how long a PONG keeps a device as a unicast destination // bindPort: 7700, // opt-in: bind the well-known receive port (default: ephemeral)});- Sends go to port
7700(the device command port). The receive socket binds an ephemeral (OS-assigned) port by default (DEC-036): only the daemon (hapbeat-helper) binds the well-known 7700, so the SDK never steals it from the helper. PONGs come back to the ephemeral source port, so discovery still works. - Pass
bindPort: 7700to opt in to binding the well-known receive port (a daemon-style listener that wants unsolicited broadcasts); it falls back to an ephemeral port if 7700 is busy. keepalive(default true) sends a PING every 5 seconds. Devices reply PONG only to a PING, so this is what keeps the unicast destination table alive. WhenappNameis set it also sendsCONNECT_STATUS, showing the app name on the device OLED (hb.close()sends an “app has left” notification to clear it).
Sending is unicast by default
Section titled “Sending is unicast by default”PLAY / STOP / STOP_ALL and streams are unicast to every device that
answered a PING, and broadcast only while none has replied yet (never both).
Whenever a single power-saving client is associated with the AP, the AP buffers group-addressed frames until its next DTIM beacon (100–300 ms). That is what a late one-shot haptic and a periodically stuttering stream come from. Nothing on the device can avoid it — the cause is an unrelated client — so the sender unicasts instead.
- To fire many devices in lockstep, pass
unicast: falseto pin sending to broadcast (one send reaches them all, while unicast goes out device by device). - Devices whose address does not match
targetare dropped from the destination set. If every known device mismatches, commands fall back to broadcast — the device applies the same filter on receipt so nothing misfires, whereas skipping would swallow a STOP whenever the cached address is stale. - The keep-alive PING above is what keeps the destination table current.
Multi-NIC (multi-homed) caveat
Section titled “Multi-NIC (multi-homed) caveat”If your PC has multiple network interfaces (wired + Wi-Fi, VPN, Docker virtual NICs, etc.),
a broadcast addressed to 255.255.255.255 may go out through a different NIC than the
Hapbeat. If devices are not found or do not fire, check that the NIC connected to the same
LAN as the Hapbeat has a route. To send to a specific segment, set broadcastAddr to that
subnet’s broadcast address (e.g. 192.168.1.255).
React Native — direct UDP (helper-free)
Section titled “React Native — direct UDP (helper-free)”The React Native build sends UDP directly from the phone (unicast by default,
same as Node) using the optional
peer dependency react-native-udp. A phone is not sandboxed like a browser, so it can open a
real UDP socket. That means no hapbeat-helper is needed, and the wire format is identical
to Node. The react-native condition in exports resolves dist/react-native.js.
const hb = await connect({ appName: "MyApp" });hb.play("sample-kit.sine_100hz", { gain: 0.5 });Setup in the app
Section titled “Setup in the app”-
Install the dependencies.
npm install react-native-udp fast-text-encodingreact-native-udp— the UDP native module (autolinked).fast-text-encoding— a required polyfill. RN Hermes (including 0.86) shipsTextEncoderbut notTextDecoder, which the wire protocol needs to decode.
-
Add a
metro.config.jsresolver so@hapbeat/sdkresolves to its React Native build.// metro.config.jsconfig.resolver.unstable_enablePackageExports = true;config.resolver.unstable_conditionNames = ["react-native", "require", "default"]; -
Make
import 'fast-text-encoding';the first import (before@hapbeat/sdk, at the top ofindex.jsorApp.tsx). Otherwise you getReferenceError: Property 'TextDecoder' doesn't exist.import "fast-text-encoding"; // ← first, before @hapbeat/sdkimport { connect } from "@hapbeat/sdk";
Platform permission notes
Section titled “Platform permission notes”- Android: the
INTERNETpermission is granted by default, and broadcast send works out of the box. Receiving discovery PONGs may need a multicast lock on some networks. AP / client isolation can block broadcast. - iOS 14+: requires the Local Network permission
(add
NSLocalNetworkUsageDescriptiontoInfo.plist).
For a complete, runnable example, see Examples.
Browser — via hapbeat-helper
Section titled “Browser — via hapbeat-helper”Because the browser build cannot send UDP, it hands instructions (play_event /
stream_begin, etc.) over WebSocket to the local hapbeat-helper, and the helper performs
the UDP broadcast.
Installing and running the helper
Section titled “Installing and running the helper”pip install hapbeat-helperhapbeat-helper # listens on ws://localhost:7703Connecting
Section titled “Connecting”const hb = await connect({ appName: "MyWebXR", helperUrl: "ws://localhost:7703", // default connectTimeoutMs: 4000, // default. Time until reject when the helper is unresponsive onConnectionLost: () => { // called when the helper goes down / restarts after the connection was established console.warn("Lost connection to hapbeat-helper"); },});- If the helper is unreachable or does not respond within
connectTimeoutMs,connect()rejects. Guide the user topip install hapbeat-helperand start it. onConnectionLostis only called when an already-established connection is later lost (the helper quits or restarts). An initial connection failure is handled by the reject side ofconnect().
Capability differences between transports
Section titled “Capability differences between transports”play / stop / stopAll (command mode) behave the same on all transports.
Node and React Native both send UDP directly and match in capability; only the browser
(via the helper) has some constraints around streaming (clip / live).
| Feature | Node (direct UDP) | React Native (direct UDP) | Browser (helper WS) |
|---|---|---|---|
command playback play(id) | ✅ | ✅ | ✅ |
target specification (command) | ✅ device self-filters | ✅ device self-filters | ✅ |
targetTimeUs (synced playback) | ✅ carried in the packet | ✅ carried in the packet | ⚠️ ignored (immediate playback only) |
| clip / live streaming | ✅ | ✅ | ✅ |
| per-device targeting of clip / stream | ✅ scoped by in-packet address | ✅ scoped by in-packet address | ⚠️ reaches every device the helper knows |
| send path | ✅ unicast by default (broadcast only until a device is found) | ✅ unicast by default (broadcast only until a device is found) | decided by the helper |
| keep-alive | ✅ PING every 5 s (+ CONNECT_STATUS) | ✅ PING every 5 s (+ CONNECT_STATUS) | — |
device discovery discover() | ✅ broadcast PING/PONG | ✅ broadcast PING/PONG | ✅ via the helper’s rescan |
Why the browser-side constraints exist:
targetTimeUsignored: the helper WS level-1 protocol does not expose a scheduled playback time and only relays immediate playback.- clip reaches every device: the helper is designed to resolve stream targets to the IPs
of known devices, and per-device clip targeting via an address string (
player_1/chest, etc.) is currently unsupported. Node clip streaming honors the in-packet address.
These relate to Command vs Clip / Streaming — clips & ad-hoc PCM / Live Streaming (openStream) as well.
Bundlers and Electron
Section titled “Bundlers and Electron”- Vite / webpack / esbuild automatically resolve the
browsercondition inexports, so no special configuration is needed (the browser build is selected at bundle time). - Electron can use either depending on your setup. Even in the renderer process, you can use the node build (direct UDP) if Node integration is enabled. Because you can send directly to devices without standing up the helper separately, the node build is the more convenient choice for desktop apps.
Summary
Section titled “Summary”- One API, three transports. The selection is made automatically by the
exportsmap. - Node = direct UDP (sends to 7700; unicast to known devices by default and broadcast only
until one is found; receive bind is ephemeral by default,
bindPortto opt in; keep-alive; mind multi-NIC). - React Native = direct UDP (requires
react-native-udp+ thefast-text-encodingpolyfill, ametro.config.jsresolver, polyfill as the first import; no helper needed). - Browser = via hapbeat-helper (requires
pip install hapbeat-helper; constraints ontargetTimeUsand per-device clip targeting). - Command-mode behavior matches across all paths, so starting with command lets you avoid worrying about the difference at first.
Read next
Section titled “Read next”- Getting Started — installation and your first event
- Command vs Clip — choosing between command and clip
- Live Streaming (openStream) — continuous streaming (
openStream)