Deno can bundle JavaScript

Lately I’ve been experimenting with Deno to help me need less tooling when building for the browser. Deno can be used to create servers and scripts, and we do use it for those purposes, but what I am interested in is getting back to building in the browser without needing so much tooling like Babel or npm. Those are not bad tools, but I feel much more productive when I’m just writing code that runs in the browser.

Just a quick aside: Deno has a built in deno bundle CLI command which I’m not using. It is intended to bundle code intended to be used as a server or script and not for the browser. To bundle for the browser, one must use the unstable emit API. There are a lot of discussions in various GitHub issues about how the bundling CLI command should work, if you are curious to read more about it.

All modern browsers support features like Promises, async/await, ES modules or import/export, and almost any other JavaScript features you can think of. When I’m developing, I like to use these features and not transpile everything all the time.

I’ll make a little script that periodically counts upward to illustrate using these new features directly in the browser and then show how one could use Deno to bundle that script for “production” or what-have-you.

Initial little script

You can find all the files related to this post in a repo on GitHub, so no need to copy and paste from the article 🤓. Also, you’ll need to have the latest version of Deno installed, which as of writing is v1.11.5.

To keep things simple, this will be the three files (one HTML, and two JavaScript):

index.html:

<!doctype html>
<html>
<head>
  <title>Example using ES modules both locally and remotely</title>
</head>
<body>
  <p id="current-count">000</p>
  <script src="index.js" type=module></script>
</body>
</html>

index.js:

import { periodiclyCount } from './local-lib'
import numeral from 'https://esm.sh/numeral'

const p = document.getElementById('current-count')

periodiclyCount(count => {
  const num = numeral(count)
  const formatted = num.format('000')
  p.innerText = formatted
})

local-lib.js:

let current = 0
let timeout = 0

export function periodiclyCount(cb) {
  timeout = setTimeout(() => {
    current += 1
    cb(current)

    periodiclyCount(cb)
  }, 5000)
}

export function stopCounting() {
  clearTimeout(timeout)
}

OK, some things to call attention to:

  1. The HTML includes the JavaScript in a <script> tag with the attribute type=module, which is important. This enables the ability to import and export
  2. The code is not just including a local library file, it’s also including an npm module named numeral using esm.sh, and I’ll write a bit more aobut esm.sh below
  3. This won’t currently work 😢

If you drag the index.html file into your browser and then look in the Console, you’ll see an error similar to:

Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at file:///.../deno-can-bundle-typescript-blog-post-1/index.js. (Reason: CORS request not http).

Ah yes, CORS again! I am often mad at CORS; I end up fighting it pretty frequently. Either way, the problem is we can’t make a “request” for other files if we are not on http. So we need a server to serve the files for us.

Little server

Since we are already planning to use Deno to bundle things into one file, we can also use Deno to make a little server so we can serve our files over http and not have to deal with the CORS problems anymore.

A quick server could be (this is also in the repo):

server.ts:

import { Application, send } from 'https://deno.land/x/[email protected]/mod.ts'
import loggerMiddleware from 'https://deno.land/x/[email protected]/mod.ts'

const app = new Application()

app
  .use(loggerMiddleware.responseTime)
  .use(loggerMiddleware.logger)
  .use(async c => {
    await send(c, c.request.url.pathname, {
      root: '.',
      index: 'index.html'
    })
  })
  .addEventListener('listen', ({ port }) => {
    console.log(`Listening http://localhost:${port}`)
  })

app.listen({ port: 10000 })

The server’s code is a bit longer than it needs to be because I like to see some logs print out when a request is made, just to make sure things are working 😉

Now, Deno is “everything off” by default. So, when we run this server have to enumerate what it should be able to do. We need this server to be able to hit the network (to request the files from https://deno.land/...) and to read from our local disk (to read and serve our files to the browser). The command to run the server looks like this:

$ deno run --allow-net --allow-read --unstable server.ts

I always package commands like this up into a file like run-server.sh and in the repo you can find such a file.

OK, we can now run the server and see our files in the browser.

What is esm.sh?

If you run the little server and load localhost:10000 you can look in the developer tool’s Network panel to see what’s happening:

Screenshot of the Network panel in Firefox showing all the different resources loaded from various places

You can see we are loading our local library from our local server and we are loading the numeral npm module from esm.sh which uses a tool called esbuild to bundle every npm module targeting browsers and serves those bundles through a CDN. So, we can load almost any npm module into our browser by import-ing it. There are other websites that do this like unpkg.com or snowpack.dev, but I prefer using esm.sh for one reason: they also serve TypeScript definitions.

The special x-typescript-types header

TypeScript types don’t matter at all in the browser, but they do matter a lot in my editor. I like to see the autocomplete or be able to get some help when writing my JavaScript and/or TypeScript. When using Deno, it fetches and caches all remote code once so it doesn’t have to be reloaded over and over again. When fetching the code to cache it, Deno will see this x-typescript-types header, and it will also fetch and cache the .d.ts file. This means when I import from something like react, my editor can show me what imports are available and their type signatures. See this example:

Screenshot of Visual Studio Code autocompleting possible imports from the react library when importing from esm.sh

This is not at all necessary to build in the browser, but I very much prefer it.

Fine, but are we going to ever bundle our code into one file or not?

Sure, let’s do it. Let’s write the code necessary to “emit” a single file which would include all our dependencies.

bundle.ts:

const encoder = new TextEncoder()
const { files } = await Deno.emit('./index.js', {
  bundle: 'classic',
  compilerOptions: {
    target: 'es2018'
  }
})
const result = files['deno:///bundle.js']
await Deno.writeFile('./dist/bundle.js', encoder.encode(result))

A few things to note:

  1. Choosing the bundle type of 'classic' means it will remove any imports or exports and package everything as an IIFE
  2. I’ve chosen the target of 'es2018' but there are [many possible targets][] one can choose
  3. When bundling many files into one, the “result file” always has the name deno:///bundle.js

Is this as good as Webpack or Rollup? No.

Deno isn’t doing any treeshaking or other nice things for us. For example, the bundle includes all of the numeral library, even the parts we are not using, so it weighs in at 23 KB:

$ exa -l dist/
.rw-r--r-- 23k myobie  5 Jul 09:23 bundle.js

However, for something small or for development and testing purposes, this has been working really great for me. I just want to write code that goes to the browser, without a step 2. Having a little Deno dev-server has been great for that. I feel much more productive when I don’t have to wrestle tooling everyday 🙂

Deno supports TypeScript and JSX natively. In the future I’ll write more about how to augment this tiny server to support building something like a React app with TypeScript without needing npm, node, webpack or any of the usual tooling.

Thanks for reading. What do you think about using Deno as a development server for JavaScript apps? Let me know.