summaryrefslogtreecommitdiff
path: root/src/sse.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-07-07 11:26:32 -0600
committert <t@tjp.lol>2026-07-07 11:26:45 -0600
commitf83578fdc9264019a1a1cef8c5484a161167d3dd (patch)
tree888f11767f944d61e5ca8eb92fa1b2dba295a4b8 /src/sse.zig
initial commit, moved libpanto over from the pantograph repo
Diffstat (limited to 'src/sse.zig')
-rw-r--r--src/sse.zig170
1 files changed, 170 insertions, 0 deletions
diff --git a/src/sse.zig b/src/sse.zig
new file mode 100644
index 0000000..c54d532
--- /dev/null
+++ b/src/sse.zig
@@ -0,0 +1,170 @@
+const std = @import("std");
+
+/// A parsed SSE event payload: the concatenated `data:` content.
+/// Owned by the caller; free with `freeEvents`.
+const Event = []u8;
+
+/// Incremental SSE line parser.
+///
+/// The HTTP client delivers arbitrary-sized read buffers; this module
+/// reassembles them into complete `data: ...\n\n` events and returns
+/// owned event payload slices.
+pub const SSEParser = struct {
+ buf: std.ArrayList(u8) = .empty,
+ allocator: std.mem.Allocator,
+
+ pub fn init(allocator: std.mem.Allocator) SSEParser {
+ return .{ .allocator = allocator };
+ }
+
+ /// Feed a chunk of raw bytes. Returns a list of complete SSE events
+ /// found in the buffer (may be empty). The caller owns the returned
+ /// slice and every event within; free with `freeEvents`.
+ pub fn feed(self: *SSEParser, chunk: []const u8) ![]Event {
+ try self.buf.appendSlice(self.allocator, chunk);
+
+ var events: std.ArrayList(Event) = .empty;
+ errdefer {
+ for (events.items) |ev| self.allocator.free(ev);
+ events.deinit(self.allocator);
+ }
+
+ var scan_pos: usize = 0;
+ while (std.mem.indexOf(u8, self.buf.items[scan_pos..], "\n\n")) |rel_delim| {
+ const delim = scan_pos + rel_delim;
+ const event_bytes = self.buf.items[scan_pos..delim];
+
+ // Collect data: lines into an owned payload string.
+ var payload: std.ArrayList(u8) = .empty;
+ errdefer payload.deinit(self.allocator);
+
+ var line_start: usize = 0;
+ var has_data = false;
+ while (line_start < event_bytes.len) {
+ const newline = std.mem.indexOfScalarPos(u8, event_bytes, line_start, '\n') orelse event_bytes.len;
+ const line = event_bytes[line_start..newline];
+
+ if (std.mem.startsWith(u8, line, "data: ")) {
+ if (has_data) try payload.append(self.allocator, '\n');
+ try payload.appendSlice(self.allocator, line["data: ".len..]);
+ has_data = true;
+ }
+ // Ignore other SSE fields (event:, id:, retry:) and comments.
+
+ line_start = newline + 1;
+ }
+
+ if (has_data) {
+ try events.append(self.allocator, try payload.toOwnedSlice(self.allocator));
+ } else {
+ payload.deinit(self.allocator);
+ }
+
+ scan_pos = delim + 2;
+ }
+
+ // Drop processed bytes; retain the buffer allocation.
+ const remaining_len = self.buf.items.len - scan_pos;
+ if (remaining_len > 0 and scan_pos > 0) {
+ std.mem.copyForwards(u8, self.buf.items[0..remaining_len], self.buf.items[scan_pos..]);
+ }
+ self.buf.shrinkRetainingCapacity(remaining_len);
+
+ return events.toOwnedSlice(self.allocator);
+ }
+
+ /// Free a list of events returned by `feed`.
+ pub fn freeEvents(self: *SSEParser, events: []Event) void {
+ for (events) |ev| self.allocator.free(ev);
+ self.allocator.free(events);
+ }
+
+ pub fn deinit(self: *SSEParser) void {
+ self.buf.deinit(self.allocator);
+ }
+};
+
+// -----------------------------------------------------------------------------
+// Tests
+// -----------------------------------------------------------------------------
+
+const testing = std.testing;
+
+test "SSEParser - single complete event" {
+ var parser = SSEParser.init(testing.allocator);
+ defer parser.deinit();
+
+ const events = try parser.feed("data: hello\n\n");
+ defer parser.freeEvents(events);
+
+ try testing.expectEqual(@as(usize, 1), events.len);
+ try testing.expectEqualStrings("hello", events[0]);
+}
+
+test "SSEParser - partial then complete" {
+ var parser = SSEParser.init(testing.allocator);
+ defer parser.deinit();
+
+ const events1 = try parser.feed("data: hel");
+ defer parser.freeEvents(events1);
+ try testing.expectEqual(@as(usize, 0), events1.len);
+
+ const events2 = try parser.feed("lo\n\n");
+ defer parser.freeEvents(events2);
+ try testing.expectEqual(@as(usize, 1), events2.len);
+ try testing.expectEqualStrings("hello", events2[0]);
+}
+
+test "SSEParser - multiple events in single chunk" {
+ var parser = SSEParser.init(testing.allocator);
+ defer parser.deinit();
+
+ const events = try parser.feed("data: one\n\ndata: two\n\n");
+ defer parser.freeEvents(events);
+
+ try testing.expectEqual(@as(usize, 2), events.len);
+ try testing.expectEqualStrings("one", events[0]);
+ try testing.expectEqualStrings("two", events[1]);
+}
+
+test "SSEParser - data DONE signal" {
+ var parser = SSEParser.init(testing.allocator);
+ defer parser.deinit();
+
+ const events = try parser.feed("data: [DONE]\n\n");
+ defer parser.freeEvents(events);
+
+ try testing.expectEqual(@as(usize, 1), events.len);
+ try testing.expectEqualStrings("[DONE]", events[0]);
+}
+
+test "SSEParser - empty event (keep-alive)" {
+ var parser = SSEParser.init(testing.allocator);
+ defer parser.deinit();
+
+ const events = try parser.feed("\n\n");
+ defer parser.freeEvents(events);
+ try testing.expectEqual(@as(usize, 0), events.len);
+}
+
+test "SSEParser - ignores non-data fields" {
+ var parser = SSEParser.init(testing.allocator);
+ defer parser.deinit();
+
+ const events = try parser.feed("event: message\ndata: payload\nid: 42\n\n");
+ defer parser.freeEvents(events);
+
+ try testing.expectEqual(@as(usize, 1), events.len);
+ try testing.expectEqualStrings("payload", events[0]);
+}
+
+test "SSEParser - multi-line data joined with newline" {
+ var parser = SSEParser.init(testing.allocator);
+ defer parser.deinit();
+
+ const events = try parser.feed("data: line1\ndata: line2\n\n");
+ defer parser.freeEvents(events);
+
+ try testing.expectEqual(@as(usize, 1), events.len);
+ try testing.expectEqualStrings("line1\nline2", events[0]);
+}