streamJsonStdoutGuard.ts 3.9 KB

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