Skip to content

Commit

Permalink
fix: correctly handle base URL that contains a URL origin (fix #149)
Browse files Browse the repository at this point in the history
  • Loading branch information
brillout committed Sep 22, 2021
1 parent ae47bd2 commit 41fb77c
Show file tree
Hide file tree
Showing 5 changed files with 192 additions and 82 deletions.
133 changes: 91 additions & 42 deletions vite-plugin-ssr/node/baseUrlHandling.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,108 @@
import { assert, assertUsage, slice } from '../shared/utils'
import { addUrlOrigin, assert, assertUsage, handleUrlOrigin, slice } from '../shared/utils'

import { getSsrEnv } from './ssrEnv'

export { analyzeBaseUrl }
export { prependBaseUrl }
export { removeBaseUrl }
export { startsWithBaseUrl }
export { assertBaseUrl }

// Possible values:
// Possible Base URL values:
// `base: '/some-nested-path/'`
// `base: 'https://another-origin.example.org/'`
// `base: './'`

function prependBaseUrl(url: string): string {
let baseUrl = getNormalizedBaseUrl()
if (baseUrl === '/') return url
assert(baseUrl.endsWith('/'))
baseUrl = slice(baseUrl, 0, -1)
assert(!baseUrl.endsWith('/'))
assert(url.startsWith('/'))
return `${baseUrl}${url}`
// `base: 'http://another-origin.example.org/'`
// `base: './'` (WIP: not supported yet)
function assertBaseUrl(baseUrl: string, userErrorMessagePrefix?: string) {
if (!userErrorMessagePrefix) {
assert(baseUrl.startsWith('/') || baseUrl.startsWith('http'))
return
}
assertUsage(
baseUrl.startsWith('/') || baseUrl.startsWith('http') || baseUrl.startsWith('./'),
userErrorMessagePrefix + 'Wrong `base` value `' + baseUrl + '`; `base` should start with `/`, `./`, or `http`.'
)
assertUsage(
!baseUrl.startsWith('./'),
'Relative Base URLs are not supported yet (`baseUrl` that starts with `./`). Open a new GitHub ticket so we can discuss adding support for your use case.'
)
}

function startsWithBaseUrl(url: string): boolean {
const baseUrl = getNormalizedBaseUrl()
if (baseUrl === '/') return true
return url.startsWith(baseUrl)
}
function removeBaseUrl(url: string): string {
const baseUrl = getNormalizedBaseUrl()
if (baseUrl === '/') return url
assert(startsWithBaseUrl(url))
function analyzeBaseUrl(url_: string) {
// Unmutable
const urlPristine = url_
// Mutable
let url = url_

assert(url.startsWith('/') || url.startsWith('http'))
const baseUrl = getBaseUrl()
assert(baseUrl.startsWith('/') || baseUrl.startsWith('http'))

if (baseUrl === '/') {
return { urlWithoutBaseUrl: urlPristine, hasBaseUrl: true }
}

const { urlWithoutOrigin, urlOrigin } = handleUrlOrigin(url)
let urlOriginHasBeenRemoved = false
{
const baseUrlOrigin = handleUrlOrigin(baseUrl).urlOrigin
const baseUrlHasOrigin = baseUrlOrigin !== null
let urlHasOrigin = urlOrigin !== null
assertUsage(
!baseUrlHasOrigin || urlHasOrigin,
`You provided a \`baseUrl\` (\`${baseUrl}\`) that contains a URL origin (\`${baseUrlOrigin!}\`) but the \`pageContext.url\` (\`${url}\`) you provided in your server middleware (\`const renderPage = createPageRenderer(/*...*/); renderPage(pageContext);\`) does not contain a URL origin. Either remove the URL origin from your \`baseUrl\` or make sure to always provide the URL origin in \`pageContext.url\`.`
)
if (urlHasOrigin && !baseUrlHasOrigin) {
urlOriginHasBeenRemoved = true
url = urlWithoutOrigin
urlHasOrigin = false
}
assert(urlHasOrigin === baseUrlHasOrigin)
}

if (!url.startsWith(baseUrl)) {
return { urlWithoutBaseUrl: urlPristine, hasBaseUrl: false }
}
assert(url.startsWith('/') || url.startsWith('http'))
url = url.slice(baseUrl.length)
/* url can actually start with `httpsome-pathname`
assert(!url.startsWith('http'))
*/
/* `handleUrlOrigin('some-pathname-without-leading-slash')` fails
assert((handleUrlOrigin(url).urlOrigin===null))
*/
if (!url.startsWith('/')) url = '/' + url
return url
}
assert(url.startsWith('/'))

function getNormalizedBaseUrl(): string {
let { baseUrl } = getSsrEnv()
baseUrl = normalizeBaseUrl(baseUrl)
return baseUrl
if (urlOriginHasBeenRemoved) {
assert(urlOrigin !== null)
assert(urlOrigin.startsWith('http'))
assert(url.startsWith('/'))
url = addUrlOrigin(url, urlOrigin)
assert(url.startsWith('http'))
}

return { urlWithoutBaseUrl: url, hasBaseUrl: true }
}

function normalizeBaseUrl(baseUrl: string): string {
if (!baseUrl) baseUrl = '/'
if (!baseUrl.endsWith('/')) baseUrl = `${baseUrl}/`
if (!baseUrl.startsWith('/') && !baseUrl.startsWith('http') && !baseUrl.startsWith('./')) baseUrl = `/${baseUrl}`
assert(baseUrl.startsWith('/') || baseUrl.startsWith('http') || baseUrl.startsWith('./'))
assert(baseUrl.endsWith('/'))
return baseUrl
function prependBaseUrl(url: string): string {
let baseUrl = getBaseUrl()

// Probably safer to remove the origin; `prependBaseUrl()` is used when injecting static assets in HTML;
// origin is useless in static asset URLs, while the origin causes trouble upon `https`/`http` mismatch.
baseUrl = handleUrlOrigin(baseUrl).urlWithoutOrigin

if (baseUrl === '/') return url

if (baseUrl.endsWith('/')) {
baseUrl = slice(baseUrl, 0, -1)
}

// We can and should expect `baseUrl` to not contain `/` doublets. (We cannot expect url to not contain `/` doublets.)
assert(!baseUrl.endsWith('/'))
assert(url.startsWith('/'))
return `${baseUrl}${url}`
}

function assertBaseUrl(baseUrl: string, errorMessagePrefix = '') {
assertUsage(
baseUrl.startsWith('/') || baseUrl.startsWith('http') || baseUrl.startsWith('./'),
errorMessagePrefix + 'Wrong `base` value `' + baseUrl + '`; `base` should start with `/`, `./`, or `http`.'
)
function getBaseUrl(): string {
const { baseUrl } = getSsrEnv()
assertBaseUrl(baseUrl)
return baseUrl
}
51 changes: 26 additions & 25 deletions vite-plugin-ssr/node/renderPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,8 @@ import {
cast,
assertWarning,
hasProp,
isPageContextUrl,
removePageContextUrlSuffix,
handlePageContextRequestSuffix,
getUrlPathname,
getUrlFull,
isPlainObject,
isObject,
getUrlParsed,
Expand All @@ -24,9 +22,10 @@ import {
PromiseType,
compareString,
assertExports,
stringifyStringArray
stringifyStringArray,
handleUrlOrigin
} from '../shared/utils'
import { removeBaseUrl, startsWithBaseUrl } from './baseUrlHandling'
import { analyzeBaseUrl } from './baseUrlHandling'
import { getPageAssets, PageAssets } from './html/injectAssets'
import { loadPageView } from '../shared/loadPageView'
import { sortPageContext } from '../shared/sortPageContext'
Expand Down Expand Up @@ -815,18 +814,25 @@ function assertArguments(...args: unknown[]) {
)
assertUsage(
typeof pageContext.url === 'string',
'`renderPage(pageContext)`: `pageContext.url` should be a string but we got `typeof pageContext.url === "' +
'`renderPage(pageContext)`: `pageContext.url` should be a string but `typeof pageContext.url === "' +
typeof pageContext.url +
'"`.'
)
assertUsage(
pageContext.url.startsWith('/') || pageContext.url.startsWith('http'),
'`renderPage(pageContext)`: `pageContext.url` should start with `/` (e.g. `/product/42`) or `http` (e.g. `http://example.org/product/42`) but `pageContext.url === "' +
pageContext.url +
'"`.'
)
try {
removeOrigin(pageContext.url)
const { url } = pageContext
const urlWithOrigin = url.startsWith('http') ? url : 'http://fake-origin.example.org' + url
// `new URL()` conveniently throws if URL is not an URL
new URL(urlWithOrigin)
} catch (err) {
assertUsage(
false,
'`renderPage(pageContext)`: argument `pageContext.url` should be a URL but we got `url==="' +
pageContext.url +
'"`.'
'`renderPage(pageContext)`: `pageContext.url` should be a URL but `pageContext.url==="' + pageContext.url + '"`.'
)
}
const len = args.length
Expand Down Expand Up @@ -938,31 +944,26 @@ function handleError(err: unknown) {
console.error(errStr)
}

function removeOrigin(url: string): string {
const urlFull = getUrlFull(url)
return urlFull
}

type PageContextUrls = { urlNormalized: string; urlPathname: string; urlParsed: UrlParsed }

function analyzeUrl(url: string): {
urlNormalized: string
isPageContextRequest: boolean
hasBaseUrl: boolean
} {
const isPageContextRequest = isPageContextUrl(url)
if (isPageContextRequest) {
url = removePageContextUrlSuffix(url)
}
url = removeOrigin(url)
assert(url.startsWith('/'))
assert(url.startsWith('/') || url.startsWith('http'))

const hasBaseUrl = startsWithBaseUrl(url)
if (hasBaseUrl) {
url = removeBaseUrl(url)
}
const { urlWithoutPageContextRequestSuffix, isPageContextRequest } = handlePageContextRequestSuffix(url)
url = urlWithoutPageContextRequestSuffix

const { urlWithoutBaseUrl, hasBaseUrl } = analyzeBaseUrl(url)
url = urlWithoutBaseUrl

url = handleUrlOrigin(url).urlWithoutOrigin
assert(url.startsWith('/'))

const urlNormalized = url
assert(urlNormalized.startsWith('/'))
return { urlNormalized, isPageContextRequest, hasBaseUrl }
}

Expand Down
6 changes: 5 additions & 1 deletion vite-plugin-ssr/node/ssrEnv.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ViteDevServer } from 'vite'
import { assertBaseUrl } from './baseUrlHandling'

export { setSsrEnv }
export { getSsrEnv }
Expand All @@ -19,10 +20,13 @@ type SsrEnv =
}

function getSsrEnv(): SsrEnv {
return global.__vite_ssr_plugin
const ssrEnv = global.__vite_ssr_plugin
assertBaseUrl(ssrEnv.baseUrl)
return ssrEnv
}

function setSsrEnv(ssrEnv: SsrEnv) {
assertBaseUrl(ssrEnv.baseUrl)
global.__vite_ssr_plugin = ssrEnv
}

Expand Down
15 changes: 10 additions & 5 deletions vite-plugin-ssr/shared/utils/getFileUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ import { slice } from './slice'
const pageContextUrlSuffix = '.pageContext.json'

export { getFileUrl }
export { isPageContextUrl }
export { removePageContextUrlSuffix }
export { handlePageContextRequestSuffix }

/**
(`/`, `.html`) -> `/index.html`
Expand Down Expand Up @@ -43,12 +42,18 @@ function getFileUrl(
return `${pathnameModified}${fileExtension}${searchString}${hashString}`
}

function isPageContextUrl(url: string): boolean {
function handlePageContextRequestSuffix(url: string): {
urlWithoutPageContextRequestSuffix: string
isPageContextRequest: boolean
} {
const urlPathname = getUrlPathname(url)
return urlPathname.endsWith(pageContextUrlSuffix)
if (!urlPathname.endsWith(pageContextUrlSuffix)) {
return { urlWithoutPageContextRequestSuffix: url, isPageContextRequest: false }
}
return { urlWithoutPageContextRequestSuffix: removePageContextUrlSuffix(url), isPageContextRequest: true }
}

function removePageContextUrlSuffix(url: string): string {
assert(isPageContextUrl(url), { url })
let { origin, pathname, searchString, hashString } = getUrlParts(url)
assert(url === `${origin}${pathname}${searchString}${hashString}`, { url })
assert(pathname.endsWith(pageContextUrlSuffix), { url })
Expand Down
69 changes: 60 additions & 9 deletions vite-plugin-ssr/shared/utils/parseUrl.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { assert } from '.'
import { assert } from './assert'
import { slice } from './slice'

export { handleUrlOrigin }
export { addUrlOrigin }

export { getUrlFull }
export { getUrlPathname }
Expand All @@ -7,18 +11,37 @@ export { getUrlParts }
export { getUrlFullWithoutHash }
export type { UrlParsed }

function handleUrlOrigin(url: string): { urlWithoutOrigin: string; urlOrigin: null | string } {
assert(url.startsWith('/') || url.startsWith('http'))
if (url.startsWith('/')) {
return { urlWithoutOrigin: url, urlOrigin: null }
} else {
const urlOrigin = parseWithNewUrl(url).origin
assert(urlOrigin !== '', { url })
assert(urlOrigin.startsWith('http'), { url })
assert(url.startsWith(urlOrigin), { url })
const urlWithoutOrigin = url.slice(urlOrigin.length)
assert(`${urlOrigin}${urlWithoutOrigin}` === url, { url })
assert(urlWithoutOrigin.startsWith('/'), { url })
return { urlWithoutOrigin, urlOrigin }
}
}
function addUrlOrigin(url: string, urlOrigin: string): string {
assert(urlOrigin.startsWith('http'), { url, urlOrigin })
if (urlOrigin.endsWith('/')) {
urlOrigin = slice(urlOrigin, 0, -1)
}
assert(!urlOrigin.endsWith('/'), { url, urlOrigin })
assert(url.startsWith('/'), { url, urlOrigin })
return `${urlOrigin}${url}`
}

/**
Returns `${pathname}${search}${hash}`. (Basically removes the origin.)
*/
function getUrlFull(url?: string): string {
// TODO
url = retrieveUrl(url)
const { origin } = parseWithNewUrl(url)
assert(url.startsWith(origin), { url })
const urlFull = url.slice(origin.length)
assert(`${origin}${urlFull}` === url, { url })
assert(urlFull.startsWith('/'), { url })
return urlFull
return handleUrlOrigin(url).urlWithoutOrigin
}

/**
Expand Down Expand Up @@ -94,7 +117,35 @@ function parseWithNewUrl(url: string) {
return { origin, pathname }
} catch (err) {
assert(url.startsWith('/'), { url })
const { pathname } = new URL('https://fake-origin.example.org' + url)
const { pathname } = new URL('http://fake-origin.example.org' + url)
return { origin: '', pathname }
}
}

/* Tempting to also apply `cleanUrl()` on `pageContext.urlNormalized` but AFAICT no one needs this; `pageContext.urlParsed` is enough.
*
function cleanUrl(url: string): string {
return getUrlFromParsed(getUrlParsed(url))
}
function getUrlFromParsed(urlParsed: UrlParsed): string {
const { origin, pathname, search, hash } = urlParsed
const searchParams = new URLSearchParams('')
assert(Array.from(searchParams.keys()).length === 0)
Object.entries(search || {}).forEach(([key, val]) => {
searchParams.set(key, val)
})
const searchString = searchParams.toString()
assert(hash === null || !hash.startsWith('#'))
const hashString = hash === null ? '' : '#' + hash
assert(origin === '' || origin.startsWith('http'))
assert(pathname.startsWith('/'))
assert(searchString === '' || searchString.startsWith('?'))
assert(hashString === '' || hashString.startsWith('#'))
return `${origin}${pathname}${searchString}${hashString}`
}
*
*/

0 comments on commit 41fb77c

Please sign in to comment.