├── .editorconfig ├── .github └── workflows │ ├── documentation-coverage.yaml │ ├── documentation.yaml │ ├── rubocop.yaml │ ├── test-coverage.yaml │ ├── test-external.yaml │ └── test.yaml ├── .gitignore ├── .rubocop.yml ├── async-cable.gemspec ├── bake.rb ├── benchmark ├── application.rb ├── broadcast.rb ├── gems.locked ├── gems.rb ├── readme.md └── servers │ ├── anycable.rb │ ├── falcon.rb │ ├── iodine.rb │ └── puma.rb ├── config └── sus.rb ├── example ├── .dockerignore ├── .gitattributes ├── .github │ ├── dependabot.yml │ └── workflows │ │ └── ci.yml ├── .gitignore ├── .kamal │ ├── hooks │ │ ├── docker-setup.sample │ │ ├── post-deploy.sample │ │ ├── post-proxy-reboot.sample │ │ ├── pre-build.sample │ │ ├── pre-connect.sample │ │ ├── pre-deploy.sample │ │ └── pre-proxy-reboot.sample │ └── secrets ├── .rubocop.yml ├── .ruby-version ├── Dockerfile ├── Gemfile ├── Gemfile.lock ├── README.md ├── Rakefile ├── app │ ├── assets │ │ ├── images │ │ │ └── .keep │ │ └── stylesheets │ │ │ └── application.css │ ├── controllers │ │ ├── application_controller.rb │ │ ├── concerns │ │ │ └── .keep │ │ └── items_controller.rb │ ├── helpers │ │ ├── application_helper.rb │ │ └── items_helper.rb │ ├── javascript │ │ ├── application.js │ │ └── controllers │ │ │ ├── application.js │ │ │ ├── hello_controller.js │ │ │ └── index.js │ ├── jobs │ │ └── application_job.rb │ ├── mailers │ │ └── application_mailer.rb │ ├── models │ │ ├── application_record.rb │ │ ├── concerns │ │ │ └── .keep │ │ └── item.rb │ └── views │ │ ├── items │ │ ├── _form.html.erb │ │ ├── _item.html.erb │ │ ├── _item.json.jbuilder │ │ ├── edit.html.erb │ │ ├── index.html.erb │ │ ├── index.json.jbuilder │ │ ├── new.html.erb │ │ ├── show.html.erb │ │ └── show.json.jbuilder │ │ ├── layouts │ │ ├── application.html.erb │ │ ├── mailer.html.erb │ │ └── mailer.text.erb │ │ └── pwa │ │ ├── manifest.json.erb │ │ └── service-worker.js ├── bin │ ├── brakeman │ ├── bundle │ ├── dev │ ├── docker-entrypoint │ ├── importmap │ ├── jobs │ ├── kamal │ ├── rails │ ├── rake │ ├── rubocop │ ├── setup │ └── thrust ├── config.ru ├── config │ ├── application.rb │ ├── boot.rb │ ├── cable.yml │ ├── cache.yml │ ├── credentials.yml.enc │ ├── database.yml │ ├── deploy.yml │ ├── environment.rb │ ├── environments │ │ ├── development.rb │ │ ├── production.rb │ │ └── test.rb │ ├── importmap.rb │ ├── initializers │ │ ├── assets.rb │ │ ├── content_security_policy.rb │ │ ├── filter_parameter_logging.rb │ │ └── inflections.rb │ ├── locales │ │ └── en.yml │ ├── puma.rb │ ├── queue.yml │ ├── recurring.yml │ ├── routes.rb │ └── storage.yml ├── db │ ├── cable_schema.rb │ ├── cache_schema.rb │ ├── migrate │ │ └── 20241115075615_create_items.rb │ ├── queue_schema.rb │ ├── schema.rb │ └── seeds.rb ├── lib │ └── tasks │ │ └── .keep ├── log │ └── .keep ├── public │ ├── 400.html │ ├── 404.html │ ├── 406-unsupported-browser.html │ ├── 422.html │ ├── 500.html │ ├── icon.png │ ├── icon.svg │ └── robots.txt ├── script │ └── .keep ├── storage │ └── .keep ├── test │ ├── application_system_test_case.rb │ ├── controllers │ │ ├── .keep │ │ └── items_controller_test.rb │ ├── fixtures │ │ ├── files │ │ │ └── .keep │ │ └── items.yml │ ├── helpers │ │ └── .keep │ ├── integration │ │ └── .keep │ ├── mailers │ │ └── .keep │ ├── models │ │ ├── .keep │ │ └── item_test.rb │ ├── system │ │ ├── .keep │ │ └── items_test.rb │ └── test_helper.rb ├── tmp │ ├── .keep │ ├── pids │ │ └── .keep │ └── storage │ │ └── .keep └── vendor │ ├── .keep │ └── javascript │ └── .keep ├── fixtures └── test_channel.rb ├── gems.rb ├── guides └── getting-started │ └── readme.md ├── lib └── async │ ├── cable.rb │ └── cable │ ├── middleware.rb │ ├── railtie.rb │ ├── socket.rb │ └── version.rb ├── license.md ├── readme.md ├── release.cert ├── releases.md └── test └── async └── cable └── middleware.rb /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 2 6 | 7 | [*.{yml,yaml}] 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /.github/workflows/documentation-coverage.yaml: -------------------------------------------------------------------------------- 1 | name: Documentation Coverage 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | COVERAGE: PartialSummary 11 | 12 | jobs: 13 | validate: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: ruby/setup-ruby@v1 19 | with: 20 | ruby-version: "3.3" 21 | bundler-cache: true 22 | 23 | - name: Validate coverage 24 | timeout-minutes: 5 25 | run: bundle exec bake decode:index:coverage lib 26 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yaml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages: 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | # Allow one concurrent deployment: 15 | concurrency: 16 | group: "pages" 17 | cancel-in-progress: true 18 | 19 | env: 20 | CONSOLE_OUTPUT: XTerm 21 | BUNDLE_WITH: maintenance 22 | 23 | jobs: 24 | generate: 25 | runs-on: ubuntu-latest 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - uses: ruby/setup-ruby@v1 31 | with: 32 | ruby-version: "3.3" 33 | bundler-cache: true 34 | 35 | - name: Installing packages 36 | run: sudo apt-get install wget 37 | 38 | - name: Generate documentation 39 | timeout-minutes: 5 40 | run: bundle exec bake utopia:project:static --force no 41 | 42 | - name: Upload documentation artifact 43 | uses: actions/upload-pages-artifact@v3 44 | with: 45 | path: docs 46 | 47 | deploy: 48 | runs-on: ubuntu-latest 49 | 50 | environment: 51 | name: github-pages 52 | url: ${{steps.deployment.outputs.page_url}} 53 | 54 | needs: generate 55 | steps: 56 | - name: Deploy to GitHub Pages 57 | id: deployment 58 | uses: actions/deploy-pages@v4 59 | -------------------------------------------------------------------------------- /.github/workflows/rubocop.yaml: -------------------------------------------------------------------------------- 1 | name: RuboCop 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | 11 | jobs: 12 | check: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: ruby/setup-ruby@v1 18 | with: 19 | ruby-version: ruby 20 | bundler-cache: true 21 | 22 | - name: Run RuboCop 23 | timeout-minutes: 10 24 | run: bundle exec rubocop 25 | -------------------------------------------------------------------------------- /.github/workflows/test-coverage.yaml: -------------------------------------------------------------------------------- 1 | name: Test Coverage 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | COVERAGE: PartialSummary 11 | 12 | jobs: 13 | test: 14 | name: ${{matrix.ruby}} on ${{matrix.os}} 15 | runs-on: ${{matrix.os}}-latest 16 | 17 | strategy: 18 | matrix: 19 | os: 20 | - ubuntu 21 | - macos 22 | 23 | ruby: 24 | - "3.3" 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: ruby/setup-ruby@v1 29 | with: 30 | ruby-version: ${{matrix.ruby}} 31 | bundler-cache: true 32 | 33 | - name: Run tests 34 | timeout-minutes: 5 35 | run: bundle exec bake test 36 | 37 | - uses: actions/upload-artifact@v4 38 | with: 39 | include-hidden-files: true 40 | if-no-files-found: error 41 | name: coverage-${{matrix.os}}-${{matrix.ruby}} 42 | path: .covered.db 43 | 44 | validate: 45 | needs: test 46 | runs-on: ubuntu-latest 47 | 48 | steps: 49 | - uses: actions/checkout@v4 50 | - uses: ruby/setup-ruby@v1 51 | with: 52 | ruby-version: "3.3" 53 | bundler-cache: true 54 | 55 | - uses: actions/download-artifact@v4 56 | 57 | - name: Validate coverage 58 | timeout-minutes: 5 59 | run: bundle exec bake covered:validate --paths */.covered.db \; 60 | -------------------------------------------------------------------------------- /.github/workflows/test-external.yaml: -------------------------------------------------------------------------------- 1 | name: Test External 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | 11 | jobs: 12 | test: 13 | name: ${{matrix.ruby}} on ${{matrix.os}} 14 | runs-on: ${{matrix.os}}-latest 15 | 16 | strategy: 17 | matrix: 18 | os: 19 | - ubuntu 20 | - macos 21 | 22 | ruby: 23 | - "3.1" 24 | - "3.2" 25 | - "3.3" 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | - uses: ruby/setup-ruby@v1 30 | with: 31 | ruby-version: ${{matrix.ruby}} 32 | bundler-cache: true 33 | 34 | - name: Run tests 35 | timeout-minutes: 10 36 | run: bundle exec bake test:external 37 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | 11 | jobs: 12 | test: 13 | name: ${{matrix.ruby}} on ${{matrix.os}} 14 | runs-on: ${{matrix.os}}-latest 15 | continue-on-error: ${{matrix.experimental}} 16 | 17 | strategy: 18 | matrix: 19 | os: 20 | - ubuntu 21 | - macos 22 | 23 | ruby: 24 | - "3.1" 25 | - "3.2" 26 | - "3.3" 27 | 28 | experimental: [false] 29 | 30 | include: 31 | - os: ubuntu 32 | ruby: truffleruby 33 | experimental: true 34 | - os: ubuntu 35 | ruby: jruby 36 | experimental: true 37 | - os: ubuntu 38 | ruby: head 39 | experimental: true 40 | 41 | steps: 42 | - uses: actions/checkout@v4 43 | - uses: ruby/setup-ruby@v1 44 | with: 45 | ruby-version: ${{matrix.ruby}} 46 | bundler-cache: true 47 | 48 | - name: Run tests 49 | timeout-minutes: 10 50 | run: bundle exec bake test 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /pkg/ 3 | /gems.locked 4 | /.covered.db 5 | /external 6 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | DisabledByDefault: true 3 | 4 | Layout/IndentationStyle: 5 | Enabled: true 6 | EnforcedStyle: tabs 7 | 8 | Layout/InitialIndentation: 9 | Enabled: true 10 | 11 | Layout/IndentationWidth: 12 | Enabled: true 13 | Width: 1 14 | 15 | Layout/IndentationConsistency: 16 | Enabled: true 17 | EnforcedStyle: normal 18 | 19 | Layout/BlockAlignment: 20 | Enabled: true 21 | 22 | Layout/EndAlignment: 23 | Enabled: true 24 | EnforcedStyleAlignWith: start_of_line 25 | 26 | Layout/BeginEndAlignment: 27 | Enabled: true 28 | EnforcedStyleAlignWith: start_of_line 29 | 30 | Layout/ElseAlignment: 31 | Enabled: true 32 | 33 | Layout/DefEndAlignment: 34 | Enabled: true 35 | 36 | Layout/CaseIndentation: 37 | Enabled: true 38 | 39 | Layout/CommentIndentation: 40 | Enabled: true 41 | 42 | Layout/EmptyLinesAroundClassBody: 43 | Enabled: true 44 | 45 | Layout/EmptyLinesAroundModuleBody: 46 | Enabled: true 47 | 48 | Style/FrozenStringLiteralComment: 49 | Enabled: true 50 | 51 | Style/StringLiterals: 52 | Enabled: true 53 | EnforcedStyle: double_quotes 54 | -------------------------------------------------------------------------------- /async-cable.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/async/cable/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "async-cable" 7 | spec.version = Async::Cable::VERSION 8 | 9 | spec.summary = "An asynchronous adapter for ActionCable." 10 | spec.authors = ["Samuel Williams"] 11 | spec.license = "MIT" 12 | 13 | spec.cert_chain = ["release.cert"] 14 | spec.signing_key = File.expand_path("~/.gem/release.pem") 15 | 16 | spec.metadata = { 17 | "documentation_uri" => "https://socketry.github.io/async-cable/", 18 | "source_code_uri" => "https://github.com/socketry/async-cable", 19 | } 20 | 21 | spec.files = Dir["{lib}/**/*", "*.md", base: __dir__] 22 | 23 | spec.required_ruby_version = ">= 3.1" 24 | 25 | spec.add_dependency "actioncable-next" 26 | spec.add_dependency "async", "~> 2.9" 27 | spec.add_dependency "async-websocket" 28 | end 29 | -------------------------------------------------------------------------------- /bake.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | # Update the project documentation with the new version number. 7 | # 8 | # @parameter version [String] The new version number. 9 | def after_gem_release_version_increment(version) 10 | context["releases:update"].call(version) 11 | context["utopia:project:readme:update"].call 12 | end 13 | -------------------------------------------------------------------------------- /benchmark/application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails" 4 | require "global_id" 5 | 6 | require "action_controller/railtie" 7 | require "action_view/railtie" 8 | require "action_cable/engine" 9 | 10 | # config/application.rb 11 | class App < Rails::Application 12 | config.root = __dir__ 13 | config.eager_load = false 14 | config.consider_all_requests_local = true 15 | config.action_dispatch.show_exceptions = false 16 | config.secret_key_base = "i_am_a_secret" 17 | 18 | config.hosts = [] 19 | 20 | config.logger = ActiveSupport::Logger.new((ENV["LOG"] == "1") ? $stdout : IO::NULL) 21 | config.log_level = (ENV["LOG"] == "1") ? :debug : :fatal 22 | 23 | routes.append do 24 | # Add routes here if needed 25 | end 26 | end 27 | 28 | ActionCable.server.config.cable = { 29 | "adapter" => ENV.fetch("ACTION_CABLE_ADAPTER", "redis"), 30 | "url" => ENV["REDIS_URL"] 31 | } 32 | 33 | ActionCable.server.config.connection_class = -> {ApplicationCable::Connection} 34 | ActionCable.server.config.disable_request_forgery_protection = true 35 | ActionCable.server.config.logger = Rails.logger 36 | 37 | # Load server configuration 38 | require_relative "servers/#{$benchmark_server}" if defined?($benchmark_server) 39 | 40 | Rails.application.initialize! 41 | 42 | module ApplicationCable 43 | class Connection < ActionCable::Connection::Base 44 | identified_by :uid 45 | end 46 | 47 | class Channel < ActionCable::Channel::Base 48 | end 49 | end 50 | 51 | class BenchmarkChannel < ApplicationCable::Channel 52 | def subscribed 53 | stream_from "all#{stream_id}" 54 | end 55 | 56 | def echo(data) 57 | transmit data 58 | end 59 | 60 | def broadcast(data) 61 | ActionCable.server.broadcast "all#{stream_id}", data 62 | # data["action"] = "broadcastResult" 63 | # transmit data 64 | end 65 | 66 | private 67 | 68 | def stream_id 69 | params[:id] || "" 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /benchmark/broadcast.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | url = ARGV.pop || "http://localhost:8080/cable" 5 | 6 | require "async" 7 | require "async/http/endpoint" 8 | require "async/websocket" 9 | 10 | OPTIONS = { 11 | # Disable compression: 12 | extensions: nil 13 | } 14 | 15 | IDENTIFIER = {channel: "BenchmarkChannel"}.to_json 16 | 17 | SUBSCRIBE_MESSAGE = Protocol::WebSocket::TextMessage.generate( 18 | command: "subscribe", 19 | identifier: IDENTIFIER 20 | ) 21 | 22 | def connect(endpoint, count: 100, parent: Async::Task.current) 23 | count.times.map do 24 | parent.async do 25 | connection = Async::WebSocket::Client.connect(endpoint, **OPTIONS) 26 | 27 | SUBSCRIBE_MESSAGE.send(connection) 28 | connection.flush 29 | 30 | while message = connection.read 31 | parsed = message.parse 32 | break if parsed[:type] == "confirm_subscription" 33 | end 34 | 35 | connection 36 | end 37 | end.map(&:wait) 38 | end 39 | 40 | def broadcast(connections, data, count: 10, parent: Async::Task.current) 41 | broadcast_message = Protocol::WebSocket::TextMessage.generate( 42 | command: "message", 43 | identifier: IDENTIFIER, 44 | data: data.to_json 45 | ) 46 | 47 | broadcast_connection = connections.first 48 | 49 | parent.async do 50 | count.times do 51 | broadcast_message.send(broadcast_connection) 52 | broadcast_connection.flush 53 | end 54 | end 55 | 56 | connections.map do |connection| 57 | parent.async do 58 | count.times do |i| 59 | while message = connection.read 60 | parsed = message.parse 61 | if parsed[:identifier] == IDENTIFIER 62 | break 63 | end 64 | end 65 | end 66 | end 67 | end.map(&:wait) 68 | end 69 | 70 | def format_duration(duration) 71 | if duration > 1 72 | "%.3fs" % duration 73 | else 74 | "%.3fms" % (duration * 1000) 75 | end 76 | end 77 | 78 | Async do 79 | endpoint = Async::HTTP::Endpoint.parse(url) 80 | connections = nil 81 | 82 | connection_count = ENV.fetch("CONNECTIONS", 5000).to_i 83 | 84 | duration = Async::Clock.measure do 85 | connections = connect(endpoint, count: connection_count) 86 | end 87 | 88 | puts "Connected #{connections.size} clients in #{format_duration(duration)}." 89 | puts "Amortized connection time: #{format_duration(duration / connection_count)}." 90 | 91 | broadcast_count = ENV.fetch("BROADCASTS", 100).to_i 92 | 93 | duration = Async::Clock.measure do 94 | broadcast(connections, {action: "broadcast", payload: "Hello, World!"}, count: broadcast_count) 95 | end 96 | 97 | puts "Broadcast #{broadcast_count} times to #{connections.size} clients in #{format_duration(duration)}." 98 | puts "Amortized broadcast time: #{format_duration(duration / (connection_count * broadcast_count))}." 99 | 100 | connections&.each do |connection| 101 | connection.shutdown 102 | end 103 | ensure 104 | connections&.each do |connection| 105 | connection.close 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /benchmark/gems.locked: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | async-cable (0.1.0) 5 | actioncable-next 6 | async (~> 2.9) 7 | async-websocket 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | actioncable (8.0.0) 13 | actionpack (= 8.0.0) 14 | activesupport (= 8.0.0) 15 | nio4r (~> 2.0) 16 | websocket-driver (>= 0.6.1) 17 | zeitwerk (~> 2.6) 18 | actioncable-next (0.1.1) 19 | actionpack (>= 7.0, <= 8.1) 20 | activesupport (>= 7.0, <= 8.1) 21 | nio4r (~> 2.0) 22 | websocket-driver (>= 0.6.1) 23 | zeitwerk (~> 2.6) 24 | actionmailbox (8.0.0) 25 | actionpack (= 8.0.0) 26 | activejob (= 8.0.0) 27 | activerecord (= 8.0.0) 28 | activestorage (= 8.0.0) 29 | activesupport (= 8.0.0) 30 | mail (>= 2.8.0) 31 | actionmailer (8.0.0) 32 | actionpack (= 8.0.0) 33 | actionview (= 8.0.0) 34 | activejob (= 8.0.0) 35 | activesupport (= 8.0.0) 36 | mail (>= 2.8.0) 37 | rails-dom-testing (~> 2.2) 38 | actionpack (8.0.0) 39 | actionview (= 8.0.0) 40 | activesupport (= 8.0.0) 41 | nokogiri (>= 1.8.5) 42 | rack (>= 2.2.4) 43 | rack-session (>= 1.0.1) 44 | rack-test (>= 0.6.3) 45 | rails-dom-testing (~> 2.2) 46 | rails-html-sanitizer (~> 1.6) 47 | useragent (~> 0.16) 48 | actiontext (8.0.0) 49 | actionpack (= 8.0.0) 50 | activerecord (= 8.0.0) 51 | activestorage (= 8.0.0) 52 | activesupport (= 8.0.0) 53 | globalid (>= 0.6.0) 54 | nokogiri (>= 1.8.5) 55 | actionview (8.0.0) 56 | activesupport (= 8.0.0) 57 | builder (~> 3.1) 58 | erubi (~> 1.11) 59 | rails-dom-testing (~> 2.2) 60 | rails-html-sanitizer (~> 1.6) 61 | activejob (8.0.0) 62 | activesupport (= 8.0.0) 63 | globalid (>= 0.3.6) 64 | activemodel (8.0.0) 65 | activesupport (= 8.0.0) 66 | activerecord (8.0.0) 67 | activemodel (= 8.0.0) 68 | activesupport (= 8.0.0) 69 | timeout (>= 0.4.0) 70 | activestorage (8.0.0) 71 | actionpack (= 8.0.0) 72 | activejob (= 8.0.0) 73 | activerecord (= 8.0.0) 74 | activesupport (= 8.0.0) 75 | marcel (~> 1.0) 76 | activesupport (8.0.0) 77 | base64 78 | benchmark (>= 0.3) 79 | bigdecimal 80 | concurrent-ruby (~> 1.0, >= 1.3.1) 81 | connection_pool (>= 2.2.5) 82 | drb 83 | i18n (>= 1.6, < 2) 84 | logger (>= 1.4.2) 85 | minitest (>= 5.1) 86 | securerandom (>= 0.3) 87 | tzinfo (~> 2.0, >= 2.0.5) 88 | uri (>= 0.13.1) 89 | anycable (1.5.1) 90 | anycable-core (= 1.5.1) 91 | grpc (~> 1.53) 92 | anycable-core (1.5.1) 93 | anyway_config (~> 2.2) 94 | google-protobuf (~> 3.25) 95 | anyway_config (2.6.4) 96 | ruby-next-core (~> 1.0) 97 | ast (2.4.2) 98 | async (2.20.0) 99 | console (~> 1.29) 100 | fiber-annotation 101 | io-event (~> 1.6, >= 1.6.5) 102 | async-container (0.18.3) 103 | async (~> 2.10) 104 | async-http (0.83.1) 105 | async (>= 2.10.2) 106 | async-pool (~> 0.9) 107 | io-endpoint (~> 0.14) 108 | io-stream (~> 0.6) 109 | metrics (~> 0.12) 110 | protocol-http (~> 0.43) 111 | protocol-http1 (>= 0.28.1) 112 | protocol-http2 (~> 0.19) 113 | traces (~> 0.10) 114 | async-http-cache (0.4.4) 115 | async-http (~> 0.56) 116 | async-pool (0.10.2) 117 | async (>= 1.25) 118 | traces 119 | async-service (0.12.0) 120 | async 121 | async-container (~> 0.16) 122 | async-websocket (0.30.0) 123 | async-http (~> 0.76) 124 | protocol-http (~> 0.34) 125 | protocol-rack (~> 0.7) 126 | protocol-websocket (~> 0.17) 127 | bake (0.21.0) 128 | bigdecimal 129 | samovar (~> 2.1) 130 | bake-gem (0.10.0) 131 | console (~> 1.25) 132 | bake-modernize (0.31.0) 133 | async-http 134 | bake 135 | build-files (~> 1.6) 136 | markly (~> 0.8) 137 | rugged 138 | bake-test (0.3.0) 139 | bake 140 | bake-test-external (0.6.0) 141 | bake 142 | base64 (0.2.0) 143 | benchmark (0.4.0) 144 | bigdecimal (3.1.8) 145 | build-files (1.9.0) 146 | builder (3.3.0) 147 | concurrent-ruby (1.3.4) 148 | connection_pool (2.4.1) 149 | console (1.29.0) 150 | fiber-annotation 151 | fiber-local (~> 1.1) 152 | json 153 | covered (0.26.0) 154 | console (~> 1.0) 155 | msgpack (~> 1.0) 156 | crass (1.0.6) 157 | date (3.4.0) 158 | decode (0.22.0) 159 | parser 160 | drb (2.2.1) 161 | erubi (1.13.0) 162 | falcon (0.48.3) 163 | async 164 | async-container (~> 0.18) 165 | async-http (~> 0.75) 166 | async-http-cache (~> 0.4) 167 | async-service (~> 0.10) 168 | bundler 169 | localhost (~> 1.1) 170 | openssl (~> 3.0) 171 | process-metrics (~> 0.2) 172 | protocol-http (~> 0.31) 173 | protocol-rack (~> 0.7) 174 | samovar (~> 2.3) 175 | fiber-annotation (0.2.0) 176 | fiber-local (1.1.0) 177 | fiber-storage 178 | fiber-storage (1.0.0) 179 | globalid (1.2.1) 180 | activesupport (>= 6.1) 181 | google-protobuf (3.25.5-x86_64-linux) 182 | googleapis-common-protos-types (1.16.0) 183 | google-protobuf (>= 3.18, < 5.a) 184 | grpc (1.67.0-x86_64-linux) 185 | google-protobuf (>= 3.25, < 5.0) 186 | googleapis-common-protos-types (~> 1.0) 187 | http-accept (2.2.1) 188 | i18n (1.14.6) 189 | concurrent-ruby (~> 1.0) 190 | io-console (0.7.2) 191 | io-endpoint (0.14.0) 192 | io-event (1.7.3) 193 | io-stream (0.6.1) 194 | iodine (0.7.58) 195 | irb (1.14.1) 196 | rdoc (>= 4.0.0) 197 | reline (>= 0.4.2) 198 | json (2.8.2) 199 | language_server-protocol (3.17.0.3) 200 | localhost (1.3.1) 201 | logger (1.6.1) 202 | loofah (2.23.1) 203 | crass (~> 1.0.2) 204 | nokogiri (>= 1.12.0) 205 | mail (2.8.1) 206 | mini_mime (>= 0.1.1) 207 | net-imap 208 | net-pop 209 | net-smtp 210 | mapping (1.1.1) 211 | marcel (1.0.4) 212 | markly (0.12.1) 213 | metrics (0.12.1) 214 | mime-types (3.6.0) 215 | logger 216 | mime-types-data (~> 3.2015) 217 | mime-types-data (3.2024.1105) 218 | mini_mime (1.1.5) 219 | minitest (5.25.1) 220 | msgpack (1.7.5) 221 | net-imap (0.5.1) 222 | date 223 | net-protocol 224 | net-pop (0.1.2) 225 | net-protocol 226 | net-protocol (0.2.2) 227 | timeout 228 | net-smtp (0.5.0) 229 | net-protocol 230 | nio4r (2.7.4) 231 | nokogiri (1.16.7-x86_64-linux) 232 | racc (~> 1.4) 233 | openssl (3.2.0) 234 | parallel (1.26.3) 235 | parser (3.3.6.0) 236 | ast (~> 2.4.1) 237 | racc 238 | process-metrics (0.3.0) 239 | console (~> 1.8) 240 | json (~> 2) 241 | samovar (~> 2.1) 242 | protocol-hpack (1.5.1) 243 | protocol-http (0.44.0) 244 | protocol-http1 (0.28.1) 245 | protocol-http (~> 0.22) 246 | protocol-http2 (0.20.0) 247 | protocol-hpack (~> 1.4) 248 | protocol-http (~> 0.18) 249 | protocol-rack (0.11.0) 250 | protocol-http (~> 0.43) 251 | rack (>= 1.0) 252 | protocol-websocket (0.20.1) 253 | protocol-http (~> 0.2) 254 | psych (5.2.0) 255 | stringio 256 | puma (6.4.3) 257 | nio4r (~> 2.0) 258 | racc (1.8.1) 259 | rack (3.1.8) 260 | rack-session (2.0.0) 261 | rack (>= 3.0.0) 262 | rack-test (2.1.0) 263 | rack (>= 1.3) 264 | rackula (1.4.1) 265 | falcon (~> 0.46) 266 | samovar (~> 2.1) 267 | variant 268 | rackup (2.2.1) 269 | rack (>= 3) 270 | rails (8.0.0) 271 | actioncable (= 8.0.0) 272 | actionmailbox (= 8.0.0) 273 | actionmailer (= 8.0.0) 274 | actionpack (= 8.0.0) 275 | actiontext (= 8.0.0) 276 | actionview (= 8.0.0) 277 | activejob (= 8.0.0) 278 | activemodel (= 8.0.0) 279 | activerecord (= 8.0.0) 280 | activestorage (= 8.0.0) 281 | activesupport (= 8.0.0) 282 | bundler (>= 1.15.0) 283 | railties (= 8.0.0) 284 | rails-dom-testing (2.2.0) 285 | activesupport (>= 5.0.0) 286 | minitest 287 | nokogiri (>= 1.6) 288 | rails-html-sanitizer (1.6.0) 289 | loofah (~> 2.21) 290 | nokogiri (~> 1.14) 291 | railties (8.0.0) 292 | actionpack (= 8.0.0) 293 | activesupport (= 8.0.0) 294 | irb (~> 1.13) 295 | rackup (>= 1.0.0) 296 | rake (>= 12.2) 297 | thor (~> 1.0, >= 1.2.2) 298 | zeitwerk (~> 2.6) 299 | rainbow (3.1.1) 300 | rake (13.2.1) 301 | rdoc (6.7.0) 302 | psych (>= 4.0.0) 303 | redis (5.3.0) 304 | redis-client (>= 0.22.0) 305 | redis-client (0.22.2) 306 | connection_pool 307 | regexp_parser (2.9.2) 308 | reline (0.5.11) 309 | io-console (~> 0.5) 310 | rubocop (1.68.0) 311 | json (~> 2.3) 312 | language_server-protocol (>= 3.17.0) 313 | parallel (~> 1.10) 314 | parser (>= 3.3.0.2) 315 | rainbow (>= 2.2.2, < 4.0) 316 | regexp_parser (>= 2.4, < 3.0) 317 | rubocop-ast (>= 1.32.2, < 2.0) 318 | ruby-progressbar (~> 1.7) 319 | unicode-display_width (>= 2.4.0, < 3.0) 320 | rubocop-ast (1.36.1) 321 | parser (>= 3.3.1.0) 322 | ruby-next-core (1.0.3) 323 | ruby-progressbar (1.13.0) 324 | rugged (1.7.2) 325 | samovar (2.3.0) 326 | console (~> 1.0) 327 | mapping (~> 1.0) 328 | securerandom (0.3.2) 329 | stringio (3.1.2) 330 | sus (0.32.0) 331 | sus-fixtures-async (0.2.0) 332 | async 333 | sus (~> 0.10) 334 | sus-fixtures-async-http (0.9.1) 335 | async-http (~> 0.54) 336 | sus (~> 0.31) 337 | sus-fixtures-async (~> 0.1) 338 | sus-fixtures-console (0.3.1) 339 | console (~> 1.25) 340 | sus (~> 0.10) 341 | thor (1.3.2) 342 | thread-local (1.1.0) 343 | timeout (0.4.2) 344 | traces (0.14.1) 345 | tzinfo (2.0.6) 346 | concurrent-ruby (~> 1.0) 347 | unicode-display_width (2.6.0) 348 | uri (1.0.2) 349 | useragent (0.16.10) 350 | utopia (2.25.0) 351 | bake (~> 0.20) 352 | concurrent-ruby (~> 1.2) 353 | console (~> 1.24) 354 | http-accept (~> 2.1) 355 | mail (~> 2.6) 356 | mime-types (~> 3.0) 357 | msgpack 358 | net-smtp 359 | rack (~> 3.0) 360 | samovar (~> 2.1) 361 | traces (~> 0.10) 362 | variant (~> 0.1) 363 | xrb (~> 0.4) 364 | utopia-project (0.32.0) 365 | decode (~> 0.17) 366 | falcon 367 | markly (~> 0.7) 368 | rackula (~> 1.3) 369 | thread-local 370 | utopia (~> 2.24) 371 | variant (0.1.1) 372 | thread-local 373 | websocket-driver (0.7.6) 374 | websocket-extensions (>= 0.1.0) 375 | websocket-extensions (0.1.5) 376 | xrb (0.11.1) 377 | zeitwerk (2.7.1) 378 | 379 | PLATFORMS 380 | x86_64-linux 381 | 382 | DEPENDENCIES 383 | anycable 384 | async-cable! 385 | async-websocket 386 | bake-gem 387 | bake-modernize 388 | bake-test 389 | bake-test-external 390 | covered 391 | decode 392 | iodine 393 | puma 394 | rails 395 | redis 396 | rubocop 397 | sus 398 | sus-fixtures-async-http 399 | sus-fixtures-console 400 | utopia-project 401 | 402 | BUNDLED WITH 403 | 2.5.22 404 | -------------------------------------------------------------------------------- /benchmark/gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | eval_gemfile "../gems.rb" 4 | 5 | gem "rails" 6 | 7 | gem "puma" 8 | gem "iodine" 9 | gem "anycable" 10 | gem "redis" 11 | 12 | -------------------------------------------------------------------------------- /benchmark/readme.md: -------------------------------------------------------------------------------- 1 | # Benchmark 2 | 3 | The basic benchmark compares connection and broadcast performance. 4 | 5 | ## Usage 6 | 7 | First, install all the dependencies using `bundle install`. 8 | 9 | Then, start a server, e.g. 10 | 11 | ```shell 12 | $ cd benchmark/servers 13 | $ bundle exec ./falcon.rb 14 | ``` 15 | 16 | Then, run `broadcast.rb`: 17 | 18 | ``` 19 | $ bundle exec ./broadcast.rb 20 | Connected 5000 clients in 2.947s. 21 | Amortized connection time: 0.589ms. 22 | Broadcast 100 times to 5000 clients in 16.713s. 23 | Amortized broadcast time: 0.033ms. 24 | ``` 25 | 26 | You can adjust the counts in the `benchmark.rb` script. 27 | 28 | ## Results 29 | 30 | Broadcast benchmark, 5000 clients, 100 broadcasts: 31 | 32 | | Server | Process | Compression | Connection Time | Broadcast Time | 33 | |--------|---------|-------------|---------------------|-------------------| 34 | | Puma | 1 | No | 0.67ms / connection | 0.04ms / message | 35 | | Falcon | 1 | No | 0.56ms / connection | 0.03ms / message | 36 | | Falcon | 1 | Deflate | 0.83ms / connection | 0.034ms / message | 37 | -------------------------------------------------------------------------------- /benchmark/servers/anycable.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require_relative "../application" 5 | require "anycable-rails" 6 | 7 | ActionCable.server.config.cable = {"adapter" => "any_cable"} 8 | 9 | class BenchmarkServer 10 | def self.run! 11 | require "anycable/cli" 12 | cli = AnyCable::CLI.new 13 | # We're already within the app context 14 | cli.define_singleton_method(:boot_app!) { } 15 | 16 | anycable_server_path = Rails.root.join("../bin/anycable-go") 17 | cli.run(["--server-command", "#{anycable_server_path} --host 0.0.0.0"]) 18 | end 19 | end 20 | 21 | BenchmarkServer.run! 22 | -------------------------------------------------------------------------------- /benchmark/servers/falcon.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "async" 5 | require "async/http/endpoint" 6 | require "async/websocket/adapters/rack" 7 | 8 | require "falcon" 9 | 10 | require "protocol/http/middleware" 11 | require_relative "../../lib/async/cable/middleware" 12 | 13 | require_relative "../application" 14 | 15 | class BenchmarkServer 16 | def self.run! 17 | Sync do 18 | websocket_endpoint = Async::HTTP::Endpoint.parse("http://127.0.0.1:8080/cable") 19 | 20 | app = ::Falcon::Server.middleware( 21 | ::Async::Cable::Middleware.new( 22 | ::Protocol::HTTP::Middleware::HelloWorld 23 | ) 24 | ) 25 | 26 | server = Falcon::Server.new(app, websocket_endpoint) 27 | server.run.wait 28 | end 29 | end 30 | end 31 | 32 | BenchmarkServer.run! 33 | -------------------------------------------------------------------------------- /benchmark/servers/iodine.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "iodine" 5 | 6 | require_relative "../application" 7 | 8 | module ActionCable 9 | module SubscriptionAdapter 10 | class Iodine < Base 11 | def initialize(*) 12 | super 13 | @redis = ::Redis.new 14 | end 15 | 16 | def broadcast(channel, payload) 17 | # FIXME: Doesn't publis to Redis when executed outside of the Iodine server 18 | # (e.g., from AnyT tests) 19 | # ::Iodine.publish(channel, payload) 20 | @redis.publish(channel, payload) 21 | end 22 | end 23 | end 24 | 25 | module Iodine 26 | # Middleware is a Rack middleware that upgrades HTTP requests to WebSocket connections 27 | class Middleware 28 | attr_reader :server 29 | 30 | delegate :logger, to: :server 31 | 32 | def initialize(_app, server: ::ActionCable.server) 33 | @server = server 34 | end 35 | 36 | def call(env) 37 | if env["rack.upgrade?"] == :websocket && server.allow_request_origin?(env) 38 | subprotocol = select_protocol(env) 39 | 40 | env["rack.upgrade"] = Socket.new(server, env, protocol: subprotocol) 41 | logger.debug "[Iodine] upgrading to WebSocket [#(subprotocol)]" 42 | [101, {"Sec-Websocket-Protocol" => subprotocol}, []] 43 | else 44 | [404, {}, ["Not Found"]] 45 | end 46 | end 47 | 48 | private 49 | 50 | def select_protocol(env) 51 | supported_protocols = ::ActionCable::INTERNAL[:protocols] 52 | request_protocols = env["HTTP_SEC_WEBSOCKET_PROTOCOL"] 53 | if !request_protocols 54 | logger.error("No Sec-WebSocket-Protocol provided") 55 | return 56 | end 57 | 58 | request_protocols = request_protocols.split(/,\s?/) if request_protocols.is_a?(String) 59 | subprotocol = request_protocols.detect { _1.in?(supported_protocols) } 60 | 61 | logger.error("Unsupported protocol: #{request_protocols}") unless subprotocol 62 | subprotocol 63 | end 64 | end 65 | 66 | # This is a server wrapper to support Iodine native pub/sub 67 | class Server < SimpleDelegator 68 | # This is a pub/sub implementation that uses 69 | # Iodine client to subscribe to channels. 70 | # For that, we need an instance of Iodine::Connection to call #subscribe/#unsubscribe on. 71 | class PubSubInterface < Data.define(:socket) 72 | delegate :iodine_client, to: :socket, allow_nil: true 73 | 74 | def subscribe(channel, handler, on_success = nil) 75 | return unless iodine_client 76 | 77 | # NOTE: Iodine doesn't allow having different handlers for the same channel name, 78 | # so, having multiple channels listening to the same stream is currently not possible. 79 | # 80 | # We can create internal, server-side, subscribers to handle original broadcast requests 81 | # and then forward them to the specific identifiers. SubsriberMap can be reused for that. 82 | iodine_client.subscribe(to: channel, handler: proc { |_, msg| handler.call(msg) }) 83 | on_success&.call 84 | end 85 | 86 | def unsubscribe(channel, _handler) 87 | iodine_client&.unsubscribe(channel) 88 | end 89 | end 90 | 91 | attr_accessor :pubsub 92 | 93 | def self.for(server, socket) 94 | new(server).tap do |srv| 95 | srv.pubsub = PubSubInterface.new(socket) 96 | end 97 | end 98 | end 99 | 100 | # Socket wraps Iodine client and provides ActionCable::Server::_Socket interface 101 | class Socket 102 | private attr_reader :server, :coder, :connection 103 | attr_reader :client 104 | 105 | alias_method :iodine_client, :client 106 | 107 | delegate :worker_pool, to: :server 108 | 109 | def initialize(server, env, protocol: nil, coder: ActiveSupport::JSON) 110 | @server = server 111 | @coder = coder 112 | @env = env 113 | @logger = server.new_tagged_logger { request } 114 | @protocol = protocol 115 | 116 | server = Server.for(server, self) 117 | @connection = server.config.connection_class.call.new(server, self) 118 | 119 | # Underlying Iodine client is set on connection open 120 | @client = nil 121 | end 122 | 123 | #== Iodine callbacks == 124 | def on_open(conn) 125 | logger.debug "[Iodine] connection opened" 126 | 127 | @client = conn 128 | connection.handle_open 129 | 130 | server.setup_heartbeat_timer 131 | server.add_connection(connection) 132 | end 133 | 134 | def on_message(_conn, msg) 135 | logger.debug "[Iodine] incoming message: #{msg}" 136 | connection.handle_incoming(coder.decode(msg)) 137 | end 138 | 139 | def on_close(conn) 140 | logger.debug "[Iodine] connection closed" 141 | server.remove_connection(connection) 142 | connection.handle_close 143 | end 144 | 145 | def on_shutdown(conn) 146 | logger.debug "[Iodine] connection shutdown" 147 | conn.write( 148 | coder.encode({ 149 | type: :disconnect, 150 | reason: ::ActionCable::INTERNAL[:disconnect_reasons][:server_restart], 151 | reconnect: true 152 | }) 153 | ) 154 | end 155 | 156 | #== ActionCable socket interface == 157 | attr_reader :env, :logger, :protocol 158 | 159 | def request 160 | # Copied from ActionCable::Server::Socket#request 161 | @request ||= begin 162 | environment = Rails.application.env_config.merge(env) if defined?(Rails.application) && Rails.application 163 | ActionDispatch::Request.new(environment || env) 164 | end 165 | end 166 | 167 | def transmit(data) 168 | client&.write(coder.encode(data)) 169 | end 170 | 171 | def close = client&.close 172 | 173 | def perform_work(receiver, method_name, *args) 174 | worker_pool.async_invoke(receiver, method_name, *args, connection: self) 175 | end 176 | end 177 | end 178 | end 179 | 180 | Iodine::PubSub.default = Iodine::PubSub::Redis.new("redis://localhost:6379") 181 | ActionCable.server.config.pubsub_adapter = "ActionCable::SubscriptionAdapter::Iodine" 182 | 183 | class BenchmarkServer 184 | def self.run! 185 | app = Rack::Builder.new do 186 | map "/cable" do 187 | use ActionCable::Iodine::Middleware 188 | run(proc { |_| [404, {"Content-Type" => "text/plain"}, ["Not here"]] }) 189 | end 190 | end 191 | 192 | Iodine::DEFAULT_SETTINGS[:port] = 8080 193 | Iodine.threads = ENV.fetch("RAILS_MAX_THREADS", 5).to_i 194 | Iodine.workers = ENV.fetch("WEB_CONCURRENCY", 4).to_i 195 | 196 | Iodine.listen service: :http, handler: app 197 | Iodine.start 198 | end 199 | end 200 | 201 | BenchmarkServer.run! 202 | -------------------------------------------------------------------------------- /benchmark/servers/puma.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require_relative "../application" 5 | 6 | ActionCable.server.config.cable = { 7 | "adapter" => ENV.fetch("ACTION_CABLE_ADAPTER", "redis"), 8 | "url" => ENV["REDIS_URL"] 9 | } 10 | 11 | class BenchmarkServer 12 | def self.run! 13 | require "puma/cli" 14 | # cli = Puma::CLI.new(["-w", "#{ENV.fetch("WEB_CONCURRENCY", 4)}", "-t", "5", "-p", "8080", "-b", "tcp://0.0.0.0"]) 15 | cli = Puma::CLI.new(["-t", "1", "-p", "8080", "-b", "tcp://0.0.0.0"]) 16 | cli.instance_variable_get(:@conf).options[:app] = Rails.application 17 | cli.run 18 | end 19 | end 20 | 21 | BenchmarkServer.run! 22 | -------------------------------------------------------------------------------- /config/sus.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | require "covered/sus" 7 | include Covered::Sus 8 | -------------------------------------------------------------------------------- /example/.dockerignore: -------------------------------------------------------------------------------- 1 | # See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files. 2 | 3 | # Ignore git directory. 4 | /.git/ 5 | /.gitignore 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore all environment files. 11 | /.env* 12 | 13 | # Ignore all default key files. 14 | /config/master.key 15 | /config/credentials/*.key 16 | 17 | # Ignore all logfiles and tempfiles. 18 | /log/* 19 | /tmp/* 20 | !/log/.keep 21 | !/tmp/.keep 22 | 23 | # Ignore pidfiles, but keep the directory. 24 | /tmp/pids/* 25 | !/tmp/pids/.keep 26 | 27 | # Ignore storage (uploaded files in development and any SQLite databases). 28 | /storage/* 29 | !/storage/.keep 30 | /tmp/storage/* 31 | !/tmp/storage/.keep 32 | 33 | # Ignore assets. 34 | /node_modules/ 35 | /app/assets/builds/* 36 | !/app/assets/builds/.keep 37 | /public/assets 38 | 39 | # Ignore CI service files. 40 | /.github 41 | 42 | # Ignore development files 43 | /.devcontainer 44 | 45 | # Ignore Docker-related files 46 | /.dockerignore 47 | /Dockerfile* 48 | -------------------------------------------------------------------------------- /example/.gitattributes: -------------------------------------------------------------------------------- 1 | # See https://git-scm.com/docs/gitattributes for more about git attribute files. 2 | 3 | # Mark the database schema as having been generated. 4 | db/schema.rb linguist-generated 5 | 6 | # Mark any vendored files as having been vendored. 7 | vendor/* linguist-vendored 8 | config/credentials/*.yml.enc diff=rails_credentials 9 | config/credentials.yml.enc diff=rails_credentials 10 | -------------------------------------------------------------------------------- /example/.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | - package-ecosystem: github-actions 9 | directory: "/" 10 | schedule: 11 | interval: daily 12 | open-pull-requests-limit: 10 13 | -------------------------------------------------------------------------------- /example/.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [ main ] 7 | 8 | jobs: 9 | scan_ruby: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | 16 | - name: Set up Ruby 17 | uses: ruby/setup-ruby@v1 18 | with: 19 | ruby-version: .ruby-version 20 | bundler-cache: true 21 | 22 | - name: Scan for common Rails security vulnerabilities using static analysis 23 | run: bin/brakeman --no-pager 24 | 25 | scan_js: 26 | runs-on: ubuntu-latest 27 | 28 | steps: 29 | - name: Checkout code 30 | uses: actions/checkout@v4 31 | 32 | - name: Set up Ruby 33 | uses: ruby/setup-ruby@v1 34 | with: 35 | ruby-version: .ruby-version 36 | bundler-cache: true 37 | 38 | - name: Scan for security vulnerabilities in JavaScript dependencies 39 | run: bin/importmap audit 40 | 41 | lint: 42 | runs-on: ubuntu-latest 43 | steps: 44 | - name: Checkout code 45 | uses: actions/checkout@v4 46 | 47 | - name: Set up Ruby 48 | uses: ruby/setup-ruby@v1 49 | with: 50 | ruby-version: .ruby-version 51 | bundler-cache: true 52 | 53 | - name: Lint code for consistent style 54 | run: bin/rubocop -f github 55 | 56 | test: 57 | runs-on: ubuntu-latest 58 | 59 | # services: 60 | # redis: 61 | # image: redis 62 | # ports: 63 | # - 6379:6379 64 | # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 65 | steps: 66 | - name: Install packages 67 | run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable curl libjemalloc2 libvips sqlite3 68 | 69 | - name: Checkout code 70 | uses: actions/checkout@v4 71 | 72 | - name: Set up Ruby 73 | uses: ruby/setup-ruby@v1 74 | with: 75 | ruby-version: .ruby-version 76 | bundler-cache: true 77 | 78 | - name: Run tests 79 | env: 80 | RAILS_ENV: test 81 | # REDIS_URL: redis://localhost:6379/0 82 | run: bin/rails db:test:prepare test test:system 83 | 84 | - name: Keep screenshots from failed system tests 85 | uses: actions/upload-artifact@v4 86 | if: failure() 87 | with: 88 | name: screenshots 89 | path: ${{ github.workspace }}/tmp/screenshots 90 | if-no-files-found: ignore 91 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # Temporary files generated by your text editor or operating system 4 | # belong in git's global ignore instead: 5 | # `$XDG_CONFIG_HOME/git/ignore` or `~/.config/git/ignore` 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore all environment files. 11 | /.env* 12 | 13 | # Ignore all logfiles and tempfiles. 14 | /log/* 15 | /tmp/* 16 | !/log/.keep 17 | !/tmp/.keep 18 | 19 | # Ignore pidfiles, but keep the directory. 20 | /tmp/pids/* 21 | !/tmp/pids/ 22 | !/tmp/pids/.keep 23 | 24 | # Ignore storage (uploaded files in development and any SQLite databases). 25 | /storage/* 26 | !/storage/.keep 27 | /tmp/storage/* 28 | !/tmp/storage/ 29 | !/tmp/storage/.keep 30 | 31 | /public/assets 32 | 33 | # Ignore master key for decrypting credentials and more. 34 | /config/master.key 35 | -------------------------------------------------------------------------------- /example/.kamal/hooks/docker-setup.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Docker set up on $KAMAL_HOSTS..." 4 | -------------------------------------------------------------------------------- /example/.kamal/hooks/post-deploy.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # A sample post-deploy hook 4 | # 5 | # These environment variables are available: 6 | # KAMAL_RECORDED_AT 7 | # KAMAL_PERFORMER 8 | # KAMAL_VERSION 9 | # KAMAL_HOSTS 10 | # KAMAL_ROLE (if set) 11 | # KAMAL_DESTINATION (if set) 12 | # KAMAL_RUNTIME 13 | 14 | echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds" 15 | -------------------------------------------------------------------------------- /example/.kamal/hooks/post-proxy-reboot.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Rebooted kamal-proxy on $KAMAL_HOSTS" 4 | -------------------------------------------------------------------------------- /example/.kamal/hooks/pre-build.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # A sample pre-build hook 4 | # 5 | # Checks: 6 | # 1. We have a clean checkout 7 | # 2. A remote is configured 8 | # 3. The branch has been pushed to the remote 9 | # 4. The version we are deploying matches the remote 10 | # 11 | # These environment variables are available: 12 | # KAMAL_RECORDED_AT 13 | # KAMAL_PERFORMER 14 | # KAMAL_VERSION 15 | # KAMAL_HOSTS 16 | # KAMAL_ROLE (if set) 17 | # KAMAL_DESTINATION (if set) 18 | 19 | if [ -n "$(git status --porcelain)" ]; then 20 | echo "Git checkout is not clean, aborting..." >&2 21 | git status --porcelain >&2 22 | exit 1 23 | fi 24 | 25 | first_remote=$(git remote) 26 | 27 | if [ -z "$first_remote" ]; then 28 | echo "No git remote set, aborting..." >&2 29 | exit 1 30 | fi 31 | 32 | current_branch=$(git branch --show-current) 33 | 34 | if [ -z "$current_branch" ]; then 35 | echo "Not on a git branch, aborting..." >&2 36 | exit 1 37 | fi 38 | 39 | remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1) 40 | 41 | if [ -z "$remote_head" ]; then 42 | echo "Branch not pushed to remote, aborting..." >&2 43 | exit 1 44 | fi 45 | 46 | if [ "$KAMAL_VERSION" != "$remote_head" ]; then 47 | echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2 48 | exit 1 49 | fi 50 | 51 | exit 0 52 | -------------------------------------------------------------------------------- /example/.kamal/hooks/pre-connect.sample: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # A sample pre-connect check 4 | # 5 | # Warms DNS before connecting to hosts in parallel 6 | # 7 | # These environment variables are available: 8 | # KAMAL_RECORDED_AT 9 | # KAMAL_PERFORMER 10 | # KAMAL_VERSION 11 | # KAMAL_HOSTS 12 | # KAMAL_ROLE (if set) 13 | # KAMAL_DESTINATION (if set) 14 | # KAMAL_RUNTIME 15 | 16 | hosts = ENV["KAMAL_HOSTS"].split(",") 17 | results = nil 18 | max = 3 19 | 20 | elapsed = Benchmark.realtime do 21 | results = hosts.map do |host| 22 | Thread.new do 23 | tries = 1 24 | 25 | begin 26 | Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME) 27 | rescue SocketError 28 | if tries < max 29 | puts "Retrying DNS warmup: #{host}" 30 | tries += 1 31 | sleep rand 32 | retry 33 | else 34 | puts "DNS warmup failed: #{host}" 35 | host 36 | end 37 | end 38 | 39 | tries 40 | end 41 | end.map(&:value) 42 | end 43 | 44 | retries = results.sum - hosts.size 45 | nopes = results.count { |r| r == max } 46 | 47 | puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ] 48 | -------------------------------------------------------------------------------- /example/.kamal/hooks/pre-deploy.sample: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # A sample pre-deploy hook 4 | # 5 | # Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds. 6 | # 7 | # Fails unless the combined status is "success" 8 | # 9 | # These environment variables are available: 10 | # KAMAL_RECORDED_AT 11 | # KAMAL_PERFORMER 12 | # KAMAL_VERSION 13 | # KAMAL_HOSTS 14 | # KAMAL_COMMAND 15 | # KAMAL_SUBCOMMAND 16 | # KAMAL_ROLE (if set) 17 | # KAMAL_DESTINATION (if set) 18 | 19 | # Only check the build status for production deployments 20 | if ENV["KAMAL_COMMAND"] == "rollback" || ENV["KAMAL_DESTINATION"] != "production" 21 | exit 0 22 | end 23 | 24 | require "bundler/inline" 25 | 26 | # true = install gems so this is fast on repeat invocations 27 | gemfile(true, quiet: true) do 28 | source "https://rubygems.org" 29 | 30 | gem "octokit" 31 | gem "faraday-retry" 32 | end 33 | 34 | MAX_ATTEMPTS = 72 35 | ATTEMPTS_GAP = 10 36 | 37 | def exit_with_error(message) 38 | $stderr.puts message 39 | exit 1 40 | end 41 | 42 | class GithubStatusChecks 43 | attr_reader :remote_url, :git_sha, :github_client, :combined_status 44 | 45 | def initialize 46 | @remote_url = `git config --get remote.origin.url`.strip.delete_prefix("https://github.com/") 47 | @git_sha = `git rev-parse HEAD`.strip 48 | @github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"]) 49 | refresh! 50 | end 51 | 52 | def refresh! 53 | @combined_status = github_client.combined_status(remote_url, git_sha) 54 | end 55 | 56 | def state 57 | combined_status[:state] 58 | end 59 | 60 | def first_status_url 61 | first_status = combined_status[:statuses].find { |status| status[:state] == state } 62 | first_status && first_status[:target_url] 63 | end 64 | 65 | def complete_count 66 | combined_status[:statuses].count { |status| status[:state] != "pending"} 67 | end 68 | 69 | def total_count 70 | combined_status[:statuses].count 71 | end 72 | 73 | def current_status 74 | if total_count > 0 75 | "Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ..." 76 | else 77 | "Build not started..." 78 | end 79 | end 80 | end 81 | 82 | 83 | $stdout.sync = true 84 | 85 | puts "Checking build status..." 86 | attempts = 0 87 | checks = GithubStatusChecks.new 88 | 89 | begin 90 | loop do 91 | case checks.state 92 | when "success" 93 | puts "Checks passed, see #{checks.first_status_url}" 94 | exit 0 95 | when "failure" 96 | exit_with_error "Checks failed, see #{checks.first_status_url}" 97 | when "pending" 98 | attempts += 1 99 | end 100 | 101 | exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS 102 | 103 | puts checks.current_status 104 | sleep(ATTEMPTS_GAP) 105 | checks.refresh! 106 | end 107 | rescue Octokit::NotFound 108 | exit_with_error "Build status could not be found" 109 | end 110 | -------------------------------------------------------------------------------- /example/.kamal/hooks/pre-proxy-reboot.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Rebooting kamal-proxy on $KAMAL_HOSTS..." 4 | -------------------------------------------------------------------------------- /example/.kamal/secrets: -------------------------------------------------------------------------------- 1 | # Secrets defined here are available for reference under registry/password, env/secret, builder/secrets, 2 | # and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either 3 | # password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git. 4 | 5 | # Example of extracting secrets from 1password (or another compatible pw manager) 6 | # SECRETS=$(kamal secrets fetch --adapter 1password --account your-account --from Vault/Item KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY) 7 | # KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD ${SECRETS}) 8 | # RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY ${SECRETS}) 9 | 10 | # Use a GITHUB_TOKEN if private repositories are needed for the image 11 | # GITHUB_TOKEN=$(gh config get -h github.com oauth_token) 12 | 13 | # Grab the registry password from ENV 14 | KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD 15 | 16 | # Improve security by using a password manager. Never check config/master.key into git! 17 | RAILS_MASTER_KEY=$(cat config/master.key) 18 | -------------------------------------------------------------------------------- /example/.rubocop.yml: -------------------------------------------------------------------------------- 1 | # Omakase Ruby styling for Rails 2 | inherit_gem: { rubocop-rails-omakase: rubocop.yml } 3 | 4 | # Overwrite or add rules to create your own house style 5 | # 6 | # # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` 7 | # Layout/SpaceInsideArrayLiteralBrackets: 8 | # Enabled: false 9 | -------------------------------------------------------------------------------- /example/.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-3.3.6 2 | -------------------------------------------------------------------------------- /example/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | # check=error=true 3 | 4 | # This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand: 5 | # docker build -t example . 6 | # docker run -d -p 80:80 -e RAILS_MASTER_KEY= --name example example 7 | 8 | # For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html 9 | 10 | # Make sure RUBY_VERSION matches the Ruby version in .ruby-version 11 | ARG RUBY_VERSION=3.3.6 12 | FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base 13 | 14 | # Rails app lives here 15 | WORKDIR /rails 16 | 17 | # Install base packages 18 | RUN apt-get update -qq && \ 19 | apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \ 20 | rm -rf /var/lib/apt/lists /var/cache/apt/archives 21 | 22 | # Set production environment 23 | ENV RAILS_ENV="production" \ 24 | BUNDLE_DEPLOYMENT="1" \ 25 | BUNDLE_PATH="/usr/local/bundle" \ 26 | BUNDLE_WITHOUT="development" 27 | 28 | # Throw-away build stage to reduce size of final image 29 | FROM base AS build 30 | 31 | # Install packages needed to build gems 32 | RUN apt-get update -qq && \ 33 | apt-get install --no-install-recommends -y build-essential git pkg-config && \ 34 | rm -rf /var/lib/apt/lists /var/cache/apt/archives 35 | 36 | # Install application gems 37 | COPY Gemfile Gemfile.lock ./ 38 | RUN bundle install && \ 39 | rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ 40 | bundle exec bootsnap precompile --gemfile 41 | 42 | # Copy application code 43 | COPY . . 44 | 45 | # Precompile bootsnap code for faster boot times 46 | RUN bundle exec bootsnap precompile app/ lib/ 47 | 48 | # Precompiling assets for production without requiring secret RAILS_MASTER_KEY 49 | RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile 50 | 51 | 52 | 53 | 54 | # Final stage for app image 55 | FROM base 56 | 57 | # Copy built artifacts: gems, application 58 | COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" 59 | COPY --from=build /rails /rails 60 | 61 | # Run and own only the runtime files as a non-root user for security 62 | RUN groupadd --system --gid 1000 rails && \ 63 | useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ 64 | chown -R rails:rails db log storage tmp 65 | USER 1000:1000 66 | 67 | # Entrypoint prepares the database. 68 | ENTRYPOINT ["/rails/bin/docker-entrypoint"] 69 | 70 | # Start server via Thruster by default, this can be overwritten at runtime 71 | EXPOSE 80 72 | CMD ["./bin/thrust", "./bin/rails", "server"] 73 | -------------------------------------------------------------------------------- /example/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" 4 | gem "rails", "~> 8.0.0" 5 | # The modern asset pipeline for Rails [https://github.com/rails/propshaft] 6 | gem "propshaft" 7 | # Use sqlite3 as the database for Active Record 8 | gem "sqlite3", ">= 2.1" 9 | # Use the Puma web server [https://github.com/puma/puma] 10 | # gem "puma", ">= 5.0" 11 | gem "falcon" 12 | # Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails] 13 | gem "importmap-rails" 14 | # Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev] 15 | gem "turbo-rails" 16 | # Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev] 17 | gem "stimulus-rails" 18 | # Build JSON APIs with ease [https://github.com/rails/jbuilder] 19 | gem "jbuilder" 20 | 21 | # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] 22 | # gem "bcrypt", "~> 3.1.7" 23 | 24 | # Windows does not include zoneinfo files, so bundle the tzinfo-data gem 25 | gem "tzinfo-data", platforms: %i[ windows jruby ] 26 | 27 | # Use the database-backed adapters for Rails.cache, Active Job, and Action Cable 28 | gem "solid_cache" 29 | gem "solid_queue" 30 | gem "solid_cable" 31 | 32 | gem "async-cable", path: "../" 33 | gem "redis" 34 | 35 | # Reduces boot times through caching; required in config/boot.rb 36 | gem "bootsnap", require: false 37 | 38 | # Deploy this application anywhere as a Docker container [https://kamal-deploy.org] 39 | gem "kamal", require: false 40 | 41 | # Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/] 42 | gem "thruster", require: false 43 | 44 | # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] 45 | # gem "image_processing", "~> 1.2" 46 | 47 | group :development, :test do 48 | # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem 49 | gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" 50 | 51 | # Static analysis for security vulnerabilities [https://brakemanscanner.org/] 52 | gem "brakeman", require: false 53 | 54 | # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] 55 | gem "rubocop-rails-omakase", require: false 56 | end 57 | 58 | group :development do 59 | # Use console on exceptions pages [https://github.com/rails/web-console] 60 | gem "web-console" 61 | end 62 | 63 | group :test do 64 | # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] 65 | gem "capybara" 66 | gem "selenium-webdriver" 67 | end 68 | -------------------------------------------------------------------------------- /example/Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | async-cable (0.1.0) 5 | actioncable-next 6 | async (~> 2.9) 7 | async-websocket 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | actioncable (8.0.0) 13 | actionpack (= 8.0.0) 14 | activesupport (= 8.0.0) 15 | nio4r (~> 2.0) 16 | websocket-driver (>= 0.6.1) 17 | zeitwerk (~> 2.6) 18 | actioncable-next (0.1.1) 19 | actionpack (>= 7.0, <= 8.1) 20 | activesupport (>= 7.0, <= 8.1) 21 | nio4r (~> 2.0) 22 | websocket-driver (>= 0.6.1) 23 | zeitwerk (~> 2.6) 24 | actionmailbox (8.0.0) 25 | actionpack (= 8.0.0) 26 | activejob (= 8.0.0) 27 | activerecord (= 8.0.0) 28 | activestorage (= 8.0.0) 29 | activesupport (= 8.0.0) 30 | mail (>= 2.8.0) 31 | actionmailer (8.0.0) 32 | actionpack (= 8.0.0) 33 | actionview (= 8.0.0) 34 | activejob (= 8.0.0) 35 | activesupport (= 8.0.0) 36 | mail (>= 2.8.0) 37 | rails-dom-testing (~> 2.2) 38 | actionpack (8.0.0) 39 | actionview (= 8.0.0) 40 | activesupport (= 8.0.0) 41 | nokogiri (>= 1.8.5) 42 | rack (>= 2.2.4) 43 | rack-session (>= 1.0.1) 44 | rack-test (>= 0.6.3) 45 | rails-dom-testing (~> 2.2) 46 | rails-html-sanitizer (~> 1.6) 47 | useragent (~> 0.16) 48 | actiontext (8.0.0) 49 | actionpack (= 8.0.0) 50 | activerecord (= 8.0.0) 51 | activestorage (= 8.0.0) 52 | activesupport (= 8.0.0) 53 | globalid (>= 0.6.0) 54 | nokogiri (>= 1.8.5) 55 | actionview (8.0.0) 56 | activesupport (= 8.0.0) 57 | builder (~> 3.1) 58 | erubi (~> 1.11) 59 | rails-dom-testing (~> 2.2) 60 | rails-html-sanitizer (~> 1.6) 61 | activejob (8.0.0) 62 | activesupport (= 8.0.0) 63 | globalid (>= 0.3.6) 64 | activemodel (8.0.0) 65 | activesupport (= 8.0.0) 66 | activerecord (8.0.0) 67 | activemodel (= 8.0.0) 68 | activesupport (= 8.0.0) 69 | timeout (>= 0.4.0) 70 | activestorage (8.0.0) 71 | actionpack (= 8.0.0) 72 | activejob (= 8.0.0) 73 | activerecord (= 8.0.0) 74 | activesupport (= 8.0.0) 75 | marcel (~> 1.0) 76 | activesupport (8.0.0) 77 | base64 78 | benchmark (>= 0.3) 79 | bigdecimal 80 | concurrent-ruby (~> 1.0, >= 1.3.1) 81 | connection_pool (>= 2.2.5) 82 | drb 83 | i18n (>= 1.6, < 2) 84 | logger (>= 1.4.2) 85 | minitest (>= 5.1) 86 | securerandom (>= 0.3) 87 | tzinfo (~> 2.0, >= 2.0.5) 88 | uri (>= 0.13.1) 89 | addressable (2.8.7) 90 | public_suffix (>= 2.0.2, < 7.0) 91 | ast (2.4.2) 92 | async (2.20.0) 93 | console (~> 1.29) 94 | fiber-annotation 95 | io-event (~> 1.6, >= 1.6.5) 96 | async-container (0.18.3) 97 | async (~> 2.10) 98 | async-http (0.83.1) 99 | async (>= 2.10.2) 100 | async-pool (~> 0.9) 101 | io-endpoint (~> 0.14) 102 | io-stream (~> 0.6) 103 | metrics (~> 0.12) 104 | protocol-http (~> 0.43) 105 | protocol-http1 (>= 0.28.1) 106 | protocol-http2 (~> 0.19) 107 | traces (~> 0.10) 108 | async-http-cache (0.4.4) 109 | async-http (~> 0.56) 110 | async-pool (0.10.2) 111 | async (>= 1.25) 112 | traces 113 | async-service (0.12.0) 114 | async 115 | async-container (~> 0.16) 116 | async-websocket (0.30.0) 117 | async-http (~> 0.76) 118 | protocol-http (~> 0.34) 119 | protocol-rack (~> 0.7) 120 | protocol-websocket (~> 0.17) 121 | base64 (0.2.0) 122 | bcrypt_pbkdf (1.1.1) 123 | bcrypt_pbkdf (1.1.1-arm64-darwin) 124 | bcrypt_pbkdf (1.1.1-x86_64-darwin) 125 | benchmark (0.4.0) 126 | bigdecimal (3.1.8) 127 | bindex (0.8.1) 128 | bootsnap (1.18.4) 129 | msgpack (~> 1.2) 130 | brakeman (6.2.2) 131 | racc 132 | builder (3.3.0) 133 | capybara (3.40.0) 134 | addressable 135 | matrix 136 | mini_mime (>= 0.1.3) 137 | nokogiri (~> 1.11) 138 | rack (>= 1.6.0) 139 | rack-test (>= 0.6.3) 140 | regexp_parser (>= 1.5, < 3.0) 141 | xpath (~> 3.2) 142 | concurrent-ruby (1.3.4) 143 | connection_pool (2.4.1) 144 | console (1.29.0) 145 | fiber-annotation 146 | fiber-local (~> 1.1) 147 | json 148 | crass (1.0.6) 149 | date (3.4.0) 150 | debug (1.9.2) 151 | irb (~> 1.10) 152 | reline (>= 0.3.8) 153 | dotenv (3.1.4) 154 | drb (2.2.1) 155 | ed25519 (1.3.0) 156 | erubi (1.13.0) 157 | et-orbi (1.2.11) 158 | tzinfo 159 | falcon (0.48.3) 160 | async 161 | async-container (~> 0.18) 162 | async-http (~> 0.75) 163 | async-http-cache (~> 0.4) 164 | async-service (~> 0.10) 165 | bundler 166 | localhost (~> 1.1) 167 | openssl (~> 3.0) 168 | process-metrics (~> 0.2) 169 | protocol-http (~> 0.31) 170 | protocol-rack (~> 0.7) 171 | samovar (~> 2.3) 172 | fiber-annotation (0.2.0) 173 | fiber-local (1.1.0) 174 | fiber-storage 175 | fiber-storage (1.0.0) 176 | fugit (1.11.1) 177 | et-orbi (~> 1, >= 1.2.11) 178 | raabro (~> 1.4) 179 | globalid (1.2.1) 180 | activesupport (>= 6.1) 181 | i18n (1.14.6) 182 | concurrent-ruby (~> 1.0) 183 | importmap-rails (2.0.3) 184 | actionpack (>= 6.0.0) 185 | activesupport (>= 6.0.0) 186 | railties (>= 6.0.0) 187 | io-console (0.7.2) 188 | io-endpoint (0.14.0) 189 | io-event (1.7.3) 190 | io-stream (0.6.1) 191 | irb (1.14.1) 192 | rdoc (>= 4.0.0) 193 | reline (>= 0.4.2) 194 | jbuilder (2.13.0) 195 | actionview (>= 5.0.0) 196 | activesupport (>= 5.0.0) 197 | json (2.8.2) 198 | kamal (2.3.0) 199 | activesupport (>= 7.0) 200 | base64 (~> 0.2) 201 | bcrypt_pbkdf (~> 1.0) 202 | concurrent-ruby (~> 1.2) 203 | dotenv (~> 3.1) 204 | ed25519 (~> 1.2) 205 | net-ssh (~> 7.3) 206 | sshkit (>= 1.23.0, < 2.0) 207 | thor (~> 1.3) 208 | zeitwerk (>= 2.6.18, < 3.0) 209 | language_server-protocol (3.17.0.3) 210 | localhost (1.3.1) 211 | logger (1.6.1) 212 | loofah (2.23.1) 213 | crass (~> 1.0.2) 214 | nokogiri (>= 1.12.0) 215 | mail (2.8.1) 216 | mini_mime (>= 0.1.1) 217 | net-imap 218 | net-pop 219 | net-smtp 220 | mapping (1.1.1) 221 | marcel (1.0.4) 222 | matrix (0.4.2) 223 | metrics (0.12.1) 224 | mini_mime (1.1.5) 225 | minitest (5.25.1) 226 | msgpack (1.7.5) 227 | net-imap (0.5.1) 228 | date 229 | net-protocol 230 | net-pop (0.1.2) 231 | net-protocol 232 | net-protocol (0.2.2) 233 | timeout 234 | net-scp (4.0.0) 235 | net-ssh (>= 2.6.5, < 8.0.0) 236 | net-sftp (4.0.0) 237 | net-ssh (>= 5.0.0, < 8.0.0) 238 | net-smtp (0.5.0) 239 | net-protocol 240 | net-ssh (7.3.0) 241 | nio4r (2.7.4) 242 | nokogiri (1.16.7-aarch64-linux) 243 | racc (~> 1.4) 244 | nokogiri (1.16.7-arm-linux) 245 | racc (~> 1.4) 246 | nokogiri (1.16.7-arm64-darwin) 247 | racc (~> 1.4) 248 | nokogiri (1.16.7-x86-linux) 249 | racc (~> 1.4) 250 | nokogiri (1.16.7-x86_64-darwin) 251 | racc (~> 1.4) 252 | nokogiri (1.16.7-x86_64-linux) 253 | racc (~> 1.4) 254 | openssl (3.2.0) 255 | ostruct (0.6.1) 256 | parallel (1.26.3) 257 | parser (3.3.6.0) 258 | ast (~> 2.4.1) 259 | racc 260 | process-metrics (0.3.0) 261 | console (~> 1.8) 262 | json (~> 2) 263 | samovar (~> 2.1) 264 | propshaft (1.1.0) 265 | actionpack (>= 7.0.0) 266 | activesupport (>= 7.0.0) 267 | rack 268 | railties (>= 7.0.0) 269 | protocol-hpack (1.5.1) 270 | protocol-http (0.44.0) 271 | protocol-http1 (0.28.1) 272 | protocol-http (~> 0.22) 273 | protocol-http2 (0.20.0) 274 | protocol-hpack (~> 1.4) 275 | protocol-http (~> 0.18) 276 | protocol-rack (0.11.0) 277 | protocol-http (~> 0.43) 278 | rack (>= 1.0) 279 | protocol-websocket (0.20.1) 280 | protocol-http (~> 0.2) 281 | psych (5.2.0) 282 | stringio 283 | public_suffix (6.0.1) 284 | raabro (1.4.0) 285 | racc (1.8.1) 286 | rack (3.1.8) 287 | rack-session (2.0.0) 288 | rack (>= 3.0.0) 289 | rack-test (2.1.0) 290 | rack (>= 1.3) 291 | rackup (2.2.1) 292 | rack (>= 3) 293 | rails (8.0.0) 294 | actioncable (= 8.0.0) 295 | actionmailbox (= 8.0.0) 296 | actionmailer (= 8.0.0) 297 | actionpack (= 8.0.0) 298 | actiontext (= 8.0.0) 299 | actionview (= 8.0.0) 300 | activejob (= 8.0.0) 301 | activemodel (= 8.0.0) 302 | activerecord (= 8.0.0) 303 | activestorage (= 8.0.0) 304 | activesupport (= 8.0.0) 305 | bundler (>= 1.15.0) 306 | railties (= 8.0.0) 307 | rails-dom-testing (2.2.0) 308 | activesupport (>= 5.0.0) 309 | minitest 310 | nokogiri (>= 1.6) 311 | rails-html-sanitizer (1.6.0) 312 | loofah (~> 2.21) 313 | nokogiri (~> 1.14) 314 | railties (8.0.0) 315 | actionpack (= 8.0.0) 316 | activesupport (= 8.0.0) 317 | irb (~> 1.13) 318 | rackup (>= 1.0.0) 319 | rake (>= 12.2) 320 | thor (~> 1.0, >= 1.2.2) 321 | zeitwerk (~> 2.6) 322 | rainbow (3.1.1) 323 | rake (13.2.1) 324 | rdoc (6.8.0) 325 | psych (>= 4.0.0) 326 | redis (5.3.0) 327 | redis-client (>= 0.22.0) 328 | redis-client (0.22.2) 329 | connection_pool 330 | regexp_parser (2.9.2) 331 | reline (0.5.11) 332 | io-console (~> 0.5) 333 | rexml (3.3.9) 334 | rubocop (1.68.0) 335 | json (~> 2.3) 336 | language_server-protocol (>= 3.17.0) 337 | parallel (~> 1.10) 338 | parser (>= 3.3.0.2) 339 | rainbow (>= 2.2.2, < 4.0) 340 | regexp_parser (>= 2.4, < 3.0) 341 | rubocop-ast (>= 1.32.2, < 2.0) 342 | ruby-progressbar (~> 1.7) 343 | unicode-display_width (>= 2.4.0, < 3.0) 344 | rubocop-ast (1.36.1) 345 | parser (>= 3.3.1.0) 346 | rubocop-minitest (0.36.0) 347 | rubocop (>= 1.61, < 2.0) 348 | rubocop-ast (>= 1.31.1, < 2.0) 349 | rubocop-performance (1.23.0) 350 | rubocop (>= 1.48.1, < 2.0) 351 | rubocop-ast (>= 1.31.1, < 2.0) 352 | rubocop-rails (2.27.0) 353 | activesupport (>= 4.2.0) 354 | rack (>= 1.1) 355 | rubocop (>= 1.52.0, < 2.0) 356 | rubocop-ast (>= 1.31.1, < 2.0) 357 | rubocop-rails-omakase (1.0.0) 358 | rubocop 359 | rubocop-minitest 360 | rubocop-performance 361 | rubocop-rails 362 | ruby-progressbar (1.13.0) 363 | rubyzip (2.3.2) 364 | samovar (2.3.0) 365 | console (~> 1.0) 366 | mapping (~> 1.0) 367 | securerandom (0.3.2) 368 | selenium-webdriver (4.26.0) 369 | base64 (~> 0.2) 370 | logger (~> 1.4) 371 | rexml (~> 3.2, >= 3.2.5) 372 | rubyzip (>= 1.2.2, < 3.0) 373 | websocket (~> 1.0) 374 | solid_cable (3.0.2) 375 | actioncable (>= 7.2) 376 | activejob (>= 7.2) 377 | activerecord (>= 7.2) 378 | railties (>= 7.2) 379 | solid_cache (1.0.6) 380 | activejob (>= 7.2) 381 | activerecord (>= 7.2) 382 | railties (>= 7.2) 383 | solid_queue (1.0.2) 384 | activejob (>= 7.1) 385 | activerecord (>= 7.1) 386 | concurrent-ruby (>= 1.3.1) 387 | fugit (~> 1.11.0) 388 | railties (>= 7.1) 389 | thor (~> 1.3.1) 390 | sqlite3 (2.2.0-aarch64-linux-gnu) 391 | sqlite3 (2.2.0-aarch64-linux-musl) 392 | sqlite3 (2.2.0-arm-linux-gnu) 393 | sqlite3 (2.2.0-arm-linux-musl) 394 | sqlite3 (2.2.0-arm64-darwin) 395 | sqlite3 (2.2.0-x86-linux-gnu) 396 | sqlite3 (2.2.0-x86-linux-musl) 397 | sqlite3 (2.2.0-x86_64-darwin) 398 | sqlite3 (2.2.0-x86_64-linux-gnu) 399 | sqlite3 (2.2.0-x86_64-linux-musl) 400 | sshkit (1.23.2) 401 | base64 402 | net-scp (>= 1.1.2) 403 | net-sftp (>= 2.1.2) 404 | net-ssh (>= 2.8.0) 405 | ostruct 406 | stimulus-rails (1.3.4) 407 | railties (>= 6.0.0) 408 | stringio (3.1.2) 409 | thor (1.3.2) 410 | thruster (0.1.9) 411 | thruster (0.1.9-aarch64-linux) 412 | thruster (0.1.9-arm64-darwin) 413 | thruster (0.1.9-x86_64-darwin) 414 | thruster (0.1.9-x86_64-linux) 415 | timeout (0.4.2) 416 | traces (0.14.1) 417 | turbo-rails (2.0.11) 418 | actionpack (>= 6.0.0) 419 | railties (>= 6.0.0) 420 | tzinfo (2.0.6) 421 | concurrent-ruby (~> 1.0) 422 | unicode-display_width (2.6.0) 423 | uri (1.0.2) 424 | useragent (0.16.10) 425 | web-console (4.2.1) 426 | actionview (>= 6.0.0) 427 | activemodel (>= 6.0.0) 428 | bindex (>= 0.4.0) 429 | railties (>= 6.0.0) 430 | websocket (1.2.11) 431 | websocket-driver (0.7.6) 432 | websocket-extensions (>= 0.1.0) 433 | websocket-extensions (0.1.5) 434 | xpath (3.2.0) 435 | nokogiri (~> 1.8) 436 | zeitwerk (2.7.1) 437 | 438 | PLATFORMS 439 | aarch64-linux 440 | aarch64-linux-gnu 441 | aarch64-linux-musl 442 | arm-linux 443 | arm-linux-gnu 444 | arm-linux-musl 445 | arm64-darwin 446 | x86-linux 447 | x86-linux-gnu 448 | x86-linux-musl 449 | x86_64-darwin 450 | x86_64-linux 451 | x86_64-linux-gnu 452 | x86_64-linux-musl 453 | 454 | DEPENDENCIES 455 | async-cable! 456 | bootsnap 457 | brakeman 458 | capybara 459 | debug 460 | falcon 461 | importmap-rails 462 | jbuilder 463 | kamal 464 | propshaft 465 | rails (~> 8.0.0) 466 | redis 467 | rubocop-rails-omakase 468 | selenium-webdriver 469 | solid_cable 470 | solid_cache 471 | solid_queue 472 | sqlite3 (>= 2.1) 473 | stimulus-rails 474 | thruster 475 | turbo-rails 476 | tzinfo-data 477 | web-console 478 | 479 | BUNDLED WITH 480 | 2.5.22 481 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # README 2 | 3 | This README would normally document whatever steps are necessary to get the 4 | application up and running. 5 | 6 | Things you may want to cover: 7 | 8 | * Ruby version 9 | 10 | * System dependencies 11 | 12 | * Configuration 13 | 14 | * Database creation 15 | 16 | * Database initialization 17 | 18 | * How to run the test suite 19 | 20 | * Services (job queues, cache servers, search engines, etc.) 21 | 22 | * Deployment instructions 23 | 24 | * ... 25 | -------------------------------------------------------------------------------- /example/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative "config/application" 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /example/app/assets/images/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketry/async-cable/0d0b82d0935699598ebd28fb8ac7fb045c71c2ec/example/app/assets/images/.keep -------------------------------------------------------------------------------- /example/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css. 3 | * 4 | * With Propshaft, assets are served efficiently without preprocessing steps. You can still include 5 | * application-wide styles in this file, but keep in mind that CSS precedence will follow the standard 6 | * cascading order, meaning styles declared later in the document or manifest will override earlier ones, 7 | * depending on specificity. 8 | * 9 | * Consider organizing styles into separate files for maintainability. 10 | */ 11 | -------------------------------------------------------------------------------- /example/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. 3 | allow_browser versions: :modern 4 | end 5 | -------------------------------------------------------------------------------- /example/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketry/async-cable/0d0b82d0935699598ebd28fb8ac7fb045c71c2ec/example/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /example/app/controllers/items_controller.rb: -------------------------------------------------------------------------------- 1 | class ItemsController < ApplicationController 2 | before_action :set_item, only: %i[ show edit update destroy ] 3 | 4 | # GET /items or /items.json 5 | def index 6 | @items = Item.all 7 | end 8 | 9 | # GET /items/1 or /items/1.json 10 | def show 11 | end 12 | 13 | # GET /items/new 14 | def new 15 | @item = Item.new 16 | end 17 | 18 | # GET /items/1/edit 19 | def edit 20 | end 21 | 22 | # POST /items or /items.json 23 | def create 24 | @item = Item.new(item_params) 25 | 26 | respond_to do |format| 27 | if @item.save 28 | format.html { redirect_to @item, notice: "Item was successfully created." } 29 | format.json { render :show, status: :created, location: @item } 30 | else 31 | format.html { render :new, status: :unprocessable_entity } 32 | format.json { render json: @item.errors, status: :unprocessable_entity } 33 | end 34 | end 35 | end 36 | 37 | # PATCH/PUT /items/1 or /items/1.json 38 | def update 39 | respond_to do |format| 40 | if @item.update(item_params) 41 | format.html { redirect_to @item, notice: "Item was successfully updated." } 42 | format.json { render :show, status: :ok, location: @item } 43 | else 44 | format.html { render :edit, status: :unprocessable_entity } 45 | format.json { render json: @item.errors, status: :unprocessable_entity } 46 | end 47 | end 48 | end 49 | 50 | # DELETE /items/1 or /items/1.json 51 | def destroy 52 | @item.destroy! 53 | 54 | respond_to do |format| 55 | format.html { redirect_to items_path, status: :see_other, notice: "Item was successfully destroyed." } 56 | format.json { head :no_content } 57 | end 58 | end 59 | 60 | private 61 | # Use callbacks to share common setup or constraints between actions. 62 | def set_item 63 | @item = Item.find(params.expect(:id)) 64 | end 65 | 66 | # Only allow a list of trusted parameters through. 67 | def item_params 68 | params.fetch(:item, {}) 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /example/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /example/app/helpers/items_helper.rb: -------------------------------------------------------------------------------- 1 | module ItemsHelper 2 | end 3 | -------------------------------------------------------------------------------- /example/app/javascript/application.js: -------------------------------------------------------------------------------- 1 | // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails 2 | import "@hotwired/turbo-rails" 3 | import "controllers" 4 | -------------------------------------------------------------------------------- /example/app/javascript/controllers/application.js: -------------------------------------------------------------------------------- 1 | import { Application } from "@hotwired/stimulus" 2 | 3 | const application = Application.start() 4 | 5 | // Configure Stimulus development experience 6 | application.debug = false 7 | window.Stimulus = application 8 | 9 | export { application } 10 | -------------------------------------------------------------------------------- /example/app/javascript/controllers/hello_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus" 2 | 3 | export default class extends Controller { 4 | connect() { 5 | this.element.textContent = "Hello World!" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /example/app/javascript/controllers/index.js: -------------------------------------------------------------------------------- 1 | // Import and register all your controllers from the importmap via controllers/**/*_controller 2 | import { application } from "controllers/application" 3 | import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" 4 | eagerLoadControllersFrom("controllers", application) 5 | -------------------------------------------------------------------------------- /example/app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | # Automatically retry jobs that encountered a deadlock 3 | # retry_on ActiveRecord::Deadlocked 4 | 5 | # Most jobs are safe to ignore if the underlying records are no longer available 6 | # discard_on ActiveJob::DeserializationError 7 | end 8 | -------------------------------------------------------------------------------- /example/app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | class ApplicationMailer < ActionMailer::Base 2 | default from: "from@example.com" 3 | layout "mailer" 4 | end 5 | -------------------------------------------------------------------------------- /example/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | primary_abstract_class 3 | end 4 | -------------------------------------------------------------------------------- /example/app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketry/async-cable/0d0b82d0935699598ebd28fb8ac7fb045c71c2ec/example/app/models/concerns/.keep -------------------------------------------------------------------------------- /example/app/models/item.rb: -------------------------------------------------------------------------------- 1 | class Item < ApplicationRecord 2 | after_create_commit -> { broadcast_append_to "items" } 3 | end 4 | -------------------------------------------------------------------------------- /example/app/views/items/_form.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_with(model: item) do |form| %> 2 | <% if item.errors.any? %> 3 |
4 |

<%= pluralize(item.errors.count, "error") %> prohibited this item from being saved:

5 | 6 | 11 |
12 | <% end %> 13 | 14 |
15 | <%= form.submit %> 16 |
17 | <% end %> 18 | -------------------------------------------------------------------------------- /example/app/views/items/_item.html.erb: -------------------------------------------------------------------------------- 1 |
2 | item id: <%= item.id %> 3 |
4 | -------------------------------------------------------------------------------- /example/app/views/items/_item.json.jbuilder: -------------------------------------------------------------------------------- 1 | json.extract! item, :id, :created_at, :updated_at 2 | json.url item_url(item, format: :json) 3 | -------------------------------------------------------------------------------- /example/app/views/items/edit.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :title, "Editing item" %> 2 | 3 |

Editing item

4 | 5 | <%= render "form", item: @item %> 6 | 7 |
8 | 9 |
10 | <%= link_to "Show this item", @item %> | 11 | <%= link_to "Back to items", items_path %> 12 |
13 | -------------------------------------------------------------------------------- /example/app/views/items/index.html.erb: -------------------------------------------------------------------------------- 1 |

<%= notice %>

2 | 3 | <% content_for :title, "Items" %> 4 | 5 |

Items

6 | 7 | <%= turbo_stream_from "items" %> 8 | 9 |
10 | <%= render @items %> 11 |
12 | 13 | <%= link_to "New item", new_item_path %> 14 | -------------------------------------------------------------------------------- /example/app/views/items/index.json.jbuilder: -------------------------------------------------------------------------------- 1 | json.array! @items, partial: "items/item", as: :item 2 | -------------------------------------------------------------------------------- /example/app/views/items/new.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :title, "New item" %> 2 | 3 |

New item

4 | 5 | <%= render "form", item: @item %> 6 | 7 |
8 | 9 |
10 | <%= link_to "Back to items", items_path %> 11 |
12 | -------------------------------------------------------------------------------- /example/app/views/items/show.html.erb: -------------------------------------------------------------------------------- 1 |

<%= notice %>

2 | 3 | <%= render @item %> 4 | 5 |
6 | <%= link_to "Edit this item", edit_item_path(@item) %> | 7 | <%= link_to "Back to items", items_path %> 8 | 9 | <%= button_to "Destroy this item", @item, method: :delete %> 10 |
11 | -------------------------------------------------------------------------------- /example/app/views/items/show.json.jbuilder: -------------------------------------------------------------------------------- 1 | json.partial! "items/item", item: @item 2 | -------------------------------------------------------------------------------- /example/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= content_for(:title) || "Example" %> 5 | 6 | 7 | 8 | <%= csrf_meta_tags %> 9 | <%= csp_meta_tag %> 10 | 11 | <%= yield :head %> 12 | 13 | <%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %> 14 | <%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %> 15 | 16 | 17 | 18 | 19 | 20 | <%# Includes all stylesheet files in app/assets/stylesheets %> 21 | <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> 22 | <%= javascript_importmap_tags %> 23 | 24 | 25 | 26 | <%= yield %> 27 | 28 | 29 | -------------------------------------------------------------------------------- /example/app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /example/app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /example/app/views/pwa/manifest.json.erb: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Example", 3 | "icons": [ 4 | { 5 | "src": "/icon.png", 6 | "type": "image/png", 7 | "sizes": "512x512" 8 | }, 9 | { 10 | "src": "/icon.png", 11 | "type": "image/png", 12 | "sizes": "512x512", 13 | "purpose": "maskable" 14 | } 15 | ], 16 | "start_url": "/", 17 | "display": "standalone", 18 | "scope": "/", 19 | "description": "Example.", 20 | "theme_color": "red", 21 | "background_color": "red" 22 | } 23 | -------------------------------------------------------------------------------- /example/app/views/pwa/service-worker.js: -------------------------------------------------------------------------------- 1 | // Add a service worker for processing Web Push notifications: 2 | // 3 | // self.addEventListener("push", async (event) => { 4 | // const { title, options } = await event.data.json() 5 | // event.waitUntil(self.registration.showNotification(title, options)) 6 | // }) 7 | // 8 | // self.addEventListener("notificationclick", function(event) { 9 | // event.notification.close() 10 | // event.waitUntil( 11 | // clients.matchAll({ type: "window" }).then((clientList) => { 12 | // for (let i = 0; i < clientList.length; i++) { 13 | // let client = clientList[i] 14 | // let clientPath = (new URL(client.url)).pathname 15 | // 16 | // if (clientPath == event.notification.data.path && "focus" in client) { 17 | // return client.focus() 18 | // } 19 | // } 20 | // 21 | // if (clients.openWindow) { 22 | // return clients.openWindow(event.notification.data.path) 23 | // } 24 | // }) 25 | // ) 26 | // }) 27 | -------------------------------------------------------------------------------- /example/bin/brakeman: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "rubygems" 3 | require "bundler/setup" 4 | 5 | ARGV.unshift("--ensure-latest") 6 | 7 | load Gem.bin_path("brakeman", "brakeman") 8 | -------------------------------------------------------------------------------- /example/bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'bundle' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "rubygems" 12 | 13 | m = Module.new do 14 | module_function 15 | 16 | def invoked_as_script? 17 | File.expand_path($0) == File.expand_path(__FILE__) 18 | end 19 | 20 | def env_var_version 21 | ENV["BUNDLER_VERSION"] 22 | end 23 | 24 | def cli_arg_version 25 | return unless invoked_as_script? # don't want to hijack other binstubs 26 | return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update` 27 | bundler_version = nil 28 | update_index = nil 29 | ARGV.each_with_index do |a, i| 30 | if update_index && update_index.succ == i && a.match?(Gem::Version::ANCHORED_VERSION_PATTERN) 31 | bundler_version = a 32 | end 33 | next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ 34 | bundler_version = $1 35 | update_index = i 36 | end 37 | bundler_version 38 | end 39 | 40 | def gemfile 41 | gemfile = ENV["BUNDLE_GEMFILE"] 42 | return gemfile if gemfile && !gemfile.empty? 43 | 44 | File.expand_path("../Gemfile", __dir__) 45 | end 46 | 47 | def lockfile 48 | lockfile = 49 | case File.basename(gemfile) 50 | when "gems.rb" then gemfile.sub(/\.rb$/, ".locked") 51 | else "#{gemfile}.lock" 52 | end 53 | File.expand_path(lockfile) 54 | end 55 | 56 | def lockfile_version 57 | return unless File.file?(lockfile) 58 | lockfile_contents = File.read(lockfile) 59 | return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ 60 | Regexp.last_match(1) 61 | end 62 | 63 | def bundler_requirement 64 | @bundler_requirement ||= 65 | env_var_version || 66 | cli_arg_version || 67 | bundler_requirement_for(lockfile_version) 68 | end 69 | 70 | def bundler_requirement_for(version) 71 | return "#{Gem::Requirement.default}.a" unless version 72 | 73 | bundler_gem_version = Gem::Version.new(version) 74 | 75 | bundler_gem_version.approximate_recommendation 76 | end 77 | 78 | def load_bundler! 79 | ENV["BUNDLE_GEMFILE"] ||= gemfile 80 | 81 | activate_bundler 82 | end 83 | 84 | def activate_bundler 85 | gem_error = activation_error_handling do 86 | gem "bundler", bundler_requirement 87 | end 88 | return if gem_error.nil? 89 | require_error = activation_error_handling do 90 | require "bundler/version" 91 | end 92 | return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION)) 93 | warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`" 94 | exit 42 95 | end 96 | 97 | def activation_error_handling 98 | yield 99 | nil 100 | rescue StandardError, LoadError => e 101 | e 102 | end 103 | end 104 | 105 | m.load_bundler! 106 | 107 | if m.invoked_as_script? 108 | load Gem.bin_path("bundler", "bundle") 109 | end 110 | -------------------------------------------------------------------------------- /example/bin/dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | exec "./bin/rails", "server", *ARGV 3 | -------------------------------------------------------------------------------- /example/bin/docker-entrypoint: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # Enable jemalloc for reduced memory usage and latency. 4 | if [ -z "${LD_PRELOAD+x}" ]; then 5 | LD_PRELOAD=$(find /usr/lib -name libjemalloc.so.2 -print -quit) 6 | export LD_PRELOAD 7 | fi 8 | 9 | # If running the rails server then create or migrate existing database 10 | if [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then 11 | ./bin/rails db:prepare 12 | fi 13 | 14 | exec "${@}" 15 | -------------------------------------------------------------------------------- /example/bin/importmap: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative "../config/application" 4 | require "importmap/commands" 5 | -------------------------------------------------------------------------------- /example/bin/jobs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative "../config/environment" 4 | require "solid_queue/cli" 5 | 6 | SolidQueue::Cli.start(ARGV) 7 | -------------------------------------------------------------------------------- /example/bin/kamal: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'kamal' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("kamal", "kamal") 28 | -------------------------------------------------------------------------------- /example/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../config/application", __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /example/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /example/bin/rubocop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "rubygems" 3 | require "bundler/setup" 4 | 5 | # explicit rubocop config increases performance slightly while avoiding config confusion. 6 | ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) 7 | 8 | load Gem.bin_path("rubocop", "rubocop") 9 | -------------------------------------------------------------------------------- /example/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "fileutils" 3 | 4 | APP_ROOT = File.expand_path("..", __dir__) 5 | 6 | def system!(*args) 7 | system(*args, exception: true) 8 | end 9 | 10 | FileUtils.chdir APP_ROOT do 11 | # This script is a way to set up or update your development environment automatically. 12 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 13 | # Add necessary setup steps to this file. 14 | 15 | puts "== Installing dependencies ==" 16 | system("bundle check") || system!("bundle install") 17 | 18 | # puts "\n== Copying sample files ==" 19 | # unless File.exist?("config/database.yml") 20 | # FileUtils.cp "config/database.yml.sample", "config/database.yml" 21 | # end 22 | 23 | puts "\n== Preparing database ==" 24 | system! "bin/rails db:prepare" 25 | 26 | puts "\n== Removing old logs and tempfiles ==" 27 | system! "bin/rails log:clear tmp:clear" 28 | 29 | unless ARGV.include?("--skip-server") 30 | puts "\n== Starting development server ==" 31 | STDOUT.flush # flush the output before exec(2) so that it displays 32 | exec "bin/dev" 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /example/bin/thrust: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "rubygems" 3 | require "bundler/setup" 4 | 5 | load Gem.bin_path("thruster", "thrust") 6 | -------------------------------------------------------------------------------- /example/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative "config/environment" 4 | 5 | run Rails.application 6 | Rails.application.load_server 7 | -------------------------------------------------------------------------------- /example/config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "rails/all" 4 | 5 | # Require the gems listed in Gemfile, including any gems 6 | # you've limited to :test, :development, or :production. 7 | Bundler.require(*Rails.groups) 8 | 9 | module Example 10 | class Application < Rails::Application 11 | # Initialize configuration defaults for originally generated Rails version. 12 | config.load_defaults 8.0 13 | 14 | # Please, add to the `ignore` list any other `lib` subdirectories that do 15 | # not contain `.rb` files, or that should not be reloaded or eager loaded. 16 | # Common ones are `templates`, `generators`, or `middleware`, for example. 17 | config.autoload_lib(ignore: %w[assets tasks]) 18 | 19 | # Configuration for the application, engines, and railties goes here. 20 | # 21 | # These settings can be overridden in specific environments using the files 22 | # in config/environments, which are processed later. 23 | # 24 | # config.time_zone = "Central Time (US & Canada)" 25 | # config.eager_load_paths << Rails.root.join("extras") 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /example/config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 2 | 3 | require "bundler/setup" # Set up gems listed in the Gemfile. 4 | require "bootsnap/setup" # Speed up boot time by caching expensive operations. 5 | -------------------------------------------------------------------------------- /example/config/cable.yml: -------------------------------------------------------------------------------- 1 | # Async adapter only works within the same process, so for manually triggering cable updates from a console, 2 | # and seeing results in the browser, you must do so from the web console (running inside the dev process), 3 | # not a terminal started via bin/rails console! Add "console" to any action or any ERB template view 4 | # to make the web console appear. 5 | development: 6 | adapter: async 7 | 8 | test: 9 | adapter: test 10 | 11 | production: 12 | adapter: solid_cable 13 | connects_to: 14 | database: 15 | writing: cable 16 | polling_interval: 0.1.seconds 17 | message_retention: 1.day 18 | -------------------------------------------------------------------------------- /example/config/cache.yml: -------------------------------------------------------------------------------- 1 | default: &default 2 | store_options: 3 | # Cap age of oldest cache entry to fulfill retention policies 4 | # max_age: <%= 60.days.to_i %> 5 | max_size: <%= 256.megabytes %> 6 | namespace: <%= Rails.env %> 7 | 8 | development: 9 | <<: *default 10 | 11 | test: 12 | <<: *default 13 | 14 | production: 15 | database: cache 16 | <<: *default 17 | -------------------------------------------------------------------------------- /example/config/credentials.yml.enc: -------------------------------------------------------------------------------- 1 | z7k+SI2I0uAC01EzmeIFcGWsYwiUILz4DpAIK7LBGMeP72I1N6hHcQFkvz+bcTG1vFjNxBO/tYxlgpEfbpJ3xyXEZ+3eVqfu45B0qOzq2wlILx7S2L5aFxsZ3pF+X+YdYSAS2TkNNZs5/ykKEVfaR0BAk1W8z7MSzGPAYEf06uFj4Eg3h/Y/qP8CEaHNFcGqiFQCyF4R+qxRS4bi9WF3qxB/iVdv6K2FHo/BkOrOeJjEnpDzEeypue2t7jF2MLZqYv0uqvlYwKCvIOoqUJtDsn3KECyBSBemc5dCYBR73kj4t6EAL4zMdEM321x62WchFYH3axzOOjKt2q6/nijFUDBfm7qDsMQJAVH3h4O4V6YxIpnF2haUJ6t0bE6dH6VR3NL0P3fpueMO1DRxO3BwJF3N3ObJ0mg22YzyW7wwkNkWjka+hpt1ZaKhfjBiCRkcjx5QfRW5vAsgpK6sAKINIpAM7Rr64QwkBk350JoDOTzFVSwt0/SHuSIV--PzYxPA7Yz5qKoR1N--J7A/Fjr1QmoZ/DUVhfTJJA== -------------------------------------------------------------------------------- /example/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite. Versions 3.8.0 and up are supported. 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem "sqlite3" 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: storage/development.sqlite3 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | database: storage/test.sqlite3 22 | 23 | 24 | # Store production database in the storage/ directory, which by default 25 | # is mounted as a persistent Docker volume in config/deploy.yml. 26 | production: 27 | primary: 28 | <<: *default 29 | database: storage/production.sqlite3 30 | cache: 31 | <<: *default 32 | database: storage/production_cache.sqlite3 33 | migrations_paths: db/cache_migrate 34 | queue: 35 | <<: *default 36 | database: storage/production_queue.sqlite3 37 | migrations_paths: db/queue_migrate 38 | cable: 39 | <<: *default 40 | database: storage/production_cable.sqlite3 41 | migrations_paths: db/cable_migrate 42 | -------------------------------------------------------------------------------- /example/config/deploy.yml: -------------------------------------------------------------------------------- 1 | # Name of your application. Used to uniquely configure containers. 2 | service: example 3 | 4 | # Name of the container image. 5 | image: your-user/example 6 | 7 | # Deploy to these servers. 8 | servers: 9 | web: 10 | - 192.168.0.1 11 | # job: 12 | # hosts: 13 | # - 192.168.0.1 14 | # cmd: bin/jobs 15 | 16 | # Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server. 17 | # Remove this section when using multiple web servers and ensure you terminate SSL at your load balancer. 18 | # 19 | # Note: If using Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption. 20 | proxy: 21 | ssl: true 22 | host: app.example.com 23 | 24 | # Credentials for your image host. 25 | registry: 26 | # Specify the registry server, if you're not using Docker Hub 27 | # server: registry.digitalocean.com / ghcr.io / ... 28 | username: your-user 29 | 30 | # Always use an access token rather than real password when possible. 31 | password: 32 | - KAMAL_REGISTRY_PASSWORD 33 | 34 | # Inject ENV variables into containers (secrets come from .kamal/secrets). 35 | env: 36 | secret: 37 | - RAILS_MASTER_KEY 38 | clear: 39 | # Run the Solid Queue Supervisor inside the web server's Puma process to do jobs. 40 | # When you start using multiple servers, you should split out job processing to a dedicated machine. 41 | SOLID_QUEUE_IN_PUMA: true 42 | 43 | # Set number of processes dedicated to Solid Queue (default: 1) 44 | # JOB_CONCURRENCY: 3 45 | 46 | # Set number of cores available to the application on each server (default: 1). 47 | # WEB_CONCURRENCY: 2 48 | 49 | # Match this to any external database server to configure Active Record correctly 50 | # Use example-db for a db accessory server on same machine via local kamal docker network. 51 | # DB_HOST: 192.168.0.2 52 | 53 | # Log everything from Rails 54 | # RAILS_LOG_LEVEL: debug 55 | 56 | # Aliases are triggered with "bin/kamal ". You can overwrite arguments on invocation: 57 | # "bin/kamal logs -r job" will tail logs from the first server in the job section. 58 | aliases: 59 | console: app exec --interactive --reuse "bin/rails console" 60 | shell: app exec --interactive --reuse "bash" 61 | logs: app logs -f 62 | dbc: app exec --interactive --reuse "bin/rails dbconsole" 63 | 64 | 65 | # Use a persistent storage volume for sqlite database files and local Active Storage files. 66 | # Recommended to change this to a mounted volume path that is backed up off server. 67 | volumes: 68 | - "example_storage:/rails/storage" 69 | 70 | 71 | # Bridge fingerprinted assets, like JS and CSS, between versions to avoid 72 | # hitting 404 on in-flight requests. Combines all files from new and old 73 | # version inside the asset_path. 74 | asset_path: /rails/public/assets 75 | 76 | # Configure the image builder. 77 | builder: 78 | arch: amd64 79 | 80 | # # Build image via remote server (useful for faster amd64 builds on arm64 computers) 81 | # remote: ssh://docker@docker-builder-server 82 | # 83 | # # Pass arguments and secrets to the Docker build process 84 | # args: 85 | # RUBY_VERSION: ruby-3.3.6 86 | # secrets: 87 | # - GITHUB_TOKEN 88 | # - RAILS_MASTER_KEY 89 | 90 | # Use a different ssh user than root 91 | # ssh: 92 | # user: app 93 | 94 | # Use accessory services (secrets come from .kamal/secrets). 95 | # accessories: 96 | # db: 97 | # image: mysql:8.0 98 | # host: 192.168.0.2 99 | # # Change to 3306 to expose port to the world instead of just local network. 100 | # port: "127.0.0.1:3306:3306" 101 | # env: 102 | # clear: 103 | # MYSQL_ROOT_HOST: '%' 104 | # secret: 105 | # - MYSQL_ROOT_PASSWORD 106 | # files: 107 | # - config/mysql/production.cnf:/etc/mysql/my.cnf 108 | # - db/production.sql:/docker-entrypoint-initdb.d/setup.sql 109 | # directories: 110 | # - data:/var/lib/mysql 111 | # redis: 112 | # image: redis:7.0 113 | # host: 192.168.0.2 114 | # port: 6379 115 | # directories: 116 | # - data:/data 117 | -------------------------------------------------------------------------------- /example/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /example/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Make code changes take effect immediately without server restart. 7 | config.enable_reloading = true 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports. 13 | config.consider_all_requests_local = true 14 | 15 | # Enable server timing. 16 | config.server_timing = true 17 | 18 | # Enable/disable Action Controller caching. By default Action Controller caching is disabled. 19 | # Run rails dev:cache to toggle Action Controller caching. 20 | if Rails.root.join("tmp/caching-dev.txt").exist? 21 | config.action_controller.perform_caching = true 22 | config.action_controller.enable_fragment_cache_logging = true 23 | config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" } 24 | else 25 | config.action_controller.perform_caching = false 26 | end 27 | 28 | # Change to :null_store to avoid any caching. 29 | config.cache_store = :memory_store 30 | 31 | # Store uploaded files on the local file system (see config/storage.yml for options). 32 | config.active_storage.service = :local 33 | 34 | # Don't care if the mailer can't send. 35 | config.action_mailer.raise_delivery_errors = false 36 | 37 | # Make template changes take effect immediately. 38 | config.action_mailer.perform_caching = false 39 | 40 | # Set localhost to be used by links generated in mailer templates. 41 | config.action_mailer.default_url_options = { host: "localhost", port: 3000 } 42 | 43 | # Print deprecation notices to the Rails logger. 44 | config.active_support.deprecation = :log 45 | 46 | # Raise an error on page load if there are pending migrations. 47 | config.active_record.migration_error = :page_load 48 | 49 | # Highlight code that triggered database queries in logs. 50 | config.active_record.verbose_query_logs = true 51 | 52 | # Append comments with runtime information tags to SQL queries in logs. 53 | config.active_record.query_log_tags_enabled = true 54 | 55 | # Highlight code that enqueued background job in logs. 56 | config.active_job.verbose_enqueue_logs = true 57 | 58 | # Raises error for missing translations. 59 | # config.i18n.raise_on_missing_translations = true 60 | 61 | # Annotate rendered view with file names. 62 | config.action_view.annotate_rendered_view_with_filenames = true 63 | 64 | # Uncomment if you wish to allow Action Cable access from any origin. 65 | # config.action_cable.disable_request_forgery_protection = true 66 | 67 | # Raise error when a before_action's only/except options reference missing actions. 68 | config.action_controller.raise_on_missing_callback_actions = true 69 | 70 | # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. 71 | # config.generators.apply_rubocop_autocorrect_after_generate! 72 | end 73 | -------------------------------------------------------------------------------- /example/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Code is not reloaded between requests. 7 | config.enable_reloading = false 8 | 9 | # Eager load code on boot for better performance and memory savings (ignored by Rake tasks). 10 | config.eager_load = true 11 | 12 | # Full error reports are disabled. 13 | config.consider_all_requests_local = false 14 | 15 | # Turn on fragment caching in view templates. 16 | config.action_controller.perform_caching = true 17 | 18 | # Cache assets for far-future expiry since they are all digest stamped. 19 | config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } 20 | 21 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 22 | # config.asset_host = "http://assets.example.com" 23 | 24 | # Store uploaded files on the local file system (see config/storage.yml for options). 25 | config.active_storage.service = :local 26 | 27 | # Assume all access to the app is happening through a SSL-terminating reverse proxy. 28 | config.assume_ssl = true 29 | 30 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 31 | config.force_ssl = true 32 | 33 | # Skip http-to-https redirect for the default health check endpoint. 34 | # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } 35 | 36 | # Log to STDOUT with the current request id as a default log tag. 37 | config.log_tags = [ :request_id ] 38 | config.logger = ActiveSupport::TaggedLogging.logger(STDOUT) 39 | 40 | # Change to "debug" to log everything (including potentially personally-identifiable information!) 41 | config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") 42 | 43 | # Prevent health checks from clogging up the logs. 44 | config.silence_healthcheck_path = "/up" 45 | 46 | # Don't log any deprecations. 47 | config.active_support.report_deprecations = false 48 | 49 | # Replace the default in-process memory cache store with a durable alternative. 50 | config.cache_store = :solid_cache_store 51 | 52 | # Replace the default in-process and non-durable queuing backend for Active Job. 53 | config.active_job.queue_adapter = :solid_queue 54 | config.solid_queue.connects_to = { database: { writing: :queue } } 55 | 56 | 57 | # Ignore bad email addresses and do not raise email delivery errors. 58 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 59 | # config.action_mailer.raise_delivery_errors = false 60 | 61 | # Set host to be used by links generated in mailer templates. 62 | config.action_mailer.default_url_options = { host: "example.com" } 63 | 64 | # Specify outgoing SMTP server. Remember to add smtp/* credentials via rails credentials:edit. 65 | # config.action_mailer.smtp_settings = { 66 | # user_name: Rails.application.credentials.dig(:smtp, :user_name), 67 | # password: Rails.application.credentials.dig(:smtp, :password), 68 | # address: "smtp.example.com", 69 | # port: 587, 70 | # authentication: :plain 71 | # } 72 | 73 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 74 | # the I18n.default_locale when a translation cannot be found). 75 | config.i18n.fallbacks = true 76 | 77 | # Do not dump schema after migrations. 78 | config.active_record.dump_schema_after_migration = false 79 | 80 | # Only use :id for inspections in production. 81 | config.active_record.attributes_for_inspect = [ :id ] 82 | 83 | # Enable DNS rebinding protection and other `Host` header attacks. 84 | # config.hosts = [ 85 | # "example.com", # Allow requests from example.com 86 | # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` 87 | # ] 88 | # 89 | # Skip DNS rebinding protection for the default health check endpoint. 90 | # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } 91 | end 92 | -------------------------------------------------------------------------------- /example/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | # The test environment is used exclusively to run your application's 2 | # test suite. You never need to work with it otherwise. Remember that 3 | # your test database is "scratch space" for the test suite and is wiped 4 | # and recreated between test runs. Don't rely on the data there! 5 | 6 | Rails.application.configure do 7 | # Settings specified here will take precedence over those in config/application.rb. 8 | 9 | # While tests run files are not watched, reloading is not necessary. 10 | config.enable_reloading = false 11 | 12 | # Eager loading loads your entire application. When running a single test locally, 13 | # this is usually not necessary, and can slow down your test suite. However, it's 14 | # recommended that you enable it in continuous integration systems to ensure eager 15 | # loading is working properly before deploying your code. 16 | config.eager_load = ENV["CI"].present? 17 | 18 | # Configure public file server for tests with cache-control for performance. 19 | config.public_file_server.headers = { "cache-control" => "public, max-age=3600" } 20 | 21 | # Show full error reports. 22 | config.consider_all_requests_local = true 23 | config.cache_store = :null_store 24 | 25 | # Render exception templates for rescuable exceptions and raise for other exceptions. 26 | config.action_dispatch.show_exceptions = :rescuable 27 | 28 | # Disable request forgery protection in test environment. 29 | config.action_controller.allow_forgery_protection = false 30 | 31 | # Store uploaded files on the local file system in a temporary directory. 32 | config.active_storage.service = :test 33 | 34 | # Tell Action Mailer not to deliver emails to the real world. 35 | # The :test delivery method accumulates sent emails in the 36 | # ActionMailer::Base.deliveries array. 37 | config.action_mailer.delivery_method = :test 38 | 39 | # Set host to be used by links generated in mailer templates. 40 | config.action_mailer.default_url_options = { host: "example.com" } 41 | 42 | # Print deprecation notices to the stderr. 43 | config.active_support.deprecation = :stderr 44 | 45 | # Raises error for missing translations. 46 | # config.i18n.raise_on_missing_translations = true 47 | 48 | # Annotate rendered view with file names. 49 | # config.action_view.annotate_rendered_view_with_filenames = true 50 | 51 | # Raise error when a before_action's only/except options reference missing actions. 52 | config.action_controller.raise_on_missing_callback_actions = true 53 | end 54 | -------------------------------------------------------------------------------- /example/config/importmap.rb: -------------------------------------------------------------------------------- 1 | # Pin npm packages by running ./bin/importmap 2 | 3 | pin "application" 4 | pin "@hotwired/turbo-rails", to: "turbo.min.js" 5 | pin "@hotwired/stimulus", to: "stimulus.min.js" 6 | pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" 7 | pin_all_from "app/javascript/controllers", under: "controllers" 8 | -------------------------------------------------------------------------------- /example/config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = "1.0" 5 | 6 | # Add additional assets to the asset load path. 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | -------------------------------------------------------------------------------- /example/config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy. 4 | # See the Securing Rails Applications Guide for more information: 5 | # https://guides.rubyonrails.org/security.html#content-security-policy-header 6 | 7 | # Rails.application.configure do 8 | # config.content_security_policy do |policy| 9 | # policy.default_src :self, :https 10 | # policy.font_src :self, :https, :data 11 | # policy.img_src :self, :https, :data 12 | # policy.object_src :none 13 | # policy.script_src :self, :https 14 | # policy.style_src :self, :https 15 | # # Specify URI for violation reports 16 | # # policy.report_uri "/csp-violation-report-endpoint" 17 | # end 18 | # 19 | # # Generate session nonces for permitted importmap, inline scripts, and inline styles. 20 | # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } 21 | # config.content_security_policy_nonce_directives = %w(script-src style-src) 22 | # 23 | # # Report violations without enforcing the policy. 24 | # # config.content_security_policy_report_only = true 25 | # end 26 | -------------------------------------------------------------------------------- /example/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. 4 | # Use this to limit dissemination of sensitive information. 5 | # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. 6 | Rails.application.config.filter_parameters += [ 7 | :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc 8 | ] 9 | -------------------------------------------------------------------------------- /example/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, "\\1en" 8 | # inflect.singular /^(ox)en/i, "\\1" 9 | # inflect.irregular "person", "people" 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym "RESTful" 16 | # end 17 | -------------------------------------------------------------------------------- /example/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization and 2 | # are automatically loaded by Rails. If you want to use locales other than 3 | # English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t "hello" 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t("hello") %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # To learn more about the API, please read the Rails Internationalization guide 20 | # at https://guides.rubyonrails.org/i18n.html. 21 | # 22 | # Be aware that YAML interprets the following case-insensitive strings as 23 | # booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings 24 | # must be quoted to be interpreted as strings. For example: 25 | # 26 | # en: 27 | # "yes": yup 28 | # enabled: "ON" 29 | 30 | en: 31 | hello: "Hello world" 32 | -------------------------------------------------------------------------------- /example/config/puma.rb: -------------------------------------------------------------------------------- 1 | # This configuration file will be evaluated by Puma. The top-level methods that 2 | # are invoked here are part of Puma's configuration DSL. For more information 3 | # about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. 4 | # 5 | # Puma starts a configurable number of processes (workers) and each process 6 | # serves each request in a thread from an internal thread pool. 7 | # 8 | # You can control the number of workers using ENV["WEB_CONCURRENCY"]. You 9 | # should only set this value when you want to run 2 or more workers. The 10 | # default is already 1. 11 | # 12 | # The ideal number of threads per worker depends both on how much time the 13 | # application spends waiting for IO operations and on how much you wish to 14 | # prioritize throughput over latency. 15 | # 16 | # As a rule of thumb, increasing the number of threads will increase how much 17 | # traffic a given process can handle (throughput), but due to CRuby's 18 | # Global VM Lock (GVL) it has diminishing returns and will degrade the 19 | # response time (latency) of the application. 20 | # 21 | # The default is set to 3 threads as it's deemed a decent compromise between 22 | # throughput and latency for the average Rails application. 23 | # 24 | # Any libraries that use a connection pool or another resource pool should 25 | # be configured to provide at least as many connections as the number of 26 | # threads. This includes Active Record's `pool` parameter in `database.yml`. 27 | threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) 28 | threads threads_count, threads_count 29 | 30 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 31 | port ENV.fetch("PORT", 3000) 32 | 33 | # Allow puma to be restarted by `bin/rails restart` command. 34 | plugin :tmp_restart 35 | 36 | # Run the Solid Queue supervisor inside of Puma for single-server deployments 37 | plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"] 38 | 39 | # Specify the PID file. Defaults to tmp/pids/server.pid in development. 40 | # In other environments, only set the PID file if requested. 41 | pidfile ENV["PIDFILE"] if ENV["PIDFILE"] 42 | -------------------------------------------------------------------------------- /example/config/queue.yml: -------------------------------------------------------------------------------- 1 | default: &default 2 | dispatchers: 3 | - polling_interval: 1 4 | batch_size: 500 5 | workers: 6 | - queues: "*" 7 | threads: 3 8 | processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %> 9 | polling_interval: 0.1 10 | 11 | development: 12 | <<: *default 13 | 14 | test: 15 | <<: *default 16 | 17 | production: 18 | <<: *default 19 | -------------------------------------------------------------------------------- /example/config/recurring.yml: -------------------------------------------------------------------------------- 1 | # production: 2 | # periodic_cleanup: 3 | # class: CleanSoftDeletedRecordsJob 4 | # queue: background 5 | # args: [ 1000, { batch_size: 500 } ] 6 | # schedule: every hour 7 | # periodic_command: 8 | # command: "SoftDeletedRecord.due.delete_all" 9 | # priority: 2 10 | # schedule: at 5am every day 11 | -------------------------------------------------------------------------------- /example/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html 3 | 4 | # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. 5 | # Can be used by load balancers and uptime monitors to verify that the app is live. 6 | get "up" => "rails/health#show", as: :rails_health_check 7 | 8 | # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) 9 | # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest 10 | # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker 11 | 12 | resources :items 13 | 14 | # Defines the root path route ("/") 15 | root "items#index" 16 | end 17 | -------------------------------------------------------------------------------- /example/config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("tmp/storage") %> 4 | 5 | local: 6 | service: Disk 7 | root: <%= Rails.root.join("storage") %> 8 | 9 | # Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) 10 | # amazon: 11 | # service: S3 12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> 13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> 14 | # region: us-east-1 15 | # bucket: your_own_bucket-<%= Rails.env %> 16 | 17 | # Remember not to checkin your GCS keyfile to a repository 18 | # google: 19 | # service: GCS 20 | # project: your_project 21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> 22 | # bucket: your_own_bucket-<%= Rails.env %> 23 | 24 | # Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) 25 | # microsoft: 26 | # service: AzureStorage 27 | # storage_account_name: your_account_name 28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> 29 | # container: your_container_name-<%= Rails.env %> 30 | 31 | # mirror: 32 | # service: Mirror 33 | # primary: local 34 | # mirrors: [ amazon, google, microsoft ] 35 | -------------------------------------------------------------------------------- /example/db/cable_schema.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema[7.1].define(version: 1) do 2 | create_table "solid_cable_messages", force: :cascade do |t| 3 | t.binary "channel", limit: 1024, null: false 4 | t.binary "payload", limit: 536870912, null: false 5 | t.datetime "created_at", null: false 6 | t.integer "channel_hash", limit: 8, null: false 7 | t.index [ "channel" ], name: "index_solid_cable_messages_on_channel" 8 | t.index [ "channel_hash" ], name: "index_solid_cable_messages_on_channel_hash" 9 | t.index [ "created_at" ], name: "index_solid_cable_messages_on_created_at" 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /example/db/cache_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Schema[7.2].define(version: 1) do 4 | create_table "solid_cache_entries", force: :cascade do |t| 5 | t.binary "key", limit: 1024, null: false 6 | t.binary "value", limit: 536870912, null: false 7 | t.datetime "created_at", null: false 8 | t.integer "key_hash", limit: 8, null: false 9 | t.integer "byte_size", limit: 4, null: false 10 | t.index [ "byte_size" ], name: "index_solid_cache_entries_on_byte_size" 11 | t.index [ "key_hash", "byte_size" ], name: "index_solid_cache_entries_on_key_hash_and_byte_size" 12 | t.index [ "key_hash" ], name: "index_solid_cache_entries_on_key_hash", unique: true 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /example/db/migrate/20241115075615_create_items.rb: -------------------------------------------------------------------------------- 1 | class CreateItems < ActiveRecord::Migration[8.0] 2 | def change 3 | create_table :items do |t| 4 | t.string :name 5 | 6 | t.timestamps 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /example/db/queue_schema.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema[7.1].define(version: 1) do 2 | create_table "solid_queue_blocked_executions", force: :cascade do |t| 3 | t.bigint "job_id", null: false 4 | t.string "queue_name", null: false 5 | t.integer "priority", default: 0, null: false 6 | t.string "concurrency_key", null: false 7 | t.datetime "expires_at", null: false 8 | t.datetime "created_at", null: false 9 | t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release" 10 | t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance" 11 | t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true 12 | end 13 | 14 | create_table "solid_queue_claimed_executions", force: :cascade do |t| 15 | t.bigint "job_id", null: false 16 | t.bigint "process_id" 17 | t.datetime "created_at", null: false 18 | t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true 19 | t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" 20 | end 21 | 22 | create_table "solid_queue_failed_executions", force: :cascade do |t| 23 | t.bigint "job_id", null: false 24 | t.text "error" 25 | t.datetime "created_at", null: false 26 | t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true 27 | end 28 | 29 | create_table "solid_queue_jobs", force: :cascade do |t| 30 | t.string "queue_name", null: false 31 | t.string "class_name", null: false 32 | t.text "arguments" 33 | t.integer "priority", default: 0, null: false 34 | t.string "active_job_id" 35 | t.datetime "scheduled_at" 36 | t.datetime "finished_at" 37 | t.string "concurrency_key" 38 | t.datetime "created_at", null: false 39 | t.datetime "updated_at", null: false 40 | t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id" 41 | t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name" 42 | t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at" 43 | t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering" 44 | t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting" 45 | end 46 | 47 | create_table "solid_queue_pauses", force: :cascade do |t| 48 | t.string "queue_name", null: false 49 | t.datetime "created_at", null: false 50 | t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true 51 | end 52 | 53 | create_table "solid_queue_processes", force: :cascade do |t| 54 | t.string "kind", null: false 55 | t.datetime "last_heartbeat_at", null: false 56 | t.bigint "supervisor_id" 57 | t.integer "pid", null: false 58 | t.string "hostname" 59 | t.text "metadata" 60 | t.datetime "created_at", null: false 61 | t.string "name", null: false 62 | t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at" 63 | t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true 64 | t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id" 65 | end 66 | 67 | create_table "solid_queue_ready_executions", force: :cascade do |t| 68 | t.bigint "job_id", null: false 69 | t.string "queue_name", null: false 70 | t.integer "priority", default: 0, null: false 71 | t.datetime "created_at", null: false 72 | t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true 73 | t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all" 74 | t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue" 75 | end 76 | 77 | create_table "solid_queue_recurring_executions", force: :cascade do |t| 78 | t.bigint "job_id", null: false 79 | t.string "task_key", null: false 80 | t.datetime "run_at", null: false 81 | t.datetime "created_at", null: false 82 | t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true 83 | t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true 84 | end 85 | 86 | create_table "solid_queue_recurring_tasks", force: :cascade do |t| 87 | t.string "key", null: false 88 | t.string "schedule", null: false 89 | t.string "command", limit: 2048 90 | t.string "class_name" 91 | t.text "arguments" 92 | t.string "queue_name" 93 | t.integer "priority", default: 0 94 | t.boolean "static", default: true, null: false 95 | t.text "description" 96 | t.datetime "created_at", null: false 97 | t.datetime "updated_at", null: false 98 | t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true 99 | t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static" 100 | end 101 | 102 | create_table "solid_queue_scheduled_executions", force: :cascade do |t| 103 | t.bigint "job_id", null: false 104 | t.string "queue_name", null: false 105 | t.integer "priority", default: 0, null: false 106 | t.datetime "scheduled_at", null: false 107 | t.datetime "created_at", null: false 108 | t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true 109 | t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all" 110 | end 111 | 112 | create_table "solid_queue_semaphores", force: :cascade do |t| 113 | t.string "key", null: false 114 | t.integer "value", default: 1, null: false 115 | t.datetime "expires_at", null: false 116 | t.datetime "created_at", null: false 117 | t.datetime "updated_at", null: false 118 | t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at" 119 | t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value" 120 | t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true 121 | end 122 | 123 | add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade 124 | add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade 125 | add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade 126 | add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade 127 | add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade 128 | add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade 129 | end 130 | -------------------------------------------------------------------------------- /example/db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # This file is the source Rails uses to define your schema when running `bin/rails 6 | # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to 7 | # be faster and is potentially less error prone than running all of your 8 | # migrations from scratch. Old migrations may fail to apply correctly if those 9 | # migrations use external dependencies or application code. 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema[8.0].define(version: 2024_11_15_075615) do 14 | create_table "items", force: :cascade do |t| 15 | t.string "name" 16 | t.datetime "created_at", null: false 17 | t.datetime "updated_at", null: false 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /example/db/seeds.rb: -------------------------------------------------------------------------------- 1 | # This file should ensure the existence of records required to run the application in every environment (production, 2 | # development, test). The code here should be idempotent so that it can be executed at any point in every environment. 3 | # The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). 4 | # 5 | # Example: 6 | # 7 | # ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| 8 | # MovieGenre.find_or_create_by!(name: genre_name) 9 | # end 10 | -------------------------------------------------------------------------------- /example/lib/tasks/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketry/async-cable/0d0b82d0935699598ebd28fb8ac7fb045c71c2ec/example/lib/tasks/.keep -------------------------------------------------------------------------------- /example/log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketry/async-cable/0d0b82d0935699598ebd28fb8ac7fb045c71c2ec/example/log/.keep -------------------------------------------------------------------------------- /example/public/400.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | The server cannot process the request due to a client error (400 Bad Request) 8 | 9 | 10 | 11 | 12 | 13 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
104 |
105 | 106 |
107 |
108 |

The server cannot process the request due to a client error. Please check the request and try again. If you’re the application owner check the logs for more information.

109 |
110 |
111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /example/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | The page you were looking for doesn’t exist (404 Not found) 8 | 9 | 10 | 11 | 12 | 13 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
104 |
105 | 106 |
107 |
108 |

The page you were looking for doesn’t exist. You may have mistyped the address or the page may have moved. If you’re the application owner check the logs for more information.

109 |
110 |
111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /example/public/406-unsupported-browser.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Your browser is not supported (406 Not Acceptable) 8 | 9 | 10 | 11 | 12 | 13 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
104 |
105 | 106 |
107 |
108 |

Your browser is not supported.
Please upgrade your browser to continue.

109 |
110 |
111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /example/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | The change you wanted was rejected (422 Unprocessable Entity) 8 | 9 | 10 | 11 | 12 | 13 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
104 |
105 | 106 |
107 |
108 |

The change you wanted was rejected. Maybe you tried to change something you didn’t have access to. If you’re the application owner check the logs for more information.

109 |
110 |
111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /example/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | We’re sorry, but something went wrong (500 Internal Server Error) 8 | 9 | 10 | 11 | 12 | 13 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
104 |
105 | 106 |
107 |
108 |

We’re sorry, but something went wrong.
If you’re the application owner check the logs for more information.

109 |
110 |
111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /example/public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketry/async-cable/0d0b82d0935699598ebd28fb8ac7fb045c71c2ec/example/public/icon.png -------------------------------------------------------------------------------- /example/public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /example/public/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | -------------------------------------------------------------------------------- /example/script/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketry/async-cable/0d0b82d0935699598ebd28fb8ac7fb045c71c2ec/example/script/.keep -------------------------------------------------------------------------------- /example/storage/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketry/async-cable/0d0b82d0935699598ebd28fb8ac7fb045c71c2ec/example/storage/.keep -------------------------------------------------------------------------------- /example/test/application_system_test_case.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ApplicationSystemTestCase < ActionDispatch::SystemTestCase 4 | driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ] 5 | end 6 | -------------------------------------------------------------------------------- /example/test/controllers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketry/async-cable/0d0b82d0935699598ebd28fb8ac7fb045c71c2ec/example/test/controllers/.keep -------------------------------------------------------------------------------- /example/test/controllers/items_controller_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ItemsControllerTest < ActionDispatch::IntegrationTest 4 | setup do 5 | @item = items(:one) 6 | end 7 | 8 | test "should get index" do 9 | get items_url 10 | assert_response :success 11 | end 12 | 13 | test "should get new" do 14 | get new_item_url 15 | assert_response :success 16 | end 17 | 18 | test "should create item" do 19 | assert_difference("Item.count") do 20 | post items_url, params: { item: {} } 21 | end 22 | 23 | assert_redirected_to item_url(Item.last) 24 | end 25 | 26 | test "should show item" do 27 | get item_url(@item) 28 | assert_response :success 29 | end 30 | 31 | test "should get edit" do 32 | get edit_item_url(@item) 33 | assert_response :success 34 | end 35 | 36 | test "should update item" do 37 | patch item_url(@item), params: { item: {} } 38 | assert_redirected_to item_url(@item) 39 | end 40 | 41 | test "should destroy item" do 42 | assert_difference("Item.count", -1) do 43 | delete item_url(@item) 44 | end 45 | 46 | assert_redirected_to items_url 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /example/test/fixtures/files/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketry/async-cable/0d0b82d0935699598ebd28fb8ac7fb045c71c2ec/example/test/fixtures/files/.keep -------------------------------------------------------------------------------- /example/test/fixtures/items.yml: -------------------------------------------------------------------------------- 1 | # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html 2 | 3 | one: 4 | name: MyString 5 | 6 | two: 7 | name: MyString 8 | -------------------------------------------------------------------------------- /example/test/helpers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketry/async-cable/0d0b82d0935699598ebd28fb8ac7fb045c71c2ec/example/test/helpers/.keep -------------------------------------------------------------------------------- /example/test/integration/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketry/async-cable/0d0b82d0935699598ebd28fb8ac7fb045c71c2ec/example/test/integration/.keep -------------------------------------------------------------------------------- /example/test/mailers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketry/async-cable/0d0b82d0935699598ebd28fb8ac7fb045c71c2ec/example/test/mailers/.keep -------------------------------------------------------------------------------- /example/test/models/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketry/async-cable/0d0b82d0935699598ebd28fb8ac7fb045c71c2ec/example/test/models/.keep -------------------------------------------------------------------------------- /example/test/models/item_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ItemTest < ActiveSupport::TestCase 4 | # test "the truth" do 5 | # assert true 6 | # end 7 | end 8 | -------------------------------------------------------------------------------- /example/test/system/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketry/async-cable/0d0b82d0935699598ebd28fb8ac7fb045c71c2ec/example/test/system/.keep -------------------------------------------------------------------------------- /example/test/system/items_test.rb: -------------------------------------------------------------------------------- 1 | require "application_system_test_case" 2 | 3 | class ItemsTest < ApplicationSystemTestCase 4 | setup do 5 | @item = items(:one) 6 | end 7 | 8 | test "visiting the index" do 9 | visit items_url 10 | assert_selector "h1", text: "Items" 11 | end 12 | 13 | test "should create item" do 14 | visit items_url 15 | click_on "New item" 16 | 17 | click_on "Create Item" 18 | 19 | assert_text "Item was successfully created" 20 | click_on "Back" 21 | end 22 | 23 | test "should update Item" do 24 | visit item_url(@item) 25 | click_on "Edit this item", match: :first 26 | 27 | click_on "Update Item" 28 | 29 | assert_text "Item was successfully updated" 30 | click_on "Back" 31 | end 32 | 33 | test "should destroy Item" do 34 | visit item_url(@item) 35 | click_on "Destroy this item", match: :first 36 | 37 | assert_text "Item was successfully destroyed" 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /example/test/test_helper.rb: -------------------------------------------------------------------------------- 1 | ENV["RAILS_ENV"] ||= "test" 2 | require_relative "../config/environment" 3 | require "rails/test_help" 4 | 5 | module ActiveSupport 6 | class TestCase 7 | # Run tests in parallel with specified workers 8 | parallelize(workers: :number_of_processors) 9 | 10 | # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. 11 | fixtures :all 12 | 13 | # Add more helper methods to be used by all tests here... 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /example/tmp/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketry/async-cable/0d0b82d0935699598ebd28fb8ac7fb045c71c2ec/example/tmp/.keep -------------------------------------------------------------------------------- /example/tmp/pids/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketry/async-cable/0d0b82d0935699598ebd28fb8ac7fb045c71c2ec/example/tmp/pids/.keep -------------------------------------------------------------------------------- /example/tmp/storage/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketry/async-cable/0d0b82d0935699598ebd28fb8ac7fb045c71c2ec/example/tmp/storage/.keep -------------------------------------------------------------------------------- /example/vendor/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketry/async-cable/0d0b82d0935699598ebd28fb8ac7fb045c71c2ec/example/vendor/.keep -------------------------------------------------------------------------------- /example/vendor/javascript/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketry/async-cable/0d0b82d0935699598ebd28fb8ac7fb045c71c2ec/example/vendor/javascript/.keep -------------------------------------------------------------------------------- /fixtures/test_channel.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "action_cable" 3 | 4 | class TestChannel < ActionCable::Channel::Base 5 | def subscribed 6 | stream_from "testing-#{test_id}" 7 | end 8 | 9 | def echo(data) 10 | transmit(data) 11 | end 12 | 13 | def broadcast(data) 14 | self.server.broadcast("testing-#{test_id}", data) 15 | end 16 | 17 | private def server 18 | @connection.send(:server) 19 | end 20 | 21 | private def test_id 22 | params[:id] || "default" 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # Released under the MIT License. 3 | # Copyright, 2023-2024, by Samuel Williams. 4 | 5 | source "https://rubygems.org" 6 | 7 | gemspec 8 | 9 | group :maintenance, optional: true do 10 | gem "bake-gem" 11 | gem "bake-modernize" 12 | gem "bake-releases" 13 | 14 | gem "utopia-project" 15 | end 16 | 17 | group :test do 18 | gem "sus" 19 | gem "covered" 20 | gem "decode" 21 | gem "rubocop" 22 | 23 | gem "rubocop-rails-omakase" 24 | 25 | gem "sus-fixtures-async-http" 26 | gem "sus-fixtures-console" 27 | 28 | gem "async-websocket" 29 | 30 | gem "bake-test" 31 | gem "bake-test-external" 32 | 33 | gem "redis" 34 | end 35 | -------------------------------------------------------------------------------- /guides/getting-started/readme.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | This guide shows you how to add `async-cable` to your project to enable real-time communication between clients and servers using Falcon and Action Cable. 4 | 5 | ## Installation 6 | 7 | Add the gem to your project: 8 | 9 | ~~~ bash 10 | $ bundle add async-cable 11 | ~~~ 12 | 13 | ## Usage 14 | 15 | To use `async-cable`, you need to add the following to your `config/application.rb`: 16 | 17 | ~~~ ruby 18 | require 'async/cable' 19 | ~~~ 20 | 21 | This will automatically add the {ruby Async::Cable::Middleware} to your middleware stack which will handle incoming WebSocket connections and integrates with Action Cable. 22 | -------------------------------------------------------------------------------- /lib/async/cable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | require_relative "cable/version" 7 | 8 | begin 9 | require "rails/railtie" 10 | rescue LoadError 11 | # Ignore. 12 | end 13 | 14 | if defined?(Rails::Railtie) 15 | require_relative "cable/railtie" 16 | end 17 | -------------------------------------------------------------------------------- /lib/async/cable/middleware.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # Released under the MIT License. 3 | # Copyright, 2024, by Samuel Williams. 4 | 5 | require "async/websocket/adapters/rack" 6 | require "action_cable" 7 | 8 | require_relative "socket" 9 | 10 | module Async 11 | module Cable 12 | class Middleware 13 | def initialize(app, server: ActionCable.server) 14 | @app = app 15 | @server = server 16 | @coder = ActiveSupport::JSON 17 | @protocols = ::ActionCable::INTERNAL[:protocols] 18 | end 19 | 20 | attr :server 21 | 22 | def call(env) 23 | if Async::WebSocket::Adapters::Rack.websocket?(env) and allow_request_origin?(env) 24 | Async::WebSocket::Adapters::Rack.open(env, protocols: @protocols) do |websocket| 25 | handle_incoming_websocket(env, websocket) 26 | end 27 | else 28 | @app.call(env) 29 | end 30 | end 31 | 32 | private 33 | 34 | def handle_incoming_websocket(env, websocket) 35 | socket = Socket.new(env, websocket, @server, coder: @coder) 36 | connection = @server.config.connection_class.call.new(@server, socket) 37 | 38 | connection.handle_open 39 | @server.add_connection(connection) 40 | @server.setup_heartbeat_timer 41 | 42 | socket_task = socket.run 43 | 44 | while message = websocket.read 45 | Console.debug(self, "Received cable data:", message.buffer) 46 | connection.handle_incoming(@coder.decode(message.buffer)) 47 | end 48 | rescue Protocol::WebSocket::ClosedError, EOFError 49 | # This is a normal disconnection. 50 | rescue => error 51 | Console.warn(self, "Abnormal client failure!", error) 52 | ensure 53 | if connection 54 | @server.remove_connection(connection) 55 | connection.handle_close 56 | end 57 | 58 | socket_task&.stop 59 | end 60 | 61 | # TODO: Shouldn't this be moved to ActionCable::Server::Base? 62 | def allow_request_origin?(env) 63 | if @server.config.disable_request_forgery_protection 64 | return true 65 | end 66 | 67 | proto = ::Rack::Request.new(env).ssl? ? "https" : "http" 68 | 69 | if @server.config.allow_same_origin_as_host && env["HTTP_ORIGIN"] == "#{proto}://#{env["HTTP_HOST"]}" 70 | return true 71 | elsif Array(@server.config.allowed_request_origins).any?{|allowed_origin| allowed_origin === env["HTTP_ORIGIN"]} 72 | return true 73 | end 74 | 75 | Console.warn(self, "Request origin not allowed!", origin: env["HTTP_ORIGIN"]) 76 | return false 77 | end 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/async/cable/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | require_relative "middleware" 7 | 8 | module Async 9 | module Cable 10 | class Railtie < Rails::Railtie 11 | initializer "async.cable.configure_rails_initialization" do |app| 12 | app.middleware.use Async::Cable::Middleware 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/async/cable/socket.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # Released under the MIT License. 3 | # Copyright, 2024, by Samuel Williams. 4 | 5 | module Async::Cable 6 | class Socket 7 | def initialize(env, websocket, server, coder: ActiveSupport::JSON) 8 | @env = env 9 | @websocket = websocket 10 | @server = server 11 | @coder = coder 12 | 13 | @output = ::Thread::Queue.new 14 | end 15 | 16 | attr :env 17 | 18 | def logger 19 | @server.logger 20 | end 21 | 22 | def request 23 | # Copied from ActionCable::Server::Socket#request 24 | @request ||= begin 25 | if defined?(Rails.application) && Rails.application 26 | environment = Rails.application.env_config.merge(@env) 27 | end 28 | 29 | ActionDispatch::Request.new(environment || @env) 30 | end 31 | end 32 | 33 | def run(parent: Async::Task.current) 34 | parent.async do 35 | while buffer = @output.pop 36 | # Console.debug(self, "Sending cable data:", buffer, flush: @output.empty?) 37 | @websocket.send_text(buffer) 38 | @websocket.flush if @output.empty? 39 | end 40 | rescue => error 41 | Console.error(self, "Error while sending cable data:", error) 42 | ensure 43 | @websocket.close_write(error) 44 | end 45 | end 46 | 47 | def transmit(data) 48 | # Console.info(self, "Transmitting data:", data, task: Async::Task.current?) 49 | @output.push(@coder.encode(data)) 50 | end 51 | 52 | def close 53 | # Console.info(self, "Closing socket.", task: Async::Task.current?) 54 | @output.close 55 | end 56 | 57 | # This can be called from the work pool, off the event loop. 58 | def perform_work(receiver, ...) 59 | # Console.info(self, "Performing work:", receiver) 60 | receiver.send(...) 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/async/cable/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # Released under the MIT License. 3 | # Copyright, 2023-2024, by Samuel Williams. 4 | 5 | module Async 6 | module Cable 7 | VERSION = "0.1.0" 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright, 2023-2024, by Samuel Williams. 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Async::Cable 2 | 3 | This is a proof-of-concept adapter for Rails 7.2+. It depends on `actioncable-next` which completely replaces the internal implementation of Action Cable with a pluggable one. 4 | 5 | [![Development Status](https://github.com/socketry/async-cable/workflows/Test/badge.svg)](https://github.com/socketry/async-cable/actions?workflow=Test) 6 | 7 | ## Usage 8 | 9 | Please see the [project documentation](https://socketry.github.io/async-cable/) for more details. 10 | 11 | - [Getting Started](https://socketry.github.io/async-cable/guides/getting-started/index) - This guide shows you how to add `async-cable` to your project to enable real-time communication between clients and servers using Falcon and Action Cable. 12 | 13 | ## Releases 14 | 15 | Please see the [project releases](https://socketry.github.io/async-cable/releases/index) for all releases. 16 | 17 | ### v0.1.0 18 | 19 | - Initial implementation. 20 | 21 | ## Contributing 22 | 23 | We welcome contributions to this project. 24 | 25 | 1. Fork it. 26 | 2. Create your feature branch (`git checkout -b my-new-feature`). 27 | 3. Commit your changes (`git commit -am 'Add some feature'`). 28 | 4. Push to the branch (`git push origin my-new-feature`). 29 | 5. Create new Pull Request. 30 | 31 | ### Developer Certificate of Origin 32 | 33 | In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed. 34 | 35 | ### Community Guidelines 36 | 37 | This project is best served by a collaborative and respectful environment. Treat each other professionally, respect differing viewpoints, and engage constructively. Harassment, discrimination, or harmful behavior is not tolerated. Communicate clearly, listen actively, and support one another. If any issues arise, please inform the project maintainers. 38 | -------------------------------------------------------------------------------- /release.cert: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIE2DCCA0CgAwIBAgIBATANBgkqhkiG9w0BAQsFADBhMRgwFgYDVQQDDA9zYW11 3 | ZWwud2lsbGlhbXMxHTAbBgoJkiaJk/IsZAEZFg1vcmlvbnRyYW5zZmVyMRIwEAYK 4 | CZImiZPyLGQBGRYCY28xEjAQBgoJkiaJk/IsZAEZFgJuejAeFw0yMjA4MDYwNDUz 5 | MjRaFw0zMjA4MDMwNDUzMjRaMGExGDAWBgNVBAMMD3NhbXVlbC53aWxsaWFtczEd 6 | MBsGCgmSJomT8ixkARkWDW9yaW9udHJhbnNmZXIxEjAQBgoJkiaJk/IsZAEZFgJj 7 | bzESMBAGCgmSJomT8ixkARkWAm56MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIB 8 | igKCAYEAomvSopQXQ24+9DBB6I6jxRI2auu3VVb4nOjmmHq7XWM4u3HL+pni63X2 9 | 9qZdoq9xt7H+RPbwL28LDpDNflYQXoOhoVhQ37Pjn9YDjl8/4/9xa9+NUpl9XDIW 10 | sGkaOY0eqsQm1pEWkHJr3zn/fxoKPZPfaJOglovdxf7dgsHz67Xgd/ka+Wo1YqoE 11 | e5AUKRwUuvaUaumAKgPH+4E4oiLXI4T1Ff5Q7xxv6yXvHuYtlMHhYfgNn8iiW8WN 12 | XibYXPNP7NtieSQqwR/xM6IRSoyXKuS+ZNGDPUUGk8RoiV/xvVN4LrVm9upSc0ss 13 | RZ6qwOQmXCo/lLcDUxJAgG95cPw//sI00tZan75VgsGzSWAOdjQpFM0l4dxvKwHn 14 | tUeT3ZsAgt0JnGqNm2Bkz81kG4A2hSyFZTFA8vZGhp+hz+8Q573tAR89y9YJBdYM 15 | zp0FM4zwMNEUwgfRzv1tEVVUEXmoFCyhzonUUw4nE4CFu/sE3ffhjKcXcY//qiSW 16 | xm4erY3XAgMBAAGjgZowgZcwCQYDVR0TBAIwADALBgNVHQ8EBAMCBLAwHQYDVR0O 17 | BBYEFO9t7XWuFf2SKLmuijgqR4sGDlRsMC4GA1UdEQQnMCWBI3NhbXVlbC53aWxs 18 | aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MC4GA1UdEgQnMCWBI3NhbXVlbC53aWxs 19 | aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MA0GCSqGSIb3DQEBCwUAA4IBgQB5sxkE 20 | cBsSYwK6fYpM+hA5B5yZY2+L0Z+27jF1pWGgbhPH8/FjjBLVn+VFok3CDpRqwXCl 21 | xCO40JEkKdznNy2avOMra6PFiQyOE74kCtv7P+Fdc+FhgqI5lMon6tt9rNeXmnW/ 22 | c1NaMRdxy999hmRGzUSFjozcCwxpy/LwabxtdXwXgSay4mQ32EDjqR1TixS1+smp 23 | 8C/NCWgpIfzpHGJsjvmH2wAfKtTTqB9CVKLCWEnCHyCaRVuKkrKjqhYCdmMBqCws 24 | JkxfQWC+jBVeG9ZtPhQgZpfhvh+6hMhraUYRQ6XGyvBqEUe+yo6DKIT3MtGE2+CP 25 | eX9i9ZWBydWb8/rvmwmX2kkcBbX0hZS1rcR593hGc61JR6lvkGYQ2MYskBveyaxt 26 | Q2K9NVun/S785AP05vKkXZEFYxqG6EW012U4oLcFl5MySFajYXRYbuUpH6AY+HP8 27 | voD0MPg1DssDLKwXyt1eKD/+Fq0bFWhwVM/1XiAXL7lyYUyOq24KHgQ2Csg= 28 | -----END CERTIFICATE----- 29 | -------------------------------------------------------------------------------- /releases.md: -------------------------------------------------------------------------------- 1 | # Releases 2 | 3 | ## v0.1.0 4 | 5 | - Initial implementation. 6 | -------------------------------------------------------------------------------- /test/async/cable/middleware.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "async/cable/middleware" 3 | 4 | require "protocol/rack/adapter" 5 | require "async/websocket/client" 6 | require "sus/fixtures/async/http/server_context" 7 | 8 | require "test_channel" 9 | 10 | describe Async::Cable::Middleware do 11 | include Sus::Fixtures::Async::HTTP::ServerContext 12 | 13 | let(:cable_server) {::ActionCable::Server::Base.new} 14 | 15 | before do 16 | cable_server.config.disable_request_forgery_protection = true 17 | cable_server.config.logger = Console 18 | cable_server.config.cable = {"adapter" => "async"} 19 | end 20 | 21 | after do 22 | @cable_server&.restart 23 | end 24 | 25 | let(:app) do 26 | Protocol::Rack::Adapter.new(subject.new(nil, server: cable_server)) 27 | end 28 | 29 | let(:connection) {Async::WebSocket::Client.connect(client_endpoint)} 30 | 31 | let(:identifier) {JSON.dump(channel: "TestChannel")} 32 | 33 | it "can connect and receive welcome messages" do 34 | welcome_message = connection.read.parse 35 | 36 | expect(welcome_message).to have_keys( 37 | type: be == "welcome" 38 | ) 39 | 40 | connection.shutdown 41 | ensure 42 | connection.close 43 | end 44 | 45 | it "can connect and send broadcast messages" do 46 | subscribe_message = ::Protocol::WebSocket::TextMessage.generate({ 47 | command: "subscribe", 48 | identifier: identifier, 49 | }) 50 | 51 | subscribe_message.send(connection) 52 | 53 | while message = connection.read 54 | confirmation = message.parse 55 | 56 | if confirmation[:type] == "confirm_subscription" 57 | break 58 | end 59 | end 60 | 61 | expect(confirmation).to have_keys( 62 | identifier: be == identifier 63 | ) 64 | 65 | broadcast_data = {action: "broadcast", payload: "Hello, World!"} 66 | 67 | broadcast_message = Protocol::WebSocket::TextMessage.generate( 68 | command: "message", 69 | identifier: identifier, 70 | data: broadcast_data.to_json 71 | ) 72 | 73 | broadcast_message.send(connection) 74 | connection.flush 75 | 76 | while message = connection.read 77 | broadcast = message.parse 78 | 79 | if broadcast[:identifier] == identifier 80 | break 81 | end 82 | end 83 | 84 | expect(broadcast).to have_keys( 85 | identifier: be == identifier 86 | ) 87 | 88 | connection.shutdown 89 | ensure 90 | connection.close 91 | end 92 | end 93 | --------------------------------------------------------------------------------