@yopdev/dev-server 3.0.2-RC → 3.0.2-RC2
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.
- package/.github/workflows/npm-publish.yml +34 -0
- package/__tests__/bootstrap.test.ts +89 -0
- package/__tests__/deferred.test.ts +86 -0
- package/__tests__/event-proxy.test.ts +42 -0
- package/__tests__/lambda-http-proxy.test.ts +179 -0
- package/dist/__tests__/bootstrap.test.d.ts +1 -0
- package/dist/__tests__/bootstrap.test.js +90 -0
- package/dist/__tests__/deferred.test.d.ts +1 -0
- package/dist/__tests__/deferred.test.js +76 -0
- package/dist/__tests__/event-proxy.test.d.ts +1 -0
- package/dist/__tests__/event-proxy.test.js +37 -0
- package/dist/__tests__/lambda-http-proxy.test.d.ts +1 -0
- package/dist/__tests__/lambda-http-proxy.test.js +135 -0
- package/dist/src/assert.d.ts +1 -0
- package/dist/src/assert.js +9 -0
- package/dist/src/cloudformation-dynamodb-table.d.ts +13 -0
- package/dist/src/cloudformation-dynamodb-table.js +45 -0
- package/dist/src/cloudformation-event-proxy.d.ts +13 -0
- package/dist/src/cloudformation-event-proxy.js +25 -0
- package/dist/src/cloudformation-lambda-http-proxy.d.ts +14 -0
- package/dist/src/cloudformation-lambda-http-proxy.js +62 -0
- package/dist/src/cloudformation.d.ts +28 -0
- package/dist/src/cloudformation.js +50 -0
- package/dist/src/config.d.ts +31 -0
- package/dist/src/config.js +2 -0
- package/dist/src/container.d.ts +18 -0
- package/dist/src/container.js +33 -0
- package/dist/src/deferred.d.ts +4 -0
- package/dist/src/deferred.js +45 -0
- package/dist/src/dev-server.d.ts +19 -0
- package/dist/src/dev-server.js +63 -0
- package/dist/src/dynamodb.d.ts +16 -0
- package/dist/src/dynamodb.js +48 -0
- package/dist/src/event-proxy.d.ts +13 -0
- package/dist/src/event-proxy.js +68 -0
- package/dist/src/factories.d.ts +3 -0
- package/dist/src/factories.js +16 -0
- package/dist/src/http-server.d.ts +25 -0
- package/dist/src/http-server.js +37 -0
- package/dist/src/index.d.ts +24 -0
- package/dist/src/index.js +46 -0
- package/dist/src/internal-queue.d.ts +11 -0
- package/dist/src/internal-queue.js +53 -0
- package/dist/src/lambda-http-proxy.d.ts +28 -0
- package/dist/src/lambda-http-proxy.js +50 -0
- package/dist/src/localstack.d.ts +11 -0
- package/dist/src/localstack.js +62 -0
- package/dist/src/mappers.d.ts +25 -0
- package/dist/src/mappers.js +176 -0
- package/dist/src/pre-traffic-hooks.d.ts +2 -0
- package/dist/src/pre-traffic-hooks.js +19 -0
- package/dist/src/responses.d.ts +5 -0
- package/dist/src/responses.js +25 -0
- package/dist/src/s3.d.ts +7 -0
- package/dist/src/s3.js +20 -0
- package/dist/src/scheduled-tasks.d.ts +6 -0
- package/dist/src/scheduled-tasks.js +20 -0
- package/dist/src/services.d.ts +22 -0
- package/dist/src/services.js +26 -0
- package/dist/src/sns-http-proxy.d.ts +28 -0
- package/dist/src/sns-http-proxy.js +66 -0
- package/dist/src/sns.d.ts +15 -0
- package/dist/src/sns.js +35 -0
- package/dist/src/sqs.d.ts +13 -0
- package/dist/src/sqs.js +33 -0
- package/dist/src/stoppable.d.ts +2 -0
- package/dist/src/stoppable.js +15 -0
- package/dist/src/tunnel.d.ts +10 -0
- package/dist/src/tunnel.js +52 -0
- package/jest.config.js +7 -0
- package/package.json +2 -5
- package/src/assert.ts +4 -0
- package/src/cloudformation-dynamodb-table.ts +97 -0
- package/src/cloudformation-event-proxy.ts +61 -0
- package/src/cloudformation-lambda-http-proxy.ts +125 -0
- package/src/cloudformation.ts +95 -0
- package/src/config.ts +34 -0
- package/src/container.ts +82 -0
- package/src/deferred.ts +60 -0
- package/src/dev-server.ts +78 -0
- package/src/dynamodb.ts +62 -0
- package/src/event-proxy.ts +101 -0
- package/src/factories.ts +19 -0
- package/src/http-server.ts +59 -0
- package/src/index.ts +32 -0
- package/src/internal-queue.ts +89 -0
- package/src/lambda-http-proxy.ts +111 -0
- package/src/localstack.ts +74 -0
- package/src/mappers.ts +231 -0
- package/src/pre-traffic-hooks.ts +24 -0
- package/src/responses.ts +28 -0
- package/src/s3.ts +24 -0
- package/src/scheduled-tasks.ts +31 -0
- package/src/services.ts +46 -0
- package/src/sns-http-proxy.ts +109 -0
- package/src/sns.ts +49 -0
- package/src/sqs.ts +46 -0
- package/src/stoppable.ts +10 -0
- package/src/tunnel.ts +32 -0
- package/tsconfig.json +9 -0
package/src/dynamodb.ts
ADDED
|
@@ -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
|
+
};
|
package/src/factories.ts
ADDED
|
@@ -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
|
+
);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { IncomingMessage, Server, ServerResponse, createServer } from "http";
|
|
2
|
+
import { once } from "events";
|
|
3
|
+
import { Logger, LoggerFactory } from "@yopdev/logging";
|
|
4
|
+
import { AddressInfo } from "net";
|
|
5
|
+
import { assertNotUndefined } from "./assert";
|
|
6
|
+
import { Lifecycle, Service, Callback } from "./services";
|
|
7
|
+
|
|
8
|
+
export const newHttpServer = (
|
|
9
|
+
name: string,
|
|
10
|
+
config: {
|
|
11
|
+
settings: HttpSettings,
|
|
12
|
+
handler: (request: IncomingMessage, requestBody: string, response: ServerResponse) => void,
|
|
13
|
+
},
|
|
14
|
+
callback?: Callback<string>,
|
|
15
|
+
) => new Service(new HttpServer(name, config.settings, config.handler), callback)
|
|
16
|
+
|
|
17
|
+
export class HttpServer implements Lifecycle<string> {
|
|
18
|
+
private LOGGER: Logger
|
|
19
|
+
private readonly protocol: string
|
|
20
|
+
private address: AddressInfo | undefined
|
|
21
|
+
private readonly server: Promise<Server>
|
|
22
|
+
|
|
23
|
+
constructor(
|
|
24
|
+
readonly name: string,
|
|
25
|
+
readonly settings: HttpSettings,
|
|
26
|
+
readonly handler: (request: IncomingMessage, requestBody: string, response: ServerResponse) => void,
|
|
27
|
+
) {
|
|
28
|
+
const server = createServer((req, res) => {
|
|
29
|
+
req.setEncoding('utf-8')
|
|
30
|
+
|
|
31
|
+
let body = ''
|
|
32
|
+
|
|
33
|
+
req.on('data', (chunks) => body = body.concat(chunks));
|
|
34
|
+
req.on('end', () => handler(req, body, res));
|
|
35
|
+
})
|
|
36
|
+
.listen(settings.port, settings.host, () => this.address = server.address() as AddressInfo)
|
|
37
|
+
this.server = once(server, 'listening')
|
|
38
|
+
.then(() => server)
|
|
39
|
+
this.protocol = settings.protocol
|
|
40
|
+
this.LOGGER = LoggerFactory.create(`HTTPSERVER[${name}]`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
start = () => this.server
|
|
44
|
+
.then(() => this.LOGGER.debug('listening [%s]', assertNotUndefined(this.address).port))
|
|
45
|
+
.then(() => this.endpointUrl())
|
|
46
|
+
|
|
47
|
+
private endpointUrl = () => {
|
|
48
|
+
const address = assertNotUndefined(this.address, 'server not started')
|
|
49
|
+
return `${this.protocol}//${address.address}:${address.port}/`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
stop = () => this.server.then((server) => server.close()).then(() => undefined)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type HttpSettings = {
|
|
56
|
+
protocol: string
|
|
57
|
+
host: string
|
|
58
|
+
port: number | undefined
|
|
59
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export { DevServer } from './dev-server'
|
|
2
|
+
export { newHttpServer as httpServer, HttpSettings } from './http-server'
|
|
3
|
+
export { newLambdaHttpProxy as lambdaHttpProxy, UNAUTHORIZED } from './lambda-http-proxy'
|
|
4
|
+
export { v1 as v1LambdaProxyPayload, v2 as v2LambdaProxyPayload } from './mappers'
|
|
5
|
+
export { newLambdaProxyFromCloudFormationTemplate as cloudFormationLambdaProxy } from './cloudformation-lambda-http-proxy'
|
|
6
|
+
export { newDynamoDbTableFromCloudFormationTemplate as cloudFormationDynamoDbTable } from './cloudformation-dynamodb-table'
|
|
7
|
+
export { newSnsHttpProxy as snsHttpProxy } from './sns-http-proxy'
|
|
8
|
+
export { newInternalQueue as internalQueue } from './internal-queue'
|
|
9
|
+
export { newEventsProxy as eventsProxy } from './event-proxy'
|
|
10
|
+
export { snsEventsFromCloudFormationTemplate as cloudFormationSnsEventHandlers } from './cloudformation-event-proxy'
|
|
11
|
+
export { newScheduledTasks as scheduledTasks } from './scheduled-tasks'
|
|
12
|
+
export { newPreTrafficHooks as preTrafficHooks } from './pre-traffic-hooks'
|
|
13
|
+
export { newDynamoDbTable as dynamoDbTable } from './dynamodb'
|
|
14
|
+
export { newContainer as container } from './container'
|
|
15
|
+
export { newTunnel as tunnel } from './tunnel'
|
|
16
|
+
export { all as allOf, oneThenOther } from './factories'
|
|
17
|
+
export { lazy, promised } from './deferred'
|
|
18
|
+
export { Lifecycle, Service, Startable } from './services'
|
|
19
|
+
export { DevServerConfig } from './config'
|
|
20
|
+
|
|
21
|
+
declare global {
|
|
22
|
+
interface Promise<T> {
|
|
23
|
+
tap(onfulfilled?: (value: T) => void, onrejected?: (reason: unknown) => void): Promise<T>;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
Promise.prototype.tap = async function <T>(
|
|
28
|
+
onfulfilled?: (value: T) => void,
|
|
29
|
+
onrejected?: (reason: unknown) => void,
|
|
30
|
+
): Promise<any> {
|
|
31
|
+
return this.then(onfulfilled, onrejected).then(async () => this);
|
|
32
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Logger, LoggerFactory } from "@yopdev/logging";
|
|
2
|
+
import { Queue, Sqs } from "./sqs";
|
|
3
|
+
import { SQSEvent, SQSRecord } from "aws-lambda";
|
|
4
|
+
import { Consumer } from "sqs-consumer";
|
|
5
|
+
import { stopConsumer } from "./stoppable";
|
|
6
|
+
import { mapToLambdaSqsRecord } from "./mappers";
|
|
7
|
+
import { Lifecycle, Service, Callback } from "./services";
|
|
8
|
+
import { DevServerConfig } from "./config";
|
|
9
|
+
import { Message, SQSClient } from "@aws-sdk/client-sqs";
|
|
10
|
+
|
|
11
|
+
export const newInternalQueue = (
|
|
12
|
+
name: string,
|
|
13
|
+
config: {
|
|
14
|
+
name: string,
|
|
15
|
+
visibility: number,
|
|
16
|
+
handler: (queue: Queue) => (e: SQSEvent) => Promise<void>,
|
|
17
|
+
fifo: boolean,
|
|
18
|
+
mapper?: (message: Message) => SQSRecord,
|
|
19
|
+
},
|
|
20
|
+
callback?: Callback<string>
|
|
21
|
+
) => new Service(new InternalQueue(
|
|
22
|
+
name,
|
|
23
|
+
config.visibility,
|
|
24
|
+
config.handler,
|
|
25
|
+
(sqs: Sqs) => config.fifo ? sqs.createFifoQueue : sqs.createStandardQueue,
|
|
26
|
+
config.mapper ?? mapToLambdaSqsRecord,
|
|
27
|
+
), callback)
|
|
28
|
+
|
|
29
|
+
class InternalQueue implements Lifecycle<string> {
|
|
30
|
+
private LOGGER: Logger
|
|
31
|
+
|
|
32
|
+
private consumer: Consumer | undefined;
|
|
33
|
+
|
|
34
|
+
constructor(
|
|
35
|
+
readonly name: string,
|
|
36
|
+
private readonly visibility: number,
|
|
37
|
+
private readonly handler: (queue: Queue) => ((e: SQSEvent) => Promise<void>),
|
|
38
|
+
private readonly creator: (sqs: Sqs) => (name: string) => Promise<Queue>,
|
|
39
|
+
private readonly mapper: (message: Message) => SQSRecord,
|
|
40
|
+
) {
|
|
41
|
+
this.LOGGER = LoggerFactory.create(`INTERNALQUEUE[${name}]`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
start = (config: DevServerConfig) => this.create(config.sqs)
|
|
45
|
+
|
|
46
|
+
private create = async (sqs: Sqs) => this.createQueue(sqs, this.name)
|
|
47
|
+
.then((queue) => this
|
|
48
|
+
.createConsumer(sqs.client, this.name, queue.url, this.handler(queue), this.visibility)
|
|
49
|
+
.then(() => queue.url)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
private async createQueue(sqs: Sqs, name: string) {
|
|
53
|
+
return this.creator(sqs)(name) //
|
|
54
|
+
.then((queue) => {
|
|
55
|
+
this.LOGGER.info('created: %s', queue.url);
|
|
56
|
+
return queue;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private createConsumer = async (
|
|
61
|
+
sqs: SQSClient,
|
|
62
|
+
name: string,
|
|
63
|
+
url: string,
|
|
64
|
+
handler: (e: SQSEvent) => Promise<void>,
|
|
65
|
+
visibility: number,
|
|
66
|
+
): Promise<InternalQueue> => Promise.resolve(() =>
|
|
67
|
+
this.consumer = Consumer.create({
|
|
68
|
+
queueUrl: url,
|
|
69
|
+
waitTimeSeconds: visibility,
|
|
70
|
+
sqs: sqs,
|
|
71
|
+
handleMessage: async (message) => handler({ Records: [this.mapper(message)] }),
|
|
72
|
+
}))
|
|
73
|
+
.then((factory) => factory())
|
|
74
|
+
.then((consumer) => {
|
|
75
|
+
consumer.on('error', (err) => {
|
|
76
|
+
this.LOGGER.error(err, 'failed to handle message');
|
|
77
|
+
});
|
|
78
|
+
consumer.on('processing_error', (err) => {
|
|
79
|
+
this.LOGGER.error(err, 'failed to process message');
|
|
80
|
+
});
|
|
81
|
+
this.LOGGER.info('consumer for %s initialized', name);
|
|
82
|
+
return consumer;
|
|
83
|
+
})
|
|
84
|
+
.then(async (consumer) => consumer.start())
|
|
85
|
+
.then(() => this.LOGGER.info('started'))
|
|
86
|
+
.then(() => this)
|
|
87
|
+
|
|
88
|
+
stop = async () => this.consumer !== undefined ? stopConsumer(this.visibility, this.consumer) : this.LOGGER.warn('no consumer')
|
|
89
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { Logger, LoggerFactory } from "@yopdev/logging";
|
|
2
|
+
import { HttpServer, HttpSettings } from "./http-server";
|
|
3
|
+
import { IncomingMessage, ServerResponse } from "http";
|
|
4
|
+
import { LambdaMapperFactory } from "./mappers";
|
|
5
|
+
import { internalServerError, writeResponse } from "./responses";
|
|
6
|
+
import { Lifecycle, Service, Callback } from "./services";
|
|
7
|
+
|
|
8
|
+
export const UNAUTHORIZED = new Error('UNAUTHORIZED');
|
|
9
|
+
export const newLambdaHttpProxy = <Context, AuthorizerContext, Event, HandlerResponse>(
|
|
10
|
+
name: string,
|
|
11
|
+
config: {
|
|
12
|
+
settings: HttpSettings,
|
|
13
|
+
routes: Route<Context, AuthorizerContext, Event, HandlerResponse>[],
|
|
14
|
+
mapper: LambdaMapperFactory<AuthorizerContext, Event, HandlerResponse>,
|
|
15
|
+
authorizer?: Authorizer<AuthorizerContext>
|
|
16
|
+
context?: () => Context
|
|
17
|
+
},
|
|
18
|
+
callback?: Callback<string>,
|
|
19
|
+
) => new Service(new LambdaHttpProxy<Context, AuthorizerContext, Event, HandlerResponse>(
|
|
20
|
+
name,
|
|
21
|
+
config.settings,
|
|
22
|
+
config.routes,
|
|
23
|
+
config.mapper,
|
|
24
|
+
config.authorizer ?? (() => Promise.resolve(undefined)),
|
|
25
|
+
config.context,
|
|
26
|
+
), callback)
|
|
27
|
+
|
|
28
|
+
class LambdaHttpProxy<Context, AuthorizerContext, Event, HandlerResponse> implements Lifecycle<string> {
|
|
29
|
+
private LOGGER: Logger
|
|
30
|
+
private readonly server: HttpServer
|
|
31
|
+
|
|
32
|
+
constructor(
|
|
33
|
+
readonly name: string,
|
|
34
|
+
settings: HttpSettings,
|
|
35
|
+
private readonly routes: Route<Context, AuthorizerContext, Event, HandlerResponse>[],
|
|
36
|
+
private readonly mapper: LambdaMapperFactory<AuthorizerContext, Event, HandlerResponse>,
|
|
37
|
+
private readonly defaultAuthorizer: Authorizer<AuthorizerContext>,
|
|
38
|
+
private readonly context: () => Context = () => undefined,
|
|
39
|
+
) {
|
|
40
|
+
this.server = new HttpServer(name, settings, this.resolveRoute)
|
|
41
|
+
this.LOGGER = LoggerFactory.create(`HTTP->LAMBDA[${this.name}]`);
|
|
42
|
+
this.LOGGER.info('registered %i routes', routes.length)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
start = () => this.server.start()
|
|
46
|
+
|
|
47
|
+
stop = () => this.server.stop()
|
|
48
|
+
|
|
49
|
+
private handler = (
|
|
50
|
+
authorizer: Authorizer<AuthorizerContext>,
|
|
51
|
+
lambdaHandler: (event: Event, context: Context) => Promise<HandlerResponse>
|
|
52
|
+
) =>
|
|
53
|
+
(request: IncomingMessage, body: string, response: ServerResponse) =>
|
|
54
|
+
this.mapper.newInstance(request, body)
|
|
55
|
+
.then(async (mapper) =>
|
|
56
|
+
authorizer(mapper.authorization())
|
|
57
|
+
.then(async (context) =>
|
|
58
|
+
lambdaHandler(mapper.event(context), this.context())
|
|
59
|
+
.then((lambda) => {
|
|
60
|
+
const result = mapper.toResponse(lambda)
|
|
61
|
+
return writeResponse(
|
|
62
|
+
response,
|
|
63
|
+
result.statusCode,
|
|
64
|
+
result.body(),
|
|
65
|
+
result.contentType,
|
|
66
|
+
result.location,
|
|
67
|
+
result.cookies,
|
|
68
|
+
)
|
|
69
|
+
})
|
|
70
|
+
.catch((e) => {
|
|
71
|
+
this.LOGGER.error(e, 'request failed to execute');
|
|
72
|
+
internalServerError(response, e.body);
|
|
73
|
+
}),
|
|
74
|
+
(e) => e === UNAUTHORIZED ? writeResponse(response, 401, '') : internalServerError(response, e))
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
private fallback = async (request: IncomingMessage, _: string, response: ServerResponse) =>
|
|
78
|
+
Promise.resolve(this.LOGGER.warn(`FALLBACK: ${request.method} to ${request.url}`)).then(() =>
|
|
79
|
+
writeResponse(
|
|
80
|
+
response,
|
|
81
|
+
404,
|
|
82
|
+
`no route found to handle ${request.method} to ${request.url}`
|
|
83
|
+
))
|
|
84
|
+
|
|
85
|
+
private resolveRoute = (request: IncomingMessage, body: string, response: ServerResponse) =>
|
|
86
|
+
(this.routes.filter((r) => r.method.test(request.method))
|
|
87
|
+
.filter((r) => r.path.test(request.url))
|
|
88
|
+
.sort((r1, r2) => r2.weight - r1.weight)
|
|
89
|
+
.map((r) => this.handler(r.authorizer ?? this.defaultAuthorizer, r.handler))
|
|
90
|
+
.find(() => true) ?? this.fallback)(request, body, response)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export abstract class Response {
|
|
94
|
+
constructor(
|
|
95
|
+
readonly statusCode: number,
|
|
96
|
+
readonly contentType: string | undefined,
|
|
97
|
+
readonly location: string | undefined,
|
|
98
|
+
readonly cookies: string[] | undefined,
|
|
99
|
+
) { }
|
|
100
|
+
body: () => string | Buffer | undefined
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type Route<Context, AuthorizerContext, Event, HandlerResponse> = {
|
|
104
|
+
method: RegExp;
|
|
105
|
+
path: RegExp;
|
|
106
|
+
weight: number;
|
|
107
|
+
authorizer?: Authorizer<AuthorizerContext>;
|
|
108
|
+
handler: (event: Event, context: Context) => Promise<HandlerResponse>;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export type Authorizer<Context> = (authorization: string) => Promise<Context | undefined>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { LoggerFactory } from "@yopdev/logging";
|
|
2
|
+
import { GenericContainer, StartedNetwork, StartedTestContainer } from "testcontainers";
|
|
3
|
+
import { AwsConfig, Config } from "./config";
|
|
4
|
+
import { Stoppable } from "./services";
|
|
5
|
+
|
|
6
|
+
const LOGGER = LoggerFactory.create('LOCALSTACK');
|
|
7
|
+
const SERVICES_PORT = 4566;
|
|
8
|
+
const NETWORK_ALIAS = 'localstack';
|
|
9
|
+
const PROTOCOL = 'http';
|
|
10
|
+
const DEFAULT_DOCKER_IMAGE = 'localstack/localstack:stable'
|
|
11
|
+
export class LocalStack implements Stoppable {
|
|
12
|
+
private readonly _start: () => Promise<string>
|
|
13
|
+
private started: StartedTestContainer | undefined
|
|
14
|
+
|
|
15
|
+
constructor(network: StartedNetwork, config?: Config) {
|
|
16
|
+
const concreteConfig = this.configOrDefaults(config)
|
|
17
|
+
const container = new GenericContainer(concreteConfig.localStackDockerImage)
|
|
18
|
+
const localStatePath = concreteConfig.localStateBindMount
|
|
19
|
+
const withLocalState = localStatePath !== undefined ?
|
|
20
|
+
container
|
|
21
|
+
.withEnvironment({
|
|
22
|
+
PERSISTENCE: '1',
|
|
23
|
+
DYNAMODB_REMOVE_EXPIRED_ITEMS: '1',
|
|
24
|
+
})
|
|
25
|
+
.withBindMounts([{
|
|
26
|
+
source: localStatePath,
|
|
27
|
+
target: '/var/lib/localstack',
|
|
28
|
+
mode: 'rw',
|
|
29
|
+
}])
|
|
30
|
+
: container
|
|
31
|
+
const ready = withLocalState
|
|
32
|
+
.withExposedPorts(concreteConfig.exposedPort)
|
|
33
|
+
.withNetwork(network)
|
|
34
|
+
.withNetworkAliases(NETWORK_ALIAS)
|
|
35
|
+
|
|
36
|
+
this._start = () => ready
|
|
37
|
+
.start()
|
|
38
|
+
.then((container) => this.started = container)
|
|
39
|
+
.then((container) => `${PROTOCOL}://${container.getHost()}:${container.getMappedPort(SERVICES_PORT)}`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private configOrDefaults(config?: Config): LocalStackConfig {
|
|
43
|
+
const selectedServicesPort = config?.boundServicesPort
|
|
44
|
+
const exposedPort = selectedServicesPort !== undefined ?
|
|
45
|
+
{ container: SERVICES_PORT, host: selectedServicesPort } :
|
|
46
|
+
SERVICES_PORT
|
|
47
|
+
return {
|
|
48
|
+
localStackDockerImage: config?.localStackDockerImage || DEFAULT_DOCKER_IMAGE,
|
|
49
|
+
exposedPort: exposedPort,
|
|
50
|
+
localStateBindMount: config?.localStateBindMount,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
start = async (): Promise<AwsConfig> => this._start()
|
|
55
|
+
.then((endpoint) => ({
|
|
56
|
+
region: 'us-east-1',
|
|
57
|
+
endpoint: endpoint,
|
|
58
|
+
credentials: {
|
|
59
|
+
accessKeyId: 'dummy',
|
|
60
|
+
secretAccessKey: 'dummy'
|
|
61
|
+
}
|
|
62
|
+
}))
|
|
63
|
+
.tap(() => LOGGER.debug('started'))
|
|
64
|
+
|
|
65
|
+
stop = () => Promise.resolve(this.started)
|
|
66
|
+
.then((container) => container ? container.stop().then(() => 'stopped') : 'not started')
|
|
67
|
+
.then((status) => LOGGER.debug(status))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type LocalStackConfig = {
|
|
71
|
+
localStackDockerImage: string
|
|
72
|
+
exposedPort: { container: number, host: number } | number
|
|
73
|
+
localStateBindMount: string | undefined
|
|
74
|
+
}
|