@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,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
|
+
}
|
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
|
+
);
|