Bundling and pregenerating assets
If you build your project in such a way that it only has a handful of client-side JavaScript files, you can enjoy the benefits of not having to deal with a bundler:
- In error messages and other places in your browserâs dev tools, line numbers are correct and stack traces are always readable â even in production and logs.
- You can use the JavaScript debugger built into your browserâs dev tools without finicky source maps.
- Not bundling doesnât add another layer that you need to debug when things go wrong (e.g. when your bundle is unexpectedly large and you donât know why).
- No messing with different build configs for client, server, tests, dev and production.
- No bundler update treadmill from Webpack to Vite to whateverâs next.
- During development, the server starts up immediately, and changes are reflected instantly. Itâs a truly awesome developer experience.
But for some very interactive apps, a lot of client-side JavaScript is unavoidable. And for most websites, what negatively impacts performance the most, is too much client-side JavaScript. Thus if you have dozens, or even hundreds, of different client-side JavaScript files, the time may have come to bundle them.
Bundling
Bundling multiple files into one is generally done because making one HTTP request is faster than making multiple. Yes, even with HTTP/2, and even today â at least until JavaScript Module Declarations or something similar is standardized and implemented by browsers. If you want to know for sure whether adding a bundler to your tech stack is worth the added complexity, youâll need to benchmark a typical user journey on your website under typical conditions â once with a bundler, and once without.
Making multiple HTTP requests is especially slow if not all URLs are initially known to the client. Although this aspect could be mitigated with rel=preload for CSS, and rel="modulepreload" for JavaScript. But without any preload hints, the client first needs to request the HTML, which contains the URL to the first JavaScript module, which in turn contains the URL to another imported JavaScript module, and so forth. This results in a so-called network-waterfall, where each request has to complete before the next can be started. Especially on slow mobile connections, this can slow down the loading of lots of files dramatically.
The same can happen in CSS when using @font-face or @import. Ideally, those should only be used in <style> tags directly in the initial HTML.
Bundling JavaScript
When bundling JavaScript, the syntax needs to be parsed and some things need to be wrapped in functions and/or renamed (e.g. to prevent clashes of variables with the same name in different modules). JavaScript bundlers like esbuild recursively follow the import statements and try to bundle only code thatâs actually used. This is called âdead-code eliminationâ, or in the JavaScript world also âtree-shakingâ (but beware that the bundler must assume that importing a module can have side-effects).
You can add esbuild to your Mastro project by running pnpm add esbuild, bun add esbuild, or deno add npm:esbuild respectively (make sure you have a package.json file and node_modules/ folder when using Deno â otherwise esbuild will not find dependencies).
Then, a route that bundles all JavaScript thatâs referenced from the client/app.ts entry point, might look as follows:
import * as esbuild from "esbuild";
export const GET = async () => {
const { outputFiles } = await esbuild.build({
entryPoints: ["client/app.ts"],
bundle: true,
format: "esm",
write: false,
});
return new Response(outputFiles[0]?.contents as BodyInit, {
headers: { "Content-Type": "text/javascript; charset=utf-8" },
});
};
It would be consumed like:
<script type="module" src="/app-bundle.js"></script>
For a Mastro sample project thatâs set up to bundle client-side JavaScript, see bundled-service-worker and its bundler.
Having a client/ folder in the root of your project (and a package.json file thatâs shared between the server and client bundled by esbuild) is only one way of doing things. In Mastro, you can name and organize things to your liking. For example, you could also have completely separate client and server folders, each with their own dependencies.
As you can imagine, bundling of hundreds of files can be computationally expensive, and would take the server longer than generating a typical HTML page. When doing static site generation, this doesnât matter. But doing that every time a user makes a request to a server would be slow and wasteful. Weâll look at pregenerating assets later.
Advanced bundling considerations
Bundling gets more complicated if not all pages of the website require the same JavaScript. In that case, sometimes bundlers are set up to create different chunks (aka âcode splittingâ). Then they need to balance the conflicting goals of fewer chunks, chunks containing no unnecessary code for that page, and little code being duplicated across chunks.
For both CSS and client-side JavaScript, there is usully a trade-off between loading only what you need for the current page (which is optimizing initial page load speed), over loading everything in a single request that the user might need if they afterwards also visit other pages (which is optimal overall, but only if the user does visit more pages).
And finally, the optimal strategy also depends on how often you modify which parts of the code, which chunks that would invalidate, and what their caching live-times are.
Bundling CSS
CSS is easier to bundle than JavaScript. The simplest way is to just concatenate all CSS files found in alphabetical order. This is a reasonalbe strategy if you donât have megabytes of CSS, and still allows you to colocate the CSS source files in the same folder with the corresponding component (e.g. /components/Header/header.css). In Mastro, a route that does that might look as follows:
import { findFiles, readTextFile } from "@mastrojs/mastro";
export const GET = async () => {
const files = await findFiles("components/**/*.css");
const contents = await Promise.all(files.map(readTextFile));
return new Response(
contents.join("\n\n"),
{ headers: { "Content-Type": "text/css" } },
);
}
Which can be consumed with:
<link rel="stylesheet" href="/styles.css">
Transforming images
Another example of an expensive route would be transforming images (e.g. resizing or compressing into WebP format). One way to do that is, with the @mastrojs/images package:
-
Deno deno add jsr:@mastrojs/imagesCopied! -
Node.js pnpm add jsr:@mastrojs/imagesCopied! -
Bun bunx jsr add @mastrojs/imagesCopied!
Then you can add a route like:
import { createImagesRoute } from "@mastrojs/images";
export const { GET, getStaticPaths } = createImagesRoute({
hero: {
transform: (image) => image.resize(300, 300),
},
hero2x: {
transform: (image) => image.resize(600, 600),
}
});
This declares two presets: hero and hero2x. Assuming you have a file images/blue-marble.jpg, you could request resized versions in WebP format as follows:
<img alt="Planet Earth"
src="/_images/hero/blue-marble.jpg.webp"
srcset="/_images/hero2x/blue-marble.jpg.webp 2x"
>
Build step
Because bundling CSS and JavaScript, and transforming images, are expensive computations, itâs common for frameworks to do this only once, in a build step, before starting the server. These pre-built files are often called assets.
Actually, weâve started the guide with an extreme application of this strategy: static site generation. There, not only images and bundles are pre-computed, but also every single HTML file is pre-generated. Thus for a static site, the above code is sufficient. But if youâre running a server, you may want to pregenerate images (or even certain HTML pages) in a build step.
Add a pregenerate task to your deno.json:
{
"tasks": {
"generate": "deno run -A mastro/generator",
"pregenerate": "deno run -A mastro/generator --only-pregenerate",
Then add deno task pregenerate to your CI/CD workflow (e.g. when using Deno Deploy, add it as your âBuild commandâ). This will generate a generated/ folder just like deno task generate would for a static site. But this time, it will only attempt to generate routes that have the following line added:
import { createImagesRoute } from "@mastrojs/images";
export const pregenerate = true;
export const { GET, getStaticPaths } = createImagesRoute({
hero: {
transform: image => image.resize(300, 300),
},
hero2x: {
transform: image => image.resize(600, 600),
}
});
Run deno task pregenerate and check what was written to the generated/ folder.
If you start the server with deno task start and access it on a http://localhost:8000, the images will still be rendered on the fly, enabling you to quickly change things when developing your website. However, when you open http://127.0.0.1:8000 in your browser (thatâs using the IP address for localhost), the Mastro server will assume weâre running in production, and load the pregenerated image from the generated/ folder. You should see in your browserâs network dev tools that this is much quicker.
You can pregenerate not only images, CSS or JavaScript, but also entire HTML pages. Simply add a export const pregenerate = true; to your route.
Usually, serving the pregenerated files with your normal web server will be fast enough. However, you could also push e.g. the generated/_images/ folder to your CDN (content delivery network), and configure it to serve all URLs starting with /_images/ directly from the CDN.