wsv 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +76 -1
- data/README.md +91 -8
- data/lib/wsv/app.rb +109 -6
- data/lib/wsv/cli.rb +49 -3
- data/lib/wsv/cors.rb +30 -0
- data/lib/wsv/path_resolver.rb +14 -1
- data/lib/wsv/request/parser.rb +52 -0
- data/lib/wsv/request/too_large.rb +18 -0
- data/lib/wsv/request.rb +4 -47
- data/lib/wsv/response/file_body.rb +31 -0
- data/lib/wsv/response/file_builder.rb +63 -0
- data/lib/wsv/response/string_body.rb +23 -0
- data/lib/wsv/response/text_builder.rb +35 -0
- data/lib/wsv/response.rb +43 -29
- data/lib/wsv/server/banner.rb +56 -0
- data/lib/wsv/server/browser_launcher.rb +63 -0
- data/lib/wsv/server/deadline_reader.rb +23 -0
- data/lib/wsv/server.rb +70 -38
- data/lib/wsv/status.rb +4 -0
- data/lib/wsv/tls_context/resolver.rb +71 -0
- data/lib/wsv/tls_context/self_signed_cert.rb +38 -0
- data/lib/wsv/tls_context.rb +29 -0
- data/lib/wsv/version.rb +1 -1
- data/lib/wsv.rb +2 -0
- data/test/app_test.rb +356 -2
- data/test/browser_launcher_test.rb +57 -0
- data/test/cli_test.rb +64 -0
- data/test/path_resolver_test.rb +35 -0
- data/test/response_test.rb +49 -0
- data/test/server_test.rb +84 -2
- data/test/tls_context_test.rb +169 -0
- metadata +17 -2
data/lib/wsv/request.rb
CHANGED
|
@@ -1,21 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "request/too_large"
|
|
4
|
+
require_relative "request/parser"
|
|
5
|
+
|
|
3
6
|
module Wsv
|
|
4
7
|
class Request
|
|
5
|
-
REQUEST_LINE_LIMIT = 8192
|
|
6
|
-
HEADER_LINE_LIMIT = 8192
|
|
7
|
-
HEADER_COUNT_LIMIT = 100
|
|
8
|
-
HEADER_TOTAL_LIMIT = 16384
|
|
9
|
-
|
|
10
|
-
class TooLarge < StandardError
|
|
11
|
-
attr_reader :status_code
|
|
12
|
-
|
|
13
|
-
def initialize(status_code)
|
|
14
|
-
super("request exceeded size limit (#{status_code})")
|
|
15
|
-
@status_code = status_code
|
|
16
|
-
end
|
|
17
|
-
end
|
|
18
|
-
|
|
19
8
|
attr_reader :method, :target, :version, :headers
|
|
20
9
|
|
|
21
10
|
def initialize(method:, target:, version:, headers:)
|
|
@@ -30,39 +19,7 @@ module Wsv
|
|
|
30
19
|
end
|
|
31
20
|
|
|
32
21
|
def self.parse(io)
|
|
33
|
-
|
|
34
|
-
return :empty unless line
|
|
35
|
-
raise TooLarge, 414 if line.bytesize >= REQUEST_LINE_LIMIT && !line.end_with?("\n")
|
|
36
|
-
|
|
37
|
-
method, target, version = line.split(/\s+/, 3)
|
|
38
|
-
version = version&.strip
|
|
39
|
-
return :malformed unless method && target && version&.start_with?("HTTP/")
|
|
40
|
-
|
|
41
|
-
headers = read_headers(io)
|
|
42
|
-
new(method: method, target: target, version: version, headers: headers)
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def self.read_headers(io)
|
|
46
|
-
headers = {}
|
|
47
|
-
total = 0
|
|
48
|
-
count = 0
|
|
49
|
-
while (line = io.gets(HEADER_LINE_LIMIT))
|
|
50
|
-
raise TooLarge, 431 if line.bytesize >= HEADER_LINE_LIMIT && !line.end_with?("\n")
|
|
51
|
-
|
|
52
|
-
stripped = line.delete_suffix("\r\n").delete_suffix("\n").delete_suffix("\r")
|
|
53
|
-
break if stripped.empty?
|
|
54
|
-
|
|
55
|
-
count += 1
|
|
56
|
-
raise TooLarge, 431 if count > HEADER_COUNT_LIMIT
|
|
57
|
-
|
|
58
|
-
total += line.bytesize
|
|
59
|
-
raise TooLarge, 431 if total > HEADER_TOTAL_LIMIT
|
|
60
|
-
|
|
61
|
-
name, value = stripped.split(":", 2)
|
|
62
|
-
headers[name.downcase] = value.strip if name && value
|
|
63
|
-
end
|
|
64
|
-
headers
|
|
22
|
+
Parser.new(io).parse
|
|
65
23
|
end
|
|
66
|
-
private_class_method :read_headers
|
|
67
24
|
end
|
|
68
25
|
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wsv
|
|
4
|
+
class Response
|
|
5
|
+
class FileBody
|
|
6
|
+
def initialize(path, offset: 0, length: nil)
|
|
7
|
+
@path = path
|
|
8
|
+
@offset = offset
|
|
9
|
+
@length = length || (File.size(path) - offset)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def to_s
|
|
13
|
+
File.open(@path, "rb") do |f|
|
|
14
|
+
f.seek(@offset)
|
|
15
|
+
f.read(@length)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def bytesize
|
|
20
|
+
@length
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def write_to(io)
|
|
24
|
+
File.open(@path, "rb") do |f|
|
|
25
|
+
f.seek(@offset)
|
|
26
|
+
IO.copy_stream(f, io, @length)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "../mime_types"
|
|
5
|
+
|
|
6
|
+
module Wsv
|
|
7
|
+
class Response
|
|
8
|
+
class FileBuilder
|
|
9
|
+
def initialize(path, head: false, range: nil, status: 200)
|
|
10
|
+
@path = path
|
|
11
|
+
@head = head
|
|
12
|
+
@range = range
|
|
13
|
+
@status = status
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def build
|
|
17
|
+
if @range
|
|
18
|
+
Response.new(status: 206, headers: range_headers, body: range_body)
|
|
19
|
+
else
|
|
20
|
+
Response.new(status: @status, headers: full_headers, body: full_body)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def size
|
|
27
|
+
@size ||= File.size(@path)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def base_headers
|
|
31
|
+
{
|
|
32
|
+
"Content-Type" => MimeTypes.for_file(@path),
|
|
33
|
+
"Last-Modified" => File.mtime(@path).httpdate,
|
|
34
|
+
"Cache-Control" => "no-cache",
|
|
35
|
+
"Accept-Ranges" => "bytes"
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def range_headers
|
|
40
|
+
base_headers.merge(
|
|
41
|
+
"Content-Length" => @range.size.to_s,
|
|
42
|
+
"Content-Range" => "bytes #{@range.begin}-#{@range.end}/#{size}"
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def full_headers
|
|
47
|
+
base_headers.merge("Content-Length" => size.to_s)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def range_body
|
|
51
|
+
return StringBody.new("") if @head
|
|
52
|
+
|
|
53
|
+
FileBody.new(@path, offset: @range.begin, length: @range.size)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def full_body
|
|
57
|
+
return StringBody.new("") if @head
|
|
58
|
+
|
|
59
|
+
FileBody.new(@path)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wsv
|
|
4
|
+
class Response
|
|
5
|
+
class StringBody
|
|
6
|
+
def initialize(string)
|
|
7
|
+
@string = string
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def to_s
|
|
11
|
+
@string
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def bytesize
|
|
15
|
+
@string.bytesize
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def write_to(io)
|
|
19
|
+
io.write(@string) unless @string.empty?
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wsv
|
|
4
|
+
class Response
|
|
5
|
+
class TextBuilder
|
|
6
|
+
def initialize(status, head: false, headers: {})
|
|
7
|
+
@status = status
|
|
8
|
+
@head = head
|
|
9
|
+
@extra_headers = headers
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def build
|
|
13
|
+
Response.new(status: @status, headers: response_headers, body: response_body)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def response_body
|
|
19
|
+
@head ? "" : message
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def message
|
|
23
|
+
@message ||= "#{@status} #{Status.reason(@status)}\n"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def response_headers
|
|
27
|
+
{
|
|
28
|
+
"Content-Type" => "text/plain; charset=utf-8",
|
|
29
|
+
"Content-Length" => message.bytesize.to_s,
|
|
30
|
+
"Cache-Control" => "no-cache"
|
|
31
|
+
}.merge(@extra_headers)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
data/lib/wsv/response.rb
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "time"
|
|
4
|
-
require_relative "mime_types"
|
|
5
3
|
require_relative "status"
|
|
6
4
|
require_relative "version"
|
|
5
|
+
require_relative "response/string_body"
|
|
6
|
+
require_relative "response/file_body"
|
|
7
|
+
require_relative "response/text_builder"
|
|
8
|
+
require_relative "response/file_builder"
|
|
7
9
|
|
|
8
10
|
module Wsv
|
|
9
11
|
class Response
|
|
@@ -12,56 +14,68 @@ module Wsv
|
|
|
12
14
|
INVALID_HEADER_NAME = /[\s:]/
|
|
13
15
|
INVALID_HEADER_VALUE = /[\r\n]/
|
|
14
16
|
|
|
15
|
-
attr_reader :status, :headers
|
|
17
|
+
attr_reader :status, :headers
|
|
16
18
|
|
|
17
19
|
def initialize(status:, headers: {}, body: "")
|
|
18
|
-
headers
|
|
19
|
-
raise ArgumentError, "invalid header name: #{name.inspect}" if name.to_s.match?(INVALID_HEADER_NAME)
|
|
20
|
-
raise ArgumentError, "invalid header value: #{value.inspect}" if value.to_s.match?(INVALID_HEADER_VALUE)
|
|
21
|
-
end
|
|
20
|
+
validate_headers(headers)
|
|
22
21
|
@status = status
|
|
23
22
|
@headers = headers
|
|
24
|
-
@body = body
|
|
23
|
+
@body = body.is_a?(String) ? StringBody.new(body) : body
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def body
|
|
27
|
+
@body.to_s
|
|
25
28
|
end
|
|
26
29
|
|
|
27
30
|
def reason
|
|
28
31
|
Status.reason(status)
|
|
29
32
|
end
|
|
30
33
|
|
|
34
|
+
# Returns a new Response with `extra` merged into the headers, sharing the
|
|
35
|
+
# same body object so streaming (FileBody) is preserved.
|
|
36
|
+
def with_headers(extra)
|
|
37
|
+
self.class.new(status: @status, headers: @headers.merge(extra), body: @body)
|
|
38
|
+
end
|
|
39
|
+
|
|
31
40
|
def write_to(io)
|
|
32
41
|
io.write "HTTP/1.1 #{status} #{reason}\r\n"
|
|
33
42
|
io.write "Server: #{SERVER_NAME}\r\n"
|
|
34
43
|
io.write "Connection: close\r\n"
|
|
44
|
+
unless headers.any? { |name, _value| name.to_s.casecmp?("X-Content-Type-Options") }
|
|
45
|
+
io.write "X-Content-Type-Options: nosniff\r\n"
|
|
46
|
+
end
|
|
35
47
|
headers.each { |name, value| io.write "#{name}: #{value}\r\n" }
|
|
36
48
|
io.write "\r\n"
|
|
37
|
-
io
|
|
49
|
+
@body.write_to(io)
|
|
38
50
|
end
|
|
39
51
|
|
|
40
|
-
def self.text(status,
|
|
41
|
-
|
|
42
|
-
base = {
|
|
43
|
-
"Content-Type" => "text/plain; charset=utf-8",
|
|
44
|
-
"Content-Length" => body.bytesize.to_s,
|
|
45
|
-
"Cache-Control" => "no-cache"
|
|
46
|
-
}
|
|
47
|
-
new(status: status, headers: base.merge(headers), body: head ? "" : body)
|
|
52
|
+
def self.text(status, **)
|
|
53
|
+
TextBuilder.new(status, **).build
|
|
48
54
|
end
|
|
49
55
|
|
|
50
|
-
def self.file(path,
|
|
51
|
-
new(
|
|
52
|
-
status: 200,
|
|
53
|
-
headers: {
|
|
54
|
-
"Content-Type" => MimeTypes.for_file(path),
|
|
55
|
-
"Content-Length" => File.size(path).to_s,
|
|
56
|
-
"Last-Modified" => File.mtime(path).httpdate,
|
|
57
|
-
"Cache-Control" => "no-cache"
|
|
58
|
-
},
|
|
59
|
-
body: head ? "" : File.binread(path)
|
|
60
|
-
)
|
|
56
|
+
def self.file(path, **)
|
|
57
|
+
FileBuilder.new(path, **).build
|
|
61
58
|
end
|
|
62
59
|
|
|
63
60
|
def self.redirect(location, head: false)
|
|
64
|
-
|
|
61
|
+
TextBuilder.new(301, head: head, headers: { "Location" => location }).build
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.not_modified
|
|
65
|
+
new(status: 304, headers: { "Cache-Control" => "no-cache" }, body: "")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.range_not_satisfiable(file_size, head: false)
|
|
69
|
+
TextBuilder.new(416, head: head, headers: { "Content-Range" => "bytes */#{file_size}" }).build
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def validate_headers(headers)
|
|
75
|
+
headers.each do |name, value|
|
|
76
|
+
raise ArgumentError, "invalid header name: #{name.inspect}" if name.to_s.match?(INVALID_HEADER_NAME)
|
|
77
|
+
raise ArgumentError, "invalid header value: #{value.inspect}" if value.to_s.match?(INVALID_HEADER_VALUE)
|
|
78
|
+
end
|
|
65
79
|
end
|
|
66
80
|
end
|
|
67
81
|
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wsv
|
|
4
|
+
class Server
|
|
5
|
+
# Renders the startup announcement (the "Serving / Bind / Local / Stop"
|
|
6
|
+
# block plus warnings about non-loopback binds and self-signed certs).
|
|
7
|
+
class Banner
|
|
8
|
+
def initialize(host:, port:, root:, out:, err:, tls:)
|
|
9
|
+
@host = host
|
|
10
|
+
@port = port
|
|
11
|
+
@root = root
|
|
12
|
+
@out = out
|
|
13
|
+
@err = err
|
|
14
|
+
@tls = tls
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def emit
|
|
18
|
+
@out.puts "Serving: #{@root}"
|
|
19
|
+
@out.puts "Bind: #{url_for(@host)}"
|
|
20
|
+
@out.puts "Local: #{url_for('127.0.0.1')}" unless localhost?(@host)
|
|
21
|
+
@out.puts "Stop: Ctrl-C"
|
|
22
|
+
warn_public_bind unless localhost?(@host)
|
|
23
|
+
warn_ephemeral_cert if @tls&.ephemeral?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def warn_public_bind
|
|
29
|
+
@err.puts "WARNING: binding to #{@host} exposes #{@root} on your network."
|
|
30
|
+
@err.puts " Pass --host 127.0.0.1 (or omit --host) for local-only access."
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def warn_ephemeral_cert
|
|
34
|
+
@err.puts "WARNING: serving with a self-signed certificate. Browsers will"
|
|
35
|
+
@err.puts " show a security warning. Pass --cert / --key for a real cert."
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def url_for(display_host)
|
|
39
|
+
"#{scheme}://#{format_host(display_host)}:#{@port}/"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def format_host(host)
|
|
43
|
+
# Bracket IPv6 literals per RFC 3986; zone IDs (`%eth0` etc.) need %25 per RFC 6874.
|
|
44
|
+
host.include?(":") ? "[#{host.gsub('%', '%25')}]" : host
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def scheme
|
|
48
|
+
@tls ? "https" : "http"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def localhost?(display_host)
|
|
52
|
+
["127.0.0.1", "localhost", "::1"].include?(display_host)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
module Wsv
|
|
6
|
+
class Server
|
|
7
|
+
# Launches the OS default browser at the served URL when `--open` is set.
|
|
8
|
+
# Best-effort: unsupported platforms or spawn failures are logged but
|
|
9
|
+
# never abort the server.
|
|
10
|
+
class BrowserLauncher
|
|
11
|
+
def initialize(host:, port:, tls:, err:)
|
|
12
|
+
@host = host
|
|
13
|
+
@port = port
|
|
14
|
+
@tls = tls
|
|
15
|
+
@err = err
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def launch
|
|
19
|
+
command = platform_command
|
|
20
|
+
unless command
|
|
21
|
+
@err.puts "wsv: --open is not supported on this platform; skipping."
|
|
22
|
+
return
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
pid = Process.spawn(*command, url, in: :close, out: File::NULL, err: File::NULL)
|
|
26
|
+
Process.detach(pid)
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
@err.puts "wsv: failed to open browser: #{e.message}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def url
|
|
34
|
+
scheme = @tls ? "https" : "http"
|
|
35
|
+
"#{scheme}://#{url_host}:#{@port}/"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def url_host
|
|
39
|
+
host = display_host
|
|
40
|
+
# IPv6 literals must be bracketed in URLs per RFC 3986. Scoped IPv6
|
|
41
|
+
# zone identifiers use `%`, which must be percent-encoded in URLs.
|
|
42
|
+
host.include?(":") ? "[#{host.gsub('%', '%25')}]" : host
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def display_host
|
|
46
|
+
# Wildcard binds aren't reachable; redirect to the matching loopback.
|
|
47
|
+
case @host
|
|
48
|
+
when "0.0.0.0" then "127.0.0.1"
|
|
49
|
+
when "::" then "::1"
|
|
50
|
+
else @host
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def platform_command
|
|
55
|
+
case RbConfig::CONFIG["host_os"]
|
|
56
|
+
when /darwin/ then ["open"]
|
|
57
|
+
when /linux|bsd/ then ["xdg-open"]
|
|
58
|
+
when /mswin|mingw|cygwin/ then ["cmd.exe", "/c", "start", ""]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wsv
|
|
4
|
+
class Server
|
|
5
|
+
# Wraps an IO with a shared deadline so each subsequent read is bounded by
|
|
6
|
+
# the time remaining until the deadline. Used to enforce a single budget
|
|
7
|
+
# across the request line and all header lines.
|
|
8
|
+
class DeadlineReader
|
|
9
|
+
def initialize(io, deadline)
|
|
10
|
+
@io = io
|
|
11
|
+
@deadline = deadline
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def gets(eol, limit)
|
|
15
|
+
remaining = @deadline - Time.now
|
|
16
|
+
raise IO::TimeoutError if remaining <= 0
|
|
17
|
+
|
|
18
|
+
@io.to_io.timeout = remaining
|
|
19
|
+
@io.gets(eol, limit)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/wsv/server.rb
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "openssl"
|
|
3
4
|
require "socket"
|
|
4
5
|
require_relative "app"
|
|
5
6
|
require_relative "request"
|
|
6
7
|
require_relative "response"
|
|
8
|
+
require_relative "server/banner"
|
|
9
|
+
require_relative "server/browser_launcher"
|
|
10
|
+
require_relative "server/deadline_reader"
|
|
7
11
|
|
|
8
12
|
module Wsv
|
|
9
13
|
class Server
|
|
@@ -20,7 +24,11 @@ module Wsv
|
|
|
20
24
|
out: $stdout,
|
|
21
25
|
err: $stderr,
|
|
22
26
|
read_timeout: DEFAULT_READ_TIMEOUT,
|
|
23
|
-
max_connections: DEFAULT_MAX_CONNECTIONS
|
|
27
|
+
max_connections: DEFAULT_MAX_CONNECTIONS,
|
|
28
|
+
tls: nil,
|
|
29
|
+
spa: false,
|
|
30
|
+
open: false,
|
|
31
|
+
cors: false
|
|
24
32
|
)
|
|
25
33
|
@host = host
|
|
26
34
|
@port = port
|
|
@@ -29,7 +37,10 @@ module Wsv
|
|
|
29
37
|
@err = err
|
|
30
38
|
@read_timeout = read_timeout
|
|
31
39
|
@max_connections = max_connections
|
|
32
|
-
@
|
|
40
|
+
@tls = tls
|
|
41
|
+
@ssl_context = tls&.to_ssl_context
|
|
42
|
+
@open = open
|
|
43
|
+
@app = App.new(@root, spa: spa, cors: cors)
|
|
33
44
|
@running = false
|
|
34
45
|
@mutex = Mutex.new
|
|
35
46
|
@active = 0
|
|
@@ -40,6 +51,7 @@ module Wsv
|
|
|
40
51
|
@running = true
|
|
41
52
|
log_startup
|
|
42
53
|
trap_signals
|
|
54
|
+
open_in_browser if @open
|
|
43
55
|
accept_loop
|
|
44
56
|
ensure
|
|
45
57
|
close
|
|
@@ -66,6 +78,8 @@ module Wsv
|
|
|
66
78
|
rescue IO::TimeoutError
|
|
67
79
|
write_response(client, Response.text(408))
|
|
68
80
|
rescue StandardError => e
|
|
81
|
+
# Treat unmapped failures as connection-scoped and close with 400 rather
|
|
82
|
+
# than letting one bad request path bring down the server.
|
|
69
83
|
@err.puts "wsv: #{e.class}: #{e.message}"
|
|
70
84
|
write_response(client, Response.text(400))
|
|
71
85
|
ensure
|
|
@@ -104,6 +118,9 @@ module Wsv
|
|
|
104
118
|
chunk = client.read_nonblock(8192, exception: false)
|
|
105
119
|
case chunk
|
|
106
120
|
when nil, :wait_writable
|
|
121
|
+
# nil = EOF. :wait_writable can come back from SSLSocket during a
|
|
122
|
+
# renegotiation (read needs an underlying write). Either way,
|
|
123
|
+
# there's nothing more we can usefully drain right now.
|
|
107
124
|
return
|
|
108
125
|
when :wait_readable
|
|
109
126
|
remaining = deadline - Time.now
|
|
@@ -113,21 +130,6 @@ module Wsv
|
|
|
113
130
|
end
|
|
114
131
|
end
|
|
115
132
|
|
|
116
|
-
class DeadlineReader
|
|
117
|
-
def initialize(io, deadline)
|
|
118
|
-
@io = io
|
|
119
|
-
@deadline = deadline
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def gets(limit)
|
|
123
|
-
remaining = @deadline - Time.now
|
|
124
|
-
raise IO::TimeoutError if remaining <= 0
|
|
125
|
-
|
|
126
|
-
@io.timeout = remaining
|
|
127
|
-
@io.gets(limit)
|
|
128
|
-
end
|
|
129
|
-
end
|
|
130
|
-
|
|
131
133
|
def accept_loop
|
|
132
134
|
while @running
|
|
133
135
|
client = nil
|
|
@@ -162,18 +164,58 @@ module Wsv
|
|
|
162
164
|
true
|
|
163
165
|
end
|
|
164
166
|
|
|
165
|
-
return
|
|
167
|
+
return spawn_rejection(client) unless accepted
|
|
166
168
|
|
|
169
|
+
begin
|
|
170
|
+
Thread.new do
|
|
171
|
+
Thread.current.report_on_exception = false
|
|
172
|
+
handle(maybe_wrap_tls(client))
|
|
173
|
+
ensure
|
|
174
|
+
@mutex.synchronize { @active -= 1 }
|
|
175
|
+
end
|
|
176
|
+
rescue ThreadError => e
|
|
177
|
+
@err.puts "wsv: thread error: #{e.message}"
|
|
178
|
+
@mutex.synchronize { @active -= 1 }
|
|
179
|
+
spawn_rejection(client)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Reject in a separate thread so a slow client cannot block accept_loop
|
|
184
|
+
# via graceful_close's drain_recv (up to DRAIN_TIMEOUT seconds).
|
|
185
|
+
def spawn_rejection(client)
|
|
167
186
|
Thread.new do
|
|
168
187
|
Thread.current.report_on_exception = false
|
|
169
|
-
|
|
170
|
-
ensure
|
|
171
|
-
@mutex.synchronize { @active -= 1 }
|
|
188
|
+
reject(client)
|
|
172
189
|
end
|
|
190
|
+
rescue ThreadError
|
|
191
|
+
reject(client)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def maybe_wrap_tls(client)
|
|
195
|
+
return client unless @ssl_context
|
|
196
|
+
|
|
197
|
+
client.timeout = @read_timeout
|
|
198
|
+
ssl = OpenSSL::SSL::SSLSocket.new(client, @ssl_context)
|
|
199
|
+
ssl.sync_close = true
|
|
200
|
+
ssl.accept
|
|
201
|
+
ssl
|
|
202
|
+
rescue StandardError
|
|
203
|
+
# If wrapping or the handshake failed, `handle` is never called and
|
|
204
|
+
# its ensure does not get a chance to close the underlying socket.
|
|
205
|
+
# Close it here so we do not leak a TCPSocket per failed handshake.
|
|
206
|
+
begin
|
|
207
|
+
client.close
|
|
208
|
+
rescue StandardError
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
raise
|
|
173
212
|
end
|
|
174
213
|
|
|
175
214
|
def reject(client)
|
|
176
|
-
|
|
215
|
+
# In TLS mode `client` is the raw TCPSocket before any handshake.
|
|
216
|
+
# Writing a plaintext 503 over it would corrupt the TLS handshake
|
|
217
|
+
# the client is about to start, so just close in that case.
|
|
218
|
+
write_response(client, Response.text(503)) unless @ssl_context
|
|
177
219
|
ensure
|
|
178
220
|
graceful_close(client)
|
|
179
221
|
end
|
|
@@ -190,28 +232,18 @@ module Wsv
|
|
|
190
232
|
end
|
|
191
233
|
end
|
|
192
234
|
rescue ArgumentError
|
|
235
|
+
# Signal.trap raises ArgumentError when called from a context that
|
|
236
|
+
# cannot install signal handlers (e.g. embedded in a non-main thread,
|
|
237
|
+
# which is how tests start the server). Skip silently in that case.
|
|
193
238
|
nil
|
|
194
239
|
end
|
|
195
240
|
|
|
196
241
|
def log_startup
|
|
197
|
-
@out
|
|
198
|
-
@out.puts "Bind: #{url_for(host)}"
|
|
199
|
-
@out.puts "Local: #{url_for('127.0.0.1')}" unless localhost?(host)
|
|
200
|
-
@out.puts "Stop: Ctrl-C"
|
|
201
|
-
warn_public_bind unless localhost?(host)
|
|
202
|
-
end
|
|
203
|
-
|
|
204
|
-
def warn_public_bind
|
|
205
|
-
@err.puts "WARNING: binding to #{host} exposes #{root} on your network."
|
|
206
|
-
@err.puts " Pass --host 127.0.0.1 (or omit --host) for local-only access."
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
def url_for(display_host)
|
|
210
|
-
"http://#{display_host}:#{port}/"
|
|
242
|
+
Banner.new(host: host, port: port, root: root, out: @out, err: @err, tls: @tls).emit
|
|
211
243
|
end
|
|
212
244
|
|
|
213
|
-
def
|
|
214
|
-
|
|
245
|
+
def open_in_browser
|
|
246
|
+
BrowserLauncher.new(host: host, port: port, tls: @tls, err: @err).launch
|
|
215
247
|
end
|
|
216
248
|
end
|
|
217
249
|
end
|
data/lib/wsv/status.rb
CHANGED
|
@@ -4,13 +4,17 @@ module Wsv
|
|
|
4
4
|
module Status
|
|
5
5
|
REASONS = {
|
|
6
6
|
200 => "OK",
|
|
7
|
+
204 => "No Content",
|
|
8
|
+
206 => "Partial Content",
|
|
7
9
|
301 => "Moved Permanently",
|
|
10
|
+
304 => "Not Modified",
|
|
8
11
|
400 => "Bad Request",
|
|
9
12
|
403 => "Forbidden",
|
|
10
13
|
404 => "Not Found",
|
|
11
14
|
405 => "Method Not Allowed",
|
|
12
15
|
408 => "Request Timeout",
|
|
13
16
|
414 => "URI Too Long",
|
|
17
|
+
416 => "Range Not Satisfiable",
|
|
14
18
|
431 => "Request Header Fields Too Large",
|
|
15
19
|
503 => "Service Unavailable"
|
|
16
20
|
}.freeze
|