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

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-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 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_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.

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:SS using only localtime and sprintf. This replaces the earlier dependency on POSIX::strftime, making the module free of POSIX entirely.

_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 an app argument.

HTTP::Handy->run: 'app' must be a code reference

The value passed to app is not a code reference.

HTTP::Handy->run: 'port' must be a number

The port argument contains non-digit characters.

HTTP::Handy->run: 'max_post_size' must be a number

The value passed to max_post_size contains 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->accept returned 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 -V
  • Your 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 time run() 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 .pm file.

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.

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.