Skip to content

MicroHs backend for miso (JS + WASM) — alpha - #1663

Merged
dmjio merged 35 commits into
masterfrom
microhs
Oct 1, 2026
Merged

dmjio merged 35 commits into
masterfrom
microhs

Conversation

@dmjio

@dmjio dmjio commented Sep 28, 2026

Copy link
Copy Markdown
Owner

This PR provides a first approximation of a JSVal implementation (assisted by Claude Fable) in a MicroHs fork, and upstreamed into miso (at this PR). It makes changes to MicroHs (and miso) where necessary for compilation to succeed.

This was done by pointing Fable at the emscripten, ghc, MicroHs, and miso source files with specific emphasis on miso's DSL FFI needing to work / compile, and its test suite to pass. The GHC WASM backend by @TerrorJack was used as a reference implementation as well.

This branch includes distribution of MicroHs via miso's flake.nix (with miso installed). It also includes a standalone runtime.c module that can be compiled to JS and can execute combinator files dynamically over the wire (good for hot reload scenario for mobile).

This PR can serve as a good base for further iteration on eval.c, and it proves that MicroHs can support the entire gamut of miso's featureset.

These branches will be iterated upon until a most ideal JSVal implementation is ready for upstream. See the "Files Changes" section here for a list of what's changed in MicroHs to support JSVal and miso.

  • It passes all of miso's integration tests (playwright-mhs) (190/190) for both emscripten's JS and WASM backends.
  290 passed
  0 failed
  Ran 290 tests [5890.500 ms]
  454 expect() calls
  • It is able to compile and run the main site (which exercises most APIs), and is competitive on size and speed.

Size comparison to https://haskell-miso.org vs. https://microhs.haskell-miso.org

┌────────────────┬─────────┬────────────┬──────────┐
│                │ gzip -9 │ brotli -11 │ zstd -19 │
├────────────────┼─────────┼────────────┼──────────┤
│ MicroHs app.js │ 283 KB  │ 228 KB     │ 243 KB   │
├────────────────┼─────────┼────────────┼──────────┤
│ GHC app.wasm   │ 968 KB  │ 730 KB     │ 806 KB   │
├────────────────┼─────────┼────────────┼──────────┤
│ Ratio          │ 3.4×    │ 3.2×       │ 3.3×     │
└────────────────┴─────────┴────────────┴──────────┘

Warning

Alpha support. This PR makes miso compile and run in the browser with MicroHs (mhs), targeting both WebAssembly and plain JavaScript through emscripten. All 290 browser integration tests pass on both backends. Expect rough edges. The Limitations section lists what is missing.

Addresses #1100

This PR covers two branches that go together:

Repo Branch Diff
dmjio/miso microhs master...microhs
dmjio/MicroHs jsval augustss/MicroHs:master...dmjio:MicroHs:jsval

The MicroHs branch is rebased onto current augustss/MicroHs master (0.16.7.0) and passes its CI (build, hackage packages, hlint), so the compare view shows only the fork's commits. miso pins it in Makefile.mhs and nix/source.nix.

The design follows GHC's WebAssembly backend (GHC.Wasm.Prim, foreign import javascript), and the miso side reuses the existing WASM FFI layer wherever it can. This integration used AI-assisted engineering via Claude Fable.


TL;DR

  • MicroHs gets a JavaScript FFI:
    • JSVal and JSString
    • sync, async and value-returning callbacks
    • JSException
    • unsafe / safe / interruptible import safety
    • a GHC.Wasm.Prim module
  • New emscripten targets in mhs.conf.in: browser (WASM), browser_js (JS only), plus emscripten_js and quickjs.
  • JS files can be embedded in the output: -js FILE, a js target key, package jsbits, and cabal js-sources through mcabal.
  • miso gets an mhs FFI layer: ffi/mhs/Miso/DSL/FFI.hs, with CPP guards for the features MicroHs lacks.
  • Nix:
    • a nix develop .#mhs shell
    • miso-mhs, sample-app-mhs-bundle and test bundles for both targets
    • CI runs Playwright against both the WASM and the JS builds

MicroHs (dmjio/MicroHs, branch jsval)

JSVal

  • Heap representation. A JSVal is a T_FORPTR node on the Haskell heap with a new foreign-pointer kind, FP_JSVAL. Its payload is an integer handle into a JS-side table, and handle 0 is undefined.

  • The handle table. Module.mhsjs is set up by mhs_js_init at startup. It holds:

    • a kv map from handle to JS value;
    • an fns cache of compiled snippets (see Snippet compilation).

    Handles wrap to 1 after 0x7fffffff and skip live keys. Using a freed handle throws mhs: JSVal k used after being freed.

  • Haskell GC. When the FORPTR node is collected, its finalizer (mhs_jsval_finalizer) removes the handle from the table, which lets JS collect the value.

  • Forced GC. The browser heap is small (see the targets below), and a long-running app could still pile up handles between GCs. So the runtime forces a GC after 100,000 new JSVal handles (JSVAL_GC_LIMIT).

  • A JSVal cannot be serialized.

JS functions, stable pointers and freeing

A Haskell callback exported to JS is backed by a stable pointer into the Haskell heap:

  1. asyncCallback* / syncCallback* pass the Haskell function as a StablePtr (new_stableptr).

  2. mkCallback(sp, …) builds a JS function f, sets f.mhs_sp = sp, and registers f with a FinalizationRegistry. It then returns f as a new JSVal.

  3. freeJSVal removes the handle from the table. If the value is a callback, it also:

    • calls back into C to run free_stableptr(sp),
    • clears mhs_sp,
    • unregisters f from the FinalizationRegistry.

    Releasing the stable pointer drops the GC root, so the next Haskell GC reclaims the closure. The FORPTR node's own finalizer runs later and is a no-op, because the handle is already gone.

  4. If Haskell never calls freeJSVal and JS garbage-collects f, the FinalizationRegistry callback calls back into Haskell's runtime with the stable pointer (_mhs_js_free_sp → free_stableptr). The effect is the same: the root is released and the Haskell closure becomes collectable.

  5. Calling a freed callback is a safe error. If JS calls a callback after freeJSVal, for example from an event listener that was never removed, the call throws mhs: callback called after being freed and no Haskell code runs. The wrapper looks up its stable pointer when it is called, not when it was created, so a freed callback can never run a newer callback that reused its stable-pointer slot.

Callbacks: sync, async, and value-returning

  • API. GHC.Wasm.Prim exports:

    • syncCallback{,1,2,3} (returns IO ())
    • syncCallback{,1,2,3}' (returns IO JSVal)
    • asyncCallback{,1,2,3}

    They are built on foreign import javascript "wrapper" / "wrapper sync", and the compiler checks that the argument and result types are JSVal.

  • Sync callbacks run immediately. Async callbacks return a Promise and run as a microtask once the caller's JavaScript has returned.

    • They use queueMicrotask, or Promise.resolve().then(…) where that doesn't exist.
    • setTimeout(…, 0) would add at least a task of latency: clamped to 4 ms when nested, and throttled in background tabs.
  • Callbacks run as real Haskell threads.

    • run_callback creates a thread, puts it at the head of the run queue and runs it without preemption.
    • This makes catch, masking, forkIO and myThreadId work inside callbacks. They used to run with no current thread.
    • Blocking. A callback called from the JavaScript event loop may block, for example in takeMVar or an interruptible import. It then waits like any other thread, and its JS caller gets undefined, or an already-resolved Promise for an async callback. A callback that returns a value, or that is called from the JavaScript code of an import, must not block; that is a fatal error.
  • Exceptions and value callbacks.

    • An uncaught Haskell exception in a callback becomes a JS Error thrown to the caller; an async callback rejects its Promise instead. A callback that blocked and then dies has no caller left, so its exception is reported like one in a forkIO thread.
    • A value-returning callback runs its action once.
  • The result reaches JS before other Haskell threads run. When JS calls a callback from its event loop, the JS side:

    1. takes the returned value;
    2. only then calls _mhs_js_run_threads() to run any threads the callback woke up.

    Once the callback has returned, nothing on the Haskell side refers to the result. A GC in one of those threads would otherwise free its handle before JS read it.

Snippet compilation and the fns cache

  • Snippets are compiled with the Function constructor, not eval. Each foreign import javascript snippet goes through new Function("Module", "mhsjs", "return " + form).
    • The form is ($1,…,$n) => (expr), falling back to ($1,…,$n) => { stmts } on a SyntaxError.
    • async is prepended for interruptible imports.
  • Each snippet is compiled once, on first use, and cached in mhsjs.fns. The key is the address of the snippet's C string: the generated code passes that string as its last argument.
  • Async functions are stored under the negated address. An interruptible import compiles to an async function and a normal one doesn't, so the cache key has to tell them apart.
    • All FFI glue for a program is generated into one C file, and the C compiler may merge identical string literals into one address.
    • A synchronous and an interruptible import with the same JavaScript text can therefore pass the same pointer.
    • Keyed by address alone, whichever ran first put its version in the cache, and the other import then got the wrong kind. For example, a synchronous import received a Promise instead of its value.
    • Two imports of the same kind with the same text sharing one entry is harmless.
  • Callback wrappers (mkCallback) don't use this cache.

JSException

  • newtype JSException = JSException JSVal with an Exception instance (Control.Exception.Internal, re-exported by GHC.Wasm.Prim). It is RTS exception code 10.
  • JS → Haskell.
    • The generated code for safe and interruptible imports is wrapped in try/catch. A JS throw is stored, then raised in Haskell as a JSException that carries the thrown value.
    • A rejected Promise in an interruptible import, or an error while converting arguments (for example a freed JSVal), is also raised as a JSException.
  • The raised exception is the JSVal itself. C raises the value's JSVal node, an ordinary GC-managed foreign pointer.
    • primIsInt, which rtsExn already uses to recognise RTS exceptions, reports such a node as code 10.
    • rtsExn then builds SomeException (JSException v) from it.
    • The value belongs to its exception from the moment it's raised, and the GC frees it with the exception.
  • This replaced a global slot. The value used to wait in a runtime global until rtsExn fetched it (js_take_exn). That had two problems:
    • If another thread raised a JS exception before the first handler ran, each handler got the wrong value.
    • Values that were never converted had to be freed by hand.
  • catch rethrows the converted exception when its handler's type doesn't match, so any number of handlers see the same JSException.
  • Show gives JavaScript exception: <message/stack>. The text is normalised across Node and QuickJS, which format .stack differently.
  • Haskell → JS. An uncaught exception in a callback becomes new Error(msg).

Safety mode (unsafe / safe / interruptible)

  • The foreign import safety annotation used to be parsed and thrown away. It is now kept:

    • data Safety = Unsafe | Safe | Interruptible is carried through the parser, desugarer and type checker.
    • EDef's ForImp and ImpJS carry it.
  • For foreign import javascript:

    Safety Generated code JS throw
    unsafe (default) plain EM_ASM fatal
    safe try/catch plus an error check JSException
    interruptible async JS returning its Promise; the calling thread waits for it (IO.jsawait) JSException (a rejected Promise is too)
  • How interruptible works. The import returns the Promise of its async JavaScript code as a JSVal, and the desugarer wraps it with a new IO.jsawait primitive: an MVar that the JS side fills when the Promise settles (Module.mhsjs.await → mhs_js_settle), followed by takeMVar. Only the calling thread waits; the other threads keep running.

  • Default differs from the Haskell report. A javascript import with no annotation is unsafe. This is documented in the README.

GHC.Wasm.Prim

  • Exports:
    • JSVal, JSString(..), JSException(..)
    • toJSString, fromJSString, textToJSString, textFromJSString
    • freeJSVal, jsNull, jsUndefined, isNull, isUndefined
    • the 12 callback functions above
  • Parity with GHC.
    • The API follows GHC's GHC.Wasm.Prim, and covers everything miso needs.
    • It adds GHCJS-style GHC.JS.Foreign.Callback functions, which let miso's mhs FFI layer re-export the callbacks instead of declaring its own wrapper imports.
    • As in GHC, JSString has no Eq/Show instances. Miso's Data.JSString defines its own, and duplicates broke the build.

JSString and Data.Text

  • newtype JSString = JSString JSVal. Conversions go through UTF-8:
    • textToJSString uses Module.UTF8ToString.
    • textFromJSString uses Module.stringToNewUTF8, then decodeUtf8.
  • Data.Text is much more complete. About 50 functions were added, including:
    • breakOn, breakOnEnd, splitAt, span, chunksOf, count
    • center, justifyLeft/justifyRight
    • mapAccumL/mapAccumR, scanl/scanr, unfoldr/unfoldrN
    • group/groupBy, inits, tails, transpose, zipWith
    • toCaseFold, toTitle, …
  • On the miso side, Data.JSString moved to ffi/jsstring/ so the WASM and mhs builds share it. MisoString is JSString by default, or Text with the text cabal flag.

Generics

  • MicroHs's GHC.Generics got the metadata synonyms (Rec0, D1, C1, S1, R/D/C/S) that miso's generic code refers to.
  • The same definitions have since landed upstream, so the net diff against augustss/master no longer contains them.
  • MicroHs still lacks default method signatures, so miso's generic defaults are compiled out. See Limitations.

Emscripten targets (mhs.conf.in)

Upstream renamed targets.conf.in to mhs.conf.in. All emscripten targets use -sSINGLE_FILE, the new js = "--pre-js" key, and extra runtime exports (stringToNewUTF8, UTF8ToString, HEAPU8, …).

Target Output Notes
emscripten WASM, Node existing; gained the js key and runtime exports
emscripten_js JS only (-sWASM=0), Node -sNODERAWFS
browser WASM, web -Oz -sSUPPORT_LONGJMP=wasm -sENVIRONMENT=web -DHEAP_CELLS=4000000
browser_js JS only, web -O3 -sWASM=0 -sENVIRONMENT=web -DHEAP_CELLS=4000000
quickjs JS only, shell run with qjs out.js
  • 4M-cell heap on the browser targets (about 60 MB of memory, against about 680 MB at the default 50M cells). With the big heap a GC almost never ran, so the JS handle table grew by thousands of entries a second.
  • -Oz -sSUPPORT_LONGJMP=wasm. The evaluator uses setjmp. With emscripten's JS longjmp, every call out of a setjmp function goes through a JS trampoline, and at -Oz a miso app spent 84% of its time there. With wasm-EH longjmp, -Oz is as fast as -O3 and about 12% smaller. browser_js has no wasm, so it stays at -O3.

Embedding JavaScript: -js, jsbits, and js-sources

  • mhs -js FILE embeds a JS file in the output, through the target's new js key (--pre-js for emscripten).
  • Each installed package's jsbits/*.js is embedded automatically.
  • MicroCabal supports cabal js-sources. It installs them into <pkg>/jsbits, so miso.cabal's existing js-sources: js/miso.js just works under mcabal. generated/mcabal.c is regenerated to include this.

Scheduler and runtime: no ASYNCIFY, threads after main

  • No ASYNCIFY. An earlier version of this branch used emscripten's ASYNCIFY to wait for the JS event loop and to implement interruptible imports. ASYNCIFY instruments the whole evaluator, because the runtime calls FFI functions through pointers. That made it 3.2x slower and the output 31% larger, so it is gone.
  • Returning to the JS event loop. Haskell threads keep their state in the heap (cleanup() discards the C stack), so no C stack has to be preserved. Whenever the run queue is empty and something on the JS side can wake a thread, run_threads() returns to JavaScript. That something is a callback, a threadDelay timer, or a settling Promise, and it works while the main thread is blocked too.
    • mhs_main() then keeps the runtime alive, and main_finish() (the old tail of main) runs later, from mhs_js_run_threads(), when the main thread finishes.
    • Upstream's WANT_IO_POLL branch of pause_exec (enabled by unix/config.h, which emscripten reuses) reported deadlock when the main thread waited on a JS callback. The emscripten path no longer gets there.
  • Threads keep running after main returns. In a browser app main returns right after setup.
    • Once a callback exists, the runtime stays alive (emscripten_exit_with_live_runtime).
    • After a callback called from the event loop, the JS side runs the threads it woke (_mhs_js_run_threads) until they all block.
    • A JS timer resumes them when the earliest threadDelay expires.
    • This fixed a one-event lag in miso renders.
  • Every call into the runtime from JS goes through Module.mhsjs.enter. It is fail-stop, and an ExitStatus is not treated as an error.
  • A fatal error stops the runtime for good. With the runtime kept alive for callbacks, exit() only throws to the JS caller, and a JS exception from an unsafe import unwinds the C stack in the middle of run_callback. Either way the page used to keep calling into a runtime whose state was gone. The JS side now records that the runtime has stopped (Module.mhsjs.stop, also set from an atexit hook and by the unsafe import wrappers before the exception propagates), and every later call into Haskell throws an Error saying so.
  • Timer delays are rounded up to whole milliseconds, so a timer never fires before the thread is due. They are capped at 2³¹−1 ms, the largest delay setTimeout accepts; it fires at once when given more.

throwTo now waits for delivery (new mt_throwto field)

  • The problem. GHC's throwTo (and so killThread) does not return until the exception has been raised in the target. MicroHs stored the exception and returned immediately, so a killed but still-runnable thread kept running until its next interruption point.
  • Why miso hit it. The test harness kills the scheduler after each test. The killed scheduler woke on the next test's notification, took the value from the shared MVar, and died halfway through the draw. The model was updated but the DOM was left stale.
  • The fix. The thrower now parks, interruptibly, until the target takes the exception or dies. A new struct mthread field, mt_throwto, does two jobs:
    • It records that the exception was already handed over. A rescheduled thread re-runs the primop from the start, so it needs to know.
    • It keeps the target reachable for the GC while the thrower points at it.
  • Exception to the rule. Inside a JS callback there is no C stack to block on, so throwTo there stays asynchronous.
    • If the target already has an exception pending, a helper thread (async_throwto) waits to hand over the new one.
    • Before, the callback blocked, which is fatal: callbacks must not block.

Lexer and type-checker fixes

  • Unboxed tuple syntax vs miso's (#>). (# opened an unboxed tuple even in (#>) and (#). It now lexes an operator when the next character is ) or an operator character other than #. (##) stays the empty unboxed tuple (unordered-containers uses it). tests/HashOper.hs and tests/Unboxed.hs cover both. The same rule is on the hash-operators branch, for upstream.
  • failureFree for record patterns. A new EUpdate case: C { f = p, .. } counts as failure-free when the constructor is and every field is. Record patterns in pattern bindings no longer need a failure continuation.

Other bug fixes

  • Foreign import C names are now unique across modules, not just within one.
  • Polymorphic (forall) foreign import types are handled (dropForallContext).
  • JS import C code is guarded with #if defined(__EMSCRIPTEN__), so modules containing JS imports still compile for other targets.
  • New Bool and StablePtr FFI marshalling. JS imports accept JSVal, Bool, Int, Word/Char, Double, Float, Ptr, ().
  • An interruptible import with a Ptr argument compiles; the generated wrapper declared the parameter as int, which clang rejects.
  • yield inside a callback is a no-op. It used to be fatal when another thread existed, because it tried to reschedule a callback that must run to completion.
  • textToJSString/textFromJSString (and so toJSString/fromJSString) pass explicit lengths, so a NUL character no longer cuts a string short.
  • js_set_haskellCallback uses globalThis._haskellCallback, which is safe under closure compilation.
  • Data.Text.Encoding imports Data.Text explicitly to avoid name clashes.
  • JSException moved from RTS exception code 8 to 10 after upstream added Serialize/Deserialize.
  • A callback that dies of an exception whose show itself throws no longer leaves the calling thread's Haskell stack misaligned.
  • Data.Text.chunksOf n with n <= 0 returns [], as the text package does, instead of an infinite list.

Upstream foreign export javascript

  • Upstream has since added foreign export javascript (augustss/MicroHs#492): the exports are EMSCRIPTEN_KEEPALIVE wasm exports that JS calls directly. This branch sits on top of it. The export table carries upstream's IsJavascript flag next to this branch's import-side changes, and both share upstream's mhs_from_Bool/mhs_to_Bool adapters (this branch's own copies were dropped in the rebase).

Standalone RTS

  • src/runtime/rts.c and make rts.js build the runtime alone. node rts.js prog.comb args… runs a pre-compiled combinator file.

Tests added to MicroHs

  • tests/JSVal.hs:
    • marshalling (numbers, Bool, Char, objects) and multi-line snippets
    • callbacks, and GC freeing handles
    • a safe TypeError, and the same TypeError rethrown through a handler of the wrong type
    • interruptible await, a rejected Promise
    • threads after main
  • tests/JSCallback.hs:
    • catch, mask, forkIO, myThreadId inside a callback
    • uncaught exceptions in sync and async callbacks
    • killThread from a callback, and a callback during await
    • use after freeJSVal, and a bounded handle table (150k allocations)
    • a value callback running exactly once
    • callbacks that block or await when called from the event loop, including a nested await while main awaits, and one that dies after blocking
    • regression tests for the review fixes, each failing on the previous runtime:
      • calling a freed callback;
      • the same snippet in a synchronous and an interruptible import;
      • no leak when callbacks die of a JSException;
      • throwTo twice from a callback;
      • a value callback from the event loop whose result survives a GC in the threads it woke
      • a callback dying of an exception that can't be shown
  • tests/ThrowToSync.hs is part of make test. It covers:
    • runnable, blocked and uninterruptibleMasked targets
    • a doomed thread racing a live one for an MVar
    • a dead target, and throwing to oneself
  • tests/JSNative.hs (make testjsnative, part of alltest) builds a program that creates callbacks for a native target and runs it.
  • tests/Text.hs covers chunksOf, including a size of 0.
  • How to run. make testjsval and make testjscallback (they need emcc and node). tests/ForImpJS.hs was updated for $1-based placeholders.

Regenerated files

  • generated/mhs.c: bootstrapped each time the compiler or library changed, and stage 2 equals stage 1.
  • generated/mcabal.c:
    • adds js-sources support;
    • queries mhs --numeric-version without the -a package-path flag. Passing -a there broke mcabal -a… install.
    • The MicroCabal fix is upstream as augustss/MicroCabal#39 ("run the mhs version probe without the package path flag").
  • generated/base.pkg: make install ships the pre-built package rather than building lib/, so it is regenerated for each library change. It is built with make generated/base.pkg from a clean checkout, using the bootstrapped compiler.

Note

Breaking change for existing foreign import javascript code. Snippets use $1..$n placeholders instead of EM_ASM's $0.., and emscripten helpers must be written as Module.X. A snippet may be an expression, or statements with return. Packages with interruptible imports must be recompiled, since those imports now desugar through IO.jsawait.


miso (dmjio/miso, branch microhs)

FFI layer: ffi/mhs/Miso/DSL/FFI.hs

  • A new mhs FFI module that mirrors ffi/wasm/Miso/DSL/FFI.hs, built on MicroHs's GHC.Wasm.Prim. It provides:
    • JSVal/JSString marshalling, invokeFunction, getProp/setProp, new, eval
    • await (an interruptible import), requestAnimationFrame, and more
  • Differences from WASM.
    • Callbacks are re-exported from GHC.Wasm.Prim instead of being declared as wrapper imports.
    • freeFunction is freeJSVal.
    • Eq JSVal is $1 === $2.
  • Selection is in miso.cabal, through impl(mhs): -DMHS, hs-source-dirs: ffi/mhs ffi/jsstring, and the existing js-sources also apply under mhs.
  • Small shims in ffi/mhs/: GHC.OverloadedLabels, GHC.Natural, GHC.StaticPtr (a stub), Data.Text.Lazy.Encoding.

Nix: nix develop .#mhs

  • A new devShell for trying MicroHs on miso and on the other apps in the haskell-miso org, compiled to both WASM and JS. It provides:
    • mhs and mcabal wrappers that point at a local package database
    • emscripten, make, bun, nodejs, http-server
    • .mcabal/, seeded on first entry from miso-mhs with base, ghc-compat, array, transformers, mtl, containers, a template-haskell stand-in, and miso already installed
    • a writable emscripten cache in .emcache/
  • Choosing the target. Pick it per build, with mcabal --options=-tbrowser build or --options=-tbrowser_js:
    nix develop .#mhs
    make -C sample-app mhs-mcabal                       # WASM (browser)
    make -C sample-app mhs-mcabal MHS_TARGET=browser_js # JS only
    make -C sample-app serve-mhs
  • New nix attributes (nix/mhs/default.nix, exported from nix/overlay.nix and default.nix):
    • microhs, microhs-packages, miso-mhs
    • sample-app-mhs-bundle
    • miso-tests-mhs-bundle (browser), miso-tests-mhs-js-bundle (browser_js)
    • playwright-mhs, playwright-mhs-js

nix/source.nix additions

Name Source
microhs dmjio/MicroHs @ jsval
mhs-ghc-compat augustss/ghc-compat
mhs-array augustss/array-mhs
mhs-containers haskell/containers (0.8 has __MHS__ support)
mhs-transformers Hackage transformers-0.6.1.2
mhs-mtl Hackage mtl-2.3.1 (one forall-kinded instance head patched with sed)

The source filter also skips .mcabal, .emcache and dist-mcabal.

CI

.github/workflows/main.yml runs the Playwright integration suite on both MicroHs backends. All 290 tests pass on each:

  • (MHS WASM) Miso integration tests built with MicroHs (browser) → nix-build -A playwright-mhs
  • (MHS JS) Miso integration tests built with MicroHs (browser_js) → nix-build -A playwright-mhs-js

sample-app

  • sample-app/Makefile: mhs, mhs-mcabal and serve-mhs targets, and MHS_TARGET (browser or browser_js).
  • sample-app/static-mhs/index.html: a single <script src="app.js">, because miso.js is embedded in the build.
  • sample-app/Main.hs: the counter is temporarily replaced with a snake game (ported from miso-snake) as a heavier test case. It uses containers, windowSub, Miso.Random, Miso.Lens and Miso.Reload.
  • Makefile.mhs: a non-nix bootstrap for Ubuntu that installs emsdk, MicroHs, the packages and miso into ./.mcabal, then builds the sample app.

Source changes for MicroHs compatibility

  • mapM, mapM_, forM, forM_, sequence, sequence_ are list-only in MicroHs. About 30 modules hide them from Prelude/Control.Monad under #ifdef __MHS__ and import the Foldable/Traversable versions instead.
  • Record-dot vs function composition. mhs lexes x.at as a record selection, so queue.at vcompId and children.at _componentId in Miso.Runtime are respelled queue . at vcompId and so on.
    • MicroHs's Prelude also exports setField, so it is hidden in several modules.
  • threadDelay overflow. createSub waited with threadDelay (secs 10000), which is 10¹⁰ µs.
    • Int is 32-bit on wasm32 and MicroHs throws Overflow above 2³¹ µs.
    • That killed the Sub thread and released windowSub listeners immediately.
    • It is now secs 1000. The change is unconditional and harmless on GHC.
  • GHC.TypeLits becomes Data.TypeLits, performMajorGC becomes performGC, ThreadStatus comes from Control.Concurrent, and some local bindings get explicit signatures.

Limitations

  • Text API is correct but slow. MicroHs's Text is a UTF-8 ByteString, but most of the new Data.Text functions work by unpack → a Data.List operation → pack. This was done out of convenience.
  • No StaticPtr. MicroHs has no StaticPointers extension or static syntax, and ffi/mhs/GHC/StaticPtr.hs is a stub. miso needs StaticPtr for iOS/Android apps through Lynx, so native mobile targets are not supported yet. The static-mount tests are compiled out.
  • No Template Haskell and no default signatures (so no Generics-derived defaults). These are guarded with MicroHs's predefined CPP macro, #ifdef __MHS__.
    • The generic default methods of ToJSVal, FromJSVal, ToObject, ToJSON, FromJSON and Router are compiled out, together with genericToJSON/genericParseJSON, so instances are written by hand.
    • Miso.Lens.TH and the [js| |] quasi-quoter are not built (-template-haskell).
  • No associated type families. In Miso.Lens, At becomes a multi-parameter class with functional dependencies (class At at k v | at -> k, at -> v), and Miso.Lens.Generic is not built under mhs.
  • mapM/mapM_/forM/forM_ are not generalized to Foldable/Traversable in MicroHs's Prelude (see above).
  • No StableName. dirtyCheck falls back to (/=), dropping the pointer-equality fast path in the model dirty check.
  • No externs file yet. Closure Compiler (advanced mode) can't be used on the browser_js output. The externs can be added in this PR.
  • ccall "wrapper" / "dynamic" are still unimplemented; only javascript wrappers are supported.
  • Some callbacks must not block. A callback that returns a value, or that is called from the JavaScript code of an import, runs with a Haskell thread on the C stack, so it can't block or await; doing so is fatal. Callbacks called from the event loop can.
  • A second throwTo can be stranded. If a thread is blocked in throwTo because the target already has an exception pending, and the target finishes without taking either exception, that thread is never woken. It needs two throwers and a masked target.
  • No timers on the quickjs target. qjs has no global setTimeout, so a threadDelay there never wakes up.
  • Interrupting throwTo doesn't cancel it. If a thread waiting in throwTo is itself interrupted, it raises the exception it received, as in GHC. But the exception it was throwing has already been handed over and still reaches the target; GHC would cancel it.

Future ideas

  • It could be possible to add a specific QuasiQuoter (when compiling with emcc) the supports inline JS [js| return 0; |], etc.
  • StableName, StaticPtr, etc. support.

cc @augustss

Adds a third browser backend alongside GHCJS and WASM: MicroHs compiled
through emscripten, linked against the standalone `js/miso.js` runtime.
Everything is behind `__MHS__` (defined by `mhs` itself) or `MHS`, so the
GHC, GHCJS and WASM builds are untouched.

Library (29 modules under `src/`)

  * Traversals. MicroHs's `Prelude`/`Control.Monad` export list-only
    `mapM`/`mapM_`/`forM`/`forM_`/`sequence`/`sequence_`; the
    `Foldable`/`Traversable` ones live in `Data.Foldable` and
    `Data.Traversable`. 26 modules hide the list-only names and import the
    general ones under `__MHS__`.

  * Generics. `mhs` has no `DefaultSignatures` and no `TypeError`, so the
    generic default methods and their helper classes are compiled out:
    `GToJSVal`/`GFromJSVal` (`Miso.DSL`), `GToJSON`/`GFromJSON` plus
    `genericToJSON`/`genericParseJSON` (`Miso.JSON`) and `GRouter`
    (`Miso.Router`). Instances are written by hand on this backend.

  * `Miso.Lens`. No associated type families, so `At` becomes a
    multi-parameter class with functional dependencies
    (`class At at k v | at -> k, at -> v`) and the `Map`/`IntMap`/`Set`/
    `IntSet`/list instances follow. `queue.at` is respelled `queue . at`:
    `mhs` lexes `x.at` as record selection.

  * `Miso.Runtime`. No `StableName`, so `dirtyCheck` drops the pointer-
    equality fast path and compares structurally; `performMajorGC` becomes
    `performGC`; `ThreadStatus` comes from `Control.Concurrent` rather than
    `GHC.Conc`.

  * `Miso.JSON`. The `JSON.stringify`/`JSON.parse` foreign imports are now
    shared with WASM (`#if defined(WASM) || defined(MHS)`), and the
    `Map MisoString v` parser uses `traverse` instead of `mapM`.

  * `Miso.CSS.Color`, `Miso.Router`: `Data.TypeLits` in place of
    `GHC.TypeLits`. `Miso.Subscription.Window`: hide `Options(..)` so the
    field selectors go with the type.

FFI shims (`ffi/mhs`, on the include path via `-i../ffi/mhs`)

  * `Miso.DSL.FFI` — the `mhs` FFI layer, mirroring `ffi/wasm`: `JSVal`,
    `JSString`, callbacks and the marshaling primitives, over MicroHs's
    `GHC.Wasm.Prim`. `MisoString` is `Text` here, i.e. `MISO_TEXT` is
    always on.
  * `GHC.StaticPtr` — types and accessors only; `mhs` has no `static`
    syntax, so `unsafeLookupStaticPtr` yields `Nothing`.
  * `GHC.OverloadedLabels` — `IsLabel` over `Data.TypeLits`' `Symbol`,
    usable through `fromLabel` with a type application.
  * `GHC.Natural`, `Data.Text.Lazy.Encoding` — the slices miso uses.

Build and test

  * `make mhs` (in `sample-app/`) builds `Main` with
    `mhs -XCPP -DMHS -DMISO_TEXT -i../src -i../ffi/mhs -t$(MHS_TARGET)`
    into `public-mhs/`, alongside `js/miso.js` and `static-mhs/index.html`.
    `MHS_TARGET` selects a section of MicroHs's `targets.conf`: `browser`
    (WebAssembly) or `browser_js` (JavaScript only, `-sWASM=0`).
    `make serve-mhs` serves the result.
  * `scripts/playwright-mhs.sh` + `ts/playwright-mhs.ts` — headless
    Chromium smoke test: build, serve on :8080, click the counter's `+`/`-`
    and print SUCCESS/ERROR, same protocol as `ts/playwright.ts`.
  * `.gitignore`: the `public-mhs/` build output.
Add impl(mhs) arms to miso.cabal (cpp-options, ffi/mhs, js-sources,
Miso.Lens.Generic excluded since MicroHs has no type families), so the
library installs with 'mcabal install' and executables build with
'mcabal --options=-tbrowser build' with miso.js embedded.

The sample-app 'make mhs' target now embeds js/miso.js with -js instead
of loading it with a script tag, and 'make mhs-mcabal' builds via mcabal.
Miso.Lens gets the CPP pragma it needs (mcabal only passes -XCPP when
the cabal file asks for it), and the playwright smoke test script is an
ES module, as node treats miso's ts files.
nix/mhs/default.nix packages the MicroHs toolchain (dmjio/MicroHs,
branch rts-standalone, from nix/source.nix), the packages miso needs
under mhs (ghc-compat, array-mhs, transformers, mtl, containers 0.8,
an empty template-haskell), miso itself installed with mcabal, and the
sample app built for the browser with miso.js embedded via js-sources.

playwright-mhs serves that bundle and runs ts/playwright-mhs.ts, like
playwright-wasm; it is added to the CI workflow.
tests/app/Main.hs compiles with mhs: the Generic/Lens.Generic/Router/Static
tests and derived JSON instances are guarded with __MHS__ (the Person JSON
instances are written by hand there), GHC.Natural is replaced by
Numeric.Natural, and miso-tests.cabal gets an impl(mhs) arm.
nix/mhs/default.nix builds the tests as miso-tests-mhs-bundle and
playwright-mhs now runs ts/playwright.ts against it with the echo server,
like playwright-wasm.  MicroHs is bumped to a revision whose runtime yields
to the JavaScript event loop while Haskell threads wait for callbacks.
Fixes the one-event render lag of the sample app under mhs: the runtime
now runs miso's scheduler thread right after the event handler that woke
it, and resumes threadDelay via a JavaScript timer.
Int is 32 bits on wasm32.  The old threadDelay (secs 10000) computed
10^10, which MicroHs rejects with an Overflow exception (GHC wraps it
silently), killing the Sub thread and releasing its resource right after
acquiring it, so e.g. windowSub listeners were removed immediately.
mhs and mcabal from the pinned MicroHs, emscripten for the browser
target, and node/bun/http-server.  The shell hook copies the package
database of miso-mhs (base, ghc-compat, containers, mtl, miso, ...) to
./.mcabal, since the store copy is read only and mcabal writes to it, and
gives emcc a writable cache in ./.emcache.  The mhs and mcabal on PATH are
wrappers that add -a for that database unless a package path is given.

  make -C sample-app mhs-mcabal   # against the installed miso package
  make -C sample-app mhs          # miso from ../src

Both directories are ignored by git and by the nix source filter.
The browser targets now use a 4M cell heap (about 60MB instead of 680MB)
and the runtime forces a GC after 100000 new JSVal handles, so the
JavaScript-side handle table stays bounded in long-running apps.
playwright-mhs runs the integration test suite now; the smoke test in
ts/playwright-mhs.ts and its script are no longer used.
Data.JSString (the wasm backend's JSString implementation, now in
ffi/jsstring so the wasm and mhs backends share it) compiles with MicroHs
as is, apart from the Text conversions, which under __MHS__ come from
MicroHs's GHC.Wasm.Prim.  The impl(mhs) arm therefore drops -DMISO_TEXT:
MisoString is JSString on MicroHs like on the other JavaScript backends,
and strings are no longer re-marshalled on every render.  The mhs FFI
module gets the JSString versions of parseInt/parseDouble/... under
#else of MISO_TEXT, as the wasm one has.

Measured on miso-snake: FFI calls per tick 3466 -> 2185, new JSVal
handles per tick 2337 -> 857, evaluator share of wall time halved.
The 280 integration tests pass.
`nix develop .#mhs` is the only documented way to get a MicroHs
toolchain with miso in its package database.  Makefile.mhs reaches the
same place from apt and git: it installs mhs, mcabal and cpphs into
./.mcabal, adds the packages miso needs under mhs, installs miso itself,
and builds the sample app.

    make -f Makefile.mhs apt      # once, needs sudo
    make -f Makefile.mhs          # -> sample-app/public-mhs
    make -f Makefile.mhs serve

The end state is the database the mhs dev shell sets up, so the existing
sample-app targets (mhs-mcabal, mhs, serve-mhs) work unchanged after it.

Each step is guarded by a stamp under .mhs-bootstrap/stamp, so a rerun
only redoes what was removed.  Revisions are pinned to the same commits
as nix/source.nix and have to be changed in both.  Two of them are not
interchangeable with the obvious alternative: MicroHs must be
dmjio/MicroHs on rts-standalone (upstream has no JSVal FFI or browser
targets), and mtl 2.3.1 needs its poly-kinded MonadCont instance head
rewritten before mhs can parse it -- that patch is checked after it is
applied so a version bump fails there instead of much later.

emscripten is required either way: the browser targets compile with emcc.
The emsdk step is skipped when emcc is already on PATH.
Something with a tick, keyboard input, randomness and a non-trivial
amount of state, to exercise more of the runtime than a counter does
when testing the MicroHs build.  Temporary: the counter is the sample
app people are pointed at, so this should not outlive the testing.

Needs containers for Data.Sequence (the body) and Data.Set (the
occupied cells); both build under mhs.
MicroHs rts-standalone merged augustss/master, so the compiler is now
0.16.6.0 (the package database path depends on the version) and
targets.conf is called mhs.conf.
The MicroHs merge with upstream moved the version from 0.15.3.0 to 0.16.6.0,
and nix/mhs/default.nix builds the package database path from it, so mcabal
installed packages in mhs-0.16.6.0 while mhs was told to look in
mhs-0.15.3.0 and reported 'dependency not installed: ghc-compat'.
Data.JSString (the wasm backend's implementation, moved to ffi/jsstring so
the wasm and mhs backends share it) compiles with MicroHs, so the impl(mhs)
arm no longer forces -DMISO_TEXT: the 'text' cabal flag decides, as on the
other client backends.  MisoString is Data.JSString by default and
Data.Text with 'text'; both configurations build and pass the integration
tests.

The nix build and Makefile.mhs now pass -text, so they actually exercise
Data.JSString; before, the 'text' flag kept defining MISO_TEXT and the
JSString code was never compiled.  Both follow MicroHs to 0.16.6.0 and to
the revision with the callback fixes and the regenerated base package.
The mhs test bundle is now built per mhs.conf target: browser
(WebAssembly, playwright-mhs) and browser_js (JavaScript only,
-sWASM=0, playwright-mhs-js). The workflow runs both.
Fixes two flaky MicroHs integration tests: a killed scheduler thread
could still consume the next test's redraw notification.
The Generic defaults of ToJSVal/ToObject are not compiled under __MHS__,
so the derived instances had no methods and failed at runtime.
pickFood retries until it finds a free cell, so with none left it
looped forever.
Only the tests that need Generic-derived instances are excluded now;
the Point, encodePure escaping, fromMisoStringEither and lexTokens
tests run with mhs too.
Trying readMaybe on every prefix was quadratic and accepted Haskell
syntax such as "(5)" and "NaN" that the wasm backend rejects.
Picks up the review fixes: freed callbacks, callback results and the GC,
the snippet cache, throwTo from callbacks, JavaScript exceptions raised as
their JSVal (no global slot), ASYNCIFY only for emcc, and Data.Text.chunksOf.
Both playwright-mhs (browser) and playwright-mhs-js (browser_js) pass all
290 tests.
MicroHs infers 'sym :: Type' without the annotation and rejects
'Capture "slug" MisoString' with a kind error; GHC is unaffected.
dmjio/MicroHs's rts-standalone branch was rebased onto upstream master
and renamed to jsval.
jsval was rebased onto upstream master 1d316428 and gained a CI fix: (##) lexes as the empty unboxed tuple again.
@augustss

Copy link
Copy Markdown

Amazing! I'll look at merging the mhs&mcabal chnages.

@augustss

Copy link
Copy Markdown

I clicked around a bit in the MicroHs version of the website, and it it seems very snappy. I'm glad MicroHs is fast enough.

Regarding Data.Text, if there are some functions that are too slow, let me know. They can always be rewritten in C, even if I don't like that much. :)

Again, this is so cool!!

@dmjio

dmjio commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner Author

It really is awesome ! MicroHs is super impressive. Huge thank you for creating it. The combinator approach definitely opens up new opportunities that are exciting to explore, like hot reload for development of web and mobile.

The performance seems great honestly (especially for most applications) and the payload much more reasonable.

The hardest part of using miso has always been acquiring the compiler, but now that problem seems solved.

Very exciting times imo.

dmjio added a commit that referenced this pull request Sep 29, 2026
make mhs, make mhs-mcabal and make serve-mhs, run in the nix develop .#mhs
shell.  static-mhs/index.html loads the single app.js the build produces
(miso.js is embedded).  Building needs miso's MicroHs support (#1663).
@dmjio

dmjio commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner Author

Will merge this first, then will update all of the example repos in the org to build against mhs (via the flake for now), checking for correctness / performance, etc. Then we could open up a draft PR for jsval against master to see if it would be eligible for an upstream.

Then we could move onto making a statically linked CLI build tool (that has mhs embedded) to get rid of the nix requirement, and include hot reload out of the box.

@dmjio
dmjio merged commit 6edf910 into master Oct 1, 2026
6 checks passed
@dmjio
dmjio deleted the microhs branch October 1, 2026 13:21
dmjio added a commit to haskell-miso/haskell-miso.org that referenced this pull request Oct 3, 2026
The MicroHs backend is merged into miso (dmjio/miso#1663), and the
rts_standalone branch is gone.  Drop the miso-mhs input, take
devShells.mhs from miso, and bump miso to 4b930160 (MicroHs c8fc0d85).
dmjio added a commit to haskell-miso/haskell-miso.org that referenced this pull request Oct 4, 2026
* Build the client with MicroHs

MicroHs (mhs, dmjio/MicroHs branch rts-standalone) compiles miso for the
browser through emscripten.  The site needs three small arms behind
`#ifdef __MHS__`, since MicroHs has no GHC.Generics metadata and no
Template Haskell:

- Site.Route: a hand-written `Router Route` instance, equivalent to what
  `deriving anyclass Router` produces (constructor name lowercased and cut
  at its first upper-case letter as the first path segment, `Index` as the
  root).
- Site.Demos: manual ToJSON/FromJSON instances for `Note` and `User`; the
  demo source strings shown on the page are unchanged.
- Site: `scrollTop` as a `foreign import javascript` instead of the
  `[js| |]` quasi-quote (Miso.FFI.QQ needs Template Haskell).

Everything else compiles as is.  Build:

  mhs -XCPP -ishared -iclient -tbrowser Main -opublic-mhs/app.js

with miso installed as an mhs package; public-mhs/ is ignored.

* make mhs: build the site with MicroHs through the flake, and in CI

`nix develop .#mhs` is miso's MicroHs shell (mhs and mcabal with miso
installed, emscripten), taken from miso's rts_standalone branch until the
backend is merged.  `make mhs` compiles the client with mhs into a single
public/index.js (WebAssembly embedded) and prerenders as usual; the
workflow gets a build-mhs job that runs it.

* flake: take the mhs shell from miso master

The MicroHs backend is merged into miso (dmjio/miso#1663), and the
rts_standalone branch is gone.  Drop the miso-mhs input, take
devShells.mhs from miso, and bump miso to 4b930160 (MicroHs c8fc0d85).
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.

2 participants