|
| 1 | +import type { NextAdapterRouting } from "../types/adapter.js"; |
| 2 | + |
| 3 | +/** |
| 4 | + * A route of `NextAdapterRouting`, with the fields Next emits on top of what the resolver reads. |
| 5 | + */ |
| 6 | +type AdapterRoute = NextAdapterRouting["beforeMiddleware"][number] & { |
| 7 | + source?: string; |
| 8 | + priority?: boolean; |
| 9 | +}; |
| 10 | + |
| 11 | +type CustomRouteGroup = "beforeMiddleware" | "beforeFiles" | "afterFiles" | "fallback"; |
| 12 | + |
| 13 | +const CUSTOM_ROUTE_GROUPS: CustomRouteGroup[] = ["beforeMiddleware", "beforeFiles", "afterFiles", "fallback"]; |
| 14 | + |
| 15 | +/** The locale capture Next prefixes localized dynamic routes with. */ |
| 16 | +const LOCALE_CAPTURE = "(?<nextLocale>[^/]{1,})"; |
| 17 | + |
| 18 | +export type NormalizeRoutingOptions = { |
| 19 | + /** The configured locales, empty when `i18n` is not configured. */ |
| 20 | + locales: string[]; |
| 21 | + /** The pathnames of the pages router API routes. */ |
| 22 | + apiPathnames: Set<string>; |
| 23 | +}; |
| 24 | + |
| 25 | +function isExternalDestination(destination: string): boolean { |
| 26 | + return destination.startsWith("http://") || destination.startsWith("https://"); |
| 27 | +} |
| 28 | + |
| 29 | +function splitDestination(destination: string): [pathname: string, search: string] { |
| 30 | + const separator = destination.indexOf("?"); |
| 31 | + return separator === -1 |
| 32 | + ? [destination, ""] |
| 33 | + : [destination.slice(0, separator), destination.slice(separator)]; |
| 34 | +} |
| 35 | + |
| 36 | +function isRedirect(route: AdapterRoute): boolean { |
| 37 | + return ( |
| 38 | + route.status !== undefined && |
| 39 | + route.status >= 300 && |
| 40 | + route.status < 400 && |
| 41 | + Object.keys(route.headers ?? {}).some((key) => key.toLowerCase() === "location") |
| 42 | + ); |
| 43 | +} |
| 44 | + |
| 45 | +/** |
| 46 | + * Whether the route is one Next already emitted for a locale. |
| 47 | + * |
| 48 | + * Next prefixes the source of every route it localizes, either with the locale group |
| 49 | + * (`/:nextInternalLocale(en|nl)/...`) or with a concrete locale for the default locale variant. |
| 50 | + * A route declared with `locale: false` keeps its source as authored. |
| 51 | + */ |
| 52 | +function isLocalized(route: AdapterRoute, locales: string[]): boolean { |
| 53 | + const source = route.source ?? ""; |
| 54 | + return ( |
| 55 | + source.startsWith("/:nextInternalLocale") || |
| 56 | + locales.some((locale) => source === `/${locale}` || source.startsWith(`/${locale}/`)) |
| 57 | + ); |
| 58 | +} |
| 59 | + |
| 60 | +/** |
| 61 | + * Drops the locale a destination targeting a pages router API route carries. |
| 62 | + * |
| 63 | + * Next localizes every destination when `i18n` is configured, but never emits a localized output |
| 64 | + * for an API route - and the resolver never localizes an `/api/` request either. The locale would |
| 65 | + * only make the destination unresolvable, so it is stripped. |
| 66 | + */ |
| 67 | +function delocalizeApiDestination(destination: string, apiPathnames: Set<string>): string { |
| 68 | + const [pathname, search] = splitDestination(destination); |
| 69 | + // The locale of a destination is a capture reference - `/$1/api/query` or `/$nextLocale/api/foo`. |
| 70 | + const localePrefix = pathname.match(/^\/\$\w+(?<rest>\/.*)$/); |
| 71 | + const rest = localePrefix?.groups?.["rest"]; |
| 72 | + return rest !== undefined && apiPathnames.has(rest) ? `${rest}${search}` : destination; |
| 73 | +} |
| 74 | + |
| 75 | +/** |
| 76 | + * Prefixes an internal destination with a locale. |
| 77 | + * |
| 78 | + * API routes are the exception - see `delocalizeApiDestination`. |
| 79 | + */ |
| 80 | +function localizeDestination(destination: string, locale: string, apiPathnames: Set<string>): string { |
| 81 | + if (isExternalDestination(destination)) { |
| 82 | + return destination; |
| 83 | + } |
| 84 | + const [pathname, search] = splitDestination(destination); |
| 85 | + if (apiPathnames.has(pathname)) { |
| 86 | + return destination; |
| 87 | + } |
| 88 | + // `/` is the pathname of the locale root itself, i.e. `/en` and not `/en/`. |
| 89 | + return `/${locale}${pathname === "/" ? "" : pathname}${search}`; |
| 90 | +} |
| 91 | + |
| 92 | +/** |
| 93 | + * Creates the variant of a route matching the pathname of a locale. |
| 94 | + * |
| 95 | + * The resolver prefixes the pathname of the request with the detected locale before matching any |
| 96 | + * route, so a route Next did not localize - i.e. one declared with `locale: false` - would never |
| 97 | + * match. The variants restore the routes for every locale the request may have been resolved to. |
| 98 | + */ |
| 99 | +function localizeRoute(route: AdapterRoute, locale: string, apiPathnames: Set<string>): AdapterRoute { |
| 100 | + return { |
| 101 | + ...route, |
| 102 | + source: route.source === undefined ? undefined : `/${locale}${route.source}`, |
| 103 | + sourceRegex: route.sourceRegex.replace(/^\^/, `^\\/${locale}`), |
| 104 | + destination: |
| 105 | + route.destination === undefined |
| 106 | + ? undefined |
| 107 | + : localizeDestination(route.destination, locale, apiPathnames), |
| 108 | + }; |
| 109 | +} |
| 110 | + |
| 111 | +/** |
| 112 | + * Turns a redirect into a route the resolver stops at. |
| 113 | + * |
| 114 | + * The resolver only stops processing a group when the matched route has a destination, so a |
| 115 | + * redirect - which carries its target in a `location` header - lets every route after it match and |
| 116 | + * override that header. Next stops at the first matching redirect, and so must we: the destination |
| 117 | + * makes the resolver return the redirect right away. |
| 118 | + */ |
| 119 | +function withRedirectDestination(route: AdapterRoute): AdapterRoute { |
| 120 | + const location = Object.entries(route.headers ?? {}).find(([key]) => key.toLowerCase() === "location")?.[1]; |
| 121 | + return location === undefined ? route : { ...route, destination: location }; |
| 122 | +} |
| 123 | + |
| 124 | +/** |
| 125 | + * The name of the capture group spanning the whole condition value, if there is one. |
| 126 | + * |
| 127 | + * `(?<destination>\w+)` captures the value as a whole, `foo-(?<id>\d+)` only a part of it. |
| 128 | + */ |
| 129 | +function wholeValueCaptureName(value: string): string | undefined { |
| 130 | + const match = value.match(/^\(\?<(?<name>\w+)>(?<body>.*)\)$/s); |
| 131 | + const body = match?.groups?.["body"]; |
| 132 | + if (body === undefined) { |
| 133 | + return undefined; |
| 134 | + } |
| 135 | + // The group only spans the whole value when its opening parenthesis is the one closing at the end. |
| 136 | + let depth = 0; |
| 137 | + for (let index = 0; index < body.length; index++) { |
| 138 | + const character = body[index]; |
| 139 | + if (character === "\\") { |
| 140 | + index++; |
| 141 | + } else if (character === "(") { |
| 142 | + depth++; |
| 143 | + } else if (character === ")" && depth-- === 0) { |
| 144 | + return undefined; |
| 145 | + } |
| 146 | + } |
| 147 | + return depth === 0 ? match?.groups?.["name"] : undefined; |
| 148 | +} |
| 149 | + |
| 150 | +/** |
| 151 | + * Renames the capture references of the `has` conditions of a route to the names the resolver binds. |
| 152 | + * |
| 153 | + * Next names them after the capture group of the condition value - `$destination` for a |
| 154 | + * `(?<destination>\w+)` query condition - while the resolver binds the value it matched to the key |
| 155 | + * of the condition instead. Only a group spanning the whole value can be renamed: it is the one |
| 156 | + * case where both are the same string. |
| 157 | + */ |
| 158 | +function alignHasCaptures(route: AdapterRoute): AdapterRoute { |
| 159 | + const renames = (route.has ?? []).flatMap((condition) => { |
| 160 | + // A `host` condition matches on the hostname and binds no capture. |
| 161 | + if (condition.type === "host" || condition.value === undefined) { |
| 162 | + return []; |
| 163 | + } |
| 164 | + const name = wholeValueCaptureName(condition.value); |
| 165 | + // The resolver strips everything but the letters of the key it binds the value to. |
| 166 | + const boundName = condition.key.replace(/[^a-zA-Z]/g, ""); |
| 167 | + return name === undefined || name === boundName ? [] : [[name, boundName] as const]; |
| 168 | + }); |
| 169 | + if (renames.length === 0) { |
| 170 | + return route; |
| 171 | + } |
| 172 | + const rename = (value: string) => |
| 173 | + renames.reduce((current, [name, boundName]) => current.replaceAll(`$${name}`, `$${boundName}`), value); |
| 174 | + return { |
| 175 | + ...route, |
| 176 | + destination: route.destination === undefined ? undefined : rename(route.destination), |
| 177 | + headers: |
| 178 | + route.headers === undefined |
| 179 | + ? undefined |
| 180 | + : Object.fromEntries(Object.entries(route.headers).map(([key, value]) => [key, rename(value)])), |
| 181 | + }; |
| 182 | +} |
| 183 | + |
| 184 | +/** |
| 185 | + * Drops the locale Next prefixed a pages router API dynamic route with. |
| 186 | + * |
| 187 | + * The resolver leaves `/api/` requests unlocalized, so the locale capture would keep the route from |
| 188 | + * ever matching one. |
| 189 | + */ |
| 190 | +function delocalizeApiDynamicRoute(route: AdapterRoute, apiPathnames: Set<string>): AdapterRoute { |
| 191 | + if (!route.source || !apiPathnames.has(route.source) || !route.sourceRegex.includes(LOCALE_CAPTURE)) { |
| 192 | + return route; |
| 193 | + } |
| 194 | + return { |
| 195 | + ...route, |
| 196 | + sourceRegex: route.sourceRegex.replace(LOCALE_CAPTURE, ""), |
| 197 | + destination: route.destination?.replace("/$nextLocale", ""), |
| 198 | + }; |
| 199 | +} |
| 200 | + |
| 201 | +/** |
| 202 | + * Reconciles the routing Next emits with the pathnames the resolver actually matches. |
| 203 | + * |
| 204 | + * Next assumes a router that localizes every request and stops at the first matching redirect, |
| 205 | + * neither of which the resolver does. This rewrites the routes so that both agree. |
| 206 | + */ |
| 207 | +export function normalizeRouting( |
| 208 | + routing: NextAdapterRouting, |
| 209 | + { locales, apiPathnames }: NormalizeRoutingOptions |
| 210 | +): NextAdapterRouting { |
| 211 | + const normalized: NextAdapterRouting = { |
| 212 | + ...routing, |
| 213 | + dynamicRoutes: routing.dynamicRoutes.map((route) => |
| 214 | + delocalizeApiDynamicRoute(route as AdapterRoute, apiPathnames) |
| 215 | + ), |
| 216 | + }; |
| 217 | + |
| 218 | + for (const group of CUSTOM_ROUTE_GROUPS) { |
| 219 | + normalized[group] = (routing[group] as AdapterRoute[]).flatMap((originalRoute) => { |
| 220 | + const route = alignHasCaptures(originalRoute); |
| 221 | + const withApiDestination: AdapterRoute = |
| 222 | + route.destination === undefined |
| 223 | + ? route |
| 224 | + : { ...route, destination: delocalizeApiDestination(route.destination, apiPathnames) }; |
| 225 | + // A priority route is matched against the request as it came in - i.e. before the resolver |
| 226 | + // localizes it - so it needs no variant. |
| 227 | + const variants = |
| 228 | + locales.length === 0 || route.priority || isLocalized(route, locales) |
| 229 | + ? [] |
| 230 | + : locales.map((locale) => localizeRoute(withApiDestination, locale, apiPathnames)); |
| 231 | + return [...variants, withApiDestination].map((variant) => |
| 232 | + !variant.priority && isRedirect(variant) && variant.destination === undefined |
| 233 | + ? withRedirectDestination(variant) |
| 234 | + : variant |
| 235 | + ); |
| 236 | + }); |
| 237 | + } |
| 238 | + |
| 239 | + return normalized; |
| 240 | +} |
0 commit comments