Skip to content

fix: ensure middleware matchers evaluate default locale #2548

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

Closed
wants to merge 20 commits into from
Closed
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6bd9781
fix: ensure 2nd level middleware match respects locales
orinokai Jul 16, 2024
49fe9ad
fix: ensure adding locale is case sensitive
orinokai Jul 18, 2024
61ff711
test: update expect message
orinokai Jul 18, 2024
1160801
chore: destructure localizedPath
orinokai Jul 18, 2024
b010192
feat: allow for locale fallbacks when adding locale prefix
orinokai Jul 19, 2024
74e2502
fix: pass NextConfig in NextRequest (needed even though it's not in t…
orinokai Jul 22, 2024
71ed888
fix: headers need to be Node.js style plain object (even though Next …
orinokai Jul 23, 2024
935e621
test: assert that paths don't match with an invalid prefix
orinokai Jul 23, 2024
8d3972c
Merge branch 'main' into rs/frp-761-imiddleware-locale
orinokai Jul 23, 2024
878f022
test: remove only
orinokai Jul 23, 2024
dccecf9
chore: refactor for clarity
orinokai Jul 23, 2024
bfd3f54
chore: revert red herring
orinokai Jul 23, 2024
e1330fd
Merge branch 'rs/frp-761-imiddleware-locale' of github.com:netlify/ne…
orinokai Jul 23, 2024
1a5886e
test: fix incorrect locale test
orinokai Jul 23, 2024
8e691dc
chore: refactor NetlifyNextRequest type for clarity
orinokai Jul 23, 2024
a7620fd
test: fix faulty i18n test logic
orinokai Jul 23, 2024
da8215f
chore: refactor to remove normalizeLocalizedTarget
orinokai Jul 24, 2024
fb8d9ea
test: update failure messages for clarity
orinokai Jul 25, 2024
af173a6
chore: update e2e test command docs
orinokai Jul 25, 2024
2bd7c8f
Merge branch 'main' into rs/frp-761-imiddleware-locale
pieh Jul 26, 2024
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
130 changes: 58 additions & 72 deletions edge-runtime/lib/next-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,103 +2,89 @@ import type { Context } from '@netlify/edge-functions'

import {
addBasePath,
addTrailingSlash,
addLocale,
normalizeDataUrl,
normalizeLocalePath,
normalizeTrailingSlash,
removeBasePath,
} from './util.ts'

interface I18NConfig {
defaultLocale: string
localeDetection?: false
locales: string[]
}
import type { NextConfig } from 'next/dist/server/config-shared'
import type { NextRequest, RequestInit } from 'next/dist/server/web/spec-extension/request.js'

export interface RequestData {
geo?: {
city?: string
country?: string
region?: string
latitude?: string
longitude?: string
timezone?: string
}
headers: Record<string, string>
ip?: string
method: string
nextConfig?: {
basePath?: string
i18n?: I18NConfig | null
trailingSlash?: boolean
skipMiddlewareUrlNormalize?: boolean
}
page?: {
name?: string
params?: { [key: string]: string }
export type NetlifyNextRequest = Partial<Omit<NextRequest, 'headers'>> &
RequestInit & {
headers: HeadersInit
}
url: string
body?: ReadableStream<Uint8Array>
detectedLocale?: string
}

const normalizeRequestURL = (
originalURL: string,
nextConfig?: RequestData['nextConfig'],
): { url: string; detectedLocale?: string } => {
const url = new URL(originalURL)
const normalizeRequest = (url: URL, nextConfig?: NextConfig): URL => {
url.pathname = removeBasePath(url.pathname, nextConfig?.basePath)

let pathname = removeBasePath(url.pathname, nextConfig?.basePath)
// We want to run middleware for data requests and expose the URL of the
// corresponding pages, so we have to normalize the URLs before running
// the handler.
url.pathname = normalizeDataUrl(url.pathname)

// If it exists, remove the locale from the URL and store it
const { detectedLocale } = normalizeLocalePath(pathname, nextConfig?.i18n?.locales)
// Normalizing the trailing slash based on the `trailingSlash` configuration
// property from the Next.js config.
url.pathname = normalizeTrailingSlash(url.pathname, nextConfig?.trailingSlash)

if (!nextConfig?.skipMiddlewareUrlNormalize) {
// We want to run middleware for data requests and expose the URL of the
// corresponding pages, so we have to normalize the URLs before running
// the handler.
pathname = normalizeDataUrl(pathname)
url.pathname = addBasePath(url.pathname, nextConfig?.basePath)

// Normalizing the trailing slash based on the `trailingSlash` configuration
// property from the Next.js config.
if (nextConfig?.trailingSlash) {
pathname = addTrailingSlash(pathname)
}
}
return url
}

url.pathname = addBasePath(pathname, nextConfig?.basePath)
export const localizeRequest = (
url: URL,
nextConfig?: NextConfig,
): { localizedUrl: URL; locale?: string } => {
url.pathname = removeBasePath(url.pathname, nextConfig?.basePath)

// Detect the locale from the URL
const { detectedLocale } = normalizeLocalePath(url.pathname, nextConfig?.i18n?.locales)

// Add the locale to the URL if not already present
url.pathname = addLocale(url.pathname, detectedLocale ?? nextConfig?.i18n?.defaultLocale)

url.pathname = addBasePath(url.pathname, nextConfig?.basePath)

return {
url: url.toString(),
detectedLocale,
localizedUrl: url,
locale: detectedLocale,
}
}

export const buildNextRequest = (
request: Request,
context: Context,
nextConfig?: RequestData['nextConfig'],
): RequestData => {
const { url, method, body, headers } = request
const { country, subdivision, city, latitude, longitude, timezone } = context.geo
const geo: RequestData['geo'] = {
city,
country: country?.code,
region: subdivision?.code,
latitude: latitude?.toString(),
longitude: longitude?.toString(),
timezone,
}
nextConfig?: NextConfig,
): NetlifyNextRequest => {
const { method, body, headers } = request
const { country, subdivision, city, latitude, longitude } = context.geo
const { i18n, basePath, trailingSlash } = nextConfig ?? {}

const { detectedLocale, url: normalizedUrl } = normalizeRequestURL(url, nextConfig)
const url = new URL(request.url)
const normalizedUrl = nextConfig?.skipMiddlewareUrlNormalize
? url
: normalizeRequest(url, nextConfig)

return {
url: normalizedUrl.toString(),
headers: Object.fromEntries(headers.entries()),
geo,
url: normalizedUrl,
method,
geo: {
city,
country: country?.code,
region: subdivision?.code,
latitude: latitude?.toString(),
longitude: longitude?.toString(),
},
ip: context.ip,
body: body ?? undefined,
nextConfig,
detectedLocale,
method,
body,
nextConfig: {
i18n,
basePath,
trailingSlash,
},
}
}
70 changes: 9 additions & 61 deletions edge-runtime/lib/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,8 @@ import { HTMLRewriter } from '../vendor/deno.land/x/[email protected]/
import { updateModifiedHeaders } from './headers.ts'
import type { StructuredLogger } from './logging.ts'
import { addMiddlewareHeaders, isMiddlewareRequest, isMiddlewareResponse } from './middleware.ts'
import { RequestData } from './next-request.ts'
import {
addBasePath,
normalizeDataUrl,
normalizeLocalePath,
normalizeTrailingSlash,
relativizeURL,
} from './util.ts'
import { NetlifyNextRequest } from './next-request.ts'
import { normalizeDataUrl, normalizeTrailingSlash, relativizeURL } from './util.ts'

export interface FetchEventResult {
response: Response
Expand All @@ -20,20 +14,20 @@ export interface FetchEventResult {

interface BuildResponseOptions {
context: Context
logger: StructuredLogger
nextConfig?: NetlifyNextRequest['nextConfig']
locale?: string
request: Request
result: FetchEventResult
nextConfig?: RequestData['nextConfig']
requestLocale?: string
logger: StructuredLogger
}

export const buildResponse = async ({
context,
logger,
nextConfig,
locale,
request,
result,
nextConfig,
requestLocale,
logger,
}: BuildResponseOptions): Promise<Response | void> => {
logger
.withFields({ is_nextresponse_next: result.response.headers.has('x-middleware-next') })
Expand Down Expand Up @@ -187,26 +181,12 @@ export const buildResponse = async ({
// respect trailing slash rules to prevent 308s
rewriteUrl.pathname = normalizeTrailingSlash(rewriteUrl.pathname, nextConfig?.trailingSlash)

const target = normalizeLocalizedTarget({ target: rewriteUrl.toString(), request, nextConfig })
if (target === request.url) {
logger.withFields({ rewrite_url: rewrite }).debug('Rewrite url is same as original url')
return
}
const target = rewriteUrl.toString()
res.headers.set('x-middleware-rewrite', relativeUrl)
request.headers.set('x-middleware-rewrite', target)
return addMiddlewareHeaders(context.rewrite(target), res)
}

// If we are redirecting a request that had a locale in the URL, we need to add it back in
if (redirect && requestLocale) {
redirect = normalizeLocalizedTarget({ target: redirect, request, nextConfig, requestLocale })
if (redirect === request.url) {
logger.withFields({ rewrite_url: rewrite }).debug('Rewrite url is same as original url')
return
}
res.headers.set('location', redirect)
}

// Data requests shouldn't automatically redirect in the browser (they might be HTML pages): they're handled by the router
if (redirect && isDataReq) {
res.headers.delete('location')
Expand All @@ -226,35 +206,3 @@ export const buildResponse = async ({

return res
}

/**
* Normalizes the locale in a URL.
*/
function normalizeLocalizedTarget({
target,
request,
nextConfig,
requestLocale,
}: {
target: string
request: Request
nextConfig?: RequestData['nextConfig']
requestLocale?: string
}) {
const targetUrl = new URL(target, request.url)

const normalizedTarget = normalizeLocalePath(targetUrl.pathname, nextConfig?.i18n?.locales)

const locale = normalizedTarget.detectedLocale ?? requestLocale
if (
locale &&
!normalizedTarget.pathname.startsWith(`/api/`) &&
!normalizedTarget.pathname.startsWith(`/_next/static/`)
) {
targetUrl.pathname =
addBasePath(`/${locale}${normalizedTarget.pathname}`, nextConfig?.basePath) || `/`
} else {
targetUrl.pathname = addBasePath(normalizedTarget.pathname, nextConfig?.basePath) || `/`
}
return targetUrl.toString()
}
2 changes: 1 addition & 1 deletion edge-runtime/lib/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@

import type { Key } from '../vendor/deno.land/x/[email protected]/index.ts'

import { compile, pathToRegexp } from '../vendor/deno.land/x/[email protected]/index.ts'
import { getCookies } from '../vendor/deno.land/[email protected]/http/cookie.ts'
import { compile, pathToRegexp } from '../vendor/deno.land/x/[email protected]/index.ts'

/*
┌─────────────────────────────────────────────────────────────────────────┐
Expand Down
14 changes: 14 additions & 0 deletions edge-runtime/lib/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,20 @@ export const addBasePath = (path: string, basePath?: string) => {
return path
}

// add locale prefix if not present, allowing for locale fallbacks
export const addLocale = (path: string, locale?: string) => {
if (
locale &&
path.toLowerCase() !== `/${locale.toLowerCase()}` &&
!path.toLowerCase().startsWith(`/${locale.toLowerCase()}/`) &&
!path.startsWith(`/api/`) &&
!path.startsWith(`/_next/static/`)
) {
return `/${locale}${path}`
}
return path
}

// https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/i18n/normalize-locale-path.ts

export interface PathLocale {
Expand Down
21 changes: 13 additions & 8 deletions edge-runtime/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ import nextConfig from './next.config.json' with { type: 'json' }

import { InternalHeaders } from './lib/headers.ts'
import { logger, LogLevel } from './lib/logging.ts'
import { buildNextRequest, RequestData } from './lib/next-request.ts'
import { buildNextRequest, localizeRequest, NetlifyNextRequest } from './lib/next-request.ts'
import { buildResponse, FetchEventResult } from './lib/response.ts'
import {
getMiddlewareRouteMatcher,
searchParamsToUrlQuery,
type MiddlewareRouteMatch,
} from './lib/routing.ts'

type NextHandler = (params: { request: RequestData }) => Promise<FetchEventResult>
type NextHandler = (params: { request: NetlifyNextRequest }) => Promise<FetchEventResult>

const matchesMiddleware: MiddlewareRouteMatch = getMiddlewareRouteMatcher(matchers || [])

Expand All @@ -31,34 +31,39 @@ export async function handleMiddleware(
context: Context,
nextHandler: NextHandler,
) {
const nextRequest = buildNextRequest(request, context, nextConfig)
const url = new URL(request.url)
const query = searchParamsToUrlQuery(url.searchParams)
const { localizedUrl, locale } = localizeRequest(url, nextConfig)
const reqLogger = logger
.withLogLevel(
request.headers.has(InternalHeaders.NFDebugLogging) ? LogLevel.Debug : LogLevel.Log,
)
.withFields({ url_path: url.pathname })
.withRequestID(request.headers.get(InternalHeaders.NFRequestID))

// Convert the incoming request to a Next.js request, which includes
// normalizing the URL, adding geo and IP information and converting
// the headers to a plain object, among other things.
const nextRequest = buildNextRequest(request, context, nextConfig)

// While we have already checked the path when mapping to the edge function,
// Next.js supports extra rules that we need to check here too, because we
// might be running an edge function for a path we should not. If we find
// that's the case, short-circuit the execution.
if (!matchesMiddleware(url.pathname, request, searchParamsToUrlQuery(url.searchParams))) {
if (!matchesMiddleware(localizedUrl.pathname, request, query)) {
reqLogger.debug('Aborting middleware due to runtime rules')

return
}

try {
const result = await nextHandler({ request: nextRequest })
const response = await buildResponse({
context,
logger: reqLogger,
nextConfig: nextRequest.nextConfig,
locale,
request,
result,
requestLocale: nextRequest.detectedLocale,
nextConfig,
logger: reqLogger,
})

return response
Expand Down
5 changes: 1 addition & 4 deletions src/build/functions/edge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,7 @@ const writeHandlerFile = async (ctx: PluginContext, { matchers, name }: NextDefi

// Writing a file with the matchers that should trigger this function. We'll
// read this file from the function at runtime.
await writeFile(
join(handlerRuntimeDirectory, 'matchers.json'),
JSON.stringify(augmentMatchers(matchers, ctx)),
)
await writeFile(join(handlerRuntimeDirectory, 'matchers.json'), JSON.stringify(matchers))

// The config is needed by the edge function to match and normalize URLs. To
// avoid shipping and parsing a large file at runtime, let's strip it down to
Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/middleware-conditions/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const config = {
source: '/hello',
},
{
source: '/nl-NL/about',
source: '/nl/about',
locale: false,
},
],
Expand Down
Loading
Loading