npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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:

  1. Wait for lsc:app:listen. The player emits it once it is ready. Anything sent earlier may be dropped.
  2. Address inbound messages with recipient: "LiSA". The player ignores everything else, which is what keeps unrelated postMessage traffic on the host page from being misread as player input.
  3. The player does not own the cart. It reports intent (lsc:product:add-to-cart outbound); the host performs the add and confirms it by sending lsc:product:add-to-cart back.

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 scroll
const 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 + hasStock
if (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 hasStock will never blank out a title, image or variant.
  • Sending price replaces both price and salePrice. That is how a product coming off sale is expressed: send the price with no salePrice.

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 UI
if (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:platforms

verify: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.