jsaddle-wasm 0.0.0.0 → 0.0.1.0
raw patch · 7 files changed
+197/−38 lines, 7 filesdep +jsaddle-wasmdep −asyncdep ~ghc-experimentalPVP ok
version bump matches the API change (PVP)
Dependencies added: jsaddle-wasm
Dependencies removed: async
Dependency ranges changed: ghc-experimental
API changes (from Hackage documentation)
+ Language.Javascript.JSaddle.Wasm: data JSVal
+ Language.Javascript.JSaddle.Wasm: jsaddleScript :: ByteString
+ Language.Javascript.JSaddle.Wasm: runWorker :: JSM () -> JSVal -> IO ()
Files
- CHANGELOG.md +4/−0
- README.md +45/−6
- jsaddle-wasm.cabal +15/−3
- src-js/Language/Javascript/JSaddle/Wasm/JS.hs +29/−0
- src-native/Language/Javascript/JSaddle/Wasm/Internal.hs +14/−2
- src-wasm/Language/Javascript/JSaddle/Wasm/Internal.hs +67/−21
- src/Language/Javascript/JSaddle/Wasm.hs +23/−6
CHANGELOG.md view
@@ -1,5 +1,9 @@ # Revision history for jsaddle-wasm +## 0.0.1.0 -- 2024-10-26++ * Added `runWorker` and `jsaddleScript` for running JSaddle and WASM logic in different execution environments. See the README for details on how to use this.+ ## 0.0.0.0 -- 2024-10-20 * First version.
README.md view
@@ -1,7 +1,7 @@ # jsaddle-wasm-[](https://github.com/amesgen/jsaddle-wasm/actions)-[](https://hackage.haskell.org/package/jsaddle-wasm-[](https://hackage.haskell.org/package/jsaddle-wasm/docs/Language-Javascript-JSaddle-Wasm.html))+[](https://github.com/amesgen/jsaddle-wasm/actions)+[](https://hackage.haskell.org/package/jsaddle-wasm)+[](https://hackage.haskell.org/package/jsaddle-wasm/docs/Language-Javascript-JSaddle-Wasm.html) Run [JSaddle][] `JSM` actions with the [GHC WASM backend][]. @@ -12,9 +12,9 @@ ## Examples -### Miso+ - Several Miso examples: https://github.com/tweag/ghc-wasm-miso-examples -Several Miso examples: https://github.com/tweag/ghc-wasm-miso-examples+ - Reflex TodoMVC example: https://github.com/tweag/ghc-wasm-miso-examples/pull/18 ## How to use @@ -62,15 +62,53 @@ await instance.exports.hs_start(); ``` +### Separating execution environments++It is also possible to run the WASM worker in a different execution environment (e.g. a web worker) than the JSaddle JavaScript code that dispatches the JSaddle command messages.++An advantage of this approach is that computationally expensive operations in WASM do not block the UI thread. A disadvantage is that there is some overhead for copying the data back and forth, and everything relying on synchronous callbacks (e.g. `stopPropagation`/`preventDefault`) definitely no longer works.++ - Instead of the `run` function above, you need to use `runWorker`:++ ```haskell+ import Language.Javascript.JSaddle.Wasm qualified as JSaddle.Wasm++ foreign export javascript "hs_runWorker" runWorker :: JSVal -> IO ()++ runWorker :: JSVal -> IO ()+ runWorker = JSaddle.Wasm.runWorker Ormolu.Live.app+ ```++ The argument to `runWorker` here can be any message port in the sense of the [Channel Messaging API][]. In particular, it must provide a `postMessage` function and a `message` event.++ For example, in a web worker, you can initialize the WASM module as above, and then run+ ```javascript+ await instance.exports.hs_runWorker(globalThis);+ ```+ as `globalThis` (or `self`) in a web worker is a message port.++ - Additionally, you need to run the JSaddle command dispatching logic on the other end of the message port.++ The necessary chunk of JavaScript is available as `jsaddleScript` both from `Language.Javascript.JSaddle.Wasm` from the main library, and also from `Language.Javascript.JSaddle.Wasm.JS` from the `js` public sublibrary, where the latter has the advantage to not depend on any JSFFI, so you can build a normal WASI command module or even a native executable while still depending on it.++ It provides a function `runJSaddle` taking a single argument, a message port.++ One way to invoke it is to save `jsaddleScript` to some file, include it via a `script` tag in your HTML file, and then run+ ```javascript+ const worker = new Worker("my-worker.js");+ runJSaddle(worker);+ ```+ ## Potential future work - - Take a closer look at synchronous callbacks (no special handling currently, but basic things like `stopPropagation` already seem to work fine).+ - Take a closer look at synchronous callbacks. We have no special handling currently, but basic things like `stopPropagation`/`preventDefault` already seem to work fine with `run` (but not with `runWorker`, as expected). - Testing (e.g. via Selenium). - Add logging/stats. - Performance/benchmarking (not clear that this is actually a bottleneck for most applications). - Optimize existing command-based implementation. - Reuse buffers - Use a serialization format more efficient than JSON.+ - Patch `jsaddle` to not go through commands, by using the WASM JS FFI. - Implement `ghcjs-dom` API directly via the WASM JS FFI. This would involve creating a `ghcjs-dom-wasm` package by adapting the FFI import syntax from `ghcjs-dom-jsffi`/`ghcjs-dom-javascript` appropriately.@@ -89,3 +127,4 @@ [browser_wasi_shim]: https://github.com/bjorn3/browser_wasi_shim [ghc-users-guide-js-api]: https://ghc.gitlab.haskell.org/ghc/doc/users_guide/wasm.html#the-javascript-api [WebGHC]: https://webghc.github.io+[Channel Messaging API]: https://developer.mozilla.org/en-US/docs/Web/API/Channel_Messaging_API
jsaddle-wasm.cabal view
@@ -1,7 +1,7 @@ cabal-version: 3.0 name: jsaddle-wasm-version: 0.0.0.0-synopsis: Run JSaddle @JSM@ with the GHC WASM backend+version: 0.0.1.0+synopsis: Run JSaddle JSM with the GHC WASM backend description: Run JSaddle @JSM@ with the GHC WASM backend. category: Web, Javascript homepage: https://github.com/amesgen/jsaddle-wasm@@ -28,6 +28,18 @@ BlockArguments OverloadedStrings +library js+ import: common+ visibility: public+ hs-source-dirs: src-js+ exposed-modules:+ Language.Javascript.JSaddle.Wasm.JS++ build-depends:+ base >=4.16 && <5,+ bytestring >=0.11 && <0.13,+ jsaddle ^>=0.9,+ library import: common hs-source-dirs: src@@ -40,11 +52,11 @@ build-depends: base >=4.16 && <5, jsaddle ^>=0.9,+ jsaddle-wasm:js, if arch(wasm32) build-depends: aeson >=2 && <2.3,- async ^>=2.2, bytestring >=0.11 && <0.13, ghc-experimental ^>=0.1, stm ^>=2.5,
+ src-js/Language/Javascript/JSaddle/Wasm/JS.hs view
@@ -0,0 +1,29 @@+-- | The JSaddle command interpreter. This lives in a sublibrary as it does not+-- depend on JSFFI as the rest of jsaddle-wasm, and hence does not induce this+-- property on downstream packages.+module Language.Javascript.JSaddle.Wasm.JS (jsaddleScript) where++import Data.ByteString.Lazy.Char8 qualified as BLC8+import Language.Javascript.JSaddle.Run.Files qualified as JSaddle.Files++-- | A chunk of JavaScript that defines a function @runJSaddle@, a function that+-- takes a message port (e.g. a web worker) as its single argument, and then+-- processes incoming JSaddle commands.+jsaddleScript :: BLC8.ByteString+jsaddleScript =+ BLC8.unlines+ [ JSaddle.Files.ghcjsHelpers,+ JSaddle.Files.initState,+ "function runJSaddle(worker) {",+ " worker.addEventListener('message', e => {",+ " const d = e.data;",+ " if (d && typeof d === 'object' && d.tag === 'jsaddle') {",+ " const batch = JSON.parse(d.msg);",+ JSaddle.Files.runBatch+ (\r -> "worker.postMessage({tag: 'jsaddle', msg: JSON.stringify(" <> r <> ")});")+ -- not clear how to support synchronous dispatch here+ Nothing,+ " }",+ " });",+ "}"+ ]
src-native/Language/Javascript/JSaddle/Wasm/Internal.hs view
@@ -1,6 +1,18 @@-module Language.Javascript.JSaddle.Wasm.Internal (run) where+module Language.Javascript.JSaddle.Wasm.Internal+ ( run,+ runWorker,+ JSVal,+ )+where import Language.Javascript.JSaddle.Types (JSM) run :: JSM () -> IO ()-run _ = fail "Language.Javascript.JSaddle.Wasm.run: only works on WASM backend"+run _ =+ fail "Language.Javascript.JSaddle.Wasm.run: only works on WASM backend"++runWorker :: JSM () -> JSVal -> IO ()+runWorker _ _ =+ fail "Language.Javascript.JSaddle.Wasm.runWorker: only works on WASM backend"++data JSVal
src-wasm/Language/Javascript/JSaddle/Wasm/Internal.hs view
@@ -1,8 +1,13 @@-module Language.Javascript.JSaddle.Wasm.Internal (run) where+module Language.Javascript.JSaddle.Wasm.Internal+ ( run,+ runWorker,+ JSVal,+ )+where -import Control.Concurrent.Async qualified as Async import Control.Concurrent.STM import Control.Exception (evaluate)+import Control.Monad ((<=<)) import Data.Aeson qualified as A import Data.ByteString (ByteString) import Data.ByteString.Internal qualified as BI@@ -15,29 +20,23 @@ import Language.Javascript.JSaddle.Run.Files qualified as JSaddle.Files import Language.Javascript.JSaddle.Types (Batch, JSM) +-- Note: It is also possible to implement this succinctly on top of 'runWorker'+-- and 'jsaddleScript', but then e.g. @stopPropagation@/@preventDefault@+-- definitely don't work, whereas they work (at least in simple cases) with the+-- implementation below. run :: JSM () -> IO () run entryPoint = do -- TODO rather use a bounded (even size 1) queue?- batchQueue :: TQueue Batch <- newTQueueIO-- let sendBatch :: Batch -> IO ()- sendBatch = atomically . writeTQueue batchQueue-- (processResult, _processSyncResult, start) <-- runJavaScript sendBatch entryPoint+ outgoingMsgQueue :: TQueue JSString <- newTQueueIO - processResultCallback <- mkPullCallback \s -> do- bs <- jsStringToByteString s- case A.eitherDecodeStrict bs of- Left e -> fail $ "jsaddle: received invalid JSON: " <> show e- Right r -> processResult r+ let sendOutgoingMessage =+ atomically . writeTQueue outgoingMsgQueue - readBatchCallback <- mkPushCallback do- batch <- atomically $ readTQueue batchQueue- byteStringToJSString $ BLC8.toStrict $ A.encode batch+ readBatchCallback <-+ mkPushCallback $ atomically $ readTQueue outgoingMsgQueue - let jsaddleRunner :: IO ()- jsaddleRunner = do+ let jsaddleRunner :: JSVal -> IO ()+ jsaddleRunner processResultCallback = do s <- byteStringToJSString . BLC8.toStrict . BLC8.unlines $ [ JSaddle.Files.ghcjsHelpers,@@ -47,15 +46,51 @@ " const batch = JSON.parse(await readBatch());", JSaddle.Files.runBatch (\r -> "processResult(JSON.stringify(" <> r <> "));")- -- TODO handle synchronous stuff (see processSyncResult)+ -- TODO Think more about how synchronous dispatching works here+ -- (see processSyncResult). For some reason, it already /seems/+ -- to work fine, at least in simple cases. Nothing, " }", "})();" ] evaluate =<< js_eval s processResultCallback readBatchCallback - Async.concurrently_ start jsaddleRunner+ runHelper entryPoint sendOutgoingMessage jsaddleRunner +runWorker :: JSM () -> JSVal -> IO ()+runWorker entryPoint worker =+ runHelper+ entryPoint+ (evaluate <=< js_postMessage worker)+ (evaluate <=< js_onMessage worker)++runHelper ::+ JSM () ->+ -- | How to send an outgoing message.+ (JSString -> IO ()) ->+ -- | Start receiving incoming messages. For every message, invoke the given+ -- 'JSVal' callback.+ (JSVal -> IO ()) ->+ IO ()+runHelper entryPoint sendOutgoingMessage onIncomingMessage = do+ (processResult, _processSyncResult, start) <-+ runJavaScript sendBatch entryPoint++ processResultCallback <- mkPullCallback \s -> do+ bs <- jsStringToByteString s+ case A.eitherDecodeStrict bs of+ Left e -> fail $ "jsaddle: received invalid JSON: " <> show e+ Right r -> processResult r++ onIncomingMessage processResultCallback++ start+ where+ sendBatch :: Batch -> IO ()+ sendBatch batch = do+ encodedBatch <- byteStringToJSString $ BLC8.toStrict $ A.encode batch+ sendOutgoingMessage encodedBatch+ -- Utilities: foreign import javascript "wrapper" mkPullCallback :: (JSString -> IO ()) -> IO JSVal@@ -64,6 +99,17 @@ foreign import javascript safe "new Function('processResult','readBatch',`(()=>{${$1}})()`)($2, $3)" js_eval :: JSString -> JSVal -> JSVal -> IO ()++-- Worker++foreign import javascript safe "$1.postMessage({tag: 'jsaddle', msg: $2})"+ js_postMessage :: JSVal -> JSString -> IO ()++foreign import javascript safe "$1.addEventListener('message', e => {\+ \const d = e.data;\+ \if (d && typeof d === 'object' && d.tag === 'jsaddle') $2(d.msg);\+ \})"+ js_onMessage :: JSVal -> JSVal -> IO () -- Conversion JSString <-> ByteString
src/Language/Javascript/JSaddle/Wasm.hs view
@@ -1,13 +1,30 @@-module Language.Javascript.JSaddle.Wasm (run) where+-- | See the [README](https://github.com/amesgen/jsaddle-wasm) for more details.+--+-- While this package also compiles on non-WASM GHCs for convenience, running+-- this function will immediately fail.+module Language.Javascript.JSaddle.Wasm+ ( run,+ runWorker, + -- * Re-exports+ jsaddleScript,+ JSVal,+ )+where+ import Language.Javascript.JSaddle.Types (JSM)+import Language.Javascript.JSaddle.Wasm.Internal (JSVal) import Language.Javascript.JSaddle.Wasm.Internal qualified as Internal+import Language.Javascript.JSaddle.Wasm.JS (jsaddleScript) -- | Run a 'JSM' action via the WASM JavaScript FFI.------ See the [README](https://github.com/amesgen/jsaddle-wasm) for more details.------ While this package also compiles on non-WASM GHCs for convenience, running--- this function will immediately fail. run :: JSM () -> IO () run = Internal.run++-- | Run the "worker part" of a 'JSM' action, interacting with the JSaddle JS+-- code via the given 'JSVal', a message port like e.g. a web worker.+--+-- The messages at the connected message port must be dispatched using+-- 'jsaddleScript'.+runWorker :: JSM () -> JSVal -> IO ()+runWorker = Internal.runWorker