@human-synthesis/norns-tron 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/PERFORMANCE.md +179 -0
- package/README.md +108 -0
- package/package.json +43 -0
- package/src/client.d.ts +39 -0
- package/src/client.js +86 -0
- package/src/core/tron-auto.js +85 -0
- package/src/core/tron-encode.js +486 -0
- package/src/core/tron-schema.js +227 -0
- package/src/core/tron-table.js +159 -0
- package/src/core/tron-trampoline.js +396 -0
- package/src/core/tron-wasm.js +169 -0
- package/src/core/tron.js +734 -0
- package/src/core/wasm-bytes.js +4 -0
- package/src/index.d.ts +105 -0
- package/src/index.js +165 -0
- package/src/server.d.ts +19 -0
- package/src/server.js +99 -0
- package/src/valibot.d.ts +14 -0
- package/src/valibot.js +84 -0
- package/wasm/parserTron2.wasm +0 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel Teodoroiu / Human Synthesis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/PERFORMANCE.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# APITRON — ghid de performanță maximă
|
|
2
|
+
|
|
3
|
+
Toate cifrele de mai jos sunt măsurate (Node v22, Xeon 2 nuclee, benchmark
|
|
4
|
+
pereche-întrețesut față de JSON nativ, mediana a 15 runde). **Raportul este
|
|
5
|
+
TRON/JSON — sub 1.00 înseamnă că TRON e mai rapid.**
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 1. Alege modul corect — asta contează cel mai mult
|
|
10
|
+
|
|
11
|
+
| situație | folosește | decode | encode | round-trip | tokeni |
|
|
12
|
+
|---|---|---:|---:|---:|---:|
|
|
13
|
+
| API intern (ambele capete codul tău) | **`defineSchema`** | 0.40–0.87x | 0.66–0.93x | 0.57–0.91x | 11–61% |
|
|
14
|
+
| Prompt LLM / consumator terț | **`encode`/`decode`** | 0.45–0.77x | 0.97–1.77x | 0.76–1.22x | 26–61% |
|
|
15
|
+
| Date numerice/columnare | **`decodeColumnar`** | ~0.30x | — | — | 35% |
|
|
16
|
+
| Payload sub ~1 KB | **JSON simplu** | — | — | — | 0% |
|
|
17
|
+
|
|
18
|
+
Diferența dintre modul cu schemă și cel auto-descriptiv este **cea mai mare
|
|
19
|
+
optimizare disponibilă** — mai mare decât orice reglaj fin. Modul cu schemă
|
|
20
|
+
câștigă pe toate axele, la orice dimensiune.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## 2. PRELOAD o singură dată, la pornirea aplicației
|
|
25
|
+
|
|
26
|
+
Greșeala numărul unu este să compilezi schema la fiecare cerere. `defineSchema`
|
|
27
|
+
face muncă reală: rezolvă ordinea câmpurilor, tabelele de enum, calea și
|
|
28
|
+
**compilează constructorul de rând cu `new Function`**. Fă asta o singură dată.
|
|
29
|
+
|
|
30
|
+
```js
|
|
31
|
+
// schemas.js — modul la nivel de aplicație, evaluat o singură dată
|
|
32
|
+
const { defineSchema } = require('apitron');
|
|
33
|
+
|
|
34
|
+
exports.users = defineSchema({
|
|
35
|
+
id: 'users.v1',
|
|
36
|
+
fields: ['id', 'name', 'role', 'region', 'score', 'active'],
|
|
37
|
+
enums: {
|
|
38
|
+
role: ['admin', 'user', 'editor'],
|
|
39
|
+
region: ['us-east-1', 'eu-west-1'],
|
|
40
|
+
active: [false, true],
|
|
41
|
+
},
|
|
42
|
+
path: '$.data',
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
// handler — per cerere, doar encode/decode
|
|
48
|
+
const { users } = require('./schemas');
|
|
49
|
+
app.get('/users', (req, res) => {
|
|
50
|
+
res.type('text/plain').send(users.encode({ meta: {...}, data: rows }));
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Efect măsurat** (envelope, 25 de rânduri): decode 1.21x → **0.63x**,
|
|
55
|
+
encode 2.49x → **0.90x**. Pe payload-uri mici, compilarea per-cerere e diferența
|
|
56
|
+
dintre a pierde și a câștiga.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## 3. Declară `enums` pentru coloanele cu puține valori distincte
|
|
61
|
+
|
|
62
|
+
Orice coloană de string/boolean cu cardinalitate mică (status, rol, regiune,
|
|
63
|
+
tip, flag-uri) devine un întreg pe fir.
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
enums: { status: ['ok', 'retried', 'failed'], active: [false, true] }
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Trei efecte simultane:
|
|
70
|
+
1. **Mai puțini tokeni** — `events-10k`: 38% → **42%**.
|
|
71
|
+
2. **Decode mai rapid** — nu se mai alocă string-uri pentru coloana aceea.
|
|
72
|
+
3. **Deblochează WASM** — o coloană de tip dicționar este un întreg pe fir, deci
|
|
73
|
+
un tabel cu string-uri devine eligibil pentru scanerul WASM. `tabular-10k`
|
|
74
|
+
este *respins* de WASM fără dicționare și **acceptat** cu ele.
|
|
75
|
+
|
|
76
|
+
**Restricție:** valorile din `enums` trebuie să fie string sau boolean.
|
|
77
|
+
Valorile numerice sunt respinse explicit — ar fi ambigue cu indicii de dicționar
|
|
78
|
+
și s-ar decoda ca `undefined`.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## 4. Nu codifica payload-uri mici
|
|
83
|
+
|
|
84
|
+
`encode()` are deja un prag de 1 KB și returnează JSON simplu sub el; `decode()`
|
|
85
|
+
îl citește transparent. Nu-l dezactiva fără motiv.
|
|
86
|
+
|
|
87
|
+
| rânduri | JSON | decode | round-trip |
|
|
88
|
+
|---:|---:|---:|---:|
|
|
89
|
+
| 5 | 0.5 KB | 4.80x | 5.16x |
|
|
90
|
+
| 25 | 2.1 KB | 1.21x | 1.70x |
|
|
91
|
+
| 100 | 8.4 KB | **0.78x** | 1.16x |
|
|
92
|
+
| 1000 | 86 KB | **0.68x** | **1.01x** |
|
|
93
|
+
| 10000 | 881 KB | **0.67x** | **0.94x** |
|
|
94
|
+
|
|
95
|
+
(Modul auto-descriptiv. Cu schemă preîncărcată pragul dispare — vezi §1.)
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## 5. Când datele sunt numerice, ia banda columnară
|
|
100
|
+
|
|
101
|
+
Cea mai rapidă cale din bibliotecă. Nu construiește deloc obiecte JS.
|
|
102
|
+
|
|
103
|
+
**Atenție:** trebuie emis cu `encodeColumnar()`. Ieșirea implicită a lui
|
|
104
|
+
`encode()` conține declarații `table` (corp JSON simplu), pe care scanerul WASM
|
|
105
|
+
nu le acceptă — `decodeColumnar` va returna `undefined` pentru ea.
|
|
106
|
+
|
|
107
|
+
```js
|
|
108
|
+
const wire = encodeColumnar(rows); // server
|
|
109
|
+
const col = decodeColumnar(wire, /* copy */ true); // client
|
|
110
|
+
if (col) {
|
|
111
|
+
// acces row-major: valoarea coloanei c din rândul r
|
|
112
|
+
const v = col.tape[r * col.cols + c];
|
|
113
|
+
} else {
|
|
114
|
+
const obj = decode(wire); // nu era eligibil, cale normală
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
**~0.30x față de `JSON.parse`** (de ~3.3 ori mai rapid). `copy: true` detașează
|
|
119
|
+
banda din memoria WASM — fără el, următorul apel de decode o invalidează.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## 6. Reține că `table: 'nested'` schimbă viteza pe tokeni
|
|
124
|
+
|
|
125
|
+
Implicit (`table: true`) tabelizează doar rândurile plate: câștig pur, și mai
|
|
126
|
+
rapid și mai puțini tokeni. `'nested'` acceptă și rânduri cu obiecte imbricate —
|
|
127
|
+
mai rapid, dar formele interioare își pierd compresia proprie.
|
|
128
|
+
|
|
129
|
+
Măsurat pe `nested-500`: round-trip 2.47x → **0.89x**, dar tokenii scad de la
|
|
130
|
+
**26% → 11%**. Alege în funcție de ce plătești: latență sau tokeni.
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## 7. Detalii de runtime care contează
|
|
135
|
+
|
|
136
|
+
- **`new Function`** — trampolina și constructorii de rând îl folosesc. Sub un
|
|
137
|
+
CSP strict fără `unsafe-eval` biblioteca revine automat la scaner
|
|
138
|
+
(`parseFast`), ~1.1–1.5x. Funcționează corect, doar mai lent.
|
|
139
|
+
- **WASM este opțional.** În Node se încarcă singur. În browser trimite tu
|
|
140
|
+
binarul: `setWasmBinary(await (await fetch('/parserTron2.wasm')).arrayBuffer())`.
|
|
141
|
+
Fără el nimic nu se strică — dispecerul pur și simplu nu ia calea WASM.
|
|
142
|
+
- **Trimite `Content-Type: text/plain`** (sau un tip propriu, ex.
|
|
143
|
+
`application/vnd.tron`). Nu `application/json` — corpul poate conține un
|
|
144
|
+
preambul care nu e JSON valid.
|
|
145
|
+
- **Compresia HTTP se aplică în continuare.** TRON reduce octeții *înainte* de
|
|
146
|
+
gzip/brotli; câștigurile se compun, dar mai puțin decât liniar.
|
|
147
|
+
- **Nu re-encoda ce nu s-a schimbat.** Răspunsurile cacheabile se codifică o
|
|
148
|
+
dată; costul de encode dispare complet și rămâne doar câștigul la decode.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## 8. Listă de verificare
|
|
153
|
+
|
|
154
|
+
- [ ] `defineSchema` apelat o singură dată, la pornire, nu per cerere
|
|
155
|
+
- [ ] `enums` declarate pentru toate coloanele categorice și booleene
|
|
156
|
+
- [ ] Payload-urile sub ~1 KB rămân JSON (pragul implicit se ocupă)
|
|
157
|
+
- [ ] `encodeColumnar` + `decodeColumnar` folosite împreună acolo unde datele sunt numerice
|
|
158
|
+
- [ ] `Content-Type` setat pe `text/plain`, nu `application/json`
|
|
159
|
+
- [ ] În browser: `setWasmBinary` apelat, sau acceptat conștient fallback-ul
|
|
160
|
+
- [ ] Măsoară cu propriile date înainte de a te baza pe aceste cifre
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## 9. Metodologie (ca să poți reproduce)
|
|
165
|
+
|
|
166
|
+
Benchmark-ul naiv dădea rezultate contradictorii — două căi de cod identice
|
|
167
|
+
difereau cu 43% — pentru că rularea mai multor decodoare în același proces face
|
|
168
|
+
inline cache-urile V8 polimorfe. Cifrele de aici sunt măsurate cu:
|
|
169
|
+
|
|
170
|
+
- **un proces copil per set de date**, ca V8 să optimizeze pentru o singură formă;
|
|
171
|
+
- **pereche întrețesută** față de echivalentul JSON nativ (A/B/A/B, cu ordinea
|
|
172
|
+
alternată), ca ambele părți să vadă aceleași condiții;
|
|
173
|
+
- **mediana a 15 runde**.
|
|
174
|
+
|
|
175
|
+
Milisecundele absolute depind de mașină; **rapoartele** sunt semnalul.
|
|
176
|
+
|
|
177
|
+
Corectitudinea: **220.118 verificări** adversariale și fuzz pe suita completă,
|
|
178
|
+
plus 22 de teste dedicate modului cu schemă și 1.500 de round-trip-uri fuzz — 0
|
|
179
|
+
eșecuri.
|
package/README.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# @human-synthesis/norns-tron
|
|
2
|
+
|
|
3
|
+
TRON serialization for the Norns ecosystem — a token-efficient, faster-than-JSON
|
|
4
|
+
wire format for APIs and LLM-facing output. Zero runtime dependencies.
|
|
5
|
+
|
|
6
|
+
TRON cuts **11–61% of tokens** vs JSON and, used correctly, beats
|
|
7
|
+
`JSON.parse` / `JSON.stringify` on decode, encode, and round-trip. Plain JSON is
|
|
8
|
+
valid TRON, so adoption is progressive and rollback is trivial. See
|
|
9
|
+
[PERFORMANCE.md](./PERFORMANCE.md) for measured numbers and usage guidance.
|
|
10
|
+
|
|
11
|
+
The encoder/decoder core is absorbed from the `apitron` research library; this
|
|
12
|
+
package adds the Norns framework glue as subpath exports.
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
@human-synthesis/norns-tron encode / decode / defineSchema / registry
|
|
16
|
+
@human-synthesis/norns-tron/server tronSerializer() for norns route()
|
|
17
|
+
@human-synthesis/norns-tron/client api fetch wrapper that speaks TRON
|
|
18
|
+
@human-synthesis/norns-tron/valibot derive wire schemas from valibot schemas
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Turn it on app-wide
|
|
22
|
+
|
|
23
|
+
```coffee
|
|
24
|
+
# src/hooks.server.c
|
|
25
|
+
import { boot } from '@human-synthesis/norns/server'
|
|
26
|
+
import { tronSerializer } from '@human-synthesis/norns-tron/server'
|
|
27
|
+
|
|
28
|
+
app := await boot
|
|
29
|
+
features: import.meta.glob('./lib/*/server/module.c', eager: true)
|
|
30
|
+
serializer: tronSerializer()
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Every `route()` response is now content-negotiated: clients that send
|
|
34
|
+
`Accept: application/tron` get TRON, everyone else (curl, third parties,
|
|
35
|
+
existing code) keeps getting JSON. Nothing breaks. Remove the `serializer`
|
|
36
|
+
line to roll the whole thing back.
|
|
37
|
+
|
|
38
|
+
Per-route control:
|
|
39
|
+
|
|
40
|
+
```coffee
|
|
41
|
+
export GET := route serializer: tronSerializer({ schema: noteWire }), handler: ...
|
|
42
|
+
export POST := route serializer: null, handler: ... # force plain JSON
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Call it from the client
|
|
46
|
+
|
|
47
|
+
```coffee
|
|
48
|
+
import { api, createApi } from '@human-synthesis/norns-tron/client'
|
|
49
|
+
|
|
50
|
+
users := await api.get '/api/users' # sends Accept: application/tron
|
|
51
|
+
await api.post '/api/notes', { title, body } # body goes out as TRON too
|
|
52
|
+
|
|
53
|
+
# inside a load function, keep SvelteKit's fetch semantics:
|
|
54
|
+
api := createApi { fetch }
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The 1.7 KB WASM scanner ships embedded as base64 — no asset wiring, works in
|
|
58
|
+
Node, Bun, and the browser. Under a strict CSP without `unsafe-eval` the
|
|
59
|
+
decoder transparently falls back to the JS scanner (~1.1–1.5x).
|
|
60
|
+
|
|
61
|
+
## Schema mode — the fastest path for internal endpoints
|
|
62
|
+
|
|
63
|
+
Both ends already know the shape from the feature contract, so nothing
|
|
64
|
+
descriptive needs to travel. Derive the wire schema from the valibot schema
|
|
65
|
+
you already have in `shared/schema.c`:
|
|
66
|
+
|
|
67
|
+
```coffee
|
|
68
|
+
# src/lib/notes/shared/schema.c
|
|
69
|
+
import * as v from 'valibot'
|
|
70
|
+
import { tronSchemaFromValibot } from '@human-synthesis/norns-tron/valibot'
|
|
71
|
+
|
|
72
|
+
export noteSchema := v.object
|
|
73
|
+
id: v.number()
|
|
74
|
+
title: v.string()
|
|
75
|
+
status: v.picklist ['draft', 'published']
|
|
76
|
+
|
|
77
|
+
export noteWire := tronSchemaFromValibot noteSchema, { id: 'notes.v1', path: '$.data' }
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`picklist`/`enum` fields become dictionary columns (integers on the wire),
|
|
81
|
+
booleans become 0/1. Compile once at module scope — never per request.
|
|
82
|
+
The `#notes.v1` tag makes version mismatches fail loudly instead of
|
|
83
|
+
misdecoding; use `createRegistry()` on a client that consumes several shapes.
|
|
84
|
+
|
|
85
|
+
## Semantics and limits
|
|
86
|
+
|
|
87
|
+
- Same value semantics as JSON: `toJSON()` is honored, so a `Date` arrives as
|
|
88
|
+
its ISO string (not a `Date` — same as `response.json()`). `Map`/`Set`
|
|
89
|
+
serialize as `{}` and `BigInt` throws, exactly like `JSON.stringify`; dev
|
|
90
|
+
mode logs a warning when a route returns them.
|
|
91
|
+
- **Not for `load` / form actions** — SvelteKit serializes those with devalue
|
|
92
|
+
(which preserves Dates, Maps, Sets). This package targets `route()`
|
|
93
|
+
endpoints, LLM-facing output, and service-to-service payloads.
|
|
94
|
+
- Payloads under ~1 KB are emitted as plain JSON automatically (still decoded
|
|
95
|
+
transparently) — below that size TRON's fixed costs don't pay for themselves.
|
|
96
|
+
- The wire content type is `application/tron`, not `application/json`: a TRON
|
|
97
|
+
body may carry a declaration preamble that is not valid JSON.
|
|
98
|
+
- Schema mode uses `new Function` for the row constructor (server-side this is
|
|
99
|
+
a non-issue; in CSP-restricted browsers the fallback scanner takes over).
|
|
100
|
+
|
|
101
|
+
## Development
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
bun test # 38 tests: core roundtrips, Date regression, server/client glue, valibot derivation
|
|
105
|
+
bun run embed-wasm # regenerate src/core/wasm-bytes.js after replacing wasm/parserTron2.wasm
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
License: MIT.
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@human-synthesis/norns-tron",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "TRON serialization for the Norns ecosystem — token-efficient, faster-than-JSON wire format for APIs and LLM-facing output.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Daniel Teodoroiu (https://humansynthesis.ai)",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/human-synthesis/norns-tron.git"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"wasm",
|
|
15
|
+
"README.md",
|
|
16
|
+
"PERFORMANCE.md"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "bun test",
|
|
20
|
+
"embed-wasm": "bun scripts/embed-wasm.mjs"
|
|
21
|
+
},
|
|
22
|
+
"exports": {
|
|
23
|
+
".": "./src/index.js",
|
|
24
|
+
"./server": "./src/server.js",
|
|
25
|
+
"./client": "./src/client.js",
|
|
26
|
+
"./valibot": "./src/valibot.js",
|
|
27
|
+
"./package.json": "./package.json"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"valibot": ">=0.31.0"
|
|
31
|
+
},
|
|
32
|
+
"peerDependenciesMeta": {
|
|
33
|
+
"valibot": {
|
|
34
|
+
"optional": true
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/client.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export const TRON_CONTENT_TYPE: 'application/tron';
|
|
2
|
+
|
|
3
|
+
export class ApiError extends Error {
|
|
4
|
+
status: number;
|
|
5
|
+
body: unknown;
|
|
6
|
+
response: Response;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Decode a Response by its content-type (TRON, JSON, or raw text). */
|
|
10
|
+
export function parseResponse(res: Response): Promise<unknown>;
|
|
11
|
+
|
|
12
|
+
export interface ApiDefaults {
|
|
13
|
+
/** fetch impl — pass SvelteKit's `fetch` inside load functions. */
|
|
14
|
+
fetch?: typeof fetch;
|
|
15
|
+
/** URL prefix, e.g. 'https://api.example.com'. */
|
|
16
|
+
base?: string;
|
|
17
|
+
/** Headers sent on every request. */
|
|
18
|
+
headers?: Record<string, string>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RequestOptions {
|
|
22
|
+
fetch?: typeof fetch;
|
|
23
|
+
headers?: Record<string, string>;
|
|
24
|
+
init?: RequestInit;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Api {
|
|
28
|
+
request(method: string, url: string, body?: unknown, opts?: RequestOptions): Promise<any>;
|
|
29
|
+
get(url: string, opts?: RequestOptions): Promise<any>;
|
|
30
|
+
del(url: string, opts?: RequestOptions): Promise<any>;
|
|
31
|
+
post(url: string, body?: unknown, opts?: RequestOptions): Promise<any>;
|
|
32
|
+
put(url: string, body?: unknown, opts?: RequestOptions): Promise<any>;
|
|
33
|
+
patch(url: string, body?: unknown, opts?: RequestOptions): Promise<any>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createApi(defaults?: ApiDefaults): Api;
|
|
37
|
+
|
|
38
|
+
/** Default instance for browser code. In load functions use createApi({ fetch }). */
|
|
39
|
+
export const api: Api;
|
package/src/client.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client fetch wrapper that speaks TRON with a norns `route()` endpoint.
|
|
3
|
+
*
|
|
4
|
+
* import { api } from '@human-synthesis/norns-tron/client';
|
|
5
|
+
* const users = await api.get('/api/users');
|
|
6
|
+
* await api.post('/api/notes', { title, body });
|
|
7
|
+
*
|
|
8
|
+
* In a load function, pass SvelteKit's fetch so relative URLs and SSR
|
|
9
|
+
* de-duplication keep working:
|
|
10
|
+
* const api = createApi({ fetch });
|
|
11
|
+
*
|
|
12
|
+
* The WASM scanner ships embedded (~2.3 KB base64) and initializes lazily, so
|
|
13
|
+
* no asset wiring is needed in the browser. Under a strict CSP without
|
|
14
|
+
* `unsafe-eval` the decoder transparently falls back to the JS scanner.
|
|
15
|
+
*/
|
|
16
|
+
import { decode, encode } from './index.js';
|
|
17
|
+
|
|
18
|
+
export const TRON_CONTENT_TYPE = 'application/tron';
|
|
19
|
+
const ACCEPT = 'application/tron, application/json;q=0.9, */*;q=0.1';
|
|
20
|
+
|
|
21
|
+
export class ApiError extends Error {
|
|
22
|
+
/**
|
|
23
|
+
* @param {number} status
|
|
24
|
+
* @param {any} body decoded error body (message/issues from route())
|
|
25
|
+
* @param {Response} response
|
|
26
|
+
*/
|
|
27
|
+
constructor(status, body, response) {
|
|
28
|
+
super(body?.message ?? `HTTP ${status}`);
|
|
29
|
+
this.name = 'ApiError';
|
|
30
|
+
this.status = status;
|
|
31
|
+
this.body = body;
|
|
32
|
+
this.response = response;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Decode a Response by its content-type (TRON, JSON, or raw text). */
|
|
37
|
+
export async function parseResponse(res) {
|
|
38
|
+
if (res.status === 204) return null;
|
|
39
|
+
const type = res.headers.get('content-type')?.split(';', 1)[0]?.trim() ?? '';
|
|
40
|
+
const text = await res.text();
|
|
41
|
+
if (text === '') return null;
|
|
42
|
+
if (type === TRON_CONTENT_TYPE) return decode(text);
|
|
43
|
+
if (type === 'application/json' || type.endsWith('+json')) return JSON.parse(text);
|
|
44
|
+
return text;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {object} [defaults]
|
|
49
|
+
* @param {typeof fetch} [defaults.fetch] fetch impl (pass SvelteKit's in load functions)
|
|
50
|
+
* @param {string} [defaults.base] URL prefix, e.g. 'https://api.example.com'
|
|
51
|
+
* @param {Record<string, string>} [defaults.headers] headers sent on every request
|
|
52
|
+
*/
|
|
53
|
+
export function createApi(defaults = {}) {
|
|
54
|
+
const base = defaults.base ?? '';
|
|
55
|
+
|
|
56
|
+
async function request(method, url, body, opts = {}) {
|
|
57
|
+
// Resolve fetch per call: in the browser `globalThis.fetch` must not be
|
|
58
|
+
// captured at module-import time (SSR would freeze the server's fetch).
|
|
59
|
+
const doFetch = opts.fetch ?? defaults.fetch ?? globalThis.fetch;
|
|
60
|
+
const headers = { accept: ACCEPT, ...defaults.headers, ...opts.headers };
|
|
61
|
+
/** @type {RequestInit} */
|
|
62
|
+
const init = { method, headers, ...opts.init };
|
|
63
|
+
if (body !== undefined) {
|
|
64
|
+
// encode() emits plain JSON under 1 KB — still valid TRON, and the
|
|
65
|
+
// server's parseBody reads both, so the content type stays stable.
|
|
66
|
+
headers['content-type'] = TRON_CONTENT_TYPE;
|
|
67
|
+
init.body = encode(body);
|
|
68
|
+
}
|
|
69
|
+
const res = await doFetch(base + url, init);
|
|
70
|
+
const parsed = await parseResponse(res);
|
|
71
|
+
if (!res.ok) throw new ApiError(res.status, parsed, res);
|
|
72
|
+
return parsed;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
request,
|
|
77
|
+
get: (url, opts) => request('GET', url, undefined, opts),
|
|
78
|
+
del: (url, opts) => request('DELETE', url, undefined, opts),
|
|
79
|
+
post: (url, body, opts) => request('POST', url, body, opts),
|
|
80
|
+
put: (url, body, opts) => request('PUT', url, body, opts),
|
|
81
|
+
patch: (url, body, opts) => request('PATCH', url, body, opts)
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Default instance for browser code. In load functions use createApi({ fetch }). */
|
|
86
|
+
export const api = createApi();
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Auto-dispatching decoder. No single strategy wins on every shape, so pick
|
|
2
|
+
// per document from cheap signals available in the prelude + a short probe:
|
|
3
|
+
//
|
|
4
|
+
// numeric columnar, all classed -> WASM tape (4x JSON.parse)
|
|
5
|
+
// single/multi-class uniform tables -> trampoline (0.7-0.9x)
|
|
6
|
+
// nested / irregular / tiny -> parseFast scanner (~1.2x)
|
|
7
|
+
//
|
|
8
|
+
// The probe reads only the class declarations and a small prefix of the body,
|
|
9
|
+
// never the whole payload, so dispatch cost is O(prelude), not O(document).
|
|
10
|
+
|
|
11
|
+
import * as TRON from './tron.js';
|
|
12
|
+
import * as TRAMP from './tron-trampoline.js';
|
|
13
|
+
import * as WASM from './tron-wasm.js';
|
|
14
|
+
import * as TBL from './tron-table.js';
|
|
15
|
+
|
|
16
|
+
const SMALL_DOC = 4096; // below this, native JSON.parse setup dominates
|
|
17
|
+
const PROBE_ROWS = 3;
|
|
18
|
+
|
|
19
|
+
function pickStrategy(text) {
|
|
20
|
+
const pre = TRON.parsePrelude(text);
|
|
21
|
+
const names = Object.keys(pre.classes);
|
|
22
|
+
if (names.length === 0) return 'json'; // plain JSON body
|
|
23
|
+
if (text.length < SMALL_DOC) return 'fast';
|
|
24
|
+
if (text.indexOf('\u0001') !== -1) return 'fast';
|
|
25
|
+
|
|
26
|
+
// Several classes means heterogeneous, nesting-heavy data: the trampoline's
|
|
27
|
+
// generic walk loses badly there (measured 1.8x) while the scanner stays
|
|
28
|
+
// ~1.1x. One class means a dominant uniform run - the trampoline's home turf.
|
|
29
|
+
if (names.length > 1) return 'fast';
|
|
30
|
+
|
|
31
|
+
// Body must look like a root array of instances for the tight paths.
|
|
32
|
+
const body = text.slice(pre.end);
|
|
33
|
+
if (body.charCodeAt(0) !== 91 /* [ */) return 'tramp-generic';
|
|
34
|
+
|
|
35
|
+
// Probe the first few rows for non-scalar fields (nested objects/arrays).
|
|
36
|
+
let i = 1, rows = 0, sawNonScalar = false;
|
|
37
|
+
while (rows < PROBE_ROWS && i < body.length) {
|
|
38
|
+
const open = body.indexOf('(', i);
|
|
39
|
+
if (open === -1) break;
|
|
40
|
+
const close = body.indexOf(')', open);
|
|
41
|
+
if (close === -1) break;
|
|
42
|
+
const row = body.slice(open + 1, close);
|
|
43
|
+
if (row.indexOf('[') !== -1 || row.indexOf('{') !== -1) sawNonScalar = true;
|
|
44
|
+
i = close + 1; rows++;
|
|
45
|
+
}
|
|
46
|
+
if (sawNonScalar) return 'tramp-generic';
|
|
47
|
+
return 'tramp';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function decode(text, opts) {
|
|
51
|
+
// TABLE MODE first: if the document declares where its tables live, decoding
|
|
52
|
+
// is JSON.parse + a constructor loop with no text transform at all.
|
|
53
|
+
const pre = TRON.parsePrelude(text);
|
|
54
|
+
if (pre.tables && pre.tables.length > 0) {
|
|
55
|
+
const t = TBL.decode(text, pre);
|
|
56
|
+
if (t !== undefined) return t;
|
|
57
|
+
}
|
|
58
|
+
const strategy = (opts && opts.strategy) || pickStrategy(text);
|
|
59
|
+
switch (strategy) {
|
|
60
|
+
case 'json': return JSON.parse(text.slice(TRON.parsePrelude(text).end) || text);
|
|
61
|
+
case 'fast': return TRON.parseFast(text);
|
|
62
|
+
case 'tramp': {
|
|
63
|
+
// Try WASM first for uniform single-class rows. The WASM scanner
|
|
64
|
+
// validates and rejects anything it cannot represent exactly, so an
|
|
65
|
+
// undefined return is a definitive "not eligible", not a guess.
|
|
66
|
+
if (!(opts && opts.noWasm) && !(opts && opts.lazy)) {
|
|
67
|
+
const w = WASM.decode(text);
|
|
68
|
+
if (w !== undefined) return w;
|
|
69
|
+
}
|
|
70
|
+
return (opts && opts.lazy) ? TRAMP.decodeLazy(text) : TRAMP.decode(text);
|
|
71
|
+
}
|
|
72
|
+
case 'tramp-generic': return TRAMP.decode(text);
|
|
73
|
+
default: return TRON.parseFast(text);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Columnar decode: returns a Float64Array view instead of JS objects for
|
|
78
|
+
// all-numeric (or fully dictionary-encoded) tables. Undefined when the payload
|
|
79
|
+
// is not eligible. This is the mode that runs ~4x faster than JSON.parse.
|
|
80
|
+
function decodeColumnar(text, copy) {
|
|
81
|
+
return WASM.decodeColumnar(text, copy);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const wasmAvailable = () => WASM.available();
|
|
85
|
+
export { decode, decodeColumnar, pickStrategy, wasmAvailable };
|