├── .editorconfig ├── .github └── workflows │ ├── documentation-coverage.yaml │ ├── documentation.yaml │ ├── rubocop.yaml │ ├── test-coverage.yaml │ └── test.yaml ├── .gitignore ├── .rubocop.yml ├── bin └── lively ├── examples ├── chatbot │ ├── application.rb │ ├── conversation.rb │ ├── gems.locked │ ├── gems.rb │ ├── public │ │ └── _static │ │ │ └── index.css │ └── toolbox.rb ├── flappy-bird │ ├── application.rb │ ├── gems.locked │ ├── gems.rb │ ├── highscore.rb │ └── public │ │ └── _static │ │ ├── clink.mp3 │ │ ├── death.mp3 │ │ ├── flappy-background.png │ │ ├── flappy-bird.png │ │ ├── flappy-pipe.png │ │ ├── gemstone.gif │ │ ├── index.css │ │ ├── music.mp3 │ │ └── quack.mp3 ├── game-of-life │ ├── application.rb │ ├── gems.locked │ └── public │ │ └── _static │ │ └── index.css ├── hello-world │ ├── application.rb │ ├── gems.locked │ └── gems.rb ├── math-quest │ ├── application.rb │ ├── gems.locked │ └── gems.rb ├── tanks │ ├── application.rb │ ├── gems.locked │ ├── gems.rb │ ├── png.rb │ └── public │ │ └── _static │ │ └── index.css ├── worms-presentation │ ├── .cursor │ │ └── rules │ │ │ └── presentation.mdc │ ├── application.rb │ ├── gems.locked │ ├── gems.rb │ ├── public │ │ └── _static │ │ │ ├── index.css │ │ │ └── pickupCoin.wav │ └── readme.md └── worms │ ├── application.rb │ ├── gems.locked │ ├── gems.rb │ └── public │ └── _static │ └── index.css ├── gems.rb ├── lib ├── lively.rb └── lively │ ├── application.rb │ ├── assets.rb │ ├── environment │ └── application.rb │ ├── hello_world.rb │ ├── pages │ ├── index.rb │ └── index.xrb │ └── version.rb ├── license.md ├── lively.gemspec ├── node_modules ├── .package-lock.json ├── @socketry │ └── live │ │ ├── Live.js │ │ ├── package.json │ │ ├── readme.md │ │ └── test │ │ └── Live.js └── morphdom │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README.md │ ├── dist │ ├── morphdom-esm.js │ ├── morphdom-factory.js │ ├── morphdom-umd.js │ ├── morphdom-umd.min.js │ └── morphdom.js │ ├── docs │ ├── old-benchmark.md │ └── virtual-dom.md │ ├── factory.js │ ├── index.d.ts │ ├── package.json │ └── src │ ├── index.js │ ├── morphAttrs.js │ ├── morphdom.js │ ├── specialElHandlers.js │ └── util.js ├── package-lock.json ├── package.json ├── public ├── _components │ ├── @socketry │ │ └── live │ │ │ ├── Live.js │ │ │ ├── package.json │ │ │ ├── readme.md │ │ │ └── test │ │ │ └── Live.js │ └── morphdom │ │ ├── morphdom-esm.js │ │ ├── morphdom-factory.js │ │ ├── morphdom-umd.js │ │ ├── morphdom-umd.min.js │ │ └── morphdom.js └── _static │ ├── Falcon.png │ ├── icon.png │ ├── index.css │ └── site.css ├── readme.md ├── release.cert └── test └── lively.rb /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 2 6 | 7 | [*.{yml,yaml}] 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /.github/workflows/documentation-coverage.yaml: -------------------------------------------------------------------------------- 1 | name: Documentation Coverage 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | COVERAGE: PartialSummary 11 | 12 | jobs: 13 | validate: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: ruby/setup-ruby@v1 19 | with: 20 | ruby-version: "3.4" 21 | bundler-cache: true 22 | 23 | - name: Validate coverage 24 | timeout-minutes: 5 25 | run: bundle exec bake decode:index:coverage lib 26 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yaml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages: 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | # Allow one concurrent deployment: 15 | concurrency: 16 | group: "pages" 17 | cancel-in-progress: true 18 | 19 | env: 20 | CONSOLE_OUTPUT: XTerm 21 | BUNDLE_WITH: maintenance 22 | 23 | jobs: 24 | generate: 25 | runs-on: ubuntu-latest 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - uses: ruby/setup-ruby@v1 31 | with: 32 | ruby-version: "3.4" 33 | bundler-cache: true 34 | 35 | - name: Installing packages 36 | run: sudo apt-get install wget 37 | 38 | - name: Generate documentation 39 | timeout-minutes: 5 40 | run: bundle exec bake utopia:project:static --force no 41 | 42 | - name: Upload documentation artifact 43 | uses: actions/upload-pages-artifact@v3 44 | with: 45 | path: docs 46 | 47 | deploy: 48 | runs-on: ubuntu-latest 49 | 50 | environment: 51 | name: github-pages 52 | url: ${{steps.deployment.outputs.page_url}} 53 | 54 | needs: generate 55 | steps: 56 | - name: Deploy to GitHub Pages 57 | id: deployment 58 | uses: actions/deploy-pages@v4 59 | -------------------------------------------------------------------------------- /.github/workflows/rubocop.yaml: -------------------------------------------------------------------------------- 1 | name: RuboCop 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | 11 | jobs: 12 | check: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: ruby/setup-ruby@v1 18 | with: 19 | ruby-version: ruby 20 | bundler-cache: true 21 | 22 | - name: Run RuboCop 23 | timeout-minutes: 10 24 | run: bundle exec rubocop 25 | -------------------------------------------------------------------------------- /.github/workflows/test-coverage.yaml: -------------------------------------------------------------------------------- 1 | name: Test Coverage 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | COVERAGE: PartialSummary 11 | 12 | jobs: 13 | test: 14 | name: ${{matrix.ruby}} on ${{matrix.os}} 15 | runs-on: ${{matrix.os}}-latest 16 | 17 | strategy: 18 | matrix: 19 | os: 20 | - ubuntu 21 | - macos 22 | 23 | ruby: 24 | - "3.4" 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: ruby/setup-ruby@v1 29 | with: 30 | ruby-version: ${{matrix.ruby}} 31 | bundler-cache: true 32 | 33 | - name: Run tests 34 | timeout-minutes: 5 35 | run: bundle exec bake test 36 | 37 | - uses: actions/upload-artifact@v4 38 | with: 39 | include-hidden-files: true 40 | if-no-files-found: error 41 | name: coverage-${{matrix.os}}-${{matrix.ruby}} 42 | path: .covered.db 43 | 44 | validate: 45 | needs: test 46 | runs-on: ubuntu-latest 47 | 48 | steps: 49 | - uses: actions/checkout@v4 50 | - uses: ruby/setup-ruby@v1 51 | with: 52 | ruby-version: "3.4" 53 | bundler-cache: true 54 | 55 | - uses: actions/download-artifact@v4 56 | 57 | - name: Validate coverage 58 | timeout-minutes: 5 59 | run: bundle exec bake covered:validate --paths */.covered.db \; 60 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | 11 | jobs: 12 | test: 13 | name: ${{matrix.ruby}} on ${{matrix.os}} 14 | runs-on: ${{matrix.os}}-latest 15 | continue-on-error: ${{matrix.experimental}} 16 | 17 | strategy: 18 | matrix: 19 | os: 20 | - ubuntu 21 | - macos 22 | 23 | ruby: 24 | - "3.2" 25 | - "3.3" 26 | - "3.4" 27 | 28 | experimental: [false] 29 | 30 | include: 31 | - os: ubuntu 32 | ruby: truffleruby 33 | experimental: true 34 | - os: ubuntu 35 | ruby: jruby 36 | experimental: true 37 | - os: ubuntu 38 | ruby: head 39 | experimental: true 40 | 41 | steps: 42 | - uses: actions/checkout@v4 43 | - uses: ruby/setup-ruby@v1 44 | with: 45 | ruby-version: ${{matrix.ruby}} 46 | bundler-cache: true 47 | 48 | - name: Run tests 49 | timeout-minutes: 10 50 | run: bundle exec bake test 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /pkg/ 3 | /gems.locked 4 | /.covered.db 5 | /external 6 | 7 | /.github/workflows/test-external.yaml 8 | /examples/*/*.sqlite3* 9 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | DisabledByDefault: true 3 | 4 | Layout/IndentationStyle: 5 | Enabled: true 6 | EnforcedStyle: tabs 7 | 8 | Layout/InitialIndentation: 9 | Enabled: true 10 | 11 | Layout/IndentationWidth: 12 | Enabled: true 13 | Width: 1 14 | 15 | Layout/IndentationConsistency: 16 | Enabled: true 17 | EnforcedStyle: normal 18 | 19 | Layout/BlockAlignment: 20 | Enabled: true 21 | 22 | Layout/EndAlignment: 23 | Enabled: true 24 | EnforcedStyleAlignWith: start_of_line 25 | 26 | Layout/BeginEndAlignment: 27 | Enabled: true 28 | EnforcedStyleAlignWith: start_of_line 29 | 30 | Layout/ElseAlignment: 31 | Enabled: true 32 | 33 | Layout/DefEndAlignment: 34 | Enabled: true 35 | 36 | Layout/CaseIndentation: 37 | Enabled: true 38 | 39 | Layout/CommentIndentation: 40 | Enabled: true 41 | 42 | Layout/EmptyLinesAroundClassBody: 43 | Enabled: true 44 | 45 | Layout/EmptyLinesAroundModuleBody: 46 | Enabled: true 47 | 48 | Layout/EmptyLineAfterMagicComment: 49 | Enabled: true 50 | 51 | Style/FrozenStringLiteralComment: 52 | Enabled: true 53 | 54 | Style/StringLiterals: 55 | Enabled: true 56 | EnforcedStyle: double_quotes 57 | -------------------------------------------------------------------------------- /bin/lively: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "async/service" 5 | require_relative "../lib/lively/environment/application" 6 | 7 | ARGV.each do |path| 8 | require(path) 9 | end 10 | 11 | configuration = Async::Service::Configuration.build do 12 | service "lively" do 13 | include Lively::Environment::Application 14 | end 15 | end 16 | 17 | Async::Service::Controller.run(configuration) 18 | -------------------------------------------------------------------------------- /examples/chatbot/application.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env lively 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2024, by Samuel Williams. 6 | 7 | require "async/ollama" 8 | require "markly" 9 | require "xrb/reference" 10 | 11 | require_relative "conversation" 12 | require_relative "toolbox" 13 | 14 | class ChatbotView < Live::View 15 | def initialize(...) 16 | super 17 | 18 | @conversation = nil 19 | @toolbox ||= Toolbox.default 20 | end 21 | 22 | def conversation 23 | @conversation ||= Conversation.find_by(id: @data[:conversation_id]) 24 | end 25 | 26 | def append_prompt(client, prompt) 27 | previous_context = conversation.context 28 | 29 | conversation_message = conversation.conversation_messages.create!(prompt: prompt, response: String.new) 30 | 31 | self.append(".conversation .messages") do |builder| 32 | self.render_message(builder, conversation_message) 33 | end 34 | 35 | generate = client.generate(prompt, context: previous_context) do |response| 36 | response.body.each do |token| 37 | conversation_message.response += token 38 | 39 | self.replace(".message.id#{conversation_message.id}") do |builder| 40 | self.render_message(builder, conversation_message) 41 | end 42 | end 43 | end 44 | 45 | conversation_message.response = generate.response 46 | conversation_message.context = generate.context 47 | conversation_message.save! 48 | 49 | return conversation_message 50 | end 51 | 52 | def update_conversation(prompt) 53 | Console.info(self, "Updating conversation", id: conversation.id, prompt: prompt) 54 | 55 | if prompt.start_with? "/explain" 56 | prompt = @toolbox.explain 57 | end 58 | 59 | Async::Ollama::Client.open do |client| 60 | conversation_message = append_prompt(client, prompt) 61 | 62 | while conversation_message 63 | messages = @toolbox.each(conversation_message.response).to_a 64 | break if messages.empty? 65 | 66 | results = [] 67 | 68 | messages.each do |message| 69 | result = @toolbox.call(message) 70 | results << result.to_json 71 | end 72 | 73 | conversation_message = append_prompt(client, results.join("\n")) 74 | end 75 | end 76 | end 77 | 78 | def handle(event) 79 | case event[:type] 80 | when "keypress" 81 | detail = event[:detail] 82 | 83 | if detail[:key] == "Enter" 84 | prompt = detail[:value] 85 | 86 | Async do 87 | update_conversation(prompt) 88 | end 89 | end 90 | end 91 | end 92 | 93 | def forward_keypress 94 | "live.forwardEvent(#{JSON.dump(@id)}, event, {value: event.target.value, key: event.key}); if (event.key == 'Enter') event.target.value = '';" 95 | end 96 | 97 | def render_message(builder, message) 98 | builder.tag(:div, class: "message id#{message.id}") do 99 | builder.inline_tag(:p, class: "prompt") do 100 | builder.text(message.prompt) 101 | end 102 | 103 | builder.inline_tag(:div, class: "response") do 104 | builder.raw(Markly.render_html(message.response)) 105 | end 106 | end 107 | end 108 | 109 | def render(builder) 110 | builder.tag(:div, class: "conversation") do 111 | builder.tag(:div, class: "messages") do 112 | conversation&.conversation_messages&.each do |message| 113 | render_message(builder, message) 114 | end 115 | end 116 | 117 | builder.tag(:input, type: "text", class: "prompt", value: @data[:prompt], onkeypress: forward_keypress, autofocus: true, placeholder: "Type prompt here...") 118 | end 119 | end 120 | end 121 | 122 | class Application < Lively::Application 123 | def self.resolver 124 | Live::Resolver.allow(ChatbotView) 125 | end 126 | 127 | def body(...) 128 | ChatbotView.root(...) 129 | end 130 | 131 | def handle(request) 132 | reference = ::XRB::Reference(request.path) 133 | 134 | if value = reference.query[:conversation_id] 135 | conversation_id = Integer(value) 136 | else 137 | conversation_id = nil 138 | end 139 | 140 | unless conversation_id 141 | reference.query[:conversation_id] = Conversation.create!(model: "llama3").id 142 | 143 | return ::Protocol::HTTP::Response[302, {"location" => reference.to_s}] 144 | else 145 | return super(request, conversation_id: conversation_id) 146 | end 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /examples/chatbot/conversation.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env lively 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2024, by Samuel Williams. 6 | 7 | require "active_record" 8 | require "console" 9 | require "console/compatible/logger" 10 | 11 | ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: "conversation.sqlite3") 12 | ActiveRecord::Base.logger = Console::Compatible::Logger.new(Console) 13 | 14 | ActiveRecord::Schema.define do 15 | create_table :conversations, if_not_exists: true do |table| 16 | table.string "model", null: false 17 | table.timestamps 18 | end 19 | 20 | create_table :conversation_messages, if_not_exists: true do |table| 21 | table.belongs_to :conversation, null: false, foreign_key: true 22 | 23 | table.json :context 24 | table.text :prompt 25 | table.text :response 26 | table.timestamps 27 | end 28 | end 29 | 30 | class ConversationMessage < ActiveRecord::Base 31 | end 32 | 33 | class Conversation < ActiveRecord::Base 34 | has_many :conversation_messages 35 | 36 | def context 37 | self.conversation_messages.order(created_at: :desc).first&.context 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /examples/chatbot/gems.locked: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: ../.. 3 | specs: 4 | lively (0.10.1) 5 | falcon (~> 0.47) 6 | live (~> 0.17) 7 | xrb 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | activemodel (7.2.2.1) 13 | activesupport (= 7.2.2.1) 14 | activerecord (7.2.2.1) 15 | activemodel (= 7.2.2.1) 16 | activesupport (= 7.2.2.1) 17 | timeout (>= 0.4.0) 18 | activesupport (7.2.2.1) 19 | base64 20 | benchmark (>= 0.3) 21 | bigdecimal 22 | concurrent-ruby (~> 1.0, >= 1.3.1) 23 | connection_pool (>= 2.2.5) 24 | drb 25 | i18n (>= 1.6, < 2) 26 | logger (>= 1.4.2) 27 | minitest (>= 5.1) 28 | securerandom (>= 0.3) 29 | tzinfo (~> 2.0, >= 2.0.5) 30 | async (2.24.0) 31 | console (~> 1.29) 32 | fiber-annotation 33 | io-event (~> 1.9) 34 | metrics (~> 0.12) 35 | traces (~> 0.15) 36 | async-container (0.24.0) 37 | async (~> 2.22) 38 | async-container-supervisor (0.5.1) 39 | async-container (~> 0.22) 40 | async-service 41 | io-endpoint 42 | memory-leak (~> 0.5) 43 | async-http (0.89.0) 44 | async (>= 2.10.2) 45 | async-pool (~> 0.9) 46 | io-endpoint (~> 0.14) 47 | io-stream (~> 0.6) 48 | metrics (~> 0.12) 49 | protocol-http (~> 0.49) 50 | protocol-http1 (~> 0.30) 51 | protocol-http2 (~> 0.22) 52 | traces (~> 0.10) 53 | async-http-cache (0.4.5) 54 | async-http (~> 0.56) 55 | async-ollama (0.4.0) 56 | async 57 | async-rest (~> 0.17) 58 | async-pool (0.10.3) 59 | async (>= 1.25) 60 | async-rest (0.19.1) 61 | async-http (~> 0.42) 62 | protocol-http (~> 0.45) 63 | async-service (0.13.0) 64 | async 65 | async-container (~> 0.16) 66 | async-websocket (0.30.0) 67 | async-http (~> 0.76) 68 | protocol-http (~> 0.34) 69 | protocol-rack (~> 0.7) 70 | protocol-websocket (~> 0.17) 71 | base64 (0.2.0) 72 | benchmark (0.4.0) 73 | bigdecimal (3.1.9) 74 | concurrent-ruby (1.3.5) 75 | connection_pool (2.5.3) 76 | console (1.30.2) 77 | fiber-annotation 78 | fiber-local (~> 1.1) 79 | json 80 | drb (2.2.1) 81 | falcon (0.51.1) 82 | async 83 | async-container (~> 0.20) 84 | async-container-supervisor (~> 0.5.0) 85 | async-http (~> 0.75) 86 | async-http-cache (~> 0.4) 87 | async-service (~> 0.10) 88 | bundler 89 | localhost (~> 1.1) 90 | openssl (~> 3.0) 91 | protocol-http (~> 0.31) 92 | protocol-rack (~> 0.7) 93 | samovar (~> 2.3) 94 | fiber-annotation (0.2.0) 95 | fiber-local (1.1.0) 96 | fiber-storage 97 | fiber-storage (1.0.1) 98 | i18n (1.14.7) 99 | concurrent-ruby (~> 1.0) 100 | io-endpoint (0.15.2) 101 | io-event (1.10.0) 102 | io-stream (0.6.1) 103 | json (2.11.3) 104 | live (0.17.0) 105 | async-websocket (~> 0.27) 106 | protocol-websocket (~> 0.19) 107 | xrb (~> 0.10) 108 | localhost (1.5.0) 109 | logger (1.7.0) 110 | mapping (1.1.3) 111 | markly (0.13.0) 112 | memory-leak (0.5.2) 113 | metrics (0.12.2) 114 | mini_portile2 (2.8.8) 115 | minitest (5.25.5) 116 | openssl (3.3.0) 117 | protocol-hpack (1.5.1) 118 | protocol-http (0.50.1) 119 | protocol-http1 (0.34.0) 120 | protocol-http (~> 0.22) 121 | protocol-http2 (0.22.1) 122 | protocol-hpack (~> 1.4) 123 | protocol-http (~> 0.47) 124 | protocol-rack (0.12.0) 125 | protocol-http (~> 0.43) 126 | rack (>= 1.0) 127 | protocol-websocket (0.20.2) 128 | protocol-http (~> 0.2) 129 | rack (3.1.14) 130 | samovar (2.3.0) 131 | console (~> 1.0) 132 | mapping (~> 1.0) 133 | securerandom (0.4.1) 134 | sqlite3 (1.7.3) 135 | mini_portile2 (~> 2.8.0) 136 | timeout (0.4.3) 137 | traces (0.15.2) 138 | tzinfo (2.0.6) 139 | concurrent-ruby (~> 1.0) 140 | xrb (0.11.1) 141 | 142 | PLATFORMS 143 | ruby 144 | x86_64-linux 145 | 146 | DEPENDENCIES 147 | activerecord (~> 7.1) 148 | async-ollama 149 | live 150 | lively! 151 | markly 152 | sqlite3 (~> 1.4) 153 | 154 | BUNDLED WITH 155 | 2.6.2 156 | -------------------------------------------------------------------------------- /examples/chatbot/gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024-2025, by Samuel Williams. 5 | 6 | source "https://rubygems.org" 7 | 8 | gem "live" 9 | gem "lively", path: "../../" 10 | 11 | gem "activerecord", "~> 7.1" 12 | gem "sqlite3", "~> 1.4" 13 | gem "markly" 14 | 15 | gem "async-ollama" 16 | -------------------------------------------------------------------------------- /examples/chatbot/public/_static/index.css: -------------------------------------------------------------------------------- 1 | .conversation { 2 | margin: 1rem; 3 | } 4 | 5 | .conversation input[type="text"] { 6 | width: 100%; 7 | box-sizing: border-box; 8 | } 9 | 10 | .conversation .prompt { 11 | font-weight: bold; 12 | } 13 | 14 | .conversation .response { 15 | margin-left: 2rem; 16 | } -------------------------------------------------------------------------------- /examples/chatbot/toolbox.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | require "async/http/internet/instance" 7 | 8 | class Tool 9 | def initialize(name, explain, &block) 10 | @name = name 11 | @explain = explain 12 | @block = block 13 | end 14 | 15 | attr :name 16 | attr :explain 17 | 18 | def as_json 19 | { 20 | "name" => @name, 21 | "explain" => @explain, 22 | } 23 | end 24 | 25 | def call(message) 26 | @block.call(message) 27 | end 28 | end 29 | 30 | class Toolbox 31 | def self.default 32 | self.new.tap do |toolbox| 33 | toolbox.register("ruby", '{"tool":"ruby", "code": "..."}') do |message| 34 | eval(message[:code]) 35 | end 36 | 37 | toolbox.register("internet.get", '{"tool":"internet.get", "url": "http://..."}') do |message| 38 | Async::HTTP::Internet.get(message[:url]).read 39 | end 40 | 41 | toolbox.register("explain", '{"tool":"explain"}') do |message| 42 | toolbox.as_json 43 | end 44 | end 45 | end 46 | 47 | PROMPT = "You have access to the following tools, which you can invoke by replying with a single line of valid JSON:\n\n" 48 | 49 | USAGE = <<~EOF 50 | Use these tools to enhance your ability to answer user queries accurately. 51 | 52 | When you need to use a tool to answer the user's query, respond **only** with the JSON invocation. 53 | - Example: {"tool":"ruby", "code": "5+5"} 54 | - **Do not** include any explanations, greetings, or additional text when invoking a tool. 55 | - If you are dealing with numbers, ensure you provide them as Integers or Floats, not Strings. 56 | 57 | After invoking a tool: 58 | 1. You will receive the tool's result as the next input. 59 | 2. Use the result to formulate a direct, user-friendly response that answers the original query. 60 | 3. Assume the user is unaware of the tool invocation or its result, so clearly summarize the answer without referring to the tool usage or the response it generated. 61 | 62 | Continue the conversation naturally after providing the answer. Ensure your responses are concise and user-focused. 63 | 64 | ## Example Flow: 65 | 66 | User: "Why doesn't 5 + 5 equal 11?" 67 | Assistant (invokes tool): {"tool": "ruby", "code": "5+5"} 68 | (Tool Result): 10 69 | Assistant: "The result of 5 + 5 is 10, because addition follows standard arithmetic rules." 70 | EOF 71 | 72 | def initialize 73 | @tools = {} 74 | end 75 | 76 | def as_json 77 | { 78 | "prompt" => PROMPT, 79 | "tools" => @tools.map(&:as_json), 80 | "usage" => USAGE, 81 | } 82 | end 83 | 84 | attr :tools 85 | 86 | def register(name, explain, &block) 87 | @tools[name] = Tool.new(name, explain, &block) 88 | end 89 | 90 | def tool?(response) 91 | if response.start_with?('{') 92 | begin 93 | return JSON.parse(response, symbolize_names: true) 94 | rescue => error 95 | Console.debug(self, error) 96 | end 97 | end 98 | 99 | return false 100 | end 101 | 102 | def each(text) 103 | return to_enum(:each, text) unless block_given? 104 | 105 | text.each_line do |line| 106 | if message = tool?(line) 107 | yield message 108 | end 109 | end 110 | end 111 | 112 | def call(message) 113 | name = message[:tool] 114 | 115 | if tool = @tools[name] 116 | result = tool.call(message) 117 | 118 | return {result: result}.to_json 119 | else 120 | raise ArgumentError.new("Unknown tool: #{name}") 121 | end 122 | rescue => error 123 | {error: error.message}.to_json 124 | end 125 | 126 | def explain 127 | buffer = String.new 128 | buffer << PROMPT 129 | 130 | @tools.each do |name, tool| 131 | buffer << tool.explain 132 | end 133 | 134 | buffer << "\n" << USAGE << "\n" 135 | 136 | return buffer 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /examples/flappy-bird/application.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env lively 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2024, by Samuel Williams. 6 | 7 | require_relative "highscore" 8 | require "async/variable" 9 | 10 | WIDTH = 420 11 | HEIGHT = 640 12 | GRAVITY = -9.8 * 50.0 13 | 14 | class BoundingBox 15 | def initialize(x, y, width, height) 16 | @x = x 17 | @y = y 18 | @width = width 19 | @height = height 20 | end 21 | 22 | attr_accessor :x, :y, :width, :height 23 | 24 | def right 25 | @x + @width 26 | end 27 | 28 | def top 29 | @y + @height 30 | end 31 | 32 | def intersect?(other) 33 | !( 34 | self.right < other.x || 35 | self.x > other.right || 36 | self.top < other.y || 37 | self.y > other.top 38 | ) 39 | end 40 | 41 | def to_s 42 | "#<#{self.class} (#{@x}, #{@y}, #{@width}, #{@height}>" 43 | end 44 | end 45 | 46 | class Bird < BoundingBox 47 | def initialize(x = 30, y = HEIGHT / 2, width: 34, height: 24) 48 | super(x, y, width, height) 49 | @velocity = 0.0 50 | end 51 | 52 | def step(dt) 53 | @velocity += GRAVITY * dt 54 | @y += @velocity * dt 55 | 56 | if @y > HEIGHT 57 | @y = HEIGHT 58 | @velocity = 0.0 59 | end 60 | end 61 | 62 | def jump 63 | @velocity = 300.0 64 | end 65 | 66 | def render(builder, remote: false) 67 | rotation = (@velocity / 20.0).clamp(-40.0, 40.0) 68 | rotate = "rotate(#{-rotation}deg)"; 69 | 70 | class_name = remote ? "bird remote" : "bird" 71 | 72 | builder.inline_tag(:div, class: class_name, style: "left: #{@x}px; bottom: #{@y}px; width: #{@width}px; height: #{@height}px; transform: #{rotate};") 73 | end 74 | end 75 | 76 | class Gemstone < BoundingBox 77 | def initialize(x, y, width: 148, height: 116) 78 | super(x, y - height / 2, width, height) 79 | end 80 | 81 | def step(dt) 82 | @x -= 100 * dt 83 | end 84 | 85 | def render(builder) 86 | builder.inline_tag(:div, class: "gemstone", style: "left: #{@x}px; bottom: #{@y}px; width: #{@width}px; height: #{@height}px;") 87 | end 88 | end 89 | 90 | class Pipe 91 | def initialize(x, y, offset = 100, random: 0, width: 44, height: 700) 92 | @x = x 93 | @y = y 94 | @offset = offset 95 | 96 | @width = width 97 | @height = height 98 | @difficulty = 0.0 99 | @scored = false 100 | 101 | @random = random 102 | end 103 | 104 | attr_accessor :x, :y, :offset 105 | 106 | # Whether the bird has passed through the pipe. 107 | attr_accessor :scored 108 | 109 | def scaled_random 110 | @random.rand(-1.0..1.0) * [@difficulty, 1.0].min 111 | end 112 | 113 | def reset! 114 | @x = WIDTH + (@random.rand * 10) 115 | @y = HEIGHT/2 + (HEIGHT/2 * scaled_random) 116 | 117 | if @offset > 50 118 | @offset -= (@difficulty * 10) 119 | end 120 | 121 | @difficulty += 0.1 122 | @scored = false 123 | end 124 | 125 | def step(dt) 126 | @x -= 100 * dt 127 | 128 | if self.right < 0 129 | reset! 130 | end 131 | end 132 | 133 | def right 134 | @x + @width 135 | end 136 | 137 | def top 138 | @y + @offset 139 | end 140 | 141 | def bottom 142 | (@y - @offset) - @height 143 | end 144 | 145 | def lower_bounding_box 146 | BoundingBox.new(@x, self.bottom, @width, @height) 147 | end 148 | 149 | def upper_bounding_box 150 | BoundingBox.new(@x, self.top, @width, @height) 151 | end 152 | 153 | def intersect?(other) 154 | lower_bounding_box.intersect?(other) || upper_bounding_box.intersect?(other) 155 | end 156 | 157 | def render(builder) 158 | display = "display: none;" if @x > WIDTH 159 | 160 | builder.inline_tag(:div, class: "pipe", style: "left: #{@x}px; bottom: #{self.bottom}px; width: #{@width}px; height: #{@height}px; #{display}") 161 | builder.inline_tag(:div, class: "pipe", style: "left: #{@x}px; bottom: #{self.top}px; width: #{@width}px; height: #{@height}px; #{display}") 162 | end 163 | end 164 | 165 | class FlappyBirdView < Live::View 166 | def initialize(*arguments, multiplayer_state: nil, **options) 167 | super(*arguments, **options) 168 | 169 | @multiplayer_state = multiplayer_state 170 | 171 | @game = nil 172 | @bird = nil 173 | @pipes = nil 174 | @bonus = nil 175 | 176 | # Defaults: 177 | @score = 0 178 | @prompt = "Press Space to Start" 179 | 180 | @random = nil 181 | @dead = nil 182 | end 183 | 184 | attr :bird 185 | 186 | def bind(page) 187 | super 188 | 189 | @multiplayer_state.add_player(self) 190 | end 191 | 192 | def close 193 | if @game 194 | @game.stop 195 | @game = nil 196 | end 197 | 198 | @multiplayer_state.remove_player(self) 199 | 200 | super 201 | end 202 | 203 | def jump 204 | play_sound("quack") if rand > 0.5 205 | 206 | @bird&.jump 207 | end 208 | 209 | def handle(event) 210 | case event[:type] 211 | when "touchstart" 212 | self.jump 213 | when "keypress" 214 | if event.dig(:detail, :key) == " " 215 | self.jump 216 | end 217 | end 218 | end 219 | 220 | def forward_keypress 221 | "live.forwardEvent(#{JSON.dump(@id)}, event, {key: event.key})" 222 | end 223 | 224 | def reset! 225 | @dead = Async::Variable.new 226 | @random = Random.new(1) 227 | 228 | @bird = Bird.new 229 | @pipes = [ 230 | Pipe.new(WIDTH + WIDTH * 1/2, HEIGHT/2, random: @random), 231 | Pipe.new(WIDTH + WIDTH * 2/2, HEIGHT/2, random: @random) 232 | ] 233 | @bonus = nil 234 | @score = 0 235 | end 236 | 237 | def play_sound(name) 238 | self.script(<<~JAVASCRIPT) 239 | if (!this.sounds) { 240 | this.sounds = {}; 241 | } 242 | 243 | if (!this.sounds[#{JSON.dump(name)}]) { 244 | this.sounds[#{JSON.dump(name)}] = new Audio('/_static/#{name}.mp3'); 245 | } 246 | 247 | this.sounds[#{JSON.dump(name)}].play(); 248 | JAVASCRIPT 249 | end 250 | 251 | def play_music 252 | self.script(<<~JAVASCRIPT) 253 | if (!this.music) { 254 | this.music = new Audio('/_static/music.mp3'); 255 | this.music.loop = true; 256 | this.music.play(); 257 | } 258 | JAVASCRIPT 259 | end 260 | 261 | def stop_music 262 | self.script(<<~JAVASCRIPT) 263 | if (this.music) { 264 | this.music.pause(); 265 | this.music = null; 266 | } 267 | JAVASCRIPT 268 | end 269 | 270 | def game_over! 271 | Console.info(self, "Player has died.") 272 | @dead.resolve(true) 273 | 274 | play_sound("death") 275 | stop_music 276 | 277 | Highscore.create!(ENV.fetch("PLAYER", "Anonymous"), @score) 278 | 279 | @prompt = "Game Over! Score: #{@score}." 280 | @game = nil 281 | 282 | self.update! 283 | 284 | raise Async::Stop 285 | end 286 | 287 | def preparing(message) 288 | @prompt = message 289 | self.update! 290 | end 291 | 292 | def start_game! 293 | if @game 294 | @game.stop 295 | @game = nil 296 | end 297 | 298 | self.reset! 299 | self.update! 300 | self.script("this.querySelector('.flappy').focus()") 301 | @game = self.run! 302 | end 303 | 304 | def wait_until_dead 305 | @dead.wait 306 | end 307 | 308 | def step(dt) 309 | @bird.step(dt) 310 | @pipes.each do |pipe| 311 | pipe.step(dt) 312 | 313 | if pipe.right < @bird.x && !pipe.scored 314 | @score += 1 315 | pipe.scored = true 316 | 317 | if @score == 3 318 | play_music 319 | end 320 | end 321 | 322 | if pipe.intersect?(@bird) 323 | return game_over! 324 | end 325 | end 326 | 327 | @bonus&.step(dt) 328 | 329 | if @bonus&.intersect?(@bird) 330 | play_sound("clink") 331 | @score = @score * 2 332 | @bonus = nil 333 | elsif @bonus and @bonus.right < 0 334 | @bonus = nil 335 | end 336 | 337 | if @score > 0 and (@score % 5).zero? 338 | @bonus = Gemstone.new(WIDTH, HEIGHT/2) 339 | end 340 | 341 | if @bird.top < 0 342 | return game_over! 343 | end 344 | end 345 | 346 | def run!(dt = 1.0/10.0) 347 | Async do 348 | start_time = Async::Clock.now 349 | 350 | while true 351 | self.step(dt) 352 | 353 | self.update! 354 | 355 | duration = Async::Clock.now - start_time 356 | if duration < dt 357 | sleep(dt - duration) 358 | else 359 | Console.info(self, "Running behind by #{duration - dt} seconds") 360 | end 361 | start_time = Async::Clock.now 362 | end 363 | end 364 | end 365 | 366 | def render(builder) 367 | builder.tag(:div, class: "flappy", tabIndex: 0, onKeyPress: forward_keypress, onTouchStart: forward_keypress) do 368 | if @game 369 | builder.inline_tag(:div, class: "score") do 370 | builder.text(@score) 371 | end 372 | else 373 | builder.inline_tag(:div, class: "prompt") do 374 | builder.text(@prompt) 375 | 376 | builder.inline_tag(:ol, class: "highscores") do 377 | Highscore.top10.each do |highscore| 378 | builder.inline_tag(:li) do 379 | builder.text("#{highscore[0]}: #{highscore[1]}") 380 | end 381 | end 382 | end 383 | end 384 | end 385 | 386 | @bird&.render(builder) 387 | 388 | @pipes&.each do |pipe| 389 | pipe.render(builder) 390 | end 391 | 392 | @bonus&.render(builder) 393 | 394 | @multiplayer_state&.players&.each do |player| 395 | if player != self 396 | player.bird&.render(builder, remote: true) 397 | end 398 | end 399 | end 400 | end 401 | end 402 | 403 | class Resolver < Live::Resolver 404 | def initialize(**state) 405 | super() 406 | 407 | @state = state 408 | end 409 | 410 | def call(id, data) 411 | if klass = @allowed[data[:class]] 412 | return klass.new(id, **data, **@state) 413 | end 414 | end 415 | end 416 | 417 | class MultiplayerState 418 | MINIMUM_PLAYERS = 1 419 | GAME_START_TIMEOUT = 5 420 | 421 | def initialize 422 | @joined = Set.new 423 | @players = nil 424 | 425 | @player_joined = Async::Condition.new 426 | 427 | @game = self.run! 428 | end 429 | 430 | attr :players 431 | 432 | def run! 433 | Async do 434 | while true 435 | Console.info(self, "Waiting for players...") 436 | while @joined.size < MINIMUM_PLAYERS 437 | @player_joined.wait 438 | end 439 | 440 | Console.info(self, "Starting game...") 441 | GAME_START_TIMEOUT.downto(0).each do |i| 442 | @joined.each do |player| 443 | player.preparing("Starting game in #{i}...") 444 | end 445 | sleep 1 446 | end 447 | 448 | @players = @joined.to_a 449 | Console.info(self, "Game started with #{@players.size} players") 450 | 451 | @players.each do |player| 452 | player.start_game! 453 | end 454 | 455 | @players.each do |player| 456 | player.wait_until_dead 457 | end 458 | 459 | Console.info(self, "Game over") 460 | @players = nil 461 | end 462 | end 463 | end 464 | 465 | def add_player(player) 466 | # Console.info(self, "Adding player: #{player}") 467 | @joined << player 468 | player.preparing("Waiting for other players...") 469 | @player_joined.signal 470 | end 471 | 472 | def remove_player(player) 473 | # Console.info(self, "Removing player: #{player}") 474 | @joined.delete(player) 475 | end 476 | end 477 | 478 | class Application < Lively::Application 479 | def self.resolver 480 | Resolver.new(multiplayer_state: MultiplayerState.new).tap do |resolver| 481 | resolver.allow(FlappyBirdView) 482 | end 483 | end 484 | 485 | def body(...) 486 | FlappyBirdView.new(...) 487 | end 488 | end 489 | -------------------------------------------------------------------------------- /examples/flappy-bird/gems.locked: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: ../.. 3 | specs: 4 | lively (0.10.1) 5 | falcon (~> 0.47) 6 | live (~> 0.17) 7 | xrb 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | async (2.24.0) 13 | console (~> 1.29) 14 | fiber-annotation 15 | io-event (~> 1.9) 16 | metrics (~> 0.12) 17 | traces (~> 0.15) 18 | async-container (0.24.0) 19 | async (~> 2.22) 20 | async-container-supervisor (0.5.1) 21 | async-container (~> 0.22) 22 | async-service 23 | io-endpoint 24 | memory-leak (~> 0.5) 25 | async-http (0.89.0) 26 | async (>= 2.10.2) 27 | async-pool (~> 0.9) 28 | io-endpoint (~> 0.14) 29 | io-stream (~> 0.6) 30 | metrics (~> 0.12) 31 | protocol-http (~> 0.49) 32 | protocol-http1 (~> 0.30) 33 | protocol-http2 (~> 0.22) 34 | traces (~> 0.10) 35 | async-http-cache (0.4.5) 36 | async-http (~> 0.56) 37 | async-pool (0.10.3) 38 | async (>= 1.25) 39 | async-service (0.13.0) 40 | async 41 | async-container (~> 0.16) 42 | async-websocket (0.30.0) 43 | async-http (~> 0.76) 44 | protocol-http (~> 0.34) 45 | protocol-rack (~> 0.7) 46 | protocol-websocket (~> 0.17) 47 | console (1.30.2) 48 | fiber-annotation 49 | fiber-local (~> 1.1) 50 | json 51 | falcon (0.51.1) 52 | async 53 | async-container (~> 0.20) 54 | async-container-supervisor (~> 0.5.0) 55 | async-http (~> 0.75) 56 | async-http-cache (~> 0.4) 57 | async-service (~> 0.10) 58 | bundler 59 | localhost (~> 1.1) 60 | openssl (~> 3.0) 61 | protocol-http (~> 0.31) 62 | protocol-rack (~> 0.7) 63 | samovar (~> 2.3) 64 | fiber-annotation (0.2.0) 65 | fiber-local (1.1.0) 66 | fiber-storage 67 | fiber-storage (1.0.1) 68 | io-endpoint (0.15.2) 69 | io-event (1.10.0) 70 | io-stream (0.6.1) 71 | json (2.11.3) 72 | live (0.17.0) 73 | async-websocket (~> 0.27) 74 | protocol-websocket (~> 0.19) 75 | xrb (~> 0.10) 76 | localhost (1.5.0) 77 | mapping (1.1.3) 78 | memory-leak (0.5.2) 79 | metrics (0.12.2) 80 | openssl (3.3.0) 81 | protocol-hpack (1.5.1) 82 | protocol-http (0.50.1) 83 | protocol-http1 (0.34.0) 84 | protocol-http (~> 0.22) 85 | protocol-http2 (0.22.1) 86 | protocol-hpack (~> 1.4) 87 | protocol-http (~> 0.47) 88 | protocol-rack (0.12.0) 89 | protocol-http (~> 0.43) 90 | rack (>= 1.0) 91 | protocol-websocket (0.20.2) 92 | protocol-http (~> 0.2) 93 | rack (3.1.14) 94 | samovar (2.3.0) 95 | console (~> 1.0) 96 | mapping (~> 1.0) 97 | sqlite3 (2.6.0-arm64-darwin) 98 | sqlite3 (2.6.0-x86_64-linux-gnu) 99 | traces (0.15.2) 100 | xrb (0.11.1) 101 | 102 | PLATFORMS 103 | arm64-darwin-23 104 | arm64-darwin-24 105 | x86_64-linux 106 | 107 | DEPENDENCIES 108 | lively! 109 | sqlite3 110 | 111 | BUNDLED WITH 112 | 2.6.2 113 | -------------------------------------------------------------------------------- /examples/flappy-bird/gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024-2025, by Samuel Williams. 5 | 6 | source "https://rubygems.org" 7 | 8 | gem "lively", path: "../../" 9 | 10 | gem "sqlite3" 11 | -------------------------------------------------------------------------------- /examples/flappy-bird/highscore.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env lively 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2024, by Samuel Williams. 6 | 7 | require "sqlite3" 8 | 9 | class Highscore 10 | def self.database 11 | @database ||= SQLite3::Database.new("highscores.sqlite3").tap do |database| 12 | database.execute("CREATE TABLE IF NOT EXISTS highscores (name TEXT, score INTEGER)") 13 | end 14 | end 15 | 16 | def self.create!(name, score) 17 | database.execute("INSERT INTO highscores (name, score) VALUES (?, ?)", [name, score]) 18 | end 19 | 20 | def self.top10 21 | database.execute("SELECT * FROM highscores ORDER BY score DESC LIMIT 10") 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /examples/flappy-bird/public/_static/clink.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketry/lively/2138fc2c489f8fdf692b2bdeaa1b08d091095f55/examples/flappy-bird/public/_static/clink.mp3 -------------------------------------------------------------------------------- /examples/flappy-bird/public/_static/death.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketry/lively/2138fc2c489f8fdf692b2bdeaa1b08d091095f55/examples/flappy-bird/public/_static/death.mp3 -------------------------------------------------------------------------------- /examples/flappy-bird/public/_static/flappy-background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketry/lively/2138fc2c489f8fdf692b2bdeaa1b08d091095f55/examples/flappy-bird/public/_static/flappy-background.png -------------------------------------------------------------------------------- /examples/flappy-bird/public/_static/flappy-bird.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketry/lively/2138fc2c489f8fdf692b2bdeaa1b08d091095f55/examples/flappy-bird/public/_static/flappy-bird.png -------------------------------------------------------------------------------- /examples/flappy-bird/public/_static/flappy-pipe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketry/lively/2138fc2c489f8fdf692b2bdeaa1b08d091095f55/examples/flappy-bird/public/_static/flappy-pipe.png -------------------------------------------------------------------------------- /examples/flappy-bird/public/_static/gemstone.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketry/lively/2138fc2c489f8fdf692b2bdeaa1b08d091095f55/examples/flappy-bird/public/_static/gemstone.gif -------------------------------------------------------------------------------- /examples/flappy-bird/public/_static/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | height: 100vh; 6 | margin: 0; 7 | } 8 | 9 | .flappy { 10 | background-image: url('/_static/flappy-background.png'); 11 | 12 | width: 420px; 13 | height: 640px; 14 | margin: auto; 15 | 16 | position: relative; 17 | overflow: hidden; 18 | 19 | transform: translate3d(0,0,0); 20 | } 21 | 22 | .flappy .score { 23 | z-index: 10; 24 | padding: 1rem; 25 | color: white; 26 | background-color: rgba(0, 0, 0, 0.5); 27 | position: relative; 28 | } 29 | 30 | .flappy .highscores { 31 | color: white; 32 | } 33 | 34 | .flappy .prompt { 35 | z-index: 20; 36 | padding: 1rem; 37 | color: white; 38 | background-color: rgba(0, 0, 0, 0.5); 39 | 40 | position: absolute; 41 | left: 0; 42 | right: 0; 43 | top: 0; 44 | bottom: 0; 45 | 46 | text-align: center; 47 | } 48 | 49 | .flappy .bird { 50 | z-index: 1; 51 | background-image: url('/_static/flappy-bird.png'); 52 | position: absolute; 53 | background-size: contain; 54 | 55 | transform: translate3d(0,0,0); 56 | transition: all 0.1s linear 0s; 57 | } 58 | 59 | .flappy .bird.remote { 60 | opacity: 50%; 61 | filter: grayscale(100%); 62 | } 63 | 64 | .flappy .pipe { 65 | z-index: 5; 66 | background-image: url('/_static/flappy-pipe.png'); 67 | position: absolute; 68 | background-size: contain; 69 | 70 | transform: translate3d(0,0,0); 71 | transition: all 0.1s linear 0s; 72 | } 73 | 74 | .flappy .gemstone { 75 | z-index: 0; 76 | background-image: url('/_static/gemstone.gif'); 77 | position: absolute; 78 | background-size: contain; 79 | 80 | transform: translate3d(0,0,0); 81 | transition: all 0.1s linear 0s; 82 | } 83 | -------------------------------------------------------------------------------- /examples/flappy-bird/public/_static/music.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketry/lively/2138fc2c489f8fdf692b2bdeaa1b08d091095f55/examples/flappy-bird/public/_static/music.mp3 -------------------------------------------------------------------------------- /examples/flappy-bird/public/_static/quack.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketry/lively/2138fc2c489f8fdf692b2bdeaa1b08d091095f55/examples/flappy-bird/public/_static/quack.mp3 -------------------------------------------------------------------------------- /examples/game-of-life/application.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env lively 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2024, by Samuel Williams. 6 | 7 | class Color < Struct.new(:h, :s, :l) 8 | def to_s 9 | # h 0...360 10 | # s 0...100% 11 | # l 0...100% 12 | "hsl(#{h}, #{s}%, #{l}%)" 13 | end 14 | 15 | def self.mix(*colors) 16 | result = Color.new(rand(60.0), 0.0, 0.0) 17 | 18 | colors.each do |color| 19 | result.h += color.h 20 | result.s += color.s 21 | result.l += color.l 22 | end 23 | 24 | result.h = (result.h / colors.size).round 25 | result.s = (result.s / colors.size).round 26 | result.l = (result.l / colors.size).round 27 | 28 | return result 29 | end 30 | 31 | def self.generate 32 | self.new(rand(360.0), 80, 80) 33 | end 34 | end 35 | 36 | class Grid 37 | def initialize(width, height) 38 | @width = width 39 | @height = height 40 | @values = Array.new(width * height) 41 | end 42 | 43 | attr :width 44 | attr :height 45 | 46 | def size 47 | @values.size 48 | end 49 | 50 | def get(x, y) 51 | @values[@width * (y % @height) + (x % @width)] 52 | end 53 | 54 | def set(x, y, value = Color.generate) 55 | @values[@width * (y % @height) + (x % @width)] = value 56 | end 57 | 58 | def toggle(x, y) 59 | if get(x, y) 60 | set(x, y, nil) 61 | else 62 | set(x, y) 63 | end 64 | end 65 | 66 | def neighbours(x, y) 67 | [ 68 | get(x-1, y-1), 69 | get(x-1, y), 70 | get(x-1, y+1), 71 | 72 | get(x, y-1), 73 | get(x, y+1), 74 | 75 | get(x+1, y-1), 76 | get(x+1, y), 77 | get(x+1, y+1), 78 | ].compact 79 | end 80 | 81 | # Any live cell with two or three live neighbours survives. 82 | # Any dead cell with three live neighbours becomes a live cell. 83 | # All other live cells die in the next generation. Similarly, all other dead cells stay dead. 84 | def alive?(x, y) 85 | current = self.get(x, y) 86 | neighbours = self.neighbours(x, y) 87 | count = neighbours.size 88 | 89 | if current && (count == 2 || count == 3) 90 | Color.mix(current, *neighbours) 91 | elsif !current && count == 3 92 | Color.mix(*neighbours) 93 | else 94 | nil 95 | end 96 | end 97 | 98 | def map 99 | updated = self.class.new(@width, @height) 100 | 101 | @height.times do |y| 102 | @width.times do |x| 103 | updated.set(x, y, yield(x, y)) 104 | end 105 | end 106 | 107 | return updated 108 | end 109 | 110 | def step 111 | self.map do |x, y| 112 | alive?(x, y) 113 | end 114 | end 115 | 116 | def rows 117 | @height.times do |y| 118 | yield y, @values[@width * y ... @width * (y+1)] 119 | end 120 | end 121 | end 122 | 123 | class GameOfLifeView < Live::View 124 | def initialize(...) 125 | super 126 | 127 | @data[:width] ||= 33 128 | @data[:height] ||= 33 129 | 130 | self.reset 131 | 132 | @update = nil 133 | end 134 | 135 | def bind(page) 136 | super(page) 137 | end 138 | 139 | def close 140 | Console.warn(self, "Stopping...") 141 | 142 | self.stop 143 | 144 | super 145 | end 146 | 147 | def start 148 | @update ||= Async do |task| 149 | while true 150 | task.sleep(1.0/5.0) 151 | 152 | @grid = @grid.step 153 | self.update! 154 | end 155 | end 156 | end 157 | 158 | def stop 159 | if @update 160 | @update.stop 161 | @update = nil 162 | end 163 | end 164 | 165 | def step 166 | unless @update 167 | @grid = @grid.step 168 | self.update! 169 | end 170 | end 171 | 172 | def reset 173 | @grid = Grid.new(@data[:width].to_i, @data[:height].to_i) 174 | end 175 | 176 | def randomize 177 | @grid = @grid.map do |x, y| 178 | if rand > 0.8 179 | Color.generate 180 | end 181 | end 182 | end 183 | 184 | def heart(number_of_points: 128, scale: 0.95) 185 | dt = (2.0 * Math::PI) / number_of_points 186 | t = 0.0 187 | 188 | while t <= (2.0 * Math::PI) 189 | t += dt 190 | 191 | x = 16*Math.sin(t)**3 192 | y = 13*Math.cos(t) - 5*Math.cos(2*t) - 2*Math.cos(3*t) - Math.cos(4*t) 193 | 194 | x = (x * scale).round 195 | y = (-1.0 * y * scale).round 196 | 197 | @grid.set(@grid.width/2 + x, @grid.height/2 + y) 198 | end 199 | end 200 | 201 | def handle(event) 202 | case event.dig(:detail, :action) 203 | when "start" 204 | self.start 205 | when "stop" 206 | self.stop 207 | when "step" 208 | self.step 209 | when "reset" 210 | self.reset 211 | self.update! 212 | when "set" 213 | self.stop 214 | x = event.dig(:detail, :x).to_i 215 | y = event.dig(:detail, :y).to_i 216 | @grid.toggle(x, y) 217 | self.update! 218 | when "randomize" 219 | self.randomize 220 | self.update! 221 | when "heart" 222 | self.heart 223 | self.update! 224 | end 225 | end 226 | 227 | def forward_coordinate 228 | "live.forwardEvent(#{JSON.dump(@id)}, event, {action: 'set', x: event.target.cellIndex, y: event.target.parentNode.rowIndex})" 229 | end 230 | 231 | def render(builder) 232 | builder.tag("div", style: "text-align: center") do 233 | builder.tag("button", onclick: forward_event(action: "start")) do 234 | builder.text("Start") 235 | end 236 | 237 | builder.tag("button", onclick: forward_event(action: "stop")) do 238 | builder.text("Stop") 239 | end 240 | 241 | builder.tag("button", onclick: forward_event(action: "step")) do 242 | builder.text("Step") 243 | end 244 | 245 | builder.tag("button", onclick: forward_event(action: "reset")) do 246 | builder.text("Reset") 247 | end 248 | 249 | builder.tag("button", onclick: forward_event(action: "randomize")) do 250 | builder.text("Randomize") 251 | end 252 | 253 | builder.tag("button", onclick: forward_event(action: "heart")) do 254 | builder.text("Heart") 255 | end 256 | end 257 | 258 | builder.tag("table", onclick: forward_coordinate) do 259 | @grid.rows do |y, row| 260 | builder.tag("tr") do 261 | row.count.times do |x| 262 | style = [] 263 | 264 | if color = @grid.get(x, y) 265 | style << "background-color: #{color}" 266 | end 267 | 268 | builder.inline("td", style: style.join(";")) 269 | end 270 | end 271 | end 272 | end 273 | end 274 | end 275 | 276 | Application = Lively::Application[GameOfLifeView] 277 | -------------------------------------------------------------------------------- /examples/game-of-life/gems.locked: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: ../.. 3 | specs: 4 | lively (0.5.0) 5 | falcon (~> 0.47) 6 | live (~> 0.8) 7 | xrb 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | async (2.11.0) 13 | console (~> 1.25, >= 1.25.2) 14 | fiber-annotation 15 | io-event (~> 1.5, >= 1.5.1) 16 | timers (~> 4.1) 17 | async-container (0.18.2) 18 | async (~> 2.10) 19 | async-http (0.66.2) 20 | async (>= 2.10.2) 21 | async-pool (>= 0.6.1) 22 | io-endpoint (~> 0.10) 23 | io-stream (~> 0.4) 24 | protocol-http (~> 0.26.0) 25 | protocol-http1 (~> 0.19.0) 26 | protocol-http2 (~> 0.17.0) 27 | traces (>= 0.10.0) 28 | async-http-cache (0.4.3) 29 | async-http (~> 0.56) 30 | async-pool (0.6.1) 31 | async (>= 1.25) 32 | async-service (0.12.0) 33 | async 34 | async-container (~> 0.16) 35 | async-websocket (0.26.1) 36 | async-http (~> 0.54) 37 | protocol-rack (~> 0.5) 38 | protocol-websocket (~> 0.11) 39 | console (1.25.2) 40 | fiber-annotation 41 | fiber-local (~> 1.1) 42 | json 43 | falcon (0.47.1) 44 | async 45 | async-container (~> 0.18) 46 | async-http (~> 0.66, >= 0.66.2) 47 | async-http-cache (~> 0.4.0) 48 | async-service (~> 0.10) 49 | bundler 50 | localhost (~> 1.1) 51 | openssl (~> 3.0) 52 | process-metrics (~> 0.2.0) 53 | protocol-rack (~> 0.5) 54 | samovar (~> 2.3) 55 | fiber-annotation (0.2.0) 56 | fiber-local (1.1.0) 57 | fiber-storage 58 | fiber-storage (0.1.0) 59 | io-endpoint (0.10.2) 60 | io-event (1.5.1) 61 | io-stream (0.4.0) 62 | json (2.7.2) 63 | live (0.8.0) 64 | async-websocket (~> 0.23) 65 | xrb 66 | localhost (1.3.1) 67 | mapping (1.1.1) 68 | openssl (3.2.0) 69 | process-metrics (0.2.1) 70 | console (~> 1.8) 71 | samovar (~> 2.1) 72 | protocol-hpack (1.4.3) 73 | protocol-http (0.26.5) 74 | protocol-http1 (0.19.1) 75 | protocol-http (~> 0.22) 76 | protocol-http2 (0.17.0) 77 | protocol-hpack (~> 1.4) 78 | protocol-http (~> 0.18) 79 | protocol-rack (0.5.1) 80 | protocol-http (~> 0.23) 81 | rack (>= 1.0) 82 | protocol-websocket (0.12.1) 83 | protocol-http (~> 0.2) 84 | rack (3.0.10) 85 | samovar (2.3.0) 86 | console (~> 1.0) 87 | mapping (~> 1.0) 88 | timers (4.3.5) 89 | traces (0.11.1) 90 | xrb (0.6.0) 91 | 92 | PLATFORMS 93 | ruby 94 | x86_64-linux 95 | 96 | DEPENDENCIES 97 | lively! 98 | 99 | BUNDLED WITH 100 | 2.5.9 101 | -------------------------------------------------------------------------------- /examples/game-of-life/public/_static/index.css: -------------------------------------------------------------------------------- 1 | table { 2 | margin: 1rem; 3 | margin: auto; 4 | border-collapse: collapse; 5 | border: 1px solid var(--main-color); 6 | } 7 | 8 | table td { 9 | border: 1px solid var(--main-color); 10 | width: 1rem; 11 | height: 1rem; 12 | } 13 | 14 | table thead { 15 | background-color: var(--header-color); 16 | border-bottom: 0.2rem solid var(--accent-color); 17 | } 18 | 19 | table tr:hover { 20 | background-color: var(--header-color); 21 | } 22 | 23 | .toolbar { 24 | text-align: center; 25 | } 26 | 27 | .equation { 28 | text-align: center; 29 | font-size: 4rem; 30 | } 31 | 32 | input { 33 | font-size: inherit; 34 | } 35 | 36 | .equation input { 37 | width: 5rem; 38 | } -------------------------------------------------------------------------------- /examples/hello-world/application.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env lively 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2024-2025, by Samuel Williams. 6 | 7 | class HelloWorldView < Live::View 8 | def bind(page) 9 | super 10 | self.update! 11 | end 12 | 13 | def render(builder) 14 | builder.text("Hello World!") 15 | end 16 | end 17 | 18 | Application = Lively::Application[HelloWorldView] 19 | -------------------------------------------------------------------------------- /examples/hello-world/gems.locked: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: ../.. 3 | specs: 4 | lively (0.10.1) 5 | falcon (~> 0.47) 6 | live (~> 0.17) 7 | xrb 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | async (2.24.0) 13 | console (~> 1.29) 14 | fiber-annotation 15 | io-event (~> 1.9) 16 | metrics (~> 0.12) 17 | traces (~> 0.15) 18 | async-container (0.24.0) 19 | async (~> 2.22) 20 | async-container-supervisor (0.5.1) 21 | async-container (~> 0.22) 22 | async-service 23 | io-endpoint 24 | memory-leak (~> 0.5) 25 | async-http (0.89.0) 26 | async (>= 2.10.2) 27 | async-pool (~> 0.9) 28 | io-endpoint (~> 0.14) 29 | io-stream (~> 0.6) 30 | metrics (~> 0.12) 31 | protocol-http (~> 0.49) 32 | protocol-http1 (~> 0.30) 33 | protocol-http2 (~> 0.22) 34 | traces (~> 0.10) 35 | async-http-cache (0.4.5) 36 | async-http (~> 0.56) 37 | async-pool (0.10.3) 38 | async (>= 1.25) 39 | async-service (0.13.0) 40 | async 41 | async-container (~> 0.16) 42 | async-websocket (0.30.0) 43 | async-http (~> 0.76) 44 | protocol-http (~> 0.34) 45 | protocol-rack (~> 0.7) 46 | protocol-websocket (~> 0.17) 47 | console (1.30.2) 48 | fiber-annotation 49 | fiber-local (~> 1.1) 50 | json 51 | falcon (0.51.1) 52 | async 53 | async-container (~> 0.20) 54 | async-container-supervisor (~> 0.5.0) 55 | async-http (~> 0.75) 56 | async-http-cache (~> 0.4) 57 | async-service (~> 0.10) 58 | bundler 59 | localhost (~> 1.1) 60 | openssl (~> 3.0) 61 | protocol-http (~> 0.31) 62 | protocol-rack (~> 0.7) 63 | samovar (~> 2.3) 64 | fiber-annotation (0.2.0) 65 | fiber-local (1.1.0) 66 | fiber-storage 67 | fiber-storage (1.0.1) 68 | io-endpoint (0.15.2) 69 | io-event (1.10.0) 70 | io-stream (0.6.1) 71 | json (2.11.3) 72 | live (0.17.0) 73 | async-websocket (~> 0.27) 74 | protocol-websocket (~> 0.19) 75 | xrb (~> 0.10) 76 | localhost (1.5.0) 77 | mapping (1.1.3) 78 | memory-leak (0.5.2) 79 | metrics (0.12.2) 80 | openssl (3.3.0) 81 | protocol-hpack (1.5.1) 82 | protocol-http (0.50.1) 83 | protocol-http1 (0.34.0) 84 | protocol-http (~> 0.22) 85 | protocol-http2 (0.22.1) 86 | protocol-hpack (~> 1.4) 87 | protocol-http (~> 0.47) 88 | protocol-rack (0.12.0) 89 | protocol-http (~> 0.43) 90 | rack (>= 1.0) 91 | protocol-websocket (0.20.2) 92 | protocol-http (~> 0.2) 93 | rack (3.1.14) 94 | samovar (2.3.0) 95 | console (~> 1.0) 96 | mapping (~> 1.0) 97 | traces (0.15.2) 98 | xrb (0.11.1) 99 | 100 | PLATFORMS 101 | aarch64-linux 102 | arm-linux 103 | arm64-darwin 104 | x86-linux 105 | x86_64-darwin 106 | x86_64-linux 107 | 108 | DEPENDENCIES 109 | lively! 110 | 111 | BUNDLED WITH 112 | 2.5.5 113 | -------------------------------------------------------------------------------- /examples/hello-world/gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024-2025, by Samuel Williams. 5 | 6 | source "https://rubygems.org" 7 | 8 | gem "lively", path: "../../" 9 | -------------------------------------------------------------------------------- /examples/math-quest/application.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env lively 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2024, by Samuel Williams. 6 | 7 | class Equation 8 | def initialize(lhs, operator, rhs) 9 | @lhs = lhs 10 | @operator = operator 11 | @rhs = rhs 12 | end 13 | 14 | def answer 15 | @lhs.send(@operator, @rhs) 16 | end 17 | 18 | def to_s 19 | "#{@lhs} #{@operator} #{@rhs}" 20 | end 21 | 22 | def correct?(input) 23 | self.answer == input.to_i 24 | end 25 | 26 | def self.generate(level) 27 | case level 28 | when 0 29 | self.new(rand(0..10), "+", rand(0..10)) 30 | when 1 31 | self.new(rand(0..20), "+", rand(0..20)) 32 | when 2 33 | self.new(rand(0..10), ["+", "-"].sample, rand(0..10)) 34 | when 3 35 | self.new(rand(-10..10), ["+", "-"].sample, rand(-10..10)) 36 | when 4 37 | self.new(rand(0..10), "*", rand(0..10)) 38 | else 39 | self.new(rand(-10..10), "*", rand(-10..10)) 40 | end 41 | end 42 | end 43 | 44 | class MathQuestView < Live::View 45 | def initialize(...) 46 | super 47 | 48 | @data[:level] ||= 0 49 | @data[:time] ||= 60 50 | @data[:score] ||= 0 51 | 52 | @update = nil 53 | @equation = nil 54 | end 55 | 56 | def bind(page) 57 | super(page) 58 | 59 | self.reset 60 | self.start 61 | end 62 | 63 | def close 64 | Console.warn(self, "Stopping...") 65 | 66 | self.stop 67 | 68 | super 69 | end 70 | 71 | def start 72 | @update ||= Async do |task| 73 | while true 74 | task.sleep(1.0) 75 | 76 | Console.warn(self, "Updating...") 77 | 78 | self.update! 79 | end 80 | end 81 | end 82 | 83 | def stop 84 | if @update 85 | @update.stop 86 | @update = nil 87 | end 88 | end 89 | 90 | def level 91 | (self.score/10).round 92 | end 93 | 94 | def score 95 | @data["score"].to_i 96 | end 97 | 98 | def score= score 99 | @data["score"] = score 100 | end 101 | 102 | def reset 103 | @answer = nil 104 | @equation = Equation.generate(self.level) 105 | end 106 | 107 | def handle(event) 108 | @answer = event.dig(:detail, :value) 109 | 110 | if @equation.correct?(@answer) 111 | self.reset 112 | self.score += 1 113 | 114 | self.update! 115 | end 116 | end 117 | 118 | def forward_value 119 | "live.forwardEvent(#{JSON.dump(@id)}, event, {action: 'change', value: event.target.value})" 120 | end 121 | 122 | def render(builder) 123 | builder.tag("p", class: "toolbar") do 124 | builder.text "Score: #{self.score}" 125 | end 126 | 127 | builder.tag("p", class: "equation") do 128 | if @equation 129 | builder.text @equation.to_s 130 | builder.text " = " 131 | builder.inline("input", type: "text", value: @answer, oninput: forward_value) 132 | else 133 | builder.text "Preparing..." 134 | end 135 | end 136 | end 137 | end 138 | 139 | Application = Lively::Application[MathQuestView] 140 | -------------------------------------------------------------------------------- /examples/math-quest/gems.locked: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: ../.. 3 | specs: 4 | lively (0.10.1) 5 | falcon (~> 0.47) 6 | live (~> 0.17) 7 | xrb 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | async (2.24.0) 13 | console (~> 1.29) 14 | fiber-annotation 15 | io-event (~> 1.9) 16 | metrics (~> 0.12) 17 | traces (~> 0.15) 18 | async-container (0.24.0) 19 | async (~> 2.22) 20 | async-container-supervisor (0.5.1) 21 | async-container (~> 0.22) 22 | async-service 23 | io-endpoint 24 | memory-leak (~> 0.5) 25 | async-http (0.89.0) 26 | async (>= 2.10.2) 27 | async-pool (~> 0.9) 28 | io-endpoint (~> 0.14) 29 | io-stream (~> 0.6) 30 | metrics (~> 0.12) 31 | protocol-http (~> 0.49) 32 | protocol-http1 (~> 0.30) 33 | protocol-http2 (~> 0.22) 34 | traces (~> 0.10) 35 | async-http-cache (0.4.5) 36 | async-http (~> 0.56) 37 | async-pool (0.10.3) 38 | async (>= 1.25) 39 | async-service (0.13.0) 40 | async 41 | async-container (~> 0.16) 42 | async-websocket (0.30.0) 43 | async-http (~> 0.76) 44 | protocol-http (~> 0.34) 45 | protocol-rack (~> 0.7) 46 | protocol-websocket (~> 0.17) 47 | console (1.30.2) 48 | fiber-annotation 49 | fiber-local (~> 1.1) 50 | json 51 | falcon (0.51.1) 52 | async 53 | async-container (~> 0.20) 54 | async-container-supervisor (~> 0.5.0) 55 | async-http (~> 0.75) 56 | async-http-cache (~> 0.4) 57 | async-service (~> 0.10) 58 | bundler 59 | localhost (~> 1.1) 60 | openssl (~> 3.0) 61 | protocol-http (~> 0.31) 62 | protocol-rack (~> 0.7) 63 | samovar (~> 2.3) 64 | fiber-annotation (0.2.0) 65 | fiber-local (1.1.0) 66 | fiber-storage 67 | fiber-storage (1.0.1) 68 | io-endpoint (0.15.2) 69 | io-event (1.10.0) 70 | io-stream (0.6.1) 71 | json (2.11.3) 72 | live (0.17.0) 73 | async-websocket (~> 0.27) 74 | protocol-websocket (~> 0.19) 75 | xrb (~> 0.10) 76 | localhost (1.5.0) 77 | mapping (1.1.3) 78 | memory-leak (0.5.2) 79 | metrics (0.12.2) 80 | openssl (3.3.0) 81 | protocol-hpack (1.5.1) 82 | protocol-http (0.50.1) 83 | protocol-http1 (0.34.0) 84 | protocol-http (~> 0.22) 85 | protocol-http2 (0.22.1) 86 | protocol-hpack (~> 1.4) 87 | protocol-http (~> 0.47) 88 | protocol-rack (0.12.0) 89 | protocol-http (~> 0.43) 90 | rack (>= 1.0) 91 | protocol-websocket (0.20.2) 92 | protocol-http (~> 0.2) 93 | rack (3.1.14) 94 | samovar (2.3.0) 95 | console (~> 1.0) 96 | mapping (~> 1.0) 97 | traces (0.15.2) 98 | xrb (0.11.1) 99 | 100 | PLATFORMS 101 | ruby 102 | x86_64-linux 103 | 104 | DEPENDENCIES 105 | lively! 106 | 107 | BUNDLED WITH 108 | 2.6.2 109 | -------------------------------------------------------------------------------- /examples/math-quest/gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024-2025, by Samuel Williams. 5 | 6 | source "https://rubygems.org" 7 | 8 | gem "lively", path: "../../" 9 | -------------------------------------------------------------------------------- /examples/tanks/application.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env lively 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2024-2025, by Samuel Williams. 6 | 7 | require_relative "png" 8 | require "base64" 9 | 10 | class Map 11 | def initialize(width = 128, height = 128) 12 | @width = width 13 | @height = height 14 | 15 | @buffer = IO::Buffer.new(width * height) 16 | 17 | @buffer.slice(0, width * (height / 2)).clear(128) 18 | end 19 | 20 | attr :width 21 | attr :height 22 | attr :buffer 23 | 24 | def step 25 | x = rand(@width) 26 | y = rand(@height) 27 | value = rand(256) 28 | 29 | @buffer.set_value(:U8, x + y * @width, value) 30 | end 31 | end 32 | 33 | class TanksView < Live::View 34 | def initialize(...) 35 | super 36 | 37 | @map = Map.new 38 | @loop = nil 39 | end 40 | 41 | def bind(page) 42 | super 43 | 44 | @loop ||= Async do 45 | while true 46 | @map.step 47 | self.update! 48 | 49 | sleep 0.001 50 | end 51 | end 52 | end 53 | 54 | def close 55 | if @loop 56 | @loop.stop 57 | @loop = nil 58 | end 59 | 60 | super 61 | end 62 | 63 | def map_data 64 | data = PNG.greyscale(@map.width, @map.height, @map.buffer) 65 | 66 | return "data:image/png;base64,#{Base64.strict_encode64(data)}" 67 | end 68 | 69 | def render(builder) 70 | builder.tag("img", src: map_data) 71 | end 72 | end 73 | 74 | Application = Lively::Application[TanksView] 75 | -------------------------------------------------------------------------------- /examples/tanks/gems.locked: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: ../.. 3 | specs: 4 | lively (0.10.1) 5 | falcon (~> 0.47) 6 | live (~> 0.17) 7 | xrb 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | async (2.24.0) 13 | console (~> 1.29) 14 | fiber-annotation 15 | io-event (~> 1.9) 16 | metrics (~> 0.12) 17 | traces (~> 0.15) 18 | async-container (0.24.0) 19 | async (~> 2.22) 20 | async-container-supervisor (0.5.1) 21 | async-container (~> 0.22) 22 | async-service 23 | io-endpoint 24 | memory-leak (~> 0.5) 25 | async-http (0.89.0) 26 | async (>= 2.10.2) 27 | async-pool (~> 0.9) 28 | io-endpoint (~> 0.14) 29 | io-stream (~> 0.6) 30 | metrics (~> 0.12) 31 | protocol-http (~> 0.49) 32 | protocol-http1 (~> 0.30) 33 | protocol-http2 (~> 0.22) 34 | traces (~> 0.10) 35 | async-http-cache (0.4.5) 36 | async-http (~> 0.56) 37 | async-pool (0.10.3) 38 | async (>= 1.25) 39 | async-service (0.13.0) 40 | async 41 | async-container (~> 0.16) 42 | async-websocket (0.30.0) 43 | async-http (~> 0.76) 44 | protocol-http (~> 0.34) 45 | protocol-rack (~> 0.7) 46 | protocol-websocket (~> 0.17) 47 | base64 (0.2.0) 48 | console (1.30.2) 49 | fiber-annotation 50 | fiber-local (~> 1.1) 51 | json 52 | falcon (0.51.1) 53 | async 54 | async-container (~> 0.20) 55 | async-container-supervisor (~> 0.5.0) 56 | async-http (~> 0.75) 57 | async-http-cache (~> 0.4) 58 | async-service (~> 0.10) 59 | bundler 60 | localhost (~> 1.1) 61 | openssl (~> 3.0) 62 | protocol-http (~> 0.31) 63 | protocol-rack (~> 0.7) 64 | samovar (~> 2.3) 65 | fiber-annotation (0.2.0) 66 | fiber-local (1.1.0) 67 | fiber-storage 68 | fiber-storage (1.0.1) 69 | io-endpoint (0.15.2) 70 | io-event (1.10.0) 71 | io-stream (0.6.1) 72 | json (2.11.3) 73 | live (0.17.0) 74 | async-websocket (~> 0.27) 75 | protocol-websocket (~> 0.19) 76 | xrb (~> 0.10) 77 | localhost (1.5.0) 78 | mapping (1.1.3) 79 | markly (0.13.0) 80 | memory-leak (0.5.2) 81 | metrics (0.12.2) 82 | openssl (3.3.0) 83 | protocol-hpack (1.5.1) 84 | protocol-http (0.50.1) 85 | protocol-http1 (0.34.0) 86 | protocol-http (~> 0.22) 87 | protocol-http2 (0.22.1) 88 | protocol-hpack (~> 1.4) 89 | protocol-http (~> 0.47) 90 | protocol-rack (0.12.0) 91 | protocol-http (~> 0.43) 92 | rack (>= 1.0) 93 | protocol-websocket (0.20.2) 94 | protocol-http (~> 0.2) 95 | rack (3.1.14) 96 | samovar (2.3.0) 97 | console (~> 1.0) 98 | mapping (~> 1.0) 99 | traces (0.15.2) 100 | xrb (0.11.1) 101 | 102 | PLATFORMS 103 | arm64-darwin-23 104 | ruby 105 | 106 | DEPENDENCIES 107 | base64 108 | lively! 109 | markly 110 | 111 | BUNDLED WITH 112 | 2.5.5 113 | -------------------------------------------------------------------------------- /examples/tanks/gems.rb: -------------------------------------------------------------------------------- 1 | # Released under the MIT License. 2 | # Copyright, 2025, by Samuel Williams. 3 | 4 | source "https://rubygems.org" 5 | 6 | gem "lively", path: "../../" 7 | gem "markly" 8 | gem "base64" 9 | -------------------------------------------------------------------------------- /examples/tanks/png.rb: -------------------------------------------------------------------------------- 1 | # Released under the MIT License. 2 | # Copyright, 2025, by Samuel Williams. 3 | 4 | require 'zlib' 5 | 6 | module PNG 7 | def self.greyscale(width, height, data) 8 | # PNG file signature 9 | png_signature = "\x89PNG\r\n\x1a\n".b 10 | 11 | # IHDR chunk 12 | bit_depth = 8 # 8 bits per pixel 13 | color_type = 0 # Greyscale 14 | compression_method = 0 15 | filter_method = 0 16 | interlace_method = 0 17 | 18 | ihdr_data = [width, height, bit_depth, color_type, compression_method, filter_method, interlace_method].pack('N2C5') 19 | ihdr_crc = Zlib.crc32("IHDR" + ihdr_data) 20 | ihdr_chunk = [ihdr_data.bytesize].pack('N') + "IHDR" + ihdr_data + [ihdr_crc].pack('N') 21 | 22 | # IDAT chunk 23 | raw_data = "" 24 | height.times do |y| 25 | row = data.get_string(y * width, width) 26 | raw_data << "\x00" + row 27 | end 28 | 29 | # Compress data with no compression (just to fit PNG structure) 30 | compressed_data = Zlib::Deflate.deflate(raw_data, Zlib::NO_COMPRESSION) 31 | idat_crc = Zlib.crc32("IDAT" + compressed_data) 32 | idat_chunk = [compressed_data.bytesize].pack('N') + "IDAT" + compressed_data + [idat_crc].pack('N') 33 | 34 | # IEND chunk 35 | iend_crc = Zlib.crc32("IEND") 36 | iend_chunk = [0].pack('N') + "IEND" + [iend_crc].pack('N') 37 | 38 | # Combine all parts into the final PNG 39 | return png_signature + ihdr_chunk + idat_chunk + iend_chunk 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /examples/tanks/public/_static/index.css: -------------------------------------------------------------------------------- 1 | /* Center the table in the page */ 2 | 3 | body { 4 | display: flex; 5 | justify-content: center; 6 | align-items: center; 7 | height: 100vh; 8 | margin: 0; 9 | } 10 | 11 | /* Make a table display as a regular size 10px x 10px per cell */ 12 | 13 | table { 14 | border-collapse: collapse; 15 | border-spacing: 0; 16 | } 17 | 18 | td { 19 | width: 2rem; 20 | height: 2rem; 21 | padding: 0; 22 | margin: 0; 23 | border: 1px solid black; 24 | 25 | /* Center the character in the cell */ 26 | text-align: center; 27 | vertical-align: middle; 28 | } 29 | -------------------------------------------------------------------------------- /examples/worms-presentation/application.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env lively 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2025, by Samuel Williams. 6 | 7 | class WormsView < Live::View 8 | def render(builder) 9 | builder.tag("div") do 10 | builder.text("Hello, world!") 11 | end 12 | end 13 | end 14 | 15 | Application = Lively::Application[WormsView] 16 | -------------------------------------------------------------------------------- /examples/worms-presentation/gems.locked: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: ../.. 3 | specs: 4 | lively (0.10.1) 5 | falcon (~> 0.47) 6 | live (~> 0.17) 7 | xrb 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | async (2.24.0) 13 | console (~> 1.29) 14 | fiber-annotation 15 | io-event (~> 1.9) 16 | metrics (~> 0.12) 17 | traces (~> 0.15) 18 | async-container (0.24.0) 19 | async (~> 2.22) 20 | async-container-supervisor (0.5.1) 21 | async-container (~> 0.22) 22 | async-service 23 | io-endpoint 24 | memory-leak (~> 0.5) 25 | async-http (0.89.0) 26 | async (>= 2.10.2) 27 | async-pool (~> 0.9) 28 | io-endpoint (~> 0.14) 29 | io-stream (~> 0.6) 30 | metrics (~> 0.12) 31 | protocol-http (~> 0.49) 32 | protocol-http1 (~> 0.30) 33 | protocol-http2 (~> 0.22) 34 | traces (~> 0.10) 35 | async-http-cache (0.4.5) 36 | async-http (~> 0.56) 37 | async-pool (0.10.3) 38 | async (>= 1.25) 39 | async-service (0.13.0) 40 | async 41 | async-container (~> 0.16) 42 | async-websocket (0.30.0) 43 | async-http (~> 0.76) 44 | protocol-http (~> 0.34) 45 | protocol-rack (~> 0.7) 46 | protocol-websocket (~> 0.17) 47 | console (1.30.2) 48 | fiber-annotation 49 | fiber-local (~> 1.1) 50 | json 51 | falcon (0.51.1) 52 | async 53 | async-container (~> 0.20) 54 | async-container-supervisor (~> 0.5.0) 55 | async-http (~> 0.75) 56 | async-http-cache (~> 0.4) 57 | async-service (~> 0.10) 58 | bundler 59 | localhost (~> 1.1) 60 | openssl (~> 3.0) 61 | protocol-http (~> 0.31) 62 | protocol-rack (~> 0.7) 63 | samovar (~> 2.3) 64 | fiber-annotation (0.2.0) 65 | fiber-local (1.1.0) 66 | fiber-storage 67 | fiber-storage (1.0.1) 68 | io-endpoint (0.15.2) 69 | io-event (1.10.0) 70 | io-stream (0.6.1) 71 | json (2.11.3) 72 | live (0.17.0) 73 | async-websocket (~> 0.27) 74 | protocol-websocket (~> 0.19) 75 | xrb (~> 0.10) 76 | localhost (1.5.0) 77 | mapping (1.1.3) 78 | memory-leak (0.5.2) 79 | metrics (0.12.2) 80 | openssl (3.3.0) 81 | protocol-hpack (1.5.1) 82 | protocol-http (0.50.1) 83 | protocol-http1 (0.34.0) 84 | protocol-http (~> 0.22) 85 | protocol-http2 (0.22.1) 86 | protocol-hpack (~> 1.4) 87 | protocol-http (~> 0.47) 88 | protocol-rack (0.12.0) 89 | protocol-http (~> 0.43) 90 | rack (>= 1.0) 91 | protocol-websocket (0.20.2) 92 | protocol-http (~> 0.2) 93 | rack (3.1.14) 94 | samovar (2.3.0) 95 | console (~> 1.0) 96 | mapping (~> 1.0) 97 | thread-local (1.1.0) 98 | traces (0.15.2) 99 | xrb (0.11.1) 100 | 101 | PLATFORMS 102 | arm64-darwin-24 103 | ruby 104 | 105 | DEPENDENCIES 106 | live 107 | lively! 108 | thread-local 109 | 110 | BUNDLED WITH 111 | 2.6.2 112 | -------------------------------------------------------------------------------- /examples/worms-presentation/gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | source "https://rubygems.org" 7 | 8 | gem "live" 9 | gem "lively", path: "../../" 10 | 11 | gem "thread-local" 12 | -------------------------------------------------------------------------------- /examples/worms-presentation/public/_static/index.css: -------------------------------------------------------------------------------- 1 | /* Center the table in the page */ 2 | body { 3 | display: flex; 4 | justify-content: center; 5 | align-items: center; 6 | height: 100vh; 7 | } 8 | 9 | /* Table styling */ 10 | table { 11 | table-layout: fixed; 12 | border-spacing: 0; 13 | 14 | border-collapse: collapse; 15 | 16 | width: 100vmin; 17 | height: 100vmin; 18 | } 19 | 20 | /* Cell styling */ 21 | td { 22 | width: 4vmin; 23 | height: 4vmin; 24 | 25 | padding: 0; 26 | margin: 0; 27 | 28 | /* Center the content in the cell */ 29 | text-align: center; 30 | vertical-align: middle; 31 | 32 | /* Scale the font size with the viewport */ 33 | font-size: 3vmin; 34 | line-height: 1; 35 | 36 | border: 1px solid rgba(0, 0, 0, 0.5); 37 | } -------------------------------------------------------------------------------- /examples/worms-presentation/public/_static/pickupCoin.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketry/lively/2138fc2c489f8fdf692b2bdeaa1b08d091095f55/examples/worms-presentation/public/_static/pickupCoin.wav -------------------------------------------------------------------------------- /examples/worms-presentation/readme.md: -------------------------------------------------------------------------------- 1 | # Live Demo 2 | 3 | Initial code: 4 | 5 | ```ruby 6 | #!/usr/bin/env lively 7 | # frozen_string_literal: true 8 | 9 | # Released under the MIT License. 10 | # Copyright, 2025, by Samuel Williams. 11 | 12 | class WormsView < Live::View 13 | def render(builder) 14 | builder.tag("div") do 15 | builder.text("Hello, world!") 16 | end 17 | end 18 | end 19 | 20 | Application = Lively::Application[WormsView] 21 | ``` -------------------------------------------------------------------------------- /examples/worms/application.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env lively 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2024-2025, by Samuel Williams. 6 | 7 | require 'thread/local' 8 | 9 | class GameState 10 | extend Thread::Local 11 | 12 | # Initialize a new game state with a board. 13 | def initialize 14 | @board = Board.new 15 | @game = nil 16 | end 17 | 18 | attr :board 19 | 20 | # Add a new player to the game. 21 | # @returns [Player] The newly created player. 22 | def add_player 23 | player = @board.add_player 24 | Console.info(self, "Player joined", player: player) 25 | 26 | self.run! 27 | 28 | return player 29 | end 30 | 31 | # Remove a player from the game. 32 | # @parameter player [Player] The player to remove. 33 | def remove_player(player) 34 | @board.remove_player(player) 35 | Console.info(self, "Player left", player: player) 36 | 37 | if @board.players.empty? 38 | self.close 39 | end 40 | end 41 | 42 | # Start or resume the game loop. 43 | # @parameter dt [Float] The time interval between steps in seconds. 44 | def run!(dt = 0.2) 45 | @game ||= Async do 46 | while true 47 | @board.step 48 | sleep(dt) 49 | end 50 | end 51 | end 52 | 53 | # Stops the game loop if it is running and sets the game instance to nil. 54 | private def close 55 | if game = @game 56 | @game = nil 57 | game.stop 58 | end 59 | end 60 | end 61 | 62 | class Player 63 | attr_reader :head, :count, :color 64 | attr_accessor :direction 65 | 66 | # Initialize a new player. 67 | # @parameter board [Board] The game board. 68 | # @parameter start_y [Integer] The initial y position. 69 | # @parameter start_x [Integer] The initial x position. 70 | # @parameter color [String] The player's color in HSL format. 71 | def initialize(board, start_y, start_x, color) 72 | @board = board 73 | @head = [start_y, start_x] 74 | @count = 1 75 | @direction = :up 76 | @color = color 77 | @on_updated = nil 78 | end 79 | 80 | # Set or get the update callback. 81 | # @parameter block [Proc] Optional block to set as the callback. 82 | # @returns [Proc] The current callback. 83 | def on_updated(&block) 84 | if block_given? 85 | @on_updated = block 86 | end 87 | 88 | return @on_updated 89 | end 90 | 91 | # Advance the player one step in their current direction. 92 | # Handles movement, collision detection, and fruit collection. 93 | def step 94 | case @direction 95 | when :up 96 | @head[0] -= 1 97 | when :down 98 | @head[0] += 1 99 | when :left 100 | @head[1] -= 1 101 | when :right 102 | @head[1] += 1 103 | end 104 | 105 | if @head[0] < 0 || @head[0] >= @board.height || @head[1] < 0 || @head[1] >= @board.width 106 | reset! 107 | return 108 | end 109 | 110 | case @board.grid[@head[0]][@head[1]] 111 | when String 112 | @count += 1 113 | @board.remove_fruit!(@head[0], @head[1]) 114 | @board.add_fruit! 115 | when Integer, Hash 116 | reset! 117 | return 118 | end 119 | 120 | @board.grid[@head[0]][@head[1]] = {count: @count, color: @color} 121 | @on_updated&.call 122 | end 123 | 124 | # Reset the player to their initial state. 125 | def reset! 126 | # Convert segments into fruit before resetting 127 | @board.grid.each_with_index do |row, y| 128 | row.each_with_index do |cell, x| 129 | if cell.is_a?(Hash) && cell[:color] == @color 130 | @board.convert_to_fruit!(y, x) 131 | end 132 | end 133 | end 134 | 135 | @head = [@board.height/2, @board.width/2] 136 | @count = 1 137 | @direction = :up 138 | end 139 | end 140 | 141 | class Board 142 | FRUITS = ["🍎", "🍐", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🍈", "🍒"] 143 | COLOR_OFFSET = 23 144 | 145 | # Initialize a new game board. 146 | # @parameter width [Integer] The width of the board. 147 | # @parameter height [Integer] The height of the board. 148 | def initialize(width = 20, height = 20) 149 | @width = width 150 | @height = height 151 | @players = [] 152 | @fruit_count = 0 153 | 154 | reset! 155 | end 156 | 157 | attr :grid, :width, :height, :players 158 | 159 | # Add a new player to the board. 160 | # @returns [Player] The newly created player. 161 | def add_player 162 | hue = (@players.size * COLOR_OFFSET) % 360 163 | color = "hsl(#{hue}, 100%, 50%)" 164 | player = Player.new(self, @height/2, @width/2, color) 165 | @players << player 166 | 167 | # Add a fruit when a new player joins 168 | add_fruit! 169 | 170 | return player 171 | end 172 | 173 | # Remove a player from the board. 174 | # @parameter player [Player] The player to remove. 175 | def remove_player(player) 176 | @players.delete(player) 177 | end 178 | 179 | # Add a fruit to a random empty cell. 180 | # @returns [Array(Integer, Integer) | Nil] The coordinates of the added fruit, or nil if no space was found. 181 | def add_fruit! 182 | Console.info(self, "Adding fruit", fruit_count: @fruit_count, players: @players.size) 183 | # Only add fruit if we have fewer than one per player 184 | if @fruit_count < @players.size 185 | 5.times do 186 | y = rand(@height) 187 | x = rand(@width) 188 | 189 | if @grid[y][x].nil? 190 | @grid[y][x] = FRUITS.sample 191 | @fruit_count += 1 192 | return y, x 193 | end 194 | end 195 | end 196 | 197 | validate_fruit_count! 198 | 199 | return nil 200 | end 201 | 202 | # Remove a fruit from the specified coordinates. 203 | # @parameter y [Integer] The y coordinate. 204 | # @parameter x [Integer] The x coordinate. 205 | def remove_fruit!(y, x) 206 | if @grid[y][x].is_a?(String) 207 | @grid[y][x] = nil 208 | @fruit_count -= 1 209 | end 210 | 211 | validate_fruit_count! 212 | end 213 | 214 | # Validate that the fruit count matches the actual number of fruits on the board. 215 | # @raises [RuntimeError] If the fruit count is incorrect. 216 | def validate_fruit_count! 217 | actual_count = @grid.flatten.count { |cell| cell.is_a?(String) } 218 | if actual_count != @fruit_count 219 | raise "Fruit count mismatch: expected #{@fruit_count}, got #{actual_count}" 220 | end 221 | end 222 | 223 | # Convert a cell to fruit. 224 | # @parameter y [Integer] The y coordinate. 225 | # @parameter x [Integer] The x coordinate. 226 | def convert_to_fruit!(y, x) 227 | unless @grid[y][x].is_a?(String) 228 | @grid[y][x] = FRUITS.sample 229 | @fruit_count += 1 230 | end 231 | 232 | validate_fruit_count! 233 | end 234 | 235 | # Reset the board to its initial state. 236 | def reset! 237 | @grid = Array.new(@height) {Array.new(@width)} 238 | @players.each(&:reset!) 239 | @fruit_count = 0 240 | add_fruit! 241 | end 242 | 243 | # Decrement the count of all player segments. 244 | def decrement 245 | @grid.each do |row| 246 | row.map! do |cell| 247 | if cell.is_a?(Hash) 248 | cell[:count] -= 1 249 | cell[:count] == 0 ? nil : cell 250 | else 251 | cell 252 | end 253 | end 254 | end 255 | end 256 | 257 | # Advance the game state by one step. 258 | def step 259 | decrement 260 | @players.each(&:step) 261 | end 262 | end 263 | 264 | class WormsView < Live::View 265 | # Initialize a new view. 266 | def initialize(...) 267 | super 268 | 269 | @game_state = GameState.instance 270 | @player = nil 271 | end 272 | 273 | # Bind the view to a page and set up the player. 274 | # @parameter page [Object] The page to bind to. 275 | def bind(page) 276 | super 277 | 278 | @player = @game_state.add_player 279 | @player.on_updated { self.update! } 280 | end 281 | 282 | # Clean up resources when the view is closed. 283 | def close 284 | if @player 285 | @game_state.remove_player(@player) 286 | @player = nil 287 | end 288 | 289 | super 290 | end 291 | 292 | # Handle input events. 293 | # @parameter event [Hash] The event to handle. 294 | def handle(event) 295 | Console.info(self, event) 296 | 297 | case event[:type] 298 | when "keypress" 299 | handle_keypress(event[:detail]) 300 | when "touchend" 301 | handle_swipe(event[:detail]) 302 | end 303 | end 304 | 305 | # Handle keyboard input. 306 | # @parameter detail [Hash] The key press details. 307 | private def handle_keypress(detail) 308 | case detail[:key] 309 | when "w" 310 | @player.direction = :up 311 | when "s" 312 | @player.direction = :down 313 | when "a" 314 | @player.direction = :left 315 | when "d" 316 | @player.direction = :right 317 | end 318 | end 319 | 320 | # Handle swipe input. 321 | # @parameter detail [Hash] The swipe details. 322 | private def handle_swipe(detail) 323 | @player.direction = detail[:direction].to_sym 324 | end 325 | 326 | # Generate the JavaScript code to handle key press events. 327 | # @returns [String] The JavaScript code to handle key press events. 328 | private def forward_keypress 329 | "live.forwardEvent(#{JSON.dump(@id)}, event, {key: event.key});" 330 | end 331 | 332 | # Generate the JavaScript code to handle touch start events. 333 | # @returns [String] The JavaScript code to handle touch start events. 334 | private def forward_touchstart 335 | "this.touchStart = {x: event.touches[0].clientX, y: event.touches[0].clientY};" 336 | end 337 | 338 | # Generate the JavaScript code to handle touch end events. 339 | # @returns [String] The JavaScript code to handle touch end events. 340 | private def forward_touchend 341 | <<~JS 342 | if (this.touchStart) { 343 | const dx = event.changedTouches[0].clientX - this.touchStart.x; 344 | const dy = event.changedTouches[0].clientY - this.touchStart.y; 345 | 346 | let direction; 347 | if (Math.abs(dx) > Math.abs(dy)) { 348 | direction = dx > 0 ? 'right' : 'left'; 349 | } else { 350 | direction = dy > 0 ? 'down' : 'up'; 351 | } 352 | 353 | live.forwardEvent(#{JSON.dump(@id)}, event, {direction}); 354 | this.touchStart = null; 355 | } 356 | JS 357 | end 358 | 359 | # Render the game board. 360 | # @parameter builder [Object] The builder to use for rendering. 361 | def render(builder) 362 | builder.tag("table", 363 | tabIndex: 0, 364 | autofocus: true, 365 | onKeyPress: forward_keypress, 366 | onTouchStart: forward_touchstart, 367 | onTouchEnd: forward_touchend 368 | ) do 369 | @game_state.board.grid.each do |row| 370 | builder.tag("tr") do 371 | row.each do |cell| 372 | if cell.is_a?(Hash) 373 | style = "background-color: #{cell[:color]}" 374 | builder.tag("td", style: style) 375 | elsif cell.is_a?(String) 376 | builder.tag("td") do 377 | builder.text(cell) 378 | end 379 | else 380 | builder.tag("td") 381 | end 382 | end 383 | end 384 | end 385 | end 386 | end 387 | end 388 | 389 | Application = Lively::Application[WormsView] 390 | -------------------------------------------------------------------------------- /examples/worms/gems.locked: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: ../.. 3 | specs: 4 | lively (0.10.1) 5 | falcon (~> 0.47) 6 | live (~> 0.17) 7 | xrb 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | async (2.24.0) 13 | console (~> 1.29) 14 | fiber-annotation 15 | io-event (~> 1.9) 16 | metrics (~> 0.12) 17 | traces (~> 0.15) 18 | async-container (0.24.0) 19 | async (~> 2.22) 20 | async-container-supervisor (0.5.1) 21 | async-container (~> 0.22) 22 | async-service 23 | io-endpoint 24 | memory-leak (~> 0.5) 25 | async-http (0.89.0) 26 | async (>= 2.10.2) 27 | async-pool (~> 0.9) 28 | io-endpoint (~> 0.14) 29 | io-stream (~> 0.6) 30 | metrics (~> 0.12) 31 | protocol-http (~> 0.49) 32 | protocol-http1 (~> 0.30) 33 | protocol-http2 (~> 0.22) 34 | traces (~> 0.10) 35 | async-http-cache (0.4.5) 36 | async-http (~> 0.56) 37 | async-pool (0.10.3) 38 | async (>= 1.25) 39 | async-service (0.13.0) 40 | async 41 | async-container (~> 0.16) 42 | async-websocket (0.30.0) 43 | async-http (~> 0.76) 44 | protocol-http (~> 0.34) 45 | protocol-rack (~> 0.7) 46 | protocol-websocket (~> 0.17) 47 | console (1.30.2) 48 | fiber-annotation 49 | fiber-local (~> 1.1) 50 | json 51 | falcon (0.51.1) 52 | async 53 | async-container (~> 0.20) 54 | async-container-supervisor (~> 0.5.0) 55 | async-http (~> 0.75) 56 | async-http-cache (~> 0.4) 57 | async-service (~> 0.10) 58 | bundler 59 | localhost (~> 1.1) 60 | openssl (~> 3.0) 61 | protocol-http (~> 0.31) 62 | protocol-rack (~> 0.7) 63 | samovar (~> 2.3) 64 | fiber-annotation (0.2.0) 65 | fiber-local (1.1.0) 66 | fiber-storage 67 | fiber-storage (1.0.1) 68 | io-endpoint (0.15.2) 69 | io-event (1.10.0) 70 | io-stream (0.6.1) 71 | json (2.11.3) 72 | live (0.17.0) 73 | async-websocket (~> 0.27) 74 | protocol-websocket (~> 0.19) 75 | xrb (~> 0.10) 76 | localhost (1.5.0) 77 | mapping (1.1.3) 78 | memory-leak (0.5.2) 79 | metrics (0.12.2) 80 | openssl (3.3.0) 81 | protocol-hpack (1.5.1) 82 | protocol-http (0.50.1) 83 | protocol-http1 (0.34.0) 84 | protocol-http (~> 0.22) 85 | protocol-http2 (0.22.1) 86 | protocol-hpack (~> 1.4) 87 | protocol-http (~> 0.47) 88 | protocol-rack (0.12.0) 89 | protocol-http (~> 0.43) 90 | rack (>= 1.0) 91 | protocol-websocket (0.20.2) 92 | protocol-http (~> 0.2) 93 | rack (3.1.14) 94 | samovar (2.3.0) 95 | console (~> 1.0) 96 | mapping (~> 1.0) 97 | thread-local (1.1.0) 98 | traces (0.15.2) 99 | xrb (0.11.1) 100 | 101 | PLATFORMS 102 | arm64-darwin-23 103 | ruby 104 | 105 | DEPENDENCIES 106 | lively! 107 | thread-local 108 | 109 | BUNDLED WITH 110 | 2.5.5 111 | -------------------------------------------------------------------------------- /examples/worms/gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024-2025, by Samuel Williams. 5 | 6 | source "https://rubygems.org" 7 | 8 | gem "lively", path: "../../" 9 | 10 | gem "thread-local" 11 | -------------------------------------------------------------------------------- /examples/worms/public/_static/index.css: -------------------------------------------------------------------------------- 1 | /* Center the table in the page */ 2 | body { 3 | display: flex; 4 | justify-content: center; 5 | align-items: center; 6 | height: 100vh; 7 | margin: 0; 8 | } 9 | 10 | /* Table styling */ 11 | table { 12 | table-layout: fixed; 13 | border-spacing: 0; 14 | 15 | border-collapse: collapse; 16 | 17 | width: 100vmin; 18 | height: 100vmin; 19 | } 20 | 21 | /* Cell styling */ 22 | td { 23 | width: 4vmin; 24 | height: 4vmin; 25 | 26 | padding: 0; 27 | margin: 0; 28 | 29 | /* Center the content in the cell */ 30 | text-align: center; 31 | vertical-align: middle; 32 | 33 | /* Scale the font size with the viewport */ 34 | font-size: 3vmin; 35 | line-height: 1; 36 | } -------------------------------------------------------------------------------- /gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2021-2024, by Samuel Williams. 5 | 6 | source "https://rubygems.org" 7 | 8 | gemspec 9 | 10 | gem "utopia" 11 | gem "io-watch" 12 | 13 | group :maintenance, optional: true do 14 | gem "bake-gem" 15 | gem "bake-modernize" 16 | 17 | gem "utopia-project" 18 | end 19 | 20 | group :test do 21 | gem "sus" 22 | gem "covered" 23 | gem "decode" 24 | gem "rubocop" 25 | 26 | gem "sus-fixtures-async-http" 27 | gem "sus-fixtures-async-webdriver" 28 | 29 | gem "bake-test" 30 | gem "bake-test-external" 31 | end 32 | -------------------------------------------------------------------------------- /lib/lively.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2021-2024, by Samuel Williams. 5 | 6 | require_relative "lively/version" 7 | 8 | require_relative "lively/assets" 9 | require_relative "lively/application" 10 | 11 | require_relative "lively/environment/application" 12 | -------------------------------------------------------------------------------- /lib/lively/application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2021-2024, by Samuel Williams. 5 | 6 | require "live" 7 | require "protocol/http/middleware" 8 | require "async/websocket/adapters/http" 9 | 10 | require_relative "pages/index" 11 | require_relative "hello_world" 12 | 13 | module Lively 14 | class Application < Protocol::HTTP::Middleware 15 | def self.[](tag) 16 | klass = Class.new(self) 17 | 18 | klass.define_singleton_method(:resolver) do 19 | Live::Resolver.allow(tag) 20 | end 21 | 22 | klass.define_method(:body) do 23 | tag.new 24 | end 25 | 26 | return klass 27 | end 28 | 29 | def self.resolver 30 | Live::Resolver.allow(HelloWorld) 31 | end 32 | 33 | def initialize(delegate, resolver: self.class.resolver) 34 | super(delegate) 35 | 36 | @resolver = resolver 37 | end 38 | 39 | def live(connection) 40 | Live::Page.new(@resolver).run(connection) 41 | end 42 | 43 | def title 44 | self.class.name 45 | end 46 | 47 | def body(...) 48 | HelloWorld.new(...) 49 | end 50 | 51 | def index(...) 52 | Pages::Index.new(title: self.title, body: self.body(...)) 53 | end 54 | 55 | def handle(request, ...) 56 | return Protocol::HTTP::Response[200, [], [self.index(...).call]] 57 | end 58 | 59 | def call(request) 60 | if request.path == "/live" 61 | return Async::WebSocket::Adapters::HTTP.open(request, &self.method(:live)) || Protocol::HTTP::Response[400] 62 | else 63 | return handle(request) 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/lively/assets.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2021-2025, by Samuel Williams. 5 | 6 | require "protocol/http/middleware" 7 | require "protocol/http/body/file" 8 | 9 | module Lively 10 | class Assets < Protocol::HTTP::Middleware 11 | DEFAULT_CACHE_CONTROL = "no-store, no-cache, must-revalidate, max-age=0" 12 | 13 | DEFAULT_CONTENT_TYPES = { 14 | ".html" => "text/html", 15 | ".css" => "text/css", 16 | ".js" => "application/javascript", 17 | ".png" => "image/png", 18 | ".jpeg" => "image/jpeg", 19 | ".gif" => "image/gif", 20 | ".mp3" => "audio/mpeg", 21 | ".wav" => "audio/wav", 22 | } 23 | 24 | PUBLIC_ROOT = File.expand_path("../../public", __dir__) 25 | 26 | def initialize(delegate, root: PUBLIC_ROOT, content_types: DEFAULT_CONTENT_TYPES, cache_control: DEFAULT_CACHE_CONTROL) 27 | super(delegate) 28 | 29 | @root = root 30 | 31 | @content_types = content_types 32 | @cache_control = cache_control 33 | end 34 | 35 | def freeze 36 | return self if frozen? 37 | 38 | @root.freeze 39 | @content_types.freeze 40 | @cache_control.freeze 41 | 42 | super 43 | end 44 | 45 | def response_for(path, content_type) 46 | headers = [ 47 | ["content-type", content_type], 48 | ["cache-control", @cache_control], 49 | ] 50 | 51 | return Protocol::HTTP::Response[200, headers, Protocol::HTTP::Body::File.open(path)] 52 | end 53 | 54 | def expand_path(path) 55 | File.realpath(File.join(@root, path)) 56 | rescue Errno::ENOENT 57 | nil 58 | end 59 | 60 | def call(request) 61 | if path = expand_path(request.path) 62 | extension = File.extname(path) 63 | content_type = @content_types[extension] 64 | 65 | if path.start_with?(@root) && File.exist?(path) && content_type 66 | return response_for(path, content_type) 67 | end 68 | end 69 | 70 | super 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/lively/environment/application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2021-2024, by Samuel Williams. 5 | 6 | require_relative "../application" 7 | require_relative "../assets" 8 | 9 | require "falcon/environment/server" 10 | 11 | module Lively 12 | module Environment 13 | module Application 14 | include Falcon::Environment::Server 15 | 16 | # def url 17 | # "http://localhost:9292" 18 | # end 19 | 20 | def count 21 | 1 22 | end 23 | 24 | def application 25 | if Object.const_defined?(:Application) 26 | ::Application 27 | else 28 | Console.warn(self, "No Application class defined, using default.") 29 | ::Lively::Application 30 | end 31 | end 32 | 33 | def middleware 34 | ::Protocol::HTTP::Middleware.build do |builder| 35 | builder.use Lively::Assets, root: File.expand_path("public", self.root) 36 | builder.use Lively::Assets, root: File.expand_path("../../../public", __dir__) 37 | builder.use self.application 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/lively/hello_world.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024-2025, by Samuel Williams. 5 | 6 | module Lively 7 | class HelloWorld < Live::View 8 | def initialize(...) 9 | super 10 | 11 | @clock = nil 12 | end 13 | 14 | def bind(page) 15 | super 16 | 17 | @clock ||= Async do 18 | while true 19 | self.update! 20 | 21 | sleep 1 22 | end 23 | end 24 | end 25 | 26 | def close 27 | @clock&.stop 28 | 29 | super 30 | end 31 | 32 | def render(builder) 33 | builder.tag(:h1) do 34 | builder.text("Hello, I'm Lively!") 35 | end 36 | 37 | builder.tag(:p) do 38 | builder.text("The time is #{Time.now}.") 39 | end 40 | 41 | builder.tag(:p) do 42 | builder.text(<<~TEXT) 43 | Lively is a simple client-server SPA framework. It is designed to be easy to use and understand, while providing a solid foundation for building interactive web applications. Create an `application.rb` file and define your own `Application` class to get started. 44 | TEXT 45 | end 46 | 47 | builder.inline_tag(:pre) do 48 | builder.text(<<~TEXT) 49 | #!/usr/bin/env lively 50 | 51 | class Application < Lively::Application 52 | def body 53 | Lively::HelloWorld.new 54 | end 55 | end 56 | TEXT 57 | end 58 | 59 | builder.tag(:p) do 60 | builder.text("Check the `examples/` directory for... you guessed it... more examples.") 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/lively/pages/index.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2021-2024, by Samuel Williams. 5 | 6 | require "xrb/template" 7 | 8 | module Lively 9 | module Pages 10 | class Index 11 | def initialize(title: "Lively", body: "Hello World") 12 | @title = title 13 | @body = body 14 | 15 | path = File.expand_path("index.xrb", __dir__) 16 | @template = XRB::Template.load_file(path) 17 | end 18 | 19 | attr :title 20 | attr :body 21 | 22 | def call 23 | @template.to_string(self) 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/lively/pages/index.xrb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | #{self.title} 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 21 | 22 | 26 | 27 | 28 | 29 | #{self.body&.to_html || "No body specified!"} 30 | 31 | -------------------------------------------------------------------------------- /lib/lively/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2021-2024, by Samuel Williams. 5 | 6 | module Lively 7 | VERSION = "0.11.0" 8 | end 9 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright, 2021-2025, by Samuel Williams. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lively.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/lively/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "lively" 7 | spec.version = Lively::VERSION 8 | 9 | spec.summary = "A simple client-server SPA framework." 10 | spec.authors = ["Samuel Williams"] 11 | spec.license = "MIT" 12 | 13 | spec.cert_chain = ["release.cert"] 14 | spec.signing_key = File.expand_path("~/.gem/release.pem") 15 | 16 | spec.homepage = "https://github.com/socketry/lively" 17 | 18 | spec.metadata = { 19 | "documentation_uri" => "https://socketry.github.io/lively/", 20 | "source_code_uri" => "https://github.com/socketry/lively.git", 21 | } 22 | 23 | spec.files = Dir.glob(["{bin,lib,public}/**/*", "*.md"], File::FNM_DOTMATCH, base: __dir__) 24 | 25 | spec.executables = ["lively"] 26 | 27 | spec.required_ruby_version = ">= 3.2" 28 | 29 | spec.add_dependency "falcon", "~> 0.47" 30 | spec.add_dependency "live", "~> 0.17" 31 | spec.add_dependency "xrb" 32 | end 33 | -------------------------------------------------------------------------------- /node_modules/.package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lively", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "node_modules/@socketry/live": { 7 | "version": "0.14.0", 8 | "resolved": "https://registry.npmjs.org/@socketry/live/-/live-0.14.0.tgz", 9 | "integrity": "sha512-33TaP1+ShULuqaVaxUJc44WLV3lhlWSjG4jfg8sjbdQ2IoTq0mRgVpvuz6wXUSi56ahoDUV9GhC5CvsBxUMgWA==", 10 | "dependencies": { 11 | "morphdom": "^2.7" 12 | } 13 | }, 14 | "node_modules/morphdom": { 15 | "version": "2.7.2", 16 | "resolved": "https://registry.npmjs.org/morphdom/-/morphdom-2.7.2.tgz", 17 | "integrity": "sha512-Dqb/lHFyTi7SZpY0a5R4I/0Edo+iPMbaUexsHHsLAByyixCDiLHPHyVoKVmrpL0THcT7V9Cgev9y21TQYq6wQg==" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /node_modules/@socketry/live/Live.js: -------------------------------------------------------------------------------- 1 | import morphdom from 'morphdom'; 2 | 3 | export class Live { 4 | #window; 5 | #document; 6 | #server; 7 | #events; 8 | #failures; 9 | #reconnectTimer; 10 | 11 | static start(options = {}) { 12 | let window = options.window || globalThis; 13 | let path = options.path || 'live' 14 | let base = options.base || window.location.href; 15 | 16 | let url = new URL(path, base); 17 | url.protocol = url.protocol.replace('http', 'ws'); 18 | 19 | return new this(window, url); 20 | } 21 | 22 | constructor(window, url) { 23 | this.#window = window; 24 | this.#document = window.document; 25 | 26 | this.url = url; 27 | this.#server = null; 28 | this.#events = []; 29 | 30 | this.#failures = 0; 31 | this.#reconnectTimer = null; 32 | 33 | // Track visibility state and connect if required: 34 | this.#document.addEventListener("visibilitychange", () => this.#handleVisibilityChange()); 35 | 36 | this.#handleVisibilityChange(); 37 | 38 | const elementNodeType = this.#window.Node.ELEMENT_NODE; 39 | 40 | // Create a MutationObserver to watch for removed nodes 41 | this.observer = new this.#window.MutationObserver((mutationsList, observer) => { 42 | for (let mutation of mutationsList) { 43 | if (mutation.type === 'childList') { 44 | for (let node of mutation.removedNodes) { 45 | if (node.nodeType !== elementNodeType) continue; 46 | 47 | if (node.classList?.contains('live')) { 48 | this.#unbind(node); 49 | } 50 | 51 | // Unbind any child nodes: 52 | for (let child of node.getElementsByClassName('live')) { 53 | this.#unbind(child); 54 | } 55 | } 56 | 57 | for (let node of mutation.addedNodes) { 58 | if (node.nodeType !== elementNodeType) continue; 59 | 60 | if (node.classList.contains('live')) { 61 | this.#bind(node); 62 | } 63 | 64 | // Bind any child nodes: 65 | for (let child of node.getElementsByClassName('live')) { 66 | this.#bind(child); 67 | } 68 | } 69 | } 70 | } 71 | }); 72 | 73 | this.observer.observe(this.#document.body, {childList: true, subtree: true}); 74 | } 75 | 76 | // -- Connection Handling -- 77 | 78 | connect() { 79 | if (this.#server) { 80 | return this.#server; 81 | } 82 | 83 | let server = this.#server = new this.#window.WebSocket(this.url); 84 | 85 | if (this.#reconnectTimer) { 86 | clearTimeout(this.#reconnectTimer); 87 | this.#reconnectTimer = null; 88 | } 89 | 90 | server.onopen = () => { 91 | this.#failures = 0; 92 | this.#flush(); 93 | this.#attach(); 94 | }; 95 | 96 | server.onmessage = (message) => { 97 | const [name, ...args] = JSON.parse(message.data); 98 | 99 | this[name](...args); 100 | }; 101 | 102 | // The remote end has disconnected: 103 | server.addEventListener('error', () => { 104 | this.#failures += 1; 105 | }); 106 | 107 | server.addEventListener('close', () => { 108 | // Explicit disconnect will clear `this.#server`: 109 | if (this.#server && !this.#reconnectTimer) { 110 | // We need a minimum delay otherwise this can end up immediately invoking the callback: 111 | const delay = Math.min(100 * (this.#failures ** 2), 60000); 112 | this.#reconnectTimer = setTimeout(() => { 113 | this.#reconnectTimer = null; 114 | this.connect(); 115 | }, delay); 116 | } 117 | 118 | if (this.#server === server) { 119 | this.#server = null; 120 | } 121 | }); 122 | 123 | return server; 124 | } 125 | 126 | disconnect() { 127 | if (this.#server) { 128 | const server = this.#server; 129 | this.#server = null; 130 | server.close(); 131 | } 132 | 133 | if (this.#reconnectTimer) { 134 | clearTimeout(this.#reconnectTimer); 135 | this.#reconnectTimer = null; 136 | } 137 | } 138 | 139 | #send(message) { 140 | if (this.#server) { 141 | try { 142 | return this.#server.send(message); 143 | } catch (error) { 144 | // console.log("Live.send", "failed to send message to server", error); 145 | } 146 | } 147 | 148 | this.#events.push(message); 149 | } 150 | 151 | #flush() { 152 | if (this.#events.length === 0) return; 153 | 154 | let events = this.#events; 155 | this.#events = []; 156 | 157 | for (var event of events) { 158 | this.#send(event); 159 | } 160 | } 161 | 162 | #handleVisibilityChange() { 163 | if (this.#document.hidden) { 164 | this.disconnect(); 165 | } else { 166 | this.connect(); 167 | } 168 | } 169 | 170 | #bind(element) { 171 | console.log("bind", element.id, element.dataset); 172 | 173 | this.#send(JSON.stringify(['bind', element.id, element.dataset])); 174 | } 175 | 176 | #unbind(element) { 177 | console.log("unbind", element.id, element.dataset); 178 | 179 | if (this.#server) { 180 | this.#send(JSON.stringify(['unbind', element.id])); 181 | } 182 | } 183 | 184 | #attach() { 185 | for (let node of this.#document.getElementsByClassName('live')) { 186 | this.#bind(node); 187 | } 188 | } 189 | 190 | #createDocumentFragment(html) { 191 | return this.#document.createRange().createContextualFragment(html); 192 | } 193 | 194 | #reply(options, ...args) { 195 | if (options?.reply) { 196 | this.#send(JSON.stringify(['reply', options.reply, ...args])); 197 | } 198 | } 199 | 200 | // -- RPC Methods -- 201 | 202 | script(id, code, options) { 203 | let element = this.#document.getElementById(id); 204 | 205 | try { 206 | let result = this.#window.Function(code).call(element); 207 | 208 | this.#reply(options, result); 209 | } catch (error) { 210 | this.#reply(options, null, {name: error.name, message: error.message, stack: error.stack}); 211 | } 212 | } 213 | 214 | update(id, html, options) { 215 | let element = this.#document.getElementById(id); 216 | let fragment = this.#createDocumentFragment(html); 217 | 218 | morphdom(element, fragment); 219 | 220 | this.#reply(options); 221 | } 222 | 223 | replace(selector, html, options) { 224 | let elements = this.#document.querySelectorAll(selector); 225 | let fragment = this.#createDocumentFragment(html); 226 | 227 | elements.forEach(element => morphdom(element, fragment.cloneNode(true))); 228 | 229 | this.#reply(options); 230 | } 231 | 232 | prepend(selector, html, options) { 233 | let elements = this.#document.querySelectorAll(selector); 234 | let fragment = this.#createDocumentFragment(html); 235 | 236 | elements.forEach(element => element.prepend(fragment.cloneNode(true))); 237 | 238 | this.#reply(options); 239 | } 240 | 241 | append(selector, html, options) { 242 | let elements = this.#document.querySelectorAll(selector); 243 | let fragment = this.#createDocumentFragment(html); 244 | 245 | elements.forEach(element => element.append(fragment.cloneNode(true))); 246 | 247 | this.#reply(options); 248 | } 249 | 250 | remove(selector, options) { 251 | let elements = this.#document.querySelectorAll(selector); 252 | 253 | elements.forEach(element => element.remove()); 254 | 255 | this.#reply(options); 256 | } 257 | 258 | dispatchEvent(selector, type, options) { 259 | let elements = this.#document.querySelectorAll(selector); 260 | 261 | elements.forEach(element => element.dispatchEvent( 262 | new this.#window.CustomEvent(type, options) 263 | )); 264 | 265 | this.#reply(options); 266 | } 267 | 268 | error(message) { 269 | console.error("Live.error", ...arguments); 270 | } 271 | 272 | // -- Event Handling -- 273 | 274 | forward(id, event) { 275 | this.connect(); 276 | 277 | this.#send( 278 | JSON.stringify(['event', id, event]) 279 | ); 280 | } 281 | 282 | forwardEvent(id, event, detail, preventDefault = false) { 283 | if (preventDefault) event.preventDefault(); 284 | 285 | this.forward(id, {type: event.type, detail: detail}); 286 | } 287 | 288 | forwardFormEvent(id, event, detail, preventDefault = true) { 289 | if (preventDefault) event.preventDefault(); 290 | 291 | let form = event.form; 292 | let formData = new FormData(form); 293 | 294 | this.forward(id, {type: event.type, detail: detail, formData: [...formData]}); 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /node_modules/@socketry/live/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@socketry/live", 3 | "type": "module", 4 | "version": "0.14.0", 5 | "description": "Live HTML tags for Ruby.", 6 | "main": "Live.js", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/socketry/live-js.git" 10 | }, 11 | "scripts": { 12 | "test": "node --test" 13 | }, 14 | "devDependencies": { 15 | "jsdom": "^24.0", 16 | "ws": "^8.17" 17 | }, 18 | "dependencies": { 19 | "morphdom": "^2.7" 20 | }, 21 | "keywords": [ 22 | "live", 23 | "dynamic", 24 | "html", 25 | "ruby" 26 | ], 27 | "author": "Samuel Williams (http://www.codeotaku.com/)", 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/socketry/live-js/issues" 31 | }, 32 | "homepage": "https://github.com/socketry/live-js#readme" 33 | } 34 | -------------------------------------------------------------------------------- /node_modules/@socketry/live/readme.md: -------------------------------------------------------------------------------- 1 | # Live (JavaScript) 2 | 3 | This is the JavaScript library for implementing the Ruby gem of the same name. 4 | 5 | ## Document Manipulation 6 | 7 | ### `live.update(id, html, options)` 8 | 9 | Updates the content of the element with the given `id` with the given `html`. The `options` parameter is optional and can be used to pass additional options to the update method. 10 | 11 | - `options.reply` - If truthy, the server will reply with `{reply: options.reply}`. 12 | 13 | ### `live.replace(selector, html, options)` 14 | 15 | Replaces the element(s) selected by the given `selector` with the given `html`. The `options` parameter is optional and can be used to pass additional options to the replace method. 16 | 17 | - `options.reply` - If truthy, the server will reply with `{reply: options.reply}`. 18 | 19 | ### `live.prepend(selector, html, options)` 20 | 21 | Prepends the given `html` to the element(s) selected by the given `selector`. The `options` parameter is optional and can be used to pass additional options to the prepend method. 22 | 23 | - `options.reply` - If truthy, the server will reply with `{reply: options.reply}`. 24 | 25 | ### `live.append(selector, html, options)` 26 | 27 | Appends the given `html` to the element(s) selected by the given `selector`. The `options` parameter is optional and can be used to pass additional options to the append method. 28 | 29 | - `options.reply` - If truthy, the server will reply with `{reply: options.reply}`. 30 | 31 | ### `live.remove(selector, options)` 32 | 33 | Removes the element(s) selected by the given `selector`. The `options` parameter is optional and can be used to pass additional options to the remove method. 34 | 35 | - `options.reply` - If truthy, the server will reply with `{reply: options.reply}`. 36 | 37 | ### `live.dispatchEvent(selector, type, options)` 38 | 39 | Dispatches an event of the given `type` on the element(s) selected by the given `selector`. The `options` parameter is optional and can be used to pass additional options to the dispatchEvent method. 40 | 41 | - `options.detail` - The detail object to pass to the event. 42 | - `options.bubbles` - A boolean indicating whether the event should bubble up through the DOM. 43 | - `options.cancelable` - A boolean indicating whether the event can be canceled. 44 | - `options.composed` - A boolean indicating whether the event will trigger listeners outside of a shadow root. 45 | 46 | ## Event Handling 47 | 48 | ### `live.forward(id, event)` 49 | 50 | Connect and forward an event on the element with the given `id`. If the connection can't be established, the event will be buffered. 51 | 52 | ### `live.forwardEvent(id, event, details)` 53 | 54 | Forward a HTML DOM event to the server. The `details` parameter is optional and can be used to pass additional details to the server. 55 | 56 | ### `live.forwardFormEvent(id, event, details)` 57 | 58 | Forward an event which has form data to the server. The `details` parameter is optional and can be used to pass additional details to the server. 59 | -------------------------------------------------------------------------------- /node_modules/@socketry/live/test/Live.js: -------------------------------------------------------------------------------- 1 | import {describe, before, beforeEach, after, it} from 'node:test'; 2 | import {ok, strict, strictEqual, deepStrictEqual} from 'node:assert'; 3 | 4 | import {WebSocket} from 'ws'; 5 | import {JSDOM} from 'jsdom'; 6 | import {Live} from '../Live.js'; 7 | 8 | class Queue { 9 | constructor() { 10 | this.items = []; 11 | this.waiting = []; 12 | } 13 | 14 | push(item) { 15 | if (this.waiting.length > 0) { 16 | let resolve = this.waiting.shift(); 17 | resolve(item); 18 | } else { 19 | this.items.push(item); 20 | } 21 | } 22 | 23 | pop() { 24 | return new Promise(resolve => { 25 | if (this.items.length > 0) { 26 | resolve(this.items.shift()); 27 | } else { 28 | this.waiting.push(resolve); 29 | } 30 | }); 31 | } 32 | 33 | async popUntil(callback) { 34 | while (true) { 35 | let item = await this.pop(); 36 | if (callback(item)) return item; 37 | } 38 | } 39 | 40 | clear() { 41 | this.items = []; 42 | this.waiting = []; 43 | } 44 | } 45 | 46 | describe('Live', function () { 47 | let dom; 48 | let webSocketServer; 49 | let messages = new Queue(); 50 | 51 | const webSocketServerConfig = {port: 3000}; 52 | const webSocketServerURL = `ws://localhost:${webSocketServerConfig.port}/live`; 53 | 54 | before(async function () { 55 | const listening = new Promise((resolve, reject) => { 56 | webSocketServer = new WebSocket.Server(webSocketServerConfig, resolve); 57 | webSocketServer.on('error', reject); 58 | }); 59 | 60 | dom = new JSDOM('

Hello World

'); 61 | // Ensure the WebSocket class is available: 62 | dom.window.WebSocket = WebSocket; 63 | 64 | await new Promise(resolve => dom.window.addEventListener('load', resolve)); 65 | 66 | await listening; 67 | 68 | webSocketServer.on('connection', socket => { 69 | socket.on('message', message => { 70 | let payload = JSON.parse(message); 71 | messages.push(payload); 72 | }); 73 | }); 74 | }); 75 | 76 | beforeEach(function () { 77 | messages.clear(); 78 | }); 79 | 80 | after(function () { 81 | webSocketServer.close(); 82 | }); 83 | 84 | it('should start the live connection', function () { 85 | const live = Live.start({window: dom.window, base: 'http://localhost/'}); 86 | ok(live); 87 | 88 | strictEqual(live.url.href, 'ws://localhost/live'); 89 | 90 | live.disconnect(); 91 | }); 92 | 93 | it('should connect to the WebSocket server', function () { 94 | const live = new Live(dom.window, webSocketServerURL); 95 | 96 | const server = live.connect(); 97 | ok(server); 98 | 99 | live.disconnect(); 100 | }); 101 | 102 | it('should handle visibility changes', async function () { 103 | const live = new Live(dom.window, webSocketServerURL); 104 | 105 | // It's tricky to test the method directly. 106 | // - Changing document.hidden is a hack. 107 | // - Sending custom events seems to cause a hang. 108 | 109 | live.connect(); 110 | deepStrictEqual(await messages.pop(), ['bind', 'my', {}]); 111 | 112 | live.disconnect(); 113 | 114 | live.connect() 115 | deepStrictEqual(await messages.pop(), ['bind', 'my', {}]); 116 | 117 | live.disconnect(); 118 | }); 119 | 120 | it('can execute scripts', async function () { 121 | const live = new Live(dom.window, webSocketServerURL); 122 | 123 | live.connect(); 124 | 125 | const connected = new Promise(resolve => { 126 | webSocketServer.on('connection', resolve); 127 | }); 128 | 129 | let socket = await connected; 130 | 131 | socket.send( 132 | JSON.stringify(['script', 'my', 'return 1+2', {reply: true}]) 133 | ); 134 | 135 | let successReply = await messages.popUntil(message => message[0] == 'reply'); 136 | strictEqual(successReply[2], 3); 137 | 138 | socket.send( 139 | JSON.stringify(['script', 'my', 'throw new Error("Test Error")', {reply: true}]) 140 | ); 141 | 142 | let errorReply = await messages.popUntil(message => message[0] == 'reply'); 143 | strictEqual(errorReply[2], null); 144 | console.log(errorReply); 145 | 146 | live.disconnect(); 147 | }); 148 | 149 | it('should handle updates', async function () { 150 | const live = new Live(dom.window, webSocketServerURL); 151 | 152 | live.connect(); 153 | 154 | const connected = new Promise(resolve => { 155 | webSocketServer.on('connection', resolve); 156 | }); 157 | 158 | let socket = await connected; 159 | 160 | socket.send( 161 | JSON.stringify(['update', 'my', '

Goodbye World!

', {reply: true}]) 162 | ); 163 | 164 | await messages.popUntil(message => message[0] == 'reply'); 165 | 166 | strictEqual(dom.window.document.getElementById('my').innerHTML, '

Goodbye World!

'); 167 | 168 | live.disconnect(); 169 | }); 170 | 171 | it('should handle updates with child live elements', async function () { 172 | const live = new Live(dom.window, webSocketServerURL); 173 | 174 | live.connect(); 175 | 176 | const connected = new Promise(resolve => { 177 | webSocketServer.on('connection', resolve); 178 | }); 179 | 180 | let socket = await connected; 181 | 182 | socket.send( 183 | JSON.stringify(['update', 'my', '
']) 184 | ); 185 | 186 | let payload = await messages.popUntil(message => message[0] == 'bind'); 187 | deepStrictEqual(payload, ['bind', 'mychild', {}]); 188 | 189 | live.disconnect(); 190 | }); 191 | 192 | it('can unbind removed elements', async function () { 193 | dom.window.document.body.innerHTML = '

Hello World

'; 194 | 195 | const live = new Live(dom.window, webSocketServerURL); 196 | 197 | live.connect(); 198 | 199 | dom.window.document.getElementById('my').remove(); 200 | 201 | let payload = await messages.popUntil(message => { 202 | return message[0] == 'unbind' && message[1] == 'my'; 203 | }); 204 | 205 | deepStrictEqual(payload, ['unbind', 'my']); 206 | 207 | live.disconnect(); 208 | }); 209 | 210 | it('should handle replacements', async function () { 211 | dom.window.document.body.innerHTML = '

Hello World

'; 212 | 213 | const live = new Live(dom.window, webSocketServerURL); 214 | 215 | live.connect(); 216 | 217 | const connected = new Promise(resolve => { 218 | webSocketServer.on('connection', resolve); 219 | }); 220 | 221 | let socket = await connected; 222 | 223 | socket.send( 224 | JSON.stringify(['replace', '#my p', '

Replaced!

', {reply: true}]) 225 | ); 226 | 227 | await messages.popUntil(message => message[0] == 'reply'); 228 | strictEqual(dom.window.document.getElementById('my').innerHTML, '

Replaced!

'); 229 | 230 | live.disconnect(); 231 | }); 232 | 233 | it('should handle prepends', async function () { 234 | const live = new Live(dom.window, webSocketServerURL); 235 | 236 | live.connect(); 237 | 238 | const connected = new Promise(resolve => { 239 | webSocketServer.on('connection', resolve); 240 | }); 241 | 242 | let socket = await connected; 243 | 244 | socket.send( 245 | JSON.stringify(['update', 'my', '

Middle

']) 246 | ); 247 | 248 | socket.send( 249 | JSON.stringify(['prepend', '#my', '

Prepended!

', {reply: true}]) 250 | ); 251 | 252 | await messages.popUntil(message => message[0] == 'reply'); 253 | strictEqual(dom.window.document.getElementById('my').innerHTML, '

Prepended!

Middle

'); 254 | 255 | live.disconnect(); 256 | }); 257 | 258 | it('should handle appends', async function () { 259 | const live = new Live(dom.window, webSocketServerURL); 260 | 261 | live.connect(); 262 | 263 | const connected = new Promise(resolve => { 264 | webSocketServer.on('connection', resolve); 265 | }); 266 | 267 | let socket = await connected; 268 | 269 | socket.send( 270 | JSON.stringify(['update', 'my', '

Middle

']) 271 | ); 272 | 273 | socket.send( 274 | JSON.stringify(['append', '#my', '

Appended!

', {reply: true}]) 275 | ); 276 | 277 | await messages.popUntil(message => message[0] == 'reply'); 278 | strictEqual(dom.window.document.getElementById('my').innerHTML, '

Middle

Appended!

'); 279 | 280 | live.disconnect(); 281 | }); 282 | 283 | it ('should handle removals', async function () { 284 | const live = new Live(dom.window, webSocketServerURL); 285 | 286 | live.connect(); 287 | 288 | const connected = new Promise(resolve => { 289 | webSocketServer.on('connection', resolve); 290 | }); 291 | 292 | let socket = await connected; 293 | 294 | socket.send( 295 | JSON.stringify(['update', 'my', '

Middle

']) 296 | ); 297 | 298 | socket.send( 299 | JSON.stringify(['remove', '#my p', {reply: true}]) 300 | ); 301 | 302 | await messages.popUntil(message => message[0] == 'reply'); 303 | strictEqual(dom.window.document.getElementById('my').innerHTML, ''); 304 | 305 | live.disconnect(); 306 | }); 307 | 308 | it ('can dispatch custom events', async function () { 309 | const live = new Live(dom.window, webSocketServerURL); 310 | 311 | live.connect(); 312 | 313 | const connected = new Promise(resolve => { 314 | webSocketServer.on('connection', resolve); 315 | }); 316 | 317 | let socket = await connected; 318 | 319 | socket.send( 320 | JSON.stringify(['dispatchEvent', '#my', 'click', {reply: true}]) 321 | ); 322 | 323 | await messages.popUntil(message => message[0] == 'reply'); 324 | 325 | live.disconnect(); 326 | }); 327 | 328 | it ('can forward events', async function () { 329 | const live = new Live(dom.window, webSocketServerURL); 330 | 331 | live.connect(); 332 | 333 | const connected = new Promise(resolve => { 334 | webSocketServer.on('connection', resolve); 335 | }); 336 | 337 | let socket = await connected; 338 | 339 | dom.window.document.getElementById('my').addEventListener('click', event => { 340 | live.forwardEvent('my', event); 341 | }); 342 | 343 | dom.window.document.getElementById('my').click(); 344 | 345 | let payload = await messages.popUntil(message => message[0] == 'event'); 346 | strictEqual(payload[1], 'my'); 347 | strictEqual(payload[2].type, 'click'); 348 | 349 | live.disconnect(); 350 | }); 351 | 352 | it ('can log errors', function () { 353 | const live = new Live(dom.window, webSocketServerURL); 354 | 355 | live.error('my', 'Test Error'); 356 | }); 357 | }); 358 | -------------------------------------------------------------------------------- /node_modules/morphdom/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | # 2.x 5 | 6 | ## 2.7.2 7 | - Fix morphing duplicate ids of incompatible tags 8 | 9 | ## 2.7.1 10 | - Pass toEl as second argument to `skipFromChildren` callback 11 | 12 | ## 2.7.0 13 | 14 | - Add new `addChild` and `skipFromChildren` callbacks to allow customization of how new children are 15 | added to a parent as well as preserving the from tree when indexing changes for diffing. 16 | 17 | ## 2.5.12 18 | 19 | - Fix merge attrs with multiple properties [PR #175](https://github.com/patrick-steele-idem/morphdom/pull/175) 20 | 21 | ## 2.5.11 22 | 23 | - Multiple forms duplication [PR #174](https://github.com/patrick-steele-idem/morphdom/pull/174) 24 | 25 | ## 2.5.10 26 | 27 | - Pr/167 - Allow document fragment patching [PR #168](https://github.com/patrick-steele-idem/morphdom/pull/168) 28 | 29 | ## 2.5.9 30 | 31 | - Faster attrs merge [PR #165](https://github.com/patrick-steele-idem/morphdom/pull/165) 32 | 33 | ## 2.5.8 34 | 35 | - Minor improvements [PR #164](https://github.com/patrick-steele-idem/morphdom/pull/164) 36 | 37 | ## 2.5.7 38 | 39 | - Chore: Alternate refactor to #155 - Move isSameNode check [PR #156](https://github.com/patrick-steele-idem/morphdom/pull/156) 40 | - Use attribute name with the prefix in XMLNS namespace [PR #133](https://github.com/patrick-steele-idem/morphdom/pull/133) 41 | 42 | ## 2.5.6 43 | 44 | - fixed the string with space trouble [PR #161](https://github.com/patrick-steele-idem/morphdom/pull/161) 45 | 46 | ## 2.5.5 47 | 48 | - Template support for creating element from string [PR #159](https://github.com/patrick-steele-idem/morphdom/pull/159) 49 | 50 | ## 2.5.4 51 | 52 | - Enhancement: Fix id key removal from tree when the element with key is inside a document fragment node (ex: shadow dom) [PR #119](https://github.com/patrick-steele-idem/morphdom/pull/119) 53 | - Minor: small refactor to morphEl to own function [PR #149](small refactor to morphEl to own function) 54 | - selectNode for range b/c documentElement not avail in Safari [commit](https://github.com/patrick-steele-idem/morphdom/commit/6afd2976ab4fac4d8e1575975531644ecc62bc1d) 55 | - clarify getNodeKey docs [PR #151](https://github.com/patrick-steele-idem/morphdom/pull/151) 56 | 57 | ## 2.5.3 58 | 59 | - Minor: update deps [PR #145](https://github.com/patrick-steele-idem/morphdom/pull/145) 60 | - Minor: Minor comments and very very minor refactors [PR #143](https://github.com/patrick-steele-idem/morphdom/pull/143) 61 | 62 | ## 2.5.2 63 | 64 | - New dist for 2.5.1. My bad! 65 | 66 | ## 2.5.1 67 | 68 | - Bugfix: Fix bug where wrong select option would get selected. [PR #117](https://github.com/patrick-steele-idem/morphdom/pull/117) 69 | 70 | ## 2.5.0 71 | 72 | - Enhancement: Publish es6 format as morphdom-esm.js [PR #141](https://github.com/patrick-steele-idem/morphdom/pull/141) 73 | - Enhancement: Start removing old browser support code paths [PR #140](https://github.com/patrick-steele-idem/morphdom/pull/140) 74 | 75 | ## 2.4.0 76 | 77 | - Enhancement: Rollup 1.0 [PR #139](https://github.com/patrick-steele-idem/morphdom/pull/139) 78 | - Enhancement: Add Typescript declaration file [PR #138](https://github.com/patrick-steele-idem/morphdom/pull/138) 79 | 80 | ## 2.3.x 81 | 82 | ### 2.3.1 83 | 84 | - Bug: Fixed losing cursor position in Edge ([PR #100](https://github.com/patrick-steele-idem/morphdom/pull/100) by [@zastavnitskiy](https://github.com/zastavnitskiy)) 85 | 86 | ### 2.3.0 87 | 88 | - Changes to improve code maintainability. Single file is now split out into multiple modules and [rollup](https://github.com/rollup/rollup) is used to build the distribution files. 89 | 90 | ## 2.2.x 91 | 92 | ### 2.2.2 93 | 94 | - Changes to ensure that `selectedIndex` is updated correctly in all browsers ([PR #94](https://github.com/patrick-steele-idem/morphdom/pull/94) by [@aknuds1](https://github.com/aknuds1)) 95 | 96 | ### 2.2.1 97 | 98 | - IE-specific bug: fix `