Skip to content

Add examples/chat: WhatsApp-style local-first chat - #183

Draft
lucas-barake wants to merge 1 commit into
mainfrom
ship/chat-app
Draft

Add examples/chat: WhatsApp-style local-first chat#183
lucas-barake wants to merge 1 commit into
mainfrom
ship/chat-app

Conversation

@lucas-barake

Copy link
Copy Markdown
Owner

A comprehensive, runnable example of the whole effect-local stack: a WhatsApp-like chat app as a pnpm monorepo under examples/chat (shared/, server/, client/, test/). Four hard-coded users, real bearer-auth login, DMs + a pinned group, typing indicators, presence, and full read receipts — ✓ gray (server-acked), ✓✓ gray (delivered to peer), ✓✓ blue (read) — with WhatsApp-style failed-send handling (retry / discard). Local-first throughout: the app renders instantly from the local replica and shows no loading states anywhere.

Layout and wiring:

  • shared/ — domain models with branded ids (UserId / ConversationId / MessageId), deterministic mutation + query handlers used verbatim by both client and server, read-state keys as JSON tuples (conversation ids contain :), and the full-roster invariant for the pinned group:everyone conversation (its deterministic id is create-if-absent, so a partial or duplicate-substituted roster would otherwise pin the group forever).
  • server/ — Node HTTP + WebSocket sync, /login issuing tokens, and a default-deny authorizeMutation that enforces senderId / createdBy === principal.
  • client/ — React + atoms over multi-tab OPFS replicas. Optimistic sends; ticks advance from the settlement stream, not from query state. Failed sends live in an in-memory overlay atom fed by a settlement daemon.
  • test/ — smoke suite over real stacks (loopback WebSockets, two SqlReplica clients, real AtomRegistry + ReplicaAtom wiring). All waits rendezvous on reactivity and settlement streams — no sleeps or polling.

The settlement daemon (client/src/settlementDaemon.ts) is the subtle part and got the most review attention. It streams settlementsFor(SendMessage, { from: "acknowledged" }) so rejections that settle during a MultiTab handoff or while the tab was closed still reach the failed overlay. Two ordering constraints are load-bearing:

// get.once: reading with get would register failedMessages as a
// dependency of this daemon atom, so every overlay write would
// invalidate and interrupt the daemon itself (replay livelock).
const next = new Map(get.once(failedMessages))
// ... overlay writes happen BEFORE acknowledgeSettlements, so a
// teardown between the two replays from the floor and the re-add
// is idempotent; the reverse order could drop the failure record.

Both failure modes were reproduced in review (the livelock as a stack overflow) and are pinned by a regression test that forges senderId (local accept, server terminal-reject) and asserts the overlay entry plus floor advancement.

Limitations, documented in the README:

  • Ephemeral identity (typing/presence) is client-asserted — EphemeralHub's AuthorizationInput doesn't expose the published value, so the server can't bind ephemeral claims to the principal. Spoofable; needs a library-level fix.
  • Receipt cursors compare client wall-clock createdAt; clock skew can corrupt ticks/unread counts. A proper fix is server-assigned per-conversation sequences.
  • "Discard" on a failed message can't unqueue an already-queued pending mutation — there is no library withdrawal API (only discardQuarantined); the entry converges away via the settlement stream.
  • The overlay is intentionally in-memory per tab; the durable log holds accepted history only.

Repo plumbing worth knowing about:

  • The pre-commit snapshot linker only covered packages/*/node_modules; it now walks the tree and links nested workspace node_modules (examples/*/*), otherwise the snapshot drowns in module-resolution diagnostics.
  • Example packages set lib: ["ES2023", "DOM"] because workspace deps are consumed as TypeScript source and packages/local sources use TextEncoder (same reason packages/local sets it).
  • The client vite bundle now outputs to dist-web/ — vite's emptyOutDir was wiping the composite project's declaration output in dist/ and desyncing incremental build state.
  • Three scoped oxlint override blocks in the root config cover platform adapters (worker bootstrap, localStorage, fetch, vite config/scripts); rule messages explicitly bless adapter-level disables.

…ver monorepo)

A comprehensive example exercising the full effect-local API surface:
multi-tab OPFS replicas, sync over WebSocket RPC with bearer auth,
ephemeral typing/presence channels, SQL-windowed message pagination,
and settlement-driven WhatsApp receipts (sent/delivered/read) plus a
failed-message overlay with retry/discard.

- shared/: branded-id domain models, deterministic mutation/query
  handlers used by both client and server, full-roster enforcement for
  the pinned group conversation, JSON-tuple read-state keys
- server/: Node HTTP + WebSocket sync, /login with token auth,
  default-deny authorizer enforcing senderId/createdBy === principal
- client/: React + atoms, no loading states (local-first render),
  optimistic sends with settlement-driven ticks, in-memory failed
  overlay fed by a settlement daemon streaming from the acknowledged
  floor (get.once + overlay-before-ack so teardown races replay
  idempotently instead of livelocking or dropping failure records)
- test/: real-stack smoke suite (loopback WS, two SqlReplica clients)
  that rendezvouses on reactivity/settlement streams - no sleeps

Root wiring: workspace glob, tsconfig project reference, vitest
include, scoped oxlint overrides for platform adapters, dist-web
ignore so the vite bundle stops clobbering composite declaration
output, and a pre-commit snapshot linker that now covers nested
workspace node_modules (examples/*/*), not just packages/*. Example
packages set the DOM lib because workspace deps are consumed as
TypeScript source whose files use web globals such as TextEncoder.
@gitguardian

gitguardian Bot commented Aug 18, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 2 secrets following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secrets in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
36289973 Triggered Generic Password 7719d63 examples/chat/shared/src/domain.ts View secret
36289974 Triggered Generic Password 7719d63 examples/chat/shared/src/domain.ts View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secrets safely. Learn here the best practices.
  3. Revoke and rotate these secrets.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant