From a849cbb83646e90e7b1348e61dc643fbffdff2ca Mon Sep 17 00:00:00 2001 From: James Date: Tue, 1 Oct 2024 22:09:33 +0100 Subject: [PATCH] restructure/simplify types --- .eslintrc | 1 + fixtures/check-route-match/config.ts | 2 +- fixtures/check-route-match/file-system.ts | 2 +- fixtures/dynamic-routes/config.ts | 2 +- fixtures/dynamic-routes/file-system.ts | 2 +- fixtures/i18n/config.ts | 2 +- fixtures/i18n/file-system.ts | 2 +- fixtures/infinite-loop/config.ts | 2 +- fixtures/infinite-loop/file-system.ts | 2 +- fixtures/run-test-set.ts | 21 +-- fixtures/trailing-slash/config.ts | 2 +- fixtures/trailing-slash/file-system.ts | 2 +- src/router.ts | 9 +- src/routes-matcher.ts | 53 +++--- src/utils/http.spec.ts | 177 ------------------ src/utils/http.ts | 127 ------------- src/utils/http/apply-headers.spec.ts | 51 +++++ src/utils/http/apply-headers.ts | 32 ++++ src/utils/http/apply-search-params.spec.ts | 65 +++++++ src/utils/http/apply-search-params.ts | 32 ++++ src/utils/http/create-mutable-response.ts | 9 + src/utils/http/create-route-request.spec.ts | 26 +++ src/utils/http/create-route-request.ts | 21 +++ src/utils/http/index.ts | 6 + src/utils/http/is-url.spec.ts | 13 ++ src/utils/http/is-url.ts | 7 + src/utils/http/parse-accept-language.spec.ts | 29 +++ src/utils/http/parse-accept-language.ts | 18 ++ src/utils/images.ts | 177 ------------------ .../images/format-resizing-response.spec.ts | 50 +++++ src/utils/images/format-resizing-response.ts | 48 +++++ .../get-resizing-properties.spec.ts} | 110 +---------- src/utils/images/get-resizing-properties.ts | 69 +++++++ src/utils/images/index.ts | 3 + .../images/is-remote-pattern-match.spec.ts | 58 ++++++ src/utils/images/is-remote-pattern-match.ts | 27 +++ src/utils/index.ts | 8 - .../apply-pcre-matches.spec.ts} | 56 +----- src/utils/pcre/apply-pcre-matches.ts | 27 +++ src/utils/pcre/index.ts | 2 + src/utils/pcre/match-pcre.spec.ts | 59 ++++++ src/utils/{pcre.ts => pcre/match-pcre.ts} | 35 +--- src/utils/routing.ts | 73 -------- .../check-has-field.spec.ts} | 11 +- .../check-has-field.ts} | 63 +++---- .../collect-locales-from-routes.ts | 3 +- src/utils/routing/get-next-phase.ts | 28 +++ .../{ => routing}/group-routes-by-phase.ts | 3 +- src/utils/routing/index.ts | 6 + src/utils/{ => routing}/is-handler-route.ts | 2 +- .../routing/is-locale-trailing-slash-regex.ts | 21 +++ tsconfig.json | 5 +- 52 files changed, 826 insertions(+), 835 deletions(-) delete mode 100644 src/utils/http.spec.ts delete mode 100644 src/utils/http.ts create mode 100644 src/utils/http/apply-headers.spec.ts create mode 100644 src/utils/http/apply-headers.ts create mode 100644 src/utils/http/apply-search-params.spec.ts create mode 100644 src/utils/http/apply-search-params.ts create mode 100644 src/utils/http/create-mutable-response.ts create mode 100644 src/utils/http/create-route-request.spec.ts create mode 100644 src/utils/http/create-route-request.ts create mode 100644 src/utils/http/index.ts create mode 100644 src/utils/http/is-url.spec.ts create mode 100644 src/utils/http/is-url.ts create mode 100644 src/utils/http/parse-accept-language.spec.ts create mode 100644 src/utils/http/parse-accept-language.ts delete mode 100644 src/utils/images.ts create mode 100644 src/utils/images/format-resizing-response.spec.ts create mode 100644 src/utils/images/format-resizing-response.ts rename src/utils/{images.spec.ts => images/get-resizing-properties.spec.ts} (64%) create mode 100644 src/utils/images/get-resizing-properties.ts create mode 100644 src/utils/images/index.ts create mode 100644 src/utils/images/is-remote-pattern-match.spec.ts create mode 100644 src/utils/images/is-remote-pattern-match.ts delete mode 100644 src/utils/index.ts rename src/utils/{pcre.spec.ts => pcre/apply-pcre-matches.spec.ts} (69%) create mode 100644 src/utils/pcre/apply-pcre-matches.ts create mode 100644 src/utils/pcre/index.ts create mode 100644 src/utils/pcre/match-pcre.spec.ts rename src/utils/{pcre.ts => pcre/match-pcre.ts} (52%) delete mode 100644 src/utils/routing.ts rename src/utils/{matcher.spec.ts => routing/check-has-field.spec.ts} (95%) rename src/utils/{matcher.ts => routing/check-has-field.ts} (92%) rename src/utils/{ => routing}/collect-locales-from-routes.ts (88%) create mode 100644 src/utils/routing/get-next-phase.ts rename src/utils/{ => routing}/group-routes-by-phase.ts (98%) create mode 100644 src/utils/routing/index.ts rename src/utils/{ => routing}/is-handler-route.ts (78%) create mode 100644 src/utils/routing/is-locale-trailing-slash-regex.ts diff --git a/.eslintrc b/.eslintrc index 3c64859..876b355 100644 --- a/.eslintrc +++ b/.eslintrc @@ -13,6 +13,7 @@ "error", { "devDependencies": ["**/*.spec.ts", "vite.config.ts"] }, ], + "import/extensions": ["error", "never"], "no-console": "error", }, "ignorePatterns": ["dist", "scripts", "fixtures/**/*.js", "fixtures/**/*.cjs"], diff --git a/fixtures/check-route-match/config.ts b/fixtures/check-route-match/config.ts index b94d712..3b9b757 100644 --- a/fixtures/check-route-match/config.ts +++ b/fixtures/check-route-match/config.ts @@ -1,4 +1,4 @@ -import type { VercelConfig } from '../../src/types'; +import type { VercelConfig } from '@/types/vercel-config'; export const config: VercelConfig = { version: 3, diff --git a/fixtures/check-route-match/file-system.ts b/fixtures/check-route-match/file-system.ts index 64f74c7..1818949 100644 --- a/fixtures/check-route-match/file-system.ts +++ b/fixtures/check-route-match/file-system.ts @@ -1,4 +1,4 @@ -import type { BuildOutput } from '../../src/types'; +import type { BuildOutput } from '@/types/build-output'; import { functionAsset, htmlAsset } from '../run-test-set'; export const fileSystem: BuildOutput = { diff --git a/fixtures/dynamic-routes/config.ts b/fixtures/dynamic-routes/config.ts index 095675b..3b8cd00 100644 --- a/fixtures/dynamic-routes/config.ts +++ b/fixtures/dynamic-routes/config.ts @@ -1,4 +1,4 @@ -import type { VercelConfig } from '../../src/types'; +import type { VercelConfig } from '@/types/vercel-config'; export const config: VercelConfig = { version: 3, diff --git a/fixtures/dynamic-routes/file-system.ts b/fixtures/dynamic-routes/file-system.ts index 2d78828..7a963ab 100644 --- a/fixtures/dynamic-routes/file-system.ts +++ b/fixtures/dynamic-routes/file-system.ts @@ -1,4 +1,4 @@ -import type { BuildOutput } from '../../src/types'; +import type { BuildOutput } from '@/types/build-output'; import { functionAsset, htmlAsset, staticAsset } from '../run-test-set'; export const fileSystem: BuildOutput = { diff --git a/fixtures/i18n/config.ts b/fixtures/i18n/config.ts index f922142..5466b69 100644 --- a/fixtures/i18n/config.ts +++ b/fixtures/i18n/config.ts @@ -1,4 +1,4 @@ -import type { VercelConfig } from '../../src/types'; +import type { VercelConfig } from '@/types/vercel-config'; export const config: VercelConfig = { version: 3, diff --git a/fixtures/i18n/file-system.ts b/fixtures/i18n/file-system.ts index 44cc5ef..ff2c9e5 100644 --- a/fixtures/i18n/file-system.ts +++ b/fixtures/i18n/file-system.ts @@ -1,4 +1,4 @@ -import type { BuildOutput } from '../../src/types'; +import type { BuildOutput } from '@/types/build-output'; import { functionAsset, htmlAsset, staticAsset } from '../run-test-set'; export const staticLocales = ['en', 'fr', 'nl', 'es'] as const; diff --git a/fixtures/infinite-loop/config.ts b/fixtures/infinite-loop/config.ts index 9bb3190..4493aaf 100644 --- a/fixtures/infinite-loop/config.ts +++ b/fixtures/infinite-loop/config.ts @@ -1,4 +1,4 @@ -import type { VercelConfig } from '../../src/types'; +import type { VercelConfig } from '@/types/vercel-config'; export const config: VercelConfig = { version: 3, diff --git a/fixtures/infinite-loop/file-system.ts b/fixtures/infinite-loop/file-system.ts index 57699ed..2229201 100644 --- a/fixtures/infinite-loop/file-system.ts +++ b/fixtures/infinite-loop/file-system.ts @@ -1,4 +1,4 @@ -import type { BuildOutput } from '../../src/types'; +import type { BuildOutput } from '@/types/build-output'; import { functionAsset, htmlAsset } from '../run-test-set'; export const fileSystem: BuildOutput = { diff --git a/fixtures/run-test-set.ts b/fixtures/run-test-set.ts index e24673b..33f85a1 100644 --- a/fixtures/run-test-set.ts +++ b/fixtures/run-test-set.ts @@ -1,25 +1,16 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { - applyHeaders, - applySearchParams, - collectLocalesFromRoutes, - createRouteRequest, -} from 'src/utils'; // eslint-disable-next-line import/no-extraneous-dependencies import { expect, suite, test, vi } from 'vitest'; +import type { BuildOutputItem, EdgeFunction, ExecutionContext } from '@/types/build-output'; +import type { Assets, Fetcher } from '@/types/request-context'; +import type { VercelConfig } from '@/types/vercel-config'; +import { applyHeaders, applySearchParams, createRouteRequest } from '@/utils/http'; +import { collectLocalesFromRoutes, groupRoutesByPhase } from '@/utils/routing'; + import { Router } from '../src'; -import type { - Assets, - BuildOutputItem, - EdgeFunction, - ExecutionContext, - Fetcher, - VercelConfig, -} from '../src/types'; -import { groupRoutesByPhase } from '../src/utils'; type TestCase = { name: string; diff --git a/fixtures/trailing-slash/config.ts b/fixtures/trailing-slash/config.ts index d6cc4dd..2e79d7d 100644 --- a/fixtures/trailing-slash/config.ts +++ b/fixtures/trailing-slash/config.ts @@ -1,4 +1,4 @@ -import type { VercelConfig } from '../../src/types'; +import type { VercelConfig } from '@/types/vercel-config'; export const config: VercelConfig = { version: 3, diff --git a/fixtures/trailing-slash/file-system.ts b/fixtures/trailing-slash/file-system.ts index 77dceb8..8138628 100644 --- a/fixtures/trailing-slash/file-system.ts +++ b/fixtures/trailing-slash/file-system.ts @@ -1,4 +1,4 @@ -import type { BuildOutput } from '../../src/types'; +import type { BuildOutput } from '@/types/build-output'; import { functionAsset, htmlAsset, staticAsset } from '../run-test-set'; export const fileSystem: BuildOutput = { diff --git a/src/router.ts b/src/router.ts index d0316f1..73d0970 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,8 +1,9 @@ -import type { ConfigMetadata } from './routes-matcher'; +import type { RoutesGroupedByPhase } from '@/types/build-output'; +import type { RequestContext } from '@/types/request-context'; +import { applyHeaders, applySearchParams, isUrl } from '@/utils/http'; + +import type { ConfigMetadata, RoutingMatch } from './routes-matcher'; import { RoutesMatcher } from './routes-matcher'; -import type { RequestContext, RoutesGroupedByPhase } from './types'; -import type { RoutingMatch } from './utils'; -import { applyHeaders, applySearchParams, isUrl } from './utils'; export class Router { constructor( diff --git a/src/routes-matcher.ts b/src/routes-matcher.ts index 1116b53..fa63b88 100644 --- a/src/routes-matcher.ts +++ b/src/routes-matcher.ts @@ -1,28 +1,39 @@ import { parse } from 'cookie'; -import type { - Phase, - RequestContext, - RoutesGroupedByPhase, - SourceRoute, - VercelWildCard, -} from './types'; -import type { MatchPCREResult, RoutingMatch } from './utils'; -import { - applyHeaders, - applyPCREMatches, - applySearchParams, - checkHasField, - getNextPhase, - isLocaleTrailingSlashRegex, - isUrl, - matchPCRE, - parseAcceptLanguage, -} from './utils'; +import type { Phase, RoutesGroupedByPhase, SourceRoute } from '@/types/build-output'; +import type { RequestContext } from '@/types/request-context'; +import type { WildCard } from '@/types/vercel-config'; +import { applyHeaders, applySearchParams, isUrl, parseAcceptLanguage } from '@/utils/http'; +import type { MatchPCREResult } from '@/utils/pcre'; +import { applyPCREMatches, matchPCRE } from '@/utils/pcre'; +import { checkHasField, getNextPhase, isLocaleTrailingSlashRegex } from '@/utils/routing'; export type ConfigMetadata = { locales: Set; - wildcardConfig: VercelWildCard[] | undefined; + wildcardConfig: WildCard[] | undefined; +}; + +export type RoutingMatch = { + path: string; + status: number | undefined; + headers: { + /** + * The headers present on a source route. + * Gets applied to the final response before the response headers from running a function. + */ + normal: Headers; + /** + * The *important* headers - the ones present on a source route that specifies `important: true`. + * Gets applied to the final response after the response headers from running a function. + */ + important: Headers; + /** + * Tracks if a location header is found, and what the value is, after running a middleware function. + */ + middlewareLocation?: string | null; + }; + searchParams: URLSearchParams; + body: BodyInit | undefined | null; }; export type CheckRouteStatus = 'skip' | 'next' | 'done' | 'error'; @@ -39,7 +50,7 @@ export class RoutesMatcher { private cookies: Record; /** Wildcard match from the Vercel build output config */ - private wildcardMatch: VercelWildCard | undefined; + private wildcardMatch: WildCard | undefined; /** Path for the matched route */ public path: string; diff --git a/src/utils/http.spec.ts b/src/utils/http.spec.ts deleted file mode 100644 index 11c691c..0000000 --- a/src/utils/http.spec.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { describe, expect, test } from 'vitest'; - -import { - applyHeaders, - applySearchParams, - createRouteRequest, - isUrl, - parseAcceptLanguage, -} from './http'; -import type { MatchPCREResult } from './pcre'; - -describe('applyHeaders', () => { - test('applies headers from normal object', () => { - const headers = new Headers({ foo: 'bar' }); - applyHeaders(headers, { other: 'value' }); - - expect(Object.fromEntries(headers.entries())).toEqual({ - foo: 'bar', - other: 'value', - }); - }); - - test('applies headers from headers object', () => { - const headers = new Headers({ foo: 'bar' }); - applyHeaders(headers, new Headers({ other: 'value' })); - - expect(Object.fromEntries(headers.entries())).toEqual({ - foo: 'bar', - other: 'value', - }); - }); - - test('applies headers from object with pcre match', () => { - const headers = new Headers({ foo: 'bar' }); - const pcreMatch: MatchPCREResult = { - match: ['localhost/index.html', 'index.html'], - captureGroupKeys: ['path'], - }; - applyHeaders(headers, { other: 'path/to/$path' }, pcreMatch); - - expect(Object.fromEntries(headers.entries())).toEqual({ - foo: 'bar', - other: 'path/to/index.html', - }); - }); - - test('appends `set-cookie` headers instead of overriding', () => { - const headers = new Headers({ 'set-cookie': 'first-value' }); - applyHeaders(headers, { 'set-cookie': 'second-value' }); - - expect([...headers.entries()]).toEqual([ - ['set-cookie', 'first-value'], - ['set-cookie', 'second-value'], - ]); - }); -}); - -describe('isUrl', () => { - test('returns true for valid url', () => { - expect(isUrl('https://test.com')).toEqual(true); - }); - - test('returns false for invalid url', () => { - expect(isUrl('test.com')).toEqual(false); - }); -}); - -describe('applySearchParams', () => { - test('merges search params onto target', () => { - const source = new URL('http://localhost/page?foo=bar'); - const target = new URL('http://localhost/page?other=value'); - - expect([...source.searchParams.entries()].length).toEqual(1); - expect([...target.searchParams.entries()].length).toEqual(1); - - applySearchParams(target.searchParams, source.searchParams); - - expect([...source.searchParams.entries()].length).toEqual(1); - expect([...target.searchParams.entries()].length).toEqual(2); - - expect(target.toString()).toEqual('http://localhost/page?other=value&foo=bar'); - }); - - test('allows multiple query params with the same key', () => { - const source = new URL('http://localhost/page?foo=bar'); - const target = new URL('http://localhost/page?other=value&foo=baz&foo=test'); - - expect([...source.searchParams.entries()].length).toEqual(1); - expect([...target.searchParams.entries()].length).toEqual(3); - - applySearchParams(target.searchParams, source.searchParams); - - expect([...source.searchParams.entries()].length).toEqual(1); - expect([...target.searchParams.entries()].length).toEqual(4); - - expect(target.toString()).toEqual('http://localhost/page?other=value&foo=baz&foo=test&foo=bar'); - }); - - test('multiple query params with the same key must be unique values', () => { - const source = new URL('http://localhost/page?foo=bar&foo=baz&foo=baz'); - const target = new URL('http://localhost/page?other=value&foo=baz'); - - expect([...source.searchParams.entries()].length).toEqual(3); - expect([...target.searchParams.entries()].length).toEqual(2); - - applySearchParams(target.searchParams, source.searchParams); - - expect([...source.searchParams.entries()].length).toEqual(3); - expect([...target.searchParams.entries()].length).toEqual(3); - - expect(target.toString()).toEqual('http://localhost/page?other=value&foo=baz&foo=bar'); - }); - - test('Next.js page params (nxtP) always override', () => { - const source = new URL('http://localhost/page?nxtPfoo=bar'); - const target = new URL('http://localhost/page?other=value&foo=baz&foo=test'); - - expect([...source.searchParams.entries()].length).toEqual(1); - expect([...target.searchParams.entries()].length).toEqual(3); - - applySearchParams(target.searchParams, source.searchParams); - - expect([...source.searchParams.entries()].length).toEqual(1); - expect([...target.searchParams.entries()].length).toEqual(3); - - expect(target.toString()).toEqual('http://localhost/page?other=value&foo=bar&nxtPfoo=bar'); - }); -}); - -describe('createRouteRequest', () => { - test('creates new request with the new path', () => { - const prevReq = new Request('http://localhost/test'); - const request = createRouteRequest(prevReq, '/new-path'); - - expect(new URL(request.url).pathname).toEqual('/new-path'); - }); - - test('creates new request with the new path without .html', () => { - const prevReq = new Request('http://localhost/test'); - const request = createRouteRequest(prevReq, '/new-path.html'); - - expect(new URL(request.url).pathname).toEqual('/new-path'); - }); - - test('creates new request with the new path without .html', () => { - const prevReq = new Request('http://localhost/test'); - const request = createRouteRequest(prevReq, '/index.html'); - - expect(new URL(request.url).pathname).toEqual('/'); - }); -}); - -describe('parseAcceptLanguage', () => { - test('extract the locales and sort by quality when present', () => { - [ - { header: '', expected: [] }, - { header: 'en', expected: ['en'] }, - { header: 'en-US,en', expected: ['en-US', 'en'] }, - { header: 'en-US,en;q=0.9,es;q=0.8', expected: ['en-US', 'en', 'es'] }, - { - header: 'en-US,fr;q=0.7,en;q=0.9,es;q=0.8', - expected: ['en-US', 'en', 'es', 'fr'], - }, - { - header: 'fr;q=0.7,en;q=0.9,en-US,es;q=0.8', - expected: ['en-US', 'en', 'es', 'fr'], - }, - { - header: 'fr;q = 0.7,en;q =0.9,en-US,es;q= 0.8', - expected: ['en-US', 'en', 'es', 'fr'], - }, - ].forEach(({ header, expected }) => { - const result = parseAcceptLanguage(header); - expect(result).toEqual(expected); - }); - }); -}); diff --git a/src/utils/http.ts b/src/utils/http.ts deleted file mode 100644 index 4c01b82..0000000 --- a/src/utils/http.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { MatchPCREResult } from './pcre'; -import { applyPCREMatches } from './pcre'; - -/** - * Applies a set of headers to a response. - * - * If the header key is `set-cookie`, the value will be appended. Otherwise, the value will be set - * and overridden to prevent duplicates which sometimes happens with headers like `x-matched-path`. - * - * @param target Headers object to apply to. - * @param source Headers to apply. - * @param pcreMatch PCRE match result to apply to header values. - */ -export function applyHeaders( - target: Headers, - source: Record | Headers, - pcreMatch?: MatchPCREResult, -): void { - const entries = source instanceof Headers ? source.entries() : Object.entries(source); - for (const [key, value] of entries) { - const lowerKey = key.toLowerCase(); - const newValue = pcreMatch?.match - ? applyPCREMatches(value, pcreMatch.match, pcreMatch.captureGroupKeys) - : value; - - if (lowerKey === 'set-cookie') { - target.append(lowerKey, newValue); - } else { - target.set(lowerKey, newValue); - } - } -} - -/** - * Checks if a string is an URL. - * - * @param url String to check. - * @returns Whether the string is an URL. - */ -export function isUrl(url: string): boolean { - return /^https?:\/\//.test(url); -} - -/** - * Merges search params from one URLSearchParams object to another. - * - * Only appends the parameter if the target does not contain it, or if the value is different and not undefined. - * - * For params prefixed with `nxtP`, it also sets the param without the prefix if it does not exist. - * The `nxtP` prefix indicates that it is for Next.js dynamic route parameters. In some cases, - * Next.js fails to derive the correct route parameters and so we need to set them manually. - * https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/constants.ts#L3 - * - * For params prefixed with `nxtI`, this is a route intercept. It sets the param without the prefix, - * and removes any intercepts from the param's value. This is so that the route intercept is able - * to have the correct route parameters for the page. - * https://github.com/vercel/next.js/blob/cdf2b79ea/packages/next/src/shared/lib/router/utils/route-regex.ts#L6 - * - * @param target Target that search params will be applied to. - * @param source Source search params to apply to the target. - */ -export function applySearchParams(target: URLSearchParams, source: URLSearchParams) { - for (const [key, value] of source.entries()) { - const nxtParamMatch = /^nxtP(.+)$/.exec(key); - const nxtInterceptMatch = /^nxtI(.+)$/.exec(key); - if (nxtParamMatch?.[1]) { - target.set(key, value); - target.set(nxtParamMatch[1], value); - } else if (nxtInterceptMatch?.[1]) { - target.set(nxtInterceptMatch[1], value.replace(/(\(\.+\))+/, '')); - } else if (!target.has(key) || (!!value && !target.getAll(key).includes(value))) { - target.append(key, value); - } - } -} - -/** - * Creates a new Request object with the same body, headers, and search params as the original. - * - * Replaces the URL with the given path, stripping the `.html` extension and `/index.html` for - * asset matching. - * https://developers.cloudflare.com/pages/platform/serving-pages/#route-matching - * - * @param req Request object to re-create. - * @param path URL to use for the new Request object. - * @returns A new Request object with the same body and headers as the original. - */ -export function createRouteRequest(req: Request, path: string) { - const newUrl = new URL(path, req.url); - applySearchParams(newUrl.searchParams, new URL(req.url).searchParams); - - newUrl.pathname = newUrl.pathname.replace(/\/index.html$/, '/').replace(/\.html$/, ''); - - return new Request(newUrl, req); -} - -/** - * Creates a new Response object with the same body and headers as the original. - * - * Useful when the response object may be immutable. - * - * @param resp Response object to re-create. - * @returns A new Response object with the same body and headers. - */ -export function createMutableResponse(resp: Response) { - return new Response(resp.body, resp); -} - -/** - * Parses the Accept-Language header value and returns an array of locales sorted by quality. - * - * @param headerValue Accept-Language header value. - * @returns Array of locales sorted by quality. - */ -export function parseAcceptLanguage(headerValue: string): string[] { - return headerValue - .split(',') - .map((val) => { - const [lang, qual] = val.split(';') as [string, string | undefined]; - const quality = parseFloat((qual ?? 'q=1').replace(/q *= */gi, '')); - - return [lang.trim(), Number.isNaN(quality) ? 1 : quality] as [string, number]; - }) - .sort((a, b) => b[1] - a[1]) - .map(([locale]) => (locale === '*' || locale === '' ? [] : locale)) - .flat(); -} diff --git a/src/utils/http/apply-headers.spec.ts b/src/utils/http/apply-headers.spec.ts new file mode 100644 index 0000000..a9dc9aa --- /dev/null +++ b/src/utils/http/apply-headers.spec.ts @@ -0,0 +1,51 @@ +import { expect, suite, test } from 'vitest'; + +import type { MatchPCREResult } from '@/utils/pcre'; + +import { applyHeaders } from './apply-headers'; + +suite('applyHeaders', () => { + test('applies headers from normal object', () => { + const headers = new Headers({ foo: 'bar' }); + applyHeaders(headers, { other: 'value' }); + + expect(Object.fromEntries(headers.entries())).toEqual({ + foo: 'bar', + other: 'value', + }); + }); + + test('applies headers from headers object', () => { + const headers = new Headers({ foo: 'bar' }); + applyHeaders(headers, new Headers({ other: 'value' })); + + expect(Object.fromEntries(headers.entries())).toEqual({ + foo: 'bar', + other: 'value', + }); + }); + + test('applies headers from object with pcre match', () => { + const headers = new Headers({ foo: 'bar' }); + const pcreMatch: MatchPCREResult = { + match: ['localhost/index.html', 'index.html'], + captureGroupKeys: ['path'], + }; + applyHeaders(headers, { other: 'path/to/$path' }, pcreMatch); + + expect(Object.fromEntries(headers.entries())).toEqual({ + foo: 'bar', + other: 'path/to/index.html', + }); + }); + + test('appends `set-cookie` headers instead of overriding', () => { + const headers = new Headers({ 'set-cookie': 'first-value' }); + applyHeaders(headers, { 'set-cookie': 'second-value' }); + + expect([...headers.entries()]).toEqual([ + ['set-cookie', 'first-value'], + ['set-cookie', 'second-value'], + ]); + }); +}); diff --git a/src/utils/http/apply-headers.ts b/src/utils/http/apply-headers.ts new file mode 100644 index 0000000..80a1371 --- /dev/null +++ b/src/utils/http/apply-headers.ts @@ -0,0 +1,32 @@ +import type { MatchPCREResult } from '@/utils/pcre'; +import { applyPCREMatches } from '@/utils/pcre'; + +/** + * Applies a set of headers to a response. + * + * If the header key is `set-cookie`, the value will be appended. Otherwise, the value will be set + * and overridden to prevent duplicates which sometimes happens with headers like `x-matched-path`. + * + * @param target Headers object to apply to. + * @param source Headers to apply. + * @param pcreMatch PCRE match result to apply to header values. + */ +export const applyHeaders = ( + target: Headers, + source: Record | Headers, + pcreMatch?: MatchPCREResult, +): void => { + const entries = source instanceof Headers ? source.entries() : Object.entries(source); + for (const [key, value] of entries) { + const lowerKey = key.toLowerCase(); + const newValue = pcreMatch?.match + ? applyPCREMatches(value, pcreMatch.match, pcreMatch.captureGroupKeys) + : value; + + if (lowerKey === 'set-cookie') { + target.append(lowerKey, newValue); + } else { + target.set(lowerKey, newValue); + } + } +}; diff --git a/src/utils/http/apply-search-params.spec.ts b/src/utils/http/apply-search-params.spec.ts new file mode 100644 index 0000000..6290c0e --- /dev/null +++ b/src/utils/http/apply-search-params.spec.ts @@ -0,0 +1,65 @@ +import { expect, suite, test } from 'vitest'; + +import { applySearchParams } from './apply-search-params'; + +suite('applySearchParams', () => { + test('merges search params onto target', () => { + const source = new URL('http://localhost/page?foo=bar'); + const target = new URL('http://localhost/page?other=value'); + + expect([...source.searchParams.entries()].length).toEqual(1); + expect([...target.searchParams.entries()].length).toEqual(1); + + applySearchParams(target.searchParams, source.searchParams); + + expect([...source.searchParams.entries()].length).toEqual(1); + expect([...target.searchParams.entries()].length).toEqual(2); + + expect(target.toString()).toEqual('http://localhost/page?other=value&foo=bar'); + }); + + test('allows multiple query params with the same key', () => { + const source = new URL('http://localhost/page?foo=bar'); + const target = new URL('http://localhost/page?other=value&foo=baz&foo=test'); + + expect([...source.searchParams.entries()].length).toEqual(1); + expect([...target.searchParams.entries()].length).toEqual(3); + + applySearchParams(target.searchParams, source.searchParams); + + expect([...source.searchParams.entries()].length).toEqual(1); + expect([...target.searchParams.entries()].length).toEqual(4); + + expect(target.toString()).toEqual('http://localhost/page?other=value&foo=baz&foo=test&foo=bar'); + }); + + test('multiple query params with the same key must be unique values', () => { + const source = new URL('http://localhost/page?foo=bar&foo=baz&foo=baz'); + const target = new URL('http://localhost/page?other=value&foo=baz'); + + expect([...source.searchParams.entries()].length).toEqual(3); + expect([...target.searchParams.entries()].length).toEqual(2); + + applySearchParams(target.searchParams, source.searchParams); + + expect([...source.searchParams.entries()].length).toEqual(3); + expect([...target.searchParams.entries()].length).toEqual(3); + + expect(target.toString()).toEqual('http://localhost/page?other=value&foo=baz&foo=bar'); + }); + + test('Next.js page params (nxtP) always override', () => { + const source = new URL('http://localhost/page?nxtPfoo=bar'); + const target = new URL('http://localhost/page?other=value&foo=baz&foo=test'); + + expect([...source.searchParams.entries()].length).toEqual(1); + expect([...target.searchParams.entries()].length).toEqual(3); + + applySearchParams(target.searchParams, source.searchParams); + + expect([...source.searchParams.entries()].length).toEqual(1); + expect([...target.searchParams.entries()].length).toEqual(3); + + expect(target.toString()).toEqual('http://localhost/page?other=value&foo=bar&nxtPfoo=bar'); + }); +}); diff --git a/src/utils/http/apply-search-params.ts b/src/utils/http/apply-search-params.ts new file mode 100644 index 0000000..991d525 --- /dev/null +++ b/src/utils/http/apply-search-params.ts @@ -0,0 +1,32 @@ +/** + * Merges search params from one URLSearchParams object to another. + * + * Only appends the parameter if the target does not contain it, or if the value is different and not undefined. + * + * For params prefixed with `nxtP`, it also sets the param without the prefix if it does not exist. + * The `nxtP` prefix indicates that it is for Next.js dynamic route parameters. In some cases, + * Next.js fails to derive the correct route parameters and so we need to set them manually. + * https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/constants.ts#L3 + * + * For params prefixed with `nxtI`, this is a route intercept. It sets the param without the prefix, + * and removes any intercepts from the param's value. This is so that the route intercept is able + * to have the correct route parameters for the page. + * https://github.com/vercel/next.js/blob/cdf2b79ea/packages/next/src/shared/lib/router/utils/route-regex.ts#L6 + * + * @param target Target that search params will be applied to. + * @param source Source search params to apply to the target. + */ +export const applySearchParams = (target: URLSearchParams, source: URLSearchParams): void => { + for (const [key, value] of source.entries()) { + const nxtParamMatch = /^nxtP(.+)$/.exec(key); + const nxtInterceptMatch = /^nxtI(.+)$/.exec(key); + if (nxtParamMatch?.[1]) { + target.set(key, value); + target.set(nxtParamMatch[1], value); + } else if (nxtInterceptMatch?.[1]) { + target.set(nxtInterceptMatch[1], value.replace(/(\(\.+\))+/, '')); + } else if (!target.has(key) || (!!value && !target.getAll(key).includes(value))) { + target.append(key, value); + } + } +}; diff --git a/src/utils/http/create-mutable-response.ts b/src/utils/http/create-mutable-response.ts new file mode 100644 index 0000000..4628471 --- /dev/null +++ b/src/utils/http/create-mutable-response.ts @@ -0,0 +1,9 @@ +/** + * Creates a new Response object with the same body and headers as the original. + * + * Useful when the response object may be immutable. + * + * @param resp Response object to re-create. + * @returns A new Response object with the same body and headers. + */ +export const createMutableResponse = (resp: Response): Response => new Response(resp.body, resp); diff --git a/src/utils/http/create-route-request.spec.ts b/src/utils/http/create-route-request.spec.ts new file mode 100644 index 0000000..eec8c1f --- /dev/null +++ b/src/utils/http/create-route-request.spec.ts @@ -0,0 +1,26 @@ +import { expect, suite, test } from 'vitest'; + +import { createRouteRequest } from './create-route-request'; + +suite('createRouteRequest', () => { + test('creates new request with the new path', () => { + const prevReq = new Request('http://localhost/test'); + const request = createRouteRequest(prevReq, '/new-path'); + + expect(new URL(request.url).pathname).toEqual('/new-path'); + }); + + test('creates new request with the new path without .html', () => { + const prevReq = new Request('http://localhost/test'); + const request = createRouteRequest(prevReq, '/new-path.html'); + + expect(new URL(request.url).pathname).toEqual('/new-path'); + }); + + test('creates new request with the new path without .html', () => { + const prevReq = new Request('http://localhost/test'); + const request = createRouteRequest(prevReq, '/index.html'); + + expect(new URL(request.url).pathname).toEqual('/'); + }); +}); diff --git a/src/utils/http/create-route-request.ts b/src/utils/http/create-route-request.ts new file mode 100644 index 0000000..ed633b9 --- /dev/null +++ b/src/utils/http/create-route-request.ts @@ -0,0 +1,21 @@ +import { applySearchParams } from './apply-search-params'; + +/** + * Creates a new Request object with the same body, headers, and search params as the original. + * + * Replaces the URL with the given path, stripping the `.html` extension and `/index.html` for + * asset matching. + * https://developers.cloudflare.com/pages/platform/serving-pages/#route-matching + * + * @param req Request object to re-create. + * @param path URL to use for the new Request object. + * @returns A new Request object with the same body and headers as the original. + */ +export const createRouteRequest = (req: Request, path: string): Request => { + const newUrl = new URL(path, req.url); + applySearchParams(newUrl.searchParams, new URL(req.url).searchParams); + + newUrl.pathname = newUrl.pathname.replace(/\/index.html$/, '/').replace(/\.html$/, ''); + + return new Request(newUrl, req); +}; diff --git a/src/utils/http/index.ts b/src/utils/http/index.ts new file mode 100644 index 0000000..cce4079 --- /dev/null +++ b/src/utils/http/index.ts @@ -0,0 +1,6 @@ +export * from './apply-headers'; +export * from './apply-search-params'; +export * from './create-mutable-response'; +export * from './create-route-request'; +export * from './is-url'; +export * from './parse-accept-language'; diff --git a/src/utils/http/is-url.spec.ts b/src/utils/http/is-url.spec.ts new file mode 100644 index 0000000..09a7e81 --- /dev/null +++ b/src/utils/http/is-url.spec.ts @@ -0,0 +1,13 @@ +import { expect, suite, test } from 'vitest'; + +import { isUrl } from './is-url'; + +suite('isUrl', () => { + test('returns true for valid url', () => { + expect(isUrl('https://test.com')).toEqual(true); + }); + + test('returns false for invalid url', () => { + expect(isUrl('test.com')).toEqual(false); + }); +}); diff --git a/src/utils/http/is-url.ts b/src/utils/http/is-url.ts new file mode 100644 index 0000000..7d38131 --- /dev/null +++ b/src/utils/http/is-url.ts @@ -0,0 +1,7 @@ +/** + * Checks if a string is an URL. + * + * @param url String to check. + * @returns Whether the string is an URL. + */ +export const isUrl = (url: string): boolean => /^https?:\/\//.test(url); diff --git a/src/utils/http/parse-accept-language.spec.ts b/src/utils/http/parse-accept-language.spec.ts new file mode 100644 index 0000000..ab1cf86 --- /dev/null +++ b/src/utils/http/parse-accept-language.spec.ts @@ -0,0 +1,29 @@ +import { expect, suite, test } from 'vitest'; + +import { parseAcceptLanguage } from './parse-accept-language'; + +suite('parseAcceptLanguage', () => { + test('extract the locales and sort by quality when present', () => { + [ + { header: '', expected: [] }, + { header: 'en', expected: ['en'] }, + { header: 'en-US,en', expected: ['en-US', 'en'] }, + { header: 'en-US,en;q=0.9,es;q=0.8', expected: ['en-US', 'en', 'es'] }, + { + header: 'en-US,fr;q=0.7,en;q=0.9,es;q=0.8', + expected: ['en-US', 'en', 'es', 'fr'], + }, + { + header: 'fr;q=0.7,en;q=0.9,en-US,es;q=0.8', + expected: ['en-US', 'en', 'es', 'fr'], + }, + { + header: 'fr;q = 0.7,en;q =0.9,en-US,es;q= 0.8', + expected: ['en-US', 'en', 'es', 'fr'], + }, + ].forEach(({ header, expected }) => { + const result = parseAcceptLanguage(header); + expect(result).toEqual(expected); + }); + }); +}); diff --git a/src/utils/http/parse-accept-language.ts b/src/utils/http/parse-accept-language.ts new file mode 100644 index 0000000..57c8275 --- /dev/null +++ b/src/utils/http/parse-accept-language.ts @@ -0,0 +1,18 @@ +/** + * Parses the Accept-Language header value and returns an array of locales sorted by quality. + * + * @param headerValue Accept-Language header value. + * @returns Array of locales sorted by quality. + */ +export const parseAcceptLanguage = (headerValue: string): string[] => + headerValue + .split(',') + .map((val) => { + const [lang, qual] = val.split(';') as [string, string | undefined]; + const quality = parseFloat((qual ?? 'q=1').replace(/q *= */gi, '')); + + return [lang.trim(), Number.isNaN(quality) ? 1 : quality] as [string, number]; + }) + .sort((a, b) => b[1] - a[1]) + .map(([locale]) => (locale === '*' || locale === '' ? [] : locale)) + .flat(); diff --git a/src/utils/images.ts b/src/utils/images.ts deleted file mode 100644 index 6867977..0000000 --- a/src/utils/images.ts +++ /dev/null @@ -1,177 +0,0 @@ -import type { - BuildOutput, - Fetcher, - VercelImageFormatWithoutPrefix, - VercelImageRemotePattern, - VercelImagesConfig, -} from '../types'; -import { applyHeaders, createMutableResponse } from './http'; - -/** - * Checks whether the given URL matches the given remote pattern from the Vercel build output - * images configuration. - * - * https://vercel.com/docs/build-output-api/v3/configuration#images - * - * @param url URL to check. - * @param pattern Remote pattern to match against. - * @returns Whether the URL matches the remote pattern. - */ -export function isRemotePatternMatch( - url: URL, - { protocol, hostname, port, pathname }: VercelImageRemotePattern, -): boolean { - // Protocol must match if defined. - if (protocol && url.protocol.replace(/:$/, '') !== protocol) return false; - // Hostname must match regexp. - if (!new RegExp(hostname).test(url.hostname)) return false; - // Port must match regexp if defined. - if (port && !new RegExp(port).test(url.port)) return false; - // Pathname must match regexp if defined. - if (pathname && !new RegExp(pathname).test(url.pathname)) return false; - // All checks passed. - return true; -} - -type ResizingProperties = { - isRelative: boolean; - imageUrl: URL; - options: { width: number; quality: number; format: string | undefined }; -}; - -/** - * Derives the properties to use for image resizing from the incoming request, respecting the - * images configuration spec from the Vercel build output config. - * - * https://vercel.com/docs/build-output-api/v3/configuration#images - * - * @param request Incoming request. - * @param config Images configuration from the Vercel build output. - * @returns Resizing properties if the request is valid, otherwise undefined. - */ -export function getResizingProperties( - request: Request, - config?: VercelImagesConfig, -): ResizingProperties | undefined { - if (request.method !== 'GET') return undefined; - - const { origin, searchParams } = new URL(request.url); - - const rawUrl = searchParams.get('url'); - const width = Number.parseInt(searchParams.get('w') ?? '', 10); - // 75 is the default quality - https://nextjs.org/docs/app/api-reference/components/image#quality - const quality = Number.parseInt(searchParams.get('q') ?? '75', 10); - - if (!rawUrl || Number.isNaN(width) || Number.isNaN(quality)) return undefined; - if (!config?.sizes?.includes(width)) return undefined; - if (quality < 0 || quality > 100) return undefined; - - const url = new URL(rawUrl, origin); - - // SVGs must be allowed by the config. - if (url.pathname.endsWith('.svg') && !config?.dangerouslyAllowSVG) { - return undefined; - } - - const isProtocolRelative = rawUrl.startsWith('//'); - const isRelative = rawUrl.startsWith('/') && !isProtocolRelative; - - if ( - // Relative URL means same origin as deployment and is allowed. - !isRelative && - // External image URL must be allowed by domains or remote patterns. - !config?.domains?.includes(url.hostname) && - !config?.remotePatterns?.find((pattern) => isRemotePatternMatch(url, pattern)) - ) { - return undefined; - } - - const acceptHeader = request.headers.get('Accept') ?? ''; - const format = config?.formats?.find((f) => acceptHeader.includes(f))?.replace('image/', '') as - | VercelImageFormatWithoutPrefix - | undefined; - - return { - isRelative, - imageUrl: url, - options: { width, quality, format }, - }; -} - -/** - * Formats the given response to match the images configuration spec from the Vercel build output - * config. - * - * Applies headers for `Content-Security-Policy` and `Content-Disposition`, if defined in the config. - * - * https://vercel.com/docs/build-output-api/v3/configuration#images - * - * @param resp Response to format. - * @param imageUrl Image URL that was resized. - * @param config Images configuration from the Vercel build output. - * @returns Formatted response. - */ -export function formatResp(resp: Response, imageUrl: URL, config?: VercelImagesConfig): Response { - const newHeaders = new Headers(); - - if (config?.contentSecurityPolicy) { - newHeaders.set('Content-Security-Policy', config.contentSecurityPolicy); - } - - if (config?.contentDispositionType) { - const fileName = imageUrl.pathname.split('/').pop(); - const contentDisposition = fileName - ? `${config.contentDispositionType}; filename="${fileName}"` - : config.contentDispositionType; - - newHeaders.set('Content-Disposition', contentDisposition); - } - - if (!resp.headers.has('Cache-Control')) { - // Fall back to the minimumCacheTTL value if there is no Cache-Control header. - // https://vercel.com/docs/concepts/image-optimization#caching - newHeaders.set('Cache-Control', `public, max-age=${config?.minimumCacheTTL ?? 60}`); - } - - const mutableResponse = createMutableResponse(resp); - applyHeaders(mutableResponse.headers, newHeaders); - - return mutableResponse; -} - -/** - * Handles image resizing requests. - * - * @param request Incoming request. - * @param config Images configuration from the Vercel build output. - * @returns Resized image response if the request is valid, otherwise a 400 response. - */ -export async function handleImageResizingRequest( - request: Request, - { buildOutput, assetsFetcher, imagesConfig }: ImageResizingOpts, -): Promise { - const opts = getResizingProperties(request, imagesConfig); - - if (!opts) { - return new Response('Invalid image resizing request', { status: 400 }); - } - - const { isRelative, imageUrl } = opts; - - // TODO: implement proper image resizing - - const imgFetch = - isRelative && imageUrl.pathname in buildOutput - ? assetsFetcher.fetch.bind(assetsFetcher) - : fetch; - - const imageResp = await imgFetch(imageUrl); - - return formatResp(imageResp, imageUrl, imagesConfig); -} - -type ImageResizingOpts = { - buildOutput: BuildOutput; - assetsFetcher: Fetcher; - imagesConfig?: VercelImagesConfig; -}; diff --git a/src/utils/images/format-resizing-response.spec.ts b/src/utils/images/format-resizing-response.spec.ts new file mode 100644 index 0000000..c7ed951 --- /dev/null +++ b/src/utils/images/format-resizing-response.spec.ts @@ -0,0 +1,50 @@ +import { expect, suite, test } from 'vitest'; + +import type { ImagesConfig } from '@/types/images'; + +import { formatResizingResponse } from './format-resizing-response'; + +const baseConfig: ImagesConfig = { + domains: ['example.com'], + sizes: [640, 750, 828, 1080, 1200], + remotePatterns: [{ hostname: '^via\\.placeholder\\.com$' }], + formats: ['image/avif', 'image/webp'], +}; + +suite('formatResizingResponse', () => { + test('applies content security policy from the config', () => { + const config = { ...baseConfig, contentSecurityPolicy: 'default-src' }; + const imageUrl = new URL('https://localhost/images/1.jpg'); + + const newResp = formatResizingResponse(new Response(), imageUrl, config); + expect(newResp.headers.get('Content-Security-Policy')).toEqual('default-src'); + }); + + test('applies content disposition from the config', () => { + const config = { ...baseConfig, contentDispositionType: 'inline' }; + const imageUrl = new URL('https://localhost/images/1.jpg'); + + const newResp = formatResizingResponse(new Response(), imageUrl, config); + expect(newResp.headers.get('Content-Disposition')).toEqual('inline; filename="1.jpg"'); + }); + + test('uses cache ttl from config when no cache header is present', () => { + const config = baseConfig; + const imageUrl = new URL('https://localhost/images/1.jpg'); + + const newResp = formatResizingResponse(new Response(), imageUrl, config); + expect(newResp.headers.get('Cache-Control')).toEqual('public, max-age=60'); + }); + + test('does not override the cache header when one is present', () => { + const config = baseConfig; + const imageUrl = new URL('https://localhost/images/1.jpg'); + + const newResp = formatResizingResponse( + new Response(null, { headers: { 'cache-control': 'test-value' } }), + imageUrl, + config, + ); + expect(newResp.headers.get('Cache-Control')).toEqual('test-value'); + }); +}); diff --git a/src/utils/images/format-resizing-response.ts b/src/utils/images/format-resizing-response.ts new file mode 100644 index 0000000..333558e --- /dev/null +++ b/src/utils/images/format-resizing-response.ts @@ -0,0 +1,48 @@ +import type { ImagesConfig } from '@/types/images'; + +import { applyHeaders, createMutableResponse } from '../http'; + +/** + * Formats the given response to match the images configuration spec from the build output + * config. + * + * Applies headers for `Content-Security-Policy` and `Content-Disposition`, if defined in the config. + * + * https://vercel.com/docs/build-output-api/v3/configuration#images + * + * @param resp Response to format. + * @param imageUrl Image URL that was resized. + * @param config Images configuration from the build output. + * @returns Formatted response. + */ +export const formatResizingResponse = ( + resp: Response, + imageUrl: URL, + config?: ImagesConfig, +): Response => { + const newHeaders = new Headers(); + + if (config?.contentSecurityPolicy) { + newHeaders.set('Content-Security-Policy', config.contentSecurityPolicy); + } + + if (config?.contentDispositionType) { + const fileName = imageUrl.pathname.split('/').pop(); + const contentDisposition = fileName + ? `${config.contentDispositionType}; filename="${fileName}"` + : config.contentDispositionType; + + newHeaders.set('Content-Disposition', contentDisposition); + } + + if (!resp.headers.has('Cache-Control')) { + // Fall back to the minimumCacheTTL value if there is no Cache-Control header. + // https://vercel.com/docs/concepts/image-optimization#caching + newHeaders.set('Cache-Control', `public, max-age=${config?.minimumCacheTTL ?? 60}`); + } + + const mutableResponse = createMutableResponse(resp); + applyHeaders(mutableResponse.headers, newHeaders); + + return mutableResponse; +}; diff --git a/src/utils/images.spec.ts b/src/utils/images/get-resizing-properties.spec.ts similarity index 64% rename from src/utils/images.spec.ts rename to src/utils/images/get-resizing-properties.spec.ts index db9ffbc..8ae8bf8 100644 --- a/src/utils/images.spec.ts +++ b/src/utils/images/get-resizing-properties.spec.ts @@ -1,71 +1,19 @@ -import { describe, expect, test } from 'vitest'; +import { expect, suite, test } from 'vitest'; -import type { VercelImageRemotePattern, VercelImagesConfig } from '../types'; -import { formatResp, getResizingProperties, isRemotePatternMatch } from './images'; +import type { ImagesConfig } from '@/types/images'; -describe('isRemotePatternMatch', () => { - test('hostname matches correctly', () => { - const config: VercelImageRemotePattern = { - hostname: '^via\\.placeholder\\.com$', - }; - - const validUrl = new URL('https://via.placeholder.com/images/1.jpg'); - expect(isRemotePatternMatch(validUrl, config)).toEqual(true); - - const invalidUrl = new URL('https://example.com/images/1.jpg'); - expect(isRemotePatternMatch(invalidUrl, config)).toEqual(false); - }); - - test('protocol matches correctly', () => { - const config: VercelImageRemotePattern = { - protocol: 'https', - hostname: '^via\\.placeholder\\.com$', - }; - - const validUrl = new URL('https://via.placeholder.com/images/1.jpg'); - expect(isRemotePatternMatch(validUrl, config)).toEqual(true); - - const invalidUrl = new URL('http://via.placeholder.com/images/1.jpg'); - expect(isRemotePatternMatch(invalidUrl, config)).toEqual(false); - }); - - test('port matches correctly', () => { - const config: VercelImageRemotePattern = { - hostname: '^via\\.placeholder\\.com$', - port: '9000', - }; - - const validUrl = new URL('https://via.placeholder.com:9000/images/1.jpg'); - expect(isRemotePatternMatch(validUrl, config)).toEqual(true); - - const invalidUrl = new URL('http://via.placeholder.com/images/1.jpg'); - expect(isRemotePatternMatch(invalidUrl, config)).toEqual(false); - }); - - test('pathname matches correctly', () => { - const config: VercelImageRemotePattern = { - hostname: '^via\\.placeholder\\.com$', - pathname: '^/images/.*$', - }; - - const validUrl = new URL('https://via.placeholder.com:9000/images/1.jpg'); - expect(isRemotePatternMatch(validUrl, config)).toEqual(true); - - const invalidUrl = new URL('http://via.placeholder.com/videos/1.mp4'); - expect(isRemotePatternMatch(invalidUrl, config)).toEqual(false); - }); -}); +import { getResizingProperties } from './get-resizing-properties'; const baseUrl = 'https://localhost/_next/image?url='; const baseValidUrl = `${baseUrl}%2Fimages%2F1.jpg`; -const baseConfig: VercelImagesConfig = { +const baseConfig: ImagesConfig = { domains: ['example.com'], sizes: [640, 750, 828, 1080, 1200], remotePatterns: [{ hostname: '^via\\.placeholder\\.com$' }], formats: ['image/avif', 'image/webp'], }; -describe('getResizingProperties', () => { +suite('getResizingProperties', () => { test('invalid method fails', () => { const url = new URL(baseValidUrl); const req = new Request(url, { method: 'POST' }); @@ -73,7 +21,7 @@ describe('getResizingProperties', () => { expect(getResizingProperties(req)).toEqual(undefined); }); - describe('request search params', () => { + suite('request search params', () => { test('invalid url fails', () => { const url = new URL(baseUrl); const req = new Request(url); @@ -117,7 +65,7 @@ describe('getResizingProperties', () => { }); }); - describe('relative (same origin) image', () => { + suite('relative (same origin) image', () => { test('image with valid request options succeeds', () => { const url = new URL(`${baseValidUrl}&w=640`); const req = new Request(url); @@ -179,7 +127,7 @@ describe('getResizingProperties', () => { }); }); - describe('protocol relative (potentially another origin) image', () => { + suite('protocol relative (potentially another origin) image', () => { const protocolRelativePrefixes = ['%2F%2F', '//', '%2f%2f', '%2f/', '/%2f']; protocolRelativePrefixes.forEach((prefix) => { @@ -204,7 +152,7 @@ describe('getResizingProperties', () => { }); }); - describe('external image', () => { + suite('external image', () => { test('external image fails with disallowed domain', () => { const url = new URL(`${baseUrl}https%3A%2F%2Finvalid.com%2Fimage.jpg&w=640`); const req = new Request(url); @@ -237,7 +185,7 @@ describe('getResizingProperties', () => { }); }); - describe('request headers', () => { + suite('request headers', () => { test('return correct format for `accept` header (webp)', () => { const url = new URL(`${baseValidUrl}&w=640`); const req = new Request(url, { headers: { Accept: 'image/webp' } }); @@ -265,41 +213,3 @@ describe('getResizingProperties', () => { }); }); }); - -describe('formatResp', () => { - test('applies content security policy from the config', () => { - const config = { ...baseConfig, contentSecurityPolicy: 'default-src' }; - const imageUrl = new URL('https://localhost/images/1.jpg'); - - const newResp = formatResp(new Response(), imageUrl, config); - expect(newResp.headers.get('Content-Security-Policy')).toEqual('default-src'); - }); - - test('applies content disposition from the config', () => { - const config = { ...baseConfig, contentDispositionType: 'inline' }; - const imageUrl = new URL('https://localhost/images/1.jpg'); - - const newResp = formatResp(new Response(), imageUrl, config); - expect(newResp.headers.get('Content-Disposition')).toEqual('inline; filename="1.jpg"'); - }); - - test('uses cache ttl from config when no cache header is present', () => { - const config = baseConfig; - const imageUrl = new URL('https://localhost/images/1.jpg'); - - const newResp = formatResp(new Response(), imageUrl, config); - expect(newResp.headers.get('Cache-Control')).toEqual('public, max-age=60'); - }); - - test('does not override the cache header when one is present', () => { - const config = baseConfig; - const imageUrl = new URL('https://localhost/images/1.jpg'); - - const newResp = formatResp( - new Response(null, { headers: { 'cache-control': 'test-value' } }), - imageUrl, - config, - ); - expect(newResp.headers.get('Cache-Control')).toEqual('test-value'); - }); -}); diff --git a/src/utils/images/get-resizing-properties.ts b/src/utils/images/get-resizing-properties.ts new file mode 100644 index 0000000..2381bf9 --- /dev/null +++ b/src/utils/images/get-resizing-properties.ts @@ -0,0 +1,69 @@ +import type { ImageFormatWithoutPrefix, ImagesConfig } from '@/types/images'; +import type { Maybe } from '@/types/utilities'; + +import { isRemotePatternMatch } from './is-remote-pattern-match'; + +export type ResizingProperties = { + isRelative: boolean; + imageUrl: URL; + options: { width: number; quality: number; format: string | undefined }; +}; + +/** + * Derives the properties to use for image resizing from the incoming request, respecting the + * images configuration spec from the build output config. + * + * https://vercel.com/docs/build-output-api/v3/configuration#images + * + * @param request Incoming request. + * @param config Images configuration from the build output. + * @returns Resizing properties if the request is valid, otherwise undefined. + */ +export const getResizingProperties = ( + request: Request, + config?: ImagesConfig, +): ResizingProperties | undefined => { + if (request.method !== 'GET') return undefined; + + const { origin, searchParams } = new URL(request.url); + + const rawUrl = searchParams.get('url'); + const width = Number.parseInt(searchParams.get('w') ?? '', 10); + // 75 is the default quality - https://nextjs.org/docs/app/api-reference/components/image#quality + const quality = Number.parseInt(searchParams.get('q') ?? '75', 10); + + if (!rawUrl || Number.isNaN(width) || Number.isNaN(quality)) return undefined; + if (!config?.sizes?.includes(width)) return undefined; + if (quality < 0 || quality > 100) return undefined; + + const url = new URL(rawUrl, origin); + + // SVGs must be allowed by the config. + if (url.pathname.endsWith('.svg') && !config?.dangerouslyAllowSVG) { + return undefined; + } + + const isProtocolRelative = rawUrl.startsWith('//'); + const isRelative = rawUrl.startsWith('/') && !isProtocolRelative; + + if ( + // Relative URL means same origin as deployment and is allowed. + !isRelative && + // External image URL must be allowed by domains or remote patterns. + !config?.domains?.includes(url.hostname) && + !config?.remotePatterns?.find((pattern) => isRemotePatternMatch(url, pattern)) + ) { + return undefined; + } + + const acceptHeader = request.headers.get('Accept') ?? ''; + const format = config?.formats + ?.find((f) => acceptHeader.includes(f)) + ?.replace('image/', '') as Maybe; + + return { + isRelative, + imageUrl: url, + options: { width, quality, format }, + }; +}; diff --git a/src/utils/images/index.ts b/src/utils/images/index.ts new file mode 100644 index 0000000..c90c3f0 --- /dev/null +++ b/src/utils/images/index.ts @@ -0,0 +1,3 @@ +export * from './format-resizing-response'; +export * from './get-resizing-properties'; +export * from './is-remote-pattern-match'; diff --git a/src/utils/images/is-remote-pattern-match.spec.ts b/src/utils/images/is-remote-pattern-match.spec.ts new file mode 100644 index 0000000..7b8c741 --- /dev/null +++ b/src/utils/images/is-remote-pattern-match.spec.ts @@ -0,0 +1,58 @@ +import { expect, suite, test } from 'vitest'; + +import type { RemotePattern } from '@/types/images'; + +import { isRemotePatternMatch } from './is-remote-pattern-match'; + +suite('isRemotePatternMatch', () => { + test('hostname matches correctly', () => { + const config: RemotePattern = { + hostname: '^via\\.placeholder\\.com$', + }; + + const validUrl = new URL('https://via.placeholder.com/images/1.jpg'); + expect(isRemotePatternMatch(validUrl, config)).toEqual(true); + + const invalidUrl = new URL('https://example.com/images/1.jpg'); + expect(isRemotePatternMatch(invalidUrl, config)).toEqual(false); + }); + + test('protocol matches correctly', () => { + const config: RemotePattern = { + protocol: 'https', + hostname: '^via\\.placeholder\\.com$', + }; + + const validUrl = new URL('https://via.placeholder.com/images/1.jpg'); + expect(isRemotePatternMatch(validUrl, config)).toEqual(true); + + const invalidUrl = new URL('http://via.placeholder.com/images/1.jpg'); + expect(isRemotePatternMatch(invalidUrl, config)).toEqual(false); + }); + + test('port matches correctly', () => { + const config: RemotePattern = { + hostname: '^via\\.placeholder\\.com$', + port: '9000', + }; + + const validUrl = new URL('https://via.placeholder.com:9000/images/1.jpg'); + expect(isRemotePatternMatch(validUrl, config)).toEqual(true); + + const invalidUrl = new URL('http://via.placeholder.com/images/1.jpg'); + expect(isRemotePatternMatch(invalidUrl, config)).toEqual(false); + }); + + test('pathname matches correctly', () => { + const config: RemotePattern = { + hostname: '^via\\.placeholder\\.com$', + pathname: '^/images/.*$', + }; + + const validUrl = new URL('https://via.placeholder.com:9000/images/1.jpg'); + expect(isRemotePatternMatch(validUrl, config)).toEqual(true); + + const invalidUrl = new URL('http://via.placeholder.com/videos/1.mp4'); + expect(isRemotePatternMatch(invalidUrl, config)).toEqual(false); + }); +}); diff --git a/src/utils/images/is-remote-pattern-match.ts b/src/utils/images/is-remote-pattern-match.ts new file mode 100644 index 0000000..48c67e5 --- /dev/null +++ b/src/utils/images/is-remote-pattern-match.ts @@ -0,0 +1,27 @@ +import type { RemotePattern } from '@/types/images'; + +/** + * Checks whether the given URL matches the given remote pattern from the build output images + * configuration. + * + * https://vercel.com/docs/build-output-api/v3/configuration#images + * + * @param url URL to check. + * @param pattern Remote pattern to match against. + * @returns Whether the URL matches the remote pattern. + */ +export const isRemotePatternMatch = ( + url: URL, + { protocol, hostname, port, pathname }: RemotePattern, +): boolean => { + // Protocol must match if defined. + if (protocol && url.protocol.replace(/:$/, '') !== protocol) return false; + // Hostname must match regexp. + if (!new RegExp(hostname).test(url.hostname)) return false; + // Port must match regexp if defined. + if (port && !new RegExp(port).test(url.port)) return false; + // Pathname must match regexp if defined. + if (pathname && !new RegExp(pathname).test(url.pathname)) return false; + // All checks passed. + return true; +}; diff --git a/src/utils/index.ts b/src/utils/index.ts deleted file mode 100644 index 00396f0..0000000 --- a/src/utils/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export * from './collect-locales-from-routes'; -export * from './group-routes-by-phase'; -export * from './http'; -export * from './images'; -export * from './is-handler-route'; -export * from './matcher'; -export * from './pcre'; -export * from './routing'; diff --git a/src/utils/pcre.spec.ts b/src/utils/pcre/apply-pcre-matches.spec.ts similarity index 69% rename from src/utils/pcre.spec.ts rename to src/utils/pcre/apply-pcre-matches.spec.ts index 9481d75..36cb186 100644 --- a/src/utils/pcre.spec.ts +++ b/src/utils/pcre/apply-pcre-matches.spec.ts @@ -1,7 +1,9 @@ -import { describe, expect, test } from 'vitest'; +import { expect, suite, test } from 'vitest'; -import type { SourceRoute } from '../types'; -import { applyPCREMatches, matchPCRE } from './pcre'; +import type { SourceRoute } from '@/types/build-output'; + +import { applyPCREMatches } from './apply-pcre-matches'; +import { matchPCRE } from './match-pcre'; type TestCase = { name: string; @@ -11,53 +13,7 @@ type TestCase = { expected: { match: boolean; captureGroupKeys: string[]; newDest?: string }; }; -describe('matchPCRE', () => { - const testCases: TestCase[] = [ - { - name: 'should match a basic route', - url: 'https://example.com/index', - route: { src: '^/index(?:/)?' }, - expected: { match: true, captureGroupKeys: [] }, - }, - { - name: 'should not match with invalid case sensitive route', - url: 'https://example.com/INDEX', - route: { src: '^/index(?:/)?', caseSensitive: true }, - expected: { match: false, captureGroupKeys: [] }, - }, - { - name: 'should match with valid case sensitive route', - url: 'https://example.com/INDEX', - route: { src: '^/INDEX(?:/)?', caseSensitive: true }, - expected: { match: true, captureGroupKeys: [] }, - }, - { - name: 'should match when case sensitive is not set', - url: 'https://example.com/index', - route: { src: '^/INDEX(?:/)?' }, - expected: { match: true, captureGroupKeys: [] }, - }, - { - name: 'should match with named capture groups', - url: 'https://example.com/index', - route: { src: '^/i(?nde)x(?:/)?' }, - expected: { match: true, captureGroupKeys: ['name'] }, - }, - ]; - - testCases.forEach((testCase) => { - test(testCase.name, () => { - const result = matchPCRE( - testCase.route.src, - new URL(testCase.url).pathname, - testCase.route.caseSensitive, - ); - expect({ ...result, match: !!result.match }).toEqual(testCase.expected); - }); - }); -}); - -describe('applyPCREMatches', () => { +suite('applyPCREMatches', () => { const testCases: TestCase[] = [ { name: 'should process a dest for a basic route', diff --git a/src/utils/pcre/apply-pcre-matches.ts b/src/utils/pcre/apply-pcre-matches.ts new file mode 100644 index 0000000..335aff5 --- /dev/null +++ b/src/utils/pcre/apply-pcre-matches.ts @@ -0,0 +1,27 @@ +/** + * Processes the value and replaced any matched parameters (index or named capture groups). + * + * @param rawStr String to process. + * @param match Matches from the PCRE matcher. + * @param captureGroupKeys Named capture group keys from the PCRE matcher. + * @param opts Options for applying the PCRE matches. + * @returns The processed string with replaced parameters. + */ +export const applyPCREMatches = ( + rawStr: string, + match: RegExpMatchArray, + captureGroupKeys: string[], + { namedOnly }: { namedOnly?: boolean } = {}, +): string => + rawStr.replace(/\$([a-zA-Z0-9_]+)/g, (originalValue, key) => { + const index = captureGroupKeys.indexOf(key); + + // If we only want named capture groups, and the key is not found, return the original value. + if (namedOnly && index === -1) { + return originalValue; + } + + // If the extracted key does not exist as a named capture group from the matcher, we can + // reasonably assume it's a number and return the matched index. Fallback to an empty string. + return (index === -1 ? match[parseInt(key, 10)] : match[index + 1]) || ''; + }); diff --git a/src/utils/pcre/index.ts b/src/utils/pcre/index.ts new file mode 100644 index 0000000..9aaf784 --- /dev/null +++ b/src/utils/pcre/index.ts @@ -0,0 +1,2 @@ +export * from './apply-pcre-matches'; +export * from './match-pcre'; diff --git a/src/utils/pcre/match-pcre.spec.ts b/src/utils/pcre/match-pcre.spec.ts new file mode 100644 index 0000000..576020a --- /dev/null +++ b/src/utils/pcre/match-pcre.spec.ts @@ -0,0 +1,59 @@ +import { expect, suite, test } from 'vitest'; + +import type { SourceRoute } from '@/types/build-output'; + +import { matchPCRE } from './match-pcre'; + +type TestCase = { + name: string; + url: string; + route: SourceRoute; + opts?: { namedOnly?: boolean }; + expected: { match: boolean; captureGroupKeys: string[]; newDest?: string }; +}; + +suite('matchPCRE', () => { + const testCases: TestCase[] = [ + { + name: 'should match a basic route', + url: 'https://example.com/index', + route: { src: '^/index(?:/)?' }, + expected: { match: true, captureGroupKeys: [] }, + }, + { + name: 'should not match with invalid case sensitive route', + url: 'https://example.com/INDEX', + route: { src: '^/index(?:/)?', caseSensitive: true }, + expected: { match: false, captureGroupKeys: [] }, + }, + { + name: 'should match with valid case sensitive route', + url: 'https://example.com/INDEX', + route: { src: '^/INDEX(?:/)?', caseSensitive: true }, + expected: { match: true, captureGroupKeys: [] }, + }, + { + name: 'should match when case sensitive is not set', + url: 'https://example.com/index', + route: { src: '^/INDEX(?:/)?' }, + expected: { match: true, captureGroupKeys: [] }, + }, + { + name: 'should match with named capture groups', + url: 'https://example.com/index', + route: { src: '^/i(?nde)x(?:/)?' }, + expected: { match: true, captureGroupKeys: ['name'] }, + }, + ]; + + testCases.forEach((testCase) => { + test(testCase.name, () => { + const result = matchPCRE( + testCase.route.src, + new URL(testCase.url).pathname, + testCase.route.caseSensitive, + ); + expect({ ...result, match: !!result.match }).toEqual(testCase.expected); + }); + }); +}); diff --git a/src/utils/pcre.ts b/src/utils/pcre/match-pcre.ts similarity index 52% rename from src/utils/pcre.ts rename to src/utils/pcre/match-pcre.ts index 201d12e..c2019d2 100644 --- a/src/utils/pcre.ts +++ b/src/utils/pcre/match-pcre.ts @@ -18,11 +18,11 @@ export type MatchPCREResult = { * @param caseSensitive Whether the regular expression should be case sensitive. * @returns The result of the matcher and the named capture group keys. */ -export function matchPCRE( +export const matchPCRE = ( expr: string, val: string | undefined | null, caseSensitive?: boolean, -): MatchPCREResult { +): MatchPCREResult => { if (val === null || val === undefined) { return { match: null, captureGroupKeys: [] }; } @@ -34,33 +34,4 @@ export function matchPCRE( const match = matcher.exec(val); return { match, captureGroupKeys }; -} - -/** - * Processes the value and replaced any matched parameters (index or named capture groups). - * - * @param rawStr String to process. - * @param match Matches from the PCRE matcher. - * @param captureGroupKeys Named capture group keys from the PCRE matcher. - * @param opts Options for applying the PCRE matches. - * @returns The processed string with replaced parameters. - */ -export function applyPCREMatches( - rawStr: string, - match: RegExpMatchArray, - captureGroupKeys: string[], - { namedOnly }: { namedOnly?: boolean } = {}, -): string { - return rawStr.replace(/\$([a-zA-Z0-9_]+)/g, (originalValue, key) => { - const index = captureGroupKeys.indexOf(key); - - // If we only want named capture groups, and the key is not found, return the original value. - if (namedOnly && index === -1) { - return originalValue; - } - - // If the extracted key does not exist as a named capture group from the matcher, we can - // reasonably assume it's a number and return the matched index. Fallback to an empty string. - return (index === -1 ? match[parseInt(key, 10)] : match[index + 1]) || ''; - }); -} +}; diff --git a/src/utils/routing.ts b/src/utils/routing.ts deleted file mode 100644 index 2e6de0b..0000000 --- a/src/utils/routing.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { Phase } from '../types'; - -export type RoutingMatch = { - path: string; - status: number | undefined; - headers: { - /** - * The headers present on a source route. - * Gets applied to the final response before the response headers from running a function. - */ - normal: Headers; - /** - * The *important* headers - the ones present on a source route that specifies `important: true`. - * Gets applied to the final response after the response headers from running a function. - */ - important: Headers; - /** - * Tracks if a location header is found, and what the value is, after running a middleware function. - */ - middlewareLocation?: string | null; - }; - searchParams: URLSearchParams; - body: BodyInit | undefined | null; -}; - -/** - * Gets the next phase of the routing process. - * - * Determines which phase should follow the `none`, `filesystem`, `rewrite`, or `resource` phases. - * Falls back to `miss`. - * - * @param phase Current phase of the routing process. - * @returns Next phase of the routing process. - */ -export function getNextPhase(phase: Phase): Phase { - switch (phase) { - // `none` applied headers/redirects/middleware/`beforeFiles` rewrites. It checked non-dynamic routes and static assets. - case 'none': - return 'filesystem'; - // `filesystem` applied `afterFiles` rewrites. It checked those rewritten routes. - case 'filesystem': - return 'rewrite'; - // `rewrite` applied dynamic params to requests. It checked dynamic routes. - case 'rewrite': - return 'resource'; - // `resource` applied `fallback` rewrites. It checked the final routes. - case 'resource': - default: - return 'miss'; - } -} - -/** - * Checks if a source route's matcher uses the regex format for locales with a trailing slash, where - * the locales specified are known. - * - * Determines whether a matcher is in the format of `^//?(?:en|fr|nl)/(.*)$`. - * - * @param src Source route `src` regex value. - * @param locales Known available locales. - * @returns Whether the source route matches the regex for a locale with a trailing slash. - */ -export function isLocaleTrailingSlashRegex(src: string, locales: Set) { - const prefix = '^//?(?:'; - const suffix = ')/(.*)$'; - - if (!src.startsWith(prefix) || !src.endsWith(suffix)) { - return false; - } - - const foundLocales = src.slice(prefix.length, -suffix.length).split('|'); - return foundLocales.every((locale) => locales.has(locale)); -} diff --git a/src/utils/matcher.spec.ts b/src/utils/routing/check-has-field.spec.ts similarity index 95% rename from src/utils/matcher.spec.ts rename to src/utils/routing/check-has-field.spec.ts index 837d844..69b143b 100644 --- a/src/utils/matcher.spec.ts +++ b/src/utils/routing/check-has-field.spec.ts @@ -1,12 +1,13 @@ import { parse } from 'cookie'; -import { describe, expect, test } from 'vitest'; +import { expect, suite, test } from 'vitest'; -import type { VercelHasField } from '../types'; -import { checkHasField } from './matcher'; +import type { SourceRouteHasField } from '@/types/build-output'; + +import { checkHasField } from './check-has-field'; type HasFieldTestCase = { name: string; - has: VercelHasField; + has: SourceRouteHasField; dest?: string; expected: { valid: boolean; newRouteDest?: string }; }; @@ -25,7 +26,7 @@ const req = new Request( const url = new URL(req.url); const cookies = parse(req.headers.get('cookie') ?? ''); -describe('checkHasField', () => { +suite('checkHasField', () => { const testCases: HasFieldTestCase[] = [ { name: 'host: valid host returns true', diff --git a/src/utils/matcher.ts b/src/utils/routing/check-has-field.ts similarity index 92% rename from src/utils/matcher.ts rename to src/utils/routing/check-has-field.ts index 0dcf66e..06fd816 100644 --- a/src/utils/matcher.ts +++ b/src/utils/routing/check-has-field.ts @@ -1,6 +1,33 @@ -/* eslint-disable @typescript-eslint/no-use-before-define */ -import type { VercelHasField } from '../types'; -import { applyPCREMatches, matchPCRE } from './pcre'; +import type { SourceRouteHasField } from '@/types/build-output'; +import { applyPCREMatches, matchPCRE } from '@/utils/pcre'; + +/** + * Gets the has field PCRE match results, and tries to apply any named capture groups to a + * route destination. + * + * @param hasValue The has field value to match against. + * @param foundValue The value found in the request. + * @param routeDest Destination to apply match to. + * @returns Whether the match is valid, and the destination with the match applied. + */ +function getHasFieldPCREMatchResult( + hasValue: string, + foundValue: string | null, + routeDest?: string, +): { valid: boolean; newRouteDest?: string } { + const { match, captureGroupKeys } = matchPCRE(hasValue, foundValue); + + if (routeDest && match && captureGroupKeys.length) { + return { + valid: !!match, + newRouteDest: applyPCREMatches(routeDest, match, captureGroupKeys, { + namedOnly: true, + }), + }; + } + + return { valid: !!match }; +} type HasFieldRequestProperties = { url: URL; @@ -18,7 +45,7 @@ type HasFieldRequestProperties = { * @returns Whether the request matches the `has` record conditions, and the new destination if it changed. */ export function checkHasField( - has: VercelHasField, + has: SourceRouteHasField, { url, cookies, headers, routeDest }: HasFieldRequestProperties, ): { valid: boolean; newRouteDest?: string } { // eslint-disable-next-line default-case @@ -51,31 +78,3 @@ export function checkHasField( } } } - -/** - * Gets the has field PCRE match results, and tries to apply any named capture groups to a - * route destination. - * - * @param hasValue The has field value to match against. - * @param foundValue The value found in the request. - * @param routeDest Destination to apply match to. - * @returns Whether the match is valid, and the destination with the match applied. - */ -function getHasFieldPCREMatchResult( - hasValue: string, - foundValue: string | null, - routeDest?: string, -): { valid: boolean; newRouteDest?: string } { - const { match, captureGroupKeys } = matchPCRE(hasValue, foundValue); - - if (routeDest && match && captureGroupKeys.length) { - return { - valid: !!match, - newRouteDest: applyPCREMatches(routeDest, match, captureGroupKeys, { - namedOnly: true, - }), - }; - } - - return { valid: !!match }; -} diff --git a/src/utils/collect-locales-from-routes.ts b/src/utils/routing/collect-locales-from-routes.ts similarity index 88% rename from src/utils/collect-locales-from-routes.ts rename to src/utils/routing/collect-locales-from-routes.ts index 28737d9..edc19bd 100644 --- a/src/utils/collect-locales-from-routes.ts +++ b/src/utils/routing/collect-locales-from-routes.ts @@ -1,4 +1,5 @@ -import type { Route, RoutesGroupedByPhase } from '../types'; +import type { Route, RoutesGroupedByPhase } from '@/types/build-output'; + import { isHandlerRoute } from './is-handler-route'; /** diff --git a/src/utils/routing/get-next-phase.ts b/src/utils/routing/get-next-phase.ts new file mode 100644 index 0000000..d46864e --- /dev/null +++ b/src/utils/routing/get-next-phase.ts @@ -0,0 +1,28 @@ +import type { Phase } from '@/types/build-output'; + +/** + * Gets the next phase of the routing process. + * + * Determines which phase should follow the `none`, `filesystem`, `rewrite`, or `resource` phases. + * Falls back to `miss`. + * + * @param phase Current phase of the routing process. + * @returns Next phase of the routing process. + */ +export const getNextPhase = (phase: Phase): Phase => { + switch (phase) { + // `none` applied headers/redirects/middleware/`beforeFiles` rewrites. It checked non-dynamic routes and static assets. + case 'none': + return 'filesystem'; + // `filesystem` applied `afterFiles` rewrites. It checked those rewritten routes. + case 'filesystem': + return 'rewrite'; + // `rewrite` applied dynamic params to requests. It checked dynamic routes. + case 'rewrite': + return 'resource'; + // `resource` applied `fallback` rewrites. It checked the final routes. + case 'resource': + default: + return 'miss'; + } +}; diff --git a/src/utils/group-routes-by-phase.ts b/src/utils/routing/group-routes-by-phase.ts similarity index 98% rename from src/utils/group-routes-by-phase.ts rename to src/utils/routing/group-routes-by-phase.ts index dcd4089..b58fa76 100644 --- a/src/utils/group-routes-by-phase.ts +++ b/src/utils/routing/group-routes-by-phase.ts @@ -1,4 +1,5 @@ -import type { Phase, Route, RoutesGroupedByPhase, SourceRoute } from '../types'; +import type { Phase, Route, RoutesGroupedByPhase, SourceRoute } from '@/types/build-output'; + import { isHandlerRoute } from './is-handler-route'; const append = (str: string, suffix: string) => (str.endsWith(suffix) ? str : `${str}${suffix}`); diff --git a/src/utils/routing/index.ts b/src/utils/routing/index.ts new file mode 100644 index 0000000..1e2d7e5 --- /dev/null +++ b/src/utils/routing/index.ts @@ -0,0 +1,6 @@ +export * from './check-has-field'; +export * from './collect-locales-from-routes'; +export * from './get-next-phase'; +export * from './group-routes-by-phase'; +export * from './is-handler-route'; +export * from './is-locale-trailing-slash-regex'; diff --git a/src/utils/is-handler-route.ts b/src/utils/routing/is-handler-route.ts similarity index 78% rename from src/utils/is-handler-route.ts rename to src/utils/routing/is-handler-route.ts index e258fbd..ae61617 100644 --- a/src/utils/is-handler-route.ts +++ b/src/utils/routing/is-handler-route.ts @@ -1,4 +1,4 @@ -import type { HandlerRoute, Route } from '../types'; +import type { HandlerRoute, Route } from '@/types/build-output'; /** * Determine whether a route is a source route or a handler. diff --git a/src/utils/routing/is-locale-trailing-slash-regex.ts b/src/utils/routing/is-locale-trailing-slash-regex.ts new file mode 100644 index 0000000..e2ce38e --- /dev/null +++ b/src/utils/routing/is-locale-trailing-slash-regex.ts @@ -0,0 +1,21 @@ +/** + * Checks if a source route's matcher uses the regex format for locales with a trailing slash, where + * the locales specified are known. + * + * Determines whether a matcher is in the format of `^//?(?:en|fr|nl)/(.*)$`. + * + * @param src Source route `src` regex value. + * @param locales Known available locales. + * @returns Whether the source route matches the regex for a locale with a trailing slash. + */ +export const isLocaleTrailingSlashRegex = (src: string, locales: Set) => { + const prefix = '^//?(?:'; + const suffix = ')/(.*)$'; + + if (!src.startsWith(prefix) || !src.endsWith(suffix)) { + return false; + } + + const foundLocales = src.slice(prefix.length, -suffix.length).split('|'); + return foundLocales.every((locale) => locales.has(locale)); +}; diff --git a/tsconfig.json b/tsconfig.json index 4cc7a2d..1c45765 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,10 @@ "stripInternal": true, "declaration": true, "declarationMap": true, - "exactOptionalPropertyTypes": false + "exactOptionalPropertyTypes": false, + "paths": { + "@/*": ["src/*/index.ts"] + } }, "include": ["**/*.ts"], "exclude": ["dist"]