wsv 0.10.0 → 0.11.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 +40 -0
- data/README.md +19 -13
- data/lib/wsv/app.rb +12 -56
- data/lib/wsv/cors.rb +7 -5
- data/lib/wsv/range_request.rb +91 -0
- data/lib/wsv/response/file_builder.rb +4 -9
- data/lib/wsv/server/banner.rb +3 -6
- data/lib/wsv/server/browser_launcher.rb +2 -8
- data/lib/wsv/server/connection.rb +104 -0
- data/lib/wsv/server/connection_throttle.rb +50 -0
- data/lib/wsv/server/url_host.rb +16 -0
- data/lib/wsv/server.rb +17 -107
- data/lib/wsv/version.rb +1 -1
- data/lib/wsv.rb +1 -0
- data/test/app_test.rb +12 -29
- data/test/banner_test.rb +59 -0
- data/test/cors_test.rb +76 -0
- data/test/range_request_test.rb +103 -0
- data/test/server_test.rb +67 -71
- data/test/test_helper.rb +8 -0
- data/wsv.gemspec +7 -6
- metadata +16 -8
- /data/{bin → exe}/wsv +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: adfd569857554409cef36ce05205eb50f833a58a27e654e1da80646afa2ec40e
|
|
4
|
+
data.tar.gz: 8d5c75289dd59dc65412ebff24bf0cf4d9aa9933befb17354f9b2fb3320b5ca3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b4514041977a6c9a599ea9f2a3735f7b4668ab18172b974453293c444c4441221e068f9374cbdd6c5058b99ecb776edbe47bda3bff4fd77731dbeb9b45cd9446
|
|
7
|
+
data.tar.gz: f1d06286b164b0f70dce52d7fd86b9fdf29c64f6cbc0515e3d26558afb4498d23277fc23b66709298afffb593e321a21498cc55dea2ab8b58e31186b48aaa021
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.11.0
|
|
4
|
+
|
|
5
|
+
- Extract `Wsv::RangeRequest` from `App`. RFC 7233 range parsing
|
|
6
|
+
(suffix / open-ended / bounded ranges, unsatisfiability) now lives
|
|
7
|
+
in its own class with a `Result` value object (`#full?` /
|
|
8
|
+
`#partial?` / `#unsatisfiable?` predicates plus `#bounds`),
|
|
9
|
+
matching the `PathResolver::Result` pattern. Behavior unchanged.
|
|
10
|
+
- Make `Server::Connection` the sole place that adds CORS overlay
|
|
11
|
+
headers (`Access-Control-Allow-Origin`, `Vary`) on outgoing
|
|
12
|
+
responses. Previously `App` applied the overlay on its return
|
|
13
|
+
value, but rescue-path responses (408 / 414 / 431 / 503 / unmapped
|
|
14
|
+
400) bypassed `App` and therefore lacked CORS headers. Now every
|
|
15
|
+
response — `App`, parser errors, timeouts, and the 503 rejection —
|
|
16
|
+
gets the overlay uniformly. `Cors#preflight` no longer includes
|
|
17
|
+
ACAO/Vary itself, since Connection adds them.
|
|
18
|
+
- `Wsv::App.new(... cors:)` now expects a `Wsv::Cors` instance (or
|
|
19
|
+
`nil`) instead of a Boolean. `Wsv::Server.new(... cors: true/false)`
|
|
20
|
+
is unchanged. Per the README's stability statement, the Ruby API
|
|
21
|
+
under `lib/wsv/` is implementation-only and may change in any
|
|
22
|
+
release.
|
|
23
|
+
|
|
24
|
+
## 0.10.1
|
|
25
|
+
|
|
26
|
+
- Update gem description and README tagline to position wsv as
|
|
27
|
+
"Defensive by design" — surfaces the actual security defaults on
|
|
28
|
+
RubyGems.org and `gem search` (the 0.10.0 description still said
|
|
29
|
+
"tiny static web server").
|
|
30
|
+
- Refactor `Wsv::Server` (~250 → 150 lines): per-connection lifecycle
|
|
31
|
+
is now `Server::Connection` (`#serve` / `#reject` + safe write /
|
|
32
|
+
drain / close), concurrency cap is `Server::ConnectionThrottle`
|
|
33
|
+
(`#try_spawn`). Behavior unchanged.
|
|
34
|
+
- Extract `Server::UrlHost.format` so `Banner` and `BrowserLauncher`
|
|
35
|
+
share the IPv6 bracketing / RFC 6874 zone-id encoding rule (was
|
|
36
|
+
duplicated in two places, fixed in tandem once already).
|
|
37
|
+
- Simplify `Response::FileBuilder` body construction: the HEAD guard
|
|
38
|
+
and range/full body branch collapse into a single method.
|
|
39
|
+
- Move executable from `bin/wsv` to `exe/wsv` per Bundler convention.
|
|
40
|
+
- Various README polish: Gemfile install path first, Examples for
|
|
41
|
+
Jekyll / Astro / Vite, GHFM `> [!WARNING]` for the prod-use caveat.
|
|
42
|
+
|
|
3
43
|
## 0.10.0
|
|
4
44
|
|
|
5
45
|
- Add `--cors` flag. When set, every response carries
|
data/README.md
CHANGED
|
@@ -1,22 +1,27 @@
|
|
|
1
1
|
# wsv
|
|
2
2
|
|
|
3
|
-
`wsv` is a zero-dependency static preview server for Ruby projects.
|
|
3
|
+
`wsv` is a zero-dependency static preview server for Ruby projects. Defensive by design: blocks dotfiles and binds to loopback by default.
|
|
4
4
|
|
|
5
|
-
It has no runtime dependencies outside Ruby's standard library. Run `wsv` in a directory and it serves that directory over HTTP.
|
|
5
|
+
It has no runtime dependencies outside Ruby's standard library. Run `wsv` in a directory and it serves that directory over HTTP/HTTPS.
|
|
6
6
|
|
|
7
7
|
Requires Ruby 3.2 or later.
|
|
8
8
|
|
|
9
9
|
## Installation
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
Add to your Gemfile:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
group :development do
|
|
15
|
+
gem "wsv"
|
|
16
|
+
end
|
|
13
17
|
```
|
|
14
18
|
|
|
15
|
-
|
|
19
|
+
Then run `bundle install` and start with `bundle exec wsv`.
|
|
20
|
+
|
|
21
|
+
Or install globally:
|
|
16
22
|
|
|
17
23
|
```sh
|
|
18
|
-
gem
|
|
19
|
-
gem install ./wsv-*.gem
|
|
24
|
+
gem install wsv
|
|
20
25
|
```
|
|
21
26
|
|
|
22
27
|
## Usage
|
|
@@ -55,10 +60,10 @@ Options:
|
|
|
55
60
|
|
|
56
61
|
`--tls` enables HTTPS on the chosen `--port`. Three modes:
|
|
57
62
|
|
|
58
|
-
1.
|
|
63
|
+
1. Ephemeral self-signed: `wsv --tls` with no cert configured: wsv
|
|
59
64
|
generates an in-memory self-signed certificate. Browsers will show a
|
|
60
65
|
security warning; click through "Advanced → Proceed" once per session.
|
|
61
|
-
2.
|
|
66
|
+
2. `~/.config/wsv/` auto-detection (recommended): if both
|
|
62
67
|
`~/.config/wsv/cert.pem` and `~/.config/wsv/key.pem` exist (resolved via
|
|
63
68
|
`$XDG_CONFIG_HOME` if set), `--tls` uses them. If only one of the two
|
|
64
69
|
files is present, wsv refuses to start so the misconfiguration does not
|
|
@@ -75,7 +80,7 @@ Options:
|
|
|
75
80
|
wsv --tls # → https://localhost:8000/ with no warning
|
|
76
81
|
```
|
|
77
82
|
|
|
78
|
-
3.
|
|
83
|
+
3. Explicit cert/key files: `wsv --cert path/to/cert.pem --key path/to/key.pem`
|
|
79
84
|
for project-specific certificates. Both flags must be provided together.
|
|
80
85
|
|
|
81
86
|
## Behavior
|
|
@@ -103,7 +108,9 @@ Options:
|
|
|
103
108
|
|
|
104
109
|
## Security model
|
|
105
110
|
|
|
106
|
-
|
|
111
|
+
> [!WARNING]
|
|
112
|
+
> `wsv` is intended for local development previews, not for production or internet-facing use.
|
|
113
|
+
|
|
107
114
|
Within that scope it tries to behave defensively:
|
|
108
115
|
|
|
109
116
|
### What `wsv` protects against
|
|
@@ -162,8 +169,7 @@ If you need any of the above, use a real production server.
|
|
|
162
169
|
`wsv` follows [Semantic Versioning](https://semver.org/). The public API
|
|
163
170
|
that SemVer covers is the CLI:
|
|
164
171
|
|
|
165
|
-
- The flags listed
|
|
166
|
-
`--version`) and their meanings.
|
|
172
|
+
- The flags listed in Options above and their meanings.
|
|
167
173
|
- The directory argument and the default behaviour when it is omitted.
|
|
168
174
|
- Process exit codes (`0` for success, `1` for usage / setup errors).
|
|
169
175
|
|
data/lib/wsv/app.rb
CHANGED
|
@@ -4,24 +4,23 @@ require "time"
|
|
|
4
4
|
require "uri"
|
|
5
5
|
require_relative "cors"
|
|
6
6
|
require_relative "path_resolver"
|
|
7
|
+
require_relative "range_request"
|
|
7
8
|
require_relative "response"
|
|
8
9
|
|
|
9
10
|
module Wsv
|
|
10
11
|
class App
|
|
11
12
|
ALLOWED_METHODS = %w[GET HEAD].freeze
|
|
12
|
-
RANGE_PATTERN = /\Abytes=(\d+)?-(\d+)?\z/
|
|
13
13
|
|
|
14
|
-
def initialize(root, spa: false, cors:
|
|
14
|
+
def initialize(root, spa: false, cors: nil)
|
|
15
15
|
@resolver = PathResolver.new(root)
|
|
16
16
|
@spa = spa
|
|
17
|
-
@cors =
|
|
17
|
+
@cors = cors
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def call(request)
|
|
21
21
|
return @cors.preflight(request) if @cors && request.method == "OPTIONS"
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
@cors ? @cors.overlay(response) : response
|
|
23
|
+
build_response(request)
|
|
25
24
|
end
|
|
26
25
|
|
|
27
26
|
private
|
|
@@ -30,7 +29,7 @@ module Wsv
|
|
|
30
29
|
head = request.head?
|
|
31
30
|
|
|
32
31
|
unless ALLOWED_METHODS.include?(request.method)
|
|
33
|
-
return Response.text(405, headers: { "Allow" =>
|
|
32
|
+
return Response.text(405, headers: { "Allow" => allow_header }, head: head)
|
|
34
33
|
end
|
|
35
34
|
|
|
36
35
|
raw_path, query = request.target.split("?", 2)
|
|
@@ -51,8 +50,8 @@ module Wsv
|
|
|
51
50
|
file_response(result.file, request, head: head)
|
|
52
51
|
end
|
|
53
52
|
|
|
54
|
-
def
|
|
55
|
-
@cors
|
|
53
|
+
def allow_header
|
|
54
|
+
(@cors&.allow_methods || ALLOWED_METHODS).join(", ")
|
|
56
55
|
end
|
|
57
56
|
|
|
58
57
|
def error_response(status, head:)
|
|
@@ -69,15 +68,11 @@ module Wsv
|
|
|
69
68
|
return Response.not_modified if not_modified?(file, request.headers["if-modified-since"])
|
|
70
69
|
|
|
71
70
|
size = File.size(file)
|
|
72
|
-
range =
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
Response.file(file, head: head)
|
|
78
|
-
else
|
|
79
|
-
Response.file(file, head: head, range: range)
|
|
80
|
-
end
|
|
71
|
+
range = RangeRequest.parse(request.headers["range"], size)
|
|
72
|
+
return Response.range_not_satisfiable(size, head: head) if range.unsatisfiable?
|
|
73
|
+
return Response.file(file, head: head) if range.full?
|
|
74
|
+
|
|
75
|
+
Response.file(file, head: head, range: range.bounds)
|
|
81
76
|
end
|
|
82
77
|
|
|
83
78
|
def not_modified?(file, header_value)
|
|
@@ -89,45 +84,6 @@ module Wsv
|
|
|
89
84
|
false
|
|
90
85
|
end
|
|
91
86
|
|
|
92
|
-
def parse_range(header_value, file_size)
|
|
93
|
-
return nil if header_value.nil? || header_value.empty?
|
|
94
|
-
|
|
95
|
-
match = header_value.match(RANGE_PATTERN)
|
|
96
|
-
# Per RFC 7233, an unparseable Range is treated as if absent: fall
|
|
97
|
-
# through as nil so the caller serves a normal 200 instead of 416.
|
|
98
|
-
return nil unless match
|
|
99
|
-
|
|
100
|
-
first, last = match.captures
|
|
101
|
-
if first.nil? && last.nil?
|
|
102
|
-
nil
|
|
103
|
-
elsif first.nil?
|
|
104
|
-
suffix_range(last.to_i, file_size)
|
|
105
|
-
elsif last.nil?
|
|
106
|
-
open_range(first.to_i, file_size)
|
|
107
|
-
else
|
|
108
|
-
bounded_range(first.to_i, last.to_i, file_size)
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def suffix_range(suffix, file_size)
|
|
113
|
-
return :unsatisfiable if suffix.zero? || file_size.zero?
|
|
114
|
-
|
|
115
|
-
[file_size - suffix, 0].max..(file_size - 1)
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def open_range(first, file_size)
|
|
119
|
-
return :unsatisfiable if first >= file_size
|
|
120
|
-
|
|
121
|
-
first..(file_size - 1)
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def bounded_range(first, last, file_size)
|
|
125
|
-
return :unsatisfiable if first > last || first >= file_size
|
|
126
|
-
|
|
127
|
-
last = file_size - 1 if last >= file_size
|
|
128
|
-
first..last
|
|
129
|
-
end
|
|
130
|
-
|
|
131
87
|
def redirect_location(raw_path, query)
|
|
132
88
|
path = URI(raw_path.to_s).path
|
|
133
89
|
path = "/" if path.empty?
|
data/lib/wsv/cors.rb
CHANGED
|
@@ -5,15 +5,17 @@ require_relative "response"
|
|
|
5
5
|
module Wsv
|
|
6
6
|
class Cors
|
|
7
7
|
ALLOW_ORIGIN = "*"
|
|
8
|
-
ALLOW_METHODS =
|
|
8
|
+
ALLOW_METHODS = %w[GET HEAD OPTIONS].freeze
|
|
9
9
|
MAX_AGE = "86400"
|
|
10
10
|
|
|
11
|
+
def allow_methods
|
|
12
|
+
ALLOW_METHODS
|
|
13
|
+
end
|
|
14
|
+
|
|
11
15
|
def preflight(request)
|
|
12
16
|
headers = {
|
|
13
|
-
"Access-Control-Allow-
|
|
14
|
-
"Access-Control-
|
|
15
|
-
"Access-Control-Max-Age" => MAX_AGE,
|
|
16
|
-
"Vary" => "Origin"
|
|
17
|
+
"Access-Control-Allow-Methods" => ALLOW_METHODS.join(", "),
|
|
18
|
+
"Access-Control-Max-Age" => MAX_AGE
|
|
17
19
|
}
|
|
18
20
|
requested = request.headers["access-control-request-headers"]
|
|
19
21
|
headers["Access-Control-Allow-Headers"] = requested if requested
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wsv
|
|
4
|
+
# Parses an HTTP `Range` header (RFC 7233) against a known file size.
|
|
5
|
+
class RangeRequest
|
|
6
|
+
PATTERN = /\Abytes=(\d+)?-(\d+)?\z/
|
|
7
|
+
|
|
8
|
+
class Result
|
|
9
|
+
attr_reader :bounds
|
|
10
|
+
|
|
11
|
+
def initialize(kind:, bounds: nil)
|
|
12
|
+
@kind = kind
|
|
13
|
+
@bounds = bounds
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def full?
|
|
17
|
+
@kind == :full
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def partial?
|
|
21
|
+
@kind == :partial
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def unsatisfiable?
|
|
25
|
+
@kind == :unsatisfiable
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.full
|
|
29
|
+
new(kind: :full)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.partial(bounds)
|
|
33
|
+
new(kind: :partial, bounds: bounds)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.unsatisfiable
|
|
37
|
+
new(kind: :unsatisfiable)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.parse(header_value, file_size)
|
|
42
|
+
new(header_value, file_size).parse
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def initialize(header_value, file_size)
|
|
46
|
+
@header_value = header_value
|
|
47
|
+
@file_size = file_size
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def parse
|
|
51
|
+
return Result.full if @header_value.nil? || @header_value.empty?
|
|
52
|
+
|
|
53
|
+
match = @header_value.match(PATTERN)
|
|
54
|
+
# Per RFC 7233, an unparseable Range is treated as if absent: return
|
|
55
|
+
# full so the caller serves a normal 200 instead of 416.
|
|
56
|
+
return Result.full unless match
|
|
57
|
+
|
|
58
|
+
first, last = match.captures
|
|
59
|
+
if first.nil? && last.nil?
|
|
60
|
+
Result.full
|
|
61
|
+
elsif first.nil?
|
|
62
|
+
suffix_range(last.to_i)
|
|
63
|
+
elsif last.nil?
|
|
64
|
+
open_range(first.to_i)
|
|
65
|
+
else
|
|
66
|
+
bounded_range(first.to_i, last.to_i)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def suffix_range(suffix)
|
|
73
|
+
return Result.unsatisfiable if suffix.zero? || @file_size.zero?
|
|
74
|
+
|
|
75
|
+
Result.partial([@file_size - suffix, 0].max..(@file_size - 1))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def open_range(first)
|
|
79
|
+
return Result.unsatisfiable if first >= @file_size
|
|
80
|
+
|
|
81
|
+
Result.partial(first..(@file_size - 1))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def bounded_range(first, last)
|
|
85
|
+
return Result.unsatisfiable if first > last || first >= @file_size
|
|
86
|
+
|
|
87
|
+
last = @file_size - 1 if last >= @file_size
|
|
88
|
+
Result.partial(first..last)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -15,9 +15,9 @@ module Wsv
|
|
|
15
15
|
|
|
16
16
|
def build
|
|
17
17
|
if @range
|
|
18
|
-
Response.new(status: 206, headers: range_headers, body:
|
|
18
|
+
Response.new(status: 206, headers: range_headers, body: body)
|
|
19
19
|
else
|
|
20
|
-
Response.new(status: @status, headers: full_headers, body:
|
|
20
|
+
Response.new(status: @status, headers: full_headers, body: body)
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
|
|
@@ -47,17 +47,12 @@ module Wsv
|
|
|
47
47
|
base_headers.merge("Content-Length" => size.to_s)
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
-
def
|
|
50
|
+
def body
|
|
51
51
|
return StringBody.new("") if @head
|
|
52
|
+
return FileBody.new(@path) unless @range
|
|
52
53
|
|
|
53
54
|
FileBody.new(@path, offset: @range.begin, length: @range.size)
|
|
54
55
|
end
|
|
55
|
-
|
|
56
|
-
def full_body
|
|
57
|
-
return StringBody.new("") if @head
|
|
58
|
-
|
|
59
|
-
FileBody.new(@path)
|
|
60
|
-
end
|
|
61
56
|
end
|
|
62
57
|
end
|
|
63
58
|
end
|
data/lib/wsv/server/banner.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "url_host"
|
|
4
|
+
|
|
3
5
|
module Wsv
|
|
4
6
|
class Server
|
|
5
7
|
# Renders the startup announcement (the "Serving / Bind / Local / Stop"
|
|
@@ -36,12 +38,7 @@ module Wsv
|
|
|
36
38
|
end
|
|
37
39
|
|
|
38
40
|
def url_for(display_host)
|
|
39
|
-
"#{scheme}://#{
|
|
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
|
|
41
|
+
"#{scheme}://#{UrlHost.format(display_host)}:#{@port}/"
|
|
45
42
|
end
|
|
46
43
|
|
|
47
44
|
def scheme
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "rbconfig"
|
|
4
|
+
require_relative "url_host"
|
|
4
5
|
|
|
5
6
|
module Wsv
|
|
6
7
|
class Server
|
|
@@ -32,14 +33,7 @@ module Wsv
|
|
|
32
33
|
|
|
33
34
|
def url
|
|
34
35
|
scheme = @tls ? "https" : "http"
|
|
35
|
-
"#{scheme}://#{
|
|
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
|
|
36
|
+
"#{scheme}://#{UrlHost.format(display_host)}:#{@port}/"
|
|
43
37
|
end
|
|
44
38
|
|
|
45
39
|
def display_host
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "deadline_reader"
|
|
4
|
+
require_relative "../request"
|
|
5
|
+
require_relative "../response"
|
|
6
|
+
|
|
7
|
+
module Wsv
|
|
8
|
+
class Server
|
|
9
|
+
# Owns a single accepted client socket. `serve` runs the request lifecycle
|
|
10
|
+
# (parse → app → write → drain → close); `reject` writes 503 (when allowed)
|
|
11
|
+
# and closes. Both share the safe-write / drain / close primitives so a
|
|
12
|
+
# broken peer cannot leak a connection or mask errors.
|
|
13
|
+
class Connection
|
|
14
|
+
DRAIN_TIMEOUT = 5
|
|
15
|
+
|
|
16
|
+
def initialize(client, err:, cors: nil)
|
|
17
|
+
@client = client
|
|
18
|
+
@err = err
|
|
19
|
+
@cors = cors
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def serve(app, read_timeout:)
|
|
23
|
+
reader = DeadlineReader.new(@client, Time.now + read_timeout)
|
|
24
|
+
request = Request.parse(reader)
|
|
25
|
+
case request
|
|
26
|
+
when :empty
|
|
27
|
+
nil
|
|
28
|
+
when :malformed
|
|
29
|
+
write(Response.text(400))
|
|
30
|
+
else
|
|
31
|
+
write(app.call(request))
|
|
32
|
+
end
|
|
33
|
+
rescue Request::TooLarge => e
|
|
34
|
+
write(Response.text(e.status_code))
|
|
35
|
+
rescue IO::TimeoutError
|
|
36
|
+
write(Response.text(408))
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
# Treat unmapped failures as connection-scoped and close with 400 rather
|
|
39
|
+
# than letting one bad request path bring down the server.
|
|
40
|
+
@err.puts "wsv: #{e.class}: #{e.message}"
|
|
41
|
+
write(Response.text(400))
|
|
42
|
+
ensure
|
|
43
|
+
graceful_close
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def reject(reply:)
|
|
47
|
+
write(Response.text(503)) if reply
|
|
48
|
+
ensure
|
|
49
|
+
graceful_close
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# Connection is the sole place that adds ACAO / Vary headers, so every
|
|
55
|
+
# response (App, parser errors, timeouts, the 503 rejection) gets them
|
|
56
|
+
# uniformly when CORS is enabled.
|
|
57
|
+
def write(response)
|
|
58
|
+
return if @client.closed?
|
|
59
|
+
|
|
60
|
+
finalize(response).write_to(@client)
|
|
61
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def finalize(response)
|
|
66
|
+
@cors ? @cors.overlay(response) : response
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def graceful_close
|
|
70
|
+
return if @client.closed?
|
|
71
|
+
|
|
72
|
+
drain_recv
|
|
73
|
+
rescue StandardError
|
|
74
|
+
nil
|
|
75
|
+
ensure
|
|
76
|
+
begin
|
|
77
|
+
@client.close unless @client.closed?
|
|
78
|
+
rescue StandardError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def drain_recv
|
|
84
|
+
deadline = Time.now + DRAIN_TIMEOUT
|
|
85
|
+
loop do
|
|
86
|
+
return if Time.now >= deadline
|
|
87
|
+
|
|
88
|
+
chunk = @client.read_nonblock(8192, exception: false)
|
|
89
|
+
case chunk
|
|
90
|
+
when nil, :wait_writable
|
|
91
|
+
# nil = EOF. :wait_writable can come back from SSLSocket during a
|
|
92
|
+
# renegotiation (read needs an underlying write). Either way,
|
|
93
|
+
# there's nothing more we can usefully drain right now.
|
|
94
|
+
return
|
|
95
|
+
when :wait_readable
|
|
96
|
+
remaining = deadline - Time.now
|
|
97
|
+
return if remaining <= 0
|
|
98
|
+
return unless @client.wait_readable([remaining, 0.2].min)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wsv
|
|
4
|
+
class Server
|
|
5
|
+
# Caps in-flight connections at `max`. `try_spawn` runs the block in a new
|
|
6
|
+
# thread when capacity is available and returns true; otherwise returns
|
|
7
|
+
# false so the caller can reject the client.
|
|
8
|
+
class ConnectionThrottle
|
|
9
|
+
def initialize(max:, err:)
|
|
10
|
+
@max = max
|
|
11
|
+
@err = err
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
@active = 0
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def try_spawn(&block)
|
|
17
|
+
return false unless reserve_slot
|
|
18
|
+
|
|
19
|
+
begin
|
|
20
|
+
Thread.new do
|
|
21
|
+
Thread.current.report_on_exception = false
|
|
22
|
+
block.call
|
|
23
|
+
ensure
|
|
24
|
+
release_slot
|
|
25
|
+
end
|
|
26
|
+
true
|
|
27
|
+
rescue ThreadError => e
|
|
28
|
+
@err.puts "wsv: thread error: #{e.message}"
|
|
29
|
+
release_slot
|
|
30
|
+
false
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def reserve_slot
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
next false if @active >= @max
|
|
39
|
+
|
|
40
|
+
@active += 1
|
|
41
|
+
true
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def release_slot
|
|
46
|
+
@mutex.synchronize { @active -= 1 }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wsv
|
|
4
|
+
class Server
|
|
5
|
+
# Formats a host for inclusion in a URL. IPv6 literals are bracketed
|
|
6
|
+
# (RFC 3986); zone identifiers (`%eth0` etc.) are percent-encoded
|
|
7
|
+
# (RFC 6874).
|
|
8
|
+
module UrlHost
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def format(host)
|
|
12
|
+
host.include?(":") ? "[#{host.gsub('%', '%25')}]" : host
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|