Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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();
});
});

View File

@@ -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>;

View File

@@ -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);
});
});

View File

@@ -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 };