Hey everyone! I am very new to WASM/WASI and only just managed to compile and transpile a rust CLI tool to JS using the wasm32-wasip2 target and jco. The resulting code works fine on nodejs, which is my target platform. I am trying to turn this CLI tool into a javascript github action. Unfortunately, the last missing step, which is bundling the code into a single file, is giving me a whole lot of trouble. So I am wondering: what bundlers have people here used successfully? Or is what I am trying to do actually impossible? My experiments suggest that something is up with inlining preview2-shim so maybe that's not meant to work?
I have tried esbuild, rollup, rolldown, microbundle, and webpack. The last three seem to generate broken code that gets stuck (not in a busy loop, though) before running my rust code. I tried nodejs inspect for those but depending on the bundler it either crashes or it simply pretends that the code ran to conclusion without reporting anything unusual.
esbuild yields more interesting results: it can generate a full bundle that runs my rust code but the rust code seems to be running in an environment without my preopens (which are {'/':'/'} for testing purposes), i.e. it always reports Not a directory for every path I try to read. If I specify --external:'@bytecodealliance*, the, preopens work fine but, of course, the resulting file is no longer self contained.
rollup simply refused to find @bytecodealliance/preview2-shim/instantiation in the first place and reports it as a missing dependency. As a consequence, the bundled file works but is not self contained.
Hey @Janno thanks for trying out Wasm/WASI and the JS WebAssembly ecosystem!
We have some examples in the Jco repo that might answer your question -- generally we lean towards the oxc set of tooling and rolldown in particular since it has much more batteries included. Some examples:
https://github.com/bytecodealliance/jco/blob/main/examples/components/node-compat/rolldown.config.mjs
https://github.com/bytecodealliance/jco/blob/main/examples/components/http-server-hono/rolldown.config.mjs
https://github.com/bytecodealliance/jco/blob/main/examples/components/http-server-hono-with-bindings/rolldown.config.mjs
(that last example might be most interesting as it's the most complex)
Thanks for the links! I managed to make rolldown work to the same degree that esbuild does, i.e. it now runs my rust code but it also fails with the Not a directory error on any read operation. And, just like with esbuild, I can get it to work properly only by forcing it to not inline @bytecodealliance/preview2-shim/instantiation. Are there any examples that use jco's --instantation option and successful produce a bundled file? The reason I am using --instantation is that I would always get permission denied errors without it on any file system access. So I ended up using that together with https://github.com/bytecodealliance/jco/pull/1213 to declare preopens and that was what made the non-bundled transpiled code execute successfully. I was a little confused by this since the sandbox is obviously meant to be more restrictive so perhaps there is something here that I could do differently to avoid --instantation and thus avoid the problematic inlining of that part of the shim.
Are there any examples that use
jco's--instantationoption and successful produce a bundled file?
So there's actually a jco book section with some details -- I think that'st he example you're looking for?
Would love contributions if you see anything wrong/confusing/etc
Would also appreciate hearing where you checked first for docs so I can go maybe update/make it easier. Clearly we need more guidance around which bundling solution to use and how.
Don't know if the Jco book is the best place if no one knows it exists!
So there's actually a jco book section with some details -- I think that'st he example you're looking for?
Yes, the sandboxing example is basically what I am using. Again, the actual code works fine until it is bundled. It's just that FS accesses no longer work after bundling. I created a minimal reproducing example. There must be something I am doing terribly wrong.
Would also appreciate hearing where you checked first for docs so I can go maybe update/make it easier. Clearly we need more guidance around which bundling solution to use and how.
These days I have a lot of trouble googling for basic stuff. I don't think I managed to find the jco book but I did find the pull request that added sandboxing. I suspect my particular path through the docs is a little unusual because I ran into this problem of not being able to perform any FS accesses by default and I had to use sandboxing to get more access to the file system. So that shaped a lot of my googling and mostly led me to github issues and similar places. Any examples I could find of people using the tool normally didn't work for me and I think I tend to just assume that they are outdated at that point and move on to find more recent docs (or give up).
Thanks for making a repro!
I think at this point it's probably worth making an actual issue against Jco -- I don't see anything obviously wrong in the code you have, and at the very least we should make this into an actual example in the repo if there's a real bug (and there very well might be -- I have some guesses, but not sure yet)!
Would you mind opening a new issue on the jco repo?
Thanks for the notes on how you got here, that's really helpful. So basically you went through the guide and got everything working (curious of course how painful this was/anything else that sucked) and then realized you didn't have any file access (hurray, sandboxing!). Would you mind sharing which setup worked? You found the jco pull request that had FS stuff, and did you pull the code out of that? or did you already have the codebase set up?
Again thanks a lot for the insight into the path you took... I'm not sure what the best way to make things more discoverable is, maybe putting links in error messages (which is crazy), or stderr output explaining where to find help on first run might work (if you downloaded and ran jco just fine when initially doing experimentation).
rolldown resolves the platform based on the bundled extension.
See: https://rolldown.rs/reference/InputOptions.platform#platform
For ESM, the default platform is browser.
As a result, the bundled code fails when run in Node.js unless the platform is explicitly set.
Solution: add platform: "node" to your rolldown.config.mjs to ensure the Node.js build is selected.
Follow-up:
After setting platform: "node" in the config, the Node version of getStdin() may block indefinitely.
This occurs because getStdin() in Node relies on a Worker thread, and the worker file (worker-thread.js) must exist in the bundled output.
If the worker file is not included or copied correctly, the Worker cannot start, leading to the observed hang.
Solution:
worker-thread in node_modules/@bytecodealliance/preview2-shim/package.json:"./worker-thread": {
"node": "./lib/io/worker-thread.js",
"default": "./lib/io/worker-thread.js"
}
rolldown.config.mjs as follows:import { defineConfig } from "rolldown";
import { defineEnv as defineUnenv } from "unenv";
const unenv = defineUnenv();
export default defineConfig({
input: {
"bundle": "main.mjs",
"worker-thread": "@bytecodealliance/preview2-shim/worker-thread",
},
platform: "node",
external: [/wasi:.*/, /node:.*/],
output: {
codeSplitting: true,
dir: "dist",
format: "esm",
entryFileNames(chunk) {
if (chunk.name === "bundle") return "[name].mjs";
return "[name].js";
},
},
resolve: {
alias: {
...unenv.env.alias,
},
},
});
This ensures that the Node.js build of worker-thread.js is correctly included in the bundled output.
new URL('./worker-thread.js', import.meta.url) now resolves correctly, allowing getStdin() and other Node-only Worker-dependent functions to work.
Note on entryFileNames()
Currently, entryFileNames() is used to differentiate .mjs for the main bundle and .js for other chunks, mainly for visual clarity.
There isn’t a simple built-in way in Rolldown to achieve this without a function, so if build performance is a concern, consider outputting all chunks as .js and renaming the main bundle afterwards.
Note on exports
The export for "./worker-thread" was added to avoid specifying the absolute path inside node_modules in the Rolldown config.
By relying on the package’s exports, we can import the worker using a stable module specifier ("@bytecodealliance/preview2-shim/worker-thread") instead of hardcoding the file path.
@ktz_alias Thank you for figuring this out! I would have never guessed.. well, any of this, really. How did you manage to figure it out?
I made the changes and the resulting bundle works correctly. The only potential problem is that it is not a single file. I'll try to see if I can still use it for my intended purpose, i.e. as a javascript github action. I know they don't allow JS package dependencies but perhaps there is some leeway for local .js files.
@Janno Yes, the exports addition is more for internal hygiene than a strict requirement—basically, it makes the package more robust for consumers and avoids hardcoding paths to node_modules. > @Victor Adossi , I guess this is roughly the intention, right? :smile:
Glad to hear the bundle works!
About the single-file issue: yeah, unfortunately Worker threads in Node require a separate file, so a truly single-file bundle is tricky. You might be able to make it work for GitHub Actions by including worker-thread.js as the extra .js file alongside your main action script.
Victor Adossi said:
Thanks for the notes on how you got here, that's really helpful. So basically you went through the guide and got everything working (curious of course how painful this was/anything else that sucked) and then realized you didn't have any file access (hurray, sandboxing!). Would you mind sharing which setup worked? You found the jco pull request that had FS stuff, and did you pull the code out of that? or did you already have the codebase set up?
Before I reply with more details I want to put into perspective how amazingly well almost all of it worked. I am not a JS developer and I only have a bit of rust experience. I wrote the initial rust code over the course of a few evenings (a joyful experience, as always) and was trying to figure out how to get the compiled binary running in a CI workflow in another repo. This is surprisingly finicky and I remembered that WASM exists. I then spent an hour or two on wasm-bindgen before realizing that I had moved outside the realm of "if it compiles, it works", i.e. anything file-system related simply panicked at runtime. I eventually found the wasm32-wasip1 target. I don't even remember anymore what workflow I tried to get a bundled js file out of it but I remember it didn't work. That's when I found wit-bindgen and jco and wasm32-wasip2. From that point on I had bunch of learning to do, which is hard to avoid with entirely new technology. But whenever I understood something, I would make a whole lot of progress. It only took maybe two evenings to get something that was at least executed by nodejs. I did not expect any of this to be so easy.
When I started with wit-bindgen and jco I initially followed the wit-bindgen github readme but I (wrongly) thought it was too complicated. I only wanted to export a function, after all, why do I need an empty struct, etc. I knew absolutely nothing about components, WIT, or any of that. I couldn't figure out how to export! a function so I went to find more tutorials and found a much more complicated example that also ended up using an empty struct. Eventually I cobbled together something that ran. And then I got really confused about the resulting permission errors. I am not sure if I read somewhere that the shim allows access to all paths by default or if I just imagined that. In any case, I found the sandboxing PR and that fixed it. I was using jco without --instantiation and was actually quite happy about that setup because it seamlessly inlined wasm code for me, which was a great help in my quest of bundling everything into a single file. When I started using --instantiation I first had to spend some time writing a script that would inline the wasm modules for me (by hardcoding an map from paths to base64 values in my getCoreModule function). The rest was just bundling problems.
ktz_alias said:
About the single-file issue: yeah, unfortunately Worker threads in Node require a separate file, so a truly single-file bundle is tricky. You might be able to make it work for GitHub Actions by including
worker-thread.jsas the extra.jsfile alongside your main action script.
I was looking at the docs for Worker earlier and they seem to suggest that the class accepts inline JS code as long as options.eval is set to true. I can't tell from the docs if the behavior is equivalent to passing the code as a file, though.
I guess I could try patching the shim. Maybe I can find out what happens if I pass the file contents directly.
Yeah, that actually works. It does require bundling worker-thread.js into a file that only depends on node:*, though, so that is going to be a bit tricky to support generically.
Thanks for coming through with the fix here @ktz_alias !
@Janno Yes, the exports addition is more for internal hygiene than a strict requirement—basically, it makes the package more robust for consumers and avoids hardcoding paths to
node_modules. > @Victor Adossi , I guess this is roughly the intention, right? :smile:
Yep that was the intention, also to help with tree shaking.
@Janno Glad to hear that it was at least somewhat smooth when you were starting up. We have some incredibly talented people working on the Rust and JS toolchains so it's thanks to them!
That's when I found
wit-bindgenandjcoandwasm32-wasip2. From that point on I had bunch of learning to do, which is hard to avoid with entirely new technology. But whenever I understood something, I would make a whole lot of progress. It only took maybe two evenings to get something that was at least executed bynodejs. I did not expect any of this to be so easy.
BTW, another way that you can get the binary to run is using wasmtime, the other host implementation that is currently present. You can even do things like wasmtime run --invoke and you don't have to write a full-blown embedding! That said I know the use case is GH actions and the easiest way to make things work there is definitely with JS so I totally understand :)
I would really love to have your example code in the jco repo, would you mind making a contribution?
You could also point me at the repo and I'll port over the code to a PR in the Jco repo and credit you if you'd like! This is a great example to have.
I think with regards to making the documentation a bit easier to get around concretely it would be good to:
wit-bindgen's README, front and center. It would have been really great if it was easy for you to land on some documentation that introduced the concepts and quick examples etc before you went down the rabbit hole (even though you got it done just fine). I spend time contributing to the docs too so always keen to find where we can make the flow easier.
I guess I could try patching the shim. Maybe I can find out what happens if I pass the file contents directly.
Maybe we can find another way to make this work. If only JS had the concept of feature flags during build! I think the closest we can get is making another entrypoint that bundles the code natively.
@Janno this is just GREAT information about what you were doing, and how hard to tried to get here, and yet you did make it work (mostly). Thanks for your time taken to tell us about it.
Last updated: Mar 23 2026 at 16:19 UTC