-- modules/http.lua - Express-like HTTP server with pure Lua routing local http = {} -- Global routing tables (shared across all states) _G._http_routes = _G._http_routes or {} _G._http_middleware = _G._http_middleware or {} -- Request/Response classes local Request = {} Request.__index = Request local Response = {} Response.__index = Response -- ====================================================================== -- ROUTER IMPLEMENTATION -- ====================================================================== local function split_path(path) local segments = {} for segment in path:gmatch("[^/]+") do table.insert(segments, segment) end return segments end local function match_route(method, path) local path_segments = split_path(path) for _, route in ipairs(_G._http_routes) do if route.method == method then local params = {} local route_segments = route.segments local match = true local i = 1 while i <= #route_segments and match do local route_seg = route_segments[i] if route_seg == "*" then -- Wildcard captures everything remaining local remaining = {} for j = i, #path_segments do table.insert(remaining, path_segments[j]) end params["*"] = table.concat(remaining, "/") break elseif route_seg:sub(1,1) == ":" then -- Parameter segment if i <= #path_segments then local param_name = route_seg:sub(2) params[param_name] = path_segments[i] else match = false end else -- Static segment if i > #path_segments or route_seg ~= path_segments[i] then match = false end end i = i + 1 end -- Must consume all segments unless wildcard if match and (i > #path_segments or route_segments[i-1] == "*") then return route, params end end end return nil, {} end function _http_handle_request(req_table, res_table) local req = Request.new(req_table) local res = Response.new(res_table) -- Execute middleware chain first local function run_middleware(index) if index > #_G._http_middleware then local route, params = match_route(req.method, req.path) req.params = params if not route then res:status(404):send("Not Found") return end -- Run route handler route.handler(req, res) return end local mw = _G._http_middleware[index] if mw.path == nil or req.path:match("^" .. mw.path:gsub("([%(%)%.%+%-%*%?%[%]%^%$%%])", "%%%1")) then mw.handler(req, res, function() run_middleware(index + 1) end) else run_middleware(index + 1) end end run_middleware(1) end -- Functions for route synchronization function _http_get_routes() return { routes = _G._http_routes, middleware = _G._http_middleware } end function _http_sync_worker_routes() local data = _G._http_routes_data if data and data.routes and data.middleware then _G._http_routes = data.routes _G._http_middleware = data.middleware end end -- ====================================================================== -- SERVER CLASS -- ====================================================================== local Server = {} Server.__index = Server function http.server() -- Workers should not create servers if _G.__IS_WORKER then return setmetatable({}, Server) end local server = setmetatable({ _server_created = false }, Server) -- Create the fasthttp server immediately local success, err = moonshark.http_create_server() if not success then error("Failed to create HTTP server: " .. (err or "unknown error")) end server._server_created = true return server end function Server:use(...) local args = {...} if #args == 1 and type(args[1]) == "function" then table.insert(_G._http_middleware, {path = nil, handler = args[1]}) elseif #args == 2 and type(args[1]) == "string" and type(args[2]) == "function" then table.insert(_G._http_middleware, {path = args[1], handler = args[2]}) else error("Invalid arguments to use()") end -- Only sync in main state if not _G.__IS_WORKER then self:_sync_routes() end return self end function Server:_add_route(method, path, handler) -- Ensure path starts with / if path:sub(1,1) ~= "/" then path = "/" .. path end local segments = split_path(path) table.insert(_G._http_routes, { method = method, path = path, segments = segments, handler = handler }) -- Only sync in main state if not _G.__IS_WORKER then self:_sync_routes() end return self end function Server:_sync_routes() -- No-op - routes are in global Lua tables, workers inherit them automatically end -- HTTP method helpers function Server:get(path, handler) return self:_add_route("GET", path, handler) end function Server:post(path, handler) return self:_add_route("POST", path, handler) end function Server:put(path, handler) return self:_add_route("PUT", path, handler) end function Server:delete(path, handler) return self:_add_route("DELETE", path, handler) end function Server:patch(path, handler) return self:_add_route("PATCH", path, handler) end function Server:head(path, handler) return self:_add_route("HEAD", path, handler) end function Server:options(path, handler) return self:_add_route("OPTIONS", path, handler) end function Server:all(path, handler) local methods = {"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"} for _, method in ipairs(methods) do self:_add_route(method, path, handler) end return self end function Server:listen(port, host, callback) -- Workers should not listen if _G.__IS_WORKER then if callback then callback() end return self end if type(host) == "function" then callback = host host = "localhost" end host = host or "localhost" local addr = host .. ":" .. tostring(port) -- Spawn workers first local success, err = moonshark.http_spawn_workers() if not success then error("Failed to spawn workers: " .. (err or "unknown error")) end -- Then start listening success, err = moonshark.http_listen(addr) if not success then error("Failed to start server: " .. (err or "unknown error")) end if callback then callback() end return self end function Server:close() if _G.__IS_WORKER then return true end return moonshark.http_close_server() end -- ====================================================================== -- REQUEST OBJECT -- ====================================================================== function Request.new(req_table) local req = setmetatable({ method = req_table.method, path = req_table.path, query = req_table.query or {}, headers = req_table.headers or {}, params = {}, body = req_table.body or "" }, Request) return req end function Request:get(header_name) return self.headers[header_name] or self.headers[header_name:lower()] end function Request:header(header_name) return self:get(header_name) end function Request:param(name, default_value) return self.params[name] or default_value end function Request:query_param(name, default_value) return self.query[name] or default_value end function Request:json() if self.body == "" then return nil end local success, result = pcall(function() return moonshark.json_decode(self.body) end) if success then return result else error("Invalid JSON in request body") end end function Request:is_json() local content_type = self:get("content-type") or "" return content_type:find("application/json") ~= nil end function Request:is_form() local content_type = self:get("content-type") or "" return content_type:find("application/x-www-form-urlencoded") ~= nil end -- ====================================================================== -- RESPONSE OBJECT -- ====================================================================== function Response.new(res_table) local res = setmetatable({ _table = res_table, _sent = false }, Response) return res end function Response:status(code) if self._sent then error("Cannot set status after response has been sent") end self._table.status = code return self end function Response:header(name, value) if self._sent then error("Cannot set headers after response has been sent") end self._table.headers[name] = value return self end function Response:set(name, value) return self:header(name, value) end function Response:type(content_type) return self:header("Content-Type", content_type) end function Response:send(data) if self._sent then error("Response already sent") end if type(data) == "table" then self:json(data) elseif type(data) == "number" then self._table.status = data self._table.body = "" else self._table.body = tostring(data or "") end self._sent = true return self end function Response:json(data) if self._sent then error("Response already sent") end self:type("application/json") local success, json_str = pcall(function() return moonshark.json_encode(data) end) if success then self._table.body = json_str else error("Failed to encode JSON response") end self._sent = true return self end function Response:text(text) if self._sent then error("Response already sent") end self:type("text/plain") self._table.body = tostring(text or "") self._sent = true return self end function Response:html(html) if self._sent then error("Response already sent") end self:type("text/html") self._table.body = tostring(html or "") self._sent = true return self end function Response:redirect(url, status) if self._sent then error("Response already sent") end status = status or 302 self:status(status) self:header("Location", url) self._table.body = "" self._sent = true return self end function Response:cookie(name, value, options) if self._sent then error("Cannot set cookies after response has been sent") end options = options or {} local cookie = name .. "=" .. tostring(value) if options.expires then cookie = cookie .. "; Expires=" .. options.expires end if options.max_age then cookie = cookie .. "; Max-Age=" .. tostring(options.max_age) end if options.domain then cookie = cookie .. "; Domain=" .. options.domain end if options.path then cookie = cookie .. "; Path=" .. options.path end if options.secure then cookie = cookie .. "; Secure" end if options.http_only then cookie = cookie .. "; HttpOnly" end if options.same_site then cookie = cookie .. "; SameSite=" .. options.same_site end local existing = self._table.headers["Set-Cookie"] if existing then if type(existing) == "table" then table.insert(existing, cookie) else self._table.headers["Set-Cookie"] = {existing, cookie} end else self._table.headers["Set-Cookie"] = cookie end return self end function Response:clear_cookie(name, options) options = options or {} options.expires = "Thu, 01 Jan 1970 00:00:00 GMT" options.max_age = 0 return self:cookie(name, "", options) end -- ====================================================================== -- MIDDLEWARE HELPERS -- ====================================================================== function http.cors(options) options = options or {} local origin = options.origin or "*" local methods = options.methods or "GET,HEAD,PUT,PATCH,POST,DELETE" local headers = options.headers or "Content-Type,Authorization" return function(req, res, next) res:header("Access-Control-Allow-Origin", origin) res:header("Access-Control-Allow-Methods", methods) res:header("Access-Control-Allow-Headers", headers) if options.credentials then res:header("Access-Control-Allow-Credentials", "true") end if req.method == "OPTIONS" then res:status(204):send("") else next() end end end function http.static(root_path) return function(req, res, next) if req.method ~= "GET" and req.method ~= "HEAD" then next() return end local file_path = moonshark.path_join(root_path, req.path) file_path = moonshark.path_clean(file_path) local abs_root = moonshark.path_abs(root_path) local abs_file = moonshark.path_abs(file_path) if not abs_file or not abs_file:find("^" .. abs_root:gsub("([%(%)%.%+%-%*%?%[%]%^%$%%])", "%%%1")) then next() return end if moonshark.file_exists(file_path) and not moonshark.file_is_dir(file_path) then local content = moonshark.file_read(file_path) if content then local ext = moonshark.path_ext(file_path):lower() local content_types = { [".html"] = "text/html", [".css"] = "text/css", [".js"] = "application/javascript", [".json"] = "application/json", [".png"] = "image/png", [".jpg"] = "image/jpeg", [".jpeg"] = "image/jpeg", [".gif"] = "image/gif", [".svg"] = "image/svg+xml", [".webp"] = "image/webp", [".txt"] = "text/plain", } local content_type = content_types[ext] or "application/octet-stream" res:type(content_type):send(content) return end end next() end end function http.json_parser() return function(req, res, next) if req:is_json() and req.body ~= "" then local success, data = pcall(function() return req:json() end) if success then req.json_body = data else res:status(400):json({error = "Invalid JSON"}) return end end next() end end function http.logger() return function(req, res, next) local start_time = os.clock() next() local duration = (os.clock() - start_time) * 1000 local status = res._table.status or 200 print(string.format("%s %s %d %.2fms", req.method, req.path, status, duration)) end end function http.create_server(callback) local app = http.server() if callback then callback(app) end return app end return http