All files / src/logger/node file-sink.ts

90.32% Statements 28/31
93.54% Branches 29/31
80% Functions 8/10
92.3% Lines 24/26

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171                                                                                                                                                                                                              3x         50x               50x 1x 1x       49x 48x 1x 47x 47x         1x     49x 1x 5x 1x 1x     49x 50x             50x   126x 1x       125x           4x       33x        
import * as os from 'node:os';
import * as path from 'node:path';
 
import type { Simplify } from 'type-fest';
 
import { errorify } from '../../utils/converter/errorify.ts';
import { createFileWriter } from '../../utils/node/file-writer.ts';
import type { LogFormatter } from '../emitter/formatter.ts';
import { plainFormatter } from '../emitter/formatter.ts';
import type { LogSink } from '../emitter/sink.ts';
import type { AsyncFinalizer } from '../implementation/finalizer.ts';
 
/**
 * Configuration options for the file sink.
 */
export type FileSinkOptions = {
  /**
   * Whether to prepend a local date (yyyyMMdd-hhmmss_) to the file name.
   *
   * @default false
   */
  readonly datePrefix?: boolean;
 
  /**
   * Whether to overwrite an existing file on the first emitted entry or not (the default is to always append new log
   * entries).
   *
   * @default false
   */
  readonly overwrite?: boolean;
 
  /**
   * File encoding.
   *
   * @default 'utf8'
   */
  readonly encoding?: BufferEncoding;
 
  /**
   * File mode (permissions) for new files.
   *
   * @default 0o666
   */
  readonly mode?: number;
 
  /**
   * Error handler callback for file operations. If not provided, errors are ignored.
   */
  readonly errorHandler?: (error: unknown) => void;
};
 
export type FileSink = Simplify<
  AsyncFinalizer<LogSink> & {
    /**
     * The path to the log file.
     */
    readonly filePath: string;
  }
>;
 
/**
 * Creates a file log sink that writes logs directly to a file.
 *
 * This sink writes each log entry immediately without buffering. For better performance with high-volume logging, wrap
 * this with batchSink.
 *
 * Features:
 *
 * - Immediate writes (no buffering)
 * - Automatic directory creation
 * - Home directory expansion
 * - Graceful error handling
 *
 * Regarding the `filePath` argument:
 *
 * - Absolute paths are used as-is
 * - Relative paths are resolved from current working directory
 * - Paths starting with ~ are expanded to home directory
 * - Simple filenames without path separators are placed in OS temp directory
 *
 * @example Basic usage
 *
 * ```ts
 * import { emitter } from 'emitnlog/logger';
 * import { fileSink } from 'emitnlog/logger/node';
 *
 * const logger = emitter.createLogger('info', fileSink('/var/log/app.log'));
 * ```
 *
 * @example With batching for performance
 *
 * ```ts
 * import { emitter } from 'emitnlog/logger';
 * import { fileSink } from 'emitnlog/logger/node';
 *
 * const logger = emitter.createLogger(
 *   'info',
 *   emitter.batchSink(fileSink('/logs/app.log'), { maxBufferSize: 100, flushDelayMs: 1000 }),
 * );
 * ```
 *
 * @param filePath The path to the log file.
 */
export const fileSink = (
  filePath: string,
  formatter: LogFormatter = plainFormatter,
  options?: FileSinkOptions,
): FileSink => {
  const config = {
    datePrefix: options?.datePrefix ?? false,
    overwrite: options?.overwrite ?? false,
    encoding: options?.encoding ?? 'utf8',
    mode: options?.mode ?? 0o666,
    errorHandler: options?.errorHandler ? (error: unknown) => options.errorHandler!(errorify(error)) : undefined,
  } as const satisfies FileSinkOptions;
 
  if (!filePath) {
    config.errorHandler?.(new Error('InvalidArgument: file path is required'));
    return { sink: () => void 0, filePath: '', flush: () => Promise.resolve(), close: () => Promise.resolve() };
  }
 
  let resolvedPath: string;
  if (filePath.includes('/') || filePath.includes('\\')) {
    if (filePath.startsWith('~')) {
      resolvedPath = path.join(os.homedir(), filePath.substring(1));
    } else if (path.isAbsolute(filePath)) {
      resolvedPath = filePath;
    } else E{
      resolvedPath = path.resolve(filePath);
    }
  } else {
    resolvedPath = path.join(os.tmpdir(), filePath);
  }
 
  if (config.datePrefix) {
    const now = new Date();
    const p = (n: number) => String(n).padStart(2, '0');
    const datePrefix = `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
    resolvedPath = path.join(path.dirname(resolvedPath), `${datePrefix}_${path.basename(resolvedPath)}`);
  }
 
  const errorHandler = options?.errorHandler && ((error: unknown) => options.errorHandler!(errorify(error)));
  const writer = createFileWriter(resolvedPath, {
    overwrite: config.overwrite,
    errorHandler,
    encoding: options?.encoding ?? 'utf8',
    mode: options?.mode ?? 0o666,
  });
 
  return {
    sink: (level, message, args): void => {
      if (writer.isClosed()) {
        return;
      }
 
      // Format the message immediately to ensure correct timestamp
      writer.write(formatter(level, message, args));
    },
 
    filePath: resolvedPath,
 
    flush(): Promise<void> {
      return writer.flush();
    },
 
    close(): Promise<void> {
      return writer.close();
    },
  };
};