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

94.91% Statements 56/59
93.54% Branches 29/31
92.85% Functions 13/14
96.22% Lines 51/53

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 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214                                                                                                                                                                                                              3x         47x           3x       47x 1x 1x       46x 45x 1x 44x 44x         1x     46x 1x 1x 1x     46x 46x 46x 46x 46x   46x 144x 99x     45x 45x 40x     46x 144x   139x 135x   4x   4x       46x 145x     46x 46x 46x   116x 1x       115x     115x 42x 42x   73x             4x       30x       30x 30x 30x         3x  
import { promises as fs } from 'node:fs';
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 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))
      : NO_OP_ERROR_HANDLER,
  } 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(path.sep)) {
    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 datePrefix = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
    resolvedPath = path.join(path.dirname(resolvedPath), `${datePrefix}_${path.basename(resolvedPath)}`);
  }
 
  let initialized = false;
  let closed = false;
  let isAppending = !config.overwrite;
  let writeQueue = Promise.resolve();
  let isFirstEntry = true;
 
  const ensureDirectory = async (): Promise<void> => {
    if (initialized) {
      return;
    }
 
    const dir = path.dirname(resolvedPath);
    await fs.mkdir(dir, { recursive: true });
    initialized = true;
  };
 
  const writeMessage = async (message: string): Promise<void> => {
    await ensureDirectory();
 
    if (isAppending) {
      await fs.appendFile(resolvedPath, message, { encoding: config.encoding, mode: config.mode });
    } else {
      await fs.writeFile(resolvedPath, message, { encoding: config.encoding, mode: config.mode });
      // After first write, always append
      isAppending = true;
    }
  };
 
  const queueMessage = (message: string): void => {
    writeQueue = writeQueue.then(() => writeMessage(message).catch((error: unknown) => config.errorHandler(error)));
  };
 
  const lineDelimiter = '\n';
  const footer = lineDelimiter;
  return {
    sink: (level, message, args): void => {
      if (closed) {
        return;
      }
 
      // Format the message immediately to ensure correct timestamp
      const formattedMessage = formatter(level, message, args);
 
      // Prepend delimiter to all entries except the first
      if (isFirstEntry) {
        queueMessage(formattedMessage);
        isFirstEntry = false;
      } else {
        queueMessage(lineDelimiter + formattedMessage);
      }
    },
 
    filePath: resolvedPath,
 
    async flush(): Promise<void> {
      await writeQueue;
    },
 
    async close(): Promise<void> {
      Iif (closed) {
        return;
      }
 
      closed = true;
      queueMessage(footer);
      await writeQueue;
    },
  };
};
 
const NO_OP_ERROR_HANDLER = (): void => void 0;