baked_handler.cr
require "baked_file_system"
require "kemal"
module Grafito
BakedFileHandler
BakedFileHandler
is a specialized HTTP::Handler
designed to serve
files embedded within the application binary using a BakedFileSystem
compatible class.
It replaces standard filesystem lookups with lookups in the provided baked assets class. This allows serving static content (HTML, CSS, JS, images, etc.) directly from memory, making the application distributable as a single binary without loose asset files.
Features:
- Serves files from a
BakedFileSystem
class. - Handles GET and HEAD requests.
- Optionally serves
index.html
for directory-like paths (e.g.,/admin/
serves/admin/index.html
). - Sets
Content-Type
based on file extension. - Allows configuring
Cache-Control
headers. - Fallthrough to the next handler if a file is not found or the method is not supported.
Usage:
require "kemal"
require "baked_file_system"
# Example BakedFileSystem class
class MyAssets
extend BakedFileSystem
bake_folder "./public_assets"
end
# In your Kemal app:
baked_asset_handler = BakedFileHandler.new(
MyAssets)
add_handler baked_asset_handler
Kemal.run
When a request like GET /css/style.css
is received, BakedFileHandler
will attempt to retrieve and serve the file associated with the key
"/css/style.css"
from the MyAssets
class.
Caveats:
- Directory listing is not supported.
- File modification times and ETag based on
mtime
are not used for caching, as baked assets are immutable at runtime. Caching relies onCache-Control
. - Range requests are not explicitly supported by this handler; the entire file content is served.
class BakedFileHandler < Kemal::StaticFileHandler
Log = ::Log.for(self)
@baked_fs_class : BakedFileSystem
@serve_index_html : Bool = true
@cache_control : String? = "max-age=604800" # Default 1 week
Creates a new BakedFileHandler
.
Arguments:
baked_fs_class
: The class object (e.g.,MyAssets
) that extendsBakedFileSystem
and contains the baked files.fallthrough
: Iftrue
(default), calls the next handler if a file is not found or if the request method is not GET or HEAD. (Passed tosuper
)serve_index_html
: Iftrue
(default), attempts to serveindex.html
for requests to directory-like paths (e.g.,/admin/
serves/admin/index.html
).cache_control
: Sets theCache-Control
header for successful responses. Defaults to "max-age=604800" (1 week). Set tonil
to omit this header.
def initialize(
@baked_fs_class : BakedFileSystem,
fallthrough = true,
@serve_index_html = true,
@cache_control = "max-age=604800",
)
Call super with a dummy public_dir, as we override call
and don't use parent's fs logic.
Parent's directory_listing is also made false as we don't support it.
super("/", fallthrough, directory_listing: false)
end
Overrides the main request handling method from HTTP::StaticFileHandler
.
This implementation bypasses filesystem checks and serves directly from
the BakedFileSystem
.
def call(context : HTTP::Server::Context)
request_path = context.request.path
unless ["GET", "HEAD"].includes? context.request.method
Method not allowed
if @fallthrough
return call_next(context)
else
context.response.status = HTTP::Status::METHOD_NOT_ALLOWED # 405
context.response.headers["Allow"] = "GET, HEAD"
return
end
end
baked_key = Path.posix(URI.decode(request_path)).relative_to("/").to_s
Attempt to serve the direct path
if serve_baked_key(context, baked_key)
return
end
If direct path failed, and it's a "directory" path (ends with / or is "." for root), and @serve_index_html is true, try serving an index.html file from that path.
if @serve_index_html && (request_path.ends_with?('/') || request_path == ".")
index_key = (baked_key == ".") ? "index.html" : Path.posix(baked_key).join("index.html").normalize.to_s
if serve_baked_key(context, index_key)
return
end
end
If nothing worked, fall through to the next handler.
call_next(context)
end
Helper to serve a file from BakedFileSystem using its key.
private def serve_baked_key(context : HTTP::Server::Context, baked_key : String)
Log.debug { "Attempting to serve baked key: '#{baked_key}' from #{@baked_fs_class}" }
Check for file existence in the BakedFileSystem first.
unless @baked_fs_class.get?(baked_key)
Log.debug { "Baked key not found: '#{baked_key}' in #{@baked_fs_class}" }
return false # Not served, allow fallthrough
end
begin
Now that we know it exists, get it.
io = @baked_fs_class.get(baked_key)
extension = Path.new(baked_key).extension.to_s # .to_s handles nil if no extension
context.response.content_type = MIME.from_extension(extension) || "application/octet-stream"
@cache_control.try { |cc|
context.response.headers["Cache-Control"] = cc
}
context.response.content_length = io.size
For GET requests, we copy the IO content to the response.
if context.request.method == "GET"
IO.copy(io, context.response)
end
Served
Log.debug { "Successfully served baked key: '#{baked_key}'" }
true
rescue ex
Catch errors during the actual serving process and ensure a response is sent if not already closed, to prevent hanging
Log.error(exception: ex) { "Error serving (already confirmed) baked key: '#{baked_key}'" }
unless context.response.closed?
begin
context.response.status = :internal_server_error
context.response.print "Error serving file."
rescue error : IO::Error
Log.warn(exception: error) { "Could not send full 500 error response for '#{baked_key}' (e.g., headers already sent or stream closed)." }
end
end
Consider this handled with an error, do not fallthrough
true
ensure
Ensure the IO is closed if it was opened (probably not needed)
io.try &.close
end
end
end
end