brumate

A component language and compiler

Web apps that resume, not hydrate.

Components render on the server. The browser keeps their state and gets code only for what responds to the user, in one bundle per page. Nothing renders twice.

$ pnpm add brumate

What ships to the browser

The server renders the whole page as HTML. The browser gets code only for the parts that respond to the user, and it comes with the page, so the first click needs no request.

Switch to hydration to compare.

The code an app ships to the browserA wireframe of a shop: a header with a search field and a cart, a sidebar with a filter switch, a banner, tabs and three product cards with buttons, and a footer. With brumate, only the search field, the filter switch, the tabs and the buttons ship code, which comes with the page, and the cart badge updates. The rest ships as HTML only. With hydration, every part of the page ships its code and runs again in the browser.on inputon changeon clickon clickupdatesEvery part ships its code and runs again
With brumate, only the parts that respond to the user ship code, and it comes with the page. The rest is HTML, and nothing renders again.With hydration, the browser loads the code of every part of the page and renders it again, static parts included.

After the HTML arrives

A page that hydrates runs every component again in the browser before the first click works. A brumate page picks up where the server left off.

When the browser is busy, with hydration and with brumateTwo strips of time. With hydration of the whole page, the main thread reads the HTML, then stays busy running the code of every component and rendering the page again. Clicks work only after that. With brumate, the main thread reads the HTML and runs a small loader, and clicks work from then on. The thread stays idle until a click, which runs one handler.HydrationHTMLRun the code of every component againClicks workbrumateHTMLClicks workLoaderIdleA click runs one handlerMain thread busyIdletime → · not to scale
The code of the page starts to load with the HTML, as one bundle for each root component. A click that comes before it arrives runs once it does.

Try it on this page

This shop page is a brumate component, rendered when this page was built. The list on the right shows every script the page has loaded. The code of the page came with it, so a color or a cart update runs at once, with no request. Turn on X-ray to see which parts ship code and which ship as HTML only.

kettle.example/pour-over
Cart 0

Pour-over kettle

★★★★★ 4.8 · 312 reviews

A gooseneck spout for a slow, even pour. Holds 0.9 L and works on every stove.

1
  • Stainless steel, 0.9 L
  • Thermometer in the lid
  • Free shipping over $50
  • HTML only. No code ships for it.
  • A handler. Its code comes with the page.
  • Updates when a value it reads changes.

The whole component is 0.54 KB gzip, in the bundle of this page. 19 of its elements ship as HTML only. The X-ray switch is CSS, with no script.

What the compiler ships

The compiler builds the graph of what reads what once, when it compiles. Only what can change in the browser ships code. Markup that cannot change ships as HTML only. These tables come from brumate inspect, run when this site was built.

Counter

<script>
  import { $memo, $signal } from 'brumate/signals';

  let count = $signal(0);
  const double = $memo(count * 2);
</script>

<div class="demo-counter">
  <button onClick=(() => count++)>Clicked {count} times</button>
  <p>Double: {double}</p>
  <p class="note">This paragraph never changes, so it ships as HTML only.</p>
</div>
ModuleHoldsgzip
entrythe tables of the component0.23 KB

The page loads 0.23 KB of this component when it starts, in its bundle. 3 elements ship as HTML only.

  • h2: event button.click runs on the event

Todos

<script>
  import { $memo, $signal } from 'brumate/signals';
  import { prevent } from 'brumate/events';

  interface Todo {
    id: number;
    title: string;
    done: boolean;
  }

  let todos = $signal<Todo[]>([
    { id: 1, title: 'Render on the server', done: true },
    { id: 2, title: 'Resume on the first click', done: false },
  ]);
  let draft = $signal('');
  const left = $memo(todos.filter((todo) => !todo.done).length);

  function add() {
    if (draft.trim()) {
      todos = [...todos, { id: Date.now(), title: draft.trim(), done: false }];
      draft = '';
    }
  }
</script>

<form class="demo-todos" onSubmit=(prevent(add))>
  <input value=draft placeholder="Add a todo" aria-label="New todo" onInput=((event) => (draft = event.currentTarget.value))>
  <button>Add</button>
</form>
<ul class="demo-list">
  #for (todo of todos by todo.id) {
    <li>
      <label>
        <input
          type="checkbox"
          checked=todo.done
          onChange=(() => (todos = todos.map((item) => (item.id === todo.id ? { ...item, done: !item.done } : item))))
        >
        <span class={ done: todo.done }>{todo.title}</span>
      </label>
    </li>
  }
</ul>
<p class="demo-left">{left} left</p>
ModuleHoldsgzip
entrythe tables of the component0.88 KB

The page loads 0.88 KB of this component when it starts, in its bundle. 4 elements ship as HTML only.

  • h4: event form.submit runs on the event, calls add
  • h6: event input.input runs on the event
  • h10: event input.change runs on the event

Streaming

Send the page now, and the data when it is ready

A #loading block shows its #else body in the first response. The server keeps the connection open and streams the real body in place once its data loads. The browser needs no code for it.

Loading data
<script>
  import { $memo } from 'brumate/signals';
  import { getProfile } from '../data.ts';

  const profile = $memo(getProfile());
</script>

#loading {
  <h2>{profile.name}</h2>
  <p>{profile.bio}</p>
} #else {
  <p class="skeleton">Loading the profile</p>
}

Server functions

Call the server like a function

Mark an async function with 'use server'. Its body and its imports, such as a database client, stay on the server. The browser gets a small function that sends the call. A form can post to one with no script at all.

Server functions
<script>
  import { $signal } from 'brumate/signals';
  import { db } from '../db.ts';

  async function like(id: number) {
    'use server';
    return db.posts.like(id);
  }

  let likes = $signal(0);
</script>

<button onClick=(async () => (likes = await like(1)))>
  {likes} likes
</button>

Typed

Errors before the page breaks

brumate-tsc checks scripts, markup, props and events. It also checks that every value the browser reads can be sent to it.

TypeScript
<script>
  import { $signal } from 'brumate/signals';

  let price = $signal(1);
  let format = $signal((n: number) => n.toFixed(2));
</script>

<button onClick=(() => price++)>{format(price)}</button>

error A function cannot be sent to the client. Keep it out of the signal or memo

Routes

Files are routes

A .brm file under src/routes renders its URL. Layouts wrap the routes of their folder. Links load pages in place and keep the layouts, with their state.

Routing
  • src/routes/
  • _layout.brmwraps every page
  • index.brm/
  • docs/
  • [page].brm/docs/routing
  • [...rest].brmevery other path

And the rest of an app

Forms without scripts

A form posts to a server function before any script loads, and with scripts off. Once the loader runs, it posts without a page load.

Scoped styles

A <style> block matches only its component. Values from the script go into the CSS and update with the state.

Shared logic

A .brm.ts module holds signals and memos that several components use, compiled like the script of a component.

Tests that resume

brumate/testing renders on the server and resumes in the DOM of a test, with the code that ships.

Deploy anywhere

Serve pages from your own server, build for Node, Bun, Deno, Cloudflare or Vercel with Nitro, or prerender a site that has no server functions.

An editor that knows .brm

The VS Code extension gives .brm files highlighting, types and the compiler's errors. Other editors use the same language server.

Start with one component

A .brm file, the Vite plugin and a server that streams pages.