- Fix MD040 (11 bare fences): add language tags (text/bash) across 7 files - Fix MD031 (2 violations): add blank lines around fence in GETTING-STARTED.md - Wire 'Markdown lint' step to fast-checks job (after Format check, before Typecheck) - Reformat .markdownlint-cli2.jsonc per Prettier (trailing commas in JSONC) - pnpm md:lint exits 0; pnpm format:check exits 0; gate can fail on bare fence (verified)
18 KiB
18 KiB
FamilySync Architecture
FamilySync is a self-hosted family organization hub — a unified, color-coded calendar and shared collaborative lists — delivered as a React PWA. The system is a two-container Docker Compose stack (API + MariaDB) running on Unraid, exposed through a Pangolin/Newt tunnel with Authelia providing OIDC authentication.
System Overview
FamilySync follows a layered architecture with Fastmail CalDAV as the external calendar source of truth. The React PWA talks exclusively to a single Hono API backend. The backend handles authentication (Authelia OIDC), calendar read/write via CalDAV (Fastmail), list persistence (MariaDB), and real-time push delivery.
graph TD
subgraph "Client (Browser / Home Screen PWA)"
PWA["React 19 PWA\n(Vite + vite-plugin-pwa)"]
SW["Service Worker\n(Workbox precache + push)"]
end
subgraph "API (apps/api — Hono on Node 22)"
AUTH["OIDC Auth\n(@hono/oidc-auth)"]
ROUTES["API Routes\n/events /lists /me /push /sse"]
BROKER["CalDAV Broker\n(tsdav + ical.js + rrule)"]
OUTBOX["Outbox Worker\n(15s drain loop)"]
POLLER["CalDAV Poller\n(5-min setInterval)"]
REMINDER["Reminder Scheduler\n(1-min setInterval)"]
SSE_LIB["List Emitter\n(in-process EventEmitter)"]
PUSH_LIB["Push Dispatcher\n(web-push VAPID)"]
end
subgraph "Persistence"
DB["MariaDB 11\n(Drizzle ORM)"]
end
subgraph "External Services"
AUTHELIA["Authelia\n(OIDC / OAuth2 IdP)"]
FASTMAIL["Fastmail CalDAV\ncaldav.fastmail.com"]
PUSH_SVC["Browser Push Services\n(APNs / FCM)"]
end
PWA -- "HTTPS (same-origin via Pangolin)" --> ROUTES
SW -- "push events" --> PWA
ROUTES --> AUTH
AUTH -- "authorization-code + PKCE" --> AUTHELIA
ROUTES --> BROKER
ROUTES --> SSE_LIB
ROUTES --> DB
BROKER -- "PROPFIND / REPORT / PUT / DELETE" --> FASTMAIL
POLLER --> BROKER
OUTBOX --> BROKER
BROKER --> DB
REMINDER --> DB
REMINDER --> PUSH_LIB
OUTBOX --> PUSH_LIB
SSE_LIB -- "text/event-stream" --> PWA
PUSH_LIB -- "VAPID push" --> PUSH_SVC
PUSH_SVC --> SW
Directory Structure
familysync/
├── apps/
│ ├── api/ # Hono backend (Node 22 + TypeScript)
│ │ └── src/
│ │ ├── index.ts # App entry: mounts routes, starts background workers
│ │ ├── routes/ # HTTP route handlers
│ │ ├── auth/ # OIDC middleware + dev-bypass + session persistence
│ │ ├── broker/ # CalDAV integration layer
│ │ ├── db/ # Drizzle schema, client, migrations
│ │ └── lib/ # Shared dispatchers and utilities
│ └── pwa/ # React 19 PWA (Vite + vite-plugin-pwa)
│ └── src/
│ ├── App.tsx # BrowserRouter shell with persistent nav chrome
│ ├── routes/ # Page-level React components
│ ├── components/ # Shared UI components
│ ├── api/ # Typed fetch wrappers (client.ts, listsClient.ts)
│ ├── hooks/ # Custom hooks (useListSSE, usePushSubscription)
│ ├── store/ # Zustand UI-state stores
│ ├── lib/ # Pure utilities (event date/time, login redirect)
│ └── sw.ts # Custom Workbox service worker
├── docker-compose.yml # Production stack
└── docker-compose.dev.yml # Dev overrides (port-binds, volume mounts)
Directory Rationale
| Directory | Purpose |
|---|---|
apps/api/src/routes/ |
One file per resource — events.ts, lists.ts, me.ts, push.ts, sse.ts, health.ts |
apps/api/src/broker/ |
All CalDAV I/O: client.ts (tsdav factory), sync.ts (REPORT→DB), poller.ts (5-min ctag check), outboxWorker.ts (async write-back), expand.ts (RRULE expansion), write.ts (PUT/DELETE), vevent.ts (ICS builder), crypto.ts (AES-256-GCM for app passwords) |
apps/api/src/auth/ |
middleware.ts (re-exports @hono/oidc-auth), devBypass.ts (DEV_AUTH_BYPASS inject), persistSessionCookie.ts (session lifetime extension), user.ts (upsert on first OIDC login) |
apps/api/src/db/ |
schema.ts (Drizzle mysqlTable definitions), client.ts (mysql2 pool), migrations/ (drizzle-kit output) |
apps/api/src/lib/ |
Stateless helpers: listEmitter.ts (EventEmitter fan-out), listChangeDispatcher.ts, eventChangeDispatcher.ts, pushDispatcher.ts (VAPID send), pushCoalescer.ts, listAccess.ts, rank.ts (fractional indexing) |
apps/pwa/src/api/ |
Thin typed fetch wrappers — client.ts (events, me, sync-status), listsClient.ts (lists and items) |
apps/pwa/src/store/ |
calendarStore.ts and listsStore.ts — Zustand UI-only state (no server data) |
apps/pwa/src/hooks/ |
useListSSE.ts (bounded-backoff EventSource), usePushSubscription.ts (VAPID subscribe) |
Key Abstractions
| Abstraction | File | Description |
|---|---|---|
app (Hono) |
apps/api/src/index.ts |
Root Hono app; mounts all routes and serves the PWA static build |
| Drizzle schema | apps/api/src/db/schema.ts |
Single source of truth for all table definitions (users, memberCredentials, calendars, calendarEvents, calendarOutbox, lists, listShares, listItems, pushSubscriptions) |
syncCalendar |
apps/api/src/broker/sync.ts |
REPORT → ical.js parse → onDuplicateKeyUpdate upsert into MariaDB |
expandOccurrences |
apps/api/src/broker/expand.ts |
Server-side RRULE expansion using ical.js + rrule; never runs in the browser |
CalendarOccurrence |
apps/api/src/broker/expand.ts |
Wire type for a single concrete event occurrence; mirrored in the PWA's api/client.ts |
calendarOutbox table |
apps/api/src/db/schema.ts |
Transactional outbox pattern — CalDAV writes are enqueued here and drained asynchronously |
runOutboxDrain |
apps/api/src/broker/outboxWorker.ts |
Drains pending outbox rows every 15s; handles retry backoff, 412 conflict, dead-lettering, and edit-as-move ordering |
publishListEvent / subscribeListEvents |
apps/api/src/lib/listEmitter.ts |
In-process EventEmitter fan-out keyed per list; SSE route subscribes on open and unsubscribes on disconnect |
dispatchPush |
apps/api/src/lib/pushDispatcher.ts |
Centralised VAPID-signed push sender; handles 410/404 subscription pruning |
SessionExpiredError |
apps/pwa/src/api/client.ts |
Typed error thrown by all fetch wrappers on 401/opaqueredirect; global QueryCache handler arms the session-expiry interstitial |
| Zustand stores | apps/pwa/src/store/ |
UI-only ephemeral state (open panels, selected date, active tab); server state always in TanStack Query |
Data Flow
Calendar read (typical request)
- On mount, the PWA's
CalendarShellcomputes a date window and firesfetchEvents(start, end)via TanStack Query. GET /api/events?start=&end=hits the HonoeventsRouter.- The route queries
calendarEvents+calendarsfrom MariaDB, filtering by the authenticated user's accessible calendars. - Raw
rawVeventblobs are passed throughexpandOccurrences()(ical.js + rrule) to produce concreteCalendarOccurrenceobjects for the window. - The JSON response is cached by TanStack Query; Schedule-X renders the events.
Calendar write (create/edit/delete)
- The PWA calls
POST /api/events/create(orPATCH /api/events/:uid/edit/DELETE /api/events/:uid). - The route validates the payload with Zod, assigns a UID, and inserts a
pendingrow intocalendarOutbox. Returns202 Acceptedimmediately. - Every 15 seconds
runOutboxDrainpicks uppendingrows, decrypts the member's Fastmail app password (AES-256-GCM), and dispatches the CalDAV write viatsdav(PUT/DELETE). - On success, the worker triggers a targeted
syncCalendarre-sync for that calendar, then marks the outbox rowdone. - The PWA's
SyncStateToastpollsGET /api/events/sync-status?uid=and invalidates the['events']TanStack Query cache when status transitions todone.
Calendar background sync (poller)
startBrokerPollerfires every 5 minutes viasetInterval.- For each row in
member_credentials, the poller decrypts the app password, builds atsdavclient, and runsfetchCalendars()(PROPFIND). - Each calendar's live
ctagis compared against the stored value. If unchanged, the calendar is skipped. - On change,
syncCalendarfetches all objects (REPORT), parses with ical.js, and upsertscalendarEventsrows. - Detected event changes are passed to
dispatchEventChange, which sends VAPID push notifications to non-actor members' subscriptions.
Live list sync
- When the PWA opens a list,
useListSSEopens anEventSourcetoGET /api/sse/listswithwithCredentials: true. - The SSE route resolves the caller's accessible list IDs via
getAccessibleListIds, then callssubscribeListEvents(listId, handler)for each one. - When any list is mutated via the REST routes (
POST /api/lists,PATCH /api/list-items/:id, etc.), the route callspublishListEvent(listId, event). - The in-process
EventEmitterfans the event out to all activesubscribeListEventshandlers, which write it to the SSE stream. - The PWA's SSE handler calls
queryClient.invalidateQueries(['list', listId]), triggering a full refetch. - A 30-second polling fallback (
refetchInterval: 30000) is always active inListDetailas a safety net.
Authentication
- An unauthenticated browser navigates to
/api/login. - The
oidcAuthMiddleware(@hono/oidc-auth) issues a302to Authelia's/authorizeendpoint with PKCE (S256). - After login, Authelia posts the authorization code to
/callback;processOAuthCallbackexchanges it for tokens and issues a signed JWT session cookie. persistSessionCookiemiddleware re-issues the cookie as persistent on every authenticated response so the PWA session survives browser close.- All
/api/*routes require the session cookie; a missing or expired session returns a302which the PWA's fetch wrappers detect asopaqueredirectand convert to aSessionExpiredError.
Component Interaction
Backend modules
routes/events.ts ──→ broker/expand.ts (read: RRULE expansion)
──→ calendarOutbox (DB) (write: enqueue)
──→ broker/sync.ts (write-sync after outbox drain)
broker/outboxWorker.ts ──→ broker/write.ts (CalDAV PUT/DELETE)
──→ broker/sync.ts (targeted re-sync)
──→ lib/eventChangeDispatcher.ts → lib/pushDispatcher.ts
broker/poller.ts ──→ broker/sync.ts
──→ lib/eventChangeDispatcher.ts
routes/lists.ts ──→ db (MariaDB)
──→ lib/listEmitter.ts (publishListEvent)
routes/sse.ts ──→ lib/listEmitter.ts (subscribeListEvents)
──→ lib/listAccess.ts
Frontend data ownership
| Concern | Owner |
|---|---|
| Calendar events | TanStack Query ['events', start, end] |
| List data + items | TanStack Query ['lists'], ['list', listId] |
| Current user | TanStack Query ['me'] |
| Writable calendars | TanStack Query ['writableCalendars'] |
| Outbox sync status | TanStack Query ['syncStatus', uid] |
| Selected calendar view + date | Zustand calendarStore |
| Event form open/mode | Zustand calendarStore |
| Active tab, create-list sheet | Zustand listsStore |
Infrastructure
| Component | Technology |
|---|---|
| Runtime | Node.js 22 LTS |
| HTTP framework | Hono 4.x (@hono/node-server) |
| Database | MariaDB 11 (Docker volume) |
| ORM | Drizzle ORM 0.45.x (mysql2 dialect) |
| Auth IdP | Authelia (pre-deployed, external) — OIDC authorization code + PKCE |
| Session middleware | @hono/oidc-auth — storage-less signed JWT cookies |
| Calendar source | Fastmail CalDAV (caldav.fastmail.com) — read via tsdav, write via transactional outbox |
| Calendar parsing | ical.js (VCALENDAR/VEVENT parse) + rrule (RRULE expansion) |
| App password storage | AES-256-GCM encrypted in member_credentials.encrypted_password |
| Push notifications | web-push (VAPID) → APNs (iOS) / FCM (Android) |
| Live list sync | In-process Node.js EventEmitter → SSE (text/event-stream) |
| Redis | Present in stack (image: redis:7-alpine); not used in current runtime (reserved for future multi-process pub/sub) |
| PWA | React 19 + Vite 8 + vite-plugin-pwa (Workbox injectManifest mode) |
| Networking | Pangolin/Newt tunnel — no open ports; split-DNS internal domain |
| Deployment | Docker Compose on Unraid; single api container serves both the API and the PWA static build |