-
Notifications
You must be signed in to change notification settings - Fork 38
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: otter training - tools and services
- Loading branch information
Showing
27 changed files
with
1,184 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
275 changes: 275 additions & 0 deletions
275
apps/showcase/src/services/webcontainer/webcontainer-runner.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,275 @@ | ||
import {FileSystemTree, IFSWatcher, WebContainer, WebContainerProcess} from '@webcontainer/api'; | ||
import {Terminal} from '@xterm/xterm'; | ||
import { | ||
BehaviorSubject, | ||
combineLatestWith, | ||
distinctUntilChanged, | ||
filter, | ||
from, | ||
map, | ||
Observable, | ||
switchMap | ||
} from 'rxjs'; | ||
|
||
const createTerminalStream = (terminal: Terminal, cb?: (data: string) => void | Promise<void>) => new WritableStream({ | ||
write: (data) => { | ||
if (cb) { | ||
void cb(data); | ||
} | ||
terminal.write(data); | ||
} | ||
}); | ||
|
||
const makeProcessWritable = (process: WebContainerProcess, terminal: Terminal) => { | ||
const input = process.input.getWriter(); | ||
terminal.onData((data) => input.write(data)); | ||
return input; | ||
}; | ||
|
||
export class WebContainerRunner { | ||
/** | ||
* WebContainer instance which is available after the | ||
*/ | ||
public readonly instance: Promise<WebContainer>; | ||
private readonly commands = new BehaviorSubject<{queue: string[]; cwd: string}>({queue: [], cwd: ''}); | ||
private readonly commandOnRun: Observable<{command: string; cwd: string} | undefined> = this.commands.pipe( | ||
map((commands) => ( | ||
commands.queue.length > 0 ? {command: commands.queue[0], cwd: commands.cwd} : undefined | ||
)) | ||
); | ||
private readonly iframe = new BehaviorSubject<HTMLIFrameElement | null>(null); | ||
private readonly shell = { | ||
terminal: new BehaviorSubject<Terminal | null>(null), | ||
process: new BehaviorSubject<WebContainerProcess | null>(null), | ||
writer: new BehaviorSubject<WritableStreamDefaultWriter | null>(null), | ||
cwd: new BehaviorSubject<string | null>(null) | ||
}; | ||
private readonly commandOutput = { | ||
terminal: new BehaviorSubject<Terminal | null>(null), | ||
process: new BehaviorSubject<WebContainerProcess | null>(null), | ||
outputLocked: new BehaviorSubject<boolean>(false) | ||
}; | ||
private watcher: IFSWatcher | null = null; | ||
|
||
constructor() { | ||
this.instance = new Promise((resolve) => { | ||
void WebContainer.boot().then((instance) => { | ||
resolve(instance); | ||
// eslint-disable-next-line no-console | ||
instance.on('error', console.error); | ||
}); | ||
}); | ||
this.commandOnRun.pipe( | ||
filter((currentCommand): currentCommand is {command: string; cwd: string} => !!currentCommand) | ||
).subscribe(({command, cwd}) => { | ||
const commandElements = command.split(' '); | ||
void this.runCommand(commandElements[0], commandElements.slice(1), cwd); | ||
}); | ||
|
||
this.iframe.pipe( | ||
filter((iframe): iframe is HTMLIFrameElement => !!iframe), | ||
distinctUntilChanged() | ||
).subscribe((iframe) => | ||
void this.instance.then( | ||
(instance) => instance.on('server-ready', (_port: number, url: string) => { | ||
iframe.removeAttribute('srcdoc'); | ||
iframe.src = url; | ||
}) | ||
) | ||
); | ||
|
||
this.commandOutput.process.pipe( | ||
filter((process): process is WebContainerProcess => !!process && !process.output.locked), | ||
combineLatestWith( | ||
this.commandOutput.terminal.pipe( | ||
filter((terminal): terminal is Terminal => !!terminal) | ||
) | ||
), | ||
filter(([process, _]) => !process.output.locked) | ||
).subscribe(([process, terminal]) => | ||
void process.output.pipeTo(createTerminalStream(terminal)) | ||
); | ||
this.shell.writer.pipe( | ||
filter((writer): writer is WritableStreamDefaultWriter => !!writer), | ||
combineLatestWith( | ||
this.shell.cwd.pipe(filter((cwd): cwd is string => !!cwd)) | ||
) | ||
).subscribe(async ([writer, processCwd]) => { | ||
try { | ||
const instance = await this.instance; | ||
await writer.write(`cd ${instance.workdir}/${processCwd} && clear \n`); | ||
} catch (e) { | ||
// eslint-disable-next-line no-console | ||
console.error(e, processCwd); | ||
} | ||
}); | ||
this.shell.process.pipe( | ||
filter((process): process is null => !process), | ||
combineLatestWith( | ||
this.shell.terminal.pipe(filter((terminal): terminal is Terminal => !!terminal)) | ||
), | ||
switchMap(([_, terminal]) => { | ||
const spawn = this.instance.then((instance) => instance.spawn('jsh')); | ||
return from(spawn).pipe( | ||
map((process) => ({ | ||
process, | ||
terminal | ||
})) | ||
); | ||
}) | ||
).subscribe(({process, terminal}) => { | ||
const cb = (data: string) => { | ||
if (['CREATE', 'UPDATE', 'RENAME', 'DELETE'].some((action) => data.includes(action))) { | ||
this.treeUpdateCallback(); | ||
} | ||
}; | ||
void process.output.pipeTo(createTerminalStream(terminal, cb)); | ||
this.shell.writer.next(makeProcessWritable(process, terminal)); | ||
this.shell.process.next(process); | ||
}); | ||
} | ||
|
||
/** | ||
* Callback on tree update | ||
*/ | ||
private treeUpdateCallback = () => {}; | ||
|
||
/** | ||
* Does the folder exist at the root of the webContainer instance? | ||
* @param folderName | ||
* @param instance | ||
* @private | ||
*/ | ||
private async doesFolderExist(folderName: string, instance: WebContainer) { | ||
try { | ||
await instance.fs.readdir(folderName); | ||
return true; | ||
} catch (_) { | ||
return false; | ||
} | ||
} | ||
|
||
/** | ||
* Run a command in the | ||
* @param command | ||
* @param args | ||
* @param cwd | ||
*/ | ||
private async runCommand(command: string, args: string[], cwd: string) { | ||
const instance = await this.instance; | ||
const process = await instance.spawn(command, args, {cwd: cwd}); | ||
this.commandOutput.process.next(process); | ||
const exitCode = await process.exit; | ||
if (exitCode !== 0) { | ||
throw new Error(`Command ${[command, ...args].join(' ')} failed! with ${exitCode}`); | ||
} | ||
this.commands.next({queue: this.commands.value.queue.slice(1), cwd}); | ||
} | ||
|
||
/** | ||
* Kill a terminal process and clear its content | ||
* @param terminalSubject | ||
* @param processSubject | ||
* @private | ||
*/ | ||
private killTerminal(terminalSubject: BehaviorSubject<Terminal | null>, processSubject: BehaviorSubject<WebContainerProcess | null>) { | ||
processSubject.value?.kill(); | ||
processSubject.next(null); | ||
terminalSubject.value?.clear(); | ||
} | ||
|
||
/** | ||
* Run a project. Mount requested files and run the associated commands in the cwd folder. | ||
* @param files to mount | ||
* @param commands to run in the project folder | ||
* @param projectFolder | ||
* @param override allow to mount files and override a project already mounted on the web container | ||
*/ | ||
public async runProject(files: FileSystemTree, commands: string[], projectFolder: string, override = false) { | ||
const instance = await this.instance; | ||
// Ensure boot is done and instance is ready for use | ||
this.shell.cwd.next(projectFolder); | ||
this.killTerminal(this.commandOutput.terminal, this.commandOutput.process); | ||
const iframe = this.iframe.value; | ||
if (iframe) { | ||
iframe.src = ''; | ||
iframe.srcdoc = 'Loading...'; | ||
} | ||
if (this.watcher) { | ||
this.watcher.close(); | ||
} | ||
if (override || !(await this.doesFolderExist(projectFolder, instance))) { | ||
await instance.mount({[projectFolder]: {directory: files}}); | ||
} | ||
this.treeUpdateCallback(); | ||
this.commands.next({queue: commands, cwd: projectFolder}); | ||
this.watcher = instance.fs.watch(`/${projectFolder}`, {encoding: 'utf-8'}, this.treeUpdateCallback); | ||
} | ||
|
||
/** | ||
* Register the method to call whenever the tree is updated | ||
* @param callback | ||
*/ | ||
public registerTreeUpdateCallback(callback: () => object) { | ||
this.treeUpdateCallback = callback; | ||
} | ||
|
||
/** | ||
* Register a new terminal that will be used as shell for the webcontainer | ||
* It is a dedicated sh process to input command. | ||
* @param terminal | ||
*/ | ||
public registerShell(terminal: Terminal) { | ||
this.shell.terminal.next(terminal); | ||
} | ||
|
||
/** | ||
* Register a new terminal to display the current process output | ||
* @param terminal | ||
*/ | ||
public registerCommandOutputTerminal(terminal: Terminal) { | ||
this.commandOutput.terminal.next(terminal); | ||
} | ||
|
||
/** | ||
* Register an iframe which will show the app resulting of the diverse commands run on the webcontainer | ||
* @param iframe | ||
*/ | ||
public registerIframe(iframe: HTMLIFrameElement | null) { | ||
const previousIframe = this.iframe.value; | ||
if (previousIframe) { | ||
previousIframe.src = ''; | ||
} | ||
this.iframe.next(iframe); | ||
} | ||
|
||
/** | ||
* Kill the current shell process and unregister the shell terminal | ||
*/ | ||
public disposeShell() { | ||
this.shell.terminal.next(null); | ||
void this.shell.writer.value?.close(); | ||
this.shell.writer.next(null); | ||
this.killTerminal(this.shell.terminal, this.shell.process); | ||
} | ||
|
||
/** | ||
* Kill the output terminal process and clear the console | ||
*/ | ||
public disposeCommandOutputTerminal() { | ||
this.killTerminal(this.shell.terminal, this.shell.process); | ||
this.commandOutput.terminal.next(null); | ||
} | ||
|
||
/** | ||
* Kill all the webContainer processes and unregister the terminal and iframe. | ||
*/ | ||
public killContainer() { | ||
this.killTerminal(this.shell.terminal, this.shell.process); | ||
this.killTerminal(this.commandOutput.terminal, this.commandOutput.process); | ||
const iframe = this.iframe.value; | ||
if (iframe) { | ||
iframe.src = ''; | ||
} | ||
} | ||
} |
Oops, something went wrong.