logo hsb.horse
← ブログ一覧に戻る

ブログ

同じ Web Streams のボイラープレートを書き続けるのに飽きた

何度も書き直していた ReadableStream<Uint8Array> のユーティリティを @hsblabs/web-stream-extras にまとめた理由。繰り返し書いていた3つのパターン、ByteTransformStream 基底クラス、Web Crypto を使ったストリーム暗号化まで。

公開日:

翻訳

ReadableStream<Uint8Array> が必要になる。空のファイルを開いて、何度も書いてきた同じコントローラーの骨格を書き始める。次に末尾でバイトを収集する必要が出てくる——またユーティリティだ。その結果を Uint8Array ではなく ArrayBuffer を期待するどこかに渡す。また変換だ。

どれも難しくない。全部ノイズだ。

そのパーツを @hsblabs/web-stream-extras にまとめた。


繰り返し書き続けた3つのパターン

チャンク配列から ReadableStream を作る

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

ストリームを単一の 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); // これも自分で書く必要があった

Uint8ArrayArrayBuffer、文字列の間で変換する

正しいビューやコピーパターンを探し回ることになる細かい不一致。


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"

インストール:

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

Node.js ≥22 とモダンブラウザで動作する。ランタイム依存なし。


バイナリ変換パイプラインを組む

カスタムのトランスフォームステージが必要なときは、ByteTransformStreamTransformStream の足回りを再実装せずに拡張できる型付き基底クラスを提供する:

import { ByteTransformStream } from "@hsblabs/web-stream-extras";
class UpperCaseStream extends ByteTransformStream {
transform(chunk: Uint8Array) {
// バイトを処理して this.push() で出力を送る
}
}

ByteQueue はチャンク境界をまたいだ部分的なバイト読み取りが必要なときの内部バッファリングを担う。encodeBase64UrldecodeBase64Url はバイトデータとテキストが接する端で使う。


ストリーム暗号化

暗号化は別のサブパスになっている。必要なければ配信されない。

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"

両関数とも ReadableStream<Uint8Array> を受け取り返すので、パイプラインの他の部分と自然につながる。内部の EncryptionStreamDecryptionStreamTransformStream ラッパーとして公開されているので、直接必要な場合はそちらを使える。

フォーマットは HKDF で導出したレコードごとの鍵とノンスを使った AES-GCM だ。ストリーム向けの設計で、汎用のファイル暗号化フォーマットではない。

マスター鍵を使うパターン

アプリケーションが別の AES-GCM マスター鍵を管理していて、暗号化されたメタデータと一緒にストリームごとの鍵を保存したい場合、webCryptoStream がそのパターンをラップする:

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

生の 32 バイト鍵がすでにあるなら、encryptStream / decryptStream だけで十分だ。


やらないこと

明示しておく価値がある:

  • 認証なし — 秘匿性のみ、身元確認ではない
  • 鍵の保存なし — ストリーム鍵をどこに保持するかはアプリケーション側の責任
  • パスワードベースの鍵導出なし — PBKDF2 / Argon2 は自分で持ち込む
  • ユーザー管理なし — 低レベルのストリームユーティリティであり、auth フレームワークではない

npm · GitHub

不足を見つけたら issue を開くのが一番早い。