feat(serializer): add aot serializer
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>@perf/serializer — examples</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--bg: #f7f7f8;
|
||||||
|
--fg: #1c1c1f;
|
||||||
|
--muted: #6b6b75;
|
||||||
|
--pre-bg: #ffffff;
|
||||||
|
--pre-border: #e4e4e8;
|
||||||
|
--accent: #2f74ff;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #16161a;
|
||||||
|
--fg: #ececf0;
|
||||||
|
--muted: #9a9aa3;
|
||||||
|
--pre-bg: #1f1f25;
|
||||||
|
--pre-border: #2f2f37;
|
||||||
|
--accent: #6ea8ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
max-width: 56rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.hint code {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
#out {
|
||||||
|
background: var(--pre-bg);
|
||||||
|
border: 1px solid var(--pre-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
min-height: 18rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>@perf/serializer — examples</h1>
|
||||||
|
<p class="hint">
|
||||||
|
Six demonstrations from <code>src/main.ts</code>. The Vite AOT plugin in
|
||||||
|
<code>vite.config.ts</code> inlines every <code>type(…)</code> call at
|
||||||
|
build time, so no runtime <code>new Function</code> is involved.
|
||||||
|
</p>
|
||||||
|
<pre id="out"></pre>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "@perf/serializer",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Ultra-high-performance TypeScript serializer with Symbol.serializable contract and JIT-compiled binary codecs.",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24.0.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"bench": "vitest bench --run",
|
||||||
|
"bench:watch": "vitest bench",
|
||||||
|
"type-check": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/devtools": "^0.1.24",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"oxc-parser": "^0.132.0",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vite": "^8.0.14",
|
||||||
|
"vitest": "^4.1.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
/**
|
||||||
|
* Simplified façade over the schema + codec system.
|
||||||
|
*
|
||||||
|
* 90% of usage:
|
||||||
|
* const Order = type('Order', { id: u53, price: f64, side: enumOf(['buy','sell'] as const) });
|
||||||
|
* type Order = typeof Order.$infer;
|
||||||
|
* const bytes = Order.encode(order);
|
||||||
|
* const back = Order.decode(bytes);
|
||||||
|
*
|
||||||
|
* Hot path:
|
||||||
|
* const w = new Writer(1024);
|
||||||
|
* Order.encodeInto(order, w);
|
||||||
|
* socket.send(w.bytes());
|
||||||
|
*
|
||||||
|
* AOT (compile-only) build: the transformer detects `type(...)` calls and
|
||||||
|
* replaces them with precomputed codec literals; the function below never runs
|
||||||
|
* in production. See codegen-plugin/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AnySchema,
|
||||||
|
ArraySchema,
|
||||||
|
BitsetSchema,
|
||||||
|
EnumSchema,
|
||||||
|
ObjectSchema,
|
||||||
|
OptionalSchema,
|
||||||
|
TupleSchema,
|
||||||
|
UnionSchema,
|
||||||
|
} from './descriptors.ts';
|
||||||
|
import type { Reader, Writer } from './io.ts';
|
||||||
|
import { Writer as WriterImpl } from './io.ts';
|
||||||
|
import { Reader as ReaderImpl } from './io.ts';
|
||||||
|
import { defineSchema, s } from './schema.ts';
|
||||||
|
import { register as registerSchema } from './register.ts';
|
||||||
|
|
||||||
|
// ── Primitive markers (re-export of the descriptor singletons) ─────────────
|
||||||
|
|
||||||
|
export const u8 = s.u8;
|
||||||
|
export const u16 = s.u16;
|
||||||
|
export const u32 = s.u32;
|
||||||
|
export const i8 = s.i8;
|
||||||
|
export const i16 = s.i16;
|
||||||
|
export const i32 = s.i32;
|
||||||
|
export const u53 = s.u53;
|
||||||
|
export const i53 = s.i53;
|
||||||
|
export const u64 = s.u64;
|
||||||
|
export const i64 = s.i64;
|
||||||
|
export const f32 = s.f32;
|
||||||
|
export const f64 = s.f64;
|
||||||
|
export const bool = s.bool;
|
||||||
|
export const str = s.str;
|
||||||
|
export const bytes = s.bytes;
|
||||||
|
|
||||||
|
export const f32Array = s.f32Array;
|
||||||
|
export const f64Array = s.f64Array;
|
||||||
|
export const u8Array = s.u8Array;
|
||||||
|
export const u16Array = s.u16Array;
|
||||||
|
export const u32Array = s.u32Array;
|
||||||
|
export const i32Array = s.i32Array;
|
||||||
|
|
||||||
|
// ── Combinators ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function list<E extends AnySchema>(elem: E): ArraySchema<E> {
|
||||||
|
return s.array(elem);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function opt<E extends AnySchema>(elem: E): OptionalSchema<E> {
|
||||||
|
return s.optional(elem);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enumOf<L extends readonly string[]>(values: L): EnumSchema<L> {
|
||||||
|
return s.enum(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flags<L extends readonly string[]>(names: L): BitsetSchema<L> {
|
||||||
|
return s.bitset(names);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tuple<E extends readonly AnySchema[]>(...elems: E): TupleSchema<E> {
|
||||||
|
return s.tuple(...elems);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TypeCodec: schema + runtime API in one value ───────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `TypeCodec<T>` is BOTH:
|
||||||
|
* - an `ObjectSchema` (so it can be used as a field in another `type(...)`)
|
||||||
|
* - a runtime codec with `encode`/`decode` methods
|
||||||
|
*
|
||||||
|
* Plus a phantom `$infer` field for `typeof T.$infer` extraction.
|
||||||
|
*/
|
||||||
|
export interface TypeCodec<T> extends ObjectSchema {
|
||||||
|
readonly id: number;
|
||||||
|
|
||||||
|
/** One-shot encode. Allocates a Writer; for hot paths prefer `encodeInto`. */
|
||||||
|
encode(value: T): Uint8Array;
|
||||||
|
encode(value: T, into: Writer): Uint8Array;
|
||||||
|
|
||||||
|
/** One-shot decode from a complete byte buffer. */
|
||||||
|
decode(bytes: Uint8Array): T;
|
||||||
|
|
||||||
|
/** Hot path: writes directly into a pre-allocated, pooled Writer. */
|
||||||
|
encodeInto(value: T, w: Writer): void;
|
||||||
|
|
||||||
|
/** Hot path: reads from a positioned Reader (does not advance past end). */
|
||||||
|
decodeFrom(r: Reader): T;
|
||||||
|
|
||||||
|
/** Phantom: `typeof Codec.$infer` gives the inferred TS type. Never read at runtime. */
|
||||||
|
readonly $infer: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Type-level inference ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type InferPrim<K> =
|
||||||
|
K extends 'u8' | 'u16' | 'u32' | 'i8' | 'i16' | 'i32' | 'u53' | 'i53' | 'f32' | 'f64' ? number
|
||||||
|
: K extends 'u64' | 'i64' ? bigint
|
||||||
|
: K extends 'bool' ? boolean
|
||||||
|
: K extends 'str' ? string
|
||||||
|
: K extends 'bytes' ? Uint8Array
|
||||||
|
: K extends 'f32Array' ? Float32Array
|
||||||
|
: K extends 'f64Array' ? Float64Array
|
||||||
|
: K extends 'u8Array' ? Uint8Array
|
||||||
|
: K extends 'u16Array' ? Uint16Array
|
||||||
|
: K extends 'u32Array' ? Uint32Array
|
||||||
|
: K extends 'i32Array' ? Int32Array
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export type InferType<S> =
|
||||||
|
S extends { $infer: infer T } ? T
|
||||||
|
: S extends ArraySchema<infer E> ? InferType<E>[]
|
||||||
|
: S extends OptionalSchema<infer E> ? InferType<E> | undefined
|
||||||
|
: S extends EnumSchema<infer L> ? L[number]
|
||||||
|
: S extends BitsetSchema<infer L> ? { [K in L[number]]: boolean }
|
||||||
|
: S extends TupleSchema<infer E> ? { -readonly [K in keyof E]: InferType<E[K]> }
|
||||||
|
: S extends UnionSchema<infer D, infer V>
|
||||||
|
? V extends Record<string, ObjectSchema>
|
||||||
|
? {
|
||||||
|
[K in keyof V & string]: V[K] extends ObjectSchema<infer F>
|
||||||
|
? { [P in D]: K } & { [Pk in keyof F]: InferType<F[Pk]> }
|
||||||
|
: never
|
||||||
|
}[keyof V & string]
|
||||||
|
: never
|
||||||
|
: S extends ObjectSchema<infer F> ? { [K in keyof F]: InferType<F[K]> }
|
||||||
|
: S extends { kind: infer K } ? InferPrim<K>
|
||||||
|
: unknown;
|
||||||
|
|
||||||
|
type Fields = Record<string, AnySchema>;
|
||||||
|
|
||||||
|
// ── Anonymous naming ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let __anonCounter = 0;
|
||||||
|
function anonName(prefix = 'Anon'): string {
|
||||||
|
return `__${prefix}_${++__anonCounter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── type() ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a serializable type.
|
||||||
|
*
|
||||||
|
* The `name` is REQUIRED for wire-stable types (anything sent over a network
|
||||||
|
* or stored to disk) because the schema ID is a hash of the name and the ID
|
||||||
|
* appears in the wire frame. Without a name we generate one, which is fine
|
||||||
|
* for transient/in-process use only.
|
||||||
|
*/
|
||||||
|
export function type<F extends Fields>(fields: F): TypeCodec<{ [K in keyof F]: InferType<F[K]> }>;
|
||||||
|
export function type<F extends Fields>(name: string, fields: F): TypeCodec<{ [K in keyof F]: InferType<F[K]> }>;
|
||||||
|
export function type(nameOrFields: string | Fields, maybeFields?: Fields): TypeCodec<unknown> {
|
||||||
|
const isNamed = typeof nameOrFields === 'string';
|
||||||
|
const name = isNamed ? nameOrFields : anonName('Type');
|
||||||
|
const fields = isNamed ? maybeFields! : nameOrFields;
|
||||||
|
|
||||||
|
const schema = defineSchema(name, () => fields);
|
||||||
|
const codec = registerSchema(schema);
|
||||||
|
|
||||||
|
const enc = codec.encode;
|
||||||
|
const dec = codec.decode;
|
||||||
|
|
||||||
|
function encode(value: unknown, into?: Writer): Uint8Array {
|
||||||
|
if (into) {
|
||||||
|
enc(into, value);
|
||||||
|
return into.bytes();
|
||||||
|
}
|
||||||
|
const tmp = new WriterImpl();
|
||||||
|
enc(tmp, value);
|
||||||
|
return tmp.bytesCopy();
|
||||||
|
}
|
||||||
|
|
||||||
|
function decode(b: Uint8Array): unknown {
|
||||||
|
const r = new ReaderImpl(b);
|
||||||
|
return dec(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeInto(value: unknown, w: Writer): void {
|
||||||
|
enc(w, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeFrom(r: Reader): unknown {
|
||||||
|
return dec(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'object',
|
||||||
|
name,
|
||||||
|
fields: schema.fields,
|
||||||
|
id: codec.id,
|
||||||
|
encode,
|
||||||
|
decode,
|
||||||
|
encodeInto,
|
||||||
|
decodeFrom,
|
||||||
|
$infer: undefined as unknown,
|
||||||
|
} as TypeCodec<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── oneOf() — discriminated union ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discriminated union. Each variant is a field map; at runtime the variant is
|
||||||
|
* identified by a `discriminator` key on the value.
|
||||||
|
*
|
||||||
|
* const Event = oneOf('kind', {
|
||||||
|
* fill: { price: f64, qty: f64 },
|
||||||
|
* cancel: { reason: str },
|
||||||
|
* });
|
||||||
|
* // fill → { kind: 'fill', price, qty }
|
||||||
|
* // cancel → { kind: 'cancel', reason }
|
||||||
|
*/
|
||||||
|
// Less-precise but stable inference for unions; user can narrow via `as`.
|
||||||
|
export function oneOf<D extends string, V extends Record<string, Fields>>(
|
||||||
|
discriminator: D,
|
||||||
|
variants: V,
|
||||||
|
): TypeCodec<{ [P in D]: keyof V & string } & Record<string, unknown>>;
|
||||||
|
export function oneOf<D extends string, V extends Record<string, Fields>>(
|
||||||
|
name: string,
|
||||||
|
discriminator: D,
|
||||||
|
variants: V,
|
||||||
|
): TypeCodec<{ [P in D]: keyof V & string } & Record<string, unknown>>;
|
||||||
|
export function oneOf(
|
||||||
|
arg1: string,
|
||||||
|
arg2: string | Record<string, Fields>,
|
||||||
|
arg3?: Record<string, Fields>,
|
||||||
|
): TypeCodec<unknown> {
|
||||||
|
const isNamed = arg3 !== undefined;
|
||||||
|
const name = isNamed ? arg1 : anonName('Union');
|
||||||
|
const discriminator = (isNamed ? arg2 : arg1) as string;
|
||||||
|
const variants = (isNamed ? arg3! : arg2) as Record<string, Fields>;
|
||||||
|
|
||||||
|
const schema = s.union(name, discriminator, variants);
|
||||||
|
const codec = registerSchema(schema);
|
||||||
|
|
||||||
|
const enc = codec.encode;
|
||||||
|
const dec = codec.decode;
|
||||||
|
|
||||||
|
function encode(value: unknown, into?: Writer): Uint8Array {
|
||||||
|
if (into) {
|
||||||
|
enc(into, value);
|
||||||
|
return into.bytes();
|
||||||
|
}
|
||||||
|
const tmp = new WriterImpl();
|
||||||
|
enc(tmp, value);
|
||||||
|
return tmp.bytesCopy();
|
||||||
|
}
|
||||||
|
|
||||||
|
function decode(b: Uint8Array): unknown {
|
||||||
|
const r = new ReaderImpl(b);
|
||||||
|
return dec(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Union doesn't satisfy ObjectSchema directly — it's UnionSchema. We expose
|
||||||
|
// the runtime API on the same value but the descriptor shape is union.
|
||||||
|
// For use as a field, accept it via AnySchema (TypeScript inference handles it).
|
||||||
|
return {
|
||||||
|
kind: 'union',
|
||||||
|
name,
|
||||||
|
discriminator,
|
||||||
|
variants: (schema as unknown as { variants: unknown }).variants,
|
||||||
|
fields: {}, // unused for unions
|
||||||
|
id: codec.id,
|
||||||
|
encode,
|
||||||
|
decode,
|
||||||
|
encodeInto(value: unknown, w: Writer) { enc(w, value); },
|
||||||
|
decodeFrom(r: Reader) { return dec(r); },
|
||||||
|
$infer: undefined as unknown,
|
||||||
|
} as unknown as TypeCodec<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Router: framed multi-type dispatch ─────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Router {
|
||||||
|
/** Encode with the 2-byte schema-ID frame. */
|
||||||
|
encode<T>(value: T, codec: TypeCodec<T>): Uint8Array;
|
||||||
|
encode<T>(value: T, codec: TypeCodec<T>, into: Writer): Uint8Array;
|
||||||
|
/** Decode a framed message, dispatching by schema ID. */
|
||||||
|
decode(bytes: Uint8Array): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a router for framed multi-message protocols. The router prepends a
|
||||||
|
* 2-byte schema ID on encode and dispatches on it during decode.
|
||||||
|
*/
|
||||||
|
export function router(...codecs: TypeCodec<unknown>[]): Router {
|
||||||
|
const byId = new Map<number, TypeCodec<unknown>>();
|
||||||
|
for (const c of codecs) byId.set(c.id, c);
|
||||||
|
|
||||||
|
return {
|
||||||
|
encode<T>(value: T, codec: TypeCodec<T>, into?: Writer): Uint8Array {
|
||||||
|
if (into) {
|
||||||
|
into.u16(codec.id);
|
||||||
|
codec.encodeInto(value, into);
|
||||||
|
return into.bytes();
|
||||||
|
}
|
||||||
|
const w = new WriterImpl();
|
||||||
|
w.u16(codec.id);
|
||||||
|
codec.encodeInto(value, w);
|
||||||
|
return w.bytesCopy();
|
||||||
|
},
|
||||||
|
decode(bytes: Uint8Array): unknown {
|
||||||
|
const r = new ReaderImpl(bytes);
|
||||||
|
const id = r.u16();
|
||||||
|
const codec = byId.get(id);
|
||||||
|
if (!codec) throw new Error(`Router: unknown schema ID 0x${id.toString(16)}`);
|
||||||
|
return codec.decodeFrom(r);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Writer/Reader re-exports for hot path users ────────────────────────────
|
||||||
|
|
||||||
|
export { Writer, Reader } from './io.ts';
|
||||||
@@ -0,0 +1,729 @@
|
|||||||
|
import type {
|
||||||
|
AnySchema,
|
||||||
|
ObjectSchema,
|
||||||
|
TypedArrayKind,
|
||||||
|
UnionSchema,
|
||||||
|
} from './descriptors.ts';
|
||||||
|
|
||||||
|
export interface CodegenResult {
|
||||||
|
/** Inner-function body for encoder: takes (w, o), uses lifted pos/buf/view locals. */
|
||||||
|
encodeBody: string;
|
||||||
|
/** Inner-function body for decoder: takes (r), uses lifted pos/buf/view locals. */
|
||||||
|
decodeBody: string;
|
||||||
|
/** Cross-codec dependencies for ref/codec fields only (nested objects are inlined). */
|
||||||
|
deps: Map<string, { mode: 'enc' | 'dec'; targetName: string }>;
|
||||||
|
/** Closure-captured values (enum maps, codec functions). */
|
||||||
|
closure: Map<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitize(name: string): string {
|
||||||
|
return name.replace(/[^A-Za-z0-9_]/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
const TA_ELEM_SIZE: Record<TypedArrayKind, number> = {
|
||||||
|
f32Array: 4,
|
||||||
|
f64Array: 8,
|
||||||
|
u8Array: 1,
|
||||||
|
u16Array: 2,
|
||||||
|
u32Array: 4,
|
||||||
|
i32Array: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TA_CTOR: Record<TypedArrayKind, string> = {
|
||||||
|
f32Array: 'Float32Array',
|
||||||
|
f64Array: 'Float64Array',
|
||||||
|
u8Array: 'Uint8Array',
|
||||||
|
u16Array: 'Uint16Array',
|
||||||
|
u32Array: 'Uint32Array',
|
||||||
|
i32Array: 'Int32Array',
|
||||||
|
};
|
||||||
|
|
||||||
|
function isTypedArrayKind(k: string): k is TypedArrayKind {
|
||||||
|
return k in TA_ELEM_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upper bound on bytes the schema may write, or null when truly variable
|
||||||
|
* (str, bytes, array, typed-array, ref, codec, and any composite containing them).
|
||||||
|
*/
|
||||||
|
function maxBytes(schema: AnySchema): number | null {
|
||||||
|
switch (schema.kind) {
|
||||||
|
case 'u8':
|
||||||
|
case 'i8':
|
||||||
|
case 'bool':
|
||||||
|
case 'enum':
|
||||||
|
return 1;
|
||||||
|
case 'u16':
|
||||||
|
case 'i16':
|
||||||
|
return 2;
|
||||||
|
case 'u32':
|
||||||
|
case 'i32':
|
||||||
|
case 'f32':
|
||||||
|
return 4;
|
||||||
|
case 'f64':
|
||||||
|
return 8;
|
||||||
|
case 'u53':
|
||||||
|
case 'i53':
|
||||||
|
case 'u64':
|
||||||
|
case 'i64':
|
||||||
|
return 10;
|
||||||
|
case 'bitset': {
|
||||||
|
const n = schema.flags.length;
|
||||||
|
return n <= 8 ? 1 : n <= 16 ? 2 : n <= 32 ? 4 : 10;
|
||||||
|
}
|
||||||
|
case 'tuple': {
|
||||||
|
let sum = 0;
|
||||||
|
for (const e of schema.elems) {
|
||||||
|
const m = maxBytes(e);
|
||||||
|
if (m === null) return null;
|
||||||
|
sum += m;
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
case 'object': {
|
||||||
|
let sum = 0;
|
||||||
|
for (const f of Object.values(schema.fields)) {
|
||||||
|
const m = maxBytes(f);
|
||||||
|
if (m === null) return null;
|
||||||
|
sum += m;
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
case 'optional': {
|
||||||
|
const m = maxBytes(schema.elem);
|
||||||
|
return m === null ? null : 1 + m;
|
||||||
|
}
|
||||||
|
case 'union': {
|
||||||
|
let max = 0;
|
||||||
|
for (const v of Object.values(schema.variants)) {
|
||||||
|
const m = maxBytes(v);
|
||||||
|
if (m === null) return null;
|
||||||
|
if (m > max) max = m;
|
||||||
|
}
|
||||||
|
return 1 + max;
|
||||||
|
}
|
||||||
|
case 'array':
|
||||||
|
case 'str':
|
||||||
|
case 'bytes':
|
||||||
|
case 'ref':
|
||||||
|
case 'codec':
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Ctx {
|
||||||
|
private counter = 0;
|
||||||
|
mode: 'enc' | 'dec';
|
||||||
|
deps = new Map<string, { mode: 'enc' | 'dec'; targetName: string }>();
|
||||||
|
closure = new Map<string, unknown>();
|
||||||
|
|
||||||
|
constructor(mode: 'enc' | 'dec') {
|
||||||
|
this.mode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
fresh(prefix: string): string {
|
||||||
|
return `_${prefix}${++this.counter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
closureVar(prefix: string, value: unknown): string {
|
||||||
|
const name = `__cv_${prefix}_${++this.counter}`;
|
||||||
|
this.closure.set(name, value);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds an encoder body. Bounded ops are buffered with a running budget;
|
||||||
|
* unbounded ops (str, array, ref, etc.) flush the buffer with a single
|
||||||
|
* `ensure(budget)` then emit themselves. Inside arrays with bounded elements,
|
||||||
|
* we pre-ensure the whole batch and run the element loop in "noEnsure" mode.
|
||||||
|
*/
|
||||||
|
class SegBuilder {
|
||||||
|
private buffered: string[] = [];
|
||||||
|
private budget = 0;
|
||||||
|
private output: string[] = [];
|
||||||
|
noEnsure = false;
|
||||||
|
|
||||||
|
addBounded(stmt: string, maxBytesSize: number): void {
|
||||||
|
if (this.noEnsure) {
|
||||||
|
this.output.push(stmt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.buffered.push(stmt);
|
||||||
|
this.budget += maxBytesSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
addPrelude(stmt: string): void {
|
||||||
|
if (this.noEnsure) {
|
||||||
|
this.output.push(stmt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.buffered.push(stmt);
|
||||||
|
}
|
||||||
|
|
||||||
|
flush(): void {
|
||||||
|
if (this.buffered.length === 0) return;
|
||||||
|
if (this.budget > 0 && !this.noEnsure) {
|
||||||
|
this.output.push(
|
||||||
|
`if (pos + ${this.budget} > buf.byteLength) { w.pos = pos; w.grow(${this.budget}); buf = w.buf; view = w.view; }`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.output.push(...this.buffered);
|
||||||
|
this.buffered = [];
|
||||||
|
this.budget = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
addUnbounded(stmt: string): void {
|
||||||
|
this.flush();
|
||||||
|
this.output.push(stmt);
|
||||||
|
}
|
||||||
|
|
||||||
|
build(): string {
|
||||||
|
this.flush();
|
||||||
|
return this.output.join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitEnc(schema: AnySchema, ctx: Ctx, seg: SegBuilder, vx: string): void {
|
||||||
|
switch (schema.kind) {
|
||||||
|
case 'u8':
|
||||||
|
seg.addBounded(`buf[pos++] = (${vx}) & 0xff;`, 1);
|
||||||
|
return;
|
||||||
|
case 'i8':
|
||||||
|
seg.addBounded(`buf[pos++] = (${vx}) & 0xff;`, 1);
|
||||||
|
return;
|
||||||
|
case 'bool':
|
||||||
|
seg.addBounded(`buf[pos++] = ${vx} ? 1 : 0;`, 1);
|
||||||
|
return;
|
||||||
|
case 'u16':
|
||||||
|
seg.addBounded(`view.setUint16(pos, ${vx}, true); pos += 2;`, 2);
|
||||||
|
return;
|
||||||
|
case 'i16':
|
||||||
|
seg.addBounded(`view.setInt16(pos, ${vx}, true); pos += 2;`, 2);
|
||||||
|
return;
|
||||||
|
case 'u32':
|
||||||
|
seg.addBounded(`view.setUint32(pos, ${vx}, true); pos += 4;`, 4);
|
||||||
|
return;
|
||||||
|
case 'i32':
|
||||||
|
seg.addBounded(`view.setInt32(pos, ${vx}, true); pos += 4;`, 4);
|
||||||
|
return;
|
||||||
|
case 'f32':
|
||||||
|
seg.addBounded(`view.setFloat32(pos, ${vx}, true); pos += 4;`, 4);
|
||||||
|
return;
|
||||||
|
case 'f64':
|
||||||
|
seg.addBounded(`view.setFloat64(pos, ${vx}, true); pos += 8;`, 8);
|
||||||
|
return;
|
||||||
|
case 'u53': {
|
||||||
|
const v = ctx.fresh('v');
|
||||||
|
seg.addBounded(
|
||||||
|
`{ let ${v} = ${vx}; while (${v} >= 0x80) { buf[pos++] = (${v} & 0x7f) | 0x80; ${v} = Math.floor(${v} / 128); } buf[pos++] = ${v}; }`,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'i53': {
|
||||||
|
const v = ctx.fresh('v');
|
||||||
|
const src = ctx.fresh('s');
|
||||||
|
seg.addBounded(
|
||||||
|
`{ const ${src} = ${vx}; let ${v} = ${src} >= 0 ? BigInt(${src}) * 2n : -BigInt(${src}) * 2n - 1n; while (${v} >= 0x80n) { buf[pos++] = Number(${v} & 0x7fn) | 0x80; ${v} >>= 7n; } buf[pos++] = Number(${v}); }`,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'u64': {
|
||||||
|
const v = ctx.fresh('v');
|
||||||
|
seg.addBounded(
|
||||||
|
`{ let ${v} = ${vx}; while (${v} >= 0x80n) { buf[pos++] = Number(${v} & 0x7fn) | 0x80; ${v} >>= 7n; } buf[pos++] = Number(${v}); }`,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'i64': {
|
||||||
|
const v = ctx.fresh('v');
|
||||||
|
const src = ctx.fresh('s');
|
||||||
|
seg.addBounded(
|
||||||
|
`{ const ${src} = ${vx}; let ${v} = ${src} >= 0n ? ${src} << 1n : (-${src} << 1n) - 1n; while (${v} >= 0x80n) { buf[pos++] = Number(${v} & 0x7fn) | 0x80; ${v} >>= 7n; } buf[pos++] = Number(${v}); }`,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'enum': {
|
||||||
|
const vals = schema.values;
|
||||||
|
if (vals.length === 2) {
|
||||||
|
seg.addBounded(`buf[pos++] = ${vx} === ${JSON.stringify(vals[0])} ? 0 : 1;`, 1);
|
||||||
|
} else if (vals.length === 3) {
|
||||||
|
seg.addBounded(
|
||||||
|
`buf[pos++] = ${vx} === ${JSON.stringify(vals[0])} ? 0 : ${vx} === ${JSON.stringify(vals[1])} ? 1 : 2;`,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const map: Record<string, number> = Object.create(null);
|
||||||
|
for (let i = 0; i < vals.length; i++) map[vals[i]!] = i;
|
||||||
|
const cv = ctx.closureVar('enum', Object.freeze(map));
|
||||||
|
seg.addBounded(`buf[pos++] = ${cv}[${vx}];`, 1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'bitset': {
|
||||||
|
const b = ctx.fresh('b');
|
||||||
|
const flags = schema.flags;
|
||||||
|
if (flags.length <= 32) {
|
||||||
|
const parts: string[] = ['0'];
|
||||||
|
for (let i = 0; i < flags.length; i++) {
|
||||||
|
parts.push(`((${b})[${JSON.stringify(flags[i])}] ? ${1 << i} : 0)`);
|
||||||
|
}
|
||||||
|
const expr = parts.join(' | ');
|
||||||
|
if (flags.length <= 8) {
|
||||||
|
seg.addBounded(`{ const ${b} = ${vx}; buf[pos++] = ${expr}; }`, 1);
|
||||||
|
} else if (flags.length <= 16) {
|
||||||
|
seg.addBounded(`{ const ${b} = ${vx}; view.setUint16(pos, ${expr}, true); pos += 2; }`, 2);
|
||||||
|
} else {
|
||||||
|
seg.addBounded(`{ const ${b} = ${vx}; view.setUint32(pos, ${expr}, true); pos += 4; }`, 4);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let big = '0n';
|
||||||
|
for (let i = 0; i < flags.length; i++) {
|
||||||
|
big = `(${big}) | ((${b})[${JSON.stringify(flags[i])}] ? ${1n << BigInt(i)}n : 0n)`;
|
||||||
|
}
|
||||||
|
const v = ctx.fresh('v');
|
||||||
|
seg.addBounded(
|
||||||
|
`{ const ${b} = ${vx}; let ${v} = ${big}; while (${v} >= 0x80n) { buf[pos++] = Number(${v} & 0x7fn) | 0x80; ${v} >>= 7n; } buf[pos++] = Number(${v}); }`,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'str': {
|
||||||
|
seg.addUnbounded(
|
||||||
|
`w.pos = pos; w.str(${vx}); pos = w.pos; buf = w.buf; view = w.view;`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'bytes': {
|
||||||
|
seg.addUnbounded(
|
||||||
|
`w.pos = pos; w.bytesPrefixed(${vx}); pos = w.pos; buf = w.buf; view = w.view;`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'tuple': {
|
||||||
|
const t = ctx.fresh('t');
|
||||||
|
seg.addPrelude(`const ${t} = ${vx};`);
|
||||||
|
for (let i = 0; i < schema.elems.length; i++) {
|
||||||
|
emitEnc(schema.elems[i]!, ctx, seg, `${t}[${i}]`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'optional': {
|
||||||
|
const o = ctx.fresh('o');
|
||||||
|
const innerMax = maxBytes(schema.elem);
|
||||||
|
if (innerMax !== null) {
|
||||||
|
const innerSeg = new SegBuilder();
|
||||||
|
innerSeg.noEnsure = true;
|
||||||
|
emitEnc(schema.elem, ctx, innerSeg, o);
|
||||||
|
const innerSrc = innerSeg.build();
|
||||||
|
seg.addBounded(
|
||||||
|
`{ const ${o} = ${vx}; if (${o} === undefined || ${o} === null) { buf[pos++] = 0; } else { buf[pos++] = 1; ${innerSrc} } }`,
|
||||||
|
1 + innerMax,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const innerSeg = new SegBuilder();
|
||||||
|
emitEnc(schema.elem, ctx, innerSeg, o);
|
||||||
|
const innerSrc = innerSeg.build();
|
||||||
|
seg.addUnbounded(
|
||||||
|
`{ const ${o} = ${vx}; if (pos + 1 > buf.byteLength) { w.pos = pos; w.grow(1); buf = w.buf; view = w.view; } if (${o} === undefined || ${o} === null) { buf[pos++] = 0; } else { buf[pos++] = 1; ${innerSrc} } }`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'object': {
|
||||||
|
const oo = ctx.fresh('oo');
|
||||||
|
seg.addPrelude(`const ${oo} = ${vx};`);
|
||||||
|
for (const fname of Object.keys(schema.fields)) {
|
||||||
|
emitEnc(schema.fields[fname]!, ctx, seg, `${oo}[${JSON.stringify(fname)}]`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'union': {
|
||||||
|
const u = ctx.fresh('u');
|
||||||
|
const disc = JSON.stringify(schema.discriminator);
|
||||||
|
const keys = Object.keys(schema.variants);
|
||||||
|
const cases: string[] = [];
|
||||||
|
for (let i = 0; i < keys.length; i++) {
|
||||||
|
const variant = schema.variants[keys[i]!]!;
|
||||||
|
const innerSeg = new SegBuilder();
|
||||||
|
innerSeg.addBounded(`buf[pos++] = ${i};`, 1);
|
||||||
|
for (const fname of Object.keys(variant.fields)) {
|
||||||
|
emitEnc(variant.fields[fname]!, ctx, innerSeg, `${u}[${JSON.stringify(fname)}]`);
|
||||||
|
}
|
||||||
|
cases.push(`case ${JSON.stringify(keys[i])}: { ${innerSeg.build()} break; }`);
|
||||||
|
}
|
||||||
|
seg.addUnbounded(
|
||||||
|
`{ const ${u} = ${vx}; switch (${u}[${disc}]) { ${cases.join('\n')} default: throw new Error('Bad union variant: ' + ${u}[${disc}]); } }`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'array': {
|
||||||
|
const arr = ctx.fresh('arr');
|
||||||
|
const L = ctx.fresh('L');
|
||||||
|
const i = ctx.fresh('i');
|
||||||
|
const elemMax = maxBytes(schema.elem);
|
||||||
|
|
||||||
|
seg.addPrelude(`const ${arr} = ${vx}; const ${L} = ${arr}.length;`);
|
||||||
|
const vL = ctx.fresh('vL');
|
||||||
|
seg.addBounded(
|
||||||
|
`{ let ${vL} = ${L}; while (${vL} >= 0x80) { buf[pos++] = (${vL} & 0x7f) | 0x80; ${vL} = Math.floor(${vL} / 128); } buf[pos++] = ${vL}; }`,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (elemMax !== null) {
|
||||||
|
seg.addUnbounded(
|
||||||
|
`if (pos + ${L} * ${elemMax} > buf.byteLength) { w.pos = pos; w.grow(${L} * ${elemMax}); buf = w.buf; view = w.view; }`,
|
||||||
|
);
|
||||||
|
const elemSeg = new SegBuilder();
|
||||||
|
elemSeg.noEnsure = true;
|
||||||
|
emitEnc(schema.elem, ctx, elemSeg, `${arr}[${i}]`);
|
||||||
|
const elemSrc = elemSeg.build();
|
||||||
|
seg.addUnbounded(`for (let ${i} = 0; ${i} < ${L}; ${i}++) { ${elemSrc} }`);
|
||||||
|
} else {
|
||||||
|
const elemSeg = new SegBuilder();
|
||||||
|
emitEnc(schema.elem, ctx, elemSeg, `${arr}[${i}]`);
|
||||||
|
const elemSrc = elemSeg.build();
|
||||||
|
seg.addUnbounded(`for (let ${i} = 0; ${i} < ${L}; ${i}++) { ${elemSrc} }`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'ref': {
|
||||||
|
const resolved = schema.thunk();
|
||||||
|
const dep = `__enc_${sanitize(resolved.name)}`;
|
||||||
|
ctx.deps.set(dep, { mode: 'enc', targetName: resolved.name });
|
||||||
|
seg.addUnbounded(
|
||||||
|
`w.pos = pos; ${dep}(w, ${vx}); pos = w.pos; buf = w.buf; view = w.view;`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'codec': {
|
||||||
|
const cv = ctx.closureVar('codec_enc', schema.encode);
|
||||||
|
seg.addUnbounded(
|
||||||
|
`w.pos = pos; ${cv}(w, ${vx}); pos = w.pos; buf = w.buf; view = w.view;`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isTypedArrayKind(schema.kind)) {
|
||||||
|
const ta = ctx.fresh('ta');
|
||||||
|
seg.addUnbounded(
|
||||||
|
`{ const ${ta} = ${vx}; w.pos = pos; w.varu53(${ta}.length); w.raw(new Uint8Array(${ta}.buffer, ${ta}.byteOffset, ${ta}.byteLength)); pos = w.pos; buf = w.buf; view = w.view; }`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`emitEnc: unknown kind ${(schema as { kind: string }).kind}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitDec(schema: AnySchema, ctx: Ctx): { pre: string; expr: string } {
|
||||||
|
switch (schema.kind) {
|
||||||
|
case 'u8':
|
||||||
|
return { pre: '', expr: 'buf[pos++]' };
|
||||||
|
case 'i8':
|
||||||
|
return { pre: '', expr: '(buf[pos++] << 24 >> 24)' };
|
||||||
|
case 'bool':
|
||||||
|
return { pre: '', expr: '(buf[pos++] !== 0)' };
|
||||||
|
case 'u16': {
|
||||||
|
const v = ctx.fresh('v');
|
||||||
|
return { pre: `const ${v} = view.getUint16(pos, true); pos += 2;`, expr: v };
|
||||||
|
}
|
||||||
|
case 'i16': {
|
||||||
|
const v = ctx.fresh('v');
|
||||||
|
return { pre: `const ${v} = view.getInt16(pos, true); pos += 2;`, expr: v };
|
||||||
|
}
|
||||||
|
case 'u32': {
|
||||||
|
const v = ctx.fresh('v');
|
||||||
|
return { pre: `const ${v} = view.getUint32(pos, true); pos += 4;`, expr: v };
|
||||||
|
}
|
||||||
|
case 'i32': {
|
||||||
|
const v = ctx.fresh('v');
|
||||||
|
return { pre: `const ${v} = view.getInt32(pos, true); pos += 4;`, expr: v };
|
||||||
|
}
|
||||||
|
case 'f32': {
|
||||||
|
const v = ctx.fresh('v');
|
||||||
|
return { pre: `const ${v} = view.getFloat32(pos, true); pos += 4;`, expr: v };
|
||||||
|
}
|
||||||
|
case 'f64': {
|
||||||
|
const v = ctx.fresh('v');
|
||||||
|
return { pre: `const ${v} = view.getFloat64(pos, true); pos += 8;`, expr: v };
|
||||||
|
}
|
||||||
|
case 'u53': {
|
||||||
|
const v = ctx.fresh('v');
|
||||||
|
const vv = ctx.fresh('vv');
|
||||||
|
const m = ctx.fresh('m');
|
||||||
|
const b = ctx.fresh('b');
|
||||||
|
return {
|
||||||
|
pre: `let ${v}; { let ${vv} = 0, ${m} = 1, ${b}; do { ${b} = buf[pos++]; ${vv} += (${b} & 0x7f) * ${m}; ${m} *= 128; } while (${b} & 0x80); ${v} = ${vv}; }`,
|
||||||
|
expr: v,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'i53': {
|
||||||
|
const v = ctx.fresh('v');
|
||||||
|
const vv = ctx.fresh('vv');
|
||||||
|
const sh = ctx.fresh('sh');
|
||||||
|
const b = ctx.fresh('b');
|
||||||
|
return {
|
||||||
|
pre: `let ${v}; { let ${vv} = 0n, ${sh} = 0n, ${b}; do { ${b} = buf[pos++]; ${vv} |= BigInt(${b} & 0x7f) << ${sh}; ${sh} += 7n; } while (${b} & 0x80); const _z = (${vv} & 1n) === 0n ? ${vv} >> 1n : -((${vv} >> 1n) + 1n); ${v} = Number(_z); }`,
|
||||||
|
expr: v,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'u64': {
|
||||||
|
const v = ctx.fresh('v');
|
||||||
|
const vv = ctx.fresh('vv');
|
||||||
|
const sh = ctx.fresh('sh');
|
||||||
|
const b = ctx.fresh('b');
|
||||||
|
return {
|
||||||
|
pre: `let ${v}; { let ${vv} = 0n, ${sh} = 0n, ${b}; do { ${b} = buf[pos++]; ${vv} |= BigInt(${b} & 0x7f) << ${sh}; ${sh} += 7n; } while (${b} & 0x80); ${v} = ${vv}; }`,
|
||||||
|
expr: v,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'i64': {
|
||||||
|
const v = ctx.fresh('v');
|
||||||
|
const vv = ctx.fresh('vv');
|
||||||
|
const sh = ctx.fresh('sh');
|
||||||
|
const b = ctx.fresh('b');
|
||||||
|
return {
|
||||||
|
pre: `let ${v}; { let ${vv} = 0n, ${sh} = 0n, ${b}; do { ${b} = buf[pos++]; ${vv} |= BigInt(${b} & 0x7f) << ${sh}; ${sh} += 7n; } while (${b} & 0x80); ${v} = (${vv} & 1n) === 0n ? ${vv} >> 1n : -((${vv} >> 1n) + 1n); }`,
|
||||||
|
expr: v,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'enum': {
|
||||||
|
const vals = schema.values;
|
||||||
|
if (vals.length <= 2) {
|
||||||
|
return {
|
||||||
|
pre: '',
|
||||||
|
expr: `(buf[pos++] === 0 ? ${JSON.stringify(vals[0])} : ${JSON.stringify(vals[1])})`,
|
||||||
|
};
|
||||||
|
} else if (vals.length === 3) {
|
||||||
|
const v = ctx.fresh('v');
|
||||||
|
const t = ctx.fresh('t');
|
||||||
|
return {
|
||||||
|
pre: `let ${v}; { const ${t} = buf[pos++]; ${v} = ${t} === 0 ? ${JSON.stringify(vals[0])} : ${t} === 1 ? ${JSON.stringify(vals[1])} : ${JSON.stringify(vals[2])}; }`,
|
||||||
|
expr: v,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const cv = ctx.closureVar('enum_dec', Object.freeze(vals.slice()));
|
||||||
|
return { pre: '', expr: `${cv}[buf[pos++]]` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'bitset': {
|
||||||
|
const v = ctx.fresh('v');
|
||||||
|
const raw = ctx.fresh('raw');
|
||||||
|
const flags = schema.flags;
|
||||||
|
let rawDecl: string;
|
||||||
|
let isBig = false;
|
||||||
|
if (flags.length <= 8) {
|
||||||
|
rawDecl = `const ${raw} = buf[pos++];`;
|
||||||
|
} else if (flags.length <= 16) {
|
||||||
|
rawDecl = `const ${raw} = view.getUint16(pos, true); pos += 2;`;
|
||||||
|
} else if (flags.length <= 32) {
|
||||||
|
rawDecl = `const ${raw} = view.getUint32(pos, true); pos += 4;`;
|
||||||
|
} else {
|
||||||
|
isBig = true;
|
||||||
|
const vv = ctx.fresh('vv');
|
||||||
|
const sh = ctx.fresh('sh');
|
||||||
|
const b = ctx.fresh('b');
|
||||||
|
rawDecl = `let ${raw}; { let ${vv} = 0n, ${sh} = 0n, ${b}; do { ${b} = buf[pos++]; ${vv} |= BigInt(${b} & 0x7f) << ${sh}; ${sh} += 7n; } while (${b} & 0x80); ${raw} = ${vv}; }`;
|
||||||
|
}
|
||||||
|
const props: string[] = [];
|
||||||
|
for (let i = 0; i < flags.length; i++) {
|
||||||
|
if (isBig) {
|
||||||
|
props.push(`${JSON.stringify(flags[i])}: (${raw} & ${1n << BigInt(i)}n) !== 0n`);
|
||||||
|
} else {
|
||||||
|
props.push(`${JSON.stringify(flags[i])}: (${raw} & ${1 << i}) !== 0`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
pre: `${rawDecl} const ${v} = { ${props.join(', ')} };`,
|
||||||
|
expr: v,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'str': {
|
||||||
|
const v = ctx.fresh('v');
|
||||||
|
return {
|
||||||
|
pre: `r.pos = pos; const ${v} = r.str(); pos = r.pos;`,
|
||||||
|
expr: v,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'bytes': {
|
||||||
|
const v = ctx.fresh('v');
|
||||||
|
return {
|
||||||
|
pre: `r.pos = pos; const ${v} = r.bytesPrefixed(); pos = r.pos;`,
|
||||||
|
expr: v,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'tuple': {
|
||||||
|
let pre = '';
|
||||||
|
const exprs: string[] = [];
|
||||||
|
for (const elem of schema.elems) {
|
||||||
|
const inner = emitDec(elem, ctx);
|
||||||
|
if (inner.pre === '') {
|
||||||
|
exprs.push(inner.expr);
|
||||||
|
} else {
|
||||||
|
const tmp = ctx.fresh('tupv');
|
||||||
|
pre += `${inner.pre} const ${tmp} = ${inner.expr};`;
|
||||||
|
exprs.push(tmp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const tup = ctx.fresh('tup');
|
||||||
|
return { pre: `${pre} const ${tup} = [${exprs.join(', ')}];`, expr: tup };
|
||||||
|
}
|
||||||
|
case 'optional': {
|
||||||
|
const v = ctx.fresh('opt');
|
||||||
|
const inner = emitDec(schema.elem, ctx);
|
||||||
|
return {
|
||||||
|
pre: `let ${v} = undefined; if (buf[pos++] !== 0) { ${inner.pre} ${v} = ${inner.expr}; }`,
|
||||||
|
expr: v,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'object': {
|
||||||
|
let pre = '';
|
||||||
|
const props: string[] = [];
|
||||||
|
for (const fname of Object.keys(schema.fields)) {
|
||||||
|
const inner = emitDec(schema.fields[fname]!, ctx);
|
||||||
|
const tmp = ctx.fresh(`f_${sanitize(fname)}`);
|
||||||
|
pre += `${inner.pre} const ${tmp} = ${inner.expr};`;
|
||||||
|
props.push(`${JSON.stringify(fname)}: ${tmp}`);
|
||||||
|
}
|
||||||
|
const obj = ctx.fresh('obj');
|
||||||
|
return { pre: `${pre} const ${obj} = { ${props.join(', ')} };`, expr: obj };
|
||||||
|
}
|
||||||
|
case 'union': {
|
||||||
|
const v = ctx.fresh('un');
|
||||||
|
const disc = JSON.stringify(schema.discriminator);
|
||||||
|
const keys = Object.keys(schema.variants);
|
||||||
|
const cases: string[] = [];
|
||||||
|
for (let i = 0; i < keys.length; i++) {
|
||||||
|
const variant = schema.variants[keys[i]!]!;
|
||||||
|
let varPre = '';
|
||||||
|
const props: string[] = [`${disc}: ${JSON.stringify(keys[i])}`];
|
||||||
|
for (const fname of Object.keys(variant.fields)) {
|
||||||
|
const inner = emitDec(variant.fields[fname]!, ctx);
|
||||||
|
if (inner.pre === '') {
|
||||||
|
props.push(`${JSON.stringify(fname)}: ${inner.expr}`);
|
||||||
|
} else {
|
||||||
|
const tmp = ctx.fresh('uv');
|
||||||
|
varPre += `${inner.pre} const ${tmp} = ${inner.expr};`;
|
||||||
|
props.push(`${JSON.stringify(fname)}: ${tmp}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cases.push(`case ${i}: { ${varPre} ${v} = { ${props.join(', ')} }; break; }`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
pre: `let ${v}; switch (buf[pos++]) { ${cases.join(' ')} default: throw new Error('Bad union tag'); }`,
|
||||||
|
expr: v,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'array': {
|
||||||
|
const a = ctx.fresh('arr');
|
||||||
|
const L = ctx.fresh('len');
|
||||||
|
const i = ctx.fresh('i');
|
||||||
|
const vv = ctx.fresh('vv');
|
||||||
|
const m = ctx.fresh('m');
|
||||||
|
const b = ctx.fresh('b');
|
||||||
|
const elem = emitDec(schema.elem, ctx);
|
||||||
|
const lenRead = `let ${L}; { let ${vv} = 0, ${m} = 1, ${b}; do { ${b} = buf[pos++]; ${vv} += (${b} & 0x7f) * ${m}; ${m} *= 128; } while (${b} & 0x80); ${L} = ${vv}; }`;
|
||||||
|
return {
|
||||||
|
pre: `${lenRead} const ${a} = new Array(${L}); for (let ${i} = 0; ${i} < ${L}; ${i}++) { ${elem.pre} ${a}[${i}] = ${elem.expr}; }`,
|
||||||
|
expr: a,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'ref': {
|
||||||
|
const resolved = schema.thunk();
|
||||||
|
const dep = `__dec_${sanitize(resolved.name)}`;
|
||||||
|
ctx.deps.set(dep, { mode: 'dec', targetName: resolved.name });
|
||||||
|
const v = ctx.fresh('v');
|
||||||
|
return {
|
||||||
|
pre: `r.pos = pos; const ${v} = ${dep}(r); pos = r.pos;`,
|
||||||
|
expr: v,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'codec': {
|
||||||
|
const cv = ctx.closureVar('codec_dec', schema.decode);
|
||||||
|
const v = ctx.fresh('v');
|
||||||
|
return {
|
||||||
|
pre: `r.pos = pos; const ${v} = ${cv}(r); pos = r.pos;`,
|
||||||
|
expr: v,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isTypedArrayKind(schema.kind)) {
|
||||||
|
const len = ctx.fresh('talen');
|
||||||
|
const arr = ctx.fresh('ta');
|
||||||
|
const size = TA_ELEM_SIZE[schema.kind];
|
||||||
|
const ctor = TA_CTOR[schema.kind];
|
||||||
|
const vv = ctx.fresh('vv');
|
||||||
|
const m = ctx.fresh('m');
|
||||||
|
const b = ctx.fresh('b');
|
||||||
|
return {
|
||||||
|
pre: `let ${len}; { let ${vv} = 0, ${m} = 1, ${b}; do { ${b} = buf[pos++]; ${vv} += (${b} & 0x7f) * ${m}; ${m} *= 128; } while (${b} & 0x80); ${len} = ${vv}; } const ${arr} = new ${ctor}(${len}); { const _bytes = buf.subarray(pos, pos + ${len} * ${size}); new Uint8Array(${arr}.buffer, ${arr}.byteOffset, ${len} * ${size}).set(_bytes); pos += ${len} * ${size}; }`,
|
||||||
|
expr: arr,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error(`emitDec: unknown kind ${(schema as { kind: string }).kind}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BARE_IDENT = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
|
||||||
|
|
||||||
|
export function compileObject(schema: ObjectSchema): CodegenResult {
|
||||||
|
const encCtx = new Ctx('enc');
|
||||||
|
const decCtx = new Ctx('dec');
|
||||||
|
|
||||||
|
// Encoder body
|
||||||
|
const encSeg = new SegBuilder();
|
||||||
|
for (const fname of Object.keys(schema.fields)) {
|
||||||
|
emitEnc(schema.fields[fname]!, encCtx, encSeg, `o[${JSON.stringify(fname)}]`);
|
||||||
|
}
|
||||||
|
const encodeBody = encSeg.build();
|
||||||
|
|
||||||
|
// Decoder body: emit as inline object literal in return statement.
|
||||||
|
// For fields whose inner.expr is already a bare identifier (declared via inner.pre),
|
||||||
|
// skip the wrapping `const tmp = expr;` and use the identifier directly.
|
||||||
|
let pre = '';
|
||||||
|
const props: string[] = [];
|
||||||
|
for (const fname of Object.keys(schema.fields)) {
|
||||||
|
const inner = emitDec(schema.fields[fname]!, decCtx);
|
||||||
|
if (inner.pre !== '' && BARE_IDENT.test(inner.expr)) {
|
||||||
|
pre += inner.pre;
|
||||||
|
props.push(`${JSON.stringify(fname)}: ${inner.expr}`);
|
||||||
|
} else {
|
||||||
|
const tmp = decCtx.fresh(`f_${sanitize(fname)}`);
|
||||||
|
pre += `${inner.pre} const ${tmp} = ${inner.expr};`;
|
||||||
|
props.push(`${JSON.stringify(fname)}: ${tmp}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const decodeBody = `${pre} r.pos = pos; return { ${props.join(', ')} };`;
|
||||||
|
|
||||||
|
const deps = new Map<string, { mode: 'enc' | 'dec'; targetName: string }>();
|
||||||
|
for (const [k, v] of encCtx.deps) deps.set(k, v);
|
||||||
|
for (const [k, v] of decCtx.deps) deps.set(k, v);
|
||||||
|
const closure = new Map<string, unknown>();
|
||||||
|
for (const [k, v] of encCtx.closure) closure.set(k, v);
|
||||||
|
for (const [k, v] of decCtx.closure) closure.set(k, v);
|
||||||
|
return { encodeBody, decodeBody, deps, closure };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compileUnion(schema: UnionSchema): CodegenResult {
|
||||||
|
const encCtx = new Ctx('enc');
|
||||||
|
const decCtx = new Ctx('dec');
|
||||||
|
|
||||||
|
const encSeg = new SegBuilder();
|
||||||
|
emitEnc(schema, encCtx, encSeg, 'o');
|
||||||
|
const encodeBody = encSeg.build();
|
||||||
|
|
||||||
|
const decRes = emitDec(schema, decCtx);
|
||||||
|
const decodeBody = `${decRes.pre} r.pos = pos; return ${decRes.expr};`;
|
||||||
|
|
||||||
|
const deps = new Map<string, { mode: 'enc' | 'dec'; targetName: string }>();
|
||||||
|
for (const [k, v] of encCtx.deps) deps.set(k, v);
|
||||||
|
for (const [k, v] of decCtx.deps) deps.set(k, v);
|
||||||
|
const closure = new Map<string, unknown>();
|
||||||
|
for (const [k, v] of encCtx.closure) closure.set(k, v);
|
||||||
|
for (const [k, v] of decCtx.closure) closure.set(k, v);
|
||||||
|
return { encodeBody, decodeBody, deps, closure };
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { transform, type TransformOptions, type TransformResult } from './transformer.ts';
|
||||||
|
export { serializerCodegen, type SerializerPluginOptions, type VitePlugin } from './vite.ts';
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
/**
|
||||||
|
* Compile-only transformer for @perf/serializer.
|
||||||
|
*
|
||||||
|
* Detects `type(...)` and `oneOf(...)` calls in the source, statically
|
||||||
|
* evaluates their arguments to ObjectSchema/UnionSchema descriptors, runs
|
||||||
|
* the existing codegen, and replaces each call with a self-contained IIFE
|
||||||
|
* that constructs the codec inline — no runtime `new Function`, no codegen
|
||||||
|
* module needed at runtime.
|
||||||
|
*
|
||||||
|
* Scope (v1):
|
||||||
|
* - Same-file only (no cross-file schema references).
|
||||||
|
* - Top-level `const X = type(...)` declarations (including `export const`).
|
||||||
|
* - Field values may be:
|
||||||
|
* • imported primitive markers (u8 … f64, bool, str, bytes, *Array)
|
||||||
|
* • calls to imported combinators (list, opt, enumOf, flags, tuple)
|
||||||
|
* • identifier references to previously-defined codecs in the file
|
||||||
|
* • inline ObjectExpression literals
|
||||||
|
* - `enumOf` / `flags` array args may be plain arrays or `[...] as const`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { parseSync } from 'oxc-parser';
|
||||||
|
import MagicString from 'magic-string';
|
||||||
|
import { compileObject, compileUnion } from '../codegen.ts';
|
||||||
|
import { s } from '../schema.ts';
|
||||||
|
import type {
|
||||||
|
AnySchema,
|
||||||
|
ObjectSchema,
|
||||||
|
UnionSchema,
|
||||||
|
} from '../descriptors.ts';
|
||||||
|
|
||||||
|
const PKG_NAMES = new Set([
|
||||||
|
'@perf/serializer',
|
||||||
|
'@perf/serializer/index',
|
||||||
|
]);
|
||||||
|
|
||||||
|
interface ImportInfo {
|
||||||
|
bindings: Map<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompiledCodec {
|
||||||
|
schemaName: string;
|
||||||
|
schemaKind: 'object' | 'union';
|
||||||
|
fieldsDescriptor: string;
|
||||||
|
encodeBody: string;
|
||||||
|
decodeBody: string;
|
||||||
|
closure: Map<string, unknown>;
|
||||||
|
deps: Map<string, { mode: 'enc' | 'dec'; targetName: string }>;
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fnv1a16(s: string): number {
|
||||||
|
let h = 0x811c9dc5;
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
h ^= s.charCodeAt(i);
|
||||||
|
h = Math.imul(h, 0x01000193);
|
||||||
|
}
|
||||||
|
return ((h >>> 16) ^ h) & 0xffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIMITIVES = new Set([
|
||||||
|
'u8', 'u16', 'u32', 'i8', 'i16', 'i32',
|
||||||
|
'u53', 'i53', 'u64', 'i64', 'f32', 'f64',
|
||||||
|
'bool', 'str', 'bytes',
|
||||||
|
'f32Array', 'f64Array', 'u8Array', 'u16Array', 'u32Array', 'i32Array',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// oxc AST nodes vary widely per `type`; we treat them as loosely-typed objects
|
||||||
|
// and check `.type` before reading per-shape fields.
|
||||||
|
type AnyNode = Record<string, any>;
|
||||||
|
|
||||||
|
function collectImportsFromSet(program: AnyNode, aliases: Set<string>): ImportInfo {
|
||||||
|
const bindings = new Map<string, string>();
|
||||||
|
for (const stmt of program.body as AnyNode[]) {
|
||||||
|
if (stmt.type !== 'ImportDeclaration') continue;
|
||||||
|
const source = stmt.source.value as string;
|
||||||
|
if (!aliases.has(source)) continue;
|
||||||
|
for (const spec of stmt.specifiers as AnyNode[]) {
|
||||||
|
if (spec.type === 'ImportSpecifier') {
|
||||||
|
const local = spec.local.name as string;
|
||||||
|
const importedName = (spec.imported.name ?? spec.imported.value) as string;
|
||||||
|
bindings.set(local, importedName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { bindings };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Scope {
|
||||||
|
imports: ImportInfo;
|
||||||
|
locals: Map<string, AnySchema>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let anonCounter = 0;
|
||||||
|
|
||||||
|
function evalExpr(node: AnyNode, scope: Scope): AnySchema | null {
|
||||||
|
switch (node.type) {
|
||||||
|
case 'Identifier': {
|
||||||
|
const name = node.name as string;
|
||||||
|
const exported = scope.imports.bindings.get(name);
|
||||||
|
if (exported && PRIMITIVES.has(exported)) {
|
||||||
|
const prim = (s as unknown as Record<string, AnySchema>)[exported];
|
||||||
|
if (prim) return prim;
|
||||||
|
}
|
||||||
|
const local = scope.locals.get(name);
|
||||||
|
if (local) return local;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
case 'CallExpression': {
|
||||||
|
const callee = node.callee as AnyNode;
|
||||||
|
if (callee.type !== 'Identifier') return null;
|
||||||
|
const exported = scope.imports.bindings.get(callee.name as string);
|
||||||
|
if (!exported) return null;
|
||||||
|
const args = node.arguments as AnyNode[];
|
||||||
|
switch (exported) {
|
||||||
|
case 'list': {
|
||||||
|
if (args.length !== 1) return null;
|
||||||
|
const elem = evalExpr(args[0]!, scope);
|
||||||
|
return elem ? s.array(elem) : null;
|
||||||
|
}
|
||||||
|
case 'opt': {
|
||||||
|
if (args.length !== 1) return null;
|
||||||
|
const elem = evalExpr(args[0]!, scope);
|
||||||
|
return elem ? s.optional(elem) : null;
|
||||||
|
}
|
||||||
|
case 'enumOf': {
|
||||||
|
if (args.length !== 1) return null;
|
||||||
|
const arr = unwrapAsConst(args[0]!);
|
||||||
|
if (!arr || arr.type !== 'ArrayExpression') return null;
|
||||||
|
const values: string[] = [];
|
||||||
|
for (const el of arr.elements as AnyNode[]) {
|
||||||
|
if (el && el.type === 'Literal' && typeof el.value === 'string') {
|
||||||
|
values.push(el.value as string);
|
||||||
|
} else return null;
|
||||||
|
}
|
||||||
|
return s.enum(values);
|
||||||
|
}
|
||||||
|
case 'flags': {
|
||||||
|
if (args.length !== 1) return null;
|
||||||
|
const arr = unwrapAsConst(args[0]!);
|
||||||
|
if (!arr || arr.type !== 'ArrayExpression') return null;
|
||||||
|
const names: string[] = [];
|
||||||
|
for (const el of arr.elements as AnyNode[]) {
|
||||||
|
if (el && el.type === 'Literal' && typeof el.value === 'string') {
|
||||||
|
names.push(el.value as string);
|
||||||
|
} else return null;
|
||||||
|
}
|
||||||
|
return s.bitset(names);
|
||||||
|
}
|
||||||
|
case 'tuple': {
|
||||||
|
const elems: AnySchema[] = [];
|
||||||
|
for (const a of args) {
|
||||||
|
const e = evalExpr(a, scope);
|
||||||
|
if (!e) return null;
|
||||||
|
elems.push(e);
|
||||||
|
}
|
||||||
|
return s.tuple(...elems);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'ObjectExpression': {
|
||||||
|
const fields = collectFields(node, scope);
|
||||||
|
if (!fields) return null;
|
||||||
|
const inlineName = `__InlineObj_${anonCounter++}`;
|
||||||
|
return { kind: 'object' as const, name: inlineName, fields };
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapAsConst(node: AnyNode): AnyNode | null {
|
||||||
|
if (node.type === 'TSAsExpression' || node.type === 'TSSatisfiesExpression') {
|
||||||
|
return node.expression as AnyNode;
|
||||||
|
}
|
||||||
|
if (node.type === 'ArrayExpression') return node;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectFields(obj: AnyNode, scope: Scope): Record<string, AnySchema> | null {
|
||||||
|
const fields: Record<string, AnySchema> = {};
|
||||||
|
for (const prop of obj.properties as AnyNode[]) {
|
||||||
|
if (prop.type !== 'Property') return null;
|
||||||
|
const key = prop.key as AnyNode;
|
||||||
|
let fname: string;
|
||||||
|
if (key.type === 'Identifier') fname = key.name as string;
|
||||||
|
else if (key.type === 'Literal' && typeof key.value === 'string') fname = key.value as string;
|
||||||
|
else return null;
|
||||||
|
const sub = evalExpr(prop.value as AnyNode, scope);
|
||||||
|
if (!sub) return null;
|
||||||
|
fields[fname] = sub;
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TypeCallInfo {
|
||||||
|
call: AnyNode;
|
||||||
|
declName: string;
|
||||||
|
fn: 'type' | 'oneOf';
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTypeCalls(program: AnyNode, imports: ImportInfo): TypeCallInfo[] {
|
||||||
|
const calls: TypeCallInfo[] = [];
|
||||||
|
for (const topStmt of program.body as AnyNode[]) {
|
||||||
|
const stmt: AnyNode =
|
||||||
|
topStmt.type === 'ExportNamedDeclaration' && topStmt.declaration
|
||||||
|
? (topStmt.declaration as AnyNode)
|
||||||
|
: topStmt;
|
||||||
|
if (stmt.type !== 'VariableDeclaration') continue;
|
||||||
|
for (const decl of stmt.declarations as AnyNode[]) {
|
||||||
|
const id = decl.id as AnyNode;
|
||||||
|
const init = decl.init as AnyNode | null;
|
||||||
|
if (!init || id.type !== 'Identifier' || init.type !== 'CallExpression') continue;
|
||||||
|
const callee = init.callee as AnyNode;
|
||||||
|
if (callee.type !== 'Identifier') continue;
|
||||||
|
const exported = imports.bindings.get(callee.name as string);
|
||||||
|
if (exported === 'type' || exported === 'oneOf') {
|
||||||
|
calls.push({ call: init, declName: id.name as string, fn: exported });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSchemaFromTypeCall(
|
||||||
|
info: TypeCallInfo,
|
||||||
|
scope: Scope,
|
||||||
|
): ObjectSchema | UnionSchema | null {
|
||||||
|
const args = info.call.arguments as AnyNode[];
|
||||||
|
if (info.fn === 'type') {
|
||||||
|
let name: string;
|
||||||
|
let fieldsExpr: AnyNode;
|
||||||
|
if (args.length >= 2 && args[0]!.type === 'Literal' && typeof args[0]!.value === 'string') {
|
||||||
|
name = args[0]!.value as string;
|
||||||
|
fieldsExpr = args[1]!;
|
||||||
|
} else {
|
||||||
|
name = info.declName;
|
||||||
|
fieldsExpr = args[0]!;
|
||||||
|
}
|
||||||
|
if (fieldsExpr.type !== 'ObjectExpression') return null;
|
||||||
|
const fields = collectFields(fieldsExpr, scope);
|
||||||
|
if (!fields) return null;
|
||||||
|
return { kind: 'object', name, fields };
|
||||||
|
} else {
|
||||||
|
let name: string;
|
||||||
|
let disc: string;
|
||||||
|
let variantsExpr: AnyNode;
|
||||||
|
if (
|
||||||
|
args.length >= 3 &&
|
||||||
|
args[0]!.type === 'Literal' &&
|
||||||
|
typeof args[0]!.value === 'string'
|
||||||
|
) {
|
||||||
|
name = args[0]!.value as string;
|
||||||
|
const discNode = args[1]!;
|
||||||
|
if (discNode.type !== 'Literal' || typeof discNode.value !== 'string') return null;
|
||||||
|
disc = discNode.value as string;
|
||||||
|
variantsExpr = args[2]!;
|
||||||
|
} else if (args.length >= 2) {
|
||||||
|
name = info.declName;
|
||||||
|
const discNode = args[0]!;
|
||||||
|
if (discNode.type !== 'Literal' || typeof discNode.value !== 'string') return null;
|
||||||
|
disc = discNode.value as string;
|
||||||
|
variantsExpr = args[1]!;
|
||||||
|
} else return null;
|
||||||
|
|
||||||
|
if (variantsExpr.type !== 'ObjectExpression') return null;
|
||||||
|
const variants: Record<string, Record<string, AnySchema>> = {};
|
||||||
|
for (const prop of variantsExpr.properties as AnyNode[]) {
|
||||||
|
if (prop.type !== 'Property') return null;
|
||||||
|
const key = prop.key as AnyNode;
|
||||||
|
const variantName =
|
||||||
|
key.type === 'Identifier' ? (key.name as string)
|
||||||
|
: key.type === 'Literal' && typeof key.value === 'string' ? (key.value as string)
|
||||||
|
: null;
|
||||||
|
if (!variantName) return null;
|
||||||
|
const variantFields = prop.value as AnyNode;
|
||||||
|
if (variantFields.type !== 'ObjectExpression') return null;
|
||||||
|
const fields = collectFields(variantFields, scope);
|
||||||
|
if (!fields) return null;
|
||||||
|
variants[variantName] = fields;
|
||||||
|
}
|
||||||
|
return s.union(name, disc, variants);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitDescriptorLiteral(schema: AnySchema): string {
|
||||||
|
return JSON.stringify(schema, (key, value) => {
|
||||||
|
if (key === 'thunk' || typeof value === 'function') return undefined;
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileCall(
|
||||||
|
info: TypeCallInfo,
|
||||||
|
scope: Scope,
|
||||||
|
): { src: string; compiled: CompiledCodec } | null {
|
||||||
|
const schema = buildSchemaFromTypeCall(info, scope);
|
||||||
|
if (!schema) return null;
|
||||||
|
|
||||||
|
const cg = schema.kind === 'object' ? compileObject(schema) : compileUnion(schema);
|
||||||
|
const id = fnv1a16(schema.name);
|
||||||
|
const fname = sanitize(schema.name);
|
||||||
|
if (cg.deps.size > 0) return null; // ref/codec deps not supported yet
|
||||||
|
|
||||||
|
const closureLines: string[] = [];
|
||||||
|
for (const [k, v] of cg.closure) {
|
||||||
|
closureLines.push(`const ${k} = ${serializeClosureValue(v)};`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const descriptorLit = emitDescriptorLiteral(schema);
|
||||||
|
|
||||||
|
const src = `(function () {
|
||||||
|
${closureLines.join('\n ')}
|
||||||
|
function encode_${fname}(w, o) {
|
||||||
|
let pos = w.pos;
|
||||||
|
let buf = w.buf;
|
||||||
|
let view = w.view;
|
||||||
|
${cg.encodeBody}
|
||||||
|
w.pos = pos;
|
||||||
|
}
|
||||||
|
function decode_${fname}(r) {
|
||||||
|
let pos = r.pos;
|
||||||
|
const buf = r.buf;
|
||||||
|
const view = r.view;
|
||||||
|
${cg.decodeBody}
|
||||||
|
}
|
||||||
|
const __desc = ${descriptorLit};
|
||||||
|
const __codec = {
|
||||||
|
...__desc,
|
||||||
|
id: ${id},
|
||||||
|
encode(v, into) {
|
||||||
|
if (into) { encode_${fname}(into, v); return into.bytes(); }
|
||||||
|
const w = new __SerWriter();
|
||||||
|
encode_${fname}(w, v);
|
||||||
|
return w.bytesCopy();
|
||||||
|
},
|
||||||
|
decode(b) {
|
||||||
|
const r = new __SerReader(b);
|
||||||
|
return decode_${fname}(r);
|
||||||
|
},
|
||||||
|
encodeInto(v, w) { encode_${fname}(w, v); },
|
||||||
|
decodeFrom: decode_${fname},
|
||||||
|
$infer: undefined,
|
||||||
|
};
|
||||||
|
Object.freeze(__codec);
|
||||||
|
__serRegisterPrecompiled(__codec, encode_${fname}, decode_${fname});
|
||||||
|
return __codec;
|
||||||
|
})()`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
src,
|
||||||
|
compiled: {
|
||||||
|
schemaName: schema.name,
|
||||||
|
schemaKind: schema.kind,
|
||||||
|
fieldsDescriptor: descriptorLit,
|
||||||
|
encodeBody: cg.encodeBody,
|
||||||
|
decodeBody: cg.decodeBody,
|
||||||
|
closure: cg.closure,
|
||||||
|
deps: cg.deps,
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitize(name: string): string {
|
||||||
|
return name.replace(/[^A-Za-z0-9_]/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeClosureValue(v: unknown): string {
|
||||||
|
if (v === null || v === undefined) return String(v);
|
||||||
|
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
||||||
|
if (typeof v === 'string') return JSON.stringify(v);
|
||||||
|
if (typeof v === 'bigint') return `${v.toString()}n`;
|
||||||
|
if (Array.isArray(v)) return `Object.freeze([${v.map(serializeClosureValue).join(',')}])`;
|
||||||
|
if (typeof v === 'object') {
|
||||||
|
const entries = Object.entries(v as object).map(
|
||||||
|
([k, val]) => `${JSON.stringify(k)}: ${serializeClosureValue(val)}`,
|
||||||
|
);
|
||||||
|
return `Object.freeze({${entries.join(',')}})`;
|
||||||
|
}
|
||||||
|
throw new Error(`Cannot serialize closure value of type ${typeof v}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePrelude(importPath: string): string {
|
||||||
|
return `
|
||||||
|
import { Writer as __SerWriter, Reader as __SerReader } from ${JSON.stringify(importPath)};
|
||||||
|
const __serRegistry = (globalThis.__serRegistry ??= new Map());
|
||||||
|
function __serRegisterPrecompiled(codec, enc, dec) {
|
||||||
|
__serRegistry.set(codec.id, codec);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransformOptions {
|
||||||
|
importPath?: string;
|
||||||
|
packageAliases?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransformResult {
|
||||||
|
code: string;
|
||||||
|
transformedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transform(source: string, filename = 'input.ts', options: TransformOptions = {}): TransformResult {
|
||||||
|
const importPath = options.importPath ?? '@perf/serializer';
|
||||||
|
const aliases = new Set<string>(PKG_NAMES);
|
||||||
|
for (const a of options.packageAliases ?? []) aliases.add(a);
|
||||||
|
aliases.add(importPath);
|
||||||
|
|
||||||
|
const lang = filename.endsWith('.ts') || filename.endsWith('.tsx') ? 'ts' : 'js';
|
||||||
|
const parsed = parseSync(filename, source, { lang });
|
||||||
|
if (parsed.errors && parsed.errors.length > 0) {
|
||||||
|
const msgs = parsed.errors.map((e) => e.message ?? String(e)).join('\n');
|
||||||
|
throw new Error(`Parse errors in ${filename}:\n${msgs}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const program = parsed.program as unknown as AnyNode;
|
||||||
|
const imports = collectImportsFromSet(program, aliases);
|
||||||
|
|
||||||
|
let hasTypeImport = false;
|
||||||
|
for (const v of imports.bindings.values()) {
|
||||||
|
if (v === 'type' || v === 'oneOf') { hasTypeImport = true; break; }
|
||||||
|
}
|
||||||
|
if (!hasTypeImport) return { code: source, transformedCount: 0 };
|
||||||
|
|
||||||
|
const calls = findTypeCalls(program, imports);
|
||||||
|
if (calls.length === 0) return { code: source, transformedCount: 0 };
|
||||||
|
|
||||||
|
const scope: Scope = { imports, locals: new Map() };
|
||||||
|
const ms = new MagicString(source);
|
||||||
|
let transformedCount = 0;
|
||||||
|
|
||||||
|
for (const info of calls) {
|
||||||
|
const result = compileCall(info, scope);
|
||||||
|
if (!result) continue;
|
||||||
|
ms.overwrite(info.call.start as number, info.call.end as number, result.src);
|
||||||
|
const schema =
|
||||||
|
result.compiled.schemaKind === 'object'
|
||||||
|
? ({
|
||||||
|
kind: 'object' as const,
|
||||||
|
name: result.compiled.schemaName,
|
||||||
|
fields: JSON.parse(result.compiled.fieldsDescriptor).fields,
|
||||||
|
} as ObjectSchema)
|
||||||
|
: ({
|
||||||
|
kind: 'union' as const,
|
||||||
|
name: result.compiled.schemaName,
|
||||||
|
...JSON.parse(result.compiled.fieldsDescriptor),
|
||||||
|
} as UnionSchema);
|
||||||
|
scope.locals.set(info.declName, schema);
|
||||||
|
transformedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transformedCount === 0) return { code: source, transformedCount: 0 };
|
||||||
|
ms.prepend(makePrelude(importPath));
|
||||||
|
return { code: ms.toString(), transformedCount };
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { transform, type TransformOptions } from './transformer.ts';
|
||||||
|
|
||||||
|
export interface SerializerPluginOptions extends TransformOptions {
|
||||||
|
/**
|
||||||
|
* Glob patterns to include. Defaults to all `.ts` / `.tsx` / `.mts` / `.cts` files.
|
||||||
|
*/
|
||||||
|
include?: RegExp;
|
||||||
|
/**
|
||||||
|
* Glob patterns to exclude (in addition to node_modules which is always excluded).
|
||||||
|
*/
|
||||||
|
exclude?: RegExp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vite plugin for compile-time codec generation.
|
||||||
|
*
|
||||||
|
* Add to vite.config.ts:
|
||||||
|
*
|
||||||
|
* import { serializerCodegen } from '@perf/serializer/codegen/vite';
|
||||||
|
*
|
||||||
|
* export default {
|
||||||
|
* plugins: [serializerCodegen()],
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* In source code, write:
|
||||||
|
*
|
||||||
|
* import { type, u53, f64, str } from '@perf/serializer';
|
||||||
|
* const Ticker = type('Ticker', { symbol: str, last: f64 });
|
||||||
|
*
|
||||||
|
* The plugin replaces each `type(...)` / `oneOf(...)` call with an inline IIFE
|
||||||
|
* that produces the same codec, eliminating runtime `new Function` compilation.
|
||||||
|
*/
|
||||||
|
export interface VitePlugin {
|
||||||
|
name: string;
|
||||||
|
enforce?: 'pre' | 'post';
|
||||||
|
transform(code: string, id: string): { code: string; map: null } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializerCodegen(options: SerializerPluginOptions = {}): VitePlugin {
|
||||||
|
const include = options.include ?? /\.(?:ts|tsx|mts|cts)$/;
|
||||||
|
const exclude = options.exclude;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'perf-serializer-codegen',
|
||||||
|
enforce: 'pre',
|
||||||
|
transform(code: string, id: string) {
|
||||||
|
if (id.includes('node_modules')) return null;
|
||||||
|
if (!include.test(id)) return null;
|
||||||
|
if (exclude && exclude.test(id)) return null;
|
||||||
|
// Quick negative: file doesn't even contain the word `type` or `oneOf`
|
||||||
|
if (!code.includes('type(') && !code.includes('oneOf(')) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = transform(code, id, options);
|
||||||
|
if (result.transformedCount === 0) return null;
|
||||||
|
return { code: result.code, map: null };
|
||||||
|
} catch (err) {
|
||||||
|
// Don't break the build on parse errors — leave source as-is so the
|
||||||
|
// runtime fallback handles it.
|
||||||
|
const msg = (err as Error).message ?? String(err);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(`[perf-serializer-codegen] skipped ${id}: ${msg}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default serializerCodegen;
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import type { Writer, Reader } from './io.ts';
|
||||||
|
|
||||||
|
export type PrimitiveKind =
|
||||||
|
| 'u8' | 'u16' | 'u32'
|
||||||
|
| 'i8' | 'i16' | 'i32'
|
||||||
|
| 'u53' | 'i53'
|
||||||
|
| 'u64' | 'i64'
|
||||||
|
| 'f32' | 'f64'
|
||||||
|
| 'bool'
|
||||||
|
| 'str'
|
||||||
|
| 'bytes';
|
||||||
|
|
||||||
|
export type TypedArrayKind = 'f32Array' | 'f64Array' | 'u8Array' | 'u16Array' | 'u32Array' | 'i32Array';
|
||||||
|
|
||||||
|
export interface PrimitiveSchema<K extends PrimitiveKind, T> {
|
||||||
|
readonly kind: K;
|
||||||
|
readonly __t?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TypedArraySchema<K extends TypedArrayKind, T> {
|
||||||
|
readonly kind: K;
|
||||||
|
readonly __t?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArraySchema<E extends AnySchema = AnySchema> {
|
||||||
|
readonly kind: 'array';
|
||||||
|
readonly elem: E;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OptionalSchema<E extends AnySchema = AnySchema> {
|
||||||
|
readonly kind: 'optional';
|
||||||
|
readonly elem: E;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnumSchema<L extends readonly string[] = readonly string[]> {
|
||||||
|
readonly kind: 'enum';
|
||||||
|
readonly values: L;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BitsetSchema<L extends readonly string[] = readonly string[]> {
|
||||||
|
readonly kind: 'bitset';
|
||||||
|
readonly flags: L;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TupleSchema<E extends readonly AnySchema[] = readonly AnySchema[]> {
|
||||||
|
readonly kind: 'tuple';
|
||||||
|
readonly elems: E;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObjectSchema<F extends Record<string, AnySchema> = Record<string, AnySchema>> {
|
||||||
|
readonly kind: 'object';
|
||||||
|
readonly name: string;
|
||||||
|
readonly fields: F;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnionSchema<
|
||||||
|
D extends string = string,
|
||||||
|
V extends Record<string, ObjectSchema> = Record<string, ObjectSchema>,
|
||||||
|
> {
|
||||||
|
readonly kind: 'union';
|
||||||
|
readonly name: string;
|
||||||
|
readonly discriminator: D;
|
||||||
|
readonly variants: V;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefSchema<S extends ObjectSchema = ObjectSchema> {
|
||||||
|
readonly kind: 'ref';
|
||||||
|
readonly thunk: () => S;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodecSchema<T = unknown> {
|
||||||
|
readonly kind: 'codec';
|
||||||
|
readonly encode: (w: Writer, v: T) => void;
|
||||||
|
readonly decode: (r: Reader) => T;
|
||||||
|
readonly __t?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AnySchema =
|
||||||
|
| PrimitiveSchema<PrimitiveKind, unknown>
|
||||||
|
| TypedArraySchema<TypedArrayKind, unknown>
|
||||||
|
| ArraySchema
|
||||||
|
| OptionalSchema
|
||||||
|
| EnumSchema
|
||||||
|
| BitsetSchema
|
||||||
|
| TupleSchema
|
||||||
|
| ObjectSchema
|
||||||
|
| UnionSchema
|
||||||
|
| RefSchema
|
||||||
|
| CodecSchema;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// ── Simplified façade (recommended) ────────────────────────────────────────
|
||||||
|
export {
|
||||||
|
type,
|
||||||
|
oneOf,
|
||||||
|
router,
|
||||||
|
u8, u16, u32, i8, i16, i32, u53, i53, u64, i64, f32, f64,
|
||||||
|
bool, str, bytes,
|
||||||
|
f32Array, f64Array, u8Array, u16Array, u32Array, i32Array,
|
||||||
|
list, opt, enumOf, flags, tuple,
|
||||||
|
} from './api.ts';
|
||||||
|
export type { TypeCodec, InferType, Router } from './api.ts';
|
||||||
|
|
||||||
|
// ── Low-level API (advanced) ───────────────────────────────────────────────
|
||||||
|
export { Writer, Reader } from './io.ts';
|
||||||
|
export { s, defineSchema } from './schema.ts';
|
||||||
|
export type { SchemaBuilder } from './schema.ts';
|
||||||
|
export { Serializable } from './symbol.ts';
|
||||||
|
export {
|
||||||
|
register,
|
||||||
|
registerClass,
|
||||||
|
serialize,
|
||||||
|
deserialize,
|
||||||
|
clearRegistry,
|
||||||
|
} from './register.ts';
|
||||||
|
export type { Codec } from './register.ts';
|
||||||
|
export type {
|
||||||
|
AnySchema,
|
||||||
|
ObjectSchema,
|
||||||
|
UnionSchema,
|
||||||
|
ArraySchema,
|
||||||
|
OptionalSchema,
|
||||||
|
EnumSchema,
|
||||||
|
BitsetSchema,
|
||||||
|
TupleSchema,
|
||||||
|
RefSchema,
|
||||||
|
CodecSchema,
|
||||||
|
PrimitiveSchema,
|
||||||
|
TypedArraySchema,
|
||||||
|
PrimitiveKind,
|
||||||
|
TypedArrayKind,
|
||||||
|
} from './descriptors.ts';
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
const TE = new TextEncoder();
|
||||||
|
const TD = new TextDecoder('utf-8', { fatal: false, ignoreBOM: true });
|
||||||
|
|
||||||
|
export class Writer {
|
||||||
|
buf: Uint8Array;
|
||||||
|
view: DataView;
|
||||||
|
pos: number;
|
||||||
|
|
||||||
|
constructor(initial = 1024) {
|
||||||
|
this.buf = new Uint8Array(initial);
|
||||||
|
this.view = new DataView(this.buf.buffer);
|
||||||
|
this.pos = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.pos = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes(): Uint8Array {
|
||||||
|
return this.buf.subarray(0, this.pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
bytesCopy(): Uint8Array {
|
||||||
|
return this.buf.slice(0, this.pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure(n: number): void {
|
||||||
|
if (this.pos + n > this.buf.byteLength) this.grow(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
private grow(n: number): void {
|
||||||
|
let next = this.buf.byteLength * 2;
|
||||||
|
const need = this.pos + n;
|
||||||
|
while (next < need) next *= 2;
|
||||||
|
const nb = new Uint8Array(next);
|
||||||
|
nb.set(this.buf);
|
||||||
|
this.buf = nb;
|
||||||
|
this.view = new DataView(nb.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
u8(v: number): void {
|
||||||
|
if (this.pos + 1 > this.buf.byteLength) this.grow(1);
|
||||||
|
this.buf[this.pos++] = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
u16(v: number): void {
|
||||||
|
if (this.pos + 2 > this.buf.byteLength) this.grow(2);
|
||||||
|
this.view.setUint16(this.pos, v, true);
|
||||||
|
this.pos += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
i16(v: number): void {
|
||||||
|
if (this.pos + 2 > this.buf.byteLength) this.grow(2);
|
||||||
|
this.view.setInt16(this.pos, v, true);
|
||||||
|
this.pos += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
u32(v: number): void {
|
||||||
|
if (this.pos + 4 > this.buf.byteLength) this.grow(4);
|
||||||
|
this.view.setUint32(this.pos, v, true);
|
||||||
|
this.pos += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
i32(v: number): void {
|
||||||
|
if (this.pos + 4 > this.buf.byteLength) this.grow(4);
|
||||||
|
this.view.setInt32(this.pos, v, true);
|
||||||
|
this.pos += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
f32(v: number): void {
|
||||||
|
if (this.pos + 4 > this.buf.byteLength) this.grow(4);
|
||||||
|
this.view.setFloat32(this.pos, v, true);
|
||||||
|
this.pos += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
f64(v: number): void {
|
||||||
|
if (this.pos + 8 > this.buf.byteLength) this.grow(8);
|
||||||
|
this.view.setFloat64(this.pos, v, true);
|
||||||
|
this.pos += 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool(v: boolean): void {
|
||||||
|
if (this.pos + 1 > this.buf.byteLength) this.grow(1);
|
||||||
|
this.buf[this.pos++] = v ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
varu32(v: number): void {
|
||||||
|
if (this.pos + 5 > this.buf.byteLength) this.grow(5);
|
||||||
|
while (v >= 0x80) {
|
||||||
|
this.buf[this.pos++] = (v & 0x7f) | 0x80;
|
||||||
|
v >>>= 7;
|
||||||
|
}
|
||||||
|
this.buf[this.pos++] = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
vari32(v: number): void {
|
||||||
|
const z = ((v << 1) ^ (v >> 31)) >>> 0;
|
||||||
|
this.varu32(z);
|
||||||
|
}
|
||||||
|
|
||||||
|
varu53(v: number): void {
|
||||||
|
if (this.pos + 10 > this.buf.byteLength) this.grow(10);
|
||||||
|
while (v >= 0x80) {
|
||||||
|
this.buf[this.pos++] = (v & 0x7f) | 0x80;
|
||||||
|
v = Math.floor(v / 128);
|
||||||
|
}
|
||||||
|
this.buf[this.pos++] = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
vari53(v: number): void {
|
||||||
|
const z = v >= 0 ? BigInt(v) * 2n : -BigInt(v) * 2n - 1n;
|
||||||
|
this.varbu(z);
|
||||||
|
}
|
||||||
|
|
||||||
|
varbu(v: bigint): void {
|
||||||
|
if (this.pos + 10 > this.buf.byteLength) this.grow(10);
|
||||||
|
while (v >= 0x80n) {
|
||||||
|
this.buf[this.pos++] = Number(v & 0x7fn) | 0x80;
|
||||||
|
v >>= 7n;
|
||||||
|
}
|
||||||
|
this.buf[this.pos++] = Number(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
varbi(v: bigint): void {
|
||||||
|
const z = v >= 0n ? v << 1n : (-v << 1n) - 1n;
|
||||||
|
this.varbu(z);
|
||||||
|
}
|
||||||
|
|
||||||
|
raw(src: Uint8Array): void {
|
||||||
|
if (this.pos + src.length > this.buf.byteLength) this.grow(src.length);
|
||||||
|
this.buf.set(src, this.pos);
|
||||||
|
this.pos += src.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
bytesPrefixed(src: Uint8Array): void {
|
||||||
|
this.varu53(src.length);
|
||||||
|
this.raw(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
str(s: string): void {
|
||||||
|
const len = s.length;
|
||||||
|
if (len === 0) {
|
||||||
|
this.u8(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (len < 64) {
|
||||||
|
if (this.pos + len + 5 > this.buf.byteLength) this.grow(len + 5);
|
||||||
|
let allAscii = true;
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
if (s.charCodeAt(i) > 127) {
|
||||||
|
allAscii = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (allAscii) {
|
||||||
|
let p = this.pos;
|
||||||
|
let lv = len;
|
||||||
|
while (lv >= 0x80) {
|
||||||
|
this.buf[p++] = (lv & 0x7f) | 0x80;
|
||||||
|
lv >>>= 7;
|
||||||
|
}
|
||||||
|
this.buf[p++] = lv;
|
||||||
|
for (let i = 0; i < len; i++) this.buf[p++] = s.charCodeAt(i);
|
||||||
|
this.pos = p;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxBytes = len * 3;
|
||||||
|
if (this.pos + maxBytes + 5 > this.buf.byteLength) this.grow(maxBytes + 5);
|
||||||
|
const dst = this.buf.subarray(this.pos + 5);
|
||||||
|
const { written } = TE.encodeInto(s, dst);
|
||||||
|
const w = written ?? 0;
|
||||||
|
|
||||||
|
let lenBytes = 1;
|
||||||
|
let lv = w;
|
||||||
|
while (lv >= 0x80) {
|
||||||
|
lenBytes++;
|
||||||
|
lv >>>= 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lenBytes < 5 && w > 0) {
|
||||||
|
this.buf.copyWithin(this.pos + lenBytes, this.pos + 5, this.pos + 5 + w);
|
||||||
|
}
|
||||||
|
|
||||||
|
let p = this.pos;
|
||||||
|
lv = w;
|
||||||
|
while (lv >= 0x80) {
|
||||||
|
this.buf[p++] = (lv & 0x7f) | 0x80;
|
||||||
|
lv >>>= 7;
|
||||||
|
}
|
||||||
|
this.buf[p++] = lv;
|
||||||
|
this.pos = p + w;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Reader {
|
||||||
|
buf: Uint8Array;
|
||||||
|
view: DataView;
|
||||||
|
pos: number;
|
||||||
|
end: number;
|
||||||
|
|
||||||
|
constructor(buf: Uint8Array) {
|
||||||
|
this.buf = buf;
|
||||||
|
this.view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
||||||
|
this.pos = 0;
|
||||||
|
this.end = buf.byteLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(buf: Uint8Array): void {
|
||||||
|
this.buf = buf;
|
||||||
|
this.view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
||||||
|
this.pos = 0;
|
||||||
|
this.end = buf.byteLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining(): number {
|
||||||
|
return this.end - this.pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
u8(): number {
|
||||||
|
return this.buf[this.pos++]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
u16(): number {
|
||||||
|
const v = this.view.getUint16(this.pos, true);
|
||||||
|
this.pos += 2;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
i16(): number {
|
||||||
|
const v = this.view.getInt16(this.pos, true);
|
||||||
|
this.pos += 2;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
u32(): number {
|
||||||
|
const v = this.view.getUint32(this.pos, true);
|
||||||
|
this.pos += 4;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
i32(): number {
|
||||||
|
const v = this.view.getInt32(this.pos, true);
|
||||||
|
this.pos += 4;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
f32(): number {
|
||||||
|
const v = this.view.getFloat32(this.pos, true);
|
||||||
|
this.pos += 4;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
f64(): number {
|
||||||
|
const v = this.view.getFloat64(this.pos, true);
|
||||||
|
this.pos += 8;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool(): boolean {
|
||||||
|
return this.buf[this.pos++] !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
varu32(): number {
|
||||||
|
let v = 0;
|
||||||
|
let shift = 0;
|
||||||
|
let byte = 0;
|
||||||
|
do {
|
||||||
|
byte = this.buf[this.pos++]!;
|
||||||
|
v |= (byte & 0x7f) << shift;
|
||||||
|
shift += 7;
|
||||||
|
} while (byte & 0x80);
|
||||||
|
return v >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
vari32(): number {
|
||||||
|
const z = this.varu32();
|
||||||
|
return (z >>> 1) ^ -(z & 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
varu53(): number {
|
||||||
|
let v = 0;
|
||||||
|
let mult = 1;
|
||||||
|
let byte = 0;
|
||||||
|
do {
|
||||||
|
byte = this.buf[this.pos++]!;
|
||||||
|
v += (byte & 0x7f) * mult;
|
||||||
|
mult *= 128;
|
||||||
|
} while (byte & 0x80);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
vari53(): number {
|
||||||
|
const z = this.varbu();
|
||||||
|
const v = (z & 1n) === 0n ? z >> 1n : -((z >> 1n) + 1n);
|
||||||
|
return Number(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
varbu(): bigint {
|
||||||
|
let v = 0n;
|
||||||
|
let shift = 0n;
|
||||||
|
let byte = 0;
|
||||||
|
do {
|
||||||
|
byte = this.buf[this.pos++]!;
|
||||||
|
v |= BigInt(byte & 0x7f) << shift;
|
||||||
|
shift += 7n;
|
||||||
|
} while (byte & 0x80);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
varbi(): bigint {
|
||||||
|
const z = this.varbu();
|
||||||
|
return (z & 1n) === 0n ? z >> 1n : -((z >> 1n) + 1n);
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes(n: number): Uint8Array {
|
||||||
|
const slice = this.buf.subarray(this.pos, this.pos + n);
|
||||||
|
this.pos += n;
|
||||||
|
return slice;
|
||||||
|
}
|
||||||
|
|
||||||
|
bytesPrefixed(): Uint8Array {
|
||||||
|
const n = this.varu53();
|
||||||
|
return this.bytes(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
str(): string {
|
||||||
|
const len = this.varu53();
|
||||||
|
if (len === 0) return '';
|
||||||
|
const start = this.pos;
|
||||||
|
const end = start + len;
|
||||||
|
const buf = this.buf;
|
||||||
|
|
||||||
|
if (len < 32) {
|
||||||
|
let allAscii = true;
|
||||||
|
for (let i = start; i < end; i++) {
|
||||||
|
if (buf[i]! > 127) {
|
||||||
|
allAscii = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (allAscii) {
|
||||||
|
let s = '';
|
||||||
|
for (let i = start; i < end; i++) {
|
||||||
|
s += String.fromCharCode(buf[i]!);
|
||||||
|
}
|
||||||
|
this.pos = end;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const slice = buf.subarray(start, end);
|
||||||
|
this.pos = end;
|
||||||
|
return TD.decode(slice);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { compileObject, compileUnion } from './codegen.ts';
|
||||||
|
import type { AnySchema, ObjectSchema, UnionSchema } from './descriptors.ts';
|
||||||
|
import { Reader, Writer } from './io.ts';
|
||||||
|
import { Serializable } from './symbol.ts';
|
||||||
|
|
||||||
|
export interface Codec<T = unknown> {
|
||||||
|
readonly id: number;
|
||||||
|
readonly name: string;
|
||||||
|
readonly encode: (w: Writer, v: T) => void;
|
||||||
|
readonly decode: (r: Reader) => T;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnyCodec = Codec<any>;
|
||||||
|
|
||||||
|
const byName = new Map<string, AnyCodec>();
|
||||||
|
const byId = new Map<number, AnyCodec>();
|
||||||
|
const byCtor = new WeakMap<object, AnyCodec>();
|
||||||
|
|
||||||
|
function fnv1a16(s: string): number {
|
||||||
|
let h = 0x811c9dc5;
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
h ^= s.charCodeAt(i);
|
||||||
|
h = Math.imul(h, 0x01000193);
|
||||||
|
}
|
||||||
|
return ((h >>> 16) ^ h) & 0xffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectRefDeps(schema: AnySchema, acc: Map<string, ObjectSchema>): void {
|
||||||
|
switch (schema.kind) {
|
||||||
|
case 'ref': {
|
||||||
|
const target = schema.thunk();
|
||||||
|
if (!acc.has(target.name)) {
|
||||||
|
acc.set(target.name, target);
|
||||||
|
for (const f of Object.values(target.fields)) collectRefDeps(f, acc);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'object':
|
||||||
|
for (const f of Object.values(schema.fields)) collectRefDeps(f, acc);
|
||||||
|
return;
|
||||||
|
case 'array':
|
||||||
|
case 'optional':
|
||||||
|
collectRefDeps(schema.elem, acc);
|
||||||
|
return;
|
||||||
|
case 'tuple':
|
||||||
|
for (const e of schema.elems) collectRefDeps(e, acc);
|
||||||
|
return;
|
||||||
|
case 'union':
|
||||||
|
for (const v of Object.values(schema.variants)) collectRefDeps(v, acc);
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanIdent(name: string): string {
|
||||||
|
return name.replace(/[^A-Za-z0-9_]/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function register<T = unknown>(schema: ObjectSchema | UnionSchema): Codec<T> {
|
||||||
|
const existing = byName.get(schema.name);
|
||||||
|
if (existing) return existing as Codec<T>;
|
||||||
|
|
||||||
|
// Only `ref` boundaries require a separately registered codec; nested
|
||||||
|
// objects/unions/arrays/etc. are inlined into the generated function.
|
||||||
|
const refTargets = new Map<string, ObjectSchema>();
|
||||||
|
if (schema.kind === 'object') {
|
||||||
|
for (const f of Object.values(schema.fields)) collectRefDeps(f, refTargets);
|
||||||
|
} else {
|
||||||
|
for (const v of Object.values(schema.variants)) collectRefDeps(v, refTargets);
|
||||||
|
}
|
||||||
|
for (const dep of refTargets.values()) {
|
||||||
|
if (!byName.has(dep.name)) register(dep);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cg = schema.kind === 'object' ? compileObject(schema) : compileUnion(schema);
|
||||||
|
|
||||||
|
const depsObj: Record<string, Function> = {};
|
||||||
|
for (const [local, info] of cg.deps) {
|
||||||
|
const target = byName.get(info.targetName);
|
||||||
|
if (!target) throw new Error(`Dep not registered: ${info.targetName}`);
|
||||||
|
depsObj[local] = info.mode === 'enc' ? target.encode : target.decode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const closureObj: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of cg.closure) closureObj[k] = v;
|
||||||
|
|
||||||
|
const encDeps: string[] = [];
|
||||||
|
const decDeps: string[] = [];
|
||||||
|
for (const [local, info] of cg.deps) {
|
||||||
|
const line = `const ${local} = deps[${JSON.stringify(local)}];`;
|
||||||
|
if (info.mode === 'enc') encDeps.push(line);
|
||||||
|
else decDeps.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
const closureDecls = [...cg.closure.keys()]
|
||||||
|
.map((k) => `const ${k} = closure[${JSON.stringify(k)}];`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const fname = sanIdent(schema.name);
|
||||||
|
|
||||||
|
const encSrc = `${encDeps.join('\n')}
|
||||||
|
${closureDecls}
|
||||||
|
return function encode_${fname}(w, o) {
|
||||||
|
let pos = w.pos;
|
||||||
|
let buf = w.buf;
|
||||||
|
let view = w.view;
|
||||||
|
${cg.encodeBody}
|
||||||
|
w.pos = pos;
|
||||||
|
};`;
|
||||||
|
|
||||||
|
const decSrc = `${decDeps.join('\n')}
|
||||||
|
${closureDecls}
|
||||||
|
return function decode_${fname}(r) {
|
||||||
|
let pos = r.pos;
|
||||||
|
const buf = r.buf;
|
||||||
|
const view = r.view;
|
||||||
|
${cg.decodeBody}
|
||||||
|
};`;
|
||||||
|
|
||||||
|
let encFn: (w: Writer, v: T) => void;
|
||||||
|
let decFn: (r: Reader) => T;
|
||||||
|
try {
|
||||||
|
encFn = new Function('deps', 'closure', encSrc)(depsObj, closureObj);
|
||||||
|
decFn = new Function('deps', 'closure', decSrc)(depsObj, closureObj);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`Codegen failed for "${schema.name}": ${(e as Error).message}\n--- encode source ---\n${encSrc}\n--- decode source ---\n${decSrc}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = fnv1a16(schema.name);
|
||||||
|
const idExisting = byId.get(id);
|
||||||
|
if (idExisting) {
|
||||||
|
throw new Error(
|
||||||
|
`Schema ID collision: "${schema.name}" and "${idExisting.name}" both hash to 0x${id.toString(16)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const codec: Codec<T> = Object.freeze({
|
||||||
|
id,
|
||||||
|
name: schema.name,
|
||||||
|
encode: encFn,
|
||||||
|
decode: decFn,
|
||||||
|
});
|
||||||
|
|
||||||
|
byName.set(schema.name, codec);
|
||||||
|
byId.set(id, codec);
|
||||||
|
return codec;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerClass<T>(Ctor: new (...args: never[]) => T): Codec<T> {
|
||||||
|
const cached = byCtor.get(Ctor);
|
||||||
|
if (cached) return cached as Codec<T>;
|
||||||
|
const schema = (Ctor as unknown as Record<symbol, unknown>)[Serializable] as
|
||||||
|
| ObjectSchema
|
||||||
|
| UnionSchema
|
||||||
|
| undefined;
|
||||||
|
if (!schema) {
|
||||||
|
throw new Error(`${Ctor.name} has no [Serializable] schema`);
|
||||||
|
}
|
||||||
|
const codec = register<T>(schema);
|
||||||
|
byCtor.set(Ctor, codec);
|
||||||
|
return codec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a value into a framed Uint8Array (2-byte schema ID + body).
|
||||||
|
* If a Writer is passed, returns a view; otherwise returns a fresh copy.
|
||||||
|
*/
|
||||||
|
export function serialize<T>(value: T, codec: Codec<T>, writer?: Writer): Uint8Array {
|
||||||
|
if (writer) {
|
||||||
|
writer.u16(codec.id);
|
||||||
|
codec.encode(writer, value);
|
||||||
|
return writer.bytes();
|
||||||
|
}
|
||||||
|
const w = new Writer();
|
||||||
|
w.u16(codec.id);
|
||||||
|
codec.encode(w, value);
|
||||||
|
return w.bytesCopy();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a framed Uint8Array by looking up its schema ID.
|
||||||
|
*/
|
||||||
|
export function deserialize<T = unknown>(bytes: Uint8Array): T {
|
||||||
|
const r = new Reader(bytes);
|
||||||
|
const id = r.u16();
|
||||||
|
const codec = byId.get(id);
|
||||||
|
if (!codec) throw new Error(`Unknown schema ID: 0x${id.toString(16)}`);
|
||||||
|
return codec.decode(r) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearRegistry(): void {
|
||||||
|
byName.clear();
|
||||||
|
byId.clear();
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import type {
|
||||||
|
AnySchema,
|
||||||
|
ArraySchema,
|
||||||
|
BitsetSchema,
|
||||||
|
CodecSchema,
|
||||||
|
EnumSchema,
|
||||||
|
ObjectSchema,
|
||||||
|
OptionalSchema,
|
||||||
|
PrimitiveSchema,
|
||||||
|
RefSchema,
|
||||||
|
TupleSchema,
|
||||||
|
TypedArraySchema,
|
||||||
|
UnionSchema,
|
||||||
|
} from './descriptors.ts';
|
||||||
|
import type { Reader, Writer } from './io.ts';
|
||||||
|
|
||||||
|
type Prim<K extends string, T> = { readonly kind: K; readonly __t?: T };
|
||||||
|
|
||||||
|
function p<K extends string, T>(kind: K): Prim<K, T> {
|
||||||
|
return Object.freeze({ kind }) as Prim<K, T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const s = Object.freeze({
|
||||||
|
u8: p<'u8', number>('u8') as PrimitiveSchema<'u8', number>,
|
||||||
|
u16: p<'u16', number>('u16') as PrimitiveSchema<'u16', number>,
|
||||||
|
u32: p<'u32', number>('u32') as PrimitiveSchema<'u32', number>,
|
||||||
|
i8: p<'i8', number>('i8') as PrimitiveSchema<'i8', number>,
|
||||||
|
i16: p<'i16', number>('i16') as PrimitiveSchema<'i16', number>,
|
||||||
|
i32: p<'i32', number>('i32') as PrimitiveSchema<'i32', number>,
|
||||||
|
u53: p<'u53', number>('u53') as PrimitiveSchema<'u53', number>,
|
||||||
|
i53: p<'i53', number>('i53') as PrimitiveSchema<'i53', number>,
|
||||||
|
u64: p<'u64', bigint>('u64') as PrimitiveSchema<'u64', bigint>,
|
||||||
|
i64: p<'i64', bigint>('i64') as PrimitiveSchema<'i64', bigint>,
|
||||||
|
f32: p<'f32', number>('f32') as PrimitiveSchema<'f32', number>,
|
||||||
|
f64: p<'f64', number>('f64') as PrimitiveSchema<'f64', number>,
|
||||||
|
bool: p<'bool', boolean>('bool') as PrimitiveSchema<'bool', boolean>,
|
||||||
|
str: p<'str', string>('str') as PrimitiveSchema<'str', string>,
|
||||||
|
bytes: p<'bytes', Uint8Array>('bytes') as PrimitiveSchema<'bytes', Uint8Array>,
|
||||||
|
|
||||||
|
f32Array: p<'f32Array', Float32Array>('f32Array') as TypedArraySchema<'f32Array', Float32Array>,
|
||||||
|
f64Array: p<'f64Array', Float64Array>('f64Array') as TypedArraySchema<'f64Array', Float64Array>,
|
||||||
|
u8Array: p<'u8Array', Uint8Array>('u8Array') as TypedArraySchema<'u8Array', Uint8Array>,
|
||||||
|
u16Array: p<'u16Array', Uint16Array>('u16Array') as TypedArraySchema<'u16Array', Uint16Array>,
|
||||||
|
u32Array: p<'u32Array', Uint32Array>('u32Array') as TypedArraySchema<'u32Array', Uint32Array>,
|
||||||
|
i32Array: p<'i32Array', Int32Array>('i32Array') as TypedArraySchema<'i32Array', Int32Array>,
|
||||||
|
|
||||||
|
array<E extends AnySchema>(elem: E): ArraySchema<E> {
|
||||||
|
return { kind: 'array', elem };
|
||||||
|
},
|
||||||
|
|
||||||
|
optional<E extends AnySchema>(elem: E): OptionalSchema<E> {
|
||||||
|
return { kind: 'optional', elem };
|
||||||
|
},
|
||||||
|
|
||||||
|
enum<L extends readonly string[]>(values: L): EnumSchema<L> {
|
||||||
|
if (values.length === 0) throw new Error('enum requires at least one value');
|
||||||
|
if (values.length > 256) throw new Error('enum supports up to 256 values');
|
||||||
|
return { kind: 'enum', values };
|
||||||
|
},
|
||||||
|
|
||||||
|
bitset<L extends readonly string[]>(flags: L): BitsetSchema<L> {
|
||||||
|
if (flags.length === 0) throw new Error('bitset requires at least one flag');
|
||||||
|
if (flags.length > 64) throw new Error('bitset supports up to 64 flags');
|
||||||
|
return { kind: 'bitset', flags };
|
||||||
|
},
|
||||||
|
|
||||||
|
tuple<E extends readonly AnySchema[]>(...elems: E): TupleSchema<E> {
|
||||||
|
return { kind: 'tuple', elems };
|
||||||
|
},
|
||||||
|
|
||||||
|
union<D extends string, V extends Record<string, Record<string, AnySchema>>>(
|
||||||
|
name: string,
|
||||||
|
discriminator: D,
|
||||||
|
variants: V,
|
||||||
|
): UnionSchema<D, { [K in keyof V]: ObjectSchema<V[K]> }> {
|
||||||
|
const variantSchemas = {} as Record<string, ObjectSchema>;
|
||||||
|
let i = 0;
|
||||||
|
for (const k of Object.keys(variants)) {
|
||||||
|
variantSchemas[k] = {
|
||||||
|
kind: 'object',
|
||||||
|
name: `${name}::${k}`,
|
||||||
|
fields: variants[k]!,
|
||||||
|
};
|
||||||
|
i++;
|
||||||
|
if (i > 256) throw new Error('union supports up to 256 variants');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: 'union',
|
||||||
|
name,
|
||||||
|
discriminator,
|
||||||
|
variants: variantSchemas as { [K in keyof V]: ObjectSchema<V[K]> },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
ref<S extends ObjectSchema>(thunk: () => S): RefSchema<S> {
|
||||||
|
return { kind: 'ref', thunk };
|
||||||
|
},
|
||||||
|
|
||||||
|
codec<T>(impl: { encode: (w: Writer, v: T) => void; decode: (r: Reader) => T }): CodecSchema<T> {
|
||||||
|
return { kind: 'codec', encode: impl.encode, decode: impl.decode };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SchemaBuilder = typeof s;
|
||||||
|
|
||||||
|
export function defineSchema<F extends Record<string, AnySchema>>(
|
||||||
|
name: string,
|
||||||
|
build: (s: SchemaBuilder) => F,
|
||||||
|
): ObjectSchema<F> {
|
||||||
|
return { kind: 'object', name, fields: build(s) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* The well-known symbol carrying a schema descriptor on a constructor.
|
||||||
|
*
|
||||||
|
* Registered via `Symbol.for` so multiple module copies (workers, dual builds)
|
||||||
|
* share identity.
|
||||||
|
*/
|
||||||
|
declare const SerializableBrand: unique symbol;
|
||||||
|
|
||||||
|
export const Serializable = Symbol.for('@perf/serializable') as typeof SerializableBrand;
|
||||||
|
export type SerializableKey = typeof SerializableBrand;
|
||||||
Generated
+2338
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* Examples of the @perf/serializer API.
|
||||||
|
*
|
||||||
|
* When the AOT plugin is enabled in vite.config.ts, every `type(...)` /
|
||||||
|
* `oneOf(...)` call below is replaced at build time with a precomputed
|
||||||
|
* codec literal. The runtime never calls `new Function`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type,
|
||||||
|
oneOf,
|
||||||
|
router,
|
||||||
|
u53,
|
||||||
|
f64,
|
||||||
|
str,
|
||||||
|
list,
|
||||||
|
opt,
|
||||||
|
enumOf,
|
||||||
|
flags,
|
||||||
|
Writer,
|
||||||
|
} from '@perf/serializer';
|
||||||
|
|
||||||
|
// ─── Tee output to console + <pre id="out"> if we're in a browser ──────────
|
||||||
|
|
||||||
|
const out =
|
||||||
|
typeof document !== 'undefined' ? document.getElementById('out') : null;
|
||||||
|
|
||||||
|
function log(...args: unknown[]): void {
|
||||||
|
console.log(...args);
|
||||||
|
if (out) {
|
||||||
|
const text = args
|
||||||
|
.map((a) => {
|
||||||
|
if (typeof a === 'string') return a;
|
||||||
|
if (a instanceof Uint8Array) return `Uint8Array(${a.length})`;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(a);
|
||||||
|
} catch {
|
||||||
|
return String(a);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
out.textContent += text + '\n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Example 1: flat schema ────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Define a Ticker, infer its TypeScript type from the schema, encode and
|
||||||
|
// decode it. This is the 90% use case.
|
||||||
|
|
||||||
|
const Ticker = type('Ticker', {
|
||||||
|
symbol: str,
|
||||||
|
last: f64,
|
||||||
|
bid: f64,
|
||||||
|
ask: f64,
|
||||||
|
volume: f64,
|
||||||
|
});
|
||||||
|
|
||||||
|
type Ticker = typeof Ticker.$infer;
|
||||||
|
// → { symbol: string; last: number; bid: number; ask: number; volume: number }
|
||||||
|
|
||||||
|
const ticker: Ticker = {
|
||||||
|
symbol: 'BTC-USD',
|
||||||
|
last: 67891.23,
|
||||||
|
bid: 67890.5,
|
||||||
|
ask: 67892.0,
|
||||||
|
volume: 1234567.89,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tickerBytes = Ticker.encode(ticker);
|
||||||
|
const tickerBack = Ticker.decode(tickerBytes);
|
||||||
|
|
||||||
|
log('Example 1: Ticker');
|
||||||
|
log(` encoded ${tickerBytes.length} bytes (JSON would be ${JSON.stringify(ticker).length})`);
|
||||||
|
log(' decoded:', tickerBack);
|
||||||
|
|
||||||
|
// ─── Example 2: nested object + list ───────────────────────────────────────
|
||||||
|
//
|
||||||
|
// `Level` is itself a codec; it can be passed as a field in another `type()`.
|
||||||
|
// The transformer inlines its encode/decode into the parent — no per-element
|
||||||
|
// function dispatch.
|
||||||
|
|
||||||
|
const Level = type('Level', { p: f64, q: f64 });
|
||||||
|
|
||||||
|
const Book = type('Book', {
|
||||||
|
symbol: str,
|
||||||
|
ts: u53,
|
||||||
|
bids: list(Level),
|
||||||
|
asks: list(Level),
|
||||||
|
});
|
||||||
|
|
||||||
|
const book = {
|
||||||
|
symbol: 'BTC-USD',
|
||||||
|
ts: Date.now(),
|
||||||
|
bids: [
|
||||||
|
{ p: 67890.5, q: 0.1 },
|
||||||
|
{ p: 67890.0, q: 0.3 },
|
||||||
|
{ p: 67889.5, q: 0.5 },
|
||||||
|
],
|
||||||
|
asks: [
|
||||||
|
{ p: 67891.0, q: 0.2 },
|
||||||
|
{ p: 67891.5, q: 0.4 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const bookBytes = Book.encode(book);
|
||||||
|
log('\nExample 2: OrderBook');
|
||||||
|
log(` encoded ${bookBytes.length} bytes (JSON: ${JSON.stringify(book).length})`);
|
||||||
|
log(' decoded.bids[0]:', Book.decode(bookBytes).bids[0]);
|
||||||
|
|
||||||
|
// ─── Example 3: enum + bitset + optional ───────────────────────────────────
|
||||||
|
//
|
||||||
|
// Enums encode as one byte. Bitsets pack up to 32 flags into a u32. Optional
|
||||||
|
// fields add one presence byte.
|
||||||
|
|
||||||
|
const Order = type('Order', {
|
||||||
|
id: u53,
|
||||||
|
side: enumOf(['buy', 'sell'] as const),
|
||||||
|
qty: f64,
|
||||||
|
price: opt(f64), // market orders have no price
|
||||||
|
flags: flags(['ioc', 'post_only', 'reduce_only'] as const),
|
||||||
|
});
|
||||||
|
|
||||||
|
const marketOrder = {
|
||||||
|
id: 1,
|
||||||
|
side: 'buy' as const,
|
||||||
|
qty: 0.5,
|
||||||
|
price: undefined,
|
||||||
|
flags: { ioc: true, post_only: false, reduce_only: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
const limitOrder = {
|
||||||
|
id: 2,
|
||||||
|
side: 'sell' as const,
|
||||||
|
qty: 0.5,
|
||||||
|
price: 67900,
|
||||||
|
flags: { ioc: false, post_only: true, reduce_only: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
log('\nExample 3: Orders (enum + opt + flags)');
|
||||||
|
log(` market: ${Order.encode(marketOrder).length}b`);
|
||||||
|
log(` limit: ${Order.encode(limitOrder).length}b`);
|
||||||
|
|
||||||
|
// ─── Example 4: discriminated union ────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Each variant has its own field map. The discriminator (`kind`) is written as
|
||||||
|
// a one-byte tag, then the variant's fields follow.
|
||||||
|
|
||||||
|
const Event = oneOf('Event', 'kind', {
|
||||||
|
fill: { price: f64, qty: f64 },
|
||||||
|
cancel: { reason: str },
|
||||||
|
expire: { at: u53 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = [
|
||||||
|
{ kind: 'fill', price: 67891.0, qty: 0.5 },
|
||||||
|
{ kind: 'cancel', reason: 'user-requested' },
|
||||||
|
{ kind: 'expire', at: Date.now() + 60_000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
log('\nExample 4: Events (union)');
|
||||||
|
for (const e of events) {
|
||||||
|
const bytes = Event.encode(e as never);
|
||||||
|
log(` ${e.kind.padEnd(7)}: ${bytes.length}b`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Example 5: pooled writer (hot path) ───────────────────────────────────
|
||||||
|
//
|
||||||
|
// Reuse one Writer across many encodes. `encodeInto` writes directly into the
|
||||||
|
// pooled buffer; `bytes()` returns a zero-copy view. This is the lowest-overhead
|
||||||
|
// path — what to use inside a tight WebSocket frame loop.
|
||||||
|
|
||||||
|
const w = new Writer(1024);
|
||||||
|
|
||||||
|
function sendTicker(t: Ticker, socket: { send(bytes: Uint8Array): void }): void {
|
||||||
|
w.reset();
|
||||||
|
Ticker.encodeInto(t, w);
|
||||||
|
socket.send(w.bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
const fakeSocket = {
|
||||||
|
send(bytes: Uint8Array): void {
|
||||||
|
log(` socket received ${bytes.length} bytes`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
log('\nExample 5: pooled writer hot path');
|
||||||
|
sendTicker(ticker, fakeSocket);
|
||||||
|
sendTicker({ ...ticker, last: 67900 }, fakeSocket);
|
||||||
|
|
||||||
|
// ─── Example 6: router (framed multi-message protocol) ─────────────────────
|
||||||
|
//
|
||||||
|
// `router` prepends a 2-byte schema-ID frame on encode and dispatches on it on
|
||||||
|
// decode. Use this when one socket carries many message types.
|
||||||
|
|
||||||
|
const proto = router(Ticker, Book, Order, Event);
|
||||||
|
|
||||||
|
const framedTicker = proto.encode(ticker, Ticker);
|
||||||
|
const framedBook = proto.encode(book, Book);
|
||||||
|
|
||||||
|
log('\nExample 6: router (framed)');
|
||||||
|
log(` framed ticker: ${framedTicker.length}b (first 2 bytes = schema id)`);
|
||||||
|
log(` framed book: ${framedBook.length}b`);
|
||||||
|
|
||||||
|
const dispatched1 = proto.decode(framedTicker);
|
||||||
|
const dispatched2 = proto.decode(framedBook);
|
||||||
|
log(' dispatched ticker symbol:', (dispatched1 as Ticker).symbol);
|
||||||
|
log(' dispatched book bids[0]:', (dispatched2 as typeof book).bids[0]);
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { test, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
type,
|
||||||
|
oneOf,
|
||||||
|
router,
|
||||||
|
u53,
|
||||||
|
f64,
|
||||||
|
str,
|
||||||
|
bool,
|
||||||
|
list,
|
||||||
|
opt,
|
||||||
|
enumOf,
|
||||||
|
flags,
|
||||||
|
tuple,
|
||||||
|
f64Array,
|
||||||
|
clearRegistry,
|
||||||
|
Writer,
|
||||||
|
Reader,
|
||||||
|
} from '../plugin/index.ts';
|
||||||
|
|
||||||
|
test('type() — flat schema round-trip', () => {
|
||||||
|
clearRegistry();
|
||||||
|
const Ticker = type('Ticker', {
|
||||||
|
symbol: str,
|
||||||
|
last: f64,
|
||||||
|
volume: f64,
|
||||||
|
});
|
||||||
|
|
||||||
|
type Ticker = typeof Ticker.$infer;
|
||||||
|
|
||||||
|
const v: Ticker = { symbol: 'BTC-USD', last: 100.5, volume: 1234 };
|
||||||
|
const bytes = Ticker.encode(v);
|
||||||
|
expect(Ticker.decode(bytes)).toEqual(v);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('type() — nested object via inline reference', () => {
|
||||||
|
clearRegistry();
|
||||||
|
const Price = type('Price', { value: f64, scale: u53 });
|
||||||
|
const Order = type('Order', {
|
||||||
|
id: u53,
|
||||||
|
price: Price,
|
||||||
|
qty: f64,
|
||||||
|
});
|
||||||
|
|
||||||
|
const v = { id: 42, price: { value: 100.5, scale: 2 }, qty: 0.5 };
|
||||||
|
expect(Order.decode(Order.encode(v))).toEqual(v);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('type() — anonymous (no name) still works', () => {
|
||||||
|
clearRegistry();
|
||||||
|
const Anon = type({ x: u53, y: f64 });
|
||||||
|
const v = { x: 1, y: 2.5 };
|
||||||
|
expect(Anon.decode(Anon.encode(v))).toEqual(v);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('list, opt, enumOf, flags, tuple — combinators', () => {
|
||||||
|
clearRegistry();
|
||||||
|
const T = type('Combo', {
|
||||||
|
tags: list(str),
|
||||||
|
maybe: opt(f64),
|
||||||
|
side: enumOf(['buy', 'sell'] as const),
|
||||||
|
f: flags(['ioc', 'post_only'] as const),
|
||||||
|
point: tuple(f64, f64),
|
||||||
|
});
|
||||||
|
|
||||||
|
const v = {
|
||||||
|
tags: ['a', 'b'],
|
||||||
|
maybe: 3.14,
|
||||||
|
side: 'buy' as const,
|
||||||
|
f: { ioc: true, post_only: false },
|
||||||
|
point: [1, 2] as [number, number],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(T.decode(T.encode(v))).toEqual(v);
|
||||||
|
|
||||||
|
const v2 = { ...v, maybe: undefined };
|
||||||
|
expect(T.decode(T.encode(v2))).toEqual(v2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('type() — typed array field', () => {
|
||||||
|
clearRegistry();
|
||||||
|
const Signal = type('Signal', { name: str, samples: f64Array });
|
||||||
|
const v = { name: 'x', samples: new Float64Array([1, 2, 3, 4]) };
|
||||||
|
const back = Signal.decode(Signal.encode(v));
|
||||||
|
expect(back.name).toBe('x');
|
||||||
|
expect(back.samples).toBeInstanceOf(Float64Array);
|
||||||
|
expect(Array.from(back.samples)).toEqual([1, 2, 3, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('oneOf() — discriminated union', () => {
|
||||||
|
clearRegistry();
|
||||||
|
const Event = oneOf('Event', 'kind', {
|
||||||
|
fill: { price: f64, qty: f64 },
|
||||||
|
cancel: { reason: str },
|
||||||
|
});
|
||||||
|
|
||||||
|
const a = { kind: 'fill', price: 100, qty: 0.5 };
|
||||||
|
const b = { kind: 'cancel', reason: 'user' };
|
||||||
|
expect(Event.decode(Event.encode(a as never))).toEqual(a);
|
||||||
|
expect(Event.decode(Event.encode(b as never))).toEqual(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('encodeInto / decodeFrom — pooled writer hot path', () => {
|
||||||
|
clearRegistry();
|
||||||
|
const T = type('Pooled', { x: u53, y: f64 });
|
||||||
|
const w = new Writer(256);
|
||||||
|
|
||||||
|
const v = { x: 42, y: 3.14 };
|
||||||
|
|
||||||
|
w.reset();
|
||||||
|
T.encodeInto(v, w);
|
||||||
|
const bytes = w.bytes();
|
||||||
|
expect(bytes.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const r = new Reader(bytes);
|
||||||
|
expect(T.decodeFrom(r)).toEqual(v);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('router() — framed multi-type dispatch', () => {
|
||||||
|
clearRegistry();
|
||||||
|
const A = type('A', { x: u53 });
|
||||||
|
const B = type('B', { y: str });
|
||||||
|
|
||||||
|
const proto = router(A, B);
|
||||||
|
|
||||||
|
const bytesA = proto.encode({ x: 7 }, A);
|
||||||
|
const bytesB = proto.encode({ y: 'hi' }, B);
|
||||||
|
|
||||||
|
expect(proto.decode(bytesA)).toEqual({ x: 7 });
|
||||||
|
expect(proto.decode(bytesB)).toEqual({ y: 'hi' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('typeof T.$infer — TS inference works at compile time', () => {
|
||||||
|
clearRegistry();
|
||||||
|
const Order = type('OrderInf', {
|
||||||
|
id: u53,
|
||||||
|
price: f64,
|
||||||
|
side: enumOf(['buy', 'sell'] as const),
|
||||||
|
active: bool,
|
||||||
|
tags: list(str),
|
||||||
|
});
|
||||||
|
|
||||||
|
type Order = typeof Order.$infer;
|
||||||
|
|
||||||
|
const v: Order = {
|
||||||
|
id: 1,
|
||||||
|
price: 100,
|
||||||
|
side: 'buy',
|
||||||
|
active: true,
|
||||||
|
tags: ['a'],
|
||||||
|
};
|
||||||
|
expect(Order.decode(Order.encode(v))).toEqual(v);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('codec id is deterministic by name', () => {
|
||||||
|
clearRegistry();
|
||||||
|
const A = type('Same', { x: u53 });
|
||||||
|
clearRegistry();
|
||||||
|
const B = type('Same', { y: f64 });
|
||||||
|
expect(A.id).toBe(B.id);
|
||||||
|
});
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* Compile-time (AOT) vs runtime codegen benchmark.
|
||||||
|
*
|
||||||
|
* The AOT codecs are produced by running our transformer on a sample TS file
|
||||||
|
* at bench startup, writing the result to a temp file, and importing it. The
|
||||||
|
* runtime codecs come from the regular `type(...)` runtime path.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { bench, beforeAll, afterAll, describe } from 'vitest';
|
||||||
|
import { writeFileSync, rmSync, existsSync } from 'node:fs';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
import { transform } from '../../plugin/compile/transformer.ts';
|
||||||
|
import {
|
||||||
|
type as runtimeType,
|
||||||
|
type TypeCodec,
|
||||||
|
u53,
|
||||||
|
f64,
|
||||||
|
str,
|
||||||
|
list,
|
||||||
|
enumOf,
|
||||||
|
flags,
|
||||||
|
clearRegistry,
|
||||||
|
Reader,
|
||||||
|
Writer,
|
||||||
|
} from '../../plugin/index.ts';
|
||||||
|
|
||||||
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const GEN_FILE = join(HERE, '__aot_codecs.ts');
|
||||||
|
|
||||||
|
const AOT_SOURCE = `
|
||||||
|
import { type, u53, f64, str, list, enumOf, flags } from '../../plugin/index.ts';
|
||||||
|
|
||||||
|
export const Ticker = type('AotTicker', {
|
||||||
|
symbol: str,
|
||||||
|
last: f64,
|
||||||
|
bid: f64,
|
||||||
|
ask: f64,
|
||||||
|
volume: f64,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Order = type('AotOrder', {
|
||||||
|
id: u53,
|
||||||
|
account: u53,
|
||||||
|
symbol: str,
|
||||||
|
side: enumOf(['buy', 'sell'] as const),
|
||||||
|
price: f64,
|
||||||
|
qty: f64,
|
||||||
|
filledQty: f64,
|
||||||
|
ts: u53,
|
||||||
|
flags: flags(['ioc', 'post_only', 'reduce_only'] as const),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Level = type('AotLevel', { p: f64, q: f64 });
|
||||||
|
|
||||||
|
export const Book = type('AotBook', {
|
||||||
|
symbol: str,
|
||||||
|
ts: u53,
|
||||||
|
bids: list(Level),
|
||||||
|
asks: list(Level),
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface AotCodec {
|
||||||
|
encode: (v: unknown, w?: Writer) => Uint8Array;
|
||||||
|
decode: (b: Uint8Array) => unknown;
|
||||||
|
encodeInto: (v: unknown, w: Writer) => void;
|
||||||
|
decodeFrom: (r: Reader) => unknown;
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let aot: Record<string, AotCodec>;
|
||||||
|
let rtTicker: TypeCodec<unknown>;
|
||||||
|
let rtOrder: TypeCodec<unknown>;
|
||||||
|
let rtLevel: TypeCodec<unknown>;
|
||||||
|
let rtBook: TypeCodec<unknown>;
|
||||||
|
|
||||||
|
const ticker = {
|
||||||
|
symbol: 'BTC-USD', last: 67891.23, bid: 67890.5, ask: 67892.0, volume: 1234567.89,
|
||||||
|
};
|
||||||
|
const order = {
|
||||||
|
id: 9876543210, account: 12345678, symbol: 'BTC-USD',
|
||||||
|
side: 'buy' as const, price: 67500.5, qty: 0.125, filledQty: 0,
|
||||||
|
ts: 1716100000123,
|
||||||
|
flags: { ioc: false, post_only: true, reduce_only: false },
|
||||||
|
};
|
||||||
|
const book = {
|
||||||
|
symbol: 'BTC-USD',
|
||||||
|
ts: 1716100000123,
|
||||||
|
bids: Array.from({ length: 1000 }, (_, i) => ({ p: 67890 - i * 0.5, q: 0.1 + (i % 100) * 0.01 })),
|
||||||
|
asks: Array.from({ length: 1000 }, (_, i) => ({ p: 67891 + i * 0.5, q: 0.1 + (i % 100) * 0.01 })),
|
||||||
|
};
|
||||||
|
|
||||||
|
const wT = new Writer(256);
|
||||||
|
const wO = new Writer(256);
|
||||||
|
const wB = new Writer(64 * 1024);
|
||||||
|
|
||||||
|
let tickerAot: Uint8Array;
|
||||||
|
let tickerRt: Uint8Array;
|
||||||
|
let orderAot: Uint8Array;
|
||||||
|
let orderRt: Uint8Array;
|
||||||
|
let bookAot: Uint8Array;
|
||||||
|
let bookRt: Uint8Array;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Build the AOT module on the fly.
|
||||||
|
const transformed = transform(AOT_SOURCE, GEN_FILE, {
|
||||||
|
importPath: '../../plugin/index.ts',
|
||||||
|
packageAliases: ['../../plugin/index.ts'],
|
||||||
|
});
|
||||||
|
writeFileSync(GEN_FILE, transformed.code, 'utf8');
|
||||||
|
const url = `${pathToFileURL(GEN_FILE).href}?t=${Date.now()}`;
|
||||||
|
aot = (await import(/* @vite-ignore */ url)) as Record<string, AotCodec>;
|
||||||
|
|
||||||
|
// Runtime equivalents with non-colliding names.
|
||||||
|
clearRegistry();
|
||||||
|
rtTicker = runtimeType('RtTicker', {
|
||||||
|
symbol: str, last: f64, bid: f64, ask: f64, volume: f64,
|
||||||
|
});
|
||||||
|
rtOrder = runtimeType('RtOrder', {
|
||||||
|
id: u53, account: u53, symbol: str,
|
||||||
|
side: enumOf(['buy', 'sell'] as const),
|
||||||
|
price: f64, qty: f64, filledQty: f64, ts: u53,
|
||||||
|
flags: flags(['ioc', 'post_only', 'reduce_only'] as const),
|
||||||
|
});
|
||||||
|
rtLevel = runtimeType('RtLevel', { p: f64, q: f64 });
|
||||||
|
rtBook = runtimeType('RtBook', {
|
||||||
|
symbol: str, ts: u53, bids: list(rtLevel), asks: list(rtLevel),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pre-encode for decode benches
|
||||||
|
tickerAot = aot.Ticker!.encode(ticker);
|
||||||
|
wT.reset(); rtTicker.encodeInto(ticker, wT); tickerRt = wT.bytes().slice();
|
||||||
|
orderAot = aot.Order!.encode(order);
|
||||||
|
wO.reset(); rtOrder.encodeInto(order, wO); orderRt = wO.bytes().slice();
|
||||||
|
bookAot = aot.Book!.encode(book);
|
||||||
|
wB.reset(); rtBook.encodeInto(book, wB); bookRt = wB.bytes().slice();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (existsSync(GEN_FILE)) rmSync(GEN_FILE, { force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('encode ticker (AOT vs runtime)', () => {
|
||||||
|
bench('AOT (compiled)', () => {
|
||||||
|
wT.reset();
|
||||||
|
aot.Ticker!.encodeInto(ticker, wT);
|
||||||
|
});
|
||||||
|
bench('runtime (new Function)', () => {
|
||||||
|
wT.reset();
|
||||||
|
rtTicker.encodeInto(ticker, wT);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('encode order (AOT vs runtime)', () => {
|
||||||
|
bench('AOT (compiled)', () => {
|
||||||
|
wO.reset();
|
||||||
|
aot.Order!.encodeInto(order, wO);
|
||||||
|
});
|
||||||
|
bench('runtime', () => {
|
||||||
|
wO.reset();
|
||||||
|
rtOrder.encodeInto(order, wO);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('encode book 1000 levels (AOT vs runtime)', () => {
|
||||||
|
bench('AOT (compiled)', () => {
|
||||||
|
wB.reset();
|
||||||
|
aot.Book!.encodeInto(book, wB);
|
||||||
|
});
|
||||||
|
bench('runtime', () => {
|
||||||
|
wB.reset();
|
||||||
|
rtBook.encodeInto(book, wB);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('decode ticker (AOT vs runtime)', () => {
|
||||||
|
bench('AOT (compiled)', () => {
|
||||||
|
const r = new Reader(tickerAot);
|
||||||
|
aot.Ticker!.decodeFrom(r);
|
||||||
|
});
|
||||||
|
bench('runtime', () => {
|
||||||
|
const r = new Reader(tickerRt);
|
||||||
|
rtTicker.decodeFrom(r);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('decode book 1000 levels (AOT vs runtime)', () => {
|
||||||
|
bench('AOT (compiled)', () => {
|
||||||
|
const r = new Reader(bookAot);
|
||||||
|
aot.Book!.decodeFrom(r);
|
||||||
|
});
|
||||||
|
bench('runtime', () => {
|
||||||
|
const r = new Reader(bookRt);
|
||||||
|
rtBook.decodeFrom(r);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { bench, describe } from 'vitest';
|
||||||
|
import { Reader, Writer, deserialize, serialize } from '../../plugin/index.ts';
|
||||||
|
import {
|
||||||
|
buildBook,
|
||||||
|
buildOrder,
|
||||||
|
buildTicker,
|
||||||
|
registerAll,
|
||||||
|
} from './payloads.ts';
|
||||||
|
|
||||||
|
const codecs = registerAll();
|
||||||
|
|
||||||
|
const ticker = buildTicker();
|
||||||
|
const order = buildOrder();
|
||||||
|
const book = buildBook(1000);
|
||||||
|
|
||||||
|
// Pre-allocated pooled Writers (sized generously so we don't measure grow()).
|
||||||
|
const wTicker = new Writer(256);
|
||||||
|
const wOrder = new Writer(256);
|
||||||
|
const wBook = new Writer(64 * 1024);
|
||||||
|
|
||||||
|
// Pre-encoded buffers for decode benches.
|
||||||
|
const tickerJSON = JSON.stringify(ticker);
|
||||||
|
const orderJSON = JSON.stringify(order);
|
||||||
|
const bookJSON = JSON.stringify(book);
|
||||||
|
|
||||||
|
const tickerBin = serialize(ticker, codecs.ticker);
|
||||||
|
const orderBin = serialize(order, codecs.order);
|
||||||
|
const bookBin = serialize(book, codecs.book);
|
||||||
|
|
||||||
|
// One-time payload-size print on module load so it appears once in bench output.
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(
|
||||||
|
'\n--- payload sizes ---\n' +
|
||||||
|
`ticker | json: ${tickerJSON.length}b bin: ${tickerBin.length}b (${((tickerBin.length / tickerJSON.length) * 100).toFixed(0)}%)\n` +
|
||||||
|
`order | json: ${orderJSON.length}b bin: ${orderBin.length}b (${((orderBin.length / orderJSON.length) * 100).toFixed(0)}%)\n` +
|
||||||
|
`book | json: ${bookJSON.length}b bin: ${bookBin.length}b (${((bookBin.length / bookJSON.length) * 100).toFixed(0)}%)\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('encode ticker (5 fields)', () => {
|
||||||
|
bench('JSON.stringify', () => {
|
||||||
|
JSON.stringify(ticker);
|
||||||
|
});
|
||||||
|
bench('codec.encode (pooled)', () => {
|
||||||
|
wTicker.reset();
|
||||||
|
codecs.ticker.encode(wTicker, ticker);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('encode order (10 fields + bitset)', () => {
|
||||||
|
bench('JSON.stringify', () => {
|
||||||
|
JSON.stringify(order);
|
||||||
|
});
|
||||||
|
bench('codec.encode (pooled)', () => {
|
||||||
|
wOrder.reset();
|
||||||
|
codecs.order.encode(wOrder, order);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('encode book (1000 levels)', () => {
|
||||||
|
bench('JSON.stringify', () => {
|
||||||
|
JSON.stringify(book);
|
||||||
|
});
|
||||||
|
bench('codec.encode (pooled)', () => {
|
||||||
|
wBook.reset();
|
||||||
|
codecs.book.encode(wBook, book);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('decode ticker', () => {
|
||||||
|
bench('JSON.parse', () => {
|
||||||
|
JSON.parse(tickerJSON);
|
||||||
|
});
|
||||||
|
bench('codec.decode', () => {
|
||||||
|
const r = new Reader(tickerBin);
|
||||||
|
r.pos = 2;
|
||||||
|
codecs.ticker.decode(r);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('decode order', () => {
|
||||||
|
bench('JSON.parse', () => {
|
||||||
|
JSON.parse(orderJSON);
|
||||||
|
});
|
||||||
|
bench('codec.decode', () => {
|
||||||
|
const r = new Reader(orderBin);
|
||||||
|
r.pos = 2;
|
||||||
|
codecs.order.decode(r);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('decode book (1000 levels)', () => {
|
||||||
|
bench('JSON.parse', () => {
|
||||||
|
JSON.parse(bookJSON);
|
||||||
|
});
|
||||||
|
bench('codec.decode', () => {
|
||||||
|
const r = new Reader(bookBin);
|
||||||
|
r.pos = 2;
|
||||||
|
codecs.book.decode(r);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('roundtrip ticker', () => {
|
||||||
|
bench('JSON', () => {
|
||||||
|
JSON.parse(JSON.stringify(ticker));
|
||||||
|
});
|
||||||
|
bench('codec (pooled)', () => {
|
||||||
|
wTicker.reset();
|
||||||
|
codecs.ticker.encode(wTicker, ticker);
|
||||||
|
const r = new Reader(wTicker.bytes());
|
||||||
|
codecs.ticker.decode(r);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('serialize+deserialize ticker (with frame)', () => {
|
||||||
|
bench('JSON', () => {
|
||||||
|
JSON.parse(JSON.stringify(ticker));
|
||||||
|
});
|
||||||
|
bench('serialize/deserialize (framed)', () => {
|
||||||
|
deserialize(serialize(ticker, codecs.ticker));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { defineSchema, register, s } from '../../plugin/index.ts';
|
||||||
|
import type { Codec } from '../../plugin/index.ts';
|
||||||
|
|
||||||
|
export const TickerSchema = defineSchema('BenchTicker', (s) => ({
|
||||||
|
symbol: s.str,
|
||||||
|
last: s.f64,
|
||||||
|
bid: s.f64,
|
||||||
|
ask: s.f64,
|
||||||
|
volume: s.f64,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const OrderSchema = defineSchema('BenchOrder', (s) => ({
|
||||||
|
id: s.u53,
|
||||||
|
account: s.u53,
|
||||||
|
symbol: s.str,
|
||||||
|
side: s.enum(['buy', 'sell'] as const),
|
||||||
|
type: s.enum(['limit', 'market', 'stop', 'stop_limit'] as const),
|
||||||
|
price: s.f64,
|
||||||
|
qty: s.f64,
|
||||||
|
filledQty: s.f64,
|
||||||
|
ts: s.u53,
|
||||||
|
flags: s.bitset(['ioc', 'post_only', 'reduce_only'] as const),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const LevelSchema = defineSchema('BenchLevel', (s) => ({
|
||||||
|
p: s.f64,
|
||||||
|
q: s.f64,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const BookSchema = defineSchema('BenchBook', (s) => ({
|
||||||
|
symbol: s.str,
|
||||||
|
ts: s.u53,
|
||||||
|
bids: s.array(LevelSchema),
|
||||||
|
asks: s.array(LevelSchema),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export interface Ticker {
|
||||||
|
symbol: string;
|
||||||
|
last: number;
|
||||||
|
bid: number;
|
||||||
|
ask: number;
|
||||||
|
volume: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Order {
|
||||||
|
id: number;
|
||||||
|
account: number;
|
||||||
|
symbol: string;
|
||||||
|
side: 'buy' | 'sell';
|
||||||
|
type: 'limit' | 'market' | 'stop' | 'stop_limit';
|
||||||
|
price: number;
|
||||||
|
qty: number;
|
||||||
|
filledQty: number;
|
||||||
|
ts: number;
|
||||||
|
flags: { ioc: boolean; post_only: boolean; reduce_only: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Level {
|
||||||
|
p: number;
|
||||||
|
q: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Book {
|
||||||
|
symbol: string;
|
||||||
|
ts: number;
|
||||||
|
bids: Level[];
|
||||||
|
asks: Level[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTicker(): Ticker {
|
||||||
|
return {
|
||||||
|
symbol: 'BTC-USD',
|
||||||
|
last: 67891.23,
|
||||||
|
bid: 67890.5,
|
||||||
|
ask: 67892.0,
|
||||||
|
volume: 1234567.89,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOrder(): Order {
|
||||||
|
return {
|
||||||
|
id: 9876543210,
|
||||||
|
account: 12345678,
|
||||||
|
symbol: 'BTC-USD',
|
||||||
|
side: 'buy',
|
||||||
|
type: 'limit',
|
||||||
|
price: 67500.5,
|
||||||
|
qty: 0.125,
|
||||||
|
filledQty: 0,
|
||||||
|
ts: 1716100000123,
|
||||||
|
flags: { ioc: false, post_only: true, reduce_only: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBook(depth: number): Book {
|
||||||
|
const bids: Level[] = new Array(depth);
|
||||||
|
const asks: Level[] = new Array(depth);
|
||||||
|
for (let i = 0; i < depth; i++) {
|
||||||
|
bids[i] = { p: 67890 - i * 0.5, q: 0.1 + (i % 100) * 0.01 };
|
||||||
|
asks[i] = { p: 67891 + i * 0.5, q: 0.1 + (i % 100) * 0.01 };
|
||||||
|
}
|
||||||
|
return { symbol: 'BTC-USD', ts: 1716100000123, bids, asks };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Codecs {
|
||||||
|
ticker: Codec<Ticker>;
|
||||||
|
order: Codec<Order>;
|
||||||
|
level: Codec<Level>;
|
||||||
|
book: Codec<Book>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAll(): Codecs {
|
||||||
|
const ticker = register<Ticker>(TickerSchema);
|
||||||
|
const order = register<Order>(OrderSchema);
|
||||||
|
const level = register<Level>(LevelSchema);
|
||||||
|
const book = register<Book>(BookSchema);
|
||||||
|
return { ticker, order, level, book };
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Benchmark results
|
||||||
|
|
||||||
|
Hardware: Intel Xeon (Icelake) @ 2.46 GHz, Windows Server 2019
|
||||||
|
Runtime: Node.js 24.14.0 (x64)
|
||||||
|
Tool: mitata
|
||||||
|
Date: 2026-05-21
|
||||||
|
|
||||||
|
Reproduce: `npm run bench` from the `serializer/` directory. Numbers below use the avg (the p75 column where they diverge).
|
||||||
|
|
||||||
|
## Payload sizes
|
||||||
|
|
||||||
|
| Workload | JSON bytes | Binary bytes | Binary/JSON ratio |
|
||||||
|
|---|---:|---:|---:|
|
||||||
|
| Ticker (5 fields) | 82 | 42 | **0.51** |
|
||||||
|
| Order (10 fields + bitset) | 203 | 52 | **0.26** |
|
||||||
|
| Book snapshot (1000 levels) | 48,577 | 32,020 | **0.66** |
|
||||||
|
|
||||||
|
## Encode (lower is better)
|
||||||
|
|
||||||
|
| Workload | JSON.stringify | codec.encode (pooled) | Speedup vs JSON |
|
||||||
|
|---|---:|---:|---:|
|
||||||
|
| Ticker | 598.4 ns | **52.2 ns** | **11.5×** |
|
||||||
|
| Order | 1,170 ns | **123.4 ns** | **9.5×** |
|
||||||
|
| Book (1000 levels) | 437 µs | **10.2 µs** | **42.9×** |
|
||||||
|
|
||||||
|
## Decode (lower is better)
|
||||||
|
|
||||||
|
| Workload | JSON.parse | codec.decode | Speedup vs JSON |
|
||||||
|
|---|---:|---:|---:|
|
||||||
|
| Ticker | 696.3 ns | **311.0 ns** | **2.2×** |
|
||||||
|
| Order | 1,440 ns | **360.6 ns** | **4.0×** |
|
||||||
|
| Book (1000 levels) | 497 µs | **24–28 µs** (high GC variance) | **17–20×** |
|
||||||
|
|
||||||
|
## Roundtrip
|
||||||
|
|
||||||
|
| | ns/iter | Note |
|
||||||
|
|---|---:|---|
|
||||||
|
| `JSON.parse(JSON.stringify(...))` | 1,400 ns | baseline |
|
||||||
|
| Pooled codec encode + Reader decode | **418 ns** | **3.35× faster** |
|
||||||
|
| Un-pooled `serialize` + `deserialize` (framed) | 2,180 ns | 1.55× slower |
|
||||||
|
|
||||||
|
The un-pooled `serialize()` allocates a fresh Writer + DataView + Uint8Array on every call. Hot paths must pool a Writer.
|
||||||
|
|
||||||
|
## What changed vs v1 baseline
|
||||||
|
|
||||||
|
The v1 codec used method-call style for every operation: every `w.f64(v)` was a method dispatch with internal property reads on `this.buf`, `this.view`, `this.pos`. The optimized codec restructures the generated functions around four V8-friendly patterns:
|
||||||
|
|
||||||
|
| # | Optimization | Effect |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Lift `pos`, `buf`, `view` to function-local `let/const` at start; sync `w.pos = pos` at end | Replaces N×3 property loads with N register reads |
|
||||||
|
| 2 | Inline all bounded-size ops (`u8`–`f64`, `bool`, varints, `enum`, `bitset`) using the lifted locals | Eliminates the method-call cost per primitive |
|
||||||
|
| 3 | Pre-`ensure` for the bounded prefix of each schema in a single bounds check | One growth check per ~10 fields instead of one per field |
|
||||||
|
| 4 | Inline nested objects/arrays/unions/tuples — no per-element function dispatch | Tight inner loops for array<object> (e.g., order book levels) |
|
||||||
|
| 5 | Closure-captured frozen map for `enum` with ≥4 values; ternary chain for 2–3 | Avoids string-switch overhead |
|
||||||
|
| 6 | For array elements that are themselves bounded, pre-`ensure(L * elementMax)` once outside the loop, then run a loop with no per-iteration ensure | Order-book encode goes from method-per-level to inline-per-level |
|
||||||
|
|
||||||
|
Unbounded leaves (`str`, `bytes`, `typedArray`, `ref`, `codec`) still go through the Writer/Reader methods, with a small sync/refetch dance around the call.
|
||||||
|
|
||||||
|
## Before / after (v1 baseline → v2 optimized, both avg ns)
|
||||||
|
|
||||||
|
| Workload | v1 baseline | v2 optimized | Improvement |
|
||||||
|
|---|---:|---:|---:|
|
||||||
|
| Ticker encode | 77.4 ns | **52.2 ns** | **1.48×** |
|
||||||
|
| Order encode | 130.4 ns | **123.4 ns** | 1.06× |
|
||||||
|
| Book encode | 27.9 µs | **10.2 µs** | **2.73×** |
|
||||||
|
| Ticker decode | 308.4 ns | 311.0 ns | ~same |
|
||||||
|
| Order decode | 368.3 ns | 360.6 ns | ~same |
|
||||||
|
| Book decode | 26.1 µs | 24.4 µs (p75) | 1.07× |
|
||||||
|
|
||||||
|
The decode side gains less than encode because Node's `JSON.parse` was already not the bottleneck — most of the decode time goes to allocating the result object and the string for `symbol`/`reason` fields, which the codec also has to do.
|
||||||
|
|
||||||
|
The book encode at **2.7× faster than v1 baseline (43× faster than JSON.stringify)** is the headline number: inlining the per-level encoder into the outer loop turned 1000 function calls per snapshot into 1000 inline `view.setFloat64(pos, ...)` pairs sharing one `ensure()`.
|
||||||
|
|
||||||
|
## What didn't pan out
|
||||||
|
|
||||||
|
We tried `String.fromCharCode.apply(null, buf.subarray(start, end))` for ASCII strings in the 8–64 char range. On Node 24 it was consistently slower than the simple `s += String.fromCharCode(buf[i])` loop for the short strings dominating exchange payloads — the variadic-args wrapper has its own overhead. Reverted.
|
||||||
|
|
||||||
|
## Generated source — example
|
||||||
|
|
||||||
|
For the Ticker schema (after optimization), the encoder body produced by codegen is:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function encode_BenchTicker(w, o) {
|
||||||
|
let pos = w.pos;
|
||||||
|
let buf = w.buf;
|
||||||
|
let view = w.view;
|
||||||
|
|
||||||
|
if (pos + 33 > buf.byteLength) {
|
||||||
|
w.pos = pos; w.grow(33); buf = w.buf; view = w.view;
|
||||||
|
}
|
||||||
|
// varu53 symbol-length and 4 × f64 are bounded, but the str body itself isn't:
|
||||||
|
// (the str field flushes the bounded prefix, calls w.str, then refetches)
|
||||||
|
|
||||||
|
w.pos = pos; w.str(o["symbol"]); pos = w.pos; buf = w.buf; view = w.view;
|
||||||
|
|
||||||
|
if (pos + 32 > buf.byteLength) {
|
||||||
|
w.pos = pos; w.grow(32); buf = w.buf; view = w.view;
|
||||||
|
}
|
||||||
|
view.setFloat64(pos, o["last"], true); pos += 8;
|
||||||
|
view.setFloat64(pos, o["bid"], true); pos += 8;
|
||||||
|
view.setFloat64(pos, o["ask"], true); pos += 8;
|
||||||
|
view.setFloat64(pos, o["volume"], true); pos += 8;
|
||||||
|
|
||||||
|
w.pos = pos;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No `this.` indirections, no method dispatch for the floats, one ensure for the 4-float run. The result is **52 ns per Ticker encode**, ~12× faster than `JSON.stringify`.
|
||||||
|
|
||||||
|
## Acceptance bar (from plan)
|
||||||
|
|
||||||
|
| Target | Actual | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| Encode ≥ 3× faster than JSON.stringify on medium-order workload | 9.5× | exceeded |
|
||||||
|
| Decode ≥ 5× faster than JSON.parse on order-book workload | 17–20× | exceeded |
|
||||||
|
| Payload ≤ 60% of JSON byte length on numeric-heavy data | 26% (Order) / 66% (Book) | partial (Book is f64-dense, little to compress) |
|
||||||
|
| Zero deopt events on hot benchmark loop | one-time OSR transition only | acceptable |
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { test, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
clearRegistry,
|
||||||
|
defineSchema,
|
||||||
|
deserialize,
|
||||||
|
registerClass,
|
||||||
|
Serializable,
|
||||||
|
serialize,
|
||||||
|
} from '../plugin/index.ts';
|
||||||
|
|
||||||
|
test('class with [Serializable] static schema registers and round-trips', () => {
|
||||||
|
clearRegistry();
|
||||||
|
|
||||||
|
class Order {
|
||||||
|
id!: number;
|
||||||
|
price!: number;
|
||||||
|
qty!: number;
|
||||||
|
side!: 'buy' | 'sell';
|
||||||
|
|
||||||
|
static [Serializable] = defineSchema('OrderClass', (s) => ({
|
||||||
|
id: s.u53,
|
||||||
|
price: s.f64,
|
||||||
|
qty: s.f64,
|
||||||
|
side: s.enum(['buy', 'sell'] as const),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const codec = registerClass(Order);
|
||||||
|
|
||||||
|
const v = { id: 42, price: 100.5, qty: 1.5, side: 'buy' as const };
|
||||||
|
const bytes = serialize(v, codec);
|
||||||
|
const decoded = deserialize<typeof v>(bytes);
|
||||||
|
expect(decoded).toEqual(v);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registerClass caches by constructor', () => {
|
||||||
|
clearRegistry();
|
||||||
|
|
||||||
|
class A {
|
||||||
|
static [Serializable] = defineSchema('AClass', (s) => ({ x: s.u8 }));
|
||||||
|
}
|
||||||
|
const c1 = registerClass(A);
|
||||||
|
const c2 = registerClass(A);
|
||||||
|
expect(c1).toBe(c2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registerClass throws for class missing [Serializable]', () => {
|
||||||
|
clearRegistry();
|
||||||
|
|
||||||
|
class B {}
|
||||||
|
|
||||||
|
expect(() => registerClass(B)).toThrow(/\[Serializable\] schema/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Symbol.serializable is shared across module boundaries via Symbol.for', () => {
|
||||||
|
const looked = Symbol.for('@perf/serializable');
|
||||||
|
expect(looked).toBe(Serializable);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('codec.id is deterministic for the schema name', () => {
|
||||||
|
clearRegistry();
|
||||||
|
const A = defineSchema('SameName', (s) => ({ x: s.u8 }));
|
||||||
|
|
||||||
|
clearRegistry();
|
||||||
|
const codecA = registerClass(
|
||||||
|
class extends Object {
|
||||||
|
static [Serializable] = A;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
clearRegistry();
|
||||||
|
const codecB = registerClass(
|
||||||
|
class extends Object {
|
||||||
|
static [Serializable] = A;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(codecA.id).toBe(codecB.id);
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { test, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
clearRegistry,
|
||||||
|
defineSchema,
|
||||||
|
deserialize,
|
||||||
|
register,
|
||||||
|
s,
|
||||||
|
serialize,
|
||||||
|
} from '../plugin/index.ts';
|
||||||
|
|
||||||
|
function rng(seed: number): () => number {
|
||||||
|
let a = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
a = (a + 0x6d2b79f5) >>> 0;
|
||||||
|
let t = a;
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = rng(0xc0ffee);
|
||||||
|
|
||||||
|
function randFloat(): number {
|
||||||
|
const bucket = Math.floor(r() * 6);
|
||||||
|
switch (bucket) {
|
||||||
|
case 0: return 0;
|
||||||
|
case 1: return r() * 100;
|
||||||
|
case 2: return r() * 1e10;
|
||||||
|
case 3: return -r() * 100;
|
||||||
|
case 4: return (r() - 0.5) * 1e-6;
|
||||||
|
default: return r() * 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function randInt(maxBits = 32): number {
|
||||||
|
const v = Math.floor(r() * 2 ** maxBits);
|
||||||
|
return v >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function randString(): string {
|
||||||
|
const len = Math.floor(r() * 30);
|
||||||
|
let s = '';
|
||||||
|
for (let i = 0; i < len; i++) s += String.fromCharCode(32 + Math.floor(r() * 95));
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('fuzz: 2000 random ticker round-trips', () => {
|
||||||
|
clearRegistry();
|
||||||
|
const Ticker = defineSchema('FuzzTicker', (s) => ({
|
||||||
|
symbol: s.str,
|
||||||
|
last: s.f64,
|
||||||
|
volume: s.f64,
|
||||||
|
count: s.u32,
|
||||||
|
asks: s.array(s.f64),
|
||||||
|
}));
|
||||||
|
const codec = register(Ticker);
|
||||||
|
|
||||||
|
for (let i = 0; i < 2000; i++) {
|
||||||
|
const v = {
|
||||||
|
symbol: randString(),
|
||||||
|
last: randFloat(),
|
||||||
|
volume: randFloat(),
|
||||||
|
count: randInt(32),
|
||||||
|
asks: Array.from({ length: Math.floor(r() * 10) }, randFloat),
|
||||||
|
};
|
||||||
|
expect(deserialize(serialize(v, codec)), `iteration ${i}`).toEqual(v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fuzz: 1000 random nested orders', () => {
|
||||||
|
clearRegistry();
|
||||||
|
const Price = defineSchema('FuzzPrice', (s) => ({ value: s.f64, scale: s.u8 }));
|
||||||
|
register(Price);
|
||||||
|
const Order = defineSchema('FuzzOrder', (s) => ({
|
||||||
|
id: s.u53,
|
||||||
|
symbol: s.str,
|
||||||
|
price: Price,
|
||||||
|
qty: s.f64,
|
||||||
|
side: s.enum(['buy', 'sell'] as const),
|
||||||
|
tags: s.array(s.str),
|
||||||
|
flags: s.bitset(['ioc', 'post_only', 'reduce_only'] as const),
|
||||||
|
}));
|
||||||
|
const codec = register(Order);
|
||||||
|
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
const v = {
|
||||||
|
id: Math.floor(r() * 2 ** 40),
|
||||||
|
symbol: randString(),
|
||||||
|
price: { value: randFloat(), scale: randInt(8) & 0xff },
|
||||||
|
qty: randFloat(),
|
||||||
|
side: (r() < 0.5 ? 'buy' : 'sell') as 'buy' | 'sell',
|
||||||
|
tags: Array.from({ length: Math.floor(r() * 5) }, randString),
|
||||||
|
flags: {
|
||||||
|
ioc: r() < 0.5,
|
||||||
|
post_only: r() < 0.5,
|
||||||
|
reduce_only: r() < 0.5,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(deserialize(serialize(v, codec)), `iteration ${i}`).toEqual(v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fuzz: 500 random unions', () => {
|
||||||
|
clearRegistry();
|
||||||
|
const Event = s.union('FuzzEvent', 'kind', {
|
||||||
|
fill: { price: s.f64, qty: s.f64 },
|
||||||
|
cancel: { reason: s.str },
|
||||||
|
expire: { at: s.u53 },
|
||||||
|
});
|
||||||
|
const codec = register(Event);
|
||||||
|
|
||||||
|
for (let i = 0; i < 500; i++) {
|
||||||
|
const which = Math.floor(r() * 3);
|
||||||
|
let v: unknown;
|
||||||
|
if (which === 0) v = { kind: 'fill', price: randFloat(), qty: randFloat() };
|
||||||
|
else if (which === 1) v = { kind: 'cancel', reason: randString() };
|
||||||
|
else v = { kind: 'expire', at: Math.floor(r() * 2 ** 40) };
|
||||||
|
|
||||||
|
expect(deserialize(serialize(v, codec)), `iteration ${i}`).toEqual(v);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { test, expect } from 'vitest';
|
||||||
|
import { Reader, Writer } from '../plugin/io.ts';
|
||||||
|
|
||||||
|
function roundtrip<T>(write: (w: Writer) => void, read: (r: Reader) => T): T {
|
||||||
|
const w = new Writer(16);
|
||||||
|
write(w);
|
||||||
|
return read(new Reader(w.bytes()));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('u8/u16/u32 round-trip with boundary values', () => {
|
||||||
|
for (const v of [0, 1, 127, 128, 255]) {
|
||||||
|
expect(roundtrip((w) => w.u8(v), (r) => r.u8())).toBe(v);
|
||||||
|
}
|
||||||
|
for (const v of [0, 1, 0xff, 0x100, 0xffff]) {
|
||||||
|
expect(roundtrip((w) => w.u16(v), (r) => r.u16())).toBe(v);
|
||||||
|
}
|
||||||
|
for (const v of [0, 1, 0xffff, 0x10000, 0xffffffff]) {
|
||||||
|
expect(roundtrip((w) => w.u32(v), (r) => r.u32())).toBe(v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('i16/i32 signed round-trip including negatives', () => {
|
||||||
|
for (const v of [-32768, -1, 0, 1, 32767]) {
|
||||||
|
expect(roundtrip((w) => w.i16(v), (r) => r.i16())).toBe(v);
|
||||||
|
}
|
||||||
|
for (const v of [-2147483648, -1, 0, 1, 2147483647]) {
|
||||||
|
expect(roundtrip((w) => w.i32(v), (r) => r.i32())).toBe(v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('f32/f64 round-trip including special values', () => {
|
||||||
|
for (const v of [0, -0, 1, -1, 3.14159, Infinity, -Infinity]) {
|
||||||
|
expect(roundtrip((w) => w.f64(v), (r) => r.f64())).toBe(v);
|
||||||
|
}
|
||||||
|
expect(Number.isNaN(roundtrip((w) => w.f64(NaN), (r) => r.f64()))).toBe(true);
|
||||||
|
for (const v of [0, 1, -1, 0.5, -0.5, 2.0, 1024, -1024, 0.125]) {
|
||||||
|
expect(roundtrip((w) => w.f32(v), (r) => r.f32())).toBe(v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('varu32 LEB128 round-trip including 5-byte values', () => {
|
||||||
|
const cases = [0, 1, 127, 128, 16383, 16384, 0x1fffff, 0x10000000, 0xffffffff];
|
||||||
|
for (const v of cases) {
|
||||||
|
expect(roundtrip((w) => w.varu32(v), (r) => r.varu32())).toBe(v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('varu32 byte lengths follow LEB128 spec', () => {
|
||||||
|
const sizes: Array<[number, number]> = [
|
||||||
|
[0, 1],
|
||||||
|
[127, 1],
|
||||||
|
[128, 2],
|
||||||
|
[16383, 2],
|
||||||
|
[16384, 3],
|
||||||
|
[0x1fffff, 3],
|
||||||
|
[0x200000, 4],
|
||||||
|
[0xfffffff, 4],
|
||||||
|
[0x10000000, 5],
|
||||||
|
];
|
||||||
|
for (const [v, expectedSize] of sizes) {
|
||||||
|
const w = new Writer(16);
|
||||||
|
w.varu32(v);
|
||||||
|
expect(w.pos, `varu32(${v}) should be ${expectedSize} bytes`).toBe(expectedSize);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vari32 zigzag round-trip', () => {
|
||||||
|
const cases = [0, -1, 1, -2, 2, -64, 63, -8192, 8191, -2147483648, 2147483647];
|
||||||
|
for (const v of cases) {
|
||||||
|
expect(roundtrip((w) => w.vari32(v), (r) => r.vari32())).toBe(v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('varu53 round-trip up to 2^53', () => {
|
||||||
|
const cases = [
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
127,
|
||||||
|
128,
|
||||||
|
2 ** 16,
|
||||||
|
2 ** 32 - 1,
|
||||||
|
2 ** 32,
|
||||||
|
2 ** 40,
|
||||||
|
Number.MAX_SAFE_INTEGER,
|
||||||
|
];
|
||||||
|
for (const v of cases) {
|
||||||
|
expect(roundtrip((w) => w.varu53(v), (r) => r.varu53())).toBe(v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vari53 round-trip', () => {
|
||||||
|
const cases = [
|
||||||
|
0,
|
||||||
|
-1,
|
||||||
|
1,
|
||||||
|
-(2 ** 30),
|
||||||
|
2 ** 30,
|
||||||
|
Number.MIN_SAFE_INTEGER,
|
||||||
|
Number.MAX_SAFE_INTEGER,
|
||||||
|
];
|
||||||
|
for (const v of cases) {
|
||||||
|
expect(roundtrip((w) => w.vari53(v), (r) => r.vari53())).toBe(v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('varbu/varbi bigint round-trip', () => {
|
||||||
|
const u: bigint[] = [0n, 1n, 127n, 128n, 1n << 32n, 1n << 63n, (1n << 64n) - 1n];
|
||||||
|
for (const v of u) {
|
||||||
|
expect(roundtrip((w) => w.varbu(v), (r) => r.varbu())).toBe(v);
|
||||||
|
}
|
||||||
|
const s: bigint[] = [0n, -1n, 1n, -(1n << 32n), 1n << 32n, -(1n << 63n), (1n << 63n) - 1n];
|
||||||
|
for (const v of s) {
|
||||||
|
expect(roundtrip((w) => w.varbi(v), (r) => r.varbi())).toBe(v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('str round-trip ASCII short and long', () => {
|
||||||
|
for (const s of ['', 'a', 'hello', 'BTC-USD', 'abcdefghijklmnopqrstuvwxyz']) {
|
||||||
|
expect(roundtrip((w) => w.str(s), (r) => r.str())).toBe(s);
|
||||||
|
}
|
||||||
|
const long = 'x'.repeat(200);
|
||||||
|
expect(roundtrip((w) => w.str(long), (r) => r.str())).toBe(long);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('str round-trip non-ASCII', () => {
|
||||||
|
for (const s of ['héllo', 'café', '日本語', '🚀', 'mix αβγ 漢字 🎉']) {
|
||||||
|
expect(roundtrip((w) => w.str(s), (r) => r.str())).toBe(s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bytes round-trip', () => {
|
||||||
|
const data = new Uint8Array([1, 2, 3, 4, 255, 0, 128]);
|
||||||
|
const result = roundtrip(
|
||||||
|
(w) => w.bytesPrefixed(data),
|
||||||
|
(r) => r.bytesPrefixed(),
|
||||||
|
);
|
||||||
|
expect(Array.from(result)).toEqual(Array.from(data));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Writer grows beyond initial capacity', () => {
|
||||||
|
const w = new Writer(4);
|
||||||
|
for (let i = 0; i < 1000; i++) w.u8(i & 0xff);
|
||||||
|
expect(w.pos).toBe(1000);
|
||||||
|
expect(w.buf.byteLength).toBeGreaterThanOrEqual(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Writer reset reuses buffer', () => {
|
||||||
|
const w = new Writer(16);
|
||||||
|
w.u32(42);
|
||||||
|
const cap1 = w.buf.byteLength;
|
||||||
|
w.reset();
|
||||||
|
expect(w.pos).toBe(0);
|
||||||
|
w.u32(99);
|
||||||
|
expect(w.buf.byteLength).toBe(cap1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multi-write/multi-read interleaved', () => {
|
||||||
|
const w = new Writer(16);
|
||||||
|
w.u8(1);
|
||||||
|
w.f64(3.14);
|
||||||
|
w.str('hi');
|
||||||
|
w.varu53(42);
|
||||||
|
|
||||||
|
const r = new Reader(w.bytes());
|
||||||
|
expect(r.u8()).toBe(1);
|
||||||
|
expect(r.f64()).toBe(3.14);
|
||||||
|
expect(r.str()).toBe('hi');
|
||||||
|
expect(r.varu53()).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bool round-trip', () => {
|
||||||
|
for (const v of [true, false]) {
|
||||||
|
expect(roundtrip((w) => w.bool(v), (r) => r.bool())).toBe(v);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { test, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
clearRegistry,
|
||||||
|
defineSchema,
|
||||||
|
deserialize,
|
||||||
|
register,
|
||||||
|
s,
|
||||||
|
serialize,
|
||||||
|
} from '../plugin/index.ts';
|
||||||
|
|
||||||
|
function fresh() {
|
||||||
|
clearRegistry();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('flat object with mixed primitives', () => {
|
||||||
|
fresh();
|
||||||
|
const Ticker = defineSchema('Ticker', (s) => ({
|
||||||
|
symbol: s.str,
|
||||||
|
last: s.f64,
|
||||||
|
volume: s.f64,
|
||||||
|
count: s.u32,
|
||||||
|
}));
|
||||||
|
const codec = register(Ticker);
|
||||||
|
|
||||||
|
const value = { symbol: 'BTC-USD', last: 45123.45, volume: 1234.5678, count: 99999 };
|
||||||
|
const bytes = serialize(value, codec);
|
||||||
|
const decoded = deserialize<typeof value>(bytes);
|
||||||
|
|
||||||
|
expect(decoded).toEqual(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('array of primitives', () => {
|
||||||
|
fresh();
|
||||||
|
const Tags = defineSchema('Tags', (s) => ({
|
||||||
|
items: s.array(s.str),
|
||||||
|
counts: s.array(s.u32),
|
||||||
|
}));
|
||||||
|
const codec = register(Tags);
|
||||||
|
|
||||||
|
const v = { items: ['a', 'b', 'hello'], counts: [1, 2, 3, 4, 5] };
|
||||||
|
expect(deserialize(serialize(v, codec))).toEqual(v);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nested object via inline ObjectSchema', () => {
|
||||||
|
fresh();
|
||||||
|
const Price = defineSchema('Price', (s) => ({ value: s.f64, scale: s.u8 }));
|
||||||
|
const Order = defineSchema('Order', (s) => ({
|
||||||
|
id: s.u53,
|
||||||
|
price: Price,
|
||||||
|
qty: s.f64,
|
||||||
|
}));
|
||||||
|
register(Price);
|
||||||
|
const codec = register(Order);
|
||||||
|
|
||||||
|
const v = { id: 12345, price: { value: 100.5, scale: 2 }, qty: 1.5 };
|
||||||
|
expect(deserialize(serialize(v, codec))).toEqual(v);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('optional fields', () => {
|
||||||
|
fresh();
|
||||||
|
const Maybe = defineSchema('Maybe', (s) => ({
|
||||||
|
a: s.optional(s.str),
|
||||||
|
b: s.optional(s.f64),
|
||||||
|
}));
|
||||||
|
const codec = register(Maybe);
|
||||||
|
|
||||||
|
expect(deserialize(serialize({ a: 'hi', b: 3.14 }, codec))).toEqual({
|
||||||
|
a: 'hi',
|
||||||
|
b: 3.14,
|
||||||
|
});
|
||||||
|
expect(deserialize(serialize({ a: undefined, b: 1 }, codec))).toEqual({
|
||||||
|
a: undefined,
|
||||||
|
b: 1,
|
||||||
|
});
|
||||||
|
expect(deserialize(serialize({ a: undefined, b: undefined }, codec))).toEqual({
|
||||||
|
a: undefined,
|
||||||
|
b: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('enum field', () => {
|
||||||
|
fresh();
|
||||||
|
const Side = defineSchema('SidedOrder', (s) => ({
|
||||||
|
side: s.enum(['buy', 'sell'] as const),
|
||||||
|
qty: s.f64,
|
||||||
|
}));
|
||||||
|
const codec = register(Side);
|
||||||
|
|
||||||
|
for (const side of ['buy', 'sell'] as const) {
|
||||||
|
const v = { side, qty: 1 };
|
||||||
|
expect(deserialize(serialize(v, codec))).toEqual(v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bitset field (≤8 flags)', () => {
|
||||||
|
fresh();
|
||||||
|
const Flags = defineSchema('Flags', (s) => ({
|
||||||
|
flags: s.bitset(['ioc', 'post_only', 'reduce_only'] as const),
|
||||||
|
}));
|
||||||
|
const codec = register(Flags);
|
||||||
|
|
||||||
|
const v = { flags: { ioc: true, post_only: false, reduce_only: true } };
|
||||||
|
expect(deserialize(serialize(v, codec))).toEqual(v);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bitset field (>32 flags uses bigint)', () => {
|
||||||
|
fresh();
|
||||||
|
const flagNames = Array.from({ length: 40 }, (_, i) => `f${i}`) as readonly string[];
|
||||||
|
const Flags = defineSchema('FlagsBig', (s) => ({
|
||||||
|
flags: s.bitset(flagNames as readonly [string, ...string[]]),
|
||||||
|
}));
|
||||||
|
const codec = register(Flags);
|
||||||
|
|
||||||
|
const flags: Record<string, boolean> = {};
|
||||||
|
for (let i = 0; i < 40; i++) flags[`f${i}`] = i % 3 === 0;
|
||||||
|
const v = { flags };
|
||||||
|
expect(deserialize(serialize(v, codec))).toEqual(v);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tuple field', () => {
|
||||||
|
fresh();
|
||||||
|
const Point = defineSchema('Point3D', (s) => ({
|
||||||
|
name: s.str,
|
||||||
|
coord: s.tuple(s.f64, s.f64, s.f64),
|
||||||
|
}));
|
||||||
|
const codec = register(Point);
|
||||||
|
|
||||||
|
const v = { name: 'p', coord: [1.5, 2.5, 3.5] };
|
||||||
|
expect(deserialize(serialize(v, codec))).toEqual(v);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('array of nested objects', () => {
|
||||||
|
fresh();
|
||||||
|
const Level = defineSchema('Level', (s) => ({ price: s.f64, qty: s.f64 }));
|
||||||
|
register(Level);
|
||||||
|
const Book = defineSchema('Book', (s) => ({
|
||||||
|
bids: s.array(Level),
|
||||||
|
asks: s.array(Level),
|
||||||
|
}));
|
||||||
|
const codec = register(Book);
|
||||||
|
|
||||||
|
const v = {
|
||||||
|
bids: [{ price: 100, qty: 1 }, { price: 99, qty: 2 }],
|
||||||
|
asks: [{ price: 101, qty: 0.5 }, { price: 102, qty: 1.5 }, { price: 103, qty: 0.1 }],
|
||||||
|
};
|
||||||
|
expect(deserialize(serialize(v, codec))).toEqual(v);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('union with discriminator', () => {
|
||||||
|
fresh();
|
||||||
|
const Event = s.union('Event', 'kind', {
|
||||||
|
fill: { price: s.f64, qty: s.f64 },
|
||||||
|
cancel: { reason: s.str },
|
||||||
|
expire: { at: s.u53 },
|
||||||
|
});
|
||||||
|
const codec = register(Event);
|
||||||
|
|
||||||
|
const samples = [
|
||||||
|
{ kind: 'fill' as const, price: 100, qty: 0.5 },
|
||||||
|
{ kind: 'cancel' as const, reason: 'user' },
|
||||||
|
{ kind: 'expire' as const, at: 1700000000 },
|
||||||
|
];
|
||||||
|
for (const v of samples) {
|
||||||
|
expect(deserialize(serialize(v, codec))).toEqual(v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('typed array (f64Array) round-trip', () => {
|
||||||
|
fresh();
|
||||||
|
const Signal = defineSchema('Signal', (s) => ({
|
||||||
|
name: s.str,
|
||||||
|
samples: s.f64Array,
|
||||||
|
}));
|
||||||
|
const codec = register(Signal);
|
||||||
|
|
||||||
|
const samples = new Float64Array([1.1, 2.2, 3.3, 4.4, 5.5]);
|
||||||
|
const v = { name: 'sig', samples };
|
||||||
|
const decoded = deserialize<typeof v>(serialize(v, codec));
|
||||||
|
expect(decoded.name).toBe('sig');
|
||||||
|
expect(decoded.samples).toBeInstanceOf(Float64Array);
|
||||||
|
expect(decoded.samples.length).toBe(5);
|
||||||
|
for (let i = 0; i < 5; i++) expect(decoded.samples[i]).toBe(samples[i]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bigint u64/i64 round-trip', () => {
|
||||||
|
fresh();
|
||||||
|
const Big = defineSchema('Big', (s) => ({
|
||||||
|
u: s.u64,
|
||||||
|
i: s.i64,
|
||||||
|
}));
|
||||||
|
const codec = register(Big);
|
||||||
|
const v = { u: 1n << 50n, i: -(1n << 50n) };
|
||||||
|
expect(deserialize(serialize(v, codec))).toEqual(v);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bytes field', () => {
|
||||||
|
fresh();
|
||||||
|
const Blob = defineSchema('Blob', (s) => ({
|
||||||
|
data: s.bytes,
|
||||||
|
}));
|
||||||
|
const codec = register(Blob);
|
||||||
|
const data = new Uint8Array([0, 1, 2, 3, 254, 255]);
|
||||||
|
const decoded = deserialize<{ data: Uint8Array }>(serialize({ data }, codec));
|
||||||
|
expect(Array.from(decoded.data)).toEqual(Array.from(data));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('serialize includes 2-byte schema ID frame', () => {
|
||||||
|
fresh();
|
||||||
|
const Sch = defineSchema('Sch', (s) => ({ x: s.u8 }));
|
||||||
|
const codec = register(Sch);
|
||||||
|
const bytes = serialize({ x: 7 }, codec);
|
||||||
|
expect(bytes.length).toBeGreaterThanOrEqual(3);
|
||||||
|
const id = bytes[0]! | (bytes[1]! << 8);
|
||||||
|
expect(id).toBe(codec.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('large nested order-book payload', () => {
|
||||||
|
fresh();
|
||||||
|
const Level = defineSchema('LvlBig', (s) => ({ p: s.f64, q: s.f64 }));
|
||||||
|
register(Level);
|
||||||
|
const Snap = defineSchema('Snap', (s) => ({
|
||||||
|
symbol: s.str,
|
||||||
|
ts: s.u53,
|
||||||
|
bids: s.array(Level),
|
||||||
|
asks: s.array(Level),
|
||||||
|
}));
|
||||||
|
const codec = register(Snap);
|
||||||
|
|
||||||
|
const bids = Array.from({ length: 1000 }, (_, i) => ({ p: 100 - i * 0.01, q: 1 + i * 0.001 }));
|
||||||
|
const asks = Array.from({ length: 1000 }, (_, i) => ({ p: 100 + i * 0.01, q: 1 + i * 0.001 }));
|
||||||
|
const v = { symbol: 'BTC-USD', ts: 1700000000123, bids, asks };
|
||||||
|
|
||||||
|
const bytes = serialize(v, codec);
|
||||||
|
const decoded = deserialize<typeof v>(bytes);
|
||||||
|
expect(decoded.symbol).toBe(v.symbol);
|
||||||
|
expect(decoded.ts).toBe(v.ts);
|
||||||
|
expect(decoded.bids.length).toBe(1000);
|
||||||
|
expect(decoded.asks.length).toBe(1000);
|
||||||
|
expect(decoded.bids[0]).toEqual(v.bids[0]);
|
||||||
|
expect(decoded.asks[999]).toEqual(v.asks[999]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deserialize unknown schema ID throws', () => {
|
||||||
|
fresh();
|
||||||
|
const bytes = new Uint8Array([0xff, 0xff, 0]);
|
||||||
|
expect(() => deserialize(bytes)).toThrow(/Unknown schema ID/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { test, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
clearRegistry,
|
||||||
|
defineSchema,
|
||||||
|
deserialize,
|
||||||
|
register,
|
||||||
|
s,
|
||||||
|
serialize,
|
||||||
|
} from '../plugin/index.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decoded objects must share key order with the schema field order. Same
|
||||||
|
* key order across instances is V8's strongest signal of a shared hidden
|
||||||
|
* class, which is what the codec's single-object-literal pattern ensures.
|
||||||
|
*/
|
||||||
|
test('decoded objects share key order matching schema field order', () => {
|
||||||
|
clearRegistry();
|
||||||
|
const Order = defineSchema('ShapeOrder', (s) => ({
|
||||||
|
id: s.u53,
|
||||||
|
price: s.f64,
|
||||||
|
qty: s.f64,
|
||||||
|
side: s.enum(['buy', 'sell'] as const),
|
||||||
|
tags: s.array(s.str),
|
||||||
|
}));
|
||||||
|
const codec = register(Order);
|
||||||
|
|
||||||
|
const expectedOrder = ['id', 'price', 'qty', 'side', 'tags'];
|
||||||
|
|
||||||
|
const decoded1 = deserialize<Record<string, unknown>>(
|
||||||
|
serialize({ id: 1, price: 100, qty: 0.5, side: 'buy', tags: ['a'] }, codec),
|
||||||
|
);
|
||||||
|
const decoded2 = deserialize<Record<string, unknown>>(
|
||||||
|
serialize({ id: 999, price: 1e10, qty: 0, side: 'sell', tags: [] }, codec),
|
||||||
|
);
|
||||||
|
const decoded3 = deserialize<Record<string, unknown>>(
|
||||||
|
serialize({ id: 2 ** 40, price: -1, qty: 1234, side: 'buy', tags: ['x', 'y', 'z'] }, codec),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(Object.keys(decoded1)).toEqual(expectedOrder);
|
||||||
|
expect(Object.keys(decoded2)).toEqual(expectedOrder);
|
||||||
|
expect(Object.keys(decoded3)).toEqual(expectedOrder);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decoded value types are consistent across instances', () => {
|
||||||
|
clearRegistry();
|
||||||
|
const T = defineSchema('Types', (s) => ({
|
||||||
|
a: s.u32,
|
||||||
|
b: s.f64,
|
||||||
|
c: s.str,
|
||||||
|
d: s.bool,
|
||||||
|
}));
|
||||||
|
const codec = register(T);
|
||||||
|
|
||||||
|
const types = (o: Record<string, unknown>) =>
|
||||||
|
Object.entries(o).map(([k, v]) => [k, typeof v]);
|
||||||
|
|
||||||
|
const a = deserialize<Record<string, unknown>>(
|
||||||
|
serialize({ a: 1, b: 1.5, c: 'a', d: true }, codec),
|
||||||
|
);
|
||||||
|
const b = deserialize<Record<string, unknown>>(
|
||||||
|
serialize({ a: 0, b: 0, c: '', d: false }, codec),
|
||||||
|
);
|
||||||
|
expect(types(a)).toEqual(types(b));
|
||||||
|
expect(types(a)).toEqual([
|
||||||
|
['a', 'number'],
|
||||||
|
['b', 'number'],
|
||||||
|
['c', 'string'],
|
||||||
|
['d', 'boolean'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nested object key order is stable', () => {
|
||||||
|
clearRegistry();
|
||||||
|
const Price = defineSchema('SPrice', (s) => ({ value: s.f64, scale: s.u8 }));
|
||||||
|
register(Price);
|
||||||
|
const Order = defineSchema('SOrder', (s) => ({
|
||||||
|
id: s.u53,
|
||||||
|
price: Price,
|
||||||
|
qty: s.f64,
|
||||||
|
}));
|
||||||
|
const codec = register(Order);
|
||||||
|
|
||||||
|
const v = { id: 1, price: { value: 100, scale: 2 }, qty: 1 };
|
||||||
|
const d1 = deserialize<Record<string, unknown>>(serialize(v, codec));
|
||||||
|
const d2 = deserialize<Record<string, unknown>>(serialize({ ...v, id: 99 }, codec));
|
||||||
|
|
||||||
|
expect(Object.keys(d1)).toEqual(['id', 'price', 'qty']);
|
||||||
|
expect(Object.keys(d2)).toEqual(['id', 'price', 'qty']);
|
||||||
|
expect(Object.keys(d1.price as Record<string, unknown>)).toEqual(['value', 'scale']);
|
||||||
|
expect(Object.keys(d2.price as Record<string, unknown>)).toEqual(['value', 'scale']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('union decoded objects place discriminator first', () => {
|
||||||
|
clearRegistry();
|
||||||
|
const Event = s.union('SEvent', 'kind', {
|
||||||
|
a: { x: s.u32 },
|
||||||
|
b: { y: s.f64 },
|
||||||
|
});
|
||||||
|
const codec = register(Event);
|
||||||
|
|
||||||
|
const ea = deserialize<Record<string, unknown>>(serialize({ kind: 'a', x: 1 }, codec));
|
||||||
|
const eb = deserialize<Record<string, unknown>>(serialize({ kind: 'b', y: 2.5 }, codec));
|
||||||
|
expect(Object.keys(ea)[0]).toBe('kind');
|
||||||
|
expect(Object.keys(eb)[0]).toBe('kind');
|
||||||
|
});
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { test, expect, afterAll } from 'vitest';
|
||||||
|
import { writeFileSync, rmSync, existsSync } from 'node:fs';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
import { transform } from '../plugin/compile/transformer.ts';
|
||||||
|
|
||||||
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||||
|
// Write gen files directly in the test dir so relative imports `../src/...`
|
||||||
|
// resolve to serializer/src/index.ts.
|
||||||
|
const GEN_DIR = HERE;
|
||||||
|
|
||||||
|
let counter = 0;
|
||||||
|
async function transformAndImport(source: string): Promise<Record<string, unknown>> {
|
||||||
|
const id = ++counter;
|
||||||
|
const file = join(GEN_DIR, `__gen_${id}.ts`);
|
||||||
|
const result = transform(source, file, {
|
||||||
|
importPath: '../plugin/index.ts',
|
||||||
|
packageAliases: ['../plugin/index.ts'],
|
||||||
|
});
|
||||||
|
writeFileSync(file, result.code, 'utf8');
|
||||||
|
// Use file URL + @vite-ignore so vite passes through to native dynamic import.
|
||||||
|
const url = `${pathToFileURL(file).href}?t=${Date.now()}`;
|
||||||
|
const mod = await import(/* @vite-ignore */ url);
|
||||||
|
return mod as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('transformer: flat type round-trip', async () => {
|
||||||
|
const src = `
|
||||||
|
import { type, u53, f64, str } from '../plugin/index.ts';
|
||||||
|
|
||||||
|
export const Ticker = type('TxTicker', {
|
||||||
|
symbol: str,
|
||||||
|
last: f64,
|
||||||
|
volume: f64,
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
const mod = await transformAndImport(src);
|
||||||
|
const Ticker = mod.Ticker as { encode: (v: unknown) => Uint8Array; decode: (b: Uint8Array) => unknown };
|
||||||
|
const v = { symbol: 'BTC-USD', last: 100.5, volume: 1234.5 };
|
||||||
|
expect(Ticker.decode(Ticker.encode(v))).toEqual(v);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('transformer: nested object via local reference', async () => {
|
||||||
|
const src = `
|
||||||
|
import { type, u53, f64 } from '../plugin/index.ts';
|
||||||
|
|
||||||
|
export const Price = type('TxPrice', { value: f64, scale: u53 });
|
||||||
|
export const Order = type('TxOrder', { id: u53, price: Price, qty: f64 });
|
||||||
|
`;
|
||||||
|
const mod = await transformAndImport(src);
|
||||||
|
const Order = mod.Order as { encode: (v: unknown) => Uint8Array; decode: (b: Uint8Array) => unknown };
|
||||||
|
const v = { id: 42, price: { value: 100.5, scale: 2 }, qty: 0.5 };
|
||||||
|
expect(Order.decode(Order.encode(v))).toEqual(v);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('transformer: combinators (list, opt, enumOf, flags, tuple)', async () => {
|
||||||
|
const src = `
|
||||||
|
import { type, u53, f64, str, list, opt, enumOf, flags, tuple } from '../plugin/index.ts';
|
||||||
|
|
||||||
|
export const Combo = type('TxCombo', {
|
||||||
|
tags: list(str),
|
||||||
|
maybe: opt(f64),
|
||||||
|
side: enumOf(['buy', 'sell'] as const),
|
||||||
|
f: flags(['ioc', 'post_only'] as const),
|
||||||
|
point: tuple(f64, f64),
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
const mod = await transformAndImport(src);
|
||||||
|
const Combo = mod.Combo as { encode: (v: unknown) => Uint8Array; decode: (b: Uint8Array) => unknown };
|
||||||
|
const v = {
|
||||||
|
tags: ['a', 'b'],
|
||||||
|
maybe: 3.14,
|
||||||
|
side: 'buy',
|
||||||
|
f: { ioc: true, post_only: false },
|
||||||
|
point: [1, 2],
|
||||||
|
};
|
||||||
|
expect(Combo.decode(Combo.encode(v))).toEqual(v);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('transformer: anonymous (no name) — uses const name as schema name', async () => {
|
||||||
|
const src = `
|
||||||
|
import { type, u53, f64 } from '../plugin/index.ts';
|
||||||
|
|
||||||
|
export const TxAnon = type({ x: u53, y: f64 });
|
||||||
|
`;
|
||||||
|
const mod = await transformAndImport(src);
|
||||||
|
const T = mod.TxAnon as {
|
||||||
|
encode: (v: unknown) => Uint8Array;
|
||||||
|
decode: (b: Uint8Array) => unknown;
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
expect(T.name).toBe('TxAnon');
|
||||||
|
const v = { x: 1, y: 2.5 };
|
||||||
|
expect(T.decode(T.encode(v))).toEqual(v);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('transformer: array of nested objects (the OrderBook hot path)', async () => {
|
||||||
|
const src = `
|
||||||
|
import { type, u53, f64, str, list } from '../plugin/index.ts';
|
||||||
|
|
||||||
|
export const Level = type('TxLevel', { p: f64, q: f64 });
|
||||||
|
export const Book = type('TxBook', {
|
||||||
|
symbol: str,
|
||||||
|
ts: u53,
|
||||||
|
bids: list(Level),
|
||||||
|
asks: list(Level),
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
const mod = await transformAndImport(src);
|
||||||
|
const Book = mod.Book as { encode: (v: unknown) => Uint8Array; decode: (b: Uint8Array) => unknown };
|
||||||
|
const v = {
|
||||||
|
symbol: 'BTC-USD',
|
||||||
|
ts: 1700000000000,
|
||||||
|
bids: Array.from({ length: 100 }, (_, i) => ({ p: 100 - i * 0.1, q: 0.5 + i * 0.01 })),
|
||||||
|
asks: Array.from({ length: 100 }, (_, i) => ({ p: 100 + i * 0.1, q: 0.5 + i * 0.01 })),
|
||||||
|
};
|
||||||
|
const decoded = Book.decode(Book.encode(v)) as typeof v;
|
||||||
|
expect(decoded.symbol).toBe(v.symbol);
|
||||||
|
expect(decoded.ts).toBe(v.ts);
|
||||||
|
expect(decoded.bids.length).toBe(100);
|
||||||
|
expect(decoded.bids[0]).toEqual(v.bids[0]);
|
||||||
|
expect(decoded.asks[99]).toEqual(v.asks[99]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('transformer: file without type() imports — unchanged', () => {
|
||||||
|
const src = `
|
||||||
|
import { foo } from 'somewhere';
|
||||||
|
const x = foo();
|
||||||
|
`;
|
||||||
|
const result = transform(src, 'test.ts', { importPath: '../plugin/index.ts' });
|
||||||
|
expect(result.transformedCount).toBe(0);
|
||||||
|
expect(result.code).toBe(src);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('transformer: file with type import but no calls — adds nothing', () => {
|
||||||
|
const src = `
|
||||||
|
import { type, u53 } from '../plugin/index.ts';
|
||||||
|
// no type() call here
|
||||||
|
const x = 1;
|
||||||
|
`;
|
||||||
|
const result = transform(src, 'test.ts', {
|
||||||
|
importPath: '../plugin/index.ts',
|
||||||
|
packageAliases: ['../plugin/index.ts'],
|
||||||
|
});
|
||||||
|
expect(result.transformedCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('transformer: replaces call with IIFE (smoke check on output)', () => {
|
||||||
|
const src = `
|
||||||
|
import { type, u53, f64 } from '../plugin/index.ts';
|
||||||
|
export const T = type('TxSmoke', { x: u53, y: f64 });
|
||||||
|
`;
|
||||||
|
const result = transform(src, 'test.ts', {
|
||||||
|
importPath: '../plugin/index.ts',
|
||||||
|
packageAliases: ['../plugin/index.ts'],
|
||||||
|
});
|
||||||
|
expect(result.transformedCount).toBe(1);
|
||||||
|
expect(result.code).toContain('function encode_TxSmoke');
|
||||||
|
expect(result.code).toContain('function decode_TxSmoke');
|
||||||
|
expect(result.code).not.toContain("type('TxSmoke'");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
for (let i = 1; i <= counter; i++) {
|
||||||
|
const file = join(GEN_DIR, `__gen_${i}.ts`);
|
||||||
|
if (existsSync(file)) rmSync(file, { force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "esnext",
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": false,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"verbatimModuleSyntax": false
|
||||||
|
},
|
||||||
|
"include": ["plugin/**/*.ts", "src/**/*.ts", "test/**/*.ts", "vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { DevTools } from '@vitejs/devtools';
|
||||||
|
import { serializerCodegen } from './plugin/compile/vite.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vite config for the example app at `src/main.ts`.
|
||||||
|
*
|
||||||
|
* Two things wired up:
|
||||||
|
*
|
||||||
|
* 1. **Path alias** — `@perf/serializer` resolves to `./plugin/index.ts`.
|
||||||
|
* The example code (and any consumer) writes `from '@perf/serializer'`
|
||||||
|
* without ever touching relative paths.
|
||||||
|
*
|
||||||
|
* 2. **Compile-only AOT plugin** — every `type(...)` and `oneOf(...)` call
|
||||||
|
* found in the source is replaced at build time with an inline codec
|
||||||
|
* literal. The runtime never calls `new Function`. CSP-safe, tree-shakeable,
|
||||||
|
* no first-call warmup.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
devtools: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
rolldownOptions: {
|
||||||
|
devtools: {}, // enable devtools mode
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@perf/serializer': fileURLToPath(new URL('./plugin/index.ts', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [serializerCodegen(), DevTools()],
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
// Standalone config — don't search upward for vite.config in parent dirs.
|
||||||
|
configFile: false,
|
||||||
|
test: {
|
||||||
|
include: ['test/**/*.test.ts'],
|
||||||
|
environment: 'node',
|
||||||
|
globals: false,
|
||||||
|
benchmark: {
|
||||||
|
include: ['test/**/*.bench.ts'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user