RevyHubX is a Next.js App Router application built from two parts: a small stable core and any number of independent feature slices.
core/ shared kernel — changes rarely, reviewed carefully
features/ one directory per tool — added freely, never collides
app/ three files: layout, dashboard, and one dynamic tool route
scripts/ registry generation, scaffolding, contract verification
| Module | Responsibility |
|---|---|
core/result |
Result<T, Code> — the shared success/failure shape |
core/telemetry |
Structured, redacted logs for every critical path |
core/workers |
Background worker framework: delayed, retryable, dead-lettered jobs |
core/lifecycle |
Record state machines: declared states, legal transitions, rejected moves |
core/idempotency |
Idempotency keys, persisted outcomes and replay protection |
core/reconciliation |
Read-only dry-run reconciliation of stored records against derived state |
core/operations |
Recovery for interrupted multi-step flows: checkpoints, resume, stuck-operation diagnostics |
core/audit |
Append-only audit trail for sensitive user and maintainer actions |
core/export |
Privacy-safe, scoped, schema-versioned data exports |
core/contract |
API contract schemas and drift detection |
core/network |
Network selection, URLs, passphrases, NetworkProvider |
core/horizon |
Memoised Horizon client and the shared error taxonomy |
core/rpc |
Minimal Soroban JSON-RPC caller |
core/format |
Stellar amount parsing/formatting and date formatting, locale-aware and exact |
core/registry |
Feature manifest types and the generated registry |
core/ui |
Accessible primitives: Field, StatusMessage, DataList, … |
core/layout |
App shell, header, sidebar |
core/testing |
renderFeature, MSW harness, axe assertions |
core/lib |
cn, clipboard, string helpers |
Core is where cross-cutting behaviour lives. A change here affects every tool, so it is deliberately small and separate from the work contributors do.
Each directory under features/ is a self-contained tool. The full
specification is in FEATURE_CONTRACT.md.
scripts/generate-registry.mjs scans features/*/manifest.ts and writes the
manifest list plus lazy loaders into core/registry/:
| File | Used by |
|---|---|
manifests.generated.ts |
Navigation, dashboard, search, generateStaticParams |
registry.generated.ts |
Direct entry lookup with load: () => import("@/features/.../panel") |
panels.generated.ts |
Optional lazy map kept for analysis/debugging only |
All three are gitignored and regenerated automatically on predev,
prebuild, pretest, prelint and postinstall.
This is the central design decision. Because the registry is generated rather than committed:
- adding a tool requires no edit to any shared file,
- dozens of feature branches can be open without conflicting,
- and navigation, routing and search stay in sync automatically.
The registry keeps a metadata-only manifest and a lazy implementation loader. A feature's metadata is eagerly imported so nav/search can render instantly, while its panel implementation is only fetched when the tool route is visited. The shared bundle budget is therefore: zero eager feature-panel imports and zero module-side costs from individual slice implementations in the landing page.
One route serves every tool:
app/tools/[slug]/page.tsx
It resolves the slug against the registry, renders FeatureShell (heading,
character line, network badges) and mounts the slice's panel.
generateStaticParams prerenders every registered tool at build time.
form input
↓ schema.ts raw string → Result<Input, Code>
↓ hooks/use<Name> state machine, network tagging, request identity
↓ lib/<domain>.ts Horizon / RPC / local computation → Result<T, Code>
↓ lib/format.ts values → display strings
↓ components/ idle | loading | success | error
Validation happens before any request. Transport failures are mapped to the
slice's own error codes by lib/<domain>.errors.ts, so a component never sees
a raw exception.
vitest with jsdom, Testing Library, MSW and axe-core.
core/testing/render.tsx—renderFeaturewraps components in the real providers and returns a bounduserEvent.core/testing/msw.ts—withMswHandlersinstalls a server with the standard lifecycle hooks. Unhandled requests fail the test.core/testing/axe.ts—expectNoAxeViolationsfails with a readable report.
Requests are always mocked at the network boundary. vi.mock on an internal
module would test the mock instead of the code.
npm run check # registry → lint → test → verify:* → fixtures → buildCI runs the same steps on every pull request, including
npm run verify:features, which fails a slice that does not meet the contract,
npm run verify:issues, which preserves a backlog of at least 40
independent specifications with a stable 20-issue advanced wave,
npm run verify:reconciliation, which fails if a declared invariant is never
checked or if the dry run ever grows a write path,
npm run verify:audit, which fails if a declared sensitive action has no
emitter at a domain boundary, if a boundary records an undeclared one, or if the
trail ever gains an update, a delete or an unredacted context, and
npm run verify:fixtures, which imports every features/*/fixtures/*.fixture.ts
file and fails with the specific file if a @stellar/stellar-sdk upgrade
broke it.
Fixtures are decentralized — one fixtures/ directory per slice, owned by
that slice's contributor — so there is no single place to eyeball after
bumping @stellar/stellar-sdk. Most fixtures build their values with real SDK
calls (Keypair, TransactionBuilder, xdr.*) rather than hand-typing them,
so a renamed export or changed constructor throws the moment the fixture
module loads. Run npm run verify:fixtures as part of every SDK-upgrade PR —
it reports exactly which fixture file failed to import and why.
See ISSUE_PUBLISHING.md for the five-at-a-time GrantFox publication flow.
Why the architecture is shaped this way — slices, the generated registry,
Result error codes, slice-local fixtures, the runtime contract ledger and
shared formatting — is recorded in adr/. Read the relevant
record before proposing to change one of those decisions.