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, a middleware pipeline (auth / logging / timing / error-transform / CSRF), an optional fluent HTTP interface for caching / retry / circuit breaking, an AuthenticationClient 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

Caching, richer retry, and circuit breaking are not in the invocation pipeline — they live in the optional fluent HTTP interface reached via peer.queryFluentInterface(...).

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:

  • Retry on transient failures — set retry: true and the client re-sends on network/timeout errors with exponential backoff (2^n × 1000ms), up to maxRetries attempts.
  • Auth re-invocation — when an auth-error middleware refreshes the token after a 401, the request is automatically re-sent once with the new credentials.

For response caching, deduplication, richer retry strategies, and circuit breaking, use the fluent HTTP interface.

const client = createClient({
url: '/api',
transport: 'http',
http: {
retry: true, // default false
maxRetries: 3, // default 3 when retry is enabled
},
});

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

All built-in middleware are factory functions (create*):

MiddlewareStagePurpose
createAuthMiddlewarepreAttach Authorization: Bearer ... from a token provider
createLoggingMiddlewarepre + post + errorStructured request/response logging
createTimingMiddlewarepre + postPer-call duration metrics
createErrorTransformMiddlewareerrorNormalise transport errors into a consistent shape
createCsrfMiddlewarepreAttach the CSRF double-submit header (cookie mode)

There is no retry, cache, or circuit-breaker middleware. Retry is an HttpClient option; caching and circuit breaking live in the fluent HTTP interface. See Middleware → Not middleware.

import {
createAuthMiddleware,
createLoggingMiddleware,
} from '@omnitron-dev/netron-browser';

client.use(createAuthMiddleware({ tokenProvider: { getToken: () => localStorage.getItem('token') } }));
client.use(createLoggingMiddleware({ logRequestPayload: false }));

Custom middleware

A middleware is a plain (ctx, next) => Promise<void> function; register it with client.use(fn, config?, stage?):

import {
type MiddlewareFunction,
MiddlewareStage,
} from '@omnitron-dev/netron-browser';

const timingMiddleware: MiddlewareFunction = async (ctx, next) => {
const start = performance.now();
try {
await next();
} finally {
console.debug(`${ctx.service}.${ctx.method}`, performance.now() - start, 'ms');
}
};

client.use(timingMiddleware, { name: 'timing', priority: 100 }, MiddlewareStage.POST_RESPONSE);

See Middleware for the full context shape, stages, and registration rules.

Fluent HTTP interface — caching, retry, circuit breaking

For HTTP-transport calls you can opt into a fluent interface that adds per-call caching, retry, deduplication, timeout, and more — without touching the middleware pipeline. It is reached through the HTTP peer's queryFluentInterface<T>(...) (HTTP transport only; not available over WebSocket):

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

// `peer` is the HttpRemotePeer for an HTTP connection.
const users = await peer.queryFluentInterface<UserService>('users@1.0.0');

// Chainable per-call configuration, then call the method:
const user = await users
.cache(60_000) // cache for 60s (number → { maxAge })
.retry({ attempts: 5 }) // fluent RetryOptions
.timeout(3_000)
.api.findById('u_42');

Each chainable method (.cache(), .retry(), .timeout(), .priority(), .dedupe(), .transform(), .fallback(), …) returns a configurable proxy; access the method through .api (or call it directly on the proxy) to execute. Configuration applies to that one call.

The fluent primitives (FluentInterface, HttpCacheManager, RetryManager, QueryBuilder, CircuitBreakerOptions, CacheOptions, RetryOptions) are re-exported from the package root and shared with @omnitron-dev/netron-http-core.

Caching

Caching is provided by the fluent interface's HttpCacheManager, not by a middleware. Configure it per call with .cache(...):

// CacheOptions:
// { maxAge, staleWhileRevalidate?, tags?, cacheOnError?, key? }
const user = await users
.cache({
maxAge: 60_000,
staleWhileRevalidate: 10_000, // serve stale up to 10s while refreshing
tags: ['user:u_42', 'tier:pro'],
})
.api.findById('u_42');

// Invalidate by cache-key pattern (string = exact/prefix, or a RegExp):
users.invalidate('user:u_42*');
users.clearCache();

A shared HttpCacheManager can be attached to the peer so every fluent interface it creates uses the same cache + stats — and so you can invalidate by tag (the array form of invalidate() matches tags, not keys):

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

const cache = new HttpCacheManager({ maxEntries: 1_000 });
peer.setCacheManager(cache);

// …after a tagged .cache({ tags: ['user:u_42'] }) call:
cache.invalidate(['user:u_42']); // invalidate everything tagged user:u_42

Note: createClient's http.caching / http.cacheTTL options are currently inert — the basic HttpClient does not cache. Use the fluent interface (or netron-react's query cache — see Caching) for response caching.

LRUCache (from @omnitron-dev/netron-browser/utils) is a general-purpose bounded cache utility — not a request cache middleware.

Retry & circuit breaking

The fluent .retry(...) takes a RetryOptions:

// RetryOptions:
// { attempts, backoff?, initialDelay?, maxDelay?, jitter?,
// shouldRetry?, onRetry?, attemptTimeout?, factor?, idempotent? }
const user = await users
.retry({
attempts: 3,
backoff: 'exponential', // 'exponential' | 'linear' | 'constant'
initialDelay: 500,
maxDelay: 8_000,
jitter: 0.1,
idempotent: true, // allow retrying ambiguous failures
})
.api.findById('u_42');

Circuit breaking is configured on a RetryManager via CircuitBreakerOptions and attached to the peer:

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

// CircuitBreakerOptions:
// { threshold, windowTime, cooldownTime, successThreshold? }
const retryManager = new RetryManager({
circuitBreaker: {
threshold: 5, // open after 5 failures…
windowTime: 10_000, // …within a 10s window
cooldownTime: 30_000, // try half-open after 30s
},
});

peer.setRetryManager(retryManager);

When the breaker is open, calls fail fast with a TitanError (code: ErrorCode.SERVICE_UNAVAILABLE, message "Circuit breaker is open") until the cooldown elapses; after cooldown one probe runs and success closes the breaker. There is no CircuitOpenError class — check e.code === ErrorCode.SERVICE_UNAVAILABLE.

Authentication

The real class is AuthenticationClient (from @omnitron-dev/netron-browser) — there is no AuthManager. Construct one, attach it to the transport client, then drive it with setAuth() / clearAuth() / logout():

import { HttpClient } from '@omnitron-dev/netron-browser';
import { AuthenticationClient, LocalTokenStorage } from '@omnitron-dev/netron-browser';

const auth = new AuthenticationClient({
storage: new LocalTokenStorage('platform:token'), // or Session/Memory/Noop
refreshConfig: { endpoint: '/auth/refresh' },
inactivityConfig: { timeout: 30 * 60_000 }, // 30 min
crossTabSync: { enabled: true }, // storage-event sync
});

// Attach to the transport (HttpClient / WebSocketClient accept `auth`):
const client = new HttpClient({ url: '/api', auth });

// On sign-in — pass the server's AuthResult:
auth.setAuth(result);

// On sign-out:
await auth.logout(); // POSTs logoutConfig.endpoint if set, then clears state
auth.clearAuth(); // local-only clear (no server call)

AuthenticationClient features:

  • Token storageLocalTokenStorage / SessionTokenStorage / MemoryTokenStorage (default) / NoopTokenStorage (cookie mode).
  • Auto-refresh — schedules a refresh before expiry via refreshConfig; refreshToken() coalesces concurrent calls.
  • Cross-tab sync — sign-in / sign-out in one tab propagates to all open tabs (via the storage event).
  • Inactivity timeout — auto-clearAuth() after N ms of no activity (inactivityConfig).
  • Eventsauth.on('authenticated' | 'token-refreshed' | 'unauthenticated' | 'error' | 'inactivity', cb).

Read the current token with auth.getToken() (synchronous, string | undefined) and check status with auth.isAuthenticated(). For the full reference — storage backends, token transports, cookie mode — see Auth.

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 { createMultiBackendClient } from '@omnitron-dev/netron-browser';

// Backends sit behind one origin, separated by path — a gateway with
// /auth, /media, /analytics under it. One auth client and one middleware
// chain then cover all of them.
const client = createMultiBackendClient({
baseUrl: 'https://api.example.com',
backends: {
auth: { path: '/auth' },
media: { path: '/media' },
analytics: { path: '/analytics' },
},
defaultBackend: 'auth',
routing: {
// String patterns match by prefix, not as globs.
patterns: [
{ pattern: 'objects', backend: 'media' },
{ pattern: 'reports', backend: 'analytics' },
],
},
});

const users = client.service<UserService>('users');
await users.findById('u_42'); // unrouted → defaultBackend, 'auth'

A string pattern matches by prefix, not as a glob; a RegExp covers anything more. A call matching no rule goes to defaultBackend rather than throwing.

Health checks

enableHealthChecks (off by default) and healthCheckInterval (30s) make the pool poll its backends. Nothing re-routes away from an unhealthy one — there is no failover map and no onUnhealthy policy; a call to a backend that is down fails like any other call.

Multi-backend for routing, per-backend auth, and the React provider.

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/errors';

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, including AuthenticationClient and the token storage + transport helpers
@omnitron-dev/netron-browser/clientNetronClient, HttpClient, WebSocketClient, BackendPool
@omnitron-dev/netron-browser/client/httpHttpClient alone
@omnitron-dev/netron-browser/client/websocketWebSocketClient alone
@omnitron-dev/netron-browser/middlewareAll built-in middleware
@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/typesAll public types

There is no /core, /core-tasks, /transport or /routing subpath — those rows described a layout the package does not have, and an import following any of them fails to resolve. What they named is in the root or under /client. AuthenticationClient is likewise at the root, not under an /auth subpath.

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
  • Authentication 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 AuthenticationClient once. Cross-tab sync requires a single instance to share state via the storage event.
  • Cache idempotent reads, not writes. Mutations should invalidate by tag, not cache.
  • Circuit breaker before retry. A RetryManager configured with circuitBreaker checks the breaker before each attempt, so a tripped 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 + AuthenticationClient.
  • Per-call new client. Defeats caching, breaks WS reconnect, wastes connections.
  • Catching Error generically. Lose the typed code; always check instanceof TitanError.
  • Stacking the HttpClient retry option with fluent .retry(...). Double retries amplify failure load; pick one layer.
  • maxReconnectAttempts: Infinity. A genuinely-dead server has open connections from every tab forever.

See also