logo hsb.horse
← Back to blog index

Blog

I Got Tired of Rewriting the Same Web Streams Boilerplate

Why I packaged my recurring ReadableStream<Uint8Array> utilities into @hsblabs/web-stream-extras — covering the three patterns I kept rewriting, the ByteTransformStream base, and stream encryption via Web Crypto.

Published:

Translations

You need a ReadableStream<Uint8Array>. You open a blank file and write the same controller skeleton you’ve written a dozen times. Then you need to collect the bytes at the end — another utility. Then you pass the result somewhere that wants an ArrayBuffer, not a Uint8Array. Another conversion.

None of it is hard. All of it is noise.

I packaged those pieces into @hsblabs/web-stream-extras.


Three patterns I kept rewriting

Creating a readable from an array of chunks

// before
const stream = new ReadableStream({
start(controller) {
for (const chunk of chunks) controller.enqueue(chunk);
controller.close();
},
});

Collecting a stream into a single Uint8Array

// before
const chunks: Uint8Array[] = [];
const reader = stream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
const result = concatU8Arrays(chunks); // also had to write this

Converting between Uint8Array, ArrayBuffer, and strings

Small mismatches that send you hunting for the right view or copy pattern.


After

import {
readableFromChunks,
readAllBytes,
stringToBinary,
binaryToString,
} from "@hsblabs/web-stream-extras";
const stream = readableFromChunks([
stringToBinary("hello"),
stringToBinary(" world"),
]);
const result = await readAllBytes(stream);
console.log(binaryToString(result)); // "hello world"

The install:

Terminal window
npm install @hsblabs/web-stream-extras

Node.js ≥22 and modern browsers. No runtime dependencies.


Building binary transform pipelines

For custom transform stages, ByteTransformStream gives you a typed base to extend without reimplementing the TransformStream scaffolding:

import { ByteTransformStream } from "@hsblabs/web-stream-extras";
class UpperCaseStream extends ByteTransformStream {
transform(chunk: Uint8Array) {
// process bytes, push output via this.push()
}
}

ByteQueue handles internal byte buffering when you need to track partial reads across chunk boundaries. encodeBase64Url and decodeBase64Url are there for the edges where byte data meets text.


Stream encryption

Encryption is a separate subpath. If you don’t need it, it doesn’t ship.

import { encryptStream, decryptStream } from "@hsblabs/web-stream-extras/encryption";
const key = crypto.getRandomValues(new Uint8Array(32));
const plaintext = readableFromChunks([stringToBinary("secret payload")]);
const encrypted = encryptStream(key, plaintext);
const decrypted = decryptStream(key, encrypted);
console.log(binaryToString(await readAllBytes(decrypted))); // "secret payload"

Both functions accept and return ReadableStream<Uint8Array>, so they compose naturally with the rest of your pipeline. The underlying EncryptionStream and DecryptionStream are exposed as TransformStream wrappers if you need them directly.

The format uses AES-GCM with HKDF-derived per-record keys and nonces. Stream-oriented, not a general-purpose file encryption format.

Master key workflows

If your application manages a separate AES-GCM master key and stores per-stream keys alongside encrypted metadata, webCryptoStream wraps that pattern:

import { webCryptoStream } from "@hsblabs/web-stream-extras/encryption";
const masterKey = await crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
const crypto$ = webCryptoStream(masterKey);
const streamKey = await crypto$.createStreamKey();
const encrypted = await crypto$.encrypt(streamKey, plaintext);
const decrypted = await crypto$.decrypt(streamKey, encrypted);

If you already have a raw 32-byte key, encryptStream / decryptStream are all you need.


What it does not do

Worth being explicit:

  • No authentication — confidentiality only, not identity
  • No key storage — where you persist stream keys is your problem
  • No password-based key derivation — bring your own PBKDF2 / Argon2
  • No user management — low-level stream utility, not an auth framework

npm · GitHub

If you run into a gap, opening an issue is the fastest way to shape what comes next.