wsv 0.10.1 → 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 +57 -0
- data/README.md +9 -3
- data/lib/wsv/app.rb +12 -56
- data/lib/wsv/cli.rb +24 -4
- data/lib/wsv/cors.rb +7 -5
- data/lib/wsv/range_request.rb +91 -0
- 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 +59 -21
- data/lib/wsv/server.rb +22 -5
- data/lib/wsv/version.rb +1 -1
- data/lib/wsv.rb +1 -0
- data/test/access_log_test.rb +94 -0
- data/test/app_test.rb +23 -29
- data/test/banner_test.rb +74 -0
- data/test/cli_test.rb +72 -1
- data/test/cors_test.rb +76 -0
- data/test/custom_app_test.rb +128 -0
- data/test/path_resolver_test.rb +8 -0
- data/test/range_request_test.rb +124 -0
- data/test/server_test.rb +92 -70
- data/test/sse_test.rb +88 -0
- data/test/test_helper.rb +8 -0
- metadata +12 -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,62 @@
|
|
|
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
|
+
|
|
39
|
+
## 0.11.0
|
|
40
|
+
|
|
41
|
+
- Extract `Wsv::RangeRequest` from `App`. RFC 7233 range parsing
|
|
42
|
+
(suffix / open-ended / bounded ranges, unsatisfiability) now lives
|
|
43
|
+
in its own class with a `Result` value object (`#full?` /
|
|
44
|
+
`#partial?` / `#unsatisfiable?` predicates plus `#bounds`),
|
|
45
|
+
matching the `PathResolver::Result` pattern. Behavior unchanged.
|
|
46
|
+
- Make `Server::Connection` the sole place that adds CORS overlay
|
|
47
|
+
headers (`Access-Control-Allow-Origin`, `Vary`) on outgoing
|
|
48
|
+
responses. Previously `App` applied the overlay on its return
|
|
49
|
+
value, but rescue-path responses (408 / 414 / 431 / 503 / unmapped
|
|
50
|
+
400) bypassed `App` and therefore lacked CORS headers. Now every
|
|
51
|
+
response — `App`, parser errors, timeouts, and the 503 rejection —
|
|
52
|
+
gets the overlay uniformly. `Cors#preflight` no longer includes
|
|
53
|
+
ACAO/Vary itself, since Connection adds them.
|
|
54
|
+
- `Wsv::App.new(... cors:)` now expects a `Wsv::Cors` instance (or
|
|
55
|
+
`nil`) instead of a Boolean. `Wsv::Server.new(... cors: true/false)`
|
|
56
|
+
is unchanged. Per the README's stability statement, the Ruby API
|
|
57
|
+
under `lib/wsv/` is implementation-only and may change in any
|
|
58
|
+
release.
|
|
59
|
+
|
|
3
60
|
## 0.10.1
|
|
4
61
|
|
|
5
62
|
- Update gem description and README tagline to position wsv as
|
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/app.rb
CHANGED
|
@@ -4,24 +4,23 @@ require "time"
|
|
|
4
4
|
require "uri"
|
|
5
5
|
require_relative "cors"
|
|
6
6
|
require_relative "path_resolver"
|
|
7
|
+
require_relative "range_request"
|
|
7
8
|
require_relative "response"
|
|
8
9
|
|
|
9
10
|
module Wsv
|
|
10
11
|
class App
|
|
11
12
|
ALLOWED_METHODS = %w[GET HEAD].freeze
|
|
12
|
-
RANGE_PATTERN = /\Abytes=(\d+)?-(\d+)?\z/
|
|
13
13
|
|
|
14
|
-
def initialize(root, spa: false, cors:
|
|
14
|
+
def initialize(root, spa: false, cors: nil)
|
|
15
15
|
@resolver = PathResolver.new(root)
|
|
16
16
|
@spa = spa
|
|
17
|
-
@cors =
|
|
17
|
+
@cors = cors
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def call(request)
|
|
21
21
|
return @cors.preflight(request) if @cors && request.method == "OPTIONS"
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
@cors ? @cors.overlay(response) : response
|
|
23
|
+
build_response(request)
|
|
25
24
|
end
|
|
26
25
|
|
|
27
26
|
private
|
|
@@ -30,7 +29,7 @@ module Wsv
|
|
|
30
29
|
head = request.head?
|
|
31
30
|
|
|
32
31
|
unless ALLOWED_METHODS.include?(request.method)
|
|
33
|
-
return Response.text(405, headers: { "Allow" =>
|
|
32
|
+
return Response.text(405, headers: { "Allow" => allow_header }, head: head)
|
|
34
33
|
end
|
|
35
34
|
|
|
36
35
|
raw_path, query = request.target.split("?", 2)
|
|
@@ -51,8 +50,8 @@ module Wsv
|
|
|
51
50
|
file_response(result.file, request, head: head)
|
|
52
51
|
end
|
|
53
52
|
|
|
54
|
-
def
|
|
55
|
-
@cors
|
|
53
|
+
def allow_header
|
|
54
|
+
(@cors&.allow_methods || ALLOWED_METHODS).join(", ")
|
|
56
55
|
end
|
|
57
56
|
|
|
58
57
|
def error_response(status, head:)
|
|
@@ -69,15 +68,11 @@ module Wsv
|
|
|
69
68
|
return Response.not_modified if not_modified?(file, request.headers["if-modified-since"])
|
|
70
69
|
|
|
71
70
|
size = File.size(file)
|
|
72
|
-
range =
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
Response.file(file, head: head)
|
|
78
|
-
else
|
|
79
|
-
Response.file(file, head: head, range: range)
|
|
80
|
-
end
|
|
71
|
+
range = RangeRequest.parse(request.headers["range"], size)
|
|
72
|
+
return Response.range_not_satisfiable(size, head: head) if range.unsatisfiable?
|
|
73
|
+
return Response.file(file, head: head) if range.full?
|
|
74
|
+
|
|
75
|
+
Response.file(file, head: head, range: range.bounds)
|
|
81
76
|
end
|
|
82
77
|
|
|
83
78
|
def not_modified?(file, header_value)
|
|
@@ -89,45 +84,6 @@ module Wsv
|
|
|
89
84
|
false
|
|
90
85
|
end
|
|
91
86
|
|
|
92
|
-
def parse_range(header_value, file_size)
|
|
93
|
-
return nil if header_value.nil? || header_value.empty?
|
|
94
|
-
|
|
95
|
-
match = header_value.match(RANGE_PATTERN)
|
|
96
|
-
# Per RFC 7233, an unparseable Range is treated as if absent: fall
|
|
97
|
-
# through as nil so the caller serves a normal 200 instead of 416.
|
|
98
|
-
return nil unless match
|
|
99
|
-
|
|
100
|
-
first, last = match.captures
|
|
101
|
-
if first.nil? && last.nil?
|
|
102
|
-
nil
|
|
103
|
-
elsif first.nil?
|
|
104
|
-
suffix_range(last.to_i, file_size)
|
|
105
|
-
elsif last.nil?
|
|
106
|
-
open_range(first.to_i, file_size)
|
|
107
|
-
else
|
|
108
|
-
bounded_range(first.to_i, last.to_i, file_size)
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def suffix_range(suffix, file_size)
|
|
113
|
-
return :unsatisfiable if suffix.zero? || file_size.zero?
|
|
114
|
-
|
|
115
|
-
[file_size - suffix, 0].max..(file_size - 1)
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def open_range(first, file_size)
|
|
119
|
-
return :unsatisfiable if first >= file_size
|
|
120
|
-
|
|
121
|
-
first..(file_size - 1)
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def bounded_range(first, last, file_size)
|
|
125
|
-
return :unsatisfiable if first > last || first >= file_size
|
|
126
|
-
|
|
127
|
-
last = file_size - 1 if last >= file_size
|
|
128
|
-
first..last
|
|
129
|
-
end
|
|
130
|
-
|
|
131
87
|
def redirect_location(raw_path, query)
|
|
132
88
|
path = URI(raw_path.to_s).path
|
|
133
89
|
path = "/" if path.empty?
|
data/lib/wsv/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
|
|
data/lib/wsv/cors.rb
CHANGED
|
@@ -5,15 +5,17 @@ require_relative "response"
|
|
|
5
5
|
module Wsv
|
|
6
6
|
class Cors
|
|
7
7
|
ALLOW_ORIGIN = "*"
|
|
8
|
-
ALLOW_METHODS =
|
|
8
|
+
ALLOW_METHODS = %w[GET HEAD OPTIONS].freeze
|
|
9
9
|
MAX_AGE = "86400"
|
|
10
10
|
|
|
11
|
+
def allow_methods
|
|
12
|
+
ALLOW_METHODS
|
|
13
|
+
end
|
|
14
|
+
|
|
11
15
|
def preflight(request)
|
|
12
16
|
headers = {
|
|
13
|
-
"Access-Control-Allow-
|
|
14
|
-
"Access-Control-
|
|
15
|
-
"Access-Control-Max-Age" => MAX_AGE,
|
|
16
|
-
"Vary" => "Origin"
|
|
17
|
+
"Access-Control-Allow-Methods" => ALLOW_METHODS.join(", "),
|
|
18
|
+
"Access-Control-Max-Age" => MAX_AGE
|
|
17
19
|
}
|
|
18
20
|
requested = request.headers["access-control-request-headers"]
|
|
19
21
|
headers["Access-Control-Allow-Headers"] = requested if requested
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wsv
|
|
4
|
+
# Parses an HTTP `Range` header (RFC 7233) against a known file size.
|
|
5
|
+
class RangeRequest
|
|
6
|
+
PATTERN = /\Abytes=(\d+)?-(\d+)?\z/
|
|
7
|
+
|
|
8
|
+
class Result
|
|
9
|
+
attr_reader :bounds
|
|
10
|
+
|
|
11
|
+
def initialize(kind:, bounds: nil)
|
|
12
|
+
@kind = kind
|
|
13
|
+
@bounds = bounds
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def full?
|
|
17
|
+
@kind == :full
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def partial?
|
|
21
|
+
@kind == :partial
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def unsatisfiable?
|
|
25
|
+
@kind == :unsatisfiable
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.full
|
|
29
|
+
new(kind: :full)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.partial(bounds)
|
|
33
|
+
new(kind: :partial, bounds: bounds)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.unsatisfiable
|
|
37
|
+
new(kind: :unsatisfiable)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.parse(header_value, file_size)
|
|
42
|
+
new(header_value, file_size).parse
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def initialize(header_value, file_size)
|
|
46
|
+
@header_value = header_value
|
|
47
|
+
@file_size = file_size
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def parse
|
|
51
|
+
return Result.full if @header_value.nil? || @header_value.empty?
|
|
52
|
+
|
|
53
|
+
match = @header_value.match(PATTERN)
|
|
54
|
+
# Per RFC 7233, an unparseable Range is treated as if absent: return
|
|
55
|
+
# full so the caller serves a normal 200 instead of 416.
|
|
56
|
+
return Result.full unless match
|
|
57
|
+
|
|
58
|
+
first, last = match.captures
|
|
59
|
+
if first.nil? && last.nil?
|
|
60
|
+
Result.full
|
|
61
|
+
elsif first.nil?
|
|
62
|
+
suffix_range(last.to_i)
|
|
63
|
+
elsif last.nil?
|
|
64
|
+
open_range(first.to_i)
|
|
65
|
+
else
|
|
66
|
+
bounded_range(first.to_i, last.to_i)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def suffix_range(suffix)
|
|
73
|
+
return Result.unsatisfiable if suffix.zero? || @file_size.zero?
|
|
74
|
+
|
|
75
|
+
Result.partial([@file_size - suffix, 0].max..(@file_size - 1))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def open_range(first)
|
|
79
|
+
return Result.unsatisfiable if first >= @file_size
|
|
80
|
+
|
|
81
|
+
Result.partial(first..(@file_size - 1))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def bounded_range(first, last)
|
|
85
|
+
return Result.unsatisfiable if first > last || first >= @file_size
|
|
86
|
+
|
|
87
|
+
last = @file_size - 1 if last >= @file_size
|
|
88
|
+
Result.partial(first..last)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -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,51 +14,88 @@ module Wsv
|
|
|
13
14
|
class Connection
|
|
14
15
|
DRAIN_TIMEOUT = 5
|
|
15
16
|
|
|
16
|
-
def initialize(client, err:)
|
|
17
|
+
def initialize(client, err:, cors: nil, access_log: NullAccessLog.new)
|
|
17
18
|
@client = client
|
|
18
19
|
@err = err
|
|
20
|
+
@cors = cors
|
|
21
|
+
@access_log = access_log
|
|
22
|
+
@remote_addr = remote_addr_of(client)
|
|
19
23
|
end
|
|
20
24
|
|
|
21
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)
|
|
22
44
|
reader = DeadlineReader.new(@client, Time.now + read_timeout)
|
|
23
45
|
request = Request.parse(reader)
|
|
24
|
-
|
|
25
|
-
when :empty
|
|
26
|
-
nil
|
|
27
|
-
when :malformed
|
|
28
|
-
write(Response.text(400))
|
|
29
|
-
else
|
|
30
|
-
write(app.call(request))
|
|
31
|
-
end
|
|
46
|
+
[request, build_response(app, request)]
|
|
32
47
|
rescue Request::TooLarge => e
|
|
33
|
-
|
|
48
|
+
[nil, Response.text(e.status_code)]
|
|
34
49
|
rescue IO::TimeoutError
|
|
35
|
-
|
|
50
|
+
[nil, Response.text(408)]
|
|
36
51
|
rescue StandardError => e
|
|
37
52
|
# Treat unmapped failures as connection-scoped and close with 400 rather
|
|
38
53
|
# than letting one bad request path bring down the server.
|
|
39
54
|
@err.puts "wsv: #{e.class}: #{e.message}"
|
|
40
|
-
|
|
41
|
-
ensure
|
|
42
|
-
graceful_close
|
|
55
|
+
[nil, Response.text(400)]
|
|
43
56
|
end
|
|
44
57
|
|
|
45
|
-
def
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
49
64
|
end
|
|
50
65
|
|
|
51
|
-
|
|
52
|
-
|
|
66
|
+
# Connection is the sole place that adds ACAO / Vary headers, so every
|
|
67
|
+
# response (App, parser errors, timeouts, the 503 rejection) gets them
|
|
68
|
+
# uniformly when CORS is enabled.
|
|
53
69
|
def write(response)
|
|
54
70
|
return if @client.closed?
|
|
55
71
|
|
|
56
|
-
response.write_to(@client)
|
|
72
|
+
finalize(response).write_to(@client)
|
|
57
73
|
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
58
74
|
nil
|
|
59
75
|
end
|
|
60
76
|
|
|
77
|
+
def finalize(response)
|
|
78
|
+
@cors ? @cors.overlay(response) : response
|
|
79
|
+
end
|
|
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
|
+
|
|
61
99
|
def graceful_close
|
|
62
100
|
return if @client.closed?
|
|
63
101
|
|