@absolutejs/router 0.0.1 → 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/README.md CHANGED
@@ -37,26 +37,31 @@ ws.data = { ...ws.data, release: handle.release, upstream: decision.shard!.url }
37
37
  // ...proxy WS frames to decision.shard.url; call handle.release() on close.
38
38
  ```
39
39
 
40
- ## v0.0.1 surface
40
+ ## Surface (0.1.0)
41
41
 
42
42
  | API | Purpose |
43
43
  |---|---|
44
44
  | `createRouter(options)` | Factory. Returns a `Router`. |
45
- | `router.route({ tenantId, channelId? })` | Returns `{ shard, decision }`. Decision is `allow` / `rate-limited` / `capped` / `no-shards`. |
46
- | `router.acquire(tenantId)` | Increment a tenant's active-connection counter; returns `{ active, release }`. `release` is idempotent. |
47
- | `router.markHealthy(id)` / `router.markUnhealthy(id)` | Caller-driven health state. Unhealthy shards are skipped; tenants on them rehash to healthy ones. |
45
+ | `router.route({ tenantId, channelId?, route? })` | Returns `{ shard, decision, emptiedBucket? }`. Decision is `allow` / `rate-limited` / `capped` / `no-shards` / `denied`. |
46
+ | `router.acquire(tenantId)` | Increment active-connection counter; returns `{ active, release }`. `release` is idempotent. |
47
+ | `router.markHealthy(id)` / `router.markUnhealthy(id)` | Caller-driven health state. Unhealthy shards are skipped. |
48
+ | `router.drainShard(id)` | Refuse new routes; existing acquires unaffected. Operator-intentional state distinct from unhealthy. `markHealthy` cancels it. |
49
+ | `router.isHealthy(id)` / `router.isDraining(id)` | Inspect state. |
48
50
  | `router.addShard(shard)` / `router.removeShard(id)` | Runtime shard membership changes. |
49
- | `router.shards()` / `router.snapshot()` | Inspection. |
51
+ | `router.shards()` | Inspect shard list. |
52
+ | `router.snapshot()` / `router.restore(snap)` | Serializable point-in-time state. Survive edge restarts without dropping rate-limit tokens. |
50
53
  | `router.dispose()` | Stop accepting routes; all subsequent `route()` returns `no-shards`. |
51
54
 
52
- ### Hash strategies
55
+ ### Hash strategies + load bias
53
56
 
54
- - **`jump`** (default) — Lamping & Veach 2014. O(log n) with no memory, exactly
55
- 1/N keys move when shards are added at the tail. Ignores `weight`.
56
- - **`rendezvous`** — HRW hash. Supports per-shard `weight` for heterogeneous
57
- engine sizes. O(N) per lookup.
57
+ - **`jump`** (default) — Lamping & Veach 2014. O(log n) with no memory, exactly 1/N keys move when shards are added at the tail. Ignores `weight` AND `load` (its design property is unconditional stickiness).
58
+ - **`rendezvous`** HRW hash. Supports per-shard `weight` for heterogeneous engine sizes; ALSO supports the `load: (shardId) => number` hook for runtime hot-spot avoidance — `effectiveWeight = weight / load`. O(N) per lookup.
58
59
  - **Custom**: pass `(key, shards) => index`.
59
60
 
61
+ ### Drain mode
62
+
63
+ `drainShard(id)` excludes a shard from new routing without marking it broken. Use this before a planned shard shutdown — tenants on the draining shard rehash to healthy non-draining shards on their NEXT route, but in-flight requests aren't torn down. The caller waits for the shard to be quiet (e.g. via the runtime's stats), then `removeShard()`. `markHealthy()` cancels a drain in case ops changes their mind.
64
+
60
65
  ### Connection cap
61
66
 
62
67
  `perTenantConnectionCap` is the max concurrent connections one tenant can hold,
@@ -64,19 +69,41 @@ counted via `acquire()` / the returned `release()`. When reached, `route()`
64
69
  returns `capped` — your gateway should refuse the upgrade with `429` /
65
70
  `503`. Default `Infinity` (no cap).
66
71
 
67
- ### Rate limit
72
+ ### Rate limits — tenant + per-route
73
+
74
+ `perTenantRateLimit` is a token bucket per tenant: `tokens` is bucket capacity AND starting balance; `refillPerSecond` continuously refills up to capacity. Each successful `route()` costs one token. Bucket is computed lazily at lookup time — no timer churn for idle tenants. Default `{ tokens: Infinity, refillPerSecond: 0 }` (no limit).
75
+
76
+ `perRouteRateLimits: Record<string, RateLimit>` layers a SECOND per-route bucket on top of the tenant-wide one. `route({ route: 'expensive' })` checks both; if either is empty, the call returns `rate-limited` with `emptiedBucket` reporting which one. Useful for "100 cheap calls / minute, 5 expensive calls / minute" shapes where one tenant-wide cap won't express the policy. **A failed route bucket does NOT consume the tenant bucket** — neither token is deducted unless both pass.
68
77
 
69
- `perTenantRateLimit` is a token bucket per tenant: `tokens` is bucket capacity
70
- AND starting balance; `refillPerSecond` continuously refills up to capacity.
71
- Each successful `route()` costs one token. Bucket is computed lazily at lookup
72
- time — no timer churn for idle tenants. Default `{ tokens: Infinity,
73
- refillPerSecond: 0 }` (no limit).
78
+ ### Allow hook (meter integration)
79
+
80
+ `allow: (tenantId) => boolean` is a caller-supplied gate. Returning `false` makes `route()` return `{ decision: 'denied' }` immediately, before any bucket is touched. The intended pairing is `@absolutejs/metering`'s `meter.allow` pass it directly:
81
+
82
+ ```ts
83
+ const meter = createMeter({ ... });
84
+ const router = createRouter({
85
+ shards,
86
+ allow: meter.allow, // refuse routes for over-quota tenants
87
+ load: (id) => runtimeRoster.load(id), // and bias toward less-loaded shards
88
+ });
89
+ ```
74
90
 
75
91
  ### Health
76
92
 
77
- The router does not probe backends itself — keeping it bun/elysia-free means
78
- no I/O. Wire your own health-check loop and call `markHealthy` / `markUnhealthy`.
79
- A live health-checking adapter is a candidate for a later 0.0.x subpath.
93
+ The router does not probe backends itself — keeping it bun/elysia-free means no I/O. Wire your own health-check loop and call `markHealthy` / `markUnhealthy`. A live health-checking adapter is a candidate for a later 0.0.x subpath.
94
+
95
+ ### Snapshot + restore
96
+
97
+ ```ts
98
+ const json = JSON.stringify(router.snapshot());
99
+ await persistToDisk('/var/lib/router/state.json', json);
100
+
101
+ // On edge restart:
102
+ const restored = createRouter({ ... same config ... });
103
+ restored.restore(JSON.parse(await readFromDisk('/var/lib/router/state.json')));
104
+ ```
105
+
106
+ Captures rate-limit token counts, per-route bucket state, shard health + drain state, per-tenant active connection counts. Without this, an edge restart hands every tenant a fresh full bucket — instant rate-limit-bypass for anyone watching the deploy times.
80
107
 
81
108
  ## Architectural role
82
109
 
package/dist/index.d.ts CHANGED
@@ -5,12 +5,13 @@
5
5
  * Sits between an incoming request / WS upgrade and N backend processes (each
6
6
  * one a `@absolutejs/runtime` instance hosting a `@absolutejs/sync` engine
7
7
  * for a subset of tenants). Decides which shard owns the tenant, whether the
8
- * tenant is over its rate limit, whether the tenant is over its connection
9
- * cap, and whether the chosen shard is healthy.
8
+ * tenant is over its rate limit (tenant-wide + per-route), whether the tenant
9
+ * is over its connection cap, whether the caller-supplied allow hook approves,
10
+ * and whether the chosen shard is healthy and not draining.
10
11
  *
11
- * v0.0.1 is intentionally bun/elysia-agnostic — pure logic, no `Bun.serve`
12
- * or proxy code. The caller wires the routing decision into whichever HTTP/WS
13
- * layer they have. An Elysia adapter ships in a later 0.0.x as a subpath.
12
+ * v0.1.0 is bun/elysia-agnostic — pure logic, no `Bun.serve` or proxy code.
13
+ * The caller wires the routing decision into whichever HTTP/WS layer they
14
+ * have. An Elysia adapter ships in a later 0.0.x as a subpath.
14
15
  */
15
16
  export type Shard = {
16
17
  id: string;
@@ -28,8 +29,8 @@ export type Shard = {
28
29
  };
29
30
  /**
30
31
  * Pure hash-strategy function. Given the routing key + the list of currently
31
- * healthy shards (with their weights), return the index in `shards` of the
32
- * chosen shard. The router never calls a strategy with an empty `shards`
32
+ * eligible shards (healthy AND not draining), return the index in `shards` of
33
+ * the chosen shard. The router never calls a strategy with an empty `shards`
33
34
  * array — that case is handled upstream as `no-shards`.
34
35
  */
35
36
  export type HashStrategyFn = (key: string, shards: ReadonlyArray<Shard>) => number;
@@ -43,6 +44,23 @@ export type RateLimit = {
43
44
  tokens: number;
44
45
  refillPerSecond: number;
45
46
  };
47
+ /**
48
+ * Optional caller-supplied gate. Called per-route with the tenant id; returning
49
+ * `false` causes the route to return `decision: 'denied'`. The intended
50
+ * use is `meter.allow` from `@absolutejs/metering` — pass it directly to refuse
51
+ * routes for over-quota tenants without wiring the integration manually.
52
+ */
53
+ export type AllowHook = (tenantId: string) => boolean;
54
+ /**
55
+ * Optional caller-supplied load metric. Called per `route()` with a shard id;
56
+ * returning a value > 1 makes the rendezvous strategy bias AWAY from this shard
57
+ * (effective weight = `shard.weight / load`). Used to avoid hot-spotting when
58
+ * a stickiness-locked shard is overloaded — the router can't move existing
59
+ * tenants, but it can avoid sending NEW tenants there.
60
+ *
61
+ * Jump-hash ignores this — it has no per-call weight bias by design.
62
+ */
63
+ export type ShardLoadFn = (shardId: string) => number;
46
64
  export type RouterOptions = {
47
65
  /** Initial shard set. Can be empty (every `route()` returns `no-shards`). */
48
66
  shards: Shard[];
@@ -60,10 +78,48 @@ export type RouterOptions = {
60
78
  * `'rate-limited'`. One `route()` call costs one token.
61
79
  */
62
80
  perTenantRateLimit?: RateLimit;
81
+ /**
82
+ * Per-route token buckets layered on top of the tenant-wide bucket.
83
+ * Keyed by route name; supplied by the caller via
84
+ * `route({ route: 'someRoute' })`. The tenant bucket AND the route bucket
85
+ * must both have a token available. When the route bucket is empty,
86
+ * `route()` returns `'rate-limited'` with `routeId` set.
87
+ */
88
+ perRouteRateLimits?: Record<string, RateLimit>;
89
+ /** Optional load hook biasing the rendezvous strategy. */
90
+ load?: ShardLoadFn;
91
+ /** Optional caller-supplied allow gate. */
92
+ allow?: AllowHook;
63
93
  /** Override `Date.now` for tests. */
64
94
  clock?: () => number;
65
95
  };
66
- export type RouteDecision = 'allow' | 'rate-limited' | 'capped' | 'no-shards';
96
+ export type RouteDecision = 'allow' | 'rate-limited' | 'capped' | 'no-shards' | 'denied';
97
+ /**
98
+ * Returned by {@link Router.metrics}. Combines point-in-time state with
99
+ * cumulative counters since `createRouter()`. Survives `dispose()` so
100
+ * post-shutdown introspection still reads totals. Added in 0.2.0.
101
+ *
102
+ * - `routes` — total `route()` calls (any decision).
103
+ * - `acquires` — total `acquire()` calls.
104
+ * - `rejectsByDecision` — counts per non-allow `RouteDecision`. The
105
+ * operator's "where am I shedding load?" answer.
106
+ * - `shardLoadDistribution` — cumulative routes assigned per shard
107
+ * since `createRouter()`. With `markHealthy`/`drainShard` this is
108
+ * the "is rebalancing actually rebalancing?" signal. (Per-shard
109
+ * *active* connection count would require route-to-acquire
110
+ * correlation that the current `acquire(tenantId)` API doesn't
111
+ * capture; cumulative routes are the directly-observable proxy.)
112
+ * - `lastRouteMs` — wall-clock of the most recent `route()` call (a
113
+ * climb means the hot path is getting slower — usually a sign that
114
+ * `load:` is doing too much work, or the shard count exploded).
115
+ */
116
+ export type RouterMetrics = {
117
+ routes: number;
118
+ acquires: number;
119
+ rejectsByDecision: Record<Exclude<RouteDecision, 'allow'>, number>;
120
+ shardLoadDistribution: Record<string, number>;
121
+ lastRouteMs: number;
122
+ };
67
123
  export type RouteRequest = {
68
124
  tenantId: string;
69
125
  /**
@@ -74,11 +130,22 @@ export type RouteRequest = {
74
130
  * tenant may land on different shards.
75
131
  */
76
132
  channelId?: string;
133
+ /**
134
+ * Optional per-route rate-limit key. Looked up in `perRouteRateLimits`.
135
+ * Unknown routes pass without per-route gating (only the tenant-wide
136
+ * bucket applies).
137
+ */
138
+ route?: string;
77
139
  };
78
140
  export type RouteResult = {
79
141
  decision: RouteDecision;
80
- /** The chosen shard. `null` only when `decision === 'no-shards'`. */
142
+ /** The chosen shard. `null` when `decision !== 'allow'`. */
81
143
  shard: Shard | null;
144
+ /**
145
+ * Set on a `'rate-limited'` result to tell the caller which bucket emptied:
146
+ * `'tenant'` for the tenant-wide bucket, the route id for a per-route bucket.
147
+ */
148
+ emptiedBucket?: 'tenant' | string;
82
149
  };
83
150
  export type AcquireHandle = {
84
151
  /** Number of active connections for this tenant after acquire (>=1). */
@@ -87,21 +154,55 @@ export type AcquireHandle = {
87
154
  release: () => void;
88
155
  };
89
156
  export type RouterSnapshot = {
157
+ version: 1;
158
+ at: number;
90
159
  shards: Array<Shard & {
91
160
  healthy: boolean;
161
+ draining: boolean;
162
+ active: number;
163
+ }>;
164
+ tenants: Array<{
165
+ tenant: string;
92
166
  active: number;
167
+ tokens: number;
168
+ lastRefillAt: number;
169
+ }>;
170
+ routeBuckets: Array<{
171
+ tenant: string;
172
+ route: string;
173
+ tokens: number;
174
+ lastRefillAt: number;
93
175
  }>;
94
- tenants: number;
95
176
  };
96
177
  export type Router = {
97
178
  route: (request: RouteRequest) => RouteResult;
98
179
  acquire: (tenantId: string) => AcquireHandle;
99
180
  markHealthy: (shardId: string) => void;
100
181
  markUnhealthy: (shardId: string) => void;
182
+ /**
183
+ * Begin draining a shard — exclude it from new routing decisions while
184
+ * leaving existing acquires alone. Semantically distinct from
185
+ * `markUnhealthy` (an operator-intentional state, not a failure).
186
+ * `markHealthy` cancels both states. Use this before a planned shard
187
+ * shutdown — tenants on the shard rehash to healthy non-draining shards
188
+ * on their next route, but in-flight requests are NOT torn down.
189
+ */
190
+ drainShard: (shardId: string) => void;
191
+ isHealthy: (shardId: string) => boolean;
192
+ isDraining: (shardId: string) => boolean;
101
193
  addShard: (shard: Shard) => void;
102
194
  removeShard: (shardId: string) => void;
103
195
  shards: () => Shard[];
104
196
  snapshot: () => RouterSnapshot;
197
+ restore: (snapshot: RouterSnapshot) => void;
198
+ /**
199
+ * Operator-shaped point-in-time + cumulative metrics since
200
+ * `createRouter()`. Use for tier dashboards and "where am I
201
+ * shedding load" alerts. Distinct from `snapshot()`, which is the
202
+ * persistence shape (preserves tenant + bucket state across a
203
+ * shard reboot). Added in 0.2.0.
204
+ */
205
+ metrics: () => RouterMetrics;
105
206
  dispose: () => void;
106
207
  };
107
208
  export declare const createRouter: (options: RouterOptions) => Router;
package/dist/index.js CHANGED
@@ -23,17 +23,19 @@ var jumpHash = (key, numBuckets) => {
23
23
  return Number(b);
24
24
  };
25
25
  var jumpStrategy = (key, shards) => jumpHash(key, shards.length);
26
- var rendezvousStrategy = (key, shards) => {
26
+ var makeRendezvousStrategy = (load) => (key, shards) => {
27
27
  let bestIndex = 0;
28
28
  let bestScore = -Infinity;
29
29
  for (let i = 0;i < shards.length; i++) {
30
30
  const shard = shards[i];
31
- const weight = shard.weight ?? 1;
32
- if (weight <= 0)
31
+ const baseWeight = shard.weight ?? 1;
32
+ if (baseWeight <= 0)
33
33
  continue;
34
+ const loadValue = load ? Math.max(load(shard.id), 0.0001) : 1;
35
+ const effectiveWeight = baseWeight / loadValue;
34
36
  const seed = fnv1a32(`${key}|${shard.id}`);
35
37
  const u = (seed + 1) / 4294967296;
36
- const score = weight * -Math.log(u);
38
+ const score = effectiveWeight * -Math.log(u);
37
39
  if (score > bestScore) {
38
40
  bestScore = score;
39
41
  bestIndex = i;
@@ -41,28 +43,41 @@ var rendezvousStrategy = (key, shards) => {
41
43
  }
42
44
  return bestIndex;
43
45
  };
44
- var resolveStrategy = (strategy) => {
46
+ var resolveStrategy = (strategy, load) => {
45
47
  if (strategy === "jump")
46
48
  return jumpStrategy;
47
49
  if (strategy === "rendezvous")
48
- return rendezvousStrategy;
50
+ return makeRendezvousStrategy(load);
49
51
  return strategy;
50
52
  };
51
53
  var createRouter = (options) => {
52
54
  const clock = options.clock ?? Date.now;
53
- const strategy = resolveStrategy(options.hashStrategy ?? "jump");
55
+ const strategy = resolveStrategy(options.hashStrategy ?? "jump", options.load);
54
56
  const cap = options.perTenantConnectionCap ?? Infinity;
55
57
  const rate = options.perTenantRateLimit ?? { refillPerSecond: 0, tokens: Infinity };
58
+ const perRouteRateLimits = options.perRouteRateLimits ?? {};
59
+ const allowHook = options.allow;
56
60
  const shardList = [...options.shards];
57
61
  const healthy = new Map;
62
+ const draining = new Set;
58
63
  for (const shard of shardList)
59
64
  healthy.set(shard.id, true);
60
65
  const tenants = new Map;
66
+ const routeBuckets = new Map;
61
67
  let disposed = false;
68
+ let totalRoutes = 0;
69
+ let totalAcquires = 0;
70
+ let lastRouteMs = 0;
71
+ const rejectsByDecision = {
72
+ "rate-limited": 0,
73
+ capped: 0,
74
+ "no-shards": 0,
75
+ denied: 0
76
+ };
77
+ const shardLoadByShard = new Map;
62
78
  const freshTenant = (now) => ({
63
79
  active: 0,
64
- lastRefillAt: now,
65
- tokens: rate.tokens
80
+ bucket: { lastRefillAt: now, tokens: rate.tokens }
66
81
  });
67
82
  const ensureTenant = (id, now) => {
68
83
  const found = tenants.get(id);
@@ -72,42 +87,88 @@ var createRouter = (options) => {
72
87
  tenants.set(id, fresh);
73
88
  return fresh;
74
89
  };
75
- const refill = (state, now) => {
76
- if (rate.refillPerSecond <= 0)
90
+ const refillBucket = (bucket, rule, now) => {
91
+ if (rule.refillPerSecond <= 0)
77
92
  return;
78
- const elapsedMs = now - state.lastRefillAt;
93
+ const elapsedMs = now - bucket.lastRefillAt;
79
94
  if (elapsedMs <= 0)
80
95
  return;
81
- const added = elapsedMs / 1000 * rate.refillPerSecond;
82
- state.tokens = Math.min(rate.tokens, state.tokens + added);
83
- state.lastRefillAt = now;
96
+ const added = elapsedMs / 1000 * rule.refillPerSecond;
97
+ bucket.tokens = Math.min(rule.tokens, bucket.tokens + added);
98
+ bucket.lastRefillAt = now;
99
+ };
100
+ const ensureRouteBucket = (tenant, route2, rule, now) => {
101
+ const key = `${tenant}|${route2}`;
102
+ const found = routeBuckets.get(key);
103
+ if (found)
104
+ return found;
105
+ const fresh = { lastRefillAt: now, tokens: rule.tokens };
106
+ routeBuckets.set(key, fresh);
107
+ return fresh;
84
108
  };
85
- const healthyShards = () => shardList.filter((shard) => healthy.get(shard.id) === true);
109
+ const eligibleShards = () => shardList.filter((shard) => healthy.get(shard.id) === true && !draining.has(shard.id));
86
110
  const route = (request) => {
111
+ const routeStart = clock();
112
+ totalRoutes += 1;
113
+ const recordRejection = (decision) => {
114
+ rejectsByDecision[decision] += 1;
115
+ lastRouteMs = clock() - routeStart;
116
+ return { decision, shard: null };
117
+ };
87
118
  if (disposed)
88
- return { decision: "no-shards", shard: null };
89
- const live = healthyShards();
119
+ return recordRejection("no-shards");
120
+ const live = eligibleShards();
90
121
  if (live.length === 0)
91
- return { decision: "no-shards", shard: null };
92
- const now = clock();
122
+ return recordRejection("no-shards");
123
+ if (allowHook && !allowHook(request.tenantId)) {
124
+ return recordRejection("denied");
125
+ }
126
+ const now = routeStart;
93
127
  const state = ensureTenant(request.tenantId, now);
94
128
  if (state.active >= cap) {
95
- return { decision: "capped", shard: null };
129
+ return recordRejection("capped");
96
130
  }
97
- refill(state, now);
98
- if (state.tokens < 1) {
99
- return { decision: "rate-limited", shard: null };
131
+ refillBucket(state.bucket, rate, now);
132
+ if (state.bucket.tokens < 1) {
133
+ rejectsByDecision["rate-limited"] += 1;
134
+ lastRouteMs = clock() - routeStart;
135
+ return {
136
+ decision: "rate-limited",
137
+ emptiedBucket: "tenant",
138
+ shard: null
139
+ };
100
140
  }
101
- state.tokens -= 1;
141
+ let routeBucket = null;
142
+ let routeRule = null;
143
+ if (request.route !== undefined && perRouteRateLimits[request.route] !== undefined) {
144
+ routeRule = perRouteRateLimits[request.route];
145
+ routeBucket = ensureRouteBucket(request.tenantId, request.route, routeRule, now);
146
+ refillBucket(routeBucket, routeRule, now);
147
+ if (routeBucket.tokens < 1) {
148
+ rejectsByDecision["rate-limited"] += 1;
149
+ lastRouteMs = clock() - routeStart;
150
+ return {
151
+ decision: "rate-limited",
152
+ emptiedBucket: request.route,
153
+ shard: null
154
+ };
155
+ }
156
+ }
157
+ state.bucket.tokens -= 1;
158
+ if (routeBucket)
159
+ routeBucket.tokens -= 1;
102
160
  const key = request.channelId === undefined ? request.tenantId : `${request.tenantId}:${request.channelId}`;
103
161
  const index = strategy(key, live);
104
162
  const chosen = live[Math.max(0, Math.min(index, live.length - 1))];
163
+ shardLoadByShard.set(chosen.id, (shardLoadByShard.get(chosen.id) ?? 0) + 1);
164
+ lastRouteMs = clock() - routeStart;
105
165
  return { decision: "allow", shard: chosen };
106
166
  };
107
167
  const acquire = (tenantId) => {
108
168
  const now = clock();
109
169
  const state = ensureTenant(tenantId, now);
110
170
  state.active += 1;
171
+ totalAcquires += 1;
111
172
  let released = false;
112
173
  return {
113
174
  active: state.active,
@@ -133,11 +194,28 @@ var createRouter = (options) => {
133
194
  disposed = true;
134
195
  shardList.length = 0;
135
196
  healthy.clear();
197
+ draining.clear();
136
198
  tenants.clear();
199
+ routeBuckets.clear();
137
200
  },
138
- markHealthy: (id) => {
201
+ drainShard: (id) => {
139
202
  if (healthy.has(id))
203
+ draining.add(id);
204
+ },
205
+ isDraining: (id) => draining.has(id),
206
+ isHealthy: (id) => healthy.get(id) === true,
207
+ metrics: () => ({
208
+ acquires: totalAcquires,
209
+ lastRouteMs,
210
+ rejectsByDecision: { ...rejectsByDecision },
211
+ routes: totalRoutes,
212
+ shardLoadDistribution: Object.fromEntries(shardLoadByShard)
213
+ }),
214
+ markHealthy: (id) => {
215
+ if (healthy.has(id)) {
140
216
  healthy.set(id, true);
217
+ draining.delete(id);
218
+ }
141
219
  },
142
220
  markUnhealthy: (id) => {
143
221
  if (healthy.has(id))
@@ -148,22 +226,76 @@ var createRouter = (options) => {
148
226
  if (at >= 0)
149
227
  shardList.splice(at, 1);
150
228
  healthy.delete(id);
229
+ draining.delete(id);
151
230
  },
152
231
  route,
153
232
  shards: () => shardList.map((shard) => ({ ...shard })),
154
- snapshot: () => ({
155
- shards: shardList.map((shard) => ({
156
- ...shard,
157
- active: 0,
158
- healthy: healthy.get(shard.id) === true
159
- })),
160
- tenants: tenants.size
161
- })
233
+ snapshot: () => {
234
+ const now = clock();
235
+ const tenantsOut = [];
236
+ for (const [tenant, state] of tenants) {
237
+ tenantsOut.push({
238
+ active: state.active,
239
+ lastRefillAt: state.bucket.lastRefillAt,
240
+ tenant,
241
+ tokens: state.bucket.tokens
242
+ });
243
+ }
244
+ const routesOut = [];
245
+ for (const [key, bucket] of routeBuckets) {
246
+ const pipe = key.indexOf("|");
247
+ if (pipe < 0)
248
+ continue;
249
+ routesOut.push({
250
+ lastRefillAt: bucket.lastRefillAt,
251
+ route: key.slice(pipe + 1),
252
+ tenant: key.slice(0, pipe),
253
+ tokens: bucket.tokens
254
+ });
255
+ }
256
+ return {
257
+ at: now,
258
+ routeBuckets: routesOut,
259
+ shards: shardList.map((shard) => {
260
+ const active = Array.from(tenants.values()).reduce((acc, state) => acc + state.active, 0);
261
+ return {
262
+ ...shard,
263
+ active,
264
+ draining: draining.has(shard.id),
265
+ healthy: healthy.get(shard.id) === true
266
+ };
267
+ }),
268
+ tenants: tenantsOut,
269
+ version: 1
270
+ };
271
+ },
272
+ restore: (snap) => {
273
+ tenants.clear();
274
+ routeBuckets.clear();
275
+ draining.clear();
276
+ for (const t of snap.tenants) {
277
+ tenants.set(t.tenant, {
278
+ active: t.active,
279
+ bucket: { lastRefillAt: t.lastRefillAt, tokens: t.tokens }
280
+ });
281
+ }
282
+ for (const r of snap.routeBuckets) {
283
+ routeBuckets.set(`${r.tenant}|${r.route}`, {
284
+ lastRefillAt: r.lastRefillAt,
285
+ tokens: r.tokens
286
+ });
287
+ }
288
+ for (const shard of snap.shards) {
289
+ healthy.set(shard.id, shard.healthy);
290
+ if (shard.draining)
291
+ draining.add(shard.id);
292
+ }
293
+ }
162
294
  };
163
295
  };
164
296
  export {
165
297
  createRouter
166
298
  };
167
299
 
168
- //# debugId=E88C919511EA465E64756E2164756E21
300
+ //# debugId=E49309CC1211AA2864756E2164756E21
169
301
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -2,9 +2,9 @@
2
2
  "version": 3,
3
3
  "sources": ["../src/index.ts"],
4
4
  "sourcesContent": [
5
- "/**\n * @absolutejs/router — multi-tenant connection routing primitive for Bun PaaS\n * gateways.\n *\n * Sits between an incoming request / WS upgrade and N backend processes (each\n * one a `@absolutejs/runtime` instance hosting a `@absolutejs/sync` engine\n * for a subset of tenants). Decides which shard owns the tenant, whether the\n * tenant is over its rate limit, whether the tenant is over its connection\n * cap, and whether the chosen shard is healthy.\n *\n * v0.0.1 is intentionally bun/elysia-agnostic — pure logic, no `Bun.serve`\n * or proxy code. The caller wires the routing decision into whichever HTTP/WS\n * layer they have. An Elysia adapter ships in a later 0.0.x as a subpath.\n */\n\nexport type Shard = {\n\tid: string;\n\t/**\n\t * Connection target for the chosen backend. Format is opaque to the router\n\t * — `ws://host:port`, `https://host`, a unix socket path, whatever the\n\t * caller's proxy layer understands.\n\t */\n\turl: string;\n\t/**\n\t * Relative weight for hash strategies that support it (rendezvous). Jump\n\t * hash ignores weights — every shard is treated as weight 1. Default 1.\n\t */\n\tweight?: number;\n};\n\n/**\n * Pure hash-strategy function. Given the routing key + the list of currently\n * healthy shards (with their weights), return the index in `shards` of the\n * chosen shard. The router never calls a strategy with an empty `shards`\n * array — that case is handled upstream as `no-shards`.\n */\nexport type HashStrategyFn = (key: string, shards: ReadonlyArray<Shard>) => number;\n\nexport type HashStrategy = 'jump' | 'rendezvous' | HashStrategyFn;\n\n/**\n * Per-tenant token-bucket rate limit. `tokens` is the bucket capacity AND the\n * starting balance; `refillPerSecond` is added continuously up to capacity.\n * Defaults: `Infinity` tokens / `0` refill = no limit.\n */\nexport type RateLimit = {\n\ttokens: number;\n\trefillPerSecond: number;\n};\n\nexport type RouterOptions = {\n\t/** Initial shard set. Can be empty (every `route()` returns `no-shards`). */\n\tshards: Shard[];\n\t/** Hash strategy. Default `'jump'`. */\n\thashStrategy?: HashStrategy;\n\t/**\n\t * Max concurrent connections per tenant (counted via `acquire()` /\n\t * `release()`). Default `Infinity` (no cap). When reached, `route()`\n\t * returns `'capped'`.\n\t */\n\tperTenantConnectionCap?: number;\n\t/**\n\t * Per-tenant token bucket. Default `{ tokens: Infinity, refillPerSecond: 0 }`\n\t * (no limit). When the tenant's bucket is empty, `route()` returns\n\t * `'rate-limited'`. One `route()` call costs one token.\n\t */\n\tperTenantRateLimit?: RateLimit;\n\t/** Override `Date.now` for tests. */\n\tclock?: () => number;\n};\n\nexport type RouteDecision = 'allow' | 'rate-limited' | 'capped' | 'no-shards';\n\nexport type RouteRequest = {\n\ttenantId: string;\n\t/**\n\t * Optional sub-key within a tenant for shardable channels. When set, the\n\t * hash key is `${tenantId}:${channelId}`. Use this when a single tenant is\n\t * too hot for one engine and its work can be partitioned (e.g. per-doc,\n\t * per-room) across multiple engines. Different channels of the same\n\t * tenant may land on different shards.\n\t */\n\tchannelId?: string;\n};\n\nexport type RouteResult = {\n\tdecision: RouteDecision;\n\t/** The chosen shard. `null` only when `decision === 'no-shards'`. */\n\tshard: Shard | null;\n};\n\nexport type AcquireHandle = {\n\t/** Number of active connections for this tenant after acquire (>=1). */\n\tactive: number;\n\t/** Releases one connection for this tenant. Idempotent — safe to call once. */\n\trelease: () => void;\n};\n\nexport type RouterSnapshot = {\n\tshards: Array<Shard & { healthy: boolean; active: number }>;\n\ttenants: number;\n};\n\nexport type Router = {\n\troute: (request: RouteRequest) => RouteResult;\n\tacquire: (tenantId: string) => AcquireHandle;\n\tmarkHealthy: (shardId: string) => void;\n\tmarkUnhealthy: (shardId: string) => void;\n\taddShard: (shard: Shard) => void;\n\tremoveShard: (shardId: string) => void;\n\tshards: () => Shard[];\n\tsnapshot: () => RouterSnapshot;\n\tdispose: () => void;\n};\n\n// -----------------------------------------------------------------------------\n// Hash strategies\n// -----------------------------------------------------------------------------\n\n/**\n * FNV-1a 32-bit. Cheap, no deps, good enough as a seed for the consistent\n * hash strategies below. Not cryptographic — do NOT use as a security hash.\n */\nconst fnv1a32 = (input: string): number => {\n\tlet hash = 0x811c9dc5;\n\tfor (let i = 0; i < input.length; i++) {\n\t\thash ^= input.charCodeAt(i);\n\t\thash = Math.imul(hash, 0x01000193);\n\t}\n\treturn hash >>> 0;\n};\n\n/**\n * Jump-consistent-hash, Lamping & Veach 2014. Maps a 64-bit key to a bucket\n * in `[0, numBuckets)`. Properties: O(log n), no memory, exactly 1/N keys\n * move when buckets are added at the tail. We mix a 32-bit FNV-1a into a\n * 64-bit-ish state via bigint to satisfy the 64-bit arithmetic the algorithm\n * expects.\n */\nconst jumpHash = (key: string, numBuckets: number): number => {\n\tif (numBuckets <= 0) return -1;\n\tlet k = BigInt(fnv1a32(key)) | (BigInt(fnv1a32(key + '#hi')) << 32n);\n\tlet b = -1n;\n\tlet j = 0n;\n\twhile (j < BigInt(numBuckets)) {\n\t\tb = j;\n\t\tk = (k * 2862933555777941757n + 1n) & 0xffffffffffffffffn;\n\t\t// (b + 1) * (1 << 31) / ((k >> 33) + 1)\n\t\tconst shifted = (k >> 33n) + 1n;\n\t\tj = ((b + 1n) * (1n << 31n)) / shifted;\n\t}\n\treturn Number(b);\n};\n\nconst jumpStrategy: HashStrategyFn = (key, shards) => jumpHash(key, shards.length);\n\n/**\n * Rendezvous (highest-random-weight) hash. For each shard, compute\n * `score = weight * -ln(rand(hash(key + shard.id)))`; pick the shard with\n * the highest score. Properties: supports per-shard weighting, single-shard\n * change moves only that shard's \"winning\" keys, O(N) per lookup.\n */\nconst rendezvousStrategy: HashStrategyFn = (key, shards) => {\n\tlet bestIndex = 0;\n\tlet bestScore = -Infinity;\n\tfor (let i = 0; i < shards.length; i++) {\n\t\tconst shard = shards[i]!;\n\t\tconst weight = shard.weight ?? 1;\n\t\tif (weight <= 0) continue;\n\t\tconst seed = fnv1a32(`${key}|${shard.id}`);\n\t\t// Map [0, 2^32) to (0, 1) and take -ln. Heavier weight = higher expected score.\n\t\tconst u = (seed + 1) / 0x1_0000_0000;\n\t\tconst score = weight * -Math.log(u);\n\t\tif (score > bestScore) {\n\t\t\tbestScore = score;\n\t\t\tbestIndex = i;\n\t\t}\n\t}\n\treturn bestIndex;\n};\n\nconst resolveStrategy = (strategy: HashStrategy): HashStrategyFn => {\n\tif (strategy === 'jump') return jumpStrategy;\n\tif (strategy === 'rendezvous') return rendezvousStrategy;\n\treturn strategy;\n};\n\n// -----------------------------------------------------------------------------\n// Router\n// -----------------------------------------------------------------------------\n\ntype TenantState = {\n\tactive: number;\n\ttokens: number;\n\tlastRefillAt: number;\n};\n\nexport const createRouter = (options: RouterOptions): Router => {\n\tconst clock = options.clock ?? Date.now;\n\tconst strategy = resolveStrategy(options.hashStrategy ?? 'jump');\n\tconst cap = options.perTenantConnectionCap ?? Infinity;\n\tconst rate = options.perTenantRateLimit ?? { refillPerSecond: 0, tokens: Infinity };\n\n\tconst shardList: Shard[] = [...options.shards];\n\tconst healthy = new Map<string, boolean>();\n\tfor (const shard of shardList) healthy.set(shard.id, true);\n\n\tconst tenants = new Map<string, TenantState>();\n\tlet disposed = false;\n\n\tconst freshTenant = (now: number): TenantState => ({\n\t\tactive: 0,\n\t\tlastRefillAt: now,\n\t\ttokens: rate.tokens,\n\t});\n\n\tconst ensureTenant = (id: string, now: number): TenantState => {\n\t\tconst found = tenants.get(id);\n\t\tif (found) return found;\n\t\tconst fresh = freshTenant(now);\n\t\ttenants.set(id, fresh);\n\t\treturn fresh;\n\t};\n\n\tconst refill = (state: TenantState, now: number) => {\n\t\tif (rate.refillPerSecond <= 0) return;\n\t\tconst elapsedMs = now - state.lastRefillAt;\n\t\tif (elapsedMs <= 0) return;\n\t\tconst added = (elapsedMs / 1000) * rate.refillPerSecond;\n\t\tstate.tokens = Math.min(rate.tokens, state.tokens + added);\n\t\tstate.lastRefillAt = now;\n\t};\n\n\tconst healthyShards = (): Shard[] => shardList.filter((shard) => healthy.get(shard.id) === true);\n\n\tconst route: Router['route'] = (request) => {\n\t\tif (disposed) return { decision: 'no-shards', shard: null };\n\n\t\tconst live = healthyShards();\n\t\tif (live.length === 0) return { decision: 'no-shards', shard: null };\n\n\t\tconst now = clock();\n\t\tconst state = ensureTenant(request.tenantId, now);\n\n\t\tif (state.active >= cap) {\n\t\t\treturn { decision: 'capped', shard: null };\n\t\t}\n\n\t\trefill(state, now);\n\t\tif (state.tokens < 1) {\n\t\t\treturn { decision: 'rate-limited', shard: null };\n\t\t}\n\t\tstate.tokens -= 1;\n\n\t\tconst key = request.channelId === undefined\n\t\t\t? request.tenantId\n\t\t\t: `${request.tenantId}:${request.channelId}`;\n\t\tconst index = strategy(key, live);\n\t\tconst chosen = live[Math.max(0, Math.min(index, live.length - 1))]!;\n\t\treturn { decision: 'allow', shard: chosen };\n\t};\n\n\tconst acquire: Router['acquire'] = (tenantId) => {\n\t\tconst now = clock();\n\t\tconst state = ensureTenant(tenantId, now);\n\t\tstate.active += 1;\n\t\tlet released = false;\n\t\treturn {\n\t\t\tactive: state.active,\n\t\t\trelease: () => {\n\t\t\t\tif (released) return;\n\t\t\t\treleased = true;\n\t\t\t\tconst current = tenants.get(tenantId);\n\t\t\t\tif (current && current.active > 0) current.active -= 1;\n\t\t\t},\n\t\t};\n\t};\n\n\treturn {\n\t\tacquire,\n\t\taddShard: (shard) => {\n\t\t\tif (shardList.some((existing) => existing.id === shard.id)) return;\n\t\t\tshardList.push(shard);\n\t\t\thealthy.set(shard.id, true);\n\t\t},\n\t\tdispose: () => {\n\t\t\tdisposed = true;\n\t\t\tshardList.length = 0;\n\t\t\thealthy.clear();\n\t\t\ttenants.clear();\n\t\t},\n\t\tmarkHealthy: (id) => {\n\t\t\tif (healthy.has(id)) healthy.set(id, true);\n\t\t},\n\t\tmarkUnhealthy: (id) => {\n\t\t\tif (healthy.has(id)) healthy.set(id, false);\n\t\t},\n\t\tremoveShard: (id) => {\n\t\t\tconst at = shardList.findIndex((shard) => shard.id === id);\n\t\t\tif (at >= 0) shardList.splice(at, 1);\n\t\t\thealthy.delete(id);\n\t\t},\n\t\troute,\n\t\tshards: () => shardList.map((shard) => ({ ...shard })),\n\t\tsnapshot: () => ({\n\t\t\tshards: shardList.map((shard) => ({\n\t\t\t\t...shard,\n\t\t\t\tactive: 0,\n\t\t\t\thealthy: healthy.get(shard.id) === true,\n\t\t\t})),\n\t\t\ttenants: tenants.size,\n\t\t}),\n\t};\n};\n"
5
+ "/**\n * @absolutejs/router — multi-tenant connection routing primitive for Bun PaaS\n * gateways.\n *\n * Sits between an incoming request / WS upgrade and N backend processes (each\n * one a `@absolutejs/runtime` instance hosting a `@absolutejs/sync` engine\n * for a subset of tenants). Decides which shard owns the tenant, whether the\n * tenant is over its rate limit (tenant-wide + per-route), whether the tenant\n * is over its connection cap, whether the caller-supplied allow hook approves,\n * and whether the chosen shard is healthy and not draining.\n *\n * v0.1.0 is bun/elysia-agnostic — pure logic, no `Bun.serve` or proxy code.\n * The caller wires the routing decision into whichever HTTP/WS layer they\n * have. An Elysia adapter ships in a later 0.0.x as a subpath.\n */\n\nexport type Shard = {\n\tid: string;\n\t/**\n\t * Connection target for the chosen backend. Format is opaque to the router\n\t * — `ws://host:port`, `https://host`, a unix socket path, whatever the\n\t * caller's proxy layer understands.\n\t */\n\turl: string;\n\t/**\n\t * Relative weight for hash strategies that support it (rendezvous). Jump\n\t * hash ignores weights — every shard is treated as weight 1. Default 1.\n\t */\n\tweight?: number;\n};\n\n/**\n * Pure hash-strategy function. Given the routing key + the list of currently\n * eligible shards (healthy AND not draining), return the index in `shards` of\n * the chosen shard. The router never calls a strategy with an empty `shards`\n * array — that case is handled upstream as `no-shards`.\n */\nexport type HashStrategyFn = (key: string, shards: ReadonlyArray<Shard>) => number;\n\nexport type HashStrategy = 'jump' | 'rendezvous' | HashStrategyFn;\n\n/**\n * Per-tenant token-bucket rate limit. `tokens` is the bucket capacity AND the\n * starting balance; `refillPerSecond` is added continuously up to capacity.\n * Defaults: `Infinity` tokens / `0` refill = no limit.\n */\nexport type RateLimit = {\n\ttokens: number;\n\trefillPerSecond: number;\n};\n\n/**\n * Optional caller-supplied gate. Called per-route with the tenant id; returning\n * `false` causes the route to return `decision: 'denied'`. The intended\n * use is `meter.allow` from `@absolutejs/metering` — pass it directly to refuse\n * routes for over-quota tenants without wiring the integration manually.\n */\nexport type AllowHook = (tenantId: string) => boolean;\n\n/**\n * Optional caller-supplied load metric. Called per `route()` with a shard id;\n * returning a value > 1 makes the rendezvous strategy bias AWAY from this shard\n * (effective weight = `shard.weight / load`). Used to avoid hot-spotting when\n * a stickiness-locked shard is overloaded — the router can't move existing\n * tenants, but it can avoid sending NEW tenants there.\n *\n * Jump-hash ignores this — it has no per-call weight bias by design.\n */\nexport type ShardLoadFn = (shardId: string) => number;\n\nexport type RouterOptions = {\n\t/** Initial shard set. Can be empty (every `route()` returns `no-shards`). */\n\tshards: Shard[];\n\t/** Hash strategy. Default `'jump'`. */\n\thashStrategy?: HashStrategy;\n\t/**\n\t * Max concurrent connections per tenant (counted via `acquire()` /\n\t * `release()`). Default `Infinity` (no cap). When reached, `route()`\n\t * returns `'capped'`.\n\t */\n\tperTenantConnectionCap?: number;\n\t/**\n\t * Per-tenant token bucket. Default `{ tokens: Infinity, refillPerSecond: 0 }`\n\t * (no limit). When the tenant's bucket is empty, `route()` returns\n\t * `'rate-limited'`. One `route()` call costs one token.\n\t */\n\tperTenantRateLimit?: RateLimit;\n\t/**\n\t * Per-route token buckets layered on top of the tenant-wide bucket.\n\t * Keyed by route name; supplied by the caller via\n\t * `route({ route: 'someRoute' })`. The tenant bucket AND the route bucket\n\t * must both have a token available. When the route bucket is empty,\n\t * `route()` returns `'rate-limited'` with `routeId` set.\n\t */\n\tperRouteRateLimits?: Record<string, RateLimit>;\n\t/** Optional load hook biasing the rendezvous strategy. */\n\tload?: ShardLoadFn;\n\t/** Optional caller-supplied allow gate. */\n\tallow?: AllowHook;\n\t/** Override `Date.now` for tests. */\n\tclock?: () => number;\n};\n\nexport type RouteDecision =\n\t| 'allow'\n\t| 'rate-limited'\n\t| 'capped'\n\t| 'no-shards'\n\t| 'denied';\n\n/**\n * Returned by {@link Router.metrics}. Combines point-in-time state with\n * cumulative counters since `createRouter()`. Survives `dispose()` so\n * post-shutdown introspection still reads totals. Added in 0.2.0.\n *\n * - `routes` — total `route()` calls (any decision).\n * - `acquires` — total `acquire()` calls.\n * - `rejectsByDecision` — counts per non-allow `RouteDecision`. The\n * operator's \"where am I shedding load?\" answer.\n * - `shardLoadDistribution` — cumulative routes assigned per shard\n * since `createRouter()`. With `markHealthy`/`drainShard` this is\n * the \"is rebalancing actually rebalancing?\" signal. (Per-shard\n * *active* connection count would require route-to-acquire\n * correlation that the current `acquire(tenantId)` API doesn't\n * capture; cumulative routes are the directly-observable proxy.)\n * - `lastRouteMs` — wall-clock of the most recent `route()` call (a\n * climb means the hot path is getting slower — usually a sign that\n * `load:` is doing too much work, or the shard count exploded).\n */\nexport type RouterMetrics = {\n\troutes: number;\n\tacquires: number;\n\trejectsByDecision: Record<Exclude<RouteDecision, 'allow'>, number>;\n\tshardLoadDistribution: Record<string, number>;\n\tlastRouteMs: number;\n};\n\nexport type RouteRequest = {\n\ttenantId: string;\n\t/**\n\t * Optional sub-key within a tenant for shardable channels. When set, the\n\t * hash key is `${tenantId}:${channelId}`. Use this when a single tenant is\n\t * too hot for one engine and its work can be partitioned (e.g. per-doc,\n\t * per-room) across multiple engines. Different channels of the same\n\t * tenant may land on different shards.\n\t */\n\tchannelId?: string;\n\t/**\n\t * Optional per-route rate-limit key. Looked up in `perRouteRateLimits`.\n\t * Unknown routes pass without per-route gating (only the tenant-wide\n\t * bucket applies).\n\t */\n\troute?: string;\n};\n\nexport type RouteResult = {\n\tdecision: RouteDecision;\n\t/** The chosen shard. `null` when `decision !== 'allow'`. */\n\tshard: Shard | null;\n\t/**\n\t * Set on a `'rate-limited'` result to tell the caller which bucket emptied:\n\t * `'tenant'` for the tenant-wide bucket, the route id for a per-route bucket.\n\t */\n\temptiedBucket?: 'tenant' | string;\n};\n\nexport type AcquireHandle = {\n\t/** Number of active connections for this tenant after acquire (>=1). */\n\tactive: number;\n\t/** Releases one connection for this tenant. Idempotent — safe to call once. */\n\trelease: () => void;\n};\n\nexport type RouterSnapshot = {\n\tversion: 1;\n\tat: number;\n\tshards: Array<Shard & { healthy: boolean; draining: boolean; active: number }>;\n\ttenants: Array<{\n\t\ttenant: string;\n\t\tactive: number;\n\t\ttokens: number;\n\t\tlastRefillAt: number;\n\t}>;\n\trouteBuckets: Array<{\n\t\ttenant: string;\n\t\troute: string;\n\t\ttokens: number;\n\t\tlastRefillAt: number;\n\t}>;\n};\n\nexport type Router = {\n\troute: (request: RouteRequest) => RouteResult;\n\tacquire: (tenantId: string) => AcquireHandle;\n\tmarkHealthy: (shardId: string) => void;\n\tmarkUnhealthy: (shardId: string) => void;\n\t/**\n\t * Begin draining a shard — exclude it from new routing decisions while\n\t * leaving existing acquires alone. Semantically distinct from\n\t * `markUnhealthy` (an operator-intentional state, not a failure).\n\t * `markHealthy` cancels both states. Use this before a planned shard\n\t * shutdown — tenants on the shard rehash to healthy non-draining shards\n\t * on their next route, but in-flight requests are NOT torn down.\n\t */\n\tdrainShard: (shardId: string) => void;\n\tisHealthy: (shardId: string) => boolean;\n\tisDraining: (shardId: string) => boolean;\n\taddShard: (shard: Shard) => void;\n\tremoveShard: (shardId: string) => void;\n\tshards: () => Shard[];\n\tsnapshot: () => RouterSnapshot;\n\trestore: (snapshot: RouterSnapshot) => void;\n\t/**\n\t * Operator-shaped point-in-time + cumulative metrics since\n\t * `createRouter()`. Use for tier dashboards and \"where am I\n\t * shedding load\" alerts. Distinct from `snapshot()`, which is the\n\t * persistence shape (preserves tenant + bucket state across a\n\t * shard reboot). Added in 0.2.0.\n\t */\n\tmetrics: () => RouterMetrics;\n\tdispose: () => void;\n};\n\n// -----------------------------------------------------------------------------\n// Hash strategies\n// -----------------------------------------------------------------------------\n\n/**\n * FNV-1a 32-bit. Cheap, no deps, good enough as a seed for the consistent\n * hash strategies below. Not cryptographic — do NOT use as a security hash.\n */\nconst fnv1a32 = (input: string): number => {\n\tlet hash = 0x811c9dc5;\n\tfor (let i = 0; i < input.length; i++) {\n\t\thash ^= input.charCodeAt(i);\n\t\thash = Math.imul(hash, 0x01000193);\n\t}\n\treturn hash >>> 0;\n};\n\n/**\n * Jump-consistent-hash, Lamping & Veach 2014. Maps a 64-bit key to a bucket\n * in `[0, numBuckets)`. Properties: O(log n), no memory, exactly 1/N keys\n * move when buckets are added at the tail.\n */\nconst jumpHash = (key: string, numBuckets: number): number => {\n\tif (numBuckets <= 0) return -1;\n\tlet k = BigInt(fnv1a32(key)) | (BigInt(fnv1a32(key + '#hi')) << 32n);\n\tlet b = -1n;\n\tlet j = 0n;\n\twhile (j < BigInt(numBuckets)) {\n\t\tb = j;\n\t\tk = (k * 2862933555777941757n + 1n) & 0xffffffffffffffffn;\n\t\tconst shifted = (k >> 33n) + 1n;\n\t\tj = ((b + 1n) * (1n << 31n)) / shifted;\n\t}\n\treturn Number(b);\n};\n\nconst jumpStrategy: HashStrategyFn = (key, shards) => jumpHash(key, shards.length);\n\nconst makeRendezvousStrategy = (load?: ShardLoadFn): HashStrategyFn => (key, shards) => {\n\tlet bestIndex = 0;\n\tlet bestScore = -Infinity;\n\tfor (let i = 0; i < shards.length; i++) {\n\t\tconst shard = shards[i]!;\n\t\tconst baseWeight = shard.weight ?? 1;\n\t\tif (baseWeight <= 0) continue;\n\t\tconst loadValue = load ? Math.max(load(shard.id), 0.0001) : 1;\n\t\tconst effectiveWeight = baseWeight / loadValue;\n\t\tconst seed = fnv1a32(`${key}|${shard.id}`);\n\t\tconst u = (seed + 1) / 0x1_0000_0000;\n\t\tconst score = effectiveWeight * -Math.log(u);\n\t\tif (score > bestScore) {\n\t\t\tbestScore = score;\n\t\t\tbestIndex = i;\n\t\t}\n\t}\n\treturn bestIndex;\n};\n\nconst resolveStrategy = (strategy: HashStrategy, load?: ShardLoadFn): HashStrategyFn => {\n\tif (strategy === 'jump') return jumpStrategy;\n\tif (strategy === 'rendezvous') return makeRendezvousStrategy(load);\n\treturn strategy;\n};\n\n// -----------------------------------------------------------------------------\n// Router\n// -----------------------------------------------------------------------------\n\ntype Bucket = {\n\ttokens: number;\n\tlastRefillAt: number;\n};\n\ntype TenantState = {\n\tactive: number;\n\tbucket: Bucket;\n};\n\nexport const createRouter = (options: RouterOptions): Router => {\n\tconst clock = options.clock ?? Date.now;\n\tconst strategy = resolveStrategy(options.hashStrategy ?? 'jump', options.load);\n\tconst cap = options.perTenantConnectionCap ?? Infinity;\n\tconst rate = options.perTenantRateLimit ?? { refillPerSecond: 0, tokens: Infinity };\n\tconst perRouteRateLimits = options.perRouteRateLimits ?? {};\n\tconst allowHook = options.allow;\n\n\tconst shardList: Shard[] = [...options.shards];\n\tconst healthy = new Map<string, boolean>();\n\tconst draining = new Set<string>();\n\tfor (const shard of shardList) healthy.set(shard.id, true);\n\n\tconst tenants = new Map<string, TenantState>();\n\t/** Per-tenant per-route bucket. Key = `${tenant}|${route}`. */\n\tconst routeBuckets = new Map<string, Bucket>();\n\tlet disposed = false;\n\t// 0.2.0: cumulative operator counters. Per-shard active count is\n\t// derived from `acquires` minus per-shard releases — but tracking\n\t// per-shard requires routing → acquire correlation that the\n\t// existing API doesn't enforce. Use point-in-time aggregation from\n\t// `tenants` Map: each tenant's active count contributes to its\n\t// most-recently-routed shard. (Acquire isn't shard-tagged today;\n\t// adding a `shardId` to the AcquireHandle is a separate change.)\n\tlet totalRoutes = 0;\n\tlet totalAcquires = 0;\n\tlet lastRouteMs = 0;\n\tconst rejectsByDecision: Record<\n\t\tExclude<RouteDecision, 'allow'>,\n\t\tnumber\n\t> = {\n\t\t'rate-limited': 0,\n\t\t'capped': 0,\n\t\t'no-shards': 0,\n\t\t'denied': 0\n\t};\n\tconst shardLoadByShard = new Map<string, number>();\n\n\tconst freshTenant = (now: number): TenantState => ({\n\t\tactive: 0,\n\t\tbucket: { lastRefillAt: now, tokens: rate.tokens },\n\t});\n\n\tconst ensureTenant = (id: string, now: number): TenantState => {\n\t\tconst found = tenants.get(id);\n\t\tif (found) return found;\n\t\tconst fresh = freshTenant(now);\n\t\ttenants.set(id, fresh);\n\t\treturn fresh;\n\t};\n\n\tconst refillBucket = (bucket: Bucket, rule: RateLimit, now: number) => {\n\t\tif (rule.refillPerSecond <= 0) return;\n\t\tconst elapsedMs = now - bucket.lastRefillAt;\n\t\tif (elapsedMs <= 0) return;\n\t\tconst added = (elapsedMs / 1000) * rule.refillPerSecond;\n\t\tbucket.tokens = Math.min(rule.tokens, bucket.tokens + added);\n\t\tbucket.lastRefillAt = now;\n\t};\n\n\tconst ensureRouteBucket = (tenant: string, route: string, rule: RateLimit, now: number): Bucket => {\n\t\tconst key = `${tenant}|${route}`;\n\t\tconst found = routeBuckets.get(key);\n\t\tif (found) return found;\n\t\tconst fresh: Bucket = { lastRefillAt: now, tokens: rule.tokens };\n\t\trouteBuckets.set(key, fresh);\n\t\treturn fresh;\n\t};\n\n\tconst eligibleShards = (): Shard[] =>\n\t\tshardList.filter((shard) => healthy.get(shard.id) === true && !draining.has(shard.id));\n\n\tconst route: Router['route'] = (request) => {\n\t\tconst routeStart = clock();\n\t\ttotalRoutes += 1;\n\t\tconst recordRejection = (\n\t\t\tdecision: Exclude<RouteDecision, 'allow'>\n\t\t): RouteResult => {\n\t\t\trejectsByDecision[decision] += 1;\n\t\t\tlastRouteMs = clock() - routeStart;\n\t\t\treturn { decision, shard: null };\n\t\t};\n\t\tif (disposed) return recordRejection('no-shards');\n\n\t\tconst live = eligibleShards();\n\t\tif (live.length === 0) return recordRejection('no-shards');\n\n\t\tif (allowHook && !allowHook(request.tenantId)) {\n\t\t\treturn recordRejection('denied');\n\t\t}\n\n\t\tconst now = routeStart;\n\t\tconst state = ensureTenant(request.tenantId, now);\n\n\t\tif (state.active >= cap) {\n\t\t\treturn recordRejection('capped');\n\t\t}\n\n\t\trefillBucket(state.bucket, rate, now);\n\t\tif (state.bucket.tokens < 1) {\n\t\t\trejectsByDecision['rate-limited'] += 1;\n\t\t\tlastRouteMs = clock() - routeStart;\n\t\t\treturn {\n\t\t\t\tdecision: 'rate-limited',\n\t\t\t\temptiedBucket: 'tenant',\n\t\t\t\tshard: null\n\t\t\t};\n\t\t}\n\n\t\tlet routeBucket: Bucket | null = null;\n\t\tlet routeRule: RateLimit | null = null;\n\t\tif (request.route !== undefined && perRouteRateLimits[request.route] !== undefined) {\n\t\t\trouteRule = perRouteRateLimits[request.route]!;\n\t\t\trouteBucket = ensureRouteBucket(request.tenantId, request.route, routeRule, now);\n\t\t\trefillBucket(routeBucket, routeRule, now);\n\t\t\tif (routeBucket.tokens < 1) {\n\t\t\t\trejectsByDecision['rate-limited'] += 1;\n\t\t\t\tlastRouteMs = clock() - routeStart;\n\t\t\t\treturn {\n\t\t\t\t\tdecision: 'rate-limited',\n\t\t\t\t\temptiedBucket: request.route,\n\t\t\t\t\tshard: null\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Commit both buckets only after both passed.\n\t\tstate.bucket.tokens -= 1;\n\t\tif (routeBucket) routeBucket.tokens -= 1;\n\n\t\tconst key = request.channelId === undefined\n\t\t\t? request.tenantId\n\t\t\t: `${request.tenantId}:${request.channelId}`;\n\t\tconst index = strategy(key, live);\n\t\tconst chosen = live[Math.max(0, Math.min(index, live.length - 1))]!;\n\t\tshardLoadByShard.set(\n\t\t\tchosen.id,\n\t\t\t(shardLoadByShard.get(chosen.id) ?? 0) + 1\n\t\t);\n\t\tlastRouteMs = clock() - routeStart;\n\t\treturn { decision: 'allow', shard: chosen };\n\t};\n\n\tconst acquire: Router['acquire'] = (tenantId) => {\n\t\tconst now = clock();\n\t\tconst state = ensureTenant(tenantId, now);\n\t\tstate.active += 1;\n\t\ttotalAcquires += 1;\n\t\tlet released = false;\n\t\treturn {\n\t\t\tactive: state.active,\n\t\t\trelease: () => {\n\t\t\t\tif (released) return;\n\t\t\t\treleased = true;\n\t\t\t\tconst current = tenants.get(tenantId);\n\t\t\t\tif (current && current.active > 0) current.active -= 1;\n\t\t\t},\n\t\t};\n\t};\n\n\treturn {\n\t\tacquire,\n\t\taddShard: (shard) => {\n\t\t\tif (shardList.some((existing) => existing.id === shard.id)) return;\n\t\t\tshardList.push(shard);\n\t\t\thealthy.set(shard.id, true);\n\t\t},\n\t\tdispose: () => {\n\t\t\tdisposed = true;\n\t\t\tshardList.length = 0;\n\t\t\thealthy.clear();\n\t\t\tdraining.clear();\n\t\t\ttenants.clear();\n\t\t\trouteBuckets.clear();\n\t\t},\n\t\tdrainShard: (id) => {\n\t\t\tif (healthy.has(id)) draining.add(id);\n\t\t},\n\t\tisDraining: (id) => draining.has(id),\n\t\tisHealthy: (id) => healthy.get(id) === true,\n\t\tmetrics: () => ({\n\t\t\tacquires: totalAcquires,\n\t\t\tlastRouteMs,\n\t\t\trejectsByDecision: { ...rejectsByDecision },\n\t\t\troutes: totalRoutes,\n\t\t\tshardLoadDistribution: Object.fromEntries(shardLoadByShard)\n\t\t}),\n\t\tmarkHealthy: (id) => {\n\t\t\tif (healthy.has(id)) {\n\t\t\t\thealthy.set(id, true);\n\t\t\t\tdraining.delete(id);\n\t\t\t}\n\t\t},\n\t\tmarkUnhealthy: (id) => {\n\t\t\tif (healthy.has(id)) healthy.set(id, false);\n\t\t},\n\t\tremoveShard: (id) => {\n\t\t\tconst at = shardList.findIndex((shard) => shard.id === id);\n\t\t\tif (at >= 0) shardList.splice(at, 1);\n\t\t\thealthy.delete(id);\n\t\t\tdraining.delete(id);\n\t\t},\n\t\troute,\n\t\tshards: () => shardList.map((shard) => ({ ...shard })),\n\t\tsnapshot: () => {\n\t\t\tconst now = clock();\n\t\t\tconst tenantsOut: RouterSnapshot['tenants'] = [];\n\t\t\tfor (const [tenant, state] of tenants) {\n\t\t\t\ttenantsOut.push({\n\t\t\t\t\tactive: state.active,\n\t\t\t\t\tlastRefillAt: state.bucket.lastRefillAt,\n\t\t\t\t\ttenant,\n\t\t\t\t\ttokens: state.bucket.tokens,\n\t\t\t\t});\n\t\t\t}\n\t\t\tconst routesOut: RouterSnapshot['routeBuckets'] = [];\n\t\t\tfor (const [key, bucket] of routeBuckets) {\n\t\t\t\tconst pipe = key.indexOf('|');\n\t\t\t\tif (pipe < 0) continue;\n\t\t\t\troutesOut.push({\n\t\t\t\t\tlastRefillAt: bucket.lastRefillAt,\n\t\t\t\t\troute: key.slice(pipe + 1),\n\t\t\t\t\ttenant: key.slice(0, pipe),\n\t\t\t\t\ttokens: bucket.tokens,\n\t\t\t\t});\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tat: now,\n\t\t\t\trouteBuckets: routesOut,\n\t\t\t\tshards: shardList.map((shard) => {\n\t\t\t\t\tconst active = Array.from(tenants.values()).reduce((acc, state) => acc + state.active, 0);\n\t\t\t\t\treturn {\n\t\t\t\t\t\t...shard,\n\t\t\t\t\t\tactive,\n\t\t\t\t\t\tdraining: draining.has(shard.id),\n\t\t\t\t\t\thealthy: healthy.get(shard.id) === true,\n\t\t\t\t\t};\n\t\t\t\t}),\n\t\t\t\ttenants: tenantsOut,\n\t\t\t\tversion: 1,\n\t\t\t};\n\t\t},\n\t\trestore: (snap) => {\n\t\t\ttenants.clear();\n\t\t\trouteBuckets.clear();\n\t\t\tdraining.clear();\n\t\t\tfor (const t of snap.tenants) {\n\t\t\t\ttenants.set(t.tenant, {\n\t\t\t\t\tactive: t.active,\n\t\t\t\t\tbucket: { lastRefillAt: t.lastRefillAt, tokens: t.tokens },\n\t\t\t\t});\n\t\t\t}\n\t\t\tfor (const r of snap.routeBuckets) {\n\t\t\t\trouteBuckets.set(`${r.tenant}|${r.route}`, {\n\t\t\t\t\tlastRefillAt: r.lastRefillAt,\n\t\t\t\t\ttokens: r.tokens,\n\t\t\t\t});\n\t\t\t}\n\t\t\tfor (const shard of snap.shards) {\n\t\t\t\thealthy.set(shard.id, shard.healthy);\n\t\t\t\tif (shard.draining) draining.add(shard.id);\n\t\t\t}\n\t\t},\n\t};\n};\n"
6
6
  ],
7
- "mappings": "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",
8
- "debugId": "E88C919511EA465E64756E2164756E21",
7
+ "mappings": "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",
8
+ "debugId": "E49309CC1211AA2864756E2164756E21",
9
9
  "names": []
10
10
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@absolutejs/router",
3
- "version": "0.0.1",
3
+ "version": "0.2.0",
4
4
  "description": "Multi-tenant connection routing primitive for Bun PaaS gateways. Consistent-hash tenant→shard, per-tenant connection cap, per-tenant rate limit, healthy-shard skip. The library that goes in front of N @absolutejs/runtime instances.",
5
5
  "repository": {
6
6
  "type": "git",