Installation

# Add this to your shard.yml
dependencies:
  action_pack_cr:
    github: OrelSokolov/action_pack.cr
    branch: master

master is a branch, not a release, so this tracks it rather than pinning a version.

Then run:

shards install

shard.yml

Crystal
>= 1.21.0

Dependencies

Runtime Dependencies

README

This README is the one indexed from the repository at its latest ref, not from the tag for this version.

action_pack.cr

A Crystal port of Rails ActionPack 8.1.4 — controllers, routing, strong parameters, cookies/session/flash, CSRF protection, HTTP caching and streaming. Namespaces ActionController / ActionDispatch mirror the original 1:1. Behavioral parity is verified against the real gem via oracle specs (Ruby cases recorded with actionpack 8.1.4, replayed in Crystal without edits).

⚠️ EXPERIMENTAL FRAMEWORK, DO NOT USE IN PRODUCTION YET.

Installation

dependencies:
  action_pack_cr:
    github: OrelSokolov/action_pack.cr
shards install

Requires Crystal >= 1.21.0. Works with active_support.cr, action_view.cr and railties.cr (boot/middleware ladder).

Usage

class ApplicationController < ActionController::Base
end

class PostsController < ApplicationController
  before_action :authenticate, only: [:index]

  def index
    @posts = Post.all
  end

  def create
    post = Post.new(params.require(:post).permit(:title, :body))
    if post.save
      redirect_to posts_path, notice: "Created"
    else
      render :new, status: :unprocessable_entity
    end
  end
end

# config/routes.cr
CRails.application.routes.draw do
  resources :posts
  root "welcome#index"
end

Run the suite with make spec (or CRAILS_VIEWS=spec/fixtures/app/views crystal spec).

Feature comparison vs actionpack 8.1.4

Controllers & callbacks

FeatureOriginal (actionpack)Port (action_pack.cr)Status
ActionController::Base inheritance✅Same class hierarchy✅
before_action / skip_before_action (only:/except:/if:/unless:)✅Same options via macro✅
around_action (symbol + block, yield continues the chain)✅Same; one generated __around_step_N per step (Crystal macro recursion limit)✅
rescue_from ... with:✅Macro-based✅
respond_to do |format| ... end✅Fixed format.html/format.json methods instead of a method_missing collector🔶
render plain:/html:/json:/template:/partial:/layout:, status:, formats:, locals:, render_to_string, head✅Same✅
.json.jbuilder action templates (implicit render for format=json)✅Via jbuilder.cr; explicit render is named-arg only (render(template:)) — positional render("...") is the JSON body🔶
redirect_to, redirect_back fallback_location:, redirect_back_or_to, notice:/alert:✅Same✅

Routing

FeatureOriginalPortStatus
root, verb helpers get/post/patch/put/delete (to:, as:, defaults:, on: member/collection)✅Same; inside a resources block to: may be omitted (controller/action inferred)✅
resources / resource (only:, except:, controller:, param:, path:, as:, defaults:) with block nesting, member/collection✅Standard seven routes in Rails draw order; a block nests under the member path (/rooms/:id/messages), names follow Rails (edit_room_message)✅
resources / resource shallow:, concerns:, concern✅Not ported❌
namespace, scope (path:, as:, module:, defaults:)✅Same; module: prefixes to: controllers too (leading / opts out), defaults: fill params the path leaves unset✅
scope constraints:, concern/concerns, wildcard *path, optional segments foo(/bar), match via:, route-level redirect✅Not ported❌
Named helpers posts_path, edit_post_path(@post)✅Macro-generated from resources/get as:✅
Polymorphic array URLs url_for([:edit, @post])✅~70%🔶

Strong parameters

FeatureOriginalPortStatus
params[:id], require(:post) (raises ParameterMissing → 400), permit, expect, to_h/to_unsafe_h/slice/except/dig/fetch✅Same; query + JSON/form body parsing, nested form keys post[title]=x✅
Nested filters permit(:title, tags: [], addresses: [:id])✅Hash-argument form permit(:title, {"author" => [:name]}) — **opts with heterogeneous values breaks Crystal 1.21🔶
HashWithIndifferentAccess semantics✅Symbol|String keys without distinction (approximation)🔶

Cookies / session / flash

FeatureOriginalPortStatus
cookies.set with expires:/path:/domain:/secure:/httponly:/same_site:, cookies.delete✅Rack-identical Set-Cookie attribute order/format✅
cookies.signed / cookies.encrypted / cookies.permanent (+ chains)✅Byte-identical wire format (MessageVerifier SHA1 / AES-256-GCM, legacy {"_rails":...} envelope), purpose isolation, CookieOverflow > 4096 B✅
session / flash in signed cookie✅With SECRET_KEY_BASE set; legacy Base64-JSON always readable (transition mode)🔶
flash[:notice]/now/keep/discard, add_flash_types✅Same✅

CSRF protection

FeatureOriginalPortStatus
protect_from_forgery with: :exception/:null_session/:reset_session, skip_forgery_protection✅Token wire format 1:1 (BREACH mask, global + per-form HMAC-SHA256, Crypto::Subtle compare); param or X-CSRF-Token header✅
Origin check, JS-response InvalidCrossOriginRequest✅Off by default, as in the gem✅
Per-class mutators, custom strategy classes✅Global config only; with: is a Symbol❌ (equivalent: global ActionController.* setters)

HTTP caching & streaming

FeatureOriginalPortStatus
fresh_when / stale? (etag/weak/strong/last_modified/public)✅strict_freshness semantics (Rails 8.1 default), weak ETag W/"sha256[0,32]"✅
expires_in, http_cache_forever, no_store✅Not ported⬜
Record validators in fresh_when(@article), template digest✅etag: accepts a String only❌ (equivalent: precompute the digest string)
send_data / send_file (filename/type/disposition), RFC 5987 Content-Disposition✅Subset of Rack::Mime for type lookup; missing file → 404✅
send_file Range requests✅Not ported⬜
ActionController::Live / SSE (response.stream, send_stream)✅Fiber-based: backpressure (buffer queue_size=10), client-disconnect cancellation, byte-exact SSE wire format✅

Beyond parity (not in actionpack)

FeaturePortStatus
TypedParams — compile-time typed action paramsDSL mirrors dry-schema 1.13.4 semantics (42/42 oracle cases); invalid params → 422 JSON errors✅
OpenAPI 3.0.3 codegen from the route table (openapi "/openapi.json")Verified with openapi3_parser 0.10.1✅
Rate limiting20/20 oracle cases✅

Deviations & Crystal-specific notes

  • Set-Cookie headers are written directly to response.headers (no deferred commit); HTTPS detection uses X-Forwarded-Proto (Crystal's HTTP::Server is unaware of TLS termination); SameSite is not injected by default (railties territory); the cookie verifier supports only SHA1/SHA256 digests.
  • In Live: IOError maps to IO::Error; ActionView completion is not written when an exception fires after commit (matches observed gem behavior); abort wakes a blocked producer immediately; id: 0 is valid (the Ruby falsy quirk is not reproducible); SSE non-String payloads use stdlib to_json.
  • permit/expect nested filters use a Hash argument (see table).
  • HTTP layer is Crystal HTTP::Server with an HTTP::Handler chain exposing the same stack operations (use/insert_before/insert_after/ swap/delete); concurrency is fiber-per-connection.
  • Benchmarks (bench/results/): SSE fanout ~482–622k events/s vs Rails+Puma 31–41k (~15×); chunked ~710–887k chunks/s (~6–7×); typed params ~30× vs dry-schema.

Testing

make spec   # or: CRAILS_VIEWS=spec/fixtures/app/views crystal spec

Oracle specs: Ruby cases in oracle/*_cases.rb are recorded with the real actionpack 8.1.4 into oracle/*_expected.yml, then replayed byte-for-byte by the Crystal mirror in spec/. Transport-level specs (Live/SSE, CSRF, data streaming, typed params E2E) live in railties.cr/spec/ against a real HTTP::Server.