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 signals
  • window.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.

RoleWho sets itemit() sends to
HOST_APPLICATIONrotom, on startupAll <iframe> elements on the page
EXTENSION_APPLICATIONextension, auto-detected via window.self !== window.topwindow.parent
null (uninitialized)neitherLocal 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:

  1. Extension loads FSI, calls window.FSI.state.cart.subscribe(fn) — gets the current cart immediately if already set
  2. Cashier updates the cart in the host POS
  3. Host calls window.FSI.state.cart._publish(newCart)
  4. FSI finds all <iframe> elements, sends postMessage({ eventName: 'cart', eventData: newCart, type: 'STATE' }) to each
  5. Extension's message listener receives it, calls window.FSI.state.cart._publish(newCart, true)
  6. All cart subscribers in the extension fire with the new data

The reverse — extension sending a command to host:

  1. Extension calls window.FSI.event.emit('pdp.open', { productSlug: '...' })
  2. FSI detects _applicationType === EXTENSION_APPLICATION, sends window.parent.postMessage(...)
  3. Host's message listener receives it, calls window.FSI.event.emit('pdp.open', data, true)
  4. 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:

  1. nativeEventHandler is defined — the bridge out of JS into native Kotlin via window.AndroidInterface.onEventWithPayload()
  2. Every event in SUPPORTED_EVENTS is auto-subscribed — when the extension emits pdp.open or close, it flows through to native without any additional wiring
  3. 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 nameKotlin classResponsibility
window.AndroidInterfaceAndroidInterfaceReceives FSI events from extensions, maps them to typed Kotlin sealed classes, routes to Compose UI
window.StoreOSBridgeInterface_nativeStoreOSBridgeInterfaceHardware 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:

VersionChange
v1 → v2orderDetail observable removed; minor: optional chaining dropped from off(), making it crash on null handler
v2 → v3orderDetail 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.