A friend runs a lab with custom software that handles sample tracking, inventory management, and reporting. It does nearly everything they need — except print labels. They have Zebra thermal printers on the network and a collection of ZPL templates, but the software has no integration with the printers, so printing labels was cumbersome and time consuming.
I built a web app to close that gap — select a printer, paste or upload ZPL, hit print. The whole thing runs as a static React site on S3 and CloudFront with no backend, because all the actual printer communication happens client-side through Zebra's Browser Print SDK.
Browser Print SDK
Zebra's Browser Print is a local agent that runs on each client machine, listening on localhost:9100 and localhost:9101 to act as a bridge between JavaScript in the browser and Zebra printers on the local network.
graph LR
A[Web App<br/>CloudFront] -->|HTTP| B[Browser Print Agent<br/>localhost:9100]
B -->|TCP 9100| C[Zebra Printer<br/>192.168.1.x]
The SDK itself consists of two minified JavaScript files — there's no npm package, no TypeScript types, and no module system integration. You load them as script tags that have to appear before your application bundle:
<!-- Browser Print SDK files - Must be loaded before the app -->
<script src="/js/BrowserPrint-3.1.250.min.js"></script>
<script src="/js/BrowserPrint-Zebra-1.1.250.min.js"></script>
The first file provides window.BrowserPrint with basic device discovery and communication primitives. The second adds BrowserPrint.Zebra.Printer, a higher-level class that exposes methods like getStatus() and getConfiguration() for working with network-connected Zebra printers specifically. The documentation doesn't make this two-tier structure particularly obvious, so it took some time to understand which capabilities come from which file and when the Zebra namespace is available.
The SSL Certificate Problem
Browser Print's local agent serves HTTPS using a self-signed certificate, which means every user on every browser has to manually navigate to https://localhost:9101/ssl_support and click through the security warning before the SDK can communicate with the agent. There's no way to automate or bypass this step — it's a manual prerequisite that has to happen at least once per browser.
The app detects when Browser Print isn't reachable and shows setup instructions with a direct link to the SSL acceptance page. This is the single biggest friction point for new users, because the app loads fine and everything looks correct, but nothing actually works until that certificate is accepted in the browser's trust store.
Device Discovery
The SDK provides getLocalDevices() to find printers on the network, and the function signature suggests it returns an array of device objects. In practice, the response shape is inconsistent — it can be a real array, an array-like object that Array.from can handle, an object with a printer property containing the array, or an object with numeric index keys.
window.BrowserPrint.getLocalDevices(
(devicesData: BrowserPrint.Device[] | DevicesData) => {
let devices: BrowserPrint.Device[] = [];
if (Array.isArray(devicesData)) {
devices = devicesData;
} else if (devicesData && typeof devicesData === 'object') {
const data = devicesData as DevicesData;
try {
devices = Array.from(
devicesData as unknown as ArrayLike<BrowserPrint.Device>
);
} catch {
devices = [];
}
if (devices.length === 0 && data.printer) {
devices = data.printer;
}
}
// Last resort: check for indexed properties
if (devices.length === 0 && devicesData) {
const indexed = devicesData as DevicesData;
if (indexed[0]) {
devices = [indexed[0]];
}
}
},
(error) => { /* ... */ },
'printer'
);
All four of those branches were discovered through trial and error across different machines and browser configurations. The documentation doesn't describe the response format in enough detail to write this defensively on the first attempt — you find out about the edge cases when discovery silently returns nothing in an environment where you know printers exist.
Configured vs Discovered Printers
Because discovery was unreliable and we knew exactly which printers were in the lab, I hard-coded them in a JSON configuration file that ships with the app:
{
"printers": [
{
"id": "main-lab-printer",
"name": "Main Lab Printer",
"ip": "192.168.1.100",
"port": 9100,
"location": "Lab",
"description": "Main Lab Printer",
"model": "ZD421CN",
"capabilities": ["4x6", "2x1", "thermal"]
}
]
}
Configured printers appear immediately when the app loads, with no discovery delay and no dependency on the SDK's ability to find them on the network. The "Discover" button runs getLocalDevices() and merges any new printers into the list, deduplicating by IP and port so configured printers don't appear twice if discovery also finds them. If the lab adds a new printer later, they can discover it at runtime without redeploying the application.
Two APIs for the Same Thing
The SDK offers two distinct paths for communicating with a printer, and the choice depends on how you obtained the device reference. If you know the printer's IP address, you can construct a Zebra.Printer directly:
if (window.BrowserPrint.Zebra && window.BrowserPrint.Zebra.Printer) {
const printer = new window.BrowserPrint.Zebra.Printer(
`${printerConfig.ip}:${printerConfig.port}`
);
printer.getConfiguration(
() => resolve(printer),
(error) => reject(new Error(`Failed to connect: ${error}`))
);
}
Zebra.Printer exposes getStatus(), getConfiguration(), and isPrinterReady() — methods that return structured data about the printer's state. The generic Device class returned by discovery only has send() and sendThenRead(), which are lower-level methods where you pass raw ZPL commands like ~HQES (host query extended status) or ~HI (host identification) and parse the text response yourself.
Neither class ships with TypeScript types, so I wrote the declarations from scratch by reading the minified source and testing against runtime behavior:
declare global {
interface Window {
BrowserPrint: typeof BrowserPrint;
}
namespace BrowserPrint {
class Device {
uid: string;
connection: string;
name: string;
deviceType: string;
version: number;
send(
data: string,
finishedCallback?: () => void,
errorCallback?: (error: Error) => void
): void;
sendThenRead(
data: string,
callback?: (error: Error | null, response?: string) => void
): void;
}
namespace Zebra {
class Printer extends Device {
constructor(uid: string);
getConfiguration(
callback: (config: any) => void,
errorCallback?: (error: Error) => void
): void;
getStatus(
callback: (status: PrinterStatus) => void,
errorCallback?: (error: Error) => void
): void;
isPrinterReady(
callback: (ready: boolean) => void,
errorCallback?: (error: Error) => void
): void;
}
interface PrinterStatus {
isReadyToPrint: boolean;
isPaused: boolean;
isHeadOpen: boolean;
isPaperOut: boolean;
isRibbonOut: boolean;
labelLengthInDots: number;
numberOfFormatsInReceiveBuffer: number;
isClearingBuffer: boolean;
}
}
}
}
The entire SDK API is callback-based — it predates Promises — so every interaction gets wrapped in new Promise() to make it usable with async/await in the rest of the application.
CSP for localhost
The app runs on CloudFront with a strict Content Security Policy, and Browser Print requires the CSP to allow connections to the local agent on both ports, both protocols, and both address formats:
contentSecurityPolicy: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
`connect-src 'self'
https://localhost:9101 http://localhost:9101
https://localhost:9100 http://localhost:9100
https://127.0.0.1:9101 http://127.0.0.1:9101
https://127.0.0.1:9100 http://127.0.0.1:9100
https://*.amazonaws.com https://*.amazoncognito.com`,
"frame-src 'none'",
"object-src 'none'",
].join('; ')
That's eight localhost entries for one SDK. Both localhost and 127.0.0.1 are necessary because browsers resolve them differently depending on the operating system and network configuration. Both HTTP and HTTPS are required because the agent's behavior varies between environments. Both ports are needed because 9100 handles printer communication while 9101 handles the SDK's own API. The SDK also requires unsafe-eval in the script source directive, which is unfortunate but unavoidable for a vendor-provided minified library. If any one of these entries is missing, the connection fails silently — no error in the console, just a blocked request that only shows up in the browser's Network tab.
Locking It Down with Cognito
The app is entirely static — no backend server, no API — but the lab still needed access control to prevent unauthorized use. I added Cognito authentication with a pre-signup Lambda trigger that validates email domains against a configurable allowlist:
const ALLOWED_DOMAINS = process.env.ALLOWED_DOMAINS?.split(',') || [];
export const handler = async (
event: PreSignUpTriggerEvent,
context: Context,
callback: Callback<PreSignUpTriggerEvent>
): Promise<void> => {
const email = event.request.userAttributes?.email;
const emailDomain = email.split('@')[1]?.toLowerCase();
const isAllowedDomain = ALLOWED_DOMAINS.some(
(d) => emailDomain === d.toLowerCase()
);
if (!isAllowedDomain) {
throw new Error('Registration restricted.');
}
event.response.autoConfirmUser = true;
event.response.autoVerifyEmail = true;
callback(null, event);
};
If the email domain matches the allowlist, the user is auto-confirmed with no verification email and no manual approval step. Anyone with a non-matching domain gets rejected at signup. The allowed domains are configured as an environment variable on the Lambda function, so the lab can adjust who has access without modifying or redeploying the application itself.
Infrastructure
The CDK infrastructure is split across three stacks that coordinate through SSM parameters, each handling a distinct phase of the deployment:
- DnsStack — creates ACM certificates and a placeholder A record using an RFC 5737 test IP (
192.0.2.1) so that Cognito's custom domain validation can pass before the CloudFront distribution exists - CognitoStack — sets up the User Pool with the custom auth domain and the pre-signup Lambda trigger for email domain validation
- ZebraPrintingStack — deploys the S3 bucket and CloudFront distribution, then replaces the placeholder DNS record with the real CloudFront alias using
deleteExisting: true
The placeholder DNS pattern exists because Cognito requires a valid A record on the domain before it will accept a custom auth domain configuration, but at that point in the deployment the main stack hasn't run yet and there's no CloudFront distribution to point to. Using an RFC 5737 documentation-reserved address as a temporary target satisfies the validation requirement without routing real traffic anywhere.
GitHub Actions handles deployment for both dev and prod environments using OIDC authentication, so there are no static AWS credentials stored in the repository.
The full source is at github.com/schuettc/zebra-printing, including user guides, IT setup documentation, and printer configuration guides.