مدیریت داده‌ها در streams یکی از اصولی‌ترین بخش‌ها در ساخت برنامه‌هاست و به همین دلیل استاندارد WHATWG Streams Standard (معروف به “Web streams”) با هدف ایجاد یک API مشترک برای کار همزمانِ مرورگرها و سرورها طراحی شد 🚀. این استاندارد در مرورگرها عرضه شد و بعداً Cloudflare Workers، Node.js، Deno و Bun آن را پذیرفتند و پایه‌ای برای APIهایی مثل fetch()</em) شد.

اما بعد از سال‌ها پیاده‌سازی و کار واقعی روی Web streams — از پیاده‌سازی در Node.js و Cloudflare Workers تا عیب‌یابی مسائل production برای مشتریان و runtimeها و کمک به توسعه‌دهنده‌ها در مواجهه با تله‌های رایج — به این نتیجه رسیدم که API استاندارد مشکلات اساسی در usability و performance دارد که با بهبودهای جزئی قابل حل نیستند ⚠️. این مشکلات باگ ساده نیستند؛ نتیجه تصمیمات طراحی‌ای هستند که شاید ده سال پیش منطقی به نظر می‌رسیدند اما امروز با نحوه نوشتن کد توسط JavaScript developers هم‌راستا نیستند.

در این مطلب مشکلات بنیادین Web streams را بررسی می‌کنم و یک رویکرد جایگزین مبتنی بر زبان JavaScript نشان می‌دهم تا ثابت کنم راه بهتری ممکن است. در بنچمارک‌ها این جایگزین بین 2x تا 120x سریع‌تر از Web streams اجرا شده در همه runtimeهایی که تست کردم (Cloudflare Workers, Node.js, Deno, Bun و مرورگرهای اصلی)؛ بهبودها ناشی از انتخاب‌های طراحی متفاوت‌اند که بهتر از ویژگی‌های مدرن JavaScript بهره می‌برند. هدفم تخریب کار قبلی نیست، بلکه شروع گفت‌وگو درباره بعدیِ ممکن است 🧠.

کمی سابقه: Streams Standard بین 2014 و 2016 توسعه یافت تا “APIs for creating, composing, and consuming streams of data that map efficiently to low-level I/O primitives” را فراهم کند. پیش از Web streams، پلتفرم وب روش استانداردی برای کار با داده‌های streaming نداشت. در آن زمان Node.js API مخصوص خودش را داشت، ولی WHATWG تصمیم گرفت از آن به عنوان نقطه شروع استفاده نکند چون مأموریتش صرفاً نیازهای مرورگرها را در بر می‌گرفت. پشتیبانی سروری هم بعداً وقتی Cross-runtime compatibility اهمیت پیدا کرد، پیوست.

نکته مهم این است که طراحی Web streams قبل از جا افتادن async iteration در JavaScript بود. سینتکس for await…of در ES2018 آمد، یعنی دو سال بعد از نهایی شدن Streams Standard. نتیجه این شد که API در ابتدا نتوانست از شیوه‌ای که بعداً متداول شد بهره ببرد و یک مدل acquisition مخصوص reader/writer را معرفی کرد که تا امروز روی تمام جنبه‌های API تأثیر گذاشته است.

تشریفاتِ بیش از حد برای عملیات رایج — رایج‌ترین کار با stream خواندن کامل آن است. با Web streams معمولاً الگوی زیر را می‌نویسیم:

// First, we acquire a reader that gives an exclusive lock
// on the stream...
const reader = stream.getReader();
const chunks = [];
try {
  // Second, we repeatedly call read and await on the returned
  // promise to either yield a chunk of data or indicate we're
  // done.
  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    chunks.push(value);
  }
} finally {
  // Finally, we release the lock on the stream
  reader.releaseLock();
}

این الگو ممکن است به نظر ذاتیِ streaming بیاید، ولی نیست. دریافت reader، مدیریت قفل و پروتکل { value, done } همگی انتخاب‌های طراحی هستند نه ضرورت‌های بنیادین. وقتی async iteration موجود شد، الگویِ زیر خیلی ساده‌تر به نظر می‌رسد:

const chunks = [];
for await (const chunk of stream) {
  chunks.push(chunk);
}

این بهتر است چون boilerplate بسیار کمتر می‌شود، اما تمام مشکلات را حل نمی‌کند. async iteration به یک API که برای آن طراحی نشده بود اضافه شد و این خودش عیوبی دارد: امکاناتی مثل BYOB (bring your own buffer) از طریق iteration در دسترس نیستند و پیچیدگی‌های زیرساختی readerها، قفل‌ها و controllers هنوز وجود دارند اما پنهان شده‌اند. وقتی مشکلی رخ می‌دهد یا به امکانات بیشتری نیاز دارید، اغلب دوباره سر از API اصلی درمی‌آورید تا بفهمید چرا stream “locked” شده یا چرا releaseLock() انتظار شما را برآورده نکرده یا برای پیدا کردن گلوگاه‌هایی در کدی که کنترلی رویش ندارید گرفتار می‌شوید 🔍.

مسئله قفل‌ها — Web streams از یک مدل locking استفاده می‌کند تا از خوانده شدن همزمان توسط چند مصرف‌کننده جلوگیری کند. وقتی getReader() می‌زنید، stream قفل می‌شود و تا زمانی که reader در دست است هیچ کس دیگری نمی‌تواند مستقیمِ stream را بخواند، آن را pipe کند یا حتی cancel کند — فقط کدی که reader را دارد می‌تواند این کارها را انجام دهد. اما این مدل خیلی آسان می‌تواند مشکل‌ساز شود:

async function peekFirstChunk(stream) {
  const reader = stream.getReader();
  const { value } = await reader.read();
  // Oops — forgot to call reader.releaseLock()
  // And the reader is no longer available when we return
  return value;
}

const first = await peekFirstChunk(stream); // TypeError: Cannot obtain lock — stream is permanently locked

for await (const chunk of stream) { /* never runs */ }

فراموش کردن releaseLock() می‌تواند به‌صورت دائمی stream را خراب کند. ویژگی locked به شما می‌گوید که stream قفل است اما نمی‌گوید چرا، توسط چه کسی یا آیا قفل هنوز قابل استفاده است یا نه. عملیات piping هم به طور داخلی قفل‌هایی می‌گیرد و گاهی stream را در طول pipe غیرقابل استفاده می‌کند — رفتاری که چندان بدیهی نیست. سال‌ها هم در مورد semantics رها کردن قفل در حضور readهای معلق (pending reads) ابهام وجود داشت؛ مشخص شد که وقتی releaseLock() زده شود، باید readهای معلق cancel شوند، اما پیاده‌سازی‌ها تا روشن‌شدن spec رفتارهای متفاوتی داشتند و کدی که به رفتار قبلی بستگی داشت ممکن است بشکند.

با این حال، قفل‌گذاری به خودیِ خود چیز بدی نیست — هدفش مرتب و منظم نگه داشتن مصرف یا تولید داده است. مشکل اصلی، نحوه دستی و آشکار مدیریت قفل‌ها با APIهایی مثل getReader() و releaseLock() است. با آمدن مدیریت خودکار قفل و reader توسط async iterables، کار برای کاربران تا حدی آسان‌تر شده، اما برای implementerها این مدل همچنان bookkeeping داخلی زیادی تحمیل می‌کند: وضعیت قفل باید چک شود، readerها پیگیری شوند و تعامل بین قفل‌ها، cancellation و حالات خطا یک ماتریس از edge caseها ایجاد می‌کند که همه باید درست هندل شوند 🧩.

BYOB: پیچیدگی بدون بازده مورد انتظار — BYOB برای این طراحی شده بود که توسعه‌دهنده‌ها بتوانند bufferهای خود را مجدداً استفاده کنند تا در سناریوهای با Throughput بالا حافظه کمتری تخصیص داده شود. ایده منطقی است: به جای اختصاص buffer جدید برای هر chunk، buffer خودتان را می‌دهید و stream آن را پر می‌کند. اما در عمل (با همه استثناها) BYOB خیلی کم به سود قابل‌مشاهده منجر می‌شود و API پیچیدگی زیادی اضافه می‌کند. باید از یک نوع reader جدا (ReadableStreamBYOBReader) و کلاس‌های مخصوص دیگری (مثل ReadableStreamBYOBRequest) استفاده کنید، چرخه عمر buffer را دقیق مدیریت کنید و semantics مربوط به ArrayBuffer detachment را بفهمید. وقتی buffer را به یک BYOB read می‌دهید، buffer detached می‌شود — به stream منتقل می‌شود — و view متفاوتی بر حافظه‌ی ممکن است متفاوت باز می‌گردد و این مدل انتقال-محور خطاپذیر و گیج‌کننده است:

const reader = stream.getReader({ mode: 'byob' });
const buffer = new ArrayBuffer(1024);
let view = new Uint8Array(buffer);
const result = await reader.read(view);
// 'view' should now be detached and unusable
// (it isn't always in every impl)
// result.value is a NEW view, possibly over different memory
view = result.value; // Must reassign

علاوه بر این، BYOB از طریق async iteration یا TransformStreams قابل استفاده نیست؛ پس توسعه‌دهنده‌هایی که دنبال خواندن zero-copy هستند، ناچار می‌شوند دوباره به حلقه دستی reader برگردند. برای implementerها هم کار زیادی اضافه می‌شود: باید pending BYOB requests را پیگیری کنند، partial fills را هندل کنند، detachment را صحیح مدیریت کنند و هماهنگی بین BYOB reader و underlying source را برقرار سازند. Web Platform Tests برای readable byte streams فایل‌های مخصوصی تنها برای edge caseهای BYOB دارند: detached buffers، bad views، ordering مربوط به response-after-enqueue و غیره 🧰.

در نتیجه BYOB برای کاربران و پیاده‌سازان پیچیده است ولی پذیرش کمی در عمل داشته؛ بیشتر توسعه‌دهنده‌ها مسیر default read را انتخاب کرده و سربار تخصیص را می‌پذیرند. اکثر پیاده‌سازی‌های userland که ReadableStreamهای سفارشی می‌سازند هم معمولاً تلاش نمی‌کنند تمام تشریفات لازم برای پشتیبانی هم‌زمان از default و BYOB را به‌درستی اجرا کنند — و به دلایل خوب: سخت است و اغلب کد وقت‌گیر ترجیح می‌دهد از مسیر ساده‌تر default read استفاده کند.

در مجموع، پیاده‌سازی کامل و “درست” برای هر دو حالت default و BYOB بزرگ، پیچیده و مستعد خطا است؛ سطحی از پیچیدگی که معمولاً توسعه‌دهندهٔ متوسط نمی‌خواهد با آن دست و پنجه نرم کند. از طرفی، طراحی‌های مبتنی بر ویژگی‌های زبانی JavaScript که پیچیدگی‌ را کاهش دهند و الگوهای رایج را ساده‌تر کنند، می‌توانند هم خوانایی و هم عملکرد را بهبود دهند — و این همان چیزی است که پیشنهاد می‌کنم در ادامه بررسی و بحث شود 🔧🙂.