Skip to content

Commit 4f65bb9

Browse files
authored
Merge pull request #5 from Thunderhorse-Framework/feature/pagi-tools
Adjust TH to the newest PAGI::Tools
2 parents 9605eb2 + 8e2f621 commit 4f65bb9

16 files changed

Lines changed: 273 additions & 62 deletions

File tree

Changes

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
Revision history for Thunderhorse
22

33
{{$NEXT}}
4+
[Public interface changes]
5+
- Added connection attribute to Context
6+
- Added empty_res, send_res, try_send_res methods to Context
7+
- Added is_ready method to Response
8+
9+
[Breaking changes]
10+
- Response is now a value (via PAGI::Tools change)
11+
- awaiting response's calls which used to send data like "text", "html" etc. is now wrong - remove "await" calls
12+
- Thunderhorse dependencies no longer ensure pagi-server will be installed (via PAGI distribution split)
13+
- pagi-server is now a part of a separate PAGI::Server module, which is not a direct dependency for Thunderhorse
414

515
0.105 - 2026-05-25
616
[Changes]

cpanfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ on 'test' => sub {
33
};
44

55
requires 'perl' => '5.040';
6-
requires 'PAGI' => '0.001023';
6+
requires 'PAGI::Tools' => '0.002000';
77
requires 'Gears' => '0.104';
88
requires 'Mooish::Base' => '1.005';
99
requires 'Future::AsyncAwait' => 0;

ex/full-app/lib/FullApp/Controller/API.pm

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ sub flatten_locations ($self, $level)
2727
return @out;
2828
}
2929

30-
async sub list_locations ($self, $ctx)
30+
sub list_locations ($self, $ctx)
3131
{
3232
my @locations = map {
3333
+{
@@ -39,6 +39,6 @@ async sub list_locations ($self, $ctx)
3939
}
4040
} $self->flatten_locations($self->router);
4141

42-
await $ctx->res->json(\@locations);
42+
$ctx->res->json(\@locations);
4343
}
4444

lib/Thunderhorse.pm

Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,15 @@ sub build_handler ($controller, $destination)
110110
}
111111
}
112112

113+
# NOTE: this method will not send the response if we already sent or if
114+
# the response is not ready. It does not check whether the context is
115+
# consumed altogether, so a manually consumed context with a non-ready
116+
# response will not send anything, likely rendering some kind of error
117+
# page (but not 404). Currently, a PAGI error is raised, informing
118+
# about app returning without sending respnose.
119+
# NOTE: this needs to be here, since we want to use $send from this context
120+
await $ctx->try_send_res;
121+
113122
# if this is a bridge and bridge did not render, it means we are
114123
# free to go deeper. Avoid first match, as it was handled already
115124
# above
@@ -214,6 +223,10 @@ Scripts must be called using C<pagi-server> (or other PAGI-specific software).
214223
Running the script using C<perl> does nothing, as the application cannot run
215224
itself - it will be built, but it will not set up a webserver.
216225
226+
C<pagi-server> can be obtained separately from L<PAGI::Server> module.
227+
Thunderhorse does not automatically include PAGI::Server as its dependency,
228+
since it is not coupled with any specific PAGI server implementation.
229+
217230
=back
218231
219232
=head2 The thunderhorse script
@@ -388,29 +401,29 @@ Return value of the destination sub is by default sent to the requestor as
388401
C<text/html> with status code C<200>. This is a common and handy shortcut, but
389402
it is equally easy to do something else. Take the following destination example:
390403
391-
async sub send_custom ($self, $ctx)
404+
async sub build_custom ($self, $ctx)
392405
{
393-
await $ctx->res->text('Plaintext response');
406+
$ctx->res->text('Plaintext response');
394407
return 'this will not get rendered';
395408
}
396409
397-
This takes response (L<Thunderhorse::Response>) from context, and sends
398-
plaintext manually. This action I<consumes> the context, marking it as
399-
finished. In this case, return value of the destination is ignored. Note that
400-
the await call on C<< ->text >> method is mandatory.
410+
This takes response (L<Thunderhorse::Response>) from context, and sets
411+
plaintext body manually. This action I<consumes> the context, marking it as
412+
finished. In this case, return value of the destination is ignored.
413+
401414
402415
Another example:
403416
404-
sub send_custom2 ($self, $ctx)
417+
sub set_custom2 ($self, $ctx)
405418
{
406-
$ctx->res->status(400)->content_type('text/plain');
407-
return 'this is rendered as plaintext and status 400';
419+
$ctx->res->content_type('text/plain');
420+
return 'this is rendered as plaintext';
408421
}
409422
410423
This time, the return value of the destination is not ignored, since only
411424
setting response metadata does not cause the context to be consumed. Status and
412425
I<Content-Type> header will not be overridden, so the response will be sent as
413-
plaintext. In this case, there is no need to await anything.
426+
plaintext.
414427
415428
While not very common, a destination can be unimplemented when C<to> is
416429
skipped. Unimplemented locations will be "stepped over" during request
@@ -523,10 +536,10 @@ bridge is created when you call C<add> on the result of another C<add>:
523536
524537
When C</admin/users> is requested, both C<check_admin> and C<list_users> will
525538
be called in sequence. The bridge destination receives the same arguments as
526-
regular destinations. If the bridge consumes the context (by sending a
527-
response), further matching stops. Otherwise, the next matching location is
528-
called. For this reason, bridge destinations should return C<undef> explicitly
529-
to avoid consuming the context by accident:
539+
regular destinations. If the bridge consumes the context, further matching
540+
stops. Otherwise, the next matching location is called. For this reason, bridge
541+
destinations should return C<undef> explicitly to avoid consuming the context
542+
by accident:
530543
531544
sub check_admin ($self, $ctx)
532545
{
@@ -644,7 +657,7 @@ would expect.
644657
One unique feature of Thunderhorse is that it does not stop searching for
645658
matches once it finds a match. Instead, it gathers a list of matching locations
646659
and then proceeds to execute them in order. It stops once one of the handlers
647-
consumes the context, which is usually done by sending a response. If no
660+
consumes the context, which is usually done by setting a response body. If no
648661
handlers consumed the context, a I<404 Not Found> error page is rendered.
649662
650663
This allows for superb flexibility, but has a couple of interesting side
@@ -765,6 +778,30 @@ matching. If we let C<important_auth> run before C<login_page>, for example by
765778
setting its order to C<-2>, it will effectively become a bridge for
766779
C<login_page>.
767780
781+
Currently, the context can be consumed by:
782+
783+
=over
784+
785+
=item * Setting the response body in L<Thunderhorse::Context/res>
786+
787+
=item * Setting the response status to one of the statuses which does not require a body
788+
789+
=item * Closing a websocket connection in L<Thunderhorse::Context/ws>
790+
791+
=item * Closing a sse connection in L<Thunderhorse::Context/sse>
792+
793+
=item * Manually sending any response via PAGI, triggering C<response_started> in C<pagi.connection> scope key
794+
795+
=item * Manually consuming the context via L<Thunderhorse::Context/consume> call
796+
797+
=back
798+
799+
This system should be pretty bulletproof, however once you use the last option
800+
and call L<Thunderhorse::Context/consume>, all safety measures are off - it's
801+
now your responsibility to make sure the route handler will render something
802+
eventually. If it doesn't, you will get a low-level PAGI exception and an error
803+
page completely bypassing any Thunderhorse error rendering. Use with caution.
804+
768805
=head2 Controllers
769806
770807
By default, all routes defined in the application's C<build> method belong to
@@ -1286,7 +1323,7 @@ This hook's method B<cannot be declared on a controller level>.
12861323
The C<on_error> hook is called when an exception occurs during request
12871324
processing.
12881325
1289-
This hook should consume the context by sending a response. The default handler
1326+
This hook should consume the context by setting a response. The default handler
12901327
calls L</render_error> method a text page with an error message.
12911328
12921329
=head3 Overriding system methods
@@ -1311,7 +1348,7 @@ following things:
13111348
13121349
=item * tries to set C<Content-Type> header to C<text/html> (if it was not set already)
13131350
1314-
=item * awaits sending C<$result> to the client using L<PAGI::Response/send> method (as text)
1351+
=item * sets C<$result> as the response body
13151352
13161353
=back
13171354
@@ -1323,11 +1360,12 @@ references and render them as JSON/YAML.
13231360
async sub render_error($self, $ctx, $code, $message = undef) { ... }
13241361
async sub render_error($self, $controller, $ctx, $code, $message = undef) { ... }
13251362
1326-
This method's default implementation sends a plain text response with code
1363+
This method's default implementation builds a plain text response with code
13271364
C<500>. The default implementation checks C<is_production> method of the
13281365
application to avoid rendering the original error message which may contain
13291366
sensitive information. It also acknowledges the existence of L<Gears::X::HTTP>,
1330-
which may change the error code to something else.
1367+
which may change the error code to something else. Original response is
1368+
discarded and a new one is built.
13311369
13321370
=head2 Performance tuning
13331371

lib/Thunderhorse/App.pm

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ async sub pagi ($self, $scope, $receive, $send)
208208
# the context
209209
if (!$ctx->is_consumed) {
210210
await $self->render_error(undef, $ctx, 404);
211+
await $ctx->send_res;
211212
}
212213

213214
return;
@@ -240,15 +241,12 @@ sub run ($self)
240241
async sub render_error ($self, $controller, $ctx, $code, $message = undef)
241242
{
242243
$message = defined $message && !$self->is_production ? $message : status_message($code);
243-
await $ctx->res->status($code)->text($message);
244+
$ctx->empty_res->status($code)->text($message);
244245
}
245246

246247
async sub render_response ($self, $controller, $ctx, $result)
247248
{
248-
await $ctx->res
249-
->status_try(200)
250-
->content_type_try('text/html')
251-
->send($result);
249+
$ctx->res->status_try(200)->html($result);
252250
}
253251

254252
#########################

lib/Thunderhorse/Context.pm

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use Mooish::Base -standard;
55

66
use Devel::StrictMode;
77

8+
use Future::AsyncAwait;
89
use Thunderhorse::Request;
910
use Thunderhorse::Response;
1011
use Thunderhorse::WebSocket;
@@ -30,9 +31,15 @@ has field 'req' => (
3031
default => sub ($self) { Thunderhorse::Request->new(context => $self) },
3132
);
3233

34+
has field 'connection' => (
35+
(STRICT ? (isa => HasMethods ['response_started']) : ()),
36+
lazy => sub ($self) { $self->scope->{'pagi.connection'} },
37+
);
38+
3339
has field 'res' => (
3440
(STRICT ? (isa => InstanceOf ['Thunderhorse::Response']) : ()),
35-
default => sub ($self) { Thunderhorse::Response->new(context => $self) },
41+
lazy => sub ($self) { Thunderhorse::Response->new(context => $self) },
42+
clearer => -hidden,
3643
);
3744

3845
# NOTE: websocket must be lazy, because it will die if scope is not websocket
@@ -105,11 +112,41 @@ sub consume ($self)
105112
sub is_consumed ($self)
106113
{
107114
return $self->_consumed
108-
|| $self->res->is_sent
115+
|| $self->connection->response_started
116+
|| $self->res->is_ready
109117
|| ($self->has_ws && $self->ws->is_closed)
110118
|| ($self->has_sse && $self->sse->is_closed);
111119
}
112120

121+
sub empty_res ($self)
122+
{
123+
$self->_clear_res;
124+
return $self->res;
125+
}
126+
127+
async sub send_res ($self)
128+
{
129+
# NOTE: we check a general pagi constraint, but raise a thunderhorse exception -
130+
# should it just die()?
131+
Gears::X::Thunderhorse->raise('response was already sent')
132+
if $self->connection->response_started;
133+
134+
await $self->res->respond($self->sender);
135+
136+
return;
137+
}
138+
139+
async sub try_send_res ($self)
140+
{
141+
return
142+
if $self->connection->response_started;
143+
144+
await $self->res->respond($self->sender)
145+
if $self->res->is_ready;
146+
147+
return;
148+
}
149+
113150
__END__
114151
115152
=head1 NAME
@@ -124,7 +161,7 @@ Thunderhorse::Context - Request handling context
124161
my $stashed_value = $ctx->stash->get('key');
125162
my $session_value = $ctx->session->get('key');
126163
127-
await $ctx->res->text("Hello World");
164+
$ctx->res->text("Hello World");
128165
}
129166
130167
=head1 DESCRIPTION
@@ -202,6 +239,13 @@ used, or other middleware that will populate C<pagi.session> scope key.
202239
L<PAGI::Session> will throw an exception if C<pagi.session> is not present in
203240
scope.
204241
242+
=head3 connection
243+
244+
Connection object for this context, taken from PAGI scope. Connection objects
245+
are inserted into the scope by the server, and there is no clear subclass they
246+
will inherit from. They follow a duck-typed interface though, and can be
247+
trusted to have at least C<response_started> method.
248+
205249
=head2 Methods
206250
207251
=head3 new
@@ -252,6 +296,31 @@ Returns true if the context has been consumed either explicitly via
252296
L</consume>, or implicitly by sending a response, closing a WebSocket
253297
connection, or closing an SSE stream.
254298
299+
=head3 empty_res
300+
301+
$new_res = $ctx->empty_res()
302+
303+
Discards the current response attached to the context, then builds and returns
304+
a fresh one. Useful if you want to completely discard response data.
305+
306+
=head3 send_res
307+
308+
await $ctx->send_res()
309+
310+
Tries to send the response back to the client. Dies if the response has already
311+
been sent. Force-sends the response even if it has an empty body.
312+
313+
=head3 try_send_res
314+
315+
$ctx->try_send_res()
316+
317+
Similar as L</send_res>, but does nothing if the response has already been
318+
sent. Also skips sending the response if it is not ready yet, according to
319+
L<Thunderhorse::Response/is_ready>.
320+
321+
Note that you don't have to use this method explicitly. Thunderhorse will
322+
automatically use it after the route handler returns.
323+
255324
=head1 SEE ALSO
256325
257326
L<Thunderhorse>, L<Gears::Context>, L<Thunderhorse::Request>,

lib/Thunderhorse/Controller.pm

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ Thunderhorse::Controller - Base controller class for Thunderhorse
117117
118118
async sub show ($self, $ctx, $id)
119119
{
120-
await $ctx->res->text("User ID: $id");
120+
$ctx->res->text("User ID: $id");
121121
}
122122
123123
=head1 DESCRIPTION

lib/Thunderhorse/Cookbook.pod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ L<Thunderhorse::App/render_error>:
2121
$html = $controller->template('error/error', {title => status_message($code)});
2222
}
2323

24-
await $ctx->res->status($code)->html($html);
24+
$ctx->res->status($code)->html($html);
2525
}
2626

2727
For example for error 404, code above will try to render C<error/404.tt>

lib/Thunderhorse/Request.pm

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,10 @@ constructor arguments.
8989
9090
=head3 update
9191
92-
$req->update()
92+
$req->update($scope, $receive, $send)
9393
94-
Updates the internal PAGI scope and receiver from the context's PAGI tuple.
95-
Called automatically when the context's PAGI tuple changes via
96-
setter of L<Thunderhorse::Context/pagi>.
94+
Updates the internal PAGI scope and receiver. Called automatically when the
95+
context's PAGI tuple changes via setter of L<Thunderhorse::Context/pagi>.
9796
9897
=head1 SEE ALSO
9998

0 commit comments

Comments
 (0)