@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.
Files changed (100) hide show
  1. package/.github/workflows/npm-publish.yml +34 -0
  2. package/__tests__/bootstrap.test.ts +89 -0
  3. package/__tests__/deferred.test.ts +86 -0
  4. package/__tests__/event-proxy.test.ts +42 -0
  5. package/__tests__/lambda-http-proxy.test.ts +179 -0
  6. package/dist/__tests__/bootstrap.test.d.ts +1 -0
  7. package/dist/__tests__/bootstrap.test.js +90 -0
  8. package/dist/__tests__/deferred.test.d.ts +1 -0
  9. package/dist/__tests__/deferred.test.js +76 -0
  10. package/dist/__tests__/event-proxy.test.d.ts +1 -0
  11. package/dist/__tests__/event-proxy.test.js +37 -0
  12. package/dist/__tests__/lambda-http-proxy.test.d.ts +1 -0
  13. package/dist/__tests__/lambda-http-proxy.test.js +135 -0
  14. package/dist/src/assert.d.ts +1 -0
  15. package/dist/src/assert.js +9 -0
  16. package/dist/src/cloudformation-dynamodb-table.d.ts +13 -0
  17. package/dist/src/cloudformation-dynamodb-table.js +45 -0
  18. package/dist/src/cloudformation-event-proxy.d.ts +13 -0
  19. package/dist/src/cloudformation-event-proxy.js +25 -0
  20. package/dist/src/cloudformation-lambda-http-proxy.d.ts +14 -0
  21. package/dist/src/cloudformation-lambda-http-proxy.js +62 -0
  22. package/dist/src/cloudformation.d.ts +28 -0
  23. package/dist/src/cloudformation.js +50 -0
  24. package/dist/src/config.d.ts +31 -0
  25. package/dist/src/config.js +2 -0
  26. package/dist/src/container.d.ts +18 -0
  27. package/dist/src/container.js +33 -0
  28. package/dist/src/deferred.d.ts +4 -0
  29. package/dist/src/deferred.js +45 -0
  30. package/dist/src/dev-server.d.ts +19 -0
  31. package/dist/src/dev-server.js +63 -0
  32. package/dist/src/dynamodb.d.ts +16 -0
  33. package/dist/src/dynamodb.js +48 -0
  34. package/dist/src/event-proxy.d.ts +13 -0
  35. package/dist/src/event-proxy.js +68 -0
  36. package/dist/src/factories.d.ts +3 -0
  37. package/dist/src/factories.js +16 -0
  38. package/dist/src/http-server.d.ts +25 -0
  39. package/dist/src/http-server.js +37 -0
  40. package/dist/src/index.d.ts +24 -0
  41. package/dist/src/index.js +46 -0
  42. package/dist/src/internal-queue.d.ts +11 -0
  43. package/dist/src/internal-queue.js +53 -0
  44. package/dist/src/lambda-http-proxy.d.ts +28 -0
  45. package/dist/src/lambda-http-proxy.js +50 -0
  46. package/dist/src/localstack.d.ts +11 -0
  47. package/dist/src/localstack.js +62 -0
  48. package/dist/src/mappers.d.ts +25 -0
  49. package/dist/src/mappers.js +176 -0
  50. package/dist/src/pre-traffic-hooks.d.ts +2 -0
  51. package/dist/src/pre-traffic-hooks.js +19 -0
  52. package/dist/src/responses.d.ts +5 -0
  53. package/dist/src/responses.js +25 -0
  54. package/dist/src/s3.d.ts +7 -0
  55. package/dist/src/s3.js +20 -0
  56. package/dist/src/scheduled-tasks.d.ts +6 -0
  57. package/dist/src/scheduled-tasks.js +20 -0
  58. package/dist/src/services.d.ts +22 -0
  59. package/dist/src/services.js +26 -0
  60. package/dist/src/sns-http-proxy.d.ts +28 -0
  61. package/dist/src/sns-http-proxy.js +66 -0
  62. package/dist/src/sns.d.ts +15 -0
  63. package/dist/src/sns.js +35 -0
  64. package/dist/src/sqs.d.ts +13 -0
  65. package/dist/src/sqs.js +33 -0
  66. package/dist/src/stoppable.d.ts +2 -0
  67. package/dist/src/stoppable.js +15 -0
  68. package/dist/src/tunnel.d.ts +10 -0
  69. package/dist/src/tunnel.js +52 -0
  70. package/jest.config.js +7 -0
  71. package/package.json +2 -5
  72. package/src/assert.ts +4 -0
  73. package/src/cloudformation-dynamodb-table.ts +97 -0
  74. package/src/cloudformation-event-proxy.ts +61 -0
  75. package/src/cloudformation-lambda-http-proxy.ts +125 -0
  76. package/src/cloudformation.ts +95 -0
  77. package/src/config.ts +34 -0
  78. package/src/container.ts +82 -0
  79. package/src/deferred.ts +60 -0
  80. package/src/dev-server.ts +78 -0
  81. package/src/dynamodb.ts +62 -0
  82. package/src/event-proxy.ts +101 -0
  83. package/src/factories.ts +19 -0
  84. package/src/http-server.ts +59 -0
  85. package/src/index.ts +32 -0
  86. package/src/internal-queue.ts +89 -0
  87. package/src/lambda-http-proxy.ts +111 -0
  88. package/src/localstack.ts +74 -0
  89. package/src/mappers.ts +231 -0
  90. package/src/pre-traffic-hooks.ts +24 -0
  91. package/src/responses.ts +28 -0
  92. package/src/s3.ts +24 -0
  93. package/src/scheduled-tasks.ts +31 -0
  94. package/src/services.ts +46 -0
  95. package/src/sns-http-proxy.ts +109 -0
  96. package/src/sns.ts +49 -0
  97. package/src/sqs.ts +46 -0
  98. package/src/stoppable.ts +10 -0
  99. package/src/tunnel.ts +32 -0
  100. 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
+ }
@@ -0,0 +1,82 @@
1
+ import { Logger, LoggerFactory } from '@yopdev/logging';
2
+ import { GenericContainer, StartedTestContainer } from 'testcontainers';
3
+ import { BindMount, Environment } from 'testcontainers/build/types';
4
+ import { Callback, Lifecycle, Service } from './services';
5
+ import { PortWithOptionalBinding } from 'testcontainers/build/utils/port';
6
+ import { Readable } from 'stream';
7
+ import { WaitStrategy } from 'testcontainers/build/wait-strategies/wait-strategy';
8
+ import { DevServerConfig } from './config';
9
+
10
+ export const newContainer = (
11
+ name: string,
12
+ config: {
13
+ image: string,
14
+ networkAlias: string,
15
+ environment?: Environment,
16
+ bindMounts?: BindMount[],
17
+ exposedPorts?: PortWithOptionalBinding[],
18
+ logConsumer?: (stream: Readable) => unknown,
19
+ startup?: {
20
+ waitStrategy: WaitStrategy,
21
+ timeout: number
22
+ },
23
+ endpointBuilder?: (port: number) => string,
24
+ },
25
+ callback?: Callback<string>,
26
+ ) =>
27
+ new Service(new Container(
28
+ name,
29
+ config.image,
30
+ config.networkAlias,
31
+ config.environment,
32
+ config.bindMounts,
33
+ config.exposedPorts,
34
+ config.logConsumer,
35
+ config.startup,
36
+ config.endpointBuilder,
37
+ ), callback);
38
+
39
+ class Container implements Lifecycle<string> {
40
+ private readonly LOGGER: Logger;
41
+ private readonly container: GenericContainer;
42
+ private readonly endpointBuilder: (port: number) => string;
43
+ private started: StartedTestContainer | undefined;
44
+
45
+ constructor(
46
+ readonly name: string,
47
+ image: string,
48
+ private readonly networkAlias: string,
49
+ environment?: Environment,
50
+ bindMounts?: BindMount[],
51
+ exposedPorts?: PortWithOptionalBinding[],
52
+ logConsumer?: (stream: Readable) => unknown,
53
+ startup?: {
54
+ waitStrategy: WaitStrategy,
55
+ timeout: number
56
+ },
57
+ endpointBuilder?: (port: number) => string,
58
+ ) {
59
+ this.LOGGER = LoggerFactory.create(`CONTAINER[${name}]`);
60
+ const generic = new GenericContainer(image);
61
+ const withEnvironment = environment ? generic.withEnvironment(environment) : generic;
62
+ const withBindMounts = bindMounts ? withEnvironment.withBindMounts(bindMounts) : withEnvironment;
63
+ const withExposedPorts = exposedPorts ? withBindMounts.withExposedPorts(...exposedPorts) : withBindMounts;
64
+ const withLogConsumer = logConsumer ? withExposedPorts.withLogConsumer(logConsumer) : withExposedPorts;
65
+ this.container = startup ? withLogConsumer.withWaitStrategy(startup.waitStrategy).withStartupTimeout(startup.timeout) : withLogConsumer;
66
+ this.networkAlias = networkAlias;
67
+ this.endpointBuilder = endpointBuilder ?? ((port: number) => `http://localhost:${port}`);
68
+ }
69
+
70
+ start = async (config: DevServerConfig) =>
71
+ Promise.resolve(this.LOGGER.info('start'))
72
+ .then(() => this.container
73
+ .withNetwork(config.network)
74
+ .withNetworkAliases(this.networkAlias)
75
+ .start()
76
+ )
77
+ .then((started) => this.started = started)
78
+ .then(() => this.endpointBuilder(this.started.getFirstMappedPort()))
79
+ .tap((url) => this.LOGGER.debug(url));
80
+
81
+ stop = async () => (this.started ?? { stop: async () => { this.LOGGER.warn('no container') } }).stop().then(() => undefined);
82
+ };
@@ -0,0 +1,60 @@
1
+ import { randomUUID } from "crypto";
2
+ import { DevServerConfig } from "./config";
3
+ import { Lifecycle, Service } from "./services";
4
+ import { Logger, LoggerFactory } from "@yopdev/logging";
5
+
6
+ export const lazy = <I>(lifecycle: (config: DevServerConfig) => Service<I>) => new Service(new Lazy(lifecycle));
7
+ export const promised = <I>(lifecycle: (config: DevServerConfig) => Promise<Service<I>>) => new Service(new Promised(lifecycle));
8
+
9
+ class Lazy<I> implements Lifecycle<I> {
10
+ private started: Lifecycle<I> | undefined;
11
+ private readonly LOGGER: Logger
12
+ readonly name: string;
13
+
14
+ constructor(private readonly configurable: (config: DevServerConfig) => Service<I>) {
15
+ this.started = undefined;
16
+ this.name = randomUUID();
17
+ this.LOGGER = LoggerFactory.create(`LAZY[${this.name}]`)
18
+ }
19
+
20
+ async start(config: DevServerConfig) {
21
+ const local = this.configurable(config);
22
+ this.started = local;
23
+ this.LOGGER.info('DISCOVERY:%s', local.name)
24
+ return local.start(config);
25
+ }
26
+
27
+ stop: () => Promise<void> = async () =>
28
+ this.withStartedOr(
29
+ async (s) => s.stop(),
30
+ async () => Promise.reject(new Error('not started')),
31
+ );
32
+
33
+ private withStartedOr<T>(task: (i: Lifecycle<I>) => T, fallback: () => T) {
34
+ const local = this.started;
35
+ if (local !== undefined) task(local);
36
+ else fallback();
37
+ }
38
+ }
39
+
40
+ class Promised<I> implements Lifecycle<I> {
41
+ private readonly LOGGER: Logger;
42
+ readonly name: string;
43
+ readonly start: (config: DevServerConfig) => Promise<I>
44
+ readonly stop: () => Promise<void>
45
+
46
+ constructor(configurable: (config: DevServerConfig) => Promise<Service<I>>) {
47
+ this.name = randomUUID();
48
+ this.LOGGER = LoggerFactory.create(`PROMISED[${this.name}]`)
49
+
50
+ let initialized: Service<I>
51
+ this.start = async (config: DevServerConfig) =>
52
+ configurable(config)
53
+ .catch((e) => e?.cleanup?.() ?? Promise.reject(e))
54
+ .then((service) => initialized = service)
55
+ .tap((service) => this.LOGGER.info('DISCOVERY:%s', service.name))
56
+ .then((service) => service.start(config))
57
+
58
+ this.stop = async () => (initialized ?? { stop: async () => this.LOGGER.info('not started') }).stop()
59
+ }
60
+ }
@@ -0,0 +1,78 @@
1
+ import { Network, RandomUuid, StartedNetwork } from "testcontainers";
2
+ import { Config } from "./config";
3
+ import { Service } from "./services";
4
+ import { LocalStack } from "./localstack";
5
+ import { Sqs } from "./sqs";
6
+ import { DynamoDb } from "./dynamodb";
7
+ import { Logger, LoggerFactory } from "@yopdev/logging";
8
+ import { Sns } from "./sns";
9
+ import { terminate } from "./tunnel";
10
+ import { S3 } from "./s3";
11
+
12
+ export class DevServer {
13
+ private readonly eventProxyTopic: string
14
+ private LOGGER: Logger
15
+ private localStack: LocalStack | undefined;
16
+ private network: StartedNetwork | undefined;
17
+
18
+ constructor(
19
+ readonly name: string,
20
+ private readonly service: Service<any>,
21
+ private readonly config?: Config,
22
+ ) {
23
+ this.LOGGER = LoggerFactory.create(`DEVSERVER[${name}]`)
24
+ const encodedName = Buffer.from(name).toString('hex').substring(0, 64)
25
+ this.eventProxyTopic = `EventProxyTopic${encodedName}`
26
+ }
27
+
28
+ start: () => Promise<StartedDevServer> = async () =>
29
+ Promise.resolve(new Network(new RandomUuid()).start())
30
+ .tap((network) => this.network = network)
31
+ .then((network) => ({
32
+ localStack: this.localStack = new LocalStack(network, this.config),
33
+ network: network
34
+ }))
35
+ .then((infra) => infra.localStack.start().then((config) => ({
36
+ aws: config,
37
+ network: infra.network
38
+ })))
39
+ .then((infra) => Promise
40
+ .resolve(new Sns(infra.aws))
41
+ .then((sns) => sns
42
+ .createTopic(this.eventProxyTopic)
43
+ .then((eventProxyTopicArn) => ({
44
+ raw: infra.aws,
45
+ network: infra.network,
46
+ sqs: new Sqs(infra.aws),
47
+ sns: new Sns(infra.aws),
48
+ s3: new S3(infra.aws),
49
+ dynamo: new DynamoDb(infra.aws),
50
+ eventsProxy: {
51
+ topic: {
52
+ arn: eventProxyTopicArn
53
+ }
54
+ },
55
+ }))
56
+ ))
57
+ .tap((config) => this.LOGGER.debug('config is %o', config))
58
+ .then((aws) => this.service.start(aws))
59
+ .catch((e) => this.shutdown()
60
+ .then(() => this.LOGGER.error('error starting service'))
61
+ .then(() => Promise.reject(e)
62
+ ))
63
+ .then(() => this.LOGGER.info('started'))
64
+ .then(() => ({
65
+ name: this.name,
66
+ stop: async () => this.shutdown().then(() => this.LOGGER.info('stopped')),
67
+ }))
68
+
69
+ private shutdown = async () => this.service.stop()
70
+ .finally(terminate)
71
+ .then(() => this.localStack?.stop())
72
+ .then(() => this.network?.stop().catch((e) => this.LOGGER.warn(e, 'failed to stop network')))
73
+ }
74
+
75
+ type StartedDevServer = {
76
+ name: string
77
+ stop: () => Promise<void>
78
+ }