Initial Commit
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
# CompressedBitPacker
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs --> - compressed-bit-packer.schema — Zod schema for `Comp…
|
||||
- [compressed-bit-packer.schema](./compressed-bit-packer.schema.md) — Zod schema for `CompressedBitPackerOptions`, the constructor options bundle accepted by `CompressedBitPacker`. Validate…
|
||||
- compressed-bit-packer.schema — Zod schema for `Comp…
|
||||
|
||||
- [compressed-bit-packer.schema](./compressed-bit-packer.schema.md) — Zod schema for `CompressedBitPackerOptions`, the constructor options bundle accepted by `CompressedBitPacker`. Validate…
|
||||
|
||||
- compressed-bit-packer.schema — Zod schema for `Comp…
|
||||
|
||||
- [compressed-bit-packer.schema](./compressed-bit-packer.schema.md) — Zod schema for `CompressedBitPackerOptions`, the constructor options bundle accepted by `CompressedBitPacker`. Validate…
|
||||
|
||||
- compressed-bit-packer.schema — Zod schema for `Comp…
|
||||
|
||||
- [compressed-bit-packer.schema](./compressed-bit-packer.schema.md) — Zod schema for `CompressedBitPackerOptions`, the constructor options bundle accepted by `CompressedBitPacker`. Validate…
|
||||
- [compressed-bit-packer.schema](./compressed-bit-packer.schema.md) — Zod schema for `CompressedBitPackerOptions`, the constructor options bundle accepted by `CompressedBitPacker`. Validate…
|
||||
|
||||
## Purpose
|
||||
|
||||
`CompressedBitPacker<T>` extends `@suaveplan/bitpacker::BitPacker<T>` to add a configurable compression stage between bit-packing and the wire bytes. The compressed bytes are framed with a self-describing algorithm header so a receiver can decompress without knowing the sender's algorithm choice in advance — the byte stream itself names the codec.
|
||||
|
||||
This is the port of upstream `bitpacker/src/compressed/bitpacker.ts`. The port keeps the upstream wire shape (`[headerLen: u8][header: ASCII algorithm][compressed payload]`) so any consumer carrying upstream-encoded bytes round-trips unchanged. Hardening over upstream:
|
||||
|
||||
- Plain `Error` throws → `BitpackerError` with stable codes (`UNKNOWN_COMPRESSION_ALGORITHM`, `COMPRESSION_FAILED`, `DECOMPRESSION_FAILED`, `COMPRESSED_BAD_HEADER`).
|
||||
- The synchronous `pack`/`unpack` overrides from upstream — which threw when compression was enabled — are dropped. Consumers call the explicit async `packWithCompression` / `unpackWithDecompression` methods. This separates the sync no-compression hot path from the async compressed path cleanly.
|
||||
- Adapter resolution goes through the injected `CompressionRegistry`, not a private static singleton.
|
||||
- No `EventEmitter`. suaveplan's `BitPacker` does not extend an emitter; observability is OTel-driven via `bitpacker-compressed.*` spans.
|
||||
|
||||
## Features
|
||||
|
||||
- `packWithCompression(data, schema?)` — bit-pack, compress, frame.
|
||||
- `unpackWithDecompression(data, schema?)` — read frame, decompress, bit-unpack.
|
||||
- Configurable algorithm + options + enabled flag via constructor `compression` config or runtime accessors.
|
||||
- Self-describing wire format: receiver dispatches to the named algorithm without prior knowledge.
|
||||
- Synchronous `pack`/`unpack` from `BitPacker` remain for the no-compression case (no header added — bytes are exactly what `BitPacker.pack` produces).
|
||||
- Pluggable `CompressionRegistry` via constructor option (defaults to the process-wide singleton).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { CompressedBitPacker } from "@suaveplan/bitpacker-compressed";
|
||||
|
||||
const schema = { x: 255, y: 255 } as const;
|
||||
const packer = new CompressedBitPacker(schema);
|
||||
|
||||
// Compression disabled (default) — frames with "none" header
|
||||
const bytes = await packer.packWithCompression({ x: 42, y: 99 });
|
||||
const decoded = await packer.unpackWithDecompression(bytes);
|
||||
// → { x: 42, y: 99 }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
CompressedBitPacker,
|
||||
createCompressionRegistry,
|
||||
} from "@suaveplan/bitpacker-compressed";
|
||||
|
||||
// Custom registry isolated from process-wide singleton, useful for tests
|
||||
// or multi-tenant servers where adapter sets must not leak.
|
||||
const registry = createCompressionRegistry();
|
||||
// Register a custom adapter (e.g. brotli) into this isolated registry.
|
||||
registry.register(new MyBrotliAdapter());
|
||||
|
||||
const packer = new CompressedBitPacker(
|
||||
{
|
||||
position: { x: "f32", y: "f32", z: "f32" },
|
||||
velocity: { x: "f32", y: "f32", z: "f32" },
|
||||
},
|
||||
{
|
||||
compression: {
|
||||
algorithm: "brotli",
|
||||
enabled: true,
|
||||
options: { quality: 11 },
|
||||
},
|
||||
compressionRegistry: registry,
|
||||
}
|
||||
);
|
||||
|
||||
const wireBytes = await packer.packWithCompression({
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
velocity: { x: 0.1, y: 0, z: 0 },
|
||||
});
|
||||
// wireBytes is [headerLen=6][b][r][o][t][l][i][brotli-compressed bit-packed payload]
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new CompressedBitPacker<T>(schema, options?)`
|
||||
|
||||
`schema` — a `BitPackerSchema` describing the data shape.
|
||||
`options.compression?` — `{ algorithm?, options?, enabled? }`. Default is `{ algorithm: "none", options: {}, enabled: false }`.
|
||||
`options.compressionRegistry?` — optional `CompressionRegistry` override. Defaults to `defaultCompressionRegistry()`.
|
||||
|
||||
### `packWithCompression(data, schema?) → Promise<Uint8Array>`
|
||||
|
||||
Bit-packs `data`, runs the bytes through the configured compression adapter (or `NoCompressionAdapter` if `enabled` is false or `algorithm` is `"none"`), and frames the result with `[headerLen: u8][header: ASCII algorithm name][payload]`. Returns the framed wire bytes.
|
||||
|
||||
### `unpackWithDecompression(data?, schema?) → Promise<unknown>`
|
||||
|
||||
Reverse of `packWithCompression`. Reads the framing header, dispatches to the named adapter for decompression (or routes the body through unchanged if the header is `"none"`), then bit-unpacks the result. If `data` is `undefined` or empty, falls back to `super.unpack(empty)` for parity with upstream.
|
||||
|
||||
### Accessors
|
||||
|
||||
`compressionAlgorithm` / `compressionOptions` / `compressionEnabled` — getters and setters for the runtime configuration. Allow callers to switch algorithms or toggle compression mid-flight.
|
||||
|
||||
### Inherited from `BitPacker`
|
||||
|
||||
Synchronous `pack(data, schema?) → Uint8Array` and `unpack(bytes, schema?) → T` remain available. They produce/consume bytes that are NOT framed (no `[headerLen][header]` prefix) — use them when compression is genuinely off and you don't want the framing overhead.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The wire format's `headerLen` byte limits algorithm names to 1–255 ASCII bytes. The four shipping defaults (`none`, `gzip`, `deflate`, plus future `brotli`/`zstd`) are all well under that. If a future custom algorithm name exceeds the limit, register the adapter under a shorter alias.
|
||||
|
||||
The synchronous `pack`/`unpack` from `BitPacker` are NOT overridden here. The upstream version overrode them but threw an error when compression was enabled — that mixed sync and async expectations confusingly. suaveplan splits cleanly: callers wanting compression call the async methods; callers OK with no compression use the inherited sync methods.
|
||||
|
||||
`CompressionRegistry` is injectable so tests can use isolated registries (`createCompressionRegistry()`) without polluting the process-wide singleton. The lazy registration of `gzip`/`deflate` on the singleton (when `CompressionStream` is available) means the singleton is environment-aware without forcing every consumer through the streams API path.
|
||||
|
||||
The `compressed-bit-packer.test.ts` test file uses `vitest.skipIf(!hasStreams)` to skip stream-backed tests in environments without `CompressionStream`. The frame/unframe helpers are tested directly via `_frameForTests` / `_unframeForTests` so the wire format is locked even in non-streaming environments.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Compressed BitPacker Options Schema
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schema for `CompressedBitPackerOptions`, the constructor options bundle accepted by `CompressedBitPacker`. Validates the shape at the package boundary so a typo in the algorithm name (`alogrithm`), a non-`CompressionRegistry` value passed as the registry (a plain object that "quacks like" one), or a stray unknown key all fail at parse time rather than producing a packer that silently runs without the consumer's intended compression configuration.
|
||||
|
||||
The schema composes `compressionConfigSchema` for the `compression` field and uses an `instanceof` check for `compressionRegistry`. Identity of the registry is preserved so the constructor receives the same instance the caller built.
|
||||
|
||||
## Features
|
||||
|
||||
- `compressedBitPackerOptionsSchema` — strict, two optional fields (`compression`, `compressionRegistry`)
|
||||
- Composes `compressionConfigSchema` for nested validation
|
||||
- `instanceof CompressionRegistry` check with identity preservation
|
||||
- `as unknown as z.ZodType<T>` cast for the EOPT mismatch
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
CompressedBitPacker,
|
||||
compressedBitPackerOptionsSchema,
|
||||
} from "@suaveplan/bitpacker-compressed";
|
||||
|
||||
const opts = compressedBitPackerOptionsSchema.parse({
|
||||
compression: { algorithm: "gzip", enabled: true },
|
||||
});
|
||||
const packer = new CompressedBitPacker({ x: 255 }, opts);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
CompressedBitPacker,
|
||||
compressedBitPackerOptionsSchema,
|
||||
createCompressionRegistry,
|
||||
GzipCompressionAdapter,
|
||||
} from "@suaveplan/bitpacker-compressed";
|
||||
|
||||
const registry = createCompressionRegistry();
|
||||
registry.register(new GzipCompressionAdapter());
|
||||
|
||||
const opts = compressedBitPackerOptionsSchema.parse({
|
||||
compression: {
|
||||
algorithm: "gzip",
|
||||
options: { level: 9 },
|
||||
enabled: true,
|
||||
},
|
||||
compressionRegistry: registry,
|
||||
});
|
||||
|
||||
const packer = new CompressedBitPacker({ x: 255 }, opts);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `compressedBitPackerOptionsSchema: z.ZodType<CompressedBitPackerOptions>`
|
||||
|
||||
Strict schema. `compression` is an optional `CompressionConfig` (validated by `compressionConfigSchema`). `compressionRegistry` is an optional `CompressionRegistry` instance (validated by `instanceof` with identity preservation).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- **Composition over duplication.** The nested `compression` field is validated by `compressionConfigSchema` from the same package. Composing rather than inlining the shape keeps the per-field error messages consistent across all entry points that accept a `CompressionConfig`.
|
||||
- **`instanceof` over duck-typing.** A duck-typed registry would compile but would not pass the prototype-aware checks the registry's lookup methods perform internally. Refusing duck-types prevents an entire class of "registry doesn't find my adapter" bugs.
|
||||
- **`.strict()` is non-negotiable.** Same rationale as elsewhere in this change: typo'd flags would otherwise silently default and hide configuration bugs.
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { defaultCompressionRegistry } from "../registry/registry.js";
|
||||
import { compressedBitPackerOptionsSchema } from "./compressed-bit-packer.schema.js";
|
||||
|
||||
describe("compressedBitPackerOptionsSchema", () => {
|
||||
it("accepts an empty options bundle", () => {
|
||||
expect(compressedBitPackerOptionsSchema.parse({})).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts a populated compression config", () => {
|
||||
const opts = {
|
||||
compression: { algorithm: "gzip", enabled: true },
|
||||
};
|
||||
expect(compressedBitPackerOptionsSchema.parse(opts)).toEqual(opts);
|
||||
});
|
||||
|
||||
it("preserves the registry identity", () => {
|
||||
const registry = defaultCompressionRegistry();
|
||||
const parsed = compressedBitPackerOptionsSchema.parse({
|
||||
compressionRegistry: registry,
|
||||
});
|
||||
expect(parsed.compressionRegistry).toBe(registry);
|
||||
});
|
||||
|
||||
it("rejects a non-CompressionRegistry value", () => {
|
||||
expect(() =>
|
||||
compressedBitPackerOptionsSchema.parse({
|
||||
compressionRegistry: { register() {} },
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects unknown keys", () => {
|
||||
expect(() =>
|
||||
compressedBitPackerOptionsSchema.parse({ bogus: true })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import z from "zod";
|
||||
import { CompressionRegistry } from "../registry/registry.js";
|
||||
import { compressionConfigSchema } from "../types/types.schema.js";
|
||||
import type { CompressedBitPackerOptions } from "./compressed-bit-packer.js";
|
||||
|
||||
const compressionRegistrySchema = z.custom<CompressionRegistry>(
|
||||
(v): v is CompressionRegistry => v instanceof CompressionRegistry,
|
||||
{ message: "compressionRegistry must be a CompressionRegistry instance" }
|
||||
);
|
||||
|
||||
/**
|
||||
* Strict Zod schema for {@link CompressedBitPackerOptions}.
|
||||
*
|
||||
* Validates the constructor options for `CompressedBitPacker`. Composes the
|
||||
* `compressionConfigSchema` for the `compression` field and an `instanceof`
|
||||
* check on the optional `compressionRegistry`.
|
||||
*/
|
||||
export const compressedBitPackerOptionsSchema = z
|
||||
.object({
|
||||
compression: compressionConfigSchema.optional(),
|
||||
compressionRegistry: compressionRegistrySchema.optional(),
|
||||
})
|
||||
.strict() as unknown as z.ZodType<CompressedBitPackerOptions>;
|
||||
@@ -0,0 +1,202 @@
|
||||
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
|
||||
import { registerDefaultStrategies } from "@suaveplan/bitpacker-strategies";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
_frameForTests,
|
||||
_unframeForTests,
|
||||
CompressedBitPacker,
|
||||
} from "./compressed-bit-packer.js";
|
||||
|
||||
const SIMPLE_SCHEMA = { x: 255, y: 255 } as const;
|
||||
type SimpleData = { x: number; y: number };
|
||||
|
||||
describe("CompressedBitPacker (compression disabled)", () => {
|
||||
it("packWithCompression frames with 'none' header", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const packer = new CompressedBitPacker(SIMPLE_SCHEMA);
|
||||
registerDefaultStrategies(packer.strategyManager);
|
||||
const bytes = await packer.packWithCompression({
|
||||
x: 1,
|
||||
y: 2,
|
||||
} as SimpleData);
|
||||
// Header: [4 (len)][n][o][n][e][...packed bytes]
|
||||
expect(bytes[0]).toBe(4);
|
||||
expect(new TextDecoder().decode(bytes.slice(1, 5))).toBe("none");
|
||||
});
|
||||
|
||||
it("round-trip via packWithCompression / unpackWithDecompression", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const packer = new CompressedBitPacker(SIMPLE_SCHEMA);
|
||||
registerDefaultStrategies(packer.strategyManager);
|
||||
const original: SimpleData = { x: 100, y: 200 };
|
||||
const bytes = await packer.packWithCompression(original);
|
||||
const decoded = (await packer.unpackWithDecompression(
|
||||
bytes
|
||||
)) as SimpleData;
|
||||
expect(decoded).toEqual(original);
|
||||
});
|
||||
|
||||
it("synchronous super.pack still works (no header added)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const packer = new CompressedBitPacker(SIMPLE_SCHEMA);
|
||||
registerDefaultStrategies(packer.strategyManager);
|
||||
const bytes = packer.pack({ x: 7, y: 8 } as SimpleData);
|
||||
// The synchronous path returns BitPacker.pack output directly,
|
||||
// no [headerLen] prefix.
|
||||
expect(bytes[0]).not.toBe(4);
|
||||
expect(packer.unpack(bytes)).toEqual({ x: 7, y: 8 });
|
||||
});
|
||||
|
||||
it("compressionEnabled / Algorithm / Options accessors round-trip", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const packer = new CompressedBitPacker(SIMPLE_SCHEMA);
|
||||
expect(packer.compressionEnabled).toBe(false);
|
||||
expect(packer.compressionAlgorithm).toBe("none");
|
||||
expect(packer.compressionOptions).toEqual({});
|
||||
packer.compressionEnabled = true;
|
||||
packer.compressionAlgorithm = "gzip";
|
||||
packer.compressionOptions = { level: 9 };
|
||||
expect(packer.compressionEnabled).toBe(true);
|
||||
expect(packer.compressionAlgorithm).toBe("gzip");
|
||||
expect(packer.compressionOptions).toEqual({ level: 9 });
|
||||
});
|
||||
|
||||
it("constructor honours compression config", { timeout: 5000 }, () => {
|
||||
const packer = new CompressedBitPacker(SIMPLE_SCHEMA, {
|
||||
compression: {
|
||||
algorithm: "deflate",
|
||||
options: { level: 6 },
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
expect(packer.compressionAlgorithm).toBe("deflate");
|
||||
expect(packer.compressionOptions).toEqual({ level: 6 });
|
||||
expect(packer.compressionEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("constructor with partial compression config defaults missing fields", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// No `algorithm` — should default to "none". No `options` — should
|
||||
// default to {}. `enabled` omitted — should default to true (since
|
||||
// the user passed compression at all, the !== false rule kicks in).
|
||||
const packer = new CompressedBitPacker(SIMPLE_SCHEMA, {
|
||||
compression: {},
|
||||
});
|
||||
expect(packer.compressionAlgorithm).toBe("none");
|
||||
expect(packer.compressionOptions).toEqual({});
|
||||
expect(packer.compressionEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("unpackWithDecompression on undefined takes the fallback (no unframe)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const packer = new CompressedBitPacker({ x: 255 } as const);
|
||||
registerDefaultStrategies(packer.strategyManager);
|
||||
// undefined data must skip the unframe path. Genesis BitPacker.unpack
|
||||
// will itself throw on a 0-byte buffer when a field expects bits,
|
||||
// but the throw must NOT be a framing error — that proves we took
|
||||
// the empty-buffer fallback branch.
|
||||
try {
|
||||
await packer.unpackWithDecompression(undefined);
|
||||
// If genesis ever softens unpack-on-empty, getting here is fine too.
|
||||
} catch (e) {
|
||||
expect((e as BitpackerError).code).not.toBe(
|
||||
BITPACKER_CODES.COMPRESSED_BAD_HEADER
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const hasStreams = typeof CompressionStream !== "undefined";
|
||||
|
||||
describe.skipIf(!hasStreams)(
|
||||
"CompressedBitPacker with stream-backed gzip",
|
||||
() => {
|
||||
it("round-trip with gzip enabled", { timeout: 10000 }, async () => {
|
||||
// Default registry lazy-registers gzip when CompressionStream
|
||||
// is available. Wait one microtask cycle for the lazy import
|
||||
// promise to resolve before exercising compression.
|
||||
const packer = new CompressedBitPacker(SIMPLE_SCHEMA, {
|
||||
compression: {
|
||||
algorithm: "gzip",
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
registerDefaultStrategies(packer.strategyManager);
|
||||
// Give the lazy registration a chance to settle.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const original: SimpleData = { x: 42, y: 99 };
|
||||
const bytes = await packer.packWithCompression(original);
|
||||
// Header: [4][g][z][i][p][compressed payload]
|
||||
expect(bytes[0]).toBe(4);
|
||||
expect(new TextDecoder().decode(bytes.slice(1, 5))).toBe("gzip");
|
||||
const decoded = (await packer.unpackWithDecompression(
|
||||
bytes
|
||||
)) as SimpleData;
|
||||
expect(decoded).toEqual(original);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
describe("frame / unframe helpers", () => {
|
||||
it("frame round-trips through unframe", { timeout: 5000 }, () => {
|
||||
const payload = new Uint8Array([10, 20, 30]);
|
||||
const framed = _frameForTests("gzip", payload);
|
||||
const { algorithm, payload: out } = _unframeForTests(framed);
|
||||
expect(algorithm).toBe("gzip");
|
||||
expect(out).toEqual(payload);
|
||||
});
|
||||
|
||||
it("frame rejects empty algorithm name with COMPRESSED_BAD_HEADER", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
_frameForTests("", new Uint8Array([1]));
|
||||
} catch (e) {
|
||||
expect((e as BitpackerError).code).toBe(
|
||||
BITPACKER_CODES.COMPRESSED_BAD_HEADER
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("frame rejects algorithm name longer than 255 bytes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const longName = "x".repeat(256);
|
||||
expect(() => _frameForTests(longName, new Uint8Array([1]))).toThrow(
|
||||
BitpackerError
|
||||
);
|
||||
});
|
||||
|
||||
it("unframe rejects bytes shorter than 2", { timeout: 5000 }, () => {
|
||||
try {
|
||||
_unframeForTests(new Uint8Array([1]));
|
||||
} catch (e) {
|
||||
expect((e as BitpackerError).code).toBe(
|
||||
BITPACKER_CODES.COMPRESSED_BAD_HEADER
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("unframe rejects header length out of range", { timeout: 5000 }, () => {
|
||||
// headerLen = 5, but only 3 bytes follow:
|
||||
const bad = new Uint8Array([5, 0x67, 0x7a, 0x00]);
|
||||
try {
|
||||
_unframeForTests(bad);
|
||||
} catch (e) {
|
||||
expect((e as BitpackerError).code).toBe(
|
||||
BITPACKER_CODES.COMPRESSED_BAD_HEADER
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("unframe rejects zero header length", { timeout: 5000 }, () => {
|
||||
const bad = new Uint8Array([0, 0x67, 0x7a]);
|
||||
expect(() => _unframeForTests(bad)).toThrow(BitpackerError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* `CompressedBitPacker<T>` extends `BitPacker<T>` to add a compress-and-frame
|
||||
* stage between bit-packing and the wire bytes. The wire format is:
|
||||
*
|
||||
* [headerLen: u8][header: ASCII algorithm name][compressed payload]
|
||||
*
|
||||
* The header makes the wire payload self-describing: a receiver can call
|
||||
* `unpackWithDecompression` without knowing the sender's algorithm, and the
|
||||
* "no compression" case is encoded via the `"none"` header so the parsing
|
||||
* path doesn't need to special-case the disabled state.
|
||||
*
|
||||
* Genesis port of upstream `bitpacker/src/compressed/bitpacker.ts`. Genesis-side
|
||||
* adjustments:
|
||||
*
|
||||
* - The synchronous `pack`/`unpack` methods are NOT overridden. Compression
|
||||
* is async; consumers must call the async `packWithCompression` /
|
||||
* `unpackWithDecompression` methods explicitly. The parent's sync methods
|
||||
* remain available for the no-compression hot path (no header added in
|
||||
* that case — bytes are exactly what `BitPacker.pack` produced).
|
||||
* - Plain `Error` throws → `BitpackerError` with stable codes.
|
||||
* - Adapter resolution goes through the injected `CompressionRegistry`,
|
||||
* not a private static singleton.
|
||||
* - No `EventEmitter` events. Genesis's `BitPacker` does not extend an
|
||||
* emitter; consumers wanting observability use OTel via the package's
|
||||
* `bitpacker-compressed.*` spans.
|
||||
*/
|
||||
|
||||
import {
|
||||
BITPACKER_CODES,
|
||||
BitPacker,
|
||||
type BitPackerSchema,
|
||||
BitpackerError,
|
||||
} from "@suaveplan/bitpacker";
|
||||
import { compressData, decompressData } from "../api/api.js";
|
||||
import {
|
||||
type CompressionRegistry,
|
||||
defaultCompressionRegistry,
|
||||
} from "../registry/registry.js";
|
||||
import type {
|
||||
BitPackerCompressionAlgorithm,
|
||||
BitPackerCompressionOptions,
|
||||
CompressionConfig,
|
||||
} from "../types/types.js";
|
||||
|
||||
export interface CompressedBitPackerOptions {
|
||||
/** Compression configuration; defaults to disabled. */
|
||||
readonly compression?: CompressionConfig;
|
||||
/** Adapter registry; defaults to `defaultCompressionRegistry()`. */
|
||||
readonly compressionRegistry?: CompressionRegistry;
|
||||
}
|
||||
|
||||
export class CompressedBitPacker<
|
||||
T extends BitPackerSchema = BitPackerSchema,
|
||||
> extends BitPacker<T> {
|
||||
private _compressionAlgorithm: BitPackerCompressionAlgorithm = "none";
|
||||
private _compressionOptions: BitPackerCompressionOptions = {};
|
||||
private _compressionEnabled = false;
|
||||
private readonly compressionRegistry: CompressionRegistry;
|
||||
|
||||
public constructor(
|
||||
schema: BitPackerSchema,
|
||||
options?: CompressedBitPackerOptions
|
||||
) {
|
||||
super(schema);
|
||||
if (options?.compression) {
|
||||
this._compressionAlgorithm =
|
||||
options.compression.algorithm ?? "none";
|
||||
this._compressionOptions = options.compression.options ?? {};
|
||||
this._compressionEnabled = options.compression.enabled !== false;
|
||||
}
|
||||
this.compressionRegistry =
|
||||
options?.compressionRegistry ?? defaultCompressionRegistry();
|
||||
}
|
||||
|
||||
public get compressionAlgorithm(): BitPackerCompressionAlgorithm {
|
||||
return this._compressionAlgorithm;
|
||||
}
|
||||
|
||||
public set compressionAlgorithm(algorithm: BitPackerCompressionAlgorithm) {
|
||||
this._compressionAlgorithm = algorithm;
|
||||
}
|
||||
|
||||
public get compressionOptions(): BitPackerCompressionOptions {
|
||||
return this._compressionOptions;
|
||||
}
|
||||
|
||||
public set compressionOptions(options: BitPackerCompressionOptions) {
|
||||
this._compressionOptions = options;
|
||||
}
|
||||
|
||||
public get compressionEnabled(): boolean {
|
||||
return this._compressionEnabled;
|
||||
}
|
||||
|
||||
public set compressionEnabled(enabled: boolean) {
|
||||
this._compressionEnabled = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack `data` to bytes, compress through the configured adapter (if
|
||||
* enabled and not `"none"`), and frame with `[headerLen][algo][payload]`.
|
||||
* The frame is what consumers send on the wire; receivers call
|
||||
* `unpackWithDecompression` to reverse.
|
||||
*/
|
||||
public async packWithCompression(
|
||||
data: unknown,
|
||||
schema?: BitPackerSchema
|
||||
): Promise<Uint8Array> {
|
||||
const packed = super.pack(data, schema);
|
||||
const algorithm =
|
||||
this._compressionEnabled && this._compressionAlgorithm !== "none"
|
||||
? this._compressionAlgorithm
|
||||
: "none";
|
||||
|
||||
let payload: Uint8Array;
|
||||
if (algorithm === "none") {
|
||||
payload = packed;
|
||||
} else {
|
||||
payload = await compressData(
|
||||
packed,
|
||||
algorithm,
|
||||
this._compressionOptions,
|
||||
this.compressionRegistry
|
||||
);
|
||||
}
|
||||
return frame(algorithm, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse of `packWithCompression`. Reads the framing header, dispatches
|
||||
* to the named adapter for decompression (or routes the body straight
|
||||
* through if the header is `"none"`), then bit-unpacks the result.
|
||||
*
|
||||
* If `data` is empty, returns the result of unpacking an empty buffer
|
||||
* via the parent class (handles the "no-data" idiom upstream callers
|
||||
* relied on).
|
||||
*/
|
||||
public async unpackWithDecompression(
|
||||
data?: Uint8Array,
|
||||
schema?: BitPackerSchema
|
||||
): Promise<unknown> {
|
||||
if (data === undefined || data.length === 0) {
|
||||
return super.unpack(data ?? new Uint8Array(0), schema);
|
||||
}
|
||||
const { algorithm, payload } = unframe(data);
|
||||
const decompressed =
|
||||
algorithm === "none"
|
||||
? payload
|
||||
: await decompressData(
|
||||
payload,
|
||||
algorithm,
|
||||
this._compressionOptions,
|
||||
this.compressionRegistry
|
||||
);
|
||||
return super.unpack(decompressed, schema);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Frame: `[headerLen: u8][header: ASCII algorithm name][payload bytes]`.
|
||||
*
|
||||
* The header length is bounded to 255 because `headerLen` is one byte. The
|
||||
* registered algorithm names ("none", "gzip", "deflate", future "brotli" /
|
||||
* "zstd") are all well under that. If a future custom algorithm name
|
||||
* exceeds 255 bytes when ASCII-encoded, the framing throws — caller can
|
||||
* then choose a shorter alias to register the same adapter under.
|
||||
*/
|
||||
function frame(algorithm: string, payload: Uint8Array): Uint8Array {
|
||||
const header = new TextEncoder().encode(algorithm);
|
||||
if (header.length === 0 || header.length > 255) {
|
||||
throw new BitpackerError(
|
||||
BITPACKER_CODES.COMPRESSED_BAD_HEADER,
|
||||
`Compression algorithm name "${algorithm}" must encode to 1–255 bytes (got ${header.length})`,
|
||||
{ context: { algorithm, headerLength: header.length } }
|
||||
);
|
||||
}
|
||||
const out = new Uint8Array(1 + header.length + payload.length);
|
||||
out[0] = header.length;
|
||||
out.set(header, 1);
|
||||
out.set(payload, 1 + header.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
function unframe(data: Uint8Array): {
|
||||
algorithm: string;
|
||||
payload: Uint8Array;
|
||||
} {
|
||||
if (data.length < 2) {
|
||||
throw new BitpackerError(
|
||||
BITPACKER_CODES.COMPRESSED_BAD_HEADER,
|
||||
`Frame too short: ${data.length} bytes (need ≥ 2)`,
|
||||
{ context: { length: data.length } }
|
||||
);
|
||||
}
|
||||
const headerLen = data[0] as number;
|
||||
if (headerLen === 0 || headerLen >= data.length) {
|
||||
throw new BitpackerError(
|
||||
BITPACKER_CODES.COMPRESSED_BAD_HEADER,
|
||||
`Frame header length ${headerLen} out of range for ${data.length}-byte frame`,
|
||||
{ context: { headerLength: headerLen, frameLength: data.length } }
|
||||
);
|
||||
}
|
||||
const algorithm = new TextDecoder().decode(data.slice(1, 1 + headerLen));
|
||||
const payload = data.slice(1 + headerLen);
|
||||
return { algorithm, payload };
|
||||
}
|
||||
|
||||
export { frame as _frameForTests, unframe as _unframeForTests };
|
||||
Reference in New Issue
Block a user