Discovery & pairing
Two problems stand between a web page and a printer: finding the bridge without asking a shopkeeper to type a port number, and getting permission without letting every website on the internet drive their hardware.
Why a port range and not one port
A single fixed port fails the first time something else is already on it — and then the shop is stuck, because nothing in the product can move it. A range means the bridge takes the first free port from 47600 to 47619 and the client finds it, so a collision costs nobody a support ticket.
The range sits just below the ephemeral block that operating systems hand out for outbound sockets, which is what stops an unrelated program grabbing the port while Printol is starting. Twenty is enough that all twenty being taken is not a real scenario, and few enough that probing them all at once is one round trip rather than twenty.
The sweep
Probe every port in parallel, keep whatever answers /printol/v1/ping with the Printol
marker, and prefer the lowest port. Closed ports fail immediately, so the sweep is bounded by the
slowest live answer, not by the number of ports.
import { discover } from 'https://printol.crawlink.com/sdk/printol.js';
// Twenty parallel probes to loopback. Settles in about one round trip.
const bridges = await discover();
// [{ origin: 'http://127.0.0.1:47600', port: 47600, protocol: 1,
// version: '0.1.0', deviceId: 'b0f3…', deviceName: 'Counter PC',
// paired: true }]The marker check matters. Something else could be listening on one of those ports; a client that
assumes anything answering is a printer will send an invoice to it. Checking app === 'printol' is the whole defence and it costs nothing.
// The same sweep, by hand, if you would rather not take the dependency.
const ports = Array.from({ length: 20 }, (_, i) => 47600 + i);
const found = (await Promise.allSettled(
ports.map(async (port) => {
const response = await fetch(`http://127.0.0.1:${port}/printol/v1/ping`, {
credentials: 'omit',
signal: AbortSignal.timeout(900)
});
const body = await response.json();
if (body.app !== 'printol') throw new Error('not ours');
return { port, ...body };
})
)).filter((r) => r.status === 'fulfilled').map((r) => r.value);What /ping deliberately does not tell you
It is the one endpoint reachable with no token, so it answers only what a client needs to decide whether to continue: the app marker, the protocol version, the app version, a stable device id, the name the shop gave the machine, and whether anything is paired at all. No printer names, no hostname, no invoice counts. A website that has not been approved learns nothing about the shop beyond the fact that Printol is installed.
Pairing
Everything else needs a token, and a token exists only because a human at that computer approved it. The handshake is three steps:
- Your page
POSTs/pairwith your app name, your origin and the scopes you want. You get back a request id and six digits. - The desktop app raises a window naming your app and origin, showing those six digits, and listing what you asked for in plain words. Somebody clicks Allow or Deny.
- Your page polls
/pair/{request_id}. On approval it gets the token, once. The SDK stores it against the device id.
const printol = await connect({
appName: 'Anand Traders Billing',
scopes: ['print', 'sales:write'],
onCode: (code) => {
// Put this on screen. The same six digits are on the counter screen,
// which is how the person approving knows they are approving you.
document.querySelector('#pair-code').textContent = code;
}
});Show the code. It is not decoration: it is how the person at the counter knows the dialog in front of them belongs to the browser tab in front of them, and not to some other page that happened to fire a request at the same moment.
Scopes
Ask for the least you need. The dialog lists every scope in plain words, and a request for the shop's sales ledger when you only wanted to print a receipt is a request that gets denied.
| Scope | Grants |
|---|---|
print | Queue print jobs and read their status. |
printers:read | List printers and their capabilities. |
catalog:read | Read items, prices and stock. |
catalog:write | Create and change items and stock. |
sales:read | Read invoices, orders, parties and reports. |
sales:write | Record invoices and orders, and add customers. |
purchases:read | Read purchases and suppliers. |
purchases:write | Record purchases, which brings stock in. |
ledger:read | Read income and expense entries. |
ledger:write | Record income and expenses. |
templates:write | Add or replace print templates. |
Tokens
- Bound to the origin that requested them. Presenting one from a different origin fails
with
origin_mismatch, so a token copied out of one site's storage is useless in another. - Long lived, and revocable. They do not expire on a timer, because a shop does not want to re-approve every morning. They stop working the moment the shop revokes them in Settings → Paired apps.
- Stored per device. The SDK keys storage on the bridge's device id, so one browser profile can hold tokens for several tills at once.
More than one bridge answered
A back office with two tills on one machine's network is not the case here — the sweep only ever reaches the local machine — but a machine can run two bridges if somebody deliberately started a second. Handle it by asking, using the device name the shop chose.
const bridges = await discover();
if (bridges.length > 1) {
const chosen = await askUserWhichTill(bridges); // deviceName is human
const printol = await connect({ appName: 'Billing', deviceId: chosen.deviceId });
}Browser rules you will hit
- Private Network Access
- Chrome and Edge treat a request from a public
httpspage to127.0.0.1as a private-network request and send a preflight asking permission. The bridge answers it withAccess-Control-Allow-Private-Network: true. Nothing is required on your side. - Mixed content
127.0.0.1counts as a trustworthy origin in Chrome, Edge and Firefox, so anhttpspage may fetch it over plainhttp. Safari is stricter and blocks it. If you must support Safari, serve your app overhttpon the local machine, or run it inside the desktop app's own window.- CORS
- The bridge reflects the paired origin on authenticated routes and allows any origin on
/pingand/pair, which is what makes discovery work before a token exists. Credentials are never accepted — the SDK sendscredentials: 'omit'and you should too.
Next: the full endpoint reference, or what all of this is defending against.