wsv 0.10.0 → 0.10.1

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: 027bb497b4d78e1d58abd8aee91bc4349439c29425dc374b535587da67b09997
4
- data.tar.gz: 5692790c2408ad64761358b3d33efccb439edb0e5c75150beed715bbabfc350e
3
+ metadata.gz: 782b303e2d6eeb0f45f6b3a012523b94611a4991d59ef6669768b21936f47a8e
4
+ data.tar.gz: 25b99b347007975bac86ccd93c212f5fb0bc85522054c36badac601bae5f3638
5
5
  SHA512:
6
- metadata.gz: 2d866a94d0f52f01e7ea50f69a2818bf2c367d704274c362863af11c71647aa80120fa72964bec31f6bfb05356ec401ced291875b9f0bc5c55f9b6b1b4b28fd8
7
- data.tar.gz: 27cdfaa3126c02c54eeb1268fdec08fc64de82a850e2523865280048f93fbd0d95f5c906eb4da26734a80e36c98570c82ed059d71bbab0459361153a4be1fbb9
6
+ metadata.gz: b3619a62d69f29a3498081d8174a0a01c61484ede73f9875de9867b4a4dd451f639dcbe0bee99fb3bea00b78c0ba289618ab7eeec3b066705d8ca437a9cea134
7
+ data.tar.gz: c58379c2eab12176f492d993d1e194dc0f3a33c16ba7d8736134498e4d13ce8031816a5d22688f1639c0ea3127c5787ad4c235874b20929490eac52881c3e9b0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.1
4
+
5
+ - Update gem description and README tagline to position wsv as
6
+ "Defensive by design" — surfaces the actual security defaults on
7
+ RubyGems.org and `gem search` (the 0.10.0 description still said
8
+ "tiny static web server").
9
+ - Refactor `Wsv::Server` (~250 → 150 lines): per-connection lifecycle
10
+ is now `Server::Connection` (`#serve` / `#reject` + safe write /
11
+ drain / close), concurrency cap is `Server::ConnectionThrottle`
12
+ (`#try_spawn`). Behavior unchanged.
13
+ - Extract `Server::UrlHost.format` so `Banner` and `BrowserLauncher`
14
+ share the IPv6 bracketing / RFC 6874 zone-id encoding rule (was
15
+ duplicated in two places, fixed in tandem once already).
16
+ - Simplify `Response::FileBuilder` body construction: the HEAD guard
17
+ and range/full body branch collapse into a single method.
18
+ - Move executable from `bin/wsv` to `exe/wsv` per Bundler convention.
19
+ - Various README polish: Gemfile install path first, Examples for
20
+ Jekyll / Astro / Vite, GHFM `> [!WARNING]` for the prod-use caveat.
21
+
3
22
  ## 0.10.0
4
23
 
5
24
  - Add `--cors` flag. When set, every response carries
data/README.md CHANGED
@@ -1,22 +1,27 @@
1
1
  # wsv
2
2
 
3
- `wsv` is a zero-dependency static preview server for Ruby projects.
3
+ `wsv` is a zero-dependency static preview server for Ruby projects. Defensive by design: blocks dotfiles and binds to loopback by default.
4
4
 
5
- It has no runtime dependencies outside Ruby's standard library. Run `wsv` in a directory and it serves that directory over HTTP.
5
+ It has no runtime dependencies outside Ruby's standard library. Run `wsv` in a directory and it serves that directory over HTTP/HTTPS.
6
6
 
7
7
  Requires Ruby 3.2 or later.
8
8
 
9
9
  ## Installation
10
10
 
11
- ```sh
12
- gem install wsv
11
+ Add to your Gemfile:
12
+
13
+ ```ruby
14
+ group :development do
15
+ gem "wsv"
16
+ end
13
17
  ```
14
18
 
15
- For local development:
19
+ Then run `bundle install` and start with `bundle exec wsv`.
20
+
21
+ Or install globally:
16
22
 
17
23
  ```sh
18
- gem build wsv.gemspec
19
- gem install ./wsv-*.gem
24
+ gem install wsv
20
25
  ```
21
26
 
22
27
  ## Usage
@@ -55,10 +60,10 @@ Options:
55
60
 
56
61
  `--tls` enables HTTPS on the chosen `--port`. Three modes:
57
62
 
58
- 1. **Ephemeral self-signed** `wsv --tls` with no cert configured: wsv
63
+ 1. Ephemeral self-signed: `wsv --tls` with no cert configured: wsv
59
64
  generates an in-memory self-signed certificate. Browsers will show a
60
65
  security warning; click through "Advanced → Proceed" once per session.
61
- 2. **`~/.config/wsv/` auto-detection (recommended)** if both
66
+ 2. `~/.config/wsv/` auto-detection (recommended): if both
62
67
  `~/.config/wsv/cert.pem` and `~/.config/wsv/key.pem` exist (resolved via
63
68
  `$XDG_CONFIG_HOME` if set), `--tls` uses them. If only one of the two
64
69
  files is present, wsv refuses to start so the misconfiguration does not
@@ -75,7 +80,7 @@ Options:
75
80
  wsv --tls # → https://localhost:8000/ with no warning
76
81
  ```
77
82
 
78
- 3. **Explicit cert/key files** `wsv --cert path/to/cert.pem --key path/to/key.pem`
83
+ 3. Explicit cert/key files: `wsv --cert path/to/cert.pem --key path/to/key.pem`
79
84
  for project-specific certificates. Both flags must be provided together.
80
85
 
81
86
  ## Behavior
@@ -103,7 +108,9 @@ Options:
103
108
 
104
109
  ## Security model
105
110
 
106
- `wsv` is intended for **local development previews, not for production or internet-facing use**.
111
+ > [!WARNING]
112
+ > `wsv` is intended for local development previews, not for production or internet-facing use.
113
+
107
114
  Within that scope it tries to behave defensively:
108
115
 
109
116
  ### What `wsv` protects against
@@ -162,8 +169,7 @@ If you need any of the above, use a real production server.
162
169
  `wsv` follows [Semantic Versioning](https://semver.org/). The public API
163
170
  that SemVer covers is the CLI:
164
171
 
165
- - The flags listed above (`-h` / `--host`, `-p` / `--port`, `--help`,
166
- `--version`) and their meanings.
172
+ - The flags listed in Options above and their meanings.
167
173
  - The directory argument and the default behaviour when it is omitted.
168
174
  - Process exit codes (`0` for success, `1` for usage / setup errors).
169
175
 
@@ -15,9 +15,9 @@ module Wsv
15
15
 
16
16
  def build
17
17
  if @range
18
- Response.new(status: 206, headers: range_headers, body: range_body)
18
+ Response.new(status: 206, headers: range_headers, body: body)
19
19
  else
20
- Response.new(status: @status, headers: full_headers, body: full_body)
20
+ Response.new(status: @status, headers: full_headers, body: body)
21
21
  end
22
22
  end
23
23
 
@@ -47,17 +47,12 @@ module Wsv
47
47
  base_headers.merge("Content-Length" => size.to_s)
48
48
  end
49
49
 
50
- def range_body
50
+ def body
51
51
  return StringBody.new("") if @head
52
+ return FileBody.new(@path) unless @range
52
53
 
53
54
  FileBody.new(@path, offset: @range.begin, length: @range.size)
54
55
  end
55
-
56
- def full_body
57
- return StringBody.new("") if @head
58
-
59
- FileBody.new(@path)
60
- end
61
56
  end
62
57
  end
63
58
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "url_host"
4
+
3
5
  module Wsv
4
6
  class Server
5
7
  # Renders the startup announcement (the "Serving / Bind / Local / Stop"
@@ -36,12 +38,7 @@ module Wsv
36
38
  end
37
39
 
38
40
  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
41
+ "#{scheme}://#{UrlHost.format(display_host)}:#{@port}/"
45
42
  end
46
43
 
47
44
  def scheme
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rbconfig"
4
+ require_relative "url_host"
4
5
 
5
6
  module Wsv
6
7
  class Server
@@ -32,14 +33,7 @@ module Wsv
32
33
 
33
34
  def url
34
35
  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
36
+ "#{scheme}://#{UrlHost.format(display_host)}:#{@port}/"
43
37
  end
44
38
 
45
39
  def display_host
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "deadline_reader"
4
+ require_relative "../request"
5
+ require_relative "../response"
6
+
7
+ module Wsv
8
+ class Server
9
+ # Owns a single accepted client socket. `serve` runs the request lifecycle
10
+ # (parse → app → write → drain → close); `reject` writes 503 (when allowed)
11
+ # and closes. Both share the safe-write / drain / close primitives so a
12
+ # broken peer cannot leak a connection or mask errors.
13
+ class Connection
14
+ DRAIN_TIMEOUT = 5
15
+
16
+ def initialize(client, err:)
17
+ @client = client
18
+ @err = err
19
+ end
20
+
21
+ def serve(app, read_timeout:)
22
+ reader = DeadlineReader.new(@client, Time.now + read_timeout)
23
+ 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
32
+ rescue Request::TooLarge => e
33
+ write(Response.text(e.status_code))
34
+ rescue IO::TimeoutError
35
+ write(Response.text(408))
36
+ rescue StandardError => e
37
+ # Treat unmapped failures as connection-scoped and close with 400 rather
38
+ # than letting one bad request path bring down the server.
39
+ @err.puts "wsv: #{e.class}: #{e.message}"
40
+ write(Response.text(400))
41
+ ensure
42
+ graceful_close
43
+ end
44
+
45
+ def reject(reply:)
46
+ write(Response.text(503)) if reply
47
+ ensure
48
+ graceful_close
49
+ end
50
+
51
+ private
52
+
53
+ def write(response)
54
+ return if @client.closed?
55
+
56
+ response.write_to(@client)
57
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError
58
+ nil
59
+ end
60
+
61
+ def graceful_close
62
+ return if @client.closed?
63
+
64
+ drain_recv
65
+ rescue StandardError
66
+ nil
67
+ ensure
68
+ begin
69
+ @client.close unless @client.closed?
70
+ rescue StandardError
71
+ nil
72
+ end
73
+ end
74
+
75
+ def drain_recv
76
+ deadline = Time.now + DRAIN_TIMEOUT
77
+ loop do
78
+ return if Time.now >= deadline
79
+
80
+ chunk = @client.read_nonblock(8192, exception: false)
81
+ case chunk
82
+ when nil, :wait_writable
83
+ # nil = EOF. :wait_writable can come back from SSLSocket during a
84
+ # renegotiation (read needs an underlying write). Either way,
85
+ # there's nothing more we can usefully drain right now.
86
+ return
87
+ when :wait_readable
88
+ remaining = deadline - Time.now
89
+ return if remaining <= 0
90
+ return unless @client.wait_readable([remaining, 0.2].min)
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wsv
4
+ class Server
5
+ # Caps in-flight connections at `max`. `try_spawn` runs the block in a new
6
+ # thread when capacity is available and returns true; otherwise returns
7
+ # false so the caller can reject the client.
8
+ class ConnectionThrottle
9
+ def initialize(max:, err:)
10
+ @max = max
11
+ @err = err
12
+ @mutex = Mutex.new
13
+ @active = 0
14
+ end
15
+
16
+ def try_spawn(&block)
17
+ return false unless reserve_slot
18
+
19
+ begin
20
+ Thread.new do
21
+ Thread.current.report_on_exception = false
22
+ block.call
23
+ ensure
24
+ release_slot
25
+ end
26
+ true
27
+ rescue ThreadError => e
28
+ @err.puts "wsv: thread error: #{e.message}"
29
+ release_slot
30
+ false
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def reserve_slot
37
+ @mutex.synchronize do
38
+ next false if @active >= @max
39
+
40
+ @active += 1
41
+ true
42
+ end
43
+ end
44
+
45
+ def release_slot
46
+ @mutex.synchronize { @active -= 1 }
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wsv
4
+ class Server
5
+ # Formats a host for inclusion in a URL. IPv6 literals are bracketed
6
+ # (RFC 3986); zone identifiers (`%eth0` etc.) are percent-encoded
7
+ # (RFC 6874).
8
+ module UrlHost
9
+ module_function
10
+
11
+ def format(host)
12
+ host.include?(":") ? "[#{host.gsub('%', '%25')}]" : host
13
+ end
14
+ end
15
+ end
16
+ end
data/lib/wsv/server.rb CHANGED
@@ -3,17 +3,15 @@
3
3
  require "openssl"
4
4
  require "socket"
5
5
  require_relative "app"
6
- require_relative "request"
7
- require_relative "response"
8
6
  require_relative "server/banner"
9
7
  require_relative "server/browser_launcher"
10
- require_relative "server/deadline_reader"
8
+ require_relative "server/connection"
9
+ require_relative "server/connection_throttle"
11
10
 
12
11
  module Wsv
13
12
  class Server
14
13
  DEFAULT_READ_TIMEOUT = 10
15
14
  DEFAULT_MAX_CONNECTIONS = 8
16
- DRAIN_TIMEOUT = 5
17
15
 
18
16
  attr_reader :host, :port, :root
19
17
 
@@ -36,14 +34,12 @@ module Wsv
36
34
  @out = out
37
35
  @err = err
38
36
  @read_timeout = read_timeout
39
- @max_connections = max_connections
40
37
  @tls = tls
41
38
  @ssl_context = tls&.to_ssl_context
42
39
  @open = open
43
40
  @app = App.new(@root, spa: spa, cors: cors)
41
+ @throttle = ConnectionThrottle.new(max: max_connections, err: err)
44
42
  @running = false
45
- @mutex = Mutex.new
46
- @active = 0
47
43
  end
48
44
 
49
45
  def start
@@ -62,74 +58,8 @@ module Wsv
62
58
  close
63
59
  end
64
60
 
65
- def handle(client)
66
- reader = DeadlineReader.new(client, Time.now + @read_timeout)
67
- request = Request.parse(reader)
68
- case request
69
- when :empty
70
- nil
71
- when :malformed
72
- write_response(client, Response.text(400))
73
- else
74
- write_response(client, @app.call(request))
75
- end
76
- rescue Request::TooLarge => e
77
- write_response(client, Response.text(e.status_code))
78
- rescue IO::TimeoutError
79
- write_response(client, Response.text(408))
80
- rescue StandardError => e
81
- # Treat unmapped failures as connection-scoped and close with 400 rather
82
- # than letting one bad request path bring down the server.
83
- @err.puts "wsv: #{e.class}: #{e.message}"
84
- write_response(client, Response.text(400))
85
- ensure
86
- graceful_close(client)
87
- end
88
-
89
61
  private
90
62
 
91
- def write_response(client, response)
92
- return if client.closed?
93
-
94
- response.write_to(client)
95
- rescue Errno::EPIPE, Errno::ECONNRESET, IOError
96
- nil
97
- end
98
-
99
- def graceful_close(client)
100
- return if client.closed?
101
-
102
- drain_recv(client)
103
- rescue StandardError
104
- nil
105
- ensure
106
- begin
107
- client.close unless client.closed?
108
- rescue StandardError
109
- nil
110
- end
111
- end
112
-
113
- def drain_recv(client)
114
- deadline = Time.now + DRAIN_TIMEOUT
115
- loop do
116
- return if Time.now >= deadline
117
-
118
- chunk = client.read_nonblock(8192, exception: false)
119
- case chunk
120
- when nil, :wait_writable
121
- # nil = EOF. :wait_writable can come back from SSLSocket during a
122
- # renegotiation (read needs an underlying write). Either way,
123
- # there's nothing more we can usefully drain right now.
124
- return
125
- when :wait_readable
126
- remaining = deadline - Time.now
127
- return if remaining <= 0
128
- return unless client.wait_readable([remaining, 0.2].min)
129
- end
130
- end
131
- end
132
-
133
63
  def accept_loop
134
64
  while @running
135
65
  client = nil
@@ -157,38 +87,25 @@ module Wsv
157
87
  end
158
88
 
159
89
  def spawn_handler(client)
160
- accepted = @mutex.synchronize do
161
- next false if @active >= @max_connections
162
-
163
- @active += 1
164
- true
165
- end
166
-
167
- return spawn_rejection(client) unless accepted
168
-
169
- begin
170
- Thread.new do
171
- Thread.current.report_on_exception = false
172
- handle(maybe_wrap_tls(client))
173
- ensure
174
- @mutex.synchronize { @active -= 1 }
175
- end
176
- rescue ThreadError => e
177
- @err.puts "wsv: thread error: #{e.message}"
178
- @mutex.synchronize { @active -= 1 }
179
- spawn_rejection(client)
90
+ accepted = @throttle.try_spawn do
91
+ Connection.new(maybe_wrap_tls(client), err: @err).serve(@app, read_timeout: @read_timeout)
180
92
  end
93
+ spawn_rejection(client) unless accepted
181
94
  end
182
95
 
183
96
  # Reject in a separate thread so a slow client cannot block accept_loop
184
- # via graceful_close's drain_recv (up to DRAIN_TIMEOUT seconds).
97
+ # via Connection#graceful_close (up to Connection::DRAIN_TIMEOUT seconds).
98
+ # In TLS mode `client` is the raw TCPSocket before any handshake; writing
99
+ # a plaintext 503 would corrupt the TLS handshake the client is about to
100
+ # start, so suppress the reply in that case.
185
101
  def spawn_rejection(client)
102
+ reply = !@ssl_context
186
103
  Thread.new do
187
104
  Thread.current.report_on_exception = false
188
- reject(client)
105
+ Connection.new(client, err: @err).reject(reply: reply)
189
106
  end
190
107
  rescue ThreadError
191
- reject(client)
108
+ Connection.new(client, err: @err).reject(reply: reply)
192
109
  end
193
110
 
194
111
  def maybe_wrap_tls(client)
@@ -200,7 +117,7 @@ module Wsv
200
117
  ssl.accept
201
118
  ssl
202
119
  rescue StandardError
203
- # If wrapping or the handshake failed, `handle` is never called and
120
+ # If wrapping or the handshake failed, `serve` is never called and
204
121
  # its ensure does not get a chance to close the underlying socket.
205
122
  # Close it here so we do not leak a TCPSocket per failed handshake.
206
123
  begin
@@ -211,15 +128,6 @@ module Wsv
211
128
  raise
212
129
  end
213
130
 
214
- def reject(client)
215
- # In TLS mode `client` is the raw TCPSocket before any handshake.
216
- # Writing a plaintext 503 over it would corrupt the TLS handshake
217
- # the client is about to start, so just close in that case.
218
- write_response(client, Response.text(503)) unless @ssl_context
219
- ensure
220
- graceful_close(client)
221
- end
222
-
223
131
  def close
224
132
  @server&.close unless @server&.closed?
225
133
  end
data/lib/wsv/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wsv
4
- VERSION = "0.10.0"
4
+ VERSION = "0.10.1"
5
5
  end
data/wsv.gemspec CHANGED
@@ -8,15 +8,16 @@ Gem::Specification.new do |spec|
8
8
  spec.authors = ["takahashim"]
9
9
  spec.email = ["takahashimm@gmail.com"]
10
10
 
11
- spec.summary = "A tiny static web server for local previews."
12
- spec.description = "wsv serves a local directory over HTTP from a zero-config CLI."
13
- spec.homepage = "https://rubygems.org/gems/wsv"
11
+ spec.summary = "A zero-dependency static preview server for Ruby projects."
12
+ spec.description = "wsv is a Ruby CLI that previews a directory over HTTP/HTTPS. " \
13
+ "Stdlib-only, no runtime dependencies. Defensive by design: " \
14
+ "blocks dotfiles, binds to loopback, ships with TLS and CORS."
15
+ spec.homepage = "https://github.com/takahashim/wsv"
14
16
  spec.license = "MIT"
15
17
  spec.required_ruby_version = ">= 3.2"
16
18
 
17
19
  spec.metadata = {
18
20
  "homepage_uri" => spec.homepage,
19
- "source_code_uri" => "https://github.com/takahashim/wsv",
20
21
  "changelog_uri" => "https://github.com/takahashim/wsv/blob/main/CHANGELOG.md",
21
22
  "rubygems_mfa_required" => "true"
22
23
  }
@@ -25,12 +26,12 @@ Gem::Specification.new do |spec|
25
26
  "CHANGELOG.md",
26
27
  "LICENSE.txt",
27
28
  "README.md",
28
- "bin/wsv",
29
+ "exe/wsv",
29
30
  "lib/**/*.rb",
30
31
  "test/**/*.rb",
31
32
  "wsv.gemspec"
32
33
  ]
33
- spec.bindir = "bin"
34
+ spec.bindir = "exe"
34
35
  spec.executables = ["wsv"]
35
36
  spec.require_paths = ["lib"]
36
37
  end
metadata CHANGED
@@ -1,15 +1,17 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wsv
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.10.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - takahashim
8
- bindir: bin
8
+ bindir: exe
9
9
  cert_chain: []
10
10
  date: 2026-05-07 00:00:00.000000000 Z
11
11
  dependencies: []
12
- description: wsv serves a local directory over HTTP from a zero-config CLI.
12
+ description: 'wsv is a Ruby CLI that previews a directory over HTTP/HTTPS. Stdlib-only,
13
+ no runtime dependencies. Defensive by design: blocks dotfiles, binds to loopback,
14
+ ships with TLS and CORS.'
13
15
  email:
14
16
  - takahashimm@gmail.com
15
17
  executables:
@@ -20,7 +22,7 @@ files:
20
22
  - CHANGELOG.md
21
23
  - LICENSE.txt
22
24
  - README.md
23
- - bin/wsv
25
+ - exe/wsv
24
26
  - lib/wsv.rb
25
27
  - lib/wsv/app.rb
26
28
  - lib/wsv/cli.rb
@@ -38,7 +40,10 @@ files:
38
40
  - lib/wsv/server.rb
39
41
  - lib/wsv/server/banner.rb
40
42
  - lib/wsv/server/browser_launcher.rb
43
+ - lib/wsv/server/connection.rb
44
+ - lib/wsv/server/connection_throttle.rb
41
45
  - lib/wsv/server/deadline_reader.rb
46
+ - lib/wsv/server/url_host.rb
42
47
  - lib/wsv/status.rb
43
48
  - lib/wsv/tls_context.rb
44
49
  - lib/wsv/tls_context/resolver.rb
@@ -54,12 +59,11 @@ files:
54
59
  - test/test_helper.rb
55
60
  - test/tls_context_test.rb
56
61
  - wsv.gemspec
57
- homepage: https://rubygems.org/gems/wsv
62
+ homepage: https://github.com/takahashim/wsv
58
63
  licenses:
59
64
  - MIT
60
65
  metadata:
61
- homepage_uri: https://rubygems.org/gems/wsv
62
- source_code_uri: https://github.com/takahashim/wsv
66
+ homepage_uri: https://github.com/takahashim/wsv
63
67
  changelog_uri: https://github.com/takahashim/wsv/blob/main/CHANGELOG.md
64
68
  rubygems_mfa_required: 'true'
65
69
  rdoc_options: []
@@ -78,5 +82,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
78
82
  requirements: []
79
83
  rubygems_version: 3.6.2
80
84
  specification_version: 4
81
- summary: A tiny static web server for local previews.
85
+ summary: A zero-dependency static preview server for Ruby projects.
82
86
  test_files: []
/data/{bin → exe}/wsv RENAMED
File without changes