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