├── .nvmrc ├── .husky └── pre-commit ├── go_modules ├── .gitignore ├── plugin-echo │ ├── .gitignore │ ├── go.mod │ ├── main.go │ └── go.sum ├── wkg.lock └── wit │ ├── shared.wit │ ├── host-api.wit │ └── plugin-api.wit ├── packages ├── web-host │ ├── src │ │ ├── assets │ │ │ └── .gitkeep │ │ ├── vite-env.d.ts │ │ ├── types │ │ │ ├── generated │ │ │ │ ├── interfaces │ │ │ │ │ ├── repl-api-guest-state.d.ts │ │ │ │ │ ├── repl-api-host-state-plugin.d.ts │ │ │ │ │ ├── repl-api-repl-logic.d.ts │ │ │ │ │ ├── repl-api-plugin.d.ts │ │ │ │ │ ├── repl-api-http-client.d.ts │ │ │ │ │ ├── repl-api-host-state.d.ts │ │ │ │ │ └── repl-api-transport.d.ts │ │ │ │ ├── host-api.d.ts │ │ │ │ └── plugin-api.d.ts │ │ │ └── index.ts │ │ ├── utils │ │ │ ├── css.ts │ │ │ └── github.ts │ │ ├── wasm │ │ │ └── host │ │ │ │ ├── host-state-plugin.ts │ │ │ │ ├── host-state.ts │ │ │ │ └── http-client.ts │ │ ├── main.tsx │ │ ├── hooks │ │ │ ├── replHistory.ts │ │ │ ├── navigation.ts │ │ │ ├── wasm.tsx │ │ │ ├── exampleCommands.ts │ │ │ └── replLogic.ts │ │ ├── components │ │ │ ├── ReplPage.tsx │ │ │ └── ReplHistory.tsx │ │ ├── index.css │ │ └── App.tsx │ ├── README.md │ ├── public │ │ ├── wasi.png │ │ ├── favicon.ico │ │ ├── banner-post.png │ │ ├── favicon-32x32.png │ │ ├── favicon-64x64.png │ │ ├── favicon-128x128.png │ │ ├── favicon-144x144.png │ │ ├── favicon-192x192.png │ │ ├── favicon-256x256.png │ │ ├── favicon-384x384.png │ │ ├── favicon-512x512.png │ │ ├── social-preview.png │ │ ├── social-preview-opt.png │ │ ├── apple-touch-icon-120x120.png │ │ ├── apple-touch-icon-180x180.png │ │ └── plugins │ │ │ └── README.md │ ├── tsconfig.json │ ├── overrides │ │ └── @bytecodealliance │ │ │ └── preview2-shim │ │ │ ├── types │ │ │ ├── interfaces │ │ │ │ ├── wasi-cli-run.d.ts │ │ │ │ ├── wasi-cli-stdin.d.ts │ │ │ │ ├── wasi-cli-stderr.d.ts │ │ │ │ ├── wasi-cli-stdout.d.ts │ │ │ │ ├── wasi-cli-terminal-input.d.ts │ │ │ │ ├── wasi-cli-terminal-output.d.ts │ │ │ │ ├── wasi-sockets-instance-network.d.ts │ │ │ │ ├── wasi-cli-exit.d.ts │ │ │ │ ├── wasi-filesystem-preopens.d.ts │ │ │ │ ├── wasi-cli-terminal-stdin.d.ts │ │ │ │ ├── wasi-cli-terminal-stderr.d.ts │ │ │ │ ├── wasi-cli-terminal-stdout.d.ts │ │ │ │ ├── wasi-io-error.d.ts │ │ │ │ ├── wasi-random-insecure.d.ts │ │ │ │ ├── wasi-cli-environment.d.ts │ │ │ │ ├── wasi-http-incoming-handler.d.ts │ │ │ │ ├── wasi-random-insecure-seed.d.ts │ │ │ │ ├── wasi-http-outgoing-handler.d.ts │ │ │ │ ├── wasi-random-random.d.ts │ │ │ │ ├── wasi-clocks-wall-clock.d.ts │ │ │ │ ├── wasi-clocks-monotonic-clock.d.ts │ │ │ │ ├── wasi-io-poll.d.ts │ │ │ │ ├── wasi-sockets-udp-create-socket.d.ts │ │ │ │ ├── wasi-sockets-tcp-create-socket.d.ts │ │ │ │ └── wasi-sockets-ip-name-lookup.d.ts │ │ │ ├── filesystem.d.ts │ │ │ ├── clocks.d.ts │ │ │ ├── io.d.ts │ │ │ ├── random.d.ts │ │ │ ├── http.d.ts │ │ │ ├── index.d.ts │ │ │ ├── sockets.d.ts │ │ │ ├── cli.d.ts │ │ │ ├── wasi-http-proxy.d.ts │ │ │ └── wasi-cli-command.d.ts │ │ │ ├── lib │ │ │ └── browser │ │ │ │ ├── index.js │ │ │ │ ├── clocks.js │ │ │ │ ├── random.js │ │ │ │ ├── sockets.js │ │ │ │ └── cli.js │ │ │ ├── README.md │ │ │ └── package.json │ ├── .gitignore │ ├── tsconfig.node.json │ ├── tests │ │ ├── repl-ui.spec.ts │ │ ├── repl-loading.spec.ts │ │ ├── navigation.spec.ts │ │ ├── repl-logic.spec.ts │ │ └── utils.ts │ ├── vite.config.ts │ ├── tsconfig.app.json │ ├── playwright.config.ts │ ├── index.html │ ├── clis │ │ └── prepareFilesystem.ts │ ├── package.json │ └── article.html └── plugin-echo │ ├── .gitignore │ ├── src │ └── component.ts │ └── package.json ├── crates ├── plugin-cat │ ├── .gitignore │ ├── Cargo.toml │ ├── README.md │ └── src │ │ └── lib.rs ├── plugin-ls │ ├── .gitignore │ ├── Cargo.toml │ └── README.md ├── plugin-tee │ ├── .gitignore │ ├── Cargo.toml │ ├── README.md │ └── src │ │ └── lib.rs ├── plugin-echo │ ├── .gitignore │ ├── README.md │ ├── Cargo.toml │ └── src │ │ └── lib.rs ├── plugin-greet │ ├── .gitignore │ ├── README.md │ ├── Cargo.toml │ └── src │ │ └── lib.rs ├── plugin-weather │ ├── .gitignore │ ├── Cargo.toml │ ├── README.md │ └── src │ │ ├── api.rs │ │ └── lib.rs ├── repl-logic-guest │ ├── .gitignore │ ├── Cargo.toml │ ├── README.md │ └── src │ │ ├── lib.rs │ │ └── parser.rs └── pluginlab │ ├── demo.gif │ ├── demo-preview.png │ ├── demo-preview-opt.png │ ├── src │ ├── main.rs │ ├── api.rs │ ├── permissions.rs │ ├── cli.rs │ ├── wasm_host.rs │ └── helpers.rs │ ├── wit │ ├── shared.wit │ ├── host-api.wit │ └── plugin-api.wit │ ├── build.rs │ ├── Cargo.toml │ └── tests │ ├── utils.rs │ ├── e2e_c_plugins.rs │ └── e2e_go_plugins.rs ├── .editorconfig ├── c_modules ├── plugin-echo │ ├── .gitignore │ └── component.c └── README.md ├── .cursor └── rules │ ├── git-commands.md │ └── general.mdc ├── fixtures ├── filesystem │ ├── data │ │ ├── processed │ │ │ └── 2024 │ │ │ │ ├── 02 │ │ │ │ └── .gitkeep │ │ │ │ ├── .gitkeep │ │ │ │ └── 01 │ │ │ │ └── .gitkeep │ │ ├── raw │ │ │ ├── incoming │ │ │ │ └── .gitkeep │ │ │ └── outgoing │ │ │ │ └── .gitkeep │ │ ├── sample.csv │ │ └── users.yaml │ ├── logs │ │ ├── errors │ │ │ ├── .gitkeep │ │ │ ├── warning │ │ │ │ └── .gitkeep │ │ │ └── critical │ │ │ │ └── .gitkeep │ │ └── app.log │ ├── documents │ │ ├── work │ │ │ └── projects │ │ │ │ ├── .gitkeep │ │ │ │ ├── alpha │ │ │ │ └── .gitkeep │ │ │ │ └── beta │ │ │ │ └── .gitkeep │ │ ├── README.md │ │ ├── notes.txt │ │ └── config.json │ ├── .hidden_file │ ├── .config │ ├── README.rust.md │ └── README.browser.md ├── valid-plugin-with-invalid-wit.wasm └── README.md ├── .env.original ├── .vscode ├── extensions.json └── settings.json ├── lint-staged.config.mjs ├── biome.json ├── .gitignore ├── LICENSE ├── Cargo.toml ├── .github └── workflows │ ├── update-homebrew-tap.yml │ ├── rust-host.yml │ └── web-host.yml ├── package.json └── scripts ├── prepare-wasm-files.sh └── prepare-wit-files.sh /.nvmrc: -------------------------------------------------------------------------------- 1 | 24.6.0 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | lint-staged 2 | -------------------------------------------------------------------------------- /go_modules/.gitignore: -------------------------------------------------------------------------------- 1 | *.wasm 2 | -------------------------------------------------------------------------------- /packages/web-host/src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/plugin-echo/.gitignore: -------------------------------------------------------------------------------- 1 | types 2 | -------------------------------------------------------------------------------- /packages/web-host/README.md: -------------------------------------------------------------------------------- 1 | # web-host 2 | -------------------------------------------------------------------------------- /crates/plugin-cat/.gitignore: -------------------------------------------------------------------------------- 1 | src/bindings.rs 2 | -------------------------------------------------------------------------------- /crates/plugin-ls/.gitignore: -------------------------------------------------------------------------------- 1 | src/bindings.rs 2 | -------------------------------------------------------------------------------- /crates/plugin-tee/.gitignore: -------------------------------------------------------------------------------- 1 | src/bindings.rs 2 | -------------------------------------------------------------------------------- /crates/plugin-echo/.gitignore: -------------------------------------------------------------------------------- 1 | src/bindings.rs 2 | -------------------------------------------------------------------------------- /crates/plugin-greet/.gitignore: -------------------------------------------------------------------------------- 1 | src/bindings.rs 2 | -------------------------------------------------------------------------------- /crates/plugin-weather/.gitignore: -------------------------------------------------------------------------------- 1 | src/bindings.rs 2 | -------------------------------------------------------------------------------- /crates/repl-logic-guest/.gitignore: -------------------------------------------------------------------------------- 1 | src/bindings.rs 2 | -------------------------------------------------------------------------------- /go_modules/plugin-echo/.gitignore: -------------------------------------------------------------------------------- 1 | internal 2 | *.wasm 3 | -------------------------------------------------------------------------------- /packages/web-host/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_size = 2 # biome uses tabs by default, display them as 2 spaces on github web ui 3 | -------------------------------------------------------------------------------- /c_modules/plugin-echo/.gitignore: -------------------------------------------------------------------------------- 1 | *.wasm 2 | plugin_api.h 3 | plugin_api.c 4 | plugin_api_component_type.o 5 | -------------------------------------------------------------------------------- /.cursor/rules/git-commands.md: -------------------------------------------------------------------------------- 1 | # Git Commands 2 | 3 | ALWAYS USE `git --no-pager` to avoid shell parsing issues. 4 | -------------------------------------------------------------------------------- /crates/pluginlab/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/webassembly-component-model-experiments/HEAD/crates/pluginlab/demo.gif -------------------------------------------------------------------------------- /crates/pluginlab/demo-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/webassembly-component-model-experiments/HEAD/crates/pluginlab/demo-preview.png -------------------------------------------------------------------------------- /fixtures/filesystem/data/processed/2024/02/.gitkeep: -------------------------------------------------------------------------------- 1 | # This file ensures the 02 directory is tracked in git 2 | # Another month directory for testing 3 | -------------------------------------------------------------------------------- /fixtures/filesystem/logs/errors/.gitkeep: -------------------------------------------------------------------------------- 1 | # This file ensures the errors directory is tracked in git 2 | # Error logs nested directory for testing 3 | -------------------------------------------------------------------------------- /packages/web-host/public/wasi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/webassembly-component-model-experiments/HEAD/packages/web-host/public/wasi.png -------------------------------------------------------------------------------- /crates/pluginlab/demo-preview-opt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/webassembly-component-model-experiments/HEAD/crates/pluginlab/demo-preview-opt.png -------------------------------------------------------------------------------- /crates/pluginlab/src/main.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | 3 | #[tokio::main] 4 | async fn main() -> Result<()> { 5 | pluginlab::run_async().await 6 | } 7 | -------------------------------------------------------------------------------- /fixtures/filesystem/data/processed/2024/.gitkeep: -------------------------------------------------------------------------------- 1 | # This file ensures the 2024 directory is tracked in git 2 | # Year-based nested directory for testing 3 | -------------------------------------------------------------------------------- /fixtures/filesystem/data/processed/2024/01/.gitkeep: -------------------------------------------------------------------------------- 1 | # This file ensures the 01 directory is tracked in git 2 | # Month-based nested directory for testing 3 | -------------------------------------------------------------------------------- /fixtures/filesystem/data/raw/incoming/.gitkeep: -------------------------------------------------------------------------------- 1 | # This file ensures the incoming directory is tracked in git 2 | # Raw data nested directory for testing 3 | -------------------------------------------------------------------------------- /packages/web-host/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/webassembly-component-model-experiments/HEAD/packages/web-host/public/favicon.ico -------------------------------------------------------------------------------- /fixtures/filesystem/data/raw/outgoing/.gitkeep: -------------------------------------------------------------------------------- 1 | # This file ensures the outgoing directory is tracked in git 2 | # Outgoing data nested directory for testing 3 | -------------------------------------------------------------------------------- /fixtures/filesystem/documents/work/projects/.gitkeep: -------------------------------------------------------------------------------- 1 | # This file ensures the projects directory is tracked in git 2 | # Nested directory structure for testing 3 | -------------------------------------------------------------------------------- /fixtures/filesystem/documents/work/projects/alpha/.gitkeep: -------------------------------------------------------------------------------- 1 | # This file ensures the alpha directory is tracked in git 2 | # Deep nested directory for testing 3 | -------------------------------------------------------------------------------- /fixtures/filesystem/documents/work/projects/beta/.gitkeep: -------------------------------------------------------------------------------- 1 | # This file ensures the beta directory is tracked in git 2 | # Another nested directory for testing 3 | -------------------------------------------------------------------------------- /fixtures/filesystem/logs/errors/warning/.gitkeep: -------------------------------------------------------------------------------- 1 | # This file ensures the warning directory is tracked in git 2 | # Warning logs nested directory for testing 3 | -------------------------------------------------------------------------------- /fixtures/filesystem/.hidden_file: -------------------------------------------------------------------------------- 1 | # This is a hidden file 2 | # It should only be visible when explicitly requested 3 | # Used for testing hidden file operations 4 | -------------------------------------------------------------------------------- /packages/web-host/public/banner-post.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/webassembly-component-model-experiments/HEAD/packages/web-host/public/banner-post.png -------------------------------------------------------------------------------- /packages/web-host/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/webassembly-component-model-experiments/HEAD/packages/web-host/public/favicon-32x32.png -------------------------------------------------------------------------------- /packages/web-host/public/favicon-64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/webassembly-component-model-experiments/HEAD/packages/web-host/public/favicon-64x64.png -------------------------------------------------------------------------------- /fixtures/filesystem/logs/errors/critical/.gitkeep: -------------------------------------------------------------------------------- 1 | # This file ensures the critical directory is tracked in git 2 | # Critical error logs nested directory for testing 3 | -------------------------------------------------------------------------------- /fixtures/valid-plugin-with-invalid-wit.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/webassembly-component-model-experiments/HEAD/fixtures/valid-plugin-with-invalid-wit.wasm -------------------------------------------------------------------------------- /packages/web-host/public/favicon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/webassembly-component-model-experiments/HEAD/packages/web-host/public/favicon-128x128.png -------------------------------------------------------------------------------- /packages/web-host/public/favicon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/webassembly-component-model-experiments/HEAD/packages/web-host/public/favicon-144x144.png -------------------------------------------------------------------------------- /packages/web-host/public/favicon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/webassembly-component-model-experiments/HEAD/packages/web-host/public/favicon-192x192.png -------------------------------------------------------------------------------- /packages/web-host/public/favicon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/webassembly-component-model-experiments/HEAD/packages/web-host/public/favicon-256x256.png -------------------------------------------------------------------------------- /packages/web-host/public/favicon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/webassembly-component-model-experiments/HEAD/packages/web-host/public/favicon-384x384.png -------------------------------------------------------------------------------- /packages/web-host/public/favicon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/webassembly-component-model-experiments/HEAD/packages/web-host/public/favicon-512x512.png -------------------------------------------------------------------------------- /packages/web-host/public/social-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/webassembly-component-model-experiments/HEAD/packages/web-host/public/social-preview.png -------------------------------------------------------------------------------- /packages/web-host/public/social-preview-opt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/webassembly-component-model-experiments/HEAD/packages/web-host/public/social-preview-opt.png -------------------------------------------------------------------------------- /packages/web-host/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /packages/web-host/src/types/generated/interfaces/repl-api-guest-state.d.ts: -------------------------------------------------------------------------------- 1 | /** @module Interface repl:api/guest-state **/ 2 | export function getReservedCommands(): Array; 3 | -------------------------------------------------------------------------------- /packages/web-host/public/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/webassembly-component-model-experiments/HEAD/packages/web-host/public/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /packages/web-host/public/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/webassembly-component-model-experiments/HEAD/packages/web-host/public/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /packages/web-host/src/types/generated/interfaces/repl-api-host-state-plugin.d.ts: -------------------------------------------------------------------------------- 1 | /** @module Interface repl:api/host-state-plugin **/ 2 | export function getReplVar(key: string): string | undefined; 3 | -------------------------------------------------------------------------------- /fixtures/filesystem/data/sample.csv: -------------------------------------------------------------------------------- 1 | id,name,age,city,active 2 | 1,Alice,28,New York,true 3 | 2,Bob,32,San Francisco,true 4 | 3,Charlie,25,Chicago,false 5 | 4,Diana,29,Boston,true 6 | 5,Eve,35,Seattle,true 7 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/interfaces/wasi-cli-run.d.ts: -------------------------------------------------------------------------------- 1 | /** @module Interface wasi:cli/run@0.2.3 **/ 2 | /** 3 | * Run the program. 4 | */ 5 | export function run(): void; 6 | -------------------------------------------------------------------------------- /packages/web-host/public/plugins/README.md: -------------------------------------------------------------------------------- 1 | This directory contains the compiled wasm plugins and repl logic guest, directly copied from the compiled wasm files, so that they can be used by the `pluginlab` binary via http. 2 | -------------------------------------------------------------------------------- /packages/web-host/src/utils/css.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /.cursor/rules/general.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | Here are the rules you MUST follow at ALL time unless you are explicitly told - YOU MUST ASK BEFORE: 7 | - adding/updating a new dependency 8 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/filesystem.d.ts: -------------------------------------------------------------------------------- 1 | export type * as preopens from './interfaces/wasi-filesystem-preopens.d.ts'; 2 | export type * as types from './interfaces/wasi-filesystem-types.d.ts'; 3 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/clocks.d.ts: -------------------------------------------------------------------------------- 1 | export type * as monotonicClock from './interfaces/wasi-clocks-monotonic-clock.d.ts'; 2 | export type * as wallClock from './interfaces/wasi-clocks-wall-clock.d.ts'; 3 | -------------------------------------------------------------------------------- /.env.original: -------------------------------------------------------------------------------- 1 | # see https://github.com/WebAssembly/wasi-sdk/releases for exact possibilities 2 | WASI_OS=macos # 'macos' | 'linux' | ''windows' 3 | WASI_ARCH=x86_64 # 'x86_64' | 'arm64' 4 | WASI_VERSION=27 5 | WASI_VERSION_FULL=${WASI_VERSION}.0 6 | -------------------------------------------------------------------------------- /packages/web-host/src/wasm/host/host-state-plugin.ts: -------------------------------------------------------------------------------- 1 | import { getReplVars } from "./host-state"; 2 | 3 | export function getReplVar(key: string): string | undefined { 4 | return getReplVars().find((replVar) => replVar.key === key)?.value; 5 | } 6 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/interfaces/wasi-cli-stdin.d.ts: -------------------------------------------------------------------------------- 1 | /** @module Interface wasi:cli/stdin@0.2.3 **/ 2 | export function getStdin(): InputStream; 3 | export type InputStream = import('./wasi-io-streams.js').InputStream; 4 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/interfaces/wasi-cli-stderr.d.ts: -------------------------------------------------------------------------------- 1 | /** @module Interface wasi:cli/stderr@0.2.3 **/ 2 | export function getStderr(): OutputStream; 3 | export type OutputStream = import('./wasi-io-streams.js').OutputStream; 4 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/interfaces/wasi-cli-stdout.d.ts: -------------------------------------------------------------------------------- 1 | /** @module Interface wasi:cli/stdout@0.2.3 **/ 2 | export function getStdout(): OutputStream; 3 | export type OutputStream = import('./wasi-io-streams.js').OutputStream; 4 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/io.d.ts: -------------------------------------------------------------------------------- 1 | export type * as error from './interfaces/wasi-io-error.d.ts'; 2 | export type * as poll from './interfaces/wasi-io-poll.d.ts'; 3 | export type * as streams from './interfaces/wasi-io-streams.d.ts'; 4 | -------------------------------------------------------------------------------- /packages/web-host/src/types/generated/interfaces/repl-api-repl-logic.d.ts: -------------------------------------------------------------------------------- 1 | /** @module Interface repl:api/repl-logic **/ 2 | export function readline(line: string): ReadlineResponse; 3 | export type ReadlineResponse = 4 | import("./repl-api-transport.js").ReadlineResponse; 5 | -------------------------------------------------------------------------------- /fixtures/filesystem/documents/README.md: -------------------------------------------------------------------------------- 1 | # Documents 2 | 3 | This is a mock documents directory for testing filesystem operations. 4 | 5 | ## Contents 6 | - Various text files 7 | - Configuration files 8 | - Sample data 9 | 10 | Feel free to modify these files during testing. 11 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "rust-lang.rust-analyzer", 4 | "bytecodealliance.wit-idl", 5 | "skellock.just", 6 | "biomejs.biome", 7 | "bradlc.vscode-tailwindcss", 8 | "ms-playwright.playwright", 9 | "ms-vscode.cpptools" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/interfaces/wasi-cli-terminal-input.d.ts: -------------------------------------------------------------------------------- 1 | /** @module Interface wasi:cli/terminal-input@0.2.3 **/ 2 | 3 | export class TerminalInput { 4 | /** 5 | * This type does not have a public constructor. 6 | */ 7 | private constructor(); 8 | } 9 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/random.d.ts: -------------------------------------------------------------------------------- 1 | export type * as insecureSeed from './interfaces/wasi-random-insecure-seed.d.ts'; 2 | export type * as insecure from './interfaces/wasi-random-insecure.d.ts'; 3 | export type * as random from './interfaces/wasi-random-random.d.ts'; 4 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/interfaces/wasi-cli-terminal-output.d.ts: -------------------------------------------------------------------------------- 1 | /** @module Interface wasi:cli/terminal-output@0.2.3 **/ 2 | 3 | export class TerminalOutput { 4 | /** 5 | * This type does not have a public constructor. 6 | */ 7 | private constructor(); 8 | } 9 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/http.d.ts: -------------------------------------------------------------------------------- 1 | export type * as incomingHandler from './interfaces/wasi-http-incoming-handler.d.ts'; 2 | export type * as outgoingHandler from './interfaces/wasi-http-outgoing-handler.d.ts'; 3 | export type * as types from './interfaces/wasi-http-types.d.ts'; 4 | -------------------------------------------------------------------------------- /packages/web-host/src/types/generated/interfaces/repl-api-plugin.d.ts: -------------------------------------------------------------------------------- 1 | /** @module Interface repl:api/plugin **/ 2 | export function name(): string; 3 | export function man(): string; 4 | export function run(payload: string): PluginResponse; 5 | export type PluginResponse = import("./repl-api-transport.js").PluginResponse; 6 | -------------------------------------------------------------------------------- /crates/pluginlab/src/api.rs: -------------------------------------------------------------------------------- 1 | pub mod host_api { 2 | #![allow(warnings)] 3 | 4 | use wasmtime; 5 | 6 | include!(concat!(env!("OUT_DIR"), "/host_api.rs")); 7 | } 8 | 9 | pub mod plugin_api { 10 | #![allow(warnings)] 11 | 12 | use wasmtime; 13 | 14 | include!(concat!(env!("OUT_DIR"), "/plugin_api.rs")); 15 | } 16 | -------------------------------------------------------------------------------- /fixtures/filesystem/.config: -------------------------------------------------------------------------------- 1 | # Hidden configuration file 2 | # This file contains sensitive configuration data 3 | # Should be handled carefully in tests 4 | 5 | [app] 6 | debug = true 7 | log_level = info 8 | max_connections = 10 9 | 10 | [filesystem] 11 | root_path = /tmp/filesystem 12 | allow_hidden = false 13 | max_file_size = 1048576 14 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/interfaces/wasi-sockets-instance-network.d.ts: -------------------------------------------------------------------------------- 1 | /** @module Interface wasi:sockets/instance-network@0.2.3 **/ 2 | /** 3 | * Get a handle to the default network. 4 | */ 5 | export function instanceNetwork(): Network; 6 | export type Network = import('./wasi-sockets-network.js').Network; 7 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/interfaces/wasi-cli-exit.d.ts: -------------------------------------------------------------------------------- 1 | /** @module Interface wasi:cli/exit@0.2.3 **/ 2 | /** 3 | * Exit the current instance and any linked instances. 4 | */ 5 | export function exit(status: Result): void; 6 | export type Result = { tag: 'ok', val: T } | { tag: 'err', val: E }; 7 | -------------------------------------------------------------------------------- /go_modules/wkg.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically generated. 2 | # It is not intended for manual editing. 3 | version = 1 4 | 5 | [[packages]] 6 | name = "wasi:cli" 7 | registry = "wasi.dev" 8 | 9 | [[packages.versions]] 10 | requirement = "=0.2.0" 11 | version = "0.2.0" 12 | digest = "sha256:e7e85458e11caf76554b724ebf4f113259decf0f3b1ee2e2930de096f72114a7" 13 | -------------------------------------------------------------------------------- /fixtures/filesystem/documents/notes.txt: -------------------------------------------------------------------------------- 1 | Meeting Notes - 2024-01-15 2 | 3 | Agenda: 4 | 1. Project status review 5 | 2. Next sprint planning 6 | 3. Technical debt discussion 7 | 8 | Action Items: 9 | - Update documentation 10 | - Review pull requests 11 | - Schedule team meeting 12 | 13 | Notes: 14 | The new filesystem API is working well. Need to add more test cases. 15 | -------------------------------------------------------------------------------- /lint-staged.config.mjs: -------------------------------------------------------------------------------- 1 | 2 | export default { 3 | 'packages/*/!(overrides)/**': 'biome check --write', 4 | 'packages/plugin-echo/**/*.ts': () => 'npm run typecheck --workspace=packages/plugin-echo', 5 | 'packages/web-host/**/*.{ts,tsx}': () => 'npm run typecheck --workspace=packages/web-host', 6 | // `cargo fmt doesn't accept files 7 | 'crates/**': () => 'cargo fmt', 8 | } 9 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/interfaces/wasi-filesystem-preopens.d.ts: -------------------------------------------------------------------------------- 1 | /** @module Interface wasi:filesystem/preopens@0.2.3 **/ 2 | /** 3 | * Return the set of preopened directories, and their paths. 4 | */ 5 | export function getDirectories(): Array<[Descriptor, string]>; 6 | export type Descriptor = import('./wasi-filesystem-types.js').Descriptor; 7 | -------------------------------------------------------------------------------- /packages/web-host/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import "./index.css"; 4 | import App from "./App.tsx"; 5 | 6 | // biome-ignore lint/style/noNonNullAssertion: root is always defined 7 | createRoot(document.getElementById("root")!).render( 8 | 9 | 10 | , 11 | ); 12 | -------------------------------------------------------------------------------- /fixtures/filesystem/README.rust.md: -------------------------------------------------------------------------------- 1 | # filesystem 2 | 3 | This directory was mounted with the `--dir tmp/filesystem` argument, giving only access to the `tmp/filesystem` directory. 4 | 5 | The `tmp/filesystem` directory is a copy of the `fixtures/filesystem` directory. 6 | 7 | You can safely modify the `tmp/filesystem` directory in your tests. 8 | 9 | The rest of the files are mock files. 10 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/index.d.ts: -------------------------------------------------------------------------------- 1 | export type * as cli from "./cli.d.ts"; 2 | export type * as clocks from './clocks.d.ts'; 3 | export type * as filesystem from './filesystem.d.ts'; 4 | export type * as http from "./http.d.ts"; 5 | export type * as io from "./io.d.ts"; 6 | export type * as random from "./random.d.ts"; 7 | export type * as sockets from "./sockets.d.ts"; 8 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.0.5/schema.json", 3 | "files": { 4 | "includes": [ 5 | "packages/**", 6 | "!**/node_modules", 7 | "!**/dist", 8 | "!**/build", 9 | "!**/package.json", 10 | "!packages/web-host/overrides/**" 11 | ] 12 | }, 13 | "formatter": { 14 | "indentStyle": "space", 15 | "indentWidth": 2 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/web-host/src/types/generated/interfaces/repl-api-http-client.d.ts: -------------------------------------------------------------------------------- 1 | /** @module Interface repl:api/http-client **/ 2 | export function get(url: string, headers: Array): HttpResponse; 3 | export interface HttpHeader { 4 | name: string; 5 | value: string; 6 | } 7 | export interface HttpResponse { 8 | status: number; 9 | ok: boolean; 10 | headers: Array; 11 | body: string; 12 | } 13 | -------------------------------------------------------------------------------- /packages/web-host/src/types/generated/interfaces/repl-api-host-state.d.ts: -------------------------------------------------------------------------------- 1 | /** @module Interface repl:api/host-state **/ 2 | export function getPluginsNames(): Array; 3 | export function getReplVars(): Array; 4 | export function setReplVar(var_: ReplVar): void; 5 | export type ReadlineResponse = 6 | import("./repl-api-transport.js").ReadlineResponse; 7 | export type ReplVar = import("./repl-api-transport.js").ReplVar; 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Rust ### 2 | # Generated by Cargo 3 | # will have compiled files and executables 4 | debug/ 5 | target/ 6 | 7 | # These are backup files generated by rustfmt 8 | **/*.rs.bk 9 | 10 | # MSVC Windows builds of rustc generate these, which store debugging information 11 | *.pdb 12 | 13 | # Local files 14 | /local 15 | /tmp 16 | 17 | ### JavaScript ### 18 | node_modules/ 19 | dist/ 20 | 21 | # C 22 | .env 23 | c_deps/ 24 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/interfaces/wasi-cli-terminal-stdin.d.ts: -------------------------------------------------------------------------------- 1 | /** @module Interface wasi:cli/terminal-stdin@0.2.3 **/ 2 | /** 3 | * If stdin is connected to a terminal, return a `terminal-input` handle 4 | * allowing further interaction with it. 5 | */ 6 | export function getTerminalStdin(): TerminalInput | undefined; 7 | export type TerminalInput = import('./wasi-cli-terminal-input.js').TerminalInput; 8 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/interfaces/wasi-cli-terminal-stderr.d.ts: -------------------------------------------------------------------------------- 1 | /** @module Interface wasi:cli/terminal-stderr@0.2.3 **/ 2 | /** 3 | * If stderr is connected to a terminal, return a `terminal-output` handle 4 | * allowing further interaction with it. 5 | */ 6 | export function getTerminalStderr(): TerminalOutput | undefined; 7 | export type TerminalOutput = import('./wasi-cli-terminal-output.js').TerminalOutput; 8 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/interfaces/wasi-cli-terminal-stdout.d.ts: -------------------------------------------------------------------------------- 1 | /** @module Interface wasi:cli/terminal-stdout@0.2.3 **/ 2 | /** 3 | * If stdout is connected to a terminal, return a `terminal-output` handle 4 | * allowing further interaction with it. 5 | */ 6 | export function getTerminalStdout(): TerminalOutput | undefined; 7 | export type TerminalOutput = import('./wasi-cli-terminal-output.js').TerminalOutput; 8 | -------------------------------------------------------------------------------- /fixtures/filesystem/documents/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": { 3 | "name": "Test Application", 4 | "version": "1.0.0", 5 | "debug": true 6 | }, 7 | "filesystem": { 8 | "root": "/tmp/filesystem", 9 | "max_file_size": 1048576, 10 | "allowed_extensions": [".txt", ".md", ".json", ".yaml", ".yml"] 11 | }, 12 | "features": { 13 | "read": true, 14 | "write": true, 15 | "delete": false, 16 | "create_directories": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/web-host/src/types/generated/host-api.d.ts: -------------------------------------------------------------------------------- 1 | // world repl:api/host-api 2 | export type * as ReplApiHostState from "./interfaces/repl-api-host-state.js"; // import repl:api/host-state 3 | export type * as ReplApiTransport from "./interfaces/repl-api-transport.js"; // import repl:api/transport 4 | export * as guestState from "./interfaces/repl-api-guest-state.js"; // export repl:api/guest-state 5 | export * as replLogic from "./interfaces/repl-api-repl-logic.js"; // export repl:api/repl-logic 6 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/lib/browser/index.js: -------------------------------------------------------------------------------- 1 | import * as clocks from "./clocks.js"; 2 | import * as filesystem from "./filesystem.js"; 3 | import * as http from "./http.js"; 4 | import * as io from "./io.js"; 5 | import * as random from "./random.js"; 6 | import * as sockets from "./sockets.js"; 7 | import * as cli from "./cli.js"; 8 | 9 | export { 10 | clocks, 11 | filesystem, 12 | http, 13 | io, 14 | random, 15 | sockets, 16 | cli, 17 | } 18 | -------------------------------------------------------------------------------- /packages/web-host/src/types/generated/plugin-api.d.ts: -------------------------------------------------------------------------------- 1 | // world repl:api/plugin-api 2 | export type * as ReplApiHostStatePlugin from "./interfaces/repl-api-host-state-plugin.js"; // import repl:api/host-state-plugin 3 | export type * as ReplApiHttpClient from "./interfaces/repl-api-http-client.js"; // import repl:api/http-client 4 | export type * as ReplApiTransport from "./interfaces/repl-api-transport.js"; // import repl:api/transport 5 | export * as plugin from "./interfaces/repl-api-plugin.js"; // export repl:api/plugin 6 | -------------------------------------------------------------------------------- /packages/plugin-echo/src/component.ts: -------------------------------------------------------------------------------- 1 | import type { plugin as pluginApi } from "./types/plugin-api"; 2 | 3 | export const plugin: typeof pluginApi = { 4 | name: () => "echo", 5 | man: () => ` 6 | NAME 7 | echo - echo a message (Built with TypeScript 🟦) 8 | 9 | USAGE 10 | echo 11 | 12 | DESCRIPTION 13 | Echo a message. 14 | `, 15 | run: (payload: string) => { 16 | return { 17 | status: "success", 18 | stdout: payload, 19 | stderr: undefined, 20 | }; 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /crates/pluginlab/wit/shared.wit: -------------------------------------------------------------------------------- 1 | interface transport { 2 | record plugin-response { 3 | status: repl-status, 4 | stdout: option, 5 | stderr: option, 6 | } 7 | 8 | enum repl-status { 9 | success, 10 | error, 11 | } 12 | 13 | record parsed-line { 14 | command: string, 15 | payload: string, 16 | } 17 | 18 | variant readline-response { 19 | to-run(parsed-line), 20 | ready(plugin-response), 21 | } 22 | 23 | record repl-var { 24 | key: string, 25 | value: string, 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /crates/repl-logic-guest/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "repl-logic-guest" 3 | version = "0.1.0" 4 | edition = { workspace = true } 5 | publish = false 6 | description = "Guest WASM component for REPL evaluation - compiles to WebAssembly for multi-language WIT hosts" 7 | 8 | [dependencies] 9 | wit-bindgen-rt = { workspace = true } 10 | 11 | [lib] 12 | crate-type = ["cdylib"] 13 | 14 | [package.metadata.component] 15 | package = "repl:api" 16 | target = { path = "../pluginlab/wit", world = "host-api" } 17 | 18 | [package.metadata.component.dependencies] 19 | -------------------------------------------------------------------------------- /crates/plugin-ls/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "plugin-ls" 3 | version = "0.1.0" 4 | edition = { workspace = true } 5 | publish = false 6 | description = "Example ls plugin for REPL based on WebAssembly Component Model - demonstrates file system access" 7 | 8 | [dependencies] 9 | wit-bindgen-rt = { workspace = true, features = ["bitflags"] } 10 | 11 | [lib] 12 | crate-type = ["cdylib"] 13 | 14 | [package.metadata.component] 15 | package = "repl:api" 16 | target = { path = "../pluginlab/wit", world = "plugin-api" } 17 | 18 | [package.metadata.component.dependencies] 19 | -------------------------------------------------------------------------------- /crates/plugin-cat/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "plugin-cat" 3 | version = "0.1.0" 4 | edition = { workspace = true } 5 | publish = false 6 | description = "Example cat plugin for REPL based on WebAssembly Component Model - demonstrates file system access" 7 | 8 | [dependencies] 9 | wit-bindgen-rt = { workspace = true, features = ["bitflags"] } 10 | 11 | [lib] 12 | crate-type = ["cdylib"] 13 | 14 | [package.metadata.component] 15 | package = "repl:api" 16 | target = { path = "../pluginlab/wit", world = "plugin-api" } 17 | 18 | [package.metadata.component.dependencies] 19 | -------------------------------------------------------------------------------- /packages/web-host/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # Project specific 27 | public/plugins/*.wasm 28 | src/wasm/generated 29 | 30 | # Playwright 31 | /test-results/ 32 | /playwright-report/ 33 | /blob-report/ 34 | /playwright/.cache/ 35 | -------------------------------------------------------------------------------- /crates/plugin-tee/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "plugin-tee" 3 | version = "0.1.0" 4 | edition = { workspace = true } 5 | publish = false 6 | description = "Example tee plugin for REPL based on WebAssembly Component Model - demonstrates file system access and modification" 7 | 8 | [dependencies] 9 | wit-bindgen-rt = { workspace = true, features = ["bitflags"] } 10 | 11 | [lib] 12 | crate-type = ["cdylib"] 13 | 14 | [package.metadata.component] 15 | package = "repl:api" 16 | target = { path = "../pluginlab/wit", world = "plugin-api" } 17 | 18 | [package.metadata.component.dependencies] 19 | -------------------------------------------------------------------------------- /crates/plugin-greet/README.md: -------------------------------------------------------------------------------- 1 | # plugin-echo 2 | 3 | Basic plugin for this REPL. Outputs a greeting message. 4 | 5 | ## Notes 6 | 7 | This crate was initialized with `cargo component new`. 8 | 9 | The building process is handled by the [`justfile`](../../justfile) in the root of the project. 10 | 11 | The `cargo component build` command is used to build the plugin. 12 | 13 | - It generates the `src/bindings.rs` file, based on the `package.metadata.component` section in the `Cargo.toml` file that describes where to find the component definition (wit files). 14 | - It then compiles the plugin to WebAssembly. 15 | -------------------------------------------------------------------------------- /crates/plugin-tee/README.md: -------------------------------------------------------------------------------- 1 | # plugin-tee 2 | 3 | Basic plugin for this REPL. Behaves like the `tee` command. 4 | 5 | ## Notes 6 | 7 | This crate was initialized with `cargo component new`. 8 | 9 | The building process is handled by the [`justfile`](../../justfile) in the root of the project. 10 | 11 | The `cargo component build` command is used to build the plugin. 12 | 13 | - It generates the `src/bindings.rs` file, based on the `package.metadata.component` section in the `Cargo.toml` file that describes where to find the component definition (wit files). 14 | - It then compiles the plugin to WebAssembly. 15 | -------------------------------------------------------------------------------- /crates/plugin-echo/README.md: -------------------------------------------------------------------------------- 1 | # plugin-echo 2 | 3 | Basic plugin for this REPL. Behaves like the `echo` command. 4 | 5 | ## Notes 6 | 7 | This crate was initialized with `cargo component new`. 8 | 9 | The building process is handled by the [`justfile`](../../justfile) in the root of the project. 10 | 11 | The `cargo component build` command is used to build the plugin. 12 | 13 | - It generates the `src/bindings.rs` file, based on the `package.metadata.component` section in the `Cargo.toml` file that describes where to find the component definition (wit files). 14 | - It then compiles the plugin to WebAssembly. 15 | -------------------------------------------------------------------------------- /crates/plugin-echo/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "plugin-echo" 3 | version = "0.1.0" 4 | edition = { workspace = true } 5 | publish = false 6 | description = "Example echo plugin for REPL based on WebAssembly Component Model - demonstrates basic plugin API implementation with argument handling and response formatting" 7 | 8 | [dependencies] 9 | wit-bindgen-rt = { workspace = true, features = ["bitflags"] } 10 | 11 | [lib] 12 | crate-type = ["cdylib"] 13 | 14 | [package.metadata.component] 15 | package = "repl:api" 16 | target = { path = "../pluginlab/wit", world = "plugin-api" } 17 | 18 | [package.metadata.component.dependencies] 19 | -------------------------------------------------------------------------------- /fixtures/filesystem/data/users.yaml: -------------------------------------------------------------------------------- 1 | users: 2 | - id: 1 3 | name: Alice 4 | email: alice@example.com 5 | role: admin 6 | permissions: 7 | - read 8 | - write 9 | - delete 10 | - id: 2 11 | name: Bob 12 | email: bob@example.com 13 | role: user 14 | permissions: 15 | - read 16 | - write 17 | - id: 3 18 | name: Charlie 19 | email: charlie@example.com 20 | role: guest 21 | permissions: 22 | - read 23 | 24 | settings: 25 | max_file_size: 1048576 26 | allowed_extensions: 27 | - .txt 28 | - .md 29 | - .json 30 | - .yaml 31 | - .csv 32 | -------------------------------------------------------------------------------- /crates/plugin-greet/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "plugin-greet" 3 | version = "0.1.0" 4 | edition = { workspace = true } 5 | publish = false 6 | description = "Example greeting plugin for REPL based on WebAssembly Component Model - demonstrates basic plugin API implementation with argument handling and response formatting" 7 | 8 | [dependencies] 9 | wit-bindgen-rt = { workspace = true, features = ["bitflags"] } 10 | 11 | [lib] 12 | crate-type = ["cdylib"] 13 | 14 | [package.metadata.component] 15 | package = "repl:api" 16 | target = { path = "../pluginlab/wit", world = "plugin-api" } 17 | 18 | [package.metadata.component.dependencies] 19 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/sockets.d.ts: -------------------------------------------------------------------------------- 1 | export type * as instanceNetwork from './interfaces/wasi-sockets-instance-network.d.ts'; 2 | export type * as ipNameLookup from './interfaces/wasi-sockets-ip-name-lookup.d.ts'; 3 | export type * as network from './interfaces/wasi-sockets-network.d.ts'; 4 | export type * as tcpCreateSocket from './interfaces/wasi-sockets-tcp-create-socket.d.ts'; 5 | export type * as tcp from './interfaces/wasi-sockets-tcp.d.ts'; 6 | export type * as udpCreateSocket from './interfaces/wasi-sockets-udp-create-socket.d.ts'; 7 | export type * as udp from './interfaces/wasi-sockets-udp.d.ts'; 8 | -------------------------------------------------------------------------------- /crates/pluginlab/wit/host-api.wit: -------------------------------------------------------------------------------- 1 | package repl:api; 2 | 3 | interface host-state { 4 | use transport.{readline-response}; 5 | use transport.{repl-var}; 6 | 7 | get-plugins-names: func() -> list; 8 | get-repl-vars: func() -> list; 9 | set-repl-var: func(var: repl-var); 10 | } 11 | 12 | interface guest-state { 13 | get-reserved-commands: func() -> list; 14 | } 15 | 16 | interface repl-logic { 17 | use transport.{readline-response}; 18 | readline: func(line: string) -> readline-response; 19 | } 20 | 21 | world host-api { 22 | import host-state; 23 | export guest-state; 24 | export repl-logic; 25 | } 26 | -------------------------------------------------------------------------------- /go_modules/wit/shared.wit: -------------------------------------------------------------------------------- 1 | // Code generated by `prepare-wit-files.sh`, from `crates/pluginlab/wit/*.wit`. DO NOT EDIT! 2 | 3 | interface transport { 4 | record plugin-response { 5 | status: repl-status, 6 | stdout: option, 7 | stderr: option, 8 | } 9 | 10 | enum repl-status { 11 | success, 12 | error, 13 | } 14 | 15 | record parsed-line { 16 | command: string, 17 | payload: string, 18 | } 19 | 20 | variant readline-response { 21 | to-run(parsed-line), 22 | ready(plugin-response), 23 | } 24 | 25 | record repl-var { 26 | key: string, 27 | value: string, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/interfaces/wasi-io-error.d.ts: -------------------------------------------------------------------------------- 1 | /** @module Interface wasi:io/error@0.2.3 **/ 2 | 3 | export class Error { 4 | /** 5 | * This type does not have a public constructor. 6 | */ 7 | private constructor(); 8 | /** 9 | * Returns a string that is suitable to assist humans in debugging 10 | * this error. 11 | * 12 | * WARNING: The returned string should not be consumed mechanically! 13 | * It may change across platforms, hosts, or other implementation 14 | * details. Parsing this string is a major platform-compatibility 15 | * hazard. 16 | */ 17 | toDebugString(): string; 18 | } 19 | -------------------------------------------------------------------------------- /packages/web-host/src/types/index.ts: -------------------------------------------------------------------------------- 1 | // wasm 2 | 3 | import type * as HostApiNamespace from "./generated/host-api"; 4 | import type { ReplStatus } from "./generated/interfaces/repl-api-transport"; 5 | import type * as PluginApiNamespace from "./generated/plugin-api"; 6 | 7 | export * from "./generated/interfaces/repl-api-transport"; 8 | 9 | export type HostApi = typeof HostApiNamespace; 10 | export type PluginApi = typeof PluginApiNamespace; 11 | 12 | // ui 13 | 14 | export type ReplHistoryEntry = 15 | | { 16 | stdout?: string; 17 | stderr?: string; 18 | status: ReplStatus; 19 | } 20 | | { 21 | stdin: string; 22 | allowHtml?: boolean; 23 | }; 24 | -------------------------------------------------------------------------------- /crates/plugin-cat/README.md: -------------------------------------------------------------------------------- 1 | # plugin-cat 2 | 3 | Advanced plugin for this REPL. Behaves like the `cat` command. 4 | 5 | Demonstrates how a WebAssembly component can access the filesystem. 6 | 7 | ## Notes 8 | 9 | This crate was initialized with `cargo component new`. 10 | 11 | The building process is handled by the [`justfile`](../../justfile) in the root of the project. 12 | 13 | The `cargo component build` command is used to build the plugin. 14 | 15 | - It generates the `src/bindings.rs` file, based on the `package.metadata.component` section in the `Cargo.toml` file that describes where to find the component definition (wit files). 16 | - It then compiles the plugin to WebAssembly. 17 | -------------------------------------------------------------------------------- /crates/plugin-ls/README.md: -------------------------------------------------------------------------------- 1 | # plugin-echo 2 | 3 | Advanced plugin for this REPL. Behaves like the `ls` command. 4 | 5 | Demonstrates how a WebAssembly component can access the filesystem. 6 | 7 | ## Notes 8 | 9 | This crate was initialized with `cargo component new`. 10 | 11 | The building process is handled by the [`justfile`](../../justfile) in the root of the project. 12 | 13 | The `cargo component build` command is used to build the plugin. 14 | 15 | - It generates the `src/bindings.rs` file, based on the `package.metadata.component` section in the `Cargo.toml` file that describes where to find the component definition (wit files). 16 | - It then compiles the plugin to WebAssembly. 17 | -------------------------------------------------------------------------------- /crates/plugin-weather/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "plugin-weather" 3 | version = "0.1.0" 4 | edition = { workspace = true } 5 | publish = false 6 | description = "Example weather plugin for REPL based on WebAssembly Component Model - demonstrates using network by calling to an external API" 7 | 8 | [dependencies] 9 | serde = { version = "1.0", features = ["derive"] } 10 | serde_json = "1.0.140" 11 | wit-bindgen-rt = { workspace = true, features = ["bitflags"] } 12 | 13 | [lib] 14 | crate-type = ["cdylib"] 15 | 16 | [package.metadata.component] 17 | package = "repl:api" 18 | target = { path = "../pluginlab/wit", world = "plugin-api" } 19 | 20 | [package.metadata.component.dependencies] 21 | -------------------------------------------------------------------------------- /c_modules/README.md: -------------------------------------------------------------------------------- 1 | # Plugins written in C 2 | 3 | You can find here plugins written in C, some are re-implementations of the plugins written in Rust. 4 | 5 | The `plugin_api.c`, `plugin_api.h` and `plugin_api.component_type.o` are generated with [`wit-bindgen`](https://github.com/bytecodealliance/wit-bindgen), based on the [wit files](../crates/pluginlab/wit) of the project. 6 | 7 | The wasm files are compiled with the [wasi-sdk](https://github.com/WebAssembly/wasi-sdk) you downloaded with `just dl-wasi-sdk`, which contain the `clang` compiler. 8 | 9 | All you have to do is run `just build` to build everything (including the C plugins) and `just test` to run the tests (including the C plugins). 10 | -------------------------------------------------------------------------------- /fixtures/filesystem/logs/app.log: -------------------------------------------------------------------------------- 1 | 2024-01-15 10:30:15 INFO Application started 2 | 2024-01-15 10:30:16 INFO Loading configuration from config.json 3 | 2024-01-15 10:30:17 INFO Filesystem initialized with root: /tmp/filesystem 4 | 2024-01-15 10:30:18 INFO User authentication successful 5 | 2024-01-15 10:30:19 INFO File read operation: documents/notes.txt 6 | 2024-01-15 10:30:20 INFO File write operation: temp/test.txt 7 | 2024-01-15 10:30:21 WARN Large file detected: backup/archive.tar.gz 8 | 2024-01-15 10:30:22 INFO Directory listing: documents/ 9 | 2024-01-15 10:30:23 INFO File delete operation: temp/old_file.txt 10 | 2024-01-15 10:30:24 ERROR Permission denied: /etc/passwd 11 | 2024-01-15 10:30:25 INFO Application shutdown 12 | -------------------------------------------------------------------------------- /go_modules/wit/host-api.wit: -------------------------------------------------------------------------------- 1 | // Code generated by `prepare-wit-files.sh`, from `crates/pluginlab/wit/*.wit`. DO NOT EDIT! 2 | 3 | package repl:api; 4 | 5 | interface host-state { 6 | use transport.{readline-response}; 7 | use transport.{repl-var}; 8 | 9 | get-plugins-names: func() -> list; 10 | get-repl-vars: func() -> list; 11 | set-repl-var: func(var: repl-var); 12 | } 13 | 14 | interface guest-state { 15 | get-reserved-commands: func() -> list; 16 | } 17 | 18 | interface repl-logic { 19 | use transport.{readline-response}; 20 | readline: func(line: string) -> readline-response; 21 | } 22 | 23 | world host-api { 24 | import host-state; 25 | export guest-state; 26 | export repl-logic; 27 | } 28 | -------------------------------------------------------------------------------- /crates/plugin-weather/README.md: -------------------------------------------------------------------------------- 1 | # plugin-echo 2 | 3 | Advanced plugin for this REPL. Calls an external API to get the weather. 4 | 5 | Demonstrates how a WebAssembly component can call an external API, via the `http_client` import exposed by the host (cli or web). 6 | 7 | ## Notes 8 | 9 | This crate was initialized with `cargo component new`. 10 | 11 | The building process is handled by the [`justfile`](../../justfile) in the root of the project. 12 | 13 | The `cargo component build` command is used to build the plugin. 14 | 15 | - It generates the `src/bindings.rs` file, based on the `package.metadata.component` section in the `Cargo.toml` file that describes where to find the component definition (wit files). 16 | - It then compiles the plugin to WebAssembly. 17 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/README.md: -------------------------------------------------------------------------------- 1 | # Preview2 Shim 2 | 3 | WASI Preview2 implementations for Node.js & browsers. 4 | 5 | Node.js support is fully tested and conformant against the Wasmtime test suite. 6 | 7 | Browser support is considered experimental, and not currently suitable for production applications. 8 | 9 | # License 10 | 11 | This project is licensed under the Apache 2.0 license with the LLVM exception. 12 | See [LICENSE](LICENSE) for more details. 13 | 14 | ### Contribution 15 | 16 | Unless you explicitly state otherwise, any contribution intentionally submitted 17 | for inclusion in this project by you, as defined in the Apache-2.0 license, 18 | shall be licensed as above, without any additional terms or conditions. 19 | -------------------------------------------------------------------------------- /packages/web-host/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2023", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "verbatimModuleSyntax": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "erasableSyntaxOnly": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "noUncheckedSideEffectImports": true 23 | }, 24 | "include": ["vite.config.ts"] 25 | } 26 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "[typescript]": { 4 | "editor.defaultFormatter": "biomejs.biome" 5 | }, 6 | "[javascript]": { 7 | "editor.defaultFormatter": "biomejs.biome" 8 | }, 9 | "[javascriptreact]": { 10 | "editor.defaultFormatter": "biomejs.biome" 11 | }, 12 | "[typescriptreact]": { 13 | "editor.defaultFormatter": "biomejs.biome" 14 | }, 15 | "[json]": { 16 | "editor.defaultFormatter": "biomejs.biome" 17 | }, 18 | "[jsonc]": { 19 | "editor.defaultFormatter": "biomejs.biome" 20 | }, 21 | "[css]": { 22 | "editor.defaultFormatter": "biomejs.biome" 23 | }, 24 | "[scss]": { 25 | "editor.defaultFormatter": "biomejs.biome" 26 | }, 27 | "[html]": { 28 | "editor.defaultFormatter": "biomejs.biome" 29 | }, 30 | } 31 | -------------------------------------------------------------------------------- /fixtures/README.md: -------------------------------------------------------------------------------- 1 | # fixtures 2 | 3 | This directory contains the fixtures for the tests. 4 | 5 | ## filesystem 6 | 7 | The `fixtures/filesystem` directory contains files and directories that are meant to be used as source for: 8 | 9 | - e2e testing the pluginlab crate - see [filesystem/README.rust.md](./filesystem/README.rust.md) 10 | - mounting a virtual filesystem in the browser - see [filesystem/README.browser.md](./filesystem/README.browser.md) 11 | 12 | ### e2e testing 13 | 14 | The `fixtures/filesystem` is copied to `tmp/filesystem` before running the tests. 15 | 16 | The tests are run with the `--dir tmp/filesystem` argument. 17 | 18 | That way, the original `fixtures/filesystem` directory is not modified by the tests. 19 | 20 | ### mounting a virtual filesystem in the browser 21 | 22 | TODO 23 | -------------------------------------------------------------------------------- /packages/web-host/src/types/generated/interfaces/repl-api-transport.d.ts: -------------------------------------------------------------------------------- 1 | /** @module Interface repl:api/transport **/ 2 | /** 3 | * # Variants 4 | * 5 | * ## `"success"` 6 | * 7 | * ## `"error"` 8 | */ 9 | export type ReplStatus = "success" | "error"; 10 | export interface PluginResponse { 11 | status: ReplStatus; 12 | stdout?: string; 13 | stderr?: string; 14 | } 15 | export interface ParsedLine { 16 | command: string; 17 | payload: string; 18 | } 19 | export type ReadlineResponse = ReadlineResponseToRun | ReadlineResponseReady; 20 | export interface ReadlineResponseToRun { 21 | tag: "to-run"; 22 | val: ParsedLine; 23 | } 24 | export interface ReadlineResponseReady { 25 | tag: "ready"; 26 | val: PluginResponse; 27 | } 28 | export interface ReplVar { 29 | key: string; 30 | value: string; 31 | } 32 | -------------------------------------------------------------------------------- /packages/web-host/src/wasm/host/host-state.ts: -------------------------------------------------------------------------------- 1 | import type { ReplVar } from "../../types/generated/interfaces/repl-api-host-state"; 2 | 3 | const internalState = { 4 | replVars: new Map(), 5 | pluginsNames: new Set(), 6 | }; 7 | 8 | export function _setPluginsNames(pluginsNames: string[]) { 9 | internalState.pluginsNames = new Set(pluginsNames); 10 | } 11 | 12 | export function getPluginsNames(): string[] { 13 | return Array.from(internalState.pluginsNames); 14 | } 15 | 16 | export function getReplVars(): ReplVar[] { 17 | return Array.from(internalState.replVars.entries()).map(([name, value]) => ({ 18 | key: name, 19 | value, 20 | })); 21 | } 22 | 23 | export function setReplVar({ key, value }: { key: string; value: string }) { 24 | internalState.replVars.set(key, value); 25 | } 26 | -------------------------------------------------------------------------------- /crates/pluginlab/build.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::fs; 3 | use std::path::Path; 4 | use wasmtime; 5 | 6 | fn main() { 7 | let out_dir = env::var("OUT_DIR").unwrap(); 8 | println!("cargo:rerun-if-changed=./wit"); 9 | 10 | // Generate host-api bindings 11 | let bindings = wasmtime::component::bindgen!({ 12 | path: "./wit", 13 | world: "host-api", 14 | async: true, 15 | stringify: true, 16 | }); 17 | fs::write(Path::new(&out_dir).join("host_api.rs"), bindings).unwrap(); 18 | 19 | // Generate plugin-api bindings 20 | let bindings = wasmtime::component::bindgen!({ 21 | path: "./wit", 22 | world: "plugin-api", 23 | async: true, 24 | stringify: true, 25 | }); 26 | fs::write(Path::new(&out_dir).join("plugin_api.rs"), bindings).unwrap(); 27 | } 28 | -------------------------------------------------------------------------------- /packages/web-host/tests/repl-ui.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@playwright/test"; 2 | import { fillAndSubmitCommand, getLastStd } from "./utils"; 3 | 4 | test("echo foo - enter", async ({ page }) => { 5 | await page.goto("/#repl"); 6 | await fillAndSubmitCommand(page, "echo foo", { expectStdout: "foo" }); 7 | }); 8 | 9 | test("echo foo - run", async ({ page }) => { 10 | await page.goto("/#repl"); 11 | const input = await page.getByPlaceholder("Type a command..."); 12 | await input.fill("echo foo"); 13 | await page.getByRole("button", { name: "Run", exact: true }).click(); 14 | const stdin = await getLastStd(page, "stdin"); 15 | await expect(stdin).toHaveText("echo foo"); 16 | const stdout = await getLastStd(page, "stdout"); 17 | await expect(stdout).toHaveText("foo"); 18 | }); 19 | 20 | // todo: test wand button 21 | -------------------------------------------------------------------------------- /crates/repl-logic-guest/README.md: -------------------------------------------------------------------------------- 1 | # repl-logic-guest 2 | 3 | This crate contains the logic of the REPL: 4 | 5 | - Parses user input into commands and payloads 6 | - Expands environment variables in command arguments (e.g., `$HOME` → `/home/user`) 7 | - Manages reserved commands that cannot be overridden by plugins: 8 | - `export =` - Sets environment variables 9 | - `help ` - Shows command documentation 10 | - `list-commands` - Lists available plugins and reserved commands 11 | - Provides manual pages for reserved commands via the `man` command 12 | - Routes non-reserved commands to plugins for execution from the host (cli or web) 13 | 14 | ## Notes 15 | 16 | This crate was initialized with `cargo component new`. 17 | 18 | The building process is handled by the [`justfile`](../../justfile) in the root of the project. 19 | -------------------------------------------------------------------------------- /go_modules/plugin-echo/go.mod: -------------------------------------------------------------------------------- 1 | module webassembly-repl/plugin-echo 2 | 3 | go 1.24 4 | 5 | tool go.bytecodealliance.org/cmd/wit-bindgen-go 6 | 7 | require ( 8 | github.com/coreos/go-semver v0.3.1 // indirect 9 | github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect 10 | github.com/klauspost/compress v1.18.0 // indirect 11 | github.com/opencontainers/go-digest v1.0.0 // indirect 12 | github.com/regclient/regclient v0.8.3 // indirect 13 | github.com/sirupsen/logrus v1.9.3 // indirect 14 | github.com/tetratelabs/wazero v1.9.0 // indirect 15 | github.com/ulikunitz/xz v0.5.12 // indirect 16 | github.com/urfave/cli/v3 v3.3.3 // indirect 17 | go.bytecodealliance.org v0.7.0 // indirect 18 | go.bytecodealliance.org/cm v0.3.0 // indirect 19 | golang.org/x/mod v0.24.0 // indirect 20 | golang.org/x/sys v0.33.0 // indirect 21 | ) 22 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/interfaces/wasi-random-insecure.d.ts: -------------------------------------------------------------------------------- 1 | /** @module Interface wasi:random/insecure@0.2.3 **/ 2 | /** 3 | * Return `len` insecure pseudo-random bytes. 4 | * 5 | * This function is not cryptographically secure. Do not use it for 6 | * anything related to security. 7 | * 8 | * There are no requirements on the values of the returned bytes, however 9 | * implementations are encouraged to return evenly distributed values with 10 | * a long period. 11 | */ 12 | export function getInsecureRandomBytes(len: bigint): Uint8Array; 13 | /** 14 | * Return an insecure pseudo-random `u64` value. 15 | * 16 | * This function returns the same type of pseudo-random data as 17 | * `get-insecure-random-bytes`, represented as a `u64`. 18 | */ 19 | export function getInsecureRandomU64(): bigint; 20 | -------------------------------------------------------------------------------- /packages/web-host/tests/repl-loading.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@playwright/test"; 2 | 3 | test("wasm should load", async ({ page }) => { 4 | await page.goto("/#repl"); 5 | await expect(page.getByText("[Host] Starting REPL host...")).toBeVisible(); 6 | }); 7 | 8 | test("repl logic should have loaded", async ({ page }) => { 9 | await page.goto("/#repl"); 10 | await expect(page.getByText("[Host] Loaded REPL logic")).toBeVisible(); 11 | }); 12 | 13 | test("plugins should have loaded under their names", async ({ page }) => { 14 | const pluginNames = ["echo", "weather", "greet", "ls", "cat", "echoc", "tee"]; 15 | await page.goto("/#repl"); 16 | for (const pluginName of pluginNames) { 17 | await expect( 18 | page.getByText(`[Host] Loaded plugin: ${pluginName}`, { exact: true }), 19 | ).toBeVisible(); 20 | } 21 | }); 22 | -------------------------------------------------------------------------------- /crates/plugin-weather/src/api.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use serde_json; 3 | use std::collections::HashMap; 4 | 5 | #[derive(Deserialize)] 6 | struct CurrentCondition { 7 | #[serde(rename = "weatherDesc")] 8 | weather_desc: Vec>, 9 | } 10 | 11 | #[derive(Deserialize)] 12 | struct WeatherResponse { 13 | current_condition: Vec, 14 | } 15 | 16 | impl WeatherResponse { 17 | fn get_weather_desc(&self) -> String { 18 | self.current_condition[0].weather_desc[0] 19 | .get("value") 20 | .unwrap() 21 | .clone() 22 | } 23 | } 24 | 25 | pub fn get_weather_from_body(body: &str) -> Result { 26 | match serde_json::from_str::(body) { 27 | Ok(response) => Ok(response.get_weather_desc()), 28 | Err(e) => Err(e.to_string()), 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/cli.d.ts: -------------------------------------------------------------------------------- 1 | export type * as environment from './interfaces/wasi-cli-environment.d.ts'; 2 | export type * as exit from './interfaces/wasi-cli-exit.d.ts'; 3 | export type * as run from './interfaces/wasi-cli-run.d.ts'; 4 | export type * as stderr from './interfaces/wasi-cli-stderr.d.ts'; 5 | export type * as stdin from './interfaces/wasi-cli-stdin.d.ts'; 6 | export type * as stdout from './interfaces/wasi-cli-stdout.d.ts'; 7 | export type * as terminalInput from './interfaces/wasi-cli-terminal-input.d.ts'; 8 | export type * as terminalOutput from './interfaces/wasi-cli-terminal-output.d.ts'; 9 | export type * as terminalStderr from './interfaces/wasi-cli-terminal-stderr.d.ts'; 10 | export type * as terminalStdin from './interfaces/wasi-cli-terminal-stdin.d.ts'; 11 | export type * as terminalStdout from './interfaces/wasi-cli-terminal-stdout.d.ts'; 12 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/interfaces/wasi-cli-environment.d.ts: -------------------------------------------------------------------------------- 1 | /** @module Interface wasi:cli/environment@0.2.3 **/ 2 | /** 3 | * Get the POSIX-style environment variables. 4 | * 5 | * Each environment variable is provided as a pair of string variable names 6 | * and string value. 7 | * 8 | * Morally, these are a value import, but until value imports are available 9 | * in the component model, this import function should return the same 10 | * values each time it is called. 11 | */ 12 | export function getEnvironment(): Array<[string, string]>; 13 | /** 14 | * Get the POSIX-style arguments to the program. 15 | */ 16 | export function getArguments(): Array; 17 | /** 18 | * Return a path that programs should use as their initial current working 19 | * directory, interpreting `.` as shorthand for this. 20 | */ 21 | export function initialCwd(): string | undefined; 22 | -------------------------------------------------------------------------------- /crates/plugin-echo/src/lib.rs: -------------------------------------------------------------------------------- 1 | #[allow(warnings)] 2 | mod bindings; 3 | 4 | use crate::bindings::exports::repl::api::plugin::Guest; 5 | use crate::bindings::repl::api::transport; 6 | 7 | struct Component; 8 | 9 | impl Guest for Component { 10 | fn name() -> String { 11 | "echo".to_string() 12 | } 13 | 14 | fn man() -> String { 15 | r#" 16 | NAME 17 | echo - Echo a message (built with Rust🦀) 18 | 19 | USAGE 20 | echo 21 | 22 | DESCRIPTION 23 | Echo a message. 24 | 25 | "# 26 | .to_string() 27 | } 28 | 29 | fn run(payload: String) -> Result { 30 | Ok(transport::PluginResponse { 31 | status: transport::ReplStatus::Success, 32 | stdout: Some(format!("{}", payload)), 33 | stderr: None, 34 | }) 35 | } 36 | } 37 | 38 | bindings::export!(Component with_types_in bindings); 39 | -------------------------------------------------------------------------------- /fixtures/filesystem/README.browser.md: -------------------------------------------------------------------------------- 1 | # filesystem 2 | 3 | You are interacting with a virtual filesystem, in your browser! 4 | 5 | The plugins `ls` and `cat` do real filesystem operations in their source code, like `std::fs::read_dir` or `std::fs::read_to_string`. 6 | 7 | Those operations are forwarded via the `@bytecodealliance/preview2-shim/filesystem` shim, which shims the `wasi:filesystem` filesystem interface. 8 | 9 | On the cli, you would interact with a real filesystem with the exact same source code as the one you are running (which was developed mainly in rust and compiled to WebAssembly, for the browser, using `jco`). 10 | 11 | The virtual filesystem was generated by the `prepareVirtualFs` command, which reads the `fixtures/filesystem` directory and generates a virtual filesystem in the [`packages/web-host/src/wasm/virtualFs.ts`](https://github.com/topheman/webassembly-component-model-experiments/blob/master/packages/web-host/src/wasm/virtualFs.ts) file. 12 | -------------------------------------------------------------------------------- /crates/plugin-greet/src/lib.rs: -------------------------------------------------------------------------------- 1 | #[allow(warnings)] 2 | mod bindings; 3 | 4 | use crate::bindings::exports::repl::api::plugin::Guest; 5 | use crate::bindings::repl::api::transport; 6 | 7 | struct Component; 8 | 9 | impl Guest for Component { 10 | fn name() -> String { 11 | "greet".to_string() 12 | } 13 | 14 | fn man() -> String { 15 | r#" 16 | NAME 17 | greet - Greet the user (built with Rust🦀) 18 | 19 | SYNOPSIS 20 | greet 21 | 22 | DESCRIPTION 23 | Greet the user with the given name. 24 | 25 | EXAMPLES 26 | > greet Tophe 27 | Hello, Tophe! 28 | 29 | "# 30 | .to_string() 31 | } 32 | 33 | fn run(payload: String) -> Result { 34 | Ok(transport::PluginResponse { 35 | status: transport::ReplStatus::Success, 36 | stdout: Some(format!("Hello, {}!", payload)), 37 | stderr: None, 38 | }) 39 | } 40 | } 41 | 42 | bindings::export!(Component with_types_in bindings); 43 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/interfaces/wasi-http-incoming-handler.d.ts: -------------------------------------------------------------------------------- 1 | /** @module Interface wasi:http/incoming-handler@0.2.3 **/ 2 | /** 3 | * This function is invoked with an incoming HTTP Request, and a resource 4 | * `response-outparam` which provides the capability to reply with an HTTP 5 | * Response. The response is sent by calling the `response-outparam.set` 6 | * method, which allows execution to continue after the response has been 7 | * sent. This enables both streaming to the response body, and performing other 8 | * work. 9 | * 10 | * The implementor of this function must write a response to the 11 | * `response-outparam` before returning, or else the caller will respond 12 | * with an error on its behalf. 13 | */ 14 | export function handle(request: IncomingRequest, responseOut: ResponseOutparam): void; 15 | export type IncomingRequest = import('./wasi-http-types.js').IncomingRequest; 16 | export type ResponseOutparam = import('./wasi-http-types.js').ResponseOutparam; 17 | -------------------------------------------------------------------------------- /crates/pluginlab/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "pluginlab" 3 | version = "0.6.0" 4 | edition = "2021" 5 | publish = true 6 | description = "Command-line interface host for Terminal REPL with plugin system (using WebAssembly Component Model)" 7 | keywords = [ 8 | "WebAssembly", 9 | "component-model", 10 | "wasmtime", 11 | "wit-bindgen", 12 | "plugin-system", 13 | ] 14 | categories = ["command-line-interface", "wasm"] 15 | homepage = "https://github.com/topheman/webassembly-component-model-experiments#readme" 16 | repository = "https://github.com/topheman/webassembly-component-model-experiments.git" 17 | license = "MIT" 18 | authors = ["Christophe Rosset "] 19 | 20 | [dependencies] 21 | wasmtime = { workspace = true } 22 | wasmtime-wasi = { workspace = true } 23 | clap = { workspace = true } 24 | tokio = { workspace = true } 25 | anyhow = { workspace = true } 26 | reqwest = "0.12.20" 27 | clap_complete = "4.5" 28 | 29 | [build-dependencies] 30 | wasmtime = { workspace = true } 31 | 32 | [dev-dependencies] 33 | rexpect = "0.5" 34 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/interfaces/wasi-random-insecure-seed.d.ts: -------------------------------------------------------------------------------- 1 | /** @module Interface wasi:random/insecure-seed@0.2.3 **/ 2 | /** 3 | * Return a 128-bit value that may contain a pseudo-random value. 4 | * 5 | * The returned value is not required to be computed from a CSPRNG, and may 6 | * even be entirely deterministic. Host implementations are encouraged to 7 | * provide pseudo-random values to any program exposed to 8 | * attacker-controlled content, to enable DoS protection built into many 9 | * languages' hash-map implementations. 10 | * 11 | * This function is intended to only be called once, by a source language 12 | * to initialize Denial Of Service (DoS) protection in its hash-map 13 | * implementation. 14 | * 15 | * # Expected future evolution 16 | * 17 | * This will likely be changed to a value import, to prevent it from being 18 | * called multiple times and potentially used for purposes other than DoS 19 | * protection. 20 | */ 21 | export function insecureSeed(): [bigint, bigint]; 22 | -------------------------------------------------------------------------------- /crates/pluginlab/tests/utils.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | pub fn find_project_root() -> PathBuf { 4 | let mut current = std::env::current_dir().unwrap(); 5 | println!("Starting search from: {:?}", current); 6 | 7 | // Walk up the directory tree looking for the workspace root Cargo.toml 8 | loop { 9 | let cargo_toml = current.join("Cargo.toml"); 10 | if cargo_toml.exists() { 11 | // Check if this is the workspace root by looking for [workspace] section 12 | if let Ok(content) = std::fs::read_to_string(&cargo_toml) { 13 | if content.contains("[workspace]") { 14 | println!("Found workspace root at: {:?}", current); 15 | return current; 16 | } 17 | } 18 | } 19 | 20 | if !current.pop() { 21 | // current.pop() moves up one directory in the path. If we're already at the root, it returns false. 22 | panic!("Could not find workspace root (Cargo.toml with [workspace])"); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2025 Christophe Rosset 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /go_modules/plugin-echo/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "webassembly-repl/plugin-echo/internal/repl/api/plugin" 5 | "webassembly-repl/plugin-echo/internal/repl/api/transport" 6 | 7 | "go.bytecodealliance.org/cm" 8 | ) 9 | 10 | func init() { 11 | // Export the plugin name function 12 | plugin.Exports.Name = func() string { 13 | return "echogo" 14 | } 15 | 16 | // Export the manual function 17 | plugin.Exports.Man = func() string { 18 | return `NAME 19 | echogo - Echo a message (built with Go) 20 | 21 | USAGE 22 | echogo 23 | 24 | DESCRIPTION 25 | Echo a message.` 26 | } 27 | 28 | // Export the run function 29 | plugin.Exports.Run = func(payload string) cm.Result[plugin.PluginResponse, plugin.PluginResponse, struct{}] { 30 | response := plugin.PluginResponse{ 31 | Status: transport.ReplStatusSuccess, 32 | Stdout: cm.Some(payload), 33 | Stderr: cm.None[string](), 34 | } 35 | return cm.OK[cm.Result[plugin.PluginResponse, plugin.PluginResponse, struct{}]](response) 36 | } 37 | } 38 | 39 | // main is required for the wasip2 target 40 | func main() {} 41 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/interfaces/wasi-http-outgoing-handler.d.ts: -------------------------------------------------------------------------------- 1 | /** @module Interface wasi:http/outgoing-handler@0.2.3 **/ 2 | /** 3 | * This function is invoked with an outgoing HTTP Request, and it returns 4 | * a resource `future-incoming-response` which represents an HTTP Response 5 | * which may arrive in the future. 6 | * 7 | * The `options` argument accepts optional parameters for the HTTP 8 | * protocol's transport layer. 9 | * 10 | * This function may return an error if the `outgoing-request` is invalid 11 | * or not allowed to be made. Otherwise, protocol errors are reported 12 | * through the `future-incoming-response`. 13 | */ 14 | export function handle(request: OutgoingRequest, options: RequestOptions | undefined): FutureIncomingResponse; 15 | export type OutgoingRequest = import('./wasi-http-types.js').OutgoingRequest; 16 | export type RequestOptions = import('./wasi-http-types.js').RequestOptions; 17 | export type FutureIncomingResponse = import('./wasi-http-types.js').FutureIncomingResponse; 18 | export type ErrorCode = import('./wasi-http-types.js').ErrorCode; 19 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/interfaces/wasi-random-random.d.ts: -------------------------------------------------------------------------------- 1 | /** @module Interface wasi:random/random@0.2.3 **/ 2 | /** 3 | * Return `len` cryptographically-secure random or pseudo-random bytes. 4 | * 5 | * This function must produce data at least as cryptographically secure and 6 | * fast as an adequately seeded cryptographically-secure pseudo-random 7 | * number generator (CSPRNG). It must not block, from the perspective of 8 | * the calling program, under any circumstances, including on the first 9 | * request and on requests for numbers of bytes. The returned data must 10 | * always be unpredictable. 11 | * 12 | * This function must always return fresh data. Deterministic environments 13 | * must omit this function, rather than implementing it with deterministic 14 | * data. 15 | */ 16 | export function getRandomBytes(len: bigint): Uint8Array; 17 | /** 18 | * Return a cryptographically-secure random or pseudo-random `u64` value. 19 | * 20 | * This function returns the same type of data as `get-random-bytes`, 21 | * represented as a `u64`. 22 | */ 23 | export function getRandomU64(): bigint; 24 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/lib/browser/clocks.js: -------------------------------------------------------------------------------- 1 | export const monotonicClock = { 2 | resolution() { 3 | // usually we dont get sub-millisecond accuracy in the browser 4 | // Note: is there a better way to determine this? 5 | return 1e6; 6 | }, 7 | now () { 8 | // performance.now() is in milliseconds, but we want nanoseconds 9 | return BigInt(Math.floor(performance.now() * 1e6)); 10 | }, 11 | subscribeInstant (instant) { 12 | instant = BigInt(instant); 13 | const now = this.now(); 14 | if (instant <= now) 15 | return this.subscribeDuration(0); 16 | return this.subscribeDuration(instant - now); 17 | }, 18 | subscribeDuration (_duration) { 19 | _duration = BigInt(_duration); 20 | console.log(`[monotonic-clock] subscribe`); 21 | } 22 | }; 23 | 24 | export const wallClock = { 25 | now() { 26 | let now = Date.now(); // in milliseconds 27 | const seconds = BigInt(Math.floor(now / 1e3)); 28 | const nanoseconds = (now % 1e3) * 1e6; 29 | return { seconds, nanoseconds }; 30 | }, 31 | resolution() { 32 | return { seconds: 0n, nanoseconds: 1e6 }; 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /packages/web-host/src/hooks/replHistory.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | import type { ReplHistoryEntry } from "../types"; 4 | 5 | const MAX_HISTORY_LENGTH = 200; 6 | 7 | /** 8 | * Handles the state of the repl - the history of commands and their results. 9 | * @param state 10 | * @param payload 11 | * @returns 12 | */ 13 | function replStateReducer( 14 | state: Array, 15 | payload: ReplHistoryEntry, 16 | ) { 17 | if (state.length >= MAX_HISTORY_LENGTH) { 18 | // remove the oldest entry 19 | return [...state.slice(1), payload]; 20 | } 21 | return [...state, payload]; 22 | } 23 | 24 | const useInnerReplHistory = create<{ 25 | history: ReplHistoryEntry[]; 26 | addEntry: (entry: ReplHistoryEntry) => void; 27 | }>((set) => ({ 28 | history: [], 29 | addEntry: (entry: ReplHistoryEntry) => { 30 | set((state: { history: ReplHistoryEntry[] }) => ({ 31 | history: replStateReducer(state.history, entry), 32 | })); 33 | }, 34 | })); 35 | 36 | export function useReplHistory() { 37 | const { addEntry, history } = useInnerReplHistory((state) => state); 38 | return { 39 | addEntry, 40 | history, 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /packages/plugin-echo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "plugin-echo", 3 | "version": "0.1.0", 4 | "description": "Example echo plugin for REPL based on WebAssembly Component Model - demonstrates basic plugin API implementation in TypeScript", 5 | "license": "ISC", 6 | "author": "Christophe Rosset", 7 | "type": "module", 8 | "scripts": { 9 | "wit-types": "rm -rf ./src/types && jco types --world-name plugin-api --out-dir ./src/types ../../crates/pluginlab/wit", 10 | "bundle": "rolldown ./src/component.ts --file ./dist/component.js", 11 | "componentize": "jco componentize ./dist/component.js --wit ../../crates/pluginlab/wit --world-name plugin-api --out ./dist/component.wasm --disable http --disable random", 12 | "build": "npm run wit-types && npm run bundle && npm run componentize", 13 | "optimize": "jco opt ./dist/component.wasm -o ./dist/component-opt.wasm", 14 | "typecheck": "tsc --noEmit ./src/component.ts" 15 | }, 16 | "dependencies": { 17 | "@bytecodealliance/componentize-js": "^0.18.2", 18 | "@bytecodealliance/jco": "^1.11.2", 19 | "rolldown": "^1.0.0-beta.19" 20 | }, 21 | "devDependencies": { 22 | "typescript": "^5.8.3" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /crates/pluginlab/wit/plugin-api.wit: -------------------------------------------------------------------------------- 1 | interface plugin { 2 | use transport.{plugin-response}; 3 | 4 | name: func() -> string; 5 | man: func() -> string; 6 | run: func(payload: string) -> result; 7 | } 8 | 9 | /// Provided by the host, accessible by plugins 10 | interface http-client { 11 | record http-header { 12 | name: string, 13 | value: string, 14 | } 15 | 16 | record http-response { 17 | status: u16, 18 | ok: bool, 19 | headers: list, 20 | body: string, 21 | } 22 | 23 | get: func(url: string, headers: list) -> result; 24 | } 25 | 26 | interface host-state-plugin { 27 | get-repl-var: func(key: string) -> option; 28 | } 29 | 30 | world plugin-api { 31 | // The wasip2 target of TinyGo assumes that the component is targeting wasi:cli/command@0.2.0 world (part of wasi:cli), 32 | // so it needs to include the imports from that world. 33 | // It's only included for the versions of the wit files destined for TinyGo. 34 | // include wasi:cli/imports@0.2.0; // SPECIFIC TinyGo - DO NOT CHANGE THIS LINE 35 | import http-client; 36 | import host-state-plugin; 37 | export plugin; 38 | } 39 | -------------------------------------------------------------------------------- /packages/web-host/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import tailwindcss from "@tailwindcss/vite"; 3 | import react from "@vitejs/plugin-react"; 4 | import { defineConfig } from "vite"; 5 | 6 | // https://vite.dev/config/ 7 | export default defineConfig({ 8 | plugins: [react(), tailwindcss()], 9 | resolve: { 10 | alias: { 11 | "repl:api/http-client": path.resolve( 12 | __dirname, 13 | "./src/wasm/host/http-client.ts", 14 | ), 15 | "repl:api/host-state": path.resolve( 16 | __dirname, 17 | "./src/wasm/host/host-state.ts", 18 | ), 19 | "repl:api/host-state-plugin": path.resolve( 20 | __dirname, 21 | "./src/wasm/host/host-state-plugin.ts", 22 | ), 23 | "@bytecodealliance/preview2-shim": path.resolve( 24 | __dirname, 25 | "./overrides/@bytecodealliance/preview2-shim/lib/browser", 26 | ), 27 | }, 28 | }, 29 | base: "/webassembly-component-model-experiments/", 30 | build: { 31 | rollupOptions: { 32 | input: { 33 | main: path.resolve(__dirname, "index.html"), 34 | article: path.resolve(__dirname, "article.html"), 35 | }, 36 | }, 37 | }, 38 | }); 39 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/interfaces/wasi-clocks-wall-clock.d.ts: -------------------------------------------------------------------------------- 1 | /** @module Interface wasi:clocks/wall-clock@0.2.3 **/ 2 | /** 3 | * Read the current value of the clock. 4 | * 5 | * This clock is not monotonic, therefore calling this function repeatedly 6 | * will not necessarily produce a sequence of non-decreasing values. 7 | * 8 | * The returned timestamps represent the number of seconds since 9 | * 1970-01-01T00:00:00Z, also known as [POSIX's Seconds Since the Epoch], 10 | * also known as [Unix Time]. 11 | * 12 | * The nanoseconds field of the output is always less than 1000000000. 13 | * 14 | * [POSIX's Seconds Since the Epoch]: https://pubs.opengroup.org/onlinepubs/9699919799/xrat/V4_xbd_chap04.html#tag_21_04_16 15 | * [Unix Time]: https://en.wikipedia.org/wiki/Unix_time 16 | */ 17 | export function now(): Datetime; 18 | /** 19 | * Query the resolution of the clock. 20 | * 21 | * The nanoseconds field of the output is always less than 1000000000. 22 | */ 23 | export function resolution(): Datetime; 24 | /** 25 | * A time and date in seconds plus nanoseconds. 26 | */ 27 | export interface Datetime { 28 | seconds: bigint, 29 | nanoseconds: number, 30 | } 31 | -------------------------------------------------------------------------------- /packages/web-host/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2022", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "verbatimModuleSyntax": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "erasableSyntaxOnly": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "noUncheckedSideEffectImports": true, 25 | "paths": { 26 | "repl:api/host-state": ["./src/wasm/host/host-state.ts"], 27 | "repl:api/http-client": ["./src/wasm/host/http-client.ts"], 28 | "repl:api/host-state-plugin": ["./src/wasm/host/host-state-plugin.ts"], 29 | "@bytecodealliance/preview2-shim": [ 30 | "./overrides/@bytecodealliance/preview2-shim" 31 | ] 32 | } 33 | }, 34 | "include": ["src", "clis", "overrides"] 35 | } 36 | -------------------------------------------------------------------------------- /crates/pluginlab/src/permissions.rs: -------------------------------------------------------------------------------- 1 | use crate::cli::Cli; 2 | 3 | pub enum NetworkPermissions { 4 | None, 5 | All, 6 | Custom(Vec), 7 | } 8 | 9 | impl From<&Cli> for NetworkPermissions { 10 | fn from(cli: &Cli) -> Self { 11 | if cli.allow_net.is_some() { 12 | if cli.allow_net.as_ref().unwrap() == "@" { 13 | return NetworkPermissions::All; 14 | } else { 15 | let domains: Vec = cli 16 | .allow_net 17 | .as_ref() 18 | .unwrap() 19 | .split(",") 20 | .map(|d| d.to_string()) 21 | .collect(); 22 | return NetworkPermissions::Custom(domains); 23 | } 24 | } 25 | if cli.allow_all { 26 | return NetworkPermissions::All; 27 | } 28 | return NetworkPermissions::None; 29 | } 30 | } 31 | 32 | impl NetworkPermissions { 33 | pub fn is_allowed(&self, domain: &str) -> bool { 34 | match self { 35 | NetworkPermissions::None => false, 36 | NetworkPermissions::All => true, 37 | NetworkPermissions::Custom(domains) => domains.contains(&domain.to_string()), 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bytecodealliance/preview2-shim", 3 | "version": "0.17.2", 4 | "description": "WASI Preview2 shim for JS environments", 5 | "author": "Guy Bedford, Eduardo Rodrigues<16357187+eduardomourar@users.noreply.github.com>", 6 | "type": "module", 7 | "types": "./types/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "types": "./types/index.d.ts", 11 | "node": "./lib/nodejs/index.js", 12 | "default": "./lib/browser/index.js" 13 | }, 14 | "./*": { 15 | "types": "./types/*.d.ts", 16 | "node": "./lib/nodejs/*.js", 17 | "default": "./lib/browser/*.js" 18 | } 19 | }, 20 | "scripts": { 21 | "test": "node --expose-gc ../../node_modules/mocha/bin/mocha.js -u tdd test/test.js --timeout 30000" 22 | }, 23 | "files": [ 24 | "types", 25 | "lib" 26 | ], 27 | "devDependencies": { 28 | "mocha": "^10.2.0" 29 | }, 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/bytecodealliance/jco.git" 33 | }, 34 | "license": "(Apache-2.0 WITH LLVM-exception)", 35 | "bugs": { 36 | "url": "https://github.com/bytecodealliance/jco/issues" 37 | }, 38 | "homepage": "https://github.com/bytecodealliance/jco#readme" 39 | } 40 | -------------------------------------------------------------------------------- /go_modules/wit/plugin-api.wit: -------------------------------------------------------------------------------- 1 | // Code generated by `prepare-wit-files.sh`, from `crates/pluginlab/wit/*.wit`. DO NOT EDIT! 2 | 3 | interface plugin { 4 | use transport.{plugin-response}; 5 | 6 | name: func() -> string; 7 | man: func() -> string; 8 | run: func(payload: string) -> result; 9 | } 10 | 11 | /// Provided by the host, accessible by plugins 12 | interface http-client { 13 | record http-header { 14 | name: string, 15 | value: string, 16 | } 17 | 18 | record http-response { 19 | status: u16, 20 | ok: bool, 21 | headers: list, 22 | body: string, 23 | } 24 | 25 | get: func(url: string, headers: list) -> result; 26 | } 27 | 28 | interface host-state-plugin { 29 | get-repl-var: func(key: string) -> option; 30 | } 31 | 32 | world plugin-api { 33 | // The wasip2 target of TinyGo assumes that the component is targeting wasi:cli/command@0.2.0 world (part of wasi:cli), 34 | // so it needs to include the imports from that world. 35 | // It's only included for the versions of the wit files destined for TinyGo. 36 | include wasi:cli/imports@0.2.0; // SPECIFIC TinyGo - DO NOT CHANGE THIS LINE 37 | import http-client; 38 | import host-state-plugin; 39 | export plugin; 40 | } 41 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/interfaces/wasi-clocks-monotonic-clock.d.ts: -------------------------------------------------------------------------------- 1 | /** @module Interface wasi:clocks/monotonic-clock@0.2.3 **/ 2 | /** 3 | * Read the current value of the clock. 4 | * 5 | * The clock is monotonic, therefore calling this function repeatedly will 6 | * produce a sequence of non-decreasing values. 7 | */ 8 | export function now(): Instant; 9 | /** 10 | * Query the resolution of the clock. Returns the duration of time 11 | * corresponding to a clock tick. 12 | */ 13 | export function resolution(): Duration; 14 | /** 15 | * Create a `pollable` which will resolve once the specified instant 16 | * has occurred. 17 | */ 18 | export function subscribeInstant(when: Instant): Pollable; 19 | /** 20 | * Create a `pollable` that will resolve after the specified duration has 21 | * elapsed from the time this function is invoked. 22 | */ 23 | export function subscribeDuration(when: Duration): Pollable; 24 | export type Pollable = import('./wasi-io-poll.js').Pollable; 25 | /** 26 | * An instant in time, in nanoseconds. An instant is relative to an 27 | * unspecified initial value, and can only be compared to instances from 28 | * the same monotonic-clock. 29 | */ 30 | export type Instant = bigint; 31 | /** 32 | * A duration of time, in nanoseconds. 33 | */ 34 | export type Duration = bigint; 35 | -------------------------------------------------------------------------------- /packages/web-host/src/utils/github.ts: -------------------------------------------------------------------------------- 1 | const pluginSourceUrlMapping = { 2 | echo: "https://github.com/topheman/webassembly-component-model-experiments/tree/master/crates/plugin-echo", 3 | weather: 4 | "https://github.com/topheman/webassembly-component-model-experiments/tree/master/crates/plugin-weather", 5 | greet: 6 | "https://github.com/topheman/webassembly-component-model-experiments/tree/master/crates/plugin-greet", 7 | ls: "https://github.com/topheman/webassembly-component-model-experiments/tree/master/crates/plugin-ls", 8 | cat: "https://github.com/topheman/webassembly-component-model-experiments/tree/master/crates/plugin-cat", 9 | echoc: 10 | "https://github.com/topheman/webassembly-component-model-experiments/blob/master/c_modules/plugin-echo/component.c", 11 | echogo: 12 | "https://github.com/topheman/webassembly-component-model-experiments/blob/master/go_modules/plugin-echo/main.go", 13 | tee: "https://github.com/topheman/webassembly-component-model-experiments/tree/master/crates/plugin-tee", 14 | } as const; 15 | 16 | export function getPluginSourceUrl( 17 | pluginName: keyof typeof pluginSourceUrlMapping | (string & {}), 18 | ) { 19 | if (pluginName in pluginSourceUrlMapping) { 20 | return pluginSourceUrlMapping[ 21 | pluginName as keyof typeof pluginSourceUrlMapping 22 | ]; 23 | } 24 | return null; 25 | } 26 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = [ 4 | "crates/pluginlab", 5 | "crates/plugin-greet", 6 | "crates/plugin-echo", 7 | "crates/plugin-ls", 8 | "crates/plugin-cat", 9 | "crates/plugin-weather", 10 | "crates/plugin-tee", 11 | "crates/repl-logic-guest", 12 | ] 13 | 14 | [workspace.package] 15 | publish = false 16 | edition = "2024" 17 | description = "Terminal REPL with sandboxed multi-language plugin system - unified codebase runs in CLI (Rust) and web (TypeScript)" 18 | keywords = [ 19 | "WebAssembly", 20 | "component-model", 21 | "wasmtime", 22 | "wit-bindgen", 23 | "plugin-system", 24 | ] 25 | categories = ["command-line-interface", "wasm"] 26 | homepage = "https://github.com/topheman/webassembly-component-model-experiments#readme" 27 | repository = "https://github.com/topheman/webassembly-component-model-experiments.git" 28 | license = "MIT" 29 | authors = ["Christophe Rosset "] 30 | 31 | [workspace.dependencies] 32 | wasmtime = { version = "35", default-features = false, features = [ 33 | "async", 34 | "demangle", 35 | "runtime", 36 | "cranelift", 37 | "component-model", 38 | "incremental-cache", 39 | "parallel-compilation", 40 | ] } 41 | wasmtime-wasi = "35" 42 | clap = { version = "4.5", features = ["derive"] } 43 | tokio = { version = "1.47.1", features = ["full"] } 44 | anyhow = "1.0" 45 | wit-bindgen-rt = { version = "0.44.0", features = ["bitflags"] } 46 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/wasi-http-proxy.d.ts: -------------------------------------------------------------------------------- 1 | // world wasi:http/proxy@0.2.3 2 | export type * as WasiCliStderr023 from './interfaces/wasi-cli-stderr.js'; // import wasi:cli/stderr@0.2.3 3 | export type * as WasiCliStdin023 from './interfaces/wasi-cli-stdin.js'; // import wasi:cli/stdin@0.2.3 4 | export type * as WasiCliStdout023 from './interfaces/wasi-cli-stdout.js'; // import wasi:cli/stdout@0.2.3 5 | export type * as WasiClocksMonotonicClock023 from './interfaces/wasi-clocks-monotonic-clock.js'; // import wasi:clocks/monotonic-clock@0.2.3 6 | export type * as WasiClocksWallClock023 from './interfaces/wasi-clocks-wall-clock.js'; // import wasi:clocks/wall-clock@0.2.3 7 | export type * as WasiHttpOutgoingHandler023 from './interfaces/wasi-http-outgoing-handler.js'; // import wasi:http/outgoing-handler@0.2.3 8 | export type * as WasiHttpTypes023 from './interfaces/wasi-http-types.js'; // import wasi:http/types@0.2.3 9 | export type * as WasiIoError023 from './interfaces/wasi-io-error.js'; // import wasi:io/error@0.2.3 10 | export type * as WasiIoPoll023 from './interfaces/wasi-io-poll.js'; // import wasi:io/poll@0.2.3 11 | export type * as WasiIoStreams023 from './interfaces/wasi-io-streams.js'; // import wasi:io/streams@0.2.3 12 | export type * as WasiRandomRandom023 from './interfaces/wasi-random-random.js'; // import wasi:random/random@0.2.3 13 | export * as incomingHandler from './interfaces/wasi-http-incoming-handler.js'; // export wasi:http/incoming-handler@0.2.3 14 | -------------------------------------------------------------------------------- /packages/web-host/src/hooks/navigation.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | type Page = "home" | "repl"; 4 | 5 | export const useHashNavigation = () => { 6 | const [currentPage, setCurrentPage] = useState(() => { 7 | // Initialize based on URL hash 8 | const hash = window.location.hash.slice(1); 9 | return hash === "repl" ? "repl" : "home"; 10 | }); 11 | 12 | // Sync URL hash with current page 13 | useEffect(() => { 14 | const newHash = currentPage === "repl" ? "#repl" : "#home"; 15 | if (window.location.hash !== newHash) { 16 | window.history.replaceState(null, "", newHash); 17 | } 18 | }, [currentPage]); 19 | 20 | // Listen for hash changes (back/forward buttons) 21 | useEffect(() => { 22 | const handleHashChange = () => { 23 | const hash = window.location.hash.slice(1); 24 | const newPage: Page = hash === "repl" ? "repl" : "home"; 25 | setCurrentPage(newPage); 26 | }; 27 | 28 | window.addEventListener("hashchange", handleHashChange); 29 | return () => window.removeEventListener("hashchange", handleHashChange); 30 | }, []); 31 | 32 | const navigateToRepl = () => { 33 | setCurrentPage("repl"); 34 | window.history.pushState(null, "", "#repl"); 35 | }; 36 | 37 | const navigateToHome = () => { 38 | setCurrentPage("home"); 39 | window.history.pushState(null, "", "#home"); 40 | }; 41 | 42 | return { 43 | currentPage, 44 | navigateToRepl, 45 | navigateToHome, 46 | }; 47 | }; 48 | -------------------------------------------------------------------------------- /packages/web-host/src/components/ReplPage.tsx: -------------------------------------------------------------------------------- 1 | import { useWasm } from "../hooks/wasm"; 2 | import { Repl } from "./Repl"; 3 | 4 | interface ReplPageProps { 5 | onBackToHome: () => void; 6 | } 7 | 8 | export const ReplPage = ({ onBackToHome }: ReplPageProps) => { 9 | const wasm = useWasm(); 10 | 11 | return ( 12 |
13 |
14 |
15 |

16 | REPL Interface 17 |

18 | 25 |
26 |
27 | {wasm.status === "loading" &&
Loading...
} 28 | {wasm.status === "ready" && } 29 | {wasm.status === "error" && ( 30 |
An error occurred, please try again.
31 | )} 32 |
33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/interfaces/wasi-io-poll.d.ts: -------------------------------------------------------------------------------- 1 | /** @module Interface wasi:io/poll@0.2.3 **/ 2 | /** 3 | * Poll for completion on a set of pollables. 4 | * 5 | * This function takes a list of pollables, which identify I/O sources of 6 | * interest, and waits until one or more of the events is ready for I/O. 7 | * 8 | * The result `list` contains one or more indices of handles in the 9 | * argument list that is ready for I/O. 10 | * 11 | * This function traps if either: 12 | * - the list is empty, or: 13 | * - the list contains more elements than can be indexed with a `u32` value. 14 | * 15 | * A timeout can be implemented by adding a pollable from the 16 | * wasi-clocks API to the list. 17 | * 18 | * This function does not return a `result`; polling in itself does not 19 | * do any I/O so it doesn't fail. If any of the I/O sources identified by 20 | * the pollables has an error, it is indicated by marking the source as 21 | * being ready for I/O. 22 | */ 23 | export function poll(in_: Array): Uint32Array; 24 | 25 | export class Pollable { 26 | /** 27 | * This type does not have a public constructor. 28 | */ 29 | private constructor(); 30 | /** 31 | * Return the readiness of a pollable. This function never blocks. 32 | * 33 | * Returns `true` when the pollable is ready, and `false` otherwise. 34 | */ 35 | ready(): boolean; 36 | /** 37 | * `block` returns immediately if the pollable is ready, and otherwise 38 | * blocks until ready. 39 | * 40 | * This function is equivalent to calling `poll.poll` on a list 41 | * containing only this pollable. 42 | */ 43 | block(): void; 44 | } 45 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/interfaces/wasi-sockets-udp-create-socket.d.ts: -------------------------------------------------------------------------------- 1 | /** @module Interface wasi:sockets/udp-create-socket@0.2.3 **/ 2 | /** 3 | * Create a new UDP socket. 4 | * 5 | * Similar to `socket(AF_INET or AF_INET6, SOCK_DGRAM, IPPROTO_UDP)` in POSIX. 6 | * On IPv6 sockets, IPV6_V6ONLY is enabled by default and can't be configured otherwise. 7 | * 8 | * This function does not require a network capability handle. This is considered to be safe because 9 | * at time of creation, the socket is not bound to any `network` yet. Up to the moment `bind` is called, 10 | * the socket is effectively an in-memory configuration object, unable to communicate with the outside world. 11 | * 12 | * All sockets are non-blocking. Use the wasi-poll interface to block on asynchronous operations. 13 | * 14 | * # Typical errors 15 | * - `not-supported`: The specified `address-family` is not supported. (EAFNOSUPPORT) 16 | * - `new-socket-limit`: The new socket resource could not be created because of a system limit. (EMFILE, ENFILE) 17 | * 18 | * # References: 19 | * - 20 | * - 21 | * - 22 | * - 23 | */ 24 | export function createUdpSocket(addressFamily: IpAddressFamily): UdpSocket; 25 | export type Network = import('./wasi-sockets-network.js').Network; 26 | export type ErrorCode = import('./wasi-sockets-network.js').ErrorCode; 27 | export type IpAddressFamily = import('./wasi-sockets-network.js').IpAddressFamily; 28 | export type UdpSocket = import('./wasi-sockets-udp.js').UdpSocket; 29 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/interfaces/wasi-sockets-tcp-create-socket.d.ts: -------------------------------------------------------------------------------- 1 | /** @module Interface wasi:sockets/tcp-create-socket@0.2.3 **/ 2 | /** 3 | * Create a new TCP socket. 4 | * 5 | * Similar to `socket(AF_INET or AF_INET6, SOCK_STREAM, IPPROTO_TCP)` in POSIX. 6 | * On IPv6 sockets, IPV6_V6ONLY is enabled by default and can't be configured otherwise. 7 | * 8 | * This function does not require a network capability handle. This is considered to be safe because 9 | * at time of creation, the socket is not bound to any `network` yet. Up to the moment `bind`/`connect` 10 | * is called, the socket is effectively an in-memory configuration object, unable to communicate with the outside world. 11 | * 12 | * All sockets are non-blocking. Use the wasi-poll interface to block on asynchronous operations. 13 | * 14 | * # Typical errors 15 | * - `not-supported`: The specified `address-family` is not supported. (EAFNOSUPPORT) 16 | * - `new-socket-limit`: The new socket resource could not be created because of a system limit. (EMFILE, ENFILE) 17 | * 18 | * # References 19 | * - 20 | * - 21 | * - 22 | * - 23 | */ 24 | export function createTcpSocket(addressFamily: IpAddressFamily): TcpSocket; 25 | export type Network = import('./wasi-sockets-network.js').Network; 26 | export type ErrorCode = import('./wasi-sockets-network.js').ErrorCode; 27 | export type IpAddressFamily = import('./wasi-sockets-network.js').IpAddressFamily; 28 | export type TcpSocket = import('./wasi-sockets-tcp.js').TcpSocket; 29 | -------------------------------------------------------------------------------- /crates/repl-logic-guest/src/lib.rs: -------------------------------------------------------------------------------- 1 | #[allow(warnings)] 2 | mod bindings; 3 | mod parser; 4 | mod reserved; 5 | mod vars; 6 | 7 | use crate::bindings::exports::repl::api::guest_state::Guest as GuestStateGuest; 8 | use crate::bindings::exports::repl::api::repl_logic::Guest as ReplLogicGuest; 9 | use crate::bindings::repl::api::host_state; 10 | use crate::bindings::repl::api::transport; 11 | 12 | struct Component {} 13 | 14 | impl ReplLogicGuest for Component { 15 | fn readline(line: String) -> transport::ReadlineResponse { 16 | let vars = host_state::get_repl_vars(); 17 | 18 | // parse the line into a command and payload + expand variables 19 | let parsed_line = parser::parse_line(&line, &vars.into()); 20 | 21 | // try to run reserved commands or show their manual 22 | // must be done before running plugins, because plugins must not override reserved commands 23 | if let Some(response) = reserved::run(&parsed_line.command, &parsed_line.payload) { 24 | return transport::ReadlineResponse::Ready(response); 25 | } 26 | 27 | if parsed_line.command == "man" 28 | && (parsed_line.payload.is_empty() || parsed_line.payload == "man") 29 | { 30 | if let Some(response) = reserved::man(&parsed_line.command) { 31 | return transport::ReadlineResponse::Ready(response); 32 | } 33 | } 34 | 35 | // if no reserved command was run return the parsed line to be passed to the plugin to run from the host 36 | transport::ReadlineResponse::ToRun(parsed_line) 37 | } 38 | } 39 | 40 | impl GuestStateGuest for Component { 41 | fn get_reserved_commands() -> Vec { 42 | reserved::get_reserved_commands() 43 | } 44 | } 45 | 46 | bindings::export!(Component with_types_in bindings); 47 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/lib/browser/random.js: -------------------------------------------------------------------------------- 1 | const MAX_BYTES = 65536; 2 | 3 | let insecureRandomValue1, insecureRandomValue2; 4 | 5 | export const insecure = { 6 | getInsecureRandomBytes (len) { 7 | return random.getRandomBytes(len); 8 | }, 9 | getInsecureRandomU64 () { 10 | return random.getRandomU64(); 11 | } 12 | }; 13 | 14 | let insecureSeedValue1, insecureSeedValue2; 15 | 16 | export const insecureSeed = { 17 | insecureSeed () { 18 | if (insecureSeedValue1 === undefined) { 19 | insecureSeedValue1 = random.getRandomU64(); 20 | insecureSeedValue2 = random.getRandomU64(); 21 | } 22 | return [insecureSeedValue1, insecureSeedValue2]; 23 | } 24 | }; 25 | 26 | export const random = { 27 | getRandomBytes(len) { 28 | const bytes = new Uint8Array(Number(len)); 29 | 30 | if (len > MAX_BYTES) { 31 | // this is the max bytes crypto.getRandomValues 32 | // can do at once see https://developer.mozilla.org/en-US/docs/Web/API/window.crypto.getRandomValues 33 | for (var generated = 0; generated < len; generated += MAX_BYTES) { 34 | // buffer.slice automatically checks if the end is past the end of 35 | // the buffer so we don't have to here 36 | crypto.getRandomValues(bytes.subarray(generated, generated + MAX_BYTES)); 37 | } 38 | } else { 39 | crypto.getRandomValues(bytes); 40 | } 41 | 42 | return bytes; 43 | }, 44 | 45 | getRandomU64 () { 46 | return crypto.getRandomValues(new BigUint64Array(1))[0]; 47 | }, 48 | 49 | insecureRandom () { 50 | if (insecureRandomValue1 === undefined) { 51 | insecureRandomValue1 = random.getRandomU64(); 52 | insecureRandomValue2 = random.getRandomU64(); 53 | } 54 | return [insecureRandomValue1, insecureRandomValue2]; 55 | } 56 | }; 57 | -------------------------------------------------------------------------------- /.github/workflows/update-homebrew-tap.yml: -------------------------------------------------------------------------------- 1 | name: Update Homebrew Tap 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | update-homebrew-tap: 9 | runs-on: ubuntu-latest 10 | 11 | permissions: 12 | contents: write # ✅ minimal required for pushing commits 13 | 14 | steps: 15 | - name: Checkout source repo 16 | uses: actions/checkout@v4 17 | - name: Update Homebrew Formula 18 | uses: topheman/update-homebrew-tap@v2 19 | with: 20 | formula-target-repository: topheman/homebrew-tap 21 | formula-target-file: Formula/pluginlab.rb 22 | tar-files: | 23 | { 24 | "linuxArm": "https://github.com/topheman/webassembly-component-model-experiments/releases/download/${{ github.ref_name }}/pluginlab-aarch64-unknown-linux-gnu.tar.gz", 25 | "linuxIntel": "https://github.com/topheman/webassembly-component-model-experiments/releases/download/${{ github.ref_name }}/pluginlab-x86_64-unknown-linux-gnu.tar.gz", 26 | "macArm": "https://github.com/topheman/webassembly-component-model-experiments/releases/download/${{ github.ref_name }}/pluginlab-aarch64-apple-darwin.tar.gz", 27 | "macIntel": "https://github.com/topheman/webassembly-component-model-experiments/releases/download/${{ github.ref_name }}/pluginlab-x86_64-apple-darwin.tar.gz" 28 | } 29 | metadata: | 30 | { 31 | "version": "${{ github.ref_name }}", 32 | "binaryName": "pluginlab", 33 | "description": "Terminal REPL with sandboxed multi-language plugin system - unified codebase runs in CLI (Rust) and web (TypeScript)", 34 | "homepage": "https://github.com/topheman/webassembly-component-model-experiments", 35 | "license": "MIT" 36 | } 37 | github-token: ${{ secrets.HOMEBREW_TAP_TOKEN }} 38 | -------------------------------------------------------------------------------- /crates/pluginlab/src/cli.rs: -------------------------------------------------------------------------------- 1 | use clap::{Parser, Subcommand, ValueEnum}; 2 | use std::path::PathBuf; 3 | 4 | #[derive(Parser, Debug)] 5 | #[command(author, version, about, long_about = None)] 6 | pub struct Cli { 7 | #[command(subcommand)] 8 | pub command: Option, 9 | 10 | /// Paths or URLs to WebAssembly plugin files 11 | #[arg(long)] 12 | pub plugins: Vec, 13 | 14 | /// Path or URL to WebAssembly REPL logic file 15 | #[arg(long)] 16 | pub repl_logic: Option, 17 | 18 | #[arg(long, default_value_t = false)] 19 | pub debug: bool, 20 | 21 | /// Path to the directory to mount (the runtime will only have access to this directory) 22 | #[arg(long, default_value = ".")] 23 | pub dir: PathBuf, 24 | 25 | /// Allow network access 26 | #[arg(short = 'N', long, num_args = 0..=1, default_missing_value = "@")] 27 | // How it works: 28 | // no flag -> None 29 | // --allow-net -> Some("@") - because "@" is not a valid value for a domain nor an IP address 30 | // --allow-net google.com,example.com -> Some("google.com,example.com") 31 | pub allow_net: Option, 32 | 33 | /// Allow file system read access 34 | #[arg(short = 'R', long, default_value_t = false)] 35 | pub allow_read: bool, 36 | 37 | /// Allow file system write access 38 | #[arg(short = 'W', long, default_value_t = false)] 39 | pub allow_write: bool, 40 | 41 | /// Allow all permissions 42 | #[arg( 43 | short = 'A', 44 | long, 45 | default_value_t = false, 46 | conflicts_with = "allow_net", 47 | conflicts_with = "allow_read", 48 | conflicts_with = "allow_write" 49 | )] 50 | pub allow_all: bool, 51 | } 52 | 53 | #[derive(Subcommand, Debug)] 54 | pub enum Commands { 55 | /// Generate completions for your own shell (shipped with the homebrew version) 56 | GenerateCompletions { 57 | /// Specify which shell you target - accepted values: bash, fish, zsh 58 | #[arg(long, value_enum)] 59 | shell: AvailableShells, 60 | }, 61 | } 62 | 63 | #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)] 64 | pub enum AvailableShells { 65 | Bash, 66 | Fish, 67 | Zsh, 68 | } 69 | -------------------------------------------------------------------------------- /crates/pluginlab/tests/e2e_c_plugins.rs: -------------------------------------------------------------------------------- 1 | mod utils; 2 | 3 | #[cfg(test)] 4 | mod e2e_c_plugins { 5 | 6 | use crate::utils::*; 7 | 8 | use rexpect::spawn; 9 | 10 | const TEST_TIMEOUT: u64 = 10000; 11 | 12 | /** 13 | * Lets us change the target directory for the plugins and repl logic. 14 | * 15 | * See the justfile for examples where we switch to testing both plugins from the filesystem and from the HTTP server. 16 | */ 17 | fn build_command(plugin_files: &[&str], repl_logic_file: &str) -> String { 18 | let prefix = 19 | std::env::var("WASM_TARGET_DIR").unwrap_or("target/wasm32-wasip1/debug".to_string()); 20 | let mut command = String::from("target/debug/pluginlab"); 21 | command.push_str(format!(" --repl-logic {}/{}", prefix, repl_logic_file).as_str()); 22 | plugin_files.iter().for_each(|file| { 23 | command.push_str(format!(" --plugins {}", file).as_str()); 24 | }); 25 | println!("Running command: {}", command); 26 | command 27 | } 28 | 29 | #[test] 30 | fn test_echo_plugin() { 31 | let project_root = find_project_root(); 32 | println!("Setting current directory to: {:?}", project_root); 33 | std::env::set_current_dir(&project_root).unwrap(); 34 | let mut session = spawn( 35 | &format!( 36 | "{} --dir tmp/filesystem --allow-read", 37 | &build_command( 38 | &["c_modules/plugin-echo/plugin-echo-c.wasm"], 39 | "repl_logic_guest.wasm" 40 | ) 41 | ), 42 | Some(TEST_TIMEOUT), 43 | ) 44 | .expect("Can't launch pluginlab with plugin greet"); 45 | 46 | session 47 | .exp_string("[Host] Starting REPL host...") 48 | .expect("Didn't see startup message"); 49 | session 50 | .exp_string("[Host] Loading plugin:") 51 | .expect("Didn't see plugin loading message"); 52 | session 53 | .exp_string("repl(0)>") 54 | .expect("Didn't see REPL prompt"); 55 | session 56 | .send_line("echoc hello") 57 | .expect("Failed to send command"); 58 | session 59 | .exp_string("hello\r\nrepl(0)>") 60 | .expect("Didn't get expected output from echoc plugin"); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /crates/pluginlab/tests/e2e_go_plugins.rs: -------------------------------------------------------------------------------- 1 | mod utils; 2 | 3 | #[cfg(test)] 4 | mod e2e_go_plugins { 5 | 6 | use crate::utils::*; 7 | 8 | use rexpect::spawn; 9 | 10 | const TEST_TIMEOUT: u64 = 10000; 11 | 12 | /** 13 | * Lets us change the target directory for the plugins and repl logic. 14 | * 15 | * See the justfile for examples where we switch to testing both plugins from the filesystem and from the HTTP server. 16 | */ 17 | fn build_command(plugin_files: &[&str], repl_logic_file: &str) -> String { 18 | let prefix = 19 | std::env::var("WASM_TARGET_DIR").unwrap_or("target/wasm32-wasip1/debug".to_string()); 20 | let mut command = String::from("target/debug/pluginlab"); 21 | command.push_str(format!(" --repl-logic {}/{}", prefix, repl_logic_file).as_str()); 22 | plugin_files.iter().for_each(|file| { 23 | command.push_str(format!(" --plugins {}", file).as_str()); 24 | }); 25 | println!("Running command: {}", command); 26 | command 27 | } 28 | 29 | #[test] 30 | fn test_echo_plugin() { 31 | let project_root = find_project_root(); 32 | println!("Setting current directory to: {:?}", project_root); 33 | std::env::set_current_dir(&project_root).unwrap(); 34 | let mut session = spawn( 35 | &format!( 36 | "{} --dir tmp/filesystem --allow-read", 37 | &build_command( 38 | &["go_modules/plugin-echo/plugin-echo-go.wasm"], 39 | "repl_logic_guest.wasm" 40 | ) 41 | ), 42 | Some(TEST_TIMEOUT), 43 | ) 44 | .expect("Can't launch pluginlab with plugin greet"); 45 | 46 | session 47 | .exp_string("[Host] Starting REPL host...") 48 | .expect("Didn't see startup message"); 49 | session 50 | .exp_string("[Host] Loading plugin:") 51 | .expect("Didn't see plugin loading message"); 52 | session 53 | .exp_string("repl(0)>") 54 | .expect("Didn't see REPL prompt"); 55 | session 56 | .send_line("echogo hello") 57 | .expect("Failed to send command"); 58 | session 59 | .exp_string("hello\r\nrepl(0)>") 60 | .expect("Didn't get expected output from echogo plugin"); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /crates/plugin-cat/src/lib.rs: -------------------------------------------------------------------------------- 1 | #[allow(warnings)] 2 | mod bindings; 3 | 4 | use crate::bindings::exports::repl::api::plugin::Guest; 5 | use crate::bindings::repl::api::transport; 6 | 7 | struct Component; 8 | 9 | impl Guest for Component { 10 | fn name() -> String { 11 | "cat".to_string() 12 | } 13 | 14 | fn man() -> String { 15 | r#" 16 | NAME 17 | cat - Print the contents of the file passed as argument (built with Rust🦀) 18 | 19 | SYNOPSIS 20 | cat 21 | 22 | DESCRIPTION 23 | Print the contents of the file passed as argument. 24 | 25 | EXAMPLES 26 | > cat README.md 27 | # This is a README file 28 | It contains some information about the project 29 | It is written in Markdown 30 | It is used to describe the project 31 | 32 | "# 33 | .to_string() 34 | } 35 | 36 | fn run(payload: String) -> Result { 37 | match std::fs::metadata(&payload) { 38 | Ok(metadata) => { 39 | if metadata.is_file() { 40 | let file = std::fs::read_to_string(&payload).unwrap(); 41 | return Ok(transport::PluginResponse { 42 | status: transport::ReplStatus::Success, 43 | stdout: Some(file), 44 | stderr: None, 45 | }); 46 | } else if metadata.is_dir() { 47 | return Ok(transport::PluginResponse { 48 | status: transport::ReplStatus::Error, 49 | stdout: None, 50 | stderr: Some(format!("cat: {}: Is a directory", payload)), 51 | }); 52 | } else { 53 | return Ok(transport::PluginResponse { 54 | status: transport::ReplStatus::Error, 55 | stdout: None, 56 | stderr: Some(format!("cat: {}: Unsupported file type", payload)), 57 | }); 58 | } 59 | } 60 | Err(err) => { 61 | return Ok(transport::PluginResponse { 62 | status: transport::ReplStatus::Error, 63 | stdout: None, 64 | stderr: Some(format!("cat: {}: {}", payload, err.to_string())), 65 | }); 66 | } 67 | } 68 | } 69 | } 70 | 71 | bindings::export!(Component with_types_in bindings); 72 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webassembly-component-model-experiments", 3 | "version": "0.1.0", 4 | "description": "Terminal REPL with sandboxed multi-language plugin system - unified codebase runs in CLI (Rust) and web (TypeScript)", 5 | "keywords": [ 6 | "WebAssembly", 7 | "WebAssembly Component Model", 8 | "WIT", 9 | "wasmtime", 10 | "wit-bindgen", 11 | "repl", 12 | "plugin-system" 13 | ], 14 | "homepage": "https://github.com/topheman/webassembly-component-model-experiments#readme", 15 | "bugs": { 16 | "url": "https://github.com/topheman/webassembly-component-model-experiments/issues" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/topheman/webassembly-component-model-experiments.git" 21 | }, 22 | "author": "Christophe Rosset", 23 | "scripts": { 24 | "test": "echo \"Error: no test specified\" && exit 1", 25 | "build": "npm run web-host:build", 26 | "preview": "npm run web-host:preview", 27 | "dev": "npm run web-host:dev", 28 | "build-plugin-echo": "cd packages/plugin-echo && npm run build", 29 | "lint": "biome check .", 30 | "lint:fix": "biome check --write .", 31 | "format": "biome format --write .", 32 | "test:e2e:all": "npm run test:e2e:all --workspace=packages/web-host", 33 | "test:e2e:ui": "npm run test:e2e:ui --workspace=packages/web-host", 34 | "test:e2e:all:preview": "npm run test:e2e:all:preview --workspace=packages/web-host", 35 | "test:e2e:ui:preview": "npm run test:e2e:ui:preview --workspace=packages/web-host", 36 | "test:e2e:report": "npm run test:e2e:report --workspace=packages/web-host", 37 | "test:e2e:like-in-ci": "npm run test:e2e:like-in-ci --workspace=packages/web-host", 38 | "typecheck": "npm run typecheck --workspace=*", 39 | "web-host:typecheck": "npm run typecheck --workspace=packages/web-host", 40 | "web-host:build": "npm run build --workspace=packages/web-host", 41 | "web-host:preview": "npm run preview --workspace=packages/web-host", 42 | "web-host:dev": "npm run dev --workspace=packages/web-host", 43 | "prepare": "husky", 44 | "clean:install": "find . -name \"node_modules\" -exec rm -rf '{}' +" 45 | }, 46 | "workspaces": [ 47 | "packages/*" 48 | ], 49 | "devDependencies": { 50 | "@biomejs/biome": "2.0.5", 51 | "husky": "^9.1.7", 52 | "lint-staged": "^16.1.2", 53 | "typescript": "^5.8.3" 54 | }, 55 | "engines": { 56 | "node": ">=22.6.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /packages/web-host/src/index.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | @theme { 4 | --animate-spin-slow: spin 5s linear infinite; 5 | --color-wasi-purple: #6c63ff; 6 | --color-wasi-violet: #9b42d6; 7 | --color-wasi-accent: #5ce1e6; 8 | --color-primary: var(--color-wasi-purple); 9 | --color-primary-50: #f5f3ff; 10 | --color-primary-100: #ede9fe; 11 | --color-primary-200: #ddd6fe; 12 | --color-primary-300: #c4b5fd; 13 | --color-primary-400: #a78bfa; 14 | --color-primary-500: #8b5cf6; 15 | --color-primary-600: #7c3aed; 16 | --color-primary-700: #6d28d9; 17 | --color-primary-800: #5b21b6; 18 | --color-primary-900: #4c1d95; 19 | --color-primary-950: #2e1065; 20 | } 21 | 22 | :root { 23 | font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; 24 | line-height: 1.5; 25 | font-weight: 400; 26 | 27 | color-scheme: light dark; 28 | color: #222; 29 | background-color: #f8f6f4; 30 | 31 | font-synthesis: none; 32 | text-rendering: optimizeLegibility; 33 | -webkit-font-smoothing: antialiased; 34 | -moz-osx-font-smoothing: grayscale; 35 | } 36 | 37 | /* Base styles that complement Tailwind */ 38 | body { 39 | margin: 0; 40 | min-width: 320px; 41 | min-height: 100vh; 42 | background-color: #f8f6f4; 43 | color: #222; 44 | } 45 | 46 | /* Prevent bouncing effect when scrolling on the REPL page (multiple scrolling containers) */ 47 | body:has([data-page="repl"]) { 48 | overflow: hidden; 49 | position: fixed; 50 | top: 0; 51 | left: 0; 52 | width: 100%; 53 | } 54 | 55 | /* Focus styles for better accessibility */ 56 | button:focus-visible { 57 | outline: 2px solid var(--color-primary); 58 | outline-offset: 2px; 59 | } 60 | 61 | @layer utilities { 62 | h3, 63 | h4, 64 | h5, 65 | h6, 66 | a, 67 | strong { 68 | color: var(--color-primary); 69 | } 70 | a { 71 | text-decoration-line: underline; 72 | } 73 | a:hover { 74 | text-decoration-line: none; 75 | } 76 | code { 77 | background-color: #f0f0f0; 78 | padding: 0.2rem 0.4rem; 79 | border-radius: 0.2rem; 80 | font-size: 0.85em; 81 | } 82 | } 83 | 84 | @utility word-break-word { 85 | word-break: break-word; 86 | } 87 | 88 | @utility word-break-all { 89 | word-break: break-all; 90 | } 91 | 92 | @keyframes pulse110 { 93 | 0%, 94 | 100% { 95 | transform: scale(1); 96 | } 97 | 50% { 98 | transform: scale(1.15); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /packages/web-host/src/hooks/wasm.tsx: -------------------------------------------------------------------------------- 1 | import { setReplVar } from "repl:api/host-state"; 2 | import { createContext, useContext, useEffect, useState } from "react"; 3 | import { prepareEngine, type WasmEngine } from "../wasm/engine"; 4 | import { useReplHistory } from "./replHistory"; 5 | 6 | type WasmContext = 7 | | { 8 | status: "loading"; 9 | error: null; 10 | engine: null; 11 | } 12 | | { 13 | status: "ready"; 14 | error: null; 15 | engine: WasmEngine; 16 | } 17 | | { 18 | status: "error"; 19 | error: Error; 20 | engine: null; 21 | }; 22 | 23 | const WasmContext = createContext({ 24 | status: "loading", 25 | error: null, 26 | engine: null, 27 | }); 28 | 29 | export function WasmProvider({ children }: { children: React.ReactNode }) { 30 | const { addEntry: addReplHistoryEntry } = useReplHistory(); 31 | const [context, setContext] = useState({ 32 | status: "loading", 33 | error: null, 34 | engine: null, 35 | }); 36 | 37 | useEffect(() => { 38 | console.log("useEffect prepareEngine"); 39 | const abortController = new AbortController(); 40 | prepareEngine({ addReplHistoryEntry, abortSignal: abortController.signal }) 41 | .then(async (engine) => { 42 | if (!engine) { 43 | console.log("prepareEngine aborted"); 44 | return; 45 | } 46 | console.log("useEffect prepareEngine success", engine); 47 | setReplVar({ key: "ROOT", value: "/Users" }); 48 | setReplVar({ key: "USER", value: "Tophe" }); 49 | setReplVar({ key: "?", value: "0" }); 50 | setContext({ 51 | status: "ready", 52 | error: null, 53 | engine, 54 | }); 55 | addReplHistoryEntry({ 56 | stdin: "[Host] REPL host ready", 57 | }); 58 | }) 59 | .catch((error) => { 60 | console.log("useEffect prepareEngine error", error); 61 | setContext({ 62 | status: "error", 63 | error, 64 | engine: null, 65 | }); 66 | }); 67 | return () => { 68 | console.log("useEffect prepareEngine abort"); 69 | abortController.abort("Avoid react useEffect re-run"); 70 | }; 71 | }, [addReplHistoryEntry]); 72 | 73 | return ( 74 | {children} 75 | ); 76 | } 77 | 78 | export function useWasm() { 79 | const context = useContext(WasmContext); 80 | return context; 81 | } 82 | -------------------------------------------------------------------------------- /crates/plugin-weather/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod api; 2 | #[allow(warnings)] 3 | mod bindings; 4 | 5 | use crate::bindings::exports::repl::api::plugin::Guest; 6 | use crate::bindings::repl::api::http_client; 7 | use crate::bindings::repl::api::transport; 8 | 9 | use crate::api::get_weather_from_body; 10 | 11 | struct Component; 12 | 13 | impl Guest for Component { 14 | fn name() -> String { 15 | "weather".to_string() 16 | } 17 | 18 | fn man() -> String { 19 | r#" 20 | NAME 21 | weather - Get the weather for a given city (built with Rust🦀) 22 | 23 | USAGE 24 | weather 25 | 26 | DESCRIPTION 27 | Get the weather for a given city. 28 | 29 | "# 30 | .to_string() 31 | } 32 | 33 | fn run(payload: String) -> Result { 34 | match http_client::get( 35 | format!("https://wttr.in/{}?format=j1", payload).as_str(), 36 | &[], 37 | ) { 38 | Ok(response) => { 39 | // todo: add more ok status codes - put that on the host side 40 | if !response.ok { 41 | return Ok(transport::PluginResponse { 42 | status: transport::ReplStatus::Error, 43 | stdout: None, 44 | stderr: Some(format!( 45 | "Error fetching weather - status code:{}", 46 | response.status 47 | )), 48 | }); 49 | } 50 | match get_weather_from_body(response.body.as_str()) { 51 | Ok(weather) => Ok(transport::PluginResponse { 52 | status: transport::ReplStatus::Success, 53 | stdout: Some(weather), 54 | stderr: None, 55 | }), 56 | Err(e) => Ok(transport::PluginResponse { 57 | status: transport::ReplStatus::Error, 58 | stdout: None, 59 | stderr: Some(format!("Error parsing result: {}", e.to_string())), 60 | }), 61 | } 62 | } 63 | Err(e) => Ok(transport::PluginResponse { 64 | status: transport::ReplStatus::Error, 65 | stdout: None, 66 | stderr: Some(format!("Error fetching weather: {}", e.to_string())), 67 | }), 68 | } 69 | } 70 | } 71 | 72 | bindings::export!(Component with_types_in bindings); 73 | -------------------------------------------------------------------------------- /packages/web-host/src/hooks/exampleCommands.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | function ls(path?: string) { 4 | if (path) { 5 | return `ls ${path}`; 6 | } 7 | return "ls"; 8 | } 9 | 10 | function cat(path?: string) { 11 | if (path) { 12 | return `cat ${path}`; 13 | } 14 | return "cat"; 15 | } 16 | 17 | const commands = [ 18 | "echo foo", 19 | "echo bar", 20 | "echo baz", 21 | "echo $0", 22 | "echo $ROOT/$USER", 23 | "greet $USER", 24 | "echo $0", 25 | "export USER=WebAssembly", 26 | "echo $ROOT/$USER", 27 | "echo $0", 28 | "echo $?", 29 | "azertyuiop", 30 | "echo $?", 31 | "echo $?", 32 | () => `export DATE=${new Date().toISOString()}`, 33 | "echo $DATE", 34 | "export USER=Tophe", 35 | "echo $ROOT/$USER", 36 | () => ls(), 37 | () => cat("README.md"), 38 | () => ls(), 39 | () => ls("data"), 40 | () => ls("data/processed"), 41 | () => ls("data/processed/2024"), 42 | () => ls("documents"), 43 | () => cat("documents/config.json"), 44 | "echo We can also write to files! 🔥", 45 | "tee output.txt", 46 | "cat output.txt", 47 | "echo We can write to files in append mode!", 48 | "tee -a output.txt", 49 | "echo Some more text", 50 | "tee -a output.txt", 51 | "echo Even more text", 52 | "tee -a output.txt", 53 | "cat output.txt", 54 | "weather Paris, France", 55 | "man weather", 56 | "help", 57 | "echoc This is the same as `echo`, implemented in C", 58 | "echogo Same, but in Go", 59 | "echoc Whole C toolchain available", 60 | "echogo Whole Go toolchain available", 61 | "echo try `man echo` vs `man echoc` vs `man echogo`", 62 | "echoc qux", 63 | "echogo qux", 64 | ]; 65 | 66 | export function useGetExampleCommand() { 67 | const [index, setIndex] = useState(0); 68 | const [command, setCommand] = useState(""); 69 | const [remaining, setRemaining] = useState(commands.length); 70 | const [done, setDone] = useState(false); 71 | 72 | return { 73 | getExampleCommand: function getExampleCommand() { 74 | if (commands.length - index - 1 === 0) { 75 | setDone(true); 76 | setIndex(0); 77 | } 78 | const command = commands[index]; 79 | const output = typeof command === "function" ? command() : command; 80 | setCommand(output); 81 | setRemaining((left) => left - 1); 82 | setIndex((index) => index + 1); 83 | return output; 84 | }, 85 | currentExampleCommand: command, 86 | remainingExampleCommands: remaining, 87 | doneExampleCommands: done, 88 | }; 89 | } 90 | -------------------------------------------------------------------------------- /packages/web-host/src/components/ReplHistory.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from "react"; 2 | 3 | import type { ReplHistoryEntry } from "../types"; 4 | import { cn } from "../utils/css"; 5 | 6 | interface ReplHistoryProps extends React.HTMLAttributes { 7 | ref: React.RefObject; 8 | history: ReplHistoryEntry[]; 9 | } 10 | 11 | export function ReplHistory({ 12 | history, 13 | className, 14 | ref, 15 | ...props 16 | }: ReplHistoryProps) { 17 | return ( 18 |
19 | {history.map((entry, index) => ( 20 | // biome-ignore lint/suspicious/noArrayIndexKey: no unique key 21 | 22 | {"stdin" in entry && entry.stdin && !entry.allowHtml && ( 23 |
28 |               {entry.stdin}
29 |             
30 | )} 31 | {"stdin" in entry && entry.stdin && entry.allowHtml && ( 32 |
39 |           )}
40 |           {"stdout" in entry && entry.stdout && (
41 |             
47 |               {entry.stdout}
48 |             
49 | )} 50 | {"stderr" in entry && entry.stderr && ( 51 |
57 |               {entry.stderr}
58 |             
59 | )} 60 | 61 | ))} 62 |
63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /packages/web-host/tests/navigation.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@playwright/test"; 2 | import { clickWandButton } from "./utils"; 3 | 4 | test("start REPL link", async ({ page }) => { 5 | await page.goto("/"); 6 | await page.getByTestId("start-repl-button-top").click({ force: true }); 7 | await expect(page).toHaveURL( 8 | "/webassembly-component-model-experiments/#repl", 9 | ); 10 | await expect( 11 | page.getByRole("heading", { name: "REPL Interface" }), 12 | ).toBeVisible(); 13 | }); 14 | 15 | test("direct load repl page", async ({ page }) => { 16 | await page.goto("/#repl"); 17 | await expect( 18 | page.getByRole("heading", { name: "REPL Interface" }), 19 | ).toBeVisible(); 20 | }); 21 | 22 | test("back to home button", async ({ page }) => { 23 | await page.goto("/#repl"); 24 | await page.getByRole("button", { name: " Back to Home" }).click(); 25 | await expect(page).toHaveURL( 26 | "/webassembly-component-model-experiments/#home", 27 | ); 28 | await expect( 29 | page.getByRole("heading", { 30 | name: "WebAssembly Component Model Experiments", 31 | }), 32 | ).toBeVisible(); 33 | }); 34 | 35 | test("back button", async ({ page }) => { 36 | await page.goto("/"); 37 | await page.getByTestId("start-repl-button-top").click({ force: true }); 38 | await expect(page).toHaveURL( 39 | "/webassembly-component-model-experiments/#repl", 40 | ); 41 | await expect( 42 | page.getByRole("heading", { name: "REPL Interface" }), 43 | ).toBeVisible(); 44 | await page.goBack(); 45 | await expect( 46 | page.getByRole("heading", { 47 | name: "WebAssembly Component Model Experiments", 48 | }), 49 | ).toBeVisible(); 50 | }); 51 | 52 | test("history should be preserved + wand button", async ({ page }) => { 53 | await page.goto("/"); 54 | await page.getByTestId("start-repl-button-top").click({ force: true }); 55 | await expect(page).toHaveURL( 56 | "/webassembly-component-model-experiments/#repl", 57 | ); 58 | await clickWandButton(page, "echo foo", { expectStdout: "foo" }); 59 | await clickWandButton(page, "echo bar", { expectStdout: "bar" }); 60 | await clickWandButton(page, "echo baz", { expectStdout: "baz" }); 61 | await page.goBack(); 62 | await expect( 63 | page.getByRole("heading", { 64 | name: "WebAssembly Component Model Experiments", 65 | }), 66 | ).toBeVisible(); 67 | await page.getByTestId("start-repl-button-top").click({ force: true }); 68 | await expect(page).toHaveURL( 69 | "/webassembly-component-model-experiments/#repl", 70 | ); 71 | await expect( 72 | page.getByText("echo foofooecho barbarecho bazbaz"), 73 | ).toBeVisible(); 74 | }); 75 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/lib/browser/sockets.js: -------------------------------------------------------------------------------- 1 | export const instanceNetwork = { 2 | instanceNetwork () { 3 | console.log(`[sockets] instance network`); 4 | } 5 | }; 6 | 7 | export const ipNameLookup = { 8 | dropResolveAddressStream () { 9 | 10 | }, 11 | subscribe () { 12 | 13 | }, 14 | resolveAddresses () { 15 | 16 | }, 17 | resolveNextAddress () { 18 | 19 | }, 20 | nonBlocking () { 21 | 22 | }, 23 | setNonBlocking () { 24 | 25 | }, 26 | }; 27 | 28 | export const network = { 29 | dropNetwork () { 30 | 31 | } 32 | }; 33 | 34 | export const tcpCreateSocket = { 35 | createTcpSocket () { 36 | 37 | } 38 | }; 39 | 40 | export const tcp = { 41 | subscribe () { 42 | 43 | }, 44 | dropTcpSocket() { 45 | 46 | }, 47 | bind() { 48 | 49 | }, 50 | connect() { 51 | 52 | }, 53 | listen() { 54 | 55 | }, 56 | accept() { 57 | 58 | }, 59 | localAddress() { 60 | 61 | }, 62 | remoteAddress() { 63 | 64 | }, 65 | addressFamily() { 66 | 67 | }, 68 | setListenBacklogSize() { 69 | 70 | }, 71 | keepAlive() { 72 | 73 | }, 74 | setKeepAlive() { 75 | 76 | }, 77 | noDelay() { 78 | 79 | }, 80 | setNoDelay() { 81 | 82 | }, 83 | unicastHopLimit() { 84 | 85 | }, 86 | setUnicastHopLimit() { 87 | 88 | }, 89 | receiveBufferSize() { 90 | 91 | }, 92 | setReceiveBufferSize() { 93 | 94 | }, 95 | sendBufferSize() { 96 | 97 | }, 98 | setSendBufferSize() { 99 | 100 | }, 101 | nonBlocking() { 102 | 103 | }, 104 | setNonBlocking() { 105 | 106 | }, 107 | shutdown() { 108 | 109 | } 110 | }; 111 | 112 | export const udp = { 113 | subscribe () { 114 | 115 | }, 116 | 117 | dropUdpSocket () { 118 | 119 | }, 120 | 121 | bind () { 122 | 123 | }, 124 | 125 | connect () { 126 | 127 | }, 128 | 129 | receive () { 130 | 131 | }, 132 | 133 | send () { 134 | 135 | }, 136 | 137 | localAddress () { 138 | 139 | }, 140 | 141 | remoteAddress () { 142 | 143 | }, 144 | 145 | addressFamily () { 146 | 147 | }, 148 | 149 | unicastHopLimit () { 150 | 151 | }, 152 | 153 | setUnicastHopLimit () { 154 | 155 | }, 156 | 157 | receiveBufferSize () { 158 | 159 | }, 160 | 161 | setReceiveBufferSize () { 162 | 163 | }, 164 | 165 | sendBufferSize () { 166 | 167 | }, 168 | 169 | setSendBufferSize () { 170 | 171 | }, 172 | 173 | nonBlocking () { 174 | 175 | }, 176 | 177 | setNonBlocking () { 178 | 179 | } 180 | }; 181 | 182 | export const udpCreateSocket = { 183 | createUdpSocket () { 184 | 185 | } 186 | }; 187 | -------------------------------------------------------------------------------- /packages/web-host/src/wasm/host/http-client.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | get as HttpGetHost, 3 | HttpHeader, 4 | HttpResponse, 5 | } from "../../types/generated/interfaces/repl-api-http-client"; 6 | 7 | // biome-ignore lint/correctness/noUnusedVariables: commented code 8 | // @ts-ignore 9 | type AsyncHttpGetHost = ( 10 | ...args: Parameters 11 | ) => Promise>; 12 | 13 | type SyncHttpGetHost = typeof HttpGetHost; 14 | 15 | // export const get: AsyncHttpGetHost = async ( 16 | // url: string, 17 | // headers: HttpHeader[] = [], 18 | // ): Promise => { 19 | // console.log("http-client: get", url, headers); 20 | // await new Promise((resolve) => setTimeout(resolve, 5000)); 21 | // const response = await fetch(url, { 22 | // headers: headers.map((header) => [header.name, header.value]), 23 | // }); 24 | // console.log("http-client: get response", response); 25 | // const result = { 26 | // status: response.status, 27 | // headers: Array.from(response.headers.entries()).map(([name, value]) => ({ 28 | // name, 29 | // value, 30 | // })), 31 | // body: response.body ? await response.text() : "", 32 | // ok: response.ok, 33 | // }; 34 | // console.log("http-client: get result", result); 35 | // return result; 36 | // }; 37 | 38 | /** 39 | * For the moment, the http client exposed in synchronous, hence the use of XMLHttpRequest. 40 | * This is not ideal, but async calls are not supported yet with jco. 41 | */ 42 | export const get: SyncHttpGetHost = ( 43 | url: string, 44 | headers: HttpHeader[], 45 | ): HttpResponse => { 46 | const request = new XMLHttpRequest(); 47 | request.open("GET", url, false); 48 | for (const header of headers) { 49 | console.log("[Host][http-client] set header ⬆️", header); 50 | request.setRequestHeader(header.name, header.value); 51 | } 52 | request.send(null); 53 | 54 | const status = request.status; 55 | const responseText = request.responseText; 56 | const responseHeaders = request 57 | .getAllResponseHeaders() 58 | .split("\n") 59 | .map((line) => { 60 | const [name, value] = line.split(": "); 61 | return { 62 | name, 63 | value, 64 | }; 65 | }) 66 | .filter((header) => header.name !== "" && header.value !== undefined); 67 | console.log("[Host][http-client] get request ⬇️", request); 68 | 69 | if (status >= 200 && status < 300) { 70 | const result = { 71 | status, 72 | ok: true, 73 | headers: responseHeaders, 74 | body: responseText, 75 | }; 76 | console.log("[Host][http-client] get result ✅", result); 77 | return result; 78 | } 79 | const result = { 80 | status: request.status, 81 | ok: false, 82 | headers: [], 83 | body: request.responseText, 84 | }; 85 | console.log("[Host][http-client] get result ❌", result); 86 | return result; 87 | }; 88 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/interfaces/wasi-sockets-ip-name-lookup.d.ts: -------------------------------------------------------------------------------- 1 | /** @module Interface wasi:sockets/ip-name-lookup@0.2.3 **/ 2 | /** 3 | * Resolve an internet host name to a list of IP addresses. 4 | * 5 | * Unicode domain names are automatically converted to ASCII using IDNA encoding. 6 | * If the input is an IP address string, the address is parsed and returned 7 | * as-is without making any external requests. 8 | * 9 | * See the wasi-socket proposal README.md for a comparison with getaddrinfo. 10 | * 11 | * This function never blocks. It either immediately fails or immediately 12 | * returns successfully with a `resolve-address-stream` that can be used 13 | * to (asynchronously) fetch the results. 14 | * 15 | * # Typical errors 16 | * - `invalid-argument`: `name` is a syntactically invalid domain name or IP address. 17 | * 18 | * # References: 19 | * - 20 | * - 21 | * - 22 | * - 23 | */ 24 | export function resolveAddresses(network: Network, name: string): ResolveAddressStream; 25 | export type Pollable = import('./wasi-io-poll.js').Pollable; 26 | export type Network = import('./wasi-sockets-network.js').Network; 27 | export type ErrorCode = import('./wasi-sockets-network.js').ErrorCode; 28 | export type IpAddress = import('./wasi-sockets-network.js').IpAddress; 29 | 30 | export class ResolveAddressStream { 31 | /** 32 | * This type does not have a public constructor. 33 | */ 34 | private constructor(); 35 | /** 36 | * Returns the next address from the resolver. 37 | * 38 | * This function should be called multiple times. On each call, it will 39 | * return the next address in connection order preference. If all 40 | * addresses have been exhausted, this function returns `none`. 41 | * 42 | * This function never returns IPv4-mapped IPv6 addresses. 43 | * 44 | * # Typical errors 45 | * - `name-unresolvable`: Name does not exist or has no suitable associated IP addresses. (EAI_NONAME, EAI_NODATA, EAI_ADDRFAMILY) 46 | * - `temporary-resolver-failure`: A temporary failure in name resolution occurred. (EAI_AGAIN) 47 | * - `permanent-resolver-failure`: A permanent failure in name resolution occurred. (EAI_FAIL) 48 | * - `would-block`: A result is not available yet. (EWOULDBLOCK, EAGAIN) 49 | */ 50 | resolveNextAddress(): IpAddress | undefined; 51 | /** 52 | * Create a `pollable` which will resolve once the stream is ready for I/O. 53 | * 54 | * Note: this function is here for WASI 0.2 only. 55 | * It's planned to be removed when `future` is natively supported in Preview3. 56 | */ 57 | subscribe(): Pollable; 58 | } 59 | -------------------------------------------------------------------------------- /packages/web-host/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from "@playwright/test"; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // import dotenv from 'dotenv'; 8 | // import path from 'path'; 9 | // dotenv.config({ path: path.resolve(__dirname, '.env') }); 10 | 11 | /** 12 | * See https://playwright.dev/docs/test-configuration. 13 | */ 14 | export default defineConfig({ 15 | testDir: "./tests", 16 | /* Run tests in files in parallel */ 17 | fullyParallel: true, 18 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 19 | forbidOnly: !!process.env.CI, 20 | /* Retry on CI only */ 21 | retries: process.env.CI ? 2 : 0, 22 | /* Opt out of parallel tests on CI. */ 23 | workers: process.env.CI ? 1 : undefined, 24 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 25 | reporter: "html", 26 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 27 | use: { 28 | /* Base URL to use in actions like `await page.goto('/')`. */ 29 | baseURL: 30 | process.env.BASE_URL || 31 | "http://localhost:5173/webassembly-component-model-experiments/", 32 | 33 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 34 | trace: "on-first-retry", 35 | }, 36 | ...(process.env.WAIT_FOR_SERVER_AT_URL 37 | ? { 38 | webServer: { 39 | command: "npm run preview -- --strictPort", 40 | url: process.env.WAIT_FOR_SERVER_AT_URL, 41 | reuseExistingServer: !process.env.CI, 42 | }, 43 | } 44 | : {}), 45 | 46 | /* Configure projects for major browsers */ 47 | projects: [ 48 | { 49 | name: "chromium", 50 | use: { ...devices["Desktop Chrome"] }, 51 | }, 52 | { 53 | name: "Mobile Chrome", 54 | use: { ...devices["Pixel 5"] }, 55 | }, 56 | 57 | { 58 | name: "firefox", 59 | use: { ...devices["Desktop Firefox"] }, 60 | }, 61 | 62 | { 63 | name: "webkit", 64 | use: { ...devices["Desktop Safari"] }, 65 | }, 66 | 67 | /* Test against mobile viewports. */ 68 | // { 69 | // name: 'Mobile Chrome', 70 | // use: { ...devices['Pixel 5'] }, 71 | // }, 72 | // { 73 | // name: 'Mobile Safari', 74 | // use: { ...devices['iPhone 12'] }, 75 | // }, 76 | 77 | /* Test against branded browsers. */ 78 | // { 79 | // name: 'Microsoft Edge', 80 | // use: { ...devices['Desktop Edge'], channel: 'msedge' }, 81 | // }, 82 | // { 83 | // name: 'Google Chrome', 84 | // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, 85 | // }, 86 | ], 87 | 88 | /* Run your local dev server before starting the tests */ 89 | // webServer: { 90 | // command: 'npm run start', 91 | // url: 'http://localhost:3000', 92 | // reuseExistingServer: !process.env.CI, 93 | // }, 94 | }); 95 | -------------------------------------------------------------------------------- /packages/web-host/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | WebAssembly Component Model Experiments 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 |
39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /packages/web-host/clis/prepareFilesystem.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node --experimental-strip-types --no-warnings 2 | import fs from "node:fs"; 3 | import path from "node:path"; 4 | import { program } from "commander"; 5 | 6 | type VirtualFs = 7 | | Record< 8 | string, 9 | | { 10 | dir: Record; 11 | } 12 | | { 13 | source: string; 14 | } 15 | | Record 16 | > 17 | | Record; 18 | 19 | function makeVirtualFs(filepath: string, acc: VirtualFs) { 20 | fs.readdirSync(filepath).forEach((file) => { 21 | const fullPath = path.join(filepath, file); 22 | if (fs.statSync(fullPath).isDirectory()) { 23 | acc[file] = { dir: {} }; 24 | makeVirtualFs(fullPath, acc[file].dir); 25 | } else { 26 | if (file === "README.rust.md") { 27 | return; 28 | } 29 | if (file === "README.browser.md") { 30 | file = "README.md"; 31 | } 32 | acc[file] = { source: fs.readFileSync(fullPath, "utf-8") }; 33 | } 34 | }); 35 | } 36 | 37 | function prepareFilesystem({ filepath }: { filepath: string }): VirtualFs { 38 | const workspaceRoot = path.join(import.meta.dirname, "..", "..", ".."); 39 | const targetDir = filepath.startsWith("/") 40 | ? filepath 41 | : path.join(workspaceRoot, filepath); 42 | if (!fs.existsSync(targetDir)) { 43 | throw new Error(`Path ${filepath} does not exist`); 44 | } 45 | const virtualFs: VirtualFs = { dir: {} }; 46 | makeVirtualFs(targetDir, virtualFs.dir); 47 | return virtualFs; 48 | } 49 | 50 | function assertPathIsString(path: string): asserts path is string { 51 | if (typeof path !== "string") { 52 | throw new Error("Path must be a string"); 53 | } 54 | } 55 | 56 | function template(data: VirtualFs): string { 57 | return `// THIS FILE IS GENERATED BY THE prepareVirtualFs COMMAND, DO NOT EDIT IT MANUALLY 58 | 59 | // It is meant to be used for mounting a virtual filesystem in the browser 60 | // interacting with @bytecodealliance/preview2-shim/filesystem , the shim for wasi:filesystem 61 | // 62 | // The \`fs\` calls like \`read\`, \`readDir\` ... in rust or other languages will be redirected to this virtual filesystem 63 | // and will interact as if the filesystem was a real one. 64 | 65 | export function makeVirtualFs() { return ${JSON.stringify(data, null, 2)}; }`; 66 | } 67 | 68 | function run() { 69 | program 70 | .description("Prepare wasm files for the web host") 71 | .requiredOption("-p, --path ", "Path to the filesystem to prepare") 72 | .option("-f, --format ", "Format to output the filesystem", "json") 73 | .action((options) => { 74 | const { path: filepath } = options; 75 | assertPathIsString(filepath); 76 | const virtualFs = prepareFilesystem({ filepath }); 77 | if (options.format === "json") { 78 | console.log(JSON.stringify(virtualFs, null, 2)); 79 | } else if (options.format === "ts") { 80 | console.log(template(virtualFs)); 81 | } 82 | }); 83 | 84 | program.parse(); 85 | } 86 | 87 | run(); 88 | -------------------------------------------------------------------------------- /packages/web-host/src/App.tsx: -------------------------------------------------------------------------------- 1 | // @ts-ignore -- private API 2 | import { _setFileData } from "@bytecodealliance/preview2-shim/filesystem"; 3 | import { useEffect } from "react"; 4 | import { HomePage } from "./components/HomePage"; 5 | import { ReplPage } from "./components/ReplPage"; 6 | import { useHashNavigation } from "./hooks/navigation"; 7 | import { WasmProvider } from "./hooks/wasm"; 8 | import { cn } from "./utils/css"; 9 | import { makeVirtualFs } from "./wasm/virtualFs"; 10 | 11 | interface HeaderProps extends React.HTMLAttributes { 12 | navigateToHome: () => void; 13 | } 14 | 15 | const Header = (props: HeaderProps) => { 16 | const { className, navigateToHome, ...rest } = props; 17 | return ( 18 |
25 |
26 | 37 |

38 | WebAssembly Component Model Experiments 39 |

40 |
41 |
42 | ); 43 | }; 44 | 45 | const Footer = (props: React.HTMLAttributes) => { 46 | const { className, ...rest } = props; 47 | return ( 48 | 67 | ); 68 | }; 69 | 70 | function App() { 71 | const { currentPage, navigateToRepl, navigateToHome } = useHashNavigation(); 72 | 73 | useEffect(() => { 74 | _setFileData(makeVirtualFs()); 75 | }, []); 76 | 77 | return ( 78 | 79 |
80 |
85 |
86 | {currentPage === "home" ? ( 87 | 88 | ) : ( 89 | 90 | )} 91 |
92 |
93 |
94 |
95 | ); 96 | } 97 | 98 | export default App; 99 | -------------------------------------------------------------------------------- /packages/web-host/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web-host", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "description": "Web host for Terminal REPL with plugin system (using WebAssembly Component Model)", 7 | "scripts": { 8 | "predev": "npm run prebuild", 9 | "predev:debug": "npm run wit-types && just build-plugins && just build-repl-logic-guest && just web-host-prepare-wasm-files-debug && npm run wasm:transpile", 10 | "dev": "vite --host", 11 | "dev:debug": "vite --host", 12 | "prebuild": "npm run wit-types && just build-plugins-release && just build-repl-logic-guest-release && just web-host-prepare-wasm-files-release && npm run wasm:transpile && npm run prepareVirtualFs", 13 | "build": "tsc -b && vite build", 14 | "preview": "vite preview --host", 15 | "lint": "biome check .", 16 | "lint:fix": "biome check --write .", 17 | "list-public-wasm-names": "ls -1 public/plugins/*.wasm|sed 's/.wasm//'|sed 's#public/plugins/##'", 18 | "typecheck": "tsc --noEmit -p tsconfig.app.json", 19 | "prepareVirtualFs": "node --experimental-strip-types --no-warnings ./clis/prepareFilesystem.ts --path fixtures/filesystem --format ts > src/wasm/virtualFs.ts; biome format --write ./src/wasm/virtualFs.ts", 20 | "test:e2e:all": "playwright test", 21 | "test:e2e:ui": "playwright test --ui", 22 | "test:e2e:all:preview": "BASE_URL=http://localhost:4173/webassembly-component-model-experiments npm run test:e2e:all", 23 | "test:e2e:ui:preview": "BASE_URL=http://localhost:4173/webassembly-component-model-experiments npm run test:e2e:ui", 24 | "test:e2e:report": "playwright show-report", 25 | "test:e2e:like-in-ci": "CI=true GITHUB_ACTIONS=true WAIT_FOR_SERVER_AT_URL=http://localhost:4173/webassembly-component-model-experiments/ npm run test:e2e:all:preview", 26 | "wasm:transpile": "npm run list-public-wasm-names --silent|xargs -I {} jco transpile --no-nodejs-compat --no-namespaced-exports public/plugins/{}.wasm -o ./src/wasm/generated/{}/transpiled", 27 | "wit-types:host-api": "jco types --world-name host-api --out-dir ./src/types/generated ../../crates/pluginlab/wit", 28 | "wit-types:plugin-api": "jco types --world-name plugin-api --out-dir ./src/types/generated ../../crates/pluginlab/wit", 29 | "wit-types": "npm run wit-types:clean && npm run wit-types:host-api && npm run wit-types:plugin-api && biome format --write ./src/types/generated", 30 | "wit-types:clean": "rm -rf ./src/types/generated" 31 | }, 32 | "dependencies": { 33 | "clsx": "^2.1.1", 34 | "lucide-react": "^0.525.0", 35 | "qrcode.react": "^4.2.0", 36 | "react": "^19.1.0", 37 | "react-dom": "^19.1.0", 38 | "tailwind-merge": "^3.3.1", 39 | "zustand": "^5.0.6" 40 | }, 41 | "devDependencies": { 42 | "@bytecodealliance/jco": "^1.13.2", 43 | "@playwright/test": "^1.54.1", 44 | "@tailwindcss/vite": "^4.1.11", 45 | "@types/node": "^24.0.4", 46 | "@types/react": "^19.1.8", 47 | "@types/react-dom": "^19.1.6", 48 | "@vitejs/plugin-react": "^4.5.2", 49 | "commander": "^12.1.0", 50 | "globals": "^16.2.0", 51 | "tailwindcss": "^4.1.11", 52 | "typescript": "~5.8.3", 53 | "typescript-eslint": "^8.34.1", 54 | "vite": "^7.0.0" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /c_modules/plugin-echo/component.c: -------------------------------------------------------------------------------- 1 | #include "plugin_api.h" 2 | #include 3 | #include 4 | 5 | /* 6 | * C implementation of the echo plugin 7 | * 8 | * IMPLEMENTATION SOURCE: 9 | * This implements the same interface as the Rust version in crates/plugin-echo/src/lib.rs 10 | * The function signatures are generated from the WIT interface by wit-bindgen: 11 | * - exports_repl_api_plugin_name() corresponds to fn name() -> String 12 | * - exports_repl_api_plugin_man() corresponds to fn man() -> String 13 | * - exports_repl_api_plugin_run() corresponds to fn run(payload: String) -> Result 14 | * 15 | * MEMORY MANAGEMENT: 16 | * - Input parameters (like payload) are owned by the runtime - DO NOT free them 17 | * - Output parameters (like ret) are populated by us, freed by the runtime 18 | * - plugin_api_string_dup() allocates new memory for string copies 19 | * - The generated _free functions handle cleanup automatically 20 | * - No explicit free() calls needed in plugin code 21 | */ 22 | 23 | void exports_repl_api_plugin_name(plugin_api_string_t *ret) 24 | { 25 | // Populate ret with "echo" as the plugin name 26 | // plugin_api_string_dup() allocates new memory and copies the string 27 | plugin_api_string_dup(ret, "echoc"); 28 | } 29 | 30 | void exports_repl_api_plugin_man(plugin_api_string_t *ret) 31 | { 32 | // Populate ret with the manual text for the echo command 33 | // plugin_api_string_dup() allocates new memory and copies the string 34 | const char *man_text = 35 | "\n" 36 | "NAME\n" 37 | " echoc - Echo a message (built with C)\n" 38 | "\n" 39 | "USAGE\n" 40 | " echoc \n" 41 | "\n" 42 | "DESCRIPTION\n" 43 | " Echo a message.\n" 44 | "\n" 45 | " "; 46 | plugin_api_string_dup(ret, man_text); 47 | } 48 | 49 | bool exports_repl_api_plugin_run(plugin_api_string_t *payload, exports_repl_api_plugin_plugin_response_t *ret) 50 | { 51 | // Set status to success (0 = success, 1 = error) 52 | ret->status = REPL_API_TRANSPORT_REPL_STATUS_SUCCESS; 53 | 54 | // Set stdout to contain the payload 55 | // is_some = true means the optional string has a value 56 | ret->stdout.is_some = true; 57 | 58 | // Create a properly null-terminated string from the payload 59 | // The payload has ptr and len, we need to ensure it's null-terminated 60 | char *temp_str = malloc(payload->len + 1); 61 | if (temp_str == NULL) 62 | { 63 | // Handle allocation failure 64 | ret->stdout.is_some = false; 65 | ret->stderr.is_some = false; 66 | return false; 67 | } 68 | 69 | // Copy the payload data and null-terminate it 70 | memcpy(temp_str, payload->ptr, payload->len); 71 | temp_str[payload->len] = '\0'; 72 | 73 | // Use plugin_api_string_dup to create the output string 74 | plugin_api_string_dup(&ret->stdout.val, temp_str); 75 | 76 | // Free our temporary string 77 | free(temp_str); 78 | 79 | // Set stderr to none (no error output) 80 | ret->stderr.is_some = false; 81 | 82 | // Return true for success (false would indicate an error) 83 | // This corresponds to Ok(response) in the Rust Result pattern 84 | return true; 85 | } 86 | -------------------------------------------------------------------------------- /scripts/prepare-wasm-files.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Prepare wasm files for the web host 4 | # Usage: ./scripts/prepare-wasm-files.sh --mode debug|release [--target-dir ] 5 | 6 | set -e 7 | 8 | usage() { 9 | echo "Usage: $0 --mode [--target-dir ]" 10 | echo " mode: debug or release" 11 | echo " target-dir: optional custom target directory (default: packages/web-host/public/plugins)" 12 | echo "" 13 | echo "Example:" 14 | echo " $0 --mode debug" 15 | echo " $0 --mode release" 16 | echo " $0 --mode debug --target-dir ./custom/plugins" 17 | exit 1 18 | } 19 | 20 | validate_mode() { 21 | local mode="$1" 22 | if [[ "$mode" != "debug" && "$mode" != "release" ]]; then 23 | echo "Error: --mode must be one of: debug, release." 24 | exit 1 25 | fi 26 | } 27 | 28 | prepare_wasm_files() { 29 | local mode="$1" 30 | local target_dir="$2" 31 | echo "Preparing wasm files for mode: $mode" 32 | 33 | local workspace_root="$(cd "$(dirname "$0")/.." && pwd)" 34 | 35 | mkdir -p "$target_dir" 36 | 37 | local wasm_files=( 38 | "target/wasm32-wasip1/$mode/plugin_echo.wasm" 39 | "target/wasm32-wasip1/$mode/plugin_greet.wasm" 40 | "target/wasm32-wasip1/$mode/plugin_ls.wasm" 41 | "target/wasm32-wasip1/$mode/plugin_cat.wasm" 42 | "target/wasm32-wasip1/$mode/plugin_weather.wasm" 43 | "target/wasm32-wasip1/$mode/plugin_tee.wasm" 44 | "c_modules/plugin-echo/plugin-echo-c.wasm" 45 | "go_modules/plugin-echo/plugin-echo-go.wasm" 46 | "target/wasm32-wasip1/$mode/repl_logic_guest.wasm" 47 | ) 48 | 49 | # Copy each wasm file 50 | for wasm_file in "${wasm_files[@]}"; do 51 | local copy_from="$workspace_root/$wasm_file" 52 | local copy_to="$target_dir/$(basename "$wasm_file")" 53 | 54 | if [[ ! -f "$copy_from" ]]; then 55 | echo "" 56 | echo "Failed to copy $copy_from to $copy_to" 57 | echo "" 58 | if [[ "$mode" == "debug" ]]; then 59 | echo "Please run the command: just build" 60 | else 61 | echo "Please run the command: just build-release" 62 | fi 63 | echo "" 64 | exit 1 65 | fi 66 | 67 | cp "$copy_from" "$copy_to" 68 | echo "Copied $copy_from to $copy_to" 69 | done 70 | } 71 | 72 | # Parse command line arguments 73 | MODE="" 74 | TARGET_DIR="" 75 | 76 | while [[ $# -gt 0 ]]; do 77 | case $1 in 78 | --mode) 79 | MODE="$2" 80 | shift 2 81 | ;; 82 | --target-dir) 83 | TARGET_DIR="$2" 84 | shift 2 85 | ;; 86 | -h|--help) 87 | usage 88 | ;; 89 | *) 90 | echo "Unknown option: $1" 91 | usage 92 | ;; 93 | esac 94 | done 95 | 96 | # Check if mode is provided 97 | if [[ -z "$MODE" ]]; then 98 | echo "Error: --mode is required" 99 | usage 100 | fi 101 | 102 | # Set default target directory if not provided 103 | if [[ -z "$TARGET_DIR" ]]; then 104 | WORKSPACE_ROOT="$(cd "$(dirname "$0")/.." && pwd)" 105 | TARGET_DIR="$WORKSPACE_ROOT/packages/web-host/public/plugins" 106 | fi 107 | 108 | # Validate mode and prepare files 109 | validate_mode "$MODE" 110 | prepare_wasm_files "$MODE" "$TARGET_DIR" 111 | -------------------------------------------------------------------------------- /crates/repl-logic-guest/src/parser.rs: -------------------------------------------------------------------------------- 1 | use crate::bindings::repl::api::transport; 2 | use crate::vars::ReplLogicVar; 3 | 4 | pub fn parse_line(line: &str, env_vars: &ReplLogicVar) -> transport::ParsedLine { 5 | // Split the line into command and arguments 6 | let parts: Vec<&str> = line.split_whitespace().collect(); 7 | 8 | if parts.is_empty() { 9 | return transport::ParsedLine { 10 | command: String::new(), 11 | payload: String::new(), 12 | }; 13 | } 14 | 15 | let command = parts[0].to_string(); 16 | let payload = if parts.len() > 1 { 17 | // Expand variables in the payload 18 | let raw_payload = parts[1..].join(" "); 19 | env_vars.expand_variables(&raw_payload) 20 | } else { 21 | String::new() 22 | }; 23 | 24 | transport::ParsedLine { command, payload } 25 | } 26 | 27 | #[cfg(test)] 28 | mod tests { 29 | use super::*; 30 | 31 | fn make_env_vars() -> ReplLogicVar { 32 | let mut env_vars = ReplLogicVar::new(); 33 | env_vars.set("HOME".to_string(), "/home/user".to_string()); 34 | env_vars.set("USER".to_string(), "john".to_string()); 35 | env_vars 36 | } 37 | 38 | #[test] 39 | fn basic_parse() { 40 | let env_vars = make_env_vars(); 41 | let result = parse_line("echo Hello, world!", &env_vars); 42 | assert_eq!(result.command, "echo".to_string()); 43 | assert_eq!(result.payload, "Hello, world!"); 44 | } 45 | 46 | #[test] 47 | fn parse_with_args() { 48 | let env_vars = make_env_vars(); 49 | let result = parse_line("echo Hello, world! -n", &env_vars); 50 | assert_eq!(result.command, "echo"); 51 | assert_eq!(result.payload, "Hello, world! -n"); 52 | } 53 | 54 | #[test] 55 | fn parse_with_variable_to_expand() { 56 | let env_vars = make_env_vars(); 57 | let result = parse_line("echo $HOME", &env_vars); 58 | assert_eq!(result.command, "echo"); 59 | assert_eq!(result.payload, "/home/user"); 60 | } 61 | 62 | #[test] 63 | fn parse_with_multiple_variables() { 64 | let env_vars = make_env_vars(); 65 | let result = parse_line("echo $HOME/$USER", &env_vars); 66 | assert_eq!(result.command, "echo"); 67 | assert_eq!(result.payload, "/home/user/john"); 68 | } 69 | 70 | #[test] 71 | fn parse_with_unknown_variable() { 72 | let env_vars = make_env_vars(); 73 | let result = parse_line("echo $UNKNOWN", &env_vars); 74 | assert_eq!(result.command, "echo"); 75 | assert_eq!(result.payload, ""); 76 | } 77 | 78 | #[test] 79 | fn parse_empty_line() { 80 | let env_vars = make_env_vars(); 81 | let result = parse_line("", &env_vars); 82 | assert_eq!(result.command, ""); 83 | assert_eq!(result.payload, ""); 84 | } 85 | 86 | #[test] 87 | fn parse_command_only() { 88 | let env_vars = make_env_vars(); 89 | let result = parse_line("ls", &env_vars); 90 | assert_eq!(result.command, "ls"); 91 | assert_eq!(result.payload, ""); 92 | } 93 | 94 | #[test] 95 | fn parse_export() { 96 | let env_vars = make_env_vars(); 97 | let result = parse_line("export FOO=BAR", &env_vars); 98 | assert_eq!(result.command, "export"); 99 | assert_eq!(result.payload, "FOO=BAR"); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /crates/plugin-tee/src/lib.rs: -------------------------------------------------------------------------------- 1 | #[allow(warnings)] 2 | mod bindings; 3 | 4 | use std::io::Write; 5 | 6 | use crate::bindings::exports::repl::api::plugin::Guest; 7 | use crate::bindings::repl::api::host_state_plugin; 8 | use crate::bindings::repl::api::transport; 9 | 10 | struct Component; 11 | 12 | impl Guest for Component { 13 | fn name() -> String { 14 | "tee".to_string() 15 | } 16 | 17 | fn man() -> String { 18 | r#" 19 | NAME 20 | tee - Copy $0 content to a file (built with Rust🦀) 21 | 22 | USAGE 23 | tee 24 | tee -a 25 | 26 | OPTIONS 27 | -a, --append Append to the file instead of overwriting it 28 | 29 | DESCRIPTION 30 | Copy $0 content to a file. 31 | 32 | "# 33 | .to_string() 34 | } 35 | 36 | fn run(payload: String) -> Result { 37 | match run_inner(payload) { 38 | Ok(content) => Ok(transport::PluginResponse { 39 | status: transport::ReplStatus::Success, 40 | stdout: Some(format!("{}", content)), 41 | stderr: None, 42 | }), 43 | Err(e) => { 44 | // e.kind() - verify if the error is a permission error 45 | return Ok(transport::PluginResponse { 46 | status: transport::ReplStatus::Error, 47 | stdout: None, 48 | stderr: Some(format!("{}", e)), 49 | }); 50 | } 51 | } 52 | } 53 | } 54 | 55 | fn run_inner(payload: String) -> Result { 56 | let is_append = payload.starts_with("-a") || payload.starts_with("--append"); 57 | let filepath = if is_append { 58 | let Some((_, filepath)) = payload.split_once(" ") else { 59 | return Err("Invalid arguments. Usage: tee or tee -a ".to_string()); 60 | }; 61 | filepath.to_string() 62 | } else { 63 | payload 64 | }; 65 | 66 | let content = host_state_plugin::get_repl_var("0").unwrap_or("".to_string()); 67 | let content_as_bytes = content.as_bytes(); 68 | 69 | if !is_append { 70 | let mut file = std::fs::File::create(&filepath) 71 | .map_err(|e| enhanced_error(e, format!("Failed to create file '{}'", filepath)))?; 72 | file.write_all(content_as_bytes) 73 | .map_err(|e| enhanced_error(e, format!("Failed to write to file '{}'", filepath)))?; 74 | return Ok(content); 75 | } else { 76 | let mut file = std::fs::File::options() 77 | .append(true) 78 | .open(&filepath) 79 | .map_err(|e| { 80 | enhanced_error( 81 | e, 82 | format!("Failed to open file in append mode '{}'", filepath), 83 | ) 84 | })?; 85 | // Add a newline before the content in append mode 86 | file.write_all(b"\n").map_err(|e| { 87 | enhanced_error(e, format!("Failed to write newline to file '{}'", filepath)) 88 | })?; 89 | file.write_all(content_as_bytes) 90 | .map_err(|e| enhanced_error(e, format!("Failed to write to file '{}'", filepath)))?; 91 | return Ok(content); 92 | } 93 | } 94 | 95 | fn enhanced_error(e: std::io::Error, more_info: String) -> String { 96 | format!("{} - {} - {}", e.kind(), more_info, e.to_string()) 97 | } 98 | 99 | bindings::export!(Component with_types_in bindings); 100 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/types/wasi-cli-command.d.ts: -------------------------------------------------------------------------------- 1 | // world wasi:cli/command@0.2.3 2 | export type * as WasiCliEnvironment023 from './interfaces/wasi-cli-environment.js'; // import wasi:cli/environment@0.2.3 3 | export type * as WasiCliExit023 from './interfaces/wasi-cli-exit.js'; // import wasi:cli/exit@0.2.3 4 | export type * as WasiCliStderr023 from './interfaces/wasi-cli-stderr.js'; // import wasi:cli/stderr@0.2.3 5 | export type * as WasiCliStdin023 from './interfaces/wasi-cli-stdin.js'; // import wasi:cli/stdin@0.2.3 6 | export type * as WasiCliStdout023 from './interfaces/wasi-cli-stdout.js'; // import wasi:cli/stdout@0.2.3 7 | export type * as WasiCliTerminalInput023 from './interfaces/wasi-cli-terminal-input.js'; // import wasi:cli/terminal-input@0.2.3 8 | export type * as WasiCliTerminalOutput023 from './interfaces/wasi-cli-terminal-output.js'; // import wasi:cli/terminal-output@0.2.3 9 | export type * as WasiCliTerminalStderr023 from './interfaces/wasi-cli-terminal-stderr.js'; // import wasi:cli/terminal-stderr@0.2.3 10 | export type * as WasiCliTerminalStdin023 from './interfaces/wasi-cli-terminal-stdin.js'; // import wasi:cli/terminal-stdin@0.2.3 11 | export type * as WasiCliTerminalStdout023 from './interfaces/wasi-cli-terminal-stdout.js'; // import wasi:cli/terminal-stdout@0.2.3 12 | export type * as WasiClocksMonotonicClock023 from './interfaces/wasi-clocks-monotonic-clock.js'; // import wasi:clocks/monotonic-clock@0.2.3 13 | export type * as WasiClocksWallClock023 from './interfaces/wasi-clocks-wall-clock.js'; // import wasi:clocks/wall-clock@0.2.3 14 | export type * as WasiFilesystemPreopens023 from './interfaces/wasi-filesystem-preopens.js'; // import wasi:filesystem/preopens@0.2.3 15 | export type * as WasiFilesystemTypes023 from './interfaces/wasi-filesystem-types.js'; // import wasi:filesystem/types@0.2.3 16 | export type * as WasiIoError023 from './interfaces/wasi-io-error.js'; // import wasi:io/error@0.2.3 17 | export type * as WasiIoPoll023 from './interfaces/wasi-io-poll.js'; // import wasi:io/poll@0.2.3 18 | export type * as WasiIoStreams023 from './interfaces/wasi-io-streams.js'; // import wasi:io/streams@0.2.3 19 | export type * as WasiRandomInsecureSeed023 from './interfaces/wasi-random-insecure-seed.js'; // import wasi:random/insecure-seed@0.2.3 20 | export type * as WasiRandomInsecure023 from './interfaces/wasi-random-insecure.js'; // import wasi:random/insecure@0.2.3 21 | export type * as WasiRandomRandom023 from './interfaces/wasi-random-random.js'; // import wasi:random/random@0.2.3 22 | export type * as WasiSocketsInstanceNetwork023 from './interfaces/wasi-sockets-instance-network.js'; // import wasi:sockets/instance-network@0.2.3 23 | export type * as WasiSocketsIpNameLookup023 from './interfaces/wasi-sockets-ip-name-lookup.js'; // import wasi:sockets/ip-name-lookup@0.2.3 24 | export type * as WasiSocketsNetwork023 from './interfaces/wasi-sockets-network.js'; // import wasi:sockets/network@0.2.3 25 | export type * as WasiSocketsTcpCreateSocket023 from './interfaces/wasi-sockets-tcp-create-socket.js'; // import wasi:sockets/tcp-create-socket@0.2.3 26 | export type * as WasiSocketsTcp023 from './interfaces/wasi-sockets-tcp.js'; // import wasi:sockets/tcp@0.2.3 27 | export type * as WasiSocketsUdpCreateSocket023 from './interfaces/wasi-sockets-udp-create-socket.js'; // import wasi:sockets/udp-create-socket@0.2.3 28 | export type * as WasiSocketsUdp023 from './interfaces/wasi-sockets-udp.js'; // import wasi:sockets/udp@0.2.3 29 | export * as run from './interfaces/wasi-cli-run.js'; // export wasi:cli/run@0.2.3 30 | -------------------------------------------------------------------------------- /packages/web-host/article.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | WebAssembly Component Model - Building a plugin system 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 54 | 55 | 56 | 57 |

🧩 WebAssembly Component Model - Building a plugin system

58 | WebAssembly Component Model - Building a plugin system 59 |

📖 Read the article

60 |
61 | 62 | 63 | -------------------------------------------------------------------------------- /crates/pluginlab/src/wasm_host.rs: -------------------------------------------------------------------------------- 1 | use crate::api::host_api::HostApi; 2 | use crate::api::plugin_api::PluginApi; 3 | use crate::cli::Cli; 4 | use crate::engine::WasmEngine; 5 | use crate::store::WasiState; 6 | use anyhow::Result; 7 | use std::collections::HashMap; 8 | use wasmtime::Store; 9 | use wasmtime_wasi::p2::WasiCtx; 10 | 11 | /// Represents a loaded plugin 12 | pub struct PluginInstance { 13 | pub plugin: PluginApi, 14 | } 15 | 16 | /// The main host that manages plugins and the REPL logic 17 | pub struct WasmHost { 18 | pub store: Store, 19 | pub repl_logic: Option, 20 | pub plugins: HashMap, 21 | } 22 | 23 | impl WasmHost { 24 | pub fn new(engine: &WasmEngine, wasi_ctx: WasiCtx, cli: &Cli) -> Self { 25 | Self { 26 | store: engine.create_store(wasi_ctx, &cli), 27 | plugins: HashMap::new(), 28 | repl_logic: None, 29 | } 30 | } 31 | 32 | pub async fn load_plugin(&mut self, engine: &WasmEngine, source: &str) -> Result<()> { 33 | let component = engine.load_component(source).await?; 34 | match engine.instantiate_plugin(&mut self.store, component).await { 35 | Ok(plugin) => { 36 | // Get the plugin name from the plugin itself 37 | let plugin_name = plugin.repl_api_plugin().call_name(&mut self.store).await?; 38 | self.plugins.insert(plugin_name, PluginInstance { plugin }); 39 | return Ok(()); 40 | } 41 | Err(e) => { 42 | if e.to_string() 43 | .contains("failed to convert function to given type") 44 | { 45 | let plugin_filename = source.split("/").last().unwrap(); 46 | let crate_version = env!("CARGO_PKG_VERSION"); 47 | eprintln!("[Host]"); 48 | eprintln!("[Host] Error: Failed instanciating {}", source); 49 | eprintln!( 50 | "[Host] You are most likely trying to use a plugin not compatible with pluginlab@{}", 51 | crate_version 52 | ); 53 | eprintln!("[Host]"); 54 | eprintln!("[Host] Try using a compatible version of the plugin by passing the following flag:"); 55 | eprintln!("[Host] --plugins https://github.com/topheman/webassembly-component-model-experiments/releases/download/pluginlab@{}/{}", crate_version, plugin_filename); 56 | eprintln!("[Host]"); 57 | eprintln!("[Host] If it doesn't work, make sure to use the latest version of pluginlab: `cargo install pluginlab`"); 58 | eprintln!("[Host]"); 59 | eprintln!("[Host] Original error:"); 60 | } 61 | return Err(e); 62 | } 63 | } 64 | } 65 | 66 | pub async fn load_repl_logic(&mut self, engine: &WasmEngine, source: &str) -> Result<()> { 67 | let component = engine.load_component(source).await?; 68 | let repl_logic = engine 69 | .instantiate_repl_logic(&mut self.store, component) 70 | .await?; 71 | self.repl_logic = Some(repl_logic); 72 | Ok(()) 73 | } 74 | 75 | #[allow(unused)] 76 | pub async fn load_repl_logic_from_bytes( 77 | &mut self, 78 | engine: &WasmEngine, 79 | bytes: &[u8], 80 | ) -> Result<()> { 81 | let component = engine.load_component_from_bytes(bytes)?; 82 | let repl_logic = engine 83 | .instantiate_repl_logic(&mut self.store, component) 84 | .await?; 85 | self.repl_logic = Some(repl_logic); 86 | Ok(()) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /crates/pluginlab/src/helpers.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::sync::{Arc, Mutex}; 3 | 4 | /// Handles setting exit status codes in REPL variables 5 | pub struct StatusHandler; 6 | 7 | impl StatusHandler { 8 | /// Set the exit status in the REPL variables 9 | pub fn set_exit_status(repl_vars: &mut Arc>>, success: bool) { 10 | let status = if success { "0" } else { "1" }; 11 | repl_vars 12 | .lock() 13 | .expect("Failed to acquire repl_vars lock") 14 | .insert("?".to_string(), status.to_string()); 15 | } 16 | } 17 | 18 | pub struct StdoutHandler; 19 | 20 | impl StdoutHandler { 21 | pub fn print_and_set_last_result( 22 | repl_vars: &mut Arc>>, 23 | result: String, 24 | ) { 25 | println!("{}", result); 26 | repl_vars 27 | .lock() 28 | .expect("Failed to acquire repl_vars lock") 29 | .insert("0".to_string(), result); 30 | } 31 | } 32 | 33 | pub fn extract_hostname(url: &str) -> String { 34 | let url = url.trim(); 35 | let url = url.trim_start_matches("http://"); 36 | let url = url.trim_start_matches("https://"); 37 | 38 | // Find the first occurrence of '/', '?', or '#' to get just the hostname 39 | let hostname = if let Some(pos) = url.find(|c| c == '/' || c == '?' || c == '#') { 40 | &url[..pos] 41 | } else { 42 | url 43 | }; 44 | 45 | // Remove trailing slash if present 46 | let hostname = hostname.trim_end_matches('/'); 47 | 48 | hostname.to_string() 49 | } 50 | 51 | #[cfg(test)] 52 | mod tests { 53 | use super::*; 54 | 55 | #[test] 56 | fn test_extract_hostname() { 57 | assert_eq!(extract_hostname("https://google.com"), "google.com"); 58 | assert_eq!(extract_hostname("https://google.com/"), "google.com"); 59 | assert_eq!(extract_hostname("https://google.com/test"), "google.com"); 60 | assert_eq!(extract_hostname("https://google.com/test/"), "google.com"); 61 | assert_eq!( 62 | extract_hostname("https://google.com/test/test"), 63 | "google.com" 64 | ); 65 | assert_eq!( 66 | extract_hostname("https://google.com/test/test/"), 67 | "google.com" 68 | ); 69 | assert_eq!( 70 | extract_hostname("https://google.com/test/test/test"), 71 | "google.com" 72 | ); 73 | assert_eq!( 74 | extract_hostname("https://google.com/test/test/test/"), 75 | "google.com" 76 | ); 77 | assert_eq!( 78 | extract_hostname("https://google.com/test/test/test/test"), 79 | "google.com" 80 | ); 81 | assert_eq!( 82 | extract_hostname("https://google.com?test=test"), 83 | "google.com" 84 | ); 85 | assert_eq!(extract_hostname("https://google.com#test"), "google.com"); 86 | assert_eq!(extract_hostname("https://192.168.1.10"), "192.168.1.10"); 87 | assert_eq!(extract_hostname("https://192.168.1.10/"), "192.168.1.10"); 88 | assert_eq!( 89 | extract_hostname("https://192.168.1.10/test"), 90 | "192.168.1.10" 91 | ); 92 | assert_eq!( 93 | extract_hostname("https://192.168.1.10/test/"), 94 | "192.168.1.10" 95 | ); 96 | assert_eq!( 97 | extract_hostname("https://192.168.1.10/test/"), 98 | "192.168.1.10" 99 | ); 100 | assert_eq!( 101 | extract_hostname("https://192.168.1.10?test=test"), 102 | "192.168.1.10" 103 | ); 104 | assert_eq!( 105 | extract_hostname("https://192.168.1.10#test"), 106 | "192.168.1.10" 107 | ); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /scripts/prepare-wit-files.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Script to prepare WIT files by copying and making modifications 4 | # Usage: ./scripts/prepare-wit-files.sh -i -o -s 5 | 6 | set -e 7 | 8 | # Colors for output 9 | RED='\033[0;31m' 10 | GREEN='\033[0;32m' 11 | YELLOW='\033[1;33m' 12 | BLUE='\033[0;34m' 13 | NC='\033[0m' # No Color 14 | 15 | # Default values 16 | INPUT_DIR="" 17 | OUTPUT_DIR="" 18 | SEARCH_STRING="" 19 | SCRIPT_NAME=$(basename "$0") 20 | 21 | # Function to show usage 22 | show_usage() { 23 | echo -e "${BLUE}Usage:${NC}" 24 | echo -e " $SCRIPT_NAME -i -o -s " 25 | echo "" 26 | echo -e "${BLUE}Options:${NC}" 27 | echo -e " -i Input directory containing .wit files" 28 | echo -e " -o Output directory for processed .wit files" 29 | echo -e " -s Search string to uncomment (e.g., 'SPECIFIC TinyGo')" 30 | echo "" 31 | echo -e "${BLUE}Examples:${NC}" 32 | echo -e " $SCRIPT_NAME -i ./crates/pluginlab/wit -o ./go_modules/wit -s 'SPECIFIC TinyGo'" 33 | echo -e " $SCRIPT_NAME -i ./src/wit -o ./dist/wit -s 'SPECIFIC Rust'" 34 | echo "" 35 | echo -e "${BLUE}Description:${NC}" 36 | echo -e " Copies .wit files from input directory to output directory and:" 37 | echo -e " 1. Adds a header comment indicating the file is generated" 38 | echo -e " 2. Uncomments lines containing the specified search string" 39 | } 40 | 41 | # Parse command line arguments 42 | while getopts "i:o:s:h" opt; do 43 | case $opt in 44 | i) INPUT_DIR="$OPTARG" ;; 45 | o) OUTPUT_DIR="$OPTARG" ;; 46 | s) SEARCH_STRING="$OPTARG" ;; 47 | h) show_usage; exit 0 ;; 48 | \?) echo -e "${RED}Invalid option: -$OPTARG${NC}" >&2; show_usage; exit 1 ;; 49 | :) echo -e "${RED}Option -$OPTARG requires an argument.${NC}" >&2; show_usage; exit 1 ;; 50 | esac 51 | done 52 | 53 | # Check if required arguments are provided 54 | if [ -z "$INPUT_DIR" ] || [ -z "$OUTPUT_DIR" ] || [ -z "$SEARCH_STRING" ]; then 55 | echo -e "${RED}Error: Missing required arguments${NC}" 56 | show_usage 57 | exit 1 58 | fi 59 | 60 | # Check if input directory exists 61 | if [ ! -d "$INPUT_DIR" ]; then 62 | echo -e "${RED}Error: Input directory '$INPUT_DIR' does not exist${NC}" 63 | exit 1 64 | fi 65 | 66 | # Create output directory if it doesn't exist 67 | mkdir -p "$OUTPUT_DIR" 68 | 69 | echo -e "${GREEN}Preparing WIT files...${NC}" 70 | echo -e "${BLUE}Input:${NC} $INPUT_DIR" 71 | echo -e "${BLUE}Output:${NC} $OUTPUT_DIR" 72 | echo -e "${BLUE}Search:${NC} $SEARCH_STRING" 73 | echo "" 74 | 75 | # Copy all .wit files from input to output 76 | echo -e "${YELLOW}Copying WIT files...${NC}" 77 | cp "$INPUT_DIR"/*.wit "$OUTPUT_DIR/" 78 | 79 | # Process each .wit file 80 | for wit_file in "$OUTPUT_DIR"/*.wit; do 81 | if [ -f "$wit_file" ]; then 82 | filename=$(basename "$wit_file") 83 | echo -e "${YELLOW}Processing $filename...${NC}" 84 | 85 | # Create a temporary file 86 | temp_file=$(mktemp) 87 | 88 | # Add the header comment 89 | echo "// Code generated by \`$SCRIPT_NAME\`, from \`$INPUT_DIR/*.wit\`. DO NOT EDIT!" > "$temp_file" 90 | echo "" >> "$temp_file" 91 | 92 | # Add the rest of the file content, uncommenting lines with search string 93 | sed "s|^\\([[:space:]]*\\)//[[:space:]]*\\(.*$SEARCH_STRING.*\\)|\\1\\2|" "$wit_file" >> "$temp_file" 94 | 95 | # Replace the original file with the modified content 96 | mv "$temp_file" "$wit_file" 97 | 98 | echo -e "${GREEN}✓ Processed $filename${NC}" 99 | fi 100 | done 101 | 102 | echo "" 103 | echo -e "${GREEN}✓ All WIT files prepared successfully!${NC}" 104 | echo -e "${YELLOW}Files copied to: $OUTPUT_DIR${NC}" 105 | -------------------------------------------------------------------------------- /.github/workflows/rust-host.yml: -------------------------------------------------------------------------------- 1 | name: rust-host 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | build-and-test: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Set variables based on OS and architecture for just dl-wasi-sdk 9 | run: | 10 | if [ "${{ runner.arch }}" = "X64" ]; then 11 | echo "WASI_ARCH=x86_64" >> $GITHUB_ENV 12 | else 13 | echo "WASI_ARCH=arm64" >> $GITHUB_ENV 14 | fi 15 | if [ "${{ runner.os }}" = "Windows" ]; then 16 | echo "WASI_OS=windows" >> $GITHUB_ENV 17 | else 18 | echo "WASI_OS=linux" >> $GITHUB_ENV 19 | fi 20 | echo "WASI_VERSION_FULL=27.0" >> $GITHUB_ENV 21 | echo "WASI_VERSION=27" >> $GITHUB_ENV 22 | - uses: actions/checkout@v4 23 | - uses: actions-rs/toolchain@v1 24 | with: 25 | toolchain: stable 26 | target: wasm32-unknown-unknown 27 | override: true 28 | - uses: cargo-bins/cargo-binstall@main 29 | - uses: extractions/setup-just@v3 30 | - uses: actions/setup-go@v5 31 | with: 32 | go-version: '1.25' 33 | - name: Install TinyGo Compiler 34 | run: | 35 | if [ "${{ runner.arch }}" = "X64" ]; then 36 | wget https://github.com/tinygo-org/tinygo/releases/download/v0.39.0/tinygo_0.39.0_amd64.deb 37 | sudo dpkg -i tinygo_0.39.0_amd64.deb 38 | else 39 | wget https://github.com/tinygo-org/tinygo/releases/download/v0.39.0/tinygo_0.39.0_armhf.deb 40 | sudo dpkg -i tinygo_0.39.0_armhf.deb 41 | fi 42 | export PATH=$PATH:/usr/local/bin 43 | - name: Check TinyGo Compiler 44 | run: tinygo version 45 | - name: Install wkg 46 | run: cargo binstall wkg 47 | 48 | - name: Install cargo-component 49 | run: cargo binstall cargo-component@0.21.1 50 | - name: Install wasm-tools 51 | run: cargo binstall wasm-tools@1.235.0 52 | - name: Install wit-bindgen 53 | run: cargo install wit-bindgen-cli@0.44.0 54 | - name: Install wasi-sdk 55 | run: | 56 | mkdir c_deps 57 | just dl-wasi-sdk 58 | - name: Build 59 | run: just build 60 | - name: Test 61 | run: just test 62 | 63 | - name: Build plugins in release mode (for release-draft) 64 | if: github.ref_type == 'tag' 65 | run: just build-plugins-release 66 | - name: Prepare wasm files (for release-draft) 67 | if: github.ref_type == 'tag' 68 | run: ./scripts/prepare-wasm-files.sh --mode release --target-dir ./tmp/plugins 69 | - name: Cache wasm files (for release-draft) 70 | if: github.ref_type == 'tag' 71 | id: cache-wasm-files 72 | uses: actions/cache@v4 73 | with: 74 | path: ./tmp/plugins 75 | key: ${{ runner.os }}-wasm-files-${{ github.sha }} 76 | 77 | release-draft: 78 | if: github.ref_type == 'tag' 79 | permissions: 80 | contents: write 81 | runs-on: ubuntu-latest 82 | needs: build-and-test 83 | env: 84 | RELEASE_NAME: ${{ github.ref_name }} 85 | steps: 86 | - uses: actions/checkout@v4 87 | - name: Restore cached wasm files 88 | id: cache-wasm-files-restore 89 | uses: actions/cache/restore@v4 90 | with: 91 | path: ./tmp/plugins 92 | key: ${{ runner.os }}-wasm-files-${{ github.sha }} 93 | - name: Create release draft if it doesn't exist 94 | uses: topheman/create-release-if-not-exist@v1 95 | with: 96 | args: ${{ env.RELEASE_NAME }} --draft --generate-notes 97 | - name: Upload wasm files to release draft 98 | run: | 99 | gh release upload ${{ env.RELEASE_NAME }} ./tmp/plugins/*.wasm 100 | env: 101 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 102 | -------------------------------------------------------------------------------- /packages/web-host/tests/repl-logic.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@playwright/test"; 2 | import { fillAndSubmitCommand, getLastStd } from "./utils"; 3 | 4 | test("echo $0", async ({ page }) => { 5 | await page.goto("/#repl"); 6 | await fillAndSubmitCommand(page, "echo foo", { expectStdout: "foo" }); 7 | await fillAndSubmitCommand(page, "echo bar", { expectStdout: "bar" }); 8 | await fillAndSubmitCommand(page, "echo $0", { expectStdout: "bar" }); 9 | }); 10 | 11 | test("echo $ROOT/$USER", async ({ page }) => { 12 | await page.goto("/#repl"); 13 | await fillAndSubmitCommand(page, "echo $ROOT/$USER", { 14 | expectStdout: "/Users/Tophe", 15 | }); 16 | }); 17 | 18 | test("export USER=WebAssembly", async ({ page }) => { 19 | await page.goto("/#repl"); 20 | await fillAndSubmitCommand(page, "export USER=WebAssembly"); 21 | await fillAndSubmitCommand(page, "echo $ROOT/$USER", { 22 | expectStdout: "/Users/WebAssembly", 23 | }); 24 | }); 25 | 26 | test("echo $?", async ({ page }) => { 27 | await page.goto("/#repl"); 28 | await fillAndSubmitCommand(page, "echo $?", { expectStdout: "0" }); 29 | await fillAndSubmitCommand(page, "azertyuiop", { 30 | expectStderr: 31 | "Unknown command: azertyuiop. Try `help` to see available commands.", 32 | }); 33 | await fillAndSubmitCommand(page, "echo $?", { expectStdout: "1" }); 34 | await fillAndSubmitCommand(page, "echo $?", { expectStdout: "0" }); 35 | }); 36 | 37 | test("help", async ({ page }) => { 38 | await page.goto("/#repl"); 39 | await fillAndSubmitCommand(page, "help"); 40 | const stdout = await getLastStd(page, "stdout"); 41 | await expect(stdout).toContainText("help - Show the manual for a command"); 42 | }); 43 | 44 | test("man help -> should show the help", async ({ page }) => { 45 | await page.goto("/#repl"); 46 | await fillAndSubmitCommand(page, "help"); 47 | const stdout = await getLastStd(page, "stdout"); 48 | await expect(stdout).toContainText("help - Show the manual for a command"); 49 | }); 50 | 51 | test("man for reserved commands", async ({ page }) => { 52 | const reservedCommands = [ 53 | ["help", "help - Show the manual for a command"], 54 | ["export", "export - Export a variable to the environment"], 55 | [ 56 | "list-commands", 57 | "list-commands - List the plugins loaded in the host and the reserved commands (not overridable by plugins) included in the REPL logic.", 58 | ], 59 | ["man", "man - Show the manual for a command"], 60 | ]; 61 | await page.goto("/#repl"); 62 | for (const [command, partialManpage] of reservedCommands) { 63 | await fillAndSubmitCommand(page, `man ${command}`); 64 | const stdout = await getLastStd(page, "stdout"); 65 | await expect(stdout).toContainText(partialManpage); 66 | } 67 | }); 68 | 69 | test("list-commands", async ({ page }) => { 70 | await page.goto("/#repl"); 71 | await fillAndSubmitCommand(page, "list-commands"); 72 | const stdout = await getLastStd(page, "stdout"); 73 | await expect(stdout).toContainText( 74 | `cat plugin 75 | echo plugin 76 | echoc plugin 77 | echogo plugin 78 | export reserved 79 | greet plugin 80 | help reserved 81 | list-commands reserved 82 | ls plugin 83 | man reserved 84 | tee plugin 85 | weather plugin`, 86 | ); 87 | }); 88 | 89 | test("man", async ({ page }) => { 90 | await page.goto("/#repl"); 91 | await fillAndSubmitCommand(page, "man"); 92 | const stdout = await getLastStd(page, "stdout"); 93 | await expect(stdout).toContainText("man - Show the manual for a command"); 94 | await fillAndSubmitCommand(page, "man man"); 95 | const stdout2 = await getLastStd(page, "stdout"); 96 | await expect(stdout2).toContainText("man - Show the manual for a command"); 97 | }); 98 | 99 | test("man echo", async ({ page }) => { 100 | await page.goto("/#repl"); 101 | await fillAndSubmitCommand(page, "man echo"); 102 | const stdout = await getLastStd(page, "stdout"); 103 | await expect(stdout).toContainText("echo - Echo a message (built with Rust"); 104 | }); 105 | -------------------------------------------------------------------------------- /packages/web-host/overrides/@bytecodealliance/preview2-shim/lib/browser/cli.js: -------------------------------------------------------------------------------- 1 | import { _setCwd as fsSetCwd } from './filesystem.js'; 2 | import { streams } from './io.js'; 3 | const { InputStream, OutputStream } = streams; 4 | 5 | const symbolDispose = Symbol.dispose ?? Symbol.for('dispose'); 6 | 7 | let _env = [], _args = [], _cwd = "/"; 8 | export function _setEnv (envObj) { 9 | _env = Object.entries(envObj); 10 | } 11 | export function _setArgs (args) { 12 | _args = args; 13 | } 14 | 15 | export function _setCwd (cwd) { 16 | fsSetCwd(_cwd = cwd); 17 | } 18 | 19 | export const environment = { 20 | getEnvironment () { 21 | return _env; 22 | }, 23 | getArguments () { 24 | return _args; 25 | }, 26 | initialCwd () { 27 | return _cwd; 28 | } 29 | }; 30 | 31 | class ComponentExit extends Error { 32 | constructor(code) { 33 | super(`Component exited ${code === 0 ? 'successfully' : 'with error'}`); 34 | this.exitError = true; 35 | this.code = code; 36 | } 37 | } 38 | 39 | export const exit = { 40 | exit (status) { 41 | throw new ComponentExit(status.tag === 'err' ? 1 : 0); 42 | }, 43 | exitWithCode (code) { 44 | throw new ComponentExit(code); 45 | } 46 | }; 47 | 48 | /** 49 | * @param {import('../common/io.js').InputStreamHandler} handler 50 | */ 51 | export function _setStdin (handler) { 52 | stdinStream.handler = handler; 53 | } 54 | /** 55 | * @param {import('../common/io.js').OutputStreamHandler} handler 56 | */ 57 | export function _setStderr (handler) { 58 | stderrStream.handler = handler; 59 | } 60 | /** 61 | * @param {import('../common/io.js').OutputStreamHandler} handler 62 | */ 63 | export function _setStdout (handler) { 64 | stdoutStream.handler = handler; 65 | } 66 | 67 | const stdinStream = new InputStream({ 68 | blockingRead (_len) { 69 | // TODO 70 | }, 71 | subscribe () { 72 | // TODO 73 | }, 74 | [symbolDispose] () { 75 | // TODO 76 | } 77 | }); 78 | let textDecoder = new TextDecoder(); 79 | const stdoutStream = new OutputStream({ 80 | write (contents) { 81 | if (contents[contents.length - 1] == 10) { 82 | // console.log already appends a new line 83 | contents = contents.subarray(0, contents.length - 1); 84 | } 85 | console.log(textDecoder.decode(contents)); 86 | }, 87 | blockingFlush () { 88 | }, 89 | [symbolDispose] () { 90 | } 91 | }); 92 | const stderrStream = new OutputStream({ 93 | write (contents) { 94 | if (contents[contents.length - 1] == 10) { 95 | // console.error already appends a new line 96 | contents = contents.subarray(0, contents.length - 1); 97 | } 98 | console.error(textDecoder.decode(contents)); 99 | }, 100 | blockingFlush () { 101 | }, 102 | [symbolDispose] () { 103 | 104 | } 105 | }); 106 | 107 | export const stdin = { 108 | InputStream, 109 | getStdin () { 110 | return stdinStream; 111 | } 112 | }; 113 | 114 | export const stdout = { 115 | OutputStream, 116 | getStdout () { 117 | return stdoutStream; 118 | } 119 | }; 120 | 121 | export const stderr = { 122 | OutputStream, 123 | getStderr () { 124 | return stderrStream; 125 | } 126 | }; 127 | 128 | class TerminalInput {} 129 | class TerminalOutput {} 130 | 131 | const terminalStdoutInstance = new TerminalOutput(); 132 | const terminalStderrInstance = new TerminalOutput(); 133 | const terminalStdinInstance = new TerminalInput(); 134 | 135 | export const terminalInput = { 136 | TerminalInput 137 | }; 138 | 139 | export const terminalOutput = { 140 | TerminalOutput 141 | }; 142 | 143 | export const terminalStderr = { 144 | TerminalOutput, 145 | getTerminalStderr () { 146 | return terminalStderrInstance; 147 | } 148 | }; 149 | 150 | export const terminalStdin = { 151 | TerminalInput, 152 | getTerminalStdin () { 153 | return terminalStdinInstance; 154 | } 155 | }; 156 | 157 | export const terminalStdout = { 158 | TerminalOutput, 159 | getTerminalStdout () { 160 | return terminalStdoutInstance; 161 | } 162 | }; 163 | -------------------------------------------------------------------------------- /.github/workflows/web-host.yml: -------------------------------------------------------------------------------- 1 | name: web-host 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Set variables based on OS and architecture for just dl-wasi-sdk 9 | run: | 10 | if [ "${{ runner.arch }}" = "X64" ]; then 11 | echo "WASI_ARCH=x86_64" >> $GITHUB_ENV 12 | else 13 | echo "WASI_ARCH=arm64" >> $GITHUB_ENV 14 | fi 15 | if [ "${{ runner.os }}" = "Windows" ]; then 16 | echo "WASI_OS=windows" >> $GITHUB_ENV 17 | else 18 | echo "WASI_OS=linux" >> $GITHUB_ENV 19 | fi 20 | echo "WASI_VERSION_FULL=27.0" >> $GITHUB_ENV 21 | echo "WASI_VERSION=27" >> $GITHUB_ENV 22 | - uses: actions/checkout@v4 23 | - uses: actions-rs/toolchain@v1 24 | with: 25 | toolchain: stable 26 | target: wasm32-unknown-unknown 27 | override: true 28 | - uses: actions/setup-node@v4 29 | with: 30 | node-version-file: .nvmrc 31 | - uses: cargo-bins/cargo-binstall@main 32 | - uses: extractions/setup-just@v3 33 | - uses: actions/setup-go@v5 34 | with: 35 | go-version: '1.25' 36 | - name: Install TinyGo Compiler 37 | run: | 38 | if [ "${{ runner.arch }}" = "X64" ]; then 39 | wget https://github.com/tinygo-org/tinygo/releases/download/v0.39.0/tinygo_0.39.0_amd64.deb 40 | sudo dpkg -i tinygo_0.39.0_amd64.deb 41 | else 42 | wget https://github.com/tinygo-org/tinygo/releases/download/v0.39.0/tinygo_0.39.0_armhf.deb 43 | sudo dpkg -i tinygo_0.39.0_armhf.deb 44 | fi 45 | export PATH=$PATH:/usr/local/bin 46 | - name: Check TinyGo Compiler 47 | run: tinygo version 48 | - name: Install wkg 49 | run: cargo binstall wkg 50 | - name: Install cargo-component 51 | run: cargo binstall cargo-component@0.21.1 52 | - name: Install wasm-tools 53 | run: cargo binstall wasm-tools@1.235.0 54 | - name: Install wit-bindgen 55 | run: cargo install wit-bindgen-cli@0.44.0 56 | - name: Install wasi-sdk 57 | run: | 58 | mkdir c_deps 59 | just dl-wasi-sdk 60 | - name: Install JavaScript dependencies 61 | run: npm ci 62 | - name: Build 63 | run: npm run web-host:build 64 | - name: Install Playwright 65 | run: npx playwright install --with-deps 66 | working-directory: ./packages/web-host 67 | - name: e2e tests (playwright) 68 | run: WAIT_FOR_SERVER_AT_URL=http://localhost:4173/webassembly-component-model-experiments/ npm run test:e2e:all:preview 69 | - uses: actions/upload-artifact@v4 70 | if: ${{ !cancelled() }} 71 | with: 72 | name: playwright-report 73 | path: ./packages/web-host/playwright-report/ 74 | retention-days: 30 75 | - name: Cache build artifacts 76 | id: cache-build-www-host 77 | uses: actions/cache@v4 78 | with: 79 | path: ./packages/web-host/dist 80 | key: ${{ runner.os }}-build-${{ github.sha }} 81 | 82 | deploy: 83 | if: github.ref == 'refs/heads/master' 84 | permissions: 85 | contents: read 86 | pages: write 87 | id-token: write 88 | runs-on: ubuntu-latest 89 | needs: build 90 | steps: 91 | - name: Restore cached build artifacts 92 | id: cache-build-www-host-restore 93 | uses: actions/cache/restore@v4 94 | with: 95 | path: ./packages/web-host/dist 96 | key: ${{ runner.os }}-build-${{ github.sha }} 97 | - name: Configure GitHub Pages 98 | uses: actions/configure-pages@v5 99 | - name: Upload GitHub Pages artifact 100 | uses: actions/upload-pages-artifact@v3 101 | with: 102 | path: ./packages/web-host/dist 103 | - name: Deploy GitHub Pages 104 | id: deployment 105 | uses: actions/deploy-pages@v4 106 | 107 | -------------------------------------------------------------------------------- /packages/web-host/tests/utils.ts: -------------------------------------------------------------------------------- 1 | import { expect, type Locator, type Page } from "@playwright/test"; 2 | 3 | /** 4 | * Get the last std output of the given type 5 | * If expectContent is provided, it will retry to get the last std output until it matches the expected content 6 | */ 7 | export async function getLastStd( 8 | page: Page, 9 | type: "stdin" | "stdout" | "stderr", 10 | { 11 | expectContent, 12 | }: { 13 | expectContent?: string; 14 | } = {}, 15 | ) { 16 | const locator = await page.locator(`[data-stdtype='${type}']`).last(); 17 | if (expectContent) { 18 | const text = await locator.textContent(); 19 | if (text?.includes(expectContent)) { 20 | return locator; 21 | } 22 | // if no match, do a hard expect that will fail the test with a clear error message 23 | // Sorry you landed here, you will most likely have to add some `sleep()` in your code 🥲 24 | await expect(locator).toHaveText(expectContent); 25 | } 26 | return locator; 27 | } 28 | 29 | /** 30 | * Get the last std output of the given type after the given locator 31 | * This is useful to get the last std output after a command has been submitted 32 | * This ensures you don't have false positives when checking the last std output 33 | */ 34 | export async function getLastStdAfter( 35 | page: Page, 36 | type: "stdin" | "stdout" | "stderr", 37 | stdLocator: Locator, 38 | ) { 39 | const stdinIndex = await stdLocator.getAttribute("data-std-index"); 40 | return await page 41 | .locator(`[data-std-index='${stdinIndex}'] ~ [data-stdtype='${type}']`) 42 | .last(); 43 | } 44 | 45 | export async function sleep(ms?: number): Promise { 46 | const DEFAULT_DELAY = 200; // taking into account the default delay necessary in the CI 47 | return new Promise((resolve) => setTimeout(resolve, ms ?? DEFAULT_DELAY)); 48 | } 49 | 50 | /** 51 | * Fill the input with the command and submit it 52 | * Pass the expected stdin, stdout and stderr to check the results 53 | */ 54 | export async function fillAndSubmitCommand( 55 | page: Page, 56 | command: string, 57 | { 58 | expectStdin, 59 | expectStdout, 60 | expectStderr, 61 | afterSubmit, 62 | }: { 63 | expectStdin?: string; 64 | expectStdout?: string; 65 | expectStderr?: string; 66 | afterSubmit?: () => Promise; 67 | } = {}, 68 | ) { 69 | const expectedStdin = expectStdin ?? command; 70 | const input = await page.getByPlaceholder("Type a command..."); 71 | await input.fill(command); 72 | await input.press("Enter"); 73 | if (afterSubmit) { 74 | await afterSubmit(); 75 | } 76 | const stdin = await getLastStd(page, "stdin", { 77 | expectContent: expectedStdin, 78 | }); 79 | if (expectStdout) { 80 | const stdout = await getLastStdAfter(page, "stdout", stdin); 81 | await expect(stdout).toHaveText(expectStdout); 82 | } 83 | if (expectStderr) { 84 | const stderr = await getLastStdAfter(page, "stderr", stdin); 85 | await expect(stderr).toHaveText(expectStderr); 86 | } 87 | } 88 | 89 | /** 90 | * Click the wand button and check the results 91 | * Pass the expected stdin, stdout and stderr to check the results 92 | */ 93 | export async function clickWandButton( 94 | page: Page, 95 | command: string, 96 | { 97 | expectStdin, 98 | expectStdout, 99 | expectStderr, 100 | }: { 101 | expectStdin?: string; 102 | expectStdout?: string; 103 | expectStderr?: string; 104 | } = {}, 105 | ) { 106 | const expectedStdin = expectStdin ?? command; 107 | await page.getByTitle("Run example command").click({ force: true }); 108 | const input = await page.getByPlaceholder("Type a command..."); 109 | await expect(input).toHaveValue(expectedStdin); 110 | const stdin = await getLastStd(page, "stdin", { 111 | expectContent: expectedStdin, 112 | }); 113 | if (expectStdout) { 114 | const stdout = await getLastStdAfter(page, "stdout", stdin); 115 | await expect(stdout).toHaveText(expectStdout); 116 | } 117 | if (expectStderr) { 118 | const stderr = await getLastStd(page, "stderr"); 119 | await expect(stderr).toHaveText(expectStderr); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /go_modules/plugin-echo/go.sum: -------------------------------------------------------------------------------- 1 | github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= 2 | github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU5CAUmr9zpesgbU6SWc8/B4mflAE4= 7 | github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= 8 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 9 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 10 | github.com/olareg/olareg v0.1.2 h1:75G8X6E9FUlzL/CSjgFcYfMgNzlc7CxULpUUNsZBIvI= 11 | github.com/olareg/olareg v0.1.2/go.mod h1:TWs+N6pO1S4bdB6eerzUm/ITRQ6kw91mVf9ZYeGtw+Y= 12 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 13 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 14 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 15 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 16 | github.com/regclient/regclient v0.8.3 h1:AFAPu/vmOYGyY22AIgzdBUKbzH+83lEpRioRYJ/reCs= 17 | github.com/regclient/regclient v0.8.3/go.mod h1:gjQh5uBVZoo/CngchghtQh9Hx81HOMKRRDd5WPcPkbk= 18 | github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= 19 | github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= 20 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 21 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 22 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 23 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 24 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 25 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 26 | github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= 27 | github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= 28 | github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= 29 | github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= 30 | github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I= 31 | github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= 32 | go.bytecodealliance.org v0.7.0 h1:CTJ1eb5kFhBKHw1/xycxxz4SmVWNKXYHhrA78oLNXhY= 33 | go.bytecodealliance.org v0.7.0/go.mod h1:PCLMft5yTQsHT9oNPWlq0I6Qdmo6THvdky2AZHjNUkA= 34 | go.bytecodealliance.org/cm v0.3.0 h1:VhV+4vjZPUGCozCg9+up+FNL3YU6XR+XKghk7kQ0vFc= 35 | go.bytecodealliance.org/cm v0.3.0/go.mod h1:JD5vtVNZv7sBoQQkvBvAAVKJPhR/bqBH7yYXTItMfZI= 36 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 37 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 38 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 39 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 40 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 41 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 42 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 43 | golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= 44 | golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= 45 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 46 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 47 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 48 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 49 | -------------------------------------------------------------------------------- /packages/web-host/src/hooks/replLogic.ts: -------------------------------------------------------------------------------- 1 | import { setReplVar } from "repl:api/host-state"; 2 | import { useMemo, useState } from "react"; 3 | import type { ReplHistoryEntry, ReplStatus } from "../types"; 4 | import type { WasmEngine } from "../wasm/engine"; 5 | import { useReplHistory } from "./replHistory"; 6 | 7 | function setExitStatusAnd$0(status: ReplStatus, stdout?: string) { 8 | if (status === "success") { 9 | setReplVar({ key: "?", value: "0" }); 10 | } else { 11 | setReplVar({ key: "?", value: "1" }); 12 | } 13 | if (stdout) { 14 | setReplVar({ key: "0", value: stdout }); 15 | } 16 | } 17 | 18 | function makeReplLogicHandler({ 19 | engine, 20 | setCommandRunning, 21 | addReplHistoryEntry, 22 | }: { 23 | engine: WasmEngine; 24 | setCommandRunning: (running: boolean) => void; 25 | addReplHistoryEntry: (entry: ReplHistoryEntry) => void; 26 | }) { 27 | return function handleInput(input: string) { 28 | addReplHistoryEntry({ stdin: input }); 29 | const result = engine.getReplLogicGuest().replLogic.readline(input); 30 | 31 | // the result of the command is only parsed, it must be run 32 | if (result.tag === "to-run") { 33 | if (result.val.command === "") { 34 | return; 35 | } 36 | 37 | // a man command for plugins, we run it from the host 38 | if (result.val.command === "man") { 39 | const plugin = engine.getPlugin(result.val.payload); 40 | if (!plugin) { 41 | addReplHistoryEntry({ 42 | stderr: `Unknown command: ${result.val.payload}. Try \`help\` to see available commands.`, 43 | status: "error", 44 | }); 45 | setExitStatusAnd$0("error"); 46 | return; 47 | } 48 | const man = plugin.man(); 49 | addReplHistoryEntry({ 50 | stdout: man, 51 | status: "success", 52 | }); 53 | setExitStatusAnd$0("success", man); 54 | return; 55 | } 56 | 57 | // a plugin command, we run it from the host 58 | const plugin = engine.getPlugin(result.val.command); 59 | if (!plugin) { 60 | addReplHistoryEntry({ 61 | stderr: `Unknown command: ${result.val.command}. Try \`help\` to see available commands.`, 62 | status: "error", 63 | }); 64 | setExitStatusAnd$0("error"); 65 | return; 66 | } 67 | // we run the plugin command in a double requestAnimationFrame to defer 68 | // its execution to the next frame and let the user see `stdin` appear 69 | // in the history (the command output may take a while to appear) 70 | // 71 | // Didn't make it work with react transitions 72 | // 73 | // Note: all actions are sync for the moment. 74 | setCommandRunning(true); 75 | requestAnimationFrame(() => { 76 | requestAnimationFrame(() => { 77 | try { 78 | const pluginResult = plugin.run(result.val.payload); 79 | addReplHistoryEntry({ 80 | stdout: pluginResult.stdout, 81 | stderr: pluginResult.stderr, 82 | status: pluginResult.status, 83 | }); 84 | setExitStatusAnd$0(pluginResult.status, pluginResult.stdout); 85 | } catch (error) { 86 | console.error(error); 87 | addReplHistoryEntry({ 88 | stderr: `Error: ${error}`, 89 | status: "error", 90 | }); 91 | setExitStatusAnd$0("error"); 92 | } finally { 93 | setCommandRunning(false); 94 | } 95 | }); 96 | }); 97 | return; 98 | } 99 | 100 | // the result of the command is ready 101 | if (result.tag === "ready") { 102 | addReplHistoryEntry({ 103 | stdout: result.val.stdout, 104 | stderr: result.val.stderr, 105 | status: result.val.status, 106 | }); 107 | setExitStatusAnd$0(result.val.status, result.val.stdout); 108 | } 109 | }; 110 | } 111 | 112 | export function useReplLogic({ engine }: { engine: WasmEngine }) { 113 | const [commandRunning, setCommandRunning] = useState(false); 114 | const { addEntry: addReplHistoryEntry } = useReplHistory(); 115 | const handleInput = useMemo( 116 | () => 117 | makeReplLogicHandler({ engine, setCommandRunning, addReplHistoryEntry }), 118 | [engine, addReplHistoryEntry], 119 | ); 120 | 121 | return { handleInput, commandRunning }; 122 | } 123 | --------------------------------------------------------------------------------