diff options
| author | t <t@tjp.lol> | 2026-07-07 11:26:32 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-07-07 11:26:45 -0600 |
| commit | f83578fdc9264019a1a1cef8c5484a161167d3dd (patch) | |
| tree | 888f11767f944d61e5ca8eb92fa1b2dba295a4b8 /src/sse.zig | |
initial commit, moved libpanto over from the pantograph repo
Diffstat (limited to 'src/sse.zig')
| -rw-r--r-- | src/sse.zig | 170 |
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]); +} |
