diff options
Diffstat (limited to '')
| -rw-r--r-- | .github/workflows/build.yml | 3 | ||||
| -rw-r--r-- | luasocket-scm-3.rockspec | 1 | ||||
| -rw-r--r-- | samples/README | 9 | ||||
| -rw-r--r-- | samples/sse.lua | 63 | ||||
| -rw-r--r-- | src/makefile | 3 | ||||
| -rw-r--r-- | src/sse.lua | 193 | ||||
| -rw-r--r-- | test/httpfixture.lua | 47 | ||||
| -rw-r--r-- | test/ssehttptest.lua | 151 | ||||
| -rw-r--r-- | test/ssetest.lua | 370 |
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 | |||
| 104 | samples directory. Just run 'lua lpr.lua <filename> | 104 | samples directory. Just run 'lua lpr.lua <filename> |
| 105 | queue=<printername>' and the file will print! | 105 | queue=<printername>' and the file will print! |
| 106 | 106 | ||
| 107 | sse.lua -- Server-Sent Events (SSE) client | ||
| 108 | |||
| 109 | This little program uses socket.sse to connect to a text/event-stream | ||
| 110 | URL and print each event received, stopping after a fixed number of | ||
| 111 | them (5 by default). With no arguments it connects to a public live | ||
| 112 | test 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 | ||
| 109 | This is the first try on a simple CDDB client. Not really | 118 | This 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 | ----------------------------------------------------------------------------- | ||
| 9 | local http = require("socket.http") | ||
| 10 | local 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 | ||
| 15 | local DEFAULT_URL = "https://stream.wikimedia.org/v2/stream/recentchange" | ||
| 16 | local DEFAULT_LIMIT = 5 | ||
| 17 | |||
| 18 | -- shortens a long data payload for readable terminal output | ||
| 19 | local function preview(text, limit) | ||
| 20 | limit = limit or 100 | ||
| 21 | if #text > limit then return string.sub(text, 1, limit) .. "..." end | ||
| 22 | return text | ||
| 23 | end | ||
| 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 | ||
| 31 | local 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 | ||
| 42 | end | ||
| 43 | |||
| 44 | -- main program | ||
| 45 | arg = arg or {} | ||
| 46 | local url = arg[1] or DEFAULT_URL | ||
| 47 | local limit = tonumber(arg[2]) or DEFAULT_LIMIT | ||
| 48 | |||
| 49 | io.write("connecting to ", url, " (stopping after ", limit, " events)\n") | ||
| 50 | local ok, code = http.request{ | ||
| 51 | url = url, | ||
| 52 | headers_callback = sse.responseheaders(makeprinter(limit)) | ||
| 53 | } | ||
| 54 | |||
| 55 | if 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") | ||
| 58 | else | ||
| 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") | ||
| 63 | end | ||
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 | ||
| 380 | TO_TOP_LDIR= \ | 381 | TO_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 | ----------------------------------------------------------------------------- | ||
| 9 | local socket = require("socket") | ||
| 10 | local string = require("string") | ||
| 11 | socket.sse = {} | ||
| 12 | local _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 | ----------------------------------------------------------------------------- | ||
| 25 | local function striponeleadingspace(value) | ||
| 26 | if string.sub(value, 1, 1) == " " then return string.sub(value, 2) end | ||
| 27 | return value | ||
| 28 | end | ||
| 29 | |||
| 30 | ----------------------------------------------------------------------------- | ||
| 31 | -- Parses one SSE field line ("name: value", "name:value" or "name") into | ||
| 32 | -- its name/value pair | ||
| 33 | ----------------------------------------------------------------------------- | ||
| 34 | local 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 | ||
| 40 | end | ||
| 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 | ----------------------------------------------------------------------------- | ||
| 50 | function _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 | ||
| 143 | end | ||
| 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 | ----------------------------------------------------------------------------- | ||
| 154 | local function mediatype(contenttype) | ||
| 155 | local mt = string.match(contenttype or "", "^%s*([^;%s]*)") | ||
| 156 | return string.lower(mt or "") | ||
| 157 | end | ||
| 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 | ----------------------------------------------------------------------------- | ||
| 167 | function _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 | ||
| 173 | end | ||
| 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 | ----------------------------------------------------------------------------- | ||
| 184 | function _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 | ||
| 191 | end | ||
| 192 | |||
| 193 | return _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 .. "\"" |
| 50 | end | 50 | end |
| 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 | 54 | local 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)" |
| 58 | function 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" |
| 71 | end | ||
| 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. | ||
| 79 | function M.accept_and_send(remote, chunks) | ||
| 80 | local parts = {} | ||
| 81 | append_connection(parts, chunks) | ||
| 82 | remote(table.concat(parts, "\n")) | ||
| 83 | end | ||
| 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. | ||
| 95 | function 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")) |
| 78 | end | 101 | end |
| 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. | ||
| 7 | local socket = require("socket") | ||
| 8 | local http = require("socket.http") | ||
| 9 | local ltn12 = require("ltn12") | ||
| 10 | local sse = require("socket.sse") | ||
| 11 | local fixture = require("httpfixture") | ||
| 12 | |||
| 13 | dofile("testsupport.lua") | ||
| 14 | |||
| 15 | local url = "http://" .. fixture.host .. ":" .. fixture.port .. "/" | ||
| 16 | |||
| 17 | local control, remote = fixture.connect() | ||
| 18 | |||
| 19 | io.write("testing a live SSE stream dispatches Messages incrementally, not batched until close: ") | ||
| 20 | do | ||
| 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") | ||
| 72 | end | ||
| 73 | print("ok") | ||
| 74 | |||
| 75 | io.write("testing headers_callback is not invoked for a redirected (3xx) response: ") | ||
| 76 | do | ||
| 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])) | ||
| 106 | end | ||
| 107 | print("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. | ||
| 113 | local 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") | ||
| 128 | end | ||
| 129 | |||
| 130 | io.write("testing the headers_callback sink-swap offer is inert when a 204 response skips the body: ") | ||
| 131 | assertsinkswapskipped(nil, fixture.response("204 No Content", { "Connection: close" }, ""), 204) | ||
| 132 | print("ok") | ||
| 133 | |||
| 134 | io.write("testing the headers_callback sink-swap offer is inert when a 304 response skips the body: ") | ||
| 135 | assertsinkswapskipped(nil, fixture.response("304 Not Modified", { "Connection: close" }, ""), 304) | ||
| 136 | print("ok") | ||
| 137 | |||
| 138 | io.write("testing the headers_callback sink-swap offer is inert when a HEAD request skips the body: ") | ||
| 139 | do | ||
| 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) | ||
| 146 | end | ||
| 147 | print("ok") | ||
| 148 | |||
| 149 | remote("os.exit()") | ||
| 150 | |||
| 151 | print("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 @@ | |||
| 1 | local socket = require("socket") | ||
| 2 | local sse = require("socket.sse") | ||
| 3 | |||
| 4 | dofile("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 | ||
| 9 | local 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 | ||
| 17 | end | ||
| 18 | |||
| 19 | -- feeds a parser sink the given raw chunks, then signals end of stream | ||
| 20 | local 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) | ||
| 26 | end | ||
| 27 | |||
| 28 | -------------------------------- | ||
| 29 | io.write("testing event/data/id parsing on a single Message, retry line consumed but not attached: ") | ||
| 30 | do | ||
| 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") | ||
| 46 | end | ||
| 47 | |||
| 48 | -------------------------------- | ||
| 49 | io.write("testing multiple data: lines are joined with \\n: ") | ||
| 50 | do | ||
| 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") | ||
| 58 | end | ||
| 59 | |||
| 60 | -------------------------------- | ||
| 61 | io.write("testing event defaults to 'message' when omitted: ") | ||
| 62 | do | ||
| 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") | ||
| 70 | end | ||
| 71 | |||
| 72 | -------------------------------- | ||
| 73 | io.write("testing id persists forward onto later Messages: ") | ||
| 74 | do | ||
| 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") | ||
| 89 | end | ||
| 90 | |||
| 91 | -------------------------------- | ||
| 92 | io.write("testing comment lines are silently absorbed when comments is off: ") | ||
| 93 | do | ||
| 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") | ||
| 101 | end | ||
| 102 | |||
| 103 | -------------------------------- | ||
| 104 | io.write("testing comment lines are dispatched distinctly when comments is on: ") | ||
| 105 | do | ||
| 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") | ||
| 115 | end | ||
| 116 | |||
| 117 | -------------------------------- | ||
| 118 | io.write("testing context table gets last_event_id/retry written and retains them after parsing ends: ") | ||
| 119 | do | ||
| 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") | ||
| 128 | end | ||
| 129 | |||
| 130 | -------------------------------- | ||
| 131 | io.write("testing context.retry persists across later events that don't repeat it, and no Message ever carries a retry field: ") | ||
| 132 | do | ||
| 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") | ||
| 145 | end | ||
| 146 | |||
| 147 | -------------------------------- | ||
| 148 | io.write("testing an oversized line produces a distinct error and aborts: ") | ||
| 149 | do | ||
| 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") | ||
| 158 | end | ||
| 159 | |||
| 160 | -------------------------------- | ||
| 161 | io.write("testing an oversized event (many lines under MAXEVENTSIZE total) produces a distinct error: ") | ||
| 162 | do | ||
| 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") | ||
| 172 | end | ||
| 173 | |||
| 174 | -------------------------------- | ||
| 175 | io.write("testing a field split across two raw-byte chunks is parsed correctly: ") | ||
| 176 | do | ||
| 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") | ||
| 186 | end | ||
| 187 | |||
| 188 | -------------------------------- | ||
| 189 | io.write("testing a Message-sink error aborts the parser sink chain: ") | ||
| 190 | do | ||
| 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") | ||
| 203 | end | ||
| 204 | |||
| 205 | -------------------------------- | ||
| 206 | io.write("testing a Message with no data: line is not dispatched: ") | ||
| 207 | do | ||
| 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") | ||
| 215 | end | ||
| 216 | |||
| 217 | -------------------------------- | ||
| 218 | io.write("testing callbacksink lets a plain callback act as a message sink: ") | ||
| 219 | do | ||
| 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") | ||
| 230 | end | ||
| 231 | |||
| 232 | -------------------------------- | ||
| 233 | io.write("testing callbacksink propagates an error returned by the callback: ") | ||
| 234 | do | ||
| 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") | ||
| 248 | end | ||
| 249 | |||
| 250 | -------------------------------- | ||
| 251 | io.write("testing callbacksink does not swallow a sink that fails with a falsy ok and no error message: ") | ||
| 252 | do | ||
| 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") | ||
| 266 | end | ||
| 267 | |||
| 268 | -------------------------------- | ||
| 269 | io.write("testing callbacksink treats a callback that returns nothing as an error, not a default success: ") | ||
| 270 | do | ||
| 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") | ||
| 276 | end | ||
| 277 | |||
| 278 | -------------------------------- | ||
| 279 | io.write("testing responseheaders forwards its config (context/comments) to the parser: ") | ||
| 280 | do | ||
| 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") | ||
| 294 | end | ||
| 295 | |||
| 296 | -------------------------------- | ||
| 297 | io.write("testing responseheaders installs the sink chain on a matching Content-Type: ") | ||
| 298 | do | ||
| 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") | ||
| 312 | end | ||
| 313 | |||
| 314 | -------------------------------- | ||
| 315 | io.write("testing responseheaders also accepts a raw ltn12-style message sink directly: ") | ||
| 316 | do | ||
| 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") | ||
| 331 | end | ||
| 332 | |||
| 333 | -------------------------------- | ||
| 334 | io.write("testing responseheaders matches a Content-Type with trailing parameters: ") | ||
| 335 | do | ||
| 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") | ||
| 341 | end | ||
| 342 | |||
| 343 | -------------------------------- | ||
| 344 | io.write("testing responseheaders declines cleanly on a non-matching Content-Type: ") | ||
| 345 | do | ||
| 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") | ||
| 358 | end | ||
| 359 | |||
| 360 | -------------------------------- | ||
| 361 | io.write("testing responseheaders declines cleanly when Content-Type is missing: ") | ||
| 362 | do | ||
| 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") | ||
| 368 | end | ||
| 369 | |||
| 370 | print("the library passed all tests") | ||
