Browser events example

Part of the examples index · See also: React example (positioning) · WASM / JS bindings docs

This is an example application, not the product. @tflo/browser-events is a first-class, framework-agnostic example that shows the engine running under a frontend with zero React: vanilla-TS observers (scroll / visibility / viewport / lifecycle / errors) feed the same WASM engine, with sinks for GA4 / console / edge. Together with the React example, it proves the engine runs under any frontend or none — same WASM binding, two co-equal examples. The product is the engine.

Capture DOM events, derive typed domain signals, ship them anywhere — the matching engine runs entirely in WebAssembly — no analytics server, no SDK lock-in, no vendor that owns the meaning of your events.

The companion package @tflo/browser-events bundles the tflo-cep WASM engine with a capture layer, viewport tracker, and pluggable sink router. GA4Sink is one option among many — the engine has zero analytics-vendor knowledge.

Why client-side derivation

Most browser analytics either ship raw events to a third-party SaaS and let them decide what counts as "engagement," or hand-roll a tangle of addEventListener + ad-hoc timeouts that drifts out of sync with what marketing or product actually asked for. Both approaches end with the meaning of your events living somewhere you don't control.

Deriving signals client-side fixes both. Patterns express domain meaning (abandoned_cart, section_engaged, rage_click) as declarative chains the team can read. The rest of the stack — sinks, collectors, warehouses — only sees the derived signals, not the raw event firehose. And because everything runs in the browser via WASM, the same engine that powers your edge gateway runs in your marketing site without a server.

1. Install

npm install @tflo/browser-events

Browser bundlers (Vite, Webpack 5, Rollup, Astro) load the WASM artifact automatically. Build size: roughly 40 KB of optimized .wasm plus ~10 KB of TypeScript glue.

2. Initialize

Construct TFloBrowser with the sinks you want to route to. Add an optional consent gate to skip outbound delivery when the user hasn't agreed.

import {
  TFloBrowser,
  Pattern,
  ConsoleSink,
  EdgeSink,
  GA4Sink,
  type EventRecord,
} from "@tflo/browser-events";

const tflo = new TFloBrowser({
  sinks: [
    new ConsoleSink(),                                    // dev visibility
    new EdgeSink({ name: "edge", endpoint: "/collect" }), // first-party
    new GA4Sink({ name: "ga4", sendTo: "G-XXXXXXX" }),    // optional
  ],
  consent: () => localStorage.getItem("analytics_consent") === "yes",
});

await tflo.init(); // load the ~40 KB WASM bundle

Sinks are name-keyed and isolated — one sink's failure doesn't block the others. The SinkRouter dispatches every emitted signal to every registered sink unless the emit closure specifies a sinks: [...] hint.

3. Capture events

Three first-class capture helpers ship in v0.1:

// Time on each section — IntersectionObserver under the hood.
tflo.captureViewport({
  target: document,
  selector: "section[data-section-id]",
  threshold: 0.5, // half visible counts as "in view"
});
// Emits viewport:enter, viewport:exit, and viewport:dwell (with durationMs).

// Capture clicks with custom payload extraction.
tflo.capture<MouseEvent>({
  target: document,
  type: "click",
  fields: (e) => {
    const el = (e.target as HTMLElement | null)?.closest("[data-analytics-action]");
    return el
      ? { action: el.getAttribute("data-analytics-action"), cartId: el.dataset.cartId }
      : {};
  },
});

// Capture page lifecycle.
tflo.capture({ target: document, type: "visibilitychange" });
window.addEventListener("pagehide", () => void tflo.flush());

Unbind functions are returned by every capture call — teardown on destroy. The viewport helper emits a trailing exit + dwell for any element still visible at unbind time, so navigating away from a page still ships the last section's dwell.

4. Derive signals with patterns

Patterns are the domain layer. The same six-method API as the Rust crate — compiled to the same WASM bytes, driven by the same engine::Runtime. See the event patterns page for the full surface.

// Cart abandonment — closing the tab still ships the signal.
const abandonedCart = new Pattern<EventRecord>("abandoned_cart")
  .timestamp((e) => e.ts)
  .when((e) => e.fields.action === "add_to_cart")
  .notThen((e) => e.fields.action === "purchase")
  .within(5 * 60 * 1000)
  .emit((m) => ({
    name: "abandoned_cart",
    payload: { cartId: m.first().fields.cartId },
    sinks: ["edge"], // only ship to the first-party collector
  }));

// Engaged with a section — viewed → deep dwell.
const engaged = new Pattern<EventRecord>("section_engaged")
  .timestamp((e) => e.ts)
  .when((e) => e.kind === "viewport:enter" && e.fields.sectionId === "pricing")
  .then((e) => e.kind === "viewport:dwell" && (e.fields.durationMs as number) > 5_000)
  .within(60_000)
  .emit((m) => ({
    name: "section_engaged",
    payload: {
      sectionId: m.first().fields.sectionId,
      dwellMs: m.last().fields.durationMs,
    },
  }));

tflo.addPattern(abandonedCart);
tflo.addPattern(engaged);

5. Route to any sink

The Sink interface is the plug-in seam. The package bundles ConsoleSink, EdgeSink (HTTP POST batching, with sendBeacon fallback on unload), and GA4Sink. Bringing Segment, Mixpanel, your own warehouse, or anything else is a fifty-line class:

import type { Sink, DerivedSignal } from "@tflo/browser-events";

// Segment integration — same shape as the bundled sinks.
class SegmentSink implements Sink {
  readonly name = "segment";
  send(signal: DerivedSignal): void {
    window.analytics?.track(signal.name, signal.payload);
  }
}

// Your own warehouse — async delivery.
class WarehouseSink implements Sink {
  readonly name = "warehouse";
  async send(signal: DerivedSignal): Promise<void> {
    await fetch("https://api.example.com/events", {
      method: "POST",
      headers: { authorization: "Bearer ..." },
      body: JSON.stringify(signal),
    });
  }
}

new TFloBrowser({
  sinks: [new SegmentSink(), new WarehouseSink(), new EdgeSink({ ... })],
});

The matching engine has zero vendor knowledge. GA4Sink is fifty lines that import nothing — write your own and the rest of the SDK doesn't change.

Why the WASM bridge matters

The same Rust crate, the same patterns, run in three places:

Detector composition that lived only on the server is now available where the user is. @tflo/browser-events and the React binding are two co-equal examples over the same WASM bridge — one is framework-agnostic vanilla-TS observers, the other is React components. The browser SDKs are not separate reimplementations; they share the generic engine::Runtime with the Rust crate via the trait abstraction over predicates, emit, and timestamp callbacks. One engine. Three idiomatic surfaces. Feature additions land once.

Browser sizing

Asset Size
Optimized tflo_cep_wasm_bg.wasm~41 KB
Generated JS glue~22 KB
TypeScript runtime (@tflo/browser-events)~10 KB (minified)
Total over the wire (gzip)~25 KB

Companion repos: tflo-cep (Rust crate) · tflo-cep-wasm (wasm-bindgen bridge) · @tflo/browser-events (TypeScript SDK).

Related docs: Event patterns · WebAssembly · WASM / JS bindings · Examples index

Other examples: React example — same WASM binding, React components with zero temporal state; the engine owns it.