Batteries, not plugins.
Background jobs, mailers, caching and conditional GET, content negotiation, live streaming, WebSockets, and internationalization all ship in the box, driven by the same request cycle. No extra modules to assemble, no ceremony to wire them together.
#background jobs
MVC::Keayl::Job runs work now or later. A subclass implements perform;
perform-now runs it immediately, and perform-later enqueues it on the
configured adapter. Positional and named arguments pass straight through. With no adapter
configured, perform-later runs inline, so a job is always runnable without setup.
class WelcomeJob is MVC::Keayl::Job {
method perform($user-id) {
# send a welcome email, warm a cache, ...
}
}
# runs now, returns the result
WelcomeJob.perform-now(42);
# enqueues on the adapter
WelcomeJob.perform-later(42);
The adapter decides what "later" means. Inline runs on enqueue, Test collects jobs without running them, Async runs each on its own thread, and the Database adapter persists each job through a pluggable store.
MVC::Keayl::Job.queue-adapter(
MVC::Keayl::Job::QueueAdapter::Async.new);
my $adapter =
MVC::Keayl::Job::QueueAdapter::Test.new;
MVC::Keayl::Job.queue-adapter($adapter);
# collected, not run
WelcomeJob.perform-later(42);
# now it runs
$adapter.perform-all;
#mailers
MVC::Keayl::Mailer builds email the way a controller builds a response: an action
renders HAML views into the parts of a message, and a delivery method sends it. mail
renders an html and a text part when both templates exist, making the
message multipart.
class NoticeMailer is MVC::Keayl::Mailer {
method welcome($user) {
self.mail(
to => $user.email,
from => 'noreply@example.com',
subject => 'Welcome',
locals => %( name => $user.name ),
);
}
}
NoticeMailer.default(
from => 'noreply@example.com');
my $mailer = NoticeMailer.new(
view-renderer => $view,
delivery => $delivery);
$mailer.deliver('welcome', $user);
Views are named for the mailer and action: NoticeMailer#welcome renders under
notice_mailer/welcome.html.haml and notice_mailer/welcome.text.haml.
Pair a mailer with a job to deliver in the background.
#caching and conditional GET
fresh-when sets validators and renders a 304 Not Modified when the
request shows the client already has the current version; is-stale is its inverse.
expires-in sets Cache-Control, and a pluggable cache store offers a
low-level key-value API with a fetch that computes-and-stores on a miss.
method show {
self.render('show')
if self.is-stale(
etag => $post,
last-modified => $post.updated-at);
}
# Cache-Control: public, max-age=3600, must-revalidate
self.expires-in(
3600,
public => True,
must-revalidate => True);
$store.fetch(
'user/1',
{ load-user(1) },
expires-in => 300);
$store.increment('visits', 1, expires-in => 3600);
$store.delete-matched(/^user\//);
#content negotiation
A controller action serves several formats with respond-to. The format is taken
from the path extension if present, otherwise negotiated from the Accept header; the
first declared format is the default, and a request that matches none gets 406.
method show {
self.respond-to([
html => { self.render('show') },
json => { self.render(:json($post.to-hash)) },
]);
}
With Accept: text/html, the html block runs and renders the template.
200 OK
Content-Type: text/html; charset=utf-8
<h1>Deploying web apps with Raku</h1>
The .json extension forces JSON, so the json block runs and renders
the serialized post.
200 OK
Content-Type: application/json
{"id":7,"title":"Deploying web apps with Raku"}phone renders show_phone while any falls back to
show. MVC::Keayl::APIController is the JSON-first base for API-only apps.
#live streaming and SSE
A live action writes the response body incrementally on its own thread, while the adapter pulls
the chunks through to the client. live runs a block, passing the controller and a
stream; the block writes chunks with write, and the framework closes the stream when
it returns. The action returns as soon as the block is spawned, so the dispatch loop is not
blocked.
method feed {
self.live(-> $controller, $stream {
CATCH {
when X::MVC::Keayl::Live::ClientDisconnected
{
cleanup();
}
}
loop {
$stream.write(next-chunk());
}
});
}
When the client goes away, the next write raises
X::MVC::Keayl::Live::ClientDisconnected, which the action catches to release
resources and stop producing.
#websockets
MVC::Keayl::Cable is an ActionCable-style abstraction for real-time messaging. A
connection holds one client, channels group its subscriptions, and a pub/sub backend fans
broadcasts out to everyone on a stream. Subclass a channel, stream from a named broadcasting in
subscribed, and add a method per action a client can invoke.
class ChatChannel is MVC::Keayl::Cable::Channel {
method subscribed {
self.stream-from(
'room:'
~ self.connection.identifiers<room>);
}
method speak(%data) {
self.broadcast-to(
'room:'
~ self.connection.identifiers<room>,
%data<message>);
}
}
stream-from subscribes the channel to a broadcasting, transmit sends
straight to this client, and broadcast-to publishes to a stream, reaching every
subscribed connection. perform dispatches a client message to an action method, and
only methods defined on the channel are callable, so framework methods cannot be invoked from the
wire. A connection declares its identifiers with identified-by and verifies them in
connect, rejecting with reject-unauthorized-connection.
#internationalization
MVC::Keayl::I18n loads locale files into one store, looks up translations by dotted
key, interpolates and pluralizes them, and localizes dates, times, and numbers.
load-locales reads every .yml, .yaml, and .json
file in a directory and merges them by top-level locale.
my $i18n = MVC::Keayl::I18n.new(
default-locale => 'en',
available-locales => <en fr ru>,
use-fallbacks => True,
);
$i18n.load-locales('config/locales'.IO);
# "Hello Ada"
$i18n.translate('greeting', name => 'Ada');
# "3 apples"
$i18n.t('apples', count => 3);