output.ts 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. /** Bounded host-side projection of a complete output file retained in E2B. */
  2. import { Buffer } from 'node:buffer'
  3. import type { SubprocessOutputRead, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess'
  4. const BASE64_TEXT = /^[A-Za-z0-9+/]+={0,2}$/u
  5. /** Reserved non-base64 frame proving that one remote encoder reached clean EOF. */
  6. export const E2B_OUTPUT_COMPLETE_FRAME = '!dsh-e2b-output-complete!'
  7. /** Incrementally decode newline-delimited base64 frames emitted by one remote encoder. */
  8. export class E2BBase64Decoder {
  9. private pending = ''
  10. private complete = false
  11. /**
  12. * Decode every complete newline-delimited frame in one arbitrarily split SDK callback.
  13. * @param text - ASCII base64 frames from E2B's decoded callback.
  14. * @returns the complete raw bytes made available by this callback.
  15. */
  16. push(text: string): Buffer {
  17. if (text.length === 0) return Buffer.alloc(0)
  18. this.pending += text
  19. const decoded: Buffer[] = []
  20. for (;;) {
  21. const boundary = this.pending.indexOf('\n')
  22. if (boundary < 0) break
  23. const frame = this.pending.slice(0, boundary)
  24. this.pending = this.pending.slice(boundary + 1)
  25. if (frame === E2B_OUTPUT_COMPLETE_FRAME) {
  26. if (this.complete) throw new Error('subprocess-e2b: duplicate output transport completion')
  27. this.complete = true
  28. continue
  29. }
  30. if (this.complete) throw new Error('subprocess-e2b: output transport continued after completion')
  31. if (!BASE64_TEXT.test(frame)) {
  32. throw new Error('subprocess-e2b: invalid base64 output transport')
  33. }
  34. const bytes = Buffer.from(frame, 'base64')
  35. if (bytes.toString('base64') !== frame) {
  36. throw new Error('subprocess-e2b: invalid base64 output transport')
  37. }
  38. decoded.push(bytes)
  39. }
  40. return Buffer.concat(decoded)
  41. }
  42. /**
  43. * Validate clean encoder completion, or discard an interrupted trailing frame after requested termination.
  44. * @param requireComplete - Whether natural completion requires the reserved EOF frame.
  45. */
  46. finish(requireComplete = true): void {
  47. if (!requireComplete) {
  48. this.pending = ''
  49. return
  50. }
  51. if (this.pending.length > 0) {
  52. throw new Error('subprocess-e2b: truncated base64 output transport')
  53. }
  54. if (!this.complete) throw new Error('subprocess-e2b: incomplete output transport')
  55. }
  56. }
  57. /** Offset reader used for one collect-mode E2B stream. */
  58. export class E2BOutputReader implements SubprocessOutputReader {
  59. private chunks: Buffer[] = []
  60. private retainedBytes = 0
  61. private totalBytes = 0
  62. private spillValid = true
  63. /**
  64. * Create a bounded reader over one remote spill path.
  65. * @param maxBytes - In-memory tail cap.
  66. * @param maxSpillBytes - Maximum complete remote file size the caller accepts.
  67. * @param spillPath - Remote full-output path.
  68. */
  69. constructor(
  70. private readonly maxBytes: number,
  71. private readonly maxSpillBytes: number | undefined,
  72. private readonly spillPath: string,
  73. ) {}
  74. /** Total bytes observed from the SDK stream. */
  75. get size(): number {
  76. return this.totalBytes
  77. }
  78. /** Stop advertising a remote spill whose writer did not reach clean EOF. */
  79. invalidateSpill(): void {
  80. this.spillValid = false
  81. }
  82. /**
  83. * Append one byte-faithful decoded transport event.
  84. * @param bytes - Raw command bytes recovered from the ASCII SDK transport.
  85. */
  86. push(bytes: Uint8Array): void {
  87. if (bytes.length === 0) return
  88. const chunk = Buffer.from(bytes)
  89. this.totalBytes += chunk.length
  90. this.chunks.push(chunk)
  91. this.retainedBytes += chunk.length
  92. while (this.retainedBytes > this.maxBytes) {
  93. const head = this.chunks[0] as Buffer
  94. const excess = this.retainedBytes - this.maxBytes
  95. if (head.length <= excess) {
  96. this.chunks.shift()
  97. this.retainedBytes -= head.length
  98. } else {
  99. this.chunks[0] = head.subarray(excess)
  100. this.retainedBytes -= excess
  101. }
  102. }
  103. }
  104. /** @inheritdoc */
  105. readFrom(fromByte: number): SubprocessOutputRead {
  106. const retained = Buffer.concat(this.chunks, this.retainedBytes)
  107. const firstRetained = this.totalBytes - this.retainedBytes
  108. const lossy = fromByte < firstRetained
  109. const start = lossy ? 0 : Math.min(retained.length, Math.max(0, fromByte - firstRetained))
  110. return {
  111. text: retained.subarray(start).toString('utf8'),
  112. nextOffset: this.totalBytes,
  113. lossy,
  114. ...(lossy && this.spillValid && this.maxSpillBytes !== undefined && this.totalBytes <= this.maxSpillBytes
  115. ? { spillPath: this.spillPath }
  116. : {}),
  117. }
  118. }
  119. }