@openbody/openbody-ts
v0.3.1
Published
OpenBody reference implementation (TypeScript): validate + canonical normalize (the conformance suite's EQUIVALENCE.md method) + conformance vector runner. Apache-2.0.
Maintainers
Readme
openbody-ts
The TypeScript reference implementation of the OpenBody standard — validate, canonically normalize (the conformance suite's EQUIVALENCE.md method), and check equivalence of OpenBody records, plus a conformance-vector runner.
Status: on npm as
@openbody/openbody-ts, tracking the current pre-v1.0 OpenBody draft (see the standard'sCHANGELOG.md). A reference implementation is one implementation, not normative —SPEC.mdis the source of truth. Licensed Apache-2.0 (the standard itself is OWFa-1.0; kept in a separate repo).
What it does
validate(record)— validates against the published JSON Schema (§§4–7).normalizeDocument(doc)— runs the EQUIVALENCE.md canonical-normalization pipeline (number → lowest-terms fixed-point, unit canon, scalar→Target expansion, ExerciseRef fold,setsexpansion, deterministic id assignment, flatten +partOf, status default, RFC 8785 serialization) → a sorted set of canonical record byte strings.equivalent(a, b)— true iff two documents normalize to the same set.src/mappers/— incumbent → OpenBody mappers (Hevy, Strong, Strava, Apple Health, FIT; Health Connect via the Apple mapper): pure functions with round-trip tests (test/mappers/). Plus format-level GPX + TCX mappers (mapGpx/mapTcx— covers Runkeeper, Komoot, AllTrails, Ride with GPS, MapMyRun, Garmin/Polar legacy exports): trackpoint streams → multi-channel location + HR/cadence/powersampleArrayMeasurements, TCX laps → per-lap WorkUnits, all linked viameasuredBy(test/mappers/{gpx,tcx}.test.ts; built against the official schemas — verify against real platform exports, OB-79). And Fitbit (Google Takeout) —mapFitbitTakeouttakes the Takeout folder's JSON files (exercise/steps/heart_rate/sleep/weight/ resting-heart-rate): Sessions + per-daysampleArrayseries + adjacent sleep-stagecategoryintervals (short wakes spliced in), exact fixed-point weights (test/mappers/fitbit.test.ts; built against publicly documented Takeout structure — verify with a real Takeout, OB-80). Plus two breadth mappers (test/mappers/{concept2,thecrag}.test.ts): Concept2 Logbook season CSV (RowErg/SkiErg/BikeErg — pieces as time/distance/continuous WorkUnits, fixed-interval workouts as Blocks with per-interval rest, stroke rate/watts as §5.13 intensity, avg HR as a linked Measurement) and theCrag climbing logbook CSV (one Session per crag day; each ascent a reps-scored WorkUnit with grade modifier and send/attemptoutcomeper the canonical §5.18 corpus encoding). Plus one outbound mapper, OpenBody → Strong CSV (mapOpenBodyToStrong) — the import path into Strong and Hevy (which accepts Strong-format CSVs). Covers everything Strong's CSV can hold: reps ± weight, bodyweight, duration and distance sets, RPE, notes — with non-kg/m/s units converted by exact decimal math. Anything Strong can't represent (supersets/round schemes, %1RM loads, energy scoring, …) degrades gracefully per the documented policy and is reported in the returned{ csv, omissions }(SPEC §10: emitting into a less-expressive target is best-effort, bounded by the target); pass{ strict: true }to throw instead. Seesrc/mappers/to-strong.tsfor the full policy.resolveExerciseRef(name, { source })— the §6.5 producer-side matching ladder: raw app exercise names → canonical registry ids, with the original string preserved losslessly (see "Exercise-name resolution" below). Wired into the Hevy/Strong mappers.
This is the artifact that makes the conformance vectors executable: it pins the canonical bytes the spec describes.
Quick start
import { mapHevy, normalizeDocument, validate } from "@openbody/openbody-ts";
import { readFileSync } from "node:fs";
const csv = readFileSync("hevy-export.csv", "utf8");
const { records, warnings } = mapHevy(csv, { subject: "athlete-1" });
for (const w of warnings) console.warn(`${w.code}: ${w.message}`);
for (const record of records) {
const { valid, errors } = validate(record);
if (!valid) throw new Error(`invalid wire record ${record.id}: ${errors}`);
}
const canonical = normalizeDocument(records);
console.log(`${records.length} wire record(s) -> ${canonical.length} canonical form(s)`);Every inbound mapper (mapHevy, mapHevyMeasurements, mapStrong, mapStrava,
mapAppleHealth, mapFit, mapFitbitTakeout, mapGpx, mapTcx, mapConcept2,
mapTheCrag) has this same shape:
(input, opts?) => MapperResult — destructure { records, warnings } and validate each
mapped wire record, as above. normalizeDocument's output is a different, string-
fixed-point comparison form (§8.3) that deliberately does not pass the same
schema validation — never feed it back into validate. See examples/ for a
runnable, fixture-backed version of each mapper, and "Errors & warnings" below for what
each one can throw vs. report on the warnings channel.
Errors & warnings
One small typed hierarchy (src/errors.ts), all exported from the package root:
OpenBodyError (base, with a machine-readable code) and its three subclasses
MapperInputError, NormalizeError, ParseError. The per-layer policy:
validatereports invalid documents via its result object ({ valid, errors }) — it never throws on an invalid doc.parseLosslessthrowsParseErroron malformed JSON text (carries the failure characteroffset).normalizeDocument/equivalentthrowNormalizeErroron structurally malformed records (invalidroundScheme/setscombinations, non-numeric fixed-point parts).- Inbound mappers return a
MapperResult—{ records, warnings }. They throwMapperInputErroronly when the input is structurally unusable (wrong file shape, missing required column/stream), and never on merely-missing optional data: that degrades and is reported on the warnings channel instead (MapWarning { code, message, context? }— e.g.default-subjectwhen nosubjectoption was passed,skipped-filefor a corrupt Takeout file,dropped-untimed-pointsfor untimed GPX/TCX points). - The outbound Strong mapper keeps its established contract: best-effort
{ csv, omissions }, throwing only under{ strict: true }.
import { mapHevy, MapperInputError } from "@openbody/openbody-ts";
try {
const { records, warnings } = mapHevy(csvText, { subject: "athlete-1" });
for (const w of warnings) console.warn(`${w.code}: ${w.message}`);
} catch (e) {
if (e instanceof MapperInputError) console.error(`not a usable export: ${e.message}`);
else throw e;
}Install
npm install @openbody/openbody-tsimport { validate, normalizeDocument, equivalent } from "@openbody/openbody-ts";Requires Node ≥ 20.19. Published with build provenance (via GitHub Actions OIDC trusted
publishing), and the vendored schema + crosswalk snapshots (vendor/) are baked into the
tarball at publish time — so a normal install needs no sibling checkouts.
Building from a git checkout instead (to contribute)? See CONTRIBUTING.md: the prepack
hook regenerates vendor/ from the sibling openbody + openbody-registry repos, which a
plain npm install git+… can't do (those snapshots are gitignored).
The published package vendors a schema snapshot (vendor/openbody.schema.json,
refreshed from the sibling openbody repo by npm run sync-schema, which runs
automatically pre-pack/publish) — it does not depend on a sibling checkout at
runtime. npm run build compiles src/ to dist/ (ESM + .d.ts); npm pack
--dry-run shows exactly what ships (dist/, src/, vendor/, README.md,
LICENSE). src/ ships so the shipped source maps resolve to real files.
Bundling for the browser. The main entry point is browser-safe (no node:* in the
module graph — see the Layout table), but validate/resolve load the vendored schema
and crosswalk via JSON import attributes (import … with { type: "json" }, preserved
into dist/). Bundle with a toolchain that understands import attributes — modern
esbuild / Vite / Rollup / Webpack 5 do; a much older bundler may need a JSON plugin or
an upgrade. Node consumers need nothing extra (the >=20.19 engines floor covers it).
Develop this repo
npm install
npm run sync-schema # vendor the schema snapshot from ../openbody (typecheck needs it)
npm run sync-crosswalk # vendor the exercise-name data from ../openbody-registry (ditto)
npm test # typecheck + biome lint + the vitest suite (test/: lossless, vectors, resolver, mappers, validate)
# …or individually:
npm run vectors # run the standard's conformance vectors against this impl (CLI wrapper)
npx vitest run test/mappers # just the mapper suites
npm run coverage # vitest with v8 coverage (thresholds enforced on src/)
npm run typecheck
npm run lint # biome check (lint + format verification; config in biome.jsonc)
npm run format # biome format --write
npm run build # compile src/ -> dist/The vector runner (dev/test-only, not part of the package's public API — it is not
re-exported from src/index.ts and is unreachable through the exports map) reads the
standard (schema + vectors) from a sibling checkout (default ../openbody); override with
OPENBODY_STANDARD=/path/to/openbody. Schema validation prefers the vendored
snapshot when present (run npm run sync-schema to refresh it), falling back to the
sibling-repo path otherwise — so OPENBODY_STANDARD also lets you validate against
an unmerged local spec change without re-syncing. This OPENBODY_STANDARD-aware
resolution lives in src/schema-loader-node.ts, a Node-only module kept separate
from src/validate.ts (and never re-exported from src/index.ts) so importing the
package's main entry point stays safe to bundle for a browser — see the Layout table.
Exercise-name resolution (SPEC §6.5)
Exercise identity is the interop problem OpenBody exists to solve: Hevy calls it
"Bench Press (Barbell)", Strong "Barbell Bench Press" — resolveExerciseRef turns both
into the same canonical registry id, without ever losing the original string:
import { resolveExerciseRef } from "@openbody/openbody-ts";
resolveExerciseRef("Bench Press (Barbell)", { source: "hevy" });
// → { id: "bench-press.barbell.flat", opaque: "Bench Press (Barbell)" }
resolveExerciseRef("Some Custom Movement", { source: "hevy" });
// → { opaque: "Some Custom Movement" } (lossless fallback — never dropped)The ladder is deterministic, climbing the strictest rung that matches:
- Exact alias — the app's exact name in its curated alias table
(
openbody-registry/crosswalk/<source>.json). A curatednullthere means "known unmappable" and is authoritative: resolution stops and falls back to opaque (no fuzzy rung may override the curator). - Canonical-id passthrough — the name already is a registry id.
- Normalized match — lowercase / punctuation-stripped / whitespace-collapsed lookup
against all alias tables and the registry's id + name index, tried in two
deterministic forms: as-is, then token-sorted (word-order agnostic). There is
deliberately no discard-the-parenthetical rung — a qualifier like "(Assisted)" is
semantically load-bearing, and dropping it would mint a false canonical id (the
near-miss mapping the crosswalk curation rule forbids); uncurated qualified names
stay opaque until an alias-table entry is curated. A normalized key claimed by two
different canonical ids is ambiguous and never matches. Known limitation: the
punctuation-strippedstep is ASCII-only (see "Known limitations" below) — a non-ASCII name's accented letters are stripped rather than folded, so it can miss a match an accent-insensitive comparison would find. - Opaque fallback —
{ opaque: name }, per §6.1/§6.5 ("couldn't resolve" never means "drop").
Resolved refs carry both id (the interop anchor) and opaque (the original app
string, byte-for-byte) — the schema's ExerciseRef permits co-presence, and it's what
lets the outbound Strong mapper re-emit the source app's own names on round-trip
(sourceNameForId is the reverse lookup). The Hevy and Strong mappers call the
resolver automatically.
Maintaining the alias tables: they live in the registry repo
(openbody-registry/crosswalk/hevy.json / strong.json) — one { name, canonical }
entry per app exercise name, canonical: null for movements the registry doesn't
cover yet (do not map to a near-miss id; null is correct until the registry grows the
entry, and openbody-registry's npm run check verifies every non-null target
resolves). After editing them, re-run npm run sync-crosswalk here to refresh the
vendored snapshot (vendor/crosswalk.json, gitignored — same pattern as the schema;
default sibling path ../openbody-registry, override with OPENBODY_REGISTRY).
Number parsing (EQUIVALENCE.md step 1)
JSON numbers are parsed losslessly from their decimal text (parseLossless →
LosslessNumber), never via float64, before fixed-point canonicalization — so
high-precision decimals and integers above 2^53 canonicalize to their exact value
(test/parse.test.ts/test/canonical.test.ts prove it). Feed documents through parseLossless (or raw text)
for full EQUIVALENCE.md fidelity; passing a value pre-parsed with JSON.parse falls back to the
lossy float64 path.
Known limitations (first cut)
- No CLI yet — the library surface (validate + normalize + runner + mappers) comes first.
mapFitrequires pre-decoded input. FIT is a binary protocol; this package bundles no binary decoder (no correctly-licensed one can be a runtime dependency — seesrc/mappers/fit.ts's header). Decode the.fitfile yourself first (e.g. withfit-file-parser, MIT) and pass the resultingmode: "list"message lists in.- Every mapper needs a
subjectid you provide.MapOptions.subjecthas no sensible default, so an omitted one falls back to the placeholderDEFAULT_SUBJECT("subj-001") — reported once via adefault-subjectMapWarning, never silently. Pass your own subject id in production. resolveExerciseRef's normalized-match rung is ASCII-only. Itsnorm()step (src/resolve.ts) collapses anything outside[a-z0-9]to a space, which strips accents and other non-ASCII letters rather than folding them (e.g. "Développé couché" normalizes to "d velopp couch", not to an accent-insensitive match against "developpe couche") — a non-ASCII exercise name can silently miss an alias-table entry that would otherwise match. No workaround today beyond a curated exact-alias entry for the affected name.
Layout
| Path | Role |
|---|---|
| src/index.ts | the package's public entry point — re-exports every symbol below |
| src/types.ts | the wire format as hand-written TypeScript types (one interface per schema $def) + the mapper contract (MapOptions / MapperResult / MapWarning / DEFAULT_SUBJECT) |
| src/canonical.ts | number/timestamp canon + RFC 8785 serialization + set-array ordering |
| src/normalize.ts | the EQUIVALENCE.md normalization / equivalence algorithm (the suite's oracle) |
| src/records.ts | CONTAINERS: the inline-container fields per recordType (§5.1), shared by validate.ts and normalize.ts |
| src/validate.ts | JSON Schema validation (ajv), browser-safe — validates against the vendored schema, no node:* imports |
| src/schema-loader-node.ts | Node-only: OPENBODY_STANDARD-aware schema resolution + standardDir, used by dev/test scripts; not exported from src/index.ts |
| src/parse.ts | lossless decimal JSON parse (parseLossless / LosslessNumber) |
| src/errors.ts | the typed error hierarchy (OpenBodyError / MapperInputError / NormalizeError / ParseError) + the per-layer error policy |
| src/resolve.ts | §6.5 exercise-name resolver (resolveExerciseRef / sourceNameForId), browser-safe — static import of the vendored crosswalk snapshot |
| src/mappers/index.ts | the mapper SDK barrel — every inbound + outbound mapper re-exported from here (and from src/index.ts) |
| src/mappers/{hevy,strong,strava,apple-health,fit,fitbit,gpx,tcx,concept2,thecrag}.ts | the 10 inbound mappers — incumbent export format → OpenBody wire records ({ records, warnings }) |
| src/mappers/to-strong.ts | the one outbound mapper — OpenBody → Strong-importable CSV ({ csv, omissions }) |
| src/mappers/{csv,xml,shared}.ts | internal mapper plumbing (quoted-CSV parsing, regex-XML parsing, cross-format telemetry helpers) — deliberately not re-exported from the package entry |
| test/*.test.ts | vitest suite for the core: lossless parse, canonical form, conformance vectors, resolver, validate |
| test/mappers/*.test.ts | one vitest file per mapper (inbound + to-strong), plus test/helpers.ts for shared assertions (expectAllValid, expectRoundTripStable, readExample, …) |
| examples/ | runnable dogfooding scripts, one directory per format (map-<format>.ts + sample fixture(s), most with a README) — see examples/README.md |
| scripts/run-vectors.ts | conformance-vector runner (npm run vectors) |
| scripts/pin-expected.ts | dev tool: regenerates a conformance vector's pinned expected canonical form from this implementation |
| scripts/sync-schema.mjs | copies the schema from the sibling openbody repo into vendor/ for publishing |
| scripts/sync-crosswalk.mjs | builds vendor/crosswalk.json (registry name index + per-app alias tables) from the sibling openbody-registry repo |
| vendor/ | gitignored; populated by sync-schema + sync-crosswalk, shipped in the published package |
