diff --git a/examples/hello_world.lua b/examples/hello_world.lua index 0b10998..c0dd6f9 100644 --- a/examples/hello_world.lua +++ b/examples/hello_world.lua @@ -31,16 +31,16 @@ server:use(function (req, res, next) next(req, res) local request = string.format('%s %s %s', req.method, req.url.path, req.http_version) local _, sent, _ = req.socket:getstats() - print( + print( string.format('%s - %s - [%s] "%s" %i %i "%s" "%s"', remote, req.url.user or '', os.date('%Y-%m-%dT%H:%M:%S%z', start), request, - res._status, + res.status or 0, sent, - req:get_headers().referrer or '-', - req:get_headers().user_agent or '-' + req:get_headers():get_one("referer") or '-', + req:get_headers():get_one("user-agent") or '-' )) end) diff --git a/luxure/error.lua b/luxure/error.lua index 6c79ad8..57848fa 100644 --- a/luxure/error.lua +++ b/luxure/error.lua @@ -54,7 +54,10 @@ end ---Raise and error with a message and status ---@param msg string ---@param status number -function Error.raise(msg, status) error(build_error_string(msg, status)) end +function Error.raise(msg, status) + error(build_error_string(msg, status)) +end + local function parse_error_msg(s) local i = 1 local keys = {"msg", "traceback", "orig_loc", "status"} @@ -79,6 +82,7 @@ end ---Wrapper around `pcall` that will reconstruct the Error object ---on failure +---@return boolean ---@return Error | string function Error.pcall(...) local res = table.pack(pcall(...)) @@ -90,7 +94,7 @@ function Error.pcall(...) "^(.+luxure/error.lua:[0-9]+): ", "") local status = math.tointeger(parsed.status) or 500 return false, new_error(stripped_message, string.format("%s %s", - parsed.orig_loc, + parsed.orig_loc or "", stripped_message), status, parsed.traceback) end diff --git a/luxure/server.lua b/luxure/server.lua index eeea0ba..8669094 100644 --- a/luxure/server.lua +++ b/luxure/server.lua @@ -1,5 +1,6 @@ local Router = require "luxure.router" local lunch = require "luncheon" +local utils = require "luxure.utils" local Request = lunch.Request local Response = lunch.Response local methods = require "luxure.methods" @@ -16,10 +17,11 @@ local log = require "log" --- ---@field private sock table socket being used by the server ---@field public router Router The router for incoming requests ----@field private middleware table List of middleware callbacks +---@field private middleware fun(req: Request, res: Response) Chain of middleware callbacks ---@field public ip string defaults to '0.0.0.0' ---@field private env string defaults to 'production' ---@field private backlog number|nil defaults to nil +---@field private kept_alive socket.tcp[] local Server = {} Server.__index = Server @@ -92,7 +94,7 @@ function Server.new_with(sock, opts) env = opts.env or "production", ---@type number backlog = opts.backlog, - _sync = opts.sync, + kept_alive = {} } return setmetatable(base, Server) end @@ -126,7 +128,6 @@ end function Server:use(middleware) log.trace("Server:use") if self.middleware == nil then - ---@type fun(req:Request,res:Response) self.middleware = function(req, res) self.router.route(self.router, req, res) end @@ -162,17 +163,18 @@ local function debug_error_body(err) local code = err.status or "500" local h2 = err.msg_with_line or "Unknown Error" local pre = err.traceback or "" - return string.format([[ - -
- - - %s %s- - - ]], code, h2, pre) + return string.format(table.concat({ + '', + '', + ' ', + ' ', + ' ', + '
%s %s', + ' ', + '', + }, '\n'), code, h2, pre) end local function error_request(env, err, res) @@ -187,7 +189,50 @@ local function error_request(env, err, res) return end res:set_content_type("text/html"):send(debug_error_body(err)) - return +end + +function Server:get_keep_alive_details(headers) + local ret = { + cached = os.time() + } + local keeps = headers:get_all("keep-alive") + for _, v in (keeps or {}) do + local timeout = v:match("timeout%s*=%s*(%d+)") + if timeout then + ret.timeout = tonumber(timeout, 10) + end + local max = v:match("max%s*=%s*(%d+)") + if max then + ret.max = max + end + end + return ret +end + +function Server:get_keep_alive(req) + log.trace("get_keep_alive") + local headers = req:get_headers() + if not headers then + return + end + local conns = headers:get_all("connection") + for _, v in ipairs(conns or {}) do + if string.find(v:lower(), "keep-alive") then + return self:get_keep_alive_details(headers) + end + end +end + +function Server:maybe_keep_alive(incoming, req, res) + log.trace("maybe_keep_alive") + if req.err then + return + end + local keep_alive_details = self:get_keep_alive(req) + if keep_alive_details then + self.kept_alive[incoming] = keep_alive_details + end + res.hold_open = not not self.kept_alive[incoming] end function Server:_tick(incoming) @@ -199,6 +244,7 @@ function Server:_tick(incoming) end local res = Response.new(200, incoming) self:route(req, res) + self:maybe_keep_alive(incoming, req, res) local has_sent = res:has_sent() if req.err then error_request(self.env, req.err, res) @@ -209,6 +255,44 @@ function Server:_tick(incoming) return 1 end +function Server:_next_incoming(err_callback) + log.trace("_next_incoming", #self.kept_alive) + local ret + repeat + local rdrs = { self.sock } + for s, dets in pairs(self.kept_alive) do + log.debug("kept:", utils.table_string(dets)) + if dets.timeout and os.difftime(os.time(), dets.cached) > dets.timeout then + self.kept_alive[s] = nil + else + table.insert(rdrs, s) + end + end + log.trace("selecting") + local rdrs, _, err = cosock.socket.select(rdrs) + log.trace("selected", #(rdrs or {}), err or "nil") + for _, rdr in ipairs(rdrs) do + -- check if it is the server socket + if rdr == self.sock then + local s, e = self.sock:accept() + if e and e ~= "timeout" then + return nil, e + end + if s then + ret = s + break + end + end + -- not the server socket + ret = rdr + -- remove from consideration until request/response is complete + self.kept_alive[rdr] = nil + break + end + until ret + return ret +end + ---A single step in the Server run loop ---which will call `accept` on the underlying socket ---and when that returns a client socket, it will @@ -216,28 +300,19 @@ end ---the registered middleware and routes function Server:tick(err_callback) log.trace("Server:tick") - local incoming, err = self.sock:accept() + local incoming, err = self:_next_incoming(err_callback) if not incoming then err_callback(err) return end - if not self._sync then - cosock.spawn(function() - local nopanic, success, err = pcall(self._tick, self, incoming) - if not nopanic then - err_callback(success) - return - end - if not success then err_callback(err) end - end, string.format("Accepted request (ptr: %s)", incoming)) - else + cosock.spawn(function() local nopanic, success, err = pcall(self._tick, self, incoming) if not nopanic then err_callback(success) return end if not success then err_callback(err) end - end + end, string.format("Accepted request (ptr: %s)", incoming)) end function Server:_run(err_callback, should_continue) @@ -246,18 +321,20 @@ function Server:_run(err_callback, should_continue) while should_continue() do self:tick(err_callback) end end +function Server:spawn(err_callback, should_continue) + self.sock:settimeout(0) + err_callback = err_callback or function() end + should_continue = should_continue or function() return true end + cosock.spawn(function() self:_run(err_callback, should_continue) end, + "luxure-main-loop") +end + ---Start this server, blocking forever ---@param err_callback fun(msg:string):boolean Optional callback to be run if `tick` returns an error function Server:run(err_callback, should_continue) log.trace("Server:run") - err_callback = err_callback or function() return true end - if not self._sync then - cosock.spawn(function() self:_run(err_callback, should_continue) end, - "luxure-main-loop") - cosock.run() - else - self:_run(err_callback, should_continue) - end + self:spawn(err_callback, should_continue) + cosock.run() end for _, method in ipairs(methods) do @@ -269,4 +346,4 @@ for _, method in ipairs(methods) do end end -return {Server = Server, Opts = Opts} +return { Server = Server, Opts = Opts } diff --git a/spec/error_spec.lua b/spec/error_spec.lua index 4c4a509..7308ef0 100644 --- a/spec/error_spec.lua +++ b/spec/error_spec.lua @@ -33,4 +33,4 @@ describe('Error', function() traceback = 'This is a traceback' }), 'This is an error with a line\nThis is a traceback') end) -end) \ No newline at end of file +end) diff --git a/spec/integration_spec.lua b/spec/integration_spec.lua index f1e985b..69698c7 100644 --- a/spec/integration_spec.lua +++ b/spec/integration_spec.lua @@ -1,8 +1,10 @@ + local lux = require 'luxure' local cosock = require 'cosock' local Request = require 'luncheon.request' local Response = require 'luncheon.response' local log = require 'log' +local test_utils = require 'spec.test_utils' function get_socket(ip, port) local sock = assert(cosock.socket.tcp()) @@ -44,7 +46,7 @@ local tests = { } describe('Integration Test', function() - it('works #integration', function() + it('works #integration',test_utils.wrap(function() local server = assert(lux.Server.new(lux.Opts.new():set_env('debug'))) server:listen() server:get('/', function(req, res) @@ -76,9 +78,13 @@ describe('Integration Test', function() f(ip, port) log.info('completed', name) end - end,'client loop') - server:run(error, function() + end, 'client loop') + + server:spawn(function(dets) + print("ERROR: dets", dets) + error() + end, function() return ct < total_tests end) - end) -end) \ No newline at end of file + end)) +end) diff --git a/spec/mock_socket.lua b/spec/mock_socket.lua index 8fd2aab..f2a5c65 100644 --- a/spec/mock_socket.lua +++ b/spec/mock_socket.lua @@ -1,63 +1,84 @@ +local cosock = require "cosock" +local utils = require "luxure.utils" ---TCP Client Socket local MockSocket = {} MockSocket.__index = MockSocket function MockSocket.new(inner) - local ret = { - recvd = 0, - sent = 0, - inner = inner or {}, - open = true, - } - setmetatable(ret, MockSocket) - return ret + local in_tx, rx = cosock.channel.new() + rx:settimeout(1) + local out_tx, out_rx = cosock.channel.new() + out_rx:settimeout(1) + local ret = { + recvd = 0, + sent = 0, + inner = rx, + in_tx = in_tx, + out_tx = out_tx, + out_rx = out_rx, + outbound = {}, + open = true, + } + setmetatable(ret, MockSocket) + for _, chunk in ipairs(inner or {}) do + in_tx:send(chunk) + end + return ret end function MockSocket:bind(ip, port) - return 1 + return 1 end function MockSocket:listen(backlog) - return 1 + return 1 end function MockSocket:getsockname() - return '0.0.0.0', 0 + return '0.0.0.0', 0 end function MockSocket:getstats() - return self.recvd, self.sent + return self.recvd, self.sent end function MockSocket:close() - self.open = false + self.open = false end function MockSocket.new_with_preamble(method, path) - return MockSocket.new({ - string.format('%s %s HTTP/1.1', string.upper(method), path) - }) + return MockSocket.new({ + string.format('%s %s HTTP/1.1', string.upper(method), path) + }) end function MockSocket:receive() - if #self.inner == 0 then - return nil, 'empty' - end - local part = table.remove(self.inner, 1) - self.recvd = self.recvd + #(part or '') - return part + if #self.inner.link.queue == 0 then + return nil, 'empty' + end + cosock.socket.sleep(0) + local part, _err = self.inner:receive() + self.recvd = self.recvd + #(part or '') + return part end function MockSocket:send(s) - if s == 'timeout' or s == 'closed' then - return nil, s - end - self.inner = self.inner or {} - self.sent = self.sent + #(s or '') - table.insert(self.inner, s) - if s then - return #s - end + self.out_tx:send(s) + if s == 'timeout' or s == 'closed' then + return nil, s + end + self.outbound = self.outbound or {} + self.sent = self.sent + #(s or '') + table.insert(self.outbound, s) + self.out_tx:send(s) + if s then + return #s + end +end + +function MockSocket:setwaker(...) + self.inner:setwaker(...) + end ---TCP Master Socket @@ -65,47 +86,63 @@ local MockTcp = {} MockTcp.__index = MockTcp function MockTcp.new(inner) - local ret = { - inner = inner or {} - } - setmetatable(ret, MockTcp) - return ret + local tx, rx = cosock.channel.new() + local ret = { + inner = rx, + accepted = {} + } + setmetatable(ret, MockTcp) + for _, sock in pairs(inner) do + tx:send(sock) + end + return ret end function MockTcp:accept() - local list = assert(table.remove(self.inner)) - return MockSocket.new(list) + local list = self.inner:receive() + local sock = MockSocket.new(list) + table.insert(self.accepted, sock) + return sock end function MockTcp:bind(ip, port) - return 1 + return 1 end function MockTcp:listen(backlog) - return 1 + return 1 end function MockTcp:getsockname() - return '0.0.0.0', 0 + return '0.0.0.0', 0 end +function MockTcp:settimeout(tm) + self.inner:settimeout(tm) +end +function MockTcp:setwaker(...) + self.inner:setwaker(...) +end local MockModule = {} MockModule.__index = MockModule -local sockets +local sock_tx, sock_rx = cosock.channel.new() function MockModule.new(inner) - sockets = inner or {} - return MockModule + for _, sock in pairs(inner) do + sock_tx:send(sock) + end + return MockModule end function MockModule.tcp() - local list = assert(table.remove(sockets), 'No sockets in the list') - return MockTcp.new(list) + local list = sock_rx:recv() + return MockTcp.new(list) end function MockModule.bind(ip, port) - return 1 + return 1 end + return { - MockSocket = MockSocket, - MockTcp = MockTcp, - MockModule = MockModule, + MockSocket = MockSocket, + MockTcp = MockTcp, + MockModule = MockModule, } diff --git a/spec/route_spec.lua b/spec/route_spec.lua index 238594a..9137df5 100644 --- a/spec/route_spec.lua +++ b/spec/route_spec.lua @@ -25,4 +25,4 @@ describe('Route', function() assert(matches, 'expected a match: ' .. utils.table_string(r)) assert(params.b == '1', 'b == 1 ' .. utils.table_string(params)) end) -end) \ No newline at end of file +end) diff --git a/spec/router_spec.lua b/spec/router_spec.lua index 3e511b5..1d6949e 100644 --- a/spec/router_spec.lua +++ b/spec/router_spec.lua @@ -2,8 +2,14 @@ local Router = require 'luxure.router' local Request = require 'luncheon.request' local Response = require 'luncheon.response' local MockSocket = require 'spec.mock_socket'.MockSocket +local test_utils = require 'spec.test_utils' +-- setup(test_utils.setup) +-- teardown(test_utils.teardown) +-- before_each(test_utils.before_each) +-- after_each(test_utils.after_each) + describe('Router', function () - it('Should call callback on route', function() + it('Should call callback on route', test_utils.wrap(function() local r = Router.new() local req = Request.tcp_source(MockSocket.new_with_preamble('GET', '/')) local called = false @@ -15,8 +21,8 @@ describe('Router', function () Response.new(200, MockSocket.new()) ) assert(called) - end) - it('should not call if route not matched', function() + end)) + it('should not call if route not matched', test_utils.wrap(function() local r = Router.new() local req = Request.tcp_source(MockSocket.new_with_preamble('GET', '/not_registered')) local called = false @@ -28,8 +34,8 @@ describe('Router', function () Response.new(200, MockSocket.new()) )) assert(not called) - end) - it('should not call if method not matched', function() + end)) + it('should not call if method not matched', test_utils.wrap(function() local r = Router.new() local req = Request.tcp_source(MockSocket.new_with_preamble('POST', '/')) local called = false @@ -41,8 +47,8 @@ describe('Router', function () Response.tcp_source(MockSocket.new()) )) assert(not called) - end) - it('Handler error should panic', function() + end)) + it('Handler error should panic', test_utils.wrap(function() local r = Router.new() local req = Request.tcp_source(MockSocket.new_with_preamble('GET', '/')) local res = Response.new(200, MockSocket.new()) @@ -54,5 +60,5 @@ describe('Router', function () res )) assert.are.equal(500, res.status) - end) + end)) end) diff --git a/spec/server_spec.lua b/spec/server_spec.lua index 5a97b9e..cf930eb 100644 --- a/spec/server_spec.lua +++ b/spec/server_spec.lua @@ -2,109 +2,153 @@ local Server = require 'luxure.server'.Server local Opts = require 'luxure.server'.Opts local mocks = require 'spec.mock_socket' local utils = require 'luxure.utils' -local Error = require 'luxure.error'.Error +local Error = require 'luxure.error' +local cosock = require "cosock" + +local test_utils = require 'spec.test_utils' describe('Server', function() - it('Should handle requests', function() - local s = assert(Server.new_with(mocks.MockTcp.new({ - {'GET / HTTP/1.1'} - }), {sync = true})) - s:listen(8080) - local called = false - s:get('/', function(req, res) - called = true - end) - s:tick() - assert(called) + it('Should handle requests', test_utils.wrap(function() + local sock = mocks.MockTcp.new({ + { 'GET / HTTP/1.1' } + }) + local s = assert(Server.new_with(sock, { debug = true })) + s:listen(8080) + local call_tx, call_rx = cosock.channel.new() + call_rx:settimeout(5) + s:get('/', function(req, res) + call_tx:send(1) end) - it('should call middleware and handle requests', function() - local s = assert(Server.new_with(mocks.MockTcp.new({ - {'GET / HTTP/1.1'} - }), {sync = true})) - s:listen(8080) - local called = false - local called_middleware = false - s:use(function(req, res, next) - called_middleware = true - next(req, res) - end) - s:get('/', function(req, res) - called = true - end) - s:tick() - assert(called) - assert(called_middleware) + s:tick() + local s, err = call_rx:receive() + if not s then + print("Error receiving from get /", err) + end + -- assert(s, "Error from call_rx: " .. tostring(err)) + end)) + it('should call middleware and handle requests #middleware', test_utils.wrap(function() + local sock = mocks.MockTcp.new({ + { 'GET / HTTP/1.1' } + }) + local s = assert(Server.new_with(sock, {})) + s:listen(8080) + local called = false + local called_middleware = false + s:use(function(req, res, next) + res:append_body("middleware") + next(req, res) end) - it('should call a middleware chain and handle requests', function() - local s = assert(Server.new_with(mocks.MockTcp.new({ - {'GET / HTTP/1.1'} - }), {sync = true})) - s:listen(8080) - local called = false - local middleware_call_count = 0 - for i=1,10,1 do - s:use(function(req, res, next) - middleware_call_count = middleware_call_count + 1 - next(req, res) - end) - end - s:get('/', function(req, res) - called = true - end) - s:tick() - assert(called) - assert(middleware_call_count == 10) + s:get('/', function(req, res) + res:send("sent") end) - it('middleware error should return 500', function() - local sock = {'GET / HTTP/1.1'} - local s = assert(Server.new_with(mocks.MockTcp.new({ - sock - }), {sync = true})) - s:listen(8080) - local called = false - s:use(function(req, res, next) - Error.assert(false, 'expected fail') - end) - s:get('/', function(req, res) - called = true - end) - s:tick() - assert(not called) - assert(string.find( - sock[1], - '^HTTP/1.1 500 Internal Server Error'), - string.format('Expected 500 found %s', utils.table_string(sock)) - ) + s:tick() + local mid = sock.accepted[1].out_rx:receive() + assert(mid:find("middlewaresent$"), string.format("no middleware: %q", mid)) + end)) + it('should call a middleware chain and handle requests', test_utils.wrap(function() + local sock = mocks.MockTcp.new({ + { 'GET / HTTP/1.1' } + }) + local s = assert(Server.new_with(sock, {})) + s:listen(8080) + local called = false + local middleware_call_count = 0 + for i = 1, 10, 1 do + s:use(function(req, res, next) + res:append_body(string.format("%i", i)) + next(req, res) + end) + end + s:get('/', function(req, res) + res:send("sent") end) - it('no endpoint found should return 404', function() - local sock = {'GET / HTTP/1.1'} - local s = assert(Server.new_with(mocks.MockTcp.new({ - sock - }), {sync = true})) - s:listen(8080) - s:tick() - assert(string.find(sock[1], '^HTTP/1.1 404 Not Found'), string.format('Expected 404 found %s', utils.table_string(sock))) + s:tick() + local ret = sock.accepted[1].out_rx:receive() + assert(ret:find("10987654321sent"), string.format("expected 10 middles and a sent: %q", ret)) + end)) + it('middleware error should return 500 #filter', test_utils.wrap(function() + local sock = mocks.MockTcp.new({ { 'GET / HTTP/1.1' } }) + local s = assert(Server.new_with(sock, Opts.new():set_env("debug"))) + s:listen(8080) + s:use(function(req, res, next) + Error.assert(false, 'expected fail') end) - it('no endpoint found should return 404, with endpoints', function() - local sock = {'GET /not-found HTTP/1.1'} - local s = assert(Server.new_with(mocks.MockTcp.new({ - sock - }), {sync = true})) - s:get('/', function() end) - s:get('/found', function() end) - s:post('/found', function() end) - s:delete('/found', function() end) - s:listen(8080) - s:tick() - assert(string.find(sock[1], '^HTTP/1.1 404 Not Found'), string.format('Expected 404 found %s', utils.table_string(sock))) + s:get('/', function(req, res) + called = true end) + s:tick() + local chunk = sock.accepted[1].out_rx:receive() + assert(not called) + assert(string.find( + chunk, + '^HTTP/1.1 500 Internal Server Error'), + string.format('Expected 500 found %s', utils.table_string(sock.accepted[1])) + ) + end)) + it('no endpoint found should return 404 #four', test_utils.wrap(function() + local sock = mocks.MockTcp.new({ { 'GET / HTTP/1.1' } }) + local s = assert(Server.new_with(sock, { debug = true })) + s:listen(8080) + s:tick(print) + local err = sock.accepted[1].out_rx:receive() + assert(string.find(err, '^HTTP/1.1 404 Not Found'), string.format('Expected 404 found %s', utils.table_string(sock))) + end)) + it('no endpoint found should return 404, with endpoints', test_utils.wrap(function() + local sock = mocks.MockTcp.new({ { 'GET /not-found HTTP/1.1' } }) + local s = assert(Server.new_with(sock)) + s:get('/', function() end) + s:get('/found', function() end) + s:post('/found', function() end) + s:delete('/found', function() end) + s:listen(8080) + s:tick() + local chunk = sock.accepted[1].out_rx:receive() + assert(string.find(chunk, '^HTTP/1.1 404 Not Found'), + string.format('Expected 404 found %s', utils.table_string(sock))) + end)) + it('error in debug returns html', test_utils.wrap(function() + local sock = mocks.MockTcp.new({ { 'GET /boom HTTP/1.1' } }) + local s = assert(Server.new_with(sock, Opts.new():set_env("debug"))) + s:get('/boom', function() + Error.raise("BOOM!") + end) + s:tick() + local ret = sock.accepted[1].out_rx:receive() + assert(string.find(ret, '', 1, true), + string.format("Expected response to start with 500 error found: %q", ret) + ) + end)) + it('keep-alive reuses socket #ka', test_utils.wrap(function() + local sock = mocks.MockTcp.new({{ + 'GET /keep-alive HTTP/1.1', + 'Content-Length: 0', + 'Connection: keep-alive', + '', + }}) + local s = assert(Server.new_with(sock, Opts.new():set_env("debug"))) + s:get('/keep-alive', function(req, res) + res:send("sent") + end) + s:tick() + local rx = sock.accepted[1].out_rx + local tx = sock.accepted[1].in_tx + local res1 = assert(rx:receive()) + assert(res1:find("HTTP/1.1 200 OK\r\n"), string.format("Invalid response: %q", res1)) + tx:send('GET /keep-alive HTTP/1.1') + tx:send('Content-Length: 0') + tx:send('Connection: keep-alive') + tx:send('') + local res2 = assert(rx:receive()) + assert.are.equal(1, #sock.accepted) + assert(res2:find("HTTP/1.1 200 OK\r\n"), string.format("Invalid response: %q", res1)) + end)) end) describe('Opts', function() - it('builder', function() - local opts = Opts.new():set_env('debug'):set_backlog(1) - assert.are.equal('debug', opts.env) - assert.are.equal(1, opts.backlog) - end) + it('builder', function() + local opts = Opts.new():set_env('debug'):set_backlog(1) + assert.are.equal('debug', opts.env) + assert.are.equal(1, opts.backlog) + end) end) diff --git a/spec/test_utils.lua b/spec/test_utils.lua new file mode 100644 index 0000000..e94d374 --- /dev/null +++ b/spec/test_utils.lua @@ -0,0 +1,11 @@ +local cosock = require "cosock" +local m = {} + +m.wrap = function(cb) + return function() + cosock.spawn(cb) + cosock.run() + end +end + +return m