webtessera is a faithful port of Tessera to
TypeScript. Its value is that a reviewer can put a TypeScript file next to its Go original and see
that they do the same thing, so a change that is clever, idiomatic and unfaithful is a bad change.
Read PORTING.md before you write code: it holds the rules, and this file covers the workflow around them.
By participating you agree to the Code of Conduct. Report security problems through SECURITY.md, not the issue tracker.
- Port work.
docs/PORTING-MAP.mdlists every upstream Go file with its status;not startedrows are open. Comment on an issue, or open one, before you start, so that two people do not port the same file. - Storage drivers and examples. The web drivers (PORTING.md §8) are new code rather than translation. Bug reports with a failing test are especially welcome.
- Bugs. A discrepancy with Go's behavior is a bug; include the Go output in the report.
- API that Tessera does not have needs a discussion first: open an issue. It also needs an ADR (see below).
| Tool | Version | Needed for |
|---|---|---|
| Bun | 1.3, pinned by the packageManager field (currently 1.3.14) |
installing, and running every script; bun.com/docs/installation |
| Node.js | 22 or newer (.nvmrc says 22) |
the test suites, and the release; it must be on your PATH (see Bun and Node) |
| Go | 1.24 or newer | bun run fixtures and bun run interop only |
| Docker | any recent version | bun run test:services only |
| git | any recent version | bun run upstream, bun run fixtures |
| Chromium | any recent version | bun run test:browser only |
For Chromium, run bunx playwright install chromium once (add --with-deps on a fresh Linux
machine), or point PLAYWRIGHT_CHROMIUM_EXECUTABLE at a Chromium you already have.
git clone https://github.com/diagnos-tech/webtessera.git
cd webtessera
bun install # also links the library into the workspace and enables the pre-commit hook
bun run upstream # optional until you need the Go sourcebun run upstream clones Tessera into .upstream/tessera (gitignored) at the commit this port is
pinned to (scripts/upstream.json). That checkout is the specification, and the fixture generator
runs against it: read the Go file and its test before you port anything.
Bun installs the dependencies and runs every script (bun run <script>); it does not run the code
under test.
ADR-0240 has the reasoning and the
evidence.
- Vitest runs on Node.
bun run test:unitstarts Vitest on the Node on yourPATH, as CI does (Node 22 and 24). Never run it on Bun's runtime (bunx --bun vitest):node:sqlitedoes not exist there, which fails seven suites, and the workerd pool never finishes. Check thatnode --versionworks: when Bun finds no Node, it makesnodemean Bun. - Write
bun run test, neverbun test.bun testis Bun's own test runner, which knows nothing about Vitest or the*_test.tsconvention. Likewisebun run build, neverbun build. - npm publishes. A release is packed with Bun and uploaded with
npm publish --provenance, becausebun publishsupports neither npm provenance nor trusted publishing (docs/RELEASING.md). You never need npm to develop. bun installalso runsprepare(scripts/prepare.mjs). It links the repository root intonode_modules/webtessera, because the examples and the site import the library by name and Bun cannot link a workspace root into its own members, and, in a git checkout, it enables the pre-commit hook. Afterbun install --ignore-scripts, runnode scripts/prepare.mjsyourself. Registry installs of the package never runprepare.
Run every script from the repository root.
| Script | What it does | When to run it |
|---|---|---|
bun run lint |
Biome: lint, format check, import order, for TypeScript, JavaScript, JSON and CSS | Before every commit; the pre-commit hook and CI both run it |
bun run lint:fix |
The same, applying every safe fix | When lint complains about formatting |
bun run format |
Biome formatter only | Rarely; lint:fix covers it |
bun run typecheck |
tsc for the Node/browser sources, then for the workerd sources |
Before every commit |
bun run test:unit |
Vitest on Node: nearly all tests | Constantly; this is the inner loop |
bun run test:watch |
The same, in watch mode | While developing |
bun run test:browser |
Vitest in real headless Chromium (*_browser_test.ts) |
When you touch the IndexedDB driver or anything runtime-sensitive |
bun run test:workers |
Vitest inside workerd (*_workers_test.ts: the SQLite driver on D1 and Durable Objects) |
When you touch the SQLite driver or anything runtime-sensitive |
bun run test:services |
Vitest against live rqlite and S3-compatible servers (*_services_test.ts) |
When you touch the rqlite adapter or the S3 sink; see Continuous integration |
bun run test |
unit, then workers, then browser | Before you open a pull request |
bun run build |
tsc emits ESM and declarations to dist/ |
Before touching package.json exports; examples need it |
bun run check |
lint, typecheck, test, build in sequence |
The full pre-PR gate |
bun run upstream |
Checks out Tessera at the pinned commit into .upstream/tessera |
Once, and after the pin moves; idempotent |
bun run fixtures |
Runs bun run upstream, then regenerates fixtures/data/ with the Go generator |
When you add or change a generator case |
bun run interop |
Builds the package, then has Tessera's Go code verify and extend logs written by webtessera, and the other way round, on every Node backend | When you touch the storage engine, a backend or a wire format |
bun run smoke |
Builds the package and runs an end-to-end smoke test of dist/, the SQLite driver included, and loads every entry point at a Worker's global scope in workerd |
Before touching package.json exports, or code that runs when a module loads |
bun run clean |
Removes dist/ |
Rarely; prepack does it |
To run one test file: bunx vitest run --config vitest.config.ts src/api/layout/paths_test.ts. Add
-t "name" to run one test.
Biome formats and lints TypeScript, JavaScript, JSON and CSS (biome.jsonc says what
it covers and why); gofmt formats the Go in fixtures/gen and interop. Nothing formats Markdown
and YAML: .editorconfig carries their conventions. The settings in
.vscode/ apply the same formatting on save, with the Biome extension.
.githooks/pre-commit runs biome check --staged and gofmt -l on what you
are about to commit, and refuses a commit that CI would reject. It only checks; bun run lint:fix
and gofmt -w <file> repair what it reports. git commit --no-verify skips it for one commit, but
CI still checks. bun install leaves an existing core.hooksPath alone, and sets none in CI; to
enable the hook by hand, run git config core.hooksPath .githooks.
Tests sit next to the code they cover, and mirror upstream:
*_test.ts, never*.test.ts.paths_test.gobecomespaths_test.ts, with the same test names, table-driven cases and values, so that the two suites can be diffed and grepped side by side (PORTING.md §3.1).*_fixtures_test.tsassert golden fixtures, separately, so that the Go-mirrored file keeps diffing line for line against its original.*_browser_test.tsrun in real Chromium (bun run test:browser),*_workers_test.tsin workerd (bun run test:workers), and*_services_test.tsagainst live servers (bun run test:services). Everything else runs on Node.testing/directories hold shared helpers, such as theObjectStoreconformance suite and the workerd test Worker. They are excluded from the published package.- A test skipped, weakened or deleted to get a build green is a blocking review finding. If you cannot make a test pass, say so in the pull request.
PORTING.md §3 has the full text and the reasoning.
- File and directory names are identical to Go, snake_case included. Declarations keep Go's order.
- Identifiers map mechanically: exported functions become camelCase, types and constants keep their Go spelling. Never rename beyond that.
uint64isbigint, always. Narrower integers arenumber.[]byteisUint8Array, neverstringorBuffer.- Errors are thrown with Go's exact message text, and sentinels are matched with the helpers in
src/internal/gostd/errors.ts. Merkle hashing and proofs stay synchronous. - Upstream comments are specification. Port them verbatim. Where the port diverges, add a
// Port note:linking the ADR. - Go stdlib stand-ins live in
src/internal/gostd/. Use them; do not write a private copy. - Dependencies are
@noble/hashesand@noble/curves, and nothing else. No Node built-ins, noBuffer, noprocessin library code. - No
any, no@ts-expect-error, no.skip, no commented-out code, noconsole.log.
For every file you port (PORTING.md §4):
- Read the Go source and its test file end to end, in
.upstream/tessera. - Port the test first, run it, and watch it fail. A test that passes before the implementation exists is broken.
- Port the implementation until it is green.
- Anything that produces bytes (tiles, bundles, checkpoints, proofs, paths, notes) also gets a
golden fixture: add a case to the Go generator in
fixtures/gen/, runbun run fixtures, and assert the resulting JSON from a*_fixtures_test.ts. The generator calls real Tessera and records what it returns; it never computes anything itself. - Update
docs/PORTING-MAP.md: status, test counts, notes. - Write an ADR for every divergence and every upstream file you chose not to port.
Two fixture rules are not negotiable:
- Never edit
fixtures/data/by hand, and never change a fixture to make a TypeScript test pass. The fixture is right and the port is wrong. If you believe a fixture is wrong, say so in the pull request and fix the generator. - Regeneration must be reproducible. After
bun run fixtures,git status --porcelain fixtures/datamust show only the changes you intended. CI regenerates the fixtures and fails on any difference.
fixtures/README.md explains the corpus and how to audit it.
Any divergence from Go, however small, any upstream file you do not port, any API Tessera does not
have, and any new dependency needs an ADR in docs/decisions/:
- Copy
0000-template.mdtoNNNN-kebab-title.md, using the next unused number. - Fill in the context (quote the upstream code), the decision, the consequences and at least one alternative you rejected, with the reason.
- Open it with the code it describes, or ahead of it if you want agreement first.
- A reviewer other than you signs the
## Reviewsection before the ADR is in force. If you and the reviewer disagree, both positions go into the ADR and the maintainers decide.
docs/README.md explains how to read the existing ADRs, and
docs/REVIEW-PROTOCOL.md lists what reviewers check, in order.
- Conventional Commits.
type(scope): summaryin the imperative, with a lowercase type fromfeat,fix,docs,test,refactor,perf,build,ci,choreorstyle. Use the area as the scope:feat(storage): …,fix(note): …,test(merkle): …. Mark a breaking change with!or aBREAKING CHANGE:footer. - Keep pull requests focused: one upstream file or one coherent change at a time.
- Fill in the pull request template. Its checklist is the definition of done from PORTING.md §10,
including the real
bun run test:unitsummary line. State plainly what is incomplete. - CI must be green. The required check is
CI passed.bun run check, plusbun run fixturesif you touched the generator, reproduces most of it; Continuous integration covers the rest.
The examples are Bun workspace packages under examples/. They import the library by its package
name, which resolves to its built dist/, so build the library with bun run build first, and again
after you change it. Each example's README says how to run it on each runtime.
bun run build
bun run --cwd examples/client-only dev # a browser's own log, in IndexedDB (Vite)
bun run --cwd examples/log-server start:node # or start:bun, start:deno: the same server on each runtime
bun run --cwd examples/session-receipts ci # any example's tests, as CI runs themCI runs every example's ci script (bun run --filter './examples/*' ci), so a new example needs a
ci script and no change to the workflows. bun run --filter starts the scripts together, so a ci
script must not rebuild the library (that would race on dist/): run bun run build first. Keep the
examples working when you change the public API.
ci.yml runs for every pull request, in the merge queue and on every push
to main. It wires together the reusable workflows (.github/workflows/_*.yml) and ends with one
job, CI passed, which fails if any other job failed or was cancelled. Branch protection requires
only that check, so adding or renaming a job never needs a settings change.
| Workflow | What it checks | Reproduce locally |
|---|---|---|
_quality.yml |
Biome, types, Go formatting and go vet, and the workflow files themselves (actionlint) |
bun run lint, bun run typecheck, gofmt -l fixtures/gen interop (must print nothing), go vet ./... in each Go module |
_test.yml |
Unit tests on Node 22 and 24; the browser, workerd and services suites; every entry point loaded at a Worker's global scope | bun run test:unit, bun run test:browser, bun run test:workers, bun run test:services, node scripts/smoke-workerd.mjs |
_compat.yml |
Fixtures regenerate unchanged from the pinned Go source; Go and TypeScript read each other's logs | bun run fixtures, bun run interop |
_package.yml |
One job packs the tarball (and its GitHub Packages variant) from the lockfile; another tests it: smoke test, publint, Are the Types Wrong? | bun pm pack, then node scripts/smoke-pack.mjs <tarball> |
_runtimes.yml |
The built package on Node, Bun and Deno | bun run build, then node scripts/smoke-runtimes.mjs (or bun, or deno run --allow-read) |
_examples.yml |
Every example's ci script |
bun run build, then bun run --filter './examples/*' ci |
_site.yml |
The landing page builds and passes its browser smoke test | bun run --filter webtessera-site ci |
bun run test:services needs an rqlite node and an S3-compatible server. To start the containers CI
uses (Docker with Compose is the only requirement):
docker compose --file .github/actions/services/compose.yaml \
--env-file .github/actions/services/services.env up --detach
set -a; . .github/actions/services/services.env; set +a
node .github/actions/services/create-bucket.mjs
bun run test:servicesWhen you change a workflow:
- Every job sets
timeout-minutesand checks out withpersist-credentials: false. Workflows default topermissions: contents: read; a job that needs more asks for exactly that. - Pin third-party actions to a commit SHA, with the release in a trailing comment. Dependabot updates both; do not replace a SHA with a moving tag.
- Put a step that several jobs share in a composite action under
.github/actions/; do not copy it. - Jobs install with
bun install --frozen-lockfile(thesetupaction does), which fails whenbun.lockdisagrees with apackage.json: commit the two together. The Bun version is thepackageManagerfield; change it there and nowhere else. Tests run on Node, and publishing uses npm (see Bun and Node). - Run actionlint before you push (
actionlintif installed, ordocker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:1.7.12). CI runs it too, but otherwise a workflow mistake shows only when that workflow runs.
CHANGELOG.md follows Keep a Changelog. Add
a line under ## [Unreleased] for any change a user of the package would notice: new or changed
exports, behavior changes, fixes. Internal refactors, test-only and documentation changes need none.
Maintainers cut releases. A pull request renames Unreleased to the new version and date and bumps
version in package.json. Publishing a GitHub Release for the tag vX.Y.Z runs
.github/workflows/release.yml, which re-runs the whole CI and,
once a reviewer approves the release environment, publishes the tested tarball to npm
(webtessera) and to GitHub Packages (@diagnos-tech/webtessera), with provenance.
docs/RELEASING.md has the step-by-step process, the one-time setup and how to
verify a release.
webtessera is licensed under Apache-2.0. There is no CLA: by submitting a contribution you
agree that it is licensed under Apache-2.0 (section 5 of the License). New files carry the header in
PORTING.md §9. If you bring in code from anywhere else, say where it came from in the pull request:
its license text goes in LICENSES/ and an entry in NOTICE, and code
derived from Go sources stays BSD-3-Clause rather than being relabeled.