@rosepetal/barcode-engine-client
v0.4.0
Published
Node client of rp-barcode serve, the persistent decode and verify service of rosepetal-barcode-sdk: one shared engine process per Node-RED runtime, framed requests over stdio, timeouts, bounded queue, restart with backoff
Keywords
Readme
@rosepetal/barcode-engine-client
Node client of rp-barcode serve, the persistent decode and verify service of
rosepetal-barcode-sdk (Go, static binary). It spawns the
binary once, talks protocol 1 over its stdin/stdout (length-prefixed frames, see below), keeps one request timer per
request (decode, verify, analyze), bounds what is in flight, restarts a dead engine with a growing wait and
stops it cleanly. CommonJS, no runtime dependencies, Node >= 18.
It exists as a package because node-red-contrib-barcode-reader and node-red-contrib-barcode-verifier share the same
binary and must share one engine process per Node-RED runtime: both require this module and acquire() its
singleton, so the process is started by the first decode or verify and stopped by the last release().
Install
npm i @rosepetal/barcode-engine-clientThe binary is not in this package. It comes from one of the platform packages, declared here as optional
dependencies and installed next to the client when the registry that carries them is configured for the
@rosepetal scope (they are published by the SDK's release workflow, 0.2.x = the SDK version):
@rosepetal/barcode-engine-linux-x64@rosepetal/barcode-engine-linux-arm64
each with the static binary at bin/rp-barcode (musl uses the same one). Without them, RP_BARCODE_ENGINE points at
an rp-barcode binary (SDK >= 0.2.0), or rp-barcode is looked up in PATH. resolveBinary() tries in that order:
RP_BARCODE_ENGINE(explicit: when set and unusable the start fails, it never falls back),- the platform package for
<platform>-<arch>(${process.platform}-${process.arch}:linux-x64,linux-arm64), along this module'snode_moduleschain, rp-barcodeinPATH,
and throws EngineError unavailable listing every place it tried. The binary runs as <path> serve.
API
const rp = require('@rosepetal/barcode-engine-client');
// One shared engine per process (started lazily by the first decode or verify)
const engine = rp.acquire(); // refs + 1, returns the singleton (rp.getEngine() returns it without counting)
rp.setEngineOptions({ defaultTimeoutMs: 8000 }); // merges into the singleton's options (see the table below)
const image = { width: 640, height: 480, channels: 1, colorSpace: 'GRAY' }; // encoding defaults to "raw"
const { symbols, image: size, timing } = await engine.decode(image, pixels, { symbologies: ['EAN13'], effort: 'robust' },
{ timeoutMs: 5000 });
// symbols: schema-1.0 symbol objects as the SDK writes them ([] when none); size: {width, height}; timing: {totalMs, decodeMs, queuedMs}
const { result } = await engine.verify(image, pixels, { region: { x: 120, y: 80, w: 400, h: 200, orientation: 0 },
options: { spec: { kind: 'gs1_table', table: '1' } },
acquisition: { milsPerPixel: 3.25 } }, { timeoutMs: 10000 });
// result: the schema-1.0 VerifyResult as the SDK writes it (status, reasons, symbol, quality, dimensions, gs1, report, …)
await rp.release(); // refs - 1; the last release() stops the processAfter its release(), a holder must not call decode()/verify()/ping() on the engine: a request after the last
release starts a process that no release will stop (it lives until the runtime exits). The reader node guards this
with its rpClosed flag; the verifier needs the same rule.
engine.decode(image, payload, options, { timeoutMs }): image is {width, height, channels, colorSpace, encoding?}
(encoding raw, payload = packed rows of width·height·channels bytes, colorSpace GRAY|RGB|BGR|RGBA|BGRA; or
encoded, payload = a JPEG/PNG/BMP/TIFF file); options is the decodeOptions object of the SDK's result schema 1.0
§12.1 (known keys only, every key optional, the server rejects unknown ones); timeoutMs (default
defaultTimeoutMs) is the client's timer and also travels as deadlineMs (sent as an integer number of milliseconds,
rounded up; the timer is capped at 2^31 − 1 ms). A payload larger than the engine's advertised limits.maxPayload is
refused with invalid_input before anything is sent.
The client passes options through as given, so a key newer than the engine is the caller's to gate. An engine
rejects a key it does not know with invalid_input even at its default value, and its hello does not list the keys
it takes: send such a key only away from its default, and only to an engine of the version that brings it (this
client pins its engine to its own exact version). That is the case of edges and projection (SDK 0.3.0) and of the
projection retries of SDK 0.4.0: projectionSharpen (off | retry) with projectionSharpenSigma (px, 0 = 2,
otherwise 0.5 to 4), projectionMarginVote, projectionEdges, projectionGuardZone, projectionHalves and projectionAngle (off |
retry). The retries
run only with projection: "fallback" and no region (otherwise they are accepted and ignored), after everything else
read nothing, in a fixed order where the first that reads decides; they add time only on the images nothing reads
(the SDK's docs/api.md §4.1, «Los reintentos de la proyección»). The reader node sends them through its
«Projection retries» level (off, sharpen, full), each key only as retry (its NEWER_OPTIONS).
projectionHalves is not in any level of the node: an individual opt-in of the engine, outside full.
engine.verify(image, payload, { region, options, acquisition, analyze }, { timeoutMs }) (SDK service 2D): the
ISO/IEC 15416 verification of the symbol in region. image/payload as decode; region is
{x, y, w, h, orientation} in image pixels: w/h are extents along the image axes whatever the orientation,
orientation is the reading direction in clockwise degrees (a multiple of 90, default 0). The client checks and
rounds the region before sending (Math.round on x, y, w, h; the orientation never rounded, an integer
multiple of 90 normalised to 0/90/180/270, so −90 is 270 and 450 is 90; w and h at least 2 px after rounding; a
missing or non-finite coordinate, an orientation that is not an integer multiple of 90 (90.4 included) or an
unknown key is invalid_input without anything sent), so a
normalised region scaled to pixels never reaches the server as a fraction; the bounds against the image are the
server's check (barcode: region {…} outside image WxH). options is the verifyOptions object of the schema
(§12.2, known keys only, every key optional; the server refuses unknown keys and values outside their vocabularies)
and acquisition the acquisition object (§12.3, calibration §12.4) or null (no physical scale, 660 nm, no
calibration); analyze: true sends analyze and the result carries scans. timeoutMs as decode (the verifier
node passes its own 10 s). Resolves { result, image, timing }: result is the VerifyResult (or
AnalysisResult) exactly as the SDK writes it, report included; status: "not_detected" is a result, not an
error; image the real size; timing is {totalMs, verifyMs, queuedMs}. An engine whose hello does not list the op
(an SDK 0.2.0 built before 2D) rejects with unsupported before anything is sent.
Other members of an Engine: start() (resolves with the hello; idempotent, never two processes), ping() (a
liveness probe the server answers without queueing), stop() (drain → shutdown → SIGTERM → SIGKILL, resolves when
the child is gone; what was pending rejects with exited), running, hello, pid, refs, lastError,
configure(options).
new Engine(options) and setEngineOptions(options) take (defaults in DEFAULTS):
| Option | Default | Meaning |
|---|---|---|
| command, args | null, ['serve'] | null resolves the binary at every start (RP_BARCODE_ENGINE may change) |
| env | null (= process.env) | environment of the child |
| maxQueue | 128 | requests in flight before overloaded |
| maxBufferedBytes | 256 MiB | bytes still unwritten to the engine's stdin before overloaded (an idle pipe always takes one frame) |
| helloTimeoutMs | 5000 | wait for the hello after the spawn |
| defaultTimeoutMs | 5000 | timeoutMs of decode/verify/ping when not given (the verifier node passes its own 10 s) |
| backoff | {initialMs: 1000, maxMs: 30000} | wait before restarting after a failed start or a crash, doubled each time, reset by a good hello |
| drainMs, exitMs | 2000, 1000 | stop(): wait for the requests in flight; wait after shutdown, then after SIGTERM |
Every failure is an EngineError with a code:
| code | From | When |
|---|---|---|
| unavailable | client | no binary, no hello in time, the spawn failed, or a start inside the wait after a failure (retry in N ms) |
| protocol | client | hello with another protocol, a bad frame from the engine, a reply that carries a payload, a verify reply without a result object, an unprompted bad_frame |
| exited | client | the process went away with the request in flight, stop(), or a stop() that landed while the request waited for a broken child to die (the engine is not relaunched for a holder that let go) |
| timeout | client | the request timer |
| overloaded | client or server | maxQueue / maxBufferedBytes reached, or the server's queue is full |
| invalid_input | client or server | client: image is not an object, payload is not a Buffer/Uint8Array, the region of a verify is not an object, has an unknown key, a missing or non-finite coordinate, a side under 2 px or an orientation that is not a multiple of 90, the payload exceeds the engine's limits.maxPayload (all refused before anything is sent) or the header cannot be framed; server: verbatim (a bad image, a payload of another length, an unknown options/acquisition key or a value outside its vocabulary, a region outside the image), the engine keeps running |
| unsupported | client or server | client: the hello does not list verify/analyze (refused before anything is sent); server: verbatim, the engine keeps running |
| deadline, internal | server | verbatim, the engine keeps running |
Events of an Engine (never 'error'): 'started' ({pid, hello, source}) after each good hello (source is
RP_BARCODE_ENGINE, the package name, PATH or command), 'exit' ({code, signal}) when the child is gone,
'stderr' (text) with the engine's log lines.
Also exported: resolveBinary({paths}), ENGINE_PACKAGES, PROTOCOL (1), DEFAULT_TIMEOUT_MS, INSTALL_HINT (the
sentence a node prints next to an unavailable), the frame codec encodeFrame, FrameParser, FramingError, and
formatName(symbol) / FORMAT_NAMES / ADDON_NAMES: the format string the nodes print for a schema-1.0 symbol
(ZXing's spelling, EAN-2/EAN-5 for the add-ons by their identifier), so the reader and the verifier agree.
Protocol 1 in short
Specified in the SDK's docs/service.md
(a private repository: the link works for the organisation's members); this client implements it as written.
- Everything on stdin/stdout is a frame:
u32 BE headerLen | u32 BE payloadLen | header (UTF-8 JSON object) | payload.headerLen2..1 MiB,payloadLen<=maxPayload(256 MiB by default,--max-payload). - The server writes a
hellofirst (id0):protocol,sdk,engineVersion,schema,ops,workers,queue,limits {maxHeader, maxPayload, maxSide, maxPixels}. The client requiresprotocol === 1. decode(id,image,options,deadlineMs) with the pixels as payload →{id, ok: true, image, symbols, timing}, never with a payload.ping→{id, ok: true}, answered without queueing.shutdown→{id, ok: true}; what is in flight finishes, what is still queued is answeredunsupported, then exit 0.verify/analyze(id,image,region,options,acquisition,deadlineMs) with the pixels as payload →{id, ok: true, image, result, timing}:resultthe schema-1.0VerifyResult/AnalysisResult(reportincluded;not_detectedis a result),timing{totalMs, verifyMs, queuedMs}. Since SDK 0.2.0 with 2D: the hello lists them inops(a 2A engine answersunsupported; the client checksopsfirst).- Errors:
{id, ok: false, error: {code, message}}withinvalid_input,unsupported,overloaded,deadline,internal(the server keeps running) orbad_frame(id0, then the server exits 3: the stream is no longer delimited, the client restarts it). - EOF on stdin: the server decodes what is in flight and what is already queued (a queued request whose
deadlineMsexpired answersdeadline), then exits 0; a broken stdout is exit 1. - Replies come out in the order they finish;
deadlineMsonly rejects a request still queued when it expires.
Testing with the fake engine
FAKE_ENGINE is the path of test/fake-engine.js, a fake rp-barcode serve in Node (protocol 1, one fixed EAN-13
per decode, canned VerifyResults for verify/analyze) that ships with the package so the tests of the nodes
never need a real binary:
const rp = require('@rosepetal/barcode-engine-client');
process.env.RP_BARCODE_ENGINE = rp.FAKE_ENGINE; // the shared engine spawns the fake (executable, #!/usr/bin/env node)
rp.setEngineOptions({ helloTimeoutMs: 3000, backoff: { initialMs: 50, maxMs: 200 }, drainMs: 500, exitMs: 300 });
// or, for an engine of your own: new Engine({ command: process.execPath, args: [rp.FAKE_ENGINE, 'serve'], env: { ...process.env, FAKE_DELAY_MS: '300' } })Its behaviour is driven by the child's environment: FAKE_DELAY_MS, FAKE_CRASH_AFTER, FAKE_PROTOCOL,
FAKE_GARBAGE, FAKE_OVERLOAD, FAKE_NO_HELLO, FAKE_IGNORE_STOP, FAKE_MAX_PAYLOAD, FAKE_REPLY_PAYLOAD,
FAKE_STALL, and for the verifier FAKE_VERIFY_STATUS=pass|fail|not_detected (the canned result of
verify/analyze, default pass), FAKE_DECODE_SYMBOLS=n (copies of the EAN-13 a decode answers, default 1,
0 = none) and FAKE_OPS=a,b,c (the ops the hello announces: FAKE_OPS=decode,ping,shutdown is a 2A engine
without verify), all documented at the top of the file. verify/analyze answer the canned results of
test/fixtures/*.json (verify-pass.json, verify-fail.json, verify-not-detected.json, analyze-pass.json:
what the CLI printed for synthetic renders, generated by test/tools/make-verify-fixtures.sh from the SDK
checkout, shipped in the package so a consumer's tests can require('@rosepetal/barcode-engine-client/test/fixtures/verify-pass.json')),
with the request's region echoed in result.symbol.region; analyze under fail/not_detected is the verify
fixture plus scans: []. Like the real server it takes encoding raw (payload.length === width·height·channels)
and encoded (a non-empty PNG/JPEG/BMP/TIFF file told by its magic bytes; the size answered is the PNG's IHDR,
else the header's width/height; with no size at all the bounds check is skipped), rejects unknown keys inside
image, region, options and acquisition, a region under 2 px, off a multiple of 90 or outside the image, and a
non-integer deadlineMs with invalid_input and the server's wording (options: json: unknown field "scans");
unlike it, it matches keys case-sensitively (the binary's encoding/json also takes TryInvert for tryInvert),
does not check option values against their vocabularies (edition: "2020" passes the fake, the server refuses it)
nor that options/acquisition (and their spec/calibration) are objects.
Development
cd clients/node
npm test # node --test: framing (10) + engine (32, verify/analyze included) on the fake engine, no child left behindTo regenerate the canned results after a change of the SDK's verification (never by npm test):
make build GO=/usr/local/go/bin/go # from the SDK root: bin/rp-barcode
cd clients/node && RP_BARCODE=../../bin/rp-barcode sh test/tools/make-verify-fixtures.shNo lockfile: the package has zero dependencies, so there is nothing to install and nothing to npm ci; the tests need
only Node's built-ins and the package's own files. The optional platform packages are resolved by each consumer's own
install (its lock), never through a lock of this package.
CI runs npm test in the node-client job of .github/workflows/ci.yml (Node 22, no install step). The Go targets
of the repository (make lint, make test) do not touch this directory.
The SDK's release workflow (.github/workflows/publish-engine.yml, on every v* tag) publishes this package to
npmjs (npm test, then npm publish --access public with NPM_TOKEN; a prerelease tag goes to the dist-tag of its
preid) in the same run that publishes the platform packages to the private registry, and installs both in clean
containers afterwards (scripts/verify-published.sh). Its version is pinned to the SDK's by TestNpmClientVersion.
Install it with npm i @rosepetal/barcode-engine-client.
