loading...
Cover image for How To Bundle Code For Deno Web Applications

How To Bundle Code For Deno Web Applications

craigmorten profile image Craig Morten ・9 min read

My a previous post on writing a React SSR app in Deno I covered how you could write a server-side rendered React app making use of JSPM and by serving client bundles using template literals.

Since the post was written over a month ago, the Deno team have released a fleet of features and bug fixes which means we can now do client-side script bundling in a far more elegant way, and is likely far closer to your current workflows in Node projects.

In this post I'll cover the deno bundle command and the Deno Compiler API, and guide you through how we can use these features to create a working React SSR application, complete with a bundled client-side script.

The Deno bundle command

Deno comes with it's own bundling capability built into the CLI.

$ deno bundle [OPTIONS] <source_file> [out_file]

This can be used to output a single JavaScript file, which includes all dependencies of the specified input.

That is, this command will include your module as well all of the sub-modules that your code imports, including remote modules importing using a URL.

For example, let's create a simple Deno script helloDeno.ts:

import { bgBlue, red, bold, italic } from "https://deno.land/std/fmt/colors.ts";

console.log(bgBlue(italic(red(bold("Hello Deno!")))));

We can run this normally using deno run helloDeno.ts:

$ deno run ./helloDeno.ts 

Hello Deno!

Where we should see a horribly unreadable Hello Deno! written in red on a blue background 😂.

Now let's check out what the deno bundle command does! We can call it with our helloDeno.ts file and provide a target output helloDeno.bundle.js. If you don't provide an output it will print to stdout on your console.

$ deno bundle ./helloDeno.ts helloDeno.bundle.js
Bundle ~/helloDeno.ts
Emit "helloDeno.bundle.js" (9.37 KB)

You should now have another file in your directory called helloDeno.bundle.js 🎉. I encourage you to open it and have a quick read through - it's complicated, but within the system registering code you should be able to find what you wrote! It will look something like:

// ... rest of the code

execute: function () {
    console.log(colors_ts_1.bgBlue(colors_ts_1.italic(colors_ts_1.red(colors_ts_1.bold("Hello Deno!")))));
}

// ... rest of the code

If you look closely you should also be able to find all of the code from the https://deno.land/std/fmt/colors.ts module we imported - as promised it has bundled all of our code, including sub-modules.

We can check that it runs by using the Deno CLI again:

$ deno run ./helloDeno.bundle.js                
Hello Deno!

This time you should notice that the execution is nearly instant! Because we have already bundled the Deno code down to a single JavaScript file we no longer have the overhead of having to run the TypeScript compiler and fetch remote modules etc. Deno can just get on with running the code!

You could now use this command to create bundled code as part of your CI / CD pipeline for client-side assets.

The Deno Compiler API

Deno also offers bundling methods as part of it's core runtime Compiler API.

Please note that this API is currently unstable. To learn more about using unstable APIs and how to use them, check out the Deno stability documentation.

This API supports three different methods built into the Deno namespace that provide access to the built-in TypeScript compiler. These are:

  • Deno.compile() - Similar to deno cache. It can fetch and cache the code, compile it, but does not run it. Returns diagnostics and a map of compiled filenames to code, but does not create any files - you need to perform this yourself.
  • Deno.bundle() - This works a lot like deno bundle. It is also very close to Deno.compile(), but instead of returning a map of files to code, it returns a single string which is a self-contained ES module.
  • Deno.transpileOnly() - Based off of the TypeScript function transpileModule() and simply converts the code from TypeScript to JavaScript and returns the source and a source-map.

Let's see how the first two could work with our simple helloDeno.ts script! (We don't cover Deno.transpileOnly(), but it is very similar to the other two).

Deno.compile()

Create a file called compile.ts and add the following:

const [diagnostics, emitMap] = await Deno.compile(
  "./helloDeno.ts",
);

console.log(emitMap);

You can then run the compile script using the following command:

$ deno run --unstable --allow-read --allow-net ./compile.ts

Note the various permissions we need to add. We are reading a local file helloDeno.ts which requires --allow-read, fetching a remote file for colours which requires --allow-net and using an unstable API which needs --unstable.

You should then see in your console something that looks like:

{
  https://deno.land/std/fmt/colors.js.map: '{"version":3,"file":"colors.js","sourceRoot":"","sources":["colors.ts"],"names":[],"mappings":"AAAA,...',
  https://deno.land/std/fmt/colors.js: "// Copyright 2018-2020 the Deno authors. All rights reserved. MIT license.
/** A module to print ANS...",
  ~/helloDeno.js.map: '{"version":3,"file":"helloDeno.js","sourceRoot":"","sources":["helloDeno.ts"],"names":[],"mappings":...',
  ~/helloDeno.js: 'import { bgBlue, red, bold, italic } from "https://deno.land/std/fmt/colors.ts";
console.log(bgBlue(...'
}

It has successfully compiled our code and generated a map of filenames to JavaScript code and source-maps.

Deno.bundle()

Let's now create a bundle.ts file and add the following:

const [diagnostics, emit] = await Deno.bundle(
  "./helloDeno.ts",
);

console.log(emit);

This should look very similar to our compile.ts script! But if we now run it, we should see something very different in our console:

$ deno run --unstable --allow-read --allow-net ./bundle.ts

// Copyright 2018-2020 the Deno authors. All rights reserved. MIT license.

// This is a specialized implementation of a System module loader.

"use strict";

// @ts-nocheck
/* eslint-disable */
let System, __instantiate;
(() => {
  const r = new Map();

// ... rest of the code

It has printed a single string of the bundled code to stdout which matches the exact output of running the deno bundle command earlier. In fact, we can recreate the deno bundle command by writing the value of emit to a file, e.g.:

const [diagnostics, emit] = await Deno.bundle(
  "./helloDeno.ts",
);

await Deno.writeTextFile("./helloDeno.bundle.v2.js", emit);

Executing the script again, but this time with the --allow-write permission will result in a file called helloDeno.bundle.v2.js being created with all the bundled code. 🎉

$ deno run --unstable --allow-read --allow-net --allow-write ./bundle.ts

Writing a React SSR app with client JS bundling

Let's now look at how we could use these compiler APIs within our application by reviewing this Opine React example.

If we look at client.tsx we can see that it is the entrypoint to the client-side JavaScript, and is responsible for hydrating the React application into an element with id set to root.

import React from "https://dev.jspm.io/react@16.13.1";
import ReactDOM from "https://dev.jspm.io/react-dom@16.13.1";
import { App } from "./components/App.tsx";

(ReactDOM as any).hydrate(
  <App />,
  // @ts-ignore
  document.getElementById("root"),
);

The App that is referenced is found in a components folder, and this creates a simple React application with some sub-components rendered using React Suspense.

// @deno-types="https://deno.land/x/types/react/v16.13.1/react.d.ts"
import React from "https://dev.jspm.io/react@16.13.1";
import { Title } from "./Title.tsx";
import { List } from "./List.tsx";

export const App = ({ isServer = false }) => {
  if (isServer) {
    return (<>
      <Title />
      <p className="app_loading">Loading Doggos...</p>
    </>);
  }

  return (<>
    <Title />
    <React.Suspense fallback={<p className="app_loading">Loading Doggos...</p>}>
      <List />
    </React.Suspense>
  </>);
};

By making use of the // @deno-types ... compiler hint we can also make use community written types for popular modules such as React.

If we now move to the server.tsx file, we can see that this is intended to be the main entrypoint of the application. If you look at the top of the file you might see some code that looks very familiar!

import { opine, serveStatic } from "../../mod.ts";
import { join, dirname } from "../../deps.ts";
import { renderFileToString } from "https://deno.land/x/dejs@0.7.0/mod.ts";
import React from "https://dev.jspm.io/react@16.13.1";
import ReactDOMServer from "https://dev.jspm.io/react-dom@16.13.1/server";
import { App } from "./components/App.tsx";

/**
 * Create our client bundle - you could split this out into
 * a preprocessing step.
 */
const [diagnostics, js] = await Deno.bundle(
  "./examples/react/client.tsx",
  undefined,
  { lib: ["dom", "dom.iterable"] },
);

if (diagnostics) {
  console.log(diagnostics);
}

/**
 * Create our Opine server.
 */
const app = opine();
const __dirname = dirname(import.meta.url);

// ... rest of the code

The first thing the server code does is use the Deno.bundle() method to create a single js bundle using the client.tsx file as the entrypoint. You can then see further down in the script that this JavaScript is then served on a /scripts/client.js path:

// ... rest of the code

/**
 * Serve our client JS bundle.
 */
app.get("/scripts/client.js", async (req, res) => {
  res.type("application/javascript").send(js);
});

// ... rest of the code

If you were looking closely, you may have noticed that the code has also passed some extra parameters to the Deno.bundle() method which we haven't covered yet! It turns out there are some additional optional parameters which you can make use of.

Deno.bundle(rootName [, sources] [, options])

This code example doesn't make use of the sources option, but you can check out how it works in the Deno documentation.

What is provided is the last options argument. This is a set of options of type Deno.CompilerOptions, which is a subset of the TypeScript compiler options containing the ones which are supported by Deno.

This application makes use of the lib option which allows you to define a list of library files to be included in the compilation. This means you can define the libraries required for the specific destination(s) of your code, for example the browser where you would usually define something like:

const [diagnostics, emit] = await Deno.bundle(
  "main.ts",
  {
    "main.ts": `document.getElementById("foo");\n`,
  },
  {
    lib: ["dom", "esnext"],
  }
);

In the above snippet we tell Deno to bundle a script called main.ts, which in this instance is defined using the sources option opposed to using an existing file, and some additional compiler options which tell the compiler that the intended target requires DOM library and ESNext support.

If you want to learn more about compiler options you can find more details in the TypeScript compile options documentation.

Running the React application

So having covered the main aspects of the code (I encourage you to read through the rest to see how it all works!), let's run the example and see the results!

First we need to clone the Opine repo locally, for example:

# Using SSH:
git clone git@github.com:asos-craigmorten/opine.git

# Using HTTPS:
git clone https://github.com/asos-craigmorten/opine.git

If then make the repository our currently working directory (e.g. cd opine) we can then run the command provided in the example's Readme:

$ deno run --allow-net --allow-read --unstable ./examples/react/server.tsx

Check ~/opine/examples/react/server.tsx
Opine started on port 3000

If we open our browser to http://localhost:3000 we can see that our application has started and running successfully! :tada

Deno React example running in the browser

And if we open our developer tools, we can see in the Network tab that the application is successfully fetching the bundled client.js file from the server and using it to run React client-side.

Developer tools Network tab with successful client.js request

Congratulations, you've managed to run a relatively complicated application (it's using Suspense for data-fetching!) using Deno and it's bundling capabilities! 🎉 🎉

This particular example has opted to run the bundler when the server starts. In production it is more likely you will run the compilation / bundling as a pre-requisite step within your CI / CD, but all the concepts are the same as we have covered!


I hope this was useful!

One thing to note is that these APIs are still marked as unstable, so you may find they change and / or break from one release to the next, however they are now in a reasonably good state so that is unlikely! If you want to be cautious, the CLI commands are stable so you can always fall back to using deno bundle instead of writing JavaScript code for the compilation.

Let me how you're managing your client-side code and bundling in Deno! Doing something different, or found a third party bundler that works really well? I'd love to hear about it in the comments below!

Till next time! 🦕

Posted on by:

craigmorten profile

Craig Morten

@craigmorten

26 • London • That JS Guy • JavaScript, TypeScript, React, Node, Deno, Kubernetes, Azure • I also tweet stuff

Discussion

markdown guide