Real-time car status widget
End-to-end recipe — scaffold, wire the streaming car-status bridge, render door / battery / lock state, ship it. ~30 minutes.
End-to-end: scaffold a mini-app, wire it to the host's car-status stream, 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.getStatus, onStatusChange, onConnectionChange,
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/sdk-cli init car-status-widget
cd car-status-widget
pnpm installThe 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 the streaming feature
The car-status stream lives in host versions ≥ 0.0.2. Edit your
generated manifest.json:
{
"id": "car_status_widget",
"name": { "en": "Car Status", "ar": "حالة السيارة" },
"minHostVersion": "0.0.2",
"permissions": ["car.status.read"],
"safeWhileDriving": true
}permissions defaults to ['car.status.read'] if omitted (v1
backward compat) but make it explicit so future you can grep for
the contract. 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,
SDKError,
type CarStatus,
type CarConnectionState,
} from '@i99dash/sdk';
const client = MiniAppClient.fromWindow();
const root = document.querySelector('#root')!;
let status: CarStatus | null = null;
let conn: CarConnectionState = 'disconnected';
function render() {
if (conn === 'disconnected') {
root.innerHTML = `<p class="banner">No car signal</p>`;
return;
}
if (status === null) {
root.innerHTML = `<p>Loading…</p>`;
return;
}
const vinTail = `****${status.vin.slice(-4)}`;
const lock = status.doorsLocked ? '🔒' : '🔓';
const battery = status.batteryPct ?? '—';
const driver = status.doors?.driver ?? 'unknown';
const stale = status.staleness === 'fresh' ? '' : ` (${status.staleness})`;
root.innerHTML = `
<div class="widget">
<div class="row">Car: ${vinTail} ${lock}</div>
<div class="row">Battery: ${battery}%</div>
<div class="row">Doors: 🚪 driver ${driver}</div>
<div class="row last-seen">Last seen: ${ageLabel(status.at)}${stale}</div>
</div>
`;
}
function ageLabel(iso: string): string {
const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 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 {
status = await client.car.getStatus();
} catch (e) {
if (e instanceof SDKError && e.code === 'CAR_STATUS_UNAVAILABLE') {
// Older host or test bridge — render a fallback.
root.innerHTML = `<p>This car doesn't support live status.</p>`;
return;
}
throw e;
}
render();
// Subscribe to deltas — store the unsub fn for cleanup.
const offStatus = client.car.onStatusChange((next) => {
status = next;
render();
});
const offConn = client.car.onConnectionChange((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', () => {
offStatus();
offConn();
});
}
bootstrap();A quick read-through of what's here:
MiniAppClient.fromWindow()— finds the host bridge. In dev, the bridge comes fromsdk-i99dash dev.client.car.getStatus()— one-shot read for the first paint. Avoid showing a loading spinner forever waiting for the first delta event.SDKErrorswitch oncode— catches the "older host doesn't support streaming" path. Renders a fallback instead of crashing.onStatusChange/onConnectionChange— return an unsubscribe fn each. Both are stored sobeforeunloadcan clean them up.status.staleness— gates the "live or stale" label separately from the connection-state banner. See Subscriptions — Connection state vs. staleness.- VIN masking — only the last 4 digits render. See Best practices — Privacy.
4. Run it locally
pnpm devThe CLI launches sdk-dev-server 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 CarStatus 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 onStatusChange 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, type CarStatusBridge } from '@i99dash/sdk';
describe('car-status widget', () => {
it('cleans up subscriptions on beforeunload', async () => {
const subscribed = vi.fn(async (notify) => {
void notify;
return { id: 'sub-1' };
});
const unsubscribed = vi.fn(async () => {});
const bridge: CarStatusBridge = {
getContext: async () => ({
userId: 'u', activeCarId: 'V', locale: 'en',
isDark: false, appVersion: '1.0.0', appId: 'car_status_widget',
}),
callApi: async () => ({ success: true, data: null }),
getCarStatus: async () => ({
vin: 'V', at: '2026-04-27T12:00:00Z', staleness: 'fresh',
}),
subscribeCarStatus: subscribed,
unsubscribeCarStatus: unsubscribed,
subscribeCarConnectionState: async () => ({ id: 'conn-1' }),
unsubscribeCarConnectionState: async () => {},
};
const client = MiniAppClient.withBridge(bridge);
const off = client.car.onStatusChange(() => {});
await Promise.resolve(); // drain the lazy subscribe microtask
expect(subscribed).toHaveBeenCalledOnce();
off();
await Promise.resolve();
expect(unsubscribed).toHaveBeenCalledOnce();
});
});pnpm testIf unsubscribed is never called, your code has a leak — the same
class of bug that throws CarStatusQuotaExceededError in
production. See Subscriptions.
6. Validate the manifest + build
pnpm sdk-i99dash validate # zod-checks manifest.json
pnpm sdk-i99dash build # produces dist/bundle.tar.gzThe 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 sdk-i99dash login # device-code flow if not already logged in
pnpm sdk-i99dash publishThe catalog picks up the new version on next refresh; users with host ≥ 0.0.2 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.warnin dev. - Reconnect logic.
onConnectionChangereflects the host's polling-loop health; banner updates automatically when the signal comes back.
Troubleshooting
| Symptom | Likely cause + fix |
|---|---|
| "This car doesn't support live status." | You're on an older host. Bump minHostVersion in the manifest so the catalog hides your app from incompatible cars. |
| Widget never updates | Check pnpm dev's console for console.warn from the SDK — the host may be pushing a malformed payload. Otherwise, check that onStatusChange is wired before bootstrap returns. |
CarStatusQuotaExceededError in dev | You're calling onStatusChange on every render (likely React strict mode). Wrap in useEffect and return the off fn. See Subscriptions. |
NotInsideHostError in browser | Opened the bundle directly via file://. Run through pnpm dev so the dev-server attaches the bridge. |
Related
getStatusreference — full API reference.- Subscriptions — full lifecycle deep-dive.
- Testing — more test patterns.
- Troubleshooting — known SDK gotchas.