├── .gitignore ├── .eslintrc.js ├── .pre-commit-config.yaml ├── .github └── workflows │ ├── dependabot.yml │ ├── package-audit.yml │ └── main.yml ├── index.test.js ├── LICENSE ├── package.json ├── resources.yml ├── README.md └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | # package directories 2 | /node_modules/ 3 | jspm_packages 4 | build 5 | coverage 6 | .nyc_output 7 | coverage 8 | .coverage-cache 9 | 10 | *.log 11 | .DS_STORE 12 | .vscode 13 | 14 | # Serverless directories 15 | .serverless 16 | .eslintcache 17 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env":{ 3 | "browser":true, 4 | "commonjs":true, 5 | "es2021":true 6 | }, 7 | "extends":[ 8 | "airbnb-base" 9 | ], 10 | "parserOptions":{ 11 | "ecmaVersion":"latest" 12 | }, 13 | "rules":{ 14 | 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v4.5.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-yaml 10 | - id: check-added-large-files 11 | - id: check-json 12 | - id: detect-private-key 13 | - id: pretty-format-json 14 | args: [ --autofix, --no-sort-keys ] 15 | -------------------------------------------------------------------------------- /.github/workflows/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /index.test.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | describe('Array', function () { 3 | describe('#getCloudfrontResources()', function () { 4 | it('domains should be a list', function () { 5 | const ServerlessAWSFunctionURLCustomDomainPlugin = require('./index') 6 | config = { 7 | domains: ['sudomain1.yourdomain.com', 'subdomain2.yourdomain.com'], 8 | hostedZoneName: 'yourdomain.com.', 9 | certificateArn: 'arn:aws:acm:us-east-1:xxxxx:certificate/xxxxxxx', 10 | route53: true 11 | } 12 | var plugin = new ServerlessAWSFunctionURLCustomDomainPlugin(); 13 | var resources = plugin.getCloudfrontResources(config); 14 | console.log(resources['Resources']['CloudFrontDistribution']['Properties']['DistributionConfig']['Comment']) 15 | assert(resources['Resources']['CloudFrontDistribution']['Properties']['DistributionConfig']['Aliases'] == config['domains']); 16 | 17 | }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 wangsha. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/package-audit.yml: -------------------------------------------------------------------------------- 1 | name: NPM audit 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 1 * *' 6 | workflow_dispatch: 7 | inputs: 8 | logLevel: 9 | description: 'Log level' 10 | required: true 11 | default: 'warning' 12 | 13 | jobs: 14 | npm-audit: 15 | runs-on: ubuntu-latest 16 | name: NPM Audit (and fix) 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v2 20 | - name: NPM Audit 21 | uses: 'luisfontes19/npm-audit-action@v0.1.0' 22 | with: 23 | project-path: "." 24 | only: prod 25 | fix: true 26 | package-lock-only: false 27 | force: true 28 | git-user: action-npm-audit 29 | git-email: action-npm-audit 30 | git-message: npm fix run from npm-audit action 31 | git-pr-title: "[SECURITY] NPM audit fix" 32 | git-branch: npm-audit-action 33 | git-remote: origin 34 | github-token: ${{ secrets.GITHUB_TOKEN }} 35 | - name: outdated version 36 | uses: ycjcl868/outdated-version-action@v1 37 | with: 38 | token: ${{ secrets.GITHUB_TOKEN }} 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-aws-function-url-custom-domain", 3 | "version": "0.9.28", 4 | "description": "Serverless AWS Lambda URL custom domain configuration via cloudfront distribution and route 53.", 5 | "main": "index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/wangsha/serverless-aws-function-url-custom-domain" 9 | }, 10 | "scripts": { 11 | "test": "npm run test-lint && npm run test-unit", 12 | "test-lint": "eslint *.js", 13 | "test-unit": "./node_modules/mocha/bin/mocha.js index.test.js" 14 | }, 15 | "author": "wangsha", 16 | "keywords": [ 17 | "serverless", 18 | "serverless plugin", 19 | "cloudfront", 20 | "function url", 21 | "lambda", 22 | "aws", 23 | "aws lambda", 24 | "amazon web services", 25 | "serverless.com" 26 | ], 27 | "dependencies": { 28 | "js-yaml": "^4.1.0", 29 | "lodash": "^4.17.21", 30 | "mustache": "^4.2.0" 31 | }, 32 | "peerDependencies": { 33 | "serverless": "3.x" 34 | }, 35 | "engines": { 36 | "node": ">=10.0" 37 | }, 38 | "license": "MIT", 39 | "devDependencies": { 40 | "eslint": "^8.17.0", 41 | "eslint-config-airbnb-base": "^15.0.0", 42 | "eslint-plugin-import": "^2.26.0", 43 | "mocha": "^10.0.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | workflow_dispatch: 9 | inputs: 10 | logLevel: 11 | description: 'Log level' 12 | required: true 13 | default: 'warning' 14 | 15 | jobs: 16 | publish: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v3 20 | - uses: actions/setup-node@v3 21 | with: 22 | node-version: 12 23 | - run: npm install 24 | - run: npm run test-unit 25 | - name: 'Automated Version Bump' 26 | uses: 'phips28/gh-action-bump-version@master' 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | with: 30 | commit-message: 'CI: bumps version to {{version}} [skip ci]' 31 | - uses: JS-DevTools/npm-publish@v1 32 | with: 33 | token: ${{ secrets.NPM_TOKEN }} 34 | - name: Check if version has been updated 35 | id: check 36 | uses: EndBug/version-check@v2.1.0 37 | with: 38 | file-url: https://unpkg.com/serverless-aws-function-url-custom-domain/package.json 39 | - name: Log when changed 40 | if: steps.check.outputs.changed == 'true' 41 | run: 'echo "Version change found in commit ${{ steps.check.outputs.commit }}! New version: ${{ steps.check.outputs.version }} (${{ steps.check.outputs.type }})"' 42 | 43 | - name: Log when unchanged 44 | if: steps.check.outputs.changed == 'false' 45 | run: 'echo "No version change :/"' 46 | -------------------------------------------------------------------------------- /resources.yml: -------------------------------------------------------------------------------- 1 | --- 2 | Resources: 3 | CloudFrontDistribution: 4 | Type: AWS::CloudFront::Distribution 5 | DeletionPolicy: Delete 6 | Properties: 7 | DistributionConfig: 8 | Enabled: true 9 | PriceClass: PriceClass_100 10 | HttpVersion: http2 11 | Comment: "Lambda FunctionURL {{{ domains }}}" 12 | Origins: 13 | - Id: LambdaFunctionURL 14 | DomainName: { "Fn::Select": [2, {'Fn::Split': ["/", {'Fn::GetAtt': ["{{{ lambdaFunctionUrl }}}", FunctionUrl]}]}]} # extract function url form your lambda resource 15 | OriginPath: '' 16 | CustomOriginConfig: 17 | HTTPPort: 80 18 | HTTPSPort: 443 19 | OriginProtocolPolicy: https-only 20 | OriginSSLProtocols: [TLSv1, TLSv1.1, TLSv1.2] 21 | DefaultCacheBehavior: 22 | TargetOriginId: LambdaFunctionURL 23 | ViewerProtocolPolicy: redirect-to-https 24 | Compress: true 25 | DefaultTTL: 0 26 | AllowedMethods: 27 | - HEAD 28 | - DELETE 29 | - POST 30 | - GET 31 | - OPTIONS 32 | - PUT 33 | - PATCH 34 | CachedMethods: 35 | - HEAD 36 | - OPTIONS 37 | - GET 38 | ForwardedValues: 39 | QueryString: true 40 | Headers: 41 | - Accept 42 | - x-api-key 43 | - Authorization 44 | Cookies: 45 | Forward: all 46 | Aliases: "{{{ domains }}}" 47 | ViewerCertificate: 48 | SslSupportMethod: sni-only 49 | MinimumProtocolVersion: TLSv1.2_2021 50 | AcmCertificateArn: "{{{certificateArn}}}" 51 | Outputs: 52 | CloudFrontDistributionDomain: 53 | Value: 54 | Fn::GetAtt: [ CloudFrontDistribution, DomainName ] 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![serverless](http://public.serverless.com/badges/v3.svg)](http://www.serverless.com) 2 | [![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)]([https://raw.githubusercontent.com/Droplr/serverless-api-cloudfront/master/LICENSE](https://raw.githubusercontent.com/wangsha/serverless-aws-function-url-custom-domain/main/LICENSE)) 3 | [![npm version](https://badge.fury.io/js/serverless-aws-function-url-custom-domain.svg)](https://badge.fury.io/js/serverless-aws-function-url-custom-domain) 4 | [![npm downloads](https://img.shields.io/npm/dt/serverless-aws-function-url-custom-domain.svg?style=flat)](https://www.npmjs.com/package/serverless-aws-function-url-custom-domain) 5 | [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit) 6 | 7 | 8 | # serverless-aws-function-url-custom-domain 9 | 10 | Automatically creates AWS CloudFront distribution and Route 53 records to AWS Lambda with [Function URL](https://aws.amazon.com/fr/blogs/aws/announcing-aws-lambda-function-urls-built-in-https-endpoints-for-single-function-microservices/) (no api gateway). 11 | 12 | ## Installation 13 | ```bash 14 | npm install --save-dev serverless-aws-function-url-custom-domain 15 | ``` 16 | 17 | ## Configuration 18 | This plugin assumes your domain is hosted and managed with AWS Route53. SSL certificate is managed via certificate manager. 19 | 20 | ```yaml 21 | # add in your serverless.yml 22 | 23 | plugins: 24 | - serverless-aws-function-url-custom-domain 25 | 26 | 27 | custom: 28 | urlDomain: 29 | domains: 30 | - ${env:SUBDOMAIN}.yourdomain.com # custom domain 1 31 | - ${env:SUBDOMAIN}-alt.yourdomain.com # custom domain 2 32 | hostedZoneName: yourdomain.com. # your domain Route 53 hosted zone name 33 | certificateArn: 'arn:aws:acm:us-east-1:xxxxx:certificate/xxxxx' # need to be located at NVirgina 34 | route53: false # disable route 53 integration 35 | functions: 36 | api: 37 | handler: wsgi_handler.handler 38 | url: true # activate function URL! 39 | 40 | ``` 41 | 42 | ### Deploy 43 | ```javascript 44 | serverless deploy 45 | ``` 46 | 47 | ### Inspect Result 48 | ```javascript 49 | serverless info --verbose 50 | ``` 51 | 52 | ```bash 53 | Output: 54 | 55 | 56 | CloudFront domain name 57 | xxxxx.cloudfront.net (CNAME: ${env:SUBDOMAIN}.yourdomain.com) 58 | 59 | ``` 60 | 61 | ### IAM Policy 62 | 63 | In order to make this plugin work as expected a few additional IAM Policies might be needed on your AWS profile. 64 | 65 | More specifically this plugin needs the following policies attached: 66 | 67 | * `cloudfront:CreateDistribution` 68 | * `cloudfront:GetDistribution` 69 | * `cloudfront:UpdateDistribution` 70 | * `cloudfront:DeleteDistribution` 71 | * `cloudfront:TagResource` 72 | * `route53:ListHostedZones` 73 | * `route53:ChangeResourceRecordSets` 74 | * `route53:GetHostedZone` 75 | * `route53:ListResourceRecordSets` 76 | * `acm:ListCertificates` 77 | 78 | You can read more about IAM profiles and policies in the [Serverless documentation](https://serverless.com/framework/docs/providers/aws/guide/credentials#creating-aws-access-keys). 79 | 80 | 81 | ## References 82 | - [serverless framework example integration](https://medium.com/@walid.karray/configuring-a-custom-domain-for-aws-lambda-function-url-with-serverless-framework-c0d78abdc253) 83 | - [AWS Lambda Function URLs](https://aws.amazon.com/fr/blogs/aws/announcing-aws-lambda-function-urls-built-in-https-endpoints-for-single-function-microservices/) 84 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const _ = require('lodash'); 3 | const yaml = require('js-yaml'); 4 | const fs = require('fs'); 5 | const Mustache = require('mustache'); 6 | 7 | class ServerlessAWSFunctionURLCustomDomainPlugin { 8 | constructor(serverless, options) { 9 | this.serverless = serverless; 10 | this.options = options; 11 | 12 | this.hooks = { 13 | 'before:package:finalize': this.createDeploymentArtifacts.bind(this), 14 | 'aws:info:displayStackOutputs': this.printSummary.bind(this), 15 | }; 16 | } 17 | 18 | createDeploymentArtifacts() { 19 | const baseResources = this.serverless.service.provider.compiledCloudFormationTemplate; 20 | 21 | var functionURLResourceName = null; 22 | for(var key in baseResources.Resources) { 23 | if (baseResources.Resources[key]['Type'] === "AWS::Lambda::Url") { 24 | functionURLResourceName = key 25 | } 26 | } 27 | 28 | if (functionURLResourceName === null) { 29 | this.serverless.cli.consoleLog("no function url defined"); 30 | return baseResources; 31 | } 32 | 33 | const config = this.serverless.service.custom.urlDomain; 34 | config['lambdaFunctionUrl'] = functionURLResourceName 35 | 36 | const resources = this.prepareResources(config); 37 | 38 | const combinedResouces = _.merge(baseResources, resources); 39 | 40 | return combinedResouces; 41 | } 42 | 43 | printSummary() { 44 | 45 | const awsInfo = _.find(this.serverless.pluginManager.getPlugins(), (plugin) => plugin.constructor.name === 'AwsInfo'); 46 | 47 | if (!awsInfo || !awsInfo.gatheredData) { 48 | return; 49 | } 50 | 51 | const { outputs } = awsInfo.gatheredData; 52 | const apiDistributionDomain = _.find(outputs, (output) => output.OutputKey === 'CloudFrontDistributionDomain'); 53 | 54 | if (!apiDistributionDomain || !apiDistributionDomain.OutputValue) { 55 | return; 56 | } 57 | 58 | const cnameDomain = this.getConfig('domains', []); 59 | 60 | this.serverless.cli.consoleLog('CloudFront domain name'); 61 | this.serverless.cli.consoleLog(`${apiDistributionDomain.OutputValue} (CNAME: ${cnameDomain})`); 62 | } 63 | 64 | prepareResources(config) { 65 | 66 | const route53 = this.getConfig('route53', true); 67 | var resources = this.getCloudfrontResources(config); 68 | if (route53) { 69 | resources = _.merge(resources, this.getRoute53Resources(config)); 70 | } 71 | return resources 72 | } 73 | 74 | getCloudfrontResources(config) { 75 | const filename = path.resolve(__dirname, 'resources.yml'); 76 | const content = fs.readFileSync(filename, 'utf-8'); 77 | const resources = yaml.load(content, { 78 | filename, 79 | }); 80 | var output = Mustache.render(JSON.stringify(resources), config); 81 | output = JSON.parse(output) 82 | output['Resources']['CloudFrontDistribution']['Properties']['DistributionConfig']['Aliases'] = config['domains'] 83 | return output; 84 | } 85 | 86 | getRoute53Resources(config) { 87 | const domains = this.getConfig('domains', null); 88 | const hostedZoneName = this.getConfig('hostedZoneName', null); 89 | 90 | const template = JSON.stringify({ 91 | "Type": "AWS::Route53::RecordSetGroup", 92 | "DeletionPolicy": "Delete", 93 | "DependsOn": [ 94 | "CloudFrontDistribution" 95 | ], 96 | "Properties": { 97 | "HostedZoneName": hostedZoneName, 98 | "RecordSets": [ 99 | { 100 | "Name": "{{{ domain }}}", 101 | "Type": "A", 102 | "AliasTarget": { 103 | "HostedZoneId": "Z2FDTNDATAQYW2", 104 | "DNSName": { 105 | "Fn::GetAtt": [ 106 | "CloudFrontDistribution", 107 | "DomainName" 108 | ] 109 | } 110 | } 111 | } 112 | ] 113 | } 114 | }) 115 | var resources = {} 116 | for (var idx in domains) { 117 | var output = Mustache.render(template, {'domain': domains[idx]}); 118 | resources[`Route53Record${idx}`] = JSON.parse(output); 119 | } 120 | return {'Resources': resources} 121 | } 122 | 123 | getConfig(field, defaultValue) { 124 | return _.get(this.serverless, `service.custom.urlDomain.${field}`, defaultValue); 125 | } 126 | } 127 | 128 | module.exports = ServerlessAWSFunctionURLCustomDomainPlugin; 129 | --------------------------------------------------------------------------------