@absolutejs/router 0.0.1 → 0.1.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,22 @@ 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';
67
97
  export type RouteRequest = {
68
98
  tenantId: string;
69
99
  /**
@@ -74,11 +104,22 @@ export type RouteRequest = {
74
104
  * tenant may land on different shards.
75
105
  */
76
106
  channelId?: string;
107
+ /**
108
+ * Optional per-route rate-limit key. Looked up in `perRouteRateLimits`.
109
+ * Unknown routes pass without per-route gating (only the tenant-wide
110
+ * bucket applies).
111
+ */
112
+ route?: string;
77
113
  };
78
114
  export type RouteResult = {
79
115
  decision: RouteDecision;
80
- /** The chosen shard. `null` only when `decision === 'no-shards'`. */
116
+ /** The chosen shard. `null` when `decision !== 'allow'`. */
81
117
  shard: Shard | null;
118
+ /**
119
+ * Set on a `'rate-limited'` result to tell the caller which bucket emptied:
120
+ * `'tenant'` for the tenant-wide bucket, the route id for a per-route bucket.
121
+ */
122
+ emptiedBucket?: 'tenant' | string;
82
123
  };
83
124
  export type AcquireHandle = {
84
125
  /** Number of active connections for this tenant after acquire (>=1). */
@@ -87,21 +128,47 @@ export type AcquireHandle = {
87
128
  release: () => void;
88
129
  };
89
130
  export type RouterSnapshot = {
131
+ version: 1;
132
+ at: number;
90
133
  shards: Array<Shard & {
91
134
  healthy: boolean;
135
+ draining: boolean;
136
+ active: number;
137
+ }>;
138
+ tenants: Array<{
139
+ tenant: string;
92
140
  active: number;
141
+ tokens: number;
142
+ lastRefillAt: number;
143
+ }>;
144
+ routeBuckets: Array<{
145
+ tenant: string;
146
+ route: string;
147
+ tokens: number;
148
+ lastRefillAt: number;
93
149
  }>;
94
- tenants: number;
95
150
  };
96
151
  export type Router = {
97
152
  route: (request: RouteRequest) => RouteResult;
98
153
  acquire: (tenantId: string) => AcquireHandle;
99
154
  markHealthy: (shardId: string) => void;
100
155
  markUnhealthy: (shardId: string) => void;
156
+ /**
157
+ * Begin draining a shard — exclude it from new routing decisions while
158
+ * leaving existing acquires alone. Semantically distinct from
159
+ * `markUnhealthy` (an operator-intentional state, not a failure).
160
+ * `markHealthy` cancels both states. Use this before a planned shard
161
+ * shutdown — tenants on the shard rehash to healthy non-draining shards
162
+ * on their next route, but in-flight requests are NOT torn down.
163
+ */
164
+ drainShard: (shardId: string) => void;
165
+ isHealthy: (shardId: string) => boolean;
166
+ isDraining: (shardId: string) => boolean;
101
167
  addShard: (shard: Shard) => void;
102
168
  removeShard: (shardId: string) => void;
103
169
  shards: () => Shard[];
104
170
  snapshot: () => RouterSnapshot;
171
+ restore: (snapshot: RouterSnapshot) => void;
105
172
  dispose: () => void;
106
173
  };
107
174
  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,31 @@ 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;
62
68
  const freshTenant = (now) => ({
63
69
  active: 0,
64
- lastRefillAt: now,
65
- tokens: rate.tokens
70
+ bucket: { lastRefillAt: now, tokens: rate.tokens }
66
71
  });
67
72
  const ensureTenant = (id, now) => {
68
73
  const found = tenants.get(id);
@@ -72,33 +77,57 @@ var createRouter = (options) => {
72
77
  tenants.set(id, fresh);
73
78
  return fresh;
74
79
  };
75
- const refill = (state, now) => {
76
- if (rate.refillPerSecond <= 0)
80
+ const refillBucket = (bucket, rule, now) => {
81
+ if (rule.refillPerSecond <= 0)
77
82
  return;
78
- const elapsedMs = now - state.lastRefillAt;
83
+ const elapsedMs = now - bucket.lastRefillAt;
79
84
  if (elapsedMs <= 0)
80
85
  return;
81
- const added = elapsedMs / 1000 * rate.refillPerSecond;
82
- state.tokens = Math.min(rate.tokens, state.tokens + added);
83
- state.lastRefillAt = now;
86
+ const added = elapsedMs / 1000 * rule.refillPerSecond;
87
+ bucket.tokens = Math.min(rule.tokens, bucket.tokens + added);
88
+ bucket.lastRefillAt = now;
84
89
  };
85
- const healthyShards = () => shardList.filter((shard) => healthy.get(shard.id) === true);
90
+ const ensureRouteBucket = (tenant, route2, rule, now) => {
91
+ const key = `${tenant}|${route2}`;
92
+ const found = routeBuckets.get(key);
93
+ if (found)
94
+ return found;
95
+ const fresh = { lastRefillAt: now, tokens: rule.tokens };
96
+ routeBuckets.set(key, fresh);
97
+ return fresh;
98
+ };
99
+ const eligibleShards = () => shardList.filter((shard) => healthy.get(shard.id) === true && !draining.has(shard.id));
86
100
  const route = (request) => {
87
101
  if (disposed)
88
102
  return { decision: "no-shards", shard: null };
89
- const live = healthyShards();
103
+ const live = eligibleShards();
90
104
  if (live.length === 0)
91
105
  return { decision: "no-shards", shard: null };
106
+ if (allowHook && !allowHook(request.tenantId)) {
107
+ return { decision: "denied", shard: null };
108
+ }
92
109
  const now = clock();
93
110
  const state = ensureTenant(request.tenantId, now);
94
111
  if (state.active >= cap) {
95
112
  return { decision: "capped", shard: null };
96
113
  }
97
- refill(state, now);
98
- if (state.tokens < 1) {
99
- return { decision: "rate-limited", shard: null };
114
+ refillBucket(state.bucket, rate, now);
115
+ if (state.bucket.tokens < 1) {
116
+ return { decision: "rate-limited", emptiedBucket: "tenant", shard: null };
100
117
  }
101
- state.tokens -= 1;
118
+ let routeBucket = null;
119
+ let routeRule = null;
120
+ if (request.route !== undefined && perRouteRateLimits[request.route] !== undefined) {
121
+ routeRule = perRouteRateLimits[request.route];
122
+ routeBucket = ensureRouteBucket(request.tenantId, request.route, routeRule, now);
123
+ refillBucket(routeBucket, routeRule, now);
124
+ if (routeBucket.tokens < 1) {
125
+ return { decision: "rate-limited", emptiedBucket: request.route, shard: null };
126
+ }
127
+ }
128
+ state.bucket.tokens -= 1;
129
+ if (routeBucket)
130
+ routeBucket.tokens -= 1;
102
131
  const key = request.channelId === undefined ? request.tenantId : `${request.tenantId}:${request.channelId}`;
103
132
  const index = strategy(key, live);
104
133
  const chosen = live[Math.max(0, Math.min(index, live.length - 1))];
@@ -133,11 +162,21 @@ var createRouter = (options) => {
133
162
  disposed = true;
134
163
  shardList.length = 0;
135
164
  healthy.clear();
165
+ draining.clear();
136
166
  tenants.clear();
167
+ routeBuckets.clear();
137
168
  },
138
- markHealthy: (id) => {
169
+ drainShard: (id) => {
139
170
  if (healthy.has(id))
171
+ draining.add(id);
172
+ },
173
+ isDraining: (id) => draining.has(id),
174
+ isHealthy: (id) => healthy.get(id) === true,
175
+ markHealthy: (id) => {
176
+ if (healthy.has(id)) {
140
177
  healthy.set(id, true);
178
+ draining.delete(id);
179
+ }
141
180
  },
142
181
  markUnhealthy: (id) => {
143
182
  if (healthy.has(id))
@@ -148,22 +187,76 @@ var createRouter = (options) => {
148
187
  if (at >= 0)
149
188
  shardList.splice(at, 1);
150
189
  healthy.delete(id);
190
+ draining.delete(id);
151
191
  },
152
192
  route,
153
193
  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
- })
194
+ snapshot: () => {
195
+ const now = clock();
196
+ const tenantsOut = [];
197
+ for (const [tenant, state] of tenants) {
198
+ tenantsOut.push({
199
+ active: state.active,
200
+ lastRefillAt: state.bucket.lastRefillAt,
201
+ tenant,
202
+ tokens: state.bucket.tokens
203
+ });
204
+ }
205
+ const routesOut = [];
206
+ for (const [key, bucket] of routeBuckets) {
207
+ const pipe = key.indexOf("|");
208
+ if (pipe < 0)
209
+ continue;
210
+ routesOut.push({
211
+ lastRefillAt: bucket.lastRefillAt,
212
+ route: key.slice(pipe + 1),
213
+ tenant: key.slice(0, pipe),
214
+ tokens: bucket.tokens
215
+ });
216
+ }
217
+ return {
218
+ at: now,
219
+ routeBuckets: routesOut,
220
+ shards: shardList.map((shard) => {
221
+ const active = Array.from(tenants.values()).reduce((acc, state) => acc + state.active, 0);
222
+ return {
223
+ ...shard,
224
+ active,
225
+ draining: draining.has(shard.id),
226
+ healthy: healthy.get(shard.id) === true
227
+ };
228
+ }),
229
+ tenants: tenantsOut,
230
+ version: 1
231
+ };
232
+ },
233
+ restore: (snap) => {
234
+ tenants.clear();
235
+ routeBuckets.clear();
236
+ draining.clear();
237
+ for (const t of snap.tenants) {
238
+ tenants.set(t.tenant, {
239
+ active: t.active,
240
+ bucket: { lastRefillAt: t.lastRefillAt, tokens: t.tokens }
241
+ });
242
+ }
243
+ for (const r of snap.routeBuckets) {
244
+ routeBuckets.set(`${r.tenant}|${r.route}`, {
245
+ lastRefillAt: r.lastRefillAt,
246
+ tokens: r.tokens
247
+ });
248
+ }
249
+ for (const shard of snap.shards) {
250
+ healthy.set(shard.id, shard.healthy);
251
+ if (shard.draining)
252
+ draining.add(shard.id);
253
+ }
254
+ }
162
255
  };
163
256
  };
164
257
  export {
165
258
  createRouter
166
259
  };
167
260
 
168
- //# debugId=E88C919511EA465E64756E2164756E21
261
+ //# debugId=C4E01145DD33C32964756E2164756E21
169
262
  //# 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\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\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\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\tif (disposed) return { decision: 'no-shards', shard: null };\n\n\t\tconst live = eligibleShards();\n\t\tif (live.length === 0) return { decision: 'no-shards', shard: null };\n\n\t\tif (allowHook && !allowHook(request.tenantId)) {\n\t\t\treturn { decision: 'denied', shard: null };\n\t\t}\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\trefillBucket(state.bucket, rate, now);\n\t\tif (state.bucket.tokens < 1) {\n\t\t\treturn { decision: 'rate-limited', emptiedBucket: 'tenant', shard: null };\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\treturn { decision: 'rate-limited', emptiedBucket: request.route, shard: null };\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\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\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\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": "C4E01145DD33C32964756E2164756E21",
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.1.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",