Shomen
Shomen
Shomen is a Crystal web framework. The server returns HTML documents. One route declaration is the contract for a page, and basic accessibility mistakes fail at compile time.
Version 0.0.0. Phases 1 to 6 are in the tree: typed routes, a typed HTML DSL, an HTTP server, form binding, a signed session cookie, CSRF protection, commands and events, an append-only event store on SQLite or Postgres, in-memory projections, HTML fragments, the official shomen.js, JSON responses, SSE, islands, and what production needs (a required secret, a Content-Security-Policy, port sharing, and graceful shutdown). Phase 7 is specified and not implemented. There is no release tag yet.
Requirements
- Crystal 1.20 or newer
- shards
- The SQLite 3 library (
libsqlite3) - Postgres, only for an application that uses it, and to run the Postgres specs
- Google Chrome or Chromium, only to run the browser specs (
shomen.jsand the counter ofexamples/hello). Without it they are pending.SHOMEN_CHROMEnames the binary
The framework shard depends on sqlite3, pg, and db from crystal-lang and will/crystal-pg. pg is written in Crystal and needs no C library.
Run the example
cd examples/hello
shards install
crystal run src/hello.cr
Open http://127.0.0.1:3000. GET / returns a document that contains <h1>Hello</h1>.
Use it from an application
shard.yml:
dependencies:
shomen:
github: SilentMalachite/Shomen
branch: main
examples/hello depends on the local checkout with path: ../.. instead.
require "shomen"
module Hello
class ShowView < Shomen::View
def to_html : String
html lang: "en" do
head do
title "Hello"
end
body do
h1 "Hello"
end
end
end
end
class Show < Shomen::Route
method GET
path "/"
struct Input
end
def call(input : Input) : Shomen::Response
render ShowView.new
end
end
end
Shomen::Server.start
Hello::Show.path returns "/". Text is escaped. html requires a lang literal such as "en", and the document needs exactly one title. button requires type: "submit" | "button" | "reset". img requires an alt literal, and alt: "" is allowed.
The server listens on 127.0.0.1:3000. A match returns 200 HTML. A bad path parameter returns 400. An unknown path, or Shomen::NotFound, returns 404 HTML. An unhandled exception returns 500 HTML with the message escaped; with SHOMEN_ENV=production the message is hidden. The exception goes to Log under shomen in every environment. Every response sets X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, X-Frame-Options: DENY, and a Content-Security-Policy (phase 6 below).
Phases 1 to 6 are what run
Phase 1 builds these:
- HTML elements listed in the specification, plus escaping
- Compile-time checks for
htmllang, onetitle,buttontype, andimgalt - Route declarations, registration, and path helpers
- HTTP responses 200, 400, 404, and 500
examples/hello
Phase 2 adds these:
Inputfields from a urlencoded form POST. A missing or malformed field is 400render(view, status: 422)to redisplay a form- A signed
shomen_sessioncookie. The key isSHOMEN_SECRET. Without it, a random key lasts until restart. The server keeps no session state, so with a fixed key a session survives a restart. Behind HTTPS, start the server withShomen::Server.start(https: true)so the cookie is alsoSecure - A CSRF token in the session.
csrf_field(csrf_token)writes it into a form. POST, PUT, PATCH, and DELETE without the matching_csrfreturn 403 - A form body over 1 MiB returns 413
- A compile-time check that every
inputhas a label in the same view: alabelwhosefor:matches the input'sid:(both string literals), a wrappinglabel, or a non-empty"aria-label"or"aria-labelledby". The view's other methods, its parent views, and included modules count.type: "hidden"is exempt. For a submit control, usebutton GET /greeting,POST /greeting, andGET /greeting/:nameinexamples/hello
Phase 3 adds these:
Shomen::Event: a struct that declaresevent_type "name". The name goes into thetypecolumn, so renaming the Crystal type keeps old rows readable. A missing or duplicate name fails at compile timeShomen::Command:callreturnsArray(Shomen::Event)orShomen::Rejectedwith messages for a 422 formShomen::Store.new("sqlite3://./var/shomen.sqlite3"):append(stream, expected_version, events)andread(after:, limit:). The file uses WAL. An append at any version other than the stream's current one raisesShomen::Conflictand writes nothing. An unhandled conflict is a 409 HTML documentShomen::Projection:applyeach event;catch_upapplies the events after the checkpoint, including events another process appended. Call it before a view reads, and once beforeShomen::Server.startto rebuild at startupGET /users/:id/edit,POST /users/:id, andGET /users/:idinexamples/hello
Phase 4 adds these:
Shomen::Fragment: a view of one element. It implementscontent, and writinghtmlin it fails at compile time. A document view puts it in withembedrender_fragment(fragment): sends the fragment alone.renderwith a fragment fails at compile timeshomen.js, served at/shomen.js.shomen_scriptwrites itsscriptelement.<a href="..." data-shomen-get="ID">and<form method="post" action="..." data-shomen-post="ID">fetch with theShomen-Target: IDheader and replace the element with that id. A redirect loads the new page. Without JavaScript the same link and form load pages as beforetarget: the id fromShomen-Target, ornil. A route answerstarget ? render_fragment(...) : render(...). Every response hasVary: Shomen-Targetjson(value, status = 200):application/json. The server never chooses JSON on its own, and errors stay HTML documents- In
examples/hello, the greeting form is a fragment: "Change" opens it in place, and a 422 replaces only the form
Phase 5 adds these:
sse(store) { fragment }: an event stream. The fragment renders now and again after each append tostorein this process, and the stream sends its HTML when it changed. With<div data-shomen-sse="URL">,shomen.jsopens the stream, and each fragment replaces the element with the same id inside that element. Appends in other processes reach it in a later phaseShomen::Island.script "name", "file.js": reads an ES module of the application at compile time and serves it at/islands/name.js. For each element withdata-shomen-island="name",shomen.jscalls the module's default export with the element. The framework adds no event listener to an element outside an island- In
examples/hello,GET /counterhas a counter island
Phase 6 adds these:
Shomen::Store.new("postgres://localhost/app"): the same store on Postgres. The URL's scheme picks SQLite (sqlite3) or Postgres (postgres,postgresql); commands, events, and projections do not change. An append takes one advisory lock, so ids become visible in order. Withoutmax_pool_sizein the URL, a process keeps at most 10 connectionsSHOMEN_ENV=production: startup fails unlessSHOMEN_SECREThas at least 32 bytes, and a 500 hides the exception messageSHOMEN_SECRET_VERIFY: a second secret that only verifies. A cookie it verifies is sent again underSHOMEN_SECRET, and a form rendered under either secret still posts. Change the secret in three deploys: put the new secret inSHOMEN_SECRET_VERIFY; swap the two; removeSHOMEN_SECRET_VERIFYContent-Security-Policy: default-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; object-src 'none'on every response. A route that sets its ownContent-Security-Policykeeps itShomen::Server.start(reuse_port: true): processes on one host share a port. On Linux, setnet.ipv4.tcp_migrate_req=1so the connections waiting on a process that stops move to the others- On SIGTERM or SIGINT the server stops accepting, closes idle connections and SSE streams, finishes the requests in progress with
Connection: close, andstartreturns withinshutdown_timeout(25 seconds by default). A second signal ends the process at once examples/hellostays on SQLite.HELLO_DATABASE_URL=postgres://localhost/hello crystal run src/hello.crruns it on an existing Postgres database
Phase 7 is specified and not in the code: consumers that run outside the request, notifications across processes, HTTP caching, and reads from replicas.
The phase list is in docs/en/02-PHASES.md.
Specification
English is the canonical text.
| English | 日本語 | |
|---|---|---|
| Specification | docs/en/00-INSTRUCTION.md | docs/00-INSTRUCTION.md |
| Architecture | docs/en/01-ARCHITECTURE.md | docs/01-ARCHITECTURE.md |
| Phases | docs/en/02-PHASES.md | docs/02-PHASES.md |
| Conventions | docs/en/03-CONVENTIONS.md | docs/03-CONVENTIONS.md |
Decision records stay in Japanese under docs/decisions/.
Development
See CONTRIBUTING.md. From the repository root:
shards install
crystal spec
crystal build src/shomen.cr --error-trace
cd examples/hello && shards install && crystal spec
Without SHOMEN_SPEC_POSTGRES the Postgres specs are pending. To run them, set it to a Postgres URL whose user may create databases, such as SHOMEN_SPEC_POSTGRES=postgres://localhost/postgres crystal spec. Each example creates a database and drops it.
On every pull request and push to main, GitHub Actions (.github/workflows/ci.yml) runs crystal tool format --check, the build, crystal spec with a Postgres 17 service and headless Chrome (so no spec is pending there), and the examples/hello specs.
crystal build writes ./shomen. Do not commit that binary.
License
MIT. Copyright 2026 Silent Malachite.
Shomen
- 0
- 0
- 0
- 0
- 3
- about 1 hour ago
- September 28, 2026
MIT License
Wed, 30 Sep 2026 00:26:45 GMT