diff options
author | Kazuki Yamaguchi <k@rhe.jp> | 2015-10-22 09:23:45 +0900 |
---|---|---|
committer | Kazuki Yamaguchi <k@rhe.jp> | 2015-10-22 09:23:45 +0900 |
commit | 3fa9a5693d7417d75a8957cc0c8ff41347714fd1 (patch) | |
tree | e924675c1d32ae2a63e349a130f846d81e1afab1 | |
download | plum-3fa9a5693d7417d75a8957cc0c8ff41347714fd1.tar.gz |
initial commit
-rw-r--r-- | .gitignore | 8 | ||||
-rw-r--r-- | Gemfile | 3 | ||||
-rw-r--r-- | README.md | 5 | ||||
-rw-r--r-- | examples/sinatra.rb | 14 | ||||
-rw-r--r-- | lib/plum/rack.rb | 12 | ||||
-rw-r--r-- | lib/plum/rack/config.rb | 24 | ||||
-rw-r--r-- | lib/plum/rack/connection.rb | 140 | ||||
-rw-r--r-- | lib/plum/rack/dsl.rb | 44 | ||||
-rw-r--r-- | lib/plum/rack/listener.rb | 59 | ||||
-rw-r--r-- | lib/plum/rack/server.rb | 55 | ||||
-rw-r--r-- | lib/plum/rack/version.rb | 5 | ||||
-rw-r--r-- | lib/rack/handler/plum.rb | 44 |
12 files changed, 413 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..854e88e --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/.bundle/ +/.yardoc +/Gemfile.lock +/tmp/* +.*.sw* +.*.local +/plum.yml +/dependency.yml @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem "plum" diff --git a/README.md b/README.md new file mode 100644 index 0000000..204f523 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# Plum::Rack +A pure-Ruby Rack HTTP/2 server + +## License +MIT License diff --git a/examples/sinatra.rb b/examples/sinatra.rb new file mode 100644 index 0000000..83c2113 --- /dev/null +++ b/examples/sinatra.rb @@ -0,0 +1,14 @@ +$LOAD_PATH << File.expand_path("../../lib", __FILE__) +require "plum/rack" +require "sinatra" + +set :server, :plum +enable :logging, :dump_errors, :raise_errors + +get "/" do + "get: #{params}" +end + +post "/" do + "post: " + params.to_s +end diff --git a/lib/plum/rack.rb b/lib/plum/rack.rb new file mode 100644 index 0000000..6650a92 --- /dev/null +++ b/lib/plum/rack.rb @@ -0,0 +1,12 @@ +$LOAD_PATH << File.expand_path("../../../../lib", __FILE__) +require "logger" +require "stringio" +require "plum" +require "rack" +require "rack/handler/plum" +require "plum/rack/version" +require "plum/rack/config" +require "plum/rack/dsl" +require "plum/rack/listener" +require "plum/rack/server" +require "plum/rack/connection" diff --git a/lib/plum/rack/config.rb b/lib/plum/rack/config.rb new file mode 100644 index 0000000..2f04886 --- /dev/null +++ b/lib/plum/rack/config.rb @@ -0,0 +1,24 @@ +module Plum + module Rack + class Config + DEFAULT_CONFIG = { + listeners: [], + debug: false, + log: nil, # $stdout + server_push: true + }.freeze + + def initialize(config) + @config = DEFAULT_CONFIG.merge(config) + end + + def [](key) + @config[key] + end + + def to_s + @config.to_s + end + end + end +end diff --git a/lib/plum/rack/connection.rb b/lib/plum/rack/connection.rb new file mode 100644 index 0000000..1100ae8 --- /dev/null +++ b/lib/plum/rack/connection.rb @@ -0,0 +1,140 @@ +module Plum + module Rack + class Connection + attr_reader :app, :sock, :plum + + def initialize(app, sock, logger) + @app = app + @sock = sock + @logger = logger + end + + def stop + @sock.close # TODO: gracefully shutdown + end + + def start + Thread.new { + begin + @sock = @sock.accept if @sock.respond_to?(:accept) # SSLSocket + @plum = setup_plum + @plum.run + rescue Errno::EPIPE, Errno::ECONNRESET => e + @logger.debug("connection closed: #{e}") + rescue StandardError => e + @logger.error("#{e.class}: #{e.message}\n#{e.backtrace.map { |b| "\t#{b}" }.join("\n")}") + end + } + end + + private + def setup_plum + plum = ::Plum::HTTPConnection.new(@sock) + plum.on(:connection_error) { |ex| @logger.error(ex) } + + plum.on(:stream) do |stream| + stream.on(:stream_error) { |ex| @logger.error(ex) } + + headers = data = nil + stream.on(:open) { + headers = nil + data = "".b + } + + stream.on(:headers) { |h| + @logger.debug("headers: " + h.map {|name, value| "#{name}: #{value}" }.join(" // ")) + headers = h + } + + stream.on(:data) { |d| + @logger.debug("data: #{d.bytesize}") + data << d # TODO: store to file? + } + + stream.on(:end_stream) { + env = new_env(headers, data) + r_headers, r_body = new_resp(@app.call(env)) + + if r_body.is_a?(::Rack::BodyProxy) + stream.respond(r_headers, end_stream: false) + r_body.each { |part| + stream.send_data(part, end_stream: false) + } + stream.send_data(nil) + else + stream.respond(r_headers, r_body) + end + } + end + + plum + end + + def new_env(h, data) + headers = h.group_by { |k, v| k }.map { |k, kvs| + if k == "cookie" + [k, kvs.map(&:last).join("; ")] + else + [k, kvs.first.last] + end + }.to_h + + cmethod = headers.delete(":method") + cpath = headers.delete(":path") + cpath_name, cpath_query = cpath.split("?", 2).map(&:to_s) + cauthority = headers.delete(":authority") + cscheme = headers.delete(":scheme") + ebase = { + "REQUEST_METHOD" => cmethod, + "SCRIPT_NAME" => "", + "PATH_INFO" => cpath_name, + "QUERY_STRING" => cpath_query.to_s, + "SERVER_NAME" => cauthority.split(":").first, + "SERVER_PORT" => (cauthority.split(":").last || 443), # TODO: forwarded header (RFC 7239) + } + + headers.each {|key, value| + ebase["HTTP_" + key.gsub("-", "_").upcase] = value + } + + ebase.merge!({ + "rack.version" => ::Rack::VERSION, + "rack.url_scheme" => cscheme, + "rack.input" => StringIO.new(data), + "rack.errors" => $stderr, + "rack.multithread" => true, + "rack.multiprocess" => false, + "rack.run_once" => false, + "rack.hijack?" => false, + }) + + ebase + end + + def new_resp(app_call) + r_status, r_h, r_body = app_call + + rbase = { + ":status" => r_status, + "server" => "plum/#{::Plum::VERSION}", + } + + r_h.each do |key, v_| + if key.start_with?("rack.") + next + end + + key = key.downcase.gsub(/^x-/, "") + vs = v_.split("\n") + if key == "set-cookie" + rbase[key] = vs.join("; ") # RFC 7540 8.1.2.5 + else + rbase[key] = vs.join(",") # RFC 7230 7 + end + end + + [rbase, r_body] + end + end + end +end diff --git a/lib/plum/rack/dsl.rb b/lib/plum/rack/dsl.rb new file mode 100644 index 0000000..f4ee850 --- /dev/null +++ b/lib/plum/rack/dsl.rb @@ -0,0 +1,44 @@ +module Plum + module Rack + module DSL + class Config + attr_reader :config + + def initialize + @config = ::Plum::Rack::Config::DEFAULT_CONFIG.dup + end + + def log(out) + if out.is_a?(String) + @config[:log] = File.open(out, "a") + else + @config[:log] = out + end + end + + def debug(bool) + @config[:debug] = !!bool + end + + def listener(type, conf) + case type + when :unix + lc = conf.merge(listener: UNIXListener) + when :tcp + lc = conf.merge(listener: TCPListener) + when :tls + lc = conf.merge(listener: TLSListener) + else + raise "Unknown listener type: #{type} (known type: :unix, :http, :https)" + end + + @config[:listeners] << lc + end + + def server_push(bool) + @config[:server_push] = !!bool + end + end + end + end +end diff --git a/lib/plum/rack/listener.rb b/lib/plum/rack/listener.rb new file mode 100644 index 0000000..e1dde02 --- /dev/null +++ b/lib/plum/rack/listener.rb @@ -0,0 +1,59 @@ +module Plum + module Rack + class BaseListener + def stop + @server.close + end + + def to_io + raise "not implemented" + end + + def accept + to_io.accept + end + end + + class TCPListener < BaseListener + def initialize(lc) + @server = ::TCPServer.new(lc[:hostname], lc[:port]) + end + + def to_io + @server.to_io + end + end + + class TLSListener < BaseListener + def initialize(hostname, port, cert, key) + ctx = OpenSSL::SSL::SSLContext.new + ctx.ssl_version = :TLSv1_2 + ctx.alpn_select_cb = -> protocols { + raise "Client does not support HTTP/2: #{protocols}" unless protocols.include?("h2") + "h2" + } + ctx.tmp_ecdh_callback = -> (sock, ise, keyl) { OpenSSL::PKey::EC.new("prime256v1") } + ctx.cert = OpenSSL::X509::Certificate.new(cert) + ctx.key = OpenSSL::PKey::RSA.new(key) + tcp_server = ::TCPServer.new(hostname, port) + @server = OpenSSL::SSL::SSLServer.new(tcp_server, ctx) + @server.start_immediately = false + end + + def to_io + @server.to_io + end + end + + class UNIXListener < BaseListener + def initialize(path, permission, user, group) + @server = ::UNIXServer.new(path) + # TODO: set permission, user, group + end + + def to_io + @server.to_io + end + end + end +end diff --git a/lib/plum/rack/server.rb b/lib/plum/rack/server.rb new file mode 100644 index 0000000..15cb10a --- /dev/null +++ b/lib/plum/rack/server.rb @@ -0,0 +1,55 @@ +module Plum + module Rack + class Server + def initialize(app, config) + @state = :null + @app = app + @logger = Logger.new(config[:log] || $stdout).tap { |l| + l.level = config[:debug] ? Logger::DEBUG : Logger::INFO + } + @listeners = config[:listeners].map { |lc| + lc[:listener].new(lc) + } + + @logger.info("Plum::Rack #{::Plum::Rack::VERSION} (Plum #{::Plum::VERSION})") + @logger.info("Config: #{config}") + end + + def start + @state = :running + while @state == :running + break if @listeners.empty? + begin + if ss = IO.select(@listeners, nil, nil, 2.0) + ss[0].each { |svr| + new_con(svr) + } + end + rescue Errno::EBADF, Errno::ENOTSOCK, IOError => e # closed + @logger.debug("socket closed?: #{e}") + rescue StandardError => e + @logger.error("#{e.class}: #{e.message}\n#{e.backtrace.map { |b| "\t#{b}" }.join("\n")}") + end + end + end + + def stop + @state = :stop + @listeners.map(&:stop) + end + + private + def new_con(svr) + sock = svr.accept + @logger.debug("accept: #{sock}") + + con = Connection.new(@app, sock, @logger) + con.start + rescue Errno::ECONNRESET, Errno::ECONNABORTED, Errno::EPROTO, Errno::EINVAL => e # closed + @logger.debug("connection closed while accepting: #{e}") + rescue StandardError => e + @logger.error("#{e.class}: #{e.message}\n#{e.backtrace.map { |b| "\t#{b}" }.join("\n")}") + end + end + end +end diff --git a/lib/plum/rack/version.rb b/lib/plum/rack/version.rb new file mode 100644 index 0000000..d968fe8 --- /dev/null +++ b/lib/plum/rack/version.rb @@ -0,0 +1,5 @@ +module Plum + module Rack + VERSION = "0.0.1" + end +end diff --git a/lib/rack/handler/plum.rb b/lib/rack/handler/plum.rb new file mode 100644 index 0000000..599a3e1 --- /dev/null +++ b/lib/rack/handler/plum.rb @@ -0,0 +1,44 @@ +module Rack + module Handler + class Plum + def self.run(app, options = {}) + opts = default_options.merge(options) + + config = ::Plum::Rack::Config.new( + listeners: [ + { + listener: ::Plum::Rack::TCPListener, + hostname: opts[:Host], + port: opts[:Port].to_i + } + ], + debug: !!opts[:Debug] + ) + + @server = ::Plum::Rack::Server.new(app, config) + yield @server if block_given? + @server.start + end + + def self.valid_options + { + "Host=HOST" => "Hostname to listen on (default: #{default_options[:Host]})", + "Port=PORT" => "Port to listen on (default: #{default_options[:Port]})", + "Debug" => "Turn on debug mode (default: #{default_options[:Debug]})", + } + end + + private + def self.default_options + rack_env = ENV["RACK_ENV"] || "development" + default_options = { + Host: rack_env == "development" ? "localhost" : "0.0.0.0", + Port: 8080, + Debug: true, + } + end + end + + register(:plum, ::Rack::Handler::Plum) + end +end |