brumate

Deploying

A brumate app builds into client files and a server bundle. The server renders pages and answers server function calls. A site without server functions can also be prerendered to static files.

Nitro builds the app for a host, such as Node, Bun, Deno, Cloudflare or Vercel, without a server of your own. See Deploying with Nitro. The sections after it describe a server that you write yourself.

Deploying with Nitro

Add Nitro's Vite plugin after brumate's, and name the client entry:

// vite.config.ts
import brumate from 'brumate/vite';
import { nitro } from 'nitro/vite';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [brumate(), nitro()],
  environments: {
    client: { build: { rollupOptions: { input: 'src/client.ts' } } },
  },
});

Nitro uses src/entry-server.ts for each request that is not a file of the build. Its fetch answers server function calls, then routes:

// src/entry-server.ts
import { handle } from 'virtual:brumate/routes';
import { handle as handleCall } from 'virtual:brumate/server';
import client from './client.ts?assets=client';

const scripts = `${import.meta.env.DEV ? '<script type="module" src="/@vite/client"></script>' : ''}<script type="module" async src="${client.entry}"></script>`;

export default {
  // Nitro's request has `ip`, the address of the socket, for rate limits.
  async fetch(request: Request & { ip?: string }): Promise<Response> {
    const address = request.ip;
    if (request.method === 'POST') {
      const answer = await handleCall(request, { address });
      if (answer) {
        return answer;
      }
    }
    return (
      (await handle(request, { scripts, address })) ?? new Response('Not found', { status: 404 })
    );
  },
};

examples/nitro is a complete app.

Build

The client and the server build separately, the client first:

vite build --outDir dist/client
vite build --ssr src/server.ts --outDir dist/server

The Vite config needs the manifest of the client build, so the server can find the client entry:

// vite.config.ts
import brumate from 'brumate/vite';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [brumate()],
  build: {
    manifest: true,
    rolldownOptions: { input: 'src/client.ts' },
  },
});

The server entry exports the two request handlers that the plugin builds:

// src/server.ts
// Server function calls.
export { handle } from 'virtual:brumate/server';
// Pages, endpoints and middleware of the route files.
export { handle as route } from 'virtual:brumate/routes';

A production server

Any server that speaks web Request and Response can serve the build. A request goes to the server functions first, then to the client files, then to the routes:

// server.prod.ts
import { readFile } from 'node:fs/promises';
import { createServer } from 'node:http';
import path from 'node:path';
import { send, toRequest } from './node.ts';

const client = path.resolve('dist/client');
const server = await import(path.resolve('dist/server/server.js'));
const manifest = JSON.parse(await readFile(path.join(client, '.vite/manifest.json'), 'utf8'));
const entry = `/${manifest['src/client.ts'].file}`;

createServer(async (incoming, outgoing) => {
  const request = await toRequest(incoming);
  const { pathname } = new URL(request.url);
  const address = incoming.socket.remoteAddress;

  // Server function calls.
  if (request.method === 'POST') {
    const answer = await server.handle(request, { address });
    if (answer) {
      return send(answer, outgoing);
    }
  }
  // The built client files.
  if (pathname.startsWith('/assets/')) {
    outgoing.setHeader('cache-control', 'public, max-age=31536000, immutable');
    outgoing.end(await readFile(path.join(client, path.normalize(pathname))));
    return;
  }
  // Pages, streamed.
  send(
    await server.route(request, {
      scripts: `<script type="module" async src="${entry}"></script>`,
      address,
      earlyHints: (link) => outgoing.writeEarlyHints({ link }),
    }),
    outgoing,
  );
}).listen(3000);

toRequest and send turn Node's request and response into web ones. examples/todos has them in node.ts, with the full server in server.prod.ts, which also sets content types and refuses paths outside dist/client.

Early hints

The client build writes the URLs of the loader and the core, which every page loads, to dist/client/.brumate/hints.json. The routes of the server build read it, so a page gets them three ways:

Client navigation and requests for #defer bodies get none, since the page has the modules already. Development has none. With a client build in another folder, set it with prerender: { client } in the plugin options, or pass hints to route yourself.

The server runtime uses only web APIs, such as Request, Response and crypto.subtle, so it runs on Node, Deno, Bun and edge hosts such as Cloudflare Workers.

Rendering without route files

An app without src/routes renders a root component itself with renderToStream from brumate/server:

import { renderToStream } from 'brumate/server';
import Root from './src/Root.brm';

const html = renderToStream(Root, {}, {
  request,
  scripts: '<script type="module" async src="/assets/client.js"></script>',
});
// The stream holds text, so it is encoded for the response.
return new Response(html.pipeThrough(new TextEncoderStream()), {
  headers: { 'content-type': 'text/html; charset=utf-8' },
});

render returns the parts of a page instead of a stream. Use it to read the headers and the status that the page set before you send anything:

import { render } from 'brumate/server';

const page = await render(Root, {}, { request });
// page.status and page.headers come from the event of the page, such as event.setStatus and event.setCookie.
// page.html, page.head and page.state are the first render. page.pending streams the rest.

Several roots on one page

A page from another tool can hold several brumate roots, such as islands. Render each one after the first with a base above the IDs of the one before it, so their state does not mix:

const header = await render(Header, {});
const cart = await render(Cart, {}, { base: header.next() });

One loader serves every root on the page.

Prerendering

A site with no server functions, such as documentation, can be written to static files at build time:

brumate({ prerender: { paths: ['/'] } });
vite build --outDir dist/client && vite build --ssr src/server.ts --outDir dist/server

Upload dist/client to the host. Plugin options lists every prerender option.

#defer on prerendered pages

The body of a #defer block renders on the server, also on a prerendered page, such as a line that names the user who is signed in. A server that has the prerendered file passes it to the routes as shell:

const html = await readFile(path.join('dist/client', url.pathname, 'index.html'), 'utf8').catch(
  () => undefined,
);
const answer = await handle(request, html === undefined ? { scripts } : { shell: html });

Content Security Policy for prerendered pages

A prerendered page is rendered once, so its inline scripts have no nonce. The build writes the SHA-256 hashes of each page's inline scripts to dist/client/.brumate/prerender.json:

{
  "pages": {
    "/about": { "file": "about/index.html", "scripts": ["sha256-…", "sha256-…"] }
  },
  "scripts": ["sha256-…", "sha256-…"]
}

Send a policy with the hashes:

import prerendered from './dist/client/.brumate/prerender.json' with { type: 'json' };

const hashes = prerendered.scripts.map((hash) => `'${hash}'`).join(' ');
const policy = `script-src 'self' ${hashes}`;

Use the hashes of every page in one policy. The client router keeps the policy of the page that the browser loaded, and runs the scripts of the next page under it. A policy that requires Trusted Types adds trusted-types brumate. See Trusted Types.

A policy with hashes cannot cover a page that the server renders on each request. When a server of your own sends both kinds of pages, give the prerendered files a nonce as it sends them, and use the nonce policy for every page:

const html = (await readFile(file, 'utf8')).replaceAll('<script', `<script nonce="${nonce}"`);

Skew protection

A resumable page lives long. A page that was open before a deploy can load code after it, such as the code of a component that a block renders, and calls server functions that the new build may have changed. Skew protection keeps such a page working, or loads it again:

The files of the client build have hashes in their names, so the files of earlier builds can stay next to the new ones for the pages that are still open. skew: { keep: 2 } keeps the files of the two builds before the last one: the build does not empty dist/client, lists its builds in .brumate/builds.json, and removes the files of older builds itself. A host that serves the directory as it is then serves old pages their code for two deploys.

// vite.config.ts
export default defineConfig({
  plugins: [brumate({ skew: { keep: 2 } })],
});

The dev server has no build ID, so nothing is checked there.

Checklist