Architecture
memrynote is a pnpm + Turborepo monorepo with an Electron desktop app, a Cloudflare Workers sync server, and shared TypeScript packages.
Top-Level Map
| Path | Purpose |
|---|---|
apps/desktop | Electron 43 + React 19 + Vite. Main / renderer / preload. |
apps/sync-server | Cloudflare Workers + Hono. D1 + R2. |
apps/docs | This documentation site (VitePress). |
packages/contracts | IPC and API contracts (Zod). |
packages/db-schema | Drizzle ORM schemas. |
packages/shared | Shared utilities. |
Renderer Page Loading
Every page in the tab content switch is loaded through React.lazy, so no page's dependency tree lands in the entry chunk. The editor stack (@blocknote, ProseMirror, Yjs) reaches the renderer only through pages that mount an editor, which means it is fetched when the user first opens a note or the Inbox rather than parsed on every launch.
The trade is deliberate. Keeping those modules out of the entry chunk removes parse work from the launch path and moves it onto the first navigation that needs it, off local disk. A page added to that switch with a static import silently returns the whole tree to the entry chunk, so new pages follow the same lazy shape as their siblings.
Renderer Chunking
The renderer build sets no manualChunks. The main process build sets one, and the two are not comparable. Main is CJS over a small fixed graph, where package-per-chunk stops rollup splitting a circular package across chunks that then load in the wrong order. The renderer is ESM over a large graph whose lazy boundaries rollup already derives from the import() calls in the page switch.
The entry itself holds only the boot path: i18n, the startup locale, and a hash check that imports either main-window-root (auth and sync providers plus App) or quick-capture-root. The main window therefore never loads Quick Capture and the Quick Capture window never loads App. Shell surfaces that are closed on first paint (settings, onboarding, the keyboard shortcuts dialog, the command palette) sit behind lazy(); the command palette is mounted closed on the first idle callback so the first Cmd+K opens an already loaded palette. Keep new closed-by-default shell surfaces on the same pattern.
Before that split, exactly one chunk was reachable from the entry without an import(), a single file of roughly 4.5 to 4.9 MB. manualChunks cannot shrink the startup path, because it moves modules between chunks rather than off the startup path, and V8 compiles function bodies lazily either way.
It can grow it. manualChunks assigns a module to its named chunk whether or not the module was reachable only through import(), so one eagerly imported module in a bucket drags the whole bucket onto the startup path. A vendor split over react, Radix, motion, and the rest of node_modules was measured at 5 eager chunks totalling 45,050,500 bytes, against 4,789,586 at the time. It pulled Shiki's grammars, Excalidraw, Mermaid, and hls.js into launch. The narrowest rule that could help, react and react-dom and scheduler alone, leaves the eager total byte-identical and only spreads it over two files.
Reducing launch parse work therefore means cutting a lazy boundary so bytes stop being reachable from the entry, the shape used for pages above and for icons below.
Icon Loading
@hugeicons/core-free-icons ships ~5,000 glyphs in one module. The icon picker lets a user choose any of them and persists the chosen key, so the set of keys the app must be able to resolve is the whole package, not a curated list.
The ~270 glyphs the UI references eagerly are re-exported from renderer/src/lib/icons/hugeicons-subset.ts, which is generated by apps/desktop/scripts/generate-hugeicon-subset.mjs and imports each glyph from its own module file. Nothing imports the package barrel statically, so the barrel stays a dynamic-only dependency and is fetched on demand for keys outside that subset.
Adding an icon to icon-map.ts means rerunning the generator; --check mode guards it in the test suite. A static import of the package barrel anywhere in the renderer silently pulls all ~5,000 glyphs back into the entry chunk.
Startup Bootstrap
Theme, zoom factor and locale are resolved in the preload, before any renderer script evaluates. Each reads a localStorage cache first and falls back to a synchronous IPC channel (settings:getStartupThemeSync, locale:getStartupSync) only when there is no usable cached value, which in practice means first run, first launch after an upgrade that introduced the cache, or cleared storage. The synchronous channel is the authority, so a missing cache can never produce a wrong first frame; the cache only removes the IPC call from the common launch.
The renderer must not re-apply any of them. Setting the theme class, the zoom factor or <html dir> a second time after the entry chunk has parsed is the visible flash this arrangement exists to prevent. The renderer's job is to consume the resolved value: main.tsx builds its i18n instance from getStartupLocale() at module scope, so the locale namespace chunks start loading with no await in front of them, and reconciles against window.api.locale.get() after the first render.
Every locale change broadcasts on locale:changed, and the preload is the only writer of the locale cache, so a language switch from settings, onboarding, or a peer device keeps the next launch correct without the renderer participating.
Trust Boundary
The user's device is trusted. The server is not. Everything that leaves the device is encrypted; the server stores ciphertext and serves it back.
Local Storage
Two SQLite databases via better-sqlite3 + Drizzle:
- Data DB — notes, journals, tasks, projects, inbox, templates, settings.
- Index DB — full-text search, link graph, embedding vectors.
Sync
D1: encrypted sync item metadata (vector clocks, blob keys, hashes).
R2: encrypted payload blobs (avoids the 1 MB D1 row limit).
Hybrid sync: bulk snapshots through
SyncItemHandlerplus incremental Yjs updates through/sync/crdt/updates.Bootstrap: a fresh device opens a time-boxed elevated window, then seeds note bodies from immutable compaction packs before the item-granular pull.
→ Sync Protocol · CRDT & Notes Sync · Sync Item Handlers · Vault Packs
Cryptography
XChaCha20-Poly1305 + Ed25519 + Argon2id, all via libsodium. Per-device sealing of the vault key. Constant-time comparisons.
IPC Boundary
Shared Zod contracts in packages/contracts. Validated at typecheck time via pnpm ipc:check.
Observability
Local logging via electron-log; switchable telemetry that ships only enums and surface names.
Verification Gates
pnpm lint
pnpm typecheck
pnpm test
pnpm ipc:check