serverless-s3-local
Version:
642 lines (579 loc) • 19.2 kB
JavaScript
const S3rver = require("s3rver");
const fs = require("fs-extra"); // Using fs-extra to ensure destination directory exist
const shell = require("shelljs");
const path = require("path");
const { fromEvent } = require("rxjs");
const { map, mergeMap } = require("rxjs/operators");
const defaultOptions = {
port: 4569,
address: "localhost",
location: ".",
accessKeyId: "S3RVER",
secretAccessKey: "S3RVER",
};
const removeBucket = ({ bucket, port }) =>
new Promise((resolve, reject) => {
shell.exec(
`aws --endpoint http://localhost:${port} s3 rb "s3://${bucket}" --force`,
{ silent: true },
(code, stdout, stderr) => {
if (code === 0) return resolve();
if (stderr && stderr.indexOf("NoSuchBucket") !== -1) return resolve();
return reject(
new Error(`failed to delete bucket ${bucket}: ${stderr || stdout}`)
);
}
);
});
class ServerlessS3Local {
constructor(serverless, options) {
this.serverless = serverless;
this.service = serverless.service;
this.options = options;
this.provider = "aws";
this.client = null;
this.lambdaHandler = null;
this.commands = {
s3: {
commands: {
start: {
usage: "Start S3 local server.",
lifecycleEvents: ["startHandler"],
options: {
port: {
shortcut: "p",
usage:
"The port number that S3 will use to communicate with your application. If you do not specify this option, the default port is 4569",
type: "string",
},
directory: {
shortcut: "d",
usage:
"The directory where S3 will store its objects. If you do not specify this option, the file will be written to the current directory.",
type: "string",
},
buckets: {
shortcut: "b",
usage: "After starting S3 local, create specified buckets",
type: "string",
},
cors: {
shortcut: "c",
usage: "Path to cors configuration xml",
type: "string",
},
noStart: {
shortcut: "n",
default: false,
usage: "Do not start S3 local (in case it is already running)",
type: "boolean",
},
allowMismatchedSignatures: {
shortcut: "a",
default: false,
usage:
"Prevent SignatureDoesNotMatch errors for all well-formed signatures",
type: "boolean",
},
website: {
shortcut: "w",
usage: "Path to website configuration xml",
type: "string",
},
serviceEndpoint: {
shortcut: "s",
usage:
"Override the AWS service root for subdomain-style access",
type: "string",
},
httpsProtocol: {
shortcut: "H",
usage:
"To enable HTTPS, specify directory (relative to your cwd, typically your project dir) for both cert.pem and key.pem files.",
type: "string",
},
vhostBuckets: {
shortcut: "v",
default: true,
usage: "Disable vhost-style access for all buckets",
type: "string",
},
},
},
create: {
usage: "Create local S3 buckets.",
lifecycleEvents: ["createHandler"],
options: {
port: {
shortcut: "p",
usage:
"The port number that S3 will use to communicate with your application. If you do not specify this option, the default port is 4569",
type: "string",
},
buckets: {
shortcut: "b",
usage: "After starting S3 local, create specified buckets",
type: "string",
},
},
},
remove: {
usage: "Remove local S3 buckets.",
lifecycleEvents: ["createHandler"],
options: {
port: {
shortcut: "p",
usage:
"The port number that S3 will use to communicate with your application. If you do not specify this option, the default port is 4569",
type: "string",
},
buckets: {
shortcut: "b",
usage: "After starting S3 local, create specified buckets",
type: "string",
},
},
},
},
},
};
this.hooks = {
"s3:start:startHandler": this.startHandler.bind(this),
"s3:create:createHandler": this.createHandler.bind(this),
"s3:remove:createHandler": this.removeHandler.bind(this),
"before:offline:start:init": this.startHandler.bind(this),
"before:offline:start": this.startHandler.bind(this),
"before:offline:start:end": this.endHandler.bind(this),
"after:webpack:compile:watch:compile":
this.subscriptionWebpackHandler.bind(this),
};
}
subscriptionWebpackHandler() {
return new Promise((resolve) => {
if (!this.s3eventSubscription) {
resolve();
}
this.s3eventSubscription.unsubscribe();
this.subscribe();
this.serverless.cli.log("constructor");
resolve();
});
}
subscribe() {
this.eventHandlers = this.getEventHandlers();
const s3Event = fromEvent(this.client, "event");
this.s3eventSubscription = s3Event
.pipe(
map((event) => {
const bucketName = event.Records[0].s3.bucket.name;
const { eventName } = event.Records[0];
const { key } = event.Records[0].s3.object;
return this.eventHandlers
.filter((handler) => handler.name === bucketName)
.filter((handler) => eventName.match(handler.pattern) !== null)
.filter((handler) => {
const obj = handler.rules.reduce(
(acc, rule) => {
if (!acc.prefix && rule.prefix) {
acc.prefix = key.match(rule.prefix);
} else if (!acc.suffix && rule.suffix) {
acc.suffix = key.match(rule.suffix);
}
return acc;
},
{
prefix: !handler.rules.some((rule) => rule.prefix),
suffix: !handler.rules.some((rule) => rule.suffix),
}
);
return obj.prefix && obj.suffix;
})
.map((handler) => () => handler.func(event));
}),
mergeMap((handler) => handler)
)
.subscribe((handler) => {
handler();
});
}
startHandler() {
return new Promise((resolve, reject) => {
this.setOptions();
const {
noStart,
address,
port,
cors,
website,
allowMismatchedSignatures,
serviceEndpoint,
httpsProtocol,
vhostBuckets,
} = this.options;
if (noStart) {
this.createBuckets().then(resolve, reject);
return;
}
const dirPath = this.options.directory || "./buckets";
fs.ensureDirSync(dirPath); // Create destination directory if not exist
const directory = fs.realpathSync(dirPath);
const configs = [
cors
? fs.readFileSync(
path.resolve(this.serverless.config.servicePath, cors),
"utf8"
)
: null,
website
? fs.readFileSync(
path.resolve(this.serverless.config.servicePath, website),
"utf8"
)
: null,
].filter((x) => !!x);
const configureBuckets = this.buckets().map((name) => {
if (typeof name === "object") {
// Fn::Sub, Fn::Join, Fn::Select
const awsFuncKey = Object.keys(name)[0];
return { name: name[awsFuncKey], configs };
}
return { name, configs };
});
let cert;
let key;
if (typeof httpsProtocol === "string" && httpsProtocol.length > 0) {
cert = fs.readFileSync(
path.resolve(httpsProtocol, "cert.pem"),
"ascii"
);
key = fs.readFileSync(path.resolve(httpsProtocol, "key.pem"), "ascii");
}
import('serverless-offline/lambda').then(module => {
this.lambdaHandler = module.default;
this.client = new S3rver({
address,
port,
silent: this.options.silent,
directory,
allowMismatchedSignatures,
configureBuckets,
serviceEndpoint,
cert,
key,
vhostBuckets,
}).run(
(err, { port: bindedPort, family, address: bindedAddress } = {}) => {
if (err) {
this.serverless.cli.log("Error occurred while starting S3 local.");
reject(err);
return;
}
this.options.port = bindedPort;
this.serverless.cli.log(
`S3 local started ( port:${bindedPort}, family: ${family}, address: ${bindedAddress} )`
);
resolve();
}
);
this.serverless.cli.log("starting handler");
this.subscribe();
});
});
}
endHandler() {
if (!this.options.noStart) {
this.client.close();
this.serverless.cli.log("S3 local closed");
}
}
createHandler() {
this.setOptions();
return this.createBuckets();
}
removeHandler() {
this.setOptions();
return this.removeBuckets();
}
createBuckets() {
const buckets = this.buckets();
if (!buckets.length) {
this.serverless.cli.log("WARN: No buckets found to create");
return Promise.resolve([]);
}
const { CreateBucketCommand } = require("@aws-sdk/client-s3")
const s3Client = this.getClient();
return Promise.all(
buckets.map((Bucket) => {
this.serverless.cli.log(`creating bucket: ${Bucket}`);
const command = new CreateBucketCommand({ Bucket });
return s3Client.send(command);
})
).catch(() => ({}));
}
removeBuckets() {
return Promise.resolve().then(() => {
const { port } = this.options;
const buckets = this.buckets();
if (!buckets.length) return null;
return Promise.all(
buckets.map((bucket) => {
this.serverless.cli.log(`removing bucket: ${bucket}`);
return removeBucket({ port, bucket });
})
);
});
}
getClient() {
const { S3Client } = require("@aws-sdk/client-s3");
return new S3Client({
forcePathStyle: true,
endpoint: `http://${this.options.host}:${this.options.port}`,
credentials: {
accessKeyId: this.options.accessKeyId,
secretAccessKey: this.options.secretAccessKey,
},
});
}
getServiceRuntime() {
// Following codes are derived from serverless/index.js
let serviceRuntime = this.service.provider.runtime;
if (!serviceRuntime) {
throw new Error('Missing required property "runtime" for provider.');
}
if (typeof serviceRuntime !== "string") {
throw new Error(
'Provider configuration property "runtime" wasn\'t a string.'
);
}
if (serviceRuntime === "provided") {
if (this.options.providedRuntime) {
serviceRuntime = this.options.providedRuntime;
} else {
throw new Error(
'Runtime "provided" is unsupported. Please add a --providedRuntime CLI option.'
);
}
}
if (
!(
serviceRuntime.startsWith("nodejs") ||
serviceRuntime.startsWith("python") ||
serviceRuntime.startsWith("ruby")
)
) {
this.serverless.cli.log(
`Warning: found unsupported runtime '${serviceRuntime}'`
);
return null;
}
return serviceRuntime;
}
getEventHandlers() {
if (
typeof this.service !== "object" ||
typeof this.service.functions !== "object"
) {
return {};
}
const lambda = new this.lambdaHandler(this.serverless, this.options);
const eventHandlers = [];
this.service.getAllFunctions().forEach((functionKey) => {
const functionDefinition = this.service.getFunction(functionKey);
lambda.create([{functionKey, functionDefinition}]); // eslint-disable-line no-underscore-dangle
const func = async (s3Event) => {
const baseEnvironment = {
IS_LOCAL: true,
IS_OFFLINE: true,
};
try {
Object.assign(
process.env,
baseEnvironment,
this.service.provider.environment,
functionDefinition.environment || {}
);
const handler = lambda.get(functionKey);
handler.setEvent(s3Event);
await handler.runHandler();
} catch (e) {
this.serverless.cli.log("Error while running handler", e);
}
};
functionDefinition.events.forEach((event) => {
const s3 = (event && (event.s3 || event.existingS3)) || undefined;
if (!s3) {
return;
}
let handlerBucketName;
let s3Events;
let s3Rules;
if (typeof s3 === "object") {
handlerBucketName = s3.bucket;
s3Events = s3.events || [s3.event || "*"];
s3Rules = s3.rules || [];
} else {
handlerBucketName = s3;
s3Events = event.events || [event.event || "*"];
s3Rules = event.rules || [];
}
const bucketResource = this.getResourceForBucket(handlerBucketName);
const name = bucketResource
? bucketResource.Properties.BucketName
: handlerBucketName;
s3Events.forEach((existingEvent) => {
const pattern = existingEvent.replace(/^s3:/, "").replace("*", ".*");
eventHandlers.push(
ServerlessS3Local.buildEventHandler(
s3,
name,
pattern,
s3Rules,
func
)
);
});
this.serverless.cli.log(`Found S3 event listener for ${name}`);
});
});
return eventHandlers;
}
static buildEventHandler(s3, name, pattern, s3Rules, func) {
const rule2regex = (rule) =>
Object.keys(rule).map(
(key) =>
(key === "prefix" && { prefix: `^${rule[key]}` }) || {
suffix: `${rule[key]}$`,
}
);
const rules = [].concat(...s3Rules.map(rule2regex));
return {
name,
pattern,
rules,
func,
};
}
getResourceForBucket(bucketName) {
const logicalResourceName = bucketName.Ref
? bucketName.Ref
: `S3Bucket${bucketName
.charAt(0)
.toUpperCase()}${bucketName.substr(1)}`;
return this.service.resources && this.service.resources.Resources
? this.service.resources.Resources[logicalResourceName]
: false;
}
getAdditionalStacks() {
const serviceAdditionalStacks = this.service.custom.additionalStacks || {};
const additionalStacks = [];
Object.keys(serviceAdditionalStacks).forEach((stack) => {
additionalStacks.push(serviceAdditionalStacks[stack]);
});
return additionalStacks;
}
hasPlugin(pluginName, strict = false) {
return this.service && this.service.plugins && this.service.plugins.modules
? this.service.plugins.modules.some((module) => {
const index = module.indexOf(pluginName);
return strict ? index === 0 : index >= 0;
})
: this.service.plugins.some((plugin) => {
const index = plugin.indexOf(pluginName);
return strict ? index === 0 : index >= 0;
});
}
hasAdditionalStacksPlugin() {
return this.hasPlugin("additional-stacks");
}
hasExistingS3Plugin() {
return this.hasPlugin("existing-s3");
}
/**
* Get bucket list from serverless.yml resources and additional stacks
*
* @return {object} Array of bucket name
*/
buckets() {
const resources =
(this.service.resources && this.service.resources.Resources) || {};
if (this.hasAdditionalStacksPlugin()) {
let additionalStacks = [];
additionalStacks = additionalStacks.concat(this.getAdditionalStacks());
additionalStacks.forEach((stack) => {
if (stack.Resources) {
Object.keys(stack.Resources).forEach((key) => {
if (stack.Resources[key].Type === "AWS::S3::Bucket") {
resources[key] = stack.Resources[key];
}
});
}
});
}
// support for serverless-plugin-existing-s3
// https://www.npmjs.com/package/serverless-plugin-existing-s3
if (this.hasExistingS3Plugin()) {
const { functions } = this.serverless.service;
const functionNames = Object.keys(functions);
functionNames.forEach((name) => {
functions[name].events.forEach((event) => {
const eventKeys = Object.keys(event);
// check if the event has an existingS3 and add if the bucket name
// is not already in the array
if (eventKeys.indexOf("existingS3") > -1) {
const resourceName = `LocalS3Bucket${event.existingS3.bucket}`;
const localBucket = {
Type: "AWS::S3::Bucket",
Properties: {
BucketName: event.existingS3.bucket,
},
};
resources[resourceName] = localBucket;
}
});
});
}
const eventSourceBuckets = Object.keys(this.service.functions).reduce(
(acc, key) => {
const serviceFunction = this.service.getFunction(key);
return acc.concat(
serviceFunction.events
.map((event) => {
const s3 = (event && (event.s3 || event.existingS3)) || undefined;
if (!s3) {
return null;
}
return typeof s3 === "object" ? s3.bucket : s3;
})
.filter((bucket) => bucket !== null)
);
},
[]
);
return Object.keys(resources)
.map((key) => {
if (
resources[key].Type === "AWS::S3::Bucket" &&
resources[key].Properties &&
resources[key].Properties.BucketName
) {
return resources[key].Properties.BucketName;
}
return null;
})
.concat(this.options.buckets)
.concat(eventSourceBuckets)
.filter((n) => n);
}
setOptions() {
const config =
(this.serverless.service.custom && this.serverless.service.custom.s3) ||
{};
this.options = {
...defaultOptions,
...(this.service.custom || {})["serverless-offline"],
...this.options,
...config,
};
}
}
module.exports = ServerlessS3Local;