/ utils / streamJsonStdoutGuard.ts
streamJsonStdoutGuard.ts
  1  import { registerCleanup } from './cleanupRegistry.js'
  2  import { logForDebugging } from './debug.js'
  3  
  4  /**
  5   * Sentinel written to stderr ahead of any diverted non-JSON line, so that
  6   * log scrapers and tests can grep for guard activity.
  7   */
  8  export const STDOUT_GUARD_MARKER = '[stdout-guard]'
  9  
 10  let installed = false
 11  let buffer = ''
 12  let originalWrite: typeof process.stdout.write | null = null
 13  
 14  function isJsonLine(line: string): boolean {
 15    // Empty lines are tolerated in NDJSON streams — treat them as valid so a
 16    // trailing newline or a blank separator doesn't trip the guard.
 17    if (line.length === 0) {
 18      return true
 19    }
 20    try {
 21      JSON.parse(line)
 22      return true
 23    } catch {
 24      return false
 25    }
 26  }
 27  
 28  /**
 29   * Install a runtime guard on process.stdout.write for --output-format=stream-json.
 30   *
 31   * SDK clients consuming stream-json parse stdout line-by-line as NDJSON. Any
 32   * stray write — a console.log from a dependency, a debug print that slipped
 33   * past review, a library banner — breaks the client's parser mid-stream with
 34   * no recovery path.
 35   *
 36   * This guard wraps process.stdout.write at the same layer the asciicast
 37   * recorder does (see asciicast.ts). Writes are buffered until a newline
 38   * arrives, then each complete line is JSON-parsed. Lines that parse are
 39   * forwarded to the real stdout; lines that don't are diverted to stderr
 40   * tagged with STDOUT_GUARD_MARKER so they remain visible without corrupting
 41   * the JSON stream.
 42   *
 43   * The blessed JSON path (structuredIO.write → writeToStdout → stdout.write)
 44   * always emits `ndjsonSafeStringify(msg) + '\n'`, so it passes straight
 45   * through. Only out-of-band writes are diverted.
 46   *
 47   * Installing twice is a no-op. Call before any stream-json output is emitted.
 48   */
 49  export function installStreamJsonStdoutGuard(): void {
 50    if (installed) {
 51      return
 52    }
 53    installed = true
 54  
 55    originalWrite = process.stdout.write.bind(
 56      process.stdout,
 57    ) as typeof process.stdout.write
 58  
 59    process.stdout.write = function (
 60      chunk: string | Uint8Array,
 61      encodingOrCb?: BufferEncoding | ((err?: Error) => void),
 62      cb?: (err?: Error) => void,
 63    ): boolean {
 64      const text =
 65        typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf-8')
 66  
 67      buffer += text
 68      let newlineIdx: number
 69      let wrote = true
 70      while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
 71        const line = buffer.slice(0, newlineIdx)
 72        buffer = buffer.slice(newlineIdx + 1)
 73        if (isJsonLine(line)) {
 74          wrote = originalWrite!(line + '\n')
 75        } else {
 76          process.stderr.write(`${STDOUT_GUARD_MARKER} ${line}\n`)
 77          logForDebugging(
 78            `streamJsonStdoutGuard diverted non-JSON stdout line: ${line.slice(0, 200)}`,
 79          )
 80        }
 81      }
 82  
 83      // Fire the callback once buffering is done. We report success even when
 84      // a line was diverted — the caller's intent (emit text) was honored,
 85      // just on a different fd.
 86      const callback = typeof encodingOrCb === 'function' ? encodingOrCb : cb
 87      if (callback) {
 88        queueMicrotask(() => callback())
 89      }
 90      return wrote
 91    } as typeof process.stdout.write
 92  
 93    registerCleanup(async () => {
 94      // Flush any partial line left in the buffer at shutdown. If it's a JSON
 95      // fragment it won't parse — divert it rather than drop it silently.
 96      if (buffer.length > 0) {
 97        if (originalWrite && isJsonLine(buffer)) {
 98          originalWrite(buffer + '\n')
 99        } else {
100          process.stderr.write(`${STDOUT_GUARD_MARKER} ${buffer}\n`)
101        }
102        buffer = ''
103      }
104      if (originalWrite) {
105        process.stdout.write = originalWrite
106        originalWrite = null
107      }
108      installed = false
109    })
110  }
111  
112  /**
113   * Testing-only reset. Restores the real stdout.write and clears the line
114   * buffer so subsequent tests start from a clean slate.
115   */
116  export function _resetStreamJsonStdoutGuardForTesting(): void {
117    if (originalWrite) {
118      process.stdout.write = originalWrite
119      originalWrite = null
120    }
121    buffer = ''
122    installed = false
123  }