@hellolisa/sdk-messages
v0.1.0
Published
Cross-platform type definitions for the LiSA Player Message API.
Readme
@hellolisa/sdk-messages
Type definitions for the LiSA Player Message API, for web, iOS, Android and React Native.
The LiSA Player runs in an iframe on the web and in a web view in native apps. It talks to the surrounding host app by exchanging JSON messages. This package is the shared contract for those messages, so an integrator does not have to rediscover the wire format for each platform they ship on.
TypeScript is the source of truth. The Swift and Kotlin files are hand-written
bindings of the same contract, and all three decode the shared fixtures in
spec/fixtures.json.
What is in the box
| Platform | Artifact | Install |
| ------------ | -------------------------------------------------------------------------- | ---------------------------------- |
| Web | @hellolisa/sdk-messages | pnpm add @hellolisa/sdk-messages |
| React Native | @hellolisa/sdk-messages | pnpm add @hellolisa/sdk-messages |
| iOS | platforms/ios/LiSAMessages.swift | Drop the file into your target |
| Android | platforms/android/LiSAMessages.kt | Drop the file into your module |
The TypeScript build has no runtime dependencies. The Swift file has none — it targets Foundation only, so it works in a UIKit or SwiftUI app without a package manager.
The Kotlin file needs kotlinx-serialization-json and its compiler plugin:
// build.gradle.kts
plugins {
kotlin("plugin.serialization") version "2.0.0" // or your Kotlin version
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1")
}It needs Kotlin 1.9 or newer (it uses data object and enum entries), and is
verified against Kotlin 2.4 and kotlinx.serialization 1.8.1. It has no Android
framework dependency, so it is also usable from a plain JVM module or shared
Kotlin Multiplatform JVM target.
The protocol in one minute
Messages travel in both directions and share one envelope:
{
"messageType": "lsc:product:add-to-cart", // what happened
"sender": "LiSA", // who sent it
"recipient": "LiSA", // only on messages sent TO the player
"messageId": "…", // optional, requests an acknowledgement
"clockDriftInMs": -42, // client clock offset from server UTC
}Three rules matter:
- Wait for
lsc:app:listen. The player emits it once it is ready. Anything sent earlier may be dropped. - Address inbound messages with
recipient: "LiSA". The player ignores everything else, which is what keeps unrelatedpostMessagetraffic on the host page from being misread as player input. - The player does not own the cart. It reports intent
(
lsc:product:add-to-cartoutbound); the host performs the add and confirms it by sendinglsc:product:add-to-cartback.
InboundMessageType and OutboundMessageType enumerate every message in each
direction.
Common flows
Five exchanges cover most integrations. Each is a round trip: the player states what it needs, the host answers from what only it knows.
1. Handshake — establish who the visitor is
Nothing you send before lsc:app:listen is guaranteed to arrive. Treat that
message as the only safe starting gun, and send the visitor's identity there.
player ──► lsc:app:listen
host ◄── lsc:visitor:pass-user-context id, displayName, avatarUrl
host ◄── lsc:product:emoji-state-update productReferences[]
host ◄── lsc:player:deeplink:parameters utm_*, affiliate ids
player ──► lsc:app:message:acknowledge (per message, when messageId was set)if (message.messageType === OutboundMessageType.AppListen) {
send(
createInboundMessage(InboundMessageType.VisitorPassUserContext, {
id: currentUser.id,
displayName: currentUser.name,
avatarUrl: currentUser.avatarUrl,
}),
);
// Returning visitors should see their previous reactions, not an empty state.
send(
createInboundMessage(InboundMessageType.ProductEmojiStateUpdate, {
productReferences: await wishlist.references(),
}),
);
}Without the user context the player treats the visitor as anonymous and prompts
for a display name before they can comment. Pass isCommentsConsentRequired:
false only when you already collected equivalent consent.
2. Viewport — keep the player stable while the keyboard is open
The player is inside an iframe or web view, so it cannot see the host's visual viewport. On mobile that matters most when the on-screen keyboard opens: the visual viewport shrinks while the layout viewport does not, and a player that does not know this renders its comment input underneath the keyboard.
player ──► lsc:player:viewport:request
host ◄── lsc:player:viewport:pass width, height, scrollX, scrollY
… and again on every visualViewport resize or scrollconst passViewport = () => {
const viewport = window.visualViewport;
send(
createInboundMessage(InboundMessageType.PlayerViewportPass, {
viewportWidth: viewport?.width ?? window.innerWidth,
viewportHeight: viewport?.height ?? window.innerHeight,
viewportScrollX: viewport?.offsetLeft ?? window.scrollX,
viewportScrollY: viewport?.offsetTop ?? window.scrollY,
}),
);
};
if (message.messageType === OutboundMessageType.PlayerViewportRequest) {
passViewport();
window.visualViewport?.addEventListener('resize', passViewport);
window.visualViewport?.addEventListener('scroll', passViewport);
}Answer the request, then keep sending on change — the player asks once. These
are fire-and-forget: send them without a messageId, because each update
supersedes the last and an acknowledgement round trip would only add latency.
On iOS and Android, report the web view's own visible bounds after the keyboard inset has been applied, rather than the full screen.
3. Presentation — floating and fullscreen
This flow runs in both directions, which is the part worth getting right. The same message type carries a request and a report, distinguished by its value:
| Value | Direction | Meaning |
| ----------------------- | ------------- | ------------------------------------- |
| requestFullscreenMode | host → player | Host asks the player to go fullscreen |
| requestFloatingMode | host → player | Host asks the player to shrink |
| fullscreen | host → player | Host already resized its container |
| floating | host → player | Host already shrank its container |
visitor taps expand
player ──► lsc:cta:click (or your own UI triggers it)
host ◄── lsc:player:ui-transition playerUiState: 'requestFullscreenMode'
host animates its container
host ◄── lsc:player:ui-transition playerUiState: 'fullscreen'await expandPlayerContainer();
send(
createInboundMessage(InboundMessageType.PlayerUiTransition, {
playerUiState: 'fullscreen',
}),
);The host owns the container, so the host owns the geometry. Send the request*
value when you want the player to drive its own internal layout change, and the
plain value once your own resize has finished, so the player's UI matches the
box it is actually in. Follow a transition with a fresh viewport message.
For native picture-in-picture use lsc:player:native-pip instead: only the host
can own a system-level PiP window, so the player asks and the host decides.
4. Product hydration — fresh price and availability
A show is authored ahead of time. By the time a visitor watches a replay, a product may have gone on sale or sold out. Hydration closes that gap from the host's own catalogue.
player ──► lsc:products:request-hydration productReferences[]
host ◄── lsc:products:hydrate products[] with price + hasStockif (message.messageType === OutboundMessageType.ProductsRequestHydration) {
const products = await catalogue.lookup(message.productReferences);
send(
createInboundMessage(InboundMessageType.ProductsHydrate, {
products: products.map((product) => ({
reference: product.sku,
hasStock: product.stock > 0,
price: {
currencyCode: product.currency,
currencySymbol: product.currencySymbol,
price: product.listPrice,
salePrice: product.salePrice,
},
variants: product.variants.map((variant) => ({
reference: variant.sku,
hasStock: variant.stock > 0,
})),
})),
}),
);
}Two rules make partial answers safe:
- Anything you omit keeps its authored value. A message carrying only
hasStockwill never blank out a title, image or variant. - Sending
pricereplaces bothpriceandsalePrice. That is how a product coming off sale is expressed: send the price with nosalePrice.
References the player does not recognise are ignored, and so are products you do not answer for — it is safe to reply with a partial or a wider set.
5. Add to cart — the player never owns the cart
The most common integration mistake is treating the outbound message as the completed action. It is a statement of intent; the cart is yours.
visitor taps add
player ──► lsc:product:add-to-cart productReference, variantReference
host adds to its own cart
host ◄── lsc:product:add-to-cart productReference, variantReference, quantity
player updates its UIif (message.messageType === OutboundMessageType.ProductAddToCart) {
await cart.add(message.productReference, message.variantReference);
send(
createInboundMessage(InboundMessageType.ProductAddToCart, {
productReference: message.productReference!,
variantReference: message.variantReference,
quantity: 1,
}),
);
}If the add fails, send nothing. The player's UI then stays in its pre-add state, which is the honest outcome.
Web
import {
InboundMessageType,
OutboundMessageType,
createInboundMessage,
parsePlayerMessage,
postMessageToPlayer,
} from '@hellolisa/sdk-messages';
const iframe = document.querySelector('iframe')!;
const playerOrigin = 'https://player.hello-lisa.com';
window.addEventListener('message', (event) => {
if (event.origin !== playerOrigin) return;
const message = parsePlayerMessage(event.data);
if (message === null) return;
switch (message.messageType) {
case OutboundMessageType.AppListen:
postMessageToPlayer(
iframe.contentWindow!,
createInboundMessage(InboundMessageType.VisitorPassUserContext, {
id: currentUser.id,
displayName: currentUser.name,
}),
playerOrigin,
);
break;
case OutboundMessageType.ProductAddToCart:
// `message` is narrowed: productReference and variantReference are typed.
void addToCart(message.productReference, message.variantReference);
break;
case OutboundMessageType.PlayerDismiss:
iframe.remove();
break;
}
});parsePlayerMessage returns null for anything that is not a player message,
so it is safe to attach to a window that carries other traffic. Always check
event.origin yourself — the parser validates shape, not provenance.
React Native
The player reaches React Native through window.ReactNativeWebView.postMessage,
which carries strings. parsePlayerMessage accepts those directly.
import { WebView } from 'react-native-webview';
import {
InboundMessageType,
OutboundMessageType,
createInboundMessage,
createInjectedPostMessageScript,
parsePlayerMessage,
} from '@hellolisa/sdk-messages';
const webViewRef = useRef<WebView>(null);
const send = (message: ReturnType<typeof createInboundMessage>) =>
webViewRef.current?.injectJavaScript(createInjectedPostMessageScript(message));
<WebView
ref={webViewRef}
source={{ uri: playerUrl }}
onMessage={(event) => {
const message = parsePlayerMessage(event.nativeEvent.data);
if (message === null) return;
if (message.messageType === OutboundMessageType.AppListen) {
send(
createInboundMessage(InboundMessageType.VisitorPassUserContext, {
id: currentUser.id,
displayName: currentUser.name,
}),
);
}
}}
/>;iOS
The player posts to the MessageFromLiSA script message handler.
let configuration = WKWebViewConfiguration()
configuration.userContentController.add(self, name: LiSAPlayerBridge.scriptMessageHandlerName)
func userContentController(
_ controller: WKUserContentController,
didReceive scriptMessage: WKScriptMessage
) {
guard let message = LiSAPlayerBridge.decode(scriptMessage.body) else { return }
switch message.messageType {
case .appListen:
let context = LiSAInboundMessage.visitorPassUserContext(
id: currentUser.id,
displayName: currentUser.name
)
webView.evaluateJavaScript(LiSAPlayerBridge.script(for: context) ?? "")
case .productAddToCart:
guard let reference = message.product?.productReference else { return }
cart.add(reference, variant: message.product?.variantReference)
case .playerDismiss:
dismiss(animated: true)
default:
break
}
}Android
The player posts to the MessageFromLiSA JavaScript interface.
webView.addJavascriptInterface(object {
@JavascriptInterface
fun postMessage(payload: String) {
val message = LiSAPlayerBridge.decode(payload) ?: return
runOnUiThread { handle(message) }
}
}, LiSAPlayerBridge.JAVASCRIPT_INTERFACE_NAME)
fun handle(message: LiSAOutboundMessage) = when (message.messageType) {
LiSAOutboundMessageType.APP_LISTEN -> webView.evaluateJavascript(
LiSAPlayerBridge.script(
LiSAInboundMessage.VisitorPassUserContext(
id = currentUser.id,
displayName = currentUser.name,
),
),
null,
)
LiSAOutboundMessageType.PRODUCT_ADD_TO_CART ->
message.product?.productReference?.let(cart::add)
LiSAOutboundMessageType.PLAYER_DISMISS -> finish()
else -> Unit
}@JavascriptInterface methods run on a background thread; hop to the main
thread before touching UI, as above.
Shape of the native bindings
TypeScript models each message as its own interface in a discriminated union,
so narrowing on messageType gives you exactly the fields that message carries.
Swift and Kotlin instead expose one message struct with a messageType and a
set of optional payloads grouped by family — product, sticker, comments,
media, interaction. Forty-odd generated types per platform would be worse to
read and worse to evolve, and a host app switches on the type anyway. Each
binding also keeps the undecoded message (raw), so a field this package does
not model yet is still reachable without waiting for a release.
Both native decoders reject a messageType they do not know, so a newer player
never crashes an older host.
Forward compatibility
Treat unknown message types as no-ops rather than errors. LiSA adds message
types without a major version bump; the default / else branch in every
example above is the intended handling.
Keeping the platforms in sync
spec/fixtures.json holds one canonical wire example
per inbound message type and a representative sample of outbound ones. It is the
shared corpus all three bindings are held to: each one decodes every outbound
fixture, rejects traffic that is not a player message, and must serialise the
inbound messages byte for byte as the fixtures have them.
When you add a message, add its fixture first — that is what keeps four platforms from drifting.
# TypeScript: types, build, ES2022 target, fixture suite
pnpm --filter @hellolisa/sdk-messages check
# Swift and Kotlin against the same fixtures
pnpm --filter @hellolisa/sdk-messages verify:platformsverify:platforms needs swiftc (Xcode command line tools) and kotlinc
(brew install kotlin). A missing toolchain is reported and skipped rather than
passed over silently, so the summary always says what was actually checked.
Legacy (Player V1) properties
Outbound messages still carry action, target and additional for V1
integrations. They are typed as LegacyMessageProperties and marked
@deprecated. New integrations should ignore them and read messageType and
the typed properties instead.
