feat(serializer): add aot serializer

This commit is contained in:
2026-05-21 09:11:51 +00:00
parent 6f417ba514
commit f327e64a6a
30 changed files with 6720 additions and 0 deletions
+208
View File
@@ -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]);