diff --git a/packages/proxy/src/handler.ts b/packages/proxy/src/handler.ts index 1a7b206f..9a99cecd 100644 --- a/packages/proxy/src/handler.ts +++ b/packages/proxy/src/handler.ts @@ -1,4 +1,5 @@ import { STATUS_CODES } from 'http'; + import { CloudFrontHeaders, CloudFrontResultResponse, @@ -6,16 +7,17 @@ import { CloudFrontRequest, } from 'aws-lambda'; +import { appendQuerystring } from './util/append-querystring'; import { fetchProxyConfig } from './util/fetch-proxy-config'; import { generateCloudFrontHeaders } from './util/generate-cloudfront-headers'; -import { ProxyConfig, RouteResult } from './types'; -import { Proxy } from './proxy'; import { createCustomOriginFromApiGateway, createCustomOriginFromUrl, serveRequestFromCustomOrigin, serveRequestFromS3Origin, } from './util/custom-origin'; +import { Proxy } from './proxy'; +import { ProxyConfig, RouteResult } from './types'; let proxyConfig: ProxyConfig; let proxy: Proxy; @@ -31,23 +33,38 @@ function isRedirect( routeResult.status >= 300 && routeResult.status <= 309 ) { - if ('Location' in routeResult.headers) { - let headers: CloudFrontHeaders = {}; - - // If the redirect is permanent, cache the result - if (routeResult.status === 301 || routeResult.status === 308) { - headers['cache-control'] = [ + const redirectTarget = routeResult.headers['Location']; + if (redirectTarget) { + // Append the original querystring to the redirect + const redirectTargetWithQuerystring = routeResult.uri_args + ? appendQuerystring(redirectTarget, routeResult.uri_args) + : redirectTarget; + + // Override the Location header value with the appended querystring + routeResult.headers['Location'] = redirectTargetWithQuerystring; + + // Redirects are not cached, see discussion for details: + // https://github.com/milliHQ/terraform-aws-next-js/issues/296 + const initialHeaders: CloudFrontHeaders = { + 'cache-control': [ { key: 'Cache-Control', - value: 'public,max-age=31536000,immutable', + value: 'public, max-age=0, must-revalidate', + }, + ], + 'content-type': [ + { + key: 'Content-Type', + value: 'text/plain', }, - ]; - } + ], + }; return { status: routeResult.status.toString(), statusDescription: STATUS_CODES[routeResult.status], - headers: generateCloudFrontHeaders(headers, routeResult.headers), + headers: generateCloudFrontHeaders(initialHeaders, routeResult.headers), + body: `Redirecting to ${redirectTargetWithQuerystring} (${routeResult.status})`, }; } } @@ -77,7 +94,6 @@ async function main( ][0].value; const apiEndpoint = request.origin!.s3!.customHeaders['x-env-api-endpoint'][0] .value; - let headers: Record = {}; try { if (!proxyConfig) { @@ -107,9 +123,10 @@ async function main( // Append query string if we have one // @see: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-event-structure.html - const requestPath = `${request.uri}${ - request.querystring !== '' ? `?${request.querystring}` : '' - }`; + const requestPath = + request.querystring !== '' + ? `${request.uri}?${request.querystring}` + : request.uri; const proxyResult = proxy.route(requestPath); // Check for redirect diff --git a/packages/proxy/src/util/append-querystring.ts b/packages/proxy/src/util/append-querystring.ts new file mode 100644 index 00000000..7a0d8cac --- /dev/null +++ b/packages/proxy/src/util/append-querystring.ts @@ -0,0 +1,31 @@ +import { URL, URLSearchParams } from 'url'; + +import isURL from './is-url'; + +/** + * Append a querystring to a relative or absolute URL. + * Already existing searchParams are not overridden by the new searchParams. + * + * @param url The relative or absolute URL + * @param searchParams The searchParams that should be merged with the input URL + */ +function appendQuerystring(url: string, searchParams: URLSearchParams): string { + const urlObj = new URL(url, 'https://n'); + const combinedSearchParams = new URLSearchParams({ + ...Object.fromEntries(searchParams), + ...Object.fromEntries(urlObj.searchParams), + }).toString(); + + if (combinedSearchParams == '') { + return url; + } + + if (isURL(url)) { + urlObj.search = ''; + return `${urlObj.toString()}?${combinedSearchParams}`; + } + + return `${urlObj.pathname}?${combinedSearchParams}`; +} + +export { appendQuerystring }; diff --git a/packages/proxy/test/handler.test.ts b/packages/proxy/test/handler.test.ts index 5b089d0c..9781a9e8 100644 --- a/packages/proxy/test/handler.test.ts +++ b/packages/proxy/test/handler.test.ts @@ -1,11 +1,10 @@ import { createServer, Server } from 'http'; -import { CloudFrontRequestEvent, CloudFrontRequest } from 'aws-lambda'; + +import { CloudFrontRequest } from 'aws-lambda'; import getPort from 'get-port'; import { ProxyConfig } from '../src/types'; - -// Max runtime of the lambda -const TIMEOUT = 30000; +import { generateCloudFrontRequestEvent } from './test-utils'; class ConfigServer { public proxyConfig?: ProxyConfig; @@ -58,1175 +57,695 @@ describe('[proxy] Handler', () => { 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; + 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; + + const cloudFrontEvent = generateCloudFrontRequestEvent({ + configEndpoint, + uri: requestPath, + }); + const result = (await handler(cloudFrontEvent)) 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', + }, + ]) + ); + }); - 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; + const cloudFrontEvent = generateCloudFrontRequestEvent({ + configEndpoint, + uri: requestPath, + }); + const result = (await handler(cloudFrontEvent)) 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', + }, + ]) + ); + }); - 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'; + 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; + const cloudFrontEvent = generateCloudFrontRequestEvent({ + configEndpoint, + uri: requestPath, + }); + const result = (await handler(cloudFrontEvent)) 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', + }, + ]) + ); + }); - // Prepare configServer - configServer.proxyConfig = proxyConfig; + 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; + const cloudFrontEvent = generateCloudFrontRequestEvent({ + configEndpoint, + uri: requestPath, + }); + const result = (await handler(cloudFrontEvent)) 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', + }, + ]) + ); + }); - // 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, - }, + test('i18n default locale rewrite', async () => { + const proxyConfig: ProxyConfig = { + lambdaRoutes: [], + prerenders: {}, + staticRoutes: [], + routes: [ + { + src: '^/(?!(?:_next/.*|en|fr\\-FR|nl)(?:/.*|$))(.*)$', + dest: '$wildcard/$1', + continue: true, + }, + { + src: '/', + locale: { + redirect: { + en: '/', + 'fr-FR': '/fr-FR', + nl: '/nl', }, - }, - ], - }; + cookie: 'NEXT_LOCALE', + }, + continue: true, + }, + { + src: '^/$', + dest: '/en', + continue: true, + }, + ], + }; + const requestPath = '/'; + + // Prepare configServer + configServer.proxyConfig = proxyConfig; + const cloudFrontEvent = generateCloudFrontRequestEvent({ + configEndpoint, + uri: requestPath, + }); + const result = (await handler(cloudFrontEvent)) as CloudFrontRequest; + + expect(result.origin?.s3).toEqual( + expect.objectContaining({ + domainName: 's3.localhost', + path: '', + }) + ); + expect(result.uri).toBe('/en'); + }); - const result = (await handler(event)) as CloudFrontRequest; + test('Correctly request /index object from S3 when requesting /', async () => { + const proxyConfig: ProxyConfig = { + staticRoutes: ['/404', '/500', '/index'], + lambdaRoutes: [], + routes: [ + { + src: '^(?:\\/((?:[^\\/]+?)(?:\\/(?:[^\\/]+?))*))\\/$', + headers: { + Location: '/$1', + }, + status: 308, + continue: true, + }, + { + src: '/404', + status: 404, + continue: true, + }, + { + handle: 'filesystem', + }, + { + handle: 'resource', + }, + { + src: '/.*', + status: 404, + }, + { + handle: 'miss', + }, + { + handle: 'rewrite', + }, + { + handle: 'hit', + }, + { + handle: 'error', + }, + { + src: '/.*', + dest: '/404', + status: 404, + }, + ], + prerenders: {}, + }; + + const requestPath = '/'; + + // Prepare configServer + configServer.proxyConfig = proxyConfig; + const cloudFrontEvent = generateCloudFrontRequestEvent({ + configEndpoint, + uri: requestPath, + }); + const result = (await handler(cloudFrontEvent)) as CloudFrontRequest; + + expect(result.origin?.s3).toEqual( + expect.objectContaining({ + domainName: 's3.localhost', + path: '', + }) + ); + expect(result.uri).toBe('/index'); + }); - 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('Add x-forwarded-host header to API-Gateway requests', async () => { + const hostHeader = 'example.org'; + const proxyConfig: ProxyConfig = { + lambdaRoutes: ['/__NEXT_API_LAMBDA_0'], + prerenders: {}, + staticRoutes: [], + routes: [ + { + src: '^(?:\\/((?:[^\\/]+?)(?:\\/(?:[^\\/]+?))*))\\/$', + headers: { + Location: '/$1', + }, + status: 308, + continue: true, + }, + { + src: '/404', + status: 404, + continue: true, + }, + { + handle: 'filesystem', + }, + { + src: '^/api/test/?$', + dest: '/__NEXT_API_LAMBDA_0', + headers: { + 'x-nextjs-page': '/api/test', + }, + check: true, + }, + { + handle: 'resource', + }, + { + src: '/.*', + status: 404, + }, + { + handle: 'miss', + }, + { + handle: 'rewrite', + }, + { + src: '^/api/test/?$', + dest: '/__NEXT_API_LAMBDA_0', + headers: { + 'x-nextjs-page': '/api/test', + }, + check: true, + }, + { + handle: 'hit', + }, + { + handle: 'error', + }, + { + src: '/.*', + dest: '/404', + status: 404, + }, + ], + }; + const requestPath = '/api/test'; + + // Prepare configServer + configServer.proxyConfig = proxyConfig; + const cloudFrontEvent = generateCloudFrontRequestEvent({ + apiGatewayEndpoint: 'api-gateway.local', + configEndpoint, + uri: requestPath, + }); + const result = (await handler(cloudFrontEvent)) as CloudFrontRequest; - test( - 'External redirect [Custom Port]', - async () => { - const proxyConfig: ProxyConfig = { - lambdaRoutes: [], - prerenders: {}, - staticRoutes: [], - routes: [ + expect(result.origin?.custom).toEqual( + expect.objectContaining({ + domainName: 'api-gateway.local', + path: '/__NEXT_API_LAMBDA_0', + }) + ); + expect(result.headers).toEqual( + expect.objectContaining({ + 'x-nextjs-page': [ { - src: '^\\/docs(?:\\/([^\\/]+?))$', - dest: 'https://example.com:666/docs/$1', - check: true, + key: 'x-nextjs-page', + value: '/api/test', }, ], - }; - 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: [ + 'x-forwarded-host': [ { - 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, - }, - }, + key: 'X-Forwarded-Host', + value: hostHeader, }, ], - }; + }) + ); + }); - const result = (await handler(event)) as CloudFrontRequest; + // Related to issue: https://github.com/milliHQ/terraform-aws-next-js/issues/218 + test('Dynamic routes with dynamic part in directory', async () => { + const proxyConfig: ProxyConfig = { + lambdaRoutes: ['/__NEXT_API_LAMBDA_0', '/__NEXT_PAGE_LAMBDA_0'], + prerenders: {}, + staticRoutes: [ + '/404', + '/500', + '/favicon.ico', + '/about', + '/users/[user_id]', + ], + routes: [ + { + src: '^(?:\\/((?:[^\\/]+?)(?:\\/(?:[^\\/]+?))*))\\/$', + headers: { + Location: '/$1', + }, + status: 308, + continue: true, + }, + { + src: '^\\/blog(?:\\/((?:[^\\/]+?)(?:\\/(?:[^\\/]+?))*))?$', + headers: { + Location: '/test/$1', + }, + status: 308, + }, + { + src: '/404', + status: 404, + continue: true, + }, + { + handle: 'filesystem', + }, + { + src: '^/api/robots/?$', + dest: '/__NEXT_API_LAMBDA_0', + headers: { + 'x-nextjs-page': '/api/robots', + }, + check: true, + }, + { + src: '^(/|/index|)/?$', + dest: '/__NEXT_PAGE_LAMBDA_0', + headers: { + 'x-nextjs-page': '/index', + }, + check: true, + }, + { + src: '^\\/robots\\.txt$', + dest: '/api/robots', + check: true, + }, + { + handle: 'resource', + }, + { + src: '/.*', + status: 404, + }, + { + handle: 'miss', + }, + { + handle: 'rewrite', + }, + { + src: '^/_next/data/oniBm2oZ9GXevuUEdEG44/index.json$', + dest: '/', + check: true, + }, + { + src: '^/_next/data/oniBm2oZ9GXevuUEdEG44/test/(?.+?)\\.json$', + dest: '/test/[...slug]?slug=$slug', + check: true, + }, + { + src: '^/test/\\[\\.\\.\\.slug\\]/?$', + dest: '/__NEXT_PAGE_LAMBDA_0', + headers: { + 'x-nextjs-page': '/test/[...slug]', + }, + check: true, + }, + { + src: '^/api/robots/?$', + dest: '/__NEXT_API_LAMBDA_0', + headers: { + 'x-nextjs-page': '/api/robots', + }, + check: true, + }, + { + src: '^(/|/index|)/?$', + dest: '/__NEXT_PAGE_LAMBDA_0', + headers: { + 'x-nextjs-page': '/index', + }, + check: true, + }, + { + src: '^/test/(?.+?)(?:/)?$', + dest: '/test/[...slug]?slug=$slug', + check: true, + }, + { + src: '^/test/\\[\\.\\.\\.slug\\]/?$', + dest: '/__NEXT_PAGE_LAMBDA_0', + headers: { + 'x-nextjs-page': '/test/[...slug]', + }, + check: true, + }, + { + src: '^/users/(?[^/]+?)(?:/)?$', + dest: '/users/[user_id]?user_id=$user_id', + check: true, + }, + { + handle: 'hit', + }, + { + handle: 'error', + }, + { + src: '/.*', + dest: '/404', + status: 404, + }, + ], + }; + const requestPath = '/users/432'; + + // Prepare configServer + configServer.proxyConfig = proxyConfig; + const cloudFrontEvent = generateCloudFrontRequestEvent({ + configEndpoint, + uri: requestPath, + }); + const result = (await handler(cloudFrontEvent)) as CloudFrontRequest; + + expect(result.origin?.s3).toEqual( + expect.objectContaining({ + domainName: 's3.localhost', + path: '', + }) + ); + expect(result.uri).toBe('/users/[user_id]'); + }); - expect(result.origin?.custom).toEqual( + test('Redirects with querystring', async () => { + const proxyConfig: ProxyConfig = { + lambdaRoutes: [], + prerenders: {}, + staticRoutes: [], + routes: [ + { + src: '^(?:\\/((?:[^\\/]+?)(?:\\/(?:[^\\/]+?))*))\\/$', + headers: { + Location: '/$1', + }, + status: 308, + continue: true, + }, + { + src: '^\\/one$', + headers: { + Location: '/newplace', + }, + status: 308, + }, + { + src: '^\\/two(?:\\/((?:[^\\/]+?)(?:\\/(?:[^\\/]+?))*))?$', + headers: { + Location: '/newplacetwo/$1', + }, + status: 308, + }, + { + src: '^\\/three$', + headers: { + Location: '/newplace?foo=bar', + }, + status: 308, + }, + { + src: '^\\/four$', + headers: { + Location: 'https://example.com', + }, + status: 308, + }, + ], + }; + configServer.proxyConfig = proxyConfig; + + { + // Remove trailing slash + // /test/?foo=bar -> /test?foo=bar + const cloudFrontEvent = generateCloudFrontRequestEvent({ + configEndpoint, + uri: '/test/', + querystring: 'foo=bar', + }); + const result = (await handler(cloudFrontEvent)) as CloudFrontRequest; + expect(result.headers).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, - }, + location: [ + { + key: 'Location', + value: '/test?foo=bar', }, - }, - ], - }; - - 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 - ); - - test( - 'i18n default locale rewrite', - async () => { - const proxyConfig: ProxyConfig = { - lambdaRoutes: [], - prerenders: {}, - staticRoutes: [], - routes: [ - { - src: '^/(?!(?:_next/.*|en|fr\\-FR|nl)(?:/.*|$))(.*)$', - dest: '$wildcard/$1', - continue: true, - }, - { - src: '/', - locale: { - redirect: { - en: '/', - 'fr-FR': '/fr-FR', - nl: '/nl', - }, - cookie: 'NEXT_LOCALE', - }, - continue: true, - }, - { - src: '^/$', - dest: '/en', - continue: true, - }, - ], - }; - const requestPath = '/'; - - // 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?.s3).toEqual( + { + // Relative route replace + // /one?foo=bar -> /newplace?foo=bar + const cloudFrontEvent = generateCloudFrontRequestEvent({ + configEndpoint, + uri: '/one', + querystring: 'foo=bar', + }); + const result = (await handler(cloudFrontEvent)) as CloudFrontRequest; + expect(result.headers).toEqual( expect.objectContaining({ - domainName: 's3.localhost', - path: '', - }) - ); - expect(result.uri).toBe('/en'); - }, - TIMEOUT - ); - - test( - 'Correctly request /index object from S3 when requesting /', - async () => { - const proxyConfig: ProxyConfig = { - staticRoutes: ['/404', '/500', '/index'], - lambdaRoutes: [], - routes: [ - { - src: '^(?:\\/((?:[^\\/]+?)(?:\\/(?:[^\\/]+?))*))\\/$', - headers: { - Location: '/$1', - }, - status: 308, - continue: true, - }, - { - src: '/404', - status: 404, - continue: true, - }, - { - handle: 'filesystem', - }, - { - handle: 'resource', - }, - { - src: '/.*', - status: 404, - }, - { - handle: 'miss', - }, - { - handle: 'rewrite', - }, - { - handle: 'hit', - }, - { - handle: 'error', - }, - { - src: '/.*', - dest: '/404', - status: 404, - }, - ], - prerenders: {}, - }; - - const requestPath = '/'; - - // 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, - }, + location: [ + { + key: 'Location', + value: '/newplace?foo=bar', }, - }, - ], - }; - - const result = (await handler(event)) as CloudFrontRequest; - - expect(result.origin?.s3).toEqual( - expect.objectContaining({ - domainName: 's3.localhost', - path: '', + ], }) ); - expect(result.uri).toBe('/index'); - }, - TIMEOUT - ); - - test( - 'Add x-forwarded-host header to API-Gateway requests', - async () => { - const hostHeader = 'example.org'; - const proxyConfig: ProxyConfig = { - lambdaRoutes: ['/__NEXT_API_LAMBDA_0'], - prerenders: {}, - staticRoutes: [], - routes: [ - { - src: '^(?:\\/((?:[^\\/]+?)(?:\\/(?:[^\\/]+?))*))\\/$', - headers: { - Location: '/$1', - }, - status: 308, - continue: true, - }, - { - src: '/404', - status: 404, - continue: true, - }, - { - handle: 'filesystem', - }, - { - src: '^/api/test/?$', - dest: '/__NEXT_API_LAMBDA_0', - headers: { - 'x-nextjs-page': '/api/test', - }, - check: true, - }, - { - handle: 'resource', - }, - { - src: '/.*', - status: 404, - }, - { - handle: 'miss', - }, - { - handle: 'rewrite', - }, - { - src: '^/api/test/?$', - dest: '/__NEXT_API_LAMBDA_0', - headers: { - 'x-nextjs-page': '/api/test', - }, - check: true, - }, - { - handle: 'hit', - }, - { - handle: 'error', - }, - { - src: '/.*', - dest: '/404', - status: 404, - }, - ], - }; - const requestPath = '/api/test'; - - // 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: hostHeader, - }, - ], - '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: 'api-gateway.local', - }, - ], - }, - 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: 'api-gateway.local', - path: '/__NEXT_API_LAMBDA_0', - }) - ); + { + // Relative route partial replace + // /two/some/path?foo=bar -> /newplace/some/path?foo=bar + const cloudFrontEvent = generateCloudFrontRequestEvent({ + configEndpoint, + uri: '/two/some/path', + querystring: 'foo=bar', + }); + const result = (await handler(cloudFrontEvent)) as CloudFrontRequest; expect(result.headers).toEqual( expect.objectContaining({ - 'x-nextjs-page': [ + location: [ { - key: 'x-nextjs-page', - value: '/api/test', + key: 'Location', + value: '/newplacetwo/some/path?foo=bar', }, ], - 'x-forwarded-host': [ + }) + ); + } + + { + // Try to override predefined param + // /three?foo=badValue -> /newplace?foo=bar + const cloudFrontEvent = generateCloudFrontRequestEvent({ + configEndpoint, + uri: '/three', + querystring: 'foo=badValue', + }); + const result = (await handler(cloudFrontEvent)) as CloudFrontRequest; + expect(result.headers).toEqual( + expect.objectContaining({ + location: [ { - key: 'X-Forwarded-Host', - value: hostHeader, + key: 'Location', + value: '/newplace?foo=bar', }, ], }) ); - }, - TIMEOUT - ); - - // Related to issue: https://github.com/milliHQ/terraform-aws-next-js/issues/218 - test( - 'Dynamic routes with dynamic part in directory', - async () => { - const proxyConfig: ProxyConfig = { - lambdaRoutes: ['/__NEXT_API_LAMBDA_0', '/__NEXT_PAGE_LAMBDA_0'], - prerenders: {}, - staticRoutes: [ - '/404', - '/500', - '/favicon.ico', - '/about', - '/users/[user_id]', - ], - routes: [ - { - src: '^(?:\\/((?:[^\\/]+?)(?:\\/(?:[^\\/]+?))*))\\/$', - headers: { - Location: '/$1', - }, - status: 308, - continue: true, - }, - { - src: '^\\/blog(?:\\/((?:[^\\/]+?)(?:\\/(?:[^\\/]+?))*))?$', - headers: { - Location: '/test/$1', - }, - status: 308, - }, - { - src: '/404', - status: 404, - continue: true, - }, - { - handle: 'filesystem', - }, - { - src: '^/api/robots/?$', - dest: '/__NEXT_API_LAMBDA_0', - headers: { - 'x-nextjs-page': '/api/robots', - }, - check: true, - }, - { - src: '^(/|/index|)/?$', - dest: '/__NEXT_PAGE_LAMBDA_0', - headers: { - 'x-nextjs-page': '/index', - }, - check: true, - }, - { - src: '^\\/robots\\.txt$', - dest: '/api/robots', - check: true, - }, - { - handle: 'resource', - }, - { - src: '/.*', - status: 404, - }, - { - handle: 'miss', - }, - { - handle: 'rewrite', - }, - { - src: '^/_next/data/oniBm2oZ9GXevuUEdEG44/index.json$', - dest: '/', - check: true, - }, - { - src: '^/_next/data/oniBm2oZ9GXevuUEdEG44/test/(?.+?)\\.json$', - dest: '/test/[...slug]?slug=$slug', - check: true, - }, - { - src: '^/test/\\[\\.\\.\\.slug\\]/?$', - dest: '/__NEXT_PAGE_LAMBDA_0', - headers: { - 'x-nextjs-page': '/test/[...slug]', - }, - check: true, - }, - { - src: '^/api/robots/?$', - dest: '/__NEXT_API_LAMBDA_0', - headers: { - 'x-nextjs-page': '/api/robots', - }, - check: true, - }, - { - src: '^(/|/index|)/?$', - dest: '/__NEXT_PAGE_LAMBDA_0', - headers: { - 'x-nextjs-page': '/index', - }, - check: true, - }, - { - src: '^/test/(?.+?)(?:/)?$', - dest: '/test/[...slug]?slug=$slug', - check: true, - }, - { - src: '^/test/\\[\\.\\.\\.slug\\]/?$', - dest: '/__NEXT_PAGE_LAMBDA_0', - headers: { - 'x-nextjs-page': '/test/[...slug]', - }, - check: true, - }, - { - src: '^/users/(?[^/]+?)(?:/)?$', - dest: '/users/[user_id]?user_id=$user_id', - check: true, - }, - { - handle: 'hit', - }, - { - handle: 'error', - }, - { - src: '/.*', - dest: '/404', - status: 404, - }, - ], - }; - const requestPath = '/users/432'; - - // 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)', - }, - ], - '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: 'api-gateway.local', - }, - ], - }, - 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?.s3).toEqual( + { + // Redirect to external URL + // /four?foo=bar -> https://example.com?foo=bar + const cloudFrontEvent = generateCloudFrontRequestEvent({ + configEndpoint, + uri: '/four', + querystring: 'foo=bar', + }); + const result = (await handler(cloudFrontEvent)) as CloudFrontRequest; + expect(result.headers).toEqual( expect.objectContaining({ - domainName: 's3.localhost', - path: '', + location: [ + { + key: 'Location', + value: 'https://example.com/?foo=bar', + }, + ], }) ); - expect(result.uri).toBe('/users/[user_id]'); - }, - TIMEOUT - ); + } + }); }); diff --git a/packages/proxy/test/proxy.unit.test.ts b/packages/proxy/test/proxy.unit.test.ts index 86a54b35..9efc62be 100644 --- a/packages/proxy/test/proxy.unit.test.ts +++ b/packages/proxy/test/proxy.unit.test.ts @@ -3,12 +3,13 @@ * @see: https://github.com/vercel/vercel/blob/master/packages/now-cli/test/dev-router.unit.js */ -import { Route } from '@vercel/routing-utils'; import { URLSearchParams } from 'url'; +import { Route } from '@vercel/routing-utils'; + import { Proxy } from '../src/proxy'; -test('[proxy-unit] captured groups', () => { +test('Captured groups', () => { const routesConfig = [{ src: '/api/(.*)', dest: '/endpoints/$1.js' }]; const result = new Proxy(routesConfig, [], []).route('/api/user'); @@ -27,7 +28,7 @@ test('[proxy-unit] captured groups', () => { }); }); -test('[proxy-unit] named groups', () => { +test('Named groups', () => { const routesConfig = [{ src: '/user/(?.+)', dest: '/user.js?id=$id' }]; const result = new Proxy(routesConfig, [], []).route('/user/123'); @@ -46,7 +47,7 @@ test('[proxy-unit] named groups', () => { }); }); -test('[proxy-unit] optional named groups', () => { +test('Optional named groups', () => { const routesConfig = [ { src: '/api/hello(/(?[^/]+))?', @@ -70,7 +71,7 @@ test('[proxy-unit] optional named groups', () => { }); }); -test('[proxy-unit] shared lambda', () => { +test('Shared lambda', () => { const routesConfig = [ { src: '^/product/\\[\\.\\.\\.slug\\]/?$', @@ -101,7 +102,7 @@ test('[proxy-unit] shared lambda', () => { }); }); -test('[proxy-unit] slug group and shared lambda', () => { +test('Slug group and shared lambda', () => { const routesConfig = [ { src: '^/product/(?.+?)(?:/)?$', @@ -137,7 +138,7 @@ test('[proxy-unit] slug group and shared lambda', () => { }); }); -test('[proxy-unit] Ignore other routes when no continue is set', () => { +test('Ignore other routes when no continue is set', () => { const routesConfig = [ { src: '/about', dest: '/about.html' }, { src: '/about', dest: '/about.php' }, @@ -160,7 +161,7 @@ test('[proxy-unit] Ignore other routes when no continue is set', () => { }); }); -test('[proxy-unit] Continue after first route found', () => { +test('Continue after first route found', () => { const routesConfig = [ { src: '/about', @@ -192,7 +193,7 @@ test('[proxy-unit] Continue after first route found', () => { }); }); -test('[proxy-unit] Redirect: Remove trailing slash', () => { +describe('Redirect: Remove trailing slash', () => { const routesConfig: Route[] = [ { src: '^(?:\\/((?:[^\\/]+?)(?:\\/(?:[^\\/]+?))*))\\/$', @@ -206,38 +207,64 @@ test('[proxy-unit] Redirect: Remove trailing slash', () => { handle: 'filesystem', }, ]; - const proxy = new Proxy(routesConfig, [], ['/test']); + let proxy: Proxy; - const result1 = proxy.route('/test/'); - expect(result1).toEqual({ - found: true, - dest: '/test', - continue: false, - status: 308, - headers: { Location: '/test' }, - isDestUrl: false, - phase: 'filesystem', - target: 'filesystem', + beforeAll(() => { + proxy = new Proxy(routesConfig, [], ['/test']); }); - const result2 = proxy.route('/other-route/'); - expect(result2).toEqual({ - found: true, - dest: '/other-route/', - continue: true, - status: 308, - headers: { Location: '/other-route' }, - uri_args: new URLSearchParams(), - matched_route: routesConfig[0], - matched_route_idx: 0, - userDest: false, - isDestUrl: false, - phase: undefined, - target: undefined, + test('Matches static route', () => { + const result = proxy.route('/test/'); + expect(result).toEqual({ + found: true, + dest: '/test', + continue: false, + status: 308, + headers: { Location: '/test' }, + isDestUrl: false, + phase: 'filesystem', + target: 'filesystem', + }); + }); + + test('Matches no route', () => { + const result = proxy.route('/other-route/'); + expect(result).toEqual({ + found: true, + dest: '/other-route/', + continue: true, + status: 308, + headers: { Location: '/other-route' }, + uri_args: new URLSearchParams(), + matched_route: routesConfig[0], + matched_route_idx: 0, + userDest: false, + isDestUrl: false, + phase: undefined, + target: undefined, + }); + }); + + test('Has querystring', () => { + const result = proxy.route('/other-route/?foo=bar'); + expect(result).toEqual({ + found: true, + dest: '/other-route/', + continue: true, + status: 308, + headers: { Location: '/other-route' }, + uri_args: new URLSearchParams('foo=bar'), + matched_route: routesConfig[0], + matched_route_idx: 0, + userDest: false, + isDestUrl: false, + phase: undefined, + target: undefined, + }); }); }); -test('[proxy-unit] With trailing slash', () => { +test('With trailing slash', () => { const routesConfig: Route[] = [ { handle: 'filesystem', @@ -270,7 +297,7 @@ test('[proxy-unit] With trailing slash', () => { }); }); -test('[proxy-unit] Redirect partial replace', () => { +test('Redirect partial replace', () => { const routesConfig = [ { src: '^\\/redir(?:\\/([^\\/]+?))$', @@ -307,7 +334,37 @@ test('[proxy-unit] Redirect partial replace', () => { }); }); -test('[proxy-unit] External rewrite', () => { +test('Redirect to partial path', () => { + const routesConfig = [ + { + src: '^\\/two(?:\\/((?:[^\\/]+?)(?:\\/(?:[^\\/]+?))*))?$', + headers: { + Location: '/newplacetwo/$1', + }, + status: 308, + }, + ] as Route[]; + + const result = new Proxy(routesConfig, [], []).route( + '/two/some/path?foo=bar' + ); + expect(result).toEqual({ + found: true, + dest: '/two/some/path', + continue: false, + status: 308, + headers: { Location: '/newplacetwo/some/path' }, + uri_args: new URLSearchParams('foo=bar'), + matched_route: routesConfig[0], + matched_route_idx: 0, + userDest: false, + isDestUrl: false, + phase: undefined, + target: undefined, + }); +}); + +test('External rewrite', () => { const routesConfig = [ { src: '^\\/docs(?:\\/([^\\/]+?))$', @@ -334,7 +391,7 @@ test('[proxy-unit] External rewrite', () => { }); }); -test('[proxy-unit] Rewrite with ^ and $', () => { +test('Rewrite with ^ and $', () => { const routesConfig = [ { src: '^/$', @@ -361,7 +418,7 @@ test('[proxy-unit] Rewrite with ^ and $', () => { }); }); -test('[proxy-unit] i18n default locale', () => { +test('I18n default locale', () => { const routesConfig = [ { src: '^/(?!(?:_next/.*|en|fr\\-FR|nl)(?:/.*|$))(.*)$', @@ -405,7 +462,7 @@ test('[proxy-unit] i18n default locale', () => { }); }); -test('[proxy-unit] static index route', () => { +test('Static index route', () => { const routesConfig = [ { handle: 'filesystem' as const, @@ -425,7 +482,7 @@ test('[proxy-unit] static index route', () => { }); }); -test('[proxy-unit] multiple dynamic parts', () => { +test('Multiple dynamic parts', () => { const routesConfig: Route[] = [ { handle: 'filesystem', @@ -462,7 +519,7 @@ test('[proxy-unit] multiple dynamic parts', () => { }); }); -test('[proxy-unit] Dynamic static route', () => { +test('Dynamic static route', () => { const routesConfig: Route[] = [ { handle: 'rewrite', diff --git a/packages/proxy/test/test-utils.ts b/packages/proxy/test/test-utils.ts new file mode 100644 index 00000000..794c6e3d --- /dev/null +++ b/packages/proxy/test/test-utils.ts @@ -0,0 +1,114 @@ +import { CloudFrontRequestEvent } from 'aws-lambda'; + +type GenerateCloudFrontRequestEventOptions = { + /** + * Endpoint of the API Gateway. + */ + apiGatewayEndpoint?: string; + /** + * URL where the proxy config can be fetched from. + */ + configEndpoint: string; + /** + * Querystring of the original request. + */ + querystring?: string; + /** + * Pathname (without querystring) of the original request. + */ + uri: string; +}; + +/** + * Generates a CloudFrontRequestEvent object. + * @see {@link https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-event-structure.html#example-origin-request} + */ +function generateCloudFrontRequestEvent( + options: GenerateCloudFrontRequestEventOptions +): CloudFrontRequestEvent { + const { + apiGatewayEndpoint = 'example.localhost', + configEndpoint, + querystring = '', + uri, + } = options; + + return { + 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: apiGatewayEndpoint, + }, + ], + }, + region: 'us-east-1', + authMethod: 'origin-access-identity', + domainName: 's3.localhost', + path: '', + }, + }, + querystring, + uri, + }, + }, + }, + ], + }; +} + +export { generateCloudFrontRequestEvent }; diff --git a/packages/proxy/test/util/append-querystring.test.ts b/packages/proxy/test/util/append-querystring.test.ts new file mode 100644 index 00000000..c6354c2b --- /dev/null +++ b/packages/proxy/test/util/append-querystring.test.ts @@ -0,0 +1,94 @@ +import { URLSearchParams } from 'url'; + +import { appendQuerystring } from '../../src/util/append-querystring'; + +describe('Relative URL', () => { + test('SearchParams', () => { + const result = appendQuerystring('/test', new URLSearchParams('foo=bar')); + expect(result).toBe('/test?foo=bar'); + }); + + test('Empty SearchParams', () => { + const result = appendQuerystring('/test', new URLSearchParams()); + expect(result).toBe('/test'); + }); + + test('Querystring, empty SearchParams', () => { + const result = appendQuerystring('/test?foo=bar', new URLSearchParams()); + expect(result).toBe('/test?foo=bar'); + }); + + test('Querystring, same SearchParams', () => { + const result = appendQuerystring( + '/test?foo=bar', + new URLSearchParams('foo=bar') + ); + expect(result).toBe('/test?foo=bar'); + }); + + test('Querystring, different SearchParams', () => { + const result = appendQuerystring( + '/test?foo=bar', + new URLSearchParams('bar=foo') + ); + expect(result).toBe('/test?bar=foo&foo=bar'); + }); + + test('Querystring, try to override SearchParams', () => { + const result = appendQuerystring( + '/test?foo=bar', + new URLSearchParams('foo=xxx') + ); + expect(result).toBe('/test?foo=bar'); + }); +}); + +describe('Absolute URL', () => { + test('SearchParams', () => { + const result = appendQuerystring( + 'https://example.org/test', + new URLSearchParams('foo=bar') + ); + expect(result).toBe('https://example.org/test?foo=bar'); + }); + + test('Empty SearchParams', () => { + const result = appendQuerystring( + 'https://example.org/test', + new URLSearchParams() + ); + expect(result).toBe('https://example.org/test'); + }); + + test('Querystring, empty SearchParams', () => { + const result = appendQuerystring( + 'https://example.org/test?foo=bar', + new URLSearchParams() + ); + expect(result).toBe('https://example.org/test?foo=bar'); + }); + + test('Querystring, same SearchParams', () => { + const result = appendQuerystring( + 'https://example.org/test?foo=bar', + new URLSearchParams('foo=bar') + ); + expect(result).toBe('https://example.org/test?foo=bar'); + }); + + test('Querystring, different SearchParams', () => { + const result = appendQuerystring( + 'https://example.org/test?foo=bar', + new URLSearchParams('bar=foo') + ); + expect(result).toBe('https://example.org/test?bar=foo&foo=bar'); + }); + + test('Querystring, try to override SearchParams', () => { + const result = appendQuerystring( + 'https://example.org/test?foo=bar', + new URLSearchParams('foo=xxx') + ); + expect(result).toBe('https://example.org/test?foo=bar'); + }); +});