Skip to content

Commit

Permalink
feat: otter training - tools and services
Browse files Browse the repository at this point in the history
  • Loading branch information
sdo-1A committed Sep 12, 2024
1 parent 546e4fe commit d6ba285
Show file tree
Hide file tree
Showing 37 changed files with 1,221 additions and 0 deletions.
9 changes: 9 additions & 0 deletions apps/showcase/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,21 @@
"@o3r/rules-engine": "workspace:^",
"@o3r/styling": "workspace:^",
"@o3r/testing": "workspace:^",
"@o3r/training-tools": "workspace:^",
"@popperjs/core": "^2.11.5",
"@vscode/codicons": "^0.0.35",
"@webcontainer/api": "~1.2.4",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.0.0",
"ag-grid-angular": "~32.1.0",
"ag-grid-community": "~32.1.0",
"bootstrap": "5.3.3",
"highlight.js": "^11.8.0",
"intl-messageformat": "~10.5.1",
"monaco-editor": "~0.50.0",
"ngx-highlightjs": "^12.0.0",
"ngx-monaco-editor-v2": "^18.0.0",
"ngx-monaco-tree": "^17.5.0",
"pixelmatch": "^5.2.1",
"pngjs": "^7.0.0",
"rxjs": "^7.8.1",
Expand Down Expand Up @@ -101,6 +109,7 @@
"@typescript-eslint/parser": "^7.14.1",
"@typescript-eslint/types": "^7.14.1",
"@typescript-eslint/utils": "^7.14.1",
"@webcontainer/api": "~1.2.4",
"concurrently": "^8.0.0",
"eslint": "^8.57.0",
"eslint-import-resolver-node": "^0.3.9",
Expand Down
1 change: 1 addition & 0 deletions apps/showcase/src/services/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './webcontainer';
3 changes: 3 additions & 0 deletions apps/showcase/src/services/webcontainer/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './webcontainer.helpers';
export * from './webcontainer.service';
export * from './webcontainer-runner';
244 changes: 244 additions & 0 deletions apps/showcase/src/services/webcontainer/webcontainer-runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import { Injectable } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { type FileSystemTree, type IFSWatcher, WebContainer, type WebContainerProcess } from '@webcontainer/api';
import { Terminal } from '@xterm/xterm';
import {
BehaviorSubject,
combineLatestWith,
distinctUntilChanged,
filter,
from,
map,
Observable,
switchMap
} from 'rxjs';
import { withLatestFrom } from 'rxjs/operators';
import { createTerminalStream, doesFolderExist, killTerminal, makeProcessWritable } from './webcontainer.helpers';

@Injectable({
providedIn: 'root'
})
export class WebContainerRunner {
/**
* WebContainer instance which is available after the boot of the WebContainer
*/
public readonly instancePromise: 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.instancePromise = WebContainer.boot().then((instance) => {
// eslint-disable-next-line no-console
instance.on('error', console.error);
return instance;
});
this.commandOnRun$.pipe(
filter((currentCommand): currentCommand is {command: string; cwd: string} => !!currentCommand),
takeUntilDestroyed()
).subscribe(({command, cwd}) => {
// TODO: support commands that contain spaces
const commandElements = command.split(' ');
void this.runCommand(commandElements[0], commandElements.slice(1), cwd);
});

this.iframe.pipe(
filter((iframe): iframe is HTMLIFrameElement => !!iframe),
distinctUntilChanged(),
withLatestFrom(this.instancePromise),
takeUntilDestroyed()
).subscribe(([iframe, 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),
takeUntilDestroyed()
).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))
),
withLatestFrom(this.instancePromise),
takeUntilDestroyed()
).subscribe(async ([[writer, processCwd], instance]) => {
try {
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))
),
withLatestFrom(this.instancePromise),
switchMap(([[_, terminal], instance]) => {
const spawn = instance.spawn('jsh');
return from(spawn).pipe(
map((process) => ({
process,
terminal
}))
);
}),
takeUntilDestroyed()
).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 = () => {};

/**
* Run a command in the requested cwd and unqueue the runner commandList
* @param command
* @param args
* @param cwd
*/
private async runCommand(command: string, args: string[], cwd: string) {
const instance = await this.instancePromise;
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});
}

/**
* 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.instancePromise;
// Ensure boot is done and instance is ready for use
this.shell.cwd.next(projectFolder);
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 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: 'utf8'}, 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);
killTerminal(this.shell.terminal, this.shell.process);
}

/**
* Kill the output terminal process and clear the console
*/
public disposeCommandOutputTerminal() {
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() {
killTerminal(this.shell.terminal, this.shell.process);
killTerminal(this.commandOutput.terminal, this.commandOutput.process);
const iframe = this.iframe.value;
if (iframe) {
iframe.src = '';
}
}
}
86 changes: 86 additions & 0 deletions apps/showcase/src/services/webcontainer/webcontainer.helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { FileSystem, getFilesTree } from '@o3r/training-tools';
import { DirectoryNode, FileNode, WebContainer, WebContainerProcess } from '@webcontainer/api';
import { Terminal } from '@xterm/xterm';
import { MonacoTreeElement } from 'ngx-monaco-tree';
import { BehaviorSubject } from 'rxjs';

/**
* Convert the given path and node to a MonacoTreeElement
* @param path
* @param node
*/
export function convertTreeRec(path: string, node: DirectoryNode | FileNode): MonacoTreeElement {
return {
name: path,
content: (node as DirectoryNode).directory
? Object.entries((node as DirectoryNode).directory)
.map(([p, n]) => convertTreeRec(p, n))
: undefined
};
}

/**
* Checks if the folder exists at the root of the WebContainer instance
* @param folderName
* @param instance
* @private
*/
export async function doesFolderExist(folderName: string, instance: WebContainer) {
try {
await instance.fs.readdir(folderName);
return true;
} catch {
return false;
}
}

/**
* Get the file tree from the path of the File System of the given WebContainer instance
* @param instance
* @param excludedFilesOrDirectories
* @param path
* @private
*/
export async function getFilesTreeFromContainer(instance: WebContainer, excludedFilesOrDirectories: string[] = [], path = '/') {
return await getFilesTree([{
path,
isDir: true
}], instance.fs as FileSystem, excludedFilesOrDirectories);
}

/**
* Kill a terminal process and clear its content
* @param terminalSubject
* @param processSubject
* @private
*/
export function killTerminal(terminalSubject: BehaviorSubject<Terminal | null>, processSubject: BehaviorSubject<WebContainerProcess | null>) {
processSubject.value?.kill();
processSubject.next(null);
terminalSubject.value?.clear();
}

/**
* Open a writable stream on the terminal that can be bound to a process to serve as output
* @param terminal
* @param cb
*/
export const createTerminalStream = (terminal: Terminal, cb?: (data: string) => void | Promise<void>) => new WritableStream({
write: (data) => {
if (cb) {
void cb(data);
}
terminal.write(data);
}
});

/**
* Allow a terminal to serve as an input console for a process
* @param process
* @param terminal
*/
export const makeProcessWritable = (process: WebContainerProcess, terminal: Terminal) => {
const input = process.input.getWriter();
terminal.onData((data) => input.write(data));
return input;
};
Loading

0 comments on commit d6ba285

Please sign in to comment.