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 +4 -4
- data/CHANGELOG.md +36 -0
- data/README.md +9 -3
- data/lib/wsv/cli.rb +24 -4
- data/lib/wsv/response/sse_body.rb +35 -0
- data/lib/wsv/response/sse_builder.rb +42 -0
- data/lib/wsv/response.rb +12 -0
- data/lib/wsv/server/access_log.rb +48 -0
- data/lib/wsv/server/connection.rb +50 -20
- data/lib/wsv/server.rb +20 -5
- data/lib/wsv/version.rb +1 -1
- data/test/access_log_test.rb +94 -0
- data/test/app_test.rb +11 -0
- data/test/banner_test.rb +15 -0
- data/test/cli_test.rb +72 -1
- data/test/custom_app_test.rb +128 -0
- data/test/path_resolver_test.rb +8 -0
- data/test/range_request_test.rb +21 -0
- data/test/server_test.rb +26 -0
- data/test/sse_test.rb +88 -0
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 77baaa8165944bc789ad3054605d4c3283ece7b4592c9477c117495bfa48e2fb
|
|
4
|
+
data.tar.gz: 1ecf4ef75a3b99e08d61a985de7f06f83622d92b0b2df769f27a937856cd185c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
41
|
+
wsv --host 0.0.0.0 -p 3000 ./dist
|
|
42
42
|
```
|
|
43
43
|
|
|
44
44
|
Options:
|
|
45
45
|
|
|
46
46
|
```text
|
|
47
|
-
|
|
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
|
-
|
|
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("
|
|
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("--
|
|
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
|
-
|
|
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
|
-
|
|
48
|
+
[nil, Response.text(e.status_code)]
|
|
35
49
|
rescue IO::TimeoutError
|
|
36
|
-
|
|
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
|
-
|
|
42
|
-
ensure
|
|
43
|
-
graceful_close
|
|
55
|
+
[nil, Response.text(400)]
|
|
44
56
|
end
|
|
45
57
|
|
|
46
|
-
def
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
@
|
|
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
|
|
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
|
@@ -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(["
|
|
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
|
data/test/path_resolver_test.rb
CHANGED
|
@@ -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")
|
data/test/range_request_test.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
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
|