| import type { WebContainer, WebContainerProcess } from '@webcontainer/api'; |
| import type { ITerminal } from '~/types/terminal'; |
| import { withResolvers } from './promises'; |
| import { atom } from 'nanostores'; |
|
|
| export async function newShellProcess(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(); |
| const output = process.output; |
|
|
| const jshReady = withResolvers<void>(); |
|
|
| let isInteractive = false; |
| output.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 process; |
| } |
|
|
| export type ExecutionResult = { output: string; exitCode: number } | undefined; |
|
|
| export class BoltShell { |
| #initialized: (() => void) | undefined; |
| #readyPromise: Promise<void>; |
| #webcontainer: WebContainer | undefined; |
| #terminal: ITerminal | undefined; |
| #process: WebContainerProcess | undefined; |
| executionState = atom< |
| { sessionId: string; active: boolean; executionPrms?: Promise<any>; abort?: () => void } | undefined |
| >(); |
| #outputStream: ReadableStreamDefaultReader<string> | undefined; |
| #shellInputStream: WritableStreamDefaultWriter<string> | 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; |
|
|
| const { process, output } = await this.newBoltShellProcess(webcontainer, terminal); |
| this.#process = process; |
| this.#outputStream = output.getReader(); |
| await this.waitTillOscCode('interactive'); |
| this.#initialized?.(); |
| } |
|
|
| get terminal() { |
| return this.#terminal; |
| } |
|
|
| get process() { |
| return this.#process; |
| } |
|
|
| async executeCommand(sessionId: string, command: string, abort?: () => void): Promise<ExecutionResult> { |
| if (!this.process || !this.terminal) { |
| return undefined; |
| } |
|
|
| const state = this.executionState.get(); |
|
|
| if (state?.active && state.abort) { |
| state.abort(); |
| } |
|
|
| |
| |
| |
| |
| this.terminal.input('\x03'); |
| await this.waitTillOscCode('prompt'); |
|
|
| if (state && state.executionPrms) { |
| await state.executionPrms; |
| } |
|
|
| |
| this.terminal.input(command.trim() + '\n'); |
|
|
| |
| 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 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; |
|
|
| const [internalOutput, terminalOutput] = process.output.tee(); |
|
|
| const jshReady = withResolvers<void>(); |
|
|
| let isInteractive = false; |
| terminalOutput.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 { process, output: internalOutput }; |
| } |
|
|
| async getCurrentExecutionResult(): Promise<ExecutionResult> { |
| const { output, exitCode } = await this.waitTillOscCode('exit'); |
| return { output, exitCode }; |
| } |
|
|
| async waitTillOscCode(waitCode: string) { |
| let fullOutput = ''; |
| let exitCode: number = 0; |
|
|
| if (!this.#outputStream) { |
| return { output: fullOutput, exitCode }; |
| } |
|
|
| const tappedStream = this.#outputStream; |
|
|
| while (true) { |
| const { value, done } = await tappedStream.read(); |
|
|
| if (done) { |
| break; |
| } |
|
|
| const text = value || ''; |
| fullOutput += text; |
|
|
| |
| 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 }; |
| } |
| } |
|
|
| |
| |
| |
| |
| export function cleanTerminalOutput(input: string): string { |
| |
| const removeOsc = input |
| .replace(/\x1b\](\d+;[^\x07\x1b]*|\d+[^\x07\x1b]*)\x07/g, '') |
| .replace(/\](\d+;[^\n]*|\d+[^\n]*)/g, ''); |
|
|
| |
| const removeAnsi = removeOsc |
| |
| .replace(/\u001b\[[\?]?[0-9;]*[a-zA-Z]/g, '') |
| .replace(/\x1b\[[\?]?[0-9;]*[a-zA-Z]/g, '') |
| |
| .replace(/\u001b\[[0-9;]*m/g, '') |
| .replace(/\x1b\[[0-9;]*m/g, '') |
| |
| .replace(/\u001b/g, '') |
| .replace(/\x1b/g, ''); |
|
|
| |
| const cleanNewlines = removeAnsi |
| .replace(/\r\n/g, '\n') |
| .replace(/\r/g, '\n') |
| .replace(/\n{3,}/g, '\n\n'); |
|
|
| |
| const formatOutput = cleanNewlines |
| |
| .replace(/^([~\/][^\n❯]+)❯/m, '$1\n❯') |
| |
| .replace(/(?<!^|\n)>/g, '\n>') |
| |
| .replace(/(?<!^|\n|\w)(error|failed|warning|Error|Failed|Warning):/g, '\n$1:') |
| |
| .replace(/(?<!^|\n|\/)(at\s+(?!async|sync))/g, '\nat ') |
| |
| .replace(/\bat\s+async/g, 'at async') |
| |
| .replace(/(?<!^|\n)(npm ERR!)/g, '\n$1'); |
|
|
| |
| const cleanSpaces = formatOutput |
| .split('\n') |
| .map((line) => line.trim()) |
| .filter((line) => line.length > 0) |
| .join('\n'); |
|
|
| |
| return cleanSpaces |
| .replace(/\n{3,}/g, '\n\n') |
| .replace(/:\s+/g, ': ') |
| .replace(/\s{2,}/g, ' ') |
| .replace(/^\s+|\s+$/g, '') |
| .replace(/\u0000/g, ''); |
| } |
|
|
| export function newBoltShellProcess() { |
| return new BoltShell(); |
| } |
|
|