opal
Opal
Opal is a modular HTTP, dependency injection, application bootstrap, and persistence toolkit for Crystal.
It is built on top of Crystal's standard HTTP::Handler stack and focuses on:
- trie-based path matching
- path parameter extraction
- method-aware routing with
404/405handling - lightweight API handlers via
LF::HTTP::Controller - compile-time guards, pipes, interceptors, and exception filters
- scoped dependency injection with deterministic lifecycle cleanup
- optional compile-time application bootstrap
- transaction-local persistence with compile-time entity and query contracts
- server-rendered interactive pages with an Opal-owned LiveView runtime
- optional accessible UI primitives with a precompiled Tailwind theme
- opt-in typed RPC and event services with transport-neutral execution and RabbitMQ AMQP 0-9-1 support
- durable typed projection handlers over RabbitMQ Streams and Crabbit
Documentation
The documentation site contains progressive tutorials, feature guides, Data and security references, architectural decisions, and generated Crystal API documentation. Start at docs/index.md, or build a local static preview with:
scripts/setup_docs.sh
scripts/serve_docs.sh
The server prints a local URL (by default http://127.0.0.1:8000). Run scripts/check_docs.sh before publishing; it also compiles the tutorial sources and every runnable example linked from the documentation.
Status
The routing, native WebSocket, LiveView, HTTP binding, DI lifecycle, Application compiler, and Data persistence/migration contracts are covered by specs in this repository.
Installation
Add this to your application's shard.yml:
dependencies:
opal:
github: mikeoz32/opal
Then install shards:
shards install
Core API
Opal exposes eleven independent layers:
-
LF::HTTP::RouterLow-level router with explicit handlers. -
LF::HTTP::AppHTTP::Handlerwrapper around a router with consistent HTTP error handling. -
LF::HTTP::ControllerCompile-time route definition with request binding and constructor DI. -
@[LF::Application]andLF::ApplicationRuntimeOptional compile-time application assembly and root-container ownership. -
require "opal/autoconfig/http"Optional HTTP controller discovery, server assembly, and lifecycle integration. -
require "opal/data"andrequire "opal/autoconfig/data"Explicit persistence APIs with optional Application-owned DataSource setup. -
LF::LiveViewServer-owned interactive pages over Opal's native WebSocket transport, with pinned Phoenix/LiveView browser packages prebundled into the shard. -
require "opal/ui"Optional stateless actions, feedback, cards, form controls, tables, dialogs, disclosure/navigation primitives, and overlays with a precompiled Tailwind theme. -
require "opal/security"Optional authentication context, API-key and signed-session authenticators, authorization guards, and CSRF protection built on the HTTP policy pipeline. -
require "opal/microservices"andrequire "opal/microservices/rabbitmq"Optional message controllers and policies, typed RPC clients, Tori-compatible contracts, deterministic in-memory testing, and a RabbitMQ AMQP 0-9-1 adapter. Theopal/autoconfig/microservices/rabbitmqentrypoint composes controllers and injectable typed clients from an application annotation. See the microservices guide for the minimal application and broker configuration. -
require "opal/autoconfig/microservices/crabbit_streams"Optional typed stream publishers and replayable projection handlers with Crabbit broker checkpoints, ordinary streams, and super streams. See the RabbitMQ Streams guide.
Basic Router
require "opal"
router = LF::HTTP::Router.new
router.get("/") do |ctx, _params|
ctx.response.print "Welcome"
end
router.get("/users/:id") do |ctx, params|
ctx.response.print "User #{params["id"]}"
end
router.post("/users") do |ctx, _params|
ctx.response.status = HTTP::Status::CREATED
ctx.response.print "created"
end
server = HTTP::Server.new([
HTTP::LogHandler.new,
router,
])
server.bind_tcp(8080)
server.listen
HTTP App
LF::HTTP::App wraps LF::HTTP::Router and converts LF::HTTP::BadRequest, LF::HTTP::NotFound, and other internal exceptions into HTTP responses.
require "opal"
app = LF::HTTP::App.new do |router|
router.get("/hello/:name") do |ctx, params|
ctx.response.print "Hello, #{params["name"]}"
end
end
server = HTTP::Server.new([
HTTP::LogHandler.new,
app,
])
server.bind_tcp(8080)
server.listen
HTTP Controller
LF::HTTP::Controller is the higher-level API surface. It supports:
- route params
- query params
HTTP::Request- JSON body parsing for
JSON::Serializable - automatic JSON responses for returned
JSON::Serializablemodels LF::HTTP::Responsereturn types such asLF::HTTP::JSONResponse- constructor injection for controller dependencies
Route arguments are request inputs only. Inject services through the controller constructor; unsupported route arguments fail at compile time.
Guards, pipes, interceptors, and filters
Controller execution policies are ordinary DI beans selected at compile time:
- guards authorize before request binding and return
403 Forbiddenwhen they reject a request; - pipes validate or transform path/query strings and parsed JSON bodies before conversion to the declared action argument type;
- interceptors wrap the action, can replace its response, or short-circuit it;
- exception filters map errors raised anywhere in the controller pipeline to a response.
Apply policies globally, to a controller, to an action, or—in the case of pipes—to one parameter:
@[LF::DI::Service]
class AuthGuard < LF::HTTP::Guard
def can_activate(context : LF::HTTP::ExecutionContext) : Bool
context.request.headers.has_key?("Authorization")
end
end
@[LF::DI::Service]
class TrimPipe < LF::HTTP::StringPipe
def transform_string(value, metadata, context) : String
value.strip
end
end
@[LF::HTTP::UseGuards(AuthGuard)]
class UsersApi
include LF::HTTP::Controller
@[LF::HTTP::Controller::Get("/users")]
def index(@[LF::HTTP::UsePipes(TrimPipe)] name : String)
"Hello, #{name}"
end
end
Controller-level annotations do not require a separate HttpPolicies class. Register the policy beans and set up the controller normally:
root.register(LF::DI::ServiceConfiguration.new)
app = LF::HTTP::App.new do |router|
UsersApi.setup_routes(router, root)
end
The order is global → controller → action → parameter. Interceptors unwind in reverse order; filters search action → controller → global.
For @[LF::HTTP::Controller::WebSocket] actions, global, controller, and action guards run before the HTTP upgrade, so the same direct annotations can reject the handshake with 403. Pipes, interceptors, and filters retain their HTTP action semantics; use explicit WebSocket message validation inside the connection handler.
Global policies are optional. With HTTP autoconfiguration, put their annotations on the @[LF::Application] class. A manually assembled standalone application can use a separate annotation owner only when it actually needs global policies:
UsersApi.setup_routes(router, root, HttpPolicies)
See examples/http_execution_pipeline_example.cr for all four policy types together.
Example
require "opal"
class UserPayload
include JSON::Serializable
property name : String
end
class UserView
include JSON::Serializable
property id : Int32
property name : String
def initialize(@id : Int32, @name : String)
end
end
@[LF::DI::Service]
class UserService
def find(id : Int32) : UserView
UserView.new(id, "User #{id}")
end
def create(name : String) : UserView
UserView.new(1, name)
end
end
class UsersApi
include LF::HTTP::Controller
def initialize(@users : UserService)
end
@[LF::HTTP::Controller::Get("/users/:id")]
def show(id : Int32)
@users.find(id)
end
@[LF::HTTP::Controller::Post("/users")]
def create(payload : UserPayload)
@users.create(payload.name)
end
end
root = LF::DI::DefaultContainer.new
root.register(LF::DI::ServiceConfiguration.new)
app = LF::HTTP::App.new do |router|
UsersApi.setup_routes(router, root)
end
server = HTTP::Server.new([
LF::HTTP::DI::RequestScopeHandler.new(root),
app,
])
WebSocket controller actions use the same route discovery and expose the raw Crystal socket. They may use callbacks:
@[LF::HTTP::Controller::WebSocket("/chat")]
def chat(ws : HTTP::WebSocket) : Nil
ws.on_message { |message| ws.send("echo: #{message}") }
end
Or they may use an explicit synchronous receive loop in the action body:
@[LF::HTTP::Controller::WebSocket("/echo")]
def echo(ws : HTTP::WebSocket) : Nil
while message = ws.receive?
ws.send("echo: #{message}")
end
end
These are two handler styles for the same WebSocket route type. Avoid mixing a manual receive loop and receive callbacks on one socket unless double processing is intentional. A finite synchronous action must call ws.close; returning alone hands control back to Crystal's HTTP::WebSocketHandler, which then starts its callback-mode ws.run loop.
A standalone server that exposes WebSocket controller routes needs both scope handlers, in this order:
connections = LF::HTTP::WebSocketConnectionRegistry.new
server = HTTP::Server.new([
LF::HTTP::DI::WebSocketScopeHandler.new(root, "websocket", connections),
LF::HTTP::DI::RequestScopeHandler.new(root),
app,
])
On shutdown, close the server, call connections.shutdown(timeout_ms), and only then shut down the root container so active connection scopes exit first.
LiveView
Opal LiveView provides interactive server-rendered pages with a prebundled upstream Phoenix LiveView browser runtime. Application projects need neither Elixir/Phoenix nor an npm asset pipeline. A page owns state for one WebSocket connection:
@[LF::LiveView::Page("/counter")]
class CounterLive < LF::LiveView::View
@count = 0
def handle_event(event : String, value : JSON::Any) : Nil
case event
when "increment" then @count += 1
else super
end
end
def render : LF::LiveView::Rendered
LF::LiveView::HTML.rendered(
%(<button id="counter" phx-click="increment">#{@count}</button>)
)
end
end
With HTTP autoconfiguration, Opal discovers annotated pages, constructor- injects dependencies into separate request and WebSocket instances, serves the initial HTML and /_opal/live.js, and mounts the socket at /_opal/live. Configure live_view.secret with at least 32 bytes. See the LiveView guide for lifecycle, structural rendering, keyed comprehensions, nested stateful components and child LiveViews, streams, live navigation, JavaScript hooks, security, forms, reconnect, manual assembly, and current feature boundaries.
UI Primitives
The optional UI layer returns normal LiveView structural renders and keeps state in the application view:
require "opal/ui"
LF::UI.button(
"Save",
type: "submit",
tone: LF::UI::Tone::Primary,
attributes: {"phx-click" => "save"}
)
LF::UI.input(
"Email",
id: "email",
name: "email",
type: "email",
error: @email_error,
required: true
)
Use LF::UI.stylesheet_tag for a zero-setup embedded theme. Interactive primitives such as dialogs, dropdowns, tabs, toasts, accordions, and tooltips additionally load LF::UI.hook_script_tag before the LiveView client; pagination can use upstream Phoenix live patches without another hook. Both assets also have cacheable mounted routes. See the UI guide and runnable UI showcase.
DI Container
The built-in DI container lives under LF::DI.
Registering beans manually
root = LF::DI::DefaultContainer.new
root.add_bean(name: "greeting_service", scope: "request", type: GreetingService) do |_ctx|
GreetingService.new("Hello")
end
Request scope
Controllers are request-scoped beans. Their constructor dependencies use the normal DI resolution rules. Route arguments bind only path, query, request, and JSON body values.
A built-in handler creates and closes a child scope around each request:
server = HTTP::Server.new([
LF::HTTP::DI::RequestScopeHandler.new(root),
app,
])
Autowired services
You can also declare services with @[LF::DI::Service] and register LF::DI::ServiceConfiguration.
Autowiring currently works like this:
- resolve by argument name and type
- if not found, fall back to type lookup
- if multiple beans of the same type exist, raise
LF::DI::AmbiguousBeanError
Lifecycle callbacks
Beans can opt into lifecycle hooks by implementing:
LF::DI::Initializable#after_properties_setLF::DI::Disposable#destroy
Lifecycle behavior:
- init runs after instance creation and before cache commit
- init runs exactly once per created instance
- child-owned disposable instances are destroyed on
scope.exit - root-owned disposable singletons are destroyed on
root.shutdown - destroy order is reverse creation order within the owning context
Example:
class RequestResource
include LF::DI::Initializable
include LF::DI::Disposable
def after_properties_set : Nil
puts "resource ready"
end
def destroy : Nil
puts "resource cleaned up"
end
end
root = LF::DI::DefaultContainer.new
root.add_bean(name: "request_resource", scope: "request", type: RequestResource) do |_ctx|
RequestResource.new
end
scope = root.enter_scope("request")
scope.get_bean("request_resource", RequestResource)
scope.exit
root.shutdown
Application Bootstrap
Application bootstrap is optional. Standalone DI and HTTP usage remain valid without an application marker.
@[LF::ApplicationConfiguration(priority: 10)]
class Infrastructure
@[LF::DI::Bean]
def clock : Clock
Clock.new
end
end
@[LF::Application]
class TodoApplication
end
TodoApplication.run do |application|
application.resolve(TodoService).start
end
The generated bootstrap method owns a fresh root container. LF::ApplicationRuntime exposes typed resolution, extension installation, shutdown, and state/error contracts; it does not expose the mutable root container. Application extensions receive a controlled LF::ApplicationContext for bean registration and scope creation.
Data
The Data layer is loaded through require "opal/data"; concrete dialects and drivers remain opt-in. Start with the Data getting-started guide.
Public Data guides:
- dialects
- entities
- relationships and cascades
- transactions and repositories
- queries
- migrations
- schema diff and migration generation
- autoconfiguration
- raw SQL and converters
Transaction rollback
LF::Data::EntityManager is transaction-local. If a transaction fails after an explicit flush, generated IDs or optimistic-lock versions may already have been written to in-memory entities even though the database rolled back. Discard every entity obtained from the failed manager and do not reuse it:
begin
source.transaction do |manager|
manager.persist(entity)
manager.flush
raise "abort"
end
rescue
# `entity` belongs to the failed manager and must be discarded.
end
Configuration
Application bootstrap eagerly registers one immutable LF::ConfigService singleton. It reads config/application.yml by default. A missing default file means empty configuration; setting OPAL_CONFIG selects an explicit file and a missing explicit file is an error.
http:
host: 127.0.0.1
port: 8080
live_view:
secret: replace-with-a-generated-production-secret
join_timeout_ms: 10000
idle_timeout_ms: 75000
config.get("http.port") # YAML::Any
config.get("http.port", 8080) # Int32
config.section("http") # YAML::Any mapping
Data Autoconfiguration
Data autoconfiguration is opt-in. The adapter owns one exact LF::Data::DataSource singleton and can apply one DI-provided migration set during Application startup:
require "opal"
require "opal/autoconfig/data"
require "sqlite3"
@[LF::ApplicationConfiguration]
class DataConfiguration
@[LF::DI::Bean]
def migration_set : LF::Data::MigrationSet
LF::Data::MigrationSet.new(CreateProjects.new)
end
end
@[LF::Application]
@[LF::AutoConfig::Data]
class TodoApplication
end
database:
url: sqlite3://./todo.db
migrations:
run_on_startup: true
database.url is required and supports the sqlite3 and postgres schemes. Applications still require the matching concrete sqlite3 or pg driver. Startup migrations default to false; when enabled, exactly one LF::Data::MigrationSet bean must exist. If HTTP autoconfiguration is also enabled, HTTP stops first, then the DataSource closes, then DI disposes its remaining singletons.
HTTP Autoconfiguration
HTTP autoconfiguration is an explicit optional require:
require "opal"
require "opal/autoconfig/http"
@[LF::Application]
@[LF::AutoConfig::HTTP]
class TodoApplication
end
TodoApplication.run_http
At compile time Opal discovers LF::HTTP::Controller includers in fully-qualified name order. At startup it builds the controller route table, the log and WebSocket handlers, connection-drain-owned request scopes, and an HTTP::Server. The server uses http.host (0.0.0.0 by default), http.port (8080 by default), and the positive http.drain_timeout_ms (30000 by default). WebSocket connections receive 1001 Going Away and have http.websocket.shutdown_timeout_ms (5000 by default) to close before their transport is forced closed. run_http blocks in HTTP::Server#listen; process termination closes the server before application DI shutdown.
Shutdown closes listeners and idle keep-alive sockets, then waits through the configured deadline for response output and upgraded connection work. A deadline breach force-closes remaining transports and surfaces LF::HTTP::AutoConfig::DrainTimeoutError through application shutdown. Opal keeps root DI alive while timed-out request scopes can still use it; after those scopes exit, retrying application shutdown completes singleton disposal.
Method dispatch is exact: HEAD and OPTIONS are not inferred from GET and must be registered explicitly. A method mismatch returns 405 with a stable, sorted Allow header. Applications can pass an LF::HTTP::Router::ErrorMapper to LF::HTTP::App.new to replace the default error body; the router preserves the status and Allow metadata.
Integration Pattern
Opal is easiest to integrate anywhere that already uses Crystal's HTTP::Handler chain.
That includes:
- plain
HTTP::Server - custom middleware stacks
- frameworks that expose handler-compatible extension points
Minimal pattern:
server = HTTP::Server.new([
HTTP::LogHandler.new,
SomeMiddleware.new,
app_or_router,
])
Where app_or_router can be either:
LF::HTTP::RouterLF::HTTP::App
Examples
The repository includes these examples:
-
examples/router_example.cr Basic router + JSON response example.
-
examples/api_route_di_example.cr
LF::HTTP::Controllerwith request-scoped DI and direct JSON model responses. -
examples/di_lifecycle_example.cr Standalone lifecycle example showing
after_properties_set,exit, andshutdown. -
examples/handler_stack_example.cr Integration through a normal
HTTP::Handlermiddleware stack. -
examples/application_bootstrap_example.cr Compile-time application discovery, generated entrypoints, and typed resolution.
-
examples/todo_api_sqlite End-to-end Todo API using Data entities, migrations, repositories, autoconfiguration, optimistic locking, and SQLite.
-
examples/data_layer_sqlite Standalone SQLite data-layer showcase covering mappings, explicit relationships and cascades, queries, unit of work, migrations, optimistic locking, rollback behavior, manual HTTP, and Application + DI + controller-discovery HTTP.
-
examples/live_view_counter Phoenix-free LiveView counter with constructor DI, query-param mount, click, debounced change, submit, title updates, reconnect, and HTML escaping.
-
examples/ui_showcase Tailwind-based
LF::UIshowcase covering typed variants, accessible forms, validation, switches, feedback, cards, tables, modal dialogs, dropdown menus, tabs, and toast notifications.
Run them with:
crystal run examples/router_example.cr
crystal run examples/api_route_di_example.cr
crystal run examples/handler_stack_example.cr
crystal run examples/application_bootstrap_example.cr
For the standalone SQLite Todo API example, run commands from examples/todo_api_sqlite:
shards install
crystal run src/todo_api_sqlite_example.cr
For the standalone data-layer showcase, run commands from examples/data_layer_sqlite:
shards install
crystal spec --no-color
crystal run src/data_layer_example_cli.cr
crystal run src/data_layer_example_http_cli.cr
crystal run src/data_layer_example_application_cli.cr
For the standalone LiveView example, run commands from examples/live_view_counter:
shards install
crystal spec --no-color
crystal run src/live_view_counter_example.cr
For the UI showcase, run commands from examples/ui_showcase:
shards install
crystal spec --no-color
crystal run src/ui_showcase_example.cr
Repository contributors can run the real-browser compatibility suite from the repository root with npm ci, npx playwright install --with-deps chromium, npm run check:live-view-client, and npm run test:live-view-browser.
Route Matching Rules
Current route behavior covered by specs:
- exact matches win over parameter matches
- root path
/is supported - trailing slashes are normalized
- repeated slashes are normalized
- extra path segments do not match
- multiple HTTP methods may share the same path
- unsupported methods return
405 Method Not Allowed
Responses
Opal includes these response helpers:
LF::HTTP::TextResponse.create("...")LF::HTTP::JSONResponse.create(serializable_object)
Controller methods may return a JSON::Serializable model directly; Opal writes it as application/json. Explicit LF::HTTP::Response implementations remain available when the response must control headers, status, or serialization behavior. Strings remain plain text; collections are not auto-serialized.
Error Types
HTTP layer
LF::HTTP::BadRequestLF::HTTP::ForbiddenLF::HTTP::NotFoundLF::HTTP::InternalServerError
DI layer
LF::DI::BeanNotFoundErrorLF::DI::BeanTypeMismatchErrorLF::DI::DuplicateBeanErrorLF::DI::ScopeMismatchErrorLF::DI::AmbiguousBeanErrorLF::DI::ContextClosedError
Application layer
LF::ConfigService::LoadErrorLF::ConfigService::MissingKeyErrorLF::ApplicationRuntime::ClosedErrorLF::ApplicationRuntime::ShutdownErrorLF::Data::AutoConfig::ConfigurationErrorLF::HTTP::AutoConfig::ConfigurationError
Testing
Run the full test suite:
crystal spec
License
See LICENSE.
opal
- 0
- 0
- 9
- 1
- 6
- about 2 hours ago
- May 18, 2026
MIT License
Tue, 08 Sep 2026 17:25:57 GMT