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:
capture(...)—addEventListenerwith optional throttling.captureViewport(...)— IntersectionObserver-backed time-on-section tracking. Emitsviewport:enter,viewport:exit, andviewport:dwell(withdurationMspre-computed).ingest(record)— for events you capture yourself via custom logic.
// 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:
- Inside a Rust service that consumes a Kafka topic.
- On an ARM edge gateway with a 5–10 MB binary.
- In a browser tab via
@tflo/browser-events(this example) or the React binding.
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.