NAME
Thunderhorse::Manual - Thunderhorse reference
SYNOPSIS
First ...
# app.pl
use v5.40;
package MyApp;
use Mooish::Base;
extends 'Thunderhorse::App';
sub build ($self)
{
$self->routes->add('/hello/:name', { to => 'greet' });
}
sub greet ($self, $ctx, $name)
{
return "Hello, $name!";
}
MyApp->new->run;
Then ...
> pagi-server app.pl
DESCRIPTION
Thunderhorse is a web framework which supports PAGI protocol natively. It builds around tools delivered by PAGI to achieve a simple, capable, and async-ready framework. The same ideas were used to build Kelp, which was based on PSGI and Plack. Thunderhorse is the spiritual successor of Kelp and carries its legacy into the world of real-time web.
Thunderhorse was designed to be light, extensible and reusable. It can seamlessly integrate with PAGI apps or middlewares, which makes it very easy to build a web server from available components. It has a very powerful and cache-friendly router. It is built on top of Gears, which means its parts are very hackable and easy to reuse in other projects.
Unlike other frameworks, Thunderhorse neither reinvents all of its wheels, nor depends on numerous CPAN dependencies to work. It leverages a moderate, hand-picked set of distributions to deliver concise, performant and well-organized system. It is also based on perl 5.40 and uses modern syntax features in its core, which allows it to further reduce the set of required dependencies and keep the core small.
The application class
Each Thunderhorse application is required to define an application class, which is a package subclass of Thunderhorse::App. The mechanism of subclassing can be chosen at will, but docs and examples will suggest Mooish::Base, which is also what the core of Thunderhorse uses. Mooish::Base imports Moo, but will also ensure all known performance-enhancing modules are loaded.
Most minimal Thunderhorse application (which does nothing) looks like this:
# lib/MyApp.pm
package MyApp;
use v5.40;
use Mooish::Base;
extends 'Thunderhorse::App';
To run it, a perl file is required, which will load, instantiate and run it (as the last expression):
use lib 'lib';
use MyApp;
MyApp->new->run;
That file can then be run using pagi-server, which will take the last expression of the file and run it when requests arrive. In the above example, all these requests will return HTTP code 404, since we haven't declared any routing yet.
Routing
To make our application do anything useful, we need to obtain the router (Thunderhorse::Router) and call add method on it. This is commonly done in the build method of the application, which is called automatically when the application object is created:
# in lib/MyApp.pm
sub build ($self)
{
my $router = $self->router;
$router->add(
'/request/path' => {
to => sub ($self, $ctx) {
return 'Hello?';
},
}
);
}
The code above lets us now visit localhost:5000/request/path and see text Hello? in the browser. This is called a routing location, and typically points at a subroutine or its name using to, also called destination. The code above could be rewritten to the following form, which would yield the exact same result:
sub build ($self)
{
my $router = $self->router;
$router->add(
'/request/path' => {
to => 'hello', # name of a method in this controller
}
);
}
sub hello ($self, $ctx)
{
return 'Hello?';
}
Destination can be a sub or async sub (using Future::AsyncAwait). It accepts two base arguments: $self which is the instance of the controller (Thunderhorse::Controller), and $ctx which is current request's context (Thunderhorse::Context). In Thunderhorse, controllers are persistent and shared across all requests, which is why a context object is defined. Controllers can never hold request-specific state, because there are multiple concurrent requests being handled at the same time due to asynchronous nature of PAGI. If destination is async, then it must await all asynchronous calls as defined by PAGI specification.
Return value of the destination sub is by default sent to the requestor as text/html with status code 200. This is a common and handy shortcut, but it is equally easy to do something else. Take the following destination example:
async sub send_custom ($self, $ctx)
{
await $ctx->res->text('Plaintext response');
return 'this will not get rendered';
}
This takes response (Thunderhorse::Response) from context, and sends plaintext manually. This action consumes the context, marking it as finished. In this case, return value of the destination is ignored. Note that the await call on ->text method is mandatory.
Another example:
sub send_custom2 ($self, $ctx)
{
$ctx->res->status(400)->content_type('text/plain');
return 'this is rendered as plaintext and status 400';
}
This time, the return value of the destination is not ignored, since only setting response metadata does not cause the context to be consumed. Status and Content-Type header will not be overridden, so the response will be sent as plaintext. In this case, there is no need to await anything.
Placeholders
Routes can contain placeholders which match parts of the URL path and make those parts available to the destination handler. Placeholders are specified using sigils in the pattern:
$router->add(
'/user/:id' => {
to => sub ($self, $ctx, $id) {
return "User ID: $id";
}
}
);
This location matches /user/123 and passes 123 as the $id parameter. Each matched placeholder is passed as additional arguments to the destination, after $self and $ctx.
Thunderhorse supports four types of placeholders:
:name- required placeholderMatches any characters except slash. The placeholder must be present in the URL for the route to match.
# matches /user/123 but not /user/ or /user '/user/:id'?name- optional placeholderMatches any characters except slash. If the placeholder is not present, it will be passed as
undefto the destination. If it follows a slash with no curly braces, that slash becomes optional as well.# matches both /post/my-slug and /post # in second case, $slug will be undef '/post/?slug'*name- wildcard placeholderMatches any characters including slashes. Always required.
# matches /files/path/to/file.txt # $path will be 'path/to/file.txt' '/files/*path'>name- slurpy placeholderOptional wildcard that matches everything including slashes. If it follows a slash with no curly braces, the slash is made optional as well.
# matches both /api and /api/v1/users '/api/>rest'
Placeholders can be enclosed in curly braces to separate them from surrounding text:
'/user-{:id}-profile'
Placeholders can be validated using checks parameter, which maps placeholder names to regular expressions:
$router->add(
'/user/:id' => {
to => 'show_user',
checks => { id => qr/\d+/ },
}
);
Optional placeholders can be given default values using defaults parameter:
$router->add(
'/post/?page' => {
to => 'list_posts',
defaults => { page => 1 },
}
);
When a default is specified, the destination will receive that value instead of undef when the placeholder is not present in the URL.
Bridges
Bridges are routes that have children. They are useful for implementing authentication, authorization, or any other pre-processing logic that should apply to multiple routes. They may also be used to group routes together. A bridge is created when you call add on the result of another add:
my $admin_bridge = $router->add(
'/admin' => {
to => 'check_admin',
}
);
$admin_bridge->add(
'/users' => {
to => 'list_users',
}
);
When /admin/users is requested, both check_admin and list_users will be called in sequence. The bridge destination receives the same arguments as regular destinations. If the bridge consumes the context (by sending a response), further matching stops. Otherwise, the next matching location is called. For this reason, bridge destinations should return undef explicitly to avoid consuming the context by accident:
sub check_admin ($self, $ctx)
{
await $self->render_error($ctx, 403)
unless $ctx->req->session->{is_admin};
# if context is not consumed, continue to next match
return undef;
}
Actions
Actions allow routes to be restricted to specific request types. By default, routes match all HTTP methods and scopes. Actions are specified using the action parameter:
$router->add(
'/api/data' => {
to => 'get_data',
action => 'http.get',
}
);
Action format is scope.method where scope is one of http, sse, or websocket, and method is an HTTP method for http or sse scope, or omitted for websocket. Either part can be * to match anything.
Common action patterns:
# Match only HTTP POST requests
action => 'http.post'
# Match any HTTP method
action => 'http.*'
# Match only WebSocket connections
action => 'websocket'
# Match only Server-Sent GET Events
action => 'sse.get'
# Match any request type (default)
action => '*.*'
Multiple routes with the same pattern but different actions can coexist, allowing different handlers for different request types:
$router->add('/api/data' => { to => 'get_data', action => 'http.get' });
$router->add('/api/data' => { to => 'post_data', action => 'http.post' });
$router->add('/api/data' => { to => 'stream_data', action => 'websocket' });
Controllers
By default, all routes defined in the application's build method belong to the application controller (Thunderhorse::AppController). However, as applications grow, it becomes useful to organize routes and their handlers into separate controller classes. Each controller is a self-contained unit with its own routes and methods.
Controllers are subclasses of Thunderhorse::Controller and typically live in a namespace under your application. Each controller has its own build method where routes are defined:
# lib/MyApp/Controller/User.pm
package MyApp::Controller::User;
use v5.40;
use Mooish::Base;
extends 'Thunderhorse::Controller';
sub build ($self)
{
my $r = $self->router;
$r->add('/users' => { to => 'list' });
$r->add('/user/:id' => { to => 'show' });
}
sub list ($self, $ctx)
{
return "List of users";
}
sub show ($self, $ctx, $id)
{
return "User $id";
}
Controllers have access to $self->app to reach the application object, and $self->router which automatically sets the controller context for route definitions. Do not use $self->app->router, as this will yield router configured for use in base app.
Loading controllers
Controllers are loaded in the application's build method using load_controller:
# in MyApp
sub build ($self)
{
$self->load_controller('User');
}
The load_controller method takes a short name and automatically prepends your application's namespace. In the above example, it loads MyApp::Controller::User. The controller's build method is called automatically, registering all its routes.
To load a controller from a different namespace, prefix the name with ^:
$self->load_controller('^Some::Other::Controller::Class');
Controllers can also be loaded from configuration files, which is covered in the "Configuration" section.
Modules
Modules are reusable, configurable parts of Thunderhorse that have great power over the system. They can add new methods and wrap application in middlewares. Creation of modules is an advanced topic, discussed in Thunderhorse::Module. Here, we will focus on modules available in base Thunderhorse.
To load a module, the following call must be made in the application:
$self->load_module('Name' => { config_key => config_value });
This loads Thunderhorse::Module::Name and initializes it with the given hash configuration. If Name is a full name of the module, it should instead be passed as ^Name to avoid adding the namespace prefix.
Logger
The Logger module (Thunderhorse::Module::Logger) adds logging capabilities to the application. It wraps the entire application to catch and log errors, and adds a log method to controllers.
Loading the module:
$self->load_module('Logger' => {
outputs => [
screen => {
'utf-8' => true,
},
],
});
Configuration is passed to Gears::Logger::Handler, which handles the actual logging using Log::Handler. Common configuration keys:
outputs- hash of Log::Handler output destinations (file, screen, etc.)date_format- strftime date format in logs, mimicing apache format by defaultlog_format- sprintf log format, mimicing apache format by default
The default log_format is [%s] [%s] %s, where placeholders are: date, level and message. Log format can be specified on Log::Handler level in outputs (per output), but it would cause duplication of formatting. In that case log_format must be set to undef to avoid an exception on startup.
Once loaded, logging can be done from any controller method:
sub some_action ($self, $ctx)
{
$self->log(info => 'Processing request');
$self->log(error => 'Something went wrong');
return "Done";
}
The Logger module also automatically logs any unhandled exceptions that occur during request processing.
Template
The Template module (Thunderhorse::Module::Template) adds template rendering capabilities using Template::Toolkit. It adds a render method to controllers.
Loading the module:
$self->load_module('Template' => {
paths => ['views'],
conf => {
EVAL_PERL => true,
},
});
Configuration is passed to Gears::Template::TT, which wraps Template Toolkit.
conf- hash of Template::Toolkit configuration valuespaths- array ref of paths to search for templatesencoding- encoding of template files, UTF-8 by default
paths and encoding will be automatically set as proper keys in Template::Toolkit config, unless it was specified there separately, in which case they will be ignored.
Once loaded, templates can be rendered from controller methods:
sub show_page ($self, $ctx)
{
return $self->render('page', {
title => 'My Page',
content => 'Hello, World!',
});
}
The first argument is the template name (.tt suffix will be added automatically), and the second is a hash reference of variables to pass to the template. The method returns the rendered content, which is then sent to the client as HTML (if the context is not already consumed).
Middleware
The Middleware module (Thunderhorse::Module::Middleware) allows loading any PAGI middleware into the application. It wraps the entire PAGI application with specified middlewares.
Loading the module:
$self->load_module('Middleware' => {
Static => {
path => '/static',
root => 'public',
},
Session => {
store => 'file',
},
});
Each key in the configuration is a middleware class name (will be prefixed with PAGI::Middleware:: unless it starts with ^). The value is a hash reference of configuration passed to that middleware's constructor.
Middlewares are applied in deterministic order (sorted by key name). To control the order explicitly, use the _order key in middleware configuration:
$self->load_module('Middleware' => {
Static => { path => '/static', root => 'public', _order => 1 },
Session => { store => 'file', _order => 2 },
});
Lower _order values are applied first, higher values are applied last.
Configuration
Thunderhorse applications can be configured using configuration files or by passing a hash to the constructor. Configuration is managed by Thunderhorse::Config, which extends Gears::Config.
Loading configuration from files
By default, Thunderhorse does not look for any configuration files. A string can be passed to initial_config, specifying the directory in which to look:
MyApp->new(initial_config => 'conf')->run;
This will load configuration from the conf directory. Configuration files are loaded in order:
Where $ext is any extension handled by available config readers (.pl for Perl scripts by default), and $env is the current environment (production, development, or test). The environment can be set via the PAGI_ENV environment variable or the env constructor parameter. pagi-server -E production also sets PAGI_ENV.
Configuration files are merged together, with environment-specific settings overriding base settings. Example structure:
# conf/config.pl
{
modules => {
Logger => {
outputs => [ screen => { ... } ],
},
},
}
# conf/production.pl
{
modules => {
Logger => {
'=outputs' => [ file => { ... } ],
},
},
}
In production environment, the Logger module will use file output instead of the screen output from base config.
Configuration merging
When multiple configuration sources are loaded, they are merged together using a smart merge system. By default, configuration keys are merged intelligently based on their types:
key- Smart merge (default)Without any prefix, configuration values are merged based on their type. Hash references are merged recursively, applying new keys and updating existing ones from the new configuration. Array references are extended with new values. Scalar values and mismatched reference types replace the old value.
# base config { controllers => ['User', 'Admin'] } # override config { controllers => ['Admin', 'API'] } # result: all controllers are loaded { controllers => ['User', 'Admin', 'API'] }=key- ReplaceThe equals sign prefix forces complete replacement of the value, regardless of type. This is useful when you want to completely override a complex structure instead of merging it.
# base config { controllers => ['User', 'Admin'] } # override config { '=controllers' => ['Admin', 'API'] } # result: duplicates are applied { controllers => ['Admin', 'API'] }+key- AddThe plus sign prefix explicitly adds to the existing value. For arrays, new elements are appended. For hashes, new keys are added and existing keys are merged recursively.
# base config { controllers => ['User', 'Admin'] } # override config { '+controllers' => ['Admin', 'API'] } # result: duplicates are applied { controllers => ['User', 'Admin', 'Admin', 'API'] }-key- RemoveThe minus sign prefix removes values from arrays. It compares the array in the new configuration with the existing array and removes matching elements. This only works for arrays.
# base config { controllers => ['User', 'Admin'] } # override config { '-controllers' => ['Admin', 'API'] } # result: the set is reduced by matched keys { controllers => ['User'] }
Type mismatches (such as trying to merge a hash into an array) raise an error. The = prefix can be used to force replacement when changing types.
Prefixes apply to the immediate key only and do not affect nested structures. To control merging of nested keys, apply prefixes to those keys explicitly:
{
modules => {
Logger => {
'=outputs' => ['file'],
'+extra' => { new_key => 'value' },
},
},
}
Loading configuration from hash
Configuration can be provided directly as a hash reference:
MyApp->new(initial_config => {
modules => {
Logger => { outputs => [ screen => {} ] },
},
})->run;
This approach is useful for testing or when configuration comes from other sources.
Loading controllers and modules from configuration
Controllers and modules can be specified in configuration files instead of calling load_controller and load_module in code:
# in config file
{
controllers => ['User', 'Admin', 'API'],
modules => {
Logger => {
outputs => [ screen => {} ],
},
Template => {
paths => ['views'],
},
},
}
The controllers key is an array of controller names to load. The modules key is a hash where keys are module names and values are configuration hashes for each module. Both controllers and modules are loaded during application initialization, before the build method is called.
Hooks
Thunderhorse is easily extensible using hooks. Hooks are methods, usually in App or Controller, which can be overridden to do something differently.
Lifespan hooks
Lifespan hooks are called during PAGI application lifecycle, when worker processes are started and shut down. They are defined in the application class:
async sub on_startup ($self, $state)
{
# Called once when worker starts
# Initialize resources, connect to databases, etc.
say "Application starting up";
}
async sub on_shutdown ($self, $state)
{
# Called once when worker is shutting down
# Clean up resources, close connections, etc.
say "Application shutting down";
}
Both hooks are async and receive a $state hash reference that is shared across the system. This can be used to store handles or other resources that need to be cleaned up, on PAGI level. This is usually not needed, as Thunderhorse has access to other means of managing persistent data.
These hooks are run when a worker is spawned and killed.
Controller hooks
Controller hooks allow customization of error handling on a per-controller basis. They can be defined in any controller or in the application class (which all controllers call by default).
on_error
The on_error hook is called when an exception occurs during request processing:
async sub on_error ($self, $ctx, $error)
{
if ($error isa 'Gears::X::HTTP') {
# HTTP error with status code
await $self->render_error($ctx, $error->code);
}
else {
# Some other error
die $error;
}
}
This hook receives the controller instance, the context, and the error object. It should consume the context by sending a response. The default implementation handles Gears::X::HTTP exceptions by calling render_error.
render_error
The render_error hook is called to render error pages:
async sub render_error ($self, $ctx, $code, $message = undef)
{
my $text = $message // HTTP::Status::status_message($code);
await $ctx->res->status($code)->html(
"<h1>Error $code</h1><p>$text</p>"
);
}
This hook receives the controller instance, the context, the HTTP status code, and an optional error message. If no message is provided and the application is in production mode, a generic status message is used instead. The default implementation sends a plain text response. The default implementation checks is_production method of the application to avoid rendering the original error message which may contain sensitive information.
Both hooks prioritize controller-specific implementations over application-level ones. If a controller defines its own on_error or render_error, those will be called instead of the application's versions. This allows fine-grained control over error handling for different parts of the application.