A read-only, JavaScript-free web client for Mastodon, rendered entirely on the server.
Every page mastoview returns is a complete, ordinary HTML document. There is no JavaScript to download or execute: no SPA, no hydration, no websockets, no browser-side API calls. The site works with JavaScript disabled, with CSS disabled, and with images disabled. You can read it with curl, a text browser, or a screen reader.
curl https://your-domain.example/fosstodon.org/public
returns the actual timeline HTML — not a shell waiting for scripts.
mastoview runs as a single Cloudflare Worker written in TypeScript with Hono for routing:
Browser ──GET /:domain/public──▶ Worker (Hono)
│
│ server-side fetch (public API only)
▼
https://:domain/api/v1/timelines/public
│
│ parse → validate → sanitize
▼
server-rendered HTML ◀── edge cache (Cache API)
src/mastodon.ts — API client. Strict timeouts, response-size caps, redirect refusal, typed error mapping (timeout, unreachable, rate-limited, not-found, forbidden, bad-response, …). Defensive parsers reject malformed objects instead of crashing.src/sanitize.ts — a self-contained HTML sanitizer (no DOM dependency): allowlist tokenizer that re-serializes status HTML, validates every URL scheme, drops dangerous subtrees (script, svg, iframe, math, …), balances tags, and rewrites instance links to internal routes.src/components.ts / src/html.ts — server-side templates (layout, status, author, avatar, media, content warning, poll, link preview, timestamp, pagination, error page).src/routes.ts — all routes, error pages, edge-cache middleware, security headers.src/styles.ts — the single small stylesheet, served from /style.css (typography-first, monospace, ~5 KB).src/validate.ts — domain/username/tag/id/query validation (SSRF-aware domain checks).There is no database and no state: the worker is a pure function of the URL.
| Route | Page |
|---|---|
/ | Landing page with instance picker |
/about | About this service |
/go?domain=… | Redirect helper for the front-page form |
/instance/:domain | Instance overview (title, description, stats, links) |
/:domain/public | Public (federated) timeline |
/:domain/local | Local timeline |
/:domain/federated | Alias → 301 to /:domain/public |
/:domain/tags/:tag | Hashtag timeline |
/:domain/@user | Profile (tabs: posts, posts+replies, media) |
/:domain/@user/:id | Single post with its conversation thread |
/:domain/search?q=… | Search posts, accounts, hashtags |
Pagination is plain links (?max_id=…) derived from the Mastodon Link response header. The instance is always explicit in the URL — mastoview is a viewer for an instance, not a mixed feed.
mastoview only calls public, unauthenticated endpoints:
GET /api/v2/instance (falls back to /api/v1/instance)GET /api/v1/timelines/public (with local=true for the local timeline)GET /api/v1/timelines/tag/:tagGET /api/v1/accounts/lookup?acct=…, GET /api/v1/accounts/:id/statusesGET /api/v1/statuses/:id, GET /api/v1/statuses/:id/contextGET /api/v2/search (per type; merged client-side)Instances differ. Some (including mastodon.social) require login for public timelines or search; mastoview maps refusals (401/403/422) to a readable "Request refused" page. Rate limits (429), outages, timeouts, deleted posts, and malformed responses each get their own error page. Nothing is hard-coded to a specific instance.
Requires Node.js 18+:
npm install
npm run dev # wrangler dev on http://localhost:8787
npm test # vitest test suite
npm run typecheck # tsc --noEmit
Then open http://localhost:8787/ or:
curl -s http://localhost:8787/fosstodon.org/public | less
(fosstodon.org, hachyderm.io, and mas.to allow anonymous timeline access; mastodon.social does not.)
npm run deploy # wrangler deploy
Configuration lives in wrangler.toml (name, main, compatibility_date). No bindings, secrets, or databases are required. After deploying, set your production host in /healthz-style checks: curl https://your-domain.example/healthz → ok.
href/src/alt/title, and forbids inline handlers, styles, and unknown schemes. javascript:, data:, and protocol-relative URLs are rejected; media sources must be absolute https:.https://<validated-domain>/api/v1|2/... paths. Domains are validated (DNS-label syntax, no IPs, no userinfo/port/path tricks), redirects are refused, and no arbitrary user-supplied URL is ever fetched.localhost impossible (a dot is mandatory).Content-Security-Policy: default-src 'none'; script-src 'none'; …, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, X-Frame-Options: DENY. The CSP itself forbids scripts even if a bug let one through.Content-Length and body length).All pages are public and stateless, so caching is safe by construction (no cookies, no auth):
caches.default) keyed by full URL, including pagination and search queries.Cache-Control (timelines s-maxage=120, stale-while-revalidate=600; instance pages 10 min; posts 60 s; CSS 1 day). TTLs are grouped in src/config.ts (CACHE_CONTROL) for easy tuning.no-store). Hits are flagged with x-mastoview-cache: hit.referrerpolicy=no-referrer).mastodon.social) show a "Request refused" page — that is their policy, not a bug.The backend is deliberately structured for additive enhancement: per-instance custom emoji, an RSS/Atom feed route, media proxying with size caps, "latest posts" link-rel navigation, authenticated reading via OAuth (carefully excluded from cache), light progressive enhancement (e.g. <details>-free CW toggles), per-user layout preferences via URL params, and trending/instance directories — all without ever making JavaScript a requirement.
MIT
TypeScript
100.0%
A read-only, JavaScript-free web client for Mastodon, rendered entirely on the server.
Every page mastoview returns is a complete, ordinary HTML document. There is no JavaScript to download or execute: no SPA, no hydration, no websockets, no browser-side API calls. The site works with JavaScript disabled, with CSS disabled, and with images disabled. You can read it with curl, a text browser, or a screen reader.
curl https://your-domain.example/fosstodon.org/public
returns the actual timeline HTML — not a shell waiting for scripts.
mastoview runs as a single Cloudflare Worker written in TypeScript with Hono for routing:
Browser ──GET /:domain/public──▶ Worker (Hono)
│
│ server-side fetch (public API only)
▼
https://:domain/api/v1/timelines/public
│
│ parse → validate → sanitize
▼
server-rendered HTML ◀── edge cache (Cache API)
src/mastodon.ts — API client. Strict timeouts, response-size caps, redirect refusal, typed error mapping (timeout, unreachable, rate-limited, not-found, forbidden, bad-response, …). Defensive parsers reject malformed objects instead of crashing.src/sanitize.ts — a self-contained HTML sanitizer (no DOM dependency): allowlist tokenizer that re-serializes status HTML, validates every URL scheme, drops dangerous subtrees (script, svg, iframe, math, …), balances tags, and rewrites instance links to internal routes.src/components.ts / src/html.ts — server-side templates (layout, status, author, avatar, media, content warning, poll, link preview, timestamp, pagination, error page).src/routes.ts — all routes, error pages, edge-cache middleware, security headers.src/styles.ts — the single small stylesheet, served from /style.css (typography-first, monospace, ~5 KB).src/validate.ts — domain/username/tag/id/query validation (SSRF-aware domain checks).There is no database and no state: the worker is a pure function of the URL.
| Route | Page |
|---|---|
/ | Landing page with instance picker |
/about | About this service |
/go?domain=… | Redirect helper for the front-page form |
/instance/:domain | Instance overview (title, description, stats, links) |
/:domain/public | Public (federated) timeline |
/:domain/local | Local timeline |
/:domain/federated | Alias → 301 to /:domain/public |
/:domain/tags/:tag | Hashtag timeline |
/:domain/@user | Profile (tabs: posts, posts+replies, media) |
/:domain/@user/:id | Single post with its conversation thread |
/:domain/search?q=… | Search posts, accounts, hashtags |
Pagination is plain links (?max_id=…) derived from the Mastodon Link response header. The instance is always explicit in the URL — mastoview is a viewer for an instance, not a mixed feed.
mastoview only calls public, unauthenticated endpoints:
GET /api/v2/instance (falls back to /api/v1/instance)GET /api/v1/timelines/public (with local=true for the local timeline)GET /api/v1/timelines/tag/:tagGET /api/v1/accounts/lookup?acct=…, GET /api/v1/accounts/:id/statusesGET /api/v1/statuses/:id, GET /api/v1/statuses/:id/contextGET /api/v2/search (per type; merged client-side)Instances differ. Some (including mastodon.social) require login for public timelines or search; mastoview maps refusals (401/403/422) to a readable "Request refused" page. Rate limits (429), outages, timeouts, deleted posts, and malformed responses each get their own error page. Nothing is hard-coded to a specific instance.
Requires Node.js 18+:
npm install
npm run dev # wrangler dev on http://localhost:8787
npm test # vitest test suite
npm run typecheck # tsc --noEmit
Then open http://localhost:8787/ or:
curl -s http://localhost:8787/fosstodon.org/public | less
(fosstodon.org, hachyderm.io, and mas.to allow anonymous timeline access; mastodon.social does not.)
npm run deploy # wrangler deploy
Configuration lives in wrangler.toml (name, main, compatibility_date). No bindings, secrets, or databases are required. After deploying, set your production host in /healthz-style checks: curl https://your-domain.example/healthz → ok.
href/src/alt/title, and forbids inline handlers, styles, and unknown schemes. javascript:, data:, and protocol-relative URLs are rejected; media sources must be absolute https:.https://<validated-domain>/api/v1|2/... paths. Domains are validated (DNS-label syntax, no IPs, no userinfo/port/path tricks), redirects are refused, and no arbitrary user-supplied URL is ever fetched.localhost impossible (a dot is mandatory).Content-Security-Policy: default-src 'none'; script-src 'none'; …, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, X-Frame-Options: DENY. The CSP itself forbids scripts even if a bug let one through.Content-Length and body length).All pages are public and stateless, so caching is safe by construction (no cookies, no auth):
caches.default) keyed by full URL, including pagination and search queries.Cache-Control (timelines s-maxage=120, stale-while-revalidate=600; instance pages 10 min; posts 60 s; CSS 1 day). TTLs are grouped in src/config.ts (CACHE_CONTROL) for easy tuning.no-store). Hits are flagged with x-mastoview-cache: hit.referrerpolicy=no-referrer).mastodon.social) show a "Request refused" page — that is their policy, not a bug.The backend is deliberately structured for additive enhancement: per-instance custom emoji, an RSS/Atom feed route, media proxying with size caps, "latest posts" link-rel navigation, authenticated reading via OAuth (carefully excluded from cache), light progressive enhancement (e.g. <details>-free CW toggles), per-user layout preferences via URL params, and trending/instance directories — all without ever making JavaScript a requirement.
MIT
TypeScript
100.0%