NAME
HTTP::Handy - A tiny HTTP/1.0 server for Perl 5.5.3 and later
VERSION
Version 1.03
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);
TABLE OF CONTENTS
"INCLUDED DOCUMENTATION" -- eg/ samples and doc/ cheat sheets
"PSGI SUBSET SPECIFICATION" --
$envkeys, response format, psgi.input"SERVER STARTUP" --
run(), directory init, log files"METHODS" --
serve_static,url_decode,parse_query,mime_type,is_htmx, response builders"DIAGNOSTICS" -- error messages and runtime warnings
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.
What is PSGI?
PSGI (Perl Web Server Gateway Interface) is a standard interface between Perl web applications and web servers, inspired by Python's WSGI and Ruby's Rack. A PSGI application is a plain code reference:
my $app = sub {
my $env = shift; # request environment hashref
# ... process the request ...
return [$status, \@headers, \@body]; # response arrayref
};
Because the interface is a simple data structure (hashref in, arrayref out), PSGI applications are portable across any PSGI-compatible server -- from the minimal HTTP::Handy to the full-featured Plack toolkit.
Official PSGI specification: https://github.com/plack/psgi-specs/blob/master/PSGI.pod
PSGI on MetaCPAN: https://metacpan.org/pod/PSGI
INCLUDED DOCUMENTATION
The eg/ directory contains sample programs demonstrating PSGI features:
eg/01_hello_world.pl Minimal app: routing, query string, env dump
eg/02_static_files.pl serve_static, cache_max_age, mime_type
eg/03_form_post.pl POST body, parse_query, multi-value fields,
Post-Redirect-Get pattern
eg/04_ltsv_viewer.pl is_htmx, LTSV log parsing, multiple status codes
The doc/ directory contains PSGI cheat sheets in 21 languages:
doc/psgi_cheatsheet.EN.txt English
doc/psgi_cheatsheet.JA.txt Japanese
doc/psgi_cheatsheet.ZH.txt Chinese (Simplified)
doc/psgi_cheatsheet.TW.txt Chinese (Traditional)
doc/psgi_cheatsheet.KO.txt Korean
doc/psgi_cheatsheet.FR.txt French
doc/psgi_cheatsheet.ID.txt Indonesian
doc/psgi_cheatsheet.VI.txt Vietnamese
doc/psgi_cheatsheet.TH.txt Thai
doc/psgi_cheatsheet.HI.txt Hindi
doc/psgi_cheatsheet.BN.txt Bengali
doc/psgi_cheatsheet.TR.txt Turkish
doc/psgi_cheatsheet.MY.txt Malay
doc/psgi_cheatsheet.TL.txt Filipino
doc/psgi_cheatsheet.KM.txt Khmer
doc/psgi_cheatsheet.MN.txt Mongolian
doc/psgi_cheatsheet.NE.txt Nepali
doc/psgi_cheatsheet.SI.txt Sinhala
doc/psgi_cheatsheet.UR.txt Urdu
doc/psgi_cheatsheet.UZ.txt Uzbek
doc/psgi_cheatsheet.BM.txt Burmese
Each cheat sheet covers: starting the server, $env keys, response format, reading the POST body, utility methods, response builders, static files, routing patterns, error handling, log files, and links to the official PSGI specification.
REQUIREMENTS
Perl : 5.5.3 or later -- all versions, all platforms
OS : Any (Windows, Unix, macOS, and others)
Modules : Core only -- IO::Socket, 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);
Directory Initialisation
run() automatically creates an Apache-like directory structure under the current working directory if the directories do not already exist:
logs/ parent directory for all log files
logs/access/ access logs (LTSV format, 10-minute rotation)
logs/error/ error log
run/ PID files and other runtime files
htdocs/ suggested document root for serve_static
conf/ configuration files
Access Log Format (LTSV)
When log is enabled, each request is written both to STDERR and to a rotating LTSV file under logs/access/.
File naming: logs/access/YYYYMMDDHHm0.log.ltsv where m0 is the 10-minute interval (00, 10, 20, 30, 40, or 50). The file is rotated automatically when the interval changes.
Each log line is a single LTSV record:
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.
Error Log
Server startup messages and application errors are written both to STDERR and to logs/error/error.log. Each line is prefixed with an ISO 8601 timestamp in brackets:
[2026-01-01T12:00:00] HTTP::Handy 1.01 started on http://0.0.0.0:8080/
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 lib/HTTP/Handy.pm # from the distribution directory
perl lib/HTTP/Handy.pm 9090 # 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.
To start a minimal server after installation via cpan or make install:
perl -MHTTP::Handy -e 'HTTP::Handy->run(app=>sub{[200,[],["ok"]]})'
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
INTERNALS -- Private Functions
_iso_time()-
Returns the current local time formatted as
YYYY-MM-DDTHH:MM:SSusing onlylocaltimeandsprintf. This replaces the earlier dependency onPOSIX::strftime, making the module free ofPOSIXentirely. _init_directories()-
Called once at server startup by
run(). Creates the standard directory layout (logs/,logs/access/,logs/error/,run/,htdocs/,conf/) under the current working directory if they do not exist. _open_access_log()-
Called after each request when logging is enabled. Opens (or rotates to) the current 10-minute LTSV access log file under
logs/access/. The filehandle is kept open between requests for efficiency and is only reopened when the 10-minute window rolls over.
DIAGNOSTICS
Startup errors
HTTP::Handy->run: 'app' is required-
run()was called without anappargument. HTTP::Handy->run: 'app' must be a code reference-
The value passed to
appis not a code reference. HTTP::Handy->run: 'port' must be a number-
The
portargument contains non-digit characters. HTTP::Handy->run: 'max_post_size' must be a number-
The value passed to
max_post_sizecontains non-digit characters. HTTP::Handy: Cannot bind to <host>:<port> - <reason>-
The server could not bind to the requested address and port. The most common cause is that another process is already listening on that port.
Runtime messages (STDERR and logs/error/error.log)
[TIMESTAMP] App error: MESSAGE-
The application code died with MESSAGE. A 500 response was sent to the client. The server continues running.
[TIMESTAMP] Accept failed: MESSAGE-
IO::Socket::INET->acceptreturned an error. The server continues to the next request. Cannot open access log: FILENAME: MESSAGE-
_open_access_log()could not open or create the rotating access log file. Access log entries are still written to STDERR.
BUGS AND LIMITATIONS
Please report any bugs or feature requests by e-mail to <ina@cpan.org>.
When reporting a bug, please include:
A minimal, self-contained test script that reproduces the problem.
The version of HTTP::Handy:
perl -MHTTP::Handy -e 'print HTTP::Handy->VERSION, "\n"'Your Perl version:
perl -VYour operating system.
Known limitations (see also "LIMITATIONS"):
Single-process, single-thread. Requests are handled one at a time. A slow client blocks all other clients for the duration of that request.
No HTTPS. See "HTTPS".
POST body fully buffered. The entire POST body is read into memory before the application is called.
Log files use the current working directory.
logs/,htdocs/, and other directories created by_init_directories()are relative to the process working directory at the timerun()is called.
DESIGN PHILOSOPHY
HTTP::Handy adheres to the Perl 5.005_03 specification -- not because we target the old interpreter, but because this specification represents the simple, original Perl programming model that makes programming enjoyable.
- Simplicity
-
One file, no build step, no installation required beyond copying. The entire server fits in a single
.pmfile. - Portability
-
Runs on every Perl from 5.005_03 through the latest release, on every operating system that Perl supports.
- Zero dependencies
-
Only core modules (
IO::Socket,Carp) are used. No CPAN installation is required. - US-ASCII source
-
All source files contain only US-ASCII characters (0x00-0x7F). This avoids encoding issues on any platform or terminal.
SEE ALSO
PSGI Specification
PSGI -- the Perl Web Server Gateway Interface specification (on MetaCPAN).
Official PSGI specification document:
https://github.com/plack/psgi-specs/blob/master/PSGI.pod
HTTP::Handy implements a strict subset of PSGI/1.1. Applications written for HTTP::Handy can be ported to full PSGI-compatible servers (such as Plack) with little or no modification, because the $env hash and response format are identical.
Related Modules
Plack -- a full-featured PSGI toolkit and server collection. Requires Perl 5.8+. For production use or more demanding workloads, migrating from HTTP::Handy to Plack is straightforward.
https://plackperl.org/
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.
INABA Hitoshi <ina@cpan.org>
COPYRIGHT AND LICENSE
This software is free software; you can redistribute it and/or modify it under the same terms as Perl itself.