serverless-offline
Version:
Emulate AWS λ and API Gateway locally when developing your Serverless project
565 lines (446 loc) • 22.8 kB
JavaScript
'use strict';
module.exports = S => {
require('coffee-script/register');
const fs = require('fs');
const path = require('path');
const Hapi = require('hapi');
const isPlainObject = require('lodash.isplainobject');
const debugLog = require('./debugLog');
const serverlessLog = S.config && S.config.serverlessPath ?
require(path.join(S.config.serverlessPath, 'utils', 'cli')).log :
console.log.bind(null, 'Serverless:');
const jsonPath = require('./jsonPath');
const createLambdaContext = require('./createLambdaContext');
const createVelocityContext = require('./createVelocityContext');
const renderVelocityTemplateObject = require('./renderVelocityTemplateObject');
function logPluginIssue() {
serverlessLog('If you think this is an issue with the plugin please submit it, thanks!');
serverlessLog('https://github.com/dherault/serverless-offline/issues');
}
return class Offline extends S.classes.Plugin {
static getName() {
return 'serverless-offline';
}
registerActions() {
S.addAction(this.start.bind(this), {
handler: 'start',
description: 'Simulates API Gateway to call your lambda functions offline',
context: 'offline',
contextAction: 'start',
options: [
{
option: 'prefix',
shortcut: 'p',
description: 'Adds a prefix to every path, to send your requests to http://localhost:3000/prefix/[your_path] instead.'
},
{
option: 'port',
shortcut: 'P',
description: 'Port to listen on. Default: 3000'
},
{
option: 'stage',
shortcut: 's',
description: 'The stage used to populate your templates. Default: the first stage found in your project'
},
{
option: 'region',
shortcut: 'r',
description: 'The region used to populate your templates. Default: the first region for the first stage found.'
},
{
option: 'skipCacheInvalidation',
shortcut: 'c',
description: 'Tells the plugin to skip require cache invalidation. A script reloading tool like Nodemon might then be needed'
},
{
option: 'httpsProtocol',
shortcut: 'H',
description: 'To enable HTTPS, specify directory (relative to your cwd, typically your project dir) for both cert.pem and key.pem files.'
}
]
});
return Promise.resolve();
}
registerHooks() {
return Promise.resolve();
}
start(optionsAndData) {
// this._logAndExit(optionsAndData);
const version = S._version;
if (!version.startsWith('0.5')) {
serverlessLog(`Offline requires Serverless v0.5.x but found ${version}. Exiting.`);
process.exit(0);
}
process.env.IS_OFFLINE = true;
this.envVars = {};
this.project = S.getProject();
this.requests = {}; // Will store the state of each request
this._setOptions();
this._registerBabel();
this._createServer();
this._createRoutes();
this._listen();
}
_setOptions() {
if (!S.cli || !S.cli.options) throw new Error('Offline could not load options from Serverless');
const userOptions = S.cli.options;
const stages = this.project.stages;
const stagesKeys = Object.keys(stages);
if (!stagesKeys.length) {
serverlessLog('Offline could not find a default stage for your project: it looks like your _meta folder is empty. If you cloned your project using git, try "sls project init" to recreate your _meta folder');
process.exit(0);
}
this.options = {
port: userOptions.port || 3000,
prefix: userOptions.prefix || '/',
stage: userOptions.stage || stagesKeys[0],
skipCacheInvalidation: userOptions.skipCacheInvalidation || false,
httpsProtocol: userOptions.httpsProtocol || '',
};
const stageVariables = stages[this.options.stage];
this.options.region = userOptions.region || Object.keys(stageVariables.regions)[0];
// Prefix must start and end with '/'
if (!this.options.prefix.startsWith('/')) this.options.prefix = '/' + this.options.prefix;
if (!this.options.prefix.endsWith('/')) this.options.prefix += '/';
this.globalBabelOptions = ((this.project.custom || {})['serverless-offline'] || {}).babelOptions;
this.velocityContextOptions = {
stageVariables,
stage: this.options.stage,
};
serverlessLog(`Starting Offline: ${this.options.stage}/${this.options.region}.`);
debugLog('options:', this.options);
debugLog('globalBabelOptions:', this.globalBabelOptions);
}
_registerBabel(isBabelRuntime, babelRuntimeOptions) {
const options = isBabelRuntime ?
babelRuntimeOptions || { presets: ['es2015'] } :
this.globalBabelOptions;
if (options) {
debugLog('Setting babel register:', options);
if (!this.babelRegister) {
debugLog('For the first time');
this.babelRegister = require('babel-register');
}
this.babelRegister(options);
}
}
_createServer() {
this.server = new Hapi.Server({
connections: {
router: {
stripTrailingSlash: true // removes trailing slashes on incoming paths.
}
}
});
const connectionOptions = { port: this.options.port };
const httpsDir = this.options.httpsProtocol;
if (typeof httpsDir === 'string' && httpsDir.length > 0) connectionOptions.tls = {
key: fs.readFileSync(path.resolve(httpsDir, 'key.pem'), 'ascii'),
cert: fs.readFileSync(path.resolve(httpsDir, 'cert.pem'), 'ascii')
};
this.server.connection(connectionOptions);
}
_createRoutes() {
const functions = this.project.getAllFunctions();
const defaultContentType = 'application/json';
functions.forEach(fun => {
// Runtime checks
// No python :'(
const funRuntime = fun.runtime;
if (funRuntime !== 'nodejs' && funRuntime !== 'babel') return;
// Templates population (with project variables)
let populatedFun;
try {
populatedFun = fun.toObjectPopulated({
stage: this.options.stage,
region: this.options.region,
});
}
catch(err) {
serverlessLog(`Error while populating function '${fun.name}' with stage '${this.options.stage}' and region '${this.options.region}':`);
this._logAndExit(err.stack);
}
const funName = fun.name;
const handlerParts = fun.handler.split('/').pop().split('.');
const handlerPath = fun.getRootPath(handlerParts[0]);
const funTimeout = fun.timeout ? fun.timeout * 1000 : 6000;
const funBabelOptions = ((fun.custom || {}).runtime || {}).babel;
console.log();
debugLog(funName, 'runtime', funRuntime, funBabelOptions || '');
serverlessLog(`Routes for ${funName}:`);
// Add a route for each endpoint
populatedFun.endpoints.forEach(endpoint => {
let firstCall = true;
const epath = endpoint.path;
const method = endpoint.method.toUpperCase();
const requestTemplates = endpoint.requestTemplates;
// Prefix must start and end with '/' BUT path must not end with '/'
let path = this.options.prefix + (epath.startsWith('/') ? epath.slice(1) : epath);
if (path !== '/' && path.endsWith('/')) path = path.slice(0, -1);
serverlessLog(`${method} ${path}`);
// Route configuration
const config = { cors: true };
// When no content-type is provided on incomming requests, APIG sets 'application/json'
if (method !== 'GET' && method !== 'HEAD') config.payload = { override: defaultContentType };
this.server.route({
method,
path,
config,
handler: (request, reply) => {
console.log();
serverlessLog(`${method} ${request.url.path} (λ: ${funName})`);
if (firstCall) {
serverlessLog('The first request might take a few extra seconds');
firstCall = false;
}
// Shared mutable state is the root of all evil
const requestId = Math.random().toString().slice(2);
this.requests[requestId] = { done: false };
this.currentRequestId = requestId;
// Holds the response to do async op
const response = reply.response().hold();
const contentType = request.mime || defaultContentType;
const requestTemplate = requestTemplates[contentType];
debugLog('requestId:', requestId);
debugLog('contentType:', contentType);
debugLog('requestTemplate:', requestTemplate);
debugLog('payload:', request.payload);
/* ENVIRONMENT VARIABLES CONFIGURATION */
// Clear old vars
for (let key in this.envVars) {
delete process.env[key];
}
// Declare new ones
this.envVars = isPlainObject(populatedFun.environment) ? populatedFun.environment : {};
for (let key in this.envVars) {
process.env[key] = this.envVars[key];
}
/* BABEL CONFIGURATION */
this._registerBabel(funRuntime === 'babel', funBabelOptions);
/* HANDLER LAZY LOADING */
let handler;
try {
if (!this.options.skipCacheInvalidation) {
debugLog('Invalidating cache...');
for (let key in require.cache) {
// Require cache invalidation, brutal and fragile. Might cause errors, if so, please submit issue.
if (!key.match('node_modules')) delete require.cache[key];
}
}
debugLog(`Loading handler... (${handlerPath})`);
handler = require(handlerPath)[handlerParts[1]];
if (typeof handler !== 'function') throw new Error(`Serverless-offline: handler for function ${funName} is not a function`);
}
catch(err) {
return this._reply500(response, `Error while loading ${funName}`, err, requestId);
}
let event = {};
/* REQUEST TEMPLATE PROCESSING (event population) */
if (requestTemplate) {
try {
debugLog('_____ REQUEST TEMPLATE PROCESSING _____');
const velocityContext = createVelocityContext(request, this.velocityContextOptions, request.payload || {});
event = renderVelocityTemplateObject(requestTemplate, velocityContext);
}
catch (err) {
return this._reply500(response, `Error while parsing template "${contentType}" for ${funName}`, err, requestId);
}
}
event.isOffline = true;
debugLog('event:', event);
// We create the context, its callback (context.done/succeed/fail) will send the HTTP response
const lambdaContext = createLambdaContext(fun, (err, data) => {
debugLog('_____ HANDLER RESOLVED _____');
// Timeout resolving
if (this._clearTimeout(requestId)) return;
// User sould not call context.done twice
if (this.requests[requestId].done) {
console.log();
serverlessLog('Warning: context.done called twice!');
debugLog('requestId:', requestId);
return;
}
this.requests[requestId].done = true;
let result = data;
let responseName = 'default';
let responseContentType = defaultContentType;
/* RESPONSE SELECTION (among endpoint's possible responses) */
// Failure handling
if (err) {
const errorMessage = err.message || err.toString();
// Mocks Lambda errors
result = {
errorMessage,
errorType: err.constructor.name,
stackTrace: err.stack ? err.stack.split('\n') : null
};
serverlessLog(`Failure: ${errorMessage}`);
if (err.stack) console.log(err.stack);
for (let key in endpoint.responses) {
if (key === 'default') continue;
if (errorMessage.match('^' + (endpoint.responses[key].selectionPattern || key) + '$')) {
responseName = key;
break;
}
}
}
debugLog(`Using response '${responseName}'`);
const chosenResponse = endpoint.responses[responseName];
/* RESPONSE PARAMETERS PROCCESSING */
const responseParameters = chosenResponse.responseParameters;
if (isPlainObject(responseParameters)) {
const responseParametersKeys = Object.keys(responseParameters);
debugLog('_____ RESPONSE PARAMETERS PROCCESSING _____');
debugLog(`Found ${responseParametersKeys.length} responseParameters for '${responseName}' response`);
responseParametersKeys.forEach(key => {
// responseParameters use the following shape: "key": "value"
const value = responseParameters[key];
const keyArray = key.split('.'); // eg: "method.response.header.location"
const valueArray = value.split('.'); // eg: "integration.response.body.redirect.url"
debugLog(`Processing responseParameter "${key}": "${value}"`);
// For now the plugin only supports modifying headers
if (key.startsWith('method.response.header') && keyArray[3]) {
const headerName = keyArray.slice(3).join('.');
let headerValue;
debugLog('Found header in left-hand:', headerName);
if (value.startsWith('integration.response')) {
if (valueArray[2] === 'body') {
debugLog('Found body in right-hand');
headerValue = JSON.stringify(valueArray[3] ? jsonPath(result, valueArray.slice(3).join('.')) : result);
} else {
console.log();
serverlessLog(`Warning: while processing responseParameter "${key}": "${value}"`);
serverlessLog(`Offline plugin only supports "integration.response.body[.JSON_path]" right-hand responseParameter. Found "${value}" instead. Skipping.`);
logPluginIssue();
console.log();
}
} else {
headerValue = value;
}
// Applies the header;
debugLog(`Will assign "${headerValue}" to header "${headerName}"`);
response.header(headerName, headerValue);
}
else {
console.log();
serverlessLog(`Warning: while processing responseParameter "${key}": "${value}"`);
serverlessLog(`Offline plugin only supports "method.response.header.PARAM_NAME" left-hand responseParameter. Found "${key}" instead. Skipping.`);
logPluginIssue();
console.log();
}
});
}
/* RESPONSE TEMPLATE PROCCESSING */
// If there is a responseTemplate, we apply it to the result
const responseTemplates = chosenResponse.responseTemplates;
if (isPlainObject(responseTemplates)) {
const responseTemplatesKeys = Object.keys(responseTemplates);
if (responseTemplatesKeys.length) {
// BAD IMPLEMENTATION: first key in responseTemplates
const templateName = responseTemplatesKeys[0];
const responseTemplate = responseTemplates[templateName];
responseContentType = templateName;
if (responseTemplate) {
debugLog('_____ RESPONSE TEMPLATE PROCCESSING _____');
debugLog(`Using responseTemplate '${templateName}'`);
try {
const reponseContext = createVelocityContext(request, this.velocityContextOptions, result);
result = renderVelocityTemplateObject({ root: responseTemplate }, reponseContext).root;
}
catch (err) {
serverlessLog(`Error while parsing responseTemplate '${templateName}' for lambda ${funName}:`);
console.log(err.stack);
}
}
}
}
/* HAPIJS RESPONSE CONFIGURATION */
const statusCode = chosenResponse.statusCode || 200;
if (!chosenResponse.statusCode) {
console.log();
serverlessLog(`Warning: No statusCode found for response "${responseName}".`);
console.log();
}
response.header('Content-Type', responseContentType);
response.statusCode = statusCode;
response.source = result;
// Log response
let whatToLog = result;
try {
whatToLog = JSON.stringify(result);
}
catch(err) {
// nothing
}
finally {
serverlessLog(err ? `Replying ${statusCode}` : `[${statusCode}] ${whatToLog}`);
debugLog('requestId:', requestId);
}
// Bon voyage!
response.send();
});
// We cannot use Hapijs's timeout feature because the logic above can take a significant time, so we implement it ourselves
this.requests[requestId].timeout = setTimeout(this._replyTimeout.bind(this, response, funName, funTimeout, requestId), funTimeout);
// Finally we call the handler
debugLog('_____ CALLING HANDLER _____');
try {
const x = handler(event, lambdaContext);
// Promise support
if (funRuntime === 'babel' && !this.requests[requestId].done) {
if (x && typeof x.then === 'function' && typeof x.catch === 'function') x
.then(lambdaContext.succeed)
.catch(lambdaContext.fail);
else if (x instanceof Error) lambdaContext.fail(x);
else lambdaContext.succeed(x);
}
}
catch(err) {
return this._reply500(response, 'Uncaught error in your handler', err, requestId);
}
},
});
});
});
}
_listen() {
this.server.start(err => {
if (err) throw err;
console.log();
serverlessLog(`Offline listening on ${this.options.httpsProtocol ? 'https' : 'http'}://localhost:${this.options.port}`);
});
}
_reply500(response, message, err, requestId) {
if (this._clearTimeout(requestId)) return;
this.requests[requestId].done = true;
serverlessLog(message);
console.log(err.stack || err);
response.statusCode = 200; // APIG replies 200 by default on failures
response.source = {
errorMessage: message,
errorType: err.constructor.name,
stackTrace: err.stack ? err.stack.split('\n') : null,
offlineInfo: 'If you believe this is an issue with the plugin please submit it, thanks. https://github.com/dherault/serverless-offline/issues',
};
serverlessLog(`Replying error in handler`);
response.send();
}
_replyTimeout(response, funName, funTimeout, requestId) {
if (this.currentRequestId !== requestId) return;
this.requests[requestId].done = true;
serverlessLog(`Replying timeout after ${funTimeout}ms`);
response.statusCode = 503;
response.source = `[Serverless-Offline] Your λ handler '${funName}' timed out after ${funTimeout}ms.`;
response.send();
}
_clearTimeout(requestId) {
const timeout = this.requests[requestId].timeout;
if (timeout && timeout._called) return true;
else clearTimeout(timeout);
}
_logAndExit() {
console.log.apply(null, arguments);
process.exit(0);
}
};
};