@wolsok/spa-cdk-stack 1.0.15 → 1.0.19

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.
@@ -1,310 +0,0 @@
1
- import { CfnOutput, Duration, RemovalPolicy } from 'aws-cdk-lib';
2
- import {
3
- Certificate,
4
- CertificateValidation,
5
- } from 'aws-cdk-lib/aws-certificatemanager';
6
- import {
7
- AllowedMethods,
8
- Distribution,
9
- HttpVersion,
10
- IDistribution,
11
- ResponseHeadersPolicy,
12
- SecurityPolicyProtocol,
13
- ViewerProtocolPolicy,
14
- } from 'aws-cdk-lib/aws-cloudfront';
15
- import { S3BucketOrigin } from 'aws-cdk-lib/aws-cloudfront-origins';
16
- import {
17
- ARecord,
18
- HostedZone,
19
- IHostedZone,
20
- RecordTarget,
21
- } from 'aws-cdk-lib/aws-route53';
22
- import { CloudFrontTarget } from 'aws-cdk-lib/aws-route53-targets';
23
- import { BlockPublicAccess, Bucket, IBucket } from 'aws-cdk-lib/aws-s3';
24
- import {
25
- BucketDeployment,
26
- CacheControl,
27
- ISource,
28
- Source,
29
- } from 'aws-cdk-lib/aws-s3-deployment';
30
- import { Construct } from 'constructs';
31
-
32
- export interface SpaProps {
33
- domainName: string;
34
- buildOutputPath: string;
35
- bucketRemovalPolicy?: RemovalPolicy;
36
- extraAllowedOrigins?: string[];
37
- }
38
-
39
- const defaultBucketRemovalPolicy: RemovalPolicy = RemovalPolicy.DESTROY;
40
-
41
- /**
42
- * SPA infrastructure, which deploys spa content to an S3 bucket.
43
- *
44
- * The spa redirects from HTTP to HTTPS, uses a CloudFront distribution,
45
- * Route53 alias record,
46
- * and ACM certificate.
47
- */
48
- export class SpaConstruct extends Construct {
49
- private bucketDeployments: BucketDeployment[] = [];
50
- private readonly bucket: Bucket;
51
- private readonly distribution: Distribution;
52
-
53
- constructor(
54
- parent: Construct,
55
- private readonly spaName: string,
56
- props: SpaProps
57
- ) {
58
- super(parent, spaName);
59
- const removalPolicy: RemovalPolicy =
60
- props.bucketRemovalPolicy ?? defaultBucketRemovalPolicy;
61
- const { buildOutputPath, extraAllowedOrigins } = props;
62
-
63
- const { mainDomain, siteDomain } = this.extractDomains(props.domainName);
64
-
65
- const zone = HostedZone.fromLookup(this, spaName, {
66
- privateZone: false,
67
- domainName: mainDomain,
68
- });
69
-
70
- new CfnOutput(this, `${spaName}-Site:`, { value: 'https://' + siteDomain });
71
-
72
- let certificate: Certificate;
73
-
74
- if (mainDomain === siteDomain) {
75
- certificate = this.createCertificate(zone, mainDomain);
76
- } else {
77
- certificate = this.createCertificate(zone, mainDomain, siteDomain);
78
- }
79
-
80
- this.bucket = this.createBucket(siteDomain, removalPolicy);
81
-
82
- this.distribution = this.createDistribution(
83
- siteDomain,
84
- certificate,
85
- this.bucket,
86
- extraAllowedOrigins
87
- );
88
- this.createARecord(siteDomain, this.distribution, zone);
89
-
90
- const indexAsset = Source.asset(buildOutputPath, {
91
- // glob ignore all but index.html
92
- exclude: ['**', '!index.html'],
93
- });
94
- this.bucketDeployments.push(
95
- this.createBucketDeployment(
96
- [indexAsset],
97
- CacheControl.noCache(),
98
- true,
99
- this.bucket,
100
- this.distribution
101
- )
102
- );
103
-
104
- const deploymentAssets = Source.asset(buildOutputPath, {
105
- exclude: ['index.html'],
106
- });
107
-
108
- this.bucketDeployments.push(
109
- this.createBucketDeployment(
110
- [deploymentAssets],
111
- CacheControl.maxAge(Duration.days(365)),
112
- false,
113
- this.bucket,
114
- this.distribution
115
- )
116
- );
117
-
118
- // Make sure that the index.html is deployed last, as it's needed to
119
- // serve the SPA so chain the deployments
120
- this.bucketDeployments[1].node.addDependency(this.bucketDeployments[0]);
121
- }
122
-
123
- /**
124
- * Add some additional assets to the bucket of the SPA
125
- */
126
- public addExtraAssets(
127
- assets: ISource[],
128
- cacheControl: CacheControl = CacheControl.maxAge(Duration.days(365)),
129
- contentType?: string
130
- ): void {
131
- const extraAssetsDeployment: BucketDeployment = this.createBucketDeployment(
132
- assets,
133
- cacheControl,
134
- false,
135
- this.bucket,
136
- this.distribution,
137
- contentType
138
- );
139
-
140
- const latestDeployment: BucketDeployment | undefined =
141
- this.bucketDeployments.at(-1);
142
- if (latestDeployment) {
143
- extraAssetsDeployment.node.addDependency(latestDeployment);
144
- }
145
-
146
- this.bucketDeployments.push(extraAssetsDeployment);
147
- }
148
-
149
- private createBucket(
150
- siteDomain: string,
151
- removalPolicy: RemovalPolicy = RemovalPolicy.DESTROY
152
- ): Bucket {
153
- // Content bucket
154
- const siteBucket = new Bucket(this, `Bucket`, {
155
- websiteIndexDocument: 'index.html',
156
- websiteErrorDocument: 'index.html',
157
- bucketName: `${siteDomain}-spa-data`,
158
- publicReadAccess: false,
159
- blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
160
- /**
161
- * The default removal policy is RETAIN, which means that cdk destroy will not attempt to delete
162
- * the new bucket, and it will remain in your account until manually deleted. By setting the policy to
163
- * DESTROY, cdk destroy will attempt to delete the bucket, but will error if the bucket is not empty.
164
- */
165
- removalPolicy: removalPolicy,
166
-
167
- /**
168
- * For sample purposes only, if you create an S3 bucket then populate it, stack destruction fails. This
169
- * setting will enable full cleanup of the demo.
170
- */
171
- autoDeleteObjects: removalPolicy === RemovalPolicy.DESTROY, // Danger not recommended for production code
172
- });
173
-
174
- new CfnOutput(this, `${siteDomain}-Bucket`, {
175
- value: siteBucket.bucketName,
176
- });
177
- return siteBucket;
178
- }
179
-
180
- private createCertificate(
181
- zone: IHostedZone,
182
- ...domainNames: string[]
183
- ): Certificate {
184
- const [mainDomain, ...restofDomains] = domainNames;
185
-
186
- // TLS certificate
187
- const certificate = new Certificate(this, 'Certificate', {
188
- validation: CertificateValidation.fromDns(zone),
189
- domainName: mainDomain,
190
- subjectAlternativeNames: restofDomains,
191
- });
192
-
193
- new CfnOutput(this, `${this.spaName}-sCertificate`, {
194
- value: certificate.certificateArn,
195
- });
196
- return certificate;
197
- }
198
-
199
- private createDistribution(
200
- siteDomain: string,
201
- certificate: Certificate,
202
- siteBucket: IBucket,
203
- allowedOrigins: string[] = []
204
- ): Distribution {
205
- const responseHeadersPolicy = new ResponseHeadersPolicy(
206
- this,
207
- 'ResponseHeadersPolicy',
208
- {
209
- corsBehavior: {
210
- accessControlAllowCredentials: false,
211
- accessControlAllowOrigins: [
212
- ...allowedOrigins,
213
- 'https://' + siteDomain,
214
- 'http://localhost',
215
- 'http://127.0.0.1',
216
- ],
217
- accessControlAllowMethods: ['GET', 'HEAD', 'OPTIONS'],
218
- accessControlAllowHeaders: ['*'],
219
- originOverride: true,
220
- },
221
- }
222
- );
223
- // CloudFront distribution that provides HTTPS
224
- const distribution = new Distribution(this, 'Distribution', {
225
- certificate: certificate,
226
- defaultRootObject: 'index.html',
227
- domainNames: [siteDomain],
228
- httpVersion: HttpVersion.HTTP2_AND_3,
229
- minimumProtocolVersion: SecurityPolicyProtocol.TLS_V1_2_2021,
230
- errorResponses: [
231
- {
232
- httpStatus: 403,
233
- responseHttpStatus: 200,
234
- responsePagePath: '/index.html',
235
- ttl: Duration.minutes(30),
236
- },
237
- ],
238
- defaultBehavior: {
239
- responseHeadersPolicy,
240
- origin: S3BucketOrigin.withOriginAccessControl(siteBucket),
241
- compress: true,
242
- allowedMethods: AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
243
- viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
244
- },
245
- });
246
- new CfnOutput(this, `${this.spaName}-DistributionId`, {
247
- value: distribution.distributionId,
248
- });
249
- return distribution;
250
- }
251
-
252
- private createARecord(
253
- siteDomain: string,
254
- distribution: Distribution,
255
- zone: IHostedZone
256
- ): void {
257
- // Route53 alias record for the CloudFront distribution
258
- new ARecord(this, 'SiteAliasRecord', {
259
- recordName: siteDomain,
260
- target: RecordTarget.fromAlias(new CloudFrontTarget(distribution)),
261
- zone,
262
- });
263
- new CfnOutput(this, `${this.spaName}-ARecord`, { value: siteDomain });
264
- }
265
-
266
- private createBucketDeployment(
267
- sources: ISource[],
268
- cacheControl: CacheControl = CacheControl.maxAge(Duration.days(365)),
269
- prune: boolean,
270
- destinationBucket: IBucket,
271
- distribution: IDistribution,
272
- contentType?: string
273
- ): BucketDeployment {
274
- // Deploy site contents to S3 spaBucket
275
- return new BucketDeployment(
276
- this,
277
- 'DeployWithInvalidation' + (this.bucketDeployments.length + 1),
278
- {
279
- sources,
280
- destinationBucket,
281
- distribution,
282
- prune,
283
- memoryLimit: 1024,
284
- distributionPaths: ['/*'],
285
- cacheControl: [cacheControl],
286
- ...(contentType ? { contentType } : {}),
287
- }
288
- );
289
- }
290
-
291
- private extractDomains(domainName: string): {
292
- subDomainWildcard: string;
293
- mainDomain: string;
294
- siteDomain: string;
295
- } {
296
- // extract main domain
297
- const siteDomain = domainName;
298
- const mainDomain = domainName.split('.').slice(-2).join('.');
299
- const subDomainWildcard: string =
300
- mainDomain === siteDomain ? mainDomain : `*.${mainDomain}`;
301
-
302
- // inform user about domain handling
303
- if (mainDomain !== siteDomain) {
304
- console.warn(
305
- `The domain ${siteDomain} is not a top level domain. The certificate will be created for *.${mainDomain}`
306
- );
307
- }
308
- return { siteDomain, mainDomain, subDomainWildcard };
309
- }
310
- }
@@ -1,121 +0,0 @@
1
- import { App, Duration, RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib';
2
- import { Match, Template } from 'aws-cdk-lib/assertions';
3
- import * as path from 'path';
4
- import { vitest } from 'vitest';
5
- import { SpaConstruct, SpaProps } from './spa/spa.construct';
6
- import { CacheControl, Source } from 'aws-cdk-lib/aws-s3-deployment';
7
-
8
- // Mock the fromLookup method
9
- vitest.mock('aws-cdk-lib/aws-route53', () => {
10
- return {
11
- HostedZone: {
12
- fromLookup: vitest.fn((domain: string) => ({ superZone: domain })),
13
- },
14
- RecordTarget: {
15
- fromAlias: vitest.fn(),
16
- },
17
- ARecord: vitest.fn(),
18
- };
19
- });
20
- class TestSpaStack extends Stack {
21
- spaConstruct: SpaConstruct;
22
- constructor(parent: App, name: string, props: SpaProps & StackProps) {
23
- super(parent, name, props);
24
-
25
- const { domainName, buildOutputPath, bucketRemovalPolicy } = props;
26
- this.spaConstruct = new SpaConstruct(this, name, {
27
- domainName,
28
- buildOutputPath,
29
- bucketRemovalPolicy: bucketRemovalPolicy,
30
- });
31
- }
32
- }
33
-
34
- const defaultTestProps: SpaProps & StackProps = {
35
- env: {
36
- region: 'us-east-1',
37
- account: '123456789',
38
- },
39
- buildOutputPath: path.join(__dirname, '../../test-build-path'),
40
- domainName: 'test-domain.com',
41
- };
42
- describe('SpaCdkStack', () => {
43
- function testStackFactory(
44
- props: Partial<SpaProps & StackProps> = defaultTestProps
45
- ): { app: App; template: () => Template; stack: TestSpaStack } {
46
- // GIVEN
47
- const app = new App();
48
-
49
- // WHEN
50
- const stack = new TestSpaStack(app, 'TestSpaStack', {
51
- ...defaultTestProps,
52
- ...props,
53
- });
54
- const template = () => Template.fromStack(stack);
55
- return { stack, app, template };
56
- }
57
-
58
- it('should match snapshot', () => {
59
- const { template } = testStackFactory();
60
- expect(template().toJSON()).toMatchSnapshot();
61
- });
62
-
63
- it('should match snapshot setting bucket removal policy to retain', () => {
64
- const { template } = testStackFactory({
65
- bucketRemovalPolicy: RemovalPolicy.RETAIN_ON_UPDATE_OR_DELETE,
66
- });
67
- expect(template().toJSON()).toMatchSnapshot();
68
- });
69
-
70
- it('should use the main domain name even if a subdomain is provided', () => {
71
- // given, when
72
- const { template } = testStackFactory({
73
- domainName: 'subdomain.test-domain.com',
74
- });
75
-
76
- //then
77
- expect(template().toJSON()).toMatchSnapshot();
78
- });
79
-
80
- it('should create an BucketDeployment for the buildOutputPath and index.html', () => {
81
- const { template } = testStackFactory();
82
- //then
83
- template().resourceCountIs('Custom::CDKBucketDeployment', 2);
84
- template().hasResourceProperties('Custom::CDKBucketDeployment', {
85
- SourceObjectKeys: Match.arrayEquals([Match.stringLikeRegexp('\\w+.zip')]),
86
- });
87
- });
88
-
89
- it('should create an extra BucketDeployment when using addExtraAssets', () => {
90
- const { template, stack } = testStackFactory();
91
- stack.spaConstruct.addExtraAssets([
92
- Source.asset(path.join(__dirname, '../../extra-assets-path')),
93
- ]);
94
- //then
95
- expect(template().toJSON()).toMatchSnapshot();
96
- });
97
-
98
- it('should create an extra BucketDeployment when using addExtraAssets with cache control', () => {
99
- const { template, stack } = testStackFactory();
100
- stack.spaConstruct.addExtraAssets(
101
- [Source.asset(path.join(__dirname, '../../extra-assets-path'))],
102
- CacheControl.maxAge(Duration.seconds(123456))
103
- );
104
- //then
105
- expect(template().toJSON()).toMatchSnapshot();
106
- });
107
-
108
- it('should create an extra Custom::CDKBucketDeployment when using addExtraAssets', () => {
109
- const { template, stack } = testStackFactory();
110
-
111
- //when
112
- stack.spaConstruct.addExtraAssets([
113
- Source.asset(path.join(__dirname, '../../extra-assets-path')),
114
- ]);
115
- //then
116
- template().resourceCountIs('Custom::CDKBucketDeployment', 3);
117
- template().hasResourceProperties('Custom::CDKBucketDeployment', {
118
- SourceObjectKeys: Match.arrayEquals([Match.stringLikeRegexp('\\w+.zip')]),
119
- });
120
- });
121
- });
@@ -1,8 +0,0 @@
1
- export function envOrDie(key: string): string {
2
- const value = process.env[key];
3
- if (!value) {
4
- console.error(`Environment variable ${key} is required`);
5
- process.exit(1);
6
- }
7
- return value;
8
- }
@@ -1,15 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta
6
- name="viewport"
7
- content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"
8
- />
9
- <meta http-equiv="X-UA-Compatible" content="ie=edge" />
10
- <title>Document</title>
11
- </head>
12
- <body>
13
- AWESOME
14
- </body>
15
- </html>
File without changes
package/tsconfig.json DELETED
@@ -1,23 +0,0 @@
1
- {
2
- "extends": "../../../tsconfig.base.json",
3
- "compilerOptions": {
4
- "forceConsistentCasingInFileNames": true,
5
- "strict": true,
6
- "noImplicitOverride": true,
7
- "noPropertyAccessFromIndexSignature": true,
8
- "noImplicitReturns": true,
9
- "noFallthroughCasesInSwitch": true,
10
- "module": "ESNext",
11
- "moduleResolution": "bundler"
12
- },
13
- "files": [],
14
- "include": [],
15
- "references": [
16
- {
17
- "path": "./tsconfig.lib.json"
18
- },
19
- {
20
- "path": "./tsconfig.spec.json"
21
- }
22
- ]
23
- }
package/tsconfig.lib.json DELETED
@@ -1,10 +0,0 @@
1
- {
2
- "extends": "./tsconfig.json",
3
- "compilerOptions": {
4
- "outDir": "../../../dist/out-tsc",
5
- "declaration": true,
6
- "types": ["node"]
7
- },
8
- "include": ["src/**/*.ts"],
9
- "exclude": ["vite.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
10
- }
@@ -1,26 +0,0 @@
1
- {
2
- "extends": "./tsconfig.json",
3
- "compilerOptions": {
4
- "outDir": "../../../dist/out-tsc",
5
- "types": [
6
- "vitest/globals",
7
- "vitest/importMeta",
8
- "vite/client",
9
- "node",
10
- "vitest"
11
- ]
12
- },
13
- "include": [
14
- "vite.config.ts",
15
- "vitest.config.ts",
16
- "src/**/*.test.ts",
17
- "src/**/*.spec.ts",
18
- "src/**/*.test.tsx",
19
- "src/**/*.spec.tsx",
20
- "src/**/*.test.js",
21
- "src/**/*.spec.js",
22
- "src/**/*.test.jsx",
23
- "src/**/*.spec.jsx",
24
- "src/**/*.d.ts"
25
- ]
26
- }
package/vite.config.ts DELETED
@@ -1,30 +0,0 @@
1
- import { defineConfig } from 'vitest/config';
2
-
3
- import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
4
-
5
- import { fileURLToPath } from 'node:url';
6
- import { dirname } from 'node:path';
7
-
8
- export default defineConfig({
9
- root: dirname(fileURLToPath(import.meta.url)),
10
- cacheDir: '../../../node_modules/.vite/libs/public/spa-cdk-stack',
11
-
12
- plugins: [nxViteTsPaths()],
13
-
14
- // Uncomment this if you are using workers.
15
- // worker: {
16
- // plugins: [ nxViteTsPaths() ],
17
- // },
18
-
19
- test: {
20
- globals: true,
21
- environment: 'node',
22
- include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
23
- reporters: ['default'],
24
- coverage: {
25
- reporter: ['lcov', 'html', 'text-summary'],
26
- reportsDirectory: '../../../coverage/libs/public/spa-cdk-stack',
27
- provider: 'v8',
28
- },
29
- },
30
- });
File without changes