wsv 0.11.0 → 0.12.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: adfd569857554409cef36ce05205eb50f833a58a27e654e1da80646afa2ec40e
4
- data.tar.gz: 8d5c75289dd59dc65412ebff24bf0cf4d9aa9933befb17354f9b2fb3320b5ca3
3
+ metadata.gz: 77baaa8165944bc789ad3054605d4c3283ece7b4592c9477c117495bfa48e2fb
4
+ data.tar.gz: 1ecf4ef75a3b99e08d61a985de7f06f83622d92b0b2df769f27a937856cd185c
5
5
  SHA512:
6
- metadata.gz: b4514041977a6c9a599ea9f2a3735f7b4668ab18172b974453293c444c4441221e068f9374cbdd6c5058b99ecb776edbe47bda3bff4fd77731dbeb9b45cd9446
7
- data.tar.gz: f1d06286b164b0f70dce52d7fd86b9fdf29c64f6cbc0515e3d26558afb4498d23277fc23b66709298afffb593e321a21498cc55dea2ab8b58e31186b48aaa021
6
+ metadata.gz: 51247e6eb2b43639dc127e44c1b0ca27feb1cb8ba7ef25120ff744343fb839893860e8091c689beb5e9db45fa9a875f59e42391681eb973d2c2c25de7d9855c7
7
+ data.tar.gz: b9434026433cc3d54f742bd099ac0afd705c96f169ed1c6067fd1f58b13fff0c61f4c2257d2cffedb0e7c15a193f07b2156f2d7776c2de3c1b50790d13d271a0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.0
4
+
5
+ - Add `Wsv::Server.new(... app:)` so callers can plug a custom request
6
+ handler in place of the default static file server. Any object whose
7
+ `#call(request)` returns a `Wsv::Response` works; the surrounding
8
+ connection / throttling / access-log / CORS / TLS machinery is reused.
9
+ - Add `Wsv::Response.sse { |io| ... }` for custom `app:` handlers that
10
+ need Server-Sent Events or other streaming responses without a
11
+ precomputed `Content-Length`. The helper emits
12
+ `Content-Type: text/event-stream; charset=utf-8`,
13
+ `Cache-Control: no-cache`, and `X-Accel-Buffering: no`, writes
14
+ chunks directly to the client socket, and ends the response when the
15
+ block returns.
16
+ - Enable `TCP_NODELAY` on accepted client sockets so small writes (SSE
17
+ frames, response headers) are flushed immediately instead of waiting in
18
+ the kernel send buffer for a Nagle ACK. Set before any TLS wrap; NODELAY
19
+ is a TCP-layer option that persists through SSL.
20
+ - Per-request access log on stdout in Common Log Format
21
+ (`host - - [date] "method target version" status bytes`). Matches the
22
+ default behavior of `python -m http.server`, `http-server`, `serve`,
23
+ `live-server`, `miniserve`, `darkhttpd`, `puma`, `rails server`, and
24
+ WEBrick. Pass `-q` / `--quiet` to suppress. Concurrent connections share
25
+ a mutex so log lines never interleave. Control chars, quotes, and
26
+ backslashes in the request line are escaped as `\xNN` to prevent log
27
+ injection from a hostile client.
28
+ - **Breaking (CLI):** `-h` is now `--help` instead of `--host`, matching the
29
+ Unix convention used by every other static / dev server (Python's
30
+ `http.server`, Jekyll, Rails, Puma, `http-server`, `serve`, miniserve,
31
+ Hugo, Caddy). Use `--host` (long form only) to set the bind address.
32
+ - `--host` accepts bare IPv6 addresses (`--host ::1`) as before, and now
33
+ also accepts the bracketed form (`--host '[::1]'`) so values copy-pasted
34
+ from a URL bar do not produce a cryptic `getaddrinfo` error. Zone
35
+ identifiers are preserved (`[fe80::1%en0]` → `fe80::1%en0`). The combined
36
+ `[host]:port` form is rejected with a clear error: pass the port via
37
+ `-p` / `--port`.
38
+
3
39
  ## 0.11.0
4
40
 
5
41
  - Extract `Wsv::RangeRequest` from `App`. RFC 7233 range parsing
data/README.md CHANGED
@@ -38,13 +38,13 @@ wsv _site # Jekyll / Bridgetown output
38
38
  wsv build # Astro / Hugo output
39
39
  wsv --spa dist # Vite / esbuild / webpack SPA output
40
40
  wsv --tls --open # HTTPS, open browser at startup
41
- wsv -h 0.0.0.0 -p 3000 ./dist
41
+ wsv --host 0.0.0.0 -p 3000 ./dist
42
42
  ```
43
43
 
44
44
  Options:
45
45
 
46
46
  ```text
47
- -h, --host HOST Bind host (default: 127.0.0.1)
47
+ --host HOST Bind host (default: 127.0.0.1; accepts IPv6 as `::1` or `[::1]`)
48
48
  -p, --port PORT Bind port (default: 8000)
49
49
  --tls Enable HTTPS (uses ~/.config/wsv/{cert,key}.pem if both present, else self-signed)
50
50
  --cert PATH TLS certificate file (PEM); implies --tls
@@ -52,7 +52,8 @@ Options:
52
52
  --spa Single-page-app mode: fall back to root index.html on 404
53
53
  --open Open the served URL in the default browser at startup
54
54
  --cors Send Access-Control-Allow-Origin: * on every response
55
- --help Show help
55
+ -q, --quiet Suppress per-request access log
56
+ -h, --help Show help
56
57
  --version Show version
57
58
  ```
58
59
 
@@ -105,6 +106,11 @@ Options:
105
106
  (and `Vary: Origin`), and `OPTIONS` preflight requests get `204 No Content`
106
107
  with the matching CORS headers. Lets a frontend on a different port (or a
107
108
  Service Worker) fetch assets from `wsv` during local development.
109
+ - One Common Log Format line per request is written to stdout
110
+ (`127.0.0.1 - - [10/Oct/2000:13:55:36 +0900] "GET / HTTP/1.1" 200 1234`).
111
+ Pass `--quiet` (or `-q`) to suppress. Errors and warnings still go to
112
+ stderr regardless. Control characters and quotes in the request line are
113
+ escaped as `\xNN` so a hostile client cannot inject log lines.
108
114
 
109
115
  ## Security model
110
116
 
data/lib/wsv/cli.rb CHANGED
@@ -26,7 +26,8 @@ module Wsv
26
26
  host: options[:host], port: options[:port], root: root,
27
27
  out: @out, err: @err, tls: tls,
28
28
  spa: options[:spa] || false, open: options[:open] || false,
29
- cors: options[:cors] || false
29
+ cors: options[:cors] || false,
30
+ quiet: options[:quiet] || false
30
31
  )
31
32
  server.start
32
33
  0
@@ -49,8 +50,8 @@ module Wsv
49
50
  parser = OptionParser.new do |opts| # rubocop:disable Metrics/BlockLength
50
51
  opts.banner = "Usage: wsv [options] [directory]"
51
52
 
52
- opts.on("-h", "--host HOST", "Bind host (default: #{DEFAULT_HOST})") do |host|
53
- options[:host] = host
53
+ opts.on("--host HOST", "Bind host (default: #{DEFAULT_HOST})") do |host|
54
+ options[:host] = normalize_host(host)
54
55
  end
55
56
 
56
57
  opts.on("-p", "--port PORT", Integer, "Bind port (default: #{DEFAULT_PORT})") do |port|
@@ -81,7 +82,11 @@ module Wsv
81
82
  options[:cors] = true
82
83
  end
83
84
 
84
- opts.on("--help", "Show help") do
85
+ opts.on("-q", "--quiet", "Suppress per-request access log") do
86
+ options[:quiet] = true
87
+ end
88
+
89
+ opts.on("-h", "--help", "Show help") do
85
90
  @out.puts opts
86
91
  options[:handled] = true
87
92
  end
@@ -123,6 +128,21 @@ module Wsv
123
128
  port
124
129
  end
125
130
 
131
+ # Accept bracketed IPv6 input as a courtesy (e.g. `--host '[::1]'`)
132
+ # so users who copy-pasted from a URL bar do not see a cryptic
133
+ # getaddrinfo error. The combined `[::1]:8000` form is rejected
134
+ # explicitly: wsv takes host and port via separate flags.
135
+ def normalize_host(host)
136
+ return host unless host.start_with?("[")
137
+ raise ArgumentError, "--host must not include a port; pass it via -p / --port" if host.match?(/\]:\d+\z/)
138
+ raise ArgumentError, "--host has unbalanced brackets: #{host}" unless host.end_with?("]")
139
+
140
+ inner = host[1..-2]
141
+ raise ArgumentError, "--host bracket value is empty" if inner.empty?
142
+
143
+ inner
144
+ end
145
+
126
146
  def resolve_tls(options)
127
147
  return nil unless options[:tls] || options[:cert] || options[:key]
128
148
 
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wsv
4
+ class Response
5
+ # Body for Server-Sent Events responses. The producer block receives the
6
+ # client socket and writes SSE frames until it returns; the surrounding
7
+ # Connection then closes the TCP connection. Write errors after the peer
8
+ # disconnects (EPIPE, ECONNRESET, IOError) propagate out of #write_to and
9
+ # are swallowed by Connection#write, so producers do not need their own
10
+ # rescue. Producers should `io.flush` after each frame to defeat TCP
11
+ # buffering.
12
+ #
13
+ # `bytesize` returns 0 because SSE streams have no a-priori known size.
14
+ # AccessLog renders 0 bytes as `-` in Common Log Format.
15
+ class SseBody
16
+ def initialize(&producer)
17
+ raise ArgumentError, "block required" unless producer
18
+
19
+ @producer = producer
20
+ end
21
+
22
+ def to_s
23
+ raise NotImplementedError, "SseBody has no static representation; use #write_to(io)"
24
+ end
25
+
26
+ def bytesize
27
+ 0
28
+ end
29
+
30
+ def write_to(io)
31
+ @producer.call(io)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sse_body"
4
+
5
+ module Wsv
6
+ class Response
7
+ # Builds a Server-Sent Events Response.
8
+ #
9
+ # Wsv::Response.sse do |io|
10
+ # io.write("data: ping\n\n")
11
+ # io.flush
12
+ # end
13
+ #
14
+ # `Connection: close` is set by Response#write_to, so the body terminates
15
+ # when the producer block returns. SSE clients (browsers) re-connect
16
+ # automatically.
17
+ class SseBuilder
18
+ DEFAULT_HEADERS = {
19
+ "Content-Type" => "text/event-stream; charset=utf-8",
20
+ "Cache-Control" => "no-cache",
21
+ # X-Accel-Buffering disables response buffering on reverse proxies
22
+ # that respect it (nginx). Without this an SSE stream behind a
23
+ # proxy would only deliver after the connection ended.
24
+ "X-Accel-Buffering" => "no"
25
+ }.freeze
26
+
27
+ def initialize(status: 200, headers: {}, &producer)
28
+ @status = status
29
+ @headers = DEFAULT_HEADERS.merge(headers)
30
+ @producer = producer
31
+ end
32
+
33
+ def build
34
+ Response.new(
35
+ status: @status,
36
+ headers: @headers,
37
+ body: SseBody.new(&@producer)
38
+ )
39
+ end
40
+ end
41
+ end
42
+ end
data/lib/wsv/response.rb CHANGED
@@ -4,8 +4,10 @@ require_relative "status"
4
4
  require_relative "version"
5
5
  require_relative "response/string_body"
6
6
  require_relative "response/file_body"
7
+ require_relative "response/sse_body"
7
8
  require_relative "response/text_builder"
8
9
  require_relative "response/file_builder"
10
+ require_relative "response/sse_builder"
9
11
 
10
12
  module Wsv
11
13
  class Response
@@ -27,6 +29,10 @@ module Wsv
27
29
  @body.to_s
28
30
  end
29
31
 
32
+ def bytesize
33
+ @body.bytesize
34
+ end
35
+
30
36
  def reason
31
37
  Status.reason(status)
32
38
  end
@@ -69,6 +75,12 @@ module Wsv
69
75
  TextBuilder.new(416, head: head, headers: { "Content-Range" => "bytes */#{file_size}" }).build
70
76
  end
71
77
 
78
+ # Build a Server-Sent Events response. The block receives the client
79
+ # socket and writes (and flushes) SSE frames until it returns.
80
+ def self.sse(status: 200, headers: {}, &producer)
81
+ SseBuilder.new(status: status, headers: headers, &producer).build
82
+ end
83
+
72
84
  private
73
85
 
74
86
  def validate_headers(headers)
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wsv
4
+ class Server
5
+ # Emits one line per served request in Common Log Format. Concurrent
6
+ # Connection threads share a single AccessLog instance, so writes are
7
+ # serialized through a mutex to avoid interleaved bytes on @out.
8
+ class AccessLog
9
+ def initialize(out:)
10
+ @out = out
11
+ @mutex = Mutex.new
12
+ end
13
+
14
+ def record(remote_addr:, request:, status:, bytes:)
15
+ line = format_line(remote_addr, request, status, bytes)
16
+ @mutex.synchronize { @out.puts(line) }
17
+ rescue IOError
18
+ nil
19
+ end
20
+
21
+ private
22
+
23
+ def format_line(remote_addr, request, status, bytes)
24
+ host = remote_addr || "-"
25
+ timestamp = Time.now.strftime("[%d/%b/%Y:%H:%M:%S %z]")
26
+ request_line = format_request_line(request)
27
+ size = bytes.positive? ? bytes.to_s : "-"
28
+ %(#{host} - - #{timestamp} "#{request_line}" #{status} #{size})
29
+ end
30
+
31
+ def format_request_line(request)
32
+ return "-" unless request
33
+
34
+ "#{sanitize(request.method)} #{sanitize(request.target)} #{sanitize(request.version)}"
35
+ end
36
+
37
+ # Replace control chars, quote, and backslash so a hostile request line
38
+ # cannot inject CR/LF or escape the surrounding quotes in the log line.
39
+ def sanitize(str)
40
+ str.to_s.gsub(/[\x00-\x1f\x7f"\\]/) { |c| format('\\x%02x', c.ord) }
41
+ end
42
+ end
43
+
44
+ class NullAccessLog
45
+ def record(**); end
46
+ end
47
+ end
48
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "access_log"
3
4
  require_relative "deadline_reader"
4
5
  require_relative "../request"
5
6
  require_relative "../response"
@@ -13,44 +14,55 @@ module Wsv
13
14
  class Connection
14
15
  DRAIN_TIMEOUT = 5
15
16
 
16
- def initialize(client, err:, cors: nil)
17
+ def initialize(client, err:, cors: nil, access_log: NullAccessLog.new)
17
18
  @client = client
18
19
  @err = err
19
20
  @cors = cors
21
+ @access_log = access_log
22
+ @remote_addr = remote_addr_of(client)
20
23
  end
21
24
 
22
25
  def serve(app, read_timeout:)
26
+ request, response = process(app, read_timeout)
27
+ write(response) if response
28
+ log_access(request, response)
29
+ ensure
30
+ graceful_close
31
+ end
32
+
33
+ def reject(reply:)
34
+ response = reply ? Response.text(503) : nil
35
+ write(response) if response
36
+ log_access(nil, response)
37
+ ensure
38
+ graceful_close
39
+ end
40
+
41
+ private
42
+
43
+ def process(app, read_timeout)
23
44
  reader = DeadlineReader.new(@client, Time.now + read_timeout)
24
45
  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
46
+ [request, build_response(app, request)]
33
47
  rescue Request::TooLarge => e
34
- write(Response.text(e.status_code))
48
+ [nil, Response.text(e.status_code)]
35
49
  rescue IO::TimeoutError
36
- write(Response.text(408))
50
+ [nil, Response.text(408)]
37
51
  rescue StandardError => e
38
52
  # Treat unmapped failures as connection-scoped and close with 400 rather
39
53
  # than letting one bad request path bring down the server.
40
54
  @err.puts "wsv: #{e.class}: #{e.message}"
41
- write(Response.text(400))
42
- ensure
43
- graceful_close
55
+ [nil, Response.text(400)]
44
56
  end
45
57
 
46
- def reject(reply:)
47
- write(Response.text(503)) if reply
48
- ensure
49
- graceful_close
58
+ def build_response(app, request)
59
+ case request
60
+ when :empty then nil
61
+ when :malformed then Response.text(400)
62
+ else app.call(request)
63
+ end
50
64
  end
51
65
 
52
- private
53
-
54
66
  # Connection is the sole place that adds ACAO / Vary headers, so every
55
67
  # response (App, parser errors, timeouts, the 503 rejection) gets them
56
68
  # uniformly when CORS is enabled.
@@ -66,6 +78,24 @@ module Wsv
66
78
  @cors ? @cors.overlay(response) : response
67
79
  end
68
80
 
81
+ def log_access(request, response)
82
+ return unless response
83
+
84
+ @access_log.record(
85
+ remote_addr: @remote_addr,
86
+ request: request.is_a?(Request) ? request : nil,
87
+ status: response.status,
88
+ bytes: response.bytesize
89
+ )
90
+ end
91
+
92
+ def remote_addr_of(client)
93
+ base = client.respond_to?(:io) ? client.io : client
94
+ base.peeraddr(false)[3]
95
+ rescue StandardError
96
+ nil
97
+ end
98
+
69
99
  def graceful_close
70
100
  return if @client.closed?
71
101
 
data/lib/wsv/server.rb CHANGED
@@ -27,7 +27,9 @@ module Wsv
27
27
  tls: nil,
28
28
  spa: false,
29
29
  open: false,
30
- cors: false
30
+ cors: false,
31
+ quiet: false,
32
+ app: nil
31
33
  )
32
34
  @host = host
33
35
  @port = port
@@ -39,7 +41,10 @@ module Wsv
39
41
  @ssl_context = tls&.to_ssl_context
40
42
  @open = open
41
43
  @cors = Cors.new if cors
42
- @app = App.new(@root, spa: spa, cors: @cors)
44
+ @access_log = quiet ? NullAccessLog.new : AccessLog.new(out: out)
45
+ # `app:` lets callers plug in a custom request handler in place of
46
+ # the default file server.
47
+ @app = app || App.new(@root, spa: spa, cors: @cors)
43
48
  @throttle = ConnectionThrottle.new(max: max_connections, err: err)
44
49
  @running = false
45
50
  end
@@ -75,6 +80,15 @@ module Wsv
75
80
  next
76
81
  end
77
82
 
83
+ # Disable Nagle so small writes (SSE frames, headers) are not held
84
+ # in the TCP send buffer waiting for an ACK. Applies before any TLS
85
+ # wrap; NODELAY is a TCP-layer option that persists through SSL.
86
+ begin
87
+ client.setsockopt(:TCP, :NODELAY, 1)
88
+ rescue StandardError
89
+ nil
90
+ end
91
+
78
92
  begin
79
93
  spawn_handler(client)
80
94
  rescue StandardError => e
@@ -90,7 +104,8 @@ module Wsv
90
104
 
91
105
  def spawn_handler(client)
92
106
  accepted = @throttle.try_spawn do
93
- Connection.new(maybe_wrap_tls(client), err: @err, cors: @cors).serve(@app, read_timeout: @read_timeout)
107
+ Connection.new(maybe_wrap_tls(client), err: @err, cors: @cors, access_log: @access_log)
108
+ .serve(@app, read_timeout: @read_timeout)
94
109
  end
95
110
  spawn_rejection(client) unless accepted
96
111
  end
@@ -104,10 +119,10 @@ module Wsv
104
119
  reply = !@ssl_context
105
120
  Thread.new do
106
121
  Thread.current.report_on_exception = false
107
- Connection.new(client, err: @err, cors: @cors).reject(reply: reply)
122
+ Connection.new(client, err: @err, cors: @cors, access_log: @access_log).reject(reply: reply)
108
123
  end
109
124
  rescue ThreadError
110
- Connection.new(client, err: @err, cors: @cors).reject(reply: reply)
125
+ Connection.new(client, err: @err, cors: @cors, access_log: @access_log).reject(reply: reply)
111
126
  end
112
127
 
113
128
  def maybe_wrap_tls(client)
data/lib/wsv/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wsv
4
- VERSION = "0.11.0"
4
+ VERSION = "0.12.0"
5
5
  end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class AccessLogTest < Minitest::Test
6
+ def test_records_clf_line_for_serviced_request
7
+ out = StringIO.new
8
+ log = Wsv::Server::AccessLog.new(out: out)
9
+
10
+ log.record(
11
+ remote_addr: "127.0.0.1",
12
+ request: build_request(method: "GET", target: "/index.html", version: "HTTP/1.1"),
13
+ status: 200,
14
+ bytes: 1234
15
+ )
16
+
17
+ assert_match(%r{\A127\.0\.0\.1 - - \[\d{2}/\w{3}/\d{4}:\d{2}:\d{2}:\d{2} [+-]\d{4}\] }, out.string)
18
+ assert_includes out.string, %("GET /index.html HTTP/1.1" 200 1234)
19
+ end
20
+
21
+ def test_uses_dash_for_zero_bytes
22
+ out = StringIO.new
23
+ Wsv::Server::AccessLog.new(out: out).record(
24
+ remote_addr: "127.0.0.1",
25
+ request: build_request(method: "HEAD", target: "/", version: "HTTP/1.1"),
26
+ status: 200,
27
+ bytes: 0
28
+ )
29
+
30
+ assert_match(/200 -\z/, out.string.chomp)
31
+ end
32
+
33
+ def test_uses_dash_when_remote_addr_unknown
34
+ out = StringIO.new
35
+ Wsv::Server::AccessLog.new(out: out).record(
36
+ remote_addr: nil,
37
+ request: build_request(method: "GET", target: "/", version: "HTTP/1.1"),
38
+ status: 200,
39
+ bytes: 1
40
+ )
41
+
42
+ assert out.string.start_with?("- - - ")
43
+ end
44
+
45
+ def test_uses_dash_when_request_unparsed
46
+ out = StringIO.new
47
+ Wsv::Server::AccessLog.new(out: out).record(
48
+ remote_addr: "127.0.0.1",
49
+ request: nil,
50
+ status: 408,
51
+ bytes: 11
52
+ )
53
+
54
+ assert_includes out.string, %("-" 408 11)
55
+ end
56
+
57
+ def test_sanitizes_control_characters_in_request_line
58
+ out = StringIO.new
59
+ Wsv::Server::AccessLog.new(out: out).record(
60
+ remote_addr: "127.0.0.1",
61
+ request: build_request(method: "GET", target: "/x\r\ninjected:1", version: "HTTP/1.1"),
62
+ status: 400,
63
+ bytes: 0
64
+ )
65
+
66
+ refute_includes out.string, "\r"
67
+ assert_equal 1, out.string.count("\n")
68
+ assert_includes out.string, '\\x0d\\x0a'
69
+ end
70
+
71
+ def test_sanitizes_quotes_in_request_target
72
+ out = StringIO.new
73
+ Wsv::Server::AccessLog.new(out: out).record(
74
+ remote_addr: "127.0.0.1",
75
+ request: build_request(method: "GET", target: %(/x"y), version: "HTTP/1.1"),
76
+ status: 200,
77
+ bytes: 1
78
+ )
79
+
80
+ assert_includes out.string, '\\x22'
81
+ end
82
+
83
+ def test_null_access_log_is_silent
84
+ log = Wsv::Server::NullAccessLog.new
85
+
86
+ assert_nil log.record(remote_addr: "127.0.0.1", request: nil, status: 200, bytes: 0)
87
+ end
88
+
89
+ private
90
+
91
+ def build_request(method:, target:, version:)
92
+ Wsv::Request.new(method: method, target: target, version: version, headers: {})
93
+ end
94
+ end
data/test/app_test.rb CHANGED
@@ -109,6 +109,17 @@ class AppTest < Minitest::Test
109
109
  assert_equal "hi", response.body
110
110
  end
111
111
 
112
+ def test_304_takes_precedence_over_range
113
+ path = File.join(@dir, "x.txt")
114
+ File.write(path, "hi")
115
+
116
+ response = @app.call(req("GET", "/x.txt",
117
+ "if-modified-since" => File.mtime(path).httpdate,
118
+ "range" => "bytes=0-0"))
119
+
120
+ assert_equal 304, response.status
121
+ end
122
+
112
123
  def test_invalid_if_modified_since_is_ignored
113
124
  File.write(File.join(@dir, "x.txt"), "hi")
114
125
 
data/test/banner_test.rb CHANGED
@@ -22,6 +22,21 @@ class BannerTest < Minitest::Test
22
22
  refute_includes err.string, "WARNING"
23
23
  end
24
24
 
25
+ def test_warns_when_binding_to_ipv6_wildcard
26
+ err = StringIO.new
27
+ build(host: "::", err: err).emit
28
+
29
+ assert_includes err.string, "WARNING"
30
+ assert_includes err.string, "::"
31
+ end
32
+
33
+ def test_no_warning_for_ipv6_loopback
34
+ err = StringIO.new
35
+ build(host: "::1", err: err).emit
36
+
37
+ refute_includes err.string, "WARNING"
38
+ end
39
+
25
40
  def test_brackets_ipv6_address_in_url
26
41
  out = StringIO.new
27
42
  build(host: "::1", port: 8000, out: out).emit
data/test/cli_test.rb CHANGED
@@ -17,7 +17,7 @@ class CLITest < Minitest::Test
17
17
 
18
18
  def test_host_port_and_directory
19
19
  Dir.mktmpdir do |dir|
20
- options = Wsv::CLI.new([]).parse_options(["-h", "127.0.0.1", "-p", "3000", dir])
20
+ options = Wsv::CLI.new([]).parse_options(["--host", "127.0.0.1", "-p", "3000", dir])
21
21
 
22
22
  assert_equal "127.0.0.1", options[:host]
23
23
  assert_equal 3000, options[:port]
@@ -25,6 +25,14 @@ class CLITest < Minitest::Test
25
25
  end
26
26
  end
27
27
 
28
+ def test_short_help_flag
29
+ out = StringIO.new
30
+ code = Wsv::CLI.new(["-h"], out: out).run
31
+
32
+ assert_equal 0, code
33
+ assert_includes out.string, "Usage: wsv"
34
+ end
35
+
28
36
  def test_long_host_port_and_directory
29
37
  Dir.mktmpdir do |dir|
30
38
  options = Wsv::CLI.new([]).parse_options(["--host", "localhost", "--port", "4567", dir])
@@ -35,6 +43,48 @@ class CLITest < Minitest::Test
35
43
  end
36
44
  end
37
45
 
46
+ def test_bare_ipv6_host
47
+ options = Wsv::CLI.new([]).parse_options(["--host", "::1"])
48
+
49
+ assert_equal "::1", options[:host]
50
+ end
51
+
52
+ def test_bracketed_ipv6_host_is_unwrapped
53
+ options = Wsv::CLI.new([]).parse_options(["--host", "[::1]"])
54
+
55
+ assert_equal "::1", options[:host]
56
+ end
57
+
58
+ def test_bracketed_ipv6_with_zone_id
59
+ options = Wsv::CLI.new([]).parse_options(["--host", "[fe80::1%en0]"])
60
+
61
+ assert_equal "fe80::1%en0", options[:host]
62
+ end
63
+
64
+ def test_host_with_port_suffix_rejected
65
+ err = StringIO.new
66
+ code = Wsv::CLI.new(["--host", "[::1]:8000"], err: err).run
67
+
68
+ assert_equal 1, code
69
+ assert_includes err.string, "must not include a port"
70
+ end
71
+
72
+ def test_host_unbalanced_brackets_rejected
73
+ err = StringIO.new
74
+ code = Wsv::CLI.new(["--host", "[::1"], err: err).run
75
+
76
+ assert_equal 1, code
77
+ assert_includes err.string, "unbalanced brackets"
78
+ end
79
+
80
+ def test_host_empty_brackets_rejected
81
+ err = StringIO.new
82
+ code = Wsv::CLI.new(["--host", "[]"], err: err).run
83
+
84
+ assert_equal 1, code
85
+ assert_includes err.string, "bracket value is empty"
86
+ end
87
+
38
88
  def test_help
39
89
  out = StringIO.new
40
90
  code = Wsv::CLI.new(["--help"], out: out).run
@@ -62,6 +112,14 @@ class CLITest < Minitest::Test
62
112
  assert_includes err.string, "port must be between 1 and 65535"
63
113
  end
64
114
 
115
+ def test_port_above_max_rejected
116
+ err = StringIO.new
117
+ code = Wsv::CLI.new(["-p", "65536"], err: err).run
118
+
119
+ assert_equal 1, code
120
+ assert_includes err.string, "port must be between 1 and 65535"
121
+ end
122
+
65
123
  def test_missing_directory
66
124
  err = StringIO.new
67
125
  missing = File.join(Dir.tmpdir, "wsv-missing-#{Time.now.to_i}-#{$$}")
@@ -89,6 +147,19 @@ class CLITest < Minitest::Test
89
147
  end
90
148
  end
91
149
 
150
+ def test_missing_cert_file_errors_at_runtime
151
+ err = StringIO.new
152
+ Dir.mktmpdir do |dir|
153
+ missing_cert = File.join(dir, "missing-cert.pem")
154
+ missing_key = File.join(dir, "missing-key.pem")
155
+ code = Wsv::CLI.new(["--cert", missing_cert, "--key", missing_key, dir], err: err).run
156
+
157
+ assert_equal 1, code
158
+ assert_includes err.string, "wsv:"
159
+ assert_includes err.string, "missing-cert.pem"
160
+ end
161
+ end
162
+
92
163
  def test_cert_and_key_parses
93
164
  Dir.mktmpdir do |dir|
94
165
  options = Wsv::CLI.new([]).parse_options(["--cert", "a.pem", "--key", "b.pem", dir])
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "socket"
5
+ require_relative "test_helper"
6
+
7
+ # Covers two related extension points:
8
+ #
9
+ # * `Wsv::Server.new(app:)` — DI a custom request handler
10
+ # * `Wsv::Response.sse { |io| ... }` — long-lived Server-Sent Events responses
11
+ class CustomAppTest < Minitest::Test
12
+ def setup
13
+ @dir = Dir.mktmpdir
14
+ @server = nil
15
+ @thread = nil
16
+ end
17
+
18
+ def teardown
19
+ @server&.stop
20
+ @thread&.join(2)
21
+ FileUtils.remove_entry(@dir)
22
+ end
23
+
24
+ def test_custom_app_handles_requests_in_place_of_default
25
+ app = Class.new do
26
+ def call(_request)
27
+ Wsv::Response.new(
28
+ status: 200,
29
+ headers: { "Content-Type" => "text/plain", "Content-Length" => "8" },
30
+ body: "from-app"
31
+ )
32
+ end
33
+ end.new
34
+
35
+ start_server(app: app)
36
+ response = get("/anything")
37
+
38
+ assert_equal "200", response.code
39
+ assert_equal "from-app", response.body
40
+ end
41
+
42
+ def test_sse_response_delivers_chunks_in_order
43
+ chunks = ["data: one\n\n", "data: two\n\n", "data: three\n\n"]
44
+ app = Class.new do
45
+ define_method(:call) do |_request|
46
+ Wsv::Response.sse do |io|
47
+ chunks.each do |c|
48
+ io.write(c)
49
+ io.flush
50
+ end
51
+ end
52
+ end
53
+ end.new
54
+
55
+ start_server(app: app)
56
+ body = read_full_body("/events")
57
+
58
+ chunks.each { |c| assert_includes body, c }
59
+ assert_operator body.index(chunks[0]), :<, body.index(chunks[1])
60
+ assert_operator body.index(chunks[1]), :<, body.index(chunks[2])
61
+ end
62
+
63
+ def test_sse_response_uses_event_stream_content_type
64
+ app = Class.new do
65
+ def call(_request)
66
+ Wsv::Response.sse { |io| io.write("data: hi\n\n") }
67
+ end
68
+ end.new
69
+
70
+ start_server(app: app)
71
+ response = get("/stream")
72
+
73
+ assert_equal "200", response.code
74
+ assert_match %r{text/event-stream}, response["content-type"]
75
+ refute response["content-length"], "sse must not advertise Content-Length"
76
+ end
77
+
78
+ private
79
+
80
+ def start_server(app:)
81
+ @server = Wsv::Server.new(
82
+ host: "127.0.0.1",
83
+ port: free_port,
84
+ root: @dir,
85
+ out: StringIO.new,
86
+ err: StringIO.new,
87
+ app: app
88
+ )
89
+ @thread = Thread.new { @server.start }
90
+ wait_until_ready
91
+ end
92
+
93
+ def free_port
94
+ s = TCPServer.new("127.0.0.1", 0)
95
+ s.addr[1]
96
+ ensure
97
+ s&.close
98
+ end
99
+
100
+ def wait_until_ready
101
+ deadline = Time.now + 2
102
+ loop do
103
+ TCPSocket.open("127.0.0.1", @server.port).close
104
+ break
105
+ rescue Errno::ECONNREFUSED
106
+ raise if Time.now >= deadline
107
+
108
+ sleep 0.01
109
+ end
110
+ end
111
+
112
+ def get(path)
113
+ Net::HTTP.get_response(URI("http://127.0.0.1:#{@server.port}#{path}"))
114
+ end
115
+
116
+ # Read until the server closes the connection (streaming responses
117
+ # omit Content-Length so Net::HTTP would still read-to-EOF, but for
118
+ # clarity we use a raw socket here).
119
+ def read_full_body(path)
120
+ socket = TCPSocket.open("127.0.0.1", @server.port)
121
+ socket.write("GET #{path} HTTP/1.1\r\nHost: localhost\r\n\r\n")
122
+ raw = socket.read
123
+ header_end = raw.index("\r\n\r\n")
124
+ raw[(header_end + 4)..]
125
+ ensure
126
+ socket&.close
127
+ end
128
+ end
@@ -13,6 +13,14 @@ class PathResolverTest < Minitest::Test
13
13
  FileUtils.remove_entry(@dir)
14
14
  end
15
15
 
16
+ def test_empty_path_returns_redirect
17
+ # `""` has no trailing slash → resolver returns redirect the same
18
+ # way it would for `/somedir` when `somedir` is a directory.
19
+ result = @resolver.resolve("")
20
+
21
+ assert_predicate result, :redirect?
22
+ end
23
+
16
24
  def test_resolves_existing_file
17
25
  path = File.join(@dir, "hello.txt")
18
26
  File.write(path, "hi")
@@ -94,6 +94,27 @@ class RangeRequestTest < Minitest::Test
94
94
  assert_predicate result, :unsatisfiable?
95
95
  end
96
96
 
97
+ def test_bounded_range_at_exact_file_boundary
98
+ result = Wsv::RangeRequest.parse("bytes=0-9", 10)
99
+
100
+ assert_predicate result, :partial?
101
+ assert_equal(0..9, result.bounds)
102
+ end
103
+
104
+ def test_suffix_range_equal_to_file_size
105
+ result = Wsv::RangeRequest.parse("bytes=-10", 10)
106
+
107
+ assert_predicate result, :partial?
108
+ assert_equal(0..9, result.bounds)
109
+ end
110
+
111
+ def test_single_byte_range_at_last_position
112
+ result = Wsv::RangeRequest.parse("bytes=9-9", 10)
113
+
114
+ assert_predicate result, :partial?
115
+ assert_equal(9..9, result.bounds)
116
+ end
117
+
97
118
  def test_multipart_range_is_full
98
119
  # `bytes=0-2,5-7` doesn't match the single-range regex; treat as absent.
99
120
  result = Wsv::RangeRequest.parse("bytes=0-2,5-7", 100)
data/test/server_test.rb CHANGED
@@ -313,6 +313,32 @@ class ServerTest < Minitest::Test
313
313
  assert_includes response, "Allow: GET, HEAD"
314
314
  end
315
315
 
316
+ def test_emits_access_log_by_default
317
+ File.write(File.join(@dir, "ok.txt"), "hello")
318
+ out = StringIO.new
319
+ @server = Wsv::Server.new(host: "127.0.0.1", port: free_port, root: @dir, out: out, err: StringIO.new)
320
+ @thread = Thread.new { @server.start }
321
+ wait_until_ready
322
+
323
+ get("/ok.txt")
324
+
325
+ assert_includes out.string, %("GET /ok.txt HTTP/1.1" 200 5)
326
+ end
327
+
328
+ def test_quiet_suppresses_access_log
329
+ File.write(File.join(@dir, "ok.txt"), "hello")
330
+ out = StringIO.new
331
+ @server = Wsv::Server.new(host: "127.0.0.1", port: free_port, root: @dir,
332
+ out: out, err: StringIO.new, quiet: true)
333
+ @thread = Thread.new { @server.start }
334
+ wait_until_ready
335
+ baseline = out.string.dup
336
+
337
+ get("/ok.txt")
338
+
339
+ assert_equal baseline, out.string
340
+ end
341
+
316
342
  private
317
343
 
318
344
  def start_server(read_timeout: Wsv::Server::DEFAULT_READ_TIMEOUT, tls: nil, cors: false)
data/test/sse_test.rb ADDED
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class SseBodyTest < Minitest::Test
6
+ def test_requires_a_block
7
+ assert_raises(ArgumentError) { Wsv::Response::SseBody.new }
8
+ end
9
+
10
+ def test_to_s_raises_not_implemented
11
+ body = Wsv::Response::SseBody.new { |io| io }
12
+ assert_raises(NotImplementedError) { body.to_s }
13
+ end
14
+
15
+ def test_bytesize_is_zero
16
+ body = Wsv::Response::SseBody.new { |io| io }
17
+
18
+ assert_equal 0, body.bytesize
19
+ end
20
+
21
+ def test_write_to_invokes_producer_with_io
22
+ received_io = nil
23
+ body = Wsv::Response::SseBody.new { |io| received_io = io }
24
+ buffer = StringIO.new
25
+ body.write_to(buffer)
26
+
27
+ assert_equal buffer, received_io
28
+ end
29
+
30
+ def test_producer_can_write_multiple_chunks
31
+ body = Wsv::Response::SseBody.new do |io|
32
+ io.write("data: hello\n\n")
33
+ io.write("data: world\n\n")
34
+ end
35
+ buffer = StringIO.new
36
+ body.write_to(buffer)
37
+
38
+ assert_equal "data: hello\n\ndata: world\n\n", buffer.string
39
+ end
40
+ end
41
+
42
+ class ResponseSseTest < Minitest::Test
43
+ def test_sse_helper_returns_response_with_sse_defaults
44
+ response = Wsv::Response.sse { |io| io }
45
+
46
+ assert_equal 200, response.status
47
+ assert_equal "text/event-stream; charset=utf-8", response.headers["Content-Type"]
48
+ assert_equal "no-cache", response.headers["Cache-Control"]
49
+ assert_equal "no", response.headers["X-Accel-Buffering"]
50
+ end
51
+
52
+ def test_sse_helper_allows_custom_status
53
+ response = Wsv::Response.sse(status: 503) { |io| io }
54
+
55
+ assert_equal 503, response.status
56
+ end
57
+
58
+ def test_sse_helper_merges_extra_headers
59
+ response = Wsv::Response.sse(headers: { "X-Custom" => "v" }) { |io| io }
60
+
61
+ assert_equal "v", response.headers["X-Custom"]
62
+ assert_equal "no-cache", response.headers["Cache-Control"]
63
+ end
64
+
65
+ def test_sse_helper_overrides_default_headers_when_supplied
66
+ response = Wsv::Response.sse(headers: { "Content-Type" => "application/x-ndjson" }) { |io| io }
67
+
68
+ assert_equal "application/x-ndjson", response.headers["Content-Type"]
69
+ end
70
+
71
+ def test_response_write_to_does_not_inject_content_length_for_sse
72
+ response = Wsv::Response.sse do |io|
73
+ io.write("data: hi\n\n")
74
+ end
75
+ buffer = StringIO.new
76
+ response.write_to(buffer)
77
+
78
+ refute_match(/^Content-Length:/i, buffer.string)
79
+ assert_match(%r{^Content-Type: text/event-stream}i, buffer.string)
80
+ assert_match(/data: hi\n\n\z/, buffer.string)
81
+ end
82
+
83
+ def test_bytesize_of_sse_response_is_zero
84
+ response = Wsv::Response.sse { |io| io }
85
+
86
+ assert_equal 0, response.bytesize
87
+ end
88
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wsv
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.0
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - takahashim
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-05-07 00:00:00.000000000 Z
10
+ date: 2026-05-16 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: 'wsv is a Ruby CLI that previews a directory over HTTP/HTTPS. Stdlib-only,
13
13
  no runtime dependencies. Defensive by design: blocks dotfiles, binds to loopback,
@@ -36,9 +36,12 @@ files:
36
36
  - lib/wsv/response.rb
37
37
  - lib/wsv/response/file_body.rb
38
38
  - lib/wsv/response/file_builder.rb
39
+ - lib/wsv/response/sse_body.rb
40
+ - lib/wsv/response/sse_builder.rb
39
41
  - lib/wsv/response/string_body.rb
40
42
  - lib/wsv/response/text_builder.rb
41
43
  - lib/wsv/server.rb
44
+ - lib/wsv/server/access_log.rb
42
45
  - lib/wsv/server/banner.rb
43
46
  - lib/wsv/server/browser_launcher.rb
44
47
  - lib/wsv/server/connection.rb
@@ -50,16 +53,19 @@ files:
50
53
  - lib/wsv/tls_context/resolver.rb
51
54
  - lib/wsv/tls_context/self_signed_cert.rb
52
55
  - lib/wsv/version.rb
56
+ - test/access_log_test.rb
53
57
  - test/app_test.rb
54
58
  - test/banner_test.rb
55
59
  - test/browser_launcher_test.rb
56
60
  - test/cli_test.rb
57
61
  - test/cors_test.rb
62
+ - test/custom_app_test.rb
58
63
  - test/path_resolver_test.rb
59
64
  - test/range_request_test.rb
60
65
  - test/request_test.rb
61
66
  - test/response_test.rb
62
67
  - test/server_test.rb
68
+ - test/sse_test.rb
63
69
  - test/test_helper.rb
64
70
  - test/tls_context_test.rb
65
71
  - wsv.gemspec