Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update Pathname Normalizers #57161

Merged
merged 4 commits into from
Oct 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 81 additions & 52 deletions packages/next/src/server/base-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import type {
AppRouteRouteModule,
} from './future/route-modules/app-route/module'
import type { Server as HTTPServer } from 'http'
import type { ImageConfigComplete } from '../shared/lib/image-config'

import { format as formatUrl, parse as parseUrl } from 'url'
import { formatHostname } from './lib/format-hostname'
Expand All @@ -54,7 +55,6 @@ import {
import { isDynamicRoute } from '../shared/lib/router/utils'
import { checkIsOnDemandRevalidate } from './api-utils'
import { setConfig } from '../shared/lib/runtime-config.external'

import { setRevalidateHeaders } from './send-payload/revalidate-headers'
import { execOnce } from '../shared/lib/utils'
import { isBlockedPage } from './utils'
Expand All @@ -71,13 +71,8 @@ import {
getRequestMeta,
removeRequestMeta,
} from './request-meta'

import type { ImageConfigComplete } from '../shared/lib/image-config'
import { removePathPrefix } from '../shared/lib/router/utils/remove-path-prefix'
import {
normalizeAppPath,
normalizeRscPath,
} from '../shared/lib/router/utils/app-paths'
import { normalizeAppPath } from '../shared/lib/router/utils/app-paths'
import { getHostname } from '../shared/lib/get-hostname'
import { parseUrl as parseUrlUtil } from '../shared/lib/router/utils/parse-url'
import { getNextPathnameInfo } from '../shared/lib/router/utils/get-next-pathname-info'
Expand Down Expand Up @@ -124,6 +119,7 @@ import {
import { matchNextDataPathname } from './lib/match-next-data-pathname'
import getRouteFromAssetPath from '../shared/lib/router/utils/get-route-from-asset-path'
import { stripInternalHeaders } from './internal-utils'
import { RSCPathnameNormalizer } from './future/normalizers/request/rsc'

export type FindComponentsResult = {
components: LoadComponentsReturnType
Expand All @@ -140,7 +136,7 @@ export type RouteHandler = (
req: BaseNextRequest,
res: BaseNextResponse,
parsedUrl: NextUrlWithParsedQuery
) => PromiseLike<boolean> | boolean
) => PromiseLike<boolean | void> | boolean | void

/**
* The normalized route manifest is the same as the route manifest, but with
Expand Down Expand Up @@ -387,6 +383,10 @@ export default abstract class Server<ServerOptions extends Options = Options> {
protected readonly i18nProvider?: I18NProvider
protected readonly localeNormalizer?: LocaleRouteNormalizer

protected readonly normalizers: {
readonly rsc: RSCPathnameNormalizer
}

public constructor(options: ServerOptions) {
const {
dir = '.',
Expand Down Expand Up @@ -449,6 +449,11 @@ export default abstract class Server<ServerOptions extends Options = Options> {
minimalMode || !!process.env.NEXT_PRIVATE_MINIMAL_MODE

this.hasAppDir = this.getHasAppDir(dev)

this.normalizers = {
rsc: new RSCPathnameNormalizer(this.hasAppDir),
}

const serverComponents = this.hasAppDir

this.nextFontManifest = this.getNextFontManifest()
Expand Down Expand Up @@ -527,11 +532,29 @@ export default abstract class Server<ServerOptions extends Options = Options> {
return this.matchers.reload()
}

protected handleNextDataRequest: RouteHandler = async (
req,
res,
parsedUrl
) => {
private handleRSCRequest: RouteHandler = (req, _res, parsedUrl) => {
if (
!parsedUrl.pathname ||
!this.normalizers.rsc.match(parsedUrl.pathname)
) {
return
}

parsedUrl.query.__nextDataReq = '1'
parsedUrl.pathname = this.normalizers.rsc.normalize(
parsedUrl.pathname,
true
)

// Update the URL.
if (req.url) {
const parsed = parseUrl(req.url)
parsed.pathname = parsedUrl.pathname
req.url = formatUrl(parsed)
}
}

private handleNextDataRequest: RouteHandler = async (req, res, parsedUrl) => {
const middleware = this.getMiddleware()
const params = matchNextDataPathname(parsedUrl.pathname)

Expand Down Expand Up @@ -624,17 +647,9 @@ export default abstract class Server<ServerOptions extends Options = Options> {
return false
}

protected handleNextImageRequest: RouteHandler = () => {
return false
}

protected handleCatchallRenderRequest: RouteHandler = () => {
return false
}

protected handleCatchallMiddlewareRequest: RouteHandler = () => {
return false
}
protected handleNextImageRequest: RouteHandler = () => {}
protected handleCatchallRenderRequest: RouteHandler = () => {}
protected handleCatchallMiddlewareRequest: RouteHandler = () => {}

protected getRouteMatchers(): RouteMatcherManager {
// Create a new manifest loader that get's the manifests from the server.
Expand Down Expand Up @@ -801,33 +816,33 @@ export default abstract class Server<ServerOptions extends Options = Options> {

// Parse url if parsedUrl not provided
if (!parsedUrl || typeof parsedUrl !== 'object') {
if (!req.url) {
throw new Error('Invariant: url can not be undefined')
}

parsedUrl = parseUrl(req.url!, true)
}

if (!parsedUrl.pathname) {
throw new Error("Invariant: pathname can't be empty")
}

// Parse the querystring ourselves if the user doesn't handle querystring parsing
if (typeof parsedUrl.query === 'string') {
parsedUrl.query = Object.fromEntries(
new URLSearchParams(parsedUrl.query)
)
}
// in minimal mode we detect RSC revalidate if the .rsc
// path is requested
if (this.minimalMode) {
if (req.url.endsWith('.rsc')) {
parsedUrl.query.__nextDataReq = '1'
} else if (req.headers['x-now-route-matches']) {
for (const param of FLIGHT_PARAMETERS) {
delete req.headers[param.toString().toLowerCase()]
}

let finished = await this.handleRSCRequest(req, res, parsedUrl)
if (finished) return

if (this.minimalMode && req.headers['x-now-route-matches']) {
for (const param of FLIGHT_PARAMETERS) {
delete req.headers[param.toString().toLowerCase()]
}
}

req.url = normalizeRscPath(req.url, this.hasAppDir)
parsedUrl.pathname = normalizeRscPath(
parsedUrl.pathname || '',
this.hasAppDir
)

this.attachRequestMeta(req, parsedUrl)

const domainLocale = this.i18nProvider?.detectDomainLocale(
Expand Down Expand Up @@ -866,12 +881,15 @@ export default abstract class Server<ServerOptions extends Options = Options> {
}
// x-matched-path is the source of truth, it tells what page
// should be rendered because we don't process rewrites in minimalMode
let matchedPath = normalizeRscPath(
new URL(req.headers['x-matched-path'] as string, 'http://localhost')
.pathname,
this.hasAppDir
let { pathname: matchedPath } = new URL(
req.headers['x-matched-path'] as string,
'http://localhost'
)

if (this.normalizers.rsc.match(matchedPath)) {
matchedPath = this.normalizers.rsc.normalize(matchedPath, true)
}

let urlPathname = new URL(req.url, 'http://localhost').pathname

// For ISR the URL is normalized to the prerenderPath so if
Expand Down Expand Up @@ -1062,7 +1080,7 @@ export default abstract class Server<ServerOptions extends Options = Options> {
parsedUrl.pathname = matchedPath
url.pathname = parsedUrl.pathname

const finished = await this.handleNextDataRequest(req, res, parsedUrl)
finished = await this.handle(req, res, parsedUrl)
if (finished) return
} catch (err) {
if (err instanceof DecodeError || err instanceof NormalizeError) {
Expand Down Expand Up @@ -1226,12 +1244,7 @@ export default abstract class Server<ServerOptions extends Options = Options> {
)
}

// Try to handle this as a `/_next/image` request.
let finished = await this.handleNextImageRequest(req, res, parsedUrl)
if (finished) return

// Try to handle the request as a `/_next/data` request.
finished = await this.handleNextDataRequest(req, res, parsedUrl)
finished = await this.handle(req, res, parsedUrl)
if (finished) return

await this.handleCatchallRenderRequest(req, res, parsedUrl)
Expand All @@ -1242,7 +1255,7 @@ export default abstract class Server<ServerOptions extends Options = Options> {
process.env.NEXT_RUNTIME !== 'edge' &&
req.headers['x-middleware-invoke']
) {
let finished = await this.handleNextDataRequest(req, res, parsedUrl)
finished = await this.handle(req, res, parsedUrl)
if (finished) return

finished = await this.handleCatchallMiddlewareRequest(
Expand All @@ -1264,8 +1277,11 @@ export default abstract class Server<ServerOptions extends Options = Options> {
throw err
}

// This wasn't a request via the matched path or the invoke path, so
// prepare for a legacy run by removing the base path.

// ensure we strip the basePath when not using an invoke header
if (!(useMatchedPathHeader || useInvokePath) && pathnameInfo.basePath) {
if (!useMatchedPathHeader && pathnameInfo.basePath) {
parsedUrl.pathname = removePathPrefix(
parsedUrl.pathname,
pathnameInfo.basePath
Expand Down Expand Up @@ -1297,6 +1313,19 @@ export default abstract class Server<ServerOptions extends Options = Options> {
}
}

protected handle: RouteHandler = async (req, res, url) => {
let finished = await this.handleNextImageRequest(req, res, url)
if (finished) return true

finished = await this.handleNextDataRequest(req, res, url)
if (finished) return true

if (this.minimalMode && this.hasAppDir) {
finished = await this.handleRSCRequest(req, res, url)
if (finished) return true
}
}

public getRequestHandler(): BaseRequestHandler {
return this.handleRequest.bind(this)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { BasePathPathnameNormalizer } from './base-path'

describe('BasePathPathnameNormalizer', () => {
describe('match', () => {
it('should return false if there is no basePath', () => {
let normalizer = new BasePathPathnameNormalizer('')
expect(normalizer.match('/')).toBe(false)
normalizer = new BasePathPathnameNormalizer('/')
expect(normalizer.match('/')).toBe(false)
})

it('should return false if the pathname does not start with the basePath', () => {
const normalizer = new BasePathPathnameNormalizer('/foo')
const pathnames = ['/bar', '/bar/foo', '/fooo/bar']
for (const pathname of pathnames) {
expect(normalizer.match(pathname)).toBe(false)
}
})

it('should return true if the pathname starts with the basePath', () => {
const normalizer = new BasePathPathnameNormalizer('/foo')
const pathnames = ['/foo', '/foo/bar', '/foo/bar/baz']
for (const pathname of pathnames) {
expect(normalizer.match(pathname)).toBe(true)
}
})
})

describe('normalize', () => {
it('should return the same pathname if there is no basePath', () => {
let normalizer = new BasePathPathnameNormalizer('')
expect(normalizer.normalize('/foo')).toBe('/foo')
normalizer = new BasePathPathnameNormalizer('/')
expect(normalizer.normalize('/foo')).toBe('/foo')
})

it('should return the same pathname if we are not matched and the pathname does not start with the basePath', () => {
const normalizer = new BasePathPathnameNormalizer('/foo')
let pathnames = ['/bar', '/bar/foo', '/fooo/bar']
for (const pathname of pathnames) {
expect(normalizer.normalize(pathname)).toBe(pathname)
}
})

it('should strip the basePath from the pathname when it matches', () => {
const normalizer = new BasePathPathnameNormalizer('/foo')
const pathnames = ['/foo', '/foo/bar', '/foo/bar/baz']
for (const pathname of pathnames) {
expect(normalizer.normalize(pathname)).toBe(pathname.substring(4))
}
})
})
})
32 changes: 32 additions & 0 deletions packages/next/src/server/future/normalizers/request/base-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { Normalizer } from '../normalizer'

export class BasePathPathnameNormalizer implements Normalizer {
private readonly basePath?: string
constructor(basePath: string) {
// A basePath of `/` is not a basePath.
if (!basePath || basePath === '/') return

this.basePath = basePath
}

public match(pathname: string) {
// If there's no basePath, we don't match.
if (!this.basePath) return false

// If the pathname doesn't start with the basePath, we don't match.
if (pathname !== this.basePath && !pathname.startsWith(this.basePath + '/'))
return false

return true
}

public normalize(pathname: string, matched?: boolean): string {
// If there's no basePath, we don't need to normalize.
if (!this.basePath) return pathname

// If we're not matched and we don't match, we don't need to normalize.
if (!matched && !this.match(pathname)) return pathname

return pathname.substring(this.basePath.length)
}
}
Loading