Capability-driven AI model routing with automatic failover
ModelMesh TypeScript works in browsers, calling AI provider APIs directly from client-side JavaScript. This guide covers setup, CORS handling, and security. For the full list of browser-compatible connectors (storage, secret stores, providers), see the Connector Catalogue. For the CORS proxy deployment, see the Proxy Guide.
┌──────────────────────────────────────────────┐
│ Browser │
│ │
│ Your App ──→ BrowserBaseProvider │
│ │ │
│ ├── proxyUrl set? │
│ │ YES → CORS Proxy ──→ API│
│ │ NO → Direct ──→ API│
└──────────────────────────────────────────────┘
Works when:
anthropic-dangerous-direct-browser-access header)host_permissionsimport { BrowserBaseProvider, createBrowserProviderConfig } from '@nistrapa/modelmesh-core';
const provider = new BrowserBaseProvider(createBrowserProviderConfig({
baseUrl: 'https://api.openai.com',
apiKey: userApiKey,
// No proxyUrl — direct connection
}));
Works everywhere. The proxy adds CORS headers so the browser allows the request.
const provider = new BrowserBaseProvider(createBrowserProviderConfig({
baseUrl: 'https://api.openai.com',
apiKey: userApiKey,
proxyUrl: 'http://localhost:9090', // CORS proxy
}));
# Option A: Node.js (zero dependencies)
node tools/cors-proxy/cors-proxy.js
# Option B: Docker
cd tools/cors-proxy
docker compose up
# Option C: Third-party
npx local-cors-proxy --proxyUrl https://api.openai.com --port 9090
Open samples/browser/index.html in your browser. Configure:
http://localhost:9090 (or leave empty for direct access)Type a message and click Send. Responses stream in real-time.
CORS (Cross-Origin Resource Sharing) is a browser security feature. When JavaScript on yoursite.com tries to call api.openai.com, the browser blocks it unless the API server explicitly allows it with response headers like:
Access-Control-Allow-Origin: *
Most AI provider APIs do not send these headers, so browsers block the requests. The CORS proxy sits between the browser and the API, adding these headers.
Important: CORS only applies in web browsers. Node.js, browser extensions (with permissions), and server-side code are not affected.
| Feature | BaseProvider | BrowserBaseProvider |
|---|---|---|
| HTTP transport | Node.js http/https |
Fetch API |
| Streaming | Node.js streams | ReadableStream |
| Timeout | req.destroy() |
AbortController |
| CORS proxy | N/A | proxyUrl config |
| Environment | Node.js only | Browser, Deno, Bun, Workers |
| API interface | Identical | Identical |
Both classes share the same protected hooks for subclassing:
_buildHeaders() — Customize request headers_buildRequestPayload() — Transform request body_parseResponse() — Parse completion response_parseSseChunk() — Parse SSE stream chunks_getCompletionEndpoint() — Override API endpointFor bundlers, import from the browser entry point to exclude Node.js-specific modules:
import {
BrowserBaseProvider,
createBrowserProviderConfig,
ModelMesh,
MeshClient,
createBrowser,
// Storage connectors
MemoryStorage,
LocalStorageStorage,
SessionStorageStorage,
IndexedDBStorage,
// Secret stores
MemorySecretStore,
BrowserSecretStore,
// Runtime metadata
RuntimeEnvironment,
} from '@nistrapa/modelmesh-core/browser';
The browser entry point excludes: ProxyServer, MeshConfig.fromFile(), FileSecretStore, HttpHealthDiscovery, KeyValueStorage (file backend), and all Node.js-dependent connectors (providers using http/https, file-based storage, env/dotenv/json/encrypted/keyring secret stores).
API keys in client-side JavaScript are visible to users. This is acceptable for:
For production apps with shared API keys, use a backend proxy that holds the keys.
The included CORS proxy is for development only. For production:
Browser extensions with host_permissions in manifest.json bypass CORS entirely:
{
"host_permissions": [
"https://api.openai.com/*",
"https://api.anthropic.com/*"
]
}
No CORS proxy needed. Use BrowserBaseProvider without proxyUrl.
anthropic-dangerous-direct-browser-access: true
x-api-key header instead of Bearer tokenTTS and STT providers (ElevenLabs, AssemblyAI) can be accessed from the browser through the same BrowserBaseProvider and CORS proxy setup. Key considerations:
TTS responses are binary audio data, not JSON. The BrowserBaseProvider returns audio bytes in the response extra.audio field. For streaming TTS, the response is a ReadableStream of audio chunks that can be fed directly to the Web Audio API or an <audio> element via MediaSource:
const response = await client.audio.speech.create({
model: "text-to-speech",
input: "Hello from the browser!",
voice: "alloy",
});
// response.extra.audio is a Blob or ReadableStream
const audioUrl = URL.createObjectURL(response.extra.audio);
const audio = new Audio(audioUrl);
audio.play();
Speech-to-text requests send audio data (from a microphone or file upload) to the STT provider. The browser MediaRecorder API captures audio, which is sent as the file parameter:
const transcript = await client.audio.transcriptions.create({
model: "speech-to-text",
file: audioBlob, // from MediaRecorder or file input
});
console.log(transcript.text);
Audio API endpoints require the same CORS proxy as text endpoints. The proxy handles binary response bodies transparently. Ensure the proxy does not set a response size limit that would truncate large audio files.
ModelMesh provides three persistent storage connectors for browser environments, plus a browser-compatible secret store. All use the standard StorageConnector interface.
Persists state across page reloads and browser restarts. Subject to ~5-10 MB limit per origin.
import { LocalStorageStorage } from '@nistrapa/modelmesh-core/browser';
const storage = new LocalStorageStorage({ prefix: 'myapp:' });
await storage.save('model-state', {
key: 'model-state',
data: new Uint8Array([1, 2, 3]),
metadata: { version: 1 },
});
const entry = await storage.load('model-state');
Identical API to localStorage, but data is cleared when the browser tab closes. Useful for ephemeral per-session state.
import { SessionStorageStorage } from '@nistrapa/modelmesh-core/browser';
const storage = new SessionStorageStorage({ prefix: 'session:' });
Natively async storage with no practical size limit. Stores binary data directly (no base64 overhead). Recommended for apps with large state.
import { IndexedDBStorage } from '@nistrapa/modelmesh-core/browser';
const storage = new IndexedDBStorage({
dbName: 'myapp',
storeName: 'mesh-state',
version: 1,
});
await storage.save('large-data', {
key: 'large-data',
data: new Uint8Array(1024 * 1024), // 1 MB
metadata: {},
});
// When done, release the connection
storage.close();
Persists API keys and secrets in localStorage. Supports the full SecretManagement interface.
import { BrowserSecretStore } from '@nistrapa/modelmesh-core/browser';
const secrets = new BrowserSecretStore({
prefix: 'myapp-secrets:',
failOnMissing: false,
});
secrets.set('OPENAI_API_KEY', userProvidedKey);
const key = secrets.get('OPENAI_API_KEY');
Security note: localStorage is not encrypted. Secrets are visible in browser DevTools. This is suitable for user-provided API keys in personal tools, not for shared production deployments with server-managed keys.
| Requirement | Use |
|---|---|
| Small state, simple persistence | LocalStorageStorage |
| Large binary data, no size limit | IndexedDBStorage |
| State that expires with the tab | SessionStorageStorage |
| Testing without browser APIs | MemoryStorage (universal) |
| Storing API keys in browser | BrowserSecretStore |
Every TypeScript connector class declares a static readonly RUNTIME property using the RuntimeEnvironment enum. This metadata enables:
assertRuntimeCompatible() throws a clear error if a connector is used in the wrong environment| Value | Constant | Runs In | Connector Examples |
|---|---|---|---|
'node' |
RuntimeEnvironment.NODE_ONLY |
Node.js, Bun, Deno (server) | All providers (OpenAI, Anthropic, Ollama, etc.), file storage, env/dotenv/json secret stores |
'browser' |
RuntimeEnvironment.BROWSER_ONLY |
Browsers | LocalStorageStorage, SessionStorageStorage, IndexedDBStorage, BrowserSecretStore |
'universal' |
RuntimeEnvironment.UNIVERSAL |
Anywhere | MemoryStorage, MemorySecretStore, BrowserBaseProvider, rotation policies, ConsoleObservability |
Use detectRuntime() and assertRuntimeCompatible() to validate connector compatibility:
import { detectRuntime, assertRuntimeCompatible, RuntimeEnvironment } from '@nistrapa/modelmesh-core';
// Check current environment
const env = detectRuntime(); // 'node' or 'browser'
// Guard against incompatible usage (throws on mismatch)
assertRuntimeCompatible('modelmesh.localstorage.v1', RuntimeEnvironment.BROWSER_ONLY);
The runtime guard checks typeof window !== 'undefined' and typeof document !== 'undefined' to distinguish browser from Node.js environments.
The Docker CORS proxy accepts a PORT environment variable (default: 9090):
# Custom port
PORT=8080 node tools/cors-proxy/cors-proxy.js
# Docker with custom port
docker compose -f tools/cors-proxy/docker-compose.yml up
# Modify the ports mapping in docker-compose.yml for non-default ports
For production deployments, replace the development proxy with a production-grade reverse proxy (nginx, Caddy) that adds CORS headers. See Security Considerations above.
See also: FAQ · Quick Start · Proxy Guide