diff --git a/packages/opal/package.json b/packages/opal/package.json index 58ff6ac..1aa20fd 100644 --- a/packages/opal/package.json +++ b/packages/opal/package.json @@ -1,6 +1,6 @@ { "name": "@ewanc26/opal", - "version": "0.2.0", + "version": "0.2.1", "description": "Convert microblog posts from Twitter, Mastodon, Threads, and Nostr to AT Protocol Bluesky posts", "author": "Ewan Croft", "license": "AGPL-3.0-only", diff --git a/packages/opal/src/index.ts b/packages/opal/src/index.ts index 02697ee..b9d6aae 100644 --- a/packages/opal/src/index.ts +++ b/packages/opal/src/index.ts @@ -14,7 +14,7 @@ export { convertNostr } from './nostr.js'; // Publishing export { publishRecords } from './publisher.js'; -export { RateLimiter } from './rate-limiter.js'; +export { RateLimiter } from '@ewanc26/croft-click-core'; // Utilities export { @@ -61,4 +61,4 @@ export type { export type { RateLimitHeaders, -} from './rate-limit-headers.js'; +} from '@ewanc26/croft-click-core'; diff --git a/packages/opal/src/rate-limit-headers.ts b/packages/opal/src/rate-limit-headers.ts deleted file mode 100644 index a8b5f34..0000000 --- a/packages/opal/src/rate-limit-headers.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Rate-limit header parsing — environment-agnostic. - * No logger dependency; callers surface messages as they see fit. - */ - -export interface RateLimitHeaders { - limit?: number; - remaining?: number; - reset?: number; - windowSeconds?: number; -} - -export function normalizeHeaders(headers: Record): Record { - const out: Record = {}; - for (const [k, v] of Object.entries(headers)) out[k.toLowerCase()] = v; - return out; -} - -export function parseRateLimitHeaders(headers: Record): RateLimitHeaders { - const h = normalizeHeaders(headers); - const get = (k: string) => h[k] ?? h[`x-${k}`] ?? ''; - - const limit = parseInt(get('ratelimit-limit'), 10); - const remaining = parseInt(get('ratelimit-remaining'), 10); - const reset = parseInt(get('ratelimit-reset'), 10); - const policy = get('ratelimit-policy'); - - let windowSeconds: number | undefined; - const m = /;w=(\d+)/.exec(policy); - if (m) windowSeconds = parseInt(m[1], 10); - else if (!isNaN(reset)) { - windowSeconds = Math.max(0, reset - Math.floor(Date.now() / 1000)); - } - - return { - limit: isNaN(limit) ? undefined : limit, - remaining: isNaN(remaining) ? undefined : remaining, - reset: isNaN(reset) ? undefined : reset, - windowSeconds, - }; -} - -export function isRateLimitError(err: unknown): boolean { - const e = err as any; - if (e?.status === 429) return true; - const msg = (e?.message ?? '').toLowerCase(); - return msg.includes('rate limit') || msg.includes('too many requests') || msg.includes('ratelimit'); -} diff --git a/packages/opal/src/rate-limiter.ts b/packages/opal/src/rate-limiter.ts deleted file mode 100644 index 75da471..0000000 --- a/packages/opal/src/rate-limiter.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * In-memory rate limiter — environment-agnostic. - * Tracks quota from response headers and gates requests to stay within limits. - */ - -import { normalizeHeaders } from './rate-limit-headers.js'; - -interface State { - limit: number; - remaining: number; - resetAt: number; // unix seconds - windowSeconds: number; -} - -export class RateLimiter { - private state: State | null = null; - private readonly headroom: number; - - constructor(opts?: { headroom?: number }) { - this.headroom = opts?.headroom ?? 0.15; - } - - updateFromHeaders(headers: Record): void { - const h = normalizeHeaders(headers); - const get = (k: string) => h[k] ?? h[`x-${k}`] ?? ''; - - const limit = parseInt(get('ratelimit-limit'), 10); - const remaining = parseInt(get('ratelimit-remaining'), 10); - const reset = parseInt(get('ratelimit-reset'), 10); - const policy = get('ratelimit-policy'); - - if (!limit || isNaN(limit) || isNaN(remaining)) return; - - let windowSeconds = 3600; - const m = /;w=(\d+)/.exec(policy); - if (m) windowSeconds = parseInt(m[1], 10); - - const now = Math.floor(Date.now() / 1000); - this.state = { - limit, - remaining, - resetAt: isNaN(reset) ? now + windowSeconds : reset, - windowSeconds, - }; - } - - getActualRemaining(): number { - if (!this.state) return 0; - if (Math.floor(Date.now() / 1000) >= this.state.resetAt) return this.state.limit; - return this.state.remaining; - } - - getServerCapacity(): { limit: number; windowSeconds: number } | null { - if (!this.state || this.state.limit === 0) return null; - return { limit: this.state.limit, windowSeconds: this.state.windowSeconds }; - } - - hasServerInfo(): boolean { - return this.state !== null && this.state.limit > 0; - } - - handleRateLimitHit(errHeaders?: Record): void { - if (errHeaders && Object.keys(errHeaders).length > 0) { - this.updateFromHeaders(errHeaders); - } - const now = Math.floor(Date.now() / 1000); - if (this.state) { - this.state.remaining = 0; - } else { - this.state = { limit: 5000, remaining: 0, resetAt: now + 60, windowSeconds: 3600 }; - } - } - - async waitForPermit(pointsNeeded: number, isCancelled?: () => boolean): Promise { - if (!this.state) return; - - const now = Math.floor(Date.now() / 1000); - if (now >= this.state.resetAt) { - this.state.remaining = this.state.limit; - this.state.resetAt = now + this.state.windowSeconds; - } - - const headroomPts = Math.floor(this.state.limit * this.headroom); - const effective = this.state.remaining - headroomPts; - - if (effective < pointsNeeded) { - const resetMs = Math.max(0, (this.state.resetAt - Math.floor(Date.now() / 1000)) + 1) * 1000; - const end = Date.now() + resetMs; - await new Promise((resolve) => { - const tick = () => { - if (isCancelled?.() || Date.now() >= end) { resolve(); return; } - setTimeout(tick, Math.min(50, end - Date.now())); - }; - tick(); - }); - if (this.state && !isCancelled?.()) { - this.state.remaining = this.state.limit; - this.state.resetAt = Math.floor(Date.now() / 1000) + this.state.windowSeconds; - } - } - - if (this.state) this.state.remaining = Math.max(0, this.state.remaining - pointsNeeded); - } -}