RNA
A bundler, a server and a test runner for modern modules and applications.
Building JavaScript modules
Transpiling and bundling JavaScript files has been the main cause of headaches in the JavaScript ecosystem for long time. Tools were necessarily complicated because they had to handle a lot of cases and very different environments.
Now, thanks to the wide support of modern features in browsers and the landing of ES modules in Node we can finally simplify tasks, configurations and workflows.
Using esbuild under the hood, RNA combines the fater JavaScript/TypeScript compiler out there with a bunch of plugins for assets management, env variables injection and JSX pragma auto import.
Setup
In order to bundle a JS module using RNA you may have to install the bundler:
$ npm i -D @chialab/rna @chialab/rna-bundler $ yarn add -D @chialab/rna @chialab/rna-bundler
and run:
$ npx rna build src/index.js --output public/index.js $ yarn rna build src/index.js --output public/index.js
This will generate a ESM bundle at the --output
destination. Using --format
and --platform
flags we can generate multiple bundles that targets both browser and Node environments.
Bundling for the Web
The Web is the primary target of the RNA toolchain. Everything is optimized for light builds to serve over the network and to work natively in modern browsers. For this reasons, esm
is the default output format and esbuild is configured to use the browser
platform.
So, the explicit command is equivalent to the previous run snippet:
$ npx rna build src/index.js --output public/index.js --format esm --platform browser $ yarn rna build src/index.js --output public/index.js --format esm --platform browser
When targeting the browser platform, RNA will respect your browser
configuration in the package.json
in order to optimize the build for the requested environment.
Using the browser
field is optimal for modules that need to run in both browser and node environments:
input
import jsdom from 'jsdom'; const document = typeof window !== undefined ? window.document : new jsdom.JSOM().window.document;
package.json
{ "browser": { "jsdom": false } }
output
const document = typeof window !== undefined ? window.document : undefined;
Bundling for Node
Node is also a first class output. Specifying the cjs
format, RNA will automatically target the node
platform, converting every import
statements to require
invokations.
$ npx rna build src/index.js --output public/index.js --format cjs --platform node $ yarn rna build src/index.js --output public/index.js --format cjs --platform node
input
import jsdom from 'jsdom'; const document = typeof window !== undefined ? window.document : new jsdom.JSOM().window.document;
output
const jsdom = require('jsdom'); const document = typeof window !== undefined ? window.document : new jsdom.JSOM().window.document;
Since even the LTS version of node supports ES modules, you may want to target node with the esm
format:
$ npx rna build src/index.js --output public/index.js --format esm --platform node $ yarn rna build src/index.js --output public/index.js --format esm --platform node
Modules resolution
Esbuild supports both the old fashioned main
fields as well the exports
field using a Node-like resolution algorithm.
Using exports field
When a module defines conditions as follow in the package.json:
{ "type": "module", "exports": { "browser": "path/to/browser/index.js", "require": "path/to/cjs/index.cjs", "default": "path/to/esm/index.js" } }
Esbuild will
-
resolve to
exports.browser
if--platform browser
-
resolve to
exports.require
if--format cjs
-
resolve to
exports.default
otherwise
Using main fields
When a module defines entrypoints as follow in the package.json:
{ "main": "path/to/cjs/index.js", "module": "path/to/esm/index.js", "browser": "path/to/browser/index.js" }
Esbuild will
-
resolve to
browser
if--platform browser
-
resolve to
main
if--format cjs
-
resolve to
module
if defined -
resolve to
main
otherwise
Read more about the esbuild resolution algorithm and node specifications.
Code splitting
Dynamic imports and URL assets can be used to split the code into multiple chunks that are loaded on demand. This is useful for loading pages on routing or for importing that large image manipulation library.
For example:
app.js
import { route } from 'router'; import { render } from 'view'; route('/profile', async () => { const { Profile } = await import('./pages/Profile.js'); render(Profile); });
Profile.js
import { render } from 'view'; export function Profile() { render('Hello world'); }
The build step of this app will generate 3 chunks:
-
vendors.js that includes the
view
dependency -
entrypoint.js that imports vendors.js and includes
router
dependency and app.js source -
chunk.js that imports vendors.js and includes Profile.js source
TypeScript
TypeScript syntax is supported out of the box from esbuild, also respecting your tsconfig.json file.
No supplementary plugin is required.
However, please not that esbuild will only transpile your source without checking your code. For typechecking, you still need the vanilla tsc
cli:
$ npm i -D typescript $ yarn add -D typescript
You can run tsc with the --noEmit
flag in order to execute typecheck only:
$ npx tsc --noEmit $ yarn tsc --noEmit
👉 See the Recommendations section for JSDoc typechecking and more TypeScript usage tricks.
ENV variables
Many JavaScript modules uses process variables for both browser and Node environments. Expecially frameworks and web apps try to access the value of the process.env.NODE_ENV
member in order to detect test or production environments. RNA comes with a plugin that automatically replaces the expression with the actual value.
$ NODE_ENV='production' npx rna build src/index.js --output public/index.js
Input
const response = await fetch('/data.json'); if (process.env.NODE_ENV !== 'production') { console.log('DEBUG', response); }
Output
const response = await fetch('/data.json');
The console statement will be removed because of dead code elimination.
Assets
Generally, files are referenced in JavaScript scripts with non-standard inputs and ad hoc loaders:
import IMAGE_URL from './assets/logo.png';
Since esbuild supports this common convention, RNA treats every unknown import as external file reference, delegating to esbuild assets collection and optimization.
Accordingly to its architecture, RNA encourages and supports for assets referenced by standard URL
instances:
const IMAGE_URL = new URL('./assets/logo.png', import.meta.url).href; const response = await fetch(IMAGE_URL); const blob = await response.blob();
This kind of reference is natively supported by browser and Node. During the build, RNA will convert those references to esbuild's imports statements in order to correctly update the path for distribution files.
Workers
In a vary similar way, RNA collects builds new Worker()
reference along the main build:
const worker = new Worker('./path/to/worker.js');
Please note that RNA does not generate a Worker
class to instantiate like webpack does, but it will just correctly update the import reference. If you need a Worker
class, you have to wrap it yourself:
const workerClass = function() { return new Worker('./path/to/worker.js'); };
⚠️ At the moment the Worker plugin does not collect importScript()
statements and does treat workers as modules, but we have plan to support the { type: "module" }
option in the near future.
JSX
Although JSX is not part of EcmaScript standards, it is largerly used by many projects and the benifits it brings are real, even if you may not need it.
Esbuild supports JSX transpilation, so RNA does it too. A plugin for auto importing the JSX pragma from a module is also available with the bundler.
$ npx rna build src/index.js --output public/index.js --jsxFactory h --jsxFragment Fragment --jsxModule '@chialab/dna'
Input
import { render } from '@chialab/dna'; render(<div>Hello world!</div>, document.body);
Output
import { render, h } from '@chialab/dna'; render(h('div', null, 'Hello world!'), document.body);
👉 See the Recommendations section for JSX alternatives using Tagged Templates.
Targeting ES5
Even if modern JavaScript is supported by the majority of browsers, sometimes we have to still support legacy verisons such as Internet Explorer or old Safari releases. Since esbuild supports transpilation from the latest ECMA version to the ES6 version, a plugin is needed for lower transpilation.
RNA provides a Babel plugin for this scopes. Once installed, it is automatically loaded by the RNA cli.
$ npm i -D @chialab/esbuild-plugin-babel $ yarn add -D @chialab/esbuild-plugin-babel
This will install Babel core packages, its env preset and an adapter for esbuild. You can configure the output using a browserslist query or specifying a Babel's config file in the root of your project.
Recommendations
Here's a list of authors' reccomendations for your project setup. Some of those hints are out of the scope of RNA itself, but they are foundamental for JavaScript development.
Eslint
Eslint is the most common linter for JavaScript. It is pluggable with parsers and custom rules and there is great community support.
First, you need to install the eslint cli:
$ npm i -D eslint
Please follow official guide for linter configuration.
We also provide our configuration preset:
$ npm i -D @chialab/eslint-config
.eslintrc.json for JavaScript projects
{ "extends": [ "@chialab/eslint-config/javascript" ] }
.eslintrc.json for TypeScript projects
{ "extends": [ "@chialab/eslint-config/typescript" ] }
Also, do not forget to install the linter extension for your IDE:
Tagged templates
Template Strings came with ES2015. They can be used to interpolate texts but also to execute more complex string manipulation using a "tag":
return tag`Hello ${name || 'world'}!`;
Since then, a lot of libraries, such as lit-html
and uhtml
, have been released to generate views using Tagged Templates.
Tagged Templates are similar to JSX: they have typings support, colorized syntax, autocomplete, hints and more but they are 100% standard JavaScript, so they don't need a transpilation step before landing the browser.
Furthermore, the htm module can be used to bring Tagged Templates support to those libraries that export the JSX pragma only:
import React from 'react'; import ReactDOM from 'react-dom'; import htm from 'htm'; const html = htm.bind(React.createElement); ReactDOM.render(html`<a href="/">Hello!</a>`, document.body);
JSDoc typechecking
You don't need TypeScipt to typecheck JavaScript, or better to say, you can use the typescript
module to check JavaScript syntax without using its syntax.
Since version 4, TypeScript improved JSDoc support and its compiler can now check JavaScript sources as well as generate .d.ts
declarations.
tsconfig.json
{ "compilerOptions": { "allowJs": true, "checkJs": true }, "include": [ "src/**/*.ts", "src/**/*.js" ] }
index.ts
function sum(a: number, b: number) { return a + b; }
index.js (equivalent)
/** * @param {number} a * @param {number} b */ function sum(a, b) { return a + b; }
The pros of this solution is that you can skip the transpilation step if you are using standard JavaScript while still performing a typecheck, the cons are a more verbose syntax and the lack of TypeScript features such as decorators.
Type imports
During the build step, esbuild removes type references and it is smart enough to detect side effects imports, but sometimes circular dependencies can cause imports order and dead code elimination issues.
Since version 4, TypeScript introduced the import type
statement that instructs the bundlers how a reference is used.
It is recommended to use this feature to import interfaces, types and references used as type only.
For example:
Parent.js
import { Child } from './Child'; export class Parent { children: Child[] = []; addChild(name: string) { this.children.push(new Child(name, this)); } }
Child.js
import type { Parent } from './Parent'; export class Child { name: string; parent: Parent; constructor(name: string, parent: Parent) { this.name = name; this.parent = parent; } }