├── .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 |
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 | })
--------------------------------------------------------------------------------