wsv 0.9.0 → 0.10.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: 3d932b12a288921b6935081095a656825c9efaaabb9d086350104c6d92c76d42
4
- data.tar.gz: 91c097132033a2030f4e5e9f8f4aa9c799078e7006320cbfff566ea88ae6c1b9
3
+ metadata.gz: 027bb497b4d78e1d58abd8aee91bc4349439c29425dc374b535587da67b09997
4
+ data.tar.gz: 5692790c2408ad64761358b3d33efccb439edb0e5c75150beed715bbabfc350e
5
5
  SHA512:
6
- metadata.gz: a272e137f58400e80dd58d4d32f6dd1f998ee5eb148c281d95674be1b087ea72c966ea4d3b84381b28589181d0e3e1a787accea3133e899f2dd8490c04ace055
7
- data.tar.gz: 7250c73ce5e90e6c85d598b5966bdc01467bc5aeff6869c3e63e2031a8f7c7d2baa8d873f0e347bf76d55e7e567787ba041cd3c4b790a2d192b5ff8fc806d2de
6
+ metadata.gz: 2d866a94d0f52f01e7ea50f69a2818bf2c367d704274c362863af11c71647aa80120fa72964bec31f6bfb05356ec401ced291875b9f0bc5c55f9b6b1b4b28fd8
7
+ data.tar.gz: 27cdfaa3126c02c54eeb1268fdec08fc64de82a850e2523865280048f93fbd0d95f5c906eb4da26734a80e36c98570c82ed059d71bbab0459361153a4be1fbb9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,47 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.0
4
+
5
+ - Add `--cors` flag. When set, every response carries
6
+ `Access-Control-Allow-Origin: *` and `Vary: Origin`, and `OPTIONS`
7
+ preflight requests get `204 No Content` with `Access-Control-Allow-Methods:
8
+ GET, HEAD, OPTIONS` (echoing `Access-Control-Request-Headers` when
9
+ present). Unblocks browser fetches from a frontend served on a different
10
+ port (or a Service Worker) during local development. Matches the `--cors`
11
+ convention used by `http-server`, `serve`, and `live-server`. Without the
12
+ flag, no CORS headers are added and `OPTIONS` continues to return `405`.
13
+ - Add `--open` flag that launches the OS default browser at the served URL
14
+ on startup. Uses `open` (macOS), `xdg-open` (Linux/BSD), or `cmd /c start`
15
+ (Windows). Best-effort: unsupported platforms or spawn failures are logged
16
+ but never abort the server. Wildcard binds (`0.0.0.0`, `::`) translate to
17
+ the matching loopback address for the URL.
18
+ - Custom 404 page convention: when the served directory contains a
19
+ `404.html` file, it is served as the body of every `404 Not Found`
20
+ response (`Content-Type: text/html`) instead of the built-in plain
21
+ text. Matches Jekyll / Hugo / Netlify behaviour. `403` and other
22
+ error statuses are not rewritten.
23
+ - Add `--spa` flag for single-page-app routing: a request whose path resolves
24
+ to `404` falls back to the root `index.html` so client-side routers (React
25
+ Router, Vue Router, etc.) get the SPA shell instead of a real 404. `403`
26
+ and other errors are unaffected -- dotfile and traversal blocks still
27
+ apply, and existing files at the requested path are served normally.
28
+ - Send `X-Content-Type-Options: nosniff` on every response so browsers
29
+ honour the declared `Content-Type` instead of MIME-sniffing the body.
30
+ - TLS / HTTPS support via Ruby's built-in `openssl` (no extra gem dependency).
31
+ - `--tls` enables HTTPS. Without `--cert / --key`, wsv looks for
32
+ `~/.config/wsv/cert.pem` and `~/.config/wsv/key.pem` (respecting
33
+ `XDG_CONFIG_HOME`); if neither is present, an ephemeral self-signed
34
+ certificate is generated in memory and a warning is printed.
35
+ - `--cert PATH --key PATH` uses a user-supplied PEM cert / key pair and
36
+ implies `--tls`. Specifying only one of the two is an error.
37
+ - `~/.config/wsv/` (or `$XDG_CONFIG_HOME/wsv/`) is the recommended location
38
+ for mkcert-issued certificates: `mkcert -cert-file ~/.config/wsv/cert.pem
39
+ -key-file ~/.config/wsv/key.pem localhost 127.0.0.1 ::1`.
40
+ - The HTTP scheme in the startup banner switches to `https://` when TLS is
41
+ enabled.
42
+ - The TLS handshake honours the per-request read deadline, so slow-handshake
43
+ clients cannot hold a worker beyond the configured timeout.
44
+
3
45
  ## 0.9.0
4
46
 
5
47
  - Normalize the redirect `Location` to an origin-form path. Previously, an
data/README.md CHANGED
@@ -1,9 +1,11 @@
1
1
  # wsv
2
2
 
3
- `wsv` is a minimal static web server for local previews.
3
+ `wsv` is a zero-dependency static preview server for Ruby projects.
4
4
 
5
5
  It has no runtime dependencies outside Ruby's standard library. Run `wsv` in a directory and it serves that directory over HTTP.
6
6
 
7
+ Requires Ruby 3.2 or later.
8
+
7
9
  ## Installation
8
10
 
9
11
  ```sh
@@ -14,7 +16,7 @@ For local development:
14
16
 
15
17
  ```sh
16
18
  gem build wsv.gemspec
17
- gem install ./wsv-0.1.0.gem
19
+ gem install ./wsv-*.gem
18
20
  ```
19
21
 
20
22
  ## Usage
@@ -26,8 +28,11 @@ wsv [options] [directory]
26
28
  Examples:
27
29
 
28
30
  ```sh
29
- wsv
30
- wsv public
31
+ wsv # serve current directory
32
+ wsv _site # Jekyll / Bridgetown output
33
+ wsv build # Astro / Hugo output
34
+ wsv --spa dist # Vite / esbuild / webpack SPA output
35
+ wsv --tls --open # HTTPS, open browser at startup
31
36
  wsv -h 0.0.0.0 -p 3000 ./dist
32
37
  ```
33
38
 
@@ -36,10 +41,43 @@ Options:
36
41
  ```text
37
42
  -h, --host HOST Bind host (default: 127.0.0.1)
38
43
  -p, --port PORT Bind port (default: 8000)
44
+ --tls Enable HTTPS (uses ~/.config/wsv/{cert,key}.pem if both present, else self-signed)
45
+ --cert PATH TLS certificate file (PEM); implies --tls
46
+ --key PATH TLS private key file (PEM); implies --tls
47
+ --spa Single-page-app mode: fall back to root index.html on 404
48
+ --open Open the served URL in the default browser at startup
49
+ --cors Send Access-Control-Allow-Origin: * on every response
39
50
  --help Show help
40
51
  --version Show version
41
52
  ```
42
53
 
54
+ ## TLS / HTTPS
55
+
56
+ `--tls` enables HTTPS on the chosen `--port`. Three modes:
57
+
58
+ 1. **Ephemeral self-signed** — `wsv --tls` with no cert configured: wsv
59
+ generates an in-memory self-signed certificate. Browsers will show a
60
+ security warning; click through "Advanced → Proceed" once per session.
61
+ 2. **`~/.config/wsv/` auto-detection (recommended)** — if both
62
+ `~/.config/wsv/cert.pem` and `~/.config/wsv/key.pem` exist (resolved via
63
+ `$XDG_CONFIG_HOME` if set), `--tls` uses them. If only one of the two
64
+ files is present, wsv refuses to start so the misconfiguration does not
65
+ silently fall back to a self-signed certificate. Combine with
66
+ [mkcert](https://github.com/FiloSottile/mkcert) to skip browser warnings:
67
+
68
+ ```sh
69
+ mkcert -install # one-time: register a local CA in your trust stores
70
+ mkdir -p ~/.config/wsv
71
+ mkcert -cert-file ~/.config/wsv/cert.pem \
72
+ -key-file ~/.config/wsv/key.pem \
73
+ localhost 127.0.0.1 ::1
74
+ chmod 600 ~/.config/wsv/key.pem
75
+ wsv --tls # → https://localhost:8000/ with no warning
76
+ ```
77
+
78
+ 3. **Explicit cert/key files** — `wsv --cert path/to/cert.pem --key path/to/key.pem`
79
+ for project-specific certificates. Both flags must be provided together.
80
+
43
81
  ## Behavior
44
82
 
45
83
  - Serves files from the selected directory.
@@ -50,6 +88,18 @@ Options:
50
88
  - Honours `If-Modified-Since` and returns `304 Not Modified` when applicable.
51
89
  - Rejects paths that resolve outside the served directory.
52
90
  - Sends `Cache-Control: no-cache` so the browser revalidates each request.
91
+ - With `--spa`, serves the root `index.html` instead of `404` when a path
92
+ resolves to "not found" (so client-side routers like React Router or
93
+ Vue Router work). `403` and other errors are unaffected, so dotfile and
94
+ traversal blocks still apply.
95
+ - If the served directory contains a `404.html` file, it is served as the
96
+ body of every `404 Not Found` response (with `Content-Type: text/html`)
97
+ instead of the built-in plain text. Matches the convention of Jekyll,
98
+ Hugo, and many static hosts.
99
+ - With `--cors`, every response carries `Access-Control-Allow-Origin: *`
100
+ (and `Vary: Origin`), and `OPTIONS` preflight requests get `204 No Content`
101
+ with the matching CORS headers. Lets a frontend on a different port (or a
102
+ Service Worker) fetch assets from `wsv` during local development.
53
103
 
54
104
  ## Security model
55
105
 
@@ -77,16 +127,22 @@ Within that scope it tries to behave defensively:
77
127
  (default 10s, configurable). Stalled connections receive `408`.
78
128
  - Header injection — CR/LF in response header values is rejected at
79
129
  construction time, so user-derived strings cannot inject extra headers.
130
+ - MIME sniffing — every response carries `X-Content-Type-Options: nosniff`
131
+ so browsers honour the declared `Content-Type` rather than guessing from
132
+ body contents.
80
133
  - Single-client monopolisation — connections are handled by a thread pool
81
- capped at `max_connections` (default 8). Excess clients receive `503`.
134
+ capped at `max_connections` (default 8). Excess clients receive `503`
135
+ (or are closed without response in TLS mode, since writing plaintext over
136
+ a half-handshaked TLS socket would corrupt the client's view of the
137
+ protocol).
82
138
  - Transient `accept(2)` errors — per-connection failures (`ECONNABORTED`,
83
139
  `EMFILE`, etc.) are logged and skipped instead of killing the server.
84
140
 
85
141
  ### What `wsv` does NOT do
86
142
 
87
143
  - Authentication, authorization, or rate limiting.
88
- - TLS / HTTPS.
89
144
  - HTTP keep-alive (each response sets `Connection: close`).
145
+ - HTTP/2. Use Caddy / nginx as a front proxy if you need it.
90
146
  - ETags / `If-None-Match`.
91
147
  - Production-grade DoS resistance under hostile network load.
92
148
  - Defend against TOCTOU attacks from other local processes that can write
@@ -115,11 +171,9 @@ Within a major version, `wsv` will not silently change the default bind
115
171
  host, default port, the dotfile-blocking rule, or the security posture in
116
172
  ways that would surprise an existing user.
117
173
 
118
- The Ruby classes inside `lib/wsv/` (`Wsv::Server`, `Wsv::App`,
119
- `Wsv::PathResolver`, `Wsv::Request`, `Wsv::Response`, `Wsv::MimeTypes`,
120
- `Wsv::Status`) are implementation details. They may change at any
121
- time, including in patch releases. If you want to embed `wsv` as a
122
- library, pin a specific version.
174
+ The Ruby classes inside `lib/wsv/` are implementation details. They may
175
+ change in any release, including patches. Pin the gem version if you
176
+ embed `wsv` as a library.
123
177
 
124
178
  ## License
125
179
 
data/lib/wsv/app.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "time"
4
4
  require "uri"
5
+ require_relative "cors"
5
6
  require_relative "path_resolver"
6
7
  require_relative "response"
7
8
 
@@ -10,27 +11,59 @@ module Wsv
10
11
  ALLOWED_METHODS = %w[GET HEAD].freeze
11
12
  RANGE_PATTERN = /\Abytes=(\d+)?-(\d+)?\z/
12
13
 
13
- def initialize(root)
14
+ def initialize(root, spa: false, cors: false)
14
15
  @resolver = PathResolver.new(root)
16
+ @spa = spa
17
+ @cors = Cors.new if cors
15
18
  end
16
19
 
17
20
  def call(request)
21
+ return @cors.preflight(request) if @cors && request.method == "OPTIONS"
22
+
23
+ response = build_response(request)
24
+ @cors ? @cors.overlay(response) : response
25
+ end
26
+
27
+ private
28
+
29
+ def build_response(request)
18
30
  head = request.head?
19
31
 
20
32
  unless ALLOWED_METHODS.include?(request.method)
21
- return Response.text(405, headers: { "Allow" => "GET, HEAD" }, head: head)
33
+ return Response.text(405, headers: { "Allow" => allow_methods }, head: head)
22
34
  end
23
35
 
24
36
  raw_path, query = request.target.split("?", 2)
25
37
  result = @resolver.resolve(raw_path)
26
38
 
27
- return Response.text(result.status, head: head) if result.error?
39
+ # SPA fallback: when the path resolves to 404, retry with "/" so client-side
40
+ # routes (React Router etc.) get index.html instead of a real 404. Other
41
+ # error statuses (403/400) are not rewritten, so dotfile / traversal blocks
42
+ # still take effect.
43
+ if @spa && result.error? && result.status == 404
44
+ fallback = @resolver.resolve("/")
45
+ result = fallback if fallback.file?
46
+ end
47
+
48
+ return error_response(result.status, head: head) if result.error?
28
49
  return Response.redirect(redirect_location(raw_path, query), head: head) if result.redirect?
29
50
 
30
51
  file_response(result.file, request, head: head)
31
52
  end
32
53
 
33
- private
54
+ def allow_methods
55
+ @cors ? Cors::ALLOW_METHODS : "GET, HEAD"
56
+ end
57
+
58
+ def error_response(status, head:)
59
+ if status == 404
60
+ # Custom 404 page convention: when the served root contains a `404.html`
61
+ # file, serve it as the body of any 404 response (Content-Type: text/html).
62
+ custom = @resolver.resolve("/404.html")
63
+ return Response.file(custom.file, status: 404, head: head) if custom.file?
64
+ end
65
+ Response.text(status, head: head)
66
+ end
34
67
 
35
68
  def file_response(file, request, head:)
36
69
  return Response.not_modified if not_modified?(file, request.headers["if-modified-since"])
@@ -60,6 +93,8 @@ module Wsv
60
93
  return nil if header_value.nil? || header_value.empty?
61
94
 
62
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.
63
98
  return nil unless match
64
99
 
65
100
  first, last = match.captures
data/lib/wsv/cli.rb CHANGED
@@ -21,7 +21,13 @@ module Wsv
21
21
  return 0 if options[:handled]
22
22
 
23
23
  root = resolve_root(options[:directory])
24
- server = Server.new(host: options[:host], port: options[:port], root: root, out: @out, err: @err)
24
+ tls = resolve_tls(options)
25
+ server = Server.new(
26
+ host: options[:host], port: options[:port], root: root,
27
+ out: @out, err: @err, tls: tls,
28
+ spa: options[:spa] || false, open: options[:open] || false,
29
+ cors: options[:cors] || false
30
+ )
25
31
  server.start
26
32
  0
27
33
  rescue OptionParser::ParseError, ArgumentError => e
@@ -33,14 +39,14 @@ module Wsv
33
39
  1
34
40
  end
35
41
 
36
- def parse_options(args)
42
+ def parse_options(args) # rubocop:disable Metrics/AbcSize
37
43
  options = {
38
44
  host: DEFAULT_HOST,
39
45
  port: DEFAULT_PORT,
40
46
  directory: Dir.pwd
41
47
  }
42
48
 
43
- parser = OptionParser.new do |opts|
49
+ parser = OptionParser.new do |opts| # rubocop:disable Metrics/BlockLength
44
50
  opts.banner = "Usage: wsv [options] [directory]"
45
51
 
46
52
  opts.on("-h", "--host HOST", "Bind host (default: #{DEFAULT_HOST})") do |host|
@@ -51,6 +57,30 @@ module Wsv
51
57
  options[:port] = validate_port(port)
52
58
  end
53
59
 
60
+ opts.on("--tls", "Enable HTTPS (uses ~/.config/wsv/{cert,key}.pem if both present, else self-signed)") do
61
+ options[:tls] = true
62
+ end
63
+
64
+ opts.on("--cert PATH", "TLS certificate file (PEM); implies --tls") do |path|
65
+ options[:cert] = path
66
+ end
67
+
68
+ opts.on("--key PATH", "TLS private key file (PEM); implies --tls") do |path|
69
+ options[:key] = path
70
+ end
71
+
72
+ opts.on("--spa", "Single-page-app mode: fall back to root index.html on 404") do
73
+ options[:spa] = true
74
+ end
75
+
76
+ opts.on("--open", "Open the served URL in the default browser at startup") do
77
+ options[:open] = true
78
+ end
79
+
80
+ opts.on("--cors", "Send Access-Control-Allow-Origin: * on every response") do
81
+ options[:cors] = true
82
+ end
83
+
54
84
  opts.on("--help", "Show help") do
55
85
  @out.puts opts
56
86
  options[:handled] = true
@@ -60,6 +90,14 @@ module Wsv
60
90
  @out.puts Wsv::VERSION
61
91
  options[:handled] = true
62
92
  end
93
+
94
+ opts.separator ""
95
+ opts.separator "Examples:"
96
+ opts.separator " wsv # serve current dir"
97
+ opts.separator " wsv _site # Jekyll / Bridgetown output"
98
+ opts.separator " wsv build # Astro / Hugo output"
99
+ opts.separator " wsv --spa dist # Vite / esbuild / webpack SPA output"
100
+ opts.separator " wsv --tls --open # HTTPS, open browser"
63
101
  end
64
102
 
65
103
  parser.parse!(args)
@@ -84,5 +122,13 @@ module Wsv
84
122
 
85
123
  port
86
124
  end
125
+
126
+ def resolve_tls(options)
127
+ return nil unless options[:tls] || options[:cert] || options[:key]
128
+
129
+ TlsContext::Resolver.resolve(cert_path: options[:cert], key_path: options[:key])
130
+ rescue OpenSSL::OpenSSLError => e
131
+ raise ArgumentError, "TLS configuration error: #{e.message}"
132
+ end
87
133
  end
88
134
  end
data/lib/wsv/cors.rb ADDED
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "response"
4
+
5
+ module Wsv
6
+ class Cors
7
+ ALLOW_ORIGIN = "*"
8
+ ALLOW_METHODS = "GET, HEAD, OPTIONS"
9
+ MAX_AGE = "86400"
10
+
11
+ def preflight(request)
12
+ 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
+ }
18
+ requested = request.headers["access-control-request-headers"]
19
+ headers["Access-Control-Allow-Headers"] = requested if requested
20
+ Response.new(status: 204, headers: headers)
21
+ end
22
+
23
+ def overlay(response)
24
+ response.with_headers(
25
+ "Access-Control-Allow-Origin" => ALLOW_ORIGIN,
26
+ "Vary" => "Origin"
27
+ )
28
+ end
29
+ end
30
+ end
@@ -50,6 +50,12 @@ module Wsv
50
50
  decoded = decode(raw_path)
51
51
  return Result.error(400) unless decoded
52
52
 
53
+ # Layered defense:
54
+ # 1. URL-level: reject `..` traversal and dotfile segments before
55
+ # touching the filesystem.
56
+ # 2. realpath-level: re-check after symlinks resolve, so an internal
57
+ # symlink cannot smuggle access to outside-root paths or to
58
+ # `.git/` / `.env` etc. via a non-dotfile-looking URL.
53
59
  relative = decoded.sub(%r{\A/+}, "")
54
60
  return Result.error(403) if hidden_segment?(relative)
55
61
 
@@ -13,7 +13,7 @@ module Wsv
13
13
  end
14
14
 
15
15
  def parse
16
- line = @io.gets(REQUEST_LINE_LIMIT)
16
+ line = @io.gets("\n", REQUEST_LINE_LIMIT)
17
17
  return :empty unless line
18
18
  raise TooLarge, 414 if line.bytesize >= REQUEST_LINE_LIMIT && !line.end_with?("\n")
19
19
 
@@ -30,7 +30,7 @@ module Wsv
30
30
  headers = {}
31
31
  total = 0
32
32
  count = 0
33
- while (line = @io.gets(HEADER_LINE_LIMIT))
33
+ while (line = @io.gets("\n", HEADER_LINE_LIMIT))
34
34
  raise TooLarge, 431 if line.bytesize >= HEADER_LINE_LIMIT && !line.end_with?("\n")
35
35
 
36
36
  stripped = line.delete_suffix("\r\n").delete_suffix("\n").delete_suffix("\r")
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wsv
4
+ class Request
5
+ # Raised by Request::Parser when the incoming request line, an individual
6
+ # header line, the total header bytes, or the header count exceeds the
7
+ # configured limit. The status_code chooses between 414 (URI Too Long)
8
+ # and 431 (Request Header Fields Too Large) at the call site.
9
+ class TooLarge < StandardError
10
+ attr_reader :status_code
11
+
12
+ def initialize(status_code)
13
+ super("request exceeded size limit (#{status_code})")
14
+ @status_code = status_code
15
+ end
16
+ end
17
+ end
18
+ end
data/lib/wsv/request.rb CHANGED
@@ -1,18 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "request/too_large"
3
4
  require_relative "request/parser"
4
5
 
5
6
  module Wsv
6
7
  class Request
7
- class TooLarge < StandardError
8
- attr_reader :status_code
9
-
10
- def initialize(status_code)
11
- super("request exceeded size limit (#{status_code})")
12
- @status_code = status_code
13
- end
14
- end
15
-
16
8
  attr_reader :method, :target, :version, :headers
17
9
 
18
10
  def initialize(method:, target:, version:, headers:)
@@ -6,17 +6,18 @@ require_relative "../mime_types"
6
6
  module Wsv
7
7
  class Response
8
8
  class FileBuilder
9
- def initialize(path, head: false, range: nil)
9
+ def initialize(path, head: false, range: nil, status: 200)
10
10
  @path = path
11
11
  @head = head
12
12
  @range = range
13
+ @status = status
13
14
  end
14
15
 
15
16
  def build
16
17
  if @range
17
18
  Response.new(status: 206, headers: range_headers, body: range_body)
18
19
  else
19
- Response.new(status: 200, headers: full_headers, body: full_body)
20
+ Response.new(status: @status, headers: full_headers, body: full_body)
20
21
  end
21
22
  end
22
23
 
data/lib/wsv/response.rb CHANGED
@@ -31,10 +31,19 @@ module Wsv
31
31
  Status.reason(status)
32
32
  end
33
33
 
34
+ # Returns a new Response with `extra` merged into the headers, sharing the
35
+ # same body object so streaming (FileBody) is preserved.
36
+ def with_headers(extra)
37
+ self.class.new(status: @status, headers: @headers.merge(extra), body: @body)
38
+ end
39
+
34
40
  def write_to(io)
35
41
  io.write "HTTP/1.1 #{status} #{reason}\r\n"
36
42
  io.write "Server: #{SERVER_NAME}\r\n"
37
43
  io.write "Connection: close\r\n"
44
+ unless headers.any? { |name, _value| name.to_s.casecmp?("X-Content-Type-Options") }
45
+ io.write "X-Content-Type-Options: nosniff\r\n"
46
+ end
38
47
  headers.each { |name, value| io.write "#{name}: #{value}\r\n" }
39
48
  io.write "\r\n"
40
49
  @body.write_to(io)
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wsv
4
+ class Server
5
+ # Renders the startup announcement (the "Serving / Bind / Local / Stop"
6
+ # block plus warnings about non-loopback binds and self-signed certs).
7
+ class Banner
8
+ def initialize(host:, port:, root:, out:, err:, tls:)
9
+ @host = host
10
+ @port = port
11
+ @root = root
12
+ @out = out
13
+ @err = err
14
+ @tls = tls
15
+ end
16
+
17
+ def emit
18
+ @out.puts "Serving: #{@root}"
19
+ @out.puts "Bind: #{url_for(@host)}"
20
+ @out.puts "Local: #{url_for('127.0.0.1')}" unless localhost?(@host)
21
+ @out.puts "Stop: Ctrl-C"
22
+ warn_public_bind unless localhost?(@host)
23
+ warn_ephemeral_cert if @tls&.ephemeral?
24
+ end
25
+
26
+ private
27
+
28
+ def warn_public_bind
29
+ @err.puts "WARNING: binding to #{@host} exposes #{@root} on your network."
30
+ @err.puts " Pass --host 127.0.0.1 (or omit --host) for local-only access."
31
+ end
32
+
33
+ def warn_ephemeral_cert
34
+ @err.puts "WARNING: serving with a self-signed certificate. Browsers will"
35
+ @err.puts " show a security warning. Pass --cert / --key for a real cert."
36
+ end
37
+
38
+ def url_for(display_host)
39
+ "#{scheme}://#{format_host(display_host)}:#{@port}/"
40
+ end
41
+
42
+ def format_host(host)
43
+ # Bracket IPv6 literals per RFC 3986; zone IDs (`%eth0` etc.) need %25 per RFC 6874.
44
+ host.include?(":") ? "[#{host.gsub('%', '%25')}]" : host
45
+ end
46
+
47
+ def scheme
48
+ @tls ? "https" : "http"
49
+ end
50
+
51
+ def localhost?(display_host)
52
+ ["127.0.0.1", "localhost", "::1"].include?(display_host)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ module Wsv
6
+ class Server
7
+ # Launches the OS default browser at the served URL when `--open` is set.
8
+ # Best-effort: unsupported platforms or spawn failures are logged but
9
+ # never abort the server.
10
+ class BrowserLauncher
11
+ def initialize(host:, port:, tls:, err:)
12
+ @host = host
13
+ @port = port
14
+ @tls = tls
15
+ @err = err
16
+ end
17
+
18
+ def launch
19
+ command = platform_command
20
+ unless command
21
+ @err.puts "wsv: --open is not supported on this platform; skipping."
22
+ return
23
+ end
24
+
25
+ pid = Process.spawn(*command, url, in: :close, out: File::NULL, err: File::NULL)
26
+ Process.detach(pid)
27
+ rescue StandardError => e
28
+ @err.puts "wsv: failed to open browser: #{e.message}"
29
+ end
30
+
31
+ private
32
+
33
+ def url
34
+ scheme = @tls ? "https" : "http"
35
+ "#{scheme}://#{url_host}:#{@port}/"
36
+ end
37
+
38
+ def url_host
39
+ host = display_host
40
+ # IPv6 literals must be bracketed in URLs per RFC 3986. Scoped IPv6
41
+ # zone identifiers use `%`, which must be percent-encoded in URLs.
42
+ host.include?(":") ? "[#{host.gsub('%', '%25')}]" : host
43
+ end
44
+
45
+ def display_host
46
+ # Wildcard binds aren't reachable; redirect to the matching loopback.
47
+ case @host
48
+ when "0.0.0.0" then "127.0.0.1"
49
+ when "::" then "::1"
50
+ else @host
51
+ end
52
+ end
53
+
54
+ def platform_command
55
+ case RbConfig::CONFIG["host_os"]
56
+ when /darwin/ then ["open"]
57
+ when /linux|bsd/ then ["xdg-open"]
58
+ when /mswin|mingw|cygwin/ then ["cmd.exe", "/c", "start", ""]
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wsv
4
+ class Server
5
+ # Wraps an IO with a shared deadline so each subsequent read is bounded by
6
+ # the time remaining until the deadline. Used to enforce a single budget
7
+ # across the request line and all header lines.
8
+ class DeadlineReader
9
+ def initialize(io, deadline)
10
+ @io = io
11
+ @deadline = deadline
12
+ end
13
+
14
+ def gets(eol, limit)
15
+ remaining = @deadline - Time.now
16
+ raise IO::TimeoutError if remaining <= 0
17
+
18
+ @io.to_io.timeout = remaining
19
+ @io.gets(eol, limit)
20
+ end
21
+ end
22
+ end
23
+ end