Class: Abqari::Server
- Inherits:
-
Object
- Object
- Abqari::Server
- Defined in:
- lib/abqari/server.rb
Defined Under Namespace
Classes: NotFoundFileHandler, QuietLog
Constant Summary collapse
- DEFAULT_PORT =
Override with
ABQARI_PORT=4001 bin/serve. Worth having because a machine running several Abqari sites has several dev servers wanting 4000, andbin/servetakes no arguments by design. 4000- DEFAULT_BIND =
Localhost-only by default. WEBrick's stdlib default is to bind to 0.0.0.0 (all interfaces) — which would expose unrendered drafts and future-dated content to anyone on the same Wi-Fi. Operators that want to preview on a phone over LAN can override with
ABQARI_BIND=0.0.0.0 bin/serve. '127.0.0.1'- PORT_RANGE =
0 is excluded deliberately: WEBrick reads it as "any free port", which would start a server the operator then has to hunt for. Ports below 1024 need root and would fail later and less clearly.
(1..65_535).freeze
- POLL_INTERVAL =
0.5- DEBOUNCE =
0.1- WATCH_GLOBS =
['content/**/*', 'app/**/*', 'themes/**/*', 'config/**/*', 'lib/**/*', 'data/**/*'].freeze
- CSS_PATTERN =
/\.css\z/.freeze
- SSE_HEARTBEAT =
How long to hold an SSE connection open before sending a no-op heartbeat. Keeps load-balancers and overzealous proxies from tearing down "idle" streams. 30 s is the typical proxy patience window.
30
Class Method Summary collapse
-
.port_from_env ⇒ Object
ABQARI_PORT, parsed strictly.
-
.validate_env! ⇒ Object
Check the environment knobs WITHOUT constructing a server or touching the filesystem.
Instance Method Summary collapse
-
#handle_reload_endpoint(_req, res) ⇒ Object
SSE endpoint at /reload.
-
#initialize(site, port: nil, bind: nil, logger: nil) ⇒ Server
constructor
A new instance of Server.
- #start ⇒ Object
-
#stop_watcher ⇒ Object
Stop the file watcher (Listen listener or the polling thread).
Constructor Details
#initialize(site, port: nil, bind: nil, logger: nil) ⇒ Server
Returns a new instance of Server.
138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 |
# File 'lib/abqari/server.rb', line 138 def initialize(site, port: nil, bind: nil, logger: nil) @site = site @port = port || self.class.port_from_env || DEFAULT_PORT @bind = bind || ENV['ABQARI_BIND'] || DEFAULT_BIND # WEBrick's internal logger. Defaults to `QuietLog` — WEBrick's # own stderr logger with client-disconnect errors filtered out # (see the class comment above for why that filtering can't live # at the SSE endpoint). Pass `logger:` to override: tests use a # fully silent `WEBrick::Log.new(File::NULL)`, and # `ABQARI_VERBOSE_LOG=true` falls back to stock WEBrick logging. @logger = logger || default_logger @version = Time.now.to_f @scope = 'all' @mutex = Mutex.new # Active SSE subscribers — each is a Queue that receives one # event per rebuild. Tracked so `rebuild` can fan a single # event out to every connected browser tab. @subscribers = [] @subscribers_mutex = Mutex.new end |
Class Method Details
.port_from_env ⇒ Object
ABQARI_PORT, parsed strictly.
String#to_i is the wrong tool here and quietly wrong in three ways:
"abc".to_i is 0, which WEBrick reads as "bind any free port" and
starts a server on a port nobody knows; "4000x".to_i is 4000, so a
typo silently works; and "99999".to_i is 99999, which fails deep in
the socket layer with a message that doesn't mention the env var.
An operator who sets this has a specific port in mind. If we can't honour it, say so here rather than serving something surprising.
105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 |
# File 'lib/abqari/server.rb', line 105 def port_from_env raw = ENV['ABQARI_PORT'].to_s.strip return nil if raw.empty? unless raw.match?(/\A\d+\z/) raise UserError, "ABQARI_PORT=#{raw.inspect} is not a number. " \ 'Set it to a port between 1 and 65535, or unset it ' \ "to use the default (#{DEFAULT_PORT})." end port = raw.to_i unless PORT_RANGE.cover?(port) raise UserError, "ABQARI_PORT=#{port} is out of range. " \ "Use #{PORT_RANGE.min}–#{PORT_RANGE.max} " \ '(ports below 1024 also need root).' end port end |
.validate_env! ⇒ Object
Check the environment knobs WITHOUT constructing a server or touching the filesystem.
bin/serve builds the entire site before it reaches
Server.new, so a typo'd ABQARI_PORT cost a full build before
the (genuinely good) error appeared. Call this first and it
fails in microseconds instead.
132 133 134 135 |
# File 'lib/abqari/server.rb', line 132 def validate_env! port_from_env nil end |
Instance Method Details
#handle_reload_endpoint(_req, res) ⇒ Object
SSE endpoint at /reload. Browsers open one EventSource per
tab and stay connected for the lifetime of the dev session;
each rebuild fans an event out to every connected stream,
which the client uses to decide between a CSS swap and a full
location.reload().
SSE wire format:
data: {"version": 1.234, "scope": "css"}\n\n
Headers required by the spec: text/event-stream content type, explicit no-cache, and keep-alive. WEBrick's chunked-response mode plus a body proc lets us hold the socket open and write events as the queue produces them.
242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 |
# File 'lib/abqari/server.rb', line 242 def handle_reload_endpoint(_req, res) res['Content-Type'] = 'text/event-stream' res['Cache-Control'] = 'no-cache' res['Connection'] = 'keep-alive' res.chunked = true queue = Queue.new @subscribers_mutex.synchronize { @subscribers << queue } res.body = proc do |out| # Initial flush — comment-line forces WEBrick to send headers # so the client's EventSource fires `onopen` and starts # consuming events. Followed by the current state so a tab # that connects mid-build doesn't miss its first rebuild. out.write(": connected\n\n") out.write(format_event(version: @version, scope: @scope)) loop do # Pull with timeout so a quiet period still gets a # heartbeat (`:\n\n` is the SSE comment form — invisible # to the application code on the client). msg = pop_with_timeout(queue, SSE_HEARTBEAT) if msg.nil? out.write(": heartbeat\n\n") next end break if msg == :close out.write(format_event(msg)) end rescue IOError, Errno::EPIPE, Errno::ECONNRESET # Client tab closed or network hiccup. Drop the queue; # WEBrick will tidy up the request thread. nil ensure @subscribers_mutex.synchronize { @subscribers.delete(queue) } end end |
#start ⇒ Object
159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 |
# File 'lib/abqari/server.rb', line 159 def start start_watcher server_opts = { BindAddress: @bind, Port: @port, AccessLog: [], DirectoryIndex: ['index.html'] } server_opts[:Logger] = @logger server = WEBrick::HTTPServer.new(server_opts) # No :DocumentRoot — that would auto-mount the stock FileHandler. # Mount the 404-aware subclass at the root instead; it inherits # DirectoryIndex and the rest of the server config. server.mount('/', NotFoundFileHandler, @site.output_dir) server.mount_proc('/__reload__', &method(:handle_reload_endpoint)) # Shutdown choreography is delicate: # # - The trap handler can't call `Mutex#synchronize` itself # (`ThreadError: can't be called from trap context`). # - But every open SSE connection has a WEBrick request thread # parked in `queue.pop` (waiting for an event or the 30-second # heartbeat tick). `server.shutdown` only sets a flag — it # doesn't kill those threads. WEBrick's main loop waits for # them to finish before `server.start` returns. # - So if we just call `server.shutdown` from the trap, WEBrick # hangs until every parked SSE thread times out (~30s each). # # The fix: the trap spawns a fresh Ruby thread (allowed from a # trap context, runs in normal context). That thread closes the # subscriber queues FIRST — which unblocks the parked SSE # threads — then calls `server.shutdown` so WEBrick can drain # cleanly. # Block form (not lambda): Ruby passes the signal number as the # block argument and lambdas are strict about arity. The block # form ignores extra args, so a clean Ctrl-C doesn't crash with # `wrong number of arguments (given 1, expected 0)`. shutdown_hook = proc do Thread.new do shutdown_subscribers server.shutdown end end trap('INT', shutdown_hook) trap('TERM', shutdown_hook) host = @bind == '0.0.0.0' ? 'localhost' : @bind Log.info "Abqari serving on http://#{host}:#{@port} (bind: #{@bind})" Log.info "Watching for changes via #{watcher_kind} (Ctrl-C to stop)." server.start end |
#stop_watcher ⇒ Object
Stop the file watcher (Listen listener or the polling thread).
In the CLI the process just exits so this is unused, but it lets
embedders — and the test suite — shut a server down cleanly
instead of leaking a background thread that keeps calling
rebuild (→ site.build) after the server is otherwise done.
219 220 221 222 223 224 225 226 227 |
# File 'lib/abqari/server.rb', line 219 def stop_watcher @listener&.stop @listener = nil if @watcher_thread @watcher_thread.kill @watcher_thread.join(1) @watcher_thread = nil end end |