thread-stream 0.9.0 → 0.11.2

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.
@@ -19,10 +19,10 @@ jobs:
19
19
 
20
20
  steps:
21
21
 
22
- - uses: actions/checkout@v2.3.4
22
+ - uses: actions/checkout@v2.3.5
23
23
 
24
24
  - name: Use Node.js
25
- uses: actions/setup-node@v2.1.5
25
+ uses: actions/setup-node@v2.4.1
26
26
  with:
27
27
  node-version: ${{ matrix.node-version }}
28
28
 
@@ -35,7 +35,7 @@ jobs:
35
35
  npm run test:ci
36
36
 
37
37
  - name: Coveralls Parallel
38
- uses: coverallsapp/github-action@v1.1.2
38
+ uses: coverallsapp/github-action@1.1.3
39
39
  with:
40
40
  github-token: ${{ secrets.github_token }}
41
41
  parallel: true
@@ -46,7 +46,7 @@ jobs:
46
46
  runs-on: ubuntu-latest
47
47
  steps:
48
48
  - name: Coveralls Finished
49
- uses: coverallsapp/github-action@master
49
+ uses: coverallsapp/github-action@1.1.3
50
50
  with:
51
51
  github-token: ${{ secrets.GITHUB_TOKEN }}
52
52
  parallel-finished: true
@@ -55,6 +55,6 @@ jobs:
55
55
  needs: test
56
56
  runs-on: ubuntu-latest
57
57
  steps:
58
- - uses: fastify/github-action-merge-dependabot@v2.0.0
58
+ - uses: fastify/github-action-merge-dependabot@v2.4.0
59
59
  with:
60
60
  github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -0,0 +1,59 @@
1
+ name: package-manager-ci
2
+ on:
3
+ push:
4
+ paths-ignore:
5
+ - 'docs/**'
6
+ - '*.md'
7
+ pull_request:
8
+ paths-ignore:
9
+ - 'docs/**'
10
+ - '*.md'
11
+ jobs:
12
+ pnpm:
13
+ name: pnpm package manager on ${{ matrix.node-version }} ${{ matrix.os }}
14
+ runs-on: ${{ matrix.os }}
15
+ strategy:
16
+ matrix:
17
+ os: [macOS-latest, windows-latest]
18
+ node-version: [12, 14, 16]
19
+ steps:
20
+ - uses: actions/checkout@v2.3.5
21
+ - name: Use Node.js ${{ matrix.node-version }}
22
+ uses: actions/setup-node@v2.4.1
23
+ with:
24
+ node-version: ${{ matrix.node-version }}
25
+ - name: Use pnpm
26
+ uses: pnpm/action-setup@v2.0.1
27
+ with:
28
+ version: ^6.0.0
29
+ - name: Install dependancies
30
+ run: pnpm install
31
+ - name: Tests
32
+ run: pnpm run test:ci
33
+
34
+ yarn-pnp:
35
+ name: yarn-pnp package manager on ${{ matrix.node-version }} ${{ matrix.os }}
36
+ runs-on: ${{ matrix.os }}
37
+ strategy:
38
+ matrix:
39
+ os: [macOS-latest]
40
+ node-version: [12, 14, 16]
41
+ steps:
42
+ - uses: actions/checkout@v2.3.5
43
+ - name: Use Node.js ${{ matrix.node-version }}
44
+ uses: actions/setup-node@v2.4.1
45
+ with:
46
+ node-version: ${{ matrix.node-version }}
47
+ - name: Use yarn
48
+ run: |
49
+ npm install -g yarn
50
+ yarn set version berry
51
+ echo "nodeLinker: pnp" >> .yarnrc.yml
52
+ echo "pnpMode: loose" >> .yarnrc.yml
53
+ yarn add -D pino-elasticsearch@^6.0.0
54
+ yarn install
55
+ env:
56
+ # needed due the yarn.lock file in repository's .gitignore
57
+ YARN_ENABLE_IMMUTABLE_INSTALLS: false
58
+ - name: Tests
59
+ run: yarn run test:yarn
package/.taprc ADDED
@@ -0,0 +1,2 @@
1
+ jobs: 1
2
+ check-coverage: false
package/README.md CHANGED
@@ -62,6 +62,30 @@ Make sure that the stream emits `'close'` when the stream completes.
62
62
  This can usually be achieved by passing the [`autoDestroy: true`](https://nodejs.org/api/stream.html#stream_new_stream_writable_options)
63
63
  flag your stream classes.
64
64
 
65
+ The underlining worker is automatically closed if the stream is garbage collected.
66
+
67
+
68
+ ### External modules
69
+
70
+ You may use this module within compatible external modules, that exports the `worker.js` interface.
71
+
72
+ ```js
73
+ const ThreadStream = require('thread-stream')
74
+
75
+ const modulePath = require.resolve('pino-elasticsearch')
76
+
77
+ const stream = new ThreadStream({
78
+ filename: modulePath,
79
+ workerData: { node: 'http://localhost:9200' }
80
+ })
81
+
82
+ stream.write('log to elasticsearch!')
83
+ stream.flushSync()
84
+ stream.end()
85
+ ```
86
+
87
+ This module works with `yarn` in PnP (plug'n play) mode too!
88
+
65
89
  ## License
66
90
 
67
91
  MIT
package/index.js CHANGED
@@ -9,6 +9,34 @@ const {
9
9
  WRITE_INDEX,
10
10
  READ_INDEX
11
11
  } = require('./lib/indexes')
12
+ const buffer = require('buffer')
13
+
14
+ // V8 limit for string size
15
+ const MAX_STRING = buffer.constants.MAX_STRING_LENGTH
16
+
17
+ class FakeWeakRef {
18
+ constructor (value) {
19
+ this._value = value
20
+ }
21
+
22
+ deref () {
23
+ return this._value
24
+ }
25
+ }
26
+
27
+ const FinalizationRegistry = global.FinalizationRegistry || class FakeFinalizationRegistry {
28
+ register () {}
29
+ unregister () {}
30
+ }
31
+
32
+ const WeakRef = global.WeakRef || FakeWeakRef
33
+
34
+ const registry = new FinalizationRegistry((worker) => {
35
+ if (worker.exited) {
36
+ return
37
+ }
38
+ worker.terminate()
39
+ })
12
40
 
13
41
  function createWorker (stream, opts) {
14
42
  const { filename, workerData } = opts
@@ -27,6 +55,14 @@ function createWorker (stream, opts) {
27
55
  }
28
56
  })
29
57
 
58
+ // We keep a strong reference for now,
59
+ // we need to start writing first
60
+ worker.stream = new FakeWeakRef(stream)
61
+
62
+ worker.on('message', onWorkerMessage)
63
+ worker.on('exit', onWorkerExit)
64
+ registry.register(stream, worker)
65
+
30
66
  return worker
31
67
  }
32
68
 
@@ -90,6 +126,66 @@ function nextFlush (stream) {
90
126
  }
91
127
  }
92
128
 
129
+ function onWorkerMessage (msg) {
130
+ const stream = this.stream.deref()
131
+ if (stream === undefined) {
132
+ this.exited = true
133
+ // Terminate the worker.
134
+ this.terminate()
135
+ return
136
+ }
137
+
138
+ switch (msg.code) {
139
+ case 'READY':
140
+ // Replace the FakeWeakRef with a
141
+ // proper one.
142
+ this.stream = new WeakRef(stream)
143
+ if (stream._sync) {
144
+ stream.ready = true
145
+ stream.flushSync()
146
+ stream.emit('ready')
147
+ } else {
148
+ stream.once('drain', function () {
149
+ stream.flush(() => {
150
+ stream.ready = true
151
+ stream.emit('ready')
152
+ })
153
+ })
154
+ nextFlush(stream)
155
+ }
156
+ break
157
+ case 'ERROR':
158
+ stream.closed = true
159
+ stream.worker.exited = true
160
+ // TODO only remove our own
161
+ stream.worker.removeAllListeners('exit')
162
+ stream.worker.terminate().then(null, () => {})
163
+ process.nextTick(() => {
164
+ stream.emit('error', msg.err)
165
+ })
166
+ break
167
+ default:
168
+ throw new Error('this should not happen: ' + msg.code)
169
+ }
170
+ }
171
+
172
+ function onWorkerExit (code) {
173
+ const stream = this.stream.deref()
174
+ if (stream === undefined) {
175
+ // Nothing to do, the worker already exit
176
+ return
177
+ }
178
+ registry.unregister(stream)
179
+ stream.closed = true
180
+ stream.worker.exited = true
181
+ setImmediate(function () {
182
+ if (code !== 0) {
183
+ stream.emit('error', new Error('The worker thread exited'))
184
+ }
185
+ stream.emit('close')
186
+ })
187
+ }
188
+
93
189
  class ThreadStream extends EventEmitter {
94
190
  constructor (opts = {}) {
95
191
  super()
@@ -110,39 +206,6 @@ class ThreadStream extends EventEmitter {
110
206
  this.closed = false
111
207
 
112
208
  this.buf = ''
113
-
114
- this.worker.on('message', (msg) => {
115
- switch (msg.code) {
116
- case 'READY':
117
- if (this._sync) {
118
- this.ready = true
119
- this.flushSync()
120
- this.emit('ready')
121
- } else {
122
- this.once('drain', function () {
123
- this.flush(() => {
124
- this.ready = true
125
- this.emit('ready')
126
- })
127
- })
128
- nextFlush(this)
129
- }
130
- break
131
- default:
132
- throw new Error('this should not happen: ' + msg.code)
133
- }
134
- })
135
-
136
- this.worker.on('exit', (code) => {
137
- this.closed = true
138
- setImmediate(() => {
139
- if (code === 0) {
140
- this.emit('close')
141
- } else {
142
- this.emit('error', new Error('The worker thread exited'))
143
- }
144
- })
145
- })
146
209
  }
147
210
 
148
211
  _write (data, cb) {
@@ -166,6 +229,12 @@ class ThreadStream extends EventEmitter {
166
229
  throw new Error('the worker has exited')
167
230
  }
168
231
 
232
+ if (this.flushing && this.buf.length + data.length >= MAX_STRING) {
233
+ // process._rawDebug('write: flushing')
234
+ this._writeSync()
235
+ this.flushing = true // we are still flushing
236
+ }
237
+
169
238
  if (!this.ready || this.flushing) {
170
239
  this.buf += data
171
240
  return this._hasSpace()
package/lib/worker.js CHANGED
@@ -15,9 +15,34 @@ const state = new Int32Array(stateBuf)
15
15
  const data = Buffer.from(dataBuf)
16
16
 
17
17
  async function start () {
18
- const fn = (await import(workerData.filename)).default
18
+ let fn
19
+ try {
20
+ fn = (await import(workerData.filename)).default
21
+ } catch (error) {
22
+ // A yarn user that tries to start a ThreadStream for an external module
23
+ // provides a filename pointing to a zip file.
24
+ // eg. require.resolve('pino-elasticsearch') // returns /foo/pino-elasticsearch-npm-6.1.0-0c03079478-6915435172.zip/bar.js
25
+ // The `import` will fail to try to load it.
26
+ // This catch block executes the `require` fallback to load the module correctly.
27
+ // In fact, yarn modifies the `require` function to manage the zipped path.
28
+ // More details at https://github.com/pinojs/pino/pull/1113
29
+ // The error codes may change based on the node.js version (ENOTDIR > 12, ERR_MODULE_NOT_FOUND <= 12 )
30
+ if ((error.code === 'ENOTDIR' || error.code === 'ERR_MODULE_NOT_FOUND') &&
31
+ workerData.filename.startsWith('file://')) {
32
+ fn = require(workerData.filename.replace('file://', ''))
33
+ } else {
34
+ throw error
35
+ }
36
+ }
19
37
  destination = await fn(workerData.workerData)
20
38
 
39
+ destination.on('error', function (err) {
40
+ parentPort.postMessage({
41
+ code: 'ERROR',
42
+ err
43
+ })
44
+ })
45
+
21
46
  destination.on('close', function () {
22
47
  // process._rawDebug('worker close emitted')
23
48
  const end = Atomics.load(state, WRITE_INDEX)
@@ -82,7 +107,17 @@ function run () {
82
107
  }
83
108
 
84
109
  process.on('unhandledRejection', function (err) {
85
- // TODO transfer this to main
86
- console.error(err)
110
+ parentPort.postMessage({
111
+ code: 'ERROR',
112
+ err
113
+ })
114
+ process.exit(1)
115
+ })
116
+
117
+ process.on('uncaughtException', function (err) {
118
+ parentPort.postMessage({
119
+ code: 'ERROR',
120
+ err
121
+ })
87
122
  process.exit(1)
88
123
  })
package/package.json CHANGED
@@ -1,19 +1,21 @@
1
1
  {
2
2
  "name": "thread-stream",
3
- "version": "0.9.0",
3
+ "version": "0.11.2",
4
4
  "description": "A streaming way to send data to a Node.js Worker Thread",
5
5
  "main": "index.js",
6
6
  "devDependencies": {
7
7
  "desm": "^1.1.0",
8
8
  "fastbench": "^1.0.1",
9
- "husky": "^6.0.0",
10
- "sonic-boom": "^1.3.2",
9
+ "husky": "^7.0.0",
10
+ "sonic-boom": "^2.0.1",
11
11
  "standard": "^16.0.3",
12
- "tap": "^15.0.0"
12
+ "tap": "^15.0.0",
13
+ "why-is-node-running": "^2.2.0"
13
14
  },
14
15
  "scripts": {
15
- "test": "standard && tap --no-check-coverage test/*.test.*js",
16
+ "test": "standard && tap test/*.test.*js",
16
17
  "test:ci": "standard && tap \"test/**/*.test.*js\" --no-check-coverage --coverage-report=lcovonly",
18
+ "test:yarn": "tap \"test/**/*.test.js\" --no-check-coverage",
17
19
  "prepare": "husky install"
18
20
  },
19
21
  "repository": {
@@ -0,0 +1,39 @@
1
+ 'use strict'
2
+
3
+ const { join } = require('path')
4
+ const ThreadStream = require('..')
5
+ const assert = require('assert')
6
+
7
+ let worker = null
8
+
9
+ function setup () {
10
+ const stream = new ThreadStream({
11
+ filename: join(__dirname, 'to-file.js'),
12
+ workerData: { dest: process.argv[2] },
13
+ sync: true
14
+ })
15
+
16
+ worker = stream.worker
17
+
18
+ stream.on('ready', function () {
19
+ stream.write('hello')
20
+ stream.write(' ')
21
+ stream.write('world\n')
22
+ stream.flushSync()
23
+ stream.unref()
24
+
25
+ // the stream object goes out of scope here
26
+ setImmediate(gc) // eslint-disable-line
27
+ })
28
+ }
29
+
30
+ setup()
31
+
32
+ let exitEmitted = false
33
+ worker.on('exit', function () {
34
+ exitEmitted = true
35
+ })
36
+
37
+ process.on('exit', function () {
38
+ assert.strictEqual(exitEmitted, true)
39
+ })
@@ -0,0 +1,29 @@
1
+ 'use strict'
2
+
3
+ const { test } = require('tap')
4
+ const ThreadStream = require('..')
5
+
6
+ const isYarnPnp = process.versions.pnp !== undefined
7
+
8
+ test('yarn module resolution', { skip: !isYarnPnp }, t => {
9
+ t.plan(5)
10
+
11
+ const modulePath = require.resolve('pino-elasticsearch')
12
+ t.match(modulePath, /.*\.zip.*/)
13
+
14
+ const stream = new ThreadStream({
15
+ filename: modulePath,
16
+ workerData: { node: null },
17
+ sync: true
18
+ })
19
+
20
+ stream.on('error', (err) => {
21
+ t.pass('error emitted')
22
+ t.equal(err.message, 'Missing node(s) option', 'module custom error')
23
+ })
24
+
25
+ t.ok(stream.write('hello world\n'))
26
+ t.ok(stream.writable)
27
+
28
+ stream.end()
29
+ })
package/test/error.js ADDED
@@ -0,0 +1,14 @@
1
+ 'use strict'
2
+
3
+ const { Writable } = require('stream')
4
+
5
+ async function run (opts) {
6
+ const stream = new Writable({
7
+ write (chunk, enc, cb) {
8
+ cb(new Error('kaboom'))
9
+ }
10
+ })
11
+ return stream
12
+ }
13
+
14
+ module.exports = run
package/test/esm.test.mjs CHANGED
@@ -1,29 +1,9 @@
1
1
  import { test } from 'tap'
2
- import { tmpdir } from 'os'
3
- import { unlinkSync, readFile } from 'fs'
2
+ import { readFile } from 'fs'
4
3
  import ThreadStream from '../index.js'
5
4
  import { join } from 'desm'
6
- import path from 'path'
7
5
  import { pathToFileURL } from 'url'
8
-
9
- const files = []
10
- let count = 0
11
-
12
- function file () {
13
- const file = path.join(tmpdir(), `thread-stream-${process.pid}-${process.hrtime().toString()}-${count++}`)
14
- files.push(file)
15
- return file
16
- }
17
-
18
- process.on('beforeExit', () => {
19
- for (const file of files) {
20
- try {
21
- unlinkSync(file)
22
- } catch (e) {
23
- console.log(e)
24
- }
25
- }
26
- })
6
+ import { file } from './helper.js'
27
7
 
28
8
  function basic (text, filename) {
29
9
  test(text, function (t) {
package/test/helper.js CHANGED
@@ -3,24 +3,31 @@
3
3
  const { join } = require('path')
4
4
  const { tmpdir } = require('os')
5
5
  const { unlinkSync } = require('fs')
6
+ const why = require('why-is-node-running')
7
+ const t = require('tap')
6
8
 
7
9
  const files = []
8
10
  let count = 0
9
11
 
10
12
  function file () {
11
- const file = join(tmpdir(), `thread-stream-${process.pid}-${process.hrtime().toString()}-${count++}`)
13
+ const file = join(tmpdir(), `thread-stream-${process.pid}-${count++}`)
12
14
  files.push(file)
13
15
  return file
14
16
  }
15
17
 
16
18
  process.on('beforeExit', () => {
19
+ t.comment('unlink files')
17
20
  for (const file of files) {
18
21
  try {
22
+ t.comment(`unliking ${file}`)
19
23
  unlinkSync(file)
20
24
  } catch (e) {
21
25
  console.log(e)
22
26
  }
23
27
  }
28
+ t.comment('unlink completed')
24
29
  })
25
30
 
26
31
  module.exports.file = file
32
+
33
+ setInterval(why, 10000).unref()
@@ -0,0 +1,40 @@
1
+ 'use strict'
2
+
3
+ const t = require('tap')
4
+ const { join } = require('path')
5
+ const { file } = require('./helper')
6
+ const { stat } = require('fs')
7
+ const ThreadStream = require('..')
8
+
9
+ t.setTimeout(30000)
10
+
11
+ const dest = file()
12
+ const stream = new ThreadStream({
13
+ filename: join(__dirname, 'to-file.js'),
14
+ workerData: { dest },
15
+ sync: false
16
+ })
17
+
18
+ let length = 0
19
+
20
+ stream.on('ready', () => {
21
+ t.pass('ready emitted')
22
+
23
+ const buf = Buffer.alloc(1024).fill('x').toString() // 1 KB
24
+
25
+ // This writes 1 GB of data
26
+ for (let i = 0; i < 1024 * 1024; i++) {
27
+ length += buf.length
28
+ stream.write(buf)
29
+ }
30
+
31
+ stream.end()
32
+ })
33
+
34
+ stream.on('close', () => {
35
+ stat(dest, (err, f) => {
36
+ t.error(err)
37
+ t.equal(f.size, length)
38
+ t.end()
39
+ })
40
+ })
@@ -86,7 +86,7 @@ test('emit error if thread have unhandledRejection', async function (t) {
86
86
  })
87
87
 
88
88
  const [err] = await once(stream, 'error')
89
- t.equal(err.message, 'The worker thread exited')
89
+ t.equal(err.message, 'kaboom')
90
90
 
91
91
  try {
92
92
  stream.write('noop')
@@ -102,3 +102,72 @@ test('emit error if thread have unhandledRejection', async function (t) {
102
102
  t.equal(err.message, 'the worker has exited')
103
103
  }
104
104
  })
105
+
106
+ test('emit error if worker stream emit error', async function (t) {
107
+ const stream = new ThreadStream({
108
+ filename: join(__dirname, 'error.js'),
109
+ sync: true
110
+ })
111
+
112
+ stream.on('ready', function () {
113
+ stream.write('hello world\n')
114
+ })
115
+
116
+ const [err] = await once(stream, 'error')
117
+ t.equal(err.message, 'kaboom')
118
+
119
+ try {
120
+ stream.write('noop')
121
+ t.fail('unreacheable')
122
+ } catch (err) {
123
+ t.equal(err.message, 'the worker has exited')
124
+ }
125
+
126
+ try {
127
+ stream.write('noop')
128
+ t.fail('unreacheable')
129
+ } catch (err) {
130
+ t.equal(err.message, 'the worker has exited')
131
+ }
132
+ })
133
+
134
+ test('emit error if thread have uncaughtException', async function (t) {
135
+ const stream = new ThreadStream({
136
+ filename: join(__dirname, 'uncaughtException.js'),
137
+ sync: true
138
+ })
139
+
140
+ stream.on('ready', function () {
141
+ stream.write('hello world\n')
142
+ })
143
+
144
+ const [err] = await once(stream, 'error')
145
+ t.equal(err.message, 'kaboom')
146
+
147
+ try {
148
+ stream.write('noop')
149
+ t.fail('unreacheable')
150
+ } catch (err) {
151
+ t.equal(err.message, 'the worker has exited')
152
+ }
153
+
154
+ try {
155
+ stream.write('noop')
156
+ t.fail('unreacheable')
157
+ } catch (err) {
158
+ t.equal(err.message, 'the worker has exited')
159
+ }
160
+ })
161
+
162
+ test('close the work if out of scope on gc', { skip: !global.WeakRef }, async function (t) {
163
+ const dest = file()
164
+ const child = fork(join(__dirname, 'close-on-gc.js'), [dest], {
165
+ execArgv: ['--expose-gc']
166
+ })
167
+
168
+ const [code] = await once(child, 'exit')
169
+ t.equal(code, 0)
170
+
171
+ const data = await readFile(dest, 'utf8')
172
+ t.equal(data, 'hello world\n')
173
+ })
@@ -0,0 +1,21 @@
1
+ 'use strict'
2
+
3
+ const { Writable } = require('stream')
4
+
5
+ // Nop console.error to avoid printing things out
6
+ console.error = () => {}
7
+
8
+ setImmediate(function () {
9
+ throw new Error('kaboom')
10
+ })
11
+
12
+ async function run (opts) {
13
+ const stream = new Writable({
14
+ write (chunk, enc, cb) {
15
+ cb()
16
+ }
17
+ })
18
+ return stream
19
+ }
20
+
21
+ module.exports = run