Printer setup in your POS¶
This guide adds a Printers page to your POS settings: the shop connects the POS to the bridge, picks which printer gets receipts, kitchen tickets and invoices, and prints a test page. No code changes are needed when the shop adds or swaps printers.
Using React?
The SDK has this page ready-made: <PrinterSettings /> and a header
<PrinterStatusBadge /> from pos-hardware-bridge/react. See
React printer widget. This guide explains how it works,
for other frameworks or your own UI.
It comes as two files you can copy into your app:
| File | What it is |
|---|---|
printing.ts |
The printing module: saved settings, connect, pair, print, live status. No UI, works with any framework. |
PrinterSettings.tsx |
A React settings page built on it. Plain HTML, so restyle it with your own components. |
What the shop sees¶
Printers
┌─ 1. Connect to POS Hardware Bridge ───────────────────────────┐
│ (•) Printers are connected to this computer │
│ ( ) Printers are connected to another computer in the shop │
│ Access token [••••••••••••••••] [Connect] │
│ Connected to Front desk PC. │
└────────────────────────────────────────────────────────────────┘
┌─ 2. Choose printers ───────────────────────────────────────────┐
│ Customer receipts [Default printer ▾] [Test print] │
│ Kitchen tickets [Kitchen (not ready) ▾] [Test print] │
│ A4 invoices [Don't print ▾] [Test print] │
└────────────────────────────────────────────────────────────────┘
┌─ 3. Options ───────────────────────────────────────────────────┐
│ [x] Print the receipt automatically after each sale │
│ Copies [1] │
└────────────────────────────────────────────────────────────────┘
flowchart LR
A["Open Settings → Printers"] --> B{"Printers on<br/>this computer?"}
B -- Yes --> C["Paste the access token"]
B -- No --> D["Type the bridge address<br/>and the 6-digit code"]
C --> E["testConnection()"]
D --> F["pairThisDevice()"] --> E
E --> G["Choose a printer per role"] --> H["Test print"]
What gets saved¶
The settings belong to the device, not the shop account: every computer
has its own token, and a till and a tablet may use different printers. The
module saves them in localStorage; use your app's own storage instead if you
have one (Electron store, secure storage on mobile). Never put the token in
your code or your database.
interface PrintSettings {
url: string; // http://127.0.0.1:9191, or the shop address on a tablet
token: string; // access token or device token
printers: Record<'receipt' | 'kitchen' | 'invoice', string | null>;
// API name per role: '' = default printer, null = don't print
autoPrint: boolean;
copies: number; // 1–5
}
Saving '' (the default printer) instead of a fixed name means the shop can
swap printers in the bridge without touching the POS.
Connecting¶
There are two cases, and the page offers both:
- The printers are on this computer. The shop opens the bridge,
Connect your POS, copies the access token and pastes it in. The address
is always
http://127.0.0.1:9191. - The printers are on another computer (a tablet or second till). On the
printer computer, they turn on Phones & tablets and click Get a code.
The POS calls
pairThisDevice(address, code, name)and saves the device token it gets back. See Phones & tablets.
testConnection() then checks three things in order, so the error says what
is wrong:
GET /health: is the bridge running at that address?GET /me: is the token accepted?GET /printers: which printers can this POS choose from?
Errors are BridgeErrors whose message is written for the shop, for example
"POS Hardware Bridge is not running on this computer. Open it from the Start
menu or the tray." Show it as it is.
Web POS on https://
A hosted web POS can reach http://127.0.0.1 from Chrome and Edge, but not
a http://192.168.… address on another computer. For that case, use the
print station instead of pairing.
Printing from the POS¶
Call printFor(role, receipt) wherever the POS prints. It reads the saved
settings, sends the receipt to that role's printer and never throws, so a
printer problem can't break a sale:
import { ReceiptBuilder } from 'pos-hardware-bridge';
import { loadSettings, printFor } from './printing';
async function onSaleCompleted(sale: Sale) {
await saveSale(sale); // 1. the sale is safe first
if (!loadSettings().autoPrint) return;
const result = await printFor('receipt', buildReceipt(sale), { title: `Bill #${sale.number}` });
if (!result.ok && !result.skipped) {
showToast(result.message, {
// canRetry is false when the bridge didn't answer: the receipt may
// already have printed, so ask the cashier before printing again.
action: { label: 'Reprint', confirm: !result.canRetry, run: () => printFor('receipt', buildReceipt(sale)) },
});
}
await printFor('kitchen', buildKitchenTicket(sale)); // skipped if set to "Don't print"
}
function buildReceipt(sale: Sale) {
const r = new ReceiptBuilder().center('MY SHOP', { bold: true, size: 'large' }).rule();
for (const line of sale.lines) r.item({ name: line.name, qty: line.qty, total: line.total });
return r.rule('double').total('TOTAL', sale.total);
}
result is one of:
| Result | Meaning | Show |
|---|---|---|
{ ok: true, printer } |
Printed | Nothing, or a short "Printed" |
{ ok: false, skipped: true } |
Not set up, or this role is Don't print | Nothing |
{ ok: false, message, canRetry } |
Didn't print | message and a Reprint button |
A4 invoices go through printInvoice(html) the same way. See
Receipts, line by line for everything ReceiptBuilder can do.
Printer status in the POS header¶
watchPrinterStatus() gives a traffic light and one line of text, so the
cashier sees an unplugged printer before a customer is waiting:
import { useEffect, useState } from 'react';
import { type PrinterStatusSummary, watchPrinterStatus } from './printing';
const COLORS = { ok: 'green', warning: 'orange', error: 'red', off: 'grey' };
export function PrinterStatusBadge() {
const [status, setStatus] = useState<PrinterStatusSummary>({ light: 'off', text: 'Checking printers…' });
useEffect(() => watchPrinterStatus(setStatus), []);
return (
<span title={status.text}>
<span style={{ color: COLORS[status.light] }}>●</span> {status.text}
</span>
);
}
It listens to the bridge's live events and also checks every 10 seconds, so it recovers by itself when the bridge restarts.
The code¶
// printing.ts: one place for everything your POS knows about printing.
import { BridgeClient, BridgeError, ReceiptBuilder } from 'pos-hardware-bridge';
import type { PrinterInfo, ReceiptDocument } from 'pos-hardware-bridge';
/** What each kind of print is for. Add your own roles here. */
export type PrintRole = 'receipt' | 'kitchen' | 'invoice';
export interface PrintSettings {
/** http://127.0.0.1:9191 on the printer computer, the shop address on a tablet. */
url: string;
/** Access token (printer computer) or device token (from pairing). */
token: string;
/** Printer API name per role. '' = the bridge's default; null = don't print. */
printers: Record<PrintRole, string | null>;
/** Print the customer receipt as soon as a sale is saved. */
autoPrint: boolean;
copies: number;
}
const STORAGE_KEY = 'pos.printing';
export const DEFAULT_SETTINGS: PrintSettings = {
url: 'http://127.0.0.1:9191',
token: '',
printers: { receipt: '', kitchen: null, invoice: '' },
autoPrint: true,
copies: 1,
};
// ---- Saved settings (per device, never in your code or database) ----
export function loadSettings(): PrintSettings {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? 'null');
return saved
? { ...DEFAULT_SETTINGS, ...saved, printers: { ...DEFAULT_SETTINGS.printers, ...saved.printers } }
: DEFAULT_SETTINGS;
} catch {
return DEFAULT_SETTINGS;
}
}
export function saveSettings(settings: PrintSettings): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
}
export function isConfigured(settings = loadSettings()): boolean {
return settings.token !== '';
}
function clientFor(settings: PrintSettings): BridgeClient {
return new BridgeClient({ url: settings.url, token: settings.token });
}
// ---- Connecting ----
export interface Connection {
bridgeName: string;
version: string;
/** 'local' = this is the printer computer; 'device' = a paired tablet or till. */
kind: 'local' | 'device';
printers: PrinterInfo[];
}
/**
* Check the address and token work, and load the printer list.
* Throws BridgeError with a message you can show as it is.
*/
export async function testConnection(url: string, token: string): Promise<Connection> {
const bridge = new BridgeClient({ url, token, timeoutMs: 5000 });
const health = await bridge.health(); // no token: is it running?
const me = await bridge.me(); // token: is it accepted?
const printers = await bridge.printers();
return {
bridgeName: health.bridge?.name ?? 'POS Hardware Bridge',
version: health.bridge?.version ?? '',
kind: me.kind,
printers,
};
}
/** A tablet or till: trade the 6-digit code for this device's own token. */
export async function pairThisDevice(url: string, code: string, deviceName: string) {
const paired = await BridgeClient.pair({ url, code, name: deviceName });
return { url: paired.url, token: paired.token };
}
// ---- Printing ----
export type PrintOutcome =
| { ok: true; printer: string }
| { ok: false; skipped: true }
| { ok: false; skipped?: false; message: string; canRetry: boolean };
/**
* Print to the printer chosen for this role. Never throws: a printer problem
* must not break a sale. Show `message` and a Reprint button when !ok.
*/
export async function printFor(
role: PrintRole,
receipt: ReceiptBuilder | ReceiptDocument,
options: { title?: string; copies?: number } = {},
): Promise<PrintOutcome> {
const settings = loadSettings();
const printer = settings.printers[role];
if (!isConfigured(settings) || printer === null) return { ok: false, skipped: true };
try {
const result = await clientFor(settings).printReceipt(receipt, {
printer: printer || undefined,
copies: options.copies ?? settings.copies,
title: options.title,
});
return { ok: true, printer: result.printer };
} catch (error) {
if (error instanceof BridgeError) {
// 0 = no answer: it may or may not have printed, so ask before reprinting.
return { ok: false, message: error.message, canRetry: error.status !== 0 };
}
return { ok: false, message: 'Printing failed.', canRetry: true };
}
}
/** A4 invoice from HTML, to the printer chosen for 'invoice'. */
export async function printInvoice(html: string, title?: string): Promise<PrintOutcome> {
const settings = loadSettings();
const printer = settings.printers.invoice;
if (!isConfigured(settings) || printer === null) return { ok: false, skipped: true };
try {
const result = await clientFor(settings).printDocument({ html }, { printer: printer || undefined, title });
return { ok: true, printer: result.printer };
} catch (error) {
const message = error instanceof BridgeError ? error.message : 'Printing failed.';
return { ok: false, message, canRetry: !(error instanceof BridgeError && error.status === 0) };
}
}
export async function printTestPage(role: PrintRole): Promise<PrintOutcome> {
const settings = loadSettings();
const printer = settings.printers[role];
if (printer === null) return { ok: false, skipped: true };
try {
const result = await clientFor(settings).printTestPage(printer || undefined);
return { ok: true, printer: result.printer };
} catch (error) {
return { ok: false, message: error instanceof Error ? error.message : 'Printing failed.', canRetry: true };
}
}
// ---- Live status for the POS header ----
export type PrinterLight = 'ok' | 'warning' | 'error' | 'off';
export interface PrinterStatusSummary {
light: PrinterLight;
/** One line for the cashier, e.g. "Front counter: out of paper". */
text: string;
}
function summarize(status: { printers: Array<{ level: PrinterLight; title: string; name: string }> }): PrinterStatusSummary {
const worst =
status.printers.find((p) => p.level === 'error') ??
status.printers.find((p) => p.level === 'warning') ??
status.printers.find((p) => p.level === 'off');
if (status.printers.length === 0) return { light: 'warning', text: 'No printers set up in the bridge' };
return worst ? { light: worst.level, text: `${worst.name}: ${worst.title}` } : { light: 'ok', text: 'Printers ready' };
}
/**
* Calls `onChange` now and whenever a printer's status changes. Also checks
* every 10 s, so it recovers when the bridge restarts. Returns a function
* that stops watching.
*/
export function watchPrinterStatus(onChange: (summary: PrinterStatusSummary) => void): () => void {
const settings = loadSettings();
if (!isConfigured(settings)) {
onChange({ light: 'off', text: 'Printing not set up' });
return () => {};
}
const bridge = clientFor(settings);
let stopped = false;
let stopListening = () => {};
let retry: ReturnType<typeof setTimeout> | undefined;
const connect = async () => {
try {
onChange(summarize(await bridge.status()));
stopListening = bridge.onStatus((payload) => onChange(summarize(payload as any)));
} catch (error) {
onChange({ light: 'error', text: error instanceof BridgeError ? error.message : 'Bridge not reachable' });
}
};
// Poll every 10 s as a safety net: covers a dropped socket or a bridge restart.
const poll = async () => {
if (stopped) return;
try {
onChange(summarize(await bridge.status()));
} catch (error) {
onChange({ light: 'error', text: error instanceof BridgeError ? error.message : 'Bridge not reachable' });
}
retry = setTimeout(poll, 10_000);
};
void connect().then(() => (retry = setTimeout(poll, 10_000)));
return () => {
stopped = true;
clearTimeout(retry);
stopListening();
};
}
// PrinterSettings.tsx: the "Printers" page in your POS settings.
import { useEffect, useState } from 'react';
import type { PrinterInfo } from 'pos-hardware-bridge';
import {
type Connection,
type PrintRole,
type PrintSettings,
loadSettings,
pairThisDevice,
printTestPage,
saveSettings,
testConnection,
} from './printing';
const ROLES: Array<{ role: PrintRole; label: string; kind: PrinterInfo['kind'] }> = [
{ role: 'receipt', label: 'Customer receipts', kind: 'receipt' },
{ role: 'kitchen', label: 'Kitchen tickets', kind: 'receipt' },
{ role: 'invoice', label: 'A4 invoices', kind: 'document' },
];
export function PrinterSettings() {
const [settings, setSettings] = useState<PrintSettings>(loadSettings);
const [connection, setConnection] = useState<Connection | null>(null);
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
const [mode, setMode] = useState<'this-computer' | 'pair'>(
settings.url.includes('127.0.0.1') || settings.url.includes('localhost') ? 'this-computer' : 'pair',
);
const [code, setCode] = useState('');
const [deviceName, setDeviceName] = useState('');
const update = (next: PrintSettings) => {
setSettings(next);
saveSettings(next);
};
const connect = async (url: string, token: string) => {
setBusy(true);
setMessage('');
try {
const result = await testConnection(url, token);
setConnection(result);
update({ ...settings, url, token });
setMessage(`Connected to ${result.bridgeName}.`);
} catch (error) {
setConnection(null);
setMessage(error instanceof Error ? error.message : String(error));
} finally {
setBusy(false);
}
};
const pair = async () => {
setBusy(true);
setMessage('');
try {
const { url, token } = await pairThisDevice(settings.url, code, deviceName || 'POS device');
setCode('');
await connect(url, token);
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
setBusy(false);
}
};
// Already set up: load the printer list when the page opens.
useEffect(() => {
if (settings.token) void connect(settings.url, settings.token);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<section>
<h2>Printers</h2>
{/* Step 1: connect */}
<fieldset disabled={busy}>
<legend>1. Connect to POS Hardware Bridge</legend>
<label>
<input type="radio" checked={mode === 'this-computer'} onChange={() => setMode('this-computer')} />
Printers are connected to this computer
</label>
<label>
<input
type="radio"
checked={mode === 'pair'}
onChange={() => {
setMode('pair');
setSettings({ ...settings, url: '' }); // the shop address, typed below
}}
/>
Printers are connected to another computer in the shop
</label>
{mode === 'this-computer' ? (
<>
<p>In the bridge, open <b>Connect your POS</b> and copy the access token.</p>
<input
type="password"
placeholder="Access token"
value={settings.token}
onChange={(e) => setSettings({ ...settings, url: 'http://127.0.0.1:9191', token: e.target.value.trim() })}
/>
<button onClick={() => connect('http://127.0.0.1:9191', settings.token)}>Connect</button>
</>
) : (
<>
<p>
On the printer computer, open <b>Connect your POS → Phones & tablets</b>, turn on network
access and click <b>Get a code</b>.
</p>
<input
placeholder="Bridge address, e.g. http://192.168.1.20:9191"
value={settings.url}
onChange={(e) => setSettings({ ...settings, url: e.target.value.trim() })}
/>
<input placeholder="6-digit code" inputMode="numeric" value={code} onChange={(e) => setCode(e.target.value)} />
<input placeholder="Name for this device, e.g. Till 2" value={deviceName} onChange={(e) => setDeviceName(e.target.value)} />
<button onClick={pair} disabled={!code}>Pair</button>
</>
)}
{message && <p role="status">{message}</p>}
</fieldset>
{/* Step 2: choose printers */}
{connection && (
<fieldset>
<legend>2. Choose printers</legend>
{ROLES.map(({ role, label, kind }) => (
<div key={role}>
<label>
{label}{' '}
<select
value={settings.printers[role] ?? 'none'}
onChange={(e) =>
update({
...settings,
printers: { ...settings.printers, [role]: e.target.value === 'none' ? null : e.target.value },
})
}
>
<option value="">Default printer</option>
{connection.printers
.filter((p) => p.kind === kind)
.map((p) => (
<option key={p.id} value={p.id}>
{p.name} {p.ready ? '' : '(not ready)'}
</option>
))}
<option value="none">Don't print</option>
</select>
</label>
<button
disabled={settings.printers[role] === null}
onClick={async () => {
const result = await printTestPage(role);
setMessage(result.ok ? `Test page sent to ${result.printer}.` : 'message' in result ? result.message : '');
}}
>
Test print
</button>
</div>
))}
{connection.printers.length === 0 && <p>No printers yet: add one in the bridge, then reopen this page.</p>}
</fieldset>
)}
{/* Step 3: options */}
{connection && (
<fieldset>
<legend>3. Options</legend>
<label>
<input
type="checkbox"
checked={settings.autoPrint}
onChange={(e) => update({ ...settings, autoPrint: e.target.checked })}
/>
Print the receipt automatically after each sale
</label>
<label>
Copies{' '}
<input
type="number"
min={1}
max={5}
value={settings.copies}
onChange={(e) => update({ ...settings, copies: Math.min(5, Math.max(1, Number(e.target.value) || 1)) })}
/>
</label>
</fieldset>
)}
</section>
);
}
Other frameworks¶
printing.ts has no UI code. For Vue, Svelte, Angular or plain JavaScript,
keep the module and build the same three steps with your own components:
- Connect: a token field (this computer) or address + code + device
name (another computer) →
testConnection()/pairThisDevice(). - Choose printers: one dropdown per role, filled from
connection.printers(receipt printers for receipts and kitchen, A4 printers for invoices), plus Default printer and Don't print. Save withsaveSettings(). - Options and a Test print button per role →
printTestPage(role).
Not using JavaScript? The module is a thin layer over the HTTP API: the same calls work from any language.
Check before you ship¶
- [ ] Wrong token → the page says the token wasn't accepted.
- [ ] Bridge closed → the page says it isn't running.
- [ ] Printer unplugged → the header badge turns red within a few seconds, and a sale still completes with a Reprint option.
- [ ] Don't print on kitchen tickets → no request is sent.
- [ ] Printers renamed or swapped in the bridge → Default printer still works.
- [ ] A tablet pairs with a code, and printing still works after the app restarts.