File Coverage

blib/lib/PAGI/Response.pm
Criterion Covered Total %
statement 261 264 98.8
branch 101 112 90.1
condition 44 51 86.2
subroutine 46 46 100.0
pod 27 27 100.0
total 479 500 95.8


line stmt bran cond sub pod time code
1             package PAGI::Response;
2              
3 11     11   560353 use strict;
  11         18  
  11         406  
4 11     11   62 use warnings;
  11         19  
  11         448  
5              
6 11     11   42 use Future::AsyncAwait;
  11         17  
  11         71  
7 11     11   541 use Carp qw(croak);
  11         16  
  11         629  
8 11     11   997 use Encode qw(encode FB_CROAK);
  11         35174  
  11         744  
9 11     11   3499 use JSON::MaybeXS ();
  11         78416  
  11         45558  
10              
11              
12             =head1 NAME
13              
14             PAGI::Response - Fluent response builder for PAGI applications
15              
16             =head1 SYNOPSIS
17              
18             use PAGI::Response;
19             use Future::AsyncAwait;
20              
21             # Basic usage in a raw PAGI app
22             async sub app ($scope, $receive, $send) {
23             my $res = PAGI::Response->new($scope, $send);
24              
25             # Fluent chaining - set status, headers, then send
26             await $res->status(200)
27             ->header('X-Custom' => 'value')
28             ->json({ message => 'Hello' });
29             }
30              
31             # Various response types
32             await $res->text("Hello World");
33             await $res->html("

Hello

");
34             await $res->json({ data => 'value' });
35             await $res->redirect('/login');
36              
37             # Streaming large responses
38             await $res->stream(async sub ($writer) {
39             await $writer->write("chunk1");
40             await $writer->write("chunk2");
41             await $writer->close();
42             });
43              
44             # File downloads
45             await $res->send_file('/path/to/file.pdf', filename => 'doc.pdf');
46              
47             =head1 DESCRIPTION
48              
49             PAGI::Response provides a fluent interface for building HTTP responses in
50             raw PAGI applications. It wraps the low-level C<$send> callback and provides
51             convenient methods for common response types.
52              
53             B (C, C
, C, C)
54             return C<$self> for fluent chaining.
55              
56             B (C, C, C, C, etc.) return
57             Futures and actually send the response. Once a finisher is called, the
58             response is sent and cannot be modified.
59              
60             B Each PAGI::Response instance can only send one response.
61             Attempting to call a finisher method twice will throw an error.
62              
63             =head1 CONSTRUCTOR
64              
65             =head2 new
66              
67             my $res = PAGI::Response->new($scope, $send);
68              
69             Creates a new response builder.
70              
71             =over 4
72              
73             =item C<$send> - Required. The PAGI send callback (coderef).
74              
75             =item C<$scope> - Required. The PAGI scope hashref.
76              
77             =back
78              
79             The scope is required because PAGI::Response stores the "response sent" flag
80             in C<< $scope->{'pagi.response.sent'} >>. This ensures that if multiple
81             Response objects are created from the same scope (e.g., in middleware chains),
82             they all share the same "sent" state and prevent double-sending responses.
83              
84             B Per-object state like C and C is NOT shared between
85             Response objects. Only the "sent" flag is shared via scope. This matches the
86             ASGI pattern where middleware wraps the C<$send> callable to intercept/modify
87             responses, and Response objects build their own status/headers before sending.
88              
89             =head1 CHAINABLE METHODS
90              
91             These methods return C<$self> for fluent chaining.
92              
93             =head2 status
94              
95             $res->status(404);
96             my $code = $res->status;
97              
98             Set or get the HTTP status code (100-599). Returns C<$self> when setting
99             for fluent chaining. When getting, returns 200 if no status has been set.
100              
101             my $res = PAGI::Response->new($scope, $send);
102             $res->status; # 200 (default, nothing set yet)
103             $res->has_status; # false
104             $res->status(201); # set explicitly
105             $res->has_status; # true
106              
107             =head2 status_try
108              
109             $res->status_try(404);
110              
111             Set the HTTP status code only if one hasn't been set yet. Useful in
112             middleware or error handlers to provide fallback status codes without
113             overriding choices made by the application:
114              
115             $res->status_try(202); # sets to 202 (nothing was set)
116             $res->status_try(500); # no-op, 202 already set
117              
118             =head2 header
119              
120             $res->header('X-Custom' => 'value');
121             my $value = $res->header('X-Custom');
122              
123             Add a response header. Can be called multiple times to add multiple headers.
124             If called with only a name, returns the last value for that header or C.
125              
126             =head2 headers
127              
128             my $headers = $res->headers;
129              
130             Returns the full header arrayref C<[ name, value ]> in order.
131              
132             =head2 header_all
133              
134             my @values = $res->header_all('Set-Cookie');
135              
136             Returns all values for the given header name (case-insensitive).
137              
138             =head2 header_try
139              
140             $res->header_try('X-Custom' => 'value');
141              
142             Add a response header only if that header name has not already been set.
143              
144             =head2 content_type
145              
146             $res->content_type('text/html; charset=utf-8');
147             my $type = $res->content_type;
148              
149             Set the Content-Type header, replacing any existing one.
150              
151             =head2 content_type_try
152              
153             $res->content_type_try('text/html; charset=utf-8');
154              
155             Set the Content-Type header only if it has not already been set.
156              
157             =head2 cookie
158              
159             $res->cookie('session' => 'abc123',
160             max_age => 3600,
161             path => '/',
162             domain => 'example.com',
163             secure => 1,
164             httponly => 1,
165             samesite => 'Strict',
166             );
167              
168             Set a response cookie. Options: max_age, expires, path, domain, secure,
169             httponly, samesite.
170              
171             =head2 delete_cookie
172              
173             $res->delete_cookie('session');
174              
175             Delete a cookie by setting it with Max-Age=0.
176              
177             =head2 scope
178              
179             my $scope = $res->scope;
180              
181             Returns the raw PAGI scope hashref. Useful for constructing helper
182             objects like L and L:
183              
184             my $stash = PAGI::Stash->new($res);
185              
186             =head2 Per-Request Shared State
187              
188             See L for per-request shared state. Construct from a
189             Response object or from the shared scope:
190              
191             use PAGI::Stash;
192             my $stash = PAGI::Stash->new($res);
193              
194             =head2 is_sent
195              
196             if ($res->is_sent) {
197             warn "Response already sent, cannot send error";
198             return;
199             }
200              
201             Returns true if the response has already been finalized (sent to the client).
202             Useful in error handlers or middleware that need to check whether they can
203             still send a response.
204              
205             =head2 has_status
206              
207             if ($res->has_status) { ... }
208              
209             Returns true if a status code has been explicitly set via C or
210             C.
211              
212             =head2 has_header
213              
214             if ($res->has_header('content-type')) { ... }
215              
216             Returns true if the given header name has been set via C
or
217             C. Header names are case-insensitive.
218              
219             =head2 has_content_type
220              
221             if ($res->has_content_type) { ... }
222              
223             Returns true if Content-Type has been explicitly set via C,
224             C, or C
/C with a Content-Type name.
225              
226             =head2 cors
227              
228             # Allow all origins (simplest case)
229             $res->cors->json({ data => 'value' });
230              
231             # Allow specific origin
232             $res->cors(origin => 'https://example.com')->json($data);
233              
234             # Full configuration
235             $res->cors(
236             origin => 'https://example.com',
237             methods => [qw(GET POST PUT DELETE)],
238             headers => [qw(Content-Type Authorization)],
239             expose => [qw(X-Request-Id X-RateLimit-Remaining)],
240             credentials => 1,
241             max_age => 86400,
242             preflight => 0,
243             )->json($data);
244              
245             Add CORS (Cross-Origin Resource Sharing) headers to the response.
246             Returns C<$self> for chaining.
247              
248             B
249              
250             =over 4
251              
252             =item * C - Allowed origin. Default: C<'*'> (all origins).
253             Can be a specific origin like C<'https://example.com'> or C<'*'> for any.
254              
255             =item * C - Arrayref of allowed HTTP methods for preflight.
256             Default: C<[qw(GET POST PUT DELETE PATCH OPTIONS)]>.
257              
258             =item * C - Arrayref of allowed request headers for preflight.
259             Default: C<[qw(Content-Type Authorization X-Requested-With)]>.
260              
261             =item * C - Arrayref of response headers to expose to the client.
262             By default, only simple headers (Cache-Control, Content-Language, etc.)
263             are accessible. Use this to expose custom headers.
264              
265             =item * C - Boolean. If true, sets
266             C, allowing cookies and
267             Authorization headers. Default: C<0>.
268              
269             =item * C - How long (in seconds) browsers should cache preflight
270             results. Default: C<86400> (24 hours).
271              
272             =item * C - Boolean. If true, includes preflight response headers
273             (Allow-Methods, Allow-Headers, Max-Age). Set this when handling OPTIONS
274             requests. Default: C<0>.
275              
276             =item * C - The Origin header value from the request.
277             Required when C is true and C is C<'*'>, because
278             the CORS spec forbids using C<'*'> with credentials. Pass the actual
279             request origin to echo it back.
280              
281             =back
282              
283             B
284              
285             =over 4
286              
287             =item * When C is true, you cannot use C<< origin => '*' >>.
288             Either specify an exact origin, or pass C with the
289             client's actual Origin header.
290              
291             =item * The C header is always set to ensure proper caching
292             when origin-specific responses are used.
293              
294             =item * For preflight (OPTIONS) requests, set C<< preflight => 1 >> and
295             typically respond with C<< $res->status(204)->empty() >>.
296              
297             =back
298              
299             =head1 FINISHER METHODS
300              
301             These methods return Futures and send the response.
302              
303             =head2 text
304              
305             await $res->text("Hello World");
306              
307             Send a plain text response with Content-Type: text/plain; charset=utf-8.
308              
309             =head2 html
310              
311             await $res->html("

Hello

");
312              
313             Send an HTML response with Content-Type: text/html; charset=utf-8.
314              
315             =head2 json
316              
317             await $res->json({ message => 'Hello' });
318              
319             Send a JSON response with Content-Type: application/json; charset=utf-8.
320              
321             =head2 redirect
322              
323             await $res->redirect('/login');
324             await $res->redirect('/new-url', 301);
325              
326             Send a redirect response with an empty body. Default status is 302.
327              
328             B This method sends the response but does NOT stop Perl execution.
329             Use C after redirect if you have more code below:
330              
331             await $res->redirect('/login');
332             return; # Important! Code below would still run otherwise
333              
334             B While RFC 7231 suggests including a short HTML body with a
335             hyperlink for clients that don't auto-follow redirects, all modern browsers
336             and HTTP clients ignore redirect bodies. If you need a body for legacy
337             compatibility, use the lower-level C<$send-E()> calls directly.
338              
339             =head2 empty
340              
341             await $res->empty();
342              
343             Send an empty response with status 204 No Content (or custom status if set).
344              
345             =head2 send
346              
347             await $res->send($text);
348             await $res->send($text, charset => 'iso-8859-1');
349              
350             Send text, encoding it to UTF-8 (or specified charset). Adds charset to
351             Content-Type if not present. This is the high-level method for sending
352             text responses.
353              
354             =head2 send_raw
355              
356             await $res->send_raw($bytes);
357              
358             Send raw bytes as the response body without any encoding. Use this for
359             binary data or when you've already encoded the content yourself.
360              
361             =head2 stream
362              
363             await $res->stream(async sub ($writer) {
364             await $writer->write("chunk1");
365             await $writer->write("chunk2");
366             await $writer->close();
367             });
368              
369             Stream response chunks via callback. The callback receives a writer object
370             with C, C, and C methods.
371              
372             =head2 writer
373              
374             my $writer = await $res->writer;
375             my $writer = await $res->writer(on_close => sub { cleanup() });
376             my $writer = await $res->writer(on_close => async sub { await cleanup() });
377              
378             Returns a L directly, sending headers immediately.
379             Unlike C, the writer is not scoped to a callback — you own it
380             and must call C when done.
381              
382             This is useful when the writer needs to be passed to event handlers,
383             pub/sub callbacks, timers, or other contexts outside a single function:
384              
385             async sub live_feed {
386             my ($self, $ctx) = @_;
387             my $writer = await $ctx->response
388             ->content_type('text/plain')
389             ->writer(on_close => sub { $bus->unsubscribe($id) });
390              
391             my $id = $bus->subscribe(async sub ($line) {
392             await $writer->write("$line\n");
393             });
394              
395             await $ctx->receive; # wait for disconnect
396             await $writer->close;
397             }
398              
399             The optional C callback is registered before headers are sent,
400             eliminating any race window with fast client disconnects. Sync and async
401             callbacks are both supported — see L under L.
402              
403             =head2 send_file
404              
405             await $res->send_file('/path/to/file.pdf');
406             await $res->send_file('/path/to/file.pdf',
407             filename => 'download.pdf',
408             inline => 1,
409             );
410              
411             # Partial file (for range requests)
412             await $res->send_file('/path/to/video.mp4',
413             offset => 1024, # Start from byte 1024
414             length => 65536, # Send 64KB
415             );
416              
417             Send a file as the response. This method uses the PAGI protocol's C
418             key for efficient server-side streaming. The file is B read into memory.
419             For production, use L to delegate file serving
420             to your reverse proxy.
421              
422             B
423              
424             =over 4
425              
426             =item * C - Set Content-Disposition attachment filename
427              
428             =item * C - Use Content-Disposition: inline instead of attachment
429              
430             =item * C - Start position in bytes (default: 0). For range requests.
431              
432             =item * C - Number of bytes to send. Defaults to file size minus offset.
433              
434             =back
435              
436             B
437              
438             # Manual range request handling
439             async sub handle_video ($req, $send) {
440             my $res = PAGI::Response->new($scope, $send);
441             my $path = '/videos/movie.mp4';
442             my $size = -s $path;
443              
444             my $range = $req->header('Range');
445             if ($range && $range =~ /bytes=(\d+)-(\d*)/) {
446             my $start = $1;
447             my $end = $2 || ($size - 1);
448             my $length = $end - $start + 1;
449              
450             return await $res->status(206)
451             ->header('Content-Range' => "bytes $start-$end/$size")
452             ->header('Accept-Ranges' => 'bytes')
453             ->send_file($path, offset => $start, length => $length);
454             }
455              
456             return await $res->header('Accept-Ranges' => 'bytes')
457             ->send_file($path);
458             }
459              
460             B For production file serving with full features (ETag caching,
461             automatic range request handling, conditional GETs, directory indexes),
462             use L instead:
463              
464             use PAGI::App::File;
465             my $files = PAGI::App::File->new(root => '/var/www/static');
466             my $app = $files->to_app;
467              
468             =head1 EXAMPLES
469              
470             =head2 Complete Raw PAGI Application
471              
472             use Future::AsyncAwait;
473             use PAGI::Request;
474             use PAGI::Response;
475              
476             my $app = async sub ($scope, $receive, $send) {
477             return await handle_lifespan($scope, $receive, $send)
478             if $scope->{type} eq 'lifespan';
479              
480             my $req = PAGI::Request->new($scope, $receive);
481             my $res = PAGI::Response->new($scope, $send);
482              
483             if ($req->method eq 'GET' && $req->path eq '/') {
484             return await $res->html('

Welcome

');
485             }
486              
487             if ($req->method eq 'POST' && $req->path eq '/api/users') {
488             my $data = await $req->json;
489             # ... create user ...
490             return await $res->status(201)
491             ->header('Location' => '/api/users/123')
492             ->json({ id => 123, name => $data->{name} });
493             }
494              
495             return await $res->status(404)->json({ error => 'Not Found' });
496             };
497              
498             =head2 Form Validation with Error Response
499              
500             async sub handle_contact ($req, $send) {
501             my $res = PAGI::Response->new($scope, $send);
502             my $form = await $req->form_params;
503              
504             my @errors;
505             my $email = $form->get('email') // '';
506             my $message = $form->get('message') // '';
507              
508             push @errors, 'Email required' unless $email;
509             push @errors, 'Invalid email' unless $email =~ /@/;
510             push @errors, 'Message required' unless $message;
511              
512             if (@errors) {
513             return await $res->status(422)
514             ->json({ error => 'Validation failed', errors => \@errors });
515             }
516              
517             # Process valid form...
518             return await $res->json({ success => 1 });
519             }
520              
521             =head2 Authentication with Cookies
522              
523             async sub handle_login ($req, $send) {
524             my $res = PAGI::Response->new($scope, $send);
525             my $data = await $req->json;
526              
527             my $user = authenticate($data->{email}, $data->{password});
528              
529             unless ($user) {
530             return await $res->status(401)->json({ error => 'Invalid credentials' });
531             }
532              
533             my $session_id = create_session($user);
534              
535             return await $res->cookie('session' => $session_id,
536             path => '/',
537             httponly => 1,
538             secure => 1,
539             samesite => 'Strict',
540             max_age => 86400, # 24 hours
541             )
542             ->json({ user => { id => $user->{id}, name => $user->{name} } });
543             }
544              
545             async sub handle_logout ($req, $send) {
546             my $res = PAGI::Response->new($scope, $send);
547              
548             return await $res->delete_cookie('session', path => '/')
549             ->json({ logged_out => 1 });
550             }
551              
552             =head2 File Download
553              
554             async sub handle_download ($req, $send) {
555             my $res = PAGI::Response->new($scope, $send);
556             my $file_id = $req->path_param('id');
557              
558             my $file = get_file($file_id); # Be sure to clean $file
559             unless ($file && -f $file->{path}) {
560             return await $res->status(404)->json({ error => 'File not found' });
561             }
562              
563             return await $res->send_file($file->{path},
564             filename => $file->{original_name},
565             );
566             }
567              
568             =head2 Streaming Large Data
569              
570             async sub handle_export ($req, $send) {
571             my $res = PAGI::Response->new($scope, $send);
572              
573             await $res->content_type('text/csv')
574             ->header('Content-Disposition' => 'attachment; filename="export.csv"')
575             ->stream(async sub ($writer) {
576             # Write CSV header
577             await $writer->write("id,name,email\n");
578              
579             # Stream rows from database
580             my $cursor = get_all_users_cursor();
581             while (my $user = $cursor->next) {
582             await $writer->write("$user->{id},$user->{name},$user->{email}\n");
583             }
584             });
585             }
586              
587             =head2 Server-Sent Events Style Streaming
588              
589             async sub handle_events ($req, $send) {
590             my $res = PAGI::Response->new($scope, $send);
591              
592             await $res->content_type('text/event-stream')
593             ->header('Cache-Control' => 'no-cache')
594             ->stream(async sub ($writer) {
595             for my $i (1..10) {
596             await $writer->write("data: Event $i\n\n");
597             await some_delay(1); # Wait 1 second
598             }
599             });
600             }
601              
602             =head2 Conditional Responses
603              
604             async sub handle_resource ($req, $send) {
605             my $res = PAGI::Response->new($scope, $send);
606             my $etag = '"abc123"';
607              
608             # Check If-None-Match for caching
609             my $if_none_match = $req->header('If-None-Match') // '';
610             if ($if_none_match eq $etag) {
611             return await $res->status(304)->empty();
612             }
613              
614             return await $res->header('ETag' => $etag)
615             ->header('Cache-Control' => 'max-age=3600')
616             ->json({ data => 'expensive computation result' });
617             }
618              
619             =head2 CORS API Endpoint
620              
621             # Simple CORS - allow all origins
622             async sub handle_api ($scope, $receive, $send) {
623             my $res = PAGI::Response->new($scope, $send);
624              
625             return await $res->cors->json({ status => 'ok' });
626             }
627              
628             # CORS with credentials (e.g., cookies, auth headers)
629             async sub handle_api_with_auth ($scope, $receive, $send) {
630             my $req = PAGI::Request->new($scope, $receive);
631             my $res = PAGI::Response->new($scope, $send);
632              
633             # Get the Origin header from request
634             my $origin = $req->header('Origin');
635              
636             return await $res->cors(
637             origin => 'https://myapp.com', # Or use request_origin
638             credentials => 1,
639             expose => [qw(X-Request-Id)],
640             )->json({ user => 'authenticated' });
641             }
642              
643             =head2 CORS Preflight Handler
644              
645             # Handle OPTIONS preflight requests
646             async sub app ($scope, $receive, $send) {
647             my $req = PAGI::Request->new($scope, $receive);
648             my $res = PAGI::Response->new($scope, $send);
649              
650             # Handle preflight
651             if ($req->method eq 'OPTIONS') {
652             return await $res->cors(
653             origin => 'https://myapp.com',
654             methods => [qw(GET POST PUT DELETE)],
655             headers => [qw(Content-Type Authorization X-Custom-Header)],
656             credentials => 1,
657             max_age => 86400,
658             preflight => 1, # Include preflight headers
659             )->status(204)->empty();
660             }
661              
662             # Handle actual request
663             return await $res->cors(
664             origin => 'https://myapp.com',
665             credentials => 1,
666             )->json({ data => 'response' });
667             }
668              
669             =head2 Dynamic CORS Origin
670              
671             # Allow multiple origins dynamically
672             my %ALLOWED_ORIGINS = map { $_ => 1 } qw(
673             https://app1.example.com
674             https://app2.example.com
675             https://localhost:3000
676             );
677              
678             async sub handle_api ($scope, $receive, $send) {
679             my $req = PAGI::Request->new($scope, $receive);
680             my $res = PAGI::Response->new($scope, $send);
681              
682             my $request_origin = $req->header('Origin') // '';
683              
684             # Check if origin is allowed
685             if ($ALLOWED_ORIGINS{$request_origin}) {
686             return await $res->cors(
687             origin => $request_origin, # Echo back the allowed origin
688             credentials => 1,
689             )->json({ data => 'allowed' });
690             }
691              
692             # Origin not allowed - respond without CORS headers
693             return await $res->status(403)->json({ error => 'Origin not allowed' });
694             }
695              
696             =head1 WRITER OBJECT
697              
698             The C method passes a writer object to its callback, and
699             C returns one directly. The writer has the following methods:
700              
701             =head3 write
702              
703             await $writer->write($chunk);
704              
705             Write a chunk of data to the response stream. Returns a L.
706              
707             Writing after close returns a failed L rather than throwing.
708             This allows cleanup code that races with close to handle the error
709             gracefully via C.
710              
711             =head3 close
712              
713             await $writer->close;
714              
715             Close the stream. Returns a L. Calling close multiple times is
716             safe — subsequent calls are no-ops.
717              
718             =head3 bytes_written
719              
720             my $n = $writer->bytes_written;
721              
722             Returns the total number of bytes written so far.
723              
724             =head3 on_close
725              
726             # Sync callback
727             $writer->on_close(sub { cleanup() });
728              
729             # Async callback — return value is awaited automatically
730             $writer->on_close(async sub {
731             await notify_stream_ended();
732             });
733              
734             # Chaining
735             $writer->on_close(sub { ... })
736             ->on_close(sub { ... });
737              
738             Registers a callback to fire when the writer closes (either explicitly
739             or via C auto-close). Callbacks can be regular subs or async
740             subs — async results are automatically awaited. Multiple callbacks run
741             in registration order. Exceptions are caught and warned but do not
742             prevent other callbacks from running. Returns C<$self> for chaining.
743              
744             B If your callback captures the writer
745             object in a closure, use C to avoid a memory leak:
746              
747             use Scalar::Util qw(weaken);
748             my $weak_writer = $writer;
749             weaken($weak_writer);
750             $writer->on_close(sub { $weak_writer->... if $weak_writer });
751              
752             The callback array is cleared after firing, so any cycle via a closure
753             is broken when the writer closes, but C prevents the object
754             from being kept alive until that point.
755              
756             =head3 is_closed
757              
758             if ($writer->is_closed) { ... }
759              
760             Returns true if the writer has been closed.
761              
762             The writer automatically closes when the C callback completes,
763             but calling C explicitly is recommended for clarity.
764              
765             =head1 ERROR HANDLING
766              
767             All finisher methods return Futures. Errors in encoding (e.g., invalid UTF-8
768             when C mode would be enabled) will cause the Future to fail.
769              
770             use Syntax::Keyword::Try;
771              
772             try {
773             await $res->json($data);
774             }
775             catch ($e) {
776             warn "Failed to send response: $e";
777             }
778              
779             =head1 SEE ALSO
780              
781             L, L, L
782              
783             =head1 AUTHOR
784              
785             PAGI Contributors
786              
787             =cut
788              
789             sub new {
790 115     115 1 656192 my ($class, $scope, $send) = @_;
791 115 100 66     819 croak("scope is required") unless $scope && ref($scope) eq 'HASH';
792 114 100       343 croak("send is required") unless $send;
793 113 100       393 croak("send must be a coderef") unless ref($send) eq 'CODE';
794              
795 112         684 my $self = bless {
796             send => $send,
797             scope => $scope,
798             # _status not set here - uses exists() check and lazy default of 200
799             _headers => [],
800             _header_set => {},
801             }, $class;
802              
803 112         410 return $self;
804             }
805              
806             sub status {
807 112     112 1 2615 my ($self, $code) = @_;
808 112 100 100     725 return $self->{_status} // 200 if @_ == 1; # lazy default
809 21 100 100     716 croak("Status must be a number between 100-599")
      100        
810             unless $code =~ /^\d+$/ && $code >= 100 && $code <= 599;
811 18         52 $self->{_status} = $code;
812 18         63 return $self;
813             }
814              
815             sub status_try {
816 2     2 1 4 my ($self, $code) = @_;
817 2 100       6 return $self if exists $self->{_status};
818 1         3 return $self->status($code);
819             }
820              
821             sub header {
822 58     58 1 1480 my ($self, $name, $value) = @_;
823 58 50       96 croak("Header name is required") unless defined $name;
824 58 100       112 if (@_ == 2) {
825 2         5 my $key = lc($name);
826 2         2 for (my $i = $#{$self->{_headers}}; $i >= 0; $i--) {
  2         7  
827 2         3 my $pair = $self->{_headers}[$i];
828 2 50       11 return $pair->[1] if lc($pair->[0]) eq $key;
829             }
830 0         0 return undef;
831             }
832 56         75 push @{$self->{_headers}}, [$name, $value];
  56         108  
833 56   50     125 my $key = lc($name // '');
834 56 50       136 $self->{_header_set}{$key} = 1 if length $key;
835 56 100       93 if ($key eq 'content-type') {
836 1         4 $self->{_content_type} = $value;
837             }
838 56         84 return $self;
839             }
840              
841             sub headers {
842 1     1 1 424 my ($self) = @_;
843 1         3 return $self->{_headers};
844             }
845              
846             sub header_all {
847 1     1 1 2 my ($self, $name) = @_;
848 1 50       5 croak("Header name is required") unless defined $name;
849 1         2 my $key = lc($name);
850 1         1 my @values;
851 1         1 for my $pair (@{$self->{_headers}}) {
  1         3  
852 1 50       4 push @values, $pair->[1] if lc($pair->[0]) eq $key;
853             }
854 1         3 return @values;
855             }
856              
857             sub header_try {
858 2     2 1 5 my ($self, $name, $value) = @_;
859 2 100       3 return $self if $self->has_header($name);
860 1         3 return $self->header($name, $value);
861             }
862              
863             sub content_type {
864 65     65 1 2305 my ($self, $type) = @_;
865 65 100       143 return $self->{_content_type} if @_ == 1;
866             # Remove existing content-type headers
867 63         168 $self->{_headers} = [grep { lc($_->[0]) ne 'content-type' } @{$self->{_headers}}];
  30         95  
  63         149  
868 63         122 push @{$self->{_headers}}, ['content-type', $type];
  63         171  
869 63         137 $self->{_header_set}{'content-type'} = 1;
870 63         127 $self->{_content_type} = $type;
871 63         109 return $self;
872             }
873              
874             sub content_type_try {
875 2     2 1 530 my ($self, $type) = @_;
876 2 100       6 return $self if exists $self->{_content_type};
877 1         3 return $self->content_type($type);
878             }
879              
880             sub has_status {
881 2     2 1 9 my ($self) = @_;
882 2 100       15 return exists $self->{_status} ? 1 : 0;
883             }
884              
885             sub has_header {
886 4     4 1 6 my ($self, $name) = @_;
887 4   50     20 my $key = lc($name // '');
888 4 50       7 return 0 unless length $key;
889 4 100       15 return $self->{_header_set}{$key} ? 1 : 0;
890             }
891              
892             sub has_content_type {
893 2     2 1 7 my ($self) = @_;
894 2 100       11 return exists $self->{_content_type} ? 1 : 0;
895             }
896              
897 6     6 1 23 sub scope { shift->{scope} }
898              
899              
900             sub is_sent {
901 6     6 1 93 my ($self) = @_;
902 6 100       23 return $self->{scope}{'pagi.response.sent'} ? 1 : 0;
903             }
904              
905             sub _mark_sent {
906 93     93   126 my ($self) = @_;
907 93 100       574 croak("Response already sent") if $self->{scope}{'pagi.response.sent'};
908 90         175 $self->{scope}{'pagi.response.sent'} = 1;
909             }
910              
911 65     65 1 173 async sub send_raw {
912 65         114 my ($self, $body) = @_;
913 65         154 $self->_mark_sent;
914              
915             # Send start
916             await $self->{send}->({
917             type => 'http.response.start',
918             status => $self->status, # uses lazy default of 200
919             headers => $self->{_headers},
920 63         152 });
921              
922             # Send body
923 63         2908 await $self->{send}->({
924             type => 'http.response.body',
925             body => $body,
926             more => 0,
927             });
928             }
929              
930 26     26 1 40 async sub send {
931 26         70 my ($self, $body, %opts) = @_;
932 26   50     122 my $charset = $opts{charset} // 'utf-8';
933              
934             # Ensure content-type has charset
935 26         33 my $has_ct = 0;
936 26         35 for my $h (@{$self->{_headers}}) {
  26         48  
937 33 100       87 if (lc($h->[0]) eq 'content-type') {
938 25         36 $has_ct = 1;
939 25 50       171 unless ($h->[1] =~ /charset=/i) {
940 0         0 $h->[1] .= "; charset=$charset";
941             }
942 25         48 last;
943             }
944             }
945 26 100       67 unless ($has_ct) {
946 1         3 push @{$self->{_headers}}, ['content-type', "text/plain; charset=$charset"];
  1         6  
947             }
948              
949             # Encode body
950 26   50     243 my $encoded = encode($charset, $body // '', FB_CROAK);
951              
952 26         2425 await $self->send_raw($encoded);
953             }
954              
955 22     22 1 2253 async sub text {
956 22         42 my ($self, $body) = @_;
957 22         63 $self->content_type('text/plain; charset=utf-8');
958 22         48 await $self->send($body);
959             }
960              
961 3     3 1 26 async sub html {
962 3         9 my ($self, $body) = @_;
963 3         11 $self->content_type('text/html; charset=utf-8');
964 3         10 await $self->send($body);
965             }
966              
967 22     22 1 1622 async sub json {
968 22         40 my ($self, $data) = @_;
969 22         58 $self->content_type('application/json; charset=utf-8');
970 22         135 my $body = JSON::MaybeXS->new(utf8 => 1, canonical => 1)->encode($data);
971 22         653 await $self->send_raw($body);
972             }
973              
974 5     5 1 19 async sub redirect {
975 5         12 my ($self, $url, $status) = @_;
976 5   100     13 $status //= 302;
977 5         10 $self->{_status} = $status;
978 5         13 $self->header('location', $url);
979 5         12 await $self->send_raw('');
980             }
981              
982 6     6 1 15 async sub empty {
983 6         10 my ($self) = @_;
984             # Use 204 if status hasn't been explicitly set
985 6 100       16 unless (exists $self->{_status}) {
986 3         6 $self->{_status} = 204;
987             }
988 6         16 await $self->send_raw(undef);
989             }
990              
991             sub cookie {
992 6     6 1 36 my ($self, $name, $value, %opts) = @_;
993 6         18 my @parts = ("$name=$value");
994              
995 6 100       17 push @parts, "Max-Age=$opts{max_age}" if defined $opts{max_age};
996 6 50       13 push @parts, "Expires=$opts{expires}" if defined $opts{expires};
997 6 100       18 push @parts, "Path=$opts{path}" if defined $opts{path};
998 6 100       14 push @parts, "Domain=$opts{domain}" if defined $opts{domain};
999 6 100       13 push @parts, "Secure" if $opts{secure};
1000 6 100       13 push @parts, "HttpOnly" if $opts{httponly};
1001 6 100       15 push @parts, "SameSite=$opts{samesite}" if defined $opts{samesite};
1002              
1003 6         16 my $cookie_str = join('; ', @parts);
1004 6         17 push @{$self->{_headers}}, ['set-cookie', $cookie_str];
  6         14  
1005              
1006 6         48 return $self;
1007             }
1008              
1009             sub delete_cookie {
1010 1     1 1 5 my ($self, $name, %opts) = @_;
1011             return $self->cookie($name, '',
1012             max_age => 0,
1013             path => $opts{path},
1014             domain => $opts{domain},
1015 1         5 );
1016             }
1017              
1018             sub cors {
1019 8     8 1 55 my ($self, %opts) = @_;
1020 8   100     23 my $origin = $opts{origin} // '*';
1021 8   100     25 my $credentials = $opts{credentials} // 0;
1022 8   100     27 my $methods = $opts{methods} // [qw(GET POST PUT DELETE PATCH OPTIONS)];
1023 8   100     27 my $headers = $opts{headers} // [qw(Content-Type Authorization X-Requested-With)];
1024 8   100     22 my $expose = $opts{expose} // [];
1025 8   100     20 my $max_age = $opts{max_age} // 86400;
1026 8   100     20 my $preflight = $opts{preflight} // 0;
1027              
1028             # Determine the origin to send back
1029 8         11 my $allow_origin;
1030 8 100 100     24 if ($origin eq '*' && $credentials) {
1031             # With credentials, can't use wildcard - use request_origin if provided
1032 1   50     4 $allow_origin = $opts{request_origin} // '*';
1033             } else {
1034 7         9 $allow_origin = $origin;
1035             }
1036              
1037             # Core CORS headers (always set)
1038 8         21 $self->header('Access-Control-Allow-Origin', $allow_origin);
1039 8         16 $self->header('Vary', 'Origin');
1040              
1041 8 100       14 if ($credentials) {
1042 3         6 $self->header('Access-Control-Allow-Credentials', 'true');
1043             }
1044              
1045 8 100       13 if (@$expose) {
1046 2         8 $self->header('Access-Control-Expose-Headers', join(', ', @$expose));
1047             }
1048              
1049             # Preflight headers (for OPTIONS responses or when explicitly requested)
1050 8 100       16 if ($preflight) {
1051 2         10 $self->header('Access-Control-Allow-Methods', join(', ', @$methods));
1052 2         9 $self->header('Access-Control-Allow-Headers', join(', ', @$headers));
1053 2         5 $self->header('Access-Control-Max-Age', $max_age);
1054             }
1055              
1056 8         34 return $self;
1057             }
1058              
1059 15     15 1 120 async sub stream {
1060 15         25 my ($self, $callback) = @_;
1061 15         35 $self->_mark_sent;
1062              
1063             # Send start
1064             await $self->{send}->({
1065             type => 'http.response.start',
1066             status => $self->status, # uses lazy default of 200
1067             headers => $self->{_headers},
1068 15         47 });
1069              
1070             # Create writer and call callback
1071 15         616 my $writer = PAGI::Response::Writer->new($self->{send});
1072 15         29 await $callback->($writer);
1073              
1074             # Ensure closed
1075 15 100       1941 await $writer->close() unless $writer->is_closed;
1076             }
1077              
1078 5     5 1 54 async sub writer {
1079 5         8 my ($self, %opts) = @_;
1080 5         10 $self->_mark_sent;
1081              
1082             # Send headers
1083             await $self->{send}->({
1084             type => 'http.response.start',
1085             status => $self->status,
1086             headers => $self->{_headers},
1087 4         8 });
1088              
1089 4         169 return PAGI::Response::Writer->new($self->{send}, %opts);
1090             }
1091              
1092             # Simple MIME type mapping
1093             my %MIME_TYPES = (
1094             '.html' => 'text/html',
1095             '.htm' => 'text/html',
1096             '.txt' => 'text/plain',
1097             '.css' => 'text/css',
1098             '.js' => 'application/javascript',
1099             '.json' => 'application/json',
1100             '.xml' => 'application/xml',
1101             '.pdf' => 'application/pdf',
1102             '.zip' => 'application/zip',
1103             '.png' => 'image/png',
1104             '.jpg' => 'image/jpeg',
1105             '.jpeg' => 'image/jpeg',
1106             '.gif' => 'image/gif',
1107             '.svg' => 'image/svg+xml',
1108             '.ico' => 'image/x-icon',
1109             '.woff' => 'font/woff',
1110             '.woff2'=> 'font/woff2',
1111             );
1112              
1113             sub _mime_type {
1114 8     8   15 my ($path) = @_;
1115 8         35 my ($ext) = $path =~ /(\.[^.]+)$/;
1116 8   100     55 return $MIME_TYPES{lc($ext // '')} // 'application/octet-stream';
      100        
1117             }
1118              
1119 12     12 1 100 async sub send_file {
1120 12         30 my ($self, $path, %opts) = @_;
1121 12 100       312 croak("File not found: $path") unless -f $path;
1122 11 50       123 croak("Cannot read file: $path") unless -r $path;
1123              
1124             # Get file size
1125 11         53 my $file_size = -s $path;
1126              
1127             # Handle offset and length for range requests
1128 11   100     56 my $offset = $opts{offset} // 0;
1129 11         16 my $length = $opts{length};
1130              
1131             # Validate offset
1132 11 100       137 croak("offset must be non-negative") if $offset < 0;
1133 10 100       129 croak("offset exceeds file size") if $offset > $file_size;
1134              
1135             # Calculate actual length to send
1136 9         11 my $max_length = $file_size - $offset;
1137 9 100       19 if (defined $length) {
1138 3 100       109 croak("length must be non-negative") if $length < 0;
1139 2 100       5 $length = $max_length if $length > $max_length;
1140             } else {
1141 6         9 $length = $max_length;
1142             }
1143              
1144             # Set content-type if not already set
1145 8         11 my $has_ct = grep { lc($_->[0]) eq 'content-type' } @{$self->{_headers}};
  0         0  
  8         43  
1146 8 50       22 unless ($has_ct) {
1147 8         17 $self->content_type(_mime_type($path));
1148             }
1149              
1150             # Set content-length based on actual bytes to send
1151 8         25 $self->header('content-length', $length);
1152              
1153             # Set content-disposition
1154 8         10 my $disposition;
1155 8 100       21 if ($opts{inline}) {
    100          
1156 1         3 $disposition = 'inline';
1157             } elsif ($opts{filename}) {
1158             # Sanitize filename for header
1159 2         6 my $safe_filename = $opts{filename};
1160 2         5 $safe_filename =~ s/["\r\n]//g;
1161 2         6 $disposition = "attachment; filename=\"$safe_filename\"";
1162             }
1163 8 100       15 $self->header('content-disposition', $disposition) if $disposition;
1164              
1165 8         41 $self->_mark_sent;
1166              
1167             # Send response start
1168             await $self->{send}->({
1169             type => 'http.response.start',
1170             status => $self->status, # uses lazy default of 200
1171             headers => $self->{_headers},
1172 8         32 });
1173              
1174             # Use PAGI file protocol for efficient server-side streaming
1175 8         321 my $body_event = {
1176             type => 'http.response.body',
1177             file => $path,
1178             };
1179              
1180             # Add offset/length only if not reading from start or not full file
1181 8 100       19 $body_event->{offset} = $offset if $offset > 0;
1182 8 100       15 $body_event->{length} = $length if $length < $max_length;
1183              
1184 8         16 await $self->{send}->($body_event);
1185             }
1186              
1187             # Writer class for streaming responses
1188             package PAGI::Response::Writer {
1189 11     11   98 use strict;
  11         19  
  11         264  
1190 11     11   48 use warnings;
  11         16  
  11         484  
1191 11     11   43 use Future::AsyncAwait;
  11         32  
  11         78  
1192 11     11   534 use Carp qw(croak);
  11         15  
  11         619  
1193 11     11   51 use Scalar::Util qw(blessed);
  11         27  
  11         7785  
1194              
1195             sub new {
1196 19     19   51 my ($class, $send, %opts) = @_;
1197 19         56 my $self = bless {
1198             send => $send,
1199             bytes_written => 0,
1200             closed => 0,
1201             _on_close => [],
1202             }, $class;
1203 19 100       38 push @{$self->{_on_close}}, $opts{on_close} if $opts{on_close};
  1         4  
1204 19         40 return $self;
1205             }
1206              
1207 18     18   1646 async sub write {
1208 18         31 my ($self, $chunk) = @_;
1209 18 100       61 die 'Writer already closed' if $self->{closed};
1210 16   50     39 $self->{bytes_written} += length($chunk // '');
1211 16         59 await $self->{send}->({
1212             type => 'http.response.body',
1213             body => $chunk,
1214             more => 1,
1215             });
1216             }
1217              
1218 17     17   396 async sub close {
1219 17         22 my ($self) = @_;
1220 17 50       39 return if $self->{closed};
1221 17         23 $self->{closed} = 1;
1222 17         45 await $self->{send}->({
1223             type => 'http.response.body',
1224             body => '',
1225             more => 0,
1226             });
1227 17         390 for my $cb (@{$self->{_on_close}}) {
  17         36  
1228 15         141 eval {
1229 15         23 my $r = $cb->();
1230 14 100 100     93 if (blessed($r) && $r->isa('Future')) {
1231 2         5 await $r;
1232             }
1233             };
1234 15 100       59 if ($@) {
1235 2         22 warn "PAGI::Response::Writer on_close callback error: $@";
1236             }
1237             }
1238              
1239             # Clear callback array to break any closure-based cycles
1240 17         96 $self->{_on_close} = [];
1241             }
1242              
1243             sub on_close {
1244 14     14   94 my ($self, $cb) = @_;
1245 14         14 push @{$self->{_on_close}}, $cb;
  14         23  
1246 14         18 return $self;
1247             }
1248              
1249 18     18   161 sub is_closed { $_[0]->{closed} }
1250              
1251 1     1   42 sub bytes_written { $_[0]->{bytes_written} }
1252             }
1253              
1254             1;