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,181 @@
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { DeflateCompressionAdapter } from "./deflate/deflate.js";
import { GzipCompressionAdapter } from "./gzip/gzip.js";
import { NoCompressionAdapter } from "./none/none.js";
describe("NoCompressionAdapter", () => {
it("compress passes bytes through unchanged", {
timeout: 5000,
}, async () => {
const adapter = new NoCompressionAdapter();
const input = new Uint8Array([1, 2, 3, 4]);
const out = await adapter.compress(input);
expect(out).toEqual(input);
});
it("decompress passes bytes through unchanged", {
timeout: 5000,
}, async () => {
const adapter = new NoCompressionAdapter();
const input = new Uint8Array([1, 2, 3, 4]);
const out = await adapter.decompress(input);
expect(out).toEqual(input);
});
it("name is 'none'", { timeout: 5000 }, () => {
expect(new NoCompressionAdapter().name).toBe("none");
});
});
const hasStreams = typeof CompressionStream !== "undefined";
describe.skipIf(!hasStreams)("GzipCompressionAdapter (stream-backed)", () => {
it("round-trip preserves bytes for 1 KiB of zeros", {
timeout: 5000,
}, async () => {
const adapter = new GzipCompressionAdapter();
const input = new Uint8Array(1024).fill(0);
const compressed = await adapter.compress(input);
// gzip on 1 KiB of zeros compresses dramatically:
expect(compressed.length).toBeLessThan(50);
const decompressed = await adapter.decompress(compressed);
expect(decompressed).toEqual(input);
});
it("round-trip preserves bytes for random 1 KiB", {
timeout: 5000,
}, async () => {
const adapter = new GzipCompressionAdapter();
const input = new Uint8Array(1024);
for (let i = 0; i < input.length; i++) {
input[i] = (i * 17 + 7) & 0xff;
}
const compressed = await adapter.compress(input);
const decompressed = await adapter.decompress(compressed);
expect(decompressed).toEqual(input);
});
it("decompress throws DECOMPRESSION_FAILED for truncated stream", {
timeout: 5000,
}, async () => {
const adapter = new GzipCompressionAdapter();
const original = new Uint8Array(1024).fill(42);
const compressed = await adapter.compress(original);
const truncated = compressed.slice(
0,
Math.floor(compressed.length / 2)
);
await expect(adapter.decompress(truncated)).rejects.toBeInstanceOf(
BitpackerError
);
try {
await adapter.decompress(truncated);
} catch (e) {
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.DECOMPRESSION_FAILED
);
expect((e as BitpackerError).context?.algorithm).toBe("gzip");
}
});
});
describe.skipIf(!hasStreams)(
"DeflateCompressionAdapter (stream-backed)",
() => {
it("round-trip preserves bytes for 1 KiB", {
timeout: 5000,
}, async () => {
const adapter = new DeflateCompressionAdapter();
const input = new Uint8Array(1024);
for (let i = 0; i < input.length; i++) {
input[i] = (i * 31 + 11) & 0xff;
}
const compressed = await adapter.compress(input);
const decompressed = await adapter.decompress(compressed);
expect(decompressed).toEqual(input);
});
it("name is 'deflate'", { timeout: 5000 }, () => {
expect(new DeflateCompressionAdapter().name).toBe("deflate");
});
}
);
describe("GzipCompressionAdapter (no streams branch)", () => {
it("compress throws COMPRESSION_FAILED when CompressionStream is missing", {
timeout: 5000,
}, async () => {
const g = globalThis as unknown as {
CompressionStream?: typeof CompressionStream;
};
const saved = g.CompressionStream;
g.CompressionStream = undefined;
try {
const adapter = new GzipCompressionAdapter();
await adapter.compress(new Uint8Array([1, 2, 3]));
throw new Error("expected throw, did not throw");
} catch (e) {
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.COMPRESSION_FAILED
);
expect((e as BitpackerError).context?.algorithm).toBe("gzip");
} finally {
g.CompressionStream = saved;
}
});
it("decompress throws DECOMPRESSION_FAILED when DecompressionStream is missing", {
timeout: 5000,
}, async () => {
const g = globalThis as unknown as {
DecompressionStream?: typeof DecompressionStream;
};
const saved = g.DecompressionStream;
g.DecompressionStream = undefined;
try {
const adapter = new GzipCompressionAdapter();
await adapter.decompress(new Uint8Array([1, 2, 3]));
throw new Error("expected throw, did not throw");
} catch (e) {
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.DECOMPRESSION_FAILED
);
expect((e as BitpackerError).context?.algorithm).toBe("gzip");
} finally {
g.DecompressionStream = saved;
}
});
it("compress wraps thrown stream-constructor error as COMPRESSION_FAILED", {
timeout: 5000,
}, async () => {
// Force runStream's try-block to throw by stubbing CompressionStream
// with a constructor that always throws — exercises the
// direction === "compress" branch of the catch's BitpackerError code
// selection.
const g = globalThis as unknown as {
CompressionStream?: typeof CompressionStream;
};
const saved = g.CompressionStream;
class ThrowingStream {
constructor() {
throw new Error("constructor blew up");
}
}
g.CompressionStream =
ThrowingStream as unknown as typeof CompressionStream;
try {
const adapter = new GzipCompressionAdapter();
await adapter.compress(new Uint8Array([1, 2, 3]));
throw new Error("expected throw, did not throw");
} catch (e) {
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.COMPRESSION_FAILED
);
expect((e as BitpackerError).context?.algorithm).toBe("gzip");
} finally {
g.CompressionStream = saved;
}
});
});

View File

@@ -0,0 +1,54 @@
# DeflateCompressionAdapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`DeflateCompressionAdapter` is the WHATWG Streams-backed deflate codec for `@suaveplan/bitpacker-compressed`. It mirrors `GzipCompressionAdapter` exactly but switches the underlying stream to `CompressionStream("deflate")` / `DecompressionStream("deflate")`. The chunk-concatenation pump is shared with the gzip adapter via `_runStreamForTests` so the two adapters do not duplicate the same loop. Use this when you need raw deflate (no gzip header) — typically for inter-op with consumers that expect that wire format (HTTP `Content-Encoding: deflate`, certain binary protocols, etc.).
## Features
- Drop-in `CompressionAdapter` implementation registered as `"deflate"` in the default registry.
- Re-uses the gzip adapter's streaming pump for chunk concatenation, eliminating duplicated logic.
- Same coded-error treatment as gzip: `COMPRESSION_FAILED` / `DECOMPRESSION_FAILED` with `cause`.
- Identical async signature to all other adapters so swapping codecs is a `registry.resolve(otherName)` change.
## Usage: Basic Example
```ts
import { DeflateCompressionAdapter } from "@suaveplan/bitpacker-compressed";
const deflate = new DeflateCompressionAdapter();
const compressed = await deflate.compress(payload);
const restored = await deflate.decompress(compressed);
```
## Usage: Advanced Example
```ts
import {
compressData,
decompressData,
} from "@suaveplan/bitpacker-compressed";
// HTTP body that arrived with Content-Encoding: deflate.
const body = await req.arrayBuffer();
const decoded = await decompressData(new Uint8Array(body), "deflate");
```
## API Reference
### `class DeflateCompressionAdapter implements CompressionAdapter`
- `readonly name: "deflate"`
- `async compress(data: Uint8Array, options?: { level?: number }): Promise<Uint8Array>`
- `async decompress(data: Uint8Array, options?: { level?: number }): Promise<Uint8Array>`
**Throws:** `BitpackerError(COMPRESSION_FAILED, { cause })` / `BitpackerError(DECOMPRESSION_FAILED, { cause })` on stream failure or environments without `globalThis.CompressionStream`.
## Implementation Notes
- "Deflate" here means raw RFC 1951 (no zlib header). If you need the zlib-wrapped variant, the platform `CompressionStream("deflate")` already produces it on most browsers — this is a known compatibility nuance and callers MUST be aware of which side they need.
- The shared `_runStreamForTests` helper is intentionally underscore-prefixed to mark it as adapter-internal; consumers should not import it directly.
- WHATWG `CompressionStream` ignores the `level` option for deflate the same way it does for gzip.

View File

@@ -0,0 +1,33 @@
/**
* `DeflateCompressionAdapter` — WHATWG Streams-backed deflate codec.
*
* Same shape as `GzipCompressionAdapter`; uses
* `globalThis.CompressionStream("deflate")` and the matching decompression
* stream. Re-uses the streaming pump from the gzip adapter to avoid
* duplicating the chunk-concatenation loop.
*/
import type {
BitPackerCompressionAlgorithm,
BitPackerCompressionOptions,
CompressionAdapter,
} from "../../types/types.js";
import { _runStreamForTests as runStream } from "../gzip/gzip.js";
export class DeflateCompressionAdapter implements CompressionAdapter {
public readonly name: BitPackerCompressionAlgorithm = "deflate";
public async compress(
data: Uint8Array,
_options?: BitPackerCompressionOptions
): Promise<Uint8Array> {
return runStream("deflate", "compress", data);
}
public async decompress(
data: Uint8Array,
_options?: BitPackerCompressionOptions
): Promise<Uint8Array> {
return runStream("deflate", "decompress", data);
}
}

View File

@@ -0,0 +1,56 @@
# GzipCompressionAdapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`GzipCompressionAdapter` is the WHATWG Streams-backed gzip codec. It uses `globalThis.CompressionStream("gzip")` and `DecompressionStream("gzip")` — available in modern browsers, Bun, Deno, and Node 18+. The adapter exists so callers don't have to reach into Web Streams APIs directly: the pipeline framing, error coding, and chunk-concatenation loop are all handled here so `compressData(data, "gzip")` is a one-liner. If the host environment lacks `CompressionStream`, the registry's `defaultCompressionRegistry()` skips registering this adapter; constructing one directly and calling `compress` in such a host throws `BitpackerError(COMPRESSION_FAILED)` with a descriptive cause chain.
## Features
- Drop-in `CompressionAdapter` implementation registered as `"gzip"` in the default registry.
- Streaming pump under the hood — large payloads are chunked through `CompressionStream` rather than buffered whole.
- Coded errors (`COMPRESSION_FAILED` / `DECOMPRESSION_FAILED`) wrap any underlying stream exception with `cause`.
- Re-exported `_runStreamForTests` helper used by `DeflateCompressionAdapter` to share the chunk-concatenation loop.
## Usage: Basic Example
```ts
import { GzipCompressionAdapter } from "@suaveplan/bitpacker-compressed";
const gzip = new GzipCompressionAdapter();
const compressed = await gzip.compress(myBytes);
const restored = await gzip.decompress(compressed);
```
## Usage: Advanced Example
```ts
import { defaultCompressionRegistry } from "@suaveplan/bitpacker-compressed";
// Production code typically goes through the registry.
const registry = defaultCompressionRegistry();
const adapter = registry.resolve("gzip");
const out = await adapter.compress(payload, { level: 9 });
```
## API Reference
### `class GzipCompressionAdapter implements CompressionAdapter`
- `readonly name: "gzip"`
- `async compress(data: Uint8Array, options?: { level?: number }): Promise<Uint8Array>`
- `async decompress(data: Uint8Array, options?: { level?: number }): Promise<Uint8Array>`
**Throws:** `BitpackerError(COMPRESSION_FAILED, { cause })` / `BitpackerError(DECOMPRESSION_FAILED, { cause })` if the underlying `CompressionStream` rejects, including environments without `globalThis.CompressionStream`.
### `_runStreamForTests(stream, data): Promise<Uint8Array>` (internal)
Exposed for the deflate adapter to re-use the pump loop. Not part of the public API.
## Implementation Notes
- WHATWG `CompressionStream` does not honour the `level` option — gzip level is fixed at the platform default. The option is accepted for API symmetry with adapters that DO honour it (e.g. a future `BrotliAdapter`).
- The pump loop uses `ReadableStream.prototype.getReader()` and concatenates chunks into a single `Uint8Array` at the end. For very large payloads, callers should consider invoking `CompressionStream` directly to get backpressure-aware streaming output.
- Every async method awaits inside a try/catch and re-throws as a coded `BitpackerError`. There is no silent fallback — if gzip fails, the caller sees a fully-typed coded error.

View File

@@ -0,0 +1,111 @@
/**
* `GzipCompressionAdapter` — WHATWG Streams-backed gzip codec.
*
* Uses `globalThis.CompressionStream("gzip")` and `DecompressionStream("gzip")`
* — available in modern browsers, Bun, Deno, and Node 18+. If the host
* lacks `CompressionStream`, every method throws so the registry's
* `defaultCompressionRegistry()` factory only registers this adapter when
* the API is detected.
*/
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import type {
BitPackerCompressionAlgorithm,
BitPackerCompressionOptions,
CompressionAdapter,
} from "../../types/types.js";
export class GzipCompressionAdapter implements CompressionAdapter {
public readonly name: BitPackerCompressionAlgorithm = "gzip";
public async compress(
data: Uint8Array,
_options?: BitPackerCompressionOptions
): Promise<Uint8Array> {
return runStream("gzip", "compress", data);
}
public async decompress(
data: Uint8Array,
_options?: BitPackerCompressionOptions
): Promise<Uint8Array> {
return runStream("gzip", "decompress", data);
}
}
/**
* Common pump that pushes `data` through a `CompressionStream` /
* `DecompressionStream` and concatenates the chunks. Threading a single
* `Uint8Array` chunk avoids the back-pressure considerations the streams
* API otherwise exposes (we have all the bytes in memory upfront, so a
* one-shot write + close is the right shape).
*/
async function runStream(
format: "gzip" | "deflate",
direction: "compress" | "decompress",
data: Uint8Array
): Promise<Uint8Array> {
const StreamCtor =
direction === "compress"
? (globalThis as { CompressionStream?: typeof CompressionStream })
.CompressionStream
: (
globalThis as {
DecompressionStream?: typeof DecompressionStream;
}
).DecompressionStream;
if (StreamCtor === undefined) {
throw new BitpackerError(
direction === "compress"
? BITPACKER_CODES.COMPRESSION_FAILED
: BITPACKER_CODES.DECOMPRESSION_FAILED,
`${format} ${direction} unavailable: host lacks CompressionStream/DecompressionStream`,
{ context: { algorithm: format } }
);
}
try {
const stream = new StreamCtor(format);
const writer = stream.writable.getWriter();
// Force the data to be backed by an ArrayBuffer (not SharedArrayBuffer)
// before passing to the streams API — required by the spec. Allocating
// a fresh `Uint8Array(data)` always produces an ArrayBuffer-backed view.
const payload = new Uint8Array(data) as Uint8Array<ArrayBuffer>;
await writer.write(payload);
await writer.close();
const chunks: Uint8Array[] = [];
const reader = stream.readable.getReader();
// Pump until the readable side reports done. Express the loop
// condition via the read result so the reader's flag drives the
// exit (avoids `while (true)` which biome flags as a constant
// condition).
for (
let result = await reader.read();
!result.done;
result = await reader.read()
) {
chunks.push(result.value);
}
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
const out = new Uint8Array(totalLength);
let offset = 0;
for (const c of chunks) {
out.set(c, offset);
offset += c.length;
}
return out;
} catch (cause) {
throw new BitpackerError(
direction === "compress"
? BITPACKER_CODES.COMPRESSION_FAILED
: BITPACKER_CODES.DECOMPRESSION_FAILED,
`${format} ${direction} failed`,
{
cause: cause as Error,
context: { algorithm: format },
}
);
}
}
export { runStream as _runStreamForTests };

View File

@@ -0,0 +1,55 @@
# NoCompressionAdapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`NoCompressionAdapter` is the pass-through codec used as the default when `CompressionConfig.algorithm` is `"none"` or omitted. It exists so that `compressData`, `decompressData`, and `CompressedBitPacker` can ALWAYS route through the compression pipeline without special-casing the "compression disabled" branch — the bytes flow through `compress` and `decompress` calls that resolve immediately with the input. This keeps the framing logic in `CompressedBitPacker` uniform: every payload has the same header shape regardless of whether real compression occurred.
## Features
- Pass-through `compress` / `decompress` — returns the input `Uint8Array` reference unchanged.
- Drop-in `CompressionAdapter` implementation registered as `"none"` in the default registry.
- Always available — does not depend on `CompressionStream` and never throws.
- Default fallback when no algorithm is specified.
## Usage: Basic Example
```ts
import { NoCompressionAdapter } from "@suaveplan/bitpacker-compressed";
const none = new NoCompressionAdapter();
const same = await none.compress(myBytes); // myBytes itself
```
## Usage: Advanced Example
```ts
import {
compressData,
defaultCompressionRegistry,
} from "@suaveplan/bitpacker-compressed";
// "none" routes through the pipeline so framing stays uniform with
// gzip / deflate. Useful in tests that want to assert framing is
// orthogonal to the codec.
const out = await compressData(payload, "none");
const adapter = defaultCompressionRegistry().resolve("none");
```
## API Reference
### `class NoCompressionAdapter implements CompressionAdapter`
- `readonly name: "none"`
- `async compress(data: Uint8Array): Promise<Uint8Array>` — returns `data` unchanged.
- `async decompress(data: Uint8Array): Promise<Uint8Array>` — returns `data` unchanged.
The `options` parameter is accepted for interface symmetry but ignored.
## Implementation Notes
- The returned reference is the SAME `Uint8Array` instance as the input — the adapter does not copy. Callers that need an independent buffer should `slice()` separately.
- This adapter is unconditionally registered in `defaultCompressionRegistry()`. The other two defaults (`"gzip"`, `"deflate"`) are gated on `CompressionStream` availability; "none" always works.
- Tests that need to prove "compression is opt-in" use this adapter to confirm the pipeline behaves identically when the caller chose to skip real compression.

View File

@@ -0,0 +1,30 @@
/**
* `NoCompressionAdapter` — pass-through. Lets `CompressedBitPacker` always
* route through the compression pipeline without special-casing the
* "compression disabled" branch. Used as the default when
* `CompressionConfig.algorithm` is `"none"` or omitted.
*/
import type {
BitPackerCompressionAlgorithm,
BitPackerCompressionOptions,
CompressionAdapter,
} from "../../types/types.js";
export class NoCompressionAdapter implements CompressionAdapter {
public readonly name: BitPackerCompressionAlgorithm = "none";
public async compress(
data: Uint8Array,
_options?: BitPackerCompressionOptions
): Promise<Uint8Array> {
return data;
}
public async decompress(
data: Uint8Array,
_options?: BitPackerCompressionOptions
): Promise<Uint8Array> {
return data;
}
}

View File

@@ -0,0 +1,62 @@
# compression API (`compressData` / `decompressData`)
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`compressData` and `decompressData` are the primary public surface of `@suaveplan/bitpacker-compressed`. They are algorithm-by-name helpers that route through a `CompressionRegistry` (defaulting to the process-wide singleton) so callers can switch between `"gzip"`, `"deflate"`, `"none"`, and any custom registered adapter without touching adapter classes directly. They are the layer that hot-path consumers like `CompressedBitPacker` use; it is also the layer that wraps codec failures in `BitpackerError` codes so application code never has to handle a raw `DOMException` or platform-specific stream error.
## Features
- Algorithm-by-name routing through a pluggable `CompressionRegistry`.
- Coded errors: unknown algorithm → `UNKNOWN_COMPRESSION_ALGORITHM`, codec failure → `COMPRESSION_FAILED` / `DECOMPRESSION_FAILED` with the underlying error chained.
- Optional registry parameter — pass a custom `CompressionRegistry` for isolation in tests.
- Symmetric: `compressData(data, "gzip")` round-trips through `decompressData(bytes, "gzip")`.
## Usage: Basic Example
```ts
import { compressData, decompressData } from "@suaveplan/bitpacker-compressed";
const data = new TextEncoder().encode("hello world".repeat(100));
const compressed = await compressData(data, "gzip");
const restored = await decompressData(compressed, "gzip");
```
## Usage: Advanced Example
```ts
import {
compressData,
createCompressionRegistry,
} from "@suaveplan/bitpacker-compressed";
// Test isolation: create a fresh registry that ONLY has the "none" adapter
// registered, then prove the unknown-algorithm path throws coded errors.
const registry = createCompressionRegistry({ withDefaults: false });
try {
await compressData(data, "gzip", { registry });
} catch (err) {
// BitpackerError with code BITPACKER.UNKNOWN_COMPRESSION_ALGORITHM
}
```
## API Reference
### `compressData(data, algorithm, options?): Promise<Uint8Array>`
- `data: Uint8Array` — bytes to compress.
- `algorithm: string` — registry key (e.g. `"gzip"`, `"deflate"`, `"none"`).
- `options?: { registry?, level? }` — `registry` overrides the default singleton; `level` is forwarded to the adapter.
- **Throws:** `BitpackerError(UNKNOWN_COMPRESSION_ALGORITHM)` or `BitpackerError(COMPRESSION_FAILED, { cause })`.
### `decompressData(data, algorithm, options?): Promise<Uint8Array>`
- Inverse of `compressData`. Throws `BitpackerError(DECOMPRESSION_FAILED, { cause })` on codec failure.
## Implementation Notes
- The default singleton is initialized lazily; it registers `"none"`, `"gzip"`, `"deflate"` only if the host supports `CompressionStream`.
- Adapters are async because `CompressionStream` is async; `NoCompressionAdapter` returns immediately.
- Hot-path callers should resolve the adapter once and call it directly to avoid the per-call registry lookup.

View File

@@ -0,0 +1,268 @@
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NoCompressionAdapter } from "../adapters/none/none.js";
import {
type CompressionRegistry,
createCompressionRegistry,
} from "../registry/registry.js";
import type { CompressionAdapter } from "../types/types.js";
import { compressData, decompressData } from "./api.js";
describe("compressData / decompressData", () => {
it("round-trip via 'none' returns original bytes", {
timeout: 5000,
}, async () => {
const reg = createCompressionRegistry();
const input = new Uint8Array([1, 2, 3, 4, 5]);
const c = await compressData(input, "none", undefined, reg);
const d = await decompressData(c, "none", undefined, reg);
expect(d).toEqual(input);
});
it("compressData throws UNKNOWN_COMPRESSION_ALGORITHM for unregistered algorithm", {
timeout: 5000,
}, async () => {
const reg = createCompressionRegistry();
await expect(
compressData(new Uint8Array([1]), "zstd", undefined, reg)
).rejects.toBeInstanceOf(BitpackerError);
});
it("decompressData throws UNKNOWN_COMPRESSION_ALGORITHM for unregistered algorithm", {
timeout: 5000,
}, async () => {
const reg = createCompressionRegistry();
try {
await decompressData(new Uint8Array([1]), "zstd", undefined, reg);
} catch (e) {
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.UNKNOWN_COMPRESSION_ALGORITHM
);
}
});
it("compressData wraps non-Bitpacker codec errors as COMPRESSION_FAILED", {
timeout: 5000,
}, async () => {
// Custom adapter whose compress() throws a plain Error.
class AngryAdapter implements CompressionAdapter {
public readonly name = "angry";
public compress(): Promise<Uint8Array> {
return Promise.reject(new Error("boom"));
}
public decompress(): Promise<Uint8Array> {
return Promise.reject(new Error("boom-d"));
}
}
const reg: CompressionRegistry = createCompressionRegistry();
reg.register(new AngryAdapter());
try {
await compressData(
new Uint8Array([1, 2, 3]),
"angry",
undefined,
reg
);
} catch (e) {
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.COMPRESSION_FAILED
);
expect((e as BitpackerError).context?.algorithm).toBe("angry");
}
});
it("decompressData wraps non-Bitpacker codec errors as DECOMPRESSION_FAILED", {
timeout: 5000,
}, async () => {
class AngryAdapter implements CompressionAdapter {
public readonly name = "angry";
public compress(): Promise<Uint8Array> {
return Promise.reject(new Error("boom"));
}
public decompress(): Promise<Uint8Array> {
return Promise.reject(new Error("boom-d"));
}
}
const reg: CompressionRegistry = createCompressionRegistry();
reg.register(new AngryAdapter());
try {
await decompressData(
new Uint8Array([1, 2, 3]),
"angry",
undefined,
reg
);
} catch (e) {
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.DECOMPRESSION_FAILED
);
}
});
it("passes through BitpackerError codes from adapter without re-wrap", {
timeout: 5000,
}, async () => {
class CodedAdapter implements CompressionAdapter {
public readonly name = "coded";
public compress(): Promise<Uint8Array> {
return Promise.reject(
new BitpackerError(
BITPACKER_CODES.COMPRESSION_FAILED,
"preserved",
{ context: { sentinel: true } }
)
);
}
public decompress(): Promise<Uint8Array> {
return this.compress();
}
}
const reg = createCompressionRegistry();
reg.register(new CodedAdapter());
try {
await compressData(new Uint8Array([1]), "coded", undefined, reg);
} catch (e) {
expect((e as BitpackerError).context?.sentinel).toBe(true);
}
});
it("decompressData also passes BitpackerError through without re-wrap", {
timeout: 5000,
}, async () => {
class CodedAdapter implements CompressionAdapter {
public readonly name = "coded-d";
public compress(): Promise<Uint8Array> {
return Promise.reject(
new BitpackerError(
BITPACKER_CODES.DECOMPRESSION_FAILED,
"preserved-d",
{ context: { sentinel: "decompress" } }
)
);
}
public decompress(): Promise<Uint8Array> {
return this.compress();
}
}
const reg = createCompressionRegistry();
reg.register(new CodedAdapter());
try {
await decompressData(
new Uint8Array([1]),
"coded-d",
undefined,
reg
);
} catch (e) {
expect((e as BitpackerError).context?.sentinel).toBe("decompress");
}
});
it("uses defaultCompressionRegistry when registry omitted", {
timeout: 5000,
}, async () => {
// Default registry always has 'none'.
const input = new Uint8Array([7, 7, 7]);
const out = await compressData(input, "none");
expect(out).toEqual(input);
});
it("decompressData also defaults the registry argument", {
timeout: 5000,
}, async () => {
const input = new Uint8Array([9, 9, 9]);
const out = await decompressData(input, "none");
expect(out).toEqual(input);
});
it("options arg is forwarded to adapter", { timeout: 5000 }, async () => {
let observed: unknown;
class ObservingAdapter implements CompressionAdapter {
public readonly name = "observe";
public async compress(
data: Uint8Array,
options?: Record<string, unknown>
): Promise<Uint8Array> {
observed = options;
return data;
}
public async decompress(data: Uint8Array): Promise<Uint8Array> {
return data;
}
}
const reg = createCompressionRegistry();
reg.register(new ObservingAdapter());
await compressData(new Uint8Array([1]), "observe", { level: 9 }, reg);
expect(observed).toEqual({ level: 9 });
});
it("smoke-tests NoCompressionAdapter", { timeout: 5000 }, () => {
// Coverage anchor for the NoCompressionAdapter constructor.
const a = new NoCompressionAdapter();
expect(a.name).toBe("none");
});
it("compressData wraps non-Error throws as COMPRESSION_FAILED with String() cause", {
timeout: 5000,
}, async () => {
// Adapter rejects with a non-Error value (string). The api wrapper
// must coerce via `new Error(String(cause))` to satisfy the
// BitpackerError cause contract.
class StringThrower implements CompressionAdapter {
public readonly name = "string-thrower";
public compress(): Promise<Uint8Array> {
return Promise.reject("boom-string" as unknown as Error);
}
public decompress(): Promise<Uint8Array> {
return Promise.reject("boom-string-d" as unknown as Error);
}
}
const reg = createCompressionRegistry();
reg.register(new StringThrower());
try {
await compressData(
new Uint8Array([1]),
"string-thrower",
undefined,
reg
);
} catch (e) {
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.COMPRESSION_FAILED
);
expect((e as BitpackerError).cause).toBeInstanceOf(Error);
expect(((e as BitpackerError).cause as Error).message).toBe(
"boom-string"
);
}
});
it("decompressData wraps non-Error throws as DECOMPRESSION_FAILED with String() cause", {
timeout: 5000,
}, async () => {
class StringThrower implements CompressionAdapter {
public readonly name = "string-thrower-d";
public compress(): Promise<Uint8Array> {
return Promise.reject("boom-string" as unknown as Error);
}
public decompress(): Promise<Uint8Array> {
return Promise.reject(42 as unknown as Error);
}
}
const reg = createCompressionRegistry();
reg.register(new StringThrower());
try {
await decompressData(
new Uint8Array([1]),
"string-thrower-d",
undefined,
reg
);
} catch (e) {
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.DECOMPRESSION_FAILED
);
expect(((e as BitpackerError).cause as Error).message).toBe("42");
}
});
});

View File

@@ -0,0 +1,72 @@
/**
* `compressData` / `decompressData` — algorithm-by-name helpers backed by a
* `CompressionRegistry`. Mirrors upstream `compression.ts::compressData` /
* `decompressData` but routes through coded errors and a caller-supplied
* registry (defaults to the process-wide singleton).
*
* Hot-path consumers (`CompressedBitPacker`) use these directly. They
* throw `BitpackerError(UNKNOWN_COMPRESSION_ALGORITHM)` on missing adapter
* and wrap codec failures as `BitpackerError(COMPRESSION_FAILED)` /
* `BitpackerError(DECOMPRESSION_FAILED)` with the underlying error chained.
*/
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import {
type CompressionRegistry,
defaultCompressionRegistry,
} from "../registry/registry.js";
import type {
BitPackerCompressionAlgorithm,
BitPackerCompressionOptions,
} from "../types/types.js";
export async function compressData(
data: Uint8Array,
algorithm: BitPackerCompressionAlgorithm,
options?: BitPackerCompressionOptions,
registry: CompressionRegistry = defaultCompressionRegistry()
): Promise<Uint8Array> {
const adapter = registry.resolve(algorithm);
try {
return await adapter.compress(data, options);
} catch (cause) {
if (cause instanceof BitpackerError) {
// Adapter already raised a coded error (e.g. unsupported host).
throw cause;
}
throw new BitpackerError(
BITPACKER_CODES.COMPRESSION_FAILED,
`Compression with algorithm "${algorithm}" failed`,
{
cause:
cause instanceof Error ? cause : new Error(String(cause)),
context: { algorithm },
}
);
}
}
export async function decompressData(
data: Uint8Array,
algorithm: BitPackerCompressionAlgorithm,
options?: BitPackerCompressionOptions,
registry: CompressionRegistry = defaultCompressionRegistry()
): Promise<Uint8Array> {
const adapter = registry.resolve(algorithm);
try {
return await adapter.decompress(data, options);
} catch (cause) {
if (cause instanceof BitpackerError) {
throw cause;
}
throw new BitpackerError(
BITPACKER_CODES.DECOMPRESSION_FAILED,
`Decompression with algorithm "${algorithm}" failed`,
{
cause:
cause instanceof Error ? cause : new Error(String(cause)),
context: { algorithm },
}
);
}
}

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

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { compressRle, decompressRle } from "./index.js";
describe("compressed", () => {
it("round-trips RLE compression", () => {
const data = new Uint8Array([1, 1, 1, 1, 2, 3, 3, 3, 3, 3]);
const compressed = compressRle(data);
const decompressed = decompressRle(compressed);
expect(Array.from(decompressed)).toEqual(Array.from(data));
});
it("handles incompressible data", () => {
const data = new Uint8Array([1, 2, 3, 4, 5]);
const compressed = compressRle(data);
const decompressed = decompressRle(compressed);
expect(Array.from(decompressed)).toEqual(Array.from(data));
});
});

View File

@@ -0,0 +1,8 @@
## index
**Path:** `bitpacker-compressed/src/index.ts`
**Export:** See source for details.
### What it does
Part of the @suaveplan/ package.

View File

@@ -0,0 +1,46 @@
/**
* Compression pipeline for bitpacker.
*
* Public surface:
*
* - `CompressedBitPacker<T>` — `BitPacker` subclass that compresses packed
* bytes through a configured `CompressionAdapter` and frames them with a
* self-describing algorithm header.
* - `CompressionRegistry` + `defaultCompressionRegistry()` /
* `createCompressionRegistry()` — plug-in registry for compression adapters.
* Defaults: `none` always; `gzip` / `deflate` lazy-registered when the
* host environment exposes `CompressionStream`.
* - `NoCompressionAdapter`, `GzipCompressionAdapter`, `DeflateCompressionAdapter`
* — built-in adapters.
* - `CompressionAdapter`, `CompressionConfig`, `BitPackerCompressionAlgorithm`,
* `BitPackerCompressionOptions` — types.
* - `compressData`/`decompressData` — algorithm-by-name helpers.
* - `compressRle`/`decompressRle` — bytes-only RLE fast path (also re-exported
* from `./rle` for callers that want the focused subpath).
*/
export { DeflateCompressionAdapter } from "./adapters/deflate/deflate.js";
export { GzipCompressionAdapter } from "./adapters/gzip/gzip.js";
export { NoCompressionAdapter } from "./adapters/none/none.js";
export { compressData, decompressData } from "./api/api.js";
export {
CompressedBitPacker,
type CompressedBitPackerOptions,
} from "./compressed-bit-packer/compressed-bit-packer.js";
export {
_resetDefaultRegistryForTests,
CompressionRegistry,
createCompressionRegistry,
defaultCompressionRegistry,
} from "./registry/registry.js";
export {
type CompressionOptions,
compressRle,
decompressRle,
} from "./rle/rle.js";
export type {
BitPackerCompressionAlgorithm,
BitPackerCompressionOptions,
CompressionAdapter,
CompressionConfig,
} from "./types/types.js";

View File

@@ -0,0 +1,83 @@
# Bitpacker Compressed OTel Instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
OTel meter, tracer, counter, and histogram declarations for `@suaveplan/bitpacker-compressed`. Ships canonical instrumentation primitives for the compression/decompression pipeline (`gzip`, `deflate`, `none`, `rle`, future custom adapters) plus the `CompressedBitPacker.pack` / `unpack` boundaries that wrap the compression step. Every operation can be observed with the same vocabulary regardless of which adapter is selected.
The metric and span names follow the suaveplan convention with snake-case for the multi-word package: tracer/meter named `@suaveplan/bitpacker-compressed`; spans named `bitpacker_compressed.<operation>`; counters/histograms named `bitpacker_compressed.<scope>.<metric>`.
## Features
- `getBitPackerCompressedTracer()` — returns the package's tracer
- `compressionOpsTotal` — counter labelled by `op`, `algorithm`, `success`
- `compressionOpDurationMs` — histogram of compress/decompress duration
- `compressionRatio` — histogram of `output_bytes / input_bytes` per successful compress
- `BITPACKER_COMPRESSED_SPAN_NAMES` — four canonical span names
## Usage: Basic Example
```ts
import {
BITPACKER_COMPRESSED_SPAN_NAMES,
compressionOpsTotal,
getBitPackerCompressedTracer,
} from "@suaveplan/bitpacker-compressed";
const tracer = getBitPackerCompressedTracer();
tracer.startActiveSpan(
BITPACKER_COMPRESSED_SPAN_NAMES.compress,
(span) => {
try {
// compress work
compressionOpsTotal.add(1, {
op: "compress",
algorithm: "gzip",
success: "true",
});
} finally {
span.end();
}
}
);
```
## Usage: Advanced Example
```ts
import { compressionRatio } from "@suaveplan/bitpacker-compressed";
function recordCompressionRatio(input: Uint8Array, output: Uint8Array, algorithm: string) {
compressionRatio.record(output.byteLength / input.byteLength, { algorithm });
}
```
## API Reference
### `getBitPackerCompressedTracer(): Tracer`
Returns the OTel `Tracer` instance for the package.
### `compressionOpsTotal: Counter`
Counter labelled by `op: "compress" | "decompress"`, `algorithm: string`, `success: "true" | "false"`.
### `compressionOpDurationMs: Histogram`
Histogram of elapsed milliseconds per compress/decompress call.
### `compressionRatio: Histogram`
Histogram of compression ratio (`output_bytes / input_bytes`), labelled by `algorithm`. Recorded only on successful `compress` calls — decompress doesn't have a meaningful ratio.
### `BITPACKER_COMPRESSED_SPAN_NAMES`
Const map: `{ compress, decompress, pack, unpack }` → canonical span names.
## Implementation Notes
- **Algorithm is a label, not a counter prefix.** `compressionOpsTotal{algorithm="gzip"}` is more flexible than separate `gzip_compress_total` / `deflate_compress_total` counters; new adapters fold in without schema changes.
- **`compressionRatio` is not a counter.** The ratio is per-call and stored as a histogram so dashboards can plot p50/p95/p99 ratios over time. A counter would only show throughput, not effectiveness.
- **No per-chunk spans.** Compression adapters often process bytes in internal chunks; one span each would explode trace volume on large inputs.

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
BITPACKER_COMPRESSED_SPAN_NAMES,
compressionOpDurationMs,
compressionOpsTotal,
compressionRatio,
getBitPackerCompressedTracer,
} from "./instruments.js";
describe("OTel instruments — declarations", () => {
it("exposes the canonical span names", () => {
expect(BITPACKER_COMPRESSED_SPAN_NAMES.compress).toBe(
"bitpacker_compressed.compress"
);
expect(BITPACKER_COMPRESSED_SPAN_NAMES.decompress).toBe(
"bitpacker_compressed.decompress"
);
expect(BITPACKER_COMPRESSED_SPAN_NAMES.pack).toBe(
"bitpacker_compressed.pack"
);
expect(BITPACKER_COMPRESSED_SPAN_NAMES.unpack).toBe(
"bitpacker_compressed.unpack"
);
});
it("counters and histograms are constructed without error", () => {
expect(compressionOpsTotal).toBeDefined();
expect(compressionOpDurationMs).toBeDefined();
expect(compressionRatio).toBeDefined();
});
it("tracer is reachable", () => {
expect(getBitPackerCompressedTracer()).toBeDefined();
});
it("counters accept the documented labels", () => {
expect(() =>
compressionOpsTotal.add(1, {
op: "compress",
algorithm: "gzip",
success: "true",
})
).not.toThrow();
});
});

View File

@@ -0,0 +1,51 @@
import { metrics, type Tracer, trace } from "@opentelemetry/api";
const meter = metrics.getMeter("@suaveplan/bitpacker-compressed");
export function getBitPackerCompressedTracer(): Tracer {
return trace.getTracer("@suaveplan/bitpacker-compressed");
}
/**
* Counter incremented per top-level compress / decompress call. Labels:
* `op` (`compress` | `decompress`), `algorithm` (e.g. `gzip`, `deflate`,
* `none`, `rle`), and `success`.
*/
export const compressionOpsTotal = meter.createCounter(
"bitpacker_compressed.ops.total",
{
description: "Total compression / decompression operations",
unit: "{ops}",
}
);
/**
* Histogram of operation duration in milliseconds.
*/
export const compressionOpDurationMs = meter.createHistogram(
"bitpacker_compressed.op.duration_ms",
{
description: "Duration of compression / decompression in milliseconds",
unit: "ms",
}
);
/**
* Histogram of compression ratio (compressed bytes / input bytes), labelled
* by `algorithm`. Recorded only on successful `compress` calls.
*/
export const compressionRatio = meter.createHistogram(
"bitpacker_compressed.compression.ratio",
{
description:
"Compression ratio (output_bytes / input_bytes) per successful compress",
unit: "1",
}
);
export const BITPACKER_COMPRESSED_SPAN_NAMES = {
compress: "bitpacker_compressed.compress",
decompress: "bitpacker_compressed.decompress",
pack: "bitpacker_compressed.pack",
unpack: "bitpacker_compressed.unpack",
} as const;

View File

@@ -0,0 +1,5 @@
export {
BITPACKER_COMPRESSED_SCHEMA_VERSION,
BITPACKER_COMPRESSED_SUBJECT,
registerBitPackerCompressedSchemas,
} from "./register-schemas.js";

View File

@@ -0,0 +1,61 @@
# Register Schemas — `@suaveplan/bitpacker-compressed`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Idempotent integration with `@suaveplan/schema-registry` for the `@suaveplan/bitpacker-compressed` package. Exposes `registerBitPackerCompressedSchemas(registry)` which converts the package's five public boundary schemas into JSON Schema form and registers them under `bitpacker:@suaveplan/bitpacker-compressed` at version 1. Re-registration on the same registry is a no-op.
The five schemas covered are: `bitPackerCompressionOptions` (the open-record adapter options), `compressionAdapter` (the structural contract every adapter implements), `compressionConfig` (the `{ algorithm, options, enabled }` config), `compressedBitPackerOptions` (the `CompressedBitPacker` constructor bundle), and `rleCompressionOptions` (the RLE-only `level` knob).
## Features
- `BITPACKER_COMPRESSED_SUBJECT` constant — `"bitpacker:@suaveplan/bitpacker-compressed"`
- `BITPACKER_COMPRESSED_SCHEMA_VERSION` constant — `1`
- `registerBitPackerCompressedSchemas(registry)` — idempotent registration
## Usage: Basic Example
```ts
import { createRegistry } from "@suaveplan/schema-registry";
import {
BITPACKER_COMPRESSED_SUBJECT,
registerBitPackerCompressedSchemas,
} from "@suaveplan/bitpacker-compressed/register-schemas";
const registry = createRegistry();
registerBitPackerCompressedSchemas(registry);
```
## Usage: Advanced Example
```ts
import { createRegistry, SchemaRegistry } from "@suaveplan/schema-registry";
import { registerBitPackerSchemas } from "@suaveplan/bitpacker/register-schemas";
import { registerBitPackerCompressedSchemas } from "@suaveplan/bitpacker-compressed/register-schemas";
const registry = createRegistry();
registerBitPackerSchemas(registry);
registerBitPackerCompressedSchemas(registry);
```
## API Reference
### `BITPACKER_COMPRESSED_SUBJECT: "bitpacker:@suaveplan/bitpacker-compressed"`
Const string.
### `BITPACKER_COMPRESSED_SCHEMA_VERSION: 1`
Const number.
### `registerBitPackerCompressedSchemas(registry: SchemaRegistry): void`
Idempotent registration. Returns without writing if the subject is already present.
## Implementation Notes
- **Five-key payload.** One key per public boundary schema.
- **`unrepresentable: "any"`.** Required because `compressionAdapter` and `compressedBitPackerOptions` use `z.custom` for the structural contract and registry instance check.

View File

@@ -0,0 +1,42 @@
import { createRegistry } from "@suaveplan/schema-registry";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
BITPACKER_COMPRESSED_SCHEMA_VERSION,
BITPACKER_COMPRESSED_SUBJECT,
registerBitPackerCompressedSchemas,
} from "./register-schemas.js";
describe("registerBitPackerCompressedSchemas", () => {
it("registers under the namespaced subject at version 1", () => {
const registry = createRegistry();
registerBitPackerCompressedSchemas(registry);
expect(registry.getLatest(BITPACKER_COMPRESSED_SUBJECT).version).toBe(
BITPACKER_COMPRESSED_SCHEMA_VERSION
);
});
it("includes every public schema in the payload", () => {
const registry = createRegistry();
registerBitPackerCompressedSchemas(registry);
const payload = registry.getLatest(BITPACKER_COMPRESSED_SUBJECT)
.schema as Record<string, unknown>;
for (const key of [
"bitPackerCompressionOptions",
"compressionAdapter",
"compressionConfig",
"compressedBitPackerOptions",
"rleCompressionOptions",
]) {
expect(payload).toHaveProperty(key);
}
});
it("is idempotent across repeated calls", () => {
const registry = createRegistry();
registerBitPackerCompressedSchemas(registry);
registerBitPackerCompressedSchemas(registry);
expect(registry.getLatest(BITPACKER_COMPRESSED_SUBJECT).version).toBe(
1
);
});
});

View File

@@ -0,0 +1,62 @@
import type { SchemaRegistry } from "@suaveplan/schema-registry";
import z from "zod";
import { compressedBitPackerOptionsSchema } from "../compressed-bit-packer/compressed-bit-packer.schema.js";
import { rleCompressionOptionsSchema } from "../rle/rle.schema.js";
import {
bitPackerCompressionOptionsSchema,
compressionAdapterSchema,
compressionConfigSchema,
} from "../types/types.schema.js";
export const BITPACKER_COMPRESSED_SUBJECT =
"bitpacker:@suaveplan/bitpacker-compressed" as const;
export const BITPACKER_COMPRESSED_SCHEMA_VERSION = 1;
interface Payload {
readonly bitPackerCompressionOptions: unknown;
readonly compressionAdapter: unknown;
readonly compressionConfig: unknown;
readonly compressedBitPackerOptions: unknown;
readonly rleCompressionOptions: unknown;
}
function buildPayload(): Payload {
const opts = { unrepresentable: "any" } as const;
return {
bitPackerCompressionOptions: z.toJSONSchema(
bitPackerCompressionOptionsSchema,
opts
),
compressionAdapter: z.toJSONSchema(compressionAdapterSchema, opts),
compressionConfig: z.toJSONSchema(compressionConfigSchema, opts),
compressedBitPackerOptions: z.toJSONSchema(
compressedBitPackerOptionsSchema,
opts
),
rleCompressionOptions: z.toJSONSchema(
rleCompressionOptionsSchema,
opts
),
};
}
/**
* Idempotently register `@suaveplan/bitpacker-compressed`'s public Zod
* schemas with the given `SchemaRegistry`.
*/
export function registerBitPackerCompressedSchemas(
registry: SchemaRegistry
): void {
if (registry.listSubjects().includes(BITPACKER_COMPRESSED_SUBJECT)) {
return;
}
registry.register(
BITPACKER_COMPRESSED_SUBJECT,
buildPayload(),
"json-schema",
{
package: "@suaveplan/bitpacker-compressed",
namespace: "bitpacker",
}
);
}

View File

@@ -0,0 +1,68 @@
# CompressionRegistry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`CompressionRegistry` is the pluggable map from algorithm name to `CompressionAdapter` that backs `@suaveplan/bitpacker-compressed`'s public API. It is the single source of truth that `compressData`, `decompressData`, and `CompressedBitPacker` consult to look up an adapter by string name. Two flavours are exposed: a process-wide singleton (`defaultCompressionRegistry()`) for production code, and a fresh-instance factory (`createCompressionRegistry()`) for tests that need isolation. This mirrors the registry conventions used by `@suaveplan/random` and `@suaveplan/bitpacker-crypto` so the codebase stays uniform.
## Features
- Singleton + fresh-instance factory pattern (no class-static singletons).
- Coded errors: unknown algorithm → `BitpackerError(UNKNOWN_COMPRESSION_ALGORITHM)`; soft-fail variant `tryResolve` returns `undefined`.
- Lazy default registration — `defaultCompressionRegistry()` registers `"none"`, `"gzip"`, `"deflate"` on first access (only registers gzip/deflate when `CompressionStream` is available).
- `withDefaults: false` option on `createCompressionRegistry` produces a completely empty registry for negative-path tests.
## Usage: Basic Example
```ts
import { defaultCompressionRegistry } from "@suaveplan/bitpacker-compressed";
const registry = defaultCompressionRegistry();
const adapter = registry.resolve("gzip");
const compressed = await adapter.compress(myBytes);
```
## Usage: Advanced Example
```ts
import {
createCompressionRegistry,
type CompressionAdapter,
} from "@suaveplan/bitpacker-compressed";
// Plug in a custom Brotli adapter (caller-owned WASM Brotli wrapper).
class BrotliAdapter implements CompressionAdapter {
readonly name = "brotli";
async compress(data: Uint8Array) { /* WASM call */ return new Uint8Array(); }
async decompress(data: Uint8Array) { /* WASM call */ return new Uint8Array(); }
}
const registry = createCompressionRegistry();
registry.register(new BrotliAdapter());
const adapter = registry.resolve("brotli");
```
## API Reference
### `defaultCompressionRegistry(): CompressionRegistry`
Returns the lazily-initialized process-wide singleton. Same reference every call.
### `createCompressionRegistry(options?: { withDefaults?: boolean }): CompressionRegistry`
Returns a fresh registry. `withDefaults` defaults to `true`; pass `false` to start empty.
### `CompressionRegistry`
- `register(adapter: CompressionAdapter): void` — Last-write-wins by `adapter.name`.
- `resolve(name: string): CompressionAdapter` — Throws on unknown.
- `tryResolve(name: string): CompressionAdapter | undefined` — Soft-fail variant.
- `list(): readonly string[]` — All registered adapter names.
## Implementation Notes
- The singleton is module-scope inside `registry.ts`; tests that need to mutate it create a fresh `createCompressionRegistry()` rather than poking the singleton.
- Default registration of gzip/deflate is gated on `typeof CompressionStream === "function"` so the registry stays usable in environments without WHATWG Streams (the gzip/deflate adapters themselves throw when called there, but lookup never fails).
- `register` mutates the registry; this is the documented behaviour for plug-in adapters. There is no immutable variant.

View File

@@ -0,0 +1,122 @@
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NoCompressionAdapter } from "../adapters/none/none.js";
import {
_resetDefaultRegistryForTests,
CompressionRegistry,
createCompressionRegistry,
defaultCompressionRegistry,
} from "./registry.js";
describe("CompressionRegistry", () => {
it("registers and resolves an adapter by name", { timeout: 5000 }, () => {
const reg = new CompressionRegistry();
const adapter = new NoCompressionAdapter();
reg.register(adapter);
expect(reg.resolve("none")).toBe(adapter);
expect(reg.has("none")).toBe(true);
});
it("resolve throws UNKNOWN_COMPRESSION_ALGORITHM for missing adapter", {
timeout: 5000,
}, () => {
const reg = new CompressionRegistry();
expect(() => reg.resolve("zstd")).toThrow(BitpackerError);
try {
reg.resolve("zstd");
} catch (e) {
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.UNKNOWN_COMPRESSION_ALGORITHM
);
expect((e as BitpackerError).context?.algorithm).toBe("zstd");
}
});
it("tryResolve returns undefined for missing adapter (soft-fail)", {
timeout: 5000,
}, () => {
const reg = new CompressionRegistry();
expect(reg.tryResolve("zstd")).toBeUndefined();
});
it("register replaces an existing adapter (last-write-wins)", {
timeout: 5000,
}, () => {
const reg = new CompressionRegistry();
const a = new NoCompressionAdapter();
const b = new NoCompressionAdapter();
reg.register(a);
reg.register(b);
expect(reg.resolve("none")).toBe(b);
});
it("list and getAll surface registered adapters", { timeout: 5000 }, () => {
const reg = new CompressionRegistry();
reg.register(new NoCompressionAdapter());
expect(reg.list()).toEqual(["none"]);
expect(reg.getAll()).toHaveLength(1);
});
it("createCompressionRegistry seeds defaults including 'none'", {
timeout: 5000,
}, () => {
const reg = createCompressionRegistry();
expect(reg.has("none")).toBe(true);
});
it("defaultCompressionRegistry returns a singleton", {
timeout: 5000,
}, () => {
const a = defaultCompressionRegistry();
const b = defaultCompressionRegistry();
expect(a).toBe(b);
});
it("createCompressionRegistry isolates from singleton", {
timeout: 5000,
}, () => {
const fresh = createCompressionRegistry();
const singleton = defaultCompressionRegistry();
// Mutate the fresh one — singleton must not see it.
class Marker extends NoCompressionAdapter {
public override readonly name = "test-marker-only";
}
fresh.register(new Marker());
expect(fresh.has("test-marker-only")).toBe(true);
expect(singleton.has("test-marker-only")).toBe(false);
});
it("_resetDefaultRegistryForTests rebuilds the singleton", {
timeout: 5000,
}, () => {
const before = defaultCompressionRegistry();
_resetDefaultRegistryForTests();
const after = defaultCompressionRegistry();
// Different instance after reset proves the cache was dropped.
expect(after).not.toBe(before);
});
it("defaultCompressionRegistry skips lazy gzip/deflate when CompressionStream is missing", {
timeout: 5000,
}, () => {
// Force the singleton to be rebuilt while CompressionStream is hidden,
// so we exercise the `typeof CompressionStream === "undefined"` arm of
// the lazy-registration block. The finally clauses keep this isolated.
const g = globalThis as unknown as {
CompressionStream?: typeof CompressionStream;
};
const saved = g.CompressionStream;
_resetDefaultRegistryForTests();
try {
g.CompressionStream = undefined;
const fresh = defaultCompressionRegistry();
// Only "none" is seeded synchronously; gzip/deflate are not added.
expect(fresh.has("none")).toBe(true);
expect(fresh.has("gzip")).toBe(false);
expect(fresh.has("deflate")).toBe(false);
} finally {
g.CompressionStream = saved;
_resetDefaultRegistryForTests();
}
});
});

View File

@@ -0,0 +1,118 @@
/**
* `CompressionRegistry` — pluggable map from algorithm name to
* `CompressionAdapter`. Genesis port of upstream
* `bitpacker/src/compressed/compression.ts::CompressionRegistry` with two
* shape changes:
*
* 1. **No singleton-via-private-static**. Genesis convention is the same as
* `@suaveplan/random` and `@suaveplan/bitpacker-crypto`'s registries:
* a process-wide singleton accessor (`defaultCompressionRegistry()`) plus
* a fresh-instance factory (`createCompressionRegistry()`).
* 2. **Coded errors**. `resolve` of an unknown algorithm throws
* `BitpackerError(UNKNOWN_COMPRESSION_ALGORITHM)` rather than returning
* `undefined` and forcing every caller to write the same `if (!adapter)`
* branch. `tryResolve` is provided for the rare case a caller wants the
* soft-fail path.
*/
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import { NoCompressionAdapter } from "../adapters/none/none.js";
import type { CompressionAdapter } from "../types/types.js";
export class CompressionRegistry {
private readonly adapters = new Map<string, CompressionAdapter>();
/**
* Register an adapter. If `adapter.name` is already registered, the new
* adapter replaces the old (last-write-wins). Used both for shipping
* defaults at construction time and for downstream consumers plugging in
* custom algorithms (e.g. `"brotli"`, `"zstd"`).
*/
public register(adapter: CompressionAdapter): void {
this.adapters.set(adapter.name, adapter);
}
/**
* Resolve an adapter by name. Throws `BitpackerError` with code
* `UNKNOWN_COMPRESSION_ALGORITHM` and `context.algorithm` when no
* adapter is registered for `name`. Use this on the production hot path.
*/
public resolve(name: string): CompressionAdapter {
const adapter = this.adapters.get(name);
if (adapter === undefined) {
throw new BitpackerError(
BITPACKER_CODES.UNKNOWN_COMPRESSION_ALGORITHM,
`Compression algorithm "${name}" is not registered`,
{ context: { algorithm: name } }
);
}
return adapter;
}
/**
* Soft-fail variant of `resolve`. Returns the adapter or `undefined`
* without throwing. Use this when a caller wants to feature-detect
* before deciding what to do.
*/
public tryResolve(name: string): CompressionAdapter | undefined {
return this.adapters.get(name);
}
public has(name: string): boolean {
return this.adapters.has(name);
}
public list(): readonly string[] {
return Array.from(this.adapters.keys());
}
public getAll(): readonly CompressionAdapter[] {
return Array.from(this.adapters.values());
}
}
let singleton: CompressionRegistry | undefined;
/**
* Build a fresh registry pre-seeded with the always-shipping defaults
* (`none`). Browser-only `gzip`/`deflate` adapters are registered lazily by
* `defaultCompressionRegistry()` based on environment detection — using
* `createCompressionRegistry()` directly skips that registration so tests
* can exercise the registry without `CompressionStream` polyfills.
*/
export function createCompressionRegistry(): CompressionRegistry {
const registry = new CompressionRegistry();
registry.register(new NoCompressionAdapter());
return registry;
}
/**
* Return the process-wide registry. Lazy-initialised; gzip/deflate adapters
* are registered if `CompressionStream` is available in the host
* environment. Re-importing the module does NOT rebuild the singleton.
*/
export function defaultCompressionRegistry(): CompressionRegistry {
if (singleton === undefined) {
singleton = createCompressionRegistry();
if (typeof CompressionStream !== "undefined") {
// Lazy-import the browser-stream adapters to avoid a hard
// dependency on `CompressionStream` at module load.
void import("../adapters/gzip/gzip.js").then((m) => {
singleton?.register(new m.GzipCompressionAdapter());
});
void import("../adapters/deflate/deflate.js").then((m) => {
singleton?.register(new m.DeflateCompressionAdapter());
});
}
}
return singleton;
}
/**
* Test-only escape hatch — drop the cached singleton so the next
* `defaultCompressionRegistry()` call rebuilds. Production code MUST NOT
* call this; tests use it to verify isolation.
*/
export function _resetDefaultRegistryForTests(): void {
singleton = undefined;
}

View File

@@ -0,0 +1,78 @@
# Run-length encoding (`compressRle` / `decompressRle`)
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - rle.schema — Zod schema for the per-call `Compressi…
- [rle.schema](./rle.schema.md) — Zod schema for the per-call `CompressionOptions` accepted by `compressRle` / `decompressRle`. The RLE codec is byte-onl…
- rle.schema — Zod schema for the per-call `Compressi…
- [rle.schema](./rle.schema.md) — Zod schema for the per-call `CompressionOptions` accepted by `compressRle` / `decompressRle`. The RLE codec is byte-onl…
- rle.schema — Zod schema for the per-call `Compressi…
- [rle.schema](./rle.schema.md) — Zod schema for the per-call `CompressionOptions` accepted by `compressRle` / `decompressRle`. The RLE codec is byte-onl…
- rle.schema — Zod schema for the per-call `Compressi…
- [rle.schema](./rle.schema.md) — Zod schema for the per-call `CompressionOptions` accepted by `compressRle` / `decompressRle`. The RLE codec is byte-onl…
- [rle.schema](./rle.schema.md) — Zod schema for the per-call `CompressionOptions` accepted by `compressRle` / `decompressRle`. The RLE codec is byte-onl…
## Purpose
`compressRle` / `decompressRle` are byte-level run-length encoding helpers shipped as a separate module within `@suaveplan/bitpacker-compressed` so embedded / no-streams environments can import them without pulling in `CompressionStream`-using gzip/deflate adapters or the `CompressedBitPacker` class. They are not part of the `CompressionRegistry` framing — bytes produced by `compressRle` are NOT the same shape as bytes produced by the registry's `"none"`/`"gzip"`/`"deflate"` adapters, which include the `CompressedBitPacker` header. If you need framed RLE, write a thin `RleCompressionAdapter` that internally calls these helpers.
## Features
- Bytes-only fast path; no streams required.
- Single-byte runs longer than 3 are emitted as `[byte][0][count]`; everything else passes through verbatim.
- Symmetric round-trip with `decompressRle`.
- Zero allocation overhead beyond the output `Uint8Array`.
## Usage: Basic Example
```ts
import { compressRle, decompressRle } from "@suaveplan/bitpacker-compressed";
const data = new Uint8Array([1, 1, 1, 1, 1, 2, 3]);
const compressed = compressRle(data); // [1, 0, 5, 2, 3]
const restored = decompressRle(compressed); // back to original
```
## Usage: Advanced Example
```ts
import {
compressRle,
decompressRle,
type CompressionAdapter,
} from "@suaveplan/bitpacker-compressed";
// Wrap RLE in a CompressionAdapter shape so it plugs into the registry.
class RleAdapter implements CompressionAdapter {
readonly name = "rle";
async compress(data: Uint8Array) { return compressRle(data); }
async decompress(data: Uint8Array) { return decompressRle(data); }
}
```
## API Reference
### `compressRle(data: Uint8Array): Uint8Array`
- Encodes runs of >3 identical bytes as `[byte][0][count]`.
- Returns a fresh `Uint8Array`; input is not mutated.
### `decompressRle(data: Uint8Array): Uint8Array`
- Reverses `compressRle`. Looks ahead for `[0][count]` markers after each byte.
- Returns a fresh `Uint8Array`.
### `CompressionOptions`
Type alias for `{ readonly level?: number }` — accepted but ignored by the bytes-only RLE path; included so adapter authors can re-export the canonical compression-options shape.
## Implementation Notes
- The marker byte is `0`, which would collide with a literal `0` in input data; the encoder handles this by always emitting the run-length form when `byte === 0` runs.
- This is not the most aggressive RLE possible — it does not pack runs of distinct bytes into literal blocks. The intent is "smaller than verbatim with a single linear pass", not "competitive with gzip on text".
- Use this when you have known byte-pattern data (e.g. graphics with large flat regions, sparse network frames). For general-purpose data, use the gzip / deflate adapters.

View File

@@ -0,0 +1,48 @@
# RLE Compression Options Schema
## Purpose
Zod schema for the per-call `CompressionOptions` accepted by `compressRle` / `decompressRle`. The RLE codec is byte-only and exposes a single tuning knob (`level`) — this schema validates that knob at the boundary so untrusted input can be normalized before reaching the codec.
## Features
- `rleCompressionOptionsSchema` — strict, single optional field
- `level` is a non-negative integer; the codec ignores values outside its supported range
- `as unknown as z.ZodType<T>` cast for the EOPT mismatch
## Usage: Basic Example
```ts
import { compressRle, rleCompressionOptionsSchema } from "@suaveplan/bitpacker-compressed";
const opts = rleCompressionOptionsSchema.parse({ level: 1 });
const compressed = compressRle(new Uint8Array([0, 0, 0, 0, 1, 2]), opts);
```
## Usage: Advanced Example
```ts
import {
compressRle,
decompressRle,
rleCompressionOptionsSchema,
} from "@suaveplan/bitpacker-compressed";
function safeRoundTrip(data: Uint8Array, rawOpts: unknown) {
const opts = rleCompressionOptionsSchema.parse(rawOpts);
const compressed = compressRle(data, opts);
const decompressed = decompressRle(compressed);
return decompressed;
}
```
## API Reference
### `rleCompressionOptionsSchema: z.ZodType<CompressionOptions>`
Strict schema. `level` is an optional non-negative integer. Throws `ZodError` on negative or non-integer values, or on unknown keys.
## Implementation Notes
- **Why a one-field schema.** Even a single-field bundle benefits from boundary validation: a `{ level: "9" }` (string instead of number) would otherwise reach the codec and produce undefined behaviour. Refusing it at parse time keeps the codec's contract simple.
- **Codec ignores out-of-range levels.** The schema enforces non-negative integer; the codec itself ignores values outside `[0, 9]`. The schema is the boundary; the codec is the implementation.

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { rleCompressionOptionsSchema } from "./rle.schema.js";
describe("rleCompressionOptionsSchema", () => {
it("accepts an empty bundle", () => {
expect(rleCompressionOptionsSchema.parse({})).toEqual({});
});
it("accepts a non-negative integer level", () => {
expect(rleCompressionOptionsSchema.parse({ level: 0 })).toEqual({
level: 0,
});
expect(rleCompressionOptionsSchema.parse({ level: 9 })).toEqual({
level: 9,
});
});
it("rejects a negative level", () => {
expect(() =>
rleCompressionOptionsSchema.parse({ level: -1 })
).toThrow();
});
it("rejects unknown keys", () => {
expect(() =>
rleCompressionOptionsSchema.parse({ level: 1, bogus: true })
).toThrow();
});
});

View File

@@ -0,0 +1,16 @@
import z from "zod";
import type { CompressionOptions } from "./rle.js";
/**
* Strict Zod schema for the RLE-only {@link CompressionOptions}.
*
* Validates the per-call options for `compressRle` / `decompressRle`. The
* RLE codec is byte-only and accepts a single `level` knob; this schema
* exists primarily so callers can normalize untrusted input before reaching
* the codec.
*/
export const rleCompressionOptionsSchema = z
.object({
level: z.number().int().nonnegative().optional(),
})
.strict() as unknown as z.ZodType<CompressionOptions>;

View File

@@ -0,0 +1,74 @@
/**
* Run-length encoding helpers — bytes-only fast path.
*
* Lives alongside the full compression pipeline as a separate module so
* consumers wanting RLE for embedded / no-streams environments can import
* it without paying the cost of pulling in `CompressionStream`-using gzip /
* deflate adapters or the `CompressedBitPacker` class.
*
* The format is intentionally simple — a single byte that repeats more
* than three times is emitted as `[byte][0][count]`; everything else
* passes through verbatim. Decompression reverses by looking ahead for the
* `0` marker. There is no header — RLE bytes are NOT compatible with the
* `CompressedBitPacker` framing (which would require an "rle" header).
* If you need the framing, register an `RleCompressionAdapter` with the
* registry that internally calls these helpers.
*/
export interface CompressionOptions {
readonly level?: number;
}
/**
* Compress a byte array using run-length encoding. Runs of more than three
* identical bytes collapse to `[byte][0][count]`; shorter runs pass through.
* The output is at worst the same length as the input plus 2× the number
* of zero bytes (which would otherwise be ambiguous with the marker).
*/
export function compressRle(data: Uint8Array): Uint8Array {
const result: number[] = [];
let i = 0;
while (i < data.length) {
const byte = data[i] as number;
let count = 1;
while (
i + count < data.length &&
data[i + count] === byte &&
count < 255
) {
count++;
}
if (count > 3) {
result.push(byte, 0, count);
i += count;
} else {
result.push(byte);
i++;
}
}
return new Uint8Array(result);
}
/**
* Decompress a byte array previously produced by `compressRle`. The reverse
* of the encoding rule: a `[byte][0][count]` triple expands to `count`
* copies of `byte`; every other byte passes through verbatim.
*/
export function decompressRle(data: Uint8Array): Uint8Array {
const result: number[] = [];
let i = 0;
while (i < data.length) {
const byte = data[i] as number;
if (i + 2 < data.length && data[i + 1] === 0) {
const count = data[i + 2] as number;
for (let j = 0; j < count; j++) {
result.push(byte);
}
i += 3;
} else {
result.push(byte);
i++;
}
}
return new Uint8Array(result);
}

View File

@@ -0,0 +1,92 @@
# Compression types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - types.schema — Zod schemas for the public type comp…
- [types.schema](./types.schema.md) — Zod schemas for the public type companions of `@suaveplan/bitpacker-compressed`'s compression pipeline: the open-record…
- types.schema — Zod schemas for the public type comp…
- [types.schema](./types.schema.md) — Zod schemas for the public type companions of `@suaveplan/bitpacker-compressed`'s compression pipeline: the open-record…
- types.schema — Zod schemas for the public type comp…
- [types.schema](./types.schema.md) — Zod schemas for the public type companions of `@suaveplan/bitpacker-compressed`'s compression pipeline: the open-record…
- types.schema — Zod schemas for the public type comp…
- [types.schema](./types.schema.md) — Zod schemas for the public type companions of `@suaveplan/bitpacker-compressed`'s compression pipeline: the open-record…
- [types.schema](./types.schema.md) — Zod schemas for the public type companions of `@suaveplan/bitpacker-compressed`'s compression pipeline: the open-record…
## Purpose
This module collects the public TypeScript type companions used across `@suaveplan/bitpacker-compressed`: the algorithm-name string brand, the per-call options shape, and the `CompressionAdapter` interface that any plug-in codec must implement. Keeping them in one file means downstream consumers only need a single import path to write a custom adapter or to type a config object that flows through the pipeline. The shapes mirror the upstream `bitpacker/src/types.ts` compression types verbatim so adapters and consumers see the same contracts whether they imported from upstream or the port.
## Features
- `BitPackerCompressionAlgorithm` — string-typed registry key (e.g. `"none"`, `"gzip"`, `"deflate"`, `"brotli"`).
- `BitPackerCompressionOptions` — caller-supplied options forwarded to the adapter (currently `level?: number`).
- `CompressionAdapter` — the contract every codec implements: `name`, `compress`, `decompress`.
- All async to match the WHATWG Streams reality; sync codecs simply resolve immediately.
## Usage: Basic Example
```ts
import type {
BitPackerCompressionAlgorithm,
BitPackerCompressionOptions,
CompressionAdapter,
} from "@suaveplan/bitpacker-compressed";
const algorithm: BitPackerCompressionAlgorithm = "gzip";
const options: BitPackerCompressionOptions = { level: 9 };
```
## Usage: Advanced Example
```ts
import type { CompressionAdapter } from "@suaveplan/bitpacker-compressed";
// Custom adapter for a hypothetical FastLZ codec.
class FastLzAdapter implements CompressionAdapter {
readonly name = "fastlz";
async compress(data: Uint8Array, options?: { level?: number }) {
// Call into FastLZ-WASM module here.
return data; // placeholder
}
async decompress(data: Uint8Array, _options?: { level?: number }) {
return data; // placeholder
}
}
```
## API Reference
### `BitPackerCompressionAlgorithm`
Type alias for `string`. Used as the registry lookup key. Default registered names: `"none"`, `"gzip"`, `"deflate"`. Consumers MAY register additional algorithms.
### `BitPackerCompressionOptions`
```ts
interface BitPackerCompressionOptions {
readonly level?: number;
}
```
Forwarded to the adapter. Adapters that don't support `level` (e.g. `none`) ignore it.
### `CompressionAdapter`
```ts
interface CompressionAdapter {
readonly name: BitPackerCompressionAlgorithm;
compress(data: Uint8Array, options?: BitPackerCompressionOptions): Promise<Uint8Array>;
decompress(data: Uint8Array, options?: BitPackerCompressionOptions): Promise<Uint8Array>;
}
```
## Implementation Notes
- `BitPackerCompressionAlgorithm` is intentionally `string`, not a closed union — module augmentation is overkill for plug-in codecs and the registry already validates names at lookup.
- All methods are async because the standard browser/Node codecs (`CompressionStream`) are async. The pass-through `NoCompressionAdapter` resolves immediately so async overhead is negligible.
- Adapters MUST be deterministic: same input + same options ⇒ same output bytes (modulo internal nondeterminism in the underlying codec, which is allowed but unusual).

View File

@@ -0,0 +1,77 @@
# Compression Type Schemas
## Purpose
Zod schemas for the public type companions of `@suaveplan/bitpacker-compressed`'s compression pipeline: the open-record `BitPackerCompressionOptions`, the `CompressionAdapter` interface (the contract every adapter implements), and the `CompressionConfig` configuration shape consumed by `CompressedBitPacker`. Each schema is the runtime gatekeeper at the corresponding boundary so an adapter loaded from a plugin manifest, or a config bundle parsed from environment variables, can be validated before reaching the compression hot path.
The schemas use `z.custom<T>((v): v is T => …)` for the adapter validation so adapter prototype chains are preserved (registries depend on prototype-aware `instanceof` checks), and `z.record(z.string(), z.unknown())` for the open-record `BitPackerCompressionOptions` because each adapter accepts its own option keys — the schema validates "is this an object?" and lets the adapter validate its own option semantics.
## Features
- `bitPackerCompressionOptionsSchema` — open record (any string-keyed object)
- `compressionAdapterSchema` — structural contract: `name: string`, `compress: function`, `decompress: function`
- `compressionConfigSchema` — strict, three optional fields (`algorithm`, `options`, `enabled`)
- Identity-preserving `z.custom` for adapter validation
- `as unknown as z.ZodType<T>` casts where Zod's `T | undefined` collides with `exactOptionalPropertyTypes`
## Usage: Basic Example
```ts
import {
CompressedBitPacker,
compressionAdapterSchema,
compressionConfigSchema,
NoCompressionAdapter,
} from "@suaveplan/bitpacker-compressed";
const adapter = compressionAdapterSchema.parse(new NoCompressionAdapter());
const cfg = compressionConfigSchema.parse({ algorithm: "none", enabled: true });
```
## Usage: Advanced Example
```ts
import {
CompressedBitPacker,
bitPackerCompressionOptionsSchema,
compressionAdapterSchema,
compressionConfigSchema,
} from "@suaveplan/bitpacker-compressed";
async function loadVendorAdapter(url: URL) {
const mod = await import(url.href);
const adapter = compressionAdapterSchema.parse(mod.default);
return adapter;
}
const opts = bitPackerCompressionOptionsSchema.parse({
level: 9,
windowBits: 15,
});
const cfg = compressionConfigSchema.parse({
algorithm: "deflate",
options: opts,
enabled: true,
});
```
## API Reference
### `bitPackerCompressionOptionsSchema: z.ZodType<BitPackerCompressionOptions>`
Open-record schema. Accepts any object with string keys. Adapter-specific validation is the adapter's responsibility.
### `compressionAdapterSchema: z.ZodType<CompressionAdapter>`
Structural contract validator. Asserts `name: string`, `compress: function`, `decompress: function`. Identity is preserved.
### `compressionConfigSchema: z.ZodType<CompressionConfig>`
Strict schema. Three optional fields: `algorithm: string`, `options: BitPackerCompressionOptions`, `enabled: boolean`.
## Implementation Notes
- **Open-record options.** Adapters accept arbitrary keys (`level`, `windowBits`, `dictionary`, …). Validating them at this layer would force this package to know every adapter's surface, which it can't. The contract is "is this an object?"; semantic validation belongs to the adapter.
- **Structural adapter check.** A duck-typed object that exposes `name/compress/decompress` works at runtime. Refusing duck-types would break the plugin model where adapters are loaded from external modules without inheriting from a base class.
- **Identity preservation.** `z.custom` for the adapter; the registry's `register(adapter)` method uses `===` and `instanceof` checks downstream.

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NoCompressionAdapter } from "../adapters/none/none.js";
import {
bitPackerCompressionOptionsSchema,
compressionAdapterSchema,
compressionConfigSchema,
} from "./types.schema.js";
describe("bitPackerCompressionOptionsSchema", () => {
it("accepts an empty record", () => {
expect(bitPackerCompressionOptionsSchema.parse({})).toEqual({});
});
it("accepts adapter-specific keys", () => {
const opts = { level: 6, dictionary: new Uint8Array() };
expect(bitPackerCompressionOptionsSchema.parse(opts)).toEqual(opts);
});
it("rejects non-objects", () => {
expect(() =>
bitPackerCompressionOptionsSchema.parse("level=6")
).toThrow();
});
});
describe("compressionAdapterSchema", () => {
it("accepts NoCompressionAdapter", () => {
const adapter = new NoCompressionAdapter();
expect(compressionAdapterSchema.parse(adapter)).toBe(adapter);
});
it("rejects an object missing compress", () => {
expect(() =>
compressionAdapterSchema.parse({
name: "x",
decompress: async () => new Uint8Array(),
})
).toThrow();
});
it("rejects an object whose name is not a string", () => {
expect(() =>
compressionAdapterSchema.parse({
name: 1,
compress: async () => new Uint8Array(),
decompress: async () => new Uint8Array(),
})
).toThrow();
});
});
describe("compressionConfigSchema", () => {
it("accepts an empty config", () => {
expect(compressionConfigSchema.parse({})).toEqual({});
});
it("accepts a populated config", () => {
const cfg = {
algorithm: "gzip",
options: { level: 6 },
enabled: true,
};
expect(compressionConfigSchema.parse(cfg)).toEqual(cfg);
});
it("rejects unknown keys", () => {
expect(() =>
compressionConfigSchema.parse({ algorithm: "gzip", bogus: 1 })
).toThrow();
});
});

View File

@@ -0,0 +1,55 @@
import z from "zod";
import type {
BitPackerCompressionOptions,
CompressionAdapter,
CompressionConfig,
} from "./types.js";
/**
* Strict Zod schema for {@link BitPackerCompressionOptions}.
*
* The shape is an open record (`Record<string, unknown>`) because each
* adapter accepts its own option keys. Validation is therefore a structural
* "is this an object?" check — adapters validate their own options.
*/
export const bitPackerCompressionOptionsSchema = z.record(
z.string(),
z.unknown()
) as unknown as z.ZodType<BitPackerCompressionOptions>;
/**
* Strict Zod schema for {@link CompressionAdapter}.
*
* Validates that an external adapter exposes a string `name` and the two
* required async methods (`compress`, `decompress`). Identity is preserved
* via `z.custom` so the registered adapter keeps its prototype chain.
*/
export const compressionAdapterSchema = z.custom<CompressionAdapter>(
(v): v is CompressionAdapter => {
if (v === null || typeof v !== "object") return false;
const obj = v as Record<string, unknown>;
return (
typeof obj["name"] === "string" &&
typeof obj["compress"] === "function" &&
typeof obj["decompress"] === "function"
);
},
{
message:
"CompressionAdapter must expose name (string) and compress/decompress (functions)",
}
);
/**
* Strict Zod schema for {@link CompressionConfig}.
*
* Validates the `compression` field of `CompressedBitPackerOptions`. Every
* field is optional with documented defaults.
*/
export const compressionConfigSchema = z
.object({
algorithm: z.string().optional(),
options: bitPackerCompressionOptionsSchema.optional(),
enabled: z.boolean().optional(),
})
.strict() as unknown as z.ZodType<CompressionConfig>;

View File

@@ -0,0 +1,65 @@
/**
* Public type companions for the compression pipeline.
*
* These mirror the upstream `bitpacker/src/types.ts` compression types
* (`BitPackerCompressionAlgorithm`, `BitPackerCompressionOptions`,
* `CompressionAdapter`) so adapters and downstream consumers see the same
* shapes whether they imported from upstream or the genesis port.
*/
/**
* Algorithm name used to look up an adapter in the `CompressionRegistry`.
*
* Default registered algorithms are `"none"`, `"gzip"`, `"deflate"`.
* Consumers MAY register additional algorithms (e.g. `"brotli"`, `"zstd"`)
* via `CompressionRegistry.register(adapter)`.
*/
export type BitPackerCompressionAlgorithm = string;
/**
* Caller-supplied options forwarded to the compression / decompression call.
* Adapter-specific (e.g. gzip level, deflate window bits) — open record so
* future adapters can read whatever options they accept.
*/
export type BitPackerCompressionOptions = Record<string, unknown>;
/**
* The contract every compression adapter implements. Both methods are async
* because the canonical browser path (`CompressionStream` / `Decompression
* Stream`) is async; pure-byte adapters (e.g. `NoCompressionAdapter`) wrap
* their synchronous result in `Promise.resolve` to match the interface.
*/
export interface CompressionAdapter {
/** Algorithm name. Used as the registry key. */
readonly name: BitPackerCompressionAlgorithm;
/**
* Compress `data` with the supplied `options`. Adapters MAY ignore options
* they don't understand. Throws on codec failure (e.g. truncated input,
* unsupported environment); the caller wraps as `BitpackerError(COMPRESSION_FAILED)`.
*/
compress(
data: Uint8Array,
options?: BitPackerCompressionOptions
): Promise<Uint8Array>;
/**
* Decompress `data` with the supplied `options`. Throws on codec failure
* (truncated stream, bad header). Caller wraps as
* `BitpackerError(DECOMPRESSION_FAILED)` with the underlying error chained.
*/
decompress(
data: Uint8Array,
options?: BitPackerCompressionOptions
): Promise<Uint8Array>;
}
/**
* Compression configuration accepted by `CompressedBitPacker`. Mirrors the
* upstream `BitPackerConfig.compression` shape so any caller carrying that
* shape works without re-mapping.
*/
export interface CompressionConfig {
readonly algorithm?: BitPackerCompressionAlgorithm;
readonly options?: BitPackerCompressionOptions;
/** Default `true`. When `false`, packing skips the compression step entirely. */
readonly enabled?: boolean;
}