@arbitro/client 0.2.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.
- package/LICENSE +21 -0
- package/README.md +266 -0
- package/dist/index.d.mts +602 -0
- package/dist/index.d.ts +602 -0
- package/dist/index.js +1634 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1576 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +83 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 zenozaga and Arbitro contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# @arbitro/client
|
|
2
|
+
|
|
3
|
+
Official TypeScript client for the [Arbitro](https://github.com/arbitro-io/arbitro) stateful flow broker.
|
|
4
|
+
|
|
5
|
+
> Status: under active development. APIs, benchmarks, defaults, and reconnect behavior may still change.
|
|
6
|
+
|
|
7
|
+
`@arbitro/client` is built for the features that make Arbitro different from a plain pub/sub broker:
|
|
8
|
+
|
|
9
|
+
- durable streams and consumers
|
|
10
|
+
- exact `stream` / `consumer` introspection and idempotent `upsert`
|
|
11
|
+
- automatic reconnect + subscription reattach
|
|
12
|
+
- subject-level `maxSubjectInflights` — the strongest flow-control feature in the system
|
|
13
|
+
- live `ack_pending` queries per consumer
|
|
14
|
+
- client + broker metrics for observability
|
|
15
|
+
|
|
16
|
+
## Why Arbitro
|
|
17
|
+
|
|
18
|
+
The headline feature is **`maxSubjectInflights`** — per-subject in-flight caps with wildcard patterns inside a single consumer group, so one hot subject does not starve the rest of the workload.
|
|
19
|
+
|
|
20
|
+
That means you can run one worker pool and still say:
|
|
21
|
+
|
|
22
|
+
- `payments.critical` → max `1`
|
|
23
|
+
- `payments.heavy.>` → max `3`
|
|
24
|
+
- `payments.light.>` → max `10`
|
|
25
|
+
|
|
26
|
+
without splitting your topology into many queues just to protect fairness.
|
|
27
|
+
|
|
28
|
+
## Requirements
|
|
29
|
+
|
|
30
|
+
- Node.js `>= 20`
|
|
31
|
+
- Arbitro broker reachable on `127.0.0.1:9898` or your own `--addr`
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install @arbitro/client
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Run the broker locally (Docker)
|
|
40
|
+
|
|
41
|
+
The broker ships as a public Docker image (musl-static, ~3 MB, scratch base):
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
docker run --rm -p 9898:9898 ghcr.io/arbitro-io/arbitro-server:0.1.0
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Pin a major/minor tag for production:
|
|
48
|
+
|
|
49
|
+
- `ghcr.io/arbitro-io/arbitro-server:0.1.0` — immutable, recommended for prod
|
|
50
|
+
- `ghcr.io/arbitro-io/arbitro-server:0.1` — auto-updates within `0.1.*`
|
|
51
|
+
- `ghcr.io/arbitro-io/arbitro-server:latest` — last tagged release
|
|
52
|
+
|
|
53
|
+
## Quick start
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
import { ArbitroClient } from '@arbitro/client'
|
|
57
|
+
|
|
58
|
+
const client = new ArbitroClient({ servers: ['127.0.0.1:9898'] })
|
|
59
|
+
await client.connect()
|
|
60
|
+
|
|
61
|
+
await client.createStream('orders', {
|
|
62
|
+
subjectFilter: 'orders.>',
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
await client.createConsumer('orders', {
|
|
66
|
+
name: 'workers',
|
|
67
|
+
filter: 'orders.>',
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const sub = await client.subscribe('workers', (msg) => {
|
|
71
|
+
console.log(msg.data().toString())
|
|
72
|
+
msg.ack()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// publish() returns Promise<void> — caller decides whether to await.
|
|
76
|
+
await client.publish('orders', 'orders.new', Buffer.from('hello'))
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Publish
|
|
80
|
+
|
|
81
|
+
`publish()` returns `Promise<void>` that resolves once the broker confirms receipt (`RepOk`). The TypeScript idiom is "everything async, the caller chooses to await" — the same call site supports both semantics:
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
await client.publish('orders', 'orders.new', data) // wait for broker ack
|
|
85
|
+
client.publish('orders', 'orders.new', data) // fire-and-forget
|
|
86
|
+
client.publish('orders', 'orders.new', data).catch(onError) // async error path
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The broker emits `RepOk` regardless of whether the caller awaits, so there's no wire-level savings from a "no-reply" variant. That's the property that lets TS expose a single, ergonomic API. Rust's lazy-future model has to pick `publish` (fire-and-forget) vs `publish_sync` (awaited) at the call site.
|
|
90
|
+
|
|
91
|
+
## Durable management
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
await client.streamExists('orders') // true
|
|
95
|
+
await client.getStreamInfo('orders') // StreamInfo | null
|
|
96
|
+
await client.listStreams() // StreamInfo[]
|
|
97
|
+
|
|
98
|
+
await client.consumerExists('workers') // true
|
|
99
|
+
await client.getConsumerInfo('workers') // ConsumerInfo | null
|
|
100
|
+
await client.listConsumers() // ConsumerInfo[]
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Upsert / delete
|
|
104
|
+
|
|
105
|
+
`upsert*` is strict: it succeeds when the entity does not exist or already exists with an equivalent config. It does not silently mutate a conflicting durable entity.
|
|
106
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
await client.upsertStream('orders', { subjectFilter: 'orders.>' })
|
|
109
|
+
await client.upsertConsumer('orders', { name: 'workers', filter: 'orders.>' })
|
|
110
|
+
|
|
111
|
+
await client.deleteConsumer('workers')
|
|
112
|
+
await client.deleteStream('orders') // default: delete metadata + data
|
|
113
|
+
await client.deleteStream('orders', { deleteData: false }) // preserve journal bytes
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Stream / consumer sugar
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
const stream = client.stream('orders')
|
|
120
|
+
const consumer = stream.consumer({ name: 'workers', filter: 'orders.>' })
|
|
121
|
+
|
|
122
|
+
await consumer.create()
|
|
123
|
+
|
|
124
|
+
const sub = await consumer.subscribe((msg) => {
|
|
125
|
+
msg.ack()
|
|
126
|
+
})
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Per-subject inflight limits
|
|
130
|
+
|
|
131
|
+
`maxSubjectInflights` caps the in-flight (delivered, unacked) count per subject pattern, with full wildcard support (`*`, `>`). Only enforced when `ackPolicy: Explicit`; silently dropped for fire-and-forget consumers (the engine doesn't track inflight without acks).
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
import { AckPolicy, DeliverPolicy } from '@arbitro/client'
|
|
135
|
+
|
|
136
|
+
await client.createConsumer('orders', {
|
|
137
|
+
name: 'workers',
|
|
138
|
+
filter: 'orders.>',
|
|
139
|
+
ackPolicy: AckPolicy.Explicit,
|
|
140
|
+
deliverPolicy: DeliverPolicy.All,
|
|
141
|
+
maxAckPending: 20_000,
|
|
142
|
+
maxSubjectInflights: [
|
|
143
|
+
{ pattern: 'orders.critical', limit: 1 },
|
|
144
|
+
{ pattern: 'orders.heavy.>', limit: 3 },
|
|
145
|
+
{ pattern: 'orders.light.>', limit: 10 },
|
|
146
|
+
],
|
|
147
|
+
})
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Query pending acks
|
|
151
|
+
|
|
152
|
+
Live count of messages delivered to a consumer but not yet acked (equivalent of NATS JetStream `num_ack_pending`). One broker round-trip; engine cost is O(1) per shard.
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
// Via Consumer wrapper
|
|
156
|
+
const consumer = await client.stream('orders')
|
|
157
|
+
.consumer({ name: 'workers' })
|
|
158
|
+
.create()
|
|
159
|
+
await consumer.getPendings() // number
|
|
160
|
+
|
|
161
|
+
// Or directly via client (when you only have the id, or by name)
|
|
162
|
+
await client.getPending(consumerId) // number
|
|
163
|
+
await client.getPending('orders', 'workers') // number (resolves id by name)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Client metrics
|
|
167
|
+
|
|
168
|
+
The client tracks atomic counters readable via `client.metrics()`. Use it as a saturation gauge for dashboards or alerts.
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
const snap = client.metrics()
|
|
172
|
+
// {
|
|
173
|
+
// publishesSent: 12048,
|
|
174
|
+
// publishBatchEntries: 3210,
|
|
175
|
+
// deliveriesReceived: 15258,
|
|
176
|
+
// activeSubscriptions: 7, // gauge
|
|
177
|
+
// acksSent: 15101,
|
|
178
|
+
// nacksSent: 12,
|
|
179
|
+
// reconnects: 0,
|
|
180
|
+
// pendingReplies: 0,
|
|
181
|
+
// }
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Typed lazy decode
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
import { schema } from '@arbitro/client'
|
|
188
|
+
|
|
189
|
+
const OrderCodec = schema({ id: 'number', status: 'string' })
|
|
190
|
+
|
|
191
|
+
const sub = await client
|
|
192
|
+
.stream('orders')
|
|
193
|
+
.consumer({ name: 'workers', filter: 'orders.>' })
|
|
194
|
+
.subscribe(OrderCodec, (msg) => {
|
|
195
|
+
console.log(msg.id, msg.status)
|
|
196
|
+
msg.ack()
|
|
197
|
+
})
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Zod codec (optional)
|
|
201
|
+
|
|
202
|
+
If you already model your payloads with [zod](https://zod.dev), use `zodCodec` for free runtime validation on decode:
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
import { ArbitroClient, zodCodec } from '@arbitro/client'
|
|
206
|
+
import { z } from 'zod'
|
|
207
|
+
|
|
208
|
+
const Order = z.object({ id: z.number(), status: z.string() })
|
|
209
|
+
const codec = zodCodec(Order)
|
|
210
|
+
|
|
211
|
+
const sub = await client
|
|
212
|
+
.stream('orders')
|
|
213
|
+
.consumer({ name: 'workers', filter: 'orders.>' })
|
|
214
|
+
.subscribe(codec, (msg) => {
|
|
215
|
+
// msg is typed as `z.output<typeof Order>` — validated on decode
|
|
216
|
+
msg.ack()
|
|
217
|
+
})
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
`zod` is an optional peer dependency. `@arbitro/client` references zod only via `import type`, so users who never call `zodCodec` pay zero runtime cost and don't need zod installed.
|
|
221
|
+
|
|
222
|
+
## Reconnect behavior
|
|
223
|
+
|
|
224
|
+
The TS client reconnects transport automatically and reattaches active subscriptions after reconnect. That behavior lives in the client, not in the benchmarks. This matters for:
|
|
225
|
+
|
|
226
|
+
- Docker restarts
|
|
227
|
+
- broker failover tests
|
|
228
|
+
- chaos scenarios with durable consumers
|
|
229
|
+
|
|
230
|
+
## Benchmarks
|
|
231
|
+
|
|
232
|
+
Three primary bench families:
|
|
233
|
+
|
|
234
|
+
| File | npm script | What it measures |
|
|
235
|
+
|---|---|---|
|
|
236
|
+
| `benches/throughput.ts` | `npm run bench` | Publish + delivery throughput across modes (fire-and-forget, batch, sync, replay-ack, replay-noack). |
|
|
237
|
+
| `benches/limits.ts` | `npm run bench:limit` | Behaviour under `maxSubjectInflights` saturation. |
|
|
238
|
+
| `benches/chaos.ts` | `npm run bench:chaos` | Restart / reconnect / persistence under failure injection. |
|
|
239
|
+
|
|
240
|
+
Examples:
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
npx tsx benches/throughput.ts --mode fire-and-forget --msgs 20000
|
|
244
|
+
npx tsx benches/throughput.ts --mode perf --seconds 10 --rate 20000 --container arbitro-server
|
|
245
|
+
npx tsx benches/limits.ts
|
|
246
|
+
npx tsx benches/chaos.ts --duration 8 --rate 50 --container arbitro-server
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## Validation
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
npm run typecheck
|
|
253
|
+
npm test
|
|
254
|
+
npm run test:integration # requires Docker
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
## Documentation
|
|
258
|
+
|
|
259
|
+
- `CONTRIBUTING.md` — dev setup, branch + commit conventions, PR review.
|
|
260
|
+
- `RELEASING.md` — SemVer policy and the npm publish flow.
|
|
261
|
+
- `.agent/rules/*.md` — internal coding rules (hot-path discipline, wire protocol, etc.).
|
|
262
|
+
- `CLAUDE.md` — index pointing at the rule files.
|
|
263
|
+
|
|
264
|
+
## License
|
|
265
|
+
|
|
266
|
+
MIT — see [LICENSE](./LICENSE).
|