388 lines
12 KiB
TypeScript
388 lines
12 KiB
TypeScript
/**
|
||
* 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,
|
||
Serializable,
|
||
} 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),
|
||
});
|
||
|
||
type Order = typeof Order.$infer;
|
||
|
||
const marketOrder = {
|
||
id: 1,
|
||
side: 'buy' as const,
|
||
qty: 0.5,
|
||
price: undefined,
|
||
flags: { ioc: true, post_only: false, reduce_only: false },
|
||
} satisfies Order;
|
||
|
||
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]);
|
||
|
||
// ─── Example 7: class with [Symbol.serializable] contract ──────────────────
|
||
//
|
||
// Attach the schema to a class via the well-known `Symbol.serializable`. The
|
||
// AOT plugin sees this and:
|
||
// • generates a class-aware decoder that returns `Object.create(Position.prototype)`
|
||
// instances, so methods/getters work on decoded values;
|
||
// • auto-registers the codec into the runtime registry on module load — no
|
||
// `registerClass(Position)` call needed;
|
||
// • makes `Position[Serializable]` the codec itself, usable directly.
|
||
|
||
class Position {
|
||
side!: 'long' | 'short';
|
||
qty!: number;
|
||
entryPrice!: number;
|
||
|
||
static [Serializable] = type('Position', {
|
||
side: enumOf(['long', 'short'] as const),
|
||
qty: f64,
|
||
entryPrice: f64,
|
||
});
|
||
|
||
get notional(): number {
|
||
return this.qty * this.entryPrice;
|
||
}
|
||
|
||
pnl(currentPrice: number): number {
|
||
return this.side === 'long'
|
||
? (currentPrice - this.entryPrice) * this.qty
|
||
: (this.entryPrice - currentPrice) * this.qty;
|
||
}
|
||
}
|
||
|
||
const pos = Object.assign(new Position(), {
|
||
side: 'long' as const,
|
||
qty: 2,
|
||
entryPrice: 67500,
|
||
});
|
||
|
||
// `Position[Serializable]` IS the codec — no `registerClass(...)` needed.
|
||
const PositionCodec = Position[Serializable]!;
|
||
const posBytes = PositionCodec.encode(pos);
|
||
const back = PositionCodec.decode(posBytes) as Position;
|
||
|
||
log('\nExample 7: class with Symbol.serializable');
|
||
log(` encoded: ${posBytes.length}b`);
|
||
log(` pos.notional: ${pos.notional}`);
|
||
log(` back.notional: ${back.notional} (method works on decoded value after AOT)`);
|
||
log(` back instanceof Position: ${back instanceof Position}`);
|
||
log(` back.pnl(68000): ${back.pnl(68000)}`);
|
||
|
||
// ─── Example 8: deeply-nested portfolio (5 levels, ~770 objects) ────────────
|
||
//
|
||
// Realistic deep-tree data: Portfolio → Accounts → Holdings → Trades →
|
||
// Executions. Demonstrates that the codec handles arbitrary nesting; under AOT
|
||
// the inner-loop encoder/decoder is fully inlined — no per-level function
|
||
// dispatch.
|
||
|
||
const Execution = type('Execution', {
|
||
id: u53,
|
||
ts: u53,
|
||
price: f64,
|
||
qty: f64,
|
||
venue: enumOf(['NYSE', 'NASDAQ', 'BATS', 'IEX'] as const),
|
||
});
|
||
|
||
const Trade = type('Trade', {
|
||
id: u53,
|
||
symbol: str,
|
||
side: enumOf(['buy', 'sell'] as const),
|
||
executions: list(Execution),
|
||
});
|
||
|
||
const Holding = type('Holding', {
|
||
symbol: str,
|
||
qty: f64,
|
||
avgEntry: f64,
|
||
trades: list(Trade),
|
||
});
|
||
|
||
const Account = type('Account', {
|
||
id: u53,
|
||
name: str,
|
||
currency: enumOf(['USD', 'EUR', 'GBP'] as const),
|
||
balance: f64,
|
||
holdings: list(Holding),
|
||
});
|
||
|
||
const Portfolio = type('Portfolio', {
|
||
ownerId: u53,
|
||
ts: u53,
|
||
accounts: list(Account),
|
||
});
|
||
|
||
type PortfolioT = typeof Portfolio.$infer;
|
||
|
||
function buildPortfolio(): PortfolioT {
|
||
const venues = ['NYSE', 'NASDAQ', 'BATS', 'IEX'] as const;
|
||
const symbols = ['AAPL', 'MSFT', 'GOOG', 'AMZN', 'TSLA'] as const;
|
||
const currencies = ['USD', 'EUR', 'GBP'] as const;
|
||
|
||
return {
|
||
ownerId: 100001,
|
||
ts: 1716100000000,
|
||
accounts: Array.from({ length: 3 }, (_, a) => ({
|
||
id: 10 + a,
|
||
name: `Account-${a + 1}`,
|
||
currency: currencies[a]!,
|
||
balance: 100_000 * (a + 1),
|
||
holdings: Array.from({ length: 5 }, (_, p) => ({
|
||
symbol: symbols[p % symbols.length]!,
|
||
qty: 100 * (p + 1),
|
||
avgEntry: 100 + p * 25,
|
||
trades: Array.from({ length: 10 }, (_, t) => ({
|
||
id: a * 1000 + p * 100 + t,
|
||
symbol: symbols[(a + p + t) % symbols.length]!,
|
||
side: t % 2 === 0 ? ('buy' as const) : ('sell' as const),
|
||
executions: Array.from({ length: 4 }, (_, e) => ({
|
||
id: a * 10000 + p * 1000 + t * 10 + e,
|
||
ts: 1716100000000 + (a * 100 + p * 50 + t * 5 + e) * 1000,
|
||
price: 100 + ((a + p + t + e) % 100) * 2,
|
||
qty: 5 + ((p + t + e) % 50),
|
||
venue: venues[e % 4]!,
|
||
})),
|
||
})),
|
||
})),
|
||
})),
|
||
};
|
||
}
|
||
|
||
const portfolio = buildPortfolio();
|
||
|
||
// Counts at each level.
|
||
const nAccounts = portfolio.accounts.length;
|
||
const nHoldings = portfolio.accounts.reduce((s, a) => s + a.holdings.length, 0);
|
||
const nTrades = portfolio.accounts.reduce(
|
||
(s, a) => s + a.holdings.reduce((ss, h) => ss + h.trades.length, 0),
|
||
0,
|
||
);
|
||
const nExecs = portfolio.accounts.reduce(
|
||
(s, a) =>
|
||
s +
|
||
a.holdings.reduce(
|
||
(ss, h) => ss + h.trades.reduce((sss, t) => sss + t.executions.length, 0),
|
||
0,
|
||
),
|
||
0,
|
||
);
|
||
|
||
const tEnc0 = performance.now();
|
||
const portfolioBytes = Portfolio.encode(portfolio);
|
||
const tEnc1 = performance.now();
|
||
const portfolioJSON = JSON.stringify(portfolio);
|
||
const tEnc2 = performance.now();
|
||
const decodedPortfolio = Portfolio.decode(portfolioBytes);
|
||
const tEnc3 = performance.now();
|
||
JSON.parse(portfolioJSON);
|
||
const tEnc4 = performance.now();
|
||
|
||
const total = 1 + nAccounts + nHoldings + nTrades + nExecs;
|
||
|
||
log('\nExample 8: deeply-nested portfolio (5 levels)');
|
||
log(
|
||
` ${nAccounts} accounts × ${nHoldings / nAccounts} holdings × ${nTrades / nHoldings} trades × ${nExecs / nTrades} executions = ${total} objects total`,
|
||
);
|
||
log(
|
||
` encoded: ${portfolioBytes.length}b in ${(tEnc1 - tEnc0).toFixed(2)}ms | JSON: ${portfolioJSON.length}b in ${(tEnc2 - tEnc1).toFixed(2)}ms (${((portfolioBytes.length / portfolioJSON.length) * 100).toFixed(0)}% of JSON)`,
|
||
);
|
||
log(
|
||
` decoded: ${(tEnc3 - tEnc2).toFixed(2)}ms | JSON.parse: ${(tEnc4 - tEnc3).toFixed(2)}ms`,
|
||
);
|
||
const sampleExec = decodedPortfolio.accounts[0]!.holdings[0]!.trades[0]!.executions[0]!;
|
||
const origExec = portfolio.accounts[0]!.holdings[0]!.trades[0]!.executions[0]!;
|
||
log(
|
||
` round-trip preserves leaf data: ${sampleExec.id === origExec.id && sampleExec.venue === origExec.venue}`,
|
||
);
|