CPL WebAssembly Edition

February 5, 2026 · View on GitHub

This directory contains the source files for the WebAssembly version of the CPL (Categorical Programming Language) interpreter.

Build outputs go to the _site/ directory in the project root.

Try Online

Visit https://msakai.github.io/cpl/ and start using CPL immediately!

Commands to Try

cpl> edit
| right object 1 with !
| end object;
right object 1 defined
cpl> edit
| right object prod(a,b) with pair is
|   pi1: prod -> a
|   pi2: prod -> b
| end object;
right object prod(+,+) defined
cpl> edit
| right object exp(a,b) with curry is
|   eval: prod(exp,a) -> b
| end object;
right object exp(-,+) defined
cpl> edit
| left object nat with pr is
|   0: 1 -> nat
|   s: nat -> nat
| end object;
left object nat defined
cpl> show pair(pi2,eval)
pair(pi2,eval)
    : prod(exp(*a,*b),*a) -> prod(*a,*b)
cpl> let add=eval.prod(pr(curry(pi2), curry(s.eval)), I)
add : prod(nat,nat) -> nat  defined
cpl> simp add.pair(s.s.0, s.0)
s.s.s.0
    : 1 -> nat

Files

Source files (in this directory)

  • index.html - Web interface with xterm.js terminal emulator
  • cpl-terminal.js - JavaScript terminal controller and WASM loader
  • tutorial.css - Stylesheet for tutorial pages

Generated files (in _site/)

The build process outputs the following files to _site/:

  • cpl.wasm - Compiled CPL interpreter
  • cpl.js - JavaScript glue module (generated by post-link processing)
  • samples.js - Sample files module
  • Plus copies of the source files above

Building

The WASM binary is built using GHC's WebAssembly backend. To build it yourself:

# From the project root
./scripts/build-wasm.sh

Prerequisites

Install GHC WebAssembly cross-compiler (GHC 9.10.3 or later):

curl https://gitlab.haskell.org/haskell-wasm/ghc-wasm-meta/-/raw/master/bootstrap.sh | sh
source ~/.ghc-wasm/env

This provides wasm32-wasi-ghc and wasm32-wasi-cabal in PATH.

Build configuration:

wasm32-wasi-cabal configure -fWeb -f-Haskeline

To clean and rebuild:

./scripts/build-wasm.sh --clean

Testing Locally

To test the WASM version locally:

# Build WASM and web files
./scripts/build-wasm.sh

# Optionally build tutorial pages
./scripts/build-tutorial.sh

# Start local server
python3 -m http.server -d _site 8000

Then open http://localhost:8000 in your browser.

Deployment

Automatic Deployment

  • Trigger: Push to master branch
  • Build trigger: Also runs on tags, pull requests, and manual dispatch (build only, no deploy)
  • Workflow: .github/workflows/wasm-deploy.yaml
  • Destination: GitHub Pages (https://msakai.github.io/cpl/)
  • Artifacts: cpl.wasm, cpl.js, index.html, cpl-terminal.js, samples.js

Manual Deployment

# Build WASM and tutorial pages
./scripts/build-wasm.sh
./scripts/build-tutorial.sh

# Deploy files from _site/ directory to your web server

Browser Compatibility

Tested and working on:

  • Chrome/Chromium 90+
  • Firefox 90+
  • Safari 15+
  • Edge 90+

Architecture

Browser
  ├── xterm.js (terminal UI)
  └── cpl-terminal.js (FFI bridge)
       └── cpl.wasm (CPL interpreter)
            └── Haskell runtime

Console Abstraction

The Haskell code uses CPP macros to conditionally compile different Console implementations, allowing the same codebase to support multiple backends:

#if defined(USE_WEB_BACKEND)
  -- JavaScript FFI implementation (GHC.Wasm.Prim / JSString)
#elif defined(USE_HASKELINE_PACKAGE)
  -- Haskeline implementation
#else
  -- Plain IO implementation
#endif

For WASM, four JavaScript FFI functions are exposed:

  • terminal_initialize() - Initialize xterm.js terminal
  • terminal_printLine(text) - Print a line to terminal
  • terminal_readLine(prompt) - Read user input (async)
  • terminal_loadFile(filename) - Load a file by name or open file picker

JavaScript FFI Flow

  1. Haskell declares FFI imports/exports in src/Main.hs:

    foreign import javascript "terminal_readLine(\$1)"
      js_readLine :: JSString -> IO JSString
    
    foreign import javascript "terminal_loadFile(\$1)"
      js_loadFile :: JSString -> IO JSString
    
    foreign export javascript "hs_start" main :: IO ()
    
  2. JavaScript implements FFI functions in cpl-terminal.js:

    window.terminal_readLine = async (jsVal) => {
      const prompt = String(jsVal);
      const result = await cplTerminal.readLine(prompt);
      return result;
    };
    
    window.terminal_loadFile = async (jsVal) => {
      const filename = String(jsVal).trim();
      if (filename) {
        // Look up built-in sample files
        if (sampleFiles[filename] !== undefined) {
          return sampleFiles[filename];
        }
        throw new Error(`File not found: ${filename}`);
      }
      // No filename: open file picker dialog
      ...
    };
    
  3. JSFFI glue code (cpl.js) connects them at instantiation using a knot-tying pattern:

    const jsffiModule = await import('./cpl.js');
    const __exports = {};
    const jsffi = jsffiModule.default(__exports);
    
    const { instance } = await WebAssembly.instantiate(wasmBytes, {
      ghc_wasm_jsffi: jsffi,
      wasi_snapshot_preview1: wasi.wasiImport,
    });
    Object.assign(__exports, instance.exports);
    

WASI Integration

The browser environment requires a WASI shim (@bjorn3/browser_wasi_shim) to satisfy the WASI snapshot preview1 imports that GHC's WASM backend emits:

import { WASI, OpenFile, File, ConsoleStdout } from '@bjorn3/browser_wasi_shim';

const fds = [
  new OpenFile(new File([])),                                       // stdin
  ConsoleStdout.lineBuffered(msg => console.log(`[CPL] ${msg}`)),   // stdout
  ConsoleStdout.lineBuffered(msg => console.warn(`[CPL] ${msg}`)),  // stderr
];
const wasi = new WASI([], [], fds);

The WASI reactor is initialized before the Haskell RTS:

wasi.initialize(instance);   // calls _initialize
instance.exports.hs_init();  // init Haskell RTS
instance.exports.hs_start(); // run main (exported via foreign export)

JavaScript Side (cpl-terminal.js)

The JavaScript side provides:

  • CPLTerminal class - Manages xterm.js terminal instance
  • Keyboard handling - Enter, Backspace, Ctrl+C, Ctrl+D, Tab
  • WASM loading - Fetches and instantiates WASM module
  • FFI bindings - Connects Haskell to JavaScript

Build Configuration (CPL.cabal)

The WASM flag in CPL.cabal enables WebAssembly-specific build options:

Flag Web
  Description: Build for browser environment with WebAssembly + JavaScript FFI
  Default: False
  Manual: True

When enabled:

  • Sets -DUSE_WEB_BACKEND CPP flag
  • Uses -no-hs-main (no standard main entry point)
  • Exports hs_init and hs_start symbols
  • Enables JavaScriptFFI extension for GHC 9.10+
  • Adds ghc-experimental dependency for GHC.Wasm.Prim

Post-link Processing

GHC's WASM backend generates a post-link.mjs tool that extracts JSFFI information from the compiled .wasm binary and produces a JavaScript glue module (cpl.js). This glue code implements the knot-tying pattern required for bidirectional FFI:

node "$LIBDIR/post-link.mjs" -i _site/cpl.wasm -o _site/cpl.js

Build Systems

Two parallel build systems are maintained:

  1. Stack (for native builds)

    • Uses stack.yaml
    • Default for development
    • Supports haskeline
  2. Cabal (for both native and WASM builds)

    • Uses CPL.cabal with flags
    • WASM builds: wasm32-wasi-cabal configure -fWeb
    • Native builds: cabal configure

Known Limitations

  • Command history - Basic line editing only
  • Tab completion - Not implemented
  • Multi-line paste - May have issues with large pastes

These features may be added in future versions.

Troubleshooting

WASM module fails to load

Check browser console for errors. Common issues:

  • Missing or incorrect MIME type for .wasm files
  • CORS issues (must serve from web server, not file://)
  • Browser doesn't support WebAssembly

Terminal not responding

  • Ensure JavaScript is enabled
  • Check browser console for errors
  • Try refreshing the page

Build errors

If ./scripts/build-wasm.sh fails:

  • Verify GHC WASM toolchain is installed correctly
  • Check that wasm32-wasi-ghc and wasm32-wasi-cabal are in PATH
  • Try ./scripts/build-wasm.sh --clean to clean build artifacts

Performance

The WASM version has similar performance to the native version for most operations, with slight overhead for JavaScript-Haskell FFI calls.

Binary Size

  • ~2.9 MB (includes GHC runtime system, libraries, and CPL interpreter)

Load Time

  • First load depends on network speed and browser WASM compilation
  • Subsequent loads are faster due to browser caching

Security

The WASM version runs in the browser's sandbox and has no access to:

  • Local file system (except via the browser file picker dialog triggered by load with no arguments)
  • Network (except for loading the WASM module itself)
  • Other tabs or browser state

This makes it safe to run untrusted CPL programs in the browser.

Contributing

To improve the WASM version:

  1. Modify Haskell code in src/Main.hs (USE_WEB_BACKEND section)
  2. Update JavaScript in web/cpl-terminal.js
  3. Build and test locally with ./scripts/build-wasm.sh && python3 -m http.server -d _site 8000
  4. Submit pull request

See main README.md for general contribution guidelines.

References