NAME
HTTP::Handy - A tiny HTTP/1.0 server for Perl 5.5.3 and later
VERSION
1.00
SYNOPSIS
use HTTP::Handy;
my $app = sub {
my $env = shift;
return [200, ['Content-Type', 'text/plain'], ['Hello, World!']];
};
HTTP::Handy->run(app => $app, port => 8080);
DESCRIPTION
HTTP::Handy is a single-file, zero-dependency HTTP/1.0 server for Perl. It implements a subset of the PSGI specification and is designed for personal use, local tools, and rapid development.
The goals of the project are simplicity and portability. The entire implementation fits in one file with no installation step beyond copying it into your project directory.
REQUIREMENTS
Perl : 5.5.3 or later -- all versions, all platforms
OS : Any (Windows, Unix, macOS, and others)
Modules : Core only -- IO::Socket, POSIX, Carp
Model : Single process, single thread
No CPAN modules are required. No C compiler or external library is needed.
SUPPORTED PROTOCOL
HTTP/1.0 only (no Keep-Alive)
Methods: GET and POST only
Connection is closed immediately after each response
PSGI SUBSET SPECIFICATION
Application Interface
A HTTP::Handy application is a plain code reference that receives a request environment hash and returns a three-element response arrayref:
my $app = sub {
my ($env) = @_;
return [$status, \@headers, \@body];
};
Request Environment -- $env
The following keys are provided in the environment hashref passed to the app:
Key Description
---------------- ------------------------------------------------
REQUEST_METHOD "GET" or "POST"
PATH_INFO URL path (e.g. "/index.html")
QUERY_STRING Query string ("key=val&..."), without leading "?"
SERVER_NAME Server hostname
SERVER_PORT Port number (integer)
CONTENT_TYPE Content-Type header of POST request
CONTENT_LENGTH Content-Length of POST body (integer)
HTTP_* Request headers, uppercased, hyphens as underscores
psgi.input Object with read() for the POST body (see below)
psgi.errors \*STDERR
psgi.url_scheme Always "http"
psgi.input Object
The psgi.input value is a HTTP::Handy::Input object. It provides:
$env->{'psgi.input'}->read($buf, $length) # read up to $length bytes
$env->{'psgi.input'}->read($buf, $len, $off) # read with offset
$env->{'psgi.input'}->seek($pos, $whence) # reposition
$env->{'psgi.input'}->tell() # current position
$env->{'psgi.input'}->getline() # read one line
$env->{'psgi.input'}->getlines() # read all lines
This object works on Perl 5.5.3, which does not support open my $fh, '<', \$scalar.
Response Format
The application must return an arrayref of exactly three elements:
[$status_code, \@headers, \@body]
$status_code-
An integer HTTP status code (e.g. 200, 404, 500).
\@headers-
A flat arrayref of header name/value pairs, alternating:
['Content-Type', 'text/html', 'X-Custom', 'value'] \@body-
An arrayref of strings. All elements are joined and sent as the response body.
['<html>', '<body>Hello</body>', '</html>']
Example:
return [200,
['Content-Type', 'text/html; charset=utf-8'],
['<h1>Hello HTTP::Handy</h1>']];
SERVER STARTUP
run(%args)
Starts the HTTP server. This call blocks indefinitely (until the process is killed).
HTTP::Handy->run(
app => $app, # required: PSGI app code reference
host => '127.0.0.1', # optional: bind address (default: 0.0.0.0)
port => 8080, # optional: port number (default: 8080)
log => 1, # optional: access log to STDERR (default: 1)
max_post_size => 10485760, # optional: max POST bytes (default: 10MB)
);
max_post_size controls how large a POST body the server will accept. Requests exceeding this limit receive a 413 response. The value is in bytes.
# Accept POST bodies up to 50 MB (e.g. for LTSV log file uploads)
HTTP::Handy->run(app => $app, port => 8080, max_post_size => 50 * 1024 * 1024);
Access Log Format (LTSV)
When log is enabled, each request is written to STDERR as a single LTSV (Labeled Tab-separated Values) line:
time:2026-01-01T12:00:00\tmethod:GET\tpath:/index.html\tstatus:200\tsize:1234\tua:Mozilla/5.0\treferer:
Fields:
time ISO 8601 local timestamp (YYYY-MM-DDTHH:MM:SS)
method HTTP method (GET or POST)
path Request path (PATH_INFO, without query string)
status HTTP status code
size Response body size in bytes
ua User-Agent header value (empty string if absent)
referer Referer header value (empty string if absent)
LTSV can be parsed line by line with split /\t/ and each field with split /:/, $field, 2. It is directly compatible with LTSV::LINQ.
METHODS
serve_static($env, $docroot [, %opts])
Serve a static file from $docroot using PATH_INFO as the file path. Returns a complete PSGI response arrayref.
my $res = HTTP::Handy->serve_static($env, './htdocs');
# With cache control (e.g. for htmx apps: cache JS/CSS, never cache HTML)
my $res = HTTP::Handy->serve_static($env, './htdocs', cache_max_age => 3600);
Options:
cache_max_age-
Sets the
Cache-Controlheader.cache_max_age => 3600 # Cache-Control: public, max-age=3600 cache_max_age => 0 # Cache-Control: no-cache (not specified) # Cache-Control: no-cache (default)For htmx applications, setting a positive
cache_max_agefor static assets (CSS, JS, images) while leaving HTML fragments at the defaultno-cacheprevents stale scripts from being reused after a partial page update.
Behaviour:
MIME type is detected automatically from the file extension
Supported types: html, htm, txt, css, js, json, xml, png, jpg, jpeg, gif, ico, svg, pdf, zip, gz, ltsv, csv, tsv
Directory access attempts to serve
index.htmlReturns 404 if the file does not exist
Returns 403 if the file cannot be opened
Path traversal (
..) is blocked with a 403 response
url_decode($str)
Decode a percent-encoded URL string. + is decoded as a space.
my $str = HTTP::Handy->url_decode('hello+world%21');
# returns: "hello world!"
parse_query($query_string)
Parse a URL query string into a hash. When the same key appears more than once, its value becomes an arrayref.
my %p = HTTP::Handy->parse_query('name=ina&tag=perl&tag=cpan');
# $p{name} eq 'ina'
# $p{tag} is ['perl', 'cpan']
mime_type($ext)
Return the MIME type string for a given file extension. The leading dot is optional.
HTTP::Handy->mime_type('html'); # 'text/html; charset=utf-8'
HTTP::Handy->mime_type('.json'); # 'application/json'
HTTP::Handy->mime_type('xyz'); # 'application/octet-stream'
is_htmx($env)
Returns 1 if the request was made by htmx (i.e. the HX-Request: true header is present), or 0 otherwise.
if (HTTP::Handy->is_htmx($env)) {
# Return an HTML fragment only
return HTTP::Handy->response_html($fragment);
} else {
# Return the full page for direct browser access
return HTTP::Handy->response_html($full_page);
}
htmx sets HX-Request: true on all requests it initiates (hx-get, hx-post, etc.), making this the standard way to distinguish partial updates from full page loads.
response_html($html [, $code])
Build an HTML response. Sets Content-Type to text/html; charset=utf-8 and Content-Length automatically. Default status is 200.
return HTTP::Handy->response_html('<h1>Hello</h1>');
return HTTP::Handy->response_html('<h1>Created</h1>', 201);
response_text($text [, $code])
Build a plain text response. Sets Content-Type to text/plain; charset=utf-8. Default status is 200.
return HTTP::Handy->response_text('Hello, World!');
response_json($json_str [, $code])
Build a JSON response. Sets Content-Type to application/json. The caller is responsible for encoding the JSON string. Default status is 200.
use mb::JSON; # or any JSON encoder that works with Perl 5.5.3
return HTTP::Handy->response_json(encode_json(\%data));
response_redirect($location [, $code])
Build a redirect response with a Location header. Default status is 302.
return HTTP::Handy->response_redirect('/new/path');
return HTTP::Handy->response_redirect('https://example.com/', 301);
ERROR HANDLING
If the application
dies, a 500 response is sent to the client and the error message is printed to STDERR. The server continues running.An unsupported HTTP method returns a 405 response.
A POST body exceeding
max_post_size(default 10 MB) returns a 413 response.Socket errors are printed to STDERR and the server continues to the next request.
STATIC FILES, CGI, AND HTMX
HTTP::Handy can serve static files and handle dynamic routes in the same application, making it self-contained with no external web server needed.
my $app = sub {
my $env = shift;
my $path = $env->{PATH_INFO};
# Dynamic API route (used as HTMX target)
if ($path =~ m{^/api/}) {
my $html_fragment = compute_fragment($env);
return HTTP::Handy->response_html($html_fragment);
}
# Static files (HTML, CSS, JS)
return HTTP::Handy->serve_static($env, './htdocs');
};
When used with HTMX, the server simply returns HTML fragments for hx-get / hx-post requests. No special support is required.
Reading POST body (equivalent to CGI's STDIN):
my $body = '';
$env->{'psgi.input'}->read($body, $env->{CONTENT_LENGTH} || 0);
my %post = HTTP::Handy->parse_query($body);
HTTPS
HTTP::Handy does not support HTTPS. TLS requires IO::Socket::SSL and OpenSSL, which depend on Perl 5.8+ and external C libraries.
For local personal use, this is not a problem: modern browsers treat 127.0.0.1 and localhost as secure contexts and do not show HTTPS warnings for HTTP on these addresses.
For LAN or internet use, place a reverse proxy in front of HTTP::Handy:
Browser <--HTTPS--> Caddy / nginx / Apache <--HTTP--> HTTP::Handy
A minimal Caddy configuration:
localhost {
reverse_proxy 127.0.0.1:8080
}
PSGI COMPATIBILITY NOTES
HTTP::Handy implements a strict subset of the PSGI/1.1 specification. The following keys defined by the PSGI spec are not set in $env:
psgi.version (PSGI requires [1,1]; not set)
psgi.multithread (not set; effectively false)
psgi.multiprocess (not set; effectively false)
psgi.run_once (not set; effectively false)
psgi.nonblocking (not set; always blocking)
psgi.streaming (not set; not supported)
Applications that check for these keys must treat their absence as false. For full PSGI/1.1 compliance use Plack (requires Perl 5.8+).
SECURITY
HTTP::Handy is designed for personal use and local development only. It is not hardened for production or internet-facing deployment.
No authentication or access control. Any client that can reach the listening port has unrestricted access.
No rate limiting or DoS protection. A slow or malicious client can occupy the single-threaded server indefinitely.
No HTTPS. All traffic is transmitted in plaintext (see "HTTPS").
POST body capped at 10 MB by default. Requests exceeding
max_post_sizereceive a 413 response, but there is no timeout on slow uploads.
Recommended practice: bind to 127.0.0.1 (loopback only) and place a hardened reverse proxy in front of HTTP::Handy for any LAN or internet use.
LIMITATIONS
HTTP/1.0 only -- no Keep-Alive, no HTTP/1.1, no HTTP/2
GET and POST only -- HEAD, PUT, DELETE, etc. return 405
Single process, single thread -- requests are handled one at a time
No HTTPS (see above)
No chunked transfer encoding
No streaming -- POST body and response body are fully buffered in memory
Maximum POST body size: 10 MB by default (configurable via
max_post_size)No cookie or session management (implement in the application layer)
DEMO
Run directly to start a self-contained demo server:
perl HTTP::Handy.pm # listens on port 8080 (default)
perl HTTP::Handy.pm 9090 # listens on port 9090
Then open http://localhost:8080/ (or the port you specified) in your browser. The demo provides three built-in pages:
/-
Top page with a GET query form and a POST form.
/echo-
Echoes GET query parameters or POST form fields in a table. Demonstrates
parse_queryfor both methods. /info-
Displays the full PSGI
$envhash for the current request. Useful for understanding what HTTP::Handy provides to the application, and for debugging routing logic.
INTERNALS -- HTTP::Handy::Input
HTTP::Handy::Input is a lightweight in-memory object that acts as a readable filehandle. It is used as the value of psgi.input in the request environment.
The reason for a custom object rather than a real filehandle is compatibility with Perl 5.5.3: the convenient idiom open my $fh, '<', \$scalar (opening a filehandle on an in-memory string) was not introduced until Perl 5.6.0. HTTP::Handy::Input provides the same interface without relying on that feature.
The object is not exported and is not intended to be instantiated directly by application code. Applications should access POST body data through $env->{'psgi.input'} as described in "PSGI SUBSET SPECIFICATION".
Available methods:
new($data) construct from a string
read($buf, $length) read up to $length bytes into $buf
read($buf, $length, $offset) read with byte offset into $buf
seek($pos, $whence) reposition (whence: 0=SET, 1=CUR, 2=END)
tell() return current byte position
getline() read and return one line (with newline)
getlines() read and return all remaining lines
SEE ALSO
PSGI -- the Perl Web Server Gateway Interface specification. HTTP::Handy implements a strict subset of PSGI. Applications written for HTTP::Handy can be ported to full PSGI servers (such as Plack) with little or no modification.
Plack -- a full-featured PSGI toolkit. Requires Perl 5.8+. For production use or more demanding workloads, migrating from HTTP::Handy to Plack is straightforward because the $env and response format are the same.
HTTP::Server::Simple -- another minimal HTTP server for Perl, with a different (non-PSGI) interface.
LTSV::LINQ -- LINQ-style queries for LTSV data, by the same author. HTTP::Handy was originally developed to serve local tools built on top of LTSV::LINQ.
LICENSE
Same as Perl itself: Artistic License or GPL, at your option.
AUTHOR
ina (CPAN)