Why is my PDF not saving intermittently in my Node function?

First, let me say, I am very new to backend application and Nodejs. I primarily do mobile development, so my knowledge of the language is limited.

I have an endpoint in Firebase Functions that builds and saves a PDF from data in Firestore and images in Storage. The PDF building works just fine, and I am not getting any errors. However, the final piece of code to save the PDF doesn’t execute consistently. I have log statements that never get fired, but sometimes the PDF is saved. I assume it has something to do with my use of async methods but I’m not sure. Is there anything blatantly wrong with this code? This is the entirety of the code I am using.

const admin = require('firebase-admin');
const firebase_tools = require('firebase-tools');
const functions = require('firebase-functions');
const Printer = require('pdfmake');
const fonts = require('pdfmake/build/vfs_fonts.js');
const {Storage} = require('@google-cloud/storage');
const url = require('url');
const https = require('https')
const os = require('os');
const fs = require('fs');
const path = require('path');


const storage = new Storage();

const bucketName = '<BUCKET NAME REMOVED FOR THIS QUESTION>'

admin.initializeApp({
  serviceAccountId: 'firebase-adminsdk-ofnne@perimeter1-d551f.iam.gserviceaccount.com',
  storageBucket: bucketName
});

const bucket = admin.storage().bucket()
const firestore = admin.firestore()

  const fontDescriptors = {
    Roboto: {
      normal: Buffer.from(fonts.pdfMake.vfs['Roboto-Regular.ttf'], 'base64'),
      bold: Buffer.from(fonts.pdfMake.vfs['Roboto-Medium.ttf'], 'base64'),
      italics: Buffer.from(fonts.pdfMake.vfs['Roboto-Italic.ttf'], 'base64'),
      bolditalics: Buffer.from(fonts.pdfMake.vfs['Roboto-Italic.ttf'], 'base64'),
    }
  };

  function buildLog(data) {
    const filePath = data.imageReference;
    const fileName = path.basename(filePath);
    const tempFilePath = path.join(os.tmpdir(), fileName);
    return {
      stack: [
      {
        image: tempFilePath,
        fit: [130, 220]
      },
      {
        text: data["logEventType"],
        style: 'small'
      },
      {
        text: data["date"],
        style: 'small'
      }
      ],
      unbreakable: true,
      width: 130
    }
  }

  function buildLogsBody(data) {
    var body = [];

    var row = []
    var count = 0

    data.forEach(function(logData) {
      const log = buildLog(logData)
      row.push(log)
      count = count + 1
      if (count == 4) {
        body.push([{columns: row, columnGap: 14}])
        body.push([{text: 'n'}])
        row = []
        count = 0
      }
    });

    body.push([{columns: row, columnGap: 14}])

    return body;
  }

  function title(incidentTitle, pageNumber, logCount, messageCount) {
    var pageTitle = "Incident Summary"
    const logPageCount = Math.ceil(logCount / 8)
    if (messageCount > 0 && pageNumber > logPageCount) {
      pageTitle = "Message History"
    }
    var body = [{
      text: incidentTitle + ' | ' + pageTitle,
      style: 'header'
    }]

    return body
  }

  function messageBody(message) {
    var body = {
      stack: [
      {
        columns: [
        {width: 'auto', text: message['senderName'], style: 'messageSender'},
        {text: message['date'], style: 'messageDate'},
        ],
        columnGap: 8,
        lineHeight: 1.5
      },
      {text: message['content'], style: 'message'},
      {text: 'n'}
      ],
      unbreakable: true
    }
    return body
  }

  function buildMessageHistory(messages) {
    var body = []
    if (messages.length > 0) {
      body.push({ text: "", pageBreak: 'after' })
    }
    messages.forEach(function(message) {
      body.push(messageBody(message))
      body.push('n')
    })
    return body
  }

  const linebreak = "n"

async function downloadImages(logs) {
  await Promise.all(logs.map(async (log) => {
    functions.logger.log('Image download started for ', log);
    const filePath = log.imageReference;
    const fileName = path.basename(filePath);
    const tempFilePath = path.join(os.tmpdir(), fileName);
    await bucket.file(filePath).download({destination: tempFilePath});
    functions.logger.log('Image downloaded locally to', tempFilePath);
  }));
}

//////////// PDF GENERATION /////////////////
exports.generatePdf = functions.https.onCall(async (data, context) => {
  console.log("PDF GENERATION STARTED **************************")

  // if (request.method !== "GET") {
  //   response.send(405, 'HTTP Method ' + request.method + ' not allowed');
  //   return null;
  // }
  const teamId = data.teamId;
  const incidentId = data.incidentId;
  const incidentRef = firestore.collection('teams/').doc(teamId).collection('/history/').doc(incidentId);
  const incidentDoc = await incidentRef.get()
  const messages = []
  const logs = []

  if (!incidentDoc.exists) {
    throw new functions.https.HttpsError('not-found', 'Incident history not found.');
  }

  const incident = incidentDoc.data()

  const incidentTitle = incident["name"]
  const date = "date" //incident["completedDate"]
  const address = incident["address"]
  
  const eventLogRef = incidentRef.collection('eventLog')
  const logCollection = await eventLogRef.get()
  logCollection.forEach(doc => {
    logs.push(doc.data())
  })

  functions.logger.log("Checking if images need to be downloaded");
  if (logs.length > 0) {
    functions.logger.log("Image download beginning");
    await downloadImages(logs);
  }
  functions.logger.log("Done with image download");

  const messagesRef = incidentRef.collection('messages')
  const messageCollection = await messagesRef.get()
  messageCollection.forEach(doc => {
    messages.push(doc.data())
  })

  ////////////// DOC DEFINITION ///////////////////////
  const docDefinition = {
    pageSize: { width: 612, height: 792 },
    pageOrientation: 'portrait',
    pageMargins: [24,60,24,24],
    header: function(currentPage, pageCount, pageSize) {
      var headerBody = {
        columns: [
            title(incidentTitle, currentPage, logs.length, messages.length),
            { 
                text: 'Page ' + currentPage.toString() + ' of ' + pageCount, 
                alignment: 'right',
                style: 'header'
            }
        ],
        margin: [24, 24, 24, 0]
      }
      return headerBody
    },
    content: [
      date,
      linebreak,
      address,
      linebreak,
      { text: [
        { text: 'Incident Commander:', style: 'header' },
        { text: ' Daniel', style: 'regular'},
        ]
      },
      linebreak,
      { 
        text: [
          { text: 'Members involved:', style: 'header' },
          {text: ' Shawn, Zack, Gabe', style: 'regular'},
        ]
      },
      linebreak,
      buildLogsBody(logs),
      buildMessageHistory(messages)
    ],
    pageBreakBefore: function(currentNode, followingNodesOnPage, nodesOnNextPage, previousNodesOnPage) {
      return currentNode.headlineLevel === 1 && followingNodesOnPage.length === 0;
    },
    styles: {
      header: {
        fontSize: 16,
        bold: true
      },
      regular: {
        fontSize: 16,
        bold: false
      },
      messageSender: {
        fontSize: 14,
        bold: true
      },
      message: {
        fontSize: 14
      },
      messageDate: {
        fontSize: 14,
        color: 'gray'
      }
    }
  }

  const printer = new Printer(fontDescriptors);
  const pdfDoc = printer.createPdfKitDocument(docDefinition);
  var chunks = []
  const pdfName = `${teamId}/${incidentId}/report.pdf`;
  pdfDoc.on('data', function (chunk) {
    chunks.push(chunk);
  });

  pdfDoc.on('end', function () {
    functions.logger.log("PDF on end started")
    const result = Buffer.concat(chunks);

    // Upload generated file to the Cloud Storage
    const fileRef = bucket.file(
      pdfName,
      { 
        metadata: { 
          contentType: 'application/pdf'
        } 
      }
    );
  
    // bucket.upload("report.pdf", { destination: "${teamId}/${incidentId}/report.pdf", public: true})
    fileRef.save(result);
    fileRef.makePublic().catch(console.error);
    // Sending generated file as a response
    // res.send(result);
    functions.logger.log("File genderated and saved.")
    return { "response": result }
  });

  pdfDoc.on('error', function (err) {
    res.status(501).send(err);
    throw new functions.https.HttpsError('internal', err);

  });

  pdfDoc.end();
})

For quick reference, the main endpoint method is the exports.generatePdf and the pdfDoc.on at the end is the code that should handle the saving, but code appears to never fire, as the logs in it are never logged, and the document is not being saved always.

Answer

This is a function lifecycle issue, your function is killed prior to completing its task because the last operation you do deal with an event handler instead of returning a Promise. The reason it sometimes works is only because you got lucky. Once a function is complete, it should have finished doing everything it needs to.

So what you need to do is correctly pipe the data from the pdfDoc stream through to Cloud Storage, all wrapped in Promise that Cloud Functions can use to monitor progress and not kill your function before it finishes.

In it’s simplest form it looks like this:

const stream = /* ... */;

const storageStream = bucket
  .file(/* path */)
  .createWriteStream(/* options */);

return new Promise((resolve, reject) => {
  storageStream.once("finish", resolve); // resolve when written
  storageStream.once("error", reject);   // reject when either stream errors
  stream.once("error", reject);

  stream.pipe(storageStream);            // pipe the data
});

Note: The Google Cloud Storage Node SDK is not the same as the Firebase Client’s Cloud Storage SDK!

return new Promise((resolve, reject) => {
  const pdfDoc = printer.createPdfKitDocument(docDefinition);
  const pdfName = `${teamId}/${incidentId}/report.pdf`;
  
  // Reference to Cloud Storage upload location
  const fileRef = bucket.file(pdfName);
  
  const pdfReadStream = pdfDoc;
  const storageWriteStream = fileRef.createWriteStream({ 
    predefinedAcl: 'publicRead', // saves calling makePublic()
    contentType: 'application/pdf'
  });
  
  // connect errors from the PDF
  pdfReadStream.on('error', (err) => {
    console.error("PDF stream error: ", err);
    reject(new functions.https.HttpsError('internal', err));
  });
  // connect errors from Cloud Storage
  storageWriteStream.on('error', (err) => {
    console.error("Storage stream error: ", err);
    reject(new functions.https.HttpsError('internal', err));
  });
  // connect upload is complete event.
  storageWriteStream.on('finish', () => {
    functions.logger.log("File generated and saved to Cloud Storage.");
    resolve({ "uploaded": true });
  });

  // pipe data through to Cloud Storage
  pdfReadStream.pipe(storageWriteStream);
  
  // finish the document
  pdfDoc.end();
});