🧔🏻‍♂️ gautier.dev

await postject.inject(
  "node",
  "NODE_SEA_BLOB",
  readFileSync("./bundle.blob"),
  { sentinelFuse: "NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2" },
);

Bundling a SvelteKit app into a single binary

TLDR

Yep, it works, you can pack the node executable, JS code and assets into a single binary with a custom SvelteKit adapter. I opened an issue to discuss adding an official adapter for this.

#Background

It’s common in the Go ecosystem to ship complete full-stack applications as a single binary. For instance, the log analyzer GoAccess has a built-in web dashboard and ships as a single executable. This ecosystem usually relies on the embed package to achieve this.

Since version 19, Node has an experimental feature called Single Executable Application (SEA) that allows bundling the Node.js runtime, application code, and assets into a single executable file.

A hello world example is provided in the documentation, here is what it looks like on Linux:

# Create a hello.js program and its SEA configuration file
echo 'console.log(`Hello, ${process.argv[2]}!`);' > hello.js
echo '{ "main": "hello.js", "output": "sea-prep.blob" }' > sea-config.json

# Generate the SEA blob to be injected in the node binary
node --experimental-sea-config sea-config.json

# Make a copy of the node binary
cp $(command -v node) hello

# Embed the blob into the node binary copy
npx postject hello NODE_SEA_BLOB sea-prep.blob \
  --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2

# Run the resulting single executable application
./hello world
# > Hello, world!

The sea-config.json file is where we list all resources we want to bundle into the final executable. In this case, it’s just the hello.js file, but we can also include assets like CSS and images.

The most binding constraint is that the main program must be a single CommonJS file, with no external dependencies nor top-level awaits. Since SvelteKit applications are made of multiple ESM modules, we’ll have to bundle our dummy application (this website) into a single CommonJS file before we can package it as a SEA binary.

#SvelteKit Adapters

When building a SvelteKit application for production, we use an adapter to generate the production artifact. For instance the @sveltejs/adapter-node adapter generates JS code made to be run with Node.js.

The build process of a SvelteKit application is in two steps:

  1. Vite transpiles all source files into JS, CSS and assets into the .svelte-kit/output directory. The code is runtime-agnostic at this point.

  2. The adapter takes the output of Vite and produces the production files in the build directory, compatible with the target runtime.

We will create an adapter-node-sea adapter that will take, as input, the output of Vite and produce a SEA binary as output.

Let’s start with a Proof of Concept: serving server-side rendered (SSR) pages, ignoring static assets and prerendered pages for now. Here is what an empty adapter looks like:

export default function adapter(): Adapter {
  return {
    name: "adapter-node-sea",

    async adapt(builder) {
      // `builder` is an object provided by SvelteKit that gives us access to
      // the build output and methods to write the final files
    },
  };
}

The first step is creating a work directory where we will produce intermediate files:

// Create an empty temporary build directory
const tmp = builder.getBuildDirectory("sea");
builder.rimraf(tmp);
builder.mkdirp(tmp);

// Copy the server code generated by SvelteKit
builder.writeServer(`${tmp}/server`);

We create a temporary build directory (.svelte-kit/sea) and copy the runtime-agnostic server code generated by SvelteKit into it.

To produce our CommonJS bundle, we will use Rolldown:

await rolldown.build({
  input: "virtual:entry",
  cwd: resolve(tmp), // Resolve all paths relative to the temporary directory
  platform: "node",
  output: {
    // Produce a CommonJS bundle with all dependencies inlined
    format: "cjs",
    inlineDynamicImports: true,
    file: "bundle.js",
  },
  plugins: [
    {
      // This plugin defines the `virtual:entry` module used as input
      name: "virtual-entry",
      resolveId(id) {
        if (id === "virtual:entry") return id;
      },
      load(id) {
        if (id === "virtual:entry") {
          return `import { getRequest, setResponse } from "@sveltejs/kit/node";
import polka from "polka";
import { Server } from "./server/index.js";

const app = new Server(${builder.generateManifest({ relativePath: "./server" })});

const ssr = async (req, res) => {
  let request;

  try {
    request = await getRequest({
      base: "http://localhost:3000",
      request: req,
      bodySizeLimit: 512_000,
    });
  } catch {
    res.statusCode = 400;
    res.end("Bad Request");
    return;
  }

  await setResponse(
    res,
    await app.respond(request, {
      platform: { req },
      getClientAddress: () => {
        return (
          req.connection?.remoteAddress ||
          req.connection?.socket?.remoteAddress ||
          req.socket?.remoteAddress ||
          req.info?.remoteAddress
        );
      },
    }),
  );
};

const server = polka().use(ssr);

const host = "0.0.0.0";
const port = 3000;

app.init({ env: process.env }).then(() => {
  server.listen({ host, port }, () => {
    console.log("Listening on http://%s:%s", host, port);
  });
});`;
        }
      },
    },
  ],
});

This code could be improved, but it does the job—we give Rolldown a virtual entry point that does the following:

  • Import the SvelteKit runtime-agnostic Server and create an instance of it. This class is not application-specific, we inject the application routes and logic through a manifest (builder.generateManifest()).

  • Create a Polka HTTP server that listens on port 3000 and forwards all requests to the SvelteKit server instance. The SSR logic is handled by SvelteKit’s app.respond() method.

  • Node-specific IncomingMessage HTTP requests are converted to standard Request using SvelteKit’s getRequest utility, then the response is converted back to Node’s ServerResponse using setResponse.

This code is a simplified version of what the official @sveltejs/adapter-node adapter does.

At this point, we have a single bundle.js file in CommonJS format that contains all the application code and dependencies. We now need to:

  1. Create the SEA configuration file;
  2. Copy the node binary;
  3. Generate the SEA blob;
  4. Inject the blob into the node binary.
  5. Copy the resulting binary to the final output directory.

With real code, it looks like this:

writeFileSync(
  `${tmp}/sea-config.json`,
  JSON.stringify({ main: `${tmp}/bundle.js`, output: `${tmp}/bundle.blob` }),
);

builder.copy(process.execPath, `${tmp}/node`);

execFileSync(`${tmp}/node`, [
  "--experimental-sea-config",
  `${tmp}/sea-config.json`,
]);

// API version of `npx postject`
await postject.inject(
  `${tmp}/node`,
  "NODE_SEA_BLOB",
  readFileSync(`${tmp}/bundle.blob`),
  { sentinelFuse: "NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2" },
);

const out = "build";
builder.rimraf(out);
builder.mkdirp(out);
builder.copy(`${tmp}/node`, `${out}/app`);

And voilà! Running vite build will produce a binary at build/app that you can be run directly:

$ ./build/app
Listening on http://0.0.0.0:3000

Opening localhost:3000 will render a plain HTML page:

The homepage of this very website, without CSS
We're halfway there

#Static Assets

It’s a great start, at least to my eyes, but I could get that yours are hurt by the lack of CSS. Let’s enrich our POC to bundle client assets as well. The implementation primitives we’ll use are:

  • The assets key of the SEA configuration file, listing all static assets to be bundled.
  • The getRawAsset(key: string) Node API to retrieve assets at runtime. I assumed this API is faster than the others because it does not make a copy of the asset buffer prior to returning it, but that remains to be benchmarked.

We first ask the builder to write the client assets to the temporary directory:

const client = builder.writeClient(`${tmp}/client`);

The client variable is an array of all files written by the builder, that we then inject into the SEA configuration:

writeFileSync(
  `${tmp}/sea-config.json`,
  JSON.stringify({
    main: `${tmp}/bundle.js`,
    output: `${tmp}/bundle.blob`,

    // This is new part: a file -> path mapping
    assets: Object.fromEntries(
      client.map((file) => [`/${file}`, `${tmp}/client/${file}`]),
    ),
  }),
);

Last but not least, we need to serve these assets with our Polka server:

import { getRawAsset, getAssetKeys } from "node:sea";
import { lookup } from "mrmime";

// A set of all bundled asset keys for quick existence checking
const knownAssets = new Set(getAssetKeys());

const server = polka()
  .get("/*", (req, res, next) => {
    if (!knownAssets.has(req.path)) {
      next();
      return;
    }

    const asset = getRawAsset(req.path);
    res.setHeader(
      "Content-Type",
      lookup(req.path) || "application/octet-stream",
    );
    res.end(Buffer.from(asset));
  })
  .use(ssr);

This is a very naive asset server—@sveltejs/adapter-node uses sirv under the hood to serve assets with proper caching headers and compression. This is not an option here as sirv relies on the filesystem, but it’s a proof of concept, let’s move on.

Let’s try that out by rebuilding (vite build) and reruning (./build/app) the application:

The homepage of this very website, with CSS
Enjoy the websiteception

And that’s it! We are still lacking many features like prerendering support, but I consider this proof of concept a success.

#But Why?

insert Ryan Reynolds meme

Why would anyone want to bundle a SvelteKit application into a single binary?

Apart from the ease of distribution, I’m curious about the performance implications of bundling everything into a single binary. I compared the runtime performance of the @sveltejs/adapter-node build versus our adapter-node-sea build using oha:

@sveltejs/adapter-nodeadapter-node-seaDifference
Homepagep50171.6 ms105.7 ms-38%
p90220.3 ms140.9 ms-36%
p95254.5 ms460.1 ms+81%
Largest asset (280 kB)p5045.9 ms11.7 ms-75%
p9051.3 ms17.5 ms-66%
p9553.8 ms50.8 ms-6%
Smallest asset (83 B)p5017.3 ms7.1 ms-59%
p9021.5 ms11.1 ms-48%
p9522.9 ms13.6 ms-41%

The performance boost is quite significant! We have this p95 outlier on the homepage that I can’t explain (and don’t intend to investigate further right now), but overall the SEA binary is consistently faster than the standard Node.js build.

#Conclusion

The SEA feature of Node.js is very promising, enabling self-contained applications with minimal ceremony. Even my naive implementation suggests significant performance gains compared to a Node.js build—for small SvelteKit apps like this website at least.

I created a SvelteKit issue to discuss adding an official @sveltejs/adapter-node-sea adapter to the SvelteKit ecosystem. If you are interested, please upvote!

#Comments

Loading comments...