Skip to content

Instantly share code, notes, and snippets.

@brianleroux
Created September 12, 2019 22:04
Show Gist options
  • Star 14 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save brianleroux/26e4d9795350047c6ae2efcbe57e1291 to your computer and use it in GitHub Desktop.
Save brianleroux/26e4d9795350047c6ae2efcbe57e1291 to your computer and use it in GitHub Desktop.
Lambda NodeJS 10.x Default Runtime JavaScript
/** Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. */
"use strict";
/**
* The runtime has a single beforeExit function which is stored in the global
* object with a symbol key.
* The symbol is not exported.
* The process.beforeExit listener is setup in index.js along with all other
* top-level process event listeners.
*/
// define a named symbol for the handler function
const LISTENER_SYMBOL = Symbol.for("aws.lambda.beforeExit");
const NO_OP_LISTENER = () => {};
// export a setter
module.exports = {
/**
* Call the listener function with no arguments.
*/
invoke: () => global[LISTENER_SYMBOL](),
/**
* Reset the listener to a no-op function.
*/
reset: () => (global[LISTENER_SYMBOL] = NO_OP_LISTENER),
/**
* Set the listener to the provided function.
*/
set: listener => (global[LISTENER_SYMBOL] = listener)
};
/**
* Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*/
"use strict";
let { logger, levels } = require("lambda-logging");
let BeforeExitListener = require("./BeforeExitListener.js");
function _homogeneousError(err) {
if (err instanceof Error) {
return err;
} else {
return new Error(err);
}
}
/**
* Build the callback function and the part of the context which exposes
* the succeed/fail/done callbacks.
* @param client {Client}
* The RAPID client used to post results/errors.
* @param id {string}
* The invokeId for the current invocation.
* @param scheduleNext {function}
* A function which takes no params and immediately schedules the next
* iteration of the invoke loop.
*/
function _rawCallbackContext(client, id, scheduleNext) {
const postError = (err, callback) => {
logger.putEvent(levels.ERROR, "Invoke Error", _homogeneousError(err));
client.postInvocationError(err, id, callback);
};
const complete = (result, callback) => {
client.postInvocationResponse(result, id, callback);
};
let waitForEmptyEventLoop = true;
let callback = function(err, result) {
BeforeExitListener.reset();
if (err !== undefined && err !== null) {
postError(err, scheduleNext);
} else {
complete(result, () => {
if (!waitForEmptyEventLoop) {
scheduleNext();
} else {
BeforeExitListener.set(scheduleNext);
}
});
}
};
let done = (err, result) => {
BeforeExitListener.reset();
if (err !== undefined && err !== null) {
postError(err, scheduleNext);
} else {
complete(result, scheduleNext);
}
};
let succeed = result => {
done(null, result);
};
let fail = err => {
if (err === undefined || err === null) {
done("handled");
} else {
done(err, null);
}
};
let callbackContext = {
get callbackWaitsForEmptyEventLoop() {
return waitForEmptyEventLoop;
},
set callbackWaitsForEmptyEventLoop(value) {
waitForEmptyEventLoop = value;
},
succeed: succeed,
fail: fail,
done: done
};
return [callback, callbackContext];
}
/**
* Wraps the callback and context so that only the first call to any callback
* succeeds.
* @param callback {function}
* the node-style callback function that was previously generated but not
* yet wrapped.
* @param callbackContext {object}
* The previously generated callbackContext object that contains
* getter/setters for the contextWaitsForEmptyeventLoop flag and the
* succeed/fail/done functions.
* @return [callback, context]
*/
function _wrappedCallbackContext(callback, callbackContext) {
let finished = false;
let onlyAllowFirstCall = function(toWrap) {
return function() {
if (!finished) {
toWrap.apply(null, arguments);
finished = true;
}
};
};
callbackContext.succeed = onlyAllowFirstCall(callbackContext.succeed);
callbackContext.fail = onlyAllowFirstCall(callbackContext.fail);
callbackContext.done = onlyAllowFirstCall(callbackContext.done);
return [onlyAllowFirstCall(callback), callbackContext];
}
/**
* Construct the base-context object which includes the required flags and
* callback methods for the Node programming model.
* @param client {Client}
* The RAPID client used to post results/errors.
* @param id {string}
* The invokeId for the current invocation.
* @param scheduleNext {function}
* A function which takes no params and immediately schedules the next
* iteration of the invoke loop.
* @return [callback, context]
* The same function and context object, but wrapped such that only the
* first call to any function will be successful. All subsequent calls are
* a no-op.
*/
module.exports.build = function(client, id, scheduleNext) {
let rawCallbackContext = _rawCallbackContext(client, id, scheduleNext);
return _wrappedCallbackContext(...rawCallbackContext);
};
/**
* Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Defines custom error types throwable by the runtime.
*/
"use strict";
const errorClasses = [
class ImportModuleError extends Error {},
class HandlerNotFound extends Error {},
class MalformedHandlerName extends Error {},
class UserCodeSyntaxError extends Error {},
class UnhandledPromiseRejection extends Error {
constructor(reason, promise) {
super(reason);
this.reason = reason;
this.promise = promise;
}
}
];
errorClasses.forEach(e => {
module.exports[e.name] = e;
e.prototype.name = `Runtime.${e.name}`;
});
/**
* Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* This module defines the InvokeContext and supporting functions. The
* InvokeContext is responsible for pulling information from the invoke headers
* and for wrapping the Rapid Client object's error and response functions.
*/
"use strict";
const assert = require("assert").strict;
const {
loggingContext,
types: { XRayTracingHeader }
} = require("lambda-logging");
const INVOKE_HEADER = {
ClientContext: "lambda-runtime-client-context",
CognitoIdentity: "lambda-runtime-cognito-identity",
ARN: "lambda-runtime-invoked-function-arn",
AWSRequestId: "lambda-runtime-aws-request-id",
DeadlineMs: "lambda-runtime-deadline-ms",
XRayTrace: "lambda-runtime-trace-id"
};
const XRAY_TRACE_HEADER_REGEX = /Root=(.*);Parent=(.*);Sampled=(.*)/;
module.exports = class InvokeContext {
constructor(headers) {
this.headers = _enforceLowercaseKeys(headers);
}
/**
* The invokeId for this request.
*/
get invokeId() {
let id = this.headers[INVOKE_HEADER.AWSRequestId];
assert.ok(id, "invocation id is missing or invalid");
return id;
}
/**
* Push relevant invoke data into the logging context.
*/
updateLoggingContext() {
loggingContext.requestId = this.invokeId;
loggingContext.xrayTracingHeader = undefined;
this._tryUpdateXRayTracingHeader();
}
_tryUpdateXRayTracingHeader() {
let tracingHeader = this.headers[INVOKE_HEADER.XRayTrace];
if (!tracingHeader) {
return;
}
try {
let matchObj = XRAY_TRACE_HEADER_REGEX.exec(tracingHeader);
loggingContext.xrayTracingHeader = new XRayTracingHeader(
matchObj[1],
matchObj[2],
matchObj[3] === "1"
);
} catch (err) {
/**
* no-op by design.
* Failure to parse the XRay header should not fail the user's function.
*/
}
}
/**
* Attach all of the relavant environmental and invocation data to the
* provided object.
* This method can throw if the headers are malformed and cannot be parsed.
* @param callbackContext {Object}
* The callbackContext object returned by a call to buildCallbackContext().
* @return {Object}
* The user context object with all required data populated from the headers
* and environment variables.
*/
attachEnvironmentData(callbackContext) {
this._forwardXRay();
return Object.assign(
callbackContext,
this._environmentalData(),
this._headerData()
);
}
/**
* All parts of the user-facing context object which are provided through
* environment variables.
*/
_environmentalData() {
return {
functionVersion: process.env["AWS_LAMBDA_FUNCTION_VERSION"],
functionName: process.env["AWS_LAMBDA_FUNCTION_NAME"],
memoryLimitInMB: process.env["AWS_LAMBDA_FUNCTION_MEMORY_SIZE"],
logGroupName: process.env["AWS_LAMBDA_LOG_GROUP_NAME"],
logStreamName: process.env["AWS_LAMBDA_LOG_STREAM_NAME"]
};
}
/**
* All parts of the user-facing context object which are provided through
* request headers.
*/
_headerData() {
const deadline = this.headers[INVOKE_HEADER.DeadlineMs];
return {
clientContext: _parseJson(
this.headers[INVOKE_HEADER.ClientContext],
"ClientContext"
),
identity: _parseJson(
this.headers[INVOKE_HEADER.CognitoIdentity],
"CognitoIdentity"
),
invokedFunctionArn: this.headers[INVOKE_HEADER.ARN],
awsRequestId: this.headers[INVOKE_HEADER.AWSRequestId],
getRemainingTimeInMillis: function() {
return deadline - Date.now();
}
};
}
/**
* Forward the XRay header into the environment variable.
*/
_forwardXRay() {
if (this.headers[INVOKE_HEADER.XRayTrace]) {
process.env["_X_AMZN_TRACE_ID"] = this.headers[INVOKE_HEADER.XRayTrace];
} else {
delete process.env["_X_AMZN_TRACE_ID"];
}
}
};
/**
* Parse a JSON string and throw a readable error if something fails.
* @param jsonString {string} - the string to attempt to parse
* @param name {name} - the name to use when describing the string in an error
* @return object - the parsed object
* @throws if jsonString cannot be parsed
*/
function _parseJson(jsonString, name) {
if (jsonString !== undefined) {
try {
return JSON.parse(jsonString);
} catch (err) {
throw new Error(`Cannot parse ${name} as json: ${err.toString()}`);
}
} else {
return undefined;
}
}
/**
* Construct a copy of an object such that all of its keys are lowercase.
*/
function _enforceLowercaseKeys(original) {
return Object.keys(original).reduce((enforced, originalKey) => {
enforced[originalKey.toLowerCase()] = original[originalKey];
return enforced;
}, {});
}
/** Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. */
"use strict";
const util = require("util");
const { logger, levels } = require("lambda-logging");
/**
* Patch the console to pass events to the putEvent api.
*/
module.exports.patchConsole = () => {
console.log = (msg, ...params) => {
logger.putEvent(levels.INFO, util.format(msg, ...params));
};
console.debug = (msg, ...params) => {
logger.putEvent(levels.DEBUG, util.format(msg, ...params));
};
console.info = (msg, ...params) => {
logger.putEvent(levels.INFO, util.format(msg, ...params));
};
console.warn = (msg, ...params) => {
logger.putEvent(levels.WARN, util.format(msg, ...params));
};
console.error = (msg, ...params) => {
logger.putEvent(levels.ERROR, util.format(msg, ...params));
};
console.trace = (msg, ...params) => {
logger.putEvent(levels.TRACE, util.format(msg, ...params));
};
console.fatal = (msg, ...params) => {
logger.putEvent(levels.FATAL, util.format(msg, ...params));
};
};
/**
* Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* This module defines the RAPID client which is responsible for all HTTP
* interactions with the RAPID layer.
*/
"use strict";
const util = require("util");
const ERROR_TYPE_HEADER = "Lambda-Runtime-Function-Error-Type";
/**
* Objects of this class are responsible for all interactions with the RAPID
* API.
*/
module.exports = class RAPIDClient {
constructor(hostnamePort, httpClient) {
this.http = httpClient || require("http");
let [hostname, port] = hostnamePort.split(":");
this.hostname = hostname;
this.port = parseInt(port, 10);
this.agent = new this.http.Agent({
keepAlive: true,
maxSockets: 1
});
}
/**
* Complete and invocation with the provided response.
* @param {Object} response
* An arbitrary object to convert to JSON and send back as as response.
* @param {String} id
* The invocation ID.
* @param (function()} callback
* The callback to run after the POST response ends
*/
postInvocationResponse(response, id, callback) {
this._post(
`/2018-06-01/runtime/invocation/${id}/response`,
response,
{},
callback
);
}
/**
* Post an initialization error to the RAPID API.
* @param {Error} error
* @param (function()} callback
* The callback to run after the POST response ends
*/
postInitError(error, callback) {
let response = _errorToResponse(error);
this._post(
`/2018-06-01/runtime/init/error`,
response,
{ [ERROR_TYPE_HEADER]: response.errorType },
callback
);
}
/**
* Post an invocation error to the RAPID API
* @param {Error} error
* @param {String} id
* The invocation ID for the in-progress invocation.
* @param (function()} callback
* The callback to run after the POST response ends
*/
postInvocationError(error, id, callback) {
let response = _errorToResponse(error);
this._post(
`/2018-06-01/runtime/invocation/${id}/error`,
response,
{ [ERROR_TYPE_HEADER]: response.errorType },
callback
);
}
/**
* Get the next invocation.
* @return {PromiseLike.<Object>}
* A promise which resolves to an invocation object that contains the body
* as json and the header array. e.g. {bodyJson, headers}
*/
nextInvocation() {
let options = {
hostname: this.hostname,
port: this.port,
path: "/2018-06-01/runtime/invocation/next",
method: "GET",
agent: this.agent
};
return new Promise((resolve, reject) => {
let request = this.http.request(options, response => {
let data = "";
response.setEncoding("utf-8");
response.on("data", chunk => {
data += chunk;
});
response.on("end", () => {
resolve({
bodyJson: data,
headers: response.headers
});
});
});
request.on("error", e => {
reject(e);
});
request.end();
});
}
/**
* HTTP Post to a path.
* @param {String} path
* @param {Object} body
* The body is serialized into JSON before posting.
* @param {Object} headers
* The http headers
* @param (function()} callback
* The callback to run after the POST response ends
*/
_post(path, body, headers, callback) {
let bodyString = _trySerializeResponse(body);
let options = {
hostname: this.hostname,
port: this.port,
path: path,
method: "POST",
headers: Object.assign(
{
"Content-Type": "application/json",
"Content-Length": Buffer.from(bodyString).length
},
headers || {}
),
agent: this.agent
};
let request = this.http.request(options, response => {
response.on("end", () => {
callback();
});
response.on("error", e => {
throw e;
});
response.on("data", () => {});
});
request.on("error", e => {
throw e;
});
request.end(bodyString, "utf-8");
}
};
/**
* Attempt to serialize an object as json. Capture the failure if it occurs and
* throw one that's known to be serializable.
*/
function _trySerializeResponse(body) {
try {
return JSON.stringify(body === undefined ? null : body);
} catch (err) {
throw new Error("Unable to stringify response body");
}
}
function _isError(obj) {
return (
obj &&
obj.name &&
obj.message &&
obj.stack &&
typeof obj.name === "string" &&
typeof obj.message === "string" &&
typeof obj.stack === "string"
);
}
/**
* Attempt to convert an object into a response object.
* This method accounts for failures when serializing the error object.
*/
function _errorToResponse(error) {
try {
if (util.types.isNativeError(error) || _isError(error)) {
return {
errorType: error.name,
errorMessage: error.message,
trace: error.stack.split("\n")
};
} else {
return {
errorType: typeof error,
errorMessage: error.toString(),
trace: []
};
}
} catch (_err) {
return {
errorType: "handled",
errorMessage:
"callback called with Error argument, but there was a problem while retrieving one or more of its message, name, and stack"
};
}
}
/**
* Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* This module defines the top-level Runtime class which controls the
* bootstrap's execution flow.
*/
"use strict";
const InvokeContext = require("./InvokeContext.js");
const CallbackContext = require("./CallbackContext.js");
const BeforeExitListener = require("./BeforeExitListener.js");
module.exports = class Runtime {
constructor(client, handler, errorCallbacks) {
this.client = client;
this.handler = handler;
this.errorCallbacks = errorCallbacks;
}
/**
* Schedule the next loop iteration to start at the beginning of the next time
* around the event loop.
*/
scheduleIteration() {
let that = this;
process.nextTick(() => {
that.handleOnce().then(
// Success is a no-op at this level. There are 2 cases:
// 1 - The user used one of the callback functions which already
// schedules the next iteration.
// 2 - The next iteration is not scheduled because the
// waitForEmptyEventLoop was set. In this case the beforeExit
// handler will automatically start the next iteration.
() => {},
// Errors should not reach this level in typical execution. If they do
// it's a sign of an issue in the Client or a bug in the runtime. So
// dump it to the console and attempt to report it as a Runtime error.
err => {
console.log(`Unexpected Top Level Error: ${err.toString()}`);
this.errorCallbacks.uncaughtException(err);
}
);
});
}
/**
* Wait for the next invocation, process it, and schedule the next iteration.
*/
async handleOnce() {
let { bodyJson, headers } = await this.client.nextInvocation();
let invokeContext = new InvokeContext(headers);
invokeContext.updateLoggingContext();
let [callback, callbackContext] = CallbackContext.build(
this.client,
invokeContext.invokeId,
this.scheduleIteration.bind(this)
);
try {
this._setErrorCallbacks(invokeContext.invokeId);
this._setDefaultExitListener(invokeContext.invokeId);
let result = this.handler(
JSON.parse(bodyJson),
invokeContext.attachEnvironmentData(callbackContext),
callback
);
if (_isPromise(result)) {
result.then(callbackContext.succeed, callbackContext.fail);
}
} catch (err) {
callback(err);
}
}
/**
* Replace the error handler callbacks.
* @param {String} invokeId
*/
_setErrorCallbacks(invokeId) {
this.errorCallbacks.uncaughtException = error => {
this.client.postInvocationError(error, invokeId, () => {
process.exit(129);
});
};
this.errorCallbacks.unhandledRejection = error => {
this.client.postInvocationError(error, invokeId, () => {
process.exit(128);
});
};
}
/**
* Setup the 'beforeExit' listener that is used if the callback is never
* called and the handler is not async.
* CallbackContext replaces the listener if a callback is invoked.
*/
_setDefaultExitListener(invokeId) {
BeforeExitListener.set(() => {
this.client.postInvocationResponse(null, invokeId, () =>
this.scheduleIteration()
);
});
}
};
function _isPromise(obj) {
return obj && obj.then && typeof obj.then === "function";
}
/**
* Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* This module defines the functions for loading the user's code as specified
* in a handler string.
*/
"use strict";
const {
HandlerNotFound,
MalformedHandlerName,
ImportModuleError,
UserCodeSyntaxError
} = require("./Errors.js");
const path = require("path");
const fs = require("fs");
const FUNCTION_EXPR = /^([^.]*)\.(.*)$/;
const RELATIVE_PATH_SUBSTRING = "..";
/**
* Break the full handler string into two pieces, the module root and the actual
* handler string.
* Given './somepath/something/module.nestedobj.handler' this returns
* ['./somepath/something', 'module.nestedobj.handler']
*/
function _moduleRootAndHandler(fullHandlerString) {
let handlerString = path.basename(fullHandlerString);
let moduleRoot = fullHandlerString.substring(
0,
fullHandlerString.indexOf(handlerString)
);
return [moduleRoot, handlerString];
}
/**
* Split the handler string into two pieces: the module name and the path to
* the handler function.
*/
function _splitHandlerString(handler) {
let match = handler.match(FUNCTION_EXPR);
if (!match || match.length != 3) {
throw new MalformedHandlerName("Bad handler");
}
return [match[1], match[2]]; // [module, function-path]
}
/**
* Resolve the user's handler function from the module.
*/
function _resolveHandler(object, nestedProperty) {
return nestedProperty.split(".").reduce((nested, key) => {
return nested && nested[key];
}, object);
}
/**
* Verify that the provided path can be loaded as a file per:
* https://nodejs.org/dist/latest-v10.x/docs/api/modules.html#modules_all_together
* @param string - the fully resolved file path to the module
* @return bool
*/
function _canLoadAsFile(modulePath) {
return fs.existsSync(modulePath) || fs.existsSync(modulePath + ".js");
}
/**
* Attempt to load the user's module.
* Attempts to directly resolve the module relative to the application root,
* then falls back to the more general require().
*/
function _tryRequire(appRoot, moduleRoot, module) {
let lambdaStylePath = path.resolve(appRoot, moduleRoot, module);
if (_canLoadAsFile(lambdaStylePath)) {
return require(lambdaStylePath);
} else {
// Why not just require(module)?
// Because require() is relative to __dirname, not process.cwd(). And the
// runtime implementation is not located in /var/task
let nodeStylePath = require.resolve(module, {
paths: [appRoot, moduleRoot]
});
return require(nodeStylePath);
}
}
/**
* Load the user's application or throw a descriptive error.
* @throws Runtime errors in two cases
* 1 - UserCodeSyntaxError if there's a syntax error while loading the module
* 2 - ImportModuleError if the module cannot be found
*/
function _loadUserApp(appRoot, moduleRoot, module) {
try {
return _tryRequire(appRoot, moduleRoot, module);
} catch (e) {
if (e instanceof SyntaxError) {
throw new UserCodeSyntaxError(e);
} else if (e.code !== undefined && e.code === "MODULE_NOT_FOUND") {
throw new ImportModuleError(e);
} else {
throw e;
}
}
}
function _throwIfInvalidHandler(fullHandlerString) {
if (fullHandlerString.includes(RELATIVE_PATH_SUBSTRING)) {
throw new MalformedHandlerName(
`'${fullHandlerString}' is not a valid handler name. Use absolute paths when specifying root directories in handler names.`
);
}
}
/**
* Load the user's function with the approot and the handler string.
* @param appRoot {string}
* The path to the application root.
* @param handlerString {string}
* The user-provided handler function in the form 'module.function'.
* @return userFuction {function}
* The user's handler function. This function will be passed the event body,
* the context object, and the callback function.
* @throws In five cases:-
* 1 - if the handler string is incorrectly formatted an error is thrown
* 2 - if the module referenced by the handler cannot be loaded
* 3 - if the function in the handler does not exist in the module
* 4 - if a property with the same name, but isn't a function, exists on the
* module
* 5 - the handler includes illegal character sequences (like relative paths
* for traversing up the filesystem '..')
* Errors for scenarios known by the runtime, will be wrapped by Runtime.* errors.
*/
module.exports.load = function(appRoot, fullHandlerString) {
_throwIfInvalidHandler(fullHandlerString);
let [moduleRoot, moduleAndHandler] = _moduleRootAndHandler(fullHandlerString);
let [module, handlerPath] = _splitHandlerString(moduleAndHandler);
let userApp = _loadUserApp(appRoot, moduleRoot, module);
let handlerFunc = _resolveHandler(userApp, handlerPath);
if (!handlerFunc) {
throw new HandlerNotFound(
`${fullHandlerString} is undefined or not exported`
);
}
if (typeof handlerFunc !== "function") {
throw new HandlerNotFound(`${fullHandlerString} is not a function`);
}
return handlerFunc;
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment