├── .eslintrc ├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .yarnrc.yml ├── LICENSE ├── README.md ├── commitlint.config.ts ├── events └── event.json ├── jest.config.js ├── package.json ├── renovate.json ├── src └── index.ts ├── tests └── index.test.ts ├── tsconfig.json └── yarn.lock /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint" 6 | ], 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended" 11 | ] 12 | } -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: PR 2 | 3 | on: 4 | pull_request: 5 | push: 6 | tags: 7 | - "v*.*.*" 8 | 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 18 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 19 | with: 20 | node-version-file: package.json 21 | - name: Install Deps 22 | run: npm install 23 | - name: Test 24 | run: | 25 | npm run test 26 | - name: Build 27 | run: | 28 | npm run build 29 | - run: | 30 | zip -qq -9 -vr s3-cloudflare-purge.zip ./dist/* 31 | - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 32 | with: 33 | name: s3-cloudflare-purge 34 | path: s3-cloudflare-purge.zip 35 | - name: Release 36 | if: startsWith(github.ref, 'refs/tags/') 37 | env: 38 | GH_TOKEN: ${{ github.token }} 39 | run: | 40 | gh release upload ${{ github.ref_name }} s3-cloudflare-purge.zip 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/node,osx,linux,windows 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=node,osx,linux,windows 3 | 4 | ### Linux ### 5 | *~ 6 | 7 | # temporary files which can be created if a process still has a handle open of a deleted file 8 | .fuse_hidden* 9 | 10 | # KDE directory preferences 11 | .directory 12 | 13 | # Linux trash folder which might appear on any partition or disk 14 | .Trash-* 15 | 16 | # .nfs files are created when an open file is removed but is still being accessed 17 | .nfs* 18 | 19 | ### Node ### 20 | # Logs 21 | logs 22 | *.log 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | lerna-debug.log* 27 | .pnpm-debug.log* 28 | 29 | # Diagnostic reports (https://nodejs.org/api/report.html) 30 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 31 | 32 | # Runtime data 33 | pids 34 | *.pid 35 | *.seed 36 | *.pid.lock 37 | 38 | # Directory for instrumented libs generated by jscoverage/JSCover 39 | lib-cov 40 | 41 | # Coverage directory used by tools like istanbul 42 | coverage 43 | *.lcov 44 | 45 | # nyc test coverage 46 | .nyc_output 47 | 48 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 49 | .grunt 50 | 51 | # Bower dependency directory (https://bower.io/) 52 | bower_components 53 | 54 | # node-waf configuration 55 | .lock-wscript 56 | 57 | # Compiled binary addons (https://nodejs.org/api/addons.html) 58 | build/Release 59 | 60 | # Dependency directories 61 | node_modules/ 62 | jspm_packages/ 63 | 64 | # Snowpack dependency directory (https://snowpack.dev/) 65 | web_modules/ 66 | 67 | # TypeScript cache 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | .npm 72 | 73 | # Optional eslint cache 74 | .eslintcache 75 | 76 | # Optional stylelint cache 77 | .stylelintcache 78 | 79 | # Microbundle cache 80 | .rpt2_cache/ 81 | .rts2_cache_cjs/ 82 | .rts2_cache_es/ 83 | .rts2_cache_umd/ 84 | 85 | # Optional REPL history 86 | .node_repl_history 87 | 88 | # Output of 'npm pack' 89 | *.tgz 90 | 91 | # Yarn Integrity file 92 | .yarn-integrity 93 | 94 | # dotenv environment variable files 95 | .env 96 | .env.development.local 97 | .env.test.local 98 | .env.production.local 99 | .env.local 100 | 101 | # parcel-bundler cache (https://parceljs.org/) 102 | .cache 103 | .parcel-cache 104 | 105 | # Next.js build output 106 | .next 107 | out 108 | 109 | # Nuxt.js build / generate output 110 | .nuxt 111 | dist 112 | 113 | # Gatsby files 114 | .cache/ 115 | # Comment in the public line in if your project uses Gatsby and not Next.js 116 | # https://nextjs.org/blog/next-9-1#public-directory-support 117 | # public 118 | 119 | # vuepress build output 120 | .vuepress/dist 121 | 122 | # vuepress v2.x temp and cache directory 123 | .temp 124 | 125 | # Docusaurus cache and generated files 126 | .docusaurus 127 | 128 | # Serverless directories 129 | .serverless/ 130 | 131 | # FuseBox cache 132 | .fusebox/ 133 | 134 | # DynamoDB Local files 135 | .dynamodb/ 136 | 137 | # TernJS port file 138 | .tern-port 139 | 140 | # Stores VSCode versions used for testing VSCode extensions 141 | .vscode-test 142 | 143 | # yarn v2 144 | .yarn/cache 145 | .yarn/unplugged 146 | .yarn/build-state.yml 147 | .yarn/install-state.gz 148 | .pnp.* 149 | 150 | ### Node Patch ### 151 | # Serverless Webpack directories 152 | .webpack/ 153 | 154 | # Optional stylelint cache 155 | 156 | # SvelteKit build / generate output 157 | .svelte-kit 158 | 159 | ### OSX ### 160 | # General 161 | .DS_Store 162 | .AppleDouble 163 | .LSOverride 164 | 165 | # Icon must end with two \r 166 | Icon 167 | 168 | 169 | # Thumbnails 170 | ._* 171 | 172 | # Files that might appear in the root of a volume 173 | .DocumentRevisions-V100 174 | .fseventsd 175 | .Spotlight-V100 176 | .TemporaryItems 177 | .Trashes 178 | .VolumeIcon.icns 179 | .com.apple.timemachine.donotpresent 180 | 181 | # Directories potentially created on remote AFP share 182 | .AppleDB 183 | .AppleDesktop 184 | Network Trash Folder 185 | Temporary Items 186 | .apdisk 187 | 188 | ### Windows ### 189 | # Windows thumbnail cache files 190 | Thumbs.db 191 | Thumbs.db:encryptable 192 | ehthumbs.db 193 | ehthumbs_vista.db 194 | 195 | # Dump file 196 | *.stackdump 197 | 198 | # Folder config file 199 | [Dd]esktop.ini 200 | 201 | # Recycle Bin used on file shares 202 | $RECYCLE.BIN/ 203 | 204 | # Windows Installer files 205 | *.cab 206 | *.msi 207 | *.msix 208 | *.msm 209 | *.msp 210 | 211 | # Windows shortcuts 212 | *.lnk 213 | 214 | # End of https://www.toptal.com/developers/gitignore/api/node,osx,linux,windows -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | yarn commitlint --edit $1 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | yarn build 2 | yarn test 3 | yarn lint-staged 4 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Descope 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # S3 Cloudflare Purge 2 | 3 | Lambda function to purge Cloudflare cache when updating S3 files 4 | -------------------------------------------------------------------------------- /commitlint.config.ts: -------------------------------------------------------------------------------- 1 | import type { UserConfig } from "@commitlint/types"; 2 | 3 | const Configuration: UserConfig = { 4 | extends: ["@commitlint/config-conventional"], 5 | }; 6 | 7 | export default Configuration; 8 | -------------------------------------------------------------------------------- /events/event.json: -------------------------------------------------------------------------------- 1 | { 2 | "Records": [ 3 | { 4 | "eventVersion": "2.0", 5 | "eventSource": "aws:s3", 6 | "awsRegion": "us-east-1", 7 | "eventTime": "1970-01-01T00:00:00.000Z", 8 | "eventName": "ObjectCreated:Put", 9 | "userIdentity": { 10 | "principalId": "EXAMPLE" 11 | }, 12 | "requestParameters": { 13 | "sourceIPAddress": "127.0.0.1" 14 | }, 15 | "responseElements": { 16 | "x-amz-request-id": "EXAMPLE123456789", 17 | "x-amz-id-2": "EXAMPLE123/5678abcdefghijklambdaisawesome/mnopqrstuvwxyzABCDEFGH" 18 | }, 19 | "s3": { 20 | "s3SchemaVersion": "1.0", 21 | "configurationId": "testConfigRule", 22 | "bucket": { 23 | "name": "example-bucket", 24 | "ownerIdentity": { 25 | "principalId": "EXAMPLE" 26 | }, 27 | "arn": "arn:aws:s3:::example-bucket" 28 | }, 29 | "object": { 30 | "key": "pages/P2DA3QqyF3N3BlmIqn1nr0LMFhrw/v2-beta/sign-up-or-in/SC2lmm0w8m35W0BNiFOKtp9pyH9gG.html", 31 | "size": 1024, 32 | "eTag": "0123456789abcdef0123456789abcdef", 33 | "sequencer": "0A1B2C3D4E5F678901" 34 | } 35 | } 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | collectCoverage: true, 6 | collectCoverageFrom: ["src/**"], 7 | coverageThreshold: { 8 | global: { 9 | branches: 100, 10 | functions: 100, 11 | lines: 100, 12 | statements: 100, 13 | }, 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lambda-s3-cloudflare-purge", 3 | "version": "1.0.0", 4 | "description": "Lambda function to purge Cloudflare cache when updating S3 files", 5 | "scripts": { 6 | "build": "esbuild src/index.ts --bundle --minify --sourcemap --platform=node --target=es2020 --outdir=./dist", 7 | "test": "jest", 8 | "lint": "eslint --max-warnings 0 src/**/*.ts", 9 | "format": "prettier --write src/**/*.ts", 10 | "prepare": "husky" 11 | }, 12 | "license": "MIT", 13 | "devDependencies": { 14 | "@commitlint/cli": "^19.3.0", 15 | "@commitlint/config-conventional": "^19.2.2", 16 | "@jest/globals": "^29.7.0", 17 | "@types/aws-lambda": "^8.10.138", 18 | "@types/node": "^22.0.0", 19 | "@typescript-eslint/eslint-plugin": "^8.0.0", 20 | "@typescript-eslint/parser": "^8.0.0", 21 | "esbuild": "^0.25.0", 22 | "eslint": "9.27.0", 23 | "husky": "^9.0.11", 24 | "jest": "^29.7.0", 25 | "lint-staged": "^16.0.0", 26 | "nock": "^14.0.0-beta.6", 27 | "prettier": "^3.2.5", 28 | "ts-jest": "^29.1.3", 29 | "ts-node": "^10.9.2", 30 | "typescript": ">=4.3 <6" 31 | }, 32 | "dependencies": { 33 | "cloudflare": "^3.2.0" 34 | }, 35 | "lint-staged": { 36 | "*.ts": [ 37 | "prettier --write", 38 | "eslint --max-warnings 0" 39 | ] 40 | }, 41 | "engines": { 42 | "node": "^22.0.0" 43 | }, 44 | "packageManager": "yarn@4.9.1" 45 | } 46 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["local>descope/renovate-config"] 4 | } 5 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { S3Event } from "aws-lambda"; 2 | import * as path from "node:path"; 3 | 4 | import { Cloudflare } from "cloudflare"; 5 | 6 | export const handler = async (event: S3Event) => { 7 | for (const envVar of [ 8 | "CLOUDFLARE_API_TOKEN", 9 | "CLOUDFLARE_ZONE_ID", 10 | "BASE_URL", 11 | ]) { 12 | if (!process.env[envVar]) throw new Error(`${envVar} is not set`); 13 | } 14 | 15 | const cloudflare = new Cloudflare({ 16 | apiToken: process.env.CLOUDFLARE_API_TOKEN, 17 | }); 18 | 19 | const key = path.dirname(event.Records[0].s3.object.key); 20 | const prefix = path.join(process.env.BASE_URL!, key); 21 | console.info({ 22 | message: "Purging cache", 23 | key, 24 | baseUrl: process.env.BASE_URL, 25 | prefix, 26 | }); 27 | await cloudflare.cache 28 | .purge({ 29 | zone_id: process.env.CLOUDFLARE_ZONE_ID!, 30 | prefixes: [prefix], 31 | }) 32 | .then((res) => 33 | console.debug({ success: true, message: res }, { depth: null }), 34 | ) 35 | .catch((err) => { 36 | console.debug({ success: false, message: err }, { depth: null }); 37 | throw new Error(err); 38 | }); 39 | }; 40 | -------------------------------------------------------------------------------- /tests/index.test.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, beforeAll, describe, expect, test } from "@jest/globals"; 2 | import * as path from "node:path"; 3 | 4 | import nock from "nock"; 5 | import { handler } from "../src/index"; 6 | 7 | import { afterEach, beforeEach } from "node:test"; 8 | import sampleEvent from "../events/event.json"; 9 | 10 | beforeAll(() => { 11 | nock.disableNetConnect(); 12 | }); 13 | 14 | beforeEach(() => { 15 | delete process.env.CLOUDFLARE_API_TOKEN; 16 | delete process.env.CLOUDFLARE_ZONE_ID; 17 | delete process.env.BASE_URL; 18 | }); 19 | 20 | describe("test handler", () => { 21 | test("ensures require env vars are set", async () => { 22 | await expect(handler(sampleEvent)).rejects.toThrowError( 23 | "CLOUDFLARE_API_TOKEN is not set", 24 | ); 25 | 26 | process.env.CLOUDFLARE_API_TOKEN = "test"; 27 | await expect(handler(sampleEvent)).rejects.toThrowError( 28 | "CLOUDFLARE_ZONE_ID is not set", 29 | ); 30 | 31 | process.env.CLOUDFLARE_ZONE_ID = "test"; 32 | await expect(handler(sampleEvent)).rejects.toThrowError( 33 | "BASE_URL is not set", 34 | ); 35 | 36 | process.env.BASE_URL = "test"; 37 | nock("https://api.cloudflare.com") 38 | .post(`/client/v4/zones/${process.env.CLOUDFLARE_ZONE_ID}/purge_cache`) 39 | .reply(200, { success: true }); 40 | await expect(handler(sampleEvent)).resolves.toBeUndefined(); 41 | }); 42 | 43 | test("ensures request is made to cloudflare", async () => { 44 | process.env.CLOUDFLARE_API_TOKEN = "testToken"; 45 | process.env.CLOUDFLARE_ZONE_ID = "testZone"; 46 | process.env.BASE_URL = "example.com"; 47 | 48 | nock("https://api.cloudflare.com", { 49 | reqheaders: { 50 | Authorization: "Bearer " + process.env.CLOUDFLARE_API_TOKEN, 51 | }, 52 | }) 53 | .post(`/client/v4/zones/${process.env.CLOUDFLARE_ZONE_ID}/purge_cache`, { 54 | prefixes: [ 55 | `${path.join(process.env.BASE_URL!, path.dirname(sampleEvent.Records[0].s3.object.key))}`, 56 | ], 57 | }) 58 | .reply(200, { success: true }); 59 | 60 | await expect(handler(sampleEvent)).resolves.toBeUndefined(); 61 | }); 62 | 63 | test("error reporting", async () => { 64 | process.env.CLOUDFLARE_API_TOKEN = "testToken"; 65 | process.env.CLOUDFLARE_ZONE_ID = "testZone"; 66 | process.env.BASE_URL = "example.com"; 67 | 68 | nock("https://api.cloudflare.com", { 69 | reqheaders: { 70 | Authorization: "Bearer " + process.env.CLOUDFLARE_API_TOKEN, 71 | }, 72 | }) 73 | .post(`/client/v4/zones/${process.env.CLOUDFLARE_ZONE_ID}/purge_cache`, { 74 | prefixes: [ 75 | `${path.join(process.env.BASE_URL!, path.dirname(sampleEvent.Records[0].s3.object.key))}`, 76 | ], 77 | }) 78 | .reply(400, { success: false }); 79 | 80 | await expect(handler(sampleEvent)).rejects.toThrowError( 81 | 'Error: 400 {"success":false}', 82 | ); 83 | }); 84 | }); 85 | 86 | afterEach(() => { 87 | nock.cleanAll(); 88 | expect(nock.pendingMocks()).toStrictEqual([]); 89 | }); 90 | 91 | afterAll(() => { 92 | nock.enableNetConnect(); 93 | }); 94 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "strict": true, 5 | "preserveConstEnums": true, 6 | "noEmit": true, 7 | "inlineSourceMap": true, 8 | "inlineSources": true, 9 | "module": "CommonJS", 10 | "moduleResolution": "Node", 11 | "esModuleInterop": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "isolatedModules": true, 14 | "resolveJsonModule": true 15 | }, 16 | "exclude": [ 17 | "node_modules" 18 | ] 19 | } --------------------------------------------------------------------------------