The bridge
What `MiniAppClient` actually wraps, why your code never touches it directly, and how to fake it in tests.
When the i99dash host loads your bundle, it injects a JavaScript
object on window called the bridge. Every conversation between
your code and the host crosses this one object — context reads and
car-status subscriptions. (External HTTPS APIs are not one of those
conversations — you reach those with plain fetch(); see
Calling an external API.)
You almost never touch the bridge directly. MiniAppClient wraps
it with types, timeouts, schema validation, and error wrapping.
The mental model
┌────────────────┐ ┌─────────────────────┐
│ your code │ │ host process │
│ │ │ (Flutter app) │
│ MiniAppClient ─┼──── JS bridge ────▶│ getContext() │
│ │ window.__i99dash │ subscribeCarStatus()│
│ │ (a JS object) │ getCarStatus() │
└────────────────┘ └─────────────────────┘The bridge is not a network call. It's an in-process JavaScript object the host populated before your bundle ran. Calls cross the WebView ↔ native boundary inside the host (Flutter platform channels), but from your code's perspective they're plain async function calls.
Why a wrapper?
You could in theory call window.__i99dashHost.getContext() directly.
You shouldn't, because MiniAppClient adds:
| What the wrapper adds | Why it matters |
|---|---|
| TypeScript types on every call | Auto-complete + compile-time guarantees |
| Schema validation (zod) | Host bumps that drop a field → loud InvalidResponseError instead of silent undefined |
| 10-second default timeout per call | Host hangs don't propagate to your UI as forever-spinners |
AbortSignal integration | Cancel an in-flight call when a user navigates away |
Wrapped errors with code + cause | Switch on a stable code; preserve the underlying reason for Sentry |
| Lazy car-status surface | No bridge subscriptions opened until you actually call client.car.* |
Reaching past it (e.g., (window as any).__i99dashHost) bypasses all
of those. Don't.
Two ways to construct a client
import { MiniAppClient } from 'i99dash';
// Production: read the bridge from window. Throws NotInsideHostError
// if there's no bridge (SSR, Storybook, jsdom).
const client = MiniAppClient.fromWindow();// Tests / dev / SSR: pass a fake bridge. Any object that satisfies
// the Bridge interface works.
const client = MiniAppClient.withBridge({
getContext: async () => ({
userId: 'test-user',
activeCarId: 'TEST-DEVICE-ID',
locale: 'en',
isDark: false,
appVersion: '1.0.0',
appId: 'my-app',
}),
});The Bridge interface is the contract. As long as your fake matches,
the rest of the SDK runs unchanged. See Testing
for the full pattern.
Surfaces the bridge exposes
| Bridge method | What for | SDK wrapper |
|---|---|---|
getContext() | Read user / device ID / locale / theme | client.getContext() |
getCarStatus() | One-shot car snapshot (host ≥ 0.0.2) | client.car.getStatus() |
subscribeCarStatus(cb) / unsubscribeCarStatus(id) | Streaming updates | client.car.onStatusChange(cb) |
subscribeCarConnectionState(cb) / unsubscribeCarConnectionState(id) | Stream connection up/down | client.car.onConnectionChange(cb) |
If your runtime has a Bridge but not a CarStatusBridge (older host,
test stub), the streaming methods throw CarStatusUnavailableError —
catch it and render a fallback.
What the bridge doesn't expose
- No network proxy. The bridge does not forward HTTP for you. To
reach an external API you declare its origin in
manifest.networkand call it with plainfetch()— the host enforces the allow-list as a Content-Security-Policy, outside the bridge entirely. See Calling an external API. - No actuators on
MiniAppClient. NolockDoors(), nosetAcOn(), no driving controls. The runtime client is read-only by construction. Privileged ops live behind the admin client exported fromi99dashand use reviewed templates. - No DOM access between mini-apps. Each mini-app is a separate
sandboxed WebView. There's no shared
localStorage, nopostMessagebetween them. - No persistent storage. Treat each launch as the first one.
localStorageworks for ephemeral UI state, but the host can clear it at any time.
How the dev-server fits in
The dev-server (run via pnpm dev) injects a bridge shim on
window before serving your page. The shim implements the same
contract as the real host bridge but routes:
getContext()→ values fromsdk.config.json'sdev.contextblock (overridable live from the control panel athttp://127.0.0.1:5173/_sdk/ui).- Car-status methods → an in-process simulator the control panel drives.
External fetch() calls are not routed by the dev-server — they go
straight to the real origin (the on-car CSP is not enforced in dev), so
point your code at a reachable endpoint while developing.
Your code is identical in dev and prod — MiniAppClient.fromWindow()
finds either bridge transparently. There is no if (dev) branch.
Related
MiniAppClient— full type signature.Bridge— the interface, for fake bridges.- Testing — how to write
withBridgetests. - Local development — fixture grammar + control panel.