@low6dev/bifrost
v0.0.7
Published
Tiny, dependency-free postMessage bridge between an arcade game iframe and its host (with React Native WebView support).
Maintainers
Readme
bifrost
Bifröst — a tiny, dependency-free
postMessagebridge between an embedded arcade game (<iframe>) and its host page, with React Native WebView support.
A game runs inside an <iframe> (or a React Native WebView) and exchanges lifecycle events with the host via postMessage. bifrost wraps both sides of that channel with a symmetric API — notify() and listen() work in either direction. The game side posts to window.parent by default; the host side posts into the iframe's contentWindow (configured via the target option), so the host can reply with payloads, push state, or send commands using the same API the game uses.
It also ships with Muninn — a symmetric storage helper that lets either side ask the other to persist data. Useful because Safari silently evicts localStorage inside iframes after an undefined period; the iframe can push its state to the host, which has durable storage. The same sendStore works in reverse so the host can hydrate the game with state on GAME_READY.
Install
From JSR (recommended — native TypeScript):
deno add jsr:@low6/bifrost
# Node / Bun / pnpm / Yarn:
npx jsr add @low6/bifrost
bunx jsr add @low6/bifrostOr from npm:
bun add @low6/bifrost
# or: npm install @low6/bifrostUsage
From the game (inside the iframe / WebView)
import { Bifrost } from "@low6/bifrost";
// `host` is the targetOrigin to post to (default "*"). Set the host origin in production.
const bifrost = new Bifrost("https://host.example.com");
// Tell the host the game is ready for its payload
bifrost.notify("GAME_READY");
// Send a round result
bifrost.notify("GAME_ROUND_END", { ticketId: 101, won: true });From the host page
import { Bifrost, type GameEvent } from "@low6/bifrost";
const iframe = document.querySelector<HTMLIFrameElement>("#game")!;
const bifrost = new Bifrost("https://game.example.com", {
// Where to post host→game messages. Pass the iframe element directly —
// Bifrost reads `.contentWindow` at send time, so it's safe to construct
// before the iframe finishes loading.
target: iframe,
// When `host` is set, only that origin is accepted by default.
// Set this explicitly to widen or narrow the allow-list.
allowedOrigins: ["https://game.example.com"],
});
const cleanup = bifrost.listen((event: GameEvent) => {
switch (event.subject) {
case "GAME_READY":
// notify symmetrically — same API as the game side
bifrost.notify("GAME_PAYLOAD", { seed: 42 });
break;
case "GAME_ROUND_END":
// event.message holds the round result
break;
}
});
// Update the target later (e.g. after a route change swaps the iframe):
bifrost.setTarget(newIframe);
// On teardown (React unmount, route change, etc.) — detach the listener:
cleanup();notify() and listen() are symmetric — calling notify() on the host side posts into the iframe's contentWindow, and the game's bifrost.listen() receives it. The same API works in both directions; the difference is just which target each side is pointing at.
Persisting data across reloads (Muninn)
Safari periodically clears localStorage inside an iframe. Push the data to the host instead:
// In the game
bifrost.sendStore("score", "1250"); // ask the host to persist this
bifrost.store("score", "1250"); // (optional) also cache locally; returns boolean
const cached = bifrost.muninn.get("score"); // read whatever's in *this* side's storage// In the host — once you've called bifrost.listen(...), incoming store envelopes
// are automatically written to the host's localStorage by Muninn. No extra wiring.
bifrost.listen((event) => { /* your existing game-event handler */ });
// Read what the game has saved
const score = bifrost.muninn.get("score");
// Or push state down to the iframe (e.g. on bifrost.notify("GAME_READY"))
bifrost.sendStore("score", "1250");sendStore is symmetric — call it from either side and the peer's Bifrost listener writes the value into its own Muninn-backed storage. store writes locally only.
Forwarding errors from the game to the host
Pass captureErrors: true and Bifrost attaches global error and unhandledrejection listeners on its window. Each event is forwarded to the peer as a GAME_ERROR envelope with a structured GameErrorPayload body — useful from the game side to surface uncaught crashes to the host page (for Sentry routing, oncall pages, in-page diagnostic toasts, etc.).
// In the game (iframe)
import { Bifrost } from "@low6/bifrost";
const bifrost = new Bifrost("https://host.example.com", { captureErrors: true });// In the host
import { Bifrost, GAME_ERROR_SUBJECT, type GameErrorPayload } from "@low6/bifrost";
const bifrost = new Bifrost("https://game.example.com", { target: iframe });
bifrost.listen((event) => {
if (event.subject === GAME_ERROR_SUBJECT) {
const err = event.message as GameErrorPayload;
Sentry.captureMessage(`[game] ${err.name ?? err.type}: ${err.message}`, {
extra: { stack: err.stack, filename: err.filename, line: err.lineno },
});
}
});Toggle at runtime with bifrost.startErrorCapture() / bifrost.stopErrorCapture() — both are idempotent. Enabling on both sides creates a full-duplex error feed, which is rarely what you want — typically only the game side captures.
Security
Bifrost talks over postMessage, which is fundamentally broadcast — any window with a reference to yours can send your listener a message. The library applies two filters before reaching your callback:
Origin allow-list. Inbound messages whose
event.originisn't in the allow-list are silently dropped. The allow-list defaults to[host]when you supply an explicithost, and to accept-any whenhost === "*"(the default — convenient for local dev, dangerous in production). Always pass an explicithostor an explicitallowedOriginswhen your page hosts third-party iframes, browser extensions are common, or you handle sensitive data.Envelope shape check. Messages that aren't valid JSON or lack a
subjectfield are ignored without logging — so the console isn't flooded with React DevTools / extension / third-party chatter.
For Muninn, inbound value payloads are also size-capped (default 1 MB, configurable via muninn.maxValueBytes) to defend against a misbehaving peer trying to fill your storage quota.
If you do nothing else, set an explicit host and an allowedOrigins list. React Native WebView messages can have an empty event.origin — include "" in your allow-list (or use a predicate) when targeting RN.
API
new Bifrost<E = string>(host?: string, options?: BifrostOptions)
host is the targetOrigin used when posting to the peer (default "*"). Prefer an explicit origin in production. E optionally narrows the event subject type.
type BifrostOptions = {
muninn?: MuninnOptions;
allowedOrigins?: string[] | ((origin: string) => boolean);
target?: Window | HTMLIFrameElement | null;
captureErrors?: boolean;
};options.muninnis forwarded to the internal Muninn instance (all fields optional).options.allowedOrigins:- Array —
event.originmust exactly match an entry. - Function — predicate called with
event.origin; returntrueto accept. - Omitted — defaults to
[host]when an explicithostwas supplied; accept-any whenhost === "*".
- Array —
options.target— destination window for outboundnotify()/sendStore(). Pass anHTMLIFrameElementto defer thecontentWindowread until send time (so you can construct Bifrost before the iframe loads). If omitted, sends route viawindow.parent(iframe case) or the last-seenlisten()peer (host fallback).options.captureErrors— whentrue, attaches globalerrorandunhandledrejectionlisteners and forwards each event to the peer as aGAME_ERRORenvelope. Defaultfalse. Seebifrost.startErrorCapture()for runtime toggling.
bifrost.listen(cb: (event: GameEvent) => void): () => void
Subscribes to inbound messages and returns a cleanup function. The cleanup removes only this specific subscriber; invoke it on teardown (React unmount, route change, etc.) to avoid stale callbacks.
One DOM listener, many subscribers. A Bifrost instance attaches at most one message event listener to the DOM — every listen() call registers its callback against the same internal listener, and Bifrost fans inbound events out to all subscribers. The DOM listener is attached lazily on the first listen() call and detached again automatically when the last subscriber's cleanup runs (the parse / origin / Muninn pipeline happens exactly once per message, not once per subscriber).
String payloads are JSON.parsed; objects are passed through. Muninn store envelopes are intercepted and applied to local storage before any subscriber runs. Messages from origins not in the allow-list, and messages without a subject field, are silently ignored. Subscribing the same function reference twice is a no-op (Set semantics).
The listener attaches to window by default. React Native hosts that still hit the legacy Android RNWebView quirk (incoming messages fire on document) can inject window.ReactNativeOS = "android" before constructing Bifrost to opt in to the document path — no user-agent sniffing.
bifrost.notify<T>(subject: E, message?: T | null): void
Posts { subject, message } to the peer. Routing: window.ReactNativeWebView.postMessage(JSON.stringify(...)) if running inside a React Native WebView; otherwise the destination from the resolution chain (target → window.parent if in an iframe → last-seen listen() peer). A silent no-op if nothing in the chain is usable.
bifrost.setTarget(target: Window | HTMLIFrameElement | null): void
Set or replace the destination window for notify() / sendStore(). Pass an HTMLIFrameElement for deferred .contentWindow resolution, or null to fall back to the default chain.
bifrost.startErrorCapture(): () => void
Attaches global error and unhandledrejection listeners to this window. Each event is forwarded to the peer as a GAME_ERROR envelope. Returns a cleanup function (also stored internally; equivalent to calling stopErrorCapture()). Idempotent — calling twice doesn't double-attach.
bifrost.stopErrorCapture(): void
Detach the error/rejection listeners. Idempotent.
GAME_ERROR_SUBJECT / type GameErrorPayload
The reserved subject and structured payload shape used by error capture:
type GameErrorPayload = {
type: "error" | "rejection";
message: string;
name?: string; // Error constructor name
stack?: string;
filename?: string;
lineno?: number;
colno?: number;
timestamp: number; // Date.now() at dispatch
};bifrost.store(key: string, value: string): boolean
Persist value against key in this side's local Muninn storage. Synchronous. Returns true on success, false if the underlying Storage rejected the write (Safari Private Browsing, quota exceeded, etc.).
bifrost.sendStore(key: string, value: string): void
Ask the peer to persist key/value via their Muninn. The peer's bifrost.listen writes it on receipt. Use this when the local side's storage isn't reliable (Safari iframes) and the peer's is.
bifrost.muninn: Muninn
Direct access to the underlying Muninn instance — useful for get, remove, clear, keys.
type GameEvent<T = unknown, E = string>
type GameEvent<T = unknown, E = string> = {
subject: E;
message?: T;
};Type the subject enum end-to-end: new Bifrost<MySubjects>() with GameEvent<Payload, MySubjects>.
new Muninn(host?: string, opts?: MuninnOptions)
Sync, namespaced wrapper around a Storage backend (default window.localStorage). Constructed automatically by Bifrost; you can also import and use it standalone via @low6/bifrost/muninn.
type MuninnOptions = {
namespace?: string; // key prefix (default "muninn")
storage?: Storage; // backing store (default window.localStorage)
maxValueBytes?: number; // cap on inbound value length (default 1_000_000)
target?: Window | HTMLIFrameElement | null; // destination for sendSet; same chain as Bifrost
};Methods:
set(key, value): boolean— returns whether the underlyingStorageaccepted the write.get(key): string | nullremove(key): voidclear(): void— removes only entries under this namespace.keys(): string[]— unprefixed keys under this namespace.sendSet(key, value): void— fire-and-forget peer relay.receive(envelope): boolean— intercept inbound; returnstrueif it was a Muninn envelope (handled or rejected).setTarget(target): void— update the destination window post-construction.resolveTarget(): Window | null— peek at the destination the next send would use.
All storage methods catch and console.warn on Safari Private Browsing / quota errors rather than throwing.
License
MIT
