File Coverage

blib/lib/PAGI/WebSocket.pm
Criterion Covered Total %
statement 372 372 100.0
branch 129 140 92.1
condition 65 79 82.2
subroutine 73 73 100.0
pod 58 58 100.0
total 697 722 96.5


line stmt bran cond sub pod time code
1             package PAGI::WebSocket;
2             $PAGI::WebSocket::VERSION = '0.002000';
3 22     22   2177738 use strict;
  22         35  
  22         735  
4 22     22   104 use warnings;
  22         42  
  22         1101  
5 22     22   101 use Carp qw(croak);
  22         29  
  22         1191  
6 22     22   5750 use Encode qw(decode FB_CROAK FB_DEFAULT LEAVE_SRC);
  22         192924  
  22         1982  
7 22     22   7768 use Hash::MultiValue;
  22         47451  
  22         1252  
8 22     22   1689 use Future::AsyncAwait;
  22         69852  
  22         198  
9 22     22   1205 use Future;
  22         72  
  22         461  
10 22     22   5087 use JSON::MaybeXS ();
  22         130064  
  22         926  
11 22     22   128 use Scalar::Util qw(blessed);
  22         28  
  22         131193  
12              
13              
14             sub new {
15 156     156 1 2187415 my ($class, $scope, $receive, $send) = @_;
16              
17 156 100 100     1111 croak "PAGI::WebSocket requires scope hashref"
18             unless $scope && ref($scope) eq 'HASH';
19 154 100 100     702 croak "PAGI::WebSocket requires receive coderef"
20             unless $receive && ref($receive) eq 'CODE';
21 152 100 100     655 croak "PAGI::WebSocket requires send coderef"
22             unless $send && ref($send) eq 'CODE';
23             croak "PAGI::WebSocket requires scope type 'websocket', got '$scope->{type}'"
24 150 100 50     640 unless ($scope->{type} // '') eq 'websocket';
25              
26             # Return existing WebSocket object if one was already created for this scope
27             # This ensures consistent state (is_connected, is_closed, callbacks) if
28             # multiple code paths create WebSocket objects from the same scope.
29 149 50       353 return $scope->{'pagi.websocket'} if $scope->{'pagi.websocket'};
30              
31 149         1389 my $self = bless {
32             scope => $scope,
33             receive => $receive,
34             send => $send,
35             _state => 'connecting', # connecting -> connected -> closed
36             _close_code => undef,
37             _close_reason => undef,
38             _on_close => [],
39             _on_error => [],
40             _on_message => [],
41             }, $class;
42              
43             # Cache in scope for reuse (weakened to avoid circular reference leak)
44 149         298 $scope->{'pagi.websocket'} = $self;
45 149         280 Scalar::Util::weaken($scope->{'pagi.websocket'});
46              
47 149         486 return $self;
48             }
49              
50             # Scope property accessors
51 4     4 1 103 sub scope { shift->{scope} }
52 2     2 1 626 sub path { shift->{scope}{path} }
53 2   66 2 1 7 sub raw_path { my $s = shift; $s->{scope}{raw_path} // $s->{scope}{path} }
  2         13  
54 19   100 19 1 61 sub query_string { shift->{scope}{query_string} // '' }
55 2   100 2 1 15 sub scheme { shift->{scope}{scheme} // 'ws' }
56 3   100 3 1 22 sub http_version { shift->{scope}{http_version} // '1.1' }
57 3   100 3 1 23 sub subprotocols { shift->{scope}{subprotocols} // [] }
58 1     1 1 7 sub client { shift->{scope}{client} }
59 1     1 1 4 sub server { shift->{scope}{server} }
60              
61              
62             # Application state (injected by PAGI::Lifespan, read-only)
63             sub state {
64 6     6 1 16 my $self = shift;
65 6   100     43 return $self->{scope}{state} // {};
66             }
67              
68             # Path parameter accessors - captured from URL path by router
69             # Stored in scope->{path_params} for router-agnostic access
70             sub path_params {
71 2     2 1 9 my ($self) = @_;
72 2   100     10 return $self->{scope}{path_params} // {};
73             }
74              
75             sub path_param {
76 6     6 1 28 my ($self, $name) = @_;
77 6   100     20 my $params = $self->{scope}{path_params} // {};
78 6         21 return $params->{$name};
79             }
80              
81             # Internal: URL decode a string (handles + as space)
82             sub _url_decode {
83 58     58   75 my ($str) = @_;
84 58 50       77 return '' unless defined $str;
85 58         74 $str =~ s/\+/ /g;
86 58         91 $str =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/ge;
  18         52  
87 58         93 return $str;
88             }
89              
90             # Internal: Decode UTF-8 with replacement or croak in strict mode
91             sub _decode_utf8 {
92 50     50   149 my ($str, $strict) = @_;
93 50 50       68 return '' unless defined $str;
94 50 100       64 my $flag = $strict ? FB_CROAK : FB_DEFAULT;
95 50         57 $flag |= LEAVE_SRC;
96 50         177 return decode('UTF-8', $str, $flag);
97             }
98              
99             # Query params as Hash::MultiValue (cached in scope)
100             # Options: strict => 1 (croak on invalid UTF-8), raw => 1 (skip UTF-8 decoding)
101             sub query_params {
102 32     32 1 277 my ($self, %opts) = @_;
103 32   100     96 my $strict = delete $opts{strict} // 0;
104 32   100     100 my $raw = delete $opts{raw} // 0;
105 32 100       247 croak("Unknown options to query_params: " . join(', ', keys %opts)) if %opts;
106              
107 31 100       60 my $cache_key = $raw ? 'pagi.websocket.query.raw' : ($strict ? 'pagi.websocket.query.strict' : 'pagi.websocket.query');
    100          
108 31 100       95 return $self->{scope}{$cache_key} if $self->{scope}{$cache_key};
109              
110 17         42 my $qs = $self->query_string;
111 17         25 my @pairs;
112              
113 17         76 for my $part (split /[&;]/, $qs) {
114 29 50       51 next unless length $part;
115 29         68 my ($key, $val) = split /=/, $part, 2;
116 29   50     52 $key //= '';
117 29   100     47 $val //= '';
118              
119             # URL decode (handles + as space)
120 29         110 my $key_decoded = _url_decode($key);
121 29         41 my $val_decoded = _url_decode($val);
122              
123             # UTF-8 decode unless raw mode
124 29 100       50 my $key_final = $raw ? $key_decoded : _decode_utf8($key_decoded, $strict);
125 29 100       799 my $val_final = $raw ? $val_decoded : _decode_utf8($val_decoded, $strict);
126              
127 28         547 push @pairs, $key_final, $val_final;
128             }
129              
130 16         67 $self->{scope}{$cache_key} = Hash::MultiValue->new(@pairs);
131 16         656 return $self->{scope}{$cache_key};
132             }
133              
134             # Raw query params (no UTF-8 decoding)
135             sub raw_query_params {
136 2     2 1 310 my $self = shift;
137 2         8 return $self->query_params(raw => 1);
138             }
139              
140             # Shortcut for single query param
141             sub query {
142 23     23 1 3247 my ($self, $name, %opts) = @_;
143 23         55 return $self->query_params(%opts)->get($name);
144             }
145              
146             # Raw single query param
147             sub raw_query {
148 2     2 1 282 my ($self, $name) = @_;
149 2         8 return $self->query($name, raw => 1);
150             }
151              
152             # Single header lookup (case-insensitive, returns last value)
153             sub header {
154 4     4 1 12 my ($self, $name) = @_;
155 4         8 $name = lc($name);
156 4         4 my $value;
157 4   50     5 for my $pair (@{$self->{scope}{headers} // []}) {
  4         32  
158 20 100       28 if (lc($pair->[0]) eq $name) {
159 4         6 $value = $pair->[1];
160             }
161             }
162 4         12 return $value;
163             }
164              
165             # All headers as Hash::MultiValue (cached in scope)
166             sub headers {
167 3     3 1 555 my $self = shift;
168 3 100       17 return $self->{scope}{'pagi.request.headers'} if $self->{scope}{'pagi.request.headers'};
169              
170 2         3 my @pairs;
171 2   50     4 for my $pair (@{$self->{scope}{headers} // []}) {
  2         9  
172 8         22 push @pairs, lc($pair->[0]), $pair->[1];
173             }
174              
175 2         15 $self->{scope}{'pagi.request.headers'} = Hash::MultiValue->new(@pairs);
176 2         125 return $self->{scope}{'pagi.request.headers'};
177             }
178              
179             # All values for a header
180             sub header_all {
181 2     2 1 6 my ($self, $name) = @_;
182 2         8 return $self->headers->get_all(lc($name));
183             }
184              
185             # State accessors
186 4     4 1 25 sub connection_state { shift->{_state} }
187              
188             sub is_connected {
189 17     17 1 939 my $self = shift;
190 17         81 return $self->{_state} eq 'connected';
191             }
192              
193             sub is_closed {
194 135     135 1 6207 my $self = shift;
195 135         911 return $self->{_state} eq 'closed';
196             }
197              
198 50     50 1 738 sub close_code { shift->{_close_code} }
199 50     50 1 87 sub close_reason { shift->{_close_reason} }
200              
201             # Outbound flow-control introspection (delegates to the pagi.transport handle)
202             sub buffered_amount {
203 2     2 1 8 my $self = shift;
204 2         5 my $t = $self->{scope}{'pagi.transport'};
205 2 100       6 return 0 unless $t;
206 1         4 return $t->buffered_amount;
207             }
208              
209             sub high_water_mark {
210 2     2 1 295 my $self = shift;
211 2         3 my $t = $self->{scope}{'pagi.transport'};
212 2 100       7 return undef unless $t;
213 1         3 return $t->high_water_mark;
214             }
215              
216             sub low_water_mark {
217 2     2 1 262 my $self = shift;
218 2         4 my $t = $self->{scope}{'pagi.transport'};
219 2 100       7 return undef unless $t;
220 1         4 return $t->low_water_mark;
221             }
222              
223             sub on_high_water {
224 2     2 1 30 my ($self, $cb) = @_;
225 2         3 my $t = $self->{scope}{'pagi.transport'};
226 2 100 66     15 $t->on_high_water($cb) if $t && $t->can('on_high_water');
227 2         9 return $self;
228             }
229              
230             sub on_drain {
231 2     2 1 331 my ($self, $cb) = @_;
232 2         4 my $t = $self->{scope}{'pagi.transport'};
233 2 100 66     14 $t->on_drain($cb) if $t && $t->can('on_drain');
234 2         7 return $self;
235             }
236              
237             sub is_writable {
238 3     3 1 4 my $self = shift;
239 3         6 my $t = $self->{scope}{'pagi.transport'};
240 3 100       7 return 1 unless $t;
241 2         5 my $high = $t->high_water_mark;
242 2 50       6 return 1 unless defined $high;
243 2 100       5 return $t->buffered_amount < $high ? 1 : 0;
244             }
245              
246             # Internal state setters
247             sub _set_state {
248 101     101   219 my ($self, $state) = @_;
249 101         213 $self->{_state} = $state;
250             }
251              
252             sub _set_closed {
253 50     50   90 my ($self, $code, $reason) = @_;
254 50         68 $self->{_state} = 'closed';
255 50   100     136 $self->{_close_code} = $code // 1005;
256 50   100     128 $self->{_close_reason} = $reason // '';
257             }
258              
259             # Register callback to run on disconnect/close
260             sub on_close {
261 17     17 1 287 my ($self, $callback) = @_;
262 17         16 push @{$self->{_on_close}}, $callback;
  17         27  
263 17         49 return $self;
264             }
265              
266             # Internal: run all on_close callbacks exactly once
267 43     43   48 async sub _run_close_callbacks {
268 43         79 my ($self) = @_;
269              
270             # Only run once
271 43 50       90 return if $self->{_close_callbacks_ran};
272 43         61 $self->{_close_callbacks_ran} = 1;
273              
274 43         112 my $code = $self->close_code;
275 43         83 my $reason = $self->close_reason;
276              
277 43         48 for my $cb (@{$self->{_on_close}}) {
  43         83  
278 15         19 eval {
279 15         26 my $r = $cb->($code, $reason);
280             # Only await if callback returns a Future
281 15 100 100     248 if (blessed($r) && $r->isa('Future')) {
282 10         17 await $r;
283             }
284             };
285 15 100       152 if ($@) {
286 1         13 warn "PAGI::WebSocket on_close callback error: $@";
287             }
288             }
289              
290             # Clear all callback arrays to break any closure-based cycles
291 43         94 $self->{_on_close} = [];
292 43         58 $self->{_on_error} = [];
293 43         159 $self->{_on_message} = [];
294             }
295              
296             # Register callback to run on errors
297             sub on_error {
298 10     10 1 227 my ($self, $callback) = @_;
299 10         13 push @{$self->{_on_error}}, $callback;
  10         17  
300 10         19 return $self;
301             }
302              
303             # Register callback to run on message receive
304             sub on_message {
305 12     12 1 266 my ($self, $callback) = @_;
306 12         19 push @{$self->{_on_message}}, $callback;
  12         26  
307 12         29 return $self;
308             }
309              
310             # Generic event registration (Socket.IO style)
311             sub on {
312 8     8 1 474 my ($self, $event, $callback) = @_;
313              
314 8 100       25 if ($event eq 'message') {
    100          
    100          
315 3         10 return $self->on_message($callback);
316             }
317             elsif ($event eq 'close') {
318 2         6 return $self->on_close($callback);
319             }
320             elsif ($event eq 'error') {
321 2         6 return $self->on_error($callback);
322             }
323             else {
324 1         232 croak "Unknown event type: $event (expected message, close, or error)";
325             }
326             }
327              
328             # Internal: trigger error callbacks
329 7     7   95 async sub _trigger_error {
330 7         17 my ($self, $error) = @_;
331              
332 7         9 for my $cb (@{$self->{_on_error}}) {
  7         17  
333 8         15 eval {
334 8         16 my $r = $cb->($error);
335 8 100 66     86 if (blessed($r) && $r->isa('Future')) {
336 2         3 await $r;
337             }
338             };
339 8 100       47 if ($@) {
340 1         30 warn "PAGI::WebSocket on_error callback error: $@";
341             }
342             }
343              
344             # If no error handlers registered, warn
345 7 100       9 if (!@{$self->{_on_error}}) {
  7         57  
346 1         23 warn "PAGI::WebSocket error: $error";
347             }
348             }
349              
350             # Accept the WebSocket connection
351 95     95 1 506 async sub accept {
352 95         153 my ($self, %opts) = @_;
353              
354 95         182 my $event = {
355             type => 'websocket.accept',
356             };
357 95 100       209 $event->{subprotocol} = $opts{subprotocol} if exists $opts{subprotocol};
358 95 100       223 $event->{headers} = $opts{headers} if exists $opts{headers};
359              
360 95         250 await $self->{send}->($event);
361 95         4767 $self->_set_state('connected');
362              
363 95         486 return $self;
364             }
365              
366             # Close the WebSocket connection
367 19     19 1 349 async sub close {
368 19         41 my ($self, $code, $reason) = @_;
369              
370             # Idempotent - don't send close twice
371 19 100       35 return if $self->is_closed;
372              
373 16   100     137 $code //= 1000;
374 16   100     49 $reason //= '';
375              
376 16         84 await $self->{send}->({
377             type => 'websocket.close',
378             code => $code,
379             reason => $reason,
380             });
381              
382 16         445 $self->_set_closed($code, $reason);
383 16         38 await $self->_run_close_callbacks;
384              
385 16         425 return $self;
386             }
387              
388             # Whether the server advertised the WebSocket denial-response extension.
389             # See L.
390             sub supports_denial_response {
391 5     5 1 12 my $self = shift;
392 5 100       25 return $self->{scope}{extensions}{'websocket.http.response'} ? 1 : 0;
393             }
394              
395             # Reject the handshake with a custom HTTP response (status/headers/body) instead
396             # of the bare 403. Falls back to a plain close when the server does not advertise
397             # the extension. Valid only before accept.
398             # See L.
399 2     2 1 11 async sub deny {
400 2         7 my ($self, %opts) = @_;
401              
402 2   50     4 my $status = $opts{status} // 403;
403 2   100     7 my $headers = $opts{headers} // [];
404 2 100       7 my $body = defined $opts{body} ? $opts{body} : '';
405              
406 2 100       5 if (!$self->supports_denial_response) {
407 1         4 await $self->{send}->({ type => 'websocket.close', code => 1008, reason => '' });
408 1         42 $self->_set_closed(1008, '');
409 1         5 return $self;
410             }
411              
412             await $self->{send}->({
413             type => 'websocket.http.response.start',
414             status => $status,
415             headers => $headers,
416 1         4 });
417 1         76 await $self->{send}->({
418             type => 'websocket.http.response.body',
419             body => $body,
420             more => 0,
421             });
422              
423 1         25 $self->_set_closed($status, '');
424 1         9 return $self;
425             }
426              
427             # Send text message
428 7     7 1 83 async sub send_text {
429 7         11 my ($self, $text) = @_;
430              
431 7 100       16 croak "Cannot send on closed WebSocket" if $self->is_closed;
432              
433 6         26 await $self->{send}->({
434             type => 'websocket.send',
435             text => $text,
436             });
437              
438 6         158 return $self;
439             }
440              
441             # Send binary message
442 3     3 1 533 async sub send_bytes {
443 3         7 my ($self, $bytes) = @_;
444              
445 3 100       9 croak "Cannot send on closed WebSocket" if $self->is_closed;
446              
447 2         11 await $self->{send}->({
448             type => 'websocket.send',
449             bytes => $bytes,
450             });
451              
452 2         61 return $self;
453             }
454              
455             # Send JSON-encoded message
456 6     6 1 443 async sub send_json {
457 6         27 my ($self, $data) = @_;
458              
459 6 100       15 croak "Cannot send on closed WebSocket" if $self->is_closed;
460              
461 5         37 my $json = JSON::MaybeXS::encode_json($data);
462              
463 5         18 await $self->{send}->({
464             type => 'websocket.send',
465             text => $json,
466             });
467              
468 5         122 return $self;
469             }
470              
471             # Safe send methods - return bool instead of throwing
472              
473 6     6 1 87 async sub try_send_text {
474 6         15 my ($self, $text) = @_;
475 6 100       12 return 0 if $self->is_closed;
476              
477 5         8 eval {
478 5         22 await $self->{send}->({
479             type => 'websocket.send',
480             text => $text,
481             });
482             };
483 5 100       401 if ($@) {
484 1         5 $self->_set_closed(1006, 'Connection lost');
485 1         5 return 0;
486             }
487 4         11 return 1;
488             }
489              
490 6     6 1 82 async sub try_send_bytes {
491 6         13 my ($self, $bytes) = @_;
492 6 100       13 return 0 if $self->is_closed;
493              
494 5         7 eval {
495 5         21 await $self->{send}->({
496             type => 'websocket.send',
497             bytes => $bytes,
498             });
499             };
500 5 100       374 if ($@) {
501 1         5 $self->_set_closed(1006, 'Connection lost');
502 1         4 return 0;
503             }
504 4         13 return 1;
505             }
506              
507 6     6 1 83 async sub try_send_json {
508 6         15 my ($self, $data) = @_;
509 6 100       10 return 0 if $self->is_closed;
510              
511 5         30 my $json = JSON::MaybeXS::encode_json($data);
512 5         8 eval {
513 5         21 await $self->{send}->({
514             type => 'websocket.send',
515             text => $json,
516             });
517             };
518 5 100       1289 if ($@) {
519 1         5 $self->_set_closed(1006, 'Connection lost');
520 1         54 return 0;
521             }
522 4         12 return 1;
523             }
524              
525             # Silent send methods - no-op when closed
526              
527 3     3 1 51 async sub send_text_if_connected {
528 3         6 my ($self, $text) = @_;
529 3 100       8 return unless $self->is_connected;
530 2         8 await $self->try_send_text($text);
531 2         71 return;
532             }
533              
534 3     3 1 51 async sub send_bytes_if_connected {
535 3         6 my ($self, $bytes) = @_;
536 3 100       8 return unless $self->is_connected;
537 2         9 await $self->try_send_bytes($bytes);
538 2         44 return;
539             }
540              
541 3     3 1 53 async sub send_json_if_connected {
542 3         7 my ($self, $data) = @_;
543 3 100       9 return unless $self->is_connected;
544 2         9 await $self->try_send_json($data);
545 2         43 return;
546             }
547              
548             # Receive methods
549              
550 69     69 1 286 async sub receive {
551 69         76 my ($self) = @_;
552              
553 69 100       131 return undef if $self->is_closed;
554              
555 64         86 while (1) {
556 99         159 my $event = await $self->{receive}->();
557              
558 98 100 66     2811 if (!defined($event) || $event->{type} eq 'websocket.disconnect') {
559             # 1005 = No Status Rcvd (RFC 6455)
560 27   100     71 my $code = $event->{code} // 1005;
561 27   100     71 my $reason = $event->{reason} // '';
562 27         72 $self->_set_closed($code, $reason);
563 27         52 await $self->_run_close_callbacks;
564 27         657 return undef;
565             }
566              
567             # Skip connect events - they're handled by accept()
568 71 100       214 next if $event->{type} eq 'websocket.connect';
569              
570 36         104 return $event;
571             }
572             }
573              
574 28     28 1 703 async sub receive_text {
575 28         34 my ($self) = @_;
576              
577 28         31 while (1) {
578 30         48 my $event = await $self->receive;
579 30 100       753 return undef unless $event;
580              
581             # Skip non-receive events and binary frames
582 20 50       38 next unless $event->{type} eq 'websocket.receive';
583 20 100       36 next unless exists $event->{text};
584              
585 18         44 return $event->{text};
586             }
587             }
588              
589 5     5 1 321 async sub receive_bytes {
590 5         9 my ($self) = @_;
591              
592 5         7 while (1) {
593 5         11 my $event = await $self->receive;
594 5 100       141 return undef unless $event;
595              
596             # Skip non-receive events and text frames
597 3 50       11 next unless $event->{type} eq 'websocket.receive';
598 3 50       8 next unless exists $event->{bytes};
599              
600 3         8 return $event->{bytes};
601             }
602             }
603              
604 4     4 1 387 async sub receive_json {
605 4         5 my ($self) = @_;
606              
607 4         10 my $text = await $self->receive_text;
608 4 100       84 return undef unless defined $text;
609              
610 3         43 return JSON::MaybeXS::decode_json($text);
611             }
612              
613             # Iteration helpers
614              
615 2     2 1 39 async sub each_message {
616 2         6 my ($self, $callback) = @_;
617              
618 2         9 while (my $event = await $self->receive) {
619 5 50       112 next unless $event->{type} eq 'websocket.receive';
620 5         10 await $callback->($event);
621             }
622              
623 2         58 return;
624             }
625              
626 6     6 1 113 async sub each_text {
627 6         11 my ($self, $callback) = @_;
628              
629 6         20 while (my $text = await $self->receive_text) {
630 9         225 await $callback->($text);
631             }
632              
633 5         130 return;
634             }
635              
636 1     1 1 2 async sub each_bytes {
637 1         2 my ($self, $callback) = @_;
638              
639 1         3 while (my $bytes = await $self->receive_bytes) {
640 1         23 await $callback->($bytes);
641             }
642              
643 1         21 return;
644             }
645              
646 3     3 1 34 async sub each_json {
647 3         8 my ($self, $callback) = @_;
648              
649 3         4 while (1) {
650 6         94 my $text = await $self->receive_text;
651 6 100       156 last unless defined $text;
652              
653 3         18 my $data = JSON::MaybeXS::decode_json($text);
654 3         94 await $callback->($data);
655             }
656              
657 3         7 return;
658             }
659              
660             # Callback-based event loop (alternative to each_* iteration)
661 9     9 1 49 async sub run {
662 9         16 my ($self) = @_;
663              
664 9         13 while (1) {
665 16         88 my $event = eval { await $self->receive };
  16         36  
666 16 100       641 if (my $err = $@) {
667 1         12 warn "PAGI::WebSocket receive error: $err";
668 1         6 last;
669             }
670 15 100       32 last unless $event;
671              
672 7 50       19 next unless $event->{type} eq 'websocket.receive';
673              
674 7   33     22 my $data = $event->{text} // $event->{bytes};
675              
676 7         8 for my $cb (@{$self->{_on_message}}) {
  7         16  
677 8         11 eval {
678 8         19 my $r = $cb->($data, $event);
679             # Await if callback returns a Future
680 5 100 66     55 if (blessed($r) && $r->isa('Future')) {
681 1         3 await $r;
682             }
683             };
684 8 100       47 if (my $err = $@) {
685 3         10 await $self->_trigger_error($err);
686             }
687             }
688             }
689              
690 9         18 return;
691             }
692              
693             # Keepalive support using WebSocket protocol-level ping/pong (RFC 6455)
694             # Sends websocket.keepalive event to server - loop-agnostic, server handles timers
695 6     6 1 20 async sub keepalive {
696 6         12 my ($self, $interval, $timeout) = @_;
697              
698 6   50     14 $interval //= 0;
699              
700 6         17 my $event = {
701             type => 'websocket.keepalive',
702             interval => $interval,
703             };
704 6 100       16 $event->{timeout} = $timeout if defined $timeout;
705              
706 6         17 await $self->{send}->($event);
707              
708 6         276 return $self;
709             }
710              
711             1;
712              
713             __END__