@hpx7/delta-pack
Version:
A TypeScript code generator and runtime for binary serialization based on schemas.
698 lines (527 loc) • 17.6 kB
Markdown
# Delta-Pack TypeScript
TypeScript implementation of delta-pack, a compact binary serialization format with efficient delta compression for real-time state synchronization.
## Installation
```bash
npm install @hpx7/delta-pack
```
## Quick Start
Delta-pack provides two approaches for working with schemas:
1. **Interpreter Mode** - Runtime schema parsing with dynamic API
2. **Codegen Mode** - Generate TypeScript code from schemas for compile-time type safety
### Interpreter Mode (Recommended for prototyping)
```typescript
import { ObjectType, StringType, IntType, load, Infer, defineSchema } from "@hpx7/delta-pack";
// Define schema in TypeScript
const schema = defineSchema({
Player: ObjectType({
id: StringType(),
name: StringType(),
score: IntType(),
}),
});
// Infer TypeScript type
type Player = Infer<typeof schema.Player, typeof schema>;
// Result: { id: string; name: string; score: number }
// Load API for the type
const Player = load<Player>(schema, "Player");
// Use the API
const player = { id: "p1", name: "Alice", score: 100 };
const encoded = Player.encode(player);
const decoded = Player.decode(encoded);
```
### Codegen Mode (Recommended for production)
```typescript
import { codegenTypescript, ObjectType, StringType, IntType } from "@hpx7/delta-pack";
import { writeFileSync } from "fs";
// Define schema in TypeScript
const schema = {
Player: ObjectType({
id: StringType(),
name: StringType(),
score: IntType(),
}),
};
// Generate TypeScript code
const code = codegenTypescript(schema);
writeFileSync("generated.ts", code);
```
Then use the generated code:
```typescript
import { Player } from "./generated";
const player: Player = { id: "p1", name: "Alice", score: 100 };
const encoded = Player.encode(player);
const decoded = Player.decode(encoded);
```
## Schema Definition
Schemas can be defined in two ways:
1. **YAML** - Human-readable format, useful for defining schemas separately from code. Parse with `parseSchemaYml()` for interpreter mode.
2. **TypeScript** - Define schemas using the type definition API. Works with both interpreter and codegen modes. Required for using the `Infer<>` type utility.
**Note:** The `Infer<>` type utility only works with TypeScript-defined schemas, not YAML-parsed schemas, since it requires compile-time type information.
### YAML Schema
Create a `schema.yml` file:
```yaml
# yaml-language-server: $schema=https://raw.githubusercontent.com/hpx7/delta-pack/refs/heads/main/schema.json
# Enums
Team:
- RED
- BLUE
# Objects
Player:
id: string
name: string
score: int
team: Team
position: Position?
Position:
x: float
y: float
# Complex types
GameState:
players: string,Player # Map<string, Player>
round: uint
phase: string
```
Parse YAML schemas with:
```typescript
import { parseSchemaYml } from "@hpx7/delta-pack";
import { readFileSync } from "fs";
const schemaYml = readFileSync("schema.yml", "utf8");
const schema = parseSchemaYml(schemaYml);
```
See the [main README](../README.md) for complete schema syntax reference.
### TypeScript Schema
Define schemas using the type definition API:
```typescript
import {
ObjectType,
StringType,
IntType,
UIntType,
FloatType,
BooleanType,
ArrayType,
OptionalType,
RecordType,
EnumType,
ReferenceType,
} from "@hpx7/delta-pack";
const Team = EnumType(["RED", "BLUE"]);
const Position = ObjectType({
x: FloatType({ precision: 0.1 }),
y: FloatType({ precision: 0.1 }),
});
const Player = ObjectType({
id: StringType(),
name: StringType(),
score: IntType(),
team: ReferenceType("Team"),
position: OptionalType(ReferenceType("Position")),
});
const GameState = ObjectType({
players: RecordType(StringType(), ReferenceType("Player")),
round: UIntType(),
phase: StringType(),
});
const schema = {
Team,
Position,
Player,
GameState,
};
```
## Interpreter API
The interpreter mode provides a runtime API for working with schemas.
### Loading a Schema
```typescript
import { ObjectType, StringType, IntType, load, Infer, defineSchema } from "@hpx7/delta-pack";
// Define schema
const schema = defineSchema({
Player: ObjectType({
id: StringType(),
name: StringType(),
score: IntType(),
}),
});
// Infer type
type Player = Infer<typeof schema.Player, typeof schema>;
// Result: { id: string; name: string; score: number }
// Load interpreter API
const Player = load<Player>(schema, "Player");
```
### API Methods
Every loaded type provides these methods:
#### `fromJson(obj: Record<string, unknown>): T`
Validates and parses JSON data, throwing if invalid. Use this when parsing untrusted or untyped data:
```typescript
// Parse unvalidated JSON data
const jsonData = JSON.parse(networkResponse);
const player = Player.fromJson(jsonData);
```
For most cases, prefer using TypeScript types directly:
```typescript
// Preferred: use TypeScript types for compile-time safety
const player: Player = { id: "p1", name: "Alice", score: 100 };
```
#### `toJson(obj: T): Record<string, unknown>`
Converts an object to JSON-serializable format. Useful for serializing to JSON or sending over HTTP:
```typescript
const player: Player = { id: "p1", name: "Alice", score: 100 };
const json = Player.toJson(player);
const jsonString = JSON.stringify(json);
```
**Format notes:**
- Maps (RecordType) are converted to plain objects
- Optional object properties with `undefined` values are excluded from the JSON
- Unions are converted to protobuf format: `{ TypeName: {...} }`
**Example with unions:**
```typescript
const action: GameAction = { type: "MoveAction", val: { x: 10, y: 20 } };
const json = GameAction.toJson(action);
// Result: { MoveAction: { x: 10, y: 20 } }
```
This format is compatible with protobuf JSON encoding and can be parsed back with `fromJson()`.
#### `encode(obj: T): Uint8Array`
Serializes an object to binary format:
```typescript
const player = { id: "p1", name: "Alice", score: 100 };
const bytes = Player.encode(player);
console.log(`Encoded size: ${bytes.length} bytes`);
```
#### `decode(bytes: Uint8Array): T`
Deserializes binary data back to an object:
```typescript
const decoded = Player.decode(bytes);
// decoded = { id: 'p1', name: 'Alice', score: 100 }
```
#### `encodeDiff(oldObj: T, newObj: T): Uint8Array`
Encodes only the differences between two objects:
```typescript
const oldPlayer = { id: "p1", name: "Alice", score: 100 };
const newPlayer = { id: "p1", name: "Alice", score: 150 };
const diff = Player.encodeDiff(oldPlayer, newPlayer);
console.log(`Diff size: ${diff.length} bytes`); // Much smaller!
```
#### `decodeDiff(oldObj: T, diffBytes: Uint8Array): T`
Applies a diff to reconstruct the new object:
```typescript
const reconstructed = Player.decodeDiff(oldPlayer, diff);
// reconstructed = { id: 'p1', name: 'Alice', score: 150 }
```
#### `equals(a: T, b: T): boolean`
Deep equality comparison with appropriate tolerance for floats:
```typescript
const isEqual = Player.equals(player1, player2);
```
For quantized floats (with `precision`), equality uses quantized value comparison. For non-quantized floats, equality uses epsilon-based comparison (0.00001 tolerance).
#### `default(): T`
Creates a default instance:
```typescript
const defaultPlayer = Player.default();
// { id: '', name: '', score: 0 }
```
## Codegen API
The codegen mode generates TypeScript code from schemas for compile-time type safety.
### Generating Code
```typescript
import { codegenTypescript } from "@hpx7/delta-pack";
import { writeFileSync } from "fs";
const code = codegenTypescript(schema);
writeFileSync("generated.ts", code);
```
### Using Generated Code
The generated code exports TypeScript types and runtime objects:
```typescript
import { Player, GameState } from "./generated";
// TypeScript types are available
const player: Player = {
id: "p1",
name: "Alice",
score: 100,
};
// Runtime objects provide the same API as interpreter mode
const encoded = Player.encode(player);
const decoded = Player.decode(encoded);
```
### Generated API
The generated code provides the same methods as interpreter mode:
- `Player.fromJson(obj)` - Validate and parse JSON data
- `Player.toJson(obj)` - Convert to JSON-serializable format
- `Player.encode(obj)` - Serialize to binary
- `Player.decode(bytes)` - Deserialize from binary
- `Player.encodeDiff(old, new)` - Encode delta
- `Player.decodeDiff(old, diff)` - Apply delta
- `Player.equals(a, b)` - Deep equality
- `Player.default()` - Default instance
## Complete Example
### Multiplayer Game State Sync
**schema.yml:**
```yaml
Team:
- RED
- BLUE
Position:
x: float
y: float
Player:
id: string
username: string
team: Team
position: Position
health: uint
score: int
GameState:
players: string,Player
round: uint
timeRemaining: float
```
**Using Interpreter Mode:**
```typescript
import {
ObjectType,
StringType,
UIntType,
FloatType,
IntType,
EnumType,
ReferenceType,
RecordType,
load,
Infer,
defineSchema,
} from "@hpx7/delta-pack";
// Define schema
const schema = defineSchema({
Team: EnumType(["RED", "BLUE"]),
Position: ObjectType({
x: FloatType(),
y: FloatType(),
}),
Player: ObjectType({
id: StringType(),
username: StringType(),
team: ReferenceType("Team"),
position: ReferenceType("Position"),
health: UIntType(),
score: IntType(),
}),
GameState: ObjectType({
players: RecordType(StringType(), ReferenceType("Player")),
round: UIntType(),
timeRemaining: FloatType(),
}),
});
// Infer types
type GameState = Infer<typeof schema.GameState, typeof schema>;
type Player = Infer<typeof schema.Player, typeof schema>;
// Load API
const GameState = load<GameState>(schema, "GameState");
// Initial state
const state1: GameState = {
players: new Map([
[
"p1",
{
id: "p1",
username: "Alice",
team: "RED",
position: { x: 100, y: 100 },
health: 100,
score: 0,
},
],
]),
round: 1,
timeRemaining: 600.0,
};
// Updated state (player moved)
const state2: GameState = {
...state1,
players: new Map([
[
"p1",
{
...state1.players.get("p1")!,
position: { x: 105.5, y: 102.3 },
},
],
]),
timeRemaining: 599.0,
};
// Full encoding
const fullBytes = GameState.encode(state2);
console.log(`Full state: ${fullBytes.length} bytes`);
// Delta encoding (much smaller!)
const diffBytes = GameState.encodeDiff(state1, state2);
console.log(`Delta: ${diffBytes.length} bytes`);
console.log(`Savings: ${((1 - diffBytes.length / fullBytes.length) * 100).toFixed(1)}%`);
// Client applies delta
const reconstructed = GameState.decodeDiff(state1, diffBytes);
console.log("State synchronized!", GameState.equals(reconstructed, state2)); // true
```
**Using Codegen Mode:**
```typescript
import {
codegenTypescript,
ObjectType,
StringType,
UIntType,
FloatType,
IntType,
EnumType,
ReferenceType,
RecordType,
} from "@hpx7/delta-pack";
import { writeFileSync } from "fs";
// Define schema
const schema = {
Team: EnumType(["RED", "BLUE"]),
Position: ObjectType({
x: FloatType(),
y: FloatType(),
}),
Player: ObjectType({
id: StringType(),
username: StringType(),
team: ReferenceType("Team"),
position: ReferenceType("Position"),
health: UIntType(),
score: IntType(),
}),
GameState: ObjectType({
players: RecordType(StringType(), ReferenceType("Player")),
round: UIntType(),
timeRemaining: FloatType(),
}),
};
// Generate code
const code = codegenTypescript(schema);
writeFileSync("generated.ts", code);
```
Then use the generated code:
```typescript
import { GameState, Player } from "./generated";
// TypeScript types are available at compile time
const state: GameState = GameState.default();
state.players.set("p1", {
id: "p1",
username: "Alice",
team: "RED",
position: { x: 100, y: 100 },
health: 100,
score: 0,
});
// Same API as interpreter mode
const encoded = GameState.encode(state);
const decoded = GameState.decode(encoded);
```
## Performance Tips
### Delta Compression
Delta encoding is most effective when:
- State changes are incremental (only a few fields change per update)
- You send updates frequently (e.g., 60 times per second in games)
- Objects are medium to large (>50 bytes)
**Typical bandwidth savings:**
- Position-only updates: 90-95% smaller
- Single field changes: 85-90% smaller
- Multiple field changes: 70-85% smaller
### Dirty Tracking Optimization
For maximum encodeDiff performance, you can use the optional `_dirty` field to mark which fields/indices/keys have changed. This allows delta encoding to skip comparison checks entirely:
```typescript
// Objects: track changed fields
const player: Player = { id: "p1", name: "Alice", score: 100 };
player.score = 150;
player._dirty = new Set(["score"]);
const diff = Player.encodeDiff(oldPlayer, player);
// Only encodes the 'score' field without checking other fields
```
```typescript
// Arrays: track changed indices
const items: Item[] = [...];
items[5] = newItem;
items._dirty = new Set([5]);
const diff = encodeDiff(oldItems, items);
// Only encodes index 5 without checking other elements
```
```typescript
// Maps (RecordType): track changed keys
const players: Map<string, Player> = new Map();
players.set("p1", updatedPlayer);
players._dirty = new Set(["p1"]);
const diff = encodeDiff(oldPlayers, players);
// Only processes key "p1" without checking other entries
```
The `_dirty` field is:
- **Optional**: If absent, full comparison is performed
- **Type-safe**: `Set<keyof T>` for objects, `Set<number>` for arrays, `Set<K>` for maps
- **Included in generated types**: Both codegen and interpreter types include `_dirty`
- **Not serialized**: The `_dirty` field is never encoded in the binary format
**When to use dirty tracking:**
- High-frequency updates (e.g., 60+ times per second)
- Large objects/collections where full comparison is expensive
- When you can reliably track changes at the application level
**Important:** If dirty tracking is enabled but incomplete (e.g., you modify a field but don't mark it dirty), the delta will be incorrect. Only use dirty tracking if you can guarantee accurate tracking.
### Quantized Floats
Use precision for floats to reduce size:
```typescript
const Position = ObjectType({
x: FloatType({ precision: 0.1 }), // ~10cm precision
y: FloatType({ precision: 0.1 }),
});
```
This enables delta compression to skip encoding unchanged floats even if they differ slightly due to floating-point imprecision.
### String Dictionary
Strings are automatically deduplicated within each encoding operation. Reuse common strings (player IDs, item names, etc.) to benefit from dictionary compression.
### Map vs Array
- Use `RecordType` (maps) when entities have unique IDs
- Use `ArrayType` when order matters or IDs aren't meaningful
Maps enable efficient delta encoding for entity collections (only changed entities are encoded).
## Examples
See the `examples/` directory for complete examples:
- `examples/primitives/` - Basic primitive types
- `examples/user/` - User profile with unions and optionals
- `examples/game/` - Multiplayer game with complex state
Each example includes:
- `schema.yml` - Schema definition
- `state1.json`, `state2.json`, ... - Example states demonstrating delta compression
## Type Reference
### Primitive Types
| Function | TypeScript Type | Description |
| -------------------------- | --------------- | ---------------------------------------- |
| `StringType()` | `string` | UTF-8 encoded string |
| `IntType()` | `number` | Variable-length signed integer |
| `UIntType()` | `number` | Variable-length unsigned integer |
| `FloatType()` | `number` | 32-bit IEEE 754 float |
| `FloatType({ precision })` | `number` | Quantized float with specified precision |
| `BooleanType()` | `boolean` | Single bit boolean |
### Container Types
| Function | TypeScript Type | Description |
| ------------------ | ---------------- | ------------------------------------ |
| `ArrayType(T)` | `T[]` | Array of type T |
| `OptionalType(T)` | `T \| undefined` | Optional value of type T |
| `RecordType(K, V)` | `Map<K, V>` | Map with key type K and value type V |
### Complex Types
| Function | TypeScript Type | Description |
| --------------------- | ------------------------ | ----------------------------------- |
| `ObjectType({ ... })` | `{ ... }` | Object with defined properties |
| `EnumType([...])` | Union of string literals | Enumerated string values |
| `ReferenceType(name)` | Named type | Reference to another type in schema |
## Development
### Running Tests
```bash
npm test # Run all tests
npm run test:coverage # Run with coverage report
npm run test:ui # Open Vitest UI
```
### Type Checking
```bash
npm run typecheck
```
### Formatting
```bash
npm run format # Format code
npm run format:check # Check formatting
```
## API Documentation
For detailed API documentation and schema syntax, see the [main README](../README.md).
## License
MIT