@absolutejs/router 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,94 @@
1
+ # Business Source License 1.1
2
+
3
+ **Licensor:** Alex Kahn
4
+
5
+ **Licensed Work:** @absolutejs/router (https://github.com/absolutejs/router)
6
+
7
+ **Change Date:** May 29, 2030
8
+
9
+ **Change License:** Apache License, Version 2.0
10
+
11
+ ---
12
+
13
+ ## Terms
14
+
15
+ The Licensor hereby grants you the right to copy, modify, create derivative
16
+ works, redistribute, and make non-production use of the Licensed Work. The
17
+ Licensor may make an Additional Use Grant, permitting limited production use.
18
+
19
+ ### Additional Use Grant
20
+
21
+ You may use the Licensed Work in production, provided your use does not include
22
+ any of the following:
23
+
24
+ 1. **Offering a Competing Service.** You may not offer the Licensed Work, or
25
+ any derivative or substantial portion of it, to third parties as a hosted or
26
+ managed service that competes with a hosted multi-tenant connection router
27
+ or WebSocket edge gateway for application runtimes (including, but not
28
+ limited to, services like Cloudflare Workers WebSockets / Smart Placement,
29
+ Vercel's edge router, Liveblocks' WebSocket fan-out, PartyKit, Ably,
30
+ Pusher, Soketi, or any similar hosted offering whose primary value to its
31
+ users is multi-tenant connection routing, tenant-to-shard stickiness,
32
+ per-tenant connection capping, or per-tenant rate limiting at the network
33
+ edge). This includes any product whose primary value to its users is the
34
+ functionality the Licensed Work provides.
35
+
36
+ 2. **Resale or Redistribution as a Standalone Product.** You may not sell,
37
+ license, or distribute the Licensed Work, or any derivative or fork of it,
38
+ as a standalone commercial product.
39
+
40
+ 3. **Removal of Attribution.** Any derivative work, fork, or redistribution of
41
+ the Licensed Work must prominently credit AbsoluteJS and include a link to
42
+ the original project repository (https://github.com/absolutejs/router).
43
+
44
+ For clarity, the following uses are expressly permitted:
45
+
46
+ - Using the Licensed Work to build and operate your own applications, websites,
47
+ internal tools, or SaaS products (whether commercial or non-commercial), so
48
+ long as the Licensed Work itself is not the primary product you are selling.
49
+ - Using the Licensed Work as a dependency in commercial software you build and
50
+ sell, as long as the software is not itself a competing managed service of
51
+ the kind described in clause 1.
52
+ - Providing consulting, development, or professional services to clients using
53
+ the Licensed Work.
54
+ - Forking and modifying the Licensed Work for your own internal use, provided
55
+ attribution is maintained.
56
+
57
+ ### Change Date and Change License
58
+
59
+ On the Change Date specified above, or on such other date as the Licensor may
60
+ specify by written notice, the Licensed Work will be made available under the
61
+ Change License (Apache License, Version 2.0). Until the Change Date, the terms
62
+ of this Business Source License 1.1 apply.
63
+
64
+ ### Trademark
65
+
66
+ This license does not grant you any rights to use the "AbsoluteJS" or
67
+ "@absolutejs" name, logo, or any related trademarks. Forks and derivative works
68
+ must not be named or branded in a manner that suggests endorsement by or
69
+ affiliation with AbsoluteJS or the Licensor.
70
+
71
+ ### Notices
72
+
73
+ You must not remove or obscure any licensing, copyright, or other notices
74
+ included in the Licensed Work.
75
+
76
+ ### No Warranty
77
+
78
+ THE LICENSED WORK IS PROVIDED "AS IS". THE LICENSOR HEREBY DISCLAIMS ALL
79
+ WARRANTIES, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF
80
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO
81
+ EVENT SHALL THE LICENSOR BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY,
82
+ WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR
83
+ IN CONNECTION WITH THE LICENSED WORK OR THE USE OR OTHER DEALINGS IN THE
84
+ LICENSED WORK.
85
+
86
+ ---
87
+
88
+ ## Contact
89
+
90
+ For commercial licensing inquiries or additional permissions, contact:
91
+
92
+ - **Alex Kahn**
93
+ - alexkahndev@gmail.com
94
+ - alexkahndev.github.io
package/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # @absolutejs/router
2
+
3
+ Multi-tenant connection routing primitive for Bun PaaS gateways. Sits in front
4
+ of N backend processes (each a [`@absolutejs/runtime`](https://github.com/absolutejs/runtime)
5
+ instance hosting a [`@absolutejs/sync`](https://github.com/absolutejs/sync) engine
6
+ for a subset of tenants) and decides — per request:
7
+
8
+ 1. Which shard owns this tenant (consistent hash, sticky)
9
+ 2. Is the tenant over its connection cap?
10
+ 3. Is the tenant over its rate limit?
11
+ 4. Is the chosen shard healthy?
12
+
13
+ Pure logic, zero Bun / Elysia surface. Wire `router.route(...)` into whichever
14
+ HTTP/WS layer you have (Bun.serve, Elysia, native node:http, anything that can
15
+ return a 503). An Elysia adapter ships in a later 0.0.x as a subpath.
16
+
17
+ ```ts
18
+ import { createRouter } from '@absolutejs/router';
19
+
20
+ const router = createRouter({
21
+ shards: [
22
+ { id: 'engine-1', url: 'ws://10.0.0.11:3000' },
23
+ { id: 'engine-2', url: 'ws://10.0.0.12:3000' },
24
+ ],
25
+ hashStrategy: 'jump',
26
+ perTenantConnectionCap: 100,
27
+ perTenantRateLimit: { tokens: 100, refillPerSecond: 10 },
28
+ });
29
+
30
+ // In your WS upgrade handler:
31
+ const decision = router.route({ tenantId, channelId });
32
+ if (decision.decision !== 'allow') {
33
+ return new Response(decision.decision, { status: 429 });
34
+ }
35
+ const handle = router.acquire(tenantId);
36
+ ws.data = { ...ws.data, release: handle.release, upstream: decision.shard!.url };
37
+ // ...proxy WS frames to decision.shard.url; call handle.release() on close.
38
+ ```
39
+
40
+ ## v0.0.1 surface
41
+
42
+ | API | Purpose |
43
+ |---|---|
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. |
48
+ | `router.addShard(shard)` / `router.removeShard(id)` | Runtime shard membership changes. |
49
+ | `router.shards()` / `router.snapshot()` | Inspection. |
50
+ | `router.dispose()` | Stop accepting routes; all subsequent `route()` returns `no-shards`. |
51
+
52
+ ### Hash strategies
53
+
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.
58
+ - **Custom**: pass `(key, shards) => index`.
59
+
60
+ ### Connection cap
61
+
62
+ `perTenantConnectionCap` is the max concurrent connections one tenant can hold,
63
+ counted via `acquire()` / the returned `release()`. When reached, `route()`
64
+ returns `capped` — your gateway should refuse the upgrade with `429` /
65
+ `503`. Default `Infinity` (no cap).
66
+
67
+ ### Rate limit
68
+
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).
74
+
75
+ ### Health
76
+
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.
80
+
81
+ ## Architectural role
82
+
83
+ - **`@absolutejs/sync`** — the engine each backend shard runs.
84
+ - **`@absolutejs/runtime`** — the process pool each backend shard spawns from.
85
+ - **`@absolutejs/metering`** — counts the bill; `meter.allow(tenant)` reads.
86
+ - **`@absolutejs/router`** — *this library*. The edge decision before traffic reaches a shard. `meter.allow()` can be wired into the gateway alongside `router.route()` to refuse over-quota tenants without paying for the upstream hop.
87
+
88
+ ## What v0.0.1 does NOT include
89
+
90
+ - The actual WS proxy implementation. Caller wires `Bun.serve` (or any HTTP/WS layer) to `router.route()` and forwards bytes themselves.
91
+ - The Elysia adapter (subpath in a later 0.0.x).
92
+ - Distributed router state across multiple edge replicas (v0.2+).
93
+ - Backend health-checking probe loop.
94
+ - TLS / HTTP3 termination.
95
+
96
+ ## License
97
+
98
+ BSL 1.1 with a named carveout for the hosted multi-tenant connection routing / WebSocket edge gateway category (Cloudflare Workers WebSockets, Cloudflare Smart Placement, Vercel edge router, Liveblocks' WebSocket fan-out, PartyKit, Ably, Pusher, Soketi). See [LICENSE](./LICENSE). Change Date: 4 years from first release; Change License: Apache 2.0.
@@ -0,0 +1,107 @@
1
+ /**
2
+ * @absolutejs/router — multi-tenant connection routing primitive for Bun PaaS
3
+ * gateways.
4
+ *
5
+ * Sits between an incoming request / WS upgrade and N backend processes (each
6
+ * one a `@absolutejs/runtime` instance hosting a `@absolutejs/sync` engine
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.
10
+ *
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.
14
+ */
15
+ export type Shard = {
16
+ id: string;
17
+ /**
18
+ * Connection target for the chosen backend. Format is opaque to the router
19
+ * — `ws://host:port`, `https://host`, a unix socket path, whatever the
20
+ * caller's proxy layer understands.
21
+ */
22
+ url: string;
23
+ /**
24
+ * Relative weight for hash strategies that support it (rendezvous). Jump
25
+ * hash ignores weights — every shard is treated as weight 1. Default 1.
26
+ */
27
+ weight?: number;
28
+ };
29
+ /**
30
+ * 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`
33
+ * array — that case is handled upstream as `no-shards`.
34
+ */
35
+ export type HashStrategyFn = (key: string, shards: ReadonlyArray<Shard>) => number;
36
+ export type HashStrategy = 'jump' | 'rendezvous' | HashStrategyFn;
37
+ /**
38
+ * Per-tenant token-bucket rate limit. `tokens` is the bucket capacity AND the
39
+ * starting balance; `refillPerSecond` is added continuously up to capacity.
40
+ * Defaults: `Infinity` tokens / `0` refill = no limit.
41
+ */
42
+ export type RateLimit = {
43
+ tokens: number;
44
+ refillPerSecond: number;
45
+ };
46
+ export type RouterOptions = {
47
+ /** Initial shard set. Can be empty (every `route()` returns `no-shards`). */
48
+ shards: Shard[];
49
+ /** Hash strategy. Default `'jump'`. */
50
+ hashStrategy?: HashStrategy;
51
+ /**
52
+ * Max concurrent connections per tenant (counted via `acquire()` /
53
+ * `release()`). Default `Infinity` (no cap). When reached, `route()`
54
+ * returns `'capped'`.
55
+ */
56
+ perTenantConnectionCap?: number;
57
+ /**
58
+ * Per-tenant token bucket. Default `{ tokens: Infinity, refillPerSecond: 0 }`
59
+ * (no limit). When the tenant's bucket is empty, `route()` returns
60
+ * `'rate-limited'`. One `route()` call costs one token.
61
+ */
62
+ perTenantRateLimit?: RateLimit;
63
+ /** Override `Date.now` for tests. */
64
+ clock?: () => number;
65
+ };
66
+ export type RouteDecision = 'allow' | 'rate-limited' | 'capped' | 'no-shards';
67
+ export type RouteRequest = {
68
+ tenantId: string;
69
+ /**
70
+ * Optional sub-key within a tenant for shardable channels. When set, the
71
+ * hash key is `${tenantId}:${channelId}`. Use this when a single tenant is
72
+ * too hot for one engine and its work can be partitioned (e.g. per-doc,
73
+ * per-room) across multiple engines. Different channels of the same
74
+ * tenant may land on different shards.
75
+ */
76
+ channelId?: string;
77
+ };
78
+ export type RouteResult = {
79
+ decision: RouteDecision;
80
+ /** The chosen shard. `null` only when `decision === 'no-shards'`. */
81
+ shard: Shard | null;
82
+ };
83
+ export type AcquireHandle = {
84
+ /** Number of active connections for this tenant after acquire (>=1). */
85
+ active: number;
86
+ /** Releases one connection for this tenant. Idempotent — safe to call once. */
87
+ release: () => void;
88
+ };
89
+ export type RouterSnapshot = {
90
+ shards: Array<Shard & {
91
+ healthy: boolean;
92
+ active: number;
93
+ }>;
94
+ tenants: number;
95
+ };
96
+ export type Router = {
97
+ route: (request: RouteRequest) => RouteResult;
98
+ acquire: (tenantId: string) => AcquireHandle;
99
+ markHealthy: (shardId: string) => void;
100
+ markUnhealthy: (shardId: string) => void;
101
+ addShard: (shard: Shard) => void;
102
+ removeShard: (shardId: string) => void;
103
+ shards: () => Shard[];
104
+ snapshot: () => RouterSnapshot;
105
+ dispose: () => void;
106
+ };
107
+ export declare const createRouter: (options: RouterOptions) => Router;
package/dist/index.js ADDED
@@ -0,0 +1,169 @@
1
+ // @bun
2
+ // src/index.ts
3
+ var fnv1a32 = (input) => {
4
+ let hash = 2166136261;
5
+ for (let i = 0;i < input.length; i++) {
6
+ hash ^= input.charCodeAt(i);
7
+ hash = Math.imul(hash, 16777619);
8
+ }
9
+ return hash >>> 0;
10
+ };
11
+ var jumpHash = (key, numBuckets) => {
12
+ if (numBuckets <= 0)
13
+ return -1;
14
+ let k = BigInt(fnv1a32(key)) | BigInt(fnv1a32(key + "#hi")) << 32n;
15
+ let b = -1n;
16
+ let j = 0n;
17
+ while (j < BigInt(numBuckets)) {
18
+ b = j;
19
+ k = k * 2862933555777941757n + 1n & 0xffffffffffffffffn;
20
+ const shifted = (k >> 33n) + 1n;
21
+ j = (b + 1n) * (1n << 31n) / shifted;
22
+ }
23
+ return Number(b);
24
+ };
25
+ var jumpStrategy = (key, shards) => jumpHash(key, shards.length);
26
+ var rendezvousStrategy = (key, shards) => {
27
+ let bestIndex = 0;
28
+ let bestScore = -Infinity;
29
+ for (let i = 0;i < shards.length; i++) {
30
+ const shard = shards[i];
31
+ const weight = shard.weight ?? 1;
32
+ if (weight <= 0)
33
+ continue;
34
+ const seed = fnv1a32(`${key}|${shard.id}`);
35
+ const u = (seed + 1) / 4294967296;
36
+ const score = weight * -Math.log(u);
37
+ if (score > bestScore) {
38
+ bestScore = score;
39
+ bestIndex = i;
40
+ }
41
+ }
42
+ return bestIndex;
43
+ };
44
+ var resolveStrategy = (strategy) => {
45
+ if (strategy === "jump")
46
+ return jumpStrategy;
47
+ if (strategy === "rendezvous")
48
+ return rendezvousStrategy;
49
+ return strategy;
50
+ };
51
+ var createRouter = (options) => {
52
+ const clock = options.clock ?? Date.now;
53
+ const strategy = resolveStrategy(options.hashStrategy ?? "jump");
54
+ const cap = options.perTenantConnectionCap ?? Infinity;
55
+ const rate = options.perTenantRateLimit ?? { refillPerSecond: 0, tokens: Infinity };
56
+ const shardList = [...options.shards];
57
+ const healthy = new Map;
58
+ for (const shard of shardList)
59
+ healthy.set(shard.id, true);
60
+ const tenants = new Map;
61
+ let disposed = false;
62
+ const freshTenant = (now) => ({
63
+ active: 0,
64
+ lastRefillAt: now,
65
+ tokens: rate.tokens
66
+ });
67
+ const ensureTenant = (id, now) => {
68
+ const found = tenants.get(id);
69
+ if (found)
70
+ return found;
71
+ const fresh = freshTenant(now);
72
+ tenants.set(id, fresh);
73
+ return fresh;
74
+ };
75
+ const refill = (state, now) => {
76
+ if (rate.refillPerSecond <= 0)
77
+ return;
78
+ const elapsedMs = now - state.lastRefillAt;
79
+ if (elapsedMs <= 0)
80
+ return;
81
+ const added = elapsedMs / 1000 * rate.refillPerSecond;
82
+ state.tokens = Math.min(rate.tokens, state.tokens + added);
83
+ state.lastRefillAt = now;
84
+ };
85
+ const healthyShards = () => shardList.filter((shard) => healthy.get(shard.id) === true);
86
+ const route = (request) => {
87
+ if (disposed)
88
+ return { decision: "no-shards", shard: null };
89
+ const live = healthyShards();
90
+ if (live.length === 0)
91
+ return { decision: "no-shards", shard: null };
92
+ const now = clock();
93
+ const state = ensureTenant(request.tenantId, now);
94
+ if (state.active >= cap) {
95
+ return { decision: "capped", shard: null };
96
+ }
97
+ refill(state, now);
98
+ if (state.tokens < 1) {
99
+ return { decision: "rate-limited", shard: null };
100
+ }
101
+ state.tokens -= 1;
102
+ const key = request.channelId === undefined ? request.tenantId : `${request.tenantId}:${request.channelId}`;
103
+ const index = strategy(key, live);
104
+ const chosen = live[Math.max(0, Math.min(index, live.length - 1))];
105
+ return { decision: "allow", shard: chosen };
106
+ };
107
+ const acquire = (tenantId) => {
108
+ const now = clock();
109
+ const state = ensureTenant(tenantId, now);
110
+ state.active += 1;
111
+ let released = false;
112
+ return {
113
+ active: state.active,
114
+ release: () => {
115
+ if (released)
116
+ return;
117
+ released = true;
118
+ const current = tenants.get(tenantId);
119
+ if (current && current.active > 0)
120
+ current.active -= 1;
121
+ }
122
+ };
123
+ };
124
+ return {
125
+ acquire,
126
+ addShard: (shard) => {
127
+ if (shardList.some((existing) => existing.id === shard.id))
128
+ return;
129
+ shardList.push(shard);
130
+ healthy.set(shard.id, true);
131
+ },
132
+ dispose: () => {
133
+ disposed = true;
134
+ shardList.length = 0;
135
+ healthy.clear();
136
+ tenants.clear();
137
+ },
138
+ markHealthy: (id) => {
139
+ if (healthy.has(id))
140
+ healthy.set(id, true);
141
+ },
142
+ markUnhealthy: (id) => {
143
+ if (healthy.has(id))
144
+ healthy.set(id, false);
145
+ },
146
+ removeShard: (id) => {
147
+ const at = shardList.findIndex((shard) => shard.id === id);
148
+ if (at >= 0)
149
+ shardList.splice(at, 1);
150
+ healthy.delete(id);
151
+ },
152
+ route,
153
+ 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
+ })
162
+ };
163
+ };
164
+ export {
165
+ createRouter
166
+ };
167
+
168
+ //# debugId=E88C919511EA465E64756E2164756E21
169
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/index.ts"],
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"
6
+ ],
7
+ "mappings": "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",
8
+ "debugId": "E88C919511EA465E64756E2164756E21",
9
+ "names": []
10
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@absolutejs/router",
3
+ "version": "0.0.1",
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
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/absolutejs/router.git"
8
+ },
9
+ "main": "./dist/index.js",
10
+ "module": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "type": "module",
13
+ "license": "BSL-1.1",
14
+ "author": "Alex Kahn",
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "keywords": [
19
+ "absolutejs",
20
+ "bun",
21
+ "paas",
22
+ "multi-tenant",
23
+ "router",
24
+ "consistent-hash",
25
+ "rate-limit",
26
+ "websocket"
27
+ ],
28
+ "scripts": {
29
+ "build": "rm -rf dist && bun build src/index.ts --outdir dist --sourcemap --target=bun && tsc --project tsconfig.build.json",
30
+ "test": "bun test tests/",
31
+ "typecheck": "tsc --noEmit",
32
+ "format": "prettier --write \"./**/*.{ts,json,md}\"",
33
+ "check:package": "bun run typecheck && bun run build && bun run test",
34
+ "release": "bun run format && bun run check:package && bun publish"
35
+ },
36
+ "peerDependencies": {
37
+ "bun-types": "^1.3.14"
38
+ },
39
+ "devDependencies": {
40
+ "@types/bun": "^1.3.14",
41
+ "prettier": "^3.8.3",
42
+ "typescript": "^6.0.3"
43
+ },
44
+ "exports": {
45
+ ".": {
46
+ "types": "./dist/index.d.ts",
47
+ "import": "./dist/index.js",
48
+ "default": "./dist/index.js"
49
+ }
50
+ },
51
+ "files": ["dist", "README.md"]
52
+ }