@rosepetal/barcode-engine-client 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/engine.js ADDED
@@ -0,0 +1,684 @@
1
+ 'use strict';
2
+ /**
3
+ * Client of `rp-barcode serve` (rosepetal-barcode-sdk, protocol 1): one persistent child process per Node-RED
4
+ * process, framed `decode`, `verify` and `analyze` requests over stdio (./framing), a timeout per request, a
5
+ * bounded queue, restart with a growing wait, and a clean stop. Spec: docs/service.md of rosepetal-barcode-sdk
6
+ * (the plan: docs/plans/fase2/00-overview.md §3, §4.3, §5.2; verify: 04-subfase-2D.md).
7
+ * node-red-contrib-barcode-reader and node-red-contrib-barcode-verifier both acquire() the shared engine of this
8
+ * module, so one Node-RED runtime runs one rp-barcode process (D9). What is specific to a node (mapping a symbol
9
+ * to its output, its options to decodeOptions or verifyOptions, a normalised region to pixels) lives in that node.
10
+ *
11
+ * Errors are EngineError with a `code`: `unavailable` (no binary, no hello, cannot run, or inside the wait after a
12
+ * failure), `protocol` (hello with another protocol, a bad frame, a reply with a payload, a verify reply without
13
+ * a result), `exited` (the process went away with the request in flight, or a stop() landed while the request
14
+ * waited for a broken child to go), `timeout` (the client's timer), `overloaded` (the client queue is full, or the
15
+ * engine has not taken the bytes already written to its stdin: `maxBufferedBytes`), `invalid_input` for an image,
16
+ * payload or region this side refuses before sending, `unsupported` when the hello does not list the op verify()
17
+ * needs, plus the server's own codes verbatim (`invalid_input`, `unsupported`, `overloaded`, `deadline`, `internal`).
18
+ *
19
+ * Events: 'started' ({pid, hello, source}), 'exit' ({code, signal}), 'stderr' (text). Never 'error'.
20
+ */
21
+ const { spawn } = require('node:child_process');
22
+ const { EventEmitter } = require('node:events');
23
+ const fs = require('node:fs');
24
+ const path = require('node:path');
25
+ const { encodeFrame, FrameParser } = require('./framing');
26
+
27
+ const PROTOCOL = 1;
28
+ const EMPTY = Buffer.alloc(0);
29
+
30
+ // Platform packages that carry the static binary at bin/rp-barcode (musl uses the same one: no linuxmusl variant)
31
+ const ENGINE_PACKAGES = {
32
+ 'linux-x64': '@rosepetal/barcode-engine-linux-x64',
33
+ 'linux-arm64': '@rosepetal/barcode-engine-linux-arm64'
34
+ };
35
+ const INSTALL_HINT = 'Install @rosepetal/barcode-engine-linux-x64 or -linux-arm64 (0.2.x, Rosepetal private npm ' +
36
+ 'registry) next to this node, or set RP_BARCODE_ENGINE to the path of an rp-barcode binary (SDK >= 0.2.0)';
37
+ const DEFAULT_TIMEOUT_MS = 5000;
38
+ const MAX_TIMER_MS = 0x7fffffff; // setTimeout's ceiling: a longer delay fires after 1 ms (TimeoutOverflowWarning)
39
+
40
+ // The request timer and the deadlineMs on the wire, from a caller's timeoutMs (`value` when positive, else
41
+ // `fallback`): an integer, because the server's deadlineMs is a Go int (2500.5 or 1e+21 is invalid_input there),
42
+ // within setTimeout's range, so a huge timeout means a long wait instead of an immediate `timeout`.
43
+ function timerMs(value, fallback) {
44
+ const ms = value > 0 ? value : fallback;
45
+ return Math.min(Math.ceil(ms), MAX_TIMER_MS);
46
+ }
47
+
48
+ const REGION_KEYS = ['x', 'y', 'w', 'h', 'orientation'];
49
+
50
+ class EngineError extends Error {
51
+ /**
52
+ * @param {string} code
53
+ * @param {string} message
54
+ */
55
+ constructor(code, message) {
56
+ super(message);
57
+ this.name = 'EngineError';
58
+ this.code = code;
59
+ }
60
+ }
61
+
62
+ // A value in an error message: strings quoted, everything else as String() prints it (NaN, undefined, [object Object])
63
+ function describe(value) {
64
+ return typeof value === 'string' ? JSON.stringify(value) : String(value);
65
+ }
66
+
67
+ /**
68
+ * The region of a verify as the server takes it (docs/service.md §3.5): the caller's {x, y, w, h, orientation?}
69
+ * checked and rounded here, so a fractional region (a normalised one scaled to pixels) never reaches the server
70
+ * as an invalid_input of its JSON decoder. x, y, w, h are finite numbers, rounded to integers (Math.round);
71
+ * orientation, 0 when absent, is an integer number of clockwise degrees that is a multiple of 90 (never rounded:
72
+ * 90.4 is not a reading direction, it is invalid_input), normalised to 0/90/180/270 as the CLI's --region does
73
+ * (-90 is 270, 450 is 90); w and h are at least 2 px after rounding (the
74
+ * server's rule, and w/h are extents along the image axes whatever the orientation); an unknown key is refused,
75
+ * never dropped in silence. The bounds against the image are the server's check (it knows the size of an
76
+ * encoded payload, this side does not).
77
+ *
78
+ * @returns {{x: number, y: number, w: number, h: number, orientation: number}}
79
+ * @throws {EngineError} code 'invalid_input'
80
+ */
81
+ function checkRegion(region) {
82
+ if (!region || typeof region !== 'object' || Array.isArray(region)) {
83
+ throw new EngineError('invalid_input', 'region must be an object {x, y, w, h, orientation} in image pixels');
84
+ }
85
+ for (const key of Object.keys(region)) {
86
+ if (!REGION_KEYS.includes(key)) {
87
+ throw new EngineError('invalid_input', `region: unknown key ${JSON.stringify(key)} (x, y, w, h, orientation)`);
88
+ }
89
+ }
90
+ const out = {};
91
+ for (const key of ['x', 'y', 'w', 'h']) {
92
+ const value = region[key];
93
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
94
+ throw new EngineError('invalid_input', `region: ${key} must be a finite number of pixels, got ${describe(value)}`);
95
+ }
96
+ out[key] = Math.round(value) || 0; // || 0: never -0
97
+ }
98
+ const orientation = region.orientation === undefined || region.orientation === null ? 0 : region.orientation;
99
+ if (typeof orientation !== 'number' || !Number.isFinite(orientation)) {
100
+ throw new EngineError('invalid_input', `region: orientation must be a number of degrees, got ${describe(orientation)}`);
101
+ }
102
+ if (!Number.isInteger(orientation) || orientation % 90 !== 0) {
103
+ throw new EngineError('invalid_input', `region: orientation ${orientation} is not a multiple of 90`);
104
+ }
105
+ if (out.w < 2 || out.h < 2) {
106
+ throw new EngineError('invalid_input', `region: width ${out.w} and height ${out.h} must be positive and at least 2 px`);
107
+ }
108
+ out.orientation = ((orientation % 360) + 360) % 360;
109
+ return out;
110
+ }
111
+
112
+ function platformId() {
113
+ return `${process.platform}-${process.arch}`;
114
+ }
115
+
116
+ // null when `file` is an executable regular file, otherwise the reason
117
+ function executableProblem(file) {
118
+ let st;
119
+ try {
120
+ st = fs.statSync(file);
121
+ } catch (err) {
122
+ return `${file}: ${err.code === 'ENOENT' ? 'not found' : err.message}`;
123
+ }
124
+ if (!st.isFile()) return `${file}: not a file`;
125
+ try {
126
+ fs.accessSync(file, fs.constants.X_OK);
127
+ } catch (_) {
128
+ return `${file}: not executable`;
129
+ }
130
+ return null;
131
+ }
132
+
133
+ // The binary of the platform package, looked up along `paths` (this module's node_modules chain by default, the order
134
+ // require uses) without require.resolve: the file is not JavaScript, the package may restrict its `exports`, and
135
+ // require caches its lookups for the life of the process.
136
+ function packageBinary(pkg, paths) {
137
+ for (const dir of paths) {
138
+ if (fs.existsSync(path.join(dir, pkg, 'package.json'))) return path.join(dir, pkg, 'bin', 'rp-barcode');
139
+ }
140
+ return null;
141
+ }
142
+
143
+ /**
144
+ * Where the engine binary is: RP_BARCODE_ENGINE (explicit: when set and unusable this fails, it never falls
145
+ * back), then the platform package's bin/rp-barcode, then rp-barcode in PATH. The binary runs as `<path> serve`.
146
+ *
147
+ * @param {{paths?: string[]}} [options] node_modules directories searched for the platform package (default: this
148
+ * module's chain, as require would); the tests point it at a temporary directory
149
+ * @returns {{path: string, source: string}} source: 'RP_BARCODE_ENGINE' | the package name | 'PATH'
150
+ * @throws {EngineError} code 'unavailable', the message lists every place that was tried
151
+ */
152
+ function resolveBinary({ paths = module.paths } = {}) {
153
+ const fromEnv = process.env.RP_BARCODE_ENGINE;
154
+ if (fromEnv) {
155
+ const file = path.resolve(fromEnv); // checked and spawned as the same file, never through PATH
156
+ const problem = executableProblem(file);
157
+ if (problem) throw new EngineError('unavailable', `RP_BARCODE_ENGINE ${problem}`);
158
+ return { path: file, source: 'RP_BARCODE_ENGINE' };
159
+ }
160
+ const problems = [];
161
+ const pkg = ENGINE_PACKAGES[platformId()];
162
+ if (pkg) {
163
+ const file = packageBinary(pkg, paths);
164
+ if (file) {
165
+ const problem = executableProblem(file);
166
+ if (!problem) return { path: file, source: pkg };
167
+ problems.push(problem);
168
+ } else {
169
+ problems.push(`${pkg} is not installed`);
170
+ }
171
+ } else {
172
+ problems.push(`no engine package for ${platformId()}`);
173
+ }
174
+ for (const dir of (process.env.PATH || '').split(path.delimiter)) {
175
+ if (!dir) continue;
176
+ const file = path.join(dir, 'rp-barcode');
177
+ if (executableProblem(file) === null) return { path: file, source: 'PATH' };
178
+ }
179
+ problems.push('rp-barcode is not in PATH');
180
+ throw new EngineError('unavailable', `no rp-barcode engine (${problems.join('; ')})`);
181
+ }
182
+
183
+ const DEFAULTS = {
184
+ command: null, // null: resolveBinary() at every start (RP_BARCODE_ENGINE may change between starts)
185
+ args: ['serve'],
186
+ env: null, // null: process.env
187
+ maxQueue: 128, // requests in flight before `overloaded` (overview §4.3)
188
+ maxBufferedBytes: 256 << 20, // bytes still unwritten to the engine's stdin before `overloaded`: 256 MiB, the
189
+ // protocol's default maxPayload. An idle pipe (nothing unwritten) always takes one
190
+ // frame, whatever its size; the budget applies once bytes pile up, which means the
191
+ // engine is not reading (a timed-out request leaves `pending`, but its bytes stay in
192
+ // the pipe buffer until the engine takes them)
193
+ helloTimeoutMs: 5000,
194
+ defaultTimeoutMs: DEFAULT_TIMEOUT_MS, // timeoutMs of decode/verify/ping when not given (the verifier node passes its own 10 s)
195
+ backoff: { initialMs: 1000, maxMs: 30000 },
196
+ drainMs: 2000, // stop(): wait for the requests in flight
197
+ exitMs: 1000 // stop(): wait after shutdown, then after SIGTERM, before escalating
198
+ };
199
+
200
+ function exitDescription(code, signal) {
201
+ return signal ? `signal ${signal}` : `code ${code}`;
202
+ }
203
+
204
+ class Engine extends EventEmitter {
205
+ /**
206
+ * @param {object} [options] see DEFAULTS; `command` null resolves the binary at every start. Two gates make
207
+ * `overloaded`: `maxQueue` requests in flight and `maxBufferedBytes` not yet taken by the engine's stdin.
208
+ */
209
+ constructor(options = {}) {
210
+ super();
211
+ this.opts = { ...DEFAULTS, args: [...DEFAULTS.args], backoff: { ...DEFAULTS.backoff } };
212
+ this.child = null;
213
+ this.pid = null;
214
+ this.hello = null;
215
+ this.starting = null; // Promise<hello> while a start is in progress
216
+ this.stopping = null; // Promise<void> while a stop is in progress
217
+ this.pending = new Map(); // id → { resolve, reject, timer, op }
218
+ this.nextId = 1; // 0 belongs to the server (hello, bad_frame)
219
+ this.refs = 0; // nodes holding the shared engine (acquire/release)
220
+ this.stops = 0; // stop() calls so far: a start() that waited across one does not spawn
221
+ this.lastError = null; // why the last start failed or the last process went away
222
+ this.backoffMs = DEFAULTS.backoff.initialMs;
223
+ this.retryAt = 0;
224
+ this._exited = Promise.resolve(); // resolves when the current child is gone ('close')
225
+ this._idleWaiters = new Set();
226
+ this._killOnExit = () => {
227
+ if (this.child) {
228
+ try { this.child.kill('SIGKILL'); } catch (_) { /* already gone */ }
229
+ }
230
+ };
231
+ this.configure(options);
232
+ }
233
+
234
+ /** Merges options (backoff merged key by key) and resets the wait; returns this. */
235
+ configure(options) {
236
+ const { backoff, ...rest } = options || {};
237
+ for (const [key, value] of Object.entries(rest)) {
238
+ if (value !== undefined) this.opts[key] = value;
239
+ }
240
+ for (const [key, value] of Object.entries(backoff || {})) {
241
+ if (value !== undefined) this.opts.backoff[key] = value;
242
+ }
243
+ this.resetBackoff();
244
+ return this;
245
+ }
246
+
247
+ resetBackoff() {
248
+ this.backoffMs = this.opts.backoff.initialMs;
249
+ this.retryAt = 0;
250
+ }
251
+
252
+ get running() {
253
+ return this.child !== null && this.hello !== null;
254
+ }
255
+
256
+ /**
257
+ * Resolves with the hello header. Idempotent: a start in progress is shared, a running engine answers at once.
258
+ * Never two processes: a child on its way out is awaited first. Inside the wait after a failure it throws
259
+ * EngineError 'unavailable' without spawning; a failed spawn throws 'unavailable' or 'protocol' and doubles
260
+ * the wait (1 s → 30 s by default); a successful hello resets it. A stop() that lands while this start()
261
+ * waits (the last release(), typically) wins over the spawn this start() was going to make: it never spawns
262
+ * for a holder that let go, and throws 'exited' unless another holder started the engine meanwhile (then
263
+ * that engine serves the request).
264
+ */
265
+ async start() {
266
+ const stops = this.stops;
267
+ for (;;) {
268
+ if (this.running) return this.hello;
269
+ if (this.starting) return this.starting;
270
+ if (this.stopping) { await this.stopping; continue; }
271
+ if (this.child) { await this._exited; continue; } // a broken child is being killed
272
+ break;
273
+ }
274
+ if (this.stops !== stops) {
275
+ throw new EngineError('exited', 'engine stopped while this request waited for it to start');
276
+ }
277
+ const now = Date.now();
278
+ if (now < this.retryAt) {
279
+ const why = this.lastError ? this.lastError.message : 'the last start failed';
280
+ throw new EngineError('unavailable', `engine unavailable, retry in ${this.retryAt - now} ms (${why})`);
281
+ }
282
+ this.starting = this._spawn().then(
283
+ (hello) => {
284
+ this.starting = null;
285
+ this.resetBackoff();
286
+ return hello;
287
+ },
288
+ (err) => {
289
+ this.starting = null;
290
+ this._scheduleRetry(err);
291
+ throw err;
292
+ });
293
+ return this.starting;
294
+ }
295
+
296
+ _scheduleRetry(err) {
297
+ this.lastError = err;
298
+ this.retryAt = Date.now() + this.backoffMs;
299
+ this.backoffMs = Math.min(this.backoffMs * 2, this.opts.backoff.maxMs);
300
+ }
301
+
302
+ _spawn() {
303
+ return new Promise((resolve, reject) => {
304
+ let command = this.opts.command;
305
+ let source = 'command';
306
+ if (!command) {
307
+ try {
308
+ ({ path: command, source } = resolveBinary());
309
+ } catch (err) {
310
+ return reject(err);
311
+ }
312
+ }
313
+ let child;
314
+ try {
315
+ child = spawn(command, this.opts.args, { stdio: ['pipe', 'pipe', 'pipe'], env: this.opts.env || process.env });
316
+ } catch (err) {
317
+ return reject(new EngineError('unavailable', `cannot run ${command}: ${err.message}`));
318
+ }
319
+ let resolveExited;
320
+ this._exited = new Promise((r) => { resolveExited = r; });
321
+ this.child = child;
322
+ this.pid = child.pid || null;
323
+ this.hello = null;
324
+ process.on('exit', this._killOnExit);
325
+
326
+ let settled = false; // the start() promise has been resolved or rejected
327
+ let alive = true; // frames from this child still mean something
328
+ let saidHello = false;
329
+ let brokenErr = null; // why this child was declared broken (kept over the exit it provokes)
330
+ const settle = (fn, value) => {
331
+ if (settled) return;
332
+ settled = true;
333
+ clearTimeout(helloTimer);
334
+ fn(value);
335
+ };
336
+ // A broken engine (no hello, wrong protocol, bad frame): the start fails if still starting, the
337
+ // engine stops being `running` at once (no new request reaches it) and the child is killed.
338
+ const broken = (err) => {
339
+ if (!alive) return;
340
+ alive = false;
341
+ brokenErr = err;
342
+ if (this.child === child) this.hello = null;
343
+ settle(reject, err);
344
+ try { child.kill('SIGKILL'); } catch (_) { /* already gone */ }
345
+ };
346
+ const helloTimer = setTimeout(
347
+ () => broken(new EngineError('unavailable', `no hello from ${command} within ${this.opts.helloTimeoutMs} ms`)),
348
+ this.opts.helloTimeoutMs);
349
+
350
+ const onFrame = (header) => {
351
+ if (!saidHello) {
352
+ if (header.op !== 'hello' || header.id !== 0) {
353
+ return broken(new EngineError('protocol', `expected hello, got ${JSON.stringify(header).slice(0, 200)}`));
354
+ }
355
+ if (header.protocol !== PROTOCOL) {
356
+ return broken(new EngineError('protocol', `engine speaks protocol ${header.protocol}, this node needs ${PROTOCOL}`));
357
+ }
358
+ saidHello = true;
359
+ this.hello = header;
360
+ settle(resolve, header);
361
+ this.emit('started', { pid: child.pid, hello: header, source });
362
+ return;
363
+ }
364
+ if (header.id === 0) {
365
+ // Unprompted after the hello: only `bad_frame` before the server exits with status 3 (§3.1)
366
+ const e = (header.ok === false && header.error) || {};
367
+ return broken(new EngineError('protocol', `engine reported ${e.code || 'an error'}: ${e.message || JSON.stringify(header).slice(0, 200)}`));
368
+ }
369
+ this._onResponse(header);
370
+ };
371
+ // Replies carry no payload (§3.2): a frame with one, or anything that is not a frame, is a protocol violation
372
+ const parser = new FrameParser({ maxPayload: 0 });
373
+ parser.on('error', (err) => broken(new EngineError('protocol', `engine wrote a bad frame: ${err.message}`)));
374
+ parser.on('frame', (header) => {
375
+ if (!alive) return;
376
+ try {
377
+ onFrame(header);
378
+ } catch (err) {
379
+ // Never let a listener throw: FrameParser would drop the rest of the chunk. Route it to the request.
380
+ const entry = this._settle(header.id);
381
+ if (entry) entry.reject(err);
382
+ else process.emitWarning(err);
383
+ }
384
+ });
385
+ child.stdin.on('error', () => { /* EPIPE after the child died: the close handler rejects the requests */ });
386
+ child.stdout.on('data', (chunk) => parser.push(chunk));
387
+ child.stderr.setEncoding('utf8');
388
+ child.stderr.on('data', (text) => this.emit('stderr', text));
389
+ child.on('error', (err) => broken(new EngineError('unavailable', `cannot run ${command}: ${err.message}`)));
390
+ // 'close', not 'exit': it fires once stdout is drained too, so replies written before an orderly exit
391
+ // still reach their requests, and it is the one event a failed spawn (ENOENT) also emits.
392
+ child.on('close', (code, signal) => {
393
+ process.off('exit', this._killOnExit);
394
+ alive = false;
395
+ clearTimeout(helloTimer);
396
+ const mine = this.child === child;
397
+ if (mine) {
398
+ this.child = null;
399
+ this.hello = null;
400
+ this.pid = null;
401
+ }
402
+ const err = new EngineError('exited', `engine exited (${exitDescription(code, signal)})`);
403
+ if (!settled) {
404
+ settle(reject, new EngineError('unavailable', err.message));
405
+ } else if (saidHello && !this.stopping) {
406
+ this._scheduleRetry(brokenErr || err); // a running engine went away on its own: wait before restarting
407
+ }
408
+ if (mine) this._rejectAll(brokenErr || err);
409
+ resolveExited();
410
+ this.emit('exit', { code, signal });
411
+ });
412
+ });
413
+ }
414
+
415
+ /** Removes one pending request and returns its entry (undefined when unknown: a late reply, a timed-out id). */
416
+ _settle(id) {
417
+ const entry = this.pending.get(id);
418
+ if (!entry) return undefined;
419
+ this.pending.delete(id);
420
+ clearTimeout(entry.timer);
421
+ this._notifyIdle();
422
+ return entry;
423
+ }
424
+
425
+ _rejectAll(err) {
426
+ const entries = [...this.pending.values()];
427
+ this.pending.clear();
428
+ for (const entry of entries) {
429
+ clearTimeout(entry.timer);
430
+ entry.reject(err);
431
+ }
432
+ this._notifyIdle();
433
+ }
434
+
435
+ _notifyIdle() {
436
+ if (this.pending.size > 0 || this._idleWaiters.size === 0) return;
437
+ const waiters = [...this._idleWaiters];
438
+ this._idleWaiters.clear();
439
+ for (const done of waiters) done();
440
+ }
441
+
442
+ /** Resolves true when no request is pending, false after ms. */
443
+ _whenIdle(ms) {
444
+ if (this.pending.size === 0) return Promise.resolve(true);
445
+ return new Promise((resolve) => {
446
+ const done = () => {
447
+ clearTimeout(timer);
448
+ resolve(true);
449
+ };
450
+ const timer = setTimeout(() => {
451
+ this._idleWaiters.delete(done);
452
+ resolve(false);
453
+ }, ms);
454
+ this._idleWaiters.add(done);
455
+ });
456
+ }
457
+
458
+ // A reply takes the shape of the op it answers: decode/ping {symbols, image, timing}, verify/analyze
459
+ // {result, image, timing} (§3.5); errors are EngineError with the server's code and message verbatim
460
+ _onResponse(header) {
461
+ const entry = this._settle(header.id);
462
+ if (!entry) return; // a late reply of a timed-out request, or an id we never sent: discarded
463
+ if (header.ok !== true) {
464
+ const e = header.error || {};
465
+ entry.reject(new EngineError(typeof e.code === 'string' && e.code ? e.code : 'internal', e.message || 'engine error'));
466
+ return;
467
+ }
468
+ if (entry.op === 'verify' || entry.op === 'analyze') {
469
+ // a reply without a result object is a protocol violation for this request (the engine goes on)
470
+ if (!header.result || typeof header.result !== 'object' || Array.isArray(header.result)) {
471
+ entry.reject(new EngineError('protocol', `${entry.op} reply without a result object`));
472
+ return;
473
+ }
474
+ entry.resolve({ result: header.result, image: header.image || null, timing: header.timing || null });
475
+ return;
476
+ }
477
+ entry.resolve({
478
+ symbols: Array.isArray(header.symbols) ? header.symbols : [],
479
+ image: header.image || null,
480
+ timing: header.timing || null
481
+ });
482
+ }
483
+
484
+ _request(header, payload, timeoutMs) {
485
+ return new Promise((resolve, reject) => {
486
+ if (!this.running) return reject(new EngineError('exited', 'engine not running'));
487
+ if (this.pending.size >= this.opts.maxQueue) {
488
+ return reject(new EngineError('overloaded', `engine overloaded (${this.pending.size} requests in flight)`));
489
+ }
490
+ const id = this.nextId++;
491
+ let frame;
492
+ try {
493
+ frame = encodeFrame({ id, ...header }, payload);
494
+ } catch (err) {
495
+ return reject(new EngineError('invalid_input', err.message));
496
+ }
497
+ const bytes = frame[0].length + frame[1].length;
498
+ const buffered = this.child.stdin.writableLength;
499
+ if (buffered > 0 && buffered + bytes > this.opts.maxBufferedBytes) {
500
+ return reject(new EngineError('overloaded',
501
+ `engine overloaded (${buffered} bytes still unwritten, ${bytes} more would exceed ${this.opts.maxBufferedBytes})`));
502
+ }
503
+ const timer = setTimeout(() => {
504
+ if (this._settle(id)) reject(new EngineError('timeout', `engine timeout after ${timeoutMs} ms`));
505
+ }, timeoutMs);
506
+ this.pending.set(id, { resolve, reject, timer, op: header.op });
507
+ try {
508
+ this.child.stdin.write(frame[0]);
509
+ if (frame[1].length > 0) this.child.stdin.write(frame[1]);
510
+ } catch (err) {
511
+ if (this._settle(id)) reject(new EngineError('exited', `engine write failed: ${err.message}`));
512
+ }
513
+ });
514
+ }
515
+
516
+ /**
517
+ * image: {width, height, channels, colorSpace, encoding?} (encoding defaults to "raw"); payload: the packed
518
+ * rows (or the encoded file); options: decodeOptions (schema 1.0 §12.1, known keys only); timeoutMs (default
519
+ * defaultTimeoutMs) is the client's timer and also travels as deadlineMs, sent as an integer number of
520
+ * milliseconds (rounded up) and capped at 2^31 - 1 ms, setTimeout's ceiling. A payload over the engine's
521
+ * advertised limits.maxPayload is refused here with `invalid_input`, without being sent.
522
+ *
523
+ * @returns {Promise<{symbols: object[], image: {width: number, height: number}|null, timing: object|null}>}
524
+ */
525
+ async decode(image, payload, options = {}, { timeoutMs } = {}) {
526
+ const timeout = timerMs(timeoutMs, this.opts.defaultTimeoutMs);
527
+ if (!image || typeof image !== 'object') throw new EngineError('invalid_input', 'image must be an object');
528
+ if (!(payload instanceof Uint8Array)) throw new EngineError('invalid_input', 'payload must be a Buffer or Uint8Array');
529
+ if (this.pending.size >= this.opts.maxQueue) {
530
+ throw new EngineError('overloaded', `engine overloaded (${this.pending.size} requests in flight)`);
531
+ }
532
+ await this.start();
533
+ const limit = this.hello && this.hello.limits ? this.hello.limits.maxPayload : undefined;
534
+ if (typeof limit === 'number' && payload.length > limit) {
535
+ throw new EngineError('invalid_input', `payload of ${payload.length} bytes exceeds the engine limit of ${limit} bytes`);
536
+ }
537
+ const header = {
538
+ op: 'decode',
539
+ image: {
540
+ width: image.width, height: image.height, channels: image.channels,
541
+ colorSpace: image.colorSpace, encoding: image.encoding || 'raw'
542
+ },
543
+ options: options || {},
544
+ deadlineMs: timeout
545
+ };
546
+ return this._request(header, payload, timeout);
547
+ }
548
+
549
+ /**
550
+ * ISO/IEC 15416 verification (or analysis, with the per-scan detail) of the symbol in `region`: image and
551
+ * payload as decode(); region {x, y, w, h, orientation?} in image pixels, checked and rounded here
552
+ * (checkRegion: finite numbers → integers, w and h extents along the image axes whatever the orientation and
553
+ * at least 2 px, orientation the reading direction, an integer multiple of 90 normalised to 0/90/180/270,
554
+ * default 0, never rounded; an unknown key, a missing or non-finite coordinate, an orientation off a multiple
555
+ * of 90 is `invalid_input` without anything sent; the bounds against
556
+ * the image are the server's check); options the verifyOptions object (schema 1.0 §12.2, known keys only,
557
+ * every key optional: the server refuses unknown ones and values outside their vocabularies); acquisition the
558
+ * acquisition object (§12.3, calibration §12.4) or null (no physical scale, 660 nm, no calibration); analyze
559
+ * true sends `analyze` (the result carries `scans`). timeoutMs as decode() (default defaultTimeoutMs; the
560
+ * verifier node passes its own 10 s). An engine whose hello does not list the op (an SDK 0.2.0 built before
561
+ * 2D) is refused with `unsupported` before anything is sent. A `not_detected` status resolves like any other
562
+ * result: it is not an error.
563
+ *
564
+ * @returns {Promise<{result: object, image: {width: number, height: number}|null, timing: {totalMs: number, verifyMs: number, queuedMs: number}|null}>}
565
+ * `result` is the VerifyResult (or AnalysisResult) exactly as the SDK writes it, `report` included
566
+ */
567
+ async verify(image, payload, { region, options = {}, acquisition = null, analyze = false } = {}, { timeoutMs } = {}) {
568
+ const timeout = timerMs(timeoutMs, this.opts.defaultTimeoutMs);
569
+ if (!image || typeof image !== 'object') throw new EngineError('invalid_input', 'image must be an object');
570
+ if (!(payload instanceof Uint8Array)) throw new EngineError('invalid_input', 'payload must be a Buffer or Uint8Array');
571
+ const rect = checkRegion(region);
572
+ if (this.pending.size >= this.opts.maxQueue) {
573
+ throw new EngineError('overloaded', `engine overloaded (${this.pending.size} requests in flight)`);
574
+ }
575
+ const op = analyze ? 'analyze' : 'verify';
576
+ const hello = await this.start(); // the hello of the engine that takes this request (not this.hello: it may have died since)
577
+ const ops = Array.isArray(hello.ops) ? hello.ops : [];
578
+ if (!ops.includes(op)) {
579
+ throw new EngineError('unsupported', `engine ${hello.engineVersion || ''} does not support ${op} (ops: ${ops.join(', ')}); ` +
580
+ 'an rp-barcode of SDK >= 0.2.0 with the verify service (2D) is required');
581
+ }
582
+ const limit = hello.limits ? hello.limits.maxPayload : undefined;
583
+ if (typeof limit === 'number' && payload.length > limit) {
584
+ throw new EngineError('invalid_input', `payload of ${payload.length} bytes exceeds the engine limit of ${limit} bytes`);
585
+ }
586
+ const header = {
587
+ op,
588
+ image: {
589
+ width: image.width, height: image.height, channels: image.channels,
590
+ colorSpace: image.colorSpace, encoding: image.encoding || 'raw'
591
+ },
592
+ region: rect,
593
+ options: options || {}, // the caller's keys as they are: an unknown one is the server's invalid_input, never dropped here
594
+ deadlineMs: timeout
595
+ };
596
+ if (acquisition) header.acquisition = acquisition;
597
+ return this._request(header, payload, timeout);
598
+ }
599
+
600
+ /** A liveness probe the server answers without queueing; resolves like an empty decode. */
601
+ async ping({ timeoutMs } = {}) {
602
+ await this.start();
603
+ return this._request({ op: 'ping' }, EMPTY, timerMs(timeoutMs, this.opts.defaultTimeoutMs));
604
+ }
605
+
606
+ /**
607
+ * Drain (≤ drainMs) → shutdown → SIGTERM → SIGKILL, exitMs between the steps (≤ 4 s with the defaults; one more
608
+ * exitMs only when something else keeps the child's pipes open after the SIGKILL).
609
+ * Resolves when the child is gone; whatever was still pending rejects with `exited`. Idempotent; a no-op
610
+ * when nothing runs.
611
+ */
612
+ async stop() {
613
+ this.stops += 1; // counted even when there is nothing to stop: a waiting start() must see it
614
+ if (this.stopping) return this.stopping;
615
+ if (this.starting) {
616
+ try { await this.starting; } catch (_) { /* the start failed: the child, if any, is being killed */ }
617
+ if (this.stopping) return this.stopping; // another stop() got here first during that wait
618
+ }
619
+ const child = this.child;
620
+ if (!child) return;
621
+ this.stopping = this._stop(child).finally(() => { this.stopping = null; });
622
+ return this.stopping;
623
+ }
624
+
625
+ async _stop(child) {
626
+ const exited = this._exited;
627
+ // Let the decodes that awaited the same start() send their frames before counting what is in flight
628
+ await new Promise((resolve) => setImmediate(resolve));
629
+ await this._whenIdle(this.opts.drainMs);
630
+ const gone = (ms) => new Promise((resolve) => {
631
+ const timer = setTimeout(() => resolve(false), ms);
632
+ exited.then(() => {
633
+ clearTimeout(timer);
634
+ resolve(true);
635
+ });
636
+ });
637
+ if (this.child === child && this.hello !== null) {
638
+ try { child.stdin.write(encodeFrame({ id: this.nextId++, op: 'shutdown' })[0]); } catch (_) { /* dead pipe */ }
639
+ }
640
+ if (!(await gone(this.opts.exitMs))) {
641
+ try { child.kill('SIGTERM'); } catch (_) { /* already gone */ }
642
+ if (!(await gone(this.opts.exitMs))) {
643
+ try { child.kill('SIGKILL'); } catch (_) { /* already gone */ }
644
+ if (!(await gone(this.opts.exitMs))) {
645
+ // 'close' also waits for every holder of the pipes (a wrapper that forked the binary): let ours go
646
+ for (const stream of [child.stdin, child.stdout, child.stderr]) stream.destroy();
647
+ await exited;
648
+ }
649
+ }
650
+ }
651
+ this._rejectAll(new EngineError('exited', 'engine stopped'));
652
+ }
653
+ }
654
+
655
+ // One engine per Node-RED process (D5), started lazily by the first decode or verify, stopped by the last release()
656
+ let shared = null;
657
+ function getEngine() {
658
+ if (!shared) shared = new Engine();
659
+ return shared;
660
+ }
661
+ function setEngineOptions(options) {
662
+ return getEngine().configure(options);
663
+ }
664
+ /** refs + 1; returns the shared engine (started lazily by the first decode or verify). */
665
+ function acquire() {
666
+ const engine = getEngine();
667
+ engine.refs += 1;
668
+ return engine;
669
+ }
670
+ /**
671
+ * refs - 1; the last release() stops the shared engine. After its release() a holder must not call decode(),
672
+ * verify() or ping() on the engine: a request after the last release starts a process that no release will stop
673
+ * (it lives until the runtime exits).
674
+ */
675
+ async function release() {
676
+ const engine = getEngine();
677
+ engine.refs = Math.max(0, engine.refs - 1);
678
+ if (engine.refs === 0) await engine.stop();
679
+ }
680
+
681
+ module.exports = {
682
+ PROTOCOL, INSTALL_HINT, DEFAULT_TIMEOUT_MS, ENGINE_PACKAGES, DEFAULTS,
683
+ EngineError, Engine, resolveBinary, getEngine, setEngineOptions, acquire, release
684
+ };