NAME
FU - Framework Ultimatum: A Lean and Efficient Zero-Dependency Web Framework.
EXPERIMENTAL
This module is still in development: it's missing important functionality and there will likely be a few breaking API changes. This framework currently powers manned.org as a test. I'll do a stable 1.0 release once FU is used in production for vndb.org, which will take a few months in the best case scenario.
SYNOPSIS
use v5.36;
use FU -spawn;
use FU::XMLWriter ':html5_';
sub myhtml_($title, $body) {
fu->set_body(html_ sub {
head_ sub {
title_ $title;
};
body_ $body;
});
}
FU::get qr{/hello/(.+)}, sub($who) {
my_html_ "Website title", sub {
h1_ "Hello, $who!";
};
};
FU::run;
DESCRIPTION
Distribution Overview
This top-level FU
module is a web development framework. The FU
distribution also includes a bunch of modules that the framework depends on or which are otherwise useful when building web backends. These modules are standalone and can be used independently of the framework:
FU::Util - JSON parsing & formatting, URI encoding, etc.
FU::Pg - PostgreSQL client.
FU::SQL - Small and safe query builder.
FU::Validate - Input validation through a schema.
FU::XMLWriter - Dynamic XML generation, easy and fast.
FU::Log - Global logger.
Note that everything in this distribution requires a moderately recent version of Perl (5.36+), a C compiler and a 64-bit POSIXy system (not Windows, that is). There are a few additional optional dependencies:
libpq.so
- required for FU::Pg, dynamically loaded throughdlopen()
.
Framework Overview
FU
is a mostly straightforward and conventional backend web framework. It doesn't try to be particularly innovative, but it does attempt to implement existing ideas in a convenient, coherent and efficient way. There are a few inherent properties of FU
's design that you will want to be aware of before digging further:
- FU is synchronous
-
FU
is an entirely synchronous framework, meaning that a single Perl process can only handle a single request at a time. This is great in that it simplifies the implementation, makes debugging easy and performance predictable.The downside is that you will want to avoid long-running requests as much as possible. Potentially slow network operations are best delegated to a background queue.
FU
intentionally does not support websockets, long-polling might work but is a bad idea because you'll need to run as many processes as there are concurrent clients, which gets wasteful very fast. If some UI latency is acceptable, interval-based polling tends to be simpler to reason about and more reliable. If such latency is not acceptable, you'll want to run a separate daemon for asynchronous tasks. - FU is buffered
-
The entire request is read into memory before your code even runs, and the generated response is buffered in full before a single byte is sent off to the client. This is, once again, great for simple and predictable code, but certainly not great if you plan to transfer large files.
The rest of this document is reference documentation; there's no easy introductionary cookbook-style docs yet, sorry about that.
Unless specifically mentioned otherwise, all methods and functions taking or returning strings deal with perl Unicode strings, not raw bytes.
Framework Configuration
- FU::init_db($info)
-
Set database configuration.
$info
can either be a connection string forFU::Pg->connect()
or a subroutine that returns a FU::Pg connection. The latter can be useful to set default parameters such ascache()
,text_params()
,client_encoding
, etc.A
query_trace()
callback is registered after connection to collect per-request performance metrics. If you want to register your own trace callback, you'll want to have it callFU::query_trace($st)
to keep the functionality.The configured database is used for
fu->db
and related methods; you can of course still manage alternative database connections in your own code if you need that, but then that won't benefit from FU's integrated transaction handling and performance tracing. - FU::debug($enable)
-
Enable or disable debug mode. Returns the current mode when no argument is given.
Debug mode currently enables more verbose logging and the
debug_info
interface below. It may influence other features in the future as well. You're of course free to use the debug setting to enable or disable debugging features in your own code. - FU::debug_info($path, $storage, $history)
-
Enable the built-in web interface for inspecting debug info. The interface is accessible from your browser at the given
$path
, which is matched againstfu->path
.When the optional
$storage
argument is given and set to an existing directory, detailed request data is logged and stored in that directory, which is then made available through the web interface. The$history
argument sets the number of requests to keep, which defaults to 100.Request logging and the web interface are only available when
FU::debug
mode is enabled.WARNING: This interface exposes internal and potentially sensitive information. When this option is configured, make sure to ABSOLUTELY NEVER enable debug mode in production! Or at least set an absolutely impossible to guess
$path
. - FU::log_slow_reqs($ms)
-
Enable logging of requests that took longer than
$ms
milliseconds to process. Can be set to 0 to disable such logging. - FU::mime_types
-
Returns a modifiable hashref that serves as a lookup table from file extension to MIME type, used by
fu->send_file()
. - FU::utf8_mimes
-
Returns a modifiable hashref listing which mime types should get a UTF-8
charset
parameter appended to them in theContent-Type
header. - FU::compress_mimes
-
Returns a modifiable hashref listing mime types for which compression makes sense.
- FU::monitor_path(@paths)
-
Add filesystem paths to be monitored for changes when running in monitor mode (see
--monitor
in "Running the Site"). When given a directory, all files under the directory are recursively checked. The given paths do not actually have to exist, errors are silently discarded. Relative paths are resolved to the current working directory at the time that the paths are checked for changes, so you may want to pass absolute paths if you ever callchdir()
.You do not have to add the current script or files in
%INC
, these are monitored by default. - FU::monitor_check($sub)
-
Register a subroutine to be called in monitor mode. The subroutine should return a true value to signal that something has changed and the process should reload, false otherwise. The subroutine is called before any filesystem paths are checked (as in
FU::monitor_path
), so if you run any build system things here, file modifications are properly detected and trigger a reload.Only one subroutine can be registered at a time. Be careful to ensure that the subroutine returns a false value at some point, otherwise you may end up in a restart loop.
Handlers & Routing
- FU::get($path, $sub)
- FU::post($path, $sub)
- FU::delete($path, $sub)
- FU::options($path, $sub)
- FU::put($path, $sub)
- FU::patch($path, $sub)
- FU::query($path, $sub)
-
Register a route handler for the given HTTP method and
$path
.$path
can either be a string, which is matched for equality withfu->path
, or a regex that must fully match the request path. If the regex contains capture groups, its contents are passed to$sub
as arguments.FU::get '/', sub { # Here goes the code for the root path. }; FU::post '/sub/path', sub { # POST requests to '/sub/path' go here. }; FU::get qr{/hello/(.+)}, sub($name) { # "GET /hello/world" goes to this code, with $name='world'. };
It is an error to register multiple handlers for the same method and path. This is verified for exact paths, but if you register handlers with overlapping regexes, it's not defined which one is actually called.
- FU::before_request($sub)
-
Register a callback to be run when a request has been received but before it's being routed to the main handler function. Callbacks are run in the order that they are registered. If
$sub
throws an error or callsfu->done
, any laterbefore_request
callbacks are not run and no routing handler is called. - FU::after_request($sub)
-
Register a callback to be run after the routing handler has finished but before the response is sent back to the client. Callbacks are run in reverse order that they are registered. These callbacks are always run, even when a previous
before_request
or the routing handler threw an error. - FU::on_error($code, $sub)
-
Register a callback to be run when the given error code (HTTP status code) is generated.
$sub
is called with the error code as arguments and should generate a suitable error page to send to the client. Only one callback can be registered for each code, calling this function another time with the same$code
overwrites a previous callback.Internally,
FU
can generate errors with code400
,404
and500
, butfu->error()
can be used to generate other errors. If no callback exists for a certain error code,500
is used as fallback.
The 'fu' Object
While the FU::
namespace is used for global configuration and utility functions, the fu
object is intended for methods that deal with request processing (although some are useful used outside of request handlers as well).
The fu
object itself can be used to store request-local data. For example, the following is a valid approach to handle user authentication:
FU::before {
fu->{user} = authenticate_user_from_cookie_or_something();
};
FU::get '/registered-users-only', sub {
fu->denied if !fu->{user};
};
In addition to the request information and response generation methods described in the sections below, it has a few utility methods:
- fu->debug
-
Read-only alias of
FU::debug
. - fu->db_conn
-
Returns the current database handle, as set with
FU::init_db()
. This is mainly useful for configuration, you generally shouldn't use this for running queries inside a request handler, seefu->db
for that instead. - fu->db
-
Returns the database transaction for the current request. Starts a new transaction if none is active.
Transactions initiated this way are automatically committed when the request has successfully been processed, or rolled back if there was an error.
- fu->sql($query, @params)
-
Convenient short-hand for
fu->db->q($query, @params)
. - fu->SQL(@args)
-
Convenient short-hand for
fu->db->Q(@args)
.
Request Information
- fu->path
-
The path component of the request. E.g. if the request is for
https://example.com/some/path?query
, this returns/some/path
. - fu->method
-
Upper-case request method, e.g. 'POST' or 'GET'.
- fu->header($name)
-
Return the request header by the given
$name
, or undef if the requests did not have that header. Header name matching is case-insensitive. If the request includes multiple headers with the same name, these are merged into a single comma-separated value. - fu->headers
-
Return a hashref with all request headers. Keys are lower-cased header names.
- fu->ip
-
Return the client IP address.
- fu->query()
-
Return the raw query part of the request URI, e.g.
https://example.com/some/path?query
this returnsquery
. - fu->query($name)
-
Parses the raw query string with
query_decode
in FU::Util and returns the value with the given $name. Beware: multiple values are returned as an array. Prefer to use the$schema
-based validation methods below to reliably handle all sorts of query strings. - fu->query($name => $schema)
-
Parse, validate and return the query parameter identified by
$name
with the given FU::Validate schema. Callsfu->error(400)
with a useful error message if validation fails. - fu->query($schema)
- fu->query($name1 => $schema1, $name2 => $schema2, ..)
-
Parse, validate and return multiple query parameters.
state $schema = FU::Validate->compile({ keys => { a => {anybool => 1}, b => {} } }); my $data = fu->query($schema); # $data = { a => .., b => .. } # Or, more concisely: my $data = fu->query(a => {anybool => 1}, b => {});
- fu->formdata($name)
- fu->formdata($schema)
-
Like
fu->query()
but returns data from the POST request body.
TODO: Support multipart/form-data
and file uploads.
TODO: Support JSON bodies.
TODO: Cookie parsing.
Generating Responses
- fu->done
-
Throw an exception to indicate that the response is "done", i.e. the current function will return and no further handlers (if any) are run. Only works if you're not catching the exception elsewhere, of course.
- fu->error($code, $message)
-
Throw an exception with a status code. If the exception is not caught elsewhere, this ends up in running the appropriate
FU::on_error
handler.$message
is optional and currently only used for logging. - fu->denied
-
Alias for
fu->error(403)
. - fu->notfound
-
Alias for
fu->error(404)
. - fu->reset
-
Reset the response to an empty state, basically undoing all effects of the methods below.
- fu->status($code)
-
Set the HTTP status code for the response. Defaults to
200
if not set and no error is thrown. - fu->add_header($name, $value)
-
Add a response header, can be used to add multiple headers with the same name.
- fu->set_header($name, $value)
-
Add a response header or overwrite the header with a new value if it already exists. Set
$value
to undef to remove a previously set header. - fu->set_body($data)
-
Set the (raw, binary) body of the response to
$data
. This method is not very convenient for writing dynamic responses, so usually you'll want to use a templating system or FU::XMLWriter:use FU::XMLWriter ':html5_'; fu->set_body(html_ sub { body_ sub { h1_ "Hello, world!"; }; });
- fu->send_file($root, $path)
-
If a file identified by
"$root/$path"
exists, set that as response and callfu->done
. Returns normally if the file does not exist. This method is mainly intended to serve small static files from a directory:FU::before_request { # We can set custom headers before send_file() fu->set_header('cache-control', 'max-age=31536000'); # Attempt to serve files from '/static/files' fu->send_file('/static/files', fu->path); # If that fails, fall back to another directory fu->send_file('/more/static/files', fu->path); # Otherwise, continue processing the request as normal fu->reset; };
$path
may be an untrusted string from the client, this method prevents path traversal attacks that go below the given$root
. It does follow symlinks, though.This method loads the entire file contents in memory and does not support range requests, so DO NOT use it to send large files. Actual web servers are much more efficient at serving static files.
The content-type header is determined from the file extension in
$path
, using the configuredFU::mime_types
. As fallback, files that look like they might be text gettext/plain
and binary files are served withapplication/octet-stream
.This method sets an appropriate
last-modified
header and supports conditional requests withif-modified-since
. - fu->redirect($code, $location)
-
Generates a HTTP redirect response and calls
fu->done
.$code
can be one of the following status codes or an alias:Status Alias Semantics ---------------------------------------- 301 perm Permanent, method may or may not change to GET 302 temp Temporary, method may or may not change to GET 303 tempget Temporary to GET 307 tempsame Temporary without changing method 308 permsame Permanent without changing method
TODO: Setting cookies.
TODO: JSON output.
Running the Site
When your script is done setting "Framework Configuration" and registering "Handlers & Routing", it should call FU::run
to actually start serving the website:
- FU::run(%options)
-
In normal circumstances, this function does not return.
When FU has been loaded with the
-spawn
flag,%options
are read from the environment variables or command line arguments documented below. Otherwise, the following corresponding options can be passed instead: http, fcgi, proc, monitor, max_reqs, listen_sock.
Command-line options are read only when FU has been loaded with -spawn
, the environment variables are always read.
- FU_HTTP=addr
- --http=addr
-
Start a local web server on the given address. addr can be an
ip:port
combination to listen on TCP, or a path (optionally prefixed withunix:
) to listen on a UNIX socket. E.g../your-script.pl --http=127.0.0.1:8000 ./your-script.pl --http=unix:/path/to/socket
WARNING: The built-in HTTP server is only intended for local development setups, it is NOT suitable for production deployments. It has no timeouts, does not enforce limits on request size, does not support HTTPS and will never adequately support keep-alive. You could put it behind a reverse proxy, but it currently also lacks provisions for extracting the client IP address from the request headers, so that's not ideal either. Much better to use FastCGI in combination with a proper web server for internet-facing deployments.
- FU_FCGI=addr
- --fcgi=addr
-
Like the HTTP counterpart above, but listen on a FastCGI socket instead. If this option is set, it takes precedence over the HTTP option.
Nginx and Apache will, in their default configuration, use a separate connection per request. If you have a more esoteric setup, you should probably be aware of the following: this implementation does not support multiplexing or pipelining. It does support keepalive, but this comes with a few caveats:
You should not attempt to keep more connections alive than the configured number of worker processes, otherwise new connection attempts will stall indefinitely.
When using
--monitor
mode, the file modification check is performed after each request rather than before, so clients may get a response from stale code.When worker processes shut down, either through
--max-reqs
or in response to a signal, there is a possibility that an incoming request on an existing connection gets interrupted.
- FU_PROC=n
- --proc=n
-
How many worker processes to spawn, defaults to 1.
- FU_MONITOR=0/1
- --monitor or --no-monitor
-
When enabled, worker processes will monitor for file changes and automatically restart on changes. This is immensely useful during development, but comes at a significant cost in performance - better not enable this in production.
- FU_MAX_REQS=n
- FU_MAX_REQS=min:max
- --max-reqs=n
- --max-reqs=min:max
-
Worker processes can automatically restart after handling a number of requests. Set to 0 (the default) to disable this feature. When set as
min:max
, the number of requests is randomized in the given range, which is useful to avoid restarting all worker processes around the same time.This option can be useful when your worker processes keep accumulating memory over time. A little pruning now and then can never hurt.
- FU_DEBUG=0/1
- --debug or --no-debug
-
Set the initial value for
FU::debug
. - FU_LOG_FILE=path
- --log-file=path
-
Set the initial value for
FU::Log::set_file()
. - LISTEN_FD=num
- LISTEN_PROTO=http/fcgi
-
Listen for incoming connections on the given file descriptor instead of creating a new listen socket. This is mainly useful if you are using an external process manager.
When --monitor
or --max-reqs
are set or --proc
is larger than 1, FU starts a supervisor process to ensure the requested number of worker processes are running and that they are restarted when necessary. When FU has been loaded with the -spawn
flag, this supervisor process runs directly from the context of the use FU
statement - that is, before the rest of your script has even loaded. This saves valuable resources: the supervisor has no need of your website code nor does it need an active connection to your database to do its job. Without the -spawn
flag, the supervisor has to run from FU::run
, which is less efficient but does allow for more flexible configuration from within your script.
When not running in supervisor mode, no separate worker processes are started and requests are instead handled directly in the starting process.
In supervisor mode, sending SIGHUP
causes all worker processes to reload their code. In both modes, SIGTERM
or SIGINT
can be used to trigger a clean shutdown.
TODO: Alternate FastCGI spawning options & server config examples.
COPYRIGHT
MIT.
AUTHOR
Yorhel <projects@yorhel.nl>