javascriptstreamdeno

Streaming a video from a server using ReadableSteam


Deno has this ReadableStream thing that is supposed to be compatible with the one from the browsers. https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream

And it should be used when you want to stream things. The example in their docs uses some Date/interval and text, not a video

But I found some older code on the internet that shows how to use it with a video:

  const { size } = await Deno.stat("video.mp4");
  const range = req.headers.get("range");

  if (!range) {
    throw new Error("no range");
  }
  console.log("req range=", range)

  const num_blocks = 100;
  const block_size = 16_384;
  const chunk_size = block_size * num_blocks;

  const start_index = Number(range.replace(/\D/g, "")?.trim());
  const end_index = Math.min(start_index + chunk_size, size);
  const file = await Deno.open("video.mp4", { read: true });
  if (start_index > 0) {
    await file.seek(start_index, Deno.SeekMode.Start);
  }

  let read_blocks = num_blocks;
  let read_bytes = 0;
  console.info("calc range=", start_index, end_index, size)
  const stream = new ReadableStream({
    start(){
      console.log("start")
    },
    async pull(controller) {
      const chunk = new Uint8Array(block_size);
      try {
        const read = await file.read(chunk);
        console.log("read block bytes=", read)
        if (read !== null && read > 0) {
          controller.enqueue(chunk.subarray(0, read));
          read_bytes += read;
        }

        read_blocks--;
        if (read_blocks === 0) {
          log.info("no more blocks", read_bytes)
          controller.close();
          file.close();
        }
      } catch (e) {
        controller.error(e);
        console.log(e)
        file.close();
      }
    },

    cancel(reason){
      console.log("canceled=", reason)
      file.close();
    }
  });

  return new Response(stream, {
    status: 206,
    headers: {
      "Content-Range": `bytes ${start_index}-${end_index}/${size}`,
      "Accept-Ranges": "bytes",
      "Content-Length": `${end_index - start_index}`,
      "Content-Type": "video/mp4",
    },
  });

The code above is supposed to server the video in chunks of around 1.5MB each, and the ReadableStream is supposed to read each chunk in 16 KB blocks, so thats 100 blocks in total.

It appears to stream fine in firefox, but I do sometimes get an error in the Deno console TypeError: The stream controller cannot close or enqueue (not always)

In Chrome it only streams for about 12 seconds, even tho the video is 30 seconds long. If you press play again it starts from the beginning. And in the console I sometimes get the same error as with Firefox.

Any idea how to fix this?


Solution

  • "The stream controller cannot close or enqueue"—is likely due to incorrectly closing the stream while it's still being read. This could happens when:

    or

    Currently, your code decrements read_blocks, and if it hits zero, it closes the stream. However, that might happen before the actual EOF is reached. Instead, check explicitly for EOF (read === null) before closing.

    You're currently enqueuing chunks even when read === 0 - this can cause issues.

    Also eg. using Deno.read prevents keeping the file handle open longer than necessary.

    const file = "video.mp4";
    const { size } = await Deno.stat(file);
    const range = req.headers.get("range");
    
    if (!range) {
        throw new Error("Missing 'Range' header. Video streaming requires byte ranges.");
    }
    
    console.log("Requested Range:", range);
    
    // Extract byte range from header
    const matches = range.match(/bytes=(\d+)-(\d+)?/);
    if (!matches) {
        throw new Error("Invalid Range format.");
    }
    
    const start = parseInt(matches[1], 10);
    const end = matches[2] ? Math.min(parseInt(matches[2], 10), size - 1) : size - 1;
    
    if (start >= size || start > end) {
        return new Response("Requested range not satisfiable", { status: 416 });
    }
    
    const chunkSize = 1_572_864; // ~1.5MB per response
    const stream = new ReadableStream({
        async start(controller) {
            try {
                console.log(`Streaming bytes ${start}-${end} of ${size}`);
    
                const file = await Deno.open(file, { read: true });
                await file.seek(start, Deno.SeekMode.Start);
    
                let bytesRead = 0;
                while (bytesRead < chunkSize) {
                    const buffer = new Uint8Array(16_384); // Read in 16KB blocks
                    const read = await file.read(buffer);
    
                    if (read === null) break;
    
                    controller.enqueue(buffer.subarray(0, read));
                    bytesRead += read;
                }
    
                controller.close();
                file.close();
            } catch (error) {
                console.error("Stream error:", error);
                controller.error(error);
            }
        },
        cancel(reason) {
            console.log("Stream canceled:", reason);
        }
    });
    
    return new Response(stream, {
        status: 206,
        headers: {
            "Content-Range": `bytes ${start}-${end}/${size}`,
            "Accept-Ranges": "bytes",
            "Content-Length": `${end - start + 1}`,
            "Content-Type": "video/mp4"
        }
    });