Skip to content

Commit 899546d

Browse files
committed
Add a general purpose decode-as-a-stream method
1 parent 4196cd7 commit 899546d

3 files changed

Lines changed: 268 additions & 1 deletion

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,20 @@ Each method accepts a buffer and returns a promise for a buffer.
6363

6464
This library also supports streaming encoding and decoding, returning web-standard `TransformStream` instances. This uses native `CompressionStream`/`DecompressionStream` where available (all modern browsers and Node 18+).
6565

66+
### `createDecodeStream(encoding)`
67+
68+
Takes an encoding (in the format of a standard HTTP [content-encoding header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding)) and returns a `TransformStream` that decodes data with the specified encoding(s), or `null` if no transformation is needed (identity encoding or undefined).
69+
70+
The encoding can be a string (e.g. `'gzip'` or `'gzip, base64'`), an array of strings, or undefined.
71+
72+
### `createEncodeStream(encoding)`
73+
74+
Takes an encoding (a valid HTTP [content-encoding](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding) name) and returns a `TransformStream` that encodes data with the specified encoding(s), or `null` if no transformation is needed (identity encoding or undefined).
75+
76+
The encoding can be a string (e.g. `'gzip'` or `'gzip, base64'`), an array of strings, or undefined.
77+
78+
### Per-codec streaming methods
79+
6680
* `createGzipStream`
6781
* `createGunzipStream`
6882
* `createDeflateStream`
@@ -73,6 +87,8 @@ This library also supports streaming encoding and decoding, returning web-standa
7387
* `createBrotliDecompressStream`
7488
* `createZstdCompressStream`
7589
* `createZstdDecompressStream`
90+
* `createBase64EncodeStream`
91+
* `createBase64DecodeStream`
7692

7793
## Browser usage
7894

src/index.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,127 @@ export function createBase64DecodeStream(): TransformStream<BufferSource, Uint8A
730730
});
731731
}
732732

733+
// --- Generic Streaming API ---
734+
735+
// Chain multiple TransformStreams into one composite stream
736+
function chainStreams(streams: TransformStream<BufferSource, Uint8Array>[]): TransformStream<BufferSource, Uint8Array> {
737+
if (streams.length === 1) {
738+
return streams[0];
739+
}
740+
741+
// Connect streams: readable of each flows into writable of next
742+
const first = streams[0];
743+
const last = streams[streams.length - 1];
744+
745+
for (let i = 0; i < streams.length - 1; i++) {
746+
streams[i].readable.pipeTo(streams[i + 1].writable);
747+
}
748+
749+
return { writable: first.writable, readable: last.readable } as TransformStream<BufferSource, Uint8Array>;
750+
}
751+
752+
// Get decoder stream for a single encoding (identity already filtered out)
753+
function getDecoderStream(encoding: string): TransformStream<BufferSource, Uint8Array> {
754+
switch (encoding.toLowerCase()) {
755+
case 'gzip':
756+
case 'x-gzip':
757+
return createGunzipStream();
758+
case 'deflate':
759+
case 'x-deflate':
760+
return createInflateStream();
761+
case 'br':
762+
return createBrotliDecompressStream();
763+
case 'zstd':
764+
return createZstdDecompressStream();
765+
case 'base64':
766+
return createBase64DecodeStream();
767+
default:
768+
throw new Error(`Unsupported encoding: ${encoding}`);
769+
}
770+
}
771+
772+
// Get encoder stream for a single encoding (identity already filtered out)
773+
function getEncoderStream(encoding: string): TransformStream<BufferSource, Uint8Array> {
774+
switch (encoding.toLowerCase()) {
775+
case 'gzip':
776+
case 'x-gzip':
777+
return createGzipStream();
778+
case 'deflate':
779+
case 'x-deflate':
780+
return createDeflateStream();
781+
case 'br':
782+
return createBrotliCompressStream();
783+
case 'zstd':
784+
return createZstdCompressStream();
785+
case 'base64':
786+
return createBase64EncodeStream();
787+
default:
788+
throw new Error(`Unsupported encoding: ${encoding}`);
789+
}
790+
}
791+
792+
// Parse encoding header into array of non-identity encodings
793+
function parseEncodings(encoding: string | string[] | undefined): string[] {
794+
if (!encoding) return [];
795+
796+
const encodings = Array.isArray(encoding)
797+
? encoding
798+
: encoding.includes(', ')
799+
? encoding.split(', ')
800+
: [encoding];
801+
802+
return encodings.filter(e => !IDENTITY_ENCODINGS.includes(e.toLowerCase()));
803+
}
804+
805+
/**
806+
* Creates a decode stream for the given content-encoding header value.
807+
* Supports multiple encodings (comma-separated or array), applied in reverse order.
808+
* Returns null if no decoding is needed (identity or no encoding).
809+
*
810+
* @example
811+
* const decoder = createDecodeStream('gzip');
812+
* const output = decoder ? stream.pipeThrough(decoder) : stream;
813+
*
814+
* @example
815+
* // Multiple encodings (decodes in reverse order)
816+
* const decoder = createDecodeStream('gzip, br');
817+
*/
818+
export function createDecodeStream(encoding: string | string[] | undefined): TransformStream<BufferSource, Uint8Array> | null {
819+
const encodings = parseEncodings(encoding);
820+
if (encodings.length === 0) return null;
821+
822+
// Reverse order for decoding (last applied encoding = first to decode)
823+
encodings.reverse();
824+
825+
if (encodings.length === 1) {
826+
return getDecoderStream(encodings[0]);
827+
}
828+
return chainStreams(encodings.map(e => getDecoderStream(e)));
829+
}
830+
831+
/**
832+
* Creates an encode stream for the given content-encoding header value.
833+
* Supports multiple encodings (comma-separated or array), applied in order.
834+
* Returns null if no encoding is needed (identity or no encoding).
835+
*
836+
* @example
837+
* const encoder = createEncodeStream('gzip');
838+
* const output = encoder ? stream.pipeThrough(encoder) : stream;
839+
*
840+
* @example
841+
* // Multiple encodings (applies gzip first, then base64)
842+
* const encoder = createEncodeStream('gzip, base64');
843+
*/
844+
export function createEncodeStream(encoding: string | string[] | undefined): TransformStream<BufferSource, Uint8Array> | null {
845+
const encodings = parseEncodings(encoding);
846+
if (encodings.length === 0) return null;
847+
848+
if (encodings.length === 1) {
849+
return getEncoderStream(encodings[0]);
850+
}
851+
return chainStreams(encodings.map(e => getEncoderStream(e)));
852+
}
853+
733854
// --- Buffer helpers ---
734855

735856
const asBuffer = (input: Buffer | Uint8Array | ArrayBuffer): Buffer => {

test/stream.spec.ts

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ import {
2121
createBase64EncodeStream,
2222
createBase64DecodeStream,
2323
encodeBase64,
24-
decodeBase64
24+
decodeBase64,
25+
createDecodeStream,
26+
createEncodeStream
2527
} from '../src/index';
2628

2729
describe("Streaming", () => {
@@ -677,6 +679,134 @@ describe("Streaming", () => {
677679
}
678680
});
679681
});
682+
683+
describe("Generic Streaming API", () => {
684+
it('should decode with createDecodeStream for single encoding', async () => {
685+
const original = 'Hello generic decode stream!';
686+
const compressed = zlib.gzipSync(original);
687+
const inputStream = createReadableStream(compressed);
688+
689+
const decodedStream = inputStream.pipeThrough(createDecodeStream('gzip')!);
690+
const decoded = await collectStream(decodedStream);
691+
692+
expect(Buffer.from(decoded).toString()).to.equal(original);
693+
});
694+
695+
it('should encode with createEncodeStream for single encoding', async () => {
696+
const original = Buffer.from('Hello generic encode stream!');
697+
const inputStream = createReadableStream(original);
698+
699+
const encodedStream = inputStream.pipeThrough(createEncodeStream('gzip')!);
700+
const encoded = await collectStream(encodedStream);
701+
702+
const decoded = zlib.gunzipSync(Buffer.from(encoded));
703+
expect(decoded.toString()).to.equal(original.toString());
704+
});
705+
706+
it('should handle multiple encodings in decode (comma-separated)', async () => {
707+
const original = 'Multiple encodings test!';
708+
// Encode: first gzip, then base64
709+
const gzipped = zlib.gzipSync(original);
710+
const encoded = await encodeBase64(gzipped);
711+
712+
const inputStream = createReadableStream(encoded);
713+
// Decode: "gzip, base64" means gzip was applied first, base64 second
714+
// So we decode base64 first, then gzip
715+
const decodedStream = inputStream.pipeThrough(createDecodeStream('gzip, base64')!);
716+
const decoded = await collectStream(decodedStream);
717+
718+
expect(Buffer.from(decoded).toString()).to.equal(original);
719+
});
720+
721+
it('should handle multiple encodings in decode (array)', async () => {
722+
const original = 'Array encodings test!';
723+
const gzipped = zlib.gzipSync(original);
724+
const encoded = await encodeBase64(gzipped);
725+
726+
const inputStream = createReadableStream(encoded);
727+
const decodedStream = inputStream.pipeThrough(createDecodeStream(['gzip', 'base64'])!);
728+
const decoded = await collectStream(decodedStream);
729+
730+
expect(Buffer.from(decoded).toString()).to.equal(original);
731+
});
732+
733+
it('should handle multiple encodings in encode', async () => {
734+
const original = Buffer.from('Encode multiple test!');
735+
const inputStream = createReadableStream(original);
736+
737+
// Encode with gzip then base64
738+
const encodedStream = inputStream.pipeThrough(createEncodeStream('gzip, base64')!);
739+
const encoded = await collectStream(encodedStream);
740+
741+
// Decode manually in reverse order
742+
const decoded64 = await decodeBase64(encoded);
743+
const decoded = zlib.gunzipSync(Buffer.from(decoded64));
744+
expect(decoded.toString()).to.equal(original.toString());
745+
});
746+
747+
it('should round-trip with encode and decode streams', async () => {
748+
const original = Buffer.from('Round trip with generic streams!');
749+
const encoding = 'gzip, base64';
750+
751+
const inputStream = createReadableStream(original);
752+
const encodedStream = inputStream.pipeThrough(createEncodeStream(encoding)!);
753+
const decodedStream = encodedStream.pipeThrough(createDecodeStream(encoding)!);
754+
const result = await collectStream(decodedStream);
755+
756+
expect(Buffer.from(result).toString()).to.equal(original.toString());
757+
});
758+
759+
it('should return null for identity/undefined encoding', async () => {
760+
// Test undefined
761+
expect(createDecodeStream(undefined)).to.equal(null);
762+
expect(createEncodeStream(undefined)).to.equal(null);
763+
764+
// Test 'identity'
765+
expect(createDecodeStream('identity')).to.equal(null);
766+
expect(createEncodeStream('identity')).to.equal(null);
767+
768+
// Test empty string equivalent
769+
expect(createDecodeStream('')).to.equal(null);
770+
});
771+
772+
it('should throw for unsupported encoding', () => {
773+
expect(() => createDecodeStream('unsupported-encoding')).to.throw('Unsupported encoding');
774+
expect(() => createEncodeStream('unsupported-encoding')).to.throw('Unsupported encoding');
775+
});
776+
777+
it('should handle case-insensitive encodings', async () => {
778+
const original = 'Case insensitive test!';
779+
const compressed = zlib.gzipSync(original);
780+
const inputStream = createReadableStream(compressed);
781+
782+
const decodedStream = inputStream.pipeThrough(createDecodeStream('GZIP')!);
783+
const decoded = await collectStream(decodedStream);
784+
785+
expect(Buffer.from(decoded).toString()).to.equal(original);
786+
});
787+
788+
it('should handle brotli encoding', async () => {
789+
const original = Buffer.from('Brotli generic test!');
790+
const inputStream = createReadableStream(original);
791+
792+
const encodedStream = inputStream.pipeThrough(createEncodeStream('br')!);
793+
const decodedStream = encodedStream.pipeThrough(createDecodeStream('br')!);
794+
const result = await collectStream(decodedStream);
795+
796+
expect(Buffer.from(result).toString()).to.equal(original.toString());
797+
});
798+
799+
it('should handle zstd encoding', async () => {
800+
const original = Buffer.from('Zstd generic test!');
801+
const inputStream = createReadableStream(original);
802+
803+
const encodedStream = inputStream.pipeThrough(createEncodeStream('zstd')!);
804+
const decodedStream = encodedStream.pipeThrough(createDecodeStream('zstd')!);
805+
const result = await collectStream(decodedStream);
806+
807+
expect(Buffer.from(result).toString()).to.equal(original.toString());
808+
});
809+
});
680810
});
681811

682812
// Helper to collect all chunks from a ReadableStream into a single Uint8Array

0 commit comments

Comments
 (0)