@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
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Callback, Lifecycle, Service } from './services';
|
|
2
|
+
import { AttributeDefinition, CreateTableCommand, GlobalSecondaryIndex, KeySchemaElement, UpdateTimeToLiveCommand } from '@aws-sdk/client-dynamodb';
|
|
3
|
+
import { CLOUDFORMATION_SCHEMA } from 'js-yaml-cloudformation-schema';
|
|
4
|
+
import { load } from 'js-yaml';
|
|
5
|
+
import { readFileSync } from 'fs';
|
|
6
|
+
import { DevServerConfig } from './config';
|
|
7
|
+
import { Logger, LoggerFactory } from '@yopdev/logging';
|
|
8
|
+
import { newDynamoDbTable } from './dynamodb';
|
|
9
|
+
|
|
10
|
+
export const newDynamoDbTableFromCloudFormationTemplate = (
|
|
11
|
+
name: string,
|
|
12
|
+
config: DynamoDbTableCloudFormationConfig,
|
|
13
|
+
callback?: Callback<string>,
|
|
14
|
+
): Service<string> => new Service(new DynamoDbTableCloudFormation(
|
|
15
|
+
name,
|
|
16
|
+
config.template,
|
|
17
|
+
config.resource,
|
|
18
|
+
config.tableName,
|
|
19
|
+
config.throughput
|
|
20
|
+
), callback)
|
|
21
|
+
|
|
22
|
+
class DynamoDbTableCloudFormation implements Lifecycle<string> {
|
|
23
|
+
private readonly LOGGER: Logger
|
|
24
|
+
private readonly tableName: (name: string) => string;
|
|
25
|
+
|
|
26
|
+
constructor(
|
|
27
|
+
readonly name: string,
|
|
28
|
+
private readonly template: (name: string) => string,
|
|
29
|
+
private readonly resource: string,
|
|
30
|
+
tableName: (name: string) => string,
|
|
31
|
+
private readonly throughput: Throughput,
|
|
32
|
+
) {
|
|
33
|
+
this.LOGGER = LoggerFactory.create(`CFN:DYNAMO[${name}]`)
|
|
34
|
+
this.tableName = tableName ? tableName : (name: string) => { throw new Error(`table name not specified on ${name}`) }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
start = (config: DevServerConfig) => Promise.resolve(
|
|
38
|
+
this.parseTableDefinition(this.template(this.name))
|
|
39
|
+
.then((definition) => definition[this.resource].Properties)
|
|
40
|
+
.then((resource) => this.createTableCommand(resource, this.throughput)
|
|
41
|
+
.then((command) => newDynamoDbTable(this.name, {
|
|
42
|
+
command: command,
|
|
43
|
+
ttlAttribute: resource.TimeToLiveSpecification?.AttributeName,
|
|
44
|
+
}))))
|
|
45
|
+
.tap(() => this.LOGGER.info('configured'))
|
|
46
|
+
.then((service) => service.start(config))
|
|
47
|
+
|
|
48
|
+
stop = () => Promise.resolve()
|
|
49
|
+
|
|
50
|
+
private createTableCommand = async (definition: DynamoDbTableCloudformationDefinitionProperties, throughput: Throughput) =>
|
|
51
|
+
new CreateTableCommand({
|
|
52
|
+
TableName: definition.TableName ?? this.tableName(this.name),
|
|
53
|
+
KeySchema: definition.KeySchema,
|
|
54
|
+
AttributeDefinitions: definition.AttributeDefinitions,
|
|
55
|
+
GlobalSecondaryIndexes: definition.GlobalSecondaryIndexes?.map((gsi: GlobalSecondaryIndex) => ({
|
|
56
|
+
...gsi,
|
|
57
|
+
ProvisionedThroughput: throughput,
|
|
58
|
+
})),
|
|
59
|
+
ProvisionedThroughput: throughput,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
private parseTableDefinition = async (path: string) => (
|
|
63
|
+
load(readFileSync(path).toString(), {
|
|
64
|
+
schema: CLOUDFORMATION_SCHEMA,
|
|
65
|
+
}) as ResourcesCloudformationDefinition
|
|
66
|
+
).Resources;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type DynamoDbTableCloudFormationConfig = {
|
|
70
|
+
template: (name: string) => string;
|
|
71
|
+
resource: string,
|
|
72
|
+
tableName: (name: string) => string;
|
|
73
|
+
throughput: Throughput;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type ResourcesCloudformationDefinition = {
|
|
77
|
+
Resources: {
|
|
78
|
+
[name: string]: {
|
|
79
|
+
Properties: DynamoDbTableCloudformationDefinitionProperties
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type DynamoDbTableCloudformationDefinitionProperties = {
|
|
85
|
+
TableName?: string;
|
|
86
|
+
KeySchema: KeySchemaElement[];
|
|
87
|
+
AttributeDefinitions: AttributeDefinition[];
|
|
88
|
+
GlobalSecondaryIndexes?: GlobalSecondaryIndex[];
|
|
89
|
+
TimeToLiveSpecification?: TimeToLiveSpecification;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type TimeToLiveSpecification = {
|
|
93
|
+
AttributeName: string;
|
|
94
|
+
Enabled: boolean;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
type Throughput = { ReadCapacityUnits: number; WriteCapacityUnits: number }
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { SQSEvent, SQSRecord } from 'aws-lambda';
|
|
2
|
+
import { CloudFormationSetup, CloudFormationSetupConfig } from './cloudformation';
|
|
3
|
+
import { EventHandler } from './event-proxy';
|
|
4
|
+
import { DevServerConfig } from './config';
|
|
5
|
+
import { Startable } from './services';
|
|
6
|
+
import { Message } from '@aws-sdk/client-sqs';
|
|
7
|
+
|
|
8
|
+
export const snsEventsFromCloudFormationTemplate = (
|
|
9
|
+
name: string,
|
|
10
|
+
config: CloudFormationEventsProxyConfig,
|
|
11
|
+
): Startable<EventHandler[]> =>
|
|
12
|
+
new CloudFormationEventsProxy(name, config);
|
|
13
|
+
class CloudFormationEventsProxy extends CloudFormationSetup<
|
|
14
|
+
{
|
|
15
|
+
SqsSubscription: {
|
|
16
|
+
BatchSize: string;
|
|
17
|
+
QueueArn: string;
|
|
18
|
+
QueueUrl: string;
|
|
19
|
+
};
|
|
20
|
+
Topic: string;
|
|
21
|
+
FilterPolicy: {
|
|
22
|
+
[name: string]: string[];
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
(event: SQSEvent) => Promise<unknown>,
|
|
26
|
+
EventHandler,
|
|
27
|
+
EventHandler[]
|
|
28
|
+
> {
|
|
29
|
+
constructor(name: string, config: CloudFormationEventsProxyConfig) {
|
|
30
|
+
super(
|
|
31
|
+
name,
|
|
32
|
+
(event) => event.Type === 'SNS',
|
|
33
|
+
async (event, handler) => ({
|
|
34
|
+
name: handler.name,
|
|
35
|
+
handler: handler,
|
|
36
|
+
matcher: (_, attributes) => {
|
|
37
|
+
const required = Object.entries(event.Properties.FilterPolicy ?? []).flatMap((entry) =>
|
|
38
|
+
entry[1].map((value) => value + entry[0]),
|
|
39
|
+
);
|
|
40
|
+
if (required.length == 0) return true;
|
|
41
|
+
const actual = Object.entries(attributes).flatMap((entry) => entry[1].Value + entry[0]);
|
|
42
|
+
return required.filter((e) => actual.includes(e)).length > 0;
|
|
43
|
+
},
|
|
44
|
+
}),
|
|
45
|
+
config.prepare,
|
|
46
|
+
config
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
protected afterStart = (_name: string, _config: DevServerConfig, routes: EventHandler[]) => routes;
|
|
51
|
+
|
|
52
|
+
logHandler(handler: EventHandler): string {
|
|
53
|
+
return `detected sns handler ${handler.name}`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type CloudFormationEventsProxyConfig = {
|
|
58
|
+
topic?: string,
|
|
59
|
+
mapper?: (message: Message) => SQSRecord,
|
|
60
|
+
prepare?: (config: DevServerConfig) => Promise<void>,
|
|
61
|
+
} & CloudFormationSetupConfig;
|
|
@@ -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
|
+
}
|
package/src/container.ts
ADDED
|
@@ -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
|
+
};
|
package/src/deferred.ts
ADDED
|
@@ -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
|
+
}
|