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.
@@ -24,7 +24,7 @@ jobs:
24
24
  contents: read
25
25
  steps:
26
26
  - name: Check out repo
27
- uses: actions/checkout@v3
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: [18, 20, 22]
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@v3
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)
@@ -0,0 +1,10 @@
1
+ 'use strict'
2
+
3
+ const neostandard = require('neostandard')
4
+
5
+ module.exports = neostandard({
6
+ ignores: [
7
+ 'test/ts/**/*',
8
+ 'test/syntax-error.mjs'
9
+ ]
10
+ })
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
- const MAX_TIMEOUT = 1000
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
- let current = Atomics.load(state, index)
8
- if (current === expected) {
9
- done(null, 'ok')
10
- return
11
- }
12
- let prior = current
13
- const check = (backoff) => {
14
- if (Date.now() > max) {
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
- setTimeout(() => {
18
- prior = current
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
- check(1)
34
+
35
+ check()
30
36
  }
31
37
 
32
- // let waitDiffCount = 0
33
38
  function waitDiff (state, index, expected, timeout, done) {
34
- // const id = waitDiffCount++
35
- // process._rawDebug(`>>> waitDiff ${id}`)
36
- const max = Date.now() + timeout
37
- let current = Atomics.load(state, index)
38
- if (current !== expected) {
39
- done(null, 'ok')
40
- return
41
- }
42
- const check = (backoff) => {
43
- // process._rawDebug(`${id} ${index} current ${current} expected ${expected}`)
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
- setTimeout(() => {
49
- current = Atomics.load(state, index)
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
- check(1)
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
- if (filename.endsWith('.ts') || filename.endsWith('.cts')) {
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
- worker = realRequire(decodeURIComponent(filename.replace(process.platform === 'win32' ? 'file:///' : 'file://', '')))
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.0.2",
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": "^20.1.0",
12
- "@types/tap": "^15.0.0",
13
- "@yao-pkg/pkg": "^5.11.5",
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": "^5.3.2",
23
- "why-is-node-running": "^2.2.2"
25
+ "typescript": "~5.7.3"
24
26
  },
25
27
  "scripts": {
26
28
  "build": "tsc --noEmit",
27
- "test": "standard && npm run build && npm run transpile && tap \"test/**/*.test.*js\" && tap --ts test/*.test.*ts",
28
- "test:ci": "standard && npm run transpile && npm run test:ci:js && npm run test:ci:ts",
29
- "test:ci:js": "tap --no-check-coverage --timeout=120 --coverage-report=lcovonly \"test/**/*.test.*js\"",
30
- "test:ci:ts": "tap --ts --no-check-coverage --coverage-report=lcovonly \"test/**/*.test.*ts\"",
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"