diff --git a/sst.config.ts b/sst.config.ts index 1d1e65cc..c5f71a03 100644 --- a/sst.config.ts +++ b/sst.config.ts @@ -1,60 +1,59 @@ // eslint-disable-next-line @typescript-eslint/triple-slash-reference /// -export default $config({ - app(input) { - return { - name: 'peterportal-client', - removal: input?.stage === 'prod' ? 'retain' : 'remove', - home: 'aws', - providers: { - aws: { - region: 'us-west-1', - }, - }, - }; - }, - async run() { - let domainName: string; - let domainRedirects: string[] | undefined; - if ($app.stage === 'prod') { - domainName = 'peterportal.org'; - domainRedirects = ['www.peterportal.org']; - } else if ($app.stage === 'dev') { - domainName = 'dev.peterportal.org'; - } else if ($app.stage.match(/^staging-(\d+)$/)) { - // check if stage is like staging-### - domainName = `${$app.stage}.peterportal.org`; - } else { - throw new Error('Invalid stage'); - } +function getDomainConfig() { + let domainName: string; + let domainRedirects: string[] | undefined; + if ($app.stage === 'prod') { + domainName = 'peterportal.org'; + domainRedirects = ['www.peterportal.org']; + } else if ($app.stage === 'dev') { + domainName = 'dev.peterportal.org'; + } else if ($app.stage.match(/^staging-(\d+)$/)) { + // check if stage is like staging-### + domainName = `${$app.stage}.peterportal.org`; + } else { + throw new Error('Invalid stage'); + } + return { domainName, domainRedirects }; +} - const lambdaFunction = new sst.aws.Function('PeterPortal Backend', { - handler: 'api/src/app.handler', - memory: '256 MB', - runtime: 'nodejs20.x', - logging: { - retention: $app.stage === 'prod' ? '2 years' : '1 week', - }, - environment: { - DATABASE_URL: process.env.DATABASE_URL!, - SESSION_SECRET: process.env.SESSION_SECRET!, - PUBLIC_API_URL: process.env.PUBLIC_API_URL!, - GOOGLE_CLIENT: process.env.GOOGLE_CLIENT!, - GOOGLE_SECRET: process.env.GOOGLE_SECRET!, - GRECAPTCHA_SECRET: process.env.GRECAPTCHA_SECRET!, - PRODUCTION_DOMAIN: process.env.PRODUCTION_DOMAIN!, - ADMIN_EMAILS: process.env.ADMIN_EMAILS!, - NODE_ENV: process.env.NODE_ENV ?? 'staging', - ANTEATER_API_KEY: process.env.ANTEATER_API_KEY!, - }, - url: true, - }); +function createLambdaFunction() { + const environment = { + DATABASE_URL: process.env.DATABASE_URL!, + SESSION_SECRET: process.env.SESSION_SECRET!, + PUBLIC_API_URL: process.env.PUBLIC_API_URL!, + GOOGLE_CLIENT: process.env.GOOGLE_CLIENT!, + GOOGLE_SECRET: process.env.GOOGLE_SECRET!, + GRECAPTCHA_SECRET: process.env.GRECAPTCHA_SECRET!, + PRODUCTION_DOMAIN: process.env.PRODUCTION_DOMAIN!, + ADMIN_EMAILS: process.env.ADMIN_EMAILS!, + NODE_ENV: process.env.NODE_ENV ?? 'staging', + ANTEATER_API_KEY: process.env.ANTEATER_API_KEY!, + }; - const forwardHostFunction = new aws.cloudfront.Function('CloudFrontFunction', { - runtime: 'cloudfront-js-2.0', - // this code is copy/pasted from an SST sveltekit component, forwards host and encodes query string - code: ` + return new sst.aws.Function('PeterPortal Backend', { + handler: 'api/src/app.handler', + memory: '256 MB', + runtime: 'nodejs20.x', + logging: { + retention: $app.stage === 'prod' ? '2 years' : '1 week', + }, + environment, + url: true, + }); +} + +/** + * forwards host since lambda function url overwrites host (x-forwarded-host is recovered in api/app.ts) + * encodes querystryings since cloudfront can't support it otherwise + * @returns cloudfront function + */ +const createCloudFrontInjectionFunction = () => + new aws.cloudfront.Function('CloudFrontFunction', { + runtime: 'cloudfront-js-2.0', + // this code is copy/pasted from an SST sveltekit component, forwards host and encodes query string + code: ` function handler(event) { var request = event.request; request.headers["x-forwarded-host"] = request.headers.host; @@ -67,51 +66,83 @@ export default $config({ return request; } `, - }); + }); - const apiOrigin: aws.types.input.cloudfront.DistributionOrigin = { - domainName: lambdaFunction.url.apply((url) => new URL(url).hostname), - originId: 'api', - customOriginConfig: { - httpPort: 80, - httpsPort: 443, - originProtocolPolicy: 'https-only', - originSslProtocols: ['TLSv1.2'], - }, - }; +function createApiOrigin(lambdaFunction: sst.aws.Function): aws.types.input.cloudfront.DistributionOrigin { + return { + domainName: lambdaFunction.url.apply((url) => new URL(url).hostname), + originId: 'api', + customOriginConfig: { + httpPort: 80, + httpsPort: 443, + originProtocolPolicy: 'https-only', + originSslProtocols: ['TLSv1.2'], + }, + }; +} - new sst.aws.StaticSite('PeterPortal Site', { - domain: { - name: domainName, - redirects: domainRedirects, - }, - path: './site', - build: { - command: 'pnpm build', - output: 'dist', +function createStaticSite( + domainName: string, + domainRedirects: string[] | undefined, + apiOrigin: aws.types.input.cloudfront.DistributionOrigin, + cloudfrontInjectionFunction: aws.cloudfront.Function, +) { + return new sst.aws.StaticSite('PeterPortal Site', { + domain: { + name: domainName, + redirects: domainRedirects, + }, + path: './site', + build: { + command: 'pnpm build', + output: 'dist', + }, + transform: { + cdn: (args) => { + args.origins = $output(args.origins).apply((origins) => [...origins, apiOrigin]); + args.orderedCacheBehaviors = [ + { + pathPattern: '/api/*', + allowedMethods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'OPTIONS', 'DELETE'], + cachedMethods: ['GET', 'HEAD'], + targetOriginId: apiOrigin.originId, + viewerProtocolPolicy: 'https-only', + cachePolicyId: '4135ea2d-6df8-44a3-9df3-4b5a84be39ad', // caching disabled policy: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-cache-policies.html + originRequestPolicyId: 'b689b0a8-53d0-40ab-baf2-68738e2966ac', // all viewer except host header policy: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-origin-request-policies.html + functionAssociations: [ + { + eventType: 'viewer-request', + functionArn: cloudfrontInjectionFunction.arn, + }, + ], + }, + ]; }, - transform: { - cdn: (args) => { - args.origins = $output(args.origins).apply((origins) => [...origins, apiOrigin]); - args.orderedCacheBehaviors = [ - { - pathPattern: '/api/*', - allowedMethods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'OPTIONS', 'DELETE'], - cachedMethods: ['GET', 'HEAD'], - targetOriginId: apiOrigin.originId, - viewerProtocolPolicy: 'https-only', - cachePolicyId: '4135ea2d-6df8-44a3-9df3-4b5a84be39ad', // caching disabled policy: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-cache-policies.html - originRequestPolicyId: 'b689b0a8-53d0-40ab-baf2-68738e2966ac', // all viewer except host header policy: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-origin-request-policies.html - functionAssociations: [ - { - eventType: 'viewer-request', - functionArn: forwardHostFunction.arn, - }, - ], - }, - ]; + }, + }); +} + +export default $config({ + app(input) { + return { + name: 'peterportal-client', + removal: input?.stage === 'prod' ? 'retain' : 'remove', + home: 'aws', + providers: { + aws: { + region: 'us-west-1', }, }, - }); + }; + }, + + async run() { + const { domainName, domainRedirects } = getDomainConfig(); + const lambdaFunction = createLambdaFunction(); + + const apiOrigin = createApiOrigin(lambdaFunction); + const cloudfrontInjectionFunction = createCloudFrontInjectionFunction(); + + createStaticSite(domainName, domainRedirects, apiOrigin, cloudfrontInjectionFunction); }, });