- TypeScript 92.1%
- HTML 3.4%
- CSS 2%
- JavaScript 1.7%
- Dockerfile 0.8%
Self-contained image: deps cached at build, plus a one-shot Vento render so its lazily-imported parser (meriyah) is in the cache — verified to boot and render with --network none. Binds 0.0.0.0 inside the container; TLS proxy in front stays the deployment model. HEALTHCHECK hits GET / so a wedged event loop gets the container replaced; the dashboard restart button relies on the restart policy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|---|---|---|
| admin | ||
| demo | ||
| src | ||
| .dockerignore | ||
| .gitignore | ||
| deno.jsonc | ||
| deno.lock | ||
| Dockerfile | ||
| mise.toml | ||
| README.md | ||
Loom
A personal web server you edit while it runs.
What is this?
Loom is a web server for one person: you. It serves your site — pages, APIs, files — and lets you change everything about it through a built-in dashboard, with no deployment step, no restarts, and no downtime. Edit a template in your browser, hit save, refresh the page. That's the whole workflow.
The name is the metaphor: a loom holds a warp of fixed threads and weaves new material through them continuously. Loom's core is the warp — small, stable, always running. Your content is the weft, changed freely while the machine runs.
Why does it exist?
Modern deployment is built for teams: containers, CI pipelines, immutable infrastructure, rollbacks across fleets. For a personal project this is all friction with none of the payoff. You don't need reproducibility across a hundred machines. You need to fix a typo in your footer without a pipeline run.
The old answer to this was PHP — drop a file, it's live. Loom keeps that immediacy but adds structure: a real template language, a key-value store, TypeScript scripts, and a management API, all behind one coherent design.
The model
One content/ folder holds everything. The file tree is the route table — no separate registry.
Extension decides how a file is handled:
.html— Vento template. Rendered per request against a data context; composable with{{ include }}, wrapped by_layout.htmlin the same directory (nested outward to the root). HTMX requests receive the page fragment without layouts automatically..ts— TypeScript script handler. Exports a single default function receiving actxobject; returns plain data (rendered through the sibling template of the same base name), aResponse(passed through), or a render directive (for non-200 status, OOB updates, etc.).- Anything else — served as-is with automatic content type. CSS, images, downloads.
Naming conventions carry the routing intent:
_-prefixed files and folders are private: layouts, partials, shared helpers. Never routed.index.htmlmaps to the directory URL.[param]in a filename declares a route parameter:blog/[slug].html→/blog/:slug.- Method suffixes on scripts:
entries.tshandles GET;entries.post.tshandles POST — same URL, one file per method. GET is implicit, so don't writeentries.get.ts: the.getis stripped and it serves GET/entries, silently colliding withentries.ts.
A draft: true line in a file's directive header (<!--loom … --> for a template,
//! draft: true for a script) keeps a page unpublished without renaming it: visitors get a 404
and it is left out of pages() listings, but a logged-in admin can still open it at its real URL to
preview it live. Drop the line to publish. (The _ prefix remains the way to keep a file fully
unrouted, including for you — draft is the softer, admin-previewable option.)
Page content lives on disk; KV holds small dynamic state (guestbook entries, counters, sessions).
Scripts access KV through ctx.kv; templates read it via the kv context variable.
A taste
A blog index at content/blog/index.html — no handler needed:
<!--loom
title: Blog
-->
<h1>Blog</h1>
<ul>
{{ for post of await pages("/blog") }}
<li><a href="{{ post.url }}">{{ post.meta.title }}</a> ({{ post.meta.date }})</li>
{{ /for }}
</ul>
Drop another .html file into content/blog/ and it appears. Titles come from each file's own
<!--loom title: … --> directive — no database.
A guestbook with two handlers side by side:
content/guestbook/entries.ts — GET, returns data for the sibling template:
export default async function (ctx) {
const entries = (await ctx.kv.list(["guestbook"])).map((e) => e.value);
return { entries };
}
content/guestbook/entries.post.ts — POST, writes and redirects. Public write endpoints follow one
shape: validate the input, rate-limit by client IP (ctx.rateLimit), then touch KV.
export default async function (ctx) {
const msg = ctx.form?.msg?.trim();
if (!msg || msg.length > 500) {
return new Response("Message must be 1–500 characters", { status: 400 });
}
if (!ctx.rateLimit("guestbook-post", { limit: 5, windowMs: 60_000 })) {
return new Response("Slow down", { status: 429 });
}
await ctx.kv.set(["guestbook", Date.now()], msg);
return new Response(null, { status: 303, headers: { Location: "/guestbook" } });
}
For HTMX requests the 303 is translated to HX-Location automatically. Reads should cap the list
too — ctx.kv.list(["guestbook"], { limit: 100, reverse: true }). No build step. No deploy. Save
and it's live.
Design principles
- The operator is trusted. Loom is single-user by design. It surfaces your mistakes — template render errors are shown in detail to an admin session — but it never treats you as an adversary.
- Missing things fail loud, not silent. A template referencing a field the handler didn't provide logs an error with the template name, the field, and the route. Visitors get the generic error page; you see the detail. There is no mystery corruption.
- Everything is inspectable. Routes are derived from the file tree. Data is a SQLite file. Templates are text files. Scripts are TypeScript files. Back up your site with tar. Diff it with git. There is no opaque state.
- Small language, clean composition. Templates express conditional and iterative rendering with
Vento
{{ if }}/{{ for }}; deeper complexity means a script handler — an explicit, always- available upgrade. - One writer, no races. The core owns the KV store; scripts access it in-process through the
ctx.kvfacade.
Architecture at a glance
┌──────────────────────────────┐
HTTP ────────▶ │ core server │
│ scan · dispatch · render │
└──────┬───────────────────────┘
│
content/ file tree → routes scanned at startup,
rescanned on file change
.html → Vento templates (in-process, mtime-cached)
.ts → dynamic import, cache-busted by mtime
other → static files with ETag
│
▼
┌──────────────────────────────┐
│ KV store (SQLite on disk) │
└──────────────────────────────┘
/admin ── dashboard (Loom app, session-protected)
/admin/api ── management API served by the core directly
Scripts run in-process via cache-busted dynamic import — no worker subprocesses, no IPC. The admin
API is served by the core itself so the repair tool cannot depend on the thing it repairs. The
dashboard is a Loom application at /admin, reachable after session login with the admin token.
What it is not
- Not multi-user. One operator, one admin token. No roles, no permissions between authors.
- Not horizontally scalable. One process, one disk. If you need a fleet, you need different software.
- Not a framework. There's no ORM, no middleware chain, no plugin ecosystem. Three resource types, one data store, one route table derived from the file tree.
- Not finished. No cron, no WebSockets, no transactions in KV, no auth for site visitors. The roadmap grows from use.
Run
Local development:
deno task dev # plain-HTTP cookies, no TOTP prompt — serves the demo app in demo/
Production:
deno task start # secure cookies + TOTP on by default — serves content/ (initially empty)
On first boot Loom creates two secrets under data/ and prints, once:
- Admin token — a 256-bit secret saved to
data/admin-token. The value is not logged; read it withcat data/admin-token. - TOTP secret — saved to
data/admin-totp. Theotpauth://URI is printed once so you can scan it into an authenticator app (or add it manually). If you miss it, deletedata/admin-totpand restart to generate and print a fresh one.
Visit /admin and log in with the token and the current 6-digit code. A demo app is included
in demo/ (served by deno task dev) so you can explore the blog, guestbook, and other examples
immediately.
Configuration
Boot-time settings come from environment variables; runtime protection knobs are edited live in the
dashboard (/admin/settings, stored in data/settings.json).
| Env var | Default | Meaning |
|---|---|---|
PORT |
8765 |
Listen port |
LOOM_HOST |
127.0.0.1 |
Bind address. Behind a proxy keep this loopback; set 0.0.0.0 only to expose Loom directly (e.g. LAN dev) |
LOOM_CONTENT |
./content |
Content root |
LOOM_SECURE_COOKIES |
on | Secure cookie attribute; set 0 for plain-HTTP localhost |
LOOM_TOTP |
on | Second-factor requirement; set 0 to disable (dev) |
LOOM_TRUSTED_PROXY |
off | Set 1 when behind a reverse proxy so the client IP is taken from the last X-Forwarded-For hop |
Live settings (dashboard): max request body size (non-admin), request timeout, max in-flight
requests, login rate-limit, memory limit (watchdog), access logging. Each has a safe range enforced
in code — a bad value can't brick the server, and data/settings.json can be edited or deleted by
hand while the server is down to recover.
Deployment
Loom speaks plain HTTP and expects a TLS-terminating reverse proxy in front (e.g. Coolify's built-in
proxy). Point the proxy at Loom, forward the Host header (needed for the admin CSRF origin
check), and let it append the client IP to X-Forwarded-For. Then set LOOM_TRUSTED_PROXY=1.
Persist data/ — it holds the KV database, the admin token, the TOTP secret, and settings.json.
chmod 700 data so the secrets stay private.
The included Dockerfile bakes the server and dashboard into a self-contained image (no network
needed at boot) and expects content/ and data/ mounted as separate volumes:
docker build -t loom .
docker run -d --restart unless-stopped \
-p 127.0.0.1:8765:8765 \
-v /srv/loom/content:/app/content \
-v /srv/loom/data:/app/data \
-e LOOM_TRUSTED_PROXY=1 \
loom
The restart policy is required: the dashboard's restart button and the memory watchdog exit the
process and rely on the platform to bring it back. First boot prints the TOTP enrollment URI to the
container log (docker logs); the admin token is in the data volume. The image binds 0.0.0.0
(container-internal); publish the port on loopback as above and keep the proxy as the only public
entrance. A HEALTHCHECK against GET / is built in.
Because a synchronous infinite loop in an operator script blocks the event loop (Loom runs scripts
in-process, by design), configure a platform healthcheck against GET / so an unresponsive process
is restarted for you. The in-app memory watchdog and the dashboard restart button handle the milder
cases (memory growth, a wedged async handler).