Express Containers (DEMO)
Running the Express server in the web browser is not new. It's the entire reason why Browserify exists. But it's 2024 so we can do better:
- Live code edit. I give you a text editor, what you do with it is up to you. Click the "Start the server" button to start the app.
- Ever wanted to delete the whole content of
node_modules/express/lib/application.js
? - Edit
$__node-std-lib/_monkey_patch.js
to change node'sprocess
shim. E.g. silence Express' debug logs by removing theDEBUG
key inprocess.env
. - Yes, you can change the Express server port. My shim even allows negative ports, but let's keep that as a secret between us.
- Ever wanted to delete the whole content of
- Your Express code does not run in the same environment as the main page. It's encapsulated inside the Wasm-ed QuickJS JavaScript engine. This app ships with a separate JS engine.
- Do not try to access
window
from your code, it will not work.
- Do not try to access
- Automatically intercept network requests using a service worker. The app can get the content from the Express server using both
fetch("http://localhost:3000/...)
and an iframe. Both requests will show up in the dev tools network tab.- Iframes can only work through service workers.
- Service workers can be a bit painful. By default,
fetch()
forwards the request to QuickJS. Web browser'sfetch()
never executes, my button'sonClick
handler calls the function on the QuickJS's context instead. You can switch this off with a toggle.
- If you do something bad, refresh the page. You get fresh, new, and untouched files.
This app is a proof of concept. The code quality is terrible and I only implemented whatever was needed to make it not crash. E.g. from
node:fs
you only getstat()
andcreateReadStream()
. That's enough to return static files (app.use(express.static('public'));
). If the app does not work, check WebContainers troubleshooting. I'm not affiliated with them, but it's a technology similar to mine.
basic-request_out.mp4
Start the Express server and execute a simple request against it. The request object is send directly to the QuickJS, without executing a network request. Edit the endpoint handler and restart the server to see the changed response.
service-workers_out.mp4
Using service workers to intercept network requests for iframes and fetch()
. I've shown browser network DevTools at the bottom. The user navigates inside the iframe. The initial index.html file is loaded from express.static()
directory.
My app cannot download stuff from the npm repository (see FAQ). Thus, I download all Express' dependencies during the app build. They are a 3.6 MB zip archive (vfs.zip
). I've also added a few files with shims so we do not have to request them later.
- Download
vfs.zip
, unpack it (in memory), and create a virtual filesystem (my customVirtualFS
structure). - Instantiate
QuickJSRuntime
. This is a generic QuickJS structure that does not do much by itself.- We override its module resolution mechanism (what happens when QuickJS sees an
import
statement). This is only used fornode:fs
andnode:net
- see bundler notes below.
- We override its module resolution mechanism (what happens when QuickJS sees an
- Show the UI to the user.
- Bundle the code using
@rollup/browser
(uses Wasm internally).- One of the main problems is rewriting CommonJS (
const fs = require('fs');
) statements. Browsers only support official ECMAScript modules (import * as fs from 'node:fs';
). - I've written a custom Rollup plugin to redirect file access to my
VirtualFS
structure. E.g. Rollup asks for"express"
and I resolve it as a string containing the file's content.- My module resolution logic is.. simplified.
- I also ported Rollup's CommonJS and JSON plugins to work in a web environment. E.g. dynamic imports are not supported.
- One of the main problems is rewriting CommonJS (
- Now we have a string that contains the app's code.
- Create a separate virtual filesystem for the finished bundle. Think of it as the
/build
folder. Let's call itvfs-2
.- Write the bundled app code into
vfs-2
's/index.js
. - Add some special files to
vfs-2
. E.g.:$__monkey_patch.js
is a script that initializes the exec environment (e.g. setsglobalThis.process
, that you know from Node).
- Copy
/public
from the original filesystem tovfs-2
. Needed to handle requests for static files. QuickJSContext
will have access to every file insidevfs-2
.
- Write the bundled app code into
- Create
QuickJSContext
fromQuickJSRuntime
. It's the thing that actually executes the code.- Inject code to handle
console.log()
,setTimeout()
. These do not exist in QuickJS by default. - Inject code that QuickJS uses to communicate with the web browser. It can:
- Request files from the
vfs-2
filesystem usingnode:fs
. - Inform the browser that Express finished initialization. Includes info about the server's port.
- Request files from the
- Run my special
$__monkey_patch.js
script to set globals e.g.process
.
- Inject code to handle
- Start the server by executing the app's script. It's the code we generated using Rollup. Accessed as
vfs-2
's/index.js
. - Notify the web browser that Express is up and running.
This is the easy way to send a request to the Express server. It does not involve service workers.
- Express is already running in our QuickJS context.
- The definition of
running
is a peculiar one. QuickJS context has a state stored in the memory and we can call another script to execute against it. This memory contains info about the Express app. The app is not "running", "waiting", or "epoll()-ing". It exists in the memory.
- The definition of
- Web browser calls a method we added to QuickJS context's
globalThis
.- Look for
forwardRequestToVM(port=_, pathname=_)
log. - In code:
const result = quickJsContext.callMethod(portListeners, 'invoke', [portHandle, pathnameHandle]);
- Look for
- QuickJS runs the entire Express to generate a response.
- The web browser receives a response by unmarshaling the
result
variable. See the sample code from point 2. - The response is a regular JS object with properties like
statusCode
,headers
, anddata
. We are done.
Instead of calling context.callMethod()
, we do an actual network request. This way we can pretend everything is normal. The request will show up in the dev tools. We can handle even an <iframe>
.
- Do the network request using
fetch()
or<iframe>
. - The service worker intercepts the request. It uses
postMessage()
to send a message back to the web page. - The web page calls
quickJsContext.callMethod()
as described in "the easy way" section. - The web page uses
postMessage()
to send the response back to the service worker. - The service worker gets the response and uses it to fulfill the original request.
In retrospect, it might have been better to run everything inside a service worker. Not sure if that's possible though.
yarn install
cd example-app && yarn install
- install dependencies for the Express app.cd ../extra-dependencies && yarn install
- install extra dependencies required by Rollup and shims.cd ..
yarn run:gen-fs
- create an initial filesystem ("vfs.zip"
file).yarn dev
- start the dev server- Go to http://localhost:8000/.
Or, yarn build
builds the prod version into ./build
.
You can also run the app in node using yarn run:node
. It is a combination of 3 commands:
yarn run:gen-fs
. Create an initial filesystem .zip file (same as above).yarn run:bundler
. Bundle the app code into./static/bundled-express.js
.yarn run:app
. Run the bundled app. Endpoint hardcoded to/hello?param0=1¶m2
.
Don't ask me, you are the one who has already read half of this readme.
After nanite WebGPU I was interested in WebAssembly. It allowed me to run Metis and meshoptimizer (both written in C) inside a web browser. What other things can it do?
I also was curious about Edge computation environments (like Next.js's middleware). They are limited in capabilities and run JavaScript. This led to QuickJs, etc.
Express is... Express. Makes for a cool demo.
You have to port the entire node API into the browser. There are a lot of shims already on npm, but all of them need updating. E.g. The virtual filesystem is partially done. No permissions or stats, but you have basic files and directories.
You also do not have access to the operating system API. This is a problem e.g. for node:crypto
.
Seems like a lot of work. I'm happy with my proof of concept. If you ever wanted to do something similar, at least now you know where to start.
I was interested in edge computing, Next.js' middleware in particular. It runs a gutted JS environment. As for usage, think of nginx's Lua scripts.
The main goal is to isolate the data. I have strict control over what is sent to QuickJS. If QuickJS crashes, I can restart it. For example, each Express request is reduced to a (port, pathname, query string)
tuple. I remove all request headers as none of the examples use them. It's a separate JS environment with strict API boundaries.
An interesting experiment. And it works. In practice, it should be possible to get similar guarantees from web workers.
Probably. There were a lot of blind alleys in this project. I'm just releasing some version that somehow works.
"vfs.zip" is the initial state of the virtual filesystem. It mainly contains the express app and its' node_modules
. The first thing my app does is unpack it and shove the data into my own VirtualFS
structure. I also added a few of my internal files.
The file is built during the GitHub actions workflow. Reasons to not fetch packages from npm at runtime:
- Suboptimal startup time.
- CORS error.
- To bypass this, it's customary to use a proxy server between the webpage and the npm repository. I ain't paying for that.
- I'd rather not build a separate package manager.
In the end, everything lands in my VirtualFS
structure. And this is where the fun part begins.
PS. After the app is bundled into a single file it gets its separate filesystem. Think of something like a
/build
directory with artifacts. It contains the final code, 3 JS files with shims, and content of the Express app's/public
to serve static files. You can preview it using a toggle in theFiles
panel if the server is running.
v86 emulates the entire CPU. It can run both Linux and Windows. I monkey-patched some JavaScript when Express threw errors. There are no similarities between our projects.
I'm not sure how StackBlitz's WebContainers work, but the main purpose is similar. The user gets to change files in a virtual filesystem and run newly written node code inside the browser. All with a single button click, no external connection is required. The basic problems are similar. E.g. They also have to handle CommonJS (const fs = require('fs')
) as it's not supported in web browsers.
I admit I did not research much of their technology. Can all their features be supported just by JS/Wasm shims? What about natively compiled packages? There is a way to inject the non-compiled versions, but it seems like a lot of "fix error after error till it works" programming. Looking at their marketing materials they ported e.g. npm
, pnpm
, and yarn
. I never looked at how package managers work.
I'm not sure if they use a separate JavaScript environment (QuickJS for me). How do you stop access to global variables? Half of the npm checks for window
to make decisions at runtime. You can sidestep this problem with bundling. Even simple Immediately Invoked Function Expressions (IIFE) allow total control over code dependencies. But you also have to deal with all other global stuff like location
, URL
, and document
. You can (should) use web workers. Not sure how much API they leak. With QuickJS I ignore the problem. It does not have variables from the browser (e.g. window
) or node (e.g. process
). I even got to write my own (quasi) event loop.
How do they deal with 1+ GB of node_modules
that are needed by seemingly every node app? It has to be downloaded and persisted. I heard indexed DB is used for storage in similar cases.
Looking at StackBlitz's career page, they want TypeScript and Rust. I know Rust has some ABI problems with Wasm e.g. compatibility with C (which might be needed?). I think a lot of choice is limited by easy access to Wasm modules. Unless you rely on many in-house libraries or Rust std lib (is it Wasm-compatible?) to help with the implementation. Or glue WebAssembly modules with tons of TS.
Sure, why not? Receive a message, format it as a url to QuickJS Express server, and then forward the response to the client. Or use it with service workers. The browser calls fetch()
which gets intercepted. Congratulations, your browser is now a proxy server.
- Rollup. Never used it before, but works well. Even has a browser version (using Wasm) - perfect! API is as good and enjoyable as
esbuild
, which is rare.- The plugin API is a bit strange, but I did not get in-depth into it.
- esbuild-wasm should also work. Will require custom plugins to handle the virtual filesystem.
- Fabrice Bellard and Charlie Gordon for QuickJS Javascript Engine.
- kosamari for "ServiceWorker for github pages" notes.
I ran out of GitHub pinned repos?