Tibi

Architecture

How a request flows through Tibi at runtime, and how a .tibi source file gets there.

Tibi is a compiler, not a framework

The distinction matters because it explains what does not ship to the browser. There is no virtual DOM, no diffing pass, no hydration shim wrapping every element, no framework runtime users program against at execution time. The Rust compiler reads .tibi files at build time and emits three things: a rendered HTML document with declarative shadow DOM templates baked in, a content-addressed bundle of customElements.define calls (one per unique component, deduped across the whole site), and a thin per-component reactive scope factory cached as a separate JS asset. Pages reference the bundles via <script src>; first paint runs against fully-rendered HTML, hydration upgrades the custom elements in place. That's the entire shape.

tibi serve reuses the same compiler in-process for SSR — the routing, store, channel, and cron surfaces below are the runtime side. A pure SSG deploy keeps none of them in the dist.

The compile pipeline

tibi::compile_page(entry) walks a .tibi file on disk through:

  1. Parse. Split the source into frontmatter / <template> / <style> / <script> blocks. The frontmatter fence may carry an optional language tag (---js / ---py / ---ts / ---rust).
  2. Evaluate frontmatter. Shell out to the matching interpreter, capture result as JSON, substitute its top-level keys into the template as {token} values. Pages and components both run their own.
  3. Resolve components. Uppercase tags in the template (<Greeting/>) resolve to sibling .tibi files. The resolver walks parent directories looking for the first ancestor with a components/ subdir, so a top-level components/ serves every page below it.
  4. Web-Component-ify. Each component call is rewritten to a <tibi-{kebab-tag}> host carrying a <template shadowrootmode="open"> with the child's <style> + rendered template. The browser attaches a Declarative Shadow Root before JS lands. Light children sit outside the template and project through <slot>.
  5. Scope page-level CSS. The page's own elements get a [data-t="..."] attribute selector (8-char FNV-1a hash); component CSS lives inside its own shadow root and never enters the page-global pool.
  6. Aggregate scripts. The page emits one customElements.define per unique component tag (deduped), the shared window.__tibiEsc helper, and the page's own <script> body wrapped in an IIFE. Each component class observes its host's attribute set and re-renders its shadow on attribute changes.

Compiling without a filesystem

Every file the compiler reads goes through a SourceProvider. FsSource reads from disk and backs compile_page; MapSource serves sources from memory; the *_in entry points (compile_page_with_ctx_in, render_route_with_ctx_in) take any provider. tibi-project-store builds on this for hosted projects: a ProjectStore trait with in-memory and directory backends, ProjectPath validation (relative paths only, no .., no hidden files apart from .tibi-config, an allow-list of file types, no symlinks followed), per-project size limits, and StoreSource, which hands a store straight to the compiler. tibi_router::scan_page_paths builds routes from a list of stored paths with the same rules as a disk scan. Pair it with FrontmatterPolicy::Deny (see Frontmatter) when the sources are untrusted. Vendored plugin components under .tibi-cache/ are not visible through a store.

Routing

tibi::scan_pages("pages") walks the directory deterministically and produces a Vec<Route> in tibi-match syntax (the in-house router crate that replaced the third-party matchit dep):

  • pages/index.tibi → /
  • pages/about.tibi → /about
  • pages/blog/index.tibi → /blog
  • pages/blog/[slug].tibi → /blog/{slug}
  • pages/files/[...rest].tibi → /files/{*rest}

_layout.tibi at any directory level wraps every sibling and descendant via <slot/>. _404.tibi and _500.tibi are fallbacks. components/ subdirs are excluded from routing.

Render modes

  • SSG: tibi build writes dist/<path>/index.html per static route. Dynamic + fallback routes are reported as skipped.
  • SSR: the Axum handler in your app calls tibi::render_route(route) per request.
  • Hybrid: SSG output + per-island props inlined into <script type="application/json" id="q-props-N"> blocks. Island JS reads its own props from there on hydration; no fetch round-trip.

Client runtime

tibi-runtime ships two embedded JS payloads in every served page:

  1. window.Tibi.store — IDB-backed reactive store. Keys use a `namespace:rest` convention; each [[store]] namespace = "..." ttl_secs = N entry in .tibi-config pins the default TTL for keys under that namespace, so per-call { ttl: N } overrides are usually unnecessary. ttl: 0 (the default for un-declared namespaces) = memory-only; ttl > 0 = persist with lazy eviction.
  2. window.Tibi.ws — singleton multiplexed WebSocket. subscribe(channel, fn) opens the shared socket lazily, sends an {op:"subscribe",channel} op frame, JSON-decodes inbound {channel,data} wrappers, routes by channel name, exponential-backoff reconnect (max 5s) with auto-resubscription. Includes an auto-subscribe to the hmr channel that reloads on "reload". window.Tibi.sse aliases the same surface for source-level back-compat.

Server channels

tibi::SseRegistry is a clone-cheap registry of named tokio::sync::broadcast channels. The transport is a single multiplexed WebSocket at GET /__tibi/ws: browsers open one socket per tab, send {op:"subscribe",channel} / {op:"unsubscribe",channel} op frames, and receive every push wrapped as {channel,data}. Nginx / Traefik / Cloudflare pass WS upgrades through cleanly, so the per-channel buffering games SSE needed are gone.

Channels are declared by the components that consume them via the __channels frontmatter convention — at tibi serve boot, Tibi walks pages/ + the sibling components/, evaluates every frontmatter once, collects every __channels entry (deduped by name), and spawns one tokio interval per channel that pushes a payload to the registry on each tick:

---js result = { __channels: [ { name: 'time', interval_ms: 1000, payload: 'iso8601' }, ], }; ---

Built-in payload kinds: unix_secs, iso8601, counter, { static: "value" }. For custom payloads (database row, HTTP fetch, computed from app state), apps wrap their own tower stack and call tibi::SseRegistry::send(channel, payload) directly — the registry is transport-agnostic and accepts opaque strings, so any push pattern works.

Scheduled jobs

Each [[cron]] entry in .tibi-config spawns one tokio task at boot. The schedule supports 5-field (min hour dom mon dow) and 6-field (sec min hour dom mon dow) cron expressions; the referenced file lives under [server] crons_dir (default crons/) and runs through the same evaluator the component frontmatter uses (node / python3 / rustc, picked from the file extension). Side effects (HTTP fetches, file IO, database writes) work via each language's stdlib — Tibi injects no runtime helpers into the cron script, so the same file runs cleanly outside Tibi for testing.

[[cron]] name = "heartbeat" schedule = "0 */5 * * * *" file = "heartbeat.js"

tibi-script (component DX)

Components opt in via <script tibi>. The compiler walks the template for declarative bindings, rewrites the markup, and splices a runtime helper prelude in front of the user body. Plain <script> blocks pass through untouched.

  • Element refs. quid="name" auto-resolves to $name in the script. The compiler also auto-assigns quid="__qN" to elements that carry directives but no manual quid.
  • Reactivity. state(initial) returns a writeable signal (x.value); computed(fn) derives one; effect(fn) re-runs whenever any signal it read changes.
  • Bindings. Use the template directives below ({{expr}}, :attr, t-show, t-bind) — the compiler emits the underlying DOM-binding helpers for you. The runtime ships them as __q-prefixed internals (__ttext / __tattr / __tshow / __tbind) so user-declared identifiers like const text = ... or const { attr } = ... never collide with the helpers.
  • Template directives (compiler rewrites at build):
    • {{expr}} — reactive text interpolation.
    • @event="handler" — listener (auto-removed on unmount). Bare-identifier handlers are called with the event; everything else runs inline with $event in scope.
    • :attr="expr" — reactive attribute (set / remove based on truthiness).
    • t-show="expr" — toggles display.
    • t-bind="ref" — two-way input binding.
    • t-if="expr" + sibling t-else-if="expr" + t-else — conditional chain. Consecutive sibling elements collapse into a single coordinator that mounts at most one branch (the first whose condition is truthy, or the t-else fallback).
    • t-for="item of list" — list render. Children are extracted at compile time into a JS template-literal render fn ({{item.x}} tokens become ${{__escHtml(__read(item.x))}}). The runtime reconciles slot-by-slot: an unchanged rendered HTML string skips the DOM swap entirely, so a parent re-render that produces the same list does not tear down or re-init any nested Custom Elements. Add t-key="item.id" for keyed identity across reorders / inserts / removals (Vue / React style).
  • Lifecycle. onMount(fn) + onUnmount(fn); on($el, event, fn) for manual listener registration; observe(callback, options?, target?) wraps MutationObserver; emit(name, detail?) dispatches a composed CustomEvent off the host.
  • Route. $route exposes a reactive view of the current URL — see the Routing page for the full surface (frontmatter ctx.route, auto template tokens, Tibi.route imperative API).
<template> <button @click="count.value += 1">Clicked {{ count }} times</button> <ul> <li t-for="entry of history">{{ entry }}</li> </ul> </template> <script tibi> const count = state(0); const history = state([]); effect(() => { history.value = [...history.value, count.value].slice(-5); }); </script>

Plugins

The TibiPlugin trait has three default-method hooks:

  • transform_tibi(&mut TibiAst, &TransformCtx) — mutate the AST per file
  • emit_assets(&mut AssetBundle) — contribute extra CSS / JS per render
  • register_channels(&mut SseRegistry) — register extra named server channels at startup (in addition to the ones components declare via their __channels frontmatter)

Each plugin declares its target_abi(). PluginRegistry::register refuses on mismatch — a plugin compiled against an older runtime fails fast instead of subtly misbehaving. Reference plugins ship for Tailwind and MDX.