@rangorithm/scan-link
v0.1.0
Published
Pair a phone by QR and stream its barcode scans to the browser over SSE.
Readme
@rangorithm/scan-link
Turn a phone into a barcode scanner for any web app. Your page shows a QR code, the Rangorithm app scans it, and every barcode the phone reads appears in your UI over Server-Sent Events.
npm install @rangorithm/scan-linkHow it works
- Your browser calls
POST /sessionson your own server and opens an SSE stream. - Your page renders a QR containing the pair code and your relay URL.
- The phone scans it, confirms your host, and pairs.
- Each scan is a
POSTfrom the phone; your server fans it out to the stream.
The phone never holds a socket open, so a WiFi-to-LTE handover costs nothing. The browser's native EventSource handles reconnect and replay.
Server
You host the relay. Mount the router anywhere; the client's baseUrl just has to match.
import express from "express";
import { createScanServer } from "@rangorithm/scan-link/server";
const app = express();
const scan = createScanServer();
app.use("/api", scan.express());
app.listen(4000);Routes created under the mount point: POST /sessions, POST /sessions/pair, GET /sessions/:id/stream, POST /sessions/:id/scans, POST /sessions/:id/unpair.
Options:
| Option | Default | Notes |
| --- | --- | --- |
| store | new InMemorySessionStore() | Swap for Redis to run multiple instances |
| pairCodeTtlMs | 5 min | How long an unscanned QR stays valid |
| pairedSessionTtlMs | 8 hours | Session lifetime after pairing |
| heartbeatMs | 20 s | :ping interval; keep under your proxy's idle timeout |
| bodyLimit | 32kb | JSON body cap |
Express is a peer dependency and is only needed for scan.express().
React
"use client";
import { useScanSession } from "@rangorithm/scan-link/react";
export function Scanner() {
const scan = useScanSession({ baseUrl: "/api", appName: "Acme WMS" });
if (scan.status !== "paired") {
return <QrCode value={scan.pairQrValue} caption={scan.pairCode} />;
}
return (
<ul>
{scan.scans.map((s) => (
<li key={s.clientScanId}>{s.value}</li>
))}
</ul>
);
}status is creating | waiting | paired | reconnecting | expired. Show reconnecting honestly — never a stale "connected". Scans arrive newest-first, deduplicated by clientScanId, so replay after a reconnect is safe.
The hook also returns deviceName, count, flashId (the newest scan for ~1s), unpair(), startNew(), copyAll(), exportCsv() and toCsv().
baseUrl may be a same-origin path. The QR needs an absolute URL, which is resolved from location.origin; pass publicUrl explicitly if you sit behind a proxy that rewrites the origin.
Without React
import { createScanSession } from "@rangorithm/scan-link";
const session = createScanSession({ baseUrl: "/api" });
session.subscribe(() => render(session.getState()));
await session.start();Relay URL safety
The pairing QR tells the phone where to upload scans, so a forged QR is the main attack surface. normalizeRelayUrl rejects anything that is not https, except on localhost and private LAN ranges for development, and the app shows the host for confirmation before the first request. Keep that confirmation step if you build your own scanner.
Proxies
SSE needs an unbuffered, long-lived response:
Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-cache, no-transform
Connection: keep-alive
X-Accel-Buffering: noDo not gzip the stream route — a compression buffer makes it look hung. If compression middleware is already installed, this router calls res.flush() after every write, which keeps it working. Serve over HTTP/2: on HTTP/1.1 the browser's six-connections-per-origin limit starves other requests while the stream is held open. Set the proxy idle timeout above heartbeatMs.
Running more than one instance
InMemorySessionStore is single-process, which also rules out serverless. Implement SessionStore over Redis pub/sub and pass it as store; nothing else changes.
import type { SessionStore } from "@rangorithm/scan-link/server";