diff --git a/package.json b/package.json index e94c3002..5a4b1ea9 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "test:e2e": "jest --config jest.e2e.config.js" }, "devDependencies": { - "@dealmore/sammy": "^1.6.0", + "@dealmore/sammy": "^1.6.1", "@types/aws-lambda": "^8.10.64", "@types/hjson": "^2.4.2", "@types/jest": "^26.0.19", @@ -25,7 +25,7 @@ "jest": "^26.6.3", "prettier": "^2.0.5", "tmp": "^0.2.1", - "ts-jest": "^26.4.4", + "ts-jest": "^26.5.5", "typescript": "^4.1.3" }, "resolutions": { diff --git a/packages/proxy/package.json b/packages/proxy/package.json index ebd87051..a56a5b4e 100644 --- a/packages/proxy/package.json +++ b/packages/proxy/package.json @@ -17,13 +17,16 @@ }, "dependencies": { "@vercel/routing-utils": "^1.9.1", + "abort-controller": "^3.0.0", "node-fetch": "^2.6.1", "pcre-to-regexp": "^1.1.0" }, "devDependencies": { - "@types/aws-lambda": "^8.10.56", + "@types/aws-lambda": "^8.10.76", + "@types/node": "^12.0.0", "@types/node-fetch": "^2.5.7", "@vercel/ncc": "^0.27.0", + "get-port": "^5.1.1", "ncc-zip": "^1.0.0" }, "files": [ diff --git a/packages/proxy/src/__test__/handler.test.ts b/packages/proxy/src/__test__/handler.test.ts new file mode 100644 index 00000000..bc37d4d8 --- /dev/null +++ b/packages/proxy/src/__test__/handler.test.ts @@ -0,0 +1,548 @@ +import { createServer, Server } from 'http'; +import { CloudFrontRequestEvent, CloudFrontRequest } from 'aws-lambda'; +import getPort from 'get-port'; + +import { ProxyConfig } from '../types'; + +// Max runtime of the lambda +const TIMEOUT = 30000; + +class ConfigServer { + public proxyConfig?: ProxyConfig; + private server?: Server; + + async start() { + const port = await getPort(); + this.server = createServer((_req, res) => { + res.end(JSON.stringify(this.proxyConfig)); + }); + + await new Promise((resolve) => this.server!.listen(port, resolve)); + + return `http://localhost:${port}`; + } + + stop() { + if (!this.server) { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + this.server!.close((err) => { + if (err) { + return reject(err); + } + resolve(); + }); + }); + } +} + +describe('[proxy] Handler', () => { + let handler: any; + let configServer: ConfigServer; + let configEndpoint: string; + + beforeEach(async () => { + // Since the handler has it's own state we need to isolate it between test runs to prevent + // using a cached proxyConfig + jest.isolateModules(() => { + handler = require('../handler').handler; + }); + + configServer = new ConfigServer(); + configEndpoint = await configServer.start(); + }); + + afterEach(async () => { + await configServer.stop(); + }); + + test( + 'External redirect [HTTP]', + async () => { + const proxyConfig: ProxyConfig = { + lambdaRoutes: [], + prerenders: {}, + staticRoutes: [], + routes: [ + { + src: '^\\/docs(?:\\/([^\\/]+?))$', + dest: 'http://example.com/docs/$1', + check: true, + }, + ], + }; + const requestPath = '/docs/hello-world'; + + // Prepare configServer + configServer.proxyConfig = proxyConfig; + + // Origin Request + // https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-event-structure.html#example-origin-request + const event: CloudFrontRequestEvent = { + 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, cf-no-cache', + }, + ], + }, + method: 'GET', + origin: { + s3: { + customHeaders: { + 'x-env-config-endpoint': [ + { + key: 'x-env-config-endpoint', + value: configEndpoint, + }, + ], + 'x-env-api-endpoint': [ + { + key: 'x-env-api-endpoint', + value: 'example.localhost', + }, + ], + }, + region: 'us-east-1', + authMethod: 'origin-access-identity', + domainName: 's3.localhost', + path: '', + }, + }, + querystring: '', + uri: requestPath, + }, + }, + }, + ], + }; + + const result = (await handler(event)) as CloudFrontRequest; + + expect(result.origin?.custom).toEqual( + expect.objectContaining({ + domainName: 'example.com', + path: '/', + port: 80, + protocol: 'http', + }) + ); + expect(result.uri).toBe('/docs/hello-world'); + expect(result.headers.host).toEqual( + expect.arrayContaining([ + { + key: 'host', + value: 'example.com', + }, + ]) + ); + }, + TIMEOUT + ); + + test( + 'External redirect [HTTPS]', + async () => { + const proxyConfig: ProxyConfig = { + lambdaRoutes: [], + prerenders: {}, + staticRoutes: [], + routes: [ + { + src: '^\\/docs(?:\\/([^\\/]+?))$', + dest: 'https://example.com/docs/$1', + check: true, + }, + ], + }; + const requestPath = '/docs/hello-world'; + + // Prepare configServer + configServer.proxyConfig = proxyConfig; + + // Origin Request + // https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-event-structure.html#example-origin-request + const event: CloudFrontRequestEvent = { + 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, cf-no-cache', + }, + ], + }, + method: 'GET', + origin: { + s3: { + customHeaders: { + 'x-env-config-endpoint': [ + { + key: 'x-env-config-endpoint', + value: configEndpoint, + }, + ], + 'x-env-api-endpoint': [ + { + key: 'x-env-api-endpoint', + value: 'example.localhost', + }, + ], + }, + region: 'us-east-1', + authMethod: 'origin-access-identity', + domainName: 's3.localhost', + path: '', + }, + }, + querystring: '', + uri: requestPath, + }, + }, + }, + ], + }; + + const result = (await handler(event)) as CloudFrontRequest; + + expect(result.origin?.custom).toEqual( + expect.objectContaining({ + domainName: 'example.com', + path: '/', + port: 443, + protocol: 'https', + }) + ); + expect(result.uri).toBe('/docs/hello-world'); + expect(result.headers.host).toEqual( + expect.arrayContaining([ + { + key: 'host', + value: 'example.com', + }, + ]) + ); + }, + TIMEOUT + ); + + test( + 'External redirect [Custom Port]', + async () => { + const proxyConfig: ProxyConfig = { + lambdaRoutes: [], + prerenders: {}, + staticRoutes: [], + routes: [ + { + src: '^\\/docs(?:\\/([^\\/]+?))$', + dest: 'https://example.com:666/docs/$1', + check: true, + }, + ], + }; + const requestPath = '/docs/hello-world'; + + // Prepare configServer + configServer.proxyConfig = proxyConfig; + + // Origin Request + // https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-event-structure.html#example-origin-request + const event: CloudFrontRequestEvent = { + 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, cf-no-cache', + }, + ], + }, + method: 'GET', + origin: { + s3: { + customHeaders: { + 'x-env-config-endpoint': [ + { + key: 'x-env-config-endpoint', + value: configEndpoint, + }, + ], + 'x-env-api-endpoint': [ + { + key: 'x-env-api-endpoint', + value: 'example.localhost', + }, + ], + }, + region: 'us-east-1', + authMethod: 'origin-access-identity', + domainName: 's3.localhost', + path: '', + }, + }, + querystring: '', + uri: requestPath, + }, + }, + }, + ], + }; + + const result = (await handler(event)) as CloudFrontRequest; + + expect(result.origin?.custom).toEqual( + expect.objectContaining({ + domainName: 'example.com', + path: '/', + port: 666, + protocol: 'https', + }) + ); + expect(result.uri).toBe('/docs/hello-world'); + expect(result.headers.host).toEqual( + expect.arrayContaining([ + { + key: 'host', + value: 'example.com', + }, + ]) + ); + }, + TIMEOUT + ); + + test( + 'External redirect [Subdomain]', + async () => { + const proxyConfig: ProxyConfig = { + lambdaRoutes: [], + prerenders: {}, + staticRoutes: [], + routes: [ + { + src: '^\\/docs(?:\\/([^\\/]+?))$', + dest: 'https://sub.example.com/docs/$1', + check: true, + }, + ], + }; + const requestPath = '/docs/hello-world'; + + // Prepare configServer + configServer.proxyConfig = proxyConfig; + + // Origin Request + // https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-event-structure.html#example-origin-request + const event: CloudFrontRequestEvent = { + 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, cf-no-cache', + }, + ], + }, + method: 'GET', + origin: { + s3: { + customHeaders: { + 'x-env-config-endpoint': [ + { + key: 'x-env-config-endpoint', + value: configEndpoint, + }, + ], + 'x-env-api-endpoint': [ + { + key: 'x-env-api-endpoint', + value: 'example.localhost', + }, + ], + }, + region: 'us-east-1', + authMethod: 'origin-access-identity', + domainName: 's3.localhost', + path: '', + }, + }, + querystring: '', + uri: requestPath, + }, + }, + }, + ], + }; + + const result = (await handler(event)) as CloudFrontRequest; + + expect(result.origin?.custom).toEqual( + expect.objectContaining({ + domainName: 'sub.example.com', + path: '/', + port: 443, + protocol: 'https', + }) + ); + expect(result.uri).toBe('/docs/hello-world'); + expect(result.headers.host).toEqual( + expect.arrayContaining([ + { + key: 'host', + value: 'sub.example.com', + }, + ]) + ); + }, + TIMEOUT + ); +}); diff --git a/packages/proxy/src/__test__/proxy.unit.test.ts b/packages/proxy/src/__test__/proxy.unit.test.ts index 97d9fed5..ce92e3d1 100644 --- a/packages/proxy/src/__test__/proxy.unit.test.ts +++ b/packages/proxy/src/__test__/proxy.unit.test.ts @@ -304,3 +304,30 @@ test('[proxy-unit] Redirect partial replace', () => { target: undefined, }); }); + +test('[proxy-unit] External rewrite', () => { + const routesConfig = [ + { + src: '^\\/docs(?:\\/([^\\/]+?))$', + dest: 'http://example.com/docs/$1', + check: true, + }, + ] as Route[]; + + const result = new Proxy(routesConfig, [], []).route('/docs/hello-world'); + + expect(result).toEqual({ + found: true, + dest: 'http://example.com/docs/hello-world', + continue: false, + status: undefined, + headers: {}, + uri_args: new URLSearchParams(''), + matched_route: routesConfig[0], + matched_route_idx: 0, + userDest: false, + isDestUrl: true, + phase: undefined, + target: 'url', + }); +}); diff --git a/packages/proxy/src/handler.ts b/packages/proxy/src/handler.ts index a30d0263..9c0b0025 100644 --- a/packages/proxy/src/handler.ts +++ b/packages/proxy/src/handler.ts @@ -1,19 +1,17 @@ import { STATUS_CODES } from 'http'; import { - CloudFrontRequestHandler, CloudFrontHeaders, - CloudFrontRequest, CloudFrontResultResponse, + CloudFrontRequestEvent, } from 'aws-lambda'; -import { - ProxyConfig, - HTTPHeaders, - ApiGatewayOriginProps, - RouteResult, -} from './types'; +import { ProxyConfig, HTTPHeaders, RouteResult } from './types'; import { Proxy } from './proxy'; -import { fetchTimeout } from './util/fetch-timeout'; +import { fetchProxyConfig } from './util/fetch-proxy-config'; +import { + createCustomOriginFromApiGateway, + createCustomOriginFromUrl, +} from './util/custom-origin'; let proxyConfig: ProxyConfig; let proxy: Proxy; @@ -31,15 +29,6 @@ function convertToCloudFrontHeaders( return cloudFrontHeaders; } -async function fetchProxyConfig(endpointUri: string) { - // Timeout the connection before 30000ms to be able to print an error message - // See Lambda@Edge Limits for origin-request event here: - // https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-requirements-limits.html#lambda-requirements-see-limits - return fetchTimeout(29500, endpointUri).then( - (res) => res.json() as Promise - ); -} - /** * Checks if a route result issued a redirect */ @@ -75,34 +64,7 @@ function isRedirect( return false; } -/** - * Modifies the request that it is served by API Gateway (Lambda) - */ -function serveFromApiGateway( - request: CloudFrontRequest, - apiEndpoint: string, - { path }: ApiGatewayOriginProps -) { - request.origin = { - custom: { - domainName: apiEndpoint, - path, - customHeaders: {}, - keepaliveTimeout: 5, - port: 443, - protocol: 'https', - readTimeout: 30, - sslProtocols: ['TLSv1.2'], - }, - }; - - // Set Host header to the apiEndpoint - return { - host: apiEndpoint, - }; -} - -export const handler: CloudFrontRequestHandler = async (event) => { +export async function handler(event: CloudFrontRequestEvent) { const { request } = event.Records[0].cf; const configEndpoint = request.origin!.s3!.customHeaders[ 'x-env-config-endpoint' @@ -134,9 +96,17 @@ export const handler: CloudFrontRequestHandler = async (event) => { // Check if we have a prerender route // Bypasses proxy if (request.uri in proxyConfig.prerenders) { - headers = serveFromApiGateway(request, apiEndpoint, { - path: `/${proxyConfig.prerenders[request.uri].lambda}`, - }); + // Modify request to be served from Api Gateway + const customOrigin = createCustomOriginFromApiGateway( + apiEndpoint, + `/${proxyConfig.prerenders[request.uri].lambda}` + ); + request.origin = { + custom: customOrigin, + }; + + // Modify `Host` header to match the external host + headers.host = apiEndpoint; } else { // Handle by proxy const proxyResult = proxy.route(requestPath); @@ -149,10 +119,38 @@ export const handler: CloudFrontRequestHandler = async (event) => { // Check if route is served by lambda if (proxyResult.target === 'lambda') { - headers = serveFromApiGateway(request, apiEndpoint, { - path: proxyResult.dest, - }); + // Modify request to be served from Api Gateway + const customOrigin = createCustomOriginFromApiGateway( + apiEndpoint, + proxyResult.dest + ); + request.origin = { + custom: customOrigin, + }; + // Modify `Host` header to match the external host + headers.host = apiEndpoint; + + // Append querystring if we have any + request.querystring = proxyResult.uri_args + ? proxyResult.uri_args.toString() + : ''; + } else if (proxyResult.target === 'url') { + // Modify request to be served from external host + const [customOrigin, destUrl] = createCustomOriginFromUrl( + proxyResult.dest + ); + request.origin = { + custom: customOrigin, + }; + + // Modify `Host` header to match the external host + headers.host = customOrigin.domainName; + + // Modify URI to match the path + request.uri = destUrl.pathname; + + // Append querystring if we have any request.querystring = proxyResult.uri_args ? proxyResult.uri_args.toString() : ''; @@ -177,4 +175,4 @@ export const handler: CloudFrontRequestHandler = async (event) => { } return request; -}; +} diff --git a/packages/proxy/src/proxy.ts b/packages/proxy/src/proxy.ts index 6a3e330f..8fc4e1cc 100644 --- a/packages/proxy/src/proxy.ts +++ b/packages/proxy/src/proxy.ts @@ -175,25 +175,8 @@ export class Proxy { isContinue = true; } - if (routeConfig.check && phase !== 'hit') { - if (!this.lambdaRoutes.has(destPath)) { - // When it is not a lambda route we cut the url_args - // for the next iteration - const nextUrl = parseUrl(destPath); - reqPathname = nextUrl.pathname!; - - // Check if we have a static route - if (!this.staticRoutes.has(reqPathname)) { - appendURLSearchParams(searchParams, nextUrl.searchParams); - continue; - } - } else { - target = 'lambda'; - } - } - + // Check for external rewrite const isDestUrl = isURL(destPath); - if (isDestUrl) { result = { found: true, @@ -207,7 +190,7 @@ export class Proxy { matched_route_idx: routeIndex, phase, headers: combinedHeaders, - target, + target: 'url', }; if (isContinue) { @@ -215,34 +198,51 @@ export class Proxy { } break; - } else { - if (!destPath.startsWith('/')) { - destPath = `/${destPath}`; - } + } - const destParsed = parseUrl(destPath); - appendURLSearchParams(searchParams, destParsed.searchParams); - result = { - found: true, - dest: destParsed.pathname || '/', - continue: isContinue, - userDest: Boolean(routeConfig.dest), - isDestUrl, - status: status, - uri_args: searchParams, - matched_route: routeConfig, - matched_route_idx: routeIndex, - phase, - headers: combinedHeaders, - target, - }; + if (routeConfig.check && phase !== 'hit') { + if (this.lambdaRoutes.has(destPath)) { + target = 'lambda'; + } else { + // When it is not a lambda route we cut the url_args + // for the next iteration + const nextUrl = parseUrl(destPath); + reqPathname = nextUrl.pathname!; - if (isContinue) { - continue; + // Check if we have a static route + if (!this.staticRoutes.has(reqPathname)) { + appendURLSearchParams(searchParams, nextUrl.searchParams); + continue; + } } + } - break; + if (!destPath.startsWith('/')) { + destPath = `/${destPath}`; + } + + const destParsed = parseUrl(destPath); + appendURLSearchParams(searchParams, destParsed.searchParams); + result = { + found: true, + dest: destParsed.pathname || '/', + continue: isContinue, + userDest: Boolean(routeConfig.dest), + isDestUrl, + status: status, + uri_args: searchParams, + matched_route: routeConfig, + matched_route_idx: routeIndex, + phase, + headers: combinedHeaders, + target, + }; + + if (isContinue) { + continue; } + + break; } } diff --git a/packages/proxy/src/types.ts b/packages/proxy/src/types.ts index f79e7dae..9e7fe7e9 100644 --- a/packages/proxy/src/types.ts +++ b/packages/proxy/src/types.ts @@ -13,8 +13,8 @@ export interface ProxyConfig { export interface RouteResult { // `true` if a route was matched, `false` otherwise found: boolean; - // if found this indicated wether it is a lambda or static file - target?: 'lambda' | 'filesystem'; + // if found this indicated wether it is a lambda or static file or an external URL + target?: 'lambda' | 'filesystem' | 'url'; // "dest": dest: string; // `true` if last route in current phase matched but set `continue: true` @@ -36,7 +36,3 @@ export interface RouteResult { // the phase that this route is defined in phase?: HandleValue | null; } - -export interface ApiGatewayOriginProps { - path: string; -} diff --git a/packages/proxy/src/util/custom-origin.ts b/packages/proxy/src/util/custom-origin.ts new file mode 100644 index 00000000..1c773a36 --- /dev/null +++ b/packages/proxy/src/util/custom-origin.ts @@ -0,0 +1,60 @@ +import { URL } from 'url'; +import { CloudFrontCustomOrigin } from 'aws-lambda'; + +/** + * Converts the input URL into a CloudFront custom origin + * @param url + * @returns + */ +export function createCustomOriginFromUrl( + url: string +): [CloudFrontCustomOrigin, URL] { + const _url = new URL(url); + + // Protocol + const protocol = _url.protocol === 'http:' ? 'http' : 'https'; + + // Get the correct port + const port = _url.port + ? parseInt(_url.port, 10) + : protocol === 'http' + ? 80 + : 443; + + return [ + { + domainName: _url.hostname, + path: '/', + customHeaders: {}, + keepaliveTimeout: 5, + port, + protocol, + readTimeout: 30, + sslProtocols: ['TLSv1.2'], + }, + _url, + ]; +} + +/** + * Modifies the request that it is served by API Gateway (Lambda) + * + * @param apiEndpoint + * @param path + * @returns + */ +export function createCustomOriginFromApiGateway( + apiEndpoint: string, + path: string +): CloudFrontCustomOrigin { + return { + domainName: apiEndpoint, + path, + customHeaders: {}, + keepaliveTimeout: 5, + port: 443, + protocol: 'https', + readTimeout: 30, + sslProtocols: ['TLSv1.2'], + }; +} diff --git a/packages/proxy/src/util/fetch-proxy-config.ts b/packages/proxy/src/util/fetch-proxy-config.ts new file mode 100644 index 00000000..9fe68158 --- /dev/null +++ b/packages/proxy/src/util/fetch-proxy-config.ts @@ -0,0 +1,18 @@ +import { fetchTimeout } from './fetch-timeout'; +import { ProxyConfig } from '../types'; + +// Timeout the connection before 30000ms to be able to print an error message +// See Lambda@Edge Limits for origin-request event here: +// https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-requirements-limits.html#lambda-requirements-see-limits +const FETCH_TIMEOUT = 29500; + +/** + * Retrieves and parses the config object for the proxy over HTTP. + * @param endpointUrl URL where the config should be fetched from + * @returns Parsed config object + */ +export function fetchProxyConfig(endpointUrl: string) { + return fetchTimeout(FETCH_TIMEOUT, endpointUrl).then( + (res) => res.json() as Promise + ); +} diff --git a/packages/proxy/src/util/fetch-timeout.ts b/packages/proxy/src/util/fetch-timeout.ts index 57835e7f..3928ce6d 100644 --- a/packages/proxy/src/util/fetch-timeout.ts +++ b/packages/proxy/src/util/fetch-timeout.ts @@ -1,19 +1,36 @@ -import fetch, { RequestInit } from 'node-fetch'; +import fetch, { Response } from 'node-fetch'; +import AbortController from 'abort-controller'; -// Fetch with timeout -// Promise.race: https://stackoverflow.com/a/49857905/831465 -export function fetchTimeout( - timeout: number, - url: string, - fetchOptions?: RequestInit -) { - return Promise.race([ - fetch(url, fetchOptions), - new Promise((_, reject) => - setTimeout( - () => reject(new Error(`Timeout while fetching config from ${url}`)), - timeout - ) - ), - ]); +/** + * Fetch with timeout + * @param timeout Timeout in milliseconds + * @param url + * @returns + */ +export async function fetchTimeout(timeout: number, url: string) { + const controller = new AbortController(); + const timeoutFunc = setTimeout(() => { + controller.abort(); + }, timeout); + + let error: Error | undefined; + let fetchResponse: Response | undefined; + + try { + fetchResponse = await fetch(url, { signal: controller.signal }); + } catch (err) { + if (err.name === 'AbortError') { + error = new Error(`Timeout while fetching config from ${url}`); + } else { + error = err; + } + } finally { + clearTimeout(timeoutFunc); + } + + if (error) { + throw error; + } + + return fetchResponse!; } diff --git a/test/fixtures/01-custom-routing/next.config.js b/test/fixtures/01-custom-routing/next.config.js index 13c4d601..3a78cbae 100644 --- a/test/fixtures/01-custom-routing/next.config.js +++ b/test/fixtures/01-custom-routing/next.config.js @@ -1,4 +1,14 @@ module.exports = { + async rewrites() { + return [ + // Rewriting to an external URL + { + source: '/docs/:slug', + destination: 'http://example.com/docs/:slug', + }, + ]; + }, + async redirects() { return [ { diff --git a/test/fixtures/01-custom-routing/probes.json b/test/fixtures/01-custom-routing/probes.json index 60664b7e..468e6198 100644 --- a/test/fixtures/01-custom-routing/probes.json +++ b/test/fixtures/01-custom-routing/probes.json @@ -1,6 +1,10 @@ { "builds": [{ "src": "package.json", "use": "tf-next" }], "probes": [ + { + "path": "/docs/hello-world", + "destPath": "http://example.com/docs/hello-world" + }, { "path": "/redir1", "status": 308, diff --git a/test/routes.test.ts b/test/routes.test.ts index 804bd90e..87aa79d6 100644 --- a/test/routes.test.ts +++ b/test/routes.test.ts @@ -1,5 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; +import { URL } from 'url'; import { parse as parseJSON } from 'hjson'; import { ConfigOutput } from 'tf-next/src/types'; import { CloudFrontResultResponse } from 'aws-lambda'; @@ -22,6 +23,7 @@ interface ProbeFile { status?: number; statusDescription?: string; responseHeaders?: Record; + destPath?: string; }[]; } @@ -33,6 +35,7 @@ describe('Test proxy config', () => { let probeFile: ProbeFile; let lambdaSAM: LambdaSAM; let proxySAM: ProxySAM; + let samEndpoint: string; beforeAll(async () => { // Get the config @@ -72,7 +75,7 @@ describe('Test proxy config', () => { console.log(data.toString()); }, }); - await lambdaSAM.start(); + samEndpoint = await lambdaSAM.start(); // Generate SAM for Proxy (Lambda@Edge) const proxyConfig = { @@ -112,41 +115,60 @@ describe('Test proxy config', () => { if ('origin' in Request) { // Request if (Request.origin?.custom) { - // Request should be served by lambda (SSR) - const basePath = Request.origin.custom.path; - const { uri, querystring } = Request; - - // Merge request headers and custom headers from origin - const headers = { - ...normalizeCloudFrontHeaders(Request.headers), - ...normalizeCloudFrontHeaders( - Request.origin.custom.customHeaders - ), - }; - const requestPath = `${basePath}${uri}${ - querystring !== '' ? `?${querystring}` : '' - }`; - - const lambdaResponse = await lambdaSAM - .sendApiGwRequest(requestPath, { - headers, - }) - .then((res) => { - const headers = res.headers; - - return res.text(); - }) - .then((text) => { - // If text is already JSON we dont need to parse base64 - if (text.startsWith('{')) { - return text; + if (samEndpoint.includes(Request.origin.custom.domainName)) { + // Request should be served by lambda (SSR) + const basePath = Request.origin.custom.path; + const { uri, querystring } = Request; + + // Merge request headers and custom headers from origin + const headers = { + ...normalizeCloudFrontHeaders(Request.headers), + ...normalizeCloudFrontHeaders( + Request.origin.custom.customHeaders + ), + }; + const requestPath = `${basePath}${uri}${ + querystring !== '' ? `?${querystring}` : '' + }`; + + const lambdaResponse = await lambdaSAM + .sendApiGwRequest(requestPath, { + headers, + }) + .then((res) => { + const headers = res.headers; + + return res.text(); + }) + .then((text) => { + // If text is already JSON we dont need to parse base64 + if (text.startsWith('{')) { + return text; + } + + return Buffer.from(text, 'base64').toString('utf-8'); + }); + + if (probe.mustContain) { + expect(lambdaResponse).toContain(probe.mustContain); + } + } else { + // Request is an external rewrite + if (probe.destPath) { + const { custom: customOrigin } = Request.origin; + const originRequest = new URL( + `${customOrigin.protocol}://${customOrigin.domainName}${ + Request.uri + }${Request.querystring ? `?${Request.querystring}` : ''}` + ); + + // Check for custom ports + if (customOrigin.port !== 80 && customOrigin.port !== 443) { + originRequest.port = customOrigin.port.toString(); } - return Buffer.from(text, 'base64').toString('utf-8'); - }); - - if (probe.mustContain) { - expect(lambdaResponse).toContain(probe.mustContain); + expect(originRequest).toEqual(new URL(probe.destPath)); + } } } else if (Request.origin?.s3) { // Request should be served by static file system (S3) diff --git a/yarn.lock b/yarn.lock index 24543387..6f097e22 100644 --- a/yarn.lock +++ b/yarn.lock @@ -387,10 +387,10 @@ exec-sh "^0.3.2" minimist "^1.2.0" -"@dealmore/sammy@^1.6.0": - version "1.6.0" - resolved "https://registry.yarnpkg.com/@dealmore/sammy/-/sammy-1.6.0.tgz#10c9c9fac88cbb62f3ea996272d0302140e9d2b8" - integrity sha512-8k7bWAfp1tC6ohG29HZdcbZ7J83gnCr3Bc5yjSyI81r6tlaArRFFfj3Gi5Ja6BlDSst04/C+t1oeWYqlYfANjg== +"@dealmore/sammy@^1.6.1": + version "1.6.1" + resolved "https://registry.yarnpkg.com/@dealmore/sammy/-/sammy-1.6.1.tgz#b5340a838b6220bde3d3a137d57c7a82ddedf365" + integrity sha512-KPt+yIt/A9IJ/TT41GvI5D2v2IOlK+hZElB20a5dnQX0F43vvNp8fg4rPJEh4ZrtOyYB2g1+cO5ontdd+0lkIg== dependencies: aws-sdk "^2.804.0" change-case "^4.1.2" @@ -697,6 +697,11 @@ resolved "https://registry.yarnpkg.com/@types/aws-lambda/-/aws-lambda-8.10.56.tgz#24fc61bf628db86412bb4f28da051df4baa532d6" integrity sha512-jaxu5br/KYxhNBNmr2GoVhIUady2zNsvSRCa4kCHW+GcM4ladPhfEyeJkkNMGo/IlVAfpcPYTsSzhYWZoSgZXA== +"@types/aws-lambda@^8.10.76": + version "8.10.76" + resolved "https://registry.yarnpkg.com/@types/aws-lambda/-/aws-lambda-8.10.76.tgz#a20191677f1f5e32fe1f26739b1d6fbbea9cf636" + integrity sha512-lCTyeRm3NWqSwDnoji0z82Pl0tsOpr1p+33AiNeidgarloWXh3wdiVRUuxEa+sY9S5YLOYGz5X3N3Zvpibvm5w== + "@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7": version "7.1.9" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.9.tgz#77e59d438522a6fb898fa43dc3455c6e72f3963d" @@ -817,7 +822,7 @@ dependencies: "@types/istanbul-lib-report" "*" -"@types/jest@26.x", "@types/jest@^26.0.19": +"@types/jest@^26.0.19": version "26.0.19" resolved "https://registry.yarnpkg.com/@types/jest/-/jest-26.0.19.tgz#e6fa1e3def5842ec85045bd5210e9bb8289de790" integrity sha512-jqHoirTG61fee6v6rwbnEuKhpSKih0tuhqeFbCmMmErhtu3BYlOZaXWjffgOstMM4S/3iQD31lI5bGLTrs97yQ== @@ -857,6 +862,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-15.0.1.tgz#ef34dea0881028d11398be5bf4e856743e3dc35a" integrity sha512-TMkXt0Ck1y0KKsGr9gJtWGjttxlZnnvDtphxUOSd0bfaR6Q1jle+sPvrzNR1urqYTWMinoKvjKfXUGsumaO1PA== +"@types/node@^12.0.0": + version "12.20.11" + resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.11.tgz#980832cd56efafff8c18aa148c4085eb02a483f4" + integrity sha512-gema+apZ6qLQK7k7F0dGkGCWQYsL0qqKORWOQO6tq46q+x+1C0vbOiOqOwRVlh4RAdbQwV/j/ryr3u5NOG1fPQ== + "@types/node@^14.0.0": version "14.14.35" resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.35.tgz#42c953a4e2b18ab931f72477e7012172f4ffa313" @@ -1047,6 +1057,13 @@ abbrev@1: resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + acorn-class-fields@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/acorn-class-fields/-/acorn-class-fields-1.0.0.tgz#b413793e6b3ddfcd17a02f9c7a850f4bbfdc1c7a" @@ -1747,9 +1764,9 @@ camelcase@^6.0.0: integrity sha512-8KMDF1Vz2gzOq54ONPJS65IvTUaB1cHJ2DMM7MbPmLZljDH1qpzzLsWdiN9pHh6qvkRVDTi/07+eNGch/oLU4w== caniuse-lite@^1.0.30001173, caniuse-lite@^1.0.30001179, caniuse-lite@^1.0.30001202: - version "1.0.30001219" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001219.tgz#5bfa5d0519f41f993618bd318f606a4c4c16156b" - integrity sha512-c0yixVG4v9KBc/tQ2rlbB3A/bgBFRvl8h8M4IeUbqCca4gsiCfvtaheUssbnux/Mb66Vjz7x8yYjDgYcNQOhyQ== + version "1.0.30001220" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001220.tgz#c080e1c8eefb99f6cc9685da6313840bdbaf4c36" + integrity sha512-pjC2T4DIDyGAKTL4dMvGUQaMUHRmhvPpAgNNTa14jaBWHu+bLQgvpFqElxh9L4829Fdx0PlKiMp3wnYldRtECA== capital-case@^1.0.4: version "1.0.4" @@ -2333,9 +2350,9 @@ ecc-jsbn@~0.1.1: safer-buffer "^2.1.0" electron-to-chromium@^1.3.634: - version "1.3.723" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.723.tgz#52769a75635342a4db29af5f1e40bd3dad02c877" - integrity sha512-L+WXyXI7c7+G1V8ANzRsPI5giiimLAUDC6Zs1ojHHPhYXb3k/iTABFmWjivEtsWrRQymjnO66/rO2ZTABGdmWg== + version "1.3.725" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.725.tgz#04fc83f9189169aff50f0a00c6b4090b910cba85" + integrity sha512-2BbeAESz7kc6KBzs7WVrMc1BY5waUphk4D4DX5dSQXJhsc3tP5ZFaiyuL0AB7vUKzDYpIeYwTYlEfxyjsGUrhw== elliptic@^6.5.3: version "6.5.4" @@ -2474,6 +2491,11 @@ etag@1.8.1: resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + events@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924" @@ -4122,11 +4144,6 @@ lodash.isplainobject@^4.0.6: resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs= -lodash.memoize@4.x: - version "4.1.2" - resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" - integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4= - lodash.sortby@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" @@ -4137,7 +4154,7 @@ lodash.union@^4.6.0: resolved "https://registry.yarnpkg.com/lodash.union/-/lodash.union-4.6.0.tgz#48bb5088409f16f1821666641c44dd1aaae3cd88" integrity sha1-SLtQiECfFvGCFmZkHETdGqrjzYg= -lodash@^4.17.13: +lodash@4.x, lodash@^4.17.13: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -6066,18 +6083,17 @@ tr46@^2.0.2: resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.3.9.tgz#717b8f220cc0bb7b44e40514c22b2e8bbc70d8b9" integrity sha1-cXuPIgzAu3tE5AUUwisui7xw2Lk= -ts-jest@^26.4.4: - version "26.4.4" - resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-26.4.4.tgz#61f13fb21ab400853c532270e52cc0ed7e502c49" - integrity sha512-3lFWKbLxJm34QxyVNNCgXX1u4o/RV0myvA2y2Bxm46iGIjKlaY0own9gIckbjZJPn+WaJEnfPPJ20HHGpoq4yg== +ts-jest@^26.5.5: + version "26.5.5" + resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-26.5.5.tgz#e40481b6ee4dd162626ba481a2be05fa57160ea5" + integrity sha512-7tP4m+silwt1NHqzNRAPjW1BswnAhopTdc2K3HEkRZjF0ZG2F/e/ypVH0xiZIMfItFtD3CX0XFbwPzp9fIEUVg== dependencies: - "@types/jest" "26.x" bs-logger "0.x" buffer-from "1.x" fast-json-stable-stringify "2.x" jest-util "^26.1.0" json5 "2.x" - lodash.memoize "4.x" + lodash "4.x" make-error "1.x" mkdirp "1.x" semver "7.x"