├── .bundle └── config ├── .dockerignore ├── .gitattributes ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .rubocop.yml ├── .ruby-version ├── Dockerfile ├── Gemfile ├── Gemfile.lock ├── Procfile.dev ├── README.md ├── Rakefile ├── anycable.toml ├── app ├── assets │ ├── builds │ │ └── .keep │ ├── config │ │ └── manifest.js │ ├── images │ │ └── .keep │ └── stylesheets │ │ ├── application.css │ │ └── application.tailwind.css ├── channels │ └── application_cable │ │ ├── channel.rb │ │ └── connection.rb ├── controllers │ ├── application_controller.rb │ ├── concerns │ │ └── .keep │ └── todos_controller.rb ├── helpers │ ├── application_helper.rb │ └── todos_helper.rb ├── javascript │ └── application.js ├── jobs │ └── application_job.rb ├── mailers │ └── application_mailer.rb ├── models │ ├── application_record.rb │ ├── concerns │ │ └── .keep │ └── todo.rb └── views │ ├── layouts │ ├── application.html.erb │ ├── mailer.html.erb │ └── mailer.text.erb │ ├── pwa │ ├── manifest.json.erb │ └── service-worker.js │ └── todos │ ├── _form.html.erb │ ├── _todo.html.erb │ ├── _todo.json.jbuilder │ ├── edit.html.erb │ ├── index.html.erb │ ├── index.json.jbuilder │ ├── new.html.erb │ ├── show.html.erb │ └── show.json.jbuilder ├── bin ├── anycable-go ├── brakeman ├── dev ├── docker-entrypoint ├── importmap ├── rails ├── rake ├── rubocop └── setup ├── config.ru ├── config ├── anycable.yml ├── application.rb ├── boot.rb ├── cable.yml ├── credentials.yml.enc ├── database.yml ├── environment.rb ├── environments │ ├── development.rb │ ├── production.rb │ ├── test.rb │ └── wasm.rb ├── importmap.rb ├── initializers │ ├── assets.rb │ ├── content_security_policy.rb │ ├── filter_parameter_logging.rb │ ├── inflections.rb │ └── permissions_policy.rb ├── locales │ └── en.yml ├── puma.rb ├── routes.rb ├── storage.yml ├── tailwind.config.js └── wasmify.yml ├── db ├── migrate │ └── 20240927184423_create_todos.rb ├── schema.rb └── seeds.rb ├── lib ├── assets │ └── .keep └── tasks │ └── .keep ├── log └── .keep ├── public ├── 404.html ├── 406-unsupported-browser.html ├── 422.html ├── 500.html ├── icon.png ├── icon.svg └── robots.txt ├── pwa ├── README.md ├── boot.html ├── boot.js ├── database.js ├── index.html ├── package.json ├── rails.sw.js ├── vite.config.js └── yarn.lock ├── storage └── .keep ├── test ├── application_system_test_case.rb ├── channels │ └── application_cable │ │ └── connection_test.rb ├── controllers │ ├── .keep │ └── todos_controller_test.rb ├── fixtures │ ├── files │ │ └── .keep │ └── todos.yml ├── helpers │ └── .keep ├── integration │ └── .keep ├── mailers │ └── .keep ├── models │ ├── .keep │ └── todo_test.rb ├── system │ ├── .keep │ └── todos_test.rb └── test_helper.rb ├── tmp ├── .keep ├── pids │ └── .keep └── storage │ └── .keep └── vendor ├── .keep └── javascript ├── .keep ├── @anycable--core.js ├── @anycable--turbo-stream.js ├── @anycable--web.js ├── @hotwired--turbo.js └── nanoevents.js /.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_BUILD__JS: "--disable-component-model" 3 | -------------------------------------------------------------------------------- /.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 (except templates). 11 | /.env* 12 | !/.env*.erb 13 | 14 | # Ignore all default key files. 15 | /config/master.key 16 | /config/credentials/*.key 17 | 18 | # Ignore all logfiles and tempfiles. 19 | /log/* 20 | /tmp/* 21 | !/log/.keep 22 | !/tmp/.keep 23 | 24 | # Ignore pidfiles, but keep the directory. 25 | /tmp/pids/* 26 | !/tmp/pids/.keep 27 | 28 | # Ignore storage (uploaded files in development and any SQLite databases). 29 | /storage/* 30 | !/storage/.keep 31 | /tmp/storage/* 32 | !/tmp/storage/.keep 33 | 34 | # Ignore assets. 35 | /node_modules/ 36 | /app/assets/builds/* 37 | !/app/assets/builds/.keep 38 | /public/assets 39 | 40 | # Ignore CI service files. 41 | /.github 42 | 43 | # Ignore development files 44 | /.devcontainer 45 | 46 | # Ignore Docker-related files 47 | /.dockerignore 48 | /Dockerfile* 49 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 all environment files (except templates). 8 | /.env* 9 | !/.env*.erb 10 | 11 | # Ignore all logfiles and tempfiles. 12 | /log/* 13 | /tmp/* 14 | !/log/.keep 15 | !/tmp/.keep 16 | 17 | # Ignore pidfiles, but keep the directory. 18 | /tmp/pids/* 19 | !/tmp/pids/ 20 | !/tmp/pids/.keep 21 | 22 | # Ignore storage (uploaded files in development and any SQLite databases). 23 | /storage/* 24 | !/storage/.keep 25 | /tmp/storage/* 26 | !/tmp/storage/ 27 | !/tmp/storage/.keep 28 | 29 | /public/assets 30 | 31 | # Ignore master key for decrypting credentials and more. 32 | /config/master.key 33 | # Ignore the compiled WebAssembly modules 34 | *.wasm 35 | # Ignore ruby.wasm build artefacts 36 | build/ 37 | rubies/ 38 | dist/ 39 | 40 | /app/assets/builds/* 41 | !/app/assets/builds/.keep 42 | bin/dist 43 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-3.3.5 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax = docker/dockerfile:1 2 | 3 | # This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand: 4 | # docker build -t my-app . 5 | # docker run -d -p 80:80 -p 443:443 --name my-app -e RAILS_MASTER_KEY= my-app 6 | 7 | # Make sure RUBY_VERSION matches the Ruby version in .ruby-version 8 | ARG RUBY_VERSION=3.3.5 9 | FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base 10 | 11 | # Rails app lives here 12 | WORKDIR /rails 13 | 14 | # Install base packages 15 | RUN apt-get update -qq && \ 16 | apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \ 17 | rm -rf /var/lib/apt/lists /var/cache/apt/archives 18 | 19 | # Set production environment 20 | ENV RAILS_ENV="production" \ 21 | BUNDLE_DEPLOYMENT="1" \ 22 | BUNDLE_PATH="/usr/local/bundle" \ 23 | BUNDLE_WITHOUT="development" 24 | 25 | # Throw-away build stage to reduce size of final image 26 | FROM base AS build 27 | 28 | # Install packages needed to build gems 29 | RUN apt-get update -qq && \ 30 | apt-get install --no-install-recommends -y build-essential git pkg-config && \ 31 | rm -rf /var/lib/apt/lists /var/cache/apt/archives 32 | 33 | # Install application gems 34 | COPY Gemfile Gemfile.lock ./ 35 | RUN bundle install && \ 36 | rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ 37 | bundle exec bootsnap precompile --gemfile 38 | 39 | # Copy application code 40 | COPY . . 41 | 42 | # Precompile bootsnap code for faster boot times 43 | RUN bundle exec bootsnap precompile app/ lib/ 44 | 45 | # Precompiling assets for production without requiring secret RAILS_MASTER_KEY 46 | RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile 47 | 48 | 49 | 50 | 51 | # Final stage for app image 52 | FROM base 53 | 54 | # Copy built artifacts: gems, application 55 | COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" 56 | COPY --from=build /rails /rails 57 | 58 | # Run and own only the runtime files as a non-root user for security 59 | RUN groupadd --system --gid 1000 rails && \ 60 | useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ 61 | chown -R rails:rails db log storage tmp 62 | USER 1000:1000 63 | 64 | # Entrypoint prepares the database. 65 | ENTRYPOINT ["/rails/bin/docker-entrypoint"] 66 | 67 | # Start the server by default, this can be overwritten at runtime 68 | EXPOSE 3000 69 | CMD ["./bin/rails", "server"] 70 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "actioncable-next", group: [:default, :wasm] 4 | # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" 5 | gem "rails", "~> 7.2", group: [:default, :wasm] 6 | # The original asset pipeline for Rails [https://github.com/rails/sprockets-rails] 7 | gem "sprockets-rails", group: [:default, :wasm] 8 | # Use sqlite3 as the database for Active Record 9 | gem "sqlite3", ">= 1.4" 10 | # Use the Puma web server [https://github.com/puma/puma] 11 | gem "puma", ">= 5.0" 12 | # Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails] 13 | gem "importmap-rails", group: [:default, :wasm] 14 | # Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev] 15 | gem "turbo-rails", group: [:default, :wasm] 16 | # Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev] 17 | gem "stimulus-rails", group: [:default, :wasm] 18 | # Use Tailwind CSS [https://github.com/rails/tailwindcss-rails] 19 | gem "tailwindcss-rails", group: [:default, :wasm] 20 | # Build JSON APIs with ease [https://github.com/rails/jbuilder] 21 | gem "jbuilder", group: [:default, :wasm] 22 | # Use Redis adapter to run Action Cable in production 23 | # gem "redis", ">= 4.0.1" 24 | gem "anycable-rails", "~> 1.5.4" 25 | # Use Kredis to get higher-level data types in Redis [https://github.com/rails/kredis] 26 | # gem "kredis" 27 | 28 | # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] 29 | # gem "bcrypt", "~> 3.1.7" 30 | 31 | # Reduces boot times through caching; required in config/boot.rb 32 | gem "bootsnap", require: false 33 | 34 | # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] 35 | # gem "image_processing", "~> 1.2" 36 | 37 | group :development, :test do 38 | # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem 39 | gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" 40 | 41 | # Static analysis for security vulnerabilities [https://brakemanscanner.org/] 42 | gem "brakeman", require: false 43 | 44 | # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] 45 | gem "rubocop-rails-omakase", require: false 46 | end 47 | 48 | group :development do 49 | # Use console on exceptions pages [https://github.com/rails/web-console] 50 | gem "web-console" 51 | end 52 | 53 | group :test do 54 | # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] 55 | gem "capybara" 56 | gem "selenium-webdriver" 57 | end 58 | 59 | gem "wasmify-rails", "~> 0.2.0", group: [:default, :wasm] 60 | 61 | group :wasm do 62 | gem "tzinfo-data" 63 | end 64 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | actioncable (7.2.1) 5 | actionpack (= 7.2.1) 6 | activesupport (= 7.2.1) 7 | nio4r (~> 2.0) 8 | websocket-driver (>= 0.6.1) 9 | zeitwerk (~> 2.6) 10 | actioncable-next (0.1.1) 11 | actionpack (>= 7.0, <= 8.1) 12 | activesupport (>= 7.0, <= 8.1) 13 | nio4r (~> 2.0) 14 | websocket-driver (>= 0.6.1) 15 | zeitwerk (~> 2.6) 16 | actionmailbox (7.2.1) 17 | actionpack (= 7.2.1) 18 | activejob (= 7.2.1) 19 | activerecord (= 7.2.1) 20 | activestorage (= 7.2.1) 21 | activesupport (= 7.2.1) 22 | mail (>= 2.8.0) 23 | actionmailer (7.2.1) 24 | actionpack (= 7.2.1) 25 | actionview (= 7.2.1) 26 | activejob (= 7.2.1) 27 | activesupport (= 7.2.1) 28 | mail (>= 2.8.0) 29 | rails-dom-testing (~> 2.2) 30 | actionpack (7.2.1) 31 | actionview (= 7.2.1) 32 | activesupport (= 7.2.1) 33 | nokogiri (>= 1.8.5) 34 | racc 35 | rack (>= 2.2.4, < 3.2) 36 | rack-session (>= 1.0.1) 37 | rack-test (>= 0.6.3) 38 | rails-dom-testing (~> 2.2) 39 | rails-html-sanitizer (~> 1.6) 40 | useragent (~> 0.16) 41 | actiontext (7.2.1) 42 | actionpack (= 7.2.1) 43 | activerecord (= 7.2.1) 44 | activestorage (= 7.2.1) 45 | activesupport (= 7.2.1) 46 | globalid (>= 0.6.0) 47 | nokogiri (>= 1.8.5) 48 | actionview (7.2.1) 49 | activesupport (= 7.2.1) 50 | builder (~> 3.1) 51 | erubi (~> 1.11) 52 | rails-dom-testing (~> 2.2) 53 | rails-html-sanitizer (~> 1.6) 54 | activejob (7.2.1) 55 | activesupport (= 7.2.1) 56 | globalid (>= 0.3.6) 57 | activemodel (7.2.1) 58 | activesupport (= 7.2.1) 59 | activerecord (7.2.1) 60 | activemodel (= 7.2.1) 61 | activesupport (= 7.2.1) 62 | timeout (>= 0.4.0) 63 | activestorage (7.2.1) 64 | actionpack (= 7.2.1) 65 | activejob (= 7.2.1) 66 | activerecord (= 7.2.1) 67 | activesupport (= 7.2.1) 68 | marcel (~> 1.0) 69 | activesupport (7.2.1) 70 | base64 71 | bigdecimal 72 | concurrent-ruby (~> 1.0, >= 1.3.1) 73 | connection_pool (>= 2.2.5) 74 | drb 75 | i18n (>= 1.6, < 2) 76 | logger (>= 1.4.2) 77 | minitest (>= 5.1) 78 | securerandom (>= 0.3) 79 | tzinfo (~> 2.0, >= 2.0.5) 80 | addressable (2.8.7) 81 | public_suffix (>= 2.0.2, < 7.0) 82 | anycable (1.5.1) 83 | anycable-core (= 1.5.1) 84 | grpc (~> 1.53) 85 | anycable-core (1.5.1) 86 | anyway_config (~> 2.2) 87 | google-protobuf (~> 3.25) 88 | anycable-rails (1.5.4) 89 | anycable (~> 1.5.0) 90 | anycable-rails-core (= 1.5.4) 91 | anycable-rails-core (1.5.4) 92 | actioncable (>= 7.0, < 9.0) 93 | anycable-core (~> 1.5.0) 94 | globalid 95 | anyway_config (2.6.4) 96 | ruby-next-core (~> 1.0) 97 | ast (2.4.2) 98 | base64 (0.2.0) 99 | bigdecimal (3.1.8) 100 | bindex (0.8.1) 101 | bootsnap (1.18.4) 102 | msgpack (~> 1.2) 103 | brakeman (6.2.1) 104 | racc 105 | builder (3.3.0) 106 | capybara (3.40.0) 107 | addressable 108 | matrix 109 | mini_mime (>= 0.1.3) 110 | nokogiri (~> 1.11) 111 | rack (>= 1.6.0) 112 | rack-test (>= 0.6.3) 113 | regexp_parser (>= 1.5, < 3.0) 114 | xpath (~> 3.2) 115 | concurrent-ruby (1.3.4) 116 | connection_pool (2.4.1) 117 | crass (1.0.6) 118 | date (3.3.4) 119 | debug (1.9.2) 120 | irb (~> 1.10) 121 | reline (>= 0.3.8) 122 | drb (2.2.1) 123 | erubi (1.13.0) 124 | globalid (1.2.1) 125 | activesupport (>= 6.1) 126 | google-protobuf (3.25.5) 127 | google-protobuf (3.25.5-aarch64-linux) 128 | google-protobuf (3.25.5-arm64-darwin) 129 | google-protobuf (3.25.5-x86-linux) 130 | google-protobuf (3.25.5-x86_64-darwin) 131 | google-protobuf (3.25.5-x86_64-linux) 132 | googleapis-common-protos-types (1.16.0) 133 | google-protobuf (>= 3.18, < 5.a) 134 | grpc (1.67.0) 135 | google-protobuf (>= 3.25, < 5.0) 136 | googleapis-common-protos-types (~> 1.0) 137 | grpc (1.67.0-aarch64-linux) 138 | google-protobuf (>= 3.25, < 5.0) 139 | googleapis-common-protos-types (~> 1.0) 140 | grpc (1.67.0-arm64-darwin) 141 | google-protobuf (>= 3.25, < 5.0) 142 | googleapis-common-protos-types (~> 1.0) 143 | grpc (1.67.0-x86-linux) 144 | google-protobuf (>= 3.25, < 5.0) 145 | googleapis-common-protos-types (~> 1.0) 146 | grpc (1.67.0-x86_64-darwin) 147 | google-protobuf (>= 3.25, < 5.0) 148 | googleapis-common-protos-types (~> 1.0) 149 | grpc (1.67.0-x86_64-linux) 150 | google-protobuf (>= 3.25, < 5.0) 151 | googleapis-common-protos-types (~> 1.0) 152 | i18n (1.14.6) 153 | concurrent-ruby (~> 1.0) 154 | importmap-rails (2.0.1) 155 | actionpack (>= 6.0.0) 156 | activesupport (>= 6.0.0) 157 | railties (>= 6.0.0) 158 | io-console (0.7.2) 159 | irb (1.14.1) 160 | rdoc (>= 4.0.0) 161 | reline (>= 0.4.2) 162 | jbuilder (2.13.0) 163 | actionview (>= 5.0.0) 164 | activesupport (>= 5.0.0) 165 | js (2.7.0) 166 | json (2.7.2) 167 | language_server-protocol (3.17.0.3) 168 | logger (1.6.1) 169 | loofah (2.22.0) 170 | crass (~> 1.0.2) 171 | nokogiri (>= 1.12.0) 172 | mail (2.8.1) 173 | mini_mime (>= 0.1.1) 174 | net-imap 175 | net-pop 176 | net-smtp 177 | marcel (1.0.4) 178 | matrix (0.4.2) 179 | mini_mime (1.1.5) 180 | minitest (5.25.1) 181 | msgpack (1.7.2) 182 | net-imap (0.4.16) 183 | date 184 | net-protocol 185 | net-pop (0.1.2) 186 | net-protocol 187 | net-protocol (0.2.2) 188 | timeout 189 | net-smtp (0.5.0) 190 | net-protocol 191 | nio4r (2.7.3) 192 | nokogiri (1.16.7-aarch64-linux) 193 | racc (~> 1.4) 194 | nokogiri (1.16.7-arm-linux) 195 | racc (~> 1.4) 196 | nokogiri (1.16.7-arm64-darwin) 197 | racc (~> 1.4) 198 | nokogiri (1.16.7-x86-linux) 199 | racc (~> 1.4) 200 | nokogiri (1.16.7-x86_64-darwin) 201 | racc (~> 1.4) 202 | nokogiri (1.16.7-x86_64-linux) 203 | racc (~> 1.4) 204 | parallel (1.26.3) 205 | parser (3.3.5.0) 206 | ast (~> 2.4.1) 207 | racc 208 | psych (5.1.2) 209 | stringio 210 | public_suffix (6.0.1) 211 | puma (6.4.3) 212 | nio4r (~> 2.0) 213 | racc (1.8.1) 214 | rack (3.1.7) 215 | rack-session (2.0.0) 216 | rack (>= 3.0.0) 217 | rack-test (2.1.0) 218 | rack (>= 1.3) 219 | rackup (2.1.0) 220 | rack (>= 3) 221 | webrick (~> 1.8) 222 | rails (7.2.1) 223 | actioncable (= 7.2.1) 224 | actionmailbox (= 7.2.1) 225 | actionmailer (= 7.2.1) 226 | actionpack (= 7.2.1) 227 | actiontext (= 7.2.1) 228 | actionview (= 7.2.1) 229 | activejob (= 7.2.1) 230 | activemodel (= 7.2.1) 231 | activerecord (= 7.2.1) 232 | activestorage (= 7.2.1) 233 | activesupport (= 7.2.1) 234 | bundler (>= 1.15.0) 235 | railties (= 7.2.1) 236 | rails-dom-testing (2.2.0) 237 | activesupport (>= 5.0.0) 238 | minitest 239 | nokogiri (>= 1.6) 240 | rails-html-sanitizer (1.6.0) 241 | loofah (~> 2.21) 242 | nokogiri (~> 1.14) 243 | railties (7.2.1) 244 | actionpack (= 7.2.1) 245 | activesupport (= 7.2.1) 246 | irb (~> 1.13) 247 | rackup (>= 1.0.0) 248 | rake (>= 12.2) 249 | thor (~> 1.0, >= 1.2.2) 250 | zeitwerk (~> 2.6) 251 | rainbow (3.1.1) 252 | rake (13.2.1) 253 | rdoc (6.7.0) 254 | psych (>= 4.0.0) 255 | regexp_parser (2.9.2) 256 | reline (0.5.10) 257 | io-console (~> 0.5) 258 | rexml (3.3.7) 259 | rubocop (1.66.1) 260 | json (~> 2.3) 261 | language_server-protocol (>= 3.17.0) 262 | parallel (~> 1.10) 263 | parser (>= 3.3.0.2) 264 | rainbow (>= 2.2.2, < 4.0) 265 | regexp_parser (>= 2.4, < 3.0) 266 | rubocop-ast (>= 1.32.2, < 2.0) 267 | ruby-progressbar (~> 1.7) 268 | unicode-display_width (>= 2.4.0, < 3.0) 269 | rubocop-ast (1.32.3) 270 | parser (>= 3.3.1.0) 271 | rubocop-minitest (0.36.0) 272 | rubocop (>= 1.61, < 2.0) 273 | rubocop-ast (>= 1.31.1, < 2.0) 274 | rubocop-performance (1.22.1) 275 | rubocop (>= 1.48.1, < 2.0) 276 | rubocop-ast (>= 1.31.1, < 2.0) 277 | rubocop-rails (2.26.2) 278 | activesupport (>= 4.2.0) 279 | rack (>= 1.1) 280 | rubocop (>= 1.52.0, < 2.0) 281 | rubocop-ast (>= 1.31.1, < 2.0) 282 | rubocop-rails-omakase (1.0.0) 283 | rubocop 284 | rubocop-minitest 285 | rubocop-performance 286 | rubocop-rails 287 | ruby-next-core (1.0.3) 288 | ruby-progressbar (1.13.0) 289 | ruby_wasm (2.7.0) 290 | ruby_wasm (2.7.0-aarch64-linux) 291 | ruby_wasm (2.7.0-aarch64-linux-musl) 292 | ruby_wasm (2.7.0-arm64-darwin) 293 | ruby_wasm (2.7.0-x86_64-darwin) 294 | ruby_wasm (2.7.0-x86_64-linux) 295 | ruby_wasm (2.7.0-x86_64-linux-musl) 296 | rubyzip (2.3.2) 297 | securerandom (0.3.1) 298 | selenium-webdriver (4.25.0) 299 | base64 (~> 0.2) 300 | logger (~> 1.4) 301 | rexml (~> 3.2, >= 3.2.5) 302 | rubyzip (>= 1.2.2, < 3.0) 303 | websocket (~> 1.0) 304 | sprockets (4.2.1) 305 | concurrent-ruby (~> 1.0) 306 | rack (>= 2.2.4, < 4) 307 | sprockets-rails (3.5.2) 308 | actionpack (>= 6.1) 309 | activesupport (>= 6.1) 310 | sprockets (>= 3.0.0) 311 | sqlite3 (2.1.0-aarch64-linux-gnu) 312 | sqlite3 (2.1.0-aarch64-linux-musl) 313 | sqlite3 (2.1.0-arm-linux-gnu) 314 | sqlite3 (2.1.0-arm-linux-musl) 315 | sqlite3 (2.1.0-arm64-darwin) 316 | sqlite3 (2.1.0-x86-linux-gnu) 317 | sqlite3 (2.1.0-x86-linux-musl) 318 | sqlite3 (2.1.0-x86_64-darwin) 319 | sqlite3 (2.1.0-x86_64-linux-gnu) 320 | sqlite3 (2.1.0-x86_64-linux-musl) 321 | stimulus-rails (1.3.4) 322 | railties (>= 6.0.0) 323 | stringio (3.1.1) 324 | tailwindcss-rails (2.7.6) 325 | railties (>= 7.0.0) 326 | tailwindcss-rails (2.7.6-aarch64-linux) 327 | railties (>= 7.0.0) 328 | tailwindcss-rails (2.7.6-arm-linux) 329 | railties (>= 7.0.0) 330 | tailwindcss-rails (2.7.6-arm64-darwin) 331 | railties (>= 7.0.0) 332 | tailwindcss-rails (2.7.6-x86_64-darwin) 333 | railties (>= 7.0.0) 334 | tailwindcss-rails (2.7.6-x86_64-linux) 335 | railties (>= 7.0.0) 336 | thor (1.3.2) 337 | timeout (0.4.1) 338 | turbo-rails (2.0.10) 339 | actionpack (>= 6.0.0) 340 | railties (>= 6.0.0) 341 | tzinfo (2.0.6) 342 | concurrent-ruby (~> 1.0) 343 | tzinfo-data (1.2024.2) 344 | tzinfo (>= 1.0.0) 345 | unicode-display_width (2.6.0) 346 | useragent (0.16.10) 347 | wasmify-rails (0.2.0) 348 | js (>= 2.6, < 3.0) 349 | railties (>= 7.0, < 9.0) 350 | ruby_wasm (>= 2.6, < 3.0) 351 | web-console (4.2.1) 352 | actionview (>= 6.0.0) 353 | activemodel (>= 6.0.0) 354 | bindex (>= 0.4.0) 355 | railties (>= 6.0.0) 356 | webrick (1.8.2) 357 | websocket (1.2.11) 358 | websocket-driver (0.7.6) 359 | websocket-extensions (>= 0.1.0) 360 | websocket-extensions (0.1.5) 361 | xpath (3.2.0) 362 | nokogiri (~> 1.8) 363 | zeitwerk (2.6.18) 364 | 365 | PLATFORMS 366 | aarch64-linux 367 | aarch64-linux-gnu 368 | aarch64-linux-musl 369 | arm-linux 370 | arm-linux-gnu 371 | arm-linux-musl 372 | arm64-darwin 373 | x86-linux 374 | x86-linux-gnu 375 | x86-linux-musl 376 | x86_64-darwin 377 | x86_64-linux 378 | x86_64-linux-gnu 379 | x86_64-linux-musl 380 | 381 | DEPENDENCIES 382 | actioncable-next 383 | anycable-rails (~> 1.5.4) 384 | bootsnap 385 | brakeman 386 | capybara 387 | debug 388 | importmap-rails 389 | jbuilder 390 | puma (>= 5.0) 391 | rails (~> 7.2) 392 | rubocop-rails-omakase 393 | selenium-webdriver 394 | sprockets-rails 395 | sqlite3 (>= 1.4) 396 | stimulus-rails 397 | tailwindcss-rails 398 | turbo-rails 399 | tzinfo-data 400 | wasmify-rails (~> 0.2.0) 401 | web-console 402 | 403 | BUNDLED WITH 404 | 2.5.18 405 | -------------------------------------------------------------------------------- /Procfile.dev: -------------------------------------------------------------------------------- 1 | web: bin/rails server 2 | css: bin/rails tailwindcss:watch 3 | ws: bin/anycable-go -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rails on Wasm playground application 2 | 3 | This is a sample application to showcase different Rails on Wasm techniques. The baseline version comes with 4 | a simple Rails app backed by a SQLite3 database. 5 | 6 | > [!Tip] 7 | > Read more about Rails on Wasm in our [Writebook](https://writebook-on-wasm.fly.dev/) 📖. 8 | 9 | See more features and variations in the [PRs marked as "demo"](https://github.com/palkan/rails-on-wasm-playground/pulls?q=is%3Aopen+is%3Apr+label%3ADemo). 10 | 11 | ## Install & Run 12 | 13 | You need to make a few steps to run this application in your browser: 14 | 15 | 1. Install Ruby deps: 16 | 17 | ```bash 18 | bundle install 19 | ``` 20 | 21 | 1+. Feel free to run the app locally (as a regular Rails app): 22 | 23 | ```bash 24 | bin/dev 25 | ``` 26 | 27 | 2. Compile the app into a Wasm module: 28 | 29 | ```bash 30 | bin/rails wasmify:pack 31 | ``` 32 | 33 | 3. Install PWA app dependencies: 34 | 35 | ```bash 36 | cd pwa/ && yarn install 37 | ``` 38 | 39 | 4. Run the PWA app to see the Rails app running in your browser: 40 | 41 | ```bash 42 | cd pwa/ && yarn dev 43 | ``` 44 | 45 | Go to [http://localhost:5173/boot.html](http://localhost:5173/boot.html). 46 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /anycable.toml: -------------------------------------------------------------------------------- 1 | # AnyCable server configuration (development). 2 | # 3 | # Read more at https://docs.anycable.io/anycable-go/configuration 4 | 5 | # Public mode disables connection authentication, pub/sub streams and broadcasts verification 6 | # public = false 7 | 8 | # The application secret key 9 | secret = "anycable-local-secret" 10 | 11 | # Broadcasting adapters for app-to-clients messages 12 | broadcast_adapters = ["http"] 13 | 14 | # Pub/sub adapter for inter-node communication 15 | # pubsub_adapter = "redis" # or "nats" 16 | 17 | [server] 18 | host = "localhost" 19 | port = 8080 20 | 21 | [logging] 22 | debug = true 23 | 24 | # Read more about broker: https://docs.anycable.io/anycable-go/reliable_streams 25 | [broker] 26 | adapter = "memory" 27 | history_ttl = 300 28 | history_limit = 100 29 | sessions_ttl = 300 30 | 31 | [rpc] 32 | host = "http://localhost:3000/_anycable" 33 | # Specify HTTP headers that must be proxied to the RPC service 34 | proxy_headers = ["cookie"] 35 | # RPC concurrency (max number of concurrent RPC requests) 36 | concurrency = 28 37 | 38 | # Read more about AnyCable JWT: https://docs.anycable.io/anycable-go/jwt_identification 39 | [jwt] 40 | # param = "jid" 41 | # force = true 42 | 43 | # Read more about AnyCable signed streams: https://docs.anycable.io/anycable-go/signed_streams 44 | [streams] 45 | # Enable public (unsigned) streams 46 | # public = true 47 | # Enable whispering support for pub/sub streams 48 | # whisper = true 49 | pubsub_channel = "$pubsub" 50 | # turbo = true 51 | # cable_ready = true 52 | 53 | [redis] 54 | # url = "redis://localhost:6379" 55 | 56 | 57 | [http_broadcast] 58 | port = 8090 59 | path = "/_broadcast" 60 | -------------------------------------------------------------------------------- /app/assets/builds/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palkan/rails-on-wasm-playground/342c70cba26f1c52688a9df3dce52bed5b9a657f/app/assets/builds/.keep -------------------------------------------------------------------------------- /app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../stylesheets .css 3 | //= link_tree ../builds 4 | //= link_tree ../../javascript .js 5 | //= link_tree ../../../vendor/javascript .js 6 | -------------------------------------------------------------------------------- /app/assets/images/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palkan/rails-on-wasm-playground/342c70cba26f1c52688a9df3dce52bed5b9a657f/app/assets/images/.keep -------------------------------------------------------------------------------- /app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS (and SCSS, if configured) file within this directory, lib/assets/stylesheets, or any plugin's 6 | * vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /app/assets/stylesheets/application.tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* 6 | 7 | @layer components { 8 | .btn-primary { 9 | @apply py-2 px-4 bg-blue-200; 10 | } 11 | } 12 | 13 | */ 14 | -------------------------------------------------------------------------------- /app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Channel < ActionCable::Channel::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Connection < ActionCable::Connection::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palkan/rails-on-wasm-playground/342c70cba26f1c52688a9df3dce52bed5b9a657f/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /app/controllers/todos_controller.rb: -------------------------------------------------------------------------------- 1 | class TodosController < ApplicationController 2 | before_action :set_todo, only: %i[ show edit update destroy ] 3 | 4 | # GET /todos or /todos.json 5 | def index 6 | @todos = Todo.all 7 | respond_to do |format| 8 | format.html 9 | format.json { render json: @todos } 10 | end 11 | end 12 | 13 | # GET /todos/1 or /todos/1.json 14 | def show 15 | end 16 | 17 | # GET /todos/new 18 | def new 19 | @todo = Todo.new 20 | end 21 | 22 | # GET /todos/1/edit 23 | def edit 24 | end 25 | 26 | # POST /todos or /todos.json 27 | def create 28 | @todo = Todo.new(todo_params) 29 | 30 | respond_to do |format| 31 | if @todo.save 32 | format.html { redirect_to todos_path, notice: "Todo was successfully created." } 33 | format.json { render :show, status: :created, location: @todo } 34 | else 35 | format.html { render :new, status: :unprocessable_entity } 36 | format.json { render json: @todo.errors, status: :unprocessable_entity } 37 | end 38 | end 39 | end 40 | 41 | # PATCH/PUT /todos/1 or /todos/1.json 42 | def update 43 | respond_to do |format| 44 | if @todo.update(todo_params) 45 | format.html { redirect_to todos_path, notice: "Todo was successfully updated." } 46 | format.json { render :show, status: :ok, location: @todo } 47 | else 48 | format.html { render :edit, status: :unprocessable_entity } 49 | format.json { render json: @todo.errors, status: :unprocessable_entity } 50 | end 51 | end 52 | end 53 | 54 | # DELETE /todos/1 or /todos/1.json 55 | def destroy 56 | @todo.destroy! 57 | 58 | respond_to do |format| 59 | format.html { redirect_to todos_path, status: :see_other, notice: "Todo was successfully destroyed." } 60 | format.json { head :no_content } 61 | end 62 | end 63 | 64 | private 65 | # Use callbacks to share common setup or constraints between actions. 66 | def set_todo 67 | @todo = Todo.find(params[:id]) 68 | end 69 | 70 | # Only allow a list of trusted parameters through. 71 | def todo_params 72 | params.require(:todo).permit(:description, :completed) 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/helpers/todos_helper.rb: -------------------------------------------------------------------------------- 1 | module TodosHelper 2 | end 3 | -------------------------------------------------------------------------------- /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"; 3 | import { createCable } from "@anycable/web"; 4 | import { start } from "@anycable/turbo-stream"; 5 | 6 | // Check if actioncable meta tag contains the URL 7 | const actionCableMeta = document.querySelector('meta[name="action-cable-url"]'); 8 | const actionCableUrl = actionCableMeta ? actionCableMeta.content : null; 9 | 10 | if (actionCableUrl && actionCableUrl.startsWith("null://")) { 11 | console.log("No Action Cable configured") 12 | } else { 13 | const cable = createCable({ logLevel: "debug" }); 14 | start(cable, { delayedUnsubscribe: true }); 15 | } 16 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | class ApplicationMailer < ActionMailer::Base 2 | default from: "from@example.com" 3 | layout "mailer" 4 | end 5 | -------------------------------------------------------------------------------- /app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | primary_abstract_class 3 | end 4 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palkan/rails-on-wasm-playground/342c70cba26f1c52688a9df3dce52bed5b9a657f/app/models/concerns/.keep -------------------------------------------------------------------------------- /app/models/todo.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Todo < ApplicationRecord 4 | after_commit do 5 | broadcast_refresh 6 | broadcast_refresh_to "todos" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= content_for(:title) || "Offline Todos" %> 5 | 6 | 7 | <%= csrf_meta_tags %> 8 | <%= csp_meta_tag %> 9 | <%= action_cable_meta_tag %> 10 | 11 | <%= yield :head %> 12 | 13 | 14 | 15 | 16 | 17 | <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %> 18 | <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> 19 | <%= javascript_importmap_tags %> 20 | 21 | 22 | 23 | <% if on_wasm? %> 24 |
25 |
26 | 27 | 28 | 29 | Local 30 |
31 |
32 | <% end %> 33 |
34 | <%= yield %> 35 |
36 | 37 | 38 | -------------------------------------------------------------------------------- /app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /app/views/pwa/manifest.json.erb: -------------------------------------------------------------------------------- 1 | { 2 | "name": "OfflineTodos", 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": "OfflineTodos.", 20 | "theme_color": "red", 21 | "background_color": "red" 22 | } 23 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/views/todos/_form.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_with(model: todo, class: "contents") do |form| %> 2 | <% if todo.errors.any? %> 3 |
4 |

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

5 | 6 | 11 |
12 | <% end %> 13 | 14 |
15 | <%= form.text_field :description, class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full" %> 16 |
17 | 18 | <% unless todo.new_record? %> 19 |
20 | <%= form.label :completed %> 21 | <%= form.check_box :completed, class: "w-6 h-6 text-green-600 rounded focus:ring-blue-500 focus:ring-2" %> 22 |
23 | <% end %> 24 | 25 |
26 | <%= form.submit "Save", class: "rounded-lg py-3 px-5 bg-green-600 text-white inline-block font-medium cursor-pointer" %> 27 |
28 | <% end %> 29 | 30 | <% unless todo.new_record? %> 31 |
32 | <%= button_to "Delete", todo, method: :delete, class: "mt-2 rounded-lg py-3 px-5 bg-gray-100 font-medium" %> 33 |
34 | <% end %> 35 | -------------------------------------------------------------------------------- /app/views/todos/_todo.html.erb: -------------------------------------------------------------------------------- 1 |
  • 2 |
    3 | <%= link_to todo.description, todo, class: "font-medium text-lg text-blue-600 dark:text-blue-500 hover:underline #{"line-through" if todo.completed?}" %> 4 |
    5 |
  • 6 | -------------------------------------------------------------------------------- /app/views/todos/_todo.json.jbuilder: -------------------------------------------------------------------------------- 1 | json.extract! todo, :id, :description, :completed, :created_at, :updated_at 2 | json.url todo_url(todo, format: :json) 3 | -------------------------------------------------------------------------------- /app/views/todos/edit.html.erb: -------------------------------------------------------------------------------- 1 |
    2 |

    Editing todo

    3 | 4 | <%= render "form", todo: @todo %> 5 | 6 | <%= link_to "Show this todo", @todo, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %> 7 | <%= link_to "Back to todos", todos_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %> 8 |
    9 | -------------------------------------------------------------------------------- /app/views/todos/index.html.erb: -------------------------------------------------------------------------------- 1 |
    2 | <%= turbo_stream_from "todos" %> 3 | <% if notice.present? %> 4 |

    <%= notice %>

    5 | <% end %> 6 | 7 | <% content_for :title, "Todos" %> 8 | 9 |
    10 |

    ToDo 📝

    11 | <%= link_to "+", new_todo_path, class: "rounded-lg py-3 px-5 border-black hover:bg-gray-100 border text-black block font-medium text-lg" %> 12 |
    13 | 14 | 17 |
    18 | -------------------------------------------------------------------------------- /app/views/todos/index.json.jbuilder: -------------------------------------------------------------------------------- 1 | json.array! @todos, partial: "todos/todo", as: :todo 2 | -------------------------------------------------------------------------------- /app/views/todos/new.html.erb: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | <%= link_to "Back to all ToDo-s", todos_path, class: "font-medium text-lg text-gray-500 hover:underline" %> 4 |
    5 |
    6 |

    New ToDo

    7 | 8 | <%= render "form", todo: @todo %> 9 |
    10 |
    11 | -------------------------------------------------------------------------------- /app/views/todos/show.html.erb: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | <%= link_to "Back to all ToDo-s", todos_path, class: "font-medium text-lg text-gray-500 hover:underline" %> 4 |
    5 | <%= turbo_stream_from @todo %> 6 |
    7 | <% if notice.present? %> 8 |

    <%= notice %>

    9 | <% end %> 10 | 11 | <%= render "form", todo: @todo %> 12 |
    13 |
    14 | -------------------------------------------------------------------------------- /app/views/todos/show.json.jbuilder: -------------------------------------------------------------------------------- 1 | json.partial! "todos/todo", todo: @todo 2 | -------------------------------------------------------------------------------- /bin/anycable-go: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd $(dirname $0)/.. 4 | 5 | # It's recommended to use the exact version of AnyCable here 6 | version="latest" 7 | 8 | if [ ! -f ./bin/dist/anycable-go ]; then 9 | echo "AnyCable server is not installed, downloading..." 10 | ./bin/rails g anycable:download --version=$version --bin-path=./bin/dist 11 | fi 12 | 13 | curVersion=$(./bin/dist/anycable-go -v) 14 | 15 | if [[ "$version" != "latest" ]]; then 16 | if [[ "$curVersion" != "$version"* ]]; then 17 | echo "AnyCable server version is not $version, downloading a new one..." 18 | ./bin/rails g anycable:download --version=$version --bin-path=./bin/dist 19 | fi 20 | fi 21 | 22 | ./bin/dist/anycable-go $@ 23 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /bin/dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | if ! gem list foreman -i --silent; then 4 | echo "Installing foreman..." 5 | gem install foreman 6 | fi 7 | 8 | # Default to port 3000 if not specified 9 | export PORT="${PORT:-3000}" 10 | 11 | # Let the debug gem allow remote connections, 12 | # but avoid loading until `debugger` is called 13 | export RUBY_DEBUG_OPEN="true" 14 | export RUBY_DEBUG_LAZY="true" 15 | 16 | exec foreman start -f Procfile.dev "$@" 17 | -------------------------------------------------------------------------------- /bin/docker-entrypoint: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # Enable jemalloc for reduced memory usage and latency. 4 | if [ -z "${LD_PRELOAD+x}" ] && [ -f /usr/lib/*/libjemalloc.so.2 ]; then 5 | export LD_PRELOAD="$(echo /usr/lib/*/libjemalloc.so.2)" 6 | fi 7 | 8 | # If running the rails server then create or migrate existing database 9 | if [ "${1}" == "./bin/rails" ] && [ "${2}" == "server" ]; then 10 | ./bin/rails db:prepare 11 | fi 12 | 13 | exec "${@}" 14 | -------------------------------------------------------------------------------- /bin/importmap: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative "../config/application" 4 | require "importmap/commands" 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "fileutils" 3 | 4 | APP_ROOT = File.expand_path("..", __dir__) 5 | APP_NAME = "offline-todos" 6 | 7 | def system!(*args) 8 | system(*args, exception: true) 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to set up or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 14 | # Add necessary setup steps to this file. 15 | 16 | puts "== Installing dependencies ==" 17 | system! "gem install bundler --conservative" 18 | system("bundle check") || system!("bundle install") 19 | 20 | # puts "\n== Copying sample files ==" 21 | # unless File.exist?("config/database.yml") 22 | # FileUtils.cp "config/database.yml.sample", "config/database.yml" 23 | # end 24 | 25 | puts "\n== Preparing database ==" 26 | system! "bin/rails db:prepare" 27 | 28 | puts "\n== Removing old logs and tempfiles ==" 29 | system! "bin/rails log:clear tmp:clear" 30 | 31 | puts "\n== Restarting application server ==" 32 | system! "bin/rails restart" 33 | 34 | # puts "\n== Configuring puma-dev ==" 35 | # system "ln -nfs #{APP_ROOT} ~/.puma-dev/#{APP_NAME}" 36 | # system "curl -Is https://#{APP_NAME}.test/up | head -n 1" 37 | end 38 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /config/anycable.yml: -------------------------------------------------------------------------------- 1 | # This file contains per-environment settings for AnyCable. 2 | # 3 | # Since AnyCable config is based on anyway_config (https://github.com/palkan/anyway_config), all AnyCable settings 4 | # can be set or overridden through the corresponding environment variables. 5 | # E.g., `rpc_host` is overridden by ANYCABLE_RPC_HOST, `debug` by ANYCABLE_DEBUG etc. 6 | # 7 | # Note that AnyCable recognizes REDIS_URL env variable for Redis pub/sub adapter. If you want to 8 | # use another Redis instance for AnyCable, provide ANYCABLE_REDIS_URL variable. 9 | # 10 | # Read more about AnyCable configuration here: https://docs.anycable.io/ruby/configuration 11 | # 12 | default: &default 13 | # Turn on/off access logs ("Started..." and "Finished...") 14 | access_logs_disabled: false 15 | # Whether to enable gRPC level logging or not 16 | log_grpc: false 17 | # Use HTTP broadcaster 18 | broadcast_adapter: http 19 | http_broadcast_url: "http://localhost:8090/_anycable" 20 | # Use HTTP RPC mounted at the specified path of your web server 21 | # Read more about AnyCable RPC: https://docs.anycable.io/anycable-go/rpc 22 | http_rpc_mount_path: "/_anycable" 23 | # Must be the same as in your AnyCable server config 24 | secret: "anycable-local-secret" 25 | 26 | development: 27 | <<: *default 28 | # WebSocket endpoint of your AnyCable server for clients to connect to 29 | # Make sure you have the `action_cable_meta_tag` in your HTML layout 30 | # to propogate this value to the client app 31 | websocket_url: "ws://localhost:8080/cable" 32 | 33 | test: 34 | <<: *default 35 | 36 | production: 37 | <<: *default 38 | websocket_url: ~ 39 | secret: ~ 40 | -------------------------------------------------------------------------------- /config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "wasmify/rails/shim" 4 | 5 | require "rails/all" 6 | 7 | # Require the gems listed in Gemfile, including any gems 8 | # you've limited to :test, :development, or :production. 9 | Bundler.require(*Rails.groups) 10 | 11 | module OfflineTodos 12 | class Application < Rails::Application 13 | # Initialize configuration defaults for originally generated Rails version. 14 | config.load_defaults 7.2 15 | 16 | # Please, add to the `ignore` list any other `lib` subdirectories that do 17 | # not contain `.rb` files, or that should not be reloaded or eager loaded. 18 | # Common ones are `templates`, `generators`, or `middleware`, for example. 19 | config.autoload_lib(ignore: %w[assets tasks]) 20 | 21 | # Configuration for the application, engines, and railties goes here. 22 | # 23 | # These settings can be overridden in specific environments using the files 24 | # in config/environments, which are processed later. 25 | # 26 | # config.time_zone = "Central Time (US & Canada)" 27 | # config.eager_load_paths << Rails.root.join("extras") 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 2 | 3 | require "bundler/setup" unless RUBY_PLATFORM =~ /wasm/ # Set up gems listed in the Gemfile. 4 | require "bootsnap/setup" unless ENV["RAILS_ENV"] == "wasm" # Speed up boot time by caching expensive operations. 5 | -------------------------------------------------------------------------------- /config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: <%= ENV.fetch("ACTION_CABLE_ADAPTER", "any_cable") %> 3 | 4 | test: 5 | adapter: test 6 | 7 | production: 8 | adapter: <%= ENV.fetch("ACTION_CABLE_ADAPTER", "any_cable") %> 9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 10 | channel_prefix: offline_todos_production 11 | 12 | wasm: 13 | adapter: inline 14 | -------------------------------------------------------------------------------- /config/credentials.yml.enc: -------------------------------------------------------------------------------- 1 | sXPXMAdx+O6A8qijeDh6OQDtjugHGJtdt4xdjQHmu5THqJglEO94FqjNNJBpillA35PQqPSZXo35tHuCW76Jh1WUEwehoVRYROPh69jsOht0BIeP1S41jmrWIAW3uuAgJEwugK8gPVgq7A5WbrYI2Y1P2cQggnZ7h9L/HKGJwjiz/r7bYaFY+HHCdgvj9/mnaYUhrRi5yAqs1fTzwBghjHZMKDZRbtcycC151WQsyc54g0dNewtirppucMBhKh2J+RoPnEucsVRkWBIjuTmlpPKiSMUSyRKhsJri5PO76ddXRb94cxo9bkNvx70aOMmlYHZ4TpjkthekOqx+NMNNh0yT+334kRzNrNRd9Ef96ZvdxIeEWfdEHdSREx5lP8Rg9NctHC3SpG7H5WN9cliGCxn0tbwW--pCZK61LyxCjwrndD--4NThl4s4zUfuvgSoGQUuUg== -------------------------------------------------------------------------------- /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 | # SQLite3 write its data on the local filesystem, as such it requires 25 | # persistent disks. If you are deploying to a managed service, you should 26 | # make sure it provides disk persistence, as many don't. 27 | # 28 | # Similarly, if you deploy your application as a Docker container, you must 29 | # ensure the database is located in a persisted volume. 30 | production: 31 | <<: *default 32 | # database: path/to/persistent/storage/production.sqlite3 33 | wasm: 34 | adapter: <%= ENV.fetch("ACTIVE_RECORD_ADAPTER") { "nulldb" } %> 35 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /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 | # In the development environment your application's code is reloaded any time 7 | # it changes. This slows down response time but is perfect for development 8 | # since you don't have to restart the web server when you make code changes. 9 | config.enable_reloading = true 10 | 11 | # Do not eager load code on boot. 12 | config.eager_load = false 13 | 14 | # Show full error reports. 15 | config.consider_all_requests_local = true 16 | 17 | # Enable server timing. 18 | config.server_timing = true 19 | 20 | # Enable/disable caching. By default caching is disabled. 21 | # Run rails dev:cache to toggle caching. 22 | if Rails.root.join("tmp/caching-dev.txt").exist? 23 | config.action_controller.perform_caching = true 24 | config.action_controller.enable_fragment_cache_logging = true 25 | 26 | config.cache_store = :memory_store 27 | config.public_file_server.headers = { "Cache-Control" => "public, max-age=#{2.days.to_i}" } 28 | else 29 | config.action_controller.perform_caching = false 30 | 31 | config.cache_store = :null_store 32 | end 33 | 34 | # Store uploaded files on the local file system (see config/storage.yml for options). 35 | config.active_storage.service = :local 36 | 37 | # Don't care if the mailer can't send. 38 | config.action_mailer.raise_delivery_errors = false 39 | 40 | # Disable caching for Action Mailer templates even if Action Controller 41 | # caching is enabled. 42 | config.action_mailer.perform_caching = false 43 | 44 | config.action_mailer.default_url_options = { host: "localhost", port: 3000 } 45 | 46 | # Print deprecation notices to the Rails logger. 47 | config.active_support.deprecation = :log 48 | 49 | # Raise exceptions for disallowed deprecations. 50 | config.active_support.disallowed_deprecation = :raise 51 | 52 | # Tell Active Support which deprecation messages to disallow. 53 | config.active_support.disallowed_deprecation_warnings = [] 54 | 55 | # Raise an error on page load if there are pending migrations. 56 | config.active_record.migration_error = :page_load 57 | 58 | # Highlight code that triggered database queries in logs. 59 | config.active_record.verbose_query_logs = true 60 | 61 | # Highlight code that enqueued background job in logs. 62 | config.active_job.verbose_enqueue_logs = true 63 | 64 | # Suppress logger output for asset requests. 65 | config.assets.quiet = true 66 | 67 | # Raises error for missing translations. 68 | # config.i18n.raise_on_missing_translations = true 69 | 70 | # Annotate rendered view with file names. 71 | config.action_view.annotate_rendered_view_with_filenames = true 72 | 73 | # Uncomment if you wish to allow Action Cable access from any origin. 74 | # config.action_cable.disable_request_forgery_protection = true 75 | 76 | # Raise error when a before_action's only/except options reference missing actions. 77 | config.action_controller.raise_on_missing_callback_actions = true 78 | 79 | # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. 80 | # config.generators.apply_rubocop_autocorrect_after_generate! 81 | end 82 | -------------------------------------------------------------------------------- /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. This eager loads most of Rails and 10 | # your application in memory, allowing both threaded web servers 11 | # and those relying on copy on write to perform better. 12 | # Rake tasks automatically ignore this option for performance. 13 | config.eager_load = true 14 | 15 | # Full error reports are disabled and caching is turned on. 16 | config.consider_all_requests_local = false 17 | config.action_controller.perform_caching = true 18 | 19 | # Ensures that a master key has been made available in ENV["RAILS_MASTER_KEY"], config/master.key, or an environment 20 | # key such as config/credentials/production.key. This key is used to decrypt credentials (and other encrypted files). 21 | # config.require_master_key = true 22 | 23 | # Disable serving static files from `public/`, relying on NGINX/Apache to do so instead. 24 | # config.public_file_server.enabled = false 25 | 26 | # Compress CSS using a preprocessor. 27 | # config.assets.css_compressor = :sass 28 | 29 | # Do not fall back to assets pipeline if a precompiled asset is missed. 30 | config.assets.compile = false 31 | 32 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 33 | # config.asset_host = "http://assets.example.com" 34 | 35 | # Specifies the header that your server uses for sending files. 36 | # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache 37 | # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX 38 | 39 | # Store uploaded files on the local file system (see config/storage.yml for options). 40 | config.active_storage.service = :local 41 | 42 | # Mount Action Cable outside main process or domain. 43 | # config.action_cable.mount_path = nil 44 | # config.action_cable.url = "wss://example.com/cable" 45 | # config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ] 46 | 47 | # Assume all access to the app is happening through a SSL-terminating reverse proxy. 48 | # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies. 49 | # config.assume_ssl = true 50 | 51 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 52 | config.force_ssl = true 53 | 54 | # Skip http-to-https redirect for the default health check endpoint. 55 | # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } 56 | 57 | # Log to STDOUT by default 58 | config.logger = ActiveSupport::Logger.new(STDOUT) 59 | .tap { |logger| logger.formatter = ::Logger::Formatter.new } 60 | .then { |logger| ActiveSupport::TaggedLogging.new(logger) } 61 | 62 | # Prepend all log lines with the following tags. 63 | config.log_tags = [ :request_id ] 64 | 65 | # "info" includes generic and useful information about system operation, but avoids logging too much 66 | # information to avoid inadvertent exposure of personally identifiable information (PII). If you 67 | # want to log everything, set the level to "debug". 68 | config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") 69 | 70 | # Use a different cache store in production. 71 | # config.cache_store = :mem_cache_store 72 | 73 | # Use a real queuing backend for Active Job (and separate queues per environment). 74 | # config.active_job.queue_adapter = :resque 75 | # config.active_job.queue_name_prefix = "offline_todos_production" 76 | 77 | # Disable caching for Action Mailer templates even if Action Controller 78 | # caching is enabled. 79 | config.action_mailer.perform_caching = false 80 | 81 | # Ignore bad email addresses and do not raise email delivery errors. 82 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 83 | # config.action_mailer.raise_delivery_errors = false 84 | 85 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 86 | # the I18n.default_locale when a translation cannot be found). 87 | config.i18n.fallbacks = true 88 | 89 | # Don't log any deprecations. 90 | config.active_support.report_deprecations = false 91 | 92 | # Do not dump schema after migrations. 93 | config.active_record.dump_schema_after_migration = false 94 | 95 | # Enable DNS rebinding protection and other `Host` header attacks. 96 | # config.hosts = [ 97 | # "example.com", # Allow requests from example.com 98 | # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` 99 | # ] 100 | # Skip DNS rebinding protection for the default health check endpoint. 101 | # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } 102 | end 103 | -------------------------------------------------------------------------------- /config/environments/test.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | # The test environment is used exclusively to run your application's 4 | # test suite. You never need to work with it otherwise. Remember that 5 | # your test database is "scratch space" for the test suite and is wiped 6 | # and recreated between test runs. Don't rely on the data there! 7 | 8 | Rails.application.configure do 9 | # Settings specified here will take precedence over those in config/application.rb. 10 | 11 | # While tests run files are not watched, reloading is not necessary. 12 | config.enable_reloading = false 13 | 14 | # Eager loading loads your entire application. When running a single test locally, 15 | # this is usually not necessary, and can slow down your test suite. However, it's 16 | # recommended that you enable it in continuous integration systems to ensure eager 17 | # loading is working properly before deploying your code. 18 | config.eager_load = ENV["CI"].present? 19 | 20 | # Configure public file server for tests with Cache-Control for performance. 21 | config.public_file_server.headers = { "Cache-Control" => "public, max-age=#{1.hour.to_i}" } 22 | 23 | # Show full error reports and disable caching. 24 | config.consider_all_requests_local = true 25 | config.action_controller.perform_caching = false 26 | config.cache_store = :null_store 27 | 28 | # Render exception templates for rescuable exceptions and raise for other exceptions. 29 | config.action_dispatch.show_exceptions = :rescuable 30 | 31 | # Disable request forgery protection in test environment. 32 | config.action_controller.allow_forgery_protection = false 33 | 34 | # Store uploaded files on the local file system in a temporary directory. 35 | config.active_storage.service = :test 36 | 37 | # Disable caching for Action Mailer templates even if Action Controller 38 | # caching is enabled. 39 | config.action_mailer.perform_caching = false 40 | 41 | # Tell Action Mailer not to deliver emails to the real world. 42 | # The :test delivery method accumulates sent emails in the 43 | # ActionMailer::Base.deliveries array. 44 | config.action_mailer.delivery_method = :test 45 | 46 | # Unlike controllers, the mailer instance doesn't have any context about the 47 | # incoming request so you'll need to provide the :host parameter yourself. 48 | config.action_mailer.default_url_options = { host: "www.example.com" } 49 | 50 | # Print deprecation notices to the stderr. 51 | config.active_support.deprecation = :stderr 52 | 53 | # Raise exceptions for disallowed deprecations. 54 | config.active_support.disallowed_deprecation = :raise 55 | 56 | # Tell Active Support which deprecation messages to disallow. 57 | config.active_support.disallowed_deprecation_warnings = [] 58 | 59 | # Raises error for missing translations. 60 | # config.i18n.raise_on_missing_translations = true 61 | 62 | # Annotate rendered view with file names. 63 | # config.action_view.annotate_rendered_view_with_filenames = true 64 | 65 | # Raise error when a before_action's only/except options reference missing actions. 66 | config.action_controller.raise_on_missing_callback_actions = true 67 | end 68 | -------------------------------------------------------------------------------- /config/environments/wasm.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "production" 4 | 5 | Rails.application.configure do 6 | config.enable_reloading = false 7 | 8 | config.assume_ssl = false 9 | config.force_ssl = false 10 | 11 | # FIXME: Tags are not being reset right now 12 | config.log_tags = [] 13 | 14 | if ENV["DEBUG"] == "1" 15 | config.consider_all_requests_local = true 16 | config.action_dispatch.show_exceptions = :none 17 | config.log_level = :debug 18 | config.logger = Logger.new($stdout) 19 | end 20 | 21 | config.cache_store = :memory_store 22 | config.active_job.queue_adapter = :inline 23 | config.action_mailer.delivery_method = :null 24 | config.action_cable.url = "null://" 25 | 26 | if config.respond_to?(:active_storage) 27 | config.active_storage.variant_processor = :null 28 | end 29 | 30 | # Do not use the same secret key base in a local app (for security reasons) 31 | config.secret_key_base = "wasm-secret" 32 | # Use a different session cookie name to avoid conflicts 33 | config.session_store :cookie_store, key: "_local_session" 34 | end 35 | -------------------------------------------------------------------------------- /config/importmap.rb: -------------------------------------------------------------------------------- 1 | # Pin npm packages by running ./bin/importmap 2 | 3 | pin "application" 4 | pin "@hotwired/turbo", to: "@hotwired--turbo.js" # @8.0.10 5 | pin "@anycable/web", to: "@anycable--web.js" # @0.9.0 6 | pin "@anycable/core", to: "@anycable--core.js" # @0.9.1 7 | pin "nanoevents" # @7.0.1 8 | pin "@anycable/turbo-stream", to: "@anycable--turbo-stream.js" # @0.7.0 9 | -------------------------------------------------------------------------------- /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 | 9 | # Precompile additional assets. 10 | # application.js, application.css, and all non-JS/CSS in the app/assets 11 | # folder are already added. 12 | # Rails.application.config.assets.precompile += %w[ admin.js admin.css ] 13 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 8 | ] 9 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /config/initializers/permissions_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide HTTP permissions policy. For further 4 | # information see: https://developers.google.com/web/updates/2018/06/feature-policy 5 | 6 | # Rails.application.config.permissions_policy do |policy| 7 | # policy.camera :none 8 | # policy.gyroscope :none 9 | # policy.microphone :none 10 | # policy.usb :none 11 | # policy.fullscreen :self 12 | # policy.payment :self, "https://secure.example.com" 13 | # end 14 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | # The ideal number of threads per worker depends both on how much time the 9 | # application spends waiting for IO operations and on how much you wish to 10 | # to prioritize throughput over latency. 11 | # 12 | # As a rule of thumb, increasing the number of threads will increase how much 13 | # traffic a given process can handle (throughput), but due to CRuby's 14 | # Global VM Lock (GVL) it has diminishing returns and will degrade the 15 | # response time (latency) of the application. 16 | # 17 | # The default is set to 3 threads as it's deemed a decent compromise between 18 | # throughput and latency for the average Rails application. 19 | # 20 | # Any libraries that use a connection pool or another resource pool should 21 | # be configured to provide at least as many connections as the number of 22 | # threads. This includes Active Record's `pool` parameter in `database.yml`. 23 | threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) 24 | threads threads_count, threads_count 25 | 26 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 27 | port ENV.fetch("PORT", 3000) 28 | 29 | # Allow puma to be restarted by `bin/rails restart` command. 30 | plugin :tmp_restart 31 | 32 | # Specify the PID file. Defaults to tmp/pids/server.pid in development. 33 | # In other environments, only set the PID file if requested. 34 | pidfile ENV["PIDFILE"] if ENV["PIDFILE"] 35 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | resources :todos 3 | # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html 4 | 5 | # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. 6 | # Can be used by load balancers and uptime monitors to verify that the app is live. 7 | get "up" => "rails/health#show", as: :rails_health_check 8 | 9 | # Render dynamic PWA files from app/views/pwa/* 10 | get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker 11 | get "manifest" => "rails/pwa#manifest", as: :pwa_manifest 12 | 13 | # Defines the root path route ("/") 14 | root "todos#index" 15 | end 16 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /config/tailwind.config.js: -------------------------------------------------------------------------------- 1 | const defaultTheme = require('tailwindcss/defaultTheme') 2 | 3 | module.exports = { 4 | content: [ 5 | './public/*.html', 6 | './app/helpers/**/*.rb', 7 | './app/javascript/**/*.js', 8 | './app/views/**/*.{erb,haml,html,slim}' 9 | ], 10 | theme: { 11 | extend: { 12 | fontFamily: { 13 | sans: ['Inter var', ...defaultTheme.fontFamily.sans], 14 | }, 15 | }, 16 | }, 17 | plugins: [ 18 | require('@tailwindcss/forms'), 19 | require('@tailwindcss/typography'), 20 | require('@tailwindcss/container-queries'), 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /config/wasmify.yml: -------------------------------------------------------------------------------- 1 | output_dir: pwa/public 2 | 3 | # Specify the list of directories to be included into the final 4 | # app.wasm file. 5 | pack_directories: 6 | - app 7 | - lib 8 | - config 9 | - db 10 | - public 11 | 12 | # Specify the list of gems to skip during the base module compiliation. 13 | # Usually, you want to specify the gems with native extensions that are 14 | # not currently Wasm-compatible. 15 | exclude_gems: 16 | - bigdecimal 17 | - nio4r 18 | - io-console 19 | - psych 20 | - date 21 | -------------------------------------------------------------------------------- /db/migrate/20240927184423_create_todos.rb: -------------------------------------------------------------------------------- 1 | class CreateTodos < ActiveRecord::Migration[7.2] 2 | def change 3 | create_table :todos do |t| 4 | t.string :description 5 | t.boolean :completed 6 | 7 | t.timestamps 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /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[7.2].define(version: 2024_09_27_184423) do 14 | create_table "todos", force: :cascade do |t| 15 | t.string "description" 16 | t.boolean "completed" 17 | t.datetime "created_at", null: false 18 | t.datetime "updated_at", null: false 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/assets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palkan/rails-on-wasm-playground/342c70cba26f1c52688a9df3dce52bed5b9a657f/lib/assets/.keep -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palkan/rails-on-wasm-playground/342c70cba26f1c52688a9df3dce52bed5b9a657f/lib/tasks/.keep -------------------------------------------------------------------------------- /log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palkan/rails-on-wasm-playground/342c70cba26f1c52688a9df3dce52bed5b9a657f/log/.keep -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
    60 |
    61 |

    The page you were looking for doesn't exist.

    62 |

    You may have mistyped the address or the page may have moved.

    63 |
    64 |

    If you are the application owner check the logs for more information.

    65 |
    66 | 67 | 68 | -------------------------------------------------------------------------------- /public/406-unsupported-browser.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Your browser is not supported (406) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
    60 |
    61 |

    Your browser is not supported.

    62 |

    Please upgrade your browser to continue.

    63 |
    64 |
    65 | 66 | 67 | -------------------------------------------------------------------------------- /public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
    60 |
    61 |

    The change you wanted was rejected.

    62 |

    Maybe you tried to change something you didn't have access to.

    63 |
    64 |

    If you are the application owner check the logs for more information.

    65 |
    66 | 67 | 68 | -------------------------------------------------------------------------------- /public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
    60 |
    61 |

    We're sorry, but something went wrong.

    62 |
    63 |

    If you are the application owner check the logs for more information.

    64 |
    65 | 66 | 67 | -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palkan/rails-on-wasm-playground/342c70cba26f1c52688a9df3dce52bed5b9a657f/public/icon.png -------------------------------------------------------------------------------- /public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | -------------------------------------------------------------------------------- /pwa/README.md: -------------------------------------------------------------------------------- 1 | # PWA app to run a Rails Wasm app 2 | 3 | The app provides a Service Worker to serve requests through the Rails/Wasm app. 4 | 5 | ## Running locallly 6 | 7 | ```sh 8 | yarn install 9 | 10 | yarn dev 11 | ``` 12 | 13 | Then go to [http://localhost:5173](http://localhost:5173). 14 | 15 | > [!NOTE] 16 | > Use Chrome or another browser supporting [CookieStore API](https://caniuse.com/?search=cookiestore). 17 | 18 | ## Serving via HTTPS 19 | 20 | Although `localhost` should work fine for development, you can also run the app over `https://` for a more production-like experience (and to attach a worker to a custom domain). 21 | 22 | For that, we recommend using [puma-dev](https://github.com/puma/puma-dev). 23 | 24 | Install `puma-dev` and add the port 5173 to its configuration: 25 | 26 | ```sh 27 | echo "5173" > ~/.puma-dev/rails-wasm 28 | ``` 29 | 30 | Then, run the server as follows: 31 | 32 | ```sh 33 | yarn dev --host 0.0.0.0 34 | ``` 35 | 36 | Go to [https://rails-wasm.test](https://rails-wasm.test) to open the app. 37 | 38 | ## Credits 39 | 40 | The launcher HTML/JS is based on the [Yuta Saito](https://github.com/kateinoigakukun)'s work on [Mastodon in the browser](https://github.com/kateinoigakukun/mastodon/tree/katei/wasmify). 41 | -------------------------------------------------------------------------------- /pwa/boot.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Rails on Wasm | Launch 9 | 10 | 74 | 75 | 76 |

    Rails on Wasm 🚀

    77 |

    78 | Wait for the Service Worker to install and boot the app. This may 79 | take a while. 80 |

    81 | 82 |

    83 | 84 | 85 | 88 | 89 |

    90 | 91 |
    92 | 93 |
    
     94 |         
    95 |

    96 | 97 | * Please ensure you close all tabs of this app before rebooting 98 | to unregister the old Service Worker. 99 | 100 |

    101 | 102 | 103 | -------------------------------------------------------------------------------- /pwa/boot.js: -------------------------------------------------------------------------------- 1 | async function registerServiceWorker() { 2 | const oldRegistrations = await navigator.serviceWorker.getRegistrations(); 3 | for (const registration of oldRegistrations) { 4 | if (registration.installing.state === "installing") { 5 | return; 6 | } 7 | } 8 | 9 | const workerUrl = 10 | import.meta.env.MODE === "production" 11 | ? "/rails.sw.js" 12 | : "/dev-sw.js?dev-sw"; 13 | 14 | await navigator.serviceWorker.register(workerUrl, { 15 | scope: "/", 16 | type: "module", 17 | }); 18 | } 19 | 20 | async function boot({ bootMessage, bootProgress, bootConsoleOutput }) { 21 | if (!("serviceWorker" in navigator)) { 22 | console.error("Service Worker is not supported in this browser."); 23 | return; 24 | } 25 | 26 | if (!navigator.serviceWorker.controller) { 27 | await registerServiceWorker(); 28 | 29 | bootMessage.textContent = "Waiting for Service Worker to activate..."; 30 | } else { 31 | console.log("Service Worker already active."); 32 | } 33 | 34 | navigator.serviceWorker.addEventListener("message", function (event) { 35 | switch (event.data.type) { 36 | case "progress": { 37 | bootMessage.textContent = event.data.step; 38 | bootProgress.value = event.data.value; 39 | break; 40 | } 41 | case "console": { 42 | bootConsoleOutput.textContent += event.data.message + "\n"; 43 | break; 44 | } 45 | default: { 46 | console.log("Unknown message type:", event.data.type); 47 | } 48 | } 49 | }); 50 | 51 | return await navigator.serviceWorker.ready; 52 | } 53 | 54 | async function init() { 55 | const bootMessage = document.getElementById("boot-message"); 56 | const bootProgress = document.getElementById("boot-progress"); 57 | const bootConsoleOutput = document.getElementById("boot-console-output"); 58 | const registration = await boot({ 59 | bootMessage, 60 | bootProgress, 61 | bootConsoleOutput, 62 | }); 63 | if (!registration) { 64 | return; 65 | } 66 | bootMessage.textContent = "Service Worker Ready"; 67 | bootProgress.value = 100; 68 | 69 | const launchButton = document.getElementById("launch-button"); 70 | launchButton.disabled = false; 71 | launchButton.addEventListener("click", async function () { 72 | // Open in a new window 73 | window.open("/", "_blank"); 74 | }); 75 | 76 | const rebootButton = document.getElementById("reboot-button"); 77 | rebootButton.disabled = false; 78 | rebootButton.addEventListener("click", async function () { 79 | await registration.unregister(); 80 | window.location.reload(); 81 | }); 82 | 83 | const reloadButton = document.getElementById("reload-button"); 84 | reloadButton.disabled = false; 85 | reloadButton.addEventListener("click", async function () { 86 | registration.active.postMessage({ type: "reload-rails" }); 87 | }); 88 | 89 | const reloadDebugButton = document.getElementById("reload-debug-button"); 90 | reloadDebugButton.disabled = false; 91 | reloadDebugButton.addEventListener("click", async function () { 92 | registration.active.postMessage({ type: "reload-rails", debug: true }); 93 | }); 94 | } 95 | 96 | init(); 97 | -------------------------------------------------------------------------------- /pwa/database.js: -------------------------------------------------------------------------------- 1 | import sqlite3InitModule from "@sqlite.org/sqlite-wasm"; 2 | 3 | export const setupSQLiteDatabase = async () => { 4 | const sqlite3 = await sqlite3InitModule(); 5 | 6 | console.log("Running SQLite3 version", sqlite3.version.libVersion); 7 | // NOTE: This database is transient and will be lost if you uninstall the service worker (aka hard reset) 8 | const db = new sqlite3.oo1.DB("/railsdb.sqlite3", "ct"); 9 | return db; 10 | }; 11 | -------------------------------------------------------------------------------- /pwa/index.html: -------------------------------------------------------------------------------- 1 | 2 |

    Please follow this link.

    3 | -------------------------------------------------------------------------------- /pwa/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wasmify-rails-starter", 3 | "private": true, 4 | "version": "0.1.0", 5 | "type": "module", 6 | "packageManager": "yarn@1.22.22", 7 | "scripts": { 8 | "dev": "vite", 9 | "build": "vite build", 10 | "preview": "vite preview" 11 | }, 12 | "devDependencies": { 13 | "vite": "^4.4.5", 14 | "vite-plugin-pwa": "^0.20.5" 15 | }, 16 | "dependencies": { 17 | "wasmify-rails": "~> 0.2.0", 18 | "@sqlite.org/sqlite-wasm": "3.46.1-build3" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /pwa/rails.sw.js: -------------------------------------------------------------------------------- 1 | import { 2 | initRailsVM, 3 | Progress, 4 | registerSQLiteWasmInterface, 5 | RackHandler, 6 | } from "wasmify-rails"; 7 | 8 | import { setupSQLiteDatabase } from "./database.js"; 9 | 10 | let db = null; 11 | 12 | const initDB = async (progress) => { 13 | if (db) return db; 14 | 15 | progress?.updateStep("Initializing SQLite database..."); 16 | db = await setupSQLiteDatabase(); 17 | progress?.updateStep("SQLite database created."); 18 | 19 | return db; 20 | }; 21 | 22 | let vm = null; 23 | 24 | const initVM = async (progress, opts = {}) => { 25 | if (vm) return vm; 26 | 27 | if (!db) { 28 | await initDB(progress); 29 | } 30 | 31 | registerSQLiteWasmInterface(self, db); 32 | 33 | let redirectConsole = true; 34 | 35 | const env = []; 36 | 37 | vm = await initRailsVM("/app.wasm", { 38 | database: { adapter: "sqlite3_wasm" }, 39 | env, 40 | progressCallback: (step) => { 41 | progress?.updateStep(step); 42 | }, 43 | outputCallback: (output) => { 44 | if (!redirectConsole) return; 45 | progress?.notify(output); 46 | }, 47 | ...opts, 48 | }); 49 | 50 | // Ensure schema is loaded 51 | progress?.updateStep("Preparing database..."); 52 | vm.eval("ActiveRecord::Tasks::DatabaseTasks.prepare_all"); 53 | 54 | redirectConsole = false; 55 | 56 | return vm; 57 | }; 58 | 59 | const resetVM = () => { 60 | vm = null; 61 | }; 62 | 63 | const installApp = async () => { 64 | const progress = new Progress(); 65 | await progress.attach(self); 66 | 67 | await initDB(progress); 68 | await initVM(progress); 69 | }; 70 | 71 | self.addEventListener("activate", (event) => { 72 | console.log("[rails-web] Activate Service Worker"); 73 | }); 74 | 75 | self.addEventListener("install", (event) => { 76 | console.log("[rails-web] Install Service Worker"); 77 | event.waitUntil(installApp().then(() => self.skipWaiting())); 78 | }); 79 | 80 | const rackHandler = new RackHandler(initVM, { assumeSSL: true, async: false }); 81 | 82 | self.addEventListener("fetch", (event) => { 83 | const bootResources = ["/boot", "/boot.js", "/boot.html", "/rails.sw.js"]; 84 | 85 | if ( 86 | bootResources.find((r) => new URL(event.request.url).pathname.endsWith(r)) 87 | ) { 88 | console.log( 89 | "[rails-web] Fetching boot files from network:", 90 | event.request.url, 91 | ); 92 | event.respondWith(fetch(event.request.url)); 93 | return; 94 | } 95 | 96 | const viteResources = ["node_modules", "@vite"]; 97 | 98 | if (viteResources.find((r) => event.request.url.includes(r))) { 99 | console.log( 100 | "[rails-web] Fetching Vite files from network:", 101 | event.request.url, 102 | ); 103 | event.respondWith(fetch(event.request.url)); 104 | return; 105 | } 106 | 107 | return event.respondWith(rackHandler.handle(event.request)); 108 | }); 109 | 110 | self.addEventListener("message", async (event) => { 111 | console.log("[rails-web] Received worker message:", event.data); 112 | 113 | if (event.data.type === "reload-rails") { 114 | const progress = new Progress(); 115 | await progress.attach(self); 116 | 117 | progress.updateStep("Reloading Rails application..."); 118 | 119 | resetVM(); 120 | await initVM(progress, { debug: event.data.debug }); 121 | } 122 | }); 123 | -------------------------------------------------------------------------------- /pwa/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import { VitePWA } from "vite-plugin-pwa"; 3 | 4 | export default defineConfig({ 5 | server: { 6 | headers: { 7 | "Cross-Origin-Opener-Policy": "same-origin", 8 | "Cross-Origin-Embedder-Policy": "require-corp", 9 | }, 10 | }, 11 | optimizeDeps: { 12 | exclude: ["@sqlite.org/sqlite-wasm"], 13 | }, 14 | plugins: [ 15 | VitePWA({ 16 | srcDir: ".", 17 | filename: "rails.sw.js", 18 | strategies: "injectManifest", 19 | injectRegister: false, 20 | manifest: false, 21 | injectManifest: { 22 | injectionPoint: null, 23 | }, 24 | devOptions: { 25 | enabled: true, 26 | }, 27 | }), 28 | ], 29 | }); 30 | -------------------------------------------------------------------------------- /storage/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palkan/rails-on-wasm-playground/342c70cba26f1c52688a9df3dce52bed5b9a657f/storage/.keep -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/channels/application_cable/connection_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module ApplicationCable 4 | class ConnectionTest < ActionCable::Connection::TestCase 5 | # test "connects with cookies" do 6 | # cookies.signed[:user_id] = 42 7 | # 8 | # connect 9 | # 10 | # assert_equal connection.user_id, "42" 11 | # end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/controllers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palkan/rails-on-wasm-playground/342c70cba26f1c52688a9df3dce52bed5b9a657f/test/controllers/.keep -------------------------------------------------------------------------------- /test/controllers/todos_controller_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class TodosControllerTest < ActionDispatch::IntegrationTest 4 | setup do 5 | @todo = todos(:one) 6 | end 7 | 8 | test "should get index" do 9 | get todos_url 10 | assert_response :success 11 | end 12 | 13 | test "should get new" do 14 | get new_todo_url 15 | assert_response :success 16 | end 17 | 18 | test "should create todo" do 19 | assert_difference("Todo.count") do 20 | post todos_url, params: { todo: { completed: @todo.completed, description: @todo.description } } 21 | end 22 | 23 | assert_redirected_to todo_url(Todo.last) 24 | end 25 | 26 | test "should show todo" do 27 | get todo_url(@todo) 28 | assert_response :success 29 | end 30 | 31 | test "should get edit" do 32 | get edit_todo_url(@todo) 33 | assert_response :success 34 | end 35 | 36 | test "should update todo" do 37 | patch todo_url(@todo), params: { todo: { completed: @todo.completed, description: @todo.description } } 38 | assert_redirected_to todo_url(@todo) 39 | end 40 | 41 | test "should destroy todo" do 42 | assert_difference("Todo.count", -1) do 43 | delete todo_url(@todo) 44 | end 45 | 46 | assert_redirected_to todos_url 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/fixtures/files/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palkan/rails-on-wasm-playground/342c70cba26f1c52688a9df3dce52bed5b9a657f/test/fixtures/files/.keep -------------------------------------------------------------------------------- /test/fixtures/todos.yml: -------------------------------------------------------------------------------- 1 | # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html 2 | 3 | one: 4 | description: MyString 5 | completed: false 6 | 7 | two: 8 | description: MyString 9 | completed: false 10 | -------------------------------------------------------------------------------- /test/helpers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palkan/rails-on-wasm-playground/342c70cba26f1c52688a9df3dce52bed5b9a657f/test/helpers/.keep -------------------------------------------------------------------------------- /test/integration/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palkan/rails-on-wasm-playground/342c70cba26f1c52688a9df3dce52bed5b9a657f/test/integration/.keep -------------------------------------------------------------------------------- /test/mailers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palkan/rails-on-wasm-playground/342c70cba26f1c52688a9df3dce52bed5b9a657f/test/mailers/.keep -------------------------------------------------------------------------------- /test/models/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palkan/rails-on-wasm-playground/342c70cba26f1c52688a9df3dce52bed5b9a657f/test/models/.keep -------------------------------------------------------------------------------- /test/models/todo_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class TodoTest < ActiveSupport::TestCase 4 | # test "the truth" do 5 | # assert true 6 | # end 7 | end 8 | -------------------------------------------------------------------------------- /test/system/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palkan/rails-on-wasm-playground/342c70cba26f1c52688a9df3dce52bed5b9a657f/test/system/.keep -------------------------------------------------------------------------------- /test/system/todos_test.rb: -------------------------------------------------------------------------------- 1 | require "application_system_test_case" 2 | 3 | class TodosTest < ApplicationSystemTestCase 4 | setup do 5 | @todo = todos(:one) 6 | end 7 | 8 | test "visiting the index" do 9 | visit todos_url 10 | assert_selector "h1", text: "Todos" 11 | end 12 | 13 | test "should create todo" do 14 | visit todos_url 15 | click_on "New todo" 16 | 17 | check "Completed" if @todo.completed 18 | fill_in "Description", with: @todo.description 19 | click_on "Create Todo" 20 | 21 | assert_text "Todo was successfully created" 22 | click_on "Back" 23 | end 24 | 25 | test "should update Todo" do 26 | visit todo_url(@todo) 27 | click_on "Edit this todo", match: :first 28 | 29 | check "Completed" if @todo.completed 30 | fill_in "Description", with: @todo.description 31 | click_on "Update Todo" 32 | 33 | assert_text "Todo was successfully updated" 34 | click_on "Back" 35 | end 36 | 37 | test "should destroy Todo" do 38 | visit todo_url(@todo) 39 | click_on "Destroy this todo", match: :first 40 | 41 | assert_text "Todo was successfully destroyed" 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tmp/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palkan/rails-on-wasm-playground/342c70cba26f1c52688a9df3dce52bed5b9a657f/tmp/.keep -------------------------------------------------------------------------------- /tmp/pids/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palkan/rails-on-wasm-playground/342c70cba26f1c52688a9df3dce52bed5b9a657f/tmp/pids/.keep -------------------------------------------------------------------------------- /tmp/storage/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palkan/rails-on-wasm-playground/342c70cba26f1c52688a9df3dce52bed5b9a657f/tmp/storage/.keep -------------------------------------------------------------------------------- /vendor/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palkan/rails-on-wasm-playground/342c70cba26f1c52688a9df3dce52bed5b9a657f/vendor/.keep -------------------------------------------------------------------------------- /vendor/javascript/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palkan/rails-on-wasm-playground/342c70cba26f1c52688a9df3dce52bed5b9a657f/vendor/javascript/.keep -------------------------------------------------------------------------------- /vendor/javascript/@anycable--core.js: -------------------------------------------------------------------------------- 1 | import{createNanoEvents as e}from"nanoevents";class ReasonError extends Error{constructor(e,t){if(e instanceof Error){super(e.message);this.cause=e}else super(e);this.reason=t;this.name="ReasonError"}}class SubscriptionRejectedError extends ReasonError{constructor(e){super("Rejected",e);this.name="SubscriptionRejectedError"}}class SubscriptionTimeoutError extends ReasonError{constructor(e){super(e||"Timed out to receive subscription ack");this.name="SubscriptionTimeoutError"}}class DisconnectedError extends ReasonError{constructor(e,t){t?super(e,t):super("Disconnected",e);this.name="DisconnectedError"}}class CommandError extends Error{constructor(e){super(e);this.name="CommandError"}}class StaleConnectionError extends DisconnectedError{constructor(e){super(e,"stale_connection");this.name="StaleConnectionError"}}function stringifyParams(e){if(!e)return"";let t=Object.keys(e).sort().filter((t=>e[t]!==void 0)).map((t=>{let s=JSON.stringify(e[t]);return`${JSON.stringify(t)}:${s}`}));return`{${t.join(",")}}`}const t=Symbol("state");class Channel{constructor(s={}){this.emitter=e();this.params=Object.freeze(s);this.initialConnect=true;this[t]="idle"}get identifier(){if(this._identifier)return this._identifier;this._identifier=stringifyParams({channel:this.channelId,...this.params});return this._identifier}get channelId(){return this.constructor.identifier}get state(){return this[t]}attached(e){if(this.receiver){if(this.receiver!==e)throw Error("Already connected to a different receiver");return false}this.receiver=e;return true}connecting(){this[t]="connecting"}connected(){if(this.state==="connected")return;if(this.state==="closed")return;this[t]="connected";let e=false;if(this.initialConnect){this.initialConnect=false;this.emit("connect",{reconnect:false,restored:e})}else this.emit("connect",{reconnect:true,restored:e})}restored(){if(this.state==="connected")throw Error("Already connected");this[t]="connected";let e=true;let s=true;this.initialConnect=false;this.emit("connect",{reconnect:s,restored:e})}disconnected(e){if(this.state!=="disconnected"&&this.state!=="closed"){this[t]="disconnected";this.emit("disconnect",e)}}closed(e){if(this.state!=="closed"){this[t]="closed";delete this.receiver;this.initialConnect=true;this.emit("close",e)}}disconnect(){this.state!=="idle"&&this.state!=="closed"&&this.receiver.unsubscribe(this)}async perform(e,t){if(this.state==="idle"||this.state==="closed")throw Error("Channel is not subscribed");return this.receiver.perform(this.identifier,e,t)}async send(e){return this.perform(void 0,e)}async whisper(e){try{await this.perform("$whisper",e)}catch(e){let t=this.receiver?this.receiver.logger:null;t&&t.warn("whisper failed: ",e)}}receive(e,t){this.emit("message",e,t)}on(e,t){return this.emitter.on(e,t)}once(e,t){let s=this.emitter.on(e,((...e)=>{s();t(...e)}));return s}emit(e,...t){return this.emitter.emit(e,...t)}ensureSubscribed(){return this.state==="connected"?Promise.resolve():this.state==="closed"?Promise.reject(Error("Channel is unsubscribed")):this.pendingSubscribe()}pendingSubscribe(){if(this._pendingSubscribe)return this._pendingSubscribe;this._pendingSubscribe=new Promise(((e,t)=>{let s=[()=>delete this._pendingSubscribe];s.push(this.on("connect",(()=>{s.forEach((e=>e()));e()})));s.push(this.on("close",(e=>{s.forEach((e=>e()));t(e||new ReasonError("Channel was disconnected before subscribing","canceled"))})))}));return this._pendingSubscribe}}class Subscription{constructor(e){this.id=e;this.intent="unsubscribed";this.state="idle";this.channels=[];this.disposed=false;this._pendings=[]}add(e){this.channels.includes(e)||this.channels.push(e)}remove(e){let t=this.channels.indexOf(e);t>-1&&this.channels.splice(t,1)}notify(e,...t){this.state=e==="restored"?"connected":e;t.length===1?this.channels.forEach((s=>s[e](t[0]))):this.channels.forEach((t=>t[e]()))}pending(e){this._checkIntent(e);let t=this._pendings[0];return t&&t.intent===e?t.promise:Promise.resolve()}ensureResubscribed(){if(!this.disposed){this.intent=void 0;this.ensureSubscribed()}}ensureSubscribed(){if(this.intent==="subscribed")return;if(this.disposed)throw Error("Subscription is disposed");this.intent="subscribed";let e=this._mergeWithPending("unsubscribed");e||this.subscriber(this)}maybeUnsubscribe(){if(this.disposed)return;if(this.intent==="unsubscribed")return;if(this.channels.length>0)return;this.intent="unsubscribed";let e=this._mergeWithPending("subscribed");e||this.unsubscriber(this)}async acquire(e){this._checkIntent(e);let t;let s=new Promise((e=>{t=e}));let i={promise:s,intent:e,release:()=>{this._pendings.splice(this._pendings.indexOf(i),1);t(i)},canceled:false,acquired:false};let n=this._pendingTop;this._pendings.push(i);n&&await n.promise;this.gvl&&await this.gvl.acquire(i,e);i.acquired=true;return i}close(e){this.disposed=true;this.intent=void 0;this.notify("closed",e)}_checkIntent(e){if(e!=="unsubscribed"&&e!=="subscribed")throw Error(`Unknown subscription intent: ${e}`)}get _pendingTop(){return this._pendings.length?this._pendings[this._pendings.length-1]:void 0}_mergeWithPending(e){let t=this._pendingTop;if(!t)return false;if(t.acquired)return false;if(t.intent!==e)return false;this._pendings.pop();t.canceled=true;return true}}class GlobalLock{constructor(){this.queue=[]}async acquire(e,t){if(t==="subscribed"){this.queue.push(e.promise.then((()=>{this.queue.splice(this.queue.indexOf(e),1)})));this.queue.length>1&&await this.queue[this.queue.length-2]}}}class Subscriptions{constructor(e){e.concurrentSubscribes===false&&(this.glv=new GlobalLock);this._subscriptions={};this._localToRemote={}}all(){return Object.values(this._subscriptions)}get(e){return this._subscriptions[e]}create(e,{subscribe:t,unsubscribe:s}){let i=this._subscriptions[e]=new Subscription(e);i.remoteId=this._localToRemote[e];i.subscriber=t;i.unsubscriber=s;i.gvl=this.glv;return i}remove(e){delete this._subscriptions[e];delete this._localToRemote[e]}storeRemoteId(e,t){this._localToRemote[e]=t;let s=this.get(e);s&&(s.remoteId=t)}}class Hub{constructor(e={}){this.subscriptions=new Subscriptions(e);this._pendingMessages=[];this._remoteToLocal={}}subscribe(e,t){this._remoteToLocal[t]=e;this.subscriptions.storeRemoteId(e,t);this.flush(t)}unsubscribe(e){let t=this.subscriptions.get(e);if(!t)return;let s=t.remoteId;s&&delete this._remoteToLocal[s];this.subscriptions.remove(e)}transmit(e,t,s){let i=this._remoteToLocal[e];if(!i){this._pendingMessages.push([e,t,s]);return}let n=this.subscriptions.get(i);n&&n.channels.forEach((e=>{e.receive(t,s)}))}close(){this._pendingMessages.length=0}get size(){return this.channels.length}get channels(){return this.subscriptions.all().flatMap((e=>e.channels))}flush(e){let t=[];for(let s of this._pendingMessages)s[0]===e?this.transmit(s[0],s[1],s[2]):t.push(s);this._pendingMessages=t}}const s={debug:0,info:1,warn:2,error:3};class BaseLogger{constructor(e){this.level=e||"warn"}log(e,t,i){s[e]{setTimeout((()=>{i(this.subscribe(e,t))}),s)}))}if(this.pendingSubscriptions[n]){this.logger.warn("subscription is already pending, skipping",n);return Promise.reject(Error("Already subscribing"))}let r=this.subscribeRetryInterval;return new Promise(((e,t)=>{let s=++i;this.pendingSubscriptions[n]={resolve:e,reject:t,id:s};this.cable.send(this.buildSubscribeRequest(n));this.maybeRetrySubscribe(s,n,r)}))}buildSubscribeRequest(e){return{command:"subscribe",identifier:e}}maybeRetrySubscribe(e,t,s){setTimeout((()=>{let i=this.pendingSubscriptions[t];if(i&&i.id===e){this.logger.warn(`no subscription ack received in ${s}ms, retrying subscribe`,t);this.cable.send(this.buildSubscribeRequest(t));this.maybeExpireSubscribe(e,t,s)}}),s)}maybeExpireSubscribe(e,t,s){setTimeout((()=>{let i=this.pendingSubscriptions[t];if(i&&i.id===e){delete this.pendingSubscriptions[t];i.reject(new SubscriptionTimeoutError(`Haven't received subscription ack in ${s*2}ms for ${t}`))}}),s)}unsubscribe(e){this.cable.send({command:"unsubscribe",identifier:e});this.pendingUnsubscriptions[e]=true;setTimeout((()=>{delete this.pendingUnsubscriptions[e]}),this.subscribeCooldownInterval);return Promise.resolve()}perform(e,t,s){if(t==="$whisper")return this.whisper(e,s);s||(s={});s.action=t;this.cable.send({command:"message",identifier:e,data:JSON.stringify(s)});return Promise.resolve()}whisper(e,t){this.cable.send({command:"whisper",identifier:e,data:t});return Promise.resolve()}receive(e){if(typeof e!=="object"){this.logger.error("unsupported message format",{message:e});return}let{type:t,identifier:s,message:i,reason:n,reconnect:r}=e;if(t==="ping")return this.cable.keepalive(e.message);this.cable.keepalive();if(t==="welcome"){let t=e.sid;t&&this.cable.setSessionId(t);return this.cable.connected()}if(t!=="disconnect"){if(t==="confirm_subscription"){let e=this.pendingSubscriptions[s];if(!e){this.logger.error("subscription not found, unsubscribing",{type:t,identifier:s});this.unsubscribe(s);return}delete this.pendingSubscriptions[s];return e.resolve(s)}if(t==="reject_subscription"){let e=this.pendingSubscriptions[s];if(!e)return this.logger.error("subscription not found",{type:t,identifier:s});delete this.pendingSubscriptions[s];return e.reject(new SubscriptionRejectedError)}if(i)return{identifier:s,message:i};this.logger.warn(`unknown message type: ${t}`,{message:e})}else{let e=new DisconnectedError(n);this.reset(e);r===false?this.cable.closed(e):this.cable.disconnected(e)}}reset(e){for(let t in this.pendingSubscriptions)this.pendingSubscriptions[t].reject(e);this.pendingSubscriptions={}}recoverableClosure(){return false}}const now$1=()=>Date.now()/1e3|0;class ActionCableExtendedProtocol extends ActionCableProtocol{constructor(e={}){super(e);this.streamsPositions={};this.subscriptionStreams={};this.pendingHistory={};this.restoreSince=e.historyTimestamp;this.restoreSince===void 0&&(this.restoreSince=now$1());this.sessionId=void 0;this.sendPongs=e.pongs}receive(e){if(typeof e!=="object"){this.logger.error("unsupported message format",{message:e});return}let{type:t,identifier:s,message:i}=e;if(t==="disconnect"||t==="reject_subscription")return super.receive(e);if(t==="confirm_subscription"){this.subscriptionStreams[s]||(this.subscriptionStreams[s]=new Set);return super.receive(e)}if(t==="ping"){!this.restoreSince===false&&(this.restoreSince=now$1());this.sendPongs&&this.sendPong();return this.cable.keepalive(e.message)}this.cable.keepalive();if(t!=="confirm_history")if(t!=="reject_history"){if(t==="welcome"){this.sessionId=e.sid;this.sessionId&&this.cable.setSessionId(this.sessionId);if(e.restored){let t=e.restored_ids||Object.keys(this.subscriptionStreams);for(let e of t)this.cable.send({identifier:e,command:"history",history:this.historyRequestFor(e)});return this.cable.restored(t)}return this.cable.connected(this.sessionId)}if(i){let t=this.trackStreamPosition(s,e.stream_id,e.epoch,e.offset);return{identifier:s,message:i,meta:t}}this.logger.warn(`unknown message type: ${t}`,{message:e})}else{this.logger.warn("failed to retrieve history",e);this.cable.notify("history_not_found",s)}else{this.logger.debug("history result received",e);this.cable.notify("history_received",s)}}buildSubscribeRequest(e){let t=super.buildSubscribeRequest(e);let s=this.historyRequestFor(e);if(s){t.history=s;this.pendingHistory[e]=true}return t}recoverableClosure(){return!!this.sessionId}historyRequestFor(e){let t={};let s=false;if(this.subscriptionStreams[e])for(let i of this.subscriptionStreams[e]){let e=this.streamsPositions[i];if(e){s=true;t[i]=e}}if(s||this.restoreSince)return{since:this.restoreSince,streams:t}}trackStreamPosition(e,t,s,i){if(t&&s){this.subscriptionStreams[e]||(this.subscriptionStreams[e]=new Set);this.subscriptionStreams[e].add(t);this.streamsPositions[t]={epoch:s,offset:i};return{stream:t,epoch:s,offset:i}}}async sendPong(){await new Promise((e=>setTimeout(e,0)));this.cable.state==="connected"&&this.cable.send({command:"pong"})}}class NoConnectionError extends ReasonError{constructor(){super("No connection","closed");this.name="NoConnectionError"}}class GhostChannel extends Channel{static identifier="__ghost__";constructor(e,t){super(t);this.channelId=e}set channelId(e){this._channelId=e}get channelId(){return this._channelId}}const n="$pubsub";class PubSubChannel extends Channel{static identifier=n;async perform(e,t){if(e==="$whisper")return super.perform(e,t);throw Error("not implemented")}}const r=Symbol("state");class Cable{constructor({transport:t,protocol:s,encoder:i,logger:n,lazy:o,hubOptions:c}){this.emitter=e();this.transport=t;this.encoder=i;this.logger=n||new NoopLogger;this.protocol=s;this.protocol.attached(this);this.hub=new Hub(c||{});this[r]="idle";this.handleClose=this.handleClose.bind(this);this.handleIncoming=this.handleIncoming.bind(this);this.transport.on("close",this.handleClose);this.transport.on("data",this.handleIncoming);this.initialConnect=true;this.recovering=false;o===false&&this.connect().catch((()=>{}))}get state(){return this[r]}async connect(){if(this.state==="connected")return Promise.resolve();if(this.state==="connecting")return this.pendingConnect();this[r]="connecting";let e=this.pendingConnect();this.logger.debug("connecting");try{await this.transport.open()}catch(e){this.handleClose(e)}return e}setSessionId(e){this.sessionId=e;this.transport.setParam("sid",e)}connected(){if(this.state==="connected")return;this.logger.info("connected");this[r]="connected";this.recovering&&this.hub.subscriptions.all().forEach((e=>e.notify("disconnected",new DisconnectedError("recovery_failed"))));this.hub.subscriptions.all().forEach((e=>this._resubscribe(e)));let e=false;this.recovering=false;if(this.initialConnect){this.initialConnect=false;this.emit("connect",{reconnect:false,restored:e})}else this.emit("connect",{reconnect:true,restored:e})}restored(e){this.logger.info("connection recovered",{remoteIds:e});this[r]="connected";this.hub.subscriptions.all().forEach((t=>{if(e&&t.remoteId&&e.includes(t.remoteId))t.notify("restored");else{t.notify("disconnected",new DisconnectedError("recovery_failed"));this._resubscribe(t)}}));let t=!this.initialConnect;let s=true;this.recovering=false;this.initialConnect=false;this.emit("connect",{reconnect:t,restored:s})}notify(e,t,s){if(t&&typeof t!=="string"){s=t;t=void 0}if(t){let i=this.hub.subscriptions.get(t);i&&i.channels.forEach((t=>t.emit("info",{type:e,data:s})))}else this.emit("info",{type:e,data:s})}handleClose(e){this.logger.debug("transport closed",{error:e});this.disconnected(new DisconnectedError(e,"transport_closed"))}disconnected(e){if(this.state==="connected"||this.state==="connecting"){this.logger.info("disconnected",{reason:e});this[r]="disconnected";this.recovering=this.protocol.recoverableClosure(e);this.recovering?this.hub.subscriptions.all().forEach((e=>e.notify("connecting"))):this.hub.subscriptions.all().forEach((t=>{t.notify("disconnected",e)}));this.protocol.reset(e);this.hub.close();this.transport.close();this.emit("disconnect",e)}}closed(e){if(this.state==="closed"||this.state==="idle")return;let t;e&&(t=e instanceof DisconnectedError?e:new DisconnectedError(e,void 0));this.logger.info("closed",{reason:e||"user"});this[r]="closed";let s=t||new DisconnectedError("cable_closed");this.hub.subscriptions.all().forEach((e=>e.notify("disconnected",s)));this.hub.close();this.protocol.reset();this.transport.close();this.initialConnect=true;this.emit("close",t)}disconnect(){this.closed()}handleIncoming(e){if(this.state==="closed"||this.state==="idle")return;let t=this.encoder.decode(e);if(t===void 0){this.logger.error("failed to decode message",{message:e});return}this.logger.debug("incoming data",t);let s=this.protocol.receive(t);if(s){this.logger.debug("processed incoming message",s);let{identifier:e,message:t,meta:i}=s;this.hub.transmit(e,t,i)}}send(e){if(this.state==="closed")throw Error("Cable is closed");let t=this.encoder.encode(e);if(t!==void 0){this.logger.debug("outgoing message",e);this.transport.send(t)}else this.logger.error("failed to encode message",{message:e})}keepalive(e){this.emit("keepalive",e)}streamFrom(e){let t=new PubSubChannel({stream_name:e});return this.subscribe(t)}streamFromSigned(e){let t=new PubSubChannel({signed_stream_name:e});return this.subscribe(t)}subscribeTo(e,t){let s;let i;if(typeof e==="string"){i=e;e=GhostChannel}s=i?new e(i,t):new e(t);return this.subscribe(s)}subscribe(e){if(!e.attached(this))return e;let t=e.identifier;e.connecting();let s=this.hub.subscriptions.get(t)||this.hub.subscriptions.create(t,{subscribe:t=>this._subscribe(t,e.channelId,e.params),unsubscribe:e=>this._unsubscribe(e)});s.add(e);s.intent==="subscribed"&&s.state==="connected"&&e.connected();s.ensureSubscribed();return e}async _resubscribe(e){if(e.intent!=="subscribed")return;let t=e.channels[0];if(t){e.notify("connecting");e.ensureResubscribed()}}async _subscribe(e,t,s){let i=e.id;this.state==="idle"&&this.connect().catch((()=>{}));if(this.state!=="connected"){this.logger.debug("cancel subscribe, no connection",{identifier:i});return}this.logger.debug("acquiring subscribe lock",{identifier:i});let n=await e.acquire("subscribed");if(n.canceled){this.logger.debug("subscribe lock has been canceled",{identifier:i});n.release();return}this.logger.debug("subscribe lock has been acquired",{identifier:i});if(e.intent!=="subscribed"){this.logger.debug("cancel subscribe request, already unsubscribed");n.release();return}if(this.state!=="connected"){this.logger.debug("cancel subscribe, no connection",{identifier:i});n.release();return}if(e.state==="connected"){this.logger.debug("already connected, skip subscribe command",{identifier:i});e.notify("connected");n.release();return}let r={identifier:t,params:s};this.logger.debug("subscribing",r);try{let n=await this.protocol.subscribe(t,s);this.hub.subscribe(i,n);this.logger.debug("subscribed",{...r,remoteId:n});e.notify("connected")}catch(t){if(t){t instanceof SubscriptionRejectedError&&this.logger.warn("rejected",r);if(t instanceof DisconnectedError){this.logger.debug("disconnected during subscription; will retry on connect",r);n.release();return}this.logger.error("failed to subscribe",{error:t,...r})}e.close(t);this.hub.unsubscribe(i)}n.release()}unsubscribe(e){let t=e.identifier;let s=this.hub.subscriptions.get(t);if(!s)throw Error(`Subscription not found: ${t}`);s.remove(e);e.closed();s.maybeUnsubscribe()}async _unsubscribe(e){let t=e.id;this.logger.debug("acquiring unsubscribe lock",{identifier:t});let s=await e.acquire("unsubscribed");if(s.canceled){this.logger.debug("unsubscribe lock has been canceled",{identifier:t});s.release();return}this.logger.debug("unsubscribe lock has been acquired",{identifier:t});if(e.intent!=="unsubscribed"){this.logger.debug("cancel unsubscribe, no longer needed",{identifier:t,intent:e.intent});s.release();return}if(e.state==="disconnected"||e.state==="closed"){this.logger.debug(`already ${e.state} connected, skip unsubscribe command`,{identifier:t});s.release();return}let i=e.remoteId;this.logger.debug("unsubscribing...",{remoteId:i});if(this.state==="connected"){try{await this.protocol.unsubscribe(i);this.logger.debug("unsubscribed remotely",{id:t})}catch(e){e&&(e instanceof DisconnectedError?this.logger.debug("cable disconnected during the unsubscribe command execution",{id:t,error:e}):this.logger.error("unsubscribe failed",{id:t,error:e}))}if(e.intent==="unsubscribed"){e.close();this.hub.unsubscribe(t)}else e.state="closed";s.release()}else{this.logger.debug("unsubscribe skipped (cable is not connected)",{id:t});e.close();this.hub.unsubscribe(t);s.release()}}async perform(e,t,s){this.state==="connecting"&&await this.pendingConnect();if(this.state==="closed"||this.state==="disconnected")throw new NoConnectionError;let i=this.hub.subscriptions.get(e);if(!i)throw Error(`Subscription not found: ${e}`);await i.pending("subscribed");if(i.intent!=="subscribed")throw Error(`Subscription is closed: ${e}`);let n=i.remoteId;let r={id:n,action:t,payload:s};this.logger.debug("perform",r);try{let e=await this.protocol.perform(n,t,s);e&&this.logger.debug("perform result",{message:e,request:r});return e}catch(e){this.logger.error("perform failed",{error:e,request:r});throw e}}on(e,t){return this.emitter.on(e,t)}once(e,t){let s=this.emitter.on(e,((...e)=>{s();t(...e)}));return s}emit(e,...t){return this.emitter.emit(e,...t)}pendingConnect(){if(this._pendingConnect)return this._pendingConnect;this._pendingConnect=new Promise(((e,t)=>{let s=[()=>delete this._pendingConnect];s.push(this.on("connect",(()=>{s.forEach((e=>e()));e()})));s.push(this.on("close",(e=>{s.forEach((e=>e()));t(e)})));s.push(this.on("disconnect",(e=>{s.forEach((e=>e()));t(e)})))}));return this._pendingConnect}}const o={maxMissingPings:2,maxReconnectAttempts:Infinity};const now=()=>Date.now();const backoffWithJitter=(e,t)=>{t=t||{};let{backoffRate:s,jitterRatio:i,maxInterval:n}=t;s=s||2;i===void 0&&(i=.5);return t=>{let r=e*s**t;let o=r*s;let c=r+(o-r)*Math.random();let h=2*(Math.random()-.5)*i;c*=1+h;n&&n{this.logger.info("Failed at reconnecting: "+e)}));return true}initListeners(){this.unbind=[];this.unbind.push(this.target.on("connect",(()=>{this.attempts=0;this.pingedAt=now();this.state="connected";this.cancelReconnect();this.startPolling()})));this.unbind.push(this.target.on("disconnect",(()=>{this.disconnectedAt=now();this.state="disconnected";this.stopPolling();this.scheduleReconnect()})));this.unbind.push(this.target.on("close",(()=>{this.disconnectedAt=now();this.state="closed";this.cancelReconnect();this.stopPolling()})));this.unbind.push(this.target.on("keepalive",(()=>{this.pingedAt=now()})));this.unbind.push((()=>{this.cancelReconnect();this.stopPolling()}))}dispose(){delete this.target;this.unbind&&this.unbind.forEach((e=>e()));delete this.unbind}startPolling(){this.pollId&&clearTimeout(this.pollId);let e=this.pingInterval+(Math.random()-.5)*this.pingInterval*.5;this.pollId=setTimeout((()=>{this.checkStale();this.state==="connected"&&this.startPolling()}),e)}stopPolling(){this.pollId&&clearTimeout(this.pollId)}checkStale(){let e=now()-this.pingedAt;if(e>this.maxMissingPings*this.pingInterval){this.logger.warn(`Stale connection: ${e}ms without pings`);this.state="pending_disconnect";this.target.disconnected(new StaleConnectionError)}}scheduleReconnect(){if(this.attempts>=this.maxReconnectAttempts){this.target.close();return}let e=this.strategy(this.attempts);this.attempts++;this.logger.info(`Reconnecting in ${e}ms (${this.attempts} attempt)`);this.state="pending_reconnect";this.reconnnectId=setTimeout((()=>this.reconnectNow()),e)}cancelReconnect(){if(this.reconnnectId){clearTimeout(this.reconnnectId);delete this.reconnnectId}}}class FallbackTransport{constructor(t,s={}){this.transports=t;this.transport=null;this.emitter=e();this.unbind=[];this.logger=s.logger||new NoopLogger}displayName(){return"fallbacked transport"}async open(){for(let e=0;e{s.setParam(e,t)}))}on(e,t){return this.emitter.on(e,t)}once(e,t){let s=this.emitter.on(e,((...e)=>{s();t(...e)}));return s}get url(){return this.transport?this.transport.url:""}resetListeners(){this.unbind.forEach((e=>e()));this.unbind.length=0;this.transport&&this.unbind.push(this.transport.on("open",(()=>{this.emitter.emit("open")})),this.transport.on("data",(e=>{this.emitter.emit("data",e)})),this.transport.on("close",(e=>{this.emitter.emit("close",e)})),this.transport.on("error",(e=>{this.emitter.emit("error",e)})))}}class WebSocketTransport{constructor(t,s={}){this.url=t;let i=s.websocketImplementation;if(i)this.Impl=i;else{if(typeof WebSocket==="undefined")throw new Error("No WebSocket support");this.Impl=WebSocket}this.connected=false;this.emitter=e();let{format:n,subprotocol:r}=s;this.format=n||"text";this.connectionOptions=s.websocketOptions;this.subprotocol=r}displayName(){return"WebSocket("+this.url+")"}open(){this.connectionOptions?this.ws=new this.Impl(this.url,this.subprotocol,this.connectionOptions):this.ws=new this.Impl(this.url,this.subprotocol);this.ws.binaryType="arraybuffer";this.initListeners();return new Promise(((e,t)=>{let s=[];s.push(this.once("open",(()=>{s.forEach((e=>e()));e()})));s.push(this.once("close",(()=>{s.forEach((e=>e()));t(Error("WS connection closed"))})))}))}setURL(e){this.url=e}setParam(e,t){let s=new URL(this.url);s.searchParams.set(e,t);let i=`${s.protocol}//${s.host}${s.pathname}?${s.searchParams}`;this.setURL(i)}send(e){if(!this.ws||!this.connected)throw Error("WebSocket is not connected");this.ws.send(e)}close(){this.ws?this.onclose():this.connected=false}on(e,t){return this.emitter.on(e,t)}once(e,t){let s=this.emitter.on(e,((...e)=>{s();t(...e)}));return s}initListeners(){this.ws.onerror=e=>{this.connected&&this.emitter.emit("error",e.error||new Error("WS Error"))};this.ws.onclose=()=>{this.onclose()};this.ws.onmessage=e=>{let t=e.data;this.format==="binary"&&(t=new Uint8Array(t));this.emitter.emit("data",t)};this.ws.onopen=()=>{this.connected=true;this.emitter.emit("open")}}onclose(){this.ws.onclose=void 0;this.ws.onmessage=void 0;this.ws.onopen=void 0;this.ws.close();delete this.ws;this.connected=false;this.emitter.emit("close")}}const c={protocol:"actioncable-v1-json",pingInterval:3e3,maxReconnectAttempts:Infinity,maxMissingPings:2,logLevel:"warn",lazy:true};function createCable(e,t){if(typeof e==="object"&&typeof t==="undefined"){t=e;e=void 0}t=t||{};if(!e&&!t.transport)throw Error("URL or transport must be specified");t=Object.assign({},c,t);let{protocol:s,websocketImplementation:i,websocketFormat:n,websocketOptions:r,fallbacks:o,logLevel:h,logger:a,transport:l,encoder:u,lazy:d,monitor:b,pingInterval:p,reconnectStrategy:g,maxMissingPings:f,maxReconnectAttempts:m,subprotocol:w,tokenRefresher:y,historyTimestamp:S,protocolOptions:v,concurrentSubscribes:E}=t;a=a||new NoopLogger(h);if(typeof s==="string"){w=w||s;let e=s.substring(0,s.lastIndexOf("-"));let t=s.substring(s.lastIndexOf("-")+1);v=v||{};if(e==="actioncable-v1")s=new ActionCableProtocol({logger:a,...v});else{if(e!=="actioncable-v1-ext")throw Error(`Protocol is not supported yet: ${s}`);s=new ActionCableExtendedProtocol({logger:a,historyTimestamp:S,...v})}if(t==="json"){u=u||new JSONEncoder;n=n||"text"}else if(t==="msgpack"){n="binary";if(!u)throw Error("Msgpack encoder must be specified explicitly. Use `@anycable/msgpack-encoder` package or build your own")}else{if(t!=="protobuf")throw Error(`Protocol is not supported yet: ${s}`);n=n||"binary";if(!u)throw Error("Protobuf encoder must be specified explicitly. Use `@anycable/protobuf-encoder` package or build your own")}}if(!s)throw Error("Protocol must be specified");l=l||new WebSocketTransport(e,{websocketImplementation:i,websocketOptions:r,subprotocol:w,format:n});o&&(l=new FallbackTransport([l,...o],{logger:a}));g=g||backoffWithJitter(p);b!==false&&(b=b||new Monitor({pingInterval:p,reconnectStrategy:g,maxMissingPings:f,maxReconnectAttempts:m,logger:a}));let C={concurrentSubscribes:E};let _=new Cable({protocol:s,transport:l,encoder:u,logger:a,lazy:d,hubOptions:C});if(b){b.watch(_);_.monitor=b}y&&watchForExpiredToken(_,(async()=>{try{await y(l)}catch(e){a.error("Failed to refresh authentication token: "+e);return false}_.connect().catch((()=>{}));return true}));return _}function watchForExpiredToken(e,t){let s=false;e.on("connect",(()=>s=false));e.on("close",(async i=>{if(i)if(s)e.logger.warn("Token auto-refresh is disabled",i);else if(i.reason==="token_expired"){s=true;await t()}}))}class ActionCableSubscription{constructor(e){this.channel=e}notify(e,...t){typeof this[e]==="function"&&this[e](...t)}perform(e,t={}){this.channel.perform(e,t)}send(e){this.channel.send(e)}get identifier(){return this.channel.identifier}unsubscribe(){return this.channel.disconnect()}}class ActionCableChannel extends GhostChannel{constructor(e,t,s){super(e,t);this.subscription=new ActionCableSubscription(this);Object.assign(this.subscription,s);this.on("connect",(()=>this.subscription.notify("connected")));this.on("disconnect",(()=>this.subscription.notify("disconnected",{allowReconnect:true})));this.on("message",(e=>this.subscription.notify("received",e)));this.on("close",(e=>{e&&e instanceof SubscriptionRejectedError?this.subscription.notify("rejected"):this.subscription.notify("disconnected",{allowReconnect:false})}))}}class ActionCableSubscriptions{constructor(e){this.cable=e}create(e,t){let s;let i;if(typeof e==="object"){s=e.channel;delete e.channel;i=e}else{s=e;i={}}let n=new ActionCableChannel(s,i,t);n.subscription.notify("initialized");this.cable.subscribe(n);return n.subscription}findAll(e){return this.cable.hub.channels.filter((t=>t.identifier===e)).map((e=>e.subscription))}}class ActionCableConsumer{constructor(e){this.cable=e;this.subscriptions=new ActionCableSubscriptions(e)}}function createConsumer(e,t){let s=createCable(e,t);return new ActionCableConsumer(s)}export{ActionCableConsumer,ActionCableExtendedProtocol,ActionCableProtocol,ActionCableSubscriptions,BaseLogger,Cable,Channel,CommandError,c as DEFAULT_OPTIONS,DisconnectedError,FallbackTransport,Hub,JSONEncoder,Monitor,NoConnectionError,NoopLogger,ReasonError,StaleConnectionError,Subscription,SubscriptionRejectedError,SubscriptionTimeoutError,Subscriptions,WebSocketTransport,backoffWithJitter,createCable,createConsumer,stringifyParams}; 2 | 3 | -------------------------------------------------------------------------------- /vendor/javascript/@anycable--turbo-stream.js: -------------------------------------------------------------------------------- 1 | import{Channel as e}from"@anycable/core";import{connectStreamSource as t,disconnectStreamSource as s}from"@hotwired/turbo";class TurboChannel extends e{static identifier="__turbo__";constructor(e,t,s){super(s);this.element=e;this.channelId=t}set channelId(e){this._channelId=e}get channelId(){return this._channelId}}function walk(e){return e&&typeof e==="object"?e instanceof Date||e instanceof RegExp?e:Array.isArray(e)?e.map(walk):Object.keys(e).reduce(((t,s)=>{let n=s[0].toLowerCase()+s.slice(1).replace(/([A-Z]+)/g,((e,t)=>"_"+t.toLowerCase()));t[n]=walk(e[s]);return t}),{}):e}function isPreview(){return document.documentElement.hasAttribute("data-turbo-preview")}class TurboStreamSourceElement extends HTMLElement{static cable;static channelClass;static delayedUnsubscribe;async connectedCallback(){t(this);if(isPreview())return;let e=this.constructor.cable;let s=this.constructor.channelClass;let n=this.getAttribute("channel");let a=this.getAttribute("signed-stream-name");let i=walk({...this.dataset});this.listeners=[];this.channel=new s(this,n,{signed_stream_name:a,...i});this.listeners.push(this.channel.on("connect",(()=>this.setAttribute("connected",""))));this.listeners.push(this.channel.on("disconnect",(()=>this.removeAttribute("connected"))));this.listeners.push(this.channel.on("message",this.dispatchMessageEvent.bind(this)));e.subscribe(this.channel)}disconnectedCallback(){s(this);if(this.channel){for(let e of this.listeners)e();this.listeners.length=0;let e=this.channel;let t=this.constructor.delayedUnsubscribe;t?setTimeout((()=>e.disconnect()),t):e.disconnect()}}dispatchMessageEvent(e){let t=new MessageEvent("message",{data:e});return this.dispatchEvent(t)}}const n="X-Socket-ID";function start(e,t={}){let s=t.tagName||"turbo-cable-stream-source";let a=t.channelClass||TurboChannel;let i=t.delayedUnsubscribe||0;i===true&&(i=300);let c=class extends TurboStreamSourceElement{};c.cable=e;c.channelClass=a;c.delayedUnsubscribe=i;customElements.get(s)===void 0&&customElements.define(s,c);if(t.requestSocketIDHeader){let s=t.requestSocketIDHeader===true?n:t.requestSocketIDHeader;document.addEventListener("turbo:before-fetch-request",(t=>{e.sessionId&&!t.detail.fetchOptions.headers[s]&&(t.detail.fetchOptions.headers[s]=e.sessionId)}))}}export{n as DEFAULT_SOCKET_HEADER,TurboChannel,start}; 2 | 3 | -------------------------------------------------------------------------------- /vendor/javascript/@anycable--web.js: -------------------------------------------------------------------------------- 1 | import{BaseLogger as e,Monitor as t,DisconnectedError as n,DEFAULT_OPTIONS as o,backoffWithJitter as i,createCable as r,ActionCableConsumer as s}from"@anycable/core";export{Channel}from"@anycable/core";class Logger extends e{writeLogEntry(e,t,n){n?console[e](t,n):console[e](t)}}class Monitor extends t{watch(e){super.watch(e);this.initActivityListeners()}initActivityListeners(){if(typeof document!=="undefined"&&typeof window!=="undefined"&&document.addEventListener&&window.addEventListener){let visibility=()=>{document.hidden||this.reconnectNow()&&this.logger.debug("Trigger reconnect due to visibility change")};let connect=e=>{this.reconnectNow()&&this.logger.debug("Trigger reconnect",{event:e})};let disconnectFrozen=()=>this.disconnect(new n("page_frozen"));document.addEventListener("visibilitychange",visibility,false);window.addEventListener("focus",connect,false);window.addEventListener("online",connect,false);window.addEventListener("resume",connect,false);window.addEventListener("freeze",disconnectFrozen,false);this.unbind.push((()=>{document.removeEventListener("visibilitychange",visibility,false);window.removeEventListener("focus",connect,false);window.removeEventListener("online",connect,false);window.removeEventListener("resume",connect,false);window.removeEventListener("freeze",disconnectFrozen,false)}))}}disconnect(e){if(this.state!=="disconnected"&&this.state!=="closed"){this.logger.info("Disconnecting",{reason:e.message});this.cancelReconnect();this.stopPolling();this.state="pending_disconnect";this.target.disconnected(e)}}}const c=["cable","action-cable"];const a="/cable";const fetchMeta=(e,t)=>{for(let n of c){let o=e.head.querySelector(`meta[name='${n}-${t}']`);if(o)return o.getAttribute("content")}};const absoluteWSUrl=e=>{if(e.match(/wss?:\/\//))return e;if(typeof window!=="undefined"){let t=window.location.protocol.replace("http","ws");return`${t}//${window.location.host}${e}`}return e};const generateUrlFromDOM=()=>{if(typeof document!=="undefined"&&document.head){let e=fetchMeta(document,"url");if(e)return absoluteWSUrl(e)}return absoluteWSUrl(a)};const historyTimestampFromMeta=()=>{if(typeof document!=="undefined"&&document.head){let e=fetchMeta(document,"history-timestamp");if(e)return e|0}};function createCable(e,t){if(typeof e==="object"&&typeof t==="undefined"){t=e;e=void 0}e=e||generateUrlFromDOM();t=t||{};t.historyTimestamp||=historyTimestampFromMeta();t=Object.assign({},o,t);let{logLevel:n,logger:s,pingInterval:c,reconnectStrategy:a,maxMissingPings:d,maxReconnectAttempts:l}=t;s=t.logger=t.logger||new Logger(n);a=t.reconnectStrategy=t.reconnectStrategy||i(c);t.monitor!==false&&(t.monitor=t.monitor||new Monitor({pingInterval:c,reconnectStrategy:a,maxMissingPings:d,maxReconnectAttempts:l,logger:s}));return r(e,t)}function createConsumer(e,t){let n=createCable(e,t);return new s(n)}function fetchTokenFromHTML(e){let t=e?e.url:void 0;if(!t){if(typeof window==="undefined")throw Error("An URL to fetch the HTML with a token MUST be specified");t=window.location.href}return async e=>{let n=await fetch(t,{credentials:"same-origin",cache:"no-cache",headers:{Accept:"text/html, application/xhtml+xml","X-ANYCABLE-OPERATION":"token-refresh"}});if(!n.ok)throw Error("Failed to fetch a page to refresh a token: "+n.status);let o=await n.text();let i=(new DOMParser).parseFromString(o,"text/html");let r=fetchMeta(i,"url");if(!r)throw Error("Couldn't find a token on the page");e.setURL(r)}}export{createCable,createConsumer,fetchTokenFromHTML}; 2 | 3 | -------------------------------------------------------------------------------- /vendor/javascript/nanoevents.js: -------------------------------------------------------------------------------- 1 | let createNanoEvents=()=>({events:{},emit(e,...t){let s=this.events[e]||[];for(let e=0,n=s.length;e{this.events[e]=this.events[e]?.filter((e=>t!==e))}}});export{createNanoEvents}; 2 | 3 | --------------------------------------------------------------------------------