clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

fix/175-template-quality-pass
lofi / docs / seed.md
seed.md 21.3K rendered
1# lofi — Local-First Mobile PWA Meta-Framework (Prototype Plan)23> **Historical prototype plan—do not use as API or template documentation.** Several identities,4> paths, package exports, and implementation choices below were rejected or replaced. Application5> developers should use the [current developer documentation](README.md); framework contributors can6> consult the [developer-experience contract](devx-contract.md) and retained spike evidence.78Working name **`lofi`** (import paths like `@nzip/lofi/core`). Goal: validate that Jazz + Preact9islands + Astro + Deno can be wrapped into an ergonomic framework where the UI always hydrates from10local data, identity is passkey-only, and **the only network surface an app developer ever sees is11the sync module's primitives/hooks**.1213Scope decisions: prototype-to-learn (not a published framework yet); Deno is the dev toolchain and14shell host; sync backend is Jazz Cloud (no self-hosted server); **Jazz 2.0 alpha with OPFS,15targeting Android Chrome 148+ / iOS Safari 16.4+ as hard floors**.1617## Stack1819| Layer | Choice | Why |20| ------------------ | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |21| Shell | **Astro 7 + @deno/astro-adapter**, all routes `prerender = true` | Prerendered app shell (instant paint, SW-precacheable); SSR of local CoValues is meaningless, so the adapter is just a first-class Deno server for the shell + a future escape hatch. Fresh 2.3 has no PWA tooling; a Preact SPA has no prerendered shell. |22| UI runtime | **Preact 10 islands via `client:only="preact"`** | Skips SSR entirely — correct when all data hydrates from local storage. |23| Data | **jazz-tools 2.0 alpha (pinned exact version), OPFS persistent mode** | ~5x faster than the 0.20.x IndexedDB backend. Persistence requires SharedWorker + Web Locks + MessageChannel; fallback is memory-only — hence the hard platform floors below. |24| Sync backend | **Jazz Cloud** (managed WebSocket) | Zero-config; self-hosting later changes nothing client-side. |25| Auth | **Jazz PasskeyAuth + 24-word passphrase recovery** | Jazz custodies the account secret in a resident WebAuthn credential; wrap, don't reinvent. PRF-derived E2E-at-rest is required for account-private fields. |26| PWA | **@vite-pwa/astro v1.2 (Workbox)** | SW's job stays deliberately small: shell precache + push readiness. The SW never touches sync or OPFS. Full prerender = full offline shell for free (precache only covers prerendered routes). |27| Cross-island state | **@preact/signals + module-scope singleton client** | React/Preact context can't cross Astro island boundaries; a module singleton + signals sidestep it. No provider component in the public API. |28| Toolchain | **Deno 2.9** task runner + server; Astro/Vite under npm-compat | Fallback if npm-compat friction appears: Node for `astro build` only, Deno for everything else. |2930## Platform floors (enforced, not advisory)3132Jazz 2.0's only fallback below these floors is memory-only storage, which silently loses data — so33`@nzip/lofi/core`'s `detect()` is a **boot gate**: unsupported browser → explicit "unsupported"34screen (or opt-in memory mode with a "nothing will persist" banner). Never a silent fallback.3536| Feature | iOS Safari tab | iOS installed PWA | Android Chrome 148+ | Framework behavior |37| ----------------------------------------------- | ----------------------------------------------- | ----------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------ |38| SharedWorker + Web Locks (Jazz 2.0 persistence) | ✅ 16.4+ (WebKit quirks — Spike 0.5) | ✅ | ✅ (148 restored SharedWorker, adds `extendedLifetime`) | Boot gate |39| Storage eviction safety | ❌ 7-day ITP | ✅ exempt | ✅ generous | Push install aggressively on iOS tabs ("installing protects your data") |40| `storage.persist()` | needs notification permission | same | heuristic grant | `requestPersistentStorage()` sequences the permission dance |41| Install | Share-sheet only; **EU iOS: unavailable (DMA)** | — | `beforeinstallprompt` / WebAPK | iOS coach-mark; Android native prompt; EU degrade banner |42| Background Sync (all variants) | ❌ never | ❌ never | ✅ | **Unused everywhere** — sync is foreground-only (lowest common denominator, simpler model) |43| WebSocket while backgrounded | drops | drops | drops/throttled | Reconnect on `visibilitychange`/`pageshow`/`online` |44| Push | ❌ | ✅ 16.4+ | ✅ | Out of prototype scope; SW is push-ready |45| WebAuthn passkeys | ✅ iCloud Keychain | ✅ | ✅ | Primary auth |46| WebAuthn PRF | Safari 18+, no external keys | same | robust | Stretch phase only; feature-detect, never require |4748App code never sniffs user agents — `@nzip/lofi/pwa` and `@nzip/lofi/sync` branch on the49`Capabilities` object from `detect()`.5051## Monorepo layout (Deno workspace)5253```54lofi/55├── deno.json # workspace: ./packages/lofi, ./apps/demo; shared tasks56├── packages/57│ └── lofi/ # @nzip/lofi — one JSR package, one version, subpath exports58│ ├── core/ # ./core — schema (re-export co/z + defineSchema), createApp(), detect()59│ ├── auth/ # ./auth — passkey registration/sign-in/recovery and authState60│ ├── sync/ # ./sync — THE ONLY NETWORK-AWARE MODULE and lifecycle manager61│ ├── ui/ # ./ui — context-free Preact hooks and adapter implementation62│ ├── pwa/ # ./pwa — PWA config, install orchestration, persistent-storage UX63│ ├── testing/ # ./testing — virtual-authenticator and offline/sync test helpers64│ └── create/ # ./create — non-interactive project creation command65└── apps/demo/ # Astro app: "Ticktick-lite" checklist; schema.ts, app.ts, islands/66```6768The public modules are subpath exports of `@nzip/lofi`, not separately versioned packages.69Dependency direction remains strict inside that package: `demo → ui,pwa,auth,sync,core` ·70`ui → sync,auth,core` · `pwa → core` · `sync → core` · `auth → core` · `core → npm:jazz-tools` and71nothing else. **Only `sync` may construct or configure a network peer** — the enforcement point for72the "sync is the only network surface" constraint.7374Multi-tab coordination comes free: the SharedWorker Jazz 2.0 already requires is the single owner of75storage (and possibly sync) across tabs. Open question for Spike 0: does Jazz 2.0 run the sync76WebSocket inside the worker or per-tab? That decides where `lifecycle.ts` attaches its reconnect77manager.7879## Public API sketch8081```ts82// schema.ts83import { co, defineSchema, z } from "@nzip/lofi/core";84export const Task = co.map({ text: z.string(), done: z.boolean(), createdAt: z.date() });85export const TaskList = co.list(Task);86export const schema = defineSchema({87 root: co.map({ lists: co.list(TaskList) }),88 profile: co.map({ name: z.string() }),89 migration: (account) => {90 if (!account.root) account.root = { lists: [] };91 },92});9394// app.ts — module scope, imported by every island (Vite guarantees one instance per page)95import { createApp } from "@nzip/lofi/core";96export const app = createApp({97 schema,98 sync: { peer: `wss://cloud.jazz.tools/?key=${import.meta.env.PUBLIC_JAZZ_KEY}` },99 storage: "opfs",100});101// createApp() registers config only; the client boots lazily on first hook/auth call,102// and the sync peer is attached exclusively by @nzip/lofi/sync.103```104105```tsx106// islands/TaskListView.tsx107import { useCoState } from "@nzip/lofi/ui";108export default function TaskListView({ listId }) {109 const list = useCoState(TaskList, listId, { resolve: { $each: true } });110 if (list === undefined) return <Spinner />; // still loading from OPFS111 if (list === null) return <NotFound />; // unavailable / no permission112 return (113 <ul>114 {list.map((t) => (115 <li key={t.id}>116 <input117 type="checkbox"118 checked={t.done}119 onChange={() => {120 t.done = !t.done;121 }}122 />{" "}123 {t.text} {/* local write; sync invisible */}124 </li>125 ))}126 </ul>127 );128}129130// The only place networking is even visible:131const { connected, syncing, pendingOps, lastSyncedAt } = useSyncStatus();132```133134Auth: `await registerPasskey({ name })` · `await signInWithPasskey()` (conditional-UI capable) ·135`await recoverWithPassphrase(words24)`. Hooks read module-scope signals — no provider component136exists, which is what makes multi-island apps work.137138## The Preact adapter decision (riskiest unknown — resolve first)139140- **Plan A (spike):** alias `react`/`react-dom`/`react/jsx-runtime` → `preact/compat` in a bare Vite141 page; mount jazz-tools/react's provider + `useCoState` + PasskeyAuth. Watch:142 `useSyncExternalStore` under compat, React-19-only APIs, synthetic-event assumptions.143- **Plan B (fallback; likely end-state):** hand-rolled hooks over jazz-tools' vanilla144 `subscribe`/`load` API. `useCoState` ≈ 30 lines (`useState` + `useEffect` around145 `Schema.subscribe(id, opts, set)`); `useAccount` ≈ 20; sync/auth hooks are just signal readers.146 **~200–300 LOC total**, no compat alias in app bundles.147- Decision rule: any flakiness in Plan A → take Plan B immediately. Adopt Plan A only if it's148 boringly perfect.149- Either way the public hooks are **context-free** (module-scope client + signals), because context150 can't span Astro islands. The spike only decides whether we reuse jazz-tools/react's hook151 internals.152153## Risk-ordered roadmap154155| # | Phase | Duration | Learn / kill criteria |156| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |157| 0 | **Spike: preact/compat × jazz-tools 2.0** — bare Vite+Preact page (no Astro, no Deno), Plan A then Plan B, anonymous account, `useCoState` round-trip to Jazz Cloud; measure gzipped bundle; find where the sync socket lives (worker vs tab) | 2 days | Which adapter plan; bundle cost. **Kill:** both plans fail to subscribe reliably → Jazz choice is dead, re-evaluate data layer |158| 0.5 | **Spike: OPFS persistence on real devices** — same page with `storage: "opfs"` on a real iPhone (Safari tab + installed) and a real Chrome-148 Android; kill/relaunch, check data survives; multi-tab open | 2 days | Does Jazz 2.0 persistent mode actually work on WebKit's SharedWorker. **Kill:** broken on iOS → fall back to jazz-tools 0.20.x/IndexedDB (architecture unchanged — `storage:` is a named seam) |159| 1 | **Spike: PasskeyAuth on real devices** — register/sign-in/passphrase-recovery over real HTTPS on iOS Safari tab, installed PWA, Android Chrome, WebAPK; verify the credential survives install | 3 days | Does Jazz's passkey custody work on mobile Safari (its PRF/largeBlob mechanism is undocumented — this is where we find out). **Kill:** fails on iOS → passphrase-primary UX or reconsider |160| 2 | **Astro shell + islands + Deno toolchain** — scaffold workspace, port spikes into `@nzip/lofi` subpath modules, two _separate_ islands sharing one client and live-updating each other (acceptance test for the singleton design); `deno task dev/build/serve` end-to-end with @deno/astro-adapter | 1 wk | Island-boundary state sharing works; npm-compat friction. **Soft kill:** Astro misbehaves under Deno → Node for build, Deno for serve; cap at 1 day |161| 3 | **PWA hardening** — Workbox shell precache, manifest, Android install prompt, iOS coach-mark, EU-iOS degrade, `storage.persist()` incl. Safari notification-permission dance, reconnect-on-foreground verified on devices | 1 wk | Offline cold-start (airplane mode → installed PWA → shell + data render); WS reconnect reliability. Failures here are degradable UX, not fatal |162| 4 | **Demo app "Ticktick-lite"** — checklists, shared lists via Group invite links (two accounts, two devices), sync badge, offline edits, conflict-free merge test | 1 wk | Is the API pleasant; where the abstraction leaks; invite-revocation gap surfaces concretely |163| 5 | **PRF-derived E2E-at-rest** — WebAuthn PRF → HKDF → field-level encryption before data enters Jazz | optional | Only if a real threat model demands it; Safari 18 iCloud-Keychain-only; feature-detect, never require |164165## Verification166167- **Secure-context device testing:** WebAuthn + SW need HTTPS and a _stable origin_ (rpId changes168 break passkeys). Preferred: `tailscale serve` (stable `*.ts.net` name); alt: reserved ngrok169 domain; mkcert for pure LAN (requires installing the root CA profile on iOS). `astro dev` behind170 the tunnel for iteration; the Deno adapter serves the built shell.171- **Per-phase device pass:** one-page manual checklist on a physical iPhone (tab + installed) and172 Android (tab + WebAPK); debug via Safari Web Inspector over USB and `chrome://inspect`.173- **CI-able WebAuthn:** Playwright CDP virtual authenticator (resident key + user verification) in174 `@nzip/lofi/testing` for register/sign-in/recovery. Real-device passkey behavior still verified175 manually in Spike 1 (virtual authenticators don't model iCloud Keychain or PRF quirks).176- **Offline:** Playwright `context.setOffline(true)` for automated shell+data cold-start; manual177 airplane mode on devices for the installed path.178- **Sync/merge:** two Playwright contexts against a throwaway Jazz Cloud key: concurrent offline179 edits → reconnect → assert convergence.180- **Lifecycle:** manual — background the installed PWA 5+ min on iOS, foreground, assert reconnect +181 flush via the sync badge.182183## Open risks (ranked)1841851. **preact/compat × jazz-tools untested** — highest uncertainty, cheapest to resolve (Spike 0);186 Plan B fallback is fully designed and small.1872. **Jazz 2.0 OPFS persistence on WebKit** — SharedWorker on iOS Safari exists but is quirk-prone;188 Spike 0.5 decides. Fallback to 0.20.x/IndexedDB costs nothing architecturally (`storage:` seam).1893. **Alpha API churn** — pin the exact 2.0-alpha version; expect breakage on every bump;190 `@nzip/lofi/core` is the single jazz-tools chokepoint containing migration cost. 2.0 docs are191 sparse — read the changelog and source when docs fail.1924. **Pre-148 Android installed base** — SharedWorker only returned in Chrome 148 (stable ~May–June193 2026); older devices hit the boot gate. Acceptable for a prototype; revisit for any real release.1945. **Astro-under-Deno friction** — pre-agreed fallback: Node for `astro build`, Deno for everything195 else.1966. **Jazz bundle size unknown** — measure in Spike 0; `client:only` islands keep it off the shell's197 critical path.1987. **iOS lifecycle fragility** — WS drops + eviction + `persist()` permission gating compound; Phase199 3 is dedicated to it; the sync badge makes failures visible instead of silent.2008. **Jazz invites can't be revoked/expired** — leaked link = standing access. Prototype: scope201 invites to writer role; "revoke" = recreate the list into a fresh Group. Framework-level gap for202 any real release.2039. **EU iOS install ban (DMA)** — browser-only mode means eviction exposure with no fix; accepted204 for the prototype, flagged for distribution.205206## Research appendix (mid-2026 findings)207208- **Jazz**: v0.20.18 stable / 2.0 alpha. Schema `co.map/co.list/co.feed` + Zod; `FileStream` binary;209 Groups (reader/writer/admin). Bindings: React, Svelte, Solid (2.0α), vanilla — **no Preact210 binding**. PasskeyAuth custodies the account secret in a resident credential (PRF/largeBlob use211 unconfirmed). Sync = WebSocket to Jazz Cloud or self-hosted Node 20+ (Deno Deploy unsupported —212 moot here). 2.0-alpha OPFS ≈ 5x faster reads/writes; persistent mode requires SharedWorker + Web213 Locks + MessageChannel, fallback memory-only.214- **Frontends**: Astro 7.0.7 (static default, `client:only`, @vite-pwa/astro v1.2,215 `@deno/astro-adapter`); Fresh 2.3.3 (Deno-native, islands, **zero PWA/SW tooling** → rejected);216 Preact 10 + Vite (SPA only, no prerendered shell → rejected). preact/compat caveats: no React-19217 `use()`, native events, concurrent rendering no-op.218- **Platform**: Chrome 148 restored SharedWorker on Android (+ `extendedLifetime`). iOS: installed219 PWAs exempt from 7-day ITP eviction, tabs are not; no Background Sync of any kind, ever; push only220 in installed PWAs 16.4+; `storage.persist()` requires notification permission; EU iOS can't221 install PWAs (DMA). PRF output is deterministic per credential → synced passkeys yield the same222 derived key across devices.223- **Niche**: no existing project combines passkey identity + CRDT sync + mobile-PWA-first224 constraints (Evolu = mnemonic; Automerge = `localfirst/auth` teams; DXOS = HALO keys;225 Zero/Triplit/PowerSync = JWT/cloud-first; Ditto = device identity). The niche is open.