io-v2-terminal.js (3214B)
1 import { OutputBuffer } from './io-v2-output.js'; 2 export class Terminal { 3 run; 4 output = new OutputBuffer(); 5 nextAfter = 0; 6 reader; 7 buffered = []; 8 eof = false; 9 ended = false; 10 constructor(run) { 11 this.run = run; 12 if (!/^[1-9][0-9]*$/.test(run) || run.length > 20 || BigInt(run) > 0xffffffffffffffffn) 13 throw new Error('invalid terminal run'); 14 } 15 get waiting() { 16 return this.reader ? { run: this.run, id: this.reader.id } : undefined; 17 } 18 read() { 19 const reader = this.reader; 20 if (!reader || !this.buffered.length && !this.eof) 21 return; 22 const bytes = this.buffered.splice(0, reader.limit); 23 this.reader = undefined; 24 return { run: this.run, id: reader.id, kind: 'read', bytes, eof: bytes.length === 0 }; 25 } 26 handle(event) { 27 if (this.ended || event.run !== this.run || !Number.isSafeInteger(event.id) || event.id < 1) 28 throw new Error('invalid terminal event'); 29 if (event.kind === 'cancel') { 30 if (event.id > this.nextAfter || !['read', 'write'].includes(event.operation)) 31 throw new Error('invalid terminal cancellation'); 32 // Completed synchronous writes/reads already have a normal acknowledgement 33 // queued. A racing abort does not erase it or manufacture a second reply. 34 if (this.reader?.id !== event.id) 35 return; 36 if (event.operation !== 'read') 37 throw new Error('invalid terminal cancellation'); 38 this.reader = undefined; 39 return { run: this.run, id: event.id, kind: 'cancelled' }; 40 } 41 if (event.id <= this.nextAfter) 42 throw new Error('stale terminal event'); 43 if (event.kind === 'read') { 44 if (this.reader || !Number.isInteger(event.limit) || event.limit < 1 || event.limit > 4096) 45 throw new Error('invalid terminal read'); 46 this.reader = { ...event }; 47 } 48 else if (event.kind === 'write') 49 this.output.append(event.stream, event.bytes); 50 else 51 throw new Error('invalid terminal operation'); 52 this.nextAfter = event.id; 53 return event.kind === 'read' ? this.read() : { run: this.run, id: event.id, kind: 'write', count: event.bytes.length }; 54 } 55 // Explicit one-line UI: no CR/LF in the submitted text, append ONE LF; an 56 // empty line is data, never EOF. Scalars, not UTF-16 code units, are bounded. 57 line(run, id, value) { 58 if (run !== this.run || this.reader?.id !== id || typeof value !== 'string' || value.length > 512 || 59 /[\r\n\uD800-\uDFFF]/u.test(value) || Array.from(value).length > 256) 60 return; 61 this.buffered = [...new TextEncoder().encode(value), 10]; 62 return this.read(); 63 } 64 endInput(run, id) { 65 if (run !== this.run || this.reader?.id !== id) 66 return; 67 this.eof = true; 68 return this.read(); 69 } 70 finish() { 71 if (this.reader) 72 throw new Error('terminal input still pending'); 73 this.ended = true; 74 this.buffered = []; 75 this.output.finish(); 76 } 77 }