Tiny HTTP server that renders stylized ASCII banners over curl https://shout.sh
  • Rust 72.8%
  • TypeScript 11.8%
  • HTML 4.8%
  • Shell 4%
  • CSS 3.4%
  • Other 3.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-30 00:06:49 +01:00
.github ci: check the workspace with its declared rust-version (#53) 2026-09-29 23:40:34 +01:00
scripts feat(worker): lower rate limits to 30 requests and 5 streams a minute (#41) 2026-09-29 12:29:14 +01:00
shout-core fix(core): keep glyph '<' in browser renders (#54) 2026-09-30 00:04:01 +01:00
shout-wasm perf(wasm): render render_once_html through the frame path (#55) 2026-09-30 00:06:49 +01:00
shout-worker refactor(core): share Shader::for_mode across Worker, wasm and bench (#48) 2026-09-29 14:29:10 +01:00
web fix(deps): update js-yaml to 4.3.2 (#44) 2026-09-29 12:59:21 +01:00
.editorconfig feat!: pivot stack to rust + cfonts 2026-04-21 16:42:00 +01:00
.gitignore feat(worker): lower rate limits to 30 requests and 5 streams a minute (#41) 2026-09-29 12:29:14 +01:00
Cargo.lock test(core): add render benchmarks for shout-core (#42) 2026-09-29 12:49:18 +01:00
Cargo.toml test(core): add a Fire one-slot render benchmark and declare rust-version 1.90 (#47) 2026-09-29 14:26:48 +01:00
deny.toml chore(security): pnpm 11 posture + cargo-deny audit (#19) 2026-08-07 16:53:08 +01:00
Justfile test(core): add render benchmarks for shout-core (#42) 2026-09-29 12:49:18 +01:00
LICENSE feat: ship static http banner service (phase 1) 2026-04-21 16:42:01 +01:00
mise.toml chore: pin node and pnpm in mise.toml (#31) 2026-09-29 10:31:18 +01:00
README.md feat(worker): lower rate limits to 30 requests and 5 streams a minute (#41) 2026-09-29 12:29:14 +01:00
renovate.json chore(ci): run smoke and web format check in CI, group worker crates (#27) 2026-09-29 01:13:46 +01:00
SECURITY.md feat: serve shout.sh from a Rust Cloudflare Worker (#26) 2026-09-28 23:48:54 +01:00

shout.sh

a tiny http service that renders stylized ascii banners over curl. it runs as a cloudflare worker.

$ curl shout.sh/HELLO
$ curl shout.sh/tiny/hello+world
$ curl shout.sh/red/alert
$ curl shout.sh/fire/boom

rainbow and fire animate by default — frames stream live to your terminal. (if you're piping or redirecting, add -N to disable curl's output buffering.)

usage

everything lives in the url path. the first segment is an optional set of +-joined directives; the rest is the text. spaces are +.

$ curl shout.sh/{directives}/{text}

directives are classified in order: font, mode, color. unknown tokens are ignored. if no directive in the first segment matches, the whole path is treated as text.

fonts

13 fonts, courtesy of cfonts:

block (default), slick, tiny, grid, pallet, shade, chrome,
simple, simpleblock, 3d, simple3d, huge, console
$ curl shout.sh/tiny/hello+world
$ curl shout.sh/fonts         # list
$ curl shout.sh/fonts/block   # preview

colors

naming a color implies solid mode:

$ curl shout.sh/red/hi
$ curl shout.sh/cyanbright/ok

available: red, green, blue, yellow, cyan, magenta, white, gray, and a *bright variant of each.

presets

curated multi-color palettes for fonts that support more than one color layer (block, chrome, 3d, etc. use two; chrome uses three). on single-color fonts like tiny the first stop is used and the rest are silently dropped, so the same preset name "just works" everywhere.

$ curl shout.sh/sunset/hi        # two-tone on block
$ curl shout.sh/ocean/3d/Hello   # preset + font
$ curl shout.sh/presets          # list presets
$ curl shout.sh/presets/sunset   # preview one

available: sunset, ocean, mint, candy, matrix, mono, neon, ember. presets imply solid mode — combining one with rainbow or fire lets the animated mode win.

modes

$ curl shout.sh/solid/hi       # solid white (or pair with a color)
$ curl shout.sh/rainbow/hi     # animated hsl hue ring
$ curl shout.sh/fire/hi        # animated red/orange/yellow flicker

solid and bare colors never animate. rainbow and fire animate by default — add once to force a static frame.

animation

$ curl shout.sh/rainbow/hi
$ curl 'shout.sh/fire/boom?fps=20&timeout=30'
$ curl shout.sh/rainbow+once/hi       # single static frame
$ curl shout.sh/solid+animate/ok      # stream a still frame (pointless, works)
  • animate — force animation on any mode.
  • once — force a single static frame.
  • ?fps=N — frames per second. default 10, capped at 30.
  • ?timeout=N — seconds before the server closes the stream. default 60, capped at 300.

a large banner can lower both. each stream is held to 1 MB/s and 64 MB in total, estimated from the size of its first frame: fps drops first, then timeout, never below 1. a normal word or phrase at the default fps and timeout stays well under both. when a stream is lowered, the response carries a header with the values it runs at, e.g. X-Shout-Capped: fps=7; timeout=60. nothing is added to the body.

browsers (detected by Accept: text/html or User-Agent: Mozilla/*) are sent a single static frame — a hung tab is not a good time.

query params

$ curl 'shout.sh/hi?font=tiny&mode=fire&once'
$ curl 'shout.sh/HELLO?format=json'

supported: font, mode, color, preset, format, animate, once, fps, timeout. query params override path directives.

format=json always returns a single static frame — json and animation don't mix.

limits

each client ip (an ipv6 /64) gets 30 requests a minute, and 5 animated streams a minute on top. over the limit, shout.sh answers 429 too many requests with Retry-After: 60. /health, /_app/*, /favicon.* and /og.png are not counted. the counts are kept per cloudflare location and are approximate. the numbers live in shout-worker/wrangler.toml.

a client can also hold at most 3 animated streams open at once (MAX_STREAMS in shout-worker/src/slots.rs). a fourth gets 429 too many requests, with Retry-After set to the seconds until the oldest open stream reaches its timeout, plus 2. closing a stream frees its slot straight away. a stream refused because 3 are already open still counts toward the 5 a minute. a cloudflare durable object per client keeps the open slots: a random id and an end time for each, nothing else. the object is named by a keyed hash (hmac-sha256) of the client ip, not the ip. if the SLOT_KEY_SECRET secret is unset, or the object cannot be reached within a second, the stream goes ahead.

endpoints

path description
/ plain-text help
/{text} render text
/{dir}/{text} render with config
/fonts list fonts
/fonts/{name} preview a font
/presets list presets
/presets/{name} preview a preset
/health health check

playground

open shout.sh in a browser and type. the same rendering pipeline that serves curl is compiled to wasm and runs locally — every frame is rendered in your tab, no streaming, no round-trips. the page shows the exact curl command for the current state so you can copy it and paste into a terminal.

$ curl shout.sh/           # plain text help (curl)
$ curl -H 'Accept: text/html' shout.sh/   # the playground html

development

$ just wasm-build     # cfonts → wasm32 via wasm-pack, for the playground
$ just web-build      # wasm-build + pnpm build of the ts client → web/dist/
$ just worker-install # pinned wrangler into shout-worker/node_modules
$ just worker-build   # the worker → shout-worker/build/ via worker-build
$ just dev            # esbuild watcher + wrangler dev on :8787

just dev reads secrets from shout-worker/.dev.vars (gitignored). for the open-stream cap, put a throwaway SLOT_KEY_SECRET=... line there; without it the cap is off. just smoke passes its own value.

needs rust with the wasm32-unknown-unknown target, wasm-pack, worker-build (cargo install worker-build --version =0.8.6 --locked), node and pnpm.

the worker lives in shout-worker/. routing, parsing and stream framing are plain rust in src/app.rs and src/stream.rs, so cargo test --all covers them on the host. src/glue.rs is the wasm-only part: it reads the request, fetches html, css, js and wasm from workers static assets (web/dist/), and drives animation frames on a timer.

justfile

$ just              # list targets
$ just test         # cargo test --all
$ just lint         # fmt check + clippy -D warnings (host and wasm32)
$ just smoke        # assets, streaming and HEAD through wrangler dev
$ just parity       # diff https://shout.sh against a running `just dev`
$ just ci           # web-build + lint + test + worker build

deployment

pushes to main deploy through the deploy job in .github/workflows/ci.yml, after ci passes. the ci job uploads the built worker and web/dist/; the deploy job downloads them and runs wrangler deploy from shout-worker/ in the production environment, which holds CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID. it never builds, so the build toolchain never shares a runner with the token. without the token the job skips the upload.

shout-worker/wrangler.toml sets the routes (shout.sh/*, www.shout.sh/*), a per-request cpu limit (needs the workers paid plan), the two rate-limit bindings, the STREAM_SLOTS durable object and its migration, and the SHOUT_EVENTS analytics engine dataset. the SLOT_KEY_SECRET secret is set once with wrangler secret put, not in ci. src/event.rs documents its columns.

license

shout.sh is licensed under the gnu general public license v3.0 or later. see LICENSE for the full text.

built with cfonts (gpl-3.0-or-later). linking cfonts in-process makes the combined work gpl-3 — fine for this project.