diff --git a/.eslintignore b/.eslintignore index 73eeb62509..07891998fb 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,4 +3,6 @@ node_modules test lib demos -plugin/src/templates/edge \ No newline at end of file +plugin/src/templates/edge +plugin/lib +plugin/dist-types \ No newline at end of file diff --git a/.github/workflows/cypress-middleware.yml b/.github/workflows/cypress-middleware.yml new file mode 100644 index 0000000000..742dd600cd --- /dev/null +++ b/.github/workflows/cypress-middleware.yml @@ -0,0 +1,73 @@ +name: Run e2e (middleware demo) +on: + pull_request: + types: [opened, labeled, unlabeled, synchronize] + push: + branches: + - main + paths: + - 'demos/middleware/**/*.{js,jsx,ts,tsx}' + - 'cypress/integration/middleware/**/*.{ts,js}' + - 'src/**/*.{ts,js}' +jobs: + cypress: + name: Cypress + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + containers: [1, 2, 3, 4] + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Generate Github token + uses: navikt/github-app-token-generator@v1 + id: get-token + with: + private-key: ${{ secrets.TOKENS_PRIVATE_KEY }} + app-id: ${{ secrets.TOKENS_APP_ID }} + + - name: Checkout @netlify/wait-for-deploy-action + uses: actions/checkout@v2 + with: + repository: netlify/wait-for-deploy-action + token: ${{ steps.get-token.outputs.token }} + path: ./.github/actions/wait-for-netlify-deploy + + - name: Wait for Netlify Deploy + id: deploy + uses: ./.github/actions/wait-for-netlify-deploy + with: + site-name: next-plugin-edge-middleware + timeout: 300 + + - name: Deploy successful + if: ${{ steps.deploy.outputs.origin-url }} + run: echo ${{ steps.deploy.outputs.origin-url }} + + - name: Node + uses: actions/setup-node@v2 + with: + node-version: '16' + + - run: npm install + + - name: Cypress run + if: ${{ steps.deploy.outputs.origin-url }} + id: cypress + uses: cypress-io/github-action@v2 + with: + browser: chrome + headless: true + record: true + parallel: true + config-file: cypress/config/middleware.json + group: 'Next Plugin - Middleware' + spec: cypress/integration/middleware/* + env: + DEBUG: '@cypress/github-action' + CYPRESS_baseUrl: ${{ steps.deploy.outputs.origin-url }} + CYPRESS_NETLIFY_CONTEXT: ${{ steps.deploy.outputs.context }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CYPRESS_RECORD_KEY: ${{ secrets.MIDDLEWARE_CYPRESS_RECORD_KEY }} diff --git a/.gitignore b/.gitignore index d606b6b62a..4f18d78b29 100644 --- a/.gitignore +++ b/.gitignore @@ -147,6 +147,7 @@ Temporary Items demos/default/.next .parcel-cache plugin/lib +plugin/dist-types # Cypress cypress/screenshots diff --git a/.prettierignore b/.prettierignore index b626363432..081d3085c9 100644 --- a/.prettierignore +++ b/.prettierignore @@ -21,6 +21,7 @@ node_modules lib tsconfig.json demos/nx-next-monorepo-demo -plugin/src/templates/edge -plugin/CHANGELOG.md \ No newline at end of file +plugin/CHANGELOG.md +plugin/lib +plugin/dist-types \ No newline at end of file diff --git a/cypress/config/middleware.json b/cypress/config/middleware.json new file mode 100644 index 0000000000..c81506ae4b --- /dev/null +++ b/cypress/config/middleware.json @@ -0,0 +1,5 @@ +{ + "baseUrl": "http://localhost:8888", + "integrationFolder": "cypress/integration/middleware", + "projectId": "yn8qwi" +} diff --git a/cypress/integration/middleware/enhanced.spec.ts b/cypress/integration/middleware/enhanced.spec.ts new file mode 100644 index 0000000000..d70d4bacd8 --- /dev/null +++ b/cypress/integration/middleware/enhanced.spec.ts @@ -0,0 +1,28 @@ +describe('Enhanced middleware', () => { + it('adds request headers', () => { + cy.request('/api/hello').then((response) => { + expect(response.body).to.have.nested.property('headers.x-hello', 'world') + }) + }) + + it('adds request headers to a rewrite', () => { + cy.request('/headers').then((response) => { + expect(response.body).to.have.nested.property('headers.x-hello', 'world') + }) + }) + + it('rewrites the response body', () => { + cy.visit('/static') + cy.get('#message').contains('This was static but has been transformed in') + cy.contains("This is an ad that isn't shown by default") + }) + + it('modifies the page props', () => { + cy.request('/_next/data/build-id/static.json').then((response) => { + expect(response.body).to.have.nested.property('pageProps.showAd', true) + expect(response.body) + .to.have.nested.property('pageProps.message') + .that.includes('This was static but has been transformed in') + }) + }) +}) diff --git a/cypress/integration/middleware/standard.spec.ts b/cypress/integration/middleware/standard.spec.ts new file mode 100644 index 0000000000..dd6b38dd97 --- /dev/null +++ b/cypress/integration/middleware/standard.spec.ts @@ -0,0 +1,22 @@ +describe('Standard middleware', () => { + it('rewrites to internal page', () => { + // preview mode is off by default + cy.visit('/shows/rewriteme') + cy.get('h1').should('contain', 'Show #100') + cy.url().should('eq', `${Cypress.config().baseUrl}/shows/rewriteme`) + }) + + it('rewrites to external page', () => { + cy.visit('/shows/rewrite-external') + cy.get('h1').should('contain', 'Example Domain') + cy.url().should('eq', `${Cypress.config().baseUrl}/shows/rewrite-external`) + }) + + it('adds headers to static pages', () => { + cy.request('/shows/static/3').then((response) => { + expect(response.headers).to.have.property('x-middleware-date') + expect(response.headers).to.have.property('x-is-deno', 'true') + expect(response.headers).to.have.property('x-modified-edge', 'true') + }) + }) +}) diff --git a/demos/middleware/middleware.ts b/demos/middleware/middleware.ts index 2f53ce9657..2d0b5ea1d5 100644 --- a/demos/middleware/middleware.ts +++ b/demos/middleware/middleware.ts @@ -1,11 +1,40 @@ import { NextResponse } from 'next/server' -import { NextFetchEvent, NextRequest } from 'next/server' +import type { NextRequest } from 'next/server' -export function middleware(request: NextRequest, ev: NextFetchEvent) { +import { MiddlewareRequest } from '@netlify/plugin-nextjs/middleware' + +export async function middleware(req: NextRequest) { let response const { nextUrl: { pathname }, - } = request + } = req + + const request = new MiddlewareRequest(req) + + if (pathname.startsWith('/static')) { + // Unlike NextResponse.next(), this actually sends the request to the origin + const res = await request.next() + const message = `This was static but has been transformed in ${req.geo.city}` + + // Transform the response HTML and props + res.replaceText('p[id=message]', message) + res.setPageProp('message', message) + res.setPageProp('showAd', true) + + return res + } + + if (pathname.startsWith('/api/hello')) { + // Add a header to the request + req.headers.set('x-hello', 'world') + return request.next() + } + + if (pathname.startsWith('/headers')) { + // Add a header to the rewritten request + req.headers.set('x-hello', 'world') + return request.rewrite('/api/hello') + } if (pathname.startsWith('/cookies')) { response = NextResponse.next() @@ -15,7 +44,7 @@ export function middleware(request: NextRequest, ev: NextFetchEvent) { if (pathname.startsWith('/shows')) { if (pathname.startsWith('/shows/rewrite-absolute')) { - response = NextResponse.rewrite(new URL('/shows/100', request.url)) + response = NextResponse.rewrite(new URL('/shows/100', req.url)) response.headers.set('x-modified-in-rewrite', 'true') } if (pathname.startsWith('/shows/rewrite-external')) { @@ -23,7 +52,7 @@ export function middleware(request: NextRequest, ev: NextFetchEvent) { response.headers.set('x-modified-in-rewrite', 'true') } if (pathname.startsWith('/shows/rewriteme')) { - const url = request.nextUrl.clone() + const url = req.nextUrl.clone() url.pathname = '/shows/100' response = NextResponse.rewrite(url) response.headers.set('x-modified-in-rewrite', 'true') diff --git a/demos/middleware/netlify.toml b/demos/middleware/netlify.toml index a0ab27257f..1bb1c1f777 100644 --- a/demos/middleware/netlify.toml +++ b/demos/middleware/netlify.toml @@ -21,3 +21,13 @@ included_files = [ [dev] framework = "#static" + +[[redirects]] +from = "/_next/static/*" +to = "/static/:splat" +status = 200 + +[[redirects]] +from = "/*" +to = "/.netlify/functions/___netlify-handler" +status = 200 diff --git a/demos/middleware/next.config.js b/demos/middleware/next.config.js index f99fac3940..b47fec533d 100644 --- a/demos/middleware/next.config.js +++ b/demos/middleware/next.config.js @@ -6,6 +6,7 @@ const nextConfig = { // your project has ESLint errors. ignoreDuringBuilds: true, }, + generateBuildId: () => 'build-id', } module.exports = nextConfig diff --git a/demos/middleware/package.json b/demos/middleware/package.json index c3c6755472..8cd8b13353 100644 --- a/demos/middleware/package.json +++ b/demos/middleware/package.json @@ -9,12 +9,12 @@ "ntl": "ntl-internal" }, "dependencies": { + "@netlify/plugin-nextjs": "*", "next": "^12.2.0", "react": "18.0.0", "react-dom": "18.0.0" }, "devDependencies": { - "@netlify/plugin-nextjs": "*", "@types/fs-extra": "^9.0.13", "@types/jest": "^27.4.1", "@types/node": "^17.0.25", @@ -23,4 +23,4 @@ "npm-run-all": "^4.1.5", "typescript": "^4.6.3" } -} +} \ No newline at end of file diff --git a/demos/middleware/pages/api/hello.js b/demos/middleware/pages/api/hello.js index df63de88fa..5670c43cca 100644 --- a/demos/middleware/pages/api/hello.js +++ b/demos/middleware/pages/api/hello.js @@ -1,5 +1,5 @@ // Next.js API route support: https://nextjs.org/docs/api-routes/introduction export default function handler(req, res) { - res.status(200).json({ name: 'John Doe' }) + res.status(200).json({ name: 'John Doe', headers: req.headers }) } diff --git a/demos/middleware/pages/index.js b/demos/middleware/pages/index.js index 8d0905762d..28906e2841 100644 --- a/demos/middleware/pages/index.js +++ b/demos/middleware/pages/index.js @@ -33,6 +33,15 @@ export default function Home() { Cookie API

+

+ Adds `x-hello` request header +

+

+ Rewrite static page content +

+

+ Adds `x-hello` request header to a rewrite +

) diff --git a/demos/middleware/pages/static.js b/demos/middleware/pages/static.js new file mode 100644 index 0000000000..0ac0fd6121 --- /dev/null +++ b/demos/middleware/pages/static.js @@ -0,0 +1,37 @@ +import * as React from 'react' + +const useHydrated = () => { + const [hydrated, setHydrated] = React.useState(false) + React.useEffect(() => { + setHydrated(true) + }, []) + return hydrated +} + +const Page = ({ message, showAd }) => { + const hydrated = useHydrated() + return ( +
+

{message}

+ {hydrated && showAd ? ( +
+

This is an ad that isn't shown by default

+ +
+ ) : ( +

No ads for me

+ )} +
+ ) +} + +export async function getStaticProps() { + return { + props: { + message: 'This is a static page', + showAd: false, + }, + } +} + +export default Page diff --git a/package-lock.json b/package-lock.json index 39555a2804..f59ac1be2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -188,12 +188,12 @@ "demos/middleware": { "version": "0.1.0", "dependencies": { + "@netlify/plugin-nextjs": "*", "next": "^12.2.0", "react": "18.0.0", "react-dom": "18.0.0" }, "devDependencies": { - "@netlify/plugin-nextjs": "*", "@types/fs-extra": "^9.0.13", "@types/jest": "^27.4.1", "@types/node": "^17.0.25", @@ -5130,13 +5130,13 @@ "version": "15.7.5", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", - "devOptional": true + "dev": true }, "node_modules/@types/react": { "version": "17.0.47", "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.47.tgz", "integrity": "sha512-mk0BL8zBinf2ozNr3qPnlu1oyVTYq+4V7WA76RgxUAtf0Em/Wbid38KN6n4abEkvO4xMTBWmnP1FtQzgkEiJoA==", - "devOptional": true, + "dev": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -5156,7 +5156,7 @@ "version": "0.16.2", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", - "devOptional": true + "dev": true }, "node_modules/@types/sinonjs__fake-timers": { "version": "8.1.1", @@ -8672,7 +8672,7 @@ "version": "3.0.11", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==", - "devOptional": true + "dev": true }, "node_modules/custom-routes": { "resolved": "demos/custom-routes", @@ -12683,7 +12683,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.0.0.tgz", "integrity": "sha512-zIE9hX70qew5qTUjSS7wi1iwj/l7+m54KWU247nhM3v806UdGj1yDndXj+IOYxxtW9zyLI+xqFNZjTuDaLUqFw==", - "devOptional": true + "dev": true }, "node_modules/import-fresh": { "version": "3.3.0", @@ -19733,7 +19733,7 @@ "version": "1.50.1", "resolved": "https://registry.npmjs.org/sass/-/sass-1.50.1.tgz", "integrity": "sha512-noTnY41KnlW2A9P8sdwESpDmo+KBNkukI1i8+hOK3footBUcohNHtdOJbckp46XO95nuvcHDDZ+4tmOnpK3hjw==", - "devOptional": true, + "dev": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", "immutable": "^4.0.0", @@ -25091,8 +25091,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-3.0.0.tgz", "integrity": "sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==", - "dev": true, - "requires": {} + "dev": true }, "chalk": { "version": "5.0.1", @@ -25447,8 +25446,7 @@ "version": "17.0.0", "resolved": "https://registry.npmjs.org/eslint-config-standard/-/eslint-config-standard-17.0.0.tgz", "integrity": "sha512-/2ks1GKyqSOkH7JFvXJicu0iMpoojkwB+f5Du/1SC0PtBL+s8v30k9njRZ21pm2drKYm2342jFnGWzttxPmZVg==", - "dev": true, - "requires": {} + "dev": true }, "eslint-import-resolver-typescript": { "version": "3.3.0", @@ -26281,13 +26279,13 @@ "version": "15.7.5", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", - "devOptional": true + "dev": true }, "@types/react": { "version": "17.0.47", "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.47.tgz", "integrity": "sha512-mk0BL8zBinf2ozNr3qPnlu1oyVTYq+4V7WA76RgxUAtf0Em/Wbid38KN6n4abEkvO4xMTBWmnP1FtQzgkEiJoA==", - "devOptional": true, + "dev": true, "requires": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -26307,7 +26305,7 @@ "version": "0.16.2", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", - "devOptional": true + "dev": true }, "@types/sinonjs__fake-timers": { "version": "8.1.1", @@ -26614,8 +26612,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "requires": {} + "dev": true }, "acorn-walk": { "version": "7.2.0", @@ -28956,7 +28953,7 @@ "version": "3.0.11", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==", - "devOptional": true + "dev": true }, "custom-routes": { "version": "file:demos/custom-routes", @@ -30086,8 +30083,7 @@ "version": "8.5.0", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.5.0.tgz", "integrity": "sha512-obmWKLUNCnhtQRKc+tmnYuQl0pFU1ibYJQ5BGhTVB08bHe9wC8qUeG7c08dj9XX+AuPj1YSGSQIHl1pnDHZR0Q==", - "dev": true, - "requires": {} + "dev": true }, "eslint-formatter-codeframe": { "version": "7.32.1", @@ -30532,8 +30528,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-6.0.0.tgz", "integrity": "sha512-7GPezalm5Bfi/E22PnQxDWH2iW9GTvAlUNTztemeHb6c1BniSyoeTrM87JkC0wYdi6aQrZX9p2qEiAno8aTcbw==", - "dev": true, - "requires": {} + "dev": true }, "eslint-plugin-react": { "version": "7.29.4", @@ -30581,8 +30576,7 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.5.0.tgz", "integrity": "sha512-8k1gRt7D7h03kd+SAAlzXkQwWK22BnK6GKZG+FJA6BAGy22CFvl8kCIXKpVux0cCxMWDQUPqSok0LKaZ0aOcCw==", - "dev": true, - "requires": {} + "dev": true }, "eslint-plugin-unicorn": { "version": "43.0.2", @@ -32047,7 +32041,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.0.0.tgz", "integrity": "sha512-zIE9hX70qew5qTUjSS7wi1iwj/l7+m54KWU247nhM3v806UdGj1yDndXj+IOYxxtW9zyLI+xqFNZjTuDaLUqFw==", - "devOptional": true + "dev": true }, "import-fresh": { "version": "3.3.0", @@ -33119,8 +33113,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz", "integrity": "sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==", - "dev": true, - "requires": {} + "dev": true }, "jest-regex-util": { "version": "27.5.1", @@ -37376,7 +37369,7 @@ "version": "1.50.1", "resolved": "https://registry.npmjs.org/sass/-/sass-1.50.1.tgz", "integrity": "sha512-noTnY41KnlW2A9P8sdwESpDmo+KBNkukI1i8+hOK3footBUcohNHtdOJbckp46XO95nuvcHDDZ+4tmOnpK3hjw==", - "devOptional": true, + "dev": true, "requires": { "chokidar": ">=3.0.0 <4.0.0", "immutable": "^4.0.0", @@ -38315,8 +38308,7 @@ "styled-jsx": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.0.2.tgz", - "integrity": "sha512-LqPQrbBh3egD57NBcHET4qcgshPks+yblyhPlH2GY8oaDgKs8SK4C3dBh3oSJjgzJ3G5t1SYEZGHkP+QEpX9EQ==", - "requires": {} + "integrity": "sha512-LqPQrbBh3egD57NBcHET4qcgshPks+yblyhPlH2GY8oaDgKs8SK4C3dBh3oSJjgzJ3G5t1SYEZGHkP+QEpX9EQ==" }, "supports-color": { "version": "9.2.2", @@ -39038,8 +39030,7 @@ "ws": { "version": "8.5.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.5.0.tgz", - "integrity": "sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==", - "requires": {} + "integrity": "sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==" } } }, @@ -39147,8 +39138,7 @@ "use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", - "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", - "requires": {} + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==" }, "util-deprecate": { "version": "1.0.2", @@ -39553,8 +39543,7 @@ "version": "7.5.7", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.7.tgz", "integrity": "sha512-KMvVuFzpKBuiIXW3E4u3mySRO2/mCHSyZDJQM5NQ9Q9KHWHWh0NHgfbRMLLrceUK5qAL4ytALJbpRMjixFZh8A==", - "dev": true, - "requires": {} + "dev": true }, "xdg-basedir": { "version": "4.0.0", diff --git a/plugin/package.json b/plugin/package.json index e8b25fedcf..77cdfea2e4 100644 --- a/plugin/package.json +++ b/plugin/package.json @@ -6,8 +6,20 @@ "files": [ "lib/**/*", "src/templates/edge/*", - "manifest.yml" + "manifest.yml", + "middleware.js" ], + "typesVersions": { + "*": { + "middleware": [ + "dist-types/middleware" + ] + } + }, + "exports": { + ".": "./lib/index.js", + "./middleware": "./lib/middleware/index.js" + }, "dependencies": { "@netlify/functions": "^1.0.0", "@netlify/ipx": "^1.1.3", @@ -41,7 +53,7 @@ "publish:pull": "git pull", "publish:install": "npm ci", "publish:test": "cd .. && npm ci && npm test", - "clean": "rimraf lib", + "clean": "rimraf lib dist-types", "build": "tsc", "watch": "tsc --watch", "prepare": "npm run build" @@ -62,4 +74,4 @@ "engines": { "node": ">=12.0.0" } -} +} \ No newline at end of file diff --git a/plugin/src/middleware/html-rewriter.ts b/plugin/src/middleware/html-rewriter.ts new file mode 100644 index 0000000000..c89115d02e --- /dev/null +++ b/plugin/src/middleware/html-rewriter.ts @@ -0,0 +1,91 @@ +/* eslint-disable max-classes-per-file */ + +// These types are inlined from the HTMLRewriter package, because we don't use the actual package here +// https://github.com/cloudflare/html-rewriter-wasm/blob/master/src/html_rewriter.d.ts +// This is Node code, so we can't import the Deno types from the URL. +export interface ContentTypeOptions { + html?: boolean +} + +export declare class Element { + before(content: string, options?: ContentTypeOptions): this + after(content: string, options?: ContentTypeOptions): this + replace(content: string, options?: ContentTypeOptions): this + remove(): this + getAttribute(name: string): string | null + hasAttribute(name: string): boolean + setAttribute(name: string, value: string): this + removeAttribute(name: string): this + prepend(content: string, options?: ContentTypeOptions): this + append(content: string, options?: ContentTypeOptions): this + setInnerContent(content: string, options?: ContentTypeOptions): this + removeAndKeepContent(): this + readonly attributes: IterableIterator<[string, string]> + readonly namespaceURI: string + readonly removed: boolean + tagName: string + onEndTag(handler: (this: this, endTag: EndTag) => void | Promise): void +} + +export declare class EndTag { + before(content: string, options?: ContentTypeOptions): this + after(content: string, options?: ContentTypeOptions): this + remove(): this + name: string +} + +export declare class Comment { + before(content: string, options?: ContentTypeOptions): this + after(content: string, options?: ContentTypeOptions): this + replace(content: string, options?: ContentTypeOptions): this + remove(): this + readonly removed: boolean + text: string +} + +export declare class TextChunk { + before(content: string, options?: ContentTypeOptions): this + after(content: string, options?: ContentTypeOptions): this + replace(content: string, options?: ContentTypeOptions): this + remove(): this + readonly lastInTextNode: boolean + readonly removed: boolean + readonly text: string +} + +export declare class Doctype { + readonly name: string | null + readonly publicId: string | null + readonly systemId: string | null +} + +export declare class DocumentEnd { + append(content: string, options?: ContentTypeOptions): this +} + +export interface ElementHandlers { + element?(element: Element): void | Promise + comments?(comment: Comment): void | Promise + text?(text: TextChunk): void | Promise +} + +export interface DocumentHandlers { + doctype?(doctype: Doctype): void | Promise + comments?(comment: Comment): void | Promise + text?(text: TextChunk): void | Promise + end?(end: DocumentEnd): void | Promise +} + +export interface HTMLRewriterOptions { + enableEsiTags?: boolean +} + +export declare class HTMLRewriter { + constructor(outputSink: (chunk: Uint8Array) => void, options?: HTMLRewriterOptions) + on(selector: string, handlers: ElementHandlers): this + onDocument(handlers: DocumentHandlers): this + write(chunk: Uint8Array): Promise + end(): Promise + free(): void +} +/* eslint-enable max-classes-per-file */ diff --git a/plugin/src/middleware/index.ts b/plugin/src/middleware/index.ts new file mode 100644 index 0000000000..ca57e04a4d --- /dev/null +++ b/plugin/src/middleware/index.ts @@ -0,0 +1,3 @@ +export * from './response' +export * from './request' +export * from './html-rewriter' diff --git a/plugin/src/middleware/request.ts b/plugin/src/middleware/request.ts new file mode 100644 index 0000000000..360eb452c2 --- /dev/null +++ b/plugin/src/middleware/request.ts @@ -0,0 +1,80 @@ +import { NextURL } from 'next/dist/server/web/next-url' +import { NextResponse } from 'next/server' +import type { NextRequest } from 'next/server' + +import { MiddlewareResponse } from './response' + +// TODO: add Context type +type Context = { + next: () => Promise +} + +/** + * Supercharge your Next middleware with Netlify Edge Functions + */ +export class MiddlewareRequest extends Request { + context: Context + originalRequest: Request + + constructor(private nextRequest: NextRequest) { + super(nextRequest) + if (!('Deno' in globalThis)) { + throw new Error('MiddlewareRequest only works in a Netlify Edge Function environment') + } + const requestId = nextRequest.headers.get('x-nf-request-id') + if (!requestId) { + throw new Error('Missing x-nf-request-id header') + } + const requestContext = globalThis.NFRequestContextMap.get(requestId) + if (!requestContext) { + throw new Error(`Could not find request context for request id ${requestId}`) + } + this.context = requestContext.context + this.originalRequest = requestContext.request + } + + // Add the headers to the original request, which will be passed to the origin + private applyHeaders() { + this.headers.forEach((value, name) => { + this.originalRequest.headers.set(name, value) + }) + } + + async next(): Promise { + this.applyHeaders() + const response = await this.context.next() + return new MiddlewareResponse(response) + } + + rewrite(destination: string | URL | NextURL, init?: ResponseInit): NextResponse { + if (typeof destination === 'string' && destination.startsWith('/')) { + destination = new URL(destination, this.url) + } + this.applyHeaders() + return NextResponse.rewrite(destination, init) + } + + get headers() { + return this.nextRequest.headers + } + + get cookies() { + return this.nextRequest.cookies + } + + get geo() { + return this.nextRequest.geo + } + + get ip() { + return this.nextRequest.ip + } + + get nextUrl() { + return this.nextRequest.url + } + + get url() { + return this.nextRequest.url.toString() + } +} diff --git a/plugin/src/middleware/response.ts b/plugin/src/middleware/response.ts new file mode 100644 index 0000000000..ad01e75609 --- /dev/null +++ b/plugin/src/middleware/response.ts @@ -0,0 +1,95 @@ +import { NextResponse } from 'next/server' + +import type { ElementHandlers } from './html-rewriter' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type NextDataTransform = }>(props: T) => T + +// A NextResponse that wraps the Netlify origin response +// We can't pass it through directly, because Next disallows returning a response body +export class MiddlewareResponse extends NextResponse { + private readonly dataTransforms: NextDataTransform[] + private readonly elementHandlers: Array<[selector: string, handlers: ElementHandlers]> + constructor(public originResponse: Response) { + super() + + // These are private in Node when compiling, but we access them in Deno at runtime + Object.defineProperty(this, 'dataTransforms', { + value: [], + enumerable: false, + writable: false, + }) + Object.defineProperty(this, 'elementHandlers', { + value: [], + enumerable: false, + writable: false, + }) + } + + /** + * Transform the page props before they are passed to the client. + * This works for both HTML pages and JSON data + */ + transformData(transform: NextDataTransform) { + // The transforms are evaluated after the middleware is returned + this.dataTransforms.push(transform) + } + + /** + * Rewrite the response HTML with the given selector and handlers + */ + rewriteHTML(selector: string, handlers: ElementHandlers) { + this.elementHandlers.push([selector, handlers]) + } + + /** + * Sets the value of a page prop. + * @see transformData if you need more control + */ + setPageProp(key: string, value: unknown) { + this.transformData((props) => { + props.pageProps ||= {} + props.pageProps[key] = value + return props + }) + } + + /** + * Replace the text of the given element. Takes either a string or a function + * that is passed the original string and returns new new string. + * @see rewriteHTML for more control + */ + replaceText(selector: string, valueOrReplacer: string | ((input: string) => string)): void { + // If it's a string then our job is simpler, because we don't need to collect the current text + if (typeof valueOrReplacer === 'string') { + this.rewriteHTML(selector, { + text(textChunk) { + if (textChunk.lastInTextNode) { + textChunk.replace(valueOrReplacer) + } else { + textChunk.remove() + } + }, + }) + } else { + let text = '' + this.rewriteHTML(selector, { + text(textChunk) { + text += textChunk.text + // We're finished, so we can replace the text + if (textChunk.lastInTextNode) { + textChunk.replace(valueOrReplacer(text)) + } else { + // Remove the chunk, because we'll be adding it back later + textChunk.remove() + } + }, + }) + } + } + + get headers(): Headers { + // If we have the origin response, we should use its headers + return this.originResponse?.headers || super.headers + } +} diff --git a/plugin/src/templates/edge/bundle.js b/plugin/src/templates/edge/bundle.js index 84846886fa..0765289028 100644 --- a/plugin/src/templates/edge/bundle.js +++ b/plugin/src/templates/edge/bundle.js @@ -1,7 +1,7 @@ /** * This placeholder is replaced with the compiled Next.js bundle at build time - * @args {Object} - * @args.request {import("./runtime.ts").RequestData} + * @param {Object} props + * @param {import("./runtime.ts").RequestData} props.request * @returns {Promise} */ -export default async (props) => {} +export default async ({ request }) => {} diff --git a/plugin/src/templates/edge/ipx.ts b/plugin/src/templates/edge/ipx.ts index 1298c7ac01..f46818495b 100644 --- a/plugin/src/templates/edge/ipx.ts +++ b/plugin/src/templates/edge/ipx.ts @@ -1,14 +1,12 @@ -import { Accepts } from "https://deno.land/x/accepts@2.1.1/mod.ts"; -import type { Context } from "netlify:edge"; +import { Accepts } from 'https://deno.land/x/accepts@2.1.1/mod.ts' +import type { Context } from 'netlify:edge' // Available at build time -import imageconfig from "./imageconfig.json" assert { - type: "json", -}; +import imageconfig from './imageconfig.json' assert { type: 'json' } -const defaultFormat = "webp" +const defaultFormat = 'webp' interface ImageConfig extends Record { - formats?: string[]; + formats?: string[] } /** @@ -17,41 +15,38 @@ interface ImageConfig extends Record { // deno-lint-ignore require-await const handler = async (req: Request, context: Context) => { - const { searchParams } = new URL(req.url); - const accept = new Accepts(req.headers); - const { formats = [defaultFormat] } = imageconfig as ImageConfig; + const { searchParams } = new URL(req.url) + const accept = new Accepts(req.headers) + const { formats = [defaultFormat] } = imageconfig as ImageConfig if (formats.length === 0) { - formats.push(defaultFormat); + formats.push(defaultFormat) } - let type = accept.types(formats) || defaultFormat; - if(Array.isArray(type)) { - type = type[0]; + let type = accept.types(formats) || defaultFormat + if (Array.isArray(type)) { + type = type[0] } - - const source = searchParams.get("url"); - const width = searchParams.get("w"); - const quality = searchParams.get("q") ?? 75; + const source = searchParams.get('url') + const width = searchParams.get('w') + const quality = searchParams.get('q') ?? 75 if (!source || !width) { - return new Response("Invalid request", { + return new Response('Invalid request', { status: 400, - }); + }) } - const modifiers = [`w_${width}`, `q_${quality}`]; + const modifiers = [`w_${width}`, `q_${quality}`] if (type) { - if(type.includes('/')) { + if (type.includes('/')) { // If this is a mimetype, strip "image/" - type = type.split('/')[1]; + type = type.split('/')[1] } - modifiers.push(`f_${type}`); + modifiers.push(`f_${type}`) } - const target = `/_ipx/${modifiers.join(",")}/${encodeURIComponent(source)}`; - return context.rewrite( - target, - ); -}; + const target = `/_ipx/${modifiers.join(',')}/${encodeURIComponent(source)}` + return context.rewrite(target) +} -export default handler; +export default handler diff --git a/plugin/src/templates/edge/runtime.ts b/plugin/src/templates/edge/runtime.ts index def301d78a..6fbb80af89 100644 --- a/plugin/src/templates/edge/runtime.ts +++ b/plugin/src/templates/edge/runtime.ts @@ -32,19 +32,43 @@ export interface RequestData { body?: ReadableStream } +export interface RequestContext { + request: Request + context: Context +} + +declare global { + // deno-lint-ignore no-var + var NFRequestContextMap: Map +} + +globalThis.NFRequestContextMap ||= new Map() + const handler = async (req: Request, context: Context) => { const url = new URL(req.url) if (url.pathname.startsWith('/_next/static/')) { return } + const geo = { + country: context.geo.country?.code, + region: context.geo.subdivision?.code, + city: context.geo.city, + } + + const requestId = req.headers.get('x-nf-request-id') + if (!requestId) { + console.error('Missing x-nf-request-id header') + } else { + globalThis.NFRequestContextMap.set(requestId, { + request: req, + context, + }) + } + const request: RequestData = { headers: Object.fromEntries(req.headers.entries()), - geo: { - country: context.geo.country?.code, - region: context.geo.subdivision?.code, - city: context.geo.city, - }, + geo, url: url.toString(), method: req.method, ip: context.ip, @@ -57,6 +81,10 @@ const handler = async (req: Request, context: Context) => { } catch (error) { console.error(error) return new Response(error.message, { status: 500 }) + } finally { + if (requestId) { + globalThis.NFRequestContextMap.delete(requestId) + } } } diff --git a/plugin/src/templates/edge/utils.ts b/plugin/src/templates/edge/utils.ts index 57230273f5..ffe251e55a 100644 --- a/plugin/src/templates/edge/utils.ts +++ b/plugin/src/templates/edge/utils.ts @@ -1,14 +1,17 @@ import type { Context } from 'netlify:edge' +import { ElementHandlers, HTMLRewriter } from 'https://deno.land/x/html_rewriter@v0.1.0-pre.17/index.ts' export interface FetchEventResult { response: Response waitUntil: Promise } +type NextDataTransform = (data: T) => T + /** * This is how Next handles rewritten URLs. */ - export function relativizeURL(url: string | string, base: string | URL) { +export function relativizeURL(url: string | string, base: string | URL) { const baseURL = typeof base === 'string' ? new URL(base) : base const relative = new URL(url, base) const origin = `${baseURL.protocol}//${baseURL.host}` @@ -17,7 +20,6 @@ export interface FetchEventResult { : relative.toString() } - export const addMiddlewareHeaders = async ( originResponse: Promise | Response, middlewareResponse: Response, @@ -34,6 +36,29 @@ export const addMiddlewareHeaders = async ( }) return response } + +interface MiddlewareResponse extends Response { + originResponse: Response + dataTransforms: NextDataTransform[] + elementHandlers: Array<[selector: string, handlers: ElementHandlers]> +} + +interface MiddlewareRequest { + request: Request + context: Context + originalRequest: Request + next(): Promise + rewrite(destination: string | URL, init?: ResponseInit): Response +} + +function isMiddlewareRequest(response: Response | MiddlewareRequest): response is MiddlewareRequest { + return 'originalRequest' in response +} + +function isMiddlewareResponse(response: Response | MiddlewareResponse): response is MiddlewareResponse { + return 'dataTransforms' in response +} + export const buildResponse = async ({ result, request, @@ -43,13 +68,65 @@ export const buildResponse = async ({ request: Request context: Context }) => { + // They've returned the MiddlewareRequest directly, so we'll call `next()` for them. + if (isMiddlewareRequest(result.response)) { + result.response = await result.response.next() + } + if (isMiddlewareResponse(result.response)) { + const { response } = result + if (request.method === 'HEAD' || request.method === 'OPTIONS') { + return response.originResponse + } + // If it's JSON we don't need to use the rewriter, we can just parse it + if (response.originResponse.headers.get('content-type')?.includes('application/json')) { + const props = await response.originResponse.json() + const transformed = response.dataTransforms.reduce((prev, transform) => { + return transform(prev) + }, props) + return context.json(transformed) + } + // This var will hold the contents of the script tag + let buffer = '' + // Create an HTMLRewriter that matches the Next data script tag + const rewriter = new HTMLRewriter() + + if (response.dataTransforms.length > 0) { + rewriter.on('script[id="__NEXT_DATA__"]', { + text(textChunk) { + // Grab all the chunks in the Next data script tag + buffer += textChunk.text + if (textChunk.lastInTextNode) { + try { + // When we have all the data, try to parse it as JSON + const data = JSON.parse(buffer.trim()) + // Apply all of the transforms to the props + const props = response.dataTransforms.reduce((prev, transform) => transform(prev), data.props) + // Replace the data with the transformed props + textChunk.replace(JSON.stringify({ ...data, props })) + } catch (err) { + console.log('Could not parse', err) + } + } else { + // Remove the chunk after we've appended it to the buffer + textChunk.remove() + } + }, + }) + } + + if (response.elementHandlers.length > 0) { + response.elementHandlers.forEach(([selector, handlers]) => rewriter.on(selector, handlers)) + } + return rewriter.transform(response.originResponse) + } const res = new Response(result.response.body, result.response) request.headers.set('x-nf-next-middleware', 'skip') + const rewrite = res.headers.get('x-middleware-rewrite') if (rewrite) { const rewriteUrl = new URL(rewrite, request.url) const baseUrl = new URL(request.url) - if(rewriteUrl.hostname !== baseUrl.hostname) { + if (rewriteUrl.hostname !== baseUrl.hostname) { // Netlify Edge Functions don't support proxying to external domains, but Next middleware does const proxied = fetch(new Request(rewriteUrl.toString(), request)) return addMiddlewareHeaders(proxied, res) diff --git a/plugin/tsconfig.json b/plugin/tsconfig.json index c1d39c8722..56fb7abc7e 100644 --- a/plugin/tsconfig.json +++ b/plugin/tsconfig.json @@ -2,6 +2,9 @@ "extends": "../tsconfig.json", "compilerOptions": { "outDir": "./lib" /* Redirect output structure to the directory. */, + "declaration": true /* Generates corresponding '.d.ts' file. */, + "declarationDir": "./dist-types", + "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ }, "include": [ "src/**/*.ts", diff --git a/tsconfig.json b/tsconfig.json index 6c77f481d9..a68cb8ff2b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,10 @@ // "incremental": true, /* Enable incremental compilation */ "target": "ES2019" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, - "lib": ["ES2020"] /* Specify library files to be included in the compilation. */, + "lib": [ + "ES2020", + "DOM" + ] /* Specify library files to be included in the compilation. */, "allowJs": true /* Allow javascript files to be compiled. */, // "checkJs": true, /* Report errors in .js files. */ // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ @@ -43,7 +46,10 @@ // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ // "typeRoots": [], /* List of folders to include type definitions from. */ - "types": ["jest", "node"], /* Type declaration files to be included in compilation. */ + "types": [ + "jest", + "node", + ], /* Type declaration files to be included in compilation. */ // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ @@ -59,5 +65,13 @@ /* Advanced Options */ "skipLibCheck": true /* Skip type checking of declaration files. */, "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ - } + }, + // The following hack is to prevent TS using the chai types instead of jest types. + // Source: https://github.com/cypress-io/cypress/issues/1087#issuecomment-552951441 + "include": [ + "node_modules/cypress", + ], + "exclude": [ + "node_modules/cypress" + ] } \ No newline at end of file