Building a Cross-Frame SDK for POS Extensions
April 2026 · 12 min read
StoreOS is a Point-of-Sale platform that supports a plugin model — independently deployed web applications (extensions) that run inside the main POS and add capabilities like AR try-on, QSR ordering, scan-and-go, and price management. Each extension is built by a different team, lives in a different Git repository, and deploys on its own release cycle.
The integration problem this creates is not trivial. Extensions run in <iframe> elements inside the host POS app. Browser security means two cross-origin frames cannot share a JavaScript execution context, a Redux store, or even a plain variable. When the cashier updates the cart in the host, an extension watching that cart has no way to know — unless you build a bridge.
That bridge is FSI — Fynd StoreOS Interface. This post is a full walkthrough of how it works: the design, the two primitives it exposes, the postMessage routing, and the Android WebView integration that extends the same contract to native hardware.
The Problem in Concrete Terms
Without a shared contract, every integration point between the host and an extension would require custom wiring — bespoke postMessage calls with no agreed message format, duplicated listener logic, and a maintenance surface that grows with every new extension. We had five extensions at the time I was building this. Scaling that without a protocol would mean five different integration contracts that all need updating when the host changes.
The goal was a single file — one fsi.js — that both the host and every extension load. It attaches to window.FSI and provides two primitives that work identically regardless of which side you're on. The underlying postMessage routing is invisible to the caller.
Two Primitives
FSI exposes exactly two things on window.FSI:
window.FSI.event— a publish/subscribe event bus for fire-and-forget signalswindow.FSI.state— a set of named observables for shared reactive state
The distinction matters. Events are imperative — "close this extension", "open this product page", "show a toast". State is declarative — "the cart currently looks like this", "the product being viewed is this". Both are needed because they solve different coordination problems.
Two Roles, One File
FSI operates in one of two roles, set at initialization time. The role determines which direction postMessage calls flow.
| Role | Who sets it | emit() sends to |
|---|---|---|
HOST_APPLICATION | rotom, on startup | All <iframe> elements on the page |
EXTENSION_APPLICATION | extension, auto-detected via window.self !== window.top | window.parent |
null (uninitialized) | neither | Local handlers only — no postMessage |
The null case is useful — it means FSI can be tested in a same-page context without iframes, and it also covers the Android WebView case (more on that below).
The Event Bus — SignalEvt
The event bus is a class called SignalEvt. Internally, handlers are stored in a plain object keyed by an auto-incrementing ID:
{
events: {
'cart.refresh': {
1: handlerFn,
2: anotherFn,
},
'close': {
3: closeFn,
}
},
id: 3 // current counter
}Storing handlers in an object rather than an array makes removal O(1) — no array scanning, no index shifting. The ID is stamped directly onto the function object: eventHandler._eventId = ++this.id. This means the same named function always carries the same ID — re-registering it is idempotent.
The third argument to emit() is the loop-breaker:
// Host calls:
window.FSI.event.emit('cart.refresh', data)
// → _applicationType is HOST_APPLICATION
// → postMessage to all iframes
// Extension's message listener receives postMessage, then calls:
window.FSI.event.emit('cart.refresh', data, true) // ← isFromEventListner=true
// → calls local handlers
// → does NOT postMessage again (loop prevented)Without that flag, a message from the host would arrive in the extension, get re-emitted, and the extension would try to postMessage back to its own parent — which would arrive in the host, fire the host handlers, and the host would postMessage to the extension again. The isFromEventListner flag is set to true exclusively by the internal message listener — callers never set it.
Shared State — Observable
The Observable class is the reactive state container. Each instance holds a single named piece of state and a list of subscribers. Two behaviors define it:
Warm subscriptions. If state has already been published when a new subscriber registers, that subscriber is called immediately with the current value. An extension that loads after the host has already set cart state still gets the current cart — no missed-state bug, no manual polling.
Deep clone on write.
this._data = JSON.parse(JSON.stringify(data))
State is always stored as a deep clone. If the host publishes the cart object and then mutates it (say, adding an item), that mutation doesn't silently affect what the extension already received. The clone is the source of truth.
Three observable state keys are available: cart, product, and orderDetail. The host pushes to these when the relevant POS view changes. Extensions subscribe to what they care about.
The Message Bridge
The full transport layer is a single window.addEventListener('message', ...) at the bottom of fsi.js, registered in both the host and every extension:
window.addEventListener('message', (event) => {
const { eventName, eventData, type } = event.data
if (eventName && type === 'SIGNAL') {
window.FSI.event.emit(eventName, eventData, true)
}
if (eventName && window.FSI.state[eventName] && type === 'STATE') {
window.FSI.state[eventName]._publish(eventData, true)
}
})Two message types: SIGNAL routes to the event bus, STATE routes to the matching observable. Both arrive with isFromEventListner=true to prevent re-broadcasting.
All postMessage calls use '*' as the target origin. Specifying an exact origin would require rotom to know every extension's URL at build time — which breaks the dynamic extension loading model. The '*' approach accepts the mild security trade-off in exchange for flexibility, and is acceptable since extensions are loaded from the rotom domain.
End-to-End Flow
Here is what happens when a cashier updates the cart and an extension needs to react:
- Extension loads FSI, calls
window.FSI.state.cart.subscribe(fn)— gets the current cart immediately if already set - Cashier updates the cart in the host POS
- Host calls
window.FSI.state.cart._publish(newCart) - FSI finds all
<iframe>elements, sendspostMessage({ eventName: 'cart', eventData: newCart, type: 'STATE' })to each - Extension's message listener receives it, calls
window.FSI.state.cart._publish(newCart, true) - All cart subscribers in the extension fire with the new data
The reverse — extension sending a command to host:
- Extension calls
window.FSI.event.emit('pdp.open', { productSlug: '...' }) - FSI detects
_applicationType === EXTENSION_APPLICATION, sendswindow.parent.postMessage(...) - Host's message listener receives it, calls
window.FSI.event.emit('pdp.open', data, true) - Host handler fires, navigates to the product page
The Android WebView Twist
StoreOS also runs as a native Android application with extensions loaded inside a Jetpack Compose WebView. In this context there is no parent browser window — the native app is the host. So FSI'spostMessage routing doesn't apply. But the extension still uses the same window.FSI.event.emit() calls. The contract doesn't change — only the transport layer underneath does.
Android achieves this through two mechanisms: injecting fsi.js directly from bundled assets before page JS runs, and registering Kotlin methods as callable JavaScript functions via Android's @JavascriptInterface mechanism.
Injection from assets, not from the network
The web version of FSI is served over HTTP from the rotom domain. Android bundles its own copy at src/main/assets/fsi.js and injects it in onPageStarted — which fires before any page JavaScript runs:
// ExtensionView.kt — onPageStarted
val jsContent = fsiFileContent + """
window.nativeEventHandler = function(eventName, payload) {
window.AndroidInterface.onEventWithPayload(eventName, payload);
};
window.FSI.constants.SUPPORTED_EVENTS.forEach(eventName => {
FSI.event.on(eventName, function(payload) {
nativeEventHandler(eventName, payload);
});
});
window.FSI.state.$stateDataPageKey._publish($STATE_DATA_FOR_EXTENSION, false);
"""
webView?.evaluateJavascript(jsContent, null)Three things happen in that single injection:
nativeEventHandleris defined — the bridge out of JS into native Kotlin viawindow.AndroidInterface.onEventWithPayload()- Every event in
SUPPORTED_EVENTSis auto-subscribed — when the extension emitspdp.openorclose, it flows through to native without any additional wiring - Initial state is pushed — the native app serializes the current cart/product/order into a JSON string before launching the WebView, and injects it via
_publish()so the extension has full context on first render
Because _applicationType is never set on Android, it stays null — which means emit() calls handlers directly in-process. The injected nativeEventHandler subscriptions are those in-process handlers. No postMessage is ever involved.
Two @JavascriptInterface classes
Android registers two Kotlin objects as callable from JavaScript:
| JS name | Kotlin class | Responsibility |
|---|---|---|
window.AndroidInterface | AndroidInterface | Receives FSI events from extensions, maps them to typed Kotlin sealed classes, routes to Compose UI |
window.StoreOSBridgeInterface_native | StoreOSBridgeInterface | Hardware bridge — receipt printer, barcode scanner, device light, camera permissions |
When an extension calls window.FSI.event.emit('close'), it flows through the auto-subscribed handler to AndroidInterface.onEventWithPayload('close', null), which maps to the ExtensionEvent.Close sealed class, which pops the Compose backstack.
Hardware calls use the bridge interface directly. The same API surface works in both web and Android environments — an extension can call window.StoreOSBridgeInterface.native.print(template) and it works whether the host is a browser or a native app, because the alias window.StoreOSBridgeInterface.native = window.StoreOSBridgeInterface_native is injected on page finish.
How It Evolved — Three Versions
FSI shipped in three versions. The meaningful changes:
| Version | Change |
|---|---|
| v1 → v2 | orderDetail observable removed; minor: optional chaining dropped from off(), making it crash on null handler |
| v2 → v3 | orderDetail restored; pre/post hook system added; on() now returns eventId; debug logs added |
The v3 hook system is the most interesting addition. It lets callers intercept events before they cross the frame boundary:
// Pre-hook — return false to cancel the event entirely
window.FSI.event.addHook('pre', 'cart.refresh', (data) => {
if (!data.items?.length) return false // cancel empty cart refreshes
return true
})
// Post-hook — runs after the event has been emitted
window.FSI.event.addHook('post', 'cart.refresh', (data) => {
analytics.track('cart_refresh_emitted', data)
})The cancel-on-falsy behavior in pre-hooks is a clean way to add rate limiting or validation without modifying the emit call sites.
One limitation: only one hook per type per event — a second registration silently replaces the first.
Design Takeaways
Looking back, the decisions that made FSI work well:
Same API, different transports. The extension always calls window.FSI.event.emit() — whether it's running inside a browser iframe or an Android WebView. The transport is an implementation detail hidden by the SDK. This is the whole point: extensions don't need to know where they are running.
Warm subscriptions eliminate a race condition class. The host might publish cart state before all extension scripts have loaded and registered their subscribers. By replaying state immediately on subscription, any extension that loads late still gets the full context. This is a detail that would have caused intermittent bugs if missed.
The loop-breaker flag is load-bearing. The third argument to emit() is internal, invisible to callers, and essential. Without it, every message crossing the frame boundary would recurse until the browser crashed. It's the kind of thing that is obvious once you think about it but easy to miss before a message storm shows up in production.
Written by Suraj Singh — SDE at Fynd, working on StoreOS extension infrastructure.