Developing Without a Build: es-dev-server
This article is part of a series on developing without a build:
- Introduction
- es-dev-server (this article)
- Testing (coming soon!)
In the introduction article, we looked into different workflows and browser APIs. In this article, we will look into how we can set up es-dev-server
, and how it can help us developing without a build.
es-dev-server
es-dev-server is a composable web server that focuses on developer productivity when developing without a build step. Through options, you can opt into extra features such as caching, reloading on file changes, SPA routing, resolving bare module imports and compatibility modes to support older browsers.
Setup
To start off let's create an empty npm project and install es-dev-server
:
npm init
npm i -D es-dev-server
Create an index.html
in the root of your project:
<!DOCTYPE html>
<html>
<head></head>
<body>
<div id="app"></div>
<script type="module" src="./src/app.js"></script>
</body>
</html>
And create a dummy src/app.js
file:
console.log('hello world');
We can now run our application with es-dev-server
:
npx es-dev-server --open
Without any extra flags, es-dev-server
acts like a regular static file server. Any extra functionality needs to be enabled explicitly.
Bare imports
One of the first things you will run into when developing without any build tools is how to deal with bare module imports like this:
import foo from 'foo';
Out of the box, the browser will throw an error, as it doesn't know how to handle these kinds of imports. In our previous article we explored how to use these imports by using import maps, an upcoming browser API.
Until import maps are properly supported in browsers, we can use the --node-resolve
flag of the dev server. This will rewrite imports in your modules using NodeJS module resolution before serving them to the browser.
To see how this works let's add lit-html
, a HTML templating library, to our project:
npm i -D lit-html
Change src/app.js
to import it:
import { render, html } from 'lit-html';
const template = html`<p>Hello world!</p>`;
render(template, document.getElementById('app'));
Now let's restart our server, adding the node resolve flag:
npx es-dev-server --node-resolve --open
If you inspect the network tab, you will see the modules are served correctly as expected. src/app.js
is rewritten to:
import { render, html } from './node_modules/lit-html/lit-html.js';
const template = html`<p>Hello world!</p>`;
render(template, document.getElementById('app'));
Watch mode
A great productivity booster is reloading the browser while you are editing files.
To enable this option, restart the server with the watch
flag:
npx es-dev-server --watch --node-resolve --open
Now change anything inside one of the served files, for example the rendered html in app.js
. You will see the browser reload automatically with the updated code.
Reloads are done using the EventSource
API, which is not supported on Edge and IE11. The dev server injects a small script, which connects to a message channel endpoint:
Caching
es-dev-server
uses the file system's last modified timestamp to return a 304 if the file hasn't changed. This significantly speeds up reloads. You can test this in your browser by turning off Disable cache
and refreshing:
Folder structure
Not every project has a single index.html
in the root of the project. Because es-dev-server
works just like a regular web server, it can serve files from any folder.
For example, let's create a new folder called demo
, and move our index.html
inside it.
We will need to adjust the script src path to reflect this change:
<script type="module" src="../src/app.js"></script>
And we need to tell the server to open inside the demo folder:
npx es-dev-server --node-resolve --open /demo/
The application should be displayed without any changes.
Changing the root dir
We might be tempted to change the root directory of the web server in order to get rid of the /demo/
part in the URL:
npx es-dev-server --root-dir /demo/ --open
However, this won't work because the web server can only serve files that are within its root directory. By default, this is the current working directory. In our case, the web server needs to be able to serve the contents of the src
folder, as well as the node_modules
folder.
This is a common problem when working in a monorepo when you want to serve files from a package subdirectory. Many of the modules you need to serve are in the root of the project, so you need to move the root directory up two levels:
npx es-dev-server --root-dir ../../ --open packages/my-package/index.html
SPA Routing
If you are building a Single Page Application you are likely doing some form of front-end routing. In order to enable deeplinking or refreshing, the web server should return your index.html
on deeper paths. This is sometimes called history API fallback.
Setting up a router is beyond the scope of this article, but the option is easy to enable using the --app-index
flag:
npx es-dev-server --node-resolve --app-index index.html --open
When using the --app-index
flag, the server will automatically open the server on your app's index if you don't pass an explicit path to --open
.
Compatibility with older browsers
Although we can use the latest versions of the major browsers for development, we still need to support older browsers in production. We also might be using some new browsers features that are not yet supported in the latest version of all the major browsers.
It would be a shame if we have to run a production build every time we want to run our app on one of these browsers. es-dev-server
supports multiple compatibility modes that help with this.
When compatibility mode is enabled, the server handles the necessary polyfills and code transformations for older browsers. This takes us into build tooling territory, so we're no longer purely "developing without build tools". I think that's fine, as we're using it only for browser compatibility. You have to opt-in to this behavior explicitly.
Let's see how it works in action. Add a dynamic import to app.js
to lazy load a module when a button is clicked:
import { html, render } from 'lit-html';
async function lazyLoad() {
await import('lit-html/directives/until.js');
}
const template = html`
<button @click=${lazyLoad}>Click me!</button>
`;
render(template, document.getElementById('app'));
The dynamic import does not really do anything functional. If we run this on Chrome, Safari, and Firefox it works just fine. Because Edge does not yet support dynamic imports we can't run this code there.
We can turn on the lightest compatibility mode, esm
, to handle this case:
npx es-dev-server --node-resolve --compatibility esm --open
With esm
enabled, es-dev-server
injects es-module-shims and adds a loader script to your index. You don't need to change any of your code for this. You can view the injected script in the index file:
There is some extra boilerplate, as the other compatibility modes might add more polyfills. The polyfills are hashed so that they can be cached aggressively in the browser.
Besides esm
there are the modern
and all
compatibility modes. These modes inject polyfills for common browser APIs and use @babel/preset-env
for transforming the latest javascript syntax to a compatible format.
In modern
compatibility mode your code is made compatible with the latest two versions of Chrome, Safari, Firefox, and Edge.
In all
compatibility mode support is extended to older browsers, all the way to IE11. On browsers which don't support es modules, they are transformed to systemjs modules.
The transformations slow down the server a bit, so I don't recommend using modern
or all
mode during regular development. You can create separate scripts in your package.json
, and run in compatibility mode only when you view your app on older browsers.
esm
mode has a negligible effect on performance, so that should be fine to use.
Import maps
In the previous article, we briefly discussed import maps as an upcoming browser feature which handles bare module imports. es-module-shims
and systemjs
both support import maps. If compatibility mode is enabled, the dev server takes care of adapting your import maps to work with these libraries, making this a viable workflow during development.
The import maps API is not fully stabilized yet, so if you're going down this path it's good to keep an eye how this standard evolves. Check out this article for a workflow using import maps.
More options
Check out the official documentation for more options, such as integrating the dev server as a library with other tools and installing custom middlewares.
Getting started
To get started with a project that sets up the dev server for you, use the open-wc
project scaffolding:
npm init @open-wc
It sets up the project with lit-element
, a web component library. You can swap this for any library of your choosing, the setup is not specific to web components.
Top comments (4)
I filed an issue on the @open-wc repo regarding a serving of a built version of a simple wc with external libraries that doesn't work on IE11, can you help? It's very much related with what's described in this post:
github.com/open-wc/open-wc/issues/805
Both supporting IE11 and keeping the libraries external are requirements for me.
Great article! Any idea on when the testing article will be written? I am especially interested on how to set up karma. I keep running into the error "SyntaxError: The requested module '../../../node_modules/../index.js' does not provide an export named 'default'". Or named export. And I would love to see your take on how (or if) that can be handled without build tools.
I think supported compatibility modes are 'auto','always','min','max' and 'none' as per constants defined here
github.com/open-wc/open-wc/blob/ma...
I couldn't find "modern", "all" and "esm" modes
Correct! This changed since creating this blog post.