diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 789148a14..3c650b65c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,3 +99,15 @@ jobs: - run: yarn install --frozen-lockfile - run: npm run build - run: npm run test:lambda + + lambda-edge: + name: 'Lambda@Edge' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 18.x + - run: yarn install --frozen-lockfile + - run: npm run build + - run: npm run test:lambda-edge \ No newline at end of file diff --git a/package.json b/package.json index 55fb7427d..c907d138a 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "test:node": "env NAME=Node jest --config ./runtime_tests/node/jest.config.js", "test:wrangler": "jest --config ./runtime_tests/wrangler/jest.config.js", "test:lambda": "env NAME=Node jest --config ./runtime_tests/lambda/jest.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", + "test:lambda-edge": "env NAME=Node jest --config ./runtime_tests/lambda-edge/jest.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 && yarn test:lambda-edge", "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'", @@ -214,6 +215,11 @@ "types": "./dist/types/adapter/vercel/index.d.ts", "import": "./dist/adapter/vercel/index.js", "require": "./dist/cjs/adapter/vercel/index.js" + }, + "./lambda-edge": { + "types": "./dist/types/adapter/lambda-edge/index.d.ts", + "import": "./dist/adapter/lambda-edge/index.js", + "require": "./dist/cjs/adapter/lambda-edge/index.js" } }, "typesVersions": { @@ -325,6 +331,9 @@ ], "vercel": [ "./dist/types/adapter/vercel" + ], + "lambda-edge": [ + "./dist/types/adapter/lambda-edge" ] } }, diff --git a/runtime_tests/lambda-edge/index.test.ts b/runtime_tests/lambda-edge/index.test.ts new file mode 100644 index 000000000..d51849202 --- /dev/null +++ b/runtime_tests/lambda-edge/index.test.ts @@ -0,0 +1,615 @@ +import { handle } from '../../src/adapter/lambda-edge/handler' +import { Hono } from '../../src/hono' +import { basicAuth } from '../../src/middleware/basic-auth' + +describe('Lambda@Edge Adapter for Hono', () => { + const app = new Hono() + + app.get('/', (c) => { + return c.text('Hello Lambda!') + }) + + app.get('/binary', (c) => { + return c.body('Fake Image', 200, { + 'Content-Type': 'image/png', + }) + }) + + 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 (Lambda@Edge viewer request)', async () => { + const event = { + "Records": [ + { + "cf": { + "config": { + "distributionDomainName": "d111111abcdef8.cloudfront.net", + "distributionId": "EDFDVBD6EXAMPLE", + "eventType": "viewer-request", + "requestId": "4TyzHTaYWb1GX1qTfsHhEqV6HUDd_BzoBZnwfnvQc_1oF26ClkoUSEQ==" + }, + "request": { + "clientIp": "203.0.113.178", + "headers": { + "host": [ + { + "key": "Host", + "value": "d111111abcdef8.cloudfront.net" + } + ], + "user-agent": [ + { + "key": "User-Agent", + "value": "curl/7.66.0" + } + ], + "accept": [ + { + "key": "accept", + "value": "*/*" + } + ] + }, + "method": "GET", + "querystring": "", + "uri": "/" + } + } + } + ] + } + const response = await handler(event) + expect(response.status).toBe('200'); + expect(response.body).toBe('Hello Lambda!'); + expect(response.headers['content-type'][0].value).toMatch(/^text\/plain/); + }) + + it('Should handle a GET request and return a 200 response (Lambda@Edge origin request)', async () => { + const event = { + "Records": [ + { + "cf": { + "config": { + "distributionDomainName": "d111111abcdef8.cloudfront.net", + "distributionId": "EDFDVBD6EXAMPLE", + "eventType": "origin-request", + "requestId": "4TyzHTaYWb1GX1qTfsHhEqV6HUDd_BzoBZnwfnvQc_1oF26ClkoUSEQ==" + }, + "request": { + "clientIp": "203.0.113.178", + "headers": { + "x-forwarded-for": [ + { + "key": "X-Forwarded-For", + "value": "203.0.113.178" + } + ], + "user-agent": [ + { + "key": "User-Agent", + "value": "Amazon CloudFront" + } + ], + "via": [ + { + "key": "Via", + "value": "2.0 2afae0d44e2540f472c0635ab62c232b.cloudfront.net (CloudFront)" + } + ], + "host": [ + { + "key": "Host", + "value": "example.org" + } + ], + "cache-control": [ + { + "key": "Cache-Control", + "value": "no-cache" + } + ] + }, + "method": "GET", + "origin": { + "custom": { + "customHeaders": {}, + "domainName": "example.org", + "keepaliveTimeout": 5, + "path": "", + "port": 443, + "protocol": "https", + "readTimeout": 30, + "sslProtocols": [ + "TLSv1", + "TLSv1.1", + "TLSv1.2" + ] + } + }, + "querystring": "", + "uri": "/" + } + } + } + ] + } + const response = await handler(event) + expect(response.status).toBe('200'); + expect(response.body).toBe('Hello Lambda!'); + expect(response.headers['content-type'][0].value).toMatch(/^text\/plain/); + }) + + it('Should handle a GET request and return a 200 response (Lambda@Edge viewer response)', async () => { + const event = { + "Records": [ + { + "cf": { + "config": { + "distributionDomainName": "d111111abcdef8.cloudfront.net", + "distributionId": "EDFDVBD6EXAMPLE", + "eventType": "viewer-response", + "requestId": "4TyzHTaYWb1GX1qTfsHhEqV6HUDd_BzoBZnwfnvQc_1oF26ClkoUSEQ==" + }, + "request": { + "clientIp": "203.0.113.178", + "headers": { + "host": [ + { + "key": "Host", + "value": "d111111abcdef8.cloudfront.net" + } + ], + "user-agent": [ + { + "key": "User-Agent", + "value": "curl/7.66.0" + } + ], + "accept": [ + { + "key": "accept", + "value": "*/*" + } + ] + }, + "method": "GET", + "querystring": "", + "uri": "/" + }, + "response": { + "headers": { + "access-control-allow-credentials": [ + { + "key": "Access-Control-Allow-Credentials", + "value": "true" + } + ], + "access-control-allow-origin": [ + { + "key": "Access-Control-Allow-Origin", + "value": "*" + } + ], + "date": [ + { + "key": "Date", + "value": "Mon, 13 Jan 2020 20:14:56 GMT" + } + ], + "referrer-policy": [ + { + "key": "Referrer-Policy", + "value": "no-referrer-when-downgrade" + } + ], + "server": [ + { + "key": "Server", + "value": "ExampleCustomOriginServer" + } + ], + "x-content-type-options": [ + { + "key": "X-Content-Type-Options", + "value": "nosniff" + } + ], + "x-frame-options": [ + { + "key": "X-Frame-Options", + "value": "DENY" + } + ], + "x-xss-protection": [ + { + "key": "X-XSS-Protection", + "value": "1; mode=block" + } + ], + "age": [ + { + "key": "Age", + "value": "2402" + } + ], + "content-type": [ + { + "key": "Content-Type", + "value": "text/html; charset=utf-8" + } + ], + "content-length": [ + { + "key": "Content-Length", + "value": "9593" + } + ] + }, + "status": "200", "statusDescription": "OK" + } + } + } + ] + } + const response = await handler(event) + expect(response.status).toBe('200'); + expect(response.body).toBe('Hello Lambda!'); + expect(response.headers['content-type'][0].value).toMatch(/^text\/plain/); + }) + + it('Should handle a GET request and return a 200 response (Lambda@Edge origin response)', async () => { + const event = { + "Records": [ + { + "cf": { + "config": { + "distributionDomainName": "d111111abcdef8.cloudfront.net", + "distributionId": "EDFDVBD6EXAMPLE", + "eventType": "origin-response", + "requestId": "4TyzHTaYWb1GX1qTfsHhEqV6HUDd_BzoBZnwfnvQc_1oF26ClkoUSEQ==" + }, + "request": { + "clientIp": "203.0.113.178", + "headers": { + "x-forwarded-for": [ + { + "key": "X-Forwarded-For", + "value": "203.0.113.178" + } + ], + "user-agent": [ + { + "key": "User-Agent", + "value": "Amazon CloudFront" + } + ], + "via": [ + { + "key": "Via", + "value": "2.0 8f22423015641505b8c857a37450d6c0.cloudfront.net (CloudFront)" + } + ], + "host": [ + { + "key": "Host", + "value": "example.org" + } + ], + "cache-control": [ + { + "key": "Cache-Control", + "value": "no-cache" + } + ] + }, + "method": "GET", + "origin": { + "custom": { + "customHeaders": {}, + "domainName": "example.org", + "keepaliveTimeout": 5, + "path": "", + "port": 443, + "protocol": "https", + "readTimeout": 30, + "sslProtocols": [ + "TLSv1", + "TLSv1.1", + "TLSv1.2" + ] + } + }, + "querystring": "", + "uri": "/" + }, + "response": { + "headers": { + "access-control-allow-credentials": [ + { + "key": "Access-Control-Allow-Credentials", + "value": "true" + } + ], + "access-control-allow-origin": [ + { + "key": "Access-Control-Allow-Origin", + "value": "*" + } + ], + "date": [ + { + "key": "Date", + "value": "Mon, 13 Jan 2020 20:12:38 GMT" + } + ], + "referrer-policy": [ + { + "key": "Referrer-Policy", + "value": "no-referrer-when-downgrade" + } + ], + "server": [ + { + "key": "Server", + "value": "ExampleCustomOriginServer" + } + ], + "x-content-type-options": [ + { + "key": "X-Content-Type-Options", + "value": "nosniff" + } + ], + "x-frame-options": [ + { + "key": "X-Frame-Options", + "value": "DENY" + } + ], + "x-xss-protection": [ + { + "key": "X-XSS-Protection", + "value": "1; mode=block" + } + ], + "content-type": [ + { + "key": "Content-Type", + "value": "text/html; charset=utf-8" + } + ], + "content-length": [ + { + "key": "Content-Length", + "value": "9593" + } + ] + }, + "status": "200", + "statusDescription": "OK" + } + } + } + ] + } + const response = await handler(event) + expect(response.status).toBe('200'); + expect(response.body).toBe('Hello Lambda!'); + expect(response.headers['content-type'][0].value).toMatch(/^text\/plain/); + }) + + it('Should handle a GET request and return a 200 response with binary', async () => { + const event = { + Records: [ + { + cf: { + config: { + distributionDomainName: 'example.com', + distributionId: 'EXAMPLE123', + eventType: 'viewer-request', + requestId: 'exampleRequestId', + }, + request: { + clientIp: '123.123.123.123', + headers: { + 'host': [ + { + key: 'Host', + value: 'example.com', + }, + ], + }, + method: 'GET', + querystring: '', + uri: '/binary', + }, + }, + }, + ], + }; + + const response = await handler(event); + + expect(response.status).toBe('200'); + expect(response.body).toBe('RmFrZSBJbWFnZQ=='); // base64 encoded fake image + expect(response.headers['content-type'][0].value).toMatch(/^image\/png/); + }); + + it('Should handle a GET request and return a 404 response', async () => { + const event = { + Records: [ + { + cf: { + config: { + distributionDomainName: 'example.com', + distributionId: 'EXAMPLE123', + eventType: 'viewer-request', + requestId: 'exampleRequestId', + }, + request: { + clientIp: '123.123.123.123', + headers: { + 'host': [ + { + key: 'Host', + value: 'example.com', + }, + ], + }, + method: 'GET', + querystring: '', + uri: '/nothing', + }, + }, + }, + ], + }; + + const response = await handler(event); + + expect(response.status).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 = { + Records: [ + { + cf: { + config: { + distributionDomainName: 'example.com', + distributionId: 'EXAMPLE123', + eventType: 'viewer-request', + requestId: 'exampleRequestId', + }, + request: { + clientIp: '123.123.123.123', + headers: { + 'host': [ + { + key: 'Host', + value: 'example.com', + }, + ], + 'content-type': [ + { + key: 'Content-Type', + value: 'application/x-www-form-urlencoded', + }, + ], + }, + method: 'POST', + querystring: '', + uri: '/post', + body: { + inputTruncated: false, + action: 'read-only', + encoding: 'base64', + data: btoa(searchParam.toString()), + }, + }, + }, + }, + ], + }; + + const response = await handler(event); + + expect(response.status).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 = { + Records: [ + { + cf: { + config: { + distributionDomainName: 'example.com', + distributionId: 'EXAMPLE123', + eventType: 'viewer-request', + requestId: 'exampleRequestId', + }, + request: { + clientIp: '123.123.123.123', + headers: { + 'host': [ + { + key: 'Host', + value: 'example.com', + }, + ], + 'content-type': [ + { + key: 'Content-Type', + value: 'plain/text', + }, + ], + }, + method: 'GET', + querystring: '', + uri: '/auth/abc', + }, + }, + }, + ], + }; + + const response = await handler(event); + + expect(response.status).toBe('401'); + }); + + it('Should handle a request and return a 401 response with Basic auth', async () => { + const event = { + Records: [ + { + cf: { + config: { + distributionDomainName: 'example.com', + distributionId: 'EXAMPLE123', + eventType: 'viewer-request', + requestId: 'exampleRequestId', + }, + request: { + clientIp: '123.123.123.123', + headers: { + 'host': [ + { + key: 'Host', + value: 'example.com', + }, + ], + 'content-type': [ + { + key: 'Content-Type', + value: 'plain/text', + }, + ], + }, + method: 'GET', + querystring: '', + uri: '/auth/abc', + }, + }, + }, + ], + }; + + const response = await handler(event); + + expect(response.status).toBe('401'); + }); +}); \ No newline at end of file diff --git a/runtime_tests/lambda-edge/jest.config.js b/runtime_tests/lambda-edge/jest.config.js new file mode 100644 index 000000000..cddb7a243 --- /dev/null +++ b/runtime_tests/lambda-edge/jest.config.js @@ -0,0 +1,7 @@ +export default { + testMatch: ['**/runtime_tests/lambda-edge/**/*.+(ts|tsx|js)'], + transform: { + '^.+\\.(ts|tsx)$': 'ts-jest', + }, + testPathIgnorePatterns: ['jest.config.js'], +} diff --git a/src/adapter/lambda-edge/handler.test.ts b/src/adapter/lambda-edge/handler.test.ts new file mode 100644 index 000000000..4ba5744a3 --- /dev/null +++ b/src/adapter/lambda-edge/handler.test.ts @@ -0,0 +1,15 @@ +import { isContentTypeBinary } from './handler' + +describe('isContentTypeBinary', () => { + it('Should determine whether it is binary', () => { + expect(isContentTypeBinary('image/png')).toBe(true) + expect(isContentTypeBinary('font/woff2')).toBe(true) + expect(isContentTypeBinary('text/plain')).toBe(false) + expect(isContentTypeBinary('text/plain; charset=UTF-8')).toBe(false) + expect(isContentTypeBinary('text/css')).toBe(false) + expect(isContentTypeBinary('text/javascript')).toBe(false) + expect(isContentTypeBinary('application/json')).toBe(false) + expect(isContentTypeBinary('application/ld+json')).toBe(false) + expect(isContentTypeBinary('application/json; charset=UTF-8')).toBe(false) + }) +}) diff --git a/src/adapter/lambda-edge/handler.ts b/src/adapter/lambda-edge/handler.ts new file mode 100644 index 000000000..71c1bb919 --- /dev/null +++ b/src/adapter/lambda-edge/handler.ts @@ -0,0 +1,147 @@ +// @denoify-ignore +import crypto from 'crypto' +import type { Hono } from '../../hono' + +import { encodeBase64 } from '../../utils/encode' + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +globalThis.crypto ??= crypto + +interface CloudFrontHeader { + key: string; + value: string; +} + +interface CloudFrontHeaders { + [name: string]: CloudFrontHeader[]; +} + +interface CloudFrontCustomOrigin { + customHeaders: CloudFrontHeaders; + domainName: string; + keepaliveTimeout: number; + path: string; + port: number; + protocol: string; + readTimeout: number; + sslProtocols: string[]; +} + +interface CloudFrontRequest { + clientIp: string; + headers: CloudFrontHeaders; + method: string; + querystring: string; + uri: string; + body?: { + inputTruncated: boolean; + action: string; + encoding: string; + data: string; + }; + origin?: { + custom: CloudFrontCustomOrigin; + }; +} + +interface CloudFrontConfig { + distributionDomainName: string; + distributionId: string; + eventType: string; + requestId: string; +} + +interface CloudFrontEvent { + cf: { + config: CloudFrontConfig; + request: CloudFrontRequest; + }; +} + +interface CloudFrontEdgeEvent { + Records: CloudFrontEvent[]; +} + +interface CloudFrontResult { + status: string; + statusDescription?: string; + headers: { + [header: string]: { + key?: string; + value: string; + }[]; + }; + body?: string; +} + +/** + * Accepts events from 'Lambda@Edge' event + * https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-event-structure.html + */ +export const handle = (app: Hono) => { + return async ( + event: CloudFrontEdgeEvent + ): Promise => { + const req = createRequest(event) + const res = await app.fetch(req) + + return createResult(res) + } +} + +const createResult = async (res: Response): Promise => { + const isBase64Encoded = isContentTypeBinary(res.headers.get('content-type') || '') + + const body = isBase64Encoded ? encodeBase64(await res.arrayBuffer()) : await res.text() + + const headers: { [header: string]: { key: string; value: string }[] } = {} + + res.headers.forEach((value, key) => { + headers[key.toLowerCase()] = [{ key: key.toLowerCase(), value }] + }) + + return { + status: res.status.toString(), + headers, + body, + } +} + +const createRequest = ( + event: CloudFrontEdgeEvent +) => { + const queryString = extractQueryString(event) + const urlPath = `https://${event.Records[0].cf.config.distributionDomainName}${event.Records[0].cf.request.uri}` + const url = queryString ? `${urlPath}?${queryString}` : urlPath + + const headers = new Headers() + for (const [k, v] of Object.entries(event.Records[0].cf.request.headers)) { + if (Array.isArray(v)) { + v.forEach(header => headers.set(k, header.value)) + } + } + const method = event.Records[0].cf.request.method + const requestInit: RequestInit = { + headers, + method, + } + + const requestBody = event.Records[0].cf.request.body + requestInit.body = requestBody?.encoding === 'base64' && requestBody?.data + ? atob(requestBody.data) + : requestBody?.data + return new Request(url, requestInit) +} + +const extractQueryString = ( + event: CloudFrontEdgeEvent +) => { + return event.Records[0].cf.request.querystring +} + +export const isContentTypeBinary = (contentType: string) => { + return !/^(text\/(plain|html|css|javascript|csv).*|application\/(.*json|.*xml).*|image\/svg\+xml)$/.test( + contentType + ) +} \ No newline at end of file diff --git a/src/adapter/lambda-edge/index.ts b/src/adapter/lambda-edge/index.ts new file mode 100644 index 000000000..fe303c423 --- /dev/null +++ b/src/adapter/lambda-edge/index.ts @@ -0,0 +1,2 @@ +// @denoify-ignore +export { handle } from './handler'