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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 782b303e2d6eeb0f45f6b3a012523b94611a4991d59ef6669768b21936f47a8e
4
- data.tar.gz: 25b99b347007975bac86ccd93c212f5fb0bc85522054c36badac601bae5f3638
3
+ metadata.gz: 77baaa8165944bc789ad3054605d4c3283ece7b4592c9477c117495bfa48e2fb
4
+ data.tar.gz: 1ecf4ef75a3b99e08d61a985de7f06f83622d92b0b2df769f27a937856cd185c
5
5
  SHA512:
6
- metadata.gz: b3619a62d69f29a3498081d8174a0a01c61484ede73f9875de9867b4a4dd451f639dcbe0bee99fb3bea00b78c0ba289618ab7eeec3b066705d8ca437a9cea134
7
- data.tar.gz: c58379c2eab12176f492d993d1e194dc0f3a33c16ba7d8736134498e4d13ce8031816a5d22688f1639c0ea3127c5787ad4c235874b20929490eac52881c3e9b0
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 -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/app.rb CHANGED
@@ -4,24 +4,23 @@ require "time"
4
4
  require "uri"
5
5
  require_relative "cors"
6
6
  require_relative "path_resolver"
7
+ require_relative "range_request"
7
8
  require_relative "response"
8
9
 
9
10
  module Wsv
10
11
  class App
11
12
  ALLOWED_METHODS = %w[GET HEAD].freeze
12
- RANGE_PATTERN = /\Abytes=(\d+)?-(\d+)?\z/
13
13
 
14
- def initialize(root, spa: false, cors: false)
14
+ def initialize(root, spa: false, cors: nil)
15
15
  @resolver = PathResolver.new(root)
16
16
  @spa = spa
17
- @cors = Cors.new if cors
17
+ @cors = cors
18
18
  end
19
19
 
20
20
  def call(request)
21
21
  return @cors.preflight(request) if @cors && request.method == "OPTIONS"
22
22
 
23
- response = build_response(request)
24
- @cors ? @cors.overlay(response) : response
23
+ build_response(request)
25
24
  end
26
25
 
27
26
  private
@@ -30,7 +29,7 @@ module Wsv
30
29
  head = request.head?
31
30
 
32
31
  unless ALLOWED_METHODS.include?(request.method)
33
- return Response.text(405, headers: { "Allow" => allow_methods }, head: head)
32
+ return Response.text(405, headers: { "Allow" => allow_header }, head: head)
34
33
  end
35
34
 
36
35
  raw_path, query = request.target.split("?", 2)
@@ -51,8 +50,8 @@ module Wsv
51
50
  file_response(result.file, request, head: head)
52
51
  end
53
52
 
54
- def allow_methods
55
- @cors ? Cors::ALLOW_METHODS : "GET, HEAD"
53
+ def allow_header
54
+ (@cors&.allow_methods || ALLOWED_METHODS).join(", ")
56
55
  end
57
56
 
58
57
  def error_response(status, head:)
@@ -69,15 +68,11 @@ module Wsv
69
68
  return Response.not_modified if not_modified?(file, request.headers["if-modified-since"])
70
69
 
71
70
  size = File.size(file)
72
- range = parse_range(request.headers["range"], size)
73
- case range
74
- when :unsatisfiable
75
- Response.range_not_satisfiable(size, head: head)
76
- when nil
77
- Response.file(file, head: head)
78
- else
79
- Response.file(file, head: head, range: range)
80
- end
71
+ range = RangeRequest.parse(request.headers["range"], size)
72
+ return Response.range_not_satisfiable(size, head: head) if range.unsatisfiable?
73
+ return Response.file(file, head: head) if range.full?
74
+
75
+ Response.file(file, head: head, range: range.bounds)
81
76
  end
82
77
 
83
78
  def not_modified?(file, header_value)
@@ -89,45 +84,6 @@ module Wsv
89
84
  false
90
85
  end
91
86
 
92
- def parse_range(header_value, file_size)
93
- return nil if header_value.nil? || header_value.empty?
94
-
95
- match = header_value.match(RANGE_PATTERN)
96
- # Per RFC 7233, an unparseable Range is treated as if absent: fall
97
- # through as nil so the caller serves a normal 200 instead of 416.
98
- return nil unless match
99
-
100
- first, last = match.captures
101
- if first.nil? && last.nil?
102
- nil
103
- elsif first.nil?
104
- suffix_range(last.to_i, file_size)
105
- elsif last.nil?
106
- open_range(first.to_i, file_size)
107
- else
108
- bounded_range(first.to_i, last.to_i, file_size)
109
- end
110
- end
111
-
112
- def suffix_range(suffix, file_size)
113
- return :unsatisfiable if suffix.zero? || file_size.zero?
114
-
115
- [file_size - suffix, 0].max..(file_size - 1)
116
- end
117
-
118
- def open_range(first, file_size)
119
- return :unsatisfiable if first >= file_size
120
-
121
- first..(file_size - 1)
122
- end
123
-
124
- def bounded_range(first, last, file_size)
125
- return :unsatisfiable if first > last || first >= file_size
126
-
127
- last = file_size - 1 if last >= file_size
128
- first..last
129
- end
130
-
131
87
  def redirect_location(raw_path, query)
132
88
  path = URI(raw_path.to_s).path
133
89
  path = "/" if path.empty?
data/lib/wsv/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
 
data/lib/wsv/cors.rb CHANGED
@@ -5,15 +5,17 @@ require_relative "response"
5
5
  module Wsv
6
6
  class Cors
7
7
  ALLOW_ORIGIN = "*"
8
- ALLOW_METHODS = "GET, HEAD, OPTIONS"
8
+ ALLOW_METHODS = %w[GET HEAD OPTIONS].freeze
9
9
  MAX_AGE = "86400"
10
10
 
11
+ def allow_methods
12
+ ALLOW_METHODS
13
+ end
14
+
11
15
  def preflight(request)
12
16
  headers = {
13
- "Access-Control-Allow-Origin" => ALLOW_ORIGIN,
14
- "Access-Control-Allow-Methods" => ALLOW_METHODS,
15
- "Access-Control-Max-Age" => MAX_AGE,
16
- "Vary" => "Origin"
17
+ "Access-Control-Allow-Methods" => ALLOW_METHODS.join(", "),
18
+ "Access-Control-Max-Age" => MAX_AGE
17
19
  }
18
20
  requested = request.headers["access-control-request-headers"]
19
21
  headers["Access-Control-Allow-Headers"] = requested if requested
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wsv
4
+ # Parses an HTTP `Range` header (RFC 7233) against a known file size.
5
+ class RangeRequest
6
+ PATTERN = /\Abytes=(\d+)?-(\d+)?\z/
7
+
8
+ class Result
9
+ attr_reader :bounds
10
+
11
+ def initialize(kind:, bounds: nil)
12
+ @kind = kind
13
+ @bounds = bounds
14
+ end
15
+
16
+ def full?
17
+ @kind == :full
18
+ end
19
+
20
+ def partial?
21
+ @kind == :partial
22
+ end
23
+
24
+ def unsatisfiable?
25
+ @kind == :unsatisfiable
26
+ end
27
+
28
+ def self.full
29
+ new(kind: :full)
30
+ end
31
+
32
+ def self.partial(bounds)
33
+ new(kind: :partial, bounds: bounds)
34
+ end
35
+
36
+ def self.unsatisfiable
37
+ new(kind: :unsatisfiable)
38
+ end
39
+ end
40
+
41
+ def self.parse(header_value, file_size)
42
+ new(header_value, file_size).parse
43
+ end
44
+
45
+ def initialize(header_value, file_size)
46
+ @header_value = header_value
47
+ @file_size = file_size
48
+ end
49
+
50
+ def parse
51
+ return Result.full if @header_value.nil? || @header_value.empty?
52
+
53
+ match = @header_value.match(PATTERN)
54
+ # Per RFC 7233, an unparseable Range is treated as if absent: return
55
+ # full so the caller serves a normal 200 instead of 416.
56
+ return Result.full unless match
57
+
58
+ first, last = match.captures
59
+ if first.nil? && last.nil?
60
+ Result.full
61
+ elsif first.nil?
62
+ suffix_range(last.to_i)
63
+ elsif last.nil?
64
+ open_range(first.to_i)
65
+ else
66
+ bounded_range(first.to_i, last.to_i)
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def suffix_range(suffix)
73
+ return Result.unsatisfiable if suffix.zero? || @file_size.zero?
74
+
75
+ Result.partial([@file_size - suffix, 0].max..(@file_size - 1))
76
+ end
77
+
78
+ def open_range(first)
79
+ return Result.unsatisfiable if first >= @file_size
80
+
81
+ Result.partial(first..(@file_size - 1))
82
+ end
83
+
84
+ def bounded_range(first, last)
85
+ return Result.unsatisfiable if first > last || first >= @file_size
86
+
87
+ last = @file_size - 1 if last >= @file_size
88
+ Result.partial(first..last)
89
+ end
90
+ end
91
+ end
@@ -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
- case request
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
- write(Response.text(e.status_code))
48
+ [nil, Response.text(e.status_code)]
34
49
  rescue IO::TimeoutError
35
- write(Response.text(408))
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
- write(Response.text(400))
41
- ensure
42
- graceful_close
55
+ [nil, Response.text(400)]
43
56
  end
44
57
 
45
- def reject(reply:)
46
- write(Response.text(503)) if reply
47
- ensure
48
- 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
49
64
  end
50
65
 
51
- private
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