node.jsfirebasegoogle-cloud-functionsgoogle-cloud-storagedocxtemplater

How to get an docx file generated in node saved to firebase storage


Hi I am quite new to docxtemplater but I absolutely love how it works. Right now I seem to be able to generate a new docx document as follows:

const functions = require('firebase-functions');
const admin = require('firebase-admin');
const {Storage} = require('@google-cloud/storage');
var PizZip = require('pizzip');
var Docxtemplater = require('docxtemplater');
admin.initializeApp();
const BUCKET = 'gs://myapp.appspot.com';

exports.test2 = functions.https.onCall((data, context) => {
// The error object contains additional information when logged with JSON.stringify (it contains a properties object containing all suberrors).
function replaceErrors(key, value) {
    if (value instanceof Error) {
        return Object.getOwnPropertyNames(value).reduce(function(error, key) {
            error[key] = value[key];
            return error;
        }, {});
    }
    return value;
}

function errorHandler(error) {
    console.log(JSON.stringify({error: error}, replaceErrors));

    if (error.properties && error.properties.errors instanceof Array) {
        const errorMessages = error.properties.errors.map(function (error) {
            return error.properties.explanation;
        }).join("\n");
        console.log('errorMessages', errorMessages);
        // errorMessages is a humanly readable message looking like this :
        // 'The tag beginning with "foobar" is unopened'
    }
    throw error;
}


let file_name = 'example.docx';// this is the file saved in my firebase storage
const File = storage.bucket(BUCKET).file(file_name);
const read = File.createReadStream();

var buffers = [];
readable.on('data', (buffer) => {
  buffers.push(buffer);
});

readable.on('end', () => {
  var buffer = Buffer.concat(buffers);  
  var zip = new PizZip(buffer);
  var doc;
  try {
      doc = new Docxtemplater(zip);
      doc.setData({
    first_name: 'Fred',
    last_name: 'Flinstone',
    phone: '0652455478',
    description: 'Web app'
});
try {
   
    doc.render();
 doc.pipe(remoteFile2.createReadStream());

}
catch (error) {
    errorHandler(error);
}

  } catch(error) {
      errorHandler(error);
  }

});
});

My issue is that i keep getting an error that doc.pipe is not a function. I am quite new to nodejs but is there a way to have the newly generated doc after doc.render() to be saved directly to the firebase storage?


Solution

  • Taking a look at the type of doc, we find that is a Docxtemplater object and find that doc.pipe is not a function of that class. To get the file out of Docxtemplater, we need to use doc.getZip() to return the file (this will be either a JSZip v2 or Pizzip instance based on what we passed to the constructor). Now that we have the zip's object, we need to generate the binary data of the zip - which is done using generate({ type: 'nodebuffer' }) (to get a Node.JS Buffer containing the data). Unfortunately because the docxtemplater library doesn't support JSZip v3+, we can't make use of the generateNodeStream() method to get a stream to use with pipe().

    With this buffer, we can either reupload it to Cloud Storage or send it back to the client that is calling the function.

    The first option is relatively simple to implement:

    import { v4 as uuidv4 } from 'uuid';
    /* ... */
    
    const contentBuffer = doc.getZip()
          .generate({type: 'nodebuffer'});
    const targetName = "compiled.docx";
      
    const targetStorageRef = admin.storage().bucket()
      .file(targetName);
    await targetStorageRef.save(contentBuffer);
    
    // send back the bucket-name pair to the caller
    return { bucket: targetBucket, name: targetName };
    

    However, to send back the file itself to the client isn't as easy because this involves switching to using a HTTP Event Function (functions.https.onRequest) because a Callable Cloud Function can only return JSON-compatible data. Here we have a middleware function that takes a callable's handler function but supports returning binary data to the client.

    import * as functions from "firebase-functions";
    import * as admin from "firebase-admin";
    import corsInit from "cors";
    
    admin.initializeApp();
    
    const cors = corsInit({ origin: true }); // TODO: Tighten
    
    function callableRequest(handler) {
      if (!handler) {
        throw new TypeError("handler is required");
      }
      
      return (req, res) => {
        cors(req, res, (corsErr) => {
          if (corsErr) {
            console.error("Request rejected by CORS", corsErr);
            res.status(412).json({ error: "cors", message: "origin rejected" });
            return;
          }
    
          // for validateFirebaseIdToken, see https://github.com/firebase/functions-samples/blob/main/authorized-https-endpoint/functions/index.js
          validateFirebaseIdToken(req, res, () => { // validateFirebaseIdToken won't pass errors to `next()`
    
            try {
              const data = req.body;
              const context = {
                auth: req.user ? { token: req.user, uid: req.user.uid } : null,
                instanceIdToken: req.get("Firebase-Instance-ID-Token"); // this is used with FCM
                rawRequest: req
              };
    
              let result: any = await handler(data, context);
    
              if (result && typeof result === "object" && "buffer" in result) {
                res.writeHead(200, [
                  ["Content-Type", res.contentType],
                  ["Content-Disposition", "attachment; filename=" + res.filename]
                ]);
                
                res.end(result.buffer);
              } else {
                result = functions.https.encode(result);
    
                res.status(200).send({ result });
              }
            } catch (err) {
              if (!(err instanceof HttpsError)) {
                // This doesn't count as an 'explicit' error.
                console.error("Unhandled error", err);
                err = new HttpsError("internal", "INTERNAL");
              }
    
              const { status } = err.httpErrorCode;
              const body = { error: err.toJSON() };
    
              res.status(status).send(body);
            }
          });
        });
      };
    })
    
    functions.https.onRequest(callableRequest(async (data, context) => {
      /* ... */
    
      const contentBuffer = doc.getZip()
          .generate({type: "nodebuffer"});
      const targetName = "compiled.docx";
    
      return {
        buffer: contentBuffer,
        contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
        filename: targetName
      }
    }));
    

    In your current code, there are a number of odd segments where you have nested try-catch blocks and variables in different scopes. To help combat this, we can make use of File#download() that returns a Promise that resolves with the file contents in a Node.JS Buffer and File#save() that returns a Promise that resolves when the given Buffer is uploaded.

    Rolling this together for reuploading to Cloud Storage gives:

    // This code is based off the examples provided for docxtemplater
    // Copyright (c) Edgar HIPP [Dual License: MIT/GPLv3]
    
    import * as functions from "firebase-functions";
    import * as admin from "firebase-admin";
    import PizZip from "pizzip";
    import Docxtemplater from "docxtemplater";
    
    admin.initializeApp();
    
    // The error object contains additional information when logged with JSON.stringify (it contains a properties object containing all suberrors).
    function replaceErrors(key, value) {
      if (value instanceof Error) {
        return Object.getOwnPropertyNames(value).reduce(
          function (error, key) {
            error[key] = value[key];
            return error;
          },
          {}
        );
      }
      return value;
    }
    
    function errorHandler(error) {
      console.log(JSON.stringify({ error: error }, replaceErrors));
    
      if (error.properties && error.properties.errors instanceof Array) {
        const errorMessages = error.properties.errors
          .map(function (error) {
            return error.properties.explanation;
          })
          .join("\n");
        console.log("errorMessages", errorMessages);
        // errorMessages is a humanly readable message looking like this :
        // 'The tag beginning with "foobar" is unopened'
      }
      throw error;
    }
    
    exports.test2 = functions.https.onCall(async (data, context) => {
      const file_name = "example.docx"; // this is the file saved in my firebase storage
      const templateRef = await admin.storage().bucket()
          .file(file_name);
      const template_content = (await templateRef.download())[0];
      const zip = new PizZip(template_content);
    
      let doc;
      try {
        doc = new Docxtemplater(zip);
      } catch (error) {
        // Catch compilation errors (errors caused by the compilation of the template : misplaced tags)
        errorHandler(error);
      }
    
      doc.setData({
        first_name: "Fred",
        last_name: "Flinstone",
        phone: "0652455478",
        description: "Web app",
      });
    
      try {
        doc.render();
      } catch (error) {
        errorHandler(error);
      }
    
      const contentBuffer = doc.getZip().generate({ type: "nodebuffer" });
    
      // do something with contentBuffer
      // e.g. reupload to Cloud Storage
      const targetStorageRef = admin.storage().bucket().file("compiled.docx");
      await targetStorageRef.save(contentBuffer);
    
      return { bucket: targetStorageRef.bucket.name, name: targetName };
    });
    

    In addition to returning a bucket-name pair to the caller, you may also consider returning an access URL to the caller. This could be a signed url that can last for up to 7 days, a download token URL (like getDownloadURL(), process described here) that can last until the token is revoked, Google Storage URI (gs://BUCKET_NAME/FILE_NAME) (not an access URL, but can be passed to a client SDK that can access it if the client passes storage security rules) or access it directly using its public URL (after the file has been marked public).

    Based on the above code, you should be able to merge in returning the file directly yourself.