=pod

=encoding utf-8

=head1 NAME

PAGI::Tools::Cookbook - Recipes for Common PAGI Tasks

=head1 DESCRIPTION

This cookbook provides ready-to-use solutions for common web development tasks
with PAGI. Each recipe is self-contained and can be adapted to your needs.

B<Prerequisites:> This cookbook assumes you've read L<PAGI::Tutorial> (the
PAGI protocol tutorial) and L<PAGI::Tools::Tutorial> (this distribution's
guide to middleware, helper classes, and built-in apps).

=head1 ROUTING RECIPES

These recipes cover advanced routing patterns using L<PAGI::App::Router> and
L<PAGI::Endpoint::Router>. For basic routing, see the Tutorial's Built-in
Applications section.

=head2 Path Parameters and Wildcards

Use C<:param> syntax for named parameters in routes:

  use strict;
  use warnings;
  use Future::AsyncAwait;
  use PAGI::App::Router;
  use PAGI::Request;
  use PAGI::Response;

  my $router = PAGI::App::Router->new;

  # Single parameter
  $router->get('/users/:id' => async sub {
      my ($scope, $receive, $send) = @_;
      my $req = PAGI::Request->new($scope, $receive);
      my $res = $req->response;

      # Access path parameter via $req->path_param()
      my $id = $req->path_param('id');

      await $res->json({ id => $id, name => 'User ' . $id })->respond($send);
  });

  # Multiple parameters
  $router->get('/posts/:post_id/comments/:comment_id' => async sub {
      my ($scope, $receive, $send) = @_;
      my $req = PAGI::Request->new($scope, $receive);
      my $res = $req->response;

      my $post_id = $req->path_param('post_id');
      my $comment_id = $req->path_param('comment_id');

      await $res->json({
          post_id => $post_id,
          comment_id => $comment_id,
      })->respond($send);
  });

  $router->to_app;

Parameters are stored in C<$scope-E<gt>{path_params}> and accessed via C<$req-E<gt>path_param($name)>.

=head3 Wildcard Routes

Use C<*name> syntax to capture multiple path segments:

  my $router = PAGI::App::Router->new;

  # Wildcard captures everything after /files/
  $router->get('/files/*path' => async sub {
      my ($scope, $receive, $send) = @_;
      my $req = PAGI::Request->new($scope, $receive);
      my $res = $req->response;

      # Access wildcard via $req->path_param()
      my $path = $req->path_param('path');

      # /files/docs/readme.txt -> path = 'docs/readme.txt'
      # /files/images/logo.png -> path = 'images/logo.png'

      await $res->text("File path: $path")->respond($send);
  });

  $router->to_app;

Wildcards match one or more path segments, while named parameters (C<:param>) match a single segment.

=head2 WebSocket and SSE Routes

Route WebSocket connections by path:

  use strict;
  use warnings;
  use Future::AsyncAwait;
  use PAGI::App::Router;
  use PAGI::WebSocket;

  my $router = PAGI::App::Router->new;

  # WebSocket echo server
  $router->websocket('/ws' => async sub {
      my ($scope, $receive, $send) = @_;
      my $ws = PAGI::WebSocket->new($scope, $receive, $send);

      await $ws->accept;

      await $ws->each_text(async sub {
          my ($text) = @_;
          await $ws->send_text("Echo: $text");
      });
  });

  # WebSocket with path parameters
  $router->websocket('/ws/chat/:room' => async sub {
      my ($scope, $receive, $send) = @_;
      my $ws = PAGI::WebSocket->new($scope, $receive, $send);

      # Access room parameter
      my $room = $scope->{path_params}{room};

      await $ws->accept;
      await $ws->send_text("Joined room: $room");

      # ... chat logic ...
  });

  $router->to_app;

=head3 SSE Routes

Route Server-Sent Events by path:

  use strict;
  use warnings;
  use Future::AsyncAwait;
  use PAGI::App::Router;
  use PAGI::SSE;

  my $router = PAGI::App::Router->new;

  # SSE stream
  $router->sse('/events' => async sub {
      my ($scope, $receive, $send) = @_;
      my $sse = PAGI::SSE->new($scope, $receive, $send);

      await $sse->keepalive(30);  # Prevent proxy timeouts
      await $sse->send_event(data => "Connected at: " . time);
      await $sse->run;  # Wait for disconnect
  });

  # SSE with path parameters
  $router->sse('/events/:channel' => async sub {
      my ($scope, $receive, $send) = @_;
      my $sse = PAGI::SSE->new($scope, $receive, $send);

      # Access channel parameter
      my $channel = $scope->{path_params}{channel};

      await $sse->start;
      await $sse->send_event(data => "Subscribed to: $channel");

      # ... streaming logic ...
  });

  $router->to_app;

=head2 Route-Level Middleware

Apply middleware to specific routes by passing an arrayref:

  use strict;
  use warnings;
  use Future::AsyncAwait;
  use PAGI::App::Router;
  use PAGI::Request;
  use PAGI::Response;

  my $router = PAGI::App::Router->new;

  # Define middleware
  my $auth_mw = sub {
      my ($app) = @_;
      async sub {
          my ($scope, $receive, $send) = @_;

          # Check authorization
          my $token = '';
          for my $h (@{$scope->{headers}}) {
              if (lc($h->[0]) eq 'authorization') {
                  $token = $h->[1];
                  last;
              }
          }

          unless ($token eq 'Bearer secret123') {
              my $res = PAGI::Response->new($scope);
              await $res->status(401)->json({ error => 'Unauthorized' })->respond($send);
              return;  # Don't call $app->()
          }

          # Authorized - continue to handler
          await $app->($scope, $receive, $send);
      };
  };

  # Apply middleware to specific routes
  $router->get('/admin' => [$auth_mw] => async sub {
      my ($scope, $receive, $send) = @_;
      my $res = PAGI::Response->new($scope);
      await $res->text('Admin panel')->respond($send);
  });

  # Multiple middleware
  my $log_mw = sub {
      my ($app) = @_;
      async sub {
          my ($scope, $receive, $send) = @_;
          warn "Request: $scope->{method} $scope->{path}\n";
          await $app->($scope, $receive, $send);
      };
  };

  $router->post('/admin/users' => [$log_mw, $auth_mw] => async sub {
      my ($scope, $receive, $send) = @_;
      # ... handler ...
  });

  $router->to_app;

Middleware are executed in order (C<$log_mw> runs before C<$auth_mw> in the example above).

=head2 Nested Routers and Mounting

Use C<mount()> to attach sub-applications or routers at a prefix:

  use strict;
  use warnings;
  use Future::AsyncAwait;
  use PAGI::App::Router;
  use PAGI::Request;
  use PAGI::Response;

  # Create API router
  my $api_router = PAGI::App::Router->new;
  $api_router->get('/users' => async sub {
      my ($scope, $receive, $send) = @_;
      my $res = PAGI::Response->new($scope);
      await $res->json([{ id => 1, name => 'Alice' }])->respond($send);
  });

  # Create main router
  my $main_router = PAGI::App::Router->new;

  # Mount API router at /api prefix
  # /api/users will be handled by $api_router
  $main_router->mount('/api' => $api_router);

  # Mount with middleware (applies to all sub-routes)
  my $cors_mw = sub {
      my ($app) = @_;
      async sub {
          my ($scope, $receive, $send) = @_;
          # Add CORS headers...
          await $app->($scope, $receive, $send);
      };
  };

  $main_router->mount('/api' => [$cors_mw] => $api_router);

  $main_router->to_app;

=head3 Mounting Components and Class Names

Mount targets (and route handlers) accept more than coderefs: anything
L<PAGI::Utils/to_app> can coerce composes directly, compiled once at
mount time.

  # A component object
  $main_router->mount('/static' => PAGI::App::File->new(root => './public'));

  # A class name - auto-required, then to_app is called on the class
  $main_router->mount('/admin' => 'MyApp::Admin');

  # A router object
  $main_router->mount('/api' => $api_router);

=head2 Class-Based Routing with PAGI::Endpoint::Router

L<PAGI::Endpoint::Router> provides an object-oriented approach to routing. You define a class that extends C<PAGI::Endpoint::Router>, implement a C<routes()> method, and use method names as handlers.

=head3 Basic Router Class

  package MyApp;
  use parent 'PAGI::Endpoint::Router';
  use strict;
  use warnings;
  use Future::AsyncAwait;

  # Define routes
  sub routes {
      my ($self, $r) = @_;

      $r->get('/' => 'home');
      $r->get('/about' => 'about');
      $r->post('/contact' => 'contact');
  }

  # Handler methods receive ($self, $ctx) for HTTP
  async sub home {
      my ($self, $ctx) = @_;
      return $ctx->html('<h1>Welcome!</h1>');
  }

  async sub about {
      my ($self, $ctx) = @_;
      return $ctx->html('<h1>About Us</h1>');
  }

  async sub contact {
      my ($self, $ctx) = @_;
      my $data = await $ctx->req->json;
      # ... process contact form ...
      return $ctx->json({ success => 1 });
  }

  1;

  # In app.pl:
  # use MyApp;
  # MyApp->to_app;

Handler methods are called with:

=over 4

=item * C<$self> - Router instance (access C<$self-E<gt>state> for per-instance data)

=item * C<$ctx> - L<PAGI::Context> - read the request via C<< $ctx->req >>, build
the response with C<< $ctx->json >> / C<< $ctx->html >> / C<< $ctx->text >> and
return it

=back

=head3 WebSocket Handlers

WebSocket handlers receive C<($self, $ctx)>; call C<< $ctx->websocket >> for the channel:

  package MyApp;
  use parent 'PAGI::Endpoint::Router';
  use strict;
  use warnings;
  use Future::AsyncAwait;

  sub routes {
      my ($self, $r) = @_;

      $r->get('/' => 'home');
      $r->websocket('/ws' => 'handle_ws');
      $r->websocket('/ws/chat/:room' => 'handle_chat');
  }

  async sub home {
      my ($self, $ctx) = @_;
      return $ctx->html('<h1>Home</h1>');
  }

  # WebSocket handler receives ($self, $ctx)
  async sub handle_ws {
      my ($self, $ctx) = @_;
      my $ws = $ctx->websocket;

      await $ws->accept;

      await $ws->each_text(async sub {
          my ($text) = @_;
          await $ws->send_text("Echo: $text");
      });
  }

  # WebSocket with path parameters
  async sub handle_chat {
      my ($self, $ctx) = @_;
      my $ws = $ctx->websocket;

      # Access path parameter via $ws->path_param()
      my $room = $ws->path_param('room');

      await $ws->accept;
      await $ws->send_text("Joined room: $room");

      # ... chat logic ...
  }

  1;

=head3 SSE Handlers

SSE handlers receive C<($self, $ctx)>; call C<< $ctx->sse >> for the channel:

  package MyApp;
  use parent 'PAGI::Endpoint::Router';
  use strict;
  use warnings;
  use Future::AsyncAwait;

  sub routes {
      my ($self, $r) = @_;

      $r->get('/' => 'home');
      $r->sse('/events' => 'handle_events');
      $r->sse('/events/:channel' => 'handle_channel');
  }

  async sub home {
      my ($self, $ctx) = @_;
      return $ctx->html('<h1>Home</h1>');
  }

  # SSE handler receives ($self, $ctx)
  async sub handle_events {
      my ($self, $ctx) = @_;
      my $sse = $ctx->sse;

      await $sse->keepalive(30);
      await $sse->send_event(data => "Connected at: " . time);
      await $sse->run;
  }

  # SSE with path parameters
  async sub handle_channel {
      my ($self, $ctx) = @_;
      my $sse = $ctx->sse;

      # Access path parameter
      my $channel = $sse->path_param('channel');

      await $sse->start;
      await $sse->send_event(data => "Channel: $channel");

      # ... streaming logic ...
  }

  1;

=head3 Initializing Resources

C<PAGI::Endpoint::Router> calls your C<routes> method once, when the app is
built, on the instance that lives for the app's lifetime. That makes C<routes>
the natural place to initialize per-worker resources and seed state: whatever
you store in C<< $self->state >> is visible to every handler.

  package MyApp;
  use parent 'PAGI::Endpoint::Router';
  use strict;
  use warnings;
  use Future::AsyncAwait;

  sub routes {
      my ($self, $r) = @_;

      # Initialize resources once, at build time
      $self->state->{db} = DBI->connect('dbi:SQLite:dbname=app.db');
      $self->state->{started_at} = time();

      $r->get('/' => 'home');
  }

  async sub home {
      my ($self, $ctx) = @_;

      # Access state initialized in routes()
      my $db = $self->state->{db};
      my $uptime = time() - $self->state->{started_at};

      return $ctx->json({
          uptime   => $uptime,
          database => defined($db) ? 'connected' : 'disconnected',
      });
  }

  1;

Note: C<$self-E<gt>state> is per-worker in multi-worker mode. For shared state, use an external store (Redis, database, etc.).

For B<asynchronous> startup and shutdown hooks -- running code on the
C<lifespan.startup> and C<lifespan.shutdown> protocol events -- wrap the app
with L<PAGI::Lifespan>, which runs C<startup>/C<shutdown> callbacks around the
application:

  use PAGI::Lifespan;

  my $app = PAGI::Lifespan->new(
      startup  => async sub ($state) {
          $state->{db} = DBI->connect('dbi:SQLite:dbname=app.db');
      },
      shutdown => async sub ($state) {
          $state->{db}->disconnect if $state->{db};
      },
      app      => MyApp->to_app,
  )->to_app;

=head3 Route-Level Middleware with Method Names

Define middleware methods and reference them by name:

  package MyApp;
  use parent 'PAGI::Endpoint::Router';
  use strict;
  use warnings;
  use Future::AsyncAwait;

  sub routes {
      my ($self, $r) = @_;

      $r->get('/' => 'home');

      # Apply middleware by method name
      $r->get('/admin' => ['require_auth'] => 'admin_page');

      # Multiple middleware
      $r->post('/admin/users' => ['log_request', 'require_auth'] => 'create_user');
  }

  # Middleware method signature: ($self, $ctx, $next)
  async sub require_auth {
      my ($self, $ctx, $next) = @_;

      my $token = $ctx->req->header('authorization');

      unless ($token && $token eq 'Bearer secret123') {
          return $ctx->json({ error => 'Unauthorized' }, status => 401);
      }

      # Authorized - continue to handler
      return await $next->();
  }

  async sub log_request {
      my ($self, $ctx, $next) = @_;

      warn "Request: " . $ctx->req->method . " " . $ctx->req->path . "\n";

      return await $next->();
  }

  async sub home {
      my ($self, $ctx) = @_;
      return $ctx->html('<h1>Home</h1>');
  }

  async sub admin_page {
      my ($self, $ctx) = @_;
      return $ctx->html('<h1>Admin Panel</h1>');
  }

  async sub create_user {
      my ($self, $ctx) = @_;
      my $data = await $ctx->req->json;
      # ... create user ...
      return $ctx->json({ success => 1 }, status => 201);
  }

  1;

Middleware methods receive C<($self, $ctx, $next)>. C<< await $next->() >> runs
the rest of the chain and returns the handler's response; return it (optionally
after decorating), or return your own response to short-circuit.

=head3 Mounting Sub-Routers

Create modular applications by mounting sub-routers:

  # lib/MyApp/API.pm
  package MyApp::API;
  use parent 'PAGI::Endpoint::Router';
  use strict;
  use warnings;
  use Future::AsyncAwait;

  sub routes {
      my ($self, $r) = @_;

      $r->get('/users' => 'list_users');
      $r->get('/users/:id' => 'get_user');
      $r->post('/users' => 'create_user');
  }

  async sub list_users {
      my ($self, $ctx) = @_;
      return $ctx->json([
          { id => 1, name => 'Alice' },
          { id => 2, name => 'Bob' },
      ]);
  }

  async sub get_user {
      my ($self, $ctx) = @_;
      my $id = $ctx->req->path_param('id');
      return $ctx->json({ id => $id, name => "User $id" });
  }

  async sub create_user {
      my ($self, $ctx) = @_;
      my $data = await $ctx->req->json;
      return $ctx->json({ id => 3, name => $data->{name} }, status => 201);
  }

  1;

  # lib/MyApp.pm
  package MyApp;
  use parent 'PAGI::Endpoint::Router';
  use strict;
  use warnings;
  use Future::AsyncAwait;
  use MyApp::API;

  sub routes {
      my ($self, $r) = @_;

      $r->get('/' => 'home');

      # Mount API router at /api prefix
      $r->mount('/api' => 'MyApp::API');
  }

  async sub home {
      my ($self, $ctx) = @_;
      return $ctx->html('<h1>Home</h1>');
  }

  1;

  # app.pl
  # use MyApp;
  # MyApp->to_app;

Now C</api/users> and C</api/users/:id> are handled by C<MyApp::API>.

=head3 Complete Example

Here's a complete working example demonstrating all features:

  package MyApp;
  use parent 'PAGI::Endpoint::Router';
  use strict;
  use warnings;
  use Future::AsyncAwait;

  sub routes {
      my ($self, $r) = @_;

      # Seed per-worker state at build time
      $self->state->{started} = time();
      $self->state->{users} = [
          { id => 1, name => 'Alice' },
          { id => 2, name => 'Bob' },
      ];

      # HTTP routes
      $r->get('/' => 'home');
      $r->get('/users' => 'list_users');
      $r->get('/users/:id' => 'get_user');
      $r->post('/users' => 'create_user');

      # Protected route
      $r->get('/admin' => ['require_auth'] => 'admin');

      # WebSocket
      $r->websocket('/ws' => 'ws_echo');

      # SSE
      $r->sse('/events' => 'events');
  }

  async sub require_auth {
      my ($self, $ctx, $next) = @_;
      my $token = $ctx->req->header('authorization');
      return $ctx->json({ error => 'Unauthorized' }, status => 401)
          unless $token && $token eq 'Bearer secret';
      return await $next->();
  }

  async sub home {
      my ($self, $ctx) = @_;
      return $ctx->html('<h1>Welcome to MyApp</h1>');
  }

  async sub list_users {
      my ($self, $ctx) = @_;
      return $ctx->json($self->state->{users});
  }

  async sub get_user {
      my ($self, $ctx) = @_;
      my $id = $ctx->req->path_param('id');
      my ($user) = grep { $_->{id} == $id } @{$self->state->{users}};
      return $ctx->json({ error => 'Not found' }, status => 404)
          unless $user;
      return $ctx->json($user);
  }

  async sub create_user {
      my ($self, $ctx) = @_;
      my $data = await $ctx->req->json;
      my $user = { id => scalar(@{$self->state->{users}}) + 1, name => $data->{name} };
      push @{$self->state->{users}}, $user;
      return $ctx->json($user, status => 201);
  }

  async sub admin {
      my ($self, $ctx) = @_;
      return $ctx->json({ message => 'Admin access granted' });
  }

  async sub ws_echo {
      my ($self, $ctx) = @_;
      my $ws = $ctx->websocket;
      await $ws->accept;
      await $ws->each_text(async sub {
          my ($text) = @_;
          await $ws->send_text("Echo: $text");
      });
  }

  async sub events {
      my ($self, $ctx) = @_;
      my $sse = $ctx->sse;
      await $sse->keepalive(30);
      await $sse->send_json({ connected => time });
      await $sse->run;
  }

  1;

Run it:

  # app.pl
  use MyApp;
  MyApp->to_app;

  # Terminal:
  pagi-server app.pl --port 5000

Test it:

  # HTTP
  curl http://localhost:5000/
  curl http://localhost:5000/users
  curl http://localhost:5000/users/1
  curl -X POST http://localhost:5000/users -H 'Content-Type: application/json' -d '{"name":"Charlie"}'

  # Protected route
  curl http://localhost:5000/admin
  curl -H 'Authorization: Bearer secret' http://localhost:5000/admin

  # WebSocket (JavaScript)
  const ws = new WebSocket('ws://localhost:5000/ws');
  ws.onmessage = (e) => console.log(e.data);
  ws.send('Hello!');

  # SSE (JavaScript)
  const sse = new EventSource('http://localhost:5000/events');
  sse.onmessage = (e) => console.log(JSON.parse(e.data));

See C<examples/endpoint-router-demo/> for more examples.

=head1 AUTHENTICATION & SESSIONS

=head2 Session Management

Use L<PAGI::Middleware::Session> for session management. The middleware
populates C<< $scope->{'pagi.session'} >>; the L<PAGI::Session> helper wraps
that hashref with a convenient (and typo-catching) API.

  use strict;
  use warnings;
  use Future::AsyncAwait;
  use PAGI::App::Router;
  use PAGI::Middleware::Builder;
  use PAGI::Session;
  use PAGI::Request;
  use PAGI::Response;

  my $router = PAGI::App::Router->new;

  $router->get('/login' => async sub {
      my ($scope, $receive, $send) = @_;
      my $req = PAGI::Request->new($scope, $receive);
      my $res = $req->response;

      # Set session data
      my $session = PAGI::Session->new($scope);
      $session->set(user_id  => 123);
      $session->set(username => 'alice');

      await $res->text('Logged in!')->respond($send);
  });

  $router->get('/profile' => async sub {
      my ($scope, $receive, $send) = @_;
      my $req = PAGI::Request->new($scope, $receive);
      my $res = $req->response;

      # Read session data (the second arg is a default for optional keys)
      my $session = PAGI::Session->new($scope);
      my $username = $session->get('username', 'Guest');

      await $res->text("Hello, $username!")->respond($send);
  });

  $router->get('/logout' => async sub {
      my ($scope, $receive, $send) = @_;
      my $req = PAGI::Request->new($scope, $receive);
      my $res = $req->response;

      # Clear session data
      PAGI::Session->new($scope)->clear;

      await $res->text('Logged out!')->respond($send);
  });

  # Wrap the router with the session middleware
  my $app = builder {
      enable 'Session',
          secret      => 'your-secret-key-here',
          cookie_name => 'session_id',
          expire      => 86400;  # 1 day
      $router;
  };

=head2 Basic Authentication

  use strict;
  use warnings;
  use Future::AsyncAwait;
  use PAGI::App::Router;
  use PAGI::Middleware::Builder;
  use PAGI::Response;

  my $router = PAGI::App::Router->new;

  $router->get('/' => async sub {
      my ($scope, $receive, $send) = @_;
      my $res = PAGI::Response->new($scope);
      await $res->text('Protected content')->respond($send);
  });

  # Protect every route with Basic Auth
  my $app = builder {
      enable 'Auth::Basic',
          realm         => 'Admin Area',
          authenticator => sub {
              my ($username, $password) = @_;
              return $username eq 'admin' && $password eq 'secret';
          };
      $router;
  };

=head2 Bearer Token (JWT)

  use strict;
  use warnings;
  use Future::AsyncAwait;
  use PAGI::App::Router;
  use PAGI::Middleware::Builder;
  use PAGI::Request;
  use PAGI::Response;

  my $router = PAGI::App::Router->new;

  $router->get('/api/me' => async sub {
      my ($scope, $receive, $send) = @_;
      my $req = PAGI::Request->new($scope, $receive);
      my $res = $req->response;

      # Claims from the validated token: the middleware stores
      # { type => 'bearer', token => ..., claims => ... } under 'pagi.auth'
      my $claims = $scope->{'pagi.auth'}{claims};

      await $res->json({ user => $claims })->respond($send);
  });

  # Verify Bearer tokens with a custom validator (returns claims or undef).
  # When the validator returns undef the middleware sends 401 itself, so the
  # handler above only runs for valid tokens.
  my $app = builder {
      enable 'Auth::Bearer',
          validator => sub {
              my ($token) = @_;
              return decode_jwt($token);  # Your JWT decode logic
          };
      $router;
  };

=head1 REAL-TIME PATTERNS

=head2 PubSub for WebSocket Chat

For real-time features with multiple WebSocket clients:

  use strict;
  use warnings;
  use Future::AsyncAwait;
  use PAGI::App::Router;
  use PAGI::WebSocket;

  # Simple in-memory room tracking
  my %rooms;  # room => { client_id => $ws }
  my $next_id = 1;

  my $router = PAGI::App::Router->new;

  $router->websocket('/chat/:room' => async sub {
      my ($scope, $receive, $send) = @_;
      my $ws = PAGI::WebSocket->new($scope, $receive, $send);
      my $room = $scope->{path_params}{room};
      my $client_id = $next_id++;

      await $ws->accept;

      # Keep connection alive through proxies/NAT (protocol-level ping every 25s)
      await $ws->keepalive(25);

      # Join room
      $rooms{$room}{$client_id} = $ws;

      # Broadcast received messages to all clients in room
      await $ws->each_text(async sub {
          my ($text) = @_;
          for my $id (keys %{$rooms{$room}}) {
              next if $id == $client_id;  # Don't echo back to sender
              await $rooms{$room}{$id}->try_send_text($text);
          }
      });

      # Leave room on disconnect
      delete $rooms{$room}{$client_id};
      delete $rooms{$room} unless keys %{$rooms{$room}};
  });

  $router->to_app;

B<Important:> This in-memory approach works within a single process. For
multi-worker or multi-server deployments, use Redis pub/sub or a message broker.

See L<PAGI::App::WebSocket::Chat> for a full-featured chat application.

=head2 SSE Dashboard

Server-Sent Events are ideal for real-time dashboards:

  use strict;
  use warnings;
  use Future::AsyncAwait;
  use PAGI::App::Router;
  use PAGI::SSE;

  my $router = PAGI::App::Router->new;

  $router->sse('/events' => async sub {
      my ($scope, $receive, $send) = @_;

      my $sse = PAGI::SSE->new($scope, $receive, $send);

      # Enable keepalive to prevent proxy timeout
      await $sse->keepalive(15);

      # Subscribe to metrics updates (your pub/sub system)
      my $sub_id = subscribe_metrics(sub {
          my ($metrics) = @_;
          $sse->try_send_event(
              event => 'metrics',
              data  => $metrics,
          );
      });

      $sse->on_close(sub { unsubscribe_metrics($sub_id) });
      await $sse->run;
  });

  $router->to_app;

JavaScript client:

  const events = new EventSource('/events');

  events.addEventListener('metrics', (e) => {
      const data = JSON.parse(e.data);
      document.getElementById('cpu').textContent = data.cpu.toFixed(1) + '%';
      document.getElementById('memory').textContent = data.memory.toFixed(1) + '%';
  });

See C<examples/sse-dashboard/> for a complete dashboard example.

=head2 Handling a Mixed Event Stream

When the receive stream carries more than one kind of event -- the protocol's
own events plus application events injected by middleware -- the
L<PAGI::Context> event dispatcher lets you register one handler per event type
and run a single loop, instead of hand-rolling a C<while> over C<receive>.

  use PAGI::Context;

  $router->websocket('/notify' => async sub {
      my ($scope, $receive, $send) = @_;
      my $ctx = PAGI::Context->new($scope, $receive, $send);
      await $ctx->accept;

      # A protocol event from the client
      $ctx->on('websocket.receive', async sub {
          my ($ctx, $event) = @_;
          await $ctx->send_text("echo: " . ($event->{text} // ''));
      });

      # An application event injected into the receive stream by middleware
      # (for example a pub/sub bridge such as the PAGI-Channels companion dist)
      $ctx->on('app.notify', async sub {
          my ($ctx, $event) = @_;
          await $ctx->send_json({ notice => $event->{message} });
      });

      # A fallback for anything you did not register a handler for
      $ctx->on_default(sub {
          my ($ctx, $event) = @_;
          warn "unexpected event: $event->{type}";
      });

      $ctx->on_error(sub {
          my ($ctx, $err, $source) = @_;
          warn "[$source] $err";
      });

      my $reason = await $ctx->run;   # 'disconnect', 'stop', or 'error'
  });

Handlers receive the protocol-typed C<$ctx> (so C<send_text>/C<send_json> work),
run in registration order, and may be C<async sub>s. The loop runs until the
client disconnects, a handler calls C<< $ctx->stop >>, or C<receive> fails;
C<run()> resolves with the reason and clears the handlers afterward so closures
over C<$ctx> do not leak.

=head2 Closing an SSE stream, and recording why

Returning from an SSE handler ends the stream when your code does. Call
C<< $sse->close >> to end it B<explicitly and immediately> -- from anywhere,
including deep in a helper -- which also wakes a parked C<< $sse->run >>. An
optional C<reason> is recorded server-side (logs, metrics, tracing) and passed
to C<on_close>; it is B<never> sent to the client, since SSE has no close frame:

  $router->sse('/quotes' => async sub {
      my ($scope, $receive, $send) = @_;
      my $sse = PAGI::SSE->new($scope, $receive, $send);

      my $sub = subscribe_feed($sse->query_param('job'));

      # One cleanup hook, however the stream ends (client gone, our close, ...).
      $sse->on_close(async sub {
          my ($sse, $reason) = @_;
          await $sub->cancel;                  # async cleanup, awaited
      });

      while (defined(my $tick = await $sub->next)) {
          await $sse->send_event(event => 'quote', data => $tick);
          next unless $tick->{final};

          # Tell the CLIENT with an in-band event it listens for, then close with
          # a SERVER-SIDE reason for your logs.
          await $sse->send_event(event => 'done', data => { ok => 1 });
          return await $sse->close(reason => 'feed_complete');
      }
      await $sse->close(reason => 'feed_gone');
  });

The two "why" channels are deliberately separate: a normal C<sse.send> event is
the only thing the browser can observe, while C<reason> is server-side metadata.
Because the close is an event on C<$send>, middleware can observe it -- wrap
C<$send> to fan close reasons out to metrics or tracing without touching the
handler:

  sub observe_sse_closes {
      my ($app) = @_;
      return async sub {
          my ($scope, $receive, $send) = @_;
          my $wrapped = async sub {
              my ($event) = @_;
              metric_inc('sse_close', $event->{reason} // 'unspecified')
                  if ($event->{type} // '') eq 'sse.close';
              return await $send->($event);    # forward unchanged
          };
          await $app->($scope, $receive, $wrapped);
      };
  }

Apply it at the mount or group level (event middleware). See L<PAGI::SSE/close>
for the method, L<PAGI::Spec::Www/"Close SSE - C<send> event"> for the protocol
event, and L<PAGI::Cookbook/"Ending a stream: return vs C<sse.close>"> for the
raw form.

=head2 Declining an SSE request (404 / 401 / 204 stop-reconnect)

An SSE request is an ordinary C<GET> with C<Accept: text/event-stream>. Before
you start the stream you can B<decline> it and return a normal HTTP response
instead -- no event stream. Send C<sse.http.response.start> followed by
C<sse.http.response.body> on the raw C<$send> (the same one you passed to
C<< PAGI::SSE->new >>), instead of calling C<< $sse->start >>:

  $router->sse('/feed' => async sub {
      my ($scope, $receive, $send) = @_;
      my $sse = PAGI::SSE->new($scope, $receive, $send);

      # Auth check before we commit to a stream.
      unless (authorized($sse->query_param('token'))) {
          await $send->({
              type    => 'sse.http.response.start',
              status  => 401,
              headers => [['content-type', 'text/plain']],
          });
          await $send->({ type => 'sse.http.response.body', body => 'Unauthorized', more => 0 });
          return;
      }

      await $sse->start;
      # ... stream ...
  });

Three common reasons to decline:

=over 4

=item * B<404> -- no such SSE endpoint. C<PAGI::App::Router> already does this
for an unmatched SSE route (see L</SSE Routes>); you rarely write it by hand.

=item * B<401> / B<403> -- authentication or authorization failed; don't open a
stream.

=item * B<204> -- the spec's mechanism to B<stop> an C<EventSource> from
reconnecting. A browser's C<EventSource> reconnects on any non-success or
network drop; answering the request with C<204 No Content> tells it to stop for
good.

=back

  # Tell EventSource to stop reconnecting: a bare 204.
  await $send->({ type => 'sse.http.response.start', status => 204, headers => [] });
  await $send->({ type => 'sse.http.response.body', body => '', more => 0 });

First-send-wins picks the mode: once you have sent C<sse.start> a decline
raises, and once you have sent C<sse.http.response.start> a stream event
(C<sse.send>, C<sse.start>, ...) raises. Decide before you start.

See L<PAGI::Spec::Www/"Decline SSE - C<send> event"> for the protocol events and
the full state machine.

=head1 BACKGROUND WORK

Web requests should respond quickly. Long-running work should be done in the background.

=head2 Fire-and-Forget Async I/O

For non-blocking operations (async HTTP calls, async database queries),
use C<< ->on_fail() >> to handle errors, then C<< ->retain() >> to prevent
"lost future" warnings:

  use strict;
  use warnings;
  use Future::AsyncAwait;
  use Future::IO;
  use PAGI::App::Router;
  use PAGI::Response;

  my $router = PAGI::App::Router->new;

  # Simulated async email service
  async sub send_welcome_email {
      my ($email) = @_;
      # In reality: await $http_client->post_async($email_api, ...);
      await Future::IO->sleep(2);
      warn "Email sent to $email\n";
  }

  $router->post('/signup' => async sub {
      my ($scope, $receive, $send) = @_;
      my $res = PAGI::Response->new($scope);

      # Respond immediately -- the client does not wait for the email
      await $res->status(201)->json({ status => 'created' })->respond($send);

      # Fire-and-forget: scheduled now, runs on later event-loop ticks
      # (after this handler returns). Always add on_fail() before retain()
      # to avoid silently swallowing errors.
      send_welcome_email('user@example.com')
          ->on_fail(sub { warn "Email send failed: @_" })
          ->retain();
  });

  $router->to_app;

B<Warning:> C<< ->retain() >> alone silently swallows errors - always add
C<< ->on_fail() >> first.

B<Middleware authors:> If your middleware inherits from L<IO::Async::Notifier>,
use C<< $self->adopt_future($f) >> instead. It properly tracks futures and
propagates unhandled errors to the notifier's C<invoke_error> method.

=head2 CPU-Bound Work in Subprocess

B<Note:> This pattern uses L<IO::Async::Function>, which is specific to
L<PAGI::Server>'s L<IO::Async> backend. It won't work with other PAGI servers.

For blocking or CPU-intensive work, use L<IO::Async::Function> to run in a child process:

  use strict;
  use warnings;
  use Future::AsyncAwait;
  use IO::Async::Function;
  use IO::Async::Loop;
  use PAGI::App::Router;
  use PAGI::Response;

  my $loop = IO::Async::Loop->new;

  # Worker runs code in subprocess
  my $pdf_worker = IO::Async::Function->new(
      code => sub {
          my ($data) = @_;
          # This blocking code runs in a CHILD PROCESS
          # It doesn't block the main event loop
          sleep 5;  # Simulate heavy work
          return "PDF generated for $data";
      },
  );
  $loop->add($pdf_worker);

  my $router = PAGI::App::Router->new;

  $router->post('/generate-pdf' => async sub {
      my ($scope, $receive, $send) = @_;
      my $res = PAGI::Response->new($scope);

      # Option A: Wait for result (blocks this request only)
      my $result = await $pdf_worker->call(args => ['report']);
      await $res->json({ result => $result })->respond($send);

      # Option B: respond immediately, generate in the background
      # await $res->json({ status => 'processing' })->respond($send);
      # my $f = $pdf_worker->call(args => ['report']);
      # $f->on_done(sub { warn "PDF ready: $_[0]\n" });
      # $f->on_fail(sub { warn "PDF failed: $_[0]\n" });
      # $f->retain();
  });

  $router->to_app;

B<Warning:> IO::Async::Function forks child processes. If running with C<--workers N>, each server worker forks its own Function workers. With 4 server workers and 2 Function workers each, you have 12 processes. For high-volume CPU work, consider a dedicated job queue instead.

See C<examples/background-tasks/> for complete working examples.

=head1 FORMS & UPLOADS

=head2 URL-Encoded Forms

  use strict;
  use warnings;
  use Future::AsyncAwait;
  use PAGI::Request;
  use PAGI::Response;

  async sub app {
      my ($scope, $receive, $send) = @_;

      my $req = PAGI::Request->new($scope, $receive);
      my $res = $req->response;

      if ($req->method eq 'POST') {
          my $form = await $req->form_params;
          my $username = $form->{username};
          my $password = $form->{password};

          # Validate and process...
          await $res->redirect('/dashboard')->respond($send);
      }
      else {
          await $res->html(<<'HTML')->respond($send);
  <form method="POST">
      <input name="username" placeholder="Username">
      <input name="password" type="password" placeholder="Password">
      <button type="submit">Login</button>
  </form>
  HTML
      }
  }

  \&app;

=head2 File Uploads (Multipart)

  use strict;
  use warnings;
  use Future::AsyncAwait;
  use PAGI::Request;
  use PAGI::Response;

  async sub app {
      my ($scope, $receive, $send) = @_;

      my $req = PAGI::Request->new($scope, $receive);
      my $res = $req->response;

      if ($req->method eq 'POST') {
          # uploads() returns a Hash::MultiValue keyed by form field name;
          # ->values gives every uploaded file across all fields
          my $uploads = await $req->uploads;
          my @files = $uploads->values;

          for my $upload (@files) {
              my $filename = $upload->filename;
              my $size = $upload->size;
              my $type = $upload->content_type;

              # Save to disk (blocking I/O)
              $upload->move_to('/uploads/' . $filename);

              # Or read content into memory
              # my $content = $upload->slurp;
          }

          await $res->json({ uploaded => scalar(@files) })->respond($send);
      }
      else {
          await $res->html(<<'HTML')->respond($send);
  <form method="POST" enctype="multipart/form-data">
      <input type="file" name="file" multiple>
      <button type="submit">Upload</button>
  </form>
  HTML
      }
  }

  \&app;

See C<examples/10-forms-and-uploads/> for complete examples.

=head1 COMMAND-LINE RECIPES

=head2 Environment Modes

pagi-server auto-detects development vs production mode based on whether
you're running in an interactive terminal:

  # Development mode (TTY detected) - Lint middleware + access logging
  pagi-server app.pl

  # Production mode (no TTY, e.g., systemd/docker) - no middleware, no logging
  # This happens automatically when running as a daemon

  # Explicit mode selection
  pagi-server -E production app.pl
  pagi-server -E development app.pl
  pagi-server -E none app.pl  # Disable all auto-middleware

  # Via environment variable
  PAGI_ENV=production pagi-server app.pl

  # Enable logging in production (writes to file)
  pagi-server -E production --access-log /var/log/pagi.log app.pl

  # Disable auto-middleware even in development
  pagi-server --no-default-middleware app.pl

=head2 Inline Apps with -e

Like C<perl -e>, you can run inline PAGI apps. This is useful for quick
tests or one-liners:

  # Quick static file server
  pagi-server -MPAGI::App::Directory \
      -e 'PAGI::App::Directory->new(root => "/var/www")->to_app'

  # Serve a single file
  pagi-server -MPAGI::App::File \
      -e 'PAGI::App::File->new(root => ".")->to_app'

  # Quick redirect
  pagi-server -MPAGI::App::Redirect \
      -e 'PAGI::App::Redirect->new(location => "https://example.com")->to_app'

=head2 Loading Modules with -M

The C<-M> flag loads modules before C<-e> code runs, like C<perl -M>:

  # Load multiple modules
  pagi-server -MPAGI::App::File -MPAGI::Middleware::Builder \
      -e 'use PAGI::Middleware::Builder; builder { enable "GZip"; PAGI::App::File->new(root => ".") }'

  # Cuddled form (no space after -M)
  pagi-server -MPAGI::App::File -e 'PAGI::App::File->new(root => ".")->to_app'

=head2 Common pagi-server Patterns

  # Development with verbose logging
  pagi-server --log-level debug app.pl

  # Production multi-worker deployment
  pagi-server -E production --host 0.0.0.0 -p 80 --workers 4 app.pl

  # With custom library paths
  pagi-server -I ./lib -I ./local/lib app.pl

  # HTTPS with TLS
  pagi-server --ssl-cert cert.pem --ssl-key key.pem -p 443 app.pl

  # Daemonized with PID file
  pagi-server -D --pid /var/run/pagi.pid app.pl

  # Maximum performance (no access logging)
  pagi-server --no-access-log --workers 8 -E production app.pl

  # Using EV event loop for better performance
  pagi-server --loop EV app.pl

=head1 DEPLOYMENT

=head2 TLS/HTTPS

L<PAGI::Server> supports TLS natively:

  pagi-server app.pl --port 443 \
      --ssl-cert /path/to/cert.pem \
      --ssl-key /path/to/key.pem

For development, generate a self-signed certificate:

  openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem \
      -days 365 -nodes -subj '/CN=localhost'

  pagi-server app.pl --port 3443 \
      --ssl-cert cert.pem --ssl-key key.pem

Then access via C<https://localhost:3443> (browser will warn about self-signed cert).

See C<examples/11-tls-https/> for more TLS configuration options.

=head2 Serving Large Files with X-Sendfile

For production deployments behind a reverse proxy (Nginx, Apache), use
L<PAGI::Middleware::XSendfile> to delegate file serving to the proxy.
This frees your application worker immediately while the proxy handles
the file transfer using optimized kernel sendfile.

=head3 Why X-Sendfile?

Direct file serving from your application ties up a worker process for
the entire transfer. With slow clients or large files, this limits your
concurrency. X-Sendfile lets your app send a header telling the proxy
to serve the file:

  # Your app sends:
  X-Accel-Redirect: /protected/files/large.bin
  Content-Type: application/octet-stream

  # Nginx intercepts and serves the file directly

Benefits:

=over 4

=item * Worker freed immediately after sending headers

=item * Proxy uses kernel sendfile (zero-copy, efficient)

=item * Proxy handles Range requests, caching, connection management

=item * Slow clients don't block your app

=back

=head3 Nginx Configuration

Configure an internal location for protected files:

  # nginx.conf
  location /protected/ {
      internal;                       # Only accessible via X-Accel-Redirect
      alias /var/www/files/;          # Actual file location
  }

=head3 Application Setup

  use strict;
  use warnings;
  use Future::AsyncAwait;
  use PAGI::Middleware::Builder;
  use PAGI::App::File;

  my $app = builder {
      enable 'XSendfile',
          type    => 'X-Accel-Redirect',
          mapping => { '/var/www/files/' => '/protected/' };

      PAGI::App::File->new(
          root          => '/var/www/files',
          handle_ranges => 0,  # Let nginx handle Range requests
      );
  };

B<Note:> Setting C<handle_ranges =E<gt> 0> is important. It tells your app
to ignore Range headers and always send full files. The reverse proxy will
handle Range requests more efficiently using its native sendfile.

=head3 Custom Download Handler

For more control (authentication, logging, custom headers):

  use PAGI::App::Router;
  use PAGI::Request;
  use PAGI::Response;

  my $router = PAGI::App::Router->new;

  $router->get('/download/:filename' => async sub {
      my ($scope, $receive, $send) = @_;
      my $req = PAGI::Request->new($scope, $receive);
      my $res = $req->response;

      my $filename = $req->path_param('filename');
      my $filepath = "/var/www/files/$filename";

      # Validate file exists and user has access
      return await $res->status(404)->text('Not found')->respond($send)
          unless -f $filepath;

      # Send file response directly via raw protocol events (XSendfile middleware will intercept)
      await $send->({
          type    => 'http.response.start',
          status  => 200,
          headers => [
              ['content-type', 'application/octet-stream'],
              ['content-disposition', qq{attachment; filename="$filename"}],
          ],
      });
      await $send->({
          type => 'http.response.body',
          file => $filepath,
      });
  });

  my $app = builder {
      enable 'XSendfile',
          type    => 'X-Accel-Redirect',
          mapping => { '/var/www/files/' => '/protected/' };
      $router;
  };

=head3 Apache Configuration (mod_xsendfile)

Enable mod_xsendfile and whitelist your file directory:

  # Apache config
  XSendFile On
  XSendFilePath /var/www/files

  # Application
  my $app = builder {
      enable 'XSendfile', type => 'X-Sendfile';
      $my_app;
  };

=head3 Filehandle Support

The middleware also works with filehandle responses, but only if the
filehandle object has a C<path()> method. L<IO::File::WithPath> from CPAN
is an easy drop-in for this:

  use IO::File::WithPath;

  # This works - IO::File::WithPath provides the path method
  my $fh = IO::File::WithPath->new('/path/to/file.bin', 'r');
  await $send->({ type => 'http.response.body', fh => $fh });

  # This does NOT trigger X-Sendfile (no path method)
  open my $plain_fh, '<', '/path/to/file.bin';
  await $send->({ type => 'http.response.body', fh => $plain_fh });

For plain filehandles, either use C<file =E<gt> $path> directly, or use
L<IO::File::WithPath>. See L<PAGI::Middleware::XSendfile> for details.

=head1 RESPONSE STATE & LIFECYCLE

A C<PAGI::Response> is a detached value: building it is fully separate from
sending it. That split means there is no single "is this done?" flag — instead
there are a few distinct signals, each answering a different question. This
section maps them so you can tell, at any point, what is going on.

=head2 The three phases

A response moves through three phases. Each has its own signal:

=over 4

=item 1. B<Build> — the handler accumulates status, headers, and a body source
on the value. No connection, no I/O. The signal here is L<PAGI::Response/has_body_source>:
"has a body source been registered?"

=item 2. B<Send committed> — C<< $ctx->respond($res) >> (or a raw
C<< $res->respond($send) >> behind the context's guard) has begun emitting the
response and has taken the connection. The signal is L<PAGI::Response/is_sent>:
"has this response gone out?" It is set at the moment the send is committed, so
a second send is rejected.

=item 3. B<Finished> — every byte has been emitted; for a stream, the callback
has run to completion and the writer is closed. There is no flag for this: it is
the resolution of the Future returned by C<respond>. C<await>-ing the send B<is>
waiting for "finished".

=back

The reason "finished" is a Future and not a flag is the same reason the value is
detached: PAGI never models a half-sent live byte-sink as state on the value.
"Committed" is a flag; "finished" is the thing you C<await>.

=head2 The signals you can inspect

  Signal                      Phase          Question it answers
  --------------------------  -------------  -------------------------------------
  has_status                  build          Was a status code set explicitly?
  has_header($name)           build          Was this header set?
  has_content_type            build          Was Content-Type set?
  has_body_source             build          Was a body (bytes/file/stream) set?
  is_sent                     send           Has the response been emitted?
  (Future from respond)       finished       Has every byte been written?
  $writer->bytes_written      finished*      How many bytes streamed so far?
  $writer->is_closed          finished*      Has the stream writer been closed?

The C<$writer> signals (*) apply only while streaming, on the
L<PAGI::Response::Writer> handed to your C<stream> callback or returned by
C<writer>. They describe the live stream; the response value itself only carries
the build and send signals.

=head2 has_body_source is intent, not bytes

The signal most likely to surprise you is C<has_body_source>. It reports that a
body B<source> is registered — not that bytes exist, and not that anything was
sent. For a stream this matters: the callback is stored at build time and does
not run until C<respond>, so a freshly-registered stream has produced zero bytes
yet C<has_body_source> is already true. That is deliberate and is the only
coherent meaning — C<respond> is what drives the stream, so "are there bytes
yet?" is unanswerable at build time and irrelevant to "is there a body to send?"

This mirrors the distinction Node.js draws between C<writableEnded> ("the
producer declared it is done") and C<writableFinished> ("all data has been
flushed"). C<has_body_source> is the former, decoupled from any I/O;
C<await>-ing C<respond> is the latter.

Intentional empty bodies count as a registered source: C<empty>, C<redirect>,
and C<send_raw('')> all set an (empty) body, so C<has_body_source> is true for
them. Only a response that never had a body method called reports false.

=head2 Deciding "did the handler produce a response?"

A framework that lets handlers populate C<< $ctx->res >> and then auto-sends it
needs to know, after the handler returns, whether to send, skip, or fall through
to the next route. That is a three-way decision, and it is a precedence ladder —
not a single predicate:

    # After running the handler, which mutated $ctx->res:
    if ($ctx->res->is_sent) {
        # The handler already took the connection itself — e.g. it called
        # ->writer($send) for live streaming, or used SSE/WebSocket. Do nothing;
        # sending again would be a double-send.
    }
    elsif ($ctx->res->has_body_source || $ctx->res->has_status) {
        # The handler registered a body, or set a status (a bare 204 / redirect
        # has no body but IS a response). Send it once, through the guard.
        await $ctx->respond($ctx->res);
    }
    else {
        # The handler touched nothing send-able — fall through to the next match
        # (or a 404).
    }

Three things make this ladder correct:

=over 4

=item * B<Check C<is_sent> first.> A handler that called C<< ->writer($send) >>
has already emitted headers (C<writer> marks the response sent), but
C<has_body_source> stays false because the live-writer path bypasses the body
slots. Without the C<is_sent> guard you would mistake an actively-streaming
response for "nothing produced".

=item * B<C<|| has_status> is required.> A C<302> redirect or a C<204> can have
no body source, yet it is a real response. C<has_status> catches the
status-only case so you do not 404 a legitimate empty-body response.

=item * B<Never inspect the private slots.> Use C<has_body_source>, not
C<< exists $res->{_stream} >> and friends — those are private and may change
(see L<PAGI::Response/SUBCLASSING (FRAMEWORK INTEGRATION)>). The predicate is
the supported way to ask.

=back

If instead your framework has handlers B<return> the response (PAGI's own
endpoint contract), you do not need this ladder at all: the returned value is
the answer, and "fall through" is an explicit sentinel you define, not an
inference from an empty response.

=head1 TESTING

Use L<PAGI::Test::Client> to test apps directly without a running server:

  use strict;
  use warnings;
  use Test2::V0;
  use PAGI::Test::Client;

  # Load your app
  my $app = require './app.pl';
  my $client = PAGI::Test::Client->new(app => $app);

  subtest 'GET /' => sub {
      my $res = $client->get('/');
      is $res->status, 200, 'status is 200';
      like $res->text, qr/Hello/, 'body contains Hello';
  };

  subtest 'POST /api/users' => sub {
      my $res = $client->post('/api/users',
          json => { name => 'Alice' },
      );
      is $res->status, 201, 'status is 201';
      is $res->json->{id}, 1, 'returns user id';
  };

  subtest 'form submission' => sub {
      my $res = $client->post('/login',
          form => { user => 'admin', pass => 'secret' },
      );
      is $res->status, 302, 'redirects after login';

      # Session cookies persist across requests
      my $dashboard = $client->get('/dashboard');
      is $dashboard->status, 200, 'authenticated access';
  };

  subtest 'custom headers' => sub {
      my $res = $client->get('/api/data',
          headers => { Authorization => 'Bearer token123' },
      );
      is $res->status, 200;
  };

  done_testing;

See L<PAGI::Test::Client> for the full API including WebSocket and SSE testing.

=head1 SEE ALSO

=over 4

=item * L<PAGI::Tutorial> - Learn the PAGI protocol from the ground up

=item * L<PAGI::Tools::Tutorial> - Guide to middleware, helpers, and built-in apps

=item * L<PAGI::App::Router> - Functional routing

=item * L<PAGI::Endpoint::Router> - Class-based routing

=item * L<PAGI::Middleware::Session> - Session management

=item * L<PAGI::App::WebSocket::Chat> - Multi-room chat application

=item * L<PAGI::Middleware::XSendfile> - Delegate file serving to reverse proxy

=item * L<PAGI::Test::Client> - Test apps without a running server

=back

=head1 AUTHOR

PAGI Contributors

=head1 COPYRIGHT AND LICENSE

This software is copyright (c) 2025 by the PAGI contributors.

This is free software; you can redistribute it and/or modify it under
the same terms as the Perl 5 programming language system itself.

=cut
