I’ve been working on this web framework for more than 4 years now, though have been hesitant to share it as there was always something that wasn’t “finished” yet. I’ve realized that it probably never will be, so I’m finally ready to show it - even though some parts are still very rudimentary. Looking forward to some feedback!
About the name: I wanted it to start with “cr” due to the relation to Crystal. The rest rhymes with “humble”, which is always a good way of thinking, or “rumble”, which fits because some parts do intentionally clash with widely-adopted industry standards.
Some key principles:
- What can be known at compile time should not be calculated at runtime
- Leverage the Crystal compiler’s type system as much as possible
- Keep things together that have strong cohesion - like HTML templates, their styles, and related JS
- Use macros to create beautiful DSLs
- Find the sweet spot between developer happiness and performance
- Prefer everything being baked into a single executable
These principles led to some decisions that will be viewed very controversially, but so far I stand by them. This is my best effort to reflect the way I want to write web applications and the consequence of all my experience with other frameworks, although I’m of course happy to take feedback or even better ideas.
Basically I’m interested to know whether this concept resonates with anyone other than myself. Contributions welcome!
Hot Crumble
Before I go to give a code example and describe the various shards, let me begin with what is meant to be an easy starting point: The hot-crumble shard. It pulls the base framework and most of the existing extensions in a compatible, verified version constellation. The name was chosen due to strong usage of Hotwire components (Turbo and Stimulus), and because I thought it was funny.
Quick Start
Create a new folder and initialize a fresh Crystal app in there:
mkdir hot-crumble-test && cd hot-crumble-test && crystal init app .
Add the shard:
dependencies:
hot-crumble:
github: sbsoftware/hot-crumble
shards install
Use the CLI to generate a minimal project folder structure:
bin/hot-crumble init -p 8080
You can then execute the watcher script
./watch.sh (requires watchexec installed)
and should see a dummy welcome page on http://localhost:8080.
Basic Usage Example
In Crumble, you define a set of Pages the server routes to, and Actions for state-changing operations that re-render a part of a page.
The following is an example session-based app where you can create a user and add text posts. You can open a user page from multiple browsers and they should sync in real time.
Let’s re-use the welcome page generated by the CLI:
src/pages/welcome_page.cr
class WelcomePage < ApplicationPage
include Crumble::Crababel
# Default would be derived from the class name: `/welcome`
root_path "/"
template do
h1 { t.welcome }
div do
# This renders the form to create a new user
RegisterUserAction.new(ctx).action_template
end
div do
h3 { "Users" }
ul do
User.all.each do |user|
li do
a href: UserPage.uri_path(user.id) do
user.name
end
end
end
end
end
end
end
A static action (not associated to a model instance) to create a new user and keep its ID in the session (rendered by the WelcomePage above):
src/actions/register_user_action.cr
class RegisterUserAction < Crumble::Turbo::Action
form do
field name : String, label: "Name: ", allow_blank: false
end
controller do
if form.valid?
user = User.create(**form.values)
# `ctx` is the request context that often gets passed around implicitly
ctx.session.update!(user_id: user.id.value)
redirect UserPage.uri_path(user.id)
end
end
# Reachable via `#action_template`, see welcome page
view do
template do
action_form.to_html do
button { "Create User" }
end
end
end
end
For that to work, we need to add the user_id property to the session class:
src/crumble/session.cr
module Crumble
module Server
class Session
# Add any properties you need to hold in your sessions here
property user_id : Int64?
end
end
end
The user model with an action to create an associated post:
src/models/user.cr
require "./post"
class User < ApplicationRecord
column name : String
has_many_of Post
# Defines a refreshable partial; used by the `add_post` action
model_template :post_list do
h3 { "Posts" }
posts.order_by_id!(:desc).each do |post|
UserPostView.new(post)
end
end
# Define an Action that renders as a form with a body field, creates a `Post` for the user on submit, and refreshes the `post_list` template afterwards (for all sessions/clients).
# The generic macro would be `model_action`, but there are a few specialized helpers like `create_child_action` to reduce boilerplate
#
# Parameters:
# action name,
# child model,
# parent ID attribute of the child model,
# model template to refresh after submit
create_child_action :add_post, Post, :user_id, :post_list do
policy do
can_submit do
model.id.value == ctx.session.user_id
end
end
form do
field body : String, label: nil, type: :textarea, allow_blank: false
end
view do
template do
h3 { "New Post" }
div AddPostForm do
# Automatically renders all defined form fields
action_form.to_html do
button(disabled: !action.policy.can_submit?) { "Create Post" }
end
end
end
end
css_class AddPostForm
# Styles added this way are automatically served via the layout
style do
rule AddPostForm do
# Nested rules only apply to descendant elements
rule textarea do
width 200.px
height 100.px
end
end
end
end
end
And the Post model:
src/models/post.cr
class Post < ApplicationRecord
column user_id : Int64
column body : String
end
The user page to render posts and the form to create them:
src/pages/user_page.cr
class UserPage < ApplicationPage
# This directive auto-loads the user instance by the ID from the URI
model user : User
template do
h1 { user.name }
div do
a WelcomePage do
"Home"
end
end
# This references the `:add_post` action's view/template we defined in the User model
user.add_post_action_template(ctx)
# This references the `model_template` defined in the User model
user.post_list.renderer(ctx)
end
end
Create self-contained view components as plain old Crystal classes. You could even add a Stimulus controller here but I’m leaving that out for brevity.
src/views/user_post_view.cr
# Used in User#post_list
class UserPostView
getter post : Post
def initialize(@post); end
ToHtml.instance_template do
div UserPost do
post.body
end
end
# Defines a Crystal class acting as a CSS class in both templates and styles
css_class UserPost
style do
rule UserPost do
border 1.px, :solid, :gray
padding 5.px
margin_bottom 5.px
width 500.px
box_shadow 3.px, 3.px, 3.px, :silver
end
end
end
Framework Components
The Crumble framework is a composition of the following shards. Any of them that don’t start with crumble- are meant to be usable as standalone in principle. I’ve also added a rough maturity label to each.
to_html.cr - stable
HTML builder engine built with macros. Very fast, and featuring an interface to extend tags with attributes via Crystal objects.
css.cr - stable/beta
A DSL to create styleheets in pure Crystal, leveraging its type system. Defines CSS classes/element IDs as Crystal classes that can be provided to HTML tag calls. Not all properties are supported yet.
js.cr - alpha
Write Crystal code that emits JavaScript via macros. This is the most experimental part of the framework and there are a lot of improvements still on the roadmap, but it worked well so far for my use cases.
crumble - stable/beta
The centerpiece, gluing together HTML, CSS and JS generation besides providing the web server and session handling. Defines Pages and Resources as routing primitives, and Forms to validate request payloads. Also captures static asset files into the binary.
stimulus.cr + connector shard crumble-stimulus - stable
Wrapper around js.cr to define Stimulus controllers as Crystal classes which can be used to assign the controllers themselves and their actions/targets/values/outlets to HTML tags in Crystal code. The connector shard automatically captures all your controllers and serves them as an asset file in the layout.
orma + connector shard crumble-orma - incomplete/alpha
ORM wrapper with another experimental feature that I call “Continuous Migration”. Basically, you define the database structure in your models without any migrations, and Orma makes sure the schema matches at runtime. This is a sharp knive - it has some consequences in what can be done and what can’t. And it supports only the most basic scenarios so far. But I’m loving it!
Technically supports SQLite and PostgreSQL so far, but only SQLite is actually tested by me.
crumble-turbo - stable/beta
Adds Turbo to the game, introducing refreshable templates and Actions for writing operations that only refresh parts of the page.
crumble-jobs - incomplete/pre-alpha
Background jobs with no fancy experiments. Heavily inspired by ActiveJob, but still in its infancy.
crababel + connector shard crumble-crababel - incomplete/pre-alpha
I18n lib keeping translations in the binary and raising compiler errors on missing ones. Still very experimental and incomplete.
web-push + connector shard crumbe-web-push - incomplete/pre-alpha
Web Push notifications - this is what I’m currently working on. Not verified to be actually functional yet.
Example Apps
Apart from some private initiatives, I maintain a few apps to experiment and gather experience using the framework. Those are not very sophisticated and constant work-in-progress, but you can have a look at them to see how Crumble projects look that are a bit more than just dummy examples.
- grocerlyst.com (Repo) - Shareable grocery lists with real-time sync between users/devices
- splitters.money (Repo) - Splitwise clone with real-time sync (currently only in German because I18n support is lagging behind)
- hitrace.fun (Repo) - Minigame for competitive reaction-testing
Known Issues
New ideas bring new challenges, and I mainly prioritized my own use cases until now. Some shortcomings have already come to my attention but haven’t been solved yet:
- Missing documentation - there are features that just aren’t mentioned anywhere
- No multi-server support - persistence backends (sessions, background jobs) only have in-memory or file-based adapters so far
- No clear best practice on how to structure project code - models can become quite large with a growing set of actions
- Orma lacks basic functionality like indexes or grouping
- Mixing
js.crcode with existing JS libs is, at best, a pain - …I bet you’ll find more in no time! :-)