github.com/GomdimApps/web-escpos-printer
Born out of React Native/SPA/Laravel projects fighting printer-specific setup for every 58mm/80mm thermal printer — idea by tech lead Sávio Godinho.
A communication wrapper for thermal receipt printers, over Web Bluetooth, Web Serial (USB cable, virtual COM port — reliable cross-platform default), WebUSB (USB cable, direct — works only when nothing else has claimed the device), or via the QZ Tray desktop app (any OS-registered printer). Builds receipts (text, images, barcodes, QR codes, PDF417) from a JSON-serializable object — no ESC/POS knowledge needed. Entirely in the browser, no Node at runtime.
Left: real receipt off a Bluetooth thermal printer. Right: the same job
rendered by renderPreview() in the browser.
<script src="https://cdn.jsdelivr.net/npm/web-escpos-printer/build/web-escpos-printer.js"></script>
<script>
const printer = new WebEscposPrinter()
connectButton.onclick = () => printer.connect() // must be a real user click
printButton.onclick = () =>
printer.printReceipt({
content: [
{ type: 'text', value: 'Hello world', align: 'center', bold: true },
{ type: 'qrcode', value: 'https://example.com' },
],
cut: 'full',
})
</script>Pin a version (@x.y.z) for production — an unpinned URL always resolves
to latest, which can break you without warning. See
demo/index.html for a full example, and
docker compose up (see docker-compose.yml) to run
it at http://localhost:3000/.
npm install web-escpos-printerimport WebEscposPrinter from 'web-escpos-printer'
const printer = new WebEscposPrinter()
async function onConnectClick() {
const info = await printer.connect() // must be called from a click handler
}
async function onPrintClick() {
await printer.printReceipt({
content: [
{ type: 'image', source: logoDataUrl }, // base64, File, Blob, URL or HTMLImageElement
{ type: 'text', value: 'Test receipt' },
{ type: 'barcode', value: '123456789012', symbology: 'code128' },
],
})
}require('web-escpos-printer') resolves to the same self-contained UMD
bundle as standalone above; import gets the ESM build with
@point-of-sale/receipt-printer-encoder/qz-tray as externals instead.
TypeScript declarations ship in build/types.
class WebEscposPrinter {
static isSupported(): boolean // Web Bluetooth support
static isQzSupported(): boolean // WebSocket support (not whether QZ Tray is running)
static isSerialSupported(): boolean // Web Serial support
static isUsbSupported(): boolean // WebUSB support
constructor(config?: WebEscposPrinterConfigInput)
onStatusChange(cb: (event: PrinterStatusEvent) => void): () => void
connect(options?:
| { transport?: 'bluetooth'; compat?: boolean; profile?: BluetoothPrinterProfile }
| { transport: 'qz'; printerName?: string }
| { transport: 'serial'; options?: SerialConnectOptions }
| { transport: 'usb'; profile?: UsbPrinterProfile }
): Promise<PrinterInfo>
listQzPrinters(query?: string): Promise<string[]>
reconnectSerial(previous: SerialPortIdentity, options?: SerialConnectOptions): Promise<PrinterInfo | null>
reconnectUsb(previous: UsbDeviceIdentity, profile?: UsbPrinterProfile): Promise<PrinterInfo | null>
disconnect(): Promise<void>
isConnected(): boolean
getPrinterInfo(): PrinterInfo | null
printReceipt(job: PrintJob): Promise<void>
printRaw(bytes: Uint8Array | number[]): Promise<void>
renderPreview(job: PrintJob): Promise<PrintPreview> // no printer/connection needed
static renderPreview(job: PrintJob, config?: WebEscposPrinterConfigInput): Promise<PrintPreview>
}PrintJob.content elements: text, image, barcode, qrcode,
pdf417, newline, rule — see src/types.ts for full
shapes. Errors reject with a .code: unsupported | user-gesture-required | connect-cancelled | connect-failed | not-connected | busy | print-failed.
safeMode: true renders an element via a safer fallback instead of its
native ESC/POS command, for printers whose firmware mishandles the native
one:
{ type: 'pdf417', value: '...', safeMode: true } // raster image
{ type: 'qrcode', value: '...', safeMode: true } // raster image
{ type: 'rule', safeMode: true } // plain ASCII '-' lineOff by default. See docs/notes/09 / 10 for the confirmed clone-printer cases behind this.
await printer.connect() // Bluetooth, restricted to known profiles — must be a real user click/tap
await printer.connect({ compat: true }) // printer not showing up? broader picker, matches after connectingPrinter not in the built-in table (or matching the wrong one)? Pass your own, no fork/rebuild needed:
import type { BluetoothPrinterProfile } from 'web-escpos-printer'
const myProfile: BluetoothPrinterProfile = {
filters: [{ services: ['000018f0-0000-1000-8000-00805f9b34fb'] }],
service: '000018f0-0000-1000-8000-00805f9b34fb',
characteristic: '00002af1-0000-1000-8000-00805f9b34fb',
language: 'esc-pos', // 'esc-pos' | 'star-prnt' | 'star-line'
codepageMapping: 'default', // forwarded as-is to ReceiptPrinterEncoder
// messageSize/sleepAfterCommand: optional BLE write pacing for printers that drop data
}
await printer.connect({ profile: myProfile }) // combine with { compat: true } tooFind service/characteristic with a BLE scanner app (nRF Connect,
LightBlue) against the printer — most clones use a vendor-specific
service.
Printing images over Bluetooth on an MTP-II/MP58C7-family clone (also
sold as HPRT HM-A200U, PixPos MP58C7, ...)? Confirmed unreliable
regardless of imageMode/chunk size/pacing — use transport: 'serial'
or 'usb' for image content on that hardware instead. See
docs/notes/13.
Reliable across Windows/Linux/macOS, no extra software. Chrome/Edge desktop only.
const info = await printer.connect({ transport: 'serial' }) // shows the native port picker
await printer.connect({ transport: 'serial', options: { baudRate: 19200 } }) // default: 9600 8N1, no flow control
// skip the picker later — reconnect silently:
const info2 = await printer.reconnectSerial({ usbVendorId: 0x0483, usbProductId: 0x5740 })
if (!info2) await printer.connect({ transport: 'serial' })A vendor's own "virtual COM port" tool (e.g. Epson's TM Virtual Port Assignment Tool) may not appear in the picker at all — not a filter bug, see docs/notes/12; use QZ Tray for those.
Cleanest transport where it works, but not reliably available on any OS without freeing the device from another driver first — see docs/notes/11. Prefer Web Serial by default.
const info = await printer.connect({ transport: 'usb' }) // picker restricted to a known vendor/product-id table
// unlisted printer — same escape hatch as BluetoothPrinterProfile:
import type { UsbPrinterProfile } from 'web-escpos-printer'
const myProfile: UsbPrinterProfile = {
filters: [{ vendorId: 0x0483, productId: 0x5743 }],
configuration: 1,
interface: 0,
language: 'esc-pos',
codepageMapping: 'default',
}
await printer.connect({ transport: 'usb', profile: myProfile })reconnectUsb({ serialNumber, vendorId, productId }, profile?) mirrors
reconnectSerial() for silent reconnect.
const printerNames = await printer.listQzPrinters() // opens the QZ Tray session if needed
const info = await printer.connect({ transport: 'qz', printerName: printerNames[0] })Requires the QZ Tray desktop app installed and the printer paired there. Shows its own permission popup per connect/print unless you configure its certificate/signature plumbing yourself. Windows caveat: docs/notes/08.
const printer = new WebEscposPrinter({
paperWidth: '80mm', // '58mm' | '80mm' | '112mm' — shorthand for columns + image/barcode width ceiling
language: 'star-prnt', // 'esc-pos' | 'star-prnt' | 'star-line', default 'esc-pos'
codepageMapping: 'xprinter', // for non-standard clone printers
printerModel: 'epson-tm-t88vi',// lets ReceiptPrinterEncoder auto-configure known-model defaults
imageMode: 'raster', // 'column' (default) | 'raster' — force GS v 0 for clone printers whose firmware mishandles the legacy ESC * band format
feedBeforeCut: 4, // blank lines fed before the cut, default 4
})
await printer.printReceipt({ paperWidth: '58mm', content: [...] }) // per-job overrides the constructorSee docs/notes/02
/ 04 for why
paperWidth/feedBeforeCut matter.
npm install
npm run build # UMD + ESM + .d.ts (what gets published to npm)
npm run build:standalone # only build/web-escpos-printer.js
npm run build:dev # same as build, in watch mode
npm test # Vitest suite against the real encoder — see AGENTS.md's "Testing" sectionMIT. The standalone UMD bundle statically includes
qz-tray, licensed
LGPL-2.1 (everything else bundled is MIT) — check LGPL-2.1's
compliance requirements if you redistribute that bundle.
