import type { WebContainer, WebContainerProcess } from '@webcontainer/api'; import type { ITerminal } from '~/types/terminal'; import { withResolvers } from './promises'; import { atom } from 'nanostores'; import { expoUrlAtom } from '~/stores/qrCodeStore'; export async function newShellProcess(webcontainer: WebContainer, terminal: ITerminal) { const args: string[] = []; // we spawn a JSH process with a fallback cols and rows in case the process is not attached yet to a visible terminal const process = await webcontainer.spawn('/bin/jsh', ['--osc', ...args], { terminal: { cols: terminal.cols ?? 80, rows: terminal.rows ?? 15, }, }); const input = process.input.getWriter(); const output = process.output; const jshReady = withResolvers(); let isInteractive = false; output.pipeTo( new WritableStream({ write(data) { if (!isInteractive) { const [, osc] = data.match(/\x1b\]654;([^\x07]+)\x07/) || []; if (osc === 'interactive') { // wait until we see the interactive OSC isInteractive = true; jshReady.resolve(); } } terminal.write(data); }, }), ); terminal.onData((data) => { // console.log('terminal onData', { data, isInteractive }); if (isInteractive) { input.write(data); } }); await jshReady.promise; return process; } export type ExecutionResult = { output: string; exitCode: number } | undefined; export class BoltShell { #initialized: (() => void) | undefined; #readyPromise: Promise; #webcontainer: WebContainer | undefined; #terminal: ITerminal | undefined; #process: WebContainerProcess | undefined; executionState = atom< { sessionId: string; active: boolean; executionPrms?: Promise; abort?: () => void } | undefined >(); #outputStream: ReadableStreamDefaultReader | undefined; #shellInputStream: WritableStreamDefaultWriter | undefined; constructor() { this.#readyPromise = new Promise((resolve) => { this.#initialized = resolve; }); } ready() { return this.#readyPromise; } async init(webcontainer: WebContainer, terminal: ITerminal) { this.#webcontainer = webcontainer; this.#terminal = terminal; // Use all three streams from tee: one for terminal, one for command execution, one for Expo URL detection const { process, commandStream, expoUrlStream } = await this.newBoltShellProcess(webcontainer, terminal); this.#process = process; this.#outputStream = commandStream.getReader(); // Start background Expo URL watcher immediately this._watchExpoUrlInBackground(expoUrlStream); await this.waitTillOscCode('interactive'); this.#initialized?.(); } async newBoltShellProcess(webcontainer: WebContainer, terminal: ITerminal) { const args: string[] = []; const process = await webcontainer.spawn('/bin/jsh', ['--osc', ...args], { terminal: { cols: terminal.cols ?? 80, rows: terminal.rows ?? 15, }, }); const input = process.input.getWriter(); this.#shellInputStream = input; // Tee the output so we can have three independent readers const [streamA, streamB] = process.output.tee(); const [streamC, streamD] = streamB.tee(); const jshReady = withResolvers(); let isInteractive = false; streamA.pipeTo( new WritableStream({ write(data) { if (!isInteractive) { const [, osc] = data.match(/\x1b\]654;([^\x07]+)\x07/) || []; if (osc === 'interactive') { isInteractive = true; jshReady.resolve(); } } terminal.write(data); }, }), ); terminal.onData((data) => { if (isInteractive) { input.write(data); } }); await jshReady.promise; // Return all streams for use in init return { process, terminalStream: streamA, commandStream: streamC, expoUrlStream: streamD }; } // Dedicated background watcher for Expo URL private async _watchExpoUrlInBackground(stream: ReadableStream) { const reader = stream.getReader(); let buffer = ''; const expoUrlRegex = /(exp:\/\/[^\s]+)/; while (true) { const { value, done } = await reader.read(); if (done) { break; } buffer += value || ''; const expoUrlMatch = buffer.match(expoUrlRegex); if (expoUrlMatch) { const cleanUrl = expoUrlMatch[1] .replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '') .replace(/[^\x20-\x7E]+$/g, ''); expoUrlAtom.set(cleanUrl); buffer = buffer.slice(buffer.indexOf(expoUrlMatch[1]) + expoUrlMatch[1].length); } if (buffer.length > 2048) { buffer = buffer.slice(-2048); } } } get terminal() { return this.#terminal; } get process() { return this.#process; } async executeCommand(sessionId: string, command: string, abort?: () => void): Promise { if (!this.process || !this.terminal) { return undefined; } const state = this.executionState.get(); if (state?.active && state.abort) { state.abort(); } /* * interrupt the current execution * this.#shellInputStream?.write('\x03'); */ this.terminal.input('\x03'); await this.waitTillOscCode('prompt'); if (state && state.executionPrms) { await state.executionPrms; } //start a new execution this.terminal.input(command.trim() + '\n'); //wait for the execution to finish const executionPromise = this.getCurrentExecutionResult(); this.executionState.set({ sessionId, active: true, executionPrms: executionPromise, abort }); const resp = await executionPromise; this.executionState.set({ sessionId, active: false }); if (resp) { try { resp.output = cleanTerminalOutput(resp.output); } catch (error) { console.log('failed to format terminal output', error); } } return resp; } async getCurrentExecutionResult(): Promise { const { output, exitCode } = await this.waitTillOscCode('exit'); return { output, exitCode }; } onQRCodeDetected?: (qrCode: string) => void; async waitTillOscCode(waitCode: string) { let fullOutput = ''; let exitCode: number = 0; let buffer = ''; // <-- Add a buffer to accumulate output if (!this.#outputStream) { return { output: fullOutput, exitCode }; } const tappedStream = this.#outputStream; // Regex for Expo URL const expoUrlRegex = /(exp:\/\/[^\s]+)/; while (true) { const { value, done } = await tappedStream.read(); if (done) { break; } const text = value || ''; fullOutput += text; buffer += text; // <-- Accumulate in buffer // Extract Expo URL from buffer and set store const expoUrlMatch = buffer.match(expoUrlRegex); if (expoUrlMatch) { // Remove any trailing ANSI escape codes or non-printable characters const cleanUrl = expoUrlMatch[1] .replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '') .replace(/[^\x20-\x7E]+$/g, ''); expoUrlAtom.set(cleanUrl); // Remove everything up to and including the URL from the buffer to avoid duplicate matches buffer = buffer.slice(buffer.indexOf(expoUrlMatch[1]) + expoUrlMatch[1].length); } // Check if command completion signal with exit code const [, osc, , , code] = text.match(/\x1b\]654;([^\x07=]+)=?((-?\d+):(\d+))?\x07/) || []; if (osc === 'exit') { exitCode = parseInt(code, 10); } if (osc === waitCode) { break; } } return { output: fullOutput, exitCode }; } } /** * Cleans and formats terminal output while preserving structure and paths * Handles ANSI, OSC, and various terminal control sequences */ export function cleanTerminalOutput(input: string): string { // Step 1: Remove OSC sequences (including those with parameters) const removeOsc = input .replace(/\x1b\](\d+;[^\x07\x1b]*|\d+[^\x07\x1b]*)\x07/g, '') .replace(/\](\d+;[^\n]*|\d+[^\n]*)/g, ''); // Step 2: Remove ANSI escape sequences and color codes more thoroughly const removeAnsi = removeOsc // Remove all escape sequences with parameters .replace(/\u001b\[[\?]?[0-9;]*[a-zA-Z]/g, '') .replace(/\x1b\[[\?]?[0-9;]*[a-zA-Z]/g, '') // Remove color codes .replace(/\u001b\[[0-9;]*m/g, '') .replace(/\x1b\[[0-9;]*m/g, '') // Clean up any remaining escape characters .replace(/\u001b/g, '') .replace(/\x1b/g, ''); // Step 3: Clean up carriage returns and newlines const cleanNewlines = removeAnsi .replace(/\r\n/g, '\n') .replace(/\r/g, '\n') .replace(/\n{3,}/g, '\n\n'); // Step 4: Add newlines at key breakpoints while preserving paths const formatOutput = cleanNewlines // Preserve prompt line .replace(/^([~\/][^\n❯]+)❯/m, '$1\n❯') // Add newline before command output indicators .replace(/(?/g, '\n>') // Add newline before error keywords without breaking paths .replace(/(? line.trim()) .filter((line) => line.length > 0) .join('\n'); // Step 6: Final cleanup return cleanSpaces .replace(/\n{3,}/g, '\n\n') // Replace multiple newlines with double newlines .replace(/:\s+/g, ': ') // Normalize spacing after colons .replace(/\s{2,}/g, ' ') // Remove multiple spaces .replace(/^\s+|\s+$/g, '') // Trim start and end .replace(/\u0000/g, ''); // Remove null characters } export function newBoltShellProcess() { return new BoltShell(); }