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

Shomen

CI Crystal

English | 日本語

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.js and the counter of examples/hello). Without it they are pending. SHOMEN_CHROME names 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 html lang, one title, button type, and img alt
  • Route declarations, registration, and path helpers
  • HTTP responses 200, 400, 404, and 500
  • examples/hello

Phase 2 adds these:

  • Input fields from a urlencoded form POST. A missing or malformed field is 400
  • render(view, status: 422) to redisplay a form
  • A signed shomen_session cookie. The key is SHOMEN_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 with Shomen::Server.start(https: true) so the cookie is also Secure
  • A CSRF token in the session. csrf_field(csrf_token) writes it into a form. POST, PUT, PATCH, and DELETE without the matching _csrf return 403
  • A form body over 1 MiB returns 413
  • A compile-time check that every input has a label in the same view: a label whose for: matches the input's id: (both string literals), a wrapping label, 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, use button
  • GET /greeting, POST /greeting, and GET /greeting/:name in examples/hello

Phase 3 adds these:

  • Shomen::Event: a struct that declares event_type "name". The name goes into the type column, so renaming the Crystal type keeps old rows readable. A missing or duplicate name fails at compile time
  • Shomen::Command: call returns Array(Shomen::Event) or Shomen::Rejected with messages for a 422 form
  • Shomen::Store.new("sqlite3://./var/shomen.sqlite3"): append(stream, expected_version, events) and read(after:, limit:). The file uses WAL. An append at any version other than the stream's current one raises Shomen::Conflict and writes nothing. An unhandled conflict is a 409 HTML document
  • Shomen::Projection: apply each event; catch_up applies the events after the checkpoint, including events another process appended. Call it before a view reads, and once before Shomen::Server.start to rebuild at startup
  • GET /users/:id/edit, POST /users/:id, and GET /users/:id in examples/hello

Phase 4 adds these:

  • Shomen::Fragment: a view of one element. It implements content, and writing html in it fails at compile time. A document view puts it in with embed
  • render_fragment(fragment): sends the fragment alone. render with a fragment fails at compile time
  • shomen.js, served at /shomen.js. shomen_script writes its script element. <a href="..." data-shomen-get="ID"> and <form method="post" action="..." data-shomen-post="ID"> fetch with the Shomen-Target: ID header and replace the element with that id. A redirect loads the new page. Without JavaScript the same link and form load pages as before
  • target: the id from Shomen-Target, or nil. A route answers target ? render_fragment(...) : render(...). Every response has Vary: Shomen-Target
  • json(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 to store in this process, and the stream sends its HTML when it changed. With <div data-shomen-sse="URL">, shomen.js opens the stream, and each fragment replaces the element with the same id inside that element. Appends in other processes reach it in a later phase
  • Shomen::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 with data-shomen-island="name", shomen.js calls the module's default export with the element. The framework adds no event listener to an element outside an island
  • In examples/hello, GET /counter has 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. Without max_pool_size in the URL, a process keeps at most 10 connections
  • SHOMEN_ENV=production: startup fails unless SHOMEN_SECRET has at least 32 bytes, and a 500 hides the exception message
  • SHOMEN_SECRET_VERIFY: a second secret that only verifies. A cookie it verifies is sent again under SHOMEN_SECRET, and a form rendered under either secret still posts. Change the secret in three deploys: put the new secret in SHOMEN_SECRET_VERIFY; swap the two; remove SHOMEN_SECRET_VERIFY
  • Content-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 own Content-Security-Policy keeps it
  • Shomen::Server.start(reuse_port: true): processes on one host share a port. On Linux, set net.ipv4.tcp_migrate_req=1 so 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, and start returns within shutdown_timeout (25 seconds by default). A second signal ends the process at once
  • examples/hello stays on SQLite. HELLO_DATABASE_URL=postgres://localhost/hello crystal run src/hello.cr runs 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.

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.

Repository

Shomen

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 3
  • about 1 hour ago
  • September 28, 2026
License

MIT License

Links
Synced at

Wed, 30 Sep 2026 00:26:45 GMT

Languages