diff --git a/README.md b/README.md index 39904f2..081cdf2 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,68 @@ in the API reference for more details. [`getStreamSink()`]: https://jsr.io/@logtape/logtape/doc/~/getStreamSink [`StreamSinkOptions`]: https://jsr.io/@logtape/logtape/doc/~/StreamSinkOptions +### File sink + +LogTape provides a platform-independent file sink. You can use it by providing +a platform-specific file driver for Deno or Node.js. Here's an example of +a file sink that writes log messages to a file: + +~~~~ typescript +// Deno +import { type FileSinkDriver, getFileSink } from "@logtape/logtape"; + +const driver: FileSinkDriver = { + openSync(path: string) { + return Deno.openSync(path, { create: true, append: true }); + }, + writeSync(fd, chunk) { + fd.writeSync(chunk); + }, + flushSync(fd) { + fd.syncSync(); + }, + closeSync(fd) { + fd.close(); + }, +}; + +configure({ + sinks: { + file: getFileSink("my-app.log", driver), + }, + // Omitted for brevity +}); +~~~~ + +~~~~ typescript +// Node.js or Bun +import fs from "node:fs"; +import { type FileSinkDriver, getFileSink } from "@logtape/logtape"; + +const driver: FileSinkDriver = { + openSync(path: string) { + return fs.openSync(path, "a"); + }, + writeSync: fs.writeSync, + flushSync: fs.fsyncSync, + closeSync: fs.closeSync, +}; + +configure({ + sinks: { + file: getFileSink("my-app.log", driver), + }, + // Omitted for brevity +}); +~~~~ + +See also [`getFileSink()`] function, [`FileSinkOptions`] interface, and +[`FileSinkDriver`] interface in the API reference for more details. + +[`getFileSink()`]: https://jsr.io/@logtape/logtape/doc/~/getFileSink +[`FileSinkOptions`]: https://jsr.io/@logtape/logtape/doc/~/FileSinkOptions +[`FileSinkDriver`]: https://jsr.io/@logtape/logtape/doc/~/FileSinkDriver + ### Buffer sink For testing purposes, you may want to collect log messages in memory. Although @@ -259,8 +321,9 @@ configure({ ### Text formatter -A stream sink writes log messages in a plain text format. You can customize -the format by providing a text formatter. The type of a text formatter is: +A stream sink and a file sink write log messages in a plain text format. +You can customize the format by providing a text formatter. The type of a +text formatter is: ~~~~ typescript export type TextFormatter = (record: LogRecord) => string; diff --git a/deno.json b/deno.json index 8960ce6..1dc7eea 100644 --- a/deno.json +++ b/deno.json @@ -8,17 +8,21 @@ "@std/async": "jsr:@std/async@^0.222.1", "@std/fs": "jsr:@std/fs@^0.223.0", "@std/testing": "jsr:@std/testing@^0.222.1", - "consolemock": "npm:consolemock@^1.1.0" + "consolemock": "npm:consolemock@^1.1.0", + "which_runtime": "https://deno.land/x/which_runtime@0.2.0/mod.ts" }, "exclude": [ "*-venv/", "coverage/", "npm/" ], + "unstable": [ + "fs" + ], "lock": false, "tasks": { "check": "deno check **/*.ts && deno lint && deno fmt --check", - "test": "deno test --allow-read=logtape/__snapshots__ --allow-write=logtape/__snapshots__", + "test": "deno test --allow-read --allow-write", "coverage": "rm -rf coverage && deno task test --coverage && deno coverage --html coverage", "dnt": "deno run -A dnt.ts", "hooks:install": "deno run --allow-read=deno.json,.git/hooks/ --allow-write=.git/hooks/ jsr:@hongminhee/deno-task-hooks", diff --git a/logtape/sink.test.ts b/logtape/sink.test.ts index 3acaa79..1ba256b 100644 --- a/logtape/sink.test.ts +++ b/logtape/sink.test.ts @@ -2,9 +2,17 @@ import { assertEquals } from "@std/assert/assert-equals"; import { assertThrows } from "@std/assert/assert-throws"; import { delay } from "@std/async/delay"; import makeConsoleMock from "consolemock"; +import fs from "node:fs"; +import { isDeno } from "which_runtime"; import { debug, error, fatal, info, warning } from "./fixtures.ts"; import type { LogLevel } from "./record.ts"; -import { getConsoleSink, getStreamSink } from "./sink.ts"; +import { + type FileSinkDriver, + getConsoleSink, + getFileSink, + getStreamSink, + type Sink, +} from "./sink.ts"; interface ConsoleMock extends Console { history(): unknown[]; @@ -117,3 +125,51 @@ Deno.test("getConsoleSink()", () => { "Invalid log level: invalid.", ); }); + +Deno.test("getFileSink()", () => { + const path = Deno.makeTempFileSync(); + let sink: Sink & { close(): void }; + if (isDeno) { + const driver: FileSinkDriver = { + openSync(path: string) { + return Deno.openSync(path, { create: true, append: true }); + }, + writeSync(fd, chunk) { + fd.writeSync(chunk); + }, + flushSync(fd) { + fd.syncSync(); + }, + closeSync(fd) { + fd.close(); + }, + }; + sink = getFileSink(path, driver); + } else { + const driver: FileSinkDriver = { + openSync(path: string) { + return fs.openSync(path, "a"); + }, + writeSync: fs.writeSync, + flushSync: fs.fsyncSync, + closeSync: fs.closeSync, + }; + sink = getFileSink(path, driver); + } + sink(debug); + sink(info); + sink(warning); + sink(error); + sink(fatal); + sink.close(); + assertEquals( + Deno.readTextFileSync(path), + `\ +2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456! +2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456! +2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456! +2023-11-14 22:13:20.000 +00:00 [ERR] my-app·junk: Hello, 123 & 456! +2023-11-14 22:13:20.000 +00:00 [FTL] my-app·junk: Hello, 123 & 456! +`, + ); +}); diff --git a/logtape/sink.ts b/logtape/sink.ts index 20597d1..99828e1 100644 --- a/logtape/sink.ts +++ b/logtape/sink.ts @@ -103,3 +103,61 @@ export function getConsoleSink(options: ConsoleSinkOptions = {}): Sink { } else throw new TypeError(`Invalid log level: ${record.level}.`); }; } + +/** + * Options for the {@link getFileSink} function. + */ +export type FileSinkOptions = StreamSinkOptions; + +/** + * A platform-specific file sink driver. + * @typeParam TFile The type of the file descriptor. + */ +export interface FileSinkDriver { + /** + * Open a file for appending and return a file descriptor. + * @param path A path to the file to open. + */ + openSync(path: string): TFile; + + /** + * Write a chunk of data to the file. + * @param fd The file descriptor. + * @param chunk The data to write. + */ + writeSync(fd: TFile, chunk: Uint8Array): void; + + /** + * Flush the file to ensure that all data is written to the disk. + * @param fd The file descriptor. + */ + flushSync(fd: TFile): void; + + /** + * Close the file. + * @param fd The file descriptor. + */ + closeSync(fd: TFile): void; +} + +/** + * Get a platform-independent file sink. + * @typeParam TFile The type of the file descriptor. + * @param path A path to the file to write to. + * @param options The options for the sink and the file driver. + * @returns A sink that writes to the file. + */ +export function getFileSink( + path: string, + options: FileSinkOptions & FileSinkDriver, +): Sink & { close: () => void } { + const formatter = options.formatter ?? defaultTextFormatter; + const encoder = options.encoder ?? new TextEncoder(); + const fd = options.openSync(path); + const sink = (record: LogRecord) => { + options.writeSync(fd, encoder.encode(formatter(record))); + options.flushSync(fd); + }; + sink.close = () => options.closeSync(fd); + return sink; +}