A blog about software development, written by Daniel Diekmeier. Archive.

Streaming Files from SvelteKit

November 6, 2023

Disclaimer: I’m pretty sure this only applies if you use @sveltejs/adapter-node, because it’s using Node.js specific APIs.

For Eintrittskarten.io, I wanted to build an endpoint which returns access controlled files. I didn’t want to load the complete files into RAM at once, instead I looked for a way to stream the files from the server to the client.

SvelteKit does not have this “built in”, but I put the following code together and it seemed to work fine:

import { Readable } from 'node:stream'

export async function GET({ params, url, locals }) {
  const filePath = path.join(MY_UPLOADS_PATH, `${params.file}-${template}.pdf`)
  // DO NOT COPY THIS INTO YOUR APP. READ ON.
  const fileStream = Readable.toWeb((fs.createReadStream(filePath))) as BodyInit
  return new Response(fileStream)
}

Create a good old Node.js ReadStream, use an (at the time of this writing) “experimental” function to transform it into another kind of Readable Stream, and send it off in the Response. Easy! Too easy!

After deploying this, every now and then, I’d get this error delivered to my Sentry instance:

TypeError: Invalid state: Controller is already closed
  File "node:internal/errors", line 406, col 5, in new NodeError
  File "node:internal/webstreams/readablestream", line 1056, col 13, in ReadableStreamDefaultController.close
  File "node:internal/webstreams/adapters", line 454, col 16, in ReadStream.<anonymous>
  File "node:internal/util", line 531, col 12, in ReadStream.<anonymous>
  File "node:internal/streams/end-of-stream", line 162, col 14, in ReadStream.onclose
  File "node:events", line 514, col 28, in ReadStream.emit
  File "node:domain", line 488, col 12, in ReadStream.emit
  File "node:internal/streams/destroy", line 132, col 10, in emitCloseNT
  File "node:internal/process/task_queues", line 81, col 21, in process.processTicksAndRejections

I could not make sense of it. This error would of course be more helpful if my application code would be somewhere in there, but no luck.

Googling for it brings me to (unrelated issues in) Unidici. Unidici is the new HTTP client in Node.js, so I understand that it’s related to me trying to stream a file, but it’s completely unclear to me why this error is happening or what I could do to prevent it. I was also never able to reproduce the error locally.

I think what is happening is that if a user stops the Response file download mid-stream (e.g. by closing the browser), the stream is closed once, but when the Node.js stream has read the whole file from disk, it tries to close the stream again – Leading to the error. But this is just a guess.

Anyway, after clicking deeper and deeper into the Unidici source code, I found a terrible hack nice convenience that you can/should use instead:

 import { Readable } from 'node:stream'

 export async function GET({ params, url, locals }) {
   const filePath = path.join(MY_UPLOADS_PATH, `${params.file}-${template}.pdf`)
-  const fileStream = Readable.toWeb((fs.createReadStream(filePath))) as BodyInit
+  const fileStream = fs.createReadStream(filePath) as unknown as BodyInit
   return new Response(fileStream)
 }

You can pass a Node.js stream directly to Response, because Unidici has a special case for this! (Even though this is not part of the spec for Response.)

(Yes, I’m sorry about the as unknown as BodyInit, but it does work. Something is wrong with the types.)

I haven't had the error pop up in Sentry even a single time since deploying this change. So, I guess by using the platform, SvelteKit does have streaming built in. Really makes you think!