@human-synthesis/norns-tron
v0.0.3
Published
TRON serialization for the Norns ecosystem — token-efficient, faster-than-JSON wire format for APIs and LLM-facing output.
Readme
@human-synthesis/norns-tron
TRON serialization for the Norns ecosystem — a token-efficient, faster-than-JSON wire format for APIs and LLM-facing output. Zero runtime dependencies.
TRON cuts 11–61% of tokens vs JSON and, used correctly, beats
JSON.parse / JSON.stringify on decode, encode, and round-trip. Plain JSON is
valid TRON, so adoption is progressive and rollback is trivial. See
PERFORMANCE.md for measured numbers and usage guidance.
The encoder/decoder core is absorbed from the apitron research library; this
package adds the Norns framework glue as subpath exports.
@human-synthesis/norns-tron encode / decode / defineSchema / registry / columnar
@human-synthesis/norns-tron/server tronSerializer() for norns route()
@human-synthesis/norns-tron/client createApi() fetch wrapper that speaks TRON
@human-synthesis/norns-tron/valibot derive wire schemas from valibot schemasTurn it on app-wide
# src/hooks.server.c
import { boot } from '@human-synthesis/norns/server'
import { tronSerializer } from '@human-synthesis/norns-tron/server'
app := await boot
features: import.meta.glob('./lib/*/server/module.c', eager: true)
serializer: tronSerializer()Every route() response is now content-negotiated: clients that send
Accept: application/tron get TRON, everyone else (curl, third parties,
existing code) keeps getting JSON. Nothing breaks. Remove the serializer
line to roll the whole thing back.
Per-route control:
export GET := route serializer: tronSerializer({ schema: noteWire }), handler: ... # schema mode
export GET := route serializer: tronSerializer({ columnar: true }), handler: ... # numeric tables
export POST := route serializer: null, handler: ... # force plain JSONCall it from the client
import { api, createApi } from '@human-synthesis/norns-tron/client'
users := await api.get '/api/users' # sends Accept: application/tron
await api.post '/api/notes', { title, body } # body goes out as TRON too
# inside a load function, keep SvelteKit's fetch semantics:
api := createApi { fetch }
# endpoints served in schema mode tag the wire; hand the client the contracts:
api := createApi { schemas: [noteWire, userWire] } # or a createRegistry()Responses are decoded by content type: self-describing TRON on its own,
tagged schema-mode documents (#notes.v1) through the matching compiled
schema, JSON as JSON. A tag with no registered schema throws instead of
handing you positional arrays. Non-2xx responses throw ApiError with the
decoded body (status, body.message, body.issues from a route() 400).
The 1.7 KB WASM scanner ships embedded as base64 — no asset wiring, works in
Node, Bun, and the browser. Under a strict CSP without unsafe-eval the
decoder transparently falls back to the JS scanner (~1.1–1.5x).
Where it pays off
| Payload | Mode | Why |
|---|---|---|
| Paged / sorted tables (DataTable, admin lists) | schema (tronSerializer({ schema }) + createApi({ schemas })) | no shape on the wire, row constructor compiled once; pairs with norns listQuery() / listResult() and norns-ui useList() |
| Picker options (Autocomplete / MultiSelect source) | default | small responses ship as JSON automatically; TRON kicks in past ~1 KB |
| Charts, aggregations, exports of numbers | columnar (tronSerializer({ columnar: true }) + api.tape()) | decodes into one Float64Array, ~3x faster than JSON.parse, no row objects |
| Agent / LLM-facing reads (search results, indexes) | self-describing (tronSerializer()) | a cold reader gets the declarations in-band; 26–61% fewer tokens on tabular data |
| Cached lists on Workers (route({ cache: { ttl } })) | any | the encoded body is stored per Accept variant, so encode runs once per TTL and clients revalidate with ETag |
The norns-demo has one page per row under /examples/tron.
Schema mode — the fastest path for internal endpoints
Both ends already know the shape from the feature contract, so nothing
descriptive needs to travel. Derive the wire schema from the valibot schema
you already have in shared/schema.c:
# src/lib/notes/shared/schema.c
import * as v from 'valibot'
import { tronSchemaFromValibot } from '@human-synthesis/norns-tron/valibot'
export noteSchema := v.object
id: v.number()
title: v.string()
status: v.picklist ['draft', 'published']
export noteWire := tronSchemaFromValibot noteSchema, { id: 'notes.v1', path: '$.data' }picklist/enum fields become dictionary columns (integers on the wire),
booleans become 0/1. Compile once at module scope — never per request.
The #notes.v1 tag makes version mismatches fail loudly instead of
misdecoding; use createRegistry() (or the schemas array) on a client that
consumes several shapes. Derivation is for flat object schemas; nested rows
still work through the self-describing mode.
Columnar mode — numbers without objects
For a table whose values are all numbers (or dictionary-able strings / booleans), skip row objects entirely:
# server
export GET := route
serializer: tronSerializer({ columnar: true })
cache: { ttl: 30 }
handler: ({ container }) => stats(container).perDay() # [{ day, count, chars }, …]
# client
{ fields, rows, cols, tape } := await api.tape '/api/stats'
count := tape[i * cols + fields.indexOf('count')]api.tape() always returns the same { fields, rows, cols, tape } shape:
the WASM fast path when the server answered columnar TRON, and a packed
fallback (tableFromRows) when it answered JSON or WASM is unavailable.
Semantics and limits
- Same value semantics as JSON:
toJSON()is honored, so aDatearrives as its ISO string (not aDate— same asresponse.json()).Map/Setserialize as{}andBigIntthrows, exactly likeJSON.stringify; dev mode logs a warning when a route returns them. - Not for
load/ form actions — SvelteKit serializes those with devalue (which preserves Dates, Maps, Sets) and only dispatches form-encoded POSTs to actions. This package targetsroute()endpoints, LLM-facing output, and service-to-service payloads; norns-ui'sFormhas an API mode (submit) for posting throughapi.post(). - Payloads under ~1 KB are emitted as plain JSON automatically (still decoded transparently) — below that size TRON's fixed costs don't pay for themselves.
- The wire content type is
application/tron, notapplication/json: a TRON body may carry a declaration preamble that is not valid JSON. - Schema mode uses
new Functionfor the row constructor where allowed and a closure fallback elsewhere (Cloudflare Workers, CSP-restricted browsers).
Development
bun test # 50 tests: core roundtrips, Date regression, server/client glue, schema registry, columnar tape, valibot derivation
bun run embed-wasm # regenerate src/core/wasm-bytes.js after replacing wasm/parserTron2.wasmLicense: MIT.
