packages feed

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 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-[![CI](https://github.com/amesgen/jsaddle-wasm/workflows/ci-wasm/badge.svg)](https://github.com/amesgen/jsaddle-wasm/actions)-[![Hackage](https://img.shields.io/hackage/v/jsaddle-wasm)](https://hackage.haskell.org/package/jsaddle-wasm-[![Haddocks](https://img.shields.io/badge/documentation-Haddocks-purple)](https://hackage.haskell.org/package/jsaddle-wasm/docs/Language-Javascript-JSaddle-Wasm.html))+[![CI](https://github.com/amesgen/jsaddle-wasm/workflows/CI/badge.svg)](https://github.com/amesgen/jsaddle-wasm/actions)+[![Hackage](https://img.shields.io/hackage/v/jsaddle-wasm)](https://hackage.haskell.org/package/jsaddle-wasm)+[![Haddocks](https://img.shields.io/badge/documentation-Haddocks-purple)](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