aboutsummaryrefslogtreecommitdiff
path: root/src/sse.lua
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/sse.lua193
1 files changed, 193 insertions, 0 deletions
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