{
  "flavors": {
    "ok": "A correct stream, for comparison: retry, id, event and data fields, blank-line delimited, then a clean close. ?events= and ?interval= (ms).",
    "stall": "Headers and one event arrive, then nothing for ?seconds= (default 10, max 20), then a clean close. A client with a connect timeout but no read timeout waits here.",
    "cut": "Ends mid-event with a clean close: a complete event, then \"data: {\\\"partial\\\":tr\" and EOF with no blank line. The spec says the unterminated event is discarded.",
    "drop": "The connection is reset mid-event (HTTP/1.1: closed with bytes outstanding; HTTP/2: RST_STREAM). Your client should report an error, not a clean end. Carries a Content-Length so the runtime can reset for real.",
    "crlf": "Every line ends in CRLF. The spec allows CR, LF or CRLF; parsers that split on \"\\n\" leave a trailing \"\\r\" in every value.",
    "cr": "Every line ends in a bare CR. Spec-legal: CR alone is a line terminator.",
    "no-space": "Field values with no space after the colon, two spaces, and nothing at all. The spec strips exactly one leading space: \"data:foo\" is \"foo\", \"data:  foo\" is \" foo\".",
    "multiline": "Multiple data: lines per event (joined with \"\\n\"), a colon inside a value, an empty data: line in the middle. Parsers that keep only the last line, or split on \":\", fail here.",
    "comments": "A leading BOM, \": keepalive\" comment lines, unknown fields (\"foo: bar\") and a field line with no colon. All four must be ignored; you should see exactly three events.",
    "split-utf8": "Multi-byte UTF-8 characters split across chunk boundaries (a 4-byte emoji as 2+2, a 3-byte euro sign as 1+2). A client that decodes each chunk separately sees U+FFFD.",
    "wrong-type": "A valid stream served as text/plain. A browser EventSource must fail; does your client notice?",
    "error-event": "An event whose name is \"error\". EventSource routes it to onerror beside real transport failures; does your client tell them apart?",
    "big": "One event with a ?bytes= (default 64 KiB, max 1 MiB) data line. Line buffers with a fixed cap truncate or crash.",
    "resume": "Sends events 1–3 and closes. Reconnect with Last-Event-ID: 3 and it sends 4–6; with 6 it answers 204 (stop). Its retry is 1000 ms so the three requests complete quickly. Does your client send Last-Event-ID on reconnect, and stop on a 204?"
  },
  "usage": "/sse/{flavor}",
  "limits": {
    "max_seconds": 20,
    "max_events": 100,
    "max_interval_ms": 5000,
    "max_bytes": 1048576
  },
  "reconnect": "Every stream starts with \"retry: 30000\" (resume alone sends \"retry: 1000\"). A GET or HEAD with valid parameters that carries a Last-Event-ID header is answered 204 No Content (reconnect declined; a 204 tells EventSource to stop reconnecting), except /sse/resume, which continues from it.",
  "witness": {
    "measured": "2026-10-05T03:43:48Z",
    "observations": 98,
    "control_rows": 14,
    "data": "https://badhttp.dev/clients.jsonl",
    "data_note": "Every observation is a row of /clients.jsonl with family \"sse\", joined to /corpus.jsonl by corpus_id; /clients indexes them with the class legend, the outcome legend and the per-flavor disagreement. The findings below are a reading of those rows, not a second source.",
    "what_this_is": "7 real SSE client libraries plus a raw-wire control (curl), one fresh client per flavor, pointed at GET /sse/{flavor} with default parameters on this exact date. It is a DATED CAPTURE, not a live measurement. The reference for each flavor is a WHATWG HTML §9.2.6 parse of the bytes the control received — derived from the wire, never typed — and each row describes what the library delivered to its caller relative to that parse and to the library's class (an EventSource-interface library reconnects after any server close; a one-shot library returns). \"differs\" is a description: a one-shot library not reconnecting on resume is its design, and a one-shot library delivering a text/plain stream is a choice the specification does not constrain. No library here is scored, ranked or called conformant. Re-run it and move the date rather than letting it stale.",
    "clients": [
      "aiohttp-sse-client 0.2.1 (eventsource)",
      "eventsource (npm) 5.1.2 (eventsource)",
      "go-sse v0.11.0 (eventsource)",
      "httpx-sse 0.4.3 (one-shot)",
      "Node built-in EventSource (undici) node v26.10.0 / undici 8.10.2 (eventsource)",
      "r3labs/sse v2.10.0 (one-shot)",
      "sseclient-py 1.9.0 (one-shot)"
    ],
    "control": [
      "curl 8.7.1"
    ],
    "reference": "REFERENCE in the /clients sse family block: per flavor, the events the WHATWG algorithm yields from the control's bytes, their SHA-256, the last event id at EOF and whether pending data was discarded",
    "findings": [
      "Two classes of library were measured and the expected ending differs by class: aiohttp-sse-client, eventsource (npm), go-sse, Node built-in EventSource (undici) implement the EventSource interface (4), which reconnects after any server close and so reports a clean end as an error event in its reconnecting state; httpx-sse, r3labs/sse, sseclient-py iterate one response and return (3). 88 of 98 rows delivered what their class is expected to and ended as expected for it: the reference parse of the control's bytes, except that the EventSource interface delivers nothing on wrong-type and, on resume, also the events 4-6 of the documented later connections (a one-shot library on wrong-type may deliver either).",
      "ok (the control stream, five events): all 7 libraries delivered the five events as the reference parse has them; endings: ended reconnecting (aiohttp-sse-client, eventsource (npm), go-sse, Node built-in EventSource (undici)); ended clean (httpx-sse, r3labs/sse, sseclient-py).",
      "cut (a complete event, then \"data: {\\\"partial\\\":tr\" and EOF with no blank line; the spec discards it): aiohttp-sse-client, eventsource (npm), go-sse, httpx-sse, Node built-in EventSource (undici) delivered one event; r3labs/sse, sseclient-py delivered the unterminated partial as an event. The spec updates the Last-Event-ID string only when a blank line is processed, so after the unterminated \"id: 2\" a reconnect still carries 1; where a library exposes its last event id: last event id 1 (aiohttp-sse-client, go-sse, httpx-sse); exposes no last event id (eventsource (npm), Node built-in EventSource (undici), sseclient-py); last event id 2 (r3labs/sse).",
      "drop (the connection reset mid-event): all 7 libraries reported it as an error or entered their reconnecting state; sseclient-py delivered none of the complete event that arrived before the reset; r3labs/sse delivered the half-written second event as an event of its own. stall (one event, then ten seconds of silence, then a close): 1 event, then ended reconnecting after 10 s (aiohttp-sse-client, eventsource (npm), go-sse, Node built-in EventSource (undici)); 1 event, then ended clean after 10 s (httpx-sse, r3labs/sse, sseclient-py).",
      "crlf: every library delivered the three events with clean values. cr (bare CR line endings, spec-legal): eventsource (npm), go-sse, httpx-sse, Node built-in EventSource (undici), r3labs/sse, sseclient-py parsed it as the spec says; aiohttp-sse-client delivered nothing at all.",
      "no-space (\"data:foo\" is \"foo\", \"data:  foo\" is \" foo\", \"data: \" and \"data:\" are \"\" and still fire): as the spec strips (eventsource (npm), go-sse, httpx-sse, Node built-in EventSource (undici), r3labs/sse, sseclient-py); values \"foo\" \"foo\" \"\" \"\" (aiohttp-sse-client).",
      "multiline (three data lines joined with LF, a colon inside a value, an empty data line in the middle): every library delivered the three values as the reference parse has them. comments (a BOM, comment lines, an unknown field, a line with no colon): three events, BOM and comments ignored (aiohttp-sse-client, eventsource (npm), httpx-sse, Node built-in EventSource (undici), sseclient-py); 3 events, ended clean, expected reconnecting for an eventsource client (go-sse); 4 events, delivered 4 events, the reference has 3 (extra: message#3:\"\") (r3labs/sse). go-sse delivered the three events and then returned without an error and without scheduling a reconnect: this stream happens to end on a bare comment line, and its read loop treats that ending as a normal return rather than the end of the stream its documentation says it reconnects after.",
      "split-utf8 (a 4-byte and a 3-byte character each split across two chunks): all 7 libraries reassembled both characters.",
      "wrong-type (a valid stream served as text/plain; the EventSource interface must fail the connection): aiohttp-sse-client, eventsource (npm), go-sse, httpx-sse, Node built-in EventSource (undici) refused it and delivered nothing; r3labs/sse, sseclient-py delivered the three events regardless. A one-shot library is under no specification obligation to check the type; the rows say which did.",
      "error-event (an event whose name is \"error\", between two ticks): aiohttp-sse-client, eventsource (npm), go-sse, httpx-sse, Node built-in EventSource (undici), r3labs/sse, sseclient-py delivered it as a named event — and for eventsource (npm), Node built-in EventSource (undici) it arrived on the same error listener as a transport failure would, so a caller must look for the event's data to tell them apart.",
      "big (one 65,536-byte data line): the 65,536-byte line intact (aiohttp-sse-client, eventsource (npm), httpx-sse, Node built-in EventSource (undici), sseclient-py); nothing of it (request failed: connection to server lost: bufio.Scanner: token too long) (go-sse); nothing of it (bufio.Scanner: token too long) (r3labs/sse). Both Go libraries read lines through a bufio.Scanner whose default token limit is 64 KiB, and a 65,536-byte data line plus its field name is longer than that.",
      "The stream's opening \"retry: 30000\" block dispatches no event in the specification (it has no data), and go-sse (on 13 of 14 flavors), httpx-sse (on 12 of 14 flavors), r3labs/sse (on 13 of 14 flavors) delivered an empty event for it; those events are recorded on the rows (preamble_events) and excluded from the comparison so they do not hide anything else. An empty event at the very end of a stream is not the opening block and is kept in the comparison.",
      "resume (ids 1–3, then 4–6 to a reconnect carrying Last-Event-ID: 3, then a 204): aiohttp-sse-client, eventsource (npm), go-sse, Node built-in EventSource (undici) ran the whole sequence — three connections, six events, stopped at the 204; per library: 3 connections, 6 events, ended stopped, Last-Event-ID sent: 3 then 6 (aiohttp-sse-client, eventsource (npm), go-sse, Node built-in EventSource (undici)); one connection, 3 events, no reconnection by design (httpx-sse, r3labs/sse, sseclient-py).",
      "0 of 98 rows hit the harness's 30 s cap and 0 failed to land. Every other row is a stream this server sent, read back from the wire with its x-badhttp-version on every connection."
    ],
    "what_the_rows_cannot_say": "Whether a library's reconnect would carry Last-Event-ID correctly on every flavor (only resume lets the reconnect run; every other stream says retry: 30000 and the harness closes the client when its first connection ends), what a library does after the 30 s cap, how a browser's EventSource behaves (no browser in the roster), and anything about the HTTP/2 path where the library could not be made to use HTTP/1.1 (the row records the protocol).",
    "reproduce": "Point the row's library at the row's url with a fresh instance, collect every event it delivers with its type, id and data, note how it signals the end, and compare with the row's events and end; for the reference, capture the bytes with curl -sS -N --http1.1 and parse them by WHATWG HTML §9.2.6. Pace the calls against the zone limit of 100 per 10 s, and treat a first response without x-badhttp-version as no observation. The harnesses that produced the rows are in scripts/sse-witness/ in the source."
  }
}
