Skip to main content

netron-browser

@omnitron-dev/netron-browser is the framework-agnostic browser RPC client for Titan services. Dual transport (HTTP + WebSocket), type-safe service proxies, full middleware pipeline, LRU caching, retry + circuit breaker, auth manager with cross-tab sync, and a multi-backend pool when you talk to more than one Netron server.

Works with any frontend. Vanilla JS, Vue, Svelte, Solid, Angular, Lit, React, Web Workers, Electron renderers — anywhere you can import an ES module. Not tied to React. For React-specific hooks / providers / cache integration, see netron-react — an optional layer on top of this package.

The server side lives inside Titan at @omnitron-dev/titan/netron (with all four transports). Pair this client with that server.

Verified against packages/netron-browser/src/.

pnpm add @omnitron-dev/netron-browser

Architecture

Quick start

import { createClient } from '@omnitron-dev/netron-browser';

const client = createClient({
url: 'http://localhost:3000',
transport: 'http',
});

await client.connect();

// 1. Direct invocation
const result = await client.invoke('calculator', 'add', [2, 3]);

// 2. Type-safe proxy
interface Calculator {
add(a: number, b: number): Promise<number>;
}

const calc = client.service<Calculator>('calculator');
const sum = await calc.add(2, 3);

// 3. Same call over WebSocket
const ws = createClient({ url: 'wss://api.example.com', transport: 'websocket' });
await ws.connect();
const live = ws.service<Calculator>('calculator');

Client options

interface NetronClientOptions {
url: string;
transport?: 'http' | 'websocket'; // default 'http'
timeout?: number; // ms
headers?: Record<string, string>;
http?: {
retry?: boolean;
maxRetries?: number;
};
websocket?: {
protocols?: string | string[];
reconnect?: boolean;
reconnectInterval?: number;
maxReconnectAttempts?: number;
};
}

Transports

HTTP

The default. One fetch per call. Works through any reverse proxy, no special routing required. Supports:

  • Request batching — concurrent calls in the same tick coalesce into one HTTP request.
  • Server-side caching headers — the server's Cache-Control is honoured client-side via the LRU cache.
  • Retry on transient failuresnetwork/5xx errors retry with exponential backoff.
  • Idempotency keys — generated for safe retries on mutating calls.
const client = createClient({
url: '/api',
transport: 'http',
http: {
retry: true,
maxRetries: 3,
},
});

WebSocket

For subscriptions, bidirectional streaming, low-latency RPC:

const client = createClient({
url: 'wss://api.example.com',
transport: 'websocket',
websocket: {
reconnect: true,
reconnectInterval: 1_000,
maxReconnectAttempts: 20,
},
});

await client.connect();

Reconnect uses exponential backoff capped at reconnectInterval × maxReconnectAttempts. The client transparently re-subscribes streams on reconnect.

Connection state

import type { ConnectionState } from '@omnitron-dev/netron-browser';

client.getState(); // 'idle' | 'connecting' | 'connected' | 'disconnected' | 'reconnecting'
client.isConnected(); // boolean
client.getMetrics(); // { requests, errors, avgLatency, ... }

Service proxies — typed RPC

interface UserService {
findById(id: string): Promise<User>;
list(filter: UserFilter): Promise<User[]>;
create(input: CreateUser): Promise<User>;
subscribe(filter: UserFilter): AsyncIterable<UserEvent>;
}

const users = client.service<UserService>('users');

await users.findById('u_42'); // Promise<User>
await users.create({ email: '...' }); // Promise<User>

for await (const evt of users.subscribe({ tier: 'pro' })) {
console.log(evt); // streamed over WS
}

The proxy is a Proxy that resolves any property access into an RPC call against the named service. No code generation required — your shared .d.ts is enough.

Service descriptor

const desc = await client.getServiceDescriptor('users');
// desc.methods, desc.subscriptions, desc.types, ...

Useful for building generic UIs that inspect a service at runtime.

Middleware pipeline

Middleware runs around every invocation. Three stages:

Each middleware has a priority — lower runs first within its stage.

Built-in middleware

MiddlewareStagePurpose
AuthMiddlewarepreAttach Authorization: Bearer ... from auth manager
RetryMiddlewareerrorRetry transient failures with exponential backoff
CacheMiddlewarepre + postLRU cache with stale-while-revalidate
LoggingMiddlewarepre + post + errorStructured request/response logging
TracingMiddlewarepre + postOpenTelemetry-style trace context
CircuitBreakerMiddlewareerrorTrip after N failures; half-open after cooldown
client.use(AuthMiddleware({ getToken: () => localStorage.getItem('token') }));
client.use(RetryMiddleware({ maxAttempts: 3, on: ['network', '5xx'] }));
client.use(CacheMiddleware({ ttl: 60_000, maxSize: 500 }));

Custom middleware

import type { NetronMiddleware } from '@omnitron-dev/netron-browser';

const TimingMiddleware: NetronMiddleware = {
stage: 'post',
priority: 100,
handler: async (ctx, next) => {
const start = performance.now();
try {
return await next();
} finally {
console.debug(`${ctx.service}.${ctx.method}`, performance.now() - start, 'ms');
}
},
};

client.use(TimingMiddleware);

Fluent interface — chainable per-call config

Configure middleware behaviour on a single call without adding it globally:

const user = await client
.cache({ ttl: 60_000 })
.retry({ maxAttempts: 5 })
.timeout(3_000)
.service<UserService>('users')
.findById('u_42');

Per-call config wins over global middleware config. Useful when 99% of calls use defaults but one hot path needs longer cache or more retries.

Caching

The LRU cache is bounded; tagged for granular invalidation:

import { LRUCache } from '@omnitron-dev/netron-browser';

const cache = new LRUCache({
maxSize: 1_000,
defaultTTL: 60_000,
staleWhileRevalidate: 10_000, // serve stale up to 10s extra; refresh in bg
});

client.use(CacheMiddleware({ cache }));

// Tag a query for selective invalidation:
await client
.cache({ tags: ['user:u_42', 'tier:pro'] })
.service<UserService>('users')
.findById('u_42');

// Later — invalidate everything tagged with that user:
cache.invalidateByTag('user:u_42');

Stats are exposed via cache.getStats() — hits, misses, evictions, hit ratio.

Retry + circuit breaker

client.use(RetryMiddleware({
maxAttempts: 3,
on: ['network', '5xx', 'timeout'],
backoff: { type: 'exponential', base: 500, max: 8_000, jitter: true },
}));

client.use(CircuitBreakerMiddleware({
threshold: 5, // open after 5 failures
resetTimeout: 30_000, // try half-open after 30s
on: ['5xx', 'timeout'],
}));

The breaker prevents a flapping backend from being hammered — once tripped, calls fail-fast with CircuitOpenError until the reset window. After cooldown, one probe request runs; success closes the breaker.

Auth manager

import { AuthManager } from '@omnitron-dev/netron-browser';

const auth = new AuthManager({
storage: 'localStorage', // 'session' | 'memory'
tokenKey: 'platform:token',
refreshEndpoint: '/auth/refresh',
inactivityTimeout: 30 * 60_000, // 30 min
crossTabSync: true, // BroadcastChannel
});

client.use(AuthMiddleware({ authManager: auth }));

// On sign-in:
await auth.setTokens({ accessToken, refreshToken, sessionId });

// On sign-out:
await auth.clear();

Auth manager features:

  • Token storage — localStorage / sessionStorage / memory.
  • Auto-refresh — on 401, call refresh endpoint, retry the original request transparently.
  • Cross-tab sync — sign-in / sign-out in one tab propagates to all open tabs (BroadcastChannel under the hood).
  • Inactivity timeout — auto-sign-out after N ms of no activity.
  • Token rotation hooksauth.on('rotated', cb) for app- level reactions.

Multi-backend client

When the app talks to multiple Netron servers — e.g., one for identity, one for media, one for analytics — wrap each in a BackendClient and pool them:

import { BackendPool, BackendClient } from '@omnitron-dev/netron-browser';

const pool = new BackendPool({
backends: {
auth: new BackendClient({ url: 'https://auth.example.com' }),
media: new BackendClient({ url: 'https://media.example.com' }),
analytics: new BackendClient({ url: 'https://analytics.example.com' }),
},
routes: {
'users.*': 'auth',
'objects.*': 'media',
'reports.*': 'analytics',
},
});

await pool.connectAll();

const users = pool.service<UserService>('users');
await users.findById('u_42'); // automatically routed to 'auth' backend

Pattern matching is glob-style. Calls not matching any rule throw BackendNotConfiguredError.

Health-aware routing

const pool = new BackendPool({
backends: /* ... */,
routes: /* ... */,
healthCheck: {
interval: 30_000,
timeout: 2_000,
onUnhealthy: 'fail', // 'fail' | 'fallback' | 'queue'
},
});

Unhealthy backends fail fast or fall back to a designated backup (when configured).

Errors

Server-side TitanError subclasses arrive as the same class on the client — wire format preserves the constructor name and code:

import { TitanError, ErrorCode } from '@omnitron-dev/netron-browser';

try {
await users.findById('missing');
} catch (e) {
if (!(e instanceof TitanError)) throw e;

switch (e.code) {
case ErrorCode.NOT_FOUND: return null;
case ErrorCode.UNAUTHORIZED: return triggerReauth();
case ErrorCode.TOO_MANY_REQUESTS: return scheduleRetry(e);
default: throw e;
}
}

→ See Titan / Errors catalog for the full code reference.

Subpaths

SubpathContents
@omnitron-dev/netron-browserEverything; convenient root
@omnitron-dev/netron-browser/clientNetronClient, HttpClient, WebSocketClient, BackendPool
@omnitron-dev/netron-browser/authAuthManager, token storage helpers
@omnitron-dev/netron-browser/middlewareAll built-in middleware
@omnitron-dev/netron-browser/coreTypes, defaults, factory helpers
@omnitron-dev/netron-browser/core-tasksBuilt-in service tasks ($system.describe, etc.)
@omnitron-dev/netron-browser/transportLow-level transport adapters
@omnitron-dev/netron-browser/packetWire-format helpers (rarely used directly)
@omnitron-dev/netron-browser/errorsTitanError hierarchy mirror
@omnitron-dev/netron-browser/utilsURL parsing, header normalisation, LRUCache
@omnitron-dev/netron-browser/routingMulti-backend route matching
@omnitron-dev/netron-browser/typesAll public types

Performance characteristics

OperationCost
HTTP RPC over LAN0.5–2 ms
WebSocket RPC after connect0.1–0.5 ms
WS reconnect from cold100 ms – 1 s (depends on backoff)
Cached call (LRU hit)~0.01 ms (no network)
Middleware overhead per call~0.05 ms per registered middleware
Proxy access~0.01 ms per property read

Bundle: ~20–30 kB gzipped for the root import. Subpath imports can ship just the transport you use (HTTP-only: ~12 kB).

Examples

The package ships runnable examples at packages/netron-browser/examples/ covering:

  • Basic HTTP usage
  • WebSocket subscriptions
  • Auth manager flow
  • Multi-backend routing
  • Custom middleware

Best practices

  • Use service proxies, not raw invoke. Types flow through; refactors stay safe.
  • One client per backend. Don't recreate on every render — treat the client as a long-lived singleton.
  • Wire AuthManager once. Cross-tab sync requires a single manager instance to share state via BroadcastChannel.
  • Cache idempotent reads, not writes. Mutations should invalidate by tag, not cache.
  • Circuit breaker before retry. Order matters — the breaker prevents the retry loop from hammering a dead backend.
  • Set timeout explicitly. Browser fetch defaults are effectively no-timeout; surface as TimeoutError for clean client UX.
  • Reconnect bounded. maxReconnectAttempts prevents tabs from connecting forever after a permanent outage.

Anti-patterns

  • Storing tokens in cookies + localStorage. Pick one. Cross- site contexts work better with HttpOnly cookies; same-site apps work better with localStorage + AuthManager.
  • Per-call new client. Defeats caching, breaks WS reconnect, wastes connections.
  • Catching Error generically. Lose the typed code; always check instanceof TitanError.
  • Custom retry logic on top of RetryMiddleware. Double retries amplify failure load; disable one or the other.
  • maxReconnectAttempts: Infinity. A genuinely-dead server has open connections from every tab forever.

See also