@ayepi/log 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Philip Diffenderfer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,216 @@
1
+ # @ayepi/log
2
+
3
+ Structured logging with **AsyncLocalStorage trace context**. Stack context with
4
+ `logWith` and it flows through the whole async call tree — and onto thrown errors —
5
+ for great tracing. Plus console interception, console + file transports, configurable
6
+ error serialization, a record filter hook, and an ayepi middleware.
7
+
8
+ ```sh
9
+ pnpm add @ayepi/log
10
+ ```
11
+
12
+ ```ts
13
+ import { createLogger } from '@ayepi/log'
14
+ const log = createLogger({ level: 'debug' })
15
+
16
+ log.logWith({ reqId: 'abc' }, async () => {
17
+ log.info('handling', { userId: 'u1' }) // record carries reqId + userId
18
+ await work() // a rejection here is tagged with { reqId, userId }
19
+ })
20
+ ```
21
+
22
+ Bare `import` has **no side effects** — console interception is opt-in.
23
+
24
+ ## `log(level, …args)`
25
+
26
+ Builds a record from mixed arguments:
27
+
28
+ - always `tms`, `level`, `msg` (the space-joined non-object args);
29
+ - object args are **merged** into the record;
30
+ - `Error` args become `error` / `additionalErrors` (serialized with `name`, `message`,
31
+ `stack`, recursive `cause`, own props) — and **any trace context attached to the error
32
+ is merged in** (so a caught error carries the context from where it was thrown);
33
+ - emitted only if `level >= ` the configured threshold (`debug < info < warn < error`).
34
+
35
+ ```ts
36
+ log.error('upload failed', err, { docId })
37
+ // { tms, level:'error', msg:'upload failed', docId, error:{ name, message, stack, cause… }, …throwSiteContext }
38
+ ```
39
+
40
+ ## Value resolution — `toLOG` / `toJSON`
41
+
42
+ Logged values are resolved to a plain **loggable shape** (deeply) before they're merged — so
43
+ the same shape appears in the record transports receive, the `sanitize` pass, and both the text
44
+ and JSON output. A value's **`toLOG()`** hook (logging-specific, **wins over `toJSON`**) or
45
+ `toJSON()` (e.g. `Date`) defines that shape; otherwise it's a structural copy.
46
+
47
+ ```ts
48
+ class Money {
49
+ constructor(private cents: number) {}
50
+ toJSON() { return this.cents } // API responses: a number
51
+ toLOG() { return `$${(this.cents / 100).toFixed(2)}` } // logs: "$19.99"
52
+ }
53
+ log.info('charged', { amount: new Money(1999) }) // → …, amount: "$19.99"
54
+ ```
55
+
56
+ `toLOG()` **may return a promise** — the line is delivered once it resolves, so an expensive or
57
+ async log view is built only when the line actually logs:
58
+
59
+ ```ts
60
+ log.debug('account', { acct: { toLOG: async () => ({ id, balance: await loadBalance() }) } })
61
+ ```
62
+
63
+ A hook that throws/rejects degrades that value to `'(unresolved value)'` (reported to
64
+ `onError`); the rest of the line still logs. `resolveLogValue(value)` runs this standalone.
65
+
66
+ For types you **don't** own (a `Request`, `URL`, `Buffer`, a third-party class), configure
67
+ `serializers` — predicate functions tried in order at every depth (first non-`undefined` wins,
68
+ taking precedence over `toLOG`/`toJSON`):
69
+
70
+ ```ts
71
+ createLogger({
72
+ serializers: [
73
+ (v) => (v instanceof URL ? v.href : undefined),
74
+ (v) => (v instanceof Request ? { method: v.method, url: v.url } : undefined),
75
+ ],
76
+ })
77
+ ```
78
+
79
+ ## Runtime control & shutdown
80
+
81
+ ```ts
82
+ log.setLevel('debug') // change the threshold at runtime (admin toggle, signal handler)
83
+ log.isLevelEnabled('debug') // guard an expensive block when logMaybe doesn't fit
84
+
85
+ await log.flush() // drain buffered transports (e.g. the file transport)
86
+ await log.close() // flush + release timers/handles — wire into an @ayepi/updown teardown
87
+ ```
88
+
89
+ `flush`/`close` run every transport in parallel and route a failing one to `onError`, so one
90
+ bad transport can't abort shutdown.
91
+
92
+ ## Context stacking — `logWith`
93
+
94
+ `logWith(add, inner)` merges `add` into the ambient context (immutably) and runs
95
+ `inner` within it. Ambient fields keep their bare key; a colliding call-site field
96
+ becomes `key2` (so `reqId`/`userId` stay stable across every log in a request). If
97
+ `inner` returns a promise, its rejection is tagged with the full context under
98
+ `LOG_CONTEXT` (innermost `logWith` wins).
99
+
100
+ ## Output & transports
101
+
102
+ Text by default (`[tms] level msg key=value, key=value`); `structured: true` for JSON.
103
+ Transports are pluggable and fire-and-forget:
104
+
105
+ - `consoleTransport(...)` — writes through the captured original console (recursion-safe).
106
+ - `fileTransport(...)` from `@ayepi/log/file` — **non-blocking + batched**: `write()`
107
+ buffers and returns immediately; lines flush to disk in batches (one append per flush,
108
+ one flush in flight) so callers never wait on I/O and the FS isn't hammered per line.
109
+ Size (default) or date rotation; `maxSize`/`maxFiles`/`flushInterval`/`maxBufferBytes`;
110
+ `close()` flushes (wire it to an `@ayepi/updown` shutdown hook).
111
+
112
+ ```ts
113
+ import { createLogger } from '@ayepi/log'
114
+ import { fileTransport } from '@ayepi/log/file'
115
+
116
+ const log = createLogger({
117
+ structured: true,
118
+ transports: [fileTransport({ path: './logs/app.log', maxSize: 10 * 1024 * 1024, maxFiles: 7 })],
119
+ filter: (r) => (r.secret ? null : { ...r, ip: redact(r.ip) }), // drop or transform before formatting
120
+ })
121
+ ```
122
+
123
+ ## Console interception (opt-in)
124
+
125
+ ```ts
126
+ import { interceptConsole, createLogger } from '@ayepi/log'
127
+
128
+ createLogger({ interceptConsole: true }) // or: const restore = interceptConsole()
129
+ console.log('routed', { through: 'the logger' }) // log/info/debug/warn/error/trace/dir
130
+ ```
131
+
132
+ ## Sanitization — `sanitize`
133
+
134
+ Declarative redaction + truncation on every record (direct calls **and** intercepted
135
+ `console.*`). Mask by key or value, cap string/array sizes, or drop a record outright:
136
+
137
+ ```ts
138
+ import { createLogger, partialMask } from '@ayepi/log'
139
+
140
+ createLogger({
141
+ sanitize: {
142
+ filter: (r) => r.level !== 'debug', // drop a record entirely
143
+ sensitiveKeys: ['password', /token$/i], // mask matching property names (any depth)
144
+ sensitiveValues: [/\b\d{16}\b/], // mask matching string values
145
+ mask: partialMask(3), // 'secret-token' → 'sec***' (default: '[redacted]')
146
+ maxStringLength: 2000, // long string → 'first 2000…... (+N more)'
147
+ maxArrayLength: 100, // big homogeneous array → first 100 + '(+N more)'
148
+ },
149
+ })
150
+ ```
151
+
152
+ `createSanitizer(opts)` builds the same transformer standalone (it has the `filter` shape, so
153
+ it composes there too). `Date`/class instances and the reserved `tms`/`level` fields are left
154
+ untouched.
155
+
156
+ ## Deferred arguments — `logMaybe`
157
+
158
+ Compute an expensive log argument **only if the line will actually be logged**. `logMaybe(fn)`
159
+ defers `fn` until the level passes the threshold; the intercepted/structured pipeline then
160
+ calls `fn(level)`, awaits it (async allowed), and treats the result as a normal argument:
161
+
162
+ ```ts
163
+ import { logMaybe } from '@ayepi/log'
164
+
165
+ log.debug('state', logMaybe(() => buildExpensiveSnapshot())) // snapshot built only at debug level
166
+ ```
167
+
168
+ A line below the threshold never runs `fn`. Outside interception, the value renders via
169
+ `toJSON` (the sync value, or `'(unresolved value)'` for a promise).
170
+
171
+ ## Middleware — `@ayepi/log/middleware` + `@ayepi/log/server`
172
+
173
+ The ayepi middleware is a **def/impl split**. The **def** is a frontend-safe contract
174
+ (no `node:async_hooks`) declared in your spec; the **impl** binds the trace-context
175
+ behavior on the server. Push per-request trace context for the whole chain + handler:
176
+
177
+ ```ts
178
+ // shared.ts — frontend-safe def (no node:async_hooks)
179
+ import { logMiddleware } from '@ayepi/log/middleware'
180
+
181
+ const trace = logMiddleware({ requires: [auth] }) // ctx.user is typed in .server below
182
+ const api = spec({ endpoints: { ...trace.group({ … }) } })
183
+ ```
184
+
185
+ ```ts
186
+ // server.ts — bind the impl (pulls in node:async_hooks)
187
+ import { logMiddleware } from '@ayepi/log/server'
188
+ import { implement } from '@ayepi/core'
189
+
190
+ const server = implement(api)
191
+ .middleware(logMiddleware.server(trace, {
192
+ context: (ctx, req) => ({ reqId: crypto.randomUUID(), userId: ctx.user.id, path: new URL(req.url).pathname }),
193
+ }))
194
+ .handlers({ … })
195
+ ```
196
+
197
+ `logMiddleware(opts?)` is a **def factory** (`opts = { name?, requires? }`) that
198
+ establishes log trace context for the downstream chain. `logMiddleware.server(def, {
199
+ context, logWith? })` — exported from `@ayepi/log/server` — binds it; the `context`
200
+ builder `(ctx, req) => object` lives here, on the server side.
201
+
202
+ ## For AI coding agents
203
+
204
+ This package ships dense, machine-oriented reference docs written for **AI coding agents**
205
+ (Claude Code, Cursor, and the like) to understand and drive the package — point your agent at them:
206
+
207
+ - [`ayepi-log-errors-console.md`](./ayepi-log-errors-console.md)
208
+ - [`ayepi-log-middleware.md`](./ayepi-log-middleware.md)
209
+ - [`ayepi-log-transports.md`](./ayepi-log-transports.md)
210
+ - [`ayepi-log.md`](./ayepi-log.md)
211
+
212
+ They live next to the source in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/log) and are **not** shipped in the npm tarball.
213
+
214
+ ## License
215
+
216
+ MIT © Philip Diffenderfer
package/dist/file.cjs ADDED
@@ -0,0 +1,175 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_internal = require("./internal.cjs");
3
+ let node_fs_promises = require("node:fs/promises");
4
+ let node_path = require("node:path");
5
+ //#region src/file.ts
6
+ /**
7
+ * # @ayepi/log/file
8
+ *
9
+ * A Node file {@link Transport} with rotation, built for **heavy load**: `write()`
10
+ * is non-blocking (it appends to an in-memory buffer and returns immediately), and
11
+ * buffered lines are flushed to disk in **batches** — one append per flush, at most
12
+ * one flush in flight — so callers never wait on I/O and the file system is not
13
+ * overwhelmed by a syscall per line.
14
+ *
15
+ * Everything touching the file system is **asynchronous** (`node:fs/promises`),
16
+ * including rotation/stat/prune, so a flush never blocks the event loop.
17
+ *
18
+ * **Size** rotation (default) keeps `app.log` bounded to `maxSize`, shifting
19
+ * `app.log → app.log.1 → …` and pruning beyond `maxFiles`. **Date** rotation writes
20
+ * `app-YYYY-MM-DD.log`. Defaults to structured JSON lines. Call `close()` (e.g. from
21
+ * an `@ayepi/updown` shutdown hook) to flush the buffer on exit.
22
+ *
23
+ * `fs` and the clock are injectable for deterministic tests.
24
+ *
25
+ * @module
26
+ */
27
+ /** Rotate when the active file would exceed this size (10 MiB). */
28
+ const DEFAULT_MAX_SIZE = 10 * 1024 * 1024;
29
+ /** Keep at most this many rotated/dated files. */
30
+ const DEFAULT_MAX_FILES = 5;
31
+ /** Flush the buffer at most this often (ms). */
32
+ const DEFAULT_FLUSH_INTERVAL = 250;
33
+ /** Force an immediate flush once the buffer reaches this many bytes. */
34
+ const DEFAULT_MAX_BUFFER_BYTES = 256 * 1024;
35
+ const NEWLINE = "\n";
36
+ /** `YYYY-MM-DD` length from an ISO string. */
37
+ const DATE_KEY_LEN = 10;
38
+ const exists = async (p) => {
39
+ try {
40
+ await (0, node_fs_promises.access)(p);
41
+ return true;
42
+ } catch {
43
+ return false;
44
+ }
45
+ };
46
+ const nodeFs = {
47
+ exists,
48
+ stat: (p) => (0, node_fs_promises.stat)(p).then((s) => ({ size: s.size })),
49
+ mkdir: (p) => (0, node_fs_promises.mkdir)(p, { recursive: true }).then(() => void 0),
50
+ appendFile: (p, d) => (0, node_fs_promises.appendFile)(p, d),
51
+ rename: (a, b) => (0, node_fs_promises.rename)(a, b),
52
+ unlink: (p) => (0, node_fs_promises.unlink)(p),
53
+ readdir: (p) => (0, node_fs_promises.readdir)(p)
54
+ };
55
+ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
56
+ /** Create a non-blocking, batched Node file {@link Transport}. */
57
+ function fileTransport(opts) {
58
+ const fs = opts.fs ?? nodeFs;
59
+ const maxSize = opts.maxSize ?? DEFAULT_MAX_SIZE;
60
+ const maxFiles = opts.maxFiles ?? DEFAULT_MAX_FILES;
61
+ const structured = opts.structured ?? true;
62
+ const strategy = opts.strategy ?? "size";
63
+ const flushInterval = opts.flushInterval ?? DEFAULT_FLUSH_INTERVAL;
64
+ const maxBufferBytes = opts.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
65
+ const now = opts.now ?? (() => Date.now());
66
+ const dir = (0, node_path.dirname)(opts.path);
67
+ const ext = (0, node_path.extname)(opts.path);
68
+ const base = (0, node_path.basename)(opts.path, ext);
69
+ let dirEnsured = false;
70
+ let lastDateKey = null;
71
+ let knownSize = -1;
72
+ let buffer = [];
73
+ let bufferBytes = 0;
74
+ let flushing = false;
75
+ let timer = null;
76
+ const ensureDir = async () => {
77
+ if (dirEnsured) return;
78
+ if (dir && !await fs.exists(dir)) await fs.mkdir(dir, { recursive: true });
79
+ dirEnsured = true;
80
+ };
81
+ const dateKey = () => new Date(now()).toISOString().slice(0, DATE_KEY_LEN);
82
+ const datedPath = (key) => (0, node_path.join)(dir, `${base}-${key}${ext}`);
83
+ /** Size rotation: drop the oldest, shift `.n → .n+1`, then `app.log → app.log.1`. */
84
+ const rotateBySize = async (path) => {
85
+ const oldest = `${path}.${maxFiles}`;
86
+ if (await fs.exists(oldest)) try {
87
+ await fs.unlink(oldest);
88
+ } catch {}
89
+ for (let i = maxFiles - 1; i >= 1; i--) if (await fs.exists(`${path}.${i}`)) await fs.rename(`${path}.${i}`, `${path}.${i + 1}`);
90
+ if (await fs.exists(path)) await fs.rename(path, `${path}.1`);
91
+ };
92
+ const pruneDated = async () => {
93
+ if (!fs.readdir) return;
94
+ const re = new RegExp(`^${escapeRegExp(base)}-(\\d{4}-\\d{2}-\\d{2})${escapeRegExp(ext)}$`);
95
+ try {
96
+ /* v8 ignore next */ const files = (await fs.readdir(dir || ".")).filter((f) => re.test(f)).sort();
97
+ while (files.length > maxFiles) {
98
+ const old = files.shift();
99
+ try {
100
+ await fs.unlink((0, node_path.join)(dir, old));
101
+ } catch {}
102
+ }
103
+ } catch {}
104
+ };
105
+ const currentSize = async (path) => {
106
+ if (!await fs.exists(path)) return 0;
107
+ try {
108
+ return (await fs.stat(path)).size;
109
+ } catch {
110
+ return 0;
111
+ }
112
+ };
113
+ async function flush() {
114
+ if (flushing || buffer.length === 0) return;
115
+ flushing = true;
116
+ if (timer) {
117
+ clearTimeout(timer);
118
+ timer = null;
119
+ }
120
+ try {
121
+ await ensureDir();
122
+ const batch = buffer.join("");
123
+ const batchBytes = bufferBytes;
124
+ buffer = [];
125
+ bufferBytes = 0;
126
+ if (strategy === "date") {
127
+ const key = dateKey();
128
+ if (key !== lastDateKey) {
129
+ lastDateKey = key;
130
+ await pruneDated();
131
+ }
132
+ await fs.appendFile(datedPath(key), batch);
133
+ } else {
134
+ if (knownSize < 0) knownSize = await currentSize(opts.path);
135
+ if (knownSize > 0 && knownSize + batchBytes > maxSize) {
136
+ await rotateBySize(opts.path);
137
+ knownSize = 0;
138
+ }
139
+ await fs.appendFile(opts.path, batch);
140
+ knownSize += batchBytes;
141
+ }
142
+ } catch (err) {
143
+ try {
144
+ opts.onError?.(err);
145
+ } catch {}
146
+ } finally {
147
+ flushing = false;
148
+ if (buffer.length > 0) scheduleFlush();
149
+ }
150
+ }
151
+ function scheduleFlush() {
152
+ if (timer || flushing) return;
153
+ timer = setTimeout(() => {
154
+ timer = null;
155
+ flush();
156
+ }, flushInterval);
157
+ timer.unref?.();
158
+ }
159
+ return {
160
+ name: "file",
161
+ write(record, text) {
162
+ const line = (structured ? require_internal.formatJson(record) : text) + NEWLINE;
163
+ buffer.push(line);
164
+ bufferBytes += Buffer.byteLength(line);
165
+ if (bufferBytes >= maxBufferBytes) flush();
166
+ else scheduleFlush();
167
+ },
168
+ flush,
169
+ async close() {
170
+ await flush();
171
+ }
172
+ };
173
+ }
174
+ //#endregion
175
+ exports.fileTransport = fileTransport;
@@ -0,0 +1,50 @@
1
+ import { d as Transport } from "./internal.cjs";
2
+
3
+ //#region src/file.d.ts
4
+
5
+ /** The minimal **async** fs surface the transport uses (`node:fs/promises` satisfies it; tests inject their own). */
6
+ interface FsLike {
7
+ exists(path: string): Promise<boolean>;
8
+ stat(path: string): Promise<{
9
+ size: number;
10
+ }>;
11
+ mkdir(path: string, opts: {
12
+ recursive: true;
13
+ }): Promise<void>;
14
+ /** Asynchronous, batched append — the hot path. */
15
+ appendFile(path: string, data: string): Promise<void>;
16
+ rename(from: string, to: string): Promise<void>;
17
+ unlink(path: string): Promise<void>;
18
+ readdir?(path: string): Promise<string[]>;
19
+ }
20
+ /** Options for {@link fileTransport}. */
21
+ interface FileTransportOptions {
22
+ /** Target file path (e.g. `'./logs/app.log'`). The directory is created if missing. */
23
+ readonly path: string;
24
+ /** Rotate when the active file would exceed this many bytes (default 10 MiB). */
25
+ readonly maxSize?: number;
26
+ /** Keep at most this many rotated/dated files (default 5). */
27
+ readonly maxFiles?: number;
28
+ /** Write structured JSON lines regardless of the logger's text/json setting (default `true`). */
29
+ readonly structured?: boolean;
30
+ /** Rotation strategy (default `'size'`). */
31
+ readonly strategy?: 'size' | 'date';
32
+ /** Flush the buffer at most this often, in ms (default 250). */
33
+ readonly flushInterval?: number;
34
+ /** Force an immediate flush once the buffer reaches this many bytes (default 256 KiB). */
35
+ readonly maxBufferBytes?: number;
36
+ /** Injected fs (default `node:fs/promises`). */
37
+ readonly fs?: FsLike;
38
+ /**
39
+ * Observe a background **flush** failure (disk full, permission denied, rotation error).
40
+ * File logging is best-effort — a failed flush is dropped and never rejects; this hook lets
41
+ * you notice. Off by default. It must not throw; if it does, the throw is ignored.
42
+ */
43
+ readonly onError?: (err: unknown) => void;
44
+ /** Injected clock for date rotation/naming (default `() => Date.now()`). */
45
+ readonly now?: () => number;
46
+ }
47
+ /** Create a non-blocking, batched Node file {@link Transport}. */
48
+ declare function fileTransport(opts: FileTransportOptions): Transport;
49
+ //#endregion
50
+ export { FileTransportOptions, FsLike, fileTransport };
package/dist/file.d.ts ADDED
@@ -0,0 +1,50 @@
1
+ import { d as Transport } from "./internal.js";
2
+
3
+ //#region src/file.d.ts
4
+
5
+ /** The minimal **async** fs surface the transport uses (`node:fs/promises` satisfies it; tests inject their own). */
6
+ interface FsLike {
7
+ exists(path: string): Promise<boolean>;
8
+ stat(path: string): Promise<{
9
+ size: number;
10
+ }>;
11
+ mkdir(path: string, opts: {
12
+ recursive: true;
13
+ }): Promise<void>;
14
+ /** Asynchronous, batched append — the hot path. */
15
+ appendFile(path: string, data: string): Promise<void>;
16
+ rename(from: string, to: string): Promise<void>;
17
+ unlink(path: string): Promise<void>;
18
+ readdir?(path: string): Promise<string[]>;
19
+ }
20
+ /** Options for {@link fileTransport}. */
21
+ interface FileTransportOptions {
22
+ /** Target file path (e.g. `'./logs/app.log'`). The directory is created if missing. */
23
+ readonly path: string;
24
+ /** Rotate when the active file would exceed this many bytes (default 10 MiB). */
25
+ readonly maxSize?: number;
26
+ /** Keep at most this many rotated/dated files (default 5). */
27
+ readonly maxFiles?: number;
28
+ /** Write structured JSON lines regardless of the logger's text/json setting (default `true`). */
29
+ readonly structured?: boolean;
30
+ /** Rotation strategy (default `'size'`). */
31
+ readonly strategy?: 'size' | 'date';
32
+ /** Flush the buffer at most this often, in ms (default 250). */
33
+ readonly flushInterval?: number;
34
+ /** Force an immediate flush once the buffer reaches this many bytes (default 256 KiB). */
35
+ readonly maxBufferBytes?: number;
36
+ /** Injected fs (default `node:fs/promises`). */
37
+ readonly fs?: FsLike;
38
+ /**
39
+ * Observe a background **flush** failure (disk full, permission denied, rotation error).
40
+ * File logging is best-effort — a failed flush is dropped and never rejects; this hook lets
41
+ * you notice. Off by default. It must not throw; if it does, the throw is ignored.
42
+ */
43
+ readonly onError?: (err: unknown) => void;
44
+ /** Injected clock for date rotation/naming (default `() => Date.now()`). */
45
+ readonly now?: () => number;
46
+ }
47
+ /** Create a non-blocking, batched Node file {@link Transport}. */
48
+ declare function fileTransport(opts: FileTransportOptions): Transport;
49
+ //#endregion
50
+ export { FileTransportOptions, FsLike, fileTransport };
package/dist/file.js ADDED
@@ -0,0 +1,174 @@
1
+ import { d as formatJson } from "./internal.js";
2
+ import { access, appendFile, mkdir, readdir, rename, stat, unlink } from "node:fs/promises";
3
+ import { basename, dirname, extname, join } from "node:path";
4
+ //#region src/file.ts
5
+ /**
6
+ * # @ayepi/log/file
7
+ *
8
+ * A Node file {@link Transport} with rotation, built for **heavy load**: `write()`
9
+ * is non-blocking (it appends to an in-memory buffer and returns immediately), and
10
+ * buffered lines are flushed to disk in **batches** — one append per flush, at most
11
+ * one flush in flight — so callers never wait on I/O and the file system is not
12
+ * overwhelmed by a syscall per line.
13
+ *
14
+ * Everything touching the file system is **asynchronous** (`node:fs/promises`),
15
+ * including rotation/stat/prune, so a flush never blocks the event loop.
16
+ *
17
+ * **Size** rotation (default) keeps `app.log` bounded to `maxSize`, shifting
18
+ * `app.log → app.log.1 → …` and pruning beyond `maxFiles`. **Date** rotation writes
19
+ * `app-YYYY-MM-DD.log`. Defaults to structured JSON lines. Call `close()` (e.g. from
20
+ * an `@ayepi/updown` shutdown hook) to flush the buffer on exit.
21
+ *
22
+ * `fs` and the clock are injectable for deterministic tests.
23
+ *
24
+ * @module
25
+ */
26
+ /** Rotate when the active file would exceed this size (10 MiB). */
27
+ const DEFAULT_MAX_SIZE = 10 * 1024 * 1024;
28
+ /** Keep at most this many rotated/dated files. */
29
+ const DEFAULT_MAX_FILES = 5;
30
+ /** Flush the buffer at most this often (ms). */
31
+ const DEFAULT_FLUSH_INTERVAL = 250;
32
+ /** Force an immediate flush once the buffer reaches this many bytes. */
33
+ const DEFAULT_MAX_BUFFER_BYTES = 256 * 1024;
34
+ const NEWLINE = "\n";
35
+ /** `YYYY-MM-DD` length from an ISO string. */
36
+ const DATE_KEY_LEN = 10;
37
+ const exists = async (p) => {
38
+ try {
39
+ await access(p);
40
+ return true;
41
+ } catch {
42
+ return false;
43
+ }
44
+ };
45
+ const nodeFs = {
46
+ exists,
47
+ stat: (p) => stat(p).then((s) => ({ size: s.size })),
48
+ mkdir: (p) => mkdir(p, { recursive: true }).then(() => void 0),
49
+ appendFile: (p, d) => appendFile(p, d),
50
+ rename: (a, b) => rename(a, b),
51
+ unlink: (p) => unlink(p),
52
+ readdir: (p) => readdir(p)
53
+ };
54
+ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
55
+ /** Create a non-blocking, batched Node file {@link Transport}. */
56
+ function fileTransport(opts) {
57
+ const fs = opts.fs ?? nodeFs;
58
+ const maxSize = opts.maxSize ?? DEFAULT_MAX_SIZE;
59
+ const maxFiles = opts.maxFiles ?? DEFAULT_MAX_FILES;
60
+ const structured = opts.structured ?? true;
61
+ const strategy = opts.strategy ?? "size";
62
+ const flushInterval = opts.flushInterval ?? DEFAULT_FLUSH_INTERVAL;
63
+ const maxBufferBytes = opts.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
64
+ const now = opts.now ?? (() => Date.now());
65
+ const dir = dirname(opts.path);
66
+ const ext = extname(opts.path);
67
+ const base = basename(opts.path, ext);
68
+ let dirEnsured = false;
69
+ let lastDateKey = null;
70
+ let knownSize = -1;
71
+ let buffer = [];
72
+ let bufferBytes = 0;
73
+ let flushing = false;
74
+ let timer = null;
75
+ const ensureDir = async () => {
76
+ if (dirEnsured) return;
77
+ if (dir && !await fs.exists(dir)) await fs.mkdir(dir, { recursive: true });
78
+ dirEnsured = true;
79
+ };
80
+ const dateKey = () => new Date(now()).toISOString().slice(0, DATE_KEY_LEN);
81
+ const datedPath = (key) => join(dir, `${base}-${key}${ext}`);
82
+ /** Size rotation: drop the oldest, shift `.n → .n+1`, then `app.log → app.log.1`. */
83
+ const rotateBySize = async (path) => {
84
+ const oldest = `${path}.${maxFiles}`;
85
+ if (await fs.exists(oldest)) try {
86
+ await fs.unlink(oldest);
87
+ } catch {}
88
+ for (let i = maxFiles - 1; i >= 1; i--) if (await fs.exists(`${path}.${i}`)) await fs.rename(`${path}.${i}`, `${path}.${i + 1}`);
89
+ if (await fs.exists(path)) await fs.rename(path, `${path}.1`);
90
+ };
91
+ const pruneDated = async () => {
92
+ if (!fs.readdir) return;
93
+ const re = new RegExp(`^${escapeRegExp(base)}-(\\d{4}-\\d{2}-\\d{2})${escapeRegExp(ext)}$`);
94
+ try {
95
+ /* v8 ignore next */ const files = (await fs.readdir(dir || ".")).filter((f) => re.test(f)).sort();
96
+ while (files.length > maxFiles) {
97
+ const old = files.shift();
98
+ try {
99
+ await fs.unlink(join(dir, old));
100
+ } catch {}
101
+ }
102
+ } catch {}
103
+ };
104
+ const currentSize = async (path) => {
105
+ if (!await fs.exists(path)) return 0;
106
+ try {
107
+ return (await fs.stat(path)).size;
108
+ } catch {
109
+ return 0;
110
+ }
111
+ };
112
+ async function flush() {
113
+ if (flushing || buffer.length === 0) return;
114
+ flushing = true;
115
+ if (timer) {
116
+ clearTimeout(timer);
117
+ timer = null;
118
+ }
119
+ try {
120
+ await ensureDir();
121
+ const batch = buffer.join("");
122
+ const batchBytes = bufferBytes;
123
+ buffer = [];
124
+ bufferBytes = 0;
125
+ if (strategy === "date") {
126
+ const key = dateKey();
127
+ if (key !== lastDateKey) {
128
+ lastDateKey = key;
129
+ await pruneDated();
130
+ }
131
+ await fs.appendFile(datedPath(key), batch);
132
+ } else {
133
+ if (knownSize < 0) knownSize = await currentSize(opts.path);
134
+ if (knownSize > 0 && knownSize + batchBytes > maxSize) {
135
+ await rotateBySize(opts.path);
136
+ knownSize = 0;
137
+ }
138
+ await fs.appendFile(opts.path, batch);
139
+ knownSize += batchBytes;
140
+ }
141
+ } catch (err) {
142
+ try {
143
+ opts.onError?.(err);
144
+ } catch {}
145
+ } finally {
146
+ flushing = false;
147
+ if (buffer.length > 0) scheduleFlush();
148
+ }
149
+ }
150
+ function scheduleFlush() {
151
+ if (timer || flushing) return;
152
+ timer = setTimeout(() => {
153
+ timer = null;
154
+ flush();
155
+ }, flushInterval);
156
+ timer.unref?.();
157
+ }
158
+ return {
159
+ name: "file",
160
+ write(record, text) {
161
+ const line = (structured ? formatJson(record) : text) + NEWLINE;
162
+ buffer.push(line);
163
+ bufferBytes += Buffer.byteLength(line);
164
+ if (bufferBytes >= maxBufferBytes) flush();
165
+ else scheduleFlush();
166
+ },
167
+ flush,
168
+ async close() {
169
+ await flush();
170
+ }
171
+ };
172
+ }
173
+ //#endregion
174
+ export { fileTransport };