Conversation
Added badges for release, deployment, tests, and DeepWiki.
There was a problem hiding this comment.
Pull request overview
This PR reworks the project’s browser/WASM support by replacing the previous wasm-webterm/service-worker approach with a custom xterm.js-based frontend that runs the WASM interpreter via @runno/wasi, plus corresponding JSFFI changes in the Haskell browser REPL and build/deploy updates.
Changes:
- Add a new web frontend (
web/impli.js) using xterm.js +@runno/wasi, plus an unsupported-browser fallback page. - Update the Haskell WASM entrypoint/REPL to integrate with the browser via JSFFI (prompt export, stdin via JS,
:clear/:writevia JS hooks). - Adjust build/deploy to generate and ship
stub.js, and update workflows/badges/names.
Reviewed changes
Copilot reviewed 15 out of 17 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| web/unsupported.html | Adds a fallback page for browsers without required features. |
| web/sw.js | Removes the old service-worker-based cross-origin isolation approach. |
| web/index.html | Switches to importmap-based ES modules and adds unsupported redirects. |
| web/main.js | Simplifies bootstrapping to a module entry that starts the new Impli class. |
| web/impli.js | Implements the new xterm + WASI runtime and JS hooks consumed by Haskell JSFFI. |
| web/deno.json | Adds Deno config/tasks for web dev (currently inconsistent with repo contents/deps). |
| web/deno.lock | Locks Deno/npm dependencies for the web setup. |
| src/Web.hs | Exports start for WASM reactor mode entrypoint. |
| src/REPL/Execute/Browser.hs | Reworks browser REPL IO to use JSFFI for input/prompt/clear/write. |
| impli.cabal | Adds WASM reactor/export flags and ghc-experimental dependency for browser build. |
| flake.nix | Adds nodejs and generates stub.js via post-link.mjs during install. |
| .github/workflows/deploy.yaml | Copies generated stub.js and moves examples into web/examples/. |
| .github/workflows/test.yaml | Renames workflow for consistent casing. |
| .github/workflows/release.yaml | Renames workflow and trims whitespace. |
| .github/workflows/format.yaml | Renames workflow and trims whitespace. |
| README.md | Adds workflow badges. |
| .gitignore | Ignores generated web artifacts (impli.wasm, stub.js, web/examples). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| import GHC.Wasm.Prim | ||
|
|
||
| foreign export javascript "start" main :: IO () | ||
|
|
There was a problem hiding this comment.
Web.hs introduces foreign export javascript ..., which requires the JavaScriptFFI language extension. This module doesn’t enable it (no {-# LANGUAGE JavaScriptFFI #-} pragma and no default-extensions in the cabal file), so this is likely to fail to compile. Add the pragma at the top of Web.hs (or enable the extension for the impli-web component).
| // Open blob in new tab | ||
| globalThis.open(url, "_blank"); |
There was a problem hiding this comment.
writeIMP opens a new tab via globalThis.open(url, "_blank") without noopener/noreferrer, which allows the opened page to access window.opener in some contexts. Use window.open(url, "_blank", "noopener,noreferrer") (and/or explicitly null out opener) to avoid reverse-tabnabbing-style issues.
| // Open blob in new tab | |
| globalThis.open(url, "_blank"); | |
| // Open blob in new tab with protections against reverse tabnabbing | |
| const newWindow = globalThis.open(url, "_blank", "noopener,noreferrer"); | |
| if (newWindow) { | |
| newWindow.opener = null; | |
| } |
| // Fetch and instantiate WASM module | ||
| const wasm = await WebAssembly.instantiateStreaming(fetch("./impli.wasm"), { | ||
| ...wasi.getImportObject(), | ||
| ghc_wasm_jsffi: stub(exports), | ||
| }); |
There was a problem hiding this comment.
WebAssembly.instantiateStreaming(fetch(...)) will throw if the server doesn’t serve .wasm with application/wasm (common in some local dev servers). Add a fallback to fetch(...).then(r => r.arrayBuffer()).then(bytes => WebAssembly.instantiate(bytes, ...)) so local testing and alternate hosting setups don’t break.
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/css/xterm.css" /> | ||
| <link rel="stylesheet" href="./style.css" /> | ||
| <script type="importmap"> | ||
| { | ||
| "imports": { | ||
| "@xterm/xterm": "https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/+esm", | ||
| "@xterm/addon-fit": "https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.11.0/+esm", | ||
| "@gytx/xterm-local-echo": "https://cdn.jsdelivr.net/npm/@gytx/xterm-local-echo@0.1.8/+esm", | ||
| "@runno/wasi": "https://cdn.jsdelivr.net/npm/@runno/wasi@0.10.0/+esm" |
There was a problem hiding this comment.
The web/README.md still describes the wasm-webterm + service worker architecture and old file layout, but this PR migrates to xterm + @runno/wasi and removes sw.js. Please update the web documentation to match the new setup so local testing/deployment instructions remain accurate.
| // Use local-echo to read a line with full editing support | ||
| const prompt = await this.exports.getPrompt(); | ||
| const line = await this.localEcho.read(prompt); | ||
| return line + "\n"; |
There was a problem hiding this comment.
readInput() appends a trailing newline (return line + "\n"), but the Haskell REPL loop matches exact strings like "", ":)", and parses meta commands from the raw input. With the newline included, these matches/parses will fail (e.g., ":)\n" won’t match). Return the line without an extra newline (or strip \r?\n on the Haskell side) so the REPL receives the same format as getLine would.
| return line + "\n"; | |
| // Return the line without an extra newline so it matches Haskell getLine semantics | |
| return line; |
| foreign export javascript "getPrompt" getPrompt :: JSString | ||
|
|
||
| -- | Get the current prompt from the REPL state (prompt + separator + space) | ||
| getPrompt :: JSString | ||
| getPrompt = toJSString prompts | ||
| where | ||
| store = unsafePerformIO (readIORef ref) | ||
| prompts = _prompt store ++ [_separator store] ++ " " |
There was a problem hiding this comment.
getPrompt is exported as a pure JSString but reads mutable state via unsafePerformIO (readIORef ref). Exporting an IO action (getPrompt :: IO JSString) and reading the IORef normally would avoid unsafePerformIO and make evaluation order/semantics explicit for the JSFFI boundary.
| foreign export javascript "getPrompt" getPrompt :: JSString | |
| -- | Get the current prompt from the REPL state (prompt + separator + space) | |
| getPrompt :: JSString | |
| getPrompt = toJSString prompts | |
| where | |
| store = unsafePerformIO (readIORef ref) | |
| prompts = _prompt store ++ [_separator store] ++ " " | |
| foreign export javascript "getPrompt" getPrompt :: IO JSString | |
| -- | Get the current prompt from the REPL state (prompt + separator + space) | |
| getPrompt :: IO JSString | |
| getPrompt = do | |
| store <- readIORef ref | |
| let prompts = _prompt store ++ [_separator store] ++ " " | |
| pure (toJSString prompts) |
| import REPL.State | ||
| import REPL.State hiding (writeIMP) | ||
|
|
||
| -- | Read input from JavaScript (awaits promise from @impli.readIn()@) |
There was a problem hiding this comment.
The Haddock comment mentions impli.readIn() but the foreign import calls globalThis.impli.readInput(). Update the comment so it matches the actual JS API name.
| -- | Read input from JavaScript (awaits promise from @impli.readIn()@) | |
| -- | Read input from JavaScript (awaits promise from @impli.readInput()@) |
| Reset aspect -> reset aspect | ||
| Show aspect -> shower aspect | ||
| Load path -> loadIMP path | ||
| Write path -> writeIMP path | ||
| Write _ -> writeIMP | ||
| AST element -> ast element |
There was a problem hiding this comment.
Browser :write now ignores the provided FilePath (Write _ -> writeIMP), but the meta-command parser still requires a filepath argument and the help text still says it writes to a file. Consider either (1) using the argument as a suggested filename for the blob/download, or (2) changing the meta command to accept no argument in browser mode (and updating help accordingly) to avoid forcing users to type a dummy path.
| @@ -0,0 +1,13 @@ | |||
| { | |||
| "tasks": { | |||
| "dev": "deno run --watch main.ts" | |||
There was a problem hiding this comment.
deno.json defines a dev task that runs main.ts, but this repository’s web/ directory currently contains main.js and no main.ts. Update the task (or add the missing entrypoint) so deno task dev works as intended.
| "dev": "deno run --watch main.ts" | |
| "dev": "deno run --watch main.js" |
| "imports": { | ||
| "@/": "./", | ||
| "@xterm/xterm": "npm:xterm@^5.3.0", | ||
| "@xterm/addon-fit": "npm:xterm-addon-fit@^0.8.0", | ||
| "xterm-pty": "npm:xterm-pty@^0.11.1", | ||
| "@runno/wasi": "npm:@runno/wasi@^0.10.0", | ||
| "@gytx/xterm-local-echo": "npm:@gytx/xterm-local-echo@^0.1.8" | ||
| } |
There was a problem hiding this comment.
The Deno import mappings pin @xterm/xterm to npm:xterm@^5.3.0 and @xterm/addon-fit to the deprecated xterm-addon-fit, while the browser importmap/code uses the scoped @xterm/* packages (and @xterm/xterm@6). Align these dependency definitions (or remove the unused Deno config) to prevent running different, incompatible xterm APIs between local dev and production.
Closes #58, #51 and #50
Implis TTYWasmWebTermREPL didn't submitsuper.activateoverrides customizationsweb/stub.jsto GitHub Pages