Skip to content

Web Support#65

Merged
bfeitknecht merged 24 commits intomasterfrom
echo-ffi
Feb 7, 2026
Merged

Web Support#65
bfeitknecht merged 24 commits intomasterfrom
echo-ffi

Conversation

@bfeitknecht
Copy link
Owner

Closes #58, #51 and #50

  • Try to hook into Implis TTY
  • Add workflow badges
  • Update workflow names
  • Rename release workflow
  • fix: WasmWebTerm REPL didn't submit
  • fix: perhaps super.activate overrides customizations
  • fix: typo lowercase l
  • fix: this should do it
  • Small restructure web/
  • Progress
  • Rework everything AGAIN
  • Proof of concept
  • Human lints
  • Always this standard input
  • Small things matter
  • Sketch out
  • Baby steps
  • God is alive
  • Holy moly
  • Example files mapped into WASI FS
  • Remove redundancy
  • Copy stub.js to GitHub Pages
  • Remove Prelude hide

Copilot AI review requested due to automatic review settings February 7, 2026 21:37
This was linked to issues Feb 7, 2026
@bfeitknecht bfeitknecht merged commit b530107 into master Feb 7, 2026
16 of 17 checks passed
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/:write via 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.

Comment on lines +19 to +22
import GHC.Wasm.Prim

foreign export javascript "start" main :: IO ()

Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +126 to +127
// Open blob in new tab
globalThis.open(url, "_blank");
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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;
}

Copilot uses AI. Check for mistakes.
Comment on lines +221 to +225
// Fetch and instantiate WASM module
const wasm = await WebAssembly.instantiateStreaming(fetch("./impli.wasm"), {
...wasi.getImportObject(),
ghc_wasm_jsffi: stub(exports),
});
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +8 to +16
<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"
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
// 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";
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
return line + "\n";
// Return the line without an extra newline so it matches Haskell getLine semantics
return line;

Copilot uses AI. Check for mistakes.
Comment on lines +50 to +57
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] ++ " "
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.
import REPL.State
import REPL.State hiding (writeIMP)

-- | Read input from JavaScript (awaits promise from @impli.readIn()@)
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
-- | Read input from JavaScript (awaits promise from @impli.readIn()@)
-- | Read input from JavaScript (awaits promise from @impli.readInput()@)

Copilot uses AI. Check for mistakes.
Comment on lines 128 to 132
Reset aspect -> reset aspect
Show aspect -> shower aspect
Load path -> loadIMP path
Write path -> writeIMP path
Write _ -> writeIMP
AST element -> ast element
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,13 @@
{
"tasks": {
"dev": "deno run --watch main.ts"
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
"dev": "deno run --watch main.ts"
"dev": "deno run --watch main.js"

Copilot uses AI. Check for mistakes.
Comment on lines +5 to +12
"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"
}
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WASM cannot open files Rework WASM REPL IO Use JS FFI for IO meta-commands

1 participant