2026-01-02 21:40:49 +00:00
|
|
|
import * as webcontainer from '@tempfix/webcontainer__api';
|
2025-12-31 08:53:01 +00:00
|
|
|
import type { IExecutionEnvironment, IFileEntry, IFileWatcher, IProcessHandle } from '../interfaces/IExecutionEnvironment.js';
|
2025-12-30 15:37:18 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* WebContainer-based execution environment.
|
|
|
|
|
* Runs Node.js and shell commands in the browser using WebContainer API.
|
|
|
|
|
*/
|
|
|
|
|
export class WebContainerEnvironment implements IExecutionEnvironment {
|
2025-12-30 15:47:15 +00:00
|
|
|
// Static shared state - WebContainer only allows ONE boot per page
|
|
|
|
|
private static sharedContainer: webcontainer.WebContainer | null = null;
|
|
|
|
|
private static bootPromise: Promise<webcontainer.WebContainer> | null = null;
|
|
|
|
|
|
2025-12-30 15:37:18 +00:00
|
|
|
private _ready: boolean = false;
|
|
|
|
|
|
|
|
|
|
public readonly type = 'webcontainer' as const;
|
|
|
|
|
|
|
|
|
|
public get ready(): boolean {
|
|
|
|
|
return this._ready;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 15:47:15 +00:00
|
|
|
private get container(): webcontainer.WebContainer | null {
|
|
|
|
|
return WebContainerEnvironment.sharedContainer;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 15:37:18 +00:00
|
|
|
// ============ Lifecycle ============
|
|
|
|
|
|
|
|
|
|
public async init(): Promise<void> {
|
2025-12-30 15:47:15 +00:00
|
|
|
// Already initialized (this instance)
|
|
|
|
|
if (this._ready && WebContainerEnvironment.sharedContainer) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If boot is in progress (by any instance), wait for it
|
|
|
|
|
if (WebContainerEnvironment.bootPromise) {
|
|
|
|
|
await WebContainerEnvironment.bootPromise;
|
|
|
|
|
this._ready = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If already booted by another instance, just mark ready
|
|
|
|
|
if (WebContainerEnvironment.sharedContainer) {
|
|
|
|
|
this._ready = true;
|
|
|
|
|
return;
|
2025-12-30 15:37:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if SharedArrayBuffer is available (required for WebContainer)
|
|
|
|
|
if (typeof SharedArrayBuffer === 'undefined') {
|
|
|
|
|
throw new Error(
|
|
|
|
|
'WebContainer requires SharedArrayBuffer which is not available. ' +
|
|
|
|
|
'Ensure your server sends these headers:\n' +
|
|
|
|
|
' Cross-Origin-Opener-Policy: same-origin\n' +
|
|
|
|
|
' Cross-Origin-Embedder-Policy: require-corp'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 15:47:15 +00:00
|
|
|
// Start boot process
|
|
|
|
|
WebContainerEnvironment.bootPromise = webcontainer.WebContainer.boot();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
WebContainerEnvironment.sharedContainer = await WebContainerEnvironment.bootPromise;
|
|
|
|
|
this._ready = true;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
// Reset promise on failure so retry is possible
|
|
|
|
|
WebContainerEnvironment.bootPromise = null;
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
2025-12-30 15:37:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async destroy(): Promise<void> {
|
2025-12-30 15:47:15 +00:00
|
|
|
if (WebContainerEnvironment.sharedContainer) {
|
|
|
|
|
WebContainerEnvironment.sharedContainer.teardown();
|
|
|
|
|
WebContainerEnvironment.sharedContainer = null;
|
|
|
|
|
WebContainerEnvironment.bootPromise = null;
|
2025-12-30 15:37:18 +00:00
|
|
|
this._ready = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============ Filesystem Operations ============
|
|
|
|
|
|
|
|
|
|
public async readFile(path: string): Promise<string> {
|
|
|
|
|
this.ensureReady();
|
|
|
|
|
return await this.container!.fs.readFile(path, 'utf-8');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async writeFile(path: string, contents: string): Promise<void> {
|
|
|
|
|
this.ensureReady();
|
|
|
|
|
await this.container!.fs.writeFile(path, contents, 'utf-8');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async readDir(path: string): Promise<IFileEntry[]> {
|
|
|
|
|
this.ensureReady();
|
|
|
|
|
const entries = await this.container!.fs.readdir(path, { withFileTypes: true });
|
|
|
|
|
|
|
|
|
|
return entries.map((entry) => ({
|
|
|
|
|
type: entry.isDirectory() ? 'directory' as const : 'file' as const,
|
|
|
|
|
name: entry.name,
|
|
|
|
|
path: path === '/' ? `/${entry.name}` : `${path}/${entry.name}`,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async mkdir(path: string): Promise<void> {
|
|
|
|
|
this.ensureReady();
|
|
|
|
|
await this.container!.fs.mkdir(path, { recursive: true });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async rm(path: string, options?: { recursive?: boolean }): Promise<void> {
|
|
|
|
|
this.ensureReady();
|
|
|
|
|
await this.container!.fs.rm(path, { recursive: options?.recursive ?? false });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async exists(path: string): Promise<boolean> {
|
|
|
|
|
this.ensureReady();
|
|
|
|
|
try {
|
|
|
|
|
await this.container!.fs.readFile(path);
|
|
|
|
|
return true;
|
|
|
|
|
} catch {
|
|
|
|
|
try {
|
|
|
|
|
await this.container!.fs.readdir(path);
|
|
|
|
|
return true;
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-31 08:53:01 +00:00
|
|
|
public watch(
|
|
|
|
|
path: string,
|
|
|
|
|
callback: (event: 'rename' | 'change', filename: string | null) => void,
|
|
|
|
|
options?: { recursive?: boolean }
|
|
|
|
|
): IFileWatcher {
|
|
|
|
|
this.ensureReady();
|
|
|
|
|
const watcher = this.container!.fs.watch(
|
|
|
|
|
path,
|
|
|
|
|
{ recursive: options?.recursive ?? false },
|
|
|
|
|
callback
|
|
|
|
|
);
|
|
|
|
|
return {
|
|
|
|
|
stop: () => watcher.close(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 15:37:18 +00:00
|
|
|
// ============ Process Execution ============
|
|
|
|
|
|
|
|
|
|
public async spawn(command: string, args: string[] = []): Promise<IProcessHandle> {
|
|
|
|
|
this.ensureReady();
|
|
|
|
|
|
|
|
|
|
const process = await this.container!.spawn(command, args);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
output: process.output as unknown as ReadableStream<string>,
|
|
|
|
|
input: process.input as unknown as { getWriter(): WritableStreamDefaultWriter<string> },
|
|
|
|
|
exit: process.exit,
|
|
|
|
|
kill: () => process.kill(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============ WebContainer-specific methods ============
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Mount files into the virtual filesystem.
|
|
|
|
|
* This is a WebContainer-specific operation.
|
|
|
|
|
* @param files - File tree structure to mount
|
|
|
|
|
*/
|
|
|
|
|
public async mount(files: webcontainer.FileSystemTree): Promise<void> {
|
|
|
|
|
this.ensureReady();
|
|
|
|
|
await this.container!.mount(files);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the underlying WebContainer instance.
|
|
|
|
|
* Use sparingly - prefer the interface methods.
|
|
|
|
|
*/
|
|
|
|
|
public getContainer(): webcontainer.WebContainer {
|
|
|
|
|
this.ensureReady();
|
|
|
|
|
return this.container!;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============ Private Helpers ============
|
|
|
|
|
|
|
|
|
|
private ensureReady(): void {
|
|
|
|
|
if (!this._ready || !this.container) {
|
|
|
|
|
throw new Error('WebContainerEnvironment not initialized. Call init() first.');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|