i99dash docs
Guides

Best practices

The non-obvious rules that prevent production incidents. Read before shipping.

The non-obvious things that prevent production incidents. Each section is "do this, not that, and here's why."

Errors

Switch on code, not class names

import { SDKError } from '@i99dash/sdk';

try {
  await client.getContext();
} catch (e) {
  if (e instanceof SDKError) {
    switch (e.code) {                        // ✓ stable, switch-friendly
      case 'NOT_INSIDE_HOST':  return showFallbackUI();
      case 'BRIDGE_TIMEOUT':   return showSlowHostBanner();
      case 'CAR_STATUS_UNAVAILABLE': return hideCarWidget();
      // …
    }
  }
  throw e;
}

code is part of the public API contract — it's pinned by the SDKErrorCode union and locked by a regression test. Class names work too (instanceof BridgeTimeoutError) but switching on code is friendlier when you have many cases. Use instanceof when you need typed access to a subclass field (BridgeTimeoutError.operation, UnknownTemplateError.templateId).

See Errors reference.

Don't catch + ignore protocol failures

// ❌ Wrong — `success: false` is data, not an exception
try {
  const r = await client.callApi({ path: '/api/v1/foo', method: 'GET' });
  return r.data;
} catch {
  return null;
}

// ✓ Right — branch on the envelope; only catch real exceptions
const r = await client.callApi({ path: '/api/v1/foo', method: 'GET' });
if (!r.success) {
  if (r.error.code === 'disallowed_path') reportConfigBug();
  return null;
}
return r.data;

The SDK only throws for transport failures (bridge crash, timeout, malformed envelope). {success: false} envelopes are first-class results — branching on them is the contract.

cause carries the underlying error

catch (e) {
  if (e instanceof BridgeTransportError) {
    Sentry.captureException(e, { extra: { cause: e.cause } });
  }
}

Every wrapped error sets cause per the ES2022 Error spec. Sentry, console.error in modern Node/browsers, and most logging libraries walk the chain automatically.

Imports

Prefer the main entry; reach for /types only when you mean it

// Standard mini-app code — main entry, runtime + types.
import { MiniAppClient, type CarStatus } from '@i99dash/sdk';

// SSR-only file that never constructs a client — type-only entry.
import type { CarStatus } from '@i99dash/sdk/types';

See Type-only imports for the exact when/why.

Don't reach into /dist or peek at internals

// ❌ Don't
import { _internal } from '@i99dash/sdk/dist/internal.js';

// ❌ Don't
const events = (window as any).__i99dashEvents;
events.dispatch('car.status', payload);

The _-prefixed exports and the window.__i99dash* globals are implementation details. They CAN change between minor versions. Use the documented surface — the SDK exports HOST_EVENTS_GLOBAL as a constant if you really need the name string.

Privacy

Never render activeCarId (VIN) in plain text

VINs are PII in some jurisdictions. The host hands you the real one because some flows need it (deep links, support tickets), but it's not for human eyeballs:

// ❌ Don't
<span>Car: {ctx.activeCarId}</span>

// ✓ Right — mask everything but the last 4
<span>Car: ****{ctx.activeCarId.slice(-4)}</span>

Same rule for any field a future context schema adds for "user identifier" — userId is opaque, but treat it as non-public: don't paste it into URLs, third-party SDKs, or analytics events.

Don't ship secrets in your bundle

Your mini-app bundle is downloaded by every user; everything in it is extractable with unzip and strings. Anything you --define or hard-code is a public string:

// ❌ Don't
const STRIPE_SECRET = 'sk_live_xxx';

// ✓ Right — fetch from your backend after the user authenticates,
// over the host's `callApi` allow-listed path
const session = await client.callApi({ path: '/api/v1/billing/session', method: 'GET' });

The mini-app sandbox does NOT make this easier — it's a per-user public bundle running inside someone else's car.

Schema + version drift

Defensive-read every optional field

The wire schemas mark almost every field as optional because the host sends what it has. A future-proof renderer treats absence as "unknown" and renders accordingly:

// ❌ Wrong — assumes the field is always present
<span>Battery: {status.batteryPct}%</span>

// ✓ Right — branches on absence
<span>Battery: {status.batteryPct ?? '—'}%</span>

Same rule for status.doors?.driverundefined means "I don't know," not "it's closed." Don't collapse the two.

minHostVersion in your manifest is a contract

Bumping it tells the catalog "anyone running an older host should NOT see my app." Bump it the moment you start using a new host feature (e.g., the streaming car-status surface — older hosts return CarStatusUnavailableError). The host UI will hide your app from users it can't satisfy, instead of installing then crashing.

Performance

One subscription per page, not per render

// ❌ Wrong — re-subscribes on every render
function Page({ client }) {
  client.car.onStatusChange(setStatus);
  // …
}

// ✓ Right — useEffect-cleanup keeps it to one
function Page({ client }) {
  useEffect(() => client.car.onStatusChange(setStatus), [client]);
  // …
}

The 11th concurrent sub from one mini-app throws CarStatusQuotaExceededError — that's the SDK telling you your code has a leak. See Subscriptions.

Don't getContext on every render — cache it

Context is stable for the life of the app launch. Read once on mount, store in a ref/state/atom:

// ✓ Right
const [ctx] = useState(() => null);
useEffect(() => {
  client.getContext().then(setCtx);
}, [client]);

Each getContext() is a real bridge round-trip. Cheap, but not free, and it's pure waste if the value didn't change.

Subscriptions and cleanup

Always store the unsubscribe fn

Repeating from Subscriptions because it's the single most common bug:

const off = client.car.onStatusChange(handler);
// later: off();

Throwing the return value away is a subscription leak. Strict-mode React, Vue HMR, Svelte page transitions all reveal it within seconds.

Tear down on tab visibility, not just unmount

The SDK already pauses your onStatusChange callbacks while the page is hidden — but if your app holds heavy in-memory state for rendering (canvas, three.js, animation loops), tear THAT down on visibilitychange to cooperate. The car-status path is handled; your render budget isn't.

Don't reach for the host

No actuators, ever

There is no client.car.lockDoors() or client.car.setAcOn(true). The SDK is read-only by construction. If you want to add an actuator, that's a separate scope, separate review, separate doc page (none today). Don't try to inject it through __i99dashHost directly.

_admin.exec is for the privileged few

If your app isn't on the admin allow-list, _admin.exec calls fail with unknown_template. That's not a bug — it's the gating chain working. Don't try to "make it work locally" by stubbing the dispatcher; use the regular SDK surface for non-admin features.

On this page