summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorKazuki Yamaguchi <k@rhe.jp>2015-10-22 09:23:45 +0900
committerKazuki Yamaguchi <k@rhe.jp>2015-10-22 09:23:45 +0900
commit3fa9a5693d7417d75a8957cc0c8ff41347714fd1 (patch)
treee924675c1d32ae2a63e349a130f846d81e1afab1
downloadplum-3fa9a5693d7417d75a8957cc0c8ff41347714fd1.tar.gz
initial commit
-rw-r--r--.gitignore8
-rw-r--r--Gemfile3
-rw-r--r--README.md5
-rw-r--r--examples/sinatra.rb14
-rw-r--r--lib/plum/rack.rb12
-rw-r--r--lib/plum/rack/config.rb24
-rw-r--r--lib/plum/rack/connection.rb140
-rw-r--r--lib/plum/rack/dsl.rb44
-rw-r--r--lib/plum/rack/listener.rb59
-rw-r--r--lib/plum/rack/server.rb55
-rw-r--r--lib/plum/rack/version.rb5
-rw-r--r--lib/rack/handler/plum.rb44
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
diff --git a/Gemfile b/Gemfile
new file mode 100644
index 0000000..aef06cf
--- /dev/null
+++ b/Gemfile
@@ -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