Skip to content
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

fix: support for redirects with 200 status. Fix crashing dev server w… #7034

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
26 changes: 19 additions & 7 deletions src/utils/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,14 @@ const formatEdgeFunctionError = (errorBuffer, acceptsHtml) => {
})
}

function isInternal(url?: string): boolean {
function isInternalFunctions(url?: string): boolean {
return url?.startsWith('/.netlify/') ?? false
}

function isInternal(url?: string): boolean {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've separated these 2 methods
All places of usage is using isInternalFunctions, and only 1 place which affects URL override use this method

return url?.startsWith('/') ?? false
}

function isFunction(functionsPort: boolean | number | undefined, url: string) {
return functionsPort && url.match(DEFAULT_FUNCTION_URL_EXPRESSION)
}
Expand Down Expand Up @@ -201,7 +205,7 @@ const handleAddonUrl = function ({ addonUrl, req, res }) {

// @ts-expect-error TS(7006) FIXME: Parameter 'match' implicitly has an 'any' type.
const isRedirect = function (match) {
return match.status && match.status >= 300 && match.status <= 400
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this is a mistake, 400 should not be included

return match.status && match.status >= 300 && match.status < 400
}

// @ts-expect-error TS(7006) FIXME: Parameter 'publicFolder' implicitly has an 'any' t... Remove this comment to see the full error message
Expand Down Expand Up @@ -415,8 +419,8 @@ const serveRedirect = async function ({
const ct = req.headers['content-type'] ? contentType.parse(req).type : ''
if (
req.method === 'POST' &&
!isInternal(req.url) &&
!isInternal(destURL) &&
!isInternalFunctions(req.url) &&
!isInternalFunctions(destURL) &&
(ct.endsWith('/x-www-form-urlencoded') || ct === 'multipart/form-data')
) {
return proxy.web(req, res, { target: options.functionsServer })
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

on line 433-437
https://github.com/netlify/cli/pull/7034/files#diff-7c2533ca30d8693b3cecb3406646bc13b86b3969b43a0aa0659bb52c7676caa9R437

(GH not allow to leave a comment there, bummer)

instead of checking if it's just a static file, or internalUrl which is now isInternalFunctions -> it uses isInternal which just checks if url starts with / meaning it's internal url.

And then basically it lands into the if-else check and does the status/url override

Expand All @@ -431,9 +435,13 @@ const serveRedirect = async function ({
match.force ||
(!staticFile && ((!options.framework && destStaticFile) || isInternal(destURL) || matchingFunction))
) {
// 3xx redirects parsed above, here are 2xx meaning just override the url of proxying page and use the status
// which comes from that url
req.url = destStaticFile ? destStaticFile + dest.search : destURL
const { status } = match
statusValue = status
if (match.force || status !== 200) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

override status code only if forcing or status is not 200, when 200 is just shadowing the page by the url, so it's status should be propagated (e.g. 404 because it's not there or idk 201 because server decided to return 201 by that url)

statusValue = status
}
console.log(`${NETLIFYDEVLOG} Rewrote URL to`, req.url)
}

Expand All @@ -450,9 +458,11 @@ const serveRedirect = async function ({

return proxy.web(req, res, { headers: functionHeaders, target: options.functionsServer })
}

if (isImageRequest(req)) {
return imageProxy(req, res)
}

const addonUrl = getAddonUrl(options.addonsUrls, req)
if (addonUrl) {
return handleAddonUrl({ req, res, addonUrl })
Expand Down Expand Up @@ -518,6 +528,8 @@ const initializeProxy = async function ({
})

proxy.on('error', (err, req, res, proxyUrl) => {
console.error('Got error from proxy', err)

// @ts-expect-error TS(2339) FIXME: Property 'proxyOptions' does not exist on type 'In... Remove this comment to see the full error message
const options = req.proxyOptions

Expand Down Expand Up @@ -632,7 +644,7 @@ const initializeProxy = async function ({
}
}

if (options.staticFile && isRedirect({ status: proxyRes.statusCode }) && proxyRes.headers.location) {
if (isRedirect({ status: proxyRes.statusCode }) && proxyRes.headers.location) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here is another change.
User has a redirect to the page, which also has internal redirect.
For instance, gatsby sites likes to do a redirection from /page to /page/ (slash in the end).

So we basically want to follow it:

  1. user hits /test/page
  2. it matches redirect /test/* -> /:splat
  3. cli proxies contents of the /:splat, which includes Location header. But status is 200 instead of 3xx (and original is 302 for instance, meaning localhost:8000 returns { 302, Location }, but location:8888 returns { 200, Location }
    Users see now this page:
image

This is browser built-in contents. Location header is there. Browser will never do the redirect since, if you want it to happen, either status should be 3xx, or another header Refresh should be there.

So, instead, here we are changing the flow for the netlify dev (which is typically runs SSGs in develop mode, not in the production mode) to this:

| Target app wants redirect on this route? Follow it

This change can be left intact and for instance, I can enable it only for some of the contexts (e.g. for dev server context). WDYT?

req.url = proxyRes.headers.location
return serveRedirect({
// We don't want to match functions at this point because any redirects
Expand Down Expand Up @@ -875,7 +887,7 @@ const onRequest = async (
hasFormSubmissionHandler &&
functionsServer &&
req.method === 'POST' &&
!isInternal(req.url) &&
!isInternalFunctions(req.url) &&
(ct.endsWith('/x-www-form-urlencoded') || ct === 'multipart/form-data')
) {
return proxy.web(req, res, { target: functionsServer })
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/__fixtures__/hugo-site/netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
command = "npm run serve"
framework = "#custom"
functions = "functions/"
port = 7000
port = 7001
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

7000 by default on OS X taken by airplay, failed my tests

publish = "out"
targetPort = 1313
1 change: 1 addition & 0 deletions tests/integration/__fixtures__/next-app/app/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export default function Home() {
return (
<main className={styles.main}>
<div className={styles.description}>
<p>Local site Dev Server</p>
<p>
Get started by editing&nbsp;
<code className={styles.code}>app/page.js</code>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function ExistsPage() {
return <p>Exists page</p>
}
10 changes: 10 additions & 0 deletions tests/integration/__fixtures__/next-app/netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ publish = "public"
[dev]
targetPort = 6123

[[redirects]]
from = "/local/*"
to = "/:splat"
status = 200

[[redirects]]
from = "/test/*"
to = "https://www.netlify.app/:splat"
status = 200

[[redirects]]
from = "*"
to = "https://www.netlify.app/:splat"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.netlify
29 changes: 29 additions & 0 deletions tests/integration/__fixtures__/site-with-redirect/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
const http = require('http')

const server = http.createServer((req, res) => {
const pathname = new URL(req.url, 'http://localhost').pathname

console.log(`Got ${pathname}`)

if (pathname === '/') {
res.write('Root page')
res.end()
} else if (pathname === '/test/exists') {
res.writeHead(302, undefined, { location: '/test/exists/' })
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

examples of the site which makes redirect, so then in netlify toml we want to land on this page, which then wants to jump to the next

res.end()
} else if (pathname === '/test/exists/') {
res.write('Test exists page')
res.end()
} else if (pathname === '/test/not-allowed') {
res.writeHead(405)
res.write('This not allowed')
res.end()
} else {
res.writeHead(404).write('Page is not found')
res.end()
}
})

server.listen(6124, () => {
console.log('Server is Running')
})
14 changes: 14 additions & 0 deletions tests/integration/__fixtures__/site-with-redirect/netlify.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[dev]
targetPort = 6124
command = "npm run dev"

[[redirects]]
from = "/local/*"
to = "/:splat"
status = 200

[[redirects]]
from = "/local-force/*"
to = "/:splat"
status = 402
force = true
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "site-with-redirect",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "node index.js"
},
"dependencies": {}
}
70 changes: 66 additions & 4 deletions tests/integration/commands/dev/redirects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { withSiteBuilder } from '../../utils/site-builder.js'
describe('redirects', () => {
setupFixtureTests('dev-server-with-functions', { devServer: true }, () => {
test<FixtureTestContext>('should send original query params to functions', async ({ devServer }) => {
const response = await fetch(`http://localhost:${devServer.port}/with-params?param2=world&other=1`, {})
const response = await fetch(`http://localhost:${devServer.port}/with-params?param2=world&other=1`)

expect(response.status).toBe(200)

Expand All @@ -21,7 +21,7 @@ describe('redirects', () => {
test<FixtureTestContext>('should send original query params to functions when using duplicate parameters', async ({
devServer,
}) => {
const response = await fetch(`http://localhost:${devServer.port}/api/echo?param=hello&param=world`, {})
const response = await fetch(`http://localhost:${devServer.port}/api/echo?param=hello&param=world`)

expect(response.status).toBe(200)

Expand All @@ -33,23 +33,85 @@ describe('redirects', () => {

setupFixtureTests('next-app', { devServer: { env: { NETLIFY_DEV_SERVER_CHECK_SSG_ENDPOINTS: 1 } } }, () => {
test<FixtureTestContext>('should prefer local files instead of redirect when not forced', async ({ devServer }) => {
const response = await fetch(`http://localhost:${devServer.port}/test.txt`, {})
const response = await fetch(`http://localhost:${devServer.port}/test.txt`)

expect(response.status).toBe(200)

const result = await response.text()
expect(result.trim()).toEqual('hello world')
expect(devServer?.output).not.toContain('Proxying to https://www.netlify.app')
})

test<FixtureTestContext>('should check for the dynamic page existence before doing redirect', async ({
devServer,
}) => {
const response = await fetch(`http://localhost:${devServer.port}/`, {})
const response = await fetch(`http://localhost:${devServer.port}/`)

expect(response.status).toBe(200)

const result = await response.text()
expect(result.toLowerCase()).toContain('local site dev server')
expect(result.toLowerCase()).not.toContain('netlify')
expect(devServer?.output).not.toContain('Proxying to https://www.netlify.app')
})

test<FixtureTestContext>('nested route redirect check for the page existence', async ({ devServer }) => {
let response = await fetch(`http://localhost:${devServer.port}/test/exists`)
expect(response.status).toBe(200)

let result = await response.text()
expect(result.toLowerCase()).toContain('exists page')
expect(devServer?.output).not.toContain('Proxying to https://www.netlify.app/exists')

response = await fetch(`http://localhost:${devServer.port}/test/about`)
expect(response.status).toBe(200)

result = await response.text()
expect(result.toLowerCase()).toContain('netlify')

expect(devServer?.output).toContain('Proxying to https://www.netlify.app/about')
})

test<FixtureTestContext>('should do local redirect', async ({ devServer }) => {
const response = await fetch(`http://localhost:${devServer.port}/local/test/exists`)

expect(response.status).toBe(200)

const result = await response.text()
expect(response.headers.get('location')).toBeNull()
expect(response.status).toBe(200)
expect(result.toLowerCase()).toContain('exists page')
expect(result.toLowerCase()).not.toContain('netlify')
expect(devServer?.output).not.toContain('Proxying to https://www.netlify.app/test')
})
})

setupFixtureTests('site-with-redirect', { devServer: true }, () => {
test<FixtureTestContext>('should do local redirect', async ({ devServer }) => {
const response = await fetch(`http://localhost:${devServer.port}/local/test/exists`)

expect(response.status).toBe(200)

const result = await response.text()
expect(response.url).toEqual(`http://localhost:${devServer.port}/local/test/exists`)
expect(response.status).toBe(200)
expect(result.toLowerCase()).toContain('exists page')
expect(result.toLowerCase()).not.toContain('netlify')
expect(devServer?.output).not.toContain('Proxying to https://www.netlify.app')
})

test<FixtureTestContext>('should pass proper status code of the redirected page', async ({ devServer }) => {
let response = await fetch(`http://localhost:${devServer.port}/local/test/not-allowed`)

expect(response.status).toBe(405)
const result = await response.text()
expect(result.toLowerCase()).toContain('this not allowed')

response = await fetch(`http://localhost:${devServer.port}/local/test/not-found`)
expect(response.status).toBe(404)

response = await fetch(`http://localhost:${devServer.port}/local-force/test/exists`)
expect(response.status).toBe(402)
})
})

Expand Down
Loading