HTTP library for batched RPC calls and real-time server-push over a single endpoint.
- RPC — calls made in the same tick are grouped into one HTTP request. The server executes them concurrently and streams each result back as it resolves, matched to the original call by index. The client resolves each promise individually as its result arrives.
- Events — server pushes named events to subscribed clients over a persistent HTTP connection, with heartbeat and auto-reconnect.
Single endpoint, two transports. RPC and event streaming share one URL and one framework route. No WebSocket upgrade negotiation, no separate SSE endpoint to manage.
Automatic batching. RPC calls queued in the same tick are merged into one HTTP request with no configuration. Ten calls become one round trip. Duplicate calls (same method and params) are deduplicated and resolved from the same result.
Streaming results. The server responds to each batch as soon as individual calls resolve — fast methods return immediately without waiting for slow ones. The client matches results to promises by index.
Plain HTTP. Works through any proxy, CDN, or load balancer that handles standard HTTP POST requests. No protocol upgrades, no persistent TCP connections for RPC.
Auto-reconnect built in. The event connection reconnects automatically on drop, resubscribes to all active events, and detects dead connections via heartbeat — without any user code.
Framework agnostic. Ships with adapters for Express, Next.js, Hono, and Micro. Any other framework is supported by implementing a two-method interface.
Zero config to start. Both Client and Server work out of the box. URL, fetch, deduplication, and reconnection all have sensible defaults.
Server
import express from 'express'
import { Server } from 'http-air/server'
import { expressHandler } from 'http-air/server/adapters/express'
const server = new Server()
server.handleRpc(async ({ method, params }) => {
return myService[method](...params)
})
// push an event whenever something happens
setInterval(() => {
server.notifyEvent('price-update', { symbol: 'BTC', price: 70000 })
}, 5000)
const app = express()
app.post('/api', expressHandler(server))Client
import { Client } from 'http-air/client'
const client = new Client({ url: '/api' })
// RPC
const result = await client.callRpc('double', [10])
// Events
const unsubscribeEvent = client.subscribeEvent('price-update', (event) => {
console.log(event.data)
})
// later
unsubscribeEvent()
client.disconnect()Server composes the router, RPC handler, and event emitter into a single instance.
import { Server } from 'http-air/server'
const server = new Server()
server.handleRpc(async ({ method, params }) => {
return myService[method](...params)
})
// validate session before accepting an events connection
server.handleEventsConnect(async (req, res) => {
const token = req.getHeader('authorization')
if (!isValid(token)) throw new Error('unauthorized')
})
// push to all subscribers whenever something happens
server.notifyEvent('price-update', { symbol: 'BTC', price: 70000 })Each adapter takes a Server instance and returns a framework-specific handler. Body parsing is handled inside the adapter.
Express
import express from 'express'
import { expressHandler } from 'http-air/server/adapters/express'
const app = express()
app.post('/api', expressHandler(server))Next.js
import { nextHandler } from 'http-air/server/adapters/next'
export default nextHandler(server)Hono
import { Hono } from 'hono'
import { honoHandler } from 'http-air/server/adapters/hono'
const app = new Hono()
app.post('/api', honoHandler(server))Micro
import { microHandler } from 'http-air/server/adapters/micro'
export default microHandler(server)Implement ServerReq and ServerRes to support any other framework:
import { ServerReq, ServerRes } from 'http-air/server'
server.handleHttp(
{
getHeader: (key) => req.headers[key],
getUrl: () => req.url,
getMethod: () => req.method,
getBody: () => req.body,
},
{
write: (content) => res.write(content),
isClosed: () => res.writableEnded,
writeHead: (status, headers) => res.writeHead(status, headers),
flushHeaders: () => res.flushHeaders(),
onClose: (cb) => res.on('close', cb),
end: () => res.end(),
destroy: () => res.destroy(),
}
)For custom setups, use the building blocks directly:
import { Router, RpcServer, EventsServer } from 'http-air/server'
const router = new Router()
const rpc = new RpcServer(router)
rpc.handle(handler)
const events = new EventsServer(router)
events.handleEventsConnect(async (req, res) => {
if (!isValid(req.getHeader('authorization'))) throw new Error('unauthorized')
})Client composes RpcClient and EventsClient behind a single instance. Use it when you need both RPC and events from the same endpoint.
import { Client } from 'http-air/client'
const client = new Client({ url: '/api' })All clients accept the same base config:
interface ClientConfig {
url?: string // endpoint URL, defaults to current page location
fetch?: typeof globalThis.fetch // custom fetch implementation
init?: RequestInit // base RequestInit merged into every request
}RpcClient and Client also accept:
interface RpcClientConfig extends ClientConfig {
batch?: boolean // group calls in the same tick, default true
deduplicate?: boolean // deduplicate calls by method+params, default true
onResponse?: (resp: ResponseMessage) => void // called on each result or error message
}Calls made in the same tick are grouped into one HTTP request automatically. Duplicate calls (same method + params) within a batch are deduplicated.
import { RpcClient } from 'http-air/client'
const client = new RpcClient({ url: '/api' })
// these three calls go out in a single request on the next tick
const [r1, r2, r3] = await Promise.all([
client.call('double', [10]),
client.call('add', [1, 2]),
client.call('double', [10]), // deduplicated — resolved from the same result as r1
])Connects automatically on the first subscribe() call and disconnects when all subscriptions are removed. Multiple subscribe/unsubscribe calls in the same tick are batched. Auto-reconnect is always enabled.
import { EventsClient } from 'http-air/client'
const events = new EventsClient({ url: '/api' })
const unsubscribe = events.subscribe('price-update', (event) => {
console.log(event.data)
})
// later — disconnects automatically when no subscriptions remain
unsubscribe()All requests use POST with the action query param. Unknown actions return 400.
Requests are sent as \n\n-delimited JSON. The server responds with 207 and streams results back as they complete — out of order is fine, matched by index.
→ POST /api?action=rpc
{"index":0,"method":"double","params":[5]}\n\n
{"index":1,"method":"increment","params":[9]}\n\n
← 207
{"index":1,"result":10}\n\n
{"index":0,"result":10}\n\n
Errors are returned inline:
{"index":0,"error":{"name":"Error","message":"something went wrong"}}\n\n
The client opens a persistent connection. The server streams \n\n-delimited JSON indefinitely. A heartbeat fires every 25 seconds to detect dead connections; the client reconnects automatically if one is missed.
→ POST /api?action=events-connect
← 200 (connection held open)
{"type":"heartbeat","ts":1234567890}\n\n
{"type":"event","name":"price-update","data":{...}}\n\n
Subscribe and unsubscribe send event names as \n\n-delimited objects in the body:
→ POST /api?action=events-subscribe
{"name":"price-update"}\n\n
{"name":"order-filled"}\n\n
→ POST /api?action=events-unsubscribe
{"name":"order-filled"}\n\n
MIT © Yosbel Marín