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

@condorinst/mobile-bridge

v0.8.4

Published

TypeScript SDK for the JS↔Native bridge of the Condor white-label mobile app.

Readme

@condorinst/mobile-bridge

Canonical TypeScript SDK for the JS↔Native bridge of the Condor white-label mobile app.

Purpose: give the JavaScript code that runs inside the white-label app's WebView a typed, runtime-validated, testable API to talk to the native Flutter side — versioned handshake, observation of keys emitted by the background, commands to the foreground service, and hooks into native screens (Settings).

New here? Start with the Client Guide"Build your White Label Home" — and the runnable examples/minimal-home.

Installation

npm install @condorinst/mobile-bridge

The published package ships a prebuilt dist/ (ESM + CJS + type declarations) — no build toolchain is required on your side.

Versioning during the pilot. The package is on 0.x (beta). Pin "@condorinst/mobile-bridge": "^0.8.0". Under semver, while on 0.x a caret range auto-updates patches only (0.8.x); moving to 0.9.0 is an intentional opt-in. 1.0.0 is reserved for production.

Quickstart

import { Bridge, BRIDGE_KEYS } from '@condorinst/mobile-bridge';

// Versioned handshake with the native side (can be called at any time;
// recommended on the client's DOMContentLoaded)
const ack = await Bridge.handshake({ clientVersion: '1.0.0' });
console.log('Native bridge:', ack.nativeVersion, 'accepted:', ack.accepted);

// Subscribe to an observable key coming from the background
const unsubscribe = Bridge.on(BRIDGE_KEYS.deviceConnection, (payload) => {
  console.log('Device:', payload);
});

// Command: enable real-time
Bridge.send('realTime', { enabled: true });

// Native hook: open the app's native Settings screen
Bridge.openSettings();

// Cleanup
unsubscribe();

The recommended model for white-label clients is BridgeClient (sandbox iframe), not the legacy Bridge. See below.

Sandbox iframe — BridgeClient + BridgeShell

The recommended model isolates the external client code inside an <iframe sandbox="allow-scripts">, communicating over window.postMessage with schema-validated messages and a default-deny policy.

For the external client — BridgeClient

API identical to the legacy Bridge, but over a postMessage transport. The client imports it and never notices it is sandboxed:

import { BridgeClient, BRIDGE_KEYS } from '@condorinst/mobile-bridge';

const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' });
console.log('Shell:', ack.shellVersion, 'accepted:', ack.accepted);

const unsubscribe = BridgeClient.on(BRIDGE_KEYS.userDevice, (payload) => {
  console.log('Devices:', payload);
});

BridgeClient.send('realTime', { serialNo: 1234, value: true });
BridgeClient.openSettings();

Differences vs Bridge:

  • Does not expose mockEmit, installDevPanel, isMock, getMockState — dev/mock APIs live only on the shell.
  • handshake resolves with { shellVersion, accepted } (not nativeVersion).
  • Commands not allowed by the ShellPolicy are silently dropped (with a console.warn). The external client has no visibility into which commands do or do not exist.

For the Condor-controlled shell — BridgeShell

import { BridgeShell, BRIDGE_KEYS, type ShellPolicy } from '@condorinst/mobile-bridge';

const iframe = document.getElementById('client-frame') as HTMLIFrameElement;

const policy: ShellPolicy = {
  readOnlyKeys: [
    BRIDGE_KEYS.userDevice,
    BRIDGE_KEYS.deviceConnection,
    BRIDGE_KEYS.deviceLightSensors,
    BRIDGE_KEYS.realTime,
  ],
  allowedCommands: ['userDevice', 'realTime', 'openSettings'],
  requiresConfirmation: ['syncTime', 'deleteMemory'],
  deniedCommands: ['dfu'],
};

const shell = new BridgeShell({
  iframe,
  policy,
  onConfirmationRequest: async ({ command, args }) => {
    return await showConfirmationModal(command, args); // shell UI
  },
});

// Layer 1 — bootstrap with an override prefix.
//
// Use bootstrapClientIframe(clientPath) when HTML comes over HTTP:
await shell.bootstrapClientIframe('https://api.condor.example/whitelabel/bundle.html');
//
// Use bootstrapClientIframeWithHtml(htmlString) when the HTML is already in
// memory — required for an asset bundled at file:// (Android WebView cannot
// fetch over file://), a build-time inlined bundle, etc:
declare const __CLIENT_HTML__: string; // e.g. via esbuild.define
await shell.bootstrapClientIframeWithHtml(__CLIENT_HTML__);

// Layer 2 — iframe attestation.
const verify = await shell.verifySandbox();
if (!verify.ok) {
  // Abort the boot — Layer 1 failed.
  throw new Error('Sandbox compromised: bridges still reachable from iframe.');
}

// From here on, BridgeShell routes postMessage ↔ native Bridge
// automatically. Nothing else to do at boot.

Layered defense (propagation of window.flutter_inappwebview)

| Layer | Where | What it does | |---|---|---| | 1 | BridgeShell.bootstrapClientIframe | srcdoc with a prefix that deletes window.flutter_inappwebview + window.CondorBridge before any client script runs. | | 2 | BridgeShell.verifySandbox + BridgeClient handler | Round-trip postMessage to attest the iframe state. | | 3 | (Native side) | Shell nonce validated natively in the Flutter bridge. | | 4 | (Fallback if 1+2 fail) | Dual InAppWebView — shell and client in separate WebViews. |

Build CLI — condor-mobile-bridge build

Generates the self-contained .zip bundle that the white-label app consumes. The app injects the client HTML via iframe.srcdoc (origin null, no base URL) → relative references do not resolve and the CSP blocks external fetches; therefore the .zip must contain a single index.html with inline JS/CSS and media as data: URIs.

The CLI does this from your build's dist/: it reads index.html, inlines <script src> (escaping </script>) and <link rel=stylesheet>, resolves CSS url() and local <img>/icons to data: URIs, and packs a .zip with index.html at the root. External refs (https://…) are preserved (and blocked by the app's CSP at runtime).

npx condor-mobile-bridge build -i dist/index.html -o dist/bundle.zip

| Option | Description | |---|---| | -i, --html <path> | Input HTML (e.g. dist/index.html). Required. | | -o, --out <path> | Output .zip (default: dist/whitelabel-bundle.zip). | | --max-bytes <n> | Warning threshold in bytes (default: 2097152 = 2 MB). |

Output (for registering the bundle with the backend — bundleSize/bundleHash):

Built dist/bundle.zip
  entry: index.html (123.4 KB self-contained)
  bundleSize (zip): 45678 bytes
  bundleHash (zip): sha256-<hex>

The hash is deterministic (fixed mtime on the zip entry) — the same input produces the same bundleHash. Above the threshold, the CLI warns (does not block): remember that the app rejects a .zip larger than 2 MB and falls back to the mock.

⚠️ Constraint: single-chunk build. The inliner is post-build and assumes a dist with one JS + one CSS + assets. Builds with code-splitting / ES modules that import across chunks break in srcdoc (the imports do not resolve). Configure your build to emit a single file:

  • Vite: vite-plugin-singlefile (or build.rollupOptions.output.inlineDynamicImports: true + manualChunks: undefined).
  • CRA: build without code-splitting (no React.lazy/dynamic import() in routes), single bundle.

Inlining inflates data: media by ~33% (base64) — size against the 2 MB limit.

Key catalog (strictly typed)

Mirrors the JS-facing side of the native foreground com keys. The strings here are what the JS receives via window.CondorBridge.emit(key, payload).

| BRIDGE_KEYS.* | Wire (JS) | Typed payload | Typical direction | |---|---|---|---| | pingPong | 'pingPong' | PingPongPayload = { data: 'ping' \| 'pong' } | bidirectional | | userDevice | 'userDevice' | UserDevicePayload = { lastUpdate, devicesConfig[], devicesTelemetry[], devicesUserConfig[] } | BG → JS | | deviceLightSensors | 'deviceLightSensors' | DeviceLightSensorsPayload = { serialNo, type, data } | BG → JS | | syncTime | 'syncTime' | SyncTimeAckPayload = { serialNo, success } | JS → BG (cmd) / BG → JS (ack) | | realTime | 'realTime' | RealTimeAckPayload = { serialNo, success, value } | JS → BG (cmd) / BG → JS (state) | | deleteMemory | 'deleteMemory' | DeleteMemoryAckPayload = { serialNo, success } | JS → BG (cmd) / BG → JS (ack) | | deviceNotification | 'deviceNotification' | DeviceNotificationPayload = { serialNo, isConnect, message, messageCode, isError } | BG → JS | | deviceConnection | 'deviceConnection' | DeviceConnectionPayload = { serialNo, isConnect } | BG → JS (status); JS → BG ({type, serialNo}, permissive) | | sensorLog | 'sensorLog' | SensorLogPayload | BG → JS | | dfu | 'dfu' | Record<string, unknown> (fallback) | BG → JS |

Automatic inference on Bridge.on:

Bridge.on(BRIDGE_KEYS.realTime, (payload) => {
  // payload: RealTimeAckPayload — type-safe!
  console.log(payload.serialNo, payload.success, payload.value);
});

Bridge.send commands remain permissive (heterogeneous shapes per type). The client passes Record<string, unknown>.

Versioning policy (semver)

  • PATCH (0.8.x): bugfix with no API change.
  • MINOR (0.x.0): new observable key, new command, backward-compatible API.

    While on 0.x, a minor bump may carry breaking changes (semver treats pre-1.0 minors as breaking). Pin ^0.8.0 to stay on safe patches.

  • MAJOR (x.0.0): incompatible schema change (key rename, payload shape change, public API change). 1.0.0 marks the production-stable contract.

The native side declares nativeVersion in the handshake; the client declares clientVersion. This release does not enforce hard semver validation — the native side accepts any clientVersion. Real validation will land once there are 2+ versions in the field.

Host detection

The package detects automatically:

  • Android / iOSwindow.flutter_inappwebview (via the flutter_inappwebview package on the Flutter side).
  • Browser outside the app (dev/test): mock mode activates automatically.

Mock mode

When the package runs outside the Flutter WebView (regular browser, Vite dev server, Storybook), the adapter activates mock mode automatically. You can develop in the browser without building the APK.

import { Bridge, BRIDGE_KEYS } from '@condorinst/mobile-bridge';

if (Bridge.isMock) {
  // Subscribe to a listener as usual
  Bridge.on(BRIDGE_KEYS.deviceConnection, (payload) => {
    console.log('Device:', payload);
  });

  // Simulate the native side sending an event
  Bridge.mockEmit(BRIDGE_KEYS.deviceConnection, {
    serialNo: 1234,
    connected: true,
  });
}

Handshake in mock is EXPLICIT

// In mock, handshake does NOT resolve by itself — the client/test responds.
const ackPromise = Bridge.handshake({ clientVersion: '1.0.0' });

if (Bridge.isMock) {
  Bridge.mockEmit('handshake-ack', {
    nativeVersion: '0.1.0',
    accepted: true,
  });
}

const ack = await ackPromise;

Design decision: it forces tests to be intentional about the native state. In real mode (inside the Flutter WebView), the ack arrives naturally from native.

Bridge.send in mock

Messages sent via Bridge.send (or helpers like openSettings) are recorded in an internal FIFO buffer (cap 100) + console.debug. Inspect via Bridge.getMockState():

Bridge.send('realTime', { enabled: true });

const state = Bridge.getMockState();
console.log(state.messages);
// → [{ handlerName: 'CondorBridge', payload: {...}, timestamp: ... }]
console.log(state.subscriberKeys);
// → ['deviceConnection']

Dev panel

An opt-in HTML panel to inspect the bridge in the browser during development.

import { Bridge } from '@condorinst/mobile-bridge';

if (Bridge.isMock) {
  Bridge.installDevPanel({
    position: 'bottom-right',   // 'bottom-right' | 'bottom-left' | 'top-*'
    maxMessages: 20,            // last N shown
    theme: 'dark',              // 'dark' | 'light'
    startCollapsed: false,
  });
}

Contents:

  • Header with a "MOCK" badge, sent counter, minimize button.
  • Sent log — last N messages (timestamp, type, truncated payload).
  • Trigger emit — dropdown of BRIDGE_KEYS + JSON textarea + "Emit" button (fires Bridge.mockEmit) + "Clear sent" button.

Idempotent — calling installDevPanel again replaces the previous panel. Throws if called in real mode (!Bridge.isMock).

Auto-respond toggle

To close the full loop in the browser (client clicks a button → native responds → subscriber fires), pass a policy that maps send to emit. The package provides the mechanism (checkbox UI + hook); you provide the policy (app-specific mappings).

import { Bridge, BRIDGE_KEYS } from '@condorinst/mobile-bridge';

if (Bridge.isMock) {
  Bridge.installDevPanel({
    startAutoRespondEnabled: true, // default false
    autoRespondDelayMs: 50,        // default 50 (simulates IPC)
    onSendForAutoRespond(msg) {
      const payload = msg.payload as { type?: string };
      switch (payload?.type) {
        case 'handshake':
          return {
            key: 'handshake-ack',
            payload: { nativeVersion: '0.1.0 (mock)', accepted: true },
          };
        case 'ping':
          return {
            key: BRIDGE_KEYS.pingPong,
            payload: { data: 'pong' },
          };
        default:
          return null; // do not auto-respond to this send
      }
    },
  });
}

Behavior:

  • The toggle appears in the panel only when onSendForAutoRespond is passed — without a policy, the panel keeps the manual-emit UX.
  • The toggle state persists in memory for the panel's lifetime. Re-install resets to startAutoRespondEnabled.
  • Errors thrown by the policy or the internal triggerEmit are caught and logged (console.error) — they do not destabilize the bridge.
  • Use BRIDGE_KEYS to avoid string-magic in the mappings.

Local development (SDK contributors)

npm install          # installs deps + runs prepare (build)
npm run typecheck    # tsc --noEmit
npm run lint         # biome check
npm run format       # biome format --write
npm run test         # vitest run
npm run build        # tsup (ESM + CJS + types in dist/)

Further reading

License

Proprietary — Condor Instruments. See LICENSE.