patx/wred
improve website and readme
Commit 2520d71 · patx · 2026-10-14T00:10:54-04:00
Comments
No comments yet.
Diff
diff --git a/README.md b/README.md
index 1886422..884d7aa 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,6 @@
# Wred
-A tiny Python/ASGI backend for HTML and JavaScript apps. One file. JSON from
-`orjson`, server from Granian. Pronounced "red".
+[Wred](https://harrisonerd.com/wred) A tiny and fast Python/ASGI backend JavaScript web apps. Pronounced "red".
Your API needs to return some JSON. It probably doesn't need a goddamn cathedral.
@@ -11,6 +10,7 @@ Your API needs to return some JSON. It probably doesn't need a goddamn cathedral
- Headers, query parameters, cached body reads, and JSON parsing.
- JSON responses, custom response tuples, and a small-file helper.
- Callable ASGI response support and routing relative to mounted paths.
+- Depends on `granian` and `orjson`. You don't have to use Granian however.
## What you don't get
@@ -20,7 +20,7 @@ docs, background jobs, WebSockets, or built-in streaming.
Use existing ASGI middleware for CORS, authentication, sessions, or compression.
[Starlette's middleware](https://starlette.dev/middleware/#using-middleware-in-other-frameworks)
-can wrap Wred directly. Your app chooses its dependencies; Wred doesn't need to
+can wrap Wred directly for example... Your app chooses its dependencies; Wred doesn't need to
know they exist.
## Install and run
@@ -28,14 +28,10 @@ know they exist.
Python 3.11+. For a new project:
```bash
-uv init my-app
-cd my-app
-uv add wred
+pip install wred
```
-In an existing uv project, just `uv add wred`. Granian and orjson are included.
-
-Save as `app.py`:
+Save the following in `app.py`:
```python
from wred import App
@@ -47,10 +43,10 @@ async def hello(request):
return {'hello': 'world'}
```
-And smoke it:
+Run it with Granian, or any other ASGI server you like:
```bash
-uv run granian --interface asginl app:app
+granian --interface asginl app:app
```
Open <http://127.0.0.1:8000>. Wred is HTTP-only; `asginl` disables lifespan.
@@ -162,36 +158,18 @@ JSON 400 responses: `{'error': message}`. Raise `WredRequestError` from a handle
for application validation failures. Unsupported response types produce JSON
500 errors; other exceptions propagate.
-## Example app
-
-The [notebook demo](https://gitman.io/patx/wred/src/example_app) includes signup,
-login, refresh cookies, logout, protected note CRUD, and rate limiting.
-Those features belong to the app, not Wred.
-
-```bash
-git clone https://gitman.io/git/patx/wred
-cd wred
-uv sync
-uv run granian --interface asginl --workers 1 example_app.app:app
-```
-
-Open <http://127.0.0.1:8000>. Data lives in memory and disappears on restart.
-
## Benchmarks
-`GET /` returning `{"hello": "world"}` on October 13, 2026. Three runs per
-framework, rotating their order each round.
+`GET /` returning `{"hello": "world"}`. Three runs per framework, rotating their order each round.
| Framework | Median requests/sec | Min | Max |
| --- | ---: | ---: | ---: |
-| Wred 0.01 | **272,088** | 262,187 | 277,656 |
+| Wred 0.03 | **272,088** | 262,187 | 277,656 |
| BlackSheep 2.6.4 | 211,619 | 205,719 | 220,455 |
| MicroPie 0.32 | 207,720 | 197,520 | 216,010 |
Python 3.14.4, Granian 2.8.4, eight workers, HTTP/1 keep-alive, asyncio,
no access logging. `wrk -t4 -c256 -d20s --latency`, five-second warmups.
-Client and server shared a machine over loopback. All nine measured runs had
-zero socket errors or non-success responses.
-
-About 31% more requests/sec than MicroPie for this workload. It's a tiny JSON
-response, not your whole application. Benchmark your own shit.
+Client and server shared a machine over loopback. All nine measured runs
+had zero socket errors or non-success responses. It's a tiny JSON response,
+not your whole application. Benchmark your own shit.
diff --git a/docs/index.html b/docs/index.html
index 856bce7..5e0757b 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -3,201 +3,55 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
- <meta name="description" content="Wred is a tiny Python/ASGI backend for HTML and JavaScript apps. Roll a route. Pass some JSON. Ship that shit.">
+ <meta name="description" content="Wred is a tiny Python/ASGI backend for HTML and JavaScript apps.">
<title>Wred — a tiny Python/ASGI backend</title>
<style>
:root { color-scheme: light; font-family: Arial, Helvetica, sans-serif; color: #702a36; background: #fff8f8; }
* { box-sizing: border-box; }
body { margin: 0; }
main { width: 100%; }
- .intro { text-align: center; padding: clamp(12px, 4dvh, 40px) 32px clamp(24px, 6dvh, 64px); }
+ .intro { text-align: left; padding: 12px 24px 16px; }
h1 { margin: 0; }
- h1 img { display: block; width: min(680px, 100%, 70dvh); height: auto; margin: auto; }
- .intro p { margin: 16px auto 0; max-width: 800px; font-size: clamp(22px, 2.3vw, 30px); line-height: 1.5; }
- .tagline { color: #bb2037; font-weight: 700; }
+ h1 img { display: block; max-width:50%; height: auto; margin: auto; }
+ .intro p { margin: 8px auto 0; max-width: 760px; font-size: 24px; line-height: 1.4; }
a { color: inherit; text-underline-offset: 4px; }
a:hover { color: #c92432; }
a:focus-visible { outline: 2px solid #c92432; outline-offset: 5px; }
- footer { display: flex; justify-content: center; flex-wrap: wrap; gap: 16px 32px; padding: 40px 24px; font-size: 18px; }
- .terminal { background: #fff0f0; border-top: 2px solid #dc8994; }
- pre { margin: 0; padding: clamp(12px, 3vw, 32px); overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; color: #702a36; font: 20px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; tab-size: 2; }
+ footer { display: flex; justify-content: center; flex-wrap: wrap; gap: 16px 32px; padding: 16px 24px; font-size: 15px; }
+ .examples { max-width: 800px; margin: 0 auto; padding: 0 24px; }
+ .examples pre { margin-bottom: 12px; background: #fff0f0; border: 1px solid #dc8994; border-radius: 6px; }
+ pre { margin: 0; padding: 12px 16px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; color: #702a36; font: 16px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; tab-size: 2; }
+ .install-command { white-space: pre; overflow-wrap: normal; overflow-x: auto; }
code { font: inherit; }
- .enhanced .stage { position: sticky; top: 0; height: 100dvh; }
- .enhanced .intro { position: absolute; inset: 0; z-index: 1; display: flex; flex-direction: column; justify-content: center; background: #fff8f8; }
- .enhanced .terminal-track { position: absolute; inset: 0; }
- .enhanced .terminal { height: 100%; opacity: 0; }
- .enhanced .terminal pre { height: 100%; overflow-y: hidden; }
- .typing code::after { content: '▍'; color: #c92432; }
- .token-command, .token-keyword { color: #c11e39; font-weight: 700; }
- .token-string { color: #a3482d; }
- .token-comment { color: #8e6670; font-style: italic; }
- .token-tag { color: #a51e4c; font-weight: 700; }
- .token-number { color: #a32f59; }
- .sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (max-width: 700px) {
.intro { padding-left: 12px; padding-right: 12px; }
- .intro p { font-size: clamp(18px, 5vw, 24px); }
+ .intro p { font-size: 17px; }
+ h1 img { max-width: 350px; }
}
</style>
</head>
<body>
<main>
- <div class="stage">
<header class="intro">
- <h1><a href="https://harrisonerd.com/wred"><img src="https://patx.gitman.io/wred/wred-logo.png" alt="Wred" width="1774" height="887"></a></h1>
- <p>A tiny Python/ASGI backend for HTML and JavaScript apps.</p>
- <p class="tagline">Roll a route. Pass some JSON. Ship that shit.</p>
+ <h1><a href="https://harrisonerd.com/wred"><img src="https://patx.gitman.io/wred/wred-logo.png" alt="Wred-embedded"></a></h1>
+ <p>Super fast & basic Python framework for simple JavaScript web apps. View the documentation and source <a target="_blank" href="https://gitman.io/patx/wred">here</a>.</p>
</header>
- <section class="terminal-track" aria-label="Scroll through installation and example files">
- <div class="terminal">
- <pre id="source" aria-label="Installation and example files"><code>$ uv init my-app
-$ cd my-app
-$ uv add wred
-
-$ cat > app.py <<'EOF'
-from wred import App
+ <section class="examples" aria-label="Installation and example">
+ <pre class="install-command" aria-label="Install"><code>pip install wred</code></pre>
+ <pre aria-label="app.py"><code>from wred import App
app = App()
[email protected]('/')
[email protected]("/")
async def index(request):
- return await app.static('index.html', root='static')
-
[email protected]('/data')
-async def populate(request):
- return [{"name": "Jim", "email": "[email protected]"}]
-EOF
-
-$ mkdir -p static
-$ cat > static/index.html <<'EOF'
-<ul id="users"></ul>
-<script type="module">
- const users = await fetch('/data').then(r => r.json());
- for (const user of users) {
- const li = document.createElement('li');
- li.textContent = `${user.name} (${user.email})`;
- document.querySelector('#users').append(li);
- }
-</script>
-EOF
+ return {"hello": "world"}
-$ uv run granian --interface asginl app:app
+# run with `granian app:app`
</code></pre>
- </div>
</section>
- </div>
</main>
<footer>
- <a href="https://gitman.io/patx/wred">Source on Gitman</a>
<a href="https://harrisonerd.com">© 2026 Harrison Erd</a>
</footer>
- <script>
- const source = document.querySelector('#source');
- const text = source.textContent;
- const main = document.querySelector('main');
- const stage = document.querySelector('.stage');
- const intro = document.querySelector('.intro');
- const terminal = document.querySelector('.terminal');
- const screen = source.cloneNode(true);
- screen.removeAttribute('id');
- screen.removeAttribute('aria-label');
- screen.setAttribute('aria-hidden', 'true');
- source.classList.add('sr-only');
- source.after(screen);
- const code = screen.querySelector('code');
- code.replaceChildren();
- const tokens = [];
- let offset = 0;
- let mode = 'shell';
-
- // Highlight once, then reveal the same text nodes as the page scrolls.
- const syntax = /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)|(#.*|\/\/.*)|(<\/?[a-z][\w-]*)|\b(from|import|async|def|return|await|const|for|of|new|if|throw|try|catch)\b|\b\d+\b/g;
- function add(value, type = '') {
- const span = document.createElement('span');
- if (type) span.className = `token-${type}`;
- const node = document.createTextNode('');
- span.append(node);
- code.append(span);
- tokens.push({ node, value, start: offset });
- offset += value.length;
- }
- for (const line of text.split(/(?<=\n)/)) {
- if (line.startsWith('$')) {
- add(line, 'command');
- if (line.includes('cat > app.py')) mode = 'python';
- if (line.includes('cat > static/index.html')) mode = 'html';
- } else if (line.trim() === 'EOF') {
- add(line, 'command');
- mode = 'shell';
- } else if (mode === 'shell') {
- add(line);
- } else {
- let end = 0;
- for (const match of line.matchAll(syntax)) {
- add(line.slice(end, match.index));
- const type = match[1] ? 'string' : match[2] ? 'comment' : match[3] ? 'tag' : match[4] ? 'keyword' : 'number';
- add(match[0], type);
- end = match.index + match[0].length;
- }
- add(line.slice(end));
- }
- }
-
- const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
- let scheduled = false;
- let previousLength = -1;
- const fadeDistance = 140;
- const typingDistance = 900;
- function reveal() {
- scheduled = false;
- const scroll = Math.max(0, -main.getBoundingClientRect().top);
- const fade = reducedMotion.matches ? 0 : Math.min(1, scroll / fadeDistance);
- intro.style.opacity = 1 - fade;
- intro.inert = fade === 1;
- terminal.style.opacity = reducedMotion.matches ? 1 : fade;
- const progress = Math.min(1, scroll / typingDistance);
- const length = reducedMotion.matches ? text.length : Math.floor(progress * text.length);
- if (length === previousLength) return;
- previousLength = length;
- for (const token of tokens) {
- const visible = token.value.slice(0, Math.max(0, length - token.start));
- if (token.node.data !== visible) token.node.data = visible;
- }
- screen.classList.toggle('typing', length < text.length);
- screen.scrollTop = screen.scrollHeight;
- }
- function schedule() {
- if (!scheduled) { scheduled = true; requestAnimationFrame(reveal); }
- }
- function layout() {
- document.documentElement.classList.toggle('enhanced', !reducedMotion.matches);
- screen.style.fontSize = '';
- if (!reducedMotion.matches) {
- // Fit the complete example, including wrapped lines, to this viewport.
- const measure = document.createElement('pre');
- measure.setAttribute('aria-hidden', 'true');
- measure.textContent = text;
- measure.style.cssText = `position:fixed; visibility:hidden; pointer-events:none; width:${screen.clientWidth}px; height:auto; font-weight:700;`;
- document.body.append(measure);
- let small = 1;
- let large = 20;
- for (let step = 0; step < 10; step++) {
- const size = (small + large) / 2;
- measure.style.fontSize = `${size}px`;
- if (measure.scrollHeight <= screen.clientHeight - 4) small = size;
- else large = size;
- }
- screen.style.fontSize = `${small}px`;
- measure.remove();
- }
- main.style.height = reducedMotion.matches ? '' : `${stage.offsetHeight + typingDistance + 100}px`;
- previousLength = -1;
- schedule();
- }
- addEventListener('scroll', schedule, { passive: true });
- addEventListener('resize', layout);
- reducedMotion.addEventListener('change', layout);
- layout();
- </script>
</body>
</html>