diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index 268585f..8c0a05e 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -21,7 +21,7 @@ jobs: - run: cd js && npm install && npm run build - run: echo "VITE_GIT_SHA=$(git rev-parse --short HEAD)" >> app/.env - run: cd app && npm install && ./node_modules/.bin/tsc && npm run prettier-check && ./node_modules/.bin/vite build - - run: cd serverless/aws && npm install && npx tsc && npm run biome-check && npm run build && cp dist/lambda_function.zip ../../app/dist + - run: cd serverless/aws && npm install && npx tsc && npm run biome-check && npm run build-zip && cp dist/lambda_function.zip ../../app/dist && npm run build-cloudformation-stack && cp dist/cloudformation-stack.yaml ../../app/dist - run: cd serverless/cloudflare && cp wrangler.toml.example wrangler.toml && npm install && npx tsc && npm run biome-check && npm run build && cp dist/index.js ../../app/dist - run: cd spec/v3 && cp *.pmtiles ../../app/dist - run: cd js/examples && mkdir ../../app/dist/examples && cp *.html ../../app/dist/examples/ diff --git a/serverless/aws/inline_lambda.ts b/serverless/aws/inline_lambda.ts new file mode 100644 index 0000000..8f1469f --- /dev/null +++ b/serverless/aws/inline_lambda.ts @@ -0,0 +1,7 @@ +import { yamlParse, yamlDump } from 'yaml-cfn'; +import fs from 'fs'; + +const template = yamlParse(fs.readFileSync('protomaps-template.yaml','utf-8')); +const code = fs.readFileSync('dist/index.js','utf8'); +template.Resources.LambdaFunction.Properties.Code = {ZipFile:code}; +fs.writeFileSync('dist/cloudformation-stack.yaml', yamlDump(template)); diff --git a/serverless/aws/package-lock.json b/serverless/aws/package-lock.json index b7b8af3..4962661 100644 --- a/serverless/aws/package-lock.json +++ b/serverless/aws/package-lock.json @@ -17,7 +17,8 @@ "@types/node": "^18.11.2", "esbuild": "^0.20.0", "tsx": "^4.7.0", - "typescript": "^4.8.4" + "typescript": "^4.8.4", + "yaml-cfn": "^0.3.2" } }, "node_modules/@aws-crypto/crc32": { @@ -1991,6 +1992,13 @@ "integrity": "sha512-BWN3M23gLO2jVG8g/XHIRFWiiV4/GckeFIqbU/C4V3xpoBBWSMk4OZomouN0wCkfQFPqgZikyLr7DOYDysIkkw==", "dev": true }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/bowser": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", @@ -2081,6 +2089,19 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -2545,6 +2566,19 @@ "bin": { "uuid": "dist/bin/uuid" } + }, + "node_modules/yaml-cfn": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/yaml-cfn/-/yaml-cfn-0.3.2.tgz", + "integrity": "sha512-MvrWhv40GKWHFGCliTGGAMwAeqIXf/bzf6WW48+xND9iMp8cTj0R8xkwM0lX/GzNN/EZKr5gP4Hx63Fn+sICoA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "js-yaml": "^4.0.0" + }, + "bin": { + "yaml-cfn": "cli.js" + } } }, "dependencies": { @@ -3995,6 +4029,12 @@ "integrity": "sha512-BWN3M23gLO2jVG8g/XHIRFWiiV4/GckeFIqbU/C4V3xpoBBWSMk4OZomouN0wCkfQFPqgZikyLr7DOYDysIkkw==", "dev": true }, + "argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, "bowser": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", @@ -4055,6 +4095,15 @@ "resolve-pkg-maps": "^1.0.0" } }, + "js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "requires": { + "argparse": "^2.0.1" + } + }, "resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -4286,6 +4335,15 @@ "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + }, + "yaml-cfn": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/yaml-cfn/-/yaml-cfn-0.3.2.tgz", + "integrity": "sha512-MvrWhv40GKWHFGCliTGGAMwAeqIXf/bzf6WW48+xND9iMp8cTj0R8xkwM0lX/GzNN/EZKr5gP4Hx63Fn+sICoA==", + "dev": true, + "requires": { + "js-yaml": "^4.0.0" + } } } } diff --git a/serverless/aws/package.json b/serverless/aws/package.json index 2d6e3a0..667e49e 100644 --- a/serverless/aws/package.json +++ b/serverless/aws/package.json @@ -7,12 +7,14 @@ "@types/node": "^18.11.2", "esbuild": "^0.20.0", "tsx": "^4.7.0", - "typescript": "^4.8.4" + "typescript": "^4.8.4", + "yaml-cfn": "^0.3.2" }, "private": true, "scripts": { "tsc": "tsc --noEmit --watch", - "build": "esbuild src/index.ts --target=es2020 --outfile=dist/index.mjs --format=esm --bundle --platform=node --target=node18 --external:@aws-sdk/client-s3 --external:@aws-sdk/node-http-handler --banner:js=//$(git describe --always) && cd dist && zip lambda_function.zip index.mjs", + "build-zip": "esbuild src/index.ts --target=es2020 --outfile=dist/index.mjs --format=esm --bundle --platform=node --target=node20 --external:@aws-sdk/client-s3 --external:@aws-sdk/node-http-handler --banner:js=//$(git describe --always) && cd dist && zip lambda_function.zip index.mjs", + "build-cloudformation-stack": "esbuild src/index.ts --target=es2020 --minify --outfile=dist/index.js --format=cjs --bundle --platform=node --target=node20 --external:@aws-sdk/client-s3 --external:@aws-sdk/node-http-handler --banner:js=//sha:$(git describe --always) && tsx inline_lambda.ts", "test": "tsx ../shared/index.test.ts", "biome": "biome check --config-path=../../js/ src --apply", "biome-check": "biome check --config-path=../../js src" diff --git a/serverless/aws/protomaps-template.yaml b/serverless/aws/protomaps-template.yaml index 970ce7a..d48ac00 100644 --- a/serverless/aws/protomaps-template.yaml +++ b/serverless/aws/protomaps-template.yaml @@ -1,36 +1,39 @@ AWSTemplateFormatVersion: '2010-09-09' -Description: CloudFormation template to create a protomaps infraestructure to serve tiles. +Description: Serve Z/X/Y tiles through CloudFront + Lambda from an existing S3 bucket. Parameters: BucketName: - Description: 'The name of the S3 bucket where you will store pmtiles files to be served (must be globally unique)' + Description: 'Name of an existing S3 bucket with .pmtiles tilesets. Should be in the same region as your CloudFormation stack.' Type: String + MinLength: 1 - CodeBucketName: - Description: 'The S3 bucket name where the Lambda function code is stored (e.g., lambda-protomaps-code)' - Type: String - - CodeKey: - Description: 'The S3 key for the Lambda function code (e.g., lambda_function.zip)' - Type: String + AllowedOrigins: + Description: 'Comma-separated list of domains (e.g. example.com) allowed by browser CORS policy, or * for all origins.' + Type: List + Default: "*" PublicHostname: - Description: 'The public custom domain name for your CloudFront distribution' + Description: 'Optional. Replace *.cloudfront.net in TileJSON with a custom hostname (e.g. example.com). See docs on how this value is cached.' Type: String - Default: 'None' -# ########################################################################## -# # S3 Bucket # -# ########################################################################## +Outputs: + CloudFrontDistributionUrl: + Description: 'URL of the CloudFront distribution' + Value: !Sub "https://${CloudFrontDistribution.DomainName}" + Export: + Name: !Sub "${AWS::StackName}-CloudFrontDistributionURL" + +Conditions: + IsPublicHostnameProvided: + Fn::Not: + - Fn::Equals: + - Ref: PublicHostname + - '' Resources: - S3Bucket: - Type: 'AWS::S3::Bucket' + LogGroup: + Type: AWS::Logs::LogGroup Properties: - BucketName: !Ref BucketName - PublicAccessBlockConfiguration: - BlockPublicAcls: true - IgnorePublicAcls: true - BlockPublicPolicy: true - RestrictPublicBuckets: true + LogGroupName: !Sub "/aws/lambda/${AWS::StackName}" + RetentionInDays: 7 LambdaExecutionRole: Type: 'AWS::IAM::Role' @@ -43,17 +46,16 @@ Resources: Service: lambda.amazonaws.com Action: sts:AssumeRole Policies: - - PolicyName: LambdaBasicExecution + - PolicyName: !Sub "${AWS::StackName}-LambdaLoggingPolicy" PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: - - logs:CreateLogGroup - logs:CreateLogStream - logs:PutLogEvents - Resource: '*' - - PolicyName: S3AccessPolicy + Resource: !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${AWS::StackName}:log-stream:*" + - PolicyName: !Sub "${AWS::StackName}-LambdaS3AccessPolicy" PolicyDocument: Version: '2012-10-17' Statement: @@ -61,76 +63,70 @@ Resources: Action: s3:GetObject Resource: !Sub arn:aws:s3:::${BucketName}/* -########################################################################## -# Lambda Function # -########################################################################## - ProtomapsLambdaFunction: - Type: 'AWS::Lambda::Function' - Properties: - FunctionName: protomaps - Runtime: nodejs18.x - Architectures: [arm64] - Role: !GetAtt LambdaExecutionRole.Arn - Handler: index.handler - MemorySize: 512 - Environment: - Variables: - BUCKET: !Ref BucketName - PUBLIC_HOSTNAME: !Ref PublicHostname - Code: - S3Bucket: !Ref CodeBucketName - S3Key: !Ref CodeKey - - ProtomapsLambdaFunctionUrl: + LambdaFunctionUrl: Type: 'AWS::Lambda::Url' Properties: AuthType: NONE - TargetFunctionArn: !GetAtt ProtomapsLambdaFunction.Arn - Cors: - AllowOrigins: ["*"] + TargetFunctionArn: !GetAtt LambdaFunction.Arn InvokeMode: BUFFERED - ProtomapsLambdaFunctionUrlPermission: + LambdaFunctionUrlPermission: Type: 'AWS::Lambda::Permission' Properties: Action: lambda:InvokeFunctionUrl - FunctionName: !Ref ProtomapsLambdaFunction + FunctionName: !Ref LambdaFunction Principal: '*' FunctionUrlAuthType: NONE -# ########################################################################## -# # CloudFront::Distribution # -# ########################################################################## + ViewerRequestCloudFrontFunction: + Type: AWS::CloudFront::Function + Properties: + Name: !Sub "${AWS::StackName}-ViewerRequestCloudFrontFunction" + AutoPublish: true + FunctionCode: | + function handler(event) { + const request = event.request; + request.headers['x-distribution-domain-name'] = { value: event.context.distributionDomainName }; + return request; + } + FunctionConfig: + Comment: 'Add x-distribution-domain header.' + Runtime: cloudfront-js-2.0 CloudFrontDistribution: Type: 'AWS::CloudFront::Distribution' + DeletionPolicy: Delete Properties: DistributionConfig: Origins: - - Id: ProtomapsLambdaOrigin - DomainName: !Select [2, !Split ["/", !GetAtt ProtomapsLambdaFunctionUrl.FunctionUrl]] + - Id: LambdaOrigin + DomainName: !Select [2, !Split ["/", !GetAtt LambdaFunctionUrl.FunctionUrl]] CustomOriginConfig: OriginProtocolPolicy: https-only DefaultCacheBehavior: - TargetOriginId: ProtomapsLambdaOrigin + TargetOriginId: LambdaOrigin ViewerProtocolPolicy: redirect-to-https CachePolicyId: !Ref CachePolicyId ResponseHeadersPolicyId: !Ref ResponseHeadersPolicyId + OriginRequestPolicyId: b689b0a8-53d0-40ab-baf2-68738e2966ac + FunctionAssociations: + Fn::If: + - IsPublicHostnameProvided + - !Ref "AWS::NoValue" + - + - EventType: viewer-request + FunctionARN: !GetAtt ViewerRequestCloudFrontFunction.FunctionARN Enabled: true HttpVersion: http2and3 - Comment: "Protomaps CloudFront Distribution" + Comment: "CloudFront Distribution" PriceClass: PriceClass_All # Change this to save cost and distribute to fewer countries. Check https://aws.amazon.com/cloudfront/pricing/ -# ########################################################################## -# # CloudFront::CachePolicy # -# ########################################################################## - CachePolicyId: Type: 'AWS::CloudFront::CachePolicy' Properties: CachePolicyConfig: - Name: 'CachingOptimized' + Name: !Sub "${AWS::StackName}-CachePolicyConfig" DefaultTTL: 86400 MaxTTL: 31536000 MinTTL: 0 @@ -146,42 +142,43 @@ Resources: ResponseHeadersPolicyId: Type: 'AWS::CloudFront::ResponseHeadersPolicy' + # DeletionPolicy: Delete Properties: ResponseHeadersPolicyConfig: - Name: 'protomaps-cors' + Name: !Sub "${AWS::StackName}-ResponseHeadersPolicyConfig" CorsConfig: AccessControlAllowOrigins: - Items: - - 'https://example.com' # Replace with your allowed origin + Items: !Ref AllowedOrigins AccessControlAllowHeaders: - Items: + Items: - '*' AccessControlAllowMethods: Items: - GET - - POST + - HEAD - OPTIONS - AccessControlAllowCredentials: false # Set to true if you want to include credentials + AccessControlExposeHeaders: + Items: + - ETag + AccessControlAllowCredentials: false # Set to true if you want to include credentials OriginOverride: true Comment: 'CORS policy for Protomaps' - DeletionPolicy: Delete - -Outputs: - BucketNameOutput: - Description: 'URL of the S3 bucket' - Value: !Sub "https://s3.console.aws.amazon.com/s3/buckets/${BucketName}" - Export: - Name: !Sub "${AWS::StackName}-S3BucketURL" - - LambdaFunctionUrl: - Description: 'URL of the Lambda function' - Value: !GetAtt ProtomapsLambdaFunctionUrl.FunctionUrl - Export: - Name: !Sub "${AWS::StackName}-LambdaFunctionURL" - - CloudFrontDistributionUrl: - Description: 'URL of the CloudFront distribution' - Value: !Sub "https://${CloudFrontDistribution.DomainName}" - Export: - Name: !Sub "${AWS::StackName}-CloudFrontDistributionURL" + LambdaFunction: + Type: 'AWS::Lambda::Function' + Properties: + FunctionName: !Sub "${AWS::StackName}-LambdaFunction" + Runtime: nodejs20.x + Architectures: [arm64] + Role: !GetAtt LambdaExecutionRole.Arn + Handler: index.handler + MemorySize: 512 + LoggingConfig: + LogGroup: !Ref LogGroup + Environment: + Variables: + BUCKET: !Ref BucketName + PUBLIC_HOSTNAME: !Ref PublicHostname + Code: + S3Bucket: !Ref BucketName + S3Key: lambda_function.zip diff --git a/serverless/aws/src/index.ts b/serverless/aws/src/index.ts index 3194d23..c4ffbe0 100644 --- a/serverless/aws/src/index.ts +++ b/serverless/aws/src/index.ts @@ -160,7 +160,12 @@ export const handlerRaw = async ( const header = await p.getHeader(); if (!tile) { - if (!process.env.PUBLIC_HOSTNAME) { + if ( + !( + process.env.PUBLIC_HOSTNAME || + event.headers["x-distribution-domain-name"] + ) + ) { return apiResp( 501, "PUBLIC_HOSTNAME must be set for TileJSON", @@ -173,7 +178,9 @@ export const handlerRaw = async ( const t = tileJSON( header, await p.getMetadata(), - process.env.PUBLIC_HOSTNAME, + process.env.PUBLIC_HOSTNAME || + event.headers["x-distribution-domain-name"] || + "", name );