Seed relay: nodes get their headers through the seed API
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:
1 parent
3d5cfb2527
commit
41859636d3
13 files changed
+289
-33
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user