@objectstack/core 17.0.0-rc.4 → 17.0.0-rc.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +521 -0
- package/dist/index.cjs +217 -52
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +419 -45
- package/dist/index.d.ts +419 -45
- package/dist/index.js +211 -52
- package/dist/index.js.map +1 -1
- package/dist/logger.cjs +9 -1
- package/dist/logger.cjs.map +1 -1
- package/dist/logger.js +9 -1
- package/dist/logger.js.map +1 -1
- package/package.json +3 -3
package/dist/logger.cjs
CHANGED
|
@@ -143,7 +143,15 @@ var ObjectLogger = class _ObjectLogger {
|
|
|
143
143
|
redact: config.redact ?? ["password", "token", "secret", "key"],
|
|
144
144
|
sourceLocation: config.sourceLocation ?? false,
|
|
145
145
|
file: config.file,
|
|
146
|
-
|
|
146
|
+
// Per-key, because `LoggerConfig` is the AUTHOR state (ADR-0122): the
|
|
147
|
+
// schema defaults `maxSize`/`maxFiles` *inside* `rotation`, so a caller
|
|
148
|
+
// may legitimately write `{ rotation: { maxSize: '5m' } }` and this
|
|
149
|
+
// constructor — which does not parse — has to fill the other half the
|
|
150
|
+
// same way `LoggerConfigSchema.parse` would.
|
|
151
|
+
rotation: {
|
|
152
|
+
maxSize: config.rotation?.maxSize ?? "10m",
|
|
153
|
+
maxFiles: config.rotation?.maxFiles ?? 5
|
|
154
|
+
}
|
|
147
155
|
};
|
|
148
156
|
this.bindings = bindings;
|
|
149
157
|
this.redactPatterns = this.config.redact.map(tokenizeFieldName).filter((words) => words.length > 0);
|
package/dist/logger.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/logger.ts"],"sourcesContent":["// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { LoggerConfig, LogLevel } from '@objectstack/spec/system';\nimport type { Logger } from '@objectstack/spec/contracts';\n\n// Re-export the contract type so consumers can do\n// `import type { Logger } from '@objectstack/core/logger'` without also\n// pulling `@objectstack/spec` into their bundle graph manually.\nexport type { Logger };\n\nconst LEVEL_ORDER: Record<LogLevel, number> = {\n debug: 0,\n info: 1,\n warn: 2,\n error: 3,\n fatal: 4,\n silent: 5,\n};\n\nconst LEVEL_COLORS: Record<LogLevel, string> = {\n debug: '\\x1b[36m',\n info: '\\x1b[32m',\n warn: '\\x1b[33m',\n error: '\\x1b[31m',\n fatal: '\\x1b[35m',\n silent: '',\n};\n\nconst RESET = '\\x1b[0m';\n\n/**\n * Split a field name into lowercase words on camelCase, `snake_case`,\n * `kebab-case`, dot and letter/digit boundaries.\n *\n * `apiKey` / `api_key` / `API_KEY` / `x-api-key` all tokenize to\n * `['api','key']`, while `monkey`, `keyword` and `tokenizer` stay a single\n * word. That difference is the whole point: it is what makes the redactor a\n * **word-boundary** matcher instead of the substring matcher it used to be\n * (#5573) — a plain `keys` field no longer reads as a secret.\n */\nfunction tokenizeFieldName(name: string): string[] {\n return name\n .replace(/([a-z0-9])([A-Z])/g, '$1 $2') // apiKey -> api Key\n .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') // APIKey -> API Key\n .replace(/([a-zA-Z])([0-9])/g, '$1 $2') // key2 -> key 2\n .split(/[^A-Za-z0-9]+/) // _ - . / space\n .filter(Boolean)\n .map((word) => word.toLowerCase());\n}\n\n/**\n * Singular form of the plural spellings the redact vocabulary actually meets\n * (`keys`, `tokens`, `secrets`, `passwords`, `passes`). Deliberately not a\n * general inflector — it only has to be right for words that end up next to a\n * redact word, and it must never turn `address`/`status` into a new word.\n */\nfunction singularizeWord(word: string): string {\n if (/(?:ss|us|is)$/.test(word)) return word; // address / status / axis\n if (/(?:ch|sh|s|x|z)es$/.test(word)) return word.slice(0, -2); // passes / boxes\n if (/[a-z0-9]s$/.test(word)) return word.slice(0, -1); // keys / tokens\n return word;\n}\n\n/**\n * Words that mark the *secret* sense of a redact word when they are glued to\n * it with no boundary to split on: `apikey`, `accesstoken`, `clientsecret`.\n *\n * Word-boundary matching covers every field name spelled the way this repo\n * spells names (camelCase config keys / snake_case machine names — Prime\n * Directive #3), but an all-lowercase concatenation has no boundary at all, so\n * `apikey` would tokenize to one word and stop being redacted. A bare\n * \"ends with `key`\" rule cannot be used to rescue it, because `monkey`,\n * `turkey` and `whiskey` end with `key` too — the exact false positives #5573\n * exists to remove. So the rescue is scoped to this explicit qualifier list:\n * `<qualifier><redact word>` is a secret, anything else glued to a redact word\n * is not.\n *\n * Consequences, on purpose:\n * - Only a **suffix** concatenation counts. `secretary` and `keyword` start\n * with a redact word and stay clear.\n * - An unlisted qualifier (`foobarkey`) is not redacted. The fix is to spell\n * the field `fooBarKey` / `foo_bar_key`, which matches generically — or to\n * add the word here.\n */\nconst CONCATENATED_SECRET_QUALIFIERS = new Set([\n 'access',\n 'account',\n 'admin',\n 'api',\n 'app',\n 'auth',\n 'bearer',\n 'client',\n 'csrf',\n 'db',\n 'database',\n 'encryption',\n 'id',\n 'jwt',\n 'master',\n 'oauth',\n 'private',\n 'public',\n 'refresh',\n 'root',\n 'secret',\n 'service',\n 'session',\n 'shared',\n 'sign',\n 'signing',\n 'ssh',\n 'token',\n 'user',\n 'webhook',\n 'xsrf',\n]);\n\n/** `apikey`/`apikeys` vs `key` — see {@link CONCATENATED_SECRET_QUALIFIERS}. */\nfunction isQualifiedConcatenation(word: string, redactWord: string): boolean {\n for (const base of [word, singularizeWord(word)]) {\n if (base.length <= redactWord.length || !base.endsWith(redactWord)) continue;\n if (CONCATENATED_SECRET_QUALIFIERS.has(base.slice(0, base.length - redactWord.length))) return true;\n }\n return false;\n}\n\n/** Does `words` contain `run` as a consecutive sub-sequence? */\nfunction containsWordRun(words: string[], run: string[]): boolean {\n for (let i = 0; i + run.length <= words.length; i++) {\n if (run.every((word, offset) => words[i + offset] === word)) return true;\n }\n return false;\n}\n\n/**\n * Word-boundary match of one configured redact pattern against one field name,\n * both already tokenized by {@link tokenizeFieldName}.\n *\n * The plural rule is the one subtlety, and it is the maintainer's ruling on\n * #5573 made consistent with itself: a **bare** plural names a collection or a\n * count, not a secret (`keys` on a Zod `unrecognized_keys` issue, `tokens` on\n * an LLM usage record), so it is left alone; a plural **inside a compound**\n * still names the secret (`apiKeys: ['sk-…']`, `refresh_tokens`) and is\n * redacted. Singular words match everywhere, compound or not.\n */\nfunction fieldWordsMatchPattern(nameWords: string[], patternWords: string[]): boolean {\n if (patternWords.length === 0 || nameWords.length === 0) return false;\n\n // A multi-word pattern (`apiKey`, `api_key`) matches a consecutive run of\n // the same words, or those words written as one concatenated token.\n if (patternWords.length > 1) {\n const glued = patternWords.join('');\n return (\n containsWordRun(nameWords, patternWords) ||\n nameWords.some((word) => word === glued || singularizeWord(word) === glued)\n );\n }\n\n const redactWord = patternWords[0];\n const isCompound = nameWords.filter((word) => /[a-z]/.test(word)).length > 1;\n return nameWords.some(\n (word) =>\n word === redactWord ||\n (isCompound && singularizeWord(word) === redactWord) ||\n isQualifiedConcatenation(word, redactWord),\n );\n}\n\n/**\n * Whether ANSI color may be written to the given stream.\n *\n * Follows the https://no-color.org convention: a non-empty `NO_COLOR` env var\n * disables color regardless of TTY, and non-TTY destinations (pipes, CI logs,\n * redirected output) always get plain text so plain-text log scanners see\n * uncolored level tags. Browser bundles have no `process`/TTY → plain text.\n */\nfunction colorEnabled(stream: { isTTY?: boolean } | undefined): boolean {\n if (typeof process !== 'undefined') {\n const noColor = (process as any).env?.NO_COLOR;\n if (noColor !== undefined && noColor !== '') return false;\n }\n return Boolean(stream?.isTTY);\n}\n\n/**\n * Resolve a Node builtin without putting it in this module's import graph.\n *\n * This entry is deliberately browser-safe — `@objectstack/client` bundles it —\n * so `fs`/`path` must never be imported statically. A lazy `require()` used to\n * meet that bar, but esbuild rewrites it to the `__require` shim in the ESM\n * output, which throws `Dynamic require of \"fs\" is not supported`. Every Node\n * ESM consumer (`os serve`, `os dev`) therefore lost file logging (#3110).\n * `process.getBuiltinModule` is a plain method call — opaque to bundlers — and\n * works in both module systems.\n */\nfunction loadNodeBuiltin<T>(id: string): T | undefined {\n if (typeof process === 'undefined') return undefined;\n\n const getBuiltinModule = (process as { getBuiltinModule?: (id: string) => unknown }).getBuiltinModule;\n if (typeof getBuiltinModule === 'function') {\n try {\n return getBuiltinModule.call(process, `node:${id}`) as T;\n } catch {\n return undefined;\n }\n }\n\n // Node < 20.16 / < 22.3 predates `getBuiltinModule`. Real `require` still\n // resolves in the CJS build; in the ESM build this is the shim that throws,\n // which the caller now reports rather than swallows.\n try {\n return require(id) as T;\n } catch {\n return undefined;\n }\n}\n\nexport class ObjectLogger implements Logger {\n private config: Required<Omit<LoggerConfig, 'file' | 'rotation' | 'name'>> & {\n file?: string;\n rotation?: { maxSize: string; maxFiles: number };\n name?: string;\n };\n private bindings: Record<string, any>;\n /** `config.redact`, tokenized once — see {@link fieldWordsMatchPattern}. */\n private redactPatterns: string[][];\n private fileStream?: any;\n /** Only the logger that opened the stream may close it — children share it. */\n private ownsFileStream = false;\n private fileLoggingDisabled = false;\n\n constructor(config: Partial<LoggerConfig> = {}, bindings: Record<string, any> = {}) {\n this.config = {\n name: config.name,\n level: config.level ?? 'info',\n format: config.format ?? 'pretty',\n redact: config.redact ?? ['password', 'token', 'secret', 'key'],\n sourceLocation: config.sourceLocation ?? false,\n file: config.file,\n rotation: config.rotation ?? { maxSize: '10m', maxFiles: 5 },\n };\n this.bindings = bindings;\n this.redactPatterns = this.config.redact.map(tokenizeFieldName).filter((words) => words.length > 0);\n\n if (this.config.file && typeof process !== 'undefined') {\n this.openFileStream(this.config.file);\n }\n }\n\n private openFileStream(path: string) {\n const fs = loadNodeBuiltin<typeof import('node:fs')>('fs');\n const nodePath = loadNodeBuiltin<typeof import('node:path')>('path');\n if (!fs || !nodePath) {\n this.disableFileLogging(path, 'no filesystem access in this runtime');\n return;\n }\n\n try {\n fs.mkdirSync(nodePath.dirname(path), { recursive: true });\n const stream = fs.createWriteStream(path, { flags: 'a' });\n // `createWriteStream` reports open failures (EACCES, EISDIR, …)\n // asynchronously. An 'error' event with no listener is fatal to the\n // process, so file logging must degrade here rather than take the\n // host down.\n stream.on('error', (err: Error) => this.disableFileLogging(path, err.message));\n this.fileStream = stream;\n this.ownsFileStream = true;\n } catch (err) {\n this.disableFileLogging(path, (err as Error).message);\n }\n }\n\n /**\n * Report — once — that an explicitly configured `file` destination is not\n * being written, and stop trying.\n *\n * Deliberately not routed through `write()`: this says the logger cannot\n * honour its own config, so `level` must not filter it. The bare `catch {}`\n * this replaces is exactly how #3110 stayed hidden.\n */\n private disableFileLogging(path: string, reason: string) {\n this.fileStream = undefined;\n this.ownsFileStream = false;\n if (this.fileLoggingDisabled) return;\n this.fileLoggingDisabled = true;\n\n const label = this.config.name ? `[${this.config.name}] ` : '';\n const notice = `${label}logger: file logging disabled — cannot write to ${path}: ${reason}`;\n if (typeof process !== 'undefined' && (process as any).stderr) {\n (process as any).stderr.write(notice + '\\n');\n } else if (typeof console !== 'undefined') {\n console.warn(notice);\n }\n }\n\n private isEnabled(level: LogLevel): boolean {\n return LEVEL_ORDER[level] >= LEVEL_ORDER[this.config.level];\n }\n\n /**\n * Whether a meta field name names one of the configured secrets.\n *\n * Until #5573 this was `lower.includes(pattern)`, which redacted every\n * field whose name merely *contained* a redact word — `keys`, `keyword`,\n * `tokens`, `monkey`, `secretary` — and replaced its value with\n * `***REDACTED***`, so the reader lost the fact AND was told a secret had\n * been withheld. Matching is now on word boundaries: `key` matches\n * `apiKey` / `api_key`, not `keys` / `monkey` / `keyword`.\n */\n private isRedactedFieldName(key: string): boolean {\n const nameWords = tokenizeFieldName(key);\n return this.redactPatterns.some((pattern) => fieldWordsMatchPattern(nameWords, pattern));\n }\n\n private redactSensitive(obj: any): any {\n if (!obj || typeof obj !== 'object') return obj;\n const redacted = Array.isArray(obj) ? [...obj] : { ...obj };\n for (const key in redacted) {\n if (this.isRedactedFieldName(key)) {\n redacted[key] = '***REDACTED***';\n } else if (typeof redacted[key] === 'object' && redacted[key] !== null) {\n redacted[key] = this.redactSensitive(redacted[key]);\n }\n }\n return redacted;\n }\n\n private write(level: LogLevel, message: string, meta?: Record<string, any>, error?: Error) {\n if (!this.isEnabled(level)) return;\n\n const context = this.redactSensitive({\n ...this.bindings,\n ...meta,\n ...(error ? { error: { message: error.message, stack: error.stack } } : {}),\n });\n\n const hasContext = Object.keys(context).length > 0;\n const ts = new Date().toISOString();\n\n const isErrorLevel = level === 'error' || level === 'fatal';\n const proc = typeof process !== 'undefined' ? (process as any) : undefined;\n const stream = proc ? (isErrorLevel ? proc.stderr : proc.stdout) : undefined;\n\n let line: string; // console output — may carry ANSI color\n let plainLine: string; // file output — never colored\n\n if (this.config.format === 'json') {\n line = plainLine = JSON.stringify({\n time: ts,\n level,\n ...(this.config.name ? { name: this.config.name } : {}),\n msg: message,\n ...context,\n });\n } else if (this.config.format === 'text') {\n const parts = [ts, level.toUpperCase(), message];\n if (hasContext) parts.push(JSON.stringify(context));\n line = plainLine = parts.join(' | ');\n } else {\n // pretty\n const label = this.config.name ? `[${this.config.name}] ` : '';\n const head = `${ts} ${level.toUpperCase()}`;\n let tail = ` ${label}${message}`;\n if (hasContext) tail += ` ${JSON.stringify(context)}`;\n plainLine = head + tail;\n const color = LEVEL_COLORS[level] || '';\n line = color && colorEnabled(stream) ? `${color}${head}${RESET}${tail}` : plainLine;\n }\n\n // Browser-safe output: prefer process streams when available, otherwise\n // fall back to console. `process` may be missing entirely (browsers) or\n // present without stdio streams (bundler shims) — both fall through to\n // console. The previous unguarded `process.stderr?.write` threw\n // `ReferenceError: process is not defined` in browsers because\n // `process` itself is the missing global, not just its `stderr` field.\n if (stream) {\n stream.write(line + '\\n');\n } else if (typeof console !== 'undefined') {\n const fn =\n level === 'error' || level === 'fatal' ? console.error\n : level === 'warn' ? console.warn\n : level === 'debug' ? console.debug\n : console.log;\n fn(line);\n }\n\n if (this.fileStream) {\n this.fileStream.write(plainLine + '\\n');\n }\n }\n\n debug(message: string, meta?: Record<string, any>): void {\n this.write('debug', message, meta);\n }\n\n info(message: string, meta?: Record<string, any>): void {\n this.write('info', message, meta);\n }\n\n warn(message: string, meta?: Record<string, any>): void {\n this.write('warn', message, meta);\n }\n\n error(message: string, errorOrMeta?: Error | Record<string, any>, meta?: Record<string, any>): void {\n this.writeErrorLike('error', message, errorOrMeta, meta);\n }\n\n fatal(message: string, errorOrMeta?: Error | Record<string, any>, meta?: Record<string, any>): void {\n this.writeErrorLike('fatal', message, errorOrMeta, meta);\n }\n\n /**\n * `error`/`fatal` dispatch — the two levels whose contract has an `Error`\n * slot in front of `meta`.\n *\n * The `Logger` contract declares `error(message, error?: Error, meta?)`, and\n * `ObjectLogger` additionally tolerates a **meta object** in the `error`\n * slot because many in-repo call sites write `logger.error(msg, { … })`.\n * That tolerance is fine; dropping a parameter the contract *declares* is\n * not, and that is what the previous dispatch did:\n *\n * if (errorOrMeta instanceof Error) this.write(level, message, meta, errorOrMeta);\n * else this.write(level, message, errorOrMeta);\n *\n * With `error === undefined` the `else` branch passed `undefined` as the\n * meta and **never read the third argument**, so every contract-shaped\n * `logger.error(msg, undefined, { … })` call rendered a bare message with\n * its diagnostics silently gone — ~15 such call sites across `metadata`,\n * `metadata-protocol`, `client` and `core/security`, plus the connector\n * reconcile seam that found this (#5575). The contract's two sibling\n * implementations (`ConsoleLogger`/`JsonLogger` in `@objectstack/observability`)\n * both honour the slot, so the contract was right and this class was the\n * outlier — declared ≠ enforced, Prime Directive #10.\n *\n * All three shapes are now honoured. When both slots carry meta, `meta`\n * (the later, more specific argument) wins on a key collision.\n */\n private writeErrorLike(\n level: 'error' | 'fatal',\n message: string,\n errorOrMeta?: Error | Record<string, any>,\n meta?: Record<string, any>,\n ): void {\n if (errorOrMeta instanceof Error) {\n this.write(level, message, meta, errorOrMeta);\n return;\n }\n const merged = errorOrMeta && meta ? { ...errorOrMeta, ...meta } : (errorOrMeta ?? meta);\n this.write(level, message, merged);\n }\n\n log(message: string, ...args: any[]): void {\n this.info(message, args.length > 0 ? { args } : undefined);\n }\n\n child(context: Record<string, any>): ObjectLogger {\n // Construct without `file`, then share the parent's stream: the\n // constructor opens eagerly, so passing `file` through would open a\n // second stream per child and immediately orphan it. That leak was\n // unreachable while #3110 kept the ESM open path dead.\n const child = new ObjectLogger({ ...this.config, file: undefined }, { ...this.bindings, ...context });\n child.config.file = this.config.file;\n child.fileStream = this.fileStream;\n return child;\n }\n\n withTrace(traceId: string, spanId?: string): ObjectLogger {\n return this.child({ traceId, spanId });\n }\n\n async destroy(): Promise<void> {\n const stream = this.fileStream;\n this.fileStream = undefined;\n // Children share the opener's stream; if they closed it too, one child's\n // teardown would end file logging for the parent and every sibling,\n // whose writes then land on a closed stream and only trip the 'error'\n // handler above.\n if (!stream || !this.ownsFileStream) return;\n this.ownsFileStream = false;\n await new Promise<void>((resolve) => stream.end(resolve));\n }\n}\n\nexport function createLogger(config?: Partial<LoggerConfig>): ObjectLogger {\n return new ObjectLogger(config);\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/logger.ts"],"sourcesContent":["// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { LoggerConfig, LogLevel } from '@objectstack/spec/system';\nimport type { Logger } from '@objectstack/spec/contracts';\n\n// Re-export the contract type so consumers can do\n// `import type { Logger } from '@objectstack/core/logger'` without also\n// pulling `@objectstack/spec` into their bundle graph manually.\nexport type { Logger };\n\nconst LEVEL_ORDER: Record<LogLevel, number> = {\n debug: 0,\n info: 1,\n warn: 2,\n error: 3,\n fatal: 4,\n silent: 5,\n};\n\nconst LEVEL_COLORS: Record<LogLevel, string> = {\n debug: '\\x1b[36m',\n info: '\\x1b[32m',\n warn: '\\x1b[33m',\n error: '\\x1b[31m',\n fatal: '\\x1b[35m',\n silent: '',\n};\n\nconst RESET = '\\x1b[0m';\n\n/**\n * Split a field name into lowercase words on camelCase, `snake_case`,\n * `kebab-case`, dot and letter/digit boundaries.\n *\n * `apiKey` / `api_key` / `API_KEY` / `x-api-key` all tokenize to\n * `['api','key']`, while `monkey`, `keyword` and `tokenizer` stay a single\n * word. That difference is the whole point: it is what makes the redactor a\n * **word-boundary** matcher instead of the substring matcher it used to be\n * (#5573) — a plain `keys` field no longer reads as a secret.\n */\nfunction tokenizeFieldName(name: string): string[] {\n return name\n .replace(/([a-z0-9])([A-Z])/g, '$1 $2') // apiKey -> api Key\n .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') // APIKey -> API Key\n .replace(/([a-zA-Z])([0-9])/g, '$1 $2') // key2 -> key 2\n .split(/[^A-Za-z0-9]+/) // _ - . / space\n .filter(Boolean)\n .map((word) => word.toLowerCase());\n}\n\n/**\n * Singular form of the plural spellings the redact vocabulary actually meets\n * (`keys`, `tokens`, `secrets`, `passwords`, `passes`). Deliberately not a\n * general inflector — it only has to be right for words that end up next to a\n * redact word, and it must never turn `address`/`status` into a new word.\n */\nfunction singularizeWord(word: string): string {\n if (/(?:ss|us|is)$/.test(word)) return word; // address / status / axis\n if (/(?:ch|sh|s|x|z)es$/.test(word)) return word.slice(0, -2); // passes / boxes\n if (/[a-z0-9]s$/.test(word)) return word.slice(0, -1); // keys / tokens\n return word;\n}\n\n/**\n * Words that mark the *secret* sense of a redact word when they are glued to\n * it with no boundary to split on: `apikey`, `accesstoken`, `clientsecret`.\n *\n * Word-boundary matching covers every field name spelled the way this repo\n * spells names (camelCase config keys / snake_case machine names — Prime\n * Directive #3), but an all-lowercase concatenation has no boundary at all, so\n * `apikey` would tokenize to one word and stop being redacted. A bare\n * \"ends with `key`\" rule cannot be used to rescue it, because `monkey`,\n * `turkey` and `whiskey` end with `key` too — the exact false positives #5573\n * exists to remove. So the rescue is scoped to this explicit qualifier list:\n * `<qualifier><redact word>` is a secret, anything else glued to a redact word\n * is not.\n *\n * Consequences, on purpose:\n * - Only a **suffix** concatenation counts. `secretary` and `keyword` start\n * with a redact word and stay clear.\n * - An unlisted qualifier (`foobarkey`) is not redacted. The fix is to spell\n * the field `fooBarKey` / `foo_bar_key`, which matches generically — or to\n * add the word here.\n */\nconst CONCATENATED_SECRET_QUALIFIERS = new Set([\n 'access',\n 'account',\n 'admin',\n 'api',\n 'app',\n 'auth',\n 'bearer',\n 'client',\n 'csrf',\n 'db',\n 'database',\n 'encryption',\n 'id',\n 'jwt',\n 'master',\n 'oauth',\n 'private',\n 'public',\n 'refresh',\n 'root',\n 'secret',\n 'service',\n 'session',\n 'shared',\n 'sign',\n 'signing',\n 'ssh',\n 'token',\n 'user',\n 'webhook',\n 'xsrf',\n]);\n\n/** `apikey`/`apikeys` vs `key` — see {@link CONCATENATED_SECRET_QUALIFIERS}. */\nfunction isQualifiedConcatenation(word: string, redactWord: string): boolean {\n for (const base of [word, singularizeWord(word)]) {\n if (base.length <= redactWord.length || !base.endsWith(redactWord)) continue;\n if (CONCATENATED_SECRET_QUALIFIERS.has(base.slice(0, base.length - redactWord.length))) return true;\n }\n return false;\n}\n\n/** Does `words` contain `run` as a consecutive sub-sequence? */\nfunction containsWordRun(words: string[], run: string[]): boolean {\n for (let i = 0; i + run.length <= words.length; i++) {\n if (run.every((word, offset) => words[i + offset] === word)) return true;\n }\n return false;\n}\n\n/**\n * Word-boundary match of one configured redact pattern against one field name,\n * both already tokenized by {@link tokenizeFieldName}.\n *\n * The plural rule is the one subtlety, and it is the maintainer's ruling on\n * #5573 made consistent with itself: a **bare** plural names a collection or a\n * count, not a secret (`keys` on a Zod `unrecognized_keys` issue, `tokens` on\n * an LLM usage record), so it is left alone; a plural **inside a compound**\n * still names the secret (`apiKeys: ['sk-…']`, `refresh_tokens`) and is\n * redacted. Singular words match everywhere, compound or not.\n */\nfunction fieldWordsMatchPattern(nameWords: string[], patternWords: string[]): boolean {\n if (patternWords.length === 0 || nameWords.length === 0) return false;\n\n // A multi-word pattern (`apiKey`, `api_key`) matches a consecutive run of\n // the same words, or those words written as one concatenated token.\n if (patternWords.length > 1) {\n const glued = patternWords.join('');\n return (\n containsWordRun(nameWords, patternWords) ||\n nameWords.some((word) => word === glued || singularizeWord(word) === glued)\n );\n }\n\n const redactWord = patternWords[0];\n const isCompound = nameWords.filter((word) => /[a-z]/.test(word)).length > 1;\n return nameWords.some(\n (word) =>\n word === redactWord ||\n (isCompound && singularizeWord(word) === redactWord) ||\n isQualifiedConcatenation(word, redactWord),\n );\n}\n\n/**\n * Whether ANSI color may be written to the given stream.\n *\n * Follows the https://no-color.org convention: a non-empty `NO_COLOR` env var\n * disables color regardless of TTY, and non-TTY destinations (pipes, CI logs,\n * redirected output) always get plain text so plain-text log scanners see\n * uncolored level tags. Browser bundles have no `process`/TTY → plain text.\n */\nfunction colorEnabled(stream: { isTTY?: boolean } | undefined): boolean {\n if (typeof process !== 'undefined') {\n const noColor = (process as any).env?.NO_COLOR;\n if (noColor !== undefined && noColor !== '') return false;\n }\n return Boolean(stream?.isTTY);\n}\n\n/**\n * Resolve a Node builtin without putting it in this module's import graph.\n *\n * This entry is deliberately browser-safe — `@objectstack/client` bundles it —\n * so `fs`/`path` must never be imported statically. A lazy `require()` used to\n * meet that bar, but esbuild rewrites it to the `__require` shim in the ESM\n * output, which throws `Dynamic require of \"fs\" is not supported`. Every Node\n * ESM consumer (`os serve`, `os dev`) therefore lost file logging (#3110).\n * `process.getBuiltinModule` is a plain method call — opaque to bundlers — and\n * works in both module systems.\n */\nfunction loadNodeBuiltin<T>(id: string): T | undefined {\n if (typeof process === 'undefined') return undefined;\n\n const getBuiltinModule = (process as { getBuiltinModule?: (id: string) => unknown }).getBuiltinModule;\n if (typeof getBuiltinModule === 'function') {\n try {\n return getBuiltinModule.call(process, `node:${id}`) as T;\n } catch {\n return undefined;\n }\n }\n\n // Node < 20.16 / < 22.3 predates `getBuiltinModule`. Real `require` still\n // resolves in the CJS build; in the ESM build this is the shim that throws,\n // which the caller now reports rather than swallows.\n try {\n return require(id) as T;\n } catch {\n return undefined;\n }\n}\n\nexport class ObjectLogger implements Logger {\n private config: Required<Omit<LoggerConfig, 'file' | 'rotation' | 'name'>> & {\n file?: string;\n rotation?: { maxSize: string; maxFiles: number };\n name?: string;\n };\n private bindings: Record<string, any>;\n /** `config.redact`, tokenized once — see {@link fieldWordsMatchPattern}. */\n private redactPatterns: string[][];\n private fileStream?: any;\n /** Only the logger that opened the stream may close it — children share it. */\n private ownsFileStream = false;\n private fileLoggingDisabled = false;\n\n constructor(config: Partial<LoggerConfig> = {}, bindings: Record<string, any> = {}) {\n this.config = {\n name: config.name,\n level: config.level ?? 'info',\n format: config.format ?? 'pretty',\n redact: config.redact ?? ['password', 'token', 'secret', 'key'],\n sourceLocation: config.sourceLocation ?? false,\n file: config.file,\n // Per-key, because `LoggerConfig` is the AUTHOR state (ADR-0122): the\n // schema defaults `maxSize`/`maxFiles` *inside* `rotation`, so a caller\n // may legitimately write `{ rotation: { maxSize: '5m' } }` and this\n // constructor — which does not parse — has to fill the other half the\n // same way `LoggerConfigSchema.parse` would.\n rotation: {\n maxSize: config.rotation?.maxSize ?? '10m',\n maxFiles: config.rotation?.maxFiles ?? 5,\n },\n };\n this.bindings = bindings;\n this.redactPatterns = this.config.redact.map(tokenizeFieldName).filter((words) => words.length > 0);\n\n if (this.config.file && typeof process !== 'undefined') {\n this.openFileStream(this.config.file);\n }\n }\n\n private openFileStream(path: string) {\n const fs = loadNodeBuiltin<typeof import('node:fs')>('fs');\n const nodePath = loadNodeBuiltin<typeof import('node:path')>('path');\n if (!fs || !nodePath) {\n this.disableFileLogging(path, 'no filesystem access in this runtime');\n return;\n }\n\n try {\n fs.mkdirSync(nodePath.dirname(path), { recursive: true });\n const stream = fs.createWriteStream(path, { flags: 'a' });\n // `createWriteStream` reports open failures (EACCES, EISDIR, …)\n // asynchronously. An 'error' event with no listener is fatal to the\n // process, so file logging must degrade here rather than take the\n // host down.\n stream.on('error', (err: Error) => this.disableFileLogging(path, err.message));\n this.fileStream = stream;\n this.ownsFileStream = true;\n } catch (err) {\n this.disableFileLogging(path, (err as Error).message);\n }\n }\n\n /**\n * Report — once — that an explicitly configured `file` destination is not\n * being written, and stop trying.\n *\n * Deliberately not routed through `write()`: this says the logger cannot\n * honour its own config, so `level` must not filter it. The bare `catch {}`\n * this replaces is exactly how #3110 stayed hidden.\n */\n private disableFileLogging(path: string, reason: string) {\n this.fileStream = undefined;\n this.ownsFileStream = false;\n if (this.fileLoggingDisabled) return;\n this.fileLoggingDisabled = true;\n\n const label = this.config.name ? `[${this.config.name}] ` : '';\n const notice = `${label}logger: file logging disabled — cannot write to ${path}: ${reason}`;\n if (typeof process !== 'undefined' && (process as any).stderr) {\n (process as any).stderr.write(notice + '\\n');\n } else if (typeof console !== 'undefined') {\n console.warn(notice);\n }\n }\n\n private isEnabled(level: LogLevel): boolean {\n return LEVEL_ORDER[level] >= LEVEL_ORDER[this.config.level];\n }\n\n /**\n * Whether a meta field name names one of the configured secrets.\n *\n * Until #5573 this was `lower.includes(pattern)`, which redacted every\n * field whose name merely *contained* a redact word — `keys`, `keyword`,\n * `tokens`, `monkey`, `secretary` — and replaced its value with\n * `***REDACTED***`, so the reader lost the fact AND was told a secret had\n * been withheld. Matching is now on word boundaries: `key` matches\n * `apiKey` / `api_key`, not `keys` / `monkey` / `keyword`.\n */\n private isRedactedFieldName(key: string): boolean {\n const nameWords = tokenizeFieldName(key);\n return this.redactPatterns.some((pattern) => fieldWordsMatchPattern(nameWords, pattern));\n }\n\n private redactSensitive(obj: any): any {\n if (!obj || typeof obj !== 'object') return obj;\n const redacted = Array.isArray(obj) ? [...obj] : { ...obj };\n for (const key in redacted) {\n if (this.isRedactedFieldName(key)) {\n redacted[key] = '***REDACTED***';\n } else if (typeof redacted[key] === 'object' && redacted[key] !== null) {\n redacted[key] = this.redactSensitive(redacted[key]);\n }\n }\n return redacted;\n }\n\n private write(level: LogLevel, message: string, meta?: Record<string, any>, error?: Error) {\n if (!this.isEnabled(level)) return;\n\n const context = this.redactSensitive({\n ...this.bindings,\n ...meta,\n ...(error ? { error: { message: error.message, stack: error.stack } } : {}),\n });\n\n const hasContext = Object.keys(context).length > 0;\n const ts = new Date().toISOString();\n\n const isErrorLevel = level === 'error' || level === 'fatal';\n const proc = typeof process !== 'undefined' ? (process as any) : undefined;\n const stream = proc ? (isErrorLevel ? proc.stderr : proc.stdout) : undefined;\n\n let line: string; // console output — may carry ANSI color\n let plainLine: string; // file output — never colored\n\n if (this.config.format === 'json') {\n line = plainLine = JSON.stringify({\n time: ts,\n level,\n ...(this.config.name ? { name: this.config.name } : {}),\n msg: message,\n ...context,\n });\n } else if (this.config.format === 'text') {\n const parts = [ts, level.toUpperCase(), message];\n if (hasContext) parts.push(JSON.stringify(context));\n line = plainLine = parts.join(' | ');\n } else {\n // pretty\n const label = this.config.name ? `[${this.config.name}] ` : '';\n const head = `${ts} ${level.toUpperCase()}`;\n let tail = ` ${label}${message}`;\n if (hasContext) tail += ` ${JSON.stringify(context)}`;\n plainLine = head + tail;\n const color = LEVEL_COLORS[level] || '';\n line = color && colorEnabled(stream) ? `${color}${head}${RESET}${tail}` : plainLine;\n }\n\n // Browser-safe output: prefer process streams when available, otherwise\n // fall back to console. `process` may be missing entirely (browsers) or\n // present without stdio streams (bundler shims) — both fall through to\n // console. The previous unguarded `process.stderr?.write` threw\n // `ReferenceError: process is not defined` in browsers because\n // `process` itself is the missing global, not just its `stderr` field.\n if (stream) {\n stream.write(line + '\\n');\n } else if (typeof console !== 'undefined') {\n const fn =\n level === 'error' || level === 'fatal' ? console.error\n : level === 'warn' ? console.warn\n : level === 'debug' ? console.debug\n : console.log;\n fn(line);\n }\n\n if (this.fileStream) {\n this.fileStream.write(plainLine + '\\n');\n }\n }\n\n debug(message: string, meta?: Record<string, any>): void {\n this.write('debug', message, meta);\n }\n\n info(message: string, meta?: Record<string, any>): void {\n this.write('info', message, meta);\n }\n\n warn(message: string, meta?: Record<string, any>): void {\n this.write('warn', message, meta);\n }\n\n error(message: string, errorOrMeta?: Error | Record<string, any>, meta?: Record<string, any>): void {\n this.writeErrorLike('error', message, errorOrMeta, meta);\n }\n\n fatal(message: string, errorOrMeta?: Error | Record<string, any>, meta?: Record<string, any>): void {\n this.writeErrorLike('fatal', message, errorOrMeta, meta);\n }\n\n /**\n * `error`/`fatal` dispatch — the two levels whose contract has an `Error`\n * slot in front of `meta`.\n *\n * The `Logger` contract declares `error(message, error?: Error, meta?)`, and\n * `ObjectLogger` additionally tolerates a **meta object** in the `error`\n * slot because many in-repo call sites write `logger.error(msg, { … })`.\n * That tolerance is fine; dropping a parameter the contract *declares* is\n * not, and that is what the previous dispatch did:\n *\n * if (errorOrMeta instanceof Error) this.write(level, message, meta, errorOrMeta);\n * else this.write(level, message, errorOrMeta);\n *\n * With `error === undefined` the `else` branch passed `undefined` as the\n * meta and **never read the third argument**, so every contract-shaped\n * `logger.error(msg, undefined, { … })` call rendered a bare message with\n * its diagnostics silently gone — ~15 such call sites across `metadata`,\n * `metadata-protocol`, `client` and `core/security`, plus the connector\n * reconcile seam that found this (#5575). The contract's two sibling\n * implementations (`ConsoleLogger`/`JsonLogger` in `@objectstack/observability`)\n * both honour the slot, so the contract was right and this class was the\n * outlier — declared ≠ enforced, Prime Directive #10.\n *\n * All three shapes are now honoured. When both slots carry meta, `meta`\n * (the later, more specific argument) wins on a key collision.\n */\n private writeErrorLike(\n level: 'error' | 'fatal',\n message: string,\n errorOrMeta?: Error | Record<string, any>,\n meta?: Record<string, any>,\n ): void {\n if (errorOrMeta instanceof Error) {\n this.write(level, message, meta, errorOrMeta);\n return;\n }\n const merged = errorOrMeta && meta ? { ...errorOrMeta, ...meta } : (errorOrMeta ?? meta);\n this.write(level, message, merged);\n }\n\n log(message: string, ...args: any[]): void {\n this.info(message, args.length > 0 ? { args } : undefined);\n }\n\n child(context: Record<string, any>): ObjectLogger {\n // Construct without `file`, then share the parent's stream: the\n // constructor opens eagerly, so passing `file` through would open a\n // second stream per child and immediately orphan it. That leak was\n // unreachable while #3110 kept the ESM open path dead.\n const child = new ObjectLogger({ ...this.config, file: undefined }, { ...this.bindings, ...context });\n child.config.file = this.config.file;\n child.fileStream = this.fileStream;\n return child;\n }\n\n withTrace(traceId: string, spanId?: string): ObjectLogger {\n return this.child({ traceId, spanId });\n }\n\n async destroy(): Promise<void> {\n const stream = this.fileStream;\n this.fileStream = undefined;\n // Children share the opener's stream; if they closed it too, one child's\n // teardown would end file logging for the parent and every sibling,\n // whose writes then land on a closed stream and only trip the 'error'\n // handler above.\n if (!stream || !this.ownsFileStream) return;\n this.ownsFileStream = false;\n await new Promise<void>((resolve) => stream.end(resolve));\n }\n}\n\nexport function createLogger(config?: Partial<LoggerConfig>): ObjectLogger {\n return new ObjectLogger(config);\n}\n"],"mappings":"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","names":[]}
|
package/dist/logger.js
CHANGED
|
@@ -125,7 +125,15 @@ var ObjectLogger = class _ObjectLogger {
|
|
|
125
125
|
redact: config.redact ?? ["password", "token", "secret", "key"],
|
|
126
126
|
sourceLocation: config.sourceLocation ?? false,
|
|
127
127
|
file: config.file,
|
|
128
|
-
|
|
128
|
+
// Per-key, because `LoggerConfig` is the AUTHOR state (ADR-0122): the
|
|
129
|
+
// schema defaults `maxSize`/`maxFiles` *inside* `rotation`, so a caller
|
|
130
|
+
// may legitimately write `{ rotation: { maxSize: '5m' } }` and this
|
|
131
|
+
// constructor — which does not parse — has to fill the other half the
|
|
132
|
+
// same way `LoggerConfigSchema.parse` would.
|
|
133
|
+
rotation: {
|
|
134
|
+
maxSize: config.rotation?.maxSize ?? "10m",
|
|
135
|
+
maxFiles: config.rotation?.maxFiles ?? 5
|
|
136
|
+
}
|
|
129
137
|
};
|
|
130
138
|
this.bindings = bindings;
|
|
131
139
|
this.redactPatterns = this.config.redact.map(tokenizeFieldName).filter((words) => words.length > 0);
|
package/dist/logger.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/logger.ts"],"sourcesContent":["// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { LoggerConfig, LogLevel } from '@objectstack/spec/system';\nimport type { Logger } from '@objectstack/spec/contracts';\n\n// Re-export the contract type so consumers can do\n// `import type { Logger } from '@objectstack/core/logger'` without also\n// pulling `@objectstack/spec` into their bundle graph manually.\nexport type { Logger };\n\nconst LEVEL_ORDER: Record<LogLevel, number> = {\n debug: 0,\n info: 1,\n warn: 2,\n error: 3,\n fatal: 4,\n silent: 5,\n};\n\nconst LEVEL_COLORS: Record<LogLevel, string> = {\n debug: '\\x1b[36m',\n info: '\\x1b[32m',\n warn: '\\x1b[33m',\n error: '\\x1b[31m',\n fatal: '\\x1b[35m',\n silent: '',\n};\n\nconst RESET = '\\x1b[0m';\n\n/**\n * Split a field name into lowercase words on camelCase, `snake_case`,\n * `kebab-case`, dot and letter/digit boundaries.\n *\n * `apiKey` / `api_key` / `API_KEY` / `x-api-key` all tokenize to\n * `['api','key']`, while `monkey`, `keyword` and `tokenizer` stay a single\n * word. That difference is the whole point: it is what makes the redactor a\n * **word-boundary** matcher instead of the substring matcher it used to be\n * (#5573) — a plain `keys` field no longer reads as a secret.\n */\nfunction tokenizeFieldName(name: string): string[] {\n return name\n .replace(/([a-z0-9])([A-Z])/g, '$1 $2') // apiKey -> api Key\n .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') // APIKey -> API Key\n .replace(/([a-zA-Z])([0-9])/g, '$1 $2') // key2 -> key 2\n .split(/[^A-Za-z0-9]+/) // _ - . / space\n .filter(Boolean)\n .map((word) => word.toLowerCase());\n}\n\n/**\n * Singular form of the plural spellings the redact vocabulary actually meets\n * (`keys`, `tokens`, `secrets`, `passwords`, `passes`). Deliberately not a\n * general inflector — it only has to be right for words that end up next to a\n * redact word, and it must never turn `address`/`status` into a new word.\n */\nfunction singularizeWord(word: string): string {\n if (/(?:ss|us|is)$/.test(word)) return word; // address / status / axis\n if (/(?:ch|sh|s|x|z)es$/.test(word)) return word.slice(0, -2); // passes / boxes\n if (/[a-z0-9]s$/.test(word)) return word.slice(0, -1); // keys / tokens\n return word;\n}\n\n/**\n * Words that mark the *secret* sense of a redact word when they are glued to\n * it with no boundary to split on: `apikey`, `accesstoken`, `clientsecret`.\n *\n * Word-boundary matching covers every field name spelled the way this repo\n * spells names (camelCase config keys / snake_case machine names — Prime\n * Directive #3), but an all-lowercase concatenation has no boundary at all, so\n * `apikey` would tokenize to one word and stop being redacted. A bare\n * \"ends with `key`\" rule cannot be used to rescue it, because `monkey`,\n * `turkey` and `whiskey` end with `key` too — the exact false positives #5573\n * exists to remove. So the rescue is scoped to this explicit qualifier list:\n * `<qualifier><redact word>` is a secret, anything else glued to a redact word\n * is not.\n *\n * Consequences, on purpose:\n * - Only a **suffix** concatenation counts. `secretary` and `keyword` start\n * with a redact word and stay clear.\n * - An unlisted qualifier (`foobarkey`) is not redacted. The fix is to spell\n * the field `fooBarKey` / `foo_bar_key`, which matches generically — or to\n * add the word here.\n */\nconst CONCATENATED_SECRET_QUALIFIERS = new Set([\n 'access',\n 'account',\n 'admin',\n 'api',\n 'app',\n 'auth',\n 'bearer',\n 'client',\n 'csrf',\n 'db',\n 'database',\n 'encryption',\n 'id',\n 'jwt',\n 'master',\n 'oauth',\n 'private',\n 'public',\n 'refresh',\n 'root',\n 'secret',\n 'service',\n 'session',\n 'shared',\n 'sign',\n 'signing',\n 'ssh',\n 'token',\n 'user',\n 'webhook',\n 'xsrf',\n]);\n\n/** `apikey`/`apikeys` vs `key` — see {@link CONCATENATED_SECRET_QUALIFIERS}. */\nfunction isQualifiedConcatenation(word: string, redactWord: string): boolean {\n for (const base of [word, singularizeWord(word)]) {\n if (base.length <= redactWord.length || !base.endsWith(redactWord)) continue;\n if (CONCATENATED_SECRET_QUALIFIERS.has(base.slice(0, base.length - redactWord.length))) return true;\n }\n return false;\n}\n\n/** Does `words` contain `run` as a consecutive sub-sequence? */\nfunction containsWordRun(words: string[], run: string[]): boolean {\n for (let i = 0; i + run.length <= words.length; i++) {\n if (run.every((word, offset) => words[i + offset] === word)) return true;\n }\n return false;\n}\n\n/**\n * Word-boundary match of one configured redact pattern against one field name,\n * both already tokenized by {@link tokenizeFieldName}.\n *\n * The plural rule is the one subtlety, and it is the maintainer's ruling on\n * #5573 made consistent with itself: a **bare** plural names a collection or a\n * count, not a secret (`keys` on a Zod `unrecognized_keys` issue, `tokens` on\n * an LLM usage record), so it is left alone; a plural **inside a compound**\n * still names the secret (`apiKeys: ['sk-…']`, `refresh_tokens`) and is\n * redacted. Singular words match everywhere, compound or not.\n */\nfunction fieldWordsMatchPattern(nameWords: string[], patternWords: string[]): boolean {\n if (patternWords.length === 0 || nameWords.length === 0) return false;\n\n // A multi-word pattern (`apiKey`, `api_key`) matches a consecutive run of\n // the same words, or those words written as one concatenated token.\n if (patternWords.length > 1) {\n const glued = patternWords.join('');\n return (\n containsWordRun(nameWords, patternWords) ||\n nameWords.some((word) => word === glued || singularizeWord(word) === glued)\n );\n }\n\n const redactWord = patternWords[0];\n const isCompound = nameWords.filter((word) => /[a-z]/.test(word)).length > 1;\n return nameWords.some(\n (word) =>\n word === redactWord ||\n (isCompound && singularizeWord(word) === redactWord) ||\n isQualifiedConcatenation(word, redactWord),\n );\n}\n\n/**\n * Whether ANSI color may be written to the given stream.\n *\n * Follows the https://no-color.org convention: a non-empty `NO_COLOR` env var\n * disables color regardless of TTY, and non-TTY destinations (pipes, CI logs,\n * redirected output) always get plain text so plain-text log scanners see\n * uncolored level tags. Browser bundles have no `process`/TTY → plain text.\n */\nfunction colorEnabled(stream: { isTTY?: boolean } | undefined): boolean {\n if (typeof process !== 'undefined') {\n const noColor = (process as any).env?.NO_COLOR;\n if (noColor !== undefined && noColor !== '') return false;\n }\n return Boolean(stream?.isTTY);\n}\n\n/**\n * Resolve a Node builtin without putting it in this module's import graph.\n *\n * This entry is deliberately browser-safe — `@objectstack/client` bundles it —\n * so `fs`/`path` must never be imported statically. A lazy `require()` used to\n * meet that bar, but esbuild rewrites it to the `__require` shim in the ESM\n * output, which throws `Dynamic require of \"fs\" is not supported`. Every Node\n * ESM consumer (`os serve`, `os dev`) therefore lost file logging (#3110).\n * `process.getBuiltinModule` is a plain method call — opaque to bundlers — and\n * works in both module systems.\n */\nfunction loadNodeBuiltin<T>(id: string): T | undefined {\n if (typeof process === 'undefined') return undefined;\n\n const getBuiltinModule = (process as { getBuiltinModule?: (id: string) => unknown }).getBuiltinModule;\n if (typeof getBuiltinModule === 'function') {\n try {\n return getBuiltinModule.call(process, `node:${id}`) as T;\n } catch {\n return undefined;\n }\n }\n\n // Node < 20.16 / < 22.3 predates `getBuiltinModule`. Real `require` still\n // resolves in the CJS build; in the ESM build this is the shim that throws,\n // which the caller now reports rather than swallows.\n try {\n return require(id) as T;\n } catch {\n return undefined;\n }\n}\n\nexport class ObjectLogger implements Logger {\n private config: Required<Omit<LoggerConfig, 'file' | 'rotation' | 'name'>> & {\n file?: string;\n rotation?: { maxSize: string; maxFiles: number };\n name?: string;\n };\n private bindings: Record<string, any>;\n /** `config.redact`, tokenized once — see {@link fieldWordsMatchPattern}. */\n private redactPatterns: string[][];\n private fileStream?: any;\n /** Only the logger that opened the stream may close it — children share it. */\n private ownsFileStream = false;\n private fileLoggingDisabled = false;\n\n constructor(config: Partial<LoggerConfig> = {}, bindings: Record<string, any> = {}) {\n this.config = {\n name: config.name,\n level: config.level ?? 'info',\n format: config.format ?? 'pretty',\n redact: config.redact ?? ['password', 'token', 'secret', 'key'],\n sourceLocation: config.sourceLocation ?? false,\n file: config.file,\n rotation: config.rotation ?? { maxSize: '10m', maxFiles: 5 },\n };\n this.bindings = bindings;\n this.redactPatterns = this.config.redact.map(tokenizeFieldName).filter((words) => words.length > 0);\n\n if (this.config.file && typeof process !== 'undefined') {\n this.openFileStream(this.config.file);\n }\n }\n\n private openFileStream(path: string) {\n const fs = loadNodeBuiltin<typeof import('node:fs')>('fs');\n const nodePath = loadNodeBuiltin<typeof import('node:path')>('path');\n if (!fs || !nodePath) {\n this.disableFileLogging(path, 'no filesystem access in this runtime');\n return;\n }\n\n try {\n fs.mkdirSync(nodePath.dirname(path), { recursive: true });\n const stream = fs.createWriteStream(path, { flags: 'a' });\n // `createWriteStream` reports open failures (EACCES, EISDIR, …)\n // asynchronously. An 'error' event with no listener is fatal to the\n // process, so file logging must degrade here rather than take the\n // host down.\n stream.on('error', (err: Error) => this.disableFileLogging(path, err.message));\n this.fileStream = stream;\n this.ownsFileStream = true;\n } catch (err) {\n this.disableFileLogging(path, (err as Error).message);\n }\n }\n\n /**\n * Report — once — that an explicitly configured `file` destination is not\n * being written, and stop trying.\n *\n * Deliberately not routed through `write()`: this says the logger cannot\n * honour its own config, so `level` must not filter it. The bare `catch {}`\n * this replaces is exactly how #3110 stayed hidden.\n */\n private disableFileLogging(path: string, reason: string) {\n this.fileStream = undefined;\n this.ownsFileStream = false;\n if (this.fileLoggingDisabled) return;\n this.fileLoggingDisabled = true;\n\n const label = this.config.name ? `[${this.config.name}] ` : '';\n const notice = `${label}logger: file logging disabled — cannot write to ${path}: ${reason}`;\n if (typeof process !== 'undefined' && (process as any).stderr) {\n (process as any).stderr.write(notice + '\\n');\n } else if (typeof console !== 'undefined') {\n console.warn(notice);\n }\n }\n\n private isEnabled(level: LogLevel): boolean {\n return LEVEL_ORDER[level] >= LEVEL_ORDER[this.config.level];\n }\n\n /**\n * Whether a meta field name names one of the configured secrets.\n *\n * Until #5573 this was `lower.includes(pattern)`, which redacted every\n * field whose name merely *contained* a redact word — `keys`, `keyword`,\n * `tokens`, `monkey`, `secretary` — and replaced its value with\n * `***REDACTED***`, so the reader lost the fact AND was told a secret had\n * been withheld. Matching is now on word boundaries: `key` matches\n * `apiKey` / `api_key`, not `keys` / `monkey` / `keyword`.\n */\n private isRedactedFieldName(key: string): boolean {\n const nameWords = tokenizeFieldName(key);\n return this.redactPatterns.some((pattern) => fieldWordsMatchPattern(nameWords, pattern));\n }\n\n private redactSensitive(obj: any): any {\n if (!obj || typeof obj !== 'object') return obj;\n const redacted = Array.isArray(obj) ? [...obj] : { ...obj };\n for (const key in redacted) {\n if (this.isRedactedFieldName(key)) {\n redacted[key] = '***REDACTED***';\n } else if (typeof redacted[key] === 'object' && redacted[key] !== null) {\n redacted[key] = this.redactSensitive(redacted[key]);\n }\n }\n return redacted;\n }\n\n private write(level: LogLevel, message: string, meta?: Record<string, any>, error?: Error) {\n if (!this.isEnabled(level)) return;\n\n const context = this.redactSensitive({\n ...this.bindings,\n ...meta,\n ...(error ? { error: { message: error.message, stack: error.stack } } : {}),\n });\n\n const hasContext = Object.keys(context).length > 0;\n const ts = new Date().toISOString();\n\n const isErrorLevel = level === 'error' || level === 'fatal';\n const proc = typeof process !== 'undefined' ? (process as any) : undefined;\n const stream = proc ? (isErrorLevel ? proc.stderr : proc.stdout) : undefined;\n\n let line: string; // console output — may carry ANSI color\n let plainLine: string; // file output — never colored\n\n if (this.config.format === 'json') {\n line = plainLine = JSON.stringify({\n time: ts,\n level,\n ...(this.config.name ? { name: this.config.name } : {}),\n msg: message,\n ...context,\n });\n } else if (this.config.format === 'text') {\n const parts = [ts, level.toUpperCase(), message];\n if (hasContext) parts.push(JSON.stringify(context));\n line = plainLine = parts.join(' | ');\n } else {\n // pretty\n const label = this.config.name ? `[${this.config.name}] ` : '';\n const head = `${ts} ${level.toUpperCase()}`;\n let tail = ` ${label}${message}`;\n if (hasContext) tail += ` ${JSON.stringify(context)}`;\n plainLine = head + tail;\n const color = LEVEL_COLORS[level] || '';\n line = color && colorEnabled(stream) ? `${color}${head}${RESET}${tail}` : plainLine;\n }\n\n // Browser-safe output: prefer process streams when available, otherwise\n // fall back to console. `process` may be missing entirely (browsers) or\n // present without stdio streams (bundler shims) — both fall through to\n // console. The previous unguarded `process.stderr?.write` threw\n // `ReferenceError: process is not defined` in browsers because\n // `process` itself is the missing global, not just its `stderr` field.\n if (stream) {\n stream.write(line + '\\n');\n } else if (typeof console !== 'undefined') {\n const fn =\n level === 'error' || level === 'fatal' ? console.error\n : level === 'warn' ? console.warn\n : level === 'debug' ? console.debug\n : console.log;\n fn(line);\n }\n\n if (this.fileStream) {\n this.fileStream.write(plainLine + '\\n');\n }\n }\n\n debug(message: string, meta?: Record<string, any>): void {\n this.write('debug', message, meta);\n }\n\n info(message: string, meta?: Record<string, any>): void {\n this.write('info', message, meta);\n }\n\n warn(message: string, meta?: Record<string, any>): void {\n this.write('warn', message, meta);\n }\n\n error(message: string, errorOrMeta?: Error | Record<string, any>, meta?: Record<string, any>): void {\n this.writeErrorLike('error', message, errorOrMeta, meta);\n }\n\n fatal(message: string, errorOrMeta?: Error | Record<string, any>, meta?: Record<string, any>): void {\n this.writeErrorLike('fatal', message, errorOrMeta, meta);\n }\n\n /**\n * `error`/`fatal` dispatch — the two levels whose contract has an `Error`\n * slot in front of `meta`.\n *\n * The `Logger` contract declares `error(message, error?: Error, meta?)`, and\n * `ObjectLogger` additionally tolerates a **meta object** in the `error`\n * slot because many in-repo call sites write `logger.error(msg, { … })`.\n * That tolerance is fine; dropping a parameter the contract *declares* is\n * not, and that is what the previous dispatch did:\n *\n * if (errorOrMeta instanceof Error) this.write(level, message, meta, errorOrMeta);\n * else this.write(level, message, errorOrMeta);\n *\n * With `error === undefined` the `else` branch passed `undefined` as the\n * meta and **never read the third argument**, so every contract-shaped\n * `logger.error(msg, undefined, { … })` call rendered a bare message with\n * its diagnostics silently gone — ~15 such call sites across `metadata`,\n * `metadata-protocol`, `client` and `core/security`, plus the connector\n * reconcile seam that found this (#5575). The contract's two sibling\n * implementations (`ConsoleLogger`/`JsonLogger` in `@objectstack/observability`)\n * both honour the slot, so the contract was right and this class was the\n * outlier — declared ≠ enforced, Prime Directive #10.\n *\n * All three shapes are now honoured. When both slots carry meta, `meta`\n * (the later, more specific argument) wins on a key collision.\n */\n private writeErrorLike(\n level: 'error' | 'fatal',\n message: string,\n errorOrMeta?: Error | Record<string, any>,\n meta?: Record<string, any>,\n ): void {\n if (errorOrMeta instanceof Error) {\n this.write(level, message, meta, errorOrMeta);\n return;\n }\n const merged = errorOrMeta && meta ? { ...errorOrMeta, ...meta } : (errorOrMeta ?? meta);\n this.write(level, message, merged);\n }\n\n log(message: string, ...args: any[]): void {\n this.info(message, args.length > 0 ? { args } : undefined);\n }\n\n child(context: Record<string, any>): ObjectLogger {\n // Construct without `file`, then share the parent's stream: the\n // constructor opens eagerly, so passing `file` through would open a\n // second stream per child and immediately orphan it. That leak was\n // unreachable while #3110 kept the ESM open path dead.\n const child = new ObjectLogger({ ...this.config, file: undefined }, { ...this.bindings, ...context });\n child.config.file = this.config.file;\n child.fileStream = this.fileStream;\n return child;\n }\n\n withTrace(traceId: string, spanId?: string): ObjectLogger {\n return this.child({ traceId, spanId });\n }\n\n async destroy(): Promise<void> {\n const stream = this.fileStream;\n this.fileStream = undefined;\n // Children share the opener's stream; if they closed it too, one child's\n // teardown would end file logging for the parent and every sibling,\n // whose writes then land on a closed stream and only trip the 'error'\n // handler above.\n if (!stream || !this.ownsFileStream) return;\n this.ownsFileStream = false;\n await new Promise<void>((resolve) => stream.end(resolve));\n }\n}\n\nexport function createLogger(config?: Partial<LoggerConfig>): ObjectLogger {\n return new ObjectLogger(config);\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/logger.ts"],"sourcesContent":["// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { LoggerConfig, LogLevel } from '@objectstack/spec/system';\nimport type { Logger } from '@objectstack/spec/contracts';\n\n// Re-export the contract type so consumers can do\n// `import type { Logger } from '@objectstack/core/logger'` without also\n// pulling `@objectstack/spec` into their bundle graph manually.\nexport type { Logger };\n\nconst LEVEL_ORDER: Record<LogLevel, number> = {\n debug: 0,\n info: 1,\n warn: 2,\n error: 3,\n fatal: 4,\n silent: 5,\n};\n\nconst LEVEL_COLORS: Record<LogLevel, string> = {\n debug: '\\x1b[36m',\n info: '\\x1b[32m',\n warn: '\\x1b[33m',\n error: '\\x1b[31m',\n fatal: '\\x1b[35m',\n silent: '',\n};\n\nconst RESET = '\\x1b[0m';\n\n/**\n * Split a field name into lowercase words on camelCase, `snake_case`,\n * `kebab-case`, dot and letter/digit boundaries.\n *\n * `apiKey` / `api_key` / `API_KEY` / `x-api-key` all tokenize to\n * `['api','key']`, while `monkey`, `keyword` and `tokenizer` stay a single\n * word. That difference is the whole point: it is what makes the redactor a\n * **word-boundary** matcher instead of the substring matcher it used to be\n * (#5573) — a plain `keys` field no longer reads as a secret.\n */\nfunction tokenizeFieldName(name: string): string[] {\n return name\n .replace(/([a-z0-9])([A-Z])/g, '$1 $2') // apiKey -> api Key\n .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') // APIKey -> API Key\n .replace(/([a-zA-Z])([0-9])/g, '$1 $2') // key2 -> key 2\n .split(/[^A-Za-z0-9]+/) // _ - . / space\n .filter(Boolean)\n .map((word) => word.toLowerCase());\n}\n\n/**\n * Singular form of the plural spellings the redact vocabulary actually meets\n * (`keys`, `tokens`, `secrets`, `passwords`, `passes`). Deliberately not a\n * general inflector — it only has to be right for words that end up next to a\n * redact word, and it must never turn `address`/`status` into a new word.\n */\nfunction singularizeWord(word: string): string {\n if (/(?:ss|us|is)$/.test(word)) return word; // address / status / axis\n if (/(?:ch|sh|s|x|z)es$/.test(word)) return word.slice(0, -2); // passes / boxes\n if (/[a-z0-9]s$/.test(word)) return word.slice(0, -1); // keys / tokens\n return word;\n}\n\n/**\n * Words that mark the *secret* sense of a redact word when they are glued to\n * it with no boundary to split on: `apikey`, `accesstoken`, `clientsecret`.\n *\n * Word-boundary matching covers every field name spelled the way this repo\n * spells names (camelCase config keys / snake_case machine names — Prime\n * Directive #3), but an all-lowercase concatenation has no boundary at all, so\n * `apikey` would tokenize to one word and stop being redacted. A bare\n * \"ends with `key`\" rule cannot be used to rescue it, because `monkey`,\n * `turkey` and `whiskey` end with `key` too — the exact false positives #5573\n * exists to remove. So the rescue is scoped to this explicit qualifier list:\n * `<qualifier><redact word>` is a secret, anything else glued to a redact word\n * is not.\n *\n * Consequences, on purpose:\n * - Only a **suffix** concatenation counts. `secretary` and `keyword` start\n * with a redact word and stay clear.\n * - An unlisted qualifier (`foobarkey`) is not redacted. The fix is to spell\n * the field `fooBarKey` / `foo_bar_key`, which matches generically — or to\n * add the word here.\n */\nconst CONCATENATED_SECRET_QUALIFIERS = new Set([\n 'access',\n 'account',\n 'admin',\n 'api',\n 'app',\n 'auth',\n 'bearer',\n 'client',\n 'csrf',\n 'db',\n 'database',\n 'encryption',\n 'id',\n 'jwt',\n 'master',\n 'oauth',\n 'private',\n 'public',\n 'refresh',\n 'root',\n 'secret',\n 'service',\n 'session',\n 'shared',\n 'sign',\n 'signing',\n 'ssh',\n 'token',\n 'user',\n 'webhook',\n 'xsrf',\n]);\n\n/** `apikey`/`apikeys` vs `key` — see {@link CONCATENATED_SECRET_QUALIFIERS}. */\nfunction isQualifiedConcatenation(word: string, redactWord: string): boolean {\n for (const base of [word, singularizeWord(word)]) {\n if (base.length <= redactWord.length || !base.endsWith(redactWord)) continue;\n if (CONCATENATED_SECRET_QUALIFIERS.has(base.slice(0, base.length - redactWord.length))) return true;\n }\n return false;\n}\n\n/** Does `words` contain `run` as a consecutive sub-sequence? */\nfunction containsWordRun(words: string[], run: string[]): boolean {\n for (let i = 0; i + run.length <= words.length; i++) {\n if (run.every((word, offset) => words[i + offset] === word)) return true;\n }\n return false;\n}\n\n/**\n * Word-boundary match of one configured redact pattern against one field name,\n * both already tokenized by {@link tokenizeFieldName}.\n *\n * The plural rule is the one subtlety, and it is the maintainer's ruling on\n * #5573 made consistent with itself: a **bare** plural names a collection or a\n * count, not a secret (`keys` on a Zod `unrecognized_keys` issue, `tokens` on\n * an LLM usage record), so it is left alone; a plural **inside a compound**\n * still names the secret (`apiKeys: ['sk-…']`, `refresh_tokens`) and is\n * redacted. Singular words match everywhere, compound or not.\n */\nfunction fieldWordsMatchPattern(nameWords: string[], patternWords: string[]): boolean {\n if (patternWords.length === 0 || nameWords.length === 0) return false;\n\n // A multi-word pattern (`apiKey`, `api_key`) matches a consecutive run of\n // the same words, or those words written as one concatenated token.\n if (patternWords.length > 1) {\n const glued = patternWords.join('');\n return (\n containsWordRun(nameWords, patternWords) ||\n nameWords.some((word) => word === glued || singularizeWord(word) === glued)\n );\n }\n\n const redactWord = patternWords[0];\n const isCompound = nameWords.filter((word) => /[a-z]/.test(word)).length > 1;\n return nameWords.some(\n (word) =>\n word === redactWord ||\n (isCompound && singularizeWord(word) === redactWord) ||\n isQualifiedConcatenation(word, redactWord),\n );\n}\n\n/**\n * Whether ANSI color may be written to the given stream.\n *\n * Follows the https://no-color.org convention: a non-empty `NO_COLOR` env var\n * disables color regardless of TTY, and non-TTY destinations (pipes, CI logs,\n * redirected output) always get plain text so plain-text log scanners see\n * uncolored level tags. Browser bundles have no `process`/TTY → plain text.\n */\nfunction colorEnabled(stream: { isTTY?: boolean } | undefined): boolean {\n if (typeof process !== 'undefined') {\n const noColor = (process as any).env?.NO_COLOR;\n if (noColor !== undefined && noColor !== '') return false;\n }\n return Boolean(stream?.isTTY);\n}\n\n/**\n * Resolve a Node builtin without putting it in this module's import graph.\n *\n * This entry is deliberately browser-safe — `@objectstack/client` bundles it —\n * so `fs`/`path` must never be imported statically. A lazy `require()` used to\n * meet that bar, but esbuild rewrites it to the `__require` shim in the ESM\n * output, which throws `Dynamic require of \"fs\" is not supported`. Every Node\n * ESM consumer (`os serve`, `os dev`) therefore lost file logging (#3110).\n * `process.getBuiltinModule` is a plain method call — opaque to bundlers — and\n * works in both module systems.\n */\nfunction loadNodeBuiltin<T>(id: string): T | undefined {\n if (typeof process === 'undefined') return undefined;\n\n const getBuiltinModule = (process as { getBuiltinModule?: (id: string) => unknown }).getBuiltinModule;\n if (typeof getBuiltinModule === 'function') {\n try {\n return getBuiltinModule.call(process, `node:${id}`) as T;\n } catch {\n return undefined;\n }\n }\n\n // Node < 20.16 / < 22.3 predates `getBuiltinModule`. Real `require` still\n // resolves in the CJS build; in the ESM build this is the shim that throws,\n // which the caller now reports rather than swallows.\n try {\n return require(id) as T;\n } catch {\n return undefined;\n }\n}\n\nexport class ObjectLogger implements Logger {\n private config: Required<Omit<LoggerConfig, 'file' | 'rotation' | 'name'>> & {\n file?: string;\n rotation?: { maxSize: string; maxFiles: number };\n name?: string;\n };\n private bindings: Record<string, any>;\n /** `config.redact`, tokenized once — see {@link fieldWordsMatchPattern}. */\n private redactPatterns: string[][];\n private fileStream?: any;\n /** Only the logger that opened the stream may close it — children share it. */\n private ownsFileStream = false;\n private fileLoggingDisabled = false;\n\n constructor(config: Partial<LoggerConfig> = {}, bindings: Record<string, any> = {}) {\n this.config = {\n name: config.name,\n level: config.level ?? 'info',\n format: config.format ?? 'pretty',\n redact: config.redact ?? ['password', 'token', 'secret', 'key'],\n sourceLocation: config.sourceLocation ?? false,\n file: config.file,\n // Per-key, because `LoggerConfig` is the AUTHOR state (ADR-0122): the\n // schema defaults `maxSize`/`maxFiles` *inside* `rotation`, so a caller\n // may legitimately write `{ rotation: { maxSize: '5m' } }` and this\n // constructor — which does not parse — has to fill the other half the\n // same way `LoggerConfigSchema.parse` would.\n rotation: {\n maxSize: config.rotation?.maxSize ?? '10m',\n maxFiles: config.rotation?.maxFiles ?? 5,\n },\n };\n this.bindings = bindings;\n this.redactPatterns = this.config.redact.map(tokenizeFieldName).filter((words) => words.length > 0);\n\n if (this.config.file && typeof process !== 'undefined') {\n this.openFileStream(this.config.file);\n }\n }\n\n private openFileStream(path: string) {\n const fs = loadNodeBuiltin<typeof import('node:fs')>('fs');\n const nodePath = loadNodeBuiltin<typeof import('node:path')>('path');\n if (!fs || !nodePath) {\n this.disableFileLogging(path, 'no filesystem access in this runtime');\n return;\n }\n\n try {\n fs.mkdirSync(nodePath.dirname(path), { recursive: true });\n const stream = fs.createWriteStream(path, { flags: 'a' });\n // `createWriteStream` reports open failures (EACCES, EISDIR, …)\n // asynchronously. An 'error' event with no listener is fatal to the\n // process, so file logging must degrade here rather than take the\n // host down.\n stream.on('error', (err: Error) => this.disableFileLogging(path, err.message));\n this.fileStream = stream;\n this.ownsFileStream = true;\n } catch (err) {\n this.disableFileLogging(path, (err as Error).message);\n }\n }\n\n /**\n * Report — once — that an explicitly configured `file` destination is not\n * being written, and stop trying.\n *\n * Deliberately not routed through `write()`: this says the logger cannot\n * honour its own config, so `level` must not filter it. The bare `catch {}`\n * this replaces is exactly how #3110 stayed hidden.\n */\n private disableFileLogging(path: string, reason: string) {\n this.fileStream = undefined;\n this.ownsFileStream = false;\n if (this.fileLoggingDisabled) return;\n this.fileLoggingDisabled = true;\n\n const label = this.config.name ? `[${this.config.name}] ` : '';\n const notice = `${label}logger: file logging disabled — cannot write to ${path}: ${reason}`;\n if (typeof process !== 'undefined' && (process as any).stderr) {\n (process as any).stderr.write(notice + '\\n');\n } else if (typeof console !== 'undefined') {\n console.warn(notice);\n }\n }\n\n private isEnabled(level: LogLevel): boolean {\n return LEVEL_ORDER[level] >= LEVEL_ORDER[this.config.level];\n }\n\n /**\n * Whether a meta field name names one of the configured secrets.\n *\n * Until #5573 this was `lower.includes(pattern)`, which redacted every\n * field whose name merely *contained* a redact word — `keys`, `keyword`,\n * `tokens`, `monkey`, `secretary` — and replaced its value with\n * `***REDACTED***`, so the reader lost the fact AND was told a secret had\n * been withheld. Matching is now on word boundaries: `key` matches\n * `apiKey` / `api_key`, not `keys` / `monkey` / `keyword`.\n */\n private isRedactedFieldName(key: string): boolean {\n const nameWords = tokenizeFieldName(key);\n return this.redactPatterns.some((pattern) => fieldWordsMatchPattern(nameWords, pattern));\n }\n\n private redactSensitive(obj: any): any {\n if (!obj || typeof obj !== 'object') return obj;\n const redacted = Array.isArray(obj) ? [...obj] : { ...obj };\n for (const key in redacted) {\n if (this.isRedactedFieldName(key)) {\n redacted[key] = '***REDACTED***';\n } else if (typeof redacted[key] === 'object' && redacted[key] !== null) {\n redacted[key] = this.redactSensitive(redacted[key]);\n }\n }\n return redacted;\n }\n\n private write(level: LogLevel, message: string, meta?: Record<string, any>, error?: Error) {\n if (!this.isEnabled(level)) return;\n\n const context = this.redactSensitive({\n ...this.bindings,\n ...meta,\n ...(error ? { error: { message: error.message, stack: error.stack } } : {}),\n });\n\n const hasContext = Object.keys(context).length > 0;\n const ts = new Date().toISOString();\n\n const isErrorLevel = level === 'error' || level === 'fatal';\n const proc = typeof process !== 'undefined' ? (process as any) : undefined;\n const stream = proc ? (isErrorLevel ? proc.stderr : proc.stdout) : undefined;\n\n let line: string; // console output — may carry ANSI color\n let plainLine: string; // file output — never colored\n\n if (this.config.format === 'json') {\n line = plainLine = JSON.stringify({\n time: ts,\n level,\n ...(this.config.name ? { name: this.config.name } : {}),\n msg: message,\n ...context,\n });\n } else if (this.config.format === 'text') {\n const parts = [ts, level.toUpperCase(), message];\n if (hasContext) parts.push(JSON.stringify(context));\n line = plainLine = parts.join(' | ');\n } else {\n // pretty\n const label = this.config.name ? `[${this.config.name}] ` : '';\n const head = `${ts} ${level.toUpperCase()}`;\n let tail = ` ${label}${message}`;\n if (hasContext) tail += ` ${JSON.stringify(context)}`;\n plainLine = head + tail;\n const color = LEVEL_COLORS[level] || '';\n line = color && colorEnabled(stream) ? `${color}${head}${RESET}${tail}` : plainLine;\n }\n\n // Browser-safe output: prefer process streams when available, otherwise\n // fall back to console. `process` may be missing entirely (browsers) or\n // present without stdio streams (bundler shims) — both fall through to\n // console. The previous unguarded `process.stderr?.write` threw\n // `ReferenceError: process is not defined` in browsers because\n // `process` itself is the missing global, not just its `stderr` field.\n if (stream) {\n stream.write(line + '\\n');\n } else if (typeof console !== 'undefined') {\n const fn =\n level === 'error' || level === 'fatal' ? console.error\n : level === 'warn' ? console.warn\n : level === 'debug' ? console.debug\n : console.log;\n fn(line);\n }\n\n if (this.fileStream) {\n this.fileStream.write(plainLine + '\\n');\n }\n }\n\n debug(message: string, meta?: Record<string, any>): void {\n this.write('debug', message, meta);\n }\n\n info(message: string, meta?: Record<string, any>): void {\n this.write('info', message, meta);\n }\n\n warn(message: string, meta?: Record<string, any>): void {\n this.write('warn', message, meta);\n }\n\n error(message: string, errorOrMeta?: Error | Record<string, any>, meta?: Record<string, any>): void {\n this.writeErrorLike('error', message, errorOrMeta, meta);\n }\n\n fatal(message: string, errorOrMeta?: Error | Record<string, any>, meta?: Record<string, any>): void {\n this.writeErrorLike('fatal', message, errorOrMeta, meta);\n }\n\n /**\n * `error`/`fatal` dispatch — the two levels whose contract has an `Error`\n * slot in front of `meta`.\n *\n * The `Logger` contract declares `error(message, error?: Error, meta?)`, and\n * `ObjectLogger` additionally tolerates a **meta object** in the `error`\n * slot because many in-repo call sites write `logger.error(msg, { … })`.\n * That tolerance is fine; dropping a parameter the contract *declares* is\n * not, and that is what the previous dispatch did:\n *\n * if (errorOrMeta instanceof Error) this.write(level, message, meta, errorOrMeta);\n * else this.write(level, message, errorOrMeta);\n *\n * With `error === undefined` the `else` branch passed `undefined` as the\n * meta and **never read the third argument**, so every contract-shaped\n * `logger.error(msg, undefined, { … })` call rendered a bare message with\n * its diagnostics silently gone — ~15 such call sites across `metadata`,\n * `metadata-protocol`, `client` and `core/security`, plus the connector\n * reconcile seam that found this (#5575). The contract's two sibling\n * implementations (`ConsoleLogger`/`JsonLogger` in `@objectstack/observability`)\n * both honour the slot, so the contract was right and this class was the\n * outlier — declared ≠ enforced, Prime Directive #10.\n *\n * All three shapes are now honoured. When both slots carry meta, `meta`\n * (the later, more specific argument) wins on a key collision.\n */\n private writeErrorLike(\n level: 'error' | 'fatal',\n message: string,\n errorOrMeta?: Error | Record<string, any>,\n meta?: Record<string, any>,\n ): void {\n if (errorOrMeta instanceof Error) {\n this.write(level, message, meta, errorOrMeta);\n return;\n }\n const merged = errorOrMeta && meta ? { ...errorOrMeta, ...meta } : (errorOrMeta ?? meta);\n this.write(level, message, merged);\n }\n\n log(message: string, ...args: any[]): void {\n this.info(message, args.length > 0 ? { args } : undefined);\n }\n\n child(context: Record<string, any>): ObjectLogger {\n // Construct without `file`, then share the parent's stream: the\n // constructor opens eagerly, so passing `file` through would open a\n // second stream per child and immediately orphan it. That leak was\n // unreachable while #3110 kept the ESM open path dead.\n const child = new ObjectLogger({ ...this.config, file: undefined }, { ...this.bindings, ...context });\n child.config.file = this.config.file;\n child.fileStream = this.fileStream;\n return child;\n }\n\n withTrace(traceId: string, spanId?: string): ObjectLogger {\n return this.child({ traceId, spanId });\n }\n\n async destroy(): Promise<void> {\n const stream = this.fileStream;\n this.fileStream = undefined;\n // Children share the opener's stream; if they closed it too, one child's\n // teardown would end file logging for the parent and every sibling,\n // whose writes then land on a closed stream and only trip the 'error'\n // handler above.\n if (!stream || !this.ownsFileStream) return;\n this.ownsFileStream = false;\n await new Promise<void>((resolve) => stream.end(resolve));\n }\n}\n\nexport function createLogger(config?: Partial<LoggerConfig>): ObjectLogger {\n return new ObjectLogger(config);\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/core",
|
|
3
|
-
"version": "17.0.0-rc.
|
|
3
|
+
"version": "17.0.0-rc.6",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Microkernel Core for ObjectStack",
|
|
6
6
|
"type": "module",
|
|
@@ -23,11 +23,11 @@
|
|
|
23
23
|
"esbuild": "^0.28.1",
|
|
24
24
|
"typescript": "^6.0.3",
|
|
25
25
|
"vitest": "^4.1.10",
|
|
26
|
-
"@objectstack/metadata-core": "17.0.0-rc.
|
|
26
|
+
"@objectstack/metadata-core": "17.0.0-rc.6"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"zod": "^4.4.3",
|
|
30
|
-
"@objectstack/spec": "17.0.0-rc.
|
|
30
|
+
"@objectstack/spec": "17.0.0-rc.6"
|
|
31
31
|
},
|
|
32
32
|
"keywords": [
|
|
33
33
|
"objectstack",
|