A one-stop guide to
api-apeon the browser: the proxy mental model, connection lifecycle, file transfer, and how to wire it cleanly into client-side stores (Redux, Zustand, useReducer) at the reducer level.
api-ape is a single client object that does three jobs over one socket:
- RPC — call any server endpoint as if it were a method:
await api.users.create({ name }) - Pub/sub — subscribe to channels by passing a callback:
const off = api.news.banking(d => …) - File ferry — embed
Blob/ArrayBufferanywhere in a payload; the client splits, uploads, and rehydrates them transparently.
It auto-connects, auto-buffers calls made before connect, auto-reconnects, auto-resubscribes, and auto-falls-back from WebSocket to HTTP streaming when WebSocket is blocked.
import api from 'api-ape'
const result = await api.greet({ name: 'World' }) // RPC
const off = api.chat.lobby(msg => render(msg)) // subscription
api.onConnectionChange(state => banner(state)) // lifecycleThat is the whole user-facing surface. Everything below is detail on top of those three primitives.
npm install api-apeimport api from 'api-ape'
api.ping().then(console.log)That's it. There is no connect() to call. On first property access the client lazily imports connectSocket, opens a WebSocket to ws[s]://<your-host>/api/ape, and enables auto-reconnect. Any calls made before the socket is open are buffered and flushed on connect.
The server hosts a built bundle at /api/ape.js:
<script src="/api/ape.js"></script>
<script>
api.message({ user: 'me', text: 'hello' })
</script>When window / document are missing (Next.js server render, etc.), the default export becomes a dummy proxy: every call returns a Promise that resolves once the client hydrates in the browser. You do not need to guard imports — but see §11 for the patterns that actually work in practice.
api is a Proxy. Property access builds a path; calling sends a request. What you pass decides what happens:
| Call shape | Becomes | Returns |
|---|---|---|
api.foo(data) |
RPC to /foo with body data |
Promise<response> |
api.foo.bar(data) |
RPC to /foo/bar |
Promise<response> |
api.foo[123](data) |
RPC to /foo/123 (dynamic segment) |
Promise<response> |
api.foo[id].bar(data) |
RPC to /foo/<id>/bar |
Promise<response> |
api.foo() |
RPC to /foo with no body |
Promise<response> |
api.foo(callback) (arg is fn) |
Subscribe to channel /foo |
() => void (unsubscribe) |
api.foo(callback, onError) |
Subscribe; onError(err) runs if the server refuses (err.code === 'SUBSCRIBE_REFUSED') |
() => void |
api.foo.bar(callback) |
Subscribe to /foo/bar |
() => void |
api.stock[ticker](callback) |
Subscribe to /stock/<ticker> |
() => void |
The call itself takes one argument — either a payload or a subscription callback (a subscription may add a second function, onError, for a server that refuses it with onSubscribe). Dynamic path segments are expressed via bracket access on the proxy chain; they never live inside the call.
api.on(type, handler)—type-filtered receiver (see §6)api.onConnectionChange(handler)— connection-state listener (see §7)api.configureLogging(option)— control framework logs (see §10)api.transport— read-only:'websocket' | 'polling' | null
Anything else becomes a path segment. Pick endpoint names that won't collide.
Subscriptions are inferred purely from the type of the single argument:
api.users(data) // RPC — data is anything except a function
api.users(callback) // SUBSCRIBE — the first arg is a function (a second function, onError, hears of a refusal by the server's onSubscribe)If you ever need to send a function-shaped payload (you don't, JSON can't carry it) you'd be stuck — but in practice this disambiguation is fine.
// Fire and forget (fine for beacons; nothing to wait for — see Timeouts below)
api.metrics.beacon({ event: 'page-view' })
// Await response
const user = await api.users.get({ id: 42 })
// With error handling
try {
await api.payments.charge({ amount, currency })
} catch (err) {
// err is an Error: the class the controller threw (err.name, err.message), plus
// err.code / err.data when the controller attached them; or a timeout/disconnect Error
if (err.code === 'CARD_DECLINED') showDeclined(err.data)
}
// Dynamic path segment: bracket access
const profile = await api.users[userId].profile()Each RPC's timer starts when you make the call (before 4.4.0 it started only when you attached .then / .catch, so await never timed out). await, .then(ok, err) and .catch() all see a timeout, and a call nobody awaits never raises an unhandled rejection.
api.metrics.beacon({ … }) // ✅ fire and forget: failure or timeout is swallowed
await api.metrics.beacon({ … }) // ✅ rejects after ~10 s if the server never repliesA controller that returns nothing still replies (undefined), so await api.x() settles.
Default timeouts: ~10 s total request, ~5 s connect window. If the connection isn't up in time, the queued send rejects.
Slow server work: a controller that needs longer than ~10 s must call this.keepalive() every few seconds. Each call sends { queryId, _keepalive: true }, which resets this timer without resolving the promise. A call that waited in the client's buffer for more than 10 s (laptop sleep, long disconnect) is refused by the server's freshness check, so keep mutations idempotent. Details, and how Bun's idleTimeout/sendPings fit in: server README, Keepalive and ping.
The Promise resolves with whatever the server controller returns. Errors thrown on the server come back as rejected Promises carrying an Error with the thrown class and message (err.name, err.message; no server stack) and the err.code / err.data the controller attached (throw Object.assign(new Error('declined'), { code: 'DECLINED', data: { retryIn: 30 } })). JSS encoding round-trips Date, RegExp, Map, Set, Error, undefined, and circular references — you don't need to JSON-stringify by hand.
const off = api.chat.lobby((msg) => {
console.log('new message:', msg)
})
// later
off()Things that are true:
- Idempotent unsubscribe — calling
off()twice is safe. - Reconnect-safe — on reconnection, the client resubscribes to every active channel automatically. You don't write retry logic.
- Multi-listener-friendly — multiple subscribers to the same channel share one server subscription. The client only sends
unsubscribewhen the last listener leaves. - Errors in callbacks are swallowed — if your callback throws, it is caught and logged; the channel keeps delivering. Don't rely on a thrown error to bubble up — if you need that, do it yourself.
Subscription callbacks receive just the data payload the server published. (Compare with api.on, which gets the wrapped {err, type, data} envelope — see §6.)
There are two ways to listen, and they are subtly different:
// (a) Channel subscription — for pub/sub channels you explicitly subscribe to
const off = api.chat.lobby((data) => …)
// (b) api.on — type-filtered receiver for any inbound message of that type
api.on('init', ({ err, type, data }) => …)
api.on('chat.lobby', ({ err, type, data }) => …)- Use
api.foo(cb)for named channels you actively subscribe to. The client tracks the subscription and resubscribes after a reconnect. - Use
api.on(type, handler)when the server pushes a typed message you want to receive but didn't subscribe to as a channel — e.g. server-initiated'init'payloads, presence pings, anything broadcast to all clients.
| Pattern | Callback receives |
|---|---|
api.foo(cb) |
cb(data) — raw data |
api.on(type, h) |
h({ err, type, data }) envelope |
This catches people out. Pick one style per channel and stick with it across your codebase.
Six states, exposed verbatim:
'offline' → navigator.onLine === false (browser unplugged)
'walled' → captive portal detected (hotel/airport WiFi)
'connecting' → handshake in progress
'connected' → ready
'disconnected' → was connected, link dropped (auto-reconnect will retry)
'closing' → graceful shutdown
const off = api.onConnectionChange((state) => {
if (state === 'connected') showOnline()
else if (state === 'walled') showCaptivePortalBanner()
else showOffline()
})
// off() // unsubscribeImportant behaviors:
- The handler is called immediately on registration with the current state. You don't need to read state separately.
onConnectionChangereturns an unsubscribe function. Always call it on cleanup.'walled'is a real state and a real UX concern — the client pings/api/ape/pingand validates the timestamp; a captive-portal redirect serves an HTML page instead, and that's how'walled'is detected. A server that is simply down or restarting gives no answer at all (refused or timed out), which is'disconnected', retried with backoff from 0.5 s to 30 s (before 4.4.0 it read as'walled'with a 30 s wait). Treat it as "offline-ish" but with an actionable banner ("Sign in to this network").
The client tries WebSocket first; if no upgrade response arrives within ~4 s, it falls back to HTTP streaming (chunked long-poll on GET /api/ape/poll). From polling mode it keeps trying to upgrade to WebSocket every ~30 s.
api.transport // 'websocket' | 'polling' | null- Don't assume WebSocket is available. Hostile networks, corporate proxies, and old load balancers strip the upgrade.
- Use
api.transportto surface a degraded-mode UX: "Slow connection mode" banner whenpolling, normal whenwebsocket. - Bun servers are WebSocket-only. There is no
/ape/pollfallback and no/ape/datafile transfer under Bun, so the client's polling fallback has nothing to talk to; that is fine on loopback. - Latency is higher on polling. Subscriptions still work. Big binary uploads still work (they go over HTTP either way).
You can put a Blob, ArrayBuffer, or TypedArray anywhere inside a payload. The client tags the location, uploads the binary out-of-band over HTTP, and the server controller receives a normal object with the binaries reattached. The reverse works too — server-sent binaries arrive as ArrayBuffer in your handler.
const file = await fileInput.files[0].arrayBuffer()
await api.documents.upload({
title: 'Q4 report',
body: file, // ← binary anywhere is fine
attachments: [{ name: 'chart.png', data: chartBlob }],
})Mechanics (you don't need to handle them, but knowing helps):
- Binaries get a JSS tag in the JSON envelope:
<!A>(ArrayBuffer/TypedArray) or<!B>(Blob). - Each binary is
PUT'd to/api/ape/data/{queryId}/{hash}after the JSON envelope is sent.
When a server controller returns or publishes a payload containing binaries, you get them back as ArrayBuffer at the same key in the response — the wire tags <!L> (server-hosted) and <!F> (peer-shared, with built-in retry/backoff for "sender hasn't uploaded yet") are unwrapped for you.
const { image } = await api.thumbs.get({ id })
// image is an ArrayBuffer
const blob = new Blob([image], { type: 'image/png' })
const url = URL.createObjectURL(blob)Don't forget to URL.revokeObjectURL(url) when you're done — and do not store raw ArrayBuffers in Redux/Zustand (see §15).
api.configureLogging(false) // silence all framework logs
api.configureLogging(true) // use console (default)
api.configureLogging({ log: noop, warn: console.warn, error: console.error })- Call it before your first RPC if possible. It's safe to call later but you may miss boot-time logs.
- Same effect as the named export
configureApeLoggingfromapi-ape.
Useful environment toggles you may see in repo code:
APIAPE_PUBSUB_LOG— gate the per-publish log line on the server side; not a client switch but worth knowing for debugging round-trips.
The default export checks typeof window on import. On the server it returns a dummy that swallows calls without errors. That's nice — but it also means api calls in server code do nothing. Two rules:
- Don't call
apifromgetServerSideProps, route handlers, or middleware. Use a normalfetchor your server client. - Don't
export const data = await api.x()at module top level. With React Server Components or any module evaluated server-side, you'll silently no-op on the server and also fire on the client at unpredictable times.
The pattern that works:
// app/page.tsx (client component)
'use client'
import { useEffect } from 'react'
import api from 'api-ape'
export default function Page() {
useEffect(() => {
let cancelled = false
api.feed.list().then(d => { if (!cancelled) setFeed(d) })
return () => { cancelled = true }
}, [])
}You asked specifically about leveraging api-ape from client-side store-level reducers. Here's the philosophy first, then three worked examples.
Components must not own subscriptions or RPCs directly. Components dispatch intents; a single bridge layer (middleware / store action / hook) translates intents into api calls and translates incoming server messages into dispatched actions. Reducers stay pure.
component → dispatch(intent)
│
▼
bridge / middleware ─────→ api.x(payload) (RPC out)
▲ │
│ ▼
api.foo(cb) ←────────── server push (event in)
│
▼
dispatch(serverEvent)
│
▼
reducer (pure)
│
▼
state → component
This gives you: one client per tab, central retry/cleanup, testable reducers, no leaks, and reconnect-aware refetches in one place.
store/apeMiddleware.js:
import api from 'api-ape'
const subscriptions = new Map() // channel → unsubscribe
export const apeMiddleware = (store) => {
// Connection state → store
api.onConnectionChange((state) => {
store.dispatch({ type: 'ape/connectionChanged', payload: state })
})
return (next) => (action) => {
switch (action.type) {
case 'ape/subscribe': {
const { channel } = action.payload
if (subscriptions.has(channel)) break
const off = api[channel.replace(/^\//, '').replace(/\//g, '.')](
(data) => store.dispatch({
type: `ape/event/${channel}`,
payload: data,
})
)
subscriptions.set(channel, off)
break
}
case 'ape/unsubscribe': {
const off = subscriptions.get(action.payload.channel)
if (off) { off(); subscriptions.delete(action.payload.channel) }
break
}
}
return next(action)
}
}store/chatSlice.js:
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import api from 'api-ape'
export const sendMessage = createAsyncThunk(
'chat/send',
async ({ room, text }, { rejectWithValue }) => {
try { return await api.chat.send({ room, text }) }
catch (err) { return rejectWithValue(String(err)) }
}
)
const slice = createSlice({
name: 'chat',
initialState: { messages: [], status: 'idle', connection: 'disconnected' },
reducers: {},
extraReducers: (b) => {
b.addCase('ape/connectionChanged', (s, a) => { s.connection = a.payload })
b.addCase('ape/event//chat/lobby', (s, a) => { s.messages.push(a.payload) })
b.addCase(sendMessage.pending, (s) => { s.status = 'sending' })
b.addCase(sendMessage.fulfilled, (s) => { s.status = 'idle' })
b.addCase(sendMessage.rejected, (s) => { s.status = 'error' })
},
})
export default slice.reducerMounting & cleanup (in your root component):
useEffect(() => {
dispatch({ type: 'ape/subscribe', payload: { channel: '/chat/lobby' } })
return () => dispatch({ type: 'ape/unsubscribe', payload: { channel: '/chat/lobby' } })
}, [dispatch])Why this is good: reducer is pure, the middleware is the only place that knows about api, every channel/event is a dispatched action you can log/replay/test.
import { create } from 'zustand'
import api from 'api-ape'
const subs = new Map()
export const useApeStore = create((set, get) => ({
connection: 'disconnected',
data: {},
status: {},
init: () => {
api.onConnectionChange((state) => set({ connection: state }))
},
rpc: async (path, payload) => {
const key = path
set((s) => ({ status: { ...s.status, [key]: 'pending' } }))
try {
const segs = path.replace(/^\//, '').split('/')
const fn = segs.reduce((a, k) => a[k], api)
const result = await fn(payload)
set((s) => ({
data: { ...s.data, [key]: result },
status: { ...s.status, [key]: 'idle' },
}))
return result
} catch (err) {
set((s) => ({ status: { ...s.status, [key]: 'error' } }))
throw err
}
},
subscribe: (channel) => {
if (subs.has(channel)) return
const segs = channel.replace(/^\//, '').split('/')
const fn = segs.reduce((a, k) => a[k], api)
const off = fn((event) => {
set((s) => ({
data: { ...s.data, [channel]: event },
}))
})
subs.set(channel, off)
},
unsubscribe: (channel) => {
const off = subs.get(channel)
if (off) { off(); subs.delete(channel) }
},
}))
// at app boot:
useApeStore.getState().init()Components:
function Lobby() {
const subscribe = useApeStore(s => s.subscribe)
const unsubscribe = useApeStore(s => s.unsubscribe)
const event = useApeStore(s => s.data['/chat/lobby'])
useEffect(() => {
subscribe('/chat/lobby')
return () => unsubscribe('/chat/lobby')
}, [subscribe, unsubscribe])
return <Messages event={event} />
}import api from 'api-ape'
import { createContext, useContext, useEffect, useReducer } from 'react'
const initial = { connection: 'disconnected', messages: [] }
function reducer(state, action) {
switch (action.type) {
case 'connection': return { ...state, connection: action.state }
case 'message': return { ...state, messages: [...state.messages, action.msg] }
case 'reset': return { ...state, messages: [] }
default: return state
}
}
const Ctx = createContext(null)
export function ApeProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initial)
useEffect(() => {
const offState = api.onConnectionChange(s =>
dispatch({ type: 'connection', state: s })
)
const offChan = api.chat.lobby(msg =>
dispatch({ type: 'message', msg })
)
return () => { offState(); offChan() }
}, [])
const send = (text) => api.chat.send({ text })
return <Ctx.Provider value={{ state, send }}>{children}</Ctx.Provider>
}
export const useApe = () => useContext(Ctx)- Normalize on the way in. Store
{ byId, ids }, not raw arrays of payloads. It pays dividends the moment subscriptions and RPCs both edit the same list. - Never store raw
ArrayBuffer/Blobin your store. Convert toURL.createObjectURL+ a tiny ref-counted blob cache, store the URL string, and revoke when refcount hits zero. - Always return the unsubscribe from
useEffect. In React StrictMode the effect runs twice on mount; not returning the unsubscriber leaks subscribers. - Reset on reconnect when needed. A reducer can listen for
connection === 'connected'and request a fresh snapshot via RPC — the cleanest way to handle "I missed events while disconnected".
// reducer fragment
case 'connection':
if (action.state === 'connected') {
api.feed.snapshot().then(snap =>
dispatchOuter({ type: 'feed/snapshot', snap }))
}
return { ...state, connection: action.state }function send(text) {
const tmpId = crypto.randomUUID()
dispatch({ type: 'add', msg: { id: tmpId, text, pending: true } })
api.chat.send({ text }).then(
saved => dispatch({ type: 'confirm', tmpId, saved }),
err => dispatch({ type: 'rollback', tmpId, err: String(err) }),
)
}Subscribe once, treat events as deltas, never re-fetch on each event. On reconnect, fetch a snapshot and reconcile.
api.presence.online(setOnlineUsers) // subscription
api.presence.heartbeat({ at: Date.now() }) // periodic RPCUse bracket access on the proxy chain so a single endpoint name covers many rooms:
const off = api.rooms[roomId].messages(msg => …)Each bracket segment is just another property on the proxy — chained subscribes accumulate the full path (/rooms/<roomId>/messages) the same way chained RPCs do.
const [q, setQ] = useState('')
useEffect(() => {
const id = setTimeout(() => api.search({ q }).then(setResults), 200)
return () => clearTimeout(id)
}, [q])async function loadMore() {
const page = await api.feed.list({ cursor })
setCursor(page.next)
setItems(prev => [...prev, ...page.items])
}Centralize this — subscribe once at the bridge layer to onConnectionChange, dispatch a "reconnected" action, let each slice opt in via extraReducers.
- Single client per tab. Use the default export — let it auto-init. Never create more than one connection.
- Gate writes on
'connected'. Buffering exists for boot, not for arbitrary offline periods. Show a UI affordance and skip writes when not connected. - Treat
'walled'as offline-with-an-action. Show a "Sign in to this network" banner. awaitRPCs you care about. A call you do not look at fails silently (§4).- Type your endpoints. The TS types use generics —
api.users.create<{name:string},{id:string}>({name})lets the response bePromise<{id:string}>. - Centralize your
apiaccess in a bridge (middleware / store action / context). One place to refactor when the contract changes. - Normalize server data before it lands in your store.
- Always return the unsubscribe from any effect that subscribes.
- ❌ Don't open your own
connectSocket()per component. It works, but you'll spawn N sockets, race subscriptions, and make timeouts inconsistent. - ❌ Don't fire-and-forget RPCs you need acknowledged. No
.then/await⇒ nobody sees the failure. - ❌ Don't store
ArrayBuffer/Blobdirectly in Redux or Zustand. They're not serializable, they're huge, and your devtools will choke. Convert to object URLs and store the URL. - ❌ Don't subscribe in render bodies. Subscribe in effects. Component renders happen many times; subscriptions must be set up once and torn down once.
- ❌ Don't assume order between
onConnectionChange('connected')and your first subscription event. They're independent. If you need "subscribe ⇒ confirmed ⇒ event", use an RPC that returns confirmation, then subscribe. - ❌ Don't re-export
apifrom a file that also runs on the server. It'll dummy-out there and confuse you when calls "vanish". Keepapiimports in client-only code paths. - ❌ Don't manually retry on disconnect. The client already does it; you'll double up.
- ❌ Don't put logic in subscription callbacks that throws on bad input. Errors get swallowed and logged; the channel keeps delivering. Validate, branch, and dispatch instead.
- Mirror your folder structure in endpoint names. If your server controllers live at
controllers/users/create.js, the client call isapi.users.create(…). This makes greps trivial. - Use bracket access for resource IDs:
api.users[id]()keeps the path entirely on the proxy chain — no string-stitching inside the call. - Surface "degraded mode" when
api.transport === 'polling'— a small "Slow mode" badge prevents support tickets. - Captive-portal banner: when
state === 'walled', show a button that openshttp://neverssl.com(or your own portal-trigger URL) in a new tab — most OSes will pop the captive-portal sign-in. - Drain logs in production:
api.configureLogging({ log: () => {}, warn: console.warn, error: Sentry.captureException }). - Unit-test reducers without the network. Because
apilives in middleware, your reducers are pure — just dispatch the actions a real connection would. - Test integration with the simulator harness in this repo (
simulator/harness/) — it boots a real api-ape server and a fake client without needing browser plumbing. - Idempotent unsubscribes. Calling
off()more than once is safe — useful when both an effect cleanup and a manual "leave room" button can fire.
// Default export
import api from 'api-ape'
// RPC
const r = await api.path.nested(payload) // /path/nested
const r = await api.users[id]() // /users/{id}
const r = await api.users[id](payload) // /users/{id} + body
// Subscriptions (single function arg)
const off = api.channel.name(data => …) // returns unsubscribe
const off = api.rooms[id](msg => …) // dynamic segment via bracket
// Type-filtered receiver
api.on('init', ({err, type, data}) => …)
api.on('chat', ({err, type, data}) => …)
// Connection state
const off = api.onConnectionChange(state => …)
// state: 'offline' | 'walled' | 'disconnected' | 'connecting' | 'connected' | 'closing'
// Logging
api.configureLogging(false)
api.configureLogging({ log, warn, error, info, debug })
// Transport introspection
api.transport // 'websocket' | 'polling' | null
// Lower-level (rarely needed)
import connectSocket from 'api-ape/client/connectSocket'
const { sender, setOnReceiver, onConnectionChange, transport } = connectSocket()
connectSocket.autoReconnect()
connectSocket.configureLogging(false)
connectSocket.ConnectionState // enum object
// Files
await api.documents.upload({ title, body: arrayBuffer, attach: blob })
const { image } = await api.thumbs.get({ id }) // image is ArrayBufferThat's the whole client. Build outward from §3 (the proxy mental model), put api behind a bridge (§12), and keep reducers pure.