@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 +21 -0
- package/README.md +216 -0
- package/dist/file.cjs +175 -0
- package/dist/file.d.cts +50 -0
- package/dist/file.d.ts +50 -0
- package/dist/file.js +174 -0
- package/dist/index.cjs +239 -0
- package/dist/index.d.cts +125 -0
- package/dist/index.d.ts +125 -0
- package/dist/index.js +218 -0
- package/dist/internal.cjs +546 -0
- package/dist/internal.d.cts +153 -0
- package/dist/internal.d.ts +153 -0
- package/dist/internal.js +415 -0
- package/dist/middleware.cjs +38 -0
- package/dist/middleware.d.cts +27 -0
- package/dist/middleware.d.ts +27 -0
- package/dist/middleware.js +37 -0
- package/dist/server.cjs +40 -0
- package/dist/server.d.cts +36 -0
- package/dist/server.d.ts +36 -0
- package/dist/server.js +39 -0
- package/package.json +100 -0
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;
|
package/dist/file.d.cts
ADDED
|
@@ -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 };
|