The Crumble framework

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.cr code with existing JS libs is, at best, a pain
  • …I bet you’ll find more in no time! :-)
5 Likes