├── .gitignore ├── lib └── Cro │ ├── WebSocket │ ├── Message │ │ └── Opcode.rakumod │ ├── Frame.rakumod │ ├── BodyParsers.rakumod │ ├── BodySerializers.rakumod │ ├── Internal.rakumod │ ├── MessageParser.rakumod │ ├── Message.rakumod │ ├── FrameSerializer.rakumod │ ├── MessageSerializer.rakumod │ ├── FrameParser.rakumod │ ├── Handler.rakumod │ ├── Client.rakumod │ └── Client │ │ └── Connection.rakumod │ └── HTTP │ └── Router │ └── WebSocket.rakumod ├── Changes ├── .github ├── pull_request_template.md └── workflows │ └── ci.yml ├── README.md ├── perf ├── masking-perf.raku ├── handler-perf.raku ├── message-serializer-perf.raku ├── message-parser-perf.raku ├── router-perf.raku ├── frame-serializer-perf.raku └── frame-parser-perf.raku ├── t ├── websocket-message.rakutest ├── websocket-handler.rakutest ├── websocket-message-parser.rakutest ├── websocket-message-serializer.rakutest ├── http-router-websocket.rakutest ├── websocket-frame-serializer.rakutest └── websocket-frame-parser.rakutest ├── META6.json ├── xt ├── certs-and-keys │ ├── ca-crt.pem │ ├── server-crt.pem │ └── server-key.pem └── websocket-client.rakutest └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | .precomp/ 2 | *.swp 3 | -------------------------------------------------------------------------------- /lib/Cro/WebSocket/Message/Opcode.rakumod: -------------------------------------------------------------------------------- 1 | package Cro::WebSocket::Message { 2 | enum Opcode is export (:Text(1), :Binary(2), :Ping(9), :Pong(10), :Close(8)); 3 | } 4 | -------------------------------------------------------------------------------- /Changes: -------------------------------------------------------------------------------- 1 | Revision history for Cro::WebSocket 2 | 3 | 0.8.10 4 | - Change dependency management to allow individual Cro modules to be 5 | updated individually. 6 | - Rename all `.pm6` files to `.rakumod` and `.t` files to `.rakutest`. 7 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | Checklist 2 | ========= 3 | (Feel free to delete this text once you went through it.) 4 | 5 | - Did you add an entry to the `Changes` file? 6 | - If there is no `{{NEXT}}` above the latest released version in `Changes`, add 7 | it. 8 | 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cro::WebSocket ![Build Status](https://github.com/croservices/cro-websocket/actions/workflows/ci.yml/badge.svg) 2 | 3 | This is part of the Cro libraries for implementing services and distributed 4 | systems in Raku. See the [Cro website](http://cro.services/) for further 5 | information and documentation. 6 | -------------------------------------------------------------------------------- /lib/Cro/WebSocket/Frame.rakumod: -------------------------------------------------------------------------------- 1 | use Cro::Message; 2 | 3 | class Cro::WebSocket::Frame does Cro::Message { 4 | enum Opcode (:Continuation(0), 5 | :Text(1), :Binary(2), 6 | :Close(8), :Ping(9), :Pong(10)); 7 | 8 | has Bool $.fin is rw; 9 | has Opcode $.opcode is rw; 10 | has Blob $.payload is rw; 11 | 12 | method trace-output(--> Str) { 13 | "WebSocket Frame - {$!opcode // 'Opcode unset'}\n" ~ self!trace-blob($!payload).indent(2); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /perf/masking-perf.raku: -------------------------------------------------------------------------------- 1 | use Crypt::Random; 2 | 3 | my $mask-buf = crypt_random_buf(4); 4 | my $masked = crypt_random_buf(65536); 5 | my $repeat = @*ARGS[0] // 100; 6 | 7 | my $t0 = now; 8 | 9 | for ^$repeat { 10 | my $payload = Blob.new((@($masked) Z+^ ((@$mask-buf xx *).flat)).Array); 11 | # say $payload; 12 | } 13 | 14 | my $t1 = now; 15 | 16 | for ^$repeat { 17 | # my $payload = Blob.new((@($masked) Z+^ ((@$mask-buf xx *).flat)).Array); 18 | # my $payload = Blob.new(@$masked >>+^>> @$mask-buf); 19 | my $expanded = Blob.allocate($masked.elems, $mask-buf); 20 | my $payload = Blob.new($masked ~^ $expanded); 21 | # say $payload; 22 | } 23 | 24 | my $t2 = now; 25 | 26 | 27 | printf "BASE: %6d in %.3fs = %.3fms ave\n", 28 | $repeat, $t1 - $t0, 1000 * ($t1 - $t0) / $repeat; 29 | printf "NEW: %6d in %.3fs = %.3fms ave\n", 30 | $repeat, $t2 - $t1, 1000 * ($t2 - $t1) / $repeat; 31 | -------------------------------------------------------------------------------- /lib/Cro/WebSocket/BodyParsers.rakumod: -------------------------------------------------------------------------------- 1 | use Cro::BodyParser; 2 | use JSON::Fast; 3 | 4 | class Cro::WebSocket::BodyParser::Text does Cro::BodyParser { 5 | method is-applicable($message) { 6 | $message.is-text 7 | } 8 | 9 | method parse($message) { 10 | $message.body-text 11 | } 12 | } 13 | 14 | class Cro::WebSocket::BodyParser::Binary does Cro::BodyParser { 15 | method is-applicable($message) { 16 | True 17 | } 18 | 19 | method parse($message) { 20 | $message.body-blob 21 | } 22 | } 23 | 24 | class Cro::WebSocket::BodyParser::JSON does Cro::BodyParser { 25 | method is-applicable($message) { 26 | # We presume that if this body parser has been installed, then we will 27 | # always be doing JSON 28 | True 29 | } 30 | 31 | method parse($message) { 32 | $message.body-blob.then: -> $blob-promise { 33 | from-json $blob-promise.result.decode('utf-8') 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /t/websocket-message.rakutest: -------------------------------------------------------------------------------- 1 | use Cro::WebSocket::Message; 2 | use Test; 3 | 4 | my $message = Cro::WebSocket::Message.new('Meteor'); 5 | is $message.is-text, True, 'Is text'; 6 | is $message.is-data, True, 'Is data'; 7 | is await($message.body-text), 'Meteor', 'Body is passed'; 8 | 9 | $message = Cro::WebSocket::Message.new(Buf.new('Meteor'.encode('utf-8'))); 10 | is $message.is-binary, True, 'Is binary'; 11 | is $message.is-data, True, 'Is data'; 12 | throws-like { await($message.body-text) }, X::Cro::BodyNotText, 13 | 'Binary message cannot have body-text called on it'; 14 | is await($message.body-blob), 'Meteor'.encode('utf-8'), 'Body can be get as blob'; 15 | 16 | my $supplier = Supplier.new; 17 | my $supply = $supplier.Supply; 18 | $message = Cro::WebSocket::Message.new($supply); 19 | my Int $counter = 0; 20 | $message.body-byte-stream.tap(-> $value { is $value, $counter, "Checked $counter"; $counter++; }); 21 | $supplier.emit(0); 22 | $supplier.emit(1); 23 | $supplier.emit(2); 24 | 25 | done-testing; 26 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' 7 | tags-ignore: 8 | - '*' 9 | pull_request: 10 | 11 | jobs: 12 | raku: 13 | strategy: 14 | matrix: 15 | os: 16 | - ubuntu-latest 17 | # - macOS-latest 18 | # - windows-latest 19 | raku-version: 20 | - "2022.04" 21 | - "latest" 22 | runs-on: ${{ matrix.os }} 23 | steps: 24 | - uses: actions/checkout@v2 25 | - uses: Raku/setup-raku@v1 26 | with: 27 | raku-version: ${{ matrix.raku-version }} 28 | - run: zef install https://github.com/croservices/cro-core/archive/master.zip 29 | - run: zef install https://github.com/croservices/cro-tls/archive/master.zip 30 | - run: zef install https://github.com/croservices/cro-http/archive/master.zip 31 | - name: Setup dependencies 32 | run: raku -v && zef install --deps-only . 33 | - name: Test code 34 | run: prove -e 'raku -Ilib' t/ xt/ 35 | -------------------------------------------------------------------------------- /lib/Cro/WebSocket/BodySerializers.rakumod: -------------------------------------------------------------------------------- 1 | use Cro::BodySerializer; 2 | use Cro::WebSocket::Message::Opcode; 3 | use JSON::Fast; 4 | 5 | class Cro::WebSocket::BodySerializer::Text does Cro::BodySerializer { 6 | method is-applicable($message, $body) { 7 | $body ~~ Str 8 | } 9 | 10 | method serialize($message, $body) { 11 | $message.opcode = Text; 12 | supply emit $body.encode('utf-8') 13 | } 14 | } 15 | 16 | class Cro::WebSocket::BodySerializer::Binary does Cro::BodySerializer { 17 | method is-applicable($message, $body) { 18 | $body ~~ Blob 19 | } 20 | 21 | method serialize($message, $blob) { 22 | $message.opcode = Binary; 23 | supply emit $blob 24 | } 25 | } 26 | 27 | class Cro::WebSocket::BodySerializer::JSON does Cro::BodySerializer { 28 | method is-applicable($message, $body) { 29 | # We presume that if this body serializer has been installed, then we 30 | # will always be doing JSON 31 | True 32 | } 33 | 34 | method serialize($message, $body) { 35 | $message.opcode = Text; 36 | supply emit to-json($body).encode('utf-8') 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/Cro/WebSocket/Internal.rakumod: -------------------------------------------------------------------------------- 1 | use Cro::BodyParserSelector; 2 | use Cro::BodySerializerSelector; 3 | use Cro::Transform; 4 | use Cro::WebSocket::Message; 5 | 6 | my class SetBodyParsers does Cro::Transform is export { 7 | has $!selector; 8 | 9 | method BUILD(:$body-parsers --> Nil) { 10 | $!selector = Cro::BodyParserSelector::List.new: 11 | parsers => $body-parsers.list; 12 | } 13 | 14 | method consumes() { Cro::WebSocket::Message } 15 | method produces() { Cro::WebSocket::Message } 16 | 17 | method transformer(Supply $in --> Supply) { 18 | supply whenever $in { 19 | .body-parser-selector = $!selector; 20 | .emit; 21 | } 22 | } 23 | } 24 | 25 | my class SetBodySerializers does Cro::Transform is export { 26 | has $!selector; 27 | 28 | method BUILD(:$body-serializers --> Nil) { 29 | $!selector = Cro::BodySerializerSelector::List.new: 30 | serializers => $body-serializers.list; 31 | } 32 | 33 | method consumes() { Cro::WebSocket::Message } 34 | method produces() { Cro::WebSocket::Message } 35 | 36 | method transformer(Supply $in --> Supply) { 37 | supply whenever $in { 38 | .body-serializer-selector = $!selector; 39 | .emit; 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /perf/handler-perf.raku: -------------------------------------------------------------------------------- 1 | use Cro::WebSocket::Handler; 2 | use Cro::WebSocket::Message; 3 | use Cro::WebSocket::Message::Opcode; 4 | 5 | my $uc-ws = Cro::WebSocket::Handler.new( 6 | -> $incoming { 7 | supply { 8 | whenever $incoming -> $message { 9 | my $body = await $message.body-text; 10 | emit Cro::WebSocket::Message.new($body.uc); 11 | } 12 | } 13 | } 14 | ); 15 | 16 | multi make-message(Str:D $message) { 17 | Cro::WebSocket::Message.new($message) 18 | } 19 | 20 | multi make-message($opcode, &generate) { 21 | Cro::WebSocket::Message.new(:$opcode, :!fragmented, body-byte-stream => generate()) 22 | } 23 | 24 | my @messages = 25 | \('First Test'), 26 | \('Second Test'), 27 | \(Ping, { supply { emit 'ping'.encode } }), 28 | \(Close, { supply { emit Blob.new(3, 232) } }), 29 | ; 30 | 31 | my $repeat = @*ARGS[0] // 10_000; 32 | my @tests = @messages.map: -> $c { |( make-message(|$c) for ^$repeat) }; 33 | my $fake-in = Supplier.new; 34 | my $complete = Promise.new; 35 | my atomicint $i = 0; 36 | 37 | my $t0 = now; 38 | 39 | $uc-ws.transformer($fake-in.Supply).tap: -> $resp { 40 | die "Did not respond with a WebSocket message" 41 | unless $resp ~~ Cro::WebSocket::Message; 42 | $complete.keep if ++⚛$i == @tests; 43 | } 44 | start { 45 | for @tests { $fake-in.emit($_) } 46 | $fake-in.done; 47 | } 48 | await $complete; 49 | 50 | my $t1 = now; 51 | my $delta = $t1 - $t0; 52 | 53 | printf "RESPONSES: %6d in %.3fs = %.3fms ave\n", 54 | +@tests, $delta, 1000 * $delta / @tests; 55 | -------------------------------------------------------------------------------- /perf/message-serializer-perf.raku: -------------------------------------------------------------------------------- 1 | use Cro::WebSocket::MessageSerializer; 2 | use Cro::WebSocket::Message; 3 | use Cro::WebSocket::Message::Opcode; 4 | 5 | 6 | multi make-message(Str:D $message) { 7 | Cro::WebSocket::Message.new($message) 8 | } 9 | 10 | multi make-message($opcode, &generate, :$fragmented = $opcode <= Binary) { 11 | Cro::WebSocket::Message.new(:$opcode, :$fragmented, body-byte-stream => generate()) 12 | } 13 | 14 | my @random-data = 255.rand.Int xx 65536; 15 | 16 | my @messages = 17 | \('First Test'), 18 | \('Second Test'), 19 | \(Text, { supply { emit 'Hel'.encode; emit 'lo'.encode; done; } }), 20 | \(Ping, { supply { emit 'Ping'.encode; done; } }), 21 | \(Close, { supply { emit Blob.new(3, 232); done } }), 22 | \(Binary, { supply { emit Blob.new(@random-data); done } }), 23 | ; 24 | 25 | 26 | my $repeat = @*ARGS[0] // 1_000; 27 | my $expected = 8; 28 | my $frames = $repeat * $expected; 29 | my @tests = @messages.map: -> $c { |( make-message(|$c) for ^$repeat) }; 30 | my $serializer = Cro::WebSocket::MessageSerializer.new; 31 | my $fake-in = Supplier.new; 32 | my $complete = Promise.new; 33 | my atomicint $i = 0; 34 | 35 | my $t0 = now; 36 | 37 | $serializer.transformer($fake-in.Supply).tap: -> $frame { 38 | die "Did not parse as a WebSocket frame" unless $frame ~~ Cro::WebSocket::Frame; 39 | $complete.keep if ++⚛$i == $frames; 40 | } 41 | start { 42 | for @tests { $fake-in.emit($_) } 43 | $fake-in.done; 44 | } 45 | await $complete; 46 | 47 | my $t1 = now; 48 | my $delta = $t1 - $t0; 49 | 50 | printf "MESSAGES: %6d in %.3fs = %.3fms ave\n", 51 | +@tests, $delta, 1000 * $delta / @tests; 52 | printf "FRAMES: %6d in %.3fs = %.3fms ave\n", 53 | $frames, $delta, 1000 * $delta / $frames; 54 | -------------------------------------------------------------------------------- /META6.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Cro::WebSocket", 3 | "description": "WebSocket client, and WebSocket support in the Cro HTTP router.", 4 | "version": "0.8.10", 5 | "api": "0", 6 | "auth": "zef:cro", 7 | "perl": "6.*", 8 | "authors": [ 9 | "Jonathan Worthington " 10 | ], 11 | "depends": [ 12 | "Cro::HTTP:ver<0.8.10+>:api<0>:auth", 13 | "Base64", 14 | "Digest::SHA1::Native", 15 | "Crypt::Random", 16 | "JSON::Fast", 17 | "OO::Monitors" 18 | ], 19 | "provides": { 20 | "Cro::HTTP::Router::WebSocket": "lib/Cro/HTTP/Router/WebSocket.rakumod", 21 | "Cro::WebSocket::BodyParsers": "lib/Cro/WebSocket/BodyParsers.rakumod", 22 | "Cro::WebSocket::BodySerializers": "lib/Cro/WebSocket/BodySerializers.rakumod", 23 | "Cro::WebSocket::Client": "lib/Cro/WebSocket/Client.rakumod", 24 | "Cro::WebSocket::Client::Connection": "lib/Cro/WebSocket/Client/Connection.rakumod", 25 | "Cro::WebSocket::Frame": "lib/Cro/WebSocket/Frame.rakumod", 26 | "Cro::WebSocket::FrameParser": "lib/Cro/WebSocket/FrameParser.rakumod", 27 | "Cro::WebSocket::FrameSerializer": "lib/Cro/WebSocket/FrameSerializer.rakumod", 28 | "Cro::WebSocket::Handler": "lib/Cro/WebSocket/Handler.rakumod", 29 | "Cro::WebSocket::Internal": "lib/Cro/WebSocket/Internal.rakumod", 30 | "Cro::WebSocket::Message": "lib/Cro/WebSocket/Message.rakumod", 31 | "Cro::WebSocket::Message::Opcode": "lib/Cro/WebSocket/Message/Opcode.rakumod", 32 | "Cro::WebSocket::MessageParser": "lib/Cro/WebSocket/MessageParser.rakumod", 33 | "Cro::WebSocket::MessageSerializer": "lib/Cro/WebSocket/MessageSerializer.rakumod" 34 | }, 35 | "resources": [], 36 | "license": "Artistic-2.0", 37 | "tags": [ 38 | "WebSocket", 39 | "Client", 40 | "Server" 41 | ], 42 | "source-url": "https://github.com/croservices/cro-websocket.git" 43 | } 44 | -------------------------------------------------------------------------------- /lib/Cro/WebSocket/MessageParser.rakumod: -------------------------------------------------------------------------------- 1 | use Cro::Transform; 2 | use Cro::WebSocket::Frame; 3 | use Cro::WebSocket::Message; 4 | use Cro::WebSocket::Message::Opcode; 5 | 6 | class Cro::WebSocket::MessageParser does Cro::Transform { 7 | method consumes() { Cro::WebSocket::Frame } 8 | method produces() { Cro::WebSocket::Message } 9 | 10 | method transformer(Supply:D $in) { 11 | supply { 12 | my $last; 13 | whenever $in -> Cro::WebSocket::Frame $frame { 14 | my $opcode = $frame.opcode; 15 | if $frame.fin { 16 | # Single frame message 17 | if $opcode { 18 | emit Cro::WebSocket::Message.new: 19 | opcode => Cro::WebSocket::Message::Opcode($opcode.value), 20 | :!fragmented, body-byte-stream => supply emit $frame.payload; 21 | } 22 | # Final frame of a fragmented message 23 | else { 24 | $last.emit($frame.payload); 25 | $last.done; 26 | } 27 | } 28 | else { 29 | # First frame of a fragmented message 30 | if $opcode { 31 | $last = Supplier::Preserving.new; 32 | $last.emit($frame.payload); 33 | emit Cro::WebSocket::Message.new: 34 | opcode => Cro::WebSocket::Message::Opcode($opcode.value), 35 | :fragmented, body-byte-stream => $last.Supply; 36 | } 37 | # Continuation frame 38 | else { 39 | $last.emit($frame.payload); 40 | } 41 | } 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/Cro/WebSocket/Message.rakumod: -------------------------------------------------------------------------------- 1 | use Cro::BodyParserSelector; 2 | use Cro::BodySerializerSelector; 3 | use Cro::MessageWithBody; 4 | use Cro::WebSocket::BodyParsers; 5 | use Cro::WebSocket::BodySerializers; 6 | use Cro::WebSocket::Message::Opcode; 7 | 8 | class Cro::WebSocket::Message does Cro::MessageWithBody { 9 | has Cro::WebSocket::Message::Opcode $.opcode is rw; 10 | has Bool $.fragmented; 11 | has Cro::BodyParserSelector $.body-parser-selector is rw = 12 | Cro::BodyParserSelector::List.new: 13 | :parsers[ 14 | Cro::WebSocket::BodyParser::Text, 15 | Cro::WebSocket::BodyParser::Binary 16 | ]; 17 | has Cro::BodySerializerSelector $.body-serializer-selector is rw = 18 | Cro::BodySerializerSelector::List.new: 19 | :serializers[ 20 | Cro::WebSocket::BodySerializer::Text, 21 | Cro::WebSocket::BodySerializer::Binary 22 | ]; 23 | has Promise $.serialization-outcome is rw; 24 | 25 | multi method new(Supply $body-byte-stream) { 26 | self.bless: :opcode(Binary), :fragmented, :$body-byte-stream; 27 | } 28 | multi method new($body) { 29 | self.bless: 30 | :opcode($body ~~ Str ?? Text !! Binary), 31 | :fragmented($body !~~ Str && $body !~~ Blob), 32 | :$body; 33 | } 34 | 35 | submethod TWEAK(:$body-byte-stream, :$body) { 36 | with $body-byte-stream { 37 | self.set-body-byte-stream($body-byte-stream); 38 | } 39 | orwith $body { 40 | self.set-body($body); 41 | } 42 | } 43 | 44 | method is-text() { $!opcode == Text } 45 | method is-binary() { $!opcode == Binary } 46 | method is-data() { $!opcode == Text | Binary } 47 | 48 | method body-text-encoding(Blob $blob) { self.is-text ?? 'utf-8' !! Nil } 49 | 50 | method trace-output(--> Str) { 51 | "WebSocket Message - {$!opcode // 'Opcode Unset'}\n"; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /lib/Cro/WebSocket/FrameSerializer.rakumod: -------------------------------------------------------------------------------- 1 | use Cro::TCP; 2 | use Cro::WebSocket::Frame; 3 | use Cro::Transform; 4 | use Crypt::Random; 5 | 6 | class Cro::WebSocket::FrameSerializer does Cro::Transform { 7 | has Bool $.mask; 8 | 9 | method consumes() { Cro::WebSocket::Frame } 10 | method produces() { Cro::TCP::Message } 11 | 12 | method transformer(Supply:D $in) { 13 | supply { 14 | whenever $in -> Cro::WebSocket::Frame $frame { 15 | my $message = Buf.new; 16 | 17 | # Fin flag and opcode 18 | $message[0] = ($frame.fin ?? 128 !! 0) + $frame.opcode.value; 19 | 20 | # Mask flag and payload length 21 | my $payload-len = $frame.payload.elems; 22 | my $pos; 23 | if $payload-len < 126 { 24 | $message[1] = ($!mask ?? 128 !! 0) + $payload-len; 25 | } 26 | elsif $payload-len < 65536 { 27 | $message[1] = $!mask ?? 254 !! 126; 28 | $message.write-uint16(2, $payload-len, BigEndian); 29 | } 30 | elsif $payload-len < 2 ** 63 { 31 | $message[1] = $!mask ?? 255 !! 127; 32 | $message.write-uint64(2, $payload-len, BigEndian); 33 | } 34 | else { 35 | die "Payload length $payload-len too large for a WebSocket frame"; 36 | } 37 | 38 | # Mask and payload 39 | if $!mask { 40 | my $mask-buf = crypt_random_buf(4); 41 | $message.append: $mask-buf; 42 | my $payload = $frame.payload ~^ Blob.allocate($frame.payload.elems, $mask-buf); 43 | emit Cro::TCP::Message.new(data => $message.append: $payload); 44 | } else { 45 | emit Cro::TCP::Message.new(data => $message.append: $frame.payload); 46 | } 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /perf/message-parser-perf.raku: -------------------------------------------------------------------------------- 1 | use Cro::WebSocket::MessageParser; 2 | 3 | constant Cont = Cro::WebSocket::Frame::Continuation; 4 | constant Text = Cro::WebSocket::Frame::Text; 5 | constant Binary = Cro::WebSocket::Frame::Binary; 6 | constant Close = Cro::WebSocket::Frame::Close; 7 | constant Ping = Cro::WebSocket::Frame::Ping; 8 | constant Pong = Cro::WebSocket::Frame::Pong; 9 | 10 | multi make-frame($opcode, Str:D $payload, Bool:D $fin = $opcode >= Close) { 11 | Cro::WebSocket::Frame.new(:$opcode, :$fin, payload => $payload.encode) 12 | } 13 | 14 | multi make-frame($opcode, $payload, Bool:D $fin = $opcode >= Close) { 15 | Cro::WebSocket::Frame.new(:$opcode, :$fin, payload => Blob.new($payload)) 16 | } 17 | 18 | my @random-data = 255.rand.Int xx 65536; 19 | 20 | my @frames = 21 | \(Text, 'Hello', True), 22 | \(Text, 'Hel'), 23 | \(Cont, 'lo', True), 24 | \(Ping, 'Hello'), 25 | \(Binary, @random-data[0..75]), 26 | \(Cont, @random-data[75^..^173]), 27 | \(Cont, @random-data[173..255], True), 28 | \(Binary, @random-data[0..255], True), 29 | \(Binary, @random-data, True), 30 | ; 31 | 32 | my $repeat = @*ARGS[0] // 10_000; 33 | my $expected = 6; 34 | my $messages = $repeat * $expected; 35 | my @tests = @frames.map: -> $c { |( make-frame(|$c) for ^$repeat) }; 36 | my $parser = Cro::WebSocket::MessageParser.new; 37 | my $fake-in = Supplier.new; 38 | my $complete = Promise.new; 39 | my atomicint $i = 0; 40 | 41 | my $t0 = now; 42 | 43 | $parser.transformer($fake-in.Supply).schedule-on($*SCHEDULER).tap: -> $message { 44 | die "Did not parse as a WebSocket message" unless $message ~~ Cro::WebSocket::Message; 45 | $complete.keep if ++⚛$i == $messages; 46 | } 47 | start { 48 | for @tests { $fake-in.emit($_) } 49 | $fake-in.done; 50 | } 51 | await $complete; 52 | 53 | my $t1 = now; 54 | my $delta = $t1 - $t0; 55 | 56 | printf "FRAMES: %6d in %.3fs = %.3fms ave\n", 57 | +@tests, $delta, 1000 * $delta / @tests; 58 | printf "MESSAGES: %6d in %.3fs = %.3fms ave\n", 59 | $messages, $delta, 1000 * $delta / $messages; 60 | -------------------------------------------------------------------------------- /xt/certs-and-keys/ca-crt.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | 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 32 | -----END CERTIFICATE----- 33 | -------------------------------------------------------------------------------- /perf/router-perf.raku: -------------------------------------------------------------------------------- 1 | use Cro::HTTP::Client; 2 | use Cro::HTTP::Router::WebSocket; 3 | use Cro::HTTP::Router; 4 | use Cro::HTTP::Server; 5 | use Cro::WebSocket::BodyParsers; 6 | use Cro::WebSocket::BodySerializers; 7 | use Cro::WebSocket::Client; 8 | use JSON::Fast; 9 | 10 | my $application = route { 11 | get -> 'chat' { 12 | web-socket -> $incoming { 13 | supply { 14 | whenever $incoming -> $message { 15 | emit('You said: ' ~ await $message.body-text); 16 | } 17 | } 18 | } 19 | } 20 | 21 | get -> 'json' { 22 | web-socket :json, -> $incoming { 23 | supply whenever $incoming -> $message { 24 | my $body = await $message.body; 25 | $body = 4242; 26 | $body++; 27 | emit $body; 28 | } 29 | } 30 | } 31 | } 32 | 33 | my $port = 3006; 34 | my $http-server = Cro::HTTP::Server.new(:$port, :$application); 35 | $http-server.start; 36 | END $http-server.stop; 37 | 38 | my $repeat = @*ARGS[0] // 1; 39 | my $json = to-json({ updated => 99, kept => 'xxx' }); 40 | my $plain-client = await Cro::WebSocket::Client.connect: "http://localhost:$port/chat"; 41 | my $json-client = await Cro::WebSocket::Client.connect: "http://localhost:$port/json"; 42 | my $plain-complete = Promise.new; 43 | my $json-complete = Promise.new; 44 | my atomicint $i = 0; 45 | my atomicint $j = 0; 46 | 47 | my $t0 = now; 48 | 49 | $plain-client.messages.tap: -> $message { 50 | my $text = await $message.body-text; 51 | $plain-complete.keep if ++⚛$i == $repeat; 52 | } 53 | start { 54 | $plain-client.send('Hello') for ^$repeat; 55 | } 56 | await $plain-complete; 57 | 58 | my $t1 = now; 59 | 60 | $json-client.messages.tap: -> $message { 61 | say 1; 62 | my $promise = $message.body-text; 63 | say 2; 64 | my $json = await $promise; 65 | say 3; 66 | $json-complete.keep if ++⚛$j == $repeat; 67 | } 68 | start { 69 | $json-client.send($json) for ^$repeat; 70 | } 71 | await $json-complete; 72 | 73 | my $t2 = now; 74 | 75 | $plain-client.close; 76 | $json-client.close; 77 | 78 | printf "PLAIN: %6d in %.3fs = %.3fms ave\n", 79 | $repeat, $t1 - $t0, 1000 * ($t1 - $t0) / $repeat; 80 | printf "JSON: %6d in %.3fs = %.3fms ave\n", 81 | $repeat, $t2 - $t1, 1000 * ($t2 - $t1) / $repeat; 82 | -------------------------------------------------------------------------------- /xt/certs-and-keys/server-crt.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | 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 37 | -----END CERTIFICATE----- 38 | -------------------------------------------------------------------------------- /perf/frame-serializer-perf.raku: -------------------------------------------------------------------------------- 1 | use Cro::TCP; 2 | use Cro::WebSocket::FrameSerializer; 3 | use Cro::WebSocket::Frame; 4 | 5 | constant Cont = Cro::WebSocket::Frame::Continuation; 6 | constant Text = Cro::WebSocket::Frame::Text; 7 | constant Binary = Cro::WebSocket::Frame::Binary; 8 | constant Close = Cro::WebSocket::Frame::Close; 9 | constant Ping = Cro::WebSocket::Frame::Ping; 10 | constant Pong = Cro::WebSocket::Frame::Pong; 11 | 12 | multi make-frame($opcode, Str:D $payload, Bool:D $fin = $opcode >= Close) { 13 | Cro::WebSocket::Frame.new(:$opcode, :$fin, payload => $payload.encode) 14 | } 15 | 16 | multi make-frame($opcode, $payload, Bool:D $fin = $opcode >= Close) { 17 | Cro::WebSocket::Frame.new(:$opcode, :$fin, payload => Blob.new($payload)) 18 | } 19 | 20 | my @random-data = 255.rand.Int xx 65536; 21 | 22 | my @frames = 23 | \(Text, 'Hello', True), 24 | \(Text, 'Hel'), 25 | \(Cont, 'lo'), 26 | \(Ping, 'Hello'), 27 | \(Pong, 'Hello'), 28 | \(Binary, @random-data[^256], True), 29 | \(Binary, @random-data[^32768], True), 30 | \(Binary, @random-data, True), 31 | ; 32 | 33 | my $repeat = @*ARGS[0] // 1_000; 34 | my @tests = @frames.map: -> $c { |( make-frame(|$c) for ^$repeat) }; 35 | my $serial-clear = Cro::WebSocket::FrameSerializer.new(:!mask); 36 | my $serial-masked = Cro::WebSocket::FrameSerializer.new( :mask); 37 | my $clear-in = Supplier.new; 38 | my $masked-in = Supplier.new; 39 | my $clear-complete = Promise.new; 40 | my $masked-complete = Promise.new; 41 | my atomicint $i = 0; 42 | my atomicint $j = 0; 43 | 44 | my $t0 = now; 45 | 46 | $serial-clear.transformer($clear-in.Supply).schedule-on($*SCHEDULER).tap: -> $message { 47 | die "Did not serialize as a TCP message" unless $message ~~ Cro::TCP::Message; 48 | $clear-complete.keep if ++⚛$i == @tests; 49 | } 50 | start { 51 | for @tests { $clear-in.emit($_) } 52 | $clear-in.done; 53 | } 54 | await $clear-complete; 55 | 56 | my $t1 = now; 57 | 58 | $serial-masked.transformer($masked-in.Supply).schedule-on($*SCHEDULER).tap: -> $message { 59 | die "Did not serialize as a TCP message" unless $message ~~ Cro::TCP::Message; 60 | $masked-complete.keep if ++⚛$j == @tests; 61 | } 62 | start { 63 | for @tests { $masked-in.emit($_) } 64 | $masked-in.done; 65 | } 66 | await $masked-complete; 67 | 68 | my $t2 = now; 69 | 70 | printf "CLEAR: %6d in %.3fs = %.3fms ave\n", 71 | +@tests, $t1 - $t0, 1000 * ($t1 - $t0) / @tests; 72 | printf "MASKED: %6d in %.3fs = %.3fms ave\n", 73 | +@tests, $t2 - $t1, 1000 * ($t2 - $t1) / @tests; 74 | -------------------------------------------------------------------------------- /lib/Cro/HTTP/Router/WebSocket.rakumod: -------------------------------------------------------------------------------- 1 | use Base64; 2 | use Digest::SHA1::Native; 3 | use Cro::HTTP::Router; 4 | use Cro::TCP; 5 | use Cro::WebSocket::FrameParser; 6 | use Cro::WebSocket::FrameSerializer; 7 | use Cro::WebSocket::Handler; 8 | use Cro::WebSocket::Internal; 9 | use Cro::WebSocket::MessageParser; 10 | use Cro::WebSocket::MessageSerializer; 11 | 12 | multi web-socket(&handler, :$json, :$body-parsers is copy, :$body-serializers is copy) is export { 13 | my constant $magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; 14 | 15 | my $request = request; 16 | my $response = response; 17 | 18 | # Bad request checking 19 | if !($request.method eq 'GET') 20 | || !($request.http-version eq '1.1') 21 | || !$request.has-header('host') 22 | || !(($request.header('Connection') // '').lc ~~ /upgrade/) 23 | || decode-base64($request.header('sec-websocket-key') // '', :bin).elems != 16 { 24 | bad-request; 25 | return; 26 | }; 27 | 28 | unless ($request.header('sec-websocket-version') // '') eq '13' { 29 | $response.status = 426; 30 | $response.append-header('Sec-WebSocket-Version', '13'); 31 | return; 32 | }; 33 | 34 | if $json { 35 | if $body-parsers === Any { 36 | $body-parsers = Cro::WebSocket::BodyParser::JSON; 37 | } 38 | else { 39 | die "Cannot use :json together with :body-parsers"; 40 | } 41 | if $body-serializers === Any { 42 | $body-serializers = Cro::WebSocket::BodySerializer::JSON; 43 | } 44 | else { 45 | die "Cannot use :json together with :body-serializers"; 46 | } 47 | } 48 | 49 | my @before; 50 | unless $body-parsers === Any { 51 | push @before, SetBodyParsers.new(:$body-parsers); 52 | } 53 | my @after; 54 | unless $body-serializers === Any { 55 | unshift @after, SetBodySerializers.new(:$body-serializers); 56 | } 57 | 58 | my $key = $request.header('sec-websocket-key'); 59 | 60 | $response.status = 101; 61 | $response.append-header('Upgrade', 'websocket'); 62 | $response.append-header('Connection', 'Upgrade'); 63 | $response.append-header('Sec-WebSocket-Accept', encode-base64(sha1($key ~ $magic), :str)); 64 | 65 | my Cro::Transform $pipeline = Cro.compose( 66 | label => "WebSocket Handler", 67 | Cro::WebSocket::FrameParser.new(:mask-required), 68 | Cro::WebSocket::MessageParser.new, 69 | |@before, 70 | Cro::WebSocket::Handler.new(&handler), 71 | |@after, 72 | Cro::WebSocket::MessageSerializer.new, 73 | Cro::WebSocket::FrameSerializer.new(:!mask) 74 | ); 75 | $response.set-body-byte-stream: 76 | $pipeline.transformer( 77 | $request.body-byte-stream.map(-> $data { Cro::TCP::Message.new(:$data) }) 78 | ).map({ $_.data }); 79 | } 80 | -------------------------------------------------------------------------------- /perf/frame-parser-perf.raku: -------------------------------------------------------------------------------- 1 | use Cro::TCP; 2 | use Cro::WebSocket::FrameParser; 3 | use Cro::WebSocket::Frame; 4 | 5 | 6 | sub make-tcp(@data) { 7 | Cro::TCP::Message.new(data => Buf.new(@data)) 8 | } 9 | 10 | my @buffers-clear = 11 | [0x81, 0x05, 0x48, 0x65, 0x6c, 0x6c, 0x6f], 12 | [0x01, 0x03, 0x48, 0x65, 0x6c], 13 | [0x80, 0x02, 0x6c, 0x6f], 14 | [0x89, 0x05, 0x48, 0x65, 0x6c, 0x6c, 0x6f], 15 | [0x8a, 0x00], 16 | ; 17 | 18 | my @buffers-masked = 19 | [0x81, 0x85, 0x37, 0xfa, 0x21, 0x3d, 0x7f, 0x9f, 0x4d, 0x51, 0x58], 20 | [0x8a, 0x85, 0x37, 0xfa, 0x21, 0x3d, 0x7f, 0x9f, 0x4d, 0x51, 0x58], 21 | ; 22 | 23 | my @random-data = 255.rand.Int xx 256; 24 | @buffers-clear.push: [0x82, 0x7E, 0x01, 0x00, |@random-data]; 25 | @buffers-masked.push: [0x82, 0xFE, 0x01, 0x00, 0x37, 0xfa, 0x21, 0x3d, |@random-data]; 26 | 27 | @random-data = 255.rand.Int xx 32768; 28 | @buffers-clear.push: [0x82, 0x7E, 0x80, 0x00, |@random-data]; 29 | @buffers-masked.push: [0x82, 0xFE, 0x80, 0x00, 0x37, 0xfa, 0x21, 0x3d, |@random-data]; 30 | 31 | @random-data = 255.rand.Int xx 65536; 32 | @buffers-clear.push: [0x82, 0x7F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, |@random-data]; 33 | @buffers-masked.push: [0x82, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x37, 0xfa, 0x21, 0x3d, |@random-data]; 34 | 35 | 36 | my $repeat = @*ARGS[0] // 1_000; 37 | my @tests-clear = @buffers-clear.map: -> $bytes { |(make-tcp($bytes) for ^$repeat) }; 38 | my @tests-masked = @buffers-masked.map: -> $bytes { |(make-tcp($bytes) for ^$repeat) }; 39 | my $parser-clear = Cro::WebSocket::FrameParser.new(:!mask-required); 40 | my $parser-masked = Cro::WebSocket::FrameParser.new( :mask-required); 41 | my $clear-in = Supplier.new; 42 | my $masked-in = Supplier.new; 43 | my $clear-complete = Promise.new; 44 | my $masked-complete = Promise.new; 45 | my atomicint $i = 0; 46 | my atomicint $j = 0; 47 | 48 | my $t0 = now; 49 | 50 | $parser-clear.transformer($clear-in.Supply).schedule-on($*SCHEDULER).tap: -> $frame { 51 | die "Did not parse as a WebSocket frame" unless $frame ~~ Cro::WebSocket::Frame; 52 | $clear-complete.keep if ++⚛$i == @tests-clear; 53 | } 54 | start { 55 | for @tests-clear { $clear-in.emit($_) } 56 | $clear-in.done; 57 | } 58 | await $clear-complete; 59 | 60 | my $t1 = now; 61 | 62 | $parser-masked.transformer($masked-in.Supply).schedule-on($*SCHEDULER).tap: -> $frame { 63 | die "Did not parse as a WebSocket frame" unless $frame ~~ Cro::WebSocket::Frame; 64 | $masked-complete.keep if ++⚛$j == @tests-masked; 65 | } 66 | start { 67 | for @tests-masked { $masked-in.emit($_) } 68 | $masked-in.done; 69 | } 70 | await $masked-complete; 71 | 72 | my $t2 = now; 73 | 74 | printf "CLEAR: %6d in %.3fs = %.3fms ave\n", 75 | +@tests-clear, $t1 - $t0, 1000 * ($t1 - $t0) / @tests-clear; 76 | printf "MASKED: %6d in %.3fs = %.3fms ave\n", 77 | +@tests-masked, $t2 - $t1, 1000 * ($t2 - $t1) / @tests-masked; 78 | -------------------------------------------------------------------------------- /t/websocket-handler.rakutest: -------------------------------------------------------------------------------- 1 | use Cro::WebSocket::Handler; 2 | use Cro::WebSocket::Message; 3 | use Test; 4 | 5 | my $completion = Promise.new; 6 | 7 | my $uc-ws = Cro::WebSocket::Handler.new( 8 | -> $incoming { 9 | supply { 10 | whenever $incoming -> $message { 11 | my $body = await $message.body-text(); 12 | emit Cro::WebSocket::Message.new($body.uc); 13 | } 14 | } 15 | } 16 | ); 17 | 18 | my $fake-in = Supplier.new; 19 | 20 | $uc-ws.transformer($fake-in.Supply).tap: -> $resp { 21 | my $text = $resp.body-text.result if $resp.opcode != Cro::WebSocket::Message::Close; 22 | 23 | with $text { 24 | ok $text eq $text.uc; 25 | } 26 | $completion.keep; 27 | }; 28 | 29 | $fake-in.emit(Cro::WebSocket::Message.new('Up me')); 30 | 31 | await Promise.anyof($completion, Promise.in(5)); 32 | 33 | unless $completion.status ~~ Kept { 34 | flunk "Handler without close promise passed doesn't work"; 35 | } 36 | 37 | my Int $count = 4; 38 | my Int $counter = 0; 39 | 40 | $completion = Promise.new; 41 | 42 | $uc-ws = Cro::WebSocket::Handler.new( 43 | -> $incoming, $close { 44 | supply { 45 | whenever $incoming -> $message { 46 | my $body = await $message.body-text(); 47 | emit Cro::WebSocket::Message.new($body.uc); 48 | } 49 | whenever $close -> $message { 50 | my $blob = $message.body-blob.result; 51 | my Int $code = ($blob[0] +< 8) +| $blob[1]; 52 | ok $code == 1000, 'Close code is 1000'; 53 | $completion.keep if $count == $counter; 54 | } 55 | } 56 | } 57 | ); 58 | 59 | $fake-in = Supplier.new; 60 | 61 | $uc-ws.transformer($fake-in.Supply).tap: -> $resp { 62 | my $text = $resp.body-text.result if $resp.opcode != 63 | Cro::WebSocket::Message::Close|Cro::WebSocket::Message::Pong; 64 | with $text { 65 | ok $text eq $text.uc; 66 | } 67 | $counter++; 68 | }; 69 | 70 | $fake-in.emit(Cro::WebSocket::Message.new('First Test')); 71 | 72 | $fake-in.emit(Cro::WebSocket::Message.new('Second Test')); 73 | 74 | $fake-in.emit(Cro::WebSocket::Message.new(opcode => Cro::WebSocket::Message::Ping, 75 | fragmented => False, 76 | body-byte-stream => supply 77 | { emit 'ping'.encode })); 78 | 79 | $fake-in.emit(Cro::WebSocket::Message.new(opcode => Cro::WebSocket::Message::Close, 80 | fragmented => False, 81 | body-byte-stream => supply # 1000 82 | { emit Blob.new(3, 232) })); 83 | 84 | await Promise.anyof($completion, Promise.in(5)); 85 | 86 | unless $completion.status ~~ Kept { 87 | flunk "Handler with close promise passed doesn't work"; 88 | } 89 | 90 | done-testing; 91 | -------------------------------------------------------------------------------- /t/websocket-message-parser.rakutest: -------------------------------------------------------------------------------- 1 | use Cro::WebSocket::MessageParser; 2 | use Test; 3 | 4 | sub frame-to-message(@frames, $desc, *@checks) { 5 | my $parser = Cro::WebSocket::MessageParser.new; 6 | my $fake-in = Supplier.new; 7 | my $complete = Promise.new; 8 | $parser.transformer($fake-in.Supply).schedule-on($*SCHEDULER).tap: -> $message { 9 | pass $desc; 10 | for @checks.kv -> $i, $check { 11 | ok $check($message), "check {$i + 1}"; 12 | } 13 | $complete.keep; 14 | } 15 | start { 16 | for @frames { 17 | $fake-in.emit($_); 18 | } 19 | $fake-in.done; 20 | } 21 | await Promise.anyof($complete, Promise.in(5)); 22 | unless $complete { 23 | flunk $desc; 24 | } 25 | } 26 | 27 | frame-to-message (Cro::WebSocket::Frame.new(fin => True, 28 | opcode => Cro::WebSocket::Frame::Text, 29 | payload => Blob.new('Hello'.encode)),), 30 | 'Hello', 31 | *.opcode == Cro::WebSocket::Message::Text, 32 | *.fragmented == False, 33 | *.body-text.result eq 'Hello'; 34 | 35 | frame-to-message (Cro::WebSocket::Frame.new(fin => False, 36 | opcode => Cro::WebSocket::Frame::Text, 37 | payload => Blob.new('Hel'.encode)), 38 | Cro::WebSocket::Frame.new(fin => True, 39 | opcode => Cro::WebSocket::Frame::Continuation, 40 | payload => Blob.new('lo'.encode))), 41 | 'Splitted Hello', 42 | *.opcode == Cro::WebSocket::Message::Text, 43 | *.fragmented == True, 44 | *.body-text.result eq 'Hello'; 45 | 46 | frame-to-message (Cro::WebSocket::Frame.new(fin => True, 47 | opcode => Cro::WebSocket::Frame::Ping, 48 | payload => Blob.new('Hello'.encode)),), 49 | 'Unmasked ping request', 50 | *.opcode == Cro::WebSocket::Message::Ping, 51 | *.fragmented == False, 52 | *.body-blob.result.decode('ascii') eq 'Hello'; 53 | 54 | my @random-data = 255.rand.Int xx 256; 55 | 56 | frame-to-message (Cro::WebSocket::Frame.new(fin => False, 57 | opcode => Cro::WebSocket::Frame::Binary, 58 | payload => Blob.new(@random-data[0..75])), 59 | Cro::WebSocket::Frame.new(fin => False, 60 | opcode => Cro::WebSocket::Frame::Continuation, 61 | payload => Blob.new(@random-data[75^..^173])), 62 | Cro::WebSocket::Frame.new(fin => True, 63 | opcode => Cro::WebSocket::Frame::Continuation, 64 | payload => Blob.new(@random-data[173..*])),), 65 | 'Splitted big data package', 66 | *.opcode == Cro::WebSocket::Message::Binary, 67 | *.fragmented == True, 68 | *.body-blob.result == Blob.new(@random-data); 69 | 70 | done-testing; 71 | -------------------------------------------------------------------------------- /xt/certs-and-keys/server-key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | 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 51 | UwsmpO9yrKDeXVRi6YIrWQNHKEn7 52 | -----END PRIVATE KEY----- 53 | -------------------------------------------------------------------------------- /lib/Cro/WebSocket/MessageSerializer.rakumod: -------------------------------------------------------------------------------- 1 | use Cro::Transform; 2 | use Cro::WebSocket::Frame; 3 | use Cro::WebSocket::Message; 4 | 5 | class Cro::WebSocket::MessageSerializer does Cro::Transform { 6 | method consumes() { Cro::WebSocket::Message } 7 | method produces() { Cro::WebSocket::Frame } 8 | 9 | method transformer(Supply:D $in) { 10 | supply { 11 | my @order; 12 | my $current = Nil; 13 | my Bool $first = True; 14 | 15 | sub set-current() { 16 | return if @order.elems == 0; 17 | return if $current; 18 | 19 | $current = @order.shift; 20 | whenever $current.body-byte-stream -> $payload { 21 | my $opcode = $first 22 | ?? Cro::WebSocket::Frame::Opcode($current.opcode.value) 23 | !! Cro::WebSocket::Frame::Continuation; 24 | if $first { 25 | .keep(True) with $current.serialization-outcome; 26 | $first = False; 27 | } 28 | emit Cro::WebSocket::Frame.new(fin => !$current.fragmented, :$opcode, :$payload); 29 | LAST { 30 | emit Cro::WebSocket::Frame.new(fin => True, 31 | opcode => Cro::WebSocket::Frame::Continuation, 32 | payload => Blob.new()) if $current.fragmented; 33 | if $first { 34 | .keep(True) with $current.serialization-outcome; 35 | } 36 | $first = True; 37 | $current = Nil; 38 | set-current; 39 | } 40 | QUIT { 41 | default { 42 | serialization-error($_); 43 | } 44 | } 45 | } 46 | CATCH { 47 | default { 48 | serialization-error($_); 49 | } 50 | } 51 | } 52 | 53 | sub serialization-error(Exception $error --> Nil) { 54 | if $first { 55 | .break($error) with $current.serialization-outcome; 56 | $first = True; 57 | $current = Nil; 58 | set-current; 59 | } 60 | } 61 | 62 | whenever $in -> Cro::WebSocket::Message $m { 63 | my $opcode = $m.opcode // -1; 64 | if $opcode == 8|9|10 { 65 | emit Cro::WebSocket::Frame.new(fin => True, 66 | opcode => Cro::WebSocket::Frame::Opcode($opcode.value), 67 | payload => $m.body-blob.result); 68 | .keep(True) with $m.serialization-outcome; 69 | CATCH { 70 | default { 71 | with $m.serialization-outcome -> $so { 72 | $m.break($_); 73 | } 74 | } 75 | } 76 | } 77 | else { 78 | @order.push: $m; 79 | set-current; 80 | } 81 | } 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /t/websocket-message-serializer.rakutest: -------------------------------------------------------------------------------- 1 | use Cro::WebSocket::MessageSerializer; 2 | use Test; 3 | 4 | sub message-to-frames(@messages, $count, $desc, *@checks) { 5 | my $serializer = Cro::WebSocket::MessageSerializer.new; 6 | my $fake-in = Supplier.new; 7 | my $completion = Promise.new; 8 | my Int $frame-count = 0; 9 | $serializer.transformer($fake-in.Supply).tap: -> $frame { 10 | for @checks[$frame-count].kv -> $i, $check { 11 | ok $check($frame), "check {$i+1}"; 12 | } 13 | $frame-count++; 14 | $completion.keep if $count == $frame-count; 15 | } 16 | await start { 17 | for @messages { $fake-in.emit($_) }; 18 | $fake-in.done; 19 | }; 20 | await Promise.anyof($completion, Promise.in(5)); 21 | if $completion { 22 | pass $desc; 23 | } else { 24 | flunk $desc; 25 | } 26 | } 27 | 28 | message-to-frames [Cro::WebSocket::Message.new('Hello')], 29 | 1, 'Hello', 30 | [(*.fin == True, 31 | *.opcode == Cro::WebSocket::Frame::Text, 32 | *.payload.decode eq 'Hello'),]; 33 | 34 | message-to-frames [Cro::WebSocket::Message.new(supply { 35 | emit 'Hel'.encode; 36 | emit 'lo'.encode; 37 | done; 38 | })], 39 | 3, 'Splitted hello', 40 | [(*.fin == False, 41 | *.opcode == Cro::WebSocket::Frame::Binary, 42 | *.payload.decode eq 'Hel'), 43 | (*.fin == False, 44 | *.opcode == Cro::WebSocket::Frame::Continuation, 45 | *.payload.decode eq 'lo'), 46 | (*.fin == True, 47 | *.opcode == Cro::WebSocket::Frame::Continuation, 48 | *.payload.decode eq '')]; 49 | 50 | message-to-frames (Cro::WebSocket::Message.new(opcode => Cro::WebSocket::Message::Ping, 51 | fragmented => False, 52 | body-byte-stream => supply { emit 'Ping'.encode; done; }),), 53 | 1, 'Control message', 54 | [(*.fin == True, 55 | *.opcode == Cro::WebSocket::Frame::Ping, 56 | *.payload.decode eq 'Ping')]; 57 | 58 | my $p1 = Promise.new; 59 | my $p2 = Promise.new; 60 | 61 | my $s = Supplier::Preserving.new; 62 | 63 | start { 64 | $s.emit: 'Before'.encode; 65 | $p1.keep; 66 | await $p2; 67 | $s.emit: 'After'.encode; 68 | $s.done; 69 | }; 70 | 71 | message-to-frames (Cro::WebSocket::Message.new($s.Supply), 72 | Cro::WebSocket::Message.new(opcode => Cro::WebSocket::Message::Ping, 73 | fragmented => False, 74 | body-byte-stream => supply { await $p1; emit 'Ping'.encode; $p2.keep; done; }), 75 | ), 76 | 4, 'Control message in-between', 77 | [(*.fin == False, 78 | *.opcode == Cro::WebSocket::Frame::Binary, 79 | *.payload.decode eq 'Before'), 80 | (*.fin == True, 81 | *.opcode == Cro::WebSocket::Frame::Ping, 82 | *.payload.decode eq 'Ping'), 83 | (*.fin == False, 84 | *.opcode == Cro::WebSocket::Frame::Continuation, 85 | *.payload.decode eq 'After'), 86 | (*.fin == True, 87 | *.opcode == Cro::WebSocket::Frame::Continuation, 88 | *.payload.decode eq '')]; 89 | 90 | done-testing; 91 | -------------------------------------------------------------------------------- /t/http-router-websocket.rakutest: -------------------------------------------------------------------------------- 1 | use Cro::HTTP::Client; 2 | use Cro::HTTP::Router::WebSocket; 3 | use Cro::HTTP::Router; 4 | use Cro::HTTP::Server; 5 | use Cro::WebSocket::BodyParsers; 6 | use Cro::WebSocket::BodySerializers; 7 | use Cro::WebSocket::Client; 8 | use Test; 9 | 10 | my $app = route { 11 | get -> 'chat' { 12 | web-socket -> $incoming { 13 | supply { 14 | whenever $incoming -> $message { 15 | emit('You said: ' ~ await $message.body-text); 16 | } 17 | } 18 | } 19 | } 20 | 21 | get -> 'parser-serializer' { 22 | web-socket 23 | :body-parsers(Cro::WebSocket::BodyParser::JSON), 24 | :body-serializers(Cro::WebSocket::BodySerializer::JSON), 25 | -> $incoming { 26 | supply whenever $incoming -> $message { 27 | my $body = await $message.body; 28 | $body = 42; 29 | $body++; 30 | emit $body; 31 | } 32 | } 33 | } 34 | 35 | get -> 'json' { 36 | web-socket :json, -> $incoming { 37 | supply whenever $incoming -> $message { 38 | my $body = await $message.body; 39 | $body = 4242; 40 | $body++; 41 | emit $body; 42 | } 43 | } 44 | } 45 | } 46 | 47 | my $http-server = Cro::HTTP::Server.new(port => 3006, application => $app); 48 | $http-server.start(); 49 | END $http-server.stop(); 50 | 51 | throws-like { await Cro::HTTP::Client.get('http://localhost:3006/chat') }, 52 | X::Cro::HTTP::Error::Client, 'Connection is not upgraded, 400 Bad Request'; 53 | 54 | { 55 | my $c = await Cro::WebSocket::Client.connect: 'http://localhost:3006/chat'; 56 | 57 | my $p = Promise.new; 58 | my %seen; 59 | $c.messages.tap: 60 | -> $m { 61 | %seen{await $m.body-text}++; 62 | $p.keep if %seen == 3; 63 | }, 64 | quit => { 65 | .note; 66 | exit(1); 67 | }; 68 | 69 | $c.send('Hello'); 70 | $c.send('Good'); 71 | $c.send('Wow'); 72 | 73 | await Promise.anyof(Promise.in(5), $p); 74 | ok $p.status == Kept, 'All expected responses were received'; 75 | ok %seen{'You said: Hello'}:exists, 'Got first message response'; 76 | ok %seen{'You said: Good'}:exists, 'Got second message response'; 77 | ok %seen{'You said: Wow'}:exists, 'Got third message response'; 78 | 79 | $c.close; 80 | } 81 | 82 | { 83 | use JSON::Fast; 84 | 85 | my $c = await Cro::WebSocket::Client.connect: 'http://localhost:3006/parser-serializer'; 86 | my $reply-promise = $c.messages.head.Promise; 87 | $c.send(to-json({ updated => 99, kept => 'xxx' })); 88 | my $reply = await $reply-promise; 89 | my $parsed; 90 | lives-ok { $parsed = from-json await $reply.body-text }, 91 | 'Get back valid JSON from websocket endpoint with JSON parser/serializer endpoint'; 92 | is $parsed, 100, 'Expected data returned (1)'; 93 | is $parsed, 'xxx', 'Expected data returned (2)'; 94 | is $parsed, 42, 'Expected data returned (3)'; 95 | $c.close; 96 | } 97 | 98 | { 99 | use JSON::Fast; 100 | 101 | my $c = await Cro::WebSocket::Client.connect: 'http://localhost:3006/json'; 102 | my $reply-promise = $c.messages.head.Promise; 103 | $c.send(to-json({ updated => 102, kept => 'xxxy' })); 104 | my $reply = await $reply-promise; 105 | my $parsed; 106 | lives-ok { $parsed = from-json await $reply.body-text }, 107 | 'Get back valid JSON from websocket endpoint that uses :json'; 108 | is $parsed, 103, 'Expected data returned (1)'; 109 | is $parsed, 'xxxy', 'Expected data returned (2)'; 110 | is $parsed, 4242, 'Expected data returned (3)'; 111 | $c.close; 112 | } 113 | 114 | done-testing; 115 | -------------------------------------------------------------------------------- /lib/Cro/WebSocket/FrameParser.rakumod: -------------------------------------------------------------------------------- 1 | use Cro::TCP; 2 | use Cro::WebSocket::Frame; 3 | use Cro::Transform; 4 | 5 | class X::Cro::WebSocket::PayloadLengthTooLarge is Exception { 6 | method message() { 7 | "WebSocket frame 8-byte extended payload lengths cannot have the high bit set" 8 | } 9 | } 10 | 11 | class X::Cro::WebSocket::IncorrectMaskFlag is Exception { 12 | method message() { 13 | "Mask flag of the FrameParser instance and the current frame flag differ" 14 | } 15 | } 16 | 17 | class X::Cro::WebSocket::Disconnect is Exception { 18 | method message() { "Connection unexpectedly closed in the middle of frame" } 19 | } 20 | 21 | class Cro::WebSocket::FrameParser does Cro::Transform { 22 | has Bool $.mask-required; 23 | 24 | method consumes() { Cro::TCP::Message } 25 | method produces() { Cro::WebSocket::Frame } 26 | 27 | method transformer(Supply:D $in) { 28 | supply { 29 | my Buf $buffer .= new; 30 | 31 | my sub emit-frame($mask-flag, $payload-len, $pos) { 32 | my $frame = Cro::WebSocket::Frame.new; 33 | my $fin-op = $buffer[0]; 34 | $frame.fin = ?($fin-op +& 128); 35 | $frame.opcode = Cro::WebSocket::Frame::Opcode($fin-op +& 15); 36 | 37 | if $mask-flag { 38 | my $mask = $buffer.subbuf($pos, 4); 39 | $frame.payload = $buffer.subbuf($pos + 4, $payload-len) 40 | ~^ Blob.allocate($payload-len, $mask); 41 | } 42 | else { 43 | $frame.payload = $buffer.subbuf($pos, $payload-len); 44 | } 45 | 46 | emit $frame; 47 | } 48 | 49 | whenever $in -> Cro::TCP::Message $packet { 50 | $buffer ~= $packet.data; 51 | 52 | # Loop in case TCP message contained data from multiple frames 53 | loop { 54 | # Smallest valid frame is 2 bytes: fin-op and mask-len 55 | last if (my $buf-len = $buffer.elems) < 2; 56 | 57 | my $mask-len = $buffer[1]; 58 | my $mask-flag = ?($mask-len +& 128); 59 | die X::Cro::WebSocket::IncorrectMaskFlag.new 60 | if $!mask-required != $mask-flag; 61 | my $base-len = $mask-len +& 127; 62 | 63 | if $base-len < 126 { 64 | my $min-len = 2 + $mask-flag * 4 + $base-len; 65 | last if $buf-len < $min-len; 66 | 67 | emit-frame($mask-flag, $base-len, 2); 68 | $buffer .= subbuf($min-len); 69 | } 70 | elsif $base-len == 126 { 71 | last if $buf-len < 4; 72 | 73 | my $payload-len = $buffer.read-uint16(2, BigEndian); 74 | my $min-len = 4 + $mask-flag * 4 + $payload-len; 75 | last if $buf-len < $min-len; 76 | 77 | emit-frame($mask-flag, $payload-len, 4); 78 | $buffer .= subbuf($min-len); 79 | } 80 | else { 81 | last if $buf-len < 10; 82 | die X::Cro::WebSocket::PayloadLengthTooLarge.new 83 | if $buffer[2] +& 128; 84 | 85 | my $payload-len = $buffer.read-uint64(2, BigEndian); 86 | my $min-len = 10 + $mask-flag * 4 + $payload-len; 87 | last if $buf-len < $min-len; 88 | 89 | emit-frame($mask-flag, $payload-len, 10); 90 | $buffer .= subbuf($min-len); 91 | } 92 | } 93 | 94 | LAST { 95 | die X::Cro::WebSocket::Disconnect.new if $buffer; 96 | } 97 | } 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /t/websocket-frame-serializer.rakutest: -------------------------------------------------------------------------------- 1 | use Cro::TCP; 2 | use Cro::WebSocket::FrameParser; 3 | use Cro::WebSocket::FrameSerializer; 4 | use Cro::WebSocket::Frame; 5 | use Test; 6 | 7 | ok Cro::WebSocket::FrameSerializer ~~ Cro::Transform, 8 | 'WebSocket frame serializer is a transform'; 9 | ok Cro::WebSocket::FrameSerializer.consumes === Cro::WebSocket::Frame, 10 | 'WebSocket frame serializer consumes TCP messages'; 11 | ok Cro::WebSocket::FrameSerializer.produces === Cro::TCP::Message, 12 | 'WebSocket frame serializer produces Frames'; 13 | 14 | sub test-example($frame, $mask, $desc) { 15 | my $serializer = Cro::WebSocket::FrameSerializer.new(:$mask); 16 | my $parser = Cro::WebSocket::FrameParser.new(mask-required => $mask); 17 | my $fake-in-s = Supplier.new; 18 | my $fake-in-p = Supplier.new; 19 | my $complete = Promise.new; 20 | $serializer.transformer($fake-in-s.Supply).schedule-on($*SCHEDULER).tap: -> $message { 21 | $parser.transformer($fake-in-p.Supply).schedule-on($*SCHEDULER).tap: -> $newframe { 22 | subtest $desc, { 23 | is $newframe.fin, $frame.fin, 'fin flag'; 24 | is $newframe.opcode, $frame.opcode, 'opcode'; 25 | ok $newframe.payload ~~ Blob, 'payload type'; 26 | is-deeply Blob.new($newframe.payload), $frame.payload, 'payload contents'; 27 | } 28 | $complete.keep; 29 | } 30 | $fake-in-p.emit($message); 31 | $fake-in-p.done; 32 | } 33 | start { 34 | $fake-in-s.emit($frame); 35 | $fake-in-s.done; 36 | } 37 | await Promise.anyof($complete, Promise.in(5)); 38 | } 39 | 40 | test-example Cro::WebSocket::Frame.new(fin => True, 41 | opcode => Cro::WebSocket::Frame::Text, 42 | payload => Blob.new('Hello'.encode)), 43 | False, 'Hello text frame'; 44 | 45 | test-example Cro::WebSocket::Frame.new(fin => True, 46 | opcode => Cro::WebSocket::Frame::Text, 47 | payload => Blob.new('Hello'.encode)), 48 | True, 'Masked Hello'; 49 | 50 | test-example Cro::WebSocket::Frame.new(fin => False, 51 | opcode => Cro::WebSocket::Frame::Text, 52 | payload => Blob.new('Hel'.encode)), 53 | False, 'Hel'; 54 | 55 | test-example Cro::WebSocket::Frame.new(fin => True, 56 | opcode => Cro::WebSocket::Frame::Continuation, 57 | payload => Blob.new('lo'.encode)), 58 | False, 'lo'; 59 | 60 | test-example Cro::WebSocket::Frame.new(fin => True, 61 | opcode => Cro::WebSocket::Frame::Ping, 62 | payload => Blob.new('Hello'.encode)), 63 | False, 'Unmasked ping request'; 64 | 65 | test-example Cro::WebSocket::Frame.new(fin => True, 66 | opcode => Cro::WebSocket::Frame::Pong, 67 | payload => Blob.new('Hello'.encode)), 68 | True, 'Masked ping response'; 69 | 70 | my @random-data = 255.rand.Int xx 256; 71 | 72 | test-example Cro::WebSocket::Frame.new(fin => True, 73 | opcode => Cro::WebSocket::Frame::Binary, 74 | payload => Blob.new(@random-data)), 75 | False, '256 bytes binary message in a single unmasked frame'; 76 | 77 | @random-data = 255.rand.Int xx 32768; 78 | 79 | test-example Cro::WebSocket::Frame.new(fin => True, 80 | opcode => Cro::WebSocket::Frame::Binary, 81 | payload => Blob.new(@random-data)), 82 | False, '32 KiB binary message in a single unmasked frame'; 83 | 84 | @random-data = 255.rand.Int xx 65536; 85 | 86 | test-example Cro::WebSocket::Frame.new(fin => True, 87 | opcode => Cro::WebSocket::Frame::Binary, 88 | payload => Blob.new(@random-data)), 89 | False, '64 KiB binary message in a single unmasked frame'; 90 | 91 | done-testing; 92 | -------------------------------------------------------------------------------- /lib/Cro/WebSocket/Handler.rakumod: -------------------------------------------------------------------------------- 1 | use Cro::Transform; 2 | use Cro::WebSocket::Message; 3 | 4 | class Cro::WebSocket::Handler does Cro::Transform { 5 | has &.block; 6 | 7 | method consumes() { Cro::WebSocket::Message } 8 | method produces() { Cro::WebSocket::Message } 9 | 10 | method new(&block) { 11 | return self.bless(:&block); 12 | } 13 | 14 | method transformer(Supply:D $in) { 15 | supply { 16 | my $supplier = Supplier::Preserving.new; 17 | my $on-close = Promise.new if &!block.count == 2; 18 | my $on-close-vow = $on-close.?vow; 19 | my $end = False; 20 | 21 | sub keep-close-promise($m = Nil) { 22 | with $on-close-vow { 23 | $on-close-vow.keep($m); 24 | $on-close-vow = Nil; 25 | } 26 | } 27 | 28 | my class CloseMessage { 29 | has $.message; 30 | } 31 | my $block-feed = $supplier.Supply.Channel.Supply.grep: -> $msg { 32 | if $msg ~~ CloseMessage { 33 | $msg.defined 34 | ?? keep-close-promise($msg.message) 35 | !! keep-close-promise(); 36 | False 37 | } 38 | else { 39 | True 40 | } 41 | } 42 | my $block-result; 43 | try { 44 | $block-result = &!block.count == 1 45 | ?? &!block($block-feed) 46 | !! &!block($block-feed, $on-close); 47 | CATCH { 48 | default { 49 | my $exception = $_; 50 | $block-result = supply die $exception; 51 | } 52 | } 53 | } 54 | 55 | sub close(Blob $code) { 56 | unless $end { 57 | emit Cro::WebSocket::Message.new( 58 | opcode => Cro::WebSocket::Message::Close, 59 | fragmented => False, 60 | body-byte-stream => supply { emit $code }); 61 | $supplier.emit(CloseMessage); 62 | done; 63 | } 64 | } 65 | 66 | whenever $block-result { 67 | when Cro::WebSocket::Message { 68 | emit $_; 69 | if .opcode == Cro::WebSocket::Message::Close { 70 | $supplier.emit(CloseMessage); 71 | $end = True; 72 | done; 73 | } 74 | } 75 | default { 76 | emit Cro::WebSocket::Message.new($_) 77 | } 78 | 79 | LAST { 80 | close(Blob.new([3, 232])); # bytes of 1000 81 | } 82 | QUIT { 83 | note "A WebSocket handler crashed: " ~ .gist; 84 | close(Blob.new([3, 343])); # bytes of 1011 85 | } 86 | } 87 | 88 | whenever $in -> Cro::WebSocket::Message $m { 89 | if $m.is-data { 90 | $supplier.emit($m); 91 | } else { 92 | given $m.opcode { 93 | when Cro::WebSocket::Message::Ping { 94 | emit Cro::WebSocket::Message.new( 95 | opcode => Cro::WebSocket::Message::Pong, 96 | fragmented => False, 97 | body-byte-stream => supply { 98 | emit (await $m.body-blob); 99 | done; 100 | }); 101 | } 102 | when Cro::WebSocket::Message::Close { 103 | emit Cro::WebSocket::Message.new( 104 | opcode => Cro::WebSocket::Message::Close, 105 | fragmented => False, 106 | body-byte-stream => supply { 107 | emit (await $m.body-blob); 108 | done; 109 | }); 110 | $supplier.emit(CloseMessage.new(message => $m)); 111 | $supplier.done; 112 | } 113 | default {} 114 | } 115 | } 116 | } 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /t/websocket-frame-parser.rakutest: -------------------------------------------------------------------------------- 1 | use Cro::TCP; 2 | use Cro::WebSocket::FrameParser; 3 | use Cro::WebSocket::Frame; 4 | use Test; 5 | 6 | ok Cro::WebSocket::FrameParser ~~ Cro::Transform, 7 | 'WebSocket frame parser is a transform'; 8 | ok Cro::WebSocket::FrameParser.consumes === Cro::TCP::Message, 9 | 'WebSocket frame parser consumes TCP messages'; 10 | ok Cro::WebSocket::FrameParser.produces === Cro::WebSocket::Frame, 11 | 'WebSocket frame parser produces Frames'; 12 | 13 | sub test-example($buf, $mask-required, $desc, *@checks, :$split = False) { 14 | my $parser = Cro::WebSocket::FrameParser.new(:$mask-required); 15 | my $fake-in = Supplier.new; 16 | my $complete = Promise.new; 17 | $parser.transformer($fake-in.Supply).schedule-on($*SCHEDULER).tap: -> $frame { 18 | ok $frame ~~ Cro::WebSocket::Frame, $desc; 19 | for @checks.kv -> $i, $check { 20 | ok $check($frame), "check {$i + 1}"; 21 | } 22 | $complete.keep; 23 | } 24 | start { 25 | if $split { 26 | my Int $split = $buf.elems.rand.Int; 27 | my $buf1 = $buf.subbuf(0, $split) ; 28 | my $buf2 = $buf.subbuf($split); 29 | $fake-in.emit(Cro::TCP::Message.new(data => $buf1)); 30 | $fake-in.emit(Cro::TCP::Message.new(data => $buf2)); 31 | } else { 32 | $fake-in.emit(Cro::TCP::Message.new(data => $buf)); 33 | } 34 | $fake-in.done; 35 | } 36 | await Promise.anyof($complete, Promise.in(5)); 37 | unless $complete { 38 | flunk $desc; 39 | } 40 | } 41 | 42 | test-example Buf.new([0x81, 0x05, 0x48, 0x65, 0x6c, 0x6c, 0x6f]), 43 | False, 'Hello', 44 | *.fin == True, 45 | *.opcode == Cro::WebSocket::Frame::Text, 46 | *.payload.decode eq 'Hello'; 47 | 48 | test-example Buf.new([0x81, 0x85, 0x37, 0xfa, 0x21, 0x3d, 0x7f, 0x9f, 0x4d, 0x51, 0x58]), 49 | True, 'Masked Hello', 50 | *.fin == True, 51 | *.opcode == Cro::WebSocket::Frame::Text, 52 | *.payload.decode eq 'Hello'; 53 | 54 | test-example Buf.new([0x01, 0x03, 0x48, 0x65, 0x6c]), 55 | False, 'Hel', 56 | *.fin == False, 57 | *.opcode == Cro::WebSocket::Frame::Text, 58 | *.payload.decode eq 'Hel'; 59 | 60 | test-example Buf.new([0x80, 0x02, 0x6c, 0x6f]), 61 | False, 'lo', 62 | *.fin == True, 63 | *.opcode == Cro::WebSocket::Frame::Continuation, 64 | *.payload.decode eq 'lo'; 65 | 66 | test-example Buf.new([0x89, 0x05, 0x48, 0x65, 0x6c, 0x6c, 0x6f]), 67 | False, 'Unmasked ping request', 68 | *.fin == True, 69 | *.opcode == Cro::WebSocket::Frame::Ping, 70 | *.payload.decode eq 'Hello'; 71 | 72 | test-example Buf.new([0x8a, 0x00]), 73 | False, 'Empty unmasked ping response', 74 | *.fin == True, 75 | *.opcode == Cro::WebSocket::Frame::Pong, 76 | *.payload.decode eq ''; 77 | 78 | test-example Buf.new([0x8a, 0x85, 0x37, 0xfa, 0x21, 0x3d, 0x7f, 0x9f, 0x4d, 0x51, 0x58]), 79 | True, 'Masked ping response', 80 | *.fin == True, 81 | *.opcode == Cro::WebSocket::Frame::Pong, 82 | *.payload.decode eq 'Hello'; 83 | 84 | test-example Buf.new([0x8a, 0x85, 0x37, 0xfa, 0x21, 0x3d, 0x7f, 0x9f, 0x4d, 0x51, 0x58]), 85 | True, 'Masked ping response', 86 | *.fin == True, 87 | *.opcode == Cro::WebSocket::Frame::Pong, 88 | *.payload.decode eq 'Hello', 89 | split => True; 90 | 91 | my @random-data = 255.rand.Int xx 256; 92 | my $message = Buf.new([0x82, 0x7E, 0x01, 0x00, |@random-data]); 93 | 94 | test-example $message, 95 | False, '256 bytes binary message in a single unmasked frame', 96 | *.fin == True, 97 | *.opcode == Cro::WebSocket::Frame::Binary, 98 | *.payload == @random-data; 99 | 100 | test-example $message, 101 | False, '256 bytes binary message in a single unmasked frame', 102 | *.fin == True, 103 | *.opcode == Cro::WebSocket::Frame::Binary, 104 | *.payload == @random-data, 105 | split => True; 106 | 107 | @random-data = 255.rand.Int xx 32768; 108 | $message = Buf.new([0x82, 0x7E, 0x80, 0x00, |@random-data]); 109 | 110 | test-example $message, 111 | False, '32 KiB binary message in a single unmasked frame', 112 | *.fin == True, 113 | *.opcode == Cro::WebSocket::Frame::Binary, 114 | *.payload == @random-data; 115 | 116 | test-example $message, 117 | False, '32 KiB binary message in a single unmasked frame', 118 | *.fin == True, 119 | *.opcode == Cro::WebSocket::Frame::Binary, 120 | *.payload == @random-data, 121 | split => True; 122 | 123 | @random-data = 255.rand.Int xx 65536; 124 | $message = Buf.new([0x82, 0x7F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, |@random-data]); 125 | 126 | test-example $message, 127 | False, '64 KiB binary message in a single unmasked frame', 128 | *.fin == True, 129 | *.opcode == Cro::WebSocket::Frame::Binary; 130 | 131 | test-example $message, 132 | False, '64 KiB binary message in a single unmasked frame', 133 | *.fin == True, 134 | *.opcode == Cro::WebSocket::Frame::Binary, 135 | split => True; 136 | 137 | done-testing; 138 | -------------------------------------------------------------------------------- /lib/Cro/WebSocket/Client.rakumod: -------------------------------------------------------------------------------- 1 | use Base64; 2 | use Cro::HTTP::Client; 3 | use Cro::HTTP::Header; 4 | use Cro::Uri; 5 | use Cro::WebSocket::BodyParsers; 6 | use Cro::WebSocket::BodySerializers; 7 | use Cro::WebSocket::Client::Connection; 8 | use Crypt::Random; 9 | use Digest::SHA1::Native; 10 | 11 | class X::Cro::WebSocket::Client::CannotUpgrade is Exception { 12 | has $.reason; 13 | method message() { "Upgrade to WebSocket failed: $!reason" } 14 | } 15 | 16 | class Cro::WebSocket::Client { 17 | has $.uri; 18 | has $.body-serializers; 19 | has $.body-parsers; 20 | has Cro::HTTP::Header @.headers; 21 | 22 | submethod BUILD(:$uri, :$body-serializers, :$body-parsers, :$json, :@headers --> Nil) { 23 | with $uri { 24 | $!uri = $uri ~~ Cro::Uri ?? $uri !! Cro::Uri.parse($uri); 25 | } 26 | if $json { 27 | if $body-parsers === Any { 28 | $!body-parsers = Cro::WebSocket::BodyParser::JSON; 29 | } 30 | else { 31 | die "Cannot use :json together with :body-parsers"; 32 | } 33 | if $body-serializers === Any { 34 | $!body-serializers = Cro::WebSocket::BodySerializer::JSON; 35 | } 36 | else { 37 | die "Cannot use :json together with :body-serializers"; 38 | } 39 | } 40 | else { 41 | $!body-parsers = $body-parsers; 42 | $!body-serializers = $body-serializers; 43 | } 44 | 45 | @!headers = Cro::HTTP::Header.new(name => 'Upgrade', value => 'websocket'), 46 | Cro::HTTP::Header.new(name => 'Connection', value => 'Upgrade'), 47 | Cro::HTTP::Header.new(name => 'Sec-WebSocket-Version', value => '13'); 48 | my $seen-protocol-header = False; 49 | for @headers { 50 | my $header = do { 51 | when Cro::HTTP::Header { 52 | $_ 53 | } 54 | when Pair { 55 | Cro::HTTP::Header.new(name => .key, value => .value) 56 | } 57 | default { 58 | die "WebSocket client headers must be Cro::HTTP::Header or Pair objects, not {.^name}"; 59 | } 60 | } 61 | @!headers.push($header); 62 | if $header.name.lc eq 'sec-websocket-protocol' { 63 | $seen-protocol-header = True; 64 | } 65 | } 66 | unless $seen-protocol-header { 67 | @!headers.push(Cro::HTTP::Header.new(name => 'Sec-WebSocket-Protocol', value => 'echo-protocol')); 68 | } 69 | } 70 | 71 | method connect($uri = '', :%ca --> Promise) { 72 | my $parsed-url; 73 | if self && $!uri { 74 | $parsed-url = $uri ?? $!uri.add($uri) !! $!uri; 75 | } else { 76 | $parsed-url = $uri ~~ Cro::Uri ?? $uri !! Cro::Uri.parse($uri); 77 | } 78 | if $parsed-url.scheme eq 'ws' { 79 | $parsed-url = Cro::Uri.parse('http' ~ $parsed-url.Str.substr(2)); 80 | } 81 | elsif $parsed-url.scheme eq 'wss' { 82 | $parsed-url = Cro::Uri.parse('https' ~ $parsed-url.Str.substr(3)); 83 | } 84 | 85 | start { 86 | my $out = Supplier::Preserving.new; 87 | 88 | my $key = encode-base64(crypt_random_buf(16), :str); 89 | my $magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; 90 | my $answer = encode-base64(sha1($key ~ $magic), :str); 91 | 92 | my @headers = do if self { 93 | flat @!headers, Cro::HTTP::Header.new(name => 'Sec-WebSocket-Key', value => $key) 94 | } else { 95 | (Cro::HTTP::Header.new(name => 'Upgrade', value => 'websocket'), 96 | Cro::HTTP::Header.new(name => 'Connection', value => 'Upgrade'), 97 | Cro::HTTP::Header.new(name => 'Sec-WebSocket-Version', value => '13'), 98 | Cro::HTTP::Header.new(name => 'Sec-WebSocket-Key', value => $key), 99 | Cro::HTTP::Header.new(name => 'Sec-WebSocket-Protocol', value => 'echo-protocol')) 100 | } 101 | 102 | my %options = headers => @headers; 103 | %options = $out.Supply; 104 | %options = '1.1'; 105 | my $resp = await Cro::HTTP::Client.get($parsed-url, |%options, :%ca); 106 | if $resp.status == 101 { 107 | # Headers check; 108 | unless $resp.header('upgrade') && $resp.header('upgrade') ~~ m:i/'websocket'/ { 109 | die X::Cro::WebSocket::Client::CannotUpgrade.new(reason => "got {$resp.header('upgrade')} for 'upgrade' header"); 110 | } 111 | unless $resp.header('connection') && $resp.header('connection') ~~ m:i/^Upgrade$/ { 112 | die X::Cro::WebSocket::Client::CannotUpgrade.new(reason => "got {$resp.header('connection')} for 'connection' header"); 113 | } 114 | with $resp.header('Sec-WebSocket-Accept') { 115 | die X::Cro::WebSocket::Client::CannotUpgrade.new(reason => "wanted '$answer', but got $_") unless .trim eq $answer; 116 | } else { 117 | die X::Cro::WebSocket::Client::CannotUpgrade.new(reason => "no Sec-WebSocket-Accept header included"); 118 | } 119 | # No extensions for now 120 | # die unless $resp.header('Sec-WebSocket-Extensions') eq Nil; 121 | # die unless $resp.header('Sec-WebSocket-Protocol') eq 'echo-protocol'; # XXX 122 | Cro::WebSocket::Client::Connection.new( 123 | in => $resp.body-byte-stream, :$out, 124 | |(%(:$!body-parsers, :$!body-serializers) with self) 125 | ) 126 | } else { 127 | die X::Cro::WebSocket::Client::CannotUpgrade.new(reason => "Response status is {$resp.status}, not 101"); 128 | } 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /lib/Cro/WebSocket/Client/Connection.rakumod: -------------------------------------------------------------------------------- 1 | use Cro; 2 | use Cro::TCP; 3 | use Cro::WebSocket::FrameParser; 4 | use Cro::WebSocket::FrameSerializer; 5 | use Cro::WebSocket::Internal; 6 | use Cro::WebSocket::Message; 7 | use Cro::WebSocket::MessageParser; 8 | use Cro::WebSocket::MessageSerializer; 9 | use OO::Monitors; 10 | 11 | class X::Cro::WebSocket::Client::Closed is Exception { 12 | has Str $.operation is required; 13 | method message() { "Cannot $!operation on a closed WebSocket connection" } 14 | } 15 | 16 | my monitor PromiseFactory { 17 | has @.promises; 18 | 19 | method get-new(--> Promise) { 20 | my $p = Promise.new; 21 | @!promises.push: $p; 22 | $p; 23 | } 24 | 25 | method keep-all() { 26 | # Maybe racing with timeout, thus the try 27 | @!promises.map({ try .keep }); 28 | @!promises = (); 29 | } 30 | 31 | method break-all() { 32 | # Maybe racing with timeout, thus the try 33 | @!promises.map({ try .break('Connection lost') }); 34 | @!promises = (); 35 | } 36 | } 37 | 38 | class Cro::WebSocket::Client::Connection { 39 | has Supply $.in; 40 | has Supplier $.out; 41 | has Supplier $.sender; 42 | has Supply $.receiver; 43 | has Promise $.closer; 44 | has PromiseFactory $.pong; 45 | has Bool $.closed; 46 | 47 | submethod BUILD(:$!in, :$!out, :$body-parsers, :$body-serializers --> Nil) { 48 | $!sender = Supplier::Preserving.new; 49 | my $receiver = Supplier::Preserving.new; 50 | $!receiver = $receiver.Supply; 51 | $!closer = Promise.new; 52 | $!pong = PromiseFactory.new; 53 | $!closed = False; 54 | 55 | my @before; 56 | unless $body-serializers === Any { 57 | unshift @before, SetBodySerializers.new(:$body-serializers); 58 | } 59 | my @after; 60 | unless $body-parsers === Any { 61 | push @after, SetBodyParsers.new(:$body-parsers); 62 | } 63 | 64 | my $pp-in = Cro.compose( 65 | Cro::WebSocket::FrameParser.new(:!mask-required), 66 | Cro::WebSocket::MessageParser.new, 67 | |@after 68 | ).transformer($!in.map(-> $data { Cro::TCP::Message.new(:$data) })); 69 | 70 | my $pp-out = Cro.compose( 71 | |@before, 72 | Cro::WebSocket::MessageSerializer.new, 73 | Cro::WebSocket::FrameSerializer.new(:mask) 74 | ).transformer($!sender.Supply); 75 | 76 | $pp-in.tap: 77 | { 78 | if .is-data { 79 | $receiver.emit: $_; 80 | } else { 81 | when $_.opcode == Cro::WebSocket::Message::Ping { 82 | my $body-byte-stream = .body-byte-stream; 83 | my $m = Cro::WebSocket::Message.new(opcode => Cro::WebSocket::Message::Pong, 84 | :!fragmented, :$body-byte-stream); 85 | # Send pong asynchronously, to break dependency between 86 | # receiver and sender. 87 | start $!sender.emit($m); 88 | } 89 | when $_.opcode == Cro::WebSocket::Message::Pong { 90 | $!pong.keep-all; 91 | } 92 | when $_.opcode == Cro::WebSocket::Message::Close { 93 | $!closer.keep($_) if $!closer.defined; 94 | $!pong.break-all; 95 | self.close(1000); 96 | $receiver.done; 97 | } 98 | } 99 | }, 100 | done => { 101 | self!unexpected-close(); 102 | $receiver.done; 103 | }, 104 | quit => { 105 | self!unexpected-close(); 106 | $receiver.quit($_); 107 | }; 108 | $pp-out.tap: { $!out.emit: .data }, quit => { $!out.quit($_) }; 109 | } 110 | 111 | method !unexpected-close(--> Nil) { 112 | $!pong.break-all; 113 | unless $!closed { 114 | $!closed = True; 115 | try $!closer.keep(self!unexpected-close-message()); 116 | } 117 | } 118 | 119 | method messages(--> Supply) { 120 | $!receiver; 121 | } 122 | 123 | multi method send(Cro::WebSocket::Message $m --> Nil) { 124 | self!ensure-open('send'); 125 | my $serialized = $m.serialization-outcome //= Promise.new; 126 | $!sender.emit($m); 127 | await $serialized; 128 | } 129 | multi method send($m) { 130 | self.send(Cro::WebSocket::Message.new($m)); 131 | } 132 | 133 | method close($code = 1000, :$timeout --> Promise) { 134 | # Double closing has no effect; 135 | return if $!closed; 136 | $!closed = True; 137 | my $p = Promise.new; 138 | start { 139 | my $message = Cro::WebSocket::Message.new(opcode => Cro::WebSocket::Message::Close, 140 | fragmented => False, 141 | body-byte-stream => body($code)); 142 | my $real-timeout = $timeout // 2; 143 | if $real-timeout == False || $real-timeout == 0 { 144 | $!sender.emit: $message; 145 | $!sender.done; 146 | $p.keep($message); 147 | } else { 148 | $!sender.emit: $message; 149 | $!sender.done; 150 | await Promise.anyof(Promise.in($real-timeout), $!closer); 151 | if $!closer.status == Kept { 152 | $p.keep($!closer.result); 153 | } else { 154 | $p.break(self!unexpected-close-message()); 155 | } 156 | } 157 | } 158 | $p; 159 | } 160 | 161 | method !unexpected-close-message() { 162 | Cro::WebSocket::Message.new(opcode => Cro::WebSocket::Message::Close, 163 | fragmented => False, 164 | body-byte-stream => body(1006)) 165 | } 166 | 167 | sub body($code) { 168 | supply { emit Blob.new($code +& 0xFF, ($code +> 8) +& 0xFF); } 169 | } 170 | 171 | method ping($data?, Int :$timeout --> Promise) { 172 | self!ensure-open('ping'); 173 | 174 | my $p = $!pong.get-new; 175 | 176 | with $timeout { 177 | Promise.in($timeout).then: { 178 | unless $p.status ~~ Kept { 179 | # We race with a pong thus the try. 180 | try $p.break; 181 | } 182 | } 183 | } 184 | 185 | $!sender.emit(Cro::WebSocket::Message.new( 186 | opcode => Cro::WebSocket::Message::Ping, 187 | fragmented => False, 188 | body-byte-stream => supply { 189 | emit ($data ?? Blob.new($data.encode) !! Blob.new); 190 | })); 191 | 192 | $p; 193 | } 194 | 195 | method !ensure-open($operation --> Nil) { 196 | die X::Cro::WebSocket::Client::Closed.new(:$operation) if $!closed; 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The Artistic License 2.0 2 | 3 | Copyright (c) 2000-2006, The Perl Foundation. 4 | 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | This license establishes the terms under which a given free software 11 | Package may be copied, modified, distributed, and/or redistributed. 12 | The intent is that the Copyright Holder maintains some artistic 13 | control over the development of that Package while still keeping the 14 | Package available as open source and free software. 15 | 16 | You are always permitted to make arrangements wholly outside of this 17 | license directly with the Copyright Holder of a given Package. If the 18 | terms of this license do not permit the full use that you propose to 19 | make of the Package, you should contact the Copyright Holder and seek 20 | a different licensing arrangement. 21 | 22 | Definitions 23 | 24 | "Copyright Holder" means the individual(s) or organization(s) 25 | named in the copyright notice for the entire Package. 26 | 27 | "Contributor" means any party that has contributed code or other 28 | material to the Package, in accordance with the Copyright Holder's 29 | procedures. 30 | 31 | "You" and "your" means any person who would like to copy, 32 | distribute, or modify the Package. 33 | 34 | "Package" means the collection of files distributed by the 35 | Copyright Holder, and derivatives of that collection and/or of 36 | those files. A given Package may consist of either the Standard 37 | Version, or a Modified Version. 38 | 39 | "Distribute" means providing a copy of the Package or making it 40 | accessible to anyone else, or in the case of a company or 41 | organization, to others outside of your company or organization. 42 | 43 | "Distributor Fee" means any fee that you charge for Distributing 44 | this Package or providing support for this Package to another 45 | party. It does not mean licensing fees. 46 | 47 | "Standard Version" refers to the Package if it has not been 48 | modified, or has been modified only in ways explicitly requested 49 | by the Copyright Holder. 50 | 51 | "Modified Version" means the Package, if it has been changed, and 52 | such changes were not explicitly requested by the Copyright 53 | Holder. 54 | 55 | "Original License" means this Artistic License as Distributed with 56 | the Standard Version of the Package, in its current version or as 57 | it may be modified by The Perl Foundation in the future. 58 | 59 | "Source" form means the source code, documentation source, and 60 | configuration files for the Package. 61 | 62 | "Compiled" form means the compiled bytecode, object code, binary, 63 | or any other form resulting from mechanical transformation or 64 | translation of the Source form. 65 | 66 | 67 | Permission for Use and Modification Without Distribution 68 | 69 | (1) You are permitted to use the Standard Version and create and use 70 | Modified Versions for any purpose without restriction, provided that 71 | you do not Distribute the Modified Version. 72 | 73 | 74 | Permissions for Redistribution of the Standard Version 75 | 76 | (2) You may Distribute verbatim copies of the Source form of the 77 | Standard Version of this Package in any medium without restriction, 78 | either gratis or for a Distributor Fee, provided that you duplicate 79 | all of the original copyright notices and associated disclaimers. At 80 | your discretion, such verbatim copies may or may not include a 81 | Compiled form of the Package. 82 | 83 | (3) You may apply any bug fixes, portability changes, and other 84 | modifications made available from the Copyright Holder. The resulting 85 | Package will still be considered the Standard Version, and as such 86 | will be subject to the Original License. 87 | 88 | 89 | Distribution of Modified Versions of the Package as Source 90 | 91 | (4) You may Distribute your Modified Version as Source (either gratis 92 | or for a Distributor Fee, and with or without a Compiled form of the 93 | Modified Version) provided that you clearly document how it differs 94 | from the Standard Version, including, but not limited to, documenting 95 | any non-standard features, executables, or modules, and provided that 96 | you do at least ONE of the following: 97 | 98 | (a) make the Modified Version available to the Copyright Holder 99 | of the Standard Version, under the Original License, so that the 100 | Copyright Holder may include your modifications in the Standard 101 | Version. 102 | 103 | (b) ensure that installation of your Modified Version does not 104 | prevent the user installing or running the Standard Version. In 105 | addition, the Modified Version must bear a name that is different 106 | from the name of the Standard Version. 107 | 108 | (c) allow anyone who receives a copy of the Modified Version to 109 | make the Source form of the Modified Version available to others 110 | under 111 | 112 | (i) the Original License or 113 | 114 | (ii) a license that permits the licensee to freely copy, 115 | modify and redistribute the Modified Version using the same 116 | licensing terms that apply to the copy that the licensee 117 | received, and requires that the Source form of the Modified 118 | Version, and of any works derived from it, be made freely 119 | available in that license fees are prohibited but Distributor 120 | Fees are allowed. 121 | 122 | 123 | Distribution of Compiled Forms of the Standard Version 124 | or Modified Versions without the Source 125 | 126 | (5) You may Distribute Compiled forms of the Standard Version without 127 | the Source, provided that you include complete instructions on how to 128 | get the Source of the Standard Version. Such instructions must be 129 | valid at the time of your distribution. If these instructions, at any 130 | time while you are carrying out such distribution, become invalid, you 131 | must provide new instructions on demand or cease further distribution. 132 | If you provide valid instructions or cease distribution within thirty 133 | days after you become aware that the instructions are invalid, then 134 | you do not forfeit any of your rights under this license. 135 | 136 | (6) You may Distribute a Modified Version in Compiled form without 137 | the Source, provided that you comply with Section 4 with respect to 138 | the Source of the Modified Version. 139 | 140 | 141 | Aggregating or Linking the Package 142 | 143 | (7) You may aggregate the Package (either the Standard Version or 144 | Modified Version) with other packages and Distribute the resulting 145 | aggregation provided that you do not charge a licensing fee for the 146 | Package. Distributor Fees are permitted, and licensing fees for other 147 | components in the aggregation are permitted. The terms of this license 148 | apply to the use and Distribution of the Standard or Modified Versions 149 | as included in the aggregation. 150 | 151 | (8) You are permitted to link Modified and Standard Versions with 152 | other works, to embed the Package in a larger work of your own, or to 153 | build stand-alone binary or bytecode versions of applications that 154 | include the Package, and Distribute the result without restriction, 155 | provided the result does not expose a direct interface to the Package. 156 | 157 | 158 | Items That are Not Considered Part of a Modified Version 159 | 160 | (9) Works (including, but not limited to, modules and scripts) that 161 | merely extend or make use of the Package, do not, by themselves, cause 162 | the Package to be a Modified Version. In addition, such works are not 163 | considered parts of the Package itself, and are not subject to the 164 | terms of this license. 165 | 166 | 167 | General Provisions 168 | 169 | (10) Any use, modification, and distribution of the Standard or 170 | Modified Versions is governed by this Artistic License. By using, 171 | modifying or distributing the Package, you accept this license. Do not 172 | use, modify, or distribute the Package, if you do not accept this 173 | license. 174 | 175 | (11) If your Modified Version has been derived from a Modified 176 | Version made by someone other than you, you are nevertheless required 177 | to ensure that your Modified Version complies with the requirements of 178 | this license. 179 | 180 | (12) This license does not grant you the right to use any trademark, 181 | service mark, tradename, or logo of the Copyright Holder. 182 | 183 | (13) This license includes the non-exclusive, worldwide, 184 | free-of-charge patent license to make, have made, use, offer to sell, 185 | sell, import and otherwise transfer the Package with respect to any 186 | patent claims licensable by the Copyright Holder that are necessarily 187 | infringed by the Package. If you institute patent litigation 188 | (including a cross-claim or counterclaim) against any party alleging 189 | that the Package constitutes direct or contributory patent 190 | infringement, then this Artistic License to you shall terminate on the 191 | date that such litigation is filed. 192 | 193 | (14) Disclaimer of Warranty: 194 | THE PACKAGE IS PROVIDED BY THE COPYRIGHT HOLDER AND CONTRIBUTORS "AS 195 | IS" AND WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES. THE IMPLIED 196 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR 197 | NON-INFRINGEMENT ARE DISCLAIMED TO THE EXTENT PERMITTED BY YOUR LOCAL 198 | LAW. UNLESS REQUIRED BY LAW, NO COPYRIGHT HOLDER OR CONTRIBUTOR WILL 199 | BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 200 | DAMAGES ARISING IN ANY WAY OUT OF THE USE OF THE PACKAGE, EVEN IF 201 | ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 202 | -------------------------------------------------------------------------------- /xt/websocket-client.rakutest: -------------------------------------------------------------------------------- 1 | use Cro::WebSocket::BodyParsers; 2 | use Cro::WebSocket::BodySerializers; 3 | use Cro::WebSocket::Client; 4 | use Cro::HTTP::Server; 5 | use Cro::HTTP::Router; 6 | use Cro::HTTP::Router::WebSocket; 7 | use JSON::Fast; 8 | use Test; 9 | 10 | constant %ca := { ca-file => 'xt/certs-and-keys/ca-crt.pem' }; 11 | constant %key-cert := { 12 | private-key-file => 'xt/certs-and-keys/server-key.pem', 13 | certificate-file => 'xt/certs-and-keys/server-crt.pem' 14 | }; 15 | 16 | my $app = route { 17 | get -> 'chat' { 18 | web-socket -> $incoming { 19 | supply { 20 | whenever $incoming -> $message { 21 | emit('You said: ' ~ await $message.body-text); 22 | } 23 | } 24 | } 25 | } 26 | get -> 'done' { 27 | web-socket -> $incoming, $close { 28 | supply { 29 | whenever $incoming { 30 | done; 31 | } 32 | } 33 | } 34 | } 35 | get -> 'json' { 36 | web-socket -> $incoming { 37 | supply whenever $incoming { 38 | my $json = from-json await .body-text; 39 | $json = 42; 40 | $json++; 41 | emit to-json $json; 42 | } 43 | } 44 | } 45 | get -> 'pingy-server' { 46 | web-socket -> $incoming { 47 | supply { 48 | whenever $incoming { LAST done } 49 | another(20); 50 | sub another($n) { 51 | if $n { 52 | whenever Promise.in(0.01 * rand) { 53 | emit Cro::WebSocket::Message.new: 54 | :opcode(Cro::WebSocket::Message::Opcode::Ping), 55 | :body('ping'), :!fragmented; 56 | another($n - 1); 57 | } 58 | } 59 | } 60 | } 61 | } 62 | } 63 | get -> 'plain' { 64 | content 'text/plain', 'Hello'; 65 | } 66 | get -> 'headers', :$foo is header, :$bar is header { 67 | web-socket -> $incoming { 68 | supply { 69 | emit("foo=$foo,bar=$bar"); 70 | } 71 | } 72 | } 73 | } 74 | 75 | my $http-server = Cro::HTTP::Server.new(port => 3005, application => $app); 76 | my $https-server = Cro::HTTP::Server.new(port => 3007, application => $app, tls => %key-cert); 77 | $http-server.start; 78 | $https-server.start; 79 | END { $http-server.stop }; 80 | END { $https-server.stop } 81 | 82 | 83 | # Non-Websocket route testing 84 | { 85 | throws-like { 86 | Cro::WebSocket::Client.connect('http://localhost:3005/plain').result; 87 | }, X::Cro::WebSocket::Client::CannotUpgrade, 'Cannot connect to non-websocket route'; 88 | } 89 | 90 | # Done testing 91 | { 92 | my $connection = Cro::WebSocket::Client.connect: 'http://localhost:3005/done'; 93 | 94 | await Promise.anyof($connection, Promise.in(5)); 95 | if $connection.status != Kept { 96 | flunk 'Connection promise is not Kept'; 97 | if $connection.status == Broken { 98 | diag $connection.cause; 99 | } 100 | bail-out; 101 | } else { 102 | $connection = $connection.result; 103 | } 104 | 105 | $connection.send('Foo'); 106 | 107 | # We need to wait until Handler's Close met the Connection 108 | await $connection.messages; 109 | 110 | dies-ok { $connection.send('Bar') }, 'Cannot send anything to closed channel(by done)'; 111 | } 112 | 113 | # Ping testing 114 | { 115 | my $connection = Cro::WebSocket::Client.connect: 'http://localhost:3005/chat'; 116 | 117 | await Promise.anyof($connection, Promise.in(5)); 118 | die "Connection timed out" unless $connection; 119 | 120 | $connection .= result; 121 | 122 | my $ping = $connection.ping; 123 | await Promise.anyof($ping, Promise.in(5)); 124 | ok $ping.status ~~ Kept, 'Empty ping is recieved'; 125 | 126 | $ping = $connection.ping('First'); 127 | await Promise.anyof($ping, Promise.in(5)); 128 | ok $ping.status ~~ Kept, 'Ping is recieved'; 129 | 130 | $ping = $connection.ping(:0timeout); 131 | dies-ok { await $ping }, 'Timeout breaks ping promise'; 132 | 133 | $connection.close; 134 | } 135 | 136 | # Cover a hang when the server sent us pings. 137 | { 138 | my $connection = Cro::WebSocket::Client.connect: 'http://localhost:3005/pingy-server'; 139 | await Promise.anyof($connection, Promise.in(5)); 140 | die "Connection timed out" unless $connection; 141 | $connection .= result; 142 | 143 | my $pinger = start { 144 | for ^20 { 145 | await $connection.ping(); 146 | sleep 0.01; 147 | } 148 | } 149 | await Promise.anyof($pinger, Promise.in(10)); 150 | ok $pinger, "No lockup when server is pinging us while we're sending too"; 151 | $connection.close; 152 | } 153 | 154 | # Chat testing 155 | { 156 | my $connection = Cro::WebSocket::Client.connect: 'http://localhost:3005/chat'; 157 | 158 | await Promise.anyof($connection, Promise.in(5)); 159 | die "Connection timed out" unless $connection; 160 | 161 | $connection .= result; 162 | 163 | my $p = Promise.new; 164 | $connection.messages.tap(-> $mess { 165 | $p.keep: await $mess.body-text 166 | }); 167 | 168 | $connection.send('Hello'); 169 | throws-like { $connection.send(5) }, 170 | X::Cro::BodySerializerSelector::NoneApplicable, 171 | 'If send resulted in error, an exception is thrown'; 172 | 173 | await Promise.anyof($p, Promise.in(5)); 174 | 175 | if $p.status ~~ Kept { 176 | ok $p.result.starts-with('You said:'), "Got expected reply"; 177 | } 178 | else { 179 | flunk "send does not work"; 180 | } 181 | 182 | # Closing 183 | my $closed = $connection.close; 184 | await Promise.anyof($closed, Promise.in(1)); 185 | ok $closed.status ~~ Kept, 'The connection is closed by close() call'; 186 | 187 | dies-ok { $connection.send('Bar') }, 'Cannot send anything to closed channel by close() call'; 188 | } 189 | 190 | # Can send custom headers 191 | { 192 | my $client = Cro::WebSocket::Client.new: 193 | headers => [ 194 | foo => 'potato', 195 | Cro::HTTP::Header.new(name => 'bar', value => 'cabbage') 196 | ]; 197 | my $connection = $client.connect: 'http://localhost:3005/headers'; 198 | 199 | await Promise.anyof($connection, Promise.in(5)); 200 | if $connection.status != Kept { 201 | flunk 'Connection promise is not Kept'; 202 | if $connection.status == Broken { 203 | diag $connection.cause; 204 | } 205 | bail-out; 206 | } else { 207 | $connection = $connection.result; 208 | } 209 | 210 | my $message = await $connection.messages.head; 211 | is await($message.body-text), 'foo=potato,bar=cabbage', 212 | 'Headers correctly transmitted'; 213 | } 214 | 215 | # Body parsers/serializers 216 | { 217 | my $client = Cro::WebSocket::Client.new: 218 | body-parsers => Cro::WebSocket::BodyParser::JSON, 219 | body-serializers => Cro::WebSocket::BodySerializer::JSON; 220 | my $connection = await $client.connect: 'http://localhost:3005/json'; 221 | my $response = $connection.messages.head.Promise; 222 | lives-ok { $connection.send({ kept => 'xxx', updated => 99 }) }, 223 | 'Can send Hash using client with JSON body serializer installed'; 224 | given await $response { 225 | my $body = await .body; 226 | ok $body.isa(Hash), 'Got hash back from body, thanks to JSON body parser'; 227 | is $body, 'xxx', 'Correct hash content (1)'; 228 | is $body, 42, 'Correct hash content (2)'; 229 | is $body, 100, 'Correct hash content (3)'; 230 | } 231 | } 232 | 233 | # The :json option for the client 234 | { 235 | my $client = Cro::WebSocket::Client.new: :json; 236 | my $connection = await $client.connect: 'http://localhost:3005/json'; 237 | my $response = $connection.messages.head.Promise; 238 | lives-ok { $connection.send({ kept => 'xxy', updated => 999 }) }, 239 | 'Can send Hash using client constructed with :json'; 240 | given await $response { 241 | my $body = await .body; 242 | ok $body.isa(Hash), 'Got hash back from body, thanks to :json'; 243 | is $body, 'xxy', 'Correct hash content (1)'; 244 | is $body, 42, 'Correct hash content (2)'; 245 | is $body, 1000, 'Correct hash content (3)'; 246 | } 247 | 248 | dies-ok { $connection.send(-> {}) }, 249 | 'If problem serializing to JSON, it dies'; 250 | } 251 | 252 | # WS / WSS handling 253 | { 254 | my $conn = await Cro::WebSocket::Client.connect('ws://localhost:3005/json'); 255 | ok $conn, 'ws schema is handled'; 256 | $conn.close; 257 | $conn = await Cro::WebSocket::Client.connect('wss://localhost:3007/json', :%ca); 258 | ok $conn, 'wss schema is handled with %ca passed'; 259 | $conn.close; 260 | dies-ok { 261 | await Cro::WebSocket::Client.connect('wss://localhost:3007/json'); 262 | }, 'wss schema fails without %ca argument passed'; 263 | } 264 | 265 | { 266 | my $http-server = Cro::HTTP::Server.new(port => 3010, application => $app); 267 | $http-server.start; 268 | 269 | my $connection = await Cro::WebSocket::Client.connect: 'http://localhost:3010/chat'; 270 | $http-server.stop; 271 | 272 | react { 273 | whenever $connection.messages { 274 | await(.body).print; 275 | LAST { 276 | pass "Client messages Supply did not hang when the server is closed"; 277 | } 278 | } 279 | } 280 | } 281 | 282 | { 283 | my $websocket-block-close = Promise.new; 284 | my $app = route { 285 | get -> 'chat' { 286 | web-socket -> $incoming { 287 | supply { 288 | whenever $incoming -> $message { 289 | } 290 | CLOSE { $websocket-block-close.keep } 291 | } 292 | } 293 | } 294 | } 295 | 296 | my $hello-http-server = Cro::HTTP::Server.new(port => 3012, application => $app); 297 | $hello-http-server.start; 298 | my $connection = await Cro::WebSocket::Client.connect: 'http://localhost:3012/chat'; 299 | $hello-http-server.stop; 300 | await Promise.anyof(Promise.in(3), $websocket-block-close); 301 | is $websocket-block-close.status, Kept, 'Incoming supply block of server was closed'; 302 | } 303 | 304 | done-testing; 305 | --------------------------------------------------------------------------------