Skip to content

atmosphere.js

TypeScript client for the Atmosphere Framework (currently 5.0.32). Supports WebTransport/HTTP3, WebSocket, SSE, HTTP Streaming, and Long-Polling transports with first-class React, Vue, and Svelte hooks.

Terminal window
npm install atmosphere.js
import { atmosphere } from 'atmosphere.js';
const subscription = await atmosphere.subscribe({
url: 'http://localhost:8080/chat',
transport: 'websocket',
}, {
message: (response) => console.log('Received:', response.responseBody),
open: (response) => console.log('Connected via:', response.transport),
close: (response) => console.log('Connection closed'),
error: (error) => console.error('Error:', error),
});
subscription.push({ user: 'John', message: 'Hello World' });

The package ships ESM, CommonJS, and TypeScript declarations. Framework integrations are tree-shakeable subpath exports:

import { Atmosphere } from 'atmosphere.js';
import { useAtmosphere, useStreaming, useChat, useRoom, usePresence } from 'atmosphere.js/react';
import { useAtmosphere, useStreaming, useChat, useRoom } from 'atmosphere.js/vue';
import { createAtmosphereStore, createStreamingStore, createChatStore, createRoomStore } from 'atmosphere.js/svelte';
import { useAtmosphereRN, useStreamingRN, useChatRN, setupReactNative } from 'atmosphere.js/react-native';

Manages subscriptions with automatic transport selection and fallback. Typically you use the shared atmosphere singleton, but you can also construct your own instance with per-client configuration or client-side interceptors:

import { Atmosphere } from 'atmosphere.js';
const atm = new Atmosphere({
logLevel: 'info', // 'debug' | 'info' | 'warn' | 'error' | 'silent'
defaultTransport: 'websocket',
fallbackTransport: 'long-polling',
});
atm.version; // '5.0.32'
atm.closeAll(); // Close every active subscription
atm.getSubscriptions(); // Map<string, Subscription>

The subscribe() callback object accepts more than the four happy-path handlers shown in Quick Start. The extra handlers are essential for production reconnection UX:

const subscription = await atmosphere.subscribe<ChatMessage>(
{
url: 'http://localhost:8080/atmosphere/chat',
transport: 'websocket',
fallbackTransport: 'sse',
reconnect: true,
reconnectInterval: 2000,
maxReconnectOnClose: 5,
contentType: 'application/json',
},
{
open: (response) => console.log('Connected via', response.transport),
message: (response) => console.log('Received:', response.responseBody),
close: (response) => console.log('Disconnected'),
error: (error) => console.error('Error:', error),
transportFailure: (reason, request) => {
console.warn(`${request.transport} failed: ${reason}, trying fallback`);
},
reconnect: (request, response) => {
console.log('Reconnecting...');
},
failureToReconnect: (request, response) => {
console.error('All reconnection attempts exhausted');
},
},
);
subscription.push('Hello, world!'); // string
subscription.push({ author: 'Alice', text: 'Hi' }); // JSON object
subscription.push(new ArrayBuffer(16)); // binary
subscription.suspend(); // pause receiving
await subscription.resume(); // resume
await subscription.close(); // disconnect

subscription.state reflects the lifecycle and is what framework hooks expose as state:

StateDescription
disconnectedNot connected
connectingConnection in progress
connectedConnection established
reconnectingAttempting to reconnect
suspendedPaused via suspend()
closedClosed via close()
errorConnection error

Resilience: ConnectionStatus + Badge components

Section titled “Resilience: ConnectionStatus + Badge components”

Wiring all eight lifecycle hooks (open, reopen, reconnect, close, error, transportFailure, clientTimeout, failureToReconnect) on every consumer is busywork, and most apps end up showing only connected/disconnected because of it. atmosphere.js ships a ConnectionStatus primitive that collapses those events into a small phase machine plus a transient event indicator, and per-framework <ConnectionStatusBadge /> components that render the result as a single import.

import { Atmosphere, ConnectionStatus } from 'atmosphere.js';
const status = new ConnectionStatus();
status.onChange((snap) => {
// snap.phase — 'idle' | 'connecting' | 'open' | 'reconnecting' | 'closed' | 'lost'
// snap.lastEvent — the most recent lifecycle hook that fired (or null)
// snap.transport — currently active transport (updates after fallback)
// snap.attempt — reconnect attempt counter; resets to 0 on every successful open
// snap.viaFallback — true once a transportFailure has been observed
// snap.lastError — most recent error, or null
// snap.since — epoch-ms when the current phase began
console.log(`[${snap.phase}] ${snap.transport}${snap.viaFallback ? ' (fallback)' : ''}`);
});
const atmosphere = new Atmosphere();
const sub = await atmosphere.subscribe(request, status.wrap({
message: (m) => console.log(m), // your own handlers are preserved
}));

status.wrap(handlers) returns a fresh SubscriptionHandlers object that calls both the status tracker and your own callbacks for each event — no double-subscription, no duplicated wiring.

idle ──subscribe()──▶ connecting ──open──▶ open ──close/reconnect──▶ reconnecting
│ │
│ ├──reopen──▶ open
│ │
└──fail (no fallback)──▶ lost ◀──failureToReconnect
└──close (clean)──▶ closed

closed and lost are terminal until the next subscribe() call. The lastEvent field always carries the most recent transition trigger so UIs can show transient affordances (toasts, badges) that the steady-state phase alone cannot express — for example, distinguishing a first open from a reopen after a disconnect.

React: useConnectionStatus + <ConnectionStatusBadge />

Section titled “React: useConnectionStatus + <ConnectionStatusBadge />”

Every React hook (useAtmosphere, useStreaming) now exposes connectionStatus reactively. Pass it straight to the Badge:

import { useStreaming, ConnectionStatusBadge } from 'atmosphere.js/react';
function AiChat() {
const { fullText, connectionStatus, send } = useStreaming({
request: { url: '/ai/chat', transport: 'websocket', fallbackTransport: 'long-polling' },
onTransportFailure: (reason) => console.warn('Falling back:', reason),
onFailureToReconnect: () => alert('Connection lost — refresh to retry'),
});
return (
<>
<ConnectionStatusBadge status={connectionStatus} />
<p>{fullText}</p>
</>
);
}

The Badge renders a colored dot + label inside a rounded pill — for example, ● Connected · websocket while open, ● Reconnecting… · websocket mid-reconnect, ● Connected · long-polling (fallback) after a transport fallback, ● Connection lost · websocket after failureToReconnect. Override colors and labels via the colors and labels props.

For consumers that manage their own subscription, use useConnectionStatus directly:

import { useConnectionStatus, ConnectionStatusBadge } from 'atmosphere.js/react';
const { status, wrap } = useConnectionStatus();
useEffect(() => {
let sub;
(async () => { sub = await atmosphere.subscribe(req, wrap({ message: handle })); })();
return () => sub?.close();
}, []);
return <ConnectionStatusBadge status={status} />;

Vue: useConnectionStatus + <ConnectionStatusBadge />

Section titled “Vue: useConnectionStatus + <ConnectionStatusBadge />”
<script setup lang="ts">
import { useStreaming, ConnectionStatusBadge } from 'atmosphere.js/vue';
const { fullText, connectionStatus, send } = useStreaming(
{ url: '/ai/chat', transport: 'websocket' },
undefined,
{ onTransportFailure: (reason) => console.warn(reason) },
);
</script>
<template>
<ConnectionStatusBadge :status="connectionStatus" />
</template>

useStreamingRN and useAtmosphereRN both expose connectionStatus, and the RN-native Badge uses View/Text (no DOM):

import { useStreamingRN, ConnectionStatusBadgeRN } from 'atmosphere.js/react-native';
const { connectionStatus, send } = useStreamingRN({
request,
onTransportFailure: (reason) => console.warn(reason),
});
return <ConnectionStatusBadgeRN status={connectionStatus} />;

Resilience behavior is driven by the request options — the Badge just visualizes whatever the client decides:

OptionEffect
transportPrimary transport (websocket, sse, streaming, long-polling, webtransport)
fallbackTransportUsed when the primary fails — fires transportFailure and continues with the fallback
reconnectEnable auto-reconnect on close (default true)
reconnectIntervalMilliseconds between reconnect attempts
maxReconnectOnCloseQuota — when exhausted, failureToReconnect fires once and the phase moves to lost
heartbeat.clientClient-side watchdog interval (ms) — expiry triggers clientTimeout

Fallback is single-level and client-driven: when the primary transport fails, the client switches to fallbackTransport (one hop), fires transportFailure, and continues. The realized + tested cascade today is WebTransport → WebSocket; longer historical chains (WebSocket → SSE → streaming → long-polling) work for one step at a time if you configure them and the server supports the target, but the framework does not chain through all four automatically without per-attempt opt-in. The viaFallback flag stays true until the next subscribe() call, so the Badge can keep showing the degraded-mode indicator even after the fallback transport reaches steady-state open.

AtmosphereRooms is the vanilla-TypeScript counterpart of the useRoom / createRoomStore hooks. Use it when you don’t want a framework integration or when you need a single rooms instance shared across parts of your app. It pairs with the server-side RoomManager and RoomInterceptor.

import { Atmosphere, AtmosphereRooms } from 'atmosphere.js';
const atm = new Atmosphere();
const rooms = new AtmosphereRooms(atm, {
url: '/atmosphere/chat',
transport: 'websocket',
});
const lobby = await rooms.join('lobby', { id: 'alice' }, {
message: (data, member) => console.log(`${member.id}: ${data}`),
join: (event) => console.log(`${event.member.id} joined`),
leave: (event) => console.log(`${event.member.id} left`),
});
lobby.broadcast('Hello!');
lobby.sendTo('bob', 'Direct message');
lobby.members; // ReadonlyMap<string, RoomMember>
lobby.leave();
await rooms.leaveAll(); // Leave every room and close the underlying subscription
AtmosphereRooms methodDescription
join(roomName, member, handlers)Join a room; returns Promise<RoomHandle>
leave(roomName)Leave a specific room
leaveAll()Leave all rooms and close the connection
room(name)Get a RoomHandle by name (or undefined)
joinedRooms()List all joined room names

All hooks require an <AtmosphereProvider> ancestor.

import { AtmosphereProvider } from 'atmosphere.js/react';
function App() {
return (
<AtmosphereProvider>
<Chat />
</AtmosphereProvider>
);
}

Subscribe to an endpoint:

import { useAtmosphere } from 'atmosphere.js/react';
function Chat() {
const { data, state, push } = useAtmosphere<Message>({
request: { url: '/chat', transport: 'websocket' },
});
return state === 'connected'
? <button onClick={() => push({ text: 'Hello' })}>Send</button>
: <p>Connecting...</p>;
}

Join a room with presence:

import { useRoom } from 'atmosphere.js/react';
function ChatRoom() {
const { joined, members, messages, broadcast } = useRoom<ChatMessage>({
request: { url: '/atmosphere/room', transport: 'websocket' },
room: 'lobby',
member: { id: 'user-1' },
});
return (
<div>
<p>{members.length} online</p>
{messages.map((m, i) => <div key={i}>{m.member.id}: {m.data.text}</div>)}
<button onClick={() => broadcast({ text: 'Hi' })}>Send</button>
</div>
);
}

Lightweight presence tracking:

import { usePresence } from 'atmosphere.js/react';
function OnlineUsers() {
const { members, count, isOnline } = usePresence({
request: { url: '/atmosphere/room', transport: 'websocket' },
room: 'lobby',
member: { id: currentUser.id },
});
return <p>{count} users online. Alice is {isOnline('alice') ? 'here' : 'away'}.</p>;
}

AI/LLM text streaming:

import { useStreaming } from 'atmosphere.js/react';
function AiChat() {
const { fullText, isStreaming, stats, routing, send } = useStreaming({
request: { url: '/ai/chat', transport: 'websocket' },
});
return (
<div>
<button onClick={() => send('Explain WebSockets')} disabled={isStreaming}>Ask</button>
<p>{fullText}</p>
{stats && <small>{stats.totalStreamingTexts} streaming texts</small>}
</div>
);
}

AI-SDK-style message/input state layered on useStreaming:

import { useChat } from 'atmosphere.js/react';
function AiChatForm() {
const { messages, input, setInput, handleSubmit, isLoading } = useChat({
request: { url: '/ai/chat', transport: 'websocket' },
});
return (
<form onSubmit={handleSubmit}>
{messages.map((message) => <p key={message.id}>{message.content}</p>)}
<input value={input} onChange={(event) => setInput(event.target.value)} />
<button disabled={isLoading}>Send</button>
</form>
);
}

Vue composables do not require a provider — they create or accept an Atmosphere instance directly.

<script setup lang="ts">
import { useAtmosphere } from 'atmosphere.js/vue';
const { data, state, push } = useAtmosphere<ChatMessage>({
url: '/chat',
transport: 'websocket',
});
</script>
<template>
<p>Status: {{ state }}</p>
<button @click="push({ text: 'Hello!' })">Send</button>
</template>
<script setup lang="ts">
import { useRoom } from 'atmosphere.js/vue';
const { members, messages, broadcast } = useRoom<ChatMessage>(
{ url: '/atmosphere/room', transport: 'websocket' },
'lobby',
{ id: 'user-1' },
);
</script>
<script setup lang="ts">
import { usePresence } from 'atmosphere.js/vue';
const { members, count, isOnline } = usePresence(
{ url: '/atmosphere/room', transport: 'websocket' },
'lobby',
{ id: currentUser.id },
);
</script>
<script setup lang="ts">
import { useStreaming } from 'atmosphere.js/vue';
const { fullText, isStreaming, send, reset } = useStreaming({
url: '/ai/chat',
transport: 'websocket',
});
</script>
<template>
<button @click="send('What is Atmosphere?')">Ask</button>
<p>{{ fullText }}</p>
<span v-if="isStreaming">Generating...</span>
</template>
<script setup lang="ts">
import { useChat } from 'atmosphere.js/vue';
const { messages, input, handleSubmit, isLoading } = useChat({
request: { url: '/ai/chat', transport: 'websocket' },
});
</script>
<template>
<form @submit="handleSubmit">
<p v-for="message in messages" :key="message.id">{{ message.content }}</p>
<input v-model="input" />
<button :disabled="isLoading">Send</button>
</form>
</template>

Svelte integrations use the store pattern — each factory returns a Svelte-compatible readable store plus action functions.

<script>
import { createAtmosphereStore } from 'atmosphere.js/svelte';
const { store: chat, push } = createAtmosphereStore({ url: '/chat', transport: 'websocket' });
</script>
<p>Status: {$chat.state}</p>
<button on:click={() => push({ text: 'Hello!' })}>Send</button>
<script>
import { createRoomStore } from 'atmosphere.js/svelte';
const { store: lobby, broadcast } = createRoomStore(
{ url: '/atmosphere/room', transport: 'websocket' },
'lobby',
{ id: 'user-1' },
);
</script>
<p>Members: {$lobby.members.map(m => m.id).join(', ')}</p>
<button on:click={() => broadcast({ text: 'Hello!' })}>Broadcast</button>
<script>
import { createPresenceStore } from 'atmosphere.js/svelte';
const presence = createPresenceStore(
{ url: '/atmosphere/room', transport: 'websocket' },
'lobby',
{ id: 'user-1' },
);
</script>
<p>{$presence.count} users online</p>
<script>
import { createStreamingStore } from 'atmosphere.js/svelte';
const { store, send, reset } = createStreamingStore({
url: '/ai/chat',
transport: 'websocket',
});
</script>
<button on:click={() => send('What is Atmosphere?')}>Ask</button>
<p>{$store.fullText}</p>
{#if $store.isStreaming}<span>Generating...</span>{/if}
<script>
import { createChatStore } from 'atmosphere.js/svelte';
const { store: chat, append, reset } = createChatStore({
request: { url: '/ai/chat', transport: 'websocket' },
});
</script>
{#each $chat.messages as message}
<p>{message.content}</p>
{/each}
<button on:click={() => append('What is Atmosphere?')}>Ask</button>
<button on:click={reset}>Reset</button>

The server sends JSON messages using the Atmosphere AI streaming protocol:

{"type": "streaming-text", "data": "Hello", "sessionId": "abc-123", "seq": 1}
{"type": "progress", "data": "Thinking...", "sessionId": "abc-123", "seq": 2}
{"type": "metadata", "key": "model", "value": "gpt-4", "sessionId": "abc-123", "seq": 3}
{"type": "complete", "data": "Done", "sessionId": "abc-123", "seq": 10}
{"type": "error", "data": "Rate limited","sessionId": "abc-123", "seq": 11}

Use subscribeStreaming for framework-agnostic streaming, useStreaming / createStreamingStore for raw text accumulation, or useChat / createChatStore for message/input state.

OptionTypeDefaultDescription
urlstring(required)Endpoint URL
transportTransportType'websocket''webtransport' · 'websocket' · 'sse' · 'long-polling' · 'streaming'
fallbackTransportTransportTypeTransport to use if primary fails
contentTypestring'text/plain'Content type for messages
trackMessageLengthbooleanEnable message length prefixing (recommended: true)
messageDelimiterstring'|'Delimiter used with length prefixing
enableProtocolbooleanEnable Atmosphere protocol handshake
reconnectbooleantrueAuto-reconnect on disconnect
reconnectIntervalnumberBase delay between reconnection attempts (ms)
maxReconnectOnClosenumber5Max reconnection attempts
maxRequestnumberMax long-polling request cycles
timeoutnumberInactivity timeout (ms)
connectTimeoutnumberConnection timeout (ms)
headersRecord<string, string>Custom HTTP headers
withCredentialsbooleanInclude cookies (CORS)
sessionTokenstringDurable session token, sent as X-Atmosphere-Session-Token
heartbeat{ client?: number; server?: number }Heartbeat intervals (ms)

atmosphere.js supports durable sessions (see Durable Sessions tutorial) via the sessionToken request property. The server assigns a token on first connect and sends it in the response headers; the client stores it (e.g., in localStorage) and replays it on reconnect to resume the same logical session across page refreshes and network outages:

const sub = await atmosphere.subscribe({
url: '/atmosphere/chat',
transport: 'websocket',
sessionToken: localStorage.getItem('atmosphere-session') ?? undefined,
}, {
open: (response) => {
const token = response.headers['X-Atmosphere-Session-Token'];
if (token) localStorage.setItem('atmosphere-session', token);
},
});

Interceptors transform messages before sending and after receiving, like a middleware stack. Outgoing interceptors run in declaration order; incoming interceptors run in reverse order so the outermost wrapper unwraps last.

const atm = new Atmosphere({
interceptors: [
{
name: 'json',
onOutgoing: (data) => typeof data === 'string' ? data : JSON.stringify(data),
onIncoming: (body) => JSON.parse(body),
},
{
name: 'envelope',
onOutgoing: (body) => JSON.stringify({ payload: body, ts: Date.now() }),
onIncoming: (body) => JSON.parse(body).payload,
},
],
});

The client connects to server endpoints defined with @ManagedService. A minimal server-client pair:

Server (Java):

@ManagedService(path = "/atmosphere/chat")
public class Chat {
@Inject private AtmosphereResource r;
@Ready
public void onReady() {
// Client connected
}
@Message
public String onMessage(String message) {
return message; // Echo to all subscribers
}
}

Client (TypeScript):

const sub = await atmosphere.subscribe(
{ url: '/atmosphere/chat', transport: 'websocket' },
{ message: (res) => console.log(res.responseBody) },
);
sub.push('Hello from atmosphere.js');

See @ManagedService tutorial for the full server-side API.

  • Chrome/Edge: last 2 versions
  • Firefox: last 2 versions + ESR
  • Safari: last 2 versions
  • Mobile Safari (iOS): last 2 versions
  • Chrome Android: last 2 versions

Complete TypeScript type reference for the public surface.

class Atmosphere {
readonly version: string; // '5.0.32'
constructor(config?: {
logLevel?: 'debug' | 'info' | 'warn' | 'error' | 'silent';
defaultTransport?: TransportType;
fallbackTransport?: TransportType;
interceptors?: AtmosphereInterceptor[];
});
subscribe<T = unknown>(
request: AtmosphereRequest,
handlers: SubscriptionHandlers<T>,
): Promise<Subscription<T>>;
closeAll(): void;
getSubscriptions(): Map<string, Subscription>;
}
interface AtmosphereRequest {
url: string;
transport?: TransportType;
fallbackTransport?: TransportType;
contentType?: string;
trackMessageLength?: boolean;
messageDelimiter?: string;
enableProtocol?: boolean;
timeout?: number;
connectTimeout?: number;
reconnect?: boolean;
reconnectInterval?: number;
maxReconnectOnClose?: number;
maxRequest?: number;
headers?: Record<string, string>;
withCredentials?: boolean;
sessionToken?: string;
heartbeat?: { client?: number; server?: number };
}
type TransportType =
| 'webtransport'
| 'websocket'
| 'sse'
| 'long-polling'
| 'streaming';
interface SubscriptionHandlers<T = unknown> {
open?: (response: AtmosphereResponse<T>) => void;
message?: (response: AtmosphereResponse<T>) => void;
close?: (response: AtmosphereResponse<T>) => void;
error?: (error: Error) => void;
reopen?: (response: AtmosphereResponse<T>) => void;
reconnect?: (request: AtmosphereRequest, response: AtmosphereResponse<T>) => void;
transportFailure?: (reason: string, request: AtmosphereRequest) => void;
clientTimeout?: (request: AtmosphereRequest) => void;
failureToReconnect?: (request: AtmosphereRequest, response: AtmosphereResponse<T>) => void;
}
interface Subscription<T = unknown> {
readonly id: string;
readonly state: ConnectionState;
push(message: string | object | ArrayBuffer): void;
close(): Promise<void>;
suspend(): void;
resume(): Promise<void>;
on(event: string, handler: (...args: unknown[]) => void): void;
off(event: string, handler: (...args: unknown[]) => void): void;
}
type ConnectionState =
| 'disconnected'
| 'connecting'
| 'connected'
| 'reconnecting'
| 'suspended'
| 'closed'
| 'error';

RoomHandle, RoomMember, PresenceEvent, RoomMessage

Section titled “RoomHandle, RoomMember, PresenceEvent, RoomMessage”
interface RoomHandle<T = unknown> {
readonly name: string;
readonly members: ReadonlyMap<string, RoomMember>;
broadcast(data: T): void;
sendTo(memberId: string, data: T): void;
leave(): Promise<void>;
}
interface RoomMember {
id: string;
metadata?: Record<string, unknown>;
}
interface PresenceEvent {
type: 'join' | 'leave';
room: string;
member: RoomMember;
timestamp: number;
}
interface RoomMessage<T = unknown> {
type: 'join' | 'leave' | 'broadcast' | 'direct' | 'presence';
room: string;
data?: T;
member?: RoomMember;
target?: string; // for direct messages
}
interface AtmosphereInterceptor {
name?: string;
onOutgoing?: (data: string | ArrayBuffer) => string | ArrayBuffer;
onIncoming?: (body: string) => string;
}

Applied in order for outgoing messages and in reverse order for incoming — the middleware-stack pattern.