├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── TODO ├── bin ├── console ├── scrawls └── setup ├── lib ├── scrawls.rb └── scrawls │ ├── config.rb │ ├── config │ ├── task.rb │ └── tasklist.rb │ ├── core.rb │ ├── ioengine │ └── base.rb │ ├── rack_handler.rb │ ├── version.rb │ └── webserver.rb ├── scrawls.gemspec └── test ├── scrawls_test.rb └── test_helper.rb /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, and in the interest of 4 | fostering an open and welcoming community, we pledge to respect all people who 5 | contribute through reporting issues, posting feature requests, updating 6 | documentation, submitting pull requests or patches, and other activities. 7 | 8 | We are committed to making participation in this project a harassment-free 9 | experience for everyone, regardless of level of experience, gender, gender 10 | identity and expression, sexual orientation, disability, personal appearance, 11 | body size, race, ethnicity, age, religion, or nationality. 12 | 13 | Examples of unacceptable behavior by participants include: 14 | 15 | * The use of sexualized language or imagery 16 | * Personal attacks 17 | * Trolling or insulting/derogatory comments 18 | * Public or private harassment 19 | * Publishing other's private information, such as physical or electronic 20 | addresses, without explicit permission 21 | * Other unethical or unprofessional conduct 22 | 23 | Project maintainers have the right and responsibility to remove, edit, or 24 | reject comments, commits, code, wiki edits, issues, and other contributions 25 | that are not aligned to this Code of Conduct, or to ban temporarily or 26 | permanently any contributor for other behaviors that they deem inappropriate, 27 | threatening, offensive, or harmful. 28 | 29 | By adopting this Code of Conduct, project maintainers commit themselves to 30 | fairly and consistently applying these principles to every aspect of managing 31 | this project. Project maintainers who do not follow or enforce the Code of 32 | Conduct may be permanently removed from the project team. 33 | 34 | This code of conduct applies both within project spaces and in public spaces 35 | when an individual is representing the project or its community. 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 38 | reported by contacting a project maintainer at wyhaines@gmail.com. All 39 | complaints will be reviewed and investigated and will result in a response that 40 | is deemed necessary and appropriate to the circumstances. Maintainers are 41 | obligated to maintain confidentiality with regard to the reporter of an 42 | incident. 43 | 44 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 45 | version 1.3.0, available at 46 | [http://contributor-covenant.org/version/1/3/0/][version] 47 | 48 | [homepage]: http://contributor-covenant.org 49 | [version]: http://contributor-covenant.org/version/1/3/0/ -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in scrawls.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Kirk Haines 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Scrawls 2 | 3 | ScRaWlS -- Simple Ruby Web Server -- is a web server implementation written in Ruby. It is intended to be a basic, extendable shell that can be utilized as a base for writing specific purpose servers or tools, with a pluggable architecture for selecting the HTTP parsing subsystem, and the threading/concurrency subsystem to be used by the server. 4 | 5 | ## Installation 6 | 7 | Add this line to your application's Gemfile: 8 | 9 | ```ruby 10 | gem 'scrawls' 11 | ``` 12 | 13 | And then execute: 14 | 15 | $ bundle 16 | 17 | Or install it yourself as: 18 | 19 | $ gem install scrawls 20 | 21 | ## Usage 22 | 23 | TODO: Write usage instructions here 24 | 25 | ## Development 26 | 27 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 28 | 29 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 30 | 31 | ## Contributing 32 | 33 | Bug reports and pull requests are welcome on GitHub at https://github.com/wyhaines/scrawls. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 34 | 35 | 36 | ## License 37 | 38 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 39 | 40 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.libs << "test" 6 | t.libs << "lib" 7 | t.test_files = FileList['test/**/*_test.rb'] 8 | end 9 | 10 | task :default => :test 11 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | * Cleanup 2 | * Logging 3 | * Exception Handling 4 | * Rack support that isn't terrible 5 | * Switch to a proper branched dev model where master is always deployable 6 | * TESTS!!!! 7 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "scrawls" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /bin/scrawls: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'scrawls' 4 | 5 | Scrawls.run 6 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/scrawls.rb: -------------------------------------------------------------------------------- 1 | require 'scrawls/core' 2 | 3 | module Scrawls 4 | def self.run 5 | config = SimpleRubyWebServer::Config.new 6 | config.parse 7 | server = SimpleRubyWebServer.new config 8 | server.run 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/scrawls/config.rb: -------------------------------------------------------------------------------- 1 | require 'mime-types' 2 | require 'optparse' 3 | require 'scrawls/config/task' 4 | require 'scrawls/config/tasklist' 5 | 6 | class SimpleRubyWebServer 7 | class Config 8 | 9 | def initialize 10 | @configuration = {} 11 | @configuration[:docroot] = '.' 12 | @configuration[:ioengine] = 'single' 13 | @configuration[:httpengine] = 'httprecognizer' 14 | @configuration[:port] = 8080 15 | @configuration[:host] = '127.0.0.1' 16 | 17 | @meta_configuration = {} 18 | @meta_configuration[:helptext] = '' 19 | end 20 | 21 | def [](val) 22 | @configuration.has_key?(val) ? @configuration[val] : @meta_configuration[val] 23 | end 24 | 25 | def config 26 | @configuration 27 | end 28 | 29 | def meta 30 | @meta_configuration 31 | end 32 | 33 | def classname(klass) 34 | parts = Array === klass ? klass : klass.split(/::/) 35 | parts.inject(::Object) {|o,n| o.const_get n} 36 | end 37 | 38 | def parse(parse_cl = true, additional_config = {}, additional_meta_config = {}, additional_tasks = nil) 39 | @configuration.merge! additional_config 40 | @meta_configuration.merge! additional_meta_config 41 | 42 | tasklist = parse_command_line if parse_cl 43 | 44 | tasklist = merge_task_lists(tasklist, additional_tasks) if additional_tasks 45 | 46 | run_task_list tasklist 47 | end 48 | 49 | def run_task_list( tasks ) 50 | tasks = tasks.sort 51 | 52 | result = nil 53 | while tasks.any? do 54 | new_task = tasks.shift 55 | result = new_task.call # If any task returns a task list, fall out of execution 56 | break if TaskList === result 57 | end 58 | 59 | tasks = merge_task_lists(tasks, result) if TaskList === result # merge any new tasks into the remaining tasks 60 | 61 | run_task_list( tasks ) if tasks.any? # run any remaining tasks 62 | end 63 | 64 | def merge_task_lists(old_list, new_list) 65 | ( old_list + new_list ).sort 66 | end 67 | 68 | def parse_command_line 69 | call_list = TaskList.new 70 | 71 | options = OptionParser.new do |opts| 72 | opts.on( '-h', '--help' ) do 73 | exe = File.basename( $PROGRAM_NAME ) 74 | @meta_configuration[:helptext] << <<-EHELP 75 | #{exe} [OPTIONS] 76 | 77 | #{exe} is a simple ruby web server. 78 | 79 | -h, --help: 80 | Show this help. 81 | 82 | -d DIR, --docroot DIR: 83 | Provide a specific directory for the docroot for this server. 84 | 85 | -a APP, --app APP: 86 | Ruby file containing a rack app to use. 87 | 88 | -i IO_ENGINE, --ioengine IO_ENGINE: 89 | Tell the webserver which concurrency engine to use. 90 | Installed IO Engines: 91 | #{`gem search -l scrawls-ioengine`.split(/\n/).select {|e| e =~ /scrawls-ioengine-/}.collect {|e| e =~ /scrawls-ioengine-(\w+)/; " #{$1}"}.join("\n")} 92 | 93 | -h HTTP_ENGINE, --httpengine HTTP_ENGINE: 94 | Tell the webserver which concurrency engine to use. 95 | Installed HTTP Engines: 96 | #{`gem search -l scrawls-httpengine`.split(/\n/).select {|e| e =~ /scrawls-httpengine-/}.collect {|e| e =~ /scrawls-httpengine-(\w+)/; " #{$1}"}.join("\n")} 97 | 98 | -p PORT, --port PORT: 99 | The port for the web server to listen on. If this flag is not used, the web 100 | server defaults to port 80. 101 | 102 | -b HOSTNAME, --bind HOSTNAME: 103 | The hostname/IP to bind to. This defaults to 127.0.0.1 if it is not provided. 104 | 105 | EHELP 106 | call_list << Task.new(9999) { puts @meta_configuration[:helptext]; exit 0 } 107 | end 108 | 109 | opts.on( '-d', '--docroot DOCROOT' ) do |docroot| 110 | call_list << Task.new(9000) { @configuration[:docroot] = docroot } 111 | end 112 | 113 | opts.on( '-a', '--app APP' ) do |app| 114 | call_list << Task.new(9000) do 115 | require "#{app}" 116 | @configuration[:racked] = true 117 | end 118 | end 119 | 120 | opts.on( '-i', '--ioengine ENGINE' ) do |ioengine| 121 | @configuration[:ioengine] = ioengine 122 | end 123 | call_list << Task.new(0) do 124 | libname = "scrawls/ioengine/#{@configuration[:ioengine]}" 125 | setup_engine(:ioengine, libname) 126 | end 127 | 128 | opts.on( '-e', '--httpengine ENGINE' ) do |httpengine| 129 | @configuration[:httpengine] = httpengine 130 | end 131 | call_list << Task.new(0) do 132 | libname = "scrawls/httpengine/#{@configuration[:httpengine]}" 133 | setup_engine(:httpengine, libname) 134 | end 135 | 136 | opts.on( '-p', '--port PORT') do |port| 137 | call_list << Task.new(9000) { n = Integer( port.to_i ); @configuration[:port] = n > 0 ? n : @configuration[:port] } 138 | end 139 | 140 | opts.on( '-b', '--bind HOST') do |host| 141 | call_list << Task.new(9000) { @configuration[:host] = host } 142 | end 143 | end 144 | 145 | leftover_argv = [] 146 | 147 | begin 148 | options.parse!(ARGV) 149 | rescue OptionParser::InvalidOption => e 150 | e.recover ARGV 151 | leftover_argv << ARGV.shift 152 | leftover_argv << ARGV.shift if ARGV.any? && ( ARGV.first[0..0] != '-' ) 153 | retry 154 | end 155 | 156 | ARGV.replace( leftover_argv ) if leftover_argv.any? 157 | 158 | call_list 159 | end 160 | 161 | def setup_engine(key, libname) 162 | require libname 163 | klass = classname( libname.split(/\//).collect {|s| s.capitalize} ) 164 | @configuration[key] = klass 165 | @configuration[key].parse_command_line(@configuration, @meta_configuration) if @configuration[key].respond_to? :parse_command_line 166 | end 167 | 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /lib/scrawls/config/task.rb: -------------------------------------------------------------------------------- 1 | class SimpleRubyWebServer 2 | class Config 3 | class Task 4 | include Comparable 5 | 6 | attr_accessor :order, :task 7 | 8 | def initialize(order = 0, &task) 9 | @order = order 10 | @task = task 11 | end 12 | 13 | def <=>(another_task) 14 | if @order < another_task.order 15 | -1 16 | elsif @order > another_task.order 17 | 1 18 | else 19 | if @task.to_s < another_task.task.to_s 20 | -1 21 | elsif @task.to_s > another_task.task.to_s 22 | 1 23 | else 24 | 0 25 | end 26 | end 27 | end 28 | 29 | def call(*args) 30 | @task.call(*args) if @task 31 | end 32 | 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/scrawls/config/tasklist.rb: -------------------------------------------------------------------------------- 1 | class SimpleRubyWebServer 2 | class Config 3 | class TaskList < Array 4 | end 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/scrawls/core.rb: -------------------------------------------------------------------------------- 1 | require 'scrawls/version' 2 | require 'scrawls/config' 3 | require 'scrawls/rack_handler' 4 | require 'time' 5 | 6 | Signal.trap("INT") { exit } # This should defer to the IO Engine for proper cleanup 7 | Signal.trap("TERM") { exit } # This should defer to the IO Engine for proper cleanup 8 | 9 | class SimpleRubyWebServer 10 | 11 | CANNED_OK = "HTTP/1.0 200 OK\r\n" 12 | 13 | attr_accessor :io_engine, :http_engine 14 | 15 | def initialize(config) 16 | @config = config 17 | end 18 | 19 | def run(&block) 20 | @rack_app = SimpleRubyWebServer.rack_app if @config[:racked] 21 | 22 | @http_engine = @config[:httpengine] 23 | 24 | @io_engine = @config[:ioengine].new self 25 | 26 | @io_engine.run @config 27 | end 28 | 29 | def has_app? 30 | @rack_app 31 | end 32 | 33 | def run_app request, ioengine 34 | ::Rack::Handler::Scrawls.serve @rack_app, request, ioengine 35 | end 36 | 37 | def process request, ioengine 38 | # This server is stupid. For any request method, and http version, it just tries to serve a static file. 39 | path = File.join( @config[:docroot], request['PATH_INFO'] ) 40 | if FileTest.directory? path 41 | deliver_directory path, ioengine 42 | elsif FileTest.exist?( path ) and FileTest.file?( path ) and File.expand_path( path ).index( File.expand_path( @config[:docroot] ) ) == 0 43 | ioengine.send_data CANNED_OK + 44 | "Content-Type: #{MIME::Types.type_for( path )}\r\n" + 45 | "Content-Length: #{File.size( path )}\r\n" + 46 | "Last-Modified: #{File.mtime( path )}\r\n" + 47 | final_headers + 48 | File.read( path ) 49 | elsif has_app? 50 | run_app request, ioengine 51 | else 52 | deliver_404 request['PATH_INFO'], ioengine 53 | end 54 | rescue Exception => e 55 | puts "ERROR\n\n#{e}\n#{e.backtrace.join("\n")}\n" 56 | deliver_500 ioengine 57 | end 58 | 59 | def final_headers 60 | "Date: #{Time.now.httpdate}\r\nConnection: close\r\n\r\n" 61 | end 62 | 63 | def deliver_directory path, ioengine 64 | deliver_403 ioengine 65 | end 66 | 67 | def deliver_404 uri, ioengine 68 | buffer = "The requested resource (#{uri}) could not be found." 69 | ioengine.send_data "HTTP/1.1 404 Not Found\r\nContent-Length:#{buffer.length}\r\nContent-Type:text/plain\r\n#{final_headers}#{buffer}" 70 | end 71 | 72 | def deliver_400 ioengine 73 | buffer = "The request was malformed and could not be completed." 74 | ioengine.send_data "HTTP/1.1 400 Bad Request\r\nContent-Length:#{buffer.length}\r\nContent-Type:text/plain\r\n#{final_headers}#{buffer}" 75 | end 76 | 77 | def deliver_403 ioengine 78 | buffer = "Forbidden. The requested resource can not be accessed." 79 | ioengine.send_data "HTTP/1.1 403 Bad Request\r\nContent-Length:#{buffer.length}\r\nContent-Type:text/plain\r\n#{final_headers}#{buffer}" 80 | end 81 | 82 | def deliver_500 ioengine 83 | buffer = "There was an internal server error." 84 | ioengine.send_data "HTTP/1.1 500 Bad Request\r\nContent-Length:#{buffer.length}\r\nContent-Type:text/plain\r\n#{final_headers}#{buffer}" 85 | end 86 | 87 | def content_type_for path 88 | MIME::TinyTypes.types.simple_type_for( path ) || 'application/octet-stream' 89 | end 90 | 91 | def data_for path_info 92 | path = File.join(@docroot,path_info) 93 | path if FileTest.exist?(path) and FileTest.file?(path) and File.expand_path(path).index(docroot) == 0 94 | end 95 | 96 | def final_headers 97 | "Date: #{Time.now.httpdate}\r\nConnection: close\r\n\r\n" 98 | end 99 | 100 | end 101 | -------------------------------------------------------------------------------- /lib/scrawls/ioengine/base.rb: -------------------------------------------------------------------------------- 1 | module Scrawls 2 | module Ioengine 3 | class Base 4 | 5 | def run( host = '0.0.0.0', port = '8080' ) 6 | # Implement the main loop of the IO Engine here 7 | end 8 | 9 | def get_request connection 10 | # Get the request from the connection. This will pass a lot of responsibility into the httpengine for the actual parsing of the request. 11 | end 12 | 13 | def handle request 14 | # Handle the request, and return a response 15 | end 16 | 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/scrawls/rack_handler.rb: -------------------------------------------------------------------------------- 1 | require 'rack/content_length' 2 | require 'rack/rewindable_input' 3 | 4 | # This is barebones and terrible. TODO: Make it less terrible. 5 | module Rack 6 | module Handler 7 | class Scrawls 8 | def self.serve(app, request, ioengine) 9 | status, headers, body = app.call(request) 10 | begin 11 | send_headers ioengine, status, headers 12 | send_body ioengine, body 13 | ensure 14 | body.close if body.respond_to? :close 15 | end 16 | end 17 | 18 | def self.send_headers(ioengine, status, headers) 19 | headers.each { |k, vs| 20 | vs.split("\n").each { |v| 21 | ioengine.send_data "#{k}: #{v}\r\n" 22 | } 23 | } 24 | ioengine.send_data "\r\n" 25 | end 26 | 27 | def self.send_body(ioengine, body) 28 | body.each { |part| 29 | ioengine.send_data part 30 | } 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/scrawls/version.rb: -------------------------------------------------------------------------------- 1 | class SimpleRubyWebServer 2 | VERSION = "0.1.0" 3 | end 4 | module Scrawls 5 | VERSION = ::SimpleRubyWebServer::VERSION 6 | end 7 | -------------------------------------------------------------------------------- /lib/scrawls/webserver.rb: -------------------------------------------------------------------------------- 1 | require 'simplereactor' 2 | require 'tinytypes' 3 | require 'getoptlong' 4 | require 'socket' 5 | 6 | class SimpleWebServer 7 | 8 | EXE = File.basename __FILE__ 9 | VERSION = "1.0" 10 | 11 | def self.parse_cmdline 12 | initialize_defaults 13 | 14 | opts = GetoptLong.new( 15 | [ '--help', '-h', GetoptLong::NO_ARGUMENT], 16 | [ '--threads', '-t', GetoptLong::REQUIRED_ARGUMENT], 17 | [ '--engine', '-e', GetoptLong::REQUIRED_ARGUMENT], 18 | [ '--port', '-p', GetoptLong::REQUIRED_ARGUMENT], 19 | [ '--docroot', '-d', GetoptLong::REQUIRED_ARGUMENT] 20 | ) 21 | 22 | opts.each do |opt, arg| 23 | case opt 24 | when '--help' 25 | puts <<-EHELP 26 | #{EXE} [OPTIONS] 27 | 28 | #{EXE} is a very simple web server. It only serves static files. It does very 29 | little parsing of the HTTP request, only fetching the small amount of 30 | information necessary to determine what resource is being requested. The server 31 | defaults to serving files from the current director when it was invoked. 32 | 33 | -h, --help: 34 | Show this help. 35 | 36 | -d DIR, --docroot DIR: 37 | Provide a specific directory for the docroot for this server. 38 | 39 | -e ENGINE, --engine ENGINE: 40 | Tell the webserver which IO engine to use. This is passed to SimpleReactor, 41 | and will be one of 'select' or 'nio'. If not specified, it will attempt to 42 | use nio, and fall back on select. 43 | 44 | -p PORT, --port PORT: 45 | The port for the web server to listen on. If this flag is not used, the web 46 | server defaults to port 80. 47 | 48 | -b HOSTNAME, --bind HOSTNAME: 49 | The hostname/IP to bind to. This defaults to 127.0.0.1 if it is not provided. 50 | 51 | -t COUNT, --threads COUNT: 52 | The number of threads to create of this web server. This defaults to a single 53 | thread. 54 | EHELP 55 | exit 56 | when '--docroot' 57 | @docroot = arg 58 | when '--engine' 59 | @engine = arg 60 | when '--port' 61 | @port = arg.to_i != 0 ? arg.to_i : @port 62 | when '--bind' 63 | @host = arg 64 | when '--threads' 65 | @threads = arg.to_i != 0 ? arg.to_i : @port 66 | end 67 | end 68 | end 69 | 70 | def self.initialize_defaults 71 | @docroot = '.' 72 | @engine = 'nio' 73 | @port = 80 74 | @host = '127.0.0.1' 75 | @threads = 1 76 | end 77 | 78 | def self.docroot 79 | @docroot 80 | end 81 | 82 | def self.engine 83 | @engine 84 | end 85 | 86 | def self.port 87 | @port 88 | end 89 | 90 | def self.host 91 | @host 92 | end 93 | 94 | def self.threads 95 | @threads 96 | end 97 | 98 | def self.run 99 | parse_cmdline 100 | 101 | SimpleReactor.use_engine @engine.to_sym 102 | 103 | webserver = SimpleWebServer.new 104 | 105 | webserver.run do |request| 106 | webserver.handle_response request 107 | end 108 | end 109 | 110 | def initialize 111 | @children = nil 112 | @docroot = self.class.docroot 113 | end 114 | 115 | def run(&block) 116 | @server = TCPServer.new self.class.host, self.class.port 117 | 118 | handle_threading 119 | 120 | SimpleReactor::Reactor.run do |reactor| 121 | @reactor = reactor 122 | @reactor.attach @server, :read do |monitor| 123 | connection = monitor.io.accept 124 | handle_request '',connection, monitor 125 | end 126 | end 127 | end 128 | 129 | def handle_request buffer, connection, monitor = nil 130 | eof = false 131 | buffer << connection.read_nonblock(16384) 132 | rescue EOFError 133 | eof = true 134 | rescue IO::WaitReadable 135 | # This is actually handled in the logic below. We just need to survive it. 136 | ensure 137 | request = parse_request buffer, connection 138 | if !request && monitor 139 | @reactor.next_tick do 140 | @reactor.attach connection, :read do |monitor| 141 | handle_request buffer, connection 142 | end 143 | end 144 | elsif eof && !request 145 | deliver_400 connection 146 | elsif request 147 | handle_response_for request, connection 148 | end 149 | 150 | if request || eof 151 | @reactor.next_tick do 152 | @reactor.detach(connection) 153 | connection.close 154 | end 155 | end 156 | 157 | end 158 | 159 | def handle_response_for request, connection 160 | path = "./#{request[:uri]}" 161 | if FileTest.exist?( path ) && FileTest.readable?( path ) 162 | deliver path, connection 163 | else 164 | deliver_404 path, connection 165 | end 166 | end 167 | 168 | def parse_request buffer, connection 169 | if buffer =~ /^(\w+) +(?:\w+:\/\/([^ \/]+))?([^ \?\#]*)\S* +HTTP\/(\d\.\d)/ 170 | request_method = $1 171 | uri = $3 172 | http_version = $4 173 | if $2 174 | name = $2.intern 175 | uri = C_slash if @uri.empty? 176 | # Rewrite the request to get rid of the http://foo portion. 177 | buffer.sub!(/^\w+ +\w+:\/\/[^ \/]+([^ \?]*)/,"#{@request_method} #{@uri}") 178 | buffer =~ /^(\w+) +(?:\w+:\/\/([^ \/]+))?([^ \?\#]*)\S* +HTTP\/(\d\.\d)/ 179 | request_method = $1 180 | uri = $3 181 | http_version = $4 182 | end 183 | uri = uri.tr('+', ' ').gsub(/((?:%[0-9a-fA-F]{2})+)/n) {[$1.delete('%')].pack('H*')} if uri.include?('%') 184 | unless name 185 | if buffer =~ /^Host: *([^\r\0:]+)/ 186 | name = $1.intern 187 | end 188 | end 189 | 190 | { :uri => uri, :request_method => request_method, :http_version => http_version, :name => name } 191 | end 192 | end 193 | 194 | def deliver uri, connection 195 | if FileTest.directory? uri 196 | deliver_directory connection 197 | else 198 | data = File.read(uri) 199 | end 200 | connection.write "HTTP/1.1 200 OK\r\nContent-Length:#{data.length}\r\nContent-Type: #{content_type_for uri}\r\nConnection:close\r\n\r\n#{data}" 201 | rescue Exception 202 | deliver_500 connection 203 | end 204 | 205 | def deliver_directory 206 | deliver_403 connection 207 | end 208 | 209 | def deliver_404 uri, connection 210 | buffer = "The requested resource (#{uri}) could not be found." 211 | connection.write "HTTP/1.1 404 Not Found\r\nContent-Length:#{buffer.length}\r\nContent-Type:text/plain\r\nConnection:close\r\n\r\n#{buffer}" 212 | end 213 | 214 | def deliver_400 connection 215 | buffer = "The request was malformed and could not be completed." 216 | connection.write "HTTP/1.1 400 Bad Request\r\nContent-Length:#{buffer.length}\r\nContent-Type:text/plain\r\nConnection:close\r\n\r\n#{buffer}" 217 | end 218 | 219 | def deliver_403 connection 220 | buffer = "Forbidden. The requested resource can not be accessed." 221 | connection.write "HTTP/1.1 403 Bad Request\r\nContent-Length:#{buffer.length}\r\nContent-Type:text/plain\r\nConnection:close\r\n\r\n#{buffer}" 222 | end 223 | 224 | def deliver_500 connection 225 | buffer = "There was an internal server error." 226 | connection.write "HTTP/1.1 500 Bad Request\r\nContent-Length:#{buffer.length}\r\nContent-Type:text/plain\r\nConnection:close\r\n\r\n#{buffer}" 227 | end 228 | 229 | def content_type_for path 230 | MIME::TinyTypes.types.simple_type_for( path ) || 'application/octet-stream' 231 | end 232 | 233 | def data_for path_info 234 | path = File.join(@docroot,path_info) 235 | path if FileTest.exist?(path) and FileTest.file?(path) and File.expand_path(path).index(docroot) == 0 236 | end 237 | 238 | def handle_threading 239 | if self.class.threads > 1 240 | @children = [] 241 | (self.class.threads - 1).times do |thread_count| 242 | pid = fork() 243 | if pid 244 | @children << pid 245 | else 246 | break 247 | end 248 | end 249 | 250 | Thread.new { waitall } 251 | end 252 | end 253 | 254 | end 255 | 256 | SimpleWebServer.run -------------------------------------------------------------------------------- /scrawls.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'scrawls/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "scrawls" 8 | spec.version = Scrawls::VERSION 9 | spec.authors = ["Kirk Haines"] 10 | spec.email = ["wyhaines@gmail.com"] 11 | 12 | spec.summary = %q{Scrawls is a simple ruby web server with a pluggable architecture.} 13 | spec.description = %q{Scrawls is a web server, written in Ruby, that lets one choose what sort of concurreny engine to use, as well as the HTTP parsing engine to use. } 14 | spec.homepage = "http://github.com/wyhaines/scrawls" 15 | spec.license = "MIT" 16 | 17 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 18 | spec.bindir = "exe" 19 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 20 | spec.require_paths = ["lib"] 21 | 22 | spec.add_development_dependency "bundler", "~> 1.12" 23 | spec.add_development_dependency "rake", "~> 10.0" 24 | spec.add_development_dependency "minitest", "~> 5.0" 25 | 26 | spec.add_runtime_dependency "mime-types", "~> 3.0" 27 | spec.add_runtime_dependency "rack", "~> 2.0" 28 | 29 | spec.add_runtime_dependency "scrawls-httpengine-httprecognizer", "~> 0.1" 30 | spec.add_runtime_dependency "scrawls-ioengine-single", "~> 0.1" 31 | spec.add_runtime_dependency "scrawls-ioengine-multiprocess", "~> 0.1" 32 | spec.add_runtime_dependency "scrawls-ioengine-multithread", "~> 0.1" 33 | spec.add_runtime_dependency "scrawls-ioengine-simplereactor", "~> 0.1" 34 | end 35 | -------------------------------------------------------------------------------- /test/scrawls_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class ScrawlsTest < Minitest::Test 4 | def test_that_it_has_a_version_number 5 | refute_nil ::Scrawls::VERSION 6 | end 7 | 8 | def test_it_does_something_useful 9 | assert false 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | require 'scrawls' 3 | 4 | require 'minitest/autorun' 5 | --------------------------------------------------------------------------------