No description
  • TypeScript 92.1%
  • HTML 3.4%
  • CSS 2%
  • JavaScript 1.7%
  • Dockerfile 0.8%
Find a file
Lukas Weber 508bcace15 feat(deploy): Dockerfile with content/ and data/ as volumes
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>
2026-08-06 21:55:43 +02:00
admin feat(editor): warn before leaving with unsaved changes 2026-08-06 21:32:27 +02:00
demo refactor: move demo app to demo/; content/ starts empty for prod 2026-08-06 18:53:10 +02:00
src feat(editor): preview layouts and CSS drafts 2026-08-06 20:16:53 +02:00
.dockerignore feat(deploy): Dockerfile with content/ and data/ as volumes 2026-08-06 21:55:43 +02:00
.gitignore chore: drop content/.gitkeep — main.ts mkdirs the content root at boot 2026-08-06 19:17:51 +02:00
deno.jsonc feat(editor): CodeMirror 6 code editor in the dashboard 2026-08-06 20:09:20 +02:00
deno.lock feat(editor): CodeMirror 6 code editor in the dashboard 2026-08-06 20:09:20 +02:00
Dockerfile feat(deploy): Dockerfile with content/ and data/ as volumes 2026-08-06 21:55:43 +02:00
mise.toml chore: add mise.toml with deno tool config 2026-07-30 11:11:29 +02:00
README.md feat(deploy): Dockerfile with content/ and data/ as volumes 2026-08-06 21:55:43 +02:00

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.html in 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 a ctx object; returns plain data (rendered through the sibling template of the same base name), a Response (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.html maps to the directory URL.
  • [param] in a filename declares a route parameter: blog/[slug].html/blog/:slug.
  • Method suffixes on scripts: entries.ts handles GET; entries.post.ts handles POST — same URL, one file per method. GET is implicit, so don't write entries.get.ts: the .get is stripped and it serves GET /entries, silently colliding with entries.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 1500 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.kv facade.

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 with cat data/admin-token.
  • TOTP secret — saved to data/admin-totp. The otpauth:// URI is printed once so you can scan it into an authenticator app (or add it manually). If you miss it, delete data/admin-totp and 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).