Read this before working in frontend/. Serval embeds a complete
React/Vite management dashboard directly into the Rust binary. The dashboard is
served by the Control Plane on port 8080; the public Data Plane (3000)
serves no UI.
build.rscompiles the Vite/React app infrontend/and the binary embeds the output (frontend/dist/) via a virtual file system (e.g.rust-embed).- Build the frontend before the backend. A stale or missing
frontend/dist/embeds outdated assets.
cd frontend
npm ci # reproducible install
npm run verify # lint + test + build, in that order — the only entry pointverify is lint && test && build folded into one script, and producing
frontend/dist/ without checking it must stay impossible. Every CI path goes
through one of exactly two front doors, both of which run this fold:
| Path | Front door |
|---|---|
pr-quality-gate.yml, build-binaries.yml |
.github/actions/frontend |
Dockerfile stage 1 (used by docker-publish, integration tests) |
npm run verify |
A Dockerfile cannot call a composite action, which is why the fold also lives in
package.json — keep the two in step. Do not add a bare npm run build step to
a workflow: adding the missing checks at each call site is what lets them drift
apart again. build.rs still runs a plain npm run build, because its job is to
produce the bundle for embedding during local development; every CI path that
ships a bundle verifies it first.
Individual scripts (npm run lint, npm test, npm run build) remain for
tight local loops.
npm test runs scripts/run-suite.mjs,
a thin wrapper over Node 24's built-in runner. It exists for one reason:
node --test exits 0 when it discovers no test files. Since verify chains
lint && test && build, a suite that silently stopped being found — a rename, a
moved directory, a change in Node's discovery patterns — would let every workflow
and the Docker image report a green frontend while checking none of it. The
wrapper asserts the suite is non-empty first, then invokes node --test with no
arguments so there is no shell glob for a Windows or macOS runner to expand.
Do not name a helper in a way that matches Node's own discovery patterns
(test.mjs, *.test.mjs, *-test.mjs, test-*.mjs, or anything under a
test/ directory) — the runner will execute it as a test case.
The suite covers two kinds of file:
src/lib/*.test.ts— the pure core. Framework-free, so it needs no DOM, no renderer and no test framework beyond the standard library.src/public-assets.test.ts— anything underpublic/, which is copied byte-for-byte intodist/. Those files are unverified by definition: no build step parses them, so typecheck, lint and build can all be green while the shipped asset renders a browser parser-error page. A--inside an XML comment infavicon.svgis the concrete way this happens.
When adding an assertion, reintroduce the defect it describes and confirm the test goes red. A test never seen fail is a hypothesis, not a guard — and assert that any glob or file list is non-empty, or every case below it passes vacuously.
Framework-free, directly testable modules that carry the app's domain vocabulary. Components are the presentation of these; the rules live here, once.
remote-data.ts—RemoteData<T>: theloading | success | failureunion that replaced everydata+loading+errorcell trio.failurecarries the last good value (stale), which is what lets a list stay on screen under an error banner after a failed revalidation. Eliminate it withfoldRemote.pagination.ts—Page<T>plus the pure cursor-pagination fold (concatPages,cursorAfter,loadedCount). Ordered, non-commutative.useRemoteQuery.ts— the effect interpreter for a keyed read.loadingis derived from whether the newest request has settled, never stored, and the effect aborts on cleanup so a stale response cannot land. Itsrefresh()revalidates without clearing the screen and resolves once the new value is committed, so a mutation can await a consistent view.useCursorPager.ts— appends further pages to a seed the query owns. Pages are tagged with the seed they extend, so an append that resolves after a refresh cannot land on the new collection.useInlineEdit.ts— theviewing | editing | saving | failedstate machine shared by every inline editor.errors.ts—messageOf: only anApiErrormessage reaches the user.format.ts— one hoistedIntl.DateTimeFormat, not one per row per render.
Rules for this directory:
- Nothing in
src/lib/may import from outsidesrc/lib/, and imports within it carry the.tsextension, so the core runs unbundled undernode --test. That is the whole reasonApiErrorlives inlib/api-error.tsand is re-exported fromapi.ts. - Derive, don't store. If a value is a function of other state (
loading, a filtered list, an error parsed from the URL), compute it during render. Two cells that must agree are two cells that can disagree. - A
react-hookscomplaint is design feedback, not noise. All three formereslint-disable react-hooks/set-state-in-effectcomments were removed by changing the state shape, not by suppressing the rule. Do not reintroduce one.
- Don't call
fetch/axiosdirectly. Route every Control Plane request through the shared API client module underfrontend/src/, so auth headers, base URL, and error handling stay consistent. - The dashboard manages snippets only. It talks to
/api/snippets(create/update/restore); it never talks to the Data Plane. Keep delivery stateless. - No telemetry/analytics. The system deliberately omits custom analytics and relies on edge network logs. Do not add client-side tracking.
- Build delivery links via
deliveryUrl(id), never by hand. The Data Plane usually lives on a different domain than the dashboard, so the base is resolved at runtime: the backend advertisesDATA_PLANE_PUBLIC_URLin the/api/auth-infobootstrap, which the dashboard records viasetDataPlaneUrl. The helper falls back to the build-timeVITE_DATA_PLANE_URL, then to a:3000-on-this-host guess for local dev. Do not reintroduce a hardcoded port or origin assumption. - Let the linter and formatter enforce style — mirror the surrounding code rather than hand-tuning formatting.
GET /api/auth-info— public bootstrap metadata, fetched before sign-in: the active authmodeand thedata_plane_urlused to build delivery links.GET /api/snippets?limit=&cursor=— paginated listing, newest-changed first. Returns{ snippets, next_cursor, limit }.limitis capped at 50 (MAX_PAGE_LIMITinfrontend/src/api.ts); pass the previous page'snext_cursorto fetch the next page. There is no page-number orOFFSETparameter — the cursor is an opaque, server-signed token, so the UI can only move forward one page at a time ("load more"), never jump to an arbitrary page.POST /api/snippets— create. Computesdata_hash, inserts the block (ON CONFLICT DO NOTHING), generates a CSPRNGroute_id, and writes version 1 topointer_history. Every snippet is editable — there is no immutable snippet kind in the UI.PATCH /api/snippets/{id}— update. Inserts the new block, repoints the route, appends topointer_history, and triggers Data Plane cache eviction for{id}.GET /api/snippets/{id}?limit=— detail, including only the newest page of the version ledger.history_countis always the exact, unpaginated ledger total;historyholds up tohistory_limitentries;history_next_cursoris set when older entries remain. EachHistoryItemcarries a server-computedversion_numberandis_current— never recompute these from array position or length, since only the newest page is ever loaded client-side.GET /api/snippets/{id}/history?limit=&cursor=— fetch an older page of the same route's history, resuming from anext_cursorreturned by this endpoint or byGET /api/snippets/{id}. Returns{ history, next_cursor, limit }.GET /api/snippets/{id}/versions/{hash}— fetch the content of one past version (used to preview a history entry before restoring).POST /api/snippets/{id}/restore— repoint the snippet to an earlier version'starget_hash; appends a newpointer_historyrow and evicts the cache.
The editor is always shown — the detail view lets the user edit the current
content and view or restore any entry in the version history. Internally a
version's target_hash is a content address that the Data Plane can serve
directly, but the UI never surfaces it as a separate "permalink" concept; it is
just a pointer to a specific revision in the edit history.
- Every collection endpoint returns an opaque, signed
next_cursorstring (ornullat the end) instead of an offset or page number. Treat it as a black box: store it, echo it back verbatim as?cursor=, never parse or construct one client-side. - Cursors are endpoint-specific — a
next_cursorfrom/api/snippetswill be rejected with400if sent to/api/snippets/{id}/history, and vice versa. Don't cache or reuse a cursor across a different collection or route id. A400response should surface as a normalApiError, not be silently retried. Restart the affected list fromcursor: undefinedif that ever happens. - Never request or render more than
MAX_PAGE_LIMIT(50) rows in one page. - After a mutation that changes ordering or appends history (create, update,
restore), refetch from the first page rather than trying to patch an
in-memory page —
Dashboard.tsxandSnippetDetail.tsxboth do this via therefresh()theiruseRemoteQueryreturns. That refetch replaces the seed page, which is also what discards any previously appended pages; there is no separate reset to keep in sync. - A revalidation must not be answered from the prefetch cache. A refetch
after a write exists to observe that write, and a link warmed seconds earlier
predates it.
useRemoteQuerypassesisRevalidationto its loader for exactly this; consult it rather than callingloadPrefetchedunconditionally.