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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 027bb497b4d78e1d58abd8aee91bc4349439c29425dc374b535587da67b09997
4
- data.tar.gz: 5692790c2408ad64761358b3d33efccb439edb0e5c75150beed715bbabfc350e
3
+ metadata.gz: adfd569857554409cef36ce05205eb50f833a58a27e654e1da80646afa2ec40e
4
+ data.tar.gz: 8d5c75289dd59dc65412ebff24bf0cf4d9aa9933befb17354f9b2fb3320b5ca3
5
5
  SHA512:
6
- metadata.gz: 2d866a94d0f52f01e7ea50f69a2818bf2c367d704274c362863af11c71647aa80120fa72964bec31f6bfb05356ec401ced291875b9f0bc5c55f9b6b1b4b28fd8
7
- data.tar.gz: 27cdfaa3126c02c54eeb1268fdec08fc64de82a850e2523865280048f93fbd0d95f5c906eb4da26734a80e36c98570c82ed059d71bbab0459361153a4be1fbb9
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
- ```sh
12
- gem install wsv
11
+ Add to your Gemfile:
12
+
13
+ ```ruby
14
+ group :development do
15
+ gem "wsv"
16
+ end
13
17
  ```
14
18
 
15
- For local development:
19
+ Then run `bundle install` and start with `bundle exec wsv`.
20
+
21
+ Or install globally:
16
22
 
17
23
  ```sh
18
- gem build wsv.gemspec
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. **Ephemeral self-signed** `wsv --tls` with no cert configured: wsv
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. **`~/.config/wsv/` auto-detection (recommended)** if both
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. **Explicit cert/key files** `wsv --cert path/to/cert.pem --key path/to/key.pem`
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
- `wsv` is intended for **local development previews, not for production or internet-facing use**.
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 above (`-h` / `--host`, `-p` / `--port`, `--help`,
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: false)
14
+ def initialize(root, spa: false, cors: nil)
15
15
  @resolver = PathResolver.new(root)
16
16
  @spa = spa
17
- @cors = Cors.new if 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
- response = build_response(request)
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" => allow_methods }, head: head)
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 allow_methods
55
- @cors ? Cors::ALLOW_METHODS : "GET, HEAD"
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 = parse_range(request.headers["range"], size)
73
- case range
74
- when :unsatisfiable
75
- Response.range_not_satisfiable(size, head: head)
76
- when nil
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 = "GET, HEAD, OPTIONS"
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-Origin" => ALLOW_ORIGIN,
14
- "Access-Control-Allow-Methods" => ALLOW_METHODS,
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: range_body)
18
+ Response.new(status: 206, headers: range_headers, body: body)
19
19
  else
20
- Response.new(status: @status, headers: full_headers, body: full_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 range_body
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
@@ -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}://#{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
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}://#{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
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