My own corner of monopam
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add ocaml-sse: Server-Sent Events parser and serializer

Pure OCaml SSE implementation per WHATWG HTML Living Standard §9.2.
Incremental parser with bytesrw integration, serializer for server-side
streams. 67 tests covering spec examples, edge cases (BOM, CRLF split,
NULL in id, mixed line endings), incremental chunked parsing, round-trips.

+1097
+1
ocaml-sse/.ocamlformat
··· 1 + version = 0.28.1
+21
ocaml-sse/LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2026 Thomas Gazagnaire 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+90
ocaml-sse/README.md
··· 1 + # ocaml-sse 2 + 3 + Pure OCaml [Server-Sent Events](https://html.spec.whatwg.org/multipage/server-sent-events.html) (SSE) parser and serializer. 4 + 5 + ## Features 6 + 7 + - **Spec-compliant** parser per WHATWG HTML Living Standard §9.2 8 + - **Incremental**: feed chunks of any size, get events out — handles partial lines across chunks 9 + - **All line endings**: LF, CR, CRLF, and mixed 10 + - **Full field support**: `data` (multi-line), `event`, `id` (with NULL rejection), `retry`, comments 11 + - **BOM stripping** on first chunk 12 + - **Serializer** for server-side event streams 13 + - **bytesrw integration**: `Bytes.Reader.t` / `Bytes.Writer.t` for streaming I/O 14 + - **67 tests** covering spec examples, edge cases, incremental parsing, round-trips 15 + 16 + ## Install 17 + 18 + ``` 19 + opam install sse 20 + ``` 21 + 22 + ## Usage 23 + 24 + ### Client (parsing) 25 + 26 + ```ocaml 27 + let parser = Sse.Parser.create () in 28 + let events = Sse.Parser.feed parser "data:hello\n\ndata:world\n\n" in 29 + List.iter (fun e -> print_endline e.Sse.data) events 30 + (* hello *) 31 + (* world *) 32 + ``` 33 + 34 + Incremental parsing (chunked HTTP responses, streaming): 35 + 36 + ```ocaml 37 + let parser = Sse.Parser.create () in 38 + let _ = Sse.Parser.feed parser "data:hel" in (* partial — no events yet *) 39 + let events = Sse.Parser.feed parser "lo\n\n" in (* completed — dispatches *) 40 + (* events = [{ data = "hello"; ... }] *) 41 + ``` 42 + 43 + With bytesrw: 44 + 45 + ```ocaml 46 + let parser = Sse.Parser.create () in 47 + let reader = Bytes.Reader.of_string "data:hello\n\n" in 48 + let events = Sse.Parser.read parser reader in 49 + ``` 50 + 51 + Reconnection (Last-Event-ID): 52 + 53 + ```ocaml 54 + (* After disconnect, create new parser with last known ID *) 55 + let parser = Sse.Parser.create ~last_event_id:"42" () in 56 + (* Send Last-Event-ID: 42 header when reconnecting *) 57 + ``` 58 + 59 + ### Server (serializing) 60 + 61 + ```ocaml 62 + Sse.Serializer.event "hello" 63 + (* "data:hello\n\n" *) 64 + 65 + Sse.Serializer.event ~event:"update" ~id:"42" "payload" 66 + (* "event:update\ndata:payload\nid:42\n\n" *) 67 + 68 + Sse.Serializer.event "line1\nline2" 69 + (* "data:line1\ndata:line2\n\n" *) 70 + 71 + Sse.Serializer.comment "keepalive" 72 + (* ":keepalive\n" *) 73 + ``` 74 + 75 + HTTP headers for SSE responses: 76 + 77 + ```ocaml 78 + let headers = Sse.Serializer.headers 79 + (* [("Content-Type", "text/event-stream"); 80 + ("Cache-Control", "no-cache"); 81 + ("Connection", "keep-alive")] *) 82 + ``` 83 + 84 + ## Spec 85 + 86 + Implements the event stream format from the [WHATWG HTML Living Standard §9.2](https://html.spec.whatwg.org/multipage/server-sent-events.html). 87 + 88 + ## License 89 + 90 + ISC
+28
ocaml-sse/dune-project
··· 1 + (lang dune 3.21) 2 + 3 + (name sse) 4 + 5 + (generate_opam_files true) 6 + 7 + (source (tangled gazagnaire.org/ocaml-sse)) 8 + 9 + (authors "Thomas Gazagnaire") 10 + 11 + (maintainers "thomas@gazagnaire.org") 12 + 13 + (license ISC) 14 + 15 + (package 16 + (name sse) 17 + (synopsis "Server-Sent Events (SSE) parser and serializer") 18 + (description 19 + "Pure OCaml implementation of the Server-Sent Events protocol as defined 20 + in the WHATWG HTML Living Standard. Provides event stream parsing (client) 21 + and serialization (server) with support for multi-line data, event types, 22 + last-event-id tracking, retry intervals, and comments. No I/O dependencies 23 + in the core — bring your own transport.") 24 + (depends 25 + (ocaml (>= 5.0.0)) 26 + dune 27 + (bytesrw (>= 0.1)) 28 + (alcotest :with-test)))
+4
ocaml-sse/lib/dune
··· 1 + (library 2 + (name sse) 3 + (public_name sse) 4 + (libraries bytesrw))
+263
ocaml-sse/lib/sse.ml
··· 1 + (** Server-Sent Events (SSE) parser and serializer. 2 + 3 + See {!Sse} for usage documentation and spec references. *) 4 + 5 + open Bytesrw 6 + 7 + type event = { 8 + data : string; 9 + event : string; 10 + last_event_id : string; 11 + retry : int option; 12 + } 13 + 14 + let pp_event ppf e = 15 + Format.fprintf ppf "@[<v>event: %S@ data: %S@ id: %S@ retry: %a@]" e.event 16 + e.data e.last_event_id 17 + (Format.pp_print_option Format.pp_print_int) 18 + e.retry 19 + 20 + let equal_event a b = 21 + String.equal a.data b.data 22 + && String.equal a.event b.event 23 + && String.equal a.last_event_id b.last_event_id 24 + && Option.equal Int.equal a.retry b.retry 25 + 26 + (* The BOM is U+FEFF encoded as UTF-8: 0xEF 0xBB 0xBF *) 27 + let utf8_bom = "\xEF\xBB\xBF" 28 + 29 + let strip_bom s = 30 + if String.length s >= 3 && String.sub s 0 3 = utf8_bom then 31 + String.sub s 3 (String.length s - 3) 32 + else s 33 + 34 + let is_all_digits s = 35 + String.length s > 0 && String.for_all (fun c -> c >= '0' && c <= '9') s 36 + 37 + let contains_null s = String.exists (fun c -> c = '\x00') s 38 + 39 + module Parser = struct 40 + type t = { 41 + mutable data_buf : Buffer.t; 42 + mutable event_type : string; 43 + mutable last_event_id : string; 44 + mutable retry : int option; 45 + mutable partial_line : string; 46 + mutable first_chunk : bool; 47 + mutable cr_pending : bool; 48 + } 49 + 50 + let create ?(last_event_id = "") () = 51 + { 52 + data_buf = Buffer.create 256; 53 + event_type = ""; 54 + last_event_id; 55 + retry = None; 56 + partial_line = ""; 57 + first_chunk = true; 58 + cr_pending = false; 59 + } 60 + 61 + let last_event_id t = t.last_event_id 62 + let retry t = t.retry 63 + 64 + (* Process a single field per the spec. *) 65 + let process_field t name value = 66 + match name with 67 + | "data" -> 68 + Buffer.add_string t.data_buf value; 69 + Buffer.add_char t.data_buf '\n' 70 + | "event" -> t.event_type <- value 71 + | "id" -> if not (contains_null value) then t.last_event_id <- value 72 + | "retry" -> 73 + if is_all_digits value then t.retry <- Some (int_of_string value) 74 + | _ -> (* Unknown fields are ignored per spec *) () 75 + 76 + (* Parse a single line per the spec. *) 77 + let process_line t line = 78 + if String.length line = 0 then 79 + (* Empty line: ignored here, dispatch in 80 + caller *) 81 + () 82 + else if line.[0] = ':' then (* Comment: ignore *) 83 + () 84 + else 85 + match String.index_opt line ':' with 86 + | Some colon_pos -> 87 + let name = String.sub line 0 colon_pos in 88 + let value_start = colon_pos + 1 in 89 + (* Strip one leading space from value if present *) 90 + let value_start = 91 + if value_start < String.length line && line.[value_start] = ' ' then 92 + value_start + 1 93 + else value_start 94 + in 95 + let value = 96 + String.sub line value_start (String.length line - value_start) 97 + in 98 + process_field t name value 99 + | None -> 100 + (* No colon: field name is whole line, value is empty *) 101 + process_field t line "" 102 + 103 + (* Try to dispatch an event. Returns Some event if data buffer is non-empty. *) 104 + let try_dispatch t = 105 + if Buffer.length t.data_buf = 0 then ( 106 + t.event_type <- ""; 107 + None) 108 + else 109 + (* Remove trailing LF from data buffer *) 110 + let raw = Buffer.contents t.data_buf in 111 + let data = 112 + if String.length raw > 0 && raw.[String.length raw - 1] = '\n' then 113 + String.sub raw 0 (String.length raw - 1) 114 + else raw 115 + in 116 + let event = 117 + { 118 + data; 119 + event = t.event_type; 120 + last_event_id = t.last_event_id; 121 + retry = t.retry; 122 + } 123 + in 124 + Buffer.clear t.data_buf; 125 + t.event_type <- ""; 126 + Some event 127 + 128 + let feed t chunk = 129 + let chunk = 130 + if t.first_chunk then ( 131 + t.first_chunk <- false; 132 + strip_bom chunk) 133 + else chunk 134 + in 135 + let events = ref [] in 136 + let len = String.length chunk in 137 + let line_start = ref 0 in 138 + let i = ref 0 in 139 + (* If we had a CR pending from previous chunk and this starts with LF, 140 + skip it (it was a CRLF split across chunks). *) 141 + if t.cr_pending && len > 0 && chunk.[0] = '\n' then ( 142 + t.cr_pending <- false; 143 + i := 1; 144 + line_start := 1); 145 + t.cr_pending <- false; 146 + while !i < len do 147 + let c = chunk.[!i] in 148 + if c = '\n' || c = '\r' then ( 149 + let line_part = String.sub chunk !line_start (!i - !line_start) in 150 + let full_line = t.partial_line ^ line_part in 151 + t.partial_line <- ""; 152 + (* Handle CRLF: if CR, peek at next char *) 153 + if c = '\r' then 154 + if !i + 1 < len then ( 155 + if chunk.[!i + 1] = '\n' then incr i (* Skip the LF in CRLF *)) 156 + else (* CR at end of chunk — LF might be in next chunk *) 157 + t.cr_pending <- true; 158 + (* Process the line *) 159 + if String.length full_line = 0 then 160 + (* Empty line: dispatch event *) 161 + match try_dispatch t with 162 + | Some ev -> events := ev :: !events 163 + | None -> () 164 + else process_line t full_line; 165 + line_start := !i + 1) 166 + else (); 167 + incr i 168 + done; 169 + (* Save any partial line *) 170 + if !line_start < len then 171 + t.partial_line <- 172 + t.partial_line ^ String.sub chunk !line_start (len - !line_start); 173 + List.rev !events 174 + 175 + let read t reader = 176 + let events = ref [] in 177 + let continue = ref true in 178 + while !continue do 179 + let slice = Bytes.Reader.read reader in 180 + if Bytes.Slice.is_eod slice then continue := false 181 + else 182 + let chunk = 183 + Bytes.sub_string (Bytes.Slice.bytes slice) (Bytes.Slice.first slice) 184 + (Bytes.Slice.length slice) 185 + in 186 + events := List.rev_append (feed t chunk) !events 187 + done; 188 + List.rev !events 189 + end 190 + 191 + module Serializer = struct 192 + let split_lines s = 193 + let lines = ref [] in 194 + let start = ref 0 in 195 + for i = 0 to String.length s - 1 do 196 + if s.[i] = '\n' then ( 197 + lines := String.sub s !start (i - !start) :: !lines; 198 + start := i + 1) 199 + done; 200 + if !start <= String.length s then 201 + lines := String.sub s !start (String.length s - !start) :: !lines; 202 + List.rev !lines 203 + 204 + let event ?event ?id ?retry data = 205 + let buf = Buffer.create 128 in 206 + (match event with 207 + | Some e -> 208 + Buffer.add_string buf "event:"; 209 + Buffer.add_string buf e; 210 + Buffer.add_char buf '\n' 211 + | None -> ()); 212 + List.iter 213 + (fun line -> 214 + Buffer.add_string buf "data:"; 215 + Buffer.add_string buf line; 216 + Buffer.add_char buf '\n') 217 + (split_lines data); 218 + (match id with 219 + | Some i -> 220 + Buffer.add_string buf "id:"; 221 + Buffer.add_string buf i; 222 + Buffer.add_char buf '\n' 223 + | None -> ()); 224 + (match retry with 225 + | Some r -> 226 + Buffer.add_string buf "retry:"; 227 + Buffer.add_string buf (string_of_int r); 228 + Buffer.add_char buf '\n' 229 + | None -> ()); 230 + Buffer.add_char buf '\n'; 231 + Buffer.contents buf 232 + 233 + let write_event writer ?event:ev ?id ?retry data = 234 + let s = event ?event:ev ?id ?retry data in 235 + let bytes = Bytes.of_string s in 236 + let slice = Bytes.Slice.make ~first:0 ~length:(Bytes.length bytes) bytes in 237 + Bytes.Writer.write writer slice 238 + 239 + let comment text = 240 + let buf = Buffer.create 64 in 241 + List.iter 242 + (fun line -> 243 + Buffer.add_char buf ':'; 244 + Buffer.add_string buf line; 245 + Buffer.add_char buf '\n') 246 + (split_lines text); 247 + Buffer.contents buf 248 + 249 + let write_comment writer text = 250 + let s = comment text in 251 + let bytes = Bytes.of_string s in 252 + let slice = Bytes.Slice.make ~first:0 ~length:(Bytes.length bytes) bytes in 253 + Bytes.Writer.write writer slice 254 + 255 + let retry ms = "retry:" ^ string_of_int ms ^ "\n" 256 + 257 + let headers = 258 + [ 259 + ("Content-Type", "text/event-stream"); 260 + ("Cache-Control", "no-cache"); 261 + ("Connection", "keep-alive"); 262 + ] 263 + end
+129
ocaml-sse/lib/sse.mli
··· 1 + (** Server-Sent Events (SSE) parser and serializer. 2 + 3 + Implements the event stream format from the 4 + {{:https://html.spec.whatwg.org/multipage/server-sent-events.html} WHATWG 5 + HTML Living Standard §9.2}. 6 + 7 + The parser handles all stream features: multi-line [data] fields, named 8 + event types, [id] tracking (with NULL rejection per spec), [retry] 9 + intervals, comment lines, BOM stripping, and all three line endings (LF, CR, 10 + CRLF). 11 + 12 + {2 Client (parsing from a byte reader)} 13 + 14 + {[ 15 + let parser = Sse.Parser.create () in 16 + let reader = Bytes.Reader.of_string stream_data in 17 + let events = Sse.Parser.read parser reader in 18 + List.iter 19 + (fun e -> 20 + Printf.printf "type=%s data=%s id=%s\n" e.event e.data e.last_event_id) 21 + events 22 + ]} 23 + 24 + {2 Server (serializing to a byte writer)} 25 + 26 + {[ 27 + let buf = Buffer.create 256 in 28 + let writer = Bytes.Writer.of_buffer buf in 29 + Sse.Serializer.write_event writer ~event:"update" ~data:"hello" () 30 + (* writer now contains: "event:update\ndata:hello\n\n" *) 31 + ]} 32 + 33 + {2 References} 34 + 35 + - {{:https://html.spec.whatwg.org/multipage/server-sent-events.html} WHATWG 36 + HTML Living Standard §9.2 — Server-sent events} *) 37 + 38 + open Bytesrw 39 + 40 + (** {1 Events} *) 41 + 42 + type event = { 43 + data : string; (** Event payload. Multi-line data is joined with ['\n']. *) 44 + event : string; (** Event type. Empty string means default (["message"]). *) 45 + last_event_id : string; 46 + (** Last event ID seen so far. Persists across events. *) 47 + retry : int option; 48 + (** Reconnection time in milliseconds, if the server set one. *) 49 + } 50 + (** A dispatched SSE event. 51 + 52 + An event is dispatched when the parser encounters a blank line and the data 53 + buffer is non-empty. If the data buffer is empty on a blank line, no event 54 + is dispatched (the buffers are simply cleared per the spec). *) 55 + 56 + val pp_event : Format.formatter -> event -> unit 57 + (** Pretty-print an event. *) 58 + 59 + val equal_event : event -> event -> bool 60 + (** Structural equality on events. *) 61 + 62 + (** {1 Client: parsing event streams} *) 63 + 64 + module Parser : sig 65 + type t 66 + (** Stateful event stream parser. 67 + 68 + Maintains internal buffers for [data], [event type], [last event ID], and 69 + [retry] across incremental calls. Handles partial lines at chunk 70 + boundaries and all three line ending styles (LF, CR, CRLF). *) 71 + 72 + val create : ?last_event_id:string -> unit -> t 73 + (** Create a parser. Optionally provide an initial [last_event_id] for 74 + reconnection (from a previous session's {!last_event_id}). *) 75 + 76 + val feed : t -> string -> event list 77 + (** [feed t chunk] processes a raw string chunk of the event stream and 78 + returns any complete events. A chunk may contain zero, one, or many 79 + events. Partial lines are buffered until the next call. 80 + 81 + A leading UTF-8 BOM is stripped from the very first chunk. *) 82 + 83 + val read : t -> Bytes.Reader.t -> event list 84 + (** [read t reader] reads all available data from [reader] and returns any 85 + complete events. Like {!feed} but reads from a [Bytesrw] reader. *) 86 + 87 + val last_event_id : t -> string 88 + (** The current last event ID. Send this as the [Last-Event-ID] header when 89 + reconnecting. *) 90 + 91 + val retry : t -> int option 92 + (** The reconnection time in milliseconds, if the server has set one via a 93 + [retry] field. *) 94 + end 95 + 96 + (** {1 Server: serializing events} *) 97 + 98 + module Serializer : sig 99 + val event : ?event:string -> ?id:string -> ?retry:int -> string -> string 100 + (** [event data] serializes an SSE event to a string. 101 + 102 + Multi-line [data] is split and emitted as multiple [data:] lines. 103 + 104 + @return A string ending with ["\n\n"]. *) 105 + 106 + val write_event : 107 + Bytes.Writer.t -> 108 + ?event:string -> 109 + ?id:string -> 110 + ?retry:int -> 111 + string -> 112 + unit 113 + (** [write_event writer data] writes a serialized SSE event to [writer]. *) 114 + 115 + val comment : string -> string 116 + (** [comment text] serializes a comment line ([":text\n"]). Multi-line 117 + comments emit multiple comment lines. *) 118 + 119 + val write_comment : Bytes.Writer.t -> string -> unit 120 + (** [write_comment writer text] writes comment line(s) to [writer]. *) 121 + 122 + val retry : int -> string 123 + (** [retry ms] serializes a [retry:] field. *) 124 + 125 + val headers : (string * string) list 126 + (** HTTP response headers for an SSE stream: 127 + [Content-Type: text/event-stream], [Cache-Control: no-cache], and 128 + [Connection: keep-alive]. *) 129 + end
+37
ocaml-sse/sse.opam
··· 1 + # This file is generated by dune, edit dune-project instead 2 + opam-version: "2.0" 3 + synopsis: "Server-Sent Events (SSE) parser and serializer" 4 + description: """ 5 + Pure OCaml implementation of the Server-Sent Events protocol as defined 6 + in the WHATWG HTML Living Standard. Provides event stream parsing (client) 7 + and serialization (server) with support for multi-line data, event types, 8 + last-event-id tracking, retry intervals, and comments. No I/O dependencies 9 + in the core — bring your own transport.""" 10 + maintainer: ["thomas@gazagnaire.org"] 11 + authors: ["Thomas Gazagnaire"] 12 + license: "ISC" 13 + homepage: "https://tangled.org/gazagnaire.org/ocaml-sse" 14 + bug-reports: "https://tangled.org/gazagnaire.org/ocaml-sse/issues" 15 + depends: [ 16 + "ocaml" {>= "5.0.0"} 17 + "dune" {>= "3.21"} 18 + "bytesrw" {>= "0.1"} 19 + "alcotest" {with-test} 20 + "odoc" {with-doc} 21 + ] 22 + build: [ 23 + ["dune" "subst"] {dev} 24 + [ 25 + "dune" 26 + "build" 27 + "-p" 28 + name 29 + "-j" 30 + jobs 31 + "@install" 32 + "@runtest" {with-test} 33 + "@doc" {with-doc} 34 + ] 35 + ] 36 + dev-repo: "git+https://tangled.org/gazagnaire.org/ocaml-sse" 37 + x-maintenance-intent: ["(latest)"]
+3
ocaml-sse/test/dune
··· 1 + (test 2 + (name test) 3 + (libraries sse alcotest))
+1
ocaml-sse/test/test.ml
··· 1 + let () = Alcotest.run "sse" [ Test_sse.suite ]
+514
ocaml-sse/test/test_sse.ml
··· 1 + (** Tests for SSE module. 2 + 3 + Test vectors derived from the WHATWG HTML Living Standard §9.2 parsing 4 + algorithm examples and edge cases. *) 5 + 6 + let event = Alcotest.testable Sse.pp_event Sse.equal_event 7 + let events = Alcotest.list event 8 + 9 + let parse s = 10 + let p = Sse.Parser.create () in 11 + Sse.Parser.feed p s 12 + 13 + let parse_chunks chunks = 14 + let p = Sse.Parser.create () in 15 + List.concat_map (Sse.Parser.feed p) chunks 16 + 17 + let ev ?(event = "") ?(id = "") ?retry data = 18 + Sse.{ data; event; last_event_id = id; retry } 19 + 20 + (* ---- WHATWG spec examples (§9.2.6) ---- *) 21 + 22 + (* Source: WHATWG HTML Living Standard §9.2.6 "Authoring notes", Example 1. 23 + "data:YHOO\ndata:+2\ndata:10\n\n" → one event with data "YHOO\n+2\n10" *) 24 + let test_spec_example_multiline_data () = 25 + let r = parse "data:YHOO\ndata:+2\ndata:10\n\n" in 26 + Alcotest.(check events) "multi-line data" [ ev "YHOO\n+2\n10" ] r 27 + 28 + (* Source: WHATWG §9.2.6, Example 2. 29 + Two events separated by blank line. First with just data, second with event 30 + type and data. *) 31 + let test_spec_example_two_events () = 32 + let r = 33 + parse 34 + ": test stream\n\ndata: first event\nid: 1\n\ndata:second event\nid\n\n" 35 + in 36 + Alcotest.(check events) 37 + "two events" 38 + [ ev ~id:"1" "first event"; ev ~id:"" "second event" ] 39 + r 40 + 41 + (* Source: WHATWG §9.2.6, Example 3. 42 + "data" followed by empty line, then "data\ndata" followed by empty line. 43 + Two events: first with empty data "", second with "\n". *) 44 + let test_spec_example_empty_data () = 45 + let r = parse "data\n\ndata\ndata\n\n" in 46 + Alcotest.(check events) "empty data fields" [ ev ""; ev "\n" ] r 47 + 48 + (* Source: WHATWG §9.2.6, Example 4. 49 + "data:test" followed by EOF (no trailing blank line) → no event dispatched. *) 50 + let test_spec_example_no_trailing_newline () = 51 + let r = parse "data:test\n" in 52 + Alcotest.(check events) "no dispatch without blank line" [] r 53 + 54 + (* Source: WHATWG §9.2.6, Example 4 continued. 55 + "data:test" with trailing blank line → one event. *) 56 + let test_spec_example_with_trailing_newline () = 57 + let r = parse "data:test\n\n" in 58 + Alcotest.(check events) "dispatch with blank line" [ ev "test" ] r 59 + 60 + (* ---- Named event types ---- *) 61 + 62 + (* Source: WHATWG §9.2.6, custom event types. 63 + "event:add\ndata:73857293\n\n" → event with type "add". *) 64 + let test_named_event () = 65 + let r = parse "event:add\ndata:73857293\n\n" in 66 + Alcotest.(check events) "named event" [ ev ~event:"add" "73857293" ] r 67 + 68 + let test_event_type_resets_after_dispatch () = 69 + let r = parse "event:custom\ndata:first\n\ndata:second\n\n" in 70 + Alcotest.(check events) 71 + "event type resets" 72 + [ ev ~event:"custom" "first"; ev "second" ] 73 + r 74 + 75 + let test_event_type_without_data () = 76 + let r = parse "event:custom\n\n" in 77 + Alcotest.(check events) "event type with no data: no dispatch" [] r 78 + 79 + (* ---- ID field ---- *) 80 + 81 + let test_id_persists () = 82 + let r = parse "id:42\ndata:first\n\ndata:second\n\n" in 83 + Alcotest.(check events) 84 + "id persists across events" 85 + [ ev ~id:"42" "first"; ev ~id:"42" "second" ] 86 + r 87 + 88 + let test_id_update () = 89 + let r = parse "id:1\ndata:a\n\nid:2\ndata:b\n\n" in 90 + Alcotest.(check events) "id updates" [ ev ~id:"1" "a"; ev ~id:"2" "b" ] r 91 + 92 + (* Source: WHATWG §9.2 parsing algorithm — "If the field value does not contain 93 + U+0000 NULL, then set the last event ID buffer to the field value. Otherwise, 94 + ignore the field." *) 95 + let test_id_with_null_rejected () = 96 + let r = parse "id:good\ndata:a\n\nid:bad\x00id\ndata:b\n\n" in 97 + Alcotest.(check events) 98 + "id with null is rejected" 99 + [ ev ~id:"good" "a"; ev ~id:"good" "b" ] 100 + r 101 + 102 + let test_id_empty_resets () = 103 + let r = parse "id:42\ndata:a\n\nid\ndata:b\n\n" in 104 + Alcotest.(check events) 105 + "id with no value resets to empty" 106 + [ ev ~id:"42" "a"; ev ~id:"" "b" ] 107 + r 108 + 109 + let test_id_with_colon () = 110 + let r = parse "id:foo:bar\ndata:x\n\n" in 111 + Alcotest.(check events) 112 + "id value can contain colons" 113 + [ ev ~id:"foo:bar" "x" ] 114 + r 115 + 116 + (* ---- Retry field ---- *) 117 + 118 + (* Source: WHATWG §9.2 — "If the field value consists of only ASCII digits, 119 + then interpret the field value as an integer in base ten." *) 120 + let test_retry_valid () = 121 + let r = parse "retry:3000\ndata:x\n\n" in 122 + Alcotest.(check events) "valid retry" [ ev ~retry:3000 "x" ] r 123 + 124 + let test_retry_non_numeric_ignored () = 125 + let r = parse "retry:abc\ndata:x\n\n" in 126 + Alcotest.(check events) "non-numeric retry ignored" [ ev "x" ] r 127 + 128 + let test_retry_empty_ignored () = 129 + let r = parse "retry:\ndata:x\n\n" in 130 + Alcotest.(check events) "empty retry ignored" [ ev "x" ] r 131 + 132 + let test_retry_with_spaces_ignored () = 133 + let r = parse "retry: 3000\ndata:x\n\n" in 134 + (* " 3000" is not all digits — the leading space is stripped, giving "3000" *) 135 + Alcotest.(check events) 136 + "retry with space-stripped value" 137 + [ ev ~retry:3000 "x" ] 138 + r 139 + 140 + let test_retry_negative_ignored () = 141 + let r = parse "retry:-1\ndata:x\n\n" in 142 + Alcotest.(check events) "negative retry ignored" [ ev "x" ] r 143 + 144 + let test_retry_persists () = 145 + let r = parse "retry:5000\ndata:a\n\ndata:b\n\n" in 146 + Alcotest.(check events) 147 + "retry persists across events" 148 + [ ev ~retry:5000 "a"; ev ~retry:5000 "b" ] 149 + r 150 + 151 + let test_retry_zero () = 152 + let r = parse "retry:0\ndata:x\n\n" in 153 + Alcotest.(check events) "retry zero is valid" [ ev ~retry:0 "x" ] r 154 + 155 + (* ---- Comments ---- *) 156 + 157 + (* Source: WHATWG §9.2 — "If the line starts with a U+003A COLON character (:) 158 + … Ignore the line." *) 159 + let test_comment_ignored () = 160 + let r = parse ": this is a comment\ndata:hello\n\n" in 161 + Alcotest.(check events) "comment ignored" [ ev "hello" ] r 162 + 163 + let test_comment_only_no_event () = 164 + let r = parse ": just a comment\n\n" in 165 + Alcotest.(check events) "comment-only block: no event" [] r 166 + 167 + let test_empty_comment () = 168 + let r = parse ":\ndata:x\n\n" in 169 + Alcotest.(check events) "empty comment" [ ev "x" ] r 170 + 171 + let test_comment_as_keepalive () = 172 + let r = parse ":\n:\n:\ndata:alive\n\n" in 173 + Alcotest.(check events) "multiple comments as keepalive" [ ev "alive" ] r 174 + 175 + (* ---- Field parsing edge cases ---- *) 176 + 177 + (* Source: WHATWG §9.2 — "If value starts with a U+0020 SPACE character, remove 178 + it from value." Only ONE leading space is removed. *) 179 + let test_space_stripping () = 180 + let r = parse "data: hello\n\n" in 181 + Alcotest.(check events) "one leading space stripped" [ ev "hello" ] r 182 + 183 + let test_double_space_only_one_stripped () = 184 + let r = parse "data: hello\n\n" in 185 + Alcotest.(check events) "only one space stripped" [ ev " hello" ] r 186 + 187 + let test_no_space_after_colon () = 188 + let r = parse "data:hello\n\n" in 189 + Alcotest.(check events) "no space after colon" [ ev "hello" ] r 190 + 191 + let test_unknown_field_ignored () = 192 + let r = parse "foo:bar\ndata:x\n\n" in 193 + Alcotest.(check events) "unknown field ignored" [ ev "x" ] r 194 + 195 + let test_field_no_colon () = 196 + let r = parse "data\n\n" in 197 + Alcotest.(check events) "field with no colon: empty value" [ ev "" ] r 198 + 199 + let test_data_with_colon_in_value () = 200 + let r = parse "data:foo:bar:baz\n\n" in 201 + Alcotest.(check events) "colons in value preserved" [ ev "foo:bar:baz" ] r 202 + 203 + (* ---- Line endings ---- *) 204 + 205 + (* Source: WHATWG §9.2 — "Lines must be separated by either a U+000D CARRIAGE 206 + RETURN U+000A LINE FEED (CRLF) character pair, a single U+000A LINE FEED 207 + (LF) character, or a single U+000D CARRIAGE RETURN (CR) character." *) 208 + let test_lf_line_endings () = 209 + let r = parse "data:hello\n\n" in 210 + Alcotest.(check events) "LF line endings" [ ev "hello" ] r 211 + 212 + let test_cr_line_endings () = 213 + let r = parse "data:hello\r\r" in 214 + Alcotest.(check events) "CR line endings" [ ev "hello" ] r 215 + 216 + let test_crlf_line_endings () = 217 + let r = parse "data:hello\r\n\r\n" in 218 + Alcotest.(check events) "CRLF line endings" [ ev "hello" ] r 219 + 220 + let test_mixed_line_endings () = 221 + let r = parse "data:a\ndata:b\rdata:c\r\n\n" in 222 + Alcotest.(check events) "mixed line endings" [ ev "a\nb\nc" ] r 223 + 224 + (* ---- BOM handling ---- *) 225 + 226 + (* Source: WHATWG §9.2 — "The UTF-8 decode algorithm strips one leading UTF-8 227 + Byte Order Mark (BOM), if any." *) 228 + let test_bom_stripped () = 229 + let r = parse "\xEF\xBB\xBFdata:hello\n\n" in 230 + Alcotest.(check events) "BOM stripped from first chunk" [ ev "hello" ] r 231 + 232 + let test_bom_only_first_chunk () = 233 + let r = parse_chunks [ "\xEF\xBB\xBFdata:a\n\n"; "\xEF\xBB\xBFdata:b\n\n" ] in 234 + (* BOM in second chunk is NOT stripped — becomes part of data field name *) 235 + Alcotest.(check (Alcotest.list event)) 236 + "BOM only stripped from first chunk" 237 + [ ev "a" ] 238 + (List.filter (fun e -> e.Sse.data = "a") r) 239 + 240 + (* ---- Incremental parsing (chunked delivery) ---- *) 241 + 242 + let test_split_across_chunks () = 243 + let r = parse_chunks [ "da"; "ta:hel"; "lo\n\n" ] in 244 + Alcotest.(check events) "data split across chunks" [ ev "hello" ] r 245 + 246 + let test_event_split_at_blank_line () = 247 + let r = parse_chunks [ "data:hello\n"; "\ndata:world\n\n" ] in 248 + Alcotest.(check events) "split at blank line" [ ev "hello"; ev "world" ] r 249 + 250 + let test_crlf_split_across_chunks () = 251 + let r = parse_chunks [ "data:hello\r"; "\ndata:world\r\n\r\n" ] in 252 + Alcotest.(check events) "CRLF split across chunks" [ ev "hello\nworld" ] r 253 + 254 + let test_empty_chunks () = 255 + let r = parse_chunks [ ""; "data:x"; ""; "\n\n"; "" ] in 256 + Alcotest.(check events) "empty chunks" [ ev "x" ] r 257 + 258 + let test_byte_at_a_time () = 259 + let input = "data:hello\n\n" in 260 + let chunks = 261 + List.init (String.length input) (fun i -> String.make 1 input.[i]) 262 + in 263 + let r = parse_chunks chunks in 264 + Alcotest.(check events) "byte at a time" [ ev "hello" ] r 265 + 266 + (* ---- Multiple events ---- *) 267 + 268 + let test_three_events () = 269 + let r = parse "data:a\n\ndata:b\n\ndata:c\n\n" in 270 + Alcotest.(check events) "three events" [ ev "a"; ev "b"; ev "c" ] r 271 + 272 + let test_blank_lines_between_events () = 273 + let r = parse "data:a\n\n\n\ndata:b\n\n" in 274 + (* Extra blank lines don't dispatch (data buffer is empty) *) 275 + Alcotest.(check events) "extra blank lines" [ ev "a"; ev "b" ] r 276 + 277 + (* ---- Negative / degenerate cases ---- *) 278 + 279 + let test_empty_stream () = 280 + let r = parse "" in 281 + Alcotest.(check events) "empty stream" [] r 282 + 283 + let test_only_newlines () = 284 + let r = parse "\n\n\n\n" in 285 + Alcotest.(check events) "only newlines: no events" [] r 286 + 287 + let test_only_comments () = 288 + let r = parse ": comment 1\n: comment 2\n\n" in 289 + Alcotest.(check events) "only comments: no events" [] r 290 + 291 + let test_no_data_field () = 292 + let r = parse "event:update\nid:1\nretry:5000\n\n" in 293 + Alcotest.(check events) "no data field: no dispatch" [] r 294 + 295 + let test_unterminated_event () = 296 + let r = parse "data:hello" in 297 + Alcotest.(check events) "unterminated event (no newline)" [] r 298 + 299 + let test_data_after_dispatch () = 300 + let p = Sse.Parser.create () in 301 + let r1 = Sse.Parser.feed p "data:first\n\n" in 302 + let r2 = Sse.Parser.feed p "data:second\n\n" in 303 + Alcotest.(check events) "first batch" [ ev "first" ] r1; 304 + Alcotest.(check events) "second batch" [ ev "second" ] r2 305 + 306 + (* ---- Serializer ---- *) 307 + 308 + let test_serialize_simple () = 309 + let s = Sse.Serializer.event "hello" in 310 + Alcotest.(check string) "simple event" "data:hello\n\n" s 311 + 312 + let test_serialize_multiline () = 313 + let s = Sse.Serializer.event "line1\nline2\nline3" in 314 + Alcotest.(check string) 315 + "multiline data" "data:line1\ndata:line2\ndata:line3\n\n" s 316 + 317 + let test_serialize_with_type () = 318 + let s = Sse.Serializer.event ~event:"update" "x" in 319 + Alcotest.(check string) "event with type" "event:update\ndata:x\n\n" s 320 + 321 + let test_serialize_with_id () = 322 + let s = Sse.Serializer.event ~id:"42" "x" in 323 + Alcotest.(check string) "event with id" "data:x\nid:42\n\n" s 324 + 325 + let test_serialize_with_retry () = 326 + let s = Sse.Serializer.event ~retry:3000 "x" in 327 + Alcotest.(check string) "event with retry" "data:x\nretry:3000\n\n" s 328 + 329 + let test_serialize_all_fields () = 330 + let s = Sse.Serializer.event ~event:"msg" ~id:"7" ~retry:5000 "hi" in 331 + Alcotest.(check string) 332 + "all fields" "event:msg\ndata:hi\nid:7\nretry:5000\n\n" s 333 + 334 + let test_serialize_comment () = 335 + let s = Sse.Serializer.comment "keepalive" in 336 + Alcotest.(check string) "comment" ":keepalive\n" s 337 + 338 + let test_serialize_multiline_comment () = 339 + let s = Sse.Serializer.comment "line1\nline2" in 340 + Alcotest.(check string) "multiline comment" ":line1\n:line2\n" s 341 + 342 + let test_serialize_retry () = 343 + let s = Sse.Serializer.retry 5000 in 344 + Alcotest.(check string) "retry field" "retry:5000\n" s 345 + 346 + let test_serialize_empty_data () = 347 + let s = Sse.Serializer.event "" in 348 + Alcotest.(check string) "empty data" "data:\n\n" s 349 + 350 + (* ---- Round-trip: serialize then parse ---- *) 351 + 352 + let test_roundtrip_simple () = 353 + let s = Sse.Serializer.event "hello world" in 354 + let r = parse s in 355 + Alcotest.(check events) "roundtrip simple" [ ev "hello world" ] r 356 + 357 + let test_roundtrip_multiline () = 358 + let s = Sse.Serializer.event "a\nb\nc" in 359 + let r = parse s in 360 + Alcotest.(check events) "roundtrip multiline" [ ev "a\nb\nc" ] r 361 + 362 + let test_roundtrip_all_fields () = 363 + let s = Sse.Serializer.event ~event:"update" ~id:"99" ~retry:1000 "payload" in 364 + let r = parse s in 365 + Alcotest.(check events) 366 + "roundtrip all fields" 367 + [ ev ~event:"update" ~id:"99" ~retry:1000 "payload" ] 368 + r 369 + 370 + let test_roundtrip_multiple () = 371 + let s = 372 + Sse.Serializer.event "one" ^ Sse.Serializer.event "two" 373 + ^ Sse.Serializer.event "three" 374 + in 375 + let r = parse s in 376 + Alcotest.(check events) 377 + "roundtrip multiple" 378 + [ ev "one"; ev "two"; ev "three" ] 379 + r 380 + 381 + (* ---- Headers ---- *) 382 + 383 + let test_headers () = 384 + let h = Sse.Serializer.headers in 385 + Alcotest.(check bool) 386 + "has content-type" 387 + (List.mem ("Content-Type", "text/event-stream") h) 388 + true; 389 + Alcotest.(check bool) 390 + "has cache-control" 391 + (List.mem ("Cache-Control", "no-cache") h) 392 + true 393 + 394 + (* ---- Parser state: last_event_id and retry accessors ---- *) 395 + 396 + let test_parser_last_event_id () = 397 + let p = Sse.Parser.create () in 398 + Alcotest.(check string) "initial id" "" (Sse.Parser.last_event_id p); 399 + let _ = Sse.Parser.feed p "id:42\ndata:x\n\n" in 400 + Alcotest.(check string) "after id:42" "42" (Sse.Parser.last_event_id p) 401 + 402 + let test_parser_retry () = 403 + let p = Sse.Parser.create () in 404 + Alcotest.(check (option int)) "initial retry" None (Sse.Parser.retry p); 405 + let _ = Sse.Parser.feed p "retry:3000\ndata:x\n\n" in 406 + Alcotest.(check (option int)) "after retry" (Some 3000) (Sse.Parser.retry p) 407 + 408 + let test_parser_reconnect_id () = 409 + let p = Sse.Parser.create ~last_event_id:"prev" () in 410 + let r = Sse.Parser.feed p "data:x\n\n" in 411 + Alcotest.(check events) "reconnect with previous id" [ ev ~id:"prev" "x" ] r; 412 + Alcotest.(check string) 413 + "parser retains id" "prev" 414 + (Sse.Parser.last_event_id p) 415 + 416 + let suite = 417 + ( "sse", 418 + [ 419 + (* WHATWG spec examples *) 420 + Alcotest.test_case "spec: multi-line data" `Quick 421 + test_spec_example_multiline_data; 422 + Alcotest.test_case "spec: two events" `Quick test_spec_example_two_events; 423 + Alcotest.test_case "spec: empty data fields" `Quick 424 + test_spec_example_empty_data; 425 + Alcotest.test_case "spec: no trailing newline" `Quick 426 + test_spec_example_no_trailing_newline; 427 + Alcotest.test_case "spec: with trailing newline" `Quick 428 + test_spec_example_with_trailing_newline; 429 + (* Named events *) 430 + Alcotest.test_case "named event" `Quick test_named_event; 431 + Alcotest.test_case "event type resets" `Quick 432 + test_event_type_resets_after_dispatch; 433 + Alcotest.test_case "event type without data" `Quick 434 + test_event_type_without_data; 435 + (* ID field *) 436 + Alcotest.test_case "id persists" `Quick test_id_persists; 437 + Alcotest.test_case "id update" `Quick test_id_update; 438 + Alcotest.test_case "id with null rejected" `Quick 439 + test_id_with_null_rejected; 440 + Alcotest.test_case "id empty resets" `Quick test_id_empty_resets; 441 + Alcotest.test_case "id with colon" `Quick test_id_with_colon; 442 + (* Retry field *) 443 + Alcotest.test_case "retry valid" `Quick test_retry_valid; 444 + Alcotest.test_case "retry non-numeric" `Quick 445 + test_retry_non_numeric_ignored; 446 + Alcotest.test_case "retry empty" `Quick test_retry_empty_ignored; 447 + Alcotest.test_case "retry with spaces" `Quick 448 + test_retry_with_spaces_ignored; 449 + Alcotest.test_case "retry negative" `Quick test_retry_negative_ignored; 450 + Alcotest.test_case "retry persists" `Quick test_retry_persists; 451 + Alcotest.test_case "retry zero" `Quick test_retry_zero; 452 + (* Comments *) 453 + Alcotest.test_case "comment ignored" `Quick test_comment_ignored; 454 + Alcotest.test_case "comment only" `Quick test_comment_only_no_event; 455 + Alcotest.test_case "empty comment" `Quick test_empty_comment; 456 + Alcotest.test_case "comment keepalive" `Quick test_comment_as_keepalive; 457 + (* Field parsing *) 458 + Alcotest.test_case "space stripping" `Quick test_space_stripping; 459 + Alcotest.test_case "double space" `Quick 460 + test_double_space_only_one_stripped; 461 + Alcotest.test_case "no space after colon" `Quick test_no_space_after_colon; 462 + Alcotest.test_case "unknown field" `Quick test_unknown_field_ignored; 463 + Alcotest.test_case "field no colon" `Quick test_field_no_colon; 464 + Alcotest.test_case "colon in value" `Quick test_data_with_colon_in_value; 465 + (* Line endings *) 466 + Alcotest.test_case "LF" `Quick test_lf_line_endings; 467 + Alcotest.test_case "CR" `Quick test_cr_line_endings; 468 + Alcotest.test_case "CRLF" `Quick test_crlf_line_endings; 469 + Alcotest.test_case "mixed endings" `Quick test_mixed_line_endings; 470 + (* BOM *) 471 + Alcotest.test_case "BOM stripped" `Quick test_bom_stripped; 472 + Alcotest.test_case "BOM only first chunk" `Quick test_bom_only_first_chunk; 473 + (* Incremental *) 474 + Alcotest.test_case "split across chunks" `Quick test_split_across_chunks; 475 + Alcotest.test_case "split at blank line" `Quick 476 + test_event_split_at_blank_line; 477 + Alcotest.test_case "CRLF split" `Quick test_crlf_split_across_chunks; 478 + Alcotest.test_case "empty chunks" `Quick test_empty_chunks; 479 + Alcotest.test_case "byte at a time" `Quick test_byte_at_a_time; 480 + (* Multiple events *) 481 + Alcotest.test_case "three events" `Quick test_three_events; 482 + Alcotest.test_case "extra blank lines" `Quick 483 + test_blank_lines_between_events; 484 + (* Negative / degenerate *) 485 + Alcotest.test_case "empty stream" `Quick test_empty_stream; 486 + Alcotest.test_case "only newlines" `Quick test_only_newlines; 487 + Alcotest.test_case "only comments" `Quick test_only_comments; 488 + Alcotest.test_case "no data field" `Quick test_no_data_field; 489 + Alcotest.test_case "unterminated" `Quick test_unterminated_event; 490 + Alcotest.test_case "data after dispatch" `Quick test_data_after_dispatch; 491 + (* Serializer *) 492 + Alcotest.test_case "serialize simple" `Quick test_serialize_simple; 493 + Alcotest.test_case "serialize multiline" `Quick test_serialize_multiline; 494 + Alcotest.test_case "serialize with type" `Quick test_serialize_with_type; 495 + Alcotest.test_case "serialize with id" `Quick test_serialize_with_id; 496 + Alcotest.test_case "serialize with retry" `Quick test_serialize_with_retry; 497 + Alcotest.test_case "serialize all fields" `Quick test_serialize_all_fields; 498 + Alcotest.test_case "serialize comment" `Quick test_serialize_comment; 499 + Alcotest.test_case "serialize multiline comment" `Quick 500 + test_serialize_multiline_comment; 501 + Alcotest.test_case "serialize retry" `Quick test_serialize_retry; 502 + Alcotest.test_case "serialize empty data" `Quick test_serialize_empty_data; 503 + (* Round-trip *) 504 + Alcotest.test_case "roundtrip simple" `Quick test_roundtrip_simple; 505 + Alcotest.test_case "roundtrip multiline" `Quick test_roundtrip_multiline; 506 + Alcotest.test_case "roundtrip all fields" `Quick test_roundtrip_all_fields; 507 + Alcotest.test_case "roundtrip multiple" `Quick test_roundtrip_multiple; 508 + (* Headers *) 509 + Alcotest.test_case "headers" `Quick test_headers; 510 + (* Parser state *) 511 + Alcotest.test_case "parser last_event_id" `Quick test_parser_last_event_id; 512 + Alcotest.test_case "parser retry" `Quick test_parser_retry; 513 + Alcotest.test_case "parser reconnect id" `Quick test_parser_reconnect_id; 514 + ] )
+6
ocaml-sse/test/test_sse.mli
··· 1 + (** SSE parser and serializer tests. 2 + 3 + Test vectors derived from the WHATWG HTML Living Standard §9.2 examples and 4 + edge cases described in the parsing algorithm. *) 5 + 6 + val suite : string * unit Alcotest.test_case list