diff --git a/lib/falcon/command/static.rb b/lib/falcon/command/static.rb
new file mode 100644
index 0000000..f4ba3d8
--- /dev/null
+++ b/lib/falcon/command/static.rb
@@ -0,0 +1,161 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2025, by Samuel Williams.
+
+require_relative "../server"
+require_relative "../endpoint"
+require_relative "../configuration"
+require_relative "../service/server"
+require_relative "../middleware/static"
+
+require "async/container"
+require "samovar"
+
+module Falcon
+ module Command
+ # Implements the `falcon static` command. Designed for serving static files.
+ #
+ # Manages a static file server for the current directory.
+ class Static < Samovar::Command
+ self.description = "Serve static files from the current directory."
+
+ # The command line options.
+ # @attribute [Samovar::Options]
+ options do
+ option "-b/--bind
", "Bind to the given hostname/address.", default: "http://localhost:3000"
+
+ option "-p/--port ", "Override the specified port.", type: Integer
+ option "-h/--hostname ", "Specify the hostname which would be used for certificates, etc."
+ option "-t/--timeout ", "Specify the maximum time to wait for non-blocking operations.", type: Float, default: nil
+
+ option "-r/--root ", "Root directory to serve static files from.", default: Dir.pwd
+ option "-i/--index ", "Index file to serve for directories.", default: "index.html"
+ option "--[no]-directory-listing", "Enable/disable directory listings.", default: true
+
+ option "--cache", "Enable the response cache."
+
+ option "--forked | --threaded | --hybrid", "Select a specific parallelism model.", key: :container, default: :forked
+
+ option "-n/--count ", "Number of instances to start.", default: 1, type: Integer
+
+ option "--forks ", "Number of forks (hybrid only).", type: Integer
+ option "--threads ", "Number of threads (hybrid only).", type: Integer
+
+ option "--[no]-restart", "Enable/disable automatic restart.", default: true
+ option "--graceful-stop ", "Duration to wait for graceful stop.", type: Float, default: 1.0
+
+ option "--health-check-timeout ", "Duration to wait for health check.", type: Float, default: 30.0
+ end
+
+ def container_options
+ @options.slice(:count, :forks, :threads, :restart, :health_check_timeout)
+ end
+
+ def endpoint_options
+ @options.slice(:hostname, :port, :timeout)
+ end
+
+ def name
+ @options[:hostname] || @options[:bind]
+ end
+
+ def root_directory
+ File.expand_path(@options[:root])
+ end
+
+ # Create a middleware stack for serving static files
+ def middleware_app
+ # Create a 404 fallback
+ not_found_app = lambda do |request|
+ Protocol::HTTP::Response[404, {'content-type' => 'text/plain'}, ['Not Found']]
+ end
+
+ # Create the static middleware
+ Middleware::Static.new(
+ not_found_app,
+ root: root_directory,
+ index: @options[:index],
+ directory_listing: @options[:directory_listing]
+ )
+ end
+
+ def environment
+ static_middleware = middleware_app
+ verbose_mode = self.parent&.verbose?
+ cache_enabled = @options[:cache]
+
+ Async::Service::Environment.new(Falcon::Environment::Server).with(
+ root: root_directory,
+
+ verbose: verbose_mode,
+ cache: cache_enabled,
+
+ container_options: self.container_options,
+ endpoint_options: self.endpoint_options,
+
+ url: @options[:bind],
+
+ name: self.name,
+
+ endpoint: ->{Endpoint.parse(url, **endpoint_options)},
+
+ # Use our custom static middleware directly
+ middleware: ->{
+ ::Protocol::HTTP::Middleware.build do
+ if verbose_mode
+ use Falcon::Middleware::Verbose
+ end
+
+ if cache_enabled
+ use Async::HTTP::Cache::General
+ end
+
+ use ::Protocol::HTTP::ContentEncoding
+
+ run static_middleware
+ end
+ }
+ )
+ end
+
+ def configuration
+ Configuration.new.tap do |configuration|
+ configuration.add(self.environment)
+ end
+ end
+
+ # The container class to use.
+ def container_class
+ case @options[:container]
+ when :threaded
+ return Async::Container::Threaded
+ when :forked
+ return Async::Container::Forked
+ when :hybrid
+ return Async::Container::Hybrid
+ end
+ end
+
+ # The endpoint to bind to.
+ def endpoint
+ Endpoint.parse(@options[:bind], **endpoint_options)
+ end
+
+ # Prepare the environment and run the controller.
+ def call
+ Console.logger.info(self) do |buffer|
+ buffer.puts "Falcon Static v#{VERSION} taking flight! Using #{self.container_class} #{self.container_options}."
+ buffer.puts "- Running on #{RUBY_DESCRIPTION}"
+ buffer.puts "- Serving files from: #{root_directory}"
+ buffer.puts "- Index file: #{@options[:index]}"
+ buffer.puts "- Binding to: #{self.endpoint}"
+ buffer.puts "- To terminate: Ctrl-C or kill #{Process.pid}"
+ buffer.puts "- To reload configuration: kill -HUP #{Process.pid}"
+ end
+
+ Async::Service::Controller.run(self.configuration, container_class: self.container_class, graceful_stop: @options[:graceful_stop])
+ end
+ end
+ end
+end
diff --git a/lib/falcon/command/top.rb b/lib/falcon/command/top.rb
index c7eb0d5..9acafc1 100644
--- a/lib/falcon/command/top.rb
+++ b/lib/falcon/command/top.rb
@@ -8,6 +8,7 @@
require_relative "virtual"
require_relative "proxy"
require_relative "redirect"
+require_relative "static"
require_relative "../version"
@@ -37,6 +38,7 @@ class Top < Samovar::Command
"virtual" => Virtual,
"proxy" => Proxy,
"redirect" => Redirect,
+ "static" => Static,
}, default: "serve"
# Whether verbose logging is enabled.
diff --git a/lib/falcon/middleware/static.rb b/lib/falcon/middleware/static.rb
new file mode 100644
index 0000000..4ae7eb9
--- /dev/null
+++ b/lib/falcon/middleware/static.rb
@@ -0,0 +1,328 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2025, by Samuel Williams.
+
+require "protocol/http/middleware"
+require "protocol/http/body/file"
+require "mime/types"
+require "cgi"
+
+module Falcon
+ module Middleware
+ # A HTTP middleware for serving static files and directory listings.
+ class Static < Protocol::HTTP::Middleware
+ # Default MIME types for common file extensions
+ MIME_TYPES = {
+ '.html' => 'text/html; charset=utf-8',
+ '.htm' => 'text/html; charset=utf-8',
+ '.css' => 'text/css',
+ '.js' => 'application/javascript',
+ '.json' => 'application/json',
+ '.xml' => 'application/xml',
+ '.txt' => 'text/plain',
+ '.md' => 'text/markdown',
+ '.png' => 'image/png',
+ '.jpg' => 'image/jpeg',
+ '.jpeg' => 'image/jpeg',
+ '.gif' => 'image/gif',
+ '.svg' => 'image/svg+xml',
+ '.ico' => 'image/x-icon',
+ '.pdf' => 'application/pdf',
+ '.zip' => 'application/zip',
+ }.freeze
+
+ # Initialize the static file middleware.
+ # @parameter app [Protocol::HTTP::Middleware] The middleware to wrap.
+ # @parameter root [String] The root directory to serve files from.
+ # @parameter index [String] The default index file for directories.
+ # @parameter directory_listing [Boolean] Whether to show directory listings.
+ def initialize(app, root: Dir.pwd, index: 'index.html', directory_listing: true)
+ super(app)
+
+ @root = File.expand_path(root)
+ @index = index
+ @directory_listing = directory_listing
+ end
+
+ # The root directory being served.
+ # @attribute [String]
+ attr :root
+
+ # The default index file.
+ # @attribute [String]
+ attr :index
+
+ # Whether directory listings are enabled.
+ # @attribute [Boolean]
+ attr :directory_listing
+
+ # Get the MIME type for a file extension.
+ # @parameter extension [String] The file extension (including the dot).
+ # @returns [String] The MIME type.
+ def mime_type_for_extension(extension)
+ MIME_TYPES[extension.downcase] || 'application/octet-stream'
+ end
+
+ # Resolve the file system path for a request path.
+ # @parameter request_path [String] The HTTP request path.
+ # @returns [String, nil] The file system path, or nil if invalid.
+ def resolve_path(request_path)
+ # Normalize the path and prevent directory traversal
+ path = File.join(@root, request_path)
+ real_path = File.realpath(path) rescue nil
+
+ # Ensure the resolved path is within the root directory
+ return nil unless real_path&.start_with?(@root)
+
+ real_path
+ end
+
+ # Generate an HTML directory listing.
+ # @parameter directory_path [String] The directory path.
+ # @parameter request_path [String] The HTTP request path.
+ # @returns [String] HTML content for the directory listing.
+ def generate_directory_listing(directory_path, request_path)
+ entries = Dir.entries(directory_path).sort
+
+ # Remove current directory entry, but keep parent unless at root
+ entries.reject! { |entry| entry == '.' }
+ entries.reject! { |entry| entry == '..' } if request_path == '/'
+
+ html = <<~HTML
+
+
+
+
+
+ Directory listing for #{CGI.escapeHTML(request_path)}
+
+
+
+
+
+
+ HTML
+
+ entries.each do |entry|
+ entry_path = File.join(directory_path, entry)
+ relative_path = File.join(request_path, entry)
+ relative_path = relative_path[1..-1] if relative_path.start_with?('//')
+
+ if File.directory?(entry_path)
+ icon = entry == '..' ? '⬆️' : '📁'
+ size = '-'
+ href = entry == '..' ? File.dirname(request_path) : relative_path
+ href += '/' unless href.end_with?('/')
+ else
+ icon = get_file_icon(entry)
+ size = format_file_size(File.size(entry_path))
+ href = relative_path
+ end
+
+ html += <<~HTML
+
+ #{icon}
+ #{CGI.escapeHTML(entry)}
+ #{size}
+
+ HTML
+ end
+
+ html += <<~HTML
+
+
+
+
+
+ HTML
+
+ html
+ end
+
+ # Get an appropriate icon for a file based on its extension.
+ # @parameter filename [String] The filename.
+ # @returns [String] An emoji icon.
+ def get_file_icon(filename)
+ ext = File.extname(filename).downcase
+ case ext
+ when '.html', '.htm' then '🌐'
+ when '.css' then '🎨'
+ when '.js' then '⚡'
+ when '.json' then '📋'
+ when '.xml' then '📄'
+ when '.txt', '.md' then '📝'
+ when '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico' then '🖼️'
+ when '.pdf' then '📕'
+ when '.zip', '.tar', '.gz' then '📦'
+ when '.rb' then '💎'
+ when '.py' then '🐍'
+ when '.java' then '☕'
+ when '.cpp', '.c', '.h' then '⚙️'
+ else '📄'
+ end
+ end
+
+ # Format file size in human-readable format.
+ # @parameter size [Integer] Size in bytes.
+ # @returns [String] Formatted size string.
+ def format_file_size(size)
+ if size < 1024
+ "#{size}B"
+ elsif size < 1024 * 1024
+ "#{(size / 1024.0).round(1)}KB"
+ elsif size < 1024 * 1024 * 1024
+ "#{(size / (1024.0 * 1024)).round(1)}MB"
+ else
+ "#{(size / (1024.0 * 1024 * 1024)).round(1)}GB"
+ end
+ end
+
+ # Handle the HTTP request for static files.
+ # @parameter request [Protocol::HTTP::Request]
+ # @returns [Protocol::HTTP::Response]
+ def call(request)
+ # Only handle GET and HEAD requests
+ return super unless request.method == 'GET' || request.method == 'HEAD'
+
+ file_path = resolve_path(request.path)
+ return super unless file_path
+
+ if File.exist?(file_path)
+ if File.directory?(file_path)
+ # Try to serve index file first
+ index_path = File.join(file_path, @index)
+ if File.file?(index_path)
+ return serve_file(index_path, request.method == 'HEAD')
+ elsif @directory_listing
+ return serve_directory_listing(file_path, request.path)
+ else
+ return super
+ end
+ elsif File.file?(file_path)
+ return serve_file(file_path, request.method == 'HEAD')
+ end
+ end
+
+ # File not found, pass to next middleware
+ super
+ end
+
+ private
+
+ # Serve a static file.
+ # @parameter file_path [String] The file system path.
+ # @parameter head_only [Boolean] Whether this is a HEAD request.
+ # @returns [Protocol::HTTP::Response]
+ def serve_file(file_path, head_only = false)
+ stat = File.stat(file_path)
+ extension = File.extname(file_path)
+ content_type = mime_type_for_extension(extension)
+
+ headers = [
+ ['content-type', content_type],
+ ['content-length', stat.size.to_s],
+ ['last-modified', stat.mtime.httpdate]
+ ]
+
+ if head_only
+ body = []
+ else
+ body = Protocol::HTTP::Body::File.open(file_path)
+ end
+
+ return Protocol::HTTP::Response[200, headers, body]
+ end
+
+ # Serve a directory listing.
+ # @parameter directory_path [String] The directory path.
+ # @parameter request_path [String] The HTTP request path.
+ # @returns [Protocol::HTTP::Response]
+ def serve_directory_listing(directory_path, request_path)
+ html = generate_directory_listing(directory_path, request_path)
+
+ headers = [
+ ['content-type', 'text/html; charset=utf-8'],
+ ['content-length', html.bytesize.to_s]
+ ]
+
+ return Protocol::HTTP::Response[200, headers, [html]]
+ end
+ end
+ end
+end
diff --git a/lib/falcon/server.rb b/lib/falcon/server.rb
index 588242f..192d242 100644
--- a/lib/falcon/server.rb
+++ b/lib/falcon/server.rb
@@ -59,11 +59,11 @@ def accept(...)
@connection_count -= 1
end
- def call(...)
+ def call(*args, **kwargs)
@request_count += 1
@active_count += 1
- super
+ super(*args, **kwargs)
ensure
@active_count -= 1
end