@jetforge/zebra-bridge 1.0.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/README.md ADDED
@@ -0,0 +1,144 @@
1
+ # Zebra Bridge for JetForge
2
+
3
+ Bridge service for one Zebra handheld barcode scanner connected over a serial COM port. It reads each scan from the configured serial port and sends the scanned value to JetForge as the plain bridge command `barcode`.
4
+
5
+ The scanner does not need to send a newline. The bridge uses an inter-byte idle timeout to treat one burst of bytes as one scanned value.
6
+
7
+ ## Running
8
+
9
+ Install dependencies:
10
+
11
+ ```bash
12
+ npm install
13
+ ```
14
+
15
+ PowerShell example for the current scanner on `COM11`:
16
+
17
+ ```powershell
18
+ $env:BRIDGE_TOKEN="jf_..."
19
+ $env:SERIAL_PATH="COM11"
20
+ npm start
21
+ ```
22
+
23
+ Supported environment variables:
24
+
25
+ | Variable | Default | Meaning |
26
+ | --- | --- | --- |
27
+ | `BRIDGE_TOKEN` | required | JetForge bridge endpoint token |
28
+ | `SERIAL_PATH` | required | Serial port path, for example `COM11` |
29
+ | `SERIAL_BAUD_RATE` | `9600` | Serial baud rate |
30
+
31
+ All other bridge settings are fixed in code:
32
+
33
+ | Setting | Value |
34
+ | --- | --- |
35
+ | HTTP base URL | `https://my.jetforge.app` |
36
+ | WebSocket URL | `wss://my.jetforge.app/bridge-ws/` |
37
+ | Scan idle timeout | `75ms` |
38
+ | Scan max buffer size | `2048` bytes |
39
+ | Barcode queue max size | `500` |
40
+ | Barcode queue retry interval | `5000ms` |
41
+ | Log barcode values | `false` |
42
+
43
+ List detected serial ports:
44
+
45
+ ```bash
46
+ npm run list-ports
47
+ ```
48
+
49
+ Simulate one scan without opening a serial port:
50
+
51
+ ```bash
52
+ node src/server.js --simulate ABC123
53
+ ```
54
+
55
+ ## Bridge Protocol
56
+
57
+ The bridge authenticates to JetForge bridge websocket with:
58
+
59
+ ```http
60
+ Authorization: Bearer jf_<endpoint_id>_<secret>
61
+ ```
62
+
63
+ After `bridge.init`, it sends periodic `heartbeat` messages. For each scan it sends:
64
+
65
+ ```json
66
+ {
67
+ "type": "barcode",
68
+ "payload": {
69
+ "value": "ABC123",
70
+ "time": 1781860000
71
+ }
72
+ }
73
+ ```
74
+
75
+ If the WebSocket is unavailable, the bridge falls back to `POST /ips/bridge/` with:
76
+
77
+ ```json
78
+ {
79
+ "command": "barcode",
80
+ "payload": {
81
+ "value": "ABC123",
82
+ "time": 1781860000
83
+ }
84
+ }
85
+ ```
86
+
87
+ Runtime diagnostics are appended to `logs/bridge-debug.log`. Bridge tokens are redacted. Barcode values are not logged.
88
+
89
+ ## JetForge Server Update Needed
90
+
91
+ This repository does not modify JetForge. The Zebra endpoint on the JetForge side should handle the bridge command `barcode`, then publish the existing UI websocket event `scanner.barcode`.
92
+
93
+ Suggested shape for `Oms\Models\Bridge\Vendor\Zebra\Scanner`:
94
+
95
+ ```php
96
+ public function receiveCommand(string $command, array $payload = []): array
97
+ {
98
+ if ($command === "barcode")
99
+ {
100
+ return $this->receiveBarcode($payload);
101
+ }
102
+
103
+ return parent::receiveCommand($command, $payload);
104
+ }
105
+
106
+ private function receiveBarcode(array $payload): array
107
+ {
108
+ $this->attendedAt = time();
109
+ $this->update();
110
+
111
+ $value = trim((string) ($payload["value"] ?? ""));
112
+ if ($value === "")
113
+ {
114
+ throw new \InvalidArgumentException("Barcode value is not specified");
115
+ }
116
+
117
+ $time = (int) ($payload["time"] ?? time());
118
+
119
+ \Oms\Models\Bridge\Log::write($this, null, \Oms\Models\Bridge\LogDirection::Incoming, "barcode", [
120
+ "value" => $value,
121
+ "time" => $time,
122
+ ]);
123
+
124
+ $event = [
125
+ "type" => "scanner.barcode",
126
+ "farm_id" => $this->farmId,
127
+ "payload" => [
128
+ "value" => $value,
129
+ "scanner_id" => $this->id,
130
+ "time" => $time,
131
+ ],
132
+ ];
133
+
134
+ $json = json_encode($event, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
135
+ \__::$sql->query("NOTIFY ui_ws_event, " . \__::$sql->dbo->wrapScalar($json));
136
+
137
+ return [
138
+ "status" => "ok",
139
+ "value" => $value,
140
+ ];
141
+ }
142
+ ```
143
+
144
+ The bridge command stays unscoped as `barcode`; only the UI websocket event remains `scanner.barcode`.
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@jetforge/zebra-bridge",
3
+ "version": "1.0.0",
4
+ "description": "Zebra barcode scanner bridge for JetForge",
5
+ "main": "src/server.js",
6
+ "bin": {
7
+ "zebra-bridge": "src/server.js"
8
+ },
9
+ "author": "Smart Dynamics SIA",
10
+ "type": "module",
11
+ "files": [
12
+ "src"
13
+ ],
14
+ "license": "MIT",
15
+ "scripts": {
16
+ "start": "node src/server.js",
17
+ "list-ports": "node src/server.js --list-ports"
18
+ },
19
+ "dependencies": {
20
+ "@serialport/parser-inter-byte-timeout": "^12.0.0",
21
+ "axios": "^1.7.2",
22
+ "serialport": "^12.0.0",
23
+ "ws": "^8.18.0"
24
+ }
25
+ }
@@ -0,0 +1,297 @@
1
+ import axios from "axios";
2
+ import WebSocket from "ws";
3
+
4
+ const heartbeatInterval = 30000;
5
+ const reconnectMinDelay = 1000;
6
+ const reconnectMaxDelay = 30000;
7
+
8
+ export default class JetForgeBridgeClient {
9
+ constructor({token, httpBaseUrl, wsUrl, onCommand, onReady, logger}) {
10
+ if (!token) {
11
+ throw new Error("BRIDGE_TOKEN is required");
12
+ }
13
+
14
+ this.token = token;
15
+ this.httpBaseUrl = httpBaseUrl;
16
+ this.wsUrl = wsUrl;
17
+ this.onCommand = onCommand;
18
+ this.onReady = onReady;
19
+ this.ws = null;
20
+ this.heartbeatTimer = null;
21
+ this.reconnectTimer = null;
22
+ this.reconnectDelay = reconnectMinDelay;
23
+ this.shouldReconnect = true;
24
+ this.logger = logger;
25
+
26
+ this.axiosInstance = axios.create({
27
+ baseURL: httpBaseUrl,
28
+ timeout: 30000,
29
+ headers: {
30
+ Authorization: `Bearer ${token}`,
31
+ "Content-Type": "application/json"
32
+ }
33
+ });
34
+ }
35
+
36
+ start() {
37
+ this.connect();
38
+ }
39
+
40
+ connect() {
41
+ if (this.ws && [WebSocket.CONNECTING, WebSocket.OPEN].includes(this.ws.readyState)) {
42
+ return;
43
+ }
44
+
45
+ this.log("info", "endpoint.websocket.connecting", {
46
+ wsUrl: this.wsUrl
47
+ });
48
+
49
+ this.ws = new WebSocket(this.wsUrl, {
50
+ headers: {
51
+ Authorization: `Bearer ${this.token}`
52
+ },
53
+ handshakeTimeout: 15000
54
+ });
55
+
56
+ this.ws.on("open", () => {
57
+ this.reconnectDelay = reconnectMinDelay;
58
+ this.log("info", "endpoint.websocket.opened");
59
+ });
60
+
61
+ this.ws.on("message", data => {
62
+ this.handleMessage(data).catch(error => {
63
+ this.log("error", "endpoint.websocket.message_handler_failed", {
64
+ error
65
+ });
66
+ });
67
+ });
68
+
69
+ this.ws.on("close", (code, reason) => {
70
+ const reasonText = reason?.toString?.() || "";
71
+ this.log("error", "endpoint.websocket.closed", {
72
+ code,
73
+ reason: reasonText
74
+ });
75
+ this.stopHeartbeat();
76
+ this.ws = null;
77
+ this.scheduleReconnect();
78
+ });
79
+
80
+ this.ws.on("error", error => {
81
+ this.log("error", "endpoint.websocket.error", {
82
+ error
83
+ });
84
+ });
85
+ }
86
+
87
+ async handleMessage(data) {
88
+ let message;
89
+
90
+ try {
91
+ message = JSON.parse(data.toString());
92
+ } catch (error) {
93
+ this.log("error", "endpoint.websocket.invalid_json", {
94
+ error
95
+ });
96
+ return;
97
+ }
98
+
99
+ if (message.type === "bridge.init") {
100
+ this.log("info", "endpoint.initialized", {
101
+ endpointId: message.endpoint_id,
102
+ serverTime: message.time
103
+ });
104
+ this.startHeartbeat();
105
+ await this.onReady?.(message);
106
+ return;
107
+ }
108
+
109
+ if (message.type === "response") {
110
+ this.log("info", "endpoint.websocket.response", {
111
+ id: message.id ?? null
112
+ });
113
+ return;
114
+ }
115
+
116
+ if (message.type === "error") {
117
+ this.log("error", "endpoint.websocket.backend_error", {
118
+ message: message.message || "unknown error",
119
+ id: message.id ?? null
120
+ });
121
+ return;
122
+ }
123
+
124
+ if (message.type !== "command" || !message.id) {
125
+ this.log("info", "endpoint.websocket.service_message_ignored", {
126
+ type: message.type || "unknown"
127
+ });
128
+ return;
129
+ }
130
+
131
+ await this.onCommand?.(message);
132
+ }
133
+
134
+ startHeartbeat() {
135
+ this.stopHeartbeat();
136
+ this.sendHeartbeat();
137
+ this.heartbeatTimer = setInterval(() => {
138
+ this.sendHeartbeat();
139
+ }, heartbeatInterval);
140
+ }
141
+
142
+ stopHeartbeat() {
143
+ if (this.heartbeatTimer) {
144
+ clearInterval(this.heartbeatTimer);
145
+ this.heartbeatTimer = null;
146
+ }
147
+ }
148
+
149
+ sendHeartbeat() {
150
+ this.sendOverWebSocket({
151
+ type: "heartbeat",
152
+ payload: {}
153
+ }).catch(error => {
154
+ this.log("error", "endpoint.heartbeat.failed", {
155
+ error
156
+ });
157
+ });
158
+ }
159
+
160
+ scheduleReconnect() {
161
+ if (!this.shouldReconnect || this.reconnectTimer) {
162
+ return;
163
+ }
164
+
165
+ const delay = this.reconnectDelay;
166
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, reconnectMaxDelay);
167
+
168
+ this.log("info", "endpoint.websocket.reconnect_scheduled", {
169
+ delay
170
+ });
171
+
172
+ this.reconnectTimer = setTimeout(() => {
173
+ this.reconnectTimer = null;
174
+ this.connect();
175
+ }, delay);
176
+ }
177
+
178
+ sendOverWebSocket(message) {
179
+ return new Promise((resolve, reject) => {
180
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
181
+ reject(new Error("JetForge bridge websocket is not open"));
182
+ return;
183
+ }
184
+
185
+ this.ws.send(JSON.stringify(message), error => {
186
+ if (error) {
187
+ reject(error);
188
+ return;
189
+ }
190
+
191
+ resolve();
192
+ });
193
+ });
194
+ }
195
+
196
+ async postBridgeCommand(command, payload = {}) {
197
+ const response = await this.axiosInstance.post("/ips/bridge/", {
198
+ command,
199
+ payload
200
+ });
201
+
202
+ return response.data;
203
+ }
204
+
205
+ async sendBridgeMessage(type, payload = {}) {
206
+ try {
207
+ await this.sendOverWebSocket({
208
+ type,
209
+ payload
210
+ });
211
+
212
+ return {
213
+ transport: "websocket"
214
+ };
215
+ } catch (websocketError) {
216
+ this.log("error", "endpoint.message.websocket_failed", {
217
+ type,
218
+ error: websocketError
219
+ });
220
+
221
+ const response = await this.postBridgeCommand(type, payload);
222
+
223
+ return {
224
+ transport: "http",
225
+ response
226
+ };
227
+ }
228
+ }
229
+
230
+ sendBarcode(value, {time = Math.floor(Date.now() / 1000)} = {}) {
231
+ return this.sendBridgeMessage("barcode", {
232
+ value,
233
+ time
234
+ });
235
+ }
236
+
237
+ async sendCommandStatus(type, commandId, payload = {}) {
238
+ const statusPayload = {
239
+ command_id: commandId,
240
+ ...payload
241
+ };
242
+
243
+ try {
244
+ await this.sendOverWebSocket({
245
+ type,
246
+ payload: statusPayload
247
+ });
248
+ } catch (error) {
249
+ this.log("error", "endpoint.command_status.websocket_failed", {
250
+ type,
251
+ commandId,
252
+ error
253
+ });
254
+ await this.postBridgeCommand(type, statusPayload);
255
+ }
256
+ }
257
+
258
+ ackCommand(commandId) {
259
+ return this.sendCommandStatus("command.ack", commandId);
260
+ }
261
+
262
+ failCommand(commandId, error) {
263
+ return this.sendCommandStatus("command.failed", commandId, {
264
+ error: error instanceof Error ? error.message : String(error || "Command failed")
265
+ });
266
+ }
267
+
268
+ close() {
269
+ this.shouldReconnect = false;
270
+ this.stopHeartbeat();
271
+
272
+ if (this.reconnectTimer) {
273
+ clearTimeout(this.reconnectTimer);
274
+ this.reconnectTimer = null;
275
+ }
276
+
277
+ if (this.ws) {
278
+ this.ws.close();
279
+ this.ws = null;
280
+ }
281
+ }
282
+
283
+ log(level, event, data = {}) {
284
+ if (this.logger) {
285
+ this.logger[level]?.(event, data);
286
+ return;
287
+ }
288
+
289
+ const message = `${level.toUpperCase()} ${event}`;
290
+ if (level === "error") {
291
+ console.error(message, data);
292
+ return;
293
+ }
294
+
295
+ console.log(message, data);
296
+ }
297
+ }
package/src/logger.js ADDED
@@ -0,0 +1,70 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+
4
+ export default class Logger {
5
+ constructor({filePath = path.resolve(process.cwd(), "logs", "bridge-debug.log")} = {}) {
6
+ this.filePath = filePath;
7
+ fs.mkdirSync(path.dirname(this.filePath), {recursive: true});
8
+ }
9
+
10
+ info(event, data = {}) {
11
+ this.write("info", event, data);
12
+ }
13
+
14
+ error(event, data = {}) {
15
+ this.write("error", event, data);
16
+ }
17
+
18
+ write(level, event, data = {}) {
19
+ const entry = {
20
+ ...sanitize(data),
21
+ time: new Date().toISOString(),
22
+ level,
23
+ event
24
+ };
25
+
26
+ const line = JSON.stringify(entry);
27
+ fs.appendFileSync(this.filePath, `${line}\n`);
28
+
29
+ const message = `[${entry.time}] ${level.toUpperCase()} ${event}`;
30
+ if (level === "error") {
31
+ console.error(message, sanitize(data));
32
+ return;
33
+ }
34
+
35
+ console.log(message, sanitize(data));
36
+ }
37
+ }
38
+
39
+ function sanitize(value) {
40
+ if (value instanceof Error) {
41
+ return {
42
+ message: value.message,
43
+ stack: value.stack
44
+ };
45
+ }
46
+
47
+ if (Array.isArray(value)) {
48
+ return value.map(sanitize);
49
+ }
50
+
51
+ if (!value || typeof value !== "object") {
52
+ return value;
53
+ }
54
+
55
+ const result = {};
56
+ for (const [key, item] of Object.entries(value)) {
57
+ if (isSecretKey(key)) {
58
+ result[key] = "[redacted]";
59
+ continue;
60
+ }
61
+
62
+ result[key] = sanitize(item);
63
+ }
64
+
65
+ return result;
66
+ }
67
+
68
+ function isSecretKey(key) {
69
+ return /token|secret|password|authorization/i.test(key);
70
+ }
@@ -0,0 +1,187 @@
1
+ import {InterByteTimeoutParser} from "@serialport/parser-inter-byte-timeout";
2
+ import {SerialPort} from "serialport";
3
+
4
+ const reconnectMinDelay = 1000;
5
+ const reconnectMaxDelay = 10000;
6
+
7
+ export default class SerialScanner {
8
+ constructor({path, baudRate, idleMs, maxBufferSize, onBarcode, logger, logBarcodeValue = false}) {
9
+ if (!path) {
10
+ throw new Error("SERIAL_PATH is required");
11
+ }
12
+
13
+ this.path = path;
14
+ this.baudRate = baudRate;
15
+ this.idleMs = idleMs;
16
+ this.maxBufferSize = maxBufferSize;
17
+ this.onBarcode = onBarcode;
18
+ this.logger = logger;
19
+ this.logBarcodeValue = logBarcodeValue;
20
+ this.port = null;
21
+ this.parser = null;
22
+ this.reconnectTimer = null;
23
+ this.reconnectDelay = reconnectMinDelay;
24
+ this.shouldReconnect = true;
25
+ }
26
+
27
+ static listPorts() {
28
+ return SerialPort.list();
29
+ }
30
+
31
+ start() {
32
+ this.shouldReconnect = true;
33
+ this.open();
34
+ }
35
+
36
+ open() {
37
+ if (this.port?.isOpen) {
38
+ return;
39
+ }
40
+
41
+ this.log("info", "scanner.serial.opening", {
42
+ path: this.path,
43
+ baudRate: this.baudRate,
44
+ idleMs: this.idleMs
45
+ });
46
+
47
+ const port = new SerialPort({
48
+ path: this.path,
49
+ baudRate: this.baudRate,
50
+ autoOpen: false
51
+ });
52
+
53
+ this.port = port;
54
+ this.parser = port.pipe(new InterByteTimeoutParser({
55
+ interval: this.idleMs,
56
+ maxBufferSize: this.maxBufferSize
57
+ }));
58
+
59
+ this.parser.on("data", data => this.handleData(data));
60
+
61
+ port.on("error", error => {
62
+ this.log("error", "scanner.serial.error", {
63
+ path: this.path,
64
+ error
65
+ });
66
+ });
67
+
68
+ port.on("close", () => {
69
+ this.log("error", "scanner.serial.closed", {
70
+ path: this.path
71
+ });
72
+ this.port = null;
73
+ this.parser = null;
74
+ this.scheduleReconnect();
75
+ });
76
+
77
+ port.open(error => {
78
+ if (error) {
79
+ this.log("error", "scanner.serial.open_failed", {
80
+ path: this.path,
81
+ error
82
+ });
83
+ this.port = null;
84
+ this.parser = null;
85
+ this.scheduleReconnect();
86
+ return;
87
+ }
88
+
89
+ this.reconnectDelay = reconnectMinDelay;
90
+ this.log("info", "scanner.serial.opened", {
91
+ path: this.path,
92
+ baudRate: this.baudRate
93
+ });
94
+ });
95
+ }
96
+
97
+ handleData(data) {
98
+ const value = normalizeBarcode(data);
99
+
100
+ if (!value) {
101
+ this.log("info", "scanner.barcode.empty_ignored", {
102
+ byteLength: data.length
103
+ });
104
+ return;
105
+ }
106
+
107
+ this.log("info", "scanner.barcode.read", {
108
+ byteLength: data.length,
109
+ length: value.length,
110
+ ...(this.logBarcodeValue ? {value} : {})
111
+ });
112
+
113
+ Promise.resolve(this.onBarcode?.(value, {
114
+ byteLength: data.length
115
+ })).catch(error => {
116
+ this.log("error", "scanner.barcode.handler_failed", {
117
+ error
118
+ });
119
+ });
120
+ }
121
+
122
+ scheduleReconnect() {
123
+ if (!this.shouldReconnect || this.reconnectTimer) {
124
+ return;
125
+ }
126
+
127
+ const delay = this.reconnectDelay;
128
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, reconnectMaxDelay);
129
+
130
+ this.log("info", "scanner.serial.reconnect_scheduled", {
131
+ path: this.path,
132
+ delay
133
+ });
134
+
135
+ this.reconnectTimer = setTimeout(() => {
136
+ this.reconnectTimer = null;
137
+ this.open();
138
+ }, delay);
139
+ }
140
+
141
+ close() {
142
+ this.shouldReconnect = false;
143
+
144
+ if (this.reconnectTimer) {
145
+ clearTimeout(this.reconnectTimer);
146
+ this.reconnectTimer = null;
147
+ }
148
+
149
+ if (!this.port) {
150
+ return;
151
+ }
152
+
153
+ const port = this.port;
154
+ this.port = null;
155
+ this.parser = null;
156
+
157
+ if (port.isOpen) {
158
+ port.close();
159
+ return;
160
+ }
161
+
162
+ port.destroy?.();
163
+ }
164
+
165
+ log(level, event, data = {}) {
166
+ if (this.logger) {
167
+ this.logger[level]?.(event, data);
168
+ return;
169
+ }
170
+
171
+ const message = `${level.toUpperCase()} ${event}`;
172
+ if (level === "error") {
173
+ console.error(message, data);
174
+ return;
175
+ }
176
+
177
+ console.log(message, data);
178
+ }
179
+ }
180
+
181
+ export function normalizeBarcode(data) {
182
+ return data
183
+ .toString("utf8")
184
+ .replace(/\0/g, "")
185
+ .replace(/[\r\n]+$/g, "")
186
+ .trim();
187
+ }
package/src/server.js ADDED
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+
3
+ import Worker from "./worker.js";
4
+ import SerialScanner from "./serial-scanner.js";
5
+ import JetForgeBridgeClient from "./jetforge-bridge-client.js";
6
+ import Logger from "./logger.js";
7
+
8
+ const httpBaseUrl = "https://my.jetforge.app";
9
+ const wsUrl = "wss://my.jetforge.app/bridge-ws/";
10
+ const defaultSerialBaudRate = 9600;
11
+ const scanIdleMs = 75;
12
+ const scanMaxBufferSize = 2048;
13
+ const queueMaxSize = 500;
14
+ const queueRetryMs = 5000;
15
+ const logBarcodeValue = false;
16
+
17
+ const args = process.argv.slice(2);
18
+
19
+ if (args.includes("--list-ports")) {
20
+ const ports = await SerialScanner.listPorts();
21
+ console.log(JSON.stringify(ports, null, 2));
22
+ process.exit(0);
23
+ }
24
+
25
+ if (args.includes("--simulate")) {
26
+ const value = args[args.indexOf("--simulate") + 1];
27
+ if (!value) {
28
+ console.error("Usage: zebra-bridge --simulate <barcode-value>");
29
+ process.exit(1);
30
+ }
31
+
32
+ const settings = readSettings({requireSerial: false});
33
+ const logger = new Logger();
34
+ const bridge = new JetForgeBridgeClient({
35
+ token: settings.token,
36
+ httpBaseUrl: settings.httpBaseUrl,
37
+ wsUrl: settings.wsUrl,
38
+ logger
39
+ });
40
+
41
+ try {
42
+ const result = await bridge.sendBarcode(value);
43
+ logger.info("scanner.barcode.simulated", {
44
+ transport: result.transport
45
+ });
46
+ } finally {
47
+ bridge.close();
48
+ }
49
+
50
+ process.exit(0);
51
+ }
52
+
53
+ const worker = new Worker(readSettings());
54
+
55
+ for (const signal of ["SIGINT", "SIGTERM"]) {
56
+ process.on(signal, () => {
57
+ worker.close();
58
+ process.exit(0);
59
+ });
60
+ }
61
+
62
+ function readSettings({requireSerial = true} = {}) {
63
+ const token = process.env.BRIDGE_TOKEN;
64
+ const serialPath = process.env.SERIAL_PATH || "";
65
+
66
+ if (requireSerial && !serialPath) {
67
+ throw new Error("SERIAL_PATH is required, for example SERIAL_PATH=COM11");
68
+ }
69
+
70
+ return {
71
+ httpBaseUrl,
72
+ wsUrl,
73
+ token,
74
+ serialPath,
75
+ serialBaudRate: readInteger("SERIAL_BAUD_RATE", defaultSerialBaudRate),
76
+ scanIdleMs,
77
+ scanMaxBufferSize,
78
+ queueMaxSize,
79
+ queueRetryMs,
80
+ logBarcodeValue
81
+ };
82
+ }
83
+
84
+ function readInteger(name, defaultValue) {
85
+ const rawValue = process.env[name];
86
+ if (rawValue === undefined || rawValue === "") {
87
+ return defaultValue;
88
+ }
89
+
90
+ const value = Number(rawValue);
91
+ if (!Number.isInteger(value) || value <= 0) {
92
+ throw new Error(`${name} must be a positive integer`);
93
+ }
94
+
95
+ return value;
96
+ }
package/src/worker.js ADDED
@@ -0,0 +1,151 @@
1
+ import JetForgeBridgeClient from "./jetforge-bridge-client.js";
2
+ import Logger from "./logger.js";
3
+ import SerialScanner from "./serial-scanner.js";
4
+
5
+ export default class Worker {
6
+ constructor(settings) {
7
+ this.settings = settings;
8
+ this.logger = new Logger();
9
+ this.queue = [];
10
+ this.isFlushing = false;
11
+ this.retryTimer = null;
12
+
13
+ this.bridge = new JetForgeBridgeClient({
14
+ token: settings.token,
15
+ httpBaseUrl: settings.httpBaseUrl,
16
+ wsUrl: settings.wsUrl,
17
+ onCommand: message => this.handleCommand(message),
18
+ onReady: () => this.flushQueue(),
19
+ logger: this.logger
20
+ });
21
+
22
+ this.scanner = new SerialScanner({
23
+ path: settings.serialPath,
24
+ baudRate: settings.serialBaudRate,
25
+ idleMs: settings.scanIdleMs,
26
+ maxBufferSize: settings.scanMaxBufferSize,
27
+ logBarcodeValue: settings.logBarcodeValue,
28
+ onBarcode: value => this.handleBarcode(value),
29
+ logger: this.logger
30
+ });
31
+
32
+ this.logger.info("bridge.worker.started", {
33
+ httpBaseUrl: settings.httpBaseUrl,
34
+ wsUrl: settings.wsUrl,
35
+ serialPath: settings.serialPath,
36
+ serialBaudRate: settings.serialBaudRate,
37
+ scanIdleMs: settings.scanIdleMs,
38
+ scanMaxBufferSize: settings.scanMaxBufferSize
39
+ });
40
+
41
+ this.bridge.start();
42
+ this.scanner.start();
43
+
44
+ this.retryTimer = setInterval(() => {
45
+ this.flushQueue();
46
+ }, settings.queueRetryMs);
47
+ }
48
+
49
+ async handleCommand(message) {
50
+ const commandId = message.id;
51
+ const command = message.command;
52
+
53
+ this.logger.info("endpoint.command.received", {
54
+ command,
55
+ commandId
56
+ });
57
+
58
+ try {
59
+ await this.bridge.ackCommand(commandId);
60
+ } catch (error) {
61
+ this.logger.error("endpoint.command.ack_failed", {
62
+ command,
63
+ commandId,
64
+ error
65
+ });
66
+ }
67
+
68
+ const error = new Error(`Unsupported Zebra bridge command: ${command}`);
69
+ this.logger.error("endpoint.command.failed", {
70
+ command,
71
+ commandId,
72
+ error
73
+ });
74
+ await this.bridge.failCommand(commandId, error);
75
+ }
76
+
77
+ handleBarcode(value) {
78
+ const item = {
79
+ value,
80
+ time: Math.floor(Date.now() / 1000),
81
+ attempts: 0
82
+ };
83
+
84
+ if (this.queue.length >= this.settings.queueMaxSize) {
85
+ const dropped = this.queue.shift();
86
+ this.logger.error("scanner.barcode.queue_full_dropped_oldest", {
87
+ droppedLength: dropped?.value?.length ?? 0,
88
+ queueMaxSize: this.settings.queueMaxSize
89
+ });
90
+ }
91
+
92
+ this.queue.push(item);
93
+ this.logger.info("scanner.barcode.queued", {
94
+ length: value.length,
95
+ queueSize: this.queue.length,
96
+ ...(this.settings.logBarcodeValue ? {value} : {})
97
+ });
98
+
99
+ this.flushQueue();
100
+ }
101
+
102
+ async flushQueue() {
103
+ if (this.isFlushing || this.queue.length === 0) {
104
+ return;
105
+ }
106
+
107
+ this.isFlushing = true;
108
+
109
+ try {
110
+ while (this.queue.length > 0) {
111
+ const item = this.queue[0];
112
+ item.attempts += 1;
113
+
114
+ try {
115
+ const result = await this.bridge.sendBarcode(item.value, {
116
+ time: item.time
117
+ });
118
+
119
+ this.queue.shift();
120
+ this.logger.info("scanner.barcode.sent", {
121
+ length: item.value.length,
122
+ attempts: item.attempts,
123
+ transport: result.transport,
124
+ queueSize: this.queue.length,
125
+ ...(this.settings.logBarcodeValue ? {value: item.value} : {})
126
+ });
127
+ } catch (error) {
128
+ this.logger.error("scanner.barcode.send_failed", {
129
+ length: item.value.length,
130
+ attempts: item.attempts,
131
+ queueSize: this.queue.length,
132
+ error
133
+ });
134
+ break;
135
+ }
136
+ }
137
+ } finally {
138
+ this.isFlushing = false;
139
+ }
140
+ }
141
+
142
+ close() {
143
+ if (this.retryTimer) {
144
+ clearInterval(this.retryTimer);
145
+ this.retryTimer = null;
146
+ }
147
+
148
+ this.scanner.close();
149
+ this.bridge.close();
150
+ }
151
+ }