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