|
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(); |
|
} |
|
|