├── .eslintrc.js ├── .github └── dependabot.yml ├── .gitignore ├── .nvmrc ├── LICENSE.md ├── README.md ├── docs └── img │ └── remix-logo.png ├── package-lock.json ├── package.json ├── src ├── adapters │ ├── api-gateway-v1.ts │ ├── api-gateway-v2.ts │ ├── application-load-balancer.ts │ └── index.ts ├── binaryTypes.ts ├── index.ts ├── server.ts └── vite-preset.ts ├── templates └── server.js ├── tsconfig.json └── tsup.config.ts /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'eslint:recommended', 4 | 'plugin:@typescript-eslint/eslint-recommended', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'prettier' 7 | ], 8 | plugins: ['simple-import-sort'], 9 | env: { 10 | 'node': true 11 | }, 12 | rules: { 13 | 'object-curly-spacing': ['error', 'always'], 14 | semi: ['error', 'never'], 15 | 'simple-import-sort/imports': [ 16 | 'error', 17 | { 18 | groups: [ 19 | ['^[^@.].*\\u0000$', '^.*\u0000$'], 20 | ['^\\u0000'], 21 | ['^@?\\w'], 22 | ['^'], 23 | ['^\\.\\.(?!/?$)', '^\\.\\./?$'], 24 | ['^\\./(?=.*/)(?!/?$)', '^\\.(?!/?$)', '^\\./?$'], 25 | ], 26 | }, 27 | ], 28 | quotes: [2, 'single'] 29 | }, 30 | } -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: "npm" 7 | directory: "/" 8 | schedule: 9 | interval: "weekly" 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Intellij+all ### 2 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 3 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 4 | 5 | # User-specific stuff 6 | .idea/**/workspace.xml 7 | .idea/**/tasks.xml 8 | .idea/**/usage.statistics.xml 9 | .idea/**/dictionaries 10 | .idea/**/shelf 11 | 12 | # AWS User-specific 13 | .idea/**/aws.xml 14 | 15 | # Generated files 16 | .idea/**/contentModel.xml 17 | 18 | # Sensitive or high-churn files 19 | .idea/**/dataSources/ 20 | .idea/**/dataSources.ids 21 | .idea/**/dataSources.local.xml 22 | .idea/**/sqlDataSources.xml 23 | .idea/**/dynamic.xml 24 | .idea/**/uiDesigner.xml 25 | .idea/**/dbnavigator.xml 26 | 27 | # Gradle 28 | .idea/**/gradle.xml 29 | .idea/**/libraries 30 | 31 | # Gradle and Maven with auto-import 32 | # When using Gradle or Maven with auto-import, you should exclude module files, 33 | # since they will be recreated, and may cause churn. Uncomment if using 34 | # auto-import. 35 | # .idea/artifacts 36 | # .idea/compiler.xml 37 | # .idea/jarRepositories.xml 38 | # .idea/modules.xml 39 | # .idea/*.iml 40 | # .idea/modules 41 | # *.iml 42 | # *.ipr 43 | 44 | # CMake 45 | cmake-build-*/ 46 | 47 | # Mongo Explorer plugin 48 | .idea/**/mongoSettings.xml 49 | 50 | # File-based project format 51 | *.iws 52 | 53 | # IntelliJ 54 | out/ 55 | 56 | # mpeltonen/sbt-idea plugin 57 | .idea_modules/ 58 | 59 | # JIRA plugin 60 | atlassian-ide-plugin.xml 61 | 62 | # Cursive Clojure plugin 63 | .idea/replstate.xml 64 | 65 | # SonarLint plugin 66 | .idea/sonarlint/ 67 | 68 | # Crashlytics plugin (for Android Studio and IntelliJ) 69 | com_crashlytics_export_strings.xml 70 | crashlytics.properties 71 | crashlytics-build.properties 72 | fabric.properties 73 | 74 | # Editor-based Rest Client 75 | .idea/httpRequests 76 | 77 | # Android studio 3.1+ serialized cache file 78 | .idea/caches/build_file_checksums.ser 79 | 80 | ### Intellij+all Patch ### 81 | # Ignore everything but code style settings and run configurations 82 | # that are supposed to be shared within teams. 83 | 84 | .idea/* 85 | 86 | !.idea/codeStyles 87 | !.idea/runConfigurations 88 | 89 | ### Linux ### 90 | *~ 91 | 92 | # temporary files which can be created if a process still has a handle open of a deleted file 93 | .fuse_hidden* 94 | 95 | # KDE directory preferences 96 | .directory 97 | 98 | # Linux trash folder which might appear on any partition or disk 99 | .Trash-* 100 | 101 | # .nfs files are created when an open file is removed but is still being accessed 102 | .nfs* 103 | 104 | ### macOS ### 105 | # General 106 | .DS_Store 107 | .AppleDouble 108 | .LSOverride 109 | 110 | # Icon must end with two \r 111 | Icon 112 | 113 | 114 | # Thumbnails 115 | ._* 116 | 117 | # Files that might appear in the root of a volume 118 | .DocumentRevisions-V100 119 | .fseventsd 120 | .Spotlight-V100 121 | .TemporaryItems 122 | .Trashes 123 | .VolumeIcon.icns 124 | .com.apple.timemachine.donotpresent 125 | 126 | # Directories potentially created on remote AFP share 127 | .AppleDB 128 | .AppleDesktop 129 | Network Trash Folder 130 | Temporary Items 131 | .apdisk 132 | 133 | ### macOS Patch ### 134 | # iCloud generated files 135 | *.icloud 136 | 137 | ### Node ### 138 | # Logs 139 | logs 140 | *.log 141 | npm-debug.log* 142 | yarn-debug.log* 143 | yarn-error.log* 144 | lerna-debug.log* 145 | .pnpm-debug.log* 146 | 147 | # Diagnostic reports (https://nodejs.org/api/report.html) 148 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 149 | 150 | # Runtime data 151 | pids 152 | *.pid 153 | *.seed 154 | *.pid.lock 155 | 156 | # Directory for instrumented libs generated by jscoverage/JSCover 157 | lib-cov 158 | 159 | # Coverage directory used by tools like istanbul 160 | coverage 161 | *.lcov 162 | 163 | # nyc test coverage 164 | .nyc_output 165 | 166 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 167 | .grunt 168 | 169 | # Bower dependency directory (https://bower.io/) 170 | bower_components 171 | 172 | # node-waf configuration 173 | .lock-wscript 174 | 175 | # Compiled binary addons (https://nodejs.org/api/addons.html) 176 | build/Release 177 | 178 | # Dependency directories 179 | node_modules/ 180 | jspm_packages/ 181 | 182 | # Snowpack dependency directory (https://snowpack.dev/) 183 | web_modules/ 184 | 185 | # TypeScript cache 186 | *.tsbuildinfo 187 | 188 | # Optional npm cache directory 189 | .npm 190 | 191 | # Optional eslint cache 192 | .eslintcache 193 | 194 | # Optional stylelint cache 195 | .stylelintcache 196 | 197 | # Microbundle cache 198 | .rpt2_cache/ 199 | .rts2_cache_cjs/ 200 | .rts2_cache_es/ 201 | .rts2_cache_umd/ 202 | 203 | # Optional REPL history 204 | .node_repl_history 205 | 206 | # Output of 'npm pack' 207 | *.tgz 208 | 209 | # Yarn Integrity file 210 | .yarn-integrity 211 | 212 | # dotenv environment variable files 213 | .env 214 | .env.development.local 215 | .env.test.local 216 | .env.production.local 217 | .env.local 218 | 219 | # parcel-bundler cache (https://parceljs.org/) 220 | .cache 221 | .parcel-cache 222 | 223 | # Next.js build output 224 | .next 225 | out 226 | 227 | # Nuxt.js build / generate output 228 | .nuxt 229 | dist 230 | 231 | # Gatsby files 232 | .cache/ 233 | # Comment in the public line in if your project uses Gatsby and not Next.js 234 | # https://nextjs.org/blog/next-9-1#public-directory-support 235 | # public 236 | 237 | # vuepress build output 238 | .vuepress/dist 239 | 240 | # vuepress v2.x temp and cache directory 241 | .temp 242 | 243 | # Docusaurus cache and generated files 244 | .docusaurus 245 | 246 | # Serverless directories 247 | .serverless/ 248 | 249 | # FuseBox cache 250 | .fusebox/ 251 | 252 | # DynamoDB Local files 253 | .dynamodb/ 254 | 255 | # TernJS port file 256 | .tern-port 257 | 258 | # Stores VSCode versions used for testing VSCode extensions 259 | .vscode-test 260 | 261 | # yarn v2 262 | .yarn/cache 263 | .yarn/unplugged 264 | .yarn/build-state.yml 265 | .yarn/install-state.gz 266 | .pnp.* 267 | 268 | ### Node Patch ### 269 | # Serverless Webpack directories 270 | .webpack/ 271 | 272 | # Optional stylelint cache 273 | 274 | # SvelteKit build / generate output 275 | .svelte-kit 276 | 277 | ### VisualStudioCode ### 278 | .vscode/* 279 | !.vscode/settings.json 280 | !.vscode/tasks.json 281 | !.vscode/launch.json 282 | !.vscode/extensions.json 283 | !.vscode/*.code-snippets 284 | 285 | # Local History for Visual Studio Code 286 | .history/ 287 | 288 | # Built Visual Studio Code Extensions 289 | *.vsix 290 | 291 | ### VisualStudioCode Patch ### 292 | # Ignore all local history of files 293 | .history 294 | .ionide 295 | 296 | # Support for Project snippet scope 297 | .vscode/*.code-snippets 298 | 299 | # Ignore code-workspaces 300 | *.code-workspace 301 | 302 | ### Windows ### 303 | # Windows thumbnail cache files 304 | Thumbs.db 305 | Thumbs.db:encryptable 306 | ehthumbs.db 307 | ehthumbs_vista.db 308 | 309 | # Dump file 310 | *.stackdump 311 | 312 | # Folder config file 313 | [Dd]esktop.ini 314 | 315 | # Recycle Bin used on file shares 316 | $RECYCLE.BIN/ 317 | 318 | # Windows Installer files 319 | *.cab 320 | *.msi 321 | *.msix 322 | *.msm 323 | *.msp 324 | 325 | # Windows shortcuts 326 | *.lnk 327 | 328 | # Project 329 | tsup.config.js 330 | src/**/*.js 331 | src/**/*.d.ts 332 | src/**/*.d.ts.map -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18 -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Wing Leung 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

Remix AWS

3 |

4 | 5 | npm version 6 | 7 | 8 | npm install size 9 | 10 | 11 | Known Vulnerabilities 12 | 13 |

14 | Remix logo 15 |

AWS adapters for Remix

16 |
17 | 18 | ## 🚀 support 19 | 20 | - API gateway v1 21 | - API gateway v2 22 | - Application load balancer 23 | 24 | ## Getting started 25 | 26 | ```shell 27 | npm install --save remix-aws 28 | ``` 29 | 30 | ```javascript 31 | // server.js 32 | import * as build from '@remix-run/dev/server-build' 33 | import {AWSProxy, createRequestHandler} from 'remix-aws' 34 | 35 | // Required in Remix v2 36 | import { installGlobals } from '@remix-run/node' 37 | installGlobals() 38 | 39 | export const handler = createRequestHandler({ 40 | build, 41 | mode: process.env.NODE_ENV, 42 | awsProxy: AWSProxy.APIGatewayV2 43 | }) 44 | ``` 45 | 46 | ### `awsProxy` 47 | 48 | By default the `awsProxy` is set to `AWSProxy.APIGatewayV2`. 49 | 50 | #### Options 51 | 52 | - `AWSProxy.APIGatewayV1` 53 | - `AWSProxy.APIGatewayV2` 54 | - `AWSProxy.ALB` 55 | - `AWSProxy.FunctionURL` 56 | 57 | ## Vite preset 58 | 59 | If you use Vite, then the `awsPreset` preset is an easy way to configure aws support. 60 | It will do a post remix build and create a handler function for use in aws lambda. 61 | 62 | There is no need for a separate `server.js` file. The preset will take care of that. 63 | However, if you want to manage your own `server.js` file, you can pas a custom `entryPoint` to your own `server.js`. 64 | 65 | ⚠️ By default Remix will set `serverModuleFormat` to `esm`. 66 | The Vite preset will automatically align the `serverModuleFormat` with the esbuild configuration used by the preset. 67 | However, to ensure that AWS lambda correctly interprets the output file as an ES module, you need to take additional steps. 68 | 69 | There are two primary methods to achieve this: 70 | 71 | - Specify the module type in package.json: 72 | Add `"type": "module"` to your package.json file and ensure that this file is included in the deployment package sent to AWS Lambda. 73 | 74 | - Use the .mjs extension: 75 | Alternatively, you can change the file extension to `.mjs`. For example, you can configure the Remix `serverBuildFile` setting to output `index.mjs`. 76 | 77 | more info: [AWS docs on ES module support in AWS lambdas](https://docs.aws.amazon.com/lambda/latest/dg/lambda-nodejs.html#designate-es-module) 78 | 79 | ```typescript 80 | import type { PluginOption } from 'vite' 81 | import type { Preset } from '@remix-run/dev' 82 | 83 | import { vitePlugin as remix } from '@remix-run/dev' 84 | import { awsPreset, AWSProxy } from 'remix-aws' 85 | import { defineConfig } from 'vite' 86 | 87 | export default defineConfig( 88 | { 89 | ... 90 | plugins: [ 91 | remix({ 92 | // serverBuildFile: 'index.mjs', // set the extension to .mjs or ship you package.json along with the build package 93 | presets: [ 94 | awsPreset({ 95 | awsProxy: AWSProxy.APIGatewayV2, 96 | 97 | // additional esbuild configuration 98 | build: { 99 | minify: true, 100 | treeShaking: true, 101 | ... 102 | } 103 | }) as Preset 104 | ] 105 | }) as PluginOption, 106 | ] 107 | } 108 | ) 109 | ``` 110 | 111 | **Example [server.js](./templates/server.js)** 112 | 113 | ```typescript 114 | import { AWSProxy, createRequestHandler } from 'remix-aws' 115 | 116 | let build = require('./build/server/index.js') 117 | 118 | export const handler = createRequestHandler({ 119 | build, 120 | mode: process.env.NODE_ENV, 121 | awsProxy: AWSProxy.APIGatewayV1 122 | }) 123 | ``` 124 | 125 | 126 | ### configuration 127 | 128 | #### `awsProxy` is optional and defaults to `AWSProxy.APIGatewayV2` 129 | 130 | #### `build` is for additional esbuild configuration for the post remix build 131 | 132 | ```json 133 | // default esbuild configuration 134 | { 135 | logLevel: 'info', 136 | entryPoints: [ 137 | 'build/server.js' 138 | ], 139 | bundle: true, 140 | sourcemap: false, 141 | platform: 'node', 142 | outfile: 'build/server/index.js', // will replace remix server build file 143 | allowOverwrite: true, 144 | write: true, 145 | } 146 | ``` 147 | check [esbuild options](https://esbuild.github.io/api/#build-options) for more information 148 | 149 | ## Notes 150 | 151 | ### split from @remix/architect 152 | 153 | As mentioned in [#3173](https://github.com/remix-run/remix/pull/3173) the goal would be to provide an AWS adapter for 154 | the community by the community. 155 | In doing so the focus will be on AWS integrations and less on Architect. I do think it's added value to provide examples 156 | for Architect, AWS SAM, AWS CDK, Serverless,... 157 | 158 | **info:** [ALB types](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/aws-lambda/trigger/alb.d.ts#L29-L48) 159 | vs [API gateway v1 types](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/aws-lambda/trigger/api-gateway-proxy.d.ts#L116-L145) 160 | -------------------------------------------------------------------------------- /docs/img/remix-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wingleung/remix-aws/3c2e4a4e7aa8118235b3dd2e9f774f36f09a7960/docs/img/remix-logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-aws", 3 | "version": "1.2.2", 4 | "description": "AWS adapter for Remix", 5 | "bugs": { 6 | "url": "https://github.com/wingleung/remix-aws/issues" 7 | }, 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/wingleung/remix-aws.git" 11 | }, 12 | "scripts": { 13 | "build": "tsup --publicDir templates" 14 | }, 15 | "license": "MIT", 16 | "main": "dist/index.js", 17 | "module": "dist/index.mjs", 18 | "typings": "dist/index.d.ts", 19 | "dependencies": { 20 | "@types/aws-lambda": "^8.10.125" 21 | }, 22 | "devDependencies": { 23 | "@types/node": "^18.18.7", 24 | "@typescript-eslint/eslint-plugin": "^7.7.0", 25 | "@typescript-eslint/parser": "^7.7.0", 26 | "eslint": "^8.19.0", 27 | "eslint-config-prettier": "^8.5.0", 28 | "eslint-plugin-simple-import-sort": "^7.0.0", 29 | "prettier": "^2.7.1", 30 | "tsup": "^8.0.2", 31 | "typescript": "^5.4.5" 32 | }, 33 | "peerDependencies": { 34 | "@remix-run/dev": "^1.6.2 || ^2.0.1", 35 | "@remix-run/node": "^1.6.2 || ^2.0.1", 36 | "esbuild": "^0.17.6" 37 | }, 38 | "engines": { 39 | "node": ">=18" 40 | }, 41 | "files": [ 42 | "dist/", 43 | "LICENSE.md", 44 | "README.md" 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /src/adapters/api-gateway-v1.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | APIGatewayProxyEvent, 3 | APIGatewayProxyEventHeaders, 4 | APIGatewayProxyResult 5 | } from 'aws-lambda' 6 | 7 | import { readableStreamToString } from '@remix-run/node' 8 | import { URLSearchParams } from 'url' 9 | 10 | import { isBinaryType } from '../binaryTypes' 11 | 12 | import { RemixAdapter } from './index' 13 | 14 | function createRemixRequest(event: APIGatewayProxyEvent): Request { 15 | const host = event.headers['x-forwarded-host'] || event.headers.Host 16 | const scheme = event.headers['x-forwarded-proto'] || 'http' 17 | 18 | const rawQueryString = new URLSearchParams(event.queryStringParameters as Record).toString() 19 | const search = rawQueryString.length > 0 ? `?${rawQueryString}` : '' 20 | const url = new URL(event.path + search, `${scheme}://${host}`) 21 | 22 | const isFormData = event.headers['content-type']?.includes( 23 | 'multipart/form-data' 24 | ) 25 | 26 | return new Request(url.href, { 27 | method: event.requestContext.httpMethod, 28 | headers: createRemixHeaders(event.headers), 29 | body: 30 | event.body && event.isBase64Encoded 31 | ? isFormData 32 | ? Buffer.from(event.body, 'base64') 33 | : Buffer.from(event.body, 'base64').toString() 34 | : event.body || undefined, 35 | }) 36 | } 37 | 38 | function createRemixHeaders( 39 | requestHeaders: APIGatewayProxyEventHeaders 40 | ): Headers { 41 | const headers = new Headers() 42 | 43 | for (const [header, value] of Object.entries(requestHeaders)) { 44 | if (value) { 45 | headers.append(header, value) 46 | } 47 | } 48 | 49 | return headers 50 | } 51 | 52 | async function sendRemixResponse( 53 | nodeResponse: Response 54 | ): Promise { 55 | const contentType = nodeResponse.headers.get('Content-Type') 56 | const isBase64Encoded = isBinaryType(contentType) 57 | let body: string | undefined 58 | 59 | if (nodeResponse.body) { 60 | if (isBase64Encoded) { 61 | body = await readableStreamToString(nodeResponse.body, 'base64') 62 | } else { 63 | body = await nodeResponse.text() 64 | } 65 | } 66 | 67 | return { 68 | statusCode: nodeResponse.status, 69 | headers: Object.fromEntries(nodeResponse.headers.entries()), 70 | body: body || '', 71 | isBase64Encoded, 72 | } 73 | } 74 | 75 | type ApiGatewayV1Adapter = RemixAdapter 76 | 77 | const apiGatewayV1Adapter: ApiGatewayV1Adapter = { 78 | createRemixRequest, 79 | sendRemixResponse 80 | } 81 | 82 | export { 83 | createRemixRequest, 84 | createRemixHeaders, 85 | sendRemixResponse, 86 | apiGatewayV1Adapter 87 | } 88 | 89 | export type { 90 | ApiGatewayV1Adapter 91 | } 92 | -------------------------------------------------------------------------------- /src/adapters/api-gateway-v2.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | APIGatewayProxyEventHeaders, 3 | APIGatewayProxyEventV2, 4 | APIGatewayProxyStructuredResultV2 5 | } from 'aws-lambda' 6 | 7 | import { readableStreamToString } from '@remix-run/node' 8 | 9 | import { isBinaryType } from '../binaryTypes' 10 | 11 | import { RemixAdapter } from './index' 12 | 13 | function createRemixRequest(event: APIGatewayProxyEventV2): Request { 14 | const host = event.headers['x-forwarded-host'] || event.headers.host 15 | const search = event.rawQueryString.length ? `?${event.rawQueryString}` : '' 16 | const scheme = event.headers['x-forwarded-proto'] || 'http' 17 | 18 | const url = new URL(event.rawPath + search, `${scheme}://${host}`) 19 | const isFormData = event.headers['content-type']?.includes( 20 | 'multipart/form-data' 21 | ) 22 | 23 | return new Request(url.href, { 24 | method: event.requestContext.http.method, 25 | headers: createRemixHeaders(event.headers, event.cookies), 26 | body: 27 | event.body && event.isBase64Encoded 28 | ? isFormData 29 | ? Buffer.from(event.body, 'base64') 30 | : Buffer.from(event.body, 'base64').toString() 31 | : event.body, 32 | }) 33 | } 34 | 35 | function createRemixHeaders( 36 | requestHeaders: APIGatewayProxyEventHeaders, 37 | requestCookies?: string[] 38 | ): Headers { 39 | const headers = new Headers() 40 | 41 | for (const [header, value] of Object.entries(requestHeaders)) { 42 | if (value) { 43 | headers.append(header, value) 44 | } 45 | } 46 | 47 | if (requestCookies) { 48 | headers.append('Cookie', requestCookies.join('; ')) 49 | } 50 | 51 | return headers 52 | } 53 | 54 | async function sendRemixResponse( 55 | nodeResponse: Response 56 | ): Promise { 57 | const cookies: string[] = [] 58 | 59 | // AWS API Gateway will send back set-cookies outside of response headers. 60 | for (const [key, values] of Object.entries(nodeResponse.headers.raw())) { 61 | if (key.toLowerCase() === 'set-cookie') { 62 | for (const value of values) { 63 | cookies.push(value) 64 | } 65 | } 66 | } 67 | 68 | if (cookies.length) { 69 | nodeResponse.headers.delete('Set-Cookie') 70 | } 71 | 72 | const contentType = nodeResponse.headers.get('Content-Type') 73 | const isBase64Encoded = isBinaryType(contentType) 74 | let body: string | undefined 75 | 76 | if (nodeResponse.body) { 77 | if (isBase64Encoded) { 78 | body = await readableStreamToString(nodeResponse.body, 'base64') 79 | } else { 80 | body = await nodeResponse.text() 81 | } 82 | } 83 | 84 | return { 85 | statusCode: nodeResponse.status, 86 | headers: Object.fromEntries(nodeResponse.headers.entries()), 87 | cookies, 88 | body, 89 | isBase64Encoded, 90 | } 91 | } 92 | 93 | type ApiGatewayV2Adapter = RemixAdapter 94 | 95 | const apiGatewayV2Adapter: ApiGatewayV2Adapter = { 96 | createRemixRequest, 97 | sendRemixResponse 98 | } 99 | 100 | export { 101 | createRemixRequest, 102 | createRemixHeaders, 103 | sendRemixResponse, 104 | apiGatewayV2Adapter 105 | } 106 | 107 | export type { 108 | ApiGatewayV2Adapter 109 | } 110 | -------------------------------------------------------------------------------- /src/adapters/application-load-balancer.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ALBEvent, 3 | ALBEventHeaders, 4 | ALBResult 5 | } from 'aws-lambda' 6 | 7 | import { readableStreamToString } from '@remix-run/node' 8 | import { URLSearchParams } from 'url' 9 | 10 | import { isBinaryType } from '../binaryTypes' 11 | 12 | import { RemixAdapter } from './index' 13 | 14 | function createRemixRequest(event: ALBEvent): Request { 15 | const headers = event?.headers || {} 16 | const host = headers['x-forwarded-host'] || headers.Host 17 | const scheme = headers['x-forwarded-proto'] || 'http' 18 | 19 | const rawQueryString = new URLSearchParams(event.queryStringParameters as Record).toString() 20 | const search = rawQueryString.length > 0 ? `?${rawQueryString}` : '' 21 | const url = new URL(event.path + search, `${scheme}://${host}`) 22 | 23 | const isFormData = headers['content-type']?.includes( 24 | 'multipart/form-data' 25 | ) 26 | 27 | return new Request(url.href, { 28 | method: event.httpMethod, 29 | headers: createRemixHeaders(headers), 30 | body: 31 | event.body && event.isBase64Encoded 32 | ? isFormData 33 | ? Buffer.from(event.body, 'base64') 34 | : Buffer.from(event.body, 'base64').toString() 35 | : event.body || undefined, 36 | }) 37 | } 38 | 39 | function createRemixHeaders( 40 | requestHeaders: ALBEventHeaders 41 | ): Headers { 42 | const headers = new Headers() 43 | 44 | for (const [header, value] of Object.entries(requestHeaders)) { 45 | if (value) { 46 | headers.append(header, value) 47 | } 48 | } 49 | 50 | return headers 51 | } 52 | 53 | async function sendRemixResponse( 54 | nodeResponse: Response 55 | ): Promise { 56 | const contentType = nodeResponse.headers.get('Content-Type') 57 | const isBase64Encoded = isBinaryType(contentType) 58 | let body: string | undefined 59 | 60 | if (nodeResponse.body) { 61 | if (isBase64Encoded) { 62 | body = await readableStreamToString(nodeResponse.body, 'base64') 63 | } else { 64 | body = await nodeResponse.text() 65 | } 66 | } 67 | 68 | return { 69 | statusCode: nodeResponse.status, 70 | headers: Object.fromEntries(nodeResponse.headers.entries()), 71 | body: body || '', 72 | isBase64Encoded, 73 | } 74 | } 75 | 76 | type ApplicationLoadBalancerAdapter = RemixAdapter 77 | 78 | const applicationLoadBalancerAdapter: ApplicationLoadBalancerAdapter = { 79 | createRemixRequest, 80 | sendRemixResponse 81 | } 82 | 83 | export { 84 | createRemixRequest, 85 | createRemixHeaders, 86 | sendRemixResponse, 87 | applicationLoadBalancerAdapter 88 | } 89 | 90 | export type { 91 | ApplicationLoadBalancerAdapter 92 | } 93 | -------------------------------------------------------------------------------- /src/adapters/index.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ALBEvent, 3 | ALBResult, 4 | APIGatewayProxyEvent, 5 | APIGatewayProxyEventV2, 6 | APIGatewayProxyResult, 7 | APIGatewayProxyStructuredResultV2 8 | } from 'aws-lambda' 9 | import type { ApiGatewayV1Adapter } from './api-gateway-v1' 10 | import type { ApiGatewayV2Adapter } from './api-gateway-v2' 11 | import type { ApplicationLoadBalancerAdapter } from './application-load-balancer' 12 | 13 | import { AWSProxy } from '../server' 14 | 15 | import { apiGatewayV1Adapter } from './api-gateway-v1' 16 | import { apiGatewayV2Adapter } from './api-gateway-v2' 17 | import { applicationLoadBalancerAdapter } from './application-load-balancer' 18 | 19 | interface RemixAdapter { 20 | createRemixRequest: (event: T) => Request 21 | sendRemixResponse: (nodeResponse: Response) => Promise 22 | } 23 | 24 | const createRemixAdapter = (awsProxy: AWSProxy): ApiGatewayV1Adapter | ApiGatewayV2Adapter | ApplicationLoadBalancerAdapter => { 25 | switch (awsProxy) { 26 | case AWSProxy.APIGatewayV1: 27 | return apiGatewayV1Adapter 28 | case AWSProxy.APIGatewayV2: 29 | case AWSProxy.FunctionURL: 30 | return apiGatewayV2Adapter 31 | case AWSProxy.ALB: 32 | return applicationLoadBalancerAdapter 33 | } 34 | } 35 | 36 | export { 37 | createRemixAdapter 38 | } 39 | 40 | export type { 41 | RemixAdapter 42 | } 43 | -------------------------------------------------------------------------------- /src/binaryTypes.ts: -------------------------------------------------------------------------------- 1 | // TODO: check if we can replace this with https://www.npmjs.com/package/mime-types 2 | 3 | /** 4 | * Common binary MIME types 5 | * @see https://github.com/architect/functions/blob/45254fc1936a1794c185aac07e9889b241a2e5c6/src/http/helpers/binary-types.js 6 | */ 7 | const binaryTypes = [ 8 | 'application/octet-stream', 9 | // Docs 10 | 'application/epub+zip', 11 | 'application/msword', 12 | 'application/pdf', 13 | 'application/rtf', 14 | 'application/vnd.amazon.ebook', 15 | 'application/vnd.ms-excel', 16 | 'application/vnd.ms-powerpoint', 17 | 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 18 | 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 19 | 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 20 | // Fonts 21 | 'font/otf', 22 | 'font/woff', 23 | 'font/woff2', 24 | // Images 25 | 'image/bmp', 26 | 'image/gif', 27 | 'image/jpeg', 28 | 'image/png', 29 | 'image/tiff', 30 | 'image/vnd.microsoft.icon', 31 | 'image/webp', 32 | // Audio 33 | 'audio/3gpp', 34 | 'audio/aac', 35 | 'audio/basic', 36 | 'audio/mpeg', 37 | 'audio/ogg', 38 | 'audio/wavaudio/webm', 39 | 'audio/x-aiff', 40 | 'audio/x-midi', 41 | 'audio/x-wav', 42 | // Video 43 | 'video/3gpp', 44 | 'video/mp2t', 45 | 'video/mpeg', 46 | 'video/ogg', 47 | 'video/quicktime', 48 | 'video/webm', 49 | 'video/x-msvideo', 50 | // Archives 51 | 'application/java-archive', 52 | 'application/vnd.apple.installer+xml', 53 | 'application/x-7z-compressed', 54 | 'application/x-apple-diskimage', 55 | 'application/x-bzip', 56 | 'application/x-bzip2', 57 | 'application/x-gzip', 58 | 'application/x-java-archive', 59 | 'application/x-rar-compressed', 60 | 'application/x-tar', 61 | 'application/x-zip', 62 | 'application/zip', 63 | ] 64 | 65 | export function isBinaryType(contentType: string | null | undefined) { 66 | if (!contentType) return false 67 | return binaryTypes.some((t) => contentType.includes(t)) 68 | } 69 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export type { GetLoadContextFunction, RequestHandler } from './server' 2 | export { AWSProxy, createRequestHandler } from './server' 3 | export { awsPreset } from './vite-preset' -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ALBEvent, 3 | ALBHandler, 4 | APIGatewayProxyEvent, 5 | APIGatewayProxyEventV2, 6 | APIGatewayProxyHandler, 7 | APIGatewayProxyHandlerV2 8 | } from 'aws-lambda' 9 | import type { 10 | AppLoadContext, 11 | ServerBuild, 12 | } from '@remix-run/node' 13 | 14 | import { 15 | createRequestHandler as createRemixRequestHandler 16 | } from '@remix-run/node' 17 | 18 | import { createRemixAdapter } from './adapters' 19 | 20 | export enum AWSProxy { 21 | APIGatewayV1 = 'APIGatewayV1', 22 | APIGatewayV2 = 'APIGatewayV2', 23 | ALB = 'ALB', 24 | FunctionURL = 'FunctionURL' 25 | } 26 | 27 | /** 28 | * A function that returns the value to use as `context` in route `loader` and 29 | * `action` functions. 30 | * 31 | * You can think of this as an escape hatch that allows you to pass 32 | * environment/platform-specific values through to your loader/action. 33 | */ 34 | export type GetLoadContextFunction = ( 35 | event: APIGatewayProxyEventV2 | APIGatewayProxyEvent | ALBEvent 36 | ) => AppLoadContext; 37 | 38 | export type RequestHandler = APIGatewayProxyHandlerV2 | APIGatewayProxyHandler | ALBHandler; 39 | 40 | /** 41 | * Returns a request handler for Architect that serves the response using 42 | * Remix. 43 | */ 44 | export function createRequestHandler({ 45 | build, 46 | getLoadContext, 47 | mode = process.env.NODE_ENV, 48 | awsProxy = AWSProxy.APIGatewayV2 49 | }: { 50 | build: ServerBuild; 51 | getLoadContext?: GetLoadContextFunction; 52 | mode?: string; 53 | awsProxy?: AWSProxy; 54 | }): RequestHandler { 55 | const handleRequest = createRemixRequestHandler(build, mode) 56 | 57 | return async (event: APIGatewayProxyEvent | APIGatewayProxyEventV2 | ALBEvent /*, context*/) => { 58 | const awsAdapter = createRemixAdapter(awsProxy) 59 | 60 | let request 61 | 62 | try { 63 | request = awsAdapter.createRemixRequest(event as APIGatewayProxyEvent & APIGatewayProxyEventV2 & ALBEvent) 64 | } catch (e: any) { 65 | return awsAdapter.sendRemixResponse( 66 | new Response(`Bad Request: ${e.message}`, { status: 400 }), 67 | ) 68 | } 69 | 70 | const loadContext = getLoadContext?.(event) 71 | 72 | const response = (await handleRequest(request, loadContext)) as Response 73 | 74 | return awsAdapter.sendRemixResponse(response) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/vite-preset.ts: -------------------------------------------------------------------------------- 1 | import type { BuildOptions } from 'esbuild' 2 | import type { 3 | Preset, 4 | VitePluginConfig 5 | } from '@remix-run/dev' 6 | import type { ResolvedVitePluginConfig } from '@remix-run/dev/dist/vite/plugin' 7 | 8 | import * as esbuild from 'esbuild' 9 | import { 10 | cpSync, 11 | readFileSync, 12 | rmSync, 13 | writeFileSync 14 | } from 'fs' 15 | import { join } from 'path' 16 | 17 | import { AWSProxy } from './index' 18 | 19 | type AwsRemixConfig = { 20 | awsProxy?: AWSProxy, 21 | build?: BuildOptions 22 | } 23 | 24 | const defaultConfig: AwsRemixConfig = { 25 | awsProxy: AWSProxy.APIGatewayV2, 26 | build: { 27 | logLevel: 'info', 28 | entryPoints: [ 29 | 'build/server.js' 30 | ], 31 | bundle: true, 32 | sourcemap: false, 33 | platform: 'node', 34 | outfile: 'build/server/index.js', // will replace remix server build file 35 | allowOverwrite: true, 36 | write: true, 37 | } 38 | } 39 | 40 | const copyDefaultServerHandler = ( 41 | remixUserConfig: ResolvedVitePluginConfig, 42 | config: AwsRemixConfig 43 | ) => { 44 | const buildDirectory = remixUserConfig.buildDirectory ?? 'build' 45 | const templateServerFile = join(__dirname, 'server.js') 46 | const destinationServerFile = join(buildDirectory, 'server.js') 47 | 48 | console.log('📋 Copying generic handler to:', buildDirectory) 49 | 50 | cpSync(templateServerFile, destinationServerFile) 51 | 52 | if (config.awsProxy) { 53 | let serverFileWithConfig = readFileSync(destinationServerFile, 'utf-8') 54 | 55 | serverFileWithConfig = serverFileWithConfig 56 | .replace( 57 | /awsProxy: .+/, 58 | `awsProxy: '${config.awsProxy}'` 59 | ) 60 | .replace( 61 | './build/server/index.js', 62 | remixUserConfig.buildDirectory + '/server/' + remixUserConfig.serverBuildFile 63 | ) 64 | 65 | writeFileSync(destinationServerFile, serverFileWithConfig, 'utf8') 66 | } 67 | } 68 | 69 | const cleanupHandler = (remixUserConfig: ResolvedVitePluginConfig) => { 70 | rmSync( 71 | join(remixUserConfig.buildDirectory ?? 'build', 'server.js') 72 | ) 73 | } 74 | 75 | const buildEndHandler: (config: AwsRemixConfig) => VitePluginConfig['buildEnd'] = 76 | (config) => 77 | async ( 78 | { 79 | remixConfig, 80 | viteConfig 81 | } 82 | ) => { 83 | console.log('👷 Building for AWS...') 84 | 85 | const isEsm = [remixConfig.serverModuleFormat, config.build?.format].includes('esm') 86 | 87 | const mergedConfig = { 88 | ...defaultConfig, 89 | ...config, 90 | build: { 91 | ...defaultConfig.build, 92 | format: remixConfig.serverModuleFormat, 93 | outfile: remixConfig.buildDirectory + '/server/' + remixConfig.serverBuildFile, 94 | publicPath: viteConfig.base, 95 | minify: Boolean(viteConfig.build.minify), 96 | sourcemap: viteConfig.build.sourcemap, 97 | target: viteConfig.build.target, 98 | 99 | // workaround dynamic require bug 100 | // https://github.com/evanw/esbuild/issues/1921#issuecomment-2302290651 101 | mainFields: isEsm 102 | ? ['module', 'main'] 103 | : undefined, 104 | banner: isEsm 105 | ? { 106 | js: 'import { createRequire } from \'module\'; const require = createRequire(import.meta.url);', 107 | } 108 | : undefined, 109 | 110 | ...config.build 111 | } as BuildOptions 112 | } 113 | 114 | const { build } = mergedConfig 115 | 116 | if (!config?.build?.entryPoints) { 117 | copyDefaultServerHandler(remixConfig, mergedConfig) 118 | } 119 | 120 | try { 121 | await esbuild.build(build as BuildOptions) 122 | 123 | console.log('✅ Build for AWS successful!') 124 | } catch (error) { 125 | console.error('🚫 Build for AWS failed:', error) 126 | 127 | process.exit(1) 128 | } finally { 129 | if (!config?.build?.entryPoints) { 130 | console.log('🧹 Cleaning up...') 131 | 132 | cleanupHandler(remixConfig) 133 | 134 | console.log('🧹 Clean up completed!') 135 | } 136 | } 137 | } 138 | 139 | export function awsPreset(config: AwsRemixConfig = {}): Preset { 140 | return { 141 | name: 'aws-preset', 142 | remixConfig: () => ({ 143 | buildEnd: buildEndHandler(config), 144 | }), 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /templates/server.js: -------------------------------------------------------------------------------- 1 | import { installGlobals } from '@remix-run/node' 2 | import { AWSProxy, createRequestHandler } from 'remix-aws' 3 | 4 | // Required in Remix v2 5 | installGlobals() 6 | 7 | let build = require('./build/server/index.js') 8 | 9 | export const handler = createRequestHandler({ 10 | build, 11 | mode: process.env.NODE_ENV, 12 | awsProxy: AWSProxy.APIGatewayV2 13 | }) 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Default", 4 | "compilerOptions": { 5 | "lib": [ 6 | "ES2022", 7 | "DOM.Iterable" 8 | ], 9 | "module": "commonjs", 10 | "target": "ES2022", 11 | "strict": true, 12 | "esModuleInterop": true, 13 | "skipLibCheck": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "useDefineForClassFields": true, 16 | "moduleResolution": "node" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | name: 'tsup', 5 | entry: ['src/index.ts'], 6 | target: 'node18', 7 | format: [ 8 | 'cjs', 9 | 'esm' 10 | ], 11 | dts: true, 12 | sourcemap: true, 13 | clean: true, 14 | external: [ 15 | 'esbuild' 16 | ] 17 | }) --------------------------------------------------------------------------------