i99dash docs
Recipes

Real-time car status widget

End-to-end recipe — scaffold, wire `client.car.read` + `client.car.subscribe`, render door / battery / lock state with live updates, ship it. ~30 minutes.

End-to-end: scaffold a mini-app, wire it to the unified client.car controller, render door / lock / battery state with live updates, ship it. ~30 minutes if you've never used the SDK.

The finished widget:

┌─────────────────────────────┐
│  Car: ****4567        🔒    │
│  Battery: 88%               │
│  Doors: 🚪 driver open      │
│  Last seen: 3 s ago         │
└─────────────────────────────┘

The widget shows live state and degrades cleanly when the host has no signal. It uses every relevant SDK feature: getContext, client.car.read for the first paint, client.car.subscribe for deltas, client.car.connectionSubscribe for the banner, client.car.identity for the masked car ID, proper cleanup, and error handling for the offline / no-host case.

Prerequisites

  • Node 20+
  • pnpm (or npm; the recipe uses pnpm)
  • 10 min of patience for the dev-server's first launch

1. Scaffold

npx i99dash init car-status-widget
cd car-status-widget
pnpm install

The CLI creates a vanilla TS template (src/main.ts, src/index.html, manifest.json). If you want React / Vue, --framework react or --framework vue. Recipe stays vanilla so the SDK calls are visible.

2. Bump minHostVersion for bridge v2

The unified client.car controller requires host bridge v2 (SDK ≥ 5.0). Edit your generated manifest.json:

{
  "id": "car_status_widget",
  "name": { "en": "Car Status", "ar": "حالة السيارة" },
  "minHostVersion": "5.0.0",
  "safeWhileDriving": true,
  "permissions": ["car.read"]
}

safeWhileDriving: true lets the host show the widget while moving — appropriate for a read-only status display.

3. Wire the SDK

Edit src/main.ts:

import {
  MiniAppClient,
  BridgeTransportError,
  type CarConnectionState,
  type CarSignalEvent,
} from 'i99dash';

const client = MiniAppClient.fromWindow();

const SIGNALS = ['battery_pct', 'door_lock', 'door_lf', 'speed_kmh'] as const;

const root = document.querySelector('#root')!;
let idTail = '----';
let values = new Map<string, unknown>();
let conn: CarConnectionState = 'disconnected';
let lastSeen = 0;

function render() {
  if (conn === 'disconnected') {
    root.innerHTML = `<p class="banner">No car signal</p>`;
    return;
  }
  if (values.size === 0) {
    root.innerHTML = `<p>Loading…</p>`;
    return;
  }
  const lock = values.get('door_lock') === 1 ? '🔒' : '🔓';
  const battery = values.get('battery_pct') ?? '—';
  const driver = values.get('door_lf') === 1 ? 'open' : 'closed';
  root.innerHTML = `
    <div class="widget">
      <div class="row">Car: ****${idTail} ${lock}</div>
      <div class="row">Battery: ${battery}%</div>
      <div class="row">Doors: 🚪 driver ${driver}</div>
      <div class="row last-seen">Last seen: ${ageLabel(lastSeen)}</div>
    </div>
  `;
}

function ageLabel(at: number): string {
  if (!at) return '—';
  const seconds = Math.round((Date.now() - at) / 1000);
  if (seconds < 5) return 'just now';
  if (seconds < 60) return `${seconds} s ago`;
  return `${Math.round(seconds / 60)} min ago`;
}

// One-shot first paint — no waiting for the first delta.
async function bootstrap() {
  try {
    const ident = await client.car.identity();
    idTail = ident.deviceId.slice(-4);

    const snap = await client.car.read([...SIGNALS]);
    for (const r of snap.results) {
      if (r.ok) values.set(r.name, r.value);
    }
    lastSeen = Date.now();
  } catch (e) {
    if (e instanceof BridgeTransportError) {
      // Older host or test bridge that doesn't ship `car.*`.
      root.innerHTML = `<p>This car doesn't support live status.</p>`;
      return;
    }
    throw e;
  }
  render();

  // Subscribe to deltas — store the unsub fns for cleanup.
  const offSignals = await client.car.subscribe({
    names: [...SIGNALS],
    onEvent: (e: CarSignalEvent) => {
      values.set(e.name, e.value);
      lastSeen = Date.now();
      render();
    },
  });

  const offConn = await client.car.connectionSubscribe((next) => {
    conn = next;
    render();
  });

  // Tear down on tab close (the host doesn't kill us automatically
  // when the user navigates away inside our shell).
  window.addEventListener('beforeunload', () => {
    offSignals();
    offConn();
  });
}

bootstrap();

A quick read-through of what's here:

  • MiniAppClient.fromWindow() — finds the host bridge. In dev, the bridge comes from i99dash dev.
  • client.car.identity() — memoised per car. We only use the deviceId tail for the masked label.
  • client.car.read([...names]) — one-shot batched read for the first paint. Avoid showing a loading spinner forever waiting for the first delta event.
  • BridgeTransportError — catches the "older host doesn't ship bridge v2" path. Renders a fallback instead of crashing.
  • client.car.subscribe + client.car.connectionSubscribe — return async unsubscribe fns. Both are stored so beforeunload can clean them up.
  • Device-ID masking — only the last 4 characters render. See Best practices — Privacy.

4. Run it locally

pnpm dev

The CLI launches the dev-server (i99dash dev) and opens your default browser at http://localhost:5173 with the dev-server's bridge shim pre-installed. You'll see a fake "parked, doors locked, 88% battery" status because the dev-server's default fixture says so.

To exercise live updates, open the dev-server's UI at http://localhost:5174/_dev and toggle the door / lock / moving state. Each change drives an onEvent callback in your widget within ~100 ms.

5. Test the cleanup path

Add a Vitest test:

// src/main.test.ts
import { describe, expect, it, vi } from 'vitest';
import { MiniAppClient } from 'i99dash';

describe('car-status widget', () => {
  it('cleans up subscriptions on teardown', async () => {
    const unsubscribed = vi.fn(async () => ({ ok: true }));
    const subscribed = vi.fn(async () => ({ subscriptionId: 'sub-1' }));

    const callHandler = async (name: string) => {
      if (name === 'car.subscribe') return subscribed();
      if (name === 'car.unsubscribe') return unsubscribed();
      if (name === 'car.identity') return { deviceId: 'byd:BYDMCKLE0PARD8801', brand: 'byd' };
      if (name === 'car.read') return { results: [] };
      return null;
    };

    const bridge = {
      getContext: async () => ({
        userId: 'u', activeCarId: 'V', locale: 'en',
        isDark: false, appVersion: '1.0.0', appId: 'car_status_widget',
      }),
      callHandler,
    };

    const client = MiniAppClient.withBridge(bridge as any);
    const off = await client.car.subscribe({
      names: ['battery_pct'],
      onEvent: () => {},
    });
    expect(subscribed).toHaveBeenCalledOnce();
    off();
    await Promise.resolve();
    expect(unsubscribed).toHaveBeenCalledOnce();
  });
});
pnpm test

If unsubscribed is never called, your code has a leak — the host enforces a per-mini-app subscriber cap and you'll eventually start seeing rejected subscribes. See Subscriptions.

6. Validate the manifest + build

pnpm i99dash validate   # zod-checks manifest.json
pnpm i99dash build      # produces dist/bundle.tar.gz

The validate step catches missing minHostVersion, malformed permissions, and (in this case) reminds you that safeWhileDriving: true requires explicit opt-in justification in your PR.

7. Publish

pnpm i99dash login    # SSH-key sign-in if not already logged in
pnpm i99dash publish

The catalog picks up the new version on next refresh; users with host ≥ 5.0 see your widget tile in the store.

What you didn't have to write

  • Throttling. The host token-buckets pushes at ~10/sec; you don't need to debounce.
  • Page Visibility handling. The SDK suppresses callbacks while hidden and fires one catch-up on resume. Your render() doesn't thrash when the user backgrounds the tab.
  • Schema validation. Every event the host pushes is zod-validated by the SDK. A malformed payload silently drops with a console.warn in dev.
  • Reconnect logic. connectionSubscribe reflects the host's polling-loop health; banner updates automatically when the signal comes back.

Troubleshooting

SymptomLikely cause + fix
"This car doesn't support live status."You're on an older host (pre-bridge-v2). Bump minHostVersion to 5.0.0 so the catalog hides your app from incompatible cars.
Widget never updatesCheck pnpm dev's console for console.warn from the SDK — the host may be pushing a malformed payload. Otherwise, check that client.car.subscribe is awaited before bootstrap returns.
Subscribe call rejects with too_many_namesYou're requesting more than CAR_MAX_NAMES (64) signals in one subscription. Split into multiple.
NotInsideHostError in browserOpened the bundle directly via file://. Run through pnpm dev so the dev-server attaches the bridge.

On this page