Module: Rakit::StaticWebServer

Defined in:
lib/rakit/static_web_server.rb

Overview

Publish static sites into a configurable root (~/.rakit/wwwroot), regenerate a root index, and control a local HTTP server (start/stop/running). Host and port are configurable (host default 127.0.0.1, port 5099) and returned regardless of server state for URL building. CLI: rakit static-web-server (start|stop|running|publish|view). See specs/003-static-web-server/contracts/ruby-api.md and specs/007-wwwroot-docs-hugo-view/contracts/ruby-api.md.

Constant Summary collapse

SITE_NAME_REGEX =
/\A[a-z0-9\-]+\z/.freeze
STOP_TIMEOUT =
5

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.hostString

Returns Host for building URLs (default 127.0.0.1). Returned regardless of server running state.

Returns:

  • Host for building URLs (default 127.0.0.1). Returned regardless of server running state.



17
18
19
# File 'lib/rakit/static_web_server.rb', line 17

def host
  @host
end

.portInteger

Returns Default port for start (default 5099). Used when no override passed to start; returned regardless of server running state.

Returns:

  • Default port for start (default 5099). Used when no override passed to start; returned regardless of server running state.



19
20
21
# File 'lib/rakit/static_web_server.rb', line 19

def port
  @port
end

.rootString

Returns Static root directory (default ~/.rakit/wwwroot). Used by publish and server lifecycle.

Returns:

  • Static root directory (default ~/.rakit/wwwroot). Used by publish and server lifecycle.



15
16
17
# File 'lib/rakit/static_web_server.rb', line 15

def root
  @root
end

Class Method Details

._collect_index_paths(dir, rel, out) ⇒ Object

Recursively find relative paths under dir (relative to root) that contain index.html or index.xml.



198
199
200
201
202
203
204
205
206
207
208
209
# File 'lib/rakit/static_web_server.rb', line 198

def self._collect_index_paths(dir, rel, out)
  Dir.entries(dir).sort.each do |e|
    next if e == "." || e == ".." || e.start_with?(".")
    full = ::File.join(dir, e)
    next unless ::File.directory?(full)
    sub_rel = rel.empty? ? e : "#{rel}/#{e}"
    index_html = ::File.join(full, "index.html")
    index_xml = ::File.join(full, "index.xml")
    out << "#{sub_rel}/" if ::File.file?(index_html) || ::File.file?(index_xml)
    _collect_index_paths(full, sub_rel, out)
  end
end

.ensure_rootObject

T008: Create root directory if missing; used before publish and start.



61
62
63
# File 'lib/rakit/static_web_server.rb', line 61

def self.ensure_root
  FileUtils.mkdir_p(root)
end

.pid_file_pathObject

T006: PID file path for start/stop/running (research: ~/.rakit/static_web_server.pid).



43
44
45
# File 'lib/rakit/static_web_server.rb', line 43

def self.pid_file_path
  ::File.expand_path("~/.rakit/static_web_server.pid")
end

.publish(site_name, source_directory) ⇒ true

Publish static content from source_directory to root/site_name (atomic copy), then regenerate root index.

Parameters:

  • Single segment (e.g. “mysite”) or path (e.g. “louparslow/rakit”); each segment must match \A+\z.

  • Existing directory path; contents are copied (no traversal outside allowed paths).

Returns:

  • on success.

Raises:

  • for invalid site_name, missing/invalid source_directory, or root not writable.



70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/rakit/static_web_server.rb', line 70

def self.publish(site_name, source_directory)
  validate_site_path!(site_name)
  src = ::File.expand_path(source_directory)
  raise ArgumentError, "source_directory does not exist: #{source_directory}" unless ::File.exist?(src)
  raise ArgumentError, "source_directory is not a directory: #{source_directory}" unless ::File.directory?(src)

  ensure_root
  unless ::File.writable?(root)
    raise ArgumentError, "root directory is not writable: #{root}"
  end

  target = ::File.join(root, site_name)
  temp_name = ".tmp_#{site_name.to_s.gsub(::File::SEPARATOR, '_')}_#{Process.pid}_#{rand(1_000_000)}"
  temp = ::File.join(root, temp_name)
  FileUtils.mkdir_p(temp)
  FileUtils.cp_r(::File.join(src, "."), temp)
  FileUtils.mkdir_p(::File.dirname(target))
  FileUtils.rm_rf(target)
  FileUtils.mv(temp, target)
  regenerate_root_index
  true
end

.regenerate_root_indexObject

T017: Write root/index.html listing all subdirectories that have index.html (or index.xml) with links.



184
185
186
187
188
189
190
191
192
193
194
195
# File 'lib/rakit/static_web_server.rb', line 184

def self.regenerate_root_index
  paths = []
  _collect_index_paths(root, "", paths)
  paths.sort!
  html = ["<!DOCTYPE html>", "<html><head><meta charset=\"utf-8\"><title>Published sites</title></head><body>", "<h1>Published sites</h1>", "<ul>"]
  paths.each do |rel|
    name = rel.chomp("/").split("/").last || rel
    html << "<li><a href=\"/#{rel}\">#{name} (#{rel})</a></li>"
  end
  html << "</ul></body></html>"
  ::File.write(::File.join(root, "index.html"), html.join("\n"))
end

.running?Boolean

Whether the server process is currently running (PID file + process check). No side effects.

Returns:

  • true if server is running, false otherwise.



95
96
97
98
99
100
101
102
103
104
# File 'lib/rakit/static_web_server.rb', line 95

def self.running?
  path = pid_file_path
  return false unless ::File.file?(path)
  pid = ::File.read(path).strip.to_i
  return false if pid <= 0
  Process.getpgid(pid)
  true
rescue Errno::ESRCH, Errno::EPERM
  false
end

.server_binaryObject

T007: Server binary (miniserve) on PATH; used by start. Returns path or nil.



48
49
50
51
52
53
54
# File 'lib/rakit/static_web_server.rb', line 48

def self.server_binary
  @server_binary ||= begin
    path = ENV["RAKIT_STATIC_SERVER_BINARY"]
    return path if path && !path.empty?
    which("miniserve")
  end
end

.server_binary=(path) ⇒ Object



56
57
58
# File 'lib/rakit/static_web_server.rb', line 56

def self.server_binary=(path)
  @server_binary = path
end

.start(options = {}) ⇒ true

Start background server serving root. Idempotent: if already running, no-op.

Parameters:

  • (defaults to: {})

    :port (optional) override default port.

Returns:

  • if server is running (started or already running).

Raises:

  • if port in use or server binary (miniserve) not found.



110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/rakit/static_web_server.rb', line 110

def self.start(options = {})
  return true if running?
  ensure_root
  regenerate_root_index  # so root / (Sites page) lists current nested sites
  p = options[:port] || port
  raise "port #{p} is already in use; change port or stop the other process" if port_in_use?(p)
  bin = server_binary
  raise "static server binary (miniserve) not found on PATH; install miniserve or set RAKIT_STATIC_SERVER_BINARY" unless bin
  root_path = ::File.expand_path(root)
  pid = spawn(bin, root_path, "--port", p.to_s, out: ::File::NULL, err: ::File::NULL)
  Process.detach(pid)
  FileUtils.mkdir_p(::File.dirname(pid_file_path))
  ::File.write(pid_file_path, pid.to_s)
  true
end

.stoptrue

Gracefully terminate server process; remove PID file. Idempotent: if not running (no PID file or process not found), no-op and return true.

Returns:

  • if stopped or already not running.



130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/rakit/static_web_server.rb', line 130

def self.stop
  path = pid_file_path
  unless ::File.file?(path)
    return true  # already not running
  end
  pid = ::File.read(path).strip.to_i
  if pid <= 0
    ::File.delete(path) rescue nil
    return true
  end
  begin
    Process.getpgid(pid)
  rescue Errno::ESRCH
    ::File.delete(path) rescue nil
    return true  # process already gone; treat as stopped
  end
  Process.kill(:TERM, pid)
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + STOP_TIMEOUT
  while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
    begin
      Process.wait(pid, Process::WNOHANG)
      break
    rescue Errno::ECHILD
      break
    end
    sleep 0.1
  end
  begin
    Process.getpgid(pid)
    Process.kill(:KILL, pid)
    Process.wait(pid)
  rescue Errno::ESRCH, Errno::ECHILD
    # already gone
  end
  ::File.delete(path) if ::File.file?(path)
  true
end

.validate_site_name!(site_name) ⇒ Object

T005: Validate site name; raise ArgumentError before any filesystem write. Single segment (e.g. “mysite”) or multi-segment path (e.g. “louparslow/rakit”); each segment must match SITE_NAME_REGEX.



28
29
30
# File 'lib/rakit/static_web_server.rb', line 28

def self.validate_site_name!(site_name)
  validate_site_path!(site_name)
end

.validate_site_path!(site_name) ⇒ Object



32
33
34
35
36
37
38
39
40
# File 'lib/rakit/static_web_server.rb', line 32

def self.validate_site_path!(site_name)
  return if site_name.nil? || site_name.to_s.empty?
  site_name.to_s.split(::File::SEPARATOR).each do |seg|
    next if seg.empty?
    unless seg.match?(SITE_NAME_REGEX)
      raise ArgumentError, "site_name segment must be lowercase alphanumeric and dashes only (e.g. my-site); got: #{seg.inspect}"
    end
  end
end

.view(relative_path) ⇒ true

Open a path on the static server in the default browser. Ensures server is running (starts if not), builds URL, launches browser.

Parameters:

  • URL path (e.g. “/louparslow/rakit/” or “louparslow/rakit”); normalized to one leading slash.

Returns:

  • on success.

Raises:

  • if browser cannot be launched (e.g. headless), with message e.g. “Could not launch browser; display required?”



172
173
174
175
176
177
178
179
180
181
# File 'lib/rakit/static_web_server.rb', line 172

def self.view(relative_path)
  start unless running?
  path = relative_path.to_s.strip
  path = "/#{path}" unless path.empty? || path.start_with?("/")
  path = "/" if path.empty?
  url = "http://#{host}:#{port}#{path}"
  $stdout.puts "Opening #{url}"
  launch_browser(url)
  true
end