File Coverage

blib/lib/Dancer2/Plugin/WebSocket.pm
Criterion Covered Total %
statement 37 53 69.8
branch 2 8 25.0
condition n/a
subroutine 9 12 75.0
pod 3 3 100.0
total 51 76 67.1


line stmt bran cond sub pod time code
1             package Dancer2::Plugin::WebSocket;
2             our $AUTHORITY = 'cpan:YANICK';
3             # ABSTRACT: add a websocket interface to your Dancers app
4             $Dancer2::Plugin::WebSocket::VERSION = '0.3.1';
5              
6 1     1   20 use v5.12.0;
  1         4  
7              
8 1     1   562 use Plack::App::WebSocket;
  1         105943  
  1         47  
9              
10 1     1   742 use Dancer2::Plugin;
  1         16199  
  1         13  
11              
12             has serializer => (
13             is => 'ro',
14             from_config => 1,
15             coerce => sub {
16             my $serializer = shift or return undef;
17             require JSON::MaybeXS;
18             JSON::MaybeXS->new( ref $serializer ? %$serializer : () );
19             },
20             );
21              
22             has login => (
23             is => 'ro',
24             from_config => sub { 0 },
25             );
26              
27             has mount_path => (
28             is => 'ro',
29             from_config => sub { '/ws' },
30             );
31              
32              
33             has 'on_'.$_ => (
34             is => 'rw',
35             plugin_keyword => 'websocket_on_'.$_,
36             default => sub { sub { } },
37             ) for qw/
38             open
39             message
40             close
41             /;
42              
43             has 'on_error' => (
44             is => 'rw',
45             plugin_keyword => 'websocket_on_error',
46             default => sub { sub {
47             my $env = shift;
48             return [500,
49             ["Content-Type" => "text/plain"],
50             ["Error: " . $env->{"plack.app.websocket.error"}]];
51             }
52             },
53             );
54              
55             has 'on_login' => (
56             is => 'rw',
57             plugin_keyword => 'websocket_on_login',
58             default => sub { sub { } },
59             );
60              
61             has connections => (
62             is => 'ro',
63             default => sub{ {} },
64             );
65              
66              
67             sub websocket_connections :PluginKeyword {
68 0     0 1 0 my $self = shift;
69 0         0 return values %{ $self->connections };
  0         0  
70 1     1   4718 }
  1         4  
  1         20  
71              
72              
73             sub websocket_url :PluginKeyword {
74 0     0 1 0 my $self = shift;
75 0         0 my $request = $self->app->request;
76 0 0       0 my $proto = $request->secure ? 'wss://' : 'ws://';
77 0         0 my $address = $proto . $request->host . $self->mount_path;
78              
79 0         0 return $address;
80 1     1   777 }
  1         3  
  1         6  
81              
82              
83             sub websocket_mount :PluginKeyword {
84 1     1 1 10 my $self = shift;
85              
86             return
87             $self->mount_path => Plack::App::WebSocket->new(
88 0     0   0 on_error => sub { $self->on_error->(@_) },
89             on_establish => sub {
90 1     1   150005 my $conn = shift; ## Plack::App::WebSocket::Connection object
91 1         4 my $env = shift; ## PSGI env
92              
93 1 50       35 if ($self->login) {
94 0 0       0 if (!$self->on_login->($conn, $env)) {
95 0         0 return;
96             }
97             }
98              
99 1         10 require Moo::Role;
100              
101 1         14 Moo::Role->apply_roles_to_object(
102             $conn, 'Dancer2::Plugin::WebSocket::Connection'
103             );
104 1         3449 $conn->manager($self);
105 1         33 $conn->serializer($self->serializer);
106 1         74 $self->connections->{$conn->id} = $conn;
107              
108 1         12 $self->on_open->( $conn, $env, @_ );
109              
110             $conn->on(
111             message => sub {
112 1         6331 my( $conn, $message ) = @_;
113 1 50       18 if( my $s = $conn->serializer ) {
114 1         15 $message = $s->decode($message);
115             }
116 1     1   724 use Try::Tiny;
  1         4  
  1         256  
117             try {
118 1         77 $self->on_message->( $conn, $message );
119             }
120             catch {
121 0           warn $_;
122 0           die $_;
123 1         19 };
124             },
125             finish => sub {
126 0           $self->on_close->($conn);
127 0           delete $self->connections->{$conn->id};
128 0           $conn = undef;
129             },
130 1         26 );
131             }
132 1         32 )->to_app;
133              
134 1     1   8 }
  1         2  
  1         29  
135              
136              
137             1;
138              
139             __END__
140              
141             =pod
142              
143             =encoding UTF-8
144              
145             =head1 NAME
146              
147             Dancer2::Plugin::WebSocket - add a websocket interface to your Dancers app
148              
149             =head1 VERSION
150              
151             version 0.3.1
152              
153             =head1 SYNOPSIS
154              
155             F<bin/app.psgi>:
156              
157             #!/usr/bin/env perl
158              
159             use strict;
160             use warnings;
161              
162             use FindBin;
163             use lib "$FindBin::Bin/../lib";
164              
165             use Plack::Builder;
166              
167             use MyApp;
168              
169             builder {
170             mount( MyApp->websocket_mount );
171             mount '/' => MyApp->to_app;
172             }
173              
174             F<config.yml>:
175              
176             plugins:
177             WebSocket:
178             # default values
179             serializer: 0
180             login: 0
181             mount_path: /ws
182              
183             F<MyApp.pm>:
184              
185             package MyApp;
186              
187             use Dancer2;
188             use Dancer2::Plugin::WebSocket;
189              
190             websocket_on_message sub {
191             my( $conn, $message ) = @_;
192             $conn->send( $message . ' world!' );
193             };
194              
195             get '/' => sub {
196             my $ws_url = websocket_url;
197             return <<"END";
198             <html>
199             <head><script>
200             var urlMySocket = "$ws_url";
201              
202             var mySocket = new WebSocket(urlMySocket);
203              
204             mySocket.onmessage = function (evt) {
205             console.log( "Got message " + evt.data );
206             };
207              
208             mySocket.onopen = function(evt) {
209             console.log("opening");
210             setTimeout( function() {
211             mySocket.send('hello'); }, 2000 );
212             };
213              
214             </script></head>
215             <body><h1>WebSocket client</h1></body>
216             </html>
217             END
218             };
219              
220             get '/say_hi' => sub {
221             $_->send([ "Hello!" ]) for websocket_connections;
222             };
223              
224             true;
225              
226             =head1 DESCRIPTION
227              
228             C<Dancer2::Plugin::WebSocket> provides an interface to L<Plack::App::WebSocket>
229             and allows to interact with the webSocket connections within the Dancer app.
230              
231             L<Plack::App::WebSocket>, and thus this plugin, requires a plack server that
232             supports the psgi I<streaming>, I<nonblocking> and I<io>. L<Twiggy>
233             is the most popular server fitting the bill.
234              
235             =head1 CONFIGURATION
236              
237             =over
238              
239             =item serializer
240              
241             If serializer is set to a C<true> value, messages will be assumed to be JSON
242             objects and will be automatically encoded/decoded using a L<JSON::MaybeXS>
243             serializer. If the value of C<serializer> is a hash, it'll be passed as
244             arguments to the L<JSON::MaybeXS> constructor.
245              
246             plugins:
247             WebSocket:
248             serializer:
249             utf8: 1
250             allow_nonref: 1
251              
252             By the way, if you want the connection to automatically serialize data
253             structures to JSON on the client side, you can do something like
254              
255             var mySocket = new WebSocket(urlMySocket);
256             mySocket.sendJSON = function(message) {
257             return this.send(JSON.stringify(message))
258             };
259              
260             // then later...
261             mySocket.sendJSON({ whoa: "auto-serialization ftw!" });
262              
263             =item mount_path
264              
265             Path for the websocket mountpoint. Defaults to C</ws>.
266              
267             =back
268              
269             =head1 PLUGIN KEYWORDS
270              
271             In the various callbacks, the connection object C<$conn>
272             is a L<Plack::App::WebSocket::Connection> object
273             augmented with the L<Dancer2::Plugin::WebSocket::Connection> role.
274              
275             =head2 websocket_on_open sub { ... }
276              
277             websocket_on_open sub {
278             my( $conn, $env ) = @_;
279             ...;
280             };
281              
282             Code invoked when a new socket is opened. Gets the new
283             connection
284             object and the Plack
285             C<$env> hash as arguments.
286              
287             =head2 websocket_on_login sub { ... }
288              
289             websocket_on_login sub {
290             my( $conn, $env ) = @_;
291             ...;
292             };
293              
294             Code invoked when a new socket is opened. Gets the
295             connection object and the Plack C<$env> hash as arguments.
296              
297             Example: return true if user is logged in and the webapp http_cookie is the same as the websocket.
298              
299             my $login_conn;
300             my $cookie_name = 'example.session';
301              
302             hook before => sub {
303             if (defined cookies->{$cookie_name}) {
304             $login_conn->{'cookie_id'} = cookies->{$cookie_name}->value;
305             }
306             $login_conn->{'login'} = logged_in_user ? 1 : 0;
307             };
308              
309             websocket_on_login sub {
310             my( $conn, $env ) = @_;
311              
312             my ($cookie_id) = ($env->{'HTTP_COOKIE'} =~ /$cookie_name=(.*);?/g);
313             if (($login_conn->{'login'}) and ($login_conn->{'cookie_id'} eq $cookie_id)) {
314             return 1;
315             } else {
316             warn "require login";
317             return 0;
318             }
319             };
320              
321             =head2 websocket_on_close sub { ... }
322              
323             websocket_on_close sub {
324             my( $conn ) = @_;
325             ...;
326             };
327              
328             Code invoked when a new socket is opened. Gets the
329             connection object as argument.
330              
331             =head2 websocket_on_error sub { ... }
332              
333             websocket_on_error sub {
334             my( $env ) = @_;
335             ...;
336             };
337              
338             Code invoked when an error is detected. Gets the Plack
339             C<$env> hash as argument and is expected to return a
340             Plack triplet.
341              
342             If not explicitly set, defaults to
343              
344             websocket_on_error sub {
345             my $env = shift;
346             return [
347             500,
348             ["Content-Type" => "text/plain"],
349             ["Error: " . $env->{"plack.app.websocket.error"}]
350             ];
351             };
352              
353             =head2 websocket_on_message sub { ... }
354              
355             websocket_on_message sub {
356             my( $conn, $message ) = @_;
357             ...;
358             };
359              
360             Code invoked when a message is received. Gets the connection
361             object and the message as arguments.
362              
363             Note that while C<websocket_on_message> fires for all messages receives, you can
364             also be a little more selective. Indeed, each connection, being a L<Plack::App::WebSocket::Connection>
365             object, can have its own (multiple) handlers. So you can do things like
366              
367             websocket_on_open sub {
368             my( $conn, $env ) = @_;
369             $conn->on( message => sub {
370             my( $conn, $message ) = @_;
371             warn "I'm only being executed for messages sent via this connection";
372             });
373             };
374              
375             =head2 websocket_connections
376              
377             Returns the list of currently open websocket connections.
378              
379             =head2 websocket_url
380              
381             Returns the full url of the websocket mountpoint.
382              
383             # assuming host is 'localhost:5000'
384             # and the mountpoint is '/ws'
385             print websocket_url; # => ws://localhost:5000/ws
386              
387             =head2 websocket_mount
388              
389             Returns the mountpoint and the Plack app coderef to be
390             used for C<mount> in F<app.psgi>. See the SYNOPSIS.
391              
392             =head1 GOTCHAS
393              
394             It seems that the closing the socket causes Google's chrome to burp the
395             following to the console:
396              
397             WebSocket connection to 'ws://...' failed: Received a broken close frame containing a reserved status code.
398              
399             Firefox seems to be happy, though. The issue is probably somewhere deep in
400             L<AnyEvent::WebSocket::Server>. Since the socket is being closed anyway, I am
401             not overly worried about it.
402              
403             =head1 SEE ALSO
404              
405             This plugin is nothing much than a sugar topping atop
406             L<Plack::App::WebSocket>, which is itself L<AnyEvent::WebSocket::Server>
407             wrapped in Plackstic.
408              
409             Mojolicious also has nice WebSocket-related offerings. See
410             L<Mojolicious::Plugin::MountPSGI> or
411             L<http://mojolicious.org/perldoc/Mojolicious/Guides/Cookbook#Web-server-embedding>.
412             (hi Joel!)
413              
414             =head1 AUTHOR
415              
416             Yanick Champoux <yanick@cpan.org>
417              
418             =head1 COPYRIGHT AND LICENSE
419              
420             This software is copyright (c) 2021, 2019, 2017 by Yanick Champoux.
421              
422             This is free software; you can redistribute it and/or modify it under
423             the same terms as the Perl 5 programming language system itself.
424              
425             =cut