aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorThijs Schreijer <thijs@thijsschreijer.nl>2026-08-31 22:27:42 +0200
committerThijs Schreijer <thijs@thijsschreijer.nl>2026-09-01 07:59:54 +0200
commitcd6b41a13af403d32e08ff416e60982b5cb718dc (patch)
tree3865d91753fd636a866d4711f50ab27613e58634
parentc5f169ec303a70707bc48fbece9b52cc90a48de0 (diff)
downloadluasocket-cd6b41a13af403d32e08ff416e60982b5cb718dc.tar.gz
luasocket-cd6b41a13af403d32e08ff416e60982b5cb718dc.tar.bz2
luasocket-cd6b41a13af403d32e08ff416e60982b5cb718dc.zip
Add SSE sink (socket.sse module)feat/sse
Implements SSE by using the replacement body sink. Replaces the sink with an SSE parser if the response is a text stream. Example application included
-rw-r--r--.github/workflows/build.yml3
-rw-r--r--luasocket-scm-3.rockspec1
-rw-r--r--samples/README9
-rw-r--r--samples/sse.lua63
-rw-r--r--src/makefile3
-rw-r--r--src/sse.lua193
-rw-r--r--test/httpfixture.lua47
-rw-r--r--test/ssehttptest.lua151
-rw-r--r--test/ssetest.lua370
9 files changed, 827 insertions, 13 deletions
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index a81649a..d2e27a4 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -43,6 +43,8 @@ jobs:
43 lua testclnt.lua 43 lua testclnt.lua
44 lua testsrvr.lua > /dev/null & 44 lua testsrvr.lua > /dev/null &
45 lua httpfixturetest.lua 45 lua httpfixturetest.lua
46 lua testsrvr.lua > /dev/null &
47 lua ssehttptest.lua
46 lua stufftest.lua 48 lua stufftest.lua
47 lua excepttest.lua 49 lua excepttest.lua
48 lua test_bind.lua 50 lua test_bind.lua
@@ -51,6 +53,7 @@ jobs:
51 lua mimetest.lua 53 lua mimetest.lua
52 lua urltest.lua 54 lua urltest.lua
53 lua headerstest.lua 55 lua headerstest.lua
56 lua ssetest.lua
54 lua test_socket_error.lua 57 lua test_socket_error.lua
55 lua test_tcp_receive_zero.lua 58 lua test_tcp_receive_zero.lua
56 lua test_udp_receive_zero.lua 59 lua test_udp_receive_zero.lua
diff --git a/luasocket-scm-3.rockspec b/luasocket-scm-3.rockspec
index 475f594..b89684c 100644
--- a/luasocket-scm-3.rockspec
+++ b/luasocket-scm-3.rockspec
@@ -67,6 +67,7 @@ local function make_plat(plat)
67 ["socket.ftp"] = "src/ftp.lua", 67 ["socket.ftp"] = "src/ftp.lua",
68 ["socket.headers"] = "src/headers.lua", 68 ["socket.headers"] = "src/headers.lua",
69 ["socket.smtp"] = "src/smtp.lua", 69 ["socket.smtp"] = "src/smtp.lua",
70 ["socket.sse"] = "src/sse.lua",
70 ltn12 = "src/ltn12.lua", 71 ltn12 = "src/ltn12.lua",
71 socket = "src/socket.lua", 72 socket = "src/socket.lua",
72 mbox = "src/mbox.lua", 73 mbox = "src/mbox.lua",
diff --git a/samples/README b/samples/README
index 4ee06b6..5fff925 100644
--- a/samples/README
+++ b/samples/README
@@ -104,6 +104,15 @@ Unix machines. It uses the lp.lua implementation, in the
104samples directory. Just run 'lua lpr.lua <filename> 104samples directory. Just run 'lua lpr.lua <filename>
105queue=<printername>' and the file will print! 105queue=<printername>' and the file will print!
106 106
107 sse.lua -- Server-Sent Events (SSE) client
108
109This little program uses socket.sse to connect to a text/event-stream
110URL and print each event received, stopping after a fixed number of
111them (5 by default). With no arguments it connects to a public live
112test feed, so you can try it with no setup at all. Just run
113
114 lua sse.lua [<url>] [<event-count>]
115
107 cddb.lua -- CDDB client 116 cddb.lua -- CDDB client
108 117
109This is the first try on a simple CDDB client. Not really 118This is the first try on a simple CDDB client. Not really
diff --git a/samples/sse.lua b/samples/sse.lua
new file mode 100644
index 0000000..16eece9
--- /dev/null
+++ b/samples/sse.lua
@@ -0,0 +1,63 @@
1-----------------------------------------------------------------------------
2-- Server-Sent Events (SSE) demo client
3-- LuaSocket sample files
4--
5-- Usage: lua sse.lua [<url>] [<event-count>]
6-- Both arguments are positional and optional; with none given, connects to
7-- a public live test feed and stops after 5 events.
8-----------------------------------------------------------------------------
9local http = require("socket.http")
10local sse = require("socket.sse")
11
12-- default target: Wikimedia's public, continuously-streaming recent-changes
13-- feed -- a real, live text/event-stream endpoint, handy for trying out
14-- socket.sse without standing up a server of your own
15local DEFAULT_URL = "https://stream.wikimedia.org/v2/stream/recentchange"
16local DEFAULT_LIMIT = 5
17
18-- shortens a long data payload for readable terminal output
19local function preview(text, limit)
20 limit = limit or 100
21 if #text > limit then return string.sub(text, 1, limit) .. "..." end
22 return text
23end
24
25-- builds a message callback that prints each received Message, then stops
26-- the stream once "limit" of them have been printed. This module parses a
27-- single request/response and never reconnects on its own (see
28-- docs/adr/0001-sse-single-shot-no-auto-reconnect.md), so a caller who wants
29-- to stop early just does what we do here: return an error from the
30-- callback, which ends the request the same way any sink error would
31local function makeprinter(limit)
32 local count = 0
33 return function(message)
34 count = count + 1
35 io.write(string.format("[%d] event=%s id=%s\n data=%s\n",
36 count, message.event, tostring(message.id), preview(message.data)))
37 if count >= limit then
38 return nil, string.format("stopped after %d events", limit)
39 end
40 return 1
41 end
42end
43
44-- main program
45arg = arg or {}
46local url = arg[1] or DEFAULT_URL
47local limit = tonumber(arg[2]) or DEFAULT_LIMIT
48
49io.write("connecting to ", url, " (stopping after ", limit, " events)\n")
50local ok, code = http.request{
51 url = url,
52 headers_callback = sse.responseheaders(makeprinter(limit))
53}
54
55if ok then
56 -- the server closed the connection on its own before we hit our limit
57 io.write("connection closed by server, code=", tostring(code), "\n")
58else
59 -- once the callback above returns an error, http.request reports it the
60 -- same way it reports any failure: (nil, message) -- so "code" here is
61 -- really our own "stopped after N events" message, not a real error
62 io.write(code, "\n")
63end
diff --git a/src/makefile b/src/makefile
index 310fe4e..a6ba91e 100644
--- a/src/makefile
+++ b/src/makefile
@@ -375,7 +375,8 @@ TO_SOCKET_LDIR= \
375 tp.lua \ 375 tp.lua \
376 ftp.lua \ 376 ftp.lua \
377 headers.lua \ 377 headers.lua \
378 smtp.lua 378 smtp.lua \
379 sse.lua
379 380
380TO_TOP_LDIR= \ 381TO_TOP_LDIR= \
381 ltn12.lua \ 382 ltn12.lua \
diff --git a/src/sse.lua b/src/sse.lua
new file mode 100644
index 0000000..fa77427
--- /dev/null
+++ b/src/sse.lua
@@ -0,0 +1,193 @@
1-----------------------------------------------------------------------------
2-- Server-Sent Events (SSE) parsing support for the Lua language.
3-- LuaSocket toolkit.
4-----------------------------------------------------------------------------
5
6-----------------------------------------------------------------------------
7-- Declare module and import dependencies
8-----------------------------------------------------------------------------
9local socket = require("socket")
10local string = require("string")
11socket.sse = {}
12local _M = socket.sse
13
14-----------------------------------------------------------------------------
15-- Program constants
16-----------------------------------------------------------------------------
17-- maximum size of a single SSE line
18_M.MAXLINESIZE = 8192
19-- maximum total size of all lines making up a single event/comment block
20_M.MAXEVENTSIZE = 65536
21
22-----------------------------------------------------------------------------
23-- Strips a single leading space, per the SSE field-value trimming rule
24-----------------------------------------------------------------------------
25local function striponeleadingspace(value)
26 if string.sub(value, 1, 1) == " " then return string.sub(value, 2) end
27 return value
28end
29
30-----------------------------------------------------------------------------
31-- Parses one SSE field line ("name: value", "name:value" or "name") into
32-- its name/value pair
33-----------------------------------------------------------------------------
34local function parsefield(line)
35 local colon = string.find(line, ":", 1, true)
36 if not colon then return line, "" end
37 local name = string.sub(line, 1, colon - 1)
38 local value = striponeleadingspace(string.sub(line, colon + 1))
39 return name, value
40end
41
42-----------------------------------------------------------------------------
43-- Builds a Parser sink: an ltn12 sink that consumes raw SSE response bytes
44-- and dispatches parsed Messages (and, if enabled, Comments) to msgsink, a
45-- Message sink. config is an optional table:
46-- comments: boolean, dispatch Comments to msgsink when true (default false)
47-- context: caller-owned table that last_event_id/retry get written into
48-- as parsing progresses
49-----------------------------------------------------------------------------
50function _M.parser(msgsink, config)
51 config = config or {}
52 local comments = config.comments
53 local context = config.context or {}
54
55 local linebuffer = ""
56 local eventsize = 0
57
58 -- id persists across Messages that omit their own id: line, per spec,
59 -- so it lives outside reset(); retry is a stream-level reconnection
60 -- hint with no per-Message meaning at all, so it is tracked only on
61 -- context.retry, never as event-local state
62 local id
63 local eventtype, data, hasdata
64
65 local function reset()
66 eventtype = nil
67 data = nil
68 hasdata = false
69 eventsize = 0
70 end
71 reset()
72
73 -- dispatches the current Message, if any data was accumulated for it,
74 -- then resets event-local state for the next one
75 local function dispatch()
76 if not hasdata then
77 reset()
78 return 1
79 end
80 local message = {
81 event = eventtype or "message",
82 data = data,
83 id = id,
84 }
85 reset()
86 local ok, err = msgsink(message)
87 if not ok then return nil, err end
88 return 1
89 end
90
91 local function processline(line)
92 if line == "" then return dispatch() end
93 if string.sub(line, 1, 1) == ":" then
94 if comments then
95 local text = striponeleadingspace(string.sub(line, 2))
96 local ok, err = msgsink({ comment = text })
97 if not ok then return nil, err end
98 end
99 return 1
100 end
101 local name, value = parsefield(line)
102 if name == "event" then
103 eventtype = value
104 elseif name == "data" then
105 data = hasdata and (data .. "\n" .. value) or value
106 hasdata = true
107 elseif name == "id" then
108 if not string.find(value, "\0", 1, true) then
109 id = value
110 context.last_event_id = id
111 end
112 elseif name == "retry" then
113 if string.find(value, "^%d+$") then
114 context.retry = tonumber(value)
115 end
116 end
117 return 1
118 end
119
120 return function(chunk, err)
121 if not chunk then
122 if err then return nil, err end
123 return 1
124 end
125 linebuffer = linebuffer .. chunk
126 while true do
127 local nl = string.find(linebuffer, "\n", 1, true)
128 if not nl then
129 if #linebuffer > _M.MAXLINESIZE then return nil, "oversized" end
130 break
131 end
132 local line = string.sub(linebuffer, 1, nl - 1)
133 linebuffer = string.sub(linebuffer, nl + 1)
134 if #line > _M.MAXLINESIZE then return nil, "oversized" end
135 if string.sub(line, -1) == "\r" then line = string.sub(line, 1, -2) end
136 eventsize = eventsize + #line + 1
137 if eventsize > _M.MAXEVENTSIZE then return nil, "oversized" end
138 local ok, procerr = processline(line)
139 if not ok then return nil, procerr end
140 end
141 return 1
142 end
143end
144
145-----------------------------------------------------------------------------
146-- The media type this module activates on, ignoring Content-Type parameters
147-----------------------------------------------------------------------------
148_M.EVENTSTREAMTYPE = "text/event-stream"
149
150-----------------------------------------------------------------------------
151-- Extracts the media type portion of a Content-Type header value, dropping
152-- any trailing parameters (e.g. "; charset=utf-8") and normalizing case
153-----------------------------------------------------------------------------
154local function mediatype(contenttype)
155 local mt = string.match(contenttype or "", "^%s*([^;%s]*)")
156 return string.lower(mt or "")
157end
158
159-----------------------------------------------------------------------------
160-- Wraps a plain function(message) ... end callback into a Message sink, so
161-- it can be used anywhere one is expected. Follows the same contract as any
162-- ltn12 sink: the callback must return a truthy value to signal success;
163-- any falsy return is an error, propagated as-is (even with no err message)
164-- rather than swallowed. A raw sink passed in here already speaks that
165-- contract, so wrapping it is a no-op.
166-----------------------------------------------------------------------------
167function _M.callbacksink(callback)
168 return function(message)
169 local ok, err = callback(message)
170 if not ok then return nil, err end
171 return 1
172 end
173end
174
175-----------------------------------------------------------------------------
176-- Builds a function suitable for reqt.headers_callback: given (code,
177-- headers, status), checks headers["content-type"] for the
178-- text/event-stream media type (ignoring trailing parameters) and, only on
179-- a match, offers a sink chaining the Parser sink (see _M.parser) to
180-- msgsink -- a Message sink or a plain callback, per _M.callbacksink -- as
181-- the request's sink. On no match, declines by returning true with no
182-- sink, leaving http.request's own sink handling untouched.
183-----------------------------------------------------------------------------
184function _M.responseheaders(msgsink, config)
185 return function(code, headers, status)
186 if not headers or mediatype(headers["content-type"]) ~= _M.EVENTSTREAMTYPE then
187 return true
188 end
189 return true, _M.parser(_M.callbacksink(msgsink), config)
190 end
191end
192
193return _M
diff --git a/test/httpfixture.lua b/test/httpfixture.lua
index 3ea0737..14c7548 100644
--- a/test/httpfixture.lua
+++ b/test/httpfixture.lua
@@ -49,18 +49,12 @@ local function quote(s)
49 return "\"" .. s .. "\"" 49 return "\"" .. s .. "\""
50end 50end
51 51
52-- Scripts the server to accept one connection (into the shared `data` 52-- Appends the script parts for one accept-send-close cycle (see
53-- global, mirroring test/testclnt.lua's reconnect()), write a sequence of 53-- accept_and_send_sequence) onto `parts`.
54-- raw byte chunks to it, then close it. `chunks` is an array of either 54local function append_connection(parts, chunks)
55-- plain strings, or {body, delay = seconds} tables -- the delay (via 55 parts[#parts + 1] = "if data then data:close() data = nil end"
56-- socket.sleep) is applied before sending that chunk, so callers can prove 56 parts[#parts + 1] = "data = server:accept()"
57-- incremental/partial delivery instead of one atomic send. 57 parts[#parts + 1] = "data:setoption(\"tcp-nodelay\", true)"
58function M.accept_and_send(remote, chunks)
59 local parts = {
60 "if data then data:close() data = nil end",
61 "data = server:accept()",
62 "data:setoption(\"tcp-nodelay\", true)",
63 }
64 for _, chunk in ipairs(chunks) do 58 for _, chunk in ipairs(chunks) do
65 local body, delay 59 local body, delay
66 if type(chunk) == "table" then 60 if type(chunk) == "table" then
@@ -74,6 +68,35 @@ function M.accept_and_send(remote, chunks)
74 parts[#parts + 1] = "data:send(" .. quote(body) .. ")" 68 parts[#parts + 1] = "data:send(" .. quote(body) .. ")"
75 end 69 end
76 parts[#parts + 1] = "data:close() data = nil" 70 parts[#parts + 1] = "data:close() data = nil"
71end
72
73-- Scripts the server to accept one connection (into the shared `data`
74-- global, mirroring test/testclnt.lua's reconnect()), write a sequence of
75-- raw byte chunks to it, then close it. `chunks` is an array of either
76-- plain strings, or {body, delay = seconds} tables -- the delay (via
77-- socket.sleep) is applied before sending that chunk, so callers can prove
78-- incremental/partial delivery instead of one atomic send.
79function M.accept_and_send(remote, chunks)
80 local parts = {}
81 append_connection(parts, chunks)
82 remote(table.concat(parts, "\n"))
83end
84
85-- Like accept_and_send, but scripts several accept-send-close cycles as one
86-- server-side script sent over a single remote() round trip. Needed for a
87-- client call that opens more than one connection in sequence within a
88-- single call of its own (e.g. socket.http.request following a redirect):
89-- queuing a second accept_and_send for that connection ahead of time would
90-- deadlock, since its remote() call can't get an ack until the server
91-- finishes the first accept_and_send's blocking accept() -- which itself
92-- can't complete until the client makes the very call that's stuck waiting
93-- on that ack. `connections` is an array of `chunks` arrays, one per
94-- connection, handled in order.
95function M.accept_and_send_sequence(remote, connections)
96 local parts = {}
97 for _, chunks in ipairs(connections) do
98 append_connection(parts, chunks)
99 end
77 remote(table.concat(parts, "\n")) 100 remote(table.concat(parts, "\n"))
78end 101end
79 102
diff --git a/test/ssehttptest.lua b/test/ssehttptest.lua
new file mode 100644
index 0000000..b4ba6bc
--- /dev/null
+++ b/test/ssehttptest.lua
@@ -0,0 +1,151 @@
1-- End-to-end coverage proving socket.http.request, the headers_callback
2-- hook, and socket.sse cooperate correctly over a real socket -- using the
3-- httpfixture.lua harness (see httpfixturetest.lua) instead of Apache or a
4-- third-party host. Also covers the two headers_callback interactions that
5-- only matter once other request-handling logic (redirects,
6-- shouldreceivebody) is in the path.
7local socket = require("socket")
8local http = require("socket.http")
9local ltn12 = require("ltn12")
10local sse = require("socket.sse")
11local fixture = require("httpfixture")
12
13dofile("testsupport.lua")
14
15local url = "http://" .. fixture.host .. ":" .. fixture.port .. "/"
16
17local control, remote = fixture.connect()
18
19io.write("testing a live SSE stream dispatches Messages incrementally, not batched until close: ")
20do
21 local received, elapsed = {}, {}
22 local t0
23 local factory = sse.responseheaders(function(message)
24 table.insert(received, message)
25 table.insert(elapsed, socket.gettime() - t0)
26 return true
27 end)
28
29 -- Framed as HTTP chunked-transfer-encoding, one SSE Message per HTTP
30 -- chunk: the "until-closed"/"default" body source reads in fixed
31 -- socket.BLOCKSIZE (2048-byte) gulps, which would silently buffer both
32 -- of these small Messages into one read and defeat this test; the
33 -- chunked source instead reads exactly each chunk's declared size, so
34 -- Message 1 surfaces as soon as its chunk arrives, independent of
35 -- Message 2's delayed chunk.
36 local function httpchunk(piece)
37 return string.format("%x\r\n%s\r\n", #piece, piece)
38 end
39
40 -- generous relative to the artificial delay so a loaded CI runner's
41 -- scheduling jitter can't turn correct incremental behavior into a
42 -- flaky failure; the delay is deliberately long enough that "arrived
43 -- before it" and "arrived after it" stay unambiguous even with slack
44 local delay = 1.0
45 local headersblock = fixture.response("200 OK", {
46 "Content-Type: text/event-stream",
47 "Transfer-Encoding: chunked",
48 "Connection: close",
49 }, "")
50 fixture.accept_and_send(remote, {
51 headersblock,
52 httpchunk("data: first\n\n"),
53 { httpchunk("data: second\n\n"), delay = delay },
54 "0\r\n\r\n",
55 })
56
57 t0 = socket.gettime()
58 local ok, code = assert(http.request{ url = url .. "sse", headers_callback = factory })
59 assert(ok, "request failed")
60 assert(code == 200, "status code mismatch: " .. tostring(code))
61 assert(#received == 2, "expected two messages, got " .. #received)
62 assert(received[1].data == "first", "wrong data for message 1: " .. tostring(received[1].data))
63 assert(received[2].data == "second", "wrong data for message 2: " .. tostring(received[2].data))
64 -- message 1 must have been dispatched well before the server even sent
65 -- (let alone finished delaying) message 2 -- if delivery were batched
66 -- until the connection closed instead, both messages would only appear
67 -- once the full delay had elapsed
68 assert(elapsed[1] < delay / 2,
69 "message 1 dispatched too late (" .. elapsed[1] .. "s) to have arrived before message 2's delayed send")
70 assert(elapsed[2] >= delay / 2,
71 "message 2 dispatched suspiciously early (" .. elapsed[2] .. "s); the server's artificial delay may not have been exercised")
72end
73print("ok")
74
75io.write("testing headers_callback is not invoked for a redirected (3xx) response: ")
76do
77 local calls = {}
78 local function responseheaders(code)
79 table.insert(calls, code)
80 return true
81 end
82
83 local redirect = fixture.response("302 Found", {
84 "Location: /final",
85 "Content-Length: 0",
86 "Connection: close",
87 }, "")
88 local finalbody = "landed"
89 local final = fixture.response("200 OK", {
90 "Content-Length: " .. #finalbody,
91 "Connection: close",
92 }, finalbody)
93 fixture.accept_and_send_sequence(remote, { { redirect }, { final } })
94
95 local target = {}
96 local ok, code = assert(http.request{
97 url = url .. "redirect-me",
98 sink = ltn12.sink.table(target),
99 headers_callback = responseheaders,
100 })
101 assert(ok, "request failed")
102 assert(table.concat(target) == finalbody, "expected the redirected response's body")
103 assert(code == 200, "expected the final response's status code, got " .. tostring(code))
104 assert(#calls == 1, "expected headers_callback to be invoked exactly once, got " .. #calls)
105 assert(calls[1] == 200, "headers_callback must only ever see the final response, got " .. tostring(calls[1]))
106end
107print("ok")
108
109-- Shared by the three shouldreceivebody variants below (204, 304, HEAD):
110-- scripts `response`, issues a request built from `reqtextra`, and asserts
111-- headers_callback still fires with `expectedcode` while the sink it offers
112-- is never actually invoked, since shouldreceivebody skips the body.
113local function assertsinkswapskipped(reqtextra, response, expectedcode)
114 local sinkcalls, invokedwith = 0, nil
115 local function responseheaders(code)
116 invokedwith = code
117 return true, function() sinkcalls = sinkcalls + 1; return 1 end
118 end
119 fixture.accept_and_send(remote, { response })
120
121 local reqt = { url = url .. "skip-body", headers_callback = responseheaders }
122 for k, v in pairs(reqtextra or {}) do reqt[k] = v end
123 local ok, code = assert(http.request(reqt))
124 assert(ok, "request failed")
125 assert(code == expectedcode, "status code mismatch: " .. tostring(code))
126 assert(invokedwith == expectedcode, "expected headers_callback to still be invoked, got " .. tostring(invokedwith))
127 assert(sinkcalls == 0, "the offered sink must never run when shouldreceivebody skips the body")
128end
129
130io.write("testing the headers_callback sink-swap offer is inert when a 204 response skips the body: ")
131assertsinkswapskipped(nil, fixture.response("204 No Content", { "Connection: close" }, ""), 204)
132print("ok")
133
134io.write("testing the headers_callback sink-swap offer is inert when a 304 response skips the body: ")
135assertsinkswapskipped(nil, fixture.response("304 Not Modified", { "Connection: close" }, ""), 304)
136print("ok")
137
138io.write("testing the headers_callback sink-swap offer is inert when a HEAD request skips the body: ")
139do
140 local body = "should never be read"
141 local response = fixture.response("200 OK", {
142 "Content-Length: " .. #body,
143 "Connection: close",
144 }, body)
145 assertsinkswapskipped({ method = "HEAD" }, response, 200)
146end
147print("ok")
148
149remote("os.exit()")
150
151print("the library passed all tests")
diff --git a/test/ssetest.lua b/test/ssetest.lua
new file mode 100644
index 0000000..03206ba
--- /dev/null
+++ b/test/ssetest.lua
@@ -0,0 +1,370 @@
1local socket = require("socket")
2local sse = require("socket.sse")
3
4dofile("testsupport.lua")
5
6-- collects everything a parser sink dispatches to it (Messages and, when
7-- enabled, Comments) into an ordered list; the "collect" name signals it
8-- passes through unchanged rather than transforming
9local function collect()
10 local list = {}
11 local snk = function(item, err)
12 if err then return nil, err end
13 table.insert(list, item)
14 return 1
15 end
16 return snk, list
17end
18
19-- feeds a parser sink the given raw chunks, then signals end of stream
20local function feed(parser, ...)
21 for _, chunk in ipairs({...}) do
22 local ok, err = parser(chunk)
23 if not ok then return nil, err end
24 end
25 return parser(nil)
26end
27
28--------------------------------
29io.write("testing event/data/id parsing on a single Message, retry line consumed but not attached: ")
30do
31 local snk, list = collect()
32 local parser = sse.parser(snk)
33 local ok = feed(parser,
34 "event: greeting\r\n" ..
35 "data: hello\r\n" ..
36 "id: 1\r\n" ..
37 "retry: 3000\r\n" ..
38 "\r\n")
39 assert(ok, "parser returned error")
40 assert(#list == 1, "expected exactly one message")
41 assert(list[1].event == "greeting", "wrong event")
42 assert(list[1].data == "hello", "wrong data")
43 assert(list[1].id == "1", "wrong id")
44 assert(list[1].retry == nil, "Message must not carry a retry field; retry only lives on the context table")
45 print("ok")
46end
47
48--------------------------------
49io.write("testing multiple data: lines are joined with \\n: ")
50do
51 local snk, list = collect()
52 local parser = sse.parser(snk)
53 local ok = feed(parser, "data: line one\ndata: line two\ndata: line three\n\n")
54 assert(ok, "parser returned error")
55 assert(#list == 1, "expected exactly one message")
56 assert(list[1].data == "line one\nline two\nline three", "data not joined correctly: " .. list[1].data)
57 print("ok")
58end
59
60--------------------------------
61io.write("testing event defaults to 'message' when omitted: ")
62do
63 local snk, list = collect()
64 local parser = sse.parser(snk)
65 local ok = feed(parser, "data: no event here\n\n")
66 assert(ok, "parser returned error")
67 assert(#list == 1, "expected exactly one message")
68 assert(list[1].event == "message", "expected default event type, got " .. tostring(list[1].event))
69 print("ok")
70end
71
72--------------------------------
73io.write("testing id persists forward onto later Messages: ")
74do
75 local snk, list = collect()
76 local parser = sse.parser(snk)
77 local ok = feed(parser,
78 "id: abc\ndata: first\n\n" ..
79 "data: second\n\n" ..
80 "id: def\ndata: third\n\n" ..
81 "data: fourth\n\n")
82 assert(ok, "parser returned error")
83 assert(#list == 4, "expected four messages, got " .. #list)
84 assert(list[1].id == "abc", "message 1 id")
85 assert(list[2].id == "abc", "message 2 id should persist from message 1")
86 assert(list[3].id == "def", "message 3 id")
87 assert(list[4].id == "def", "message 4 id should persist from message 3")
88 print("ok")
89end
90
91--------------------------------
92io.write("testing comment lines are silently absorbed when comments is off: ")
93do
94 local snk, list = collect()
95 local parser = sse.parser(snk)
96 local ok = feed(parser, ": this is a comment\ndata: real message\n\n")
97 assert(ok, "parser returned error")
98 assert(#list == 1, "expected only the message, comment should be absorbed")
99 assert(list[1].data == "real message", "wrong data")
100 print("ok")
101end
102
103--------------------------------
104io.write("testing comment lines are dispatched distinctly when comments is on: ")
105do
106 local snk, list = collect()
107 local parser = sse.parser(snk, { comments = true })
108 local ok = feed(parser, ": keep-alive\ndata: real message\n\n")
109 assert(ok, "parser returned error")
110 assert(#list == 2, "expected comment and message, got " .. #list)
111 assert(list[1].comment == "keep-alive", "wrong comment text: " .. tostring(list[1].comment))
112 assert(list[1].event == nil, "comment must not look like a message")
113 assert(list[2].data == "real message", "wrong data")
114 print("ok")
115end
116
117--------------------------------
118io.write("testing context table gets last_event_id/retry written and retains them after parsing ends: ")
119do
120 local snk = collect()
121 local context = {}
122 local parser = sse.parser(snk, { context = context })
123 local ok = feed(parser, "id: xyz\nretry: 5000\ndata: hi\n\n")
124 assert(ok, "parser returned error")
125 assert(context.last_event_id == "xyz", "context.last_event_id not written")
126 assert(context.retry == 5000, "context.retry not written")
127 print("ok")
128end
129
130--------------------------------
131io.write("testing context.retry persists across later events that don't repeat it, and no Message ever carries a retry field: ")
132do
133 local snk, list = collect()
134 local context = {}
135 local parser = sse.parser(snk, { context = context })
136 local ok = feed(parser,
137 "retry: 2000\ndata: first\n\n" ..
138 "data: second\n\n")
139 assert(ok, "parser returned error")
140 assert(#list == 2, "expected two messages, got " .. #list)
141 assert(list[1].retry == nil, "message 1 must not carry a retry field")
142 assert(list[2].retry == nil, "message 2 must not carry a retry field")
143 assert(context.retry == 2000, "context.retry should still reflect the most recent retry hint after a later event that didn't repeat it")
144 print("ok")
145end
146
147--------------------------------
148io.write("testing an oversized line produces a distinct error and aborts: ")
149do
150 local snk, list = collect()
151 local parser = sse.parser(snk)
152 local huge = string.rep("x", sse.MAXLINESIZE + 1)
153 local ok, err = feed(parser, "data: " .. huge .. "\n\n")
154 assert(not ok, "expected parser to fail on oversized line")
155 assert(err == "oversized", "expected 'oversized' error, got " .. tostring(err))
156 assert(#list == 0, "no message should have been dispatched")
157 print("ok")
158end
159
160--------------------------------
161io.write("testing an oversized event (many lines under MAXEVENTSIZE total) produces a distinct error: ")
162do
163 local snk, list = collect()
164 local parser = sse.parser(snk)
165 local line = "data: " .. string.rep("y", 100) .. "\n"
166 local lines = string.rep(line, math.ceil(sse.MAXEVENTSIZE / #line) + 1)
167 local ok, err = feed(parser, lines)
168 assert(not ok, "expected parser to fail on oversized event")
169 assert(err == "oversized", "expected 'oversized' error, got " .. tostring(err))
170 assert(#list == 0, "no message should have been dispatched")
171 print("ok")
172end
173
174--------------------------------
175io.write("testing a field split across two raw-byte chunks is parsed correctly: ")
176do
177 local snk, list = collect()
178 local parser = sse.parser(snk)
179 -- split mid field-name, mid value, and mid line-terminator
180 local ok = feed(parser, "eve", "nt: greet", "ing\ndata: hel", "lo\r", "\n\r\n")
181 assert(ok, "parser returned error")
182 assert(#list == 1, "expected exactly one message, got " .. #list)
183 assert(list[1].event == "greeting", "wrong event: " .. tostring(list[1].event))
184 assert(list[1].data == "hello", "wrong data: " .. tostring(list[1].data))
185 print("ok")
186end
187
188--------------------------------
189io.write("testing a Message-sink error aborts the parser sink chain: ")
190do
191 local calls = 0
192 local snk = function(message)
193 calls = calls + 1
194 if calls == 1 then return 1 end
195 return nil, "sink refused message"
196 end
197 local parser = sse.parser(snk)
198 local ok, err = feed(parser, "data: first\n\ndata: second\n\n")
199 assert(not ok, "expected parser to propagate message sink error")
200 assert(err == "sink refused message", "unexpected error: " .. tostring(err))
201 assert(calls == 2, "expected the sink to be invoked twice")
202 print("ok")
203end
204
205--------------------------------
206io.write("testing a Message with no data: line is not dispatched: ")
207do
208 local snk, list = collect()
209 local parser = sse.parser(snk)
210 local ok = feed(parser, "event: ping\nid: 1\n\ndata: real\n\n")
211 assert(ok, "parser returned error")
212 assert(#list == 1, "the dataless block should not have produced a message")
213 assert(list[1].data == "real", "wrong data")
214 print("ok")
215end
216
217--------------------------------
218io.write("testing callbacksink lets a plain callback act as a message sink: ")
219do
220 local received = {}
221 local callback = function(message) table.insert(received, message); return true end
222 local snk = sse.callbacksink(callback)
223 local parser = sse.parser(snk)
224 local ok = feed(parser, "data: first\n\ndata: second\n\n")
225 assert(ok, "parser returned error")
226 assert(#received == 2, "expected two messages, got " .. #received)
227 assert(received[1].data == "first", "wrong data")
228 assert(received[2].data == "second", "wrong data")
229 print("ok")
230end
231
232--------------------------------
233io.write("testing callbacksink propagates an error returned by the callback: ")
234do
235 local calls = 0
236 local callback = function(message)
237 calls = calls + 1
238 if message.data == "bad" then return nil, "callback refused" end
239 return true
240 end
241 local snk = sse.callbacksink(callback)
242 local parser = sse.parser(snk)
243 local ok, err = feed(parser, "data: good\n\ndata: bad\n\ndata: unreachable\n\n")
244 assert(not ok, "expected callback error to abort the chain")
245 assert(err == "callback refused", "unexpected error: " .. tostring(err))
246 assert(calls == 2, "expected the callback to stop being invoked after it errors, got " .. calls)
247 print("ok")
248end
249
250--------------------------------
251io.write("testing callbacksink does not swallow a sink that fails with a falsy ok and no error message: ")
252do
253 local calls = 0
254 local rawsink = function(message)
255 calls = calls + 1
256 if message.data == "bad" then return false end
257 return 1
258 end
259 local snk = sse.callbacksink(rawsink)
260 local parser = sse.parser(snk)
261 local ok, err = feed(parser, "data: good\n\ndata: bad\n\ndata: unreachable\n\n")
262 assert(not ok, "expected the falsy ok to abort the chain even without an error message")
263 assert(err == nil, "no error message was given, so none should be invented")
264 assert(calls == 2, "expected the sink to stop being invoked once it fails, got " .. calls)
265 print("ok")
266end
267
268--------------------------------
269io.write("testing callbacksink treats a callback that returns nothing as an error, not a default success: ")
270do
271 local snk = sse.callbacksink(function(message) end)
272 local parser = sse.parser(snk)
273 local ok = feed(parser, "data: hi\n\n")
274 assert(not ok, "a callback must explicitly return truthy to signal success, matching a raw sink's contract")
275 print("ok")
276end
277
278--------------------------------
279io.write("testing responseheaders forwards its config (context/comments) to the parser: ")
280do
281 local received = {}
282 local context = {}
283 local factory = sse.responseheaders(
284 function(message) table.insert(received, message); return true end,
285 { comments = true, context = context })
286 local ok, sink = factory(200, { ["content-type"] = "text/event-stream" }, "HTTP/1.1 200 OK")
287 assert(ok, "expected success")
288 local fed = feed(sink, ": keep-alive\nid: xyz\ndata: hello\n\n")
289 assert(fed, "sink chain returned an error")
290 assert(#received == 2, "expected the comment and the message, got " .. #received)
291 assert(received[1].comment == "keep-alive", "comments config was not forwarded to the parser")
292 assert(context.last_event_id == "xyz", "context table was not forwarded to the parser")
293 print("ok")
294end
295
296--------------------------------
297io.write("testing responseheaders installs the sink chain on a matching Content-Type: ")
298do
299 local received = {}
300 local factory = sse.responseheaders(function(message) table.insert(received, message); return true end)
301 local reqt = {}
302 reqt.headers_callback = factory
303 local ok, sink = reqt.headers_callback(200, { ["content-type"] = "text/event-stream" }, "HTTP/1.1 200 OK")
304 assert(ok, "expected success on a matching content-type")
305 assert(type(sink) == "function", "expected a sink function on a matching content-type")
306 reqt.sink = sink
307 local fed = feed(reqt.sink, "data: hello\n\n")
308 assert(fed, "sink chain returned an error")
309 assert(#received == 1, "expected one message dispatched through the installed sink")
310 assert(received[1].data == "hello", "wrong data")
311 print("ok")
312end
313
314--------------------------------
315io.write("testing responseheaders also accepts a raw ltn12-style message sink directly: ")
316do
317 local received = {}
318 -- a raw sink following full ltn12 protocol: truthy on success, (nil, err) on failure
319 local rawsink = function(message)
320 table.insert(received, message)
321 return 1
322 end
323 local factory = sse.responseheaders(rawsink)
324 local ok, sink = factory(200, { ["content-type"] = "text/event-stream" }, "HTTP/1.1 200 OK")
325 assert(ok, "expected success")
326 local fed = feed(sink, "data: raw\n\n")
327 assert(fed, "sink chain returned an error")
328 assert(#received == 1, "expected one message")
329 assert(received[1].data == "raw", "wrong data")
330 print("ok")
331end
332
333--------------------------------
334io.write("testing responseheaders matches a Content-Type with trailing parameters: ")
335do
336 local factory = sse.responseheaders(function() end)
337 local ok, sink = factory(200, { ["content-type"] = "text/event-stream; charset=utf-8" }, "HTTP/1.1 200 OK")
338 assert(ok, "expected success")
339 assert(type(sink) == "function", "expected a sink for a parameterized but matching content-type")
340 print("ok")
341end
342
343--------------------------------
344io.write("testing responseheaders declines cleanly on a non-matching Content-Type: ")
345do
346 local calledback = false
347 local factory = sse.responseheaders(function() calledback = true end)
348 local reqt = { sink = "original sink placeholder" }
349 local ok, sink = factory(200, { ["content-type"] = "text/html" }, "HTTP/1.1 200 OK")
350 assert(ok, "a non-matching content-type must still succeed (just decline the sink swap)")
351 assert(sink == nil, "a non-matching content-type must not offer a sink swap")
352 -- mirror what http.lua does with the factory's return values, to prove
353 -- the caller's existing sink survives untouched
354 if sink then reqt.sink = sink end
355 assert(reqt.sink == "original sink placeholder", "existing sink must be left untouched")
356 assert(not calledback, "message callback must not fire for a non-matching content-type")
357 print("ok")
358end
359
360--------------------------------
361io.write("testing responseheaders declines cleanly when Content-Type is missing: ")
362do
363 local factory = sse.responseheaders(function() end)
364 local ok, sink = factory(200, {}, "HTTP/1.1 200 OK")
365 assert(ok, "missing content-type must still succeed (just decline the sink swap)")
366 assert(sink == nil, "missing content-type must not offer a sink swap")
367 print("ok")
368end
369
370print("the library passed all tests")