Fork me on GitHub
#shadow-cljs
<
2024-02-04
>
ghaskins13:02:56

@thheller I’m working on an interesting application of shadow-cljs: a wasm-based server-side sandboxed runtime. Being a Clojure shop, I would like to include a Clojure or Clojurescript dialect in the offered languages. I got a POC running with the latter using shadow-cljs, albeit awkwardly. I’m writing to ask if there is a better way to do what I am doing or if there is interest in making the general options more first-class within shadow-cljs. I will admit right up front that I am not a javascript expert, so I may have very well missed some opportunities to do what I am about to explain in a more shadow-cljs direct/supported way. But in a nutshell, my flow is 1. Create a nodejs compatible script with shadow-cljs by compiling cljs to :target :node-script. 2. Manually stripping the shell preamble in the generated :node-script output, because rollup (see next step) doesn’t like this. 3. Running rollup (https://www.npmjs.com/package/rollup) against the shadow-cljs generated code using the node-resolve/node-builtins/node-globals rollup plugins to create a .mjs ESM module. 4. Running javy (https://github.com/bytecodealliance/javy) to bundle the .mjs ESM along with quickjs runtime and compile them to a .wasm target that can be directly run by wasm runtimes such as wasmtime or wasmedge, similar to how one might run the original :nodescript via nodejs. A few thoughts to consider: a. an option for the :node-script to omit the preamble (assuming it doesn’t already exist). b. a target that combines the :node-script and rollup steps (e.g. “:node-rollup”?) to produce a ESM compatible output might be interesting c. a first-class :wasm target that combines all of the steps might also be interesting.

thheller13:02:22

a) :hashbang false in the :node-script build config

👍 1
thheller13:02:38

why not :target :esm and skipping rollup?

ghaskins13:02:04

heh, see “I will admit right up front that I am not a javascript expert”

thheller13:02:17

I have not looked at javy, so dunno what its limitation for the packaged JS are?

ghaskins13:02:22

i was having trouble getting through the forest, perhaps because of the trees

ghaskins13:02:44

(I did try that, but was having issues)

ghaskins13:02:58

but its probably my inexperience in the js area

ghaskins13:02:00

let me try again

thheller13:02:36

the workflow for something like this is totally gonna suck though

thheller13:02:47

WASM can't do hot-reload and stuff

ghaskins13:02:50

in terms of repl, etc

ghaskins13:02:52

yeah, agreed

thheller13:02:11

I mean you maybe could develop against node and then just use this as a deployment target

thheller13:02:26

all depends on how capable javy is (which again I haven't looked at)

ghaskins13:02:33

im basically trying to provide a lambda-function like substrate in the backend, so sandboxing is critical as the code will be untrusted

ghaskins13:02:39

but yeah, that would be the idea

ghaskins13:02:56

dev-tooling above the wasm target, and then “release” builds go to wasm

ghaskins13:02:17

its not ideal, but there are a bunch of nice things the wasm sandbox brings too

ghaskins13:02:02

i would guess the javy limitations are more to do with quickjs vs a full v8 interpreter

ghaskins13:02:15

but I am not an expert there, either

ghaskins13:02:45

so far, from a POC perspective, the stack looks promising in terms of bringing clojure(script) to the mix

ghaskins13:02:10

I will try your suggestions for the hash-bang and :esm

thheller13:02:30

release builds will likely just work, dev builds will probably never work, so no watch/compile

thheller13:02:56

the code likely doesn't support any ESM features (as in no export/import)

ghaskins13:02:59

yeah…i was prototyping with release…didnt even try watch/compile as the rollup wouldve been ugly at best

thheller13:02:10

so you can also use the super simplistic :target :single-file I added recently

ghaskins13:02:23

ah, im intrigued…what does that do?

ghaskins13:02:19

cool…i will check that out as well…ty

thheller13:02:26

basically just compiles everything and puts it into a single file. completely without anything runtime specific

ghaskins13:02:31

I would like to retain the ability to bring in npm modules…is that precluded with that target?

thheller13:02:46

that still works

ghaskins13:02:46

or are you still doing all the shadow-cljs magic to bundle everything in

ghaskins13:02:33

ty…i will report back on what I find

ghaskins13:02:14

> I have not looked at javy, so dunno what its limitation for the packaged JS are? One thing I might not have been clear about: This actually all works (at least for my basic POC cljs code, so far). Which is very cool and a testament to shadow-cljs

ghaskins13:02:07

I think you were getting at the ability for javy to support hot-reload, etc, but I just wanted to emphasize that at least as a release target flow, everything I described worked, which I am thrilled about

thheller13:02:21

not what I meant no

thheller13:02:01

I mean for example in react-native the hermes runtime doesn't support const, so that has to be transpiled to var

thheller13:02:22

runtimes vary greatly in which JS features they support

ghaskins13:02:03

Got it. Well, so far so good. But perhaps landmines await

thheller13:02:16

await would be another thing 😉

ghaskins13:02:37

Like I said, I suspect limitations would more derive from quick js

thheller13:02:40

also a while back people got excited about the graaljs runtime, which works but is also a quite limited runtime. so in the end doing something practical with that becomes annoying

thheller13:02:35

yeah as far as I understand the docs the JS is left as is and just executed via quickjs

ghaskins13:02:49

Yeah, I don’t know graaljs but I’d guess quickjs is in similar camp

thheller13:02:48

> QuickJS is a small and embeddable Javascript engine. It supports the ES2023 specification including modules, asynchronous generators, proxies and BigInt.

thheller13:02:57

thats already far more than graaljs 😛

ghaskins13:02:37

In any case, my customers just need to do very simple lambda-function like things to do transformations, so this might be good enough to offer a clojure dialect but not enough for a major app

ghaskins13:02:01

Awesome, that’s good news

thheller14:02:17

maybe even REPL capable

thheller14:02:53

so could likely make a :target :quickjs that you can then optionally turn into wasm

ghaskins14:02:20

That would be awesome. I suspect interest in this area will grow beyond me, soon

thheller14:02:20

busy with other stuff currently, but I'm intrigued enough to check it out sometime soon

👍 2
ghaskins14:02:56

I’ll share my POC once I clean up a few things like the hash-bang and maybe :am

ghaskins17:02:42

@thheller so I tried the :esm option, and I get something that compiles but when I run it, nothing happens. At a high level, I understand that the issue is likely that nothing understands where the application entry point is when I am only compiling it as an ESM module. I tried to hack it with an init-fn but still no dice. I am a bit out of my depth on the js side, but perhaps you have an idea

ghaskins17:02:15

Basically I am doing this

ghaskins17:02:18

{:builds {:app  {:target           :esm
                 :output-dir       "target"
                 :init-fn           shadow-wasm.core/main
                 :modules {:app {:exports {default shadow-wasm.core/main}}}}}
 :lein true}

ghaskins17:02:30

(ns shadow-wasm.core)

(defn hello []
  (println "Hello, World"))

(defn main []
  (hello))

thheller17:02:08

that config is incorrect

ghaskins17:02:42

12:08 $ shadow-cljs release app
shadow-cljs - config: /Users/ghaskins/sandbox/wasm/sci/shadow-cljs.edn
shadow-cljs - running: lein run -m shadow.cljs.devtools.cli --npm release app
[:app] Compiling ...
[:app] Build completed. (45 files, 0 compiled, 0 warnings, 4.67s)
(default ) ✔ ~/sandbox/wasm/sci [master L|⚑ 1]
12:09 $ javy compile target/app.js -o target/app.wasm
(default ) ✔ ~/sandbox/wasm/sci [master L|⚑ 1]
12:09 $ time wasmtime target/app.wasm

real	0m0.458s
user	0m1.833s
sys	0m0.119s

thheller17:02:44

{:builds {:app  {:target           :esm
                 :output-dir       "target"
                 :runtime :custom
                 :modules {:app {:init-fn shadow-wasm.core/main}}}}
 :lein true}

ghaskins17:02:54

let me try that

thheller17:02:54

I mean technically it was a correct config, just not doing what you expected probably

ghaskins17:02:03

makes sense

ghaskins17:02:05

building now

thheller17:02:15

basically the top level :init-fn had no effect, and you just got afile with a default export

ghaskins17:02:30

I figured, just didnt know the magic to fix

thheller17:02:47

the :runtime :custom is just there to stop some browser-specific stuff being injects since :browser is the default

ghaskins17:02:51

$ time wasmtime target/app.wasm
Hello, World

real	0m0.451s
user	0m1.829s
sys	0m0.111s

ghaskins17:02:05

this is looking really good

ghaskins17:02:12

now let me see if I can get deps rolled in

ghaskins17:02:13

but to your earlier suggestion: in this new world its only shadow :esm and javy, no more rollup or the node preamble trickery

👍 1
thheller17:02:45

I did some brief quickjs tests myself. the runtime is basic JS but has no support for websockets or fetch

thheller17:02:54

but still pretty neat

thheller17:02:28

there are some built-in modules, since shadow-cljs won't be able to bundle them you probably want to set :js-options {:keep-as-import #{"os" "std"}} in the build config

ghaskins17:02:38

oh, that will be interesting…Im still new in the wasm world, too, so its not clear what ultimate limitations I may face

ghaskins17:02:51

ok, i will add that

thheller17:02:26

but then you can (:require ["std" :as std]) (std/getenviron)

thheller17:02:53

dunno if those are available in the wasm variant, but quickjs has them

ghaskins17:02:56

I assume this is what you mean

ghaskins17:02:58

{:builds {:app  {:target     :esm
                 :output-dir "target"
                 :runtime    :custom
                 :js-options {:keep-as-import #{"os" "std"}}
                 :modules    {:app {:init-fn shadow-wasm.core/main}}}}
 :lein true}

thheller17:02:35

if you feel like doing some benchmarks I'd be interested to see some numbers comparing to node/browser

ghaskins17:02:02

if you have pointers as to what you’d like to see benchmarked, I can try to give it a whirl

thheller17:02:23

well whatever your code ends up doing

ghaskins17:02:28

after wasm AOT this is the basic setup time

ghaskins17:02:30

$ time wasmtime target/app.wasm
Hello, World

real	0m0.052s
user	0m0.033s
sys	0m0.016s

ghaskins17:02:38

let me try a nodejs version

thheller17:02:50

nah not interested in startup time

thheller17:02:56

more in like runtime perf difference

ghaskins17:02:09

give me some more time to build this up

ghaskins17:02:15

then ill try to get some numbers

ghaskins17:02:12

FWIW, there is anecdotal evidence that fetch is somehow supported on top of quickjs at least when running on wasmedge: https://wasmedge.org/docs/develop/javascript/networking#fetch-client

ghaskins17:02:15

not sure how they do that, though

ghaskins17:02:46

(I know even less about wasm than I do about js, at least right now)

thheller17:02:48

yeah I think thats just provided by wasmedge rather than quickjs

👍 1
thheller17:02:04

just console.log(fetch) in a JS files crashes the process

ghaskins18:02:39

Yeah, i have some digging to do

ghaskins18:02:50

I just tried this too:

diff --git a/project.clj b/project.clj
index 088703e..4863aae 100644
--- a/project.clj
+++ b/project.clj
@@ -4,5 +4,6 @@
                  [org.clojure/clojurescript "1.11.132"
                   :exclusions [com.google.javascript/closure-compiler-unshaded
                                org.clojure/google-closure-library]]
-                 [thheller/shadow-cljs "2.27.1"]]
+                 [thheller/shadow-cljs "2.27.1"]
+                 [funcool/httpurr "2.0.0"]]
   :source-paths ["src"])
diff --git a/src/shadow_wasm/core.cljs b/src/shadow_wasm/core.cljs
index f1319a2..8757121 100644
--- a/src/shadow_wasm/core.cljs
+++ b/src/shadow_wasm/core.cljs
@@ -1,7 +1,9 @@
-(ns shadow-wasm.core)
+(ns shadow-wasm.core
+  (:require [httpurr.client.xhr :as http]))

 (defn hello []
-  (println "Hello, World"))
+  (println "Hello, World")
+  (http/get ""))

 (defn main []
   (hello))
\ No newline at end of file

ghaskins18:02:05

but it pukes with

ghaskins18:02:07

$ time wasmtime target/app.wasm
Hello, World
Error while running JS: Uncaught ReferenceError: 'XMLHttpRequest' is not defined
    at Wg (function.mjs)
    at <anonymous> (function.mjs:279)
    at <anonymous> (function.mjs:286)
    at fg (function.mjs:97)
    at <anonymous> (function.mjs)
    at <anonymous> (function.mjs)
    at d (function.mjs)
    at e (function.mjs)
    at call (native)
    at h (function.mjs)
    at <anonymous> (function.mjs:290)

Error: failed to run main module `target/app.wasm`

Caused by:
    0: failed to invoke command default
    1: error while executing at wasm backtrace:
           0: 0x61516 - <unknown>!<wasm function 109>
           1: 0x739d0 - <unknown>!<wasm function 169>
           2: 0xbf6de - <unknown>!<wasm function 1050>
    2: wasm trap: wasm `unreachable` instruction executed

real	0m0.469s
user	0m1.849s
sys	0m0.119s

ghaskins18:02:25

of course, XHR may be a different beast than fetch

ghaskins18:02:11

This is something that perhaps you understand: So I added this

diff --git a/src/shadow_wasm/core.cljs b/src/shadow_wasm/core.cljs
index f1319a2..45e4fce 100644
--- a/src/shadow_wasm/core.cljs
+++ b/src/shadow_wasm/core.cljs
@@ -1,7 +1,8 @@
-(ns shadow-wasm.core)
+(ns shadow-wasm.core
+  (:require ["http" :refer (fetch)]))

 (defn hello []
-  (println "Hello, World"))
+  (println "Hello, World: " fetch))

 (defn main []
   (hello))

ghaskins18:02:24

but I get this

$ shadow-cljs release app
shadow-cljs - config: /Users/ghaskins/sandbox/wasm/sci/shadow-cljs.edn
shadow-cljs - running: lein run -m shadow.cljs.devtools.cli --npm release app
[:app] Compiling ...
The required JS dependency "http" is not available, it was required by "shadow_wasm/core.cljs".

Dependency Trace:
	shadow_wasm/core.cljs

Searched for npm packages in:
	/Users/ghaskins/sandbox/wasm/sci/node_modules
http is part of the node-libs-browser polyfill package to provide node-native package support
for none-node builds. You should install shadow-cljs in your project to provide that dependency.

	npm install --save-dev shadow-cljs

See: 

ghaskins18:02:38

but I already have shadow in the deps

ghaskins18:02:40

$ cat package.json
{
  "private": true,
  "devDependencies": {
    "@rollup/plugin-node-resolve": "^15.2.3",
    "rollup-plugin-node-builtins": "^2.1.2",
    "rollup-plugin-node-globals": "^1.4.0",
    "shadow-cljs": "2.27.1"
  }
}

ghaskins18:02:29

so im not sure whats wrong

ghaskins18:02:46

(ignore the rollup deps, those were from the earlier experiments

ghaskins19:02:02

I think part of the story here is that it looks like wasmedge might supply a forked version of quickjs

ghaskins19:02:22

(which I am not getting by building with javy

thheller19:02:48

http is node built-in package, so not a regular npm package

ghaskins19:02:05

yeah, i think I get it now

thheller19:02:20

if its also built-in for javy you can use :keep-as-import to tell shadow not to bundle it, and instead load it at runtime

ghaskins19:02:31

I think the deal is that wasmedge has a modified quickjs which supplies certain things like fetch

ghaskins19:02:41

oh, thats interesting

ghaskins19:02:44

let me try that

thheller19:02:40

I should tweak that error message I guess, as it does not apply to :target :esm. only :browser builds

ghaskins19:02:03

makes sense

ghaskins19:02:19

this explains how the node apis are available in the wasmedge tooling

ghaskins19:02:45

so they are implementing certain nodejs built-ins in terms of wasi constructs

ghaskins19:02:24

which means I probably cant use javy, but I can probably still use the shadow :esm on top

ghaskins19:02:11

but I might need shadow to basically build as if its :node-script but bundled as if its :esm / rollup

ghaskins19:02:34

or, maybe the :js-imports trick you mentioned

thheller19:02:39

I don't know why you think rollup does anything useful here?

thheller19:02:55

literally the problem is that shadow-cljs by default for :esm tries to bundle ALL dependencies

thheller19:02:03

if can't find http and fails

thheller19:02:14

thus you configure it NOT to bundle that and instead load it at runtime

thheller19:02:23

rollup cannot bundle it either, so there is no difference

thheller19:02:03

I guess the only difference is that you are using rollup with node-builtins, which basically tells rollup to not bundle node built-in

thheller19:02:14

which is identical to what :keep-as-import is doing, just a bit more manual

thheller19:02:56

an alternate is adding :js-options {:js-provider :import}, that will make shadow-cljs NOT bundle anything

thheller19:02:08

which you should do if you think about using rollup at all, as shadow partially bundling some stuff and then rollup the rest doesn't make sense

thheller19:02:40

:node-script is very specifically targetting node, hence the name. it does not seems to be the correct fit here.

ghaskins19:02:27

> I don’t know why you think rollup does anything useful here? Sorry, I just meant “need an ESM module, similar to how shadow :esm or rollup provide”

ghaskins19:02:53

i think I understand what you are saying about the js-imports though

ghaskins19:02:55

so ill try that

ghaskins19:02:29

IIU, I think what I need to do is basically have shadow bundle anything that is not a node built-in, and tell it to skip things that are node builtins, and they will be provided by the wasmedge variant of quckjs

ghaskins19:02:04

then its the best of both worlds and I wont need rollup, as you indicated

thheller19:02:04

yeah, it is best to not bundle anything the runtime provides. as you'd likely just get a worse variant from npm

👍 1
ghaskins19:02:55

Hmm, I thought I was beginning to understand, but still stymied

ghaskins19:02:57

$ git diff
diff --git a/shadow-cljs.edn b/shadow-cljs.edn
index a50167c..4a8cc31 100644
--- a/shadow-cljs.edn
+++ b/shadow-cljs.edn
@@ -1,6 +1,6 @@
 {:builds {:app  {:target     :esm
                  :output-dir "target"
                  :runtime    :custom
-                 :js-options {:keep-as-import #{"os" "std"}}
+                 :js-options {:keep-as-import #{"os" "std" "http"}}
                  :modules    {:app {:init-fn shadow-wasm.core/main}}}}
  :lein true}
diff --git a/src/shadow_wasm/core.cljs b/src/shadow_wasm/core.cljs
index f1319a2..c9861a7 100644
--- a/src/shadow_wasm/core.cljs
+++ b/src/shadow_wasm/core.cljs
@@ -1,6 +1,8 @@
-(ns shadow-wasm.core)
+(ns shadow-wasm.core
+  (:require ["http" :refer (fetch)]))

 (defn hello []
+  (js/console.log fetch)
   (println "Hello, World"))

 (defn main []

ghaskins19:02:05

$ wasmedge --dir .:. wasmedge_quickjs.wasm target/app.js
ReferenceError: could not load module filename 'http'

ghaskins19:02:26

oh well…ill keep digging

thheller19:02:05

so I guess its not available at runtime 😛

ghaskins19:02:45

heh…apparently. Im basically trying to create the cljs equivalent to https://github.com/second-state/wasmedge-quickjs/blob/main/example_js/wasi_http_fetch.js

ghaskins19:02:02

can you confirm if (:require ["http" :refer (fetch)])

ghaskins19:02:07

is the right import statement?

ghaskins19:02:24

hmm..ok..let me sanity check and actually load their .js example

thheller19:02:30

in the docs it says Install WasmEdge-QuickJS. did you do that? seems to be an optional addon

ghaskins19:02:43

I believe so, yes

ghaskins19:02:56

im running it like this

ghaskins19:02:58

wasmedge --dir .:. wasmedge_quickjs.wasm target/app.js

ghaskins19:02:09

where the target/app.js is the shadow :esm build

ghaskins19:02:15

but its probably PEBCAC

ghaskins19:02:26

ill sanity check with their example: I think I am close

thheller19:02:27

you can just create a .js file with

import { fetch } from 'http';
console.log(fetch)

ghaskins19:02:37

true, thats a good idea

ghaskins19:02:11

same error, so theres a different problem in my flow

ghaskins19:02:22

ah, i think I get whats wrong

ghaskins19:02:45

its not just that I need the wasmedge-quickjs, but I also need to grant the runtime the modules that it refers to

ghaskins19:02:02

everything works when I am in the wasmedge-quickjs repo checkout

ghaskins19:02:27

basically this stuff

ghaskins19:02:38

is getting access via the “--dir .:.”

ghaskins19:02:45

but bottom line, its working now!

👍 1
ghaskins19:02:26

thanks for all the patience…i learned a ton

ghaskins19:02:34

this is def pretty cool

thheller20:02:42

I tested quickjs with a compile/watch build and they both worked. just had to set :devtools {:enabled false}

ghaskins20:02:55

thats encouraging

thheller20:02:57

there is no live-reload or REPL but still better than waiting for release build all the time

ghaskins20:02:29

I think overall the flow of dev in node and release to wasm wont be too horrible

ghaskins20:02:46

(if its not possible to solve all the way, I mean)

thheller20:02:33

I mean its likely possible to support a repl/hot-reload with those extra modules

thheller20:02:56

its possible with stock quickjs as well using the std package, just not using websockets

ghaskins20:02:06

if the barrier was the lack of IO etc, yeah, that should bring a bunch to the table

thheller20:02:49

there is a built-in for http requests in quickjs

thheller20:02:58

urlGet(url, options = undefined)
Download url using the curl command line utility. options is an optional object containing the following optional properties:

ghaskins20:02:12

oh, thats interesting

thheller20:02:13

seems a bit sketch but could totally be enough

thheller20:02:49

shadow currently only supports websockets, but no reason there couldn't be a http polling alternative

ghaskins20:02:04

makes sense