brumate

Routing

Routes are .brm files under src/routes. The path of a file is the URL that it renders, and a _layout.brm wraps the routes of its folder.

Route files

A small blog could have these files:

src/routes/
├── _layout.brm            the document around every page
├── index.brm              /
├── about.brm              /about
├── posts/
│   ├── index.brm          /posts
│   ├── [slug].brm         /posts/hello-world
│   └── _loading.brm       shows while a post loads
├── docs/
│   └── [...path].brm      /docs/guide/install, and any depth below /docs
├── @aside/
│   ├── _default.brm       the sidebar of every other page
│   └── posts/[slug].brm   the sidebar of a post
├── api/
│   └── posts.ts           an endpoint: GET /api/posts
├── admin/
│   └── _middleware.ts     runs before every route under /admin
├── _error.brm             errors, and paths that match no route
└── [...rest].brm          a page for every path that nothing else matches

Route files lists every file and what it does.

Params

A page gets the dynamic segments of its path as params. RouteParams from brumate/routes gives their type:

<!-- src/routes/posts/[slug].brm -->
<script>
  import type { RouteParams } from 'brumate/routes';
  import { $memo, $props } from 'brumate/signals';
  import { db } from '../../db.ts';

  const { params } = $props<{ params: RouteParams<'/posts/[slug]'> }>();

  async function getPost(slug: string) {
    'use server';
    return db.posts.find(slug);
  }

  const post = $memo(getPost(params.slug));
</script>

<h1>{post.title}</h1>
<p>{post.body}</p>

Layouts

A _layout.brm wraps every route of its folder and of the folders inside it. It gets the matched route as children, and inserts it where the route should go:

<!-- src/routes/_layout.brm -->
<script>
  import { $props } from 'brumate/signals';

  const { children } = $props();
</script>

<html lang="en">
  <head>
    <meta charset="utf-8">
  </head>
  <body>
    <nav><a href="/">Home</a> <a href="/posts">Posts</a></nav>
    <main>{children}</main>
  </body>
</html>

Named slots

A folder that starts with @ is a second route tree for the same URLs. Its match renders in a slot of the layout next to it, named after the folder:

<!-- src/routes/_layout.brm -->
<script>
  import { $props } from 'brumate/signals';

  const { children, aside } = $props();
</script>

<main>{children}</main>
<aside>{aside}</aside>

For /posts/hello-world, with the files above:

For /about, @aside has no match, so aside renders @aside/_default.brm. A slot with no match and no _default.brm renders nothing.

Errors and 404

_error.brm renders in place of a route of its folder that throws. It gets error, with a message and a status:

<!-- src/routes/_error.brm -->
<script>
  import { $props } from 'brumate/signals';

  const { error } = $props<{ error: { message: string; status: number } }>();
</script>

#head {
  <title>{`Error ${error.status}`}</title>
}

<h1>Something went wrong</h1>
<p>{error.status}: {error.message}</p>

A catch-all page can answer with a 404 status itself, with setStatus on the event of the page:

<!-- src/routes/[...rest].brm -->
<script>
  import { $props } from 'brumate/signals';
  import { getEvent } from 'brumate/server';

  const { params } = $props<{ params: { rest: string } }>();
  getEvent().setStatus(404);
</script>

<h1>No page at /{params.rest}</h1>

setStatus works in the script of a component while the first part of the page renders. Content that streams in later, such as a #loading body, cannot change the status, because the response has started.

Loading

_loading.brm shows while a route of its folder loads. The layouts render first, and the route streams in when it is ready:

<!-- src/routes/posts/_loading.brm -->
<p>Loading the post…</p>

This works like a #loading block around the route. See Async data.

Endpoints

A .ts file in the routes answers requests at its path. It exports a function for each HTTP method, which gets the request and the params and returns a Response:

// src/routes/api/posts/[slug].ts
import { db } from '../../../db.ts';

export async function GET(request: Request, params: Record<string, string>): Promise<Response> {
  return Response.json(await db.posts.find(params.slug));
}

export async function DELETE(request: Request, params: Record<string, string>): Promise<Response> {
  await db.posts.remove(params.slug);
  return new Response(null, { status: 204 });
}

Middleware

src/middleware.ts runs for every request of the app: pages, endpoints, server function calls, form posts and the requests for #defer bodies. It exports the middleware as its default, written with defineMiddleware for its types:

// src/middleware.ts
import { defineMiddleware } from 'brumate/server';
import { session } from './session.ts';

export default defineMiddleware(async (event, next) => {
  event.locals.user = await session.get(event);
  if (event.kind === 'call' && event.call.id.includes('#admin') && !event.locals.user?.admin) {
    return new Response('Forbidden', { status: 403 });
  }
  return next();
});

A 'use server' file can export middleware too, with the same shape. It runs before each function of the file, after the middleware of the entry, for a group of functions with the same rule. The browser gets no stub for it.

_middleware.ts runs before every route of its folder and the folders inside it, endpoints included. It exports middleware, which answers the request itself, or calls next for the answer of the route:

// src/routes/admin/_middleware.ts
import { session } from '../../session.ts';

export async function middleware(request: Request, next: () => Promise<Response>) {
  if (!(await session(request))) {
    return Response.redirect(new URL('/login', request.url));
  }
  const response = await next();
  response.headers.set('cache-control', 'no-store');
  return response;
}

The middleware of each folder on the path runs from the outside in.

Request locals

Middleware hands what it finds, such as the user, to the rest of the request through event.locals, as above. Components, server functions and endpoints read it with getEvent().locals:

async function listOrders() {
  'use server';
  const { user } = getEvent().locals;
  return user ? db.orders.of(user.id) : [];
}

Its type comes from the Locals interface of brumate/server, which the app adds to:

// src/locals.d.ts
declare module 'brumate/server' {
  interface Locals {
    user?: { id: string; name: string; admin: boolean };
  }
}

Caching pages

A route exports cache from its module script to let a browser or a CDN keep the page:

<script module>
  export const cache = { maxAge: 60, staleWhileRevalidate: 600 };
</script>

<h1>About</h1>
#defer ('load') {
  <p>Signed in as {user}.</p>
}

The server also keeps each public page, in the store of cached, and answers the next request for the same URL from it, with x-brumate-cache: hit:

Links are plain <a> elements. href from brumate/routes writes the URL of a route from its path in the route files, and TypeScript checks both:

<script>
  import { href } from 'brumate/routes';
</script>

<a href=(href('/posts/[slug]', { slug: post.slug }))>{post.title}</a>

Serving routes

virtual:brumate/routes exports handle(request, options), which answers a web Request with the page, endpoint or middleware answer for its path:

const { handle } = await vite.ssrLoadModule('virtual:brumate/routes');
const response = await handle(request, {
  scripts: '<script type="module" async src="/src/client.ts"></script>',
});

It returns undefined when no route matches and the app has no entry middleware, and the root has neither _error.brm nor _middleware.ts, so the server can answer with its own 404. Deploying shows a full server for development and production.

examples/todos has a layout, an @aside slot, a dynamic route, an endpoint and a 404 page. examples/tracker adds middleware that guards a page, a session, and a page prerendered next to dynamic ones.