All files / src/utils/node file-writer.ts

100% Statements 48/48
100% Branches 16/16
100% Functions 19/19
100% Lines 44/44

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                                                                                                                                                                                                                                6x 73x 4x   4x   1x 1x 1x 1x 1x   4x     69x     73x 155x   73x 73x 73x 155x   152x 5x 5x   147x       73x 73x 155x     73x   73x       4x 4x       157x 155x 154x   155x   157x       10x     129x     50x 50x       73x                     6x 76x 2x     76x 4x     76x    
import { appendFile, mkdir, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, isAbsolute, join, resolve } from 'node:path';
 
import { stringify } from '../converter/stringify.ts';
 
/**
 * A sequential, queue-based file writer. Writes are buffered in order and flushed asynchronously. The writer is safe to
 * use from concurrent call sites — all writes are serialized through an internal queue.
 */
export type FileWriter = {
  /**
   * The resolved absolute path of the file being written to.
   */
  readonly filePath: string;
 
  /**
   * Tagged template shorthand for {@link write}. Interpolated values are converted to strings via `stringify`. No-op
   * after {@link close} has been called.
   *
   * @example
   *
   * ```typescript
   * writer.w`entry ${index}: ${payload}`;
   * ```
   */
  readonly w: (strings: TemplateStringsArray, ...values: unknown[]) => void;
 
  /**
   * Enqueues content to be written to the file. Returns the writer for fluent chaining. No-op after {@link close} has
   * been called.
   *
   * @param content The content to write.
   * @returns The writer for fluent chaining.
   */
  readonly write: (content: string) => FileWriter;
 
  /**
   * Waits for all previously enqueued writes to complete.
   */
  readonly flush: () => Promise<void>;
 
  /**
   * Returns true if the writer is closed.
   *
   * @returns A boolean indicating if the writer is closed.
   */
  readonly isClosed: () => boolean;
 
  /**
   * Marks the writer as closed and waits for all pending writes to complete. Subsequent writes are ignored.
   */
  readonly close: () => Promise<void>;
};
 
/**
 * Options for {@link createFileWriter}.
 */
type FileWriterOptions = {
  /**
   * Whether to overwrite the file on the first write. Subsequent writes always append.
   *
   * @default false
   */
  readonly overwrite?: boolean;
 
  /**
   * By default a newline is appended to every write. Set to `true` to disable this behavior.
   *
   * @default false
   */
  readonly skipNewLine?: boolean;
 
  /**
   * Base directory used when `file` is a relative path. Ignored if the `file` path is already absolute or if this value
   * is not itself absolute.
   *
   * @default the directory yielded by Node's `path.resolve`.
   */
  readonly directory?: string;
 
  /**
   * Character encoding for file operations.
   *
   * @default Node's default (`'utf8'`).
   */
  readonly encoding?: BufferEncoding;
 
  /**
   * File mode (permission bits) applied when creating the file.
   *
   * @default Node's default.
   */
  readonly mode?: number;
 
  /**
   * Error handler callback for file operations. If not provided, errors are silently ignored.
   */
  readonly errorHandler?: (error: unknown) => void;
};
 
/**
 * Creates a {@link FileWriter} that writes to the given file path. The parent directory is created on the first write if
 * it does not already exist.
 *
 * If `file` is empty or falsy, returns a no-op writer (closed immediately, `filePath` is `''`) and, if provided,
 * invokes `options.errorHandler` with an error before returning.
 *
 * @param file The file path. May be absolute, relative, or start with `~/` (expanded to the user's home directory).
 * @param options Writer configuration.
 * @returns A new file writer.
 */
export const createFileWriter = (file: string, options?: FileWriterOptions): FileWriter => {
  if (!file) {
    options?.errorHandler?.(new Error('InvalidArgument: file path is required'));
 
    const writer: FileWriter = {
      filePath: '',
      w: () => void 0,
      write: () => writer,
      flush: () => Promise.resolve(),
      isClosed: () => true,
      close: () => Promise.resolve(),
    };
    return writer;
  }
 
  const resolvedPath = resolvePath(file, options?.directory);
 
  let initPromise: Promise<void> | undefined;
  const ensureDirectory = (): Promise<void> =>
    (initPromise ??= mkdir(dirname(resolvedPath), { recursive: true }).then(() => void 0));
 
  let needsOverwrite = options?.overwrite;
  const fileOptions = { encoding: options?.encoding, mode: options?.mode };
  const writeMessage = async (message: string): Promise<void> => {
    await ensureDirectory();
 
    if (needsOverwrite) {
      await writeFile(resolvedPath, message, fileOptions);
      needsOverwrite = false;
    } else {
      await appendFile(resolvedPath, message, fileOptions);
    }
  };
 
  let writeQueue = Promise.resolve();
  const queueMessage = (message: string): void => {
    writeQueue = writeQueue.then(() => writeMessage(message).catch((error: unknown) => options?.errorHandler?.(error)));
  };
 
  let closed = false;
 
  const writer: FileWriter = {
    filePath: resolvedPath,
 
    w: (strings, ...values) => {
      const content = String.raw(strings, ...values.map((v) => stringify(v)));
      writer.write(content);
    },
 
    write: (content) => {
      if (!closed) {
        if (!options?.skipNewLine) {
          content += '\n';
        }
        queueMessage(content);
      }
      return writer;
    },
 
    flush(): Promise<void> {
      return writeQueue;
    },
 
    isClosed: () => closed,
 
    close(): Promise<void> {
      closed = true;
      return writeQueue;
    },
  };
 
  return writer;
};
 
/**
 * Resolves a file path to an absolute path. Supports `~/` expansion (user home directory) and an optional base
 * directory for relative paths.
 *
 * @param path The file path to resolve.
 * @param directory Optional base directory for relative paths. Ignored if not absolute.
 * @returns The resolved absolute path.
 */
export const resolvePath = (path: string, directory?: string): string => {
  if (path.startsWith('~/')) {
    path = join(homedir(), path.slice(2));
  }
 
  if (!isAbsolute(path)) {
    path = directory && isAbsolute(directory) ? resolve(directory, path) : resolve(path);
  }
 
  return path;
};