Streaming FilesTechniques · NestJS

Send bytes, not JSON: stream a file from disk with StreamableFile, declare its type and download name three ways, refuse a missing file before streaming, and export the cats as a CSV built in memory.

What you will learn

Read the theory for Streaming Files

All Techniques lessons

All NestJS courses

loading types…

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.

  1. GET /records/cats.csv streams the archive file as text/csv, as an attachment named cats.csv, with the headers declared on the handler.
  2. GET /records/:name streams any record with the default content type, and answers 404 for a name that is not on disk, before any streaming starts.
  3. GET /cats/export builds the CSV in memory, sets its type and an export.csv attachment disposition through the response object, and returns it as a StreamableFile.

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-Type is application/octet-stream: nothing declared the type; add @Header, res.set or 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 with existsSync first.
  • Content-Disposition is missing on the export: res.set was called without passthrough, 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, inline displays.
  • Check existsSync before streaming, and throw a NotFoundException like 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)).