TriFrost

TriFrost Docs

Learn and build with confidence, from a first project with workers to global scale on bare metal.

๐Ÿงฌ TriFrost Atomic

TriFrost's Atomic Runtime gives you fine-grained reactivity, scoped state, lifecycle awareness, and safe interop with the DOM, all SSR-first and CSP-safe.

It extends the <Script> hydration model with a tiny (~8KB) runtime and per-node VMs.

The result? Island-level interactivity that composes, without client bundles.

๐Ÿ’ก Read the blog: How Atomic is TriFrost Atomic


What Atomic adds

When you pass atomic: true to createScript(), every <Script> and their parent node gains:

  • An isolated VM instance ID (el.$uid)
  • Scoped pub/sub (el.$publish, el.$subscribe, ...)
  • Reactive data proxy (data.$watch, data.$set, data.$bind)
  • Lifecycle hooks (el.$unmount, el.$mount)
  • Global store access ($.storeSet, $.storeGet, with reactive broadcasts)
  • Access to the Atomic utils ($.fire, $.on, $.fetch, ...)
  • Typed module access ($.modal.open(), $.audio.play(), ...) via the Module(...) factory + createScript({modules})

It's like a mini reactive runtime baked directly into your DOM tree.


Runtime Footprint

When enabled:

  • Adds ~8KB static runtime (served once, cached forever)
  • Injects ~100-200B glue logic on a page with scripts

Itโ€™s faster, smaller, and safer than client frameworks.


โœจ el Additions

With atomic enabled, each hydrated node (el) becomes a full VM host:

<Script>{({el}) => {
  console.log(el.$uid);        // unique vm id
  el.$unmount = () => {};      // cleanup logic
  el.$publish('some:event');   // broadcast
  el.$subscribe('some:event', (val) => ...); // listen
}}</Script>

Each el now has:

  • el.$uid: Deterministic ID
  • el.$unmount: Called automatically when removed from DOM
  • el.$mount: Called automatically once VM node has been instantiated
  • el.$subscribe(topic, fn): Method allowing you to subscribe to a topic on the pubsub relay
  • el.$subscribeOnce(topic, fn): Method allowing you to subscribe to a topic on the pubsub relay with automatic unsubscribe once triggered
  • el.$unsubscribe(topic): Method allowing you to unsubscribe from a specific topic on the pubsub relay
  • el.$publish(topic, data): Method allowing you to publish data to a specific topic on the pubsub relay
  • Automatic integration with mutation observers and relay system
  • $.<module>: Automatically injected when referenced in a <Script>, providing typed access to registered modules

๐Ÿ’ก To register teardown logic for a dynamic element (e.g., created in a module), use el.$unmount = () => { ... }. This will automatically run when the element is removed from the DOM.

Scoped Pub/Sub Relay

Every atomic script runs inside an isolated VM that can communicate across nodes using scoped pub/sub messages.

<Script>{({el}) => {
  el.$subscribe('filters:open', (val) => console.log('opened?', val));
  el.$publish('filters:open', true);
}}</Script>

These relay messages are:

  • Fully isolated per page render
  • Strongly typed via global AtomicRelay interface
  • Automatically cleaned up on unmount

Relay Typing:

Each component can extend the global AtomicRelay with its own available messages:

type FilterEvents = {
  'filters:open': boolean;
}

declare global {
  interface AtomicRelay extends FilterEvents {}
}

function MyFilters () {
  return <div>
    ...
    <Script>{({el}) => {
      ...
      el.$subscribe('filters:open', (val) => {
        val.toFixed(); // โŒ Error (val is boolean)
      });
      ...
    }}</Script>
  </div>;
}

Cross-Component Pub/Sub Example:

{/* Component A */}
type ModalEvents = {
  'modal:open': void;
  'modal:close': void;
}

declare global {
  interface AtomicRelay extends ModalEvents {}
}

<Script>
  {({el}) => {
    el.$subscribe('modal:open', () => {
      el.classList.add('show');
    });
  }}
</Script>

{/* Component B */}
<Script>
  {({el}) => {
    const btn = el.querySelector('button');
    btn?.addEventListener('click', () => {
      el.$publish('modal:open');
    });
  }}
</Script>

Here, Component B opens Component A, without needing props, context, or DOM queries. Both scripts remain colocated and isolated.

๐Ÿ’ก Relay is broadcast-style โ€” anyone can publish, and any other <Script> VM can listen.

Want to target just a parent?

Use $.fire(el, type) to dispatch a DOM event to just the parent.

{/* Inside a child component */}
<Script>
  {({el, $}) => {
    $.fire(el, 'custom:action', {data: {foo: 123}});
  }}
</Script>

{/* Inside a wrapping component */}
<Script>
  {({el}) => {
    el.addEventListener('custom:action', (e) => {
      console.log('Child wants to do something:', e.detail);
    });
  }}
</Script>

Unlike relay, $.fire(...) walks the DOM tree either up (default) or down. Perfect for scoped signals without global subscriptions.

Combined with the global store (below) and AtomicRelay/AtomicStore typing (also below), this gives you safe reactive messaging across the page.

๐ŸŒ Global Store

TriFrost Atomic includes a global reactive store:

  • Write with $.storeSet('key', value)
  • Read with $.storeGet('key')
  • Listen with el.$subscribe('$store:key', handler)

This store is:

  • Fully reactive
  • Deeply typed (via AtomicStore)
  • Automatically hydrated from localStorage

Usage:

<Script>{({el, $}) => {
  $.storeSet('theme', 'dark', {persist: true});
  const theme = $.storeGet('theme'); // 'dark'

  el.$subscribe('$store:theme', (val) => {
    el.setAttribute('data-theme', val);
  });
}}</Script>

Behavior:

  • Setting a key emits $store:key event
  • Deleting a key emits $store:key with undefined
  • Persisted keys auto-hydrate on load (prefixed via $tfs:)

Persistence Example:

$.storeSet('locale', 'en', {persist: true});

Store changes automatically emit relay events:

$.storeSet('locale', 'en');
// Will auto trigger: el.$subscribe('$store:locale', ...)

Store values are deeply typed:

const theme = $.storeGet('theme'); // Type: 'light' | 'dark'

This provides lightweight global coordination with zero globals.

Store and Relay Together:

{/* Somewhere in Component A */}
<Script>
  {({el}) => {
    el.$subscribe('$store:theme', (val) => {
      console.log('Theme changed:', val);
      el.setAttribute('data-theme', val);
    });
  }}
</Script>

{/* Somewhere else in Component B */}
type StoreData = {
  theme: 'dark' | 'light'
};

declare global {
  interface AtomicStore extends StoreData {}
}

<Script>
  {({$}) => {
    $.storeSet('theme', 'dark'); // triggers A's listener
  }}
</Script>

This demonstrates:

  • How store changes act like global pub/sub
  • That VMs can subscribe to store changes as if they were events
๐Ÿ” Global Contracts: AtomicRelay & AtomicStore

TriFrost uses global ambient interfaces for all relay/store typing.

There are currently two of these ambient interfaces available:

  • AtomicRelay: type contract for $publish and $subscribe
  • AtomicStore: type contract for $.storeGet, $.storeSet, and $.storeDel

This means:

  • No passing around and importing of Event or Store types
  • No manual unions
  • Autocomplete works everywhere

These interfaces are available globally inside every <Script> block or registered Module, with no need to import or union types manually.

Letโ€™s say your Game component defines its own events and store shape. By extending the ambient interfaces below, these types become globally available to all <Script> and Module instances, no imports needed:

// types.ts or within your component file
type GameEvents = {
  'game:evt:boot': void;
  'game:evt:countdown': void;
};

type GameStore = {
  gameConfig: {
    music: 'on' | 'off';
    difficulty: 'beginner' | 'intermediate' | 'expert';
  };
};

declare global {
  interface AtomicRelay extends GameEvents {}
  interface AtomicStore extends GameStore {}
}

โœ… Each component or module can extend the global AtomicRelay and AtomicStore types. These declarations become ambiently available inside all <Script> blocks and registered Modules. No imports needed.

<Script data={{ evtStart: 'game:evt:boot' as keyof AtomicRelay }}>
  {({ el, data, $ }) => {
    el.$subscribe(data.evtStart, () => {
      const music = $.storeGet('gameConfig').music;
      el.$publish(music === 'on' ? 'audio:play' : 'audio:pause');
    });

    el.$publish('game:evt:countdown');
  }}
</Script>

You get:

  • ๐Ÿ”’ Strong typing for all $.store* and $publish/$subscribe calls
  • ๐Ÿšซ No manual unions or generics required
  • โšก Fully typed keyof AtomicStore and keyof AtomicRelay autocomplete in any script/module
  • โœ… Zero runtime cost

๐Ÿง  Why this matters

This reduces ceremony, avoids repetition, and ensures consistent typing across your entire atomic runtime. It keeps your relay/store contract global, type-safe, and frictionless.

๐Ÿ’ก Modular by Design

Since every component can safely extend AtomicRelay or AtomicStore, event and store contracts stay colocated, without requiring a central registry or union types.


โŒš Reactive data Proxy

When atomic is enabled, the data object that you pass to your <Script> instances is elevated and automatically upgraded into a reactive proxy:

<Script data={{count: 1}}>{({data}) => {
  data.$watch('count', (val) => console.log('Count:', val));

  data.$set('count', 5); // reactive
}}</Script>

You can:

  • Call $set(path, value) for granular updates, or pass a full object for deep merges, or pass a path and an object for leaf merges
  • Use $watch(path, handler) to respond to changes. The handler receives (newVal, oldVal)
  • Bind data to DOM with $bind(path, selector) or $bind(path, selector, watcher) to bind and watch in one line
  • $set(path, val) or deep-merge objects with $set(val)
  • $watch(path, fn) to subscribe to changes. The watcher fn has the following signature arguments (newVal, oldVal)
  • $bind(path, selector) to two-way bind to inputs, or $bind(path, selector, watcher) to combine a bind and watch in a single line

Everything is:

  • Deeply typed (with your own data shape)
  • Scoped to the current node
  • Automatically cleaned up on DOM removal

Example form binding + backend update:

<form>
  <fieldset>
    <legend>Type</legend>
    <label><input type="radio" name="type" value="all" /> All</label>
    <label><input type="radio" name="type" value="blog" /> Blog</label>
    <label><input type="radio" name="type" value="release" /> Release</label>
  </fieldset>
  {/* We pass initial filter state to clientside */}
  <Script data={{filters: {type: 'all'}}}>
    {({data, $}) => {
      /* Two-way bind radios with data proxy */
      data.$bind('filters.type', 'input[name="type"]');

      /* Watch for changes and refetch news */
      data.$watch('filters', async () => {
        const res = await $.fetch<DocumentFragment>('/filter-news', {
          method: 'POST',
          body: data.filters
        });

        /* If all's good, replace our current news list with the content from the server */
        if (res.ok && res.content) {
          document.getElementById('news-list')?.replaceWith(res.content);
        }
      });
    }}
  </Script>
</form>
<div id="news-list">
  {/* Initial server-rendered content, replaced on filter changes */}
</div>

Reactive data is minimal, atomic, and DOM-aware by default, no extra setup needed.


๐ŸŒ Global Store

Atomic exposes a shared global store for cross-component coordination:

<Script data={{locale:'nl'}}>{({$}) => {
  $.storeSet('locale', data.locale);
  console.log($.storeGet('locale')); // -> 'nl'
}}</Script>

But more importantly:

  • The store hydrates automatically from localStorage on load (using the $tfs: prefix)
  • $.storeSet(key, value) broadcasts a $store:key relay event
  • Use el.$subscribe('$store:locale', handler) to listen reactively anywhere
  • Pass { persist: true } to storeSet to persist the value to localStorage
  • $.storeDel(key) removes a key from memory and localStorage, and triggers a relay event
  • The store is fully typed via your createScript<..., ..., Store>() signature
<Script>{({el, $}) => {
  /* Listen to locale update */
  el.$subscribe('$store:locale', (locale) => {
    ...
  });
}}</Script>

Ideal for global state like themes, locales, onboarding flags, and other app-wide coordination.


๐Ÿงฉ Atomic Modules

When using createModule(...) and passing your modules into createScript({modules}), all <Script> blocks gain typed access to those modules via $.<name>.

For example:

<Script>
  {({ el, $ }) => {
    $.modal.open({ frag: '/about' });
    $.audio.play('intro');
  }}
</Script>

Modules are:

  • Declared server-side
  • Registered at the script level (not global)
  • Auto-delivered just-in-time โ€” no bundlers, no dead code

โœ… They only ship when referenced in a <Script>, keeping your payloads atomic and minimal.

How to Define a Module

Below is an example Modal module (as seen in Atomic Arcade with full source here):

// Modal.ts
import {Module} from '~/script';

export function Modal () {
  return Module({
    name: 'modal',
    mod: ({ $ }) => {
      let root: HTMLDivElement | null = null;

      function open(frag: DocumentFragment) {
        root = $.create('div', { children: [frag] });
        document.body.appendChild(root);
      }

      return {
        open: async ({frag}:{frag:string}) => {
          if (root) root.remove();
          const res = await $.fetch<DocumentFragment>(frag);
          if (res.ok && res.content) open(res.content);
        },
        close: () => { root?.remove(); root = null; },
      };
    },
  });
}

Then register it:

// script.ts
import { createScript, createModule } from '@trifrost/core';
import { type Env } from './types';
import { css } from './css';
import { Modal } from './components/modules/Modal';

export const { Module } = createModule({css});

const config = {
  atomic: true,
  css,
  modules: {
    modal: Modal,
  },
} as const;

export const { Script, script } = createScript<typeof config, Env>(config);

Now inside any <Script>, you can reference $.modal, $.audio, etc., with full typing, no imports needed.

For a full example (include an AudioPlayer module) see Atomic Arcade, or play it live.


๐Ÿ”ง Atomic $ Utilities

Atomic gives you access to the Atomic $ utilities. A suite of safe, zero-dependency, DOM-native helpers.

<Script>{({el, $}) => {
  $.on(el, 'click', () => console.log('clicked'));
  $.storeSet('theme', 'dark');
}}</Script>
Event utilities
  • $.fire(el, type, {data?, mode?}): Fires a CustomEvent from the provided element. Defaults to bubbling upward.
  • $.on(el, type, handler): Adds an event listener and automatically cleans up on unmount/element remove.
  • $.once(el, type, handler): Adds a one-time event listener that auto-cleans on first call.
DOM utilities
  • $.blurActive(): Removes focus from the currently active element
  • $.clear(el): Clears a dom node
  • $.create(tag, opts): Creates a new DOM element (uses createElementNS for known SVG tags, and infers the return type by tag)
  • $.cssVar(name): Retrieve the value of a css static variable registered with css.var (see Style System)
  • $.cssTheme(name): Retrieve the value of a css theme variable registered with css.theme (see Style System)
  • $.query(el, selector): Scoped querySelector. (with type inference for common selectors)
  • $.queryAll(el, selector): Scoped querySelectorAll with array result. (with type inference for common selectors)
  • $.timedAttr(el, attr, opts): Sets an attribute on el and removes it after opts.duration (optional after callback)
  • $.timedClass(el, className, opts): Adds a class to el and removes it after opts.duration (optional after callback)
Global Store access
  • $.storeGet(key): Get a value from the global kv store.
  • $.storeSet(key, value, opts?:{persist:boolean}): Set a value in the global kv store and optionally persists to local storage.
  • $.storeDel(key): Deletes a value from global kv store and local storage.
Miscellaneous
  • $.debounce(fn, delay): Debounced function wrapper.
  • $.eq(a, b): Structural equality check.
  • $.fetch(...): Smart wrapper around fetch with automatic body serialization and content parsing.
  • $.goto(...): Navigation helper to navigate to a specific url
  • $.isArr: Verify a provided value is an array (type guarded)
  • $.isBool: Verify a provided value is a boolean (type guarded)
  • $.isDate: Verify a provided value is a valid Date instance (type guarded)
  • $.isFn: Verify a provided value is a function (type guarded)
  • $.isInt: Verify a provided value is an integer (type guarded)
  • $.isNum: Verify a provided value is a finite number (type guarded)
  • $.isObj: Verify a provided value is a plain object (type guarded)
  • $.isStr: Verify a provided value is a string (type guarded)
  • $.isTouch: Boolean getter which returns true if the device has touch capabilities and false if it doesnt
  • $.sleep(ms): Resolves after the specified delay.
  • $.uid(): Generates a random id.
Notes on $.goto

$.goto is a high-level navigation helper for declarative and ergonomic client-side routing with built-in handling for query merging, blank tab opening, and replace-mode navigation.

Examples:

$.goto("/dashboard");
// โ†’ Navigates to: /dashboard
$.goto("/login", "replace");
// โ†’ Replaces current history entry with /login
$.goto("https://external.site", "blank");
// โ†’ Opens https://external.site in a new tab
// Current url: https://app.local/settings?page=2&theme=dark

$.goto("/account", "query");
// โ†’ Navigates to: /account?page=2&theme=dark

$.goto("/search?q=test", "query");
// โ†’ Navigates to: /search?q=test&page=2&theme=dark

$.goto("/search?q=test&page=3", "query");
// โ†’ Navigates to: /search?q=test&page=3&theme=dark

$.goto("/profile", {
  replace: true,
  includeQuery: true
});
// โ†’ Replaces history with: /profile?page=2&theme=dark
Notes on $.fetch
  • Automatically parses JSON, HTML, text, blobs, etc. based on the response Content-Type.
  • Supports timeout: auto-aborts request after X milliseconds (internally uses AbortController)
  • Supports credentials: Sent as include by default to support cookies/session auth.
  • Returns
{
  content: T | null;
  status: number;
  ok: boolean; /* True for http 2xx */
  headers: Headers;
  raw: Response | null;
}
  • Graceful fallback: On unexpected content types or parse failures, content falls back to null.
  • Safe defaults: Automatically serializes JSON payloads and sets headers unless FormData is used
  • Auto-converts HTML response to a DocumentFragment for insertion ease
  • Auto-converts Binary response to a Blob

Examples

Event Handling + Store Access
<Script data={{user: {name: 'Anna'}}}>
  {(el, data, $) => {
    const btn = $.query(el, 'button')!;
    const msg = $.query(el, 'p')!;

    $.on(btn, 'click', () => {
      const token = $.storeGet('auth.token');
      msg.textContent = token ? 'Authenticated โœ…' : 'No Token โŒ';

      $.fire(el, 'user:click', {data: {name: data.user.name}});
    });

    $.once(el, 'user:click', e => {
      console.log('Clicked once:', e.detail);
    });
  }}
</Script>
Debounced watch + Sleep
<Script data={{search: ''}}>
  {(el, data, $) => {
    const resultBox = $.query(el, '.results')!;

    data.$bind('search', 'input[name="search"]');

    data.$watch('search', $.debounce(async (term) => {
      if (!term || term.length < 3) {
        resultBox.textContent = 'Enter at least 3 characters...';
        return;
      }

      resultBox.textContent = 'Searching...';
      await $.sleep(500); // simulate async

      // Fake result
      resultBox.textContent = term.toUpperCase();
    }, 300));
  }}
</Script>
Fetch and Inject
<div>
  <button>Load Content</button>
  <section></section>
  <Script>{(el, data, $) => {
    const btn = $.query(el, 'button')!;
    const section = $.query(el, 'section')!;

    $.on(btn, 'click', async () => {
      const {status, content} = await $.fetch<DocumentFragment>('/snippet.html');
      if (status === 200 && content) {
        section.replaceChildren(content);
      }
    });
  }}</Script>
</div>
POST JSON Payload
<Script>{async (el, data, $) => {
  const {status, content} = await $.fetch<{userId: string}>('/api/create-user', {
    method: 'POST',
    body: {name: 'Alice', age: 30}
  });

  if (status === 201) {
    console.log('Created user:', content);
  }
}}</Script>
Blob Fetch (eg: download)
<Script>
  {async (el, data, $) => {
    const {status, content} = await $.fetch<Blob>('/download/file.zip');
    if (status === 200 && content) {
      const url = URL.createObjectURL(content);
      window.open(url, '_blank');
    }
  }}
</Script>
Timeout Fetch
const res = await $.fetch('/api/heavy', { timeout: 3000 });
if (!res.ok) console.error('Request timed out or failed');

Best Practices

  • โœ… Define your Script and Module factories once (via createScript() and createModule()), and share them across your app
  • โœ… Use atomic: true if you need reactivity, scoped lifecycle, pub/sub, or interactivity
  • โœ… Pass modules into createScript({modules}) to enable $.<name> just-in-time delivery
  • โœ… Keep global logic in Modules (Module(...)), and local logic colocated in <Script>
  • โœ… Prefer data.$watch(...) and data.$bind(...) over manual DOM tracking
  • โœ… Use the $ utils for everything from event listeners to element creation ($.on, $.create, $.fire, etc.)
  • โœ… Define your event and store contracts using AtomicRelay and AtomicStore interfaces for full global typing
  • โœ… Keep behavior colocated with markup, every <Script> is a reactive, isolated, fragment-safe unit
  • โœ… Design modules like services: reusable, lazy, stateless, and DOM-aware
  • โœ… Clean up logic with el.$unmount
  • โŒ Donโ€™t manually import your modules client-side, use the $.<module> interface
  • โŒ Donโ€™t mutate DOM outside the Atomic runtime, use $ helpers to stay reactive

TLDR

  • createScript({atomic: true}) enables fine-grained reactivity with lifecycle, pub/sub, data proxying, and more
  • <Script> blocks become per-node reactive VMs, isolated, hydrated, CSP-safe
  • Define global service-based logic using Module(...) and pass them to createScript({modules})
  • Access registered modules inside <Script> via $.<module>, zero import, fully typed
  • Modules are delivered just-in-time, only when referenced
  • Global pub/sub via el.$publish, el.$subscribe, typed with AtomicRelay
  • Global reactive store via $.storeSet, $.storeGet, typed with AtomicStore
  • $.fetch, $.fire, $.on, $.debounce, a full DOM-native toolkit included
  • No bundling, no hydration wrappers, everything SSR-first and fragment-ready

๐Ÿš€ It's the power of a reactive runtime, delivered one fragment at a time.


Atomic Arcade ๐ŸŽฎ

Want to see TriFrost Atomic in action? Check out Atomic Arcade, a fully interactive, zero-bundle gaming experience running entirely on Cloudflare Workers.

Built with TriFrost Atomic, the arcade showcases three classic games:

  • Tetris: With keyboard controls, dynamic theming, fragment-based rendering and canvas control.
  • Breakout: Featuring DOM-driven canvas control and some nice bouncy effects.
  • Snake: With fast-paced reactivity and SSR-based food fragments.

Everything is powered by Atomic <Script> components and global Module-based services. Ambiently typed via AtomicRelay and AtomicStore, no client bundles, no hydration ceremony.

๐Ÿ’พ View the source: github.com/trifrost-js/example-atomic-arcade

Atomic Arcade is a perfect reference for building modular, interactive islands using TriFrostโ€™s reactivity, lifecycle hooks, and global typing model, all in a worker-optimized footprint.


Next Steps

Ready to learn more?

Loved the read? Share it with others