Files
Dimension-47/.planning/research/STACK.md

25 KiB

Stack Research

Domain: TTRPG campaign management — additions for next milestone (PWA + multi-screen battle + extended WS + Obsidian browser + Level-Up) Researched: 2026-04-27 Confidence: HIGH (versions verified against Context7 + official npm/changelog; all additions sit on top of locked NestJS 11 / React 19 / Prisma 7 / Socket.io 4 stack)

Scope: This file recommends ONLY new libraries/patterns to add. The locked existing stack (NestJS 11.0.1, React 19.2.0, Prisma 7.2.0, PostgreSQL, Socket.io 4.8.3, Tailwind v4.1.18, Zustand 5, TanStack Query 5, axios, framer-motion, lucide-react, class-validator, JWT, @anthropic-ai/sdk) is documented in .planning/codebase/STACK.md and is NOT re-evaluated.

Core Additions

Technology Version Purpose Why Recommended
vite-plugin-pwa ^1.2.0 PWA shell, manifest injection, service worker generation, Workbox bundling The de-facto Vite PWA plugin. v1.0.1 added explicit Vite 7 support (project is on Vite 7.2.4); v1.2.0 (Nov 2025) is current. Provides useRegisterSW hook from virtual:pwa-register/react for the existing React 19 client — slots in without touching App shell. Use injectManifest strategy so we can hand-author the service worker (push handler + custom REST cache rules) instead of being constrained by generateSW.
workbox-precaching workbox-routing workbox-strategies workbox-expiration workbox-cacheable-response ^7.3.0 (whatever vite-plugin-pwa pulls in) Caching primitives for the custom service worker When using injectManifest, these are dev-deps (peer deps of vite-plugin-pwa). Provides precacheAndRoute(self.__WB_MANIFEST), NetworkFirst (REST endpoints), CacheFirst (immutable assets, vault images), StaleWhileRevalidate (translation strings).
@vite-pwa/assets-generator latest 0.x Generate icon set + maskable icons + splash screens from one source SVG Avoids hand-cropping ~20 icon variants. CLI step in build pipeline. Optional but high-leverage.
web-push ^3.6.7 Server-side Web Push delivery (VAPID-signed) from NestJS Reference Node.js library by web-push-libs, used by every Web Push tutorial and most production deployments. Provides generateVAPIDKeys(), setVapidDetails(), sendNotification(). Stable API (3.6.x line is multi-year mature; the relative quietness on the changelog is a sign of maturity, not abandonment). Installed and called from a new notifications NestJS module.
@dice-roller/rpg-dice-roller ^5.5.1 Server-side dice notation parser and roller The canonical TS-typed RPG dice library. Supports modifiers needed for PF2e: keep-highest/lowest, exploding (!), drop-lowest, rerolls (r, ro), success/failure pools, math expressions, and arbitrary number of dice. Persistent damage in PF2e is not a special dice mechanic — it's just 1d6 (or whatever) re-rolled at end of turn; the PF2e-specific layer (crit-doubling, fatal trait, persistent flagging, recovery DC) is application logic on top of the parser, not parser logic. Run dice on the server (auth'd, audit-loggable, anti-cheat).
react-markdown ^9.x React component for safe markdown → React tree Fits React 19 (uses unified/remark/rehype toolchain, no dangerouslySetInnerHTML needed). Plugin-extensible via remarkPlugins and rehypePlugins. The components prop is exactly what we need to inject custom React components for Wikilinks (clickable to internal vault routes), images (resolved against the vault base), and code blocks (Shiki).
remark-gfm ^4.x GitHub-flavored markdown extensions (tables, strikethrough, task lists, autolinks) Obsidian markdown is GFM-flavored. Drop-in remark plugin.
@portaljs/remark-wiki-link ^1.2.0 Parse Obsidian-style [[note]] and [[note|alias]] wikilinks into AST nodes Maintained, supports obsidian-short and obsidian-absolute path resolution modes (matches actual Obsidian behavior, where [[Foo]] resolves to the shortest matching file). The original remark-wiki-link is too generic; the flowershow/@portaljs fork specifically targets Obsidian semantics. AST node lets react-markdown components mapping render as a custom <WikiLink> React component routing into our vault browser.
remark-obsidian-callout (or inline custom plugin) ^1.x Render Obsidian > [!note] / > [!warning] callouts Optional but Obsidian-flavored vaults heavily use callouts. Can also be hand-rolled as a small remark plugin (≤50 LOC) that detects the [!type] pattern in blockquotes — the user owns the vault content style, so a tight in-house plugin is acceptable.
react-shiki ^0.9.x Syntax highlighting for code blocks inside vault markdown Recommended replacement for react-syntax-highlighter (latter is unmaintained, slow first-paint, has open vulnerabilities). Wraps Shiki — same TextMate grammars as VS Code — and exposes both a <ShikiHighlighter> component and a useShikiHighlighter hook. Offers fine-grained bundle control: pick only the languages we want (e.g., typescript, bash, json, markdown, python) to keep bundle size sane.
webdav (perry-mitchell client) ^5.x TS-typed WebDAV client for the NestJS server to read the user's self-hosted vault TypeScript-native, supports Basic/Digest/Token auth, browser-and-Node compatible (we'll use Node in the server). Server-side fetch keeps vault credentials off the client and lets the existing JWT auth gate vault access. Maintained, last commits within current cycle.

Supporting Libraries

Library Version Purpose When to Use
idb (or idb-keyval) ^8.x / ^6.x Promise-wrapped IndexedDB for offline payload cache When TanStack Query's in-memory cache isn't enough — i.e., persisting last-fetched character sheet, vault notes, equipment DB extracts so they survive a hard reload offline. idb-keyval if all we need is get/set; idb if we want indexed schemas. Recommendation: start with idb-keyval.
@tanstack/query-sync-storage-persister + @tanstack/react-query-persist-client matching ^5.90.x Persist React Query cache to localStorage/IndexedDB The "right" way to make existing useQuery calls offline-readable without rewriting hooks. Wrap QueryClientProvider with PersistQueryClientProvider. Plug-in fit with the existing TanStack Query 5.90.19 install.
unified ^11.x Core processor that react-markdown already pulls in Don't install separately unless authoring custom AST plugins; transitive via react-markdown. Listed for visibility.
rehype-slug + rehype-autolink-headings ^6.x / ^7.x Slug + anchor links on rendered headings Vault notes get stable URL fragments. Optional.
class-variance-authority (cva) ^0.7.x Variant-driven Tailwind class composition Optional. Only if new Battle/Display screens grow many size/state variants. Existing code uses clsx + tailwind-merge which already covers most cases — only add CVA if a component file starts looking like a chain of nested ternaries.
usehooks-ts (specifically useBroadcastChannel, useMediaQuery) ^3.x Tiny well-tested React hooks Avoid hand-rolling useBroadcastChannelusehooks-ts already wraps it with proper cleanup. Pick à la carte; don't import the whole library.

Development Tools

Tool Purpose Notes
web-push CLI (npx web-push generate-vapid-keys) One-shot VAPID key generation Run once; store keys in server/.env as VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT (mailto). Don't regenerate without re-subscribing all users.
@vite-pwa/assets-generator CLI Build-time icon and splash screen generation Run as pwa-assets-generator script before production build. Source SVG checked into repo.
Workbox dev tools / Chrome DevTools "Application" tab Debug service worker, cache contents, push subscriptions Standard browser tooling; no install. Critical for verifying offline behavior of cached endpoints.
Lighthouse / PWA tab in Chrome Validate manifest, install prompt, service worker quality Run before each release of the milestone. Aim for "Installable" + offline-capable green checks.

Installation

# Client (cd client)
npm install vite-plugin-pwa workbox-precaching workbox-routing workbox-strategies workbox-expiration workbox-cacheable-response
npm install -D @vite-pwa/assets-generator

npm install react-markdown remark-gfm @portaljs/remark-wiki-link react-shiki shiki
npm install rehype-slug rehype-autolink-headings   # optional

npm install idb-keyval
npm install @tanstack/react-query-persist-client @tanstack/query-sync-storage-persister

npm install usehooks-ts   # only the hooks we need

# Server (cd server)
npm install web-push @dice-roller/rpg-dice-roller webdav
npm install -D @types/web-push   # check if shipped types are sufficient first; @types/web-push exists but the package now ships its own .d.ts in 3.6.x

# One-time
cd server && npx web-push generate-vapid-keys   # capture into .env

Alternatives Considered

Recommended Alternative When to Use Alternative
vite-plugin-pwa Hand-written service worker + manifest If we ever need behavior the plugin actively fights (e.g., dev-only SW with HMR). Not the case here — injectManifest strategy already gives full control.
vite-plugin-pwa Workbox Serwist (Workbox fork) Active fork, cleaner ESM. Worth tracking but not yet the default for Vite — vite-plugin-pwa upstream still uses Workbox. Switch only if Workbox stalls.
web-push Self-rolled VAPID + ECE encryption Never. The encryption details (ECDH, HKDF, AES-128-GCM) are the kind of code you do not want to maintain yourself.
web-push @vv-01/web-push-service (NestJS-specific wrapper) If we later want OneSignal-like multi-tenant features. Overkill for self-hosted single-group app.
@dice-roller/rpg-dice-roller dice-notation (Morgul/rpgdice) Smaller, simpler. Use if rpg-dice-roller's modifier set turns out to be too feature-heavy. But for PF2e (with rerolls, exploding, keep-highest, math) the full feature set is justified.
@dice-roller/rpg-dice-roller Hand-rolled regex parser Tempting but a trap — every TTRPG team that did this regrets it within a year (operator precedence, kh3 vs kl3, error messages). Use the library.
react-markdown markdown-to-jsx Smaller bundle (~10KB vs ~50KB), no plugin ecosystem. Use only if bundle size becomes critical and we don't need wikilinks/GFM/Shiki. We do need all three.
react-markdown marked + custom React renderer Faster parser. Loses the unified/remark plugin ecosystem (no wikilink plugin, no callouts). Not worth the speed gain.
react-markdown @uiw/react-md-editor Bundles an editor — we explicitly do NOT need editing in v1 of vault browser. Out of scope.
@portaljs/remark-wiki-link remark-wiki-link-plus Also viable. PortalJS variant has cleaner Obsidian path resolution semantics. Tied; pick one and stick to it.
react-shiki Plain Shiki + custom integration Use if we want SSR pre-rendered code blocks. We don't have SSR; client-side react-shiki is correct.
react-shiki Prism / prismjs Smaller bundle, but theme + accuracy are noticeably worse. PrismJS is also not actively keeping pace with newer language additions.
react-shiki highlight.js Zero-config but ~340KB uncompressed full bundle, less accurate, dated themes.
webdav (perry-mitchell) Plain HTTP file server (@nestjs/serve-static pointing at vault dir) Strongly worth considering — see "Stack Patterns by Variant" below. If the user is willing to mount the vault directory into the server's filesystem, a custom NestJS controller serving markdown over authenticated HTTP is simpler than WebDAV. WebDAV is the right answer ONLY if the vault lives on a separate machine the server has WebDAV access to.
webdav (perry-mitchell) tsdav Also TS-native, but adds CalDAV/CardDAV which we don't need. Smaller cognitive surface to use perry-mitchell's.
webdav (perry-mitchell) git-based sync (clone the vault repo on server) Works, but is a fight: Obsidian's git workflows are messy (large binary attachments, .obsidian/ config noise), and "read-only" via git means scheduled pulls. Adds infra without paying for itself.
idb-keyval LocalStorage LocalStorage is sync, ~5MB cap, blocks main thread. IDB (via idb-keyval) is the right call for character sheet snapshots and vault notes.
BroadcastChannel for table-display sync postMessage between window.opener and child window postMessage works but loses messages if either window reloads, and requires the child to be opened from the parent. BroadcastChannel survives reloads and works between independently opened tabs as long as same-origin.
BroadcastChannel A shared SharedWorker Overkill. SharedWorker would be useful if we wanted a single shared WebSocket connection across tabs — we can defer that until we see actual traffic problems.

What NOT to Use

Avoid Why Use Instead
react-syntax-highlighter Unmaintained, slow first-paint when there are many code blocks, has flagged audit issues react-shiki
marked (as React renderer) No native React tree output, no remark plugin ecosystem, tempts you to dangerouslySetInnerHTML react-markdown
pwa-asset-generator (the older CLI) Older project, less Vite-aware @vite-pwa/assets-generator
firebase-admin or FCM-tier push Native push platform lock-in, account onboarding required, no advantage over standard Web Push for our self-hosted setup web-push (standards-based VAPID)
Capacitor / Cordova / PWABuilder native wrappers Adds an app store distribution path we explicitly don't want (PROJECT.md "Out of Scope: Native iOS/Android-App") Stick with PWA install-to-home-screen
Background Sync API on iOS iOS Safari does not support Background Sync as of 2026 — code that depends on it will silently no-op on iPhones Treat write operations as online-only (already in scope: "Offline-Bearbeiten ist explizit raus"); use foreground sync with explicit user action on reconnect
Silent push / data-only push on iOS iOS Safari does not support silent/background-wake push (only user-visible notifications) Always send a visible notification payload; if you need to "trigger app data sync", do it foreground when the user taps the notification
Hand-written dice notation parser Operator-precedence bugs, no error recovery, no modifier extensibility @dice-roller/rpg-dice-roller
Storing dice rolls only in client No audit trail, trivially cheatable, no cross-player visibility Roll on server in a NestJS service; persist Roll records in Prisma; broadcast via Socket.io
localStorage event for cross-window sync Fires only on OTHER tabs (not the originating one), doesn't survive private browsing edge cases BroadcastChannel
LocalStorage for vault note caching Sync, 5MB cap, will hit the cap with a real vault IndexedDB via idb-keyval (or Workbox's runtime caching for HTTP responses)
Running dice rolls only on the client Trivial to cheat with devtools, no replay log Server authoritative, client merely formats
Allowing service worker to bypass auth on /api/* Cached private data could leak across users sharing a device Scope cache keys by user ID; on logout, call caches.delete() for user-bound caches

Stack Patterns by Variant

Vault transport: WebDAV vs HTTP-mounted

If the vault is on the same machine as the NestJS server (or NFS/SMB-mounted to it):

  • Build a small vault NestJS module that reads markdown directly from the filesystem with fs/promises
  • Auth via existing JWT guard (gate behind ADMIN/GM/PLAYER roles per PROJECT.md role model)
  • Endpoints: GET /api/vault/tree, GET /api/vault/note?path=..., GET /api/vault/search?q=..., GET /api/vault/asset?path=... (for embedded images)
  • Cache-Control headers tuned for Workbox CacheFirst on assets, NetworkFirst on notes
  • Why: simpler, lower latency, no extra protocol surface, full control over auth — best fit for the "self-hosted for own group" setup

If the vault is on a separate machine the server can reach:

  • Use webdav (perry-mitchell) inside the same vault NestJS module
  • Hide WebDAV credentials in .env; never expose them to the client
  • Endpoints stay the same shape — module just swaps fs calls for WebDAV calls
  • Why: WebDAV is the most common TTRPG-friendly self-hosted protocol (Nextcloud, Synology, raw sabre/dav)

Recommendation: Default to filesystem mount. Implement the vault module with a swappable VaultProvider interface (FsVaultProvider, WebDAVVaultProvider) so the choice is one DI binding, not a rewrite. This matches the existing module pattern in server/src/modules/.

Multi-screen: Same-machine GM laptop driving table display

Scenario A — Both screens served from same browser (likely):

  • GM opens main app on display 1
  • "Open Tisch-Display" button → window.open('/battle/:sessionId/display', 'tisch-display', '...') on display 2
  • Sync via BroadcastChannel('battle-:sessionId') — same-origin, both windows post and listen
  • Display window subscribes to the same battle Socket.io namespace as the GM (read-only, GM events broadcast to both)
  • Display window does NOT need its own auth dance — share JWT via the broadcast handshake

Scenario B — Table display is a separate device (Raspberry Pi / kiosk PC):

  • Both devices connect to the same Socket.io namespace independently
  • Add a display room/role on the gateway; emit only safe-for-public events to it (no GM private notes)
  • Skip BroadcastChannel entirely — WS-only

Recommendation: Build for Scenario B (separate device, WS-only). It's the more capable pattern, and Scenario A is a degenerate case that just happens to work because BroadcastChannel is a no-op when only one window is listening. Don't make BroadcastChannel a load-bearing dependency.

iOS PWA Push: degraded mode by default

  • Document explicitly: iOS PWA push works ONLY when the app is installed to home screen (not in Safari tabs)
  • Use Declarative Web Push payloads (Safari 18.4+, March 2026) where possible — no service worker needed, more reliable
  • For Android/desktop Chrome where service-worker-driven push works fully, use the richer payload (custom actions, badges)
  • Server should send the same logical message; client UX gracefully degrades

Offline strategy by content type

Content Cache strategy Reason
App shell (HTML/JS/CSS) Precache (Workbox precacheAndRoute) Hashed assets, immutable
/api/characters/:id NetworkFirst, 5s timeout, 24h cache Always want fresh, fall back to cached when offline
/api/equipment/* StaleWhileRevalidate, 7-day cache Mostly static after seed; show cached fast, refresh background
/api/translations/* CacheFirst, 30-day cache Translations are append-only
/api/vault/note?path=... NetworkFirst, 5s timeout, 7-day cache Vault rarely changes during a session
/api/vault/asset?path=... CacheFirst, 30-day cache, max 200 entries Images, large, immutable
Translation cache hits via /api/translations StaleWhileRevalidate Already cached server-side; client cache is bonus

Version Compatibility

Package Compatible With Notes
vite-plugin-pwa@^1.2.0 vite@^7.0.0 (project: 7.2.4) v1.0.1 was the cut-over to Vite 7. Don't pin below 1.0.1.
react-markdown@^9.x react@^18 || ^19 (project: 19.2.0) v9 is the major-version line that supports React 19. v8 requires React 18.
react-shiki@^0.9.x React 18 + 19 Verify peer dep at install; pin patch version after install. Pre-1.0 — read changelog before bumping.
@portaljs/remark-wiki-link@^1.2.0 unified@^11, remark@^15 (transitive via react-markdown@9) Aligned with current unified ecosystem.
web-push@^3.6.7 Node 16+ (project: Node 22 LTS) Mature; release cadence intentionally slow.
@dice-roller/rpg-dice-roller@^5.5.1 Node 18+, browser ES2020+ Pure TS, no native deps.
webdav@^5.x Node 16+, modern browsers v5 active dev; v4 is in maintenance. Use v5 for new code.
idb-keyval@^6.x All browsers with IndexedDB (i.e. all evergreen) No SSR concerns since we're SPA.
@tanstack/react-query-persist-client Match @tanstack/react-query major (project: 5.90.19, so use 5.x) Same release train.

Module-fit notes (where each addition lands in existing structure)

  • vite-plugin-pwaclient/vite.config.ts plugin entry; new client/src/sw.ts for injectManifest strategy; new client/src/shared/hooks/use-pwa.ts wrapping useRegisterSW from virtual:pwa-register/react
  • web-push → new server/src/modules/notifications/ module (controller + service + Prisma PushSubscription model). Mirrors the existing module pattern (auth, campaigns, characters)
  • @dice-roller/rpg-dice-roller → new server/src/modules/dice/ module with DiceService.roll(notation, context) and a RollLog Prisma model. Roll events emitted on existing Socket.io infrastructure via a new gateway or extended battle/character gateway — match the existing gateway pattern in characters.gateway.ts and battle.gateway.ts
  • react-markdown + plugins + react-shiki → new client/src/features/vault/ feature folder per the existing features/*/components convention. New vault-renderer.tsx component owns the <Markdown> component config; wiki-link.tsx, vault-image.tsx, vault-code-block.tsx are the custom React components passed via components prop
  • webdav (or fs-based) → new server/src/modules/vault/ module with VaultProvider interface and either FsVaultProvider or WebDAVVaultProvider injected via NestJS DI. Endpoints prefixed /api/vault/*
  • idb-keyval + React Query persistclient/src/app/ (where the QueryClient already lives, or wherever App.tsx mounts providers). Wrap QueryClientProvider with PersistQueryClientProvider
  • BroadcastChannelclient/src/features/battle/hooks/use-display-channel.ts, used by the new display-mode route

Sources

  • /vite-pwa/vite-plugin-pwa (Context7, HIGH) — confirmed injectManifest strategy syntax, React useRegisterSW hook, Vite 7 support since v1.0.1
  • /web-push-libs/web-push (Context7, HIGH) — confirmed generateVAPIDKeys(), setVapidDetails(), sendNotification() API surface; current API and 410/404/429 error patterns
  • /remarkjs/react-markdown (Context7, HIGH) — confirmed remarkPlugins, components prop pattern for custom rendering; v9 line is current
  • /shikijs/shiki (Context7, HIGH) — Shiki 4.0.2 current; ESM-bundled grammars
  • /avgvstvs96/react-shiki (Context7, HIGH) — react-shiki recommended replacement for react-syntax-highlighter
  • /googlechrome/workbox (Context7, HIGH) — caching strategies primitives
  • npmjs.com release pages for vite-plugin-pwa, web-push, @dice-roller/rpg-dice-roller, webdav, react-shiki (Web search verified, MEDIUM-HIGH) — version numbers cross-checked
  • vite-plugin-pwa v1.2.0 release (npm) — released Nov 27, 2025; v1.0.1 added Vite 7 support
  • vite-pwa-org docs (Netlify)injectManifest, useRegisterSW patterns
  • Vite 7 release notes — Vite 7 requires Node 20.19+ / 22.12+
  • web-push npm — v3.6.7 mature line; web-push-libs org
  • @dice-roller/rpg-dice-roller npm — v5.5.1 current
  • react-shiki npm — v0.9.3 current (April 2026)
  • Shiki npm — v4.0.2 current
  • @portaljs/remark-wiki-link npm — Obsidian-flavored path resolution
  • perry-mitchell/webdav-client GitHub — TS-native WebDAV client v5
  • PWA iOS limitations 2026 (MagicBell) — iOS push only when installed to home screen, no Background Sync, Declarative Web Push since Safari 18.4
  • BroadcastChannel API (MDN) — same-origin cross-window messaging
  • Workbox strategies (Chrome for Developers) — NetworkFirst/CacheFirst/StaleWhileRevalidate
  • coddingtonbear/obsidian-local-rest-apinot adopted but listed as a reference if vault is hosted inside Obsidian itself rather than a static directory

Stack research for: Dimension47 next milestone (PWA + multi-screen battle + extended WS + Obsidian vault browser + Level-Up) Researched: 2026-04-27