cardano-tessera-client
v0.3.0
Published
Typed HTTP client for a Tessera serving backend: the CIP-179 survey list, bundles, per-transaction responses, artifacts and health, decoded into cip-179 types, with the contract's version check, its constants and the per-network epoch calendar.
Maintainers
Readme
cardano-tessera-client
The typed client for a Tessera
serving backend: the CIP-179 survey list, one survey's bundle, the responses
a transaction carried, tally artifacts and health, decoded into
cip-179 types, with the contract's version checked before the
first read is trusted.
Best-effort and 0.x. The stability promise is the HTTP contract's own version
(API_VERSION, changelog in backend/server/CHANGELOG.md of the Tessera
repository), which this package tracks: the API is first the seam between
Tessera's own frontend and backend, and this package exists so a host does
not have to hand-write that seam again.
Install
npm install cardano-tessera-client cip-179cip-179 is a peer dependency: the client returns its types and throws its
Cip179DecodeError, and a host that also uses cip-179 directly must see one
copy of that class for instanceof to hold.
Read a survey
import { createTesseraClient, currentEpoch } from "cardano-tessera-client";
const client = createTesseraClient({
baseUrl: "https://tessera-backend-preprod.matthieu-pizenberg.workers.dev",
network: "preprod",
});
const answer = await client.wholeBundle(
"5910e44ca9bb9a41625280a1335a4a59941a15716d6959901c9b8e20a058649d:0",
);
if (!answer.ready) {
// The backend has not completed its first refresh — wait, do not alarm.
} else {
const { survey, responses, verdicts, govLinks, tip } = answer.body;
// survey.definition and survey.ref feed <tessera-respond>, with
// tipEpoch: currentEpoch("preprod").
}Every snapshot-derived method (surveys, surveysByRefs, changes,
changesSince, bundle, wholeBundle, responded, responsesByTx) answers
{ ready: true, body } | { ready: false }; the false branch is the backend's
own 503 {"error":"snapshot not ready"}, an ordinary state before its first
refresh. Any other non-2xx answer throws TesseraHttpError with the status.
The first of those reads also fetches /health and refuses a backend serving
another network than the one given, or another contract major than
API_VERSION's; a minor the client does not know is accepted.
Input the contract would refuse is refused here first, with a RangeError
and no request: a survey key not matching SURVEY_KEY_RE, a limit outside
1–MAX_PAGE_LIMIT, more than MAX_CREDENTIALS credentials, more than
MAX_TX_STATUS_HASHES hashes, a hash that is not 64 lowercase hex characters.
Methods
| Method | Route | Answer |
| :------------------------------------------ | :---------------------------------- | :------------------------------------- |
| liveness() | GET /health | BackendLiveness |
| health() | GET /api/health | BackendHealth |
| surveys(params?) | GET /api/surveys (paged) | SnapshotAnswer<SurveyListPayload> |
| surveysByRefs(keys) | GET /api/surveys?refs= | SnapshotAnswer<SurveyListPayload> |
| changes(cursor, limit?) | GET /api/surveys?changes= | SnapshotAnswer<SurveyChangesPayload> |
| changesSince(sinceUnix, limit?) | GET /api/surveys?since= | SnapshotAnswer<SurveyChangesPayload> |
| bundle(survey, cursor?) | GET /api/surveys/{txHash}/{index} | SnapshotAnswer<SurveyBundlePayload> |
| wholeBundle(survey) | every page of the above | SnapshotAnswer<SurveyBundlePayload> |
| responded(credentials) | GET /api/responded | SnapshotAnswer<RespondedPayload> |
| responsesByTx(txHash) | GET /api/responses/{txHash} | SnapshotAnswer<TxResponsesPayload> |
| artifact(survey) / artifactByHash(hash) | the artifact routes | TallyArtifact \| null |
| tip() | GET /api/tip | ChainTip |
| txStatus(hashes) | GET /api/tx_status | Record<string, number \| null> |
| pparams() | GET /api/pparams | unknown (evolution-sdk's shape) |
A survey is named by its record's ref or by its key, <txHash>:<index>.
bundle is one page — enough for a host that only renders the survey, since
the definition rides every page; wholeBundle follows nextCursor to the end
with collectSurveyBundle's restart rule, for anything that counts or
displays responses.
Mirror the surveys
A host that keeps its own copy of the surveys walks the list once — or names the instant it last ran — and then asks only for what changed:
let cursor = load(); // the string from the last tick, or null
if (cursor === null && lastRunUnix === null) {
// Nothing to resume from: walk the list once, and keep where it ends.
let page = await client.surveys({ limit: 200 });
for (;;) {
if (!page.ready) return;
if (page.body.resync) return; // walk again next tick
apply(page.body);
if (page.body.nextCursor === null) break;
page = await client.surveys({ limit: 200, cursor: page.body.nextCursor });
}
save(page.body.changesCursor!);
return;
}
// Resume: from the last tick's cursor, or from when this copy last ran.
const delta =
cursor === null
? await client.changesSince(lastRunUnix!)
: await client.changes(cursor);
if (!delta.ready) return;
for (const key of delta.body.removed) forget(key); // before the rows
apply(delta.body);
save(delta.body.nextCursor);A change is delivered once and never missed, and the consumer never handles a
generation number. A delta always continues: nextCursor is never null, and
the change selection never answers resync. A removal is advisory and can be
transient (a reorg re-lands the transaction at a new slot), so state that
cannot be rebuilt is confirmed with surveysByRefs before it is destroyed.
The delta carries no filter and no counts: filter locally.
changesSince reports removals back to the first change-selection deploy on
that backend (2026-09-04 on both testnets). A copy older than that gets its
rows but not the sweeps of that era, so it starts from the walk instead.
Which epoch a host passes as tipEpoch
currentEpoch(network) — the calendar's epoch, from EPOCH_ZERO_UNIX and
SECONDS_PER_EPOCH. A survey accepts responses through its endEpoch
inclusive and the ledger's epoch is wall-clock, so the calendar is what
decides "still open". A stored snapshot's tip.epoch lags it by up to one
refresh interval, which around an epoch boundary shows a just-closed survey
as open.
What a host must not claim
The host contract in interop/preprod.md of the Tessera repository lists the
limits that travel with this data: a sealed response hides its answers and
nothing else; a governance link is a discovery relation, not an endorsement;
results are per role and never merged; countedByRole is provisional while a
survey has no artifact.
Development
pnpm install
pnpm --filter cardano-tessera-client type-check
pnpm --filter cardano-tessera-client test
pnpm --filter cardano-tessera-client build # emits dist/ for publishingIn the workspace the package is consumed straight from src; dist/ is only
produced for publishing, where publishConfig.exports swaps the entry points
to the compiled output at pnpm publish time.
