The seed API keeps a pool of NNTP connections (SEED_NNTP_CONNECTIONS) and serves GET /api/v1/seed/ranges/:id/chunk?from=&to= for leased ranges: the gzip stream the news server sent, passed on unopened. seed:node fetches that way by default (SEED_FETCH=relay), so a node needs only a token and SEED_CONNECTIONS; SEED_FETCH=direct keeps its own NNTP connections. All fetching then comes from the indexer's IP (the account allows two). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
92 lines
2.4 KiB
TypeScript
92 lines
2.4 KiB
TypeScript
import nntpConfig from '#config/nntp'
|
|
import indexerConfig from '#config/indexer'
|
|
import { XoverClient } from '#lib/nntp/xover_client'
|
|
|
|
interface Slot {
|
|
client: XoverClient
|
|
group?: string
|
|
usedAt: number
|
|
}
|
|
|
|
/** A pooled connection unused this long is replaced: the news server may have dropped it. */
|
|
const IDLE_MS = 2 * 60 * 1000
|
|
|
|
/**
|
|
* NNTP connections in the seed API for relay nodes (CONCEPT.md D38): the
|
|
* indexer fetches overview chunks and passes the gzip stream on unopened, so
|
|
* nodes need no news server account and every connection comes from this
|
|
* machine's IP. SEED_NNTP_CONNECTIONS (0: relay off).
|
|
*/
|
|
class SeedFetchPool {
|
|
private idle: Slot[] = []
|
|
private open = 0
|
|
private waiters: (() => void)[] = []
|
|
|
|
get size() {
|
|
return indexerConfig.seed.nntpConnections
|
|
}
|
|
|
|
async fetch(group: string, from: number, to: number): Promise<Buffer> {
|
|
const slot = await this.acquire()
|
|
let ok = false
|
|
try {
|
|
if (slot.group !== group) {
|
|
await slot.client.group(group)
|
|
slot.group = group
|
|
}
|
|
const raw = await slot.client.xoverRaw(from, to)
|
|
ok = true
|
|
return raw
|
|
} finally {
|
|
this.release(slot, ok)
|
|
}
|
|
}
|
|
|
|
private async acquire(): Promise<Slot> {
|
|
while (true) {
|
|
const slot = this.idle.pop()
|
|
if (slot) {
|
|
if (Date.now() - slot.usedAt < IDLE_MS) return slot
|
|
slot.client.close()
|
|
this.open--
|
|
continue
|
|
}
|
|
if (this.open < this.size) {
|
|
this.open++
|
|
try {
|
|
const client = await XoverClient.connect({
|
|
host: nntpConfig.host,
|
|
port: nntpConfig.port,
|
|
secure: nntpConfig.secure,
|
|
servername: nntpConfig.tlsServername,
|
|
user: nntpConfig.user,
|
|
password: nntpConfig.password?.release(),
|
|
timeoutMs: nntpConfig.commandTimeoutMs,
|
|
compress: true,
|
|
})
|
|
return { client, usedAt: Date.now() }
|
|
} catch (error) {
|
|
this.open--
|
|
this.waiters.shift()?.()
|
|
throw error
|
|
}
|
|
}
|
|
await new Promise<void>((resolve) => this.waiters.push(resolve))
|
|
}
|
|
}
|
|
|
|
private release(slot: Slot, ok: boolean) {
|
|
if (ok) {
|
|
slot.usedAt = Date.now()
|
|
this.idle.push(slot)
|
|
} else {
|
|
// A failed command can leave the connection mid-reply: start afresh.
|
|
slot.client.close()
|
|
this.open--
|
|
}
|
|
this.waiters.shift()?.()
|
|
}
|
|
}
|
|
|
|
export default new SeedFetchPool()
|