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