serverless-offline-sns
Version:
Serverless plugin to run a local SNS server and call lambdas with events notifications.
378 lines (377 loc) • 16.5 kB
JavaScript
import { SNSAdapter } from "./sns-adapter.js";
import express from "express";
import cors from "cors";
import { SNSServer } from "./sns-server.js";
import _ from "lodash";
import { topicNameFromArn } from "./helpers.js";
import { LambdaClient, InvokeCommand } from "@aws-sdk/client-lambda";
import lodashfp from 'lodash/fp.js';
const { get, has } = lodashfp;
class ServerlessOfflineSns {
config = {};
serverless;
commands;
localPort = 4002;
remotePort = 4002;
hooks;
_snsAdapter;
app;
server;
options;
location = "";
region = "";
accountId = "123456789012";
autoSubscribe = true;
get adapter() {
if (!this._snsAdapter) {
throw new Error("SNS adapter not initialised — call start() first");
}
return this._snsAdapter;
}
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(express.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": () => {
void 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.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();
this.serve();
await this.subscribeAll();
return this.adapter;
}
async waitForSigint() {
return new Promise((res) => {
process.on("SIGINT", () => {
this.log("Halting offline-sns server");
res(true);
});
});
}
serve() {
new SNSServer((msg, ctx) => this.debug(msg, ctx), this.app, this.region, this.accountId, this.config.retry ?? 0, this.config["retry-interval"] ?? 0);
}
getFunctionName(name) {
let result;
Object.entries(this.serverless.service.functions).forEach(([funcName, funcValue]) => {
const events = get(["events"], funcValue);
if (events)
events.forEach((event) => {
const attribute = get(["sqs", "arn"], event);
if (!attribute?.["Fn::GetAtt"])
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 (!endPoint?.["Fn::GetAtt"])
return;
const [resourceName, attribute] = endPoint["Fn::GetAtt"];
type = get(["Type"], resources[resourceName]);
if (attribute !== "Arn")
return;
if (type !== "AWS::SQS::Queue")
return;
const queueName = get(["Properties", "QueueName"], resources[resourceName]);
const filterPolicy = get(["Properties", "FilterPolicy"], value);
const filterPolicyScope = get(["Properties", "FilterPolicyScope"], 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(`${key} does not have a topic name, skipping`);
return;
}
// SQS-protocol subscriptions don't require a direct Lambda function —
// the Lambda is triggered by SQS separately.
if (protocol?.toLowerCase() !== "sqs" && !fnName) {
this.log(`${topicName} does not have a function, skipping`);
return;
}
subscriptions.push({
fnName,
options: {
topicName,
protocol,
queueName,
rawMessageDelivery,
filterPolicy,
filterPolicyScope,
},
});
});
return subscriptions;
}
async subscribeAll() {
this.setupSnsAdapter();
await this.unsubscribeAll();
this.debug("subscribing functions");
const subscribePromises = [];
if (this.autoSubscribe) {
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 ?? "(sqs)"}"`);
const data = await this.adapter.createTopic(subscription.options.topicName);
this.debug("topic: " + JSON.stringify(data));
if (!data.TopicArn) {
throw new Error(`createTopic did not return a TopicArn for "${subscription.options.topicName}"`);
}
if (subscription.options.protocol?.toLowerCase() === "sqs") {
const sqsBase = this.config["sqsEndpoint"] || `http://127.0.0.1:${this.localPort}`;
const queueUrl = subscription.options.queueName
? `${sqsBase}/queue/${subscription.options.queueName}`
: sqsBase;
await this.adapter.subscribeQueue(queueUrl, data.TopicArn, subscription.options);
}
else {
const fn = this.serverless.service.functions[subscription.fnName];
const handler = this.createHandler(subscription.fnName, fn, location);
await this.adapter.subscribe(fn, handler, data.TopicArn, subscription.options);
}
}
async unsubscribeAll() {
const subs = await this.adapter.listSubscriptions();
this.debug("subs!: " + JSON.stringify(subs));
await Promise.all((subs.Subscriptions ?? [])
.filter((sub) => sub.Endpoint != null && sub.Endpoint.indexOf(":" + this.remotePort) > -1)
.filter((sub) => sub.SubscriptionArn != null && sub.SubscriptionArn !== "PendingConfirmation")
.map((sub) => this.adapter.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.adapter.createTopic(topicName);
this.debug("topic: " + JSON.stringify(data));
if (!data.TopicArn) {
throw new Error(`createTopic did not return a TopicArn for "${topicName}"`);
}
const handler = this.createHandler(fnName, fn, lambdasLocation);
await this.adapter.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.adapter.createTopic(topicName);
this.debug("topic: " + JSON.stringify(data));
if (!data.TopicArn) {
throw new Error(`createTopic did not return a TopicArn for "${topicName}"`);
}
await this.adapter.subscribeQueue(queueUrl, data.TopicArn, snsConfig);
}
createHandler(fnName, fn, _location) {
return this.createInvokeCommandHandler(fnName);
}
createInvokeCommandHandler(fnName) {
const lambdaPort = this.config.lambdaPort ?? 3002;
const service = this.serverless.service.service ?? "";
const stage = this.serverless.service.provider.stage ?? "dev";
const functionName = `${service}-${stage}-${fnName}`;
const client = new LambdaClient({
endpoint: `http://127.0.0.1:${lambdaPort}`,
region: this.region,
credentials: { accessKeyId: "local", secretAccessKey: "local" },
});
return (event, _ctx, cb) => {
const payload = new TextEncoder().encode(JSON.stringify(event));
client.send(new InvokeCommand({ FunctionName: functionName, Payload: payload }))
.then((response) => {
const result = response.Payload ? JSON.parse(new TextDecoder().decode(response.Payload)) : null;
cb(null, result);
})
.catch((err) => {
this.log(`ERROR invoking ${functionName}: ${err.message}`, "ERROR[serverless-offline-sns]: ");
cb(err);
});
};
}
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) {
const ctxStr = String(context);
this.log(String(msg), `DEBUG[serverless-offline-sns][${ctxStr}]: `);
}
else {
this.log(String(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) {
const server = this.server;
await new Promise((resolve) => {
server.closeAllConnections();
server.close(() => resolve());
});
}
}
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"] || "", this.config["sqsEndpoint"] || "");
}
}
export default ServerlessOfflineSns;