thread-stream 4.0.0 → 4.1.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.
@@ -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
@@ -18,6 +18,8 @@ const kImpl = Symbol('kImpl')
18
18
  // V8 limit for string size
19
19
  const MAX_STRING = buffer.constants.MAX_STRING_LENGTH
20
20
 
21
+ function noop () {}
22
+
21
23
  class FakeWeakRef {
22
24
  constructor (value) {
23
25
  this._value = value
@@ -54,6 +56,7 @@ function createWorker (stream, opts) {
54
56
 
55
57
  const worker = new Worker(toExecute, {
56
58
  ...opts.workerOpts,
59
+ name: opts.workerOpts?.name || 'thread-stream',
57
60
  trackUnmanagedFds: false,
58
61
  workerData: {
59
62
  filename: filename.indexOf('file://') === 0
@@ -114,8 +117,8 @@ function nextFlush (stream) {
114
117
  write(stream, toWrite, nextFlush.bind(null, stream))
115
118
  } else {
116
119
  // multi-byte utf-8
117
- stream.flush(() => {
118
- // err is already handled in flush()
120
+ waitForRead(stream, () => {
121
+ // err is already handled in waitForRead()
119
122
  if (stream.destroyed) {
120
123
  return
121
124
  }
@@ -141,7 +144,7 @@ function nextFlush (stream) {
141
144
  // we had a flushSync in the meanwhile
142
145
  return
143
146
  }
144
- stream.flush(() => {
147
+ waitForRead(stream, () => {
145
148
  Atomics.store(stream[kImpl].state, READ_INDEX, 0)
146
149
  Atomics.store(stream[kImpl].state, WRITE_INDEX, 0)
147
150
  Atomics.notify(stream[kImpl].state, READ_INDEX)
@@ -162,13 +165,19 @@ function onWorkerMessage (msg) {
162
165
  return
163
166
  }
164
167
 
168
+ // Node.js watch mode may send internal worker messages that do not
169
+ // participate in thread-stream's worker protocol.
170
+ if (msg?.code == null) {
171
+ return
172
+ }
173
+
165
174
  switch (msg.code) {
166
175
  case 'READY':
167
176
  // Replace the FakeWeakRef with a
168
177
  // proper one.
169
178
  this.stream = new WeakRef(stream)
170
179
 
171
- stream.flush(() => {
180
+ waitForRead(stream, () => {
172
181
  stream[kImpl].ready = true
173
182
  stream.emit('ready')
174
183
  })
@@ -183,6 +192,19 @@ function onWorkerMessage (msg) {
183
192
  stream.emit(msg.name, msg.args)
184
193
  }
185
194
  break
195
+ case 'FLUSHED': {
196
+ if (msg.context !== 'thread-stream') {
197
+ destroy(stream, new Error('this should not happen: ' + msg.code))
198
+ break
199
+ }
200
+
201
+ const cb = stream[kImpl].flushCallbacks.get(msg.id)
202
+ if (cb) {
203
+ stream[kImpl].flushCallbacks.delete(msg.id)
204
+ process.nextTick(cb)
205
+ }
206
+ break
207
+ }
186
208
  case 'WARNING':
187
209
  process.emitWarning(msg.err)
188
210
  break
@@ -227,6 +249,8 @@ class ThreadStream extends EventEmitter {
227
249
  this[kImpl].errored = null
228
250
  this[kImpl].closed = false
229
251
  this[kImpl].buf = ''
252
+ this[kImpl].flushCallbacks = new Map()
253
+ this[kImpl].nextFlushId = 0
230
254
 
231
255
  // TODO (fix): Make private?
232
256
  this.worker = createWorker(this, opts) // TODO (fix): make private
@@ -287,28 +311,15 @@ class ThreadStream extends EventEmitter {
287
311
  }
288
312
 
289
313
  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
- }
314
+ cb = typeof cb === 'function' ? cb : noop
296
315
 
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) => {
316
+ flushBuffer(this, (err) => {
301
317
  if (err) {
302
- destroy(this, err)
303
318
  process.nextTick(cb, err)
304
319
  return
305
320
  }
306
- if (res === 'not-equal') {
307
- // TODO handle deadlock
308
- this.flush(cb)
309
- return
310
- }
311
- process.nextTick(cb)
321
+
322
+ requestWorkerFlush(this, cb)
312
323
  })
313
324
  }
314
325
 
@@ -366,6 +377,93 @@ class ThreadStream extends EventEmitter {
366
377
  }
367
378
  }
368
379
 
380
+ function flushBuffer (stream, cb) {
381
+ if (stream[kImpl].destroyed) {
382
+ process.nextTick(cb, new Error('the worker has exited'))
383
+ return
384
+ }
385
+
386
+ if (!stream[kImpl].sync && (stream[kImpl].flushing || stream[kImpl].buf.length > 0)) {
387
+ setImmediate(flushBuffer, stream, cb)
388
+ return
389
+ }
390
+
391
+ waitForRead(stream, cb)
392
+ }
393
+
394
+ function waitForRead (stream, cb) {
395
+ const writeIndex = Atomics.load(stream[kImpl].state, WRITE_INDEX)
396
+ wait(stream[kImpl].state, READ_INDEX, writeIndex, Infinity, (err, res) => {
397
+ if (err) {
398
+ destroy(stream, err)
399
+ cb(err)
400
+ return
401
+ }
402
+
403
+ if (res !== 'ok') {
404
+ waitForRead(stream, cb)
405
+ return
406
+ }
407
+
408
+ cb()
409
+ })
410
+ }
411
+
412
+ function requestWorkerFlush (stream, cb) {
413
+ if (stream[kImpl].destroyed) {
414
+ process.nextTick(cb, new Error('the worker has exited'))
415
+ return
416
+ }
417
+
418
+ if (!stream[kImpl].ready) {
419
+ const onReady = () => {
420
+ cleanup()
421
+ requestWorkerFlush(stream, cb)
422
+ }
423
+ const onClose = () => {
424
+ cleanup()
425
+ process.nextTick(cb, new Error('the worker has exited'))
426
+ }
427
+ const cleanup = () => {
428
+ stream.off('ready', onReady)
429
+ stream.off('close', onClose)
430
+ }
431
+
432
+ stream.once('ready', onReady)
433
+ stream.once('close', onClose)
434
+ return
435
+ }
436
+
437
+ const id = ++stream[kImpl].nextFlushId
438
+ stream[kImpl].flushCallbacks.set(id, cb)
439
+
440
+ try {
441
+ stream.worker.postMessage({
442
+ code: 'FLUSH',
443
+ context: 'thread-stream',
444
+ id
445
+ })
446
+ } catch (err) {
447
+ stream[kImpl].flushCallbacks.delete(id)
448
+ destroy(stream, err)
449
+ process.nextTick(cb, err)
450
+ }
451
+ }
452
+
453
+ function failPendingFlushCallbacks (stream, err) {
454
+ const callbacks = stream[kImpl].flushCallbacks
455
+ if (callbacks.size === 0) {
456
+ return
457
+ }
458
+
459
+ const flushErr = err || new Error('the worker has exited')
460
+
461
+ for (const cb of callbacks.values()) {
462
+ process.nextTick(cb, flushErr)
463
+ }
464
+ callbacks.clear()
465
+ }
466
+
369
467
  function error (stream, err) {
370
468
  setImmediate(() => {
371
469
  stream.emit('error', err)
@@ -377,6 +475,7 @@ function destroy (stream, err) {
377
475
  return
378
476
  }
379
477
  stream[kImpl].destroyed = true
478
+ failPendingFlushCallbacks(stream, err)
380
479
 
381
480
  if (err) {
382
481
  stream[kImpl].errored = err
package/lib/worker.js CHANGED
@@ -12,6 +12,8 @@ const {
12
12
  } = workerData
13
13
 
14
14
  let destination
15
+ const flushQueue = []
16
+ let flushing = false
15
17
 
16
18
  const state = new Int32Array(stateBuf)
17
19
  const data = Buffer.from(dataBuf)
@@ -19,6 +21,101 @@ const data = Buffer.from(dataBuf)
19
21
  // Keep the event loop alive - Atomics.waitAsync promises don't prevent worker exit
20
22
  const keepAlive = setInterval(() => {}, 60 * 60 * 1000)
21
23
 
24
+ function onParentPortMessage (msg) {
25
+ if (!msg || msg.code !== 'FLUSH' || msg.context !== 'thread-stream') {
26
+ return
27
+ }
28
+
29
+ flushQueue.push(msg.id)
30
+ processFlushQueue()
31
+ }
32
+
33
+ function processFlushQueue () {
34
+ if (flushing || !destination) {
35
+ return
36
+ }
37
+
38
+ const id = flushQueue.shift()
39
+ if (id === undefined) {
40
+ return
41
+ }
42
+
43
+ flushing = true
44
+ flushDestination((err) => {
45
+ flushing = false
46
+
47
+ if (err) {
48
+ parentPort.postMessage({
49
+ code: 'ERROR',
50
+ err
51
+ })
52
+ return
53
+ }
54
+
55
+ parentPort.postMessage({
56
+ code: 'FLUSHED',
57
+ context: 'thread-stream',
58
+ id
59
+ })
60
+
61
+ processFlushQueue()
62
+ })
63
+ }
64
+
65
+ function flushDestination (cb) {
66
+ if (typeof destination?.flush === 'function') {
67
+ if (destination.flush.length === 0) {
68
+ try {
69
+ const result = destination.flush()
70
+ if (result && typeof result.then === 'function') {
71
+ result.then(() => cb(), cb)
72
+ } else {
73
+ cb()
74
+ }
75
+ } catch (err) {
76
+ cb(err)
77
+ }
78
+ return
79
+ }
80
+
81
+ let done = false
82
+ const onDone = (err) => {
83
+ if (done) {
84
+ return
85
+ }
86
+ done = true
87
+ cb(err)
88
+ }
89
+
90
+ try {
91
+ const result = destination.flush(onDone)
92
+ if (result && typeof result.then === 'function') {
93
+ result.then(() => onDone(), onDone)
94
+ }
95
+ } catch (err) {
96
+ onDone(err)
97
+ }
98
+ return
99
+ }
100
+
101
+ if (typeof destination?.flushSync === 'function') {
102
+ try {
103
+ destination.flushSync()
104
+ cb()
105
+ } catch (err) {
106
+ cb(err)
107
+ }
108
+ return
109
+ }
110
+
111
+ if (destination?.writableNeedDrain && !destination?.writableEnded) {
112
+ destination.once('drain', cb)
113
+ return
114
+ }
115
+
116
+ cb()
117
+ }
118
+
22
119
  async function start () {
23
120
  let worker
24
121
  try {
@@ -93,12 +190,16 @@ async function start () {
93
190
  process.exit(0)
94
191
  })
95
192
  })
193
+
194
+ processFlushQueue()
96
195
  }
97
196
 
98
197
  // No .catch() handler,
99
198
  // in case there is an error it goes
100
199
  // to unhandledRejection
101
200
  start().then(function () {
201
+ parentPort.on('message', onParentPortMessage)
202
+
102
203
  parentPort.postMessage({
103
204
  code: 'READY'
104
205
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thread-stream",
3
- "version": "4.0.0",
3
+ "version": "4.1.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",
20
+ "neostandard": "^0.13.0",
22
21
  "pino-elasticsearch": "^8.0.0",
23
- "sonic-boom": "^4.0.1",
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,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)