/**
 * This extension sets up a http-serving Cloud Function.
 *
 * The function reads all bundle specifications under a configured Firestore
 * collection, and serves requests for bundle files specified by the
 * specifications.
 */ import * as admin from "firebase-admin" ; import * as functions from "firebase-functions" ; import { Timestamp } from "@google-cloud/firestore" ; import { BundleSpec , build , ParamsSpec } from "./build_bundle" ; import { Storage } from "@google-cloud/storage" ; import { createGzip } from "zlib" ; const { Readable } = require ( "stream" ) ; const BUNDLESPEC_COLLECTION = process . env . BUNDLESPEC_COLLECTION || "bundles" ; const BUNDLE_STORAGE_BUCKET = process . env . BUNDLE_STORAGE_BUCKET || "bundle-server-files" ; const STORAGE_PREFIX = process . env . STORAGE_PREFIX || "bundles" ; admin . initializeApp ( ) ; const db = admin . firestore ( ) ; const bucket = new Storage ( ) . bucket ( BUNDLE_STORAGE_BUCKET ) ; const _waits = [ ] ; const _specs = { } ; let _specsReady = false ; function specsReady ( ) { _specsReady = true ; while ( _waits && _waits . length > 0 ) { ( _waits . pop ( ) || ( ( ) => { } ) ) ( ) ; } } function spec ( name ) { if ( _specsReady ) { return Promise . resolve ( _specs [ name ] || null ) ; } return new Promise ( ( resolve ) => { _waits . push ( ( ) => { resolve ( _specs [ name ] || null ) ; } ) ; } ) ; } function filterQuery ( qs , params ) { const out = { } ; for ( const k in qs ) { if ( params [ k ] ) out [ k ] = qs [ k ] ; } return out ; } function sortQuery ( qs ) { const arr = [ ] ; for ( const k in qs ) { arr . push ( [ k , qs [ k ] . toString ( ) ] . join ( "=" ) ) ; } return arr . sort ( ) . join ( "&" ) ; } function storagePath ( bundleId , query ) { return `${ STORAGE_PREFIX }/${ bundleId }?${ sortQuery ( query ) }` ; } /**
 * Returns a `ReadableStream` if a valid GCS file can be found for the given
 * bundle ID and query parameters.
 *
 * Returns null if the file and out-of-date or if an error occurs.
 */ async function fileCacheStream ( bundleId , query , options ) { const file = bucket . file ( storagePath ( bundleId , query ) ) ; try { const [ metadata ] = await file . getMetadata ( ) ; const createTime = new Date ( metadata . timeCreated ) . getTime ( ) ; if ( ( options . notBefore && createTime < options . notBefore . toMillis ( ) ) || Date . now ( ) > createTime + options . ttlSec * 1000 ) { file . delete ( ) ; return null ; } return file . createReadStream ( { decompress : ! options . gzip } ) ; } catch ( e ) { functions . logger . error ( "getMetadata error:" , e . message , e . code ) ; return null ; } } db . collection ( BUNDLESPEC_COLLECTION ) . onSnapshot ( ( snap ) => { snap . docs . forEach ( ( doc ) => { _specs [ doc . id ] = doc . data ( ) ; } ) ; specsReady ( ) ; } ) ; /**
 * Cloud Function to serve bundle building http requests.
 *
 * The last path segment of the http request will be the bundle ID to look for, and the
 * request query parameters will be the values to use to parameterize the bundle
 * specification.
 *
 * If the bundle specification has `fileCache` == true, it would first check if
 * there is a valid bundle file saved in GCS, and return that if yes. It would
 * save the built bundle file GCS, if a valid bundle file could not be found.
 */ export const serve = functions . handler . https . onRequest ( async ( req , res ) : Promise < any > => { functions . logger . debug ( "accept-encoding:" , req . get ( "accept-encoding" ) , req . headers ) ; const canGzip = req . get ( "accept-encoding" ) ?. includes ( "gzip" ) || false ; if ( canGzip ) { res . set ( "content-encoding" , "gzip" ) ; } functions . logger . debug ( "canGzip:" , canGzip ) ; const parts = req . path . split ( "/" ) ; const bundleId = parts [ parts . length - 1 ] ; const bundleSpec = await spec ( bundleId ) ; functions . logger . debug ( "spec:" , bundleSpec ) ; if ( ! bundleSpec ) { res . status ( 404 ) . send ( "Could not find bundle with ID " + bundleId ) ; return ; } const paramValues = filterQuery ( req . query , bundleSpec . params || { } ) ; if ( bundleSpec . serverCache || bundleSpec . clientCache ) { const maxAgeString = bundleSpec . clientCache ? `, max-age=${ bundleSpec . clientCache }` : "" ; const sMaxAgeString = bundleSpec . serverCache ? `, s-maxage=${ bundleSpec . serverCache }` : "" ; res . set ( "cache-control" , `public${ maxAgeString }${ sMaxAgeString }` ) ; } if ( bundleSpec . fileCache ) { functions . logger . debug ( "handling fileCache" , bundleSpec . fileCache ) ; const outStream = await fileCacheStream ( bundleId , paramValues , { ttlSec : bundleSpec . fileCache , notBefore : bundleSpec . notBefore , gzip : canGzip , } ) ; if ( outStream ) { functions . logger . debug ( "fileCache HIT" ) ; return outStream . pipe ( res ) ; } } const gzip = createGzip ( { level : 9 } ) ; try { let stream = Readable . from ( ( await build ( db , bundleId , bundleSpec , paramValues ) ) . build ( ) ) ; if ( canGzip ) { stream = stream . pipe ( gzip ) ; } if ( bundleSpec . fileCache ) { functions . logger . debug ( "streaming to GCS..." ) ; let storageStream = stream ; if ( ! canGzip ) { storageStream = stream . pipe ( gzip ) ; } storageStream . pipe ( bucket . file ( storagePath ( bundleId , paramValues ) ) . createWriteStream ( { metadata : { contentEncoding : "gzip" } } ) ) ; } return stream . pipe ( res ) ; } catch ( e ) { functions . logger . error ( e ) ; res . status ( 500 ) . send ( e . message ) ; } } ) ;