'use strict'; const pluginDefs = require('./plugin_defs'); // Maps the name of a server-side hook to a string explaining the deprecation // (e.g., 'use the foo hook instead'). // // If you want to deprecate the fooBar hook, do the following: // // const hooks = require('ep_etherpad-lite/static/js/pluginfw/hooks'); // hooks.deprecationNotices.fooBar = 'use the newSpiffy hook instead'; // exports.deprecationNotices = {}; const deprecationWarned = {}; const checkDeprecation = (hook) => { const notice = exports.deprecationNotices[hook.hook_name]; if (notice == null) return; if (deprecationWarned[hook.hook_fn_name]) return; console.warn(`${hook.hook_name} hook used by the ${hook.part.plugin} plugin ` + `(${hook.hook_fn_name}) is deprecated: ${notice}`); deprecationWarned[hook.hook_fn_name] = true; }; // Calls the node-style callback when the Promise settles. Unlike util.callbackify, this takes a // Promise (rather than a function that returns a Promise), and it returns a Promise (rather than a // function that returns undefined). const attachCallback = (p, cb) => p.then( (val) => cb(null, val), // Callbacks often only check the truthiness, not the nullness, of the first parameter. To avoid // problems, always pass a truthy value as the first argument if the Promise is rejected. (err) => cb(err || new Error(err))); // Normalizes the value provided by hook functions so that it is always an array. `undefined` (but // not `null`!) becomes an empty array, array values are returned unmodified, and non-array values // are wrapped in an array (so `null` becomes `[null]`). const normalizeValue = (val) => { // `undefined` is treated the same as `[]`. IMPORTANT: `null` is *not* treated the same as `[]` // because some hooks use `null` as a special value. if (val === undefined) return []; if (Array.isArray(val)) return val; return [val]; }; // Flattens the array one level. const flatten1 = (array) => array.reduce((a, b) => a.concat(b), []); // Calls the hook function synchronously and returns the value provided by the hook function (via // callback or return value). // // A synchronous hook function can provide a value in these ways: // // * Call the callback, passing the desired value (which may be `undefined`) directly as the first // argument, then return `undefined`. // * For hook functions with three (or more) parameters: Directly return the desired value, which // must not be `undefined`. Note: If a three-parameter hook function directly returns // `undefined` and it has not already called the callback then it is indicating that it is not // yet done and will eventually call the callback. This behavior is not supported by synchronous // hooks. // * For hook functions with two (or fewer) parameters: Directly return the desired value (which // may be `undefined`). // // The callback passed to a hook function is guaranteed to return `undefined`, so it is safe for // hook functions to do `return cb(value);`. // // A hook function can signal an error by throwing. // // A hook function settles when it provides a value (via callback or return) or throws. If a hook // function attempts to settle again (e.g., call the callback again, or call the callback and also // return a value) then the second attempt has no effect except either an error message is logged or // there will be an unhandled promise rejection depending on whether the the subsequent attempt is a // duplicate (same value or error) or different, respectively. // // See the tests in src/tests/backend/specs/hooks.js for examples of supported and prohibited // behaviors. // const callHookFnSync = (hook, context) => { checkDeprecation(hook); // This var is used to keep track of whether the hook function already settled. let outcome; // This is used to prevent recursion. let doubleSettleErr; const settle = (err, val, how) => { doubleSettleErr = null; const state = err == null ? 'resolved' : 'rejected'; if (outcome != null) { // It was already settled, which indicates a bug. const action = err == null ? 'resolve' : 'reject'; const msg = (`DOUBLE SETTLE BUG IN HOOK FUNCTION (plugin: ${hook.part.plugin}, ` + `function name: ${hook.hook_fn_name}, hook: ${hook.hook_name}): ` + `Attempt to ${action} via ${how} but it already ${outcome.state} ` + `via ${outcome.how}. Ignoring this attempt to ${action}.`); console.error(msg); if (state !== outcome.state || (err == null ? val !== outcome.val : err !== outcome.err)) { // The second settle attempt differs from the first, which might indicate a serious bug. doubleSettleErr = new Error(msg); throw doubleSettleErr; } return; } outcome = {state, err, val, how}; if (val && typeof val.then === 'function') { console.error(`PROHIBITED PROMISE BUG IN HOOK FUNCTION (plugin: ${hook.part.plugin}, ` + `function name: ${hook.hook_fn_name}, hook: ${hook.hook_name}): ` + 'The hook function provided a "thenable" (e.g., a Promise) which is ' + 'prohibited because the hook expects to get the value synchronously.'); } }; // IMPORTANT: This callback must return `undefined` so that a hook function can safely do // `return callback(value);` for backwards compatibility. const callback = (ret) => { settle(null, ret, 'callback'); }; let val; try { val = hook.hook_fn(hook.hook_name, context, callback); } catch (err) { if (err === doubleSettleErr) throw err; // Avoid recursion. try { settle(err, null, 'thrown exception'); } catch (doubleSettleErr) { // Schedule the throw of the double settle error on the event loop via // Promise.resolve().then() (which will result in an unhandled Promise rejection) so that the // original error is the error that is seen by the caller. Fixing the original error will // likely fix the double settle bug, so the original error should get priority. Promise.resolve().then(() => { throw doubleSettleErr; }); } throw err; } // IMPORTANT: This MUST check for undefined -- not nullish -- because some hooks intentionally use // null as a special value. if (val === undefined) { if (outcome != null) return outcome.val; // Already settled via callback. if (hook.hook_fn.length >= 3) { console.error(`UNSETTLED FUNCTION BUG IN HOOK FUNCTION (plugin: ${hook.part.plugin}, ` + `function name: ${hook.hook_fn_name}, hook: ${hook.hook_name}): ` + 'The hook function neither called the callback nor returned a non-undefined ' + 'value. This is prohibited because it will result in freezes when a future ' + 'version of Etherpad updates the hook to support asynchronous behavior.'); } else { // The hook function is assumed to not have a callback parameter, so fall through and accept // `undefined` as the resolved value. // // IMPORTANT: "Rest" parameters and default parameters are not included in `Function.length`, // so the assumption does not hold for wrappers such as: // // const wrapper = (...args) => real(...args); // // ECMAScript does not provide a way to determine whether a function has default or rest // parameters, so there is no way to be certain that a hook function with `length` < 3 will // not call the callback. Synchronous hook functions that call the callback even though // `length` < 3 will still work properly without any logged warnings or errors, but: // // * Once the hook is upgraded to support asynchronous hook functions, calling the callback // asynchronously will cause a double settle error, and the hook function will prematurely // resolve to `undefined` instead of the desired value. // // * The above "unsettled function" warning is not logged if the function fails to call the // callback like it is supposed to. // // Wrapper functions can avoid problems by setting the wrapper's `length` property to match // the real function's `length` property: // // Object.defineProperty(wrapper, 'length', {value: real.length}); } } settle(null, val, 'returned value'); return outcome.val; }; // DEPRECATED: Use `callAllSerial()` or `aCallAll()` instead. // // Invokes all registered hook functions synchronously. // // Arguments: // * hookName: Name of the hook to invoke. // * context: Passed unmodified to the hook functions, except nullish becomes {}. // // Return value: // A flattened array of hook results. Specifically, it is equivalent to doing the following: // 1. Collect all values returned by the hook functions into an array. // 2. Convert each `undefined` entry into `[]`. // 3. Flatten one level. exports.callAll = (hookName, context) => { if (context == null) context = {}; const hooks = pluginDefs.hooks[hookName] || []; return flatten1(hooks.map((hook) => normalizeValue(callHookFnSync(hook, context)))); }; // Calls the hook function asynchronously and returns a Promise that either resolves to the hook // function's provided value or rejects with an error generated by the hook function. // // An asynchronous hook function can provide a value in these ways: // // * Call the callback, passing a Promise (or thenable) that resolves to the desired value (which // may be `undefined`) as the first argument. // * Call the callback, passing the desired value (which may be `undefined`) directly as the first // argument. // * Return a Promise (or thenable) that resolves to the desired value (which may be `undefined`). // * For hook functions with three (or more) parameters: Directly return the desired value, which // must not be `undefined`. Note: If a hook function directly returns `undefined` and it has not // already called the callback then it is indicating that it is not yet done and will eventually // call the callback. // * For hook functions with two (or fewer) parameters: Directly return the desired value (which // may be `undefined`). // // The callback passed to a hook function is guaranteed to return `undefined`, so it is safe for // hook functions to do `return cb(valueOrPromise);`. // // A hook function can signal an error in these ways: // // * Throw. // * Return a Promise that rejects. // * Pass a Promise that rejects as the first argument to the provided callback. // // A hook function settles when it directly provides a value, when it throws, or when the Promise it // provides settles (resolves or rejects). If a hook function attempts to settle again (e.g., call // the callback again, or return a value and also call the callback) then the second attempt has no // effect except either an error message is logged or an Error object is thrown depending on whether // the the subsequent attempt is a duplicate (same value or error) or different, respectively. // // See the tests in src/tests/backend/specs/hooks.js for examples of supported and prohibited // behaviors. // const callHookFnAsync = async (hook, context) => { checkDeprecation(hook); return await new Promise((resolve, reject) => { // This var is used to keep track of whether the hook function already settled. let outcome; const settle = (err, val, how) => { const state = err == null ? 'resolved' : 'rejected'; if (outcome != null) { // It was already settled, which indicates a bug. const action = err == null ? 'resolve' : 'reject'; const msg = (`DOUBLE SETTLE BUG IN HOOK FUNCTION (plugin: ${hook.part.plugin}, ` + `function name: ${hook.hook_fn_name}, hook: ${hook.hook_name}): ` + `Attempt to ${action} via ${how} but it already ${outcome.state} ` + `via ${outcome.how}. Ignoring this attempt to ${action}.`); console.error(msg); if (state !== outcome.state || (err == null ? val !== outcome.val : err !== outcome.err)) { // The second settle attempt differs from the first, which might indicate a serious bug. throw new Error(msg); } return; } outcome = {state, err, val, how}; if (err == null) { resolve(val); } else { reject(err); } }; // IMPORTANT: This callback must return `undefined` so that a hook function can safely do // `return callback(value);` for backwards compatibility. const callback = (ret) => { // Wrap ret in a Promise so that a hook function can do `callback(asyncFunction());`. Note: If // ret is a Promise (or other thenable), Promise.resolve() will flatten it into this new // Promise. Promise.resolve(ret).then( (val) => settle(null, val, 'callback'), (err) => settle(err, null, 'rejected Promise passed to callback')); }; let ret; try { ret = hook.hook_fn(hook.hook_name, context, callback); } catch (err) { try { settle(err, null, 'thrown exception'); } catch (doubleSettleErr) { // Schedule the throw of the double settle error on the event loop via // Promise.resolve().then() (which will result in an unhandled Promise rejection) so that // the original error is the error that is seen by the caller. Fixing the original error // will likely fix the double settle bug, so the original error should get priority. Promise.resolve().then(() => { throw doubleSettleErr; }); } throw err; } // IMPORTANT: This MUST check for undefined -- not nullish -- because some hooks intentionally // use null as a special value. if (ret === undefined) { if (hook.hook_fn.length >= 3) { // The hook function has a callback parameter and it returned undefined, which means the // hook function will settle (or has already settled) via the provided callback. return; } else { // The hook function is assumed to not have a callback parameter, so fall through and accept // `undefined` as the resolved value. // // IMPORTANT: "Rest" parameters and default parameters are not included in // `Function.length`, so the assumption does not hold for wrappers such as: // // const wrapper = (...args) => real(...args); // // ECMAScript does not provide a way to determine whether a function has default or rest // parameters, so there is no way to be certain that a hook function with `length` < 3 will // not call the callback. Hook functions with `length` < 3 that call the callback // asynchronously will cause a double settle error, and the hook function will prematurely // resolve to `undefined` instead of the desired value. // // Wrapper functions can avoid problems by setting the wrapper's `length` property to match // the real function's `length` property: // // Object.defineProperty(wrapper, 'length', {value: real.length}); } } // Wrap ret in a Promise so that hook functions can be async (or otherwise return a Promise). // Note: If ret is a Promise (or other thenable), Promise.resolve() will flatten it into this // new Promise. Promise.resolve(ret).then( (val) => settle(null, val, 'returned value'), (err) => settle(err, null, 'Promise rejection')); }); }; // Invokes all registered hook functions asynchronously and concurrently. This is NOT the async // equivalent of `callAll()`: `callAll()` calls the hook functions serially (one at a time) but this // function calls them concurrently. Use `callAllSerial()` if the hook functions must be called one // at a time. // // Arguments: // * hookName: Name of the hook to invoke. // * context: Passed unmodified to the hook functions, except nullish becomes {}. // * cb: Deprecated. Optional node-style callback. The following: // const p1 = hooks.aCallAll('myHook', context, cb); // is equivalent to: // const p2 = hooks.aCallAll('myHook', context).then( // (val) => cb(null, val), (err) => cb(err || new Error(err))); // // Return value: // If cb is nullish, this function resolves to a flattened array of hook results. Specifically, it // is equivalent to doing the following: // 1. Collect all values returned by the hook functions into an array. // 2. Convert each `undefined` entry into `[]`. // 3. Flatten one level. // If cb is non-null, this function resolves to the value returned by cb. exports.aCallAll = async (hookName, context, cb = null) => { if (cb != null) return await attachCallback(exports.aCallAll(hookName, context), cb); if (context == null) context = {}; const hooks = pluginDefs.hooks[hookName] || []; const results = await Promise.all( hooks.map(async (hook) => normalizeValue(await callHookFnAsync(hook, context)))); return flatten1(results); }; // Like `aCallAll()` except the hook functions are called one at a time instead of concurrently. // Only use this function if the hook functions must be called one at a time, otherwise use // `aCallAll()`. exports.callAllSerial = async (hookName, context) => { if (context == null) context = {}; const hooks = pluginDefs.hooks[hookName] || []; const results = []; for (const hook of hooks) { results.push(normalizeValue(await callHookFnAsync(hook, context))); } return flatten1(results); }; // DEPRECATED: Use `aCallFirst()` instead. // // Like `aCallFirst()`, but synchronous. Hook functions must provide their values synchronously. exports.callFirst = (hookName, context) => { if (context == null) context = {}; const predicate = (val) => val.length; const hooks = pluginDefs.hooks[hookName] || []; for (const hook of hooks) { const val = normalizeValue(callHookFnSync(hook, context)); if (predicate(val)) return val; } return []; }; // Invokes the registered hook functions one at a time until one provides a value that meets a // customizable condition. // // Arguments: // * hookName: Name of the hook to invoke. // * context: Passed unmodified to the hook functions, except nullish becomes {}. // * cb: Deprecated callback. The following: // const p1 = hooks.aCallFirst('myHook', context, cb); // is equivalent to: // const p2 = hooks.aCallFirst('myHook', context).then( // (val) => cb(null, val), (err) => cb(err || new Error(err))); // * predicate: Optional predicate function that returns true if the hook function provided a // value that satisfies a desired condition. If nullish, the predicate defaults to a non-empty // array check. The predicate is invoked each time a hook function returns. It takes one // argument: the normalized value provided by the hook function. If the predicate returns // truthy, iteration over the hook functions stops (no more hook functions will be called). // // Return value: // If cb is nullish, resolves to an array that is either the normalized value that satisfied the // predicate or empty if the predicate was never satisfied. If cb is non-nullish, resolves to the // value returned from cb(). exports.aCallFirst = async (hookName, context, cb = null, predicate = null) => { if (cb != null) { return await attachCallback(exports.aCallFirst(hookName, context, null, predicate), cb); } if (context == null) context = {}; if (predicate == null) predicate = (val) => val.length; const hooks = pluginDefs.hooks[hookName] || []; for (const hook of hooks) { const val = normalizeValue(await callHookFnAsync(hook, context)); if (predicate(val)) return val; } return []; }; exports.exportedForTestingOnly = { callHookFnAsync, callHookFnSync, deprecationWarned, };