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.
- package/.claude/settings.local.json +15 -0
- package/.github/workflows/ci.yml +4 -4
- package/README.md +4 -1
- package/index.d.ts +7 -0
- package/index.js +209 -105
- package/lib/indexes.js +3 -1
- package/lib/wait.js +11 -2
- package/lib/worker.js +128 -9
- package/package.json +11 -13
- package/test/flush-worker.js +68 -0
- package/test/flush.test.js +112 -0
- package/test/message-without-code.js +19 -0
- package/test/multibyte-overrun.test.mjs +33 -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
|
@@ -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
|
-
//
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
119
|
+
if (stream[kImpl].ending) {
|
|
120
|
+
end(stream)
|
|
121
|
+
} else if (stream[kImpl].needDrain) {
|
|
122
|
+
process.nextTick(drain, stream)
|
|
123
|
+
}
|
|
99
124
|
|
|
100
|
-
|
|
101
|
-
end(stream)
|
|
102
|
-
} else if (stream[kImpl].needDrain) {
|
|
103
|
-
process.nextTick(drain, stream)
|
|
125
|
+
return
|
|
104
126
|
}
|
|
105
127
|
|
|
106
|
-
|
|
128
|
+
write(stream, leftover, noop)
|
|
129
|
+
continue
|
|
107
130
|
}
|
|
108
131
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
|
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].
|
|
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
|
|
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].
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
307
|
-
|
|
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,
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
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].
|
|
595
|
+
while (stream[kImpl].bufLen !== 0) {
|
|
467
596
|
const writeIndex = Atomics.load(stream[kImpl].state, WRITE_INDEX)
|
|
468
|
-
|
|
597
|
+
const leftover = stream[kImpl].data.length - writeIndex
|
|
469
598
|
if (leftover === 0) {
|
|
470
599
|
flushSync(stream)
|
|
471
|
-
|
|
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
|
-
|
|
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
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(
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
"
|
|
22
|
-
"
|
|
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
|
|
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,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
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)
|