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.
- package/.github/workflows/ci.yml +4 -4
- package/README.md +4 -1
- package/index.d.ts +7 -0
- package/index.js +120 -21
- package/lib/worker.js +101 -0
- package/package.json +10 -12
- package/test/flush-worker.js +68 -0
- package/test/flush.test.js +112 -0
- package/test/message-without-code.js +19 -0
- package/test/report-thread-name.js +16 -0
- package/test/thread-management.test.js +12 -0
- package/test/watch-mode.test.js +30 -0
- package/test/worker-name.test.js +43 -0
- package/.husky/pre-commit +0 -4
- package/CLAUDE.md +0 -53
package/.github/workflows/ci.yml
CHANGED
|
@@ -24,7 +24,7 @@ jobs:
|
|
|
24
24
|
contents: read
|
|
25
25
|
steps:
|
|
26
26
|
- name: Check out repo
|
|
27
|
-
uses: actions/checkout@
|
|
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@
|
|
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@
|
|
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
|
-
|
|
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
|
|
118
|
-
// err is already handled in
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
307
|
-
|
|
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.
|
|
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.
|
|
11
|
+
"real-require": "^1.0.0"
|
|
12
12
|
},
|
|
13
13
|
"devDependencies": {
|
|
14
|
-
"@types/node": "^
|
|
14
|
+
"@types/node": "^25.0.2",
|
|
15
15
|
"@yao-pkg/pkg": "^6.0.0",
|
|
16
|
-
"borp": "^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
|
-
"
|
|
21
|
-
"neostandard": "^0.12.2",
|
|
20
|
+
"neostandard": "^0.13.0",
|
|
22
21
|
"pino-elasticsearch": "^8.0.0",
|
|
23
|
-
"sonic-boom": "^
|
|
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
|
|
31
|
-
"test:ci": "npm run lint && npm run transpile && borp --pattern
|
|
32
|
-
"test:yarn": "npm run transpile && borp --pattern
|
|
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
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)
|