How to generate PDFs with Firebase functions
A step by step tutorial for creating a PDF, uploading it and returning an expiring link to download it.
Often, when I have a hard time solving a task, I like to summarize my learnings in a blog post. Like, the article I wished I found when I started out. This is definitely one of those.
In this tutorial, we'll use Firebase Functions to generate a PDF document with pdfkit, upload it to Firebase Storage, and return a signed and secure URL where it can be viewed. And we'll add a few fancy features as we go.
I needed to do this because we needed a way to generate receipts for a side project I have (opra.no, go check it out!). Your use case is probably different, but as long as you want to generate a PDF, host it somewhere and (or) do all that within the confines of Firebase / Google Cloud, you're probably going to find this article useful.
So without further ado - let's jump in!
Step 1: Set up Firebase
The very first thing you want to do is to get your Firebase project up and running. I'm not going to do this in this article, because by the time you read this, you probably know how to do that. If not, go check out the Getting Started guide in the Firebase docs for a quick refresher.
One thing you do need to do, however, is to create a service account. We'll be using the firebase-admin npm package to do a few things, and that requires a service account, and some keys in a JSON file. Again, the documentation is great here, so if you haven't set this up before, go check out their setup guide.
Step 2: Bootstrap your function
So everything Firebase is up and running - that's great. Let's create a new callable function.
import * as functions from 'firebase-functions';
export const getPdfUrl = functions
.https.onCall(async (data, context) => {
return 'Hello world'
});
This little snippet sets up a cloud function, that you can call in your client app by using the firebase.functions().httpsCallable('getPdfUrl')
function. Pretty neat!
Let's write some comments to outline the body of our shiny new function:
import * as functions from 'firebase-functions';
export const getPdfUrl = functions
.https.onCall(async (data, context) => {
// Generate a PDF
// Upload it to Firebase Storage
// Generate expiring URL and return it
});
Sounds doable, right?
Step 3: Generating the PDF
There are many ways to generate PDFs, but I went for the tried and tested pdfkit package. You can do pretty crazy stuff with SVGs and custom fonts and what not, but we're not going to focus too much on all of those features today.
The first thing you need to do is to install the pdfkit dependency (and its types, if you're using TypeScript):
npm install pdfkit
# and if you're using TypeScript, you need the type definitions as well
npm install --save-dev @types/pdfkit
Next, let's import it and create a document we can manipulate. We'll also add a few lines of text to the document while we're at it:
import * as functions from 'firebase-functions';
import PdfKit from 'pdfkit';
export const getPdfUrl = functions
.https.onCall(async (data, context) => {
const doc = new PdfKit();
doc
.fontSize(24)
.text("Receipt")
.fontSize(16)
.moveDown(2)
.text("This is your receipt!")
});
Here, we create a new document instance by calling the PdfKit import, and then we add a bunch of stuff to it. We're manipulating the font size, writing some text, moving down a few blocks to make it look "just right". As I said, there's a lot of stuff you can do here, but the documentation is pretty good, and making fancy receipts isn't what this article is about.
Anyways, this looks promising - let's upload it!
Step 4: Upload the PDF
Uploading stuff to Firebase is usually pretty simple, especially from the browser. From a cloud function, however, you'll have to use streams! They might sound scary, but they're pretty nice once you get to know them!
First, we need to get a reference to our file.
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';
import uuid from 'uuid';
import PdfKit from 'pdfkit';
export const getPdfUrl = functions
.https.onCall(async (data, context) => {
const doc = new PdfKit();
let receiptId = uuid.v4();
const fileRef = admin
.storage()
.bucket()
.file(`receipts/receipt-${receiptId}.pdf`);
doc
.fontSize(24)
.text("Receipt")
.fontSize(16)
.moveDown(2)
.text("This is your receipt!")
});
We call the storage function of the firebase-admin library, select the default bucket, and then create a reference to a file with a unique name. I've used the uuid library to do this, but you can choose whatever method fits your data.
The next thing we want to do is to open a stream to that file's location, so we can upload the PDF document to the Firebase storage service. If you're not an expert on streams, just relax, I wasn't either. Think of them as pipelines for data, that you can "pour" stuff into as it's generated. This way, you don't have to generate all 1000 pages of a document and keep that in memory before you ship it off to your storage service!
To create this stream, we call the .createWriteStream function of our file reference. Note that we're passing two options to this function. We set "resumable" to false, since we're only creating a small PDF in this example, and we specify the content type of the file. This way, browsers will know how to read this file by looking at its metadata, and not its file ending.
We also add some event handlers to figure out when the stream is done and when it errs out. Finally, we "connect" our data source (the PDF document) to our write stream, specify what data we want to have, and call pdfkit's .end() function when we're done.
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';
import uuid from 'uuid';
import PdfKit from 'pdfkit';
export const getPdfUrl = functions
.https.onCall(async (data, context) => {
const doc = new PdfKit();
let receiptId = uuid.v4();
const fileRef = admin
.storage()
.bucket()
.file(`receipts/receipt-${receiptId}.pdf`);
// I'll explain this part in a second
await new Promise<void>((resolve, reject) => {
const writeStream = file.createWriteStream({
resumable: false,
contentType: "application/pdf",
});
writeStream.on("finish", () => resolve());
writeStream.on("error", (e) => reject(e));
doc.pipe(writeStream);
doc
.fontSize(24)
.text("Receipt")
.fontSize(16)
.moveDown(2)
.text("This is your receipt!")
doc.end()
});
});
Woah, that looked weird. Especially that Promise part. Here's what's happening, and why I ended up doing it like that.
These cloud functions are great, because they can return a value, not just do random calculations and enter them into the void. In our case, I want to return the URL of the uploaded file, and in order to do so, I want to wait for the upload to be finished.
When I write await new Promise((resolve, reject) => {})
, I can pass the resolve and reject methods into the on("finish") and on("error") callbacks. This way, I can "wait" until the stream of PDF data is written to my storage service, and then return the URL. It's ugly, but it gets the job done. If you know of a better way to get the same result, please let me know! 😁
Step 5: Generate an expiring URL
With Firebase's storage services, you can mark any upload as public or private, and you can write complex rules about who can access them. In my case, creating receipts, I wanted all files to be private by default, but you could generate a link that would make it accessible to anyone with the link within a given time frame.
Firebase makes this pretty straight-forward with it's getSignedUrl method. Let's continue on with our code example!
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';
import uuid from 'uuid';
import PdfKit from 'pdfkit';
export const getPdfUrl = functions
.https.onCall(async (data, context) => {
const doc = new PdfKit();
let receiptId = uuid.v4();
const file = admin
.storage()
.bucket()
.file(`receipts/receipt-${receiptId}.pdf`);
await new Promise<void>((resolve, reject) => {
const writeStream = file.createWriteStream({
resumable: false,
contentType: "application/pdf",
});
writeStream.on("finish", () => resolve());
writeStream.on("error", (e) => reject(e));
doc.pipe(writeStream);
doc
.fontSize(24)
.text("Receipt")
.fontSize(16)
.moveDown(2)
.text("This is your receipt!")
doc.end()
});
const url = await file.getSignedUrl({
version: "v4",
action: "read",
expires: Date.now() + 24 * 60 * 60 * 1000,
});
return { url };
});
Towards the bottom of our example, we're calling the getSignedUrl method with a version, allowed action(s) and when the link expires. Then, we simply return the URL (or an object containing the URL) to whoever called our cloud function.
A word of caution: I had to screw around with some of the roles for my Google service account to get signed URLs to work, but I can't for the life of me remember what I did. I remember seeing what to do in the docs though.
Bonus step: Avoid generating generated files
A side effect of being lazy in general is that doing unnecessary work just feels wrong. If we've already created this PDF previously, why go through the hassle of generating and uploading it again? As long as nothing should change, we can just generate an expiring link to the existing version!
Here's the completed cloud function, complete with caching and all:
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';
import PdfKit from 'pdfkit';
import * as api from './api';
export const getPdfUrl = functions
.https.onCall(async (data, context) => {
const doc = new PdfKit();
let receiptId = await api.getReceiptIdForUser(context);
const file = admin
.storage()
.bucket()
.file(`receipts/receipt-${receiptId}.pdf`);
const [exists] = await file.exists();
if (exists) {
const url = await file.getSignedUrl({
version: "v4",
action: "read",
expires: Date.now() + 24 * 60 * 60 * 1000,
});
return { url };
}
await new Promise<void>((resolve, reject) => {
const writeStream = file.createWriteStream({
resumable: false,
contentType: "application/pdf",
});
writeStream.on("finish", () => resolve());
writeStream.on("error", (e) => reject(e));
doc.pipe(writeStream);
doc
.fontSize(24)
.text("Receipt")
.fontSize(16)
.moveDown(2)
.text("This is your receipt!")
doc.end()
});
const url = await file.getSignedUrl({
version: "v4",
action: "read",
expires: Date.now() + 24 * 60 * 60 * 1000,
});
return { url };
});
And voilá!
Here's a slightly refactored version, just for your viewing pleasure.
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';
import PdfKit from 'pdfkit';
import * as api from './api';
const getSignedUrl = file => file.getSignedUrl({
version: "v4",
action: "read",
expires: Date.now() + 24 * 60 * 60 * 1000,
});
const createAndUploadPdf = file => new Promise<void>((resolve, reject) => {
const doc = new PdfKit();
const writeStream = file.createWriteStream({
resumable: false,
contentType: "application/pdf",
});
writeStream.on("finish", () => resolve());
writeStream.on("error", (e) => reject(e));
doc.pipe(writeStream);
doc
.fontSize(24)
.text("Receipt")
.fontSize(16)
.moveDown(2)
.text("This is your receipt!")
doc.end()
});
const getFileRef = async (context) => {
let receiptId = await api.getReceiptForPdf(context);
return admin
.storage()
.bucket()
.file(`receipts/receipt-${receiptId}.pdf`);
}
export const getPdfUrl = functions
.https.onCall(async (data, context) => {
const file = getFileRef();
const [exists] = await file.exists();
if (exists) {
const url = await getSignedUrl(file);
return { url };
}
await createAndUploadPdf(file);
const url = await getSignedUrl(file);
return { url };
});
Note that in these last two examples, I switch out the way I get the receipt ID. Instead of using the uuid library, we now have to generate a stable ID somehow (to check if it exists). In my code, that ID is based on data I get from my payment provider, but you can generate it based on whatever you want (user id and date, for example).
Firebase Functions and its storage service is great for a lot of things, and PDF generation and storage is definitely one of them. Here, we went through I hope this article helped you implement a similar-ish feature in your own project.
Thanks for reading, and please let me know on Twitter l if you have any feedback.
Have a great day :)