- Zig 99.1%
- Nix 0.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
The enqueue* methods may now be called from any thread while next() runs on the consumer thread: the pending queue is guarded by a std.Io.Mutex, so initDynamic/pipelineDynamic take an Io. next() and deinit remain single-consumer by contract (NNTP responses are strictly ordered on one connection), and static pipelines never touch the mutex, staying allocation- and lock-free. Adds a stress test: four producer threads enqueueing numbers and message-ids concurrently with the consuming next() loop. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_013VcyQiGUcfHVsK2mpKvBdR |
||
| LICENSES | ||
| src | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
| REUSE.toml | ||
znntp
An NNTP client library for Zig (0.16).
Supported standards
| Standard | Title | Support in znntp |
|---|---|---|
| RFC 3977 | Network News Transfer Protocol (NNTP) | Core protocol: session administration, group and article selection, retrieval, posting, LIST, OVER/HDR, and pipelining |
| RFC 4643 | NNTP Extension for Authentication | AUTHINFO USER/PASS |
| RFC 8054 | NNTP Extension for Compression | COMPRESS DEFLATE, both directions; interoperates with sync-flush and partial-flush servers |
| RFC 2980 | Common NNTP Extensions | XOVER, for servers that predate RFC 3977 |
| RFC 8143 | Using TLS with NNTP | Implicit TLS (NNTPS on port 563) with system-root certificate verification, as the RFC recommends |
| RFC 1951 | DEFLATE Compressed Data Format | Compression via std.compress.flate; decompression via a custom resumable inflate suited to interactive streams |
Design
znntp.Clientimplements the protocol over anystd.Io.Reader/std.Io.Writerpair. Transport is the caller's business — plain TCP, TLS, a proxy, or fixed buffers in tests.znntp.Connectionis the batteries-included transport: TCP or TLS (NNTPS, port 563) with system-root certificate verification, built onstd.Io.- Zero-copy reads: response messages and multiline data lines borrow
from the reader's buffer and are invalidated by the next read. Copy what
you keep, or use the
*Allocvariants. - Dot-stuffing/unstuffing and CRLF handling are dealt with in both directions; command arguments are checked against CRLF injection.
Usage
const std = @import("std");
const znntp = @import("znntp");
pub fn main() !void {
var gpa_state: std.heap.DebugAllocator(.{}) = .init;
defer _ = gpa_state.deinit();
const gpa = gpa_state.allocator();
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
const conn = try znntp.Connection.connect(gpa, io, "news.example.com", .{
.tls = .on, // NNTPS on port 563; use .off for plain NNTP on 119
});
defer conn.destroy();
var client = conn.client();
const greeting = try client.readGreeting();
std.debug.print("{s}\n", .{greeting.message});
// try client.authenticate("user", "password");
// Compress the rest of the session (COMPRESS DEFLATE, RFC 8054).
// Works with both sync-flush and partial-flush servers (INN included):
// the read side uses a resumable inflate that never blocks while
// decodable data is pending.
// try conn.enableDeflate(&client);
const info = try client.group("misc.test");
// Overview of the last few articles (lines borrow the read buffer).
var it = try client.over(.{ .span = .{ .low = info.low, .high = info.high } });
while (try it.next()) |line| {
const entry = try znntp.OverviewEntry.parse(line);
std.debug.print("{d} {s}\n", .{ entry.number, entry.subject });
}
// Fetch an article into owned memory (capped at 1 MiB).
var art = try client.articleAlloc(gpa, .{ .number = info.high }, .limited(1024 * 1024));
defer art.deinit(gpa);
std.debug.print("{s}\n", .{art.data});
try client.quit();
}
Pipelined bulk fetches
For downloading many articles, Pipeline keeps a bounded window of
commands in flight (RFC 3977 pipelining) so the server overlaps its
responses with your requests — without the unbounded-burst deadlock:
var pipe = client.pipeline(.body, .{ .range = .{ .low = info.low, .high = info.high } }, .{ .window = 16 });
while (try pipe.next()) |item| {
switch (item.result) {
.info => |art| {
var data = item.data.?;
while (try data.next()) |line| { ... }
},
.missing => |code| { ... }, // article expired or cancelled; run continues
}
}
Sources can be a number range, a slice of numbers, or a slice of
message-ids; verbs are .article, .head, .body, and .stat.
Unconsumed article data is discarded automatically on the next
iteration. Don't interleave other commands while a pipeline is running.
A pipeline can also start empty and be fed while it runs — enqueue new work between iterations as you discover it (following References headers, walking an NZB, ...), from any thread. Message-ids are copied, so it's fine to pass slices parsed out of the borrowed read buffer:
var pipe = client.pipelineDynamic(gpa, io, .article, .{ .window = 16 });
defer pipe.deinit();
try pipe.enqueueMessageId(root_id);
while (try pipe.next()) |item| {
// ...parse the article, discover more...
try pipe.enqueueMessageId(referenced_id);
try pipe.enqueueRange(low, high);
}
// null means idle, not finished: enqueue more and call next() again.
Commands not wrapped by a helper can be sent through the escape hatch:
const r = try client.command("XFEATURE COMPRESS GZIP", .{});
if (!r.isSuccess()) return error.Unsupported;
Multiline responses must be consumed fully (iterate to null, or call
discard()) before sending the next command.
Demo CLI
zig build run -- news.example.com misc.test --tls --compress
Prints the greeting, capabilities, and the last ten overview lines of the group.
Testing
zig build test
Protocol tests run against in-memory streams; no network access needed.