AbsoluteJS

Router

Per-tenant connection routing for the gateway in front of your Bun backends. Sits between an incoming request / WS upgrade and N backend processes; decides which shard owns the tenant, whether the tenant is over its rate limit, whether the tenant is over its connection cap, and whether the chosen shard is healthy and not draining. Pure logic — wire it into whatever HTTP/WS layer you have.

#One deterministic decision at the gateway

Every route call applies the same ordered gates so quota, capacity, health, and shard ownership cannot disagree.

  1. Authorize
    Ask the application policy or meter whether this tenant may consume work.
  2. Bound
    Check the tenant request bucket and active connection cap.
  3. Filter
    Remove unhealthy and intentionally draining shards from the candidate set.
  4. Place
    Choose a sticky owner with jump hash or weighted rendezvous.
  5. Explain
    Return an explicit allow, denied, capped, rate-limited, or no-shards decision.

#Quick Start

route() returns { decision, shard, emptiedBucket? } where decision is one of allow / rate-limited / capped / no-shards / denied. The gateway forwards bytes to shard.url on allow; everything else is a 4xx.

TS
import { createRouter } from '@absolutejs/router';

const router = createRouter({
  shards: [
    { id: 'engine-1', url: 'ws://10.0.0.11:3000' },
    { id: 'engine-2', url: 'ws://10.0.0.12:3000' },
  ],
  hashStrategy: 'jump',                    // default — exact 1/N movement
  perTenantConnectionCap: 100,
  perTenantRateLimit: { tokens: 100, refillPerSecond: 10 },
});

// In your WS upgrade handler:
const decision = router.route({ tenantId, channelId });
if (decision.decision !== 'allow') {
  return new Response(decision.decision, { status: 429 });
}
const handle = router.acquire(tenantId);
// ...proxy WS frames to decision.shard.url; call handle.release() on close.

#Hash Strategies + Load Bias

Default is jump-consistent-hash (Lamping & Veach 2014) — O(log n), no memory, exactly 1/N keys move on a shard-add at the tail. Rendezvous (HRW) supports per-shard weights for heterogeneous engine sizes AND a per-call load(shardId) hook to bias AWAY from hot shards. Both strategies are sticky — the same tenant always lands on the same shard until membership changes.

TS
// jump (default) — Lamping & Veach 2014. O(log n), no memory, exactly
// 1/N keys move when shards are added at the tail.  Ignores weight.
createRouter({ shards, hashStrategy: 'jump' });

// rendezvous — HRW hash. Supports per-shard weight for heterogeneous
// engine sizes. O(N) per lookup. Also accepts a load() hook biasing
// AWAY from overloaded shards: effectiveWeight = weight / load(id).
createRouter({
  shards: [
    { id: 'big',   url: 'ws://...', weight: 8 },
    { id: 'small', url: 'ws://...', weight: 1 },
  ],
  hashStrategy: 'rendezvous',
  load: (id) => runtimeRoster.get(id)?.activeTenants ?? 1,
});

// Custom strategy: (key, healthyShards) => index
createRouter({
  shards,
  hashStrategy: (key, shards) => fnv1a32(key) % shards.length,
});

#Drain Mode

drainShard(id) is the operator-intentional "finishing up" state — distinct from markUnhealthy(id) which means "broken, route around now." Both exclude the shard from new routing; drain leaves existing acquires alone. Use drain before a planned shard reboot; tenants rehash to healthy non-draining shards on their next route, but in-flight requests aren't torn down.

TS
// drainShard(id) excludes a shard from new routing without marking it
// broken. Existing acquires keep running. For planned shard rotation:
router.drainShard('engine-1');
// Wait for the runtime to report 0 active tenants on that shard, then:
router.removeShard('engine-1');

// markUnhealthy(id) is the failure variant — tenants rehash to a
// healthy shard immediately.  markHealthy(id) clears BOTH states.

#Connection Cap + Rate Limits

perTenantConnectionCap counts active connections via acquire() / release(); over the cap, route() returns capped. The token-bucket perTenantRateLimit gates per-call; perRouteRateLimits layers a SECOND bucket per named route with atomic two-bucket commit (a failed route bucket does NOT consume the tenant bucket).

TS
// Per-tenant connection cap counted via acquire() / release().
// When reached, route() returns { decision: 'capped' }.
const router = createRouter({
  shards,
  perTenantConnectionCap: 100,
  perTenantRateLimit: { tokens: 100, refillPerSecond: 10 },

  // Per-route limits layered on top — atomic two-bucket commit. A failed
  // route bucket does NOT consume the tenant bucket.
  perRouteRateLimits: {
    upload: { tokens: 5, refillPerSecond: 0.083 },     // 5/min
  },
});

router.route({ tenantId: 'acme', route: 'upload' });
// → { decision: 'rate-limited', emptiedBucket: 'upload' } after 5 calls

#Meter Integration via allow()

The allow: (tenantId) => boolean hook is the meter+router wire-up in one line: allow: meter.allow. When the meter has tripped a tenant, the router returns denied at the edge — the gateway can surface "quota exceeded" without paying for the upstream hop.

TS
import { createMeter } from '@absolutejs/metering';
import { createRouter } from '@absolutejs/router';

const meter = createMeter({ /* ... */ });

// The allow hook is the meter+router wire-up in one line.
const router = createRouter({
  shards,
  allow: meter.allow,   // refuse routes for over-quota tenants at the edge
  load: (id) => roster.load(id),
});

// route() now returns { decision: 'denied', shard: null } when meter.allow
// returns false. The gateway can surface a 'quota exceeded' page without
// paying for the upstream hop.

#Snapshot & Restore

Preserve rate-limit tokens across edge restarts. Without this, a deploy hands every tenant a fresh full bucket — instant rate-limit bypass for anyone watching the deploy times.

TS
// Preserve rate-limit tokens across edge restarts. Without this, a deploy
// hands every tenant a fresh full bucket — instant rate-limit bypass for
// anyone watching the deploy times.
const snap = router.snapshot();
await Bun.write('/var/lib/router/state.json', JSON.stringify(snap));

// On edge restart:
const restored = createRouter({ /* ... same config ... */ });
restored.restore(JSON.parse(await Bun.file('/var/lib/router/state.json').text()));

Current package surface

What ships today

@absolutejs/routerv0.5.2 · betaPlatform & InfranpmSource
2entry points35symbols

Import surface · click to copy

31 symbols
RegiontypePermalinkSource
TS
type Region = {
    id: string;
    /**
     * Relative weight for the default assignment strategy (weighted
     * rendezvous). Higher weight = proportionally more tenants land here.
     * Default 1. Weights <= 0 exclude the region from default assignment
     * (explicit `assignRegion` still works).
     */
    weight?: number;
};
Exported from @absolutejs/router

Outcomes

What you can build

Overview

Multi-tenant connection routing primitive for Bun PaaS gateways. Sits in front of N backend processes (each a @absolutejs/runtime instance hosting a @absolutejs/sync engine for a subset of tenants) and decides — per request:

Surface (0.1.0)

API — Purpose

Region-aware routing (0.4.0)

createRouter shards WITHIN a region; createRegionDirectory decides which region a tenant lives in. Sticky, deterministic assignment — weighted rendezvous over region ids by default, so every replica computes the same answer without coordination — plus an optional caller hook for latency-based placement and explicit overrides for control-plane onboarding decisions.

Hardening checklist

Production guidance

Make every external boundary explicitPin the deployed @absolutejs/router version, replace example or memory-backed dependencies with durable implementations, bound external calls, protect credentials, and emit enough evidence to retry or recover safely.

Follow in order

Troubleshooting path

1
Trace from the first failed boundary
Reproduce the smallest canonical @absolutejs/router example, confirm the supported entry point and version in the API explorer, then inspect the first boundary that did not produce its documented result.