@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
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// GENERATED by scripts/embed-wasm.mjs — do not edit by hand.
|
|
2
|
+
// Source: wasm/parserTron2.wasm (1720 bytes).
|
|
3
|
+
export const WASM_BASE64 =
|
|
4
|
+
'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';
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* APITRON — TRON encoder/decoder for LLM-oriented APIs.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface EncodeOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Below this many bytes of equivalent JSON, encode() returns plain JSON
|
|
8
|
+
* instead — under ~1 KB the fixed costs dominate and TRON is both slower and
|
|
9
|
+
* no smaller. decode() reads plain JSON transparently. Default: 1024.
|
|
10
|
+
* Set to 0 to always encode as TRON.
|
|
11
|
+
*/
|
|
12
|
+
minBytes?: number;
|
|
13
|
+
/** Dictionary-encode low-cardinality string/boolean columns. Default: true. */
|
|
14
|
+
dict?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Emit `table` declarations so decoding needs no text transform.
|
|
17
|
+
* `true` — flat uniform rows only (faster AND fewer tokens: always a win).
|
|
18
|
+
* `'nested'` — also table-ify rows containing nested objects/arrays: faster
|
|
19
|
+
* still, but the inner shapes lose their own compression, so tokens can get
|
|
20
|
+
* worse. Opt in deliberately.
|
|
21
|
+
* Default: true.
|
|
22
|
+
*/
|
|
23
|
+
table?: boolean | 'nested';
|
|
24
|
+
/** Skip the minBytes check entirely. */
|
|
25
|
+
force?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SchemaSpec {
|
|
29
|
+
/** Optional version tag, emitted as a `#id` line and used by the registry. */
|
|
30
|
+
id?: string;
|
|
31
|
+
/** Field names, in the exact order rows are serialized. */
|
|
32
|
+
fields: string[];
|
|
33
|
+
/**
|
|
34
|
+
* Per-field dictionaries. Values must be strings or booleans — numeric enum
|
|
35
|
+
* values are rejected because they are ambiguous with dictionary indices.
|
|
36
|
+
*/
|
|
37
|
+
enums?: Record<string, Array<string | boolean>>;
|
|
38
|
+
/** Where the row array lives: `'$'` (root) or e.g. `'$.data'`. Default `'$'`. */
|
|
39
|
+
path?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CompiledSchema {
|
|
43
|
+
encode(value: unknown): string;
|
|
44
|
+
decode(text: string): unknown;
|
|
45
|
+
/** Read the `#id` tag without parsing the body. Returns null when untagged. */
|
|
46
|
+
peek(text: string): string | null;
|
|
47
|
+
readonly fields: string[];
|
|
48
|
+
readonly id: string | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SchemaRegistry {
|
|
52
|
+
register(spec: SchemaSpec): CompiledSchema;
|
|
53
|
+
get(id: string): CompiledSchema | undefined;
|
|
54
|
+
/** PEEK the `#id` tag, then decode with the matching schema. */
|
|
55
|
+
decode(text: string): unknown;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ColumnarResult {
|
|
59
|
+
fields: string[];
|
|
60
|
+
rows: number;
|
|
61
|
+
cols: number;
|
|
62
|
+
/** Row-major, length = rows * cols. */
|
|
63
|
+
tape: Float64Array;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Encode as self-describing TRON (or plain JSON when under `minBytes`). */
|
|
67
|
+
export function encode(value: unknown, opts?: EncodeOptions): string;
|
|
68
|
+
|
|
69
|
+
/** Decode any TRON document, including plain JSON. */
|
|
70
|
+
export function decode(text: string): unknown;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Encode specifically for `decodeColumnar`. The default `encode()` emits table
|
|
74
|
+
* declarations, which the WASM scanner cannot take; this emits the form it can.
|
|
75
|
+
*/
|
|
76
|
+
export function encodeColumnar(value: unknown, opts?: { minBytes?: number; force?: boolean }): string;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Decode a numeric/dictionary table to a flat Float64Array, or undefined.
|
|
80
|
+
* The payload must come from `encodeColumnar()`.
|
|
81
|
+
*/
|
|
82
|
+
export function decodeColumnar(text: string, copy?: boolean): ColumnarResult | undefined;
|
|
83
|
+
|
|
84
|
+
/** PRELOAD: compile a schema once at startup from your interface contract. */
|
|
85
|
+
export function defineSchema(spec: SchemaSpec): CompiledSchema;
|
|
86
|
+
|
|
87
|
+
/** A registry for services serving several response shapes. */
|
|
88
|
+
export function createRegistry(): SchemaRegistry;
|
|
89
|
+
|
|
90
|
+
/** True when the WASM fast path is usable here. */
|
|
91
|
+
export function wasmAvailable(): boolean;
|
|
92
|
+
|
|
93
|
+
/** Supply the WASM binary yourself (browsers, bundlers, Deno). */
|
|
94
|
+
export function setWasmBinary(bytes: ArrayBuffer | Uint8Array): void;
|
|
95
|
+
|
|
96
|
+
export const raw: {
|
|
97
|
+
stringify(value: unknown, opts?: EncodeOptions): string;
|
|
98
|
+
parse(text: string): unknown;
|
|
99
|
+
parseFast(text: string): unknown;
|
|
100
|
+
parsePrelude(text: string): {
|
|
101
|
+
classes: Record<string, { f: string[]; d: Array<Array<string | boolean> | null> }>;
|
|
102
|
+
tables: Array<{ cls: string; path: string }>;
|
|
103
|
+
end: number;
|
|
104
|
+
};
|
|
105
|
+
};
|
package/src/index.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* norns-tron — TRON encoder/decoder for LLM-oriented APIs.
|
|
3
|
+
* Core absorbed from the apitron research library (same authors).
|
|
4
|
+
*
|
|
5
|
+
* Two modes:
|
|
6
|
+
*
|
|
7
|
+
* 1. SELF-DESCRIBING — encode(value) / decode(wire)
|
|
8
|
+
* The payload carries its own declarations, so any consumer can read it
|
|
9
|
+
* cold (an LLM, a third party). Use for prompts and public responses.
|
|
10
|
+
*
|
|
11
|
+
* 2. SCHEMA-PRELOADED — defineSchema(spec) -> { encode, decode }
|
|
12
|
+
* Both ends share an interface contract, so nothing describing the shape
|
|
13
|
+
* travels on the wire and the row constructor is compiled once at startup.
|
|
14
|
+
* Fastest on every axis. Use for internal APIs.
|
|
15
|
+
*
|
|
16
|
+
* Zero runtime dependencies. WASM is optional and loaded lazily.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import * as TRON from './core/tron.js';
|
|
20
|
+
import * as ENCODER from './core/tron-encode.js';
|
|
21
|
+
import * as AUTO from './core/tron-auto.js';
|
|
22
|
+
import * as SCHEMA from './core/tron-schema.js';
|
|
23
|
+
import * as WASM from './core/tron-wasm.js';
|
|
24
|
+
|
|
25
|
+
// Below this many bytes of equivalent JSON, the fixed costs of shape detection
|
|
26
|
+
// dominate and plain JSON is simply faster and no smaller. Measured crossover
|
|
27
|
+
// is ~1 KB for size and ~5 KB for decode; 1 KB is the conservative default.
|
|
28
|
+
const DEFAULT_MIN_BYTES = 1024;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Encode a value as self-describing TRON.
|
|
32
|
+
*
|
|
33
|
+
* Falls back to plain JSON for payloads under `minBytes` — decode() reads that
|
|
34
|
+
* transparently, since a document with no declarations is just JSON.
|
|
35
|
+
*
|
|
36
|
+
* @param {*} value
|
|
37
|
+
* @param {{minBytes?:number, dict?:boolean, table?:boolean|'nested', force?:boolean}} [opts]
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
function encode(value, opts) {
|
|
41
|
+
const o = opts || {};
|
|
42
|
+
const minBytes = o.minBytes === undefined ? DEFAULT_MIN_BYTES : o.minBytes;
|
|
43
|
+
if (!o.force && minBytes > 0) {
|
|
44
|
+
const json = JSON.stringify(value);
|
|
45
|
+
if (json === undefined) return 'null';
|
|
46
|
+
if (json.length < minBytes) return json; // too small to be worth it
|
|
47
|
+
}
|
|
48
|
+
return ENCODER.stringify(value, {
|
|
49
|
+
dict: o.dict === undefined ? true : o.dict,
|
|
50
|
+
table: o.table === undefined ? true : o.table,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Decode any TRON document — including plain JSON, which is valid TRON.
|
|
56
|
+
* Picks the fastest safe strategy automatically.
|
|
57
|
+
* @param {string} text
|
|
58
|
+
* @returns {*}
|
|
59
|
+
*/
|
|
60
|
+
function decode(text) {
|
|
61
|
+
return AUTO.decode(text);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Encode specifically for `decodeColumnar`.
|
|
66
|
+
*
|
|
67
|
+
* The default `encode()` emits `table` declarations, which makes the body plain
|
|
68
|
+
* JSON — great for the object path, but the WASM scanner looks for the `Ck(...)`
|
|
69
|
+
* row syntax and will not accept it. This helper emits the form the columnar
|
|
70
|
+
* decoder can actually take (dictionaries on, table declarations off).
|
|
71
|
+
*
|
|
72
|
+
* Use it only when the consumer calls `decodeColumnar`; for everything else
|
|
73
|
+
* `encode()` is the better default.
|
|
74
|
+
*
|
|
75
|
+
* @param {*} value
|
|
76
|
+
* @param {{minBytes?:number, force?:boolean}} [opts]
|
|
77
|
+
* @returns {string}
|
|
78
|
+
*/
|
|
79
|
+
function encodeColumnar(value, opts) {
|
|
80
|
+
const o = opts || {};
|
|
81
|
+
return encode(value, {
|
|
82
|
+
minBytes: o.minBytes,
|
|
83
|
+
force: o.force === undefined ? true : o.force,
|
|
84
|
+
dict: true,
|
|
85
|
+
table: false,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Decode an all-numeric (or fully dictionary-encoded) table into a flat
|
|
91
|
+
* Float64Array instead of JS objects.
|
|
92
|
+
*
|
|
93
|
+
* This is the fastest decode path — ~3x faster than JSON.parse — but the caller
|
|
94
|
+
* must be able to work columnar.
|
|
95
|
+
*
|
|
96
|
+
* NOTE: the payload must come from `encodeColumnar()`. Output of the default
|
|
97
|
+
* `encode()` is NOT eligible (it emits table declarations, which the WASM
|
|
98
|
+
* scanner does not accept) and this returns undefined for it.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} text
|
|
101
|
+
* @param {boolean} [copy] copy the tape out of WASM memory (it is otherwise
|
|
102
|
+
* invalidated by the next decode call)
|
|
103
|
+
* @returns {{fields:string[], rows:number, cols:number, tape:Float64Array}|undefined}
|
|
104
|
+
*/
|
|
105
|
+
function decodeColumnar(text, copy) {
|
|
106
|
+
return AUTO.decodeColumnar(text, copy);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* PRELOAD — compile a schema once, at startup, from your interface contract.
|
|
111
|
+
*
|
|
112
|
+
* const users = defineSchema({
|
|
113
|
+
* id: 'users.v1', // optional #tag for routing
|
|
114
|
+
* fields: ['id', 'name', 'role', 'score'], // exact order matters
|
|
115
|
+
* enums: { role: ['admin', 'user'] }, // strings/booleans only
|
|
116
|
+
* path: '$.data', // '$' = root array
|
|
117
|
+
* });
|
|
118
|
+
*
|
|
119
|
+
* @param {{id?:string, fields:string[], enums?:Object, path?:string}} spec
|
|
120
|
+
* @returns {{encode:Function, decode:Function, peek:Function, fields:string[], id:?string}}
|
|
121
|
+
*/
|
|
122
|
+
function defineSchema(spec) {
|
|
123
|
+
return SCHEMA.compile(spec);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A registry for services serving several response shapes. PEEK routes a
|
|
128
|
+
* document to its schema by its `#id` tag without parsing the body.
|
|
129
|
+
*/
|
|
130
|
+
function createRegistry() {
|
|
131
|
+
return SCHEMA.createRegistry();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** True when the WASM fast path is usable in this environment. */
|
|
135
|
+
function wasmAvailable() {
|
|
136
|
+
try { return AUTO.wasmAvailable(); } catch (e) { return false; }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Supply the WASM binary yourself (browsers, bundlers, Deno).
|
|
141
|
+
* @param {ArrayBuffer|Uint8Array} bytes contents of wasm/parserTron2.wasm
|
|
142
|
+
*/
|
|
143
|
+
function setWasmBinary(bytes) {
|
|
144
|
+
WASM.setWasmBinary(bytes);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Lower-level entry points, for when you want to bypass the dispatcher.
|
|
148
|
+
const raw = {
|
|
149
|
+
stringify: ENCODER.stringify, // always encodes, no size threshold
|
|
150
|
+
parse: TRON.parse, // tolerant scanner (accepts whitespace, unquoted keys)
|
|
151
|
+
parseFast: TRON.parseFast, // strict scanner, no `new Function` needed
|
|
152
|
+
parsePrelude: TRON.parsePrelude,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export {
|
|
156
|
+
encode,
|
|
157
|
+
decode,
|
|
158
|
+
encodeColumnar,
|
|
159
|
+
decodeColumnar,
|
|
160
|
+
defineSchema,
|
|
161
|
+
createRegistry,
|
|
162
|
+
wasmAvailable,
|
|
163
|
+
setWasmBinary,
|
|
164
|
+
raw,
|
|
165
|
+
};
|
package/src/server.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CompiledSchema, EncodeOptions } from './index.js';
|
|
2
|
+
|
|
3
|
+
export const TRON_CONTENT_TYPE: 'application/tron';
|
|
4
|
+
|
|
5
|
+
/** True when the request's Accept header asks for TRON. */
|
|
6
|
+
export function acceptsTron(request: Request): boolean;
|
|
7
|
+
|
|
8
|
+
export interface Serializer {
|
|
9
|
+
serialize(result: unknown, event: { request: Request; url?: URL }): Response | null;
|
|
10
|
+
parseBody(request: Request, contentType: string): Promise<unknown> | undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TronSerializerOptions extends EncodeOptions {
|
|
14
|
+
/** Compiled schema from defineSchema() — skips shape detection (fastest mode). */
|
|
15
|
+
schema?: CompiledSchema;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Build a serializer for norns route() / setSerializer() / boot({ serializer }). */
|
|
19
|
+
export function tronSerializer(opts?: TronSerializerOptions): Serializer;
|
package/src/server.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server glue for Norns `route()`.
|
|
3
|
+
*
|
|
4
|
+
* Usage (app-wide):
|
|
5
|
+
* // src/hooks.server.c (or wherever boot() runs)
|
|
6
|
+
* import { setSerializer } from '@human-synthesis/norns/server';
|
|
7
|
+
* import { tronSerializer } from '@human-synthesis/norns-tron/server';
|
|
8
|
+
* setSerializer(tronSerializer());
|
|
9
|
+
*
|
|
10
|
+
* Per-route override / opt-out:
|
|
11
|
+
* export const POST = route({ serializer: null, handler }); // plain JSON
|
|
12
|
+
* export const GET = route({ serializer: tronSerializer({ schema }), handler });
|
|
13
|
+
*
|
|
14
|
+
* The serializer only kicks in when the client asked for TRON via the Accept
|
|
15
|
+
* header, so curl, third parties and existing consumers keep getting JSON.
|
|
16
|
+
* Plain JSON is valid TRON, so clients may also send TRON request bodies
|
|
17
|
+
* unconditionally — `parseBody` reads both.
|
|
18
|
+
*/
|
|
19
|
+
import { encode, decode } from './index.js';
|
|
20
|
+
|
|
21
|
+
export const TRON_CONTENT_TYPE = 'application/tron';
|
|
22
|
+
|
|
23
|
+
/** True when the request's Accept header asks for TRON. */
|
|
24
|
+
export function acceptsTron(request) {
|
|
25
|
+
const accept = request.headers.get('accept');
|
|
26
|
+
return accept !== null && accept.includes(TRON_CONTENT_TYPE);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEV = typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production';
|
|
30
|
+
const warned = new Set();
|
|
31
|
+
|
|
32
|
+
// Cheap capped walk: Map/Set/BigInt silently degrade in any JSON-family
|
|
33
|
+
// format (Map/Set -> {}, BigInt throws). json() has the same problem, but a
|
|
34
|
+
// dev-time nudge here beats debugging a mangled payload. Dates are fine —
|
|
35
|
+
// they encode as ISO strings, exactly like JSON.stringify.
|
|
36
|
+
function devWarn(value, path) {
|
|
37
|
+
let budget = 200;
|
|
38
|
+
(function walk(v, p) {
|
|
39
|
+
if (budget-- <= 0 || v === null || typeof v !== 'object') {
|
|
40
|
+
if (typeof v === 'bigint' && !warned.has(p)) {
|
|
41
|
+
warned.add(p);
|
|
42
|
+
console.warn(`[norns-tron] ${p}: BigInt cannot be serialized (same as JSON) — convert to string/number`);
|
|
43
|
+
}
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if ((v instanceof Map || v instanceof Set) && !warned.has(p)) {
|
|
47
|
+
warned.add(p);
|
|
48
|
+
console.warn(`[norns-tron] ${p}: ${v.constructor.name} serializes as {} (same as JSON) — convert to array/object first`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (Array.isArray(v)) {
|
|
52
|
+
for (let i = 0; i < v.length && budget > 0; i++) walk(v[i], p);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
for (const k of Object.keys(v)) { if (budget <= 0) break; walk(v[k], p + '.' + k); }
|
|
56
|
+
})(value, path);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build a serializer for norns `route()` / `setSerializer()`.
|
|
61
|
+
*
|
|
62
|
+
* @param {object} [opts]
|
|
63
|
+
* @param {{encode:Function, decode:Function}} [opts.schema] compiled schema from
|
|
64
|
+
* defineSchema() — skips shape detection entirely (fastest mode)
|
|
65
|
+
* @param {number} [opts.minBytes] below this JSON size, emit plain JSON (default 1024)
|
|
66
|
+
* @param {boolean} [opts.dict] dictionary-encode low-cardinality columns (default true)
|
|
67
|
+
* @param {boolean|'nested'} [opts.table] emit table declarations (default true)
|
|
68
|
+
* @returns {{serialize: Function, parseBody: Function}}
|
|
69
|
+
*/
|
|
70
|
+
export function tronSerializer(opts = {}) {
|
|
71
|
+
const { schema, ...encodeOpts } = opts;
|
|
72
|
+
return {
|
|
73
|
+
/** @returns {Response|null} null = fall through to json() */
|
|
74
|
+
serialize(result, event) {
|
|
75
|
+
if (!acceptsTron(event.request)) return null;
|
|
76
|
+
const value = result ?? null;
|
|
77
|
+
if (DEV) devWarn(value, event.url?.pathname ?? '$');
|
|
78
|
+
const body = schema ? schema.encode(value) : encode(value, encodeOpts);
|
|
79
|
+
return new Response(body, {
|
|
80
|
+
headers: { 'content-type': TRON_CONTENT_TYPE }
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
/** @returns {Promise<any>|undefined} undefined = content type not handled */
|
|
85
|
+
parseBody(request, contentType) {
|
|
86
|
+
if (contentType !== TRON_CONTENT_TYPE) return undefined;
|
|
87
|
+
return request.text().then(
|
|
88
|
+
(text) => {
|
|
89
|
+
try {
|
|
90
|
+
return decode(text);
|
|
91
|
+
} catch {
|
|
92
|
+
return null; // schema validation then rejects, same as malformed JSON
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
() => null
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
package/src/valibot.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { CompiledSchema, SchemaSpec } from './index.js';
|
|
2
|
+
|
|
3
|
+
export interface DeriveOptions {
|
|
4
|
+
/** Version tag emitted as a `#id` line and used by createRegistry(). */
|
|
5
|
+
id?: string;
|
|
6
|
+
/** Where the row array lives: `'$'` (root) or e.g. `'$.data'`. */
|
|
7
|
+
path?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Derive a plain TRON schema spec from a valibot v.object(...) schema. */
|
|
11
|
+
export function tronSpecFromValibot(objectSchema: unknown, opts?: DeriveOptions): SchemaSpec;
|
|
12
|
+
|
|
13
|
+
/** Derive and compile in one step. Call at module scope, never per request. */
|
|
14
|
+
export function tronSchemaFromValibot(objectSchema: unknown, opts?: DeriveOptions): CompiledSchema;
|
package/src/valibot.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive a TRON schema spec from a valibot object schema, so the wire contract
|
|
3
|
+
* lives in one place — the feature's `shared/schema.c`.
|
|
4
|
+
*
|
|
5
|
+
* // shared/schema.c
|
|
6
|
+
* export noteSchema := v.object({ id: v.number(), title: v.string(),
|
|
7
|
+
* status: v.picklist(['draft','published']) })
|
|
8
|
+
* export noteWire := tronSchemaFromValibot(noteSchema, { id: 'notes.v1', path: '$.data' })
|
|
9
|
+
*
|
|
10
|
+
* // server: route({ serializer: tronSerializer({ schema: noteWire }), handler })
|
|
11
|
+
* // client: noteWire.decode(await res.text())
|
|
12
|
+
*
|
|
13
|
+
* Derivation rules:
|
|
14
|
+
* - fields = object entry names, in declaration order
|
|
15
|
+
* - picklist -> enum (string options only; numeric picklists are skipped —
|
|
16
|
+
* numbers are ambiguous with dictionary indices on the wire)
|
|
17
|
+
* - enum -> enum from its string values
|
|
18
|
+
* - boolean -> enum [false, true] (a boolean column as 0/1 on the wire)
|
|
19
|
+
* - optional / nullable / nullish wrappers are unwrapped first
|
|
20
|
+
*/
|
|
21
|
+
import { defineSchema } from './index.js';
|
|
22
|
+
|
|
23
|
+
function unwrap(schema) {
|
|
24
|
+
let s = schema;
|
|
25
|
+
while (s && (s.type === 'optional' || s.type === 'nullable' || s.type === 'nullish' || s.type === 'non_optional' || s.type === 'non_nullable' || s.type === 'non_nullish')) {
|
|
26
|
+
s = s.wrapped;
|
|
27
|
+
}
|
|
28
|
+
return s;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function enumValues(schema) {
|
|
32
|
+
const s = unwrap(schema);
|
|
33
|
+
if (!s) return null;
|
|
34
|
+
if (s.type === 'picklist' && Array.isArray(s.options)) {
|
|
35
|
+
return s.options.every((o) => typeof o === 'string') ? s.options.slice() : null;
|
|
36
|
+
}
|
|
37
|
+
if (s.type === 'enum' && s.enum && typeof s.enum === 'object') {
|
|
38
|
+
const vals = Object.values(s.enum).filter((v) => typeof v === 'string');
|
|
39
|
+
return vals.length > 0 ? vals : null;
|
|
40
|
+
}
|
|
41
|
+
if (s.type === 'boolean') return [false, true];
|
|
42
|
+
if (s.type === 'literal' && (typeof s.literal === 'string' || typeof s.literal === 'boolean')) {
|
|
43
|
+
return [s.literal];
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Derive the plain spec — useful when you want to tweak it before compiling.
|
|
50
|
+
*
|
|
51
|
+
* @param {any} objectSchema a valibot v.object(...) schema
|
|
52
|
+
* @param {{id?: string, path?: string}} [opts]
|
|
53
|
+
* @returns {{id?: string, fields: string[], enums?: Record<string, any[]>, path?: string}}
|
|
54
|
+
*/
|
|
55
|
+
export function tronSpecFromValibot(objectSchema, opts = {}) {
|
|
56
|
+
const s = unwrap(objectSchema);
|
|
57
|
+
if (!s || s.type !== 'object' || !s.entries) {
|
|
58
|
+
throw new Error('tronSpecFromValibot: expected a valibot object schema (got ' + (s?.type ?? typeof objectSchema) + ')');
|
|
59
|
+
}
|
|
60
|
+
const fields = Object.keys(s.entries);
|
|
61
|
+
/** @type {Record<string, any[]>} */
|
|
62
|
+
const enums = {};
|
|
63
|
+
let any = false;
|
|
64
|
+
for (const f of fields) {
|
|
65
|
+
const vals = enumValues(s.entries[f]);
|
|
66
|
+
if (vals) { enums[f] = vals; any = true; }
|
|
67
|
+
}
|
|
68
|
+
const spec = { fields };
|
|
69
|
+
if (any) spec.enums = enums;
|
|
70
|
+
if (opts.id) spec.id = opts.id;
|
|
71
|
+
if (opts.path) spec.path = opts.path;
|
|
72
|
+
return spec;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Derive and compile in one step (defineSchema is a startup-time cost — call
|
|
77
|
+
* this at module scope, never per request).
|
|
78
|
+
*
|
|
79
|
+
* @param {any} objectSchema a valibot v.object(...) schema
|
|
80
|
+
* @param {{id?: string, path?: string}} [opts]
|
|
81
|
+
*/
|
|
82
|
+
export function tronSchemaFromValibot(objectSchema, opts = {}) {
|
|
83
|
+
return defineSchema(tronSpecFromValibot(objectSchema, opts));
|
|
84
|
+
}
|
|
Binary file
|