halt-rate 0.3.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/dist/index.d.mts CHANGED
@@ -130,6 +130,54 @@ declare namespace extractors {
130
130
  export { extractors_HEALTH_CHECK_PATHS as HEALTH_CHECK_PATHS, extractors_extractApiKey as extractApiKey, extractors_extractIp as extractIp, extractors_extractPath as extractPath, extractors_extractUserId as extractUserId, extractors_isHealthCheck as isHealthCheck, extractors_isPrivateIp as isPrivateIp };
131
131
  }
132
132
 
133
+ /**
134
+ * Dynamic limits — change policies at runtime without restarting.
135
+ *
136
+ * `PolicyRegistry` holds named policies you can mutate live. Use it as the
137
+ * limiter's `policy` via `registry.resolver(selector)`:
138
+ *
139
+ * const registry = new PolicyRegistry([presets.PUBLIC_API]);
140
+ * const limiter = new RateLimiter({
141
+ * store,
142
+ * policy: registry.resolver((req) => 'public_api'),
143
+ * });
144
+ * // later, no restart:
145
+ * registry.update('public_api', { limit: 500 });
146
+ *
147
+ * For limits stored in Redis/DB/config (and shared across a fleet), use
148
+ * `cachedPolicyResolver` with a loader that reads that shared state.
149
+ */
150
+
151
+ declare class PolicyRegistry {
152
+ private policies;
153
+ constructor(initial?: Policy[]);
154
+ /** Add or replace a policy (keyed by `policy.name`). */
155
+ register(policy: Policy): this;
156
+ get(name: string): Policy | undefined;
157
+ has(name: string): boolean;
158
+ /** Mutate fields of an existing policy at runtime (e.g. `{ limit: 500 }`). */
159
+ update(name: string, patch: Partial<Omit<Policy, 'name'>>): Policy;
160
+ remove(name: string): boolean;
161
+ list(): Policy[];
162
+ /**
163
+ * Build a resolver for the limiter's `policy` option.
164
+ * @param selector maps a request to a registered policy name.
165
+ */
166
+ resolver(selector: (request: any) => string): (request: any) => Policy;
167
+ }
168
+ interface CachedPolicyResolverOptions {
169
+ /** Cache entry lifetime in ms (default 5000). */
170
+ ttlMs?: number;
171
+ /** Cache key for a request (default: the loaded policy is shared under one key). */
172
+ key?: (request: any) => string;
173
+ }
174
+ /**
175
+ * Wrap a (possibly async) loader with a per-key TTL cache. The loader reads your
176
+ * source of truth (Redis/DB/config), so limits propagate across a fleet and
177
+ * refresh live — without restarting. Use the returned function as `policy`.
178
+ */
179
+ declare function cachedPolicyResolver(loader: (request: any) => Policy | Promise<Policy>, options?: CachedPolicyResolverOptions): (request: any) => Promise<Policy>;
180
+
133
181
  /**
134
182
  * In-process statistics collector for rate-limit observability.
135
183
  *
@@ -253,4 +301,4 @@ declare class OpenTelemetryMetrics implements TelemetryHooks {
253
301
  onViolation(_identifier: string, _penalty: Penalty, severity: number): void;
254
302
  }
255
303
 
256
- export { AtomicStore, Decision, EvaluateInput, type FailMode, type OTelCounterLike, type OTelMeterLike, OpenTelemetryMetrics, Penalty, Policy, Quota, RedisClientLike, RedisStore, type RedisStoreOptions, StatsCollector, type StatsCollectorOptions, type StatsSnapshot, TelemetryHooks, extractors, index as presets };
304
+ export { AtomicStore, type CachedPolicyResolverOptions, Decision, EvaluateInput, type FailMode, type OTelCounterLike, type OTelMeterLike, OpenTelemetryMetrics, Penalty, Policy, PolicyRegistry, Quota, RedisClientLike, RedisStore, type RedisStoreOptions, StatsCollector, type StatsCollectorOptions, type StatsSnapshot, TelemetryHooks, cachedPolicyResolver, extractors, index as presets };
package/dist/index.d.ts CHANGED
@@ -130,6 +130,54 @@ declare namespace extractors {
130
130
  export { extractors_HEALTH_CHECK_PATHS as HEALTH_CHECK_PATHS, extractors_extractApiKey as extractApiKey, extractors_extractIp as extractIp, extractors_extractPath as extractPath, extractors_extractUserId as extractUserId, extractors_isHealthCheck as isHealthCheck, extractors_isPrivateIp as isPrivateIp };
131
131
  }
132
132
 
133
+ /**
134
+ * Dynamic limits — change policies at runtime without restarting.
135
+ *
136
+ * `PolicyRegistry` holds named policies you can mutate live. Use it as the
137
+ * limiter's `policy` via `registry.resolver(selector)`:
138
+ *
139
+ * const registry = new PolicyRegistry([presets.PUBLIC_API]);
140
+ * const limiter = new RateLimiter({
141
+ * store,
142
+ * policy: registry.resolver((req) => 'public_api'),
143
+ * });
144
+ * // later, no restart:
145
+ * registry.update('public_api', { limit: 500 });
146
+ *
147
+ * For limits stored in Redis/DB/config (and shared across a fleet), use
148
+ * `cachedPolicyResolver` with a loader that reads that shared state.
149
+ */
150
+
151
+ declare class PolicyRegistry {
152
+ private policies;
153
+ constructor(initial?: Policy[]);
154
+ /** Add or replace a policy (keyed by `policy.name`). */
155
+ register(policy: Policy): this;
156
+ get(name: string): Policy | undefined;
157
+ has(name: string): boolean;
158
+ /** Mutate fields of an existing policy at runtime (e.g. `{ limit: 500 }`). */
159
+ update(name: string, patch: Partial<Omit<Policy, 'name'>>): Policy;
160
+ remove(name: string): boolean;
161
+ list(): Policy[];
162
+ /**
163
+ * Build a resolver for the limiter's `policy` option.
164
+ * @param selector maps a request to a registered policy name.
165
+ */
166
+ resolver(selector: (request: any) => string): (request: any) => Policy;
167
+ }
168
+ interface CachedPolicyResolverOptions {
169
+ /** Cache entry lifetime in ms (default 5000). */
170
+ ttlMs?: number;
171
+ /** Cache key for a request (default: the loaded policy is shared under one key). */
172
+ key?: (request: any) => string;
173
+ }
174
+ /**
175
+ * Wrap a (possibly async) loader with a per-key TTL cache. The loader reads your
176
+ * source of truth (Redis/DB/config), so limits propagate across a fleet and
177
+ * refresh live — without restarting. Use the returned function as `policy`.
178
+ */
179
+ declare function cachedPolicyResolver(loader: (request: any) => Policy | Promise<Policy>, options?: CachedPolicyResolverOptions): (request: any) => Promise<Policy>;
180
+
133
181
  /**
134
182
  * In-process statistics collector for rate-limit observability.
135
183
  *
@@ -253,4 +301,4 @@ declare class OpenTelemetryMetrics implements TelemetryHooks {
253
301
  onViolation(_identifier: string, _penalty: Penalty, severity: number): void;
254
302
  }
255
303
 
256
- export { AtomicStore, Decision, EvaluateInput, type FailMode, type OTelCounterLike, type OTelMeterLike, OpenTelemetryMetrics, Penalty, Policy, Quota, RedisClientLike, RedisStore, type RedisStoreOptions, StatsCollector, type StatsCollectorOptions, type StatsSnapshot, TelemetryHooks, extractors, index as presets };
304
+ export { AtomicStore, type CachedPolicyResolverOptions, Decision, EvaluateInput, type FailMode, type OTelCounterLike, type OTelMeterLike, OpenTelemetryMetrics, Penalty, Policy, PolicyRegistry, Quota, RedisClientLike, RedisStore, type RedisStoreOptions, StatsCollector, type StatsCollectorOptions, type StatsSnapshot, TelemetryHooks, cachedPolicyResolver, extractors, index as presets };
package/dist/index.js CHANGED
@@ -486,7 +486,8 @@ var RateLimiter = class {
486
486
  return decision2;
487
487
  }
488
488
  const store = this.store;
489
- let algorithm = this.algorithmCache.get(policy.name);
489
+ const cacheKey = `${policy.name}|${policy.algorithm}|${policy.limit}|${policy.window}|${policy.burst}`;
490
+ let algorithm = this.algorithmCache.get(cacheKey);
490
491
  if (!algorithm) {
491
492
  if (policy.algorithm === "token_bucket" /* TOKEN_BUCKET */) {
492
493
  algorithm = new TokenBucket(policy.burst, policy.limit, policy.window);
@@ -500,7 +501,7 @@ var RateLimiter = class {
500
501
  } else {
501
502
  throw new Error(`Algorithm ${policy.algorithm} not implemented`);
502
503
  }
503
- this.algorithmCache.set(policy.name, algorithm);
504
+ this.algorithmCache.set(cacheKey, algorithm);
504
505
  }
505
506
  const span = this.otelTracer?.startSpan?.("halt.check", { attributes: { policy: policy.name, key } });
506
507
  const state = store.get(storageKey);
@@ -1095,6 +1096,70 @@ function getPlanPolicy(planName) {
1095
1096
  return PLAN_TIERS[normalized];
1096
1097
  }
1097
1098
 
1099
+ // src/core/registry.ts
1100
+ var PolicyRegistry = class {
1101
+ constructor(initial = []) {
1102
+ this.policies = /* @__PURE__ */ new Map();
1103
+ for (const p of initial) this.register(p);
1104
+ }
1105
+ /** Add or replace a policy (keyed by `policy.name`). */
1106
+ register(policy) {
1107
+ this.policies.set(policy.name, policy);
1108
+ return this;
1109
+ }
1110
+ get(name) {
1111
+ return this.policies.get(name);
1112
+ }
1113
+ has(name) {
1114
+ return this.policies.has(name);
1115
+ }
1116
+ /** Mutate fields of an existing policy at runtime (e.g. `{ limit: 500 }`). */
1117
+ update(name, patch) {
1118
+ const existing = this.policies.get(name);
1119
+ if (!existing) throw new Error(`Unknown policy: ${name}`);
1120
+ const updated = { ...existing, ...patch, name: existing.name };
1121
+ if (patch.limit !== void 0 && patch.burst === void 0) {
1122
+ delete updated.burst;
1123
+ }
1124
+ this.policies.set(name, updated);
1125
+ return updated;
1126
+ }
1127
+ remove(name) {
1128
+ return this.policies.delete(name);
1129
+ }
1130
+ list() {
1131
+ return [...this.policies.values()];
1132
+ }
1133
+ /**
1134
+ * Build a resolver for the limiter's `policy` option.
1135
+ * @param selector maps a request to a registered policy name.
1136
+ */
1137
+ resolver(selector) {
1138
+ return (request) => {
1139
+ const name = selector(request);
1140
+ const policy = this.policies.get(name);
1141
+ if (!policy) throw new Error(`Unknown policy: ${name}`);
1142
+ return policy;
1143
+ };
1144
+ }
1145
+ };
1146
+ function cachedPolicyResolver(loader, options = {}) {
1147
+ const ttlMs = options.ttlMs ?? 5e3;
1148
+ const keyFn = options.key ?? (() => "__default__");
1149
+ const cache = /* @__PURE__ */ new Map();
1150
+ return async (request) => {
1151
+ const key = keyFn(request);
1152
+ const now = Date.now();
1153
+ const hit = cache.get(key);
1154
+ if (hit && hit.expires > now) {
1155
+ return hit.policy;
1156
+ }
1157
+ const policy = await loader(request);
1158
+ cache.set(key, { policy, expires: now + ttlMs });
1159
+ return policy;
1160
+ };
1161
+ }
1162
+
1098
1163
  // src/core/quota.ts
1099
1164
  var QuotaPeriod = /* @__PURE__ */ ((QuotaPeriod2) => {
1100
1165
  QuotaPeriod2["HOURLY"] = "hourly";
@@ -1624,6 +1689,7 @@ exports.PENALTY_LENIENT = PENALTY_LENIENT;
1624
1689
  exports.PENALTY_MODERATE = PENALTY_MODERATE;
1625
1690
  exports.PENALTY_STRICT = PENALTY_STRICT;
1626
1691
  exports.PenaltyManager = PenaltyManager;
1692
+ exports.PolicyRegistry = PolicyRegistry;
1627
1693
  exports.QUOTA_ENTERPRISE_MONTHLY = QUOTA_ENTERPRISE_MONTHLY;
1628
1694
  exports.QUOTA_FREE_DAILY = QUOTA_FREE_DAILY;
1629
1695
  exports.QUOTA_FREE_MONTHLY = QUOTA_FREE_MONTHLY;
@@ -1634,6 +1700,7 @@ exports.QuotaPeriod = QuotaPeriod;
1634
1700
  exports.RateLimiter = RateLimiter;
1635
1701
  exports.RedisStore = RedisStore;
1636
1702
  exports.StatsCollector = StatsCollector;
1703
+ exports.cachedPolicyResolver = cachedPolicyResolver;
1637
1704
  exports.extractors = extractors_exports;
1638
1705
  exports.isAtomicStore = isAtomicStore;
1639
1706
  exports.normalizePolicy = normalizePolicy;