A point-of-sale printer speaks ESC/POS over a serial port: a handful of control bytes for init, cut and opening the cash drawer, plain text in between. std/browser_serial reaches it straight from wasm32-wasi with await, skipping the system's print dialog — the slowest step of a sale, and one that cannot cut paper or open a drawer. Bytes cross as Array<int>, never as a String: a String would cross the shim through TextEncoder/TextDecoder and silently corrupt control bytes that are not valid UTF-8.
// 117 — Web Serial: un ticket ESC/POS directo a una impresora térmica, sin // el diálogo de impresión del sistema // // Target wasm32-wasi (corre en el navegador, o bajo node con // examples/browser/run-node.mjs). Pensado para un punto de venta: la caja // hoy imprime con el diálogo del sistema en cada venta (el paso más lento // del cobro, medido) y no puede cortar el papel ni abrir la gaveta — Web // Serial resuelve las dos cosas, y es el mismo camino que necesita la // integración con una impresora FISCAL (obligatoria para vender a // consumidor final en varios países), que habla por puerto serie con un // protocolo propio de comandos y respuestas. // // Construir: nyx build --target wasm32-wasi // (necesita binaryen: `sudo apt install binaryen`, o NYX_WASM_OPT=/ruta/a/wasm-opt; // nyx build lo usa SOLO porque el programa espera al anfitrión) // // Lo que conviene saber: // - Los bytes de ESC/POS cruzan como Array<int> (0-255), NO como String — // un String cruza el shim por TextEncoder/TextDecoder (UTF-8) y // corrompería en silencio los bytes de control que no son texto válido. // Ver std/browser_serial.nx para el porqué completo. // - serial_request_port() muestra el diálogo de permiso del navegador la // PRIMERA vez; serial_authorized_ports() reconecta sin diálogo en // visitas siguientes (llamar esa primero y, si viene vacío, recién ahí // pedir uno nuevo — evita mostrar el diálogo en cada carga de página). // - Sin Web Serial (Firefox, Safari, o la página sin HTTPS/localhost), // serial_request_port() es Err(kind: "io") — la señal para volver al // diálogo de impresión del sistema en vez de romper la venta. // - serial_read(p, max, timeout_ms) puede volver con MENOS de `max` bytes // si el plazo se cumple habiendo llegado al menos uno: una respuesta // corta de la impresora fiscal sigue siendo válida, no es un error. import "std/browser_serial" import "std/error" const ESC: int = 27 const GS: int = 29 // Arma los bytes de un ticket mínimo: init, una línea de texto, corte de // papel y apertura de gaveta — los cuatro comandos que pidió el reporte de // nyxerp. fn armar_ticket(texto: String) -> Array<int> { var bytes: Array<int> = [] // ESC @ — init: resetea la impresora al estado por defecto bytes.push(ESC) bytes.push(64) // el texto del ticket, byte a byte (String = bytes en Nyx — ver // LLM.md §5 — así que esto ya viaja como los bytes ASCII/Latin-1 que // espera la impresora, sin decodificar) var i: int = 0 while i < texto.length() { bytes.push(texto.charAt(i)) i = i + 1 } bytes.push(10) // salto de línea final // GS V 66 0 — corte parcial de papel (modo B de la mayoría de // impresoras EPSON-compatibles) bytes.push(GS) bytes.push(86) bytes.push(66) bytes.push(0) // ESC p 0 25 250 — abre la gaveta de dinero conectada a la impresora // (pin 2, pulso de 25×2ms encendido / 250×2ms apagado — valores típicos) bytes.push(ESC) bytes.push(112) bytes.push(0) bytes.push(25) bytes.push(250) return bytes } // Conecta (reusando un puerto ya autorizado si lo hay), imprime el ticket y // cierra. Devuelve cuántos bytes se escribieron, o el kind de Error si algo // falló — el llamador decide con eso si reintenta o cae a la impresión del // sistema. async fn imprimir_ticket(texto: String) -> Result<int, Error> { // primero los ya autorizados (sin diálogo); si no hay ninguno, recién // ahí se pide uno nuevo (con diálogo) var puerto_id: int = -1 let autorizados = await serial_authorized_ports() match autorizados { Result.Ok(ids) => { let lista: Array<int> = ids if lista.length() > 0 { puerto_id = lista[0] } } Result.Err(_e) => { } } var p: SerialPort = SerialPort { id: 0 } if puerto_id >= 0 { p = SerialPort { id: puerto_id } } else { let pedido = await serial_request_port() match pedido { Result.Ok(puerto) => { p = puerto } Result.Err(e) => { return Result.Err(e) } } } let abierto = await serial_open(p, 9600) match abierto { Result.Ok(_) => { } Result.Err(e) => { return Result.Err(e) } } let ticket: Array<int> = armar_ticket(texto) let escrito = await serial_write(p, ticket) let _c = await serial_close(p) return escrito } fn main() { let r = await imprimir_ticket("Gracias por su compra") match r { Result.Ok(n) => { println("ticket impreso: " + int_to_string(n) + " bytes") } Result.Err(e) => { // "io" = Web Serial no disponible: acá es donde un POS real // caería a window.print() con el ticket como HTML de 80mm. println("no se pudo imprimir por serial (" + e.kind + "): volver a impresión del sistema") } } }
ticket impreso: 33 bytes
How it works
armar_ticket builds four ESC/POS commands by hand: ESC @ resets the printer, the ticket text follows byte for byte (Nyx strings are already bytes — see the gotcha in LLM.md §5), then GS V 66 0 cuts the paper and ESC p 0 25 250 pulses the cash-drawer pin. Nothing here is text encoding: it is the exact byte sequence the printer's firmware expects.
serial_authorized_ports() is tried FIRST, so a returning customer reconnects with no permission dialog; serial_request_port(), which does show one, is the fallback for a first visit. serial_read(p, max, timeout_ms) — not used for a print-only ticket, but needed for a fiscal printer's replies — can return fewer than max bytes once the deadline hits and at least one arrived: a short reply from the printer is still valid, not an error.
Without Web Serial — Firefox, Safari, or a page served without HTTPS/localhost — every call in the chain returns Err(kind: "io"), which is exactly the signal main uses to fall back to the system's print dialog instead of losing the sale: "no se pudo imprimir por serial (io): volver a impresión del sistema". WebUSB, for printers that only expose that interface, is left for a later phase.