npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

4b-react-print

v0.9.0

Published

Silent printing for React — an in-app toggle, a local print agent, and a browser-dialog fallback

Readme

4b-react-print

Silent printing for React — decided in your app, not by browser launch flags.

npm install 4b-react-print

Why 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-agent

In 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 overrides

useSilentPrintQueue(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 is false.

<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:5173 has to reach the agent at 192.168.0.224 — its own 127.0.0.1 is 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 toggle

Leave 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 site

Passing 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 — completed reads "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.state is preparing and the message is the fallback below. isPrinting is 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-agent
Options
  -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 message

PORT, 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-local

That 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.1 is 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 pointing agentUrl at 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 yourself

Endpoints

| 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:5173

Building 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 canPrintSilently and tell the operator when it is false. 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