├── .github ├── CODEOWNERS ├── actions │ └── libextism │ │ └── action.yaml └── workflows │ ├── docs.yaml │ └── test.yaml ├── .gitignore ├── .yardopts ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── Makefile ├── README.md ├── Rakefile ├── bin ├── bundle ├── irb ├── rake ├── rdbg ├── rdoc ├── ri ├── rufo ├── yard ├── yardoc └── yri ├── extism.gemspec ├── lib ├── extism.rb └── extism │ ├── current_plugin.rb │ ├── host_environment.rb │ ├── libextism.rb │ ├── manifest.rb │ ├── plugin.rb │ ├── version.rb │ └── wasm.rb ├── sig └── extism.rbs ├── test ├── test_extism.rb └── test_helper.rb └── wasm ├── count_vowels.wasm ├── count_vowels_kvstore.wasm ├── reflect.wasm └── store_credit.wasm /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @bhelx 2 | -------------------------------------------------------------------------------- /.github/actions/libextism/action.yaml: -------------------------------------------------------------------------------- 1 | on: [workflow_call] 2 | 3 | name: libextism 4 | 5 | inputs: 6 | gh-token: 7 | description: "A GitHub PAT" 8 | default: ${{ github.token }} 9 | 10 | runs: 11 | using: composite 12 | steps: 13 | - uses: actions/checkout@v3 14 | with: 15 | repository: extism/cli 16 | path: .extism-cli 17 | - uses: ./.extism-cli/.github/actions/extism-cli 18 | - name: Install 19 | shell: bash 20 | run: sudo extism lib install --version git --github-token ${{ inputs.gh-token }} 21 | -------------------------------------------------------------------------------- /.github/workflows/docs.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | release: 4 | types: 5 | - released 6 | name: Documentation 7 | jobs: 8 | build: 9 | name: Publish 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: ruby/setup-ruby@v1 14 | with: 15 | ruby-version: '3.1' 16 | - name: Build Library and Docs 17 | run: | 18 | make prepare 19 | make docs 20 | - name: Deploy 21 | if: success() 22 | uses: crazy-max/ghaction-github-pages@v1 23 | with: 24 | target_branch: gh-pages 25 | build_dir: ./doc 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | 29 | 30 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Ruby CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | ruby-version: ['3.1', '3.0', '2.7'] 15 | steps: 16 | - uses: actions/checkout@v3 17 | - uses: ./.github/actions/libextism 18 | - name: Set up Ruby ${{ matrix.ruby-version }} 19 | uses: ruby/setup-ruby@ec02537da5712d66d4d50a0f33b7eb52773b5ed1 20 | with: 21 | ruby-version: ${{ matrix.ruby-version }} 22 | - name: Run tests 23 | run: | 24 | make test 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /spec/examples.txt 9 | /test/tmp/ 10 | /test/version_tmp/ 11 | /tmp/ 12 | 13 | # Used by dotenv library to load environment variables. 14 | # .env 15 | 16 | # Ignore Byebug command history file. 17 | .byebug_history 18 | 19 | ## Specific to RubyMotion: 20 | .dat* 21 | .repl_history 22 | build/ 23 | *.bridgesupport 24 | build-iPhoneOS/ 25 | build-iPhoneSimulator/ 26 | 27 | ## Specific to RubyMotion (use of CocoaPods): 28 | # 29 | # We recommend against adding the Pods directory to your .gitignore. However 30 | # you should judge for yourself, the pros and cons are mentioned at: 31 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 32 | # 33 | # vendor/Pods/ 34 | 35 | ## Documentation cache and generated files: 36 | /.yardoc/ 37 | /_yardoc/ 38 | /doc/ 39 | /rdoc/ 40 | 41 | ## Environment normalization: 42 | /.bundle/ 43 | /vendor/bundle 44 | /lib/bundler/man/ 45 | 46 | # for a library or gem, you might want to ignore these files since the code is 47 | # intended to run in multiple environments; otherwise, check them in: 48 | # Gemfile.lock 49 | # .ruby-version 50 | # .ruby-gemset 51 | 52 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 53 | .rvmrc 54 | 55 | # Used by RuboCop. Remote config files pulled in from inherit_from directive. 56 | # .rubocop-https?--* -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --readme README.md 2 | --exclude lib/extism/libextism.rb 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in extism.gemspec 6 | gemspec 7 | 8 | gem 'ffi', '~> 1.15.5' 9 | gem 'rake', '~> 13.0' 10 | 11 | group :development do 12 | gem 'debug' 13 | gem 'minitest', '~> 5.20.0' 14 | gem 'rufo', '~> 0.13.0' 15 | gem 'yard', '~> 0.9.28' 16 | end 17 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | extism (1.0.1) 5 | ffi (~> 1.0) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | debug (1.8.0) 11 | irb (>= 1.5.0) 12 | reline (>= 0.3.1) 13 | ffi (1.15.5) 14 | io-console (0.6.0) 15 | irb (1.8.1) 16 | rdoc 17 | reline (>= 0.3.8) 18 | minitest (5.20.0) 19 | psych (5.1.0) 20 | stringio 21 | rake (13.0.6) 22 | rdoc (6.5.0) 23 | psych (>= 4.0.0) 24 | reline (0.3.8) 25 | io-console (~> 0.5) 26 | rufo (0.13.0) 27 | stringio (3.0.8) 28 | yard (0.9.34) 29 | 30 | PLATFORMS 31 | arm64-darwin-22 32 | arm64-darwin-23 33 | x86_64-linux 34 | 35 | DEPENDENCIES 36 | debug 37 | extism! 38 | ffi (~> 1.15.5) 39 | minitest (~> 5.20.0) 40 | rake (~> 13.0) 41 | rufo (~> 0.13.0) 42 | yard (~> 0.9.28) 43 | 44 | BUNDLED WITH 45 | 2.4.10 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Dylibso, Inc. 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | RUBYGEMS_API_KEY ?= 2 | 3 | .PHONY: prepare test 4 | 5 | prepare: 6 | bundle install 7 | bundle binstubs --all 8 | 9 | test: prepare 10 | bundle exec rake test 11 | 12 | clean: 13 | rm -f extism-*.gem 14 | 15 | publish-local: clean prepare 16 | gem build extism.gemspec 17 | gem push extism-*.gem 18 | 19 | publish: clean prepare 20 | gem build extism.gemspec 21 | GEM_HOST_API_KEY=$(RUBYGEMS_API_KEY) gem push extism-*.gem 22 | 23 | lint: 24 | bundle exec rufo --check . 25 | 26 | format: 27 | bundle exec rufo . 28 | 29 | docs: 30 | bundle exec yard 31 | 32 | show-docs: docs 33 | open doc/index.html 34 | 35 | seed: 36 | curl -L https://github.com/extism/plugins/releases/latest/download/count_vowels.debug.wasm > wasm/count_vowels.wasm 37 | curl -L https://github.com/extism/plugins/releases/latest/download/reflect.debug.wasm > wasm/reflect.wasm 38 | curl -L https://github.com/extism/plugins/releases/latest/download/store_credit.debug.wasm > wasm/store_credit.wasm 39 | 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Extism Ruby Host SDK 2 | 3 | This repo contains the ruby gem for integrating with the [Extism](https://extism.org/) runtime. Install this library into your host ruby application to run Extism plug-ins. 4 | 5 | ## Installation 6 | 7 | ### Install the Extism Runtime Dependency 8 | 9 | For this library, you first need to install the Extism Runtime. You can [download the shared object directly from a release](https://github.com/extism/extism/releases) or use the [Extism CLI](https://github.com/extism/cli) to install it: 10 | 11 | ```bash 12 | sudo extism lib install latest 13 | 14 | #=> Fetching https://github.com/extism/extism/releases/download/v0.5.2/libextism-aarch64-apple-darwin-v0.5.2.tar.gz 15 | #=> Copying libextism.dylib to /usr/local/lib/libextism.dylib 16 | #=> Copying extism.h to /usr/local/include/extism.h 17 | ``` 18 | 19 | ### Install the Gem 20 | 21 | Add this library to your [Gemfile](https://bundler.io/): 22 | 23 | ```ruby 24 | gem 'extism', '~> 1.0.0' 25 | ``` 26 | 27 | Or if installing on the system level: 28 | 29 | ``` 30 | gem install extism 31 | ``` 32 | 33 | ## Getting Started 34 | 35 | This guide should walk you through some of the concepts in Extism and this ruby library. 36 | 37 | > *Note*: You should be able to follow this guide by copy pasting the code into `irb`. 38 | 39 | ### Creating A Plug-in 40 | 41 | The primary concept in Extism is the [plug-in](https://extism.org/docs/concepts/plug-in). You can think of a plug-in as a code module stored in a `.wasm` file. 42 | 43 | Since you may not have an Extism plug-in on hand to test, let's load a demo plug-in from the web: 44 | 45 | ```ruby 46 | # First require the library 47 | require "extism" 48 | 49 | url = "https://github.com/extism/plugins/releases/latest/download/count_vowels.wasm" 50 | manifest = Extism::Manifest.from_url url 51 | plugin = Extism::Plugin.new(manifest) 52 | ``` 53 | 54 | > **Note**: See [the Manifest docs](https://extism.github.io/ruby-sdk/Extism/Manifest.html) as it has a rich schema and a lot of options. 55 | 56 | ### Calling A Plug-in's Exports 57 | 58 | This plug-in was written in Rust and it does one thing, it counts vowels in a string. As such, it exposes one "export" function: `count_vowels`. We can call exports using [Extism::Plugin#call](https://extism.github.io/ruby-sdk/Extism/Plugin.html#call-instance_method): 59 | 60 | ```ruby 61 | plugin.call("count_vowels", "Hello, World!") 62 | # => {"count": 3, "total": 3, "vowels": "aeiouAEIOU"} 63 | ``` 64 | 65 | All exports have a simple interface of bytes-in and bytes-out. This plug-in happens to take a string and return a JSON encoded string with a report of results. 66 | 67 | ### Plug-in State 68 | 69 | Plug-ins may be stateful or stateless. Plug-ins can maintain state b/w calls by the use of variables. Our count vowels plug-in remembers the total number of vowels it's ever counted in the "total" key in the result. You can see this by making subsequent calls to the export: 70 | 71 | ```ruby 72 | plugin.call("count_vowels", "Hello, World!") 73 | # => {"count": 3, "total": 6, "vowels": "aeiouAEIOU"} 74 | plugin.call("count_vowels", "Hello, World!") 75 | # => {"count": 3, "total": 9, "vowels": "aeiouAEIOU"} 76 | ``` 77 | 78 | These variables will persist until this plug-in is freed or you initialize a new one. 79 | 80 | ### Configuration 81 | 82 | Plug-ins may optionally take a configuration object. This is a static way to configure the plug-in. Our count-vowels plugin takes an optional configuration to change out which characters are considered vowels. Example: 83 | 84 | ```ruby 85 | plugin = Extism::Plugin.new(manifest) 86 | plugin.call("count_vowels", "Yellow, World!") 87 | # => {"count": 3, "total": 3, "vowels": "aeiouAEIOU"} 88 | 89 | plugin = Extism::Plugin.new(manifest, config: { vowels: "aeiouyAEIOUY" }) 90 | plugin.call("count_vowels", "Yellow, World!") 91 | # => {"count": 4, "total": 4, "vowels": "aeiouAEIOUY"} 92 | ``` 93 | 94 | ### Host Functions 95 | 96 | Let's extend our count-vowels example a little bit: Instead of storing the `total` in an ephemeral plug-in var, let's store it in a persistent key-value store! 97 | 98 | Wasm can't use our KV store on it's own. This is where [Host Functions](https://extism.org/docs/concepts/host-functions) come in. 99 | 100 | [Host functions](https://extism.org/docs/concepts/host-functions) allow us to grant new capabilities to our plug-ins from our application. They are simply some ruby methods you write which can be passed down and invoked from any language inside the plug-in. 101 | 102 | Let's load the manifest like usual but load up this `count_vowels_kvstore` plug-in: 103 | 104 | ```ruby 105 | url = "https://github.com/extism/plugins/releases/latest/download/count_vowels_kvstore.wasm" 106 | manifest = Extism::Manifest.from_url(url) 107 | ``` 108 | 109 | > *Note*: The source code for this is [here](https://github.com/extism/plugins/blob/main/count_vowels_kvstore/src/lib.rs) and is written in rust, but it could be written in any of our PDK languages. 110 | 111 | Unlike our previous plug-in, this plug-in expects you to provide host functions that satisfy our its import interface for a KV store. 112 | 113 | In the ruby sdk, we have a concept for this called a [Host Environment](https://extism.github.io/ruby-sdk/Extism/HostEnvironment.html). An environment is an instance of a class that implements any host functions your plug-in needs. 114 | 115 | We want to expose two functions to our plugin, `kv_write(key: String, value: Bytes)` which writes a bytes value to a key and `kv_read(key: String) -> Bytes` which reads the bytes at the given `key`. 116 | 117 | ```ruby 118 | # pretend this is Redis or something :) 119 | KV_STORE = {} 120 | 121 | class KvEnvironment 122 | include Extism::HostEnvironment 123 | 124 | # We need to describe the wasm function signature of each host function 125 | # to register them to this environment 126 | register_import :kv_read, [Extism::ValType::PTR], [Extism::ValType::PTR] 127 | register_import :kv_write, [Extism::ValType::PTR, Extism::ValType::PTR], [] 128 | 129 | def kv_read(plugin, inputs, outputs, _user_data) 130 | key = plugin.input_as_string(inputs.first) 131 | val = KV_STORE[key] || [0].pack('V') # get 4 LE bytes for 0 default 132 | puts "Read from key=#{key}" 133 | plugin.output_string(outputs.first, val) 134 | end 135 | 136 | def kv_write(plugin, inputs, _outputs, _user_data) 137 | key = plugin.input_as_string(inputs.first) 138 | val = plugin.input_as_string(inputs[1]) 139 | puts "Writing value=#{val.unpack1('V')} from key=#{key}" 140 | KV_STORE[key] = val 141 | end 142 | end 143 | ``` 144 | 145 | > *Note*: In order to write host functions you should get familiar with the methods on the [Extism::CurrentPlugin](https://extism.github.io/ruby-sdk/Extism/CurrentPlugin.html) class. The `plugin` parameter is an instance of this class. 146 | 147 | Now we just need to create a new host environment and pass it in when loading the plug-in. Here our environment initializer takes no arguments, but you could imagine putting some customer specific instance variables in there: 148 | 149 | ```ruby 150 | env = KvEnvironment.new 151 | plugin = Extism::Plugin.new(manifest, environment: env) 152 | ``` 153 | 154 | Now we can invoke the event: 155 | 156 | ```ruby 157 | plugin.call("count_vowels", "Hello, World!") 158 | # => Read from key=count-vowels" 159 | # => Writing value=3 from key=count-vowels" 160 | # => {"count": 3, "total": 3, "vowels": "aeiouAEIOU"} 161 | plugin.call("count_vowels", "Hello, World!") 162 | # => Read from key=count-vowels" 163 | # => Writing value=6 from key=count-vowels" 164 | # => {"count": 3, "total": 6, "vowels": "aeiouAEIOU"} 165 | ``` 166 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rake/testtask" 5 | 6 | Rake::TestTask.new(:test) do |t| 7 | t.libs << "test" 8 | t.libs << "lib" 9 | t.test_files = FileList["test/**/test_*.rb"] 10 | end 11 | 12 | task default: :test 13 | -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'bundle' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "rubygems" 12 | 13 | m = Module.new do 14 | module_function 15 | 16 | def invoked_as_script? 17 | File.expand_path($0) == File.expand_path(__FILE__) 18 | end 19 | 20 | def env_var_version 21 | ENV["BUNDLER_VERSION"] 22 | end 23 | 24 | def cli_arg_version 25 | return unless invoked_as_script? # don't want to hijack other binstubs 26 | return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update` 27 | bundler_version = nil 28 | update_index = nil 29 | ARGV.each_with_index do |a, i| 30 | if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN 31 | bundler_version = a 32 | end 33 | next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ 34 | bundler_version = $1 35 | update_index = i 36 | end 37 | bundler_version 38 | end 39 | 40 | def gemfile 41 | gemfile = ENV["BUNDLE_GEMFILE"] 42 | return gemfile if gemfile && !gemfile.empty? 43 | 44 | File.expand_path("../Gemfile", __dir__) 45 | end 46 | 47 | def lockfile 48 | lockfile = 49 | case File.basename(gemfile) 50 | when "gems.rb" then gemfile.sub(/\.rb$/, gemfile) 51 | else "#{gemfile}.lock" 52 | end 53 | File.expand_path(lockfile) 54 | end 55 | 56 | def lockfile_version 57 | return unless File.file?(lockfile) 58 | lockfile_contents = File.read(lockfile) 59 | return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ 60 | Regexp.last_match(1) 61 | end 62 | 63 | def bundler_requirement 64 | @bundler_requirement ||= 65 | env_var_version || cli_arg_version || 66 | bundler_requirement_for(lockfile_version) 67 | end 68 | 69 | def bundler_requirement_for(version) 70 | return "#{Gem::Requirement.default}.a" unless version 71 | 72 | bundler_gem_version = Gem::Version.new(version) 73 | 74 | requirement = bundler_gem_version.approximate_recommendation 75 | 76 | return requirement unless Gem.rubygems_version < Gem::Version.new("2.7.0") 77 | 78 | requirement += ".a" if bundler_gem_version.prerelease? 79 | 80 | requirement 81 | end 82 | 83 | def load_bundler! 84 | ENV["BUNDLE_GEMFILE"] ||= gemfile 85 | 86 | activate_bundler 87 | end 88 | 89 | def activate_bundler 90 | gem_error = activation_error_handling do 91 | gem "bundler", bundler_requirement 92 | end 93 | return if gem_error.nil? 94 | require_error = activation_error_handling do 95 | require "bundler/version" 96 | end 97 | return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION)) 98 | warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`" 99 | exit 42 100 | end 101 | 102 | def activation_error_handling 103 | yield 104 | nil 105 | rescue StandardError, LoadError => e 106 | e 107 | end 108 | end 109 | 110 | m.load_bundler! 111 | 112 | if m.invoked_as_script? 113 | load Gem.bin_path("bundler", "bundle") 114 | end 115 | -------------------------------------------------------------------------------- /bin/irb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'irb' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("irb", "irb") 28 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rake' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rake", "rake") 28 | -------------------------------------------------------------------------------- /bin/rdbg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rdbg' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("debug", "rdbg") 28 | -------------------------------------------------------------------------------- /bin/rdoc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rdoc' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rdoc", "rdoc") 28 | -------------------------------------------------------------------------------- /bin/ri: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'ri' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rdoc", "ri") 28 | -------------------------------------------------------------------------------- /bin/rufo: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rufo' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rufo", "rufo") 28 | -------------------------------------------------------------------------------- /bin/yard: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'yard' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("yard", "yard") 28 | -------------------------------------------------------------------------------- /bin/yardoc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'yardoc' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("yard", "yardoc") 28 | -------------------------------------------------------------------------------- /bin/yri: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'yri' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("yard", "yri") 28 | -------------------------------------------------------------------------------- /extism.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'lib/extism/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'extism' 7 | spec.version = Extism::VERSION 8 | spec.authors = ['zach'] 9 | spec.email = ['zachshipko@gmail.com'] 10 | 11 | spec.summary = 'Extism WASM SDK' 12 | spec.description = 'A library for loading and executing WASM plugins' 13 | spec.homepage = 'https://github.com/extism/extism' 14 | spec.license = 'MIT' 15 | spec.required_ruby_version = '>= 2.6.0' 16 | 17 | # spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'" 18 | 19 | spec.metadata['homepage_uri'] = spec.homepage 20 | spec.metadata['source_code_uri'] = 'https://github.com/extism/extism' 21 | spec.metadata['changelog_uri'] = 'https://github.com/extism/extism' 22 | 23 | # Specify which files should be added to the gem when it is released. 24 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 25 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 26 | `git ls-files -z`.split("\x0").reject do |f| 27 | (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)}) 28 | end 29 | end 30 | spec.bindir = 'exe' 31 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 32 | spec.require_paths = ['lib'] 33 | 34 | # Uncomment to register a new dependency of your gem 35 | spec.add_dependency 'ffi', '~> 1.0' 36 | 37 | # For more information and examples about making a new gem, check out our 38 | # guide at: https://bundler.io/guides/creating_gem.html 39 | end 40 | -------------------------------------------------------------------------------- /lib/extism.rb: -------------------------------------------------------------------------------- 1 | require 'ffi' 2 | require 'json' 3 | require_relative './extism/manifest' 4 | require_relative './extism/version' 5 | require_relative './extism/plugin' 6 | require_relative './extism/current_plugin' 7 | require_relative './extism/libextism' 8 | require_relative './extism/wasm' 9 | require_relative './extism/host_environment' 10 | 11 | module Extism 12 | class Error < StandardError 13 | end 14 | 15 | # Return the version of Extism 16 | # 17 | # @return [String] The version string of the Extism runtime 18 | def self.extism_version 19 | LibExtism.extism_version 20 | end 21 | 22 | # Set log file and level, this is a global configuration 23 | # @param name [String] The path to the logfile 24 | # @param level [String] The log level. One of {"debug", "error", "info", "trace" } 25 | def self.set_log_file(name, level = nil) 26 | LibExtism.extism_log_file(name, level) 27 | end 28 | 29 | $PLUGINS = {} 30 | $FREE_PLUGIN = proc { |ptr| 31 | x = $PLUGINS[ptr] 32 | unless x.nil? 33 | LibExtism.extism_plugin_free(x[:plugin]) 34 | $PLUGINS.delete(ptr) 35 | end 36 | } 37 | 38 | 39 | $FUNCTIONS = {} 40 | $FREE_FUNCTION = proc { |ptr| 41 | x = $FUNCTIONS[ptr] 42 | unless x.nil? 43 | LibExtism.extism_function_free(x[:function]) 44 | $FUNCTIONS.delete(ptr) 45 | end 46 | } 47 | 48 | # Represents a "block" of memory in Extism. 49 | # This memory is in the communication buffer b/w the 50 | # guest in the host and technically lives in host memory. 51 | class Memory 52 | attr_reader :offset, :len 53 | 54 | def initialize(offset, len) 55 | @offset = offset 56 | @len = len 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/extism/current_plugin.rb: -------------------------------------------------------------------------------- 1 | module Extism 2 | # Represents a reference to a plugin in a host function 3 | # Use this class to read and write to the memory of the plugin 4 | # These methods allow you to get data in and out of the plugin 5 | # in a host function 6 | class CurrentPlugin 7 | # let's not let people construct these since it comes from a pointer 8 | private_class_method :new 9 | 10 | # Initialize a CurrentPlugin given an pointer 11 | # 12 | # @param ptr [FFI::Pointer] the raw pointer to the plugin 13 | def initialize(ptr) 14 | @ptr = ptr 15 | end 16 | 17 | # Allocates a memory block in the plugin 18 | # 19 | # @example Allocate 1kB 20 | # mem = current_plugin.alloc(1_024) 21 | # current_plugin.free(mem) 22 | # 23 | # @param num_bytes [Integer] The amount in bytes to allocate 24 | # @return [Extism::Memory] The reference to the freshly allocated memory 25 | def alloc(num_bytes) 26 | offset = LibExtism.extism_current_plugin_memory_alloc(@ptr, num_bytes) 27 | Memory.new(offset, num_bytes) 28 | end 29 | 30 | # Frees the memory block 31 | # 32 | # @example 33 | # mem = current_plugin.alloc(1_024) 34 | # current_plugin.free(mem) 35 | # 36 | # @param memory [Extism::Memory] The memory object you wish to free 37 | # @return [Extism::Memory] The reference to the freshly allocated memory 38 | def free(memory) 39 | LibExtism.extism_current_plugin_memory_free(@ptr, memory.offset) 40 | end 41 | 42 | # Gets the memory block at a given offset. Note: try to use input_* and output_* methods where possible. 43 | # 44 | # @example 45 | # mem = current_plugin.memory_at_offset(123456789) 46 | # current_plugin.free(mem) 47 | # 48 | # @raise [Extism::Error] if memory block could not be found 49 | # 50 | # @param offset [Integer] The offset pointer to the memory. This is relative to the plugin not the host. 51 | # @return [Extism::Memory] The reference to the memory block if found 52 | def memory_at_offset(offset) 53 | len = LibExtism.extism_current_plugin_memory_length(@ptr, offset) 54 | raise Extism::Error, "Could not find memory block at offset #{offset}" if len.zero? 55 | 56 | Memory.new(offset, len) 57 | end 58 | 59 | # Gets the input as a string 60 | # 61 | # @raise [Extism::Error] if memory block could not be found 62 | # 63 | # @example 64 | # param1 = current_plugin.input_as_string(inputs.first) 65 | # raise "Failed" unless param1 == "First param from plug-in host function call" 66 | # 67 | # @param input [Extism::Val] The input val from the host function 68 | # @return [String] raw bytes as a string 69 | def input_as_string(input) 70 | raise ArgumentError, 'input is not an Extism::Val' unless input.instance_of? Extism::Val 71 | 72 | mem = memory_at_offset(input.value) 73 | memory_ptr(mem).read_bytes(mem.len) 74 | end 75 | 76 | # Gets the input as a JSON parsed Hash 77 | # 78 | # @raise [Extism::Error] if memory block could not be found 79 | # 80 | # @example 81 | # param1 = current_plugin.input_as_json(inputs.first) 82 | # raise "Failed" unless param1 == {hello: "world"} 83 | # 84 | # @param input [Extism::Val] The input val from the host function 85 | # @return [Hash] The Hash object 86 | def input_as_json(input) 87 | raise ArgumentError, 'input is not an Extism::Val' unless input.instance_of? Extism::Val 88 | 89 | mem = memory_at_offset(input.value) 90 | str = memory_ptr(mem).read_bytes(mem.len) 91 | JSON.parse(str) 92 | end 93 | 94 | # Sets string to the return of the host function 95 | # 96 | # @example 97 | # msg = "A string returned from the host function" 98 | # current_plugin.output_string(outputs.first, msg) 99 | # 100 | # @raise [Extism::Error] if memory block could not be found 101 | # 102 | # @param output [Extism::Val] The output val from the host function 103 | # @param bytes [String] The bytes to set 104 | def output_string(output, bytes) 105 | mem = alloc(bytes.length) 106 | memory_ptr(mem).put_bytes(0, bytes) 107 | set_return(output, mem.offset) 108 | end 109 | 110 | # Sets json to the return of the host function 111 | # 112 | # @example 113 | # msg = {hello: "world"} 114 | # current_plugin.output_json(outputs.first, msg) 115 | # 116 | # @raise [Extism::Error] if memory block could not be found 117 | # 118 | # @param output [Extism::Val] The output val from the host function 119 | # @param obj [Hash] The hash object to turn to JSON 120 | def output_json(output, obj) 121 | bytes = JSON.generate(obj) 122 | mem = alloc(bytes.length) 123 | memory_ptr(mem).put_bytes(0, bytes) 124 | set_return(output, mem.offset) 125 | end 126 | 127 | # Sets the return value parameter 128 | # 129 | # @raise [Extism::Error] if memory block could not be found 130 | # 131 | # @param output [Extism::Val] The output val from the host function 132 | # @param value [Integer | Float] The i32 value 133 | def set_return(output, value) 134 | case output.type 135 | when :i32, :i64, :f32, :f64 136 | output.value = value 137 | else 138 | raise ArgumentError, "Don't know how to set output type #{output.type}" 139 | end 140 | end 141 | 142 | private 143 | 144 | # Returns a raw pointer (absolute to the host) to the given memory block 145 | # **Danger**: Be careful with this. it's not exposed for a reason. 146 | # This is a pointer in host memory so it could read outside of the plugin 147 | # if manipulated 148 | def memory_ptr(mem) 149 | plugin_ptr = LibExtism.extism_current_plugin_memory(@ptr) 150 | FFI::Pointer.new(plugin_ptr.address + mem.offset) 151 | end 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /lib/extism/host_environment.rb: -------------------------------------------------------------------------------- 1 | module Extism 2 | # Represents an "environment" that can be imported to a plug-in 3 | # 4 | # @example 5 | # class MyEnvironment 6 | # include Extism::HostEnvironment 7 | # # we need to register each import that the plug-in expects and match the Wasm signature 8 | # # register_import takes the name, the param types, and the return types 9 | # register_import :reflect, [Extism::ValType::I64], [Extism::ValType::I64] 10 | # 11 | # # reflect just takes a string from the plug-in and reflects it back in return 12 | # def reflect(plugin, inputs, outputs, _user_data) 13 | # msg = plugin.input_as_string(inputs.first) 14 | # plugin.output_string(outputs.first, msg) 15 | # end 16 | # end 17 | # 18 | module HostEnvironment 19 | def self.included(base) 20 | base.extend ClassMethods 21 | base.class_variable_set(:@@import_funcs, []) 22 | end 23 | 24 | # Creates the host functions to pass to the plug-in on intialization. 25 | # Used internally by the Plugin initializer 26 | # 27 | # @see Extism::Plugin::new 28 | # 29 | # @return [Array] 30 | def host_functions 31 | import_funcs = self.class.class_variable_get(:@@import_funcs) 32 | import_funcs.map do |f| 33 | name, params, returns = f 34 | Extism::Function.new( 35 | name.to_s, 36 | params, 37 | returns, 38 | method(name).to_proc 39 | ) 40 | end 41 | end 42 | 43 | module ClassMethods 44 | # Register an import by name. You must know the wasm signature 45 | # of the function to do this. 46 | # 47 | # @example 48 | # register_import :my_func, [Extism::ValType::I64], [Extism::ValType::F64] 49 | # 50 | # @param [Symbol | String] func_name The name of the wasm import function. Assumes `env` namespace. 51 | # @param [Array] parameters The Wasm types of the parameters that the import takes 52 | # @param [Array] returns The Wasm types of the returns that the import returns. Will usually be just be one of these. 53 | def register_import(func_name, parameters, returns) 54 | import_funcs = class_variable_get(:@@import_funcs) 55 | import_funcs << [func_name, parameters, returns] 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/extism/libextism.rb: -------------------------------------------------------------------------------- 1 | module Extism 2 | # Private module used to interface with the Extism runtime. 3 | # *Warning*: Do not use or rely on this directly 4 | # improperly using this interface may enable exploits and the interface 5 | # might change over time. 6 | module LibExtism 7 | extend FFI::Library 8 | ffi_lib 'extism' 9 | 10 | def self.from_int_array(ruby_array) 11 | ptr = FFI::MemoryPointer.new(:int, ruby_array.length) 12 | ptr.write_array_of_int(ruby_array) 13 | ptr 14 | end 15 | 16 | typedef :uint64, :ExtismMemoryHandle 17 | typedef :uint64, :ExtismSize 18 | 19 | enum :ExtismValType, %i[I32 I64 F32 F64 V128 FuncRef ExternRef] 20 | 21 | class ExtismValUnion < FFI::Union 22 | layout :i32, :int32, 23 | :i64, :int64, 24 | :f32, :float, 25 | :f64, :double 26 | end 27 | 28 | class ExtismVal < FFI::Struct 29 | layout :t, :ExtismValType, 30 | :v, ExtismValUnion 31 | end 32 | 33 | class ExtismFunction < FFI::Struct 34 | layout :name, :string, 35 | :inputs, :pointer, 36 | :n_inputs, :uint64, 37 | :outputs, :pointer, 38 | :n_outputs, :uint64, 39 | :data, :pointer 40 | end 41 | 42 | callback :ExtismFunctionType, [ 43 | :pointer, # plugin 44 | :pointer, # inputs 45 | :ExtismSize, # n_inputs 46 | :pointer, # outputs 47 | :ExtismSize, # n_outputs 48 | :pointer # user_data 49 | ], :void 50 | 51 | callback :ExtismFreeFunctionType, [], :void 52 | 53 | attach_function :extism_plugin_id, [:pointer], :pointer 54 | attach_function :extism_current_plugin_memory, [:pointer], :pointer 55 | attach_function :extism_current_plugin_memory_alloc, %i[pointer ExtismSize], :ExtismMemoryHandle 56 | attach_function :extism_current_plugin_memory_length, %i[pointer ExtismMemoryHandle], :ExtismSize 57 | attach_function :extism_current_plugin_memory_free, %i[pointer ExtismMemoryHandle], :void 58 | attach_function :extism_function_new, 59 | %i[string pointer ExtismSize pointer ExtismSize ExtismFunctionType ExtismFreeFunctionType pointer], :pointer 60 | attach_function :extism_function_free, [:pointer], :void 61 | attach_function :extism_function_set_namespace, %i[pointer string], :void 62 | attach_function :extism_plugin_new, %i[pointer ExtismSize pointer ExtismSize bool pointer], :pointer 63 | attach_function :extism_plugin_new_error_free, [:pointer], :void 64 | attach_function :extism_plugin_free, [:pointer], :void 65 | attach_function :extism_plugin_cancel_handle, [:pointer], :pointer 66 | attach_function :extism_plugin_cancel, [:pointer], :bool 67 | attach_function :extism_plugin_config, %i[pointer pointer ExtismSize], :bool 68 | attach_function :extism_plugin_function_exists, %i[pointer string], :bool 69 | attach_function :extism_plugin_call, %i[pointer string pointer ExtismSize], :int32 70 | attach_function :extism_error, [:pointer], :string 71 | attach_function :extism_plugin_error, [:pointer], :string 72 | attach_function :extism_plugin_output_length, [:pointer], :ExtismSize 73 | attach_function :extism_plugin_output_data, [:pointer], :pointer 74 | attach_function :extism_log_file, %i[string string], :bool 75 | attach_function :extism_version, [], :string 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/extism/manifest.rb: -------------------------------------------------------------------------------- 1 | module Extism 2 | # The manifest represents a recipe to build a plug-in. 3 | # It generally consists of a path to one wasm module 4 | # but could contain more. It also helps you define some 5 | # options and restrictions on the runtime behavior of the plug-in. 6 | # See https://extism.org/docs/concepts/manifest for more info. 7 | class Manifest 8 | attr_reader :manifest_data 9 | 10 | # Create a manifest of a single wasm from url. 11 | # Look at {Manifest#initialize} for an interface with more control 12 | # 13 | # @see Manifest::initialize 14 | # @param [String] url The url to the wasm module 15 | # @param [String | nil] hash An optional sha256 integrity hash. Defaults to nil 16 | # @param [String | nil] name An optional name. Defaults to nil 17 | # @return [Extism::Manifest] 18 | def self.from_url(url, hash: nil, name: nil) 19 | wasm = { url: url } 20 | wasm[:hash] = hash unless hash.nil? 21 | wasm[:name] = name unless hash.nil? 22 | 23 | Manifest.new({ wasm: [wasm] }) 24 | end 25 | 26 | # Create a manifest of a single wasm from file path. 27 | # Look at {Manifest#initialize} for an interface with more control 28 | # 29 | # @see Manifest::initialize 30 | # @param [String] path The path to the wasm module on disk 31 | # @param [String | nil] hash An optional sha256 integrity hash. Defaults to nil 32 | # @param [String | nil] name An optional name. Defaults to nil 33 | # @return [Extism::Manifest] 34 | def self.from_path(path, hash: nil, name: nil) 35 | wasm = { path: path } 36 | wasm[:hash] = hash unless hash.nil? 37 | wasm[:name] = name unless hash.nil? 38 | 39 | Manifest.new({ wasm: [wasm] }) 40 | end 41 | 42 | # Create a manifest of a single wasm module with raw binary data. 43 | # Look at {Manifest#initialize} for an interface with more control 44 | # Consider using a file path instead of the raw wasm binary in memory. 45 | # The performance is often better letting the runtime load the binary itself. 46 | # 47 | # @see Manifest::initialize 48 | # @param [String] data The binary data of the wasm module 49 | # @param [String | nil] hash An optional sha256 integrity hash. Defaults to nil 50 | # @param [String | nil] name An optional name. Defaults to nil 51 | # @return [Extism::Manifest] 52 | def self.from_bytes(data, hash: nil, name: nil) 53 | wasm = { data: data } 54 | wasm[:hash] = hash unless hash.nil? 55 | wasm[:name] = name unless hash.nil? 56 | 57 | Manifest.new({ wasm: [wasm] }) 58 | end 59 | 60 | # Initialize a manifest 61 | # See https://extism.org/docs/concepts/manifest for schema 62 | # 63 | # @param [Hash] data The Hash data that conforms the Manifest schema 64 | def initialize(data) 65 | @manifest_data = data 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/extism/plugin.rb: -------------------------------------------------------------------------------- 1 | module Extism 2 | # A Plugin represents an instance of your WASM program 3 | # created from the given manifest. 4 | class Plugin 5 | # Intialize a plugin 6 | # 7 | # @example Initialize a plugin from a url 8 | # manifest = Extism::Manifest.from_url "https://github.com/extism/plugins/releases/latest/download/count_vowels.wasm" 9 | # plugin = Extism::Plugin.new(manifest) 10 | # 11 | # @example Pass a config object to configure the plug-in 12 | # plugin = Extism::Plugin.new(manifest, config: { hello: "world" }) 13 | # 14 | # @example Initalize a plug-in that needs WASI 15 | # plugin = Extism::Plugin.new(manifest, wasi: true) 16 | # 17 | # @param wasm [Hash, String, Manifest] The manifest as a Hash or WASM binary as a String. See https://extism.org/docs/concepts/manifest/. 18 | # @param wasi [Boolean] Enable WASI support 19 | # @param config [Hash] The plugin config 20 | def initialize(wasm, environment: nil, functions: [], wasi: false, config: nil) 21 | wasm = case wasm 22 | when Hash 23 | JSON.generate(wasm) 24 | when Manifest 25 | JSON.generate(wasm.manifest_data) 26 | else 27 | wasm 28 | end 29 | 30 | code = FFI::MemoryPointer.new(:char, wasm.bytesize) 31 | errmsg = FFI::MemoryPointer.new(:pointer) 32 | code.put_bytes(0, wasm) 33 | if functions.empty? && environment 34 | unless environment.respond_to?(:host_functions) 35 | raise ArgumentError 'environment should implement host_functions method' 36 | end 37 | 38 | functions = environment.host_functions 39 | end 40 | funcs_ptr = FFI::MemoryPointer.new(LibExtism::ExtismFunction) 41 | funcs_ptr.write_array_of_pointer(functions.map { |f| f.send(:pointer) }) 42 | @plugin = LibExtism.extism_plugin_new(code, wasm.bytesize, funcs_ptr, functions.length, wasi, errmsg) 43 | if @plugin.null? 44 | err = errmsg.read_pointer.read_string 45 | LibExtism.extism_plugin_new_error_free errmsg.read_pointer 46 | raise Error, err 47 | end 48 | $PLUGINS[object_id] = { plugin: @plugin } 49 | ObjectSpace.define_finalizer(self, $FREE_PLUGIN) 50 | return if config.nil? or @plugin.null? 51 | 52 | s = JSON.generate(config) 53 | ptr = FFI::MemoryPointer.from_string(s) 54 | LibExtism.extism_plugin_config(@plugin, ptr, s.bytesize) 55 | end 56 | 57 | # Check if a function exists 58 | # 59 | # @param name [String] The name of the function 60 | # @return [Boolean] Returns true if function exists 61 | def has_function?(name) 62 | LibExtism.extism_plugin_function_exists(@plugin, name) 63 | end 64 | 65 | # Call a function by name 66 | # 67 | # @example 68 | # input = JSON.generate({hello: "world"}) 69 | # result = plugin.call("my_func", input) 70 | # output = JSON.parse(result) 71 | # 72 | # @param name [String] The function name 73 | # @param data [String] The input data for the function 74 | # @return [String] The output from the function in String form 75 | def call(name, data, &block) 76 | # If no block was passed then use Pointer::read_string 77 | block ||= ->(buf, len) { buf.read_string(len) } 78 | input = FFI::MemoryPointer.from_string(data) 79 | rc = LibExtism.extism_plugin_call(@plugin, name, input, data.bytesize) 80 | if rc != 0 81 | err = LibExtism.extism_plugin_error(@plugin) 82 | raise Error, 'extism_call failed' if err&.empty? 83 | 84 | raise Error, err 85 | 86 | end 87 | 88 | out_len = LibExtism.extism_plugin_output_length(@plugin) 89 | buf = LibExtism.extism_plugin_output_data(@plugin) 90 | block.call(buf, out_len) 91 | end 92 | 93 | # Free a plugin, this should be called when the plugin is no longer needed 94 | # 95 | # @return [void] 96 | def free 97 | return if @plugin.null? 98 | 99 | $PLUGINS.delete(object_id) 100 | LibExtism.extism_plugin_free(@plugin) 101 | @plugin = nil 102 | end 103 | 104 | # Get a CancelHandle for a plugin 105 | def cancel_handle 106 | CancelHandle.new(LibExtism.extism_plugin_cancel_handle(@plugin)) 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /lib/extism/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Extism 4 | VERSION = '1.0.1' 5 | end 6 | -------------------------------------------------------------------------------- /lib/extism/wasm.rb: -------------------------------------------------------------------------------- 1 | module Extism 2 | # Extism specific values for Wasm types. Useful when you need to describe 3 | # something in pure wasm like host function signatures. 4 | # 5 | # @example 6 | # register_import :hostfunc, [Extism::ValType::I32, Extism::ValType::F64], [Extism::ValType::I64] 7 | module ValType 8 | I32 = 0 9 | I64 = 1 10 | PTR = 1 11 | F32 = 2 12 | F64 = 3 13 | V128 = 4 14 | FUNC_REF = 5 15 | EXTERN_REF = 6 16 | end 17 | 18 | # A raw Wasm value. Contains the type and the data 19 | class Val 20 | def initialize(ptr) 21 | @c_val = LibExtism::ExtismVal.new(ptr) 22 | end 23 | 24 | def type 25 | case @c_val[:t] 26 | when :I32 27 | :i32 28 | when :I64 29 | :i64 30 | when :PTR 31 | :i64 32 | when :F32 33 | :f32 34 | when :F64 35 | :f64 36 | else 37 | raise "Unsupported wasm value type #{type}" 38 | end 39 | end 40 | 41 | def value 42 | @c_val[:v][type] 43 | end 44 | 45 | def value=(val) 46 | @c_val[:v][type] = val 47 | end 48 | end 49 | 50 | # A CancelHandle can be used to cancel a running plugin from another thread 51 | class CancelHandle 52 | def initialize(handle) 53 | @handle = handle 54 | end 55 | 56 | # Cancel the plugin used to generate the handle 57 | def cancel 58 | LibExtism.extism_plugin_cancel(@handle) 59 | end 60 | end 61 | 62 | # Represents a host function. This is mostly for internal use and you should 63 | # try to use HostEnvironment instead 64 | # 65 | # @see Extism::HostEnvironment 66 | class Function 67 | # Create a new host function 68 | # 69 | # @param name [String] Must match the import name in Wasm. Doesn't include namespace. All extism host functions are in the env name space 70 | # @param params [Array[Extism::ValType]] An array of val types matching the import's params 71 | # @param returns [Array[Extism::ValType]] An array of val types matching the import returns 72 | # @param func_proc [Proc] A proc that will be executed when the host function is executed 73 | # @param user_data [Object] Any reference to object you want to be passed back to you when the func is invoked 74 | # @param on_free [Proc] A proc triggered when this function is freed by the runtime. Not guaranteed to trigger. 75 | def initialize(name, params, returns, func_proc, user_data: nil, on_free: nil) 76 | @name = name 77 | @params = params 78 | @returns = returns 79 | @func = func_proc 80 | @user_data = user_data 81 | @on_free = on_free 82 | end 83 | 84 | private 85 | 86 | # Gets the pointer to this function. 87 | # Warning: This should not be used 88 | def pointer 89 | return @_pointer if @_pointer 90 | 91 | free = @on_free || proc {} 92 | args = LibExtism.from_int_array(@params) 93 | returns = LibExtism.from_int_array(@returns) 94 | @_pointer = LibExtism.extism_function_new(@name, args, @params.length, returns, @returns.length, c_func, free, 95 | nil) 96 | $FUNCTIONS[object_id] = { function: @_pointer} 97 | ObjectSpace.define_finalizer(self, $FREE_FUNCTION) 98 | return @_pointer 99 | end 100 | 101 | def c_func 102 | @c_func ||= proc do |plugin_ptr, inputs_ptr, inputs_size, outputs_ptr, outputs_size, _data_ptr| 103 | current_plugin = Extism::CurrentPlugin.send(:new, plugin_ptr) 104 | val_struct_size = LibExtism::ExtismVal.size 105 | 106 | inputs = (0...inputs_size).map do |i| 107 | Val.new(inputs_ptr + i * val_struct_size) 108 | end 109 | outputs = (0...outputs_size).map do |i| 110 | Val.new(outputs_ptr + i * val_struct_size) 111 | end 112 | 113 | @func.call(current_plugin, inputs, outputs, @user_data) 114 | end 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /sig/extism.rbs: -------------------------------------------------------------------------------- 1 | module Extism 2 | VERSION: String 3 | # See the writing guide of rbs: https://github.com/ruby/rbs#guides 4 | end 5 | -------------------------------------------------------------------------------- /test/test_extism.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | CUSTOMER = { 6 | full_name: 'John Smith', 7 | customer_id: 'abcd1234', 8 | total_spend: { 9 | currency: 'USD', 10 | amount_in_cents: 20_000 11 | }, 12 | credit: { 13 | currency: 'USD', 14 | amount_in_cents: 0 15 | } 16 | } 17 | 18 | KV_STORE = {} 19 | 20 | class KvEnvironment 21 | include Extism::HostEnvironment 22 | 23 | register_import :kv_read, [Extism::ValType::PTR], [Extism::ValType::PTR] 24 | register_import :kv_write, [Extism::ValType::PTR, Extism::ValType::PTR], [] 25 | 26 | def kv_read(plugin, inputs, outputs, _user_data) 27 | key = plugin.input_as_string(inputs.first) 28 | val = KV_STORE[key] || [0].pack('V') # get 4 LE bytes for 0 default 29 | plugin.output_string(outputs.first, val) 30 | end 31 | 32 | def kv_write(plugin, inputs, _outputs, _user_data) 33 | key = plugin.input_as_string(inputs.first) 34 | val = plugin.input_as_string(inputs[1]) 35 | KV_STORE[key] = val 36 | end 37 | end 38 | 39 | class Environment 40 | include Extism::HostEnvironment 41 | 42 | register_import :add_credit, [Extism::ValType::PTR, Extism::ValType::PTR], [Extism::ValType::PTR] 43 | register_import :send_email, [Extism::ValType::PTR, Extism::ValType::PTR], [] 44 | 45 | attr_accessor :credit_args, :email_args 46 | 47 | def add_credit(plugin, inputs, outputs, _user_data) 48 | customer_id = plugin.input_as_string(inputs.first) 49 | amount = plugin.input_as_json(inputs[1]) 50 | self.credit_args = [customer_id, amount] 51 | plugin.output_json(outputs.first, CUSTOMER) 52 | end 53 | 54 | def send_email(plugin, inputs, _outputs, _user_data) 55 | customer_id = plugin.input_as_string(inputs.first) 56 | email = plugin.input_as_json(inputs[1]) 57 | self.email_args = [customer_id, email] 58 | end 59 | end 60 | 61 | class TestExtism < Minitest::Test 62 | def test_that_it_has_a_version_number 63 | refute_nil Extism::VERSION 64 | end 65 | 66 | def test_plugin_call 67 | plugin = Extism::Plugin.new(vowels_manifest, config: { vowels: 'aeiouyAEIOUY' }) 68 | res = JSON.parse(plugin.call('count_vowels', 'this is a test')) 69 | assert_equal res['count'], 4 70 | res = JSON.parse(plugin.call('count_vowels', 'this is a test again')) 71 | assert_equal res['count'], 7 72 | res = JSON.parse(plugin.call('count_vowels', 'this is a test thrice')) 73 | assert_equal res['count'], 6 74 | res = JSON.parse(plugin.call('count_vowels', '🌎hello🌎world🌎')) 75 | assert_equal res['count'], 3 76 | res = JSON.parse(plugin.call('count_vowels', 'does y count?')) 77 | assert_equal res['count'], 5 78 | assert_equal res['vowels'], 'aeiouyAEIOUY' 79 | end 80 | 81 | def test_can_free_plugin 82 | plugin = Extism::Plugin.new(vowels_manifest) 83 | _res = plugin.call('count_vowels', 'this is a test') 84 | plugin.free 85 | assert_raises(Extism::Error) do 86 | _res = plugin.call('count_vowels', 'this is a test') 87 | end 88 | end 89 | 90 | def test_kv_store 91 | plugin = Extism::Plugin.new(count_vowels_kvstore_manifest, environment: KvEnvironment.new) 92 | res = JSON.parse plugin.call('count_vowels', 'this is a test') 93 | assert_equal res['total'], 4 94 | res = JSON.parse plugin.call('count_vowels', 'this is a test') 95 | assert_equal res['total'], 8 96 | end 97 | 98 | def test_errors_on_bad_manifest 99 | assert_raises(Extism::Error) do 100 | _plugin = Extism::Plugin.new({ not_a_real_manifest: true }) 101 | end 102 | end 103 | 104 | def test_has_function 105 | plugin = Extism::Plugin.new(vowels_manifest) 106 | assert plugin.has_function? 'count_vowels' 107 | refute plugin.has_function? 'i_am_not_a_function' 108 | end 109 | 110 | def test_errors_on_unknown_function 111 | plugin = Extism::Plugin.new(vowels_manifest) 112 | assert_raises(Extism::Error) do 113 | plugin.call('non_existent_function', 'input') 114 | end 115 | end 116 | 117 | def test_host_functions 118 | func = proc do |current_plugin, inputs, outputs, user_data| 119 | input = current_plugin.input_as_string(inputs.first) 120 | current_plugin.output_string(outputs.first, "#{input} #{user_data}") 121 | end 122 | f = Extism::Function.new('host_reflect', [Extism::ValType::I64], [Extism::ValType::I64], func, 123 | user_data: 'My User Data') 124 | plugin = Extism::Plugin.new(reflect_manifest, functions: [f], wasi: true) 125 | result = plugin.call('reflect', 'Hello, World!') 126 | assert_equal result, 'Hello, World! My User Data' 127 | end 128 | 129 | def test_environment 130 | env = Environment.new 131 | plugin = Extism::Plugin.new(store_credit_manifest, environment: env, wasi: true) 132 | _result = plugin.call('on_charge_succeeded', JSON.generate({ event_type: 'charge.succeeded', customer: CUSTOMER })) 133 | assert_equal env.credit_args[0], 'abcd1234' 134 | assert_equal env.credit_args[1], { 'amount_in_cents' => 1_000, 'currency' => 'USD' } 135 | assert_equal env.email_args[0], 'abcd1234' 136 | assert_equal env.email_args[1], 137 | { 'subject' => 'A gift for you John Smith', 'body' => 'You have received $10 in store credit!' } 138 | end 139 | 140 | private 141 | 142 | def vowels_manifest 143 | Extism::Manifest.from_path File.join(__dir__, '../wasm/count_vowels.wasm') 144 | end 145 | 146 | def reflect_manifest 147 | Extism::Manifest.from_path File.join(__dir__, '../wasm/reflect.wasm') 148 | end 149 | 150 | def store_credit_manifest 151 | Extism::Manifest.from_path File.join(__dir__, '../wasm/store_credit.wasm') 152 | end 153 | 154 | def count_vowels_kvstore_manifest 155 | Extism::Manifest.from_path File.join(__dir__, '../wasm/count_vowels_kvstore.wasm') 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 4 | require "extism" 5 | 6 | require "minitest/autorun" 7 | -------------------------------------------------------------------------------- /wasm/count_vowels.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/extism/ruby-sdk/976176a17e49d20cb6c456e648bd13dcba219e3e/wasm/count_vowels.wasm -------------------------------------------------------------------------------- /wasm/count_vowels_kvstore.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/extism/ruby-sdk/976176a17e49d20cb6c456e648bd13dcba219e3e/wasm/count_vowels_kvstore.wasm -------------------------------------------------------------------------------- /wasm/reflect.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/extism/ruby-sdk/976176a17e49d20cb6c456e648bd13dcba219e3e/wasm/reflect.wasm -------------------------------------------------------------------------------- /wasm/store_credit.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/extism/ruby-sdk/976176a17e49d20cb6c456e648bd13dcba219e3e/wasm/store_credit.wasm --------------------------------------------------------------------------------