Last active
November 4, 2017 06:29
-
-
Save samthecodingman/3cb124bfdb6374182d15bb1e717d67b0 to your computer and use it in GitHub Desktop.
Defines a helper class to create Firebase Functions that handle recurring tasks that are triggered using the Firebase Realtime Database.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/*! adminTaskFunction.js | Samuel Jones 2017 | MIT License | github.com/samthecodingman */ | |
/** | |
* @file Defines a helper class to create Firebase Functions that handle recurring tasks that are triggered using the Firebase Realtime Database. | |
* @author Samuel Jones (github.com/samthecodingman) | |
*/ | |
const functions = require('firebase-functions') | |
const lodashGet = require('lodash').get; | |
// get this file at https://gist.github.com/samthecodingman/54661827f6ceeb2d44afc1c9b2c285b7 | |
const app = require('./adminWorkerApp') | |
/** | |
* Convienience wrapper around `functions.config()` to extract sub-properties. | |
* @param {String} path - the desired key path | |
* @param {*} defaultVal - the fallback value to be returned instead of `undefined` | |
* @return {*} the desired value, given default value or `undefined`. Normally is a string. | |
*/ | |
function getEnv(path, defaultVal) { | |
return lodashGet(functions.config(), path, defaultVal) | |
} | |
// declare database structure | |
const ADMIN_TASKS_TABLE = '/AdminTasks/Tasks' | |
const ADMIN_TASKS_HISTORY_TABLE = '/AdminTasks/TaskHistory' | |
/** | |
* Event handler. | |
* @typedef {DatabaseEventHandler} | |
* @type {Callback} | |
* @param {Event<DeltaSnapshot>} - Object containing information for this | |
* Firebase Realtime Database event. | |
*/ | |
/** | |
* Admin Task Handler. | |
* | |
* If the returned value is `true` (or returned promise resolves to `true`), | |
* the task will be considered incomplete and the task will be retriggered. | |
* @typedef {TaskTriggerHandler} | |
* @type {Callback} | |
* @param {Object} - the configuration object for this admin task | |
* @return {(Promise<any>|any)} - value (or resolving promise containing a | |
* value) indicating if work is incomplete. | |
*/ | |
/** | |
* Builder used to create Task Cloud Functions triggered by updates to the | |
* Firebase Realtime Database. | |
* @type {Object} | |
*/ | |
class TaskBuilder { | |
/** | |
* Creates a new TaskBuilder instance | |
* @param {String} taskName - the name of the task | |
*/ | |
constructor (taskName) { | |
this.name = taskName | |
} | |
/** | |
* Creates a new history entry. | |
* @return {Promise<String>} - a Promise containing the new run ID. | |
*/ | |
saveTaskStart () { | |
let thenRef = app.database() | |
.ref(`${ADMIN_TASKS_HISTORY_TABLE}/${this.name}`) | |
.push({ | |
'start': (new Date()).toISOString() | |
}) | |
return thenRef.then(() => thenRef.key) | |
} | |
/** | |
* Saves the end of the task chain. | |
* @param {String} runID - the current run ID. | |
* @return {Promise} - a promise that resolves when the wrtie completes. | |
*/ | |
saveTaskEnd (runID) { | |
let thisPath = `${ADMIN_TASKS_TABLE}/${this.name}` | |
let histRunPath = `${ADMIN_TASKS_HISTORY_TABLE}/${this.name}/${runID}` | |
let updateData = {} | |
updateData[thisPath + '/activeRunID'] = null // delete run id entry | |
updateData[thisPath + '/trigger'] = false // reset task state | |
updateData[histRunPath + '/finish'] = (new Date()).toISOString() | |
return app.database().ref().update(updateData) | |
} | |
/** | |
* Reinvoke this task chain. | |
* @param {String} runID - the current run ID. | |
* @param {Number} depth - the current task depth | |
* @return {Promise} - a promise that resolves when the write completes. | |
*/ | |
reinvokeTask (runID, depth) { | |
let ref = app.database().ref(`${ADMIN_TASKS_TABLE}/${this.name}`) | |
let updateData = { 'trigger': depth + 1 } | |
if (runID) updateData.activeRunID = runID | |
return ref.update(updateData) | |
} | |
/** | |
* Event handler that fires every time an admin task is triggered in the | |
* Firebase Realtime Database. | |
* @param {TaskTriggerHandler} handler - the admin task handler | |
* @return {CloudFunction} - a preconfigured Database Cloud Function that | |
* triggers this task. | |
*/ | |
onTrigger (handler) { | |
if (typeof handler !== 'function') { | |
throw new TypeError('handler must be a non-null function.') | |
} | |
return functions.database | |
.ref(`${ADMIN_TASKS_TABLE}/${this.name}`) | |
.onUpdate((event) => { | |
let deltaSnap = event.data | |
let state = deltaSnap.child('trigger').val() | |
let taskName = this.name | |
// state is falsy --> task was completed, no further action needed. | |
if (!state) { | |
console.log(`AdminTask[${taskName}]: No action needed.`) | |
return | |
} | |
let depth = (state === true) ? 1 : parseInt(state) | |
if (isNaN(depth)) { | |
return Promise.reject(new Error('tasks/invalid-task-state')) | |
} | |
let configRefAsApp = app.database().ref(`${ADMIN_TASKS_TABLE}/${taskName}/config`) | |
let currData = deltaSnap.val() | |
let config = currData.config || {} | |
if (!config.maxDepth) { | |
config.maxDepth = getEnv('management.maxtaskdepth', 10) | |
} | |
if (depth > config.maxDepth) { // fail fast | |
return Promise.reject(new Error('tasks/task-limit-reached')) | |
} | |
if (!config.maxTaskSecs) { | |
config.maxTaskSecs = getEnv('management.maxtaskduration', 50) | |
} | |
defineGetter(config, 'app', () => app) | |
defineGetter(config, 'ref', () => configRefAsApp) | |
defineGetter(config, 'event', () => event) | |
defineGetter(config, 'depth', () => depth) | |
let metaDataTask = state !== true | |
? Promise.resolve() : this.saveTaskStart() | |
let handlerTask = Promise.resolve() | |
.then(() => handler(config)) // casts result to Promise | |
return Promise.all([metaDataTask, handlerTask]) | |
.then((results) => { | |
let runID = results[0] // metaDataTask result | |
if (results[1] === true) { // handlerTask result | |
console.log(`AdminTask[${taskName}][${depth}]: Reinvoking...`) | |
return this.reinvokeTask(runID, depth) | |
} else { | |
console.log(`AdminTask[${taskName}][${depth}]: Completed.`) | |
return this.saveTaskEnd(runID || currData.activeRunID) | |
} | |
}) | |
}) | |
} | |
} | |
/** | |
* Initializes a new TaskBuilder instance for the given task. | |
* @param {String} taskName - the name of the task | |
* @return {TaskBuilder} - the task cloud function builder | |
*/ | |
exports.task = function createTaskBuilder (taskName) { | |
if (!process.env.FUNCTION_NAME) { // Type check when offline | |
if (!taskName) { | |
throw new TypeError('taskName cannot be falsy.') | |
} | |
if (taskName.trim() !== taskName) { | |
throw new TypeError( | |
'taskName should not contain leading/trailing whitespace.') | |
} | |
if (/\.|\$|\[|\]|#|\//g.test(taskName)) { | |
throw new Error(`Invalid task name ${taskName} (cannot contain .$[]#/)`) | |
} | |
} | |
return new TaskBuilder(taskName) | |
} | |
/** | |
* Helper function for creating a getter on an object. | |
* | |
* @param {Object} obj | |
* @param {String} name | |
* @param {Function} getter | |
* @private | |
* @author expressjs/express | |
*/ | |
function defineGetter(obj, name, getter) { | |
Object.defineProperty(obj, name, { | |
configurable: false, | |
enumerable: true, | |
get: getter | |
}) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Recurring Admin Tasks using Firebase Functions
Purpose
This script allows you to define a task that performs some administrative action inside of a Firebase Cloud Function. The task can be triggered using the Firebase Realtime Database. Using the RTDB allows you to make use of the RTDB's security rules to secure your function. This prevents potential abuse of your functions like that encountered using Authorized HTTPS Endpoints where a malicious user could hammer the endpoint with hundreds of invalid requests causing your wallet to suffer.
Prerequisites
Securing your Realtime Database
The following rules configuration is recommended to maintain and trigger the tasks. It assumes that you will use
adminWorkerApp.js
to generate the admin worker.Defining a Task
The
config
objectThe
config
variable is an object containing data at/AdminTasks/Tasks/{taskName}/config
in addition to:app
1: a read-only handle to theFirebaseApp
instance linked with this task.ref
1: a read-only handle to theDatabaseReference
for this configuration object.depth
1: a read-only value indicating the current level of recursion.event
1: a read-only handle to the triggering event'sEvent<DeltaSnapshot>
.Note: The
DeltaSnapshot
points to the location/AdminTasks/Tasks/{taskName}
maxTaskSecs
: a value indicating how long this task iteration should be allowed to run.Default:
functions.config().management.maxtaskduration
or50
if not specified.maxDepth
: a value indicating how many times this task can trigger itself.Default:
functions.config().management.maxtaskdepth
or10
if not specified.1: These values will shadow (ignore) those specified in
/AdminTasks/Tasks/{taskName}/config
Usage
Somewhere in your Firebase Functions code, declare your admin task:
Triggering a Task
To trigger a task function, write
true
to the location/AdminTasks/Tasks/{taskName}/trigger
.Using the Firebase Console
Know your project ID? Just replace it in this URL:
https://console.firebase.google.com/u/0/project/PROJECTID/database/data/AdminTasks/Tasks
Alternatively,
AdminTasks/Tasks
At that location, type
true
for the value oftrigger
under the desired task. (e.g. at/AdminTasks/Tasks/{taskName}/trigger
)Using a Client SDK
This must be done by a user with the custom claim
isAdmin
if using the above security rules. See the docs for more information on how to do this.Example
Implementing the
reap()
function ofconnect-session-firebase
as an administrator task. The task will iterate through the values of the table/sessions
and mark each expired session for removal as applicable. If the function runs longer than 50 seconds, it will store the last checked key, commit the current changes and then reinvoke itself (from insideadminTaskFunction.js
).