milestone 8: web server, rest api, sse, auth, metrics and static assets
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
//! `GET /api/queries/live` — the query log as it happens (ruling 20).
|
||||
//!
|
||||
//! Server-sent events over chunked transfer. The response buffer is EMPTY on
|
||||
//! purpose: `BodyWriter.flush` pushes only the protocol writer, never the body
|
||||
//! writer's own buffer (http.zig:780), so with a zero-length buffer every
|
||||
//! write lands in the chunked drain and one `flush` puts the frame on the
|
||||
//! wire. `retry: 3000` goes out first so a dropped stream reconnects on the
|
||||
//! browser's side without configuration.
|
||||
//!
|
||||
//! The subscriber owns one hub slot and drains it between waits. A ring
|
||||
//! overflow means this client is too slow for the query rate; the stream ends
|
||||
//! cleanly and the reconnecting client re-syncs through `/api/queries` —
|
||||
//! dropping the client beats holding queries back (PLAN §11.4). The `: ping`
|
||||
//! heartbeat every 15 s keeps middleboxes from reaping an idle connection.
|
||||
//!
|
||||
//! The route is rate-limit exempt (a long-lived stream must not drain its
|
||||
//! address's token bucket) but pays the per-address SSE connection cap, which
|
||||
//! binds loopback too: hub slots are a fixed resource.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const address = @import("../../platform/address.zig");
|
||||
const http_util = @import("../http_util.zig");
|
||||
const queries_repo = @import("../../storage/repositories/queries_repo.zig");
|
||||
const server = @import("../server.zig");
|
||||
const sse = @import("../sse.zig");
|
||||
|
||||
pub const retry_preamble = "retry: 3000\n\n";
|
||||
pub const heartbeat = ": ping\n\n";
|
||||
|
||||
/// Ruling 20's heartbeat cadence. Awake clock: a suspended box owes no pings.
|
||||
pub const heartbeat_interval: std.Io.Clock.Duration = .{
|
||||
.raw = .fromSeconds(15),
|
||||
.clock = .awake,
|
||||
};
|
||||
|
||||
/// One event's `data:` payload — the `/api/queries` row fields (ruling 20),
|
||||
/// minus `id`: a live entry precedes persistence, so no row id exists yet.
|
||||
pub const EventView = struct {
|
||||
ts: i64,
|
||||
domain: []const u8,
|
||||
client_ip: []const u8,
|
||||
qtype: ?u16,
|
||||
blocked: bool,
|
||||
block_reason: []const u8,
|
||||
response_time_us: ?i64,
|
||||
cache_hit: ?bool,
|
||||
upstream: []const u8,
|
||||
};
|
||||
|
||||
pub fn view(entry: *const sse.Entry) EventView {
|
||||
return .{
|
||||
.ts = entry.timestamp,
|
||||
.domain = entry.domain(),
|
||||
.client_ip = entry.clientIp(),
|
||||
.qtype = entry.qtype,
|
||||
.blocked = entry.blocked,
|
||||
.block_reason = entry.blockReason(),
|
||||
.response_time_us = entry.response_time_us,
|
||||
.cache_hit = entry.cache_hit,
|
||||
.upstream = entry.upstream(),
|
||||
};
|
||||
}
|
||||
|
||||
/// One `event: query` frame. JSON never contains a raw newline, so the whole
|
||||
/// payload is a single `data:` line.
|
||||
pub fn writeEvent(w: *std.Io.Writer, entry: *const sse.Entry) std.Io.Writer.Error!void {
|
||||
try w.writeAll("event: query\ndata: ");
|
||||
var stringify: std.json.Stringify = .{ .writer = w };
|
||||
try stringify.write(view(entry));
|
||||
try w.writeAll("\n\n");
|
||||
}
|
||||
|
||||
pub fn stream(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
request: *http_util.Request,
|
||||
) http_util.HandlerError!void {
|
||||
const hub = state.hub orelse
|
||||
return http_util.respondError(request, .service_unavailable, "live stream unavailable");
|
||||
|
||||
const peer = address.NetAddress.fromIp(request.peer);
|
||||
if (state.limiter) |limiter| {
|
||||
if (!limiter.tryAcquireSse(io, std.Io.Clock.awake.now(io), peer))
|
||||
return http_util.respondError(request, .too_many_requests, "too many live streams from this address");
|
||||
}
|
||||
defer if (state.limiter) |limiter| limiter.releaseSse(io, peer);
|
||||
|
||||
const id = hub.subscribe(io) orelse
|
||||
return http_util.respondError(request, .service_unavailable, "live stream is full");
|
||||
defer hub.unsubscribe(io, id);
|
||||
|
||||
var response = try request.http.respondStreaming(&.{}, .{
|
||||
.respond_options = .{
|
||||
.extra_headers = &.{
|
||||
.{ .name = "content-type", .value = "text/event-stream" },
|
||||
.{ .name = "cache-control", .value = "no-store" },
|
||||
},
|
||||
},
|
||||
});
|
||||
const w = &response.writer;
|
||||
try w.writeAll(retry_preamble);
|
||||
// The browser acts on the headers, not the first event; send them now.
|
||||
try response.flush();
|
||||
|
||||
while (true) {
|
||||
while (hub.next(io, id)) |entry| try writeEvent(w, &entry);
|
||||
try response.flush();
|
||||
|
||||
// Checked after the drain: entries that predate the overflow still
|
||||
// reach the client before the stream ends.
|
||||
if (hub.overflowed(io, id)) break;
|
||||
|
||||
const wake = hub.wait(io, id, heartbeat_interval) catch return;
|
||||
if (wake == .timeout) {
|
||||
try w.writeAll(heartbeat);
|
||||
try response.flush();
|
||||
}
|
||||
}
|
||||
|
||||
try response.end();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "the event payload carries the /api/queries row fields, minus id" {
|
||||
const row_fields = @typeInfo(queries_repo.QueryRow).@"struct".fields;
|
||||
const view_fields = @typeInfo(EventView).@"struct".fields;
|
||||
comptime {
|
||||
std.debug.assert(view_fields.len == row_fields.len - 1);
|
||||
std.debug.assert(std.mem.eql(u8, row_fields[0].name, "id"));
|
||||
for (row_fields[1..], view_fields) |row_field, view_field| {
|
||||
std.debug.assert(std.mem.eql(u8, row_field.name, view_field.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "a frame is one event line and one data line of JSON" {
|
||||
const entry: sse.Entry = .init(.{
|
||||
.timestamp = 1_700_000_000,
|
||||
.domain = "ads.example",
|
||||
.client_ip = "192.0.2.10",
|
||||
.qtype = 1,
|
||||
.blocked = true,
|
||||
.block_reason = "blocklist_domain",
|
||||
.response_time_us = 42,
|
||||
.cache_hit = false,
|
||||
.upstream = "https://dns.example/dns-query",
|
||||
});
|
||||
|
||||
var buf: [1024]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&buf);
|
||||
try writeEvent(&writer, &entry);
|
||||
const frame = writer.buffered();
|
||||
|
||||
try testing.expect(std.mem.startsWith(u8, frame, "event: query\ndata: {"));
|
||||
try testing.expect(std.mem.endsWith(u8, frame, "}\n\n"));
|
||||
try testing.expectEqual(@as(usize, 3), std.mem.count(u8, frame, "\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"ts\":1700000000"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"domain\":\"ads.example\""));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"blocked\":true"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"block_reason\":\"blocklist_domain\""));
|
||||
}
|
||||
|
||||
test "an unlogged field stays null and an empty string stays a string" {
|
||||
const entry: sse.Entry = .init(.{
|
||||
.timestamp = 1,
|
||||
.domain = "safe.example",
|
||||
.client_ip = "192.0.2.11",
|
||||
});
|
||||
|
||||
var buf: [1024]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&buf);
|
||||
try writeEvent(&writer, &entry);
|
||||
const frame = writer.buffered();
|
||||
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"qtype\":null"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"cache_hit\":null"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"upstream\":\"\""));
|
||||
}
|
||||
Reference in New Issue
Block a user