io.ts (3194B)
1 import { Graph, type NodeId } from './graph.js'; 2 import { readNatural, readText, line } from './codecs.js'; 3 export interface Trace { step: number; name: string; detail: string; depth: number; } 4 export interface IOHost { 5 before(event: Trace): Promise<void>; 6 after(event: { step: number; name: string; value: string }): void; 7 write(text: string): void; 8 read(): Promise<string>; 9 running(): void; 10 } 11 // collectActions requires exclusive ownership: unrelated caller handles may be 12 // invalidated unless included in retainedRoots. Normal callers retain the 13 // non-collecting API by default. Snapshot extra roots before the first action. 14 export async function runIO(graph: Graph, handle: NodeId, host: IOHost, collectActions = false, retainedRoots: Iterable<NodeId> = []): Promise<NodeId> { 15 const retained = [...retainedRoots]; 16 const [sentinel, body] = graph.pair(handle, 'IO envelope'); 17 if (readText(graph, sentinel, 7) !== 'tricuIO') throw new Error('Missing tricuIO sentinel'); 18 const [version, initial] = graph.pair(body, 'IO version/action'); 19 if (readNatural(graph, version) !== 1n) throw new Error('Unsupported tricuIO version'); 20 const frames: NodeId[] = []; let action = initial, output = 0; 21 const names = new Map([[0n, 'pure'], [1n, 'bind'], [2n, 'abortIO'], [10n, 'putStr'], [11n, 'getLine']]); 22 for (let step = 1; step <= 128; step++) { 23 if (collectActions) graph.collect([action, ...frames, ...retained]); 24 const [tag, payload] = graph.pair(action, 'action'); 25 const code = readNatural(graph, tag), name = names.get(code); 26 if (!name) throw new Error(`Unsupported browser IO action ${code}`); 27 // Don't inspect arbitrary pure/abort payloads merely to build a trace. 28 const detail = code === 10n ? readText(graph, payload) : `node #${payload}`; 29 await host.before({ step, name, detail, depth: frames.length }); 30 if (code === 1n) { 31 const [left, continuation] = graph.pair(payload, 'bind'); 32 frames.push(continuation); action = left; 33 host.after({ step, name, value: 'continuation handle saved' }); continue; 34 } 35 if (code === 2n) throw new Error(`Execution aborted (diagnostic node #${payload}; no payload forcing).`); 36 let value: NodeId; 37 if (code === 0n) value = payload; 38 else if (code === 10n) { 39 output += Array.from(detail).length; 40 if (output > 16_384) throw new Error('Terminal output limit exceeded'); 41 host.write(detail); value = graph.leaf; 42 } else value = line(graph, await host.read()); 43 host.after({ step, name, value: `node #${value}` }); 44 if (!frames.length) return value; 45 host.running(); action = graph.app(frames.pop()!, value); 46 } 47 throw new Error('IO action limit exceeded'); 48 } 49 50 // Ignore malformed/wrong-kind/stale resumes without consuming the waiting token. 51 // The page validates interactively; the Worker must also retain a retryable wait. 52 export function resumeValue(kind: 'input' | 'step', value: unknown): string | undefined { 53 if (kind === 'step') return value === undefined ? '' : undefined; 54 if (typeof value !== 'string' || value.length > 512 || /[\r\n\uD800-\uDFFF]/u.test(value) || Array.from(value).length > 256) return undefined; 55 return value; 56 }