diff --git a/README.md b/README.md index 6677e945ad..f0ed64bc7f 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,12 @@ aware that this will result in slower performance, as all pages that match middl For more details on Next.js Middleware with Netlify, see the [middleware docs](https://github.com/netlify/next-runtime/blob/main/docs/middleware.md). +### Limitations + +Due to how the site configuration is handled when it's run using Netlify Edge Functions, data such as `locale` and `defaultLocale` will be missing on the `req.nextUrl` object when running `netlify dev`. + +However, this data is available on `req.nextUrl` in a production environment. + ## Monorepos If you are using a monorepo you will need to change `publish` to point to the full path to the built `.next` directory, diff --git a/jestSetup.js b/jestSetup.js new file mode 100644 index 0000000000..c9a73e7cfb --- /dev/null +++ b/jestSetup.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line n/no-unpublished-require +require('jest-fetch-mock').enableMocks() diff --git a/package-lock.json b/package-lock.json index 9c924d96ba..d7792e4f84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "eslint-plugin-unicorn": "^43.0.2", "husky": "^7.0.4", "jest": "^27.0.0", + "jest-fetch-mock": "^3.0.3", "netlify-plugin-cypress": "^2.2.0", "npm-run-all": "^4.1.5", "prettier": "^2.1.2", @@ -3979,9 +3980,9 @@ } }, "node_modules/@netlify/edge-bundler/node_modules/node-fetch": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.2.3.tgz", - "integrity": "sha512-AXP18u4pidSZ1xYXRDPY/8jdv3RAozIt/WLNR/MBGZAz+xjtlr90RvCnsvHQRiXyWliZF/CpytExp32UU67/SA==", + "version": "3.2.10", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.2.10.tgz", + "integrity": "sha512-MhuzNwdURnZ1Cp4XTazr69K0BTizsBroX7Zx3UgDSVcZYKF/6p0CBe4EUb/hLqmzVhl0UpYfgRljQ4yxE+iCxA==", "dev": true, "dependencies": { "data-uri-to-buffer": "^4.0.0", @@ -9035,6 +9036,15 @@ "yarn": ">=1" } }, + "node_modules/cross-fetch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", + "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", + "dev": true, + "dependencies": { + "node-fetch": "2.6.7" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -11963,9 +11973,9 @@ } }, "node_modules/fetch-blob": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.1.5.tgz", - "integrity": "sha512-N64ZpKqoLejlrwkIAnb9iLSA3Vx/kjgzpcDhygcqJ2KKjky8nCgUQ+dzXtbrLaWZGZNmNfQTsiQ0weZ1svglHg==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", "dev": true, "funding": [ { @@ -14461,6 +14471,16 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/jest-fetch-mock": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz", + "integrity": "sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==", + "dev": true, + "dependencies": { + "cross-fetch": "^3.0.4", + "promise-polyfill": "^8.1.3" + } + }, "node_modules/jest-get-type": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", @@ -17378,17 +17398,17 @@ "node_modules/node-fetch/node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, "node_modules/node-fetch/node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "node_modules/node-fetch/node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" @@ -19087,6 +19107,12 @@ "node": ">=0.4.0" } }, + "node_modules/promise-polyfill": { + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.2.3.tgz", + "integrity": "sha512-Og0+jCRQetV84U8wVjMNccfGCnMQ9mGs9Hv78QFe+pSDD3gWTpz0y+1QCuxy5d/vBFuZ3iwP2eycAkvqIMPmWg==", + "dev": true + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -25936,9 +25962,9 @@ }, "dependencies": { "node-fetch": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.2.3.tgz", - "integrity": "sha512-AXP18u4pidSZ1xYXRDPY/8jdv3RAozIt/WLNR/MBGZAz+xjtlr90RvCnsvHQRiXyWliZF/CpytExp32UU67/SA==", + "version": "3.2.10", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.2.10.tgz", + "integrity": "sha512-MhuzNwdURnZ1Cp4XTazr69K0BTizsBroX7Zx3UgDSVcZYKF/6p0CBe4EUb/hLqmzVhl0UpYfgRljQ4yxE+iCxA==", "dev": true, "requires": { "data-uri-to-buffer": "^4.0.0", @@ -29699,6 +29725,15 @@ "cross-spawn": "^7.0.1" } }, + "cross-fetch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", + "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", + "dev": true, + "requires": { + "node-fetch": "2.6.7" + } + }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -32002,9 +32037,9 @@ } }, "fetch-blob": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.1.5.tgz", - "integrity": "sha512-N64ZpKqoLejlrwkIAnb9iLSA3Vx/kjgzpcDhygcqJ2KKjky8nCgUQ+dzXtbrLaWZGZNmNfQTsiQ0weZ1svglHg==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", "dev": true, "requires": { "node-domexception": "^1.0.0", @@ -33812,6 +33847,16 @@ "jest-util": "^27.5.1" } }, + "jest-fetch-mock": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz", + "integrity": "sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==", + "dev": true, + "requires": { + "cross-fetch": "^3.0.4", + "promise-polyfill": "^8.1.3" + } + }, "jest-get-type": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", @@ -36160,17 +36205,17 @@ "tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, "webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", "requires": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" @@ -37413,6 +37458,12 @@ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", "dev": true }, + "promise-polyfill": { + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.2.3.tgz", + "integrity": "sha512-Og0+jCRQetV84U8wVjMNccfGCnMQ9mGs9Hv78QFe+pSDD3gWTpz0y+1QCuxy5d/vBFuZ3iwP2eycAkvqIMPmWg==", + "dev": true + }, "prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", diff --git a/package.json b/package.json index 1ee92eaab7..bac5e91189 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "eslint-plugin-unicorn": "^43.0.2", "husky": "^7.0.4", "jest": "^27.0.0", + "jest-fetch-mock": "^3.0.3", "netlify-plugin-cypress": "^2.2.0", "npm-run-all": "^4.1.5", "prettier": "^2.1.2", @@ -81,6 +82,9 @@ "node": ">=16.0.0" }, "jest": { + "setupFiles": [ + "./jestSetup.js" + ], "testMatch": [ "**/test/**/*.js", "**/test/**/*.ts", @@ -106,4 +110,4 @@ "demos/custom-routes", "demos/next-with-edge-functions" ] -} \ No newline at end of file +} diff --git a/packages/next/src/middleware/request.ts b/packages/next/src/middleware/request.ts index c323d607cd..f04554ad7d 100644 --- a/packages/next/src/middleware/request.ts +++ b/packages/next/src/middleware/request.ts @@ -83,7 +83,7 @@ export class MiddlewareRequest extends Request { } get nextUrl() { - return this.nextRequest.url + return this.nextRequest.nextUrl } get url() { diff --git a/packages/next/test/request.spec.ts b/packages/next/test/request.spec.ts new file mode 100644 index 0000000000..da43029d7f --- /dev/null +++ b/packages/next/test/request.spec.ts @@ -0,0 +1,118 @@ +import Chance from 'chance' +import { NextURL } from 'next/dist/server/web/next-url' +import { NextCookies } from 'next/dist/server/web/spec-extension/cookies' +import { NextRequest } from 'next/server' +import { MiddlewareRequest } from '../src/middleware/request' + +const chance = new Chance() + +describe('MiddlewareRequest', () => { + let nextRequest, mockHeaders, mockHeaderValue, requestId, geo, ip, url + + beforeEach(() => { + globalThis.Deno = {} + globalThis.NFRequestContextMap = new Map() + + ip = chance.ip() + url = chance.url() + + const context = { + geo: { + country: { + code: chance.country(), + }, + subdivision: { + code: chance.province(), + }, + city: chance.city(), + }, + ip, + } + + geo = { + country: context.geo.country?.code, + region: context.geo.subdivision?.code, + city: context.geo.city, + } + + const req = new URL(url) + + requestId = chance.guid() + globalThis.NFRequestContextMap.set(requestId, { + request: req, + context, + }) + + mockHeaders = new Headers() + mockHeaderValue = chance.word() + + mockHeaders.append('foo', mockHeaderValue) + mockHeaders.append('x-nf-request-id', requestId) + + const request = { + headers: mockHeaders, + geo, + method: 'GET', + ip: context.ip, + body: null, + } + + nextRequest = new NextRequest(req, request) + }) + + afterEach(() => { + nextRequest = null + requestId = null + delete globalThis.Deno + delete globalThis.NFRequestContextMap + }) + + it('throws an error when MiddlewareRequest is run outside of edge environment', () => { + delete globalThis.Deno + expect(() => new MiddlewareRequest(nextRequest)).toThrowError( + 'MiddlewareRequest only works in a Netlify Edge Function environment', + ) + }) + + it('throws an error when x-nf-request-id header is missing', () => { + nextRequest.headers.delete('x-nf-request-id') + expect(() => new MiddlewareRequest(nextRequest)).toThrowError('Missing x-nf-request-id header') + }) + + it('throws an error when request context is missing', () => { + globalThis.NFRequestContextMap.delete(requestId) + expect(() => new MiddlewareRequest(nextRequest)).toThrowError( + `Could not find request context for request id ${requestId}`, + ) + }) + + it('returns the headers object', () => { + const mwRequest = new MiddlewareRequest(nextRequest) + expect(mwRequest.headers).toStrictEqual(mockHeaders) + }) + + it('returns the cookies object', () => { + const mwRequest = new MiddlewareRequest(nextRequest) + expect(mwRequest.cookies).toBeInstanceOf(NextCookies) + }) + + it('returns the geo object', () => { + const mwRequest = new MiddlewareRequest(nextRequest) + expect(mwRequest.geo).toStrictEqual(geo) + }) + + it('returns the ip object', () => { + const mwRequest = new MiddlewareRequest(nextRequest) + expect(mwRequest.ip).toStrictEqual(ip) + }) + + it('returns the nextUrl object', () => { + const mwRequest = new MiddlewareRequest(nextRequest) + expect(mwRequest.nextUrl).toBeInstanceOf(NextURL) + }) + + it('returns the url', () => { + const mwRequest = new MiddlewareRequest(nextRequest) + expect(mwRequest.url).toEqual(url) + }) +}) diff --git a/packages/runtime/src/helpers/edge.ts b/packages/runtime/src/helpers/edge.ts index 946045c6e9..cafe4a3d6f 100644 --- a/packages/runtime/src/helpers/edge.ts +++ b/packages/runtime/src/helpers/edge.ts @@ -7,6 +7,8 @@ import { copy, copyFile, emptyDir, ensureDir, readJSON, readJson, writeJSON, wri import type { MiddlewareManifest } from 'next/dist/build/webpack/plugins/middleware-plugin' import type { RouteHas } from 'next/dist/lib/load-custom-routes' +import { getRequiredServerFiles } from './config' + // This is the format as of next@12.2 interface EdgeFunctionDefinitionV1 { env: string[] @@ -198,6 +200,11 @@ export const writeEdgeFunctions = async (netlifyConfig: NetlifyConfig) => { await copy(getEdgeTemplatePath('../edge-shared'), join(edgeFunctionRoot, 'edge-shared')) + const { publish } = netlifyConfig.build + const nextConfigFile = await getRequiredServerFiles(publish) + const nextConfig = nextConfigFile.config + await writeJSON(join(edgeFunctionRoot, 'edge-shared', 'nextConfig.json'), nextConfig) + if (!process.env.NEXT_DISABLE_EDGE_IMAGES) { console.log( 'Using Netlify Edge Functions for image format detection. Set env var "NEXT_DISABLE_EDGE_IMAGES=true" to disable.', diff --git a/packages/runtime/src/templates/edge-shared/nextConfig.json b/packages/runtime/src/templates/edge-shared/nextConfig.json new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/runtime/src/templates/edge/runtime.ts b/packages/runtime/src/templates/edge/runtime.ts index da7c5e3278..e5b1870184 100644 --- a/packages/runtime/src/templates/edge/runtime.ts +++ b/packages/runtime/src/templates/edge/runtime.ts @@ -1,6 +1,7 @@ import type { Context } from 'https://edge.netlify.com' // Available at build time import matchers from './matchers.json' assert { type: 'json' } +import nextConfig from '../edge-shared/nextConfig.json' assert { type: 'json' } import edgeFunction from './bundle.js' import { buildResponse } from '../edge-shared/utils.ts' import { getMiddlewareRouteMatcher, MiddlewareRouteMatch, searchParamsToUrlQuery } from '../edge-shared/next-utils.ts' @@ -32,7 +33,7 @@ export interface RequestData { name?: string params?: { [key: string]: string } } - url: string + url: URL body?: ReadableStream } @@ -81,10 +82,11 @@ const handler = async (req: Request, context: Context) => { const request: RequestData = { headers: Object.fromEntries(req.headers.entries()), geo, - url: url.toString(), + url, method: req.method, ip: context.ip, body: req.body ?? undefined, + nextConfig, } try {