Adapters
An adapter translates between a runtime and the core. It exposes three things —
POST /enroll, POST /enroll/status, and the WS /ws session socket — and it
never parses a protocol frame. The core drives the entire handshake, acks,
resync and command dispatch.
Every packaged adapter ships with a fake-node run over its own transport, so a
phone pairs against any of them identically. None of them required a change to
@luno-oss/core.
The contract
Whichever runtime you are on, an integration is these four calls. Everything else on these pages is the glue a particular framework needs to make them.
- Route
POST /enrollandPOST /enroll/statustoluno.http.handle - Authorise the
WS /wsupgrade withluno.connections.authorize— before upgrading - Open a session with
luno.connections.open(device, sink)and pump frames intosession.receive - Call
session.close()when the socket ends
Pick your runtime
Fetch-native. One handler across Node, Workers, Deno and Bun.
HonoClassic Node. Also the socket bridge for any bare http.Server.
A single plugin that registers routes and the socket.
FastifyA LunoModule and a LUNO token, wired through DI.
The edge path, with Durable Objects holding the session.
Cloudflare WorkersNo adapter needed — route handlers talk to the core directly.
Next.jsFunctions for enrolment, Cloud Run for the socket.
FirebaseThe contract in full, and how to prove it.
Writing your ownNot every platform can hold a socket
This is the one thing worth understanding before you choose. The protocol is built around a long-lived, bidirectional connection. Several popular platforms are request-scoped and cannot hold one — a property of those platforms, not of Luno.
| Runtime | Enrolment | Session socket | Package |
|---|---|---|---|
| Hono (Node, Deno, Bun) | Yes | Yes | @luno-oss/hono |
| Express | Yes | Yes | @luno-oss/express |
| Fastify | Yes | Yes | @luno-oss/fastify |
| NestJS | Yes | Yes | @luno-oss/nestjs |
| Cloudflare Workers | Yes | Yes — Durable Objects | @luno-oss/cloudflare |
| Next.js, self-hosted | Yes | Yes — custom server | none needed |
| Next.js on Vercel | Yes | No | none needed |
| Firebase Cloud Run | Yes | Yes | @luno-oss/express |
| Firebase Functions | Yes | No | none needed |
| Deno Deploy / Supabase Edge | Yes | Partly — ephemeral instances | @luno-oss/hono |
Where the socket column says no, enrolment still works and your application code is unchanged; the socket needs a companion process that can stay alive. See Next.js and Firebase for the concrete shapes.
The HTTP fallback transport is designed, not built. v1 implements sockets
only. FrameSink is defined so a long-poll or SSE buffer satisfies it without
change, and the additive versioning rules mean the fallback can land later
without a redesign — but today, a socketless platform needs a companion
service.
Authentication at the boundary
An unknown device credential fails the WS /ws upgrade with an HTTP 401.
The authorisation runs before the socket is upgraded, deliberately: the node
treats a 401 as “re-enrolment required” and pauses, whereas a post-upgrade close
looks like a transient failure and it would reconnect through it forever.
If you write your own integration, authorise before upgrading. Accepting the socket and closing it on bad credentials turns every revoked device into a reconnect loop against your server.
Operator and API auth for your own dashboard routes is your application’s concern — adapters only handle the node credential. See the two axes.
Why the duplication is deliberate
The socket bridge — authorise before upgrade, then hand off to
connections.open — is roughly the same thirty-five lines in each adapter. That
repetition is a choice: each adapter stays self-contained and copy-pasteable,
rather than introducing a shared dependency between them that would have to
absorb every runtime’s quirks.