Seed relay: nodes get their headers through the seed API
Test and deploy / test (push) Successful in 1m28s
Test and deploy / deploy (push) Successful in 2m5s

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>
This commit is contained in:
Daan MeijerandClaude Opus 5.5 committed 2026-10-05 15:24:39 +02:00
1 parent 3d5cfb2527
commit 41859636d3
13 files changed
+289 -33

No files matched your search

+23 -4
View File
@@ -1,6 +1,6 @@
import net from 'node:net'
import tls from 'node:tls'
import { unzipSync } from 'node:zlib'
import { deflateSync, unzipSync } from 'node:zlib'
export interface XoverClientOptions {
host: string
@@ -90,6 +90,20 @@ export class XoverClient {
return this.timed(() => this.overviews(from, to))
}
/**
* Overviews of `from`..`to` as one zlib/gzip stream, as the server sent it when it
* compresses (plain replies are deflated here): the seed API passes these to nodes
* without unpacking them (CONCEPT.md D38). `parseOverviews(unzipSync(raw).toString())`.
*/
async xoverRaw(from: number, to: number): Promise<Buffer> {
return this.timed(async () => {
const reply = await this.command(`XOVER ${from}-${to}`)
if (reply.startsWith('420') || reply.startsWith('423')) return deflateSync(Buffer.alloc(0))
await this.expect(reply, ['224'])
return reply.includes('COMPRESS=GZIP') ? (await this.compressedRaw()).raw : deflateSync(Buffer.from(await this.plainBody()))
})
}
/** A reply that stalls for timeoutMs fails the connection; idle time between commands doesn't count. */
private async timed<T>(exchange: () => Promise<T>): Promise<T> {
this.sock.setTimeout(this.timeoutMs)
@@ -179,15 +193,20 @@ export class XoverClient {
}
}
/** A zlib/gzip stream followed by a "." line: complete once it ends in ".\r\n" and inflates. */
private async compressedBody(): Promise<string> {
return (await this.compressedRaw()).text
}
/** A zlib/gzip stream followed by a "." line: complete once it ends in ".\r\n" and inflates. */
private async compressedRaw(): Promise<{ raw: Buffer; text: string }> {
while (true) {
const all = this.buffer()
if (all.length > DOT_LINE.length && all.subarray(all.length - DOT_LINE.length).equals(DOT_LINE)) {
const raw = all.subarray(0, all.length - DOT_LINE.length)
try {
const text = unzipSync(all.subarray(0, all.length - DOT_LINE.length)).toString('utf8')
const text = unzipSync(raw).toString('utf8')
this.take(all.length)
return text
return { raw: Buffer.from(raw), text }
} catch {
// ".\r\n" inside the compressed data: wait for more.
}
+8 -2
View File
@@ -1,10 +1,16 @@
import type { XoverClient, Overview } from '../nntp/xover_client.js'
import type { Overview } from '../nntp/xover_client.js'
import { segmentFromHeader, type IngestSegment, type OverviewHeader } from '../ingest/segment.js'
import { FileAggregator } from '../aggregate/index.js'
import { toFragment, toSeedFile, type SeedBatch } from './wire.js'
/** Where a range's overviews come from: an NNTP connection, or the seed API's relay. */
export interface OverviewSource {
group(name: string): Promise<unknown>
xover(from: number, to: number): Promise<Overview[]>
}
export interface RunRangeOptions {
client: XoverClient
client: OverviewSource
group: string
/** Inclusive article numbers. */
start: number