diff --git a/docs/api-reference/next.config.js/headers.md b/docs/api-reference/next.config.js/headers.md index 9d672f1383fd9..b3aaf30bcd27a 100644 --- a/docs/api-reference/next.config.js/headers.md +++ b/docs/api-reference/next.config.js/headers.md @@ -175,3 +175,41 @@ module.exports = { }, } ``` + +### Headers with i18n support + +When leveraging [`i18n` support](/docs/advanced-features/i18n-routing.md) with headers each `source` is automatically prefixed to handle the configured `locales` unless you add `locale: false` to the header: + +```js +module.exports = { + i18n: { + locales: ['en', 'fr', 'de'], + defaultLocale: 'en', + }, + + async headers() { + return [ + { + source: '/with-locale', // automatically handles all locales + headers: [ + { + key: 'x-hello', + value: 'world', + }, + ], + }, + { + // does not handle locales automatically since locale: false is set + source: '/nl/with-locale-manual', + locale: false, + headers: [ + { + key: 'x-hello', + value: 'world', + }, + ], + }, + ] + }, +} +``` diff --git a/docs/api-reference/next.config.js/redirects.md b/docs/api-reference/next.config.js/redirects.md index 02b9771af56e5..83114b5618970 100644 --- a/docs/api-reference/next.config.js/redirects.md +++ b/docs/api-reference/next.config.js/redirects.md @@ -120,4 +120,34 @@ module.exports = { } ``` +### Redirects with i18n support + +When leveraging [`i18n` support](/docs/advanced-features/i18n-routing.md) with redirects each `source` and `destination` is automatically prefixed to handle the configured `locales` unless you add `locale: false` to the redirect: + +```js +module.exports = { + i18n: { + locales: ['en', 'fr', 'de'], + defaultLocale: 'en', + }, + + async redirects() { + return [ + { + source: '/with-locale', // automatically handles all locales + destination: '/another', // automatically passes the locale on + permanent: false, + }, + { + // does not handle locales automatically since locale: false is set + source: '/nl/with-locale-manual', + destination: '/nl/another', + locale: false, + permanent: false, + }, + ] + }, +} +``` + In some rare cases, you might need to assign a custom status code for older HTTP Clients to properly redirect. In these cases, you can use the `statusCode` property instead of the `permanent` property, but not both. Note: to ensure IE11 compatibility a `Refresh` header is automatically added for the 308 status code. diff --git a/docs/api-reference/next.config.js/rewrites.md b/docs/api-reference/next.config.js/rewrites.md index 41ed98dd25303..0ae1af38dccdd 100644 --- a/docs/api-reference/next.config.js/rewrites.md +++ b/docs/api-reference/next.config.js/rewrites.md @@ -163,3 +163,31 @@ module.exports = { }, } ``` + +### Rewrites with i18n support + +When leveraging [`i18n` support](/docs/advanced-features/i18n-routing.md) with rewrites each `source` and `destination` is automatically prefixed to handle the configured `locales` unless you add `locale: false` to the rewrite: + +```js +module.exports = { + i18n: { + locales: ['en', 'fr', 'de'], + defaultLocale: 'en', + }, + + async rewrites() { + return [ + { + source: '/with-locale', // automatically handles all locales + destination: '/another', // automatically passes the locale on + }, + { + // does not handle locales automatically since locale: false is set + source: '/nl/with-locale-manual', + destination: '/nl/another', + locale: false, + }, + ] + }, +} +``` diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index 352fd3127c7a7..497e383d635e5 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -254,6 +254,7 @@ export default async function build( const buildCustomRoute = ( r: { source: string + locale?: false basePath?: false statusCode?: number destination?: string @@ -262,7 +263,7 @@ export default async function build( ) => { const keys: any[] = [] - if (r.basePath !== false) { + if (r.basePath !== false && (!config.i18n || r.locale === false)) { r.source = `${config.basePath}${r.source}` if (r.destination && r.destination.startsWith('/')) { @@ -270,6 +271,18 @@ export default async function build( } } + if (config.i18n && r.locale !== false) { + const basePath = r.basePath !== false ? config.basePath || '' : '' + + r.source = `${basePath}/:nextInternalLocale(${config.i18n.locales + .map((locale: string) => escapeStringRegexp(locale)) + .join('|')})${r.source}` + + if (r.destination && r.destination?.startsWith('/')) { + r.destination = `${basePath}/:nextInternalLocale${r.destination}` + } + } + const routeRegex = pathToRegexp(r.source, keys, { strict: true, sensitive: false, diff --git a/packages/next/build/webpack/loaders/next-serverless-loader.ts b/packages/next/build/webpack/loaders/next-serverless-loader.ts index abcce38108787..c5e49b2e17c1f 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader.ts @@ -191,8 +191,7 @@ const nextServerlessLoader: loader.Loader = function () { rewrite.destination, params, parsedUrl.query, - true, - "${basePath}" + true ) Object.assign(parsedUrl.query, parsedDestination.query) @@ -200,13 +199,43 @@ const nextServerlessLoader: loader.Loader = function () { Object.assign(parsedUrl, parsedDestination) - if (parsedUrl.pathname === '${page}'){ + let fsPathname = parsedUrl.pathname + + ${ + basePath + ? ` + fsPathname = fsPathname.replace( + new RegExp('^${basePath}'), + '' + ) || '/' + ` + : '' + } + + ${ + i18n + ? ` + const destLocalePathResult = normalizeLocalePath( + fsPathname, + i18n.locales + ) + fsPathname = destLocalePathResult.pathname + + parsedUrl.query.nextInternalLocale = ( + destLocalePathResult.detectedLocale || + params.nextInternalLocale + ) + ` + : '' + } + + if (fsPathname === '${page}'){ break } ${ pageIsDynamicRoute ? ` - const dynamicParams = dynamicRouteMatcher(parsedUrl.pathname);\ + const dynamicParams = dynamicRouteMatcher(fsPathname);\ if (dynamicParams) { parsedUrl.query = { ...parsedUrl.query, @@ -235,12 +264,10 @@ const nextServerlessLoader: loader.Loader = function () { const handleLocale = i18nEnabled ? ` // get pathname from URL with basePath stripped for locale detection - const i18n = ${i18n} const accept = require('@hapi/accept') const cookie = require('next/dist/compiled/cookie') const { detectLocaleCookie } = require('next/dist/next-server/lib/i18n/detect-locale-cookie') const { detectDomainLocale } = require('next/dist/next-server/lib/i18n/detect-domain-locale') - const { normalizeLocalePath } = require('next/dist/next-server/lib/i18n/normalize-locale-path') let locales = i18n.locales let defaultLocale = i18n.defaultLocale let detectedLocale = detectLocaleCookie(req, i18n.locales) @@ -400,6 +427,9 @@ const nextServerlessLoader: loader.Loader = function () { ${dynamicRouteImports} const { parse: parseUrl } = require('url') const { apiResolver } = require('next/dist/next-server/server/api-utils') + const { normalizeLocalePath } = require('next/dist/next-server/lib/i18n/normalize-locale-path') + const i18n = ${i18n || '{}'} + ${rewriteImports} ${dynamicRouteMatcher} @@ -416,6 +446,12 @@ const nextServerlessLoader: loader.Loader = function () { // to ensure we are using the correct values const trustQuery = req.headers['${vercelHeader}'] const parsedUrl = handleRewrites(parseUrl(req.url, true)) + + if (parsedUrl.query.nextInternalLocale) { + detectedLocale = parsedUrl.query.nextInternalLocale + delete parsedUrl.query.nextInternalLocale + } + let hasValidParams = true ${normalizeDynamicRouteParams} @@ -482,6 +518,8 @@ const nextServerlessLoader: loader.Loader = function () { const {PERMANENT_REDIRECT_STATUS} = require('next/dist/next-server/lib/constants') const buildManifest = require('${buildManifest}'); const reactLoadableManifest = require('${reactLoadableManifest}'); + const { normalizeLocalePath } = require('next/dist/next-server/lib/i18n/normalize-locale-path') + const i18n = ${i18n || '{}'} const appMod = require('${absoluteAppPath}') let App = appMod.default || appMod.then && appMod.then(mod => mod.default); @@ -606,6 +644,11 @@ const nextServerlessLoader: loader.Loader = function () { ${handleLocale} + if (parsedUrl.query.nextInternalLocale) { + detectedLocale = parsedUrl.query.nextInternalLocale + delete parsedUrl.query.nextInternalLocale + } + const renderOpts = Object.assign( { Component, diff --git a/packages/next/lib/load-custom-routes.ts b/packages/next/lib/load-custom-routes.ts index a6200ae308e2f..81ab2e9e9e89e 100644 --- a/packages/next/lib/load-custom-routes.ts +++ b/packages/next/lib/load-custom-routes.ts @@ -9,11 +9,13 @@ export type Rewrite = { source: string destination: string basePath?: false + locale?: false } export type Header = { source: string basePath?: false + locale?: false headers: Array<{ key: string; value: string }> } @@ -21,8 +23,6 @@ export type Header = { export type Redirect = Rewrite & { statusCode?: number permanent?: boolean - destination: string - basePath?: false } export const allowedStatusCodes = new Set([301, 302, 303, 307, 308]) @@ -157,10 +157,11 @@ function checkCustomRoutes( 'source', 'destination', 'basePath', + 'locale', ...(isRedirect ? ['statusCode', 'permanent'] : []), ]) } else { - allowedKeys = new Set(['source', 'headers', 'basePath']) + allowedKeys = new Set(['source', 'headers', 'basePath', 'locale']) } for (const route of routes) { @@ -201,6 +202,10 @@ function checkCustomRoutes( invalidParts.push('`basePath` must be undefined or false') } + if (typeof route.locale !== 'undefined' && route.locale !== false) { + invalidParts.push('`locale` must be undefined or true') + } + if (!route.source) { invalidParts.push('`source` is missing') } else if (typeof route.source !== 'string') { @@ -386,11 +391,13 @@ export default async function loadCustomRoutes( source: '/:file((?:[^/]+/)*[^/]+\\.\\w+)/', destination: '/:file', permanent: true, + locale: config.i18n ? false : undefined, }, { source: '/:notfile((?:[^/]+/)*[^/\\.]+)', destination: '/:notfile/', permanent: true, + locale: config.i18n ? false : undefined, } ) if (config.basePath) { @@ -399,6 +406,7 @@ export default async function loadCustomRoutes( destination: config.basePath + '/', permanent: true, basePath: false, + locale: config.i18n ? false : undefined, }) } } else { @@ -406,6 +414,7 @@ export default async function loadCustomRoutes( source: '/:path+/', destination: '/:path+', permanent: true, + locale: config.i18n ? false : undefined, }) if (config.basePath) { redirects.unshift({ @@ -413,6 +422,7 @@ export default async function loadCustomRoutes( destination: config.basePath, permanent: true, basePath: false, + locale: config.i18n ? false : undefined, }) } } diff --git a/packages/next/next-server/lib/router/utils/prepare-destination.ts b/packages/next/next-server/lib/router/utils/prepare-destination.ts index 9063992e18319..8019d777e12a0 100644 --- a/packages/next/next-server/lib/router/utils/prepare-destination.ts +++ b/packages/next/next-server/lib/router/utils/prepare-destination.ts @@ -58,6 +58,7 @@ export default function prepareDestination( // clone query so we don't modify the original query = Object.assign({}, query) + const hadLocale = query.__nextLocale delete query.__nextLocale delete query.__nextDefaultLocale @@ -121,7 +122,12 @@ export default function prepareDestination( // add path params to query if it's not a redirect and not // already defined in destination query or path - const paramKeys = Object.keys(params) + let paramKeys = Object.keys(params) + + // remove internal param for i18n + if (hadLocale) { + paramKeys = paramKeys.filter((name) => name !== 'nextInternalLocale') + } if ( appendParamsToQuery && @@ -144,7 +150,7 @@ export default function prepareDestination( const [pathname, hash] = newUrl.split('#') parsedDestination.pathname = pathname parsedDestination.hash = `${hash ? '#' : ''}${hash || ''}` - delete parsedDestination.search + delete (parsedDestination as any).search } catch (err) { if (err.message.match(/Expected .*? to not repeat, but got an array/)) { throw new Error( diff --git a/packages/next/next-server/server/next-server.ts b/packages/next/next-server/server/next-server.ts index 9b84fd1a9d9bc..549d8a7726056 100644 --- a/packages/next/next-server/server/next-server.ts +++ b/packages/next/next-server/server/next-server.ts @@ -84,6 +84,7 @@ import * as Log from '../../build/output/log' import { imageOptimizer } from './image-optimizer' import { detectDomainLocale } from '../lib/i18n/detect-domain-locale' import cookie from 'next/dist/compiled/cookie' +import escapeStringRegexp from 'next/dist/compiled/escape-string-regexp' const getCustomRouteMatcher = pathMatch(true) @@ -504,6 +505,7 @@ export default class Server { pageChecker: PageChecker useFileSystemPublicRoutes: boolean dynamicRoutes: DynamicRoutes | undefined + locales: string[] } { const server: Server = this const publicRoutes = fs.existsSync(this.publicDir) @@ -658,14 +660,41 @@ export default class Server { : '' } - const getCustomRoute = (r: Rewrite | Redirect | Header, type: RouteType) => - ({ + const getCustomRouteLocalePrefix = (r: { + locale?: false + destination?: string + }) => { + const { i18n } = this.nextConfig + + if (!i18n || r.locale === false || !this.renderOpts.dev) return '' + + if (r.destination && r.destination.startsWith('/')) { + r.destination = `/:nextInternalLocale${r.destination}` + } + + return `/:nextInternalLocale(${i18n.locales + .map((locale: string) => escapeStringRegexp(locale)) + .join('|')})` + } + + const getCustomRoute = ( + r: Rewrite | Redirect | Header, + type: RouteType + ) => { + const match = getCustomRouteMatcher( + `${getCustomRouteBasePath(r)}${getCustomRouteLocalePrefix(r)}${ + r.source + }` + ) + + return { ...r, type, - match: getCustomRouteMatcher(`${getCustomRouteBasePath(r)}${r.source}`), + match, name: type, fn: async (_req, _res, _params, _parsedUrl) => ({ finished: false }), - } as Route & Rewrite & Header) + } as Route & Rewrite & Header + } // Headers come very first const headers = this.customRoutes.headers.map((r) => { @@ -810,6 +839,18 @@ export default class Server { // next.js core assumes page path without trailing slash pathname = removePathTrailingSlash(pathname) + if (this.nextConfig.i18n) { + const localePathResult = normalizeLocalePath( + pathname, + this.nextConfig.i18n?.locales + ) + + if (localePathResult.detectedLocale) { + pathname = localePathResult.pathname + parsedUrl.query.__nextLocale = localePathResult.detectedLocale + } + } + if (params?.path?.[0] === 'api') { const handled = await this.handleApiRequest( req as NextApiRequest, @@ -845,6 +886,7 @@ export default class Server { dynamicRoutes: this.dynamicRoutes, basePath: this.nextConfig.basePath, pageChecker: this.hasPage.bind(this), + locales: this.nextConfig.i18n?.locales, } } diff --git a/packages/next/next-server/server/router.ts b/packages/next/next-server/server/router.ts index 30b9042cab643..cd1c2bd6d846c 100644 --- a/packages/next/next-server/server/router.ts +++ b/packages/next/next-server/server/router.ts @@ -3,6 +3,7 @@ import { UrlWithParsedQuery } from 'url' import pathMatch from '../lib/router/utils/path-match' import { removePathTrailingSlash } from '../../client/normalize-trailing-slash' +import { normalizeLocalePath } from '../lib/i18n/normalize-locale-path' export const route = pathMatch() @@ -52,6 +53,7 @@ export default class Router { pageChecker: PageChecker dynamicRoutes: DynamicRoutes useFileSystemPublicRoutes: boolean + locales: string[] constructor({ basePath = '', @@ -63,6 +65,7 @@ export default class Router { dynamicRoutes = [], pageChecker, useFileSystemPublicRoutes, + locales = [], }: { basePath: string headers: Route[] @@ -73,6 +76,7 @@ export default class Router { dynamicRoutes: DynamicRoutes | undefined pageChecker: PageChecker useFileSystemPublicRoutes: boolean + locales: string[] }) { this.basePath = basePath this.headers = headers @@ -83,6 +87,7 @@ export default class Router { this.catchAllRoute = catchAllRoute this.dynamicRoutes = dynamicRoutes this.useFileSystemPublicRoutes = useFileSystemPublicRoutes + this.locales = locales } setDynamicRoutes(routes: DynamicRoutes = []) { @@ -101,6 +106,8 @@ export default class Router { // memoize page check calls so we don't duplicate checks for pages const pageChecks: { [name: string]: Promise } = {} const memoizedPageChecker = async (p: string): Promise => { + p = normalizeLocalePath(p, this.locales).pathname + if (pageChecks[p]) { return pageChecks[p] } @@ -178,11 +185,7 @@ export default class Router { } // re-add locale for custom-routes to allow matching against - if ( - isCustomRoute && - (req as any).__nextStrippedLocale && - parsedUrl.query.__nextLocale - ) { + if (isCustomRoute && parsedUrl.query.__nextLocale) { if (keepBasePath) { currentPathname = replaceBasePath(this.basePath, currentPathname!) } diff --git a/test/integration/i18n-support-base-path/next.config.js b/test/integration/i18n-support-base-path/next.config.js index 95c230b6ca6d8..18f254b1aef1d 100644 --- a/test/integration/i18n-support-base-path/next.config.js +++ b/test/integration/i18n-support-base-path/next.config.js @@ -25,17 +25,19 @@ module.exports = { async redirects() { return [ { - source: '/en-US/redirect', + source: '/en-US/redirect-1', destination: '/somewhere-else', permanent: false, + locale: false, }, { - source: '/nl/redirect', + source: '/nl/redirect-2', destination: '/somewhere-else', permanent: false, + locale: false, }, { - source: '/redirect', + source: '/redirect-3', destination: '/somewhere-else', permanent: false, }, @@ -44,23 +46,35 @@ module.exports = { async rewrites() { return [ { - source: '/en-US/rewrite', + source: '/en-US/rewrite-1', destination: '/another', + locale: false, }, { - source: '/nl/rewrite', - destination: '/another', + source: '/nl/rewrite-2', + destination: '/nl/another', + locale: false, + }, + { + source: '/fr/rewrite-3', + destination: '/nl/another', + locale: false, }, { - source: '/rewrite', + source: '/rewrite-4', destination: '/another', }, + { + source: '/rewrite-5', + destination: 'http://localhost:__EXTERNAL_PORT__', + }, ] }, async headers() { return [ { - source: '/en-US/add-header', + source: '/en-US/add-header-1', + locale: false, headers: [ { key: 'x-hello', @@ -69,7 +83,8 @@ module.exports = { ], }, { - source: '/nl/add-header', + source: '/nl/add-header-2', + locale: false, headers: [ { key: 'x-hello', @@ -78,7 +93,7 @@ module.exports = { ], }, { - source: '/add-header', + source: '/add-header-3', headers: [ { key: 'x-hello', diff --git a/test/integration/i18n-support-base-path/test/index.test.js b/test/integration/i18n-support-base-path/test/index.test.js index 026b5c8f06dac..26fc029dc3e95 100644 --- a/test/integration/i18n-support-base-path/test/index.test.js +++ b/test/integration/i18n-support-base-path/test/index.test.js @@ -21,7 +21,20 @@ const ctx = { appDir, } -describe('i18n Support', () => { +describe('i18n Support basePath', () => { + beforeAll(async () => { + ctx.externalPort = await findPort() + ctx.externalApp = http.createServer((req, res) => { + res.statusCode = 200 + res.end(JSON.stringify({ url: req.url, external: true })) + }) + await new Promise((resolve, reject) => { + ctx.externalApp.listen(ctx.externalPort, (err) => + err ? reject(err) : resolve() + ) + }) + }) + describe('dev mode', () => { const curCtx = { ...ctx, @@ -29,6 +42,7 @@ describe('i18n Support', () => { } beforeAll(async () => { nextConfig.replace('// basePath', 'basePath') + nextConfig.replace(/__EXTERNAL_PORT__/g, ctx.externalPort) await fs.remove(join(appDir, '.next')) curCtx.appPort = await findPort() curCtx.app = await launchApp(appDir, curCtx.appPort) @@ -44,6 +58,7 @@ describe('i18n Support', () => { describe('production mode', () => { beforeAll(async () => { nextConfig.replace('// basePath', 'basePath') + nextConfig.replace(/__EXTERNAL_PORT__/g, ctx.externalPort) await fs.remove(join(appDir, '.next')) await nextBuild(appDir) ctx.appPort = await findPort() @@ -64,6 +79,7 @@ describe('i18n Support', () => { await fs.remove(join(appDir, '.next')) nextConfig.replace('// target', 'target') nextConfig.replace('// basePath', 'basePath') + nextConfig.replace(/__EXTERNAL_PORT__/g, ctx.externalPort) await nextBuild(appDir) ctx.appPort = await findPort() @@ -118,6 +134,60 @@ describe('i18n Support', () => { }) }) + it('should resolve rewrites correctly', async () => { + const serverFile = getPageFileFromPagesManifest(appDir, '/another') + const appPort = await findPort() + const mod = require(join(appDir, '.next/serverless', serverFile)) + + const server = http.createServer(async (req, res) => { + try { + await mod.render(req, res) + } catch (err) { + res.statusCode = 500 + res.end('internal err') + } + }) + + await new Promise((resolve, reject) => { + server.listen(appPort, (err) => (err ? reject(err) : resolve())) + }) + console.log('listening on', appPort) + + const requests = await Promise.all( + [ + '/en-US/rewrite-1', + '/nl/rewrite-2', + '/fr/rewrite-3', + '/en-US/rewrite-4', + '/fr/rewrite-4', + ].map((path) => + fetchViaHTTP(appPort, `${ctx.basePath}${path}`, undefined, { + redirect: 'manual', + }) + ) + ) + + server.close() + + const checks = [ + ['en-US', '/rewrite-1'], + ['nl', '/rewrite-2'], + ['nl', '/rewrite-3'], + ['en-US', '/rewrite-4'], + ['fr', '/rewrite-4'], + ] + + for (let i = 0; i < requests.length; i++) { + const res = requests[i] + const [locale, asPath] = checks[i] + const $ = cheerio.load(await res.text()) + expect($('html').attr('lang')).toBe(locale) + expect($('#router-locale').text()).toBe(locale) + expect($('#router-pathname').text()).toBe('/another') + expect($('#router-as-path').text()).toBe(asPath) + } + }) + runTests(ctx) }) diff --git a/test/integration/i18n-support/next.config.js b/test/integration/i18n-support/next.config.js index 95c230b6ca6d8..18f254b1aef1d 100644 --- a/test/integration/i18n-support/next.config.js +++ b/test/integration/i18n-support/next.config.js @@ -25,17 +25,19 @@ module.exports = { async redirects() { return [ { - source: '/en-US/redirect', + source: '/en-US/redirect-1', destination: '/somewhere-else', permanent: false, + locale: false, }, { - source: '/nl/redirect', + source: '/nl/redirect-2', destination: '/somewhere-else', permanent: false, + locale: false, }, { - source: '/redirect', + source: '/redirect-3', destination: '/somewhere-else', permanent: false, }, @@ -44,23 +46,35 @@ module.exports = { async rewrites() { return [ { - source: '/en-US/rewrite', + source: '/en-US/rewrite-1', destination: '/another', + locale: false, }, { - source: '/nl/rewrite', - destination: '/another', + source: '/nl/rewrite-2', + destination: '/nl/another', + locale: false, + }, + { + source: '/fr/rewrite-3', + destination: '/nl/another', + locale: false, }, { - source: '/rewrite', + source: '/rewrite-4', destination: '/another', }, + { + source: '/rewrite-5', + destination: 'http://localhost:__EXTERNAL_PORT__', + }, ] }, async headers() { return [ { - source: '/en-US/add-header', + source: '/en-US/add-header-1', + locale: false, headers: [ { key: 'x-hello', @@ -69,7 +83,8 @@ module.exports = { ], }, { - source: '/nl/add-header', + source: '/nl/add-header-2', + locale: false, headers: [ { key: 'x-hello', @@ -78,7 +93,7 @@ module.exports = { ], }, { - source: '/add-header', + source: '/add-header-3', headers: [ { key: 'x-hello', diff --git a/test/integration/i18n-support/pages/api/hello.js b/test/integration/i18n-support/pages/api/hello.js new file mode 100644 index 0000000000000..b7a0952300a15 --- /dev/null +++ b/test/integration/i18n-support/pages/api/hello.js @@ -0,0 +1,3 @@ +export default (req, res) => { + res.json({ hello: true, query: req.query }) +} diff --git a/test/integration/i18n-support/pages/api/post/[slug].js b/test/integration/i18n-support/pages/api/post/[slug].js new file mode 100644 index 0000000000000..225e2fc8f2607 --- /dev/null +++ b/test/integration/i18n-support/pages/api/post/[slug].js @@ -0,0 +1,3 @@ +export default (req, res) => { + res.json({ post: true, query: req.query }) +} diff --git a/test/integration/i18n-support/server.js b/test/integration/i18n-support/server.js new file mode 100644 index 0000000000000..101320568c1d3 --- /dev/null +++ b/test/integration/i18n-support/server.js @@ -0,0 +1,14 @@ +const http = require('http') +const mod = require('./.next/serverless/pages/another') + +const server = http.createServer(async (req, res) => { + try { + await mod.render(req, res) + } catch (err) { + console.error(err) + res.statusCode = 500 + res.end('internal error') + } +}) + +server.listen(3000, () => console.log('listening')) diff --git a/test/integration/i18n-support/test/index.test.js b/test/integration/i18n-support/test/index.test.js index c37351c01c367..5a3107f155e21 100644 --- a/test/integration/i18n-support/test/index.test.js +++ b/test/integration/i18n-support/test/index.test.js @@ -22,6 +22,20 @@ const ctx = { } describe('i18n Support', () => { + beforeAll(async () => { + ctx.externalPort = await findPort() + ctx.externalApp = http.createServer((req, res) => { + res.statusCode = 200 + res.end(JSON.stringify({ url: req.url, external: true })) + }) + await new Promise((resolve, reject) => { + ctx.externalApp.listen(ctx.externalPort, (err) => + err ? reject(err) : resolve() + ) + }) + }) + afterAll(() => ctx.externalApp.close()) + describe('dev mode', () => { const curCtx = { ...ctx, @@ -29,11 +43,13 @@ describe('i18n Support', () => { } beforeAll(async () => { await fs.remove(join(appDir, '.next')) + nextConfig.replace(/__EXTERNAL_PORT__/g, ctx.externalPort) curCtx.appPort = await findPort() curCtx.app = await launchApp(appDir, curCtx.appPort) }) afterAll(async () => { await killApp(curCtx.app) + nextConfig.restore() }) runTests(curCtx) @@ -42,13 +58,17 @@ describe('i18n Support', () => { describe('production mode', () => { beforeAll(async () => { await fs.remove(join(appDir, '.next')) + nextConfig.replace(/__EXTERNAL_PORT__/g, ctx.externalPort) await nextBuild(appDir) ctx.appPort = await findPort() ctx.app = await nextStart(appDir, ctx.appPort) ctx.buildPagesDir = join(appDir, '.next/server/pages') ctx.buildId = await fs.readFile(join(appDir, '.next/BUILD_ID'), 'utf8') }) - afterAll(() => killApp(ctx.app)) + afterAll(async () => { + await killApp(ctx.app) + nextConfig.restore() + }) runTests(ctx) }) @@ -57,6 +77,7 @@ describe('i18n Support', () => { beforeAll(async () => { await fs.remove(join(appDir, '.next')) nextConfig.replace('// target', 'target') + nextConfig.replace(/__EXTERNAL_PORT__/g, ctx.externalPort) await nextBuild(appDir) ctx.appPort = await findPort() @@ -111,6 +132,60 @@ describe('i18n Support', () => { }) }) + it('should resolve rewrites correctly', async () => { + const serverFile = getPageFileFromPagesManifest(appDir, '/another') + const appPort = await findPort() + const mod = require(join(appDir, '.next/serverless', serverFile)) + + const server = http.createServer(async (req, res) => { + try { + await mod.render(req, res) + } catch (err) { + res.statusCode = 500 + res.end('internal err') + } + }) + + await new Promise((resolve, reject) => { + server.listen(appPort, (err) => (err ? reject(err) : resolve())) + }) + console.log('listening on', appPort) + + const requests = await Promise.all( + [ + '/en-US/rewrite-1', + '/nl/rewrite-2', + '/fr/rewrite-3', + '/en-US/rewrite-4', + '/fr/rewrite-4', + ].map((path) => + fetchViaHTTP(appPort, `${ctx.basePath}${path}`, undefined, { + redirect: 'manual', + }) + ) + ) + + server.close() + + const checks = [ + ['en-US', '/rewrite-1'], + ['nl', '/rewrite-2'], + ['nl', '/rewrite-3'], + ['en-US', '/rewrite-4'], + ['fr', '/rewrite-4'], + ] + + for (let i = 0; i < requests.length; i++) { + const res = requests[i] + const [locale, asPath] = checks[i] + const $ = cheerio.load(await res.text()) + expect($('html').attr('lang')).toBe(locale) + expect($('#router-locale').text()).toBe(locale) + expect($('#router-pathname').text()).toBe('/another') + expect($('#router-as-path').text()).toBe(asPath) + } + }) + runTests(ctx) }) diff --git a/test/integration/i18n-support/test/shared.js b/test/integration/i18n-support/test/shared.js index 2c90427aff7f4..e4822fcf0f66d 100644 --- a/test/integration/i18n-support/test/shared.js +++ b/test/integration/i18n-support/test/shared.js @@ -309,7 +309,15 @@ export function runTests(ctx) { }) it('should apply redirects correctly', async () => { - for (const path of ['/redirect', '/en-US/redirect', '/nl/redirect']) { + for (const [path, shouldRedirect, locale] of [ + ['/en-US/redirect-1', true], + ['/en/redirect-1', false], + ['/nl/redirect-2', true], + ['/fr/redirect-2', false], + ['/redirect-3', true, '/en-US'], + ['/en/redirect-3', true, '/en'], + ['/nl-NL/redirect-3', true, '/nl-NL'], + ]) { const res = await fetchViaHTTP( ctx.appPort, `${ctx.basePath}${path}`, @@ -318,16 +326,29 @@ export function runTests(ctx) { redirect: 'manual', } ) - expect(res.status).toBe(307) - const parsed = url.parse(res.headers.get('location'), true) - expect(parsed.pathname).toBe(`${ctx.basePath}/somewhere-else`) - expect(parsed.query).toEqual({}) + expect(res.status).toBe(shouldRedirect ? 307 : 404) + + if (shouldRedirect) { + const parsed = url.parse(res.headers.get('location'), true) + expect(parsed.pathname).toBe( + `${ctx.basePath}${locale || ''}/somewhere-else` + ) + expect(parsed.query).toEqual({}) + } } }) it('should apply headers correctly', async () => { - for (const path of ['/add-header', '/en-US/add-header', '/nl/add-header']) { + for (const [path, shouldAdd] of [ + ['/en-US/add-header-1', true], + ['/en/add-header-1', false], + ['/nl/add-header-2', true], + ['/fr/add-header-2', false], + ['/add-header-3', true], + ['/en/add-header-3', true], + ['/nl-NL/add-header-3', true], + ]) { const res = await fetchViaHTTP( ctx.appPort, `${ctx.basePath}${path}`, @@ -337,30 +358,69 @@ export function runTests(ctx) { } ) expect(res.status).toBe(404) - expect(res.headers.get('x-hello')).toBe('world') + expect(res.headers.get('x-hello')).toBe(shouldAdd ? 'world' : null) } }) it('should apply rewrites correctly', async () => { - const checks = [ + let res = await fetchViaHTTP( + ctx.appPort, + `${ctx.basePath}/en-US/rewrite-1`, + undefined, { - locale: 'en-US', - path: '/rewrite', - }, + redirect: 'manual', + } + ) + + expect(res.status).toBe(200) + + let html = await res.text() + let $ = cheerio.load(html) + expect($('html').attr('lang')).toBe('en-US') + expect($('#router-locale').text()).toBe('en-US') + expect($('#router-pathname').text()).toBe('/another') + expect($('#router-as-path').text()).toBe('/rewrite-1') + + res = await fetchViaHTTP( + ctx.appPort, + `${ctx.basePath}/nl/rewrite-2`, + undefined, { - locale: 'en-US', - path: '/en-US/rewrite', - }, + redirect: 'manual', + } + ) + + expect(res.status).toBe(200) + + html = await res.text() + $ = cheerio.load(html) + expect($('html').attr('lang')).toBe('nl') + expect($('#router-locale').text()).toBe('nl') + expect($('#router-pathname').text()).toBe('/another') + expect($('#router-as-path').text()).toBe('/rewrite-2') + + res = await fetchViaHTTP( + ctx.appPort, + `${ctx.basePath}/fr/rewrite-3`, + undefined, { - locale: 'nl', - path: '/nl/rewrite', - }, - ] + redirect: 'manual', + } + ) - for (const check of checks) { - const res = await fetchViaHTTP( + expect(res.status).toBe(200) + + html = await res.text() + $ = cheerio.load(html) + expect($('html').attr('lang')).toBe('nl') + expect($('#router-locale').text()).toBe('nl') + expect($('#router-pathname').text()).toBe('/another') + expect($('#router-as-path').text()).toBe('/rewrite-3') + + for (const locale of locales) { + res = await fetchViaHTTP( ctx.appPort, - `${ctx.basePath}${check.path}`, + `${ctx.basePath}/${locale}/rewrite-4`, undefined, { redirect: 'manual', @@ -369,12 +429,27 @@ export function runTests(ctx) { expect(res.status).toBe(200) - const html = await res.text() - const $ = cheerio.load(html) - expect($('html').attr('lang')).toBe(check.locale) - expect($('#router-locale').text()).toBe(check.locale) + html = await res.text() + $ = cheerio.load(html) + expect($('html').attr('lang')).toBe(locale) + expect($('#router-locale').text()).toBe(locale) expect($('#router-pathname').text()).toBe('/another') - expect($('#router-as-path').text()).toBe('/rewrite') + expect($('#router-as-path').text()).toBe('/rewrite-4') + + res = await fetchViaHTTP( + ctx.appPort, + `${ctx.basePath}/${locale}/rewrite-5`, + undefined, + { + redirect: 'manual', + } + ) + + expect(res.status).toBe(200) + + const json = await res.json() + expect(json.url).toBe('/') + expect(json.external).toBe(true) } })