react-native-nitro-osc
v0.2.0
Published
react-native-nitro-osc
Readme
react-native-nitro-osc
OSC (Open Sound Control) for React Native, powered by Nitro Modules.
Send and receive OSC messages over UDP — turn a phone into a control surface for lighting desks, VJ software, DAWs, synths, or anything else that speaks OSC.
The codec and the socket are independent. Encode a message and send it however you like, or hand the socket bytes that never came from this library at all.
Platform support
| Platform | Status | |---|---| | iOS (physical device) | Supported — sending verified on hardware | | iOS Simulator | Works, but Local Network permission behaves differently than on device — verify on hardware | | Android | Implemented and tested against loopback traffic; not yet verified on a physical device |
Prerequisites
- React Native with the New Architecture enabled (developed against 0.86)
react-native-nitro-modulesinstalled in your app
Installation
npm install react-native-nitro-osc react-native-nitro-modules
cd ios && pod installAndroid needs no setup beyond the INTERNET permission, which the React Native app
template already declares. iOS needs one Info.plist key — see below.
iOS: Local Network permission
Sending to a LAN address triggers the system Local Network prompt, so your app needs
NSLocalNetworkUsageDescription in its Info.plist:
<key>NSLocalNetworkUsageDescription</key>
<string>Sends OSC messages to devices on your local network.</string>Two things to design around:
- The prompt fires on the first
send(), not onbind()orreceive(). - That first datagram may be dropped while the prompt is pending, and every
datagram is dropped if the user denies it.
send()does not throw in either case — UDP delivery is never acknowledged, so a successfulsend()means the packet left, not that it arrived.
NSBonjourServices is not required. This library performs no discovery.
Quick start
import { NitroModules } from 'react-native-nitro-modules'
import { encodeOscMessage, OscSocket } from 'react-native-nitro-osc'
const socket = NitroModules.createHybridObject<OscSocket>('OscSocket')
socket.bind(0) // 0 = let the OS pick a port
const bytes = encodeOscMessage({
address: '/layer/1/opacity',
args: [{ type: 'f', value: 0.25 }],
})
if (bytes != null) {
socket.send('192.168.1.42', 9000, bytes)
}
socket.close()API
OscSocket
A UDP socket, and nothing more. Send and receive share one file descriptor, so this is a single object rather than a sender/receiver pair.
import { NitroModules } from 'react-native-nitro-modules'
import { OscSocket } from 'react-native-nitro-osc'
const socket = NitroModules.createHybridObject<OscSocket>('OscSocket')| Method | Signature | Description |
|---|---|---|
| bind | (port: number, host?: string) => number | Binds the local socket and returns the port actually bound. Pass 0 to let the OS assign one. host defaults to the IPv4 wildcard. Throws if already bound, or if the bind fails. |
| send | (host: string, port: number, data: ArrayBuffer) => void | Sends one datagram. Requires a prior bind. |
| receive | (timeoutMs: number) => ArrayBuffer \| null | Blocks up to timeoutMs for one datagram, returning its bytes or null on timeout. Requires a prior bind. |
| close | () => void | Closes the socket. Safe to call more than once. |
bind() first. send() and receive() both throw if the socket is not bound.
host must be a literal IPv4 dotted-quad. No DNS lookup is performed, and an
IPv6-formatted address is rejected rather than silently mishandled.
receive() blocks the calling thread. Poll it with a short timeout — tens of
milliseconds, not seconds. "Nothing arrived yet" is the common case, and a long
timeout freezes the caller for its full duration.
encodeOscMessage
encodeOscMessage(message: OscMessage): ArrayBuffer | nullEncodes one OSC message. Returns null — never throws — if the address is not
sendable or a string argument contains an embedded NUL.
decodeOscPacket
decodeOscPacket(bytes: ArrayBuffer): OscDecodeResultDecodes a packet into a flat list of messages. Bundles are flattened recursively and their timetags ignored, so callers never branch on bundle-vs-message.
Decoding never throws — a malformed datagram off the network is an ordinary outcome, not an exceptional one:
const result = decodeOscPacket(bytes)
if (result.ok) {
for (const message of result.messages) {
console.log(message.address, message.args)
}
} else {
console.warn('bad packet:', result.error.kind)
}result.error.kind is 'malformed' (with a reason) or 'bundle-too-deep'
(nesting past 16 levels).
isSendableOscAddress
isSendableOscAddress(path: string): booleanTrue if path is a literal OSC address safe to send: starts with /, all
characters ASCII-graphic, and none of # * , ? [ ] { }. Pattern-matching
address expressions are deliberately rejected — this library sends to concrete
addresses.
Argument types
type OscArg =
| { type: 'i'; value: number } // int32
| { type: 'f'; value: number } // float32
| { type: 's'; value: string } // string
| { type: 'b'; value: ArrayBuffer } // blob
| { type: 'd'; value: number } // float64
| { type: 'bool'; value: boolean } // 'T' / 'F'
| { type: 'nil' } // 'N'
| { type: 'unsupported'; tag: string }| OSC tag | Encode | Decode | Notes |
|---|---|---|---|
| i f s b | Yes | Yes | The four types OSC 1.0 requires |
| d | Yes | Yes | float64 |
| T / F | Yes | Yes | Surfaces as { type: 'bool' } |
| N | Yes | Yes | Surfaces as { type: 'nil' } |
| h I | No | Partial | Width is known and skipped correctly, but no value is surfaced — you get { type: 'unsupported', tag }. h is int64, which a JS number cannot hold losslessly |
| [ ], m, r, c | No | No | Rejects the whole message |
The union is tiered deliberately. A decoder must know every argument's byte width to find where the next one begins — even for values it will not hand back — because one unrecognized tag would otherwise silently misalign everything after it.
Receiving
const socket = NitroModules.createHybridObject<OscSocket>('OscSocket')
const port = socket.bind(9000)
console.log('listening on', port)
// Call this from a timer or a background loop — not in a tight synchronous loop.
const bytes = socket.receive(20) // short timeout: this blocks
if (bytes != null) {
const result = decodeOscPacket(bytes)
if (result.ok) {
// handle result.messages
}
}Limitations
- IPv4 only. IPv6 hosts are rejected rather than silently mishandled.
- Bundles decode but do not encode. Send one message at a time.
- No discovery. No mDNS/Bonjour — you supply the host and port.
- One thread. A socket is not safe for concurrent
sendandreceivefrom different threads. - Pre-1.0 OSC messages without a type-tag string are treated as malformed.
Design: why the codec is TypeScript
OSC needs no vendor SDK and no OS-gatekept API — it is a message format sent over a plain UDP socket. The wire format (padded strings, type-tag-prefixed arguments, 4-byte alignment) is simple enough that a hand-written TypeScript codec is small, and there is no frame-pacing, reconnect, or native-service logic here that would justify bridging to a native implementation.
So the native surface is as thin as it can be: open a socket, send bytes, receive
bytes. It exists at all only because React Native's JS runtime has no datagram API —
fetch and WebSocket are HTTP/TCP-shaped, not UDP.
Development
See CONTRIBUTING.md for the repository layout, the checks, and the branching convention.
