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?.driver — undefined 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.
Related
- Errors reference — every error code with trigger + fix.
- Subscriptions — full lifecycle reference.
- Testing — assert these patterns hold.