Files
What you'll learn
- Return a file as a StreamableFile from a read stream or a buffer, and explain why not to pipe to the response yourself
- Set Content-Type and Content-Disposition with @Header, res.set() and the StreamableFile options
- Refuse a missing file with a 404 before streaming starts
Streaming Files
Not every response is JSON. The shelter keeps an archive of records on disk and wants to hand a CSV of today's cats to whoever asks, and neither is something you build in memory, JSON.stringify and return. A file is bytes, possibly many, and the right way to send bytes is to stream them: read a chunk, write a chunk, never hold the whole thing. Node has streams for that, Express lets you pipe one into the response, and Nest adds a small class so that piping does not cost you the rest of the framework.
StreamableFile
The obvious code is createReadStream(path).pipe(res). It works, and it bypasses Nest: a handler that pipes to the raw response has taken the response over, so interceptors after it never see a value, and a thrown error goes nowhere sensible. StreamableFile returns the stream as a value instead:
@Get()
getFile(): StreamableFile {
const file = createReadStream(join(process.cwd(), 'package.json'));
return new StreamableFile(file);
}
Nest does the piping, after the interceptors, with the platform's own mechanism, so the same handler works under Express and Fastify. The constructor takes a readable stream or a Buffer; the buffer form is for content built in memory, a CSV assembled from rows, a generated image, a small PDF.
Headers
Without a content type the file goes out as application/octet-stream, which a browser downloads and nothing opens. Three places set the headers, and which you use is taste:
@Get()
@Header('Content-Type', 'application/json')
@Header('Content-Disposition', 'attachment; filename="package.json"')
getStaticFile(): StreamableFile {
return new StreamableFile(createReadStream(join(process.cwd(), 'package.json')));
}
@Header() on the handler, for headers that never change. Or res.set({ ... }) with @Res({ passthrough: true }), when the values are computed. Or the options object, new StreamableFile(file, { type: 'application/json', disposition: 'attachment; filename="package.json"', length: 1234 }), when the file knows its own type. Content-Disposition: attachment asks the browser to save the file under the given name; inline asks it to display it.
Files that are not there
A stream errors when its file does not exist, and by then the response may have started. StreamableFile has an error handler for that case, setErrorHandler((err, res) => ...), with a default that answers 400 and the error's message. Better than handling the error is not causing it: check with existsSync before creating the stream, and throw a NotFoundException that the exception layer answers as it answers every other 404. The check costs nothing and keeps the error in Nest's hands.
Where it sits
The value the handler returns is a StreamableFile, so interceptors run as usual: middleware → guards → interceptors → pipes → handler → interceptors → the pipe to the response. A logging interceptor sees the response go out; a serialisation interceptor leaves a StreamableFile alone.
Your task
The archive under records/ opens up, and the cats become a spreadsheet.
GET /records/cats.csvstreams the archive file astext/csv, as an attachment namedcats.csv, with the headers declared on the handler.GET /records/:namestreams any record with the default content type, and answers404for a name that is not on disk, before any streaming starts.GET /cats/exportbuilds the CSV in memory, sets its type and anexport.csvattachment disposition through the response object, and returns it as aStreamableFile.
Run after step 1 and open the response panel: the body is the file's text, the content type is text/csv. Then remove the @Header lines and run again to see what a file with no declared type looks like to a client.
When it fails
- The body is the JSON of a stream object, or empty: the handler returned the stream itself rather than a
StreamableFile. Content-Typeisapplication/octet-stream: nothing declared the type; add@Header,res.setor the options object.400 ENOENT: no such file or directory, open '/records/nope.csv': the stream was created for a missing file and its error reached the default handler. Check withexistsSyncfirst.Content-Dispositionis missing on the export:res.setwas called withoutpassthrough, or after the return.
Remember
- Return
new StreamableFile(stream | buffer); Nest pipes it after the interceptors. - Set the type and disposition with
@Header(),res.set()or the options object;attachment; filename="x"downloads,inlinedisplays. - Check
existsSyncbefore streaming, and throw aNotFoundExceptionlike any other route. createReadStream(join(process.cwd(), ...))reads from the project's directory; here, from the lesson's files.
Stuck? Show a hint
records.controller.ts: @Header('Content-Type', 'text/csv') and @Header('Content-Disposition', 'attachment; filename="cats.csv"') on archive(), returning new StreamableFile(createReadStream(join(process.cwd(), 'records', 'cats.csv'))); record() checks existsSync(path) and throws NotFoundException, else returns new StreamableFile(createReadStream(path)). cats.controller.ts: res.set({ 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': 'attachment; filename="export.csv"' }) then return new StreamableFile(Buffer.from(csv)).
Press Run tests to start the app. Its log appears here.Graded endpoints
records/cats.csv streamed from disk, typed and named by @Header
The same file through the generic route, with no declared type
existsSync said no, so the route threw a 404 before creating a stream
A row for the export
A second row
A CSV built in memory, returned as a StreamableFile over a Buffer, with the headers set through res.set()