Skip to Content

Firebase

There is no @luno-oss/firebase package, and the reason is worth stating plainly: Cloud Functions cannot hold a WebSocket open. They are request-scoped and terminate when the response is sent, so they can serve enrolment but can never carry a node session.

That is a property of the platform, not a gap in Luno. The fix is to put the socket somewhere that stays alive — on Google Cloud, that is Cloud Run.

Cloud Run runs an ordinary Node container that can hold sockets, so this is just a normal Node deployment. Use whichever framework you prefer — Express or Hono:

server.ts
import express from 'express' import { createLuno } from '@luno-oss/core' import { attachLunoWebSocket, registerLunoEnroll } from '@luno-oss/express' import { firestoreStore } from './firestore-store' const luno = createLuno({ store: firestoreStore(), secret: process.env.LUNO_SECRET!, wsUrl: `${process.env.PUBLIC_URL}/ws` }) const app = express() registerLunoEnroll(app, luno) const server = app.listen(Number(process.env.PORT) || 8080) attachLunoWebSocket(server, luno)

Configure the service with minimum instances ≥ 1 and CPU always allocated. Cloud Run will otherwise scale to zero and drop every connected node — and because the node treats a dropped socket as transient, it will reconnect in a loop against a service that keeps going back to sleep.

Raise the request timeout too (up to 60 minutes); it bounds how long a WebSocket may stay open.

Session state lives in one container’s memory by default, so either run a single instance or supply a SessionRegistry that routes across instances. See Ports.

What Functions can and cannot do

If your app is already Functions-based, enrolment can stay there. luno.http.handle takes a structural { method, url, json() }, so an onRequest handler needs no adapter:

functions/src/index.ts
import { onRequest } from 'firebase-functions/v2/https' import { luno } from './luno' export const lunoEnroll = onRequest(async (req, res) => { const result = await luno.http.handle({ method: req.method, url: req.url, json: async () => req.body }) res.status(result.status).set(result.headers).send(result.body) })
SurfaceCloud FunctionsCloud Run
POST /enrollYesYes
POST /enroll/statusYesYes
WS /ws sessionNoYes
luno.sms.send from your codeYesYes

Splitting them is fine. Sending from a Function while the socket lives on Cloud Run needs no extra wiring: luno.sms.send persists the command first, and whichever process holds the socket dispatches it. That is the same path an offline device takes, so it is already the well-tested one.

Both processes must share a store — that is where devices, credentials and messages live.

Firestore as a store

Firestore can back a LunoStore, but read Stores first. The requirement that matters is claim():

Consuming a pairing session must be linearizable. Under maxEnrollments: 1, two nodes submitting the same code at the same moment must produce exactly one enrolment. A get() then set() will race, and the bug shows up only under load, in production, as a duplicate device.

Use runTransaction for the claim. Do not hand-roll it with a read followed by a write.

Prove it before you trust it — the conformance suite includes a concurrent-claim test specifically for this:

import { describe } from 'vitest' import { describeStoreConformance } from '@luno-oss/testing/store' describe('firestoreStore', () => { describeStoreConformance(() => firestoreStore(emulatorDb)) })

Realtime Database is not the session

It is worth saying explicitly, because it is a natural assumption: the node speaks the Luno wire protocol over a WebSocket to your server. It does not speak Firebase’s protocol, and pointing it at Realtime Database or Firestore will not work. Those are options for the store, not the transport.

Next

  • Express — the Cloud Run integration in detail
  • Stores — writing and proving a Firestore store
  • Next.js — the same split on Vercel