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 })
);
},
};
vite devruns the app. Nitro renders pages in a worker, and calls of server functions go to the entry, so they share its memory. Edits update the page as in Edit while it runs.vite buildwrites.output.node .output/server/index.mjsserves it, andvite previewtoo.NITRO_PRESET, orpresetin the options ofnitro(), builds for another host, such ascloudflare-moduleorvercel. The example is tested with the default Node preset. Brumate needs no Node APIs at runtime, so no compatibility flag is needed.- Pass the address of the client as
address, for rate limits andclientAddress:request.ipon Nitro, which is the address of the socket, or thecf-connecting-ipheader on Cloudflare. Without it, a rate limit with nokeythrows in production. Behind a proxy, name it withconfigureRateLimit({ proxies }). See Rate limiting. - A host that stops a function when its response ends, such as Cloudflare Workers, gives
fetcha context withwaitUntil. Pass it on, ashandle(request, { scripts, waitUntil: (promise) => context.waitUntil(promise) }), andevent.waitUntilkeeps work going on the host. client.ts?assets=clientgives the URL of the client entry, in development and in the build. Addnitro/vite/typesto thetypesoftsconfig.jsonfor its type.- Keep the results of
cachedand the pages that the server keeps in Nitro's storage, so every instance of the app shares them:setCacheStore(storageStore(useStorage('cache')))at the top of the entry, withuseStoragefromnitro/storage. Configure thecachemount with a driver such as Redis in the options ofnitro(). - Nitro is in beta. The example uses version
3.0.260903-beta.
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.
routereturnsundefinedwhen no route matches and there is no root_error.brm.sendanswers with a 404 then.sendwrites the headers withtoNodeHeadersfrombrumate/server, which keeps eachset-cookieapart.Object.fromEntries(answer.headers)would join them, and the browser would keep only one cookie.- Load the client entry with
async, so it starts while#loadingcontent is still streaming. - Files under
/assets/have hashes in their names, so they can be cached for a long time.
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:
- A
<link rel="modulepreload">for each at the start of its head, so the browser loads the core with the HTML. Linkheaders with the same URLs. Hosts such as Cloudflare turn them into103 Early Hintsfor the next visits.earlyHints, a callback that receives theLinkvalues before the page renders. Node'swriteEarlyHintssends them as a103response at once, so the browser loads the modules while the server renders.
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' },
});
- The first part of the page is sent as soon as it renders.
#loadingcontent follows as its data arrives. - Pass
request, so components and the server functions that their memos call can read it withgetEvent(). noncesets a Content Security Policy nonce on the inline scripts that the stream adds.- A root component that renders only part of a page takes
startandendmarkup around it.
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
- After the server build, each path renders to an
index.htmlin a folder of its path indist/client. - Links on a rendered page lead to more pages, which render too.
crawl: falserenders only the listed paths. - The root
_error.brmrenders as404.html, which most static hosts show for a missing page. - Pages that answer with another status, or with a redirect, are not written. Endpoints are not written either.
- Client navigation and layouts that keep their state work on a static host too.
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 });
- The routes send the shell at once, then the body of each
#deferblock with theloadoridletrigger, in the same response, as a#loadingbody streams. The browser does not ask for them. - Each body renders in a request of its own, so middleware runs for it, and it can read the user. The response is
private, no-cache. - A
visibleblock, and a block whose body fails, keep their fallback until the browser asks, as on a page without a shell. - The request for a
#deferbody, with the headerx-brumate-defer, and client navigation render the route as usual, whatevershellis. - A static host without a server answers with the file. The browser asks for each body when its trigger fires, and without a server the block keeps its fallback.
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-…"]
}
pageshas the hashes of each page. The error page is404.scriptshas the hashes of every page, once each.
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:
- Each build has an ID. The client build writes it to
dist/client/.brumate/build.json, and the server build reads it, so build the client first.BRUMATE_BUILDin the environment names the ID for both builds, such as the commit in CI. - The page state carries the ID, and the browser sends it with each server function call and each request for a
#deferbody. A call to a function that still exists runs. A call to a function that is gone answers 409 with the build of the server, and the browser loads the next page in full. A form of$actionposts again without scripts, and the page that answers is of the new build. - A form that posts to a server function carries the ID in its URL, as
__build, so a post without scripts is checked too. A post from another build to a function that is gone answers 303 to its page, fromReferer, or to/when the page is on another site. The page then loads with the new build. - When the code of a handler fails to load, the browser asks the server for its build. When it differs, the files of the old build may be gone, and the page loads again. Any other error, such as a module that throws when it loads, goes to the nearest
#try, as the error of a handler does. - Client navigation compares the build of the next page with its own, and loads the page in full when they differ, because the layouts that it would keep are code of the old build.
- A page loads again, or in full, once for each build in a tab. The tab keeps the builds in session storage. So a deploy whose files are broken, a cache that still serves the old page, or two builds that answer in turn during a deploy cannot load pages in a loop. The next time, the error goes to
#try, or client navigation swaps the page in. Without session storage, the page does not load again. - The page gets a
brumate:skewevent when it notices a deploy, with'call','code'or'defer'indetail, before anything loads again. An app can save a draft in it.
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
- Build the client before the server.
- Serve
dist/client/assetswith long cache headers. - Send server function calls,
POSTrequests to/_brumate/…by default, tohandle. - Set
originsin the plugin options if pages on another origin of yours call server functions. See Security. - Check what each route ships with
report: trueor abudget. See Inspect.