From d0fb79c6361549f567a9bbbc3a7a77f06201dec5 Mon Sep 17 00:00:00 2001 From: Lindsay Levine Date: Sat, 29 May 2021 09:55:25 -0400 Subject: [PATCH 1/2] feat: support for basePath --- .../fixtures/next.config.js-with-basePath | 4 + .../fixtures/pages/getServerSideProps/[id].js | 2 +- .../pages/getServerSideProps/static.js | 2 +- src/cypress/integration/basePath_spec.js | 735 ++++++++++++++++++ src/lib/config.js | 1 - src/lib/helpers/convertToBasePathRedirects.js | 68 ++ src/lib/steps/setupRedirects.js | 26 +- src/tests/__snapshots__/basePath.test.js.snap | 74 ++ src/tests/basePath.test.js | 51 ++ .../fixtures/next.config.js-with-basePath.js | 4 + 10 files changed, 960 insertions(+), 7 deletions(-) create mode 100644 src/cypress/fixtures/next.config.js-with-basePath create mode 100644 src/cypress/integration/basePath_spec.js create mode 100644 src/lib/helpers/convertToBasePathRedirects.js create mode 100644 src/tests/__snapshots__/basePath.test.js.snap create mode 100644 src/tests/basePath.test.js create mode 100644 src/tests/fixtures/next.config.js-with-basePath.js 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 (

- 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
Refresh the page to see server-side rendering in action.
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 }) => (

- 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
Refresh the page to see server-side rendering in action.
diff --git a/src/cypress/integration/basePath_spec.js b/src/cypress/integration/basePath_spec.js new file mode 100644 index 0000000000..9759dd5ba3 --- /dev/null +++ b/src/cypress/integration/basePath_spec.js @@ -0,0 +1,735 @@ +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) + }) + }) + + context('with dynamic route', () => { + it('loads TV show', () => { + cy.visit('/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('/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('/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('/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('/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('/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('/') + 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('/getServerSideProps/1337') + + cy.get('h1').should('contain', 'Show #1337') + cy.get('p').should('contain', 'Whodunnit?') + }) + + it('loads TV show when SSR-ing', () => { + cy.ssr('/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: '/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('/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('/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('/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('/') + 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('/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('/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('/getStaticProps/1') + cy.get('h1').should('contain', 'Show #1') + cy.get('p').should('contain', 'Under the Dome') + + cy.visit('/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('/') + 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: '/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('/getStaticProps/withFallback/3') + cy.get('h1').should('contain', 'Show #3') + cy.get('p').should('contain', 'Bitten') + + cy.visit('/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('/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('/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('/') + 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('/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('/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('/getStaticProps/withFallback/my/path/1') + cy.get('h1').should('contain', 'Show #1') + cy.get('p').should('contain', 'Under the Dome') + + cy.visit('/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('/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('/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('/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('/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('/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('/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('/api/enterPreview?id=999') + + cy.url().should('include', '/previewTest/999') + }) + + it('redirects to static preview test page', () => { + cy.visit('/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('/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('/api/enterPreviewStatic') + cy.get('h1').should('contain', 'Number: 3') + } + }) + + it('can move in and out of preview mode for SSRed page', () => { + cy.visit('/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('/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('/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('/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('/previewTest/static').then((response) => { + expect(response.headers['cache-control']).to.include('public') + }) + + cy.visit('/api/enterPreviewStatic') + + cy.request('/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('/static') + + cy.get('p').should('contain', 'It is a static page.') + }) + + it('renders when SSR-ing', () => { + cy.visit('/static') + + cy.get('p').should('contain', 'It is a static page.') + }) + }) + + context('with dynamic route', () => { + it('renders', () => { + cy.visit('/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('/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: '/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..ad4cf692a0 --- /dev/null +++ b/src/lib/helpers/convertToBasePathRedirects.js @@ -0,0 +1,68 @@ +// This helper converts the collection of redirects for all page types into +// the necessary redirects for a basePath-generated site +// i.e. +// no basePath: +// /ssr /.netlify/functions/next_ssr 200 +// with basePath configured: +// /ssr /base/ssr 301! +// /base/ssr /.netlify/functions/next_ssr 200 + +const getBasePathDefaultRedirects = ({ basePath, nextRedirects }) => { + if (basePath === '') return [] + 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 === '/') { + 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 preview mode redirect + basePathRedirects.push({ + route: `${basePath}${r.route}`, + target: r.target, + conditions: r.conditions, + }) + } else if (!r.route.includes('_next/') && r.target.includes('/.netlify/functions')) { + 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/steps/setupRedirects.js b/src/lib/steps/setupRedirects.js index c86b48acf3..a161ad604d 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 { DYNAMIC_PARAMETER_REGEX } = require('../constants/regex') +const convertToBasePathRedirects = require('../helpers/convertToBasePathRedirects') 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,18 @@ const setupRedirects = async (publishPath) => { // Add _redirect section heading redirects.push('# Next-on-Netlify Redirects') + const { basePath } = await getNextConfig() + const hasBasePath = basePath !== '' + if (hasBasePath) { + 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, @@ -62,13 +71,22 @@ const setupRedirects = async (publishPath) => { const sortedStaticRedirects = getSortedRedirects(staticRedirects) const sortedDynamicRedirects = getSortedRedirects(dynamicRedirects) + const basePathSortFunc = (a, b) => (a.target.includes(basePath) ? -1 : 1) + const allRedirects = hasBasePath + ? [...sortedStaticRedirects.sort(basePathSortFunc), ...sortedDynamicRedirects.sort(basePathSortFunc)] + : [...sortedStaticRedirects, ...sortedDynamicRedirects] + // Assemble redirects for each route - ;[...sortedStaticRedirects, ...sortedDynamicRedirects].forEach((nextRedirect) => { + allRedirects.forEach((nextRedirect) => { // One route may map to multiple Netlify routes: e.g., catch-all pages // 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 formattedTarget = + hasBasePath && target.includes(basePath) + ? target.replace(DYNAMIC_PARAMETER_REGEX, '/:$1').replace('...', '') + : target + const redirectPieces = [netlifyRoute, formattedTarget, `${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..7df11b943e --- /dev/null +++ b/src/tests/__snapshots__/basePath.test.js.snap @@ -0,0 +1,74 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Routing creates Netlify redirects 1`] = ` +"# Next-on-Netlify Redirects +/getStaticProps/withRevalidate/2 /foo/getStaticProps/withRevalidate/2 301! +/getStaticProps/withRevalidate/1 /foo/getStaticProps/withRevalidate/1 301! +/getStaticProps/with-revalidate /foo/getStaticProps/with-revalidate 301! +/getServerSideProps/static /foo/getServerSideProps/static 301! +/api/static /foo/api/static 301! +/api/hello-background /foo/api/hello-background 301! +/ /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 +/foo /.netlify/functions/next_index 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/2 /.netlify/functions/next_getStaticProps_id 200 Cookie=__prerender_bypass,__next_preview_data +/foo/getStaticProps/static /.netlify/functions/next_getStaticProps_static 200 Cookie=__prerender_bypass,__next_preview_data +/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/4 /.netlify/functions/next_getStaticProps_withFallback_id 200 Cookie=__prerender_bypass,__next_preview_data +/foo/getStaticProps/withFallback/my/path/1 /.netlify/functions/next_getStaticProps_withFallback_slug 200 Cookie=__prerender_bypass,__next_preview_data +/foo/getStaticProps/withFallback/my/path/2 /.netlify/functions/next_getStaticProps_withFallback_slug 200 Cookie=__prerender_bypass,__next_preview_data +/foo/getStaticProps/withFallbackBlocking/3 /.netlify/functions/next_getStaticProps_withFallbackBlocking_id 200 Cookie=__prerender_bypass,__next_preview_data +/foo/getStaticProps/withFallbackBlocking/4 /.netlify/functions/next_getStaticProps_withFallbackBlocking_id 200 Cookie=__prerender_bypass,__next_preview_data +/foo/getStaticProps/withRevalidate/1 /.netlify/functions/next_getStaticProps_withRevalidate_id 200 +/foo/getStaticProps/withRevalidate/2 /.netlify/functions/next_getStaticProps_withRevalidate_id 200 +/shows/:params/* /foo/shows/:params 301! +/shows/:id /foo/shows/:id 301! +/getStaticProps/withRevalidate/withFallback/:id /foo/getStaticProps/withRevalidate/withFallback/:id 301! +/getStaticProps/withFallbackBlocking/:id /foo/getStaticProps/withFallbackBlocking/:id 301! +/getStaticProps/withFallback/:slug/* /foo/getStaticProps/withFallback/:slug 301! +/getStaticProps/withFallback/:id /foo/getStaticProps/withFallback/:id 301! +/getServerSideProps/:id /foo/getServerSideProps/:id 301! +/getServerSideProps/all /foo/getServerSideProps/all/:[slug] 301! +/getServerSideProps/all/* /foo/getServerSideProps/all/:[slug] 301! +/api/shows/:params/* /foo/api/shows/:params 301! +/api/shows/:id /foo/api/shows/:id 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 +/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 +/nextimg/* /.netlify/functions/next_image 200 +/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', +} From 906d9f67ce82315bb6afd9136448b9e8c20af785 Mon Sep 17 00:00:00 2001 From: Lindsay Levine Date: Wed, 2 Jun 2021 11:38:43 -0400 Subject: [PATCH 2/2] chore: cleanup, catch-all, cypress tests pass --- src/cypress/integration/basePath_spec.js | 114 ++++++++++-------- src/lib/helpers/convertToBasePathRedirects.js | 19 +-- src/lib/helpers/formatRedirectTarget.js | 9 ++ src/lib/pages/withoutProps/redirects.js | 11 ++ src/lib/steps/setupRedirects.js | 23 ++-- src/tests/__snapshots__/basePath.test.js.snap | 64 ++++++---- 6 files changed, 142 insertions(+), 98 deletions(-) create mode 100644 src/lib/helpers/formatRedirectTarget.js diff --git a/src/cypress/integration/basePath_spec.js b/src/cypress/integration/basePath_spec.js index 9759dd5ba3..00a3c014bf 100644 --- a/src/cypress/integration/basePath_spec.js +++ b/src/cypress/integration/basePath_spec.js @@ -74,18 +74,30 @@ describe('getInitialProps', () => { 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('/shows/24251') + 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('/shows/24251') + cy.ssr('/foo/shows/24251') cy.get('h1').should('contain', 'Show #24251') cy.get('p').should('contain', 'Animal Science') @@ -94,7 +106,7 @@ describe('getInitialProps', () => { context('with catch-all route', () => { it('displays all URL parameters, including query string parameters', () => { - cy.visit('/shows/94/this-is-all/being/captured/yay?search=dog&custom-param=cat') + 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') @@ -112,7 +124,7 @@ describe('getInitialProps', () => { }) it('displays all URL parameters when SSR-ing, including query string parameters', () => { - cy.visit('/shows/94/this-is-all/being/captured/yay?search=dog&custom-param=cat') + 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') @@ -159,21 +171,21 @@ describe('getServerSideProps', () => { context('with static route', () => { it('loads TV shows', () => { - cy.visit('/getServerSideProps/static') + 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('/getServerSideProps/static') + 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('/') + cy.visit('/foo') cy.window().then((w) => (w.noReload = true)) // Navigate to page and test that no reload is performed @@ -187,14 +199,14 @@ describe('getServerSideProps', () => { context('with dynamic route', () => { it('loads TV show', () => { - cy.visit('/getServerSideProps/1337') + 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('/getServerSideProps/1337') + cy.ssr('/foo/getServerSideProps/1337') cy.get('h1').should('contain', 'Show #1337') cy.get('p').should('contain', 'Whodunnit?') @@ -224,7 +236,7 @@ describe('getServerSideProps', () => { context('with catch-all route', () => { it('does not match base path (without params)', () => { cy.request({ - url: '/getServerSideProps/catch/all', + url: '/foo/getServerSideProps/catch/all', failOnStatusCode: false, }).then((response) => { expect(response.status).to.eq(404) @@ -235,14 +247,14 @@ describe('getServerSideProps', () => { }) it('loads TV show with one param', () => { - cy.visit('/getServerSideProps/catch/all/1337') + 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('/getServerSideProps/catch/all/1337/multiple/params') + cy.visit('/foo/getServerSideProps/catch/all/1337/multiple/params') cy.get('h1').should('contain', 'Show #1337') cy.get('p').should('contain', 'Whodunnit?') @@ -273,14 +285,14 @@ describe('getServerSideProps', () => { describe('getStaticProps', () => { context('with static route', () => { it('loads TV show', () => { - cy.visit('/getStaticProps/static') + 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('/') + cy.visit('/foo') cy.window().then((w) => (w.noReload = true)) // Navigate to page and test that no reload is performed @@ -293,14 +305,14 @@ describe('getStaticProps', () => { context('with revalidate', () => { it('loads TV show', () => { - cy.visit('/getStaticProps/with-revalidate') + 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('/getStaticProps/with-revalidate') + cy.ssr('/foo/getStaticProps/with-revalidate') cy.get('h1').should('contain', 'Show #71') cy.get('p').should('contain', 'Dancing with the Stars') @@ -311,17 +323,17 @@ describe('getStaticProps', () => { context('with dynamic route', () => { context('without fallback', () => { it('loads shows 1 and 2', () => { - cy.visit('/getStaticProps/1') + cy.visit('/foo/getStaticProps/1') cy.get('h1').should('contain', 'Show #1') cy.get('p').should('contain', 'Under the Dome') - cy.visit('/getStaticProps/2') + 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('/') + cy.visit('/foo') cy.window().then((w) => (w.noReload = true)) // Navigate to page and test that no reload is performed @@ -342,7 +354,7 @@ describe('getStaticProps', () => { it('returns 404 when trying to access non-defined path', () => { cy.request({ - url: '/getStaticProps/3', + url: '/foo/getStaticProps/3', failOnStatusCode: false, }).then((response) => { expect(response.status).to.eq(404) @@ -355,31 +367,31 @@ describe('getStaticProps', () => { context('with fallback', () => { it('loads pre-rendered TV shows 3 and 4', () => { - cy.visit('/getStaticProps/withFallback/3') + cy.visit('/foo/getStaticProps/withFallback/3') cy.get('h1').should('contain', 'Show #3') cy.get('p').should('contain', 'Bitten') - cy.visit('/getStaticProps/withFallback/4') + 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('/getStaticProps/withFallback/75') + 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('/getStaticProps/withFallback/75') + 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('/') + cy.visit('/foo') cy.window().then((w) => (w.noReload = true)) // Navigate to page and test that no reload is performed @@ -407,14 +419,14 @@ describe('getStaticProps', () => { context('with revalidate', () => { it('loads TV show', () => { - cy.visit('/getStaticProps/withRevalidate/75') + 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('/getStaticProps/withRevalidate/75') + cy.ssr('/foo/getStaticProps/withRevalidate/75') cy.get('h1').should('contain', 'Show #75') cy.get('p').should('contain', 'The Mindy Project') @@ -445,17 +457,17 @@ describe('getStaticProps', () => { context('with catch-all route', () => { context('with fallback', () => { it('loads pre-rendered shows 1 and 2', () => { - cy.visit('/getStaticProps/withFallback/my/path/1') + 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('/getStaticProps/withFallback/my/path/2') + 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('/getStaticProps/withFallback/undefined/catch/all/path/75') + 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') @@ -493,7 +505,7 @@ describe('getStaticProps', () => { describe('API endpoint', () => { context('with static route', () => { it('returns hello world, with all response headers', () => { - cy.request('/api/static').then((response) => { + 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') @@ -504,7 +516,7 @@ describe('API endpoint', () => { context('with dynamic route', () => { it('returns TV show', () => { - cy.request('/api/shows/305').then((response) => { + cy.request('/foo/api/shows/305').then((response) => { expect(response.headers['content-type']).to.include('application/json') expect(response.body).to.have.property('show') @@ -516,7 +528,7 @@ describe('API endpoint', () => { context('with catch-all route', () => { it('returns all URL paremeters, including query string parameters', () => { - cy.request('/api/shows/590/this/path/is/captured?metric=dog&p2=cat').then((response) => { + 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 @@ -539,7 +551,7 @@ describe('API endpoint', () => { }) it('redirects with res.redirect', () => { - cy.visit('/api/redirect?to=999') + cy.visit('/foo/api/redirect?to=999') cy.url().should('include', '/shows/999') cy.get('h1').should('contain', 'Show #999') @@ -547,7 +559,7 @@ describe('API endpoint', () => { }) it('exposes function context on the req object', () => { - cy.request('/api/context').then((response) => { + cy.request('/foo/api/context').then((response) => { const { req: { netlifyFunctionParams: { event, context }, @@ -570,13 +582,13 @@ describe('API endpoint', () => { describe('Preview Mode', () => { it('redirects to preview test page with dynamic route', () => { - cy.visit('/api/enterPreview?id=999') + cy.visit('/foo/api/enterPreview?id=999') cy.url().should('include', '/previewTest/999') }) it('redirects to static preview test page', () => { - cy.visit('/api/enterPreviewStatic') + cy.visit('/foo/api/enterPreviewStatic') cy.url().should('include', '/previewTest/static') }) @@ -604,7 +616,7 @@ describe('Preview Mode', () => { }) it('renders serverSideProps page in preview mode', () => { - cy.visit('/api/enterPreview?id=999') + cy.visit('/foo/api/enterPreview?id=999') if (Cypress.env('DEPLOY') === 'local') { cy.makeCookiesWorkWithHttpAndReload() @@ -617,13 +629,13 @@ describe('Preview Mode', () => { 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('/api/enterPreviewStatic') + 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('/api/enterPreview?id=999') + cy.visit('/foo/api/enterPreview?id=999') if (Cypress.env('DEPLOY') === 'local') { cy.makeCookiesWorkWithHttpAndReload() @@ -640,7 +652,7 @@ describe('Preview Mode', () => { cy.get('p').should('contain', 'Corey Lof') // Exit preview mode - cy.visit('/api/exitPreview') + cy.visit('/foo/api/exitPreview') // Verify that we're no longer in preview mode cy.contains('previewTest/222').click() @@ -650,7 +662,7 @@ describe('Preview Mode', () => { it('can move in and out of preview mode for static page', () => { if (Cypress.env('DEPLOY') !== 'local') { - cy.visit('/api/enterPreviewStatic') + cy.visit('/foo/api/enterPreviewStatic') cy.window().then((w) => (w.noReload = true)) cy.get('h1').should('contain', 'Number: 3') @@ -664,7 +676,7 @@ describe('Preview Mode', () => { cy.window().should('have.property', 'noReload', true) // Exit preview mode - cy.visit('/api/exitPreview') + cy.visit('/foo/api/exitPreview') // TO-DO: test if this is the static html? // Verify that we're no longer in preview mode @@ -675,13 +687,13 @@ describe('Preview Mode', () => { it('hits the prerendered html out of preview mode and netlify function in preview mode', () => { if (Cypress.env('DEPLOY') !== 'local') { - cy.request('/previewTest/static').then((response) => { + cy.request('/foo/previewTest/static').then((response) => { expect(response.headers['cache-control']).to.include('public') }) - cy.visit('/api/enterPreviewStatic') + cy.visit('/foo/api/enterPreviewStatic') - cy.request('/previewTest/static').then((response) => { + cy.request('/foo/previewTest/static').then((response) => { expect(response.headers['cache-control']).to.include('private') }) } @@ -691,13 +703,13 @@ describe('Preview Mode', () => { describe('pre-rendered HTML pages', () => { context('with static route', () => { it('renders', () => { - cy.visit('/static') + cy.visit('/foo/static') cy.get('p').should('contain', 'It is a static page.') }) it('renders when SSR-ing', () => { - cy.visit('/static') + cy.visit('/foo/static') cy.get('p').should('contain', 'It is a static page.') }) @@ -705,14 +717,14 @@ describe('pre-rendered HTML pages', () => { context('with dynamic route', () => { it('renders', () => { - cy.visit('/static/superdynamic') + 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('/static/superdynamic') + 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.') @@ -723,7 +735,7 @@ describe('pre-rendered HTML pages', () => { describe('404 page', () => { it('renders', () => { cy.request({ - url: '/this-page-does-not-exist', + url: '/foo/this-page-does-not-exist', failOnStatusCode: false, }).then((response) => { expect(response.status).to.eq(404) diff --git a/src/lib/helpers/convertToBasePathRedirects.js b/src/lib/helpers/convertToBasePathRedirects.js index ad4cf692a0..e154bb8f53 100644 --- a/src/lib/helpers/convertToBasePathRedirects.js +++ b/src/lib/helpers/convertToBasePathRedirects.js @@ -1,14 +1,10 @@ // This helper converts the collection of redirects for all page types into // the necessary redirects for a basePath-generated site -// i.e. -// no basePath: -// /ssr /.netlify/functions/next_ssr 200 -// with basePath configured: -// /ssr /base/ssr 301! -// /base/ssr /.netlify/functions/next_ssr 200 +// 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/*`, @@ -24,6 +20,7 @@ const convertToBasePathRedirects = ({ basePath, 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: '/', @@ -38,13 +35,21 @@ const convertToBasePathRedirects = ({ basePath, nextRedirects }) => { ] basePathRedirects.push(...indexRedirects) } else if (!r.route.includes('_next/') && r.target.includes('/.netlify/functions') && r.conditions) { - // If preview mode redirect + // 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, 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 a161ad604d..50ff238d09 100644 --- a/src/lib/steps/setupRedirects.js +++ b/src/lib/steps/setupRedirects.js @@ -4,8 +4,8 @@ const { existsSync, readFileSync, writeFileSync } = require('fs-extra') const getNextConfig = require('../../../helpers/getNextConfig') const { CUSTOM_REDIRECTS_PATH, NEXT_IMAGE_FUNCTION_NAME } = require('../config') -const { DYNAMIC_PARAMETER_REGEX } = require('../constants/regex') 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') @@ -48,8 +48,7 @@ const setupRedirects = async (publishPath) => { redirects.push('# Next-on-Netlify Redirects') const { basePath } = await getNextConfig() - const hasBasePath = basePath !== '' - if (hasBasePath) { + if (basePath !== '') { nextRedirects = convertToBasePathRedirects({ basePath, nextRedirects }) } @@ -71,22 +70,18 @@ const setupRedirects = async (publishPath) => { const sortedStaticRedirects = getSortedRedirects(staticRedirects) const sortedDynamicRedirects = getSortedRedirects(dynamicRedirects) - const basePathSortFunc = (a, b) => (a.target.includes(basePath) ? -1 : 1) - const allRedirects = hasBasePath - ? [...sortedStaticRedirects.sort(basePathSortFunc), ...sortedDynamicRedirects.sort(basePathSortFunc)] - : [...sortedStaticRedirects, ...sortedDynamicRedirects] - // Assemble redirects for each route - allRedirects.forEach((nextRedirect) => { + ;[...sortedStaticRedirects, ...sortedDynamicRedirects].forEach((nextRedirect) => { // One route may map to multiple Netlify routes: e.g., catch-all pages // require two Netlify routes in the _redirects file getNetlifyRoutes(nextRedirect.route).forEach((netlifyRoute) => { const { conditions = [], force = false, statusCode = '200', target } = nextRedirect - const formattedTarget = - hasBasePath && target.includes(basePath) - ? target.replace(DYNAMIC_PARAMETER_REGEX, '/:$1').replace('...', '') - : target - const redirectPieces = [netlifyRoute, formattedTarget, `${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 index 7df11b943e..ee4b58d4a6 100644 --- a/src/tests/__snapshots__/basePath.test.js.snap +++ b/src/tests/__snapshots__/basePath.test.js.snap @@ -2,12 +2,6 @@ exports[`Routing creates Netlify redirects 1`] = ` "# Next-on-Netlify Redirects -/getStaticProps/withRevalidate/2 /foo/getStaticProps/withRevalidate/2 301! -/getStaticProps/withRevalidate/1 /foo/getStaticProps/withRevalidate/1 301! -/getStaticProps/with-revalidate /foo/getStaticProps/with-revalidate 301! -/getServerSideProps/static /foo/getServerSideProps/static 301! -/api/static /foo/api/static 301! -/api/hello-background /foo/api/hello-background 301! / /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 @@ -22,34 +16,40 @@ exports[`Routing creates Netlify redirects 1`] = ` /_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/2 /.netlify/functions/next_getStaticProps_id 200 Cookie=__prerender_bypass,__next_preview_data -/foo/getStaticProps/static /.netlify/functions/next_getStaticProps_static 200 Cookie=__prerender_bypass,__next_preview_data +/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/4 /.netlify/functions/next_getStaticProps_withFallback_id 200 Cookie=__prerender_bypass,__next_preview_data -/foo/getStaticProps/withFallback/my/path/1 /.netlify/functions/next_getStaticProps_withFallback_slug 200 Cookie=__prerender_bypass,__next_preview_data -/foo/getStaticProps/withFallback/my/path/2 /.netlify/functions/next_getStaticProps_withFallback_slug 200 Cookie=__prerender_bypass,__next_preview_data -/foo/getStaticProps/withFallbackBlocking/3 /.netlify/functions/next_getStaticProps_withFallbackBlocking_id 200 Cookie=__prerender_bypass,__next_preview_data -/foo/getStaticProps/withFallbackBlocking/4 /.netlify/functions/next_getStaticProps_withFallbackBlocking_id 200 Cookie=__prerender_bypass,__next_preview_data +/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 -/shows/:params/* /foo/shows/:params 301! -/shows/:id /foo/shows/:id 301! -/getStaticProps/withRevalidate/withFallback/:id /foo/getStaticProps/withRevalidate/withFallback/:id 301! -/getStaticProps/withFallbackBlocking/:id /foo/getStaticProps/withFallbackBlocking/:id 301! -/getStaticProps/withFallback/:slug/* /foo/getStaticProps/withFallback/:slug 301! -/getStaticProps/withFallback/:id /foo/getStaticProps/withFallback/:id 301! -/getServerSideProps/:id /foo/getServerSideProps/:id 301! -/getServerSideProps/all /foo/getServerSideProps/all/:[slug] 301! -/getServerSideProps/all/* /foo/getServerSideProps/all/:[slug] 301! -/api/shows/:params/* /foo/api/shows/:params 301! -/api/shows/:id /foo/api/shows/:id 301! +/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 @@ -57,6 +57,8 @@ exports[`Routing creates Netlify redirects 1`] = ` /_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 @@ -69,6 +71,16 @@ exports[`Routing creates Netlify redirects 1`] = ` /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" `;