thread-stream 3.0.2 → 4.0.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 +3 -23
- package/CLAUDE.md +53 -0
- package/eslint.config.js +10 -0
- package/index.js +4 -0
- package/lib/wait.js +50 -43
- package/lib/worker.js +23 -14
- package/package.json +14 -18
- package/test/base.test.js +68 -82
- package/test/bench.test.js +3 -3
- package/test/bundlers.test.js +8 -9
- package/test/commonjs-fallback.test.js +17 -22
- package/test/context.test.js +6 -6
- package/test/end.test.js +22 -27
- package/test/esm.test.mjs +8 -9
- package/test/event.test.js +10 -9
- package/test/helper.js +1 -10
- package/test/indexes.test.js +4 -4
- package/test/multibyte-chars.test.mjs +14 -13
- package/test/pkg/index.js +23 -23
- package/test/pkg/pkg.config.json +3 -4
- package/test/pkg/pkg.test.js +5 -6
- package/test/post-message.test.js +4 -5
- package/test/string-limit-2.test.js +24 -30
- package/test/string-limit.test.js +24 -29
- package/test/syntax-error.mjs +2 -0
- package/test/thread-management.test.js +34 -17
- package/test/transpiled.test.js +5 -6
- package/test/ts-native.test.mjs +35 -0
- package/test/ts-node-fallback.test.js +35 -0
- package/.taprc +0 -4
- package/test/never-drain.test.js +0 -57
- package/test/ts.test.ts +0 -33
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@v4
|
|
28
28
|
with:
|
|
29
29
|
persist-credentials: false
|
|
30
30
|
|
|
@@ -38,15 +38,12 @@ jobs:
|
|
|
38
38
|
contents: read
|
|
39
39
|
strategy:
|
|
40
40
|
matrix:
|
|
41
|
-
node-version: [
|
|
41
|
+
node-version: [20, 22, 24]
|
|
42
42
|
os: [macos-latest, ubuntu-latest, windows-latest]
|
|
43
|
-
exclude:
|
|
44
|
-
- os: windows-latest
|
|
45
|
-
node-version: 22
|
|
46
43
|
|
|
47
44
|
steps:
|
|
48
45
|
- name: Check out repo
|
|
49
|
-
uses: actions/checkout@
|
|
46
|
+
uses: actions/checkout@v4
|
|
50
47
|
with:
|
|
51
48
|
persist-credentials: false
|
|
52
49
|
|
|
@@ -61,23 +58,6 @@ jobs:
|
|
|
61
58
|
- name: Run tests
|
|
62
59
|
run: npm run test:ci
|
|
63
60
|
|
|
64
|
-
- name: Coveralls Parallel
|
|
65
|
-
uses: coverallsapp/github-action@v2.2.3
|
|
66
|
-
with:
|
|
67
|
-
github-token: ${{ secrets.github_token }}
|
|
68
|
-
parallel: true
|
|
69
|
-
flag-name: run-${{ matrix.node-version }}-${{ matrix.os }}
|
|
70
|
-
|
|
71
|
-
coverage:
|
|
72
|
-
needs: test
|
|
73
|
-
runs-on: ubuntu-latest
|
|
74
|
-
steps:
|
|
75
|
-
- name: Coveralls Finished
|
|
76
|
-
uses: coverallsapp/github-action@v2.2.3
|
|
77
|
-
with:
|
|
78
|
-
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
79
|
-
parallel-finished: true
|
|
80
|
-
|
|
81
61
|
automerge:
|
|
82
62
|
name: Automerge Dependabot PRs
|
|
83
63
|
if: >
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
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)
|
package/eslint.config.js
ADDED
package/index.js
CHANGED
|
@@ -122,6 +122,7 @@ function nextFlush (stream) {
|
|
|
122
122
|
|
|
123
123
|
Atomics.store(stream[kImpl].state, READ_INDEX, 0)
|
|
124
124
|
Atomics.store(stream[kImpl].state, WRITE_INDEX, 0)
|
|
125
|
+
Atomics.notify(stream[kImpl].state, READ_INDEX)
|
|
125
126
|
|
|
126
127
|
// Find a toWrite length that fits the buffer
|
|
127
128
|
// it must exists as the buffer is at least 4 bytes length
|
|
@@ -143,6 +144,7 @@ function nextFlush (stream) {
|
|
|
143
144
|
stream.flush(() => {
|
|
144
145
|
Atomics.store(stream[kImpl].state, READ_INDEX, 0)
|
|
145
146
|
Atomics.store(stream[kImpl].state, WRITE_INDEX, 0)
|
|
147
|
+
Atomics.notify(stream[kImpl].state, READ_INDEX)
|
|
146
148
|
nextFlush(stream)
|
|
147
149
|
})
|
|
148
150
|
} else {
|
|
@@ -468,6 +470,7 @@ function writeSync (stream) {
|
|
|
468
470
|
flushSync(stream)
|
|
469
471
|
Atomics.store(stream[kImpl].state, READ_INDEX, 0)
|
|
470
472
|
Atomics.store(stream[kImpl].state, WRITE_INDEX, 0)
|
|
473
|
+
Atomics.notify(stream[kImpl].state, READ_INDEX)
|
|
471
474
|
continue
|
|
472
475
|
} else if (leftover < 0) {
|
|
473
476
|
// stream should never happen
|
|
@@ -485,6 +488,7 @@ function writeSync (stream) {
|
|
|
485
488
|
flushSync(stream)
|
|
486
489
|
Atomics.store(stream[kImpl].state, READ_INDEX, 0)
|
|
487
490
|
Atomics.store(stream[kImpl].state, WRITE_INDEX, 0)
|
|
491
|
+
Atomics.notify(stream[kImpl].state, READ_INDEX)
|
|
488
492
|
|
|
489
493
|
// Find a toWrite length that fits the buffer
|
|
490
494
|
// it must exists as the buffer is at least 4 bytes length
|
package/lib/wait.js
CHANGED
|
@@ -1,61 +1,68 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// Maximum wait time for a single waitAsync call
|
|
4
|
+
// Used as a fallback poll interval in case notifications are missed
|
|
5
|
+
// Keep this low enough for good throughput but high enough to not busy-loop
|
|
6
|
+
const WAIT_MS = 10000
|
|
4
7
|
|
|
5
8
|
function wait (state, index, expected, timeout, done) {
|
|
6
|
-
const max = Date.now() + timeout
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
const max = timeout === Infinity ? Infinity : Date.now() + timeout
|
|
10
|
+
|
|
11
|
+
const check = () => {
|
|
12
|
+
const current = Atomics.load(state, index)
|
|
13
|
+
if (current === expected) {
|
|
14
|
+
done(null, 'ok')
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (max !== Infinity && Date.now() > max) {
|
|
15
19
|
done(null, 'timed-out')
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Wait for any change from current value
|
|
24
|
+
const remaining = max === Infinity ? WAIT_MS : Math.min(WAIT_MS, Math.max(1, max - Date.now()))
|
|
25
|
+
const result = Atomics.waitAsync(state, index, current, remaining)
|
|
26
|
+
|
|
27
|
+
if (result.async) {
|
|
28
|
+
result.value.then(check)
|
|
16
29
|
} else {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
current = Atomics.load(state, index)
|
|
20
|
-
if (current === prior) {
|
|
21
|
-
check(backoff >= MAX_TIMEOUT ? MAX_TIMEOUT : backoff * 2)
|
|
22
|
-
} else {
|
|
23
|
-
if (current === expected) done(null, 'ok')
|
|
24
|
-
else done(null, 'not-equal')
|
|
25
|
-
}
|
|
26
|
-
}, backoff)
|
|
30
|
+
// Value already changed (not-equal) - recheck on next tick
|
|
31
|
+
setImmediate(check)
|
|
27
32
|
}
|
|
28
33
|
}
|
|
29
|
-
|
|
34
|
+
|
|
35
|
+
check()
|
|
30
36
|
}
|
|
31
37
|
|
|
32
|
-
// let waitDiffCount = 0
|
|
33
38
|
function waitDiff (state, index, expected, timeout, done) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
// process._rawDebug('' + backoff)
|
|
45
|
-
if (Date.now() > max) {
|
|
39
|
+
const max = timeout === Infinity ? Infinity : Date.now() + timeout
|
|
40
|
+
|
|
41
|
+
const check = () => {
|
|
42
|
+
const current = Atomics.load(state, index)
|
|
43
|
+
if (current !== expected) {
|
|
44
|
+
done(null, 'ok')
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (max !== Infinity && Date.now() > max) {
|
|
46
49
|
done(null, 'timed-out')
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Wait for value to change from expected
|
|
54
|
+
const remaining = max === Infinity ? WAIT_MS : Math.min(WAIT_MS, Math.max(1, max - Date.now()))
|
|
55
|
+
const result = Atomics.waitAsync(state, index, expected, remaining)
|
|
56
|
+
|
|
57
|
+
if (result.async) {
|
|
58
|
+
result.value.then(check)
|
|
47
59
|
} else {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (current !== expected) {
|
|
51
|
-
done(null, 'ok')
|
|
52
|
-
} else {
|
|
53
|
-
check(backoff >= MAX_TIMEOUT ? MAX_TIMEOUT : backoff * 2)
|
|
54
|
-
}
|
|
55
|
-
}, backoff)
|
|
60
|
+
// Value already changed (not-equal) - recheck on next tick
|
|
61
|
+
setImmediate(check)
|
|
56
62
|
}
|
|
57
63
|
}
|
|
58
|
-
|
|
64
|
+
|
|
65
|
+
check()
|
|
59
66
|
}
|
|
60
67
|
|
|
61
68
|
module.exports = { wait, waitDiff }
|
package/lib/worker.js
CHANGED
|
@@ -16,22 +16,13 @@ let destination
|
|
|
16
16
|
const state = new Int32Array(stateBuf)
|
|
17
17
|
const data = Buffer.from(dataBuf)
|
|
18
18
|
|
|
19
|
+
// Keep the event loop alive - Atomics.waitAsync promises don't prevent worker exit
|
|
20
|
+
const keepAlive = setInterval(() => {}, 60 * 60 * 1000)
|
|
21
|
+
|
|
19
22
|
async function start () {
|
|
20
23
|
let worker
|
|
21
24
|
try {
|
|
22
|
-
|
|
23
|
-
// TODO: add support for the TSM modules loader ( https://github.com/lukeed/tsm ).
|
|
24
|
-
if (!process[Symbol.for('ts-node.register.instance')]) {
|
|
25
|
-
realRequire('ts-node/register')
|
|
26
|
-
} else if (process.env.TS_NODE_DEV) {
|
|
27
|
-
realRequire('ts-node-dev')
|
|
28
|
-
}
|
|
29
|
-
// TODO: Support ES imports once tsc, tap & ts-node provide better compatibility guarantees.
|
|
30
|
-
// Remove extra forwardslash on Windows
|
|
31
|
-
worker = realRequire(decodeURIComponent(filename.replace(process.platform === 'win32' ? 'file:///' : 'file://', '')))
|
|
32
|
-
} else {
|
|
33
|
-
worker = (await realImport(filename))
|
|
34
|
-
}
|
|
25
|
+
worker = (await realImport(filename))
|
|
35
26
|
} catch (error) {
|
|
36
27
|
// A yarn user that tries to start a ThreadStream for an external module
|
|
37
28
|
// provides a filename pointing to a zip file.
|
|
@@ -48,7 +39,24 @@ async function start () {
|
|
|
48
39
|
// When bundled with pkg, an undefined error is thrown when called with realImport
|
|
49
40
|
// When bundled with pkg and using node v20, an ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING error is thrown when called with realImport
|
|
50
41
|
// More info at: https://github.com/pinojs/thread-stream/issues/143
|
|
51
|
-
|
|
42
|
+
try {
|
|
43
|
+
worker = realRequire(decodeURIComponent(filename.replace(process.platform === 'win32' ? 'file:///' : 'file://', '')))
|
|
44
|
+
} catch {
|
|
45
|
+
throw error
|
|
46
|
+
}
|
|
47
|
+
} else if (filename.endsWith('.ts') || filename.endsWith('.cts')) {
|
|
48
|
+
// Native TypeScript import failed (type stripping not enabled).
|
|
49
|
+
// Fall back to ts-node for TypeScript files.
|
|
50
|
+
try {
|
|
51
|
+
if (!process[Symbol.for('ts-node.register.instance')]) {
|
|
52
|
+
realRequire('ts-node/register')
|
|
53
|
+
} else if (process.env.TS_NODE_DEV) {
|
|
54
|
+
realRequire('ts-node-dev')
|
|
55
|
+
}
|
|
56
|
+
worker = realRequire(decodeURIComponent(filename.replace(process.platform === 'win32' ? 'file:///' : 'file://', '')))
|
|
57
|
+
} catch {
|
|
58
|
+
throw error
|
|
59
|
+
}
|
|
52
60
|
} else {
|
|
53
61
|
throw error
|
|
54
62
|
}
|
|
@@ -80,6 +88,7 @@ async function start () {
|
|
|
80
88
|
const end = Atomics.load(state, WRITE_INDEX)
|
|
81
89
|
Atomics.store(state, READ_INDEX, end)
|
|
82
90
|
Atomics.notify(state, READ_INDEX)
|
|
91
|
+
clearInterval(keepAlive)
|
|
83
92
|
setImmediate(() => {
|
|
84
93
|
process.exit(0)
|
|
85
94
|
})
|
package/package.json
CHANGED
|
@@ -1,42 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thread-stream",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.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",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=20"
|
|
9
|
+
},
|
|
7
10
|
"dependencies": {
|
|
8
11
|
"real-require": "^0.2.0"
|
|
9
12
|
},
|
|
10
13
|
"devDependencies": {
|
|
11
|
-
"@types/node": "^
|
|
12
|
-
"@
|
|
13
|
-
"
|
|
14
|
+
"@types/node": "^22.0.0",
|
|
15
|
+
"@yao-pkg/pkg": "^6.0.0",
|
|
16
|
+
"borp": "^0.21.0",
|
|
14
17
|
"desm": "^1.3.0",
|
|
18
|
+
"eslint": "^9.39.1",
|
|
15
19
|
"fastbench": "^1.0.1",
|
|
16
20
|
"husky": "^9.0.6",
|
|
21
|
+
"neostandard": "^0.12.2",
|
|
17
22
|
"pino-elasticsearch": "^8.0.0",
|
|
18
23
|
"sonic-boom": "^4.0.1",
|
|
19
|
-
"standard": "^17.0.0",
|
|
20
|
-
"tap": "^16.2.0",
|
|
21
24
|
"ts-node": "^10.8.0",
|
|
22
|
-
"typescript": "
|
|
23
|
-
"why-is-node-running": "^2.2.2"
|
|
25
|
+
"typescript": "~5.7.3"
|
|
24
26
|
},
|
|
25
27
|
"scripts": {
|
|
26
28
|
"build": "tsc --noEmit",
|
|
27
|
-
"
|
|
28
|
-
"test
|
|
29
|
-
"test:ci
|
|
30
|
-
"test:
|
|
31
|
-
"test:yarn": "npm run transpile && tap \"test/**/*.test.js\" --no-check-coverage",
|
|
29
|
+
"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'",
|
|
32
33
|
"transpile": "sh ./test/ts/transpile.sh",
|
|
33
34
|
"prepare": "husky install"
|
|
34
35
|
},
|
|
35
|
-
"standard": {
|
|
36
|
-
"ignore": [
|
|
37
|
-
"test/ts/**/*"
|
|
38
|
-
]
|
|
39
|
-
},
|
|
40
36
|
"repository": {
|
|
41
37
|
"type": "git",
|
|
42
38
|
"url": "git+https://github.com/mcollina/thread-stream.git"
|