@human-synthesis/norns-tron 0.0.1 → 0.0.3
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/README.md +57 -10
- package/package.json +1 -1
- package/src/client.d.ts +23 -1
- package/src/client.js +115 -7
- package/src/core/codegen.js +120 -0
- package/src/core/tron-schema.js +7 -12
- package/src/core/tron-table.js +3 -7
- package/src/core/tron-trampoline.js +6 -25
- package/src/core/tron.js +3 -7
- package/src/server.d.ts +6 -0
- package/src/server.js +13 -3
package/README.md
CHANGED
|
@@ -12,9 +12,9 @@ The encoder/decoder core is absorbed from the `apitron` research library; this
|
|
|
12
12
|
package adds the Norns framework glue as subpath exports.
|
|
13
13
|
|
|
14
14
|
```
|
|
15
|
-
@human-synthesis/norns-tron encode / decode / defineSchema / registry
|
|
15
|
+
@human-synthesis/norns-tron encode / decode / defineSchema / registry / columnar
|
|
16
16
|
@human-synthesis/norns-tron/server tronSerializer() for norns route()
|
|
17
|
-
@human-synthesis/norns-tron/client
|
|
17
|
+
@human-synthesis/norns-tron/client createApi() fetch wrapper that speaks TRON
|
|
18
18
|
@human-synthesis/norns-tron/valibot derive wire schemas from valibot schemas
|
|
19
19
|
```
|
|
20
20
|
|
|
@@ -38,8 +38,9 @@ line to roll the whole thing back.
|
|
|
38
38
|
Per-route control:
|
|
39
39
|
|
|
40
40
|
```coffee
|
|
41
|
-
export GET := route serializer: tronSerializer({ schema: noteWire }), handler: ...
|
|
42
|
-
export
|
|
41
|
+
export GET := route serializer: tronSerializer({ schema: noteWire }), handler: ... # schema mode
|
|
42
|
+
export GET := route serializer: tronSerializer({ columnar: true }), handler: ... # numeric tables
|
|
43
|
+
export POST := route serializer: null, handler: ... # force plain JSON
|
|
43
44
|
```
|
|
44
45
|
|
|
45
46
|
## Call it from the client
|
|
@@ -52,12 +53,33 @@ await api.post '/api/notes', { title, body } # body goes out as TRON too
|
|
|
52
53
|
|
|
53
54
|
# inside a load function, keep SvelteKit's fetch semantics:
|
|
54
55
|
api := createApi { fetch }
|
|
56
|
+
|
|
57
|
+
# endpoints served in schema mode tag the wire; hand the client the contracts:
|
|
58
|
+
api := createApi { schemas: [noteWire, userWire] } # or a createRegistry()
|
|
55
59
|
```
|
|
56
60
|
|
|
61
|
+
Responses are decoded by content type: self-describing TRON on its own,
|
|
62
|
+
tagged schema-mode documents (`#notes.v1`) through the matching compiled
|
|
63
|
+
schema, JSON as JSON. A tag with no registered schema throws instead of
|
|
64
|
+
handing you positional arrays. Non-2xx responses throw `ApiError` with the
|
|
65
|
+
decoded body (`status`, `body.message`, `body.issues` from a `route()` 400).
|
|
66
|
+
|
|
57
67
|
The 1.7 KB WASM scanner ships embedded as base64 — no asset wiring, works in
|
|
58
68
|
Node, Bun, and the browser. Under a strict CSP without `unsafe-eval` the
|
|
59
69
|
decoder transparently falls back to the JS scanner (~1.1–1.5x).
|
|
60
70
|
|
|
71
|
+
## Where it pays off
|
|
72
|
+
|
|
73
|
+
| Payload | Mode | Why |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| 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()` |
|
|
76
|
+
| Picker options (`Autocomplete` / `MultiSelect` `source`) | default | small responses ship as JSON automatically; TRON kicks in past ~1 KB |
|
|
77
|
+
| Charts, aggregations, exports of numbers | columnar (`tronSerializer({ columnar: true })` + `api.tape()`) | decodes into one `Float64Array`, ~3x faster than `JSON.parse`, no row objects |
|
|
78
|
+
| 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 |
|
|
79
|
+
| 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` |
|
|
80
|
+
|
|
81
|
+
The [norns-demo](https://github.com/human-synthesis/norns-demo) has one page per row under `/examples/tron`.
|
|
82
|
+
|
|
61
83
|
## Schema mode — the fastest path for internal endpoints
|
|
62
84
|
|
|
63
85
|
Both ends already know the shape from the feature contract, so nothing
|
|
@@ -80,7 +102,30 @@ export noteWire := tronSchemaFromValibot noteSchema, { id: 'notes.v1', path: '$.
|
|
|
80
102
|
`picklist`/`enum` fields become dictionary columns (integers on the wire),
|
|
81
103
|
booleans become 0/1. Compile once at module scope — never per request.
|
|
82
104
|
The `#notes.v1` tag makes version mismatches fail loudly instead of
|
|
83
|
-
misdecoding; use `createRegistry()` on a client that
|
|
105
|
+
misdecoding; use `createRegistry()` (or the `schemas` array) on a client that
|
|
106
|
+
consumes several shapes. Derivation is for flat object schemas; nested rows
|
|
107
|
+
still work through the self-describing mode.
|
|
108
|
+
|
|
109
|
+
## Columnar mode — numbers without objects
|
|
110
|
+
|
|
111
|
+
For a table whose values are all numbers (or dictionary-able strings /
|
|
112
|
+
booleans), skip row objects entirely:
|
|
113
|
+
|
|
114
|
+
```coffee
|
|
115
|
+
# server
|
|
116
|
+
export GET := route
|
|
117
|
+
serializer: tronSerializer({ columnar: true })
|
|
118
|
+
cache: { ttl: 30 }
|
|
119
|
+
handler: ({ container }) => stats(container).perDay() # [{ day, count, chars }, …]
|
|
120
|
+
|
|
121
|
+
# client
|
|
122
|
+
{ fields, rows, cols, tape } := await api.tape '/api/stats'
|
|
123
|
+
count := tape[i * cols + fields.indexOf('count')]
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`api.tape()` always returns the same `{ fields, rows, cols, tape }` shape:
|
|
127
|
+
the WASM fast path when the server answered columnar TRON, and a packed
|
|
128
|
+
fallback (`tableFromRows`) when it answered JSON or WASM is unavailable.
|
|
84
129
|
|
|
85
130
|
## Semantics and limits
|
|
86
131
|
|
|
@@ -89,19 +134,21 @@ misdecoding; use `createRegistry()` on a client that consumes several shapes.
|
|
|
89
134
|
serialize as `{}` and `BigInt` throws, exactly like `JSON.stringify`; dev
|
|
90
135
|
mode logs a warning when a route returns them.
|
|
91
136
|
- **Not for `load` / form actions** — SvelteKit serializes those with devalue
|
|
92
|
-
(which preserves Dates, Maps, Sets)
|
|
93
|
-
endpoints, LLM-facing output,
|
|
137
|
+
(which preserves Dates, Maps, Sets) and only dispatches form-encoded POSTs
|
|
138
|
+
to actions. This package targets `route()` endpoints, LLM-facing output,
|
|
139
|
+
and service-to-service payloads; norns-ui's `Form` has an API mode
|
|
140
|
+
(`submit`) for posting through `api.post()`.
|
|
94
141
|
- Payloads under ~1 KB are emitted as plain JSON automatically (still decoded
|
|
95
142
|
transparently) — below that size TRON's fixed costs don't pay for themselves.
|
|
96
143
|
- The wire content type is `application/tron`, not `application/json`: a TRON
|
|
97
144
|
body may carry a declaration preamble that is not valid JSON.
|
|
98
|
-
- Schema mode uses `new Function` for the row constructor
|
|
99
|
-
|
|
145
|
+
- Schema mode uses `new Function` for the row constructor where allowed and a
|
|
146
|
+
closure fallback elsewhere (Cloudflare Workers, CSP-restricted browsers).
|
|
100
147
|
|
|
101
148
|
## Development
|
|
102
149
|
|
|
103
150
|
```sh
|
|
104
|
-
bun test #
|
|
151
|
+
bun test # 50 tests: core roundtrips, Date regression, server/client glue, schema registry, columnar tape, valibot derivation
|
|
105
152
|
bun run embed-wasm # regenerate src/core/wasm-bytes.js after replacing wasm/parserTron2.wasm
|
|
106
153
|
```
|
|
107
154
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@human-synthesis/norns-tron",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "TRON serialization for the Norns ecosystem — token-efficient, faster-than-JSON wire format for APIs and LLM-facing output.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Daniel Teodoroiu (https://humansynthesis.ai)",
|
package/src/client.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ColumnarResult, CompiledSchema, SchemaRegistry } from './index.js';
|
|
2
|
+
|
|
1
3
|
export const TRON_CONTENT_TYPE: 'application/tron';
|
|
2
4
|
|
|
3
5
|
export class ApiError extends Error {
|
|
@@ -6,8 +8,17 @@ export class ApiError extends Error {
|
|
|
6
8
|
response: Response;
|
|
7
9
|
}
|
|
8
10
|
|
|
11
|
+
/** Compiled schemas (from defineSchema / tronSchemaFromValibot) or a createRegistry() registry. */
|
|
12
|
+
export type Schemas = CompiledSchema[] | SchemaRegistry;
|
|
13
|
+
|
|
14
|
+
/** Decode a TRON/JSON body; tagged schema-mode documents need `schemas`. */
|
|
15
|
+
export function decodeText(text: string, schemas?: Schemas): unknown;
|
|
16
|
+
|
|
9
17
|
/** Decode a Response by its content-type (TRON, JSON, or raw text). */
|
|
10
|
-
export function parseResponse(res: Response): Promise<unknown>;
|
|
18
|
+
export function parseResponse(res: Response, schemas?: Schemas): Promise<unknown>;
|
|
19
|
+
|
|
20
|
+
/** Pack row objects into the `{fields, rows, cols, tape}` shape decodeColumnar returns. */
|
|
21
|
+
export function tableFromRows(rows: Array<Record<string, unknown>>, fields?: string[]): ColumnarResult;
|
|
11
22
|
|
|
12
23
|
export interface ApiDefaults {
|
|
13
24
|
/** fetch impl — pass SvelteKit's `fetch` inside load functions. */
|
|
@@ -16,12 +27,21 @@ export interface ApiDefaults {
|
|
|
16
27
|
base?: string;
|
|
17
28
|
/** Headers sent on every request. */
|
|
18
29
|
headers?: Record<string, string>;
|
|
30
|
+
/** Contracts for endpoints served with `tronSerializer({ schema })`. */
|
|
31
|
+
schemas?: Schemas;
|
|
19
32
|
}
|
|
20
33
|
|
|
21
34
|
export interface RequestOptions {
|
|
22
35
|
fetch?: typeof fetch;
|
|
23
36
|
headers?: Record<string, string>;
|
|
24
37
|
init?: RequestInit;
|
|
38
|
+
/** Per-call override of the schemas given to createApi. */
|
|
39
|
+
schemas?: Schemas;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface TapeOptions extends RequestOptions {
|
|
43
|
+
/** Column order when the server answered with row objects instead of a columnar body. */
|
|
44
|
+
fields?: string[];
|
|
25
45
|
}
|
|
26
46
|
|
|
27
47
|
export interface Api {
|
|
@@ -31,6 +51,8 @@ export interface Api {
|
|
|
31
51
|
post(url: string, body?: unknown, opts?: RequestOptions): Promise<any>;
|
|
32
52
|
put(url: string, body?: unknown, opts?: RequestOptions): Promise<any>;
|
|
33
53
|
patch(url: string, body?: unknown, opts?: RequestOptions): Promise<any>;
|
|
54
|
+
/** GET a numeric table as a flat Float64Array (see `tronSerializer({ columnar: true })`). */
|
|
55
|
+
tape(url: string, opts?: TapeOptions): Promise<ColumnarResult>;
|
|
34
56
|
}
|
|
35
57
|
|
|
36
58
|
export function createApi(defaults?: ApiDefaults): Api;
|
package/src/client.js
CHANGED
|
@@ -9,11 +9,20 @@
|
|
|
9
9
|
* de-duplication keep working:
|
|
10
10
|
* const api = createApi({ fetch });
|
|
11
11
|
*
|
|
12
|
+
* Endpoints served in SCHEMA mode (`tronSerializer({ schema })`) tag the wire
|
|
13
|
+
* with `#id` and carry no shape declarations, so the client must know the
|
|
14
|
+
* contract too. Hand the compiled schemas (or a registry) to createApi and
|
|
15
|
+
* responses are routed to the right decoder by their tag:
|
|
16
|
+
* const api = createApi({ schemas: [noteWire, userWire] });
|
|
17
|
+
*
|
|
18
|
+
* Numeric tables served with `tronSerializer({ columnar: true })` can be read
|
|
19
|
+
* straight into a Float64Array with `api.tape(url)` — no row objects at all.
|
|
20
|
+
*
|
|
12
21
|
* The WASM scanner ships embedded (~2.3 KB base64) and initializes lazily, so
|
|
13
22
|
* no asset wiring is needed in the browser. Under a strict CSP without
|
|
14
23
|
* `unsafe-eval` the decoder transparently falls back to the JS scanner.
|
|
15
24
|
*/
|
|
16
|
-
import { decode, encode } from './index.js';
|
|
25
|
+
import { decode, decodeColumnar, encode } from './index.js';
|
|
17
26
|
|
|
18
27
|
export const TRON_CONTENT_TYPE = 'application/tron';
|
|
19
28
|
const ACCEPT = 'application/tron, application/json;q=0.9, */*;q=0.1';
|
|
@@ -33,25 +42,98 @@ export class ApiError extends Error {
|
|
|
33
42
|
}
|
|
34
43
|
}
|
|
35
44
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
38
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Normalize the `schemas` option into something with `decode(text)` that
|
|
47
|
+
* routes by the `#id` tag: a registry from createRegistry(), or an array of
|
|
48
|
+
* compiled schemas from defineSchema() / tronSchemaFromValibot().
|
|
49
|
+
*/
|
|
50
|
+
function toRegistry(schemas) {
|
|
51
|
+
if (!schemas) return null;
|
|
52
|
+
if (typeof schemas.decode === 'function' && !Array.isArray(schemas)) return schemas;
|
|
53
|
+
const byId = new Map();
|
|
54
|
+
for (const s of schemas) {
|
|
55
|
+
if (!s || typeof s.decode !== 'function') throw new Error('createApi: `schemas` must contain compiled schemas (defineSchema / tronSchemaFromValibot)');
|
|
56
|
+
if (s.id) byId.set(s.id, s);
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
decode(text) {
|
|
60
|
+
const nl = text.indexOf('\n');
|
|
61
|
+
const id = text.slice(1, nl === -1 ? text.length : nl);
|
|
62
|
+
const s = byId.get(id);
|
|
63
|
+
if (!s) throw new Error(`unknown schema id "${id}" — register it via createApi({ schemas })`);
|
|
64
|
+
return s.decode(text);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Decode a TRON (or JSON) body. Tagged schema-mode documents (`#id\n…`) need
|
|
71
|
+
* the matching compiled schema; pass a registry or the array given to
|
|
72
|
+
* createApi. Untagged documents are self-describing and decode on their own.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} text
|
|
75
|
+
* @param {any} [schemas]
|
|
76
|
+
*/
|
|
77
|
+
export function decodeText(text, schemas) {
|
|
78
|
+
if (text.charCodeAt(0) === 35 /* # */) {
|
|
79
|
+
const registry = toRegistry(schemas);
|
|
80
|
+
if (!registry) {
|
|
81
|
+
const nl = text.indexOf('\n');
|
|
82
|
+
throw new Error(`response is schema-tagged (${text.slice(0, nl === -1 ? 40 : nl)}) but no schemas were given to createApi()`);
|
|
83
|
+
}
|
|
84
|
+
return registry.decode(text);
|
|
85
|
+
}
|
|
86
|
+
return decode(text);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Decode a Response by its content-type (TRON, JSON, or raw text).
|
|
91
|
+
* @param {Response} res
|
|
92
|
+
* @param {any} [schemas] compiled schemas / registry for tagged TRON bodies
|
|
93
|
+
*/
|
|
94
|
+
export async function parseResponse(res, schemas) {
|
|
95
|
+
if (res.status === 204 || res.status === 304) return null;
|
|
39
96
|
const type = res.headers.get('content-type')?.split(';', 1)[0]?.trim() ?? '';
|
|
40
97
|
const text = await res.text();
|
|
41
98
|
if (text === '') return null;
|
|
42
|
-
if (type === TRON_CONTENT_TYPE) return
|
|
99
|
+
if (type === TRON_CONTENT_TYPE) return decodeText(text, schemas);
|
|
43
100
|
if (type === 'application/json' || type.endsWith('+json')) return JSON.parse(text);
|
|
44
101
|
return text;
|
|
45
102
|
}
|
|
46
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Row objects -> the same `{fields, rows, cols, tape}` shape decodeColumnar
|
|
106
|
+
* returns. Used when the server answered JSON (no Accept negotiation, small
|
|
107
|
+
* payload) or the WASM scanner is unavailable, so `api.tape()` has one shape.
|
|
108
|
+
*
|
|
109
|
+
* @param {Array<Record<string, unknown>>} rows
|
|
110
|
+
* @param {string[]} [fields] column order (default: keys of the first row)
|
|
111
|
+
*/
|
|
112
|
+
export function tableFromRows(rows, fields) {
|
|
113
|
+
if (!Array.isArray(rows) || rows.length === 0) {
|
|
114
|
+
return { fields: fields ?? [], rows: 0, cols: fields?.length ?? 0, tape: new Float64Array(0) };
|
|
115
|
+
}
|
|
116
|
+
const f = fields ?? Object.keys(rows[0]);
|
|
117
|
+
const cols = f.length;
|
|
118
|
+
const tape = new Float64Array(rows.length * cols);
|
|
119
|
+
for (let i = 0; i < rows.length; i++) {
|
|
120
|
+
const r = rows[i];
|
|
121
|
+
for (let k = 0; k < cols; k++) tape[i * cols + k] = Number(r[f[k]]);
|
|
122
|
+
}
|
|
123
|
+
return { fields: f, rows: rows.length, cols, tape };
|
|
124
|
+
}
|
|
125
|
+
|
|
47
126
|
/**
|
|
48
127
|
* @param {object} [defaults]
|
|
49
128
|
* @param {typeof fetch} [defaults.fetch] fetch impl (pass SvelteKit's in load functions)
|
|
50
129
|
* @param {string} [defaults.base] URL prefix, e.g. 'https://api.example.com'
|
|
51
130
|
* @param {Record<string, string>} [defaults.headers] headers sent on every request
|
|
131
|
+
* @param {any} [defaults.schemas] compiled schemas (array) or a createRegistry()
|
|
132
|
+
* registry, for endpoints served in schema mode
|
|
52
133
|
*/
|
|
53
134
|
export function createApi(defaults = {}) {
|
|
54
135
|
const base = defaults.base ?? '';
|
|
136
|
+
const registry = toRegistry(defaults.schemas);
|
|
55
137
|
|
|
56
138
|
async function request(method, url, body, opts = {}) {
|
|
57
139
|
// Resolve fetch per call: in the browser `globalThis.fetch` must not be
|
|
@@ -67,18 +149,44 @@ export function createApi(defaults = {}) {
|
|
|
67
149
|
init.body = encode(body);
|
|
68
150
|
}
|
|
69
151
|
const res = await doFetch(base + url, init);
|
|
70
|
-
const parsed = await parseResponse(res);
|
|
152
|
+
const parsed = await parseResponse(res, opts.schemas ?? registry);
|
|
71
153
|
if (!res.ok) throw new ApiError(res.status, parsed, res);
|
|
72
154
|
return parsed;
|
|
73
155
|
}
|
|
74
156
|
|
|
157
|
+
/**
|
|
158
|
+
* GET a numeric table as a flat Float64Array tape. Fast path: the server
|
|
159
|
+
* used `tronSerializer({ columnar: true })` and WASM is available. Fallback:
|
|
160
|
+
* decode rows and pack them, so callers always get the same shape.
|
|
161
|
+
*/
|
|
162
|
+
async function tape(url, opts = {}) {
|
|
163
|
+
const doFetch = opts.fetch ?? defaults.fetch ?? globalThis.fetch;
|
|
164
|
+
const headers = { accept: ACCEPT, ...defaults.headers, ...opts.headers };
|
|
165
|
+
const res = await doFetch(base + url, { method: 'GET', headers, ...opts.init });
|
|
166
|
+
const type = res.headers.get('content-type')?.split(';', 1)[0]?.trim() ?? '';
|
|
167
|
+
const text = await res.text();
|
|
168
|
+
if (!res.ok) {
|
|
169
|
+
let parsed = text;
|
|
170
|
+
try { parsed = type === TRON_CONTENT_TYPE ? decodeText(text, registry) : JSON.parse(text); } catch { /* keep text */ }
|
|
171
|
+
throw new ApiError(res.status, parsed, res);
|
|
172
|
+
}
|
|
173
|
+
if (text === '') return tableFromRows([], opts.fields);
|
|
174
|
+
if (type === TRON_CONTENT_TYPE) {
|
|
175
|
+
const col = decodeColumnar(text, true);
|
|
176
|
+
if (col) return col;
|
|
177
|
+
return tableFromRows(decodeText(text, registry), opts.fields);
|
|
178
|
+
}
|
|
179
|
+
return tableFromRows(JSON.parse(text), opts.fields);
|
|
180
|
+
}
|
|
181
|
+
|
|
75
182
|
return {
|
|
76
183
|
request,
|
|
77
184
|
get: (url, opts) => request('GET', url, undefined, opts),
|
|
78
185
|
del: (url, opts) => request('DELETE', url, undefined, opts),
|
|
79
186
|
post: (url, body, opts) => request('POST', url, body, opts),
|
|
80
187
|
put: (url, body, opts) => request('PUT', url, body, opts),
|
|
81
|
-
patch: (url, body, opts) => request('PATCH', url, body, opts)
|
|
188
|
+
patch: (url, body, opts) => request('PATCH', url, body, opts),
|
|
189
|
+
tape
|
|
82
190
|
};
|
|
83
191
|
}
|
|
84
192
|
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Row-constructor factories shared by every decoder.
|
|
2
|
+
//
|
|
3
|
+
// The compiled form (`new Function`) hands V8 a monomorphic object literal
|
|
4
|
+
// `{k1:r[0],k2:r[1],...}` — one hidden-class allocation per row. Runtimes
|
|
5
|
+
// that forbid code generation from strings (Cloudflare Workers, strict CSP)
|
|
6
|
+
// throw an EvalError on the first `new Function`, so every call site goes
|
|
7
|
+
// through here and falls back to an equivalent closure: same fields, same
|
|
8
|
+
// order, same dictionary handling — only the allocation is a little slower.
|
|
9
|
+
|
|
10
|
+
let codegenAllowed = null;
|
|
11
|
+
|
|
12
|
+
// Probed once per isolate. `new Function` throws at construction time when
|
|
13
|
+
// code generation is disallowed, which is exactly the signal we want.
|
|
14
|
+
export function canCodegen() {
|
|
15
|
+
if (codegenAllowed === null) {
|
|
16
|
+
try { new Function('return 1')(); codegenAllowed = true; }
|
|
17
|
+
catch (e) { codegenAllowed = false; }
|
|
18
|
+
}
|
|
19
|
+
return codegenAllowed;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ---- plain row constructor -------------------------------------------
|
|
23
|
+
// Returns a factory `fac(D) -> ctor(r)` where D is the per-field dictionary
|
|
24
|
+
// table (index -> value, or null for non-dictionary columns).
|
|
25
|
+
//
|
|
26
|
+
// fields ordered field names
|
|
27
|
+
// dictFlags 1 for dictionary columns, 0 otherwise
|
|
28
|
+
// offset index of the first field inside the wire row (marker mode = 1)
|
|
29
|
+
// typed when true, a dictionary column only dereferences NUMBERS and
|
|
30
|
+
// passes any other wire value through literally (schema mode:
|
|
31
|
+
// the encoder may emit an out-of-set string as-is)
|
|
32
|
+
|
|
33
|
+
export function compiledRowCtorFactory(fields, dictFlags, offset, typed) {
|
|
34
|
+
let body = 'return function(r){return {';
|
|
35
|
+
for (let k = 0; k < fields.length; k++) {
|
|
36
|
+
if (k) body += ',';
|
|
37
|
+
const i = k + offset;
|
|
38
|
+
let src;
|
|
39
|
+
if (dictFlags[k] !== 1) src = 'r[' + i + ']';
|
|
40
|
+
else if (typed) src = '(typeof r[' + i + ']==="number"?D[' + k + '][r[' + i + ']]:r[' + i + '])';
|
|
41
|
+
else src = 'D[' + k + '][r[' + i + ']]';
|
|
42
|
+
body += JSON.stringify(fields[k]) + ':' + src;
|
|
43
|
+
}
|
|
44
|
+
body += '}}';
|
|
45
|
+
return new Function('D', body);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function closureRowCtorFactory(fields, dictFlags, offset, typed) {
|
|
49
|
+
const nf = fields.length;
|
|
50
|
+
const names = fields.slice();
|
|
51
|
+
const flags = dictFlags.slice();
|
|
52
|
+
return function (D) {
|
|
53
|
+
return function (r) {
|
|
54
|
+
const o = {};
|
|
55
|
+
for (let k = 0; k < nf; k++) {
|
|
56
|
+
const v = r[k + offset];
|
|
57
|
+
o[names[k]] = flags[k] === 1 && (!typed || typeof v === 'number') ? D[k][v] : v;
|
|
58
|
+
}
|
|
59
|
+
return o;
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function rowCtorFactory(fields, dictFlags, offset, typed) {
|
|
65
|
+
return canCodegen()
|
|
66
|
+
? compiledRowCtorFactory(fields, dictFlags, offset, typed)
|
|
67
|
+
: closureRowCtorFactory(fields, dictFlags, offset, typed);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---- lazy row class ---------------------------------------------------
|
|
71
|
+
// simdjson "On Demand" style: the row keeps the raw wire array and exposes
|
|
72
|
+
// one getter per field, plus toJSON() so JSON.stringify sees a plain object.
|
|
73
|
+
|
|
74
|
+
export function compiledLazyClassFactory(fields, dictFlags, offset) {
|
|
75
|
+
let body = 'return class{constructor(r){this._r=r}';
|
|
76
|
+
for (let k = 0; k < fields.length; k++) {
|
|
77
|
+
const fname = JSON.stringify(fields[k]);
|
|
78
|
+
const expr = dictFlags[k] === 1
|
|
79
|
+
? ('D[' + k + '][this._r[' + (k + offset) + ']]')
|
|
80
|
+
: ('this._r[' + (k + offset) + ']');
|
|
81
|
+
body += 'get ' + fname + '(){return ' + expr + '}';
|
|
82
|
+
}
|
|
83
|
+
body += 'toJSON(){return {';
|
|
84
|
+
for (let k = 0; k < fields.length; k++) {
|
|
85
|
+
if (k) body += ',';
|
|
86
|
+
body += JSON.stringify(fields[k]) + ':this[' + JSON.stringify(fields[k]) + ']';
|
|
87
|
+
}
|
|
88
|
+
body += '}}}';
|
|
89
|
+
return new Function('D', body);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function closureLazyClassFactory(fields, dictFlags, offset) {
|
|
93
|
+
const nf = fields.length;
|
|
94
|
+
const names = fields.slice();
|
|
95
|
+
const flags = dictFlags.slice();
|
|
96
|
+
return function (D) {
|
|
97
|
+
class Row {
|
|
98
|
+
constructor(r) { this._r = r; }
|
|
99
|
+
toJSON() {
|
|
100
|
+
const o = {};
|
|
101
|
+
for (let k = 0; k < nf; k++) o[names[k]] = this[names[k]];
|
|
102
|
+
return o;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
for (let k = 0; k < nf; k++) {
|
|
106
|
+
const i = k + offset;
|
|
107
|
+
const get = flags[k] === 1
|
|
108
|
+
? function () { return D[k][this._r[i]]; }
|
|
109
|
+
: function () { return this._r[i]; };
|
|
110
|
+
Object.defineProperty(Row.prototype, names[k], { get, enumerable: true, configurable: true });
|
|
111
|
+
}
|
|
112
|
+
return Row;
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function lazyClassFactory(fields, dictFlags, offset) {
|
|
117
|
+
return canCodegen()
|
|
118
|
+
? compiledLazyClassFactory(fields, dictFlags, offset)
|
|
119
|
+
: closureLazyClassFactory(fields, dictFlags, offset);
|
|
120
|
+
}
|
package/src/core/tron-schema.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { rowCtorFactory } from './codegen.js';
|
|
2
|
+
|
|
1
3
|
// SCHEMA PRE-REGISTRATION — "preload, peek, process".
|
|
2
4
|
//
|
|
3
5
|
// In a real API the client already knows the response shape from the interface
|
|
@@ -106,19 +108,12 @@ function compile(spec) {
|
|
|
106
108
|
}
|
|
107
109
|
const anyDict = decTables.some(d => d !== null);
|
|
108
110
|
|
|
109
|
-
// ---- row constructor,
|
|
111
|
+
// ---- row constructor, built ONCE at startup rather than per request ----
|
|
110
112
|
// A dictionary column may still carry a literal string if the encoder saw a
|
|
111
|
-
// value outside the declared set, so those columns test the wire type
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const src = decTables[k] === null
|
|
116
|
-
? 'r[' + k + ']'
|
|
117
|
-
: '(typeof r[' + k + ']==="number"?D[' + k + '][r[' + k + ']]:r[' + k + '])';
|
|
118
|
-
body += JSON.stringify(fields[k]) + ':' + src;
|
|
119
|
-
}
|
|
120
|
-
body += '}}';
|
|
121
|
-
const ctor = new Function('D', body)(decTables);
|
|
113
|
+
// value outside the declared set, so those columns test the wire type
|
|
114
|
+
// (`typed` mode). Compiled via new Function where allowed, closure elsewhere.
|
|
115
|
+
const dictFlags = decTables.map(d => (d === null ? 0 : 1));
|
|
116
|
+
const ctor = rowCtorFactory(fields, dictFlags, 0, true)(decTables);
|
|
122
117
|
|
|
123
118
|
// navigate to the table's container; compiled to a straight-line walk
|
|
124
119
|
function locate(root) {
|
package/src/core/tron-table.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { rowCtorFactory } from './codegen.js';
|
|
2
|
+
|
|
1
3
|
// TABLE MODE — transform-free decoding.
|
|
2
4
|
//
|
|
3
5
|
// Profiling the remaining losses showed the decoder was paying for text
|
|
@@ -95,13 +97,7 @@ function getCtor(fields, dictFlags, dicts) {
|
|
|
95
97
|
const key = dictFlags.join('') + '|' + fields.join(',');
|
|
96
98
|
let fac = ctorCache.get(key);
|
|
97
99
|
if (!fac) {
|
|
98
|
-
|
|
99
|
-
for (let k = 0; k < fields.length; k++) {
|
|
100
|
-
if (k) body += ',';
|
|
101
|
-
body += JSON.stringify(fields[k]) + ':' + (dictFlags[k] === 1 ? ('D[' + k + '][r[' + k + ']]') : ('r[' + k + ']'));
|
|
102
|
-
}
|
|
103
|
-
body += '}}';
|
|
104
|
-
fac = new Function('D', body);
|
|
100
|
+
fac = rowCtorFactory(fields, dictFlags, 0, false);
|
|
105
101
|
ctorCache.set(key, fac);
|
|
106
102
|
}
|
|
107
103
|
return fac(dicts);
|
|
@@ -24,10 +24,12 @@
|
|
|
24
24
|
// Safe because canonical TRON escapes "(" and ")" inside strings (see encStr
|
|
25
25
|
// in tron.js), so every raw paren in the document is structural.
|
|
26
26
|
//
|
|
27
|
-
// Caveats:
|
|
28
|
-
//
|
|
27
|
+
// Caveats: row constructors are compiled with new Function when the runtime
|
|
28
|
+
// allows it and built as closures otherwise (Cloudflare Workers, strict CSP —
|
|
29
|
+
// see codegen.js); a literal U+0001 in the source falls back to the scanner.
|
|
29
30
|
|
|
30
31
|
import { parsePrelude, parseFast, applyTables } from './tron.js';
|
|
32
|
+
import { rowCtorFactory, lazyClassFactory } from './codegen.js';
|
|
31
33
|
|
|
32
34
|
const MARK = '\u0001';
|
|
33
35
|
const MARK_ESC = '\\u0001'; // the 6-character JSON escape, not the raw char
|
|
@@ -48,14 +50,7 @@ function getCtorFactory(fields, dictFlags, offset) {
|
|
|
48
50
|
const key = offset + '|' + dictFlags.join('') + '|' + fields.join(',');
|
|
49
51
|
let fac = ctorFactoryCache.get(key);
|
|
50
52
|
if (fac) return fac;
|
|
51
|
-
|
|
52
|
-
for (let k = 0; k < fields.length; k++) {
|
|
53
|
-
if (k) body += ',';
|
|
54
|
-
body += JSON.stringify(fields[k]) + ':';
|
|
55
|
-
body += dictFlags[k] === 1 ? ('D[' + k + '][r[' + (k + offset) + ']]') : ('r[' + (k + offset) + ']');
|
|
56
|
-
}
|
|
57
|
-
body += '}}';
|
|
58
|
-
fac = new Function('D', body);
|
|
53
|
+
fac = rowCtorFactory(fields, dictFlags, offset, false);
|
|
59
54
|
ctorFactoryCache.set(key, fac);
|
|
60
55
|
return fac;
|
|
61
56
|
}
|
|
@@ -77,21 +72,7 @@ function getLazyClass(fields, dictFlags, offset) {
|
|
|
77
72
|
const key = offset + '|' + dictFlags.join('') + '|' + fields.join(',');
|
|
78
73
|
let fac = lazyClassCache.get(key);
|
|
79
74
|
if (fac) return fac;
|
|
80
|
-
|
|
81
|
-
for (let k = 0; k < fields.length; k++) {
|
|
82
|
-
const fname = JSON.stringify(fields[k]);
|
|
83
|
-
const expr = dictFlags[k] === 1
|
|
84
|
-
? ('D[' + k + '][this._r[' + (k + offset) + ']]')
|
|
85
|
-
: ('this._r[' + (k + offset) + ']');
|
|
86
|
-
body += 'get ' + fname + '(){return ' + expr + '}';
|
|
87
|
-
}
|
|
88
|
-
body += 'toJSON(){return {';
|
|
89
|
-
for (let k = 0; k < fields.length; k++) {
|
|
90
|
-
if (k) body += ',';
|
|
91
|
-
body += JSON.stringify(fields[k]) + ':this[' + JSON.stringify(fields[k]) + ']';
|
|
92
|
-
}
|
|
93
|
-
body += '}}}';
|
|
94
|
-
fac = new Function('D', body);
|
|
75
|
+
fac = lazyClassFactory(fields, dictFlags, offset);
|
|
95
76
|
lazyClassCache.set(key, fac);
|
|
96
77
|
return fac;
|
|
97
78
|
}
|
package/src/core/tron.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { rowCtorFactory } from './codegen.js';
|
|
2
|
+
|
|
1
3
|
// TRON — Token Reduced Object Notation (interpretation of the documented format).
|
|
2
4
|
// A superset of JSON that adds class declarations + positional instantiation:
|
|
3
5
|
//
|
|
@@ -141,13 +143,7 @@ function _tblCtor(fields, dictFlags, dicts) {
|
|
|
141
143
|
const key = dictFlags.join('') + '|' + fields.join(',');
|
|
142
144
|
let fac = _tblCtorCache.get(key);
|
|
143
145
|
if (!fac) {
|
|
144
|
-
|
|
145
|
-
for (let k = 0; k < fields.length; k++) {
|
|
146
|
-
if (k) body += ',';
|
|
147
|
-
body += JSON.stringify(fields[k]) + ':' + (dictFlags[k] === 1 ? ('D[' + k + '][r[' + k + ']]') : ('r[' + k + ']'));
|
|
148
|
-
}
|
|
149
|
-
body += '}}';
|
|
150
|
-
fac = new Function('D', body);
|
|
146
|
+
fac = rowCtorFactory(fields, dictFlags, 0, false);
|
|
151
147
|
_tblCtorCache.set(key, fac);
|
|
152
148
|
}
|
|
153
149
|
return fac(dicts);
|
package/src/server.d.ts
CHANGED
|
@@ -13,6 +13,12 @@ export interface Serializer {
|
|
|
13
13
|
export interface TronSerializerOptions extends EncodeOptions {
|
|
14
14
|
/** Compiled schema from defineSchema() — skips shape detection (fastest mode). */
|
|
15
15
|
schema?: CompiledSchema;
|
|
16
|
+
/**
|
|
17
|
+
* Emit the columnar form `api.tape()` / `decodeColumnar()` read into a
|
|
18
|
+
* Float64Array. The handler must return a root array of flat numeric rows.
|
|
19
|
+
* Mutually exclusive with `schema`.
|
|
20
|
+
*/
|
|
21
|
+
columnar?: boolean;
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
/** Build a serializer for norns route() / setSerializer() / boot({ serializer }). */
|
package/src/server.js
CHANGED
|
@@ -10,13 +10,14 @@
|
|
|
10
10
|
* Per-route override / opt-out:
|
|
11
11
|
* export const POST = route({ serializer: null, handler }); // plain JSON
|
|
12
12
|
* export const GET = route({ serializer: tronSerializer({ schema }), handler });
|
|
13
|
+
* export const GET = route({ serializer: tronSerializer({ columnar: true }), handler });
|
|
13
14
|
*
|
|
14
15
|
* The serializer only kicks in when the client asked for TRON via the Accept
|
|
15
16
|
* header, so curl, third parties and existing consumers keep getting JSON.
|
|
16
17
|
* Plain JSON is valid TRON, so clients may also send TRON request bodies
|
|
17
18
|
* unconditionally — `parseBody` reads both.
|
|
18
19
|
*/
|
|
19
|
-
import { encode, decode } from './index.js';
|
|
20
|
+
import { encode, encodeColumnar, decode } from './index.js';
|
|
20
21
|
|
|
21
22
|
export const TRON_CONTENT_TYPE = 'application/tron';
|
|
22
23
|
|
|
@@ -62,20 +63,29 @@ function devWarn(value, path) {
|
|
|
62
63
|
* @param {object} [opts]
|
|
63
64
|
* @param {{encode:Function, decode:Function}} [opts.schema] compiled schema from
|
|
64
65
|
* defineSchema() — skips shape detection entirely (fastest mode)
|
|
66
|
+
* @param {boolean} [opts.columnar] emit the form `decodeColumnar` / `api.tape()`
|
|
67
|
+
* can read into a Float64Array. The handler must return a root array of
|
|
68
|
+
* flat rows whose values are all numbers (or dictionary-able strings /
|
|
69
|
+
* booleans). Anything else still round-trips, just through the object path.
|
|
65
70
|
* @param {number} [opts.minBytes] below this JSON size, emit plain JSON (default 1024)
|
|
66
71
|
* @param {boolean} [opts.dict] dictionary-encode low-cardinality columns (default true)
|
|
67
72
|
* @param {boolean|'nested'} [opts.table] emit table declarations (default true)
|
|
68
73
|
* @returns {{serialize: Function, parseBody: Function}}
|
|
69
74
|
*/
|
|
70
75
|
export function tronSerializer(opts = {}) {
|
|
71
|
-
const { schema, ...encodeOpts } = opts;
|
|
76
|
+
const { schema, columnar, ...encodeOpts } = opts;
|
|
77
|
+
if (schema && columnar) throw new Error('tronSerializer: `schema` and `columnar` are mutually exclusive');
|
|
72
78
|
return {
|
|
73
79
|
/** @returns {Response|null} null = fall through to json() */
|
|
74
80
|
serialize(result, event) {
|
|
75
81
|
if (!acceptsTron(event.request)) return null;
|
|
76
82
|
const value = result ?? null;
|
|
77
83
|
if (DEV) devWarn(value, event.url?.pathname ?? '$');
|
|
78
|
-
const body = schema
|
|
84
|
+
const body = schema
|
|
85
|
+
? schema.encode(value)
|
|
86
|
+
: columnar
|
|
87
|
+
? encodeColumnar(value, { minBytes: encodeOpts.minBytes, force: encodeOpts.force })
|
|
88
|
+
: encode(value, encodeOpts);
|
|
79
89
|
return new Response(body, {
|
|
80
90
|
headers: { 'content-type': TRON_CONTENT_TYPE }
|
|
81
91
|
});
|