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



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

def host
  @host
end

.portInteger



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

def port
  @port
end

.rootString



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

def root
  @root
end

Class Method Details

.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.

Raises:

  • (ArgumentError)

    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 site subdirectories alphabetically with links.



178
179
180
181
182
183
184
185
186
# File 'lib/rakit/static_web_server.rb', line 178

def self.regenerate_root_index
  entries = Dir.entries(root).sort.select do |e|
    e != "." && e != ".." && e != "index.html" && !e.start_with?(".") && ::File.directory?(::File.join(root, e))
  end
  html = ["<!DOCTYPE html>", "<html><head><meta charset=\"utf-8\"><title>Published sites</title></head><body>", "<h1>Published sites</h1>", "<ul>"]
  entries.each { |name| html << "<li><a href=\"/#{name}/\">#{name}</a></li>" }
  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.



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.

Raises:

  • (RuntimeError)

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



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

def self.start(options = {})
  return true if running?
  ensure_root
  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. Fail-fast if not running.

Raises:

  • (RuntimeError)

    if server was not running (no PID file or process not found).



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
# File 'lib/rakit/static_web_server.rb', line 130

def self.stop
  path = pid_file_path
  raise "server is not running (no PID file at #{path})" unless ::File.file?(path)
  pid = ::File.read(path).strip.to_i
  raise "server is not running (invalid PID in #{path})" if pid <= 0
  begin
    Process.getpgid(pid)
  rescue Errno::ESRCH
    ::File.delete(path)
    raise "server is not running (process #{pid} not found)"
  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.

Raises:

  • (RuntimeError)

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



167
168
169
170
171
172
173
174
175
# File 'lib/rakit/static_web_server.rb', line 167

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}"
  launch_browser(url)
  true
end