Class: Abqari::Server

Inherits:
Object
  • Object
show all
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, and bin/serve takes 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

Instance Method Summary collapse

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_envObject

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

#startObject



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_watcherObject

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