< B) Dev | C) Build | > D) Test |
This page documents how jsenv can be used to generate an optimized version of source files into a directory.
Best parts of jsenv build:
- Large browser support
- Precise cache invalidation; versioning invalidates only what has changed.
- Support and use
<script type="importmap">
with fallback if needed - Support top level
await
- Support
import.meta.url
,import.meta.resolve
- Support module scripts:
<script type="module" src="./file.js">
- Support inline module scripts:
<script type="module">console.log("hello");</script>
- Support classic scripts:
<scrit src="./file.js">
- Support inline classic script:
<script>console.log("hello");</script>
- Support inline style:
<style>body: { color: orange; }</style>
- Support module workers:
new Worker("./file.js", { type: "module"});
- And many more things...
Table of contents
This section shows how to build project source files using jsenv.
project/ src/ index.html package.json
Adding a build have the following impacts on that file structure:
project/
+ dist/
+ index.html
+ scripts/
+ build.mjs
src/
index.html
package.json
scripts/build.mjs:
import { build } from "@jsenv/core";
await build({
sourceDirectoryUrl: new URL("../src/", import.meta.url),
buildDirectoryUrl: new URL("../dist/", import.meta.url),
entryPoints: {
"./index.html": "index.html",
},
});
Before generating a build, install dependencies with the following command:
npm i --save-dev @jsenv/core
Everything is ready, build can be generated with the following command:
node ./scripts/build.mjs
It will display the following output in the terminal:
By default build generates code compatible with the following browsers:
- Chrome 64+
- Safari 11.3+
- Edge 79+
- Firefox 67+
- Opera 51+
- Safari on IOS 12+
- Samsung Internet 9.2+
The browser support can be increased or decreased using runtimeCompat
:
import { build } from "@jsenv/core";
await build({
sourceDirectoryUrl: new URL("../src/", import.meta.url),
buildDirectoryUrl:new URL("../dist/", import.meta.url),
entryPoints: {
"./main.html": "index.html",
},
+ runtimeCompat: {
+ chrome: "55",
+ edge: "15",
+ firefox: "52",
+ safari: "11",
+ },
});
Build ensure transformations are performed according to the browser support: if <script type="module">
can be preserved, they will be.
The maximum compatibility that can be obtained after build is:
- Chrome 7+
- Safari 5.1+
- Edge 12+
- Firefox 2+
- Opera 12+
- Safari on IOS 6+
- Samsung Internet 1+
When runtimeCompat
contains browsers not supporting <script type="module"></script>
it is tempting to think the good thing to do is to generate 2 builds and use <script nomodule>
↗.
<!-- this is NOT what jsenv does -->
<script
type="module"
src="/dist/main.js"
></script>
<script
nomodule
src="/dist/main.nomodule.js"
></script>
This has been tried on a big codebase served to a lot of users. The result: there is no significant performance impact for users. Moreover generating a second set of files has costs:
- Manual tests must be runned also on old browsers
- Automated tests as well
- Finally it takes more time to generate the build
For these reasons jsenv generates a single <script>
tag.
<!-- this is what jsenv does -->
<script src="/dist/main.nomodule.js"></script>
Note It's still possible to obtain X set of files by calling
build
multiple times with their ownruntimeCompat
andbuildDirectoryUrl
.
Build does not inject polyfills. If code uses Promise
and needs to be compatible with browsers that do not support Promise
, the polyfill must be added. (suggestion: https://polyfill.io↗).
A typical build end up with a file structure similar to:
dist/
js/
main.js
app.js
css/
main.css
index.html
Entry points is an object describing the source files to build. It can be any type of file: HTML, CSS, JS, ...
Entry point values are used to control the name of the source file inside the build directory:
import { build } from "@jsenv/build";
await build({
sourceDirectoryUrl: new URL("../src/", import.meta.url),
buildDirectoryUrl: new URL("../dist/", import.meta.url),
entryPoints: {
"./index.html": "index_after_build.html",
"./about.html": "about_after_build.html",
},
});
dist/
js/
main.js
app.js
css/
main.css
index_after_build.html
about_after_build.html
It's possible to regroup assets into a dedicated directory using assetsDirectory
parameter:
import { build } from "@jsenv/core";
await build({
sourceDirectoryUrl: new URL("../src/", import.meta.url),
buildDirectoryUrl: new URL("../dist/", import.meta.url),
entryPoints: {
"./index.html": "index.html",
},
assetsDirectory: "assets/",
});
Resulting in the following structure in the build directory:
dist/
assets/
js/
main.js
app.js
css/
main.css
index.html
Bundling drastically reduces the number of files after build by concatenating file contents. It is enabled by default.
The bundlers used under the hood are described in the table below:
File type | bundler used under the hood |
---|---|
js module | rollup↗ |
css | lightningcss↗ |
Use an object to configure what is bundled:
import { build } from "@jsenv/core";
await build({
sourceDirectoryUrl: new URL("../src/", import.meta.url),
buildDirectoryUrl: new URL("../dist/", import.meta.url),
entryPoints: {
"./index.html": "index.html",
},
bundling: {
js_module: false,
css: true,
},
});
Or pass bundling: false
to disable bundling entirely.
chunks
parameter can be used to assign source files to build files. The code below puts the content of node module files and a.js inside vendors.js:
import { build } from "@jsenv/core";
await build({
sourceDirectoryUrl: new URL("../src/", import.meta.url),
buildDirectoryUrl: new URL("../dist/", import.meta.url),
entryPoints: {
"./index.html": "index.html",
},
bundling: {
js_module: {
chunks: {
vendors: {
"file:///**/node_modules/": true,
"./a.js": true,
},
},
},
},
});
The source files not assigned by chunks
are distributed optimally into build files.
Minification decreases file size. It is enabled by default.
The minifiers used under the hood are described in the table below:
File type | Minifier used under the hood |
---|---|
js module and js classic | terser↗ |
html and svg | html-minifier↗ |
css | lightningcss↗ |
json | White spaces are removed using JSON.stringify |
Use an object to configure what is minified:
import { build } from "@jsenv/core";
await build({
sourceDirectoryUrl: new URL("../src/", import.meta.url),
buildDirectoryUrl: new URL("../dist/", import.meta.url),
entryPoints: {
"./index.html": "index.html",
},
minification: {
html: false,
css: true,
js_classic: true,
js_module: true,
json: false,
svg: false,
},
});
Or pass minification: false
to disable minification entirely.
Inside build files, url paths is absolute and versioned by default.
src/index.html:
<script type="module" src="./main.js"></script>
Becomes the following dist/index.html:
<script type="module" src="/js/main.js?v=16e5f70d"></script>
It's possible to configure the build urls to obtain the following:
- <script type="module" src="/js/main.js?v=16e5f70d"></script>
+ <script type="module" src="https://cdn.example.com/js/main.js?v=16e5f70d"></script>
Example of code putting "https://cdn.example.com"
in front of every urls in the build file contents:
import { build } from "@jsenv/core";
await build({
sourceDirectoryUrl: new URL("../src/", import.meta.url),
buildDirectoryUrl: new URL("../dist/", import.meta.url),
entryPoints: {
"./main.html": "index.html",
},
base: "https://cdn.example.com",
});
When versioning method is not specified, the version is injected as url search param:
<script type="module" src="/js/main.js?v=16e5f70d"></script>
Effect of filename versioning method:
- <script type="module" src="/js/main.js?v=16e5f70d"></script>
+ <script type="module" src="/js/main-16e5f70d.js"></script>
Effect of disabling versioning:
- <script type="module" src="/js/main.js?v=16e5f70d"></script>
+ <script type="module" src="/js/main.js"></script>
Example of code using filename versioning method:
import { build } from "@jsenv/core";
await build({
sourceDirectoryUrl: new URL("../src/", import.meta.url),
buildDirectoryUrl: new URL("../dist/", import.meta.url),
entryPoints: {
"./main.html": "index.html",
},
versioningMethod: "filename",
});
Example of code disabling versioning:
import { build } from "@jsenv/core";
await build({
sourceDirectoryUrl: new URL("../src/", import.meta.url),
buildDirectoryUrl: new URL("../dist/", import.meta.url),
entryPoints: {
"./main.html": "index.html",
},
versioning: false,
});
Build avoids cascading hash changes↗ using <script type="importmap">
↗.
The following browsers are supporting importmap:
- Chrome 89+
- Safari 16.4+
- Edge 89+
- Firefox 108+
- Opera 76+
- Safari on IOS 16.4+
- Samsung Internet 15+
If something in the runtimeCompat
configured for the build does not support importmap, build converts js modules to the systemjs format. This allow to keep versioning urls without introducing cascading hash changes.
If source file contains resource hints, they are updated by the build to reflect the state of the files after build. In some situations build will also inject resource hints and/or remove useless ones.
In a project with the following file structure
project/
src/
boot.js
app.js
index.html
...many js files...
With index.html preloading boot.js and app.js:
<link rel="preload" href="./boot.js" as="script" crossorigin="" />
<link rel="preload" href="./app.js" as="script" crossorigin="" />
During build, code shared by boot.js and app.js might be put into an intermediate file to improve code reuse. In that case build will inject a preload link to that new file introduced during build:
<link rel="preload" href="/js/boot.js?v=12345678" as="script" crossorigin="" />
<link
rel="preload"
href="/js/generated.js?v=12367845"
as="script"
crossorigin=""
/>
<link rel="preload" href="/js/app.js?v=87654321" as="script" crossorigin="" />
<link rel="preload" href="./main.js" as="script" crossorigin="" />
☝️ Assuming nothing else in the code is referencing "main.js", the following warning is logged during build
⚠ remove resource hint because cannot find "file:///demo/main.js" in the graph
To remove this warning, remove the resource hint from the html file.
A similar warning is logged whenever a file is no longer needed after build (like when the file is bundled into an other). In that case the warning is a bit different
⚠ remove resource hint on "file:///demo/main.js" because it was bundled
Here again, remove the resource hint from the html file as it becomes useless after build.
Array of custom jsenv plugins that will be used while building files.
Read more in G) Plugins.
Let's see what happens during build when some code registers a service worker:
index.html:
<!doctype html>
<html>
<head>
<title>Title</title>
<meta charset="utf-8" />
<link rel="icon" href="data:," />
</head>
<body>
Hello world
<script type="module" src="./main.js"></script>
<script>
window.navigator.serviceWorker.register("./sw.js");
</script>
</body>
</html>
sw.js:
const urls = ["/"];
const addUrlsToCache = async (urls) => {
const cache = await caches.open("v1");
await cache.addAll(urls);
};
self.addEventListener("install", (event) => {
event.waitUntil(addUrlsToCache(urls));
});
- build recognize
navigator.serviceWorker.register
and consider sw.js is a service worker entry file. - build injects code at the top of sw.js:
+ self.resourcesFromJsenvBuild = {
+ "/main.html": {
+ "version": "a3b3b305"
+ },
+ "/js/main.js": {
+ "version": "54f517a9",
+ "versionedUrl": "/js/main.js?v=54f517a9"
+ },
+ };
Thanks to this the service worker becomes aware of all the files generated during the build. It can use this information to put all urls into browser cache and make the page work offline for instance.
To do this the code inside sw.js need to be adjusted a bit:
const urls = ["/'];
+ const resourcesFromJsenvBuild = self.resourcesFromJsenvBuild;
+ if (resourcesFromJsenvBuild) {
+ Object.keys(resourcesFromJsenvBuild).forEach((key) => {
+ const resource = resourcesFromJsenvBuild[key]
+ if (resource.versionedUrl) {
+ urls.push(resource.versionedUrl);
+ }
+ });
+ }
In case you don't have your own service worker already you can use @jsenv/service-worker
Same as sourcemaps
in B) Dev but default value is "none"
Start a server for build files; Acts as a server for static files without any logic.
import { startBuildServer } from "@jsenv/core";
const buildServer = await startBuildServer({
buildDirectoryUrl: new URL("../dist/", import.meta.url),
port: 8000,
});
A string or url leading to the directory where build files are written. This parameter is required.
Number listened by the build server (default is 9779). 0
listen a random available port.
Same as https in B) Dev
< B) Dev | C) Build | > D) Test |