From 6daea0133426d367f2b8297c68f940ea3338fa35 Mon Sep 17 00:00:00 2001 From: Evandro Leopoldino Goncalves Date: Sun, 10 Aug 2025 12:32:12 +0200 Subject: [PATCH 1/4] docs: add LDoc documentation across core and plugins Pegasus: module overview and server API docs Request/Response: document fields, methods, and usage; headers behavior and streaming Handler: lifecycle and plugin hooks Plugins: compress, downloads, files, router, tls annotated; options and behavior No functional changes. --- src/pegasus/handler.lua | 125 +++++++++++++++++++++++++++ src/pegasus/init.lua | 81 ++++++++++++++++++ src/pegasus/plugins/compress.lua | 41 +++++++++ src/pegasus/plugins/downloads.lua | 28 +++++- src/pegasus/plugins/files.lua | 19 ++++- src/pegasus/plugins/router.lua | 31 ++++++- src/pegasus/plugins/tls.lua | 20 ++++- src/pegasus/request.lua | 93 ++++++++++++++++++++ src/pegasus/response.lua | 136 +++++++++++++++++++++++++++++- 9 files changed, 568 insertions(+), 6 deletions(-) diff --git a/src/pegasus/handler.lua b/src/pegasus/handler.lua index 371a4e5..961bb29 100644 --- a/src/pegasus/handler.lua +++ b/src/pegasus/handler.lua @@ -1,10 +1,70 @@ +--- Module `pegasus.handler` +-- +-- Internal orchestrator that wires the server socket to request/response +-- objects and drives the plugin pipeline. +-- +-- Lifecycle for each connection/request: +-- 1. `pluginsNewConnection(client)` can wrap/replace or reject the client +-- 2. Request/Response objects are created +-- 3. `pluginsNewRequestResponse(request, response)` runs +-- 4. `pluginsBeforeProcess(request, response)` runs +-- 5. User `callback(request, response)` is invoked +-- 6. `pluginsAfterProcess(request, response)` runs +-- 7. If response not closed, a default 404 is written +-- +-- Plugins may also: +-- - modify Request/Response metatables via `alterRequestResponseMetaTable` +-- - intercept file processing via `processFile` +-- - filter/transform streamed body via `processBodyData` +-- +-- Minimal plugin example: +-- ```lua +-- local MyPlugin = {} +-- function MyPlugin:new() +-- return setmetatable({}, { __index = self }) +-- end +-- function MyPlugin:beforeProcess(req, res) +-- res:addHeader('X-Powered-By', 'Pegasus') +-- end +-- return MyPlugin +-- ``` +-- +-- @module pegasus.handler + local Request = require 'pegasus.request' local Response = require 'pegasus.response' local Files = require 'pegasus.plugins.files' +--- The request/response handler and plugin runner. +-- +-- Fields: +-- - `log`: logger used by the server and plugins +-- - `callback`: user callback `function(request, response)` +-- - `plugins`: array of plugin instances +-- +-- @type Handler +---@class Handler +---@field log table +---@field callback fun(request: table, response: table)|nil +---@field plugins table local Handler = {} Handler.__index = Handler +--- Construct a `Handler`. +-- +-- When `location` is a non-empty string, automatically enables the `files` +-- plugin to serve static files from that directory (default index `/index.html`). +-- +-- @tparam function callback user function(request, response) +-- @tparam string location base directory for static files (optional) +-- @tparam table plugins list of plugin instances (optional) +-- @tparam table logger logger instance (optional) +-- @treturn Handler handler +---@param callback fun(request: table, response: table)|nil +---@param location string|nil +---@param plugins table|nil +---@param logger table|nil +---@return Handler function Handler:new(callback, location, plugins, logger) local handler = {} handler.log = logger or require('pegasus.log') @@ -27,6 +87,8 @@ function Handler:new(callback, location, plugins, logger) return result end +--- Allow plugins to alter `Request`/`Response` metatables before use. +-- Stops early if a plugin returns a truthy value. function Handler:pluginsAlterRequestResponseMetatable() for _, plugin in ipairs(self.plugins) do if plugin.alterRequestResponseMetaTable then @@ -38,6 +100,12 @@ function Handler:pluginsAlterRequestResponseMetatable() end end +--- Run `newConnection` hook across plugins. +-- A plugin may wrap or replace the client, or return falsy to abort. +-- @tparam table client accepted client socket +-- @treturn table|false client or false to stop +---@param client table +---@return table|false function Handler:pluginsNewConnection(client) for _, plugin in ipairs(self.plugins) do if plugin.newConnection then @@ -50,6 +118,14 @@ function Handler:pluginsNewConnection(client) return client end +--- Run `newRequestResponse` hook across plugins. +-- Stops early if a plugin returns a truthy value. +-- @tparam table request +-- @tparam table response +-- @treturn any stop value if any plugin aborts +---@param request table +---@param response table +---@return any function Handler:pluginsNewRequestResponse(request, response) for _, plugin in ipairs(self.plugins) do if plugin.newRequestResponse then @@ -61,6 +137,14 @@ function Handler:pluginsNewRequestResponse(request, response) end end +--- Run `beforeProcess` hook across plugins. +-- Stops early if a plugin returns a truthy value. +-- @tparam table request +-- @tparam table response +-- @treturn any stop value if any plugin aborts +---@param request table +---@param response table +---@return any function Handler:pluginsBeforeProcess(request, response) for _, plugin in ipairs(self.plugins) do if plugin.beforeProcess then @@ -72,6 +156,14 @@ function Handler:pluginsBeforeProcess(request, response) end end +--- Run `afterProcess` hook across plugins. +-- Stops early if a plugin returns a truthy value. +-- @tparam table request +-- @tparam table response +-- @treturn any stop value if any plugin aborts +---@param request table +---@param response table +---@return any function Handler:pluginsAfterProcess(request, response) for _, plugin in ipairs(self.plugins) do if plugin.afterProcess then @@ -83,6 +175,16 @@ function Handler:pluginsAfterProcess(request, response) end end +--- Run `processFile` hook across plugins for a given filename. +-- Stops early if a plugin returns a truthy value. +-- @tparam table request +-- @tparam table response +-- @tparam string filename +-- @treturn any stop value if any plugin aborts +---@param request table +---@param response table +---@param filename string +---@return any function Handler:pluginsProcessFile(request, response, filename) for _, plugin in ipairs(self.plugins) do if plugin.processFile then @@ -95,6 +197,17 @@ function Handler:pluginsProcessFile(request, response, filename) end end +--- Run the body data through plugins' `processBodyData` filters. +-- Each plugin receives `(data, stayOpen, request, response)` and returns the +-- (possibly) transformed data. The result of one plugin is passed to the next. +-- @tparam string data body chunk (may be empty string) +-- @tparam boolean stayOpen whether the connection stays open (chunked) +-- @tparam table response associated response +-- @treturn string transformed data +---@param data string +---@param stayOpen boolean +---@param response table +---@return string function Handler:processBodyData(data, stayOpen, response) local localData = data @@ -112,6 +225,18 @@ function Handler:processBodyData(data, stayOpen, response) return localData end +--- Process a single client by creating `Request`/`Response` and running pipeline. +-- If the callback does not close the response, a default 404 page is sent. +-- +-- @tparam string|number port server port +-- @tparam table client accepted client socket +-- @tparam table server listening server socket +-- @treturn[1] boolean|nil false when connection was rejected by a plugin +-- @treturn[2] nil normal completion +---@param port string|integer +---@param client table +---@param server table +---@return boolean|nil function Handler:processRequest(port, client, server) client = self:pluginsNewConnection(client) if not client then diff --git a/src/pegasus/init.lua b/src/pegasus/init.lua index dd34795..a4ffe2f 100644 --- a/src/pegasus/init.lua +++ b/src/pegasus/init.lua @@ -1,12 +1,73 @@ +--- Module `pegasus` +-- +-- Minimal, embeddable HTTP server with a simple plugin system. +-- +-- Basic usage: +-- ```lua +-- local Pegasus = require 'pegasus' +-- local server = Pegasus:new{ host = '127.0.0.1', port = '8080' } +-- server:start(function(request, response) +-- response:statusCode(200) +-- response:addHeader('Content-Type', 'text/plain') +-- response:write('Hello, world!') +-- end) +-- ``` +-- +-- Notes: +-- - If [LuaLogging](https://keplerproject.github.io/lualogging/) is available, it will be auto-detected and used for logging. +-- - Common plugins include `files`, `router`, `compress`, `downloads`, and `tls`. +-- - `start` runs a blocking accept loop; run in a dedicated OS thread/process if you need concurrency. +-- +-- @module pegasus + local socket = require 'socket' local Handler = require 'pegasus.handler' -- require lualogging if available, "pegasus.log" will automatically pick it up pcall(require, 'logging') +--- The Pegasus HTTP server class. +-- Instances are created via `Pegasus:new(params)`. +-- +-- Fields (defaults in parentheses): +-- - `host` ("*") bind address, e.g. "127.0.0.1" or "::". +-- - `port` ("9090") bind port. +-- - `location` ("") base directory for static files/plugins that use the filesystem. +-- - `plugins` ({}) array/table of plugin callables or plugin configurations. +-- - `timeout` (1) client socket timeout (seconds, blocking operations). +-- - `log` (auto) logger compatible with `pegasus.log` API. Defaults to `require('pegasus.log')` and integrates with LuaLogging when present. +-- +-- @type Pegasus +-- @tfield string host +-- @tfield string|number port +-- @tfield string location +-- @tfield table plugins +-- @tfield number timeout +-- @tfield table log +---@class Pegasus +---@field host string +---@field port string|integer +---@field location string +---@field plugins table +---@field timeout number +---@field log table local Pegasus = {} Pegasus.__index = Pegasus +--- Create a new Pegasus server instance. +-- +-- Parameters table accepts: +-- - `host`: bind address (default "*"). +-- - `port`: bind port (default "9090"). +-- - `location`: base directory used by some plugins (default ""). +-- - `plugins`: list/table of plugins to be applied (default {}). +-- - `timeout`: client socket timeout in seconds (default 1). +-- - `log`: logger instance; if omitted, `pegasus.log` is used (integrates with LuaLogging when available). +-- +-- @tparam[opt] table params configuration table +-- @treturn Pegasus server +---@param params table|nil +---@return Pegasus function Pegasus:new(params) params = params or {} local server = {} @@ -21,6 +82,26 @@ function Pegasus:new(params) return setmetatable(server, self) end +--- Start the server accept loop (blocking). +-- +-- The provided callback is invoked once per incoming HTTP request. +-- +-- Example: +-- ```lua +-- server:start(function(request, response) +-- -- inspect `request` (method, path, headers, body, query, etc.) +-- -- then write a response +-- response:statusCode(200) +-- response:addHeader('Content-Type', 'text/plain') +-- response:write('OK') +-- end) +-- ``` +-- +-- Errors during `socket.bind` will raise; accept errors are logged and the loop continues. +-- +-- @tparam function callback function(request, response) +-- @raise on bind failure +---@param callback fun(request: table, response: table) function Pegasus:start(callback) local handler = Handler:new(callback, self.location, self.plugins, self.log) local server = assert(socket.bind(self.host, self.port)) diff --git a/src/pegasus/plugins/compress.lua b/src/pegasus/plugins/compress.lua index b4bef02..1a38c43 100644 --- a/src/pegasus/plugins/compress.lua +++ b/src/pegasus/plugins/compress.lua @@ -1,3 +1,19 @@ +--- Module `pegasus.plugins.compress` +-- +-- Response body compressor that applies gzip when the client accepts it +-- and compression is beneficial. Works with both `lua-zlib` and `lzlib`. +-- +-- Behavior: +-- - Detects `Accept-Encoding: gzip` on the request (or `Content-Encoding` already set) +-- - Sets `Content-Encoding: gzip` when compressing +-- - Supports streaming with chunked encoding; maintains an internal zlib stream +-- - Leaves content unchanged when compression does not reduce size +-- +-- Options for `Compress:new{ ... }`: +-- - `level`: zlib compression level (defaults to zlib default; use `NO_COMPRESSION` to disable) +-- +-- @module pegasus.plugins.compress + local ZlibStream = {} do local zlib = require "zlib" @@ -80,6 +96,12 @@ local Compress = {} do Compress.BEST_COMPRESSION = ZlibStream.BEST_COMPRESSION Compress.DEFAULT_COMPRESSION = ZlibStream.DEFAULT_COMPRESSION + --- Create a new `Compress` plugin instance. + -- @tparam[opt] table options + -- @tparam[opt] number options.level zlib compression level + -- @treturn table plugin instance + ---@param options table|nil + ---@return Compress function Compress:new(options) local compress = {} compress.options = options or {} @@ -87,6 +109,25 @@ local Compress = {} do return setmetatable(compress, self) end + --- Compress response body data when appropriate. + -- + -- Invoked for every body chunk. When `stayOpen` is true, keeps an internal + -- zlib stream and returns the compressed chunk. On the final call with + -- `data == nil`, closes the stream and returns any trailer bytes. + -- + -- If compression does not reduce size (for non-streaming case), the + -- original `data` is returned and no `Content-Encoding` header is set. + -- + -- @tparam string|nil data body chunk, or `nil` to finish streaming + -- @tparam boolean stayOpen whether the response is streaming (chunked) + -- @tparam table request request object + -- @tparam table response response object + -- @treturn string data possibly compressed + ---@param data string|nil + ---@param stayOpen boolean + ---@param request table + ---@param response table + ---@return string function Compress:processBodyData(data, stayOpen, request, response) local accept_encoding diff --git a/src/pegasus/plugins/downloads.lua b/src/pegasus/plugins/downloads.lua index ebe60ea..4219d94 100644 --- a/src/pegasus/plugins/downloads.lua +++ b/src/pegasus/plugins/downloads.lua @@ -1,4 +1,16 @@ ---- A plugin that allows to download files via a browser. +--- Module `pegasus.plugins.downloads` +-- +-- A plugin that exposes a virtual directory for file downloads. Matches +-- a configurable `prefix` and serves files from a configured `location` +-- using `Content-Disposition: attachment`. +-- +-- @module pegasus.plugins.downloads +-- @usage +-- local Downloads = require('pegasus.plugins.downloads') +-- local plugin = Downloads:new{ location = './public', prefix = 'downloads' } +-- -- Add plugin to Pegasus +-- +-- The plugin only responds to `GET` and `HEAD`. local Downloads = {} Downloads.__index = Downloads @@ -14,6 +26,13 @@ Downloads.__index = Downloads -- for the file in the filesystem. Defaults to `false`, unless `options.prefix` is omitted, -- then it defaults to `true`. -- @return the new plugin +--- @tparam table options options table +--- @tparam[opt="./"] string options.location base directory (relative to cwd) +--- @tparam[opt="downloads/"] string options.prefix path prefix triggering the plugin +--- @tparam[opt] boolean options.stripPrefix whether to strip the prefix from the filesystem path +--- @treturn table plugin instance +---@param options table|nil +---@return Downloads function Downloads:new(options) options = options or {} local plugin = {} @@ -54,6 +73,13 @@ function Downloads:new(options) return plugin end +--- Handle a new request/response pair; serve a download when the path matches. +-- @tparam table request +-- @tparam table response +-- @treturn boolean stop whether request handling should stop +---@param request table +---@param response table +---@return boolean function Downloads:newRequestResponse(request, response) local stop = false diff --git a/src/pegasus/plugins/files.lua b/src/pegasus/plugins/files.lua index c0a3eb2..df67291 100644 --- a/src/pegasus/plugins/files.lua +++ b/src/pegasus/plugins/files.lua @@ -1,4 +1,9 @@ ---- A plugin that serves static content from a folder. +--- Module `pegasus.plugins.files` +-- +-- A plugin that serves static content from a folder and optionally redirects +-- `/` to a configured default file. +-- +-- @module pegasus.plugins.files local mimetypes = require 'mimetypes' @@ -14,6 +19,8 @@ Files.__index = Files -- @tparam[opt="index.html"] options.default string filename to serve for top-level without path. Use an empty -- string to have none. -- @return the new plugin +---@param options table|nil +---@return Files function Files:new(options) options = options or {} local plugin = {} @@ -46,6 +53,16 @@ end +--- Handle a new request/response pair; serve static files. +-- - Redirects `/` to `options.default` when set +-- - Serves `GET`/`HEAD` only +-- +-- @tparam table request +-- @tparam table response +-- @treturn boolean stop whether request handling should stop +---@param request table +---@param response table +---@return boolean function Files:newRequestResponse(request, response) local stop = false diff --git a/src/pegasus/plugins/router.lua b/src/pegasus/plugins/router.lua index 8bf8b12..7ebf1f2 100644 --- a/src/pegasus/plugins/router.lua +++ b/src/pegasus/plugins/router.lua @@ -1,4 +1,11 @@ ---- A plugin that routes requests based on path and method. +--- Module `pegasus.plugins.router` +-- +-- A plugin that routes requests based on path and method, with support for +-- path parameters and pre/post hooks at both router and path levels. +-- +-- @module pegasus.plugins.router +-- +-- A plugin that routes requests based on path and method. -- Supports path parameters. -- -- The `routes` table to configure the router is a hash-table where the keys are the path, and @@ -110,6 +117,17 @@ -- routes = routes, -- } +--- Router plugin instance. +-- +-- Options passed to `Router:new{ ... }`: +-- - `prefix` (string, optional): base path for all routes +-- - `routes` (table, required): route definitions (see module docs) +-- +-- Methods invoked by the handler: +-- - `newRequestResponse(request, response)` +-- +-- @type Router +---@class Router local Router = {} Router.__index = Router @@ -188,6 +206,8 @@ end -- @tparam[opt] options.prefix string the base path for all underlying routes. -- @tparam options.routes table route definitions to be handled by this router plugin instance. -- @return the new plugin +---@param options table|nil +---@return Router function Router:new(options) options = options or {} local plugin = {} @@ -206,6 +226,15 @@ end +--- Route the request to the matching path/method callback. +-- Populates `request.pathParameters` and `request.routerPath` upon match. +-- Executes callbacks in order: router pre, path pre, method, path post, router post. +-- @tparam table request +-- @tparam table response +-- @treturn boolean stop whether request handling should stop +---@param request table +---@param response table +---@return boolean function Router:newRequestResponse(request, response) local stop = false diff --git a/src/pegasus/plugins/tls.lua b/src/pegasus/plugins/tls.lua index 18c7ed6..4218c57 100644 --- a/src/pegasus/plugins/tls.lua +++ b/src/pegasus/plugins/tls.lua @@ -1,4 +1,12 @@ ---- A plugin that enables TLS (https). +--- Module `pegasus.plugins.tls` +-- +-- A plugin that enables TLS (https) for connections using LuaSec. +-- Should be the first plugin, since it wraps the client socket and performs +-- the TLS handshake before any other plugin or handler accesses the socket. +-- +-- @module pegasus.plugins.tls +-- +-- A plugin that enables TLS (https). -- This plugin should not be used with Copas. Since Copas has native TLS support -- and can handle simultaneous `http` and `https` connections. See the Copas example -- to learn how to set that up. @@ -23,6 +31,8 @@ TLS.__index = TLS -- } -- } -- local tls_plugin = require("pegasus.plugins.tls"):new(sslparams) +---@param sslparams table|nil +---@return TLS function TLS:new(sslparams) sslparams = sslparams or {} assert(sslparams.wrap, "'sslparam.wrap' is a required option") @@ -32,6 +42,14 @@ function TLS:new(sslparams) }, TLS) end +--- Wrap an accepted client socket and perform the TLS handshake. +-- Optionally sets SNI if provided in `sslparams`. +-- @tparam table client accepted client socket +-- @tparam table handler the Pegasus handler (for logging) +-- @treturn table|false wrapped client or false on failure +---@param client table +---@param handler table +---@return table|false function TLS:newConnection(client, handler) local params = self.sslparams diff --git a/src/pegasus/request.lua b/src/pegasus/request.lua index 8c9687b..c6579e2 100644 --- a/src/pegasus/request.lua +++ b/src/pegasus/request.lua @@ -1,3 +1,25 @@ +--- Module `pegasus.request` +-- +-- Parsed HTTP request facade used by your application callback. +-- Instances are created internally by Pegasus and passed to +-- `server:start(function(request, response) ... end)`. +-- +-- Quick example: +-- ```lua +-- server:start(function(req, res) +-- local method = req:method() +-- local path = req:path() +-- local headers = req:headers() +-- local qs = req.querystring -- table of query params +-- +-- if method == 'POST' then +-- local form = req:post() -- urlencoded form body, or nil if not POST +-- end +-- end) +-- ``` +-- +-- @module pegasus.request + local Response = require 'pegasus.response' local function normalizePath(path) @@ -28,6 +50,35 @@ local function normalizePath(path) return value end +--- The HTTP request object. +-- +-- Fields: +-- - `client`: the underlying accepted socket client. +-- - `server`: the listening server socket. +-- - `log`: logger compatible with `pegasus.log` API. +-- - `port`: server port bound. +-- - `ip`: remote peer address if available (may be `nil` under TLS). +-- - `querystring`: table of parsed query-string parameters. Values may be a string or a table of strings if repeated. +-- - `response`: associated `Response` object, with a back-reference via `response.request`. +-- +-- Methods of interest: +-- - `method()` -> string +-- - `path()` -> string +-- - `headers()` -> case-insensitive table of headers (values string or table) +-- - `receiveBody([size])` -> string|false (chunked read of request body) +-- - `post()` -> table|nil (parses `application/x-www-form-urlencoded` body when method is POST) +-- +-- Internal helpers: `parseFirstLine`, `parseUrlEncoded`. +-- +-- @type Request +---@class Request +---@field client table +---@field server table +---@field log table +---@field port string|integer +---@field ip string|nil +---@field querystring table +---@field response table local Request = {} Request.__index = Request Request.PATTERN_METHOD = '^(.-)%s' @@ -38,6 +89,18 @@ Request.PATTERN_PATH ..Request.PATTERN_PROTOCOL) Request.PATTERN_QUERY_STRING = '([^=]*)=([^&]*)&?' Request.PATTERN_HEADER = '([%w-]+):[ \t]*([%w \t%p]*)' +--- Internal: construct a new Request. +-- +-- @tparam string|number port server port +-- @tparam table client accepted client socket +-- @tparam table server listening server socket +-- @tparam table handler internal handler (provides `log` and body processing) +-- @treturn Request request +---@param port string|integer +---@param client table +---@param server table +---@param handler table +---@return Request function Request:new(port, client, server, handler) local obj = {} obj.client = client @@ -60,6 +123,7 @@ function Request:new(port, client, server, handler) return setmetatable(obj, self) end +--- Internal: parse the request line and query-string on first access. function Request:parseFirstLine() if (self._firstLine ~= nil) then return @@ -97,6 +161,12 @@ function Request:parseFirstLine() self.querystring = self:parseUrlEncoded(querystring) end +--- Parse `application/x-www-form-urlencoded` data. +-- Returns a table where duplicate keys are represented as an array of values. +-- @tparam string data +-- @treturn table params +---@param data string|nil +---@return table function Request:parseUrlEncoded(data) local output = {} @@ -118,22 +188,38 @@ function Request:parseUrlEncoded(data) return output end +--- Convenience: parse POST body as `application/x-www-form-urlencoded`. +-- Returns `nil` if method is not POST. +-- @treturn[1] table parsed form values +-- @treturn[2] nil when not a POST request +---@return table|nil function Request:post() if self:method() ~= 'POST' then return nil end local data = self:receiveBody() return self:parseUrlEncoded(data) end +--- Request path (normalized), without query-string. +-- @treturn string path +---@return string function Request:path() self:parseFirstLine() return self._path end +--- HTTP method. +-- @treturn string method +---@return string function Request:method() self:parseFirstLine() return self._method end +--- Read and parse headers. +-- Returns a case-insensitive table: looking up with any casing works. +-- If a header appears multiple times, the value becomes a table of strings. +-- @treturn table headers +---@return table function Request:headers() if self._headerParsed then return self._headers @@ -178,6 +264,13 @@ function Request:headers() return headers end +--- Receive a chunk of the request body. +-- Ensures headers are parsed. Returns `false` when there is no body or it is fully consumed. +-- When a socket timeout happens, any partial data is returned. +-- @tparam[opt] number size preferred chunk size +-- @treturn string|false chunk or false if no more content +---@param size number|nil +---@return string|false function Request:receiveBody(size) if not self._headerParsed then self:headers() diff --git a/src/pegasus/response.lua b/src/pegasus/response.lua index 13d4799..b5ba939 100644 --- a/src/pegasus/response.lua +++ b/src/pegasus/response.lua @@ -1,3 +1,20 @@ +--- Module `pegasus.response` +-- +-- HTTP response writer used by your application callback. +-- Instances are created internally by Pegasus and passed to +-- `server:start(function(request, response) ... end)`. +-- +-- Quick example: +-- ```lua +-- server:start(function(req, res) +-- res:statusCode(200) +-- :contentType('application/json') +-- :write('{"ok":true}') +-- end) +-- ``` +-- +-- @module pegasus.response + local mimetypes = require 'mimetypes' local function toHex(dec) @@ -82,9 +99,38 @@ local DEFAULT_ERROR_MESSAGE = [[ ]] +--- The HTTP response object. +-- +-- Usage pattern: chainable calls for fluent responses. +-- +-- Methods of interest: +-- - `statusCode(code[, text])` +-- - `contentType(value)` / `addHeader(name, value)` / `addHeaders(table)` +-- - `write(body[, stayOpen])` (streams when `stayOpen == true`) +-- - `close()` (finish chunked stream) +-- - `writeFile(path[, contentType])` (200 OK) +-- - `sendFile(path)` (attachment) +-- - `redirect(location[, temporary])` +-- +-- Notes: +-- - When streaming (`stayOpen == true`), Transfer-Encoding: chunked is used. +-- - For HEAD requests, bodies are automatically skipped. +-- +-- @type Response +---@class Response +---@field status integer +---@field request table local Response = {} Response.__index = Response +--- Internal: construct a new Response. +-- +-- @tparam table client accepted client socket +-- @tparam table writeHandler internal handler (provides `log` and body processing) +-- @treturn Response response +---@param client table +---@param writeHandler table +---@return Response function Response:new(client, writeHandler) local newObj = {} newObj.log = writeHandler.log @@ -101,12 +147,25 @@ function Response:new(client, writeHandler) return setmetatable(newObj, self) end +--- Add a response header. +-- Errors if headers were already sent. +-- @tparam string key +-- @tparam string|number|table value +-- @treturn Response self +---@param key string +---@param value any +---@return Response function Response:addHeader(key, value) assert(not self._headersSended, "can't add header, they were already sent") self._headers[key] = value return self end +--- Add multiple headers. +-- @tparam table params +-- @treturn Response self +---@param params table +---@return Response function Response:addHeaders(params) for key, value in pairs(params) do self:addHeader(key, value) @@ -115,10 +174,23 @@ function Response:addHeaders(params) return self end +--- Set the `Content-Type` header. +-- @tparam string value +-- @treturn Response self +---@param value string +---@return Response function Response:contentType(value) return self:addHeader('Content-Type', value) end +--- Set the HTTP status line. +-- Must be called before headers are sent. +-- @tparam number statusCode +-- @tparam[opt] string statusText (defaults to standard text for the code) +-- @treturn Response self +---@param statusCode integer +---@param statusText string|nil +---@return Response function Response:statusCode(statusCode, statusText) assert(not self._headersSended, "can't set status code, it was already sent") self.status = statusCode @@ -128,6 +200,9 @@ function Response:statusCode(statusCode, statusText) return self end +--- Skip writing the response body (used for HEAD requests). +-- @tparam[opt] boolean skip defaults to true +---@param skip boolean|nil function Response:skipBody(skip) if skip == nil then skip = true @@ -135,6 +210,8 @@ function Response:skipBody(skip) self._skipBody = not not skip end +--- Internal: serialize headers. +---@return string function Response:_getHeaders() local headers = {} @@ -151,6 +228,13 @@ function Response:_getHeaders() return table.concat(headers) end +--- Write a default HTML error body for a given status. +-- @tparam number statusCode +-- @tparam[opt] string errMessage +-- @treturn Response self +---@param statusCode integer +---@param errMessage string|nil +---@return Response function Response:writeDefaultErrorMessage(statusCode, errMessage) self:statusCode(statusCode) local content = string.gsub(DEFAULT_ERROR_MESSAGE, '{{ STATUS_CODE }}', statusCode) @@ -159,6 +243,10 @@ function Response:writeDefaultErrorMessage(statusCode, errMessage) return self end +--- Finish a chunked response and mark as closed. +-- Idempotent; safe to call multiple times. +-- @treturn Response self +---@return Response function Response:close() if not self.closed then local body = self._writeHandler:processBodyData(nil, true, self) @@ -177,6 +265,10 @@ function Response:close() return self end +--- Send only the headers, without a body. +-- Useful for redirects and HEAD responses. +-- @treturn Response self +---@return Response function Response:sendOnlyHeaders() self:sendHeaders(false, '') self:write('\r\n') @@ -184,6 +276,15 @@ function Response:sendOnlyHeaders() return self end +--- Send headers if not already sent. +-- Adds `Transfer-Encoding: chunked` when `stayOpen == true`; otherwise sets `Content-Length` when body is a string. +-- Also sets a default `Date` and `Content-Type` header if not present. +-- @tparam boolean stayOpen whether to keep the connection open for chunked streaming +-- @tparam[opt] string body current body chunk (used to set Content-Length) +-- @treturn Response self +---@param stayOpen boolean +---@param body string|nil +---@return Response function Response:sendHeaders(stayOpen, body) if self._headersSended then return self @@ -208,6 +309,16 @@ function Response:sendHeaders(stayOpen, body) return self end +--- Write response body. +-- When `stayOpen == true`, the body is sent as a chunk and the connection remains open. +-- When `stayOpen ~= true`, headers are sent with `Content-Length` and the socket is closed afterwards. +-- `nil` body is treated as empty string. +-- @tparam[opt] string body +-- @tparam[opt] boolean stayOpen +-- @treturn Response self +---@param body string|nil +---@param stayOpen boolean|nil +---@return Response function Response:write(body, stayOpen) body = self._writeHandler:processBodyData(body or '', stayOpen, self) self:sendHeaders(stayOpen, body) @@ -242,7 +353,15 @@ local function readfile(filename) return value, err end --- return nil+err if not ok +--- Write a file to the response with a 200 status. +-- Returns nil+err if file cannot be read. +-- @tparam string|file* filename path or legacy file descriptor (deprecated) +-- @tparam[opt] string contentType override content type +-- @treturn[1] Response self +-- @treturn[2] nil,error on failure +---@param filename any +---@param contentType string|nil +---@return Response|nil,any function Response:writeFile(filename, contentType) if type(filename) ~= "string" then -- deprecated backward compatibility; file is a file-descriptor @@ -265,7 +384,13 @@ function Response:writeFile(filename, contentType) return self end --- download by browser, return nil+err if not ok +--- Send a file as an attachment (download). +-- Returns nil+err if the file cannot be read. +-- @tparam string path filesystem path +-- @treturn[1] Response self +-- @treturn[2] nil,error on failure +---@param path string +---@return Response|nil,any function Response:sendFile(path) local filename = path:match("[^/]*$") -- only filename, no path self:addHeader('Content-Disposition', 'attachment; filename="' .. filename .. '"') @@ -279,6 +404,13 @@ function Response:sendFile(path) return self end +--- Redirect to a different URL. +-- @tparam string location destination URL +-- @tparam[opt] boolean temporary when true uses 302, otherwise 301 +-- @treturn Response self +---@param location string +---@param temporary boolean|nil +---@return Response function Response:redirect(location, temporary) self:statusCode(temporary and 302 or 301) self:addHeader('Location', location) From 2002aa4ef2d68fa0d6132189cf46df858a48c650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Evandro=20Leopoldino=20Gon=C3=A7alves?= Date: Sun, 10 Aug 2025 20:32:45 +0200 Subject: [PATCH 2/4] Update src/pegasus/plugins/router.lua Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/pegasus/plugins/router.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pegasus/plugins/router.lua b/src/pegasus/plugins/router.lua index 7ebf1f2..783d319 100644 --- a/src/pegasus/plugins/router.lua +++ b/src/pegasus/plugins/router.lua @@ -5,7 +5,6 @@ -- -- @module pegasus.plugins.router -- --- A plugin that routes requests based on path and method. -- Supports path parameters. -- -- The `routes` table to configure the router is a hash-table where the keys are the path, and From 50d69f134348df548152b738ea40c93193db8166 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Evandro=20Leopoldino=20Gon=C3=A7alves?= Date: Sun, 10 Aug 2025 20:32:51 +0200 Subject: [PATCH 3/4] Update src/pegasus/plugins/tls.lua Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/pegasus/plugins/tls.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pegasus/plugins/tls.lua b/src/pegasus/plugins/tls.lua index 4218c57..2963223 100644 --- a/src/pegasus/plugins/tls.lua +++ b/src/pegasus/plugins/tls.lua @@ -6,7 +6,6 @@ -- -- @module pegasus.plugins.tls -- --- A plugin that enables TLS (https). -- This plugin should not be used with Copas. Since Copas has native TLS support -- and can handle simultaneous `http` and `https` connections. See the Copas example -- to learn how to set that up. From b38f053bc4b945fa91600f97c9891c0d17a31dd8 Mon Sep 17 00:00:00 2001 From: Evandro Leopoldino Goncalves Date: Mon, 11 Aug 2025 19:48:54 +0200 Subject: [PATCH 4/4] release: add rockspec for v1.0.9-0 Prepare LuaRocks spec for 1.0.9 release. --- rockspecs/pegasus-1.0.9-0.rockspec | 49 ++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 rockspecs/pegasus-1.0.9-0.rockspec diff --git a/rockspecs/pegasus-1.0.9-0.rockspec b/rockspecs/pegasus-1.0.9-0.rockspec new file mode 100644 index 0000000..3487b8e --- /dev/null +++ b/rockspecs/pegasus-1.0.9-0.rockspec @@ -0,0 +1,49 @@ +local package_name = "pegasus" +local package_version = "1.0.9" +local rockspec_revision = "0" +local github_account_name = "evandrolg" +local github_repo_name = "pegasus.lua" + + +package = package_name +version = package_version .. "-" .. rockspec_revision + +source = { + url = "git+https://github.com/" .. github_account_name .. "/" .. github_repo_name .. ".git", + branch = (package_version == "dev") and "master" or nil, + tag = (package_version ~= "dev") and ("v" .. package_version) or nil, +} + +description = { + summary = 'Pegasus.lua is an http server to work with web applications written in Lua language.', + maintainer = 'Evandro Leopoldino Gonçalves (@evandrolg) ', + license = 'MIT ', + homepage = "https://github.com/" .. github_account_name .. "/" .. github_repo_name, +} + +dependencies = { + "lua >= 5.1", + "mimetypes >= 1.0.0-1", + "luasocket >= 0.1.0-0", + "luafilesystem >= 1.6", + "lzlib >= 0.4.1.53-1", +} + +build = { + type = "builtin", + modules = { + ['pegasus.init'] = "src/pegasus/init.lua", + ['pegasus.handler'] = 'src/pegasus/handler.lua', + ['pegasus.request'] = 'src/pegasus/request.lua', + ['pegasus.response'] = 'src/pegasus/response.lua', + ['pegasus.compress'] = 'src/pegasus/compress.lua', + ['pegasus.log'] = 'src/pegasus/log.lua', + ['pegasus.plugins.compress'] = 'src/pegasus/plugins/compress.lua', + ['pegasus.plugins.downloads'] = 'src/pegasus/plugins/downloads.lua', + ['pegasus.plugins.files'] = 'src/pegasus/plugins/files.lua', + ['pegasus.plugins.router'] = 'src/pegasus/plugins/router.lua', + ['pegasus.plugins.tls'] = 'src/pegasus/plugins/tls.lua', + } +} + +