diff --git a/package-lock.json b/package-lock.json index d6ce22695..60ceb3b51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5704,6 +5704,23 @@ "license": "MIT", "peer": true }, + "node_modules/build-output-router": { + "version": "0.0.1-defb153", + "resolved": "https://registry.npmjs.org/build-output-router/-/build-output-router-0.0.1-defb153.tgz", + "integrity": "sha512-wTwzTq2im6s6O9jfpO/C2GCw9is9/Jow80KfideML0CUOMKr4bf36tREllNgg23DDlplR+Cwa30FG70xgfspkg==", + "dependencies": { + "cookie": "0.6.0", + "pcre-to-regexp": "1.1.0" + } + }, + "node_modules/build-output-router/node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/builtins": { "version": "5.0.1", "dev": true, @@ -14615,7 +14632,7 @@ } }, "packages/eslint-plugin-next-on-pages": { - "version": "1.11.3", + "version": "1.13.3", "license": "MIT", "dependencies": { "@types/estree-jsx": "^1.0.0", @@ -14653,20 +14670,19 @@ }, "packages/next-on-pages": { "name": "@cloudflare/next-on-pages", - "version": "1.11.3", + "version": "1.13.3", "license": "MIT", "dependencies": { "acorn": "^8.8.0", "ast-types": "^0.14.2", + "build-output-router": "0.0.1-defb153", "chalk": "^5.2.0", "chokidar": "^3.5.3", "commander": "^11.1.0", - "cookie": "^0.5.0", "esbuild": "^0.15.3", "js-yaml": "^4.1.0", "miniflare": "^3.20231218.1", "package-manager-manager": "^0.2.0", - "pcre-to-regexp": "^1.1.0", "semver": "^7.5.2" }, "bin": { diff --git a/packages/next-on-pages/package.json b/packages/next-on-pages/package.json index 0847fd4f7..5d1f8d3e9 100644 --- a/packages/next-on-pages/package.json +++ b/packages/next-on-pages/package.json @@ -56,15 +56,14 @@ "dependencies": { "acorn": "^8.8.0", "ast-types": "^0.14.2", + "build-output-router": "0.0.1-defb153", "chalk": "^5.2.0", "chokidar": "^3.5.3", "commander": "^11.1.0", - "cookie": "^0.5.0", "esbuild": "^0.15.3", "js-yaml": "^4.1.0", "miniflare": "^3.20231218.1", "package-manager-manager": "^0.2.0", - "pcre-to-regexp": "^1.1.0", "semver": "^7.5.2" }, "devDependencies": { @@ -88,9 +87,9 @@ "vitest-environment-miniflare": "^2.13.0" }, "peerDependencies": { + "@cloudflare/workers-types": "^4.20240208.0", "vercel": ">=30.0.0", - "wrangler": "^3.28.2", - "@cloudflare/workers-types": "^4.20240208.0" + "wrangler": "^3.28.2" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { diff --git a/packages/next-on-pages/src/buildApplication/buildWorkerFile.ts b/packages/next-on-pages/src/buildApplication/buildWorkerFile.ts index 2d01843cd..a6053651c 100644 --- a/packages/next-on-pages/src/buildApplication/buildWorkerFile.ts +++ b/packages/next-on-pages/src/buildApplication/buildWorkerFile.ts @@ -6,6 +6,7 @@ import { generateGlobalJs } from './generateGlobalJs'; import type { ProcessedVercelOutput } from './processVercelOutput'; import { getNodeEnv } from '../utils/getNodeEnv'; import { normalizePath } from '../utils'; +import { collectLocalesFromRoutes } from 'build-output-router/router'; import { cliLog } from '../cli'; /** @@ -20,10 +21,6 @@ export function constructBuildOutputRecord( outputDir: string, ) { if (item.type === 'static') { - return `{ type: ${JSON.stringify(item.type)} }`; - } - - if (item.type === 'override') { return `{ type: ${JSON.stringify(item.type)}, path: ${item.path ? JSON.stringify(item.path) : undefined}, @@ -86,7 +83,7 @@ export async function buildWorkerFile( __CONFIG__: JSON.stringify(vercelConfig), __NODE_ENV__: JSON.stringify(getNodeEnv()), __BUILD_METADATA__: JSON.stringify({ - collectedLocales: collectLocales(vercelConfig.routes), + collectedLocales: collectLocalesFromRoutes(vercelConfig.routes), }), }, outfile: outputFile, @@ -134,22 +131,3 @@ type BuildWorkerFileOpts = { customEntrypoint?: string; minify?: boolean; }; - -/** - * Collects all the locales present in the processed Vercel routes - * - * @param routes The Vercel routes to collect the locales from - * @returns an array containing all the found locales (without duplicates) - */ -function collectLocales(routes: ProcessedVercelRoutes): string[] { - const locales = Object.values(routes) - .flat() - .flatMap(source => { - if (source.locale?.redirect) { - return Object.keys(source.locale.redirect); - } - return []; - }) - .filter(Boolean); - return [...new Set(locales)]; -} diff --git a/packages/next-on-pages/src/buildApplication/processVercelOutput.ts b/packages/next-on-pages/src/buildApplication/processVercelOutput.ts index b9a5fdaf2..38c6f0d41 100644 --- a/packages/next-on-pages/src/buildApplication/processVercelOutput.ts +++ b/packages/next-on-pages/src/buildApplication/processVercelOutput.ts @@ -230,8 +230,8 @@ function applyVercelOverrides( const assetPath = addLeadingSlash(rawAssetPath); const servedPath = addLeadingSlash(rawServedPath ?? ''); - const newValue: BuildOutputStaticOverride = { - type: 'override', + const newValue: BuildOutputStaticAsset = { + type: 'static', path: assetPath, headers: contentType ? { 'content-type': contentType } : undefined, }; @@ -270,14 +270,14 @@ function applyPrerenderedRoutes( const path = route?.path ?? stripFuncExtension(relativePath); vercelOutput.set(path, { - type: 'override', + type: 'static', path, headers: route?.headers, }); route?.overrides?.forEach(overridenPath => { vercelOutput.set(overridenPath, { - type: 'override', + type: 'static', path, headers: route?.headers, }); diff --git a/packages/next-on-pages/templates/_worker.js/handleRequest.ts b/packages/next-on-pages/templates/_worker.js/handleRequest.ts deleted file mode 100644 index 418ba249d..000000000 --- a/packages/next-on-pages/templates/_worker.js/handleRequest.ts +++ /dev/null @@ -1,128 +0,0 @@ -import type { MatchedSet } from './utils'; -import { - applyHeaders, - applySearchParams, - isUrl, - runOrFetchBuildOutputItem, -} from './utils'; -import { RoutesMatcher } from './routes-matcher'; -import type { RequestContext } from '../../src/utils/requestContext'; - -/** - * Handles a request by processing and matching it against all the routing phases. - * - * @param reqCtx Request Context object (contains all we need in to know regarding the request in order to handle it). - * @param config The processed Vercel build output config. - * @param output Vercel build output. - * @param buildMetadata Metadata generated by the next-on-pages build process. - * @returns An instance of the router. - */ -export async function handleRequest( - reqCtx: RequestContext, - config: ProcessedVercelConfig, - output: VercelBuildOutput, - buildMetadata: NextOnPagesBuildMetadata, -): Promise { - const matcher = new RoutesMatcher( - config.routes, - output, - reqCtx, - buildMetadata, - config.wildcard, - ); - const match = await findMatch(matcher); - - return generateResponse(reqCtx, match, output); -} - -/** - * Finds a match for the request. - * - * @param matcher Instance of the matcher for the request. - * @param phase The phase to run, either `none` or `error`. - * @param skipErrorMatch Whether to skip the error match. - * @returns The matched set of path, status, headers, and search params. - */ -async function findMatch( - matcher: RoutesMatcher, - phase: 'none' | 'error' = 'none', - skipErrorMatch = false, -): Promise { - const result = await matcher.run(phase); - - if ( - result === 'error' || - (!skipErrorMatch && matcher.status && matcher.status >= 400) - ) { - return findMatch(matcher, 'error', true); - } - - return { - path: matcher.path, - status: matcher.status, - headers: matcher.headers, - searchParams: matcher.searchParams, - body: matcher.body, - }; -} - -/** - * Serves a file from the Vercel build output. - * - * @param reqCtx Request Context object. - * @param match The match from the Vercel build output. - * @returns A response object. - */ -async function generateResponse( - reqCtx: RequestContext, - { path = '/404', status, headers, searchParams, body }: MatchedSet, - output: VercelBuildOutput, -): Promise { - // Redirect user to external URL for redirects. - const locationHeader = headers.normal.get('location'); - if (locationHeader) { - // Apply the search params to the location header if it was not from middleware. - // Middleware that returns a redirect will specify the destination, including any search params - // that they want to include. Therefore, we should not be appending search params to those. - if (locationHeader !== headers.middlewareLocation) { - const paramsStr = [...searchParams.keys()].length - ? `?${searchParams.toString()}` - : ''; - headers.normal.set('location', `${locationHeader ?? '/'}${paramsStr}`); - } - - return new Response(null, { status, headers: headers.normal }); - } - - let resp: Response; - - if (body !== undefined) { - // If we have a response body from matching, use it instead. - resp = new Response(body, { status }); - } else if (isUrl(path)) { - // If the path is an URL from matching, that means it was rewritten to a full URL. - const url = new URL(path); - applySearchParams(url.searchParams, searchParams); - resp = await fetch(url, reqCtx.request); - } else { - // Otherwise, we need to serve a file from the Vercel build output. - resp = await runOrFetchBuildOutputItem(output[path], reqCtx, { - path, - status, - headers, - searchParams, - }); - } - - const newHeaders = headers.normal; - applyHeaders(newHeaders, resp.headers); - applyHeaders(newHeaders, headers.important); - - resp = new Response(resp.body, { - ...resp, - status: status || resp.status, - headers: newHeaders, - }); - - return resp; -} diff --git a/packages/next-on-pages/templates/_worker.js/index.ts b/packages/next-on-pages/templates/_worker.js/index.ts index 50cf212af..0cc547b18 100644 --- a/packages/next-on-pages/templates/_worker.js/index.ts +++ b/packages/next-on-pages/templates/_worker.js/index.ts @@ -1,12 +1,13 @@ import { SUSPENSE_CACHE_URL } from '../cache'; -import { handleRequest } from './handleRequest'; import { setupRoutesIsolation } from './routesIsolation'; import { adjustRequestForVercel, + getAssetsHandler, handleImageResizingRequest, patchFetch, } from './utils'; import type { AsyncLocalStorage } from 'node:async_hooks'; +import { Router } from 'build-output-router'; declare const __NODE_ENV__: string; @@ -21,6 +22,11 @@ declare const __ALSes_PROMISE__: Promise; }>; +const router = new Router(__CONFIG__.routes, { + locales: new Set(__BUILD_METADATA__.collectedLocales), + wildcardConfig: __CONFIG__.wildcard, +}); + export default { async fetch(request, env, ctx) { setupRoutesIsolation(); @@ -60,16 +66,13 @@ export default { const adjustedRequest = adjustRequestForVercel(request); - return handleRequest( - { - request: adjustedRequest, - ctx, - assetsFetcher: env.ASSETS, - }, - __CONFIG__, - __BUILD_OUTPUT__, - __BUILD_METADATA__, - ); + const assets = getAssetsHandler(__BUILD_OUTPUT__, { + request: adjustedRequest, + ctx, + assetsFetcher: env.ASSETS, + }); + + return router.fetch({ request: adjustedRequest, ctx, assets }); }, ); }, diff --git a/packages/next-on-pages/templates/_worker.js/routes-matcher.ts b/packages/next-on-pages/templates/_worker.js/routes-matcher.ts deleted file mode 100644 index cbfbc64ca..000000000 --- a/packages/next-on-pages/templates/_worker.js/routes-matcher.ts +++ /dev/null @@ -1,681 +0,0 @@ -import { parse } from 'cookie'; -import type { MatchPCREResult, MatchedSetHeaders } from './utils'; -import { isLocaleTrailingSlashRegex, parseAcceptLanguage } from './utils'; -import { - applyHeaders, - applyPCREMatches, - applySearchParams, - checkhasField, - getNextPhase, - isUrl, - matchPCRE, - runOrFetchBuildOutputItem, -} from './utils'; -import type { RequestContext } from '../../src/utils/requestContext'; - -export type CheckRouteStatus = 'skip' | 'next' | 'done' | 'error'; -export type CheckPhaseStatus = Extract; - -/** - * The routes matcher is used to match a request to a route and run the route's middleware. - */ -export class RoutesMatcher { - /** URL from the request to match */ - private url: URL; - /** Cookies from the request to match */ - private cookies: Record; - /** Wildcard match from the Vercel build output config */ - private wildcardMatch: VercelWildCard | undefined; - - /** Path for the matched route */ - public path: string; - /** Status for the response object */ - public status: number | undefined; - /** Headers for the response object */ - public headers: MatchedSetHeaders; - /** Search params for the response object */ - public searchParams: URLSearchParams; - /** Custom response body from middleware */ - public body: BodyInit | undefined | null; - - /** Counter for how many times the function to check a phase has been called */ - public checkPhaseCounter; - /** Tracker for the middleware that have been invoked in a phase */ - private middlewareInvoked: string[]; - /** Locales found during routing */ - public locales: Set; - - /** - * Creates a new instance of a request matcher. - * - * The matcher is used to match a request to a route and run the route's middleware. - * - * @param routes The processed Vercel build output config routes. - * @param output Vercel build output. - * @param reqCtx Request context object; request object, assets fetcher, and execution context. - * @param buildMetadata Metadata generated by the next-on-pages build process. - * @param wildcardConfig Wildcard options from the Vercel build output config. - * @returns The matched set of path, status, headers, and search params. - */ - constructor( - /** Processed routes from the Vercel build output config. */ - private routes: ProcessedVercelRoutes, - /** Vercel build output. */ - private output: VercelBuildOutput, - /** Request Context object for the request to match */ - private reqCtx: RequestContext, - buildMetadata: NextOnPagesBuildMetadata, - wildcardConfig?: VercelWildcardConfig, - ) { - this.url = new URL(reqCtx.request.url); - this.cookies = parse(reqCtx.request.headers.get('cookie') || ''); - - this.path = this.url.pathname || '/'; - this.headers = { normal: new Headers(), important: new Headers() }; - this.searchParams = new URLSearchParams(); - applySearchParams(this.searchParams, this.url.searchParams); - - this.checkPhaseCounter = 0; - this.middlewareInvoked = []; - - this.wildcardMatch = wildcardConfig?.find( - w => w.domain === this.url.hostname, - ); - - this.locales = new Set(buildMetadata.collectedLocales); - } - - /** - * Checks if a Vercel source route from the build output config matches the request. - * - * @param route Build output config source route. - * @param checkStatus Whether to check the status code of the route. - * @returns The source path match result if the route matches, otherwise `undefined`. - */ - private checkRouteMatch( - route: VercelSource, - { - checkStatus, - checkIntercept, - }: { checkStatus: boolean; checkIntercept: boolean }, - ): { routeMatch: MatchPCREResult; routeDest?: string } | undefined { - const srcMatch = matchPCRE(route.src, this.path, route.caseSensitive); - if (!srcMatch.match) return; - - // One of the HTTP `methods` conditions must be met - skip if not met. - if ( - route.methods && - !route.methods - .map(m => m.toUpperCase()) - .includes(this.reqCtx.request.method.toUpperCase()) - ) { - return; - } - - const hasFieldProps = { - url: this.url, - cookies: this.cookies, - headers: this.reqCtx.request.headers, - routeDest: route.dest, - }; - - // All `has` conditions must be met - skip if one is not met. - if ( - route.has?.find(has => { - const result = checkhasField(has, hasFieldProps); - if (result.newRouteDest) { - // If the `has` condition had a named capture to update the destination, update it. - hasFieldProps.routeDest = result.newRouteDest; - } - return !result.valid; - }) - ) { - return; - } - - // All `missing` conditions must not be met - skip if one is met. - if (route.missing?.find(has => checkhasField(has, hasFieldProps).valid)) { - return; - } - - // Required status code must match (i.e. for error routes) - skip if not met. - if (checkStatus && route.status !== this.status) { - return; - } - - if (checkIntercept && route.dest) { - const interceptRouteRegex = /\/(\(\.+\))+/; - const destIsIntercept = interceptRouteRegex.test(route.dest); - const pathIsIntercept = interceptRouteRegex.test(this.path); - - // If the new destination is an intercept route, only allow it if the current path is also - // an intercept route. - if (destIsIntercept && !pathIsIntercept) { - return; - } - } - - return { routeMatch: srcMatch, routeDest: hasFieldProps.routeDest }; - } - - /** - * Processes the response from running a middleware function. - * - * Handles rewriting the URL and applying redirects, response headers, and overriden request headers. - * - * @param resp Middleware response object. - */ - private processMiddlewareResp(resp: Response): void { - const overrideKey = 'x-middleware-override-headers'; - const overrideHeader = resp.headers.get(overrideKey); - if (overrideHeader) { - const overridenHeaderKeys = new Set( - overrideHeader.split(',').map(h => h.trim()), - ); - - for (const key of overridenHeaderKeys.keys()) { - const valueKey = `x-middleware-request-${key}`; - const value = resp.headers.get(valueKey); - - if (this.reqCtx.request.headers.get(key) !== value) { - if (value) { - this.reqCtx.request.headers.set(key, value); - } else { - this.reqCtx.request.headers.delete(key); - } - } - - resp.headers.delete(valueKey); - } - - resp.headers.delete(overrideKey); - } - - const rewriteKey = 'x-middleware-rewrite'; - const rewriteHeader = resp.headers.get(rewriteKey); - - if (rewriteHeader) { - const newUrl = new URL(rewriteHeader, this.url); - - const rewriteIsExternal = this.url.hostname !== newUrl.hostname; - - this.path = rewriteIsExternal ? `${newUrl}` : newUrl.pathname; - - applySearchParams(this.searchParams, newUrl.searchParams); - - resp.headers.delete(rewriteKey); - } - - const middlewareNextKey = 'x-middleware-next'; - const middlewareNextHeader = resp.headers.get(middlewareNextKey); - if (middlewareNextHeader) { - resp.headers.delete(middlewareNextKey); - } else if (!rewriteHeader && !resp.headers.has('location')) { - // We should set the final response body and status to the middleware's if it does not want - // to continue and did not rewrite/redirect the URL. - this.body = resp.body; - this.status = resp.status; - } else if ( - resp.headers.has('location') && - resp.status >= 300 && - resp.status < 400 - ) { - this.status = resp.status; - } - - // copy to the request object the headers that have been set by the middleware - applyHeaders(this.reqCtx.request.headers, resp.headers); - - applyHeaders(this.headers.normal, resp.headers); - this.headers.middlewareLocation = resp.headers.get('location'); - } - - /** - * Runs the middleware function for a route if it exists. - * - * @param path Path to the route's middleware function. - * @returns Whether the middleware function was run successfully. - */ - private async runRouteMiddleware(path?: string): Promise { - // If there is no path, return true as it did not result in an error. - if (!path) return true; - - const item = path && this.output[path]; - if (!item || item.type !== 'middleware') { - // The middleware function could not be found. Set the status to 500 and bail out. - this.status = 500; - return false; - } - - const resp = await runOrFetchBuildOutputItem(item, this.reqCtx, { - path: this.path, - searchParams: this.searchParams, - headers: this.headers, - status: this.status, - }); - this.middlewareInvoked.push(path); - - if (resp.status === 500) { - // The middleware function threw an error. Set the status and bail out. - this.status = resp.status; - return false; - } - - this.processMiddlewareResp(resp); - return true; - } - - /** - * Resets the response status and headers if the route should override them. - * - * @param route Build output config source route. - */ - private applyRouteOverrides(route: VercelSource): void { - if (!route.override) return; - - this.status = undefined; - this.headers.normal = new Headers(); - this.headers.important = new Headers(); - } - - /** - * Applies the route's headers for the response object. - * - * @param route Build output config source route. - * @param srcMatch Matches from the PCRE matcher. - * @param captureGroupKeys Named capture group keys from the PCRE matcher. - */ - private applyRouteHeaders( - route: VercelSource, - srcMatch: RegExpMatchArray, - captureGroupKeys: string[], - ): void { - if (!route.headers) return; - - applyHeaders(this.headers.normal, route.headers, { - match: srcMatch, - captureGroupKeys, - }); - - if (route.important) { - applyHeaders(this.headers.important, route.headers, { - match: srcMatch, - captureGroupKeys, - }); - } - } - - /** - * Applies the route's status code for the response object. - * - * @param route Build output config source route. - */ - private applyRouteStatus(route: VercelSource): void { - if (!route.status) return; - - this.status = route.status; - } - - /** - * Applies the route's destination for the matching the path to the Vercel build output. - * - * Applies any wildcard matches to the destination. - * - * @param route Build output config source route. - * @param srcMatch Matches from the PCRE matcher. - * @param captureGroupKeys Named capture group keys from the PCRE matcher. - * @returns The previous path for the route before applying the destination. - */ - private applyRouteDest( - route: VercelSource, - srcMatch: RegExpMatchArray, - captureGroupKeys: string[], - ): string { - if (!route.dest) return this.path; - - const prevPath = this.path; - let processedDest = route.dest; - - // Apply wildcard matches before PCRE matches - if (this.wildcardMatch && /\$wildcard/.test(processedDest)) { - processedDest = processedDest.replace( - /\$wildcard/g, - this.wildcardMatch.value, - ); - } - - this.path = applyPCREMatches(processedDest, srcMatch, captureGroupKeys); - - // NOTE: Special handling for `/index` RSC routes. Sometimes the Vercel build output config - // has a record to rewrite `^/` to `/index.rsc`, however, this will hit requests to pages - // that aren't `/`. In this case, we should check that the previous path is `/`. This should - // not match requests to `/__index.prefetch.rsc` as Vercel handles those requests missing in - // later phases. - // https://github.com/vercel/vercel/blob/31daff/packages/next/src/utils.ts#L3321 - const isRscIndex = /\/index\.rsc$/i.test(this.path); - const isPrevAbsoluteIndex = /^\/(?:index)?$/i.test(prevPath); - const isPrevPrefetchRscIndex = /^\/__index\.prefetch\.rsc$/i.test(prevPath); - if (isRscIndex && !isPrevAbsoluteIndex && !isPrevPrefetchRscIndex) { - this.path = prevPath; - } - - // NOTE: Special handling for `.rsc` requests. If the Vercel CLI failed to generate an RSC version - // of the page and the build output config has a record mapping the request to the RSC variant, we - // should strip the `.rsc` extension from the path. We do not strip the extension if the request is - // to a `.prefetch.rsc` file as Vercel handles those requests missing in later phases. - const isRsc = /\.rsc$/i.test(this.path); - const isPrefetchRsc = /\.prefetch\.rsc$/i.test(this.path); - const pathExistsInOutput = this.path in this.output; - if (isRsc && !isPrefetchRsc && !pathExistsInOutput) { - this.path = this.path.replace(/\.rsc/i, ''); - } - - // Merge search params for later use when serving a response. - const destUrl = new URL(this.path, this.url); - applySearchParams(this.searchParams, destUrl.searchParams); - - // If the new dest is not an URL, update the path with the path from the URL. - if (!isUrl(this.path)) this.path = destUrl.pathname; - - return prevPath; - } - - /** - * Applies the route's redirects for locales and internationalization. - * - * @param route Build output config source route. - */ - private applyLocaleRedirects(route: VercelSource): void { - if (!route.locale?.redirect) return; - - // Automatic locale detection is only supposed to occur at the root. However, the build output - // sometimes uses `/` as the regex instead of `^/$`. So, we should check if the `route.src` is - // equal to the path if it is not a regular expression, to determine if we are at the root. - // https://nextjs.org/docs/pages/building-your-application/routing/internationalization#automatic-locale-detection - const srcIsRegex = /^\^(.)*$/.test(route.src); - if (!srcIsRegex && route.src !== this.path) return; - - // If we already have a location header set, we might have found a locale redirect earlier. - if (this.headers.normal.has('location')) return; - - const { - locale: { redirect: redirects, cookie: cookieName }, - } = route; - - const cookieValue = cookieName && this.cookies[cookieName]; - const cookieLocales = parseAcceptLanguage(cookieValue ?? ''); - - const headerLocales = parseAcceptLanguage( - this.reqCtx.request.headers.get('accept-language') ?? '', - ); - - // Locales from the cookie take precedence over the header. - const locales = [...cookieLocales, ...headerLocales]; - - const redirectLocales = locales - .map(locale => redirects[locale]) - .filter(Boolean) as string[]; - - const redirectValue = redirectLocales[0]; - if (redirectValue) { - const needsRedirecting = !this.path.startsWith(redirectValue); - if (needsRedirecting) { - this.headers.normal.set('location', redirectValue); - this.status = 307; - } - return; - } - } - - /** - * Modifies the source route's `src` regex to be friendly with previously found locale's in the - * `miss` phase. - * - * There is a source route generated for rewriting `/{locale}/*` to `/*` when no file was found - * for the path. This causes issues when using an SSR function for the index page as the request - * to `/{locale}` will not be caught by the regex. Therefore, the regex needs to be updated to - * also match requests to solely `/{locale}` when the path has no trailing slash. - * - * @param route Build output config source route. - * @param phase Current phase of the routing process. - * @returns The route with the locale friendly regex. - */ - private getLocaleFriendlyRoute( - route: VercelSource, - phase: VercelPhase, - ): VercelSource { - if (!this.locales || phase !== 'miss') { - return route; - } - - if (isLocaleTrailingSlashRegex(route.src, this.locales)) { - return { - ...route, - src: route.src.replace(/\/\(\.\*\)\$$/, '(?:/(.*))?$'), - }; - } - - return route; - } - - /** - * Checks a route to see if it matches the current request. - * - * @param phase Current phase of the routing process. - * @param route Build output config source route. - * @returns The status from checking the route. - */ - private async checkRoute( - phase: VercelPhase, - rawRoute: VercelSource, - ): Promise { - const localeFriendlyRoute = this.getLocaleFriendlyRoute(rawRoute, phase); - const { routeMatch, routeDest } = - this.checkRouteMatch(localeFriendlyRoute, { - checkStatus: phase === 'error', - // The build output config correctly maps relevant request paths to be intercepts in the - // `none` phase, while the `rewrite` phase can contain entries that rewrite to an intercept - // that matches requests that are not actually intercepts, causing a 404. - checkIntercept: phase === 'rewrite', - }) ?? {}; - - const route: VercelSource = { ...localeFriendlyRoute, dest: routeDest }; - - // If this route doesn't match, continue to the next one. - if (!routeMatch?.match) return 'skip'; - - // If this route is a middleware route, check if it has already been invoked. - if ( - route.middlewarePath && - this.middlewareInvoked.includes(route.middlewarePath) - ) { - return 'skip'; - } - - const { match: srcMatch, captureGroupKeys } = routeMatch; - - // If this route overrides, replace the response headers and status. - this.applyRouteOverrides(route); - - // If this route has a locale, apply the redirects for it. - this.applyLocaleRedirects(route); - - // Call and process the middleware if this is a middleware route. - const success = await this.runRouteMiddleware(route.middlewarePath); - if (!success) return 'error'; - // If the middleware set a response body or resulted in a redirect, we are done. - if (this.body !== undefined || this.headers.middlewareLocation) { - return 'done'; - } - - // Update final headers with the ones from this route. - this.applyRouteHeaders(route, srcMatch, captureGroupKeys); - - // Update the status code if this route has one. - this.applyRouteStatus(route); - - // Update the path with the new destination. - const prevPath = this.applyRouteDest(route, srcMatch, captureGroupKeys); - - // If `check` is required and the path isn't a URL, check it again. - if (route.check && !isUrl(this.path)) { - if (prevPath === this.path) { - // NOTE: If the current/rewritten path is the same as the one that entered the phase, it - // can cause an infinite loop. Therefore, we should just set the status to `404` instead - // when we are in the `miss` phase. Otherwise, we should continue to the next phase. - // This happens with invalid `/_next/static/...` and `/_next/data/...` requests. - - if (phase !== 'miss') { - return this.checkPhase(getNextPhase(phase)); - } - - this.status = 404; - } else if (phase === 'miss') { - // When in the `miss` phase, enter `filesystem` if the file is not in the build output. This - // avoids rewrites in `none` that do the opposite of those in `miss`, and would cause infinite - // loops (e.g. i18n). If it is in the build output, remove a potentially applied `404` status. - if ( - !(this.path in this.output) && - !(this.path.replace(/\/$/, '') in this.output) - ) { - return this.checkPhase('filesystem'); - } - - if (this.status === 404) { - this.status = undefined; - } - } else { - // In all other instances, we need to enter the `none` phase so we can ensure that requests - // for the `RSC` variant of pages are served correctly. - return this.checkPhase('none'); - } - } - - // If we found a match and shouldn't continue finding matches, break out of the loop. - if (!route.continue) { - return 'done'; - } - - // If the route is a redirect then we're actually done - const isRedirect = - route.status && route.status >= 300 && route.status <= 399; - if (isRedirect) { - return 'done'; - } - - return 'next'; - } - - /** - * Checks a phase from the routing process to see if any route matches the current request. - * - * @param phase Current phase for routing. - * @returns The status from checking the phase. - */ - private async checkPhase(phase: VercelPhase): Promise { - if (this.checkPhaseCounter++ >= 50) { - // eslint-disable-next-line no-console - console.error( - `Routing encountered an infinite loop while checking ${this.url.pathname}`, - ); - this.status = 500; - return 'error'; - } - - // Reset the middleware invoked list as this is a new phase. - this.middlewareInvoked = []; - let shouldContinue = true; - - for (const route of this.routes[phase]) { - const result = await this.checkRoute(phase, route); - - if (result === 'error') { - return 'error'; - } - - if (result === 'done') { - shouldContinue = false; - break; - } - } - - // In the `hit` phase or for external urls/redirects/middleware responses, return the match. - if ( - phase === 'hit' || - isUrl(this.path) || - this.headers.normal.has('location') || - !!this.body - ) { - return 'done'; - } - - if (phase === 'none') { - // applications using the Pages router with i18n plus a catch-all root route - // redirect all requests (including /api/ ones) to the catch-all route, the only - // way to prevent this erroneous behavior is to remove the locale here if the - // path without the locale exists in the vercel build output - for (const locale of this.locales) { - const localeRegExp = new RegExp(`/${locale}(/.*)`); - const match = this.path.match(localeRegExp); - const pathWithoutLocale = match?.[1]; - if (pathWithoutLocale && pathWithoutLocale in this.output) { - this.path = pathWithoutLocale; - break; - } - } - } - - let pathExistsInOutput = this.path in this.output; - - // paths could incorrectly not be detected as existing in the output due to the `trailingSlash` setting - // in `next.config.mjs`, so let's check for that here and update the path in such case - if (!pathExistsInOutput && this.path.endsWith('/')) { - const newPath = this.path.replace(/\/$/, ''); - pathExistsInOutput = newPath in this.output; - if (pathExistsInOutput) { - this.path = newPath; - } - } - - // In the `miss` phase, set status to 404 if no path was found and it isn't an error code. - if (phase === 'miss' && !pathExistsInOutput) { - const should404 = !this.status || this.status < 400; - this.status = should404 ? 404 : this.status; - } - - let nextPhase: VercelHandleValue = 'miss'; - if (pathExistsInOutput || phase === 'miss' || phase === 'error') { - // If the route exists, enter the `hit` phase. For `miss` and `error` phases, enter the `hit` - // phase to update headers (e.g. `x-matched-path`). - nextPhase = 'hit'; - } else if (shouldContinue) { - nextPhase = getNextPhase(phase); - } - - return this.checkPhase(nextPhase); - } - - /** - * Runs the matcher for a phase. - * - * @param phase The phase to start matching routes from. - * @returns The status from checking for matches. - */ - public async run( - phase: Extract = 'none', - ): Promise { - // Reset the counter for each run. - this.checkPhaseCounter = 0; - const result = await this.checkPhase(phase); - - // Update status to redirect user to external URL. - if ( - this.headers.normal.has('location') && - (!this.status || this.status < 300 || this.status >= 400) - ) { - this.status = 307; - } - - return result; - } -} diff --git a/packages/next-on-pages/templates/_worker.js/utils/http.ts b/packages/next-on-pages/templates/_worker.js/utils/http.ts deleted file mode 100644 index 3c2c5d2eb..000000000 --- a/packages/next-on-pages/templates/_worker.js/utils/http.ts +++ /dev/null @@ -1,136 +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(), isNaN(quality) ? 1 : quality] as [string, number]; - }) - .sort((a, b) => b[1] - a[1]) - .map(([locale]) => (locale === '*' || locale === '' ? [] : locale)) - .flat(); -} diff --git a/packages/next-on-pages/templates/_worker.js/utils/images.ts b/packages/next-on-pages/templates/_worker.js/utils/images.ts index aadbb13c6..817888744 100644 --- a/packages/next-on-pages/templates/_worker.js/utils/images.ts +++ b/packages/next-on-pages/templates/_worker.js/utils/images.ts @@ -1,143 +1,7 @@ -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: RequestInitCfPropertiesImage; -}; - -/** - * 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(format => acceptHeader.includes(format)) - ?.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; -} +import { + formatResizingResponse, + getResizingProperties, +} from 'build-output-router/images'; /** * Handles image resizing requests. @@ -167,7 +31,7 @@ export async function handleImageResizingRequest( const imageResp = await imgFetch(imageUrl); - return formatResp(imageResp, imageUrl, imagesConfig); + return formatResizingResponse(imageResp, imageUrl, imagesConfig); } type ImageResizingOpts = { diff --git a/packages/next-on-pages/templates/_worker.js/utils/index.ts b/packages/next-on-pages/templates/_worker.js/utils/index.ts index 5f03e5e43..fe00b2a25 100644 --- a/packages/next-on-pages/templates/_worker.js/utils/index.ts +++ b/packages/next-on-pages/templates/_worker.js/utils/index.ts @@ -1,7 +1,4 @@ -export * from './matcher'; export * from './request'; -export * from './http'; -export * from './pcre'; export * from './routing'; export * from './images'; export * from './fetch'; diff --git a/packages/next-on-pages/templates/_worker.js/utils/matcher.ts b/packages/next-on-pages/templates/_worker.js/utils/matcher.ts deleted file mode 100644 index 16cf8ff94..000000000 --- a/packages/next-on-pages/templates/_worker.js/utils/matcher.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { applyPCREMatches, matchPCRE } from './pcre'; - -type HasFieldRequestProperties = { - url: URL; - cookies: Record; - headers: Headers; - routeDest?: string; -}; - -/** - * Checks if a Vercel source route's `has` record conditions match a request, and whether the request - * destination should be updated based on the `has` record. - * - * @param has The `has` record conditions to check against the request. - * @param requestProperties The request properties to check against. - * @returns Whether the request matches the `has` record conditions, and the new destination if it changed. - */ -export function checkhasField( - has: VercelHasField, - { url, cookies, headers, routeDest }: HasFieldRequestProperties, -): { valid: boolean; newRouteDest?: string } { - switch (has.type) { - case 'host': { - return { valid: url.hostname === has.value }; - } - case 'header': { - if (has.value !== undefined) { - return getHasFieldPCREMatchResult( - has.value, - headers.get(has.key), - routeDest, - ); - } - - return { valid: headers.has(has.key) }; - } - case 'cookie': { - const cookie = cookies[has.key]; - - if (cookie && has.value !== undefined) { - return getHasFieldPCREMatchResult(has.value, cookie, routeDest); - } - - return { valid: cookie !== undefined }; - } - case 'query': { - if (has.value !== undefined) { - return getHasFieldPCREMatchResult( - has.value, - url.searchParams.get(has.key), - routeDest, - ); - } - - return { valid: url.searchParams.has(has.key) }; - } - } -} - -/** - * 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/packages/next-on-pages/templates/_worker.js/utils/pcre.ts b/packages/next-on-pages/templates/_worker.js/utils/pcre.ts deleted file mode 100644 index 1733382c7..000000000 --- a/packages/next-on-pages/templates/_worker.js/utils/pcre.ts +++ /dev/null @@ -1,65 +0,0 @@ -// pcre-to-regexp converts a PCRE string to a regular expression. It also extracts the named -// capture group keys, which is useful for matching and replacing parameters. -// This is the same library used by Vercel in the build output, and is used here to ensure -// consistency and proper support. -import createPCRE from 'pcre-to-regexp/dist/index.js'; - -export type MatchPCREResult = { - match: RegExpMatchArray | null; - captureGroupKeys: string[]; -}; - -/** - * Checks if a value matches with a PCRE-compatible string, and extract the capture group keys. - * - * @param expr PCRE-compatible string. - * @param val String to check with the regular expression. - * @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( - expr: string, - val: string | undefined | null, - caseSensitive?: boolean, -): MatchPCREResult { - if (val === null || val === undefined) { - return { match: null, captureGroupKeys: [] }; - } - - const flag = caseSensitive ? '' : 'i'; - const captureGroupKeys: string[] = []; - - const matcher = createPCRE(`%${expr}%${flag}`, captureGroupKeys); - 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/packages/next-on-pages/templates/_worker.js/utils/routing.ts b/packages/next-on-pages/templates/_worker.js/utils/routing.ts index 100741541..a21929089 100644 --- a/packages/next-on-pages/templates/_worker.js/utils/routing.ts +++ b/packages/next-on-pages/templates/_worker.js/utils/routing.ts @@ -1,157 +1,90 @@ -import type { RequestContext } from '../../../src/utils/requestContext'; +import type { Assets } from 'build-output-router'; import { applyHeaders, createRouteRequest, createMutableResponse, applySearchParams, -} from './http'; - -export type MatchedSetHeaders = { - /** - * 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; -}; - -export type MatchedSet = { - path: string; - status: number | undefined; - headers: MatchedSetHeaders; - 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: VercelPhase): VercelHandleValue { - 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': { - return 'miss'; - } - default: { - return 'miss'; - } - } -} +} from 'build-output-router/router'; +import type { RequestContext } from '../../../src/utils/requestContext'; /** - * Runs or fetches a build output item. + * Gets an asset handler that runs or fetches a build output item. * - * @param item Build output item to run or fetch. - * @param request Request object. - * @param match Matched route details. - * @param assets Fetcher for static assets. - * @param ctx Execution context for the request. + * @param output Build output. + * @param requestCtx Request context. * @returns Response object. */ -export async function runOrFetchBuildOutputItem( - item: VercelBuildOutputItem | undefined, +export function getAssetsHandler( + output: VercelBuildOutput, { request, assetsFetcher, ctx }: RequestContext, - { path, searchParams }: Omit, -) { - let resp: Response | undefined = undefined; +): Assets { + return { + has: p => p in output, + get: p => { + const item = output[p]; + if (!item) return null; - // Apply the search params from matching the route to the request URL. - const url = new URL(request.url); - applySearchParams(url.searchParams, searchParams); - const req = new Request(url, request); + return { + isStaticAsset: item.type === 'static' || item.type === 'override', + isRouteFunction: item.type === 'function', + isMiddleware: item.type === 'middleware', + fetch: async ({ path, searchParams }) => { + let resp: Response | undefined = undefined; - try { - switch (item?.type) { - case 'function': - case 'middleware': { - const edgeFunction: EdgeFunction = await import(item.entrypoint); - try { - resp = await edgeFunction.default(req, ctx); - } catch (e) { - const err = e as Error; - if ( - err.name === 'TypeError' && - err.message.endsWith('default is not a function') - ) { - throw new Error( - `An error occurred while evaluating the target edge function (${item.entrypoint})`, - ); - } - throw e; - } - break; - } - case 'override': { - resp = createMutableResponse( - await assetsFetcher.fetch(createRouteRequest(req, item.path ?? path)), - ); - - if (item.headers) { - applyHeaders(resp.headers, item.headers); - } - break; - } - case 'static': { - resp = await assetsFetcher.fetch(createRouteRequest(req, path)); - break; - } - default: { - resp = new Response('Not Found', { status: 404 }); - } - } - } catch (e) { - // eslint-disable-next-line no-console - console.error(e); - return new Response('Internal Server Error', { status: 500 }); - } + // Apply the search params from matching the route to the request URL. + const url = new URL(request.url); + applySearchParams(url.searchParams, searchParams); + const req = new Request(url, request); - return createMutableResponse(resp); -} + try { + switch (item?.type) { + case 'function': + case 'middleware': { + const edgeFunction: EdgeFunction = await import( + item.entrypoint + ); + try { + resp = await edgeFunction.default(req, ctx); + } catch (e) { + const err = e as Error; + if ( + err.name === 'TypeError' && + err.message.endsWith('default is not a function') + ) { + throw new Error( + `An error occurred while evaluating the target edge function (${item.entrypoint})`, + ); + } + throw e; + } + break; + } + case 'static': { + resp = await assetsFetcher.fetch( + createRouteRequest(req, item.path ?? path), + ); -/** - * 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; - } + if (item.headers) { + resp = createMutableResponse(resp); + applyHeaders(resp.headers, item.headers); + } + break; + } + default: { + resp = new Response('Not Found', { status: 404 }); + } + } + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + return new Response('Internal Server Error', { + status: 500, + }); + } - const foundLocales = src.slice(prefix.length, -suffix.length).split('|'); - return foundLocales.every(locale => locales.has(locale)); + return createMutableResponse(resp); + }, + }; + }, + }; } diff --git a/packages/next-on-pages/tests/_helpers/index.ts b/packages/next-on-pages/tests/_helpers/index.ts index ed9b99cf2..dd54ba481 100644 --- a/packages/next-on-pages/tests/_helpers/index.ts +++ b/packages/next-on-pages/tests/_helpers/index.ts @@ -173,10 +173,6 @@ function constructBuildOutputRecord( workerJsDir: string, ): VercelBuildOutputItem { if (item.type === 'static') { - return { type: item.type }; - } - - if (item.type === 'override') { return { type: item.type, path: item.path, @@ -248,7 +244,7 @@ export async function createRouterTestData( const item = buildOutput[path]; const contentType = - (item?.type === 'override' && item.headers?.['content-type']) || + (item?.type === 'static' && item.headers?.['content-type']) || 'text/plain;charset=UTF-8'; const fsPath = join(resolve('.vercel', 'output', 'static'), path); diff --git a/packages/next-on-pages/tests/src/buildApplication/processVercelOutput.test.ts b/packages/next-on-pages/tests/src/buildApplication/processVercelOutput.test.ts index 793a38c50..091942b77 100644 --- a/packages/next-on-pages/tests/src/buildApplication/processVercelOutput.test.ts +++ b/packages/next-on-pages/tests/src/buildApplication/processVercelOutput.test.ts @@ -159,7 +159,7 @@ describe('processVercelOutput', () => { { headers: { 'content-type': 'text/html; charset=utf-8' }, path: '/404.html', - type: 'override', + type: 'static', }, ], [ @@ -167,7 +167,7 @@ describe('processVercelOutput', () => { { headers: { 'content-type': 'text/html; charset=utf-8' }, path: '/500.html', - type: 'override', + type: 'static', }, ], [ @@ -175,7 +175,7 @@ describe('processVercelOutput', () => { { headers: { 'content-type': 'text/html; charset=utf-8' }, path: '/index.html', - type: 'override', + type: 'static', }, ], [ @@ -196,7 +196,7 @@ describe('processVercelOutput', () => { { headers: { 'content-type': 'text/html; charset=utf-8' }, path: '/404.html', - type: 'override', + type: 'static', }, ], [ @@ -204,7 +204,7 @@ describe('processVercelOutput', () => { { headers: { 'content-type': 'text/html; charset=utf-8' }, path: '/500.html', - type: 'override', + type: 'static', }, ], [ @@ -212,7 +212,7 @@ describe('processVercelOutput', () => { { headers: { 'content-type': 'text/html; charset=utf-8' }, path: '/index.html', - type: 'override', + type: 'static', }, ], [ @@ -220,7 +220,7 @@ describe('processVercelOutput', () => { { headers: { 'content-type': 'text/html; charset=utf-8' }, path: '/index.html', - type: 'override', + type: 'static', }, ], ]), @@ -342,7 +342,7 @@ describe('processVercelOutput', () => { { headers: { 'content-type': 'text/html; charset=utf-8' }, path: '/404.html', - type: 'override', + type: 'static', }, ], [ @@ -350,7 +350,7 @@ describe('processVercelOutput', () => { { headers: { 'content-type': 'text/html; charset=utf-8' }, path: '/500.html', - type: 'override', + type: 'static', }, ], [ @@ -360,7 +360,7 @@ describe('processVercelOutput', () => { vary: 'RSC, Next-Router-State-Tree, Next-Router-Prefetch', }, path: '/index.html', - type: 'override', + type: 'static', }, ], [ @@ -371,7 +371,7 @@ describe('processVercelOutput', () => { vary: 'RSC, Next-Router-State-Tree, Next-Router-Prefetch', }, path: '/index.rsc', - type: 'override', + type: 'static', }, ], [ @@ -381,7 +381,7 @@ describe('processVercelOutput', () => { vary: 'RSC, Next-Router-State-Tree, Next-Router-Prefetch', }, path: '/nested/(route-group)/foo.html', - type: 'override', + type: 'static', }, ], [ @@ -396,7 +396,7 @@ describe('processVercelOutput', () => { { headers: { 'content-type': 'text/html; charset=utf-8' }, path: '/404.html', - type: 'override', + type: 'static', }, ], [ @@ -404,7 +404,7 @@ describe('processVercelOutput', () => { { headers: { 'content-type': 'text/html; charset=utf-8' }, path: '/500.html', - type: 'override', + type: 'static', }, ], [ @@ -414,7 +414,7 @@ describe('processVercelOutput', () => { vary: 'RSC, Next-Router-State-Tree, Next-Router-Prefetch', }, path: '/index.html', - type: 'override', + type: 'static', }, ], [ @@ -424,7 +424,7 @@ describe('processVercelOutput', () => { vary: 'RSC, Next-Router-State-Tree, Next-Router-Prefetch', }, path: '/index.html', - type: 'override', + type: 'static', }, ], [ @@ -434,7 +434,7 @@ describe('processVercelOutput', () => { vary: 'RSC, Next-Router-State-Tree, Next-Router-Prefetch', }, path: '/nested/(route-group)/foo.html', - type: 'override', + type: 'static', }, ], [ @@ -444,7 +444,7 @@ describe('processVercelOutput', () => { vary: 'RSC, Next-Router-State-Tree, Next-Router-Prefetch', }, path: '/nested/(route-group)/foo.html', - type: 'override', + type: 'static', }, ], ]), diff --git a/packages/next-on-pages/tests/templates/utils/http.test.ts b/packages/next-on-pages/tests/templates/utils/http.test.ts deleted file mode 100644 index 0ce790c26..000000000 --- a/packages/next-on-pages/tests/templates/utils/http.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { describe, test, expect } from 'vitest'; -import type { MatchPCREResult } from '../../../templates/_worker.js/utils'; -import { - applyHeaders, - applySearchParams, - createRouteRequest, - isUrl, - parseAcceptLanguage, -} from '../../../templates/_worker.js/utils'; - -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/packages/next-on-pages/tests/templates/utils/images.test.ts b/packages/next-on-pages/tests/templates/utils/images.test.ts deleted file mode 100644 index fe163058a..000000000 --- a/packages/next-on-pages/tests/templates/utils/images.test.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { - formatResp, - getResizingProperties, - isRemotePatternMatch, -} from '../../../templates/_worker.js/utils'; - -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); - }); -}); - -const baseUrl = 'https://localhost/_next/image?url='; -const baseValidUrl = `${baseUrl}%2Fimages%2F1.jpg`; -const baseConfig: VercelImagesConfig = { - domains: ['example.com'], - sizes: [640, 750, 828, 1080, 1200], - remotePatterns: [{ hostname: '^via\\.placeholder\\.com$' }], - formats: ['image/avif', 'image/webp'], -}; - -describe('getResizingProperties', () => { - test('invalid method fails', () => { - const url = new URL(baseValidUrl); - const req = new Request(url, { method: 'POST' }); - - expect(getResizingProperties(req)).toEqual(undefined); - }); - - describe('request search params', () => { - test('invalid url fails', () => { - const url = new URL(baseUrl); - const req = new Request(url); - - expect(getResizingProperties(req)).toEqual(undefined); - }); - - test('invalid width fails', () => { - const url = new URL(`${baseValidUrl}&w=abc`); - const req = new Request(url); - - expect(getResizingProperties(req)).toEqual(undefined); - }); - - test('invalid quality fails', () => { - const url = new URL(`${baseValidUrl}&w=100&q=abc`); - const req = new Request(url); - - expect(getResizingProperties(req)).toEqual(undefined); - }); - - test('invalid width in images config fails', () => { - const url = new URL(`${baseValidUrl}&w=100`); - const req = new Request(url); - - expect(getResizingProperties(req, baseConfig)).toEqual(undefined); - }); - - test('invalid quality (>100) fails', () => { - const url = new URL(`${baseValidUrl}&w=640&q=150`); - const req = new Request(url); - - expect(getResizingProperties(req, baseConfig)).toEqual(undefined); - }); - - test('invalid quality (<0) fails', () => { - const url = new URL(`${baseValidUrl}&w=640&q=-1`); - const req = new Request(url); - - expect(getResizingProperties(req, baseConfig)).toEqual(undefined); - }); - }); - - describe('relative (same origin) image', () => { - test('image with valid request options succeeds', () => { - const url = new URL(`${baseValidUrl}&w=640`); - const req = new Request(url); - - const result = getResizingProperties(req, baseConfig); - expect(result).toEqual({ - isRelative: true, - imageUrl: new URL('https://localhost/images/1.jpg'), - options: { format: undefined, width: 640, quality: 75 }, - }); - }); - - ['/', '%2f', '%2F'].forEach(char => { - test(`image with valid request options succeeds (using '${char}'s)`, () => { - const baseValidUrl = `${baseUrl}${char}images${char}1.jpg`; - const url = new URL(`${baseValidUrl}&w=640`); - const req = new Request(url); - - const result = getResizingProperties(req, baseConfig); - expect(result).toEqual({ - isRelative: true, - imageUrl: new URL('https://localhost/images/1.jpg'), - options: { format: undefined, width: 640, quality: 75 }, - }); - }); - }); - - test('svg image fails when config disallows svgs', () => { - const url = new URL(`${baseValidUrl.replace('jpg', 'svg')}&w=640`); - const req = new Request(url); - const config = { ...baseConfig, dangerouslyAllowSVG: false }; - - expect(getResizingProperties(req, config)).toEqual(undefined); - }); - - test('svg image succeeds when config allows svgs', () => { - const url = new URL(`${baseValidUrl.replace('jpg', 'svg')}&w=640`); - const req = new Request(url); - const config = { ...baseConfig, dangerouslyAllowSVG: true }; - - const result = getResizingProperties(req, config); - expect(result).toEqual({ - isRelative: true, - imageUrl: new URL('https://localhost/images/1.svg'), - options: { format: undefined, width: 640, quality: 75 }, - }); - }); - - test('svg image succeeds when config allows them', () => { - const url = new URL(`${baseValidUrl.replace('jpg', 'svg')}&w=640`); - const req = new Request(url); - const config = { ...baseConfig, dangerouslyAllowSVG: true }; - - const result = getResizingProperties(req, config); - expect(result).toEqual({ - isRelative: true, - imageUrl: new URL('https://localhost/images/1.svg'), - options: { format: undefined, width: 640, quality: 75 }, - }); - }); - }); - - describe('protocol relative (potentially another origin) image', () => { - const protocolRelativePrefixes = ['%2F%2F', '//', '%2f%2f', '%2f/', '/%2f']; - - protocolRelativePrefixes.forEach(prefix => { - test(`image with valid request options succeeds (with ${prefix} prefix)`, () => { - const url = new URL( - `${baseUrl}${prefix}via.placeholder.com%2Fimage.jpg&w=640`, - ); - const req = new Request(url); - const result = getResizingProperties(req, baseConfig); - expect(result).toEqual({ - isRelative: false, - imageUrl: new URL('https://via.placeholder.com/image.jpg'), - options: { format: undefined, width: 640, quality: 75 }, - }); - }); - }); - - protocolRelativePrefixes.forEach(prefix => { - test(`image with disallowed domain fails (with "${prefix}" prefix)`, () => { - const url = new URL(`${baseUrl}${prefix}invalid.com%2Fimage.jpg&w=640`); - const req = new Request(url); - expect(getResizingProperties(req, baseConfig)).toEqual(undefined); - }); - }); - }); - - describe('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); - - expect(getResizingProperties(req, baseConfig)).toEqual(undefined); - }); - - test('external image succeeds with allowed domain', () => { - const url = new URL( - `${baseUrl}https%3A%2F%2Fexample.com%2Fimage.jpg&w=640`, - ); - const req = new Request(url); - - const result = getResizingProperties(req, baseConfig); - expect(result).toEqual({ - isRelative: false, - imageUrl: new URL('https://example.com/image.jpg'), - options: { format: undefined, width: 640, quality: 75 }, - }); - }); - - test('external image suceeds with allowed remote pattern', () => { - const url = new URL( - `${baseUrl}https%3A%2F%2Fvia.placeholder.com%2Fimage.jpg&w=640`, - ); - const req = new Request(url); - - const result = getResizingProperties(req, baseConfig); - expect(result).toEqual({ - isRelative: false, - imageUrl: new URL('https://via.placeholder.com/image.jpg'), - options: { format: undefined, width: 640, quality: 75 }, - }); - }); - }); - - describe('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' } }); - - const result = getResizingProperties(req, baseConfig); - expect(result).toEqual({ - isRelative: true, - imageUrl: new URL('https://localhost/images/1.jpg'), - options: { format: 'webp', width: 640, quality: 75 }, - }); - }); - - test('return correct format for `accept` header (avif)', () => { - const url = new URL(`${baseValidUrl}&w=640`); - const req = new Request(url, { - headers: { Accept: 'image/avif,image/webp' }, - }); - - const result = getResizingProperties(req, baseConfig); - expect(result).toEqual({ - isRelative: true, - imageUrl: new URL('https://localhost/images/1.jpg'), - options: { format: 'avif', width: 640, quality: 75 }, - }); - }); - }); -}); - -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/packages/next-on-pages/tests/templates/utils/matcher.test.ts b/packages/next-on-pages/tests/templates/utils/matcher.test.ts deleted file mode 100644 index c364f5bd8..000000000 --- a/packages/next-on-pages/tests/templates/utils/matcher.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { describe, test, expect } from 'vitest'; -import { parse } from 'cookie'; -import { checkhasField } from '../../../templates/_worker.js/utils'; - -type HasFieldTestCase = { - name: string; - has: VercelHasField; - dest?: string; - expected: { valid: boolean; newRouteDest?: string }; -}; - -const req = new Request( - 'https://test.com/index?queryWithValue=value&queryWithoutValue=&source=query', - { - headers: { - source: 'header', - headerWithValue: 'value', - headerWithoutValue: undefined as unknown as string, - cookie: 'cookieWithValue=value; cookieWithoutValue=; source=cookie', - }, - }, -); -const url = new URL(req.url); -const cookies = parse(req.headers.get('cookie') ?? ''); - -describe('checkhasField', () => { - const testCases: HasFieldTestCase[] = [ - { - name: 'host: valid host returns true', - has: { type: 'host', value: 'test.com' }, - expected: { valid: true }, - }, - { - name: 'host: invalid host returns false', - has: { type: 'host', value: 'test2.com' }, - expected: { valid: false }, - }, - { - name: 'header: has with key+value match returns true', - has: { type: 'header', key: 'headerWithValue', value: 'value' }, - expected: { valid: true }, - }, - { - name: 'header: has with key+value mismatch returns false', - has: { type: 'header', key: 'headerWithValue', value: 'value2' }, - expected: { valid: false }, - }, - { - name: 'header: has with key match returns true', - has: { type: 'header', key: 'headerWithoutValue' }, - expected: { valid: true }, - }, - { - name: 'header: has with key but no value mismatch returns false', - has: { type: 'header', key: 'headerWithoutValue', value: 'value' }, - expected: { valid: false }, - }, - { - name: 'cookie: has with key+value match returns true', - has: { type: 'cookie', key: 'cookieWithValue', value: 'value' }, - expected: { valid: true }, - }, - { - name: 'cookie: has with key+value mismatch returns false', - has: { type: 'cookie', key: 'cookieWithValue', value: 'alt-value' }, - expected: { valid: false }, - }, - { - name: 'cookie: has with key match returns true', - has: { type: 'cookie', key: 'cookieWithValue' }, - expected: { valid: true }, - }, - { - name: 'query: has with key+value match returns true', - has: { type: 'query', key: 'queryWithValue', value: 'value' }, - expected: { valid: true }, - }, - { - name: 'query: has with key+value mismatch returns false', - has: { type: 'query', key: 'queryWithValue', value: 'alt-value' }, - expected: { valid: false }, - }, - { - name: 'query: has with key match returns true', - has: { type: 'query', key: 'queryWithoutValue' }, - expected: { valid: true }, - }, - { - name: 'query: has with key but no value mismatch returns false', - has: { type: 'query', key: 'queryWithoutValue', value: 'value' }, - expected: { valid: false }, - }, - { - name: 'query: has with named capture returns a new dest on match', - has: { type: 'query', key: 'source', value: '(?.*)' }, - dest: '/source/$source', - expected: { valid: true, newRouteDest: '/source/query' }, - }, - { - name: 'query: has with named capture does not update missing named captures in dest', - has: { type: 'query', key: 'source', value: '(?.*)' }, - dest: '/source/$source/$age', - expected: { valid: true, newRouteDest: '/source/query/$age' }, - }, - { - name: 'query: has with named capture return valid on match when key is not in dest', - has: { type: 'query', key: 'source', value: '(?.*)' }, - dest: '/source/$age', - expected: { valid: true, newRouteDest: '/source/$age' }, - }, - { - name: 'query: has with named capture does not return dest on no matches', - has: { type: 'query', key: 'invalidKey', value: '(?.*)' }, - dest: '/source/$source', - expected: { valid: false, newRouteDest: undefined }, - }, - { - name: 'header: has with named capture returns a new dest on match', - has: { type: 'header', key: 'source', value: '(?.*)' }, - dest: '/source/$source', - expected: { valid: true, newRouteDest: '/source/header' }, - }, - { - name: 'cookie: has with named capture returns a new dest on match', - has: { type: 'cookie', key: 'source', value: '(?.*)' }, - dest: '/source/$source', - expected: { valid: true, newRouteDest: '/source/cookie' }, - }, - ]; - - testCases.forEach(testCase => { - test(testCase.name, () => { - const result = checkhasField(testCase.has, { - url, - cookies, - headers: req.headers, - routeDest: testCase.dest, - }); - expect(result).toEqual(testCase.expected); - }); - }); -}); diff --git a/packages/next-on-pages/tests/templates/utils/pcre.test.ts b/packages/next-on-pages/tests/templates/utils/pcre.test.ts deleted file mode 100644 index 977da1070..000000000 --- a/packages/next-on-pages/tests/templates/utils/pcre.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { describe, test, expect } from 'vitest'; -import { - matchPCRE, - applyPCREMatches, -} from '../../../templates/_worker.js/utils'; - -type TestCase = { - name: string; - url: string; - route: VercelSource; - opts?: { namedOnly?: boolean }; - 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', () => { - const testCases: TestCase[] = [ - { - name: 'should process a dest for a basic route', - url: 'https://example.com/index', - route: { src: '^/index(?:/)?', dest: '/index.html' }, - expected: { match: true, captureGroupKeys: [], newDest: '/index.html' }, - }, - { - name: 'should process a `$0` dest for a basic route', - url: 'https://example.com/index', - route: { src: '^/index(?:/)?', dest: '/new/$0/dest' }, - expected: { - match: true, - captureGroupKeys: [], - newDest: '/new//index/dest', - }, - }, - { - name: 'should process a `$1` dest for a basic route', - url: 'https://example.com/index', - route: { src: '^/i(nde)x(?:/)?', dest: '/new/$1/dest' }, - expected: { match: true, captureGroupKeys: [], newDest: '/new/nde/dest' }, - }, - { - name: 'should process dest for a route with named groups', - url: 'https://example.com/index', - route: { src: '^/i(?nde)x(?:/)?', dest: '/new/$name/dest' }, - expected: { - match: true, - captureGroupKeys: ['name'], - newDest: '/new/nde/dest', - }, - }, - { - name: 'should process dest for a route with multiple named groups', - url: 'https://example.com/index/123', - route: { - src: '^/i(?nde)x/(?\\d+)(?:/)?', - dest: '/new/$name/$id/dest', - }, - expected: { - match: true, - captureGroupKeys: ['name', 'id'], - newDest: '/new/nde/123/dest', - }, - }, - { - name: 'should process dest for route with named groups to query params', - url: 'https://example.com/index/123', - route: { - src: '^/i(?nde)x/(?\\d+)(?:/)?', - dest: '/new/$name/dest?id=$id', - }, - expected: { - match: true, - captureGroupKeys: ['name', 'id'], - newDest: '/new/nde/dest?id=123', - }, - }, - { - name: 'should process dest for route with missing query param in dest', - url: 'https://example.com/index', - route: { src: '^/i(?nde)x(?:/)?', dest: '/new/$name/dest?id=$id' }, - expected: { - match: true, - captureGroupKeys: ['name'], - newDest: '/new/nde/dest?id=', - }, - }, - { - name: 'should only apply matched named capture groups when `namedOnly` is set', - url: 'https://example.com/index', - route: { src: '^/i(?nde)x(?:/)?', dest: '/new/$name/$dest?id=$id' }, - opts: { namedOnly: true }, - expected: { - match: true, - captureGroupKeys: ['name'], - newDest: '/new/nde/$dest?id=$id', - }, - }, - { - name: 'should process dest for a route with named group containing underscore', - url: 'https://example.com/index', - route: { src: '^/i(?nde)x(?:/)?', dest: '/new/$na_me/dest' }, - expected: { - match: true, - captureGroupKeys: ['na_me'], - newDest: '/new/nde/dest', - }, - }, - ]; - - testCases.forEach(testCase => { - test(testCase.name, () => { - const { match, captureGroupKeys } = matchPCRE( - testCase.route.src, - new URL(testCase.url).pathname, - testCase.route.caseSensitive, - ); - const result = applyPCREMatches( - testCase.route.dest ?? '', - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - match!, - captureGroupKeys, - testCase.opts, - ); - - const { newDest: expectedNewDest, ...expected } = testCase.expected; - expect({ captureGroupKeys, match: !!match }).toEqual(expected); - expect(result).toEqual(expectedNewDest); - }); - }); -}); diff --git a/packages/next-on-pages/vercel.types.d.ts b/packages/next-on-pages/vercel.types.d.ts index 8f41d2401..ea0236106 100644 --- a/packages/next-on-pages/vercel.types.d.ts +++ b/packages/next-on-pages/vercel.types.d.ts @@ -174,13 +174,11 @@ type ProcessedVercelConfig = Override< ProcessedVercelRoutes >; -type BuildOutputStaticAsset = { type: 'static' }; -type BuildOutputStaticOverride = { - type: 'override'; - path: string; +type BuildOutputStaticAsset = { + type: 'static' | 'override'; // keep `override` for backwards compatibility + path?: string; headers?: Record; }; -type BuildOutputStaticItem = BuildOutputStaticAsset | BuildOutputStaticOverride; type BuildOutputFunction = { type: 'function' | 'middleware'; @@ -204,7 +202,7 @@ type AdjustedBuildOutputFunction = Override< >; type VercelBuildOutputItem = | AdjustedBuildOutputFunction - | BuildOutputStaticItem; + | BuildOutputStaticAsset; type VercelBuildOutput = { [key: string]: VercelBuildOutputItem;