Scanner Fleet Developer guide

TSE Scanner Client developer guide

@tse/scanner-client lets a web page scan documents through the TSE Scanner Agent, the Windows tray app running on the employee's PC. Your page talks to the agent on http://127.0.0.1:47391; your backend only receives the files your page uploads.

The page talks to the agent on the employee's PC; only your upload reaches your backend

The agent accepts only origins on its allowlist and browsers the employee paired by clicking Allow. Your backend never calls the agent.

Setup

Five steps, about 20 minutes plus IT's install time. No token is needed: the package registry allows anonymous pulls.

  1. Point npm at the registry. Add one line to your project's .npmrc:

    @tse:registry=https://gitlab.com/api/v4/projects/87828624/packages/npm/
    
  2. Install the package, pinned to an exact version:

    npm install --save-exact @tse/scanner-client@1.1.2
    
  3. Add the agent URL to your runtime config (public/config.js), so a site can change the port without a rebuild:

    window.SCANNER_AGENT_URL = "http://127.0.0.1:47391";
    

    Type it in src/types/global.d.ts: SCANNER_AGENT_URL?: string inside interface Window.

  4. Send IT the exact origin of every environment that will scan: scheme + host + port, no path (for example https://staging.portal.example.com). IT installs the agent with that list. An origin missing from it fails with 403 origin_not_allowed, or with "Failed to fetch" when the browser blocks the preflight.

  5. Serve the page over https. Chrome and Edge only let a secure page call 127.0.0.1. http://localhost works for development. An http test site needs chrome://flags/#unsafely-treat-insecure-origin-as-secure (Edge: edge://flags) with its origin on each tester's browser.

The package is plain ES modules: it loads in any bundler and in Node (useful for unit tests); a browser without a bundler needs an import map for @tse/scanner-client. Types resolve on TypeScript 5 and on TypeScript 4.x with "moduleResolution": "node". React 18 or later is an optional peer dependency, needed only for @tse/scanner-client/react.

Create the client once per app, not per component:

import { ScannerAgentClient } from "@tse/scanner-client";

let client: ScannerAgentClient | null = null;

export const getScannerClient = () => {
  client ??= new ScannerAgentClient({ baseUrl: window.SCANNER_AGENT_URL ?? "http://127.0.0.1:47391" });
  return client;
};

React quick start

useScanSession runs the whole flow: connect, pair, one scan per side, rotate, cancel. ScanReviewPanel is a ready-made UI on top of it. This example scans an ID card front and back and uploads one stacked JPEG.

import { composeVertical, type ScanRequest } from "@tse/scanner-client";
import { ScanReviewPanel, useScanSession, type SideSpec } from "@tse/scanner-client/react";

import { getScannerClient } from "./scannerClient";
import { scanLabels } from "./scanLabels";

// Module-level constants: the hook re-creates its callbacks when these change identity.
const SIDES: SideSpec[] = [
  { key: "front", label: "Front" },
  { key: "back", label: "Back" },
];
const SCAN_REQUEST: ScanRequest = { source: "flatbed", dpi: 300, colorMode: "color", format: "jpeg", autoCrop: true };

export const IdCardScan = ({ onUploaded, onCancel }: { onUploaded: () => void; onCancel: () => void }) => {
  const session = useScanSession({ client: getScannerClient(), sides: SIDES, scanRequest: SCAN_REQUEST });

  const handleConfirm = async (images: Blob[]) => {
    const composite = await composeVertical(images, { type: "image/jpeg", quality: 0.92 });
    const form = new FormData();
    form.append("File", new File([composite], "id-card.jpg", { type: "image/jpeg" }));
    await uploadDocument(form); // your normal authenticated API call
    onUploaded();
  };

  return (
    <ScanReviewPanel session={session} sides={SIDES} labels={scanLabels} onConfirm={handleConfirm} onCancel={onCancel} className="id-card-scan" />
  );
};

What happens on screen, in order:

  1. Checking: the hook calls health() on mount (turn off with autoConnect: false).
  2. Agent unavailable: the panel lists labels.unavailableSteps and a Retry button.
  3. Unpaired: a Pair button. On click the agent opens a Windows dialog with a six-digit code; the panel shows the same code through labels.pairingHint(code). The token is kept in localStorage per origin, so this happens once per browser.
  4. Ready: one card per side with Scan, Rescan and Rotate. Each scan uses maxPages: 1 and your scanRequest.
  5. Confirm: enabled when every side is scanned. onConfirm gets the rotated images in side order; the panel disables itself until your promise settles.

labels is where your translations go. Every field is required:

import type { ScanReviewLabels } from "@tse/scanner-client/react";

export const scanLabels: ScanReviewLabels = {
  scan: "Scan", rescan: "Scan again", rotate: "Rotate", cancelScan: "Stop scanning",
  scanning: "Scanning…", waiting: "Not scanned", done: "Scanned",
  confirm: "Save", cancel: "Cancel", retry: "Retry", pair: "Connect this browser to the scanner",
  pairingHint: (code) => `Check that the dialog on this PC shows ${code}, then click Allow.`,
  unavailableTitle: "The scanner agent is not running on this PC",
  unavailableSteps: ["Open TSE Scanner Agent from the Start menu.", "Then click Retry."],
  connectedTo: (deviceName, version) => `Connected to ${deviceName} (agent ${version})`,
  errorMessage: (error) => messageFor(error.code), // see Errors
};

The panel ships with no CSS. Style its BEM classes (tse-scan, tse-scan__side, tse-scan__btn--primary, …) under your own className. To build your own UI instead, use the hook alone: it returns agent, sides, allDone, scan(key), rotate(key, 90), collect() and the rest listed in the API reference.

Plain TypeScript quick start

Without React (Angular, Vue, plain JS), call the client directly. scan() starts the job, polls it and downloads every page as a Blob.

import { ScannerAgentClient, generatePairingCode } from "@tse/scanner-client";

const client = new ScannerAgentClient({ baseUrl: window.SCANNER_AGENT_URL ?? "http://127.0.0.1:47391" });

export async function scanDocument(showCode: (code: string) => void, signal?: AbortSignal): Promise<Blob[]> {
  if (!(await client.isPaired())) {
    const code = generatePairingCode();
    showCode(code); // "Check the code 123-456 in the dialog and click Allow"
    await client.pair(code); // resolves after Allow; rejects with pairing_denied on Deny
  }
  const result = await client.scan(
    { source: "flatbed", dpi: 300, colorMode: "color", format: "jpeg", autoCrop: true },
    { signal, onProgress: (p) => console.debug(p.state, Math.round(p.fraction * 100)) },
  );
  return result.pages.map((page) => page.blob);
}

API reference

Everything below is exported from @tse/scanner-client, except the hook and panel, which come from @tse/scanner-client/react.

ScannerAgentClient options

Option Default Meaning
baseUrl required Agent URL, from runtime config
tokenStorage localStorage, key tse-scanner-agent:token:<baseUrl> Where the pairing token lives; memoryTokenStorage() keeps it in memory only
fetch global fetch Inject a fake for tests
pollIntervalMs 300 Delay between job status polls
requestTimeoutMs 5,000 Health, devices, start, poll
pairingTimeoutMs 90,000 Must outlive the Windows consent dialog
imageTimeoutMs 30,000 One page download

ScannerAgentClient methods

Method Returns Notes
health() AgentHealth status, version, driver, paired, fleet?; no token needed
isAvailable() boolean health() that never throws
isPaired() boolean Agent says paired and this browser holds a token
pair(code?) the code Shows the consent dialog; resolves on Allow and stores the token
hasToken / forgetToken() boolean / void Read or clear the stored token
getDevices() ScannerDevice[] id, name, driver
getCapabilities(id) ScannerCapabilities Flatbed, feeder, duplex, dpis, colorModes
scan(request, { onProgress, signal }) ScanResult Start, poll, download all pages; abort cancels on the agent
startScan / getScan / cancelScan ScanJobSnapshot Low-level job control
fetchPage(jobId, index) ScannedImage One download per page, then the agent deletes it

ScanRequest

Every field is optional; the agent fills in its configured defaults (flatbed, 300 DPI, color, JPEG quality 90).

Field Values
deviceId an id from getDevices()
source flatbed | feeder | duplex
dpi up to 600
colorMode color | gray | blackWhite
format jpeg | png
jpegQuality JPEG quality
maxPages up to 10
autoCrop true cuts each page to the item on the glass and straightens it up to ±45°; a full page or an empty glass comes back uncropped. Omitted = agent setting (off). Needs agent 1.1.0 or later; agent 1.1.2 crops pale cards and cards touching the glass edge better; agent 1.1.3 also crops the back of a national ID, where only the print differs from the lid

useScanSession(options)

Options: client, sides ({ key, label }[]), scanRequest?, autoConnect? (default true). Returns:

Member Meaning
agent checking | unavailable | unpaired | pairing | ready
health, devices, error, pairingCode Last known agent state; pairingCode is set while pairing
sides[key] status (empty | scanning | done | error), image, rotation, progress, error
activeSide, allDone Side being scanned; every side done
connect(), pair() Re-check the agent; start pairing
scan(key), cancel() One scan at a time; a second call while one runs is ignored
rotate(key, degrees), clear(key), reset() Edit the session
collect() Rotated blobs in side order; throws if a side is missing

ScanReviewPanel props

session, sides, labels, onConfirm(images), onCancel(), plus optional busy (disables everything), instructions (shown under the sides) and className.

Image helpers

Function Use
composeVertical(blobs, { type, quality, gap, background }) Stack images top to bottom into one, no scaling. Defaults: gap 24 px, background #ffffff, JPEG quality 0.92
rotateImage(blob, degrees, { type, quality }) Rotate by a multiple of 90°
computeVerticalStack, rotatedSize, normalizeRotation Pure layout math, testable without a canvas
generatePairingCode() Six random digits from crypto.getRandomValues

Errors

Everything throws ScannerAgentError with a code, an HTTP status when the agent answered, and three shortcuts: isUnavailable, needsPairing, isCancelled. Map codes to your own translated text; never show error.message to employees.

Code Cause Show the employee
agent_unavailable Agent not running, or the page is plain http Start TSE Scanner Agent from the Start menu, then Retry
unauthorized Token revoked from the tray (the client already cleared it) Connect again
pairing_denied Employee clicked Deny Connect again and click Allow
pairing_in_progress Another pairing dialog is open Finish the open dialog first
origin_not_allowed Site not in the agent's allowlist Contact IT (Setup step 4)
busy, device_busy Another scan is running Wait and retry
feeder_empty, cover_open, paper_jam Device needs attention Check the scanner, then retry
device_offline, device_not_found USB or power Check the cable and power, then retry
source_not_supported e.g. feeder on a flatbed-only device Use getCapabilities() to offer only what exists
timeout Scan took longer than 90 s Retry
cancelled Your own AbortController Nothing
anything else (driver_error, no_pages, http_<status>) Unexpected A generic "scanning failed, try again"
const MESSAGE_KEYS: Partial<Record<ScannerErrorCode, string>> = {
  agent_unavailable: "scannerErrorUnavailable",
  busy: "scannerErrorBusy",
  device_busy: "scannerErrorBusy",
  // …one entry per row above
};
const messageFor = (code: ScannerErrorCode) => t(MESSAGE_KEYS[code] ?? "scannerErrorGeneric");

Pitfalls

Each of these came up while wiring the national ID scan into the TSE frontend.

Pitfall Symptom Fix
Session mounted with the page The agent is called on page load, and a hidden dialog keeps a live session Render the component that calls useScanSession only while the dialog is open ({isOpen && <Panel/>}); Bootstrap modals keep their children mounted
Unstable sides / scanRequest Callbacks re-created every render, session reset on language change Module-level constants, or useMemo keyed on your translate function
Pairing code in an RTL page An Arabic UI shows 156-535 for code 535-156, so it does not match the Windows dialog Labels are plain strings, so <span dir="ltr"> is not possible. Wrap the code in Unicode isolates: `⁦${code}⁩`. Do the same for the device name and version
Panel has no styles Unstyled buttons; a theme variable that looks right may not be (--color-primary is white in the TSE frontend) Style tse-scan* classes under your className with the same tokens your Button component uses
Errors inside onConfirm An exception becomes an unhandled promise rejection and the employee sees nothing Catch inside onConfirm, show a message, and close the dialog only when the upload succeeded
One file per document type on the backend A second upload replaces the first, so the back overwrites the front Send front and back as one composeVertical image

Testing without a scanner

Three options, from most realistic to fastest:

  1. Fake scanner driver. Install the agent with driver Fake scanner (/DRIVER=fake on a silent install). Pairing, jobs and errors are real; the pages are synthetic.
  2. Test console (web/console in the agent repository; ask IT for its address): live agent checks, pairing and scan-to-upload. Use it to tell an agent problem from a page problem.
  3. Fake fetch in unit tests. The client takes a fetch option, so no agent is needed:
import { ScannerAgentClient, memoryTokenStorage } from "@tse/scanner-client";

const json = (body: unknown, status = 200) =>
  new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });

const client = new ScannerAgentClient({
  baseUrl: "http://agent.test",
  tokenStorage: memoryTokenStorage(),
  pollIntervalMs: 1,
  fetch: async (input) => {
    const path = new URL(String(input)).pathname;
    if (path === "/health") return json({ status: "ok", agent: "test", version: "1.1.2", driver: "fake", paired: false });
    if (path === "/pair") return json({ token: "test-token" });
    return json({ code: "device_offline", message: "offline" }, 503);
  },
});

For a whole flow, answer POST /scan with a job, GET /scan/{id} with state: "completed" and pageCount, and GET /scan/{id}/image/{n} with an image body plus x-scan-width and x-scan-height headers.

Support: the tray icon and the console's Prerequisites tab show the PC's Device ID (SA-XXXXX); IT finds the device in the Fleet dashboard by that ID.

More

The agent repository is private; ask for access to open these links.