Skip to content

Commit

Permalink
Replace node.js url module with WHATWG URL (vercel#14827)
Browse files Browse the repository at this point in the history
Replace `url.parse` and `url.resolve` logic with whatwg `URL`, Bring in a customized `format` function to handle the node url objects that can be passed to router methods. This eliminates the need for `url` (and thus `native-url`) in core. Looks like it shaves off about 2.5Kb, according to the `size-limits` integration tests.
  • Loading branch information
Janpot committed Jul 13, 2020
1 parent d2699be commit 3369d67
Show file tree
Hide file tree
Showing 10 changed files with 224 additions and 91 deletions.
42 changes: 13 additions & 29 deletions packages/next/client/link.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,23 @@
declare const __NEXT_DATA__: any

import React, { Children } from 'react'
import { parse, resolve, UrlObject } from 'url'
import { UrlObject } from 'url'
import { PrefetchOptions, NextRouter } from '../next-server/lib/router/router'
import {
execOnce,
formatWithValidation,
getLocationOrigin,
} from '../next-server/lib/utils'
import { execOnce, getLocationOrigin } from '../next-server/lib/utils'
import { useRouter } from './router'
import { addBasePath } from '../next-server/lib/router/router'
import { normalizeTrailingSlash } from './normalize-trailing-slash'

function isLocal(href: string): boolean {
const url = parse(href, false, true)
const origin = parse(getLocationOrigin(), false, true)

return (
!url.host || (url.protocol === origin.protocol && url.host === origin.host)
)
import { addBasePath, resolveHref } from '../next-server/lib/router/router'

/**
* Detects whether a given url is from the same origin as the current page (browser only).
*/
function isLocal(url: string): boolean {
const locationOrigin = getLocationOrigin()
const resolved = new URL(url, locationOrigin)
return resolved.origin === locationOrigin
}

type Url = string | UrlObject

function formatUrl(url: Url): string {
return (
url &&
formatWithValidation(
normalizeTrailingSlash(typeof url === 'object' ? url : parse(url))
)
)
}

export type LinkProps = {
href: Url
as?: Url
Expand Down Expand Up @@ -182,12 +168,10 @@ function Link(props: React.PropsWithChildren<LinkProps>) {
const router = useRouter()

const { href, as } = React.useMemo(() => {
const resolvedHref = resolve(router.pathname, formatUrl(props.href))
const resolvedHref = resolveHref(router.pathname, props.href)
return {
href: resolvedHref,
as: props.as
? resolve(router.pathname, formatUrl(props.as))
: resolvedHref,
as: props.as ? resolveHref(router.pathname, props.as) : resolvedHref,
}
}, [router.pathname, props.href, props.as])

Expand Down
16 changes: 15 additions & 1 deletion packages/next/client/normalize-trailing-slash.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { UrlObject } from 'url'

/**
* Removes the trailing slash of a path if there is one. Preserves the root path `/`.
*/
export function removePathTrailingSlash(path: string): string {
return path.endsWith('/') && path !== '/' ? path.slice(0, -1) : path
}

/**
* Normalizes the trailing slash of a path according to the `trailingSlash` option
* in `next.config.js`.
*/
const normalizePathTrailingSlash = process.env.__NEXT_TRAILING_SLASH
? (path: string): string => {
if (/\.[^/]+\/?$/.test(path)) {
Expand All @@ -16,10 +23,17 @@ const normalizePathTrailingSlash = process.env.__NEXT_TRAILING_SLASH
}
: removePathTrailingSlash

export function normalizeTrailingSlash(url: UrlObject): UrlObject {
/**
* Normalizes the trailing slash of the path of a parsed url. Non-destructive.
*/
export function normalizeTrailingSlash(url: URL): URL
export function normalizeTrailingSlash(url: UrlObject): UrlObject
export function normalizeTrailingSlash(url: UrlObject | URL): UrlObject | URL {
const normalizedPath =
url.pathname && normalizePathTrailingSlash(url.pathname)
return url.pathname === normalizedPath
? url
: url instanceof URL
? Object.assign(new URL(url.href), { pathname: normalizedPath })
: Object.assign({}, url, { pathname: normalizedPath })
}
12 changes: 8 additions & 4 deletions packages/next/client/page-loader.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { parse } from 'url'
import mitt from '../next-server/lib/mitt'
import { isDynamicRoute } from './../next-server/lib/router/utils/is-dynamic'
import { getRouteMatcher } from './../next-server/lib/router/utils/route-matcher'
import { getRouteRegex } from './../next-server/lib/router/utils/route-regex'
import { searchParamsToUrlQuery } from './../next-server/lib/router/utils/search-params-to-url-query'
import { parseRelativeUrl } from './../next-server/lib/router/utils/parse-relative-url'
import escapePathDelimiters from '../next-server/lib/router/utils/escape-path-delimiters'
import getAssetPathFromRoute from './../next-server/lib/router/utils/get-asset-path-from-route'

Expand Down Expand Up @@ -111,8 +112,11 @@ export default class PageLoader {
* @param {string} asPath the URL as shown in browser (virtual path); used for dynamic routes
*/
getDataHref(href, asPath, ssg) {
const { pathname: hrefPathname, query, search } = parse(href, true)
const { pathname: asPathname } = parse(asPath)
const { pathname: hrefPathname, searchParams, search } = parseRelativeUrl(
href
)
const query = searchParamsToUrlQuery(searchParams)
const { pathname: asPathname } = parseRelativeUrl(asPath)
const route = normalizeRoute(hrefPathname)

const getHrefForSlug = (/** @type string */ path) => {
Expand Down Expand Up @@ -177,7 +181,7 @@ export default class PageLoader {
* @param {string} asPath the URL as shown in browser (virtual path); used for dynamic routes
*/
prefetchData(href, asPath) {
const { pathname: hrefPathname } = parse(href, true)
const { pathname: hrefPathname } = parseRelativeUrl(href)
const route = normalizeRoute(hrefPathname)
return this.promisedSsgManifest.then(
(s, _dataHref) =>
Expand Down
94 changes: 54 additions & 40 deletions packages/next/next-server/lib/router/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// tslint:disable:no-console
import { ParsedUrlQuery } from 'querystring'
import { ComponentType } from 'react'
import { parse, UrlObject } from 'url'
import { UrlObject } from 'url'
import mitt, { MittEmitter } from '../mitt'
import {
AppContextType,
Expand All @@ -15,6 +15,8 @@ import {
import { isDynamicRoute } from './utils/is-dynamic'
import { getRouteMatcher } from './utils/route-matcher'
import { getRouteRegex } from './utils/route-regex'
import { searchParamsToUrlQuery } from './utils/search-params-to-url-query'
import { parseRelativeUrl } from './utils/parse-relative-url'
import {
normalizeTrailingSlash,
removePathTrailingSlash,
Expand All @@ -36,20 +38,43 @@ function prepareRoute(path: string) {

type Url = UrlObject | string

function formatUrl(url: Url): string {
return url
? formatWithValidation(
normalizeTrailingSlash(typeof url === 'object' ? url : parse(url))
)
: url
/**
* Resolves a given hyperlink with a certain router state (basePath not included).
* Preserves absolute urls.
*/
export function resolveHref(currentPath: string, href: Url): string {
// we use a dummy base url for relative urls
const base = new URL(currentPath, 'http://n')
const urlAsString =
typeof href === 'string' ? href : formatWithValidation(href)
const finalUrl = normalizeTrailingSlash(new URL(urlAsString, base))
// if the origin didn't change, it means we received a relative href
return finalUrl.origin === base.origin
? finalUrl.href.slice(finalUrl.origin.length)
: finalUrl.href
}

function prepareUrlAs(url: Url, as: Url) {
function prepareUrlAs(router: NextRouter, url: Url, as: Url) {
// If url and as provided as an object representation,
// we'll format them into the string version here.
return {
url: addBasePath(formatUrl(url)),
as: as ? addBasePath(formatUrl(as)) : as,
url: addBasePath(resolveHref(router.pathname, url)),
as: as ? addBasePath(resolveHref(router.pathname, as)) : as,
}
}

function tryParseRelativeUrl(
url: string
): null | ReturnType<typeof parseRelativeUrl> {
try {
return parseRelativeUrl(url)
} catch (err) {
if (process.env.NODE_ENV !== 'production') {
throw new Error(
`Invalid href passed to router: ${url} https://err.sh/vercel/next.js/invalid-href-passed`
)
}
return null
}
}

Expand Down Expand Up @@ -318,14 +343,12 @@ export default class Router implements BaseRouter {
return
}

const { url, as, options } = e.state
const { pathname } = parseRelativeUrl(url)

// Make sure we don't re-render on initial load,
// can be caused by navigating back from an external site
if (
e.state &&
this.isSsr &&
e.state.as === this.asPath &&
parse(e.state.url).pathname === this.pathname
) {
if (this.isSsr && as === this.asPath && pathname === this.pathname) {
return
}

Expand All @@ -335,7 +358,6 @@ export default class Router implements BaseRouter {
return
}

const { url, as, options } = e.state
if (process.env.NODE_ENV !== 'production') {
if (typeof url === 'undefined' || typeof as === 'undefined') {
console.warn(
Expand Down Expand Up @@ -389,7 +411,7 @@ export default class Router implements BaseRouter {
* @param options object you can define `shallow` and other options
*/
push(url: Url, as: Url = url, options = {}) {
;({ url, as } = prepareUrlAs(url, as))
;({ url, as } = prepareUrlAs(this, url, as))
return this.change('pushState', url, as, options)
}

Expand All @@ -400,7 +422,7 @@ export default class Router implements BaseRouter {
* @param options object you can define `shallow` and other options
*/
replace(url: Url, as: Url = url, options = {}) {
;({ url, as } = prepareUrlAs(url, as))
;({ url, as } = prepareUrlAs(this, url, as))
return this.change('replaceState', url, as, options)
}

Expand Down Expand Up @@ -447,7 +469,12 @@ export default class Router implements BaseRouter {
return resolve(true)
}

let { pathname, query, protocol } = parse(url, true)
const parsed = tryParseRelativeUrl(url)

if (!parsed) return

let { pathname, searchParams } = parsed
const query = searchParamsToUrlQuery(searchParams)

// url and as should always be prefixed with basePath by this
// point by either next/link or router.push/replace so strip the
Expand All @@ -456,15 +483,6 @@ export default class Router implements BaseRouter {
? removePathTrailingSlash(delBasePath(pathname))
: pathname

if (!pathname || protocol) {
if (process.env.NODE_ENV !== 'production') {
throw new Error(
`Invalid href passed to router: ${url} https://err.sh/vercel/next.js/invalid-href-passed`
)
}
return resolve(false)
}

const cleanedAs = delBasePath(as)

// If asked to change the current URL we should reload the current page
Expand All @@ -480,7 +498,7 @@ export default class Router implements BaseRouter {
const { shallow = false } = options

if (isDynamicRoute(route)) {
const { pathname: asPathname } = parse(cleanedAs)
const { pathname: asPathname } = parseRelativeUrl(cleanedAs)
const routeRegex = getRouteRegex(route)
const routeMatch = getRouteMatcher(routeRegex)(asPathname)
if (!routeMatch) {
Expand Down Expand Up @@ -805,16 +823,11 @@ export default class Router implements BaseRouter {
options: PrefetchOptions = {}
): Promise<void> {
return new Promise((resolve, reject) => {
const { pathname, protocol } = parse(url)
const parsed = tryParseRelativeUrl(url)

if (!pathname || protocol) {
if (process.env.NODE_ENV !== 'production') {
throw new Error(
`Invalid href passed to router: ${url} https://err.sh/vercel/next.js/invalid-href-passed`
)
}
return
}
if (!parsed) return

const { pathname } = parsed

// Prefetch is not supported in development mode because it would trigger on-demand-entries
if (process.env.NODE_ENV !== 'production') {
Expand Down Expand Up @@ -873,7 +886,8 @@ export default class Router implements BaseRouter {
}

_getStaticData = (dataHref: string): Promise<object> => {
const pathname = prepareRoute(parse(dataHref).pathname!)
let { pathname } = parseRelativeUrl(dataHref)
pathname = prepareRoute(pathname)

return process.env.NODE_ENV === 'production' && this.sdc[pathname]
? Promise.resolve(this.sdc[dataHref])
Expand Down
73 changes: 73 additions & 0 deletions packages/next/next-server/lib/router/utils/format-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Format function modified from nodejs
// Copyright Joyent, Inc. and other Node contributors.
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the
// "Software"), to deal in the Software without restriction, including
// without limitation the rights to use, copy, modify, merge, publish,
// distribute, sublicense, and/or sell copies of the Software, and to permit
// persons to whom the Software is furnished to do so, subject to the
// following conditions:
//
// The above copyright notice and this permission notice shall be included
// in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
// USE OR OTHER DEALINGS IN THE SOFTWARE.

import { UrlObject } from 'url'
import { encode as encodeQuerystring } from 'querystring'

const slashedProtocols = /https?|ftp|gopher|file/

export function formatUrl(urlObj: UrlObject) {
let { auth, hostname } = urlObj
let protocol = urlObj.protocol || ''
let pathname = urlObj.pathname || ''
let hash = urlObj.hash || ''
let query = urlObj.query || ''
let host: string | false = false

auth = auth ? encodeURIComponent(auth).replace(/%3A/i, ':') + '@' : ''

if (urlObj.host) {
host = auth + urlObj.host
} else if (hostname) {
host = auth + (~hostname.indexOf(':') ? `[${hostname}]` : hostname)
if (urlObj.port) {
host += ':' + urlObj.port
}
}

if (query && typeof query === 'object') {
// query = '' + new URLSearchParams(query);
query = encodeQuerystring(query)
}

let search = urlObj.search || (query && `?${query}`) || ''

if (protocol && protocol.substr(-1) !== ':') protocol += ':'

if (
urlObj.slashes ||
((!protocol || slashedProtocols.test(protocol)) && host !== false)
) {
host = '//' + (host || '')
if (pathname && pathname[0] !== '/') pathname = '/' + pathname
} else if (!host) {
host = ''
}

if (hash && hash[0] !== '#') hash = '#' + hash
if (search && search[0] !== '?') search = '?' + search

pathname = pathname.replace(/[?#]/g, encodeURIComponent)
search = search.replace('#', '%23')

return `${protocol}${host}${pathname}${search}${hash}`
}
Loading

0 comments on commit 3369d67

Please sign in to comment.