oss.sarwagya.wtf

Custom adapters

Implement the BluetoothAdapter contract and plug any transport into the same client surface — filesystem, WebSocket bridge, mock hardware.

The promise

Write an object that implements BluetoothAdapter. Pass it to createBluetooth:

import { createBluetooth } from "@0xsarwagya/agnostic-web-ble";
import { nativeWebBluetoothAdapter } from "@0xsarwagya/agnostic-web-ble/adapters/native";
import { myAdapter } from "./my-adapter";
 
const bt = createBluetooth({
  adapters: [nativeWebBluetoothAdapter(), myAdapter()],
});
 
// Everything below this line is identical whether myAdapter or native wins.
const device = await bt.requestDevice({ filters: [{ services: ["180f"] }] });

Selection is deterministic — createBluetooth awaits isAvailable() on each candidate in order and picks the first that returns truthy. Put more specific adapters earlier in the array.

The contract

Verbatim from src/types.ts:

export type Unsubscribe = () => void;
 
export type BluetoothCapabilities = {
  requestDevice: boolean;
  notifications: boolean;
  writeWithoutResponse: boolean;
  /** true when device access requires a user gesture (e.g. a click). */
  requiresUserGesture: boolean;
};
 
export type AdapterAvailability = {
  available: boolean;
  reason?: string;
  docsUrl?: string;
};
 
export interface BluetoothAdapter {
  readonly id: string;
  isAvailable(): boolean | Promise<boolean>;
  describeAvailability?(): AdapterAvailability | Promise<AdapterAvailability>;
  capabilities(): Promise<BluetoothCapabilities>;
  requestDevice(options: RequestDeviceOptions): Promise<BluetoothDevice>;
}

Method-by-method:

  • id — Stable string. Used in error messages and logs. Convention: kebab-case, transport-first (native-web-bluetooth, websocket-bridge, fs-simulation).
  • isAvailable() — Cheap synchronous or async probe. No side effects, no user prompts. Return true only if requestDevice has a real chance of succeeding in the current runtime.
  • describeAvailability() — Optional. Structured version of isAvailable(). Return a reason and docsUrl when unavailable so the client can render an honest "not supported here" state without a try/catch on requestDevice.
  • capabilities() — Honest report of what this adapter can actually do in this runtime. Called after selection; safe to be async and probe.
  • requestDevice(options) — Return a BluetoothDevice the caller can connect() to. Respect options.signal. Throw BluetoothError on failure.

Everything downstream — connect, getPrimaryService, readValue, subscribe — lives on the objects your requestDevice returns. The adapter is the entry point; the rest is your own object graph.

Capability reporting

capabilities() is not a wish list. It is what the client can rely on right now.

FieldMeaning
requestDeviceYou can produce a BluetoothDevice. Almost always true if isAvailable() returned true.
notificationscharacteristic.subscribe() works. If false, callers should poll readValue().
writeWithoutResponsewriteValue(v, { withoutResponse: true }) skips the ack round-trip. If false, the option is ignored and writes always ack.
requiresUserGestureCalling requestDevice outside a user gesture (click, keypress) will fail. Native is true; a WebSocket bridge or simulation is false.

Clients read this once via bt.capabilities() and branch UI on it. Lie in this object and you break every consumer.

Error taxonomy

Every failure your adapter reports must be a BluetoothError with one of these codes (from src/errors.ts):

UNSUPPORTED               UNAVAILABLE               PERMISSION_DENIED
DEVICE_NOT_FOUND          CONNECTION_FAILED         DISCONNECTED
SERVICE_NOT_FOUND         CHARACTERISTIC_NOT_FOUND  READ_FAILED
WRITE_FAILED              SUBSCRIPTION_FAILED       TIMEOUT
ADAPTER_ERROR             UNKNOWN

Rule of thumb:

  • Runtime doesn't ship the API at all → UNSUPPORTED.
  • No adapter reports available in this runtime → UNAVAILABLE (thrown by the client, not by you).
  • Bridge / socket / helper crashed → ADAPTER_ERROR.
  • User dismissed the chooser → DEVICE_NOT_FOUND.
  • User denied a permission prompt → PERMISSION_DENIED.
  • Everything you can't classify → UNKNOWN.

Preserve the original error via cause. Set recoverable: true only if a retry has a real chance of working (e.g. a transient disconnect, not a permission denial):

throw new BluetoothError({
  code: "READ_FAILED",
  message: `Read of ${uuid} failed: ${err.message}`,
  operation: "readValue",
  adapterId: "websocket-bridge",
  recoverable: true,
  cause: err,
});

Always populate operation — it's what turns a generic failure into a debuggable one.

Example one — filesystem-backed simulation adapter

Small. No networking. Reads canned devices from JSON. Useful for demos, tests, and reproducing bug reports.

import fs from "node:fs/promises";
import type {
  BluetoothAdapter,
  RequestDeviceOptions,
} from "@0xsarwagya/agnostic-web-ble";
import { BluetoothError } from "@0xsarwagya/agnostic-web-ble";
 
type Fixture = {
  id: string;
  name: string;
  services: { uuid: string; characteristics: { uuid: string; value: string }[] }[];
};
 
export function fsSimulationAdapter(path: string): BluetoothAdapter {
  const load = () =>
    fs.readFile(path, "utf8").then((s) => JSON.parse(s) as Fixture[]);
  return {
    id: "fs-simulation",
    async isAvailable() {
      try { await fs.access(path); return true; } catch { return false; }
    },
    async capabilities() {
      return {
        requestDevice: true,
        notifications: false,
        writeWithoutResponse: false,
        requiresUserGesture: false,
      };
    },
    async requestDevice(options: RequestDeviceOptions) {
      const fixtures = await load();
      const picked = fixtures.find((f) => matches(f, options));
      if (!picked) {
        throw new BluetoothError({
          code: "DEVICE_NOT_FOUND",
          operation: "requestDevice",
          adapterId: "fs-simulation",
          message: "No fixture matched filters.",
        });
      }
      return buildDevice(picked);
    },
  };
}

Ship it as a peer package. Point tests at a fixtures directory. Reproducing a customer's exact device is now a JSON file.

Example two — WebSocket bridge adapter

The interesting one. A tiny helper on the user's machine drives real Bluetooth (via BlueZ, CoreBluetooth, WinRT), and the browser talks to it over ws://127.0.0.1. This is how you get Bluetooth in Firefox, in Safari, in a WebView that lacks the API, or in an Electron renderer without dragging Node into your bundle.

The pattern

  • Helper binds 127.0.0.1 only. Never 0.0.0.0. LAN is not your friend.
  • Wire protocol is JSON over a single WebSocket. Verbs map 1:1 to adapter methods: requestDevice, connect, disconnect, getPrimaryService, getCharacteristic, readValue, writeValue, subscribe, unsubscribe. Each request has { id, verb, params }; each reply has { id, ok, result | error }. Notifications from the helper are unsolicited frames with { event, handle, value? }.
  • Handles, not pointers. The helper returns opaque string handles for devices, connections, services, characteristics. The client adapter wraps them in the object graph the library expects. Never leak handles into user-visible APIs.
  • Origin allowlist. The helper reads Origin on the WS upgrade and rejects anything not on its configured list. Ship an empty allowlist by default; the user adds their app's origin at install.
  • Per-launch token, ≥128 bits. The helper generates a fresh token on start, prints it to the terminal, and requires it in the first frame within 1 second. Wrong token or timeout → close. Three failed attempts from one origin → refuse further connections until helper restart.
  • Firefox has no Private Network Access. A page on the open internet can, in principle, reach 127.0.0.1:port. The token is the only real barrier. Treat it like a password: never log it, never persist it in the browser, never put it in a URL. Copy-paste from the terminal into a one-time prompt in your app.
  • Ship prebuilt binaries. Install UX is curl | sh for macOS/Linux, an .exe for Windows. Source available; ask the paranoid to build from it.

Sketch

import type {
  BluetoothAdapter,
  RequestDeviceOptions,
} from "@0xsarwagya/agnostic-web-ble";
import { BluetoothError } from "@0xsarwagya/agnostic-web-ble";
 
export function websocketBridgeAdapter(opts: {
  url: string;
  token: string;
}): BluetoothAdapter {
  let socket: WebSocket | null = null;
  const pending = new Map<
    string,
    { resolve: (v: unknown) => void; reject: (e: unknown) => void }
  >();
 
  const connect = () =>
    new Promise<WebSocket>((resolve, reject) => {
      if (socket && socket.readyState === WebSocket.OPEN) return resolve(socket);
      const ws = new WebSocket(opts.url);
      ws.onopen = () =>
        ws.send(JSON.stringify({ verb: "hello", token: opts.token }));
      ws.onmessage = (ev) => {
        const msg = JSON.parse(String(ev.data));
        if (msg.verb === "hello-ok") { socket = ws; resolve(ws); return; }
        if (msg.verb === "hello-fail") {
          ws.close();
          reject(new BluetoothError({
            code: "PERMISSION_DENIED",
            operation: "requestDevice",
            adapterId: "websocket-bridge",
            message: "Bridge rejected token.",
          }));
          return;
        }
        // ...dispatch notifications, disconnects, request replies by id
      };
      ws.onerror = () =>
        reject(new BluetoothError({
          code: "ADAPTER_ERROR",
          operation: "requestDevice",
          adapterId: "websocket-bridge",
          message: "Bridge socket errored.",
        }));
    });
 
  const rpc = async <T>(
    verb: string,
    params: unknown,
    signal?: AbortSignal,
  ): Promise<T> => {
    if (signal?.aborted) throw signal.reason;
    const ws = await connect();
    const id = crypto.randomUUID();
    return new Promise<T>((resolve, reject) => {
      pending.set(id, { resolve: resolve as (v: unknown) => void, reject });
      signal?.addEventListener("abort", () => {
        pending.delete(id);
        // Production adapters should also send a `cancel` frame so the
        // helper stops the underlying operation on its side.
        reject(signal.reason);
      });
      ws.send(JSON.stringify({ id, verb, params }));
    });
  };
 
  return {
    id: "websocket-bridge",
    async isAvailable() { try { await connect(); return true; } catch { return false; } },
    async capabilities() { return rpc("capabilities", {}); },
    async requestDevice(options: RequestDeviceOptions) {
      const handle = await rpc<string>("requestDevice", options, options.signal);
      return buildDevice(handle, rpc);
    },
  };
}

buildDevice returns a BluetoothDevice whose connect() calls rpc("connect", { handle }) and returns a BluetoothConnection wrapping the returned connection handle, and so on down the chain. Full production adapter is a few hundred lines. The interesting parts are all above.

What NOT to do

Publish your adapter

If you write one, open an issue on 0xsarwagya/agnostic-web-ble with the package name, a one-line pitch, and which runtime it unlocks. A running list lives in the README so people can find it. A good adapter is a small library — a filesystem simulator, a WebSocket bridge, a mock harness for a specific chipset. Each one is a runtime the client library now works in without changing a line of core.

The contract is the boundary. Everything on your side of it is yours.