http-air 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.claude/settings.local.json +11 -0
  2. package/README.md +313 -0
  3. package/index.d.ts +0 -0
  4. package/index.js +1 -0
  5. package/lib/client/batch.d.ts +15 -0
  6. package/lib/client/batch.js +42 -0
  7. package/lib/client/client.d.ts +28 -0
  8. package/lib/client/client.js +61 -0
  9. package/lib/client/events.d.ts +21 -0
  10. package/lib/client/events.js +217 -0
  11. package/lib/client/index.d.ts +5 -0
  12. package/lib/client/index.js +9 -0
  13. package/lib/client/rpc.d.ts +24 -0
  14. package/lib/client/rpc.js +118 -0
  15. package/lib/server/adapters/express.d.ts +2 -0
  16. package/lib/server/adapters/express.js +27 -0
  17. package/lib/server/adapters/hono.d.ts +3 -0
  18. package/lib/server/adapters/hono.js +34 -0
  19. package/lib/server/adapters/micro.d.ts +3 -0
  20. package/lib/server/adapters/micro.js +24 -0
  21. package/lib/server/adapters/next.d.ts +3 -0
  22. package/lib/server/adapters/next.js +22 -0
  23. package/lib/server/events.d.ts +20 -0
  24. package/lib/server/events.js +141 -0
  25. package/lib/server/index.d.ts +6 -0
  26. package/lib/server/index.js +11 -0
  27. package/lib/server/router.d.ts +23 -0
  28. package/lib/server/router.js +20 -0
  29. package/lib/server/rpc.d.ts +9 -0
  30. package/lib/server/rpc.js +55 -0
  31. package/lib/server/server.d.ts +12 -0
  32. package/lib/server/server.js +27 -0
  33. package/lib/shared/json-stream.d.ts +26 -0
  34. package/lib/shared/json-stream.js +57 -0
  35. package/lib/shared/message.d.ts +13 -0
  36. package/lib/shared/message.js +2 -0
  37. package/lib/shared/stream-constants.d.ts +6 -0
  38. package/lib/shared/stream-constants.js +9 -0
  39. package/package.json +38 -0
  40. package/tsconfig.build.json +5 -0
  41. package/tsconfig.json +110 -0
@@ -0,0 +1,11 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(npm run build:*)",
5
+ "Bash(npm test)",
6
+ "Bash(grep:*)",
7
+ "Bash(ls:*)",
8
+ "Bash(npx tsc:*)"
9
+ ]
10
+ }
11
+ }
package/README.md ADDED
@@ -0,0 +1,313 @@
1
+ # http-air
2
+
3
+ HTTP library for batched RPC calls and real-time server-push over a single endpoint.
4
+
5
+ - **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.
6
+ - **Events** — server pushes named events to subscribed clients over a persistent HTTP connection, with heartbeat and auto-reconnect.
7
+ - **SWR cache** — RPC calls support stale-while-revalidate: return a cached value immediately and revalidate in the background, with a cancel handle.
8
+
9
+ ## Why http-air
10
+
11
+ **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.
12
+
13
+ **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.
14
+
15
+ **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.
16
+
17
+ **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.
18
+
19
+ **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.
20
+
21
+ **Framework agnostic.** Ships with adapters for Express, Next.js, Hono, and Micro. Any other framework is supported by implementing a two-method interface.
22
+
23
+ **Zero config to start.** Both `Client` and `Server` work out of the box. URL, fetch, deduplication, and reconnection all have sensible defaults.
24
+
25
+ ## Quick start
26
+
27
+ **Server**
28
+
29
+ ```ts
30
+ import express from 'express'
31
+ import { Server } from 'http-air/server'
32
+ import { expressHandler } from 'http-air/server/adapters/express'
33
+
34
+ const server = new Server()
35
+
36
+ server.handleRpc(async ({ method, params }) => {
37
+ return myService[method](...params)
38
+ })
39
+
40
+ // push an event whenever something happens
41
+ setInterval(() => {
42
+ server.notifyEvent('price-update', { symbol: 'BTC', price: 70000 })
43
+ }, 5000)
44
+
45
+ const app = express()
46
+ app.post('/api', expressHandler(server))
47
+ ```
48
+
49
+ **Client**
50
+
51
+ ```ts
52
+ import { Client } from 'http-air/client'
53
+
54
+ const client = new Client({ url: '/api' })
55
+
56
+ // RPC
57
+ const result = await client.callRpc('double', [10])
58
+
59
+ // Events
60
+ const unsubscribeEvent = client.subscribeEvent('price-update', (event) => {
61
+ console.log(event.data)
62
+ })
63
+
64
+ // later
65
+ unsubscribeEvent()
66
+ client.disconnect()
67
+ ```
68
+
69
+ ---
70
+
71
+ ## Server
72
+
73
+ `Server` composes the router, RPC handler, and event emitter into a single instance.
74
+
75
+ ```ts
76
+ import { Server } from 'http-air/server'
77
+
78
+ const server = new Server()
79
+
80
+ server.handleRpc(async ({ method, params }) => {
81
+ return myService[method](...params)
82
+ })
83
+
84
+ // push to all subscribers whenever something happens
85
+ server.notifyEvent('price-update', { symbol: 'BTC', price: 70000 })
86
+ ```
87
+
88
+ ### Framework adapters
89
+
90
+ Each adapter takes a `Server` instance and returns a framework-specific handler. Body parsing is handled inside the adapter.
91
+
92
+ **Express**
93
+ ```ts
94
+ import express from 'express'
95
+ import { expressHandler } from 'http-air/server/adapters/express'
96
+
97
+ const app = express()
98
+ app.post('/api', expressHandler(server))
99
+ ```
100
+
101
+ **Next.js**
102
+ ```ts
103
+ import { nextHandler } from 'http-air/server/adapters/next'
104
+
105
+ export default nextHandler(server)
106
+ ```
107
+
108
+ **Hono**
109
+ ```ts
110
+ import { Hono } from 'hono'
111
+ import { honoHandler } from 'http-air/server/adapters/hono'
112
+
113
+ const app = new Hono()
114
+ app.post('/api', honoHandler(server))
115
+ ```
116
+
117
+ **Micro**
118
+ ```ts
119
+ import { microHandler } from 'http-air/server/adapters/micro'
120
+
121
+ export default microHandler(server)
122
+ ```
123
+
124
+ ### Custom adapter
125
+
126
+ Implement `ServerReq` and `ServerRes` to support any other framework:
127
+
128
+ ```ts
129
+ import { ServerReq, ServerRes } from 'http-air/server'
130
+
131
+ server.handleHttp(
132
+ {
133
+ getHeader: (key) => req.headers[key],
134
+ getUrl: () => req.url,
135
+ getMethod: () => req.method,
136
+ getBody: () => req.body,
137
+ },
138
+ {
139
+ write: (content) => res.write(content),
140
+ isClosed: () => res.writableEnded,
141
+ writeHead: (status, headers) => res.writeHead(status, headers),
142
+ flushHeaders: () => res.flushHeaders(),
143
+ onClose: (cb) => res.on('close', cb),
144
+ end: () => res.end(),
145
+ destroy: () => res.destroy(),
146
+ }
147
+ )
148
+ ```
149
+
150
+ ### Lower-level API
151
+
152
+ For custom setups, use the building blocks directly:
153
+
154
+ ```ts
155
+ import { Router, RpcServer, EventsServer } from 'http-air/server'
156
+
157
+ const router = new Router()
158
+ const rpc = new RpcServer(router)
159
+ rpc.handle(handler)
160
+ const events = new EventsServer(router)
161
+ ```
162
+
163
+ ---
164
+
165
+ ## Client
166
+
167
+ `Client` composes `RpcClient` and `EventsClient` behind a single instance. Use it when you need both RPC and events from the same endpoint.
168
+
169
+ ```ts
170
+ import { Client } from 'http-air/client'
171
+
172
+ const client = new Client({ url: '/api' })
173
+ ```
174
+
175
+ All clients accept the same base config:
176
+
177
+ ```ts
178
+ interface ClientConfig {
179
+ url?: string // endpoint URL, defaults to current page location
180
+ fetch?: typeof globalThis.fetch // custom fetch implementation
181
+ init?: RequestInit // base RequestInit merged into every request
182
+ }
183
+ ```
184
+
185
+ `RpcClient` and `Client` also accept:
186
+
187
+ ```ts
188
+ interface RpcClientConfig extends ClientConfig {
189
+ batch?: boolean // group calls in the same tick, default true
190
+ deduplicate?: boolean // deduplicate calls by method+params, default true
191
+ onResponse?: (resp: ResponseMessage) => void // called on each result or error message
192
+ }
193
+ ```
194
+
195
+ ### RPC
196
+
197
+ Calls made in the same tick are grouped into one HTTP request automatically. Duplicate calls (same method + params) within a batch are deduplicated.
198
+
199
+ ```ts
200
+ import { RpcClient } from 'http-air/client'
201
+
202
+ const client = new RpcClient({ url: '/api' })
203
+
204
+ // these three calls go out in a single request on the next tick
205
+ const [r1, r2, r3] = await Promise.all([
206
+ client.call('double', [10]),
207
+ client.call('add', [1, 2]),
208
+ client.call('double', [10]), // deduplicated — resolved from the same result as r1
209
+ ])
210
+ ```
211
+
212
+ ### SWR (stale-while-revalidate)
213
+
214
+ `callSwr` returns a tuple `[stale, fresh, cancel]` — immediately a cached (stale) value and a background revalidation promise. If no cache exists both point to the same request. If the cached value is within `ttl` (ms) no request is made.
215
+
216
+ ```ts
217
+ const [stale, fresh, cancel] = client.callSwr('getUser', [42], 5000)
218
+
219
+ // render immediately with cached data
220
+ const user = await stale
221
+
222
+ // update once revalidated
223
+ const updated = await fresh
224
+
225
+ // cancel the background revalidation (e.g. component unmounted)
226
+ cancel()
227
+ ```
228
+
229
+ Works well inside a React `useEffect`:
230
+
231
+ ```ts
232
+ useEffect(() => {
233
+ const [stale, fresh, cancel] = rpcClient.callSwr('getUser', [id], 5_000)
234
+ stale.then(setUser)
235
+ fresh.then(setUser)
236
+ return cancel
237
+ }, [id])
238
+ ```
239
+
240
+ `Client` exposes the same method as `callRpcSwr`.
241
+
242
+ ---
243
+
244
+ ### Events
245
+
246
+ 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.
247
+
248
+ ```ts
249
+ import { EventsClient } from 'http-air/client'
250
+
251
+ const events = new EventsClient({ url: '/api' })
252
+
253
+ const unsubscribe = events.subscribe('price-update', (event) => {
254
+ console.log(event.data)
255
+ })
256
+
257
+ // later — disconnects automatically when no subscriptions remain
258
+ unsubscribe()
259
+ ```
260
+
261
+ ---
262
+
263
+ ## Protocol
264
+
265
+ All requests use `POST` with the `action` query param. Unknown actions return `400`.
266
+
267
+ ### RPC (`action=rpc`)
268
+
269
+ 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`.
270
+
271
+ ```
272
+ → POST /api?action=rpc
273
+ {"index":0,"method":"double","params":[5]}\n\n
274
+ {"index":1,"method":"increment","params":[9]}\n\n
275
+
276
+ ← 207
277
+ {"index":1,"result":10}\n\n
278
+ {"index":0,"result":10}\n\n
279
+ ```
280
+
281
+ Errors are returned inline:
282
+
283
+ ```
284
+ {"index":0,"error":{"name":"Error","message":"something went wrong"}}\n\n
285
+ ```
286
+
287
+ ### Events (`action=events-connect`)
288
+
289
+ 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.
290
+
291
+ ```
292
+ → POST /api?action=events-connect
293
+ ← 200 (connection held open)
294
+ {"type":"heartbeat","ts":1234567890}\n\n
295
+ {"type":"event","name":"price-update","data":{...}}\n\n
296
+ ```
297
+
298
+ Subscribe and unsubscribe send event names as `\n\n`-delimited objects in the body:
299
+
300
+ ```
301
+ → POST /api?action=events-subscribe
302
+ {"name":"price-update"}\n\n
303
+ {"name":"order-filled"}\n\n
304
+
305
+ → POST /api?action=events-unsubscribe
306
+ {"name":"order-filled"}\n\n
307
+ ```
308
+
309
+ ---
310
+
311
+ ## License
312
+
313
+ MIT © [Yosbel Marín](https://github.com/yosbelms)
package/index.d.ts ADDED
File without changes
package/index.js ADDED
@@ -0,0 +1 @@
1
+ "use strict";
@@ -0,0 +1,15 @@
1
+ export interface BatchOptions<T> {
2
+ /** If provided, duplicate keys are ignored while items are queued */
3
+ key?: (item: T) => string;
4
+ }
5
+ /** Generic batch accumulator. Collects items and auto-flushes on next tick or at max size. */
6
+ export declare class Batch<T> {
7
+ private onFlush;
8
+ private items;
9
+ private keySet;
10
+ private scheduleTimeout?;
11
+ private key?;
12
+ constructor(onFlush: (items: T[]) => void, options?: BatchOptions<T>);
13
+ add(item: T): void;
14
+ flush(): void;
15
+ }
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Batch = void 0;
4
+ const MAX_BATCH_SIZE = 1000;
5
+ /** Generic batch accumulator. Collects items and auto-flushes on next tick or at max size. */
6
+ class Batch {
7
+ onFlush;
8
+ items = [];
9
+ keySet = new Set();
10
+ scheduleTimeout;
11
+ key;
12
+ constructor(onFlush, options = {}) {
13
+ this.onFlush = onFlush;
14
+ this.key = options.key;
15
+ }
16
+ add(item) {
17
+ if (this.key) {
18
+ const k = this.key(item);
19
+ if (this.keySet.has(k))
20
+ return;
21
+ this.keySet.add(k);
22
+ }
23
+ this.items.push(item);
24
+ if (this.items.length >= MAX_BATCH_SIZE) {
25
+ this.flush();
26
+ }
27
+ else if (this.scheduleTimeout === void 0) {
28
+ this.scheduleTimeout = setTimeout(() => this.flush(), 0);
29
+ }
30
+ }
31
+ flush() {
32
+ clearTimeout(this.scheduleTimeout);
33
+ this.scheduleTimeout = void 0;
34
+ if (this.items.length === 0)
35
+ return;
36
+ const items = this.items;
37
+ this.items = [];
38
+ this.keySet.clear();
39
+ this.onFlush(items);
40
+ }
41
+ }
42
+ exports.Batch = Batch;
@@ -0,0 +1,28 @@
1
+ import { RpcClientConfig, SwrResult } from './rpc';
2
+ export interface ClientConfig {
3
+ /** Endpoint URL. Defaults to current page location */
4
+ url?: string;
5
+ /** Fetch implementation. Defaults to globalThis.fetch */
6
+ fetch?: typeof globalThis.fetch;
7
+ /** Base RequestInit merged into every request */
8
+ init?: RequestInit;
9
+ }
10
+ /** Resolve url and fetch from config */
11
+ export declare function resolveConfig(config: ClientConfig): {
12
+ url: any;
13
+ fetchFn: typeof fetch;
14
+ };
15
+ /** Serialize items to \n\n-delimited JSON */
16
+ export declare function toNdjson<T>(items: T[]): string;
17
+ export declare class Client {
18
+ private rpc;
19
+ private events;
20
+ constructor(config?: RpcClientConfig);
21
+ callRpc(method: string, params: any[]): Promise<any>;
22
+ callRpcSwr(method: string, params: any[], ttl: number): SwrResult;
23
+ subscribeEvent(eventName: string | string[], handler: (data: any) => void): () => void;
24
+ unsubscribeEvent(eventName: string | string[], handler: (data: any) => void): void;
25
+ disconnect(): void;
26
+ }
27
+ /** Read a response body as text chunks */
28
+ export declare function readResponseBody(response: Response, onChunk: (chunk: string) => void, onDone: () => void): Promise<void>;
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Client = void 0;
4
+ exports.resolveConfig = resolveConfig;
5
+ exports.toNdjson = toNdjson;
6
+ exports.readResponseBody = readResponseBody;
7
+ const stream_constants_1 = require("../shared/stream-constants");
8
+ const rpc_1 = require("./rpc");
9
+ const events_1 = require("./events");
10
+ /** Resolve url and fetch from config */
11
+ function resolveConfig(config) {
12
+ return {
13
+ url: config.url ?? globalThis?.location?.href ?? 'http://localhost/',
14
+ fetchFn: config.fetch ?? globalThis.fetch,
15
+ };
16
+ }
17
+ /** Serialize items to \n\n-delimited JSON */
18
+ function toNdjson(items) {
19
+ return items.map(i => JSON.stringify(i)).join(stream_constants_1.DELIMITER) + stream_constants_1.DELIMITER;
20
+ }
21
+ class Client {
22
+ rpc;
23
+ events;
24
+ constructor(config = {}) {
25
+ this.rpc = new rpc_1.RpcClient(config);
26
+ this.events = new events_1.EventsClient(config);
27
+ }
28
+ callRpc(method, params) {
29
+ return this.rpc.call(method, params);
30
+ }
31
+ callRpcSwr(method, params, ttl) {
32
+ return this.rpc.callSwr(method, params, ttl);
33
+ }
34
+ subscribeEvent(eventName, handler) {
35
+ return this.events.subscribe(eventName, handler);
36
+ }
37
+ unsubscribeEvent(eventName, handler) {
38
+ this.events.unsubscribe(eventName, handler);
39
+ }
40
+ disconnect() {
41
+ this.events.disconnect();
42
+ }
43
+ }
44
+ exports.Client = Client;
45
+ /** Read a response body as text chunks */
46
+ async function readResponseBody(response, onChunk, onDone) {
47
+ const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
48
+ try {
49
+ while (true) {
50
+ const { value, done } = await reader.read();
51
+ if (done) {
52
+ onDone();
53
+ return;
54
+ }
55
+ onChunk(value);
56
+ }
57
+ }
58
+ catch {
59
+ onDone();
60
+ }
61
+ }
@@ -0,0 +1,21 @@
1
+ import { ClientConfig } from './client';
2
+ export declare class EventsClient {
3
+ private fetcher;
4
+ private sessionId;
5
+ private serverId;
6
+ private response;
7
+ private abortController;
8
+ private status;
9
+ private listenersMap;
10
+ private subscribedSet;
11
+ private subscribeBatch;
12
+ private unsubscribeBatch;
13
+ constructor(config?: ClientConfig);
14
+ private buildFetcher;
15
+ private connect;
16
+ private performConnect;
17
+ private read;
18
+ disconnect(): void;
19
+ subscribe(eventName: string | string[], handler: (data: any) => void): () => void;
20
+ unsubscribe(eventName: string | string[], handler: (data: any) => void): void;
21
+ }