@yopdev/dev-server 3.0.2-RC → 3.0.2-RC1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,125 @@
1
+ import { CloudFormationSetup, CloudFormationSetupConfig } from './cloudformation';
2
+ import { DevServerConfig } from './config';
3
+ import { HttpSettings } from './http-server';
4
+ import { Authorizer, Route, newLambdaHttpProxy } from './lambda-http-proxy';
5
+ import { Callback, Service, Startable } from './services';
6
+ import { LambdaMapperFactory, LambdaPayloadVersion, PathParameterResolver } from './mappers';
7
+
8
+ const PATH_VARIABLE_CAPTURE = /{(.*?)}/g;
9
+ const QUERY_STRING_OR_LOCATION_REG_EXP = '(?:([?#].*))?';
10
+ const PROXY_PATH_PARAM = 'proxy';
11
+ export const newLambdaProxyFromCloudFormationTemplate = <Context, AuthorizerContext, Event, HandlerResponse>(
12
+ name: string,
13
+ settings: HttpSettings,
14
+ payloadVersion: LambdaPayloadVersion<AuthorizerContext, Event, HandlerResponse>,
15
+ config: CloudFormationLambdaProxyConfig<Context, AuthorizerContext, Event, HandlerResponse>,
16
+ callback: Callback<string>,
17
+ ): Startable<Service<string>> => new CloudFormationLambdaProxy(name, settings, config.extraRoutes, payloadVersion, config, callback);
18
+
19
+ class CloudFormationLambdaProxy<Context, AuthorizerContext, Event, HandlerResponse> extends CloudFormationSetup<
20
+ {
21
+ Path: string;
22
+ Method: string;
23
+ },
24
+ (event: Event) => Promise<HandlerResponse>,
25
+ Route<Context, AuthorizerContext, Event, HandlerResponse>,
26
+ Service<string>
27
+ > {
28
+ private mapper: LambdaMapperFactory<AuthorizerContext, Event, HandlerResponse>;
29
+ private authorizer: (cfg: DevServerConfig) => Authorizer<AuthorizerContext>;
30
+ private pathParameterResolver: PathParameterResolver<Event>
31
+
32
+ constructor(
33
+ name: string,
34
+ private readonly settings: HttpSettings,
35
+ private readonly extraRoutes: Route<Context, AuthorizerContext, Event, HandlerResponse>[],
36
+ payloadVersion: LambdaPayloadVersion<AuthorizerContext, Event, HandlerResponse>,
37
+ config: CloudFormationLambdaProxyConfig<Context, AuthorizerContext, Event, HandlerResponse>,
38
+ private readonly callback?: Callback<string>,
39
+ ) {
40
+ super(
41
+ name,
42
+ (event) => event.Type === 'HttpApi',
43
+ async (event, handler) =>
44
+ Promise.resolve(this.pathWithCapturingGroups(event.Properties.Path)).then(
45
+ (pathWithCapturingGroups) => ({
46
+ method: new RegExp(this.method(event.Properties.Method)),
47
+ path: new RegExp(`^${pathWithCapturingGroups}${QUERY_STRING_OR_LOCATION_REG_EXP}?$`),
48
+ weight: this.computeWeight(pathWithCapturingGroups),
49
+ handler: this.pathParameterCapture(new RegExp(pathWithCapturingGroups), handler, config.context),
50
+ }),
51
+ ),
52
+ config.prepare,
53
+ config,
54
+ );
55
+ this.mapper = payloadVersion.mapper;
56
+ this.authorizer = config?.authorizer;
57
+ this.pathParameterResolver = payloadVersion.resolver;
58
+ }
59
+
60
+ protected afterStart = (name: string, cfg: DevServerConfig, routes: Route<Context, AuthorizerContext, Event, HandlerResponse>[]) =>
61
+ newLambdaHttpProxy(
62
+ `${name}:apigw`,
63
+ {
64
+ settings: this.settings,
65
+ routes: this.extraRoutes.concat(routes),
66
+ authorizer: (this.authorizer || (() => undefined))(cfg),
67
+ mapper: this.mapper,
68
+ },
69
+ this.callback,
70
+ );
71
+
72
+ private computeWeight = (path: string) => this.segments(path) - this.variables(path);
73
+
74
+ private segments = (path: string) => path.split('/').length;
75
+ private variables = (path: string) => path.split('(?<').length;
76
+
77
+ logHandler(handler: Route<Context, AuthorizerContext, Event, HandlerResponse>): string {
78
+ return `detected handler for ${handler.method} on ${handler.path} with weight ${handler.weight}`;
79
+ }
80
+
81
+ private pathWithCapturingGroups = (source: string) =>
82
+ [...source.matchAll(PATH_VARIABLE_CAPTURE)].reduce(
83
+ (prev, curr) => prev.replaceAll(curr[0], `(?<${this.normalizeCaptureGroupName(curr[1]).replaceAll("+","")}>.*)`),
84
+ source,
85
+ );
86
+
87
+ private normalizeCaptureGroupName = (original: string) => (original === 'proxy+' ? PROXY_PATH_PARAM : original);
88
+
89
+ private pathParameterCapture =
90
+ (pathWithCapturingGroups: RegExp, handler: (event: Event, context?: Context) => Promise<HandlerResponse>, context?: () => Context) =>
91
+ async (event: Event) => {
92
+ const pathParameters = pathWithCapturingGroups.exec(this.pathParameterResolver.locate(event))?.groups;
93
+ this.pathParameterResolver.store(
94
+ event, Object.fromEntries(
95
+ (Object.entries(pathParameters ?? {}))
96
+ .map(([k, v]) => [k, decodeURIComponent(v)])
97
+ )
98
+ )
99
+ return handler(event, context?.());
100
+ };
101
+
102
+ private method = (cloudFormationValue: string) => {
103
+ switch(cloudFormationValue) {
104
+ case 'ANY':
105
+ return '.*'
106
+ case 'HEAD':
107
+ case 'OPTIONS':
108
+ case 'GET':
109
+ case 'POST':
110
+ case 'PUT':
111
+ case 'DELETE':
112
+ case 'PATCH':
113
+ return cloudFormationValue;
114
+ default:
115
+ throw new Error(`unsupported method ${cloudFormationValue}`);
116
+ }
117
+ }
118
+ }
119
+
120
+ type CloudFormationLambdaProxyConfig<Context, AuthorizerContext, Event, HandlerResponse> = {
121
+ authorizer?: (config: DevServerConfig) => Authorizer<AuthorizerContext> | undefined,
122
+ extraRoutes: Route<Context, AuthorizerContext, Event, HandlerResponse>[],
123
+ prepare?: (config: DevServerConfig) => Promise<void>,
124
+ context?: () => Context,
125
+ } & CloudFormationSetupConfig;
@@ -0,0 +1,95 @@
1
+ import { CLOUDFORMATION_SCHEMA } from 'js-yaml-cloudformation-schema';
2
+ import { readFileSync } from 'fs';
3
+ import { resolve } from 'path';
4
+ import { load } from 'js-yaml';
5
+ import { Logger, LoggerFactory } from '@yopdev/logging';
6
+ import { DevServerConfig } from './config';
7
+ import { Startable } from './services';
8
+
9
+ export abstract class CloudFormationSetup<P, H, R, F> implements Startable<F> {
10
+ private readonly template: (name: string) => string;
11
+ private readonly handlers: (name: string) => string;
12
+ private readonly handlerNameResolver: (name: string) => string;
13
+ private readonly LOGGER: Logger;
14
+ constructor(
15
+ readonly name: string,
16
+ private readonly eventFilter: (event: Event<P>) => boolean,
17
+ private readonly handlerResolver: (event: Event<P>, handler: H) => Promise<R>,
18
+ private readonly beforeStart: ((config: DevServerConfig) => Promise<void>) | undefined,
19
+ config: CloudFormationSetupConfig,
20
+ ) {
21
+ this.LOGGER = LoggerFactory.create(`CFN:FUNCTION[${name}]`);
22
+ this.template = config.template;
23
+ this.handlers = config.handlers;
24
+ this.handlerNameResolver = config.handlerNameResolver;
25
+ }
26
+ start = async (config: DevServerConfig) =>
27
+ (this.beforeStart || Promise.resolve)(config)
28
+ .then(this.resolvedHandlers)
29
+ .then(async (routes) => this.afterStart(this.name, config, routes));
30
+
31
+ protected abstract afterStart: (name: string, config: DevServerConfig, routes: R[]) => F;
32
+
33
+ private resolvedHandlers = async (): Promise<R[]> => {
34
+ const template = this.template(this.name);
35
+ this.LOGGER.info('parsing %s', template);
36
+ const definition = this.parseApiEvents(template);
37
+ const functions = this.handlers(this.name);
38
+ const routes = Object.values(definition)
39
+ .filter((resource) => resource.Type === 'AWS::Serverless::Function')
40
+ .filter((resource) => resource.Properties.Events)
41
+ .flatMap((resource) =>
42
+ Object.values(resource.Properties.Events).map((event) => ({
43
+ ...event,
44
+ Handler: resource.Properties.Handler,
45
+ })),
46
+ )
47
+ .filter(this.eventFilter)
48
+ .map(async (event) =>
49
+ import(functions).then(async (handlers) =>
50
+ Promise.resolve(handlers[this.handlerNameResolver(event.Handler)])
51
+ .then(async (handler: H) =>
52
+ (handler !== undefined) ?
53
+ this.handlerResolver(event, handler) :
54
+ Promise.reject(new Error(`function ${event.Handler} defined in ${resolve(template)} not found in ${resolve(functions)}`)))
55
+ .then((handler) => {
56
+ this.LOGGER.info(this.logHandler(handler));
57
+ return handler;
58
+ }),
59
+ ),
60
+ );
61
+ return Promise.all(routes);
62
+ };
63
+
64
+ abstract logHandler(handler: R): string;
65
+
66
+ private parseApiEvents = (path: string) =>
67
+ (
68
+ load(readFileSync(path).toString(), {
69
+ schema: CLOUDFORMATION_SCHEMA,
70
+ }) as {
71
+ Resources: {
72
+ [name: string]: {
73
+ Type: string;
74
+ Properties: {
75
+ Handler: string;
76
+ Events: {
77
+ [name: string]: Event<P>;
78
+ };
79
+ };
80
+ };
81
+ };
82
+ }
83
+ ).Resources;
84
+ }
85
+
86
+ export type CloudFormationSetupConfig = {
87
+ template: (name: string) => string;
88
+ handlers: (name: string) => string;
89
+ handlerNameResolver: (name: string) => string;
90
+ };
91
+
92
+ type Event<P> = {
93
+ Type: string;
94
+ Properties: P;
95
+ };
package/src/config.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { Sqs } from "./sqs"
2
+ import { Sns } from "./sns"
3
+ import { DynamoDb } from "./dynamodb"
4
+ import { StartedNetwork } from "testcontainers"
5
+ import { S3 } from "./s3"
6
+
7
+ export type Config = {
8
+ boundServicesPort?: number
9
+ localStackDockerImage?: string
10
+ localStateBindMount?: string
11
+ }
12
+
13
+ export type AwsConfig = {
14
+ region: string,
15
+ endpoint: string,
16
+ credentials: {
17
+ accessKeyId: string,
18
+ secretAccessKey: string
19
+ }
20
+ }
21
+
22
+ export type DevServerConfig = {
23
+ raw: AwsConfig
24
+ network: StartedNetwork
25
+ sqs: Sqs
26
+ sns: Sns
27
+ s3: S3
28
+ dynamo: DynamoDb
29
+ eventsProxy: {
30
+ topic: {
31
+ arn: string
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,82 @@
1
+ import { Logger, LoggerFactory } from '@yopdev/logging';
2
+ import { GenericContainer, StartedTestContainer } from 'testcontainers';
3
+ import { BindMount, Environment } from 'testcontainers/build/types';
4
+ import { Callback, Lifecycle, Service } from './services';
5
+ import { PortWithOptionalBinding } from 'testcontainers/build/utils/port';
6
+ import { Readable } from 'stream';
7
+ import { WaitStrategy } from 'testcontainers/build/wait-strategies/wait-strategy';
8
+ import { DevServerConfig } from './config';
9
+
10
+ export const newContainer = (
11
+ name: string,
12
+ config: {
13
+ image: string,
14
+ networkAlias: string,
15
+ environment?: Environment,
16
+ bindMounts?: BindMount[],
17
+ exposedPorts?: PortWithOptionalBinding[],
18
+ logConsumer?: (stream: Readable) => unknown,
19
+ startup?: {
20
+ waitStrategy: WaitStrategy,
21
+ timeout: number
22
+ },
23
+ endpointBuilder?: (port: number) => string,
24
+ },
25
+ callback?: Callback<string>,
26
+ ) =>
27
+ new Service(new Container(
28
+ name,
29
+ config.image,
30
+ config.networkAlias,
31
+ config.environment,
32
+ config.bindMounts,
33
+ config.exposedPorts,
34
+ config.logConsumer,
35
+ config.startup,
36
+ config.endpointBuilder,
37
+ ), callback);
38
+
39
+ class Container implements Lifecycle<string> {
40
+ private readonly LOGGER: Logger;
41
+ private readonly container: GenericContainer;
42
+ private readonly endpointBuilder: (port: number) => string;
43
+ private started: StartedTestContainer | undefined;
44
+
45
+ constructor(
46
+ readonly name: string,
47
+ image: string,
48
+ private readonly networkAlias: string,
49
+ environment?: Environment,
50
+ bindMounts?: BindMount[],
51
+ exposedPorts?: PortWithOptionalBinding[],
52
+ logConsumer?: (stream: Readable) => unknown,
53
+ startup?: {
54
+ waitStrategy: WaitStrategy,
55
+ timeout: number
56
+ },
57
+ endpointBuilder?: (port: number) => string,
58
+ ) {
59
+ this.LOGGER = LoggerFactory.create(`CONTAINER[${name}]`);
60
+ const generic = new GenericContainer(image);
61
+ const withEnvironment = environment ? generic.withEnvironment(environment) : generic;
62
+ const withBindMounts = bindMounts ? withEnvironment.withBindMounts(bindMounts) : withEnvironment;
63
+ const withExposedPorts = exposedPorts ? withBindMounts.withExposedPorts(...exposedPorts) : withBindMounts;
64
+ const withLogConsumer = logConsumer ? withExposedPorts.withLogConsumer(logConsumer) : withExposedPorts;
65
+ this.container = startup ? withLogConsumer.withWaitStrategy(startup.waitStrategy).withStartupTimeout(startup.timeout) : withLogConsumer;
66
+ this.networkAlias = networkAlias;
67
+ this.endpointBuilder = endpointBuilder ?? ((port: number) => `http://localhost:${port}`);
68
+ }
69
+
70
+ start = async (config: DevServerConfig) =>
71
+ Promise.resolve(this.LOGGER.info('start'))
72
+ .then(() => this.container
73
+ .withNetwork(config.network)
74
+ .withNetworkAliases(this.networkAlias)
75
+ .start()
76
+ )
77
+ .then((started) => this.started = started)
78
+ .then(() => this.endpointBuilder(this.started.getFirstMappedPort()))
79
+ .tap((url) => this.LOGGER.debug(url));
80
+
81
+ stop = async () => (this.started ?? { stop: async () => { this.LOGGER.warn('no container') } }).stop().then(() => undefined);
82
+ };
@@ -0,0 +1,60 @@
1
+ import { randomUUID } from "crypto";
2
+ import { DevServerConfig } from "./config";
3
+ import { Lifecycle, Service } from "./services";
4
+ import { Logger, LoggerFactory } from "@yopdev/logging";
5
+
6
+ export const lazy = <I>(lifecycle: (config: DevServerConfig) => Service<I>) => new Service(new Lazy(lifecycle));
7
+ export const promised = <I>(lifecycle: (config: DevServerConfig) => Promise<Service<I>>) => new Service(new Promised(lifecycle));
8
+
9
+ class Lazy<I> implements Lifecycle<I> {
10
+ private started: Lifecycle<I> | undefined;
11
+ private readonly LOGGER: Logger
12
+ readonly name: string;
13
+
14
+ constructor(private readonly configurable: (config: DevServerConfig) => Service<I>) {
15
+ this.started = undefined;
16
+ this.name = randomUUID();
17
+ this.LOGGER = LoggerFactory.create(`LAZY[${this.name}]`)
18
+ }
19
+
20
+ async start(config: DevServerConfig) {
21
+ const local = this.configurable(config);
22
+ this.started = local;
23
+ this.LOGGER.info('DISCOVERY:%s', local.name)
24
+ return local.start(config);
25
+ }
26
+
27
+ stop: () => Promise<void> = async () =>
28
+ this.withStartedOr(
29
+ async (s) => s.stop(),
30
+ async () => Promise.reject(new Error('not started')),
31
+ );
32
+
33
+ private withStartedOr<T>(task: (i: Lifecycle<I>) => T, fallback: () => T) {
34
+ const local = this.started;
35
+ if (local !== undefined) task(local);
36
+ else fallback();
37
+ }
38
+ }
39
+
40
+ class Promised<I> implements Lifecycle<I> {
41
+ private readonly LOGGER: Logger;
42
+ readonly name: string;
43
+ readonly start: (config: DevServerConfig) => Promise<I>
44
+ readonly stop: () => Promise<void>
45
+
46
+ constructor(configurable: (config: DevServerConfig) => Promise<Service<I>>) {
47
+ this.name = randomUUID();
48
+ this.LOGGER = LoggerFactory.create(`PROMISED[${this.name}]`)
49
+
50
+ let initialized: Service<I>
51
+ this.start = async (config: DevServerConfig) =>
52
+ configurable(config)
53
+ .catch((e) => e?.cleanup?.() ?? Promise.reject(e))
54
+ .then((service) => initialized = service)
55
+ .tap((service) => this.LOGGER.info('DISCOVERY:%s', service.name))
56
+ .then((service) => service.start(config))
57
+
58
+ this.stop = async () => (initialized ?? { stop: async () => this.LOGGER.info('not started') }).stop()
59
+ }
60
+ }
@@ -0,0 +1,78 @@
1
+ import { Network, RandomUuid, StartedNetwork } from "testcontainers";
2
+ import { Config } from "./config";
3
+ import { Service } from "./services";
4
+ import { LocalStack } from "./localstack";
5
+ import { Sqs } from "./sqs";
6
+ import { DynamoDb } from "./dynamodb";
7
+ import { Logger, LoggerFactory } from "@yopdev/logging";
8
+ import { Sns } from "./sns";
9
+ import { terminate } from "./tunnel";
10
+ import { S3 } from "./s3";
11
+
12
+ export class DevServer {
13
+ private readonly eventProxyTopic: string
14
+ private LOGGER: Logger
15
+ private localStack: LocalStack | undefined;
16
+ private network: StartedNetwork | undefined;
17
+
18
+ constructor(
19
+ readonly name: string,
20
+ private readonly service: Service<any>,
21
+ private readonly config?: Config,
22
+ ) {
23
+ this.LOGGER = LoggerFactory.create(`DEVSERVER[${name}]`)
24
+ const encodedName = Buffer.from(name).toString('hex').substring(0, 64)
25
+ this.eventProxyTopic = `EventProxyTopic${encodedName}`
26
+ }
27
+
28
+ start: () => Promise<StartedDevServer> = async () =>
29
+ Promise.resolve(new Network(new RandomUuid()).start())
30
+ .tap((network) => this.network = network)
31
+ .then((network) => ({
32
+ localStack: this.localStack = new LocalStack(network, this.config),
33
+ network: network
34
+ }))
35
+ .then((infra) => infra.localStack.start().then((config) => ({
36
+ aws: config,
37
+ network: infra.network
38
+ })))
39
+ .then((infra) => Promise
40
+ .resolve(new Sns(infra.aws))
41
+ .then((sns) => sns
42
+ .createTopic(this.eventProxyTopic)
43
+ .then((eventProxyTopicArn) => ({
44
+ raw: infra.aws,
45
+ network: infra.network,
46
+ sqs: new Sqs(infra.aws),
47
+ sns: new Sns(infra.aws),
48
+ s3: new S3(infra.aws),
49
+ dynamo: new DynamoDb(infra.aws),
50
+ eventsProxy: {
51
+ topic: {
52
+ arn: eventProxyTopicArn
53
+ }
54
+ },
55
+ }))
56
+ ))
57
+ .tap((config) => this.LOGGER.debug('config is %o', config))
58
+ .then((aws) => this.service.start(aws))
59
+ .catch((e) => this.shutdown()
60
+ .then(() => this.LOGGER.error('error starting service'))
61
+ .then(() => Promise.reject(e)
62
+ ))
63
+ .then(() => this.LOGGER.info('started'))
64
+ .then(() => ({
65
+ name: this.name,
66
+ stop: async () => this.shutdown().then(() => this.LOGGER.info('stopped')),
67
+ }))
68
+
69
+ private shutdown = async () => this.service.stop()
70
+ .finally(terminate)
71
+ .then(() => this.localStack?.stop())
72
+ .then(() => this.network?.stop().catch((e) => this.LOGGER.warn(e, 'failed to stop network')))
73
+ }
74
+
75
+ type StartedDevServer = {
76
+ name: string
77
+ stop: () => Promise<void>
78
+ }
@@ -0,0 +1,62 @@
1
+ import { CreateTableCommand, DynamoDBClient, UpdateTimeToLiveCommand } from "@aws-sdk/client-dynamodb";
2
+ import { AwsConfig, DevServerConfig } from "./config";
3
+ import { Logger, LoggerFactory } from "@yopdev/logging";
4
+ import { Lifecycle, Service, Callback } from "./services";
5
+ import { assertNotUndefined } from "./assert";
6
+
7
+ export const newDynamoDbTable = (
8
+ name: string,
9
+ config: {
10
+ command: CreateTableCommand,
11
+ ttlAttribute?: string,
12
+ },
13
+ callback?: Callback<string>,
14
+ ) => new Service(new DynamoDbTableCreator(name, config.command, config.ttlAttribute), callback)
15
+
16
+ class DynamoDbTableCreator implements Lifecycle<string> {
17
+ private LOGGER: Logger;
18
+ constructor(
19
+ readonly name: string,
20
+ private readonly command: CreateTableCommand,
21
+ private readonly ttlAttribute: string | undefined,
22
+ ) {
23
+ this.LOGGER = LoggerFactory.create(`TABLE[${name}]`)
24
+ }
25
+
26
+ start = async (config: DevServerConfig) => config.dynamo.createTable(this.command)
27
+ .tap((output) => this.LOGGER.debug(output))
28
+ .then(() => this.ttlAttribute)
29
+ .then((ttl) => ttl !== undefined ? this.setupTimeToLive(config.dynamo, ttl) : Promise.resolve())
30
+ .then(() => this.name)
31
+
32
+ private setupTimeToLive = async (dynamodb: DynamoDb, attributeName: string) =>
33
+ dynamodb.client.send(new UpdateTimeToLiveCommand({
34
+ TableName: this.command.input.TableName,
35
+ TimeToLiveSpecification: {
36
+ Enabled: true,
37
+ AttributeName: attributeName,
38
+ }
39
+ }))
40
+ .tap((out) => this.LOGGER.info('set ttl attribute %s? %s', attributeName, out.$metadata.httpStatusCode === 200))
41
+ .then(() => undefined)
42
+
43
+ stop = () => Promise.resolve()
44
+ }
45
+
46
+ const LOGGER = LoggerFactory.create('DYNAMODB');
47
+ export class DynamoDb {
48
+ readonly client: DynamoDBClient
49
+
50
+ constructor(config: AwsConfig) {
51
+ this.client = new DynamoDBClient(config)
52
+ }
53
+
54
+ createTable = async (factory: CreateTableCommand) => this.client
55
+ .send(factory) //
56
+ .tap((table) => LOGGER.debug('created %s', assertNotUndefined(table.TableDescription).TableName))
57
+ .catch((reason) => {
58
+ LOGGER.error('failed to create table')
59
+ if (reason.name !== 'ResourceInUseException') return Promise.reject(reason);
60
+ return { TableDescription: { TableName: factory.input.TableName } }
61
+ })
62
+ }
@@ -0,0 +1,101 @@
1
+ import { Consumer } from 'sqs-consumer';
2
+ import { SNSMessage, SNSMessageAttributes, SQSEvent, SQSRecord } from 'aws-lambda';
3
+ import { mapToLambdaSqsRecord } from './mappers';
4
+ import { Sns } from './sns';
5
+ import { Sqs } from './sqs';
6
+ import { Message, SQSClient } from '@aws-sdk/client-sqs';
7
+ import { Logger, LoggerFactory } from '@yopdev/logging';
8
+ import { stopConsumer } from './stoppable';
9
+ import { assertNotUndefined } from './assert';
10
+ import { Lifecycle, Service } from './services';
11
+ import { DevServerConfig } from './config';
12
+
13
+ export const newEventsProxy = (
14
+ name: string,
15
+ config: {
16
+ handlers: EventHandler[],
17
+ topic?: string,
18
+ mapper?: (message: Message) => SQSRecord,
19
+ },
20
+ ) => new Service(new EventsProxy(name, config.handlers, config.mapper ?? mapToLambdaSqsRecord, config.topic))
21
+ class EventsProxy implements Lifecycle<void> {
22
+ private LOGGER: Logger
23
+ private consumer: Consumer | undefined;
24
+ private readonly pollingFrequency = 1;
25
+ private stopped = true;
26
+
27
+ constructor(
28
+ readonly name: string,
29
+ private readonly handlers: EventHandler[],
30
+ private readonly mapper: (message: Message) => SQSRecord,
31
+ private readonly topic?: string,
32
+ ) {
33
+ this.LOGGER = LoggerFactory.create(`SNS->SQS[${name}]`);
34
+ this.LOGGER.debug(
35
+ 'handling events from %s',
36
+ topic ?? 'the event bus',
37
+ );
38
+ }
39
+
40
+ private queueName = () => `EventProxyQueue${Buffer.from(this.name).toString('hex').substring(0, 64)}`
41
+
42
+ start = async (config: DevServerConfig) => this.queueConnectedToTopic(config.sns, this.topic ?? config.eventsProxy.topic.arn, config.sqs, this.queueName())
43
+ .then((url) => this.startEventsConsumer(config.sqs.client, url))
44
+ .tap(() => this.stopped = false)
45
+ .then((consumer) => this.LOGGER.debug(consumer))
46
+
47
+ private queueConnectedToTopic = async (sns: Sns, topic: string, sqs: Sqs, queue: string) => sqs.createStandardQueue(queue)
48
+ .then((queue) => sns
49
+ .createSubscription({ arn: topic }, { arn: queue.arn })
50
+ .then(() => queue.url)
51
+ )
52
+
53
+ private startEventsConsumer = async (sqs: SQSClient, url: string): Promise<string> => {
54
+ this.consumer = Consumer.create({
55
+ waitTimeSeconds: this.pollingFrequency,
56
+ queueUrl: url,
57
+ sqs: sqs,
58
+ handleMessage: async (message) => this.onEachMessage(message),
59
+ });
60
+ this.consumer.start();
61
+ return url;
62
+ }
63
+
64
+ private extractErrorMessage = (e: any) => typeof e === 'string' ? e : e.message !== undefined && typeof e.message === 'string' ? e.message : ''
65
+
66
+ private onEachMessage = async (message: Message) => {
67
+ if (this.stopped) {
68
+ this.LOGGER.warn('stopped events proxy received message %o', message);
69
+ return;
70
+ }
71
+ const body = assertNotUndefined(message.Body, 'body is not present');
72
+ const json = JSON.parse(body) as SNSMessage
73
+ const attributes = json.MessageAttributes
74
+ const subject = json.Subject;
75
+
76
+ const handlers = this.handlers.filter((handler) => handler.matcher(subject, attributes));
77
+ if (handlers.length === 0) {
78
+ this.LOGGER.warn('no handlers found for message %o', message);
79
+ }
80
+ const record = this.mapper(message);
81
+ return Promise.all(handlers.map((handler) =>
82
+ handler
83
+ .handler({ Records: [record] })
84
+ .then(() => {
85
+ this.LOGGER.debug('handler %s accepted message', handler.name);
86
+ })
87
+ .catch((e) => {
88
+ const error = this.extractErrorMessage(e);
89
+ this.LOGGER.error(e, 'handler %s failed with %s', handler.name, error);
90
+ })
91
+ )).then(() => undefined)
92
+ }
93
+
94
+ stop = async () => stopConsumer(this.pollingFrequency, this.consumer)
95
+ }
96
+
97
+ export type EventHandler = {
98
+ name: string;
99
+ handler: (event: SQSEvent) => Promise<unknown>;
100
+ matcher: (subject: string, attributes: SNSMessageAttributes) => boolean;
101
+ };
@@ -0,0 +1,19 @@
1
+ import { DevServerConfig } from "./config";
2
+ import { Service } from "./services";
3
+
4
+ export const all = (services: Service<any>[]): Service<any> => new Service(
5
+ {
6
+ name: `ALLOF[${services.map((each) => each.name).join(',')}]`,
7
+ start: async (config: DevServerConfig) => Promise.all(services.map((each) => each.start(config))).then(() => this),
8
+ stop: async () => Promise.all(services.map((each) => each.stop())).then()
9
+ },
10
+ )
11
+
12
+ export const oneThenOther = (one: Service<unknown>, other: Service<unknown>): Service<unknown> =>
13
+ new Service(
14
+ {
15
+ name: `[${one.name}]->[${other.name}]`,
16
+ start: async (config: DevServerConfig) => one.start(config).then(async () => other.start(config)),
17
+ stop: async () => other.stop().then(async () => one.stop()),
18
+ },
19
+ );