@absolutejs/router 0.2.0 → 0.4.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
@@ -18,39 +18,43 @@ return a 503). An Elysia adapter ships in a later 0.0.x as a subpath.
18
18
  import { createRouter } from '@absolutejs/router';
19
19
 
20
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 },
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
28
  });
29
29
 
30
30
  // In your WS upgrade handler:
31
31
  const decision = router.route({ tenantId, channelId });
32
32
  if (decision.decision !== 'allow') {
33
- return new Response(decision.decision, { status: 429 });
33
+ return new Response(decision.decision, { status: 429 });
34
34
  }
35
35
  const handle = router.acquire(tenantId);
36
- ws.data = { ...ws.data, release: handle.release, upstream: decision.shard!.url };
36
+ ws.data = {
37
+ ...ws.data,
38
+ release: handle.release,
39
+ upstream: decision.shard!.url
40
+ };
37
41
  // ...proxy WS frames to decision.shard.url; call handle.release() on close.
38
42
  ```
39
43
 
40
44
  ## Surface (0.1.0)
41
45
 
42
- | API | Purpose |
43
- |---|---|
44
- | `createRouter(options)` | Factory. Returns a `Router`. |
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. |
50
- | `router.addShard(shard)` / `router.removeShard(id)` | Runtime shard membership changes. |
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. |
53
- | `router.dispose()` | Stop accepting routes; all subsequent `route()` returns `no-shards`. |
46
+ | API | Purpose |
47
+ | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
48
+ | `createRouter(options)` | Factory. Returns a `Router`. |
49
+ | `router.route({ tenantId, channelId?, route?, region? })` | Returns `{ shard, decision, emptiedBucket? }`. Decision is `allow` / `rate-limited` / `capped` / `no-shards` / `no-region-shards` / `denied`. |
50
+ | `router.acquire(tenantId)` | Increment active-connection counter; returns `{ active, release }`. `release` is idempotent. |
51
+ | `router.markHealthy(id)` / `router.markUnhealthy(id)` | Caller-driven health state. Unhealthy shards are skipped. |
52
+ | `router.drainShard(id)` | Refuse new routes; existing acquires unaffected. Operator-intentional state distinct from unhealthy. `markHealthy` cancels it. |
53
+ | `router.isHealthy(id)` / `router.isDraining(id)` | Inspect state. |
54
+ | `router.addShard(shard)` / `router.removeShard(id)` | Runtime shard membership changes. |
55
+ | `router.shards()` | Inspect shard list. |
56
+ | `router.snapshot()` / `router.restore(snap)` | Serializable point-in-time state. Survive edge restarts without dropping rate-limit tokens. |
57
+ | `router.dispose()` | Stop accepting routes; all subsequent `route()` returns `no-shards`. |
54
58
 
55
59
  ### Hash strategies + load bias
56
60
 
@@ -105,12 +109,106 @@ restored.restore(JSON.parse(await readFromDisk('/var/lib/router/state.json')));
105
109
 
106
110
  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.
107
111
 
112
+ ## Region-aware routing (0.4.0)
113
+
114
+ `createRouter` shards WITHIN a region; `createRegionDirectory` decides which
115
+ region a tenant lives in. Sticky, deterministic assignment — weighted
116
+ rendezvous over region ids by default, so every replica computes the same
117
+ answer without coordination — plus an optional caller hook for latency-based
118
+ placement and explicit overrides for control-plane onboarding decisions.
119
+
120
+ ```ts
121
+ import { createRegionDirectory, createRouter } from '@absolutejs/router';
122
+
123
+ const directory = createRegionDirectory({
124
+ regions: [
125
+ { id: 'us-east', weight: 2 }, // twice the tenants of eu-west
126
+ { id: 'eu-west' }
127
+ ],
128
+ // Optional: latency-based placement. Return undefined to fall back to
129
+ // the default weighted-rendezvous strategy.
130
+ assign: (tenantId) => edgeProbe.closestRegion(tenantId)
131
+ });
132
+
133
+ const router = createRouter({
134
+ shards: [
135
+ { id: 'us-1', url: 'ws://10.0.0.11:3000', region: 'us-east' },
136
+ { id: 'us-2', url: 'ws://10.0.0.12:3000', region: 'us-east' },
137
+ { id: 'eu-1', url: 'ws://10.1.0.11:3000', region: 'eu-west' }
138
+ ]
139
+ });
140
+
141
+ // The wire-up: the directory picks the region, the router picks the shard.
142
+ const decision = router.route({
143
+ tenantId,
144
+ region: directory.regionFor(tenantId)
145
+ });
146
+ ```
147
+
148
+ | API | Purpose |
149
+ | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
150
+ | `createRegionDirectory({ regions, assign?, clock?, tracerProvider? })` | Factory. At least one region required. |
151
+ | `directory.regionFor(tenantId)` | Sticky assignment, created on first call. Re-assigns lazily if the region was removed. |
152
+ | `directory.assignRegion(tenantId, regionId)` | Explicit override (control-plane onboarding). Throws on unknown region. |
153
+ | `directory.release(tenantId)` | Forget the assignment; next `regionFor` re-assigns. |
154
+ | `directory.addRegion(region)` / `directory.removeRegion(id)` | Runtime region membership. Removal re-assigns its tenants lazily. |
155
+ | `directory.regions()` | Inspect the region list. |
156
+ | `directory.snapshot()` / `directory.restore(snap)` | Assignments (+ override flags) survive control-plane restarts. |
157
+ | `directory.metrics()` | `{ assignments, byRegion, overrides }`. |
158
+
159
+ Shards without a `region` are region-agnostic — they remain candidates for
160
+ ANY requested region (back-compat: an existing single-region deployment keeps
161
+ working untouched). A `route({ region })` with no candidate shards in that
162
+ region returns `decision: 'no-region-shards'` — distinguishable from the
163
+ cluster-wide `no-shards` in `metrics().rejectsByDecision`, so "region drained"
164
+ and "cluster empty" alert separately.
165
+
166
+ ## Custom-domain map (0.4.0)
167
+
168
+ A tenant's traffic arrives as `app.acme.com` (their CNAME), not as a tenant
169
+ id. `createDomainMap` is the first lookup in the edge gateway: hostname →
170
+ tenant, dependency-free and O(1)-ish (a Map for exact hosts, a Map keyed by
171
+ suffix for wildcards).
172
+
173
+ ```ts
174
+ import { createDomainMap } from '@absolutejs/router';
175
+
176
+ const domainMap = createDomainMap({
177
+ // Resolver of last resort — the platform's own subdomain scheme.
178
+ fallback: (host) =>
179
+ host.endsWith('.cloud.absolutejs.com')
180
+ ? host.slice(0, -'.cloud.absolutejs.com'.length)
181
+ : undefined
182
+ });
183
+
184
+ domainMap.add('app.acme.com', 'acme'); // exact custom domain
185
+ domainMap.add('*.acme.com', 'acme'); // wildcard — exactly one label deep
186
+
187
+ // Edge gateway: host header → tenant → region → shard.
188
+ const hit = domainMap.resolve(request.headers.get('host') ?? '');
189
+ if (!hit) return new Response('unknown domain', { status: 404 });
190
+ const decision = router.route({
191
+ tenantId: hit.tenantId,
192
+ region: directory.regionFor(hit.tenantId)
193
+ });
194
+ ```
195
+
196
+ | API | Purpose |
197
+ | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
198
+ | `createDomainMap({ fallback?, clock?, tracerProvider? })` | Factory. |
199
+ | `map.add(hostname, tenantId)` | Lowercases. Exact hosts and `*.example.com` wildcards (one label depth — TLS-wildcard-certificate scoping). Throws on other `*` placements. |
200
+ | `map.remove(hostname)` | Delete an entry (exact or `*.` form). |
201
+ | `map.resolve(hostname)` | `{ tenantId, matched: 'exact' \| 'wildcard' \| 'fallback' } \| null`. Strips port, lowercases. Exact beats wildcard beats fallback. |
202
+ | `map.list()` | Every entry; wildcards in their `*.` form. |
203
+ | `map.snapshot()` / `map.restore(snap)` | Entries survive edge restarts. |
204
+ | `map.metrics()` | `{ entries, resolves, hits, misses, lastResolveMs }`. A climbing miss rate usually means stale DNS pointing at the gateway after an offboard. |
205
+
108
206
  ## Architectural role
109
207
 
110
208
  - **`@absolutejs/sync`** — the engine each backend shard runs.
111
209
  - **`@absolutejs/runtime`** — the process pool each backend shard spawns from.
112
210
  - **`@absolutejs/metering`** — counts the bill; `meter.allow(tenant)` reads.
113
- - **`@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.
211
+ - **`@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.
114
212
 
115
213
  ## What v0.0.1 does NOT include
116
214
 
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Custom-domain map — hostname → tenant resolution for custom domains at
3
+ * the edge gateway. 0.4.0, closes the edge-routing gap (5.6) from the
4
+ * PaaS guide.
5
+ *
6
+ * A tenant's traffic arrives as `app.acme.com` (their CNAME), not as a
7
+ * tenant id. The domain map is the first lookup in the edge gateway:
8
+ *
9
+ * ```ts
10
+ * const hit = domainMap.resolve(request.headers.get('host') ?? '');
11
+ * if (!hit) return new Response('unknown domain', { status: 404 });
12
+ * router.route({ tenantId: hit.tenantId, region: directory.regionFor(hit.tenantId) });
13
+ * ```
14
+ *
15
+ * Dependency-free and O(1)-ish: a Map for exact hosts, a Map keyed by
16
+ * suffix for `*.example.com` wildcards (one lookup each — no per-entry
17
+ * scan).
18
+ */
19
+ import { type TracerProvider } from '@absolutejs/telemetry';
20
+ export type DomainMatch = 'exact' | 'wildcard' | 'fallback';
21
+ export type DomainResolution = {
22
+ tenantId: string;
23
+ matched: DomainMatch;
24
+ };
25
+ /**
26
+ * Optional caller-supplied resolver of last resort, consulted when neither
27
+ * an exact nor a wildcard entry matches. The intended use is the platform's
28
+ * own subdomain scheme — parse `<workspace>.cloud.absolutejs.com` into a
29
+ * tenant id without registering every workspace as an entry. Returning
30
+ * `undefined` makes the resolve a miss (`resolve()` returns `null`).
31
+ */
32
+ export type DomainFallbackFn = (hostname: string) => string | undefined;
33
+ export type DomainMapOptions = {
34
+ /** Optional resolver of last resort. See {@link DomainFallbackFn}. */
35
+ fallback?: DomainFallbackFn;
36
+ /** Override `Date.now` for tests. */
37
+ clock?: () => number;
38
+ /**
39
+ * Optional OpenTelemetry tracer provider. When set, `resolve()` is
40
+ * wrapped in `router.domain_resolve` spans with `abs.domain.host`,
41
+ * and (on a hit) `abs.tenant` + `abs.domain.matched` attributes.
42
+ * Status OK on hit, ERROR on miss. When omitted, all tracing is a
43
+ * zero-allocation noop.
44
+ */
45
+ tracerProvider?: TracerProvider;
46
+ };
47
+ export type DomainMapEntry = {
48
+ /** Wildcard entries keep their `*.example.com` form here. */
49
+ hostname: string;
50
+ tenantId: string;
51
+ };
52
+ export type DomainMapSnapshot = {
53
+ version: 1;
54
+ at: number;
55
+ entries: DomainMapEntry[];
56
+ };
57
+ /**
58
+ * Returned by {@link DomainMap.metrics}.
59
+ *
60
+ * - `entries` — registered entries (exact + wildcard).
61
+ * - `resolves` — total `resolve()` calls.
62
+ * - `hits` / `misses` — resolves that found a tenant (any match kind)
63
+ * vs. resolves that returned `null`. A climbing miss rate usually
64
+ * means stale DNS pointing at the gateway after an offboard.
65
+ * - `lastResolveMs` — wall-clock of the most recent `resolve()` call.
66
+ */
67
+ export type DomainMapMetrics = {
68
+ entries: number;
69
+ resolves: number;
70
+ hits: number;
71
+ misses: number;
72
+ lastResolveMs: number;
73
+ };
74
+ export type DomainMap = {
75
+ /**
76
+ * Register a hostname → tenant entry. Hostnames are lowercased.
77
+ * `*.example.com` registers a wildcard matching exactly one label
78
+ * (`app.example.com` yes; `a.b.example.com` and the bare apex no).
79
+ * Throws on any other `*` placement.
80
+ */
81
+ add: (hostname: string, tenantId: string) => void;
82
+ remove: (hostname: string) => void;
83
+ /**
84
+ * Resolve a request's `Host` value to a tenant. Strips the port,
85
+ * lowercases, then checks exact → wildcard → `fallback` hook (exact
86
+ * beats wildcard). Returns `null` on a miss.
87
+ */
88
+ resolve: (hostname: string) => DomainResolution | null;
89
+ list: () => DomainMapEntry[];
90
+ snapshot: () => DomainMapSnapshot;
91
+ restore: (snapshot: DomainMapSnapshot) => void;
92
+ metrics: () => DomainMapMetrics;
93
+ };
94
+ export declare const createDomainMap: (options?: DomainMapOptions) => DomainMap;
package/dist/index.d.ts CHANGED
@@ -13,6 +13,9 @@
13
13
  * The caller wires the routing decision into whichever HTTP/WS layer they
14
14
  * have. An Elysia adapter ships in a later 0.0.x as a subpath.
15
15
  */
16
+ import { type TracerProvider } from '@absolutejs/telemetry';
17
+ export * from './region';
18
+ export * from './domain';
16
19
  export type Shard = {
17
20
  id: string;
18
21
  /**
@@ -26,6 +29,14 @@ export type Shard = {
26
29
  * hash ignores weights — every shard is treated as weight 1. Default 1.
27
30
  */
28
31
  weight?: number;
32
+ /**
33
+ * Region this shard serves (matches a `Region.id` in the paired
34
+ * `createRegionDirectory`). When `route()` is called with a `region`,
35
+ * only shards in that region — plus region-less shards, for
36
+ * back-compat — are candidates. Omit for a single-region deployment.
37
+ * Added in 0.4.0.
38
+ */
39
+ region?: string;
29
40
  };
30
41
  /**
31
42
  * Pure hash-strategy function. Given the routing key + the list of currently
@@ -92,8 +103,19 @@ export type RouterOptions = {
92
103
  allow?: AllowHook;
93
104
  /** Override `Date.now` for tests. */
94
105
  clock?: () => number;
106
+ /**
107
+ * Optional OpenTelemetry tracer provider. When set, `route()` and
108
+ * `acquire()` are wrapped in `router.route` / `router.acquire`
109
+ * spans with `abs.tenant`, `abs.route.decision`, and (on allow)
110
+ * `abs.route.shard` attributes. When omitted, all tracing is a
111
+ * zero-allocation noop. Added in 0.3.0.
112
+ *
113
+ * Structural type via `@absolutejs/telemetry`; no peer-dep on
114
+ * `@opentelemetry/api`.
115
+ */
116
+ tracerProvider?: TracerProvider;
95
117
  };
96
- export type RouteDecision = 'allow' | 'rate-limited' | 'capped' | 'no-shards' | 'denied';
118
+ export type RouteDecision = 'allow' | 'rate-limited' | 'capped' | 'no-shards' | 'no-region-shards' | 'denied';
97
119
  /**
98
120
  * Returned by {@link Router.metrics}. Combines point-in-time state with
99
121
  * cumulative counters since `createRouter()`. Survives `dispose()` so
@@ -136,6 +158,16 @@ export type RouteRequest = {
136
158
  * bucket applies).
137
159
  */
138
160
  route?: string;
161
+ /**
162
+ * Optional region filter. When set, only shards whose `region` matches
163
+ * — plus region-less shards, for back-compat — are candidates. When
164
+ * the region has eligible shards registered but none are candidates,
165
+ * `route()` returns `'no-region-shards'` (distinguishable from the
166
+ * cluster-wide `'no-shards'`). The intended source is the paired
167
+ * directory: `route({ tenantId, region: directory.regionFor(tenantId) })`.
168
+ * Added in 0.4.0.
169
+ */
170
+ region?: string;
139
171
  };
140
172
  export type RouteResult = {
141
173
  decision: RouteDecision;
package/dist/index.js CHANGED
@@ -1,5 +1,58 @@
1
1
  // @bun
2
- // src/index.ts
2
+ // node_modules/@absolutejs/telemetry/dist/index.js
3
+ var NOOP_SPAN_CONTEXT = {
4
+ spanId: "0000000000000000",
5
+ traceFlags: 0,
6
+ traceId: "00000000000000000000000000000000"
7
+ };
8
+ var noopSpan = {
9
+ addEvent: () => noopSpan,
10
+ end: () => {},
11
+ isRecording: () => false,
12
+ recordException: () => {},
13
+ setAttribute: () => noopSpan,
14
+ setAttributes: () => noopSpan,
15
+ setStatus: () => noopSpan,
16
+ spanContext: () => NOOP_SPAN_CONTEXT,
17
+ updateName: () => noopSpan
18
+ };
19
+ var startActiveSpanNoop = (_name, optionsOrFn, maybeFn) => {
20
+ const fn = typeof optionsOrFn === "function" ? optionsOrFn : maybeFn;
21
+ return fn(noopSpan);
22
+ };
23
+ var noopTracer = {
24
+ startActiveSpan: startActiveSpanNoop,
25
+ startSpan: () => noopSpan
26
+ };
27
+ var tracerOrNoop = (provider, name, version) => provider !== undefined ? provider.getTracer(name, version) : noopTracer;
28
+ var ABS_ATTRS = {
29
+ tenant: "abs.tenant",
30
+ shardId: "abs.shard.id",
31
+ engineId: "abs.engine.id",
32
+ collection: "abs.collection",
33
+ mutation: "abs.mutation",
34
+ mutationAttempt: "abs.mutation.attempt",
35
+ subscriptionId: "abs.subscription.id",
36
+ batchSize: "abs.batch.size",
37
+ clusterMessageOrigin: "abs.cluster.origin",
38
+ jobId: "abs.job.id",
39
+ jobKind: "abs.job.kind",
40
+ jobAttempt: "abs.job.attempt",
41
+ jobMaxAttempts: "abs.job.max_attempts",
42
+ workerId: "abs.worker.id",
43
+ runtimeKey: "abs.runtime.key",
44
+ runtimePid: "abs.runtime.pid",
45
+ runtimePort: "abs.runtime.port",
46
+ runtimeExitReason: "abs.runtime.exit_reason",
47
+ runtimeReadinessMs: "abs.runtime.readiness_ms",
48
+ routeShard: "abs.route.shard",
49
+ routeDecision: "abs.route.decision",
50
+ secretName: "abs.secret.name",
51
+ secretFingerprint: "abs.secret.fingerprint",
52
+ auditKind: "abs.audit.kind"
53
+ };
54
+
55
+ // src/region.ts
3
56
  var fnv1a32 = (input) => {
4
57
  let hash = 2166136261;
5
58
  for (let i = 0;i < input.length; i++) {
@@ -8,10 +61,241 @@ var fnv1a32 = (input) => {
8
61
  }
9
62
  return hash >>> 0;
10
63
  };
64
+ var createRegionDirectory = (options) => {
65
+ if (options.regions.length === 0) {
66
+ throw new Error("createRegionDirectory requires at least one region");
67
+ }
68
+ const clock = options.clock ?? Date.now;
69
+ const assignHook = options.assign;
70
+ const tracer = tracerOrNoop(options.tracerProvider, "@absolutejs/router");
71
+ const regionList = [...options.regions];
72
+ const assignments = new Map;
73
+ const hasRegion = (id) => regionList.some((region) => region.id === id);
74
+ const pickRegion = (tenantId) => {
75
+ let bestId = regionList[0].id;
76
+ let bestScore = -Infinity;
77
+ for (const region of regionList) {
78
+ const weight = region.weight ?? 1;
79
+ if (weight <= 0)
80
+ continue;
81
+ const seed = fnv1a32(`${tenantId}|${region.id}`);
82
+ const u = (seed + 1) / 4294967296;
83
+ const score = weight * -Math.log(u);
84
+ if (score > bestScore) {
85
+ bestScore = score;
86
+ bestId = region.id;
87
+ }
88
+ }
89
+ return bestId;
90
+ };
91
+ const regionFor = (tenantId) => {
92
+ const found = assignments.get(tenantId);
93
+ if (found && hasRegion(found.region))
94
+ return found.region;
95
+ const span = tracer.startSpan("router.region_assign", {
96
+ attributes: { [ABS_ATTRS.tenant]: tenantId }
97
+ });
98
+ const hookChoice = assignHook?.(tenantId);
99
+ const region = hookChoice !== undefined && hasRegion(hookChoice) ? hookChoice : pickRegion(tenantId);
100
+ assignments.set(tenantId, { override: false, region });
101
+ span.setAttribute("abs.region", region);
102
+ span.setStatus({ code: 1 });
103
+ span.end();
104
+ return region;
105
+ };
106
+ return {
107
+ addRegion: (region) => {
108
+ if (hasRegion(region.id))
109
+ return;
110
+ regionList.push(region);
111
+ },
112
+ assignRegion: (tenantId, regionId) => {
113
+ if (!hasRegion(regionId)) {
114
+ throw new Error(`assignRegion: unknown region "${regionId}"`);
115
+ }
116
+ assignments.set(tenantId, { override: true, region: regionId });
117
+ },
118
+ metrics: () => {
119
+ const byRegion = {};
120
+ let overrides = 0;
121
+ for (const assignment of assignments.values()) {
122
+ byRegion[assignment.region] = (byRegion[assignment.region] ?? 0) + 1;
123
+ if (assignment.override)
124
+ overrides += 1;
125
+ }
126
+ return { assignments: assignments.size, byRegion, overrides };
127
+ },
128
+ regionFor,
129
+ regions: () => regionList.map((region) => ({ ...region })),
130
+ release: (tenantId) => {
131
+ assignments.delete(tenantId);
132
+ },
133
+ removeRegion: (regionId) => {
134
+ const at = regionList.findIndex((region) => region.id === regionId);
135
+ if (at >= 0)
136
+ regionList.splice(at, 1);
137
+ },
138
+ restore: (snap) => {
139
+ assignments.clear();
140
+ for (const a of snap.assignments) {
141
+ assignments.set(a.tenant, {
142
+ override: a.override,
143
+ region: a.region
144
+ });
145
+ }
146
+ },
147
+ snapshot: () => {
148
+ const assignmentsOut = [];
149
+ for (const [tenant, assignment] of assignments) {
150
+ assignmentsOut.push({
151
+ override: assignment.override,
152
+ region: assignment.region,
153
+ tenant
154
+ });
155
+ }
156
+ return {
157
+ assignments: assignmentsOut,
158
+ at: clock(),
159
+ regions: regionList.map((region) => ({ ...region })),
160
+ version: 1
161
+ };
162
+ }
163
+ };
164
+ };
165
+ // src/domain.ts
166
+ var normalizeHost = (hostname) => {
167
+ let host = hostname.trim().toLowerCase();
168
+ if (host.startsWith("[")) {
169
+ const close = host.indexOf("]");
170
+ if (close >= 0)
171
+ host = host.slice(0, close + 1);
172
+ return host;
173
+ }
174
+ const colon = host.indexOf(":");
175
+ if (colon >= 0)
176
+ host = host.slice(0, colon);
177
+ return host;
178
+ };
179
+ var createDomainMap = (options = {}) => {
180
+ const clock = options.clock ?? Date.now;
181
+ const fallbackHook = options.fallback;
182
+ const tracer = tracerOrNoop(options.tracerProvider, "@absolutejs/router");
183
+ const exact = new Map;
184
+ const wildcard = new Map;
185
+ let totalResolves = 0;
186
+ let hits = 0;
187
+ let misses = 0;
188
+ let lastResolveMs = 0;
189
+ const resolve = (hostname) => {
190
+ const resolveStart = clock();
191
+ totalResolves += 1;
192
+ const host = normalizeHost(hostname);
193
+ const span = tracer.startSpan("router.domain_resolve", {
194
+ attributes: { "abs.domain.host": host }
195
+ });
196
+ const finish = (result) => {
197
+ if (result === null) {
198
+ misses += 1;
199
+ span.setStatus({ code: 2 });
200
+ } else {
201
+ hits += 1;
202
+ span.setAttribute(ABS_ATTRS.tenant, result.tenantId);
203
+ span.setAttribute("abs.domain.matched", result.matched);
204
+ span.setStatus({ code: 1 });
205
+ }
206
+ span.end();
207
+ lastResolveMs = clock() - resolveStart;
208
+ return result;
209
+ };
210
+ const exactHit = exact.get(host);
211
+ if (exactHit !== undefined) {
212
+ return finish({ matched: "exact", tenantId: exactHit });
213
+ }
214
+ const dot = host.indexOf(".");
215
+ if (dot > 0) {
216
+ const wildcardHit = wildcard.get(host.slice(dot + 1));
217
+ if (wildcardHit !== undefined) {
218
+ return finish({ matched: "wildcard", tenantId: wildcardHit });
219
+ }
220
+ }
221
+ const fallbackHit = fallbackHook?.(host);
222
+ if (fallbackHit !== undefined) {
223
+ return finish({ matched: "fallback", tenantId: fallbackHit });
224
+ }
225
+ return finish(null);
226
+ };
227
+ const add = (hostname, tenantId) => {
228
+ const host = normalizeHost(hostname);
229
+ if (host.startsWith("*.")) {
230
+ const suffix = host.slice(2);
231
+ if (suffix.length === 0 || suffix.includes("*")) {
232
+ throw new Error(`add: unsupported wildcard pattern "${hostname}" \u2014 only "*.example.com" (one leading label) is supported`);
233
+ }
234
+ wildcard.set(suffix, tenantId);
235
+ return;
236
+ }
237
+ if (host.includes("*")) {
238
+ throw new Error(`add: unsupported wildcard pattern "${hostname}" \u2014 only "*.example.com" (one leading label) is supported`);
239
+ }
240
+ exact.set(host, tenantId);
241
+ };
242
+ const list = () => {
243
+ const entries = [];
244
+ for (const [hostname, tenantId] of exact) {
245
+ entries.push({ hostname, tenantId });
246
+ }
247
+ for (const [suffix, tenantId] of wildcard) {
248
+ entries.push({ hostname: `*.${suffix}`, tenantId });
249
+ }
250
+ return entries;
251
+ };
252
+ return {
253
+ add,
254
+ list,
255
+ metrics: () => ({
256
+ entries: exact.size + wildcard.size,
257
+ hits,
258
+ lastResolveMs,
259
+ misses,
260
+ resolves: totalResolves
261
+ }),
262
+ remove: (hostname) => {
263
+ const host = normalizeHost(hostname);
264
+ if (host.startsWith("*.")) {
265
+ wildcard.delete(host.slice(2));
266
+ return;
267
+ }
268
+ exact.delete(host);
269
+ },
270
+ resolve,
271
+ restore: (snap) => {
272
+ exact.clear();
273
+ wildcard.clear();
274
+ for (const entry of snap.entries) {
275
+ add(entry.hostname, entry.tenantId);
276
+ }
277
+ },
278
+ snapshot: () => ({
279
+ at: clock(),
280
+ entries: list(),
281
+ version: 1
282
+ })
283
+ };
284
+ };
285
+
286
+ // src/index.ts
287
+ var fnv1a322 = (input) => {
288
+ let hash = 2166136261;
289
+ for (let i = 0;i < input.length; i++) {
290
+ hash ^= input.charCodeAt(i);
291
+ hash = Math.imul(hash, 16777619);
292
+ }
293
+ return hash >>> 0;
294
+ };
11
295
  var jumpHash = (key, numBuckets) => {
12
296
  if (numBuckets <= 0)
13
297
  return -1;
14
- let k = BigInt(fnv1a32(key)) | BigInt(fnv1a32(key + "#hi")) << 32n;
298
+ let k = BigInt(fnv1a322(key)) | BigInt(fnv1a322(key + "#hi")) << 32n;
15
299
  let b = -1n;
16
300
  let j = 0n;
17
301
  while (j < BigInt(numBuckets)) {
@@ -33,7 +317,7 @@ var makeRendezvousStrategy = (load) => (key, shards) => {
33
317
  continue;
34
318
  const loadValue = load ? Math.max(load(shard.id), 0.0001) : 1;
35
319
  const effectiveWeight = baseWeight / loadValue;
36
- const seed = fnv1a32(`${key}|${shard.id}`);
320
+ const seed = fnv1a322(`${key}|${shard.id}`);
37
321
  const u = (seed + 1) / 4294967296;
38
322
  const score = effectiveWeight * -Math.log(u);
39
323
  if (score > bestScore) {
@@ -54,7 +338,10 @@ var createRouter = (options) => {
54
338
  const clock = options.clock ?? Date.now;
55
339
  const strategy = resolveStrategy(options.hashStrategy ?? "jump", options.load);
56
340
  const cap = options.perTenantConnectionCap ?? Infinity;
57
- const rate = options.perTenantRateLimit ?? { refillPerSecond: 0, tokens: Infinity };
341
+ const rate = options.perTenantRateLimit ?? {
342
+ refillPerSecond: 0,
343
+ tokens: Infinity
344
+ };
58
345
  const perRouteRateLimits = options.perRouteRateLimits ?? {};
59
346
  const allowHook = options.allow;
60
347
  const shardList = [...options.shards];
@@ -65,6 +352,7 @@ var createRouter = (options) => {
65
352
  const tenants = new Map;
66
353
  const routeBuckets = new Map;
67
354
  let disposed = false;
355
+ const tracer = tracerOrNoop(options.tracerProvider, "@absolutejs/router");
68
356
  let totalRoutes = 0;
69
357
  let totalAcquires = 0;
70
358
  let lastRouteMs = 0;
@@ -72,6 +360,7 @@ var createRouter = (options) => {
72
360
  "rate-limited": 0,
73
361
  capped: 0,
74
362
  "no-shards": 0,
363
+ "no-region-shards": 0,
75
364
  denied: 0
76
365
  };
77
366
  const shardLoadByShard = new Map;
@@ -108,18 +397,40 @@ var createRouter = (options) => {
108
397
  };
109
398
  const eligibleShards = () => shardList.filter((shard) => healthy.get(shard.id) === true && !draining.has(shard.id));
110
399
  const route = (request) => {
400
+ const span = tracer.startSpan("router.route", {
401
+ attributes: {
402
+ [ABS_ATTRS.tenant]: request.tenantId,
403
+ ...request.route !== undefined ? { "abs.route.name": request.route } : {},
404
+ ...request.region !== undefined ? { "abs.region": request.region } : {}
405
+ }
406
+ });
111
407
  const routeStart = clock();
112
408
  totalRoutes += 1;
409
+ const finishSpan = (result) => {
410
+ span.setAttribute(ABS_ATTRS.routeDecision, result.decision);
411
+ if (result.shard !== null) {
412
+ span.setAttribute(ABS_ATTRS.routeShard, result.shard.id);
413
+ }
414
+ span.setStatus({
415
+ code: result.decision === "allow" ? 1 : 2
416
+ });
417
+ span.end();
418
+ return result;
419
+ };
113
420
  const recordRejection = (decision) => {
114
421
  rejectsByDecision[decision] += 1;
115
422
  lastRouteMs = clock() - routeStart;
116
- return { decision, shard: null };
423
+ return finishSpan({ decision, shard: null });
117
424
  };
118
425
  if (disposed)
119
426
  return recordRejection("no-shards");
120
427
  const live = eligibleShards();
121
428
  if (live.length === 0)
122
429
  return recordRejection("no-shards");
430
+ const candidates = request.region === undefined ? live : live.filter((shard) => shard.region === undefined || shard.region === request.region);
431
+ if (candidates.length === 0) {
432
+ return recordRejection("no-region-shards");
433
+ }
123
434
  if (allowHook && !allowHook(request.tenantId)) {
124
435
  return recordRejection("denied");
125
436
  }
@@ -132,11 +443,11 @@ var createRouter = (options) => {
132
443
  if (state.bucket.tokens < 1) {
133
444
  rejectsByDecision["rate-limited"] += 1;
134
445
  lastRouteMs = clock() - routeStart;
135
- return {
446
+ return finishSpan({
136
447
  decision: "rate-limited",
137
448
  emptiedBucket: "tenant",
138
449
  shard: null
139
- };
450
+ });
140
451
  }
141
452
  let routeBucket = null;
142
453
  let routeRule = null;
@@ -147,28 +458,34 @@ var createRouter = (options) => {
147
458
  if (routeBucket.tokens < 1) {
148
459
  rejectsByDecision["rate-limited"] += 1;
149
460
  lastRouteMs = clock() - routeStart;
150
- return {
461
+ return finishSpan({
151
462
  decision: "rate-limited",
152
463
  emptiedBucket: request.route,
153
464
  shard: null
154
- };
465
+ });
155
466
  }
156
467
  }
157
468
  state.bucket.tokens -= 1;
158
469
  if (routeBucket)
159
470
  routeBucket.tokens -= 1;
160
471
  const key = request.channelId === undefined ? request.tenantId : `${request.tenantId}:${request.channelId}`;
161
- const index = strategy(key, live);
162
- const chosen = live[Math.max(0, Math.min(index, live.length - 1))];
472
+ const index = strategy(key, candidates);
473
+ const chosen = candidates[Math.max(0, Math.min(index, candidates.length - 1))];
163
474
  shardLoadByShard.set(chosen.id, (shardLoadByShard.get(chosen.id) ?? 0) + 1);
164
475
  lastRouteMs = clock() - routeStart;
165
- return { decision: "allow", shard: chosen };
476
+ return finishSpan({ decision: "allow", shard: chosen });
166
477
  };
167
478
  const acquire = (tenantId) => {
479
+ const span = tracer.startSpan("router.acquire", {
480
+ attributes: { [ABS_ATTRS.tenant]: tenantId }
481
+ });
168
482
  const now = clock();
169
483
  const state = ensureTenant(tenantId, now);
170
484
  state.active += 1;
171
485
  totalAcquires += 1;
486
+ span.setAttribute("abs.tenant.active", state.active);
487
+ span.setStatus({ code: 1 });
488
+ span.end();
172
489
  let released = false;
173
490
  return {
174
491
  active: state.active,
@@ -294,8 +611,10 @@ var createRouter = (options) => {
294
611
  };
295
612
  };
296
613
  export {
297
- createRouter
614
+ createRouter,
615
+ createRegionDirectory,
616
+ createDomainMap
298
617
  };
299
618
 
300
- //# debugId=E49309CC1211AA2864756E2164756E21
619
+ //# debugId=73F582FCBD0A9B0764756E2164756E21
301
620
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "version": 3,
3
- "sources": ["../src/index.ts"],
3
+ "sources": ["../node_modules/@absolutejs/telemetry/dist/index.js", "../src/region.ts", "../src/domain.ts", "../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 (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"
5
+ "// @bun\n// src/index.ts\nvar SpanKind = {\n INTERNAL: 0,\n SERVER: 1,\n CLIENT: 2,\n PRODUCER: 3,\n CONSUMER: 4\n};\nvar SpanStatusCode = {\n UNSET: 0,\n OK: 1,\n ERROR: 2\n};\nvar NOOP_SPAN_CONTEXT = {\n spanId: \"0000000000000000\",\n traceFlags: 0,\n traceId: \"00000000000000000000000000000000\"\n};\nvar noopSpan = {\n addEvent: () => noopSpan,\n end: () => {},\n isRecording: () => false,\n recordException: () => {},\n setAttribute: () => noopSpan,\n setAttributes: () => noopSpan,\n setStatus: () => noopSpan,\n spanContext: () => NOOP_SPAN_CONTEXT,\n updateName: () => noopSpan\n};\nvar createNoopSpan = () => noopSpan;\nvar startActiveSpanNoop = (_name, optionsOrFn, maybeFn) => {\n const fn = typeof optionsOrFn === \"function\" ? optionsOrFn : maybeFn;\n return fn(noopSpan);\n};\nvar noopTracer = {\n startActiveSpan: startActiveSpanNoop,\n startSpan: () => noopSpan\n};\nvar createNoopTracer = () => noopTracer;\nvar createNoopTracerProvider = () => ({\n getTracer: () => noopTracer\n});\nvar tracerOrNoop = (provider, name, version) => provider !== undefined ? provider.getTracer(name, version) : noopTracer;\nvar ABS_ATTRS = {\n tenant: \"abs.tenant\",\n shardId: \"abs.shard.id\",\n engineId: \"abs.engine.id\",\n collection: \"abs.collection\",\n mutation: \"abs.mutation\",\n mutationAttempt: \"abs.mutation.attempt\",\n subscriptionId: \"abs.subscription.id\",\n batchSize: \"abs.batch.size\",\n clusterMessageOrigin: \"abs.cluster.origin\",\n jobId: \"abs.job.id\",\n jobKind: \"abs.job.kind\",\n jobAttempt: \"abs.job.attempt\",\n jobMaxAttempts: \"abs.job.max_attempts\",\n workerId: \"abs.worker.id\",\n runtimeKey: \"abs.runtime.key\",\n runtimePid: \"abs.runtime.pid\",\n runtimePort: \"abs.runtime.port\",\n runtimeExitReason: \"abs.runtime.exit_reason\",\n runtimeReadinessMs: \"abs.runtime.readiness_ms\",\n routeShard: \"abs.route.shard\",\n routeDecision: \"abs.route.decision\",\n secretName: \"abs.secret.name\",\n secretFingerprint: \"abs.secret.fingerprint\",\n auditKind: \"abs.audit.kind\"\n};\nvar withSpan = async (tracer, name, options, fn) => tracer.startActiveSpan(name, options, async (span) => {\n try {\n const result = await fn(span);\n span.setStatus({ code: SpanStatusCode.OK });\n return result;\n } catch (error) {\n span.setStatus({\n code: SpanStatusCode.ERROR,\n message: error instanceof Error ? error.message : String(error)\n });\n span.recordException(error);\n throw error;\n } finally {\n span.end();\n }\n});\nvar withSpanSync = (tracer, name, options, fn) => tracer.startActiveSpan(name, options, (span) => {\n try {\n const result = fn(span);\n span.setStatus({ code: SpanStatusCode.OK });\n return result;\n } catch (error) {\n span.setStatus({\n code: SpanStatusCode.ERROR,\n message: error instanceof Error ? error.message : String(error)\n });\n span.recordException(error);\n throw error;\n } finally {\n span.end();\n }\n});\nexport {\n withSpanSync,\n withSpan,\n tracerOrNoop,\n createNoopTracerProvider,\n createNoopTracer,\n createNoopSpan,\n SpanStatusCode,\n SpanKind,\n ABS_ATTRS\n};\n\n//# debugId=3038092490E875A764756E2164756E21\n//# sourceMappingURL=index.js.map\n",
6
+ "/**\n * Region directory — the \"which region does this tenant live in?\" primitive\n * that pairs with `createRouter`. 0.4.0, closes the multi-region gap (5.1)\n * from the PaaS guide.\n *\n * `createRouter` shards WITHIN a region; nothing decided which region a\n * tenant lives in. The directory owns that decision: sticky, deterministic\n * assignment (weighted rendezvous over region ids by default), an optional\n * caller hook for latency-based placement, and explicit overrides for\n * control-plane onboarding decisions. The intended wire-up:\n *\n * ```ts\n * router.route({ tenantId, region: directory.regionFor(tenantId) });\n * ```\n */\n\nimport {\n\tABS_ATTRS,\n\ttracerOrNoop,\n\ttype TracerProvider\n} from '@absolutejs/telemetry';\n\nexport type Region = {\n\tid: string;\n\t/**\n\t * Relative weight for the default assignment strategy (weighted\n\t * rendezvous). Higher weight = proportionally more tenants land here.\n\t * Default 1. Weights <= 0 exclude the region from default assignment\n\t * (explicit `assignRegion` still works).\n\t */\n\tweight?: number;\n};\n\n/**\n * Optional caller-supplied assignment hook, consulted on first-time\n * assignment (e.g. latency-based placement from an edge probe). Returning\n * `undefined` — or an id that is not a registered region — falls back to\n * the default weighted-rendezvous strategy.\n */\nexport type RegionAssignFn = (tenantId: string) => string | undefined;\n\nexport type RegionDirectoryOptions = {\n\t/** Region set. At least one region is required. */\n\tregions: Region[];\n\t/** Optional assignment hook. See {@link RegionAssignFn}. */\n\tassign?: RegionAssignFn;\n\t/** Override `Date.now` for tests. */\n\tclock?: () => number;\n\t/**\n\t * Optional OpenTelemetry tracer provider. When set, first-time\n\t * assignments are wrapped in `router.region_assign` spans with\n\t * `abs.tenant` + `abs.region` attributes. When omitted, all tracing\n\t * is a zero-allocation noop.\n\t */\n\ttracerProvider?: TracerProvider;\n};\n\n/**\n * Returned by {@link RegionDirectory.metrics}. Point-in-time view of the\n * assignment table — the operator's \"is my region spread what I think it\n * is?\" answer.\n *\n * - `assignments` — number of tenants currently assigned.\n * - `byRegion` — current assignment count per region id.\n * - `overrides` — how many current assignments were explicit\n * `assignRegion` overrides rather than strategy/hook picks.\n */\nexport type RegionDirectoryMetrics = {\n\tassignments: number;\n\tbyRegion: Record<string, number>;\n\toverrides: number;\n};\n\nexport type RegionDirectorySnapshot = {\n\tversion: 1;\n\tat: number;\n\tregions: Region[];\n\tassignments: Array<{\n\t\ttenant: string;\n\t\tregion: string;\n\t\toverride: boolean;\n\t}>;\n};\n\nexport type RegionDirectory = {\n\t/**\n\t * The sticky assignment for a tenant, created on first call. Once\n\t * assigned, every subsequent call returns the same region until\n\t * `release()` or `removeRegion()` invalidates it. If the assigned\n\t * region has been removed, the tenant is lazily re-assigned here.\n\t */\n\tregionFor: (tenantId: string) => string;\n\t/**\n\t * Explicit override — the control-plane onboarding decision. Throws\n\t * on an unknown region id. Overrides survive `snapshot()`/`restore()`\n\t * and are counted separately in `metrics().overrides`.\n\t */\n\tassignRegion: (tenantId: string, regionId: string) => void;\n\t/** Forget a tenant's assignment. Next `regionFor` re-assigns. */\n\trelease: (tenantId: string) => void;\n\taddRegion: (region: Region) => void;\n\t/**\n\t * Remove a region. Tenants assigned to it are NOT eagerly moved —\n\t * they re-assign lazily on their next `regionFor()` call.\n\t */\n\tremoveRegion: (regionId: string) => void;\n\tregions: () => Region[];\n\tsnapshot: () => RegionDirectorySnapshot;\n\t/**\n\t * Repopulate the assignment table from a previously captured\n\t * `snapshot()` — assignments must survive control-plane restarts.\n\t * Region membership itself comes from the factory options /\n\t * `addRegion` (same contract as `Router.restore`, which does not\n\t * recreate shard membership).\n\t */\n\trestore: (snapshot: RegionDirectorySnapshot) => void;\n\tmetrics: () => RegionDirectoryMetrics;\n};\n\n/** Same FNV-1a 32-bit as `createRouter`'s hash strategies. Not cryptographic. */\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\ntype Assignment = {\n\tregion: string;\n\toverride: boolean;\n};\n\nexport const createRegionDirectory = (\n\toptions: RegionDirectoryOptions\n): RegionDirectory => {\n\tif (options.regions.length === 0) {\n\t\tthrow new Error('createRegionDirectory requires at least one region');\n\t}\n\tconst clock = options.clock ?? Date.now;\n\tconst assignHook = options.assign;\n\tconst tracer = tracerOrNoop(options.tracerProvider, '@absolutejs/router');\n\n\tconst regionList: Region[] = [...options.regions];\n\tconst assignments = new Map<string, Assignment>();\n\n\tconst hasRegion = (id: string): boolean =>\n\t\tregionList.some((region) => region.id === id);\n\n\t/**\n\t * Weighted rendezvous over region ids — same scheme as the router's\n\t * rendezvous shard strategy, minus the load hook. Deterministic and\n\t * stable: a tenant's default region depends only on the tenant id +\n\t * the region set, so add/remove of a region moves O(weight/total) of\n\t * tenants and every replica computes the same answer.\n\t */\n\tconst pickRegion = (tenantId: string): string => {\n\t\tlet bestId = regionList[0]!.id;\n\t\tlet bestScore = -Infinity;\n\t\tfor (const region of regionList) {\n\t\t\tconst weight = region.weight ?? 1;\n\t\t\tif (weight <= 0) continue;\n\t\t\tconst seed = fnv1a32(`${tenantId}|${region.id}`);\n\t\t\tconst u = (seed + 1) / 0x1_0000_0000;\n\t\t\tconst score = weight * -Math.log(u);\n\t\t\tif (score > bestScore) {\n\t\t\t\tbestScore = score;\n\t\t\t\tbestId = region.id;\n\t\t\t}\n\t\t}\n\t\treturn bestId;\n\t};\n\n\tconst regionFor: RegionDirectory['regionFor'] = (tenantId) => {\n\t\tconst found = assignments.get(tenantId);\n\t\t// A stale assignment (region since removed) re-assigns lazily.\n\t\tif (found && hasRegion(found.region)) return found.region;\n\t\t// 0.4.0: span the first-time assignment only — steady-state\n\t\t// lookups are pure Map reads and stay span-free.\n\t\tconst span = tracer.startSpan('router.region_assign', {\n\t\t\tattributes: { [ABS_ATTRS.tenant]: tenantId }\n\t\t});\n\t\tconst hookChoice = assignHook?.(tenantId);\n\t\tconst region =\n\t\t\thookChoice !== undefined && hasRegion(hookChoice)\n\t\t\t\t? hookChoice\n\t\t\t\t: pickRegion(tenantId);\n\t\tassignments.set(tenantId, { override: false, region });\n\t\tspan.setAttribute('abs.region', region);\n\t\tspan.setStatus({ code: 1 /* OK */ });\n\t\tspan.end();\n\t\treturn region;\n\t};\n\n\treturn {\n\t\taddRegion: (region) => {\n\t\t\tif (hasRegion(region.id)) return;\n\t\t\tregionList.push(region);\n\t\t},\n\t\tassignRegion: (tenantId, regionId) => {\n\t\t\tif (!hasRegion(regionId)) {\n\t\t\t\tthrow new Error(`assignRegion: unknown region \"${regionId}\"`);\n\t\t\t}\n\t\t\tassignments.set(tenantId, { override: true, region: regionId });\n\t\t},\n\t\tmetrics: () => {\n\t\t\tconst byRegion: Record<string, number> = {};\n\t\t\tlet overrides = 0;\n\t\t\tfor (const assignment of assignments.values()) {\n\t\t\t\tbyRegion[assignment.region] =\n\t\t\t\t\t(byRegion[assignment.region] ?? 0) + 1;\n\t\t\t\tif (assignment.override) overrides += 1;\n\t\t\t}\n\t\t\treturn { assignments: assignments.size, byRegion, overrides };\n\t\t},\n\t\tregionFor,\n\t\tregions: () => regionList.map((region) => ({ ...region })),\n\t\trelease: (tenantId) => {\n\t\t\tassignments.delete(tenantId);\n\t\t},\n\t\tremoveRegion: (regionId) => {\n\t\t\tconst at = regionList.findIndex((region) => region.id === regionId);\n\t\t\tif (at >= 0) regionList.splice(at, 1);\n\t\t\t// Assignments to the removed region are left in place — they\n\t\t\t// re-assign lazily on the tenant's next regionFor().\n\t\t},\n\t\trestore: (snap) => {\n\t\t\tassignments.clear();\n\t\t\tfor (const a of snap.assignments) {\n\t\t\t\tassignments.set(a.tenant, {\n\t\t\t\t\toverride: a.override,\n\t\t\t\t\tregion: a.region\n\t\t\t\t});\n\t\t\t}\n\t\t},\n\t\tsnapshot: () => {\n\t\t\tconst assignmentsOut: RegionDirectorySnapshot['assignments'] = [];\n\t\t\tfor (const [tenant, assignment] of assignments) {\n\t\t\t\tassignmentsOut.push({\n\t\t\t\t\toverride: assignment.override,\n\t\t\t\t\tregion: assignment.region,\n\t\t\t\t\ttenant\n\t\t\t\t});\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tassignments: assignmentsOut,\n\t\t\t\tat: clock(),\n\t\t\t\tregions: regionList.map((region) => ({ ...region })),\n\t\t\t\tversion: 1\n\t\t\t};\n\t\t}\n\t};\n};\n",
7
+ "/**\n * Custom-domain map — hostname → tenant resolution for custom domains at\n * the edge gateway. 0.4.0, closes the edge-routing gap (5.6) from the\n * PaaS guide.\n *\n * A tenant's traffic arrives as `app.acme.com` (their CNAME), not as a\n * tenant id. The domain map is the first lookup in the edge gateway:\n *\n * ```ts\n * const hit = domainMap.resolve(request.headers.get('host') ?? '');\n * if (!hit) return new Response('unknown domain', { status: 404 });\n * router.route({ tenantId: hit.tenantId, region: directory.regionFor(hit.tenantId) });\n * ```\n *\n * Dependency-free and O(1)-ish: a Map for exact hosts, a Map keyed by\n * suffix for `*.example.com` wildcards (one lookup each — no per-entry\n * scan).\n */\n\nimport {\n\tABS_ATTRS,\n\ttracerOrNoop,\n\ttype TracerProvider\n} from '@absolutejs/telemetry';\n\nexport type DomainMatch = 'exact' | 'wildcard' | 'fallback';\n\nexport type DomainResolution = {\n\ttenantId: string;\n\tmatched: DomainMatch;\n};\n\n/**\n * Optional caller-supplied resolver of last resort, consulted when neither\n * an exact nor a wildcard entry matches. The intended use is the platform's\n * own subdomain scheme — parse `<workspace>.cloud.absolutejs.com` into a\n * tenant id without registering every workspace as an entry. Returning\n * `undefined` makes the resolve a miss (`resolve()` returns `null`).\n */\nexport type DomainFallbackFn = (hostname: string) => string | undefined;\n\nexport type DomainMapOptions = {\n\t/** Optional resolver of last resort. See {@link DomainFallbackFn}. */\n\tfallback?: DomainFallbackFn;\n\t/** Override `Date.now` for tests. */\n\tclock?: () => number;\n\t/**\n\t * Optional OpenTelemetry tracer provider. When set, `resolve()` is\n\t * wrapped in `router.domain_resolve` spans with `abs.domain.host`,\n\t * and (on a hit) `abs.tenant` + `abs.domain.matched` attributes.\n\t * Status OK on hit, ERROR on miss. When omitted, all tracing is a\n\t * zero-allocation noop.\n\t */\n\ttracerProvider?: TracerProvider;\n};\n\nexport type DomainMapEntry = {\n\t/** Wildcard entries keep their `*.example.com` form here. */\n\thostname: string;\n\ttenantId: string;\n};\n\nexport type DomainMapSnapshot = {\n\tversion: 1;\n\tat: number;\n\tentries: DomainMapEntry[];\n};\n\n/**\n * Returned by {@link DomainMap.metrics}.\n *\n * - `entries` — registered entries (exact + wildcard).\n * - `resolves` — total `resolve()` calls.\n * - `hits` / `misses` — resolves that found a tenant (any match kind)\n * vs. resolves that returned `null`. A climbing miss rate usually\n * means stale DNS pointing at the gateway after an offboard.\n * - `lastResolveMs` — wall-clock of the most recent `resolve()` call.\n */\nexport type DomainMapMetrics = {\n\tentries: number;\n\tresolves: number;\n\thits: number;\n\tmisses: number;\n\tlastResolveMs: number;\n};\n\nexport type DomainMap = {\n\t/**\n\t * Register a hostname → tenant entry. Hostnames are lowercased.\n\t * `*.example.com` registers a wildcard matching exactly one label\n\t * (`app.example.com` yes; `a.b.example.com` and the bare apex no).\n\t * Throws on any other `*` placement.\n\t */\n\tadd: (hostname: string, tenantId: string) => void;\n\tremove: (hostname: string) => void;\n\t/**\n\t * Resolve a request's `Host` value to a tenant. Strips the port,\n\t * lowercases, then checks exact → wildcard → `fallback` hook (exact\n\t * beats wildcard). Returns `null` on a miss.\n\t */\n\tresolve: (hostname: string) => DomainResolution | null;\n\tlist: () => DomainMapEntry[];\n\tsnapshot: () => DomainMapSnapshot;\n\trestore: (snapshot: DomainMapSnapshot) => void;\n\tmetrics: () => DomainMapMetrics;\n};\n\n/**\n * Lowercase + strip the `:port` suffix from a `Host` header value.\n * Bracketed IPv6 literals (`[::1]:8080`) keep their brackets.\n */\nconst normalizeHost = (hostname: string): string => {\n\tlet host = hostname.trim().toLowerCase();\n\tif (host.startsWith('[')) {\n\t\tconst close = host.indexOf(']');\n\t\tif (close >= 0) host = host.slice(0, close + 1);\n\t\treturn host;\n\t}\n\tconst colon = host.indexOf(':');\n\tif (colon >= 0) host = host.slice(0, colon);\n\treturn host;\n};\n\nexport const createDomainMap = (options: DomainMapOptions = {}): DomainMap => {\n\tconst clock = options.clock ?? Date.now;\n\tconst fallbackHook = options.fallback;\n\tconst tracer = tracerOrNoop(options.tracerProvider, '@absolutejs/router');\n\n\t/** Exact hostname → tenant id. */\n\tconst exact = new Map<string, string>();\n\t/** Wildcard suffix (the part after `*.`) → tenant id. */\n\tconst wildcard = new Map<string, string>();\n\n\tlet totalResolves = 0;\n\tlet hits = 0;\n\tlet misses = 0;\n\tlet lastResolveMs = 0;\n\n\tconst resolve: DomainMap['resolve'] = (hostname) => {\n\t\tconst resolveStart = clock();\n\t\ttotalResolves += 1;\n\t\tconst host = normalizeHost(hostname);\n\t\t// 0.4.0: span the resolve. Hot-path safe — noop tracer when no\n\t\t// tracerProvider is set.\n\t\tconst span = tracer.startSpan('router.domain_resolve', {\n\t\t\tattributes: { 'abs.domain.host': host }\n\t\t});\n\t\tconst finish = (\n\t\t\tresult: DomainResolution | null\n\t\t): DomainResolution | null => {\n\t\t\tif (result === null) {\n\t\t\t\tmisses += 1;\n\t\t\t\tspan.setStatus({ code: 2 /* ERROR */ });\n\t\t\t} else {\n\t\t\t\thits += 1;\n\t\t\t\tspan.setAttribute(ABS_ATTRS.tenant, result.tenantId);\n\t\t\t\tspan.setAttribute('abs.domain.matched', result.matched);\n\t\t\t\tspan.setStatus({ code: 1 /* OK */ });\n\t\t\t}\n\t\t\tspan.end();\n\t\t\tlastResolveMs = clock() - resolveStart;\n\t\t\treturn result;\n\t\t};\n\n\t\tconst exactHit = exact.get(host);\n\t\tif (exactHit !== undefined) {\n\t\t\treturn finish({ matched: 'exact', tenantId: exactHit });\n\t\t}\n\n\t\t// Wildcard matches exactly one label: strip the first label and\n\t\t// look the remainder up as a suffix. `a.b.example.com` produces\n\t\t// the suffix `b.example.com`, so a `*.example.com` entry does\n\t\t// NOT match two labels deep — by design (mirrors how TLS\n\t\t// wildcard certificates scope).\n\t\tconst dot = host.indexOf('.');\n\t\tif (dot > 0) {\n\t\t\tconst wildcardHit = wildcard.get(host.slice(dot + 1));\n\t\t\tif (wildcardHit !== undefined) {\n\t\t\t\treturn finish({ matched: 'wildcard', tenantId: wildcardHit });\n\t\t\t}\n\t\t}\n\n\t\tconst fallbackHit = fallbackHook?.(host);\n\t\tif (fallbackHit !== undefined) {\n\t\t\treturn finish({ matched: 'fallback', tenantId: fallbackHit });\n\t\t}\n\t\treturn finish(null);\n\t};\n\n\tconst add: DomainMap['add'] = (hostname, tenantId) => {\n\t\tconst host = normalizeHost(hostname);\n\t\tif (host.startsWith('*.')) {\n\t\t\tconst suffix = host.slice(2);\n\t\t\tif (suffix.length === 0 || suffix.includes('*')) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`add: unsupported wildcard pattern \"${hostname}\" — only \"*.example.com\" (one leading label) is supported`\n\t\t\t\t);\n\t\t\t}\n\t\t\twildcard.set(suffix, tenantId);\n\t\t\treturn;\n\t\t}\n\t\tif (host.includes('*')) {\n\t\t\tthrow new Error(\n\t\t\t\t`add: unsupported wildcard pattern \"${hostname}\" — only \"*.example.com\" (one leading label) is supported`\n\t\t\t);\n\t\t}\n\t\texact.set(host, tenantId);\n\t};\n\n\tconst list: DomainMap['list'] = () => {\n\t\tconst entries: DomainMapEntry[] = [];\n\t\tfor (const [hostname, tenantId] of exact) {\n\t\t\tentries.push({ hostname, tenantId });\n\t\t}\n\t\tfor (const [suffix, tenantId] of wildcard) {\n\t\t\tentries.push({ hostname: `*.${suffix}`, tenantId });\n\t\t}\n\t\treturn entries;\n\t};\n\n\treturn {\n\t\tadd,\n\t\tlist,\n\t\tmetrics: () => ({\n\t\t\tentries: exact.size + wildcard.size,\n\t\t\thits,\n\t\t\tlastResolveMs,\n\t\t\tmisses,\n\t\t\tresolves: totalResolves\n\t\t}),\n\t\tremove: (hostname) => {\n\t\t\tconst host = normalizeHost(hostname);\n\t\t\tif (host.startsWith('*.')) {\n\t\t\t\twildcard.delete(host.slice(2));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\texact.delete(host);\n\t\t},\n\t\tresolve,\n\t\trestore: (snap) => {\n\t\t\texact.clear();\n\t\t\twildcard.clear();\n\t\t\t// Re-add through the same classification path so wildcard\n\t\t\t// entries land back in the suffix map.\n\t\t\tfor (const entry of snap.entries) {\n\t\t\t\tadd(entry.hostname, entry.tenantId);\n\t\t\t}\n\t\t},\n\t\tsnapshot: () => ({\n\t\t\tat: clock(),\n\t\t\tentries: list(),\n\t\t\tversion: 1\n\t\t})\n\t};\n};\n",
8
+ "/**\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\nimport {\n\tABS_ATTRS,\n\ttracerOrNoop,\n\ttype TracerProvider\n} from '@absolutejs/telemetry';\n\n// 0.4.0: region directory (gap 5.1) + custom-domain map (gap 5.6) ship as\n// sibling primitives alongside createRouter.\nexport * from './region';\nexport * from './domain';\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\t/**\n\t * Region this shard serves (matches a `Region.id` in the paired\n\t * `createRegionDirectory`). When `route()` is called with a `region`,\n\t * only shards in that region — plus region-less shards, for\n\t * back-compat — are candidates. Omit for a single-region deployment.\n\t * Added in 0.4.0.\n\t */\n\tregion?: string;\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 = (\n\tkey: string,\n\tshards: ReadonlyArray<Shard>\n) => 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\t/**\n\t * Optional OpenTelemetry tracer provider. When set, `route()` and\n\t * `acquire()` are wrapped in `router.route` / `router.acquire`\n\t * spans with `abs.tenant`, `abs.route.decision`, and (on allow)\n\t * `abs.route.shard` attributes. When omitted, all tracing is a\n\t * zero-allocation noop. Added in 0.3.0.\n\t *\n\t * Structural type via `@absolutejs/telemetry`; no peer-dep on\n\t * `@opentelemetry/api`.\n\t */\n\ttracerProvider?: TracerProvider;\n};\n\nexport type RouteDecision =\n\t| 'allow'\n\t| 'rate-limited'\n\t| 'capped'\n\t| 'no-shards'\n\t| 'no-region-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\t/**\n\t * Optional region filter. When set, only shards whose `region` matches\n\t * — plus region-less shards, for back-compat — are candidates. When\n\t * the region has eligible shards registered but none are candidates,\n\t * `route()` returns `'no-region-shards'` (distinguishable from the\n\t * cluster-wide `'no-shards'`). The intended source is the paired\n\t * directory: `route({ tenantId, region: directory.regionFor(tenantId) })`.\n\t * Added in 0.4.0.\n\t */\n\tregion?: 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<\n\t\tShard & { healthy: boolean; draining: boolean; active: number }\n\t>;\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) =>\n\tjumpHash(key, shards.length);\n\nconst makeRendezvousStrategy =\n\t(load?: ShardLoadFn): HashStrategyFn =>\n\t(key, shards) => {\n\t\tlet bestIndex = 0;\n\t\tlet bestScore = -Infinity;\n\t\tfor (let i = 0; i < shards.length; i++) {\n\t\t\tconst shard = shards[i]!;\n\t\t\tconst baseWeight = shard.weight ?? 1;\n\t\t\tif (baseWeight <= 0) continue;\n\t\t\tconst loadValue = load ? Math.max(load(shard.id), 0.0001) : 1;\n\t\t\tconst effectiveWeight = baseWeight / loadValue;\n\t\t\tconst seed = fnv1a32(`${key}|${shard.id}`);\n\t\t\tconst u = (seed + 1) / 0x1_0000_0000;\n\t\t\tconst score = effectiveWeight * -Math.log(u);\n\t\t\tif (score > bestScore) {\n\t\t\t\tbestScore = score;\n\t\t\t\tbestIndex = i;\n\t\t\t}\n\t\t}\n\t\treturn bestIndex;\n\t};\n\nconst resolveStrategy = (\n\tstrategy: HashStrategy,\n\tload?: ShardLoadFn\n): 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(\n\t\toptions.hashStrategy ?? 'jump',\n\t\toptions.load\n\t);\n\tconst cap = options.perTenantConnectionCap ?? Infinity;\n\tconst rate = options.perTenantRateLimit ?? {\n\t\trefillPerSecond: 0,\n\t\ttokens: Infinity\n\t};\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.3.0: OTel tracer (noop when options.tracerProvider unset).\n\tconst tracer = tracerOrNoop(options.tracerProvider, '@absolutejs/router');\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<Exclude<RouteDecision, 'allow'>, number> = {\n\t\t'rate-limited': 0,\n\t\tcapped: 0,\n\t\t'no-shards': 0,\n\t\t'no-region-shards': 0,\n\t\tdenied: 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 = (\n\t\ttenant: string,\n\t\troute: string,\n\t\trule: RateLimit,\n\t\tnow: number\n\t): 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(\n\t\t\t(shard) => healthy.get(shard.id) === true && !draining.has(shard.id)\n\t\t);\n\n\tconst route: Router['route'] = (request) => {\n\t\t// 0.3.0: span the routing decision. Hot-path safe — when no\n\t\t// tracerProvider is set, the noop tracer's startSpan returns\n\t\t// the singleton noop span (zero allocation).\n\t\tconst span = tracer.startSpan('router.route', {\n\t\t\tattributes: {\n\t\t\t\t[ABS_ATTRS.tenant]: request.tenantId,\n\t\t\t\t...(request.route !== undefined\n\t\t\t\t\t? { 'abs.route.name': request.route }\n\t\t\t\t\t: {}),\n\t\t\t\t...(request.region !== undefined\n\t\t\t\t\t? { 'abs.region': request.region }\n\t\t\t\t\t: {})\n\t\t\t}\n\t\t});\n\t\tconst routeStart = clock();\n\t\ttotalRoutes += 1;\n\t\tconst finishSpan = (result: RouteResult): RouteResult => {\n\t\t\tspan.setAttribute(ABS_ATTRS.routeDecision, result.decision);\n\t\t\tif (result.shard !== null) {\n\t\t\t\tspan.setAttribute(ABS_ATTRS.routeShard, result.shard.id);\n\t\t\t}\n\t\t\tspan.setStatus({\n\t\t\t\tcode: result.decision === 'allow' ? 1 /* OK */ : 2 /* ERROR */\n\t\t\t});\n\t\t\tspan.end();\n\t\t\treturn result;\n\t\t};\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 finishSpan({ 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\t// 0.4.0: region filter. Region-less shards are region-agnostic\n\t\t// (back-compat) and remain candidates for any requested region.\n\t\tconst candidates =\n\t\t\trequest.region === undefined\n\t\t\t\t? live\n\t\t\t\t: live.filter(\n\t\t\t\t\t\t(shard) =>\n\t\t\t\t\t\t\tshard.region === undefined ||\n\t\t\t\t\t\t\tshard.region === request.region\n\t\t\t\t\t);\n\t\tif (candidates.length === 0) {\n\t\t\treturn recordRejection('no-region-shards');\n\t\t}\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 finishSpan({\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 (\n\t\t\trequest.route !== undefined &&\n\t\t\tperRouteRateLimits[request.route] !== undefined\n\t\t) {\n\t\t\trouteRule = perRouteRateLimits[request.route]!;\n\t\t\trouteBucket = ensureRouteBucket(\n\t\t\t\trequest.tenantId,\n\t\t\t\trequest.route,\n\t\t\t\trouteRule,\n\t\t\t\tnow\n\t\t\t);\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 finishSpan({\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 =\n\t\t\trequest.channelId === undefined\n\t\t\t\t? request.tenantId\n\t\t\t\t: `${request.tenantId}:${request.channelId}`;\n\t\tconst index = strategy(key, candidates);\n\t\tconst chosen =\n\t\t\tcandidates[Math.max(0, Math.min(index, candidates.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 finishSpan({ decision: 'allow', shard: chosen });\n\t};\n\n\tconst acquire: Router['acquire'] = (tenantId) => {\n\t\t// 0.3.0: span the acquire. It's instantaneous bookkeeping, but\n\t\t// the per-tenant active count is one of the most operationally\n\t\t// interesting metrics — a sustained climb signals tenants\n\t\t// holding more connections than expected.\n\t\tconst span = tracer.startSpan('router.acquire', {\n\t\t\tattributes: { [ABS_ATTRS.tenant]: tenantId }\n\t\t});\n\t\tconst now = clock();\n\t\tconst state = ensureTenant(tenantId, now);\n\t\tstate.active += 1;\n\t\ttotalAcquires += 1;\n\t\tspan.setAttribute('abs.tenant.active', state.active);\n\t\tspan.setStatus({ code: 1 /* OK */ });\n\t\tspan.end();\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(\n\t\t\t\t\t\t(acc, state) => acc + state.active,\n\t\t\t\t\t\t0\n\t\t\t\t\t);\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
9
  ],
7
- "mappings": "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",
8
- "debugId": "E49309CC1211AA2864756E2164756E21",
10
+ "mappings": "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",
11
+ "debugId": "73F582FCBD0A9B0764756E2164756E21",
9
12
  "names": []
10
13
  }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Region directory — the "which region does this tenant live in?" primitive
3
+ * that pairs with `createRouter`. 0.4.0, closes the multi-region gap (5.1)
4
+ * from the PaaS guide.
5
+ *
6
+ * `createRouter` shards WITHIN a region; nothing decided which region a
7
+ * tenant lives in. The directory owns that decision: sticky, deterministic
8
+ * assignment (weighted rendezvous over region ids by default), an optional
9
+ * caller hook for latency-based placement, and explicit overrides for
10
+ * control-plane onboarding decisions. The intended wire-up:
11
+ *
12
+ * ```ts
13
+ * router.route({ tenantId, region: directory.regionFor(tenantId) });
14
+ * ```
15
+ */
16
+ import { type TracerProvider } from '@absolutejs/telemetry';
17
+ export type Region = {
18
+ id: string;
19
+ /**
20
+ * Relative weight for the default assignment strategy (weighted
21
+ * rendezvous). Higher weight = proportionally more tenants land here.
22
+ * Default 1. Weights <= 0 exclude the region from default assignment
23
+ * (explicit `assignRegion` still works).
24
+ */
25
+ weight?: number;
26
+ };
27
+ /**
28
+ * Optional caller-supplied assignment hook, consulted on first-time
29
+ * assignment (e.g. latency-based placement from an edge probe). Returning
30
+ * `undefined` — or an id that is not a registered region — falls back to
31
+ * the default weighted-rendezvous strategy.
32
+ */
33
+ export type RegionAssignFn = (tenantId: string) => string | undefined;
34
+ export type RegionDirectoryOptions = {
35
+ /** Region set. At least one region is required. */
36
+ regions: Region[];
37
+ /** Optional assignment hook. See {@link RegionAssignFn}. */
38
+ assign?: RegionAssignFn;
39
+ /** Override `Date.now` for tests. */
40
+ clock?: () => number;
41
+ /**
42
+ * Optional OpenTelemetry tracer provider. When set, first-time
43
+ * assignments are wrapped in `router.region_assign` spans with
44
+ * `abs.tenant` + `abs.region` attributes. When omitted, all tracing
45
+ * is a zero-allocation noop.
46
+ */
47
+ tracerProvider?: TracerProvider;
48
+ };
49
+ /**
50
+ * Returned by {@link RegionDirectory.metrics}. Point-in-time view of the
51
+ * assignment table — the operator's "is my region spread what I think it
52
+ * is?" answer.
53
+ *
54
+ * - `assignments` — number of tenants currently assigned.
55
+ * - `byRegion` — current assignment count per region id.
56
+ * - `overrides` — how many current assignments were explicit
57
+ * `assignRegion` overrides rather than strategy/hook picks.
58
+ */
59
+ export type RegionDirectoryMetrics = {
60
+ assignments: number;
61
+ byRegion: Record<string, number>;
62
+ overrides: number;
63
+ };
64
+ export type RegionDirectorySnapshot = {
65
+ version: 1;
66
+ at: number;
67
+ regions: Region[];
68
+ assignments: Array<{
69
+ tenant: string;
70
+ region: string;
71
+ override: boolean;
72
+ }>;
73
+ };
74
+ export type RegionDirectory = {
75
+ /**
76
+ * The sticky assignment for a tenant, created on first call. Once
77
+ * assigned, every subsequent call returns the same region until
78
+ * `release()` or `removeRegion()` invalidates it. If the assigned
79
+ * region has been removed, the tenant is lazily re-assigned here.
80
+ */
81
+ regionFor: (tenantId: string) => string;
82
+ /**
83
+ * Explicit override — the control-plane onboarding decision. Throws
84
+ * on an unknown region id. Overrides survive `snapshot()`/`restore()`
85
+ * and are counted separately in `metrics().overrides`.
86
+ */
87
+ assignRegion: (tenantId: string, regionId: string) => void;
88
+ /** Forget a tenant's assignment. Next `regionFor` re-assigns. */
89
+ release: (tenantId: string) => void;
90
+ addRegion: (region: Region) => void;
91
+ /**
92
+ * Remove a region. Tenants assigned to it are NOT eagerly moved —
93
+ * they re-assign lazily on their next `regionFor()` call.
94
+ */
95
+ removeRegion: (regionId: string) => void;
96
+ regions: () => Region[];
97
+ snapshot: () => RegionDirectorySnapshot;
98
+ /**
99
+ * Repopulate the assignment table from a previously captured
100
+ * `snapshot()` — assignments must survive control-plane restarts.
101
+ * Region membership itself comes from the factory options /
102
+ * `addRegion` (same contract as `Router.restore`, which does not
103
+ * recreate shard membership).
104
+ */
105
+ restore: (snapshot: RegionDirectorySnapshot) => void;
106
+ metrics: () => RegionDirectoryMetrics;
107
+ };
108
+ export declare const createRegionDirectory: (options: RegionDirectoryOptions) => RegionDirectory;
package/package.json CHANGED
@@ -1,52 +1,63 @@
1
1
  {
2
- "name": "@absolutejs/router",
3
- "version": "0.2.0",
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"]
2
+ "name": "@absolutejs/router",
3
+ "version": "0.4.0",
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
+ "homepage": "https://github.com/absolutejs/router",
10
+ "bugs": {
11
+ "url": "https://github.com/absolutejs/router/issues"
12
+ },
13
+ "main": "./dist/index.js",
14
+ "module": "./dist/index.js",
15
+ "types": "./dist/index.d.ts",
16
+ "type": "module",
17
+ "license": "BSL-1.1",
18
+ "author": "Alex Kahn",
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "keywords": [
23
+ "absolutejs",
24
+ "bun",
25
+ "paas",
26
+ "multi-tenant",
27
+ "router",
28
+ "consistent-hash",
29
+ "rate-limit",
30
+ "websocket"
31
+ ],
32
+ "scripts": {
33
+ "build": "rm -rf dist && bun build src/index.ts --outdir dist --sourcemap --target=bun && tsc --project tsconfig.build.json",
34
+ "test": "bun test tests/",
35
+ "typecheck": "tsc --noEmit",
36
+ "format": "prettier --write \"./**/*.{ts,json,md}\"",
37
+ "check:package": "bun run typecheck && bun run build && bun run test",
38
+ "release": "bun run format && bun run check:package && bun publish"
39
+ },
40
+ "dependencies": {
41
+ "@absolutejs/telemetry": "^0.0.2"
42
+ },
43
+ "peerDependencies": {
44
+ "bun-types": "^1.3.14"
45
+ },
46
+ "devDependencies": {
47
+ "@absolutejs/telemetry": "^0.0.2",
48
+ "@types/bun": "^1.3.14",
49
+ "prettier": "^3.8.3",
50
+ "typescript": "^6.0.3"
51
+ },
52
+ "exports": {
53
+ ".": {
54
+ "types": "./dist/index.d.ts",
55
+ "import": "./dist/index.js",
56
+ "default": "./dist/index.js"
57
+ }
58
+ },
59
+ "files": [
60
+ "dist",
61
+ "README.md"
62
+ ]
52
63
  }