rtnbleserver #
A Bluetooth LE GATT server for React Native.
The server authenticates a peer with a one-time password, exchanges DIDs, records the handshake time, and reports the result back to JavaScript.
Installation #
npm install rtnbleserver
Usage #
import {
getRecord,
handleDID,
setDID,
setOTP,
startGatt,
stopGatt,
} from 'rtnbleserver';
setOTP('123456');
setDID('did:plc:yourdid');
handleDID((record) => {
console.log(record.did, record.createdAt);
});
startGatt();
// Later, when the server is no longer needed:
stopGatt();
setOTP(otp)sets the OTP a peer has to write before the server will send its DID.setDID(did)sets the DID this device sends: to the peer when acting as a server, to the server when acting as a client.handleDID(callback)registers the callback invoked when a handshake completes. The callback receives{ did, createdAt }. Acting as a server,didis the peer's DID; acting as a client,didis the server's DID.createdAtis always read from the clock of the device running the GATT server, as an ISO 8601 (RFC 3339) UTC timestamp. Registering a callback replaces the previous one.getRecord()returns the last completed handshake as{ did, createdAt }, ornullwhen no handshake has completed. Acting as a server,didis the peer's DID; acting as a client,didis the server's DID.startGatt()starts the GATT server and advertising.stopGatt()stops advertising, closes the server, and clears the session state. The last record is kept.
Client #
The same module can act as the GATT client. It never starts a GATT server: it scans for
the service, authenticates with the OTP, sends the DID from setDID, and reports the
DID and timestamp it received from the server.
setDID('did:plc:yourdid');
handleDID((record) => {
console.log(record.did, record.createdAt);
});
handleClientError((message) => {
console.warn(message);
});
startClient('123456');
startClient(otp)scans for the first device advertising the service, connects, enables notifications, writesotp, sends the DID fromsetDID, and then calls thehandleDIDcallback with the server's DID and the timestamp recorded by the server. It throws when the DID is not set, Bluetooth is off, permissions are missing, or a client is already running.stopClient()aborts the scan or connection.handleClientError(callback)registers the callback for asynchronous failures: timeouts, disconnects, and scan failures. Registering a callback replaces the previous one.
A client handshake gives up after 15 seconds.
GATT protocol #
The server advertises one primary service. All UUIDs are fixed:
| Purpose | UUID |
|---|---|
| Service | 5f1d0000-9b3a-4c7e-8d2f-6a4b1c8e0f93 |
| OTP | 5f1d0001-9b3a-4c7e-8d2f-6a4b1c8e0f93 |
| DID | 5f1d0002-9b3a-4c7e-8d2f-6a4b1c8e0f93 |
| Record | 5f1d0003-9b3a-4c7e-8d2f-6a4b1c8e0f93 |
| Characteristic | Properties | Value |
|---|---|---|
| OTP | write | UTF-8 OTP digits. The write succeeds only when it matches setOTP. |
| DID | read, write, notify | This device's DID (read/notify) and the peer's DID (write). |
| Record | read, notify | {"did":"<client did>","createdAt":"<ISO 8601>"} after the handshake. |
Flow:
- The peer connects and writes the OTP to the OTP characteristic.
- On a match, the server marks the peer as authorized and notifies its own DID on the DID characteristic. A mismatch is rejected: Android cancels the connection, and iOS responds with an authorization error (CoreBluetooth cannot disconnect a central).
- The peer writes its DID to the DID characteristic.
- The server records the time from its own clock (the device running the GATT
server, not the peer), notifies the record on the Record characteristic, and
calls the
handleDIDcallback.
DID and record notifications are chunked so they work with the default 23-byte MTU. Each notification carries a two-byte header followed by up to 18 payload bytes:
[sequence:1][total:1][payload...]
Concatenate the payloads in sequence order. The DID and Record characteristics can also be read; reads return the complete value, so use a long read (read blob) when the value is longer than the negotiated MTU.
Notifications require the peer to enable them (write 0x0001 to the standard CCCD,
which the server exposes for the DID and Record characteristics).
The client runs the complementary side: it subscribes to the DID and Record
characteristics, writes the OTP, writes its own DID in frames, then reassembles the
server's DID notification and the Record notification. Its handleDID callback
combines the server's DID with the createdAt field of the record.
Permissions #
Android: the library declares BLUETOOTH_CONNECT, BLUETOOTH_ADVERTISE,
BLUETOOTH_SCAN (with neverForLocation), and ACCESS_FINE_LOCATION for Android 11
and below. The app has to request the runtime permissions it uses before calling
startGatt() or startClient(), for example with PermissionsAndroid. On Android 11
and below, scanning also requires location services to be enabled.
iOS: add NSBluetoothAlwaysUsageDescription to the app's Info.plist. The permission
prompt appears when startGatt() creates the CBPeripheralManager or startClient()
creates the CBCentralManager.
Two synchronous checks report whether the runtime permissions are currently granted:
import { hasClientPermissions, hasServerPermissions } from 'rtnbleserver';
hasServerPermissions(); // BLUETOOTH_CONNECT + BLUETOOTH_ADVERTISE
hasClientPermissions(); // BLUETOOTH_SCAN + BLUETOOTH_CONNECT
The checks never prompt. On iOS, authorization is notDetermined until
CoreBluetooth has been used, so both return false before the first startGatt() or
startClient() call triggers the prompt.
Using a local checkout in a host app #
A published install is a normal directory in node_modules. When a host app depends
on this repository through a file: path, the package is symlinked and Metro resolves
the sources to their real path. Point Metro at the checkout and keep peer dependencies
in the host app so only one copy of React Native is bundled:
const path = require('node:path');
const config = getDefaultConfig(__dirname);
config.watchFolders = [path.resolve(__dirname, '../RTNbleServer')];
config.resolver.nodeModulesPaths = [path.resolve(__dirname, 'node_modules')];
config.resolver.disableHierarchicalLookup = true;
With Bun, file: dependencies are installed as a tree of symlinks, which Expo's
autolinking does not traverse. Add the checkout to the host app's autolinking search
paths so the Android and iOS sources are discovered:
{
"expo": {
"autolinking": {
"searchPaths": ["../RTNbleServer"]
}
}
}
Development #
The library has no example app. See CONTRIBUTING.md for the standalone Android build and the JavaScript build.
Contributing #
License #
MIT