UNPKG

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
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;