thread-stream 4.0.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(npx tap:*)",
5
+ "Bash(node:*)",
6
+ "Bash(for i in 1 2 3 4 5)",
7
+ "Bash(do)",
8
+ "Bash(echo:*)",
9
+ "Bash(done)",
10
+ "Bash(npm test:*)"
11
+ ],
12
+ "deny": [],
13
+ "ask": []
14
+ }
15
+ }
@@ -24,7 +24,7 @@ jobs:
24
24
  contents: read
25
25
  steps:
26
26
  - name: Check out repo
27
- uses: actions/checkout@v4
27
+ uses: actions/checkout@v6
28
28
  with:
29
29
  persist-credentials: false
30
30
 
@@ -38,17 +38,17 @@ jobs:
38
38
  contents: read
39
39
  strategy:
40
40
  matrix:
41
- node-version: [20, 22, 24]
41
+ node-version: [20, 22, 24, 26]
42
42
  os: [macos-latest, ubuntu-latest, windows-latest]
43
43
 
44
44
  steps:
45
45
  - name: Check out repo
46
- uses: actions/checkout@v4
46
+ uses: actions/checkout@v6
47
47
  with:
48
48
  persist-credentials: false
49
49
 
50
50
  - name: Setup Node ${{ matrix.node-version }}
51
- uses: actions/setup-node@v4
51
+ uses: actions/setup-node@v6
52
52
  with:
53
53
  node-version: ${{ matrix.node-version }}
54
54
 
package/README.md CHANGED
@@ -39,6 +39,8 @@ stream.flush(function () {
39
39
  })
40
40
  ```
41
41
 
42
+ `flush(cb)` waits for the worker destination flush when supported (`flush`, `flushSync`, or pending `drain`).
43
+
42
44
  In `worker.js`:
43
45
 
44
46
  ```js
@@ -87,7 +89,8 @@ This module works with `yarn` in PnP (plug'n play) mode too!
87
89
  ### Emit events
88
90
 
89
91
  You can emit events on the ThreadStream from your worker using [`worker.parentPort.postMessage()`](https://nodejs.org/api/worker_threads.html#workerparentport).
90
- The message (JSON object) must have the following data structure:
92
+ Messages that do not carry a thread-stream protocol `code` are ignored.
93
+ For custom events, the message (JSON object) must have the following data structure:
91
94
 
92
95
  ```js
93
96
  parentPort.postMessage({
package/index.d.ts CHANGED
@@ -59,6 +59,13 @@ declare class ThreadStream extends EventEmitter {
59
59
  * Calling the {@link write()} method after calling {@link end()} will emit an error.
60
60
  */
61
61
  end(): void
62
+ /**
63
+ * Flush the stream asynchronously.
64
+ *
65
+ * The callback is invoked once data has been consumed by the worker and the
66
+ * worker destination has acknowledged the flush.
67
+ */
68
+ flush(cb?: (err?: Error) => void): void
62
69
  /**
63
70
  * Flush the stream synchronously.
64
71
  * This method should be called in the shutdown phase to make sure that all data has been flushed.
package/index.js CHANGED
@@ -8,16 +8,33 @@ const { pathToFileURL } = require('url')
8
8
  const { wait } = require('./lib/wait')
9
9
  const {
10
10
  WRITE_INDEX,
11
- READ_INDEX
11
+ READ_INDEX,
12
+ SEQ_INDEX
12
13
  } = require('./lib/indexes')
13
14
  const buffer = require('buffer')
14
15
  const assert = require('assert')
15
16
 
16
17
  const kImpl = Symbol('kImpl')
17
18
 
18
- // V8 limit for string size
19
+ // Maximum pending buffered data before forcing a synchronous drain
19
20
  const MAX_STRING = buffer.constants.MAX_STRING_LENGTH
20
21
 
22
+ function noop () {}
23
+
24
+ function updateState (stream, fn) {
25
+ Atomics.add(stream[kImpl].state, SEQ_INDEX, 1)
26
+ fn()
27
+ Atomics.add(stream[kImpl].state, SEQ_INDEX, 1)
28
+ Atomics.notify(stream[kImpl].state, SEQ_INDEX)
29
+ }
30
+
31
+ function resetIndexes (stream) {
32
+ updateState(stream, () => {
33
+ Atomics.store(stream[kImpl].state, READ_INDEX, 0)
34
+ Atomics.store(stream[kImpl].state, WRITE_INDEX, 0)
35
+ })
36
+ }
37
+
21
38
  class FakeWeakRef {
22
39
  constructor (value) {
23
40
  this._value = value
@@ -54,6 +71,7 @@ function createWorker (stream, opts) {
54
71
 
55
72
  const worker = new Worker(toExecute, {
56
73
  ...opts.workerOpts,
74
+ name: opts.workerOpts?.name || 'thread-stream',
57
75
  trackUnmanagedFds: false,
58
76
  workerData: {
59
77
  filename: filename.indexOf('file://') === 0
@@ -90,66 +108,46 @@ function drain (stream) {
90
108
  }
91
109
 
92
110
  function nextFlush (stream) {
93
- const writeIndex = Atomics.load(stream[kImpl].state, WRITE_INDEX)
94
- let leftover = stream[kImpl].data.length - writeIndex
111
+ while (true) {
112
+ const writeIndex = Atomics.load(stream[kImpl].state, WRITE_INDEX)
113
+ const leftover = stream[kImpl].data.length - writeIndex
114
+
115
+ if (leftover > 0) {
116
+ if (stream[kImpl].bufLen === 0) {
117
+ stream[kImpl].flushing = false
95
118
 
96
- if (leftover > 0) {
97
- if (stream[kImpl].buf.length === 0) {
98
- stream[kImpl].flushing = false
119
+ if (stream[kImpl].ending) {
120
+ end(stream)
121
+ } else if (stream[kImpl].needDrain) {
122
+ process.nextTick(drain, stream)
123
+ }
99
124
 
100
- if (stream[kImpl].ending) {
101
- end(stream)
102
- } else if (stream[kImpl].needDrain) {
103
- process.nextTick(drain, stream)
125
+ return
104
126
  }
105
127
 
106
- return
128
+ write(stream, leftover, noop)
129
+ continue
107
130
  }
108
131
 
109
- let toWrite = stream[kImpl].buf.slice(0, leftover)
110
- let toWriteBytes = Buffer.byteLength(toWrite)
111
- if (toWriteBytes <= leftover) {
112
- stream[kImpl].buf = stream[kImpl].buf.slice(leftover)
113
- // process._rawDebug('writing ' + toWrite.length)
114
- write(stream, toWrite, nextFlush.bind(null, stream))
115
- } else {
116
- // multi-byte utf-8
117
- stream.flush(() => {
118
- // err is already handled in flush()
132
+ if (leftover === 0) {
133
+ if (writeIndex === 0 && stream[kImpl].bufLen === 0) {
134
+ // we had a flushSync in the meanwhile
135
+ return
136
+ }
137
+ waitForRead(stream, () => {
119
138
  if (stream.destroyed) {
120
139
  return
121
140
  }
122
141
 
123
- Atomics.store(stream[kImpl].state, READ_INDEX, 0)
124
- Atomics.store(stream[kImpl].state, WRITE_INDEX, 0)
125
- Atomics.notify(stream[kImpl].state, READ_INDEX)
126
-
127
- // Find a toWrite length that fits the buffer
128
- // it must exists as the buffer is at least 4 bytes length
129
- // and the max utf-8 length for a char is 4 bytes.
130
- while (toWriteBytes > stream[kImpl].data.length) {
131
- leftover = leftover / 2
132
- toWrite = stream[kImpl].buf.slice(0, leftover)
133
- toWriteBytes = Buffer.byteLength(toWrite)
134
- }
135
- stream[kImpl].buf = stream[kImpl].buf.slice(leftover)
136
- write(stream, toWrite, nextFlush.bind(null, stream))
142
+ resetIndexes(stream)
143
+ nextFlush(stream)
137
144
  })
138
- }
139
- } else if (leftover === 0) {
140
- if (writeIndex === 0 && stream[kImpl].buf.length === 0) {
141
- // we had a flushSync in the meanwhile
142
145
  return
143
146
  }
144
- stream.flush(() => {
145
- Atomics.store(stream[kImpl].state, READ_INDEX, 0)
146
- Atomics.store(stream[kImpl].state, WRITE_INDEX, 0)
147
- Atomics.notify(stream[kImpl].state, READ_INDEX)
148
- nextFlush(stream)
149
- })
150
- } else {
147
+
151
148
  // This should never happen
152
149
  destroy(stream, new Error('overwritten'))
150
+ return
153
151
  }
154
152
  }
155
153
 
@@ -162,13 +160,19 @@ function onWorkerMessage (msg) {
162
160
  return
163
161
  }
164
162
 
163
+ // Node.js watch mode may send internal worker messages that do not
164
+ // participate in thread-stream's worker protocol.
165
+ if (msg?.code == null) {
166
+ return
167
+ }
168
+
165
169
  switch (msg.code) {
166
170
  case 'READY':
167
171
  // Replace the FakeWeakRef with a
168
172
  // proper one.
169
173
  this.stream = new WeakRef(stream)
170
174
 
171
- stream.flush(() => {
175
+ waitForRead(stream, () => {
172
176
  stream[kImpl].ready = true
173
177
  stream.emit('ready')
174
178
  })
@@ -183,6 +187,19 @@ function onWorkerMessage (msg) {
183
187
  stream.emit(msg.name, msg.args)
184
188
  }
185
189
  break
190
+ case 'FLUSHED': {
191
+ if (msg.context !== 'thread-stream') {
192
+ destroy(stream, new Error('this should not happen: ' + msg.code))
193
+ break
194
+ }
195
+
196
+ const cb = stream[kImpl].flushCallbacks.get(msg.id)
197
+ if (cb) {
198
+ stream[kImpl].flushCallbacks.delete(msg.id)
199
+ process.nextTick(cb)
200
+ }
201
+ break
202
+ }
186
203
  case 'WARNING':
187
204
  process.emitWarning(msg.err)
188
205
  break
@@ -226,7 +243,11 @@ class ThreadStream extends EventEmitter {
226
243
  this[kImpl].finished = false
227
244
  this[kImpl].errored = null
228
245
  this[kImpl].closed = false
229
- this[kImpl].buf = ''
246
+ this[kImpl].buf = []
247
+ this[kImpl].bufHead = 0
248
+ this[kImpl].bufLen = 0
249
+ this[kImpl].flushCallbacks = new Map()
250
+ this[kImpl].nextFlushId = 0
230
251
 
231
252
  // TODO (fix): Make private?
232
253
  this.worker = createWorker(this, opts) // TODO (fix): make private
@@ -236,6 +257,7 @@ class ThreadStream extends EventEmitter {
236
257
  }
237
258
 
238
259
  write (data) {
260
+ const dataBuf = Buffer.isBuffer(data) ? data : Buffer.from(data)
239
261
  if (this[kImpl].destroyed) {
240
262
  error(this, new Error('the worker has exited'))
241
263
  return false
@@ -246,7 +268,7 @@ class ThreadStream extends EventEmitter {
246
268
  return false
247
269
  }
248
270
 
249
- if (this[kImpl].flushing && this[kImpl].buf.length + data.length >= MAX_STRING) {
271
+ if (this[kImpl].flushing && this[kImpl].bufLen + dataBuf.length >= MAX_STRING) {
250
272
  try {
251
273
  writeSync(this)
252
274
  this[kImpl].flushing = true
@@ -256,7 +278,8 @@ class ThreadStream extends EventEmitter {
256
278
  }
257
279
  }
258
280
 
259
- this[kImpl].buf += data
281
+ this[kImpl].buf.push(dataBuf)
282
+ this[kImpl].bufLen += dataBuf.length
260
283
 
261
284
  if (this[kImpl].sync) {
262
285
  try {
@@ -273,7 +296,7 @@ class ThreadStream extends EventEmitter {
273
296
  setImmediate(nextFlush, this)
274
297
  }
275
298
 
276
- this[kImpl].needDrain = this[kImpl].data.length - this[kImpl].buf.length - Atomics.load(this[kImpl].state, WRITE_INDEX) <= 0
299
+ this[kImpl].needDrain = this[kImpl].data.length - this[kImpl].bufLen - Atomics.load(this[kImpl].state, WRITE_INDEX) <= 0
277
300
  return !this[kImpl].needDrain
278
301
  }
279
302
 
@@ -287,28 +310,15 @@ class ThreadStream extends EventEmitter {
287
310
  }
288
311
 
289
312
  flush (cb) {
290
- if (this[kImpl].destroyed) {
291
- if (typeof cb === 'function') {
292
- process.nextTick(cb, new Error('the worker has exited'))
293
- }
294
- return
295
- }
313
+ cb = typeof cb === 'function' ? cb : noop
296
314
 
297
- // TODO write all .buf
298
- const writeIndex = Atomics.load(this[kImpl].state, WRITE_INDEX)
299
- // process._rawDebug(`(flush) readIndex (${Atomics.load(this.state, READ_INDEX)}) writeIndex (${Atomics.load(this.state, WRITE_INDEX)})`)
300
- wait(this[kImpl].state, READ_INDEX, writeIndex, Infinity, (err, res) => {
315
+ flushBuffer(this, (err) => {
301
316
  if (err) {
302
- destroy(this, err)
303
317
  process.nextTick(cb, err)
304
318
  return
305
319
  }
306
- if (res === 'not-equal') {
307
- // TODO handle deadlock
308
- this.flush(cb)
309
- return
310
- }
311
- process.nextTick(cb)
320
+
321
+ requestWorkerFlush(this, cb)
312
322
  })
313
323
  }
314
324
 
@@ -366,6 +376,93 @@ class ThreadStream extends EventEmitter {
366
376
  }
367
377
  }
368
378
 
379
+ function flushBuffer (stream, cb) {
380
+ if (stream[kImpl].destroyed) {
381
+ process.nextTick(cb, new Error('the worker has exited'))
382
+ return
383
+ }
384
+
385
+ if (!stream[kImpl].sync && (stream[kImpl].flushing || stream[kImpl].bufLen > 0)) {
386
+ setImmediate(flushBuffer, stream, cb)
387
+ return
388
+ }
389
+
390
+ waitForRead(stream, cb)
391
+ }
392
+
393
+ function waitForRead (stream, cb) {
394
+ const writeIndex = Atomics.load(stream[kImpl].state, WRITE_INDEX)
395
+ wait(stream[kImpl].state, READ_INDEX, writeIndex, Infinity, (err, res) => {
396
+ if (err) {
397
+ destroy(stream, err)
398
+ cb(err)
399
+ return
400
+ }
401
+
402
+ if (res !== 'ok') {
403
+ waitForRead(stream, cb)
404
+ return
405
+ }
406
+
407
+ cb()
408
+ })
409
+ }
410
+
411
+ function requestWorkerFlush (stream, cb) {
412
+ if (stream[kImpl].destroyed) {
413
+ process.nextTick(cb, new Error('the worker has exited'))
414
+ return
415
+ }
416
+
417
+ if (!stream[kImpl].ready) {
418
+ const onReady = () => {
419
+ cleanup()
420
+ requestWorkerFlush(stream, cb)
421
+ }
422
+ const onClose = () => {
423
+ cleanup()
424
+ process.nextTick(cb, new Error('the worker has exited'))
425
+ }
426
+ const cleanup = () => {
427
+ stream.off('ready', onReady)
428
+ stream.off('close', onClose)
429
+ }
430
+
431
+ stream.once('ready', onReady)
432
+ stream.once('close', onClose)
433
+ return
434
+ }
435
+
436
+ const id = ++stream[kImpl].nextFlushId
437
+ stream[kImpl].flushCallbacks.set(id, cb)
438
+
439
+ try {
440
+ stream.worker.postMessage({
441
+ code: 'FLUSH',
442
+ context: 'thread-stream',
443
+ id
444
+ })
445
+ } catch (err) {
446
+ stream[kImpl].flushCallbacks.delete(id)
447
+ destroy(stream, err)
448
+ process.nextTick(cb, err)
449
+ }
450
+ }
451
+
452
+ function failPendingFlushCallbacks (stream, err) {
453
+ const callbacks = stream[kImpl].flushCallbacks
454
+ if (callbacks.size === 0) {
455
+ return
456
+ }
457
+
458
+ const flushErr = err || new Error('the worker has exited')
459
+
460
+ for (const cb of callbacks.values()) {
461
+ process.nextTick(cb, flushErr)
462
+ }
463
+ callbacks.clear()
464
+ }
465
+
369
466
  function error (stream, err) {
370
467
  setImmediate(() => {
371
468
  stream.emit('error', err)
@@ -377,6 +474,7 @@ function destroy (stream, err) {
377
474
  return
378
475
  }
379
476
  stream[kImpl].destroyed = true
477
+ failPendingFlushCallbacks(stream, err)
380
478
 
381
479
  if (err) {
382
480
  stream[kImpl].errored = err
@@ -398,13 +496,43 @@ function destroy (stream, err) {
398
496
  }
399
497
  }
400
498
 
401
- function write (stream, data, cb) {
499
+ function write (stream, maxBytes, cb) {
402
500
  // data is smaller than the shared buffer length
403
501
  const current = Atomics.load(stream[kImpl].state, WRITE_INDEX)
404
- const length = Buffer.byteLength(data)
405
- stream[kImpl].data.write(data, current)
406
- Atomics.store(stream[kImpl].state, WRITE_INDEX, current + length)
407
- Atomics.notify(stream[kImpl].state, WRITE_INDEX)
502
+ let offset = current
503
+ let remaining = maxBytes
504
+
505
+ while (remaining > 0 && stream[kImpl].bufLen !== 0) {
506
+ const head = stream[kImpl].bufHead
507
+ const buf = stream[kImpl].buf[head]
508
+
509
+ if (buf.length <= remaining) {
510
+ buf.copy(stream[kImpl].data, offset)
511
+ offset += buf.length
512
+ remaining -= buf.length
513
+ stream[kImpl].bufLen -= buf.length
514
+ stream[kImpl].bufHead = head + 1
515
+
516
+ if (stream[kImpl].bufHead === stream[kImpl].buf.length) {
517
+ stream[kImpl].buf.length = 0
518
+ stream[kImpl].bufHead = 0
519
+ } else if (stream[kImpl].bufHead >= 1024 && stream[kImpl].bufHead * 2 >= stream[kImpl].buf.length) {
520
+ stream[kImpl].buf.splice(0, stream[kImpl].bufHead)
521
+ stream[kImpl].bufHead = 0
522
+ }
523
+ continue
524
+ }
525
+
526
+ buf.copy(stream[kImpl].data, offset, 0, remaining)
527
+ stream[kImpl].buf[head] = buf.subarray(remaining)
528
+ stream[kImpl].bufLen -= remaining
529
+ offset += remaining
530
+ remaining = 0
531
+ }
532
+
533
+ updateState(stream, () => {
534
+ Atomics.store(stream[kImpl].state, WRITE_INDEX, offset)
535
+ })
408
536
  cb()
409
537
  return true
410
538
  }
@@ -421,9 +549,10 @@ function end (stream) {
421
549
  let readIndex = Atomics.load(stream[kImpl].state, READ_INDEX)
422
550
 
423
551
  // process._rawDebug('writing index')
424
- Atomics.store(stream[kImpl].state, WRITE_INDEX, -1)
552
+ updateState(stream, () => {
553
+ Atomics.store(stream[kImpl].state, WRITE_INDEX, -1)
554
+ })
425
555
  // process._rawDebug(`(end) readIndex (${Atomics.load(stream.state, READ_INDEX)}) writeIndex (${Atomics.load(stream.state, WRITE_INDEX)})`)
426
- Atomics.notify(stream[kImpl].state, WRITE_INDEX)
427
556
 
428
557
  // Wait for the process to complete
429
558
  let spins = 0
@@ -463,44 +592,19 @@ function writeSync (stream) {
463
592
  }
464
593
  stream[kImpl].flushing = false
465
594
 
466
- while (stream[kImpl].buf.length !== 0) {
595
+ while (stream[kImpl].bufLen !== 0) {
467
596
  const writeIndex = Atomics.load(stream[kImpl].state, WRITE_INDEX)
468
- let leftover = stream[kImpl].data.length - writeIndex
597
+ const leftover = stream[kImpl].data.length - writeIndex
469
598
  if (leftover === 0) {
470
599
  flushSync(stream)
471
- Atomics.store(stream[kImpl].state, READ_INDEX, 0)
472
- Atomics.store(stream[kImpl].state, WRITE_INDEX, 0)
473
- Atomics.notify(stream[kImpl].state, READ_INDEX)
600
+ resetIndexes(stream)
474
601
  continue
475
602
  } else if (leftover < 0) {
476
603
  // stream should never happen
477
604
  throw new Error('overwritten')
478
605
  }
479
606
 
480
- let toWrite = stream[kImpl].buf.slice(0, leftover)
481
- let toWriteBytes = Buffer.byteLength(toWrite)
482
- if (toWriteBytes <= leftover) {
483
- stream[kImpl].buf = stream[kImpl].buf.slice(leftover)
484
- // process._rawDebug('writing ' + toWrite.length)
485
- write(stream, toWrite, cb)
486
- } else {
487
- // multi-byte utf-8
488
- flushSync(stream)
489
- Atomics.store(stream[kImpl].state, READ_INDEX, 0)
490
- Atomics.store(stream[kImpl].state, WRITE_INDEX, 0)
491
- Atomics.notify(stream[kImpl].state, READ_INDEX)
492
-
493
- // Find a toWrite length that fits the buffer
494
- // it must exists as the buffer is at least 4 bytes length
495
- // and the max utf-8 length for a char is 4 bytes.
496
- while (toWriteBytes > stream[kImpl].buf.length) {
497
- leftover = leftover / 2
498
- toWrite = stream[kImpl].buf.slice(0, leftover)
499
- toWriteBytes = Buffer.byteLength(toWrite)
500
- }
501
- stream[kImpl].buf = stream[kImpl].buf.slice(leftover)
502
- write(stream, toWrite, cb)
503
- }
607
+ write(stream, leftover, cb)
504
608
  }
505
609
  }
506
610
 
package/lib/indexes.js CHANGED
@@ -1,9 +1,11 @@
1
1
  'use strict'
2
2
 
3
+ const SEQ_INDEX = 2
3
4
  const WRITE_INDEX = 4
4
5
  const READ_INDEX = 8
5
6
 
6
7
  module.exports = {
7
8
  WRITE_INDEX,
8
- READ_INDEX
9
+ READ_INDEX,
10
+ SEQ_INDEX
9
11
  }
package/lib/wait.js CHANGED
@@ -50,12 +50,21 @@ function waitDiff (state, index, expected, timeout, done) {
50
50
  return
51
51
  }
52
52
 
53
- // Wait for value to change from expected
53
+ // Wait for value to change from expected.
54
+ // If we are notified, resume immediately even if the value cycled back
55
+ // to the same number before we could re-read it.
54
56
  const remaining = max === Infinity ? WAIT_MS : Math.min(WAIT_MS, Math.max(1, max - Date.now()))
55
57
  const result = Atomics.waitAsync(state, index, expected, remaining)
56
58
 
57
59
  if (result.async) {
58
- result.value.then(check)
60
+ result.value.then((res) => {
61
+ if (res === 'ok') {
62
+ done(null, 'ok')
63
+ return
64
+ }
65
+
66
+ check()
67
+ })
59
68
  } else {
60
69
  // Value already changed (not-equal) - recheck on next tick
61
70
  setImmediate(check)
package/lib/worker.js CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  const { realImport, realRequire } = require('real-require')
4
4
  const { workerData, parentPort } = require('worker_threads')
5
- const { WRITE_INDEX, READ_INDEX } = require('./indexes')
5
+ const { StringDecoder } = require('string_decoder')
6
+ const { WRITE_INDEX, READ_INDEX, SEQ_INDEX } = require('./indexes')
6
7
  const { waitDiff } = require('./wait')
7
8
 
8
9
  const {
@@ -12,13 +13,111 @@ const {
12
13
  } = workerData
13
14
 
14
15
  let destination
16
+ const flushQueue = []
17
+ let flushing = false
15
18
 
16
19
  const state = new Int32Array(stateBuf)
17
20
  const data = Buffer.from(dataBuf)
21
+ const decoder = new StringDecoder('utf8')
18
22
 
19
23
  // Keep the event loop alive - Atomics.waitAsync promises don't prevent worker exit
20
24
  const keepAlive = setInterval(() => {}, 60 * 60 * 1000)
21
25
 
26
+ function onParentPortMessage (msg) {
27
+ if (!msg || msg.code !== 'FLUSH' || msg.context !== 'thread-stream') {
28
+ return
29
+ }
30
+
31
+ flushQueue.push(msg.id)
32
+ processFlushQueue()
33
+ }
34
+
35
+ function processFlushQueue () {
36
+ if (flushing || !destination) {
37
+ return
38
+ }
39
+
40
+ const id = flushQueue.shift()
41
+ if (id === undefined) {
42
+ return
43
+ }
44
+
45
+ flushing = true
46
+ flushDestination((err) => {
47
+ flushing = false
48
+
49
+ if (err) {
50
+ parentPort.postMessage({
51
+ code: 'ERROR',
52
+ err
53
+ })
54
+ return
55
+ }
56
+
57
+ parentPort.postMessage({
58
+ code: 'FLUSHED',
59
+ context: 'thread-stream',
60
+ id
61
+ })
62
+
63
+ processFlushQueue()
64
+ })
65
+ }
66
+
67
+ function flushDestination (cb) {
68
+ if (typeof destination?.flush === 'function') {
69
+ if (destination.flush.length === 0) {
70
+ try {
71
+ const result = destination.flush()
72
+ if (result && typeof result.then === 'function') {
73
+ result.then(() => cb(), cb)
74
+ } else {
75
+ cb()
76
+ }
77
+ } catch (err) {
78
+ cb(err)
79
+ }
80
+ return
81
+ }
82
+
83
+ let done = false
84
+ const onDone = (err) => {
85
+ if (done) {
86
+ return
87
+ }
88
+ done = true
89
+ cb(err)
90
+ }
91
+
92
+ try {
93
+ const result = destination.flush(onDone)
94
+ if (result && typeof result.then === 'function') {
95
+ result.then(() => onDone(), onDone)
96
+ }
97
+ } catch (err) {
98
+ onDone(err)
99
+ }
100
+ return
101
+ }
102
+
103
+ if (typeof destination?.flushSync === 'function') {
104
+ try {
105
+ destination.flushSync()
106
+ cb()
107
+ } catch (err) {
108
+ cb(err)
109
+ }
110
+ return
111
+ }
112
+
113
+ if (destination?.writableNeedDrain && !destination?.writableEnded) {
114
+ destination.once('drain', cb)
115
+ return
116
+ }
117
+
118
+ cb()
119
+ }
120
+
22
121
  async function start () {
23
122
  let worker
24
123
  try {
@@ -93,12 +192,16 @@ async function start () {
93
192
  process.exit(0)
94
193
  })
95
194
  })
195
+
196
+ processFlushQueue()
96
197
  }
97
198
 
98
199
  // No .catch() handler,
99
200
  // in case there is an error it goes
100
201
  // to unhandledRejection
101
202
  start().then(function () {
203
+ parentPort.on('message', onParentPortMessage)
204
+
102
205
  parentPort.postMessage({
103
206
  code: 'READY'
104
207
  })
@@ -106,18 +209,30 @@ start().then(function () {
106
209
  process.nextTick(run)
107
210
  })
108
211
 
212
+ function readState () {
213
+ while (true) {
214
+ const seq = Atomics.load(state, SEQ_INDEX)
215
+
216
+ if ((seq & 1) !== 0) {
217
+ continue
218
+ }
219
+
220
+ const current = Atomics.load(state, READ_INDEX)
221
+ const end = Atomics.load(state, WRITE_INDEX)
222
+
223
+ if (seq === Atomics.load(state, SEQ_INDEX)) {
224
+ return { current, end, seq }
225
+ }
226
+ }
227
+ }
228
+
109
229
  function run () {
110
- const current = Atomics.load(state, READ_INDEX)
111
- const end = Atomics.load(state, WRITE_INDEX)
230
+ const { current, end, seq } = readState()
112
231
 
113
232
  // process._rawDebug(`pre state ${current} ${end}`)
114
233
 
115
234
  if (end === current) {
116
- if (end === data.length) {
117
- waitDiff(state, READ_INDEX, end, Infinity, run)
118
- } else {
119
- waitDiff(state, WRITE_INDEX, end, Infinity, run)
120
- }
235
+ waitDiff(state, SEQ_INDEX, seq, Infinity, run)
121
236
  return
122
237
  }
123
238
 
@@ -125,11 +240,15 @@ function run () {
125
240
 
126
241
  if (end === -1) {
127
242
  // process._rawDebug('end')
243
+ const remaining = decoder.end()
244
+ if (remaining.length > 0) {
245
+ destination.write(remaining)
246
+ }
128
247
  destination.end()
129
248
  return
130
249
  }
131
250
 
132
- const toWrite = data.toString('utf8', current, end)
251
+ const toWrite = decoder.write(data.subarray(current, end))
133
252
  // process._rawDebug('worker writing: ' + toWrite)
134
253
 
135
254
  const res = destination.write(toWrite)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thread-stream",
3
- "version": "4.0.0",
3
+ "version": "4.2.0",
4
4
  "description": "A streaming way to send data to a Node.js Worker Thread",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -8,30 +8,28 @@
8
8
  "node": ">=20"
9
9
  },
10
10
  "dependencies": {
11
- "real-require": "^0.2.0"
11
+ "real-require": "^1.0.0"
12
12
  },
13
13
  "devDependencies": {
14
- "@types/node": "^22.0.0",
14
+ "@types/node": "^25.0.2",
15
15
  "@yao-pkg/pkg": "^6.0.0",
16
- "borp": "^0.21.0",
16
+ "borp": "^1.0.0",
17
17
  "desm": "^1.3.0",
18
18
  "eslint": "^9.39.1",
19
19
  "fastbench": "^1.0.1",
20
- "husky": "^9.0.6",
21
- "neostandard": "^0.12.2",
22
- "pino-elasticsearch": "^8.0.0",
23
- "sonic-boom": "^4.0.1",
20
+ "neostandard": "^0.13.0",
21
+ "pino-elasticsearch": "^9.0.0",
22
+ "sonic-boom": "^5.0.0",
24
23
  "ts-node": "^10.8.0",
25
24
  "typescript": "~5.7.3"
26
25
  },
27
26
  "scripts": {
28
27
  "build": "tsc --noEmit",
29
28
  "lint": "eslint",
30
- "test": "npm run lint && npm run build && npm run transpile && borp --pattern 'test/*.test.{js,mjs}'",
31
- "test:ci": "npm run lint && npm run transpile && borp --pattern 'test/*.test.{js,mjs}'",
32
- "test:yarn": "npm run transpile && borp --pattern 'test/*.test.js'",
33
- "transpile": "sh ./test/ts/transpile.sh",
34
- "prepare": "husky install"
29
+ "test": "npm run lint && npm run build && npm run transpile && borp --pattern \"test/*.test.{js,mjs}\"",
30
+ "test:ci": "npm run lint && npm run transpile && borp --pattern \"test/*.test.{js,mjs}\"",
31
+ "test:yarn": "npm run transpile && borp --pattern \"test/*.test.js\"",
32
+ "transpile": "sh ./test/ts/transpile.sh"
35
33
  },
36
34
  "repository": {
37
35
  "type": "git",
@@ -0,0 +1,68 @@
1
+ 'use strict'
2
+
3
+ const { EventEmitter } = require('events')
4
+ const { parentPort } = require('worker_threads')
5
+
6
+ function createDestination (mode) {
7
+ const destination = new EventEmitter()
8
+ destination.writableEnded = false
9
+ destination.writableNeedDrain = false
10
+
11
+ destination.write = function () {
12
+ if (mode === 'drain') {
13
+ destination.writableNeedDrain = true
14
+ setTimeout(() => {
15
+ destination.writableNeedDrain = false
16
+ parentPort.postMessage({
17
+ code: 'EVENT',
18
+ name: 'destination-drain'
19
+ })
20
+ destination.emit('drain')
21
+ }, 50)
22
+ }
23
+
24
+ return true
25
+ }
26
+
27
+ destination.end = function () {
28
+ destination.writableEnded = true
29
+ destination.emit('close')
30
+ }
31
+
32
+ if (mode === 'flush') {
33
+ destination.flush = function (cb) {
34
+ setTimeout(() => {
35
+ parentPort.postMessage({
36
+ code: 'EVENT',
37
+ name: 'destination-flushed'
38
+ })
39
+ cb()
40
+ }, 50)
41
+ }
42
+ }
43
+
44
+ if (mode === 'flush-sync') {
45
+ destination.flushSync = function () {
46
+ parentPort.postMessage({
47
+ code: 'EVENT',
48
+ name: 'destination-flush-sync'
49
+ })
50
+ }
51
+ }
52
+
53
+ if (mode === 'exit-on-flush') {
54
+ destination.flush = function (_cb) {
55
+ setTimeout(() => {
56
+ process.exit(0)
57
+ }, 20)
58
+ }
59
+ }
60
+
61
+ return destination
62
+ }
63
+
64
+ async function run (opts) {
65
+ return createDestination(opts.mode)
66
+ }
67
+
68
+ module.exports = run
@@ -0,0 +1,112 @@
1
+ 'use strict'
2
+
3
+ const { test } = require('node:test')
4
+ const assert = require('node:assert')
5
+ const { once } = require('node:events')
6
+ const { join } = require('node:path')
7
+ const ThreadStream = require('..')
8
+
9
+ function createStream (mode) {
10
+ return new ThreadStream({
11
+ filename: join(__dirname, 'flush-worker.js'),
12
+ workerData: { mode },
13
+ sync: false
14
+ })
15
+ }
16
+
17
+ test('flush waits for worker destination.flush(cb)', async function () {
18
+ const stream = createStream('flush')
19
+ let flushed = false
20
+
21
+ stream.on('destination-flushed', () => {
22
+ flushed = true
23
+ })
24
+
25
+ assert.ok(stream.write('hello'))
26
+
27
+ await new Promise((resolve, reject) => {
28
+ stream.flush((err) => {
29
+ if (err) {
30
+ reject(err)
31
+ return
32
+ }
33
+ resolve()
34
+ })
35
+ })
36
+
37
+ assert.strictEqual(flushed, true)
38
+
39
+ const close = once(stream, 'close')
40
+ stream.end()
41
+ await close
42
+ })
43
+
44
+ test('flush falls back to destination.flushSync()', async function () {
45
+ const stream = createStream('flush-sync')
46
+ let called = false
47
+
48
+ stream.on('destination-flush-sync', () => {
49
+ called = true
50
+ })
51
+
52
+ assert.ok(stream.write('hello'))
53
+
54
+ await new Promise((resolve, reject) => {
55
+ stream.flush((err) => {
56
+ if (err) {
57
+ reject(err)
58
+ return
59
+ }
60
+ resolve()
61
+ })
62
+ })
63
+
64
+ assert.strictEqual(called, true)
65
+
66
+ const close = once(stream, 'close')
67
+ stream.end()
68
+ await close
69
+ })
70
+
71
+ test('flush waits for drain when destination has no flush API', async function () {
72
+ const stream = createStream('drain')
73
+ let drained = false
74
+
75
+ stream.on('destination-drain', () => {
76
+ drained = true
77
+ })
78
+
79
+ assert.ok(stream.write('hello'))
80
+
81
+ await new Promise((resolve, reject) => {
82
+ stream.flush((err) => {
83
+ if (err) {
84
+ reject(err)
85
+ return
86
+ }
87
+ resolve()
88
+ })
89
+ })
90
+
91
+ assert.strictEqual(drained, true)
92
+
93
+ const close = once(stream, 'close')
94
+ stream.end()
95
+ await close
96
+ })
97
+
98
+ test('pending flush callbacks fail when worker exits', async function () {
99
+ const stream = createStream('exit-on-flush')
100
+ const close = once(stream, 'close')
101
+
102
+ assert.ok(stream.write('hello'))
103
+
104
+ const err = await new Promise((resolve) => {
105
+ stream.flush(resolve)
106
+ })
107
+
108
+ assert.ok(err)
109
+ assert.strictEqual(err.message, 'the worker has exited')
110
+
111
+ await close
112
+ })
@@ -0,0 +1,19 @@
1
+ 'use strict'
2
+
3
+ const { Writable } = require('stream')
4
+ const { parentPort } = require('worker_threads')
5
+
6
+ async function run () {
7
+ parentPort.postMessage({
8
+ internal: 'watch-mode'
9
+ })
10
+
11
+ return new Writable({
12
+ autoDestroy: true,
13
+ write (chunk, enc, cb) {
14
+ cb()
15
+ }
16
+ })
17
+ }
18
+
19
+ module.exports = run
@@ -0,0 +1,33 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { readFile } from 'node:fs/promises'
4
+ import ThreadStream from '../index.js'
5
+ import { join } from 'desm'
6
+ import { file } from './helper.js'
7
+
8
+ test('preserves multibyte records that cross the buffer boundary', async () => {
9
+ const dest = file()
10
+ const stream = new ThreadStream({
11
+ bufferSize: 128,
12
+ filename: join(import.meta.url, 'to-file.js'),
13
+ workerData: { dest },
14
+ sync: false
15
+ })
16
+
17
+ let expected = ''
18
+
19
+ for (let i = 0; i < 1000; i++) {
20
+ const line = `{"idx":${i},"alert":"🚨"}\n`
21
+ expected += line
22
+ stream.write(line)
23
+ }
24
+
25
+ await new Promise((resolve, reject) => {
26
+ stream.once('error', reject)
27
+ stream.once('close', resolve)
28
+ stream.end()
29
+ })
30
+
31
+ const data = await readFile(dest, 'utf8')
32
+ assert.strictEqual(data, expected)
33
+ })
@@ -0,0 +1,16 @@
1
+ 'use strict'
2
+
3
+ const { Writable } = require('stream')
4
+ const { threadName, parentPort } = require('worker_threads')
5
+
6
+ module.exports = function () {
7
+ parentPort.once('message', function ({ port }) {
8
+ port.postMessage({ threadName })
9
+ })
10
+
11
+ return new Writable({
12
+ write (chunk, encoding, callback) {
13
+ callback()
14
+ }
15
+ })
16
+ }
@@ -27,6 +27,9 @@ test('emit error if thread exits', async function (t) {
27
27
  })
28
28
 
29
29
  const closed = once(stream, 'close').catch(() => {})
30
+ // Keep a persistent error listener to avoid unhandled late error events
31
+ // reported as asynchronous activity by stricter test runners.
32
+ stream.on('error', () => {})
30
33
 
31
34
  stream.on('ready', () => {
32
35
  stream.write('hello world\n')
@@ -53,6 +56,9 @@ test('emit error if thread have unhandledRejection', async function (t) {
53
56
  })
54
57
 
55
58
  const closed = once(stream, 'close').catch(() => {})
59
+ // Keep a persistent error listener to avoid unhandled late error events
60
+ // reported as asynchronous activity by stricter test runners.
61
+ stream.on('error', () => {})
56
62
 
57
63
  stream.on('ready', () => {
58
64
  stream.write('hello world\n')
@@ -79,6 +85,9 @@ test('emit error if worker stream emit error', async function (t) {
79
85
  })
80
86
 
81
87
  const closed = once(stream, 'close').catch(() => {})
88
+ // Keep a persistent error listener to avoid unhandled late error events
89
+ // reported as asynchronous activity by stricter test runners.
90
+ stream.on('error', () => {})
82
91
 
83
92
  stream.on('ready', () => {
84
93
  stream.write('hello world\n')
@@ -105,6 +114,9 @@ test('emit error if thread have uncaughtException', async function (t) {
105
114
  })
106
115
 
107
116
  const closed = once(stream, 'close').catch(() => {})
117
+ // Keep a persistent error listener to avoid unhandled late error events
118
+ // reported as asynchronous activity by stricter test runners.
119
+ stream.on('error', () => {})
108
120
 
109
121
  stream.on('ready', () => {
110
122
  stream.write('hello world\n')
@@ -0,0 +1,30 @@
1
+ 'use strict'
2
+
3
+ const { test } = require('node:test')
4
+ const assert = require('node:assert')
5
+ const { once } = require('events')
6
+ const { join } = require('path')
7
+ const ThreadStream = require('..')
8
+
9
+ test('ignores worker messages without a protocol code', async function () {
10
+ const stream = new ThreadStream({
11
+ filename: join(__dirname, 'message-without-code.js'),
12
+ sync: false
13
+ })
14
+
15
+ const errors = []
16
+ stream.on('error', err => {
17
+ errors.push(err)
18
+ })
19
+
20
+ const ready = once(stream, 'ready')
21
+ const close = once(stream, 'close')
22
+
23
+ assert.ok(stream.write('hello world\n'))
24
+ stream.end()
25
+
26
+ await ready
27
+ await close
28
+
29
+ assert.deepStrictEqual(errors, [])
30
+ })
@@ -0,0 +1,43 @@
1
+ 'use strict'
2
+
3
+ const { test } = require('node:test')
4
+ const assert = require('node:assert')
5
+ const { join } = require('path')
6
+ const { once } = require('events')
7
+ const { MessageChannel } = require('worker_threads')
8
+ const ThreadStream = require('..')
9
+
10
+ // threadName was added in Node.js v22.20.0 and v24.6.0
11
+ const [major, minor] = process.versions.node.split('.').map(Number)
12
+ const supportsThreadName = (major === 22 && minor >= 20) || major >= 24
13
+
14
+ test('worker has default name "thread-stream"', { skip: !supportsThreadName }, async function (t) {
15
+ const { port1, port2 } = new MessageChannel()
16
+ const stream = new ThreadStream({
17
+ filename: join(__dirname, 'report-thread-name.js'),
18
+ sync: true
19
+ })
20
+
21
+ t.after(() => stream.end())
22
+
23
+ stream.emit('message', { port: port1 }, [port1])
24
+ const [{ threadName }] = await once(port2, 'message')
25
+ assert.strictEqual(threadName, 'thread-stream')
26
+ })
27
+
28
+ test('worker name can be overridden via workerOpts', { skip: !supportsThreadName }, async function (t) {
29
+ const { port1, port2 } = new MessageChannel()
30
+ const stream = new ThreadStream({
31
+ filename: join(__dirname, 'report-thread-name.js'),
32
+ workerOpts: {
33
+ name: 'my-custom-worker'
34
+ },
35
+ sync: true
36
+ })
37
+
38
+ t.after(() => stream.end())
39
+
40
+ stream.emit('message', { port: port1 }, [port1])
41
+ const [{ threadName }] = await once(port2, 'message')
42
+ assert.strictEqual(threadName, 'my-custom-worker')
43
+ })
package/.husky/pre-commit DELETED
@@ -1,4 +0,0 @@
1
- #!/usr/bin/env sh
2
- . "$(dirname -- "$0")/_/husky.sh"
3
-
4
- npm test
package/CLAUDE.md DELETED
@@ -1,53 +0,0 @@
1
- # CLAUDE.md
2
-
3
- This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
-
5
- ## Build & Test Commands
6
-
7
- ```sh
8
- npm test # Run linter (standard), type check, transpile, and all tests
9
- npm run build # Type check only (tsc --noEmit)
10
- npm run transpile # Generate transpiled test files for various ES targets
11
-
12
- # Run a single test file
13
- npx tap test/base.test.js
14
-
15
- # Run only JavaScript tests (faster)
16
- npx tap "test/**/*.test.*js"
17
-
18
- # Run only TypeScript tests
19
- npx tap --ts test/*.test.*ts
20
- ```
21
-
22
- ## Code Style
23
-
24
- This project uses [Standard](https://standardjs.com/) for linting. No semicolons, 2-space indentation.
25
-
26
- ## Architecture
27
-
28
- thread-stream is a library for streaming data to a Node.js Worker Thread using SharedArrayBuffer for high-performance inter-thread communication.
29
-
30
- ### Core Components
31
-
32
- - **index.js** - Main `ThreadStream` class extending EventEmitter. Manages the worker lifecycle, handles writes via SharedArrayBuffer, and coordinates synchronization using Atomics.
33
-
34
- - **lib/worker.js** - Runs in the Worker Thread. Loads the user-provided destination module, reads data from SharedArrayBuffer, and writes to the destination stream. Handles both ESM and CommonJS modules, including yarn PnP compatibility.
35
-
36
- - **lib/indexes.js** - Defines SharedArrayBuffer index constants (`WRITE_INDEX`, `READ_INDEX`) used for Atomics coordination.
37
-
38
- - **lib/wait.js** - Async polling utilities (`wait`, `waitDiff`) for cross-thread synchronization without blocking the main thread.
39
-
40
- ### Data Flow
41
-
42
- 1. Main thread writes strings to an internal buffer, then copies to SharedArrayBuffer
43
- 2. Atomics.notify signals the worker that data is available
44
- 3. Worker reads from SharedArrayBuffer via Atomics.load and writes to destination stream
45
- 4. Worker updates READ_INDEX and notifies main thread when done
46
- 5. Special index values (-1 for end, -2 for error) signal stream termination
47
-
48
- ### Key Features
49
-
50
- - **Sync/async modes** - `sync: true` blocks until data is written; async mode uses `setImmediate` batching
51
- - **Backpressure** - Handles `drain` events from destination streams
52
- - **GC cleanup** - Uses FinalizationRegistry to terminate orphaned workers
53
- - **TypeScript support** - Workers can be `.ts` files (requires ts-node)