serverless-offline-sns
Version:
Serverless plugin to run a local SNS server and call lambdas with events notifications.
461 lines (460 loc) • 20.2 kB
JavaScript
import * as shell from "shelljs";
import { SNSAdapter } from "./sns-adapter.js";
import express from "express";
import cors from "cors";
import bodyParser from "body-parser";
import { SNSServer } from "./sns-server.js";
import _ from "lodash";
import { resolve } from "path";
import { topicNameFromArn } from "./helpers.js";
import { spawn } from "child_process";
import lodashfp from 'lodash/fp.js';
const { get, has } = lodashfp;
import { loadServerlessConfig } from "./sls-config-parser.js";
import url from 'url';
class ServerlessOfflineSns {
config;
serverless;
commands;
localPort;
remotePort;
hooks;
snsAdapter;
app;
snsServer;
server;
options;
location;
region;
accountId;
servicesDirectory;
autoSubscribe;
constructor(serverless, options = {}) {
this.app = express();
this.app.use(cors());
this.app.use((req, res, next) => {
// fix for https://github.com/s12v/sns/issues/45 not sending content-type
req.headers["content-type"] = req.headers["content-type"] || "text/plain";
next();
});
this.app.use(bodyParser.json({
type: ["application/json", "text/plain"],
limit: "10mb",
}));
this.options = options;
this.serverless = serverless;
this.commands = {
"offline-sns": {
usage: "Listens to offline SNS events and passes them to configured Lambda fns",
lifecycleEvents: ["start", "cleanup"],
commands: {
start: {
lifecycleEvents: ["init", "end"],
},
cleanup: {
lifecycleEvents: ["init"],
},
},
},
};
this.hooks = {
"before:offline:start": () => this.start(),
"before:offline:start:init": () => this.start(),
"after:offline:start:end": () => this.stop(),
"offline-sns:start:init": () => {
this.start();
return this.waitForSigint();
},
"offline-sns:cleanup:init": async () => {
this.init();
this.setupSnsAdapter();
return this.unsubscribeAll();
},
"offline-sns:start:end": () => this.stop(),
};
}
init() {
process.env = _.extend({}, process.env, this.serverless.service.provider.environment);
this.config =
this.serverless.service.custom["serverless-offline-sns"] || {};
this.localPort = this.config.port || this.config.localPort || 4002;
this.remotePort = this.config.port || this.config.remotePort || 4002;
this.accountId = this.config.accountId || "123456789012";
const offlineConfig = this.serverless.service.custom["serverless-offline"] || {};
this.servicesDirectory = this.config.servicesDirectory || "";
this.location = process.cwd();
const locationRelativeToCwd = this.options.location || this.config.location || offlineConfig.location;
if (locationRelativeToCwd) {
this.location = process.cwd() + "/" + locationRelativeToCwd;
}
else if (this.serverless.config.servicePath) {
this.location = this.serverless.config.servicePath;
}
if (this.serverless.service.provider.region) {
this.region = this.serverless.service.provider.region;
}
else {
this.region = "us-east-1";
}
this.autoSubscribe = this.config.autoSubscribe === undefined ? true : this.config.autoSubscribe;
}
async start() {
this.init();
await this.listen();
await this.serve();
await this.subscribeAll();
return this.snsAdapter;
}
async waitForSigint() {
return new Promise((res) => {
process.on("SIGINT", () => {
this.log("Halting offline-sns server");
res(true);
});
});
}
async serve() {
this.snsServer = new SNSServer((msg, ctx) => this.debug(msg, ctx), this.app, this.region, this.accountId);
}
getFunctionName(name) {
let result;
Object.entries(this.serverless.service.functions).forEach(([funcName, funcValue]) => {
const events = get(["events"], funcValue);
events &&
events.forEach((event) => {
const attribute = get(["sqs", "arn"], event);
if (!has("Fn::GetAtt", attribute))
return;
const [resourceName, value] = attribute["Fn::GetAtt"];
if (value !== "Arn")
return;
if (name !== resourceName)
return;
result = funcName;
});
});
return result;
}
getResourceSubscriptions(serverless) {
const resources = serverless.service.resources?.Resources;
const subscriptions = [];
if (!resources)
return subscriptions;
new Map(Object.entries(resources)).forEach((value, key) => {
let type = get(["Type"], value);
if (type !== "AWS::SNS::Subscription")
return;
const endPoint = get(["Properties", "Endpoint"], value);
if (!has("Fn::GetAtt", endPoint))
return;
const [resourceName, attribute] = endPoint["Fn::GetAtt"];
type = get(["Type"], resources[resourceName]);
if (attribute !== "Arn")
return;
if (type !== "AWS::SQS::Queue")
return;
const filterPolicy = get(["Properties", "FilterPolicy"], value);
const protocol = get(["Properties", "Protocol"], value);
const rawMessageDelivery = get(["Properties", "RawMessageDelivery"], value);
const topicArn = get(["Properties", "TopicArn", "Ref"], value);
const topicName = get(["Properties", "TopicName"], resources[topicArn]);
const fnName = this.getFunctionName(resourceName);
if (!topicName) {
this.log(`${fnName} does not have a topic name, skipping`);
return;
}
if (!fnName) {
this.log(`${topicName} does not have a function, skipping`);
return;
}
subscriptions.push({
fnName,
options: {
topicName,
protocol,
rawMessageDelivery,
filterPolicy,
},
});
});
return subscriptions;
}
async subscribeAll() {
this.setupSnsAdapter();
await this.unsubscribeAll();
this.debug("subscribing functions");
const subscribePromises = [];
if (this.autoSubscribe) {
if (this.servicesDirectory) {
shell.cd(this.servicesDirectory);
for (const directory of shell.ls("-d", "*/")) {
shell.cd(directory);
const service = directory.split("/")[0];
const serverless = await loadServerlessConfig(shell.pwd().toString(), this.debug);
this.debug("Processing subscriptions for ", service);
this.debug("shell.pwd()", shell.pwd());
this.debug("serverless functions", JSON.stringify(serverless.service.functions));
const subscriptions = this.getResourceSubscriptions(serverless);
subscriptions.forEach((subscription) => subscribePromises.push(this.subscribeFromResource(subscription, this.location)));
Object.keys(serverless.service.functions).map((fnName) => {
const fn = serverless.service.functions[fnName];
subscribePromises.push(Promise.all(fn.events
.filter((event) => event.sns != null)
.map((event) => {
return this.subscribe(serverless, fnName, event.sns, shell.pwd());
})));
});
shell.cd("../");
}
}
else {
const subscriptions = this.getResourceSubscriptions(this.serverless);
subscriptions.forEach((subscription) => subscribePromises.push(this.subscribeFromResource(subscription, this.location)));
Object.keys(this.serverless.service.functions).map((fnName) => {
const fn = this.serverless.service.functions[fnName];
subscribePromises.push(Promise.all(fn.events
.filter((event) => event.sns != null)
.map((event) => {
return this.subscribe(this.serverless, fnName, event.sns, this.location);
})));
});
}
}
await this.subscribeAllQueues(subscribePromises);
}
async subscribeAllQueues(subscribePromises) {
await Promise.all(subscribePromises);
this.debug("subscribing queues");
await Promise.all((this.config.subscriptions || []).map((sub) => {
return this.subscribeQueue(sub.queue, sub.topic);
}));
}
async subscribeFromResource(subscription, location) {
this.debug("subscribe: " + subscription.fnName);
this.log(`Creating topic: "${subscription.options.topicName}" for fn "${subscription.fnName}"`);
const data = await this.snsAdapter.createTopic(subscription.options.topicName);
this.debug("topic: " + JSON.stringify(data));
const fn = this.serverless.service.functions[subscription.fnName];
const handler = await this.createHandler(subscription.fnName, fn, location);
await this.snsAdapter.subscribe(fn, handler, data.TopicArn, subscription.options);
}
async unsubscribeAll() {
const subs = await this.snsAdapter.listSubscriptions();
this.debug("subs!: " + JSON.stringify(subs));
await Promise.all(subs.Subscriptions.filter((sub) => sub.Endpoint.indexOf(":" + this.remotePort) > -1)
.filter((sub) => sub.SubscriptionArn !== "PendingConfirmation")
.map((sub) => this.snsAdapter.unsubscribe(sub.SubscriptionArn)));
}
async subscribe(serverless, fnName, snsConfig, lambdasLocation) {
this.debug("subscribe: " + fnName);
const fn = serverless.service.functions[fnName];
if (!fn.runtime) {
fn.runtime = serverless.service.provider.runtime;
}
let topicName = "";
// https://serverless.com/framework/docs/providers/aws/events/sns#using-a-pre-existing-topic
if (typeof snsConfig === "string") {
if (snsConfig.indexOf("arn:aws:sns") === 0) {
topicName = topicNameFromArn(snsConfig);
}
else {
topicName = snsConfig;
}
}
else if (snsConfig.topicName && typeof snsConfig.topicName === "string") {
topicName = snsConfig.topicName;
}
else if (snsConfig.arn && typeof snsConfig.arn === "string") {
topicName = topicNameFromArn(snsConfig.arn);
}
if (!topicName) {
this.log(`Unable to create topic for "${fnName}". Please ensure the sns configuration is correct.`);
return Promise.resolve(`Unable to create topic for "${fnName}". Please ensure the sns configuration is correct.`);
}
this.log(`Creating topic: "${topicName}" for fn "${fnName}"`);
const data = await this.snsAdapter.createTopic(topicName);
this.debug("topic: " + JSON.stringify(data));
const handler = await this.createHandler(fnName, fn, lambdasLocation);
await this.snsAdapter.subscribe(fn, handler, data.TopicArn, snsConfig);
}
async subscribeQueue(queueUrl, snsConfig) {
this.debug("subscribe: " + queueUrl);
let topicName = "";
// https://serverless.com/framework/docs/providers/aws/events/sns#using-a-pre-existing-topic
if (typeof snsConfig === "string") {
if (snsConfig.indexOf("arn:aws:sns") === 0) {
topicName = topicNameFromArn(snsConfig);
}
else {
topicName = snsConfig;
}
}
else if (snsConfig.topicName && typeof snsConfig.topicName === "string") {
topicName = snsConfig.topicName;
}
else if (snsConfig.arn && typeof snsConfig.arn === "string") {
topicName = topicNameFromArn(snsConfig.arn);
}
if (!topicName) {
this.log(`Unable to create topic for "${queueUrl}". Please ensure the sns configuration is correct.`);
return Promise.resolve(`Unable to create topic for "${queueUrl}". Please ensure the sns configuration is correct.`);
}
this.log(`Creating topic: "${topicName}" for queue "${queueUrl}"`);
const data = await this.snsAdapter.createTopic(topicName);
this.debug("topic: " + JSON.stringify(data));
await this.snsAdapter.subscribeQueue(queueUrl, data.TopicArn, snsConfig);
}
async createHandler(fnName, fn, location) {
if (!fn.runtime || fn.runtime.startsWith("nodejs")) {
return await this.createJavascriptHandler(fn, location);
}
else {
return async () => await this.createProxyHandler(fnName, fn, location);
}
}
async createProxyHandler(funName, funOptions, location) {
const options = this.options;
return (event, context) => {
const args = ["invoke", "local", "-f", funName];
const stage = options.s || options.stage;
if (stage) {
args.push("-s", stage);
}
// Use path to binary if provided, otherwise assume globally-installed
const binPath = options.b || options.binPath;
const cmd = binPath || "sls";
const process = spawn(cmd, args, {
cwd: location,
shell: true,
stdio: ["pipe", "pipe", "pipe"],
});
process.stdin.write(`${JSON.stringify(event)}\n`);
process.stdin.end();
const results = [];
let error = false;
process.stdout.on("data", (data) => {
if (data) {
const str = data.toString();
if (str) {
// should we check the debug flag & only log if debug is true?
console.log(str);
results.push(data.toString());
}
}
});
process.stderr.on("data", (data) => {
error = true;
console.warn("error", data);
context.fail(data);
});
process.on("close", (code) => {
if (!error) {
// try to parse to json
// valid result should be a json array | object
// technically a string is valid json
// but everything comes back as a string
// so we can't reliably detect json primitives with this method
let response = null;
// we go end to start because the one we want should be last
// or next to last
for (let i = results.length - 1; i >= 0; i--) {
// now we need to find the min | max [] or {} within the string
// if both exist then we need the outer one.
// { "something": [] } is valid,
// [{"something": "valid"}] is also valid
// *NOTE* Doesn't currently support 2 separate valid json bundles
// within a single result.
// this can happen if you use a python logger
// and then do log.warn(json.dumps({'stuff': 'here'}))
const item = results[i];
const firstCurly = item.indexOf("{");
const firstSquare = item.indexOf("[");
let start = 0;
let end = item.length;
if (firstCurly === -1 && firstSquare === -1) {
// no json found
continue;
}
if (firstSquare === -1 || firstCurly < firstSquare) {
// found an object
start = firstCurly;
end = item.lastIndexOf("}") + 1;
}
else if (firstCurly === -1 || firstSquare < firstCurly) {
// found an array
start = firstSquare;
end = item.lastIndexOf("]") + 1;
}
try {
response = JSON.parse(item.substring(start, end));
break;
}
catch (err) {
// not json, check the next one
continue;
}
}
if (response !== null) {
context.succeed(response);
}
else {
context.succeed(results.join("\n"));
}
}
});
};
}
async createJavascriptHandler(fn, location) {
// Options are passed from the command line in the options parameter
this.debug(process.cwd());
const handlerFnNameIndex = fn.handler.lastIndexOf('.');
const handlerPath = fn.handler.substring(0, handlerFnNameIndex);
const handlerFnName = fn.handler.substring(handlerFnNameIndex + 1);
const fullHandlerPath = resolve(location, handlerPath);
const handlers = await import(`${url.pathToFileURL(fullHandlerPath)}.js`);
return handlers[handlerFnName] || handlers.default[handlerFnName];
}
log(msg, prefix = "INFO[serverless-offline-sns]: ") {
this.serverless.cli.log.call(this.serverless.cli, prefix + msg);
}
debug(msg, context) {
if (this.config.debug) {
if (context) {
this.log(msg, `DEBUG[serverless-offline-sns][${context}]: `);
}
else {
this.log(msg, "DEBUG[serverless-offline-sns]: ");
}
}
}
async listen() {
this.debug("starting plugin");
let host = "127.0.0.1";
if (this.config.host) {
this.debug(`using specified host ${this.config.host}`);
host = this.config.host;
}
else if (this.options.host) {
this.debug(`using offline specified host ${this.options.host}`);
host = this.options.host;
}
return new Promise((res) => {
this.server = this.app.listen(this.localPort, host, () => {
this.debug(`listening on ${host}:${this.localPort}`);
res(true);
});
this.server.setTimeout(0);
});
}
async stop() {
this.init();
this.debug("stopping plugin");
if (this.server) {
this.server.close();
}
}
setupSnsAdapter() {
this.snsAdapter = new SNSAdapter(this.localPort, this.remotePort, this.serverless.service.provider.region, this.config["sns-endpoint"], (msg, ctx) => this.debug(msg, ctx), this.app, this.serverless.service.service, this.serverless.service.provider.stage, this.accountId, this.config.host, this.config["sns-subscribe-endpoint"]);
}
}
export default ServerlessOfflineSns;