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-Control header.

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_age for static assets (CSS, JS, images) while leaving HTML fragments at the default no-cache prevents 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.html

  • Returns 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_size receive 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_query for both methods.

/info

Displays the full PSGI $env hash 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)