@yopdev/dev-server 3.0.2-RC → 3.0.2-RC2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/npm-publish.yml +34 -0
- package/__tests__/bootstrap.test.ts +89 -0
- package/__tests__/deferred.test.ts +86 -0
- package/__tests__/event-proxy.test.ts +42 -0
- package/__tests__/lambda-http-proxy.test.ts +179 -0
- package/dist/__tests__/bootstrap.test.d.ts +1 -0
- package/dist/__tests__/bootstrap.test.js +90 -0
- package/dist/__tests__/deferred.test.d.ts +1 -0
- package/dist/__tests__/deferred.test.js +76 -0
- package/dist/__tests__/event-proxy.test.d.ts +1 -0
- package/dist/__tests__/event-proxy.test.js +37 -0
- package/dist/__tests__/lambda-http-proxy.test.d.ts +1 -0
- package/dist/__tests__/lambda-http-proxy.test.js +135 -0
- package/dist/src/assert.d.ts +1 -0
- package/dist/src/assert.js +9 -0
- package/dist/src/cloudformation-dynamodb-table.d.ts +13 -0
- package/dist/src/cloudformation-dynamodb-table.js +45 -0
- package/dist/src/cloudformation-event-proxy.d.ts +13 -0
- package/dist/src/cloudformation-event-proxy.js +25 -0
- package/dist/src/cloudformation-lambda-http-proxy.d.ts +14 -0
- package/dist/src/cloudformation-lambda-http-proxy.js +62 -0
- package/dist/src/cloudformation.d.ts +28 -0
- package/dist/src/cloudformation.js +50 -0
- package/dist/src/config.d.ts +31 -0
- package/dist/src/config.js +2 -0
- package/dist/src/container.d.ts +18 -0
- package/dist/src/container.js +33 -0
- package/dist/src/deferred.d.ts +4 -0
- package/dist/src/deferred.js +45 -0
- package/dist/src/dev-server.d.ts +19 -0
- package/dist/src/dev-server.js +63 -0
- package/dist/src/dynamodb.d.ts +16 -0
- package/dist/src/dynamodb.js +48 -0
- package/dist/src/event-proxy.d.ts +13 -0
- package/dist/src/event-proxy.js +68 -0
- package/dist/src/factories.d.ts +3 -0
- package/dist/src/factories.js +16 -0
- package/dist/src/http-server.d.ts +25 -0
- package/dist/src/http-server.js +37 -0
- package/dist/src/index.d.ts +24 -0
- package/dist/src/index.js +46 -0
- package/dist/src/internal-queue.d.ts +11 -0
- package/dist/src/internal-queue.js +53 -0
- package/dist/src/lambda-http-proxy.d.ts +28 -0
- package/dist/src/lambda-http-proxy.js +50 -0
- package/dist/src/localstack.d.ts +11 -0
- package/dist/src/localstack.js +62 -0
- package/dist/src/mappers.d.ts +25 -0
- package/dist/src/mappers.js +176 -0
- package/dist/src/pre-traffic-hooks.d.ts +2 -0
- package/dist/src/pre-traffic-hooks.js +19 -0
- package/dist/src/responses.d.ts +5 -0
- package/dist/src/responses.js +25 -0
- package/dist/src/s3.d.ts +7 -0
- package/dist/src/s3.js +20 -0
- package/dist/src/scheduled-tasks.d.ts +6 -0
- package/dist/src/scheduled-tasks.js +20 -0
- package/dist/src/services.d.ts +22 -0
- package/dist/src/services.js +26 -0
- package/dist/src/sns-http-proxy.d.ts +28 -0
- package/dist/src/sns-http-proxy.js +66 -0
- package/dist/src/sns.d.ts +15 -0
- package/dist/src/sns.js +35 -0
- package/dist/src/sqs.d.ts +13 -0
- package/dist/src/sqs.js +33 -0
- package/dist/src/stoppable.d.ts +2 -0
- package/dist/src/stoppable.js +15 -0
- package/dist/src/tunnel.d.ts +10 -0
- package/dist/src/tunnel.js +52 -0
- package/jest.config.js +7 -0
- package/package.json +2 -5
- package/src/assert.ts +4 -0
- package/src/cloudformation-dynamodb-table.ts +97 -0
- package/src/cloudformation-event-proxy.ts +61 -0
- package/src/cloudformation-lambda-http-proxy.ts +125 -0
- package/src/cloudformation.ts +95 -0
- package/src/config.ts +34 -0
- package/src/container.ts +82 -0
- package/src/deferred.ts +60 -0
- package/src/dev-server.ts +78 -0
- package/src/dynamodb.ts +62 -0
- package/src/event-proxy.ts +101 -0
- package/src/factories.ts +19 -0
- package/src/http-server.ts +59 -0
- package/src/index.ts +32 -0
- package/src/internal-queue.ts +89 -0
- package/src/lambda-http-proxy.ts +111 -0
- package/src/localstack.ts +74 -0
- package/src/mappers.ts +231 -0
- package/src/pre-traffic-hooks.ts +24 -0
- package/src/responses.ts +28 -0
- package/src/s3.ts +24 -0
- package/src/scheduled-tasks.ts +31 -0
- package/src/services.ts +46 -0
- package/src/sns-http-proxy.ts +109 -0
- package/src/sns.ts +49 -0
- package/src/sqs.ts +46 -0
- package/src/stoppable.ts +10 -0
- package/src/tunnel.ts +32 -0
- package/tsconfig.json +9 -0
package/src/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
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Logger, LoggerFactory } from '@yopdev/logging';
|
|
2
|
+
import { clearInterval } from 'timers';
|
|
3
|
+
import { Lifecycle, Service } from './services';
|
|
4
|
+
|
|
5
|
+
export const newScheduledTasks = (
|
|
6
|
+
name: string,
|
|
7
|
+
schedules: Rate[],
|
|
8
|
+
) => new Service(new ScheduledTasks(name, schedules))
|
|
9
|
+
|
|
10
|
+
class ScheduledTasks implements Lifecycle<void> {
|
|
11
|
+
private LOGGER: Logger
|
|
12
|
+
|
|
13
|
+
constructor(readonly name: string, private readonly schedules: Rate[]) {
|
|
14
|
+
this.LOGGER = LoggerFactory.create(`SCHEDULER[${name}]`)
|
|
15
|
+
this.LOGGER.info('registered %i scheduled tasks', schedules.length);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
intervals: NodeJS.Timeout[] = [];
|
|
19
|
+
|
|
20
|
+
start = async () => Promise.resolve(
|
|
21
|
+
this.schedules.forEach((schedule) => this.intervals.push(setInterval(schedule.task, schedule.frequency * 1000)))
|
|
22
|
+
)
|
|
23
|
+
.then(() => undefined)
|
|
24
|
+
|
|
25
|
+
stop = async () => Promise.resolve(this.intervals.forEach((interval) => clearInterval(interval)))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type Rate = {
|
|
29
|
+
frequency: number;
|
|
30
|
+
task: () => Promise<unknown>;
|
|
31
|
+
}
|
package/src/services.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Logger, LoggerFactory } from "@yopdev/logging";
|
|
2
|
+
import { DevServerConfig } from "./config";
|
|
3
|
+
|
|
4
|
+
export interface Lifecycle<I> extends Startable<I>, Stoppable {
|
|
5
|
+
name: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface Startable<T> {
|
|
9
|
+
start(config: DevServerConfig): Promise<T>
|
|
10
|
+
}
|
|
11
|
+
export interface Stoppable {
|
|
12
|
+
stop(): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type Callback<I> = (instance: I) => Promise<void>
|
|
16
|
+
|
|
17
|
+
export class Service<I> implements Lifecycle<I> {
|
|
18
|
+
private readonly DEFAULT_CALLBACK: Callback<unknown> = () => Promise.resolve(this.LOGGER.debug('no callback'));
|
|
19
|
+
|
|
20
|
+
readonly name: string
|
|
21
|
+
readonly start: (config: DevServerConfig) => Promise<I>
|
|
22
|
+
readonly stop: () => Promise<void>
|
|
23
|
+
private doStop: () => Promise<void>
|
|
24
|
+
private readonly LOGGER: Logger
|
|
25
|
+
|
|
26
|
+
constructor(service: Lifecycle<I>, private readonly callback?: Callback<I>) {
|
|
27
|
+
this.name = service.name
|
|
28
|
+
this.LOGGER = LoggerFactory.create(`SERVICE[${this.name}]`)
|
|
29
|
+
const stop = async () => service.stop()
|
|
30
|
+
this.start = (config: DevServerConfig) => service.start(config)
|
|
31
|
+
.catch((error) => stop()
|
|
32
|
+
.then(() => this.doStop = () => Promise.resolve(this.LOGGER.error(error, 'failed')))
|
|
33
|
+
.then(() => Promise.reject(error))
|
|
34
|
+
)
|
|
35
|
+
.then((started) => this
|
|
36
|
+
.callbackOrDefault(started)
|
|
37
|
+
.then(() => this.LOGGER.info('started'))
|
|
38
|
+
.then(() => started)
|
|
39
|
+
)
|
|
40
|
+
this.doStop = () => stop()
|
|
41
|
+
.then(() => this.LOGGER.info('stopped'))
|
|
42
|
+
this.stop = () => this.doStop();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private callbackOrDefault = async (instance: I) => (this.callback ?? this.DEFAULT_CALLBACK)(instance)
|
|
46
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Logger, LoggerFactory } from "@yopdev/logging";
|
|
2
|
+
import { HttpServer, HttpSettings } from "./http-server";
|
|
3
|
+
import { Sns } from "./sns";
|
|
4
|
+
import { IncomingMessage, ServerResponse } from "http";
|
|
5
|
+
import { MessageAttributeValue } from "@aws-sdk/client-sns";
|
|
6
|
+
import { internalServerError, writeResponse } from "./responses";
|
|
7
|
+
import { Lifecycle, Service, Callback } from "./services";
|
|
8
|
+
import { DevServerConfig } from "./config";
|
|
9
|
+
import { assertNotUndefined } from "./assert";
|
|
10
|
+
|
|
11
|
+
export const newSnsHttpProxy = (
|
|
12
|
+
name: string,
|
|
13
|
+
config: {
|
|
14
|
+
settings: HttpSettings,
|
|
15
|
+
method: string,
|
|
16
|
+
subject?: string,
|
|
17
|
+
topic?: string,
|
|
18
|
+
},
|
|
19
|
+
callback?: Callback<string>,
|
|
20
|
+
) => new Service(new SnsHttpProxy(name, config.settings, config.method, config.subject, config.topic), callback)
|
|
21
|
+
|
|
22
|
+
export class SnsHttpProxy implements Lifecycle<string> {
|
|
23
|
+
private LOGGER: Logger
|
|
24
|
+
private server: HttpServer
|
|
25
|
+
private config: {
|
|
26
|
+
topic: string
|
|
27
|
+
sns: Sns
|
|
28
|
+
} | undefined
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
readonly name: string,
|
|
32
|
+
private readonly settings: HttpSettings,
|
|
33
|
+
private readonly method: string,
|
|
34
|
+
private readonly subject?: string,
|
|
35
|
+
private readonly topic?: string,
|
|
36
|
+
) {
|
|
37
|
+
this.server = new HttpServer(
|
|
38
|
+
this.name,
|
|
39
|
+
this.settings,
|
|
40
|
+
this.handler(this.method, this.subject)
|
|
41
|
+
)
|
|
42
|
+
this.LOGGER = LoggerFactory.create(`HTTP->SNS[${name}]`)
|
|
43
|
+
this.LOGGER.info(
|
|
44
|
+
'forwarding %s %s events to %s',
|
|
45
|
+
subject ? `events with ${subject} subject` : 'all',
|
|
46
|
+
method,
|
|
47
|
+
topic ?? 'the event bus',
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
start = (config: DevServerConfig) => Promise.resolve(this.server)
|
|
52
|
+
.then(server => server.start())
|
|
53
|
+
.tap(() => {
|
|
54
|
+
this.config = {
|
|
55
|
+
topic: this.topic ?? config.eventsProxy.topic.arn,
|
|
56
|
+
sns: config.sns
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
.then((endpoint) => endpoint)
|
|
60
|
+
|
|
61
|
+
stop = () => this.onlyWhenStarted(this.server).stop()
|
|
62
|
+
.then(() => this.LOGGER.debug('stopped'))
|
|
63
|
+
|
|
64
|
+
handler = (method: string, subject?: string) =>
|
|
65
|
+
(request: IncomingMessage, body: string, response: ServerResponse) =>
|
|
66
|
+
Promise.resolve((assertNotUndefined(this.config)))
|
|
67
|
+
.then((config) =>
|
|
68
|
+
config.sns
|
|
69
|
+
.publish(
|
|
70
|
+
{ arn: config.topic },
|
|
71
|
+
this.extractMessage(method, request, body),
|
|
72
|
+
subject,
|
|
73
|
+
this.extractMessageAttributes(request)
|
|
74
|
+
)
|
|
75
|
+
.then(() => writeResponse(response, 200, ''))
|
|
76
|
+
.catch((e) => {
|
|
77
|
+
this.LOGGER.error(e, 'request failed to execute');
|
|
78
|
+
internalServerError(response, e.body);
|
|
79
|
+
})
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
extractMessage = (method: string, request: IncomingMessage, body: string) => {
|
|
83
|
+
switch (method) {
|
|
84
|
+
case 'GET':
|
|
85
|
+
return this.extractQueryString(request) || '';
|
|
86
|
+
case 'POST':
|
|
87
|
+
return body;
|
|
88
|
+
default:
|
|
89
|
+
throw new Error();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private onlyWhenStarted = (server: HttpServer | undefined) => assertNotUndefined(server, 'not started')
|
|
94
|
+
|
|
95
|
+
private extractQueryString = (request: IncomingMessage) => request.url?.substring(request.url?.indexOf('?') + 1)
|
|
96
|
+
|
|
97
|
+
private extractMessageAttributes = (request: IncomingMessage): Record<string, MessageAttributeValue> => {
|
|
98
|
+
const initialValue = {};
|
|
99
|
+
return Object.keys(request.headers).reduce((obj, item) => {
|
|
100
|
+
return {
|
|
101
|
+
...obj,
|
|
102
|
+
[item]: {
|
|
103
|
+
DataType: 'String',
|
|
104
|
+
StringValue: request.headers[item],
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}, initialValue);
|
|
108
|
+
}
|
|
109
|
+
}
|
package/src/sns.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { CreateTopicCommand, MessageAttributeValue, PublishCommand, SNSClient, SubscribeCommand } from "@aws-sdk/client-sns";
|
|
2
|
+
import { AwsConfig } from "./config";
|
|
3
|
+
import { assertNotUndefined } from "./assert";
|
|
4
|
+
import { LoggerFactory } from "@yopdev/logging";
|
|
5
|
+
|
|
6
|
+
const LOGGER = LoggerFactory.create('SNS')
|
|
7
|
+
export class Sns {
|
|
8
|
+
readonly client: SNSClient;
|
|
9
|
+
|
|
10
|
+
constructor(aws: AwsConfig) {
|
|
11
|
+
this.client = new SNSClient(aws);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
createTopic = async (topic: string) => this.client
|
|
15
|
+
.send(
|
|
16
|
+
new CreateTopicCommand({
|
|
17
|
+
Name: topic,
|
|
18
|
+
}),
|
|
19
|
+
)
|
|
20
|
+
.then((o) => assertNotUndefined(o.TopicArn, 'TopicArn is not present'))
|
|
21
|
+
.tap((arn) => LOGGER.debug('topic %o created', arn))
|
|
22
|
+
|
|
23
|
+
createSubscription = async (topic: { arn: string }, queue: { arn: string }) => this.client
|
|
24
|
+
.send(
|
|
25
|
+
new SubscribeCommand({
|
|
26
|
+
TopicArn: topic.arn,
|
|
27
|
+
Protocol: 'sqs',
|
|
28
|
+
Endpoint: queue.arn,
|
|
29
|
+
}),
|
|
30
|
+
)
|
|
31
|
+
.then(() => LOGGER.debug('subscription %s->%s created', topic.arn, queue.arn))
|
|
32
|
+
|
|
33
|
+
publish = async (
|
|
34
|
+
topic: { arn: string },
|
|
35
|
+
body: string,
|
|
36
|
+
subject?: string,
|
|
37
|
+
attributes?: Record<string, MessageAttributeValue>,
|
|
38
|
+
) => this.client
|
|
39
|
+
.send(
|
|
40
|
+
new PublishCommand({
|
|
41
|
+
TopicArn: topic.arn,
|
|
42
|
+
Message: body,
|
|
43
|
+
MessageAttributes: attributes,
|
|
44
|
+
Subject: subject,
|
|
45
|
+
}),
|
|
46
|
+
)
|
|
47
|
+
.then(() => LOGGER.trace('published %s[%s]', topic.arn, subject))
|
|
48
|
+
.then()
|
|
49
|
+
}
|
package/src/sqs.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { CreateQueueCommand, GetQueueAttributesCommand, SQSClient } from "@aws-sdk/client-sqs";
|
|
2
|
+
import { assertNotUndefined } from "./assert";
|
|
3
|
+
import { AwsConfig } from "./config";
|
|
4
|
+
import { LoggerFactory } from "@yopdev/logging";
|
|
5
|
+
|
|
6
|
+
const QUEUE_NAME_ATTRIBUTE_NAME = 'QueueArn'
|
|
7
|
+
const LOGGER = LoggerFactory.create('SQS')
|
|
8
|
+
export class Sqs {
|
|
9
|
+
readonly client: SQSClient;
|
|
10
|
+
|
|
11
|
+
constructor(config: AwsConfig) {
|
|
12
|
+
this.client = new SQSClient(config)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
createStandardQueue = async (name: string) => this.createNamedQueue(name, false);
|
|
16
|
+
createFifoQueue = async (name: string) => this.createNamedQueue(name, true);
|
|
17
|
+
|
|
18
|
+
private createNamedQueue = async (name: string, fifo: boolean): Promise<Queue> => this.client
|
|
19
|
+
.send(
|
|
20
|
+
new CreateQueueCommand({
|
|
21
|
+
QueueName: fifo ? `${name}.fifo` : name,
|
|
22
|
+
Attributes: {
|
|
23
|
+
FifoQueue: fifo ? 'true' : undefined,
|
|
24
|
+
}
|
|
25
|
+
}),
|
|
26
|
+
)
|
|
27
|
+
.then(
|
|
28
|
+
(create) => this.client
|
|
29
|
+
.send(
|
|
30
|
+
new GetQueueAttributesCommand({
|
|
31
|
+
QueueUrl: create.QueueUrl,
|
|
32
|
+
AttributeNames: [QUEUE_NAME_ATTRIBUTE_NAME],
|
|
33
|
+
}),
|
|
34
|
+
)
|
|
35
|
+
.then((attributes) => ({
|
|
36
|
+
url: assertNotUndefined(create.QueueUrl),
|
|
37
|
+
arn: assertNotUndefined(attributes.Attributes)[QUEUE_NAME_ATTRIBUTE_NAME]!,
|
|
38
|
+
}))
|
|
39
|
+
)
|
|
40
|
+
.tap((queue) => LOGGER.debug('created %s', queue.arn))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type Queue = {
|
|
44
|
+
arn: string;
|
|
45
|
+
url: string;
|
|
46
|
+
};
|
package/src/stoppable.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Consumer } from "sqs-consumer";
|
|
2
|
+
|
|
3
|
+
export const stopConsumer = async (queueWaitTimeSecs: number, target?: Consumer): Promise<void> => {
|
|
4
|
+
if (target) {
|
|
5
|
+
target.stop();
|
|
6
|
+
return new Promise((resolve) => {
|
|
7
|
+
setTimeout(resolve, queueWaitTimeSecs * 1000 + 1);
|
|
8
|
+
});
|
|
9
|
+
} else { return Promise.resolve() }
|
|
10
|
+
}
|
package/src/tunnel.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Lifecycle, Service } from './services';
|
|
2
|
+
import { Logger, LoggerFactory } from '@yopdev/logging';
|
|
3
|
+
import * as ngrok from '@ngrok/ngrok';
|
|
4
|
+
import { assertNotUndefined } from './assert';
|
|
5
|
+
|
|
6
|
+
export const terminate = async () => ngrok.kill();
|
|
7
|
+
export const newTunnel = (config: { name: string; port: number; fixed?: { name: string; token: string } }) =>
|
|
8
|
+
new Service(new Tunnel(config.name, config.port, config.fixed));
|
|
9
|
+
class Tunnel implements Lifecycle<string> {
|
|
10
|
+
private logger: Logger;
|
|
11
|
+
constructor(
|
|
12
|
+
readonly name: string,
|
|
13
|
+
private readonly port: number,
|
|
14
|
+
private readonly domain?: {
|
|
15
|
+
name: string,
|
|
16
|
+
token: string,
|
|
17
|
+
},
|
|
18
|
+
) {
|
|
19
|
+
this.logger = LoggerFactory.create(`TUNNEL[${name}]`);
|
|
20
|
+
}
|
|
21
|
+
start = async () =>
|
|
22
|
+
ngrok
|
|
23
|
+
.forward({
|
|
24
|
+
proto: 'http',
|
|
25
|
+
domain: this.domain?.name,
|
|
26
|
+
addr: this.port,
|
|
27
|
+
authtoken: this.domain?.token,
|
|
28
|
+
})
|
|
29
|
+
.then((listener) => assertNotUndefined(listener.url()))
|
|
30
|
+
.tap((url) => this.logger.debug(url))
|
|
31
|
+
stop = async () => ngrok.disconnect();
|
|
32
|
+
}
|