Streaming Files from SvelteKit
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) Undici. Undici 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 Undici 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 Undici 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!
Update, July 2025
Sadly, the error did not go away after all. It might have occured less often, and I think depending on the exact version of Node and Undici, it might not have occured at all for periods of time. But it always ended up coming back.
But a few weeks ago, there was an exciting addition to Node.js. From the changelog for v22.17.0:
fs.FileHandle.readableWebStreamgetsautoCloseoptionThis gives developers explicit control over whether the file descriptor should be closed when the stream ends. Helps avoid subtle resource leaks.
Subtle resource leaks? When the stream ends? Like, idk, an invalid state when the controller is already closed? Are you seeing what I’m seeing? Here we go again:
import { Readable } from 'node:stream'
import fs from 'node:fs/promises'
import path from 'node:path'
export async function GET({ params, url, locals }) {
const filePath = path.join(MY_UPLOADS_PATH, `${params.file}-${template}.pdf`)
- const fileStream = fs.createReadStream(filePath) as unknown as BodyInit
+ const fileHandle = await fs.open(filePath)
+ // @ts-expect-error: @types/node doesn't know this yet
+ const fileStream = fileHandle.readableWebStream({ autoClose: true }) as BodyInit
return new Response(fileStream)
}
Ever since deploying this a few weeks ago, the error has disappeared. Fingers crossed that it will stay away this time!
