Skip to Content
DocumentationBackend SDKOverview

Backend SDK

You never have to implement the protocol yourself. The @luno-oss/* packages provide a framework-independent core that handles pairing, enrolment, device management, the connection handshake and messaging — and thin adapters that mount it on whatever you already run.

import { createLuno, memoryStore } from '@luno-oss/core' const luno = createLuno({ store: memoryStore(), secret: process.env.LUNO_SECRET! })

That object is the whole API. An adapter’s only job is to hand it HTTP requests and sockets.

Layers

┌────────────────────────────────────────────────────────┐ │ ADAPTERS @luno-oss/hono · express · fastify · nestjs · │ │ cloudflare — translate runtime ⇄ core. │ │ no business logic. │ └───────────────────────────▲────────────────────────────┘ │ depends on ┌───────────────────────────┴────────────────────────────┐ │ APPLICATION services: pairing · enrolment · devices │ │ messaging · presence · sessions · audit │ │ ── declares PORTS (interfaces) it needs ── │ ├────────────────────────────────────────────────────────┤ │ DOMAIN entities, state machines, policy, invariants │ │ pure. no I/O. no async. fully unit-testable. │ └───────────────────────────▲────────────────────────────┘ │ depends on ┌───────────────────────────┴────────────────────────────┐ │ PROTOCOL @luno-oss/protocol — envelope, commands, │ │ events, acks, control, QR payload, version negotiation│ │ zero dependencies. shared with client SDKs. │ └────────────────────────────────────────────────────────┘ INFRASTRUCTURE (store-postgres, …) implements ports. Depends inward. Nothing depends on it.

Dependencies point inward, always. @luno-oss/core imports @luno-oss/protocol and nothing else. Infrastructure and adapters are injected at composition time by you, never imported by the core.

Packages

PackageDepends onContains
@luno-oss/protocolEnvelope, command, event, ack and control types; codecs; DecodeResult; version negotiation; QR payload parse and build; pairing DTOs; error taxonomy
@luno-oss/coreprotocolDomain, application services, port interfaces, createLuno(), the fetch router, and memoryStore()
@luno-oss/testingprotocol, coreA scriptable fake node and the store conformance kit
@luno-oss/store-postgrescoreA durable, driver-agnostic LunoStore
@luno-oss/honocoreNear-zero glue: Hono is already fetch-native
@luno-oss/expresscorefetch↔Express bridge plus ws upgrade handling
@luno-oss/fastifycoreThe same for Fastify
@luno-oss/nestjscoreA LunoModule wiring the engine through DI
@luno-oss/cloudflarecoreWorker entry plus Durable Object session

@luno-oss/protocol is its own package because client SDKs need the protocol types without the server engine. A dashboard rendering a device_status payload wants the envelope types, not pairing policy and storage ports.

Why “framework-independent” is the easy half

Not depending on Express is mostly a lint rule. The harder constraint is runtime independence:

AssumptionBreaks onConsequence
node:cryptoWorkers, Deno (partly)Hashing and randomness must be a port
BufferWorkers, browsersUint8Array and Web Crypto only
setIntervalRequest-scoped functions, WorkersTimers must be a port, never ambient
In-process Map of socketsAny multi-instance deployThe session registry must be a port
Holding a socket openFirebase, Appwrite, Vercel functionsSee below
process.envWorkers (uses bindings)Config is injected, never read ambiently

So the rule is not “no Express import”. It is: the core targets the intersection of Node 18+, Workers, Deno and Bun — pure ESM TypeScript, Web Crypto, fetch types, and no ambient I/O of any kind. That intersection is enforced mechanically by lint rules and a CI job that type-checks the core against Workers and Deno lib types, not by review discipline.

Not every platform can hold a socket

The protocol is built around a long-lived, stateful, bidirectional connection. Several popular platforms are request-scoped and simply cannot hold one open. This is a property of those platforms, not of the code.

PlatformCan hold a node socket?How it works
Node (Express/Nest/Fastify/Hono)Yesws server in-process; sessions in memory plus routing if multi-instance
Cloudflare WorkersYes — Durable ObjectsOne DO per device; the DO is the session
BunYesNative WS server
Next.js, self-hostedYesCustom server; the socket attaches to its http.Server
Deno Deploy / Supabase EdgePartlyDeno.upgradeWebSocket works, but instances are ephemeral
Firebase FunctionsNoEnrolment yes; the socket needs Cloud Run
Vercel / Next serverlessNoEnrolment yes; the socket needs a companion service

Enrolment works everywhere on that list — only the long-lived socket is constrained, and where it is unavailable your application code does not change. Next.js and Firebase have pages on exactly that split.

The architecture accommodates this with two moves: SessionRegistry is a port with local and routed shapes, so the core never assumes where the socket lives; and an HTTP fallback transport is specified with its protocol surface reserved.

The HTTP fallback 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 for the socket.

In this section