Преглед изворни кода

Implement bunyan-format output and encapsulate pino-http in @growi/logger; update related configurations and tests

Yuki Takei пре 5 дана
родитељ
комит
64b1ce11e8

+ 10 - 10
.kiro/specs/migrate-logger-to-pino/tasks.md

@@ -198,8 +198,8 @@
   - Verify the production build succeeds
   - _Requirements: 5.1, 5.3, 6.1, 6.4_
 
-- [ ] 12. Implement bunyan-like output format (development only)
-- [ ] 12.1 Create the bunyan-format custom transport module
+- [x] 12. Implement bunyan-like output format (development only)
+- [x] 12.1 Create the bunyan-format custom transport module
   - Create `packages/logger/src/transports/bunyan-format.ts` that default-exports a function returning a pino-pretty stream
   - Use `customPrettifiers.time` to format epoch as `HH:mm:ss.SSSZ` (UTC time-only, no brackets)
   - Use `customPrettifiers.level` to return `${label.padStart(5)} ${log.name}` (right-aligned 5-char level + namespace)
@@ -208,7 +208,7 @@
   - Verify the module is built to `dist/transports/bunyan-format.js` by vite's `preserveModules` config
   - _Requirements: 12.1, 12.2, 12.3, 12.4, 12.5, 12.6_
 
-- [ ] 12.2 Update TransportFactory to use bunyan-format transport in dev only
+- [x] 12.2 Update TransportFactory to use bunyan-format transport in dev only
   - In the **development** branch of `createNodeTransportOptions`, change the transport target from `'pino-pretty'` to the resolved path of `bunyan-format.js` (via `import.meta.url`)
   - Remove `translateTime` and `ignore` options from the dev transport config (now handled inside the custom transport)
   - Pass `singleLine: false` for dev
@@ -217,14 +217,14 @@
   - Update unit tests in `transport-factory.spec.ts`: dev target contains `bunyan-format`; prod + FORMAT_NODE_LOG target is `'pino-pretty'`
   - _Requirements: 12.1, 12.6, 12.7, 12.8_
 
-- [ ] 12.3 Verify bunyan-format output
+- [x] 12.3 Verify bunyan-format output
   - Run the dev server and confirm log output matches the bunyan-format "short" style: `HH:mm:ss.SSSZ LEVEL name: message`
   - Confirm colorization works (DEBUG=cyan, INFO=green, WARN=yellow, ERROR=red)
   - Confirm multi-line output in dev (extra fields on subsequent lines)
   - _Requirements: 12.1, 12.2, 12.3, 12.4, 12.5_
 
-- [ ] 13. Encapsulate pino-http in @growi/logger
-- [ ] 13.1 Create HTTP logger middleware factory in @growi/logger
+- [x] 13. Encapsulate pino-http in @growi/logger
+- [x] 13.1 Create HTTP logger middleware factory in @growi/logger
   - Create `packages/logger/src/http-logger.ts` exporting `async createHttpLoggerMiddleware(options?)`
   - The function creates `pinoHttp` middleware internally with `loggerFactory(namespace)`
   - In development mode (`NODE_ENV !== 'production'`): dynamically import `morganLikeFormatOptions` via `await import('./morgan-like-format-options')` and apply to pino-http options
@@ -235,7 +235,7 @@
   - Export `createHttpLoggerMiddleware` from `packages/logger/src/index.ts`
   - _Requirements: 13.1, 13.2, 13.3, 13.5, 13.6_
 
-- [ ] 13.2 (P) Migrate apps/app to use createHttpLoggerMiddleware
+- [x] 13.2 (P) Migrate apps/app to use createHttpLoggerMiddleware
   - Replace the direct `pinoHttp` import and configuration in `apps/app/src/server/crowi/index.ts` with `await createHttpLoggerMiddleware(...)` from `@growi/logger`
   - Pass the `/_next/static/` autoLogging ignore function via the options
   - Remove `pino-http` and its type imports from the file
@@ -244,7 +244,7 @@
   - Run `pnpm --filter @growi/app lint:typecheck` to confirm no type errors
   - _Requirements: 13.4_
 
-- [ ] 13.3 (P) Migrate apps/slackbot-proxy to use createHttpLoggerMiddleware
+- [x] 13.3 (P) Migrate apps/slackbot-proxy to use createHttpLoggerMiddleware
   - Replace the direct `pinoHttp` import and configuration in `apps/slackbot-proxy/src/Server.ts` with `await createHttpLoggerMiddleware(...)` from `@growi/logger`
   - Remove `pino-http` and its type imports from the file
   - Remove `morganLikeFormatOptions` import (now applied internally in dev only)
@@ -253,8 +253,8 @@
   - Run `pnpm --filter @growi/slackbot-proxy lint:typecheck` to confirm no type errors
   - _Requirements: 13.4_
 
-- [ ] 14. Validate bunyan-format and HTTP encapsulation
-- [ ] 14.1 Run full validation
+- [x] 14. Validate bunyan-format and HTTP encapsulation
+- [x] 14.1 Run full validation
   - Run `@growi/logger` package tests
   - Run lint and type-check for apps/app and apps/slackbot-proxy
   - Run `turbo run build --filter @growi/app` to verify production build succeeds

+ 0 - 1
apps/app/package.json

@@ -213,7 +213,6 @@
     "passport-local": "^1.0.0",
     "passport-saml": "^3.2.0",
     "pathe": "^2.0.3",
-    "pino-http": "^11.0.0",
     "pretty-bytes": "^6.1.1",
     "prop-types": "^15.8.1",
     "qs": "^6.14.2",

+ 6 - 9
apps/app/src/server/crowi/index.ts

@@ -2,12 +2,11 @@ import next from 'next';
 import http from 'node:http';
 import path from 'node:path';
 import { createTerminus } from '@godaddy/terminus';
-import { morganLikeFormatOptions } from '@growi/logger';
+import { createHttpLoggerMiddleware } from '@growi/logger';
 import attachmentRoutes from '@growi/remark-attachment-refs/dist/server';
 import lsxRoutes from '@growi/remark-lsx/dist/server/index.cjs';
 import type { Express } from 'express';
 import mongoose from 'mongoose';
-import pinoHttp, { type Options as PinoHttpOptions } from 'pino-http';
 
 import instantiateAuditLogBulkExportJobCleanUpCronService from '~/features/audit-log-bulk-export/server/service/audit-log-bulk-export-job-clean-up-cron';
 import instantiateAuditLogBulkExportJobCronService from '~/features/audit-log-bulk-export/server/service/audit-log-bulk-export-job-cron';
@@ -632,18 +631,16 @@ class Crowi {
 
     require('./express-init')(this, express);
 
-    // HTTP request logging with pino-http (morgan-like one-liner format)
-    const httpLoggerOptions: PinoHttpOptions = {
-      logger: loggerFactory('express'),
-      ...morganLikeFormatOptions,
-      // supress logging for Next.js static files in development mode
+    // HTTP request logging via @growi/logger (encapsulates pino-http)
+    const httpLogger = await createHttpLoggerMiddleware({
+      // suppress logging for Next.js static files in development mode
       ...(env !== 'production' && {
         autoLogging: {
           ignore: (req) => req.url?.startsWith('/_next/static/') ?? false,
         },
       }),
-    };
-    express.use(pinoHttp(httpLoggerOptions));
+    });
+    express.use(httpLogger);
 
     this.express = express;
   }

+ 0 - 1
apps/slackbot-proxy/package.json

@@ -60,7 +60,6 @@
     "method-override": "^3.0.0",
     "mysql2": "^2.2.5",
     "read-pkg-up": "^7.0.1",
-    "pino-http": "^11.0.0",
     "tslib": "^2.8.0",
     "typeorm": "=0.2.45"
   },

+ 5 - 10
apps/slackbot-proxy/src/Server.ts

@@ -3,7 +3,7 @@ import '@tsed/swagger';
 import '@tsed/typeorm'; // !! DO NOT MODIFY !! -- https://github.com/tsedio/tsed/issues/1332#issuecomment-837840612
 
 import { createTerminus } from '@godaddy/terminus';
-import { morganLikeFormatOptions } from '@growi/logger';
+import { createHttpLoggerMiddleware } from '@growi/logger';
 import { HttpServer, PlatformApplication } from '@tsed/common';
 import { Configuration, Inject, InjectorService } from '@tsed/di';
 import bodyParser from 'body-parser';
@@ -12,7 +12,6 @@ import cookieParser from 'cookie-parser';
 import type { Express } from 'express';
 import helmet from 'helmet';
 import methodOverride from 'method-override';
-import pinoHttp, { type Options as PinoHttpOptions } from 'pino-http';
 import type { ConnectionOptions } from 'typeorm';
 import { getConnectionManager } from 'typeorm';
 
@@ -123,7 +122,7 @@ export class Server {
     }
   }
 
-  $beforeRoutesInit(): void {
+  async $beforeRoutesInit(): Promise<void> {
     this.app
       .use(cookieParser())
       .use(compress({}))
@@ -135,7 +134,7 @@ export class Server {
         }),
       );
 
-    this.setupLogger();
+    await this.setupLogger();
   }
 
   $afterRoutesInit(): void {
@@ -162,12 +161,8 @@ export class Server {
   /**
    * Setup logger for requests
    */
-  private setupLogger(): void {
-    const httpLogger = pinoHttp({
-      // Type assertion needed: @growi/logger returns Logger<string> but pino-http expects Logger<LevelWithSilent>
-      logger: loggerFactory('express') as unknown as PinoHttpOptions['logger'],
-      ...morganLikeFormatOptions,
-    });
+  private async setupLogger(): Promise<void> {
+    const httpLogger = await createHttpLoggerMiddleware();
     this.app.use(httpLogger);
   }
 }

+ 2 - 1
packages/logger/package.json

@@ -26,7 +26,8 @@
   },
   "dependencies": {
     "minimatch": "^9.0.0",
-    "pino": "^9.0.0"
+    "pino": "^9.0.0",
+    "pino-http": "^11.0.0"
   },
   "peerDependencies": {
     "pino-pretty": "^13.0.0"

+ 98 - 0
packages/logger/src/http-logger.spec.ts

@@ -0,0 +1,98 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+// Mock pino-http before importing
+vi.mock('pino-http', () => {
+  const pinoHttp = vi.fn((_opts: unknown) => {
+    return (_req: unknown, _res: unknown, next: () => void) => next();
+  });
+  return { default: pinoHttp };
+});
+
+// Mock logger-factory
+vi.mock('./logger-factory', () => ({
+  loggerFactory: vi.fn(() => ({
+    level: 'info',
+    info: vi.fn(),
+    debug: vi.fn(),
+    warn: vi.fn(),
+    error: vi.fn(),
+    trace: vi.fn(),
+    fatal: vi.fn(),
+  })),
+}));
+
+describe('createHttpLoggerMiddleware', () => {
+  const originalEnv = process.env;
+
+  beforeEach(() => {
+    process.env = { ...originalEnv };
+    vi.resetModules();
+  });
+
+  afterEach(() => {
+    process.env = originalEnv;
+  });
+
+  it('returns an Express-compatible middleware function', async () => {
+    const { createHttpLoggerMiddleware } = await import('./http-logger');
+    const middleware = await createHttpLoggerMiddleware();
+    expect(typeof middleware).toBe('function');
+  });
+
+  it('uses "express" as the default namespace', async () => {
+    const { loggerFactory } = await import('./logger-factory');
+    const { createHttpLoggerMiddleware } = await import('./http-logger');
+    await createHttpLoggerMiddleware();
+    expect(loggerFactory).toHaveBeenCalledWith('express');
+  });
+
+  it('accepts a custom namespace', async () => {
+    const { loggerFactory } = await import('./logger-factory');
+    const { createHttpLoggerMiddleware } = await import('./http-logger');
+    await createHttpLoggerMiddleware({ namespace: 'custom-http' });
+    expect(loggerFactory).toHaveBeenCalledWith('custom-http');
+  });
+
+  it('passes autoLogging options to pino-http', async () => {
+    const pinoHttp = (await import('pino-http')).default;
+    const { createHttpLoggerMiddleware } = await import('./http-logger');
+
+    const ignoreFn = (req: { url?: string }) =>
+      req.url?.startsWith('/_next/') ?? false;
+    await createHttpLoggerMiddleware({ autoLogging: { ignore: ignoreFn } });
+
+    expect(pinoHttp).toHaveBeenCalledWith(
+      expect.objectContaining({
+        autoLogging: { ignore: ignoreFn },
+      }),
+    );
+  });
+
+  it('applies morganLikeFormatOptions in development mode', async () => {
+    process.env.NODE_ENV = 'development';
+    const pinoHttp = (await import('pino-http')).default;
+    const { createHttpLoggerMiddleware } = await import('./http-logger');
+    await createHttpLoggerMiddleware();
+
+    expect(pinoHttp).toHaveBeenCalledWith(
+      expect.objectContaining({
+        customSuccessMessage: expect.any(Function),
+        customErrorMessage: expect.any(Function),
+        customLogLevel: expect.any(Function),
+      }),
+    );
+  });
+
+  it('does not apply morganLikeFormatOptions in production mode', async () => {
+    process.env.NODE_ENV = 'production';
+    const pinoHttp = (await import('pino-http')).default;
+    const { createHttpLoggerMiddleware } = await import('./http-logger');
+    await createHttpLoggerMiddleware();
+
+    const callArgs = (pinoHttp as ReturnType<typeof vi.fn>).mock
+      .calls[0][0] as Record<string, unknown>;
+    expect(callArgs.customSuccessMessage).toBeUndefined();
+    expect(callArgs.customErrorMessage).toBeUndefined();
+    expect(callArgs.customLogLevel).toBeUndefined();
+  });
+});

+ 49 - 0
packages/logger/src/http-logger.ts

@@ -0,0 +1,49 @@
+import type { IncomingMessage, ServerResponse } from 'node:http';
+import pinoHttp, {
+  type HttpLogger,
+  type Options as PinoHttpOptions,
+} from 'pino-http';
+
+import { loggerFactory } from './logger-factory';
+
+interface HttpLoggerOptions {
+  /** Logger namespace, defaults to 'express' */
+  namespace?: string;
+  /** Auto-logging configuration (e.g., route ignore patterns) */
+  autoLogging?: {
+    ignore: (req: { url?: string }) => boolean;
+  };
+}
+
+/**
+ * Create Express middleware for HTTP request logging.
+ * In dev: uses pino-http with morgan-like formatting (dynamically imported).
+ * In prod: uses pino-http with default formatting.
+ *
+ * The pino-http dependency is encapsulated here — consumer apps
+ * should not import pino-http directly.
+ */
+export async function createHttpLoggerMiddleware(
+  options?: HttpLoggerOptions,
+): Promise<HttpLogger<IncomingMessage, ServerResponse>> {
+  const namespace = options?.namespace ?? 'express';
+  const logger = loggerFactory(namespace);
+
+  const httpOptions: PinoHttpOptions = {
+    // Logger<string> → pino-http's expected Logger type
+    logger: logger as unknown as PinoHttpOptions['logger'],
+    ...(options?.autoLogging != null
+      ? { autoLogging: options.autoLogging }
+      : {}),
+  };
+
+  // In development, dynamically import morgan-like format options
+  if (process.env.NODE_ENV !== 'production') {
+    const { morganLikeFormatOptions } = await import(
+      './morgan-like-format-options'
+    );
+    Object.assign(httpOptions, morganLikeFormatOptions);
+  }
+
+  return pinoHttp(httpOptions);
+}

+ 1 - 0
packages/logger/src/index.ts

@@ -1,4 +1,5 @@
 export { parseEnvLevels } from './env-var-parser';
+export { createHttpLoggerMiddleware } from './http-logger';
 export { resolveLevel } from './level-resolver';
 export { initializeLoggerFactory, loggerFactory } from './logger-factory';
 export { morganLikeFormatOptions } from './morgan-like-format-options';

+ 2 - 10
packages/logger/src/transport-factory.spec.ts

@@ -15,18 +15,10 @@ describe('createNodeTransportOptions', () => {
   });
 
   describe('development mode', () => {
-    it('returns pino-pretty transport config', () => {
+    it('returns bunyan-format transport config', () => {
       const opts = createNodeTransportOptions(false);
       expect(opts.transport).toBeDefined();
-      expect(opts.transport?.target).toBe('pino-pretty');
-    });
-
-    it('includes translateTime, ignore, and singleLine options', () => {
-      const opts = createNodeTransportOptions(false);
-      const popts = opts.transport?.options as Record<string, unknown>;
-      expect(popts?.translateTime).toBeTruthy();
-      expect(popts?.ignore).toContain('pid');
-      expect(popts?.ignore).toContain('hostname');
+      expect(opts.transport?.target).toContain('bunyan-format');
     });
 
     it('returns singleLine: false for full multi-line context', () => {

+ 13 - 6
packages/logger/src/transport-factory.ts

@@ -1,3 +1,5 @@
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
 import type { LoggerOptions, TransportSingleOptions } from 'pino';
 
 interface NodeTransportOptions {
@@ -17,20 +19,25 @@ function isFormattedOutputEnabled(): boolean {
 
 /**
  * Create pino transport/options for Node.js environment.
- * Development: pino-pretty with human-readable output.
- * Production: raw JSON by default; pino-pretty when FORMAT_NODE_LOG is truthy.
+ * Development: bunyan-format custom transport with human-readable output.
+ * Production: raw JSON by default; standard pino-pretty when FORMAT_NODE_LOG is truthy.
  */
 export function createNodeTransportOptions(
   isProduction: boolean,
 ): NodeTransportOptions {
   if (!isProduction) {
-    // Development: always use pino-pretty
+    // Development: use bunyan-format custom transport (dev only)
+    // Use path.join to resolve sibling module — avoids Vite's `new URL(…, import.meta.url)` asset transform
+    const thisDir = path.dirname(fileURLToPath(import.meta.url));
+    const bunyanFormatPath = path.join(
+      thisDir,
+      'transports',
+      'bunyan-format.js',
+    );
     return {
       transport: {
-        target: 'pino-pretty',
+        target: bunyanFormatPath,
         options: {
-          translateTime: 'SYS:standard',
-          ignore: 'pid,hostname',
           singleLine: false,
         },
       },

+ 129 - 0
packages/logger/src/transports/bunyan-format.spec.ts

@@ -0,0 +1,129 @@
+import { PassThrough, Writable } from 'node:stream';
+import { describe, expect, it } from 'vitest';
+
+import bunyanFormat from './bunyan-format';
+
+function createWithCapture(opts: { singleLine?: boolean } = {}) {
+  const dest = new PassThrough();
+  const chunks: string[] = [];
+  dest.on('data', (chunk: Buffer) => chunks.push(chunk.toString()));
+  const stream = bunyanFormat({ ...opts, colorize: false, destination: dest });
+  return { stream, chunks };
+}
+
+function writeLine(
+  stream: NodeJS.WritableStream,
+  log: Record<string, unknown>,
+) {
+  stream.write(`${JSON.stringify(log)}\n`);
+}
+
+describe('bunyan-format transport', () => {
+  it('returns a writable stream', () => {
+    const { stream } = createWithCapture();
+    expect(stream).toBeDefined();
+    expect(stream).toBeInstanceOf(Writable);
+  });
+
+  it('formats log output as HH:mm:ss.SSSZ LEVEL name: message', async () => {
+    const { stream, chunks } = createWithCapture({ singleLine: true });
+
+    writeLine(stream, {
+      level: 20,
+      time: new Date('2026-03-30T10:06:30.419Z').getTime(),
+      name: 'growi:service:page',
+      msg: 'some message',
+    });
+
+    await new Promise((resolve) => setTimeout(resolve, 50));
+
+    const output = chunks.join('');
+    expect(output).toBe(
+      '10:06:30.419Z DEBUG growi:service:page: some message\n',
+    );
+  });
+
+  it('right-aligns level labels to 5 characters', async () => {
+    const { stream, chunks } = createWithCapture({ singleLine: true });
+
+    writeLine(stream, {
+      level: 30,
+      time: Date.now(),
+      name: 'test',
+      msg: 'info',
+    });
+    writeLine(stream, {
+      level: 40,
+      time: Date.now(),
+      name: 'test',
+      msg: 'warn',
+    });
+    writeLine(stream, {
+      level: 10,
+      time: Date.now(),
+      name: 'test',
+      msg: 'trace',
+    });
+
+    await new Promise((resolve) => setTimeout(resolve, 50));
+
+    const output = chunks.join('');
+    expect(output).toContain(' INFO test:');
+    expect(output).toContain(' WARN test:');
+    expect(output).toContain('TRACE test:');
+  });
+
+  it('appends extra fields on a new line when singleLine is false', async () => {
+    const { stream, chunks } = createWithCapture({ singleLine: false });
+
+    writeLine(stream, {
+      level: 20,
+      time: Date.now(),
+      name: 'test',
+      msg: 'hello',
+      extra: 'value',
+    });
+
+    await new Promise((resolve) => setTimeout(resolve, 50));
+
+    const output = chunks.join('');
+    expect(output).toContain('hello');
+    expect(output).toContain('\n    {"extra":"value"}');
+  });
+
+  it('appends extra fields inline when singleLine is true', async () => {
+    const { stream, chunks } = createWithCapture({ singleLine: true });
+
+    writeLine(stream, {
+      level: 30,
+      time: Date.now(),
+      name: 'test',
+      msg: 'hello',
+      extra: 'value',
+    });
+
+    await new Promise((resolve) => setTimeout(resolve, 50));
+
+    const output = chunks.join('');
+    expect(output).toContain('hello {"extra":"value"}');
+  });
+
+  it('excludes pid and hostname from extra fields', async () => {
+    const { stream, chunks } = createWithCapture({ singleLine: true });
+
+    writeLine(stream, {
+      level: 30,
+      time: Date.now(),
+      name: 'test',
+      msg: 'hello',
+      pid: 12345,
+      hostname: 'myhost',
+    });
+
+    await new Promise((resolve) => setTimeout(resolve, 50));
+
+    const output = chunks.join('');
+    expect(output).not.toContain('pid');
+    expect(output).not.toContain('hostname');
+  });
+});

+ 108 - 0
packages/logger/src/transports/bunyan-format.ts

@@ -0,0 +1,108 @@
+import { Writable } from 'node:stream';
+
+interface BunyanFormatOptions {
+  singleLine?: boolean;
+  colorize?: boolean;
+  destination?: NodeJS.WritableStream;
+}
+
+const LEVELS: Record<number, string> = {
+  10: 'TRACE',
+  20: 'DEBUG',
+  30: 'INFO',
+  40: 'WARN',
+  50: 'ERROR',
+  60: 'FATAL',
+};
+
+// ANSI color codes by level
+const COLORS: Record<number, string> = {
+  10: '\x1b[90m', // gray for TRACE
+  20: '\x1b[36m', // cyan for DEBUG
+  30: '\x1b[32m', // green for INFO
+  40: '\x1b[33m', // yellow for WARN
+  50: '\x1b[31m', // red for ERROR
+  60: '\x1b[31m', // red for FATAL
+};
+const RESET = '\x1b[0m';
+
+/**
+ * Format a log object into bunyan-format "short" style:
+ *   HH:mm:ss.SSSZ LEVEL name: message
+ */
+function formatLine(
+  log: Record<string, unknown>,
+  singleLine: boolean,
+  colorize: boolean,
+): string {
+  const time = new Date(log.time as number).toISOString().slice(11);
+  const level = log.level as number;
+  const label = (LEVELS[level] ?? 'INFO').padStart(5);
+  const name = (log.name as string) ?? '';
+  const msg = (log.msg as string) ?? '';
+
+  const color = colorize ? (COLORS[level] ?? '') : '';
+  const reset = colorize ? RESET : '';
+
+  let line = `${color}${time} ${label} ${name}:${reset} ${msg}`;
+
+  // Extra fields (exclude standard pino fields)
+  const extras: Record<string, unknown> = {};
+  for (const [key, val] of Object.entries(log)) {
+    if (
+      key !== 'level' &&
+      key !== 'time' &&
+      key !== 'msg' &&
+      key !== 'name' &&
+      key !== 'pid' &&
+      key !== 'hostname'
+    ) {
+      extras[key] = val;
+    }
+  }
+
+  if (Object.keys(extras).length > 0) {
+    const extraStr = JSON.stringify(extras);
+    if (singleLine) {
+      line += ` ${extraStr}`;
+    } else {
+      line += `\n    ${extraStr}`;
+    }
+  }
+
+  return `${line}\n`;
+}
+
+/**
+ * Custom pino transport producing bunyan-format "short" mode output.
+ * Format: HH:mm:ss.SSSZ LEVEL name: message
+ *
+ * Development only — this module is never imported in production.
+ * Uses fs.writeSync(1, ...) to write directly to stdout fd, bypassing
+ * thread-stream's stdout interception in Worker threads.
+ */
+// biome-ignore lint/style/noDefaultExport: pino transports require a default export for thread-stream Worker loading
+export default (opts: BunyanFormatOptions) => {
+  const singleLine = opts.singleLine ?? false;
+  const colorize = opts.colorize ?? !process.env.NO_COLOR;
+  const destination = opts.destination;
+
+  const out = destination ?? process.stdout;
+
+  return new Writable({
+    write(chunk, _encoding, callback) {
+      const text = typeof chunk === 'string' ? chunk : chunk.toString();
+      // thread-stream may batch multiple JSON lines into one chunk
+      for (const line of text.split('\n')) {
+        if (line.length === 0) continue;
+        try {
+          const log = JSON.parse(line);
+          out.write(formatLine(log, singleLine, colorize));
+        } catch {
+          out.write(`${line}\n`);
+        }
+      }
+      callback();
+    },
+  });
+};

+ 3 - 6
pnpm-lock.yaml

@@ -618,9 +618,6 @@ importers:
       pathe:
         specifier: ^2.0.3
         version: 2.0.3
-      pino-http:
-        specifier: ^11.0.0
-        version: 11.0.0
       pretty-bytes:
         specifier: ^6.1.1
         version: 6.1.1
@@ -1166,9 +1163,6 @@ importers:
       mysql2:
         specifier: ^2.2.5
         version: 2.3.3
-      pino-http:
-        specifier: ^11.0.0
-        version: 11.0.0
       read-pkg-up:
         specifier: ^7.0.1
         version: 7.0.1
@@ -1401,6 +1395,9 @@ importers:
       pino:
         specifier: ^9.0.0
         version: 9.14.0
+      pino-http:
+        specifier: ^11.0.0
+        version: 11.0.0
     devDependencies:
       pino-pretty:
         specifier: ^13.0.0