The road to universal JavaScript
Universal JavaScript. JavaScript that works in every environment. JavaScript that runs on both the client and the server, something thinking about for years (see 1, 2). Where are we now?
A little example #
Let’s say I need to parse the titles from 100 podcast episodes. They’re in some old XML format that is a bit tough to parse. What do I need to write this in modern-day Node.js?
import { XMLParser } from "fast-xml-parser";
import { url_prefix } from "./data.mjs";
function fetch_episode(episode_number) {
return fetch(`${url_prefix}${episode_number}`)
.then(res => res.text())
.then(data => {
const obj = new XMLParser().parse(data)
return obj['!doctype'].html.head.meta.meta.title
})
.catch((e) => {
return undefined
})
}
const episode_requests = new Array(100)
.fill(0)
.map((el, i) => fetch_episode(i))
const results = await Promise.all(episode_requests)
// List all of them
console.log(results.filter(Boolean))
Okay, that’s not bad. fast-xml-parser
is a Node.js dependency. Since the Node.js team spent some time getting modules up and running, I can use this CommonJS style module in an EcmaScript module. Just like that.
$ npm install --save fast-xml-parser
Loading resources via fetch
is available in Node 18 without a flag. You can test it in earlier versions using --experimental-fetch
. There are some edge cases that might require some attention, but overall it’s in a good shape and it’s fun to use. Fantastic work, Node, and Undici teams!
What about Deno? #
There are more JavaScript runtimes out there. What about Deno? This is my main script:
import { XMLParser } from "fast-xml-parser";
import { url_prefix } from "./data.mjs";
function fetch_episode(episode_number) {
return fetch(`${url_prefix}${episode_number}`)
.then(res => res.text())
.then(data => {
const obj = new XMLParser().parse(data)
return obj['!doctype'].html.head.meta.meta.title
})
.catch((e) => {
return undefined
})
}
const episode_requests = new Array(100)
.fill(0)
.map((el, i) => fetch_episode(i))
const results = await Promise.all(episode_requests)
// List all of them
console.log(results.filter(Boolean))
Wait? That’s the same script? Does it work just like that?
Not exactly. Deno uses a different way to load modules: It requires them via pointing to a URL. Tools like Skypack and JSPM allow Node.js dependencies to be delivered via URL. And a nice feature called Import Maps make it nice to wire them up in your code.
{
"imports": {
"fast-xml-parser": "https://ga.jspm.io/npm:[email protected]/src/fxp.js"
},
"scopes": {
"https://ga.jspm.io/": {
"strnum": "https://ga.jspm.io/npm:[email protected]/strnum.js"
}
}
}
There is an import map generator that lives on the JSPM site. The same output can be used to make the same script work in the browser (CORS issues notwithstanding).
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>The website's title</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<script type="importmap">
{
"imports": {
"fast-xml-parser": "https://ga.jspm.io/npm:[email protected]/src/fxp.js"
},
"scopes": {
"https://ga.jspm.io/": {
"strnum": "https://ga.jspm.io/npm:[email protected]/strnum.js"
}
}
}
</script>
<script type="module">
// .. see above
</script>
</body>
</html>
But isn’ that cool? Since the fast-xml-parser
has no native dependencies, just JavaScript, it works out of the box.
Cloudflare workers #
Okay, there are more JavaScript runtimes out there. One JS runtime that I use a lot are Cloudflare workers. They’re edge handlers and allow for quick transformations of responses before you deliver. I can use – you guessed it – the same script as above. I deal with dependencies by bundling them up with esbuild
$ esbuild index.mjs --bundle --outfile=bundle.js --format=esm
I also limit the number of titles to fetch by 10. Cloudflare workers are for edge responses, they need to limit the outgoing connections for tons of reasons.
On my own JavaScript runtime #
I currently work on a JavaScript runtime. It’s based on Rust and Deno and deals with a couple of intricacies that are unique to the domain I’m operating in. Early on we decided to focus on web standards and support fetch
, EcmaScript modules, etc. to make sure we have a common subset of compatibility. Guess what. The script above works just like that on my own JavaScript runtime.
Winter #
I think having the possibility to run the same code everywhere is exciting, and it’s also a wonderful step in the right direction. It’s a start, and there’s a lot more to achieve in the future. But the future looks bright.
Today, folks at Cloudflare, Deno, Vercel, Node.js, Bloomberg, and Shopify have announced the Web Interoperable JavaScript Community Group, or in short: wintercg
. This group wants to ensure that all runtime vendors move in the same direction by adopting features that are available in browsers as a common standard.
Also, check out James Snell’s talk “Yes, Node.js is part of the web platform”, which should give you more ideas about where everything is heading.
For me, it’s great! Choosing web standards has made my effort compatible with all the other vendors. What does this mean for you? Cross-platform compatible dependencies. You chose the platform that works best for your need, and you can take your app along.