diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index df09ae1..7aef5ed 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -40,15 +40,18 @@ jobs: with: check_name: "Test Results (Windows)" files: .test-report.xml + - if: '!cancelled()' + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: .test-report.xml - run: deno coverage --lcov .cov > .cov.lcov - uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} slug: dahlia/logtape file: .cov.lcov - - run: deno task dnt - - run: bun run ./test_runner.js - working-directory: ${{ github.workspace }}/npm/ + - run: deno task test:bun - run: deno task check publish: @@ -64,33 +67,40 @@ jobs: deno-version: v2.x - if: github.ref_type == 'branch' run: | - jq \ + v="$(jq \ + --raw-output \ --arg build "$GITHUB_RUN_NUMBER" \ --arg commit "${GITHUB_SHA::8}" \ - '.version = .version + "-dev." + $build + "+" + $commit' \ - deno.json > deno.json.tmp - mv deno.json.tmp deno.json + '.version + "-dev." + $build + "+" + $commit' \ + logtape/deno.json)" + deno run --allow-read --allow-write scripts/update_versions.ts "$v" + deno task check:versions - if: github.ref_type == 'tag' run: | set -ex - [[ "$(jq -r .version deno.json)" = "$GITHUB_REF_NAME" ]] - - run: 'deno task dnt "$(jq -r .version deno.json)"' + [[ "$(jq -r .version logtape/deno.json)" = "$GITHUB_REF_NAME" ]] + deno task check:versions + - run: deno task dnt-all - if: github.event_name == 'push' run: | set -ex npm config set //registry.npmjs.org/:_authToken "$NPM_AUTH_TOKEN" - if [[ "$GITHUB_REF_TYPE" = "tag" ]]; then - npm publish --provenance --access public - else - npm publish --provenance --access public --tag dev - fi + for npm in */npm/; do + pushd "$npm" + if [[ "$GITHUB_REF_TYPE" = "tag" ]]; then + npm publish --provenance --access public + else + npm publish --provenance --access public --tag dev + fi + popd + done env: NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} working-directory: ${{ github.workspace }}/npm/ - if: github.event_name == 'pull_request' - run: deno publish --dry-run --allow-dirty + run: deno task publish --dry-run --allow-dirty - if: github.event_name == 'push' - run: deno publish --allow-dirty + run: deno task publish --allow-dirty publish-docs: if: github.event_name == 'push' @@ -113,12 +123,14 @@ jobs: bun install if [[ "$GITHUB_REF_TYPE" = "tag" ]]; then bun add -D "@logtape/logtape@$GITHUB_REF_NAME" + bun add -D "@logtape/file@$GITHUB_REF_NAME" bun add -D @logtape/otel@latest EXTRA_NAV_TEXT=Unstable \ EXTRA_NAV_LINK="$UNSTABLE_DOCS_URL" \ bun run build else bun add -D @logtape/logtape@dev + bun add -D @logtape/file@dev bun add -D @logtape/otel@dev EXTRA_NAV_TEXT=Stable \ EXTRA_NAV_LINK="$STABLE_DOCS_URL" \ diff --git a/.gitignore b/.gitignore index 5a27765..e4b141a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ +.dnt-import-map.json coverage/ npm/ diff --git a/.vscode/settings.json b/.vscode/settings.json index a5b0e22..968eac1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,7 +13,7 @@ "editor.defaultFormatter": "denoland.vscode-deno", "editor.formatOnSave": true, "editor.codeActionsOnSave": { - "source.organizeImports": "explicit" + "source.sortImports": "always" } }, "[json]": { @@ -28,13 +28,14 @@ "editor.defaultFormatter": "denoland.vscode-deno", "editor.formatOnSave": true, "editor.codeActionsOnSave": { - "source.organizeImports": "explicit" + "source.sortImports": "always" } }, "cSpell.words": [ "Codecov", "consolemock", "deno", + "filesink", "hongminhee", "logtape", "runtimes", diff --git a/CHANGES.md b/CHANGES.md index 19bd313..5d1e82e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,6 +8,13 @@ Version 0.9.0 To be released. + - Moved file sinks and rotating file sinks to separate packages. + + - Moved `getFileSink()` function to `@logtape/file` package. + - Moved `FileSinkOptions` interface to `@logtape/file` package. + - Moved `getRotatingFileSink()` function to `@logtape/file` package. + - Moved `RotatingFileSinkOptions` interface to `@logtape/file` package. + - Added synchronous versions of configuration functions. [[#12], [#29] by Murph Murphy] diff --git a/README.md b/README.md new file mode 120000 index 0000000..c88801f --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +logtape/README.md \ No newline at end of file diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..a86da08 --- /dev/null +++ b/deno.json @@ -0,0 +1,62 @@ +{ + "workspace": [ + "./logtape", + "./file" + ], + "imports": { + "@david/which-runtime": "jsr:@david/which-runtime@^0.2.1", + "@deno/dnt": "jsr:@deno/dnt@^0.41.1", + "@std/assert": "jsr:@std/assert@^0.222.1", + "@std/async": "jsr:@std/async@^0.222.1", + "@std/fs": "jsr:@std/fs@^0.223.0", + "@std/path": "jsr:@std/path@^1.0.2", + "@std/testing": "jsr:@std/testing@^0.222.1", + "consolemock": "npm:consolemock@^1.1.0" + }, + "unstable": [ + "fs" + ], + "lock": false, + "exclude": [ + ".github/", + "docs/" + ], + "tasks": { + "check": { + "command": "deno check **/*.ts && deno lint && deno fmt --check", + "dependencies": [ + "check:versions" + ] + }, + "check:versions": "deno run --allow-read scripts/check_versions.ts", + "test": "deno test --allow-read --allow-write", + "coverage": "rm -rf coverage && deno task test --coverage && deno coverage --html coverage", + "dnt-all": "deno task --recursive dnt", + "test-all:bun": "deno task --recursive test:bun", + "test-all": { + "dependencies": [ + "test", + "test-all:bun" + ] + }, + "publish": { + "command": "deno publish", + "dependencies": [ + "check", + "test" + ] + }, + "hooks:install": "deno run --allow-read=deno.json,.git/hooks/ --allow-write=.git/hooks/ jsr:@hongminhee/deno-task-hooks", + "hooks:pre-commit": { + "dependencies": [ + "check" + ] + }, + "hooks:pre-push": { + "dependencies": [ + "check", + "test" + ] + } + } +} diff --git a/docs/.jsr-cache.file.json b/docs/.jsr-cache.file.json new file mode 100644 index 0000000..1e4ccaa --- /dev/null +++ b/docs/.jsr-cache.file.json @@ -0,0 +1 @@ +{"package":"@logtape/file","version":"0.9.0-dev.1","index":{"getFileSink()":{"kind":[{"char":"f","kind":"Function","title":"Function"}],"name":"getFileSink","file":".","doc":"Get a file sink.\n\nNote that this function is unavailable in the browser.\n","url":"https://jsr.io/@logtape/file@0.9.0-dev.1/doc/~/getFileSink","deprecated":false,"label":"getFileSink()"},"getRotatingFileSink()":{"kind":[{"char":"f","kind":"Function","title":"Function"}],"name":"getRotatingFileSink","file":".","doc":"Get a rotating file sink.\n\nThis sink writes log records to a file, and rotates the file when it reaches\nthe `maxSize`. The rotated files are named with the original file name\nfollowed by a dot and a number, starting from 1. The number is incremented\nfor each rotation, and the maximum number of files to keep is `maxFiles`.\n\nNote that this function is unavailable in the browser.\n","url":"https://jsr.io/@logtape/file@0.9.0-dev.1/doc/~/getRotatingFileSink","deprecated":false,"label":"getRotatingFileSink()"}}} \ No newline at end of file diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 5bdb201..0700c7f 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -1,18 +1,24 @@ +import { transformerTwoslash } from "@shikijs/vitepress-twoslash"; import { jsrRef } from "markdown-it-jsr-ref"; import { defineConfig } from "vitepress"; -import { transformerTwoslash } from "@shikijs/vitepress-twoslash"; const jsrRefVersion = process.env.CI === "true" && process.env.GITHUB_REF_TYPE === "tag" ? "stable" : "unstable"; -const jsrRefPlugin = await jsrRef({ +const jsrRef_logtape = await jsrRef({ package: "@logtape/logtape", version: jsrRefVersion, cachePath: ".jsr-cache.json", }); +const jsrRef_file = await jsrRef({ + package: "@logtape/file", + version: jsrRefVersion, + cachePath: ".jsr-cache.file.json", +}); + let extraNav: { text: string; link: string }[] = []; if (process.env.EXTRA_NAV_TEXT && process.env.EXTRA_NAV_LINK) { extraNav = [ @@ -116,7 +122,8 @@ export default defineConfig({ }), ], config(md) { - md.use(jsrRefPlugin); + md.use(jsrRef_logtape); + md.use(jsrRef_file); }, }, diff --git a/docs/bun.lockb b/docs/bun.lockb index f86d533..99dc4ee 100755 Binary files a/docs/bun.lockb and b/docs/bun.lockb differ diff --git a/docs/manual/sinks.md b/docs/manual/sinks.md index 77dc400..38317fc 100644 --- a/docs/manual/sinks.md +++ b/docs/manual/sinks.md @@ -136,12 +136,38 @@ File sink > [!NOTE] > File sink is unavailable in the browser environment. -LogTape provides a file sink as well. Here's an example of a file sink that -writes log messages to a file: +LogTape provides a file sink through a separate package *@logtape/file*: + +::: code-group + +~~~~ sh [Deno] +deno add jsr:@logtape/file +~~~~ + +~~~~ sh [npm] +npm add @logtape/file +~~~~ + +~~~~ sh [pnpm] +pnpm add @logtape/file +~~~~ + +~~~~ sh [Yarn] +yarn add @logtape/file +~~~~ + +~~~~ sh [Bun] +bun add @logtape/file +~~~~ + +::: + +Here's an example of a file sink that writes log messages to a file: ~~~~ typescript twoslash // @noErrors: 2345 -import { configure, getFileSink } from "@logtape/logtape"; +import { getFileSink } from "@logtape/file"; +import { configure } from "@logtape/logtape"; await configure({ sinks: { @@ -181,12 +207,39 @@ the ability to *rotate* the log file when it reaches a certain size. This means: This rotation process helps prevent any single log file from growing too large, which can cause issues with file handling, log analysis, and storage management. -To use the rotating file sink, you can use the `getRotatingFileSink()` function. +To use the rotating file sink, you can use the `getRotatingFileSink()` function, +which is provided by the *@logtape/file* package: + +::: code-group + +~~~~ sh [Deno] +deno add jsr:@logtape/file +~~~~ + +~~~~ sh [npm] +npm add @logtape/file +~~~~ + +~~~~ sh [pnpm] +pnpm add @logtape/file +~~~~ + +~~~~ sh [Yarn] +yarn add @logtape/file +~~~~ + +~~~~ sh [Bun] +bun add @logtape/file +~~~~ + +::: + Here's an example of a rotating file sink that writes log messages to a file: ~~~~ typescript twoslash // @noErrors: 2345 -import { configure, getRotatingFileSink } from "@logtape/logtape"; +import { getRotatingFileSink } from "@logtape/file"; +import { configure } from "@logtape/logtape"; await configure({ sinks: { diff --git a/docs/package.json b/docs/package.json index 857d2d9..764034b 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,13 +1,12 @@ { - "dependencies": { - "@logtape/sentry": "^0.1.0", - "@sentry/node": "^8.40.0" - }, "devDependencies": { "@biomejs/biome": "^1.8.3", "@cloudflare/workers-types": "^4.20240909.0", + "@logtape/file": "^0.9.0-dev.1", "@logtape/logtape": "0.9.0-dev.127", + "@logtape/sentry": "^0.1.0", "@logtape/otel": "^0.2.0", + "@sentry/node": "^8.40.0", "@shikijs/vitepress-twoslash": "^1.17.6", "@teidesu/deno-types": "^1.46.3", "@types/bun": "^1.1.9", diff --git a/file/README.md b/file/README.md new file mode 100644 index 0000000..58b1104 --- /dev/null +++ b/file/README.md @@ -0,0 +1,43 @@ + + +File sinks for LogTape +====================== + +[![JSR][JSR badge]][JSR] +[![npm][npm badge]][npm] + +This package provides file sinks for [LogTape]. You can use the file sinks to +write log records to files. For details, read the docs: + + - [File sink] + - [Rotating file sink] + +[JSR]: https://jsr.io/@logtape/file +[JSR badge]: https://jsr.io/badges/@logtape/file +[npm]: https://www.npmjs.com/package/@logtape/file +[npm badge]: https://img.shields.io/npm/v/@logtape/file?logo=npm +[LogTape]: https://logtape.org/ +[File sink]: https://logtape.org/manual/sinks#file-sink +[Rotating file sink]: https://logtape.org/manual/sinks#rotating-file-sink + + +Installation +------------ + +This package is available on [JSR] and [npm]. You can install it for various +JavaScript runtimes and package managers: + +~~~~ sh +deno add @logtape/file # for Deno +npm add @logtape/file # for npm +pnpm add @logtape/file # for pnpm +yarn add @logtape/file # for Yarn +bun add @logtape/file # for Bun +~~~~ + + +Docs +---- + +The docs of LogTape is available at . +For the API references, see . diff --git a/file/deno.json b/file/deno.json new file mode 100644 index 0000000..7fd18fe --- /dev/null +++ b/file/deno.json @@ -0,0 +1,20 @@ +{ + "name": "@logtape/file", + "version": "0.9.0", + "license": "MIT", + "exports": "./mod.ts", + "exclude": [ + "coverage/", + "npm/", + ".dnt-import-map.json" + ], + "tasks": { + "dnt": "deno run -A dnt.ts", + "test:bun": { + "command": "cd npm/ && bun run ./test_runner.js && cd ../", + "dependencies": [ + "dnt" + ] + } + } +} diff --git a/file/dnt.ts b/file/dnt.ts new file mode 100644 index 0000000..45e492f --- /dev/null +++ b/file/dnt.ts @@ -0,0 +1,64 @@ +import { build, emptyDir } from "@deno/dnt"; +import workspace from "../deno.json" with { type: "json" }; +import metadata from "./deno.json" with { type: "json" }; + +await emptyDir("./npm"); + +const imports = { + "@logtape/logtape": "../logtape/mod.ts", + ...workspace.imports, +}; + +await Deno.writeTextFile( + ".dnt-import-map.json", + JSON.stringify({ imports }, undefined, 2), +); + +await build({ + package: { + name: metadata.name, + version: Deno.args[0] ?? metadata.version, + description: "File sink and rotating file sink for LogTape", + keywords: ["logging", "log", "logger", "file", "sink", "rotating"], + license: "MIT", + author: { + name: "Hong Minhee", + email: "hong@minhee.org", + url: "https://hongminhee.org/", + }, + homepage: "https://logtape.org/", + repository: { + type: "git", + url: "git+https://github.com/dahlia/logtape.git", + directory: "file/", + }, + bugs: { + url: "https://github.com/dahlia/logtape/issues", + }, + funding: [ + "https://github.com/sponsors/dahlia", + ], + }, + outDir: "./npm", + entryPoints: ["./mod.ts"], + importMap: "./.dnt-import-map.json", + mappings: { + "./filesink.jsr.ts": "./filesink.node.ts", + "./filesink.deno.ts": "./filesink.node.ts", + }, + shims: { + deno: "dev", + }, + typeCheck: "both", + declaration: "separate", + declarationMap: true, + compilerOptions: { + lib: ["ES2021", "DOM"], + }, + async postBuild() { + await Deno.copyFile("../LICENSE", "npm/LICENSE"); + await Deno.copyFile("README.md", "npm/README.md"); + }, +}); + +// cSpell: ignore Minhee filesink diff --git a/file/filesink.base.ts b/file/filesink.base.ts new file mode 100644 index 0000000..e61b933 --- /dev/null +++ b/file/filesink.base.ts @@ -0,0 +1,158 @@ +import { + defaultTextFormatter, + type LogRecord, + type Sink, + type StreamSinkOptions, +} from "@logtape/logtape"; + +/** + * Options for the {@link getBaseFileSink} 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. The sink is also a disposable + * object that closes the file when disposed. + */ +export function getBaseFileSink( + path: string, + options: FileSinkOptions & FileSinkDriver, +): Sink & Disposable { + const formatter = options.formatter ?? defaultTextFormatter; + const encoder = options.encoder ?? new TextEncoder(); + const fd = options.openSync(path); + const sink: Sink & Disposable = (record: LogRecord) => { + options.writeSync(fd, encoder.encode(formatter(record))); + options.flushSync(fd); + }; + sink[Symbol.dispose] = () => options.closeSync(fd); + return sink; +} + +/** + * Options for the {@link getBaseRotatingFileSink} function. + */ +export interface RotatingFileSinkOptions extends FileSinkOptions { + /** + * The maximum bytes of the file before it is rotated. 1 MiB by default. + */ + maxSize?: number; + + /** + * The maximum number of files to keep. 5 by default. + */ + maxFiles?: number; +} + +/** + * A platform-specific rotating file sink driver. + */ +export interface RotatingFileSinkDriver extends FileSinkDriver { + /** + * Get the size of the file. + * @param path A path to the file. + * @returns The `size` of the file in bytes, in an object. + */ + statSync(path: string): { size: number }; + + /** + * Rename a file. + * @param oldPath A path to the file to rename. + * @param newPath A path to be renamed to. + */ + renameSync(oldPath: string, newPath: string): void; +} + +/** + * Get a platform-independent rotating file sink. + * + * This sink writes log records to a file, and rotates the file when it reaches + * the `maxSize`. The rotated files are named with the original file name + * followed by a dot and a number, starting from 1. The number is incremented + * for each rotation, and the maximum number of files to keep is `maxFiles`. + * + * @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. The sink is also a disposable + * object that closes the file when disposed. + */ +export function getBaseRotatingFileSink( + path: string, + options: RotatingFileSinkOptions & RotatingFileSinkDriver, +): Sink & Disposable { + const formatter = options.formatter ?? defaultTextFormatter; + const encoder = options.encoder ?? new TextEncoder(); + const maxSize = options.maxSize ?? 1024 * 1024; + const maxFiles = options.maxFiles ?? 5; + let offset: number = 0; + try { + const stat = options.statSync(path); + offset = stat.size; + } catch { + // Continue as the offset is already 0. + } + let fd = options.openSync(path); + function shouldRollover(bytes: Uint8Array): boolean { + return offset + bytes.length > maxSize; + } + function performRollover(): void { + options.closeSync(fd); + for (let i = maxFiles - 1; i > 0; i--) { + const oldPath = `${path}.${i}`; + const newPath = `${path}.${i + 1}`; + try { + options.renameSync(oldPath, newPath); + } catch (_) { + // Continue if the file does not exist. + } + } + options.renameSync(path, `${path}.1`); + offset = 0; + fd = options.openSync(path); + } + const sink: Sink & Disposable = (record: LogRecord) => { + const bytes = encoder.encode(formatter(record)); + if (shouldRollover(bytes)) performRollover(); + options.writeSync(fd, bytes); + options.flushSync(fd); + offset += bytes.length; + }; + sink[Symbol.dispose] = () => options.closeSync(fd); + return sink; +} diff --git a/logtape/filesink.deno.ts b/file/filesink.deno.ts similarity index 83% rename from logtape/filesink.deno.ts rename to file/filesink.deno.ts index 17f459e..0af357e 100644 --- a/logtape/filesink.deno.ts +++ b/file/filesink.deno.ts @@ -1,12 +1,11 @@ -import { webDriver } from "./filesink.web.ts"; +import type { Sink } from "@logtape/logtape"; import { type FileSinkOptions, - getFileSink as getBaseFileSink, - getRotatingFileSink as getBaseRotatingFileSink, + getBaseFileSink, + getBaseRotatingFileSink, type RotatingFileSinkDriver, type RotatingFileSinkOptions, - type Sink, -} from "./sink.ts"; +} from "./filesink.base.ts"; /** * A Deno-specific file sink driver. @@ -42,9 +41,6 @@ export function getFileSink( path: string, options: FileSinkOptions = {}, ): Sink & Disposable { - if ("document" in globalThis) { - return getBaseFileSink(path, { ...options, ...webDriver }); - } return getBaseFileSink(path, { ...options, ...denoDriver }); } @@ -67,9 +63,6 @@ export function getRotatingFileSink( path: string, options: RotatingFileSinkOptions = {}, ): Sink & Disposable { - if ("document" in globalThis) { - return getBaseRotatingFileSink(path, { ...options, ...webDriver }); - } return getBaseRotatingFileSink(path, { ...options, ...denoDriver }); } diff --git a/logtape/filesink.jsr.ts b/file/filesink.jsr.ts similarity index 90% rename from logtape/filesink.jsr.ts rename to file/filesink.jsr.ts index 4284e26..7cb1e9c 100644 --- a/logtape/filesink.jsr.ts +++ b/file/filesink.jsr.ts @@ -1,6 +1,11 @@ -import type { FileSinkOptions, RotatingFileSinkOptions, Sink } from "./sink.ts"; +import type { Sink } from "@logtape/logtape"; +import type { + FileSinkOptions, + RotatingFileSinkOptions, +} from "./filesink.base.ts"; const filesink: Omit = + // dnt-shim-ignore await ("Deno" in globalThis ? import("./filesink.deno.ts") : import("./filesink.node.ts")); diff --git a/logtape/filesink.node.ts b/file/filesink.node.ts similarity index 63% rename from logtape/filesink.node.ts rename to file/filesink.node.ts index 8cf65ae..e84ce51 100644 --- a/logtape/filesink.node.ts +++ b/file/filesink.node.ts @@ -1,34 +1,26 @@ -// @ts-ignore: a trick to avoid module resolution error on non-Node.js environ -import fsMod from "./fs.ts"; -import type fsType from "node:fs"; -import { webDriver } from "./filesink.web.ts"; +import type { Sink } from "@logtape/logtape"; +import fs from "node:fs"; import { type FileSinkOptions, - getFileSink as getBaseFileSink, - getRotatingFileSink as getBaseRotatingFileSink, + getBaseFileSink, + getBaseRotatingFileSink, type RotatingFileSinkDriver, type RotatingFileSinkOptions, - type Sink, -} from "./sink.ts"; - -// @ts-ignore: a trick to avoid module resolution error on non-Node.js environ -const fs = fsMod as (typeof fsType | null); +} from "./filesink.base.ts"; /** * A Node.js-specific file sink driver. */ -export const nodeDriver: RotatingFileSinkDriver = fs == null - ? webDriver - : { - openSync(path: string) { - return fs.openSync(path, "a"); - }, - writeSync: fs.writeSync, - flushSync: fs.fsyncSync, - closeSync: fs.closeSync, - statSync: fs.statSync, - renameSync: fs.renameSync, - }; +export const nodeDriver: RotatingFileSinkDriver = { + openSync(path: string) { + return fs.openSync(path, "a"); + }, + writeSync: fs.writeSync, + flushSync: fs.fsyncSync, + closeSync: fs.closeSync, + statSync: fs.statSync, + renameSync: fs.renameSync, +}; /** * Get a file sink. @@ -44,9 +36,6 @@ export function getFileSink( path: string, options: FileSinkOptions = {}, ): Sink & Disposable { - if ("document" in globalThis) { - return getBaseFileSink(path, { ...options, ...webDriver }); - } return getBaseFileSink(path, { ...options, ...nodeDriver }); } @@ -69,9 +58,6 @@ export function getRotatingFileSink( path: string, options: RotatingFileSinkOptions = {}, ): Sink & Disposable { - if ("document" in globalThis) { - return getBaseRotatingFileSink(path, { ...options, ...webDriver }); - } return getBaseRotatingFileSink(path, { ...options, ...nodeDriver }); } diff --git a/logtape/filesink.test.ts b/file/filesink.test.ts similarity index 66% rename from logtape/filesink.test.ts rename to file/filesink.test.ts index c1f2042..686fce9 100644 --- a/logtape/filesink.test.ts +++ b/file/filesink.test.ts @@ -1,8 +1,59 @@ +import { isDeno } from "@david/which-runtime"; +import type { Sink } from "@logtape/logtape"; import { assertEquals } from "@std/assert/assert-equals"; import { join } from "@std/path/join"; +import fs from "node:fs"; +import { debug, error, fatal, info, warning } from "../logtape/fixtures.ts"; +import { type FileSinkDriver, getBaseFileSink } from "./filesink.base.ts"; import { getFileSink, getRotatingFileSink } from "./filesink.deno.ts"; -import { debug, error, fatal, info, warning } from "./fixtures.ts"; -import type { Sink } from "./sink.ts"; + +Deno.test("getBaseFileSink()", () => { + const path = Deno.makeTempFileSync(); + let sink: Sink & Disposable; + 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 = getBaseFileSink(path, driver); + } else { + const driver: FileSinkDriver = { + openSync(path: string) { + return fs.openSync(path, "a"); + }, + writeSync: fs.writeSync, + flushSync: fs.fsyncSync, + closeSync: fs.closeSync, + }; + sink = getBaseFileSink(path, driver); + } + sink(debug); + sink(info); + sink(warning); + sink(error); + sink(fatal); + sink[Symbol.dispose](); + 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! +`, + ); +}); Deno.test("getFileSink()", () => { const path = Deno.makeTempFileSync(); diff --git a/file/mod.ts b/file/mod.ts new file mode 100644 index 0000000..194862a --- /dev/null +++ b/file/mod.ts @@ -0,0 +1,7 @@ +export type { + FileSinkDriver, + FileSinkOptions, + RotatingFileSinkDriver, + RotatingFileSinkOptions, +} from "./filesink.base.ts"; +export { getFileSink, getRotatingFileSink } from "./filesink.jsr.ts"; diff --git a/logtape/deno.json b/logtape/deno.json index dbd6603..ff9641e 100644 --- a/logtape/deno.json +++ b/logtape/deno.json @@ -3,32 +3,16 @@ "version": "0.9.0", "license": "MIT", "exports": "./mod.ts", - "imports": { - "@deno/dnt": "jsr:@deno/dnt@^0.41.1", - "@std/assert": "jsr:@std/assert@^0.222.1", - "@std/async": "jsr:@std/async@^0.222.1", - "@std/fs": "jsr:@std/fs@^0.223.0", - "@std/path": "jsr:@std/path@^1.0.2", - "@std/testing": "jsr:@std/testing@^0.222.1", - "consolemock": "npm:consolemock@^1.1.0", - "which_runtime": "https://deno.land/x/which_runtime@0.2.0/mod.ts" - }, "exclude": [ - "coverage/", "npm/" ], - "unstable": [ - "fs" - ], - "lock": false, "tasks": { - "check": "deno check **/*.ts && deno lint && deno fmt --check", - "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", - "test-all": "deno task test && deno task dnt && cd npm/ && bun run ./test_runner.js && cd ../", - "hooks:install": "deno run --allow-read=deno.json,.git/hooks/ --allow-write=.git/hooks/ jsr:@hongminhee/deno-task-hooks", - "hooks:pre-commit": "deno task check", - "hooks:pre-push": "deno task test" + "test:bun": { + "command": "cd npm/ && bun run ./test_runner.js && cd ../", + "dependencies": [ + "dnt" + ] + } } } diff --git a/logtape/dnt.ts b/logtape/dnt.ts index d1ee7a2..42f8421 100644 --- a/logtape/dnt.ts +++ b/logtape/dnt.ts @@ -5,7 +5,7 @@ await emptyDir("./npm"); await build({ package: { - name: "@logtape/logtape", + name: metadata.name, version: Deno.args[0] ?? metadata.version, description: "Simple logging library with zero dependencies for " + "Deno/Node.js/Bun/browsers", @@ -20,6 +20,7 @@ await build({ repository: { type: "git", url: "git+https://github.com/dahlia/logtape.git", + directory: "logtape/", }, bugs: { url: "https://github.com/dahlia/logtape/issues", @@ -30,12 +31,7 @@ await build({ }, outDir: "./npm", entryPoints: ["./mod.ts"], - importMap: "./deno.json", - mappings: { - "./filesink.jsr.ts": "./filesink.node.ts", - "./filesink.deno.ts": "./filesink.node.ts", - "./fs.ts": "./fs.js", - }, + importMap: "../deno.json", shims: { deno: "dev", }, @@ -46,12 +42,6 @@ await build({ lib: ["ES2021", "DOM"], }, async postBuild() { - await Deno.writeTextFile( - "npm/esm/fs.js", - 'import fs from "./fs.cjs";\nexport default fs;\n', - ); - await Deno.copyFile("fs.cjs", "npm/esm/fs.cjs"); - await Deno.copyFile("fs.cjs", "npm/script/fs.js"); await Deno.writeTextFile( "npm/esm/nodeUtil.js", 'import util from "./nodeUtil.cjs";\nexport default util;\n', diff --git a/logtape/filesink.web.ts b/logtape/filesink.web.ts deleted file mode 100644 index e589f56..0000000 --- a/logtape/filesink.web.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { RotatingFileSinkDriver } from "./sink.ts"; - -function notImplemented(): T { - throw new Error("File sink is not available in the browser."); -} - -/** - * A browser-specific file sink driver. All methods throw an error. - */ -export const webDriver: RotatingFileSinkDriver = { - openSync: notImplemented, - writeSync: notImplemented, - flushSync: notImplemented, - closeSync: notImplemented, - statSync: notImplemented, - renameSync: notImplemented, -}; diff --git a/logtape/fs.cjs b/logtape/fs.cjs deleted file mode 100644 index fe20ac6..0000000 --- a/logtape/fs.cjs +++ /dev/null @@ -1,20 +0,0 @@ -let fs = null; -if ( - typeof window === "undefined" && ( - "process" in globalThis && "versions" in globalThis.process && - "node" in globalThis.process.versions && - typeof globalThis.caches === "undefined" && - typeof globalThis.addEventListener !== "function" || - "Bun" in globalThis - ) -) { - try { - // Intentionally confuse static analysis of bundlers: - const $require = [require]; - fs = $require[0](`${["node", "fs"].join(":")}`); - } catch { - fs = null; - } -} - -module.exports = fs; diff --git a/logtape/fs.js b/logtape/fs.js deleted file mode 100644 index 1c2b978..0000000 --- a/logtape/fs.js +++ /dev/null @@ -1 +0,0 @@ -export * from "node:fs"; diff --git a/logtape/fs.ts b/logtape/fs.ts deleted file mode 100644 index 29de881..0000000 --- a/logtape/fs.ts +++ /dev/null @@ -1,22 +0,0 @@ -let fs = null; -if ( - // @ts-ignore: process is a global variable - "process" in globalThis && "versions" in globalThis.process && - // @ts-ignore: process is a global variable - "node" in globalThis.process.versions && - typeof globalThis.caches === "undefined" && - typeof globalThis.addEventListener !== "function" || - "Bun" in globalThis -) { - try { - fs = await import("node" + ":fs"); - } catch (e) { - if (e instanceof TypeError) { - fs = null; - } else { - throw e; - } - } -} - -export default fs; diff --git a/logtape/mod.ts b/logtape/mod.ts index e40f504..7c9a858 100644 --- a/logtape/mod.ts +++ b/logtape/mod.ts @@ -11,7 +11,6 @@ export { resetSync, } from "./config.ts"; export { type ContextLocalStorage, withContext } from "./context.ts"; -export { getFileSink, getRotatingFileSink } from "./filesink.jsr.ts"; export { type Filter, type FilterLike, @@ -42,10 +41,8 @@ export { getLogger, type Logger } from "./logger.ts"; export type { LogRecord } from "./record.ts"; export { type ConsoleSinkOptions, - type FileSinkOptions, getConsoleSink, getStreamSink, - type RotatingFileSinkOptions, type Sink, type StreamSinkOptions, withFilter, diff --git a/logtape/sink.test.ts b/logtape/sink.test.ts index 1497821..7b240e6 100644 --- a/logtape/sink.test.ts +++ b/logtape/sink.test.ts @@ -1,20 +1,11 @@ import { assertEquals } from "@std/assert/assert-equals"; import { assertThrows } from "@std/assert/assert-throws"; import makeConsoleMock from "consolemock"; -import fs from "node:fs"; -import { isDeno } from "which_runtime"; import { debug, error, fatal, info, warning } from "./fixtures.ts"; import { defaultTextFormatter } from "./formatter.ts"; import type { LogLevel } from "./level.ts"; import type { LogRecord } from "./record.ts"; -import { - type FileSinkDriver, - getConsoleSink, - getFileSink, - getStreamSink, - type Sink, - withFilter, -} from "./sink.ts"; +import { getConsoleSink, getStreamSink, withFilter } from "./sink.ts"; Deno.test("withFilter()", () => { const buffer: LogRecord[] = []; @@ -223,51 +214,3 @@ Deno.test("getConsoleSink()", () => { }, ]); }); - -Deno.test("getFileSink()", () => { - const path = Deno.makeTempFileSync(); - let sink: Sink & Disposable; - 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[Symbol.dispose](); - 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 4a3b31b..22246c2 100644 --- a/logtape/sink.ts +++ b/logtape/sink.ts @@ -165,155 +165,3 @@ export function getConsoleSink(options: ConsoleSinkOptions = {}): Sink { } }; } - -/** - * 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. The sink is also a disposable - * object that closes the file when disposed. - */ -export function getFileSink( - path: string, - options: FileSinkOptions & FileSinkDriver, -): Sink & Disposable { - const formatter = options.formatter ?? defaultTextFormatter; - const encoder = options.encoder ?? new TextEncoder(); - const fd = options.openSync(path); - const sink: Sink & Disposable = (record: LogRecord) => { - options.writeSync(fd, encoder.encode(formatter(record))); - options.flushSync(fd); - }; - sink[Symbol.dispose] = () => options.closeSync(fd); - return sink; -} - -/** - * Options for the {@link getRotatingFileSink} function. - */ -export interface RotatingFileSinkOptions extends FileSinkOptions { - /** - * The maximum bytes of the file before it is rotated. 1 MiB by default. - */ - maxSize?: number; - - /** - * The maximum number of files to keep. 5 by default. - */ - maxFiles?: number; -} - -/** - * A platform-specific rotating file sink driver. - */ -export interface RotatingFileSinkDriver extends FileSinkDriver { - /** - * Get the size of the file. - * @param path A path to the file. - * @returns The `size` of the file in bytes, in an object. - */ - statSync(path: string): { size: number }; - - /** - * Rename a file. - * @param oldPath A path to the file to rename. - * @param newPath A path to be renamed to. - */ - renameSync(oldPath: string, newPath: string): void; -} - -/** - * Get a platform-independent rotating file sink. - * - * This sink writes log records to a file, and rotates the file when it reaches - * the `maxSize`. The rotated files are named with the original file name - * followed by a dot and a number, starting from 1. The number is incremented - * for each rotation, and the maximum number of files to keep is `maxFiles`. - * - * @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. The sink is also a disposable - * object that closes the file when disposed. - */ -export function getRotatingFileSink( - path: string, - options: RotatingFileSinkOptions & RotatingFileSinkDriver, -): Sink & Disposable { - const formatter = options.formatter ?? defaultTextFormatter; - const encoder = options.encoder ?? new TextEncoder(); - const maxSize = options.maxSize ?? 1024 * 1024; - const maxFiles = options.maxFiles ?? 5; - let offset: number = 0; - try { - const stat = options.statSync(path); - offset = stat.size; - } catch { - // Continue as the offset is already 0. - } - let fd = options.openSync(path); - function shouldRollover(bytes: Uint8Array): boolean { - return offset + bytes.length > maxSize; - } - function performRollover(): void { - options.closeSync(fd); - for (let i = maxFiles - 1; i > 0; i--) { - const oldPath = `${path}.${i}`; - const newPath = `${path}.${i + 1}`; - try { - options.renameSync(oldPath, newPath); - } catch (_) { - // Continue if the file does not exist. - } - } - options.renameSync(path, `${path}.1`); - offset = 0; - fd = options.openSync(path); - } - const sink: Sink & Disposable = (record: LogRecord) => { - const bytes = encoder.encode(formatter(record)); - if (shouldRollover(bytes)) performRollover(); - options.writeSync(fd, bytes); - options.flushSync(fd); - offset += bytes.length; - }; - sink[Symbol.dispose] = () => options.closeSync(fd); - return sink; -} diff --git a/scripts/check_versions.ts b/scripts/check_versions.ts new file mode 100644 index 0000000..1c85958 --- /dev/null +++ b/scripts/check_versions.ts @@ -0,0 +1,24 @@ +import { dirname, join } from "@std/path"; +import metadata from "../deno.json" with { type: "json" }; + +const root = dirname(import.meta.dirname!); +const versions: Record = {}; + +for (const member of metadata.workspace) { + const file = join(root, member, "deno.json"); + const json = await Deno.readTextFile(file); + const data = JSON.parse(json); + versions[join(member, "deno.json")] = data.version; +} +let version: string | undefined; + +for (const file in versions) { + if (version != null && versions[file] !== version) { + console.error("Versions are inconsistent:"); + for (const file in versions) { + console.error(` ${file}: ${versions[file]}`); + } + Deno.exit(1); + } + version = versions[file]; +} diff --git a/scripts/update_versions.ts b/scripts/update_versions.ts new file mode 100644 index 0000000..3b51255 --- /dev/null +++ b/scripts/update_versions.ts @@ -0,0 +1,19 @@ +import { dirname, join } from "@std/path"; +import metadata from "../deno.json" with { type: "json" }; + +const root = dirname(import.meta.dirname!); + +if (Deno.args.length < 1) { + console.error("error: no argument"); + Deno.exit(1); +} + +const version = Deno.args[0]; + +for (const member of metadata.workspace) { + const file = join(root, member, "deno.json"); + const json = await Deno.readTextFile(file); + const data = JSON.parse(json); + data.version = version; + await Deno.writeTextFile(file, JSON.stringify(data, undefined, 2)); +}