Repository: pinojs/pino
Pull request #1913 (2024-03-01 15:44:40)

build(deps): bump pnpm/action-setup from 2.4.0 to 3.0.0
Bumps [pnpm/action-setup](https://github.com/pnpm/action-setup) from 2.4.0 to 3.0.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/pnpm/action-setup/releases">pnpm/action-setup's releases</a>.</em></p>
<blockquote>
<h2>v3.0.0</h2>
<p>The action is updated to run on Node.js v20</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/pnpm/action-setup/commit/a3252b78c470c02df07e9d59298aecedc3ccdd6d"><code>a3252b7</code></a> docs(README): update version</li>
<li><a href="https://github.com/pnpm/action-setup/commit/1ee9c9d01d7ec8c679f2f85de0ff31e8af2c9944"><code>1ee9c9d</code></a> feat!: node20 upgrade (<a href="https://redirect.github.com/pnpm/action-setup/issues/110">#110</a>)</li>
<li><a href="https://github.com/pnpm/action-setup/commit/ebcfd6995dade4b0104ac774445cef8b3b4635b0"><code>ebcfd69</code></a> Bump actions/setup-node from 3 to 4 (<a href="https://redirect.github.com/pnpm/action-setup/issues/103">#103</a>)</li>
<li><a href="https://github.com/pnpm/action-setup/commit/d2613e087f2e0aa841925861c5a5f7395d552177"><code>d2613e0</code></a> docs: update pnpm version in caching example (<a href="https://redirect.github.com/pnpm/action-setup/issues/94">#94</a>)</li>
<li><a href="https://github.com/pnpm/action-setup/commit/d928be8e0cf9bc125caccbd7302fd195e03cb569"><code>d928be8</code></a> Bump actions/checkout from 3 to 4 (<a href="https://redirect.github.com/pnpm/action-setup/issues/96">#96</a>)</li>
<li>See full diff in <a href="https://github.com/pnpm/action-setup/compare/v2.4.0...v3.0.0">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=pnpm/action-setup&package-manager=github_actions&previous-version=2.4.0&new-version=3.0.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

You can trigger a rebase of this PR by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)


</details>

> **Note**
> Automatic rebases have been disabled on this pull request as it has been open for over 30 days.



Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 77bc0be..274931b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -31,7 +31,7 @@ jobs:
           persist-credentials: false
 
       - name: Dependency review
-        uses: actions/dependency-review-action@v3
+        uses: actions/dependency-review-action@v4
 
   test:
     name: ${{ matrix.node-version }} ${{ matrix.os }}
@@ -42,10 +42,11 @@ jobs:
       fail-fast: false
       matrix:
         os: [macOS-latest, windows-latest, ubuntu-latest]
-        node-version: [14, 16, 18, 19, 20, 21]
-        exclude:
-          - os: windows-latest
-            node-version: 14
+        node-version: [18, 20, 22]
+      exclude:
+        - os: windows-latest
+          node-version: 22
+
     steps:
       - name: Check out repo
         uses: actions/checkout@v4
diff --git a/.github/workflows/package-manager-ci.yml b/.github/workflows/package-manager-ci.yml
index a6858e2..294046b 100644
--- a/.github/workflows/package-manager-ci.yml
+++ b/.github/workflows/package-manager-ci.yml
@@ -14,7 +14,7 @@ jobs:
       fail-fast: false
       matrix:
         os: [windows-latest, ubuntu-latest]
-        node-version: [16]
+        node-version: [20]
     steps:
       - uses: actions/checkout@v4
         with:
@@ -24,7 +24,7 @@ jobs:
         with:
           node-version: ${{ matrix.node-version }}
       - name: Use pnpm
-        uses: pnpm/action-setup@v2.4.0
+        uses: pnpm/action-setup@v3.0.0
         with:
           version: ^6.0.0
       - name: Install dependancies
@@ -39,7 +39,7 @@ jobs:
       fail-fast: false
       matrix:
         os: [windows-latest, ubuntu-latest]
-        node-version: [16]
+        node-version: [20]
     steps:
       - uses: actions/checkout@v4
         with:
@@ -68,7 +68,7 @@ jobs:
       fail-fast: false
       matrix:
         os: [windows-latest, ubuntu-latest]
-        node-version: [16]
+        node-version: [20]
     steps:
       - uses: actions/checkout@v4
         with:
diff --git a/README.md b/README.md
index 2592975..27c98b2 100644
--- a/README.md
+++ b/README.md
@@ -143,12 +143,13 @@ Pino is an **OPEN Open Source Project**. This means that:
 
 > Individuals making significant and valuable contributions are given commit-access to the project to contribute as they see fit. This project is more like an open wiki than a standard guarded open source project.
 
-See the [CONTRIBUTING.md](https://github.com/pinojs/pino/blob/master/CONTRIBUTING.md) file for more details.
+See the [CONTRIBUTING.md](https://github.com/pinojs/pino/blob/main/CONTRIBUTING.md) file for more details.
 
 <a name="acknowledgments"></a>
 ## Acknowledgments
 
 This project was kindly sponsored by [nearForm](https://nearform.com).
+This project is kindly sponsored by [Platformatic](https://platformatic.dev).
 
 Logo and identity designed by Cosmic Fox Design: https://www.behance.net/cosmicfox.
 
diff --git a/browser.js b/browser.js
index 04b00ce..f786739 100644
--- a/browser.js
+++ b/browser.js
@@ -302,7 +302,7 @@ function createWrap (self, opts, rootLogger, level) {
       const proto = (Object.getPrototypeOf && Object.getPrototypeOf(this) === _console) ? _console : this
       for (var i = 0; i < args.length; i++) args[i] = arguments[i]
 
-      if (opts.serialize && !opts.asObject) {
+      if (opts.serialize && !opts.transmit) {
         applySerializers(args, this._serialize, this.serializers, this._stdErrSerialize)
       }
       if (opts.asObject || opts.formatters) {
@@ -330,17 +330,22 @@ function createWrap (self, opts, rootLogger, level) {
 
 function asObject (logger, level, args, ts, formatters = {}) {
   const {
-    level: levelFormatter = () => logger.levels.values[level],
+    level: levelFormatter,
     log: logObjectFormatter = (obj) => obj
   } = formatters
-  if (logger._serialize) applySerializers(args, logger._serialize, logger.serializers, logger._stdErrSerialize)
   const argsCloned = args.slice()
   let msg = argsCloned[0]
   const logObject = {}
   if (ts) {
     logObject.time = ts
   }
-  logObject.level = levelFormatter(level, logger.levels.values[level])
+
+  if (levelFormatter) {
+    const formattedLevel = levelFormatter(level, logger.levels.values[level])
+    Object.assign(logObject, formattedLevel)
+  } else {
+    logObject.level = logger.levels.values[level]
+  }
 
   let lvl = (logger._childLevel | 0) + 1
   if (lvl < 1) lvl = 1
@@ -361,9 +366,9 @@ function applySerializers (args, serialize, serializers, stdErrSerialize) {
   for (const i in args) {
     if (stdErrSerialize && args[i] instanceof Error) {
       args[i] = pino.stdSerializers.err(args[i])
-    } else if (typeof args[i] === 'object' && !Array.isArray(args[i])) {
+    } else if (typeof args[i] === 'object' && !Array.isArray(args[i]) && serialize) {
       for (const k in args[i]) {
-        if (serialize && serialize.indexOf(k) > -1 && k in serializers) {
+        if (serialize.indexOf(k) > -1 && k in serializers) {
           args[i][k] = serializers[k](args[i][k])
         }
       }
@@ -385,6 +390,7 @@ function transmit (logger, opts, args) {
     logger.serializers,
     logger._stdErrSerialize === undefined ? true : logger._stdErrSerialize
   )
+
   logger._logEvent.ts = ts
   logger._logEvent.messages = args.filter(function (arg) {
     // bindings can only be objects, so reference equality check via indexOf is fine
diff --git a/docs/api.md b/docs/api.md
index 1fc4384..80fa249 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -241,13 +241,13 @@ child.info('this will have both `foo: 1` and `bar: 2`')
 logger.info('this will still only have `foo: 1`')
 ```
 
-As of pino 7.x, when the `mixin` is used with the [`nestedKey` option](#opt-nestedkey), 
-the object returned from the `mixin` method will also be nested. Prior versions would mix 
-this object into the root. 
+As of pino 7.x, when the `mixin` is used with the [`nestedKey` option](#opt-nestedkey),
+the object returned from the `mixin` method will also be nested. Prior versions would mix
+this object into the root.
 
 ```js
 const logger = pino({
-    nestedKey: 'payload', 
+    nestedKey: 'payload',
     mixin() {
         return { requestId: requestId.currentId() }
     }
@@ -590,7 +590,7 @@ when using the `transport` option. In this case, an `Error` will be thrown.
 
 #### `onChild` (Function)
 
-The `onChild` function is a synchronous callback that will be called on each creation of a new child, passing the child instance as its first argument.  
+The `onChild` function is a synchronous callback that will be called on each creation of a new child, passing the child instance as its first argument.
 Any error thrown inside the callback will be uncaught and should be handled inside the callback.
 ```js
 const parent = require('pino')({ onChild: (instance) => {
@@ -609,7 +609,7 @@ Default: `pino.destination(1)` (STDOUT)
 The `destination` parameter can be a file descriptor, a file path, or an
 object with `dest` property pointing to a fd or path.
 An ordinary Node.js `stream` file descriptor can be passed as the
-destination (such as the result 
+destination (such as the result
 of `fs.createWriteStream`) but for peak log writing performance, it is strongly
 recommended to use `pino.destination` to create the destination stream.
 Note that the `destination` parameter can be the result of `pino.transport()`.
@@ -1001,7 +1001,7 @@ Adds to the bindings of this logger instance.
 **Note:** Does not overwrite bindings. Can potentially result in duplicate keys in
 log lines.
 
-* See [`bindings` parameter in `logger.child`](#logger-child-bindings)     
+* See [`bindings` parameter in `logger.child`](#logger-child-bindings)
 
 <a id="flush"></a>
 ### `logger.flush([cb])`
@@ -1239,6 +1239,30 @@ const transport = pino.transport({
 pino(transport)
 ```
 
+Multiple transports can now be defined to include pipelines:
+
+```js
+const pino = require('pino')
+const transport = pino.transport({
+  targets: [{
+    level: 'info',
+    target: 'pino-pretty' // must be installed separately
+  }, {
+    level: 'trace',
+    target: 'pino/file',
+    options: { destination: '/path/to/store/logs' }
+  }, {
+    pipeline: [{
+      target: 'pino-syslog' // must be installed separately
+    }, {
+      target: 'pino-socket' // must be installed separately
+    }]
+  }
+  ]
+})
+pino(transport)
+```
+
 If `WeakRef`, `WeakMap`, and `FinalizationRegistry` are available in the current runtime (v14.5.0+), then the thread
 will be automatically terminated in case the stream or logger goes out of scope.
 The `transport()` function adds a listener to `process.on('beforeExit')` and `process.on('exit')` to ensure the worker
@@ -1276,7 +1300,7 @@ For more on transports, how they work, and how to create them see the [`Transpor
 * `target`:  The transport to pass logs through. This may be an installed module name or an absolute path.
 * `options`:  An options object which is serialized (see [Structured Clone Algorithm](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm)), passed to the worker thread, parsed and then passed to the exported transport function.
 * `worker`: [Worker thread](https://nodejs.org/api/worker_threads.html#worker_threads_new_worker_filename_options) configuration options. Additionally, the `worker` option supports `worker.autoEnd`. If this is set to `false` logs will not be flushed on process exit. It is then up to the developer to call `transport.end()` to flush logs.
-* `targets`: May be specified instead of `target`. Must be an array of transport configurations. Transport configurations include the aforementioned `options` and `target` options plus a `level` option which will send only logs above a specified level to a transport.
+* `targets`: May be specified instead of `target`. Must be an array of transport configurations and/or pipelines. Transport configurations include the aforementioned `options` and `target` options plus a `level` option which will send only logs above a specified level to a transport.
 * `pipeline`: May be specified instead of `target`. Must be an array of transport configurations. Transport configurations include the aforementioned `options` and `target` options. All intermediate steps in the pipeline _must_ be `Transform` streams and not `Writable`.
 * `dedupe`: See [pino.multistream options](#pino-multistream)
 
diff --git a/docs/browser.md b/docs/browser.md
index deb465c..a23ce83 100644
--- a/docs/browser.md
+++ b/docs/browser.md
@@ -144,7 +144,7 @@ Unlike server pino the serializers apply to every object passed to the logger me
 if the `asObject` option is `true`, this results in the serializers applying to the
 first object (as in server pino).
 
-For more info on serializers see https://github.com/pinojs/pino/blob/master/docs/api.md#parameters.
+For more info on serializers see https://github.com/pinojs/pino/blob/master/docs/api.md#mergingobject.
 
 ### `transmit` (Object)
 
diff --git a/docs/bundling.md b/docs/bundling.md
index 6467b8e..1c326a9 100644
--- a/docs/bundling.md
+++ b/docs/bundling.md
@@ -7,7 +7,6 @@ In particular, a bundler must ensure that the following files are also bundled s
 * `lib/worker.js` from the `thread-stream` dependency
 * `file.js`
 * `lib/worker.js`
-* `lib/worker-pipeline.js`
 * Any transport used by the user (like `pino-pretty`)
 
 Once the files above have been generated, the bundler must also add information about the files above by injecting a code that sets `__bundlerPathsOverrides` in the `globalThis` object.
@@ -22,12 +21,11 @@ globalThis.__bundlerPathsOverrides = {
   'thread-stream-worker': pinoWebpackAbsolutePath('./thread-stream-worker.js')
   'pino/file': pinoWebpackAbsolutePath('./pino-file.js'),
   'pino-worker': pinoWebpackAbsolutePath('./pino-worker.js'),
-  'pino-pipeline-worker': pinoWebpackAbsolutePath('./pino-pipeline-worker.js'),
   'pino-pretty': pinoWebpackAbsolutePath('./pino-pretty.js'),
 };
 ```
 
-Note that `pino/file`, `pino-worker`, `pino-pipeline-worker`, and `thread-stream-worker` are required identifiers. Other identifiers are possible based on the user configuration.
+Note that `pino/file`, `pino-worker` and `thread-stream-worker` are required identifiers. Other identifiers are possible based on the user configuration.
 
 ## Webpack Plugin
 
diff --git a/docs/lts.md b/docs/lts.md
index 115c266..2c880cb 100644
--- a/docs/lts.md
+++ b/docs/lts.md
@@ -43,7 +43,8 @@ A "month" is defined as 30 consecutive days.
 
 | Version | Release Date | End Of LTS Date | Node.js              |
 | :------ | :----------- | :-------------- | :------------------- |
-| 8.x     | 2022-06-01   | TBD             | 14, 16, 18           |
+| 9.x     | 2024-04-26   | TBD             | 18, 20, 22            |
+| 8.x     | 2022-06-01   | 2024-10-26      | 14, 16, 18, 20        |
 | 7.x     | 2021-10-14   | 2023-06-01      | 12, 14, 16           |
 | 6.x     | 2020-03-07   | 2022-04-14      | 10, 12, 14, 16       |
 
@@ -58,6 +59,6 @@ the YAML workflow labels below:
 
 | OS      | YAML Workflow Label    | Node.js      |
 |---------|------------------------|--------------|
-| Linux   | `ubuntu-latest`        | 14,16,18     |
-| Windows | `windows-latest`       | 14,16,18     |
-| MacOS   | `macos-latest`         | 14,16,18     |
+| Linux   | `ubuntu-latest`        | 18, 20, 22   |
+| Windows | `windows-latest`       | 18, 20, 22   |
+| MacOS   | `macos-latest`         | 18, 20, 22   |
diff --git a/docs/web.md b/docs/web.md
index 754e033..49a31d0 100644
--- a/docs/web.md
+++ b/docs/web.md
@@ -236,24 +236,34 @@ See the [nestjs-pino README](https://npm.im/nestjs-pino) for more info.
 ## Pino with H3
 
 ```sh
-npm install pino-http
+npm install pino-http h3
 ```
 
+Save as `server.mjs`:
+
 ```js
-import { createServer } from 'http'
-import { createApp } from 'h3'
+import { createApp, createRouter, eventHandler, fromNodeMiddleware } from "h3";
 import pino from 'pino-http'
 
-const app = createApp()
+export const app = createApp();
 
-app.use(pino())
+const router = createRouter();
+app.use(router);
+app.use(fromNodeMiddleware(pino()))
 
-app.use('/', (req) => {
-  req.log.info('something')
+app.use(eventHandler((event) => {
+  event.node.req.log.info('something')
   return 'hello world'
-})
-
-createServer(app).listen(process.env.PORT || 3000)
+}))
+
+router.get(
+  "/",
+  eventHandler((event) => {
+    return { path: event.path, message: "Hello World!" };
+  }),
+);
 ```
 
+Execute `npx --yes listhen -w --open ./server.mjs`.
+
 See the [pino-http README](https://npm.im/pino-http) for more info.
diff --git a/lib/meta.js b/lib/meta.js
index a55693a..57fc39c 100644
--- a/lib/meta.js
+++ b/lib/meta.js
@@ -1,3 +1,3 @@
 'use strict'
 
-module.exports = { version: '8.21.0' }
+module.exports = { version: '9.0.0' }
diff --git a/lib/transport-stream.js b/lib/transport-stream.js
index 9da0ff8..da864a9 100644
--- a/lib/transport-stream.js
+++ b/lib/transport-stream.js
@@ -13,7 +13,7 @@ module.exports = loadTransportStreamBuilder
 async function loadTransportStreamBuilder (target) {
   let fn
   try {
-    const toLoad = 'file://' + target
+    const toLoad = target.startsWith('file://') ? target : 'file://' + target
 
     if (toLoad.endsWith('.ts') || toLoad.endsWith('.cts')) {
       // TODO: add support for the TSM modules loader ( https://github.com/lukeed/tsm ).
@@ -31,8 +31,10 @@ async function loadTransportStreamBuilder (target) {
     // See this PR for details: https://github.com/pinojs/thread-stream/pull/34
     if ((error.code === 'ENOTDIR' || error.code === 'ERR_MODULE_NOT_FOUND')) {
       fn = realRequire(target)
-    } else if (error.code === undefined) {
+    } else if (error.code === undefined || error.code === 'ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING') {
       // When bundled with pkg, an undefined error is thrown when called with realImport
+      // When bundled with pkg and using node v20, an ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING error is thrown when called with realImport
+      // More info at: https://github.com/pinojs/thread-stream/issues/143
       fn = realRequire(decodeURIComponent(target))
     } else {
       throw error
diff --git a/lib/transport.js b/lib/transport.js
index 6be075f..b8ce347 100644
--- a/lib/transport.js
+++ b/lib/transport.js
@@ -87,20 +87,29 @@ function transport (fullOptions) {
 
   if (targets) {
     target = bundlerOverrides['pino-worker'] || join(__dirname, 'worker.js')
-    options.targets = targets.map((dest) => {
+    options.targets = targets.filter(dest => dest.target).map((dest) => {
       return {
         ...dest,
         target: fixTarget(dest.target)
       }
     })
+    options.pipelines = targets.filter(dest => dest.pipeline).map((dest) => {
+      return dest.pipeline.map((t) => {
+        return {
+          ...t,
+          level: dest.level, // duplicate the pipeline `level` property defined in the upper level
+          target: fixTarget(t.target)
+        }
+      })
+    })
   } else if (pipeline) {
-    target = bundlerOverrides['pino-pipeline-worker'] || join(__dirname, 'worker-pipeline.js')
-    options.targets = pipeline.map((dest) => {
+    target = bundlerOverrides['pino-worker'] || join(__dirname, 'worker.js')
+    options.pipelines = [pipeline.map((dest) => {
       return {
         ...dest,
         target: fixTarget(dest.target)
       }
-    })
+    })]
   }
 
   if (levels) {
diff --git a/lib/worker-pipeline.js b/lib/worker-pipeline.js
deleted file mode 100644
index 76cb3b8..0000000
--- a/lib/worker-pipeline.js
+++ /dev/null
@@ -1,38 +0,0 @@
-'use strict'
-
-const EE = require('events')
-const loadTransportStreamBuilder = require('./transport-stream')
-const { pipeline, PassThrough } = require('stream')
-
-// This file is not checked by the code coverage tool,
-// as it is not reliable.
-
-/* istanbul ignore file */
-
-module.exports = async function ({ targets }) {
-  const streams = await Promise.all(targets.map(async (t) => {
-    const fn = await loadTransportStreamBuilder(t.target)
-    const stream = await fn(t.options)
-    return stream
-  }))
-  const ee = new EE()
-
-  const stream = new PassThrough({
-    autoDestroy: true,
-    destroy (_, cb) {
-      ee.on('error', cb)
-      ee.on('closed', cb)
-    }
-  })
-
-  pipeline(stream, ...streams, function (err) {
-    if (err && err.code !== 'ERR_STREAM_PREMATURE_CLOSE') {
-      ee.emit('error', err)
-      return
-    }
-
-    ee.emit('closed')
-  })
-
-  return stream
-}
diff --git a/lib/worker.js b/lib/worker.js
index c20c19a..dcbd5d3 100644
--- a/lib/worker.js
+++ b/lib/worker.js
@@ -1,5 +1,7 @@
 'use strict'
 
+const EE = require('events')
+const { pipeline, PassThrough } = require('stream')
 const pino = require('../pino.js')
 const build = require('pino-abstract-transport')
 const loadTransportStreamBuilder = require('./transport-stream')
@@ -9,36 +11,144 @@ const loadTransportStreamBuilder = require('./transport-stream')
 
 /* istanbul ignore file */
 
-module.exports = async function ({ targets, levels, dedupe }) {
-  targets = await Promise.all(targets.map(async (t) => {
-    const fn = await loadTransportStreamBuilder(t.target)
-    const stream = await fn(t.options)
-    return {
-      level: t.level,
-      stream
-    }
-  }))
-  return build(process, {
-    parse: 'lines',
-    metadata: true,
-    close (err, cb) {
-      let expected = 0
-      for (const transport of targets) {
-        expected++
-        transport.stream.on('close', closeCb)
-        transport.stream.end()
+/*
+ * > Multiple targets & pipelines
+ *
+ *
+ * ┌─────────────────────────────────────────────────┐    ┌─────┐
+ * │                                                 │    │  p  │
+ * │                                                 │    │  i  │
+ * │                   target                        │    │  n  │
+ * │               │ ────────────────────────────────┼────┤  o  │
+ * │   targets     │   target                        │    │  .  │
+ * │ ────────────► │ ────────────────────────────────┼────┤  m  │       source
+ * │               │   target                        │    │  u  │         │
+ * │               │ ────────────────────────────────┼────┤  l  │         │write
+ * │               │                                 │    │  t  │         ▼
+ * │               │  pipeline   ┌───────────────┐   │    │  i  │      ┌────────┐
+ * │               │ ──────────► │  PassThrough  ├───┼────┤  s  ├──────┤        │
+ * │               │             └───────────────┘   │    │  t  │ write│ Thread │
+ * │               │                                 │    │  r  │◄─────┤ Stream │
+ * │               │  pipeline   ┌───────────────┐   │    │  e  │      │        │
+ * │               │ ──────────► │  PassThrough  ├───┼────┤  a  │      └────────┘
+ * │                             └───────────────┘   │    │  m  │
+ * │                                                 │    │     │
+ * └─────────────────────────────────────────────────┘    └─────┘
+ *
+ *
+ *
+ *  > One single pipeline or target
+ *
+ *
+ *                                                           source
+ *                                                             │
+ * ┌────────────────────────────────────────────────┐          │write
+ * │                                                │          ▼
+ * │                                                │      ┌────────┐
+ * │   targets     │   target                       │      │        │
+ * │ ────────────► │  ──────────────────────────────┤      │        │
+ * │               │                                │      │        │
+ * │                                                ├──────┤        │
+ * │                                                │      │        │
+ * │                                                │      │        │
+ * │                     OR                         │      │        │
+ * │                                                │      │        │
+ * │                                                │      │        │
+ * │                               ┌──────────────┐ │      │        │
+ * │   targets     │   pipeline    │              │ │      │ Thread │
+ * │ ────────────► │  ────────────►│ PassThrough  ├─┤      │ Stream │
+ * │               │               │              │ │      │        │
+ * │                               └──────────────┘ │      │        │
+ * │                                                │      │        │
+ * │                     OR                         │ write│        │
+ * │                                                │◄─────┤        │
+ * │                                                │      │        │
+ * │                ┌──────────────┐                │      │        │
+ * │    pipeline    │              │                │      │        │
+ * │ ──────────────►│ PassThrough  ├────────────────┤      │        │
+ * │                │              │                │      │        │
+ * │                └──────────────┘                │      └────────┘
+ * │                                                │
+ * │                                                │
+ * └────────────────────────────────────────────────┘
+ */
+
+module.exports = async function ({ targets, pipelines, levels, dedupe }) {
+  const targetStreams = []
+
+  // Process targets
+  if (targets && targets.length) {
+    targets = await Promise.all(targets.map(async (t) => {
+      const fn = await loadTransportStreamBuilder(t.target)
+      const stream = await fn(t.options)
+      return {
+        level: t.level,
+        stream
       }
+    }))
+
+    targetStreams.push(...targets)
+  }
+
+  // Process pipelines
+  if (pipelines && pipelines.length) {
+    pipelines = await Promise.all(
+      pipelines.map(async (p) => {
+        let level
+        const pipeDests = await Promise.all(
+          p.map(async (t) => {
+            // level assigned to pipeline is duplicated over all its targets, just store it
+            level = t.level
+            const fn = await loadTransportStreamBuilder(t.target)
+            const stream = await fn(t.options)
+            return stream
+          }
+          ))
 
-      function closeCb () {
-        if (--expected === 0) {
-          cb(err)
+        return {
+          level,
+          stream: createPipeline(pipeDests)
+        }
+      })
+    )
+    targetStreams.push(...pipelines)
+  }
+
+  // Skip building the multistream step if either one single pipeline or target is defined and
+  // return directly the stream instance back to TreadStream.
+  // This is equivalent to define either:
+  //
+  // pino.transport({ target: ... })
+  //
+  // OR
+  //
+  // pino.transport({ pipeline: ... })
+  if (targetStreams.length === 1) {
+    return targetStreams[0].stream
+  } else {
+    return build(process, {
+      parse: 'lines',
+      metadata: true,
+      close (err, cb) {
+        let expected = 0
+        for (const transport of targetStreams) {
+          expected++
+          transport.stream.on('close', closeCb)
+          transport.stream.end()
+        }
+
+        function closeCb () {
+          if (--expected === 0) {
+            cb(err)
+          }
         }
       }
-    }
-  })
+    })
+  }
 
+  // TODO: Why split2 was not used for pipelines?
   function process (stream) {
-    const multi = pino.multistream(targets, { levels, dedupe })
+    const multi = pino.multistream(targetStreams, { levels, dedupe })
     // TODO manage backpressure
     stream.on('data', function (chunk) {
       const { lastTime, lastMsg, lastObj, lastLevel } = this
@@ -51,4 +161,34 @@ module.exports = async function ({ targets, levels, dedupe }) {
       multi.write(chunk + '\n')
     })
   }
+
+  /**
+ * Creates a pipeline using the provided streams and return an instance of `PassThrough` stream
+ * as a source for the pipeline.
+ *
+ * @param {(TransformStream|WritableStream)[]} streams An array of streams.
+ *   All intermediate streams in the array *MUST* be `Transform` streams and only the last one `Writable`.
+ * @returns A `PassThrough` stream instance representing the source stream of the pipeline
+ */
+  function createPipeline (streams) {
+    const ee = new EE()
+    const stream = new PassThrough({
+      autoDestroy: true,
+      destroy (_, cb) {
+        ee.on('error', cb)
+        ee.on('closed', cb)
+      }
+    })
+
+    pipeline(stream, ...streams, function (err) {
+      if (err && err.code !== 'ERR_STREAM_PREMATURE_CLOSE') {
+        ee.emit('error', err)
+        return
+      }
+
+      ee.emit('closed')
+    })
+
+    return stream
+  }
 }
diff --git a/package.json b/package.json
index dbbd775..5a1a850 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "pino",
-  "version": "8.21.0",
+  "version": "9.0.0",
   "description": "super fast, all natural json logger",
   "main": "pino.js",
   "type": "commonjs",
@@ -63,6 +63,7 @@
     "@types/flush-write-stream": "^1.0.0",
     "@types/node": "^20.2.3",
     "@types/tap": "^15.0.6",
+    "@yao-pkg/pkg": "^5.11.5",
     "airtap": "4.0.4",
     "benchmark": "^2.1.4",
     "bole": "^5.0.5",
@@ -83,7 +84,7 @@
     "log": "^6.0.0",
     "loglevel": "^1.6.7",
     "midnight-smoker": "1.1.1",
-    "pino-pretty": "^10.2.1",
+    "pino-pretty": "^11.0.0",
     "pre-commit": "^1.2.2",
     "proxyquire": "^2.1.3",
     "pump": "^3.0.0",
@@ -96,7 +97,7 @@
     "tape": "^5.5.3",
     "through2": "^4.0.0",
     "ts-node": "^10.9.1",
-    "tsd": "^0.30.4",
+    "tsd": "^0.31.0",
     "typescript": "^5.1.3",
     "winston": "^3.7.2"
   },
@@ -105,12 +106,12 @@
     "fast-redact": "^3.1.1",
     "on-exit-leak-free": "^2.1.0",
     "pino-abstract-transport": "^1.2.0",
-    "pino-std-serializers": "^6.0.0",
+    "pino-std-serializers": "^7.0.0",
     "process-warning": "^3.0.0",
     "quick-format-unescaped": "^4.0.3",
     "real-require": "^0.2.0",
     "safe-stable-stringify": "^2.3.1",
-    "sonic-boom": "^3.7.0",
+    "sonic-boom": "^4.0.1",
     "thread-stream": "^2.6.0"
   },
   "tsd": {
diff --git a/pino.d.ts b/pino.d.ts
index 41ffdfb..3efe722 100644
--- a/pino.d.ts
+++ b/pino.d.ts
@@ -259,10 +259,11 @@ declare namespace pino {
 
     interface TransportPipelineOptions<TransportOptions = Record<string, any>> extends TransportBaseOptions<TransportOptions>{
         pipeline: TransportSingleOptions<TransportOptions>[]
+        level?: LevelWithSilentOrString
     }
 
     interface TransportMultiOptions<TransportOptions = Record<string, any>> extends TransportBaseOptions<TransportOptions>{
-        targets: readonly TransportTargetOptions<TransportOptions>[],
+        targets: readonly (TransportTargetOptions<TransportOptions>|TransportPipelineOptions<TransportOptions>)[],
         levels?: Record<string, number>
         dedupe?: boolean
     }
@@ -427,6 +428,17 @@ declare namespace pino {
              * pino.info('hi') // creates and logs {msg: 'hi', level: 30, time: <ts>}
              */
             asObject?: boolean;
+            formatters?: {
+                /**
+                 * Changes the shape of the log level.
+                 * The default shape is { level: number }.
+                 */
+                level?: (label: string, number: number) => object;
+                /**
+                 * Changes the shape of the log object.
+                 */
+                log?: (object: Record<string, unknown>) => Record<string, unknown>;
+            }
             /**
              * Instead of passing log messages to `console.log` they can be passed to a supplied function. If `write` is
              * set to a single function, all logging objects are passed to this function. If `write` is an object, it


Test patch:
diff --git a/test/browser-serializers.test.js b/test/browser-serializers.test.js
index 77d6dee..07cfa60 100644
--- a/test/browser-serializers.test.js
+++ b/test/browser-serializers.test.js
@@ -284,7 +284,6 @@ test('children inherit parent serializers', ({ end, is }) => {
 
 test('children serializers get called', ({ end, is }) => {
   const parent = pino({
-    test: 'this',
     browser: {
       serialize: true,
       write (o) {
@@ -301,7 +300,6 @@ test('children serializers get called', ({ end, is }) => {
 
 test('children serializers get called when inherited from parent', ({ end, is }) => {
   const parent = pino({
-    test: 'this',
     serializers: parentSerializers,
     browser: {
       serialize: true,
diff --git a/test/browser.test.js b/test/browser.test.js
index c296411..ab0b045 100644
--- a/test/browser.test.js
+++ b/test/browser.test.js
@@ -166,7 +166,7 @@ test('opts.browser.asObject logs pino-like object to console', ({ end, ok, is })
   end()
 })
 
-test('opts.browser.formatters logs pino-like object to console', ({ end, ok, is }) => {
+test('opts.browser.formatters (level) logs pino-like object to console', ({ end, ok, is }) => {
   const info = console.info
   console.info = function (o) {
     is(o.level, 30)
@@ -189,7 +189,7 @@ test('opts.browser.formatters logs pino-like object to console', ({ end, ok, is
   end()
 })
 
-test('opts.browser.formatters logs pino-like object to console', ({ end, ok, is }) => {
+test('opts.browser.formatters (log) logs pino-like object to console', ({ end, ok, is }) => {
   const info = console.info
   console.info = function (o) {
     is(o.level, 30)
@@ -213,40 +213,66 @@ test('opts.browser.formatters logs pino-like object to console', ({ end, ok, is
   end()
 })
 
-test('opts.browser.write func log single string', ({ end, ok, is }) => {
-  const instance = pino({
+test('opts.browser.serialize and opts.browser.transmit only serializes log data once', ({ end, ok, is }) => {
+  const instance = require('../browser')({
+    serializers: {
+      extras (data) {
+        return { serializedExtras: data }
+      }
+    },
     browser: {
+      serialize: ['extras'],
+      transmit: {
+        level: 'info',
+        send (level, o) {
+          is(o.messages[0].extras.serializedExtras, 'world')
+        }
+      }
+    }
+  })
+
+  instance.info({ extras: 'world' }, 'test')
+  end()
+})
+
+test('opts.browser.serialize and opts.asObject only serializes log data once', ({ end, ok, is }) => {
+  const instance = require('../browser')({
+    serializers: {
+      extras (data) {
+        return { serializedExtras: data }
+      }
+    },
+    browser: {
+      serialize: ['extras'],
+      asObject: true,
       write: function (o) {
-        is(o.level, 30)
-        is(o.msg, 'test')
-        ok(o.time)
+        is(o.extras.serializedExtras, 'world')
       }
     }
   })
-  instance.info('test')
 
+  instance.info({ extras: 'world' }, 'test')
   end()
 })
 
-test('opts.browser.write func string joining', ({ end, ok, is }) => {
+test('opts.browser.write func log single string', ({ end, ok, is }) => {
   const instance = pino({
     browser: {
       write: function (o) {
         is(o.level, 30)
-        is(o.msg, 'test test2 test3')
+        is(o.msg, 'test')
         ok(o.time)
       }
     }
   })
-  instance.info('test %s %s', 'test2', 'test3')
+  instance.info('test')
 
   end()
 })
 
-test('opts.browser.write func string joining when asObject is true', ({ end, ok, is }) => {
+test('opts.browser.write func string joining', ({ end, ok, is }) => {
   const instance = pino({
     browser: {
-      asObject: true,
       write: function (o) {
         is(o.level, 30)
         is(o.msg, 'test test2 test3')
diff --git a/test/pkg/index.js b/test/pkg/index.js
new file mode 100644
index 0000000..762a62f
--- /dev/null
+++ b/test/pkg/index.js
@@ -0,0 +1,46 @@
+'use strict'
+
+const os = require('os')
+const { join } = require('path')
+const { readFile } = require('fs').promises
+const { watchFileCreated, file } = require('../helper')
+const { test } = require('tap')
+const pino = require('../../pino')
+
+const { pid } = process
+const hostname = os.hostname()
+
+/**
+ * This file is packaged using pkg in order to test if transport-stream.js works in that context
+ */
+
+test('pino.transport with worker destination overridden by bundler and mjs transport', async ({ same, teardown }) => {
+  globalThis.__bundlerPathsOverrides = {
+    'pino-worker': join(__dirname, '..', '..', 'lib/worker.js')
+  }
+
+  const destination = file()
+  const transport = pino.transport({
+    targets: [
+      {
+        target: join(__dirname, '..', 'fixtures', 'ts', 'to-file-transport.es2017.cjs'),
+        options: { destination }
+      }
+    ]
+  })
+
+  teardown(transport.end.bind(transport))
+  const instance = pino(transport)
+  instance.info('hello')
+  await watchFileCreated(destination)
+  const result = JSON.parse(await readFile(destination))
+  delete result.time
+  same(result, {
+    pid,
+    hostname,
+    level: 30,
+    msg: 'hello'
+  })
+
+  globalThis.__bundlerPathsOverrides = undefined
+})
diff --git a/test/pkg/pkg.config.json b/test/pkg/pkg.config.json
new file mode 100644
index 0000000..a7d2b99
--- /dev/null
+++ b/test/pkg/pkg.config.json
@@ -0,0 +1,17 @@
+{
+    "pkg": {
+        "assets": [
+            "../../lib/worker.js",
+            "../../lib/transport-stream.js",
+            "../../test/fixtures/ts/to-file-transport.es2017.cjs",
+            "../../node_modules/pino-abstract-transport/index.js"
+        ],
+        "targets": [
+            "node14",
+            "node16",
+            "node18",
+            "node20"
+        ],
+        "outputPath": "test/pkg"
+    }
+}
\ No newline at end of file
diff --git a/test/pkg/pkg.test.js b/test/pkg/pkg.test.js
new file mode 100644
index 0000000..1def2f8
--- /dev/null
+++ b/test/pkg/pkg.test.js
@@ -0,0 +1,56 @@
+'use strict'
+
+const { test } = require('tap')
+const config = require('./pkg.config.json')
+const { promisify } = require('util')
+const { unlink } = require('fs/promises')
+const { join } = require('path')
+const { platform } = require('process')
+const exec = promisify(require('child_process').exec)
+
+/**
+ * The following regex is for tesintg the deprecation warning that is thrown by the `punycode` module.
+ * Exact text that it's matching is:
+ * (node:1234) [DEP0040] DeprecationWarning: The `punycode` module is deprecated.
+    Please use a userland alternative instead.
+
+    (Use `node --trace-deprecation ...` to show where the warning was created)
+ */
+const deprecationWarningRegex = /^\(\w+:\d+\)\s\[[\w|\d]+\]\sDeprecationWarning: The `punycode` module is deprecated\.\s+Please use a userland alternative instead\.\s+\(Use `node --trace-deprecation \.\.\.` to show where the warning was created\)\s+$/
+
+test('worker test when packaged into executable using pkg', async (t) => {
+  const packageName = 'index'
+
+  // package the app into several node versions, check config for more info
+  const filePath = `${join(__dirname, packageName)}.js`
+  const configPath = join(__dirname, 'pkg.config.json')
+  const { stderr } = await exec(`npx pkg ${filePath} --config ${configPath}`)
+
+  // there should be no error when packaging
+  const expectedvalue = stderr === '' || deprecationWarningRegex.test(stderr)
+  t.ok(expectedvalue)
+
+  // pkg outputs files in the following format by default: {filename}-{node version}
+  for (const target of config.pkg.targets) {
+    // execute the packaged test
+    let executablePath = `${join(config.pkg.outputPath, packageName)}-${target}`
+
+    // when on windows, we need the .exe extension
+    if (platform === 'win32') {
+      executablePath = `${executablePath}.exe`
+    } else {
+      executablePath = `./${executablePath}`
+    }
+
+    const { stderr } = await exec(executablePath)
+
+    // check if there were no errors
+    const expectedvalue = stderr === '' || deprecationWarningRegex.test(stderr)
+    t.ok(expectedvalue)
+
+    // clean up afterwards
+    await unlink(executablePath)
+  }
+
+  t.end()
+})
diff --git a/test/transport/bundlers-support.test.js b/test/transport/bundlers-support.test.js
index efe8a5e..cb10221 100644
--- a/test/transport/bundlers-support.test.js
+++ b/test/transport/bundlers-support.test.js
@@ -95,33 +95,3 @@ test('pino.transport with worker destination overridden by bundler and mjs trans
 
   globalThis.__bundlerPathsOverrides = undefined
 })
-
-test('pino.transport with worker-pipeline destination overridden by bundler', async ({ same, teardown }) => {
-  globalThis.__bundlerPathsOverrides = {
-    'pino-pipeline-worker': join(__dirname, '..', '..', 'lib/worker-pipeline.js')
-  }
-
-  const destination = file()
-  const transport = pino.transport({
-    pipeline: [
-      {
-        target: join(__dirname, '..', 'fixtures', 'to-file-transport.js'),
-        options: { destination }
-      }
-    ]
-  })
-  teardown(transport.end.bind(transport))
-  const instance = pino(transport)
-  instance.info('hello')
-  await watchFileCreated(destination)
-  const result = JSON.parse(await readFile(destination))
-  delete result.time
-  same(result, {
-    pid,
-    hostname,
-    level: 30,
-    msg: 'hello'
-  })
-
-  globalThis.__bundlerPathsOverrides = undefined
-})
diff --git a/test/transport/core.test.js b/test/transport/core.test.js
index ee5ef10..b8af673 100644
--- a/test/transport/core.test.js
+++ b/test/transport/core.test.js
@@ -134,7 +134,7 @@ test('pino.transport with two files', async ({ same, teardown }) => {
   const transport = pino.transport({
     targets: [{
       level: 'info',
-      target: join(__dirname, '..', 'fixtures', 'to-file-transport.js'),
+      target: 'file://' + join(__dirname, '..', 'fixtures', 'to-file-transport.js'),
       options: { destination: dest1 }
     }, {
       level: 'info',
diff --git a/test/transport/pipeline.test.js b/test/transport/pipeline.test.js
index 25b244e..a845d44 100644
--- a/test/transport/pipeline.test.js
+++ b/test/transport/pipeline.test.js
@@ -6,6 +6,7 @@ const { readFile } = require('fs').promises
 const { watchFileCreated, file } = require('../helper')
 const { test } = require('tap')
 const pino = require('../../')
+const { DEFAULT_LEVELS } = require('../../lib/constants')
 
 const { pid } = process
 const hostname = os.hostname()
@@ -29,8 +30,106 @@ test('pino.transport with a pipeline', async ({ same, teardown }) => {
   same(result, {
     pid,
     hostname,
-    level: 30,
+    level: DEFAULT_LEVELS.info,
     msg: 'hello',
     service: 'pino' // this property was added by the transform
   })
 })
+
+test('pino.transport with targets containing pipelines', async ({ same, teardown }) => {
+  const destinationA = file()
+  const destinationB = file()
+  const transport = pino.transport({
+    targets: [
+      {
+        target: join(__dirname, '..', 'fixtures', 'to-file-transport.js'),
+        options: { destination: destinationA }
+      },
+      {
+        pipeline: [
+          {
+            target: join(__dirname, '..', 'fixtures', 'transport-transform.js')
+          },
+          {
+            target: join(__dirname, '..', 'fixtures', 'to-file-transport.js'),
+            options: { destination: destinationB }
+          }
+        ]
+      }
+    ]
+  })
+
+  teardown(transport.end.bind(transport))
+  const instance = pino(transport)
+  instance.info('hello')
+  await watchFileCreated(destinationA)
+  await watchFileCreated(destinationB)
+  const resultA = JSON.parse(await readFile(destinationA))
+  const resultB = JSON.parse(await readFile(destinationB))
+  delete resultA.time
+  delete resultB.time
+  same(resultA, {
+    pid,
+    hostname,
+    level: DEFAULT_LEVELS.info,
+    msg: 'hello'
+  })
+  same(resultB, {
+    pid,
+    hostname,
+    level: DEFAULT_LEVELS.info,
+    msg: 'hello',
+    service: 'pino' // this property was added by the transform
+  })
+})
+
+test('pino.transport with targets containing pipelines with levels defined and dedupe', async ({ same, teardown }) => {
+  const destinationA = file()
+  const destinationB = file()
+  const transport = pino.transport({
+    targets: [
+      {
+        target: join(__dirname, '..', 'fixtures', 'to-file-transport.js'),
+        options: { destination: destinationA },
+        level: DEFAULT_LEVELS.info
+      },
+      {
+        pipeline: [
+          {
+            target: join(__dirname, '..', 'fixtures', 'transport-transform.js')
+          },
+          {
+            target: join(__dirname, '..', 'fixtures', 'to-file-transport.js'),
+            options: { destination: destinationB }
+          }
+        ],
+        level: DEFAULT_LEVELS.error
+      }
+    ],
+    dedupe: true
+  })
+
+  teardown(transport.end.bind(transport))
+  const instance = pino(transport)
+  instance.info('hello info')
+  instance.error('hello error')
+  await watchFileCreated(destinationA)
+  await watchFileCreated(destinationB)
+  const resultA = JSON.parse(await readFile(destinationA))
+  const resultB = JSON.parse(await readFile(destinationB))
+  delete resultA.time
+  delete resultB.time
+  same(resultA, {
+    pid,
+    hostname,
+    level: DEFAULT_LEVELS.info,
+    msg: 'hello info'
+  })
+  same(resultB, {
+    pid,
+    hostname,
+    level: DEFAULT_LEVELS.error,
+    msg: 'hello error',
+    service: 'pino' // this property was added by the transform
+  })
+})
diff --git a/test/types/pino-type-only.test-d.ts b/test/types/pino-type-only.test-d.ts
index 4f1053f..eb6221d 100644
--- a/test/types/pino-type-only.test-d.ts
+++ b/test/types/pino-type-only.test-d.ts
@@ -1,7 +1,7 @@
 import { expectAssignable, expectType, expectNotAssignable } from "tsd";
 
 import pino from "../../";
-import type {LevelWithSilent, Logger, LogFn, P, DestinationStreamWithMetadata,  Level, LevelOrString, LevelWithSilentOrString, LoggerExtras } from "../../pino";
+import type {LevelWithSilent, Logger, LogFn, P, DestinationStreamWithMetadata,  Level, LevelOrString, LevelWithSilentOrString, LoggerExtras, LoggerOptions } from "../../pino";
 
 // NB: can also use `import * as pino`, but that form is callable as `pino()`
 // under `esModuleInterop: false` or `pino.default()` under `esModuleInterop: true`.
@@ -46,3 +46,19 @@ const needsMetadata: typeof pino.symbols.needsMetadataGsym = pino.symbols.needsM
 if (stream[needsMetadata]) {
     expectType<number>(stream.lastLevel);
 }
+
+const loggerOptions:LoggerOptions = {
+    browser: {
+        formatters: {
+            log(obj) {
+                return obj
+            },
+            level(label, number) {
+                return { label, number}
+            }
+
+        }
+    }
+}
+
+expectType<LoggerOptions>(loggerOptions)