├── .dockerdev ├── .bashrc ├── Aptfile ├── Dockerfile └── docker-compose.yml ├── .github └── workflows │ └── build_and_deploy.yml ├── .gitignore ├── .mdlrc ├── .rbnext.rb ├── .rbnextrc ├── Brewfile ├── Brewfile.lock.json ├── CHANGELOG.md ├── LICENSE.txt ├── Makefile ├── README.md ├── Rakefile ├── bintest └── acli.rb ├── build_config.rb ├── build_config.rb.lock ├── dip.yml ├── etc └── server.rb ├── mrbgem.rake ├── mrblib ├── acli.rb └── acli │ ├── cli.rb │ ├── client.rb │ ├── coders.rb │ ├── commands.rb │ ├── core_ext.rb │ ├── logging.rb │ ├── poller.rb │ ├── socket.rb │ ├── utils.rb │ ├── version.rb │ └── websockets_ext.rb ├── test ├── client_test.rb ├── commands_test.rb └── utils_test.rb └── tools └── acli └── acli.c /.dockerdev/.bashrc: -------------------------------------------------------------------------------- 1 | PS1="[\[\e[31m\]\w\[\e[0m\]] " 2 | -------------------------------------------------------------------------------- /.dockerdev/Aptfile: -------------------------------------------------------------------------------- 1 | automake 2 | bison 3 | bzip2 4 | ca-certificates 5 | clang 6 | cpio 7 | file 8 | g++-multilib 9 | gcc-multilib 10 | gobject-introspection 11 | gzip 12 | intltool 13 | libgirepository1.0-dev 14 | libgsf-1-dev 15 | libreadline-dev 16 | libssl-dev 17 | libtool 18 | libxml2-dev 19 | libyaml-dev 20 | llvm-dev 21 | make 22 | patch 23 | sed 24 | uuid-dev 25 | valac 26 | wget 27 | xz-utils 28 | zlib1g-dev 29 | python-pip 30 | -------------------------------------------------------------------------------- /.dockerdev/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG RUBY_VERSION 2 | FROM ruby:$RUBY_VERSION-buster 3 | 4 | # Common dependencies 5 | RUN apt-get update -qq \ 6 | && DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \ 7 | build-essential \ 8 | gnupg2 \ 9 | curl \ 10 | less \ 11 | git \ 12 | && apt-get clean \ 13 | && rm -rf /var/cache/apt/archives/* \ 14 | && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \ 15 | && truncate -s 0 /var/log/*log 16 | 17 | ENV LANG=C.UTF-8 18 | 19 | # Install mruby dependencies 20 | COPY ./Aptfile /tmp/Aptfile 21 | RUN apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get -yq dist-upgrade && \ 22 | DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \ 23 | $(cat /tmp/Aptfile | xargs) && \ 24 | apt-get clean && \ 25 | rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && \ 26 | truncate -s 0 /var/log/*log 27 | 28 | # Update Ruby tools 29 | RUN gem update --system 30 | 31 | WORKDIR /home/mruby/src 32 | 33 | # Install wslay 34 | RUN pip install sphinx && \ 35 | git clone --depth 1 --branch release-1.1.0 https://github.com/tatsuhiro-t/wslay && \ 36 | (cd wslay && \ 37 | autoreconf -i && \ 38 | automake && \ 39 | autoconf && \ 40 | ./configure --prefix=/home/mruby/opt/wslay && \ 41 | make) && \ 42 | rm -rf wslay 43 | 44 | ENV PKG_CONFIG_PATH=/home/mruby/opt/wslay:$PKG_CONFIG_PATH 45 | ENV LD_LIBRARY_PATH=/home/mruby/wslay/lib:$LD_LIBRARY_PATH 46 | 47 | # Install libressl 48 | RUN curl http://ftp.openbsd.org/pub/OpenBSD/LibreSSL/libressl-2.5.4.tar.gz | tar -xzv && \ 49 | mv /home/mruby/src/libressl-2.5.4 /home/mruby/src/libressl && \ 50 | (cd libressl && \ 51 | ./configure --prefix=/home/mruby/opt/libressl && \ 52 | make && make check && \ 53 | make install) && \ 54 | rm -rf libressl 55 | 56 | ENV PKG_CONFIG_PATH=/home/mruby/opt/libressl:$PKG_CONFIG_PATH 57 | ENV LD_LIBRARY_PATH=/home/mruby/opt/libressl/lib:$LD_LIBRARY_PATH 58 | 59 | WORKDIR /home/mruby/code 60 | -------------------------------------------------------------------------------- /.dockerdev/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2.4' 2 | 3 | services: 4 | dev: &dev 5 | build: 6 | context: . 7 | dockerfile: ./Dockerfile 8 | args: 9 | RUBY_VERSION: '2.6.3' 10 | stdin_open: true 11 | tty: true 12 | image: palkan/acli-dev:0.1.4 13 | working_dir: ${PWD} 14 | volumes: 15 | - ..:/${PWD}:cached 16 | - ./.bashrc:/root/.bashrc:ro 17 | - bundle:/usr/local/bundle 18 | - mruby:/${PWD}/mruby 19 | - docker_etc:/_etc:cached 20 | environment: 21 | HISTFILE: /_etc/.bash_history 22 | PROMPT_DIRTRIM: 2 23 | tmpfs: 24 | - /tmp 25 | 26 | volumes: 27 | mruby: 28 | bundle: 29 | docker_etc: 30 | -------------------------------------------------------------------------------- /.github/workflows/build_and_deploy.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | test_linux: 12 | runs-on: ubuntu-latest 13 | container: 14 | image: anycable/acli-dev 15 | steps: 16 | - uses: actions/checkout@v2 17 | - uses: actions/cache@v1 18 | with: 19 | path: mruby 20 | key: mruby3-${{ hashFiles('**/build_config.rb.lock') }} 21 | restore-keys: | 22 | mruby3- 23 | - name: Install test deps 24 | run: | 25 | gem install unparser -v 0.6.4 26 | gem install childprocess ruby-next 27 | - name: Test 28 | run: | 29 | rake nextify 30 | rake host_clean 31 | rake test 32 | - name: Build 33 | if: contains(github.ref, 'master') 34 | env: 35 | BUILD_TARGET: Linux-x86_64 36 | run: | 37 | rake host_clean 38 | rake compile 39 | - name: Upload linux build 40 | if: contains(github.ref, 'master') 41 | uses: actions/upload-artifact@v1 42 | with: 43 | name: acli-Linux-x86_64 44 | path: mruby/build/Linux-x86_64/bin/acli 45 | 46 | test_macos: 47 | runs-on: macos-latest 48 | steps: 49 | - uses: actions/checkout@v2 50 | - uses: actions/cache@v1 51 | with: 52 | path: mruby 53 | key: mruby3-${{ hashFiles('**/build_config.rb.lock') }} 54 | restore-keys: | 55 | mruby3- 56 | - name: Install Homebrew 57 | run: /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install.sh)" 58 | - name: Install deps 59 | run: brew bundle 60 | - name: Install test deps 61 | run: gem install childprocess ruby-next 62 | - name: Test 63 | run: | 64 | rake nextify 65 | rake host_clean 66 | rake test 67 | - name: Build 68 | if: contains(github.ref, 'master') 69 | env: 70 | BUILD_TARGET: Darwin-x86_64 71 | run: make build-macos 72 | - name: Upload MacOS build 73 | if: contains(github.ref, 'master') 74 | uses: actions/upload-artifact@v1 75 | with: 76 | name: acli-Darwin-x86_64 77 | path: mruby/build/Darwin-x86_64/bin/acli 78 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | mruby/ 2 | dist/ 3 | *.tar.xz 4 | .mrb 5 | mrblib/.rbnext 6 | -------------------------------------------------------------------------------- /.mdlrc: -------------------------------------------------------------------------------- 1 | rules "~MD013", "~MD033", "~MD041" 2 | -------------------------------------------------------------------------------- /.rbnext.rb: -------------------------------------------------------------------------------- 1 | module MRubyNext 2 | refine MRuby::Gem::Specification do 3 | # Add transpiled files to the list of Ruby files instead of source files 4 | def setup_ruby_next!(next_dir: ".rbnext") 5 | lib_root = File.join(@dir, "mrblib") 6 | 7 | next_root = Pathname.new(next_dir).absolute? ? next_dir : File.join(lib_root, next_dir) 8 | 9 | Dir.glob("#{next_root}/**/*.rb").each do |next_file| 10 | orig_file = next_file.sub(next_root, lib_root) 11 | index = @rbfiles.index(orig_file) 12 | raise "Source file not found for: #{next_file}" unless index 13 | @rbfiles[index] = next_file 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /.rbnextrc: -------------------------------------------------------------------------------- 1 | nextify: | 2 | ./mrblib 3 | --no-refine 4 | --rewrite=pattern-matching 5 | --single-version 6 | --transpile-mode=rewrite 7 | --proposed 8 | 9 | core_ext: | 10 | --name=deconstruct 11 | --name=patternerror 12 | -------------------------------------------------------------------------------- /Brewfile: -------------------------------------------------------------------------------- 1 | brew "wslay" 2 | brew "libressl" 3 | -------------------------------------------------------------------------------- /Brewfile.lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "entries": { 3 | "brew": { 4 | "wslay": { 5 | "version": "1.1.1", 6 | "bottle": { 7 | "rebuild": 0, 8 | "root_url": "https://ghcr.io/v2/homebrew/core", 9 | "files": { 10 | "arm64_monterey": { 11 | "cellar": ":any", 12 | "url": "https://ghcr.io/v2/homebrew/core/wslay/blobs/sha256:f09d67afcce3498de58ebcb20cf0c5478ff9cd909fa3841a9545e526c31f9b34", 13 | "sha256": "f09d67afcce3498de58ebcb20cf0c5478ff9cd909fa3841a9545e526c31f9b34" 14 | }, 15 | "arm64_big_sur": { 16 | "cellar": ":any", 17 | "url": "https://ghcr.io/v2/homebrew/core/wslay/blobs/sha256:3921e0d42b7388dd8229d2019d67319330b7c53e862c120612b72565a7eff37f", 18 | "sha256": "3921e0d42b7388dd8229d2019d67319330b7c53e862c120612b72565a7eff37f" 19 | }, 20 | "monterey": { 21 | "cellar": ":any", 22 | "url": "https://ghcr.io/v2/homebrew/core/wslay/blobs/sha256:9d44bad51a861ee84b5cbdf755d7f786b4b54b49441cc5e424b1921983de0d7d", 23 | "sha256": "9d44bad51a861ee84b5cbdf755d7f786b4b54b49441cc5e424b1921983de0d7d" 24 | }, 25 | "big_sur": { 26 | "cellar": ":any", 27 | "url": "https://ghcr.io/v2/homebrew/core/wslay/blobs/sha256:aa3c50a846b0e72238f22dc55ff1d158e2d2845c75997f6d508383be122d4f8f", 28 | "sha256": "aa3c50a846b0e72238f22dc55ff1d158e2d2845c75997f6d508383be122d4f8f" 29 | }, 30 | "catalina": { 31 | "cellar": ":any", 32 | "url": "https://ghcr.io/v2/homebrew/core/wslay/blobs/sha256:b0c31393b4065ddad22d079252f4310ccafee1c26d5ea56a58c2bc3bfa728b46", 33 | "sha256": "b0c31393b4065ddad22d079252f4310ccafee1c26d5ea56a58c2bc3bfa728b46" 34 | }, 35 | "mojave": { 36 | "cellar": ":any", 37 | "url": "https://ghcr.io/v2/homebrew/core/wslay/blobs/sha256:4ea82d98c0fd0cfcc1e842dde6e0fbd15355d538876f24fa0c2ca6f05ed17926", 38 | "sha256": "4ea82d98c0fd0cfcc1e842dde6e0fbd15355d538876f24fa0c2ca6f05ed17926" 39 | }, 40 | "high_sierra": { 41 | "cellar": ":any", 42 | "url": "https://ghcr.io/v2/homebrew/core/wslay/blobs/sha256:6aade683b7db8a32c859e54134568bdb3983d57878783d86c89e5d28c5e8db77", 43 | "sha256": "6aade683b7db8a32c859e54134568bdb3983d57878783d86c89e5d28c5e8db77" 44 | }, 45 | "x86_64_linux": { 46 | "cellar": ":any_skip_relocation", 47 | "url": "https://ghcr.io/v2/homebrew/core/wslay/blobs/sha256:eee1f87dcfd142d6131fdb354f5aacdfc22991d8666e267dc5ff7fcc6df57eff", 48 | "sha256": "eee1f87dcfd142d6131fdb354f5aacdfc22991d8666e267dc5ff7fcc6df57eff" 49 | } 50 | } 51 | } 52 | }, 53 | "libressl": { 54 | "version": "3.5.3", 55 | "bottle": { 56 | "rebuild": 0, 57 | "root_url": "https://ghcr.io/v2/homebrew/core", 58 | "files": { 59 | "arm64_monterey": { 60 | "cellar": "/opt/homebrew/Cellar", 61 | "url": "https://ghcr.io/v2/homebrew/core/libressl/blobs/sha256:69c8b3bd77a93b7d66c10547d7513989422d59eff4f51d52b5bc4df5be7c6527", 62 | "sha256": "69c8b3bd77a93b7d66c10547d7513989422d59eff4f51d52b5bc4df5be7c6527" 63 | }, 64 | "arm64_big_sur": { 65 | "cellar": "/opt/homebrew/Cellar", 66 | "url": "https://ghcr.io/v2/homebrew/core/libressl/blobs/sha256:a7e45093051a0a7961d88caa88002864eac2d00b1eca53cc75cf35c471d46680", 67 | "sha256": "a7e45093051a0a7961d88caa88002864eac2d00b1eca53cc75cf35c471d46680" 68 | }, 69 | "monterey": { 70 | "cellar": "/usr/local/Cellar", 71 | "url": "https://ghcr.io/v2/homebrew/core/libressl/blobs/sha256:183d6b2c20714d89aea7522bdf0cdedab4490a11f8f56671f155362f3231d98b", 72 | "sha256": "183d6b2c20714d89aea7522bdf0cdedab4490a11f8f56671f155362f3231d98b" 73 | }, 74 | "big_sur": { 75 | "cellar": "/usr/local/Cellar", 76 | "url": "https://ghcr.io/v2/homebrew/core/libressl/blobs/sha256:9afd1be45a3f183c8b2ac2fe5ed5c8defc3fd9a5ef8b4e2db9ab2d7122f29692", 77 | "sha256": "9afd1be45a3f183c8b2ac2fe5ed5c8defc3fd9a5ef8b4e2db9ab2d7122f29692" 78 | }, 79 | "catalina": { 80 | "cellar": "/usr/local/Cellar", 81 | "url": "https://ghcr.io/v2/homebrew/core/libressl/blobs/sha256:ed9f90222d3d7ea6382bedc140bcaee1242080afcff3a7bf38b17083c929dd0e", 82 | "sha256": "ed9f90222d3d7ea6382bedc140bcaee1242080afcff3a7bf38b17083c929dd0e" 83 | }, 84 | "x86_64_linux": { 85 | "cellar": "/home/linuxbrew/.linuxbrew/Cellar", 86 | "url": "https://ghcr.io/v2/homebrew/core/libressl/blobs/sha256:187419900c62a0673ef001737a8ccd8b3a336077faa093593e2d305cfb141148", 87 | "sha256": "187419900c62a0673ef001737a8ccd8b3a336077faa093593e2d305cfb141148" 88 | } 89 | } 90 | } 91 | } 92 | } 93 | }, 94 | "system": { 95 | "macos": { 96 | "monterey": { 97 | "HOMEBREW_VERSION": "3.5.9", 98 | "HOMEBREW_PREFIX": "/opt/homebrew", 99 | "Homebrew/homebrew-core": "f7e407a30792600ef8fc95972b373ed73087f380", 100 | "CLT": "13.4.0.0.1.1651278267", 101 | "Xcode": "13.4", 102 | "macOS": "12.5" 103 | } 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | ## master 4 | 5 | ## 0.6.0 (2023-08-09) 6 | 7 | - Support pongs. ([@palkan][]) 8 | 9 | Enable pongs by providing the `--pong` optipn. 10 | 11 | - Show messages meta information (stream ID, offset, etc.). ([@palkan][]) 12 | 13 | - Add `\h offset:` support. 14 | 15 | ## 0.5.0 (2023-02-28) 16 | 17 | - Add `--debug` option to print debug information. ([@palkan][]) 18 | 19 | - Fix URL normalization (do not downcase, 'cause it can break query parameters). ([@palkan][]) 20 | 21 | ## 0.4.1 (2022-08-10) 22 | 23 | - Add history (`\h`) command. ([@palkan][]) 24 | 25 | The history command only allows to fetch messages from the specified time. 26 | 27 | ## 0.4.0 (2021-05-20) 28 | 29 | - Add Msgpack support (`--msgpack`). ([@palkan][]) 30 | 31 | - Add WS sub-protocols support. ([@palkan][]) 32 | 33 | - Upgrade to mruby 3.0. ([@palkan][]) 34 | 35 | ## 0.3.1 (2020-06-30) 36 | 37 | - Add `--channel-params` option. ([@palkan][]) 38 | 39 | Now you can connect and subscribe to a parameterized channel via a single command. 40 | 41 | - Fix using namespaced Ruby classes as channel names. ([@palkan][]) 42 | 43 | - Handle `reject_subscription` message. ([@palkan][]) 44 | 45 | ## 0.3.0 (2020-04-21) 46 | 47 | - Handle `disconnect` messages. ([@palkan][]) 48 | 49 | - Add HTTP headers support. ([@palkan][]) 50 | 51 | - Remove `-m` and add `--quit-after` option. ([@palkan][]) 52 | 53 | - Fix query string support. ([@palkan][]) 54 | 55 | ## 0.2.0 (2017-06-14) 56 | 57 | - Add TLS support. ([@palkan][]) 58 | 59 | Support connection to secure endpoints, i.e. `acli -u wss://example.com/cable`. 60 | 61 | [@palkan]: https://github.com/palkan 62 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017-2020 Vladimir Dementyev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | HOMEBREW_PREFIX ?= "/usr/local" 2 | 3 | build-macos: 4 | rake host_clean 5 | mkdir -p $(HOMEBREW_PREFIX)/opt/libressl/lib/dylibs 6 | (cd $(HOMEBREW_PREFIX)/opt/libressl/lib && ls | grep .dylib | xargs -I@ mv $(HOMEBREW_PREFIX)/opt/libressl/lib/@ $(HOMEBREW_PREFIX)/opt/libressl/lib/dylibs/@) 7 | BUILD_TARGET=Darwin-x86_64 rake compile 8 | (cd $(HOMEBREW_PREFIX)/opt/libressl/lib/dylibs && ls | grep .dylib | xargs -I@ mv $(HOMEBREW_PREFIX)/opt/libressl/lib/dylibs/@ $(HOMEBREW_PREFIX)/opt/libressl/lib/@) 9 | 10 | ACLI_VERSION := $(shell sh -c 'cat .dockerdev/docker-compose.yml | grep "acli-dev" | sed "s/ image: palkan\/acli-dev://"') 11 | 12 | anycable/acli-dev: 13 | docker push anycable/acli-dev:$(ACLI_VERSION) 14 | docker tag anycable/acli-dev:$(ACLI_VERSION) anycable/acli-dev:latest 15 | docker push anycable/acli-dev:latest 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Build](https://github.com/palkan/acli/workflows/Build/badge.svg) 2 | 3 | # Action Cable CLI 4 | 5 | ACLI is an Action Cable command-line interface written in [mRuby](http://mruby.org). 6 | 7 | It's a standalone binary which can be used: 8 | 9 | - In development for playing with Action Cable channels (instead of struggling with browsers) 10 | 11 | - For monitoring and benchmarking. 12 | 13 | 14 | Sponsored by Evil Martians 15 | 16 | ## Installation 17 | 18 | Currently only MacOS (x86\_64 and ARM) and Linux (x86\_64) are supported. 19 | **PRs are welcomed** for other platforms support. 20 | 21 | ### Precompiled binaries 22 | 23 | See GitHub [releases](https://github.com/palkan/acli/releases). You can download the latest release by using cURL: 24 | 25 | ```sh 26 | curl -L https://github.com/palkan/acli/releases/latest/download/acli-`uname -s`-`uname -m` > /usr/local/bin/acli 27 | chmod +x /usr/local/bin/acli 28 | ``` 29 | 30 | You can also find edge (master) builds in [Actions](https://github.com/palkan/acli/actions). 31 | 32 | ### Homebrew 33 | 34 | Coming soon 35 | 36 | ## Usage 37 | 38 | ACLI is an interactive tool by design, i.e., it is asking you for input if necessary. 39 | Just run it without any arguments: 40 | 41 | ```sh 42 | $ acli 43 | 44 | Enter URL: 45 | 46 | # After successful connection you receive a message: 47 | Connected to Action Cable at http://example.com/cable 48 | ``` 49 | 50 | Then you can run Action Cable commands: 51 | 52 | ```sh 53 | # Subscribe to channel (without parameters) 54 | \s channel_name 55 | 56 | # Subscribe to channel with params 57 | 58 | \s+ channel_name id:1 59 | 60 | # or interactively 61 | 62 | \s+ 63 | Enter channel ID: 64 | ... 65 | # Generate params object by providing keys and values one by one 66 | Enter key (or press ENTER to finish): 67 | ... 68 | Enter value: 69 | # After successful subscription you receive a message 70 | Subscribed to channel_name 71 | 72 | 73 | # Performing actions 74 | \p speak message:Hello! 75 | 76 | # or interactively (the same way as \s+) 77 | \p+ 78 | 79 | 80 | # Retrieving the channel's history since the specified time 81 | 82 | # Relative, for example, 10 minutes ago ("h" and "s" modifiers are also supported) 83 | \h since:10m 84 | 85 | # Absolute since the UTC time (seconds) 86 | \h since:1650634535 87 | ``` 88 | 89 | You can also provide URL and channel info using CLI options: 90 | 91 | ```sh 92 | acli -u http://example.com/cable -c channel_name 93 | 94 | # or using full option names 95 | acli --url=http://example.com/cable --channel=channel_name 96 | 97 | # you can omit scheme and even path (/cable is used by default) 98 | acli -u example.com 99 | ``` 100 | 101 | To pass channel params use `--channel-params` option: 102 | 103 | ```sh 104 | acli -u http://example.com/cable -c ChatChannel --channel-params=id:1,name:"Jack" 105 | ``` 106 | 107 | You can pass additional request headers: 108 | 109 | ```sh 110 | acli -u example.com --headers="x-api-token:secret,cookie:username=john" 111 | ``` 112 | 113 | Other commands: 114 | 115 | ```sh 116 | # Print help 117 | \? 118 | 119 | # Quit 120 | \q 121 | ``` 122 | 123 | Other command-line options: 124 | 125 | ```sh 126 | # Print usage 127 | acli -h 128 | 129 | # Print version 130 | acli -v 131 | 132 | # Quit after M incoming messages (excluding pings and system messages) 133 | acli -u http://example.com/cable -c channel_name --quit-after=M 134 | 135 | # Enabling PONG commands in response to PINGs 136 | acli -u http://example.com/cable --pong 137 | ``` 138 | 139 | ## Development 140 | 141 | We have Docker & [Dip](https://github.com/bibendi/dip) configuration for development: 142 | 143 | ```sh 144 | # initial provision 145 | dip provision 146 | 147 | # run rake tasks 148 | dip rake test 149 | 150 | # or open a console within a container 151 | dip bash 152 | ``` 153 | 154 | You can also build the project _locally_ (on MacOS or Linux): `rake compile` or `rake test`. 155 | 156 | ### Requirements 157 | 158 | - [Ruby Next](https://github.com/ruby-next/ruby-next) (`gem install ruby-next`) 159 | 160 | - [libressl](https://www.libressl.org/) (`brew install libressl`) 161 | 162 | - [wslay](https://github.com/tatsuhiro-t/wslay) (`brew install libressl`) 163 | 164 | - [childprocess](https://github.com/enkessler/childprocess) gem for testing (`gem install childprocess`) 165 | 166 | ## Contributing 167 | 168 | Bug reports and pull requests are welcome on [GitHub](https://github.com/palkan/acli). 169 | 170 | ## License 171 | 172 | The gem is available as open-source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 173 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "fileutils" 2 | 3 | MRUBY_VERSION = "3.0.0" 4 | 5 | task :mruby do 6 | sh "git clone --branch=#{MRUBY_VERSION} --depth=1 https://github.com/mruby/mruby" 7 | end 8 | 9 | APP_NAME = ENV["APP_NAME"] || "acli" 10 | APP_ROOT = ENV["APP_ROOT"] || Dir.pwd 11 | # avoid redefining constants in mruby Rakefile 12 | mruby_root = File.expand_path(ENV["MRUBY_ROOT"] || "#{APP_ROOT}/mruby") 13 | mruby_config = File.expand_path(ENV["MRUBY_CONFIG"] || "build_config.rb") 14 | ENV["MRUBY_ROOT"] = mruby_root 15 | ENV["MRUBY_CONFIG"] = mruby_config 16 | 17 | Rake::Task[:mruby].invoke unless Dir.exist?(File.join(mruby_root, "lib")) 18 | 19 | Dir.chdir(mruby_root) 20 | load "#{mruby_root}/Rakefile" 21 | 22 | desc "compile binary" 23 | task compile: [:all] do 24 | %W(#{mruby_root}/build/x86_64-pc-linux-gnu/bin/#{APP_NAME} #{mruby_root}/build/i686-pc-linux-gnu/#{APP_NAME}").each do |bin| 25 | sh "strip --strip-unneeded #{bin}" if File.exist?(bin) 26 | end 27 | end 28 | 29 | Rake::Task["test"].clear 30 | desc "run all tests" 31 | task test: ["nextify", "test:build:lib", "test:run:lib", "test:run:bin"] 32 | 33 | desc "cleanup" 34 | task :clean do 35 | sh "rake deep_clean" 36 | end 37 | 38 | desc "clean host build without deleting gems" 39 | task :host_clean do 40 | sh "rm -rf #{File.join(mruby_root, "build", "host")}" 41 | end 42 | 43 | desc "run build" 44 | task run: :default do 45 | exec File.join(mruby_root, "bin", APP_NAME) 46 | end 47 | 48 | desc "run mirb" 49 | task irb: :default do 50 | exec File.join(mruby_root, "bin", "mirb") 51 | end 52 | 53 | Rake::Task["run"].clear 54 | 55 | desc "run compiled binary" 56 | task run: :compile do 57 | args = 58 | if (split_index = ARGV.index("--")) 59 | ARGV[(split_index+1)..-1] 60 | else 61 | [] 62 | end 63 | 64 | sh "bin/acli #{args.join(" ")}" 65 | end 66 | 67 | desc "transpile source code with ruby-next" 68 | task :nextify do 69 | Dir.chdir(APP_ROOT) do 70 | sh "ruby-next nextify -V" 71 | end 72 | end 73 | 74 | namespace :rbnext do 75 | desc "generate core extensions file" 76 | task core_ext: [] do 77 | Dir.chdir(APP_ROOT) do 78 | sh "ruby-next core_ext -o mrblib/acli/core_ext.rb" 79 | end 80 | end 81 | end 82 | 83 | Rake::Task[:gensym].enhance [:nextify] 84 | -------------------------------------------------------------------------------- /bintest/acli.rb: -------------------------------------------------------------------------------- 1 | require_relative "../mrblib/acli/version" 2 | require "socket" 3 | require "json" 4 | require "childprocess" 5 | 6 | BIN_PATH = File.join(File.dirname(__FILE__), "../mruby/bin/acli") 7 | 8 | class AcliProcess 9 | attr_reader :process, :wio, :rio 10 | 11 | def initialize(*args, bin: BIN_PATH) 12 | @rio, @wio = IO.pipe 13 | @process = ChildProcess.build(bin, *args) 14 | process.io.stdout = process.io.stderr = @wio 15 | process.duplex = true 16 | end 17 | 18 | def running 19 | process.start 20 | yield process 21 | ensure 22 | process.stop 23 | end 24 | 25 | def readline(time = 0.99) 26 | line = nil 27 | thread = Thread.new { line = rio.readline } 28 | 29 | loop do 30 | return line if line 31 | sleep 0.2 32 | 33 | time -= 0.2 34 | 35 | if time < 0 36 | thread.terminate 37 | return "" 38 | end 39 | end 40 | end 41 | 42 | def puts(*args) 43 | process.io.stdin.puts(*args) 44 | end 45 | end 46 | 47 | assert("with invalid url") do 48 | acli = AcliProcess.new("-u", "localhost:80:80") 49 | acli.running do |process| 50 | assert_include acli.readline, "Error: " 51 | assert_false process.alive? 52 | assert_equal 1, process.exit_code 53 | end 54 | end 55 | 56 | assert("version") do 57 | acli = AcliProcess.new("-v") 58 | 59 | acli.running do |process| 60 | assert_include acli.readline, "v#{Acli::VERSION}" 61 | assert_false process.alive? 62 | assert_equal 0, process.exit_code 63 | end 64 | end 65 | 66 | assert("help") do 67 | acli = AcliProcess.new("-h") 68 | 69 | acli.running do |process| 70 | assert_include( 71 | acli.readline, 72 | "Usage: acli [options]" 73 | ) 74 | assert_false process.alive? 75 | assert_equal 0, process.exit_code 76 | end 77 | end 78 | 79 | SERVER_RUNNING = 80 | begin 81 | server_process = AcliProcess.new(bin: File.join(__dir__, "../etc/server.rb")) 82 | $stdout.puts "\nStarting test server..." 83 | server_process.process.leader = true 84 | server_process.process.start 85 | 86 | # Wait 'till server is ready 87 | loop do 88 | line = server_process.readline 89 | if line =~ /Listening on/ 90 | $stdout.puts "Server started" 91 | at_exit { server_process.process.stop } 92 | break true 93 | end 94 | 95 | break false unless server_process.process.alive? 96 | 97 | sleep 1 if line =~ /Timed out/ 98 | end 99 | end 100 | 101 | return $stdout.puts("Skip integration tests: server is not runnnig") unless SERVER_RUNNING 102 | 103 | assert("connects successfully") do 104 | acli = AcliProcess.new("-u", "localhost:8080") 105 | acli.running do |process| 106 | assert_true process.alive?, "Process failed" 107 | assert_include(acli.readline, "Connected to Action Cable at ws://localhost:8080/cable") 108 | end 109 | end 110 | 111 | assert("connects with query") do 112 | acli = AcliProcess.new("-u", "localhost:8080/cable?token=secret#page=home") 113 | acli.running do |process| 114 | assert_true process.alive?, "Process failed" 115 | assert_include( 116 | acli.readline, 117 | "Connected to Action Cable at ws://localhost:8080/cable?token=secret" 118 | ) 119 | end 120 | end 121 | 122 | assert("subscribes to a channel") do 123 | acli = AcliProcess.new("-u", "localhost:8080") 124 | acli.running do |process| 125 | assert_true process.alive?, "Process failed" 126 | assert_include( 127 | acli.readline, 128 | "Connected to Action Cable" 129 | ) 130 | 131 | acli.puts '\s DemoChannel' 132 | assert_include( 133 | acli.readline, 134 | "Subscribed to DemoChannel" 135 | ) 136 | end 137 | end 138 | 139 | assert("handles subsciption rejection") do 140 | acli = AcliProcess.new("-u", "localhost:8080") 141 | acli.running do |process| 142 | assert_true process.alive?, "Process failed" 143 | assert_include( 144 | acli.readline, 145 | "Connected to Action Cable" 146 | ) 147 | 148 | acli.puts '\s ProtectedChannel' 149 | 150 | assert_include( 151 | acli.readline, 152 | "Subscription rejected" 153 | ) 154 | end 155 | end 156 | 157 | assert("subscribes to a channel with int param") do 158 | acli = AcliProcess.new("-u", "localhost:8080") 159 | acli.running do |process| 160 | assert_true process.alive?, "Process failed" 161 | assert_include( 162 | acli.readline, 163 | "Connected to Action Cable" 164 | ) 165 | 166 | acli.puts '\s EchoChannel param:1' 167 | assert_include( 168 | acli.readline, 169 | "Subscribed to EchoChannel" 170 | ) 171 | 172 | acli.puts '\p echo_params' 173 | assert_include( 174 | acli.readline, 175 | {param: 1}.to_json 176 | ) 177 | end 178 | end 179 | 180 | assert("subscribes to a channel with string param") do 181 | acli = AcliProcess.new("-u", "localhost:8080") 182 | acli.running do |process| 183 | assert_true process.alive?, "Process failed" 184 | assert_include( 185 | acli.readline, 186 | "Connected to Action Cable" 187 | ) 188 | 189 | acli.puts '\s EchoChannel param:"Test"' 190 | assert_include( 191 | acli.readline, 192 | "Subscribed to EchoChannel" 193 | ) 194 | 195 | acli.puts '\p echo_params' 196 | assert_include( 197 | acli.readline, 198 | {param: "Test"}.to_json 199 | ) 200 | end 201 | end 202 | 203 | assert("subscribes to a namespaced channel with params") do 204 | acli = AcliProcess.new("-u", "localhost:8080") 205 | acli.running do |process| 206 | assert_true process.alive?, "Process failed" 207 | assert_include( 208 | acli.readline, 209 | "Connected to Action Cable" 210 | ) 211 | 212 | acli.puts '\s Namespaced::EchoChannel param:1 p_a_m:"str"' 213 | 214 | assert_include( 215 | acli.readline, 216 | "Subscribed to Namespaced::EchoChannel" 217 | ) 218 | 219 | acli.puts '\p echo_params' 220 | assert_include( 221 | acli.readline, 222 | {param: 1, p_a_m: "str"}.to_json 223 | ) 224 | end 225 | end 226 | 227 | assert("subscribes with channel params") do 228 | acli = AcliProcess.new("-u", "localhost:8080", "--channel", "Namespaced::EchoChannel", "--channel-params", "param:1,p_a_m:'str'") 229 | acli.running do |process| 230 | assert_true process.alive?, "Process failed" 231 | assert_include( 232 | acli.readline, 233 | "Connected to Action Cable" 234 | ) 235 | 236 | assert_include( 237 | acli.readline, 238 | "Subscribed to Namespaced::EchoChannel" 239 | ) 240 | 241 | acli.puts '\p echo_params' 242 | assert_include( 243 | acli.readline, 244 | {param: 1, p_a_m: "str"}.to_json 245 | ) 246 | end 247 | end 248 | 249 | assert("performs action") do 250 | acli = AcliProcess.new("-u", "localhost:8080", "--channel", "EchoChannel") 251 | acli.running do |process| 252 | assert_true process.alive?, "Process failed" 253 | assert_include( 254 | acli.readline, 255 | "Connected to Action Cable" 256 | ) 257 | assert_include( 258 | acli.readline, 259 | "Subscribed to EchoChannel" 260 | ) 261 | 262 | acli.puts '\p echo msg:"hello"' 263 | assert_include( 264 | acli.readline, 265 | {pong: {msg: "hello"}}.to_json 266 | ) 267 | end 268 | end 269 | 270 | assert("request query is supported") do 271 | acli = AcliProcess.new("-u", "localhost:8080/cable?token=s3cr3t", "-c", "EchoChannel") 272 | acli.running do |process| 273 | assert_true process.alive?, "Process failed" 274 | assert_include( 275 | acli.readline, 276 | "Connected to Action Cable" 277 | ) 278 | assert_include( 279 | acli.readline, 280 | "Subscribed to EchoChannel" 281 | ) 282 | 283 | acli.puts '\p echo_token' 284 | assert_include( 285 | acli.readline, 286 | {token: "s3cr3t"}.to_json 287 | ) 288 | end 289 | end 290 | 291 | assert("connects with header") do 292 | acli = AcliProcess.new("-u", "localhost:8080", "-c", "EchoChannel", "--headers", "x-api-token:secretos") 293 | acli.running do |process| 294 | assert_true process.alive?, "Process failed" 295 | assert_include( 296 | acli.readline, 297 | "Connected to Action Cable" 298 | ) 299 | assert_include( 300 | acli.readline, 301 | "Subscribed to EchoChannel" 302 | ) 303 | 304 | acli.puts '\p echo_token' 305 | assert_include( 306 | acli.readline, 307 | {token: "secretos"}.to_json 308 | ) 309 | end 310 | end 311 | 312 | assert("connects with multiple headers") do 313 | acli = AcliProcess.new("-u", "localhost:8080", "-c", "EchoChannel", "--headers", "x-api-token:secretos,x-api-gate:21") 314 | acli.running do |process| 315 | assert_true process.alive?, "Process failed" 316 | assert_include( 317 | acli.readline, 318 | "Connected to Action Cable" 319 | ) 320 | assert_include( 321 | acli.readline, 322 | "Subscribed to EchoChannel" 323 | ) 324 | 325 | acli.puts '\p echo_headers' 326 | assert_include( 327 | acli.readline, 328 | [["HTTP_X_API_TOKEN","secretos"],["HTTP_X_API_GATE", "21"]].to_json 329 | ) 330 | end 331 | end 332 | 333 | assert("connects with cookie header") do 334 | acli = AcliProcess.new("-u", "localhost:8080", "-c", "EchoChannel", "--headers", "cookie:token=nookie") 335 | acli.running do |process| 336 | assert_true process.alive?, "Process failed" 337 | assert_include( 338 | acli.readline, 339 | "Connected to Action Cable" 340 | ) 341 | assert_include( 342 | acli.readline, 343 | "Subscribed to EchoChannel" 344 | ) 345 | 346 | acli.puts '\p echo_token' 347 | assert_include( 348 | acli.readline, 349 | {token: "nookie"}.to_json 350 | ) 351 | end 352 | end 353 | 354 | assert("quit after connect") do 355 | acli = AcliProcess.new("--url", "localhost:8080", "--quit-after", "connect") 356 | acli.running do |process| 357 | assert_true process.alive?, "Process failed" 358 | assert_include( 359 | acli.readline, 360 | "Connected to Action Cable" 361 | ) 362 | assert_false process.alive? 363 | end 364 | end 365 | 366 | assert("quit after subscribed") do 367 | acli = AcliProcess.new("--url", "localhost:8080", "-c", "DemoChannel", "--quit-after", "subscribed") 368 | acli.running do |process| 369 | assert_true process.alive?, "Process failed" 370 | assert_include( 371 | acli.readline, 372 | "Connected to Action Cable" 373 | ) 374 | assert_include( 375 | acli.readline, 376 | "Subscribed to DemoChannel" 377 | ) 378 | assert_false process.alive? 379 | end 380 | end 381 | 382 | assert("quit after N") do 383 | acli = AcliProcess.new("--url", "localhost:8080", "-c", "EchoChannel", "--quit-after", "2") 384 | acli.running do |process| 385 | assert_true process.alive?, "Process failed" 386 | assert_include( 387 | acli.readline, 388 | "Connected to Action Cable" 389 | ) 390 | assert_include( 391 | acli.readline, 392 | "Subscribed to EchoChannel" 393 | ) 394 | 395 | acli.puts '\p echo_token' 396 | assert_include(acli.readline, "token") 397 | 398 | acli.puts '\p echo_token' 399 | assert_include(acli.readline, "token") 400 | 401 | assert_false process.alive? 402 | end 403 | end 404 | -------------------------------------------------------------------------------- /build_config.rb: -------------------------------------------------------------------------------- 1 | def gem_config(conf) 2 | conf.gembox 'default' 3 | 4 | # be sure to include this gem (the cli app) 5 | conf.gem(File.expand_path(File.dirname(__FILE__))) 6 | 7 | libressl_dir = ENV["LIBRESSL_DIR"] 8 | 9 | libressl_dir ||= 10 | if RUBY_PLATFORM =~ /darwin/i 11 | "#{ENV["HOMEBREW_PREFIX"] || "/usr/local" }/opt/libressl" 12 | else 13 | # Our docker image default 14 | "/home/mruby/opt/libressl" 15 | end 16 | 17 | # C compiler settings 18 | conf.cc do |cc| 19 | cc.include_paths << ["#{libressl_dir}/include"] 20 | linker.library_paths << ["#{libressl_dir}/lib"] 21 | linker.flags_before_libraries << [ 22 | "#{libressl_dir}/lib/libtls.a", 23 | "#{libressl_dir}/lib/libssl.a", 24 | "#{libressl_dir}/lib/libcrypto.a" 25 | ] 26 | 27 | if RUBY_PLATFORM =~ /darwin/i 28 | cc.include_paths << %w(/usr/local/include) 29 | linker.library_paths << %w(/usr/local/lib) 30 | else 31 | cc.flags << [ENV['CFLAGS'] || %w(-fPIC -DHAVE_ARPA_INET_H)] 32 | end 33 | end 34 | end 35 | 36 | build_targets = ENV.fetch("BUILD_TARGET", "").split(",") 37 | 38 | MRuby::Build.new do |conf| 39 | toolchain :clang 40 | 41 | conf.enable_bintest 42 | conf.enable_debug 43 | conf.enable_test 44 | 45 | gem_config(conf) 46 | end 47 | 48 | if build_targets.include?("Linux-x86_64") 49 | MRuby::Build.new("Linux-x86_64") do |conf| 50 | toolchain :gcc 51 | 52 | conf.disable_lock 53 | 54 | gem_config(conf) 55 | 56 | conf.cc do |cc| 57 | linker.libraries << %w(readline ncurses tls ssl crypto tinfo pthread) 58 | linker.flags_before_libraries.unshift("-static") 59 | end 60 | end 61 | end 62 | 63 | if build_targets.include?("Darwin-x86_64") 64 | MRuby::Build.new("Darwin-x86_64") do |conf| 65 | toolchain :gcc 66 | 67 | conf.disable_lock 68 | 69 | gem_config(conf) 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /build_config.rb.lock: -------------------------------------------------------------------------------- 1 | --- 2 | mruby: 3 | version: 3.0.0 4 | release_no: 30000 5 | git_commit: 0f45836b5954accf508f333f932741b925214471 6 | builds: 7 | host: 8 | https://github.com/iij/mruby-regexp-pcre.git: 9 | url: https://github.com/iij/mruby-regexp-pcre.git 10 | branch: HEAD 11 | commit: 71bd16ba59239f04aefb73bb6d46d5b581f27b1b 12 | version: 0.0.0 13 | https://github.com/Asmod4n/mruby-uri-parser.git: 14 | url: https://github.com/Asmod4n/mruby-uri-parser.git 15 | branch: HEAD 16 | commit: bbfb475e3125bb7edf50aae110f5d5a64f4e3f6a 17 | version: 0.0.0 18 | https://github.com/mttech/mruby-getopts: 19 | url: https://github.com/mttech/mruby-getopts 20 | branch: HEAD 21 | commit: 32878292c1162a911b39e1e0810a28ab2cb83f6a 22 | version: 0.0.0 23 | https://github.com/Asmod4n/mruby-websockets.git: 24 | url: https://github.com/Asmod4n/mruby-websockets.git 25 | branch: HEAD 26 | commit: 9bb66308085acbeb727f8ef21979b7884d8fb322 27 | version: 0.0.0 28 | https://github.com/Asmod4n/mruby-simplemsgpack.git: 29 | url: https://github.com/Asmod4n/mruby-simplemsgpack.git 30 | branch: HEAD 31 | commit: 9df3c4a4d6ff1b62cbedb6c680b2af2f72015e40 32 | version: 0.0.0 33 | https://github.com/iij/mruby-mtest.git: 34 | url: https://github.com/iij/mruby-mtest.git 35 | branch: HEAD 36 | commit: 34a477d249b0d21671058be33aa75d04c3e981ba 37 | version: 0.0.0 38 | https://github.com/iij/mruby-errno.git: 39 | url: https://github.com/iij/mruby-errno.git 40 | branch: HEAD 41 | commit: b4415207ff6ea62360619c89a1cff83259dc4db0 42 | version: 0.0.0 43 | https://github.com/Asmod4n/mruby-phr.git: 44 | url: https://github.com/Asmod4n/mruby-phr.git 45 | branch: HEAD 46 | commit: 1bab77000280141802d56a20a5413ba0900b69df 47 | version: 0.0.0 48 | https://github.com/Asmod4n/mruby-wslay: 49 | url: https://github.com/Asmod4n/mruby-wslay 50 | branch: HEAD 51 | commit: 47fc6c9795399efb4bcf2258a69125afcab00428 52 | version: 0.0.0 53 | https://github.com/Asmod4n/mruby-poll.git: 54 | url: https://github.com/Asmod4n/mruby-poll.git 55 | branch: HEAD 56 | commit: f33ce28bc3ebb8650d91a0bf31da3e163e29af45 57 | version: 0.0.0 58 | https://github.com/Asmod4n/mruby-tls.git: 59 | url: https://github.com/Asmod4n/mruby-tls.git 60 | branch: HEAD 61 | commit: af809343feb18d11b97d346072e15ab736f1ac3f 62 | version: 0.0.0 63 | https://github.com/Asmod4n/mruby-sysrandom.git: 64 | url: https://github.com/Asmod4n/mruby-sysrandom.git 65 | branch: HEAD 66 | commit: 75347e898686c8c044b0fa8a76f42ad25daee45c 67 | version: 0.0.0 68 | https://github.com/Asmod4n/mruby-b64.git: 69 | url: https://github.com/Asmod4n/mruby-b64.git 70 | branch: HEAD 71 | commit: 6d8f36b1bd310aa1b0dccd6fe7b0e9d7551d7718 72 | version: 0.0.0 73 | https://github.com/Asmod4n/mruby-secure-compare.git: 74 | url: https://github.com/Asmod4n/mruby-secure-compare.git 75 | branch: HEAD 76 | commit: 433a73a483b550ad13e3a9af33707300c1c7822d 77 | version: 0.0.0 78 | https://github.com/Asmod4n/mruby-string-is-utf8.git: 79 | url: https://github.com/Asmod4n/mruby-string-is-utf8.git 80 | branch: HEAD 81 | commit: 1c639fe845008d437420a61852329a158ddd74b1 82 | version: 0.0.0 83 | https://github.com/palkan/mruby-simplemsgpack.git: 84 | url: https://github.com/palkan/mruby-simplemsgpack.git 85 | branch: HEAD 86 | commit: 2d908b1c9cdfb5f12319866ef483179be22660ad 87 | version: 0.0.0 88 | https://github.com/palkan/mruby-json.git: 89 | url: https://github.com/palkan/mruby-json.git 90 | branch: HEAD 91 | commit: 29550959d21f6eea75a26b0e05c1bda0c43f5ef4 92 | version: 0.0.0 93 | -------------------------------------------------------------------------------- /dip.yml: -------------------------------------------------------------------------------- 1 | version: '4' 2 | 3 | compose: 4 | files: 5 | - .dockerdev/docker-compose.yml 6 | project_name: acli_dev 7 | 8 | interaction: 9 | bash: 10 | description: Open a Bash shell in app's container 11 | service: dev 12 | command: /bin/bash 13 | 14 | rake: 15 | description: Run rake tasks 16 | service: dev 17 | command: rake 18 | 19 | provision: 20 | - dip bash -c "gem install childprocess pry-byebug ruby-next" 21 | - dip rake 22 | -------------------------------------------------------------------------------- /etc/server.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/inline" 5 | 6 | gemfile(true) do 7 | source "https://rubygems.org" 8 | 9 | gem "rails", "~> 6.0" 10 | gem "puma", "~> 4.0" 11 | end 12 | 13 | require "rails" 14 | require "global_id" 15 | 16 | require "action_controller/railtie" 17 | require "action_view/railtie" 18 | require "action_cable/engine" 19 | 20 | require "rack/handler/puma" 21 | 22 | class TestApp < Rails::Application 23 | secrets.secret_token = "secret_token" 24 | secrets.secret_key_base = "secret_key_base" 25 | 26 | config.logger = Logger.new($stdout) 27 | config.log_level = :debug 28 | config.eager_load = true 29 | 30 | config.filter_parameters << :token 31 | 32 | initializer "routes" do 33 | Rails.application.routes.draw do 34 | mount ActionCable.server => "/cable" 35 | end 36 | end 37 | end 38 | 39 | module ApplicationCable 40 | class Connection < ActionCable::Connection::Base 41 | identified_by :id, :token 42 | 43 | def connect 44 | self.id = SecureRandom.uuid 45 | self.token = request.params[:token] || 46 | request.cookies["token"] || 47 | request.headers["X-API-TOKEN"] 48 | end 49 | end 50 | end 51 | 52 | module ApplicationCable 53 | class Channel < ActionCable::Channel::Base 54 | end 55 | end 56 | 57 | ActionCable.server.config.cable = { "adapter" => "async" } 58 | ActionCable.server.config.connection_class = -> { ApplicationCable::Connection } 59 | ActionCable.server.config.disable_request_forgery_protection = true 60 | 61 | class DemoChannel < ApplicationCable::Channel 62 | def subscribed 63 | stream_from "demo" 64 | end 65 | end 66 | 67 | class ProtectedChannel < ApplicationCable::Channel 68 | def subscribed 69 | reject unless params["secret"] 70 | end 71 | end 72 | 73 | class EchoChannel < ApplicationCable::Channel 74 | def subscribed 75 | stream_from "echo" 76 | end 77 | 78 | def echo_params 79 | transmit params.without("channel") 80 | end 81 | 82 | def echo(data) 83 | transmit({pong: data.without("action")}) 84 | end 85 | 86 | def echo_token 87 | transmit({token: token}) 88 | end 89 | 90 | def echo_headers 91 | headers = connection.send(:request).headers.select { |k,| k.start_with?(/http_x_api/i) } 92 | transmit headers 93 | end 94 | end 95 | 96 | module Namespaced 97 | class EchoChannel < ::EchoChannel 98 | end 99 | end 100 | 101 | Rails.application.initialize! 102 | 103 | Rack::Handler::Puma.run(Rails.application, :Port => 8080) 104 | -------------------------------------------------------------------------------- /mrbgem.rake: -------------------------------------------------------------------------------- 1 | require_relative ".rbnext" 2 | using MRubyNext 3 | 4 | MRuby::Gem::Specification.new("acli") do |spec| 5 | spec.license = "MIT" 6 | spec.author = "Vladimir Dementyev" 7 | spec.summary = "acli" 8 | spec.bins = ["acli"] 9 | 10 | spec.setup_ruby_next! 11 | 12 | spec.add_dependency "mruby-exit", core: "mruby-exit" 13 | spec.add_dependency "mruby-string-ext", core: "mruby-string-ext" 14 | spec.add_dependency "mruby-hash-ext", core: "mruby-hash-ext" 15 | spec.add_dependency "mruby-kernel-ext", core: "mruby-kernel-ext" 16 | spec.add_dependency "mruby-struct", core: "mruby-struct" 17 | spec.add_dependency "mruby-enumerator", core: "mruby-enumerator" 18 | 19 | spec.add_dependency "mruby-json", github: "palkan/mruby-json" 20 | spec.add_dependency "mruby-regexp-pcre", mgem: "mruby-regexp-pcre" 21 | spec.add_dependency "mruby-uri-parser", mgem: "mruby-uri-parser" 22 | spec.add_dependency "mruby-getopts", mgem: "mruby-getopts" 23 | 24 | spec.add_dependency "mruby-websockets", github: "Asmod4n/mruby-websockets" 25 | spec.add_dependency "mruby-simplemsgpack", github: "palkan/mruby-simplemsgpack" 26 | 27 | spec.add_test_dependency "mruby-mtest", mgem: "mruby-mtest" 28 | end 29 | -------------------------------------------------------------------------------- /mrblib/acli.rb: -------------------------------------------------------------------------------- 1 | def __main__(argv) 2 | Acli::Cli.new(argv).run 3 | end 4 | -------------------------------------------------------------------------------- /mrblib/acli/cli.rb: -------------------------------------------------------------------------------- 1 | module Acli 2 | DEFAULT_CABLE_PATH = "/cable" 3 | 4 | class Error < StandardError; end 5 | class ClonnectionClosedError < Error; end 6 | 7 | class Cli 8 | attr_reader :logger 9 | 10 | def initialize(argv) 11 | @options = parse_options(argv) 12 | end 13 | 14 | def run 15 | @logger = @options.delete(:debug) ? DebugLogger.new : NoopLogger.new 16 | url = @options.delete(:url) || ask_for_url 17 | 18 | logger.log "Provided URL: #{url}" 19 | 20 | uri = URI.parse(Utils.normalize_url(url)) 21 | uri.instance_variable_set(:@path, DEFAULT_CABLE_PATH) if uri.path.nil? || uri.path.empty? 22 | 23 | logger.log "Normalized URL: #{uri.to_s}" 24 | 25 | poller = Poller.new 26 | socket = Socket.new(uri, headers: @options.delete(:headers), protocol: @options.delete(:protocol), logger: logger) 27 | client = Client.new(Utils.uri_to_ws_s(uri), socket, **@options, logger: logger) 28 | 29 | poller.add_client(client) 30 | 31 | poller.listen 32 | rescue URI::Error, Acli::Error => e 33 | Utils.exit_with_error(e) 34 | end 35 | 36 | private 37 | 38 | def print_version 39 | puts "v#{VERSION}" 40 | exit 0 41 | end 42 | 43 | def print_help 44 | puts <<-USAGE 45 | Usage: acli [options] 46 | 47 | Options: 48 | -u, --url # URL to connect 49 | -c, --channel # Channel to subscribe to 50 | 51 | --channel-params # Channel subscription params in a form ":" separated by "," 52 | 53 | --headers # Additional HTTP headers in a form ":" separated by "," 54 | # Example: `--headers="x-api-token:secret,cookie:user_id=26"` 55 | # NOTE: the value should not contain whitespaces. 56 | 57 | --sub-protocol # Custom WebSocket subprotocol 58 | 59 | --msgpack # Provide this switch to use Msgpack encoded messages for communication 60 | 61 | --quit-after # Automatically quit after an even occured. 62 | # Possible values are: 63 | # - connected — quit right after successful connection ("welcome" message) 64 | # - subscribed — quit after successful subscription to a channel 65 | # - N (integer) — quit after receiving N incoming messages (excluding pings and system messages) 66 | 67 | --debug # Display debug information 68 | 69 | -v # Print version 70 | -h # Print this help 71 | USAGE 72 | exit 0 73 | end 74 | 75 | def ask_for_url 76 | Utils.prompt("Enter URL: ") 77 | end 78 | 79 | def parse_options(argv) 80 | class << argv; include Getopts; end 81 | argv.getopts( 82 | "vhu:c:", 83 | "url:", 84 | "channel:", 85 | "channel-params:", 86 | "headers:", 87 | "sub-protocol:", 88 | "quit-after:", 89 | "msgpack", 90 | "pong", 91 | "debug" 92 | ).yield_self do |opts| 93 | print_version if opts["v"] 94 | print_help if opts["h"] 95 | 96 | headers = 97 | if opts["headers"] 98 | opts["headers"].split(",") 99 | end 100 | 101 | channel = opts["c"] || opts["channel"] 102 | 103 | if !channel.empty? && !opts["channel-params"].empty? 104 | channel = "#{channel} #{opts["channel-params"].gsub(",", " ")}" 105 | end 106 | 107 | if opts["msgpack"] 108 | opts["sub-protocol"] = "actioncable-v1-msgpack" if opts["sub-protocol"].empty? 109 | opts[:coder] = Coders::Msgpack 110 | end 111 | 112 | if opts["pong"] 113 | opts[:pong] = true 114 | end 115 | 116 | { 117 | url: opts["u"] || opts["url"], 118 | channel: channel, 119 | quit_after: opts["quit-after"], 120 | headers: headers, 121 | protocol: opts["sub-protocol"], 122 | coder: opts[:coder], 123 | pong: opts[:pong], 124 | debug: opts.key?("debug"), 125 | }.tap { |data| data.delete_if { _2.nil? || (_2.is_a?(String) && _2.empty?) } } 126 | end 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /mrblib/acli/client.rb: -------------------------------------------------------------------------------- 1 | module Acli 2 | class Client 3 | attr_reader :identifier, :socket, :commands, 4 | :quit_after, :quit_after_messages, :channel_to_subscribe, 5 | :connected, :url, :coder, :logger, :pong 6 | 7 | alias connected? connected 8 | 9 | attr_accessor :received_count, :last_ping_at, :last_seen_stream, :last_seen_epoch 10 | 11 | def initialize(url, socket, channel: nil, quit_after: nil, coder: Coders::JSON, pong: false, logger: NoopLogger.new) 12 | @logger = logger 13 | @url = url 14 | @connected = false 15 | @socket = socket 16 | @commands = Commands.new(self, logger: logger) 17 | @channel_to_subscribe = channel 18 | @coder = coder 19 | @pong = pong 20 | 21 | parse_quit_after!(quit_after) if quit_after 22 | 23 | @received_count = 0 24 | 25 | logger.log "Client: url=#{url}, channel=#{channel_to_subscribe}, quit_after: #{quit_after}" 26 | end 27 | 28 | def frame_format 29 | coder.frame_format 30 | end 31 | 32 | def handle_command(command) 33 | return unless command 34 | return unless Commands.is?(command) 35 | 36 | commands.prepare_command(command).then do |msg| 37 | next unless msg 38 | socket.send(msg, frame_format) 39 | end 40 | end 41 | 42 | def close 43 | socket.close 44 | exit 0 45 | end 46 | 47 | def handle_incoming(msg) 48 | logger.log "Incoming: #{msg}" 49 | 50 | data = coder.decode(msg) 51 | data = data.transform_keys!(&:to_sym) 52 | case data 53 | in type: "confirm_subscription", identifier: 54 | subscribed! identifier 55 | in type: "reject_subscription", identifier: 56 | puts "Subscription rejected" 57 | in type: "confirm_history", identifier: 58 | # no-op 59 | in type: "reject_history", identifier: 60 | puts "Failed to retrieve history" 61 | in type: "ping" 62 | track_ping! 63 | in type: "welcome", **opts 64 | connected!(opts) 65 | in type: "disconnect", **opts 66 | puts "Disconnected by server: " \ 67 | "#{opts.fetch(:reason, "unknown reason")} " \ 68 | "(reconnect: #{opts.fetch(:reconnect, "")})" 69 | close 70 | in message:, identifier:, **meta 71 | received(message, meta) 72 | end 73 | end 74 | 75 | def connected!(opts) 76 | @connected = true 77 | puts "Connected to Action Cable at #{@url}" 78 | puts "Session ID: #{opts[:sid]}#{opts[:restored] ? ' (restored)' : ''}" if opts[:sid] 79 | quit! if quit_after == "connect" 80 | subscribe if channel_to_subscribe 81 | end 82 | 83 | def track_ping! 84 | self.last_ping_at = Time.now 85 | 86 | if pong 87 | socket.send coder.encode({ "command" => "pong" }), frame_format 88 | end 89 | end 90 | 91 | def subscribed!(identifier) 92 | @identifier = identifier 93 | channel_name = 94 | begin 95 | JSON.parse(identifier)["channel"] 96 | rescue 97 | identifier 98 | end 99 | puts "Subscribed to #{channel_name}" 100 | quit! if quit_after == "subscribed" 101 | end 102 | 103 | def received(msg, meta) 104 | puts msg.to_json + format_meta(meta) 105 | 106 | if meta[:stream_id] 107 | self.last_seen_stream = meta[:stream_id] 108 | self.last_seen_epoch = meta[:epoch] 109 | end 110 | 111 | track_incoming 112 | end 113 | 114 | def format_meta(meta) 115 | return "" if meta.empty? 116 | 117 | " # stream_id=#{meta[:stream_id]} epoch=#{meta[:epoch]} offset=#{meta[:offset]}" 118 | end 119 | 120 | def subscribe 121 | handle_command "\\s #{channel_to_subscribe}" 122 | end 123 | 124 | def track_incoming 125 | self.received_count += 1 126 | quit! if quit_after == "message" && quit_after_messages == received_count 127 | end 128 | 129 | def quit! 130 | puts "Quit." 131 | close 132 | end 133 | 134 | def parse_quit_after!(value) 135 | @quit_after = 136 | case value 137 | in "connect" | "subscribed" 138 | value 139 | in /\d+/ 140 | @quit_after_messages = value.to_i 141 | "message" 142 | end 143 | end 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /mrblib/acli/coders.rb: -------------------------------------------------------------------------------- 1 | module Acli 2 | module Coders 3 | module JSON 4 | def self.encode(data) 5 | data.to_json 6 | end 7 | 8 | def self.decode(str) 9 | ::JSON.parse(str) 10 | end 11 | 12 | def self.frame_format 13 | :text_frame 14 | end 15 | end 16 | 17 | module Msgpack 18 | def self.encode(data) 19 | MessagePack.pack(data) 20 | end 21 | 22 | def self.decode(str) 23 | MessagePack.unpack(str) 24 | end 25 | 26 | def self.frame_format 27 | :binary_frame 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /mrblib/acli/commands.rb: -------------------------------------------------------------------------------- 1 | module Acli 2 | class Commands 3 | COMMANDS = { 4 | "s" => "subscribe", 5 | "s+" => "isubscribe", 6 | "p" => "perform", 7 | "p+" => "iperform", 8 | "h" => "history", 9 | "?" => "print_help", 10 | "q" => "quit" 11 | } 12 | 13 | def self.is?(str) 14 | str =~ /^\\[\w\?]+\+?/ 15 | end 16 | 17 | attr_reader :client, :logger 18 | 19 | def initialize(client, logger: NoopLogger.new) 20 | @logger = logger 21 | @client = client 22 | end 23 | 24 | def coder 25 | client.coder 26 | end 27 | 28 | def prepare_command(str) 29 | logger.log "Preparing command: #{str}" 30 | 31 | m = /^\\([\w\?]+\+?)(?:\s+((?:\w|\:\:)+(?:$|[^\:]\s)))?(.*)/.match(str) 32 | cmd, arg, options = m[1], m[2], m[3] 33 | return puts "Unknown command: #{cmd}" unless COMMANDS.key?(cmd) 34 | args = [] 35 | args << arg.strip if arg && !arg.strip.empty? 36 | args << parse_kv(options) if options && !options.strip.empty? 37 | 38 | logger.log "Parsed command: #{cmd}(#{args})" 39 | self.send(COMMANDS.fetch(cmd), *args) 40 | rescue ArgumentError => e 41 | puts "Command failed: #{e.message}" 42 | nil 43 | end 44 | 45 | def print_help 46 | puts <<-USAGE 47 | Commands: 48 | 49 | \\s channel [params] # Subscribe to channel (you can provide params 50 | using key-value string, e.g. "id:2 name:jack") 51 | \\s+ [channel] [params] # Interactive subscribe 52 | 53 | \\p action [params] # Perform action 54 | \\p+ [action] [params] # Interactive version of perform action 55 | 56 | \\h [since | offset] # Request message history since the specified time (UTC timestamp or string) or offset (for the only stream observed) 57 | 58 | \\q # Exit 59 | \\? # Print this help 60 | USAGE 61 | end 62 | 63 | def subscribe(channel, params = {}) 64 | params["channel"] = channel 65 | coder.encode({ "command" => "subscribe", "identifier" => params.to_json }) 66 | end 67 | 68 | def isubscribe(channel = nil, params = {}) 69 | channel ||= Utils.prompt("Enter channel ID: ") 70 | params.merge!(request_message) 71 | subscribe(channel, params) 72 | end 73 | 74 | def perform(action, params = {}) 75 | data = params.merge(action: action) 76 | coder.encode({ "command" => "message", "identifier" => @client.identifier, "data" => data.to_json }) 77 | end 78 | 79 | def iperform(action = nil, params = {}) 80 | action ||= Utils.prompt("Enter action: ") 81 | params.merge!(request_message) 82 | perform(action, params) 83 | end 84 | 85 | def history(params = {}) 86 | raise ArgumentError, 'History command has a form: \\h since: or \\h offset:' unless params.is_a?(Hash) 87 | 88 | since = params&.fetch("since", nil) 89 | offset = params&.fetch("offset", nil) 90 | 91 | raise ArgumentError, "Since or offset argument is required" unless since || offset 92 | 93 | if offset 94 | raise ArgumentError, "No streams were observed" unless @client.last_seen_stream 95 | 96 | coder.encode({ 97 | "command" => "history", 98 | "identifier" => @client.identifier, 99 | "history" => 100 | { 101 | "streams" => { 102 | @client.last_seen_stream => { 103 | "offset" => Integer(offset), 104 | "epoch" => @client.last_seen_epoch 105 | } 106 | } 107 | } 108 | }) 109 | else 110 | ts = nil 111 | 112 | begin 113 | ts = Integer(since) 114 | rescue ArgumentError 115 | ts = parse_relative_time(since) 116 | end 117 | 118 | coder.encode({ "command" => "history", "identifier" => @client.identifier, "history" => { "since" => ts } }) 119 | end 120 | end 121 | 122 | def quit 123 | puts "Good-bye!.." 124 | client.close 125 | nil 126 | end 127 | 128 | def request_message 129 | msg = {} 130 | loop do 131 | key = Utils.prompt("Enter key (or press ENTER to finish): ") 132 | break msg if key.empty? 133 | msg[key] = Utils.serialize(Utils.prompt("Enter value: ")) 134 | end 135 | end 136 | 137 | def parse_kv(str) 138 | str.scan(/(\w+)\s*:\s*([^\s\:]*)/).each.with_object({}) do |kv, acc| 139 | k, v = kv[0], kv[1] 140 | acc[k] = Utils.serialize(v) 141 | end 142 | end 143 | 144 | def parse_relative_time(str) 145 | ts = Time.now.to_i 146 | 147 | val, precision = str[0..-2], str[-1..-1] 148 | 149 | val = Integer(val) 150 | 151 | case precision 152 | when "s" 153 | ts -= val 154 | when "m" 155 | ts -= (val * 60) 156 | when "h" 157 | ts -= (val * 60 * 60) 158 | else 159 | raise ArgumentError, "Unknown relative time: #{str}" 160 | end 161 | end 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /mrblib/acli/core_ext.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Generated by Ruby Next v0.12.0 using the following command: 4 | # 5 | # ruby-next core_ext --name=deconstruct --name=patternerror -o mrblib/acli/core_ext.rb 6 | # 7 | 8 | class Array 9 | def deconstruct 10 | self 11 | end 12 | end 13 | 14 | class Hash 15 | def deconstruct_keys(_) 16 | self 17 | end 18 | end 19 | 20 | class Object 21 | class NoMatchingPatternError < StandardError 22 | end 23 | end 24 | 25 | class Struct 26 | alias deconstruct to_a 27 | 28 | def deconstruct_keys(keys) 29 | raise TypeError, "wrong argument type #{keys.class} (expected Array or nil)" if keys && !keys.is_a?(Array) 30 | 31 | return to_h unless keys 32 | 33 | keys.each_with_object({}) do |k, acc| 34 | # if k is Symbol and not a member of a Struct return {} 35 | return {} if (Symbol === k || String === k) && !members.include?(k.to_sym) 36 | # if k is Integer check that index is not ouf of bounds 37 | return {} if Integer === k && k > size - 1 38 | acc[k] = self[k] 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /mrblib/acli/logging.rb: -------------------------------------------------------------------------------- 1 | module Acli 2 | class DebugLogger 3 | def log(msg) 4 | puts "[DEBUG] [#{Time.now.to_s}] #{msg}" 5 | end 6 | end 7 | 8 | class NoopLogger 9 | def log(_msg) 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /mrblib/acli/poller.rb: -------------------------------------------------------------------------------- 1 | module Acli 2 | class Poller 3 | attr_reader :poll, :stdin, :socket_fd, :client 4 | 5 | def initialize 6 | @poll = Poll.new 7 | @stdin = poll.add(STDIN_FILENO, Poll::In) 8 | end 9 | 10 | def add_client(client) 11 | @client = client 12 | 13 | @socket_fd = client.socket.setup_poller(poll) 14 | end 15 | 16 | def listen 17 | expect_stdin = false 18 | while ready_fds = poll.wait 19 | ready_fds.each do |ready_fd| 20 | if ready_fd == stdin 21 | expect_stdin = false 22 | handle_stdin 23 | else 24 | frame = client.socket.receive_frame 25 | next client.handle_incoming(frame) if frame 26 | 27 | # there is a bug (?) in poll/socket, which activates 28 | # socket fd for read right before stdin 29 | if expect_stdin 30 | raise Acli::ClonnectionClosedError, "Connection closed unexpectedly" 31 | end 32 | expect_stdin = true 33 | end 34 | end 35 | end 36 | end 37 | 38 | private 39 | 40 | def handle_stdin 41 | command = STDIN.gets&.chomp 42 | return unless command 43 | stdin.events = 0 44 | client.handle_command(command) if command.length > 0 45 | ensure 46 | stdin.events = Poll::In 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /mrblib/acli/socket.rb: -------------------------------------------------------------------------------- 1 | module Acli 2 | class Socket 3 | attr_reader :ws, :tls, :logger 4 | alias tls? tls 5 | 6 | def initialize(uri, headers: nil, protocol: "actioncable-v1-json", logger: NoopLogger.new) 7 | @logger = logger 8 | @tls = uri.scheme == "https" 9 | 10 | connection_class = tls? ? WebSocket::WssConnection : WebSocket::WsConnection 11 | 12 | fullpath = "#{uri.path}#{uri.query ? "?#{uri.query}" : ""}" 13 | logger.log "Socket connection params: host=#{uri.host} port=#{uri.port} path=#{fullpath} tls=#{tls?}" 14 | @ws = connection_class.new(uri.host, uri.port, fullpath, tls_config) 15 | ws.custom_headers = headers 16 | ws.protocol = protocol 17 | ws.logger = logger 18 | 19 | setup_ws 20 | end 21 | 22 | def socket 23 | tls? ? ws.instance_variable_get(:@tcp_socket) : ws.instance_variable_get(:@socket) 24 | end 25 | 26 | def setup_poller(poll) 27 | ws.instance_variable_set(:@poll, poll) 28 | poll.add(socket.fileno).tap do |pi| 29 | ws.instance_variable_set(:@socket_pi, pi) 30 | end 31 | end 32 | 33 | def receive_frame(timeout = -1) 34 | handle_frame(ws.recv(timeout)) 35 | end 36 | 37 | def send(msg, opcode = nil, timeout = -1) 38 | logger.log "Socket send: #{msg}" 39 | ws.send(msg, opcode, timeout) 40 | end 41 | 42 | def handle_frame(frame) 43 | return unless frame 44 | 45 | if frame.opcode == :connection_close 46 | logger.log "Socket received close: #{frame.status_code}" 47 | 48 | raise Acli::ClonnectionClosedError, "Closed with status: #{frame.status_code}" 49 | end 50 | 51 | frame.msg if frame.opcode == :text_frame || frame.opcode == :binary_frame 52 | end 53 | 54 | def close(status_code = :normal_closure, reason = nil, timeout = -1) 55 | logger.log "Socket closed: #{status_code}" 56 | ws.close(status_code, reason, timeout) 57 | end 58 | 59 | private 60 | 61 | # Based on WebSocket::WsConnection#setup 62 | def setup_ws 63 | ws.http_handshake 64 | ws.make_nonblock 65 | ws.setup_ws 66 | rescue => e 67 | @ws.instance_variable_get(:@socket).close 68 | raise e 69 | end 70 | 71 | def tls_config 72 | Tls::Config.new(noverify: true) 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /mrblib/acli/utils.rb: -------------------------------------------------------------------------------- 1 | module Acli 2 | module Utils 3 | def exit_with_error(e, code = 1) 4 | puts "Error: #{e.message}\n#{e.backtrace.join("\n")}" 5 | exit code 6 | end 7 | 8 | def prompt(message = "Type something: ") 9 | print message 10 | STDIN.gets.chomp 11 | end 12 | 13 | def serialize(str) 14 | case str 15 | when /\A(true|t|yes|y)\z/i 16 | true 17 | when /\A(false|f|no|n)\z/i 18 | false 19 | when /\A(nil|null)\z/i 20 | nil 21 | when /\A\d+\z/ 22 | str.to_i 23 | when /\A\d*\.\d+\z/ 24 | str.to_f 25 | when /\A['"].*['"]\z/ 26 | str.gsub(/(\A['"]|['"]\z)/, '') 27 | else 28 | str 29 | end 30 | end 31 | 32 | # Downcase and prepend with protocol if missing 33 | def normalize_url(url) 34 | # Replace ws protocol with http, 'cause URI cannot resolve port for non-HTTP 35 | url.sub!("ws", "http") 36 | url = "http://#{url}" unless url.start_with?("http") 37 | url 38 | end 39 | 40 | def uri_to_ws_s(uri) 41 | "#{(uri.scheme == "https" || uri.scheme == "wss") ? "wss://" : "ws://"}"\ 42 | "#{uri.userinfo ? "#{uri.userinfo}@" : ""}"\ 43 | "#{uri.host}"\ 44 | "#{uri.port == 80 || (uri.port == 443 && uri.scheme == "https") ? "" : ":#{uri.port}"}"\ 45 | "#{uri.path}"\ 46 | "#{uri.query ? "?#{uri.query}" : ""}" 47 | end 48 | 49 | extend self 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /mrblib/acli/version.rb: -------------------------------------------------------------------------------- 1 | module Acli 2 | VERSION = "0.6.0" 3 | end 4 | -------------------------------------------------------------------------------- /mrblib/acli/websockets_ext.rb: -------------------------------------------------------------------------------- 1 | module WebSocket 2 | class WsConnection 3 | attr_accessor :custom_headers, :protocol, :logger 4 | 5 | def http_handshake 6 | key = WebSocket.create_key 7 | 8 | headers = [ 9 | "Host: #{@host}:#{@port}", 10 | "Connection: Upgrade", 11 | "Upgrade: websocket", 12 | "Sec-WebSocket-Version: 13", 13 | "Sec-WebSocket-Key: #{key}" 14 | ] 15 | 16 | headers << "Sec-WebSocket-Protocol: #{protocol}" if protocol 17 | headers += custom_headers if custom_headers 18 | 19 | headers_str = headers.join("\r\n") 20 | 21 | logger.log "WS Handshake: host=#{@host}:#{@port} path=#{@path} headers=#{headers_str}" 22 | 23 | @socket.write("GET #{@path} HTTP/1.1\r\n#{headers_str}\r\n\r\n") 24 | buf = @socket.recv(16384) 25 | phr = Phr.new 26 | while true 27 | case phr.parse_response(buf) 28 | when Fixnum 29 | break 30 | when :incomplete 31 | buf << @socket.recv(16384) 32 | when :parser_error 33 | raise Error, "HTTP Parser error" 34 | end 35 | end 36 | 37 | response_headers = phr.headers.to_h 38 | 39 | unless response_headers.key?('sec-websocket-accept') 40 | raise Error, "WebSocket upgrade failed" 41 | end 42 | 43 | if protocol && response_headers['sec-websocket-protocol'] != protocol 44 | raise Error, "Couldn't negotiate the right subprotocol. Expected #{protocol}, got #{response_headers['sec-websocket-protocol']}" 45 | end 46 | 47 | unless WebSocket.create_accept(key).securecmp(response_headers.fetch('sec-websocket-accept')) 48 | raise Error, "Handshake failure" 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/client_test.rb: -------------------------------------------------------------------------------- 1 | module Acli 2 | class ClientTest < MTest::Unit::TestCase 3 | class FakeSocket 4 | def close 5 | @closed = true 6 | end 7 | 8 | def closed? 9 | @closed 10 | end 11 | end 12 | 13 | def test_handle_incoming_ping 14 | client = Client.new("", nil) 15 | 16 | assert_nil client.last_ping_at 17 | client.handle_incoming(%({"type":"ping"})) 18 | 19 | assert !client.last_ping_at.nil? 20 | end 21 | 22 | def test_handle_incoming_welcome 23 | client = Client.new("", nil) 24 | 25 | assert !client.connected? 26 | client.handle_incoming(%({"type":"welcome"})) 27 | 28 | assert client.connected? 29 | end 30 | 31 | def test_handle_incoming_subscribed 32 | client = Client.new("", nil) 33 | 34 | assert_nil client.identifier 35 | client.handle_incoming(%({"type":"confirm_subscription","identifier":"test_channel"})) 36 | 37 | assert_equal "test_channel", client.identifier 38 | end 39 | 40 | def test_handle_disconnect 41 | socket = FakeSocket.new 42 | client = Client.new("", socket) 43 | # Avoid exiting, 'cause it halts tests execution 44 | client.define_singleton_method(:exit) {|*|} 45 | 46 | client.handle_incoming(%({"type":"disconnect","reason":"server_restart", "reconnect": true})) 47 | 48 | assert socket.closed? 49 | end 50 | 51 | def test_unknown_message 52 | client = Client.new("", nil) 53 | 54 | assert_raise(NoMatchingPatternError) { client.handle_incoming(%({"typo":"confirm_subscription"})) } 55 | end 56 | end 57 | end 58 | 59 | MTest::Unit.new.run 60 | -------------------------------------------------------------------------------- /test/commands_test.rb: -------------------------------------------------------------------------------- 1 | module Acli 2 | class TestCommands < MTest::Unit::TestCase 3 | class TestClient 4 | attr_reader :identifier 5 | attr_accessor :last_seen_stream, :last_seen_epoch 6 | 7 | def initialize(identifier = "test") 8 | @identifier = identifier 9 | end 10 | 11 | def close 12 | @closed = true 13 | end 14 | 15 | def closed? 16 | @closed == true 17 | end 18 | 19 | def coder 20 | Coders::JSON 21 | end 22 | end 23 | 24 | def client 25 | @client ||= TestClient.new 26 | end 27 | 28 | def test_quit 29 | subject = Commands.new(client) 30 | subject.prepare_command "\\q" 31 | assert client.closed? 32 | end 33 | 34 | def test_subscribe 35 | subject = Commands.new(client) 36 | assert_equal( 37 | "{\"command\":\"subscribe\",\"identifier\":\"{\\\"id\\\":2,\\\"channel\\\":\\\"chat\\\"}\"}", 38 | subject.prepare_command("\\s chat id:2") 39 | ) 40 | end 41 | 42 | def test_namespaced_subscribe 43 | subject = Commands.new(client) 44 | assert_equal( 45 | "{\"command\":\"subscribe\",\"identifier\":\"{\\\"id\\\":2,\\\"name\\\":\\\"jack\\\",\\\"channel\\\":\\\"Admin::Chat\\\"}\"}", 46 | subject.prepare_command("\\s Admin::Chat id:2 name:'jack'") 47 | ) 48 | end 49 | 50 | def test_perform 51 | subject = Commands.new(client) 52 | assert_equal( 53 | "{\"command\":\"message\",\"identifier\":\"test\",\"data\":\"{\\\"message\\\":\\\"Hello!\\\",\\\"sid\\\":123,\\\"action\\\":\\\"speak\\\"}\"}", 54 | subject.prepare_command("\\p speak message:\"Hello!\" sid:123") 55 | ) 56 | end 57 | 58 | def test_history_ago 59 | subject = Commands.new(client) 60 | 61 | now_i = Time.now.to_i 62 | now_i_10m = now_i - 10 * 60 63 | 64 | command_str = subject.prepare_command("\\h since:10m") 65 | 66 | command = JSON.parse(command_str) 67 | 68 | assert_equal("history", command.fetch("command")) 69 | 70 | assert(command.fetch("history").fetch("since") >= now_i_10m) 71 | assert(command.fetch("history").fetch("since") < now_i_10m + 5) 72 | end 73 | 74 | def test_history_int 75 | subject = Commands.new(client) 76 | assert_equal( 77 | "{\"command\":\"history\",\"identifier\":\"test\",\"history\":{\"since\":1650634535}}", 78 | subject.prepare_command("\\h since:1650634535") 79 | ) 80 | end 81 | 82 | def test_history_offset 83 | client.last_seen_stream = "abc" 84 | client.last_seen_epoch = "bc" 85 | 86 | subject = Commands.new(client) 87 | assert_equal( 88 | "{\"command\":\"history\",\"identifier\":\"test\",\"history\":{\"streams\":{\"abc\":{\"offset\":13,\"epoch\":\"bc\"}}}}", 89 | subject.prepare_command("\\h offset:13") 90 | ) 91 | end 92 | end 93 | end 94 | 95 | MTest::Unit.new.run 96 | -------------------------------------------------------------------------------- /test/utils_test.rb: -------------------------------------------------------------------------------- 1 | module Acli 2 | class UtilsTest < MTest::Unit::TestCase 3 | def test_serialize 4 | assert_equal true, Utils.serialize('t') 5 | assert_equal true, Utils.serialize('y') 6 | assert_equal true, Utils.serialize('yes') 7 | 8 | assert_equal false, Utils.serialize('f') 9 | assert_equal false, Utils.serialize('n') 10 | assert_equal false, Utils.serialize('no') 11 | 12 | assert_equal 1, Utils.serialize('1') 13 | assert_equal 3.14, Utils.serialize('3.14') 14 | 15 | assert_equal nil, Utils.serialize('nil') 16 | assert_equal nil, Utils.serialize('null') 17 | 18 | assert_equal 'word', Utils.serialize(%("word")) 19 | assert_equal 'word', Utils.serialize(%('word')) 20 | assert_equal 'word', Utils.serialize('word') 21 | end 22 | 23 | def test_normalize_url 24 | assert_equal "http://example.com?q=BLa-bLA", Utils.normalize_url("ws://example.com?q=BLa-bLA") 25 | assert_equal "http://example.com?q=1", Utils.normalize_url("http://example.com?q=1") 26 | assert_equal "https://example.com?q=1", Utils.normalize_url("wss://example.com?q=1") 27 | end 28 | 29 | def test_uri_to_ws_s 30 | %w[ 31 | http://example.com/?q=1 32 | https://example.com/cable 33 | ws://example.com:999/?q=1 34 | wss://user:pass@test.com:321/test/path 35 | ].each do |url| 36 | assert_equal url.sub("http", "ws"), Utils.uri_to_ws_s(URI.parse(url)) 37 | end 38 | end 39 | end 40 | end 41 | 42 | MTest::Unit.new.run 43 | -------------------------------------------------------------------------------- /tools/acli/acli.c: -------------------------------------------------------------------------------- 1 | // This file is generated by mruby-cli. Do not touch. 2 | #include 3 | #include 4 | 5 | /* Include the mruby header */ 6 | #include 7 | #include 8 | 9 | int main(int argc, char *argv[]) 10 | { 11 | mrb_state *mrb = mrb_open(); 12 | mrb_value ARGV = mrb_ary_new_capa(mrb, argc); 13 | int i; 14 | int return_value; 15 | 16 | for (i = 0; i < argc; i++) { 17 | mrb_ary_push(mrb, ARGV, mrb_str_new_cstr(mrb, argv[i])); 18 | } 19 | mrb_define_global_const(mrb, "ARGV", ARGV); 20 | 21 | // call __main__(ARGV) 22 | mrb_funcall(mrb, mrb_top_self(mrb), "__main__", 1, ARGV); 23 | 24 | return_value = EXIT_SUCCESS; 25 | 26 | if (mrb->exc) { 27 | mrb_print_error(mrb); 28 | return_value = EXIT_FAILURE; 29 | } 30 | mrb_close(mrb); 31 | 32 | return return_value; 33 | } 34 | --------------------------------------------------------------------------------