@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.
- package/.github/workflows/npm-publish.yml +33 -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/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
|
@@ -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
|
+
}
|
package/src/mappers.ts
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { Message } from "@aws-sdk/client-sqs";
|
|
2
|
+
import { APIGatewayEventRequestContextV2WithAuthorizer, APIGatewayProxyEvent, APIGatewayProxyEventHeaders, APIGatewayProxyEventPathParameters, APIGatewayProxyEventQueryStringParameters, APIGatewayProxyEventV2, APIGatewayProxyEventV2WithRequestContext, APIGatewayProxyResult, APIGatewayProxyStructuredResultV2, SQSRecord } from "aws-lambda";
|
|
3
|
+
import { IncomingMessage } from "http";
|
|
4
|
+
import { URL } from "url";
|
|
5
|
+
import { Response } from "./lambda-http-proxy";
|
|
6
|
+
|
|
7
|
+
export const mapToLambdaSqsRecord = (message: Message): SQSRecord => {
|
|
8
|
+
if (!message.Body) throw new Error('message Body must be present');
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
messageId: 'N/A',
|
|
12
|
+
receiptHandle: 'N/A',
|
|
13
|
+
body: message.Body,
|
|
14
|
+
attributes: {
|
|
15
|
+
ApproximateReceiveCount: 'N/A',
|
|
16
|
+
SentTimestamp: 'N/A',
|
|
17
|
+
SenderId: 'N/A',
|
|
18
|
+
ApproximateFirstReceiveTimestamp: 'N/A',
|
|
19
|
+
},
|
|
20
|
+
messageAttributes: {},
|
|
21
|
+
md5OfBody: 'N/A',
|
|
22
|
+
eventSource: 'N/A',
|
|
23
|
+
eventSourceARN: 'N/A',
|
|
24
|
+
awsRegion: 'N/A',
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const payloadV1JwtAuthorizerLambdaMapper = <AuthorizerContext>(): LambdaMapperFactory<AuthorizerContext, APIGatewayProxyEvent, APIGatewayProxyResult> => ({
|
|
29
|
+
newInstance: (request, body) => Promise.resolve(new DefaultLambdaMapper<AuthorizerContext>(request, body))
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const payloadV2LambdaAuthorizerLambdaMapper = <AuthorizerContext>(): LambdaMapperFactory<AuthorizerContext, APIGatewayEventRequestContextV2WithGenericAuthorizer<AuthorizerContext>, APIGatewayProxyStructuredResultV2> => ({
|
|
33
|
+
newInstance: (request, body) => Promise.resolve(new DefaultLambdaMapper<AuthorizerContext>(request, body))
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const payloadV1PathParameterResolver: PathParameterResolver<APIGatewayProxyEvent> = {
|
|
37
|
+
locate: (event: APIGatewayProxyEvent) => event.path,
|
|
38
|
+
store: (event: APIGatewayProxyEvent, params: APIGatewayProxyEventPathParameters) => event.pathParameters = params
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const payloadV2PathParameterResolver: PathParameterResolver<APIGatewayProxyEventV2> = {
|
|
42
|
+
locate: (event: APIGatewayProxyEventV2) => event.rawPath,
|
|
43
|
+
store: (event: APIGatewayProxyEventV2, params: APIGatewayProxyEventPathParameters) => event.pathParameters = params
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type LambdaPayloadVersion<AuthorizerContext, Event, HandlerResponse> = {
|
|
47
|
+
mapper: LambdaMapperFactory<AuthorizerContext, Event, HandlerResponse>,
|
|
48
|
+
resolver: PathParameterResolver<Event>,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const v1 = <AuthorizerContext>(): LambdaPayloadVersion<AuthorizerContext, APIGatewayProxyEvent, APIGatewayProxyResult> => ({
|
|
52
|
+
mapper: payloadV1JwtAuthorizerLambdaMapper<AuthorizerContext>(),
|
|
53
|
+
resolver: payloadV1PathParameterResolver,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
export const v2 = <AuthorizerContext>(): LambdaPayloadVersion<AuthorizerContext, APIGatewayProxyEventV2, APIGatewayProxyStructuredResultV2> => ({
|
|
57
|
+
mapper: payloadV2LambdaAuthorizerLambdaMapper<AuthorizerContext>(),
|
|
58
|
+
resolver: payloadV2PathParameterResolver,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
class DefaultLambdaMapper<AuthorizerContext> implements LambdaMapper<AuthorizerContext, APIGatewayProxyEvent & APIGatewayEventRequestContextV2WithGenericAuthorizer<AuthorizerContext>, APIGatewayProxyResult & APIGatewayProxyStructuredResultV2> {
|
|
62
|
+
private readonly url: URL
|
|
63
|
+
private readonly authorizationHeaderValue: string
|
|
64
|
+
private readonly time: number
|
|
65
|
+
private readonly method: string
|
|
66
|
+
private readonly queryStringParameters: APIGatewayProxyEventQueryStringParameters
|
|
67
|
+
private readonly headers: APIGatewayProxyEventHeaders
|
|
68
|
+
|
|
69
|
+
constructor(request: IncomingMessage, private readonly body: string) {
|
|
70
|
+
if (!request.url || !request.method) throw new Error('url and method are required');
|
|
71
|
+
|
|
72
|
+
const url = new URL(request.url, `http://${request.headers.host}`);
|
|
73
|
+
|
|
74
|
+
const qsp: APIGatewayProxyEventQueryStringParameters = {};
|
|
75
|
+
url.searchParams.forEach((v, k) => (qsp[k] = decodeURIComponent(v)));
|
|
76
|
+
this.queryStringParameters = qsp;
|
|
77
|
+
|
|
78
|
+
const headers: APIGatewayProxyEventHeaders = {};
|
|
79
|
+
if (request.headers['content-type']) headers['content-type'] = request.headers['content-type'];
|
|
80
|
+
if (request.headers['accept']) headers['accept'] = request.headers['accept'];
|
|
81
|
+
if (request.headers['origin']) headers['origin'] = request.headers['origin'];
|
|
82
|
+
if (request.headers['cookie']) headers['cookie'] = request.headers['cookie'];
|
|
83
|
+
const authorizationHeaderValue = request.headers['authorization']
|
|
84
|
+
if (authorizationHeaderValue) {
|
|
85
|
+
headers['authorization'] = authorizationHeaderValue;
|
|
86
|
+
this.authorizationHeaderValue = authorizationHeaderValue;
|
|
87
|
+
}
|
|
88
|
+
this.headers = headers
|
|
89
|
+
this.url = url
|
|
90
|
+
this.time = 1428582896000;
|
|
91
|
+
this.method = request.method
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
toResponse = (response: APIGatewayProxyResult | APIGatewayProxyStructuredResultV2) => response.isBase64Encoded ?
|
|
95
|
+
new Base64EncodedResponse(response.statusCode, response.headers?.['content-type']?.toString(), response.headers?.['location']?.toString(), response.body, this.readSetCookies(response)) :
|
|
96
|
+
new StringResponse(response.statusCode, response.headers?.['content-type']?.toString(), response.headers?.['location']?.toString(), response.body, this.readSetCookies(response))
|
|
97
|
+
|
|
98
|
+
private readSetCookies = (response: APIGatewayProxyResult | APIGatewayProxyStructuredResultV2): string[] => {
|
|
99
|
+
// V2: structured response with cookies array
|
|
100
|
+
if ('cookies' in response && Array.isArray(response.cookies)) {
|
|
101
|
+
return response.cookies;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// V1: check multiValueHeaders first
|
|
105
|
+
if ('multiValueHeaders' in response && response.multiValueHeaders?.['Set-Cookie']) {
|
|
106
|
+
return response.multiValueHeaders['Set-Cookie'].map(c => c.toString());
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// V1: fallback to single header
|
|
110
|
+
if ('headers' in response && response.headers?.['Set-Cookie']) {
|
|
111
|
+
const cookieHeader = response.headers['Set-Cookie'];
|
|
112
|
+
return Array.isArray(cookieHeader) ? cookieHeader.map(c => c.toString()) : [cookieHeader];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return [];
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
event = (context: AuthorizerContext): APIGatewayProxyEvent & APIGatewayEventRequestContextV2WithGenericAuthorizer<AuthorizerContext> => ({
|
|
119
|
+
version: '2.0',
|
|
120
|
+
rawPath: this.url.pathname,
|
|
121
|
+
rawQueryString: this.url.search,
|
|
122
|
+
routeKey: '',
|
|
123
|
+
httpMethod: this.method,
|
|
124
|
+
body: this.body,
|
|
125
|
+
headers: this.headers,
|
|
126
|
+
path: this.url.pathname,
|
|
127
|
+
pathParameters: null,
|
|
128
|
+
queryStringParameters: this.queryStringParameters,
|
|
129
|
+
resource: '',
|
|
130
|
+
multiValueHeaders: {},
|
|
131
|
+
isBase64Encoded: false,
|
|
132
|
+
multiValueQueryStringParameters: null,
|
|
133
|
+
stageVariables: null,
|
|
134
|
+
requestContext: {
|
|
135
|
+
domainName: this.headers.host,
|
|
136
|
+
domainPrefix: '',
|
|
137
|
+
http: {
|
|
138
|
+
method: this.method,
|
|
139
|
+
path: this.url.pathname,
|
|
140
|
+
protocol: 'http',
|
|
141
|
+
sourceIp: '127.0.0.1',
|
|
142
|
+
userAgent: this.headers['user-agent'],
|
|
143
|
+
},
|
|
144
|
+
routeKey: '',
|
|
145
|
+
time: new Date(this.time).toString(),
|
|
146
|
+
timeEpoch: this.time,
|
|
147
|
+
accountId: '',
|
|
148
|
+
apiId: '',
|
|
149
|
+
authorizer: context,
|
|
150
|
+
httpMethod: '',
|
|
151
|
+
identity: {
|
|
152
|
+
accessKey: '',
|
|
153
|
+
accountId: '',
|
|
154
|
+
apiKey: '',
|
|
155
|
+
apiKeyId: '',
|
|
156
|
+
caller: '',
|
|
157
|
+
clientCert: {
|
|
158
|
+
clientCertPem: '',
|
|
159
|
+
issuerDN: '',
|
|
160
|
+
serialNumber: '',
|
|
161
|
+
subjectDN: '',
|
|
162
|
+
validity: { notAfter: '', notBefore: '' },
|
|
163
|
+
},
|
|
164
|
+
cognitoAuthenticationProvider: '',
|
|
165
|
+
cognitoAuthenticationType: '',
|
|
166
|
+
cognitoIdentityId: '',
|
|
167
|
+
cognitoIdentityPoolId: '',
|
|
168
|
+
principalOrgId: '',
|
|
169
|
+
sourceIp: '',
|
|
170
|
+
user: '',
|
|
171
|
+
userAgent: '',
|
|
172
|
+
userArn: '',
|
|
173
|
+
},
|
|
174
|
+
path: '',
|
|
175
|
+
protocol: '',
|
|
176
|
+
requestId: '',
|
|
177
|
+
requestTimeEpoch: this.time,
|
|
178
|
+
resourceId: '',
|
|
179
|
+
resourcePath: '',
|
|
180
|
+
stage: '',
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
authorization = () => this.authorizationHeaderValue
|
|
185
|
+
}
|
|
186
|
+
interface LambdaMapper<AuthorizerContext, Event, HandlerResponse> {
|
|
187
|
+
event(context: AuthorizerContext): Event
|
|
188
|
+
authorization(): string
|
|
189
|
+
toResponse: (response: HandlerResponse) => Response
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface LambdaMapperFactory<AuthorizerContext, Event, LambdaResponse> {
|
|
193
|
+
newInstance(request: IncomingMessage, body: string): Promise<LambdaMapper<AuthorizerContext, Event, LambdaResponse>>;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
class StringResponse extends Response {
|
|
197
|
+
readonly body: () => string
|
|
198
|
+
|
|
199
|
+
constructor(
|
|
200
|
+
statusCode: number,
|
|
201
|
+
contentType: string | undefined,
|
|
202
|
+
location: string | undefined,
|
|
203
|
+
body: string | undefined,
|
|
204
|
+
cookies: string[] | undefined
|
|
205
|
+
) {
|
|
206
|
+
super(statusCode, contentType, location, cookies)
|
|
207
|
+
this.body = () => body;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
class Base64EncodedResponse extends Response {
|
|
212
|
+
readonly body: () => Buffer
|
|
213
|
+
|
|
214
|
+
constructor(
|
|
215
|
+
statusCode: number,
|
|
216
|
+
contentType: string | undefined,
|
|
217
|
+
location: string | undefined,
|
|
218
|
+
body: string,
|
|
219
|
+
cookies: string[] | undefined,
|
|
220
|
+
) {
|
|
221
|
+
super(statusCode, contentType, location, cookies)
|
|
222
|
+
this.body = () => Buffer.from(body, 'base64')
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
type APIGatewayEventRequestContextV2WithGenericAuthorizer<AuthorizerContext> = APIGatewayProxyEventV2WithRequestContext<APIGatewayEventRequestContextV2WithAuthorizer<AuthorizerContext>>;
|
|
227
|
+
|
|
228
|
+
export type PathParameterResolver<Event> = {
|
|
229
|
+
locate: (event: Event) => string
|
|
230
|
+
store: (event: Event, params: APIGatewayProxyEventPathParameters) => void
|
|
231
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Logger, LoggerFactory } from '@yopdev/logging';
|
|
2
|
+
import { Lifecycle, Service } from './services';
|
|
3
|
+
|
|
4
|
+
export const newPreTrafficHooks = (
|
|
5
|
+
name: string,
|
|
6
|
+
hooks: () => Promise<void>[],
|
|
7
|
+
) => new Service(new PreTrafficHooks(name, hooks))
|
|
8
|
+
|
|
9
|
+
class PreTrafficHooks implements Lifecycle<void> {
|
|
10
|
+
private LOGGER: Logger
|
|
11
|
+
constructor(
|
|
12
|
+
readonly name: string,
|
|
13
|
+
private readonly hooks: () => Promise<void>[]
|
|
14
|
+
) {
|
|
15
|
+
this.LOGGER = LoggerFactory.create(`PRETRAFFIC[${name}]`);
|
|
16
|
+
this.LOGGER.info('%i hooks registered', hooks.length);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
start = async () => Promise
|
|
20
|
+
.all(this.hooks())
|
|
21
|
+
.then(() => undefined)
|
|
22
|
+
|
|
23
|
+
stop = async () => Promise.resolve()
|
|
24
|
+
}
|
package/src/responses.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { LoggerFactory } from '@yopdev/logging';
|
|
2
|
+
import { ServerResponse } from 'http';
|
|
3
|
+
|
|
4
|
+
const LOG = LoggerFactory.create('RESPONSES');
|
|
5
|
+
export function internalServerError(res: ServerResponse, body: any) {
|
|
6
|
+
LOG.error(body, 'internal server error handled');
|
|
7
|
+
return writeResponse(res, 500, 'internal server error. check system logs');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function writeResponse(
|
|
11
|
+
res: ServerResponse,
|
|
12
|
+
statusCode: number,
|
|
13
|
+
body: string | Buffer,
|
|
14
|
+
contentType?: string,
|
|
15
|
+
location?: string,
|
|
16
|
+
cookies?: string[],
|
|
17
|
+
) {
|
|
18
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
19
|
+
res.setHeader('Access-Control-Allow-Methods', '*');
|
|
20
|
+
res.setHeader('Access-Control-Allow-Headers', '*');
|
|
21
|
+
if (cookies) {
|
|
22
|
+
res.setHeader('Set-Cookie', cookies);
|
|
23
|
+
}
|
|
24
|
+
if (contentType) res.setHeader('Content-Type', contentType);
|
|
25
|
+
if (location) res.setHeader('Location', location);
|
|
26
|
+
res.statusCode = statusCode;
|
|
27
|
+
res.end(body);
|
|
28
|
+
}
|
package/src/s3.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { CORSConfiguration, CORSRule, CreateBucketCommand, PutBucketCorsCommand, S3Client } from '@aws-sdk/client-s3';
|
|
2
|
+
import { AwsConfig } from './config';
|
|
3
|
+
import { LoggerFactory } from '@yopdev/logging';
|
|
4
|
+
|
|
5
|
+
const LOGGER = LoggerFactory.create('S3')
|
|
6
|
+
export class S3 {
|
|
7
|
+
readonly client: S3Client;
|
|
8
|
+
|
|
9
|
+
constructor(aws: AwsConfig) {
|
|
10
|
+
this.client = new S3Client({ forcePathStyle: true, ...aws });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
createBucket = async (name: string, cors?: CORSConfiguration) =>
|
|
14
|
+
this.client.send(
|
|
15
|
+
new CreateBucketCommand({
|
|
16
|
+
Bucket: name,
|
|
17
|
+
}),
|
|
18
|
+
)
|
|
19
|
+
.then(() => cors && this.client.send(new PutBucketCorsCommand({
|
|
20
|
+
Bucket: name,
|
|
21
|
+
CORSConfiguration: cors
|
|
22
|
+
})))
|
|
23
|
+
.tap(() => LOGGER.debug('bucket %s created', name))
|
|
24
|
+
}
|