Skip to content

Commit 917930d

Browse files
committed
feat(logger): implement lazy Console initialization for early bootstrap safety
Deferred Console creation until first logger use to prevent initialization errors during early Node.js bootstrap when stdout isn't ready yet. This allows the logger to be safely imported in bootstrap code without causing ERR_CONSOLE_WRITABLE_STREAM errors. Changes: - Added #getConsole() private method for lazy Console initialization - Moved Console creation from constructor to first access - Added privateConstructorArgs WeakMap for dynamic method support - Updated all privateConsole.get() calls to use #getConsole() - Dynamic console methods use WeakMap directly (can't access private methods) Benefits: - Logger can be imported during early Node.js bootstrap - Zero API changes - fully backward compatible - Minimal performance overhead (one-time initialization check) - Fixes CI build failures in smol binary bootstrap
1 parent f4db4d2 commit 917930d

File tree

1 file changed

+69
-29
lines changed

1 file changed

+69
-29
lines changed

src/logger.ts

Lines changed: 69 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ const consolePropAttributes = {
215215
}
216216
const maxIndentation = 1000
217217
const privateConsole = new WeakMap()
218+
const privateConstructorArgs = new WeakMap()
218219

219220
const consoleSymbols = Object.getOwnPropertySymbols(globalConsole)
220221

@@ -346,6 +347,7 @@ export class Logger {
346347
constructor(...args: unknown[]) {
347348
// Store constructor args for child loggers
348349
this.#constructorArgs = args
350+
privateConstructorArgs.set(this, args)
349351

350352
// Store options if provided (for future extensibility)
351353
const options = args['0']
@@ -357,20 +359,40 @@ export class Logger {
357359
this.#options = { __proto__: null }
358360
}
359361

360-
if (args.length) {
361-
privateConsole.set(this, constructConsole(...args))
362-
} else {
363-
// Create a new console that acts like the builtin one so that it will
364-
// work with Node's --frozen-intrinsics flag.
365-
const con = constructConsole({
366-
stdout: process.stdout,
367-
stderr: process.stderr,
368-
}) as typeof console & Record<string, unknown>
369-
for (const { 0: key, 1: method } of boundConsoleEntries) {
370-
con[key] = method
362+
// Note: Console initialization is now lazy (happens on first use).
363+
// This allows logger to be imported during early bootstrap before
364+
// stdout is ready, avoiding ERR_CONSOLE_WRITABLE_STREAM errors.
365+
}
366+
367+
/**
368+
* Get the Console instance for this logger, creating it lazily on first access.
369+
*
370+
* This lazy initialization allows the logger to be imported during early
371+
* Node.js bootstrap before stdout is ready, avoiding Console initialization
372+
* errors (ERR_CONSOLE_WRITABLE_STREAM).
373+
*
374+
* @private
375+
*/
376+
#getConsole(): typeof console & Record<string, unknown> {
377+
let con = privateConsole.get(this)
378+
if (!con) {
379+
// Lazy initialization - create Console on first use.
380+
if (this.#constructorArgs.length) {
381+
con = constructConsole(...this.#constructorArgs)
382+
} else {
383+
// Create a new console that acts like the builtin one so that it will
384+
// work with Node's --frozen-intrinsics flag.
385+
con = constructConsole({
386+
stdout: process.stdout,
387+
stderr: process.stderr,
388+
}) as typeof console & Record<string, unknown>
389+
for (const { 0: key, 1: method } of boundConsoleEntries) {
390+
con[key] = method
391+
}
371392
}
372393
privateConsole.set(this, con)
373394
}
395+
return con
374396
}
375397

376398
/**
@@ -510,8 +532,7 @@ export class Logger {
510532
args: unknown[],
511533
stream?: 'stderr' | 'stdout',
512534
): this {
513-
const con = privateConsole.get(this) as typeof console &
514-
Record<string, unknown>
535+
const con = this.#getConsole()
515536
const text = args.at(0)
516537
const hasText = typeof text === 'string'
517538
// Determine which stream this method writes to
@@ -547,7 +568,7 @@ export class Logger {
547568
* @private
548569
*/
549570
#symbolApply(symbolType: string, args: unknown[]): this {
550-
const con = privateConsole.get(this)
571+
const con = this.#getConsole()
551572
let text = args.at(0)
552573
// biome-ignore lint/suspicious/noImplicitAnyLet: Flexible argument handling.
553574
let extras
@@ -649,7 +670,7 @@ export class Logger {
649670
* ```
650671
*/
651672
assert(value: unknown, ...message: unknown[]): this {
652-
const con = privateConsole.get(this)
673+
const con = this.#getConsole()
653674
con.assert(value, ...message)
654675
this[lastWasBlankSymbol](false)
655676
return value ? this : this[incLogCallCountSymbol]()
@@ -680,7 +701,7 @@ export class Logger {
680701
'clearVisible() is only available on the main logger instance, not on stream-bound instances',
681702
)
682703
}
683-
const con = privateConsole.get(this)
704+
const con = this.#getConsole()
684705
con.clear()
685706
if ((con as any)._stdout.isTTY) {
686707
;(this as any)[lastWasBlankSymbol](true)
@@ -707,7 +728,7 @@ export class Logger {
707728
* ```
708729
*/
709730
count(label?: string | undefined): this {
710-
const con = privateConsole.get(this)
731+
const con = this.#getConsole()
711732
con.count(label)
712733
this[lastWasBlankSymbol](false)
713734
return this[incLogCallCountSymbol]()
@@ -809,7 +830,7 @@ export class Logger {
809830
* ```
810831
*/
811832
dir(obj: unknown, options?: unknown | undefined): this {
812-
const con = privateConsole.get(this)
833+
const con = this.#getConsole()
813834
con.dir(obj, options)
814835
this[lastWasBlankSymbol](false)
815836
return this[incLogCallCountSymbol]()
@@ -830,7 +851,7 @@ export class Logger {
830851
* ```
831852
*/
832853
dirxml(...data: unknown[]): this {
833-
const con = privateConsole.get(this)
854+
const con = this.#getConsole()
834855
con.dirxml(data)
835856
this[lastWasBlankSymbol](false)
836857
return this[incLogCallCountSymbol]()
@@ -1159,7 +1180,7 @@ export class Logger {
11591180
const text = this.#stripSymbols(msg)
11601181
// Note: Step messages always go to stdout (unlike info/fail/etc which go to stderr).
11611182
const indent = this.#getIndent('stdout')
1162-
const con = privateConsole.get(this) as typeof console &
1183+
const con = this.#getConsole() as typeof console &
11631184
Record<string, unknown>
11641185
con.log(
11651186
applyLinePrefix(`${LOG_SYMBOLS.step} ${text}`, {
@@ -1281,7 +1302,7 @@ export class Logger {
12811302
tabularData: unknown,
12821303
properties?: readonly string[] | undefined,
12831304
): this {
1284-
const con = privateConsole.get(this)
1305+
const con = this.#getConsole()
12851306
con.table(tabularData, properties)
12861307
this[lastWasBlankSymbol](false)
12871308
return this[incLogCallCountSymbol]()
@@ -1311,7 +1332,7 @@ export class Logger {
13111332
* ```
13121333
*/
13131334
timeEnd(label?: string | undefined): this {
1314-
const con = privateConsole.get(this)
1335+
const con = this.#getConsole()
13151336
con.timeEnd(label)
13161337
this[lastWasBlankSymbol](false)
13171338
return this[incLogCallCountSymbol]()
@@ -1342,7 +1363,7 @@ export class Logger {
13421363
* ```
13431364
*/
13441365
timeLog(label?: string | undefined, ...data: unknown[]): this {
1345-
const con = privateConsole.get(this)
1366+
const con = this.#getConsole()
13461367
con.timeLog(label, ...data)
13471368
this[lastWasBlankSymbol](false)
13481369
return this[incLogCallCountSymbol]()
@@ -1369,7 +1390,7 @@ export class Logger {
13691390
* ```
13701391
*/
13711392
trace(message?: unknown | undefined, ...args: unknown[]): this {
1372-
const con = privateConsole.get(this)
1393+
const con = this.#getConsole()
13731394
con.trace(message, ...args)
13741395
this[lastWasBlankSymbol](false)
13751396
return this[incLogCallCountSymbol]()
@@ -1418,7 +1439,7 @@ export class Logger {
14181439
* ```
14191440
*/
14201441
write(text: string): this {
1421-
const con = privateConsole.get(this)
1442+
const con = this.#getConsole()
14221443
// Write directly to the original stdout stream to bypass Console formatting
14231444
// (e.g., group indentation). Try multiple approaches to get the raw stream:
14241445
// 1. Use stored reference from constructor options
@@ -1459,7 +1480,7 @@ export class Logger {
14591480
* ```
14601481
*/
14611482
progress(text: string): this {
1462-
const con = privateConsole.get(this)
1483+
const con = this.#getConsole()
14631484
const stream = this.#getTargetStream()
14641485
const streamObj = stream === 'stderr' ? con._stderr : con._stdout
14651486
streamObj.write(`∴ ${text}`)
@@ -1496,7 +1517,7 @@ export class Logger {
14961517
* ```
14971518
*/
14981519
clearLine(): this {
1499-
const con = privateConsole.get(this)
1520+
const con = this.#getConsole()
15001521
const stream = this.#getTargetStream()
15011522
const streamObj = stream === 'stderr' ? con._stderr : con._stdout
15021523
if (streamObj.isTTY) {
@@ -1534,8 +1555,27 @@ Object.defineProperties(
15341555
if (!(Logger.prototype as any)[key] && typeof value === 'function') {
15351556
// Dynamically name the log method without using Object.defineProperty.
15361557
const { [key]: func } = {
1537-
[key](...args: unknown[]) {
1538-
const con = privateConsole.get(this)
1558+
[key](this: Logger, ...args: unknown[]) {
1559+
// Access Console via WeakMap directly since private methods can't be
1560+
// called from dynamically created functions.
1561+
let con = privateConsole.get(this)
1562+
if (!con) {
1563+
// Lazy initialization - this will only happen if someone calls a
1564+
// dynamically added console method before any core logger method.
1565+
const constructorArgs = privateConstructorArgs.get(this) || []
1566+
if (constructorArgs.length) {
1567+
con = constructConsole(...constructorArgs)
1568+
} else {
1569+
con = constructConsole({
1570+
stdout: process.stdout,
1571+
stderr: process.stderr,
1572+
}) as typeof console & Record<string, unknown>
1573+
for (const { 0: k, 1: method } of boundConsoleEntries) {
1574+
con[k] = method
1575+
}
1576+
}
1577+
privateConsole.set(this, con)
1578+
}
15391579
const result = (con as any)[key](...args)
15401580
return result === undefined || result === con ? this : result
15411581
},

0 commit comments

Comments
 (0)