Skip to content

Commit

Permalink
feat(adapter): introduce AWS Lambda Adapter (honojs#987)
Browse files Browse the repository at this point in the history
* feat: introduce AWS Lambda adapter

* denoify ignore

* export settings
  • Loading branch information
yusukebe authored Mar 17, 2023
1 parent 0238dc6 commit 1d55839
Show file tree
Hide file tree
Showing 6 changed files with 214 additions and 3 deletions.
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,15 @@ jobs:
- run: yarn install --frozen-lockfile
- run: npm run build
- run: npm run test:wrangler

lambda:
name: 'AWS Lambda'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 18.x
- run: yarn install --frozen-lockfile
- run: npm run build
- run: npm run test:lambda
6 changes: 6 additions & 0 deletions jest.lambda.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default {
testMatch: ['**/test_lambda/**/*.+(ts|tsx|js)'],
transform: {
'^.+\\.(ts|tsx)$': 'ts-jest',
},
}
15 changes: 12 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@
"test:fastly": "jest --config ./jest.fastly.config.js",
"test:lagon": "start-server-and-test \"lagon dev test_lagon/index.ts\" http://127.0.0.1:1234 \"yarn jest test_lagon/index.test.ts --testMatch '**/*.test.ts'\"",
"test:node": "env NAME=Node jest --config ./jest.node.config.js",
"test:wrangler": "jest --config ./jest.wrangler.config.js",
"test:all": "yarn test && yarn test:deno && yarn test:bun && yarn test:fastly && yarn test:lagon && yarn test:node && yarn test:wrangler",
"test:wrangler": "jest --config ./jest.lambda.config.js",
"test:lambda": "env NAME=Node jest --config ./jest.node.config.js",
"test:all": "yarn test && yarn test:deno && yarn test:bun && yarn test:fastly && yarn test:lagon && yarn test:node && yarn test:wrangler && yarn test:lambda",
"lint": "eslint --ext js,ts src .eslintrc.cjs",
"lint:fix": "eslint --ext js,ts src .eslintrc.cjs --fix",
"denoify": "rimraf deno_dist && denoify && rimraf 'deno_dist/**/*.test.ts'",
Expand Down Expand Up @@ -173,6 +174,11 @@
"types": "./dist/types/adapter/nextjs/index.d.ts",
"import": "./dist/adapter/nextjs/index.js",
"require": "./dist/cjs/adapter/nextjs/index.js"
},
"./aws-lambda": {
"types": "./dist/types/adapter/aws-lambda/index.d.ts",
"import": "./dist/adapter/aws-lambda/index.js",
"require": "./dist/cjs/adapter/aws-lambda/index.js"
}
},
"typesVersions": {
Expand Down Expand Up @@ -260,6 +266,9 @@
],
"nextjs": [
"./dist/types/adapter/nextjs"
],
"aws-lambda": [
"./dist/types/adapter/aws-lambda"
]
}
},
Expand Down Expand Up @@ -332,4 +341,4 @@
"engines": {
"node": ">=16.0.0"
}
}
}
64 changes: 64 additions & 0 deletions src/adapter/aws-lambda/handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// @denoify-ignore
import crypto from 'crypto'
import type { Hono } from '../../hono'

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
globalThis.crypto = crypto

interface APIGatewayEvent {
httpMethod: string
headers: Record<string, string | undefined>
path: string
body: string | null
isBase64Encoded: boolean
requestContext: {
domainName: string
}
}

interface APIGatewayProxyResult {
statusCode: number
body: string
headers: Record<string, string>
isBase64Encoded: boolean
}

export const handle = (app: Hono) => {
return async (event: APIGatewayEvent): Promise<APIGatewayProxyResult> => {
const req = createRequest(event)
const res = await app.fetch(req)
const arrayBuffer = await res.arrayBuffer()
const result: APIGatewayProxyResult = {
statusCode: res.status,
body: String.fromCharCode(...new Uint8Array(arrayBuffer)),
headers: {},
isBase64Encoded: false,
}

res.headers.forEach((value, key) => {
result.headers[key] = value
})

return result
}
}

const createRequest = (event: APIGatewayEvent) => {
const url = `https://${event.requestContext.domainName}${event.path}`
const headers = new Headers()
for (const [k, v] of Object.entries(event.headers)) {
if (v) headers.set(k, v)
}
const method = event.httpMethod

const requestInit: RequestInit = {
headers: headers,
method: method,
}

if (event.body) {
requestInit.body = event.isBase64Encoded ? atob(event.body) : event.body
}
return new Request(url, requestInit)
}
2 changes: 2 additions & 0 deletions src/adapter/aws-lambda/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// @denoify-ignore
export { handle } from './handler'
118 changes: 118 additions & 0 deletions test_lambda/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { handle } from '../src/adapter/aws-lambda/handler'
import { Hono } from '../src/hono'
import { basicAuth } from '../src/middleware/basic-auth'

describe('AWS Lambda Adapter for Hono', () => {
const app = new Hono()

app.get('/', (c) => {
return c.text('Hello Lambda!')
})

app.post('/post', async (c) => {
const body = (await c.req.parseBody()) as { message: string }
return c.text(body.message)
})

const username = 'hono-user-a'
const password = 'hono-password-a'
app.use('/auth/*', basicAuth({ username, password }))
app.get('/auth/abc', (c) => c.text('Good Night Lambda!'))

const handler = handle(app)

it('Should handle a GET request and return a 200 response', async () => {
const event = {
httpMethod: 'GET',
headers: { 'content-type': 'text/plain' },
path: '/',
body: null,
isBase64Encoded: false,
requestContext: {
domainName: 'example.com',
},
}

const response = await handler(event)
expect(response.statusCode).toBe(200)
expect(response.body).toBe('Hello Lambda!')
expect(response.headers['content-type']).toMatch(/^text\/plain/)
expect(response.isBase64Encoded).toBe(false)
})

it('Should handle a GET request and return a 404 response', async () => {
const event = {
httpMethod: 'GET',
headers: { 'content-type': 'text/plain' },
path: '/nothing',
body: null,
isBase64Encoded: false,
requestContext: {
domainName: 'example.com',
},
}

const response = await handler(event)
expect(response.statusCode).toBe(404)
})

it('Should handle a POST request and return a 200 response', async () => {
const searchParam = new URLSearchParams()
searchParam.append('message', 'Good Morning Lambda!')
const event = {
httpMethod: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
path: '/post',
body: btoa(searchParam.toString()),
isBase64Encoded: true,
requestContext: {
domainName: 'example.com',
},
}

const response = await handler(event)
expect(response.statusCode).toBe(200)
expect(response.body).toBe('Good Morning Lambda!')
})

it('Should handle a request and return a 401 response with Basic auth', async () => {
const event = {
httpMethod: 'GET',
headers: {
'Content-Type': 'plain/text',
},
path: '/auth/abc',
body: null,
isBase64Encoded: true,
requestContext: {
domainName: 'example.com',
},
}

const response = await handler(event)
expect(response.statusCode).toBe(401)
})

it('Should handle a request and return a 200 response with Basic auth', async () => {
const credential = 'aG9uby11c2VyLWE6aG9uby1wYXNzd29yZC1h'
const event = {
httpMethod: 'GET',
headers: {
'Content-Type': 'plain/text',
Authorization: `Basic ${credential}`,
},
path: '/auth/abc',
body: null,
isBase64Encoded: true,
requestContext: {
domainName: 'example.com',
},
}

const response = await handler(event)
expect(response.statusCode).toBe(200)
expect(response.body).toBe('Good Night Lambda!')
})
})

0 comments on commit 1d55839

Please sign in to comment.