Skip to content

feat: add WASM/WebBluetooth support - #436

Open
acouvreur wants to merge 31 commits into
tinygo-org:devfrom
acouvreur:add-webbluetooth
Open

feat: add WASM/WebBluetooth support#436
acouvreur wants to merge 31 commits into
tinygo-org:devfrom
acouvreur:add-webbluetooth

Conversation

@acouvreur

@acouvreur acouvreur commented Apr 10, 2026

Copy link
Copy Markdown
Member

This is a work in progress proposal to add support for the Web Bluetooth API through WASM.

I can currently successfully connect to a device, but I'm still having some trouble/experimentations discovering services, characteristics and interacting with them.

AI Disclaimer: This was mostly implemented using Claude Opus 4.6

I had a lot of fun interacting with bluetooth device from a webpage through WASM with the same underlying code that can run in linux, mac, windows or embedded systems.

Comment thread examples/webbluetooth/html/wasm.wasm Outdated
Comment thread examples/webbluetooth/html/wasm_exec.js Outdated
@deadprogram

Copy link
Copy Markdown
Member

What is the status with this PR @acouvreur 😸

@acouvreur

Copy link
Copy Markdown
Member Author

What is the status with this PR @acouvreur 😸

Are you interested with bringing WASM / WebBluetooth support?

The current implementation is a load of duct taping but it works! I can polish the PR a little bit and we can merge it if you think that'd be a good addition.

@deadprogram

Copy link
Copy Markdown
Member

Are you interested with bringing WASM / WebBluetooth support?

I am indeed!

@acouvreur

Copy link
Copy Markdown
Member Author

Are you interested with bringing WASM / WebBluetooth support?

I am indeed!

Cool! Let me clean that up :)

The listening flag was never set to true, so EnableNotifications(nil) kept
the JS listener attached and released no resources. A late event then found
a nil callback. A second call added a duplicate listener.
A wrapped Go function blocks the JS event loop until it returns. A callback
that calls back into this package waits for a promise and deadlocks the page.
A DataView can be a window into a larger ArrayBuffer. Wrapping only the
buffer gave the wrong bytes.
Calling toString on undefined or null throws inside the catch handler and
stops the program.
Package level maps grew for the life of the page and were shared by all
adapters.
StopScan now returns errNotScanning when no scan runs, and Scan returns
errScanning for a second scan. This agrees with the other backends.
Read now has a value receiver, and a UUID that does not parse gives an error
instead of a zero UUID.
The server now only serves the html directory. The README gives the build
steps for TinyGo and for the standard Go toolchain.
A panic stops the Go runtime, so the page stayed dead after the user closed
the device picker.
Without a job that builds for js/wasm, the browser backend can break without
a signal.
This matches the README at the root of the repository.
The support file and the module must come from the same toolchain. A mixed
pair gives an import error in the browser.
The two build sections already show the pair of commands for each toolchain.
The module now exports globalThis.ble with a function for each operation, and
the page does all of the display work. Each export returns a Promise and runs
the work on a new goroutine, because a function that JavaScript calls must not
block the event loop.
Battery Level sends a new value every second, so the notifications panel shows
data immediately. The examples/battery peripheral serves it.
Reset releases the disconnect listeners and drops the known devices, so that
Enable can run again. BLEAdapter needs this method.
The screenshot did not show the current page.
512 is the maximum length of an attribute value, not the maximum ATT MTU.
Write can use a long write up to the maximum attribute value length. A write
without response must fit the negotiated MTU, which GetMTU does not give.
requestDevice gives only an id and a name, so the RSSI is 0 and the payload
has no service UUIDs.
@acouvreur
acouvreur marked this pull request as ready for review September 2, 2026 18:29
@acouvreur

Copy link
Copy Markdown
Member Author

@deadprogram looks good for me now...

Clean up the example so that the built wasm exposes functions to be used in a JS program to interact with the tinygo-org/bluetooth APIs.

Quite safe merge IMO.

Next step for me is to update #468. I would love to add cancellable operations to this library.

With JS you can easily map an AbortController Signal to a Go context.Context.

@deadprogram deadprogram left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this!

Here are some comments from an automated revview.

Overall

This is a good addition and it is near to merge. The structure agrees with the other backends. gap_js.go uses the same Address shape as the macOS backend (opaque ID, Set, no-op SetRandom), and the interface assertions (BLEAdapter, GAPDevice, GATTCService, GATTCCharacteristic) are all present. The known WASM traps are correct: user callbacks run on a new goroutine so that they do not stop the JS event loop, DataView values are read at their own offset and length, and js.Func values get released.

Points to think about

1. Adapter.RequestedServices is only on the js backend (adapter_js.go)

No other backend has an exported field on Adapter, so code that sets this field does not compile for Linux, macOS, or Windows. This makes the "same code everywhere" goal weaker. Two options: keep it and accept the limit (it is documented), or add the same field as a no-op to the other backends so that portable code compiles. This is the one API decision in the PR that is difficult to change later.

2. Adapter.Reset leaves a connection open (adapter_js.go)

Reset removes the disconnect listeners and clears the maps, but it does not disconnect the GATT server. The browser keeps the connection to a device that is connected. A gatt.disconnect() on each known device would make the reset complete.

3. EnableNotifications(nil) calls stopNotifications always (gattc_js.go)

If notifications were never started, the code still calls stopNotifications on the characteristic. This is harmless in Chrome, but a return after stopListening when c.listening is false is more clear.

4. Read truncates without a signal (gattc_js.go)

If the value is longer than data, Read gives the first len(data) bytes and no indication. Please make sure that this agrees with the macOS and Windows backends.

5. The example imports syscall/js with no build constraint

go build ./... on Linux now stops at examples/webbluetooth. CI does not use ./..., and examples/circuitplay already breaks it, so this changes nothing today. A //go:build js line in the example is still an inexpensive improvement.

6. requestDevice and user activation

Scan calls navigator.bluetooth.requestDevice from a goroutine, so the call leaves the task of the click. Chrome accepts this while the transient activation is valid (some seconds), which is why the example works. A note in the README that the page must call requestDevice immediately after a user action would prevent a difficult bug report later.

Nits

  • examples/webbluetooth/html/.gitignore has no final newline.
  • The WASM installation steps are given two times (repository README and example README). This is not incorrect, only repetition.

Comment thread Makefile Outdated
TinyGo gives a smaller module and a faster build than the standard toolchain.
Linux, macOS and Windows give the length of the value, even when it is longer
than the buffer. The caller can then see that the value was cut.
Reset removed the listeners but left the browser connected to the device.
The browser refuses requestDevice when the user activation is too old.
A characteristic that comes from a second discovery has listening false while
the browser still sends notifications.
@deadprogram

Copy link
Copy Markdown
Member

Thanks for this. I built the branch with both toolchains and go vet for GOOS=js is clean. The file layout follows the other backends well.

Some points from a read of the code.

1. RequestedServices is a WASM-only public field (adapter_js.go:30)

This is the only new public item that portable code must set. A program for Linux and for the browser cannot set it without a build tag, which removes much of the value of the shared API. We can put the field on every Adapter as a no-op elsewhere, or give the list to Scan or to ConnectionParams. It is public API, so it is better to decide now.

2. The notification listeners leak (gattc_js.go:262)

Adapter.Reset releases the gattserverdisconnected listeners, but no path releases the characteristicvaluechanged listeners. Device.Disconnect does not release them either. A page that connects and disconnects many times keeps one js.Func for each subscription until the page closes.

3. A user cancel looks like a failure (gattc_js.go:290)

jsError gives a plain string, so Scan returns NotFoundError: User cancelled the requestDevice() chooser. A cancel is a normal flow. A sentinel error for that case lets the caller tell the two apart.

Smaller items

  • Enable does not check window.isSecureContext. A page on plain HTTP fails later with an opaque error from requestDevice.
  • Device.Disconnect returns nil when the device is not connected. gap_linux.go:540 gives the error of the underlying call.
  • The listener in setDisconnectHandler (gap_js.go:181) calls listener.Release() inside its own invocation. It works, but a defer is easier to read.
  • The maxAttributeValueLength comment quotes 4 lines of the specification. Please keep it to 2 lines. The URL is enough.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants