wsv 0.10.0 → 0.10.1
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 +19 -0
- data/README.md +19 -13
- 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 +96 -0
- data/lib/wsv/server/connection_throttle.rb +50 -0
- data/lib/wsv/server/url_host.rb +16 -0
- data/lib/wsv/server.rb +14 -106
- data/lib/wsv/version.rb +1 -1
- data/wsv.gemspec +7 -6
- metadata +12 -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: 782b303e2d6eeb0f45f6b3a012523b94611a4991d59ef6669768b21936f47a8e
|
|
4
|
+
data.tar.gz: 25b99b347007975bac86ccd93c212f5fb0bc85522054c36badac601bae5f3638
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b3619a62d69f29a3498081d8174a0a01c61484ede73f9875de9867b4a4dd451f639dcbe0bee99fb3bea00b78c0ba289618ab7eeec3b066705d8ca437a9cea134
|
|
7
|
+
data.tar.gz: c58379c2eab12176f492d993d1e194dc0f3a33c16ba7d8736134498e4d13ce8031816a5d22688f1639c0ea3127c5787ad4c235874b20929490eac52881c3e9b0
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.1
|
|
4
|
+
|
|
5
|
+
- Update gem description and README tagline to position wsv as
|
|
6
|
+
"Defensive by design" — surfaces the actual security defaults on
|
|
7
|
+
RubyGems.org and `gem search` (the 0.10.0 description still said
|
|
8
|
+
"tiny static web server").
|
|
9
|
+
- Refactor `Wsv::Server` (~250 → 150 lines): per-connection lifecycle
|
|
10
|
+
is now `Server::Connection` (`#serve` / `#reject` + safe write /
|
|
11
|
+
drain / close), concurrency cap is `Server::ConnectionThrottle`
|
|
12
|
+
(`#try_spawn`). Behavior unchanged.
|
|
13
|
+
- Extract `Server::UrlHost.format` so `Banner` and `BrowserLauncher`
|
|
14
|
+
share the IPv6 bracketing / RFC 6874 zone-id encoding rule (was
|
|
15
|
+
duplicated in two places, fixed in tandem once already).
|
|
16
|
+
- Simplify `Response::FileBuilder` body construction: the HEAD guard
|
|
17
|
+
and range/full body branch collapse into a single method.
|
|
18
|
+
- Move executable from `bin/wsv` to `exe/wsv` per Bundler convention.
|
|
19
|
+
- Various README polish: Gemfile install path first, Examples for
|
|
20
|
+
Jekyll / Astro / Vite, GHFM `> [!WARNING]` for the prod-use caveat.
|
|
21
|
+
|
|
3
22
|
## 0.10.0
|
|
4
23
|
|
|
5
24
|
- 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
|
|
|
@@ -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,96 @@
|
|
|
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:)
|
|
17
|
+
@client = client
|
|
18
|
+
@err = err
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def serve(app, read_timeout:)
|
|
22
|
+
reader = DeadlineReader.new(@client, Time.now + read_timeout)
|
|
23
|
+
request = Request.parse(reader)
|
|
24
|
+
case request
|
|
25
|
+
when :empty
|
|
26
|
+
nil
|
|
27
|
+
when :malformed
|
|
28
|
+
write(Response.text(400))
|
|
29
|
+
else
|
|
30
|
+
write(app.call(request))
|
|
31
|
+
end
|
|
32
|
+
rescue Request::TooLarge => e
|
|
33
|
+
write(Response.text(e.status_code))
|
|
34
|
+
rescue IO::TimeoutError
|
|
35
|
+
write(Response.text(408))
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
# Treat unmapped failures as connection-scoped and close with 400 rather
|
|
38
|
+
# than letting one bad request path bring down the server.
|
|
39
|
+
@err.puts "wsv: #{e.class}: #{e.message}"
|
|
40
|
+
write(Response.text(400))
|
|
41
|
+
ensure
|
|
42
|
+
graceful_close
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def reject(reply:)
|
|
46
|
+
write(Response.text(503)) if reply
|
|
47
|
+
ensure
|
|
48
|
+
graceful_close
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def write(response)
|
|
54
|
+
return if @client.closed?
|
|
55
|
+
|
|
56
|
+
response.write_to(@client)
|
|
57
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def graceful_close
|
|
62
|
+
return if @client.closed?
|
|
63
|
+
|
|
64
|
+
drain_recv
|
|
65
|
+
rescue StandardError
|
|
66
|
+
nil
|
|
67
|
+
ensure
|
|
68
|
+
begin
|
|
69
|
+
@client.close unless @client.closed?
|
|
70
|
+
rescue StandardError
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def drain_recv
|
|
76
|
+
deadline = Time.now + DRAIN_TIMEOUT
|
|
77
|
+
loop do
|
|
78
|
+
return if Time.now >= deadline
|
|
79
|
+
|
|
80
|
+
chunk = @client.read_nonblock(8192, exception: false)
|
|
81
|
+
case chunk
|
|
82
|
+
when nil, :wait_writable
|
|
83
|
+
# nil = EOF. :wait_writable can come back from SSLSocket during a
|
|
84
|
+
# renegotiation (read needs an underlying write). Either way,
|
|
85
|
+
# there's nothing more we can usefully drain right now.
|
|
86
|
+
return
|
|
87
|
+
when :wait_readable
|
|
88
|
+
remaining = deadline - Time.now
|
|
89
|
+
return if remaining <= 0
|
|
90
|
+
return unless @client.wait_readable([remaining, 0.2].min)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
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
|
data/lib/wsv/server.rb
CHANGED
|
@@ -3,17 +3,15 @@
|
|
|
3
3
|
require "openssl"
|
|
4
4
|
require "socket"
|
|
5
5
|
require_relative "app"
|
|
6
|
-
require_relative "request"
|
|
7
|
-
require_relative "response"
|
|
8
6
|
require_relative "server/banner"
|
|
9
7
|
require_relative "server/browser_launcher"
|
|
10
|
-
require_relative "server/
|
|
8
|
+
require_relative "server/connection"
|
|
9
|
+
require_relative "server/connection_throttle"
|
|
11
10
|
|
|
12
11
|
module Wsv
|
|
13
12
|
class Server
|
|
14
13
|
DEFAULT_READ_TIMEOUT = 10
|
|
15
14
|
DEFAULT_MAX_CONNECTIONS = 8
|
|
16
|
-
DRAIN_TIMEOUT = 5
|
|
17
15
|
|
|
18
16
|
attr_reader :host, :port, :root
|
|
19
17
|
|
|
@@ -36,14 +34,12 @@ module Wsv
|
|
|
36
34
|
@out = out
|
|
37
35
|
@err = err
|
|
38
36
|
@read_timeout = read_timeout
|
|
39
|
-
@max_connections = max_connections
|
|
40
37
|
@tls = tls
|
|
41
38
|
@ssl_context = tls&.to_ssl_context
|
|
42
39
|
@open = open
|
|
43
40
|
@app = App.new(@root, spa: spa, cors: cors)
|
|
41
|
+
@throttle = ConnectionThrottle.new(max: max_connections, err: err)
|
|
44
42
|
@running = false
|
|
45
|
-
@mutex = Mutex.new
|
|
46
|
-
@active = 0
|
|
47
43
|
end
|
|
48
44
|
|
|
49
45
|
def start
|
|
@@ -62,74 +58,8 @@ module Wsv
|
|
|
62
58
|
close
|
|
63
59
|
end
|
|
64
60
|
|
|
65
|
-
def handle(client)
|
|
66
|
-
reader = DeadlineReader.new(client, Time.now + @read_timeout)
|
|
67
|
-
request = Request.parse(reader)
|
|
68
|
-
case request
|
|
69
|
-
when :empty
|
|
70
|
-
nil
|
|
71
|
-
when :malformed
|
|
72
|
-
write_response(client, Response.text(400))
|
|
73
|
-
else
|
|
74
|
-
write_response(client, @app.call(request))
|
|
75
|
-
end
|
|
76
|
-
rescue Request::TooLarge => e
|
|
77
|
-
write_response(client, Response.text(e.status_code))
|
|
78
|
-
rescue IO::TimeoutError
|
|
79
|
-
write_response(client, Response.text(408))
|
|
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.
|
|
83
|
-
@err.puts "wsv: #{e.class}: #{e.message}"
|
|
84
|
-
write_response(client, Response.text(400))
|
|
85
|
-
ensure
|
|
86
|
-
graceful_close(client)
|
|
87
|
-
end
|
|
88
|
-
|
|
89
61
|
private
|
|
90
62
|
|
|
91
|
-
def write_response(client, response)
|
|
92
|
-
return if client.closed?
|
|
93
|
-
|
|
94
|
-
response.write_to(client)
|
|
95
|
-
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
96
|
-
nil
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def graceful_close(client)
|
|
100
|
-
return if client.closed?
|
|
101
|
-
|
|
102
|
-
drain_recv(client)
|
|
103
|
-
rescue StandardError
|
|
104
|
-
nil
|
|
105
|
-
ensure
|
|
106
|
-
begin
|
|
107
|
-
client.close unless client.closed?
|
|
108
|
-
rescue StandardError
|
|
109
|
-
nil
|
|
110
|
-
end
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
def drain_recv(client)
|
|
114
|
-
deadline = Time.now + DRAIN_TIMEOUT
|
|
115
|
-
loop do
|
|
116
|
-
return if Time.now >= deadline
|
|
117
|
-
|
|
118
|
-
chunk = client.read_nonblock(8192, exception: false)
|
|
119
|
-
case chunk
|
|
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.
|
|
124
|
-
return
|
|
125
|
-
when :wait_readable
|
|
126
|
-
remaining = deadline - Time.now
|
|
127
|
-
return if remaining <= 0
|
|
128
|
-
return unless client.wait_readable([remaining, 0.2].min)
|
|
129
|
-
end
|
|
130
|
-
end
|
|
131
|
-
end
|
|
132
|
-
|
|
133
63
|
def accept_loop
|
|
134
64
|
while @running
|
|
135
65
|
client = nil
|
|
@@ -157,38 +87,25 @@ module Wsv
|
|
|
157
87
|
end
|
|
158
88
|
|
|
159
89
|
def spawn_handler(client)
|
|
160
|
-
accepted = @
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
@active += 1
|
|
164
|
-
true
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
return spawn_rejection(client) unless accepted
|
|
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)
|
|
90
|
+
accepted = @throttle.try_spawn do
|
|
91
|
+
Connection.new(maybe_wrap_tls(client), err: @err).serve(@app, read_timeout: @read_timeout)
|
|
180
92
|
end
|
|
93
|
+
spawn_rejection(client) unless accepted
|
|
181
94
|
end
|
|
182
95
|
|
|
183
96
|
# Reject in a separate thread so a slow client cannot block accept_loop
|
|
184
|
-
# via graceful_close
|
|
97
|
+
# via Connection#graceful_close (up to Connection::DRAIN_TIMEOUT seconds).
|
|
98
|
+
# In TLS mode `client` is the raw TCPSocket before any handshake; writing
|
|
99
|
+
# a plaintext 503 would corrupt the TLS handshake the client is about to
|
|
100
|
+
# start, so suppress the reply in that case.
|
|
185
101
|
def spawn_rejection(client)
|
|
102
|
+
reply = !@ssl_context
|
|
186
103
|
Thread.new do
|
|
187
104
|
Thread.current.report_on_exception = false
|
|
188
|
-
|
|
105
|
+
Connection.new(client, err: @err).reject(reply: reply)
|
|
189
106
|
end
|
|
190
107
|
rescue ThreadError
|
|
191
|
-
|
|
108
|
+
Connection.new(client, err: @err).reject(reply: reply)
|
|
192
109
|
end
|
|
193
110
|
|
|
194
111
|
def maybe_wrap_tls(client)
|
|
@@ -200,7 +117,7 @@ module Wsv
|
|
|
200
117
|
ssl.accept
|
|
201
118
|
ssl
|
|
202
119
|
rescue StandardError
|
|
203
|
-
# If wrapping or the handshake failed, `
|
|
120
|
+
# If wrapping or the handshake failed, `serve` is never called and
|
|
204
121
|
# its ensure does not get a chance to close the underlying socket.
|
|
205
122
|
# Close it here so we do not leak a TCPSocket per failed handshake.
|
|
206
123
|
begin
|
|
@@ -211,15 +128,6 @@ module Wsv
|
|
|
211
128
|
raise
|
|
212
129
|
end
|
|
213
130
|
|
|
214
|
-
def reject(client)
|
|
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
|
|
219
|
-
ensure
|
|
220
|
-
graceful_close(client)
|
|
221
|
-
end
|
|
222
|
-
|
|
223
131
|
def close
|
|
224
132
|
@server&.close unless @server&.closed?
|
|
225
133
|
end
|
data/lib/wsv/version.rb
CHANGED
data/wsv.gemspec
CHANGED
|
@@ -8,15 +8,16 @@ Gem::Specification.new do |spec|
|
|
|
8
8
|
spec.authors = ["takahashim"]
|
|
9
9
|
spec.email = ["takahashimm@gmail.com"]
|
|
10
10
|
|
|
11
|
-
spec.summary = "A
|
|
12
|
-
spec.description = "wsv
|
|
13
|
-
|
|
11
|
+
spec.summary = "A zero-dependency static preview server for Ruby projects."
|
|
12
|
+
spec.description = "wsv is a Ruby CLI that previews a directory over HTTP/HTTPS. " \
|
|
13
|
+
"Stdlib-only, no runtime dependencies. Defensive by design: " \
|
|
14
|
+
"blocks dotfiles, binds to loopback, ships with TLS and CORS."
|
|
15
|
+
spec.homepage = "https://github.com/takahashim/wsv"
|
|
14
16
|
spec.license = "MIT"
|
|
15
17
|
spec.required_ruby_version = ">= 3.2"
|
|
16
18
|
|
|
17
19
|
spec.metadata = {
|
|
18
20
|
"homepage_uri" => spec.homepage,
|
|
19
|
-
"source_code_uri" => "https://github.com/takahashim/wsv",
|
|
20
21
|
"changelog_uri" => "https://github.com/takahashim/wsv/blob/main/CHANGELOG.md",
|
|
21
22
|
"rubygems_mfa_required" => "true"
|
|
22
23
|
}
|
|
@@ -25,12 +26,12 @@ Gem::Specification.new do |spec|
|
|
|
25
26
|
"CHANGELOG.md",
|
|
26
27
|
"LICENSE.txt",
|
|
27
28
|
"README.md",
|
|
28
|
-
"
|
|
29
|
+
"exe/wsv",
|
|
29
30
|
"lib/**/*.rb",
|
|
30
31
|
"test/**/*.rb",
|
|
31
32
|
"wsv.gemspec"
|
|
32
33
|
]
|
|
33
|
-
spec.bindir = "
|
|
34
|
+
spec.bindir = "exe"
|
|
34
35
|
spec.executables = ["wsv"]
|
|
35
36
|
spec.require_paths = ["lib"]
|
|
36
37
|
end
|
metadata
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: wsv
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.10.
|
|
4
|
+
version: 0.10.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- takahashim
|
|
8
|
-
bindir:
|
|
8
|
+
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 2026-05-07 00:00:00.000000000 Z
|
|
11
11
|
dependencies: []
|
|
12
|
-
description: wsv
|
|
12
|
+
description: 'wsv is a Ruby CLI that previews a directory over HTTP/HTTPS. Stdlib-only,
|
|
13
|
+
no runtime dependencies. Defensive by design: blocks dotfiles, binds to loopback,
|
|
14
|
+
ships with TLS and CORS.'
|
|
13
15
|
email:
|
|
14
16
|
- takahashimm@gmail.com
|
|
15
17
|
executables:
|
|
@@ -20,7 +22,7 @@ files:
|
|
|
20
22
|
- CHANGELOG.md
|
|
21
23
|
- LICENSE.txt
|
|
22
24
|
- README.md
|
|
23
|
-
-
|
|
25
|
+
- exe/wsv
|
|
24
26
|
- lib/wsv.rb
|
|
25
27
|
- lib/wsv/app.rb
|
|
26
28
|
- lib/wsv/cli.rb
|
|
@@ -38,7 +40,10 @@ files:
|
|
|
38
40
|
- lib/wsv/server.rb
|
|
39
41
|
- lib/wsv/server/banner.rb
|
|
40
42
|
- lib/wsv/server/browser_launcher.rb
|
|
43
|
+
- lib/wsv/server/connection.rb
|
|
44
|
+
- lib/wsv/server/connection_throttle.rb
|
|
41
45
|
- lib/wsv/server/deadline_reader.rb
|
|
46
|
+
- lib/wsv/server/url_host.rb
|
|
42
47
|
- lib/wsv/status.rb
|
|
43
48
|
- lib/wsv/tls_context.rb
|
|
44
49
|
- lib/wsv/tls_context/resolver.rb
|
|
@@ -54,12 +59,11 @@ files:
|
|
|
54
59
|
- test/test_helper.rb
|
|
55
60
|
- test/tls_context_test.rb
|
|
56
61
|
- wsv.gemspec
|
|
57
|
-
homepage: https://
|
|
62
|
+
homepage: https://github.com/takahashim/wsv
|
|
58
63
|
licenses:
|
|
59
64
|
- MIT
|
|
60
65
|
metadata:
|
|
61
|
-
homepage_uri: https://
|
|
62
|
-
source_code_uri: https://github.com/takahashim/wsv
|
|
66
|
+
homepage_uri: https://github.com/takahashim/wsv
|
|
63
67
|
changelog_uri: https://github.com/takahashim/wsv/blob/main/CHANGELOG.md
|
|
64
68
|
rubygems_mfa_required: 'true'
|
|
65
69
|
rdoc_options: []
|
|
@@ -78,5 +82,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
78
82
|
requirements: []
|
|
79
83
|
rubygems_version: 3.6.2
|
|
80
84
|
specification_version: 4
|
|
81
|
-
summary: A
|
|
85
|
+
summary: A zero-dependency static preview server for Ruby projects.
|
|
82
86
|
test_files: []
|
/data/{bin → exe}/wsv
RENAMED
|
File without changes
|