Module: Rakit::StaticWebServer

Defined in:
lib/rakit/static_web_server.rb

Overview

Publish static sites into a configurable root, regenerate a root index, and control a local HTTP server (start/stop/running). CLI: rakit static-web-server (start|stop|running|publish). See contracts/ruby-api.md and quickstart in specs/003-static-web-server/quickstart.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

.portInteger

Returns Default port for start (default 5099). Used when no override passed to start.

Returns:

  • (Integer)

    Default port for start (default 5099). Used when no override passed to start.



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

def port
  @port
end

.rootString

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

Returns:

  • (String)

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



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

def root
  @root
end

Class Method Details

.ensure_rootObject

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



47
48
49
# File 'lib/rakit/static_web_server.rb', line 47

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



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

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:

  • site_name (String)

    Must match \A+\z (lowercase, alphanumeric, dashes only).

  • source_directory (String)

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

Returns:

  • (true)

    on success.

Raises:

  • (ArgumentError)

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



56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/rakit/static_web_server.rb', line 56

def self.publish(site_name, source_directory)
  validate_site_name!(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
  # Check root is writable before we do any copy (T015 / edge case).
  unless ::File.writable?(root)
    raise ArgumentError, "root directory is not writable: #{root}"
  end

  target = ::File.join(root, site_name)
  temp = ::File.join(root, ".tmp_#{site_name}_#{Process.pid}_#{rand(1_000_000)}")
  FileUtils.mkdir_p(temp)
  FileUtils.cp_r(::File.join(src, "."), temp)
  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.



149
150
151
152
153
154
155
156
157
# File 'lib/rakit/static_web_server.rb', line 149

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.

Returns:

  • (Boolean)

    true if server is running, false otherwise.



80
81
82
83
84
85
86
87
88
89
# File 'lib/rakit/static_web_server.rb', line 80

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.



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

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



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

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

.start(options = {}) ⇒ true

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

Parameters:

  • options (Hash) (defaults to: {})

    :port (optional) override default port.

Returns:

  • (true)

    if server is running (started or already running).

Raises:

  • (RuntimeError)

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



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

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.

Returns:

  • (true)

    if stopped.

Raises:

  • (RuntimeError)

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



115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# File 'lib/rakit/static_web_server.rb', line 115

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.

Raises:

  • (ArgumentError)


23
24
25
26
# File 'lib/rakit/static_web_server.rb', line 23

def self.validate_site_name!(site_name)
  return if site_name.is_a?(String) && site_name.match?(SITE_NAME_REGEX)
  raise ArgumentError, "site_name must be lowercase alphanumeric and dashes only (e.g. my-site); got: #{site_name.inspect}"
end