A lightweight, privacy-focused Mastodon client built with Preact and Signals.
Live: mastodawn.com
Mastodawn was built as a reference implementation for an ultra-fast Preact Single Page Application with full Signals-based rendering and reactivity. It demonstrates how to build a complex, real-world PWA that stays lightweight and responsive.
- ~50KB application bundle (gzip)
- Full Signals reactivity — no Context, no reducers, just reactive state
- Offline-first with Service Worker caching and streaming updates
- Installable PWA with push notifications, share target, and app shortcuts
- Privacy-focused — all data stays on your device; communicates directly with Mastodon instances
| Layer | Technology |
|---|---|
| UI Framework | Preact 10 |
| State Management | @preact/signals |
| Routing | preact-iso |
| Build Tool | Vite 4 |
| Type Checking | TypeScript (JSDoc) |
| Styling | Tailwind CSS 3 + DaisyUI |
| Icons | Iconify (compile-time inlined) |
Mastodawn avoids Context and reducers entirely. State is modeled as signals — fine-grained reactive values that trigger re-renders only where their .value is read:
// API responses are wrapped in signals for reactive updates
const timeline = signal([]);
// WebSocket stream updates mutate the signal directly
stream.on('update', status => {
timeline.value = [status, ...timeline.value];
});
// Components re-render automatically when timeline.value changes
function Timeline() {
return <For each={timeline}>{post => <Post post={post} />}</For>;
}Key patterns used throughout the codebase (src/lib/utils.jsx):
<For each={signal}>— efficient keyed list rendering from a signal<Show when={signal}>— conditional rendering driven by a signalasyncComputed()— derived async state (fetch-on-signal-change with Suspense)useSignalRef()— a signal that doubles as a DOM ref
A custom Vite plugin compiles the Service Worker (src/sw/) with a versioned asset manifest injected at build time:
- Install: precaches the app shell and all hashed assets
- Fetch: cache-first for assets, network-first for HTML
- Share Target: intercepts
POST /composefor the Web Share Target API - Push: displays native notifications and routes taps to the app
The API client (src/lib/masto.js) is a custom ~1,500-line module with:
- URL-template-based request definitions (
"GET /statuses/{id}") - In-flight request deduplication
- Cache API integration with TTL-based invalidation
- Signal-wrapped responses for reactive UI updates
- WebSocket streaming with automatic reconnection (exponential backoff)
- Multi-instance support (connect to multiple Mastodon servers)
Authentication uses a popup-based OAuth flow:
- App opens a popup to the Mastodon instance's OAuth endpoint
- Instance redirects to
/oauth-callback.htmlwith an authorization code - Callback page posts the code back to the opener via
postMessage - App exchanges the code for an access token — stored locally, never sent to any backend
- Node.js 16+
- npm 7+
# Install dependencies
npm install
# Start dev server (HTTPS, required for Service Worker)
npm run dev
# Type-check
npm run check
# Production build
npm run build
# Preview production build
npm run previewThe dev server starts on https://localhost:5173 with HTTPS enabled via mkcert (you may need to accept the self-signed certificate).
| Command | Description |
|---|---|
npm run dev |
Start Vite dev server with HTTPS and HMR |
npm run build |
Production build to dist/ |
npm run check |
Run TypeScript type checking |
npm run preview |
Serve the production build locally |
src/
├── index.jsx # Entry point (hydrate + prerender)
├── style.css # Global styles (Tailwind)
├── components/
│ ├── app.jsx # Root component with lazy-loaded routes
│ ├── masto.jsx # Mastodon client singleton + signal state
│ ├── timeline.jsx # Feed rendering with virtual scrolling
│ ├── post.jsx # Status/post renderer
│ ├── compose.jsx # Post composer with media upload
│ ├── header.jsx # Navigation header
│ ├── service-worker-manager.jsx
│ └── ... # Media viewer, GIF picker, etc.
├── routes/ # Page components (lazy-loaded)
│ ├── home.jsx # Home timeline
│ ├── explore.jsx # Discover content
│ ├── profile.jsx # User profiles
│ ├── notifications.jsx # Notification feed
│ ├── messages.jsx # Direct messages
│ ├── compose.jsx # Compose page
│ ├── bookmarks.jsx # Saved posts
│ ├── settings.jsx # App settings
│ └── ...
├── lib/
│ ├── masto.js # Mastodon API client + WebSocket streaming
│ ├── utils.jsx # Signal utilities (For, Show, asyncComputed)
│ ├── sw.js # Service Worker RPC helpers
│ └── ...
└── sw/
└── index.js # Service Worker implementation
Mastodawn is a static SPA. The production build outputs to dist/ and can be deployed to any static hosting provider.
Netlify (current deployment): the included netlify.toml handles SPA routing.
For other hosts, configure a catch-all redirect so all routes serve index.html:
# nginx example
location / {
try_files $uri /index.html;
}Contributions are welcome! This project serves primarily as a reference implementation, but bug fixes and improvements are appreciated.
- Fork the repository
- Create your branch (
git checkout -b my-fix) - Commit your changes (
git commit -m 'Fix something') - Push (
git push origin my-fix) - Open a Pull Request
MIT — Jason Miller