4b-react-print
v0.9.0
Published
Silent printing for React — an in-app toggle, a local print agent, and a browser-dialog fallback
Maintainers
Readme
4b-react-print
Silent printing for React — decided in your app, not by browser launch flags.
npm install 4b-react-printWhy this exists
A web page cannot suppress the print dialog. window.print() always prompts,
and there is no permission to request. The usual workaround is to launch the
browser with a kiosk-printing flag:
| Browser | Flag |
| ------------- | ------------------------------------------------- |
| Chrome / Edge | --kiosk-printing |
| Firefox | print.always_print_silent in about:config |
Those work, but they are a poor fit for a staffed print station:
- Invisible. Nothing in the UI says whether silent printing is on.
- Per-profile. A new machine, a new browser, or a fresh profile loses it.
- All-or-nothing. Every site in that browser prints without a dialog.
- Unreadable. The page cannot query the flag, so it can never tell the operator the truth about what is about to happen.
4b-react-print moves the decision into the application: a toggle the operator can
see, stored with the app, honoured by a small local agent — and an honest
fallback to the ordinary print dialog when that agent is not reachable.
How it works
your React app the print station
┌───────────────────────┐ ┌──────────────────────┐
│ useSilentPrint(…) │ POST /print │ 4b-react-print-agent │
│ ├ silent? ──────────┼──────────────►│ → PDF → lp / CUPS │
│ └ else react-to-print │ GET /jobs/:id │
│ └ print dialog │◄──────────────┤ → job state │
└───────────────────────┘ └──────────────────────┘Two backends, one interface. The app detects which is available at runtime and never branches on it:
| Backend | Silent | Job status | Requires | | -------------- | ------ | ---------- | -------------------------------------- | | Print agent | yes | yes | the agent running on the print machine | | Browser dialog | no | no | nothing — always available |
If the agent is not reachable, the job still prints via the dialog and the
result says degraded: true. A print is never lost because silent printing
was unavailable.
Quick start
1. Run the agent on the machine with the printer
npx 4b-react-print-agentIn development, let the dev server run it for you instead — one command, and nothing to remember to start:
// vite.config.js
import { printAgent } from "4b-react-print/vite";
export default defineConfig({ plugins: [react(), printAgent()] });It follows the dev server's own binding: on --host the agent goes onto the LAN
too, so a phone opening the Network address can reach it; without it the agent
stays on loopback. See The Vite plugin.
2. Wrap your app
import { SilentPrintProvider } from "4b-react-print";
export default function App() {
return (
<SilentPrintProvider>
<YourApp />
</SilentPrintProvider>
);
}agentUrl is not needed: the agent is found by probing. Set it only to pin a
specific address.
The package ships no UI and no stylesheet — it is hooks only, so the toggle and status display look like the rest of your app. See Building the controls for a worked example.
3. Print
A station prints one sheet after another, so useSilentPrintQueue is the usual
entry point — it serialises jobs and releases each one when the printer says
it is finished, not on a timer:
import { useRef } from "react";
import { useSilentPrintQueue } from "4b-react-print";
function BadgeStation({ placeholder }) {
const printRef = useRef();
const { enqueue, queueLength, isPrinting, status, currentImageUrl } =
useSilentPrintQueue({
contentRef: printRef, // printed by the dialog; artwork read from its <img>
paperSize: "4x6in", // "A4" | "letter" | { width, height, unit } | …
silent: true, // force it here; omit to follow the operator's toggle
});
return (
<>
<button onClick={() => enqueue(nextBadgeUrl())}>Print badge</button>
<span>{queueLength} waiting</span>
{/* While a job is in flight `status` is never null, so no fallback text
is needed — see "The label, ready to render". */}
{isPrinting && <p>{status.message}</p>}
{/* Bind the sheet to `currentImageUrl`. The queue waits for *this* image
to render and decode before handing the job over, which is what stops
sheet 2 printing sheet 1's artwork. */}
<div ref={printRef}>
<img src={currentImageUrl ?? placeholder} alt="" />
</div>
</>
);
}enqueue takes a URL, or an object for per-job overrides
({ imageUrl, paperSize, silent, copies }), and returns how many jobs are
waiting behind it. Jobs that arrive mid-print wait their turn.
Without the queue
useSilentPrint is the primitive underneath — no queue, one print() you call
yourself. It takes the same options, which the queue forwards untouched:
import { useRef } from "react";
import { useSilentPrint } from "4b-react-print";
function Badge({ artwork }) {
const ref = useRef();
const { print, status, isPrinting, paperSize } = useSilentPrint({
// ── what to print ──────────────────────────────────────────────
contentRef: ref, // printed by the dialog; artwork read from its <img>
imageUrl: artwork, // optional — name the artwork directly instead
// getImageUrl: async () => fetchNextUrl(), // or resolve it at print time
// ── how to print it ────────────────────────────────────────────
paperSize: "4x6in", // "A4" | "letter" | { width, height, unit } | …
singlePage: true, // default — clamp output to exactly one sheet
silent: undefined, // leave unset to follow the user's toggle;
// true/false forces it for this call site
// ── anything else goes to react-to-print untouched ─────────────
documentTitle: "Badge",
onAfterPrint: () => console.log("dialog closed"),
});
return (
<>
<button onClick={() => print()} disabled={isPrinting}>
{isPrinting ? "Printing…" : `Print ${paperSize.css}`}
</button>
{status && <p>{status.message}</p>}
<div ref={ref}>
<img src={artwork} alt="" />
</div>
</>
);
}Every option is optional except a source for the artwork — contentRef,
imageUrl or getImageUrl. The minimum that works:
const { print } = useSilentPrint({ contentRef: ref });Calling it:
const result = await print(); // uses the options above
await print({ silent: false }); // dialog, just this once
await print({ imageUrl: other, paperSize: "A4" });// different artwork/stock
result; // { ok, mode: "silent" | "dialog", backend?, jobId?, degraded?, error? }mode tells you which path actually ran. degraded: true means silent was
wanted but nothing could honour it — the job still printed, via the dialog.
Migrating from react-to-print is one line — every unrecognised option is
forwarded to it untouched, so documentTitle, fonts and nonce keep working:
-const print = useReactToPrint({ contentRef, onAfterPrint });
+const { print, status } = useSilentPrint({ contentRef, onAfterPrint });Nothing changes until the toggle is on and the agent is reachable.
API
useSilentPrint(options)
Options are read at hook time, as in react-to-print.
| Option | Type | Default | |
| --------------- | ---------------------------------- | ------------------ | -------------------------------------- |
| silent | boolean | provider toggle | force silent on/off at this call site |
| paperSize | name or{width,height,unit} | "4x6in" | stock for both paths |
| singlePage | boolean | true | never print more than one sheet |
| contentRef | RefObject | — | what the dialog prints; artwork source |
| imageUrl | string | fromcontentRef | artwork, named directly |
| getImageUrl | () => string \| Promise<string> | — | resolved at print time |
Returns:
| | |
| --------------------- | ------------------------------------------------------------- |
| print(overrides?) | async →{ ok, mode, backend?, jobId?, degraded?, error? } |
| status | { state, percent, message, isTerminal }, or null |
| isPrinting | true until the job reaches a terminal state |
| paperSize | the resolved size |
await print(); // uses the hook's options
await print({ silent: false }); // dialog, just this once
await print({ imageUrl: url, paperSize: "A4" }); // per-call overridesuseSilentPrintQueue(options)
One print at a time, released by the printer rather than a timer. Takes
everything useSilentPrint takes, plus:
| Option | Default | |
| ------------------- | ---------- | ---------------------------------------- |
| cooldownMs | 20000 | only when a job cannot be tracked |
| dialogTimeoutMs | 300000 | dialog never reported back (tab closed) |
| onJobDone | — | (job, result) after each job settles |
const { enqueue, queueLength, isPrinting, status, currentImageUrl } =
useSilentPrintQueue({ contentRef, paperSize: "4x6in" });
enqueue(url); // hand it an image URL
enqueue({ silent: false }); // or an object, to override
<div ref={contentRef}><img src={currentImageUrl ?? placeholder} /></div>Jobs arriving mid-print wait their turn; each starts as soon as the printer is
finished with the last. Bind your preview to currentImageUrl and the screen
cannot disagree with the sheet.
The hook waits for that image to be rendered and loaded before printing.
Without it, the dialog path — which prints whatever is inside contentRef —
reproduces the previous sheet.
| Returns | |
| ------------------------------------ | --------------------------------------------------------------------------------------------- |
| enqueue(url \| job?) | add a job; returns how many are waiting behind it (0 if it started at once) |
| queueLength | jobs still waiting |
| isPrinting | a job is in flight |
| currentJob / currentImageUrl | what is printing now |
| status | { state, percent, message, isTerminal } — the queue's own wording while it holds a sheet |
| clearQueue() | drop everything still waiting |
| print(overrides?) | bypass the queue entirely |
useSilentPrintSettings()
Everything needed to drive the toggle and show what is happening:
const {
settings, // { silent, backend, printerName, copies, agentUrl }
update, // (patch) => void
refresh, // re-probe backends
detecting,
available, // reachable backends
activeBackend,
canPrintSilently, // false → the toggle cannot be honoured right now
printers,
job,
lastError,
} = useSilentPrintSettings();Never present the toggle alone as proof that printing is silent. Check
canPrintSilently, and say so when it isfalse.
<SilentPrintProvider>
| Prop | Default | |
| ----------------- | ------------------------- | ------------------------------------ |
| agentUrl | "auto" | where the agent listens — probed when unset |
| defaultSilent | false | initial toggle state, first run only |
| storageKey | 4b-react-print.v1 | localStorage key |
Detection runs at mount, re-probes every 5s while the toggle is on but unsatisfied, and re-probes immediately after any silent-path failure — so the UI never advertises a backend that has gone away.
Concepts
Where the agent is found
The agent has to be on the machine holding the printer, and which machine that is depends on how the app is served. Two arrangements are both ordinary, and they want opposite answers:
- A dev server, or a single-box station. The machine serving the page is
the machine with the printer. A phone opening
http://192.168.0.224:5173has to reach the agent at192.168.0.224— its own127.0.0.1is itself. - A server handing the app to stations. Each station has its own printer and its own agent on loopback. The serving machine has no printer at all.
The page cannot tell these apart from its address, so it does not try. With
agentUrl unset it asks the page's own host first, then loopback, stopping at
whichever answers.
The order matters more than it looks. A page on a LAN address asking for
127.0.0.1 is a Local Network Access request — browsers gate it behind a
permission, and when it is refused they report it as a missing CORS header:
Cross-Origin Request Blocked … (Reason: CORS header
'Access-Control-Allow-Origin' missing). Status code: 200.That message names the wrong cause. The agent sent the header; the browser never let the response count. Trying the page's own host first crosses no boundary, so the case where the machine serving the app is the machine printing never makes that request at all. Loopback still follows for the station deployment, where the crossing is unavoidable and the agent grants the private-network preflight for it.
A page served from a public origin has only one candidate, its own loopback, so a deployed station behaves exactly as it always has.
Set agentUrl to pin one address. It is then used literally and nothing else is
tried — an address someone named was meant, and quietly printing somewhere else
would be worse than not printing.
| Export | |
| ------------------------ | --------------------------------------------------- |
| agentUrlCandidates() | the addresses that would be probed, in order |
| resolveAgentUrl() | the single best guess, without probing |
| AUTO_AGENT_URL | the "auto" sentinel agentUrl defaults to |
| DEFAULT_AGENT_PORT | 4321 |
Silent is a request, not a guarantee
silent: true asks for silent printing. Without a reachable backend the job
goes through the dialog and the result reports mode: "dialog", degraded: true.
Resolution is per job, most specific first:
enqueue({ silent }) → hook option → provider toggleLeave it unset everywhere and the operator's toggle governs — the right default for a staffed station.
Artwork
A backend talking to the OS cannot print a React tree, so the silent path needs
something rasterisable. Pass imageUrl, or let the hook take the first <img>
inside contentRef. The dialog path always prints contentRef with its own
@media print CSS.
The page sends the bytes, not a URL. The browser holds the session, the origin and any certificate exceptions, so it is the side that can actually read the artwork; an agent re-fetching the same URL from Node has none of that. Bytes are passed through unchanged rather than re-encoded through a canvas, which would recompress the image and drop its colour profile.
Paper size
Resolved once, then handed to each layer in the form it needs: @page for the
dialog, PDF points for the agent, media= for CUPS. That is why the two paths
cannot end up on different stock.
Built-in names: 4x6in, 6x4in, 5x7in, A4, A5, A6, letter, legal.
Explicit sizes take { width, height, unit } with in, mm, cm or pt. An
unknown name throws at render rather than printing a wrong sheet.
One sheet per job
The browser's print dialog has a Pages control — All, Current, a custom
range — and no web API can set it. A page whose content runs past one sheet
will hand the operator an All they have to notice and change, and a silent job
has no operator to notice at all. Either way you get two sheets where you wanted
one.
singlePage (default true) closes that off from both ends:
| Path | What it does |
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| Dialog | clampsbody to exactly the page box with overflow: hidden, and forbids page breaks — so there is only ever one page for All to mean |
| Agent | adds-o page-ranges=1 to lp (a page range for SumatraPDF on Windows) — the spooler refuses to emit a second sheet |
The dialog side is prevention, not enforcement: it works by making overflow impossible rather than by driving a control the page cannot reach. Content taller than the sheet is clipped, which is the right trade for badges and labels — a card cut off is obvious, a surprise second sheet is not.
Turn it off with singlePage: false when a job genuinely spans pages:
await print({ singlePage: false }); // this job only
useSilentPrint({ singlePage: false }); // this call sitePassing your own pageStyle overrides the generated CSS entirely, so the
dialog-side clamp is yours to reproduce; the agent-side page-ranges still
applies.
Job status
lp returns as soon as a job is spooled, which is not the same as paper coming
out — so a submitted job reports submitted, and the provider polls until the
printer is genuinely finished.
| status.state | |
| ---------------- | ---------------------------------------------------------- |
| submitted | accepted by the spooler |
| queued | waiting at the printer |
| printing | on the roller |
| completed | finished |
| dialog | went through the print dialog; nothing further is knowable |
| failed | silent path broke, fell back to the dialog |
| timeout | the printer stopped reporting — go and look at it |
| unknown | left the queue without confirmation |
Two limits are worth wording carefully in your own UI:
- CUPS files a cancelled job under completed alongside a successful one, so an unconfirmed job reads as "No longer in the queue", not "Printed".
- A stalled job usually shows up as the printer going
stopped— a jam or an empty tray — while the job itself still looks fine. That is surfaced separately, with the reason CUPS gives.
Job tracking needs CUPS; on Windows the agent reports unknown.
The label, ready to render
status.message is the state in operator language, so a call site does not
carry its own state-to-text map — and cannot miss a state:
const { status, isPrinting } = useSilentPrintQueue({ contentRef });
{isPrinting && <span>{status.message}</span>}| status.state | status.message |
| ---------------- | ----------------------------------------- |
| submitted | Sent to the printer… |
| queued | Queued at the printer… |
| printing | Printing… |
| completed | Printed |
| dialog | Waiting for the print dialog… |
| failed | Silent print failed — using the dialog… |
| timeout | No response from the printer — check it |
| unknown | Left the print queue |
useSilentPrintQueue words two moments the job itself cannot describe:
- while it holds the station after a sheet —
completedreads "Printed — preparing the next sheet…", because the operator should wait rather than reach for the print; - between dequeuing a job and the backend's first report, where
status.stateispreparingand the message is the fallback below.isPrintingis already true there, so a status bar bound to it is never blank.
A state this version has no wording for falls back to
DEFAULT_JOB_MESSAGE ("Printing — please wait…") — an older app stays
readable against a newer agent. Both maps are exported if you want your own
wording, or another language:
import { JOB_MESSAGE, DEFAULT_JOB_MESSAGE } from "4b-react-print";Progress, sent → printed
status.percent is a number 0-100 across the job's life:
| status.state | status.percent |
| --------------------------------------------- | ------------------ |
| submitted | 25 |
| queued | 50 |
| printing | 75 |
| completed | 100 |
| dialog failed timeout unknown | null |
const { status } = useSilentPrintQueue({ contentRef });
{status?.percent != null ? (
<progress value={status.percent} max={100} />
) : (
<span>{status?.state}</span>
)}This is stage progress, not a measurement. Nothing in the stack reports a
sheet as it prints — not the browser, not CUPS, not the printer; they report
only that a sheet finished. So percent moves in four steps and is never
interpolated to look smoother than the information behind it. Treat it as "how
far through the pipeline", which is what an operator actually needs to know.
null means progress is genuinely unknowable: a job handed to the print dialog
reports nothing back, and a failed or timed-out job has no position. Show the
state label there rather than a bar stuck at some arbitrary value.
The mapping is exported as JOB_PROGRESS if you want different weightings:
import { JOB_PROGRESS } from "4b-react-print";Genuine per-sheet percentages are only possible for multi-copy jobs, where CUPS
reports job-media-sheets-completed against a known total. For single-sheet
badges there is nothing finer to report.
The agent
Ships with the package. Run it on the machine with the printer:
npx 4b-react-print-agentOptions
-p, --port <n> Port to listen on (default 4321)
--host <addr> Address to bind (default 127.0.0.1)
--allow-origin <o> Restrict CORS to this origin (repeatable).
Wildcards one label deep: https://*.example.com
--allow-local Also allow pages served from this machine
(localhost, 127.0.0.1 and this host's own LAN
addresses, any port)
--max-artwork <size> Request body limit (default 32mb)
-h, --help Show this messagePORT, HOST and ALLOWED_ORIGINS work too; flags win over environment.
It binds to loopback by default — the agent drives a physical printer and should not be reachable from the network unless you say so. Bind elsewhere and it warns when no origin is allow-listed.
An origin may be exact, or name a wildcard label — for a deployment that mints a hostname per build, listing them one at a time is not possible:
4b-react-print-agent --allow-origin "https://*.example.com"The wildcard stands for exactly one label and never crosses a dot, so
https://*.example.com matches https://preview-42.example.com but not
https://a.b.example.com, and not a lookalike domain that merely contains the
name. The scheme must match too.
An allow-list written for production locks out your own dev server, and the
failure is silent from the page — the browser blocks the response, the app
reports no backend, and the station prints through the dialog as if the agent
were not there. Add --allow-local so one agent serves both:
4b-react-print-agent --allow-origin "https://*.example.com" --allow-localThat accepts http://localhost:<any port>, http://127.0.0.1:<any port>, and
this host's own LAN addresses. A dev server prints two URLs:
➜ Local: http://localhost:5173/
➜ Network: http://192.168.0.224:5173/Both are the same page on the same machine, so both are allowed — and the
address is discovered from the interfaces rather than written down, because it
changes with DHCP and with the venue. It is re-read periodically, so a station
that gets a new address keeps printing without a restart. The port is a
wildcard; https://localhost:5173 (wrong scheme) is still refused.
This covers the host's own addresses, never the subnet: http://192.168.0.99
is refused even on the same LAN.
A different device cannot use this agent. Open the app on a tablet and its
127.0.0.1is the tablet — there is no agent there, so it prints through the dialog. Serving one agent to several devices means binding it to the network (--host 0.0.0.0), allow-listing the origins, and pointingagentUrlat the print machine. That trades away the loopback guarantee that makes a permissive default safe, so do it deliberately.
The agent prints what it will accept as it starts, and warns once per origin it turns away:
4b-react-print agent listening on http://127.0.0.1:4321
Origins allowed: https://*.example.com, http://localhost:*, http://127.0.0.1:*
Refused origin https://elsewhere.example — not in the allow-list.Embed it in an existing Node process instead:
import { startAgent, createAgent } from "4b-react-print/agent";
await startAgent({ port: 4321, allowedOrigins: ["http://localhost:5180"] });
app.use("/print-agent", createAgent()); // or mount the Express app yourselfEndpoints
| Route | Returns |
| -------------------- | --------------------------------------------------------------------------------- |
| GET /health | { ok, canPrint, tool, printers, reason } — what backend detection probes |
| GET /printers | { printers: [{ name, isDefault }] } |
| GET /status | every printer's state plus the current queue |
| POST /print | { jobId, state } — body { imageData, printerName?, copies?, paperSize? } |
| GET /jobs/:jobId | { state, printer, printerState, detail } |
paperSize arrives pre-resolved as { points: [w, h], cupsMedia }, so the
agent never reimplements the size table.
Platform support
| | Printing | Job status |
| ------------- | ---------------------------------------------------------------------------- | ------------------ |
| macOS / Linux | lp (CUPS) | full |
| Windows | SumatraPDF — setSUMATRA_PATH if not in C:\Program Files\SumatraPDF\ | reportsunknown |
The Vite plugin
In development the machine serving the app is the machine with the printer, so the dev server may as well run the agent:
import { printAgent } from "4b-react-print/vite";
export default defineConfig({ plugins: [react(), printAgent()] });npm run dev is then the whole setup. Started separately the agent is a second
thing to remember, and forgetting it is invisible from the page — the job simply
falls back to the print dialog with nothing to say why.
| Option | Default | |
| ------------------ | -------------------------- | ------------------------------------------- |
| port | 4321 | port to listen on |
| host | follows the dev server | 0.0.0.0 under --host, else loopback |
| allowedOrigins | [] | extra origins beyond the local ones |
| allowLocal | true | accept pages served from this machine |
| enable | true | set false to skip starting it |
It follows the dev server's binding rather than choosing separately, because
--host — the flag that lets another device open the app — is exactly the flag
that has to put the agent within that device's reach. Without it the agent stays
off the network, which is the safer default: CORS governs browsers, but a
request carrying no Origin at all is not a cross-origin request to police, so
an agent on 0.0.0.0 can be driven by anything on the LAN.
An agent already on the port is reused rather than treated as a failure, and the plugin closes the one it started when the dev server closes. Dev only — a production build is served elsewhere, and the agent belongs where the printer is.
Keeping it running
The agent is a foreground process and dies with its terminal. For a permanent
station, run it under launchd (macOS), systemd (Linux) or as a Windows
service so it survives reboots. On macOS that is a LaunchAgent at
~/Library/LaunchAgents/, with RunAtLoad to start it at login and
KeepAlive to bring it back if it exits:
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/4b-react-print-agent</string>
<string>--allow-origin</string>
<string>https://*.example.com</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>Use the absolute path from which 4b-react-print-agent — launchd has no shell
and no user PATH — and set EnvironmentVariables.PATH so the Node the script
was installed against is findable.
Serving the app from a deployed origin
Nothing about the agent is deployed: it runs on the machine holding the
printer, the browser on that machine talks to 127.0.0.1, and the request never
leaves the box. You do not have to configure that difference — with agentUrl
left unset the page probes loopback and, when it was served from a private
address, the machine that served it, using whichever answers. A deployed origin
is never private, so a deployed station only ever probes its own loopback.
What changes is that the page is now HTTPS while the agent is HTTP:
| Browser | Reachinghttp://127.0.0.1 from an HTTPS page |
| ------------- | --------------------------------------------------------------------------------------------------------- |
| Chrome / Edge | works — loopback counts as a trustworthy origin, and the agent grants Chrome's private-network preflight |
| Firefox | works, same treatment of loopback |
| Safari | blocked — the job falls back to the print dialog |
Put stations on Chrome or Edge. Chrome has also been moving local-network access toward a one-time permission prompt, so test on the build the station actually runs rather than assuming it stays silent.
Then name the deployed origin, since the default allows any:
4b-react-print-agent --allow-origin "https://*.example.com" --allow-origin http://localhost:5173Building the controls
The package is hooks only — no components, no stylesheet. A print station's
controls belong in the host app's design, and a prebuilt bar would either clash
with it or need theming hooks for every part. Everything you need is on
useSilentPrintSettings().
A complete toggle, in about twenty lines:
import { useSilentPrintSettings } from "4b-react-print";
function PrintControls() {
const { settings, update, canPrintSilently, activeBackend, detecting, job } =
useSilentPrintSettings();
// The toggle is on but nothing can honour it — say so, rather than letting
// the switch imply something that will not happen.
const unsupported = settings.silent && !canPrintSilently;
return (
<div>
<label>
<input
type="checkbox"
checked={settings.silent}
onChange={(e) => update({ silent: e.target.checked })}
/>
Silent print
</label>
<span>
{detecting
? "Detecting…"
: unsupported
? "No agent reachable — will use the print dialog"
: `via ${activeBackend.label}`}
</span>
{job && <span>{job.state}</span>}
</div>
);
}Add a printer picker and copies from the same hook:
const { settings, update, printers, refresh } = useSilentPrintSettings();
<select
value={settings.printerName}
onChange={(e) => update({ printerName: e.target.value })}
>
<option value="">System default</option>
{printers.map((p) => (
<option key={p.name} value={p.name}>
{p.displayName || p.name}{p.isDefault ? " (default)" : ""}
</option>
))}
</select>
<input
type="number"
min="1"
value={settings.copies}
onChange={(e) => update({ copies: Number(e.target.value) || 1 })}
/>
<button onClick={refresh}>Re-detect</button>The one rule: never let the toggle alone imply that printing is silent. Check
canPrintSilentlyand tell the operator when it isfalse. A switch that lies about what the printer will do is worse than no switch.
Hide your controls when printing, so they never land on the sheet:
@media print {
.print-controls { display: none !important; }
}Diagnosing a station you cannot reach
--doctor is for whoever has a terminal on the machine. Once the app is
deployed that is nobody: the operator is standing at the station and you are
not. The same answer is on the context, so the app can show it on the device
where it is failing.
import { useSilentPrintSettings, formatDiagnostics } from "4b-react-print";
function PrintSupport() {
const { diagnostics } = useSilentPrintSettings();
if (!diagnostics) return null;
const report = formatDiagnostics(diagnostics);
return (
<details>
<summary>Printing: {diagnostics.ok ? "ready" : "not available"}</summary>
<pre>{report}</pre>
<button onClick={() => navigator.clipboard.writeText(report)}>
Copy report
</button>
</details>
);
}diagnostics is structured as well as printable — ok, reason, remedy,
agentUrl, tried, agent.{version,platform,tool,printers} and page.origin
— so a station can also show just the one sentence an operator needs:
{!diagnostics.ok && <p>{diagnostics.reason}</p>}The report distinguishes the two failures that look identical from the page and have opposite fixes:
| What the operator sees | What it means |
| ---------------------- | ------------- |
| agent none reachable | the agent is not installed or not running on that device |
| agent http://… (answered, unusable) | the agent is running; the machine cannot print — the reason says which of CUPS, a printer, or SumatraPDF is missing |
It is plain text on purpose: it survives being pasted into a chat, read out over a phone, or photographed — which is how this actually travels from a station to whoever can fix it.
Troubleshooting
| Symptom | Cause |
| -------------------------------------- | ------------------------------------------------------------------------------------------ |
| No silent backend — using dialog | the agent is not running, oragentUrl is wrong |
| NetworkError / Failed to fetch | nothing is listening on the agent port |
| Artwork URL returned text/html | the URL served a page, not an image — often a dev server's SPA fallback on the wrong port |
| Could not read artwork | cross-origin without CORS, or a dead URL |
| Silent toggle on but still prompting | no backend reachable — check the chip, which always tells the truth |
| Prints here, dialog on another machine | that machine cannot print — run 4b-react-print-agent --doctor on it. Usually no CUPS, no printer configured, or Windows without SumatraPDF |
| Works locally, refused once deployed | the deployed origin is not allow-listed — a wildcard needs the agent at >= 0.5.0 |
| Refused at the apex, fine on subdomains | https://*.example.com covers one label and not https://example.com — allow-list the apex too; the agent says so when it refuses one |
| Another device on the LAN prints to the dialog | the agent is bound to loopback, so only the machine running it can reach it — start it with --host 0.0.0.0, or let the Vite plugin do it under --host |
| Refused only in Safari | Safari blocks HTTPS pages from reachinghttp://localhost — use Chrome or Edge |
The agent must be running for silent printing. That is not a limitation of this library; it is the reason it exists.
Requirements
- React >= 18
react-to-print>= 3 (peer dependency — install it yourself)- Node >= 18 for the agent
- CUPS for job status
Licence
MIT
