File Coverage

blib/lib/PAGI/Response.pm
Criterion Covered Total %
statement 302 302 100.0
branch 119 130 91.5
condition 56 63 88.8
subroutine 57 57 100.0
pod 31 31 100.0
total 565 583 96.9


line stmt bran cond sub pod time code
1             package PAGI::Response;
2             $PAGI::Response::VERSION = '0.002000';
3 19     19   835815 use strict;
  19         26  
  19         690  
4 19     19   87 use warnings;
  19         64  
  19         787  
5              
6 19     19   92 use Future::AsyncAwait;
  19         23  
  19         160  
7 19     19   948 use Carp qw(croak);
  19         46  
  19         1071  
8 19     19   3026 use Encode qw(encode FB_CROAK);
  19         99893  
  19         1418  
9 19     19   5902 use JSON::MaybeXS ();
  19         140746  
  19         681  
10 19     19   6585 use PAGI::Headers ();
  19         34  
  19         84971  
11              
12              
13             =encoding UTF-8
14              
15             =head1 NAME
16              
17             PAGI::Response - Fluent response builder for PAGI applications
18              
19             =head1 SYNOPSIS
20              
21             use PAGI::Response;
22             use Future::AsyncAwait;
23              
24             # A response is a VALUE: build it, then send it (or return it, or mount it).
25              
26             # Raw PAGI app: build the value, send it with respond($send)
27             async sub app ($scope, $receive, $send) {
28             my $res = PAGI::Response->new($scope); # detached -- no connection
29             await $res->status(200)
30             ->header('X-Custom' => 'value')
31             ->json({ message => 'Hello' }) # sets the body, returns $self
32             ->respond($send); # the single send step
33             }
34              
35             # In an endpoint you just RETURN it; dispatch sends it for you:
36             async sub get ($self, $ctx) {
37             return $ctx->json({ message => 'Hello' }, status => 200);
38             }
39              
40             # Class-method factories build a detached response in one call;
41             # status/content_type/headers go as trailing options:
42             my $res = PAGI::Response->text("Hello World");
43             my $res = PAGI::Response->html("

Hello

");
44             my $res = PAGI::Response->json({ data => 'value' });
45             my $res = PAGI::Response->json({ error => 'not found' }, status => 404);
46             my $res = PAGI::Response->redirect('/login');
47              
48             # Because it's a value, it works anywhere an app does:
49             $router->mount('/health' => PAGI::Response->json({ ok => \1 }));
50              
51             # Streaming: the callback runs at send time (auto-closes when done)
52             await PAGI::Response->new($scope)
53             ->content_type('text/csv')
54             ->stream(async sub ($writer) {
55             await $writer->write("id,name\n");
56             await $writer->write("1,Alice\n");
57             })
58             ->respond($send);
59              
60             # File downloads:
61             await PAGI::Response->new($scope)
62             ->send_file('/path/to/file.pdf', filename => 'doc.pdf')
63             ->respond($send);
64              
65             =head1 DESCRIPTION
66              
67             PAGI::Response provides a fluent interface for building HTTP responses in
68             PAGI applications. It is a detached value object: it holds status, headers,
69             and body but has no connection. Sending is done via L or L.
70              
71             B (C, C
, C, C)
72             return C<$self> for fluent chaining.
73              
74             B (C, C, C, C, etc.) set the
75             response body and also return C<$self>. They can be called as class-method
76             factories (C<< PAGI::Response->json($data) >>) or as instance methods
77             (C<< $res->json($data) >>).
78              
79             =head1 CONSTRUCTOR
80              
81             =head2 new
82              
83             my $res = PAGI::Response->new;
84             my $res = PAGI::Response->new($scope);
85              
86             Creates a detached response value. The response holds no connection and no
87             C<$send> callback — it is a pure value object that accumulates status,
88             headers, and body via the chainer methods.
89              
90             =over 4
91              
92             =item C<$scope> - Optional. A PAGI scope hashref. When provided it is stored
93             inert (for accessors like C and helpers like L).
94             It is B used as a connection — no C<$send> is stored here.
95              
96             =back
97              
98             To actually send the response, call L with the C<$send> callback,
99             or mount it as a PAGI app via L.
100              
101             Because the constructor stores no connection, the same response value can be
102             served to multiple connections (re-entrantly) by calling C more than
103             once.
104              
105             =head1 CHAINABLE METHODS
106              
107             These methods return C<$self> for fluent chaining.
108              
109             =head2 status
110              
111             $res->status(404);
112             my $code = $res->status;
113              
114             Set or get the HTTP status code (100-599). Returns C<$self> when setting
115             for fluent chaining. When getting, returns 200 if no status has been set.
116              
117             my $res = PAGI::Response->new($scope);
118             $res->status; # 200 (default, nothing set yet)
119             $res->has_status; # false
120             $res->status(201); # set explicitly
121             $res->has_status; # true
122              
123             =head2 status_try
124              
125             $res->status_try(404);
126              
127             Set the HTTP status code only if one hasn't been set yet. Useful in
128             middleware or error handlers to provide fallback status codes without
129             overriding choices made by the application:
130              
131             $res->status_try(202); # sets to 202 (nothing was set)
132             $res->status_try(500); # no-op, 202 already set
133              
134             =head2 header
135              
136             $res->header('X-Custom' => 'value');
137             my $value = $res->header('X-Custom');
138              
139             Add a response header. Can be called multiple times to add multiple headers.
140             If called with only a name, returns the last value for that header or C.
141              
142             =head2 headers
143              
144             my $headers = $res->headers;
145              
146             Returns the response headers as a L object. The object's C<@{}>
147             overload yields a copy of the C<[name, value]> pairs in insertion order, so
148             existing code that iterates C<@{$res->headers}> continues to work.
149              
150             =head2 header_all
151              
152             my @values = $res->header_all('Set-Cookie');
153              
154             Returns all values for the given header name (case-insensitive).
155              
156             =head2 header_try
157              
158             $res->header_try('X-Custom' => 'value');
159              
160             Add a response header only if that header name has not already been set.
161              
162             =head2 remove_header
163              
164             $res->remove_header('X-Custom');
165              
166             Remove all instances of the named header (case-insensitive). Returns C<$self>
167             for fluent chaining. No-op if the header was not set.
168              
169             =head2 content_type
170              
171             $res->content_type('text/html; charset=utf-8');
172             my $type = $res->content_type;
173             $res->content_type(undef); # clears Content-Type so a body method can re-default it
174              
175             Set the Content-Type header, replacing any existing one. Passing C
176             removes Content-Type entirely, which lets a subsequent body method (C,
177             C, C) re-apply its default.
178              
179             =head2 content_type_try
180              
181             $res->content_type_try('text/html; charset=utf-8');
182              
183             Set the Content-Type header only if it has not already been set.
184              
185             =head2 cookie
186              
187             $res->cookie('session' => 'abc123',
188             max_age => 3600,
189             path => '/',
190             domain => 'example.com',
191             secure => 1,
192             httponly => 1,
193             samesite => 'Strict',
194             );
195              
196             Set a response cookie. Options: max_age, expires, path, domain, secure,
197             httponly, samesite.
198              
199             =head2 delete_cookie
200              
201             $res->delete_cookie('session');
202              
203             Delete a cookie by setting it with Max-Age=0.
204              
205             =head2 scope
206              
207             my $scope = $res->scope;
208              
209             Returns the raw PAGI scope hashref. Useful for constructing helper
210             objects like L and L:
211              
212             my $stash = PAGI::Stash->new($res);
213              
214             =head2 Per-Request Shared State
215              
216             See L for per-request shared state. Construct from a
217             Response object or from the shared scope:
218              
219             use PAGI::Stash;
220             my $stash = PAGI::Stash->new($res);
221              
222             =head2 is_sent
223              
224             if ($res->is_sent) {
225             warn "Response already sent, cannot send error";
226             return;
227             }
228              
229             Returns true if the server-owned C object for this request
230             reports C — meaning the response has started on this
231             connection (headers have been emitted). Reflects a server-owned fact, not a
232             flag on this Response value.
233              
234             Returns 0 if there is no C in scope (server-less / not
235             started). Dies (C) if a C is present but lacks the
236             C method, which indicates a non-conforming server.
237              
238             =head2 has_status
239              
240             if ($res->has_status) { ... }
241              
242             Returns true if a status code has been explicitly set via C or
243             C.
244              
245             =head2 has_header
246              
247             if ($res->has_header('content-type')) { ... }
248              
249             Returns true if the given header name has been set via C
or
250             C. Header names are case-insensitive.
251              
252             =head2 has_content_type
253              
254             if ($res->has_content_type) { ... }
255              
256             Returns true if Content-Type has been explicitly set via C,
257             C, or C
/C with a Content-Type name.
258              
259             =head2 has_body_source
260              
261             if ($res->has_body_source) { ... }
262              
263             Returns true if a B has been registered on the response — a
264             buffered body (via C/C/C/C/C/C/
265             C), a file (via C), or a stream callback (via C).
266              
267             This is a B signal. It answers "did the handler
268             register something to send?", B "have any bytes been produced or sent":
269              
270             =over 4
271              
272             =item * For a C, it is true the instant the callback is registered,
273             B C runs it and before a single byte is written. A registered
274             stream that has produced zero bytes still reports C true — that
275             is the only coherent meaning, since C is what drives the stream.
276              
277             =item * An B counts: C, C, and
278             C all register a body (the empty string), so they report true.
279             A response that has had no body method called reports false.
280              
281             =item * It is independent of L. C describes the
282             value; C describes whether the value has gone out on a connection. For
283             "has the response been emitted", use C; for "has the stream finished",
284             C the Future returned by C.
285              
286             =back
287              
288             A response whose handler set only a status or a header (no body method) reports
289             C false even though it is a legitimate response (e.g. a bare
290             C<204> or a redirect built only via C + C
). Frameworks deciding
291             whether to auto-send should therefore test C<< $res->has_body_source ||
292             $res->has_status >>. See the L
293             section for the full state model.
294              
295             =head2 cors
296              
297             # Allow all origins (simplest case)
298             $res->cors->json({ data => 'value' });
299              
300             # Allow specific origin
301             $res->cors(origin => 'https://example.com')->json($data);
302              
303             # Full configuration
304             $res->cors(
305             origin => 'https://example.com',
306             methods => [qw(GET POST PUT DELETE)],
307             headers => [qw(Content-Type Authorization)],
308             expose => [qw(X-Request-Id X-RateLimit-Remaining)],
309             credentials => 1,
310             max_age => 86400,
311             preflight => 0,
312             )->json($data);
313              
314             Add CORS (Cross-Origin Resource Sharing) headers to the response.
315             Returns C<$self> for chaining.
316              
317             B
318              
319             =over 4
320              
321             =item * C - Allowed origin. Default: C<'*'> (all origins).
322             Can be a specific origin like C<'https://example.com'> or C<'*'> for any.
323              
324             =item * C - Arrayref of allowed HTTP methods for preflight.
325             Default: C<[qw(GET POST PUT DELETE PATCH OPTIONS)]>.
326              
327             =item * C - Arrayref of allowed request headers for preflight.
328             Default: C<[qw(Content-Type Authorization X-Requested-With)]>.
329              
330             =item * C - Arrayref of response headers to expose to the client.
331             By default, only simple headers (Cache-Control, Content-Language, etc.)
332             are accessible. Use this to expose custom headers.
333              
334             =item * C - Boolean. If true, sets
335             C, allowing cookies and
336             Authorization headers. Default: C<0>.
337              
338             =item * C - How long (in seconds) browsers should cache preflight
339             results. Default: C<86400> (24 hours).
340              
341             =item * C - Boolean. If true, includes preflight response headers
342             (Allow-Methods, Allow-Headers, Max-Age). Set this when handling OPTIONS
343             requests. Default: C<0>.
344              
345             =item * C - The Origin header value from the request.
346             Required when C is true and C is C<'*'>, because
347             the CORS spec forbids using C<'*'> with credentials. Pass the actual
348             request origin to echo it back.
349              
350             =back
351              
352             B
353              
354             =over 4
355              
356             =item * When C is true, you cannot use C<< origin => '*' >>.
357             Either specify an exact origin, or pass C with the
358             client's actual Origin header.
359              
360             =item * The C header is always set to ensure proper caching
361             when origin-specific responses are used.
362              
363             =item * For preflight (OPTIONS) requests, set C<< preflight => 1 >> and
364             typically respond with C<< $res->status(204)->empty() >>.
365              
366             =back
367              
368             =head1 SEND PRIMITIVE AND APP MOUNTING
369              
370             =head2 respond
371              
372             await $res->respond($send);
373              
374             The single send primitive for a detached response value. Reads the
375             accumulated status, headers, and body from C<$self> and emits the
376             appropriate PAGI protocol events via C<$send>.
377              
378             C<$send> must be a coderef (the PAGI send callback). C does
379             B mutate the response object, so the same response value can be
380             passed to C multiple times for different connections.
381              
382             For streaming responses (set up via the C<_stream> slot), C
383             sends the start event, runs the stream callback with a
384             L, and ensures the writer is closed.
385              
386             Returns a L.
387              
388             =head2 to_app
389              
390             my $app = $res->to_app;
391              
392             Returns a PAGI application coderef C that
393             calls L with the given C<$send> when invoked. Use this to mount
394             a response value directly as a PAGI app:
395              
396             my $not_found = PAGI::Response->new
397             ->status(404)
398             ->_set_body('Not Found', 'text/plain');
399              
400             # Mount as a fallback app
401             my $app = $not_found->to_app;
402              
403             =head1 BODY METHODS
404              
405             These methods set the response body and return C<$self>. Sending happens via
406             L / L or the endpoint return contract.
407              
408             Each method works as both a B and an B:
409              
410             # Class-method factory — creates a new detached response and returns it
411             return $ctx->json($data); # instance method on existing $res
412             return PAGI::Response->json($data); # factory shorthand
413              
414             # Chain body with other setters before sending
415             PAGI::Response->json($data)->status(201)->respond($send)->get;
416              
417             The Content-Type these methods set is a B: an explicit C
418             set beforehand is preserved, not overridden.
419              
420             These helpers UTF-8-encode the body, so they make the Content-Type advertise
421             that encoding. When you preset a charset-less type they append C<; charset=utf-8>
422             to it — C<< content_type('application/xml')->html($xml) >> sends
423             C (charset is meaningful for XML and C,
424             RFC 7303). The exceptions are C and the C<+json> structured-suffix
425             types, which are left bare: JSON is always UTF-8 and defines no charset parameter
426             (RFC 8259). An explicit charset you set yourself is never overridden. If you need
427             a body in some other encoding, encode it yourself and use L.
428              
429             =head2 Trailing options (status, content_type, headers)
430              
431             The body methods C, C, C, C, and C accept
432             trailing named options as a convenience so you can set status, content-type,
433             and extra headers in a single call without chaining:
434              
435             PAGI::Response->json($data, status => 404);
436             PAGI::Response->text('Hi', status => 201, headers => ['X-Foo' => 'bar']);
437             PAGI::Response->send_raw($bytes, content_type => 'application/octet-stream');
438             PAGI::Response->empty(status => 304);
439              
440             Recognised options:
441              
442             =over 4
443              
444             =item B — HTTP status code (integer).
445              
446             =item B — sets the Content-Type header, overriding any default.
447              
448             =item B — a flat arrayref of C<< name => value >> pairs to append.
449             Example: C<< headers => ['X-Foo' => 'bar', 'X-Baz' => 'qux'] >>.
450              
451             =back
452              
453             An unrecognised option name causes an immediate C, catching typos such
454             as C 404> before they silently send 200.
455              
456             The existing chaining form C<< ->json($data)->status(404) >> keeps working.
457              
458             =head2 text
459              
460             $res->text("Hello World");
461             PAGI::Response->text("Hello World");
462             PAGI::Response->text("Not found", status => 404);
463              
464             Set body to the UTF-8–encoded string with Content-Type: text/plain; charset=utf-8.
465             Accepts trailing options (C, C, C). Returns C<$self>.
466              
467             =head2 html
468              
469             $res->html("

Hello

");
470             PAGI::Response->html("

Hello

");
471             PAGI::Response->html("

Error

", status => 500);
472              
473             Set body to the UTF-8–encoded string with Content-Type: text/html; charset=utf-8.
474             Accepts trailing options (C, C, C). Returns C<$self>.
475              
476             =head2 json
477              
478             $res->json({ message => 'Hello' });
479             PAGI::Response->json({ message => 'Hello' });
480             PAGI::Response->json({ error => 'nope' }, status => 404);
481              
482             Set body to the JSON-encoded data with Content-Type: application/json. No charset
483             parameter is added — JSON is always UTF-8 and C defines none
484             (RFC 8259). Accepts trailing options (C, C, C).
485             Returns C<$self>.
486              
487             =head2 redirect
488              
489             $res->redirect('/login');
490             $res->redirect('/new-url', 301);
491             PAGI::Response->redirect('/login');
492              
493             Set an empty body and a Location header. Default status is 302. Returns C<$self>.
494              
495             B While RFC 7231 suggests including a short HTML body with a
496             hyperlink for clients that don't auto-follow redirects, all modern browsers
497             and HTTP clients ignore redirect bodies. If you need a body for legacy
498             compatibility, set it explicitly after calling C.
499              
500             =head2 empty
501              
502             $res->empty;
503             PAGI::Response->new->empty;
504             PAGI::Response->empty(status => 304);
505              
506             Set an empty body with status 204 No Content (or keep a previously set status).
507             Accepts trailing options (C, C, C); an explicit
508             C option overrides the 204 default. Returns C<$self>.
509              
510             =head2 send
511              
512             $res->send($text);
513             $res->send($text, charset => 'iso-8859-1');
514              
515             Set body to the encoded text (UTF-8 by default, or the specified charset).
516             Defaults the Content-Type to C and appends the charset to a
517             charset-less type, on the same rules as L (C and
518             C<+json> types stay bare). Returns C<$self>.
519              
520             =head2 send_raw
521              
522             $res->send_raw($bytes);
523             PAGI::Response->send_raw($bytes, content_type => 'application/octet-stream');
524              
525             Set body to raw bytes without any encoding. Use for binary data or pre-encoded
526             content. Accepts trailing options (C, C, C).
527             Returns C<$self>.
528              
529             =head2 stream
530              
531             $res->stream(async sub {
532             my ($writer) = @_;
533             await $writer->write("chunk1");
534             await $writer->write("chunk2");
535             await $writer->close();
536             });
537             PAGI::Response->stream($callback);
538              
539             Store a streaming callback. When the response is sent via L, the callback
540             receives a L and streams chunks. Returns C<$self>.
541              
542             =head2 writer
543              
544             my $writer = await $res->writer($send);
545             my $writer = await $res->writer($send, on_close => sub { cleanup() });
546             my $writer = await $res->writer($send, on_close => async sub { await cleanup() });
547              
548             Returns a L directly, sending headers immediately.
549             Unlike C, the writer is not scoped to a callback — you own it
550             and must call C when done.
551              
552             C<$send> must be a coderef (the PAGI send callback). This is the same
553             C<$send> you would pass to L.
554              
555             This is useful when the writer needs to be passed to event handlers,
556             pub/sub callbacks, timers, or other contexts outside a single function:
557              
558             async sub live_feed {
559             my ($self, $ctx) = @_;
560             my $writer = await $ctx->response
561             ->content_type('text/plain')
562             ->writer($ctx->send, on_close => sub { $bus->unsubscribe($id) });
563              
564             my $id = $bus->subscribe(async sub ($line) {
565             await $writer->write("$line\n");
566             });
567              
568             await $ctx->receive; # wait for disconnect
569             await $writer->close;
570             }
571              
572             The optional C callback is registered before headers are sent,
573             eliminating any race window with fast client disconnects. Sync and async
574             callbacks are both supported — see L under L.
575              
576             =head2 send_file
577              
578             $res->send_file('/path/to/file.pdf');
579             $res->send_file('/path/to/file.pdf',
580             filename => 'download.pdf',
581             inline => 1,
582             );
583             PAGI::Response->send_file('/path/to/file.pdf');
584              
585             # Partial file (for range requests)
586             $res->send_file('/path/to/video.mp4',
587             offset => 1024, # Start from byte 1024
588             length => 65536, # Send 64KB
589             );
590              
591             Set the response to serve a file. Stats the file and sets Content-Type,
592             Content-Length, and Content-Disposition at call time. The PAGI protocol's
593             C key is used for efficient server-side streaming (file not read into
594             memory) when L is called. Returns C<$self>.
595              
596             For production, use L to delegate file serving
597             to your reverse proxy.
598              
599             B
600              
601             =over 4
602              
603             =item * C - Set Content-Disposition attachment filename
604              
605             =item * C - Use Content-Disposition: inline instead of attachment
606              
607             =item * C - Start position in bytes (default: 0). For range requests.
608              
609             =item * C - Number of bytes to send. Defaults to file size minus offset.
610              
611             =back
612              
613             B
614              
615             # Manual range request handling
616             async sub handle_video {
617             my ($req, $send) = @_;
618             my $path = '/videos/movie.mp4';
619             my $size = -s $path;
620              
621             my $range = $req->header('Range');
622             if ($range && $range =~ /bytes=(\d+)-(\d*)/) {
623             my $start = $1;
624             my $end = $2 || ($size - 1);
625             my $length = $end - $start + 1;
626              
627             return await PAGI::Response->new
628             ->status(206)
629             ->header('Content-Range' => "bytes $start-$end/$size")
630             ->header('Accept-Ranges' => 'bytes')
631             ->send_file($path, offset => $start, length => $length)
632             ->respond($send);
633             }
634              
635             return await PAGI::Response->new
636             ->header('Accept-Ranges' => 'bytes')
637             ->send_file($path)
638             ->respond($send);
639             }
640              
641             B For production file serving with full features (ETag caching,
642             automatic range request handling, conditional GETs, directory indexes),
643             use L instead:
644              
645             use PAGI::App::File;
646             my $files = PAGI::App::File->new(root => '/var/www/static');
647             my $app = $files->to_app;
648              
649             =head1 EXAMPLES
650              
651             =head2 Complete Raw PAGI Application
652              
653             use Future::AsyncAwait;
654             use PAGI::Request;
655             use PAGI::Response;
656              
657             my $app = async sub ($scope, $receive, $send) {
658             return await handle_lifespan($scope, $receive, $send)
659             if $scope->{type} eq 'lifespan';
660              
661             my $req = PAGI::Request->new($scope, $receive);
662             my $res = $req->response;
663              
664             if ($req->method eq 'GET' && $req->path eq '/') {
665             return await $res->html('

Welcome

')->respond($send);
666             }
667              
668             if ($req->method eq 'POST' && $req->path eq '/api/users') {
669             my $data = await $req->json;
670             # ... create user ...
671             return await $res->status(201)
672             ->header('Location' => '/api/users/123')
673             ->json({ id => 123, name => $data->{name} })
674             ->respond($send);
675             }
676              
677             return await $res->status(404)->json({ error => 'Not Found' })->respond($send);
678             };
679              
680             =head2 Form Validation with Error Response
681              
682             async sub handle_contact ($req, $send) {
683             my $res = $req->response;
684             my $form = await $req->form_params;
685              
686             my @errors;
687             my $email = $form->get('email') // '';
688             my $message = $form->get('message') // '';
689              
690             push @errors, 'Email required' unless $email;
691             push @errors, 'Invalid email' unless $email =~ /@/;
692             push @errors, 'Message required' unless $message;
693              
694             if (@errors) {
695             return await $res->status(422)
696             ->json({ error => 'Validation failed', errors => \@errors })
697             ->respond($send);
698             }
699              
700             # Process valid form...
701             return await $res->json({ success => 1 })->respond($send);
702             }
703              
704             =head2 Authentication with Cookies
705              
706             async sub handle_login ($req, $send) {
707             my $res = $req->response;
708             my $data = await $req->json;
709              
710             my $user = authenticate($data->{email}, $data->{password});
711              
712             unless ($user) {
713             return await $res->status(401)->json({ error => 'Invalid credentials' })->respond($send);
714             }
715              
716             my $session_id = create_session($user);
717              
718             return await $res->cookie('session' => $session_id,
719             path => '/',
720             httponly => 1,
721             secure => 1,
722             samesite => 'Strict',
723             max_age => 86400, # 24 hours
724             )
725             ->json({ user => { id => $user->{id}, name => $user->{name} } })
726             ->respond($send);
727             }
728              
729             async sub handle_logout ($req, $send) {
730             my $res = $req->response;
731              
732             return await $res->delete_cookie('session', path => '/')
733             ->json({ logged_out => 1 })
734             ->respond($send);
735             }
736              
737             =head2 File Download
738              
739             async sub handle_download ($req, $send) {
740             my $res = $req->response;
741             my $file_id = $req->path_param('id');
742              
743             my $file = get_file($file_id); # Be sure to clean $file
744             unless ($file && -f $file->{path}) {
745             return await $res->status(404)->json({ error => 'File not found' })->respond($send);
746             }
747              
748             return await $res->send_file($file->{path},
749             filename => $file->{original_name},
750             )->respond($send);
751             }
752              
753             =head2 Streaming Large Data
754              
755             async sub handle_export ($req, $send) {
756             my $res = $req->response;
757              
758             await $res->content_type('text/csv')
759             ->header('Content-Disposition' => 'attachment; filename="export.csv"')
760             ->stream(async sub ($writer) {
761             # Write CSV header
762             await $writer->write("id,name,email\n");
763              
764             # Stream rows from database
765             my $cursor = get_all_users_cursor();
766             while (my $user = $cursor->next) {
767             await $writer->write("$user->{id},$user->{name},$user->{email}\n");
768             }
769             })
770             ->respond($send);
771             }
772              
773             =head2 Server-Sent Events Style Streaming
774              
775             async sub handle_events ($req, $send) {
776             my $res = $req->response;
777              
778             await $res->content_type('text/event-stream')
779             ->header('Cache-Control' => 'no-cache')
780             ->stream(async sub ($writer) {
781             for my $i (1..10) {
782             await $writer->write("data: Event $i\n\n");
783             await some_delay(1); # Wait 1 second
784             }
785             })
786             ->respond($send);
787             }
788              
789             =head2 Conditional Responses
790              
791             async sub handle_resource ($req, $send) {
792             my $res = $req->response;
793             my $etag = '"abc123"';
794              
795             # Check If-None-Match for caching
796             my $if_none_match = $req->header('If-None-Match') // '';
797             if ($if_none_match eq $etag) {
798             return await $res->status(304)->empty()->respond($send);
799             }
800              
801             return await $res->header('ETag' => $etag)
802             ->header('Cache-Control' => 'max-age=3600')
803             ->json({ data => 'expensive computation result' })
804             ->respond($send);
805             }
806              
807             =head2 CORS API Endpoint
808              
809             # Simple CORS - allow all origins
810             async sub handle_api ($scope, $receive, $send) {
811             my $res = PAGI::Response->new($scope);
812              
813             return await $res->cors->json({ status => 'ok' })->respond($send);
814             }
815              
816             # CORS with credentials (e.g., cookies, auth headers)
817             async sub handle_api_with_auth ($scope, $receive, $send) {
818             my $req = PAGI::Request->new($scope, $receive);
819             my $res = $req->response;
820              
821             # Get the Origin header from request
822             my $origin = $req->header('Origin');
823              
824             return await $res->cors(
825             origin => 'https://myapp.com', # Or use request_origin
826             credentials => 1,
827             expose => [qw(X-Request-Id)],
828             )->json({ user => 'authenticated' })->respond($send);
829             }
830              
831             =head2 CORS Preflight Handler
832              
833             # Handle OPTIONS preflight requests
834             async sub app ($scope, $receive, $send) {
835             my $req = PAGI::Request->new($scope, $receive);
836             my $res = $req->response;
837              
838             # Handle preflight
839             if ($req->method eq 'OPTIONS') {
840             return await $res->cors(
841             origin => 'https://myapp.com',
842             methods => [qw(GET POST PUT DELETE)],
843             headers => [qw(Content-Type Authorization X-Custom-Header)],
844             credentials => 1,
845             max_age => 86400,
846             preflight => 1, # Include preflight headers
847             )->status(204)->empty()->respond($send);
848             }
849              
850             # Handle actual request
851             return await $res->cors(
852             origin => 'https://myapp.com',
853             credentials => 1,
854             )->json({ data => 'response' })->respond($send);
855             }
856              
857             =head2 Dynamic CORS Origin
858              
859             # Allow multiple origins dynamically
860             my %ALLOWED_ORIGINS = map { $_ => 1 } qw(
861             https://app1.example.com
862             https://app2.example.com
863             https://localhost:3000
864             );
865              
866             async sub handle_api ($scope, $receive, $send) {
867             my $req = PAGI::Request->new($scope, $receive);
868             my $res = $req->response;
869              
870             my $request_origin = $req->header('Origin') // '';
871              
872             # Check if origin is allowed
873             if ($ALLOWED_ORIGINS{$request_origin}) {
874             return await $res->cors(
875             origin => $request_origin, # Echo back the allowed origin
876             credentials => 1,
877             )->json({ data => 'allowed' })->respond($send);
878             }
879              
880             # Origin not allowed - respond without CORS headers
881             return await $res->status(403)->json({ error => 'Origin not allowed' })->respond($send);
882             }
883              
884             =head1 WRITER OBJECT
885              
886             The C method passes a writer object to its callback, and
887             C returns one directly. The writer has the following methods:
888              
889             =head3 write
890              
891             await $writer->write($chunk);
892              
893             Write a chunk of data to the response stream. Returns a L.
894              
895             Writing after close returns a failed L rather than throwing.
896             This allows cleanup code that races with close to handle the error
897             gracefully via C.
898              
899             =head3 close
900              
901             await $writer->close;
902              
903             Close the stream. Returns a L. Calling close multiple times is
904             safe — subsequent calls are no-ops.
905              
906             =head3 bytes_written
907              
908             my $n = $writer->bytes_written;
909              
910             Returns the total number of bytes written so far.
911              
912             =head3 on_close
913              
914             # Sync callback
915             $writer->on_close(sub { cleanup() });
916              
917             # Async callback — return value is awaited automatically
918             $writer->on_close(async sub {
919             await notify_stream_ended();
920             });
921              
922             # Chaining
923             $writer->on_close(sub { ... })
924             ->on_close(sub { ... });
925              
926             Registers a callback to fire when the writer closes (either explicitly
927             or via C auto-close). Callbacks can be regular subs or async
928             subs — async results are automatically awaited. Multiple callbacks run
929             in registration order. Exceptions are caught and warned but do not
930             prevent other callbacks from running. Returns C<$self> for chaining.
931              
932             B If your callback captures the writer
933             object in a closure, use C to avoid a memory leak:
934              
935             use Scalar::Util qw(weaken);
936             my $weak_writer = $writer;
937             weaken($weak_writer);
938             $writer->on_close(sub { $weak_writer->... if $weak_writer });
939              
940             The callback array is cleared after firing, so any cycle via a closure
941             is broken when the writer closes, but C prevents the object
942             from being kept alive until that point.
943              
944             =head3 is_closed
945              
946             if ($writer->is_closed) { ... }
947              
948             Returns true if the writer has been closed.
949              
950             The writer automatically closes when the C callback completes,
951             but calling C explicitly is recommended for clarity.
952              
953             =head1 ERROR AND ALTERNATE RESPONSES
954              
955             A response is a value, so "produce a 404 instead" is just returning a different
956             value -- no exceptions needed:
957              
958             async sub show ($self, $ctx) {
959             my $user = await find_user($ctx->req->path_param('id'));
960             return PAGI::Response->json({ error => 'not found' }, status => 404)
961             unless $user;
962             return $ctx->json($user);
963             }
964              
965             For cases that recur across handlers, prefer modeling the absence as a value
966             (a "null object") whose own method returns the right response, instead of
967             throwing from deep in the stack:
968              
969             my $user = await find_user($ctx) // UnauthenticatedUser->new($ctx);
970             return $user->dashboard; # a real user renders; an UnauthenticatedUser
971             # returns a 401 / login response
972              
973             Here C is a class you define; its C method
974             returns a C just as a real user's would.
975              
976             =head1 SUBCLASSING (FRAMEWORK INTEGRATION)
977              
978             Framework authors can subclass C to add their own response
979             sugar while reusing the value machinery. The contract is small and stable:
980              
981             =over 4
982              
983             =item * B C<< $class->new($scope) >>. The scope is optional and
984             inert (used only for C and helpers like L); a response
985             never holds a connection. A Moose subclass can C and
986             provide C returning C<($scope)>.
987              
988             =item * B C<< respond($send) >> to customize how the response is sent.
989             Call C<< $self->SUPER::respond($send) >> to do the actual emission. The
990             connection (C<$send>) arrives as the argument; do not store or re-bind it -- a
991             response value is connection-free until the moment it is sent.
992              
993             =item * B -- C, C
, C,
994             C, C, C, C, the C predicates
995             (C, C, C, C), and
996             the body methods (C/C/C/C/C/C/
997             C/C, with trailing options). Do B reach into the
998             C<_>-prefixed internals (C<_headers>, C<_body>, C<_status>, C<_stream>, ...);
999             they are private and may change.
1000              
1001             =item * Adding response sugar via a role/mixin works unchanged -- a role that
1002             calls the public chainers and body methods needs no special support.
1003              
1004             =back
1005              
1006             A response value never needs C<$send> until it is sent, so "I don't have a
1007             connection here" just means "I am not sending yet": hold the value and call
1008             C (or return it from an endpoint, where dispatch sends it) when a
1009             connection is available.
1010              
1011             =head1 SEE ALSO
1012              
1013             L, L, L
1014              
1015             =head1 AUTHOR
1016              
1017             PAGI Contributors
1018              
1019             =cut
1020              
1021             sub new {
1022 181     181 1 982832 my ($class, $scope) = @_;
1023 181 100 100     868 croak("scope must be a hashref") if defined $scope && ref($scope) ne 'HASH';
1024 180         691 return bless {
1025             scope => $scope, # optional, inert (accessors / Stash); NOT a connection
1026             _headers => PAGI::Headers->new,
1027             }, $class;
1028             }
1029              
1030             sub status {
1031 197     197 1 1994 my ($self, $code) = @_;
1032 197 100 100     741 return $self->{_status} // 200 if @_ == 1; # lazy default
1033 60 100 100     871 croak("Status must be a number between 100-599")
      100        
1034             unless $code =~ /^\d+$/ && $code >= 100 && $code <= 599;
1035 57         128 $self->{_status} = $code;
1036 57         158 return $self;
1037             }
1038              
1039             sub status_try {
1040 10     10 1 18 my ($self, $code) = @_;
1041 10 100       63 return $self if exists $self->{_status};
1042 7         22 return $self->status($code);
1043             }
1044              
1045             sub header {
1046 66     66 1 1740 my ($self, $name, $value) = @_;
1047 66 50       118 croak("Header name is required") unless defined $name;
1048 66 100       130 return $self->{_headers}->get($name) if @_ == 2; # getter: last value
1049 64         163 $self->{_headers}->add($name, $value); # setter: append
1050 64         176 return $self;
1051             }
1052              
1053 4     4 1 34 sub headers { return $_[0]->{_headers} }
1054              
1055             sub header_all {
1056 1     1 1 3 my ($self, $name) = @_;
1057 1 50       5 croak("Header name is required") unless defined $name;
1058 1         5 return $self->{_headers}->get_all($name);
1059             }
1060              
1061             sub header_try {
1062 2     2 1 6 my ($self, $name, $value) = @_;
1063 2         6 $self->{_headers}->set_default($name, $value);
1064 2         2 return $self;
1065             }
1066              
1067             sub remove_header {
1068 1     1 1 2 my ($self, $name) = @_;
1069 1 50       3 croak("Header name is required") unless defined $name;
1070 1         4 $self->{_headers}->remove($name);
1071 1         5 return $self;
1072             }
1073              
1074             sub content_type {
1075 121     121 1 1736 my ($self, $type) = @_;
1076 121 100       354 return $self->{_headers}->get('content-type') if @_ == 1; # getter
1077 39 100       66 if (defined $type) { $self->{_headers}->set('content-type', $type) }
  38         90  
1078 1         4 else { $self->{_headers}->remove('content-type') } # content_type(undef) clears
1079 39         88 return $self;
1080             }
1081              
1082             sub content_type_try {
1083 90     90 1 965 my ($self, $type) = @_;
1084 90         247 $self->{_headers}->set_default('content-type', $type);
1085 90         126 return $self;
1086             }
1087              
1088             sub has_status {
1089 3     3 1 53 my ($self) = @_;
1090 3 100       23 return exists $self->{_status} ? 1 : 0;
1091             }
1092              
1093 4     4 1 13 sub has_header { return $_[0]->{_headers}->has($_[1]) }
1094 85     85 1 184 sub has_content_type { return $_[0]->{_headers}->has('content-type') }
1095              
1096             sub has_body_source {
1097 12     12 1 35 my ($self) = @_;
1098 12 100 100     62 return (exists $self->{_body} || exists $self->{_stream} || exists $self->{_file}) ? 1 : 0;
1099             }
1100              
1101 8     8 1 1162 sub scope { shift->{scope} }
1102              
1103             sub _set_body {
1104 119     119   3572 my ($self, $bytes, $default_type) = @_;
1105 119         203 $self->{_body} = $bytes;
1106 119 100       402 $self->content_type_try($default_type) if defined $default_type;
1107 119         163 return $self;
1108             }
1109              
1110             # The UTF-8 text body helpers call this so the Content-Type advertises the
1111             # encoding they just applied. A charset is appended only when the type both
1112             # lacks one and actually defines a charset parameter. application/json and the
1113             # structured-suffix +json types define none — JSON is always UTF-8 per RFC 8259
1114             # — so they are left bare; application/xml, text/*, and the +xml types do carry
1115             # charset (RFC 7303), so they get it.
1116             sub _ensure_charset {
1117 79     79   106 my ($self, $charset) = @_;
1118 79   100     275 $charset //= 'utf-8';
1119 79 50       175 return $self unless $self->has_content_type;
1120 79         166 my $ct = $self->content_type;
1121 79 100       343 return $self if $ct =~ /charset=/i;
1122 35         154 my ($type) = $ct =~ m{^\s*([^;]+)};
1123 35   50     73 $type //= '';
1124 35         76 $type =~ s/\s+\z//;
1125 35 100 100     134 return $self if lc($type) eq 'application/json' || $type =~ /\+json\z/i;
1126 6         20 $self->content_type("$ct; charset=$charset");
1127 6         7 return $self;
1128             }
1129              
1130             sub _render_headers {
1131 134     134   179 my ($self, $extra_len) = @_;
1132 134         293 my $pairs = $self->{_headers}->to_pairs;
1133 134 100       237 if (defined $extra_len) {
1134             # Buffered response: Content-Length is authoritative. Drop any user-set
1135             # Content-Length (no duplicates) and any Transfer-Encoding (CL+TE is a
1136             # request-smuggling vector), then append the one true length.
1137             @$pairs = grep {
1138 108         144 (my $k = $_->[0]) =~ tr/A-Z/a-z/; # ASCII fold (field names are ASCII tokens)
  135         264  
1139 135 100       444 $k ne 'content-length' && $k ne 'transfer-encoding'
1140             } @$pairs;
1141 108         231 push @$pairs, ['content-length', $extra_len];
1142             }
1143 134         524 return $pairs;
1144             }
1145              
1146 129     129 1 263 async sub respond {
1147 129         170 my ($self, $send) = @_;
1148 129 50       266 croak("send must be a coderef") unless ref($send) eq 'CODE';
1149              
1150 129 100       244 if ($self->{_stream}) {
1151 15         36 await $send->({
1152             type => 'http.response.start',
1153             status => $self->status,
1154             headers => $self->_render_headers(undef),
1155             });
1156 15         608 my $writer = PAGI::Response::Writer->new($send);
1157 15         33 await $self->{_stream}->($writer);
1158 15 100       1974 await $writer->close() unless $writer->is_closed;
1159 15         121 return;
1160             }
1161              
1162 114 100       199 if ($self->{_file}) {
1163 6         8 my $fd = $self->{_file};
1164             # Headers (incl. content-length) were set at send_file() build time.
1165 6         12 await $send->({
1166             type => 'http.response.start',
1167             status => $self->status,
1168             headers => $self->_render_headers(undef),
1169             });
1170             my $body_event = {
1171             type => 'http.response.body',
1172             file => $fd->{path},
1173 6         286 };
1174 6 100       13 $body_event->{offset} = $fd->{offset} if exists $fd->{offset};
1175 6 100       12 $body_event->{length} = $fd->{length} if exists $fd->{length};
1176 6         10 await $send->($body_event);
1177 6         148 return;
1178             }
1179              
1180 108   50     199 my $body = $self->{_body} // '';
1181 108         218 await $send->({
1182             type => 'http.response.start',
1183             status => $self->status,
1184             headers => $self->_render_headers(length $body),
1185             });
1186 108         4222 await $send->({ type => 'http.response.body', body => $body, more => 0 });
1187 108         2556 return;
1188             }
1189              
1190             sub to_app {
1191 1     1 1 36 my ($self) = @_;
1192 1     1   336 return async sub {
1193 1         3 my ($scope, $receive, $send) = @_;
1194 1         3 await $self->respond($send);
1195 1         6 };
1196             }
1197              
1198              
1199             sub is_sent {
1200 7     7 1 60 my ($self) = @_;
1201 7 50       14 my $conn = $self->{scope} ? $self->{scope}{'pagi.connection'} : undef;
1202 7 50       13 return 0 unless $conn; # no connection object: server-less / not started
1203 7 100       106 Carp::croak("pagi.connection lacks response_started (non-conforming server)")
1204             unless $conn->can('response_started');
1205 6 100       10 return $conn->response_started ? 1 : 0;
1206             }
1207              
1208             # Returns the invocant if it is already an instance; otherwise creates a new
1209             # detached instance from the class name. Allows finisher methods to be called
1210             # as either class-method factories or instance methods.
1211             sub _self_or_new {
1212 145     145   199 my ($proto) = @_;
1213 145 100       315 return ref($proto) ? $proto : $proto->new;
1214             }
1215              
1216             # Encode a text string to UTF-8 bytes, croaking on invalid characters.
1217             # Replicates the encoding used by the old send() method.
1218             sub _enc {
1219 52     52   96 my ($str, $charset) = @_;
1220 52   100     207 $charset //= 'utf-8';
1221 52   50     526 return encode($charset, $str // '', FB_CROAK);
1222             }
1223              
1224             my %_RESPONSE_OPTS = map { $_ => 1 } qw(status content_type headers);
1225              
1226             sub _apply_opts {
1227 103     103   154 my ($self, %opts) = @_;
1228 103         175 for my $k (keys %opts) {
1229             croak "Unknown response option '$k' (known: status, content_type, headers)"
1230 9 100       196 unless $_RESPONSE_OPTS{$k};
1231             }
1232 102 100       212 $self->status($opts{status}) if defined $opts{status};
1233 102 100       180 $self->content_type($opts{content_type}) if defined $opts{content_type};
1234 102 100       191 if (my $h = $opts{headers}) {
1235 2 100       118 croak "headers must be an even-length arrayref [ name => value, ... ]"
1236             if @$h % 2;
1237 1         3 my @pairs = @$h;
1238 1         2 while (@pairs) {
1239 1         3 my ($name, $value) = splice(@pairs, 0, 2);
1240 1         3 $self->header($name, $value);
1241             }
1242             }
1243 101         140 return $self;
1244             }
1245              
1246             sub send_raw {
1247 17     17 1 774 my ($proto, $body, %opts) = @_;
1248 17         38 my $self = $proto->_self_or_new;
1249 17   50     61 $self->_set_body($body // '', undef);
1250 17         56 $self->_apply_opts(%opts);
1251 17         48 return $self;
1252             }
1253              
1254             sub send {
1255 3     3 1 12 my ($proto, $body, %opts) = @_;
1256 3         9 my $self = $proto->_self_or_new;
1257 3   50     14 my $charset = $opts{charset} // 'utf-8';
1258 3         9 my $encoded = _enc($body, $charset);
1259 3 100       292 $self->content_type("text/plain; charset=$charset") unless $self->has_content_type;
1260 3         13 $self->_ensure_charset($charset);
1261 3         6 $self->{_body} = $encoded;
1262 3         9 return $self;
1263             }
1264              
1265             sub text {
1266 42     42 1 3790 my ($proto, $body, %opts) = @_;
1267 42         98 my $self = $proto->_self_or_new;
1268 42         83 $self->_set_body(_enc($body), 'text/plain; charset=utf-8');
1269 42         108 $self->_apply_opts(%opts);
1270 41         87 $self->_ensure_charset;
1271 41         279 return $self;
1272             }
1273              
1274             sub html {
1275 7     7 1 354 my ($proto, $body, %opts) = @_;
1276 7         16 my $self = $proto->_self_or_new;
1277 7         16 $self->_set_body(_enc($body), 'text/html; charset=utf-8');
1278 7         20 $self->_apply_opts(%opts);
1279 7         15 $self->_ensure_charset;
1280 7         34 return $self;
1281             }
1282              
1283             sub json {
1284 29     29 1 3820 my ($proto, $data, %opts) = @_;
1285 29         79 my $self = $proto->_self_or_new;
1286 29         164 my $body = JSON::MaybeXS->new(utf8 => 1, canonical => 1)->encode($data);
1287 29         769 $self->_set_body($body, 'application/json');
1288 29         74 $self->_apply_opts(%opts);
1289 28         66 $self->_ensure_charset;
1290 28         166 return $self;
1291             }
1292              
1293             sub redirect {
1294 13     13 1 418 my ($proto, $url, $status) = @_;
1295 13         24 my $self = $proto->_self_or_new;
1296 13   100     50 $self->status($status // 302)->header('location', $url);
1297 13         29 $self->_set_body('', undef);
1298 13         85 return $self;
1299             }
1300              
1301             sub empty {
1302 8     8 1 652 my ($proto, %opts) = @_;
1303 8         19 my $self = $proto->_self_or_new;
1304 8         23 $self->status_try(204);
1305 8         20 $self->_set_body('', undef);
1306 8         20 $self->_apply_opts(%opts);
1307 8         28 return $self;
1308             }
1309              
1310             sub cookie {
1311 5     5 1 25 my ($self, $name, $value, %opts) = @_;
1312 5         10 my @parts = ("$name=$value");
1313              
1314 5 100       16 push @parts, "Max-Age=$opts{max_age}" if defined $opts{max_age};
1315 5 50       8 push @parts, "Expires=$opts{expires}" if defined $opts{expires};
1316 5 100       10 push @parts, "Path=$opts{path}" if defined $opts{path};
1317 5 100       23 push @parts, "Domain=$opts{domain}" if defined $opts{domain};
1318 5 100       8 push @parts, "Secure" if $opts{secure};
1319 5 100       9 push @parts, "HttpOnly" if $opts{httponly};
1320 5 100       10 push @parts, "SameSite=$opts{samesite}" if defined $opts{samesite};
1321              
1322 5         11 my $cookie_str = join('; ', @parts);
1323 5         15 $self->{_headers}->add('set-cookie', $cookie_str);
1324              
1325 5         15 return $self;
1326             }
1327              
1328             sub delete_cookie {
1329 1     1 1 6 my ($self, $name, %opts) = @_;
1330             return $self->cookie($name, '',
1331             max_age => 0,
1332             path => $opts{path},
1333             domain => $opts{domain},
1334 1         7 );
1335             }
1336              
1337             sub cors {
1338 6     6 1 39 my ($self, %opts) = @_;
1339 6   100     16 my $origin = $opts{origin} // '*';
1340 6   100     15 my $credentials = $opts{credentials} // 0;
1341 6   100     18 my $methods = $opts{methods} // [qw(GET POST PUT DELETE PATCH OPTIONS)];
1342 6   100     43 my $headers = $opts{headers} // [qw(Content-Type Authorization X-Requested-With)];
1343 6   100     16 my $expose = $opts{expose} // [];
1344 6   100     13 my $max_age = $opts{max_age} // 86400;
1345 6   100     11 my $preflight = $opts{preflight} // 0;
1346              
1347             # Determine the origin to send back
1348 6         7 my $allow_origin;
1349 6 100 100     18 if ($origin eq '*' && $credentials) {
1350             # With credentials, can't use wildcard - use request_origin if provided
1351 1   50     3 $allow_origin = $opts{request_origin} // '*';
1352             } else {
1353 5         7 $allow_origin = $origin;
1354             }
1355              
1356             # Core CORS headers (always set)
1357 6         18 $self->header('Access-Control-Allow-Origin', $allow_origin);
1358 6         12 $self->header('Vary', 'Origin');
1359              
1360 6 100       10 if ($credentials) {
1361 2         5 $self->header('Access-Control-Allow-Credentials', 'true');
1362             }
1363              
1364 6 100       8 if (@$expose) {
1365 1         5 $self->header('Access-Control-Expose-Headers', join(', ', @$expose));
1366             }
1367              
1368             # Preflight headers (for OPTIONS responses or when explicitly requested)
1369 6 100       11 if ($preflight) {
1370 1         4 $self->header('Access-Control-Allow-Methods', join(', ', @$methods));
1371 1         4 $self->header('Access-Control-Allow-Headers', join(', ', @$headers));
1372 1         2 $self->header('Access-Control-Max-Age', $max_age);
1373             }
1374              
1375 6         24 return $self;
1376             }
1377              
1378             sub stream {
1379 15     15 1 146 my ($proto, $callback) = @_;
1380 15         32 my $self = $proto->_self_or_new;
1381 15         29 $self->{_stream} = $callback;
1382 15         50 return $self;
1383             }
1384              
1385 6     6 1 77 async sub writer {
1386 6         11 my ($self, $send, %opts) = @_;
1387 6 50       14 croak("send must be a coderef") unless ref($send) eq 'CODE';
1388             # A writer takes over the connection for live streaming; it can only be
1389             # taken once on a given response value. (The cross-stack "did a response
1390             # start" fact lives on pagi.connection; this is a local single-takeover guard.)
1391 6 100       216 croak("Response already sent") if $self->{_writer_started};
1392 5         10 $self->{_writer_started} = 1;
1393              
1394             # Send headers
1395 5         12 await $send->({
1396             type => 'http.response.start',
1397             status => $self->status,
1398             headers => $self->_render_headers(undef),
1399             });
1400              
1401 5         204 return PAGI::Response::Writer->new($send, %opts);
1402             }
1403              
1404             # Simple MIME type mapping
1405             my %MIME_TYPES = (
1406             '.html' => 'text/html',
1407             '.htm' => 'text/html',
1408             '.txt' => 'text/plain',
1409             '.css' => 'text/css',
1410             '.js' => 'application/javascript',
1411             '.json' => 'application/json',
1412             '.xml' => 'application/xml',
1413             '.pdf' => 'application/pdf',
1414             '.zip' => 'application/zip',
1415             '.png' => 'image/png',
1416             '.jpg' => 'image/jpeg',
1417             '.jpeg' => 'image/jpeg',
1418             '.gif' => 'image/gif',
1419             '.svg' => 'image/svg+xml',
1420             '.ico' => 'image/x-icon',
1421             '.woff' => 'font/woff',
1422             '.woff2'=> 'font/woff2',
1423             );
1424              
1425             sub _mime_type {
1426 7     7   11 my ($path) = @_;
1427 7         22 my ($ext) = $path =~ /(\.[^.]+)$/;
1428 7   100     51 return $MIME_TYPES{lc($ext // '')} // 'application/octet-stream';
      100        
1429             }
1430              
1431             sub send_file {
1432 11     11 1 140 my ($proto, $path, %opts) = @_;
1433 11         25 my $self = $proto->_self_or_new;
1434              
1435 11 100       356 croak("File not found: $path") unless -f $path;
1436 10 50       74 croak("Cannot read file: $path") unless -r $path;
1437              
1438             # Get file size
1439 10         51 my $file_size = -s $path;
1440              
1441             # Handle offset and length for range requests
1442 10   100     37 my $offset = $opts{offset} // 0;
1443 10         14 my $length = $opts{length};
1444              
1445             # Validate offset
1446 10 100       167 croak("offset must be non-negative") if $offset < 0;
1447 9 100       116 croak("offset exceeds file size") if $offset > $file_size;
1448              
1449             # Calculate actual length to send
1450 8         12 my $max_length = $file_size - $offset;
1451 8 100       14 if (defined $length) {
1452 3 100       107 croak("length must be non-negative") if $length < 0;
1453 2 100       5 $length = $max_length if $length > $max_length;
1454             } else {
1455 5         7 $length = $max_length;
1456             }
1457              
1458             # Set content-type if not already set
1459 7         14 $self->content_type_try(_mime_type($path));
1460              
1461             # Set content-length based on actual bytes to send
1462 7         23 $self->header('content-length', $length);
1463              
1464             # Set content-disposition
1465 7         8 my $disposition;
1466 7 100       23 if ($opts{inline}) {
    100          
1467 1         1 $disposition = 'inline';
1468             } elsif ($opts{filename}) {
1469             # Sanitize filename for header
1470 1         2 my $safe_filename = $opts{filename};
1471 1         4 $safe_filename =~ s/["\r\n]//g;
1472 1         3 $disposition = "attachment; filename=\"$safe_filename\"";
1473             }
1474 7 100       16 $self->header('content-disposition', $disposition) if $disposition;
1475              
1476             # Store the file send descriptor; respond() handles the actual emission.
1477             # offset/length are stored only when they narrow the full-file default.
1478 7         25 my $file_desc = { path => $path };
1479 7 100       17 $file_desc->{offset} = $offset if $offset > 0;
1480 7 100       12 $file_desc->{length} = $length if $length < $max_length;
1481 7         12 $self->{_file} = $file_desc;
1482              
1483 7         27 return $self;
1484             }
1485              
1486             # Writer class for streaming responses
1487             package PAGI::Response::Writer {
1488 19     19   182 use strict;
  19         31  
  19         520  
1489 19     19   92 use warnings;
  19         45  
  19         967  
1490 19     19   87 use Future::AsyncAwait;
  19         31  
  19         180  
1491 19     19   1044 use Carp qw(croak);
  19         49  
  19         1244  
1492 19     19   150 use Scalar::Util qw(blessed);
  19         25  
  19         12854  
1493              
1494             sub new {
1495 20     20   34 my ($class, $send, %opts) = @_;
1496 20         66 my $self = bless {
1497             send => $send,
1498             bytes_written => 0,
1499             closed => 0,
1500             _on_close => [],
1501             }, $class;
1502 20 100       45 push @{$self->{_on_close}}, $opts{on_close} if $opts{on_close};
  1         2  
1503 20         42 return $self;
1504             }
1505              
1506 17     17   1632 async sub write {
1507 17         27 my ($self, $chunk) = @_;
1508 17 100       64 die 'Writer already closed' if $self->{closed};
1509 15   50     32 $self->{bytes_written} += length($chunk // '');
1510 15         55 await $self->{send}->({
1511             type => 'http.response.body',
1512             body => $chunk,
1513             more => 1,
1514             });
1515             }
1516              
1517 17     17   448 async sub close {
1518 17         22 my ($self) = @_;
1519 17 50       29 return if $self->{closed};
1520 17         45 $self->{closed} = 1;
1521 17         71 await $self->{send}->({
1522             type => 'http.response.body',
1523             body => '',
1524             more => 0,
1525             });
1526 17         373 for my $cb (@{$self->{_on_close}}) {
  17         35  
1527 15         23 eval {
1528 15         22 my $r = $cb->();
1529 14 100 100     128 if (blessed($r) && $r->isa('Future')) {
1530 2         6 await $r;
1531             }
1532             };
1533 15 100       57 if ($@) {
1534 2         21 warn "PAGI::Response::Writer on_close callback error: $@";
1535             }
1536             }
1537              
1538             # Clear callback array to break any closure-based cycles
1539 17         74 $self->{_on_close} = [];
1540             }
1541              
1542             sub on_close {
1543 14     14   123 my ($self, $cb) = @_;
1544 14         14 push @{$self->{_on_close}}, $cb;
  14         22  
1545 14         21 return $self;
1546             }
1547              
1548 18     18   128 sub is_closed { $_[0]->{closed} }
1549              
1550 1     1   42 sub bytes_written { $_[0]->{bytes_written} }
1551             }
1552             $PAGI::Response::Writer::VERSION = '0.002000';
1553              
1554             1;