njs-modbus 3.1.1 → 3.3.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.
Files changed (44) hide show
  1. package/README.md +84 -35
  2. package/README.zh-CN.md +84 -35
  3. package/dist/index.cjs +1291 -497
  4. package/dist/index.d.ts +99 -31
  5. package/dist/index.mjs +1291 -498
  6. package/dist/utils.cjs +536 -0
  7. package/dist/utils.d.ts +163 -0
  8. package/dist/utils.mjs +522 -0
  9. package/package.json +22 -2
  10. package/dist/src/error-code.d.ts +0 -17
  11. package/dist/src/index.d.ts +0 -7
  12. package/dist/src/layers/application/abstract-application-layer.d.ts +0 -26
  13. package/dist/src/layers/application/ascii-application-layer.d.ts +0 -23
  14. package/dist/src/layers/application/index.d.ts +0 -6
  15. package/dist/src/layers/application/rtu-application-layer.d.ts +0 -34
  16. package/dist/src/layers/application/tcp-application-layer.d.ts +0 -16
  17. package/dist/src/layers/physical/abstract-physical-layer.d.ts +0 -50
  18. package/dist/src/layers/physical/index.d.ts +0 -12
  19. package/dist/src/layers/physical/serial-physical-layer.d.ts +0 -70
  20. package/dist/src/layers/physical/tcp-client-physical-layer.d.ts +0 -20
  21. package/dist/src/layers/physical/tcp-physical-connection.d.ts +0 -16
  22. package/dist/src/layers/physical/tcp-server-physical-layer.d.ts +0 -29
  23. package/dist/src/layers/physical/udp-client-physical-layer.d.ts +0 -34
  24. package/dist/src/layers/physical/udp-server-physical-layer.d.ts +0 -51
  25. package/dist/src/layers/physical/utils.d.ts +0 -39
  26. package/dist/src/layers/physical/vars.d.ts +0 -11
  27. package/dist/src/master/index.d.ts +0 -3
  28. package/dist/src/master/master-session.d.ts +0 -18
  29. package/dist/src/master/master.d.ts +0 -140
  30. package/dist/src/slave/index.d.ts +0 -2
  31. package/dist/src/slave/slave.d.ts +0 -119
  32. package/dist/src/types.d.ts +0 -54
  33. package/dist/src/utils/bitsToMs.d.ts +0 -13
  34. package/dist/src/utils/callback.d.ts +0 -8
  35. package/dist/src/utils/checkRange.d.ts +0 -1
  36. package/dist/src/utils/crc.d.ts +0 -1
  37. package/dist/src/utils/index.d.ts +0 -11
  38. package/dist/src/utils/isUint8.d.ts +0 -8
  39. package/dist/src/utils/lrc.d.ts +0 -1
  40. package/dist/src/utils/predictRtuFrameLength.d.ts +0 -17
  41. package/dist/src/utils/promisify-cb.d.ts +0 -4
  42. package/dist/src/utils/rtu-timing.d.ts +0 -63
  43. package/dist/src/utils/whitelist.d.ts +0 -11
  44. package/dist/src/vars.d.ts +0 -49
package/dist/index.cjs CHANGED
@@ -91,6 +91,10 @@ exports.ConformityLevel = void 0;
91
91
  })(exports.ConformityLevel || (exports.ConformityLevel = {}));
92
92
  /** Shared empty Buffer to avoid repeated allocations. */
93
93
  const EMPTY_BUFFER = Buffer.alloc(0);
94
+ /** Shared no-op function to avoid repeated allocations. */
95
+ const NOOP = () => {
96
+ /* no-op */
97
+ };
94
98
  /** Modbus V1.1b3 PDU quantity limits. */
95
99
  const LIMITS = {
96
100
  READ_COILS_MIN: 0x0001,
@@ -162,40 +166,75 @@ function bitsToMs(baudRate, bits) {
162
166
  * open / close / destroy callbacks.
163
167
  */
164
168
  function drainCbs(cbs, err) {
165
- if (!cbs)
169
+ if (!cbs) {
166
170
  return;
171
+ }
167
172
  for (const cb of cbs) {
168
173
  cb?.(err);
169
174
  }
170
175
  }
171
176
 
172
- function inRange(n, [min, max]) {
173
- return n >= min && n <= max;
174
- }
175
- function isRangeArray(range) {
176
- return Array.isArray(range[0]);
177
- }
178
177
  function checkRange(value, range) {
179
178
  if (!range || range.length === 0) {
180
179
  return true;
181
180
  }
182
- const values = Array.isArray(value) ? value : [value];
183
- if (isRangeArray(range)) {
184
- for (const r of range) {
185
- const [min, max] = r;
186
- const [lo, hi] = min <= max ? [min, max] : [max, min];
187
- if (values.every((n) => inRange(n, [lo, hi]))) {
181
+ const isMultiRange = Array.isArray(range[0]);
182
+ const isValueArray = Array.isArray(value);
183
+ if (!isValueArray && !isMultiRange) {
184
+ const r = range;
185
+ const min = r[0], max = r[1];
186
+ const v = value;
187
+ return min <= max ? v >= min && v <= max : v >= max && v <= min;
188
+ }
189
+ if (!isValueArray && isMultiRange) {
190
+ const ranges = range;
191
+ const v = value;
192
+ for (let i = 0; i < ranges.length; i++) {
193
+ const min = ranges[i][0], max = ranges[i][1];
194
+ const lo = min <= max ? min : max;
195
+ const hi = min <= max ? max : min;
196
+ if (v >= lo && v <= hi) {
188
197
  return true;
189
198
  }
190
199
  }
191
200
  return false;
192
201
  }
193
- const [min, max] = range;
194
- const [lo, hi] = min <= max ? [min, max] : [max, min];
195
- return values.every((n) => inRange(n, [lo, hi]));
202
+ const values = value;
203
+ if (values.length === 0) {
204
+ return true;
205
+ }
206
+ if (!isMultiRange) {
207
+ const r = range;
208
+ const min = r[0], max = r[1];
209
+ const lo = min <= max ? min : max;
210
+ const hi = min <= max ? max : min;
211
+ for (let i = 0; i < values.length; i++) {
212
+ if (values[i] < lo || values[i] > hi) {
213
+ return false;
214
+ }
215
+ }
216
+ return true;
217
+ }
218
+ const ranges = range;
219
+ for (let i = 0; i < ranges.length; i++) {
220
+ const min = ranges[i][0], max = ranges[i][1];
221
+ const lo = min <= max ? min : max;
222
+ const hi = min <= max ? max : min;
223
+ let allInRange = true;
224
+ for (let j = 0; j < values.length; j++) {
225
+ if (values[j] < lo || values[j] > hi) {
226
+ allInRange = false;
227
+ break;
228
+ }
229
+ }
230
+ if (allInRange) {
231
+ return true;
232
+ }
233
+ }
234
+ return false;
196
235
  }
197
236
 
198
- const TABLE = [
237
+ const TABLE = new Uint16Array([
199
238
  0x0000, 0xc0c1, 0xc181, 0x0140, 0xc301, 0x03c0, 0x0280, 0xc241, 0xc601, 0x06c0, 0x0780, 0xc741, 0x0500, 0xc5c1, 0xc481, 0x0440, 0xcc01,
200
239
  0x0cc0, 0x0d80, 0xcd41, 0x0f00, 0xcfc1, 0xce81, 0x0e40, 0x0a00, 0xcac1, 0xcb81, 0x0b40, 0xc901, 0x09c0, 0x0880, 0xc841, 0xd801, 0x18c0,
201
240
  0x1980, 0xd941, 0x1b00, 0xdbc1, 0xda81, 0x1a40, 0x1e00, 0xdec1, 0xdf81, 0x1f40, 0xdd01, 0x1dc0, 0x1c80, 0xdc41, 0x1400, 0xd4c1, 0xd581,
@@ -212,11 +251,11 @@ const TABLE = [
212
251
  0x59c0, 0x5880, 0x9841, 0x8801, 0x48c0, 0x4980, 0x8941, 0x4b00, 0x8bc1, 0x8a81, 0x4a40, 0x4e00, 0x8ec1, 0x8f81, 0x4f40, 0x8d01, 0x4dc0,
213
252
  0x4c80, 0x8c41, 0x4400, 0x84c1, 0x8581, 0x4540, 0x8701, 0x47c0, 0x4680, 0x8641, 0x8201, 0x42c0, 0x4380, 0x8341, 0x4100, 0x81c1, 0x8081,
214
253
  0x4040,
215
- ];
216
- function crc(data, start = 0, end = data.length) {
254
+ ]);
255
+ function crc(data, start, end) {
217
256
  let crc = 0xffff;
218
257
  for (let index = start; index < end; index++) {
219
- crc = (TABLE[(crc ^ data[index]) & 0xff] ^ (crc >> 8)) & 0xffff;
258
+ crc = TABLE[(crc ^ data[index]) & 0xff] ^ (crc >> 8);
220
259
  }
221
260
  return crc;
222
261
  }
@@ -229,110 +268,104 @@ function crc(data, start = 0, end = data.length) {
229
268
  * Infinity, and out-of-range values uniformly.
230
269
  */
231
270
  function isUint8(n) {
232
- return Number.isInteger(n) && n >= 0 && n <= 255;
271
+ return (n & 0xff) === n;
233
272
  }
234
273
 
235
- function lrc(data) {
236
- return (~data.reduce((sum, n) => sum + n, 0) + 1) & 0xff;
274
+ function lrc(data, start, end) {
275
+ let sum = 0;
276
+ for (let i = start; i < end; i++) {
277
+ sum += data[i];
278
+ }
279
+ return -sum & 0xff;
237
280
  }
238
281
 
239
- const REQUEST_FIXED_LENGTHS = {
240
- [exports.FunctionCode.READ_COILS]: 8,
241
- [exports.FunctionCode.READ_DISCRETE_INPUTS]: 8,
242
- [exports.FunctionCode.READ_HOLDING_REGISTERS]: 8,
243
- [exports.FunctionCode.READ_INPUT_REGISTERS]: 8,
244
- [exports.FunctionCode.WRITE_SINGLE_COIL]: 8,
245
- [exports.FunctionCode.WRITE_SINGLE_REGISTER]: 8,
246
- [exports.FunctionCode.REPORT_SERVER_ID]: 4,
247
- [exports.FunctionCode.MASK_WRITE_REGISTER]: 10,
248
- [exports.FunctionCode.READ_DEVICE_IDENTIFICATION]: 7,
249
- };
250
- const REQUEST_BYTE_COUNT = {
251
- [exports.FunctionCode.WRITE_MULTIPLE_COILS]: { offset: 6, extra: 9 },
252
- [exports.FunctionCode.WRITE_MULTIPLE_REGISTERS]: { offset: 6, extra: 9 },
253
- [exports.FunctionCode.READ_WRITE_MULTIPLE_REGISTERS]: { offset: 10, extra: 13 },
254
- };
255
- const RESPONSE_FIXED_LENGTHS = {
256
- [exports.FunctionCode.WRITE_SINGLE_COIL]: 8,
257
- [exports.FunctionCode.WRITE_SINGLE_REGISTER]: 8,
258
- [exports.FunctionCode.WRITE_MULTIPLE_COILS]: 8,
259
- [exports.FunctionCode.WRITE_MULTIPLE_REGISTERS]: 8,
260
- [exports.FunctionCode.MASK_WRITE_REGISTER]: 10,
261
- };
262
- const RESPONSE_BYTE_COUNT = {
263
- [exports.FunctionCode.READ_COILS]: { offset: 2, extra: 5 },
264
- [exports.FunctionCode.READ_DISCRETE_INPUTS]: { offset: 2, extra: 5 },
265
- [exports.FunctionCode.READ_HOLDING_REGISTERS]: { offset: 2, extra: 5 },
266
- [exports.FunctionCode.READ_INPUT_REGISTERS]: { offset: 2, extra: 5 },
267
- [exports.FunctionCode.REPORT_SERVER_ID]: { offset: 2, extra: 5 },
268
- [exports.FunctionCode.READ_WRITE_MULTIPLE_REGISTERS]: { offset: 2, extra: 5 },
269
- };
270
- /** Sentinel: caller needs to feed more bytes before length can be determined. */
271
282
  const PREDICT_NEED_MORE = 0;
272
- /** Sentinel: function code is not in the standard tables. */
273
283
  const PREDICT_UNKNOWN = -1;
274
- /**
275
- * Predict the total RTU frame length (PDU + 2-byte CRC) given the leading bytes.
276
- *
277
- * Returns a sentinel-encoded number to avoid per-call object allocation on the
278
- * RTU decode hot path:
279
- * - Positive integer (>= 4): total frame length, function code is known.
280
- * - {@link PREDICT_NEED_MORE} (0): function code is known but more bytes are
281
- * required (typically waiting on the byteCount byte).
282
- * - {@link PREDICT_UNKNOWN} (-1): function code is not in the standard tables —
283
- * the framing layer must defer to a registered `CustomFunctionCode` or treat
284
- * this as a framing error.
285
- */
286
- function predictRtuFrameLength(buffer, isResponse) {
287
- if (buffer.length < 2) {
284
+ const REQ_TABLE = new Int32Array(256);
285
+ const RES_TABLE = new Int32Array(256);
286
+ (function initTables() {
287
+ REQ_TABLE[exports.FunctionCode.READ_COILS] = 8;
288
+ REQ_TABLE[exports.FunctionCode.READ_DISCRETE_INPUTS] = 8;
289
+ REQ_TABLE[exports.FunctionCode.READ_HOLDING_REGISTERS] = 8;
290
+ REQ_TABLE[exports.FunctionCode.READ_INPUT_REGISTERS] = 8;
291
+ REQ_TABLE[exports.FunctionCode.WRITE_SINGLE_COIL] = 8;
292
+ REQ_TABLE[exports.FunctionCode.WRITE_SINGLE_REGISTER] = 8;
293
+ REQ_TABLE[exports.FunctionCode.REPORT_SERVER_ID] = 4;
294
+ REQ_TABLE[exports.FunctionCode.MASK_WRITE_REGISTER] = 10;
295
+ REQ_TABLE[exports.FunctionCode.READ_DEVICE_IDENTIFICATION] = 7;
296
+ REQ_TABLE[exports.FunctionCode.WRITE_MULTIPLE_COILS] = -1545;
297
+ REQ_TABLE[exports.FunctionCode.WRITE_MULTIPLE_REGISTERS] = -1545;
298
+ REQ_TABLE[exports.FunctionCode.READ_WRITE_MULTIPLE_REGISTERS] = -2573;
299
+ RES_TABLE[exports.FunctionCode.WRITE_SINGLE_COIL] = 8;
300
+ RES_TABLE[exports.FunctionCode.WRITE_SINGLE_REGISTER] = 8;
301
+ RES_TABLE[exports.FunctionCode.WRITE_MULTIPLE_COILS] = 8;
302
+ RES_TABLE[exports.FunctionCode.WRITE_MULTIPLE_REGISTERS] = 8;
303
+ RES_TABLE[exports.FunctionCode.MASK_WRITE_REGISTER] = 10;
304
+ RES_TABLE[exports.FunctionCode.READ_COILS] = -517;
305
+ RES_TABLE[exports.FunctionCode.READ_DISCRETE_INPUTS] = -517;
306
+ RES_TABLE[exports.FunctionCode.READ_HOLDING_REGISTERS] = -517;
307
+ RES_TABLE[exports.FunctionCode.READ_INPUT_REGISTERS] = -517;
308
+ RES_TABLE[exports.FunctionCode.REPORT_SERVER_ID] = -517;
309
+ RES_TABLE[exports.FunctionCode.READ_WRITE_MULTIPLE_REGISTERS] = -517;
310
+ RES_TABLE[exports.FunctionCode.READ_DEVICE_IDENTIFICATION] = -999;
311
+ })();
312
+ function predictRtuFrameLength(buffer, start, end, isResponse) {
313
+ const len = end - start;
314
+ if (len < 2) {
288
315
  return PREDICT_NEED_MORE;
289
316
  }
290
- const fc = buffer[1];
291
- if (isResponse && (fc & EXCEPTION_OFFSET) !== 0) {
292
- return 5;
293
- }
294
- const fixed = (isResponse ? RESPONSE_FIXED_LENGTHS : REQUEST_FIXED_LENGTHS)[fc];
295
- if (fixed !== undefined) {
296
- return fixed;
297
- }
298
- const bc = (isResponse ? RESPONSE_BYTE_COUNT : REQUEST_BYTE_COUNT)[fc];
299
- if (bc !== undefined) {
300
- if (buffer.length <= bc.offset) {
301
- return PREDICT_NEED_MORE;
317
+ const fc = buffer[start + 1];
318
+ if (isResponse) {
319
+ if ((fc & EXCEPTION_OFFSET) !== 0) {
320
+ return 5;
321
+ }
322
+ const val = RES_TABLE[fc];
323
+ if (val > 0) {
324
+ return val;
325
+ }
326
+ if (val < 0) {
327
+ if (val === -999) {
328
+ // FC 43 / MEI 14 response — inline to avoid function-call overhead on
329
+ // the framing hot path (even though this FC is uncommon).
330
+ if (end - start < 8) {
331
+ return PREDICT_NEED_MORE;
332
+ }
333
+ if (buffer[start + 2] !== MEI_READ_DEVICE_ID) {
334
+ return PREDICT_UNKNOWN;
335
+ }
336
+ const numObjs = buffer[start + 7];
337
+ let cursor = start + 8;
338
+ for (let i = 0; i < numObjs; i++) {
339
+ if (end < cursor + 2) {
340
+ return PREDICT_NEED_MORE;
341
+ }
342
+ cursor += 2 + buffer[cursor + 1];
343
+ }
344
+ return cursor - start + 2;
345
+ }
346
+ const decode = -val;
347
+ const offset = decode >> 8;
348
+ if (len <= offset) {
349
+ return PREDICT_NEED_MORE;
350
+ }
351
+ return (decode & 0xff) + buffer[start + offset];
302
352
  }
303
- return bc.extra + buffer[bc.offset];
304
- }
305
- if (isResponse && fc === exports.FunctionCode.READ_DEVICE_IDENTIFICATION) {
306
- return predictFc43_14Response(buffer);
307
- }
308
- return PREDICT_UNKNOWN;
309
- }
310
- /**
311
- * Walk the variable-length FC 0x2B / MEI 0x0E (Read Device Identification)
312
- * response structure per Modbus V1.1b3 §6.21.
313
- *
314
- * Layout (after unit and fc):
315
- * mei(1) rdic(1) conformity(1) more(1) nextObjId(1) numObjs(1)
316
- * [objId(1) objLen(1) objData(objLen)] × numObjs
317
- * CRC(2)
318
- */
319
- function predictFc43_14Response(buffer) {
320
- if (buffer.length < 8) {
321
- return PREDICT_NEED_MORE;
322
- }
323
- if (buffer[2] !== MEI_READ_DEVICE_ID) {
324
- return PREDICT_UNKNOWN;
325
353
  }
326
- const numObjs = buffer[7];
327
- let offset = 8;
328
- for (let i = 0; i < numObjs; i++) {
329
- if (buffer.length < offset + 2) {
330
- return PREDICT_NEED_MORE;
354
+ else {
355
+ const val = REQ_TABLE[fc];
356
+ if (val > 0) {
357
+ return val;
358
+ }
359
+ if (val < 0) {
360
+ const decode = -val;
361
+ const offset = decode >> 8;
362
+ if (len <= offset) {
363
+ return PREDICT_NEED_MORE;
364
+ }
365
+ return (decode & 0xff) + buffer[start + offset];
331
366
  }
332
- const objLen = buffer[offset + 1];
333
- offset += 2 + objLen;
334
367
  }
335
- return offset + 2;
368
+ return PREDICT_UNKNOWN;
336
369
  }
337
370
 
338
371
  /**
@@ -341,10 +374,12 @@ function predictFc43_14Response(buffer) {
341
374
  function promisifyCb(fn) {
342
375
  return new Promise((resolve, reject) => {
343
376
  fn((err) => {
344
- if (err)
377
+ if (err) {
345
378
  reject(err);
346
- else
379
+ }
380
+ else {
347
381
  resolve();
382
+ }
348
383
  });
349
384
  });
350
385
  }
@@ -397,6 +432,177 @@ function resolveRtuTiming(opts = {}, baudRate) {
397
432
  return { intervalBetweenFrames, interCharTimeout };
398
433
  }
399
434
 
435
+ /**
436
+ * Hybrid timer manager: uses native `setTimeout` for low concurrency
437
+ * and switches to a binary min-heap when concurrency exceeds the threshold.
438
+ *
439
+ * Benchmarks (add + clear throughput, Node 24, x64):
440
+ * 1 concurrent: setTimeout ~1.7× faster than heap
441
+ * 2 concurrent: setTimeout ~1.6× faster than heap
442
+ * 5 concurrent: setTimeout ~1.5-1.9× faster than heap
443
+ * 10 concurrent: roughly equal
444
+ * 20 concurrent: heap ~1.3× faster than setTimeout[]
445
+ * 50 concurrent: heap ~1.4-1.7× faster than setTimeout[]
446
+ *
447
+ * The crossover point is around 10 concurrent timers, so the default
448
+ * `concurrentThreshold = 2` keeps the common 1-2 request case on the
449
+ * fast direct path while delegating to the heap for larger batches.
450
+ */
451
+ class TimerHeap {
452
+ _deadlines = [];
453
+ _ids = [];
454
+ _seqs = [];
455
+ _counter = 0;
456
+ _timer = null;
457
+ _onFire;
458
+ _boundTick;
459
+ _threshold;
460
+ _mode = 'direct';
461
+ _directTimers = new Map();
462
+ /**
463
+ * @param onFire Callback invoked with the timer id when it expires.
464
+ * @param concurrentThreshold Maximum number of timers kept as individual
465
+ * native `setTimeout` handles. Once exceeded, all timers migrate to
466
+ * the internal heap and share a single native timer. Default is 2.
467
+ */
468
+ constructor(onFire, concurrentThreshold = 2) {
469
+ this._onFire = onFire;
470
+ this._boundTick = this._onTick.bind(this);
471
+ this._threshold = concurrentThreshold;
472
+ }
473
+ get size() {
474
+ return this._mode === 'direct' ? this._directTimers.size : this._deadlines.length;
475
+ }
476
+ add(id, ms) {
477
+ if (this._mode === 'direct' && this._directTimers.size + 1 <= this._threshold) {
478
+ const deadline = performance.now() + ms;
479
+ const handle = setTimeout(() => {
480
+ if (this._mode !== 'direct') {
481
+ return;
482
+ }
483
+ this._directTimers.delete(id);
484
+ this._onFire(id);
485
+ }, ms);
486
+ this._directTimers.set(id, { handle, deadline });
487
+ return;
488
+ }
489
+ if (this._mode === 'direct') {
490
+ this._mode = 'heap';
491
+ for (const [existingId, { handle, deadline }] of this._directTimers) {
492
+ clearTimeout(handle);
493
+ const remaining = Math.max(0, Math.ceil(deadline - performance.now()));
494
+ if (remaining === 0) {
495
+ this._onFire(existingId);
496
+ }
497
+ else {
498
+ this._heapAdd(existingId, remaining);
499
+ }
500
+ }
501
+ this._directTimers.clear();
502
+ }
503
+ this._heapAdd(id, ms);
504
+ }
505
+ clear() {
506
+ for (const { handle } of this._directTimers.values()) {
507
+ clearTimeout(handle);
508
+ }
509
+ this._directTimers.clear();
510
+ this._mode = 'direct';
511
+ if (this._timer) {
512
+ clearTimeout(this._timer);
513
+ this._timer = null;
514
+ }
515
+ this._deadlines.length = 0;
516
+ this._ids.length = 0;
517
+ this._seqs.length = 0;
518
+ this._counter = 0;
519
+ }
520
+ _heapAdd(id, ms) {
521
+ const deadline = performance.now() + ms;
522
+ const seq = this._counter++;
523
+ let i = this._deadlines.length;
524
+ this._deadlines.push(deadline);
525
+ this._ids.push(id);
526
+ this._seqs.push(seq);
527
+ while (i > 0) {
528
+ const p = (i - 1) >> 1;
529
+ const parentComesFirst = this._deadlines[p] < deadline || (this._deadlines[p] === deadline && this._seqs[p] < seq);
530
+ if (parentComesFirst) {
531
+ break;
532
+ }
533
+ this._deadlines[i] = this._deadlines[p];
534
+ this._ids[i] = this._ids[p];
535
+ this._seqs[i] = this._seqs[p];
536
+ i = p;
537
+ }
538
+ this._deadlines[i] = deadline;
539
+ this._ids[i] = id;
540
+ this._seqs[i] = seq;
541
+ if (i === 0) {
542
+ this._refresh();
543
+ }
544
+ }
545
+ _refresh() {
546
+ if (this._timer) {
547
+ clearTimeout(this._timer);
548
+ this._timer = null;
549
+ }
550
+ if (this._deadlines.length === 0) {
551
+ return;
552
+ }
553
+ const delay = Math.max(0, Math.ceil(this._deadlines[0] - performance.now()));
554
+ const safeDelay = Math.min(delay, 2147483647);
555
+ this._timer = setTimeout(this._boundTick, safeDelay);
556
+ }
557
+ _onTick() {
558
+ this._timer = null;
559
+ const now = performance.now();
560
+ try {
561
+ while (this._deadlines.length > 0 && this._deadlines[0] <= now) {
562
+ const id = this._pop();
563
+ this._onFire(id);
564
+ }
565
+ }
566
+ finally {
567
+ this._refresh();
568
+ }
569
+ }
570
+ _pop() {
571
+ const topId = this._ids[0];
572
+ const lastId = this._ids.pop();
573
+ const lastDeadline = this._deadlines.pop();
574
+ const lastSeq = this._seqs.pop();
575
+ const n = this._deadlines.length;
576
+ if (n > 0) {
577
+ let i = 0;
578
+ const half = n >> 1;
579
+ while (i < half) {
580
+ let minChild = (i << 1) + 1;
581
+ const rightChild = minChild + 1;
582
+ if (rightChild < n) {
583
+ const rightComesFirst = this._deadlines[rightChild] < this._deadlines[minChild] ||
584
+ (this._deadlines[rightChild] === this._deadlines[minChild] && this._seqs[rightChild] < this._seqs[minChild]);
585
+ if (rightComesFirst) {
586
+ minChild = rightChild;
587
+ }
588
+ }
589
+ const lastComesFirst = lastDeadline < this._deadlines[minChild] || (lastDeadline === this._deadlines[minChild] && lastSeq < this._seqs[minChild]);
590
+ if (lastComesFirst) {
591
+ break;
592
+ }
593
+ this._deadlines[i] = this._deadlines[minChild];
594
+ this._ids[i] = this._ids[minChild];
595
+ this._seqs[i] = this._seqs[minChild];
596
+ i = minChild;
597
+ }
598
+ this._deadlines[i] = lastDeadline;
599
+ this._ids[i] = lastId;
600
+ this._seqs[i] = lastSeq;
601
+ }
602
+ return topId;
603
+ }
604
+ }
605
+
400
606
  /**
401
607
  * Normalize an IP address by stripping the IPv4-mapped IPv6 prefix.
402
608
  * This ensures consistent comparison of addresses like `::ffff:192.168.1.1`.
@@ -1341,14 +1547,16 @@ function createPhysicalLayer(config) {
1341
1547
  * established and discarded when the connection closes. Subclasses implement
1342
1548
  * ASCII, RTU, or TCP framing rules.
1343
1549
  */
1344
- class AbstractApplicationLayer extends EventEmitter {
1550
+ class AbstractApplicationLayer {
1551
+ /** Called when a complete frame is decoded. Defaults to no-op. */
1552
+ onFraming = NOOP;
1553
+ /** Called when a framing error is detected. Defaults to no-op. */
1554
+ onFramingError = NOOP;
1345
1555
  flush() {
1346
1556
  // no-op — override in subclasses
1347
1557
  }
1348
- addCustomFunctionCode(cfc) {
1349
- }
1350
- removeCustomFunctionCode(fc) {
1351
- }
1558
+ addCustomFunctionCode(cfc) { }
1559
+ removeCustomFunctionCode(fc) { }
1352
1560
  }
1353
1561
 
1354
1562
  const MAX_FRAME_LENGTH = 256;
@@ -1362,7 +1570,7 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1362
1570
  _threePointFiveT;
1363
1571
  _onePointFiveT;
1364
1572
  _customFunctionCodes = new Map();
1365
- _cleanupFns = new Set();
1573
+ _cleanupCbs = [];
1366
1574
  get connection() {
1367
1575
  return this._connection;
1368
1576
  }
@@ -1378,7 +1586,7 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1378
1586
  const onData = (data) => {
1379
1587
  const state = this._state;
1380
1588
  if (state.t15Expired && state.end > state.start) {
1381
- this.emit('framing-error', new Error('Inter-character timeout (t1.5) exceeded'));
1589
+ this.onFramingError(new Error('Inter-character timeout (t1.5) exceeded'));
1382
1590
  state.start = 0;
1383
1591
  state.end = 0;
1384
1592
  }
@@ -1420,7 +1628,7 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1420
1628
  // flushBuffer freed nothing — the entire pool is unparseable
1421
1629
  // residue (typically a misconfigured poolSize for the wire's
1422
1630
  // frame size). Hard reset; we cannot recover automatically.
1423
- this.emit('framing-error', new Error('Frame buffer exhausted before complete frame received'));
1631
+ this.onFramingError(new Error('Frame buffer exhausted before complete frame received'));
1424
1632
  currentState.start = 0;
1425
1633
  currentState.end = 0;
1426
1634
  currentState.t15Expired = false;
@@ -1456,16 +1664,16 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1456
1664
  }
1457
1665
  };
1458
1666
  connection.on('data', onData);
1459
- this._cleanupFns.add(() => connection.off('data', onData));
1667
+ this._cleanupCbs.push(() => connection.off('data', onData));
1460
1668
  const onClose = () => {
1461
- for (const fn of this._cleanupFns) {
1669
+ for (const fn of this._cleanupCbs) {
1462
1670
  fn();
1463
1671
  }
1464
- this._cleanupFns.clear();
1672
+ this._cleanupCbs.length = 0;
1465
1673
  this.clearStateTimers();
1466
1674
  };
1467
1675
  connection.on('close', onClose);
1468
- this._cleanupFns.add(() => connection.off('close', onClose));
1676
+ this._cleanupCbs.push(() => connection.off('close', onClose));
1469
1677
  }
1470
1678
  clearStateTimers() {
1471
1679
  const state = this._state;
@@ -1478,35 +1686,40 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1478
1686
  state.interCharTimer = undefined;
1479
1687
  }
1480
1688
  }
1689
+ /**
1690
+ * Shared handler for every "frame is not yet complete" exit in `flushBuffer`.
1691
+ * Returns `true` when the caller should `return` (strict reset), `false` to
1692
+ * `break` the parse loop. Hot path never reaches here — only error/incomplete
1693
+ * edges. Extracted as a method so it is not recreated on every `flushBuffer`
1694
+ * call.
1695
+ */
1696
+ _handleIncomplete(state, strict) {
1697
+ if (strict) {
1698
+ this.onFramingError(new Error(state.t15Expired ? 'Inter-character timeout (t1.5) exceeded' : 'Incomplete frame at t3.5'));
1699
+ state.start = 0;
1700
+ state.end = 0;
1701
+ state.t15Expired = false;
1702
+ return true;
1703
+ }
1704
+ if (state.t15Expired) {
1705
+ this.onFramingError(new Error('Inter-character timeout (t1.5) exceeded'));
1706
+ state.start = 0;
1707
+ state.end = 0;
1708
+ state.t15Expired = false;
1709
+ }
1710
+ return false;
1711
+ }
1481
1712
  flushBuffer(strict) {
1482
1713
  const state = this._state;
1483
1714
  const isResponse = this.ROLE === 'MASTER';
1484
1715
  const pool = state.pool;
1485
1716
  const customFCs = this._customFunctionCodes;
1486
- // Shared handler for every "frame is not yet complete" exit. Returns true
1487
- // when the caller should `return` (strict reset), false to `break` the
1488
- // parse loop. Hot path never reaches here — only error/incomplete edges.
1489
- const handleIncomplete = () => {
1490
- if (strict) {
1491
- this.emit('framing-error', new Error(state.t15Expired ? 'Inter-character timeout (t1.5) exceeded' : 'Incomplete frame at t3.5'));
1492
- state.start = 0;
1493
- state.end = 0;
1494
- state.t15Expired = false;
1495
- return true;
1496
- }
1497
- if (state.t15Expired) {
1498
- this.emit('framing-error', new Error('Inter-character timeout (t1.5) exceeded'));
1499
- state.start = 0;
1500
- state.end = 0;
1501
- state.t15Expired = false;
1502
- }
1503
- return false;
1504
- };
1505
1717
  while (state.end - state.start > 0) {
1506
1718
  const available = state.end - state.start;
1507
1719
  if (available < MIN_FRAME_LENGTH) {
1508
- if (handleIncomplete())
1720
+ if (this._handleIncomplete(state, strict)) {
1509
1721
  return;
1722
+ }
1510
1723
  break;
1511
1724
  }
1512
1725
  const fc = pool[state.start + 1];
@@ -1514,7 +1727,7 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1514
1727
  let expected;
1515
1728
  if (cfc) {
1516
1729
  const predictor = isResponse ? cfc.predictResponseLength : cfc.predictRequestLength;
1517
- const predicted = predictor(pool.subarray(state.start, state.end));
1730
+ const predicted = predictor(pool, state.start, state.end);
1518
1731
  // Normalize custom predictor's `null` to the std sentinel so both
1519
1732
  // paths share the same NEED_MORE tail below.
1520
1733
  expected = predicted ?? PREDICT_NEED_MORE;
@@ -1522,9 +1735,9 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1522
1735
  else {
1523
1736
  // Standard FC path. predictRtuFrameLength uses sentinel returns to
1524
1737
  // avoid per-call object allocation on the decode hot path.
1525
- expected = predictRtuFrameLength(pool.subarray(state.start, state.end), isResponse);
1738
+ expected = predictRtuFrameLength(pool, state.start, state.end, isResponse);
1526
1739
  if (expected === PREDICT_UNKNOWN) {
1527
- this.emit('framing-error', new Error(`Unknown function code 0x${fc.toString(16).padStart(2, '0')} — register a CustomFunctionCode to frame this FC`));
1740
+ this.onFramingError(new Error(`Unknown function code 0x${fc.toString(16).padStart(2, '0')} — register a CustomFunctionCode to frame this FC`));
1528
1741
  state.start = 0;
1529
1742
  state.end = 0;
1530
1743
  return;
@@ -1535,12 +1748,13 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1535
1748
  state.start += 1;
1536
1749
  continue;
1537
1750
  }
1538
- if (handleIncomplete())
1751
+ if (this._handleIncomplete(state, strict)) {
1539
1752
  return;
1753
+ }
1540
1754
  break;
1541
1755
  }
1542
1756
  if (expected > MAX_FRAME_LENGTH || expected < MIN_FRAME_LENGTH) {
1543
- this.emit('framing-error', new Error('Invalid data'));
1757
+ this.onFramingError(new Error('Invalid data'));
1544
1758
  state.start = 0;
1545
1759
  state.end = 0;
1546
1760
  return;
@@ -1550,8 +1764,9 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1550
1764
  state.start += 1;
1551
1765
  continue;
1552
1766
  }
1553
- if (handleIncomplete())
1767
+ if (this._handleIncomplete(state, strict)) {
1554
1768
  return;
1769
+ }
1555
1770
  break;
1556
1771
  }
1557
1772
  // CRC check inline: no helper call, no subarray for the CRC body.
@@ -1561,7 +1776,7 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1561
1776
  const actualCrc = crc(pool, crcStart, crcEnd);
1562
1777
  if (expectedCrc !== actualCrc) {
1563
1778
  if (strict) {
1564
- this.emit('framing-error', new Error('CRC mismatch'));
1779
+ this.onFramingError(new Error('CRC mismatch'));
1565
1780
  state.start = 0;
1566
1781
  state.end = 0;
1567
1782
  state.t15Expired = false;
@@ -1582,7 +1797,7 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1582
1797
  data: frameBuf.subarray(2, expected - 2),
1583
1798
  buffer: frameBuf,
1584
1799
  };
1585
- this.emit('framing', frame);
1800
+ this.onFraming(frame);
1586
1801
  }
1587
1802
  if (state.start > 0) {
1588
1803
  if (state.start < state.end) {
@@ -1609,11 +1824,22 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1609
1824
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
1610
1825
  encode(unit, fc, data, transaction) {
1611
1826
  const buffer = Buffer.allocUnsafe(data.length + 4);
1612
- buffer.writeUInt8(unit, 0);
1613
- buffer.writeUInt8(fc, 1);
1614
- buffer.set(data, 2);
1827
+ // Inline header — direct typed-array stores skip Buffer's per-call checks.
1828
+ buffer[0] = unit;
1829
+ buffer[1] = fc;
1830
+ if (data.length <= 16) {
1831
+ for (let i = 0; i < data.length; i++) {
1832
+ buffer[2 + i] = data[i];
1833
+ }
1834
+ }
1835
+ else {
1836
+ buffer.set(data, 2);
1837
+ }
1615
1838
  const crcEnd = buffer.length - 2;
1616
- buffer.writeUInt16LE(crc(buffer, 0, crcEnd), crcEnd);
1839
+ const c = crc(buffer, 0, crcEnd);
1840
+ // Little-endian inline write of CRC trailer.
1841
+ buffer[crcEnd] = c & 0xff;
1842
+ buffer[crcEnd + 1] = (c >>> 8) & 0xff;
1617
1843
  return buffer;
1618
1844
  }
1619
1845
  }
@@ -1623,9 +1849,8 @@ const CHAR_CODE = {
1623
1849
  CR: '\r'.charCodeAt(0),
1624
1850
  LF: '\n'.charCodeAt(0),
1625
1851
  };
1626
- // Modbus ASCII frame encodes at most 256 PDU bytes as 512 hex chars between
1627
- // `:` and `\r`. Cap per-connection buffering so a peer that never sends `\r`
1628
- // cannot grow `state.frame` without bound.
1852
+ // Modbus ASCII frame body is capped well below the theoretical maximum so a
1853
+ // peer that never sends `\r` cannot grow `state.frame` without bound.
1629
1854
  const MAX_ASCII_PAYLOAD = 512;
1630
1855
  const HEX_DECODE = new Uint8Array(256);
1631
1856
  HEX_DECODE.fill(0xff);
@@ -1644,8 +1869,8 @@ class AsciiApplicationLayer extends AbstractApplicationLayer {
1644
1869
  ROLE;
1645
1870
  lenientHex;
1646
1871
  _connection;
1647
- _state = { status: 'idle', frame: [] };
1648
- _cleanupFns = new Set();
1872
+ _state = { status: 'idle', frame: new Uint8Array(MAX_ASCII_PAYLOAD), frameLen: 0 };
1873
+ _cleanupCbs = [];
1649
1874
  get connection() {
1650
1875
  return this._connection;
1651
1876
  }
@@ -1669,114 +1894,148 @@ class AsciiApplicationLayer extends AbstractApplicationLayer {
1669
1894
  };
1670
1895
  const onData = (data) => {
1671
1896
  const state = this._state;
1672
- data.forEach((value) => {
1897
+ for (let i = 0; i < data.length; i++) {
1898
+ const value = data[i];
1673
1899
  switch (state.status) {
1674
1900
  case 'idle': {
1675
1901
  if (value === CHAR_CODE.COLON) {
1676
1902
  state.status = 'reception';
1677
- state.frame = [];
1903
+ state.frameLen = 0;
1678
1904
  }
1679
1905
  break;
1680
1906
  }
1681
1907
  case 'reception': {
1682
1908
  if (value === CHAR_CODE.COLON) {
1683
- state.frame = [];
1909
+ state.frameLen = 0;
1684
1910
  }
1685
1911
  else if (value === CHAR_CODE.CR) {
1686
1912
  state.status = 'waiting end';
1687
1913
  }
1688
- else if (state.frame.length >= MAX_ASCII_PAYLOAD) {
1914
+ else if (state.frameLen >= MAX_ASCII_PAYLOAD) {
1689
1915
  state.status = 'idle';
1690
- state.frame = [];
1691
- this.emit('framing-error', new Error('Invalid data'));
1916
+ state.frameLen = 0;
1917
+ this.onFramingError(new Error('Invalid data'));
1692
1918
  }
1693
1919
  else if (!isHexChar(value)) {
1694
1920
  state.status = 'idle';
1695
- state.frame = [];
1696
- this.emit('framing-error', new Error('Invalid hex character'));
1921
+ state.frameLen = 0;
1922
+ this.onFramingError(new Error('Invalid hex character'));
1697
1923
  }
1698
1924
  else {
1699
- state.frame.push(value);
1925
+ state.frame[state.frameLen++] = value;
1700
1926
  }
1701
1927
  break;
1702
1928
  }
1703
1929
  case 'waiting end': {
1704
1930
  if (value === CHAR_CODE.COLON) {
1705
1931
  state.status = 'reception';
1706
- state.frame = [];
1932
+ state.frameLen = 0;
1707
1933
  }
1708
1934
  else {
1709
1935
  state.status = 'idle';
1710
1936
  if (value === CHAR_CODE.LF) {
1711
- this.framing(Buffer.from(state.frame));
1937
+ this.framing(state.frame, state.frameLen);
1712
1938
  }
1713
1939
  }
1714
1940
  break;
1715
1941
  }
1716
1942
  }
1717
- });
1943
+ }
1718
1944
  };
1719
1945
  connection.on('data', onData);
1720
- this._cleanupFns.add(() => connection.off('data', onData));
1946
+ this._cleanupCbs.push(() => connection.off('data', onData));
1721
1947
  const onClose = () => {
1722
- for (const fn of this._cleanupFns) {
1948
+ for (const fn of this._cleanupCbs) {
1723
1949
  fn();
1724
1950
  }
1725
- this._cleanupFns.clear();
1951
+ this._cleanupCbs.length = 0;
1726
1952
  };
1727
1953
  connection.on('close', onClose);
1728
- this._cleanupFns.add(() => connection.off('close', onClose));
1954
+ this._cleanupCbs.push(() => connection.off('close', onClose));
1729
1955
  }
1730
- framing(hexChars) {
1731
- if (hexChars.length < 6) {
1732
- this.emit('framing-error', new Error('Insufficient data length'));
1956
+ framing(hexChars, hexLen) {
1957
+ if (hexLen < 6) {
1958
+ this.onFramingError(new Error('Insufficient data length'));
1959
+ return;
1960
+ }
1961
+ if (hexLen % 2 !== 0) {
1962
+ this.onFramingError(new Error('Invalid data'));
1733
1963
  return;
1734
1964
  }
1735
- if (hexChars.length % 2 !== 0) {
1736
- this.emit('framing-error', new Error('Invalid data'));
1965
+ const byteLen = hexLen >> 1;
1966
+ // Decode unit and fc directly from the first 4 hex characters —
1967
+ // avoids allocating a full decoded buffer just to read two bytes.
1968
+ const unitHi = HEX_DECODE[hexChars[0]];
1969
+ const unitLo = HEX_DECODE[hexChars[1]];
1970
+ const fcHi = HEX_DECODE[hexChars[2]];
1971
+ const fcLo = HEX_DECODE[hexChars[3]];
1972
+ if (unitHi === 0xff || unitLo === 0xff || fcHi === 0xff || fcLo === 0xff) {
1973
+ this.onFramingError(new Error('Invalid hex character'));
1737
1974
  return;
1738
1975
  }
1739
- const decoded = Buffer.allocUnsafe(hexChars.length / 2);
1740
- for (let i = 0; i < hexChars.length; i += 2) {
1741
- const hi = HEX_DECODE[hexChars[i]];
1742
- const lo = HEX_DECODE[hexChars[i + 1]];
1743
- // Defensive: the FSM should already have filtered non-hex characters,
1744
- // but guard here in case framing is ever called directly.
1976
+ const unit = (unitHi << 4) | unitLo;
1977
+ const fc = (fcHi << 4) | fcLo;
1978
+ // Decode LRC from the last 2 hex characters.
1979
+ const lrcHi = HEX_DECODE[hexChars[hexLen - 2]];
1980
+ const lrcLo = HEX_DECODE[hexChars[hexLen - 1]];
1981
+ if (lrcHi === 0xff || lrcLo === 0xff) {
1982
+ this.onFramingError(new Error('Invalid hex character'));
1983
+ return;
1984
+ }
1985
+ const lrcIn = (lrcHi << 4) | lrcLo;
1986
+ // Decode data portion (between unit/fc and lrc) into a right-sized buffer.
1987
+ // dataLen may be 0 for a frame that is only unit + fc + lrc.
1988
+ const dataLen = byteLen - 3;
1989
+ const data = Buffer.allocUnsafe(dataLen);
1990
+ let hexOff = 4;
1991
+ for (let i = 0; i < dataLen; i++) {
1992
+ const hi = HEX_DECODE[hexChars[hexOff]];
1993
+ const lo = HEX_DECODE[hexChars[hexOff + 1]];
1745
1994
  if (hi === 0xff || lo === 0xff) {
1746
- this.emit('framing-error', new Error('Invalid hex character'));
1995
+ this.onFramingError(new Error('Invalid hex character'));
1747
1996
  return;
1748
1997
  }
1749
- decoded[i / 2] = (hi << 4) | lo;
1998
+ data[i] = (hi << 4) | lo;
1999
+ hexOff += 2;
1750
2000
  }
1751
- const frame = {
1752
- unit: decoded[0],
1753
- fc: decoded[1],
1754
- data: decoded.subarray(2, decoded.length - 1),
1755
- buffer: hexChars,
1756
- };
1757
- const lrcPassed = decoded[decoded.length - 1] === lrc(decoded.subarray(0, decoded.length - 1));
1758
- if (!lrcPassed) {
1759
- this.emit('framing-error', new Error('LRC check failed'));
2001
+ // Compute LRC over unit + fc + data.
2002
+ let sum = unit + fc;
2003
+ for (let i = 0; i < dataLen; i++) {
2004
+ sum += data[i];
2005
+ }
2006
+ const lrcComputed = (~sum + 1) & 0xff;
2007
+ if (lrcIn !== lrcComputed) {
2008
+ this.onFramingError(new Error('LRC check failed'));
1760
2009
  return;
1761
2010
  }
1762
- this.emit('framing', frame);
2011
+ const frame = {
2012
+ unit,
2013
+ fc,
2014
+ data,
2015
+ buffer: Buffer.copyBytesFrom(hexChars, 0, hexLen),
2016
+ };
2017
+ this.onFraming(frame);
1763
2018
  }
1764
2019
  flush() {
1765
- this._state = { status: 'idle', frame: [] };
2020
+ this._state.status = 'idle';
2021
+ this._state.frameLen = 0;
1766
2022
  }
1767
2023
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
1768
2024
  encode(unit, fc, data, transaction) {
1769
2025
  const buffer = Buffer.allocUnsafe(data.length + 3);
1770
- buffer.writeUInt8(unit, 0);
1771
- buffer.writeUInt8(fc, 1);
2026
+ // Inline header + LRC — direct typed-array stores skip Buffer's per-call checks.
2027
+ buffer[0] = unit;
2028
+ buffer[1] = fc;
1772
2029
  buffer.set(data, 2);
1773
- buffer.writeUInt8(lrc(buffer.subarray(0, -1)), buffer.length - 1);
2030
+ buffer[buffer.length - 1] = lrc(buffer, 0, buffer.length - 1);
1774
2031
  const out = Buffer.allocUnsafe(1 + buffer.length * 2 + 2);
1775
2032
  out[0] = CHAR_CODE.COLON;
2033
+ let outOff = 1;
1776
2034
  for (let i = 0; i < buffer.length; i++) {
1777
2035
  const byte = buffer[i];
1778
- out[1 + i * 2] = HEX_ENCODE[byte >> 4];
1779
- out[2 + i * 2] = HEX_ENCODE[byte & 0x0f];
2036
+ out[outOff] = HEX_ENCODE[byte >> 4];
2037
+ out[outOff + 1] = HEX_ENCODE[byte & 0x0f];
2038
+ outOff += 2;
1780
2039
  }
1781
2040
  out[out.length - 2] = CHAR_CODE.CR;
1782
2041
  out[out.length - 1] = CHAR_CODE.LF;
@@ -1791,7 +2050,7 @@ class TcpApplicationLayer extends AbstractApplicationLayer {
1791
2050
  _connection;
1792
2051
  _transactionId = 1;
1793
2052
  _buffer = EMPTY_BUFFER;
1794
- _cleanupFns = new Set();
2053
+ _cleanupCbs = [];
1795
2054
  get connection() {
1796
2055
  return this._connection;
1797
2056
  }
@@ -1800,6 +2059,17 @@ class TcpApplicationLayer extends AbstractApplicationLayer {
1800
2059
  this.ROLE = role;
1801
2060
  this._connection = connection;
1802
2061
  const onData = (data) => {
2062
+ // Fast path: _buffer is empty and data is a single, complete frame.
2063
+ // This avoids the tryExtract subarray allocations + while loop
2064
+ // for the overwhelmingly common case (one frame per TCP packet).
2065
+ if (this._buffer.length === 0 && data.length >= 8) {
2066
+ const length = (data[4] << 8) | data[5]; // inline BE read
2067
+ const total = 6 + length;
2068
+ if (data[2] === 0 && data[3] === 0 && total <= MAX_TCP_FRAME && length >= 2 && data.length === total) {
2069
+ this.processFrame(data);
2070
+ return;
2071
+ }
2072
+ }
1803
2073
  let buffer = this._buffer;
1804
2074
  if (buffer.length === 0) {
1805
2075
  buffer = data;
@@ -1817,7 +2087,7 @@ class TcpApplicationLayer extends AbstractApplicationLayer {
1817
2087
  break;
1818
2088
  }
1819
2089
  else {
1820
- this.emit('framing-error', result.error);
2090
+ this.onFramingError(result.error);
1821
2091
  buffer = EMPTY_BUFFER;
1822
2092
  break;
1823
2093
  }
@@ -1837,15 +2107,15 @@ class TcpApplicationLayer extends AbstractApplicationLayer {
1837
2107
  }
1838
2108
  };
1839
2109
  connection.on('data', onData);
1840
- this._cleanupFns.add(() => connection.off('data', onData));
2110
+ this._cleanupCbs.push(() => connection.off('data', onData));
1841
2111
  const onClose = () => {
1842
- for (const fn of this._cleanupFns) {
2112
+ for (const fn of this._cleanupCbs) {
1843
2113
  fn();
1844
2114
  }
1845
- this._cleanupFns.clear();
2115
+ this._cleanupCbs.length = 0;
1846
2116
  };
1847
2117
  connection.on('close', onClose);
1848
- this._cleanupFns.add(() => connection.off('close', onClose));
2118
+ this._cleanupCbs.push(() => connection.off('close', onClose));
1849
2119
  }
1850
2120
  tryExtract(buffer) {
1851
2121
  if (buffer.length < 8) {
@@ -1854,7 +2124,7 @@ class TcpApplicationLayer extends AbstractApplicationLayer {
1854
2124
  if (buffer[2] !== 0 || buffer[3] !== 0) {
1855
2125
  return { kind: 'error', error: new Error('Invalid data') };
1856
2126
  }
1857
- const length = buffer.readUInt16BE(4);
2127
+ const length = (buffer[4] << 8) | buffer[5]; // inline BE read
1858
2128
  const total = 6 + length;
1859
2129
  if (total > MAX_TCP_FRAME || length < 2) {
1860
2130
  return { kind: 'error', error: new Error('Invalid data') };
@@ -1862,29 +2132,48 @@ class TcpApplicationLayer extends AbstractApplicationLayer {
1862
2132
  if (buffer.length < total) {
1863
2133
  return { kind: 'insufficient' };
1864
2134
  }
1865
- return { kind: 'frame', frame: buffer.subarray(0, total), rest: buffer.subarray(total) };
2135
+ return { kind: 'frame', frame: buffer.subarray(0, total), rest: total === buffer.length ? EMPTY_BUFFER : buffer.subarray(total) };
1866
2136
  }
1867
2137
  processFrame(buffer) {
1868
2138
  const frame = {
1869
- transaction: buffer.readUInt16BE(0),
2139
+ // Inline 16-bit BE read — direct typed-array loads skip readUInt16BE's
2140
+ // argument coercion + bounds check. Symmetric to the header writes in
2141
+ // encode() below. Hits on every received TCP frame.
2142
+ transaction: (buffer[0] << 8) | buffer[1],
1870
2143
  unit: buffer[6],
1871
2144
  fc: buffer[7],
1872
2145
  data: buffer.subarray(8),
1873
2146
  buffer,
1874
2147
  };
1875
- this.emit('framing', frame);
2148
+ this.onFraming(frame);
1876
2149
  }
1877
2150
  flush() {
1878
2151
  this._buffer = EMPTY_BUFFER;
1879
2152
  }
1880
2153
  encode(unit, fc, data, transaction) {
1881
2154
  const buffer = Buffer.allocUnsafe(data.length + 8);
1882
- buffer.writeUInt16BE(transaction ?? this._transactionId, 0);
1883
- buffer.writeUInt16BE(0, 2);
1884
- buffer.writeUInt16BE(data.length + 2, 4);
1885
- buffer.writeUInt8(unit, 6);
1886
- buffer.writeUInt8(fc, 7);
1887
- buffer.set(data, 8);
2155
+ // Inline big-endian header writes — direct typed-array stores skip the
2156
+ // argument validation + bounds checks that `writeUInt16BE/writeUInt8` run.
2157
+ const tid = transaction ?? this._transactionId;
2158
+ const len = data.length + 2;
2159
+ buffer[0] = (tid >>> 8) & 0xff;
2160
+ buffer[1] = tid & 0xff;
2161
+ buffer[2] = 0;
2162
+ buffer[3] = 0;
2163
+ buffer[4] = (len >>> 8) & 0xff;
2164
+ buffer[5] = len & 0xff;
2165
+ buffer[6] = unit;
2166
+ buffer[7] = fc;
2167
+ // Small-payload fast path: avoid C++ TypedArray.prototype.set boundary
2168
+ // crossing when the copy is just a few bytes (common for FC 3/4/6 requests).
2169
+ if (data.length <= 16) {
2170
+ for (let i = 0; i < data.length; i++) {
2171
+ buffer[8 + i] = data[i];
2172
+ }
2173
+ }
2174
+ else {
2175
+ buffer.set(data, 8);
2176
+ }
1888
2177
  if (transaction === undefined) {
1889
2178
  this._transactionId = (this._transactionId + 1) % 65536 || 1;
1890
2179
  }
@@ -1928,26 +2217,33 @@ class MasterSession {
1928
2217
  }
1929
2218
 
1930
2219
  function validateResponse(frame, unit, fc) {
1931
- if (frame.unit !== unit || frame.fc !== fc)
2220
+ if (frame.unit !== unit || frame.fc !== fc) {
1932
2221
  throw new Error('Invalid response');
2222
+ }
1933
2223
  }
1934
2224
  function validateByteCountResponse(frame, unit, fc, byteCount) {
1935
2225
  validateResponse(frame, unit, fc);
1936
- if (frame.data.length < 1 + byteCount)
2226
+ if (frame.data.length < 1 + byteCount) {
1937
2227
  throw new Error('Insufficient data length');
1938
- if (frame.data.length !== 1 + byteCount)
2228
+ }
2229
+ if (frame.data.length !== 1 + byteCount) {
1939
2230
  throw new Error('Invalid response');
1940
- if (frame.data[0] !== byteCount)
2231
+ }
2232
+ if (frame.data[0] !== byteCount) {
1941
2233
  throw new Error('Invalid response');
2234
+ }
1942
2235
  }
1943
2236
  function validateEchoResponse(frame, unit, fc, expected) {
1944
2237
  validateResponse(frame, unit, fc);
1945
- if (frame.data.length < expected.length)
2238
+ if (frame.data.length < expected.length) {
1946
2239
  throw new Error('Insufficient data length');
1947
- if (frame.data.length !== expected.length)
2240
+ }
2241
+ if (frame.data.length !== expected.length) {
1948
2242
  throw new Error('Invalid response');
1949
- if (!frame.data.equals(expected))
2243
+ }
2244
+ if (!frame.data.equals(expected)) {
1950
2245
  throw new Error('Invalid response');
2246
+ }
1951
2247
  }
1952
2248
  class ModbusMaster extends EventEmitter {
1953
2249
  timeout;
@@ -1963,14 +2259,32 @@ class ModbusMaster extends EventEmitter {
1963
2259
  _queueDatas = [];
1964
2260
  _queueTimeouts = [];
1965
2261
  _queueBroadcasts = [];
1966
- _queueResolves = [];
1967
- _queueRejects = [];
2262
+ _queueCallbacks = [];
1968
2263
  _queueHead = 0;
1969
2264
  _queueLen = 0;
1970
2265
  _draining = false;
1971
2266
  _nextTid = 1;
1972
2267
  _cleanupFns = new Set();
1973
2268
  _closePromise = null;
2269
+ _nextExchangeId = 1;
2270
+ // Global timer heap with lazy deletion — one native setTimeout for all requests.
2271
+ _pendingExchanges = new Map();
2272
+ _timerHeap = new TimerHeap((id) => {
2273
+ const pending = this._pendingExchanges.get(id);
2274
+ if (!pending) {
2275
+ return;
2276
+ } // lazy deletion: already handled
2277
+ pending.settled = true;
2278
+ this._pendingExchanges.delete(id);
2279
+ if (pending.sessionKey !== null) {
2280
+ this._masterSession.stop(pending.sessionKey);
2281
+ }
2282
+ const cb = pending.callback;
2283
+ if (cb) {
2284
+ pending.callback = null;
2285
+ cb(new Error('Timeout'));
2286
+ }
2287
+ });
1974
2288
  get state() {
1975
2289
  return this._physicalLayer.state;
1976
2290
  }
@@ -1997,17 +2311,21 @@ class ModbusMaster extends EventEmitter {
1997
2311
  for (const cfc of this._customFunctionCodes.values()) {
1998
2312
  appLayer.addCustomFunctionCode(cfc);
1999
2313
  }
2000
- const cleanupFraming = () => appLayer.off('framing', onFraming);
2314
+ const cleanupFraming = () => {
2315
+ appLayer.onFraming = NOOP;
2316
+ };
2001
2317
  const onFraming = (frame) => {
2002
2318
  this._masterSession.handleFrame(frame);
2003
2319
  };
2004
- appLayer.on('framing', onFraming);
2320
+ appLayer.onFraming = onFraming;
2005
2321
  this._cleanupFns.add(cleanupFraming);
2006
- const cleanupFramingError = () => appLayer.off('framing-error', onFramingError);
2322
+ const cleanupFramingError = () => {
2323
+ appLayer.onFramingError = NOOP;
2324
+ };
2007
2325
  const onFramingError = (error) => {
2008
2326
  this._masterSession.handleError(error);
2009
2327
  };
2010
- appLayer.on('framing-error', onFramingError);
2328
+ appLayer.onFramingError = onFramingError;
2011
2329
  this._cleanupFns.add(cleanupFramingError);
2012
2330
  const cleanupClose = () => connection.off('close', onClose);
2013
2331
  const onClose = () => {
@@ -2063,31 +2381,23 @@ class ModbusMaster extends EventEmitter {
2063
2381
  }
2064
2382
  return new AsciiApplicationLayer('MASTER', connection, this._protocol.opts);
2065
2383
  }
2066
- send(unit, fc, data, timeout, broadcast) {
2384
+ _send(unit, fc, data, timeout, broadcast, callback) {
2067
2385
  if (this._physicalLayer.state !== exports.PhysicalState.OPEN) {
2068
- return Promise.reject(new Error('Master is not open'));
2386
+ callback(new Error('Master is not open'));
2387
+ return;
2069
2388
  }
2070
2389
  if (this.concurrent) {
2071
- return new Promise((resolve, reject) => {
2072
- this._exchange(unit, fc, data, timeout, broadcast, (err, frame) => {
2073
- if (err)
2074
- reject(err);
2075
- else
2076
- resolve(frame);
2077
- });
2078
- });
2390
+ this._exchange(unit, fc, data, timeout, broadcast, callback);
2391
+ return;
2079
2392
  }
2080
- return new Promise((resolve, reject) => {
2081
- this._queueUnits.push(unit);
2082
- this._queueFcs.push(fc);
2083
- this._queueDatas.push(data);
2084
- this._queueTimeouts.push(timeout);
2085
- this._queueBroadcasts.push(broadcast);
2086
- this._queueResolves.push(resolve);
2087
- this._queueRejects.push(reject);
2088
- this._queueLen++;
2089
- this._drain();
2090
- });
2393
+ this._queueUnits.push(unit);
2394
+ this._queueFcs.push(fc);
2395
+ this._queueDatas.push(data);
2396
+ this._queueTimeouts.push(timeout);
2397
+ this._queueBroadcasts.push(broadcast);
2398
+ this._queueCallbacks.push(callback);
2399
+ this._queueLen++;
2400
+ this._drain();
2091
2401
  }
2092
2402
  _drain() {
2093
2403
  if (this._draining || this._physicalLayer.state !== exports.PhysicalState.OPEN) {
@@ -2108,14 +2418,12 @@ class ModbusMaster extends EventEmitter {
2108
2418
  const data = this._queueDatas[h];
2109
2419
  const timeout = this._queueTimeouts[h];
2110
2420
  const broadcast = this._queueBroadcasts[h];
2111
- const resolve = this._queueResolves[h];
2112
- const reject = this._queueRejects[h];
2113
- // Drop references so the GC can reclaim data buffers and resolve/reject
2421
+ const callback = this._queueCallbacks[h];
2422
+ // Drop references so the GC can reclaim data buffers and callback
2114
2423
  // closures while the rest of the queue is still draining. Primitives
2115
2424
  // (unit/fc/timeout/broadcast) need no clearing.
2116
2425
  this._queueDatas[h] = undefined;
2117
- this._queueResolves[h] = undefined;
2118
- this._queueRejects[h] = undefined;
2426
+ this._queueCallbacks[h] = undefined;
2119
2427
  this._queueHead = h + 1;
2120
2428
  this._queueLen--;
2121
2429
  if (this._queueLen === 0) {
@@ -2126,15 +2434,11 @@ class ModbusMaster extends EventEmitter {
2126
2434
  this._queueDatas.length = 0;
2127
2435
  this._queueTimeouts.length = 0;
2128
2436
  this._queueBroadcasts.length = 0;
2129
- this._queueResolves.length = 0;
2130
- this._queueRejects.length = 0;
2437
+ this._queueCallbacks.length = 0;
2131
2438
  this._queueHead = 0;
2132
2439
  }
2133
2440
  this._exchange(unit, fc, data, timeout, broadcast, (err, frame) => {
2134
- if (err)
2135
- reject(err);
2136
- else
2137
- resolve(frame);
2441
+ callback(err, frame);
2138
2442
  this._processNext();
2139
2443
  });
2140
2444
  }
@@ -2154,30 +2458,39 @@ class ModbusMaster extends EventEmitter {
2154
2458
  if (!this.concurrent) {
2155
2459
  appLayer.flush();
2156
2460
  }
2461
+ // Lazy-deletion timer architecture:
2462
+ // 1. Assign an exchangeId and register in _pendingExchanges.
2463
+ // 2. Push deadline into the global TimerHeap (one native setTimeout under
2464
+ // load; a fast direct-timer path is used when only 1-2 exchanges are
2465
+ // pending).
2466
+ // 3. When the response arrives, delete from Map — the heap entry is left
2467
+ // behind and silently discarded when it surfaces at the top (lazy deletion).
2468
+ const exchangeId = this._nextExchangeId++;
2469
+ const pending = { settled: false, callback, sessionKey: null };
2470
+ this._pendingExchanges.set(exchangeId, pending);
2157
2471
  if (broadcast) {
2158
- // Broadcast: no response expected. Skip the session entirely — registering
2159
- // a waiter under FIFO_KEY would race with any concurrent FIFO request (the
2160
- // broadcast's stop() would clear the other request's slot/timer).
2161
- let settled = false;
2162
- const timer = setTimeout(() => {
2163
- if (settled)
2164
- return;
2165
- settled = true;
2166
- callback(new Error('Timeout'));
2167
- }, timeout);
2472
+ // Broadcast: no response expected. Skip the session entirely.
2473
+ this._timerHeap.add(exchangeId, timeout);
2168
2474
  connection.write(appLayer.encode(unit, fc, data), (writeErr) => {
2169
- if (settled)
2475
+ const p = this._pendingExchanges.get(exchangeId);
2476
+ if (!p || p.settled) {
2170
2477
  return;
2171
- clearTimeout(timer);
2172
- settled = true;
2478
+ }
2479
+ const cb = p.callback;
2480
+ if (!cb) {
2481
+ return;
2482
+ }
2483
+ p.settled = true;
2484
+ p.callback = null;
2485
+ this._pendingExchanges.delete(exchangeId);
2173
2486
  if (writeErr) {
2174
- callback(writeErr);
2487
+ cb(writeErr);
2175
2488
  }
2176
2489
  else if (this._physicalLayer.state !== exports.PhysicalState.OPEN) {
2177
- callback(new Error('Master is not open'));
2490
+ cb(new Error('Master is not open'));
2178
2491
  }
2179
2492
  else {
2180
- callback(null);
2493
+ cb(null);
2181
2494
  }
2182
2495
  });
2183
2496
  return;
@@ -2191,62 +2504,118 @@ class ModbusMaster extends EventEmitter {
2191
2504
  }
2192
2505
  const key = tid ?? FIFO_KEY;
2193
2506
  const payload = appLayer.encode(unit, fc, data, tid);
2194
- // Timeout starts before write (covers write + response phases).
2195
- // The session waiter is registered only after write succeeds.
2196
- // settled guard prevents double-invocation when timeout fires during write.
2197
- let settled = false;
2198
- const timer = setTimeout(() => {
2199
- if (settled)
2200
- return;
2201
- settled = true;
2202
- this._masterSession.stop(key);
2203
- callback(new Error('Timeout'));
2204
- }, timeout);
2507
+ pending.sessionKey = key;
2508
+ this._timerHeap.add(exchangeId, timeout);
2205
2509
  connection.write(payload, (writeErr) => {
2510
+ const p = this._pendingExchanges.get(exchangeId);
2511
+ if (!p || p.settled) {
2512
+ return;
2513
+ }
2206
2514
  if (writeErr) {
2207
- if (!settled) {
2208
- clearTimeout(timer);
2209
- settled = true;
2210
- callback(writeErr);
2515
+ const cb = p.callback;
2516
+ if (cb) {
2517
+ p.settled = true;
2518
+ p.callback = null;
2519
+ this._pendingExchanges.delete(exchangeId);
2520
+ cb(writeErr);
2211
2521
  }
2212
2522
  return;
2213
2523
  }
2214
2524
  if (this._physicalLayer.state !== exports.PhysicalState.OPEN) {
2215
- if (!settled) {
2216
- clearTimeout(timer);
2217
- settled = true;
2218
- callback(new Error('Master is not open'));
2525
+ const cb = p.callback;
2526
+ if (cb) {
2527
+ p.settled = true;
2528
+ p.callback = null;
2529
+ this._pendingExchanges.delete(exchangeId);
2530
+ cb(new Error('Master is not open'));
2219
2531
  }
2220
2532
  return;
2221
2533
  }
2222
2534
  // Write succeeded — register in session for frame matching only.
2223
- // Timeout is managed by the local timer above.
2535
+ // Timeout is managed by the global timer heap above.
2224
2536
  this._masterSession.start(key, (err, frame) => {
2225
- if (settled)
2537
+ const p2 = this._pendingExchanges.get(exchangeId);
2538
+ if (!p2 || p2.settled) {
2226
2539
  return;
2227
- clearTimeout(timer);
2228
- settled = true;
2229
- callback(err, frame);
2540
+ }
2541
+ const cb = p2.callback;
2542
+ if (cb) {
2543
+ p2.settled = true;
2544
+ p2.callback = null;
2545
+ this._pendingExchanges.delete(exchangeId);
2546
+ cb(err, frame);
2547
+ }
2230
2548
  });
2231
2549
  });
2232
2550
  }
2233
2551
  writeFC1Or2(unit, fc, address, length, timeout) {
2234
2552
  const byteCount = Math.ceil(length / 8);
2235
2553
  const bufferTx = Buffer.allocUnsafe(4);
2236
- bufferTx.writeUInt16BE(address, 0);
2237
- bufferTx.writeUInt16BE(length, 2);
2238
- return this.send(unit, fc, bufferTx, timeout, unit === 0).then((frame) => {
2239
- if (!frame)
2240
- return;
2241
- validateByteCountResponse(frame, unit, fc, byteCount);
2242
- const data = new Array(length);
2243
- for (let i = 0; i < length; i++) {
2244
- data[i] = (frame.data[1 + ~~(i / 8)] & (1 << i % 8)) > 0;
2245
- }
2246
- // Mutate the frame in place rather than spread-copying — `frame` is freshly
2247
- // allocated per request and not retained anywhere else.
2248
- frame.data = data;
2249
- return frame;
2554
+ // Inline big-endian writes — direct typed-array stores skip the argument
2555
+ // validation + bounds checks that `writeUInt16BE` runs on each call.
2556
+ bufferTx[0] = (address >>> 8) & 0xff;
2557
+ bufferTx[1] = address & 0xff;
2558
+ bufferTx[2] = (length >>> 8) & 0xff;
2559
+ bufferTx[3] = length & 0xff;
2560
+ return new Promise((resolve, reject) => {
2561
+ this._send(unit, fc, bufferTx, timeout, unit === 0, (err, frame) => {
2562
+ if (err) {
2563
+ reject(err);
2564
+ return;
2565
+ }
2566
+ if (!frame) {
2567
+ resolve(undefined);
2568
+ return;
2569
+ }
2570
+ try {
2571
+ validateByteCountResponse(frame, unit, fc, byteCount);
2572
+ const data = new Array(length);
2573
+ let byteIdx = 1;
2574
+ let outIdx = 0;
2575
+ const fullBytes = length >> 3;
2576
+ for (let b = 0; b < fullBytes; b++) {
2577
+ const byte = frame.data[byteIdx++];
2578
+ data[outIdx++] = (byte & 0x01) > 0;
2579
+ data[outIdx++] = (byte & 0x02) > 0;
2580
+ data[outIdx++] = (byte & 0x04) > 0;
2581
+ data[outIdx++] = (byte & 0x08) > 0;
2582
+ data[outIdx++] = (byte & 0x10) > 0;
2583
+ data[outIdx++] = (byte & 0x20) > 0;
2584
+ data[outIdx++] = (byte & 0x40) > 0;
2585
+ data[outIdx++] = (byte & 0x80) > 0;
2586
+ }
2587
+ const rem = length & 7;
2588
+ if (rem) {
2589
+ const byte = frame.data[byteIdx];
2590
+ data[outIdx++] = (byte & 0x01) > 0;
2591
+ if (rem > 1) {
2592
+ data[outIdx++] = (byte & 0x02) > 0;
2593
+ }
2594
+ if (rem > 2) {
2595
+ data[outIdx++] = (byte & 0x04) > 0;
2596
+ }
2597
+ if (rem > 3) {
2598
+ data[outIdx++] = (byte & 0x08) > 0;
2599
+ }
2600
+ if (rem > 4) {
2601
+ data[outIdx++] = (byte & 0x10) > 0;
2602
+ }
2603
+ if (rem > 5) {
2604
+ data[outIdx++] = (byte & 0x20) > 0;
2605
+ }
2606
+ if (rem > 6) {
2607
+ data[outIdx++] = (byte & 0x40) > 0;
2608
+ }
2609
+ }
2610
+ // Mutate the frame in place rather than spread-copying — `frame` is freshly
2611
+ // allocated per request and not retained anywhere else.
2612
+ frame.data = data;
2613
+ resolve(frame);
2614
+ }
2615
+ catch (e) {
2616
+ reject(e);
2617
+ }
2618
+ });
2250
2619
  });
2251
2620
  }
2252
2621
  writeFC1;
@@ -2260,19 +2629,42 @@ class ModbusMaster extends EventEmitter {
2260
2629
  writeFC3Or4(unit, fc, address, length, timeout) {
2261
2630
  const byteCount = length * 2;
2262
2631
  const bufferTx = Buffer.allocUnsafe(4);
2263
- bufferTx.writeUInt16BE(address, 0);
2264
- bufferTx.writeUInt16BE(length, 2);
2265
- return this.send(unit, fc, bufferTx, timeout, unit === 0).then((frame) => {
2266
- if (!frame)
2267
- return;
2268
- validateByteCountResponse(frame, unit, fc, byteCount);
2269
- const bufferRx = frame.data.subarray(1);
2270
- const data = new Array(length);
2271
- for (let i = 0; i < length; i++) {
2272
- data[i] = bufferRx.readUInt16BE(i * 2);
2273
- }
2274
- frame.data = data;
2275
- return frame;
2632
+ // Inline big-endian writes — see writeFC1Or2 for the rationale.
2633
+ bufferTx[0] = (address >>> 8) & 0xff;
2634
+ bufferTx[1] = address & 0xff;
2635
+ bufferTx[2] = (length >>> 8) & 0xff;
2636
+ bufferTx[3] = length & 0xff;
2637
+ return new Promise((resolve, reject) => {
2638
+ this._send(unit, fc, bufferTx, timeout, unit === 0, (err, frame) => {
2639
+ if (err) {
2640
+ reject(err);
2641
+ return;
2642
+ }
2643
+ if (!frame) {
2644
+ resolve(undefined);
2645
+ return;
2646
+ }
2647
+ try {
2648
+ validateByteCountResponse(frame, unit, fc, byteCount);
2649
+ const bufferRx = frame.data.subarray(1);
2650
+ const data = new Array(length);
2651
+ // Inline big-endian read — `bufferRx[i]` is a direct typed-array
2652
+ // load, while `readUInt16BE` runs argument coercion + bounds check
2653
+ // on each call. Symmetric to the slave-side BE write inlining
2654
+ // in handleFC3/FC4. At length=125 (FC3 max) that's 250 saved
2655
+ // bounds-check pairs per response.
2656
+ let off = 0;
2657
+ for (let i = 0; i < length; i++) {
2658
+ data[i] = (bufferRx[off] << 8) | bufferRx[off + 1];
2659
+ off += 2;
2660
+ }
2661
+ frame.data = data;
2662
+ resolve(frame);
2663
+ }
2664
+ catch (e) {
2665
+ reject(e);
2666
+ }
2667
+ });
2276
2668
  });
2277
2669
  }
2278
2670
  writeFC3;
@@ -2287,28 +2679,61 @@ class ModbusMaster extends EventEmitter {
2287
2679
  writeSingleCoil(unit, address, value, timeout = this.timeout) {
2288
2680
  const fc = exports.FunctionCode.WRITE_SINGLE_COIL;
2289
2681
  const bufferTx = Buffer.allocUnsafe(4);
2290
- bufferTx.writeUInt16BE(address, 0);
2291
- bufferTx.writeUInt16BE(value ? COIL_ON : COIL_OFF, 2);
2292
- return this.send(unit, fc, bufferTx, timeout, unit === 0).then((frame) => {
2293
- if (!frame)
2294
- return;
2295
- validateEchoResponse(frame, unit, fc, bufferTx);
2296
- frame.data = value;
2297
- return frame;
2682
+ const coilValue = value ? COIL_ON : COIL_OFF;
2683
+ // Inline big-endian writes see writeFC1Or2 for the rationale.
2684
+ bufferTx[0] = (address >>> 8) & 0xff;
2685
+ bufferTx[1] = address & 0xff;
2686
+ bufferTx[2] = (coilValue >>> 8) & 0xff;
2687
+ bufferTx[3] = coilValue & 0xff;
2688
+ return new Promise((resolve, reject) => {
2689
+ this._send(unit, fc, bufferTx, timeout, unit === 0, (err, frame) => {
2690
+ if (err) {
2691
+ reject(err);
2692
+ return;
2693
+ }
2694
+ if (!frame) {
2695
+ resolve(undefined);
2696
+ return;
2697
+ }
2698
+ try {
2699
+ validateEchoResponse(frame, unit, fc, bufferTx);
2700
+ frame.data = value;
2701
+ resolve(frame);
2702
+ }
2703
+ catch (e) {
2704
+ reject(e);
2705
+ }
2706
+ });
2298
2707
  });
2299
2708
  }
2300
2709
  writeFC6;
2301
2710
  writeSingleRegister(unit, address, value, timeout = this.timeout) {
2302
2711
  const fc = exports.FunctionCode.WRITE_SINGLE_REGISTER;
2303
2712
  const bufferTx = Buffer.allocUnsafe(4);
2304
- bufferTx.writeUInt16BE(address, 0);
2305
- bufferTx.writeUInt16BE(value, 2);
2306
- return this.send(unit, fc, bufferTx, timeout, unit === 0).then((frame) => {
2307
- if (!frame)
2308
- return;
2309
- validateEchoResponse(frame, unit, fc, bufferTx);
2310
- frame.data = value;
2311
- return frame;
2713
+ // Inline big-endian writes — see writeFC1Or2 for the rationale.
2714
+ bufferTx[0] = (address >>> 8) & 0xff;
2715
+ bufferTx[1] = address & 0xff;
2716
+ bufferTx[2] = (value >>> 8) & 0xff;
2717
+ bufferTx[3] = value & 0xff;
2718
+ return new Promise((resolve, reject) => {
2719
+ this._send(unit, fc, bufferTx, timeout, unit === 0, (err, frame) => {
2720
+ if (err) {
2721
+ reject(err);
2722
+ return;
2723
+ }
2724
+ if (!frame) {
2725
+ resolve(undefined);
2726
+ return;
2727
+ }
2728
+ try {
2729
+ validateEchoResponse(frame, unit, fc, bufferTx);
2730
+ frame.data = value;
2731
+ resolve(frame);
2732
+ }
2733
+ catch (e) {
2734
+ reject(e);
2735
+ }
2736
+ });
2312
2737
  });
2313
2738
  }
2314
2739
  writeFC15;
@@ -2316,20 +2741,45 @@ class ModbusMaster extends EventEmitter {
2316
2741
  const fc = exports.FunctionCode.WRITE_MULTIPLE_COILS;
2317
2742
  const byteCount = Math.ceil(value.length / 8);
2318
2743
  const bufferTx = Buffer.alloc(5 + byteCount);
2319
- bufferTx.writeUInt16BE(address, 0);
2320
- bufferTx.writeUInt16BE(value.length, 2);
2321
- bufferTx.writeUInt8(byteCount, 4);
2322
- value.forEach((v, i) => {
2323
- if (v) {
2324
- bufferTx[5 + ~~(i / 8)] |= 1 << i % 8;
2744
+ // Inline big-endian writes — see writeFC1Or2 for the rationale.
2745
+ bufferTx[0] = (address >>> 8) & 0xff;
2746
+ bufferTx[1] = address & 0xff;
2747
+ bufferTx[2] = (value.length >>> 8) & 0xff;
2748
+ bufferTx[3] = value.length & 0xff;
2749
+ bufferTx[4] = byteCount;
2750
+ let acc = 0;
2751
+ let out = 5;
2752
+ for (let i = 0; i < value.length; i++) {
2753
+ if (value[i]) {
2754
+ acc |= 1 << (i & 7);
2325
2755
  }
2326
- });
2327
- return this.send(unit, fc, bufferTx, timeout, unit === 0).then((frame) => {
2328
- if (!frame)
2329
- return;
2330
- validateEchoResponse(frame, unit, fc, bufferTx.subarray(0, 4));
2331
- frame.data = value;
2332
- return frame;
2756
+ if ((i & 7) === 7) {
2757
+ bufferTx[out++] = acc;
2758
+ acc = 0;
2759
+ }
2760
+ }
2761
+ if ((value.length & 7) !== 0) {
2762
+ bufferTx[out] = acc;
2763
+ }
2764
+ return new Promise((resolve, reject) => {
2765
+ this._send(unit, fc, bufferTx, timeout, unit === 0, (err, frame) => {
2766
+ if (err) {
2767
+ reject(err);
2768
+ return;
2769
+ }
2770
+ if (!frame) {
2771
+ resolve(undefined);
2772
+ return;
2773
+ }
2774
+ try {
2775
+ validateEchoResponse(frame, unit, fc, bufferTx.subarray(0, 4));
2776
+ frame.data = value;
2777
+ resolve(frame);
2778
+ }
2779
+ catch (e) {
2780
+ reject(e);
2781
+ }
2782
+ });
2333
2783
  });
2334
2784
  }
2335
2785
  writeFC16;
@@ -2337,53 +2787,105 @@ class ModbusMaster extends EventEmitter {
2337
2787
  const fc = exports.FunctionCode.WRITE_MULTIPLE_REGISTERS;
2338
2788
  const byteCount = value.length * 2;
2339
2789
  const bufferTx = Buffer.allocUnsafe(5 + byteCount);
2340
- bufferTx.writeUInt16BE(address, 0);
2341
- bufferTx.writeUInt16BE(value.length, 2);
2342
- bufferTx.writeUInt8(byteCount, 4);
2343
- value.forEach((v, i) => {
2344
- bufferTx.writeUInt16BE(v, 5 + i * 2);
2345
- });
2346
- return this.send(unit, fc, bufferTx, timeout, unit === 0).then((frame) => {
2347
- if (!frame)
2348
- return;
2349
- validateEchoResponse(frame, unit, fc, bufferTx.subarray(0, 4));
2350
- frame.data = value;
2351
- return frame;
2790
+ // Inline big-endian writes — see writeFC1Or2 for the rationale.
2791
+ bufferTx[0] = (address >>> 8) & 0xff;
2792
+ bufferTx[1] = address & 0xff;
2793
+ bufferTx[2] = (value.length >>> 8) & 0xff;
2794
+ bufferTx[3] = value.length & 0xff;
2795
+ bufferTx[4] = byteCount;
2796
+ let off = 5;
2797
+ for (let i = 0; i < value.length; i++) {
2798
+ const v = value[i];
2799
+ bufferTx[off] = (v >>> 8) & 0xff;
2800
+ bufferTx[off + 1] = v & 0xff;
2801
+ off += 2;
2802
+ }
2803
+ return new Promise((resolve, reject) => {
2804
+ this._send(unit, fc, bufferTx, timeout, unit === 0, (err, frame) => {
2805
+ if (err) {
2806
+ reject(err);
2807
+ return;
2808
+ }
2809
+ if (!frame) {
2810
+ resolve(undefined);
2811
+ return;
2812
+ }
2813
+ try {
2814
+ validateEchoResponse(frame, unit, fc, bufferTx.subarray(0, 4));
2815
+ frame.data = value;
2816
+ resolve(frame);
2817
+ }
2818
+ catch (e) {
2819
+ reject(e);
2820
+ }
2821
+ });
2352
2822
  });
2353
2823
  }
2354
2824
  handleFC17;
2355
2825
  reportServerId(unit, serverIdLength = 1, timeout = this.timeout) {
2356
2826
  const fc = exports.FunctionCode.REPORT_SERVER_ID;
2357
- return this.send(unit, fc, EMPTY_BUFFER, timeout, unit === 0).then((frame) => {
2358
- if (!frame)
2359
- return;
2360
- validateResponse(frame, unit, fc);
2361
- if (frame.data.length < 2 + serverIdLength)
2362
- throw new Error('Insufficient data length');
2363
- if (frame.data.length !== 1 + frame.data[0])
2364
- throw new Error('Invalid response');
2365
- const runStatusIndex = 1 + serverIdLength;
2366
- frame.data = {
2367
- serverId: Array.from(frame.data.subarray(1, runStatusIndex)),
2368
- runIndicatorStatus: frame.data[runStatusIndex] === 0xff,
2369
- additionalData: Array.from(frame.data.subarray(runStatusIndex + 1)),
2370
- };
2371
- return frame;
2827
+ return new Promise((resolve, reject) => {
2828
+ this._send(unit, fc, EMPTY_BUFFER, timeout, unit === 0, (err, frame) => {
2829
+ if (err) {
2830
+ reject(err);
2831
+ return;
2832
+ }
2833
+ if (!frame) {
2834
+ resolve(undefined);
2835
+ return;
2836
+ }
2837
+ try {
2838
+ validateResponse(frame, unit, fc);
2839
+ if (frame.data.length < 2 + serverIdLength) {
2840
+ throw new Error('Insufficient data length');
2841
+ }
2842
+ if (frame.data.length !== 1 + frame.data[0]) {
2843
+ throw new Error('Invalid response');
2844
+ }
2845
+ const runStatusIndex = 1 + serverIdLength;
2846
+ frame.data = {
2847
+ serverId: Array.from(frame.data.subarray(1, runStatusIndex)),
2848
+ runIndicatorStatus: frame.data[runStatusIndex] === 0xff,
2849
+ additionalData: Array.from(frame.data.subarray(runStatusIndex + 1)),
2850
+ };
2851
+ resolve(frame);
2852
+ }
2853
+ catch (e) {
2854
+ reject(e);
2855
+ }
2856
+ });
2372
2857
  });
2373
2858
  }
2374
2859
  handleFC22;
2375
2860
  maskWriteRegister(unit, address, andMask, orMask, timeout = this.timeout) {
2376
2861
  const fc = exports.FunctionCode.MASK_WRITE_REGISTER;
2377
2862
  const bufferTx = Buffer.allocUnsafe(6);
2378
- bufferTx.writeUInt16BE(address, 0);
2379
- bufferTx.writeUInt16BE(andMask, 2);
2380
- bufferTx.writeUInt16BE(orMask, 4);
2381
- return this.send(unit, fc, bufferTx, timeout, unit === 0).then((frame) => {
2382
- if (!frame)
2383
- return;
2384
- validateEchoResponse(frame, unit, fc, bufferTx);
2385
- frame.data = { andMask, orMask };
2386
- return frame;
2863
+ // Inline big-endian writes — see writeFC1Or2 for the rationale.
2864
+ bufferTx[0] = (address >>> 8) & 0xff;
2865
+ bufferTx[1] = address & 0xff;
2866
+ bufferTx[2] = (andMask >>> 8) & 0xff;
2867
+ bufferTx[3] = andMask & 0xff;
2868
+ bufferTx[4] = (orMask >>> 8) & 0xff;
2869
+ bufferTx[5] = orMask & 0xff;
2870
+ return new Promise((resolve, reject) => {
2871
+ this._send(unit, fc, bufferTx, timeout, unit === 0, (err, frame) => {
2872
+ if (err) {
2873
+ reject(err);
2874
+ return;
2875
+ }
2876
+ if (!frame) {
2877
+ resolve(undefined);
2878
+ return;
2879
+ }
2880
+ try {
2881
+ validateEchoResponse(frame, unit, fc, bufferTx);
2882
+ frame.data = { andMask, orMask };
2883
+ resolve(frame);
2884
+ }
2885
+ catch (e) {
2886
+ reject(e);
2887
+ }
2888
+ });
2387
2889
  });
2388
2890
  }
2389
2891
  handleFC23;
@@ -2392,71 +2894,121 @@ class ModbusMaster extends EventEmitter {
2392
2894
  const byteCount = write.value.length * 2;
2393
2895
  const readByteCount = read.length * 2;
2394
2896
  const bufferTx = Buffer.allocUnsafe(9 + byteCount);
2395
- bufferTx.writeUInt16BE(read.address, 0);
2396
- bufferTx.writeUInt16BE(read.length, 2);
2397
- bufferTx.writeUInt16BE(write.address, 4);
2398
- bufferTx.writeUInt16BE(write.value.length, 6);
2399
- bufferTx.writeUInt8(byteCount, 8);
2400
- write.value.forEach((v, i) => {
2401
- bufferTx.writeUInt16BE(v, 9 + i * 2);
2402
- });
2403
- return this.send(unit, fc, bufferTx, timeout, unit === 0).then((frame) => {
2404
- if (!frame)
2405
- return;
2406
- validateByteCountResponse(frame, unit, fc, readByteCount);
2407
- const bufferRx = frame.data.subarray(1);
2408
- frame.data = Array.from({ length: read.length }, (_, index) => bufferRx.readUInt16BE(index * 2));
2409
- return frame;
2897
+ // Inline big-endian writes — see writeFC1Or2 for the rationale.
2898
+ bufferTx[0] = (read.address >>> 8) & 0xff;
2899
+ bufferTx[1] = read.address & 0xff;
2900
+ bufferTx[2] = (read.length >>> 8) & 0xff;
2901
+ bufferTx[3] = read.length & 0xff;
2902
+ bufferTx[4] = (write.address >>> 8) & 0xff;
2903
+ bufferTx[5] = write.address & 0xff;
2904
+ bufferTx[6] = (write.value.length >>> 8) & 0xff;
2905
+ bufferTx[7] = write.value.length & 0xff;
2906
+ bufferTx[8] = byteCount;
2907
+ let off = 9;
2908
+ for (let i = 0; i < write.value.length; i++) {
2909
+ const v = write.value[i];
2910
+ bufferTx[off] = (v >>> 8) & 0xff;
2911
+ bufferTx[off + 1] = v & 0xff;
2912
+ off += 2;
2913
+ }
2914
+ return new Promise((resolve, reject) => {
2915
+ this._send(unit, fc, bufferTx, timeout, unit === 0, (err, frame) => {
2916
+ if (err) {
2917
+ reject(err);
2918
+ return;
2919
+ }
2920
+ if (!frame) {
2921
+ resolve(undefined);
2922
+ return;
2923
+ }
2924
+ try {
2925
+ validateByteCountResponse(frame, unit, fc, readByteCount);
2926
+ const bufferRx = frame.data.subarray(1);
2927
+ // Dense pre-sized array + inline BE read — drops the Array.from
2928
+ // closure + N readUInt16BE bounds-check pairs. See writeFC3Or4
2929
+ // response handler for the same optimization.
2930
+ const data = new Array(read.length);
2931
+ let off = 0;
2932
+ for (let i = 0; i < read.length; i++) {
2933
+ data[i] = (bufferRx[off] << 8) | bufferRx[off + 1];
2934
+ off += 2;
2935
+ }
2936
+ frame.data = data;
2937
+ resolve(frame);
2938
+ }
2939
+ catch (e) {
2940
+ reject(e);
2941
+ }
2942
+ });
2410
2943
  });
2411
2944
  }
2412
2945
  handleFC43_14;
2413
2946
  readDeviceIdentification(unit, readDeviceIDCode, objectId, timeout = this.timeout) {
2414
2947
  const fc = exports.FunctionCode.READ_DEVICE_IDENTIFICATION;
2415
- return this.send(unit, fc, Buffer.from([MEI_READ_DEVICE_ID, readDeviceIDCode, objectId]), timeout, unit === 0).then((frame) => {
2416
- if (!frame)
2417
- return;
2418
- validateResponse(frame, unit, fc);
2419
- if (frame.data.length < 6)
2420
- throw new Error('Insufficient data length');
2421
- if (frame.data[0] !== MEI_READ_DEVICE_ID || frame.data[1] !== readDeviceIDCode)
2422
- throw new Error('Invalid response');
2423
- const objects = [];
2424
- let object = [];
2425
- let totalBytes = 0;
2426
- for (const v of frame.data.subarray(6)) {
2427
- switch (object.length) {
2428
- case 0:
2429
- case 1: {
2430
- object.push(v);
2431
- break;
2948
+ return new Promise((resolve, reject) => {
2949
+ this._send(unit, fc, Buffer.from([MEI_READ_DEVICE_ID, readDeviceIDCode, objectId]), timeout, unit === 0, (err, frame) => {
2950
+ if (err) {
2951
+ reject(err);
2952
+ return;
2953
+ }
2954
+ if (!frame) {
2955
+ resolve(undefined);
2956
+ return;
2957
+ }
2958
+ try {
2959
+ validateResponse(frame, unit, fc);
2960
+ if (frame.data.length < 6) {
2961
+ throw new Error('Insufficient data length');
2432
2962
  }
2433
- case 2: {
2434
- object.push([v]);
2435
- break;
2963
+ if (frame.data[0] !== MEI_READ_DEVICE_ID || frame.data[1] !== readDeviceIDCode) {
2964
+ throw new Error('Invalid response');
2436
2965
  }
2437
- case 3: {
2438
- object[2].push(v);
2439
- if (object[1] === object[2].length) {
2440
- objects.push({ id: object[0], value: Buffer.from(object[2]).toString() });
2441
- totalBytes += 2 + object[1];
2442
- object = [];
2966
+ const objects = [];
2967
+ let object = [];
2968
+ let totalBytes = 0;
2969
+ for (const v of frame.data.subarray(6)) {
2970
+ switch (object.length) {
2971
+ case 0:
2972
+ case 1: {
2973
+ object.push(v);
2974
+ break;
2975
+ }
2976
+ case 2: {
2977
+ object.push([v]);
2978
+ break;
2979
+ }
2980
+ case 3: {
2981
+ object[2].push(v);
2982
+ if (object[1] === object[2].length) {
2983
+ objects.push({ id: object[0], value: Buffer.from(object[2]).toString() });
2984
+ totalBytes += 2 + object[1];
2985
+ object = [];
2986
+ }
2987
+ break;
2988
+ }
2989
+ default:
2990
+ break;
2443
2991
  }
2444
- break;
2445
2992
  }
2993
+ if (objects.length !== frame.data[5]) {
2994
+ throw new Error('Invalid response');
2995
+ }
2996
+ if (frame.data.length !== 6 + totalBytes) {
2997
+ throw new Error('Invalid response');
2998
+ }
2999
+ frame.data = {
3000
+ readDeviceIDCode,
3001
+ conformityLevel: frame.data[2],
3002
+ moreFollows: frame.data[3] === 0xff,
3003
+ nextObjectId: frame.data[4],
3004
+ objects,
3005
+ };
3006
+ resolve(frame);
2446
3007
  }
2447
- }
2448
- if (objects.length !== frame.data[5])
2449
- throw new Error('Invalid response');
2450
- if (frame.data.length !== 6 + totalBytes)
2451
- throw new Error('Invalid response');
2452
- frame.data = {
2453
- readDeviceIDCode,
2454
- conformityLevel: frame.data[2],
2455
- moreFollows: frame.data[3] === 0xff,
2456
- nextObjectId: frame.data[4],
2457
- objects,
2458
- };
2459
- return frame;
3008
+ catch (e) {
3009
+ reject(e);
3010
+ }
3011
+ });
2460
3012
  });
2461
3013
  }
2462
3014
  addCustomFunctionCode(cfc) {
@@ -2469,15 +3021,28 @@ class ModbusMaster extends EventEmitter {
2469
3021
  }
2470
3022
  sendCustomFC(unit, fc, data, timeout = this.timeout) {
2471
3023
  const payload = Buffer.isBuffer(data) ? data : Buffer.from(data);
2472
- return this.send(unit, fc, payload, timeout, unit === 0).then((frame) => {
2473
- if (!frame)
2474
- return;
2475
- validateResponse(frame, unit, fc);
2476
- return frame.data;
3024
+ return new Promise((resolve, reject) => {
3025
+ this._send(unit, fc, payload, timeout, unit === 0, (err, frame) => {
3026
+ if (err) {
3027
+ reject(err);
3028
+ return;
3029
+ }
3030
+ if (!frame) {
3031
+ resolve(undefined);
3032
+ return;
3033
+ }
3034
+ try {
3035
+ validateResponse(frame, unit, fc);
3036
+ resolve(frame.data);
3037
+ }
3038
+ catch (e) {
3039
+ reject(e);
3040
+ }
3041
+ });
2477
3042
  });
2478
3043
  }
2479
3044
  /**
2480
- * Open the underlying physical layer and begin accepting connections.
3045
+ * Open the underlying physical layer and establish a connection.
2481
3046
  *
2482
3047
  * A `ModbusMaster` instance can only be opened once. Once {@link close}
2483
3048
  * is called — explicitly or because the physical layer disconnected —
@@ -2499,9 +3064,25 @@ class ModbusMaster extends EventEmitter {
2499
3064
  const end = this._queueHead + this._queueLen;
2500
3065
  this._queueLen = 0;
2501
3066
  for (let i = this._queueHead; i < end; i++) {
2502
- this._queueRejects[i](rejectErr);
3067
+ this._queueCallbacks[i](rejectErr);
2503
3068
  }
2504
3069
  this._masterSession.stopAll(rejectErr);
3070
+ this._timerHeap.clear();
3071
+ // Settle any in-flight exchanges that weren't reached by stopAll
3072
+ // (broadcasts have no session waiter; non-broadcasts still in the
3073
+ // pre-write-window haven't registered in session yet).
3074
+ for (const pending of this._pendingExchanges.values()) {
3075
+ if (pending.settled) {
3076
+ continue;
3077
+ }
3078
+ pending.settled = true;
3079
+ const cb = pending.callback;
3080
+ if (cb) {
3081
+ pending.callback = null;
3082
+ cb(rejectErr);
3083
+ }
3084
+ }
3085
+ this._pendingExchanges.clear();
2505
3086
  let err = null;
2506
3087
  try {
2507
3088
  await promisifyCb((cb) => this._physicalLayer.close(cb));
@@ -2538,7 +3119,9 @@ class ModbusSlave extends EventEmitter {
2538
3119
  _protocol;
2539
3120
  _appLayers = new Map();
2540
3121
  _customFunctionCodes = new Map();
2541
- _locks = new Map();
3122
+ // Active interval locks. Typical length: 0 (no contention) — 1-2 (cross-
3123
+ // connection contention). Linear scan for overlap is sub-µs.
3124
+ _intervalLocks = [];
2542
3125
  _cleanupFns = new Set();
2543
3126
  _closePromise = null;
2544
3127
  get state() {
@@ -2565,7 +3148,9 @@ class ModbusSlave extends EventEmitter {
2565
3148
  for (const cfc of this._customFunctionCodes.values()) {
2566
3149
  appLayer.addCustomFunctionCode(cfc);
2567
3150
  }
2568
- const cleanupFraming = () => appLayer.off('framing', onFraming);
3151
+ const cleanupFraming = () => {
3152
+ appLayer.onFraming = NOOP;
3153
+ };
2569
3154
  const onFraming = (frame) => {
2570
3155
  if (this._physicalLayer.state !== exports.PhysicalState.OPEN) {
2571
3156
  return;
@@ -2580,7 +3165,7 @@ class ModbusSlave extends EventEmitter {
2580
3165
  appLayerData.queues.frames.push(frame);
2581
3166
  this._drain(appLayer, appLayerData.queues);
2582
3167
  };
2583
- appLayer.on('framing', onFraming);
3168
+ appLayer.onFraming = onFraming;
2584
3169
  this._cleanupFns.add(cleanupFraming);
2585
3170
  const cleanupClose = () => connection.off('close', onClose);
2586
3171
  const onClose = () => {
@@ -2647,10 +3232,66 @@ class ModbusSlave extends EventEmitter {
2647
3232
  const byteCount = (length + 7) >> 3;
2648
3233
  const pdu = Buffer.allocUnsafe(byteCount + 1);
2649
3234
  pdu[0] = byteCount;
2650
- pdu.fill(0, 1, byteCount + 1);
2651
- for (let i = 0; i < length; i++) {
2652
- if (coils[i]) {
2653
- pdu[1 + (i >> 3)] |= 1 << (i & 7);
3235
+ if (coils instanceof Uint8Array) {
3236
+ // Branchless fast path `coils[i]` is already 0/1, no boolean
3237
+ // coercion or conditional jumps. At max payload (2000 coils) this
3238
+ // avoids 2000 branch-predictor slots and boolean-to-number casts.
3239
+ let out = 1;
3240
+ const fullBytes = length >> 3;
3241
+ for (let i = 0; i < fullBytes; i++) {
3242
+ const base = i << 3;
3243
+ pdu[out++] =
3244
+ (coils[base] & 1) |
3245
+ ((coils[base + 1] & 1) << 1) |
3246
+ ((coils[base + 2] & 1) << 2) |
3247
+ ((coils[base + 3] & 1) << 3) |
3248
+ ((coils[base + 4] & 1) << 4) |
3249
+ ((coils[base + 5] & 1) << 5) |
3250
+ ((coils[base + 6] & 1) << 6) |
3251
+ ((coils[base + 7] & 1) << 7);
3252
+ }
3253
+ const rem = length & 7;
3254
+ if (rem) {
3255
+ const base = fullBytes << 3;
3256
+ let acc = coils[base] & 1;
3257
+ if (rem > 1) {
3258
+ acc |= (coils[base + 1] & 1) << 1;
3259
+ }
3260
+ if (rem > 2) {
3261
+ acc |= (coils[base + 2] & 1) << 2;
3262
+ }
3263
+ if (rem > 3) {
3264
+ acc |= (coils[base + 3] & 1) << 3;
3265
+ }
3266
+ if (rem > 4) {
3267
+ acc |= (coils[base + 4] & 1) << 4;
3268
+ }
3269
+ if (rem > 5) {
3270
+ acc |= (coils[base + 5] & 1) << 5;
3271
+ }
3272
+ if (rem > 6) {
3273
+ acc |= (coils[base + 6] & 1) << 6;
3274
+ }
3275
+ pdu[out] = acc;
3276
+ }
3277
+ }
3278
+ else {
3279
+ // Fallback for boolean[] — accumulate into `acc` and write a full byte
3280
+ // once each lane is finished. Saves N `|=` read-modify-writes on the
3281
+ // output buffer.
3282
+ let acc = 0;
3283
+ let out = 1;
3284
+ for (let i = 0; i < length; i++) {
3285
+ if (coils[i]) {
3286
+ acc |= 1 << (i & 7);
3287
+ }
3288
+ if ((i & 7) === 7) {
3289
+ pdu[out++] = acc;
3290
+ acc = 0;
3291
+ }
3292
+ }
3293
+ if ((length & 7) !== 0) {
3294
+ pdu[out] = acc;
2654
3295
  }
2655
3296
  }
2656
3297
  await response(appLayer.encode(frame.unit, frame.fc, pdu, frame.transaction));
@@ -2683,10 +3324,60 @@ class ModbusSlave extends EventEmitter {
2683
3324
  const byteCount = (length + 7) >> 3;
2684
3325
  const pdu = Buffer.allocUnsafe(byteCount + 1);
2685
3326
  pdu[0] = byteCount;
2686
- pdu.fill(0, 1, byteCount + 1);
2687
- for (let i = 0; i < length; i++) {
2688
- if (discreteInputs[i]) {
2689
- pdu[1 + (i >> 3)] |= 1 << (i & 7);
3327
+ if (discreteInputs instanceof Uint8Array) {
3328
+ let out = 1;
3329
+ const fullBytes = length >> 3;
3330
+ for (let i = 0; i < fullBytes; i++) {
3331
+ const base = i << 3;
3332
+ pdu[out++] =
3333
+ (discreteInputs[base] & 1) |
3334
+ ((discreteInputs[base + 1] & 1) << 1) |
3335
+ ((discreteInputs[base + 2] & 1) << 2) |
3336
+ ((discreteInputs[base + 3] & 1) << 3) |
3337
+ ((discreteInputs[base + 4] & 1) << 4) |
3338
+ ((discreteInputs[base + 5] & 1) << 5) |
3339
+ ((discreteInputs[base + 6] & 1) << 6) |
3340
+ ((discreteInputs[base + 7] & 1) << 7);
3341
+ }
3342
+ const rem = length & 7;
3343
+ if (rem) {
3344
+ const base = fullBytes << 3;
3345
+ let acc = discreteInputs[base] & 1;
3346
+ if (rem > 1) {
3347
+ acc |= (discreteInputs[base + 1] & 1) << 1;
3348
+ }
3349
+ if (rem > 2) {
3350
+ acc |= (discreteInputs[base + 2] & 1) << 2;
3351
+ }
3352
+ if (rem > 3) {
3353
+ acc |= (discreteInputs[base + 3] & 1) << 3;
3354
+ }
3355
+ if (rem > 4) {
3356
+ acc |= (discreteInputs[base + 4] & 1) << 4;
3357
+ }
3358
+ if (rem > 5) {
3359
+ acc |= (discreteInputs[base + 5] & 1) << 5;
3360
+ }
3361
+ if (rem > 6) {
3362
+ acc |= (discreteInputs[base + 6] & 1) << 6;
3363
+ }
3364
+ pdu[out] = acc;
3365
+ }
3366
+ }
3367
+ else {
3368
+ let acc = 0;
3369
+ let out = 1;
3370
+ for (let i = 0; i < length; i++) {
3371
+ if (discreteInputs[i]) {
3372
+ acc |= 1 << (i & 7);
3373
+ }
3374
+ if ((i & 7) === 7) {
3375
+ pdu[out++] = acc;
3376
+ acc = 0;
3377
+ }
3378
+ }
3379
+ if ((length & 7) !== 0) {
3380
+ pdu[out] = acc;
2690
3381
  }
2691
3382
  }
2692
3383
  await response(appLayer.encode(frame.unit, frame.fc, pdu, frame.transaction));
@@ -2718,8 +3409,15 @@ class ModbusSlave extends EventEmitter {
2718
3409
  const registers = await model.readHoldingRegisters(address, length);
2719
3410
  const pdu = Buffer.allocUnsafe(length * 2 + 1);
2720
3411
  pdu[0] = length * 2;
3412
+ // Inline big-endian write — `pdu[i] = v` is a direct typed-array store,
3413
+ // while `writeUInt16BE` runs argument validation + bounds checks on each
3414
+ // call. At length=125 (FC3 max) that's 250 saved checks per request.
3415
+ let off = 1;
2721
3416
  for (let i = 0; i < length; i++) {
2722
- pdu.writeUInt16BE(registers[i], 1 + i * 2);
3417
+ const v = registers[i];
3418
+ pdu[off] = (v >>> 8) & 0xff;
3419
+ pdu[off + 1] = v & 0xff;
3420
+ off += 2;
2723
3421
  }
2724
3422
  await response(appLayer.encode(frame.unit, frame.fc, pdu, frame.transaction));
2725
3423
  }
@@ -2750,8 +3448,13 @@ class ModbusSlave extends EventEmitter {
2750
3448
  const registers = await model.readInputRegisters(address, length);
2751
3449
  const pdu = Buffer.allocUnsafe(length * 2 + 1);
2752
3450
  pdu[0] = length * 2;
3451
+ // Inline big-endian write — see handleFC3 for the rationale.
3452
+ let off = 1;
2753
3453
  for (let i = 0; i < length; i++) {
2754
- pdu.writeUInt16BE(registers[i], 1 + i * 2);
3454
+ const v = registers[i];
3455
+ pdu[off] = (v >>> 8) & 0xff;
3456
+ pdu[off + 1] = v & 0xff;
3457
+ off += 2;
2755
3458
  }
2756
3459
  await response(appLayer.encode(frame.unit, frame.fc, pdu, frame.transaction));
2757
3460
  }
@@ -2830,8 +3533,42 @@ class ModbusSlave extends EventEmitter {
2830
3533
  return;
2831
3534
  }
2832
3535
  const value = new Array(length);
2833
- for (let i = 0; i < length; i++) {
2834
- value[i] = (frame.data[5 + Math.floor(i / 8)] & (1 << i % 8)) > 0;
3536
+ let byteIdx = 5;
3537
+ let outIdx = 0;
3538
+ const fullBytes = length >> 3;
3539
+ for (let b = 0; b < fullBytes; b++) {
3540
+ const byte = frame.data[byteIdx++];
3541
+ value[outIdx++] = (byte & 0x01) > 0;
3542
+ value[outIdx++] = (byte & 0x02) > 0;
3543
+ value[outIdx++] = (byte & 0x04) > 0;
3544
+ value[outIdx++] = (byte & 0x08) > 0;
3545
+ value[outIdx++] = (byte & 0x10) > 0;
3546
+ value[outIdx++] = (byte & 0x20) > 0;
3547
+ value[outIdx++] = (byte & 0x40) > 0;
3548
+ value[outIdx++] = (byte & 0x80) > 0;
3549
+ }
3550
+ const rem = length & 7;
3551
+ if (rem) {
3552
+ const byte = frame.data[byteIdx];
3553
+ value[outIdx++] = (byte & 0x01) > 0;
3554
+ if (rem > 1) {
3555
+ value[outIdx++] = (byte & 0x02) > 0;
3556
+ }
3557
+ if (rem > 2) {
3558
+ value[outIdx++] = (byte & 0x04) > 0;
3559
+ }
3560
+ if (rem > 3) {
3561
+ value[outIdx++] = (byte & 0x08) > 0;
3562
+ }
3563
+ if (rem > 4) {
3564
+ value[outIdx++] = (byte & 0x10) > 0;
3565
+ }
3566
+ if (rem > 5) {
3567
+ value[outIdx++] = (byte & 0x20) > 0;
3568
+ }
3569
+ if (rem > 6) {
3570
+ value[outIdx++] = (byte & 0x40) > 0;
3571
+ }
2835
3572
  }
2836
3573
  try {
2837
3574
  if (model.writeMultipleCoils) {
@@ -2869,8 +3606,10 @@ class ModbusSlave extends EventEmitter {
2869
3606
  return;
2870
3607
  }
2871
3608
  const value = new Array(length);
3609
+ let off = 5;
2872
3610
  for (let i = 0; i < length; i++) {
2873
- value[i] = (frame.data[5 + i * 2] << 8) | frame.data[6 + i * 2];
3611
+ value[i] = (frame.data[off] << 8) | frame.data[off + 1];
3612
+ off += 2;
2874
3613
  }
2875
3614
  try {
2876
3615
  if (model.writeMultipleRegisters) {
@@ -2935,7 +3674,7 @@ class ModbusSlave extends EventEmitter {
2935
3674
  return;
2936
3675
  }
2937
3676
  try {
2938
- await this._withAddressLock([address], async () => {
3677
+ await this._withIntervalLock(address, address + 1, async () => {
2939
3678
  if (model.maskWriteRegister) {
2940
3679
  await model.maskWriteRegister(address, andMask, orMask);
2941
3680
  }
@@ -2981,12 +3720,13 @@ class ModbusSlave extends EventEmitter {
2981
3720
  return;
2982
3721
  }
2983
3722
  const value = new Array(length.write);
3723
+ let off = 9;
2984
3724
  for (let i = 0; i < length.write; i++) {
2985
- value[i] = (frame.data[9 + i * 2] << 8) | frame.data[10 + i * 2];
3725
+ value[i] = (frame.data[off] << 8) | frame.data[off + 1];
3726
+ off += 2;
2986
3727
  }
2987
3728
  try {
2988
- const writeAddresses = Array.from({ length: length.write }, (_, i) => address.write + i);
2989
- await this._withAddressLock(writeAddresses, async () => {
3729
+ await this._withIntervalLock(address.write, address.write + length.write, async () => {
2990
3730
  if (model.writeMultipleRegisters) {
2991
3731
  await model.writeMultipleRegisters(address.write, value);
2992
3732
  }
@@ -2999,8 +3739,13 @@ class ModbusSlave extends EventEmitter {
2999
3739
  const registers = await model.readHoldingRegisters(address.read, length.read);
3000
3740
  const pdu = Buffer.allocUnsafe(length.read * 2 + 1);
3001
3741
  pdu[0] = length.read * 2;
3742
+ // Inline big-endian write — see handleFC3 for the rationale.
3743
+ let off = 1;
3002
3744
  for (let i = 0; i < length.read; i++) {
3003
- pdu.writeUInt16BE(registers[i], 1 + i * 2);
3745
+ const v = registers[i];
3746
+ pdu[off] = (v >>> 8) & 0xff;
3747
+ pdu[off + 1] = v & 0xff;
3748
+ off += 2;
3004
3749
  }
3005
3750
  await response(appLayer.encode(frame.unit, frame.fc, pdu, frame.transaction));
3006
3751
  }
@@ -3159,7 +3904,11 @@ class ModbusSlave extends EventEmitter {
3159
3904
  return Promise.resolve();
3160
3905
  }
3161
3906
  return new Promise((resolve) => {
3162
- appLayer.connection.write(data, () => resolve());
3907
+ // Pass `resolve` directly as the write cb (vs `() => resolve()`)
3908
+ // saves one closure allocation per response. `resolve` may receive
3909
+ // the write's err arg, but our Promise<void> contract ignores any
3910
+ // resolved value and the response handler doesn't check success.
3911
+ appLayer.connection.write(data, resolve);
3163
3912
  });
3164
3913
  };
3165
3914
  // Hot path: unicast to a known unit dispatches to a single model.
@@ -3208,24 +3957,68 @@ class ModbusSlave extends EventEmitter {
3208
3957
  return true;
3209
3958
  }
3210
3959
  }
3211
- async _withAddressLock(addresses, fn) {
3212
- const sorted = [...new Set(addresses)].sort((a, b) => a - b);
3213
- const previous = sorted.map((addr) => this._locks.get(addr) ?? Promise.resolve());
3214
- const work = Promise.all(previous).then(() => fn());
3215
- const cleanup = work.catch(() => {
3216
- /* ignore */
3217
- });
3218
- for (const addr of sorted) {
3219
- this._locks.set(addr, cleanup);
3960
+ /**
3961
+ * Serialize a code block over the half-open address interval `[lo, hi)`.
3962
+ * The block runs after all previously-installed locks whose intervals
3963
+ * overlap with this one have completed. Two non-overlapping intervals
3964
+ * execute in parallel.
3965
+ *
3966
+ * Locks are tracked in a flat array (`_intervalLocks`); the typical depth
3967
+ * is 0-2 entries, so the linear overlap scan is sub-µs. Compare with the
3968
+ * old per-address Map design, where FC23 writing 121 registers allocated
3969
+ * ~125 objects per request (one Promise.resolve / address + Set + sort +
3970
+ * Promise.all); this version allocates 1-3.
3971
+ */
3972
+ async _withIntervalLock(lo, hi, fn) {
3973
+ // Find overlapping active entries. Two half-open intervals [a, b) and
3974
+ // [c, d) overlap iff a < d && c < b.
3975
+ let overlap = null;
3976
+ const locks = this._intervalLocks;
3977
+ for (let i = 0; i < locks.length; i++) {
3978
+ const l = locks[i];
3979
+ if (l.lo < hi && lo < l.hi) {
3980
+ if (overlap === null) {
3981
+ overlap = [];
3982
+ }
3983
+ overlap.push(l.promise);
3984
+ }
3985
+ }
3986
+ // Install our entry BEFORE awaiting so any concurrent caller arriving
3987
+ // after this point sees us and waits. Promise field is filled in below.
3988
+ const entry = { lo, hi, promise: undefined };
3989
+ locks.push(entry);
3990
+ let work;
3991
+ if (overlap === null) {
3992
+ work = fn();
3993
+ }
3994
+ else if (overlap.length === 1) {
3995
+ // Skip Promise.all when there's exactly one prior — avoids one
3996
+ // intermediate Promise allocation in the most common contention case.
3997
+ // `.then(fn)` (vs `.then(() => fn())`) saves a closure: fn's signature
3998
+ // is () => Promise<T>, and the resolved value `.then` passes in is
3999
+ // silently discarded by JS's loose-arity rule.
4000
+ work = overlap[0].then(fn);
4001
+ }
4002
+ else {
4003
+ work = Promise.all(overlap).then(fn);
3220
4004
  }
4005
+ // We never want the cleanup latch to reject — swallow errors so
4006
+ // downstream `.then` chains don't see an unhandled rejection. Using the
4007
+ // shared `NOOP` singleton avoids allocating a fresh arrow function on
4008
+ // every locked request.
4009
+ entry.promise = work.catch(NOOP);
3221
4010
  try {
3222
4011
  return await work;
3223
4012
  }
3224
4013
  finally {
3225
- for (const addr of sorted) {
3226
- if (this._locks.get(addr) === cleanup) {
3227
- this._locks.delete(addr);
4014
+ const i = locks.indexOf(entry);
4015
+ if (i !== -1) {
4016
+ // O(1) swap-and-pop since lock order doesn't matter for correctness.
4017
+ const last = locks.length - 1;
4018
+ if (i !== last) {
4019
+ locks[i] = locks[last];
3228
4020
  }
4021
+ locks.pop();
3229
4022
  }
3230
4023
  }
3231
4024
  }
@@ -3356,6 +4149,7 @@ exports.MasterSession = MasterSession;
3356
4149
  exports.ModbusError = ModbusError;
3357
4150
  exports.ModbusMaster = ModbusMaster;
3358
4151
  exports.ModbusSlave = ModbusSlave;
4152
+ exports.NOOP = NOOP;
3359
4153
  exports.RtuApplicationLayer = RtuApplicationLayer;
3360
4154
  exports.SerialPhysicalLayer = SerialPhysicalLayer;
3361
4155
  exports.TcpApplicationLayer = TcpApplicationLayer;