diff --git a/src/cypress/fixtures/next.config.js-with-basePath b/src/cypress/fixtures/next.config.js-with-basePath new file mode 100644 index 0000000000..6ad9e1249d --- /dev/null +++ b/src/cypress/fixtures/next.config.js-with-basePath @@ -0,0 +1,4 @@ +module.exports = { + target: "experimental-serverless-trace", + basePath: "/foo", +}; diff --git a/src/cypress/fixtures/pages/getServerSideProps/[id].js b/src/cypress/fixtures/pages/getServerSideProps/[id].js index 3487e968de..39e1d862ac 100644 --- a/src/cypress/fixtures/pages/getServerSideProps/[id].js +++ b/src/cypress/fixtures/pages/getServerSideProps/[id].js @@ -11,7 +11,7 @@ const Show = ({ errorCode, show }) => { return ( <div> <p> - This page uses getInitialProps() to fetch the show with the ID provided in the URL: /shows/:id + This page uses getServerSideProps() to fetch the show with the ID provided in the URL: /shows/:id <br /> Refresh the page to see server-side rendering in action. <br /> diff --git a/src/cypress/fixtures/pages/getServerSideProps/static.js b/src/cypress/fixtures/pages/getServerSideProps/static.js index dfbce6da56..4c4faea824 100644 --- a/src/cypress/fixtures/pages/getServerSideProps/static.js +++ b/src/cypress/fixtures/pages/getServerSideProps/static.js @@ -3,7 +3,7 @@ import Link from 'next/link' const Show = ({ show }) => ( <div> <p> - This page uses getInitialProps() to fetch the show with the ID provided in the URL: /shows/:id + This page uses getServerSideProps() to fetch the show with the ID provided in the URL: /shows/:id <br /> Refresh the page to see server-side rendering in action. <br /> diff --git a/src/cypress/integration/basePath_spec.js b/src/cypress/integration/basePath_spec.js new file mode 100644 index 0000000000..00a3c014bf --- /dev/null +++ b/src/cypress/integration/basePath_spec.js @@ -0,0 +1,747 @@ +const project = 'basePath' + +before(() => { + // When changing the base URL within a spec file, Cypress runs the spec twice + // To avoid rebuilding and redeployment on the second run, we check if the + // project has already been deployed. + cy.task('isDeployed').then((isDeployed) => { + // Cancel setup, if already deployed + if (isDeployed) return + + // Clear project folder + cy.task('clearProject', { project }) + + // Copy NextJS files + cy.task('copyFixture', { + project, + from: 'pages', + to: 'pages', + }) + cy.task('copyFixture', { + project, + from: 'next.config.js-with-basePath', + to: 'next.config.js', + }) + + // Copy package.json file + cy.task('copyFixture', { + project, + from: 'package.json', + to: 'package.json', + }) + + // Copy Netlify settings + cy.task('copyFixture', { + project, + from: 'netlify.toml', + to: 'netlify.toml', + }) + cy.task('copyFixture', { + project, + from: '.netlify', + to: '.netlify', + }) + + // Build + cy.task('buildProject', { project }) + + // Deploy + cy.task('deployProject', { project }, { timeout: 480 * 1000 }) + }) + + // Set base URL + cy.task('getBaseUrl', { project }).then((url) => { + Cypress.config('baseUrl', url) + }) +}) + +after(() => { + // While the before hook runs twice (it's re-run when the base URL changes), + // the after hook only runs once. + cy.task('clearDeployment') +}) + +describe('getInitialProps', () => { + context('with static route', () => { + it('loads TV shows', () => { + cy.visit('/') + + cy.get('ul').first().children().should('have.length', 5) + }) + + it('loads TV shows when SSR-ing', () => { + cy.ssr('/') + + cy.get('ul').first().children().should('have.length', 5) + }) + + it('loads TV shows w basePath', () => { + cy.visit('/foo') + + cy.get('ul').first().children().should('have.length', 5) + }) + + it('loads TV shows when SSR-ing w basePath', () => { + cy.ssr('/foo') + + cy.get('ul').first().children().should('have.length', 5) + }) + }) + + context('with dynamic route', () => { + it('loads TV show', () => { + cy.visit('/foo/shows/24251') + + cy.get('h1').should('contain', 'Show #24251') + cy.get('p').should('contain', 'Animal Science') + }) + + it('loads TV show when SSR-ing', () => { + cy.ssr('/foo/shows/24251') + + cy.get('h1').should('contain', 'Show #24251') + cy.get('p').should('contain', 'Animal Science') + }) + }) + + context('with catch-all route', () => { + it('displays all URL parameters, including query string parameters', () => { + cy.visit('/foo/shows/94/this-is-all/being/captured/yay?search=dog&custom-param=cat') + + // path parameters + cy.get('p').should('contain', '[0]: 94') + cy.get('p').should('contain', '[1]: this-is-all') + cy.get('p').should('contain', '[2]: being') + cy.get('p').should('contain', '[3]: captured') + cy.get('p').should('contain', '[4]: yay') + + // query string parameters + cy.get('p').should('contain', '[search]: dog') + cy.get('p').should('contain', '[custom-param]: cat') + + cy.get('h1').should('contain', 'Show #94') + cy.get('p').should('contain', 'Defiance') + }) + + it('displays all URL parameters when SSR-ing, including query string parameters', () => { + cy.visit('/foo/shows/94/this-is-all/being/captured/yay?search=dog&custom-param=cat') + + // path parameters + cy.get('p').should('contain', '[0]: 94') + cy.get('p').should('contain', '[1]: this-is-all') + cy.get('p').should('contain', '[2]: being') + cy.get('p').should('contain', '[3]: captured') + cy.get('p').should('contain', '[4]: yay') + + // query string parameters + cy.get('p').should('contain', '[search]: dog') + cy.get('p').should('contain', '[custom-param]: cat') + + cy.get('h1').should('contain', 'Show #94') + cy.get('p').should('contain', 'Defiance') + }) + }) +}) + +describe('getServerSideProps', () => { + it('exposes function context on the req object', () => { + cy.visit('/foo/getServerSideProps/context') + + cy.get('pre') + .first() + .then((json) => { + const { + req: { + netlifyFunctionParams: { event, context }, + }, + } = JSON.parse(json.html()) + + expect(event).to.have.property('path', '/foo/getServerSideProps/context') + expect(event).to.have.property('httpMethod', 'GET') + expect(event).to.have.property('headers') + expect(event).to.have.property('multiValueHeaders') + expect(event).to.have.property('isBase64Encoded') + expect(context.done).to.be.undefined + expect(context.getRemainingTimeInMillis).to.be.undefined + expect(context).to.have.property('awsRequestId') + expect(context).to.have.property('callbackWaitsForEmptyEventLoop') + expect(context).to.have.property('clientContext') + }) + }) + + context('with static route', () => { + it('loads TV shows', () => { + cy.visit('/foo/getServerSideProps/static') + + cy.get('h1').should('contain', 'Show #42') + cy.get('p').should('contain', 'Sleepy Hollow') + }) + + it('loads TV shows when SSR-ing', () => { + cy.ssr('/foo/getServerSideProps/static') + + cy.get('h1').should('contain', 'Show #42') + cy.get('p').should('contain', 'Sleepy Hollow') + }) + + it('loads page props from data .json file when navigating to it', () => { + cy.visit('/foo') + cy.window().then((w) => (w.noReload = true)) + + // Navigate to page and test that no reload is performed + // See: https://glebbahmutov.com/blog/detect-page-reload/ + cy.contains('getServerSideProps/static').click() + cy.get('h1').should('contain', 'Show #42') + cy.get('p').should('contain', 'Sleepy Hollow') + cy.window().should('have.property', 'noReload', true) + }) + }) + + context('with dynamic route', () => { + it('loads TV show', () => { + cy.visit('/foo/getServerSideProps/1337') + + cy.get('h1').should('contain', 'Show #1337') + cy.get('p').should('contain', 'Whodunnit?') + }) + + it('loads TV show when SSR-ing', () => { + cy.ssr('/foo/getServerSideProps/1337') + + cy.get('h1').should('contain', 'Show #1337') + cy.get('p').should('contain', 'Whodunnit?') + }) + + it('loads page props from data .json file when navigating to it', () => { + cy.visit('/') + cy.window().then((w) => (w.noReload = true)) + + // Navigate to page and test that no reload is performed + // See: https://glebbahmutov.com/blog/detect-page-reload/ + cy.contains('getServerSideProps/1337').click() + + cy.get('h1').should('contain', 'Show #1337') + cy.get('p').should('contain', 'Whodunnit?') + + cy.contains('Go back home').click() + cy.contains('getServerSideProps/1338').click() + + cy.get('h1').should('contain', 'Show #1338') + cy.get('p').should('contain', 'The Whole Truth') + + cy.window().should('have.property', 'noReload', true) + }) + }) + + context('with catch-all route', () => { + it('does not match base path (without params)', () => { + cy.request({ + url: '/foo/getServerSideProps/catch/all', + failOnStatusCode: false, + }).then((response) => { + expect(response.status).to.eq(404) + cy.state('document').write(response.body) + }) + + cy.get('h2').should('contain', 'This page could not be found.') + }) + + it('loads TV show with one param', () => { + cy.visit('/foo/getServerSideProps/catch/all/1337') + + cy.get('h1').should('contain', 'Show #1337') + cy.get('p').should('contain', 'Whodunnit?') + }) + + it('loads TV show with multiple params', () => { + cy.visit('/foo/getServerSideProps/catch/all/1337/multiple/params') + + cy.get('h1').should('contain', 'Show #1337') + cy.get('p').should('contain', 'Whodunnit?') + }) + + it('loads page props from data .json file when navigating to it', () => { + cy.visit('/') + cy.window().then((w) => (w.noReload = true)) + + // Navigate to page and test that no reload is performed + // See: https://glebbahmutov.com/blog/detect-page-reload/ + cy.contains('getServerSideProps/catch/all/1337').click() + + cy.get('h1').should('contain', 'Show #1337') + cy.get('p').should('contain', 'Whodunnit?') + + cy.contains('Go back home').click() + cy.contains('getServerSideProps/catch/all/1338').click() + + cy.get('h1').should('contain', 'Show #1338') + cy.get('p').should('contain', 'The Whole Truth') + + cy.window().should('have.property', 'noReload', true) + }) + }) +}) + +describe('getStaticProps', () => { + context('with static route', () => { + it('loads TV show', () => { + cy.visit('/foo/getStaticProps/static') + + cy.get('h1').should('contain', 'Show #71') + cy.get('p').should('contain', 'Dancing with the Stars') + }) + + it('loads page props from data .json file when navigating to it', () => { + cy.visit('/foo') + cy.window().then((w) => (w.noReload = true)) + + // Navigate to page and test that no reload is performed + // See: https://glebbahmutov.com/blog/detect-page-reload/ + cy.contains('getStaticProps/static').click() + cy.get('h1').should('contain', 'Show #71') + cy.get('p').should('contain', 'Dancing with the Stars') + cy.window().should('have.property', 'noReload', true) + }) + + context('with revalidate', () => { + it('loads TV show', () => { + cy.visit('/foo/getStaticProps/with-revalidate') + + cy.get('h1').should('contain', 'Show #71') + cy.get('p').should('contain', 'Dancing with the Stars') + }) + + it('loads TV shows when SSR-ing', () => { + cy.ssr('/foo/getStaticProps/with-revalidate') + + cy.get('h1').should('contain', 'Show #71') + cy.get('p').should('contain', 'Dancing with the Stars') + }) + }) + }) + + context('with dynamic route', () => { + context('without fallback', () => { + it('loads shows 1 and 2', () => { + cy.visit('/foo/getStaticProps/1') + cy.get('h1').should('contain', 'Show #1') + cy.get('p').should('contain', 'Under the Dome') + + cy.visit('/foo/getStaticProps/2') + cy.get('h1').should('contain', 'Show #2') + cy.get('p').should('contain', 'Person of Interest') + }) + + it('loads page props from data .json file when navigating to it', () => { + cy.visit('/foo') + cy.window().then((w) => (w.noReload = true)) + + // Navigate to page and test that no reload is performed + // See: https://glebbahmutov.com/blog/detect-page-reload/ + cy.contains('getStaticProps/1').click() + + cy.get('h1').should('contain', 'Show #1') + cy.get('p').should('contain', 'Under the Dome') + + cy.contains('Go back home').click() + cy.contains('getStaticProps/2').click() + + cy.get('h1').should('contain', 'Show #2') + cy.get('p').should('contain', 'Person of Interest') + + cy.window().should('have.property', 'noReload', true) + }) + + it('returns 404 when trying to access non-defined path', () => { + cy.request({ + url: '/foo/getStaticProps/3', + failOnStatusCode: false, + }).then((response) => { + expect(response.status).to.eq(404) + cy.state('document').write(response.body) + }) + + cy.get('h2').should('contain', 'This page could not be found.') + }) + }) + + context('with fallback', () => { + it('loads pre-rendered TV shows 3 and 4', () => { + cy.visit('/foo/getStaticProps/withFallback/3') + cy.get('h1').should('contain', 'Show #3') + cy.get('p').should('contain', 'Bitten') + + cy.visit('/foo/getStaticProps/withFallback/4') + cy.get('h1').should('contain', 'Show #4') + cy.get('p').should('contain', 'Arrow') + }) + + it('loads non-pre-rendered TV show', () => { + cy.visit('/foo/getStaticProps/withFallback/75') + + cy.get('h1').should('contain', 'Show #75') + cy.get('p').should('contain', 'The Mindy Project') + }) + + it('loads non-pre-rendered TV shows when SSR-ing', () => { + cy.ssr('/foo/getStaticProps/withFallback/75') + + cy.get('h1').should('contain', 'Show #75') + cy.get('p').should('contain', 'The Mindy Project') + }) + + it('loads page props from data .json file when navigating to it', () => { + cy.visit('/foo') + cy.window().then((w) => (w.noReload = true)) + + // Navigate to page and test that no reload is performed + // See: https://glebbahmutov.com/blog/detect-page-reload/ + cy.contains('getStaticProps/withFallback/3').click() + + cy.get('h1').should('contain', 'Show #3') + cy.get('p').should('contain', 'Bitten') + + cy.contains('Go back home').click() + cy.contains('getStaticProps/withFallback/4').click() + + cy.get('h1').should('contain', 'Show #4') + cy.get('p').should('contain', 'Arrow') + + cy.contains('Go back home').click() + cy.contains('getStaticProps/withFallback/75').click() + + cy.get('h1').should('contain', 'Show #75') + cy.get('p').should('contain', 'The Mindy Project') + + cy.window().should('have.property', 'noReload', true) + }) + }) + + context('with revalidate', () => { + it('loads TV show', () => { + cy.visit('/foo/getStaticProps/withRevalidate/75') + + cy.get('h1').should('contain', 'Show #75') + cy.get('p').should('contain', 'The Mindy Project') + }) + + it('loads TV shows when SSR-ing', () => { + cy.ssr('/foo/getStaticProps/withRevalidate/75') + + cy.get('h1').should('contain', 'Show #75') + cy.get('p').should('contain', 'The Mindy Project') + }) + + it('loads page props from data .json file when navigating to it', () => { + cy.visit('/') + cy.window().then((w) => (w.noReload = true)) + + // Navigate to page and test that no reload is performed + // See: https://glebbahmutov.com/blog/detect-page-reload/ + cy.contains('getStaticProps/withRevalidate/3').click() + + cy.get('h1').should('contain', 'Show #3') + cy.get('p').should('contain', 'Bitten') + + cy.contains('Go back home').click() + cy.contains('getStaticProps/withRevalidate/4').click() + + cy.get('h1').should('contain', 'Show #4') + cy.get('p').should('contain', 'Arrow') + + cy.window().should('have.property', 'noReload', true) + }) + }) + }) + + context('with catch-all route', () => { + context('with fallback', () => { + it('loads pre-rendered shows 1 and 2', () => { + cy.visit('/foo/getStaticProps/withFallback/my/path/1') + cy.get('h1').should('contain', 'Show #1') + cy.get('p').should('contain', 'Under the Dome') + + cy.visit('/foo/getStaticProps/withFallback/my/path/2') + cy.get('h1').should('contain', 'Show #2') + cy.get('p').should('contain', 'Person of Interest') + }) + + it('loads non-pre-rendered TV show', () => { + cy.visit('/foo/getStaticProps/withFallback/undefined/catch/all/path/75') + + cy.get('h1').should('contain', 'Show #75') + cy.get('p').should('contain', 'The Mindy Project') + }) + + it('loads page props from data .json file when navigating to it', () => { + cy.visit('/') + cy.window().then((w) => (w.noReload = true)) + + // Navigate to page and test that no reload is performed + // See: https://glebbahmutov.com/blog/detect-page-reload/ + cy.contains('getStaticProps/withFallback/my/path/1').click() + + cy.get('h1').should('contain', 'Show #1') + cy.get('p').should('contain', 'Under the Dome') + + cy.contains('Go back home').click() + cy.contains('getStaticProps/withFallback/my/path/2').click() + + cy.get('h1').should('contain', 'Show #2') + cy.get('p').should('contain', 'Person of Interest') + + cy.contains('Go back home').click() + cy.contains('getStaticProps/withFallback/my/undefined/path/75').click() + + cy.get('h1').should('contain', 'Show #75') + cy.get('p').should('contain', 'The Mindy Project') + + cy.window().should('have.property', 'noReload', true) + }) + }) + }) +}) + +describe('API endpoint', () => { + context('with static route', () => { + it('returns hello world, with all response headers', () => { + cy.request('/foo/api/static').then((response) => { + expect(response.headers['content-type']).to.include('application/json') + expect(response.headers['my-custom-header']).to.include('header123') + + expect(response.body).to.have.property('message', 'hello world :)') + }) + }) + }) + + context('with dynamic route', () => { + it('returns TV show', () => { + cy.request('/foo/api/shows/305').then((response) => { + expect(response.headers['content-type']).to.include('application/json') + + expect(response.body).to.have.property('show') + expect(response.body.show).to.have.property('id', 305) + expect(response.body.show).to.have.property('name', 'Black Mirror') + }) + }) + }) + + context('with catch-all route', () => { + it('returns all URL paremeters, including query string parameters', () => { + cy.request('/foo/api/shows/590/this/path/is/captured?metric=dog&p2=cat').then((response) => { + expect(response.headers['content-type']).to.include('application/json') + + // Params + expect(response.body).to.have.property('params') + expect(response.body.params).to.deep.eq(['590', 'this', 'path', 'is', 'captured']) + + // Query string parameters + expect(response.body).to.have.property('queryStringParams') + expect(response.body.queryStringParams).to.deep.eq({ + metric: 'dog', + p2: 'cat', + }) + + // Show + expect(response.body).to.have.property('show') + expect(response.body.show).to.have.property('id', 590) + expect(response.body.show).to.have.property('name', 'Pokémon') + }) + }) + }) + + it('redirects with res.redirect', () => { + cy.visit('/foo/api/redirect?to=999') + + cy.url().should('include', '/shows/999') + cy.get('h1').should('contain', 'Show #999') + cy.get('p').should('contain', 'Flash Gordon') + }) + + it('exposes function context on the req object', () => { + cy.request('/foo/api/context').then((response) => { + const { + req: { + netlifyFunctionParams: { event, context }, + }, + } = response.body + + expect(event).to.have.property('path', '/foo/api/context') + expect(event).to.have.property('httpMethod', 'GET') + expect(event).to.have.property('headers') + expect(event).to.have.property('multiValueHeaders') + expect(event).to.have.property('isBase64Encoded') + expect(context.done).to.be.undefined + expect(context.getRemainingTimeInMillis).to.be.undefined + expect(context).to.have.property('awsRequestId') + expect(context).to.have.property('callbackWaitsForEmptyEventLoop') + expect(context).to.have.property('clientContext') + }) + }) +}) + +describe('Preview Mode', () => { + it('redirects to preview test page with dynamic route', () => { + cy.visit('/foo/api/enterPreview?id=999') + + cy.url().should('include', '/previewTest/999') + }) + + it('redirects to static preview test page', () => { + cy.visit('/foo/api/enterPreviewStatic') + + cy.url().should('include', '/previewTest/static') + }) + + it('sets cookies on client', () => { + Cypress.Cookies.debug(true) + cy.getCookie('__prerender_bypass').should('not.exist') + cy.getCookie('__next_preview_data').should('not.exist') + + cy.visit('/api/enterPreview?id=999') + + cy.getCookie('__prerender_bypass').should('not.be', null) + cy.getCookie('__next_preview_data').should('not.be', null) + }) + + it('sets cookies on client with static redirect', () => { + Cypress.Cookies.debug(true) + cy.getCookie('__prerender_bypass').should('not.exist') + cy.getCookie('__next_preview_data').should('not.exist') + + cy.visit('/api/enterPreviewStatic') + + cy.getCookie('__prerender_bypass').should('not.be', null) + cy.getCookie('__next_preview_data').should('not.be', null) + }) + + it('renders serverSideProps page in preview mode', () => { + cy.visit('/foo/api/enterPreview?id=999') + + if (Cypress.env('DEPLOY') === 'local') { + cy.makeCookiesWorkWithHttpAndReload() + } + + cy.get('h1').should('contain', 'Person #999') + cy.get('p').should('contain', 'Sebastian Lacause') + }) + + it('renders staticProps page in preview mode', () => { + // cypress local (aka netlify dev) doesn't support cookie-based redirects + if (Cypress.env('DEPLOY') !== 'local') { + cy.visit('/foo/api/enterPreviewStatic') + cy.get('h1').should('contain', 'Number: 3') + } + }) + + it('can move in and out of preview mode for SSRed page', () => { + cy.visit('/foo/api/enterPreview?id=999') + + if (Cypress.env('DEPLOY') === 'local') { + cy.makeCookiesWorkWithHttpAndReload() + } + + cy.get('h1').should('contain', 'Person #999') + cy.get('p').should('contain', 'Sebastian Lacause') + + cy.contains('Go back home').click() + + // Verify that we're still in preview mode + cy.contains('previewTest/222').click() + cy.get('h1').should('contain', 'Person #222') + cy.get('p').should('contain', 'Corey Lof') + + // Exit preview mode + cy.visit('/foo/api/exitPreview') + + // Verify that we're no longer in preview mode + cy.contains('previewTest/222').click() + cy.get('h1').should('contain', 'Show #222') + cy.get('p').should('contain', 'Happyland') + }) + + it('can move in and out of preview mode for static page', () => { + if (Cypress.env('DEPLOY') !== 'local') { + cy.visit('/foo/api/enterPreviewStatic') + cy.window().then((w) => (w.noReload = true)) + + cy.get('h1').should('contain', 'Number: 3') + + cy.contains('Go back home').click() + + // Verify that we're still in preview mode + cy.contains('previewTest/static').click() + cy.get('h1').should('contain', 'Number: 3') + + cy.window().should('have.property', 'noReload', true) + + // Exit preview mode + cy.visit('/foo/api/exitPreview') + + // TO-DO: test if this is the static html? + // Verify that we're no longer in preview mode + cy.contains('previewTest/static').click() + cy.get('h1').should('contain', 'Number: 4') + } + }) + + it('hits the prerendered html out of preview mode and netlify function in preview mode', () => { + if (Cypress.env('DEPLOY') !== 'local') { + cy.request('/foo/previewTest/static').then((response) => { + expect(response.headers['cache-control']).to.include('public') + }) + + cy.visit('/foo/api/enterPreviewStatic') + + cy.request('/foo/previewTest/static').then((response) => { + expect(response.headers['cache-control']).to.include('private') + }) + } + }) +}) + +describe('pre-rendered HTML pages', () => { + context('with static route', () => { + it('renders', () => { + cy.visit('/foo/static') + + cy.get('p').should('contain', 'It is a static page.') + }) + + it('renders when SSR-ing', () => { + cy.visit('/foo/static') + + cy.get('p').should('contain', 'It is a static page.') + }) + }) + + context('with dynamic route', () => { + it('renders', () => { + cy.visit('/foo/static/superdynamic') + + cy.get('p').should('contain', 'It is a static page.') + cy.get('p').should('contain', 'it has a dynamic URL parameter: /static/:id.') + }) + + it('renders when SSR-ing', () => { + cy.visit('/foo/static/superdynamic') + + cy.get('p').should('contain', 'It is a static page.') + cy.get('p').should('contain', 'it has a dynamic URL parameter: /static/:id.') + }) + }) +}) + +describe('404 page', () => { + it('renders', () => { + cy.request({ + url: '/foo/this-page-does-not-exist', + failOnStatusCode: false, + }).then((response) => { + expect(response.status).to.eq(404) + cy.state('document').write(response.body) + }) + + cy.get('h2').should('contain', 'This page could not be found.') + }) +}) diff --git a/src/lib/config.js b/src/lib/config.js index b828bf00b2..59ca693aae 100644 --- a/src/lib/config.js +++ b/src/lib/config.js @@ -1,6 +1,5 @@ const { join } = require('path') -const getNextDistDir = require('./helpers/getNextDistDir') const getNextSrcDirs = require('./helpers/getNextSrcDir') // This is where next-on-netlify will place all static files. diff --git a/src/lib/helpers/convertToBasePathRedirects.js b/src/lib/helpers/convertToBasePathRedirects.js new file mode 100644 index 0000000000..e154bb8f53 --- /dev/null +++ b/src/lib/helpers/convertToBasePathRedirects.js @@ -0,0 +1,73 @@ +// This helper converts the collection of redirects for all page types into +// the necessary redirects for a basePath-generated site +// NOTE: /withoutProps/redirects.js has some of its own contained basePath logic + +const getBasePathDefaultRedirects = ({ basePath, nextRedirects }) => { + if (basePath === '') return [] + // In a basePath-configured site, all _next assets are fetched with the prepended basePath + return [ + { + route: `${basePath}/_next/*`, + target: '/_next/:splat', + statusCode: '301', + force: true, + }, + ] +} + +const convertToBasePathRedirects = ({ basePath, nextRedirects }) => { + if (basePath === '') return nextRedirects + const basePathRedirects = getBasePathDefaultRedirects({ basePath, nextRedirects }) + nextRedirects.forEach((r) => { + if (r.route === '/') { + // On Vercel, a basePath configured site 404s on /, but we can ensure it redirects to /basePath + const indexRedirects = [ + { + route: '/', + target: basePath, + statusCode: '301', + force: true, + }, + { + route: basePath, + target: r.target, + }, + ] + basePathRedirects.push(...indexRedirects) + } else if (!r.route.includes('_next/') && r.target.includes('/.netlify/functions') && r.conditions) { + // If this is a preview mode redirect, we need different behavior than other function targets below + // because the conditions prevent us from doing a route -> basePath/route force + basePathRedirects.push({ + route: `${basePath}${r.route}`, + target: r.target, + force: true, + conditions: r.conditions, + }) + basePathRedirects.push({ + route: `${basePath}${r.route}`, + target: r.route, + }) + } else if (!r.route.includes('_next/') && r.target.includes('/.netlify/functions')) { + // This force redirect is necessary for non-preview mode function targets because the serverless lambdas + // try to strip basePath and redirect to the plain route per https://github.com/vercel/next.js/blob/5bff9eac084b69affe3560c4f4cfd96724aa5e49/packages/next/next-server/lib/router/router.ts#L974 + const functionRedirects = [ + { + route: r.route, + target: `${basePath}${r.route}`, + statusCode: '301', + force: true, + }, + { + route: `${basePath}${r.route}`, + target: r.target, + }, + ] + basePathRedirects.push(...functionRedirects) + } else { + basePathRedirects.push(r) + } + }) + return basePathRedirects +} + +module.exports = convertToBasePathRedirects diff --git a/src/lib/helpers/formatRedirectTarget.js b/src/lib/helpers/formatRedirectTarget.js new file mode 100644 index 0000000000..ed8d3c7c09 --- /dev/null +++ b/src/lib/helpers/formatRedirectTarget.js @@ -0,0 +1,9 @@ +// Returns formatted redirect target +const { DYNAMIC_PARAMETER_REGEX } = require('../constants/regex') + +const formatRedirectTarget = ({ basePath, target }) => + basePath !== '' && target.includes(basePath) + ? target.replace(DYNAMIC_PARAMETER_REGEX, '/:$1').replace('[', '').replace(']', '').replace('...', '') + : target + +module.exports = formatRedirectTarget diff --git a/src/lib/pages/withoutProps/redirects.js b/src/lib/pages/withoutProps/redirects.js index f48ba3ad58..b85eafb6b3 100644 --- a/src/lib/pages/withoutProps/redirects.js +++ b/src/lib/pages/withoutProps/redirects.js @@ -1,3 +1,4 @@ +const getNextConfig = require('../../../../helpers/getNextConfig') const addDefaultLocaleRedirect = require('../../helpers/addDefaultLocaleRedirect') const asyncForEach = require('../../helpers/asyncForEach') const isDynamicRoute = require('../../helpers/isDynamicRoute') @@ -20,12 +21,22 @@ const getPages = require('./pages') const getRedirects = async () => { const redirects = [] const pages = await getPages() + const { basePath } = await getNextConfig() await asyncForEach(pages, async ({ route, filePath }) => { const target = filePath.replace(/pages/, '') await addDefaultLocaleRedirect(redirects, route, target) + // For sites that use basePath, manually add necessary redirects here specific + // only to this page type (which excludes static route redirects by default) + if (basePath !== '') { + redirects.push({ + route: `${basePath}${route}`, + target: route, + }) + } + // Only create normal redirects for pages with dynamic routing if (!isDynamicRoute(route)) return diff --git a/src/lib/steps/setupRedirects.js b/src/lib/steps/setupRedirects.js index c86b48acf3..50ff238d09 100644 --- a/src/lib/steps/setupRedirects.js +++ b/src/lib/steps/setupRedirects.js @@ -2,7 +2,10 @@ const { join } = require('path') const { existsSync, readFileSync, writeFileSync } = require('fs-extra') +const getNextConfig = require('../../../helpers/getNextConfig') const { CUSTOM_REDIRECTS_PATH, NEXT_IMAGE_FUNCTION_NAME } = require('../config') +const convertToBasePathRedirects = require('../helpers/convertToBasePathRedirects') +const formatRedirectTarget = require('../helpers/formatRedirectTarget') const getNetlifyRoutes = require('../helpers/getNetlifyRoutes') const getSortedRedirects = require('../helpers/getSortedRedirects') const isDynamicRoute = require('../helpers/isDynamicRoute') @@ -31,7 +34,7 @@ const setupRedirects = async (publishPath) => { const getSPRevalidateRedirects = require('../pages/getStaticPropsWithRevalidate/redirects') const getWithoutPropsRedirects = require('../pages/withoutProps/redirects') - const nextRedirects = [ + let nextRedirects = [ ...(await getApiRedirects()), ...(await getInitialPropsRedirects()), ...(await getServerSidePropsRedirects()), @@ -44,12 +47,17 @@ const setupRedirects = async (publishPath) => { // Add _redirect section heading redirects.push('# Next-on-Netlify Redirects') + const { basePath } = await getNextConfig() + if (basePath !== '') { + nextRedirects = convertToBasePathRedirects({ basePath, nextRedirects }) + } + const staticRedirects = nextRedirects.filter(({ route }) => !isDynamicRoute(removeFileExtension(route))) const dynamicRedirects = nextRedirects.filter(({ route }) => isDynamicRoute(removeFileExtension(route))) // Add necessary next/image redirects for our image function dynamicRedirects.push({ - route: '/_next/image* url=:url w=:width q=:quality', + route: `${basePath || ''}/_next/image* url=:url w=:width q=:quality`, target: `/nextimg/:url/:width/:quality`, statusCode: '301', force: true, @@ -68,7 +76,12 @@ const setupRedirects = async (publishPath) => { // require two Netlify routes in the _redirects file getNetlifyRoutes(nextRedirect.route).forEach((netlifyRoute) => { const { conditions = [], force = false, statusCode = '200', target } = nextRedirect - const redirectPieces = [netlifyRoute, target, `${statusCode}${force ? '!' : ''}`, conditions.join(' ')] + const redirectPieces = [ + netlifyRoute, + formatRedirectTarget({ basePath, target }), + `${statusCode}${force ? '!' : ''}`, + conditions.join(' '), + ] const redirect = redirectPieces.join(' ').trim() logItem(redirect) redirects.push(redirect) diff --git a/src/tests/__snapshots__/basePath.test.js.snap b/src/tests/__snapshots__/basePath.test.js.snap new file mode 100644 index 0000000000..ee4b58d4a6 --- /dev/null +++ b/src/tests/__snapshots__/basePath.test.js.snap @@ -0,0 +1,86 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Routing creates Netlify redirects 1`] = ` +"# Next-on-Netlify Redirects +/ /foo 301! +/_next/data/%BUILD_ID%/getServerSideProps/static.json /.netlify/functions/next_getServerSideProps_static 200 +/_next/data/%BUILD_ID%/getStaticProps/1.json /.netlify/functions/next_getStaticProps_id 200! Cookie=__prerender_bypass,__next_preview_data +/_next/data/%BUILD_ID%/getStaticProps/2.json /.netlify/functions/next_getStaticProps_id 200! Cookie=__prerender_bypass,__next_preview_data +/_next/data/%BUILD_ID%/getStaticProps/static.json /.netlify/functions/next_getStaticProps_static 200! Cookie=__prerender_bypass,__next_preview_data +/_next/data/%BUILD_ID%/getStaticProps/with-revalidate.json /.netlify/functions/next_getStaticProps_withrevalidate 200 +/_next/data/%BUILD_ID%/getStaticProps/withFallback/3.json /.netlify/functions/next_getStaticProps_withFallback_id 200! Cookie=__prerender_bypass,__next_preview_data +/_next/data/%BUILD_ID%/getStaticProps/withFallback/4.json /.netlify/functions/next_getStaticProps_withFallback_id 200! Cookie=__prerender_bypass,__next_preview_data +/_next/data/%BUILD_ID%/getStaticProps/withFallback/my/path/1.json /.netlify/functions/next_getStaticProps_withFallback_slug 200! Cookie=__prerender_bypass,__next_preview_data +/_next/data/%BUILD_ID%/getStaticProps/withFallback/my/path/2.json /.netlify/functions/next_getStaticProps_withFallback_slug 200! Cookie=__prerender_bypass,__next_preview_data +/_next/data/%BUILD_ID%/getStaticProps/withFallbackBlocking/3.json /.netlify/functions/next_getStaticProps_withFallbackBlocking_id 200! Cookie=__prerender_bypass,__next_preview_data +/_next/data/%BUILD_ID%/getStaticProps/withFallbackBlocking/4.json /.netlify/functions/next_getStaticProps_withFallbackBlocking_id 200! Cookie=__prerender_bypass,__next_preview_data +/_next/data/%BUILD_ID%/getStaticProps/withRevalidate/1.json /.netlify/functions/next_getStaticProps_withRevalidate_id 200 +/_next/data/%BUILD_ID%/getStaticProps/withRevalidate/2.json /.netlify/functions/next_getStaticProps_withRevalidate_id 200 +/api/hello-background /foo/api/hello-background 301! +/api/static /foo/api/static 301! +/foo /.netlify/functions/next_index 200 +/foo/404 /404 200 +/foo/_next/* /_next/:splat 301! +/foo/api/hello-background /.netlify/functions/next_api_hello-background 200 +/foo/api/static /.netlify/functions/next_api_static 200 +/foo/getServerSideProps/static /.netlify/functions/next_getServerSideProps_static 200 +/foo/getStaticProps/1 /.netlify/functions/next_getStaticProps_id 200! Cookie=__prerender_bypass,__next_preview_data +/foo/getStaticProps/1 /getStaticProps/1 200 +/foo/getStaticProps/2 /.netlify/functions/next_getStaticProps_id 200! Cookie=__prerender_bypass,__next_preview_data +/foo/getStaticProps/2 /getStaticProps/2 200 +/foo/getStaticProps/static /.netlify/functions/next_getStaticProps_static 200! Cookie=__prerender_bypass,__next_preview_data +/foo/getStaticProps/static /getStaticProps/static 200 +/foo/getStaticProps/with-revalidate /.netlify/functions/next_getStaticProps_withrevalidate 200 +/foo/getStaticProps/withFallback/3 /.netlify/functions/next_getStaticProps_withFallback_id 200! Cookie=__prerender_bypass,__next_preview_data +/foo/getStaticProps/withFallback/3 /getStaticProps/withFallback/3 200 +/foo/getStaticProps/withFallback/4 /.netlify/functions/next_getStaticProps_withFallback_id 200! Cookie=__prerender_bypass,__next_preview_data +/foo/getStaticProps/withFallback/4 /getStaticProps/withFallback/4 200 +/foo/getStaticProps/withFallback/my/path/1 /.netlify/functions/next_getStaticProps_withFallback_slug 200! Cookie=__prerender_bypass,__next_preview_data +/foo/getStaticProps/withFallback/my/path/1 /getStaticProps/withFallback/my/path/1 200 +/foo/getStaticProps/withFallback/my/path/2 /.netlify/functions/next_getStaticProps_withFallback_slug 200! Cookie=__prerender_bypass,__next_preview_data +/foo/getStaticProps/withFallback/my/path/2 /getStaticProps/withFallback/my/path/2 200 +/foo/getStaticProps/withFallbackBlocking/3 /.netlify/functions/next_getStaticProps_withFallbackBlocking_id 200! Cookie=__prerender_bypass,__next_preview_data +/foo/getStaticProps/withFallbackBlocking/3 /getStaticProps/withFallbackBlocking/3 200 +/foo/getStaticProps/withFallbackBlocking/4 /.netlify/functions/next_getStaticProps_withFallbackBlocking_id 200! Cookie=__prerender_bypass,__next_preview_data +/foo/getStaticProps/withFallbackBlocking/4 /getStaticProps/withFallbackBlocking/4 200 +/foo/getStaticProps/withRevalidate/1 /.netlify/functions/next_getStaticProps_withRevalidate_id 200 +/foo/getStaticProps/withRevalidate/2 /.netlify/functions/next_getStaticProps_withRevalidate_id 200 +/foo/static /static 200 +/getServerSideProps/static /foo/getServerSideProps/static 301! +/getStaticProps/with-revalidate /foo/getStaticProps/with-revalidate 301! +/getStaticProps/withRevalidate/1 /foo/getStaticProps/withRevalidate/1 301! +/getStaticProps/withRevalidate/2 /foo/getStaticProps/withRevalidate/2 301! +/_next/data/%BUILD_ID%/getServerSideProps/all.json /.netlify/functions/next_getServerSideProps_all_slug 200 +/_next/data/%BUILD_ID%/getServerSideProps/all/* /.netlify/functions/next_getServerSideProps_all_slug 200 +/_next/data/%BUILD_ID%/getServerSideProps/:id.json /.netlify/functions/next_getServerSideProps_id 200 +/_next/data/%BUILD_ID%/getStaticProps/withFallback/:id.json /.netlify/functions/next_getStaticProps_withFallback_id 200 +/_next/data/%BUILD_ID%/getStaticProps/withFallback/:slug/* /.netlify/functions/next_getStaticProps_withFallback_slug 200 +/_next/data/%BUILD_ID%/getStaticProps/withFallbackBlocking/:id.json /.netlify/functions/next_getStaticProps_withFallbackBlocking_id 200 +/_next/data/%BUILD_ID%/getStaticProps/withRevalidate/withFallback/:id.json /.netlify/functions/next_getStaticProps_withRevalidate_withFallback_id 200 +/api/shows/:id /foo/api/shows/:id 301! +/api/shows/:params/* /foo/api/shows/:params 301! +/foo/_next/image* url=:url w=:width q=:quality /nextimg/:url/:width/:quality 301! +/foo/api/shows/:id /.netlify/functions/next_api_shows_id 200 +/foo/api/shows/:params/* /.netlify/functions/next_api_shows_params 200 +/foo/getServerSideProps/all /.netlify/functions/next_getServerSideProps_all_slug 200 +/foo/getServerSideProps/all/* /.netlify/functions/next_getServerSideProps_all_slug 200 +/foo/getServerSideProps/:id /.netlify/functions/next_getServerSideProps_id 200 +/foo/getStaticProps/withFallback/:id /.netlify/functions/next_getStaticProps_withFallback_id 200 +/foo/getStaticProps/withFallback/:slug/* /.netlify/functions/next_getStaticProps_withFallback_slug 200 +/foo/getStaticProps/withFallbackBlocking/:id /.netlify/functions/next_getStaticProps_withFallbackBlocking_id 200 +/foo/getStaticProps/withRevalidate/withFallback/:id /.netlify/functions/next_getStaticProps_withRevalidate_withFallback_id 200 +/foo/shows/:id /.netlify/functions/next_shows_id 200 +/foo/shows/:params/* /.netlify/functions/next_shows_params 200 +/foo/static/:id /static/[id] 200 +/getServerSideProps/all /foo/getServerSideProps/all/:slug 301! +/getServerSideProps/all/* /foo/getServerSideProps/all/:slug 301! +/getServerSideProps/:id /foo/getServerSideProps/:id 301! +/getStaticProps/withFallback/:id /foo/getStaticProps/withFallback/:id 301! +/getStaticProps/withFallback/:slug/* /foo/getStaticProps/withFallback/:slug 301! +/getStaticProps/withFallbackBlocking/:id /foo/getStaticProps/withFallbackBlocking/:id 301! +/getStaticProps/withRevalidate/withFallback/:id /foo/getStaticProps/withRevalidate/withFallback/:id 301! +/nextimg/* /.netlify/functions/next_image 200 +/shows/:id /foo/shows/:id 301! +/shows/:params/* /foo/shows/:params 301! +/static/:id /static/[id].html 200" +`; diff --git a/src/tests/basePath.test.js b/src/tests/basePath.test.js new file mode 100644 index 0000000000..0c2a02aeec --- /dev/null +++ b/src/tests/basePath.test.js @@ -0,0 +1,51 @@ +// Test next-on-netlify when a custom distDir is set in next.config.js + +const { EOL } = require('os') +const { parse, join } = require('path') +const { readFileSync } = require('fs-extra') +const buildNextApp = require('./helpers/buildNextApp') + +// The name of this test file (without extension) +const FILENAME = parse(__filename).name + +// The directory which will be used for testing. +// We simulate a NextJS app within that directory, with pages, and a +// package.json file. +const PROJECT_PATH = join(__dirname, 'builds', FILENAME) + +// Capture the output to verify successful build +let buildOutput + +beforeAll( + async () => { + buildOutput = await buildNextApp() + .forTest(__filename) + .withPages('pages') + .withNextConfig('next.config.js-with-basePath.js') + .withPackageJson('package.json') + .build() + }, + // time out after 180 seconds + 180 * 1000, +) + +describe('next-on-netlify', () => { + test('builds successfully', () => { + expect(buildOutput).toMatch('Next on Netlify') + expect(buildOutput).toMatch('Success! All done!') + }) +}) + +describe('Routing', () => { + test('creates Netlify redirects', async () => { + // Read _redirects file + const contents = readFileSync(join(PROJECT_PATH, 'out_publish', '_redirects')) + let redirects = contents.toString() + + // Replace non-persistent build ID with placeholder + redirects = redirects.replace(/\/_next\/data\/[^\/]+\//g, '/_next/data/%BUILD_ID%/') + + // Check that redirects match + expect(redirects).toMatchSnapshot() + }) +}) diff --git a/src/tests/fixtures/next.config.js-with-basePath.js b/src/tests/fixtures/next.config.js-with-basePath.js new file mode 100644 index 0000000000..133213593d --- /dev/null +++ b/src/tests/fixtures/next.config.js-with-basePath.js @@ -0,0 +1,4 @@ +module.exports = { + target: 'serverless', + basePath: '/foo', +}