From 965dbdd4c249e5ea8bef17690e7a2f4884ea7ea2 Mon Sep 17 00:00:00 2001 From: Sam Curry Date: Mon, 31 Aug 2026 10:14:05 +0800 Subject: [PATCH 01/10] fix: log a DOMException instead of throwing from redaction `AbortSignal.timeout()` rejects with a `DOMException`, and logging one threw a TypeError out of the `logger.error(...)` call itself, so the caller lost the log line and everything it meant to do next. `redactValues` deep-clones the record before it redacts it. es-toolkit clones an `Error` with `structuredClone` and then re-assigns `message` and `name`, but `structuredClone` rebuilds a `DOMException` as a `DOMException`, whose `message` and `name` are getter-only prototype accessors. The assignment throws. It is specific to `DOMException`: `URL`, `Headers`, `AbortSignal`, `Request`, `Response`, `Error` and `AggregateError` all clone without complaint. Substitute the plain object `serializeError` already builds before cloning, and carry own symbols across so a `DOMException` given to the logger as the whole record keeps winston's `LEVEL` and `SPLAT` routing symbols. `serializeErrorFormat` now walks `SPLAT` as well. Winston keeps the raw metadata argument there in addition to merging its properties onto `info`, so an `Error` logged as `logger.error(msg, { error })` is reachable twice. Serializing only the string keys left the live `Error` under `SPLAT` for every later format to trip over, which is how the `DOMException` reached the clone. It also covers `logger.error('failed: %j', { error })`, where winston builds `info` from `SPLAT` alone. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 14 ++++++++ package.json | 2 +- src/index.spec.ts | 57 ++++++++++++++++++++++++++++++ src/redact-values.spec.ts | 40 +++++++++++++++++++++ src/redact-values.ts | 26 ++++++++++++-- src/serialize-error-format.spec.ts | 22 +++++++++++- src/serialize-error-format.ts | 10 +++++- 7 files changed, 166 insertions(+), 5 deletions(-) create mode 100644 src/redact-values.spec.ts diff --git a/README.md b/README.md index 56ea162..0f9abac 100644 --- a/README.md +++ b/README.md @@ -331,6 +331,20 @@ const logger = createLogger({ }) ``` +#### `DOMException` + +`AbortSignal.timeout()` and `AbortSignal.abort()` reject with a `DOMException`, so it is what a `catch` block receives whenever a fetch, a stream or a job is abandoned on a deadline. It needs no special treatment: log it like any other error. + +```ts +try { + await fetch(url, { signal: AbortSignal.timeout(5_000) }) +} catch (error) { + logger.error('delivery failed', { error }) // { error: { name: 'TimeoutError', message: ..., stack: ... } } +} +``` + +A `DOMException` keeps `message` and `name` as getter-only accessors on its prototype, which makes it the one error type a deep clone cannot rebuild. The library substitutes a plain object before it clones the log record, so `redactPaths` handles a `DOMException` like any other value. + For direct format usage, `serializeErrorFormat` accepts the same override and `createSerializableErrorReplacer(serializer)` builds a matching JSON replacer: ```ts diff --git a/package.json b/package.json index 0a21664..01780a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@makerx/node-winston", - "version": "2.0.2", + "version": "2.0.3", "private": false, "description": "A set of winston formats, console transport and logger creation functions", "author": "MakerX", diff --git a/src/index.spec.ts b/src/index.spec.ts index e6f919f..9dfbdc8 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -383,6 +383,63 @@ describe('createLogger mapAuditLevelForOtel', () => { }) }) +describe('createLogger DOMException', () => { + // `AbortSignal.timeout()` rejects with a `DOMException`, whose `message` and `name` are + // getter-only prototype accessors. A deep clone cannot rebuild one, so redaction used to throw a + // TypeError out of the log call itself, costing the caller the log line and everything after it. + const aborted = () => new DOMException('the operation timed out', 'TimeoutError') + + it('logs one held in metadata, at any depth', () => { + const transport = new InMemoryTransport({}) + const logger = createLogger({ + consoleOptions: { silent: true }, + transports: [transport], + redactPaths: ['authorization'], + }) + + logger.error('flat', { error: aborted() }) + logger.error('nested', { context: { error: aborted() } }) + logger.error('in an array', { errors: [aborted()] }) + + expect(transport.logs.length).toBe(3) + expect(transport.logs[0].error).toMatchObject({ name: 'TimeoutError', message: 'the operation timed out' }) + expect(transport.logs[0].error.stack).toBeDefined() + expect(transport.logs[1].context.error).toMatchObject({ name: 'TimeoutError' }) + expect(transport.logs[2].errors[0]).toMatchObject({ name: 'TimeoutError' }) + }) + + it('logs one passed as the whole record', () => { + const transport = new InMemoryTransport({}) + const logger = createLogger({ + consoleOptions: { silent: true }, + transports: [transport], + redactPaths: ['authorization'], + }) + + logger.error(aborted() as unknown as string) + + expect(transport.logs[0]).toMatchObject({ name: 'TimeoutError', message: 'the operation timed out' }) + }) + + it('logs one with every logger-level format enabled', () => { + const transport = new InMemoryTransport({}) + const logger = createLogger({ + consoleOptions: { silent: true }, + transports: [transport], + omitPaths: ['service'], + redactPaths: ['authorization'], + mapAuditLevelForOtel: true, + flatten: true, + }) + + logger.error('everything on', { service: 'svc', authorization: 'Bearer abc', error: aborted() }) + + expect(transport.logs[0].service).toBeUndefined() + expect(transport.logs[0].authorization).toBe('') + expect(transport.logs[0].error).toContain('TimeoutError') + }) +}) + class InMemoryTransport extends TransportStream { // eslint-disable-next-line @typescript-eslint/no-explicit-any logs: Record[] diff --git a/src/redact-values.spec.ts b/src/redact-values.spec.ts new file mode 100644 index 0000000..a1f9b7b --- /dev/null +++ b/src/redact-values.spec.ts @@ -0,0 +1,40 @@ +import { LEVEL } from 'triple-beam' +import { describe, expect, it } from 'vitest' +import { redactValues } from './redact-values' + +describe('redactValues', () => { + it('clones a DOMException instead of failing on its getter-only properties', () => { + const aborted = new DOMException('the operation timed out', 'TimeoutError') + + const result = redactValues({ error: aborted }, 'authorization') as { error: Record } + + expect(result.error).toMatchObject({ + name: 'TimeoutError', + message: 'the operation timed out', + stack: aborted.stack, + }) + }) + + it('redacts inside a DOMException', () => { + const aborted = Object.assign(new DOMException('the operation timed out', 'TimeoutError'), { + authorization: 'Bearer secret', + }) + + const result = redactValues({ error: aborted }, 'authorization') as { error: Record } + + expect(result.error.authorization).toBe('') + expect(aborted.authorization).toBe('Bearer secret') + }) + + it('keeps the symbol properties of a DOMException logged as the whole record', () => { + const info = Object.assign(new DOMException('the operation timed out', 'TimeoutError'), { + [LEVEL]: 'error', + level: 'error', + }) + + const result = redactValues(info, 'authorization') as Record + + expect(result).toMatchObject({ name: 'TimeoutError', message: 'the operation timed out', level: 'error' }) + expect(result[LEVEL]).toBe('error') + }) +}) diff --git a/src/redact-values.ts b/src/redact-values.ts index b9b8dd9..396a167 100644 --- a/src/redact-values.ts +++ b/src/redact-values.ts @@ -1,4 +1,26 @@ -import { cloneDeep, forOwn, get, isNil, isObject, set } from 'es-toolkit/compat' +import { cloneDeepWith, forOwn, get, isNil, isObject, set } from 'es-toolkit/compat' +import { serializeError } from './serialize-error' + +// A `DOMException` — what `AbortSignal.timeout()` rejects with — cannot survive a deep clone. +// es-toolkit clones an `Error` with `structuredClone` and then re-assigns `message` and `name`, but +// `structuredClone` rebuilds a `DOMException` as a `DOMException`, whose `message` and `name` are +// getter-only prototype accessors, so the assignment throws a `TypeError`. Thrown from inside a +// format, that `TypeError` comes out of the `logger.error(...)` call itself: the caller loses the +// log line and everything it meant to do after it. Substitute the plain, already-cycle-safe object +// `serializeError` builds, which holds the same facts and clones without complaint. +const plainDomException = (error: DOMException): Record => { + const plain = serializeError(error) as Record + // `serializeError` walks string keys only. Carry own symbols across so a `DOMException` given to + // the logger as the whole record keeps winston's `LEVEL` and `SPLAT` routing symbols. + for (const symbol of Object.getOwnPropertySymbols(error)) { + plain[symbol] = (error as unknown as Record)[symbol] + } + return plain +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const cloneForRedaction = (obj: any) => + cloneDeepWith(obj, (value) => (value instanceof DOMException ? plainDomException(value) : undefined)) // Expands a single path against the current node, supporting `[*]` to iterate every element of an // array segment. Without `[*]` it falls back to lodash-style get/set on a dot path. @@ -45,7 +67,7 @@ export const redactValuesWith = if (isObject(value)) redact(value) }) return current - })(cloneDeep(obj)) + })(cloneForRedaction(obj)) } export const redactValues = redactValuesWith('') diff --git a/src/serialize-error-format.spec.ts b/src/serialize-error-format.spec.ts index b4f46ba..8d9b0d0 100644 --- a/src/serialize-error-format.spec.ts +++ b/src/serialize-error-format.spec.ts @@ -1,5 +1,5 @@ import { TransformableInfo } from 'logform' -import { LEVEL } from 'triple-beam' +import { LEVEL, SPLAT } from 'triple-beam' import { describe, expect, it } from 'vitest' import { serializeErrorFormat, SerializeErrorFormatOptions } from './serialize-error-format' @@ -73,4 +73,24 @@ describe('serializeErrorFormat', () => { expect(result.a).toEqual({ value: 42 }) expect(result.b).toEqual({ value: 42 }) }) + + it('serialises errors held under the SPLAT symbol', () => { + const error = new Error('boom') + const input = { [LEVEL]: 'info', level: 'info', message: '', [SPLAT]: [{ error }] } as TransformableInfo + const fmt = serializeErrorFormat() + + const result = fmt.transform(input, fmt.options) as unknown as Record + + expect(result[SPLAT][0].error).not.toBeInstanceOf(Error) + expect(result[SPLAT][0].error.message).toBe('boom') + }) + + it('leaves a SPLAT holding no errors alone', () => { + const input = { [LEVEL]: 'info', level: 'info', message: '', [SPLAT]: ['a', 1] } as TransformableInfo + const fmt = serializeErrorFormat() + + const result = fmt.transform(input, fmt.options) as unknown as Record + + expect(result[SPLAT]).toEqual(['a', 1]) + }) }) diff --git a/src/serialize-error-format.ts b/src/serialize-error-format.ts index df7cdee..2ffbc00 100644 --- a/src/serialize-error-format.ts +++ b/src/serialize-error-format.ts @@ -1,4 +1,5 @@ import { TransformableInfo } from 'logform' +import { SPLAT } from 'triple-beam' import { format } from 'winston' import { ErrorSerializer, serializeError } from './serialize-error' @@ -18,6 +19,11 @@ export interface SerializeErrorFormatOptions { * Only the top-level `info` object is mutated (to preserve winston's Symbol-keyed * routing props); nested objects and arrays are rebuilt, so caller-supplied metadata * references are never mutated. + * + * The `SPLAT` symbol is walked as well. Winston keeps the raw metadata argument there in addition + * to merging its properties onto `info`, so an `Error` logged as `logger.error(msg, { error })` is + * reachable twice. Serializing only the string keys leaves the live `Error` under `SPLAT` for every + * later format to trip over. */ export const serializeErrorFormat = format((info, opts) => { const serializer = (opts as SerializeErrorFormatOptions | undefined)?.serializer ?? serializeError @@ -36,8 +42,10 @@ export const serializeErrorFormat = format((info, opts) => { seen.delete(value) } } - const record = info as unknown as Record + const record = info as unknown as Record const seen = new WeakSet([record]) for (const key of Object.keys(record)) record[key] = walk(record[key], seen) + const splat = record[SPLAT] + if (Array.isArray(splat)) record[SPLAT] = splat.map((value) => walk(value, seen)) return info as TransformableInfo }) From f1e811610775aca89160828a38e93d854b01dda2 Mon Sep 17 00:00:00 2001 From: Sam Curry Date: Mon, 31 Aug 2026 10:19:50 +0800 Subject: [PATCH 02/10] build: clear the audit advisories blocking CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npm run audit` reported 25 advisories across 8 packages, all of them dev-only (`npm audit --omit=dev` was already clean) and all reachable within the existing semver ranges. `npm audit fix` resolved seven: brace-expansion 5.0.5, 1.1.14 -> 5.0.9, 1.1.18 fast-uri 3.1.0 -> 3.1.6 js-yaml 4.1.1 -> 4.3.2 nanoid 3.3.11 -> 3.3.18 postcss 8.5.10 -> 8.5.26 shell-quote 1.8.3 -> 1.10.0 vite 8.0.8 -> 8.2.2 The eighth, esbuild, needed a direct bump: the advisory is fixed in 0.28.1, and `vite@8.2.2` already allows `^0.27.0 || ^0.28.0`, but `tsx@4.21.0` pins `~0.27.0`. `tsx@4.23.13` moves to `~0.28.0`, so esbuild resolves to 0.28.2. The `postcss` override floor moves from `^8.5.10` to `^8.5.23`, the first release outside the advisory range, so a resolution from scratch cannot land back below the fix. `.nsprc` stays empty — no advisory needed an exception. Co-Authored-By: Claude Opus 5 (1M context) --- package-lock.json | 706 +++++++++++++++++++++------------------------- package.json | 4 +- 2 files changed, 330 insertions(+), 380 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9b35e44..dcff776 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@makerx/node-winston", - "version": "2.0.2", + "version": "2.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@makerx/node-winston", - "version": "2.0.2", + "version": "2.0.3", "license": "MIT", "dependencies": { "@colors/colors": "^1.6.0", @@ -39,7 +39,7 @@ "rimraf": "^6.1.3", "rollup": "4.60.2", "tslib": "^2.8.1", - "tsx": "4.21.0", + "tsx": "4.23.13", "typescript": "^6.0.2", "vitest": "^4.1.4" }, @@ -236,44 +236,10 @@ "kuler": "^2.0.0" } }, - "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], @@ -288,9 +254,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -305,9 +271,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -322,9 +288,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], @@ -339,9 +305,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], @@ -356,9 +322,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], @@ -373,9 +339,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], @@ -390,9 +356,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], @@ -407,9 +373,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], @@ -424,9 +390,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], @@ -441,9 +407,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], @@ -458,9 +424,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], @@ -475,9 +441,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], @@ -492,9 +458,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], @@ -509,9 +475,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], @@ -526,9 +492,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], @@ -543,9 +509,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], @@ -560,9 +526,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], @@ -577,9 +543,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], @@ -594,9 +560,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], @@ -611,9 +577,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -628,9 +594,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -645,9 +611,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], @@ -662,9 +628,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], @@ -679,9 +645,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -696,9 +662,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -977,29 +943,10 @@ "node": ">=20.0" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", - "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, "node_modules/@oxc-project/types": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", - "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz", + "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", "dev": true, "license": "MIT", "funding": { @@ -1019,10 +966,27 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz", + "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", - "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz", + "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==", "cpu": [ "arm64" ], @@ -1037,9 +1001,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", - "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz", + "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==", "cpu": [ "arm64" ], @@ -1054,9 +1018,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", - "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz", + "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==", "cpu": [ "x64" ], @@ -1071,9 +1035,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", - "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz", + "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==", "cpu": [ "x64" ], @@ -1088,9 +1052,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", - "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz", + "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==", "cpu": [ "arm" ], @@ -1105,13 +1069,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz", + "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1122,13 +1089,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", - "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz", + "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1139,13 +1109,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz", + "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1156,13 +1129,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz", + "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1173,13 +1149,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz", + "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1190,13 +1169,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", - "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz", + "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1207,9 +1189,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", - "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz", + "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==", "cpu": [ "arm64" ], @@ -1223,29 +1205,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", - "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.9.2", - "@emnapi/runtime": "1.9.2", - "@napi-rs/wasm-runtime": "^1.1.3" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", - "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz", + "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==", "cpu": [ "arm64" ], @@ -1260,9 +1223,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", - "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz", + "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==", "cpu": [ "x64" ], @@ -1277,9 +1240,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", - "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -1787,17 +1750,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -2134,16 +2086,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { @@ -2619,9 +2571,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -3425,9 +3377,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3438,32 +3390,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/escalade": { @@ -3870,9 +3822,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", "dev": true, "funding": [ { @@ -4132,19 +4084,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-tsconfig": { - "version": "4.13.7", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", - "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -4907,10 +4846,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -4986,9 +4935,9 @@ } }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -5002,23 +4951,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -5037,9 +4986,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -5058,9 +5007,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -5079,9 +5028,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -5100,9 +5049,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -5121,13 +5070,16 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5142,13 +5094,16 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5163,13 +5118,16 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5184,13 +5142,16 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5205,9 +5166,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -5226,9 +5187,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -5550,9 +5511,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -6113,9 +6074,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -6159,9 +6120,9 @@ } }, "node_modules/postcss": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -6179,7 +6140,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -6381,16 +6342,6 @@ "node": ">=4" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, "node_modules/rimraf": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", @@ -6422,16 +6373,16 @@ } }, "node_modules/rimraf/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/rimraf/node_modules/glob": { @@ -6469,14 +6420,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", - "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz", + "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.124.0", - "@rolldown/pluginutils": "1.0.0-rc.15" + "@oxc-project/types": "=0.147.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -6485,21 +6436,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.15", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", - "@rolldown/binding-darwin-x64": "1.0.0-rc.15", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + "@rolldown/binding-android-arm-eabi": "1.2.6", + "@rolldown/binding-android-arm64": "1.2.6", + "@rolldown/binding-darwin-arm64": "1.2.6", + "@rolldown/binding-darwin-x64": "1.2.6", + "@rolldown/binding-freebsd-x64": "1.2.6", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", + "@rolldown/binding-linux-arm64-gnu": "1.2.6", + "@rolldown/binding-linux-arm64-musl": "1.2.6", + "@rolldown/binding-linux-ppc64-gnu": "1.2.6", + "@rolldown/binding-linux-s390x-gnu": "1.2.6", + "@rolldown/binding-linux-x64-gnu": "1.2.6", + "@rolldown/binding-linux-x64-musl": "1.2.6", + "@rolldown/binding-openharmony-arm64": "1.2.6", + "@rolldown/binding-win32-arm64-msvc": "1.2.6", + "@rolldown/binding-win32-x64-msvc": "1.2.6" } }, "node_modules/rollup": { @@ -6719,9 +6670,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", "dev": true, "license": "MIT", "engines": { @@ -7270,9 +7221,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -7327,14 +7278,13 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" + "esbuild": "~0.28.0" }, "bin": { "tsx": "dist/cli.mjs" @@ -7541,17 +7491,17 @@ } }, "node_modules/vite": { - "version": "8.0.8", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", - "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.15", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -7567,7 +7517,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", diff --git a/package.json b/package.json index 01780a6..29b61d1 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "rimraf": "^6.1.3", "rollup": "4.60.2", "tslib": "^2.8.1", - "tsx": "4.21.0", + "tsx": "4.23.13", "typescript": "^6.0.2", "vitest": "^4.1.4" }, @@ -82,6 +82,6 @@ "yamlify-object-colors": "^1.0.3" }, "overrides": { - "postcss": "^8.5.10" + "postcss": "^8.5.23" } } From 0df8681a3c3902fa7bc8ded310bfb36210e8fe9f Mon Sep 17 00:00:00 2001 From: Sam Curry Date: Mon, 31 Aug 2026 10:35:43 +0800 Subject: [PATCH 03/10] fix: address review of the DOMException handling Three findings from review, all confirmed against the code. The `SPLAT` walk was destructive. The main walk rebuilds every object it visits as a plain record, which is right for metadata bound for a transport but wrong for `SPLAT`: `format.splat()` interpolates those values into the message, and a `Date` rebuilt as a plain record holds no own enumerable keys, so `logger.info('%j', date)` rendered `{}` in place of the ISO value. Replace errors under `SPLAT` with a separate walk that recurses through arrays and plain objects only, rebuilds a container only when it really holds an error, and returns the same reference for anything untouched. Redaction ignored a configured `errorSerializer`. The substitution for a `DOMException` hard-coded `serializeError`, so with both `redactPaths` and an `errorSerializer` a `DOMException` reached the transports in the default shape while every other error used the consumer's. `redactValuesWith` now takes the serializer as an optional second argument, `redactFormat` forwards it, and `createLogger` passes the one it was given. The README misstated the platform API: `AbortSignal.timeout()` returns a signal, it does not reject. An operation cancelled through the signal rejects with the signal's `reason`, which is the `DOMException`. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 4 +-- src/index.spec.ts | 16 +++++++++ src/index.ts | 2 +- src/redact-format.ts | 13 ++++++-- src/redact-values.spec.ts | 10 +++++- src/redact-values.ts | 21 ++++++------ src/serialize-error-format.spec.ts | 30 +++++++++++++++++ src/serialize-error-format.ts | 52 +++++++++++++++++++++++++++--- 8 files changed, 128 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 0f9abac..8927da9 100644 --- a/README.md +++ b/README.md @@ -333,7 +333,7 @@ const logger = createLogger({ #### `DOMException` -`AbortSignal.timeout()` and `AbortSignal.abort()` reject with a `DOMException`, so it is what a `catch` block receives whenever a fetch, a stream or a job is abandoned on a deadline. It needs no special treatment: log it like any other error. +An `AbortSignal` carries a `DOMException` as its `reason`, and an operation cancelled through one rejects with that reason. A `DOMException` is therefore what a `catch` block receives whenever a fetch, a stream or a job is abandoned on a deadline. It needs no special treatment: log it like any other error. ```ts try { @@ -343,7 +343,7 @@ try { } ``` -A `DOMException` keeps `message` and `name` as getter-only accessors on its prototype, which makes it the one error type a deep clone cannot rebuild. The library substitutes a plain object before it clones the log record, so `redactPaths` handles a `DOMException` like any other value. +A `DOMException` keeps `message` and `name` as getter-only accessors on its prototype, which makes it the one error type a deep clone cannot rebuild. The library substitutes a serialized object before it clones the log record, using the configured `errorSerializer`, so `redactPaths` handles a `DOMException` like any other value. For direct format usage, `serializeErrorFormat` accepts the same override and `createSerializableErrorReplacer(serializer)` builds a matching JSON replacer: diff --git a/src/index.spec.ts b/src/index.spec.ts index 9dfbdc8..69cf0f6 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -438,6 +438,22 @@ describe('createLogger DOMException', () => { expect(transport.logs[0].authorization).toBe('') expect(transport.logs[0].error).toContain('TimeoutError') }) + + it('serialises one with a configured errorSerializer, wherever it sits in the record', () => { + const transport = new InMemoryTransport({}) + const logger = createLogger({ + consoleOptions: { silent: true }, + transports: [transport], + redactPaths: ['authorization'], + errorSerializer: (error) => ({ kind: error.name, detail: error.message }), + }) + + logger.error('nested', { error: aborted() }) + logger.error(aborted() as unknown as string) + + expect(transport.logs[0].error).toEqual({ kind: 'TimeoutError', detail: 'the operation timed out' }) + expect(transport.logs[1]).toMatchObject({ kind: 'TimeoutError', detail: 'the operation timed out' }) + }) }) class InMemoryTransport extends TransportStream { diff --git a/src/index.ts b/src/index.ts index b39f656..c41f4ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -270,7 +270,7 @@ export function createLogger(options: CreateLoggerOptions): any { const loggerFormats: Format[] = [serializeErrorFormat({ serializer: errorSerializer })] if (mapAuditForOtel) loggerFormats.push(mapAuditLevelForOtel()) if (omitPaths) loggerFormats.push(omitFormat({ paths: omitPaths })) - if (redactPaths) loggerFormats.push(redactFormat({ paths: redactPaths, redactedValue })) + if (redactPaths) loggerFormats.push(redactFormat({ paths: redactPaths, redactedValue, errorSerializer })) if (options.loggerOptions?.format) loggerFormats.push(options.loggerOptions.format) // flatten is applied last so all prior transformations are captured in the stringified values if (flatten) loggerFormats.push(jsonStringifyValuesFormat({ replacer: flattenReplacer })) diff --git a/src/redact-format.ts b/src/redact-format.ts index 1f4421d..24ca216 100644 --- a/src/redact-format.ts +++ b/src/redact-format.ts @@ -1,8 +1,17 @@ import { TransformableInfo } from 'logform' import { format } from 'winston' import { redactValuesWith } from './redact-values' +import { type ErrorSerializer } from './serialize-error' export const redactFormat = format((info, opts) => { - const { paths, redactedValue = '' } = opts as { paths: string[]; redactedValue?: string } - return redactValuesWith(redactedValue)(info, ...paths) as TransformableInfo + const { + paths, + redactedValue = '', + errorSerializer, + } = opts as { + paths: string[] + redactedValue?: string + errorSerializer?: ErrorSerializer + } + return redactValuesWith(redactedValue, errorSerializer)(info, ...paths) as TransformableInfo }) diff --git a/src/redact-values.spec.ts b/src/redact-values.spec.ts index a1f9b7b..458c3d9 100644 --- a/src/redact-values.spec.ts +++ b/src/redact-values.spec.ts @@ -1,6 +1,6 @@ import { LEVEL } from 'triple-beam' import { describe, expect, it } from 'vitest' -import { redactValues } from './redact-values' +import { redactValues, redactValuesWith } from './redact-values' describe('redactValues', () => { it('clones a DOMException instead of failing on its getter-only properties', () => { @@ -37,4 +37,12 @@ describe('redactValues', () => { expect(result).toMatchObject({ name: 'TimeoutError', message: 'the operation timed out', level: 'error' }) expect(result[LEVEL]).toBe('error') }) + it('substitutes a DOMException with the supplied serializer', () => { + const aborted = new DOMException('the operation timed out', 'TimeoutError') + const redact = redactValuesWith('', (error) => ({ kind: error.name, detail: error.message })) + + const result = redact({ error: aborted }, 'authorization') as { error: Record } + + expect(result.error).toEqual({ kind: 'TimeoutError', detail: 'the operation timed out' }) + }) }) diff --git a/src/redact-values.ts b/src/redact-values.ts index 396a167..c75dc0c 100644 --- a/src/redact-values.ts +++ b/src/redact-values.ts @@ -1,5 +1,5 @@ import { cloneDeepWith, forOwn, get, isNil, isObject, set } from 'es-toolkit/compat' -import { serializeError } from './serialize-error' +import { type ErrorSerializer, serializeError } from './serialize-error' // A `DOMException` — what `AbortSignal.timeout()` rejects with — cannot survive a deep clone. // es-toolkit clones an `Error` with `structuredClone` and then re-assigns `message` and `name`, but @@ -8,10 +8,10 @@ import { serializeError } from './serialize-error' // format, that `TypeError` comes out of the `logger.error(...)` call itself: the caller loses the // log line and everything it meant to do after it. Substitute the plain, already-cycle-safe object // `serializeError` builds, which holds the same facts and clones without complaint. -const plainDomException = (error: DOMException): Record => { - const plain = serializeError(error) as Record - // `serializeError` walks string keys only. Carry own symbols across so a `DOMException` given to - // the logger as the whole record keeps winston's `LEVEL` and `SPLAT` routing symbols. +const plainDomException = (error: DOMException, serializer: ErrorSerializer): Record => { + const plain = serializer(error) as Record + // A serializer walks string keys only. Carry own symbols across so a `DOMException` given to the + // logger as the whole record keeps winston's `LEVEL` and `SPLAT` routing symbols. for (const symbol of Object.getOwnPropertySymbols(error)) { plain[symbol] = (error as unknown as Record)[symbol] } @@ -19,8 +19,8 @@ const plainDomException = (error: DOMException): Record - cloneDeepWith(obj, (value) => (value instanceof DOMException ? plainDomException(value) : undefined)) +const cloneForRedaction = (obj: any, serializer: ErrorSerializer) => + cloneDeepWith(obj, (value) => (value instanceof DOMException ? plainDomException(value, serializer) : undefined)) // Expands a single path against the current node, supporting `[*]` to iterate every element of an // array segment. Without `[*]` it falls back to lodash-style get/set on a dot path. @@ -52,10 +52,13 @@ const applyPath = (current: unknown, path: string, redactedValue: string) => { * - a dot-separated path (`user.email`) — uses es-toolkit/compat's get/set * - a path with `[*]` wildcards (`files[*].name`, `users[*].addresses[*].zip`, `tags[*]`) — iterates each element of the array at that segment * Key checks are applied at every level of the object via recursion. + * @param errorSerializer Used to substitute a `DOMException`, which no deep clone can rebuild. + * Defaults to the library's {@link serializeError}. `createLogger` passes whatever `errorSerializer` + * it was given, so a `DOMException` reaches the transports in the same shape as every other error. * @returns A new object with the specified keys redacted */ export const redactValuesWith = - (redactedValue: string) => + (redactedValue: string, errorSerializer: ErrorSerializer = serializeError) => // eslint-disable-next-line @typescript-eslint/no-explicit-any (obj: any, ...keys: string[]) => { return (function redact(current) { @@ -67,7 +70,7 @@ export const redactValuesWith = if (isObject(value)) redact(value) }) return current - })(cloneForRedaction(obj)) + })(cloneForRedaction(obj, errorSerializer)) } export const redactValues = redactValuesWith('') diff --git a/src/serialize-error-format.spec.ts b/src/serialize-error-format.spec.ts index 8d9b0d0..b56e4b9 100644 --- a/src/serialize-error-format.spec.ts +++ b/src/serialize-error-format.spec.ts @@ -93,4 +93,34 @@ describe('serializeErrorFormat', () => { expect(result[SPLAT]).toEqual(['a', 1]) }) + it('leaves non-error splat arguments as they are, so format.splat() can interpolate them', () => { + const date = new Date('2020-01-02T03:04:05Z') + const nested = { when: date, n: 1 } + const input = { [LEVEL]: 'info', level: 'info', message: '', [SPLAT]: [date, nested] } as TransformableInfo + const fmt = serializeErrorFormat() + + const result = fmt.transform(input, fmt.options) as unknown as Record + + // Same references: rebuilt as plain records these hold no own enumerable keys, and `%j` would + // render `{}` in place of the date. + expect(result[SPLAT][0]).toBe(date) + expect(result[SPLAT][1]).toBe(nested) + }) + + it('rebuilds only the splat branch that holds an error', () => { + const untouched = { n: 1 } + const input = { + [LEVEL]: 'info', + level: 'info', + message: '', + [SPLAT]: [untouched, { error: new Error('boom'), when: new Date('2020-01-02T03:04:05Z') }], + } as TransformableInfo + const fmt = serializeErrorFormat() + + const result = fmt.transform(input, fmt.options) as unknown as Record[]> + + expect(result[SPLAT][0]).toBe(untouched) + expect(result[SPLAT][1].error).toMatchObject({ message: 'boom' }) + expect(result[SPLAT][1].when).toBeInstanceOf(Date) + }) }) diff --git a/src/serialize-error-format.ts b/src/serialize-error-format.ts index 2ffbc00..6b947ba 100644 --- a/src/serialize-error-format.ts +++ b/src/serialize-error-format.ts @@ -20,11 +20,53 @@ export interface SerializeErrorFormatOptions { * routing props); nested objects and arrays are rebuilt, so caller-supplied metadata * references are never mutated. * - * The `SPLAT` symbol is walked as well. Winston keeps the raw metadata argument there in addition - * to merging its properties onto `info`, so an `Error` logged as `logger.error(msg, { error })` is - * reachable twice. Serializing only the string keys leaves the live `Error` under `SPLAT` for every - * later format to trip over. + * Errors under the `SPLAT` symbol are replaced too. Winston keeps the raw metadata argument there + * in addition to merging its properties onto `info`, so an `Error` logged as + * `logger.error(msg, { error })` is reachable twice, and serializing only the string keys leaves + * the live `Error` under `SPLAT` for every later format to trip over. `SPLAT` is treated more + * gently than the rest of the record: see {@link replaceErrors}. */ +const isPlainObject = (value: object) => { + const prototype = Object.getPrototypeOf(value) as unknown + return prototype === Object.prototype || prototype === null +} + +/** + * Substitutes serialized errors inside the raw splat arguments, and changes nothing else. + * + * The main walk rebuilds every object it visits as a plain record, which is what metadata bound for + * a transport needs. `SPLAT` is not metadata: `format.splat()` interpolates it into the message, so + * a `Date` or a `Map` has to arrive as itself — rebuilt as a plain record it holds no own + * enumerable keys, and `%j` would render `{}` in place of its value. So this recurses through + * arrays and plain objects only, and rebuilds one only when it really holds an error. Anything + * untouched comes back as the same reference. + */ +const replaceErrors = (value: unknown, serializer: ErrorSerializer, seen: WeakSet): unknown => { + if (value instanceof Error) return serializer(value) + if (!value || typeof value !== 'object') return value + // A cycle is left as it is: the record is winston's, and this walk replaces rather than copies. + if (seen.has(value)) return value + if (!Array.isArray(value) && !isPlainObject(value)) return value + + seen.add(value) + try { + if (Array.isArray(value)) { + const out = value.map((entry) => replaceErrors(entry, serializer, seen)) + return out.some((entry, index) => entry !== value[index]) ? out : value + } + const source = value as Record + const out: Record = {} + let replaced = false + for (const key of Object.keys(source)) { + out[key] = replaceErrors(source[key], serializer, seen) + if (out[key] !== source[key]) replaced = true + } + return replaced ? out : value + } finally { + seen.delete(value) + } +} + export const serializeErrorFormat = format((info, opts) => { const serializer = (opts as SerializeErrorFormatOptions | undefined)?.serializer ?? serializeError const walk = (value: unknown, seen: WeakSet): unknown => { @@ -46,6 +88,6 @@ export const serializeErrorFormat = format((info, opts) => { const seen = new WeakSet([record]) for (const key of Object.keys(record)) record[key] = walk(record[key], seen) const splat = record[SPLAT] - if (Array.isArray(splat)) record[SPLAT] = splat.map((value) => walk(value, seen)) + if (Array.isArray(splat)) record[SPLAT] = replaceErrors(splat, serializer, new WeakSet()) return info as TransformableInfo }) From dfaea57659c089c43769e600beec586b9f38b441 Mon Sep 17 00:00:00 2001 From: Sam Curry Date: Mon, 31 Aug 2026 10:40:40 +0800 Subject: [PATCH 04/10] docs: correct comments left stale by the review fixes Moving `replaceErrors` above `serializeErrorFormat` left the format's doc block attached to `isPlainObject`. Put it back on the export it describes. `redact-values.ts` still named `serializeError` as what it substitutes, which stopped being true once the configured serializer was threaded through, and `errorSerializer` in `CreateLoggerOptions` did not mention that `redactFormat` now uses it. Two comments repeated the wording review corrected in the README: `AbortSignal.timeout()` returns a signal, it does not reject. Co-Authored-By: Claude Opus 5 (1M context) --- src/index.spec.ts | 2 +- src/index.ts | 7 ++++--- src/redact-values.ts | 15 ++++++++------- src/serialize-error-format.ts | 30 +++++++++++++++--------------- 4 files changed, 28 insertions(+), 26 deletions(-) diff --git a/src/index.spec.ts b/src/index.spec.ts index 69cf0f6..12c6d89 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -384,7 +384,7 @@ describe('createLogger mapAuditLevelForOtel', () => { }) describe('createLogger DOMException', () => { - // `AbortSignal.timeout()` rejects with a `DOMException`, whose `message` and `name` are + // An operation cancelled through an `AbortSignal` rejects with a `DOMException`, whose `message` and `name` are // getter-only prototype accessors. A deep clone cannot rebuild one, so redaction used to throw a // TypeError out of the log call itself, costing the caller the log line and everything after it. const aborted = () => new DOMException('the operation timed out', 'TimeoutError') diff --git a/src/index.ts b/src/index.ts index c41f4ce..bb9751c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -177,9 +177,10 @@ export interface CreateLoggerOptions { mapAuditLevelForOtel?: boolean /** - * Custom serializer used whenever an `Error` instance is encountered, both by the logger-level - * `serializeErrorFormat` (walks the full info tree) and by the Console transport's - * `format.json` replacer (safety net for errors that slip through). Defaults to the library's + * Custom serializer used whenever an `Error` instance is encountered: by the logger-level + * `serializeErrorFormat` (walks the full info tree), by `redactFormat` (which substitutes a + * `DOMException` no deep clone can rebuild), and by the Console transport's `format.json` + * replacer (safety net for errors that slip through). Defaults to the library's * `serializeError`, which captures `name`/`message`/`stack`/`code`/`cause`/`errors` even when * non-enumerable, walks own enumerable properties, and is safe against circular references. */ diff --git a/src/redact-values.ts b/src/redact-values.ts index c75dc0c..de03ad0 100644 --- a/src/redact-values.ts +++ b/src/redact-values.ts @@ -1,13 +1,14 @@ import { cloneDeepWith, forOwn, get, isNil, isObject, set } from 'es-toolkit/compat' import { type ErrorSerializer, serializeError } from './serialize-error' -// A `DOMException` — what `AbortSignal.timeout()` rejects with — cannot survive a deep clone. -// es-toolkit clones an `Error` with `structuredClone` and then re-assigns `message` and `name`, but -// `structuredClone` rebuilds a `DOMException` as a `DOMException`, whose `message` and `name` are -// getter-only prototype accessors, so the assignment throws a `TypeError`. Thrown from inside a -// format, that `TypeError` comes out of the `logger.error(...)` call itself: the caller loses the -// log line and everything it meant to do after it. Substitute the plain, already-cycle-safe object -// `serializeError` builds, which holds the same facts and clones without complaint. +// A `DOMException` — the reason an `AbortSignal` carries, so what a cancelled operation rejects +// with — cannot survive a deep clone. es-toolkit clones an `Error` with `structuredClone` and then +// re-assigns `message` and `name`, but `structuredClone` rebuilds a `DOMException` as a +// `DOMException`, whose `message` and `name` are getter-only prototype accessors, so the assignment +// throws a `TypeError`. Thrown from inside a format, that `TypeError` comes out of the +// `logger.error(...)` call itself: the caller loses the log line and everything it meant to do +// after it. Substitute the plain object the serializer builds, which holds the same facts and +// clones without complaint. const plainDomException = (error: DOMException, serializer: ErrorSerializer): Record => { const plain = serializer(error) as Record // A serializer walks string keys only. Carry own symbols across so a `DOMException` given to the diff --git a/src/serialize-error-format.ts b/src/serialize-error-format.ts index 6b947ba..ea12303 100644 --- a/src/serialize-error-format.ts +++ b/src/serialize-error-format.ts @@ -11,21 +11,6 @@ export interface SerializeErrorFormatOptions { serializer?: ErrorSerializer } -/** - * Walks the log info object, replacing any `Error` instances (including nested ones) - * with the plain-object result of the configured serializer so downstream formats and - * transports see JSON-serializable errors with `message` and `stack` intact. - * - * Only the top-level `info` object is mutated (to preserve winston's Symbol-keyed - * routing props); nested objects and arrays are rebuilt, so caller-supplied metadata - * references are never mutated. - * - * Errors under the `SPLAT` symbol are replaced too. Winston keeps the raw metadata argument there - * in addition to merging its properties onto `info`, so an `Error` logged as - * `logger.error(msg, { error })` is reachable twice, and serializing only the string keys leaves - * the live `Error` under `SPLAT` for every later format to trip over. `SPLAT` is treated more - * gently than the rest of the record: see {@link replaceErrors}. - */ const isPlainObject = (value: object) => { const prototype = Object.getPrototypeOf(value) as unknown return prototype === Object.prototype || prototype === null @@ -67,6 +52,21 @@ const replaceErrors = (value: unknown, serializer: ErrorSerializer, seen: WeakSe } } +/** + * Walks the log info object, replacing any `Error` instances (including nested ones) + * with the plain-object result of the configured serializer so downstream formats and + * transports see JSON-serializable errors with `message` and `stack` intact. + * + * Only the top-level `info` object is mutated (to preserve winston's Symbol-keyed + * routing props); nested objects and arrays are rebuilt, so caller-supplied metadata + * references are never mutated. + * + * Errors under the `SPLAT` symbol are replaced too. Winston keeps the raw metadata argument there + * in addition to merging its properties onto `info`, so an `Error` logged as + * `logger.error(msg, { error })` is reachable twice, and serializing only the string keys leaves + * the live `Error` under `SPLAT` for every later format to trip over. `SPLAT` is treated more + * gently than the rest of the record: see {@link replaceErrors}. + */ export const serializeErrorFormat = format((info, opts) => { const serializer = (opts as SerializeErrorFormatOptions | undefined)?.serializer ?? serializeError const walk = (value: unknown, seen: WeakSet): unknown => { From 0ebee6ba1c3957f7f6f1f2974ce25f3c7db78eff Mon Sep 17 00:00:00 2001 From: Sam Curry Date: Mon, 31 Aug 2026 10:47:59 +0800 Subject: [PATCH 05/10] test: cover a DOMException passed as the second argument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `logger.error('failed', error)` takes a different winston path from both shapes already covered: with a message and a non-plain second argument, winston lifts `message`, `stack` and `cause` onto a fresh info object and keeps the raw error under `SPLAT` — which is where the clone used to find it and throw. It fails on `main` with the same TypeError, and no log line is written. Co-Authored-By: Claude Opus 5 (1M context) --- src/index.spec.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/index.spec.ts b/src/index.spec.ts index 12c6d89..554def8 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -408,6 +408,23 @@ describe('createLogger DOMException', () => { expect(transport.logs[2].errors[0]).toMatchObject({ name: 'TimeoutError' }) }) + // A separate winston code path: with a message and a non-plain second argument it lifts + // `message`, `stack` and `cause` onto a fresh info object and keeps the raw error under `SPLAT`, + // which is where the clone used to find it. + it('logs one passed as the second argument', () => { + const transport = new InMemoryTransport({}) + const logger = createLogger({ + consoleOptions: { silent: true }, + transports: [transport], + redactPaths: ['authorization'], + }) + + logger.error('delivery failed', aborted() as unknown as string) + + expect(transport.logs[0].message).toBe('delivery failed the operation timed out') + expect(transport.logs[0].stack).toContain('TimeoutError') + }) + it('logs one passed as the whole record', () => { const transport = new InMemoryTransport({}) const logger = createLogger({ From 0de2406db54b0be9121b8d4de156cd75f45f32d5 Mon Sep 17 00:00:00 2001 From: Sam Curry Date: Mon, 31 Aug 2026 10:54:59 +0800 Subject: [PATCH 06/10] fix: serialize a record that is itself an error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `logger.error(err)` takes a winston branch of its own: it assigns `level` and the routing symbols onto the error and writes the error as the record. `message`, `stack` and `name` are not own enumerable properties of an `Error`, so every transport that spreads or enumerates the record loses them. It is not a corner case — this library's own `CallbackTransport` hands `meta` to its callback without a stack, and `{ ...info }` yields no message at all. `serializeErrorFormat` now replaces such a record with its serialized form, re-applying `level` and every own symbol afterwards: those are winston's routing, not error data, and a custom serializer has no reason to return them. `message` is left to the serializer, so one that drops it produces a record without one, exactly as it already does for a nested error. Detection is `instanceof Error`, so a plain info object that happens to carry `message` and `stack` is untouched. Minor rather than patch: a custom transport that received an `Error` instance for this call shape now receives a plain object. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 19 +++++++++---- package.json | 2 +- src/index.spec.ts | 43 ++++++++++++++++++++++++++++++ src/serialize-error-format.spec.ts | 26 ++++++++++++++++++ src/serialize-error-format.ts | 32 +++++++++++++++++++--- 5 files changed, 113 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 8927da9..053d023 100644 --- a/README.md +++ b/README.md @@ -295,14 +295,21 @@ const logger = createLogger({ The `Error` class's `message` and `stack` properties [are not enumerable](https://stackoverflow.com/questions/18391212/is-it-not-possible-to-stringify-an-error-using-json-stringify), so `JSON.stringify(new Error('message'))` returns `'{}'`. -Winston has special handling when an `Error` is the first or second argument to a log call: +Winston lifts `message`, `stack` and `cause` onto the record when an `Error` is the **second** argument to a log call: ```ts -logger.log(new Error('cause')) // { message: 'cause', stack: ... } logger.log('message', new Error('cause')) // { message: 'message cause', stack: ... } ``` -But when errors are nested inside structured log data, `message` and `stack` are lost: +It does nothing of the kind for the other two shapes. + +An `Error` passed **alone** becomes the record itself, and since `message`, `stack` and `name` are not own enumerable properties, any transport that spreads or enumerates it receives none of them: + +```ts +logger.log(new Error('cause')) // { ...info } is { level: 'info' } — no message, no stack +``` + +An `Error` **nested** in structured log data loses `message` and `stack` for the same reason: ```ts try { @@ -312,15 +319,17 @@ try { } ``` -`createLogger` solves this with two complementary mechanisms: +`createLogger` solves both with two complementary mechanisms: -- `serializeErrorFormat` runs at the logger level and walks the log info, replacing any `Error` instance (at any depth) with a plain, JSON-serializable object via the library's `serializeError`. This applies to every transport. +- `serializeErrorFormat` runs at the logger level and walks the log info, replacing any `Error` instance (at any depth, the record itself included) with a plain, JSON-serializable object via the library's `serializeError`. This applies to every transport. When the record is the error, `level` and winston's routing symbols are re-applied to the serialized object so routing still works. - `serializableErrorReplacer` is passed to the Console transport's final `format.json()` as a safety net — [logform](https://github.com/winstonjs/logform) uses [safe-stable-stringify](https://www.npmjs.com/package/safe-stable-stringify), which accepts a replacer, so any `Error` that slips through is still serialised correctly. ```ts format.json({ replacer: serializableErrorReplacer }) ``` +> **Upgrading from 2.0.** A record that is itself an `Error` now reaches transports as a plain object rather than an `Error` instance, so that it carries `name`, `message` and `stack` as ordinary properties. A custom transport that tested `info instanceof Error` should read those properties instead. + To plug in a custom transformation (for example, an `Error`-normalising function previously applied via a custom winston-transport), pass it via `errorSerializer` — it's threaded into both mechanisms: ```ts diff --git a/package.json b/package.json index 29b61d1..b079428 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@makerx/node-winston", - "version": "2.0.3", + "version": "2.1.0", "private": false, "description": "A set of winston formats, console transport and logger creation functions", "author": "MakerX", diff --git a/src/index.spec.ts b/src/index.spec.ts index 554def8..ea76ed5 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -383,6 +383,49 @@ describe('createLogger mapAuditLevelForOtel', () => { }) }) +describe('createLogger error as the whole record', () => { + // `logger.error(err)` makes the error the record. `message`, `stack` and `name` are not own + // enumerable properties, so a transport that spreads or enumerates it used to receive neither + // a message nor a stack. + it('gives a transport the message, name and stack', () => { + const transport = new InMemoryTransport({}) + const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport] }) + + logger.error(Object.assign(new TypeError('boom'), { code: 'E_BOOM' }) as unknown as string) + + expect(transport.logs[0]).toMatchObject({ + name: 'TypeError', + message: 'boom', + code: 'E_BOOM', + level: 'error', + }) + expect(transport.logs[0].stack).toContain('TypeError: boom') + }) + + it('still routes at the right level', () => { + const transport = new InMemoryTransport({}) + const logger = createLogger({ + consoleOptions: { silent: true }, + transports: [transport], + loggerOptions: { level: 'warn' }, + }) + + logger.error(new TypeError('kept') as unknown as string) + logger.info(new TypeError('filtered out') as unknown as string) + + expect(transport.logs.map((l) => l.message)).toEqual(['kept']) + }) + + it('leaves a plain info object alone', () => { + const transport = new InMemoryTransport({}) + const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport] }) + + logger.log({ level: 'info', message: 'plain', stack: 'a string' }) + + expect(transport.logs[0]).toMatchObject({ message: 'plain', stack: 'a string' }) + }) +}) + describe('createLogger DOMException', () => { // An operation cancelled through an `AbortSignal` rejects with a `DOMException`, whose `message` and `name` are // getter-only prototype accessors. A deep clone cannot rebuild one, so redaction used to throw a diff --git a/src/serialize-error-format.spec.ts b/src/serialize-error-format.spec.ts index b56e4b9..8df8df3 100644 --- a/src/serialize-error-format.spec.ts +++ b/src/serialize-error-format.spec.ts @@ -123,4 +123,30 @@ describe('serializeErrorFormat', () => { expect(result[SPLAT][1].error).toMatchObject({ message: 'boom' }) expect(result[SPLAT][1].when).toBeInstanceOf(Date) }) + it('replaces a record that is itself an error, keeping level and the routing symbols', () => { + const error = Object.assign(new TypeError('boom'), { [LEVEL]: 'error', level: 'error' }) + const fmt = serializeErrorFormat() + + const result = fmt.transform(error as unknown as TransformableInfo, fmt.options) as unknown as Record + + expect(result).not.toBeInstanceOf(Error) + expect(result).toMatchObject({ name: 'TypeError', message: 'boom', level: 'error' }) + expect(result.stack).toBeDefined() + expect(result[LEVEL]).toBe('error') + }) + + it('serialises a record that is itself an error with the configured serializer', () => { + const error = Object.assign(new TypeError('boom'), { [LEVEL]: 'error', level: 'error' }) + const fmt = serializeErrorFormat({ serializer: (e: Error) => ({ kind: e.name }) }) + + const result = fmt.transform(error as unknown as TransformableInfo, fmt.options) as unknown as Record + + expect(result).toMatchObject({ kind: 'TypeError', level: 'error' }) + expect(result[LEVEL]).toBe('error') + }) + + it('leaves a plain info object that merely looks like a log record alone', () => { + const result = run({ message: 'not an error', stack: 'a string' }) + expect(result).toMatchObject({ message: 'not an error', stack: 'a string' }) + }) }) diff --git a/src/serialize-error-format.ts b/src/serialize-error-format.ts index ea12303..9315aab 100644 --- a/src/serialize-error-format.ts +++ b/src/serialize-error-format.ts @@ -52,12 +52,38 @@ const replaceErrors = (value: unknown, serializer: ErrorSerializer, seen: WeakSe } } +/** + * Replaces the record itself when it is an `Error`. + * + * `logger.error(err)` takes a winston branch of its own: it assigns `level` and the routing symbols + * onto the error and writes the error as the record. `message`, `stack` and `name` are not own + * enumerable properties of an `Error`, so every transport that spreads or enumerates the record + * loses them — `{ ...info }` yields neither a message nor a stack. Serializing lifts them onto a + * plain object. + * + * `level` and every own symbol are re-applied afterwards: they are winston's routing, not error + * data, and a custom serializer has no reason to return them. `message` is left to the serializer, + * so one that drops it produces a record without one, exactly as it already does for a nested + * error. + */ +const serializeRecord = (error: Error, serializer: ErrorSerializer): Record => { + const record = serializer(error) as Record + // Guarded because `serializeErrorFormat` is also usable outside `createLogger`, on a record + // winston has not stamped a level onto. + if ('level' in error) record.level = (error as unknown as { level: unknown }).level + for (const symbol of Object.getOwnPropertySymbols(error)) { + record[symbol] = (error as unknown as Record)[symbol] + } + return record +} + /** * Walks the log info object, replacing any `Error` instances (including nested ones) * with the plain-object result of the configured serializer so downstream formats and * transports see JSON-serializable errors with `message` and `stack` intact. * - * Only the top-level `info` object is mutated (to preserve winston's Symbol-keyed + * A record that is itself an `Error` is replaced outright: see {@link serializeRecord}. Otherwise + * only the top-level `info` object is mutated (to preserve winston's Symbol-keyed * routing props); nested objects and arrays are rebuilt, so caller-supplied metadata * references are never mutated. * @@ -84,10 +110,10 @@ export const serializeErrorFormat = format((info, opts) => { seen.delete(value) } } - const record = info as unknown as Record + const record = (info instanceof Error ? serializeRecord(info, serializer) : info) as unknown as Record const seen = new WeakSet([record]) for (const key of Object.keys(record)) record[key] = walk(record[key], seen) const splat = record[SPLAT] if (Array.isArray(splat)) record[SPLAT] = replaceErrors(splat, serializer, new WeakSet()) - return info as TransformableInfo + return record as unknown as TransformableInfo }) From 84ee15e972692755810fbe75fe24eba3af914a59 Mon Sep 17 00:00:00 2001 From: Sam Curry Date: Mon, 31 Aug 2026 11:09:57 +0800 Subject: [PATCH 07/10] fix: address the second review round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings, all reproduced before fixing. Both places that stamp winston's routing back onto a serialized error mutated the serializer's return value. A serializer is free to return a frozen record, or a cached one shared between calls, and stamping either throws out of the log call — the very failure this PR exists to fix: logger.error(new TypeError('boom')) TypeError: Cannot add property level, object is not extensible Copy first, in `serializeRecord` and in `plainDomException`. The `SPLAT` walk returned the source container on a back-edge, so a rebuilt cyclic branch linked back to the unprocessed original and a live error stayed reachable through the cycle. Replace the `WeakSet` with a `Map` from source to replacement, which resolves a back-edge to the replacement and, as a bonus, keeps a container reached twice without a cycle shared. The README example used `logger.log(err)`, whose one-argument overload wants a complete entry carrying its own `level`. A bare error there is dropped without being logged at all, so it demonstrated nothing. `logger.error(err)` is the shape the section is about. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- src/redact-values.spec.ts | 12 +++++++ src/redact-values.ts | 4 ++- src/serialize-error-format.spec.ts | 33 ++++++++++++++++++++ src/serialize-error-format.ts | 50 +++++++++++++++++++----------- 5 files changed, 81 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 053d023..78455a8 100644 --- a/README.md +++ b/README.md @@ -306,7 +306,7 @@ It does nothing of the kind for the other two shapes. An `Error` passed **alone** becomes the record itself, and since `message`, `stack` and `name` are not own enumerable properties, any transport that spreads or enumerates it receives none of them: ```ts -logger.log(new Error('cause')) // { ...info } is { level: 'info' } — no message, no stack +logger.error(new Error('cause')) // { ...info } was { level: 'error' } — no message, no stack ``` An `Error` **nested** in structured log data loses `message` and `stack` for the same reason: diff --git a/src/redact-values.spec.ts b/src/redact-values.spec.ts index 458c3d9..732ab8b 100644 --- a/src/redact-values.spec.ts +++ b/src/redact-values.spec.ts @@ -45,4 +45,16 @@ describe('redactValues', () => { expect(result.error).toEqual({ kind: 'TimeoutError', detail: 'the operation timed out' }) }) + it('does not mutate the record a custom serializer returns', () => { + const record = Object.assign(new DOMException('the operation timed out', 'TimeoutError'), { + [LEVEL]: 'error', + level: 'error', + }) + const redact = redactValuesWith('', (error) => Object.freeze({ kind: error.name })) + + const result = redact(record, 'authorization') as Record + + expect(result).toMatchObject({ kind: 'TimeoutError' }) + expect(result[LEVEL]).toBe('error') + }) }) diff --git a/src/redact-values.ts b/src/redact-values.ts index de03ad0..710f8fe 100644 --- a/src/redact-values.ts +++ b/src/redact-values.ts @@ -10,7 +10,9 @@ import { type ErrorSerializer, serializeError } from './serialize-error' // after it. Substitute the plain object the serializer builds, which holds the same facts and // clones without complaint. const plainDomException = (error: DOMException, serializer: ErrorSerializer): Record => { - const plain = serializer(error) as Record + // Copied, never mutated in place: a serializer is free to return a frozen record, or a cached one + // shared between calls, and stamping symbols onto either would throw or leak. + const plain = { ...serializer(error) } as Record // A serializer walks string keys only. Carry own symbols across so a `DOMException` given to the // logger as the whole record keeps winston's `LEVEL` and `SPLAT` routing symbols. for (const symbol of Object.getOwnPropertySymbols(error)) { diff --git a/src/serialize-error-format.spec.ts b/src/serialize-error-format.spec.ts index 8df8df3..001feec 100644 --- a/src/serialize-error-format.spec.ts +++ b/src/serialize-error-format.spec.ts @@ -149,4 +149,37 @@ describe('serializeErrorFormat', () => { const result = run({ message: 'not an error', stack: 'a string' }) expect(result).toMatchObject({ message: 'not an error', stack: 'a string' }) }) + it('rebuilds a cyclic splat branch so no live error is reachable through the cycle', () => { + const branch: Record = { error: new Error('boom') } + branch.self = branch + const input = { [LEVEL]: 'info', level: 'info', message: '', [SPLAT]: [branch] } as TransformableInfo + const fmt = serializeErrorFormat() + + const result = fmt.transform(input, fmt.options) as unknown as Record[]> + const walked = result[SPLAT][0] as unknown as { error: unknown; self: { error: unknown } } + + expect(walked.error).not.toBeInstanceOf(Error) + expect(walked.self).toBe(walked) + expect(walked.self.error).not.toBeInstanceOf(Error) + }) + + it('keeps a shared splat reference shared', () => { + const shared = { error: new Error('boom') } + const input = { [LEVEL]: 'info', level: 'info', message: '', [SPLAT]: [{ a: shared, b: shared }] } as TransformableInfo + const fmt = serializeErrorFormat() + + const result = fmt.transform(input, fmt.options) as unknown as Record + + expect(result[SPLAT][0].a).toBe(result[SPLAT][0].b) + }) + + it('does not mutate the record a custom serializer returns', () => { + const error = Object.assign(new TypeError('boom'), { [LEVEL]: 'error', level: 'error' }) + const fmt = serializeErrorFormat({ serializer: (e: Error) => Object.freeze({ kind: e.name }) }) + + const result = fmt.transform(error as unknown as TransformableInfo, fmt.options) as unknown as Record + + expect(result).toMatchObject({ kind: 'TypeError', level: 'error' }) + expect(result[LEVEL]).toBe('error') + }) }) diff --git a/src/serialize-error-format.ts b/src/serialize-error-format.ts index 9315aab..d09c60c 100644 --- a/src/serialize-error-format.ts +++ b/src/serialize-error-format.ts @@ -25,31 +25,43 @@ const isPlainObject = (value: object) => { * enumerable keys, and `%j` would render `{}` in place of its value. So this recurses through * arrays and plain objects only, and rebuilds one only when it really holds an error. Anything * untouched comes back as the same reference. + * + * `rebuilt` maps each source container to its replacement, so a back-edge resolves to the + * replacement rather than to the unprocessed source — otherwise a rebuilt branch would link back to + * the original and leave a live error reachable through the cycle. A container reached twice + * without a cycle resolves to the same replacement both times, which keeps shared references + * shared. */ -const replaceErrors = (value: unknown, serializer: ErrorSerializer, seen: WeakSet): unknown => { +const replaceErrors = (value: unknown, serializer: ErrorSerializer, rebuilt: Map): unknown => { if (value instanceof Error) return serializer(value) if (!value || typeof value !== 'object') return value - // A cycle is left as it is: the record is winston's, and this walk replaces rather than copies. - if (seen.has(value)) return value + if (rebuilt.has(value)) return rebuilt.get(value) if (!Array.isArray(value) && !isPlainObject(value)) return value - seen.add(value) - try { - if (Array.isArray(value)) { - const out = value.map((entry) => replaceErrors(entry, serializer, seen)) - return out.some((entry, index) => entry !== value[index]) ? out : value - } - const source = value as Record - const out: Record = {} + if (Array.isArray(value)) { + const out: unknown[] = [] + rebuilt.set(value, out) let replaced = false - for (const key of Object.keys(source)) { - out[key] = replaceErrors(source[key], serializer, seen) - if (out[key] !== source[key]) replaced = true + for (let index = 0; index < value.length; index++) { + out[index] = replaceErrors(value[index], serializer, rebuilt) + if (out[index] !== value[index]) replaced = true } + // `replaced` is true whenever a back-edge was taken, because the replacement it resolved to is + // not the source. So an unreplaced container never has one pointing at its discarded copy. + if (!replaced) rebuilt.set(value, value) return replaced ? out : value - } finally { - seen.delete(value) } + + const source = value as Record + const out: Record = {} + rebuilt.set(value, out) + let replaced = false + for (const key of Object.keys(source)) { + out[key] = replaceErrors(source[key], serializer, rebuilt) + if (out[key] !== source[key]) replaced = true + } + if (!replaced) rebuilt.set(value, value) + return replaced ? out : value } /** @@ -67,7 +79,9 @@ const replaceErrors = (value: unknown, serializer: ErrorSerializer, seen: WeakSe * error. */ const serializeRecord = (error: Error, serializer: ErrorSerializer): Record => { - const record = serializer(error) as Record + // Copied, never mutated in place: a serializer is free to return a frozen record, or a cached one + // shared between calls, and stamping routing onto either would throw or leak. + const record = { ...serializer(error) } as Record // Guarded because `serializeErrorFormat` is also usable outside `createLogger`, on a record // winston has not stamped a level onto. if ('level' in error) record.level = (error as unknown as { level: unknown }).level @@ -114,6 +128,6 @@ export const serializeErrorFormat = format((info, opts) => { const seen = new WeakSet([record]) for (const key of Object.keys(record)) record[key] = walk(record[key], seen) const splat = record[SPLAT] - if (Array.isArray(splat)) record[SPLAT] = replaceErrors(splat, serializer, new WeakSet()) + if (Array.isArray(splat)) record[SPLAT] = replaceErrors(splat, serializer, new Map()) return record as unknown as TransformableInfo }) From 6675a203146af2432a6542e6002c2859f4b76abc Mon Sep 17 00:00:00 2001 From: Sam Curry Date: Mon, 31 Aug 2026 11:28:57 +0800 Subject: [PATCH 08/10] fix: address the third review round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redaction wrote into the values a serializer returned. `plainDomException` spread the serialized record shallowly, so its nested values stayed shared with whatever the serializer handed back — a cached record, or a reference to the error's own properties. Redaction then wrote through them: serializer returns a cached { context: { authorization: 'secret' } } → cached.context.authorization becomes '' The substitution is now built by deep-cloning the serialized record through the same customizer, with each `DOMException` mapped to the object standing in for it, so a serializer that puts the error back into its own output resolves to that substitute instead of recursing forever. The `SPLAT` walk rebuilt a cyclic argument even when it held no error: a back-edge always resolves to something that is not the source, which counts as a change. That cost an unrelated argument its identity and every symbol and non-enumerable property on it. Gate the walk per argument on whether an error is reachable at all. Found while testing the above, and fixed here because this PR claims redaction never throws on the record it is given: the redaction walk had no cycle guard, so any cyclic record overflowed the stack — on `main` too, with no `DOMException` involved. `logger.error('x', cyclic)` with `redactPaths` was a RangeError out of the log call. Co-Authored-By: Claude Opus 5 (1M context) --- src/redact-values.spec.ts | 31 ++++++++++++++++++++ src/redact-values.ts | 47 +++++++++++++++++++++--------- src/serialize-error-format.spec.ts | 32 ++++++++++++++++++++ src/serialize-error-format.ts | 34 ++++++++++++++++++++- 4 files changed, 130 insertions(+), 14 deletions(-) diff --git a/src/redact-values.spec.ts b/src/redact-values.spec.ts index 732ab8b..9f5b33f 100644 --- a/src/redact-values.spec.ts +++ b/src/redact-values.spec.ts @@ -57,4 +57,35 @@ describe('redactValues', () => { expect(result).toMatchObject({ kind: 'TimeoutError' }) expect(result[LEVEL]).toBe('error') }) + it('does not write into the nested values a serializer returns', () => { + const cached = { context: { authorization: 'secret' } } + const redact = redactValuesWith('', () => cached) + + const result = redact({ error: new DOMException('t', 'TimeoutError') }, 'authorization') as { + error: { context: { authorization: string } } + } + + expect(result.error.context.authorization).toBe('') + expect(cached.context.authorization).toBe('secret') + }) + + it('substitutes a serializer that returns the error it was given', () => { + const aborted = new DOMException('the operation timed out', 'TimeoutError') + const redact = redactValuesWith('', (error) => ({ kind: error.name, original: error })) + + const result = redact({ error: aborted }, 'authorization') as { error: Record } + + expect(result.error.kind).toBe('TimeoutError') + expect(result.error.original).toBe(result.error) + }) + it('redacts a cyclic record instead of overflowing the stack', () => { + const cyclic: Record = { authorization: 'secret' } + cyclic.self = cyclic + + const result = redactValues({ a: cyclic }, 'authorization') as { a: Record } + + expect(result.a.authorization).toBe('') + expect(result.a.self).toBe(result.a) + expect(cyclic.authorization).toBe('secret') + }) }) diff --git a/src/redact-values.ts b/src/redact-values.ts index 710f8fe..6c2321b 100644 --- a/src/redact-values.ts +++ b/src/redact-values.ts @@ -9,21 +9,33 @@ import { type ErrorSerializer, serializeError } from './serialize-error' // `logger.error(...)` call itself: the caller loses the log line and everything it meant to do // after it. Substitute the plain object the serializer builds, which holds the same facts and // clones without complaint. -const plainDomException = (error: DOMException, serializer: ErrorSerializer): Record => { - // Copied, never mutated in place: a serializer is free to return a frozen record, or a cached one - // shared between calls, and stamping symbols onto either would throw or leak. - const plain = { ...serializer(error) } as Record - // A serializer walks string keys only. Carry own symbols across so a `DOMException` given to the - // logger as the whole record keeps winston's `LEVEL` and `SPLAT` routing symbols. - for (const symbol of Object.getOwnPropertySymbols(error)) { - plain[symbol] = (error as unknown as Record)[symbol] +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const cloneForRedaction = (obj: any, serializer: ErrorSerializer) => { + // Each `DOMException` maps to the object standing in for it, so a serializer that puts the error + // back into its own output resolves to that substitute rather than recursing forever. + const substituted = new Map>() + + const substitute = (value: unknown): unknown => { + if (!(value instanceof DOMException)) return undefined + const existing = substituted.get(value) + if (existing) return existing + + const plain: Record = {} + substituted.set(value, plain) + // Deep-cloned rather than spread. Redaction writes into what it is given, and a serializer may + // return a frozen record, a cached one shared between calls, or one holding a reference to the + // error's own properties — sharing any of those would corrupt the original. + Object.assign(plain, cloneDeepWith(serializer(value), substitute)) + // A serializer walks string keys only. Carry own symbols across so a `DOMException` given to + // the logger as the whole record keeps winston's `LEVEL` and `SPLAT` routing symbols. + for (const symbol of Object.getOwnPropertySymbols(value)) { + plain[symbol] = cloneDeepWith((value as unknown as Record)[symbol], substitute) + } + return plain } - return plain -} -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const cloneForRedaction = (obj: any, serializer: ErrorSerializer) => - cloneDeepWith(obj, (value) => (value instanceof DOMException ? plainDomException(value, serializer) : undefined)) + return cloneDeepWith(obj, substitute) +} // Expands a single path against the current node, supporting `[*]` to iterate every element of an // array segment. Without `[*]` it falls back to lodash-style get/set on a dot path. @@ -64,7 +76,16 @@ export const redactValuesWith = (redactedValue: string, errorSerializer: ErrorSerializer = serializeError) => // eslint-disable-next-line @typescript-eslint/no-explicit-any (obj: any, ...keys: string[]) => { + // A clone of a cyclic record is cyclic too, and the walk below would never return — a + // RangeError out of the log call, which is the one thing redaction must not cause. Visiting a + // node once is enough whether it was reached through a cycle or shared by two branches: + // redaction writes the same value either way. + const seen = new WeakSet() return (function redact(current) { + if (isObject(current)) { + if (seen.has(current)) return current + seen.add(current) + } for (const k of keys) { applyPath(current, k, redactedValue) } diff --git a/src/serialize-error-format.spec.ts b/src/serialize-error-format.spec.ts index 001feec..b124e94 100644 --- a/src/serialize-error-format.spec.ts +++ b/src/serialize-error-format.spec.ts @@ -182,4 +182,36 @@ describe('serializeErrorFormat', () => { expect(result).toMatchObject({ kind: 'TypeError', level: 'error' }) expect(result[LEVEL]).toBe('error') }) + it('leaves a cyclic splat argument holding no error completely alone', () => { + const marker = Symbol('marker') + const cyclic: Record = { n: 1, [marker]: 'kept' } + cyclic.self = cyclic + Object.defineProperty(cyclic, 'hidden', { value: 'kept', enumerable: false }) + const input = { [LEVEL]: 'info', level: 'info', message: '', [SPLAT]: [cyclic] } as TransformableInfo + const fmt = serializeErrorFormat() + + const result = fmt.transform(input, fmt.options) as unknown as Record + const walked = result[SPLAT][0] as Record + + expect(walked).toBe(cyclic) + expect(walked[marker]).toBe('kept') + expect(walked.hidden).toBe('kept') + }) + + it('replaces only the argument that holds an error', () => { + const untouched: Record = { n: 1 } + untouched.self = untouched + const input = { + [LEVEL]: 'info', + level: 'info', + message: '', + [SPLAT]: [untouched, { error: new Error('boom') }], + } as TransformableInfo + const fmt = serializeErrorFormat() + + const result = fmt.transform(input, fmt.options) as unknown as Record[]> + + expect(result[SPLAT][0]).toBe(untouched) + expect(result[SPLAT][1].error).toMatchObject({ message: 'boom' }) + }) }) diff --git a/src/serialize-error-format.ts b/src/serialize-error-format.ts index d09c60c..3e6727b 100644 --- a/src/serialize-error-format.ts +++ b/src/serialize-error-format.ts @@ -32,6 +32,31 @@ const isPlainObject = (value: object) => { * without a cycle resolves to the same replacement both times, which keeps shared references * shared. */ +/** + * Whether an error is reachable from a splat argument, so {@link replaceErrors} runs only where it + * has something to do. + * + * A container is marked `false` while it is being examined, which cuts cycles: going back round one + * reaches nothing the walk is not already looking at. That makes an in-progress entry pessimistic + * for a sibling asked later, so this is only ever asked about a whole argument — the node holding + * the error always finishes examining its own keys, and the answer for the argument is right. + */ +const holdsError = (value: unknown, seen: Map): boolean => { + if (value instanceof Error) return true + if (!value || typeof value !== 'object') return false + const known = seen.get(value) + if (known !== undefined) return known + if (!Array.isArray(value) && !isPlainObject(value)) return false + + seen.set(value, false) + const source = value as Record + const found = Array.isArray(value) + ? value.some((entry) => holdsError(entry, seen)) + : Object.keys(source).some((key) => holdsError(source[key], seen)) + seen.set(value, found) + return found +} + const replaceErrors = (value: unknown, serializer: ErrorSerializer, rebuilt: Map): unknown => { if (value instanceof Error) return serializer(value) if (!value || typeof value !== 'object') return value @@ -128,6 +153,13 @@ export const serializeErrorFormat = format((info, opts) => { const seen = new WeakSet([record]) for (const key of Object.keys(record)) record[key] = walk(record[key], seen) const splat = record[SPLAT] - if (Array.isArray(splat)) record[SPLAT] = replaceErrors(splat, serializer, new Map()) + if (Array.isArray(splat)) { + // Gated per argument. A cyclic container always takes a back-edge to its replacement, which + // counts as a change, so without this an argument holding no error at all would be rebuilt — + // losing its identity and any symbol or non-enumerable property with it. + const rebuilt = new Map() + const next = splat.map((value) => (holdsError(value, new Map()) ? replaceErrors(value, serializer, rebuilt) : value)) + if (next.some((value, index) => value !== splat[index])) record[SPLAT] = next + } return record as unknown as TransformableInfo }) From 4cfaccc5ab140275405d4048e0a5aba85ad04ff6 Mon Sep 17 00:00:00 2001 From: Sam Curry Date: Mon, 31 Aug 2026 11:35:38 +0800 Subject: [PATCH 09/10] test: cover redactFormat composed on its own Checking the claim that reverting any one piece of these fixes breaks a test showed it was not true of `redact-format.ts` or `index.ts`. Both carry the `errorSerializer` through to redaction, and once a record that is itself an error is serialized, `serializeErrorFormat` leaves nothing for redaction to substitute in the `createLogger` pipeline at all. `redactFormat` is a public export, though, and composed on its own it is the only thing between a `DOMException` and a deep clone that cannot rebuild one. That path had no test; it has one now, along with the rest of the format's behaviour. The pass-through in `createLogger` stays, with a comment saying it is for consistency rather than effect. It matters for a caller composing `redactFormat` themselves, and it keeps the shapes aligned if a future path ever does let a live error reach redaction. Co-Authored-By: Claude Opus 5 (1M context) --- src/index.ts | 4 ++++ src/redact-format.spec.ts | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 src/redact-format.spec.ts diff --git a/src/index.ts b/src/index.ts index bb9751c..7b2df00 100644 --- a/src/index.ts +++ b/src/index.ts @@ -271,6 +271,10 @@ export function createLogger(options: CreateLoggerOptions): any { const loggerFormats: Format[] = [serializeErrorFormat({ serializer: errorSerializer })] if (mapAuditForOtel) loggerFormats.push(mapAuditLevelForOtel()) if (omitPaths) loggerFormats.push(omitFormat({ paths: omitPaths })) + // `errorSerializer` is passed for consistency rather than for effect: `serializeErrorFormat` runs + // first and leaves no live `Error` behind, so redaction has nothing left to substitute here. It + // matters when `redactFormat` is composed on its own, and it keeps the shapes aligned if a future + // path ever does let one through. if (redactPaths) loggerFormats.push(redactFormat({ paths: redactPaths, redactedValue, errorSerializer })) if (options.loggerOptions?.format) loggerFormats.push(options.loggerOptions.format) // flatten is applied last so all prior transformations are captured in the stringified values diff --git a/src/redact-format.spec.ts b/src/redact-format.spec.ts new file mode 100644 index 0000000..fa2885e --- /dev/null +++ b/src/redact-format.spec.ts @@ -0,0 +1,37 @@ +import { TransformableInfo } from 'logform' +import { LEVEL } from 'triple-beam' +import { describe, expect, it } from 'vitest' +import { redactFormat } from './redact-format' + +const run = (info: Record, opts: Record) => { + const input = { [LEVEL]: 'info', level: 'info', message: '', ...info } as TransformableInfo + const fmt = redactFormat(opts) + return fmt.transform(input, fmt.options) as unknown as Record +} + +describe('redactFormat', () => { + it('redacts the given paths', () => { + const result = run({ authorization: 'Bearer abc' }, { paths: ['authorization'] }) + expect(result.authorization).toBe('') + }) + + it('honours a custom redactedValue', () => { + const result = run({ token: 'abc' }, { paths: ['token'], redactedValue: '***' }) + expect(result.token).toBe('***') + }) + + // Composed on its own, without `serializeErrorFormat` ahead of it, this is the only thing + // standing between a `DOMException` and a deep clone that cannot rebuild one. + it('substitutes a DOMException with the supplied errorSerializer', () => { + const result = run( + { error: new DOMException('the operation timed out', 'TimeoutError') }, + { paths: ['authorization'], errorSerializer: (error: Error) => ({ kind: error.name }) }, + ) + expect(result.error).toEqual({ kind: 'TimeoutError' }) + }) + + it('falls back to the library serializer when none is supplied', () => { + const result = run({ error: new DOMException('the operation timed out', 'TimeoutError') }, { paths: ['authorization'] }) + expect(result.error).toMatchObject({ name: 'TimeoutError', message: 'the operation timed out' }) + }) +}) From 34b7a4ca245265b50ebf83e2c34e6962dedce944 Mon Sep 17 00:00:00 2001 From: Sam Curry Date: Mon, 31 Aug 2026 14:12:55 +0800 Subject: [PATCH 10/10] fix: keep what a rebuilt splat argument was not replacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilding a container to replace an error inside it dropped everything `Object.keys` does not report. Measured before the fix: object: symbol=undefined nonEnumerable=undefined nullProto: proto=Object.prototype array: extra=undefined symbol=undefined The doc comment claims the walk substitutes errors and changes nothing else, so that was an overclaim: a custom format reading `SPLAT` saw more than the error substitution. A rebuilt object is now created on the source's own prototype, and both branches copy across every own key the walk did not visit, descriptor intact — symbols, non-enumerable properties, and an array's non-index properties. `length` is skipped, since an array manages its own. Co-Authored-By: Claude Opus 5 (1M context) --- src/serialize-error-format.spec.ts | 44 ++++++++++++++++++++++++++++++ src/serialize-error-format.ts | 28 ++++++++++++++++--- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/serialize-error-format.spec.ts b/src/serialize-error-format.spec.ts index b124e94..ac544e1 100644 --- a/src/serialize-error-format.spec.ts +++ b/src/serialize-error-format.spec.ts @@ -214,4 +214,48 @@ describe('serializeErrorFormat', () => { expect(result[SPLAT][0]).toBe(untouched) expect(result[SPLAT][1].error).toMatchObject({ message: 'boom' }) }) + it('keeps the symbols and non-enumerable properties of a rebuilt splat argument', () => { + const marker = Symbol('marker') + const argument: Record = { error: new Error('boom'), [marker]: 'kept' } + Object.defineProperty(argument, 'hidden', { value: 'kept', enumerable: false }) + const input = { [LEVEL]: 'info', level: 'info', message: '', [SPLAT]: [argument] } as TransformableInfo + const fmt = serializeErrorFormat() + + const result = fmt.transform(input, fmt.options) as unknown as Record + const walked = result[SPLAT][0] as Record + + expect(walked).not.toBe(argument) + expect(walked.error).not.toBeInstanceOf(Error) + expect(walked[marker]).toBe('kept') + expect(walked.hidden).toBe('kept') + expect(Object.getOwnPropertyDescriptor(walked, 'hidden')?.enumerable).toBe(false) + }) + + it('keeps the prototype of a rebuilt null-prototype splat argument', () => { + const argument = Object.assign(Object.create(null), { error: new Error('boom') }) as object + const input = { [LEVEL]: 'info', level: 'info', message: '', [SPLAT]: [argument] } as TransformableInfo + const fmt = serializeErrorFormat() + + const result = fmt.transform(input, fmt.options) as unknown as Record + + expect(Object.getPrototypeOf(result[SPLAT][0])).toBeNull() + }) + + it('keeps the extra properties of a rebuilt splat array', () => { + const marker = Symbol('marker') + const argument = [new Error('boom')] as unknown[] & Record + argument.extra = 'kept' + argument[marker] = 'kept' + const input = { [LEVEL]: 'info', level: 'info', message: '', [SPLAT]: [argument] } as TransformableInfo + const fmt = serializeErrorFormat() + + const result = fmt.transform(input, fmt.options) as unknown as Record + const walked = result[SPLAT][0] as unknown[] & Record + + expect(Array.isArray(walked)).toBe(true) + expect(walked.length).toBe(1) + expect(walked[0]).not.toBeInstanceOf(Error) + expect(walked.extra).toBe('kept') + expect(walked[marker]).toBe('kept') + }) }) diff --git a/src/serialize-error-format.ts b/src/serialize-error-format.ts index 3e6727b..126d56b 100644 --- a/src/serialize-error-format.ts +++ b/src/serialize-error-format.ts @@ -24,7 +24,8 @@ const isPlainObject = (value: object) => { * a `Date` or a `Map` has to arrive as itself — rebuilt as a plain record it holds no own * enumerable keys, and `%j` would render `{}` in place of its value. So this recurses through * arrays and plain objects only, and rebuilds one only when it really holds an error. Anything - * untouched comes back as the same reference. + * untouched comes back as the same reference, and a container that must be rebuilt keeps its + * prototype and every own key the rebuild did not replace. * * `rebuilt` maps each source container to its replacement, so a back-edge resolves to the * replacement rather than to the unprocessed source — otherwise a rebuilt branch would link back to @@ -57,36 +58,55 @@ const holdsError = (value: unknown, seen: Map): boolean => { return found } +/** + * Carries across every own key the rebuild did not visit — symbols, non-enumerable properties, an + * array's non-index properties — so a container rebuilt to replace an error inside it keeps + * everything the replacement did not touch. `length` is skipped: an array manages its own. + */ +const carryOverUnvisited = (out: T, source: object, visited: Set): T => { + for (const key of Reflect.ownKeys(source)) { + if (visited.has(key)) continue + if (Array.isArray(source) && key === 'length') continue + Object.defineProperty(out, key, Object.getOwnPropertyDescriptor(source, key) as PropertyDescriptor) + } + return out +} + const replaceErrors = (value: unknown, serializer: ErrorSerializer, rebuilt: Map): unknown => { if (value instanceof Error) return serializer(value) if (!value || typeof value !== 'object') return value if (rebuilt.has(value)) return rebuilt.get(value) if (!Array.isArray(value) && !isPlainObject(value)) return value + const visited = new Set() + if (Array.isArray(value)) { const out: unknown[] = [] rebuilt.set(value, out) let replaced = false for (let index = 0; index < value.length; index++) { out[index] = replaceErrors(value[index], serializer, rebuilt) + visited.add(String(index)) if (out[index] !== value[index]) replaced = true } // `replaced` is true whenever a back-edge was taken, because the replacement it resolved to is // not the source. So an unreplaced container never has one pointing at its discarded copy. if (!replaced) rebuilt.set(value, value) - return replaced ? out : value + return replaced ? carryOverUnvisited(out, value, visited) : value } const source = value as Record - const out: Record = {} + // Built on the source's own prototype, so a null-prototype argument stays one. + const out = Object.create(Object.getPrototypeOf(value) as object | null) as Record rebuilt.set(value, out) let replaced = false for (const key of Object.keys(source)) { out[key] = replaceErrors(source[key], serializer, rebuilt) + visited.add(key) if (out[key] !== source[key]) replaced = true } if (!replaced) rebuilt.set(value, value) - return replaced ? out : value + return replaced ? carryOverUnvisited(out, value, visited) : value } /**